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
|
|
|
|
|
|
2026-04-09 21:57:56 +02:00
|
|
|
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,
|
2026-04-09 21:57:56 +02:00
|
|
|
_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,
|
2026-04-09 21:57:56 +02:00
|
|
|
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,
|
2026-04-09 21:57:56 +02:00
|
|
|
)
|
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
|
|
|
|
|
|
2026-04-09 21:57:56 +02:00
|
|
|
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"]
|
|
|
|
|
assert len(zitate) == 1
|
|
|
|
|
assert "geschlechtersensiblen" in zitate[0]["text"]
|
|
|
|
|
|
|
|
|
|
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):
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
pdf_bytes, page, highlighted = render_highlighted_page("fake-xx-9999", 1, "x")
|
2026-04-10 10:16:00 +02:00
|
|
|
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):
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
pdf_bytes, _, _ = render_highlighted_page(sample_pid, 99999, "x")
|
2026-04-10 10:16:00 +02:00
|
|
|
assert pdf_bytes is None
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
pdf_bytes2, _, _ = render_highlighted_page(sample_pid, 0, "x")
|
2026-04-10 10:16:00 +02:00
|
|
|
assert pdf_bytes2 is None
|
|
|
|
|
|
|
|
|
|
def test_renders_full_pdf_with_highlight(self, sample_pid):
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
pdf_bytes, found_page, highlighted = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit")
|
2026-04-10 10:16:00 +02:00
|
|
|
assert pdf_bytes is not None
|
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
|
|
|
assert pdf_bytes[:5] == b"%PDF-"
|
|
|
|
|
assert found_page >= 1
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
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):
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
pdf_bytes, _, highlighted = render_highlighted_page(sample_pid, 1, "")
|
2026-04-10 10:16:00 +02:00
|
|
|
assert pdf_bytes is not None
|
|
|
|
|
assert pdf_bytes[:5] == b"%PDF-"
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
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
|
|
|
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +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",
|
|
|
|
|
)
|
2026-04-10 10:16:00 +02:00
|
|
|
assert pdf_bytes is not None
|
|
|
|
|
assert pdf_bytes[:5] == b"%PDF-"
|
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
|
|
|
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
|