Skip to content
Draft
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
73 changes: 43 additions & 30 deletions generative/agents/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:

<!--NOTE-->
title: Konzeptname (knapp, EINE Idee)
Expand Down Expand Up @@ -137,7 +133,19 @@

Wenn ein Konzept aus der Liste nicht im Text vorkommt: keinen <!--NOTE-->-Block ausgeben. Direkt zum nächsten Konzept oder zum finalen <!--END-->. 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}
"""

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -440,14 +449,17 @@ 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,
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=f"## Trunkierungs-Hinweis (höchste Priorität)\n{trunc_hint}\n\n",
chunk_title=concept.title,
chunk_text=concept_text[:8000],
)
Expand Down Expand Up @@ -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],
)
Expand Down
144 changes: 144 additions & 0 deletions generative/tests/test_extractor_prompt_layout.py
Original file line number Diff line number Diff line change
@@ -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 = """<!--NOTE-->
title: Konzept Alpha
aliases: Alpha
tags: uni/ibi
proposed_tags:
synthesis_confidence: low
action: create
extend_path:
<!--BODY-->
# Konzept Alpha: Ein Kernsatz

Dieser Body bricht mitten im Satz ab und endet auf ein
<!--END-->
"""


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
85 changes: 85 additions & 0 deletions generative/tools/cache_report.py
Original file line number Diff line number Diff line change
@@ -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()
Loading