gwoe-antragspruefer/tests/test_embeddings.py

504 lines
22 KiB
Python
Raw Normal View History

Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
"""Tests for embeddings.py prompt formatting.
Reproduces the LLM-Halluzinations-Bug from the 2026-04-08 session
(commits 1b5fd96 + bc7f4a6): the original ``format_quotes_for_prompt``
rendered each chunk as ``- S. X: "text"`` without any reference to the
programme name. As a result the LLM hallucinated familiar source labels
("FDP NRW Wahlprogramm 2022") for chunks that actually came from MV/BE,
because that was the strongest training-set prior for budget-policy
citations.
Fix: prepend the fully-qualified PROGRAMME[programm_id]["name"] to each
quote.
"""
import sys
import types
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
import pytest
Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
# Stub openai before importing embeddings, since the test environment may
# not have it installed and we don't actually need to make API calls.
if "openai" not in sys.modules:
openai_stub = types.ModuleType("openai")
openai_stub.OpenAI = lambda **kw: None
sys.modules["openai"] = openai_stub
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
# On dev machines an older third-party "fitz" package may shadow PyMuPDF's
# legacy import alias — verify the loaded module actually has ``open`` and
# fall back to ``pymupdf`` (the canonical name in PyMuPDF ≥ 1.24) when the
# wrong "fitz" is in front of pymupdf on sys.path.
try:
import fitz as _fitz
if not hasattr(_fitz, "open"):
import pymupdf as _pymupdf
sys.modules["fitz"] = _pymupdf
except ImportError:
try:
import pymupdf as _pymupdf
sys.modules["fitz"] = _pymupdf
except ImportError:
pass # render tests will skip via fixture below
from app import embeddings as embeddings_mod
from app.embeddings import (
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
_chunk_pdf_url,
_chunk_source_label,
#60 Reopen — Option B: server-side reconstruct of zitat quelle/url Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen Halluzinations-Case gezeigt, den A+C nicht gefangen hat: BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen Q-IDs. Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern, solange wir der LLM-Selbstauskunft vertrauen. Fix (Option B aus dem ursprünglichen Plan): `embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im analyzer **nach** json.loads aber **vor** Pydantic-Validation: 1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste. 2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`, identische Logik wie Sub-D Test). 3. Match → quelle/url server-seitig durch _chunk_source_label und _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN. 4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert). Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt keinen Pfad mehr zu "echter Text, falsche quelle". Tests: - TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei hallucinated, no-op bei leeren chunks, anchor-match-Fallback, short-needle und soft-hyphen Edge-Cases - 185/185 grün (179 + 6 neu) Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
find_chunk_for_text,
format_quotes_for_prompt,
get_relevant_quotes_for_antrag,
#60 Reopen — Option B: server-side reconstruct of zitat quelle/url Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen Halluzinations-Case gezeigt, den A+C nicht gefangen hat: BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen Q-IDs. Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern, solange wir der LLM-Selbstauskunft vertrauen. Fix (Option B aus dem ursprünglichen Plan): `embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im analyzer **nach** json.loads aber **vor** Pydantic-Validation: 1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste. 2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`, identische Logik wie Sub-D Test). 3. Match → quelle/url server-seitig durch _chunk_source_label und _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN. 4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert). Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt keinen Pfad mehr zu "echter Text, falsche quelle". Tests: - TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei hallucinated, no-op bei leeren chunks, anchor-match-Fallback, short-needle und soft-hyphen Edge-Cases - 185/185 grün (179 + 6 neu) Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
reconstruct_zitate,
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
render_highlighted_page,
PROGRAMME,
)
Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
# ─────────────────────────────────────────────────────────────────────────────
# _chunk_source_label — fully-qualified programme name + page
# ─────────────────────────────────────────────────────────────────────────────
class TestChunkSourceLabel:
def test_known_programme_id(self):
chunk = {"programm_id": "fdp-mv-2021", "seite": 73, "text": "..."}
label = _chunk_source_label(chunk)
assert "FDP Mecklenburg-Vorpommern" in label
assert "S. 73" in label
def test_known_programme_id_for_be(self):
chunk = {"programm_id": "spd-be-2023", "seite": 24, "text": "..."}
label = _chunk_source_label(chunk)
assert "SPD Berlin" in label
assert "2021" in label # the BE-2023.pdf files contain 2021er programmes
assert "S. 24" in label
def test_unknown_programme_id_falls_back_to_id(self):
chunk = {"programm_id": "fake-xx-9999", "seite": 1, "text": "..."}
label = _chunk_source_label(chunk)
# Should not crash, should at least include the id and the page
assert "fake-xx-9999" in label
assert "S. 1" in label
def test_missing_seite_uses_questionmark(self):
chunk = {"programm_id": "cdu-mv-2021", "text": "..."}
label = _chunk_source_label(chunk)
assert "?" in label
# ─────────────────────────────────────────────────────────────────────────────
# format_quotes_for_prompt — every chunk must carry programme identification
# ─────────────────────────────────────────────────────────────────────────────
EXAMPLE_QUOTES = {
"FDP": {
"wahlprogramm": [
{
"programm_id": "fdp-mv-2021",
"partei": "FDP",
"typ": "wahlprogramm",
"seite": 73,
"text": "Die Grundsätze von Wirtschaftlichkeit und Sparsamkeit",
"similarity": 0.63,
},
],
"parteiprogramm": [
{
"programm_id": "fdp-grundsatz",
"partei": "FDP",
"typ": "parteiprogramm",
"seite": 93,
"text": "Liberale Marktwirtschaft erfordert solide Haushalte",
"similarity": 0.60,
},
],
},
"SPD": {
"wahlprogramm": [
{
"programm_id": "spd-mv-2021",
"partei": "SPD",
"typ": "wahlprogramm",
"seite": 22,
"text": "Verkehrswende weg vom motorisierten Individualverkehr",
"similarity": 0.58,
},
],
},
}
class TestFormatQuotesForPrompt:
def test_empty_input_returns_empty_string(self):
assert format_quotes_for_prompt({}) == ""
def test_renders_party_headings(self):
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
assert "### FDP" in out
assert "### SPD" in out
def test_every_chunk_has_programme_name(self):
"""Regression: pre-fix this used "S. X:" only, no programme name —
the LLM then hallucinated NRW-2022 sources from training data."""
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
# Each of the three chunks must reference its source programme
assert "FDP Mecklenburg-Vorpommern" in out
assert "FDP Grundsatzprogramm" in out
assert "SPD Mecklenburg-Vorpommern" in out
def test_contains_strict_citation_instruction(self):
"""The prompt header must explicitly forbid hallucinated sources."""
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
#60 Fix A+C: ENUM-basiertes Zitieren + top_k 2→5 Strukturelle Lösung für die LLM-Halluzinations-Cases aus #60: A — ENUM-Anker - format_quotes_for_prompt nummeriert jeden retrievten Chunk als [Q1], [Q2], … - Neue ZITATEREGEL im Prompt erzwingt vier Bedingungen: 1. Jedes Zitat MUSS auf genau einen [Qn]-Chunk verweisen 2. Der text-String MUSS eine wörtliche, zusammenhängende Passage von min. 5 Wörtern aus genau diesem Chunk sein 3. Die quelle MUSS exakt das Source-Label des gewählten Chunks sein 4. Wenn kein Chunk passt: leeres zitate-Array — lieber 0 als erfunden - analyzer.py:get_system_prompt: Wichtige-Regeln-Block zieht den selben Mechanismus nach, damit das LLM den [Qn]-Anker auch im System-Prompt sieht und nicht nur im User-Prompt. C — Recall-Boost - analyzer.py:run_analysis: top_k_per_partei 2 → 5. In den drei Cases aus #60 lagen die "richtigen" Seiten (S.36, S.37) bisher außerhalb des Top-3-Windows; mit Top-5 erhöht sich die Wahrscheinlichkeit, dass sie überhaupt im Kontext landen. Hintergrund — die Halluzinationen waren KEIN Embedding-Bug: Die retrievten Chunks für Case 1 enthielten S.58 (richtige Seite, falscher Snippet) — das LLM hat den Snippet aus seinem Trainingswissen über GRÜNE-Wahlprogramme rekonstruiert statt aus dem retrievten Chunk-Text zu zitieren. Cases 2/3 hatten die zitierten Seiten gar nicht im Top-3-Window — das LLM hat sowohl Seite als auch Snippet halluziniert. ENUM-Anker verhindert beides strukturell, weil ein nicht-existenter [Qn] sofort als Cheating sichtbar wäre. Tests: - test_chunks_get_enum_ids - test_zitateregel_mentions_enum_anchor - 179/179 grün Refs: #60, #54 (Sub-D), #50 (Umbrella E2E)
2026-04-09 22:21:39 +02:00
assert "wörtlich" in out.lower()
def test_chunks_get_enum_ids(self):
"""Issue #60 fix: each chunk must be tagged with a stable [Qn] id
so the LLM can be forced to anchor every citation in a specific
retrieved chunk instead of inventing snippets from training data.
"""
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
# 2 wahlprogramm chunks + 1 grundsatz chunk = 3 IDs total
assert "[Q1]" in out
assert "[Q2]" in out
assert "[Q3]" in out
assert "[Q4]" not in out # only 3 chunks in EXAMPLE_QUOTES
def test_zitateregel_mentions_enum_anchor(self):
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
# The prompt header must mention the ENUM anchor mechanism so
# the LLM understands what [Qn] means.
assert "[Q" in out
assert "ZITATEREGEL" in out
Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
def test_no_nrw_2022_appears_unless_chunks_are_actually_nrw(self):
"""Sanity: a pure MV+SPD chunk set must not mention NRW anywhere."""
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
assert "NRW" not in out
assert "Nordrhein-Westfalen" not in out
def test_renders_separate_blocks_for_wahl_and_parteiprogramm(self):
out = format_quotes_for_prompt(EXAMPLE_QUOTES)
assert "**Wahlprogramm:**" in out
assert "**Grundsatzprogramm:**" in out
def test_get_relevant_quotes_for_antrag_populates_results(self, monkeypatch):
"""Regression for the partei_upper NameError (Phase B / #55 / eb045d0):
The dict-write line still referenced ``partei_upper`` after the
rest of the function had been renamed to ``partei_lookup``. The
result was that ``get_relevant_quotes_for_antrag`` raised
``NameError`` on every call, was silently swallowed by the
``except Exception`` in ``analyzer.run_analysis``, and silently
downgraded *every* assessment to keyword search which then
caused the LLM hallucinations tracked in #60.
Test strategy: monkeypatch ``find_relevant_chunks`` so we don't
need real embeddings, then call the wrapper and assert it
actually returns a populated dict instead of crashing.
"""
def fake_find_relevant_chunks(query, parteien=None, typ=None,
bundesland=None, top_k=3,
min_similarity=0.5):
return [{
"programm_id": "gruene-nrw-2022",
"partei": parteien[0] if parteien else "GRÜNE",
"typ": typ or "wahlprogramm",
"seite": 58,
"text": "Wahlalter ab 16",
"similarity": 0.7,
}]
monkeypatch.setattr(embeddings_mod, "find_relevant_chunks",
fake_find_relevant_chunks)
result = get_relevant_quotes_for_antrag(
antrag_text="Wahlalter ab 16",
fraktionen=["GRÜNE"],
bundesland="NRW",
top_k_per_partei=2,
)
assert result, "Expected a non-empty result dict, got empty"
# The keys are canonical party names; either GRÜNE itself or
# whatever the canonical mapper returns for it.
assert any("GR" in k.upper() for k in result.keys())
# And the structure must be the {wahlprogramm, parteiprogramm} dict
first = next(iter(result.values()))
assert "wahlprogramm" in first
assert "parteiprogramm" in first
#60 Reopen — Option B: server-side reconstruct of zitat quelle/url Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen Halluzinations-Case gezeigt, den A+C nicht gefangen hat: BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen Q-IDs. Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern, solange wir der LLM-Selbstauskunft vertrauen. Fix (Option B aus dem ursprünglichen Plan): `embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im analyzer **nach** json.loads aber **vor** Pydantic-Validation: 1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste. 2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`, identische Logik wie Sub-D Test). 3. Match → quelle/url server-seitig durch _chunk_source_label und _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN. 4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert). Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt keinen Pfad mehr zu "echter Text, falsche quelle". Tests: - TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei hallucinated, no-op bei leeren chunks, anchor-match-Fallback, short-needle und soft-hyphen Edge-Cases - 185/185 grün (179 + 6 neu) Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
# ─────────────────────────────────────────────────────────────────────────────
# reconstruct_zitate — Issue #60 Option B (server-side citation rewrite)
# ─────────────────────────────────────────────────────────────────────────────
class TestReconstructZitate:
"""Verify the post-processing pass that overwrites LLM-emitted quelle/url
with the canonical source label of whichever retrieved chunk actually
contains the cited text. Drops zitate that don't match any chunk.
Background: BB 8/673 (Sub-D run after the A+C deploy) showed the LLM
copying real text from one chunk but writing the page number from a
different chunk into ``quelle``. The ENUM-anchor in the prompt is only
a soft hint; this post-processing step is the structural binding.
"""
def _make_chunk(self, programm_id: str, seite: int, text: str) -> dict:
return {
"programm_id": programm_id,
"partei": programm_id.split("-")[0].upper(),
"typ": "wahlprogramm",
"seite": seite,
"text": text,
"similarity": 0.7,
}
def test_overwrites_wrong_seite_with_real_chunk_seite(self):
"""The BB 8/673 case: LLM cites text from S.27 chunk but writes
S.4 in quelle. After reconstruct_zitate the quelle must point to
the real S.27 chunk."""
real_chunk = self._make_chunk(
"bsw-bb-2024", 27,
"wertschätzung für lehrerinnen und lehrer abbau von arbeitsüberlastung",
)
wrong_chunk = self._make_chunk(
"bsw-bb-2024", 4,
"in brandenburg weniger als 14 euro in der stunde verdient",
)
semantic_quotes = {
"BSW": {"wahlprogramm": [wrong_chunk, real_chunk], "parteiprogramm": []},
}
data = {
"wahlprogrammScores": [{
"fraktion": "BSW",
"wahlprogramm": {
"score": 7,
"begründung": "...",
"zitate": [{
"text": "Wertschätzung für Lehrerinnen und Lehrer Abbau von Arbeitsüberlastung",
"quelle": "BSW Brandenburg Wahlprogramm 2024, S. 4", # WRONG
"url": "/static/referenzen/bsw-bb-2024.pdf#page=4",
}],
},
"parteiprogramm": {"score": 0, "begründung": "...", "zitate": []},
}],
}
out = reconstruct_zitate(data, semantic_quotes)
z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0]
assert z["quelle"] == "BSW Brandenburg Wahlprogramm 2024, S. 27"
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
# Post-#47: URL ist der Highlight-Cite-Endpoint mit pid+seite+q.
# Static-Fallback nur noch wenn der Chunk kein text-Feld hat.
assert z["url"].startswith("/api/wahlprogramm-cite?")
assert "pid=bsw-bb-2024" in z["url"]
assert "seite=27" in z["url"]
#60 Reopen — Option B: server-side reconstruct of zitat quelle/url Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen Halluzinations-Case gezeigt, den A+C nicht gefangen hat: BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen Q-IDs. Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern, solange wir der LLM-Selbstauskunft vertrauen. Fix (Option B aus dem ursprünglichen Plan): `embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im analyzer **nach** json.loads aber **vor** Pydantic-Validation: 1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste. 2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`, identische Logik wie Sub-D Test). 3. Match → quelle/url server-seitig durch _chunk_source_label und _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN. 4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert). Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt keinen Pfad mehr zu "echter Text, falsche quelle". Tests: - TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei hallucinated, no-op bei leeren chunks, anchor-match-Fallback, short-needle und soft-hyphen Edge-Cases - 185/185 grün (179 + 6 neu) Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
def test_drops_zitat_not_found_in_any_chunk(self):
"""If a snippet was hallucinated entirely (no matching chunk),
the zitat must be removed rather than persisted."""
chunk = self._make_chunk(
"spd-lsa-2021", 41,
"die stärkung einer geschlechtersensiblen berufsorientierung",
)
semantic_quotes = {
"SPD": {"wahlprogramm": [chunk], "parteiprogramm": []},
}
data = {
"wahlprogrammScores": [{
"fraktion": "SPD",
"wahlprogramm": {
"score": 7,
"begründung": "...",
"zitate": [
{"text": "Wir Sozialdemokratinnen ächten Rechtsextremismus seit 1863",
"quelle": "SPD Sachsen-Anhalt 2021, S. 37"},
{"text": "die Stärkung einer geschlechtersensiblen Berufsorientierung",
"quelle": "SPD Sachsen-Anhalt 2021, S. 41"},
],
},
"parteiprogramm": {"score": 0, "begründung": "...", "zitate": []},
}],
}
out = reconstruct_zitate(data, semantic_quotes)
zitate = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"]
# Beide Zitate bleiben erhalten — das nicht-matchende wird als
# unverified markiert statt gedroppt (Hybrid-Ansatz).
assert len(zitate) == 2
# Das halluzinierte Zitat ist unverified
halluziniert = [z for z in zitate if "Rechtsextremismus" in z["text"]]
assert halluziniert[0]["verified"] is False
# Das echte Zitat ist verified
echt = [z for z in zitate if "geschlechtersensiblen" in z["text"]]
assert echt[0]["verified"] is True
#60 Reopen — Option B: server-side reconstruct of zitat quelle/url Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen Halluzinations-Case gezeigt, den A+C nicht gefangen hat: BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen Q-IDs. Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern, solange wir der LLM-Selbstauskunft vertrauen. Fix (Option B aus dem ursprünglichen Plan): `embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im analyzer **nach** json.loads aber **vor** Pydantic-Validation: 1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste. 2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`, identische Logik wie Sub-D Test). 3. Match → quelle/url server-seitig durch _chunk_source_label und _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN. 4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert). Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt keinen Pfad mehr zu "echter Text, falsche quelle". Tests: - TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei hallucinated, no-op bei leeren chunks, anchor-match-Fallback, short-needle und soft-hyphen Edge-Cases - 185/185 grün (179 + 6 neu) Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
def test_empty_semantic_quotes_is_noop(self):
data = {"wahlprogrammScores": [{
"fraktion": "X",
"wahlprogramm": {"score": 5, "begründung": "x",
"zitate": [{"text": "abc def ghi jkl mno pqr", "quelle": "X"}]},
"parteiprogramm": {"score": 0, "begründung": "x", "zitate": []},
}]}
out = reconstruct_zitate(data, {})
# No chunks → no postprocessing applied; data passes through unchanged
assert out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0]["quelle"] == "X"
def test_anchor_match_when_full_substring_misses(self):
"""LLM may slightly truncate a snippet — 5-word-anchor still binds."""
chunk = self._make_chunk(
"cdu-nrw-2022", 24,
"wir wollen interprofessionelle netzwerkstrukturen für kinderschutz fördern dazu werden wir stellen schaffen",
)
semantic_quotes = {"CDU": {"wahlprogramm": [chunk], "parteiprogramm": []}}
data = {"wahlprogrammScores": [{
"fraktion": "CDU",
"wahlprogramm": {
"score": 8, "begründung": "...",
"zitate": [{
"text": "Wir wollen interprofessionelle Netzwerkstrukturen für Kinderschutz fördern",
"quelle": "CDU NRW Wahlprogramm 2022, S. 999", # wrong page
}],
},
"parteiprogramm": {"score": 0, "begründung": "...", "zitate": []},
}]}
out = reconstruct_zitate(data, semantic_quotes)
z = out["wahlprogrammScores"][0]["wahlprogramm"]["zitate"][0]
assert z["quelle"] == "CDU NRW Wahlprogramm 2022, S. 24"
def test_find_chunk_for_text_short_needle_returns_none(self):
chunk = self._make_chunk("x", 1, "egal was hier steht")
assert find_chunk_for_text("ja", [chunk]) is None
def test_find_chunk_for_text_handles_soft_hyphen(self):
chunk = self._make_chunk(
"bsw-bb-2024", 27,
"handys und tablets wertschätzung für lehrerinnen und lehrer",
)
# LLM-emitted text with the soft hyphen \xad mid-word, as PyMuPDF
# would extract from a PDF line break.
text = "Handys und Tablets. Wertschätzung für Lehrerinnen und Lehrer"
assert find_chunk_for_text(text, [chunk]) is chunk
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
# ─────────────────────────────────────────────────────────────────────────────
# _chunk_pdf_url + render_highlighted_page — Issue #47 PDF-Highlighting
# ─────────────────────────────────────────────────────────────────────────────
class TestChunkPdfUrl:
"""Verify the URL builder switches between the cite-endpoint (when
chunk text is present) and the static fallback (Pre-#47 chunks).
"""
def test_cite_url_when_text_present(self):
chunk = {
"programm_id": "gruene-grundsatz",
"seite": 36,
"text": "Plattformen müssen umfassend reguliert werden",
}
url = _chunk_pdf_url(chunk)
assert url is not None
assert url.startswith("/api/wahlprogramm-cite?")
assert "pid=gruene-grundsatz" in url
assert "seite=36" in url
# URL-encoded query (urlencode/quote_plus uses + for space)
assert "Plattformen" in url
def test_static_fallback_when_no_text(self):
chunk = {"programm_id": "fdp-mv-2021", "seite": 73}
url = _chunk_pdf_url(chunk)
assert url == "/static/referenzen/fdp-mv-2021.pdf#page=73"
def test_unknown_programme_returns_none(self):
chunk = {"programm_id": "fake-xx-9999", "seite": 1, "text": "x" * 50}
assert _chunk_pdf_url(chunk) is None
def test_url_truncates_long_text_to_200_chars(self):
chunk = {
"programm_id": "gruene-grundsatz",
"seite": 36,
"text": "A" * 1000,
}
url = _chunk_pdf_url(chunk)
assert url is not None
# Eingebettete Text-Länge ist auf 200 Zeichen begrenzt — sonst
# blasen 500-Zeichen-Snippets das Assessment-JSON auf.
# Der `q=`-Parameter darf nicht 1000 'A' enthalten.
assert "A" * 1000 not in url
assert "A" * 200 in url
class TestRenderHighlightedPage:
"""Smoke-Test gegen ein reales Wahlprogramm-PDF aus dem
referenzen-Verzeichnis. Bestätigt dass PyMuPDF einen 1-Seiten-PDF
mit Highlight-Annotation produziert. Skipped wenn das Test-PDF
nicht im Repo vorhanden ist.
"""
@pytest.fixture
def sample_pid(self):
# Wir nehmen einen kleinen, sicher vorhandenen Eintrag aus PROGRAMME.
# spd-grundsatz ist seit Tag 1 indexiert und im Repo committed.
from pathlib import Path
from app.embeddings import PROGRAMME
pid = "spd-grundsatz"
info = PROGRAMME.get(pid)
if not info:
pytest.skip("PROGRAMME registry missing spd-grundsatz")
path = Path(__file__).parent.parent / "app" / "static" / "referenzen" / info["pdf"]
if not path.exists():
pytest.skip(f"Test-PDF {path} nicht im Repo")
return pid
def test_unknown_pid_returns_none(self):
pdf_bytes, page, highlighted = render_highlighted_page("fake-xx-9999", 1, "x")
assert pdf_bytes is None
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
def test_invalid_seite_returns_none(self, sample_pid):
pdf_bytes, _, _ = render_highlighted_page(sample_pid, 99999, "x")
assert pdf_bytes is None
pdf_bytes2, _, _ = render_highlighted_page(sample_pid, 0, "x")
assert pdf_bytes2 is None
def test_renders_full_pdf_with_highlight(self, sample_pid):
pdf_bytes, found_page, highlighted = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit")
assert pdf_bytes is not None
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b"%PDF-"
assert found_page >= 1
assert highlighted is True
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
def test_returns_pdf_even_when_query_empty(self, sample_pid):
pdf_bytes, _, highlighted = render_highlighted_page(sample_pid, 1, "")
assert pdf_bytes is not None
assert pdf_bytes[:5] == b"%PDF-"
assert highlighted is False
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
def test_returns_pdf_when_query_not_found_flagged_unhighlighted(self, sample_pid):
pdf_bytes, _, highlighted = render_highlighted_page(
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
sample_pid, 1, "this exact phrase definitely does not exist anywhere",
)
assert pdf_bytes is not None
assert pdf_bytes[:5] == b"%PDF-"
assert highlighted is False
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
def test_format_quotes_truncates_long_chunks_at_500_chars():
"""Truncation-Test for format_quotes_for_prompt — sat lange als
Methode in TestRenderHighlightedPage (falsche Class-Zuordnung
durch Edit-Reihenfolge), jetzt module-level."""
long_chunk = {
"FDP": {
"wahlprogramm": [
{
"programm_id": "fdp-mv-2021",
"seite": 1,
"text": "A" * 1000, # 1000 chars → should be truncated
"similarity": 0.7,
}
],
Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
}
#47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF- Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum Page-Anchor zu springen und den Leser selbst suchen zu lassen. Implementation: embeddings.render_highlighted_page(programm_id, seite, query) - Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz) - Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die angeforderte Seite in einen neuen Document → kleinere Response - search_for(query[:200]) → Bounding-Boxes aller Treffer - Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation, identisch zu find_chunk_for_text/Sub-D-Logik) - add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0) - Returns serialisierte PDF-Bytes oder None embeddings._chunk_pdf_url - Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL mit pid=, seite=, q=urlencoded(text[:200]) - Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47 rückwärts-kompatibel) - text wird auf 200 Zeichen abgeschnitten, sonst blasen 500-Zeichen-Snippets jedes Assessment-JSON auf main.py /api/wahlprogramm-cite Endpoint - Validiert pid gegen PROGRAMME registry - seite: 1 ≤ n ≤ 2000 - Response: application/pdf, Cache-Control max-age=86400 - 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch: reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate selbst nötig. Tests: 194/194 grün (185 + 9 neue): - TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate) - TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid render, empty query, query-not-found-falls-back-zu-leerem-Highlight) - Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor) Refs: #47
2026-04-10 01:09:45 +02:00
}
out = format_quotes_for_prompt(long_chunk)
# Truncation marker
assert "..." in out
# Original chunk text 1000 chars not present in full
assert "A" * 1000 not in out