diff --git a/generative/agents/context_builder.py b/generative/agents/context_builder.py index 5555453..7c78852 100644 --- a/generative/agents/context_builder.py +++ b/generative/agents/context_builder.py @@ -35,6 +35,49 @@ def _read_frontmatter(path: Path) -> dict: return {} +def note_type(path: Path) -> str: + """`type:`-Frontmatter-Wert einer Note (lowercase, gestrippt). '' wenn fehlt/unlesbar.""" + return str(_read_frontmatter(path).get("type", "")).strip().lower() + + +def is_dedup_eligible(path: Path) -> bool: + """True wenn die Note als Duplikat-/Merge-Kandidat einer Konzept-Note zählt. + + `type: literature`/`moc`/`merge-stub` koexistieren per Vault-Design mit Konzept-Notes + (Schema-Lit vs. Schema-Konzept) und sind nie deren Duplikat — sie werden hier + ausgeschlossen. Quelle der Liste: config.DEDUP_EXCLUDE_TYPES. + """ + from generative.config import DEDUP_EXCLUDE_TYPES + return note_type(path) not in DEDUP_EXCLUDE_TYPES + + +def resolve_vault_relpath(ref: str, existing_concepts: dict[str, str] | None) -> str | None: + """Löst einen Titel-/Alias-/Datei-Stem-Verweis auf den relativen Vault-Pfad auf. + + cross_reference präsentiert dem LLM Kandidaten als `Path(p).stem`, daher kommt ein + duplicate_path/extend_path meist als Datei-Stem zurück (z.B. 'ba-lit-…'), gelegentlich + als Titel. Beide Wege werden gegen existing_concepts (Titel/Alias→relpath bzw. dessen + Stems) aufgelöst. None, wenn kein Vault-Treffer (z.B. Intra-Run-Sibling). + + Gibt den RELATIVEN Pfad zurück (kein VAULT-Join) — jeder Caller joint mit seinem + eigenen VAULT (testbar pro Modul). + """ + if not ref or not existing_concepts: + return None + key = ref.strip().strip("[]").split("|", 1)[0].split("#", 1)[0].strip() + rel = existing_concepts.get(key.lower()) + if rel: + return rel + # Stem-Fallback: nur EINDEUTIG auflösen. Teilen mehrere Vault-Notes denselben + # Datei-Stem (gleichnamige Dateien in verschiedenen Ordnern), ist die Zuordnung + # mehrdeutig → konservativ None (kein willkürlicher Treffer → kein Fehl-Merge). + stem = Path(key).stem.lower() + matches = {v for v in existing_concepts.values() if Path(v).stem.lower() == stem} + if len(matches) == 1: + return next(iter(matches)) + return None + + def build_existing_concepts(scan_dirs: list[Path] | None = None) -> dict[str, str]: """Gibt {concept_title: file_path} für alle Notes im Vault zurück (ohne SKIP_DIRS). diff --git a/generative/agents/cross_reference.py b/generative/agents/cross_reference.py index bf063cd..76ab098 100644 --- a/generative/agents/cross_reference.py +++ b/generative/agents/cross_reference.py @@ -22,6 +22,34 @@ def _clean_wikilink(s: str) -> str: '[[[[..]]]]' führen (Muster wie vault_writer.rewrite_merged_related_links).""" return (s or "").strip().strip("[]").strip() + +def _resolve_vault_path(dup_path: str, existing_concepts: dict[str, str] | None) -> Path | None: + """Löst einen vom LLM gelieferten duplicate_path auf eine reale Vault-Datei auf. + + Kandidaten werden dem LLM als `Path(p).stem` präsentiert (siehe Prompt-Bau), daher + kommt dup_path meist als Datei-Stem zurück (z.B. 'ba-lit-ebner-gegenfurtner-2019'), + gelegentlich als Titel. Beide Wege werden gegen existing_concepts (Titel/Alias→Pfad) + aufgelöst. None, wenn kein Vault-Treffer (z.B. Intra-Run-Sibling — bisheriges Verhalten). + """ + from generative.agents.context_builder import resolve_vault_relpath + rel = resolve_vault_relpath(dup_path, existing_concepts) + return VAULT / rel if rel else None + + +def _dup_target_eligible(dup_path: str, existing_concepts: dict[str, str] | None) -> bool: + """False, wenn dup_path auf eine Note zeigt, deren Typ per Vault-Design mit + Konzept-Notes KOEXISTIERT (literature/moc/merge-stub) → kein echtes Duplikat. + + Verhindert den False-Positive „Konzept-Note ist Dup ihrer eigenen Lit-Note" + (Ebner-Run 2026-06-23). Unauflösbarer/nicht-Vault dup_path → True (bisheriges + Verhalten bleibt; Intra-Run-Siblings regelt resolve_sibling_dups). + """ + target = _resolve_vault_path(dup_path, existing_concepts) + if target is None: + return True + from generative.agents.context_builder import is_dedup_eligible + return is_dedup_eligible(target) + # Lazy-loaded NLI CrossEncoder — wird nur bei ENABLE_NLI_VALIDATION=1 geladen _nli_encoder = None _nli_lock = __import__("threading").Lock() # Thread-Safety bei parallelem Stage-6-Load @@ -384,7 +412,17 @@ def run(draft: AtomicNoteDraft, existing_concepts: dict[str, str], # reinem Pfad/Titel — normalisieren, sonst entsteht unten "[[[[Titel]]]]" und ein # verklammertes Duplikat-Flag (beobachtet im Ebner-Run 2026-06-22). dup_path = _clean_wikilink(data.get("duplicate_path") or "").split("|", 1)[0].strip() - if dup_risk == "high": + # Typ-bewusstes Blocking: ein Dup-Treffer gegen eine koexistierende Lit-/MoC-/Stub-Note + # ist KEIN echtes Duplikat (Schema-Lit ≠ Schema-Konzept) — sonst würde eine Konzept-Note + # fälschlich in ihre eigene Lit-Note gemergt (Ebner-Run 2026-06-23). Dann: kein + # action=extend, aber den Bezug als related-Link erhalten (Konzept SOLL auf Quelle linken). + if dup_risk == "high" and not _dup_target_eligible(dup_path, existing_concepts): + draft.quality_flags.append(f"ℹ️ Verwandte Quelle/Note (kein Konzept-Duplikat): {dup_path}") + if dup_path: + dup_link = f"[[{Path(dup_path).stem}]]" + if dup_link not in data["related"]: + data["related"].insert(0, dup_link) + elif dup_risk == "high": # Duplikat ist der stärkste mögliche Vault-Beleg — confidence NICHT runter, # stattdessen action='extend' setzen + Duplikat als related-Link aufnehmen, # damit Confidence-Agent has_vault_corroboration=True erkennt diff --git a/generative/agents/planner.py b/generative/agents/planner.py index 5f18993..ce01a16 100644 --- a/generative/agents/planner.py +++ b/generative/agents/planner.py @@ -19,7 +19,7 @@ from generative.agents.base import call_claude from generative.agents.cross_reference import _tokens # Stoppwort-gefilterte Content-Tokens from generative.agents.structured_output import parse_planner_output -from generative.config import MODEL_PLANNER +from generative.config import MODEL_PLANNER, TITLE_PRESENCE_COSINE_THRESHOLD from generative.schemas.atomic_note import ConceptPlan, ConceptItem _PROMPT = """Du bist ein Wissensmanagement-Assistent, der Atomic Notes in Obsidian anlegt. @@ -214,24 +214,70 @@ def run(overview: str, relevance_profile: dict, +_SENT_EMB_CACHE: dict[tuple[int, int], object] = {} +_SENT_EMB_CACHE_MAX = 16 # Deckel gegen unbounded growth in Langläufern (GUI/Batch) + + +def _default_semantic_presence(title: str, full_text: str) -> float: + """MAX-Cosine zwischen Titel-Embedding und den Satz-Embeddings des Volltexts. + + Cross-lingualer Präsenz-Check via multilinguales MiniLM (bereits für ER geladen). + MAX statt Mean: ein kurzer Titel trifft EINEN Absatz, nicht den Dokument-Durchschnitt + (Mean-Pooling verwässert den lokalen Treffer). Satz-Embeddings werden pro Volltext + gecacht (mehrere verworfene Konzepte → ein Encode). Fail-open: fehlt + sentence-transformers oder schlägt das Encoding fehl → 1.0 (kein zusätzliches Reject; + der lexikalische Filter bleibt die einzige Linie). 0.0 nur bei leerem Text. + """ + try: + from generative.pipeline.embeddings import embed_title, _sentences, _model + import numpy as np + except Exception: + return 1.0 + sents = [s for s in _sentences(full_text) if len(s) > 15] + if not sents: + return 0.0 + try: + key = (len(full_text), hash(full_text)) + embs = _SENT_EMB_CACHE.get(key) + if embs is None: + embs = np.asarray(_model().encode( + sents, show_progress_bar=False, normalize_embeddings=True, batch_size=64)) + # Cache deckt mehrere verworfene Konzepte DESSELBEN Laufs ab (ein Encode statt N). + # Kleiner LRU-artiger Deckel, damit ein Langläufer (GUI-Server, Batch-Eval über + # viele PDFs) nicht unbounded wächst (~600 KB pro Volltext). + if len(_SENT_EMB_CACHE) >= _SENT_EMB_CACHE_MAX: + _SENT_EMB_CACHE.pop(next(iter(_SENT_EMB_CACHE))) + _SENT_EMB_CACHE[key] = embs + te = embed_title(title) + return float(embs.dot(te).max()) + except Exception: + return 1.0 + + def filter_hallucinated(plan: ConceptPlan, full_text: str, - min_coverage: float = 0.5) -> tuple[ConceptPlan, list[str]]: - """Verwirft Konzepte deren Titel-Tokens nur teilweise im PDF-Volltext vorkommen. + min_coverage: float = 0.5, + semantic_presence_fn=None) -> tuple[ConceptPlan, list[str]]: + """Verwirft Konzepte deren Titel im PDF weder lexikalisch noch semantisch vorkommen. - Coverage-Filter: |Title-Tokens ∩ Text-Tokens| / |Title-Tokens| ≥ min_coverage + Lexikalischer Coverage-Filter: |Title-Tokens ∩ Text-Tokens| / |Title-Tokens| ≥ min_coverage. - Ergänzt durch Planner-Prompt-Instruktion (Self-Filter: action="skip" für - konzepte die nur aus Trainingswissen bekannt sind). Kein domain-spezifischer - Code-Filter — neutral für alle Themenbereiche. + Cross-lingualer Rettungsanker (Ebner-Run 2026-06-23): der reine Token-Schnitt ist + sprachblind — ein deutscher (paraphrasierter) Titel hat null wörtlichen Overlap mit + einer englischen Quelle und würde fälschlich verworfen (so starb der Paper-Kernbefund + „Lern-Zufriedenheits-Dissoziation"). Scheitert die lexikalische Coverage, wird daher + ZUSÄTZLICH die semantische Präsenz geprüft (MAX-Cosine, multilinguales Embedding); + liegt sie ≥ TITLE_PRESENCE_COSINE_THRESHOLD, wird das Konzept gerettet. Reiner + OR-Kanal: kann nur retten, nie zusätzlich verwerfen. Beispiele (min_coverage=0.5): - - "Maslow Bedürfnishierarchie" → Coverage 2/2 → kept - - "Blockchain für Information Retrieval" → Coverage 1/3 < 0.5 → rejected + - "Maslow Bedürfnishierarchie" → Coverage 2/2 → kept (lexikalisch) + - "Lern-Zufriedenheits-Dissoziation" (EN-Quelle) → Coverage 0, aber max-cos 0.83 → gerettet + - "Blockchain für Information Retrieval" → Coverage 1/3 UND max-cos 0.36 → rejected Returns: (gefilterter Plan, Liste verworfener Konzept-Titel) """ text_tokens = _tokens(full_text) - full_text_lower = full_text.lower() + sem_fn = semantic_presence_fn or _default_semantic_presence rejected: list[str] = [] kept: list[ConceptItem] = [] for c in plan.concepts: @@ -241,8 +287,12 @@ def filter_hallucinated(plan: ConceptPlan, full_text: str, continue coverage = len(title_tokens & text_tokens) / len(title_tokens) if coverage < min_coverage: - rejected.append(c.title) - continue + # Sprachblindheit abfangen: bevor verworfen wird, cross-lingualen + # semantischen Präsenz-Check als Rettungsanker. + if sem_fn(c.title, full_text) < TITLE_PRESENCE_COSINE_THRESHOLD: + rejected.append(c.title) + continue + # sonst gerettet → weiter zur Blacklist-Prüfung # Blacklist-Check: generische Einzel-Konzepte verwerfen (portiert aus extractive). # Normalisierung auf lowercase nötig da LLM Title-Case ausgibt. if c.title.strip().lower() in _GENERIC_BLACKLIST: diff --git a/generative/config.py b/generative/config.py index b0b09c8..aeede01 100644 --- a/generative/config.py +++ b/generative/config.py @@ -63,6 +63,40 @@ # Critic-Schwelle: Auto-Write nur bei Score >= Schwelle UND alle Hard-Gates pass CRITIC_AUTO_THRESHOLD = 4 # von 5 Tests, siehe Schema-Konzept Milestone 1.1 +# Cross-lingualer Rettungsanker für filter_hallucinated (planner.py): der lexikalische +# Token-Coverage-Filter ist sprachblind — ein deutscher (paraphrasierter) Konzept-Titel +# hat null wörtlichen Overlap mit einer englischen Quelle und würde fälschlich als +# „halluziniert" verworfen (Ebner-Run 2026-06-23: der Paper-Kernbefund „Lern-Zufriedenheits- +# Dissoziation" starb genau so). Geprüft wird dann die semantische Präsenz: MAX-Cosine des +# Titel-Embeddings gegen die Satz-Embeddings des Volltexts (multilinguales MiniLM, bereits +# geladen). Schwelle gemessen auf der Ebner-EN-Quelle (n=1, NICHT voll kalibriert): echte +# Konzepte 0.575–0.825, echte Halluzinationen 0.357/0.358 → 0.50 trennt mit großem Abstand. +# Reiner OR-RETTUNGSANKER: greift nur, wenn der lexikalische Filter ablehnt — kann ein +# Konzept also nur RETTEN, nie zusätzlich verwerfen. ENV-überschreibbar für Kalibrierung. +TITLE_PRESENCE_COSINE_THRESHOLD = float(os.getenv("ATOMIC_AGENT_TITLE_PRESENCE_COSINE", "0.50")) + +# Typ-bewusstes Dedup-Blocking: Note-Typen, die per Vault-Design mit Konzept-Notes +# KOEXISTIEREN und daher nie Duplikat-Kandidaten sind. Eine `type: literature`-Note ist die +# Note ÜBER ein Paper, eine `type: atomic`-Note die Note ÜBER ein Konzept DARIN — beide +# existieren gleichzeitig (Schema-Lit vs. Schema-Konzept); `moc`/`merge-stub` sind Pointer- +# bzw. Zwischen-Notes. Ohne diesen Filter flaggt cross_reference eine Konzept-Note fälschlich +# als Duplikat ihrer eigenen Lit-Note (Ebner-Run 2026-06-23: „Webinar" → Dup von +# ba-lit-ebner-gegenfurtner-2019). related-LINKS über Typgrenzen bleiben erlaubt (eine +# Konzept-Note SOLL auf ihre Quelle verlinken) — nur der Dup/extend- und Merge-Stub-Pfad +# wird typ-bewusst. +DEDUP_EXCLUDE_TYPES = frozenset({"literature", "moc", "merge-stub"}) + +# #8 Body-Redundanz-Detektion: Schwelle, ab der zwei DISTINKTE create-Notes EINES Laufs +# als inhaltlich stark überlappend geflaggt werden (seiteneffekt-freier Review-Hinweis, kein +# Merge, kein Strip). Zwei empirische Gates (Ebner-Audit 2026-06-23) zeigten: solche +# Geschwister sind weder mergebar (distinkte Konzepte) noch satz-strippbar (Redundanz +# paraphrasiert: exakt 0/10, fuzzy≥0.93 nur 1/10 Sätze) — der einzige verlustfreie Eingriff +# ist ein Flag für den menschlichen Reviewer. Default 0.90 liegt deutlich über typischer +# Distinkt-Note-Cosine, unter dem gemessenen #8-Paar (0.967). Tiefer als der ER-Hard-Merge- +# Gate (0.985), weil ein Flag risikolos ist; ENV-überschreibbar für Kalibrierung. +REDUNDANT_SIBLING_COSINE_THRESHOLD = float( + os.getenv("ATOMIC_AGENT_REDUNDANT_SIBLING_COSINE", "0.90")) + # Chunk-Größe Fallback (Wörter) CHUNK_WORDS = 3000 diff --git a/generative/orchestrator.py b/generative/orchestrator.py index dd6ffee..5b33ce5 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -68,6 +68,7 @@ def _span(name: str, **attrs): MODEL_LLM_DEDUP, MAX_CHUNKS_SHORT_DOC, MAX_PAGES_SHORT_DOC, + REDUNDANT_SIBLING_COSINE_THRESHOLD, ) from generative.runtime_config import ( load_runtime_config, cap_actionable_concepts, count_actionable, @@ -970,6 +971,64 @@ def _absorb_alias(name: str) -> None: return kept, len(drop_idx) +def flag_redundant_siblings(drafts: list[AtomicNoteDraft], + threshold: float | None = None, + body_cosine_fn=None, + ) -> tuple[list[AtomicNoteDraft], int]: + """#8: seiteneffekt-freier Flag bei hoher Body-Überlappung zwischen DISTINKTEN Notes. + + Zwei empirische Gates (Ebner-Audit 2026-06-23) zeigten: Geschwister-Notes EINES Laufs + mit hoher Body-Cosine (gemessen 0.967) sind weder mergebar (distinkte Konzepte: + Kirkpatrick-Modell = Theorie vs. Satisfaction-Learning-Dissoziation = Befund) noch + satz-strippbar (Redundanz paraphrasiert, nicht dupliziert — exakt 0/10, fuzzy≥0.93 nur + 1/10 Sätze). Der einzige verlustfreie Eingriff ist ein Flag, der den menschlichen + Reviewer auf die Überlappung hinweist ("Kontext kürzen/verlinken"). KEIN Merge, KEIN + Strip, KEIN Kollabieren — die Notes bleiben unverändert, nur quality_flags wächst. + + Läuft NACH resolve_sibling_dups + dedup_hub_subconcepts (echte Dups und Hub→Sub schon + behandelt) und VOR dem Writer (Flag landet via _yaml_list im Frontmatter, im Inbox- + Review sichtbar). Nur create-Drafts werden paarweise verglichen: extend-Drafts gehören + dem Merge-Pfad (resolve_sibling_dups / write_note) und werden hier nicht doppelt geflaggt. + + body_cosine_fn(i, j) ist injizierbar (deterministische Tests, vgl. filter_hallucinated); + Default berechnet Body-Embeddings einmal und nutzt embeddings.cosine. + """ + if threshold is None: + threshold = REDUNDANT_SIBLING_COSINE_THRESHOLD + + # Nur create-Drafts mit nicht-leerem Body: extend gehört dem Merge-Pfad; ein leerer Body + # kann nicht redundant sein (Cosine 0) und würde nur das Embedding-Modell unnötig laden. + candidates = [i for i, d in enumerate(drafts) + if d.action == "create" and (d.body or "").strip()] + if len(candidates) < 2: + return drafts, 0 + + if body_cosine_fn is None: + body_embs = {i: embeddings.embed_body(drafts[i].body or "") for i in candidates} + + def body_cosine_fn(i, j): + return embeddings.cosine(body_embs[i], body_embs[j]) + + def _add_flag(draft: AtomicNoteDraft, other_title: str, cos: float) -> None: + marker = f"Überlappung mit [[{other_title}]]" + if any(marker in f for f in draft.quality_flags): # idempotent + return + draft.quality_flags.append( + f"⚠️ Hohe inhaltliche {marker} (Body-cos={cos:.2f}) — " + f"beim Review Kontext kürzen/verlinken") + + n_pairs = 0 + for a in range(len(candidates)): + for b in range(a + 1, len(candidates)): + i, j = candidates[a], candidates[b] + cos = body_cosine_fn(i, j) + if cos >= threshold: + _add_flag(drafts[i], drafts[j].title, cos) + _add_flag(drafts[j], drafts[i].title, cos) + n_pairs += 1 + return drafts, n_pairs + + def _auto_start_dashboard() -> None: """Startet den Dashboard-Server im Hintergrund falls er noch nicht läuft.""" import socket, subprocess @@ -1678,6 +1737,15 @@ def main(argv: list[str] | None = None): if stripped: print(f"\n[boilerplate-dedup] {stripped} geteilte Sätze aus Sub-Notes in Hubs zentralisiert") + # --- #8: Body-Redundanz-Flag zwischen DISTINKTEN Geschwister-Notes --- + # Nach den Dedup-Stages (echte Dups/Hub→Sub schon behandelt): distinkte create-Notes mit + # hoher Body-Cosine sind weder mergebar noch satz-strippbar (2 empirische Gates, + # Ebner-Audit) → seiteneffekt-freier Flag für den menschlichen Reviewer, kein Eingriff. + drafts, n_redund = flag_redundant_siblings(drafts) + if n_redund: + print(f"[redundanz-flag] {n_redund} Note-Paar(e) mit hoher Body-Überlappung markiert " + f"(Review-Hinweis, kein Merge)") + # --- Schritt 7: Vault-Writer --- # F2: enriched_meta = CrossRef-Daten überschreiben pdf_metadata wo vorhanden. # Ein per Title-RATEN gefundener CrossRef-Treffer (kein harter ID-Match) darf die diff --git a/generative/pipeline/vault_writer.py b/generative/pipeline/vault_writer.py index 30ea202..96cae37 100644 --- a/generative/pipeline/vault_writer.py +++ b/generative/pipeline/vault_writer.py @@ -512,12 +512,19 @@ def find_existing_in_vault(title: str, aliases: list[str], excludiert bereits 00-inbox/98-system/99-archive/08-dashboards (siehe SKIP_DIRS). Match-Reihenfolge: exakter Title, dann jeder Alias. Erster Treffer gewinnt. """ + from generative.agents.context_builder import is_dedup_eligible candidates = [title.strip().lower()] candidates.extend(a.strip().lower() for a in aliases if a) for c in candidates: rel_path = existing_concepts.get(c) if rel_path: - return VAULT / rel_path + target = VAULT / rel_path + # Typ-bewusst: eine `type: literature`/`moc`/`merge-stub`-Note koexistiert per + # Design mit Konzept-Notes und ist kein Merge-Ziel — überspringen, damit ein + # weiterer (Alias-)Kandidat noch eine echte Konzept-Note treffen kann. + if not is_dedup_eligible(target): + continue + return target return None @@ -815,6 +822,21 @@ def write_note(note: AtomicNoteDraft, source_file: str, dry_run: bool = False, if existing_concepts: existing_vault = find_existing_in_vault(note.title, note.aliases, existing_concepts) + # #2b: cross_reference setzt bei einem echten Konzept-Dup mit ABWEICHENDEM Titel + # action=extend + extend_path= (z.B. Draft „Information Need" → Vault-Note + # „Wilson Information Need"). find_existing_in_vault matcht das nicht (Titel/Alias-only), + # das Signal verpuffte bisher → Vault-Dublette ([[Ungelesenes-Pipeline-Signal]]). extend_path + # wird jetzt als Merge-Ziel honoriert — typ-sicher: is_dedup_eligible schließt literature/ + # moc/merge-stub aus (das #2a-Gate setzt extend_path ohnehin nur noch für Konzept-Notes). + # resolve_sibling_dups regelt Intra-Run-Siblings vorher; diese Auflösung greift nur, wenn + # dort kein Vault-Treffer als Alias hinterlegt wurde. + if (existing_vault is None and existing_concepts + and note.action == "extend" and note.extend_path): + from generative.agents.context_builder import resolve_vault_relpath, is_dedup_eligible + _rel = resolve_vault_relpath(note.extend_path, existing_concepts) + if _rel and is_dedup_eligible(VAULT / _rel): + existing_vault = VAULT / _rel + if existing_vault is not None: # Pre-Merge Source-Check (MVP): Prüfe ob bestehende Note dieselbe Quelle hat. # Wenn source-file abweicht → andere Primärquelle → stub markiert als cross-source. diff --git a/generative/tests/test_planner_hallucination_filter.py b/generative/tests/test_planner_hallucination_filter.py new file mode 100644 index 0000000..2a2da02 --- /dev/null +++ b/generative/tests/test_planner_hallucination_filter.py @@ -0,0 +1,79 @@ +"""Tests für den cross-lingualen Rettungsanker in planner.filter_hallucinated. + +Der lexikalische Token-Coverage-Filter ist sprachblind: ein deutscher (paraphrasierter) +Konzept-Titel hat null wörtlichen Overlap mit einer englischen Quelle und wurde fälschlich +als „halluziniert" verworfen (Ebner-Run 2026-06-23: der Paper-Kernbefund „Lern-Zufriedenheits- +Dissoziation" starb so). Der semantische Präsenz-Check (MAX-Cosine) rettet solche Konzepte, +bevor sie verworfen werden — als reiner OR-Kanal (kann nur retten, nie zusätzlich verwerfen). +""" +from generative.agents.planner import filter_hallucinated +from generative.schemas.atomic_note import ConceptPlan, ConceptItem + + +def _plan(*titles): + return ConceptPlan( + source_title="S", source_summary="zwei Sätze worum es geht", + concepts=[ConceptItem( + title=t, priority="high", chapter="", action="create", + extend_path=None, category="conceptual", origin="primary", + cited_authors=[]) for t in titles], + ) + + +def test_lexically_present_kept_without_consulting_semantic(): + # Titel lexikalisch im Text → semantischer Kanal darf gar nicht erst aufgerufen werden. + calls = [] + + def _spy(title, text): + calls.append(title) + return 0.0 # würde verwerfen, falls fälschlich konsultiert + + kept, rejected = filter_hallucinated( + _plan("Webinar"), "A meta-analysis about Webinar effectiveness in learning.", + semantic_presence_fn=_spy) + assert [c.title for c in kept.concepts] == ["Webinar"] + assert rejected == [] + assert calls == [] # lexikalischer Pass → kein Embedding-Call + + +def test_crosslingual_concept_rescued_by_high_semantic_presence(): + # DE-Titel, EN-Quelle → lexikalische Coverage 0, aber semantisch hoch → GERETTET. + kept, rejected = filter_hallucinated( + _plan("Lern-Zufriedenheits-Dissoziation"), + "Learning and satisfaction were negatively associated in all three conditions.", + semantic_presence_fn=lambda t, txt: 0.83) + assert [c.title for c in kept.concepts] == ["Lern-Zufriedenheits-Dissoziation"] + assert rejected == [] + + +def test_genuine_hallucination_rejected_by_low_semantic_presence(): + # Weder lexikalisch noch semantisch präsent → verworfen (Default-Schwelle 0.50). + kept, rejected = filter_hallucinated( + _plan("Quantenverschränkung in Photonenpaaren"), + "A meta-analysis about webinars and student satisfaction.", + semantic_presence_fn=lambda t, txt: 0.36) + assert kept.concepts == [] + assert "Quantenverschränkung in Photonenpaaren" in rejected + + +def test_blacklist_rejected_even_when_semantic_rescues_lexical(): + # Generischer Titel: vom semantischen Kanal lexikalisch gerettet, dann aber von der + # Generika-Blacklist verworfen — Reihenfolge muss erhalten bleiben. + kept, rejected = filter_hallucinated( + _plan("System"), "irrelevanter Text ohne das Wort", + semantic_presence_fn=lambda t, txt: 0.99) + assert kept.concepts == [] + assert "System" in rejected + + +def test_rescue_respects_configurable_threshold(): + # Schwelle ist die config-Konstante; ein Wert knapp darunter wird verworfen. + from generative.agents import planner + import generative.config as cfg + # max-cos 0.49 < Default 0.50 → reject; 0.51 → rescue + plan = _plan("Bildungs-Meta-Analyse-Selektion") + text = "A meta-analysis selecting randomized controlled trials in education." + kept_low, rej_low = filter_hallucinated(plan, text, semantic_presence_fn=lambda t, x: cfg.TITLE_PRESENCE_COSINE_THRESHOLD - 0.01) + kept_high, rej_high = filter_hallucinated(plan, text, semantic_presence_fn=lambda t, x: cfg.TITLE_PRESENCE_COSINE_THRESHOLD + 0.01) + assert kept_low.concepts == [] and rej_low + assert [c.title for c in kept_high.concepts] == ["Bildungs-Meta-Analyse-Selektion"] diff --git a/generative/tests/test_redundant_sibling_flag.py b/generative/tests/test_redundant_sibling_flag.py new file mode 100644 index 0000000..02632ac --- /dev/null +++ b/generative/tests/test_redundant_sibling_flag.py @@ -0,0 +1,132 @@ +"""Tests für #8: Detektions-Flag bei hoher Body-Überlappung zwischen DISTINKTEN Notes. + +Zwei empirische Gates (Session 2026-06-23, Ebner-Audit) zeigten: Geschwister-Notes mit +hoher Body-Cosine sind weder mergebar (distinkte Konzepte: Kirkpatrick-Modell = Theorie +vs. Satisfaction-Learning-Dissoziation = Befund) noch satz-strippbar (Redundanz +paraphrasiert, nicht dupliziert — exakt 0/10, fuzzy≥0.93 nur 1/10 Sätze). Statt eines +riskanten Strips: ein seiteneffekt-freier Flag auf BEIDE Notes, der den menschlichen +Reviewer auf die Überlappung hinweist. Kein Body-Eingriff, kein Kollabieren. + +flag_redundant_siblings() läuft NACH resolve_sibling_dups + dedup_hub_subconcepts (echte +Dups/Hub-Sub schon behandelt) und vor dem Writer (Flag landet im Frontmatter). +""" +import pytest + +from generative.orchestrator import flag_redundant_siblings +from generative.schemas.atomic_note import AtomicNoteDraft + + +def _draft(title, *, body="", action="create", extend_path=None): + return AtomicNoteDraft( + title=title, + body=body or f"Body von {title}", + source_anchors=[], + related=[], + tags=[], + synthesis_confidence="high", + action=action, + extend_path=extend_path, + ) + + +def _cos_map(pairs): + """Injizierbare body_cosine_fn aus einem {(i,j): cos}-Dict (symmetrisch).""" + def fn(i, j): + return pairs.get((i, j), pairs.get((j, i), 0.0)) + return fn + + +def test_two_distinct_create_drafts_above_threshold_flag_both(): + d_a = _draft("Kirkpatrick-Modell") + d_b = _draft("Satisfaction-Learning-Dissoziation") + + kept, n = flag_redundant_siblings( + [d_a, d_b], threshold=0.90, body_cosine_fn=_cos_map({(0, 1): 0.967})) + + assert n == 1 + assert len(kept) == 2 # nichts kollabiert — beide bleiben distinkt + # BEIDE bekommen den Flag, jeweils auf den anderen verweisend + assert any("[[Satisfaction-Learning-Dissoziation]]" in f for f in d_a.quality_flags) + assert any("[[Kirkpatrick-Modell]]" in f for f in d_b.quality_flags) + # Flag enthält Review-Hinweis (kein Strip) + assert any("Review" in f for f in d_a.quality_flags) + + +def test_below_threshold_no_flag(): + d_a = _draft("Webinar als Lernformat") + d_b = _draft("Kirkpatrick-Modell") + + kept, n = flag_redundant_siblings( + [d_a, d_b], threshold=0.90, body_cosine_fn=_cos_map({(0, 1): 0.70})) + + assert n == 0 + assert d_a.quality_flags == [] + assert d_b.quality_flags == [] + + +def test_extend_drafts_excluded(): + # extend-Drafts werden bereits von resolve_sibling_dups / write_note behandelt — + # hier nicht doppelt flaggen, selbst wenn die Body-Cosine hoch wäre. + d_a = _draft("Konzept A") + d_b = _draft("Konzept B") + d_c = _draft("Vault-Dup", action="extend", extend_path="Bestehende Vault-Note") + + # alle Paare hoch — aber d_c ist extend und darf nicht verglichen werden + kept, n = flag_redundant_siblings( + [d_a, d_b, d_c], threshold=0.90, + body_cosine_fn=_cos_map({(0, 1): 0.95, (0, 2): 0.95, (1, 2): 0.95})) + + assert n == 1 # nur das create/create-Paar (a,b) + assert d_c.quality_flags == [] # extend nie geflaggt + assert any("[[Konzept B]]" in f for f in d_a.quality_flags) + + +def test_single_create_draft_noop(): + d_a = _draft("Allein") + kept, n = flag_redundant_siblings([d_a], threshold=0.90, + body_cosine_fn=_cos_map({})) + assert n == 0 + assert d_a.quality_flags == [] + + +def test_idempotent_no_duplicate_flags(): + d_a = _draft("A") + d_b = _draft("B") + cos = _cos_map({(0, 1): 0.95}) + + flag_redundant_siblings([d_a, d_b], threshold=0.90, body_cosine_fn=cos) + flag_redundant_siblings([d_a, d_b], threshold=0.90, body_cosine_fn=cos) + + # zweimal laufen darf den Flag nicht duplizieren + a_redund = [f for f in d_a.quality_flags if "Überlappung mit [[B]]" in f] + assert len(a_redund) == 1 + + +def test_one_high_one_low_among_three(): + d_a = _draft("A") + d_b = _draft("B") + d_c = _draft("C") + # a~b hoch, a~c und b~c niedrig + cos = _cos_map({(0, 1): 0.95, (0, 2): 0.40, (1, 2): 0.42}) + + kept, n = flag_redundant_siblings([d_a, d_b, d_c], threshold=0.90, + body_cosine_fn=cos) + assert n == 1 + assert d_c.quality_flags == [] # C überlappt mit niemandem + assert any("[[B]]" in f for f in d_a.quality_flags) + assert any("[[A]]" in f for f in d_b.quality_flags) + + +@pytest.mark.slow +def test_real_embeddings_default_path(): + """Ohne Injection: echtes Embedding-Modell, zwei fast identische Bodies → Flag. + Validiert dass der Default-Pfad (config-Schwelle, embeddings.embed_body/cosine) wirkt.""" + shared = ("Lernumgebungen lassen sich nach Synchronizität und Modalität " + "klassifizieren. Die Meta-Analyse fand einen kleinen positiven Effekt " + "auf die Lernleistung mit großer Heterogenität zwischen den Studien.") + d_a = _draft("Note A", body=shared) + d_b = _draft("Note B", body=shared + " Geringfügige Ergänzung.") + + kept, n = flag_redundant_siblings([d_a, d_b]) # Default-Schwelle, echtes Modell + assert n == 1 + assert any("Überlappung" in f for f in d_a.quality_flags) diff --git a/generative/tests/test_typeaware_dedup.py b/generative/tests/test_typeaware_dedup.py new file mode 100644 index 0000000..43a9728 --- /dev/null +++ b/generative/tests/test_typeaware_dedup.py @@ -0,0 +1,125 @@ +"""Tests für typ-bewusstes Dedup-Blocking (Konzept-Note ≠ Duplikat ihrer Lit-Note). + +Ebner-Run 2026-06-23: die Konzept-Note „Webinar" wurde als Duplikat-Risiko-hoch gegen die +existierende `type: literature`-Note ba-lit-ebner-gegenfurtner-2019 geflaggt — obwohl Lit- +und Konzept-Notes per Vault-Design (Schema-Lit vs. Schema-Konzept) koexistieren. Ursache: +der Dedup-Pool/-Lookup war typ-blind. Fix: literature/moc/merge-stub sind keine Dup-/Merge- +Kandidaten; related-Links über Typgrenzen bleiben erlaubt. +""" +from generative.agents.context_builder import is_dedup_eligible, note_type, resolve_vault_relpath +from generative.schemas.atomic_note import AtomicNoteDraft + + +def _write_note(path, type_, title="X"): + # Titel quoten — reale Notes tun das (Umlaute/Doppelpunkte im Titel), sonst bricht YAML. + path.write_text(f'---\ntype: {type_}\ntitle: "{title}"\n---\nKörper-Text.', encoding="utf-8") + + +def _draft(title, action="create", extend_path=None): + return AtomicNoteDraft( + title=title, body="Körper-Text der Note.", source_anchors=[], related=[], + tags=[], synthesis_confidence="high", aliases=[], + action=action, extend_path=extend_path) + + +def test_note_type_reads_frontmatter(tmp_path): + p = tmp_path / "n.md" + _write_note(p, "Literature") # Groß/Klein egal + assert note_type(p) == "literature" + + +def test_is_dedup_eligible_excludes_literature_moc_stub(tmp_path): + for t, expected in [("literature", False), ("moc", False), ("merge-stub", False), + ("atomic", True), ("note", True), ("", True)]: + p = tmp_path / f"{t or 'empty'}.md" + _write_note(p, t) + assert is_dedup_eligible(p) is expected, t + + +def test_find_existing_in_vault_skips_literature(tmp_path, monkeypatch): + import generative.pipeline.vault_writer as vw + monkeypatch.setattr(vw, "VAULT", tmp_path) + _write_note(tmp_path / "ba-lit-ebner.md", "literature", title="BA — Lit: Ebner") + ec = {"webinar": "ba-lit-ebner.md"} # Titel/Alias-Treffer zeigt auf eine Lit-Note + assert vw.find_existing_in_vault("Webinar", [], ec) is None + + +def test_find_existing_in_vault_returns_concept(tmp_path, monkeypatch): + import generative.pipeline.vault_writer as vw + monkeypatch.setattr(vw, "VAULT", tmp_path) + _write_note(tmp_path / "webinar.md", "atomic", title="Webinar") + ec = {"webinar": "webinar.md"} + assert vw.find_existing_in_vault("Webinar", [], ec) == tmp_path / "webinar.md" + + +def test_find_existing_alias_finds_concept_after_skipping_lit(tmp_path, monkeypatch): + # Titel trifft eine Lit-Note (übersprungen), ein Alias trifft eine echte Konzept-Note. + import generative.pipeline.vault_writer as vw + monkeypatch.setattr(vw, "VAULT", tmp_path) + _write_note(tmp_path / "lit.md", "literature") + _write_note(tmp_path / "concept.md", "atomic") + ec = {"webinar": "lit.md", "synchrones online-lehrformat": "concept.md"} + got = vw.find_existing_in_vault("Webinar", ["synchrones Online-Lehrformat"], ec) + assert got == tmp_path / "concept.md" + + +def test_cross_reference_dup_target_eligible(tmp_path, monkeypatch): + from generative.agents import cross_reference as cr + monkeypatch.setattr(cr, "VAULT", tmp_path) + _write_note(tmp_path / "ba-lit-ebner-gegenfurtner-2019.md", "literature") + _write_note(tmp_path / "affective-access.md", "atomic") + ec = { + "ba — lit: ebner & gegenfurtner (2019)": "ba-lit-ebner-gegenfurtner-2019.md", + "affective access": "affective-access.md", + } + # dup_path wie vom LLM geliefert: der Datei-Stem (so werden Kandidaten präsentiert). + assert cr._dup_target_eligible("ba-lit-ebner-gegenfurtner-2019", ec) is False # Lit → kein Dup + assert cr._dup_target_eligible("affective-access", ec) is True # Konzept → Dup ok + assert cr._dup_target_eligible("Nicht-Im-Vault-Sibling", ec) is True # unauflösbar → unverändert + assert cr._dup_target_eligible("affective access", ec) is True # Titel-Treffer (nicht Stem) + + +# --- #2b: write_note honoriert extend_path typ-sicher (Vault-Konzept-Dup mit anderem Titel) --- + +def test_resolve_vault_relpath_stem_title_wikilink(): + ec = {"wilson information need": "04-wissen/wilson-information-need.md"} + assert resolve_vault_relpath("wilson-information-need", ec) == "04-wissen/wilson-information-need.md" # Stem + assert resolve_vault_relpath("Wilson Information Need", ec) == "04-wissen/wilson-information-need.md" # Titel + assert resolve_vault_relpath("[[wilson-information-need]]", ec) == "04-wissen/wilson-information-need.md" # Wikilink + assert resolve_vault_relpath("Unbekannt", ec) is None + assert resolve_vault_relpath("", ec) is None + + +def test_resolve_vault_relpath_ambiguous_stem_returns_none(): + # Zwei Notes mit gleichem Datei-Stem in verschiedenen Ordnern → mehrdeutig → None + # (kein willkürlicher Treffer → kein Fehl-Merge). Titel-Treffer bleibt eindeutig. + ec = {"konzept a": "04-wissen/webinar.md", "konzept b": "01-studium/webinar.md"} + assert resolve_vault_relpath("webinar", ec) is None + assert resolve_vault_relpath("Konzept A", ec) == "04-wissen/webinar.md" + + +def test_write_note_honors_extend_path_to_concept(tmp_path, monkeypatch): + # Draft-Titel ≠ Vault-Titel, aber extend_path zeigt (per Stem) auf eine Konzept-Note. + # find_existing_in_vault matcht nicht → früher Dublette; jetzt via extend_path → Merge-Stub. + import generative.pipeline.vault_writer as vw + monkeypatch.setattr(vw, "VAULT", tmp_path) + _write_note(tmp_path / "wilson-information-need.md", "atomic", title="Wilson Information Need") + inbox = tmp_path / "inbox"; inbox.mkdir() + ec = {"wilson information need": "wilson-information-need.md"} + d = _draft("Information Need", action="extend", extend_path="wilson-information-need") + target = vw.write_note(d, source_file="X.pdf", dry_run=False, existing_concepts=ec, inbox_dir=inbox) + assert "MERGE" in target.name # Merge-Stub statt create-Dublette + assert target.exists() + + +def test_write_note_extend_path_to_literature_not_merged(tmp_path, monkeypatch): + # Defense-in-depth: zeigt extend_path (entgegen #2a) doch auf eine Lit-Note → KEIN Merge. + import generative.pipeline.vault_writer as vw + monkeypatch.setattr(vw, "VAULT", tmp_path) + _write_note(tmp_path / "ba-lit-x.md", "literature", title="BA Lit X") + inbox = tmp_path / "inbox"; inbox.mkdir() + ec = {"ba lit x": "ba-lit-x.md"} + d = _draft("Webinar", action="extend", extend_path="ba-lit-x") + target = vw.write_note(d, source_file="X.pdf", dry_run=False, existing_concepts=ec, inbox_dir=inbox) + assert "MERGE" not in target.name # keine Lit-Note als Merge-Ziel → normale create-Note + assert target.exists()