Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions generative/agents/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
40 changes: 39 additions & 1 deletion generative/agents/cross_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
74 changes: 62 additions & 12 deletions generative/agents/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions generative/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions generative/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading