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",
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,

View File

@ -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:

View File

@ -1801,8 +1801,11 @@ async def run_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 = {

View File

@ -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)

View File

@ -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)
# ─────────────────────────────────────────────────────────────────────────────