2026-03-28 22:30:24 +01:00
|
|
|
|
"""GWÖ-Antragsprüfer — FastAPI Webapp."""
|
|
|
|
|
|
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
import logging
|
2026-03-28 22:30:24 +01:00
|
|
|
|
import uuid
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, File, Form, UploadFile, Request, BackgroundTasks, HTTPException
|
|
|
|
|
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, Response
|
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
|
from fastapi.templating import Jinja2Templates
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
|
|
|
|
from slowapi.util import get_remote_address
|
|
|
|
|
|
from slowapi.errors import RateLimitExceeded
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
from .validators import (
|
|
|
|
|
|
MAX_SEARCH_QUERY_LEN,
|
|
|
|
|
|
validate_drucksache,
|
|
|
|
|
|
validate_search_query,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Strukturiertes Logging für die ganze App. uvicorn registriert seinen
|
|
|
|
|
|
# eigenen Root-Handler erst beim Start; wir setzen ein neutrales Format
|
|
|
|
|
|
# für unsere Module früh, damit logger.exception() auch beim Boot greift.
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
|
|
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
from .config import settings
|
|
|
|
|
|
from .database import (
|
|
|
|
|
|
init_db, get_job, create_job, update_job,
|
|
|
|
|
|
get_all_assessments, get_assessment, upsert_assessment, import_json_assessments,
|
|
|
|
|
|
search_assessments
|
|
|
|
|
|
)
|
|
|
|
|
|
from .parlamente import get_adapter, ADAPTERS
|
2026-04-07 14:17:54 +02:00
|
|
|
|
from .bundeslaender import alle_bundeslaender
|
2026-03-28 22:30:24 +01:00
|
|
|
|
from .analyzer import analyze_antrag
|
|
|
|
|
|
from .report import generate_html_report, generate_pdf_report
|
|
|
|
|
|
from .embeddings import (
|
|
|
|
|
|
init_embeddings_db, get_programme_info, get_indexing_status,
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
index_programm, render_highlighted_page, PROGRAMME,
|
2026-03-28 22:30:24 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title=settings.app_name,
|
|
|
|
|
|
version=settings.app_version,
|
|
|
|
|
|
docs_url=None, # Disable /docs in production
|
|
|
|
|
|
redoc_url=None, # Disable /redoc in production
|
|
|
|
|
|
openapi_url=None, # Disable /openapi.json in production
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
# Rate-Limiter — fängt Resource-Exhaustion auf den teuren POST-Endpoints
|
|
|
|
|
|
# (LLM-Calls + Indexing). Issue #57 Befund #1 (HIGH). Default in-memory
|
|
|
|
|
|
# Storage; für mehrere Worker müsste man auf Redis umstellen, solange wir
|
|
|
|
|
|
# auf einem Container laufen reicht das Default-Storage.
|
|
|
|
|
|
limiter = Limiter(key_func=get_remote_address, default_limits=[])
|
|
|
|
|
|
app.state.limiter = limiter
|
|
|
|
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# Security Headers Middleware
|
|
|
|
|
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
|
|
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
|
|
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
|
|
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
|
|
|
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
|
|
|
|
|
# CSP: Allow self, inline styles (for templates), and PDF viewer
|
|
|
|
|
|
response.headers["Content-Security-Policy"] = (
|
|
|
|
|
|
"default-src 'self'; "
|
|
|
|
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
|
|
|
|
"script-src 'self' 'unsafe-inline'; "
|
|
|
|
|
|
"img-src 'self' data:; "
|
|
|
|
|
|
"frame-ancestors 'none';"
|
|
|
|
|
|
)
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Setup directories
|
|
|
|
|
|
settings.data_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
settings.reports_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Static files and templates
|
|
|
|
|
|
static_dir = Path(__file__).parent / "static"
|
|
|
|
|
|
templates_dir = Path(__file__).parent / "templates"
|
|
|
|
|
|
static_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
templates_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
|
|
|
|
templates = Jinja2Templates(directory=str(templates_dir))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.on_event("startup")
|
|
|
|
|
|
async def startup():
|
|
|
|
|
|
await init_db()
|
|
|
|
|
|
init_embeddings_db()
|
|
|
|
|
|
# JSON import disabled - all assessments now live in SQLite DB only
|
|
|
|
|
|
# Legacy import would overwrite new v5 assessments with old format
|
|
|
|
|
|
# count = await import_json_assessments(settings.data_dir / "assessments")
|
|
|
|
|
|
# if count > 0:
|
|
|
|
|
|
# print(f"Imported {count} assessments from JSON files")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
|
async def index(request: Request):
|
|
|
|
|
|
"""Landing page with upload form."""
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
# Frontend-Liste: synthetischer "ALL"-Eintrag (Bundesweit) zuerst, dann
|
|
|
|
|
|
# die echten Bundesländer aus der Konfig. Der "ALL"-Code ist eine reine
|
|
|
|
|
|
# Frontend/API-Konvention, kein Eintrag in bundeslaender.py.
|
|
|
|
|
|
bl_list = [{"code": "ALL", "name": "🌍 Bundesweit", "active": True}]
|
|
|
|
|
|
bl_list.extend(
|
|
|
|
|
|
{"code": bl.code, "name": bl.name, "active": bl.aktiv}
|
|
|
|
|
|
for bl in alle_bundeslaender()
|
|
|
|
|
|
)
|
|
|
|
|
|
# Map code → parlament_name, damit das Frontend ohne extra Backend-Call
|
|
|
|
|
|
# für jeden Antrag den Parlamentsnamen anzeigen kann.
|
|
|
|
|
|
parlament_names = {
|
|
|
|
|
|
bl.code: bl.parlament_name for bl in alle_bundeslaender()
|
|
|
|
|
|
}
|
2026-03-28 22:30:24 +01:00
|
|
|
|
return templates.TemplateResponse("index.html", {
|
|
|
|
|
|
"request": request,
|
|
|
|
|
|
"app_name": settings.app_name,
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
"bundeslaender": bl_list,
|
|
|
|
|
|
"parlament_names": parlament_names,
|
2026-03-28 22:30:24 +01:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/analyze")
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
@limiter.limit("10/minute")
|
2026-03-28 22:30:24 +01:00
|
|
|
|
async def start_analysis(
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
request: Request,
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
|
text: Optional[str] = Form(None),
|
|
|
|
|
|
file: Optional[UploadFile] = File(None),
|
|
|
|
|
|
bundesland: str = Form("NRW"),
|
|
|
|
|
|
model: str = Form("qwen-plus"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Start analysis job."""
|
|
|
|
|
|
if not text and not file:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Entweder Text oder PDF-Datei erforderlich")
|
|
|
|
|
|
|
|
|
|
|
|
# Extract text from PDF if uploaded
|
|
|
|
|
|
if file and file.filename:
|
|
|
|
|
|
import fitz # PyMuPDF
|
|
|
|
|
|
pdf_bytes = await file.read()
|
|
|
|
|
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
|
|
|
|
|
text = ""
|
|
|
|
|
|
for page in doc:
|
|
|
|
|
|
text += page.get_text()
|
|
|
|
|
|
doc.close()
|
|
|
|
|
|
|
|
|
|
|
|
# Create job
|
|
|
|
|
|
job_id = str(uuid.uuid4())
|
|
|
|
|
|
await create_job(job_id, text[:500], bundesland, model)
|
|
|
|
|
|
|
|
|
|
|
|
# Start background analysis
|
|
|
|
|
|
background_tasks.add_task(run_analysis, job_id, text, bundesland, model)
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse({"job_id": job_id, "status": "queued"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run_analysis(job_id: str, text: str, bundesland: str, model: str):
|
|
|
|
|
|
"""Background task for analysis."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
await update_job(job_id, status="processing")
|
|
|
|
|
|
|
|
|
|
|
|
# Run LLM analysis
|
|
|
|
|
|
assessment = await analyze_antrag(text, bundesland, model)
|
|
|
|
|
|
|
|
|
|
|
|
# Generate reports
|
|
|
|
|
|
html_path = settings.reports_dir / f"{job_id}.html"
|
|
|
|
|
|
pdf_path = settings.reports_dir / f"{job_id}.pdf"
|
|
|
|
|
|
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
await generate_html_report(assessment, html_path, bundesland=bundesland)
|
|
|
|
|
|
await generate_pdf_report(assessment, pdf_path, bundesland=bundesland)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
await update_job(
|
|
|
|
|
|
job_id,
|
|
|
|
|
|
status="completed",
|
|
|
|
|
|
result=assessment.model_dump_json(),
|
|
|
|
|
|
html_path=str(html_path),
|
|
|
|
|
|
pdf_path=str(pdf_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
await update_job(job_id, status="failed", error=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/status/{job_id}")
|
|
|
|
|
|
async def get_status(job_id: str):
|
|
|
|
|
|
"""Get job status."""
|
|
|
|
|
|
job = await get_job(job_id)
|
|
|
|
|
|
if not job:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
|
|
|
|
|
return JSONResponse({
|
|
|
|
|
|
"job_id": job_id,
|
|
|
|
|
|
"status": job["status"],
|
|
|
|
|
|
"created_at": job["created_at"],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/result/{job_id}", response_class=HTMLResponse)
|
|
|
|
|
|
async def get_result(request: Request, job_id: str):
|
|
|
|
|
|
"""Get analysis result as HTML."""
|
|
|
|
|
|
job = await get_job(job_id)
|
|
|
|
|
|
if not job:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
|
|
|
|
|
if job["status"] != "completed":
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
|
|
|
|
|
|
|
|
|
|
|
|
html_path = Path(job["html_path"])
|
|
|
|
|
|
if html_path.exists():
|
|
|
|
|
|
return HTMLResponse(html_path.read_text())
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Report nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/result/{job_id}/pdf")
|
|
|
|
|
|
async def get_pdf(job_id: str):
|
|
|
|
|
|
"""Download PDF report."""
|
|
|
|
|
|
job = await get_job(job_id)
|
|
|
|
|
|
if not job:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
|
|
|
|
|
if job["status"] != "completed":
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Job noch nicht fertig: {job['status']}")
|
|
|
|
|
|
|
|
|
|
|
|
pdf_path = Path(job["pdf_path"])
|
|
|
|
|
|
if pdf_path.exists():
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
|
pdf_path,
|
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
|
filename=f"gwoe-bericht-{job_id[:8]}.pdf"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="PDF nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Load assessments from database
|
|
|
|
|
|
@app.get("/api/assessments")
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
async def list_assessments(bundesland: Optional[str] = None):
|
|
|
|
|
|
"""Return assessments from database, optionally filtered by Bundesland.
|
|
|
|
|
|
|
|
|
|
|
|
``bundesland="ALL"`` and missing parameter both mean "no filter".
|
|
|
|
|
|
"""
|
|
|
|
|
|
rows = await get_all_assessments(bundesland)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# Convert DB format to frontend format
|
|
|
|
|
|
assessments = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
assessments.append({
|
|
|
|
|
|
"drucksache": row.get("drucksache"),
|
|
|
|
|
|
"title": row.get("title"),
|
|
|
|
|
|
"fraktionen": row.get("fraktionen", []),
|
|
|
|
|
|
"datum": row.get("datum"),
|
|
|
|
|
|
"link": row.get("link"),
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
"bundesland": row.get("bundesland"),
|
2026-03-28 22:30:24 +01:00
|
|
|
|
"gwoeScore": row.get("gwoe_score"),
|
|
|
|
|
|
"gwoeBegründung": row.get("gwoe_begruendung"),
|
|
|
|
|
|
"gwoeMatrix": row.get("gwoe_matrix", []),
|
|
|
|
|
|
"gwoeSchwerpunkt": row.get("gwoe_schwerpunkt", []),
|
|
|
|
|
|
"wahlprogrammScores": row.get("wahlprogramm_scores", []),
|
|
|
|
|
|
"verbesserungen": row.get("verbesserungen", []),
|
|
|
|
|
|
"stärken": row.get("staerken", []),
|
|
|
|
|
|
"schwächen": row.get("schwaechen", []),
|
|
|
|
|
|
"empfehlung": row.get("empfehlung"),
|
|
|
|
|
|
"empfehlungSymbol": row.get("empfehlung_symbol"),
|
|
|
|
|
|
"verbesserungspotenzial": row.get("verbesserungspotenzial"),
|
|
|
|
|
|
"themen": row.get("themen", []),
|
|
|
|
|
|
"antragZusammenfassung": row.get("antrag_zusammenfassung"),
|
|
|
|
|
|
"antragKernpunkte": row.get("antrag_kernpunkte", []),
|
|
|
|
|
|
})
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
return assessments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Get single assessment (use query param for drucksache with /)
|
|
|
|
|
|
@app.get("/api/assessment")
|
|
|
|
|
|
async def get_single_assessment(drucksache: str):
|
|
|
|
|
|
"""Get a single assessment by drucksache ID."""
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
drucksache = validate_drucksache(drucksache)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
row = await get_assessment(drucksache)
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Assessment nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"drucksache": row.get("drucksache"),
|
|
|
|
|
|
"title": row.get("title"),
|
|
|
|
|
|
"fraktionen": row.get("fraktionen", []),
|
|
|
|
|
|
"datum": row.get("datum"),
|
|
|
|
|
|
"link": row.get("link"),
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
"bundesland": row.get("bundesland"),
|
2026-03-28 22:30:24 +01:00
|
|
|
|
"gwoeScore": row.get("gwoe_score"),
|
|
|
|
|
|
"gwoeBegründung": row.get("gwoe_begruendung"),
|
|
|
|
|
|
"gwoeMatrix": row.get("gwoe_matrix", []),
|
|
|
|
|
|
"gwoeSchwerpunkt": row.get("gwoe_schwerpunkt", []),
|
|
|
|
|
|
"wahlprogrammScores": row.get("wahlprogramm_scores", []),
|
|
|
|
|
|
"verbesserungen": row.get("verbesserungen", []),
|
|
|
|
|
|
"stärken": row.get("staerken", []),
|
|
|
|
|
|
"schwächen": row.get("schwaechen", []),
|
|
|
|
|
|
"empfehlung": row.get("empfehlung"),
|
|
|
|
|
|
"empfehlungSymbol": row.get("empfehlung_symbol"),
|
|
|
|
|
|
"verbesserungspotenzial": row.get("verbesserungspotenzial"),
|
|
|
|
|
|
"themen": row.get("themen", []),
|
|
|
|
|
|
"antragZusammenfassung": row.get("antrag_zusammenfassung"),
|
|
|
|
|
|
"antragKernpunkte": row.get("antrag_kernpunkte", []),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Generate PDF on demand for an assessment
|
|
|
|
|
|
@app.get("/api/assessment/pdf")
|
|
|
|
|
|
async def download_assessment_pdf(drucksache: str):
|
|
|
|
|
|
"""Generate and download PDF for an assessment."""
|
|
|
|
|
|
from .models import Assessment
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
|
|
|
|
|
|
drucksache = validate_drucksache(drucksache)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
row = await get_assessment(drucksache)
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Assessment nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
# Check if PDF already exists
|
|
|
|
|
|
safe_name = drucksache.replace("/", "-")
|
|
|
|
|
|
pdf_path = settings.reports_dir / f"{safe_name}.pdf"
|
|
|
|
|
|
|
|
|
|
|
|
if not pdf_path.exists():
|
|
|
|
|
|
# Convert DB row to Assessment model for report generation
|
|
|
|
|
|
assessment_data = {
|
|
|
|
|
|
"drucksache": row.get("drucksache"),
|
|
|
|
|
|
"title": row.get("title"),
|
|
|
|
|
|
"fraktionen": row.get("fraktionen", []),
|
|
|
|
|
|
"datum": row.get("datum"),
|
|
|
|
|
|
"link": row.get("link"),
|
|
|
|
|
|
"gwoe_score": row.get("gwoe_score") or 0,
|
|
|
|
|
|
"gwoe_begruendung": row.get("gwoe_begruendung") or "",
|
|
|
|
|
|
"gwoe_matrix": row.get("gwoe_matrix", []),
|
|
|
|
|
|
"gwoe_schwerpunkt": row.get("gwoe_schwerpunkt", []),
|
|
|
|
|
|
"wahlprogramm_scores": row.get("wahlprogramm_scores", []),
|
|
|
|
|
|
"verbesserungen": row.get("verbesserungen", []),
|
|
|
|
|
|
"staerken": row.get("staerken", []),
|
|
|
|
|
|
"schwaechen": row.get("schwaechen", []),
|
|
|
|
|
|
"empfehlung": row.get("empfehlung") or "",
|
|
|
|
|
|
"empfehlung_symbol": row.get("empfehlung_symbol") or "",
|
|
|
|
|
|
"verbesserungspotenzial": row.get("verbesserungspotenzial") or "",
|
|
|
|
|
|
"themen": row.get("themen", []),
|
|
|
|
|
|
"antrag_zusammenfassung": row.get("antrag_zusammenfassung") or "",
|
|
|
|
|
|
"antrag_kernpunkte": row.get("antrag_kernpunkte", []),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
assessment = Assessment(**assessment_data)
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
await generate_pdf_report(
|
|
|
|
|
|
assessment,
|
|
|
|
|
|
pdf_path,
|
|
|
|
|
|
bundesland=row.get("bundesland"),
|
|
|
|
|
|
)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
|
pdf_path,
|
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
|
filename=f"gwoe-{safe_name}.pdf"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Search internal DB only
|
|
|
|
|
|
@app.get("/api/search")
|
|
|
|
|
|
async def search_internal(
|
|
|
|
|
|
q: str,
|
|
|
|
|
|
bundesland: str = "NRW",
|
|
|
|
|
|
limit: int = 50
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Search internal assessments database only.
|
|
|
|
|
|
"""
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
q = validate_search_query(q)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
db_results = await search_assessments(q, bundesland, limit)
|
|
|
|
|
|
|
|
|
|
|
|
results = []
|
|
|
|
|
|
for row in db_results:
|
|
|
|
|
|
results.append({
|
|
|
|
|
|
"drucksache": row.get("drucksache"),
|
|
|
|
|
|
"title": row.get("title"),
|
|
|
|
|
|
"fraktionen": row.get("fraktionen", []),
|
|
|
|
|
|
"datum": row.get("datum"),
|
|
|
|
|
|
"link": row.get("link"),
|
|
|
|
|
|
"bundesland": bundesland,
|
|
|
|
|
|
"gwoeScore": row.get("gwoe_score"),
|
|
|
|
|
|
"themen": row.get("themen", []),
|
|
|
|
|
|
"status": "checked",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Search external parliament portal (Landtag)
|
|
|
|
|
|
@app.get("/api/search-landtag")
|
|
|
|
|
|
async def search_landtag(
|
|
|
|
|
|
q: str,
|
|
|
|
|
|
bundesland: str = "NRW",
|
|
|
|
|
|
limit: int = 20
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Search external parliament portal (e.g., NRW OPAL).
|
|
|
|
|
|
Returns results that can be analyzed with "Jetzt prüfen".
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
|
|
|
|
|
|
Requires a concrete Bundesland — the special "ALL" / Bundesweit mode
|
|
|
|
|
|
cannot pick a single Landtag adapter and is rejected with HTTP 400.
|
2026-03-28 22:30:24 +01:00
|
|
|
|
"""
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
q = validate_search_query(q)
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
if not bundesland or bundesland == "ALL":
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Landtag-Suche benötigt ein konkretes Bundesland",
|
|
|
|
|
|
)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
adapter = get_adapter(bundesland)
|
|
|
|
|
|
if not adapter:
|
|
|
|
|
|
return {"error": f"Bundesland {bundesland} noch nicht unterstützt"}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
external = await adapter.search(q, limit)
|
|
|
|
|
|
results = []
|
|
|
|
|
|
for doc in external:
|
|
|
|
|
|
results.append({
|
|
|
|
|
|
"drucksache": doc.drucksache,
|
|
|
|
|
|
"title": doc.title,
|
|
|
|
|
|
"fraktionen": doc.fraktionen,
|
|
|
|
|
|
"datum": doc.datum,
|
|
|
|
|
|
"link": doc.link,
|
|
|
|
|
|
"bundesland": bundesland,
|
|
|
|
|
|
"typ": doc.typ,
|
|
|
|
|
|
"gwoeScore": None,
|
|
|
|
|
|
"status": "unchecked",
|
|
|
|
|
|
})
|
|
|
|
|
|
return results
|
|
|
|
|
|
except Exception as e:
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
logger.exception("Landtag search error for q=%r bundesland=%s", q, bundesland)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
return {"error": f"Suchfehler: {str(e)}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: Analyze a document from parliament portal
|
|
|
|
|
|
@app.post("/api/analyze-drucksache")
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
@limiter.limit("10/minute")
|
2026-03-28 22:30:24 +01:00
|
|
|
|
async def analyze_drucksache(
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
request: Request,
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
|
drucksache: str = Form(...),
|
|
|
|
|
|
bundesland: str = Form("NRW"),
|
|
|
|
|
|
model: str = Form("qwen-plus")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Download a document from parliament portal and analyze it.
|
|
|
|
|
|
"""
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
drucksache = validate_drucksache(drucksache)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# Check if already analyzed
|
|
|
|
|
|
existing = await get_assessment(drucksache)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return {"status": "already_checked", "drucksache": drucksache}
|
|
|
|
|
|
|
|
|
|
|
|
# Get adapter and download
|
|
|
|
|
|
adapter = get_adapter(bundesland)
|
|
|
|
|
|
if not adapter:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Bundesland {bundesland} nicht unterstützt")
|
|
|
|
|
|
|
|
|
|
|
|
# Download text
|
|
|
|
|
|
text = await adapter.download_text(drucksache)
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Dokument {drucksache} nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
# Get document metadata
|
|
|
|
|
|
doc = await adapter.get_document(drucksache)
|
|
|
|
|
|
|
|
|
|
|
|
# Create job
|
|
|
|
|
|
job_id = str(uuid.uuid4())
|
|
|
|
|
|
await create_job(job_id, text[:500], bundesland, model)
|
|
|
|
|
|
|
|
|
|
|
|
# Start background analysis
|
|
|
|
|
|
background_tasks.add_task(
|
|
|
|
|
|
run_drucksache_analysis,
|
|
|
|
|
|
job_id, drucksache, text, bundesland, model, doc
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "queued", "job_id": job_id, "drucksache": drucksache}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run_drucksache_analysis(
|
|
|
|
|
|
job_id: str,
|
|
|
|
|
|
drucksache: str,
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
bundesland: str,
|
|
|
|
|
|
model: str,
|
|
|
|
|
|
doc
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Background task for drucksache analysis."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
await update_job(job_id, status="processing")
|
|
|
|
|
|
|
|
|
|
|
|
# Run LLM analysis
|
|
|
|
|
|
assessment = await analyze_antrag(text, bundesland, model)
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare data for DB
|
|
|
|
|
|
assessment_data = {
|
|
|
|
|
|
"drucksache": drucksache,
|
|
|
|
|
|
"title": assessment.title or (doc.title if doc else f"Drucksache {drucksache}"),
|
|
|
|
|
|
"fraktionen": assessment.fraktionen,
|
|
|
|
|
|
"datum": assessment.datum or (doc.datum if doc else ""),
|
|
|
|
|
|
"link": doc.link if doc else "",
|
|
|
|
|
|
"bundesland": bundesland,
|
|
|
|
|
|
"gwoeScore": assessment.gwoe_score,
|
|
|
|
|
|
"gwoeBegründung": assessment.gwoe_begruendung,
|
|
|
|
|
|
"gwoeMatrix": [m.model_dump() for m in assessment.gwoe_matrix],
|
|
|
|
|
|
"gwoeSchwerpunkt": assessment.gwoe_schwerpunkt,
|
|
|
|
|
|
"wahlprogrammScores": [w.model_dump() for w in assessment.wahlprogramm_scores],
|
|
|
|
|
|
"verbesserungen": [v.model_dump() for v in assessment.verbesserungen],
|
|
|
|
|
|
"stärken": assessment.staerken,
|
|
|
|
|
|
"schwächen": assessment.schwaechen,
|
|
|
|
|
|
"empfehlung": assessment.empfehlung,
|
|
|
|
|
|
"empfehlungSymbol": assessment.empfehlung_symbol,
|
|
|
|
|
|
"verbesserungspotenzial": assessment.verbesserungspotenzial,
|
|
|
|
|
|
"themen": assessment.themen,
|
|
|
|
|
|
"antragZusammenfassung": assessment.antrag_zusammenfassung,
|
|
|
|
|
|
"antragKernpunkte": assessment.antrag_kernpunkte,
|
|
|
|
|
|
"source": "webapp",
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Save to DB
|
|
|
|
|
|
await upsert_assessment(assessment_data)
|
|
|
|
|
|
|
|
|
|
|
|
# Generate reports
|
|
|
|
|
|
html_path = settings.reports_dir / f"{job_id}.html"
|
|
|
|
|
|
pdf_path = settings.reports_dir / f"{job_id}.pdf"
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
|
|
|
|
|
|
await generate_html_report(assessment, html_path, bundesland=bundesland)
|
|
|
|
|
|
await generate_pdf_report(assessment, pdf_path, bundesland=bundesland)
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
await update_job(
|
|
|
|
|
|
job_id,
|
|
|
|
|
|
status="completed",
|
|
|
|
|
|
result=assessment.model_dump_json(),
|
|
|
|
|
|
html_path=str(html_path),
|
|
|
|
|
|
pdf_path=str(pdf_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
# Volltext-Stack via logger.exception, NICHT via print — landet so im
|
|
|
|
|
|
# strukturierten Container-Log und wird vom logging-Framework formatiert
|
|
|
|
|
|
logger.exception("run_drucksache_analysis failed for drucksache=%s", drucksache)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
await update_job(job_id, status="failed", error=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# API: List available Bundesländer
|
|
|
|
|
|
@app.get("/api/bundeslaender")
|
|
|
|
|
|
async def list_bundeslaender():
|
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
|
|
|
|
"""List available bundesländer with their status.
|
|
|
|
|
|
|
|
|
|
|
|
Includes the synthetic "ALL" / Bundesweit entry as the first item so
|
|
|
|
|
|
that the frontend can render it directly. ``parlament_name`` is added
|
|
|
|
|
|
so the detail view can show the source parliament without an extra
|
|
|
|
|
|
backend round-trip.
|
|
|
|
|
|
"""
|
|
|
|
|
|
out = [{
|
|
|
|
|
|
"code": "ALL",
|
|
|
|
|
|
"name": "🌍 Bundesweit",
|
|
|
|
|
|
"parlament_name": None,
|
|
|
|
|
|
"active": True,
|
|
|
|
|
|
}]
|
|
|
|
|
|
out.extend({
|
|
|
|
|
|
"code": bl.code,
|
|
|
|
|
|
"name": bl.name,
|
|
|
|
|
|
"parlament_name": bl.parlament_name,
|
|
|
|
|
|
"active": bl.aktiv,
|
|
|
|
|
|
} for bl in alle_bundeslaender())
|
|
|
|
|
|
return out
|
2026-03-28 22:30:24 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === Quellen / Programme ===
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/quellen", response_class=HTMLResponse)
|
|
|
|
|
|
async def quellen_page(request: Request):
|
|
|
|
|
|
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen."""
|
|
|
|
|
|
programmes = get_programme_info()
|
|
|
|
|
|
status = get_indexing_status()
|
|
|
|
|
|
|
|
|
|
|
|
return templates.TemplateResponse("quellen.html", {
|
|
|
|
|
|
"request": request,
|
|
|
|
|
|
"app_name": settings.app_name,
|
|
|
|
|
|
"programmes": programmes,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
@app.get("/api/wahlprogramm-cite")
|
2026-04-10 09:57:58 +02:00
|
|
|
|
async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str = ""):
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
"""Render eine Wahlprogramm-Seite mit gelb hervorgehobener Zitat-Stelle.
|
|
|
|
|
|
|
|
|
|
|
|
Issue #47: Klick auf eine Zitat-Quelle im Report soll direkt zur
|
|
|
|
|
|
Stelle im Wahlprogramm-PDF springen, mit dem zitierten Snippet
|
|
|
|
|
|
visuell markiert. Statt das ganze PDF auszuliefern (Browser scrollt
|
|
|
|
|
|
auf #page=N und Leser muss von Hand suchen), liefern wir hier ein
|
|
|
|
|
|
1-Seiten-PDF mit ``add_highlight_annot``-Annotation auf den per
|
|
|
|
|
|
``page.search_for`` gefundenen Bounding-Boxes.
|
|
|
|
|
|
|
2026-04-10 09:57:58 +02:00
|
|
|
|
Akzeptiert ``pid`` (PROGRAMME-Key) ODER ``pdf`` (Dateiname wie
|
|
|
|
|
|
``spd-grundsatzprogramm.pdf``). Letzterer ermöglicht die retroaktive
|
|
|
|
|
|
Nutzung von Pre-#47-URLs im Frontend, wo nur der statische Pfad
|
|
|
|
|
|
``/static/referenzen/<pdf>#page=<N>`` gespeichert ist.
|
|
|
|
|
|
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
Security: ``pid`` muss ein registrierter PROGRAMME-Key sein —
|
|
|
|
|
|
verhindert Path-Traversal und arbiträren File-Read aus dem
|
|
|
|
|
|
referenzen-Verzeichnis. ``seite`` wird per Pydantic-Coercion
|
|
|
|
|
|
auf int gezwungen. ``q`` ist auf 200 Zeichen begrenzt im Renderer.
|
|
|
|
|
|
"""
|
2026-04-10 09:57:58 +02:00
|
|
|
|
# Reverse-Lookup: pdf-Filename → programm_id, falls nur pdf angegeben.
|
2026-04-10 10:08:02 +02:00
|
|
|
|
# Zwei Stufen: exakter Match, dann fuzzy (Year-Suffix-Stripping), weil
|
|
|
|
|
|
# Pre-#47 Assessments halluzinierte Dateinamen haben können, z.B.
|
|
|
|
|
|
# "gruene-grundsatzprogramm-2020.pdf" statt "gruene-grundsatzprogramm.pdf".
|
2026-04-10 09:57:58 +02:00
|
|
|
|
if not pid and pdf:
|
2026-04-10 10:08:02 +02:00
|
|
|
|
# Stage 1: exakt
|
2026-04-10 09:57:58 +02:00
|
|
|
|
for p, info in PROGRAMME.items():
|
|
|
|
|
|
if info.get("pdf") == pdf:
|
|
|
|
|
|
pid = p
|
|
|
|
|
|
break
|
2026-04-10 10:08:02 +02:00
|
|
|
|
# Stage 2: Year-Suffix stripping (z.B. "X-2020.pdf" → "X.pdf")
|
|
|
|
|
|
if not pid:
|
|
|
|
|
|
import re
|
|
|
|
|
|
stripped = re.sub(r"-\d{4}\.pdf$", ".pdf", pdf)
|
|
|
|
|
|
if stripped != pdf:
|
|
|
|
|
|
for p, info in PROGRAMME.items():
|
|
|
|
|
|
if info.get("pdf") == stripped:
|
|
|
|
|
|
pid = p
|
|
|
|
|
|
break
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
if pid not in PROGRAMME:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unbekanntes Wahlprogramm")
|
|
|
|
|
|
if seite < 1 or seite > 2000:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ungültige Seitennummer")
|
|
|
|
|
|
|
2026-04-10 10:16:00 +02:00
|
|
|
|
pdf_bytes, found_page = render_highlighted_page(pid, seite, q)
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
if pdf_bytes is None:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=404,
|
|
|
|
|
|
detail="Wahlprogramm-PDF oder Seite nicht verfügbar",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
info = PROGRAMME[pid]
|
|
|
|
|
|
safe_name = info.get("pdf", f"{pid}.pdf")
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=pdf_bytes,
|
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Content-Disposition": f'inline; filename="{safe_name}"',
|
|
|
|
|
|
"Cache-Control": "public, max-age=86400",
|
2026-04-10 10:16:00 +02:00
|
|
|
|
# found_page als Header mitgeben, damit das Frontend den
|
|
|
|
|
|
# #page=N Fragment korrekt setzen kann (bei page-redirect
|
|
|
|
|
|
# nach Fallback-Suche auf anderer Seite als angefordert).
|
|
|
|
|
|
"X-Found-Page": str(found_page),
|
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.
Implementation:
embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None
embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
500-Zeichen-Snippets jedes Assessment-JSON auf
main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range
Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.
Tests: 194/194 grün (185 + 9 neue):
- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)
Refs: #47
2026-04-10 01:09:45 +02:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
@app.get("/api/programme")
|
|
|
|
|
|
async def list_programme():
|
|
|
|
|
|
"""List all available programmes."""
|
|
|
|
|
|
return get_programme_info()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/programme/status")
|
|
|
|
|
|
async def programme_status():
|
|
|
|
|
|
"""Get indexing status of all programmes."""
|
|
|
|
|
|
return get_indexing_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/programme/index")
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
@limiter.limit("3/minute")
|
2026-03-28 22:30:24 +01:00
|
|
|
|
async def index_programme(
|
Security hotfixes #1, #2, #6 from audit (#57)
Drei akute Befunde aus dem Live-System-Audit (Issue #57):
- **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi
Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache
(10/min) und /api/programme/index (3/min). Verhindert, dass ein
unauthentifizierter Client mit einer Schleife die DashScope-Quota oder
die CPU des Containers leerziehen kann. Default-Storage reicht solange
wir auf einem einzigen Worker laufen.
- **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read
via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen
Felder in app/report.py laufen jetzt durch html.escape() bevor sie in
die HTML-Template interpoliert werden. format_redline_html escape-first
und ersetzt dann die Markdown-Marker durch von uns kontrollierte
<span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein
nacktes " den title="..."-Wert nicht mehr beenden und einen Event-
Handler injizieren kann. Toter jinja2-Import in report.py entfernt
(war never used, blockierte nur den lokalen Test).
- **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die
Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout
in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld.
Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der
Escape-First-Ansatz das nicht versehentlich kaputt macht.
77 alte Unit-Tests + 8 neue → 85 grün.
Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt
in tests/integration/test_main_security.py als separates Folge-Item.
Refs: #57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
|
|
|
|
request: Request,
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
|
programm_id: str = Form(None),
|
|
|
|
|
|
all_programmes: bool = Form(False),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Index programme(s) for semantic search."""
|
|
|
|
|
|
pdf_dir = static_dir / "referenzen"
|
|
|
|
|
|
|
|
|
|
|
|
if all_programmes:
|
|
|
|
|
|
# Index sequentially to avoid DB locks
|
|
|
|
|
|
async def index_all_sequential():
|
|
|
|
|
|
for prog_id in PROGRAMME.keys():
|
|
|
|
|
|
try:
|
|
|
|
|
|
index_programm(prog_id, pdf_dir)
|
Phase A: Audit-Restbefunde #57.3/4/7 (Roadmap #59)
Drei verbleibende Audit-Befunde aus #57 in einem Patch:
- **#57.3 MEDIUM** Drucksache-Regex-Validation: neue
app/validators.py mit validate_drucksache() als gemeinsamer
Validation-Funnel. Pattern ^\d{1,3}/\d{1,7}([-(].{1,20})?$ deckt
alle 10 aktiven Bundesländer (8/6390, 18/12345, 8/6390(neu),
23/3700-A) ab und blockt Path-Traversal (../, /etc/passwd) plus
Standard-Injection (;, <, &). Drei Endpoints durchgeschleust:
/api/assessment, /api/assessment/pdf, /api/analyze-drucksache.
- **#57.4 MEDIUM** print() → logging.getLogger(__name__): main.py
und analyzer.py auf strukturiertes Logging umgestellt. LLM-Inhalte
werden NICHT mehr als Volltext geloggt — neue Helper
_content_fingerprint() liefert nur "len=N sha1=XXXX", reicht zur
Forensik ohne Antrag-Inhalte ins Container-Log zu leaken.
basicConfig() mit ISO-Format setzt strukturiertes Logging früh,
damit logger.exception() auch beim Boot greift.
- **#57.7 LOW-MED** Search-Query-Limit: validate_search_query() mit
MAX_SEARCH_QUERY_LEN=200 schützt /api/search und /api/search-landtag
vor 10-MB-Query-DoS. database._parse_search_query() loggt jetzt
shlex.ValueError-Fallback statt ihn zu verschlucken (deckt Memory-
Regel "stille excepts in Adaptern" ab).
Tests: neue tests/test_main_validators.py mit 22 Cases — Drucksache-
Whitelist-Roundtrip + Path-Traversal-Reject, Search-Query Längen-
Edge-Cases. 107 Unit-Tests grün (85 alt + 22 neu).
Validators in eigenem Modul (app/validators.py), damit Tests sie ohne
slowapi-Dependency direkt importieren können.
Refs: #57, #59 (Phase A)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:15:16 +02:00
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception("Error indexing programme %s", prog_id)
|
2026-03-28 22:30:24 +01:00
|
|
|
|
background_tasks.add_task(index_all_sequential)
|
|
|
|
|
|
return {"status": "indexing", "programmes": list(PROGRAMME.keys())}
|
|
|
|
|
|
|
|
|
|
|
|
if programm_id and programm_id in PROGRAMME:
|
|
|
|
|
|
background_tasks.add_task(index_programm, programm_id, pdf_dir)
|
|
|
|
|
|
return {"status": "indexing", "programm_id": programm_id}
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ungültiges Programm")
|
|
|
|
|
|
|
|
|
|
|
|
|
Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:57 +02:00
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Auswertungen #58 — Bundesland × Partei × Wahlperiode Aggregations-Sicht
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/auswertungen", response_class=HTMLResponse)
|
|
|
|
|
|
async def auswertungen_page(request: Request):
|
|
|
|
|
|
"""Statische Seite, die die Matrix-Endpoints per fetch() lädt."""
|
|
|
|
|
|
from .wahlperioden import all_wahlperioden
|
|
|
|
|
|
return templates.TemplateResponse("auswertungen.html", {
|
|
|
|
|
|
"request": request,
|
|
|
|
|
|
"app_name": settings.app_name,
|
|
|
|
|
|
"wahlperioden": sorted(all_wahlperioden()),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/auswertungen/matrix")
|
|
|
|
|
|
async def auswertungen_matrix(wahlperiode: Optional[str] = None):
|
|
|
|
|
|
"""2D-Matrix Bundesland × Partei mit Anzahl + Ø-GWÖ-Score."""
|
|
|
|
|
|
from .auswertungen import aggregate_matrix
|
|
|
|
|
|
return aggregate_matrix(filter_wp=wahlperiode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/auswertungen/zeitreihe")
|
|
|
|
|
|
async def auswertungen_zeitreihe(bundesland: str, partei: str):
|
|
|
|
|
|
"""Score-Verlauf einer (BL, Partei)-Kombination über alle WPs."""
|
|
|
|
|
|
from .auswertungen import aggregate_zeitreihe
|
|
|
|
|
|
return aggregate_zeitreihe(bundesland, partei)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/auswertungen/export.csv")
|
|
|
|
|
|
async def auswertungen_export_csv():
|
|
|
|
|
|
"""Long-Format-CSV-Export aller Assessments. Deckt #45 mit ab."""
|
|
|
|
|
|
from .auswertungen import export_long_format
|
|
|
|
|
|
csv_text = export_long_format()
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=csv_text,
|
|
|
|
|
|
media_type="text/csv",
|
|
|
|
|
|
headers={"Content-Disposition": 'attachment; filename="gwoe-assessments.csv"'},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 22:30:24 +01:00
|
|
|
|
# Health check
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
|
async def health():
|
|
|
|
|
|
return {"status": "ok", "version": settings.app_version}
|