feat(analyzer): zeitpunkt-genaue Bewertung — datum-Filter durch Embedding-Suche

Schließt #224. ADR 0013 hat die Datenbasis für historische Bewertung
geschaffen (programme.PROGRAMME mit gueltig_ab/gueltig_bis); jetzt
nutzt der Analyzer sie auch tatsächlich.

Vorher: get_relevant_quotes_for_antrag suchte über ALLE Wahl- und
Grundsatzprogramme einer Partei in einem BL — egal aus welcher WP.
Folge: ein Antrag aus 2018 in NRW konnte Zitate aus dem cdu-nrw-2022
Wahlprogramm (das er noch nicht kennen konnte) zugeordnet bekommen.
Anachronismus-Halluzination.

Nachher: Wenn ``datum`` (ISO YYYY-MM-DD) durchgereicht wird, filtert
``find_relevant_chunks`` die Chunks auf Programme mit
``[gueltig_ab, gueltig_bis)`` ⊇ datum. Programme, die zum
Antragszeitpunkt nicht galten, werden komplett ausgelassen.

Signaturen erweitert (alle additiv, datum=None ⇒ altes Verhalten):
- embeddings.find_relevant_chunks(..., datum=None)
- embeddings.get_relevant_quotes_for_antrag(..., datum=None)
- analyzer.analyze_antrag(..., datum=None)

main.run_drucksache_analysis: reicht doc.datum durch (DIP/OPAL liefern
das Antragsdatum vor dem LLM-Call, kein Zwei-Pass-Workaround nötig).

Tests:
- test_embeddings.test_datum_param_is_passed_through_to_find_relevant_chunks
- test_bug_regressions.test_analyzer_propagates_datum_to_embeddings
1244/1244 Unit-Tests grün.
This commit is contained in:
Dotty Dotter 2026-05-08 22:07:32 +02:00
parent 9169e7699d
commit 7d507f81f4
5 changed files with 158 additions and 7 deletions

View File

@ -302,6 +302,7 @@ async def analyze_antrag(
bundesland: str = "NRW", bundesland: str = "NRW",
model: str = "qwen-plus", model: str = "qwen-plus",
bewerter: Optional[LlmBewerter] = None, bewerter: Optional[LlmBewerter] = None,
datum: Optional[str] = None,
) -> Assessment: ) -> Assessment:
"""Analyze a parliamentary motion using the LLM. """Analyze a parliamentary motion using the LLM.
@ -312,6 +313,11 @@ async def analyze_antrag(
akzeptiert; andere Adapter können eigene Modell-Namen nutzen). akzeptiert; andere Adapter können eigene Modell-Namen nutzen).
bewerter: ``LlmBewerter``-Implementierung. Default: ``QwenBewerter`` bewerter: ``LlmBewerter``-Implementierung. Default: ``QwenBewerter``
(DashScope/Qwen). Tests reichen hier ``FakeLlmBewerter``. (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 Nach ADR 0008: der HTTP-Call samt Retry-Loop lebt im Adapter; hier
bleibt nur noch die Application-Logik (Prompt-Komposition, Semantic- bleibt nur noch die Application-Logik (Prompt-Komposition, Semantic-
@ -344,6 +350,7 @@ async def analyze_antrag(
try: try:
semantic_quotes = get_relevant_quotes_for_antrag( semantic_quotes = get_relevant_quotes_for_antrag(
text, fraktionen, bundesland=bundesland, top_k_per_partei=5, text, fraktionen, bundesland=bundesland, top_k_per_partei=5,
datum=datum,
) )
quotes_context = format_quotes_for_prompt( quotes_context = format_quotes_for_prompt(
semantic_quotes, searched_parties=fraktionen, semantic_quotes, searched_parties=fraktionen,

View File

@ -991,6 +991,7 @@ def find_relevant_chunks(
bundesland: str = None, bundesland: str = None,
top_k: int = 3, top_k: int = 3,
min_similarity: float = 0.5, min_similarity: float = 0.5,
datum: Optional[str] = None,
) -> list[dict]: ) -> list[dict]:
"""Find most relevant chunks for a query. """Find most relevant chunks for a query.
@ -998,6 +999,13 @@ def find_relevant_chunks(
bundesland: Wenn gesetzt, werden nur Chunks dieses Bundeslands ODER bundesland: Wenn gesetzt, werden nur Chunks dieses Bundeslands ODER
globale Chunks (bundesland IS NULL, z.B. Grundsatzprogramme) globale Chunks (bundesland IS NULL, z.B. Grundsatzprogramme)
berücksichtigt. Wenn None, kein Filter. 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 # 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)" sql += " AND (bundesland = ? OR bundesland IS NULL)"
params.append(bundesland) 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() rows = conn.execute(sql, params).fetchall()
conn.close() conn.close()
@ -1055,6 +1085,7 @@ def get_relevant_quotes_for_antrag(
fraktionen: list[str], fraktionen: list[str],
bundesland: str, bundesland: str,
top_k_per_partei: int = 2, top_k_per_partei: int = 2,
datum: Optional[str] = None,
) -> dict[str, list[dict]]: ) -> dict[str, list[dict]]:
"""Get relevant quotes from Wahl- and Parteiprogramme for an Antrag. """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 bundesland: Pflicht. Bestimmt, welche Wahlprogramme durchsucht werden
und welche Regierungsfraktionen zusätzlich zu den Antragstellern und welche Regierungsfraktionen zusätzlich zu den Antragstellern
einbezogen werden. 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 # Lokaler Import vermeidet Zirkularität: bundeslaender.py importiert nichts
# aus diesem Modul, aber der saubere Trennstrich bleibt erhalten. # 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) canonical = normalize_partei(partei, bundesland=bundesland)
partei_lookup = canonical or partei partei_lookup = canonical or partei
# Wahlprogramm — bundesland-gefiltert # Wahlprogramm — bundesland-gefiltert + ggf. zeitpunkt-gefiltert
wahl_chunks = find_relevant_chunks( wahl_chunks = find_relevant_chunks(
antrag_text, antrag_text,
parteien=[partei_lookup], parteien=[partei_lookup],
@ -1095,9 +1131,13 @@ def get_relevant_quotes_for_antrag(
bundesland=bundesland, bundesland=bundesland,
top_k=top_k_per_partei, top_k=top_k_per_partei,
min_similarity=0.35, 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( partei_chunks = find_relevant_chunks(
antrag_text, antrag_text,
parteien=[partei_lookup], parteien=[partei_lookup],
@ -1105,6 +1145,7 @@ def get_relevant_quotes_for_antrag(
bundesland=bundesland, bundesland=bundesland,
top_k=top_k_per_partei, top_k=top_k_per_partei,
min_similarity=0.35, min_similarity=0.35,
datum=datum,
) )
if wahl_chunks or partei_chunks: if wahl_chunks or partei_chunks:

View File

@ -1800,10 +1800,13 @@ async def run_drucksache_analysis(
"""Background task for drucksache analysis.""" """Background task for drucksache analysis."""
try: try:
await update_job(job_id, status="processing") await update_job(job_id, status="processing")
# Run LLM analysis # Antrag-Datum (falls bekannt) für zeitpunkt-gefilterte Embedding-Suche
assessment = await analyze_antrag(text, bundesland, model) # 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 # Prepare data for DB
assessment_data = { assessment_data = {
"drucksache": drucksache, "drucksache": drucksache,

View File

@ -13,6 +13,7 @@ import json
import sqlite3 import sqlite3
import sys import sys
import types import types
from typing import Optional
import pytest import pytest
@ -359,6 +360,70 @@ class TestPflichtFraktionen:
for fraktion in BUNDESLAENDER["NRW"].landtagsfraktionen: for fraktion in BUNDESLAENDER["NRW"].landtagsfraktionen:
assert fraktion in prompt, f"Fraktion {fraktion!r} fehlt im user_prompt" 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) # Bug 5 — NRW-Titel + Regierungsfraktionen im LLM-Prompt (Commit 038ebd6)

View File

@ -196,7 +196,7 @@ class TestFormatQuotesForPrompt:
""" """
def fake_find_relevant_chunks(query, parteien=None, typ=None, def fake_find_relevant_chunks(query, parteien=None, typ=None,
bundesland=None, top_k=3, bundesland=None, top_k=3,
min_similarity=0.5): min_similarity=0.5, datum=None):
return [{ return [{
"programm_id": "gruene-nrw-2022", "programm_id": "gruene-nrw-2022",
"partei": parteien[0] if parteien else "GRÜNE", "partei": parteien[0] if parteien else "GRÜNE",
@ -224,6 +224,41 @@ class TestFormatQuotesForPrompt:
assert "wahlprogramm" in first assert "wahlprogramm" in first
assert "parteiprogramm" 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) # reconstruct_zitate — Issue #60 Option B (server-side citation rewrite)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────