diff --git a/generative/agents/extractor.py b/generative/agents/extractor.py index dddd450..62d123b 100644 --- a/generative/agents/extractor.py +++ b/generative/agents/extractor.py @@ -15,10 +15,14 @@ from generative.schemas.atomic_note import AtomicNoteDraft, TextAnchor, ConceptPlan from generative.schemas.citation import CitationMeta -_PROMPT = """Du extrahierst eine Atomic Note aus dem unten stehenden Textabschnitt — schemakonform für einen Obsidian-Vault. - -## Quellen-Metadaten (NUR diese verwenden — niemals andere Autorennamen erfinden) -{source_meta} +## Prefix-Cache-Layout (#147): Alle call-variablen Blöcke (Whitelist-Sortierung, +## Konzepte, existing, Task-Hints, Chunk) stehen am ENDE des Templates, der +## statische Instruktions-Kern vorn. Anthropic-Prompt-Caching matcht token- +## präfix-basiert — ein variabler Block vorn beendet den cachebaren Präfix und +## macht die statischen Regeln bei jedem Fan-out-Call zur teuren Cache-Neuanlage +## (gemessen: 41 % der Input-Seite). Neue Blöcke: Statisches vor die Quellen- +## Metadaten, Variables dahinter. test_extractor_prompt_layout.py wacht darüber. +_PROMPT = """Du extrahierst eine Atomic Note aus dem unten stehenden Textabschnitt — schemakonform für einen Obsidian-Vault. Quellen-Metadaten, Ziel-Konzepte und der Textabschnitt folgen nach den Regeln. ## Wichtigste Regel — Quellenbindung JEDE inhaltliche Aussage muss aus dem Text unten stammen. Erfinde NICHTS aus deinem Gedächtnis. @@ -83,11 +87,9 @@ Nur bei der ersten Erwähnung. Danach reicht der Nachname. ## Tags (F10 — STRIKT aus Whitelist für `tags`, NIE erfinden) -Wähle 1–3 `tags` AUSSCHLIESSLICH aus der untenstehenden Whitelist. Diese Tags steuern Auto-Note-Mover-Routing und sind autoritativ — Erfindung ist verboten. - -{tag_whitelist} +Wähle 1–3 `tags` AUSSCHLIESSLICH aus der Whitelist (Abschnitt „Tag-Whitelist" weiter unten). Diese Tags steuern Auto-Note-Mover-Routing und sind autoritativ — Erfindung ist verboten. -Faustregel: Domain-passende Tags aus dem **Quellnah**-Block bevorzugen wenn vorhanden, **Übrige**-Block nur wenn dort ein wirklich passender Tag steht. KEINEN `uni/ibi/konzept` oder `bachelorarbeit` als Default — nur wenn die Quelle tatsächlich aus einem IBI-/BA-Kontext stammt. Lieber 1 thematisch korrekter Tag als 2–3 mit fehlpassendem Domain-Tag. Wenn nichts in der Whitelist wirklich passt: `tags:` leer lassen. +Faustregel: Domain-passende Tags aus dem **Quellnah**-Block der Whitelist bevorzugen wenn vorhanden, **Übrige**-Block nur wenn dort ein wirklich passender Tag steht. KEINEN `uni/ibi/konzept` oder `bachelorarbeit` als Default — nur wenn die Quelle tatsächlich aus einem IBI-/BA-Kontext stammt. Lieber 1 thematisch korrekter Tag als 2–3 mit fehlpassendem Domain-Tag. Wenn nichts in der Whitelist wirklich passt: `tags:` leer lassen. ## Proposed-Tags (Bootstrap für neue Domains) Wenn KEIN passender Tag in der Whitelist existiert UND die Quelle klar eine neue Domain markiert (z.B. Change-Management ohne `change-management`-Tag im Vault), darfst du in `proposed-tags` 1–2 Vorschläge machen. Strikte Konvention: @@ -97,13 +99,7 @@ - **Kein Routing** — Proposed-Tags lösen kein Auto-Note-Mover aus. User reviewed beim Inbox-Triage. - Wenn Whitelist-Tags ausreichen: `proposed-tags:` leer lassen. -## Ziel-Konzepte (vom Planner) -{concepts} - -## Bereits existierende Notes (nicht duplizieren — bei starker Überschneidung action="extend" mit extend_path) -{existing} - -{background_block}{related_mentions_block}## Output — NUR dieses Format, kein erklärender Text, KEINE JSON-Codeblöcke: +## Output — NUR dieses Format, kein erklärender Text, KEINE JSON-Codeblöcke: title: Konzeptname (knapp, EINE Idee) @@ -137,7 +133,19 @@ Wenn ein Konzept aus der Liste nicht im Text vorkommt: keinen -Block ausgeben. Direkt zum nächsten Konzept oder zum finalen . Kein Kommentar, keine Erklärung, keine Abwesenheits-Notiz — stummes Weglassen. -## Textabschnitt: {chunk_title} +## Quellen-Metadaten (NUR diese verwenden — niemals andere Autorennamen erfinden) +{source_meta} + +## Tag-Whitelist (für `tags` — Regeln siehe Abschnitt „Tags" oben) +{tag_whitelist} + +## Ziel-Konzepte (vom Planner) +{concepts} + +## Bereits existierende Notes (nicht duplizieren — bei starker Überschneidung action="extend" mit extend_path) +{existing} + +{background_block}{related_mentions_block}{task_hints}## Textabschnitt: {chunk_title} {chunk_text} """ @@ -390,19 +398,20 @@ async def run_per_concept( "Adressiere diesen Punkt direkt in der neuen Version.\n\n" ) - prompt = ( - refine_block - + _PROMPT.format( - source_meta=_format_source_meta(citation), - author_short=_short_author(citation), - concepts=concepts_str, - existing=existing_str or "(noch keine)", - background_block=_format_background_block(background_context), - related_mentions_block=_format_related_mentions(related_mentions), - tag_whitelist=_format_tag_whitelist(tag_whitelist, source_text=concept_text), - chunk_title=concept.title, - chunk_text=concept_text, # pdf_chunker.concept_text_window liefert bereits gerankte Top-Fenster (Option D, max_chars=8000) - ) + # #147: refine_block wird als task_hints ans variable Prompt-ENDE gereicht + # (direkt vor den Textabschnitt — hohe Salienz), nicht mehr vorangestellt: + # ein Präfix-Block würde den Prompt-Cache-Präfix aller Fan-out-Calls brechen. + prompt = _PROMPT.format( + source_meta=_format_source_meta(citation), + author_short=_short_author(citation), + concepts=concepts_str, + existing=existing_str or "(noch keine)", + background_block=_format_background_block(background_context), + related_mentions_block=_format_related_mentions(related_mentions), + tag_whitelist=_format_tag_whitelist(tag_whitelist, source_text=concept_text), + task_hints=refine_block, + chunk_title=concept.title, + chunk_text=concept_text, # pdf_chunker.concept_text_window liefert bereits gerankte Top-Fenster (Option D, max_chars=8000) ) raw = await call_claude_async(prompt, model=MODEL_EXTRACTOR, agent="extractor") @@ -440,7 +449,9 @@ async def run_per_concept( "Sätze vollständig mit Satzendzeichen. Empirie-Phase auf 1–2 Sätze " "kürzen wenn Platzdruck. Definition + Substanz haben Vorrang." ) - retry_prompt = (f"## Trunkierungs-Hinweis (höchste Priorität)\n{trunc_hint}\n\n") + _PROMPT.format( + # #147: Hinweis als task_hints ans variable Ende — der Retry teilt sich so + # den statischen Prompt-Präfix mit dem Erst-Call (Cache-Read statt -Neuanlage). + retry_prompt = _PROMPT.format( source_meta=_format_source_meta(citation), author_short=_short_author(citation), concepts=concepts_str, @@ -448,6 +459,7 @@ async def run_per_concept( background_block=_format_background_block(background_context), related_mentions_block=_format_related_mentions(related_mentions), tag_whitelist=_format_tag_whitelist(tag_whitelist, source_text=concept_text), + task_hints=f"## Trunkierungs-Hinweis (höchste Priorität)\n{trunc_hint}\n\n", chunk_title=concept.title, chunk_text=concept_text[:8000], ) @@ -510,6 +522,7 @@ async def run( background_block="", # run() hat kein background_context — legacy-Pfad related_mentions_block="", # run() hat kein related_mentions — legacy-Pfad tag_whitelist=_format_tag_whitelist(tag_whitelist, source_text=chunk_text), + task_hints="", # run() kennt weder Self-Refine noch Trunkierungs-Retry chunk_title=chunk_title, chunk_text=chunk_text[:8000], ) diff --git a/generative/tests/test_extractor_prompt_layout.py b/generative/tests/test_extractor_prompt_layout.py new file mode 100644 index 0000000..8bfdeb2 --- /dev/null +++ b/generative/tests/test_extractor_prompt_layout.py @@ -0,0 +1,144 @@ +"""Layout-Wächter für den Extractor-Prompt (#147, Prefix-Cache-Ordnung). + +Anthropic-Prompt-Caching matcht token-präfix-basiert: Der cachebare Präfix +endet am ersten Token, das sich zwischen Fan-out-Calls unterscheidet. Diese +Tests erzwingen, dass alle call-variablen Blöcke (Whitelist-Sortierung, +Konzepte, existing, Task-Hints, Chunk) HINTER dem statischen Instruktions-Kern +liegen. Regression (variabler Block rutscht nach vorn) = die statischen Regeln +werden bei jedem Fan-out-Call wieder zur teuren Cache-Neuanlage (real gemessen +41 % der Input-Seite, Issue #147). +""" + +import asyncio +import os + +from generative.agents import extractor +from generative.schemas.atomic_note import ConceptItem +from generative.schemas.citation import CitationMeta + +# Letzter Satz des statischen Instruktions-Kerns — alles danach ist call-variabel. +_STATIC_TAIL_SENTINEL = "stummes Weglassen." + +_VARIABLE_PLACEHOLDERS = ( + "{source_meta}", + "{tag_whitelist}", + "{concepts}", + "{existing}", + "{background_block}", + "{related_mentions_block}", + "{task_hints}", + "{chunk_title}", + "{chunk_text}", +) + + +def test_template_variable_blocks_after_static_core(): + tpl = extractor._PROMPT + static_end = tpl.index(_STATIC_TAIL_SENTINEL) + for placeholder in _VARIABLE_PLACEHOLDERS: + assert tpl.index(placeholder) > static_end, ( + f"{placeholder} liegt vor dem Ende des statischen Kerns — " + "bricht den Prompt-Cache-Präfix aller Fan-out-Calls (#147)" + ) + # Der Chunk bleibt das letzte Element des Prompts. + assert tpl.rstrip().endswith("{chunk_text}") + + +def _concept(title: str) -> ConceptItem: + return ConceptItem(title=title, priority="high", chapter="1", action="create") + + +def _capture_prompts(monkeypatch, outputs: list[str], calls: list[dict]) -> None: + """Ersetzt den LLM-Call: captured Prompts, liefert vorgegebene Outputs.""" + + async def fake_call(prompt, **_kw): + calls.append({"prompt": prompt}) + return outputs[min(len(calls) - 1, len(outputs) - 1)] + + monkeypatch.setattr(extractor, "call_claude_async", fake_call) + + +_CITATION = CitationMeta(author="Kuhlthau, Carol", year="1991", title="ISP", doi=None, source_file="isp.pdf") +_WHITELIST = ["uni/ibi", "methoden"] + + +def test_fanout_prompts_share_static_prefix(monkeypatch): + """Zwei Calls verschiedener Konzepte müssen den kompletten statischen Kern + als gemeinsamen String-Präfix teilen — die Voraussetzung für Cache-Reads.""" + calls: list[dict] = [] + _capture_prompts(monkeypatch, [""], calls) + + for title, text in (("Konzept Alpha", "Text über Alpha."), ("Konzept Beta", "Ganz anderer Text über Beta.")): + result = asyncio.run( + extractor.run_per_concept( + _concept(title), text, {"Alte Note": "04-wissen/alt.md"}, citation=_CITATION, tag_whitelist=_WHITELIST + ) + ) + assert result is None # leerer Fake-Output → None; Prompt ist trotzdem captured + + assert len(calls) == 2 + common = os.path.commonprefix([calls[0]["prompt"], calls[1]["prompt"]]) + assert _STATIC_TAIL_SENTINEL in common, ( + "Der statische Instruktions-Kern ist NICHT im gemeinsamen Präfix der " + "Fan-out-Prompts — ein call-variabler Block steht zu weit vorn (#147)" + ) + + +def test_refine_hint_goes_to_variable_tail_not_prompt_start(monkeypatch): + """Self-Refine-Hinweis darf den Prompt-Präfix nicht mehr brechen: Er steht + im variablen Schwanz (nach 'existing', vor dem Textabschnitt), nicht vorn.""" + calls: list[dict] = [] + _capture_prompts(monkeypatch, [""], calls) + + asyncio.run( + extractor.run_per_concept( + _concept("Konzept Alpha"), + "Text über Alpha.", + {}, + citation=_CITATION, + tag_whitelist=_WHITELIST, + revision_hint="Empirie-Teil kürzen.", + ) + ) + prompt = calls[0]["prompt"] + assert prompt.startswith("Du extrahierst"), "Prompt beginnt nicht mehr mit dem statischen Kern" + hint_pos = prompt.index("## Revision-Hinweis") + assert hint_pos > prompt.index("## Bereits existierende Notes") + assert hint_pos < prompt.index("## Textabschnitt:") + + +_TRUNCATED_OUTPUT = """ +title: Konzept Alpha +aliases: Alpha +tags: uni/ibi +proposed_tags: +synthesis_confidence: low +action: create +extend_path: + +# Konzept Alpha: Ein Kernsatz + +Dieser Body bricht mitten im Satz ab und endet auf ein + +""" + + +def test_truncation_retry_shares_static_prefix(monkeypatch): + """Der Trunkierungs-Retry stellt den Hinweis nicht mehr voran: Erst- und + Retry-Prompt teilen den statischen Präfix (Cache-Read statt Neuanlage).""" + calls: list[dict] = [] + _capture_prompts(monkeypatch, [_TRUNCATED_OUTPUT, ""], calls) + + asyncio.run( + extractor.run_per_concept( + _concept("Konzept Alpha"), "Text über Alpha.", {}, citation=_CITATION, tag_whitelist=_WHITELIST + ) + ) + + assert len(calls) == 2, "Trunkierungs-Retry hat nicht gefeuert — Fixture-Body endet nicht unvollständig?" + retry_prompt = calls[1]["prompt"] + assert retry_prompt.startswith("Du extrahierst") + assert "## Trunkierungs-Hinweis" in retry_prompt + assert retry_prompt.index("## Trunkierungs-Hinweis") < retry_prompt.index("## Textabschnitt:") + common = os.path.commonprefix([calls[0]["prompt"], retry_prompt]) + assert _STATIC_TAIL_SENTINEL in common diff --git a/generative/tools/cache_report.py b/generative/tools/cache_report.py new file mode 100644 index 0000000..5134be8 --- /dev/null +++ b/generative/tools/cache_report.py @@ -0,0 +1,85 @@ +"""Cache-Auswertung eines Run-Traces — Messwerkzeug für #147 (Prefix-Cache). + +Aggregiert pro Agent die echten LLM-Calls (Response-Cache-Hits separat), +Input-/Output-Tokens und cache_read vs. cache_creation. Der Creation-Anteil +an der Cache-Seite ist die Kennzahl des #147-Umbaus: gesund ist Creation nur +in der ersten Fan-out-Welle, danach Reads. + +Aufruf: + uv run python -m generative.tools.cache_report [trace.jsonl] + +Ohne Argument wird der neueste Trace unter generative/.cache/runs/ genommen. +""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from pathlib import Path + +from generative.config import CACHE_DIR + + +def aggregate(trace_path: Path) -> dict[str, dict]: + agg: dict[str, dict] = defaultdict( + lambda: {"calls": 0, "resp_cache_hits": 0, "in": 0, "out": 0, "read": 0, "create": 0} + ) + with trace_path.open(encoding="utf-8") as f: + for line in f: + try: + r = json.loads(line) + except json.JSONDecodeError: + continue + if "model" not in r: + continue + a = agg[r.get("agent", "?")] + if r.get("cached"): + a["resp_cache_hits"] += 1 + continue + a["calls"] += 1 + a["in"] += r.get("input_tokens", 0) or 0 + a["out"] += r.get("output_tokens", 0) or 0 + a["read"] += r.get("cache_read_tokens", 0) or 0 + a["create"] += r.get("cache_creation_tokens", 0) or 0 + return dict(agg) + + +def render(agg: dict[str, dict]) -> str: + lines = [ + f"{'agent':<16}{'calls':>6}{'hits':>6}{'input':>10}{'output':>9}{'cache_read':>12}{'cache_create':>13}{'create%':>9}" + ] + tot = {"calls": 0, "in": 0, "out": 0, "read": 0, "create": 0} + for agent, a in sorted(agg.items()): + cache_side = a["read"] + a["create"] + pct = (100 * a["create"] / cache_side) if cache_side else 0.0 + lines.append( + f"{agent:<16}{a['calls']:>6}{a['resp_cache_hits']:>6}{a['in']:>10}{a['out']:>9}" + f"{a['read']:>12}{a['create']:>13}{pct:>8.1f}%" + ) + for k in tot: + tot[k] += a[k] + cache_side = tot["read"] + tot["create"] + pct = (100 * tot["create"] / cache_side) if cache_side else 0.0 + lines.append("-" * 81) + lines.append( + f"{'TOTAL':<16}{tot['calls']:>6}{'':>6}{tot['in']:>10}{tot['out']:>9}" + f"{tot['read']:>12}{tot['create']:>13}{pct:>8.1f}%" + ) + return "\n".join(lines) + + +def main() -> None: + if len(sys.argv) > 1: + trace = Path(sys.argv[1]) + else: + runs = sorted((CACHE_DIR / "runs").glob("*.jsonl")) + if not runs: + raise SystemExit("Kein Trace unter generative/.cache/runs/ gefunden.") + trace = runs[-1] + print(f"Trace: {trace}") + print(render(aggregate(trace))) + + +if __name__ == "__main__": + main()