diff --git a/generative/orchestrator.py b/generative/orchestrator.py index 95ed8cc..ab3d22e 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -110,6 +110,7 @@ def _reconfigure_streams_utf8() -> None: MAX_PAGES_SHORT_DOC, REDUNDANT_SIBLING_COSINE_THRESHOLD, ENABLE_FAITHFULNESS_GATE, + TITLE_PRESENCE_COSINE_THRESHOLD, is_maintainer, ) from generative.runtime_config import ( @@ -190,6 +191,7 @@ async def run_extractors_per_concept( background_map: dict[str, list[str]] | None = None, related_mentions: list[str] | None = None, max_concurrent_calls: int | None = None, + semantic_window_fn=None, ) -> tuple[list[AtomicNoteDraft], dict, int, list[tuple[str, str]]]: """Pro Konzept ein Extractor-Call mit den relevanten Textstellen aus ALLEN Chunks. @@ -197,6 +199,15 @@ async def run_extractors_per_concept( werden vor dem LLM-Call verworfen (zusätzlicher Halluzinations-Schutz neben planner.filter_hallucinated). + #127: Bleibt der lexikalische Fenster-Scan (`pdf_chunker.concept_text_window`) + leer, wird VOR dem endgültigen [skip] ein semantischer Fallback versucht + (`pdf_chunker.semantic_concept_window` per Default) — derselbe cross-linguale + Rettungskanal wie `planner.filter_hallucinated` (#66), hier auf Fenster- statt + Satz-Ebene, weil das Ergebnis direkt als Extraktor-Kontext dient. Fail-closed: + rettet nur bei Cosine ≥ TITLE_PRESENCE_COSINE_THRESHOLD, sonst bleibt der Skip. + `semantic_window_fn(full_text, title) -> (fenster, cosine)` ist injizierbar + (deterministische Tests ohne ML-Modell-Load, vgl. filter_hallucinated). + #308: Bleibt ein Konzept nach Erst-Call UND #280-Retry (beide auf dem 400- Wort-Fenster) leer, folgt genau EIN Rescue-Versuch mit deutlich größerem Fenster (_RESCUE_WINDOW_WORDS) statt eines weiteren Retries auf demselben @@ -256,6 +267,10 @@ def _search_terms(c) -> list[str]: terms.extend(t for t in _tokens(c.title) if len(t) >= 4) return terms + sem_window_fn = semantic_window_fn or ( + lambda text, title: pdf_chunker.semantic_concept_window(text, title, TITLE_PRESENCE_COSINE_THRESHOLD) + ) + tasks: list = [] concept_for_idx: list = [] # parallele Liste für besseres Logging contexts: list = [] # parallele Liste mit (concept, ctext) für concept_map @@ -266,13 +281,25 @@ def _search_terms(c) -> list[str]: # Größe für Sliding-Window-Scoring), nicht mehr ±expansion wie vor 2026-05-17. ctext = pdf_chunker.concept_text_window(full_text, _search_terms(c), window_words=400) if not ctext.strip(): - print(f" [skip] '{c.title}' nicht im Volltext gefunden (Halluzinations-Schutz)", file=sys.stderr) - # #197 Nachbesserung: bisher stummer Pre-Call-Drop (Konzept nicht im - # Volltext) → Funnel-Event. Konfliktfrei zu #216 (das diesen Block nicht anfasst). - _trace_stage_outcome( - c.title, "extractor", "dropped", drop_reason="empty_extraction", detail="not in fulltext" - ) - continue + # #127: lexikalischer Scan ist sprachblind (deutscher Planner-Titel + # auf englischer Quelle -> 0 Token-Overlap) -- VOR dem endgültigen + # Skip denselben semantischen Rettungskanal wie #66 anbieten. + rescued_ctext, rescue_score = sem_window_fn(full_text, c.title) + if rescued_ctext.strip(): + print( + f" [semantic-window-fallback] '{c.title}' lexikalisch nicht gefunden, " + f"semantisch gerettet (cosine={rescue_score:.2f})", + file=sys.stderr, + ) + ctext = rescued_ctext + else: + print(f" [skip] '{c.title}' nicht im Volltext gefunden (Halluzinations-Schutz)", file=sys.stderr) + # #197 Nachbesserung: bisher stummer Pre-Call-Drop (Konzept nicht im + # Volltext) → Funnel-Event. Konfliktfrei zu #216 (das diesen Block nicht anfasst). + _trace_stage_outcome( + c.title, "extractor", "dropped", drop_reason="empty_extraction", detail="not in fulltext" + ) + continue tasks.append(_run_with_sem(c, ctext)) concept_for_idx.append(c.title) contexts.append((c, ctext)) diff --git a/generative/pipeline/pdf_chunker.py b/generative/pipeline/pdf_chunker.py index 602d36f..705f005 100644 --- a/generative/pipeline/pdf_chunker.py +++ b/generative/pipeline/pdf_chunker.py @@ -341,6 +341,182 @@ def assess_text_quality(text: str) -> TextQuality: ) +def _iter_word_windows(n_words: int, window_words: int, stride: int | None = None): + """Wort-Index-Fenster `(start, end)`, 50%-Stride per Default. + + Geteilte Sliding-Window-Geometrie zwischen `concept_text_window`s + lexikalischem Scorer und dem #127-Fallback (`semantic_concept_window`) — + beide sollen exakt dieselben Fenstergrenzen sehen, kein zweites + Chunking-Schema für denselben Volltext. + """ + stride = stride if stride is not None else max(1, window_words // 2) + start = 0 + while start < n_words: + end = min(start + window_words, n_words) + yield start, end + if end >= n_words: + break + start += stride + + +def _page_at_word_map(full_text: str) -> list[str | None]: + """Seiten-Marker (`[S. N]`) pro Wort-Index — Vorarbeit für den Snippet-Bau + in `concept_text_window` UND `semantic_concept_window` (#127). Nur + line-isolierte Pipeline-Marker (`\\n\\n[S. N]\\n\\n` aus pages_to_marked_text) + zählen als Seitenanfang — Inline-Quellenverweise im Fließtext nicht (siehe + `concept_text_window`-Docstring für die volle Begründung).""" + _real_markers = [(m.start(), m.group(1)) for m in re.finditer(r"(?m)^[ \t]*\[S\.\s*(\d+)\][ \t]*$", full_text)] + page_at_word: list[str | None] = [] + _cur_page: str | None = None + _mi = 0 + for _tok in re.finditer(r"\S+", full_text): + while _mi < len(_real_markers) and _real_markers[_mi][0] <= _tok.start(): + _cur_page = _real_markers[_mi][1] + _mi += 1 + page_at_word.append(_cur_page) + return page_at_word + + +def _prefix_page_marker(snippet: str, page: str | None) -> str: + """Stellt einem markerlosen Snippet seinen gültigen Seitenmarker voran + (s. `_page_at_word_map`) — sonst erbt die Downstream-Seitenableitung + (Extractor-LLM, Verifier, Renderer) die Seite eines früheren Snippets.""" + if page is not None and not snippet.lstrip().startswith("[S."): + return f"[S. {page}] {snippet}" + return snippet + + +# #127: Satz-Embeddings pro (Volltext, window_words) gecacht — mehrere in +# Stage 5 lexikalisch leer gebliebene Konzepte DESSELBEN Laufs (z.B. alle drei +# Top-Konzepte eines fast-Profils) teilen sich EIN Batch-Encode statt es pro +# Konzept zu wiederholen. Gleiches Muster wie planner._SENT_EMB_CACHE. +_WINDOW_SENT_CACHE: dict[tuple[int, int, int], tuple] = {} +_WINDOW_SENT_CACHE_MAX = 8 + + +def _window_sentence_embeddings(full_text: str, words: list[str], window_words: int): + """(spans, window_texts, sentences, sentence_embeddings) für die + Sliding-Window-Fenster von `words`. + + Satz-Ebene statt Fenster-Mittelwert: Kalibrierung #127 zeigt, dass + Mean-Pooling über ein ganzes 400-Wort-Fenster den Score eines einzelnen + treffenden Satzes verwässert (Fenster enthält 10-15 Sätze, nur einer + trägt den Konzeptbezug) — ein reales Rettungsfall-Beispiel („Andragogik“ + auf der Knowles-Quelle) lag im Fenster-Mittel bei 0.43, auf Satzebene aber + deutlich über der Schwelle. Damit bleibt `TITLE_PRESENCE_COSINE_THRESHOLD` + (kalibriert für genau diese Satz-MAX-Methodik in + `planner._default_semantic_presence`) gültig wiederverwendbar. + + Sätze aus überlappenden Fenstern (50%-Stride) werden dedupliziert (Wert = + Text), bevor EIN `model.encode()`-Call über alle eindeutigen Sätze läuft + — sonst würde der Overlap-Bereich doppelt encodet. + + Wirft bei fehlendem/kaputtem Embedding-Modell — Aufrufer fängt ab + (fail-closed, #127 ist ein reiner Rettungskanal, keine Pflichtstufe). + """ + key = (len(full_text), hash(full_text), window_words) + cached = _WINDOW_SENT_CACHE.get(key) + if cached is not None: + return cached + + from generative.embeddings import _sentences, _model + import numpy as np + + spans = list(_iter_word_windows(len(words), window_words)) + window_texts = [" ".join(words[s:e]) for s, e in spans] + + uniq_sentences: list[str] = [] + seen: set[str] = set() + for wtext in window_texts: + for s2 in _sentences(wtext): + if len(s2) > 15 and s2 not in seen: + seen.add(s2) + uniq_sentences.append(s2) + + if uniq_sentences: + sent_embs = np.asarray( + _model().encode(uniq_sentences, show_progress_bar=False, normalize_embeddings=True, batch_size=64) + ) + else: + sent_embs = np.zeros((0, _model().get_sentence_embedding_dimension())) + + result = (spans, window_texts, uniq_sentences, sent_embs) + if len(_WINDOW_SENT_CACHE) >= _WINDOW_SENT_CACHE_MAX: + _WINDOW_SENT_CACHE.pop(next(iter(_WINDOW_SENT_CACHE))) + _WINDOW_SENT_CACHE[key] = result + return result + + +def semantic_concept_window( + full_text: str, + title: str, + threshold: float, + window_words: int = 400, + max_chars: int = 8000, +) -> tuple[str, float]: + """#127: semantischer Fallback für `concept_text_window()` bei lexikalisch + leerem Ergebnis. + + Der Stage-5-Skip (orchestrator.run_extractors_per_concept) ist rein + lexikalisch und sprachblind: ein deutscher Planner-Titel auf einer + englischen Quelle hat 0 Token-Overlap, obwohl dasselbe Konzept + `planner.filter_hallucinated`s #66-Rettungsanker (semantische Präsenz, + multilinguales MiniLM) bereits passiert haben kann — die beiden Checks + laufen auf demselben Volltext, aber nur Stage 4 hat einen semantischen + Kanal. Dieser Fallback bietet denselben Kanal auch hier an: MAX-Cosine + zwischen Titel-Embedding und Satz-Embeddings (multilinguales MiniLM, + `generative.embeddings`) — exakt dieselbe Methodik + Schwelle + (`TITLE_PRESENCE_COSINE_THRESHOLD`) wie #66, damit der kalibrierte Wert + gültig bleibt (siehe `_window_sentence_embeddings`-Docstring für den + Kalibrierungsbefund, der Fenster-Mittelwert statt Satz-MAX verwarf). + Das Ergebnisfenster für den Extraktor-Kontext ist trotzdem eines der + SELBEN Sliding-Window-Fenster wie der lexikalische Scorer + (`_iter_word_windows`) — kein zweites Chunking-Schema; es wird das erste + Fenster (Dokumentreihenfolge) gewählt, das den Treffer-Satz enthält. + + Anders als `_default_semantic_presence` (reiner OR-Kanal in Stage 4, + fail-OPEN) muss dieser Fallback FAIL-CLOSED sein: das Ergebnis wird + direkt als Extraktor-Kontext weiterverwendet, nicht nur als binäres + Keep/Reject-Signal — ein Encoding-Fehler darf keinen unkontrollierten + Volltext-Ausschnitt durchreichen. + + Returns: (bestes Fenster mit Seitenmarker, höchste Cosine). Leerer String + wenn kein Satz die Schwelle erreicht ODER das Embedding-Modell nicht + verfügbar ist/das Encoding scheitert (Score dann bestmöglich, sonst 0.0) + — der Aufrufer behält in diesem Fall den bisherigen `[skip]`. + """ + if not title.strip() or not full_text.strip(): + return "", 0.0 + words = full_text.split() + if not words: + return "", 0.0 + + try: + from generative.embeddings import embed_title + + spans, window_texts, sentences, sent_embs = _window_sentence_embeddings(full_text, words, window_words) + if not sentences: + return "", 0.0 + te = embed_title(title) + sims = sent_embs.dot(te) + best_i = int(sims.argmax()) + best_score = float(sims[best_i]) + except Exception: + return "", 0.0 + + if best_score < threshold: + return "", max(best_score, 0.0) + + best_sentence = sentences[best_i] + page_at_word = _page_at_word_map(full_text) + for (s, _e), wtext in zip(spans, window_texts): + if best_sentence in wtext: + return _prefix_page_marker(wtext, page_at_word[s])[:max_chars], best_score + # Sollte nicht eintreten (Treffer-Satz stammt aus genau diesen Fenstern) — + # fail-closed statt eines Fensters ohne nachvollziehbaren Bezug. + return "", best_score + + def concept_text_window(full_text: str, search_terms: list[str], window_words: int = 400, max_chars: int = 8000) -> str: """Sliding-Window Co-Occurrence Ranking — wählt die thematisch dichtesten Fenster aus dem Volltext (Option D, Gemini-Review 2026-05-17). @@ -371,26 +547,14 @@ def concept_text_window(full_text: str, search_terms: list[str], window_words: i if not words: return "" - # Seite pro Wort-Index tracken: damit ein selektiertes Fenster, das mitten auf - # einer Seite beginnt (der `[S. N]`-Marker stand am Seitenanfang, vor dem - # Fenster), seinen korrekten Marker vorangestellt bekommt. Sonst erbt die - # Downstream-Seitenableitung ("letzter [S. N]-Marker vor der Fundstelle": - # Extractor-LLM, Verifier, Renderer) die Seite eines früheren Snippets → - # falsche Fußnoten-Seite (#4 Anker-Clustering, Merrill-Run 2026-06-24). - # NUR line-isolierte Pipeline-Marker (`\n\n[S. N]\n\n` aus pages_to_marked_text) - # zählen als Seitenanfang — Inline-Quellenverweise wie „vgl. [S. 12]" im - # Fließtext NICHT (sonst erbt Folgetext die zitierte statt der echten Seite; - # Codex-Review 2026-06-24). re.finditer(r"\S+") liefert dieselbe Token-Folge - # wie full_text.split() oben, plus Positionen fürs Marker-Mapping. - _real_markers = [(m.start(), m.group(1)) for m in re.finditer(r"(?m)^[ \t]*\[S\.\s*(\d+)\][ \t]*$", full_text)] - page_at_word: list[str | None] = [] - _cur_page: str | None = None - _mi = 0 - for _tok in re.finditer(r"\S+", full_text): - while _mi < len(_real_markers) and _real_markers[_mi][0] <= _tok.start(): - _cur_page = _real_markers[_mi][1] - _mi += 1 - page_at_word.append(_cur_page) + # Seite pro Wort-Index tracken (s. `_page_at_word_map`): damit ein + # selektiertes Fenster, das mitten auf einer Seite beginnt (der `[S. N]`- + # Marker stand am Seitenanfang, vor dem Fenster), seinen korrekten Marker + # vorangestellt bekommt. Sonst erbt die Downstream-Seitenableitung + # ("letzter [S. N]-Marker vor der Fundstelle": Extractor-LLM, Verifier, + # Renderer) die Seite eines früheren Snippets → falsche Fußnoten-Seite + # (#4 Anker-Clustering, Merrill-Run 2026-06-24). + page_at_word = _page_at_word_map(full_text) # Title normalisieren auf gleiche Whitespace-Form wie `chunk` (single-space-join) # — sonst matcht z.B. "Multi-Agent\n\nSystem" nicht im normalisierten Chunk. @@ -400,11 +564,8 @@ def concept_text_window(full_text: str, search_terms: list[str], window_words: i title_re = re.compile(re.escape(title), re.IGNORECASE) if title else None token_res = [re.compile(r"\b" + re.escape(t) + r"\b", re.IGNORECASE) for t in tokens] - stride = max(1, window_words // 2) scored: list[tuple[int, int, int]] = [] # (score, start_word, end_word) - start = 0 - while start < len(words): - end = min(start + window_words, len(words)) + for start, end in _iter_word_windows(len(words), window_words): chunk = " ".join(words[start:end]) score = 0 if title_re: @@ -414,9 +575,6 @@ def concept_text_window(full_text: str, search_terms: list[str], window_words: i score += 1 if score > 0: scored.append((score, start, end)) - if end >= len(words): - break - start += stride if not scored: return "" @@ -456,11 +614,7 @@ def concept_text_window(full_text: str, search_terms: list[str], window_words: i # Overhead ist aber vernachlässigbar gegen das ohnehin unterausgenutzte Budget. snippets: list[str] = [] for s, e in merged: - snip = " ".join(words[s:e]) - page = page_at_word[s] - if page is not None and not snip.lstrip().startswith("[S."): - snip = f"[S. {page}] {snip}" - snippets.append(snip) + snippets.append(_prefix_page_marker(" ".join(words[s:e]), page_at_word[s])) return "\n\n[...]\n\n".join(snippets) diff --git a/generative/tests/test_pdf_chunker.py b/generative/tests/test_pdf_chunker.py index 949633d..33cdcf0 100644 --- a/generative/tests/test_pdf_chunker.py +++ b/generative/tests/test_pdf_chunker.py @@ -9,8 +9,17 @@ from __future__ import annotations import re - -from generative.pipeline.pdf_chunker import drop_frontmatter_pages, page_range_of_text, concept_text_window +import numpy as np +import pytest + +from generative import embeddings +from generative.pipeline import pdf_chunker as pdf_chunker_module +from generative.pipeline.pdf_chunker import ( + drop_frontmatter_pages, + page_range_of_text, + concept_text_window, + semantic_concept_window, +) # ---- drop_frontmatter_pages --------------------------------------------- @@ -521,3 +530,136 @@ def _raise_not_found(*args, **kwargs): monkeypatch.setattr(_pc.subprocess, "run", _raise_not_found) assert _pc.pdf_metadata(tmp_path / "x.pdf") == {} + + +# ---- semantic_concept_window: Issue #127 -------------------------------- +# Deterministisches Fake-Modell statt echtem sentence-transformers-Load (Muster +# aus test_embed_body_lru.py/test_redundant_sibling_flag.py): jeder registrierte +# Text bekommt eine orthogonale Basisrichtung -> Cosine ist exakt 1.0 bei +# Text-Gleichheit, sonst 0.0. Reicht für Argmax-/Schwellen-/Cache-Verhalten; +# realistische Zwischenwerte sind Sache der Kalibrierung (PR-Body), nicht der +# Unit-Tests. WICHTIG: eine EINZIGE Instanz pro Test (nicht pro `_model()`- +# Aufruf neu bauen) — sonst reseten sich `_index` zwischen dem Satz-Batch-Call +# und dem separaten Titel-Call und Text A aus Call 1 kollidiert mit Text B aus +# Call 2 (beide bekommen Index 0 in ihrer je eigenen Instanz). + + +class _OneHotModel: + def __init__(self, counter: dict, dim: int = 64): + self._counter = counter + self._dim = dim + self._index: dict[str, int] = {} + + def get_sentence_embedding_dimension(self): + return self._dim + + def _vec(self, text: str): + if text not in self._index: + self._index[text] = len(self._index) + v = np.zeros(self._dim) + v[self._index[text] % self._dim] = 1.0 + return v + + def encode(self, texts, show_progress_bar=False, normalize_embeddings=True, batch_size=64): + self._counter["encode_calls"] = self._counter.get("encode_calls", 0) + 1 + if len(texts) > 1: + self._counter["batch_calls"] = self._counter.get("batch_calls", 0) + 1 + return np.array([self._vec(t) for t in texts]) + + +def _clear_window_sent_cache(): + pdf_chunker_module._WINDOW_SENT_CACHE.clear() + + +def test_semantic_window_rescues_matching_sentence(monkeypatch): + """Titel-Text ist exakt einer der Sätze im Volltext (Cosine 1.0 im + One-Hot-Fake) -> Fenster mit diesem Satz wird zurückgegeben, Score 1.0.""" + _clear_window_sent_cache() + model = _OneHotModel({}) + monkeypatch.setattr(embeddings, "_model", lambda: model) + + full_text = "Andragogik ist ein Modell des Lernens. Ein unbeteiligter zweiter Satz ueber etwas anderes." + window, score = semantic_concept_window(full_text, "Andragogik ist ein Modell des Lernens.", threshold=0.5) + + assert score == 1.0 + assert "Andragogik ist ein Modell des Lernens." in window + + +def test_semantic_window_below_threshold_returns_empty(monkeypatch): + """Kein Satz erreicht die Schwelle (One-Hot: 0.0 für alle Nicht-Treffer, + da Titel und Sätze disjunkte Basisrichtungen bekommen) -> leerer String, + aber der beste (hier: 0.0) Score wird fürs Logging zurückgegeben -- + fail-closed, kein Crash.""" + _clear_window_sent_cache() + model = _OneHotModel({}) + monkeypatch.setattr(embeddings, "_model", lambda: model) + + full_text = "Voellig unabhaengiger Satz eins. Und noch ein zweiter Satz ohne Bezug." + window, score = semantic_concept_window(full_text, "Ein Titel der nirgends vorkommt", threshold=0.5) + + assert window == "" + assert score == 0.0 + + +def test_semantic_window_caches_across_concepts_same_fulltext(monkeypatch): + """Zwei Konzepte, DERSELBE full_text -> der teure Satz-Batch-Call + (`_window_sentence_embeddings`) läuft nur EINMAL (Cache-Treffer beim + zweiten Aufruf) -- mehrere in Stage 5 leer gebliebene Konzepte eines + Laufs teilen sich die Fenster-Sätze. Die separaten (billigen) Ein-Text- + Titel-Calls zählen hier bewusst nicht mit (`batch_calls` statt + `encode_calls`).""" + _clear_window_sent_cache() + counter: dict = {} + model = _OneHotModel(counter) + monkeypatch.setattr(embeddings, "_model", lambda: model) + + full_text = "Ein Satz ueber Andragogik. Ein zweiter Satz ueber Paedagogik." + semantic_concept_window(full_text, "Ein Satz ueber Andragogik.", threshold=0.99) + semantic_concept_window(full_text, "Ein zweiter Satz ueber Paedagogik.", threshold=0.99) + + assert counter["batch_calls"] == 1 + + +def test_semantic_window_fails_closed_on_model_error(monkeypatch): + """Embedding-Modell nicht ladbar/kaputt -> ("", 0.0) statt Crash oder + unkontrolliertem Rettungs-Fenster (#127 muss fail-closed sein, anders als + der fail-open `_default_semantic_presence`-Kanal in Stage 4).""" + _clear_window_sent_cache() + + def _broken_model(): + raise RuntimeError("Modell-Load fehlgeschlagen (simuliert)") + + monkeypatch.setattr(embeddings, "_model", _broken_model) + + window, score = semantic_concept_window("Irgendein Volltext.", "Irgendein Titel", threshold=0.5) + assert window == "" + assert score == 0.0 + + +def test_semantic_window_empty_title_or_text_short_circuits(monkeypatch): + """Leerer Titel/Volltext -> sofort ("", 0.0), kein Modell-Zugriff nötig.""" + _clear_window_sent_cache() + counter: dict = {} + monkeypatch.setattr(embeddings, "_model", lambda: _OneHotModel(counter)) + + assert semantic_concept_window("Ein Volltext.", " ", threshold=0.5) == ("", 0.0) + assert semantic_concept_window(" ", "Ein Titel", threshold=0.5) == ("", 0.0) + assert "encode_calls" not in counter + + +@pytest.mark.slow +def test_semantic_window_real_model_crosslingual_rescue(): + """Echtes Embedding-Modell, kein Mock (Default-Pfad-Validierung, analog + test_real_embeddings_default_path in test_redundant_sibling_flag.py): + deutscher Titel auf englischem Fließtext ohne jeden lexikalischen + Overlap -- die Kernsituation aus Issue #127.""" + pdf_chunker_module._WINDOW_SENT_CACHE.clear() + full_text = ( + "This chapter explains how adults direct their own education. " + "Self-directed learning means the individual takes the initiative " + "in diagnosing needs, formulating goals, and evaluating outcomes. " + "Completely unrelated filler about kitchen appliances and warranty terms follows here." + ) + window, score = semantic_concept_window(full_text, "Selbstgesteuertes Lernen", threshold=0.5) + assert score >= 0.5 + assert "self-directed" in window.lower() or "initiative" in window.lower() diff --git a/generative/tests/test_stage5_semantic_fallback.py b/generative/tests/test_stage5_semantic_fallback.py new file mode 100644 index 0000000..3616b44 --- /dev/null +++ b/generative/tests/test_stage5_semantic_fallback.py @@ -0,0 +1,247 @@ +"""Issue #127: Stage-5-Volltext-Check ist sprachblind. + +`concept_text_window()` (der Stage-5-Skip in `run_extractors_per_concept`) ist +rein lexikalisch. Ein deutscher Planner-Titel auf einer englischen Quelle hat +0 Token-Overlap und wird verworfen -- selbst wenn dasselbe Konzept +`planner.filter_hallucinated`s #66-Rettungsanker (semantische Präsenz) bereits +passiert hat (Ebner/Knowles-Befund: 0 Draft-Notes trotz thematisch passender +Quelle). Fix: vor dem endgültigen [skip] denselben semantischen Rettungskanal +anbieten -- fail-closed, reiner OR-Kanal wie #66. + +`semantic_window_fn` ist injizierbar (analog `filter_hallucinated`s +`semantic_presence_fn`) -- Tests laufen ohne echtes Embedding-Modell. +""" + +from __future__ import annotations + +import asyncio +import json + +import generative.agents.tracing as tracing +from generative.agents import extractor +from generative.agents.tracing import JsonlBackend +from generative import orchestrator as orch +from generative.schemas.atomic_note import ConceptItem, ConceptPlan + + +def _capture(monkeypatch, tmp_path): + """Gleiches Muster wie test_extractor_window_rescue.py._capture.""" + backend = JsonlBackend(run_dir=tmp_path, run_id="test-run") + monkeypatch.setattr(tracing, "_backend", backend) + + def _read_stage_events() -> list[dict]: + f = tmp_path / "test-run.jsonl" + if not f.exists(): + return [] + events = [json.loads(line) for line in f.read_text(encoding="utf-8").splitlines() if line.strip()] + return [e for e in events if e.get("type") == "stage_outcome"] + + return _read_stage_events + + +def _concept(title: str) -> ConceptItem: + return ConceptItem(title=title, priority="high", chapter="Kap. 1", action="create") + + +_NOTE_RESPONSE = """\ + +title: Andragogik +aliases: Andragogik +tags: +proposed_tags: +synthesis_confidence: low +action: create +extend_path: + +# Andragogik: Erwachsenenpädagogisches Modell + +Andragogik beschreibt selbstgesteuertes Lernen bei Erwachsenen (S. 4). + +page: S. 4 + +Andragogy has appeared with increasing frequency in the literature. + +""" + + +# --- (a) semantisch gerettet: Fallback greift, Extractor-Task entsteht ------ + + +def test_semantic_fallback_rescues_crosslingual_title(monkeypatch, tmp_path): + """Deutscher Titel, englische Quelle -> lexikalisch leer, semantisch + gerettet (cosine >= Schwelle) -> Extractor läuft mit dem gelieferten + Fenster, kein dropped-Event, Log-Signatur [semantic-window-fallback].""" + read = _capture(monkeypatch, tmp_path) + calls = {"n": 0} + + async def fake_call(prompt, *, model, agent, **kwargs): + calls["n"] += 1 + return _NOTE_RESPONSE + + monkeypatch.setattr(extractor, "call_claude_async", fake_call) + + def fake_semantic_window_fn(full_text, title): + assert title == "Andragogik" + return "[S. 4] Andragogy has appeared with increasing frequency in the literature.", 0.75 + + plan = ConceptPlan(source_title="T", source_summary="S", concepts=[_concept("Andragogik")]) + full_text = "This chapter discusses adult learning theory without the German term at all." + + drafts, concept_map, dropped, failures = asyncio.run( + orch.run_extractors_per_concept( + full_text, plan, existing_concepts={}, semantic_window_fn=fake_semantic_window_fn + ) + ) + + assert calls["n"] == 1 + assert [d.title for d in drafts] == ["Andragogik"] + assert dropped == 0 + assert failures == [] + assert "Andragogik" in concept_map + assert read() == [] # kein dropped-Event + + +def test_semantic_fallback_log_signature(monkeypatch, tmp_path, capsys): + """Log-Zeile trägt die neue Rettungs-Signatur inkl. Cosine-Wert.""" + _capture(monkeypatch, tmp_path) + + async def fake_call(prompt, *, model, agent, **kwargs): + return _NOTE_RESPONSE + + monkeypatch.setattr(extractor, "call_claude_async", fake_call) + + def fake_semantic_window_fn(full_text, title): + return "gerettetes Fenster", 0.6789 + + plan = ConceptPlan(source_title="T", source_summary="S", concepts=[_concept("Andragogik")]) + asyncio.run( + orch.run_extractors_per_concept( + "irrelevanter Volltext ohne Titel-Token", + plan, + existing_concepts={}, + semantic_window_fn=fake_semantic_window_fn, + ) + ) + + err = capsys.readouterr().err + assert "[semantic-window-fallback]" in err + assert "Andragogik" in err + assert "cosine=0.68" in err # :.2f-Formatierung + + +# --- (b) themenfremd: weiterhin skip + Funnel-Event ------------------------- + + +def test_semantic_fallback_rejects_unrelated_title(monkeypatch, tmp_path, capsys): + """Titel ohne semantischen Bezug -> Fallback liefert leeres Fenster -> + weiterhin [skip] + dropped/empty_extraction-Event, kein Extractor-Call.""" + read = _capture(monkeypatch, tmp_path) + calls = {"n": 0} + + async def fake_call(prompt, *, model, agent, **kwargs): + calls["n"] += 1 + return _NOTE_RESPONSE + + monkeypatch.setattr(extractor, "call_claude_async", fake_call) + + def fake_semantic_window_fn(full_text, title): + return "", 0.12 # unter der Schwelle -> kein Rettungsfenster + + plan = ConceptPlan( + source_title="T", source_summary="S", concepts=[_concept("Quantenverschränkung in Photonenpaaren")] + ) + full_text = "Der schnelle braune Fuchs springt über den faulen Hund." + + drafts, concept_map, dropped, failures = asyncio.run( + orch.run_extractors_per_concept( + full_text, plan, existing_concepts={}, semantic_window_fn=fake_semantic_window_fn + ) + ) + + assert calls["n"] == 0 # kein Extractor-Call -- schon vorher gedroppt + assert drafts == [] + # dropped = len(tasks) - len(drafts) (siehe Docstring) -- ein Konzept, das + # schon VOR dem Task-Dispatch (weder lexikalisch noch semantisch im + # Volltext) verworfen wird, taucht nie in `tasks` auf und zählt daher + # NICHT in `dropped` (unverändertes Verhalten des bestehenden [skip]-Pfads, + # vgl. test_run_extractors_empty_ctext_emits_stage_outcome). + assert dropped == 0 + assert failures == [] + + events = read() + assert len(events) == 1 + e = events[0] + assert e["title"] == "Quantenverschränkung in Photonenpaaren" + assert e["stage"] == "extractor" + assert e["outcome"] == "dropped" + assert e["drop_reason"] == "empty_extraction" + assert e["detail"] == "not in fulltext" + + err = capsys.readouterr().err + assert "[skip]" in err + assert "[semantic-window-fallback]" not in err + + +# --- (c) lexikalischer Treffer: Fallback wird gar nicht erst berechnet ----- + + +def test_semantic_fallback_not_consulted_when_lexically_present(monkeypatch, tmp_path): + """Titel lexikalisch im Text -> concept_text_window() liefert bereits ein + Fenster -> semantic_window_fn darf gar nicht erst aufgerufen werden + (Performance: kein Embedding-Call im Normalfall, vgl. #66-Analogtest + test_lexically_present_kept_without_consulting_semantic).""" + calls: list[str] = [] + + async def fake_call(prompt, *, model, agent, **kwargs): + return _NOTE_RESPONSE + + monkeypatch.setattr(extractor, "call_claude_async", fake_call) + + def spy_semantic_window_fn(full_text, title): + calls.append(title) + return "sollte nie verwendet werden", 0.99 + + plan = ConceptPlan(source_title="T", source_summary="S", concepts=[_concept("Andragogik")]) + full_text = "Ein Kapitel über Andragogik und ihre Prinzipien beim Lernen Erwachsener." + + drafts, concept_map, dropped, failures = asyncio.run( + orch.run_extractors_per_concept( + full_text, plan, existing_concepts={}, semantic_window_fn=spy_semantic_window_fn + ) + ) + + assert calls == [] # lexikalischer Treffer -> kein Fallback-Aufruf + assert dropped == 0 + assert [d.title for d in drafts] == ["Andragogik"] + + +# --- Default-Verdrahtung: ohne Injection zeigt der Default auf pdf_chunker -- + + +def test_default_semantic_window_fn_wired_to_pdf_chunker(monkeypatch, tmp_path): + """Ohne explizite Injection nutzt run_extractors_per_concept + `pdf_chunker.semantic_concept_window` mit der config-Schwelle + TITLE_PRESENCE_COSINE_THRESHOLD -- verifiziert per Spy statt echtem + Modell-Load (deterministisch, kein ML-Dependency in diesem Test).""" + read = _capture(monkeypatch, tmp_path) + seen: list[tuple] = [] + + def spy_semantic_concept_window(full_text, title, threshold, **kwargs): + seen.append((title, threshold)) + return "", 0.0 + + monkeypatch.setattr(orch.pdf_chunker, "semantic_concept_window", spy_semantic_concept_window) + + plan = ConceptPlan(source_title="T", source_summary="S", concepts=[_concept("Xyzzy Plughversion")]) + full_text = "Ein völlig anderer Text ohne jeden Bezug." + + asyncio.run(orch.run_extractors_per_concept(full_text, plan, existing_concepts={})) + + assert len(seen) == 1 + title, threshold = seen[0] + assert title == "Xyzzy Plughversion" + from generative.config import TITLE_PRESENCE_COSINE_THRESHOLD + + assert threshold == TITLE_PRESENCE_COSINE_THRESHOLD + events = read() + assert len(events) == 1 and events[0]["drop_reason"] == "empty_extraction" diff --git a/generative/tests/test_stage_outcome_events.py b/generative/tests/test_stage_outcome_events.py index b4b236e..1fb28f7 100644 --- a/generative/tests/test_stage_outcome_events.py +++ b/generative/tests/test_stage_outcome_events.py @@ -313,12 +313,23 @@ def _concept(title: str) -> ConceptItem: def test_run_extractors_empty_ctext_emits_stage_outcome(monkeypatch, tmp_path): """Konzept nicht im Volltext gefunden (`if not ctext.strip(): continue`) — - fiel bisher stumm vor jeder Event-Instrumentierung weg.""" + fiel bisher stumm vor jeder Event-Instrumentierung weg. + + #127: der lexikalische Skip fragt seither zusätzlich einen semantischen + Fallback (`semantic_window_fn`) — hier per No-Op-Fake auf leer gehalten, + damit dieser Test weiterhin ohne echtes Embedding-Modell läuft (das + Rettungskanal-Verhalten selbst ist in test_stage5_semantic_fallback.py + dediziert getestet). Der Titel ist auch semantisch themenfremd, ein + echter Fallback würde also ohnehin leer bleiben.""" read = _capture(monkeypatch, tmp_path) plan = ConceptPlan(source_title="T", source_summary="S", concepts=[_concept("Xyzzy Plughversion Frobnicate")]) full_text = "Der schnelle braune Fuchs springt über den faulen Hund." # kein Titel-Token enthalten - asyncio.run(orch.run_extractors_per_concept(full_text, plan, existing_concepts={})) + asyncio.run( + orch.run_extractors_per_concept( + full_text, plan, existing_concepts={}, semantic_window_fn=lambda text, title: ("", 0.0) + ) + ) events = read() assert len(events) == 1