diff --git a/app/analyzer.py b/app/analyzer.py index 888f71b..8f0fbc7 100644 --- a/app/analyzer.py +++ b/app/analyzer.py @@ -302,6 +302,7 @@ async def analyze_antrag( bundesland: str = "NRW", model: str = "qwen-plus", bewerter: Optional[LlmBewerter] = None, + datum: Optional[str] = None, ) -> Assessment: """Analyze a parliamentary motion using the LLM. @@ -312,6 +313,11 @@ async def analyze_antrag( akzeptiert; andere Adapter können eigene Modell-Namen nutzen). bewerter: ``LlmBewerter``-Implementierung. Default: ``QwenBewerter`` (DashScope/Qwen). Tests reichen hier ``FakeLlmBewerter``. + datum: ISO-Datum (YYYY-MM-DD) des Antrags. Wenn gesetzt, wird die + semantische Suche zeitpunkt-gefiltert: nur Wahl- und + Grundsatzprogramme, die zum Antragszeitpunkt galten, werden + durchsucht (ADR 0013). Wenn None: alle Programme — wichtig + für Tests und für Anträge ohne extrahierbares Datum. Nach ADR 0008: der HTTP-Call samt Retry-Loop lebt im Adapter; hier bleibt nur noch die Application-Logik (Prompt-Komposition, Semantic- @@ -344,6 +350,7 @@ async def analyze_antrag( try: semantic_quotes = get_relevant_quotes_for_antrag( text, fraktionen, bundesland=bundesland, top_k_per_partei=5, + datum=datum, ) quotes_context = format_quotes_for_prompt( semantic_quotes, searched_parties=fraktionen, diff --git a/app/embeddings.py b/app/embeddings.py index f57c028..04b4e00 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -991,6 +991,7 @@ def find_relevant_chunks( bundesland: str = None, top_k: int = 3, min_similarity: float = 0.5, + datum: Optional[str] = None, ) -> list[dict]: """Find most relevant chunks for a query. @@ -998,6 +999,13 @@ def find_relevant_chunks( bundesland: Wenn gesetzt, werden nur Chunks dieses Bundeslands ODER globale Chunks (bundesland IS NULL, z.B. Grundsatzprogramme) berücksichtigt. Wenn None, kein Filter. + datum: ISO-Datum (YYYY-MM-DD). Wenn gesetzt, werden nur Chunks + zurückgegeben, deren ``programm_id`` in einem Programm liegt, + dessen Geltungszeitraum [gueltig_ab, gueltig_bis) das Datum + enthält. Damit erfolgen historische Bewertungen gegen das + zeitpunkt-richtige Programm (ADR 0013). Wenn None: alle + Programme (gegenwärtig und vergangen) durchsuchbar — Default + für Rückwärtskompatibilität. """ # Query-Embedding muss im selben Vektorraum wie die gespeicherten Chunks @@ -1026,6 +1034,28 @@ def find_relevant_chunks( sql += " AND (bundesland = ? OR bundesland IS NULL)" params.append(bundesland) + if datum: + # Welche programm_ids gelten zu diesem Datum? Pre-compute aus PROGRAMME. + valid_pids = [] + for pid, info in PROGRAMME.items(): + ab = info.get("gueltig_ab") + if not ab: + continue + bis = info.get("gueltig_bis") + if datum < ab: + continue + if bis is not None and datum >= bis: + continue + valid_pids.append(pid) + if valid_pids: + placeholders = ",".join("?" * len(valid_pids)) + sql += f" AND programm_id IN ({placeholders})" + params.extend(valid_pids) + else: + # Kein Programm gilt zu diesem Datum — leere Resultmenge. + conn.close() + return [] + rows = conn.execute(sql, params).fetchall() conn.close() @@ -1055,6 +1085,7 @@ def get_relevant_quotes_for_antrag( fraktionen: list[str], bundesland: str, top_k_per_partei: int = 2, + datum: Optional[str] = None, ) -> dict[str, list[dict]]: """Get relevant quotes from Wahl- and Parteiprogramme for an Antrag. @@ -1062,6 +1093,11 @@ def get_relevant_quotes_for_antrag( bundesland: Pflicht. Bestimmt, welche Wahlprogramme durchsucht werden und welche Regierungsfraktionen zusätzlich zu den Antragstellern einbezogen werden. + datum: ISO-Datum des Antrags. Wenn gesetzt, werden nur Programme + durchsucht, deren Geltungszeitraum [gueltig_ab, gueltig_bis) + das Datum enthält — historische Anträge werden gegen das + zeitpunkt-richtige Programm bewertet (ADR 0013). Wenn None: + alle Programme dieser Partei (Default — Rückwärtskompat). """ # Lokaler Import vermeidet Zirkularität: bundeslaender.py importiert nichts # aus diesem Modul, aber der saubere Trennstrich bleibt erhalten. @@ -1087,7 +1123,7 @@ def get_relevant_quotes_for_antrag( canonical = normalize_partei(partei, bundesland=bundesland) partei_lookup = canonical or partei - # Wahlprogramm — bundesland-gefiltert + # Wahlprogramm — bundesland-gefiltert + ggf. zeitpunkt-gefiltert wahl_chunks = find_relevant_chunks( antrag_text, parteien=[partei_lookup], @@ -1095,9 +1131,13 @@ def get_relevant_quotes_for_antrag( bundesland=bundesland, top_k=top_k_per_partei, min_similarity=0.35, + datum=datum, ) - # Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit) + # Parteiprogramm (Grundsatz, federal — bundesland=NULL matched implizit). + # Hier wird ``datum`` ebenfalls weitergereicht: zum Antragszeitpunkt + # noch nicht gültige Grundsatzprogramme (z.B. cdu-grundsatz von 2024 + # bei einem Antrag aus 2010) sollen nicht zitiert werden. partei_chunks = find_relevant_chunks( antrag_text, parteien=[partei_lookup], @@ -1105,6 +1145,7 @@ def get_relevant_quotes_for_antrag( bundesland=bundesland, top_k=top_k_per_partei, min_similarity=0.35, + datum=datum, ) if wahl_chunks or partei_chunks: diff --git a/app/main.py b/app/main.py index 9b978e6..8dbb446 100644 --- a/app/main.py +++ b/app/main.py @@ -1800,10 +1800,13 @@ async def run_drucksache_analysis( """Background task for drucksache analysis.""" try: await update_job(job_id, status="processing") - - # Run LLM analysis - assessment = await analyze_antrag(text, bundesland, model) - + + # Antrag-Datum (falls bekannt) für zeitpunkt-gefilterte Embedding-Suche + # mitreichen — historische Drucksachen werden gegen ihre damals + # gültigen Wahlprogramme bewertet (ADR 0013). + antrag_datum = doc.datum if doc and doc.datum else None + assessment = await analyze_antrag(text, bundesland, model, datum=antrag_datum) + # Prepare data for DB assessment_data = { "drucksache": drucksache, diff --git a/tests/test_bug_regressions.py b/tests/test_bug_regressions.py index 9147811..63a77f1 100644 --- a/tests/test_bug_regressions.py +++ b/tests/test_bug_regressions.py @@ -13,6 +13,7 @@ import json import sqlite3 import sys import types +from typing import Optional import pytest @@ -359,6 +360,70 @@ class TestPflichtFraktionen: for fraktion in BUNDESLAENDER["NRW"].landtagsfraktionen: assert fraktion in prompt, f"Fraktion {fraktion!r} fehlt im user_prompt" + def test_analyzer_propagates_datum_to_embeddings(self, monkeypatch): + """ADR 0013 / Task #224: analyze_antrag(datum=X) muss das Datum + an get_relevant_quotes_for_antrag durchreichen. Sonst zieht die + historische Bewertung Zitate aus später veröffentlichten + Programmen heran (Anachronismus).""" + import app.analyzer as analyzer_mod + import app.embeddings as emb_mod + + captured: list[Optional[str]] = [] + + def fake_get_relevant_quotes_for_antrag( + antrag_text, fraktionen, bundesland, + top_k_per_partei=2, datum=None, + ): + captured.append(datum) + return {} + + # EMBEDDINGS_DB muss "existieren", damit der semantische Pfad + # (statt Keyword-Fallback) genommen wird. analyzer.py hat den + # Namen direkt importiert, also muss dort auch gepatcht werden. + fake_exists = type("P", (), {"exists": lambda self: True})() + monkeypatch.setattr(emb_mod, "EMBEDDINGS_DB", fake_exists) + monkeypatch.setattr(analyzer_mod, "EMBEDDINGS_DB", fake_exists) + monkeypatch.setattr( + analyzer_mod, "get_relevant_quotes_for_antrag", + fake_get_relevant_quotes_for_antrag, + ) + # format_quotes_for_prompt muss auch monkeypatched werden, weil + # es mit dem leeren Dict aufgerufen wird. + monkeypatch.setattr( + analyzer_mod, "format_quotes_for_prompt", + lambda quotes, searched_parties=None: "", + ) + + class FakeBewerter: + async def bewerte(self, request): + return { + "drucksache": "18/1", "title": "Test", "fraktionen": ["SPD"], + "datum": "2018-09-01", "link": None, + "gwoeScore": 5, "gwoeBegründung": "Test", + "gwoeMatrix": [], "gwoeSchwerpunkt": [], + "wahlprogrammScores": [], "verbesserungen": [], + "stärken": [], "schwächen": [], + "empfehlung": "Überarbeiten", "empfehlungSymbol": "[!]", + "verbesserungspotenzial": "mittel", "themen": [], + "antragZusammenfassung": "Test", "antragKernpunkte": [], + "konfidenz": "mittel", + "shareThreads": "", "shareTwitter": "", "shareMastodon": "", + } + + asyncio.get_event_loop().run_until_complete( + analyzer_mod.analyze_antrag( + text="Antrag aus 2018 in NRW.", + bundesland="NRW", + model="qwen-plus", + bewerter=FakeBewerter(), + datum="2018-09-01", + ) + ) + + assert captured == ["2018-09-01"], ( + f"datum nicht durchgereicht; captured={captured}" + ) + # =========================================================================== # Bug 5 — NRW-Titel + Regierungsfraktionen im LLM-Prompt (Commit 038ebd6) diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index e1de23f..92c163a 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -196,7 +196,7 @@ class TestFormatQuotesForPrompt: """ def fake_find_relevant_chunks(query, parteien=None, typ=None, bundesland=None, top_k=3, - min_similarity=0.5): + min_similarity=0.5, datum=None): return [{ "programm_id": "gruene-nrw-2022", "partei": parteien[0] if parteien else "GRÜNE", @@ -224,6 +224,41 @@ class TestFormatQuotesForPrompt: assert "wahlprogramm" in first assert "parteiprogramm" in first + def test_datum_param_is_passed_through_to_find_relevant_chunks(self, monkeypatch): + """ADR 0013: zeitpunkt-genaue Bewertung. Wenn ``datum`` an + ``get_relevant_quotes_for_antrag`` übergeben wird, muss es + unverändert an ``find_relevant_chunks`` weiterfließen — sonst + zieht die historische Antrags-Bewertung Zitate aus später + veröffentlichten Programmen heran (Anachronismus).""" + captured: list[dict] = [] + + def fake_find_relevant_chunks(query, parteien=None, typ=None, + bundesland=None, top_k=3, + min_similarity=0.5, datum=None): + captured.append({"typ": typ, "datum": datum, "parteien": parteien}) + return [] # leer, wir prüfen nur das Pass-Through + + monkeypatch.setattr(embeddings_mod, "find_relevant_chunks", + fake_find_relevant_chunks) + + get_relevant_quotes_for_antrag( + antrag_text="irgendein Antragstext", + fraktionen=["CDU"], + bundesland="NRW", + top_k_per_partei=2, + datum="2018-09-01", + ) + + assert captured, "find_relevant_chunks should have been called" + for call in captured: + assert call["datum"] == "2018-09-01", \ + f"datum nicht durchgereicht: {call}" + # Wahlprogramm und Parteiprogramm separat aufgerufen + typen = {c["typ"] for c in captured} + assert "wahlprogramm" in typen + assert "parteiprogramm" in typen + + # ───────────────────────────────────────────────────────────────────────────── # reconstruct_zitate — Issue #60 Option B (server-side citation rewrite) # ─────────────────────────────────────────────────────────────────────────────