diff --git a/app/database.py b/app/database.py index 757fd19..ef590c1 100644 --- a/app/database.py +++ b/app/database.py @@ -200,14 +200,24 @@ async def get_assessment(drucksache: str) -> Optional[dict]: return None -async def get_all_assessments() -> list[dict]: - """Get all assessments from database.""" +async def get_all_assessments(bundesland: str = None) -> list[dict]: + """Get all assessments from database, optionally filtered by Bundesland. + + The special value ``"ALL"`` and ``None`` mean no filter — both behave + identically and return every row. Any other value becomes a strict + ``WHERE bundesland = ?`` match. + """ import json + sql = "SELECT * FROM assessments" + params: list = [] + if bundesland and bundesland != "ALL": + sql += " WHERE bundesland = ?" + params.append(bundesland) + sql += " ORDER BY gwoe_score DESC" + async with aiosqlite.connect(settings.db_path) as db: db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT * FROM assessments ORDER BY gwoe_score DESC" - ) + cursor = await db.execute(sql, params) rows = await cursor.fetchall() results = [] for row in rows: @@ -293,7 +303,7 @@ async def search_assessments(query: str, bundesland: str = None, limit: int = 50 """ params = [f"%{first_term}%"] * 4 - if bundesland: + if bundesland and bundesland != "ALL": sql += " AND bundesland = ?" params.append(bundesland) diff --git a/app/main.py b/app/main.py index 4ec0d4a..6f01b95 100644 --- a/app/main.py +++ b/app/main.py @@ -84,13 +84,24 @@ async def startup(): @app.get("/", response_class=HTMLResponse) async def index(request: Request): """Landing page with upload form.""" + # 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() + } return templates.TemplateResponse("index.html", { "request": request, "app_name": settings.app_name, - "bundeslaender": [ - {"code": bl.code, "name": bl.name, "active": bl.aktiv} - for bl in alle_bundeslaender() - ], + "bundeslaender": bl_list, + "parlament_names": parlament_names, }) @@ -138,9 +149,9 @@ async def run_analysis(job_id: str, text: str, bundesland: str, model: str): html_path = settings.reports_dir / f"{job_id}.html" pdf_path = settings.reports_dir / f"{job_id}.pdf" - await generate_html_report(assessment, html_path) - await generate_pdf_report(assessment, pdf_path) - + await generate_html_report(assessment, html_path, bundesland=bundesland) + await generate_pdf_report(assessment, pdf_path, bundesland=bundesland) + await update_job( job_id, status="completed", @@ -203,10 +214,13 @@ async def get_pdf(job_id: str): # API: Load assessments from database @app.get("/api/assessments") -async def list_assessments(): - """Return all assessments from database.""" - rows = await get_all_assessments() - +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) + # Convert DB format to frontend format assessments = [] for row in rows: @@ -216,6 +230,7 @@ async def list_assessments(): "fraktionen": row.get("fraktionen", []), "datum": row.get("datum"), "link": row.get("link"), + "bundesland": row.get("bundesland"), "gwoeScore": row.get("gwoe_score"), "gwoeBegründung": row.get("gwoe_begruendung"), "gwoeMatrix": row.get("gwoe_matrix", []), @@ -231,7 +246,7 @@ async def list_assessments(): "antragZusammenfassung": row.get("antrag_zusammenfassung"), "antragKernpunkte": row.get("antrag_kernpunkte", []), }) - + return assessments @@ -249,6 +264,7 @@ async def get_single_assessment(drucksache: str): "fraktionen": row.get("fraktionen", []), "datum": row.get("datum"), "link": row.get("link"), + "bundesland": row.get("bundesland"), "gwoeScore": row.get("gwoe_score"), "gwoeBegründung": row.get("gwoe_begruendung"), "gwoeMatrix": row.get("gwoe_matrix", []), @@ -306,7 +322,11 @@ async def download_assessment_pdf(drucksache: str): try: assessment = Assessment(**assessment_data) - await generate_pdf_report(assessment, pdf_path) + await generate_pdf_report( + assessment, + pdf_path, + bundesland=row.get("bundesland"), + ) except Exception as e: raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {e}") @@ -356,7 +376,15 @@ async def search_landtag( """ Search external parliament portal (e.g., NRW OPAL). Returns results that can be analyzed with "Jetzt prüfen". + + Requires a concrete Bundesland — the special "ALL" / Bundesweit mode + cannot pick a single Landtag adapter and is rejected with HTTP 400. """ + if not bundesland or bundesland == "ALL": + raise HTTPException( + status_code=400, + detail="Landtag-Suche benötigt ein konkretes Bundesland", + ) adapter = get_adapter(bundesland) if not adapter: return {"error": f"Bundesland {bundesland} noch nicht unterstützt"} @@ -471,10 +499,10 @@ async def run_drucksache_analysis( # Generate reports html_path = settings.reports_dir / f"{job_id}.html" pdf_path = settings.reports_dir / f"{job_id}.pdf" - - await generate_html_report(assessment, html_path) - await generate_pdf_report(assessment, pdf_path) - + + await generate_html_report(assessment, html_path, bundesland=bundesland) + await generate_pdf_report(assessment, pdf_path, bundesland=bundesland) + await update_job( job_id, status="completed", @@ -492,11 +520,26 @@ async def run_drucksache_analysis( # API: List available Bundesländer @app.get("/api/bundeslaender") async def list_bundeslaender(): - """List available bundesländer with their status.""" - return [ - {"code": bl.code, "name": bl.name, "active": bl.aktiv} - for bl in alle_bundeslaender() - ] + """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 # === Quellen / Programme === diff --git a/app/report.py b/app/report.py index 7652d70..b125cdb 100644 --- a/app/report.py +++ b/app/report.py @@ -7,6 +7,7 @@ from typing import Optional from jinja2 import Environment, FileSystemLoader from .models import Assessment, MATRIX_LABELS, EMPFEHLUNG_CONFIG +from .bundeslaender import BUNDESLAENDER # ECOnGOOD Colors COLORS = { @@ -93,10 +94,25 @@ def build_matrix_html(assessment: Assessment) -> str: return '\n'.join(html) -async def generate_html_report(assessment: Assessment, output_path: Path) -> None: - """Generate HTML report.""" - +async def generate_html_report( + assessment: Assessment, + output_path: Path, + bundesland: Optional[str] = None, +) -> None: + """Generate HTML report. + + ``bundesland`` is the optional state code (e.g. ``"NRW"``, ``"LSA"``). + When set and known in ``BUNDESLAENDER``, the resulting report carries + the parlament name in its header so the source parliament is always + visible — important since assessments from multiple bundesländer share + the same Drucksachen-ID space. + """ + empf_config = EMPFEHLUNG_CONFIG.get(assessment.empfehlung.value, {}) + + parlament_name = "" + if bundesland and bundesland in BUNDESLAENDER: + parlament_name = BUNDESLAENDER[bundesland].parlament_name html = f""" @@ -141,6 +157,14 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non color: var(--color-blue); margin-bottom: 0.5rem; }} + + .header-parlament {{ + font-size: 9pt; + color: var(--color-blue); + font-weight: bold; + margin-top: 0.4rem; + letter-spacing: 0.3px; + }} h1 {{ color: var(--color-darkgray); @@ -327,8 +351,9 @@ async def generate_html_report(assessment: Assessment, output_path: Path) -> Non