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:
parent
9169e7699d
commit
7d507f81f4
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user