MVP 0.94
This commit is contained in:
parent
f17b3083e8
commit
4f37991ad1
145
backend/config/generation_instructions.seed.json
Normal file
145
backend/config/generation_instructions.seed.json
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
{
|
||||||
|
"seed_revision": "2026-08-27-generation-guidelines-v1",
|
||||||
|
"purpose": "journal_generate",
|
||||||
|
"max_instruction_chars": 1000,
|
||||||
|
"max_label_chars": 80,
|
||||||
|
"max_summary_chars": 160,
|
||||||
|
"slots": {
|
||||||
|
"transformation": {
|
||||||
|
"instruction_key": "transformation_instructions",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "journal-generate-transformation-correction",
|
||||||
|
"guideline_key": "correction",
|
||||||
|
"label": "behutsam",
|
||||||
|
"summary": "Nur Korrektur von Sprache und Zeichensetzung.",
|
||||||
|
"instruction": "Korrigiere nur Rechtschreibung, Grammatik und Zeichensetzung. Behalte Satzbau, Reihenfolge und Formulierungen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-transformation-copyedit",
|
||||||
|
"guideline_key": "copyedit",
|
||||||
|
"label": "redaktionell",
|
||||||
|
"summary": "Glättet Übergänge und behält die vorhandene Struktur.",
|
||||||
|
"instruction": "Glätte holprige Stellen und verbessere Übergänge. Behalte die vorhandene Satz- und Absatzstruktur weitgehend."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-transformation-reshape",
|
||||||
|
"guideline_key": "reshape",
|
||||||
|
"label": "spürbar",
|
||||||
|
"summary": "Formuliert schwache Stellen neu, wenn die Lesbarkeit gewinnt.",
|
||||||
|
"instruction": "Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Die Grundstruktur darf sich ändern, wenn die Lesbarkeit gewinnt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-transformation-substantial",
|
||||||
|
"guideline_key": "substantial",
|
||||||
|
"label": "substanziell",
|
||||||
|
"summary": "Überarbeitet Sätze und Absätze eigenständig.",
|
||||||
|
"instruction": "Überarbeite den Text substanziell. Formuliere Sätze und Absätze eigenständig neu, wenn dadurch Lesbarkeit, Rhythmus oder Wirkung gewinnen. Bewahre nicht automatisch die Quellsyntax.",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"instruction_key": "detail_instructions",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "journal-generate-detail-compact",
|
||||||
|
"guideline_key": "compact",
|
||||||
|
"label": "kompakt",
|
||||||
|
"summary": "Nur die tragenden belegten Ereignisse.",
|
||||||
|
"instruction": "Wähle nur die tragenden belegten Ereignisse. Lass nebensächliche Einzelheiten weg."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-detail-selected",
|
||||||
|
"guideline_key": "selected",
|
||||||
|
"label": "ausgewählt",
|
||||||
|
"summary": "Wichtige Ereignisse und wenige markante Details.",
|
||||||
|
"instruction": "Behalte die wichtigen belegten Ereignisse und wenige markante Details."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-detail-broad",
|
||||||
|
"guideline_key": "broad",
|
||||||
|
"label": "weitgehend",
|
||||||
|
"summary": "Die meisten belegten Ereignisse und einmaligen Details.",
|
||||||
|
"instruction": "Behalte die meisten belegten Ereignisse und einmaligen Details. Kürze nur Offensichtliches."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-detail-complete",
|
||||||
|
"guideline_key": "complete",
|
||||||
|
"label": "vollständig",
|
||||||
|
"summary": "Alle belegten Ereignisse; nur echte Wiederholungen entfernen.",
|
||||||
|
"instruction": "Erhalte sämtliche belegten Ereignisse und einmaligen Details. Entferne nur echte Wiederholungen.",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"instruction_key": "voice_instructions",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "journal-generate-voice-neutral",
|
||||||
|
"guideline_key": "neutral",
|
||||||
|
"label": "neutral",
|
||||||
|
"summary": "Neutraler Journalstil, Writing Profile nur als leise Tendenz.",
|
||||||
|
"instruction": "Schreibe in einem neutralen Journalstil. Das Writing Profile höchstens als leise Tendenz."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-voice-light",
|
||||||
|
"guideline_key": "light",
|
||||||
|
"label": "dezent",
|
||||||
|
"summary": "Rhythmus und Wortwahl des Writing Profiles zurückhaltend.",
|
||||||
|
"instruction": "Nimm Rhythmus und Wortwahl des Writing Profiles zurückhaltend auf."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-voice-noticeable",
|
||||||
|
"guideline_key": "noticeable",
|
||||||
|
"label": "spürbar",
|
||||||
|
"summary": "Writing Profile spürbar, ohne in den Vordergrund zu treten.",
|
||||||
|
"instruction": "Wende das Writing Profile spürbar an, ohne es in den Vordergrund zu stellen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-voice-clear",
|
||||||
|
"guideline_key": "clear",
|
||||||
|
"label": "deutlich",
|
||||||
|
"summary": "Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich.",
|
||||||
|
"instruction": "Wende Rhythmus, Wortwahl und Reflexionsdichte des Writing Profiles deutlich an, ohne den Text künstlich zu literarisieren.",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"narrative": {
|
||||||
|
"instruction_key": "narrative_instructions",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "journal-generate-narrative-chronicle",
|
||||||
|
"guideline_key": "chronicle",
|
||||||
|
"label": "Chronik",
|
||||||
|
"summary": "Sachlich in der Reihenfolge der Quellen.",
|
||||||
|
"instruction": "Erzähle sachlich in der Reihenfolge der Quellen. Gewichte nichts extra."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-narrative-structured",
|
||||||
|
"guideline_key": "structured",
|
||||||
|
"label": "gegliedert",
|
||||||
|
"summary": "Klar gegliedert, ohne besondere Momente extra herauszustellen.",
|
||||||
|
"instruction": "Gliedere den Tag klar. Besondere Momente nicht extra herausstellen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-narrative-weighted",
|
||||||
|
"guideline_key": "weighted",
|
||||||
|
"label": "gewichtet",
|
||||||
|
"summary": "Belegte Kontraste und besondere Momente erkennbar.",
|
||||||
|
"instruction": "Arbeite belegte Kontraste und besondere Momente erkennbar heraus. Erzeuge daraus keine neue Dramatik, Bewertung oder Kausalität.",
|
||||||
|
"is_default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "journal-generate-narrative-emphasized",
|
||||||
|
"guideline_key": "emphasized",
|
||||||
|
"label": "betont",
|
||||||
|
"summary": "Belegte Kontraste und Höhepunkte im Vordergrund.",
|
||||||
|
"instruction": "Stelle belegte Kontraste und Höhepunkte deutlich in den Vordergrund. Erzeuge daraus keine neue Dramatik, Bewertung oder Kausalität."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
"id": "mvp-journal-generate",
|
"id": "mvp-journal-generate",
|
||||||
"slug": "mvp.journal_generate",
|
"slug": "mvp.journal_generate",
|
||||||
"name": "MVP Journalentwurf",
|
"name": "MVP Journalentwurf",
|
||||||
"description": "Stufe 2: persönliche Narration aus dem lokalen Verified Artifact. Explizit ausgelöst. Nicht der Dialogzug.",
|
"description": "Stufe 2: persönliche Narration mit journalspezifischer Generation Policy. Explizit ausgelöst. Nicht der Dialogzug.",
|
||||||
"category": "mvp",
|
"category": "mvp",
|
||||||
"prompt_type": "base",
|
"prompt_type": "base",
|
||||||
"required_feature": "ai_calls",
|
"required_feature": "ai_calls",
|
||||||
"template": "Schreibe eine inhaltstreue, redaktionell verbesserte Tagebuchfassung in der Ich-Form von [[SELF]].\n\nPriorität, höher schlägt niedriger:\n1. Keine neuen Informationen erfinden.\n2. Tatsachen, Bedeutung, Unsicherheit, Verneinung sowie Plan versus Vollzug bewahren.\n3. Rechtschreibung, Grammatik und Zeichensetzung korrigieren.\n4. Lesbarkeit, Satzbau, Wiederholungen, Absätze und Übergänge verbessern.\n5. WRITING_PROFILE und STYLE_EXAMPLES anwenden.\n6. Gute Originalformulierungen erhalten; schwache Formulierungen verbessern.\n7. Nur Titel und fertigen Journaltext ausgeben.\n\nFaktentreue ist nicht Wortlauttreue. Paraphrasieren und neu strukturieren ist erlaubt. Rechtschreibfehler sind keine geschützten Fakten. Unsicherheit bleibt Unsicherheit, muss aber nicht wortgleich bleiben. Sprachlich nötige Übergänge sind erlaubt, solange sie keine neue Ursache, Bewertung oder Handlung behaupten. Direkte Rede und bewusst stilprägende Formulierungen dürfen bleiben. Der Text muss nicht künstlich vom Ausgangstext abweichen. Ein hoher Wortlautanteil ist erlaubt, wenn der Ausgangstext bereits gut ist. Ein nahezu unveränderter Text mit übernommenen Fehlern und schwachen Übergängen erfüllt den Auftrag nicht.\n\nEDITORIAL_MODE: {{editorial_mode}}\n{{editorial_instructions}}\n\nKurze synthetische Arbeitsbeispiele, keine inhaltliche Schablone für den heutigen Tag:\nprose_edit — Rohtext: «ich gieng zum laden und es war kald.» wird zu «Ich ging zum Laden, und es war kalt.»\nnotes_to_journal — Rohtext: «- laden; - brot; - später park» wird zu «Im Laden holte ich Brot. Später war ich im Park.»\n\nWRITING_PROFILE (nur Schreibweise, keine zusätzlichen Tatsachen):\n{{writing_profile}}\n\nSTYLE_EXAMPLES (nur Ton, Rhythmus, sprachliche Entscheidungen; Inhalte nicht übernehmen):\n{{style_examples}}\n\nCURRENT_DAY_SOURCES (einzige Tatsachen des heutigen Eintrags, keine Ausgabevorlage):\n{{reconstruction}}\n\nEXISTING_TEXT, nur wenn ausdrücklich gewünscht:\n{{existing_text}}\n\nNamen und Orte nur als die im Kontext bereits vorhandenen Platzhalter schreiben, zeichengetreu und unverändert. Keine Klarnamen. Keine neuen Platzhalter. Keine Auslassungspunkte in doppelten Klammern. Ausgabe: erste Zeile kurze Überschrift, danach zusammenhängende Absätze. Keine Meta-Kommentare.\n",
|
"template": "Du redigierst einen persönlichen Tagebucheintrag in der Ich-Form.\n\nAUFGABE\nFormuliere aus CURRENT_DAY_SOURCES einen lesenswerten, eigenständigen Journaltext. Die Quellen bestimmen, was geschehen ist. WRITING_PROFILE bestimmt, wie es erzählt wird. Quellsyntax, Rechtschreibfehler und Dialogstruktur sind keine Ausgabevorlage.\n\nBEARBEITUNG\n{{transformation_instructions}}\n{{detail_instructions}}\n{{voice_instructions}}\n{{narrative_instructions}}\n\nINHALTSTREUE\nVerändere keine Ereignisse, Beteiligten, Orte, Zeiten oder zeitlichen Abläufe. Erhalte Verneinungen, Unsicherheiten, Korrekturen, ausdrücklich genannte Gefühle und Bewertungen sowie den Unterschied zwischen Plan und Vollzug. Ergänze keine neuen Tatsachen, Ursachen, Motive, Gefühle oder Hintergrundinformationen. Übergänge dürfen verbinden, aber nichts erklären, was die Quellen nicht erklären. Bei unklaren Satzfragmenten nicht raten, sondern neutral umformulieren oder nur den unverständlichen Teil weglassen.\n\nSTIL\nKorrigiere Rechtschreibung, Grammatik und Zeichensetzung. Verbessere Satzbau, Rhythmus, Übergänge und Absätze. Fasse echte Wiederholungen zusammen. Formuliere schwache oder fehlerhafte Stellen eigenständig neu. Gute persönliche Formulierungen dürfen erhalten bleiben. STYLE_EXAMPLES dienen ausschließlich als Stilreferenz; ihre Inhalte gehören nicht zum heutigen Tag.\nDie Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus.\n\nDATENSCHUTZ\nÜbernimm vorhandene Platzhalter wie [[PERSON:01]] unverändert. Erzeuge keine neuen Platzhalter und schreibe keine Klarnamen an ihre Stelle.\n\nKONTEXT\n\nWRITING_PROFILE\n{{writing_profile}}\n\nSTYLE_EXAMPLES\n{{style_examples}}\n\nCURRENT_DAY_SOURCES\n{{reconstruction}}\n\nEXISTING_TEXT\n{{existing_text}}\n\nAUSGABE\nErste Zeile: eine kurze passende Überschrift ohne neue Tatsachen. Danach zusammenhängende, natürlich gegliederte Absätze. Keine Aufzählung und keine Meta-Erklärung.\n",
|
||||||
"seed_revision": "2026-08-27-journal-placeholders-v1"
|
"seed_revision": "2026-08-27-journal-mixed-sources-v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mvp-journal-reconstruct",
|
"id": "mvp-journal-reconstruct",
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,10 @@ def build_internal_context(
|
||||||
reconstruction: str = "",
|
reconstruction: str = "",
|
||||||
day_spec: dict | None = None,
|
day_spec: dict | None = None,
|
||||||
style_examples: str = "",
|
style_examples: str = "",
|
||||||
editorial_mode: str = "",
|
transformation_instructions: str = "",
|
||||||
editorial_instructions: str = "",
|
detail_instructions: str = "",
|
||||||
|
voice_instructions: str = "",
|
||||||
|
narrative_instructions: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
purpose = purpose if purpose in PURPOSES else "dialogue_turn"
|
purpose = purpose if purpose in PURPOSES else "dialogue_turn"
|
||||||
if conversation_id and not space_id:
|
if conversation_id and not space_id:
|
||||||
|
|
@ -154,8 +156,10 @@ def build_internal_context(
|
||||||
brief = compile_task_brief(profile_id, "journal_generate")
|
brief = compile_task_brief(profile_id, "journal_generate")
|
||||||
items.append({"type": "writing_profile", "compiled_brief": brief})
|
items.append({"type": "writing_profile", "compiled_brief": brief})
|
||||||
items.append({"type": "style_examples", "body": style_examples or ""})
|
items.append({"type": "style_examples", "body": style_examples or ""})
|
||||||
items.append({"type": "editorial_mode", "text": editorial_mode or ""})
|
items.append({"type": "transformation_instructions", "text": transformation_instructions or ""})
|
||||||
items.append({"type": "editorial_instructions", "text": editorial_instructions or ""})
|
items.append({"type": "detail_instructions", "text": detail_instructions or ""})
|
||||||
|
items.append({"type": "voice_instructions", "text": voice_instructions or ""})
|
||||||
|
items.append({"type": "narrative_instructions", "text": narrative_instructions or ""})
|
||||||
if reconstruction:
|
if reconstruction:
|
||||||
items.append({"type": "reconstruction", "body": reconstruction})
|
items.append({"type": "reconstruction", "body": reconstruction})
|
||||||
if include_existing and existing_text:
|
if include_existing and existing_text:
|
||||||
|
|
@ -186,8 +190,10 @@ def assemble_text(context: dict) -> dict[str, str]:
|
||||||
existing_text = ""
|
existing_text = ""
|
||||||
reconstruction = ""
|
reconstruction = ""
|
||||||
style_examples = ""
|
style_examples = ""
|
||||||
editorial_mode = ""
|
transformation_instructions = ""
|
||||||
editorial_instructions = ""
|
detail_instructions = ""
|
||||||
|
voice_instructions = ""
|
||||||
|
narrative_instructions = ""
|
||||||
user_bodies: list[str] = []
|
user_bodies: list[str] = []
|
||||||
purpose = context.get("purpose") or ""
|
purpose = context.get("purpose") or ""
|
||||||
for item in context.get("items") or []:
|
for item in context.get("items") or []:
|
||||||
|
|
@ -229,10 +235,14 @@ def assemble_text(context: dict) -> dict[str, str]:
|
||||||
reconstruction = item.get("body") or ""
|
reconstruction = item.get("body") or ""
|
||||||
elif kind == "style_examples":
|
elif kind == "style_examples":
|
||||||
style_examples = item.get("body") or ""
|
style_examples = item.get("body") or ""
|
||||||
elif kind == "editorial_mode":
|
elif kind == "transformation_instructions":
|
||||||
editorial_mode = item.get("text") or ""
|
transformation_instructions = item.get("text") or ""
|
||||||
elif kind == "editorial_instructions":
|
elif kind == "detail_instructions":
|
||||||
editorial_instructions = item.get("text") or ""
|
detail_instructions = item.get("text") or ""
|
||||||
|
elif kind == "voice_instructions":
|
||||||
|
voice_instructions = item.get("text") or ""
|
||||||
|
elif kind == "narrative_instructions":
|
||||||
|
narrative_instructions = item.get("text") or ""
|
||||||
elif kind == "opening":
|
elif kind == "opening":
|
||||||
pass
|
pass
|
||||||
opening_hint = ""
|
opening_hint = ""
|
||||||
|
|
@ -254,8 +264,10 @@ def assemble_text(context: dict) -> dict[str, str]:
|
||||||
"dialogue_context": "\n".join(dialogue_parts).strip(),
|
"dialogue_context": "\n".join(dialogue_parts).strip(),
|
||||||
"writing_profile": writing_profile,
|
"writing_profile": writing_profile,
|
||||||
"style_examples": style_examples,
|
"style_examples": style_examples,
|
||||||
"editorial_mode": editorial_mode,
|
"transformation_instructions": transformation_instructions,
|
||||||
"editorial_instructions": editorial_instructions,
|
"detail_instructions": detail_instructions,
|
||||||
|
"voice_instructions": voice_instructions,
|
||||||
|
"narrative_instructions": narrative_instructions,
|
||||||
"interaction_hint": interaction_hint,
|
"interaction_hint": interaction_hint,
|
||||||
"existing_text": existing_text,
|
"existing_text": existing_text,
|
||||||
"reconstruction": reconstruction,
|
"reconstruction": reconstruction,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ _WRITING_VERSION_COLUMNS = {
|
||||||
_JOURNAL_DAY_COLUMNS = {
|
_JOURNAL_DAY_COLUMNS = {
|
||||||
"scratch_json": "TEXT NOT NULL DEFAULT '[]'",
|
"scratch_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||||
}
|
}
|
||||||
|
_JOURNAL_DRAFT_COLUMNS = {
|
||||||
|
"generation_snapshot": "TEXT NOT NULL DEFAULT '{}'",
|
||||||
|
}
|
||||||
_IDENTITY_MAPPING_COLUMNS = {
|
_IDENTITY_MAPPING_COLUMNS = {
|
||||||
"canonical_label": "TEXT NOT NULL DEFAULT ''",
|
"canonical_label": "TEXT NOT NULL DEFAULT ''",
|
||||||
"entity_type": "TEXT NOT NULL DEFAULT 'PERSON'",
|
"entity_type": "TEXT NOT NULL DEFAULT 'PERSON'",
|
||||||
|
|
@ -449,6 +452,7 @@ def init_db() -> None:
|
||||||
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
|
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
|
||||||
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
|
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
|
||||||
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
|
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
|
||||||
|
_ensure_columns(conn, "journal_drafts", _JOURNAL_DRAFT_COLUMNS)
|
||||||
_ensure_columns(conn, "writing_profiles", _WRITING_PROFILE_COLUMNS)
|
_ensure_columns(conn, "writing_profiles", _WRITING_PROFILE_COLUMNS)
|
||||||
_migrate_writing_profile_dialogue_style(conn)
|
_migrate_writing_profile_dialogue_style(conn)
|
||||||
_ensure_columns(conn, "writing_profile_sources", _WRITING_SOURCE_COLUMNS)
|
_ensure_columns(conn, "writing_profile_sources", _WRITING_SOURCE_COLUMNS)
|
||||||
|
|
@ -474,6 +478,13 @@ def init_db() -> None:
|
||||||
_mark(conn, "012_profile_shell")
|
_mark(conn, "012_profile_shell")
|
||||||
_mark(conn, "013_journal_generate_narration")
|
_mark(conn, "013_journal_generate_narration")
|
||||||
_mark(conn, "014_identity_registry")
|
_mark(conn, "014_identity_registry")
|
||||||
|
from journal_generation_policy import backfill_missing_settings, seed_generation_instructions
|
||||||
|
|
||||||
|
seed_generation_instructions(conn)
|
||||||
|
backfill_missing_settings(conn)
|
||||||
|
_mark(conn, "015_journal_generation_settings")
|
||||||
|
_mark(conn, "016_generation_instruction_fragments")
|
||||||
|
_mark(conn, "017_generation_guidelines")
|
||||||
from writing_profile_store import bootstrap_from_existing
|
from writing_profile_store import bootstrap_from_existing
|
||||||
|
|
||||||
bootstrap_from_existing()
|
bootstrap_from_existing()
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ def execute_prompt(
|
||||||
"source_text": source_text,
|
"source_text": source_text,
|
||||||
"privacy_tokens": preview["privacy_tokens"],
|
"privacy_tokens": preview["privacy_tokens"],
|
||||||
"prompt_slug": prompt.get("slug"),
|
"prompt_slug": prompt.get("slug"),
|
||||||
|
"prompt_revision": prompt.get("seed_revision") or "",
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"disable_context_compression": disable_context_compression,
|
"disable_context_compression": disable_context_compression,
|
||||||
"budget": budget,
|
"budget": budget,
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,22 @@ def _contract_fake_spans(text: str) -> list[dict]:
|
||||||
found.append(item)
|
found.append(item)
|
||||||
start = item["end"]
|
start = item["end"]
|
||||||
|
|
||||||
if re.search(r"Sushi kam", source):
|
|
||||||
add("Sushi", "PERSON")
|
|
||||||
if re.search(r"(?i)Frau\s+Sushi", source):
|
if re.search(r"(?i)Frau\s+Sushi", source):
|
||||||
add("Sushi", "PERSON")
|
add("Sushi", "PERSON")
|
||||||
|
else:
|
||||||
|
for match in re.finditer(r"Sushi", source):
|
||||||
|
if source[match.end() : match.end() + 4] == " kam":
|
||||||
|
key = (match.start(), match.end(), "PERSON")
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
found.append(
|
||||||
|
{
|
||||||
|
"start": match.start(),
|
||||||
|
"end": match.end(),
|
||||||
|
"text": "Sushi",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
}
|
||||||
|
)
|
||||||
if re.search(r"(?i)Projekt\s+Aurora", source):
|
if re.search(r"(?i)Projekt\s+Aurora", source):
|
||||||
add("Aurora", "PROJECT")
|
add("Aurora", "PROJECT")
|
||||||
if re.search(r"(?i)(Organisation|Firma|bei)\s+Nordwerk", source):
|
if re.search(r"(?i)(Organisation|Firma|bei)\s+Nordwerk", source):
|
||||||
|
|
@ -499,6 +511,8 @@ def _assign_request_tokens(
|
||||||
confirmed_hits += 1
|
confirmed_hits += 1
|
||||||
entity_type = found["entity_type"]
|
entity_type = found["entity_type"]
|
||||||
demask = canonical
|
demask = canonical
|
||||||
|
aliases = list(found.get("aliases") or [])
|
||||||
|
labels = confirmed_match_labels(found)
|
||||||
else:
|
else:
|
||||||
key = f"{span.entity_type}:{label.casefold()}"
|
key = f"{span.entity_type}:{label.casefold()}"
|
||||||
if key not in by_label:
|
if key not in by_label:
|
||||||
|
|
@ -511,6 +525,8 @@ def _assign_request_tokens(
|
||||||
entity_type = span.entity_type
|
entity_type = span.entity_type
|
||||||
source = "request_local"
|
source = "request_local"
|
||||||
demask = label
|
demask = label
|
||||||
|
aliases = []
|
||||||
|
labels = [label]
|
||||||
mappings.append(
|
mappings.append(
|
||||||
{
|
{
|
||||||
"token": token,
|
"token": token,
|
||||||
|
|
@ -521,6 +537,8 @@ def _assign_request_tokens(
|
||||||
"source": source,
|
"source": source,
|
||||||
"start": span.start,
|
"start": span.start,
|
||||||
"end": span.end,
|
"end": span.end,
|
||||||
|
"aliases": aliases,
|
||||||
|
"labels": labels,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return mappings, request_local, confirmed_hits
|
return mappings, request_local, confirmed_hits
|
||||||
|
|
@ -529,17 +547,17 @@ def _assign_request_tokens(
|
||||||
def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str | None) -> tuple[list[dict], int]:
|
def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str | None) -> tuple[list[dict], int]:
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
return mappings, 0
|
return mappings, 0
|
||||||
already = {(item.get("local_label") or "").casefold() for item in mappings}
|
already_labels = {(item.get("local_label") or "").casefold() for item in mappings if item.get("start") is None}
|
||||||
extra: list[dict] = []
|
extra: list[dict] = []
|
||||||
hits = 0
|
hits = 0
|
||||||
for row in masking_rows_from_confirmed(profile_id):
|
for row in masking_rows_from_confirmed(profile_id):
|
||||||
label = row.get("local_label") or ""
|
label = row.get("local_label") or ""
|
||||||
if not label or label.casefold() in already:
|
if not label or label.casefold() in already_labels:
|
||||||
continue
|
continue
|
||||||
if not re.search(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", text or "", re.IGNORECASE):
|
if not re.search(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", text or "", re.IGNORECASE):
|
||||||
continue
|
continue
|
||||||
extra.append(row)
|
extra.append(row)
|
||||||
already.add(label.casefold())
|
already_labels.add(label.casefold())
|
||||||
hits += 1
|
hits += 1
|
||||||
return mappings + extra, hits
|
return mappings + extra, hits
|
||||||
|
|
||||||
|
|
@ -685,6 +703,9 @@ def detect_personal_egress(profile_id: str | None, source_text: str) -> Detectio
|
||||||
"token": item.get("token"),
|
"token": item.get("token"),
|
||||||
"entity_type": item.get("entity_type"),
|
"entity_type": item.get("entity_type"),
|
||||||
"demask_label": item.get("demask_label") or item.get("local_label"),
|
"demask_label": item.get("demask_label") or item.get("local_label"),
|
||||||
|
"canonical_label": item.get("canonical_label") or item.get("demask_label") or item.get("local_label"),
|
||||||
|
"aliases": list(item.get("aliases") or []),
|
||||||
|
"labels": list(item.get("labels") or []),
|
||||||
"source": item.get("source"),
|
"source": item.get("source"),
|
||||||
}
|
}
|
||||||
for item in mappings
|
for item in mappings
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ Usage from backend/:
|
||||||
python entity_detect_eval.py --live
|
python entity_detect_eval.py --live
|
||||||
|
|
||||||
Synthetic sentences only. No personal data. Live quality stays unconfirmed
|
Synthetic sentences only. No personal data. Live quality stays unconfirmed
|
||||||
until an explicit --live run succeeds.
|
until an explicit --live run succeeds against gold spans.
|
||||||
|
Fake mode proves the scoring contract, not live semantic quality.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -20,45 +21,158 @@ ROOT = Path(__file__).resolve().parent
|
||||||
if str(ROOT) not in sys.path:
|
if str(ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _span(text: str, needle: str, entity_type: str, *, occurrence: int = 0) -> dict:
|
||||||
|
start = -1
|
||||||
|
found = -1
|
||||||
|
while True:
|
||||||
|
start = text.find(needle, start + 1)
|
||||||
|
if start < 0:
|
||||||
|
raise ValueError(f"needle {needle!r} occurrence {occurrence} missing in {text!r}")
|
||||||
|
found += 1
|
||||||
|
if found == occurrence:
|
||||||
|
return {
|
||||||
|
"start": start,
|
||||||
|
"end": start + len(needle),
|
||||||
|
"text": needle,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
CASES = (
|
CASES = (
|
||||||
{
|
{
|
||||||
"id": "common_noun",
|
"id": "person_vs_food",
|
||||||
"text": "Ich ging auf den Balkon und setzte mich.",
|
"text": "Ich aß Sushi. Sushi kam später.",
|
||||||
"expect_empty_types": True,
|
"expected": [_span("Ich aß Sushi. Sushi kam später.", "Sushi", "PERSON", occurrence=1)],
|
||||||
"note": "Allgemeines Substantiv, kein Eigenname.",
|
"note": "Person versus Lebensmittel: nur die identifizierende Nennung.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "food_vs_person",
|
"id": "project_vs_activity",
|
||||||
"text": "Ich aß Sushi. Sushi kam ins Wohnzimmer.",
|
"text": "Ich arbeitete am privaten Projekt Aurora. Danach arbeitete ich.",
|
||||||
"note": "Dasselbe Wort als Gericht und als mögliche Person.",
|
"expected": [_span("Ich arbeitete am privaten Projekt Aurora. Danach arbeitete ich.", "Aurora", "PROJECT")],
|
||||||
|
"note": "Privates Projekt versus allgemeine Tätigkeit.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "person",
|
"id": "place_vs_room",
|
||||||
"text": "Ich traf Anna am Nachmittag.",
|
"text": "Ich war in Hamburg. Später saß ich im Wohnzimmer.",
|
||||||
"expect_types": {"PERSON"},
|
"expected": [_span("Ich war in Hamburg. Später saß ich im Wohnzimmer.", "Hamburg", "PLACE")],
|
||||||
"note": "Klarer Personenname.",
|
"note": "Genauer Eigenort versus allgemeiner Raum.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "project",
|
"id": "org_vs_noun",
|
||||||
"text": "Ich arbeitete am privaten Projekt Aurora.",
|
"text": "Ich sprach mit der Firma Nordwerk. Das Notizbuch blieb liegen.",
|
||||||
"expect_types": {"PROJECT"},
|
"expected": [_span("Ich sprach mit der Firma Nordwerk. Das Notizbuch blieb liegen.", "Nordwerk", "ORG")],
|
||||||
"note": "Privates Projekt, kein Allerweltsgegenstand.",
|
"note": "Organisation versus allgemeines Substantiv.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "place_org",
|
"id": "person_vs_group",
|
||||||
|
"text": "Anna kam vorbei. Die Nachbarn spielten draußen.",
|
||||||
|
"expected": [_span("Anna kam vorbei. Die Nachbarn spielten draußen.", "Anna", "PERSON")],
|
||||||
|
"note": "Personenname versus generische Personengruppe.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "animal_vs_org",
|
||||||
|
"text": "Die Seehunde schwammen nah am Ufer.",
|
||||||
|
"expected": [],
|
||||||
|
"note": "Tierbezeichnung versus Organisation oder Person.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "typo_common",
|
||||||
|
"text": "Ich gieng zum Laden.",
|
||||||
|
"expected": [],
|
||||||
|
"note": "Tippfehler eines Allgemeinbegriffs ist keine Identität.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "same_word_two_roles",
|
||||||
|
"text": "Ich aß Sushi. Sushi kam ins Zimmer.",
|
||||||
|
"expected": [_span("Ich aß Sushi. Sushi kam ins Zimmer.", "Sushi", "PERSON", occurrence=1)],
|
||||||
|
"note": "Identischer Wortlaut in zwei semantischen Rollen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "multipart_place",
|
||||||
"text": "Ich war in Hamburg und sprach mit der Organisation Nordwerk.",
|
"text": "Ich war in Hamburg und sprach mit der Organisation Nordwerk.",
|
||||||
"expect_types": {"PLACE", "ORG"},
|
"expected": [
|
||||||
"note": "Ort und Organisation.",
|
_span("Ich war in Hamburg und sprach mit der Organisation Nordwerk.", "Hamburg", "PLACE"),
|
||||||
|
_span("Ich war in Hamburg und sprach mit der Organisation Nordwerk.", "Nordwerk", "ORG"),
|
||||||
|
],
|
||||||
|
"note": "Mehrteiliger Ort plus Organisation, keine Ganzsatz-Spans.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no_sentence_span",
|
||||||
|
"text": "Ich traf Anna am Nachmittag.",
|
||||||
|
"expected": [_span("Ich traf Anna am Nachmittag.", "Anna", "PERSON")],
|
||||||
|
"note": "Nur der Name, kein Ganzsatz- oder Satzfragment-Span.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _summarize(entities: list[dict]) -> dict:
|
def _key(item: dict) -> tuple[int, int, str, str]:
|
||||||
|
return (
|
||||||
|
int(item.get("start") or -1),
|
||||||
|
int(item.get("end") or -1),
|
||||||
|
str(item.get("text") or ""),
|
||||||
|
str(item.get("entity_type") or "").upper(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _offset_key(item: dict) -> tuple[int, int, str]:
|
||||||
|
return (int(item.get("start") or -1), int(item.get("end") or -1), str(item.get("text") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def score_spans(predicted: list[dict], expected: list[dict]) -> dict:
|
||||||
|
pred = [_key(item) for item in predicted]
|
||||||
|
gold = [_key(item) for item in expected]
|
||||||
|
pred_off = [_offset_key(item) for item in predicted]
|
||||||
|
gold_off = [_offset_key(item) for item in expected]
|
||||||
|
found = [item for item in gold if item in pred]
|
||||||
|
unexpected = [item for item in pred if item not in gold]
|
||||||
|
missing = [item for item in gold if item not in pred]
|
||||||
|
wrong_type = []
|
||||||
|
wrong_offset = []
|
||||||
|
for item in predicted:
|
||||||
|
matches_text = [
|
||||||
|
gold_item
|
||||||
|
for gold_item in expected
|
||||||
|
if (gold_item.get("text") or "") == (item.get("text") or "")
|
||||||
|
]
|
||||||
|
if not matches_text:
|
||||||
|
continue
|
||||||
|
if _key(item) in gold:
|
||||||
|
continue
|
||||||
|
same_offsets = any(_offset_key(item) == _offset_key(gold_item) for gold_item in matches_text)
|
||||||
|
if same_offsets:
|
||||||
|
wrong_type.append(_key(item))
|
||||||
|
else:
|
||||||
|
wrong_offset.append(_key(item))
|
||||||
|
tp = len(found)
|
||||||
|
fp = len(unexpected)
|
||||||
|
fn = len(missing)
|
||||||
|
precision = tp / (tp + fp) if (tp + fp) else 1.0
|
||||||
|
recall = tp / (tp + fn) if (tp + fn) else 1.0
|
||||||
|
return {
|
||||||
|
"expected_found": tp,
|
||||||
|
"expected_count": len(gold),
|
||||||
|
"unexpected": fp,
|
||||||
|
"missing": fn,
|
||||||
|
"wrong_type": len(wrong_type),
|
||||||
|
"wrong_offset": len(wrong_offset),
|
||||||
|
"precision": round(precision, 4),
|
||||||
|
"recall": round(recall, 4),
|
||||||
|
"sentence_or_fragment_span": any(
|
||||||
|
(item.get("end") or 0) - (item.get("start") or 0) > max(len(item.get("text") or ""), 0)
|
||||||
|
and " " in (item.get("text") or "")
|
||||||
|
for item in predicted
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize(entities: list[dict], expected: list[dict]) -> dict:
|
||||||
types = sorted({(item.get("entity_type") or "").upper() for item in entities})
|
types = sorted({(item.get("entity_type") or "").upper() for item in entities})
|
||||||
return {
|
return {
|
||||||
"count": len(entities),
|
"count": len(entities),
|
||||||
"types": types,
|
"types": types,
|
||||||
"has_labels": False,
|
"has_labels": False,
|
||||||
|
"metrics": score_spans(entities, expected),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,8 +182,20 @@ def run_fake() -> dict:
|
||||||
rows = []
|
rows = []
|
||||||
for case in CASES:
|
for case in CASES:
|
||||||
entities = _contract_fake_spans(case["text"])
|
entities = _contract_fake_spans(case["text"])
|
||||||
rows.append({"id": case["id"], "note": case["note"], "entities": _summarize(entities), "mode": "fake"})
|
rows.append(
|
||||||
return {"mode": "fake", "live_quality": "unconfirmed", "cases": rows}
|
{
|
||||||
|
"id": case["id"],
|
||||||
|
"note": case["note"],
|
||||||
|
"entities": _summarize(entities, case["expected"]),
|
||||||
|
"mode": "fake",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "fake",
|
||||||
|
"live_quality": "unconfirmed",
|
||||||
|
"detect_model_unconfirmed": "openai/gpt-4.1-nano",
|
||||||
|
"cases": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_live() -> dict:
|
def run_live() -> dict:
|
||||||
|
|
@ -82,21 +208,35 @@ def run_live() -> dict:
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
outcome = detect_personal_egress(None, case["text"])
|
outcome = detect_personal_egress(None, case["text"])
|
||||||
elapsed = int((time.perf_counter() - started) * 1000)
|
elapsed = int((time.perf_counter() - started) * 1000)
|
||||||
types = sorted({(item.get("entity_type") or "") for item in outcome.mappings})
|
predicted = [
|
||||||
|
{
|
||||||
|
"start": item.get("start"),
|
||||||
|
"end": item.get("end"),
|
||||||
|
"text": item.get("local_label") or item.get("text"),
|
||||||
|
"entity_type": item.get("entity_type"),
|
||||||
|
}
|
||||||
|
for item in outcome.mappings
|
||||||
|
]
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"id": case["id"],
|
"id": case["id"],
|
||||||
"note": case["note"],
|
"note": case["note"],
|
||||||
"types": types,
|
"metrics": score_spans(predicted, case["expected"]),
|
||||||
"request_local_hits": outcome.stats.request_local_hits,
|
"request_local_hits": outcome.stats.request_local_hits,
|
||||||
"detect_calls": outcome.stats.detect_calls,
|
"detect_calls": outcome.stats.detect_calls,
|
||||||
"detect_ms": elapsed,
|
"detect_ms": elapsed,
|
||||||
"coverage": outcome.stats.full_detection_coverage,
|
"coverage": outcome.stats.full_detection_coverage,
|
||||||
"detect_tokens": outcome.stats.total_tokens,
|
"detect_tokens": outcome.stats.total_tokens,
|
||||||
"detect_cost": outcome.stats.cost,
|
"detect_cost": outcome.stats.cost,
|
||||||
|
"detect_model": outcome.stats.detect_model,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {"mode": "live", "live_quality": "ran", "cases": rows}
|
return {
|
||||||
|
"mode": "live",
|
||||||
|
"live_quality": "unconfirmed",
|
||||||
|
"detect_model_unconfirmed": "openai/gpt-4.1-nano",
|
||||||
|
"cases": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
@ -105,8 +245,12 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
payload = run_live() if args.live else run_fake()
|
payload = run_live() if args.live else run_fake()
|
||||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
print(
|
||||||
|
"Live-Qualität: unbestätigt. Das konfigurierte openai/gpt-4.1-nano "
|
||||||
|
"gilt durch reale False-Positive-Vorschläge nicht als zuverlässig bestätigt."
|
||||||
|
)
|
||||||
if payload["mode"] != "live":
|
if payload["mode"] != "live":
|
||||||
print("Live-Qualität: noch nicht bestätigt. Explizit: python entity_detect_eval.py --live")
|
print("Explizit live: python entity_detect_eval.py --live")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,28 @@ def confirmed_match_labels(item: dict) -> list[str]:
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def mapping_spellings(item: dict) -> list[str]:
|
||||||
|
"""All known writings of one mapping: canonical, aliases, observed label, demask form."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
|
||||||
|
def add(raw) -> None:
|
||||||
|
label = (raw or "").strip()
|
||||||
|
if not label or label.casefold() in seen or not is_maskable_label(label):
|
||||||
|
return
|
||||||
|
seen.add(label.casefold())
|
||||||
|
result.append(label)
|
||||||
|
|
||||||
|
add(item.get("local_label"))
|
||||||
|
add(item.get("canonical_label"))
|
||||||
|
add(item.get("demask_label"))
|
||||||
|
for alias in item.get("aliases") or []:
|
||||||
|
add(alias)
|
||||||
|
for label in item.get("labels") or []:
|
||||||
|
add(label)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def masking_rows_from_confirmed(profile_id: str) -> list[dict]:
|
def masking_rows_from_confirmed(profile_id: str) -> list[dict]:
|
||||||
"""One masking row per confirmed canonical label or alias. Demask uses canonical."""
|
"""One masking row per confirmed canonical label or alias. Demask uses canonical."""
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
|
@ -202,6 +224,8 @@ def masking_rows_from_confirmed(profile_id: str) -> list[dict]:
|
||||||
"demask_label": canonical,
|
"demask_label": canonical,
|
||||||
"entity_type": entity_type,
|
"entity_type": entity_type,
|
||||||
"source": "confirmed_registry",
|
"source": "confirmed_registry",
|
||||||
|
"aliases": [item for item in confirmed_match_labels(item) if item.casefold() != label.casefold()],
|
||||||
|
"labels": confirmed_match_labels(item),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Journal-adapter editorial policy. Not a general provenance or privacy rule.
|
"""Journal-adapter editorial policy. Not a general provenance or privacy rule.
|
||||||
|
|
||||||
Fact fidelity is not wording fidelity. Editorial mode is chosen locally, without
|
Fact fidelity is not wording fidelity. Mixed prose, notes and fragments in the
|
||||||
a second model call. Historical texts are style references, never today's facts.
|
same day are the normal case and are handled in one generate call. Historical
|
||||||
|
texts are style references, never today's facts.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -18,29 +19,11 @@ from writing_profile_store import (
|
||||||
list_style_sources,
|
list_style_sources,
|
||||||
)
|
)
|
||||||
|
|
||||||
PROSE_EDIT = "prose_edit"
|
|
||||||
NOTES_TO_JOURNAL = "notes_to_journal"
|
|
||||||
EDITORIAL_MODES = (PROSE_EDIT, NOTES_TO_JOURNAL)
|
|
||||||
|
|
||||||
STYLE_EXAMPLE_MAX = 2
|
STYLE_EXAMPLE_MAX = 2
|
||||||
STYLE_EXAMPLE_CHARS = 900
|
STYLE_EXAMPLE_CHARS = 900
|
||||||
MIN_EXAMPLE_CHARS = 40
|
MIN_EXAMPLE_CHARS = 40
|
||||||
|
|
||||||
INSTRUCTIONS = {
|
GENERATE_SEED_REVISION = "2026-08-27-journal-mixed-sources-v1"
|
||||||
PROSE_EDIT: (
|
|
||||||
"Modus prose_edit: Der Rohtext ist bereits erzählerisch. "
|
|
||||||
"Gute Formulierungen bewahren. Rechtschreibung, Grammatik und Zeichensetzung "
|
|
||||||
"korrigieren. Holprige Stellen glätten, Wiederholungen reduzieren, Absätze und "
|
|
||||||
"Übergänge verbessern. Die persönliche Schreibstimme anwenden. "
|
|
||||||
"Keine unnötige vollständige Neufassung erzwingen."
|
|
||||||
),
|
|
||||||
NOTES_TO_JOURNAL: (
|
|
||||||
"Modus notes_to_journal: Die Quellen sind Stichpunkte, Kurztexte oder Fragmente. "
|
|
||||||
"Daraus zusammenhängende Journalprosa bilden. Nur sprachlich nötige Verbindungen "
|
|
||||||
"herstellen. Keine neuen Tatsachen, Ursachen oder Bewertungen ergänzen. "
|
|
||||||
"Die Stichpunkte nicht inklusive ihrer Fehler hintereinanderkopieren."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
EMPTY_STYLE_EXAMPLES = (
|
EMPTY_STYLE_EXAMPLES = (
|
||||||
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE "
|
"Keine historischen Stilbeispiele. Es gilt nur WRITING_PROFILE "
|
||||||
|
|
@ -48,26 +31,6 @@ EMPTY_STYLE_EXAMPLES = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def choose_editorial_mode(user_bodies: list[str]) -> str:
|
|
||||||
"""MVP mode choice. No classifier model.
|
|
||||||
|
|
||||||
A source block counts as already narrative when it contains `.`, `!` or `?`.
|
|
||||||
Mixed default: `prose_edit` when at least half of the non-empty blocks are
|
|
||||||
narrative; otherwise `notes_to_journal`.
|
|
||||||
"""
|
|
||||||
blocks = [(item or "").strip() for item in user_bodies if (item or "").strip()]
|
|
||||||
if not blocks:
|
|
||||||
return NOTES_TO_JOURNAL
|
|
||||||
narrative = sum(1 for block in blocks if any(mark in block for mark in ".!?"))
|
|
||||||
if narrative * 2 >= len(blocks):
|
|
||||||
return PROSE_EDIT
|
|
||||||
return NOTES_TO_JOURNAL
|
|
||||||
|
|
||||||
|
|
||||||
def editorial_instructions(mode: str) -> str:
|
|
||||||
return INSTRUCTIONS.get(mode) or INSTRUCTIONS[PROSE_EDIT]
|
|
||||||
|
|
||||||
|
|
||||||
def lexical_similarity(left: str, right: str) -> float:
|
def lexical_similarity(left: str, right: str) -> float:
|
||||||
"""Diagnostic only. Must not reject a draft or trigger a retry."""
|
"""Diagnostic only. Must not reject a draft or trigger a retry."""
|
||||||
a = re.sub(r"\s+", " ", (left or "").strip().lower())
|
a = re.sub(r"\s+", " ", (left or "").strip().lower())
|
||||||
|
|
@ -77,6 +40,85 @@ def lexical_similarity(left: str, right: str) -> float:
|
||||||
return round(SequenceMatcher(None, a, b).ratio(), 3)
|
return round(SequenceMatcher(None, a, b).ratio(), 3)
|
||||||
|
|
||||||
|
|
||||||
|
_DANGLING_DETERMINER = re.compile(
|
||||||
|
r"(?i)\b(?:der|die|das|des|dem|den|ein|eine|einem|einen|einer)\s*$"
|
||||||
|
)
|
||||||
|
_DETERMINER_BEFORE_FINITE = re.compile(
|
||||||
|
r"(?i)\b(?:des|dem|den|der|die|das|ein|eine|einem|einen|einer)\s+"
|
||||||
|
r"(?:kann|können|konnte|muss|müssen|will|wollen|soll|sollen|"
|
||||||
|
r"ist|sind|war|waren|wird|werden|hat|haben|hatte|"
|
||||||
|
r"geht|gehen|ging|kam|kommen|kommt|gelangen|gelangte)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def incomplete_syntax_markers(text: str) -> int:
|
||||||
|
"""Diagnostic count of dangling determiners or unpunctuated long clauses.
|
||||||
|
|
||||||
|
Does not rewrite text and must not trigger a retry.
|
||||||
|
"""
|
||||||
|
body = (text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
clauses = [part.strip() for part in re.split(r"(?<=[.!?])\s+|\n+", body) if part.strip()]
|
||||||
|
for clause in clauses:
|
||||||
|
bare = clause.rstrip(".!?…\"»'")
|
||||||
|
if _DANGLING_DETERMINER.search(bare):
|
||||||
|
count += 1
|
||||||
|
if _DETERMINER_BEFORE_FINITE.search(clause):
|
||||||
|
count += 1
|
||||||
|
for para in re.split(r"\n\s*\n", body):
|
||||||
|
chunk = para.strip()
|
||||||
|
if len(chunk.split()) >= 8 and not re.search(r"[.!?]", chunk):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def style_example_diagnostics(examples: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"count": len(examples or []),
|
||||||
|
"kinds": [item.get("kind") or "style" for item in (examples or [])],
|
||||||
|
"chars": sum(len(item.get("excerpt") or "") for item in (examples or [])),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def writing_profile_trace(profile_id: str) -> dict:
|
||||||
|
"""Presence metadata only. No profile text, no labels."""
|
||||||
|
from writing_profile_store import (
|
||||||
|
NEUTRAL_JOURNAL_STYLE,
|
||||||
|
compile_task_brief,
|
||||||
|
get_profile,
|
||||||
|
has_confirmed_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmed = has_confirmed_profile(profile_id)
|
||||||
|
profile = get_profile(profile_id)
|
||||||
|
core = ((profile.get("core") or {}).get("value") or "").strip()
|
||||||
|
facet = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in profile.get("facets") or []
|
||||||
|
if item.get("facet_key") == "autobiographical_journal" and (item.get("value") or "").strip()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
traits = [
|
||||||
|
item
|
||||||
|
for item in profile.get("traits") or []
|
||||||
|
if item.get("status") == "active" and (item.get("statement") or "").strip()
|
||||||
|
]
|
||||||
|
brief = compile_task_brief(profile_id, "journal_generate")
|
||||||
|
return {
|
||||||
|
"confirmed": confirmed,
|
||||||
|
"present": bool(confirmed and brief and brief != NEUTRAL_JOURNAL_STYLE),
|
||||||
|
"neutral_fallback": brief == NEUTRAL_JOURNAL_STYLE,
|
||||||
|
"has_core": bool(confirmed and core),
|
||||||
|
"has_facet": bool(confirmed and facet),
|
||||||
|
"trait_count": len(traits) if confirmed else 0,
|
||||||
|
"brief_chars": len(brief or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def narration_sources_text(artifact: dict) -> str:
|
def narration_sources_text(artifact: dict) -> str:
|
||||||
"""Present attested day facts to the model. Not a wording template, not JSON."""
|
"""Present attested day facts to the model. Not a wording template, not JSON."""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
Usage from backend/:
|
Usage from backend/:
|
||||||
python journal_eval.py # synthetic, fake provider, contract only
|
python journal_eval.py # synthetic, fake provider, contract only
|
||||||
python journal_eval.py --live --profile-id <id>
|
python journal_eval.py --live --profile-id <id>
|
||||||
|
python journal_eval.py --profile-ab # two synthetic writing profiles, prompts only unless --live
|
||||||
|
|
||||||
Never writes private texts into the repository. Live quality stays unconfirmed
|
Never writes private texts into the repository. Live quality stays unconfirmed
|
||||||
until an explicit --live run succeeds.
|
until an explicit --live run succeeds. The harness never declares a winner.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -28,8 +29,11 @@ VARIANT_PREVIOUS = "kansho_previous"
|
||||||
VARIANT_CURRENT = "kansho_current"
|
VARIANT_CURRENT = "kansho_current"
|
||||||
|
|
||||||
BASELINE_TEMPLATE = (
|
BASELINE_TEMPLATE = (
|
||||||
"Überarbeite diesen Rohtext zu einem ansprechenden Tagebucheintrag in meinem Stil.\n\n"
|
"Erstelle aus diesen Angaben einen ansprechenden persönlichen Tagebucheintrag. "
|
||||||
"{{reconstruction}}\n"
|
"Bewahre alle Tatsachen und Unsicherheiten, erfinde nichts, korrigiere Sprache "
|
||||||
|
"und schreibe im bereitgestellten persönlichen Stil.\n\n"
|
||||||
|
"Persönlicher Stil:\n{{writing_profile}}\n\n"
|
||||||
|
"Angaben:\n{{reconstruction}}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
PREVIOUS_TEMPLATE = (
|
PREVIOUS_TEMPLATE = (
|
||||||
|
|
@ -41,13 +45,112 @@ PREVIOUS_TEMPLATE = (
|
||||||
"Verified Artifact:\n{{reconstruction}}\n"
|
"Verified Artifact:\n{{reconstruction}}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
SYNTHETIC_PROSE = (
|
PROFILE_A = (
|
||||||
"ich bin dan zum markt gegangen und da war es zimlich voll. "
|
"Core: kurze Sätze, trockener Schnitt, wenig Adjektive, kaum Reflexion. "
|
||||||
"vielleicht bleibe ich kürzer. ich wollte noch brot holen, hab es aber nicht gemacht. "
|
"Wortwahl nüchtern, Rhythmus abgehackt."
|
||||||
"die rote tasche lag im auto."
|
|
||||||
)
|
)
|
||||||
SYNTHETIC_NOTES = "- markt\n- kirschen kaufen\n- später hafen"
|
PROFILE_B = (
|
||||||
SYNTHETIC_TYPOS = ("zimlich", "dan zum")
|
"Core: längere, ruhig fließende Sätze, beobachtend, leise Reflexion am Satzende. "
|
||||||
|
"Wortwahl behutsam, Rhythmus getragen."
|
||||||
|
)
|
||||||
|
|
||||||
|
FIXTURES: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "prose_typos",
|
||||||
|
"class": "already_narrative_with_errors",
|
||||||
|
"source": (
|
||||||
|
"ich bin dan zum markt gegangen und da war es zimlich voll. "
|
||||||
|
"vielleicht bleibe ich kürzer. ich wollte noch brot holen, hab es aber nicht gemacht. "
|
||||||
|
"die rote tasche lag im auto."
|
||||||
|
),
|
||||||
|
"typos": ("zimlich", "dan zum"),
|
||||||
|
"must_keep": ("vielleicht", "nicht gemacht", "rote tasche"),
|
||||||
|
"must_not_invent": ("traurig", "weil"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notes_fragments",
|
||||||
|
"class": "bullet_points_and_fragments",
|
||||||
|
"source": "markt\nkirschen kaufen\nspäter hafen",
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("markt", "kirschen", "hafen"),
|
||||||
|
"must_not_invent": ("glücklich", "weil"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan_vs_done",
|
||||||
|
"class": "plan_versus_completion",
|
||||||
|
"source": "Ich wollte um elf zum Hafen. Stattdessen blieb ich zu Hause. Den Brief habe ich nicht abgeschickt.",
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("wollte", "blieb", "nicht abgeschickt"),
|
||||||
|
"must_not_invent": ("geschickt", "bin zum hafen"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "negation_uncertainty",
|
||||||
|
"class": "negation_and_uncertainty",
|
||||||
|
"source": "Vielleicht kommt der Techniker. Ich bin unsicher wegen des Tees. Den Kuchen habe ich nicht gebacken.",
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("vielleicht", "unsicher", "nicht gebacken"),
|
||||||
|
"must_not_invent": ("sicher", "gebacken"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "correction",
|
||||||
|
"class": "correction_of_earlier_claim",
|
||||||
|
"source": "Zuerst dachte ich, der Markt sei um neun. Später korrigierte ich mich: er war schon um acht zu Ende.",
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("zuerst", "korrigierte", "acht"),
|
||||||
|
"must_not_invent": ("neun zu ende",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "imprecise_time",
|
||||||
|
"class": "imprecise_time",
|
||||||
|
"source": "Gegen sechs bin ich aufgewacht. Irgendwann vorm Mittag war ich am Markt.",
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("gegen sechs", "irgendwann"),
|
||||||
|
"must_not_invent": ("genau 6:00", "12:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "outstanding_event",
|
||||||
|
"class": "outstanding_event_among_everyday",
|
||||||
|
"source": (
|
||||||
|
"gegen 6 uhr aufgewacht\n"
|
||||||
|
"morgenroutine mit sprache, notizen und tee\n"
|
||||||
|
"unsichere teepraeferenz\n"
|
||||||
|
"techniker sollte um 11 uhr kommen\n"
|
||||||
|
"kueche aufgeraeumt\n"
|
||||||
|
"gegen 9 uhr gefruehstueckt\n"
|
||||||
|
"kinder erst gegen 10 oder 11 uhr aufgestanden\n"
|
||||||
|
"normaler strandtag\n"
|
||||||
|
"ploetzlich seehunde im wasser gesehen"
|
||||||
|
),
|
||||||
|
"typos": ("praeferenz", "aufgeraeumt", "gefruehstueckt"),
|
||||||
|
"must_keep": ("normaler strandtag", "seehunde", "unsichere"),
|
||||||
|
"must_not_invent": ("gluecklich", "schicksal"),
|
||||||
|
"weight_tokens": ("seehunde", "normaler"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recurring_people",
|
||||||
|
"class": "recurring_people_and_projects",
|
||||||
|
"source": (
|
||||||
|
"Am Vormittag sprach ich mit [[PERSON:01]] über Projekt [[PROJECT:01]]. "
|
||||||
|
"Später half [[PERSON:01]] beim Aufräumen. [[PROJECT:01]] blieb liegen."
|
||||||
|
),
|
||||||
|
"typos": (),
|
||||||
|
"must_keep": ("[[PERSON:01]]", "[[PROJECT:01]]", "blieb liegen"),
|
||||||
|
"must_not_invent": ("[[PERSON:02]]",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "incomplete_clause",
|
||||||
|
"class": "incomplete_source_rebuildable",
|
||||||
|
"source": "ich gieng zum laden. danach sprach ich mit dem und kam zurück. es war kald.",
|
||||||
|
"typos": ("gieng", "kald"),
|
||||||
|
"must_keep": ("laden", "kam zurück"),
|
||||||
|
"must_not_invent": ("nachbarn", "freund"),
|
||||||
|
"incomplete_ok": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SYNTHETIC_PROSE = FIXTURES[0]["source"]
|
||||||
|
SYNTHETIC_NOTES = FIXTURES[1]["source"]
|
||||||
|
SYNTHETIC_TYPOS = FIXTURES[0]["typos"]
|
||||||
|
|
||||||
|
|
||||||
def _words(text: str) -> list[str]:
|
def _words(text: str) -> list[str]:
|
||||||
|
|
@ -67,13 +170,15 @@ def score_output(source: str, output: str, *, profile: str = "", typos: tuple[st
|
||||||
sentences = _sentences(body)
|
sentences = _sentences(body)
|
||||||
paragraphs = [item for item in re.split(r"\n\s*\n", body) if item.strip()]
|
paragraphs = [item for item in re.split(r"\n\s*\n", body) if item.strip()]
|
||||||
avg_len = round(sum(len(item.split()) for item in sentences) / max(1, len(sentences)), 2)
|
avg_len = round(sum(len(item.split()) for item in sentences) / max(1, len(sentences)), 2)
|
||||||
transitions = len(re.findall(r"(?i)\b(?:danach|später|dann|zuerst|schließlich)\b", body))
|
transitions = len(re.findall(r"(?i)\b(?:danach|später|dann|zuerst|schließlich|bis|plötzlich)\b", body))
|
||||||
profile_words = set(_words(profile))
|
profile_words = set(_words(profile))
|
||||||
style_overlap = round(len(out_words & profile_words) / max(1, len(profile_words)), 3) if profile_words else 0.0
|
style_overlap = round(len(out_words & profile_words) / max(1, len(profile_words)), 3) if profile_words else 0.0
|
||||||
kept = round(len(src_words & out_words) / max(1, len(src_words)), 3)
|
kept = round(len(src_words & out_words) / max(1, len(src_words)), 3)
|
||||||
extra = sorted(out_words - src_words - profile_words)
|
extra = sorted(out_words - src_words - profile_words)
|
||||||
lost = sorted(src_words - out_words)
|
lost = sorted(src_words - out_words)
|
||||||
remaining_typos = [item for item in typos if item.lower() in body.lower()]
|
remaining_typos = [item for item in typos if item.lower() in body.lower()]
|
||||||
|
from journal_editorial import incomplete_syntax_markers
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"spelling_typos_remaining": remaining_typos,
|
"spelling_typos_remaining": remaining_typos,
|
||||||
"spelling_typos_fixed": [item for item in typos if item.lower() not in body.lower()],
|
"spelling_typos_fixed": [item for item in typos if item.lower() not in body.lower()],
|
||||||
|
|
@ -86,6 +191,8 @@ def score_output(source: str, output: str, *, profile: str = "", typos: tuple[st
|
||||||
"new_info_tokens": extra[:24],
|
"new_info_tokens": extra[:24],
|
||||||
"lost_info_tokens": lost[:24],
|
"lost_info_tokens": lost[:24],
|
||||||
"lexical_similarity": lexical_similarity(source, body),
|
"lexical_similarity": lexical_similarity(source, body),
|
||||||
|
"incomplete_syntax": incomplete_syntax_markers(body),
|
||||||
|
"unresolved_privacy_tokens": bool(re.search(r"\[\[\s*(?:…|\.{2,})\s*\]\]", body)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,6 +207,31 @@ def variant_templates() -> dict[str, str]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_context(source: str, *, writing_profile: str | None = None) -> dict[str, str]:
|
||||||
|
artifact = {
|
||||||
|
"kind": "verified_artifact",
|
||||||
|
"coverage": "all_selected_sources",
|
||||||
|
"sources": [{"source_id": "u1", "role": "user", "text": source}],
|
||||||
|
}
|
||||||
|
from journal_editorial import narration_sources_text
|
||||||
|
from journal_generation_policy import compile_selection, default_selection_ids
|
||||||
|
from writing_profile_store import NEUTRAL_JOURNAL_STYLE
|
||||||
|
|
||||||
|
compiled = compile_selection(default_selection_ids())
|
||||||
|
return {
|
||||||
|
"reconstruction": narration_sources_text(artifact) or source,
|
||||||
|
"writing_profile": writing_profile or NEUTRAL_JOURNAL_STYLE,
|
||||||
|
"style_examples": "Keine historischen Stilbeispiele.",
|
||||||
|
**compiled.instructions,
|
||||||
|
"existing_text": "",
|
||||||
|
"space_title": "Eval",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_context(source: str) -> dict[str, str]:
|
||||||
|
return fixture_context(source)
|
||||||
|
|
||||||
|
|
||||||
def run_variant(
|
def run_variant(
|
||||||
name: str,
|
name: str,
|
||||||
template: str,
|
template: str,
|
||||||
|
|
@ -109,13 +241,14 @@ def run_variant(
|
||||||
live: bool,
|
live: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from engine import execute_prompt
|
from engine import execute_prompt
|
||||||
|
from model_catalog import resolve_generate_metadata
|
||||||
from prompt_budget import plan_journal_budget
|
from prompt_budget import plan_journal_budget
|
||||||
from providers import generate_provider
|
from providers import generate_provider
|
||||||
from model_catalog import resolve_generate_metadata
|
|
||||||
|
|
||||||
prompt = {
|
prompt = {
|
||||||
"id": f"eval-{name}",
|
"id": f"eval-{name}",
|
||||||
"slug": "mvp.journal_generate" if name == VARIANT_CURRENT else f"eval.{name}",
|
"slug": "mvp.journal_generate" if name == VARIANT_CURRENT else f"eval.{name}",
|
||||||
|
"seed_revision": "eval",
|
||||||
"prompt_type": "base",
|
"prompt_type": "base",
|
||||||
"required_feature": "ai_calls",
|
"required_feature": "ai_calls",
|
||||||
"template": template,
|
"template": template,
|
||||||
|
|
@ -145,87 +278,136 @@ def run_variant(
|
||||||
"total_tokens": diag.get("total_tokens"),
|
"total_tokens": diag.get("total_tokens"),
|
||||||
"cost": diag.get("cost"),
|
"cost": diag.get("cost"),
|
||||||
"runtime_ms": elapsed_ms,
|
"runtime_ms": elapsed_ms,
|
||||||
|
"generate_ms": diag.get("generate_ms"),
|
||||||
"model": (result.get("trace") or {}).get("model") or diag.get("actual_model"),
|
"model": (result.get("trace") or {}).get("model") or diag.get("actual_model"),
|
||||||
|
"prompt_revision": diag.get("prompt_revision"),
|
||||||
"fake_provider": not live,
|
"fake_provider": not live,
|
||||||
|
"rendered_contains_profile": (context.get("writing_profile") or "")[:40]
|
||||||
|
in ((result.get("trace") or {}).get("intern") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def synthetic_context(source: str) -> dict[str, str]:
|
def _offline_row(name: str, template: str, context: dict[str, str]) -> dict[str, Any]:
|
||||||
artifact = {
|
|
||||||
"kind": "verified_artifact",
|
|
||||||
"coverage": "all_selected_sources",
|
|
||||||
"sources": [{"source_id": "u1", "role": "user", "text": source}],
|
|
||||||
}
|
|
||||||
from journal_editorial import (
|
|
||||||
NOTES_TO_JOURNAL,
|
|
||||||
PROSE_EDIT,
|
|
||||||
choose_editorial_mode,
|
|
||||||
editorial_instructions,
|
|
||||||
narration_sources_text,
|
|
||||||
)
|
|
||||||
from writing_profile_store import NEUTRAL_JOURNAL_STYLE
|
|
||||||
|
|
||||||
mode = choose_editorial_mode([source])
|
|
||||||
return {
|
|
||||||
"reconstruction": narration_sources_text(artifact) or source,
|
|
||||||
"writing_profile": NEUTRAL_JOURNAL_STYLE,
|
|
||||||
"style_examples": "Keine historischen Stilbeispiele.",
|
|
||||||
"editorial_mode": mode,
|
|
||||||
"editorial_instructions": editorial_instructions(mode),
|
|
||||||
"existing_text": "",
|
|
||||||
"space_title": "Eval",
|
|
||||||
"expected_mode": PROSE_EDIT if mode == PROSE_EDIT else NOTES_TO_JOURNAL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compare_synthetic(*, live: bool = False, profile_id: str | None = None) -> dict[str, Any]:
|
|
||||||
from placeholders import resolve_template
|
from placeholders import resolve_template
|
||||||
import placeholder_mvp # noqa: F401
|
|
||||||
from privacy_gateway import _fake_complete
|
from privacy_gateway import _fake_complete
|
||||||
|
|
||||||
|
rendered = resolve_template(template, context)
|
||||||
|
started = time.perf_counter()
|
||||||
|
content = _fake_complete("journal_generate", rendered)
|
||||||
|
return {
|
||||||
|
"variant": name,
|
||||||
|
"live": False,
|
||||||
|
"content": content,
|
||||||
|
"prompt_tokens": None,
|
||||||
|
"completion_tokens": None,
|
||||||
|
"total_tokens": None,
|
||||||
|
"cost": None,
|
||||||
|
"runtime_ms": int((time.perf_counter() - started) * 1000),
|
||||||
|
"model": "fake",
|
||||||
|
"fake_provider": True,
|
||||||
|
"rendered": rendered,
|
||||||
|
"rendered_contains_profile": (context.get("writing_profile") or "")[:24] in rendered,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _human_blind_pair(left: dict, right: dict) -> dict[str, Any]:
|
||||||
|
"""Side-by-side without naming a winner. Mapping stays in the machine report."""
|
||||||
|
return {
|
||||||
|
"prompt_1": {"label": "Prompt 1", "text": left.get("content") or ""},
|
||||||
|
"prompt_2": {"label": "Prompt 2", "text": right.get("content") or ""},
|
||||||
|
"hidden_mapping": {
|
||||||
|
"prompt_1": left.get("variant"),
|
||||||
|
"prompt_2": right.get("variant"),
|
||||||
|
},
|
||||||
|
"instruction": (
|
||||||
|
"Blind bewerten: Faktentreue, Unsicherheit, Sprache, Lesefluss, "
|
||||||
|
"Gewichtung, Profiltreue, Eigenständigkeit. Keine automatische Siegeraussage."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_synthetic(
|
||||||
|
*,
|
||||||
|
live: bool = False,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
profile_ab: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
import placeholder_mvp # noqa: F401
|
||||||
|
|
||||||
templates = variant_templates()
|
templates = variant_templates()
|
||||||
source = SYNTHETIC_PROSE
|
fixtures_out = []
|
||||||
context = synthetic_context(source)
|
for fixture in FIXTURES:
|
||||||
rows = []
|
context = fixture_context(fixture["source"])
|
||||||
for name, template in templates.items():
|
rows = []
|
||||||
|
for name, template in templates.items():
|
||||||
|
if live:
|
||||||
|
if not profile_id:
|
||||||
|
raise SystemExit("--live requires --profile-id")
|
||||||
|
row = run_variant(name, template, context, profile_id=profile_id, live=True)
|
||||||
|
else:
|
||||||
|
row = _offline_row(name, template, context)
|
||||||
|
row["scores"] = score_output(
|
||||||
|
fixture["source"],
|
||||||
|
row["content"],
|
||||||
|
profile=context.get("writing_profile") or "",
|
||||||
|
typos=tuple(fixture.get("typos") or ()),
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
current = next((item for item in rows if item["variant"] == VARIANT_CURRENT), rows[-1])
|
||||||
|
baseline = next((item for item in rows if item["variant"] == VARIANT_BASELINE), rows[0])
|
||||||
|
fixtures_out.append(
|
||||||
|
{
|
||||||
|
"id": fixture["id"],
|
||||||
|
"class": fixture["class"],
|
||||||
|
"source": fixture["source"],
|
||||||
|
"variants": rows,
|
||||||
|
"human_blind": _human_blind_pair(current, baseline),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_ab_report = None
|
||||||
|
if profile_ab:
|
||||||
|
source = next(item["source"] for item in FIXTURES if item["id"] == "outstanding_event")
|
||||||
|
ctx_a = fixture_context(source, writing_profile=PROFILE_A)
|
||||||
|
ctx_b = fixture_context(source, writing_profile=PROFILE_B)
|
||||||
|
current_template = templates[VARIANT_CURRENT]
|
||||||
if live:
|
if live:
|
||||||
if not profile_id:
|
if not profile_id:
|
||||||
raise SystemExit("--live requires --profile-id")
|
raise SystemExit("--live requires --profile-id")
|
||||||
row = run_variant(name, template, context, profile_id=profile_id, live=True)
|
row_a = run_variant("profile_a", current_template, ctx_a, profile_id=profile_id, live=True)
|
||||||
|
row_b = run_variant("profile_b", current_template, ctx_b, profile_id=profile_id, live=True)
|
||||||
else:
|
else:
|
||||||
rendered = resolve_template(template, context)
|
row_a = _offline_row("profile_a", current_template, ctx_a)
|
||||||
started = time.perf_counter()
|
row_b = _offline_row("profile_b", current_template, ctx_b)
|
||||||
content = _fake_complete("journal_generate", rendered)
|
rendered_a = row_a.get("rendered") or ""
|
||||||
row = {
|
rendered_b = row_b.get("rendered") or ""
|
||||||
"variant": name,
|
profile_ab_report = {
|
||||||
"live": False,
|
"same_facts": ctx_a["reconstruction"] == ctx_b["reconstruction"],
|
||||||
"content": content,
|
"prompts_differ": (PROFILE_A in rendered_a and PROFILE_B in rendered_b and PROFILE_A not in rendered_b)
|
||||||
"prompt_tokens": None,
|
if not live
|
||||||
"completion_tokens": None,
|
else row_a.get("content") != row_b.get("content"),
|
||||||
"total_tokens": None,
|
"profile_a_in_prompt": PROFILE_A[:20] in rendered_a if not live else None,
|
||||||
"cost": None,
|
"profile_b_in_prompt": PROFILE_B[:20] in rendered_b if not live else None,
|
||||||
"runtime_ms": int((time.perf_counter() - started) * 1000),
|
"human_blind": _human_blind_pair(row_a, row_b),
|
||||||
"model": "fake",
|
"note": (
|
||||||
"fake_provider": True,
|
"Offline: beweist verschiedene Stilvorgaben im gerenderten Prompt, nicht Live-Prosa."
|
||||||
}
|
if not live
|
||||||
row["scores"] = score_output(
|
else "Live-A/B über das Privacy Gateway. Ob Rhythmus und Wortwahl divergieren, bewertet ein Mensch."
|
||||||
source,
|
),
|
||||||
row["content"],
|
}
|
||||||
profile=context.get("writing_profile") or "",
|
|
||||||
typos=SYNTHETIC_TYPOS,
|
|
||||||
)
|
|
||||||
row["source"] = "synthetic"
|
|
||||||
rows.append(row)
|
|
||||||
return {
|
return {
|
||||||
"live": live,
|
"live": live,
|
||||||
"live_quality_confirmed": False,
|
"live_quality_confirmed": False,
|
||||||
|
"winner_declared": False,
|
||||||
"note": (
|
"note": (
|
||||||
"Fake-Provider-Lauf: beweist den Vergleichsvertrag, nicht echte Modellprosa."
|
"Fake-Provider-Lauf: beweist den Vergleichsvertrag, nicht echte Modellprosa."
|
||||||
if not live
|
if not live
|
||||||
else "Live-Lauf über das Privacy Gateway. Qualitative Bewertung bleibt manuell."
|
else "Live-Lauf über das Privacy Gateway. Qualitative Bewertung bleibt manuell. Keine automatische Siegeraussage."
|
||||||
),
|
),
|
||||||
"source_kind": "synthetic",
|
"source_kind": "synthetic",
|
||||||
"variants": rows,
|
"fixtures": fixtures_out,
|
||||||
|
"profile_ab": profile_ab_report,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,6 +415,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description="Journal narration quality comparison (opt-in).")
|
parser = argparse.ArgumentParser(description="Journal narration quality comparison (opt-in).")
|
||||||
parser.add_argument("--live", action="store_true", help="Call the configured generate provider. Costs money.")
|
parser.add_argument("--live", action="store_true", help="Call the configured generate provider. Costs money.")
|
||||||
parser.add_argument("--profile-id", help="Required with --live. Uses the Privacy Gateway.")
|
parser.add_argument("--profile-id", help="Required with --live. Uses the Privacy Gateway.")
|
||||||
|
parser.add_argument("--profile-ab", action="store_true", help="Compare two synthetic writing profiles.")
|
||||||
parser.add_argument("--json", action="store_true", help="Print JSON instead of text.")
|
parser.add_argument("--json", action="store_true", help="Print JSON instead of text.")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.live:
|
if args.live:
|
||||||
|
|
@ -252,20 +435,31 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
from db import init_db
|
from db import init_db
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
report = compare_synthetic(live=bool(args.live), profile_id=args.profile_id)
|
report = compare_synthetic(live=bool(args.live), profile_id=args.profile_id, profile_ab=bool(args.profile_ab))
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
print(report["note"])
|
print(report["note"])
|
||||||
if not report["live"]:
|
if not report["live"]:
|
||||||
print("Live-Qualität: noch nicht bestätigt.")
|
print("Live-Qualität: noch nicht bestätigt. Keine Siegeraussage.")
|
||||||
for item in report["variants"]:
|
for fixture in report["fixtures"]:
|
||||||
scores = item["scores"]
|
print(f"\n[{fixture['id']} / {fixture['class']}]")
|
||||||
print(
|
for item in fixture["variants"]:
|
||||||
f"{item['variant']}: similarity={scores['lexical_similarity']} "
|
scores = item["scores"]
|
||||||
f"keep={scores['fact_token_keep']} typos_left={scores['spelling_typos_remaining']} "
|
print(
|
||||||
f"tokens={item.get('total_tokens')} cost={item.get('cost')} ms={item['runtime_ms']}"
|
f" {item['variant']}: similarity={scores['lexical_similarity']} "
|
||||||
)
|
f"keep={scores['fact_token_keep']} typos_left={scores['spelling_typos_remaining']} "
|
||||||
|
f"incomplete={scores['incomplete_syntax']} tokens={item.get('total_tokens')} "
|
||||||
|
f"cost={item.get('cost')} ms={item['runtime_ms']}"
|
||||||
|
)
|
||||||
|
blind = fixture["human_blind"]
|
||||||
|
print(" Blindvergleich: Prompt 1 vs Prompt 2 (Mapping nur im JSON).")
|
||||||
|
print(f" {blind['instruction']}")
|
||||||
|
if report.get("profile_ab"):
|
||||||
|
ab = report["profile_ab"]
|
||||||
|
print("\n[profile_ab]")
|
||||||
|
print(f" same_facts={ab['same_facts']} prompts_differ={ab['prompts_differ']}")
|
||||||
|
print(f" {ab['note']}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,25 @@ from context_builder import assemble_text, build_internal_context
|
||||||
from dialogue_store import StoreError, list_conversations_for_day, list_messages
|
from dialogue_store import StoreError, list_conversations_for_day, list_messages
|
||||||
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt
|
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt
|
||||||
from journal_policy import require_explicit_generate, source_conversation_ids
|
from journal_policy import require_explicit_generate, source_conversation_ids
|
||||||
from identity_store import is_maskable_label, list_mappings
|
from identity_store import is_maskable_label, list_mappings, mapping_spellings
|
||||||
from journal_body import clean_title
|
from journal_body import clean_title
|
||||||
from journal_editorial import (
|
from journal_editorial import (
|
||||||
choose_editorial_mode,
|
|
||||||
editorial_instructions,
|
|
||||||
format_style_examples,
|
format_style_examples,
|
||||||
|
incomplete_syntax_markers,
|
||||||
lexical_similarity,
|
lexical_similarity,
|
||||||
narration_sources_text,
|
narration_sources_text,
|
||||||
select_journal_style_examples,
|
select_journal_style_examples,
|
||||||
|
style_example_diagnostics,
|
||||||
|
writing_profile_trace,
|
||||||
|
)
|
||||||
|
from journal_generation_policy import (
|
||||||
|
assert_journal_prompt_contract,
|
||||||
|
assert_template_resolved,
|
||||||
|
compile_selection,
|
||||||
|
draft_snapshot,
|
||||||
|
mark_guidelines_used,
|
||||||
|
policy_trace,
|
||||||
|
resolve_run_selection,
|
||||||
)
|
)
|
||||||
from journal_reconstruct import (
|
from journal_reconstruct import (
|
||||||
assign_source_ids,
|
assign_source_ids,
|
||||||
|
|
@ -42,6 +52,9 @@ from providers import generate_provider
|
||||||
from retrieval import retrieve
|
from retrieval import retrieve
|
||||||
from writing_profile_store import remember_dialogue_style
|
from writing_profile_store import remember_dialogue_style
|
||||||
|
|
||||||
|
JOURNAL_NOT_ACCEPTED = "journal_generation_not_accepted"
|
||||||
|
JOURNAL_NOT_ACCEPTED_MESSAGE = "Generierung nicht übernommen."
|
||||||
|
|
||||||
|
|
||||||
IDENTITY_PLACEHOLDER = re.compile(
|
IDENTITY_PLACEHOLDER = re.compile(
|
||||||
r"\[\[\s*(?:SELF|PERSON:[^\]]+|PLACE:[^\]]+|ORG:[^\]]+|PROJECT:[^\]]+|…|\.{2,})\s*\]\]",
|
r"\[\[\s*(?:SELF|PERSON:[^\]]+|PLACE:[^\]]+|ORG:[^\]]+|PROJECT:[^\]]+|…|\.{2,})\s*\]\]",
|
||||||
|
|
@ -70,7 +83,7 @@ def _split_title(content: str) -> tuple[str, str]:
|
||||||
|
|
||||||
|
|
||||||
def _local_narration(reconstruction: dict) -> tuple[str, str]:
|
def _local_narration(reconstruction: dict) -> tuple[str, str]:
|
||||||
"""Fail-closed draft from verified local sources. No leaking model text."""
|
"""Local source preview only. Never stored as a generated journal draft."""
|
||||||
parts = [str(part).strip() for part in claim_texts(reconstruction) if str(part).strip()]
|
parts = [str(part).strip() for part in claim_texts(reconstruction) if str(part).strip()]
|
||||||
body = "\n\n".join(parts)
|
body = "\n\n".join(parts)
|
||||||
return "Ein Tag", body
|
return "Ein Tag", body
|
||||||
|
|
@ -85,6 +98,8 @@ def unattested_journal_content(
|
||||||
"""Journal-only: historical names not in the sources are unattested facts, not a privacy leak.
|
"""Journal-only: historical names not in the sources are unattested facts, not a privacy leak.
|
||||||
|
|
||||||
Title and body are checked together. Placeholders must stay consistent across both.
|
Title and body are checked together. Placeholders must stay consistent across both.
|
||||||
|
Attestation is token-based: any attested spelling of a token (canonical or alias)
|
||||||
|
allows every known spelling of that same token after local demasking.
|
||||||
"""
|
"""
|
||||||
body = text or ""
|
body = text or ""
|
||||||
allowed = {canonical_token(token).upper().replace(" ", "") for token in (active_tokens or [])}
|
allowed = {canonical_token(token).upper().replace(" ", "") for token in (active_tokens or [])}
|
||||||
|
|
@ -96,30 +111,118 @@ def unattested_journal_content(
|
||||||
if token in GENERIC_PLACEHOLDER_INNER or token not in allowed:
|
if token in GENERIC_PLACEHOLDER_INNER or token not in allowed:
|
||||||
return "unattested_placeholder"
|
return "unattested_placeholder"
|
||||||
sources = "\n".join(source_user or [])
|
sources = "\n".join(source_user or [])
|
||||||
|
spellings_by_token: dict[str, list[str]] = {}
|
||||||
for item in mappings or []:
|
for item in mappings or []:
|
||||||
label = (item.get("local_label") or "").strip()
|
token = canonical_token(item.get("token") or "").upper().replace(" ", "")
|
||||||
token = (item.get("token") or "").strip()
|
if not token:
|
||||||
if not label or not token or not is_maskable_label(label):
|
|
||||||
continue
|
continue
|
||||||
if identity_occurrence_count(sources, label, token) > 0:
|
bucket = spellings_by_token.setdefault(token, [])
|
||||||
|
seen = {label.casefold() for label in bucket}
|
||||||
|
for label in mapping_spellings(item):
|
||||||
|
if not label or label.casefold() in seen:
|
||||||
|
continue
|
||||||
|
seen.add(label.casefold())
|
||||||
|
bucket.append(label)
|
||||||
|
attested: set[str] = set()
|
||||||
|
for token, labels in spellings_by_token.items():
|
||||||
|
for label in labels:
|
||||||
|
if identity_occurrence_count(sources, label, token) > 0:
|
||||||
|
attested.add(token)
|
||||||
|
break
|
||||||
|
for token, labels in spellings_by_token.items():
|
||||||
|
if token in attested:
|
||||||
continue
|
continue
|
||||||
for match in identity_label_pattern(label).finditer(body):
|
for label in labels:
|
||||||
if is_identity_mention(body, match.start(), match.end(), token):
|
if not is_maskable_label(label):
|
||||||
return "unattested_identity"
|
continue
|
||||||
|
for match in identity_label_pattern(label).finditer(body):
|
||||||
|
if is_identity_mention(body, match.start(), match.end(), token):
|
||||||
|
return "unattested_identity"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _identity_leak_result(purpose: str, exc: EngineError) -> dict:
|
def _reject_generation(
|
||||||
diag = getattr(exc, "diagnostics", None) or {}
|
*,
|
||||||
|
reason: str,
|
||||||
|
diagnostics: dict | None = None,
|
||||||
|
source_preview: str = "",
|
||||||
|
extra: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
diag = dict(diagnostics or {})
|
||||||
|
trace = dict(diag.get("trace") or {})
|
||||||
|
extra = dict(extra or {})
|
||||||
|
trace.update(
|
||||||
|
{
|
||||||
|
"purpose": "journal_generate",
|
||||||
|
"prompt_slug": extra.get("prompt_slug") or trace.get("prompt_slug") or "mvp.journal_generate",
|
||||||
|
"model_text_accepted": False,
|
||||||
|
"narration_source": "not_accepted",
|
||||||
|
"abort_reason": reason,
|
||||||
|
"source_preview": source_preview,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
diag["trace"] = trace
|
||||||
|
diag["log"] = extra.get("log") or diag.get("log") or trace.get("log") or []
|
||||||
|
diag["model_text_accepted"] = False
|
||||||
|
diag["abort_reason"] = reason
|
||||||
|
diag["provenance_decision"] = extra.get("provenance_decision") or diag.get("provenance_decision")
|
||||||
|
raise EngineError(
|
||||||
|
JOURNAL_NOT_ACCEPTED,
|
||||||
|
JOURNAL_NOT_ACCEPTED_MESSAGE,
|
||||||
|
409,
|
||||||
|
diagnostics=diag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_journal_trace(
|
||||||
|
reconstruct_result: dict,
|
||||||
|
narrate_result: dict,
|
||||||
|
*,
|
||||||
|
run_log: list[dict],
|
||||||
|
narrate_prompt: dict,
|
||||||
|
profile_meta: dict,
|
||||||
|
style_meta: dict,
|
||||||
|
dropped: list[str],
|
||||||
|
extra: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
reconstruct_trace = _stage_trace(reconstruct_result, "local_source_artifact")
|
||||||
|
reconstruct_trace.setdefault("purpose", "local_source_artifact")
|
||||||
|
reconstruct_trace.setdefault("status", "local_ok")
|
||||||
|
reconstruct_trace.setdefault("stage1", "local_ok")
|
||||||
|
narrate_trace = _stage_trace(narrate_result, "journal_generate")
|
||||||
|
narrate_trace.setdefault("prompt_slug", narrate_prompt.get("slug") or "mvp.journal_generate")
|
||||||
|
narrate_trace["prompt_revision"] = (
|
||||||
|
narrate_trace.get("prompt_revision")
|
||||||
|
or narrate_prompt.get("seed_revision")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
narrate_trace["writing_profile"] = profile_meta
|
||||||
|
narrate_trace["style_examples"] = {**style_meta, "dropped": "style_examples" in dropped}
|
||||||
|
narrate_trace["dropped_optional_blocks"] = dropped
|
||||||
|
diag = narrate_result.get("diagnostics") or {}
|
||||||
|
if diag.get("generate_ms") is not None:
|
||||||
|
narrate_trace["generate_ms"] = diag.get("generate_ms")
|
||||||
|
if diag.get("prompt_revision"):
|
||||||
|
narrate_trace["prompt_revision"] = diag.get("prompt_revision")
|
||||||
|
if diag.get("generate_calls") is not None:
|
||||||
|
narrate_trace.setdefault("generate_calls", diag.get("generate_calls"))
|
||||||
|
if diag.get("model"):
|
||||||
|
narrate_trace.setdefault("model", diag.get("model"))
|
||||||
|
if diag.get("provider"):
|
||||||
|
narrate_trace.setdefault("provider", diag.get("provider"))
|
||||||
|
if diag.get("completion_tokens") is not None:
|
||||||
|
narrate_trace.setdefault("completion_tokens", diag.get("completion_tokens"))
|
||||||
|
if extra:
|
||||||
|
narrate_trace.update(extra)
|
||||||
return {
|
return {
|
||||||
"content": "",
|
**narrate_trace,
|
||||||
"trace": {
|
"prompt_revision": narrate_trace.get("prompt_revision"),
|
||||||
"purpose": purpose,
|
"writing_profile": profile_meta,
|
||||||
"guard": "identity_leak_blocked",
|
"style_examples": narrate_trace.get("style_examples"),
|
||||||
"log": list(diag.get("log") or []),
|
"dropped_optional_blocks": dropped,
|
||||||
"response_validation_retry": diag.get("response_validation_retry"),
|
"log": run_log,
|
||||||
},
|
"stages": [reconstruct_trace, narrate_trace],
|
||||||
"diagnostics": diag,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,6 +354,8 @@ def generate_draft(
|
||||||
conversation_ids: list[str] | None = None,
|
conversation_ids: list[str] | None = None,
|
||||||
include_existing: bool = False,
|
include_existing: bool = False,
|
||||||
explicit: bool = True,
|
explicit: bool = True,
|
||||||
|
generation_selection: dict | None = None,
|
||||||
|
remember_generation_selection: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
require_explicit_generate(explicit)
|
require_explicit_generate(explicit)
|
||||||
day = get_day(profile_id, journal_day_id)
|
day = get_day(profile_id, journal_day_id)
|
||||||
|
|
@ -261,6 +366,13 @@ def generate_draft(
|
||||||
)
|
)
|
||||||
remember_dialogue_style(profile_id, exclude_conversation_ids=selected)
|
remember_dialogue_style(profile_id, exclude_conversation_ids=selected)
|
||||||
existing_text = _existing_text(profile_id, journal_day_id) if include_existing else ""
|
existing_text = _existing_text(profile_id, journal_day_id) if include_existing else ""
|
||||||
|
selection_ids, persist_meta = resolve_run_selection(
|
||||||
|
profile_id,
|
||||||
|
generation_selection,
|
||||||
|
remember_generation_selection,
|
||||||
|
)
|
||||||
|
narrate_prompt = load_active_prompt("mvp.journal_generate")
|
||||||
|
assert_journal_prompt_contract(narrate_prompt.get("template") or "")
|
||||||
|
|
||||||
config = generate_provider()
|
config = generate_provider()
|
||||||
if not config:
|
if not config:
|
||||||
|
|
@ -294,12 +406,19 @@ def generate_draft(
|
||||||
for message in assign_source_ids(source_messages)
|
for message in assign_source_ids(source_messages)
|
||||||
if message.get("role") == "user" and (message.get("body") or "").strip()
|
if message.get("role") == "user" and (message.get("body") or "").strip()
|
||||||
]
|
]
|
||||||
editorial_mode = choose_editorial_mode(source_user)
|
compiled_policy = compile_selection(selection_ids)
|
||||||
|
policy_meta = policy_trace(
|
||||||
|
compiled_policy,
|
||||||
|
source=persist_meta["source"],
|
||||||
|
remembered=persist_meta["remembered"],
|
||||||
|
)
|
||||||
|
profile_meta = writing_profile_trace(profile_id)
|
||||||
style_example_rows = select_journal_style_examples(
|
style_example_rows = select_journal_style_examples(
|
||||||
profile_id,
|
profile_id,
|
||||||
exclude_dates=[day.get("calendar_date") or ""],
|
exclude_dates=[day.get("calendar_date") or ""],
|
||||||
)
|
)
|
||||||
style_examples = format_style_examples(style_example_rows)
|
style_examples = format_style_examples(style_example_rows)
|
||||||
|
style_meta = style_example_diagnostics(style_example_rows)
|
||||||
run_log: list[dict] = []
|
run_log: list[dict] = []
|
||||||
_event(
|
_event(
|
||||||
run_log,
|
run_log,
|
||||||
|
|
@ -309,14 +428,19 @@ def generate_draft(
|
||||||
coverage=reconstruction.get("coverage") or "all_selected_sources",
|
coverage=reconstruction.get("coverage") or "all_selected_sources",
|
||||||
status="local_ok",
|
status="local_ok",
|
||||||
source_count=len(source_user),
|
source_count=len(source_user),
|
||||||
editorial_mode=editorial_mode,
|
writing_profile_present=profile_meta.get("present"),
|
||||||
|
style_example_count=style_meta.get("count"),
|
||||||
|
generation_selection_source=policy_meta.get("source"),
|
||||||
|
generation_selection_keys=",".join(
|
||||||
|
f"{slot}:{policy_meta.get('keys', {}).get(slot)}"
|
||||||
|
for slot in ("transformation", "detail", "voice", "narrative")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
reconstruct_result = {
|
reconstruct_result = {
|
||||||
"trace": _local_stage_trace(reconstruction, source_count=len(source_user)),
|
"trace": _local_stage_trace(reconstruction, source_count=len(source_user)),
|
||||||
"content": reconstruction_text(reconstruction),
|
"content": reconstruction_text(reconstruction),
|
||||||
}
|
}
|
||||||
|
|
||||||
narrate_prompt = load_active_prompt("mvp.journal_generate")
|
|
||||||
narrate_context = build_internal_context(
|
narrate_context = build_internal_context(
|
||||||
profile_id,
|
profile_id,
|
||||||
space_id=day["space_id"],
|
space_id=day["space_id"],
|
||||||
|
|
@ -328,8 +452,10 @@ def generate_draft(
|
||||||
conversation_id=selected[0] if selected else None,
|
conversation_id=selected[0] if selected else None,
|
||||||
reconstruction=narration_sources_text(reconstruction),
|
reconstruction=narration_sources_text(reconstruction),
|
||||||
style_examples=style_examples,
|
style_examples=style_examples,
|
||||||
editorial_mode=editorial_mode,
|
transformation_instructions=compiled_policy.instructions["transformation_instructions"],
|
||||||
editorial_instructions=editorial_instructions(editorial_mode),
|
detail_instructions=compiled_policy.instructions["detail_instructions"],
|
||||||
|
voice_instructions=compiled_policy.instructions["voice_instructions"],
|
||||||
|
narrative_instructions=compiled_policy.instructions["narrative_instructions"],
|
||||||
)
|
)
|
||||||
assembled = assemble_text(narrate_context)
|
assembled = assemble_text(narrate_context)
|
||||||
try:
|
try:
|
||||||
|
|
@ -345,10 +471,13 @@ def generate_draft(
|
||||||
_raise_budget(exc)
|
_raise_budget(exc)
|
||||||
if dropped:
|
if dropped:
|
||||||
_event(run_log, "journal_generate", "budget_pack", dropped=",".join(dropped))
|
_event(run_log, "journal_generate", "budget_pack", dropped=",".join(dropped))
|
||||||
shape_source = "model"
|
|
||||||
mappings = list_mappings(profile_id)
|
mappings = list_mappings(profile_id)
|
||||||
|
preview_title, preview_body = _local_narration(reconstruction)
|
||||||
|
source_preview = f"{preview_title}\n\n{preview_body}".strip()
|
||||||
narrate_result: dict = {}
|
narrate_result: dict = {}
|
||||||
try:
|
try:
|
||||||
|
rendered_preview = preview_prompt(narrate_prompt, assembled)
|
||||||
|
assert_template_resolved(rendered_preview.get("rendered") or "")
|
||||||
narrate_result = execute_prompt(
|
narrate_result = execute_prompt(
|
||||||
narrate_prompt,
|
narrate_prompt,
|
||||||
profile_id,
|
profile_id,
|
||||||
|
|
@ -372,44 +501,94 @@ def generate_draft(
|
||||||
combined = f"{title}\n\n{body}".strip()
|
combined = f"{title}\n\n{body}".strip()
|
||||||
unattested = unattested_journal_content(combined, source_user, mappings, active_tokens)
|
unattested = unattested_journal_content(combined, source_user, mappings, active_tokens)
|
||||||
if unattested:
|
if unattested:
|
||||||
title, body = _local_narration(reconstruction)
|
|
||||||
shape_source = "fallback"
|
|
||||||
narrate_result = {
|
|
||||||
**narrate_result,
|
|
||||||
"content": f"{title}\n\n{body}".strip(),
|
|
||||||
"trace": {
|
|
||||||
**(narrate_result.get("trace") or {}),
|
|
||||||
"guard": "unattested_content_blocked",
|
|
||||||
"reason": unattested,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_event(
|
_event(
|
||||||
run_log,
|
run_log,
|
||||||
"journal_generate",
|
"journal_generate",
|
||||||
"narration_result",
|
"narration_result",
|
||||||
status="local_fallback",
|
status="not_accepted",
|
||||||
reason=unattested,
|
reason=unattested,
|
||||||
)
|
)
|
||||||
else:
|
bundle = _compose_journal_trace(
|
||||||
_event(
|
reconstruct_result,
|
||||||
run_log,
|
narrate_result,
|
||||||
"journal_generate",
|
run_log=run_log,
|
||||||
"narration_result",
|
narrate_prompt=narrate_prompt,
|
||||||
status="model",
|
profile_meta=profile_meta,
|
||||||
editorial_mode=editorial_mode,
|
style_meta=style_meta,
|
||||||
lexical_similarity=lexical_similarity("\n".join(source_user), body),
|
dropped=dropped,
|
||||||
|
extra={
|
||||||
|
"narration_source": "not_accepted",
|
||||||
|
"model_text_accepted": False,
|
||||||
|
"provenance_decision": unattested,
|
||||||
|
"abort_reason": unattested,
|
||||||
|
"source_preview": source_preview,
|
||||||
|
"generation_selection": policy_meta,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
_reject_generation(
|
||||||
|
reason=unattested,
|
||||||
|
diagnostics={
|
||||||
|
**(narrate_result.get("diagnostics") or {}),
|
||||||
|
"trace": bundle,
|
||||||
|
"log": run_log,
|
||||||
|
"provenance_decision": unattested,
|
||||||
|
},
|
||||||
|
source_preview=source_preview,
|
||||||
|
extra=bundle,
|
||||||
|
)
|
||||||
|
_event(
|
||||||
|
run_log,
|
||||||
|
"journal_generate",
|
||||||
|
"narration_result",
|
||||||
|
status="model",
|
||||||
|
lexical_similarity=lexical_similarity("\n".join(source_user), body),
|
||||||
|
incomplete_syntax=incomplete_syntax_markers(body),
|
||||||
|
writing_profile_present=profile_meta.get("present"),
|
||||||
|
)
|
||||||
except EngineError as exc:
|
except EngineError as exc:
|
||||||
|
if exc.code == JOURNAL_NOT_ACCEPTED:
|
||||||
|
raise
|
||||||
_stamp_log(run_log, "journal_generate", _log_items(exc))
|
_stamp_log(run_log, "journal_generate", _log_items(exc))
|
||||||
if exc.code != "response_validation_failed":
|
diag = dict(exc.diagnostics or {})
|
||||||
diag = dict(exc.diagnostics or {})
|
generate_called = bool(diag.get("generate_called") or diag.get("generate_calls"))
|
||||||
diag["log"] = run_log
|
failed_result = {
|
||||||
|
"diagnostics": diag,
|
||||||
|
"trace": dict(diag.get("trace") or {}),
|
||||||
|
"content": "",
|
||||||
|
}
|
||||||
|
bundle = _compose_journal_trace(
|
||||||
|
reconstruct_result,
|
||||||
|
failed_result,
|
||||||
|
run_log=run_log,
|
||||||
|
narrate_prompt=narrate_prompt,
|
||||||
|
profile_meta=profile_meta,
|
||||||
|
style_meta=style_meta,
|
||||||
|
dropped=dropped,
|
||||||
|
extra={
|
||||||
|
"narration_source": "not_accepted",
|
||||||
|
"model_text_accepted": False,
|
||||||
|
"abort_reason": exc.code,
|
||||||
|
"source_preview": source_preview,
|
||||||
|
"generation_selection": policy_meta,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
diag["log"] = run_log
|
||||||
|
diag["trace"] = bundle
|
||||||
|
if not generate_called:
|
||||||
raise EngineError(exc.code, exc.message, exc.status_code, diag) from exc
|
raise EngineError(exc.code, exc.message, exc.status_code, diag) from exc
|
||||||
title, body = _local_narration(reconstruction)
|
_event(
|
||||||
shape_source = "fallback"
|
run_log,
|
||||||
narrate_result = _identity_leak_result("journal_generate", exc)
|
"journal_generate",
|
||||||
narrate_result["content"] = f"{title}\n\n{body}".strip()
|
"narration_result",
|
||||||
_event(run_log, "journal_generate", "narration_result", status="local_fallback", reason="identity_leak_blocked")
|
status="not_accepted",
|
||||||
|
reason=exc.code,
|
||||||
|
)
|
||||||
|
_reject_generation(
|
||||||
|
reason=exc.code,
|
||||||
|
diagnostics=diag,
|
||||||
|
source_preview=source_preview,
|
||||||
|
extra=bundle,
|
||||||
|
)
|
||||||
person_labels = [
|
person_labels = [
|
||||||
(item.get("local_label") or "").strip()
|
(item.get("local_label") or "").strip()
|
||||||
for item in mappings
|
for item in mappings
|
||||||
|
|
@ -421,9 +600,24 @@ def generate_draft(
|
||||||
body,
|
body,
|
||||||
source_user,
|
source_user,
|
||||||
person_labels=person_labels,
|
person_labels=person_labels,
|
||||||
source=shape_source,
|
source="model",
|
||||||
)
|
)
|
||||||
before_entries = {item["id"]: item.get("current_version_id") for item in current_entries(profile_id, journal_day_id)}
|
before_entries = {item["id"]: item.get("current_version_id") for item in current_entries(profile_id, journal_day_id)}
|
||||||
|
model = (
|
||||||
|
((narrate_result.get("diagnostics") or {}).get("actual_model"))
|
||||||
|
or ((narrate_result.get("trace") or {}).get("model"))
|
||||||
|
or narrate_result.get("provider")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
snapshot = draft_snapshot(compiled_policy, prompt=narrate_prompt, model=str(model or ""))
|
||||||
|
mark_guidelines_used(
|
||||||
|
[
|
||||||
|
compiled_policy.ids["transformation"],
|
||||||
|
compiled_policy.ids["detail"],
|
||||||
|
compiled_policy.ids["voice"],
|
||||||
|
compiled_policy.ids["narrative"],
|
||||||
|
]
|
||||||
|
)
|
||||||
draft = insert_draft(
|
draft = insert_draft(
|
||||||
profile_id,
|
profile_id,
|
||||||
journal_day_id,
|
journal_day_id,
|
||||||
|
|
@ -431,25 +625,30 @@ def generate_draft(
|
||||||
body=body,
|
body=body,
|
||||||
source_conversation_ids=selected,
|
source_conversation_ids=selected,
|
||||||
source_message_ids=_message_ids(profile_id, selected),
|
source_message_ids=_message_ids(profile_id, selected),
|
||||||
|
generation_snapshot=snapshot,
|
||||||
)
|
)
|
||||||
after_entries = current_entries(profile_id, journal_day_id)
|
after_entries = current_entries(profile_id, journal_day_id)
|
||||||
for item in after_entries:
|
for item in after_entries:
|
||||||
previous = before_entries.get(item["id"])
|
previous = before_entries.get(item["id"])
|
||||||
if previous is not None and previous != item.get("current_version_id"):
|
if previous is not None and previous != item.get("current_version_id"):
|
||||||
raise StoreError("policy_violation", "Generate darf die Nutzerfassung nicht verändern", 500)
|
raise StoreError("policy_violation", "Generate darf die Nutzerfassung nicht verändern", 500)
|
||||||
reconstruct_trace = _stage_trace(reconstruct_result, "local_source_artifact")
|
bundle = _compose_journal_trace(
|
||||||
reconstruct_trace.setdefault("purpose", "local_source_artifact")
|
reconstruct_result,
|
||||||
reconstruct_trace.setdefault("status", "local_ok")
|
narrate_result,
|
||||||
reconstruct_trace.setdefault("stage1", "local_ok")
|
run_log=run_log,
|
||||||
narrate_trace = _stage_trace(narrate_result, "journal_generate")
|
narrate_prompt=narrate_prompt,
|
||||||
narrate_trace["editorial_mode"] = editorial_mode
|
profile_meta=profile_meta,
|
||||||
if shape_source == "model":
|
style_meta=style_meta,
|
||||||
narrate_trace["lexical_similarity"] = lexical_similarity("\n".join(source_user), body)
|
dropped=dropped,
|
||||||
|
extra={
|
||||||
|
"narration_source": "model",
|
||||||
|
"model_text_accepted": True,
|
||||||
|
"provenance_decision": "accepted",
|
||||||
|
"lexical_similarity": lexical_similarity("\n".join(source_user), body),
|
||||||
|
"incomplete_syntax": incomplete_syntax_markers(body),
|
||||||
|
"generation_selection": policy_meta,
|
||||||
|
},
|
||||||
|
)
|
||||||
draft["run_log"] = run_log
|
draft["run_log"] = run_log
|
||||||
draft["trace"] = {
|
draft["trace"] = bundle
|
||||||
**narrate_trace,
|
|
||||||
"editorial_mode": editorial_mode,
|
|
||||||
"log": run_log,
|
|
||||||
"stages": [reconstruct_trace, narrate_trace],
|
|
||||||
}
|
|
||||||
return draft
|
return draft
|
||||||
|
|
|
||||||
864
backend/journal_generation_policy.py
Normal file
864
backend/journal_generation_policy.py
Normal file
|
|
@ -0,0 +1,864 @@
|
||||||
|
"""Named, versioned journal generation guidelines. Not model temperature.
|
||||||
|
|
||||||
|
Four independent dimensions are selected by ID. Instruction text lives in
|
||||||
|
generation_guidelines, seeded from JSON. This module selects, validates and
|
||||||
|
composes. It does not own prompt wording and has no numeric 0–100 path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from db import get_db, row_to_dict
|
||||||
|
from journal_policy import PolicyError
|
||||||
|
from placeholders import CONTEXT_PATTERN
|
||||||
|
|
||||||
|
PURPOSE_JOURNAL = "journal_generate"
|
||||||
|
USER_SLOTS = ("transformation", "detail", "voice", "narrative")
|
||||||
|
ALLOWED_SLOTS = USER_SLOTS
|
||||||
|
LEGACY_SOURCE_MODE_SLOT = "source_mode"
|
||||||
|
SELECTION_KEYS = (
|
||||||
|
"transformation_id",
|
||||||
|
"detail_id",
|
||||||
|
"voice_id",
|
||||||
|
"narrative_id",
|
||||||
|
)
|
||||||
|
SLOT_TO_ID_KEY = {
|
||||||
|
"transformation": "transformation_id",
|
||||||
|
"detail": "detail_id",
|
||||||
|
"voice": "voice_id",
|
||||||
|
"narrative": "narrative_id",
|
||||||
|
}
|
||||||
|
ID_KEY_TO_SLOT = {value: key for key, value in SLOT_TO_ID_KEY.items()}
|
||||||
|
SLOT_INSTRUCTION_KEYS = {
|
||||||
|
"transformation": "transformation_instructions",
|
||||||
|
"detail": "detail_instructions",
|
||||||
|
"voice": "voice_instructions",
|
||||||
|
"narrative": "narrative_instructions",
|
||||||
|
}
|
||||||
|
REQUIRED_JOURNAL_PLACEHOLDERS = (
|
||||||
|
"transformation_instructions",
|
||||||
|
"detail_instructions",
|
||||||
|
"voice_instructions",
|
||||||
|
"narrative_instructions",
|
||||||
|
"writing_profile",
|
||||||
|
"style_examples",
|
||||||
|
"reconstruction",
|
||||||
|
"existing_text",
|
||||||
|
)
|
||||||
|
RETIRED_JOURNAL_PLACEHOLDERS = (
|
||||||
|
"source_mode_instructions",
|
||||||
|
"editorial_mode",
|
||||||
|
"editorial_instructions",
|
||||||
|
)
|
||||||
|
STATUS_DRAFT = "draft"
|
||||||
|
STATUS_ACTIVE = "active"
|
||||||
|
STATUS_ARCHIVED = "archived"
|
||||||
|
STATUSES = (STATUS_DRAFT, STATUS_ACTIVE, STATUS_ARCHIVED)
|
||||||
|
MAX_INSTRUCTION_CHARS = 1000
|
||||||
|
MAX_LABEL_CHARS = 80
|
||||||
|
MAX_SUMMARY_CHARS = 160
|
||||||
|
KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
|
||||||
|
SEED_PATH = Path(__file__).resolve().parent / "config" / "generation_instructions.seed.json"
|
||||||
|
PUBLIC_FIELDS = (
|
||||||
|
"id",
|
||||||
|
"purpose",
|
||||||
|
"slot",
|
||||||
|
"guideline_key",
|
||||||
|
"label",
|
||||||
|
"summary",
|
||||||
|
"sort_order",
|
||||||
|
"status",
|
||||||
|
"revision",
|
||||||
|
"cloned_from",
|
||||||
|
"is_default",
|
||||||
|
"is_system_seed",
|
||||||
|
"seed_revision",
|
||||||
|
"used_at",
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationPolicyError(PolicyError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
code: str = "invalid_generation_selection",
|
||||||
|
status_code: int = 400,
|
||||||
|
):
|
||||||
|
super().__init__(code, message, status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogError(GenerationPolicyError):
|
||||||
|
def __init__(self, message: str, *, code: str = "generation_policy_invalid", status_code: int = 409):
|
||||||
|
super().__init__(message, code=code, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CompiledPolicy:
|
||||||
|
ids: dict[str, str]
|
||||||
|
keys: dict[str, str]
|
||||||
|
labels: dict[str, str]
|
||||||
|
summaries: dict[str, str]
|
||||||
|
revisions: dict[str, int]
|
||||||
|
instructions: dict[str, str]
|
||||||
|
seed_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_seed_document() -> dict:
|
||||||
|
return json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(raw) -> bool:
|
||||||
|
return bool(int(raw)) if not isinstance(raw, bool) else raw
|
||||||
|
|
||||||
|
|
||||||
|
def _limits() -> tuple[int, int, int]:
|
||||||
|
seed = load_seed_document()
|
||||||
|
return (
|
||||||
|
int(seed.get("max_instruction_chars") or MAX_INSTRUCTION_CHARS),
|
||||||
|
int(seed.get("max_label_chars") or MAX_LABEL_CHARS),
|
||||||
|
int(seed.get("max_summary_chars") or MAX_SUMMARY_CHARS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(raw, field: str, *, max_chars: int, allow_empty: bool = False) -> str:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text and not allow_empty:
|
||||||
|
raise CatalogError(f"{field} darf nicht leer sein.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
if len(text) > max_chars:
|
||||||
|
raise CatalogError(f"{field} darf höchstens {max_chars} Zeichen haben.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_key(raw) -> str:
|
||||||
|
key = (raw or "").strip()
|
||||||
|
if not KEY_RE.match(key):
|
||||||
|
raise CatalogError(
|
||||||
|
"guideline_key muss aus Kleinbuchstaben, Ziffern und Unterstrich bestehen.",
|
||||||
|
code="invalid_generation_guideline",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _row(item: dict | None, *, include_instruction: bool = False) -> dict | None:
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
payload = {field: item.get(field) for field in PUBLIC_FIELDS}
|
||||||
|
payload["is_default"] = _as_bool(item.get("is_default", 0))
|
||||||
|
payload["is_system_seed"] = _as_bool(item.get("is_system_seed", 0))
|
||||||
|
payload["revision"] = int(item.get("revision") or 1)
|
||||||
|
payload["sort_order"] = int(item.get("sort_order") or 0)
|
||||||
|
payload["guideline_key"] = item.get("guideline_key") or ""
|
||||||
|
if include_instruction:
|
||||||
|
payload["instruction"] = item.get("instruction") or ""
|
||||||
|
payload["seed_id"] = item.get("seed_id") or ""
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(guideline_id: str) -> dict | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
return row_to_dict(
|
||||||
|
conn.execute("SELECT * FROM generation_guidelines WHERE id = ?", (guideline_id,)).fetchone()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_guideline(guideline_id: str, *, include_instruction: bool = True) -> dict:
|
||||||
|
row = _fetch(guideline_id)
|
||||||
|
if not row:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
return _row(row, include_instruction=include_instruction)
|
||||||
|
|
||||||
|
|
||||||
|
def list_guidelines(
|
||||||
|
purpose: str = PURPOSE_JOURNAL,
|
||||||
|
*,
|
||||||
|
slot: str | None = None,
|
||||||
|
statuses: tuple[str, ...] | None = None,
|
||||||
|
include_instruction: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
query = "SELECT * FROM generation_guidelines WHERE purpose = ?"
|
||||||
|
params: list = [purpose]
|
||||||
|
if slot:
|
||||||
|
query += " AND slot = ?"
|
||||||
|
params.append(slot)
|
||||||
|
if statuses:
|
||||||
|
query += " AND status IN (" + ",".join("?" for _ in statuses) + ")"
|
||||||
|
params.extend(statuses)
|
||||||
|
query += " ORDER BY slot, sort_order, revision, label"
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = [row_to_dict(row) for row in conn.execute(query, params).fetchall()]
|
||||||
|
return [_row(row, include_instruction=include_instruction) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def overview_payload(purpose: str = PURPOSE_JOURNAL) -> dict:
|
||||||
|
seed = load_seed_document()
|
||||||
|
items = list_guidelines(purpose)
|
||||||
|
slots = {slot: [item for item in items if item["slot"] == slot] for slot in ALLOWED_SLOTS}
|
||||||
|
return {
|
||||||
|
"purpose": purpose,
|
||||||
|
"seed_revision": seed.get("seed_revision") or "",
|
||||||
|
"max_instruction_chars": int(seed.get("max_instruction_chars") or MAX_INSTRUCTION_CHARS),
|
||||||
|
"max_label_chars": int(seed.get("max_label_chars") or MAX_LABEL_CHARS),
|
||||||
|
"max_summary_chars": int(seed.get("max_summary_chars") or MAX_SUMMARY_CHARS),
|
||||||
|
"slots": slots,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def active_user_options(purpose: str = PURPOSE_JOURNAL) -> dict[str, list[dict]]:
|
||||||
|
items = list_guidelines(purpose, statuses=(STATUS_ACTIVE,))
|
||||||
|
return {
|
||||||
|
slot: [
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"key": item["guideline_key"],
|
||||||
|
"label": item["label"],
|
||||||
|
"summary": item["summary"],
|
||||||
|
"revision": item["revision"],
|
||||||
|
"is_default": item["is_default"],
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
if item["slot"] == slot
|
||||||
|
]
|
||||||
|
for slot in USER_SLOTS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_selection_ids(purpose: str = PURPOSE_JOURNAL) -> dict[str, str]:
|
||||||
|
items = list_guidelines(purpose, statuses=(STATUS_ACTIVE,))
|
||||||
|
chosen = {}
|
||||||
|
for slot in USER_SLOTS:
|
||||||
|
slot_items = [item for item in items if item["slot"] == slot]
|
||||||
|
if not slot_items:
|
||||||
|
raise CatalogError(f"{slot} hat keine aktive Ausprägung.")
|
||||||
|
preferred = next((item for item in slot_items if item["is_default"]), slot_items[0])
|
||||||
|
chosen[SLOT_TO_ID_KEY[slot]] = preferred["id"]
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def _require_active_for_run(row: dict, slot: str) -> dict:
|
||||||
|
if not row:
|
||||||
|
raise GenerationPolicyError(f"{slot} ist unbekannt.")
|
||||||
|
if row.get("slot") != slot:
|
||||||
|
raise GenerationPolicyError(f"{slot} verweist auf die falsche Dimension.")
|
||||||
|
if row.get("status") != STATUS_ACTIVE:
|
||||||
|
raise GenerationPolicyError(
|
||||||
|
f"{slot} ist nicht für neue Läufe verfügbar.",
|
||||||
|
code="invalid_generation_selection",
|
||||||
|
)
|
||||||
|
if not (row.get("instruction") or "").strip():
|
||||||
|
raise CatalogError(f"{slot} hat keine Anweisung.")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def validate_selection(raw, *, purpose: str = PURPOSE_JOURNAL) -> dict[str, dict]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise GenerationPolicyError("generation_selection muss die vier Ausprägungs-IDs enthalten.")
|
||||||
|
unknown = sorted(set(raw) - set(SELECTION_KEYS))
|
||||||
|
if unknown:
|
||||||
|
raise GenerationPolicyError("Unbekannte Auswahl: " + ", ".join(unknown))
|
||||||
|
missing = [key for key in SELECTION_KEYS if not (raw.get(key) or "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise GenerationPolicyError("generation_selection ist unvollständig: " + ", ".join(missing))
|
||||||
|
chosen = {}
|
||||||
|
for key in SELECTION_KEYS:
|
||||||
|
slot = ID_KEY_TO_SLOT[key]
|
||||||
|
row = _fetch(str(raw[key]).strip())
|
||||||
|
chosen[slot] = _require_active_for_run(row, slot)
|
||||||
|
if (row.get("purpose") or purpose) != purpose:
|
||||||
|
raise GenerationPolicyError(f"{slot} gehört nicht zu diesem Zweck.")
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def compile_selection(
|
||||||
|
selection: dict[str, str],
|
||||||
|
*,
|
||||||
|
purpose: str = PURPOSE_JOURNAL,
|
||||||
|
) -> CompiledPolicy:
|
||||||
|
chosen = validate_selection(selection, purpose=purpose)
|
||||||
|
instructions = {
|
||||||
|
SLOT_INSTRUCTION_KEYS[slot]: chosen[slot]["instruction"] for slot in USER_SLOTS
|
||||||
|
}
|
||||||
|
revision = ""
|
||||||
|
for row in chosen.values():
|
||||||
|
revision = row.get("seed_revision") or revision
|
||||||
|
return CompiledPolicy(
|
||||||
|
ids={slot: chosen[slot]["id"] for slot in USER_SLOTS},
|
||||||
|
keys={slot: chosen[slot]["guideline_key"] for slot in USER_SLOTS},
|
||||||
|
labels={slot: chosen[slot]["label"] for slot in USER_SLOTS},
|
||||||
|
summaries={slot: chosen[slot].get("summary") or "" for slot in USER_SLOTS},
|
||||||
|
revisions={slot: int(chosen[slot].get("revision") or 1) for slot in USER_SLOTS},
|
||||||
|
instructions=instructions,
|
||||||
|
seed_revision=revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def policy_trace(
|
||||||
|
compiled: CompiledPolicy,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
remembered: bool,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"remembered": remembered,
|
||||||
|
"ids": dict(compiled.ids),
|
||||||
|
"keys": dict(compiled.keys),
|
||||||
|
"labels": dict(compiled.labels),
|
||||||
|
"revisions": dict(compiled.revisions),
|
||||||
|
"seed_revision": compiled.seed_revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def draft_snapshot(compiled: CompiledPolicy, *, prompt: dict | None = None, model: str = "") -> dict:
|
||||||
|
"""Persistable run snapshot. No prompt bodies or instruction texts."""
|
||||||
|
return {
|
||||||
|
"transformation": {
|
||||||
|
"id": compiled.ids["transformation"],
|
||||||
|
"key": compiled.keys["transformation"],
|
||||||
|
"label": compiled.labels["transformation"],
|
||||||
|
"revision": compiled.revisions["transformation"],
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"id": compiled.ids["detail"],
|
||||||
|
"key": compiled.keys["detail"],
|
||||||
|
"label": compiled.labels["detail"],
|
||||||
|
"revision": compiled.revisions["detail"],
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"id": compiled.ids["voice"],
|
||||||
|
"key": compiled.keys["voice"],
|
||||||
|
"label": compiled.labels["voice"],
|
||||||
|
"revision": compiled.revisions["voice"],
|
||||||
|
},
|
||||||
|
"narrative": {
|
||||||
|
"id": compiled.ids["narrative"],
|
||||||
|
"key": compiled.keys["narrative"],
|
||||||
|
"label": compiled.labels["narrative"],
|
||||||
|
"revision": compiled.revisions["narrative"],
|
||||||
|
},
|
||||||
|
"prompt_slug": (prompt or {}).get("slug") or "",
|
||||||
|
"prompt_revision": (prompt or {}).get("seed_revision") or "",
|
||||||
|
"model": model or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_summary(snapshot: dict | None) -> str:
|
||||||
|
data = snapshot or {}
|
||||||
|
labels = [
|
||||||
|
(data.get("transformation") or {}).get("label") or "",
|
||||||
|
(data.get("detail") or {}).get("label") or "",
|
||||||
|
(data.get("voice") or {}).get("label") or "",
|
||||||
|
(data.get("narrative") or {}).get("label") or "",
|
||||||
|
]
|
||||||
|
labels = [item for item in labels if item]
|
||||||
|
if not labels:
|
||||||
|
return ""
|
||||||
|
line = " · ".join(item[:1].upper() + item[1:] if item else item for item in labels)
|
||||||
|
return f"Erzeugt mit:\n{line}"
|
||||||
|
|
||||||
|
|
||||||
|
def assert_journal_prompt_contract(template: str) -> None:
|
||||||
|
keys = CONTEXT_PATTERN.findall(template or "")
|
||||||
|
retired = [key for key in RETIRED_JOURNAL_PLACEHOLDERS if key in keys]
|
||||||
|
if retired:
|
||||||
|
shown = ", ".join(f"{{{{{key}}}}}" for key in retired)
|
||||||
|
raise CatalogError(
|
||||||
|
"Dieser Journal-Prompt verwendet den veralteten Platzhalter "
|
||||||
|
+ shown
|
||||||
|
+ ". Die Unterscheidung zwischen Fließtext- und Stichpunktmodus ist entfallen. "
|
||||||
|
"Bitte den Prompt unter Admin → Prompts aktualisieren.",
|
||||||
|
code="prompt_contract_incompatible",
|
||||||
|
)
|
||||||
|
missing = [key for key in REQUIRED_JOURNAL_PLACEHOLDERS if key not in keys]
|
||||||
|
if missing:
|
||||||
|
shown = ", ".join(f"{{{{{key}}}}}" for key in missing)
|
||||||
|
raise CatalogError(
|
||||||
|
"Dieser Journal-Prompt erfüllt den aktuellen Vertrag nicht. Es fehlen: "
|
||||||
|
+ shown
|
||||||
|
+ ". Bitte den Prompt unter Admin → Prompts aktualisieren.",
|
||||||
|
code="prompt_contract_incompatible",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_template_resolved(rendered: str) -> None:
|
||||||
|
leftover = CONTEXT_PATTERN.findall(rendered or "")
|
||||||
|
if leftover:
|
||||||
|
raise CatalogError(
|
||||||
|
"Promptplatzhalter konnten nicht aufgelöst werden: "
|
||||||
|
+ ", ".join(f"{{{{{key}}}}}" for key in leftover),
|
||||||
|
code="unresolved_placeholder",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_selection(profile_id: str) -> dict[str, str] | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = row_to_dict(
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT transformation_id, detail_id, voice_id, narrative_id, updated
|
||||||
|
FROM journal_generation_selection
|
||||||
|
WHERE profile_id = ?
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
values = {key: row[key] for key in SELECTION_KEYS}
|
||||||
|
values["updated"] = row.get("updated") or ""
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def save_selection(profile_id: str, selection: dict[str, str]) -> dict[str, str]:
|
||||||
|
chosen = validate_selection(selection)
|
||||||
|
checked = {SLOT_TO_ID_KEY[slot]: chosen[slot]["id"] for slot in USER_SLOTS}
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO journal_generation_selection (
|
||||||
|
profile_id, transformation_id, detail_id, voice_id, narrative_id, updated
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(profile_id) DO UPDATE SET
|
||||||
|
transformation_id = excluded.transformation_id,
|
||||||
|
detail_id = excluded.detail_id,
|
||||||
|
voice_id = excluded.voice_id,
|
||||||
|
narrative_id = excluded.narrative_id,
|
||||||
|
updated = datetime('now')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
profile_id,
|
||||||
|
checked["transformation_id"],
|
||||||
|
checked["detail_id"],
|
||||||
|
checked["voice_id"],
|
||||||
|
checked["narrative_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stored = load_selection(profile_id) or {**checked, "updated": ""}
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_selection(profile_id: str) -> dict[str, str]:
|
||||||
|
existing = load_selection(profile_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
return save_selection(profile_id, default_selection_ids())
|
||||||
|
|
||||||
|
|
||||||
|
def settings_payload(profile_id: str) -> dict:
|
||||||
|
stored = get_or_create_selection(profile_id)
|
||||||
|
selection = {key: stored[key] for key in SELECTION_KEYS}
|
||||||
|
return {
|
||||||
|
"selection": selection,
|
||||||
|
"updated": stored.get("updated") or "",
|
||||||
|
"options": active_user_options(),
|
||||||
|
"defaults": default_selection_ids(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_run_selection(
|
||||||
|
profile_id: str,
|
||||||
|
snapshot: dict | None,
|
||||||
|
remember: bool,
|
||||||
|
) -> tuple[dict[str, str], dict]:
|
||||||
|
if snapshot is None:
|
||||||
|
stored = get_or_create_selection(profile_id)
|
||||||
|
values = {key: stored[key] for key in SELECTION_KEYS}
|
||||||
|
validate_selection(values)
|
||||||
|
return values, {"source": "profile", "remembered": False}
|
||||||
|
values = {key: str(snapshot.get(key) or "").strip() for key in SELECTION_KEYS}
|
||||||
|
validate_selection(values)
|
||||||
|
remembered = False
|
||||||
|
if remember:
|
||||||
|
save_selection(profile_id, values)
|
||||||
|
remembered = True
|
||||||
|
return values, {"source": "request", "remembered": remembered}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_guidelines_used(ids: list[str]) -> None:
|
||||||
|
clean = [item for item in ids if item]
|
||||||
|
if not clean:
|
||||||
|
return
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET used_at = COALESCE(used_at, datetime('now'))
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
[(item,) for item in clean],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fields(raw: dict) -> dict:
|
||||||
|
max_instruction, max_label, max_summary = _limits()
|
||||||
|
return {
|
||||||
|
"guideline_key": _clean_key(raw.get("guideline_key") or raw.get("key")),
|
||||||
|
"label": _clean_text(raw.get("label"), "label", max_chars=max_label),
|
||||||
|
"summary": _clean_text(raw.get("summary"), "summary", max_chars=max_summary, allow_empty=True),
|
||||||
|
"instruction": _clean_text(raw.get("instruction"), "instruction", max_chars=max_instruction),
|
||||||
|
"sort_order": int(raw.get("sort_order") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_guideline(purpose: str, slot: str, body: dict) -> dict:
|
||||||
|
if slot not in ALLOWED_SLOTS:
|
||||||
|
raise CatalogError("Unbekannter Slot.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
fields = _write_fields(body)
|
||||||
|
guideline_id = str(uuid.uuid4())
|
||||||
|
with get_db() as conn:
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), -1) AS n FROM generation_guidelines WHERE purpose = ? AND slot = ?",
|
||||||
|
(purpose, slot),
|
||||||
|
).fetchone()["n"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generation_guidelines (
|
||||||
|
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
|
||||||
|
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
|
||||||
|
created, updated
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, 0, 0, '', '', datetime('now'), datetime('now'))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
guideline_id,
|
||||||
|
purpose,
|
||||||
|
slot,
|
||||||
|
fields["guideline_key"],
|
||||||
|
fields["label"],
|
||||||
|
fields["summary"],
|
||||||
|
fields["instruction"],
|
||||||
|
fields["sort_order"] if body.get("sort_order") is not None else int(count) + 1,
|
||||||
|
STATUS_DRAFT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return get_guideline(guideline_id)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_guideline(guideline_id: str) -> dict:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("slot") == LEGACY_SOURCE_MODE_SLOT:
|
||||||
|
raise CatalogError(
|
||||||
|
"Der Quellenmodus ist kein aktiver Slot mehr.",
|
||||||
|
code="invalid_generation_guideline",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generation_guidelines (
|
||||||
|
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
|
||||||
|
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
|
||||||
|
created, updated
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', ?, datetime('now'), datetime('now'))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
new_id,
|
||||||
|
current["purpose"],
|
||||||
|
current["slot"],
|
||||||
|
current["guideline_key"],
|
||||||
|
current["label"],
|
||||||
|
current["summary"],
|
||||||
|
current["instruction"],
|
||||||
|
int(current.get("sort_order") or 0),
|
||||||
|
STATUS_DRAFT,
|
||||||
|
int(current.get("revision") or 1) + 1,
|
||||||
|
current["id"],
|
||||||
|
current.get("seed_revision") or "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return get_guideline(new_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_guideline(guideline_id: str, body: dict) -> dict:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("status") != STATUS_DRAFT:
|
||||||
|
raise CatalogError(
|
||||||
|
"Aktive oder archivierte Ausprägungen sind unveränderlich. Bitte klonen.",
|
||||||
|
code="guideline_immutable",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
fields = _write_fields({**current, **body, "guideline_key": body.get("guideline_key") or current["guideline_key"]})
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET guideline_key = ?, label = ?, summary = ?, instruction = ?, sort_order = ?,
|
||||||
|
updated = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
fields["guideline_key"],
|
||||||
|
fields["label"],
|
||||||
|
fields["summary"],
|
||||||
|
fields["instruction"],
|
||||||
|
fields["sort_order"],
|
||||||
|
guideline_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return get_guideline(guideline_id)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_guideline(guideline_id: str) -> dict:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("status") == STATUS_ARCHIVED:
|
||||||
|
raise CatalogError("Archivierte Ausprägungen können nicht veröffentlicht werden.", code="guideline_archived", status_code=409)
|
||||||
|
max_instruction, max_label, max_summary = _limits()
|
||||||
|
_clean_text(current.get("label"), "label", max_chars=max_label)
|
||||||
|
_clean_text(current.get("summary"), "summary", max_chars=max_summary, allow_empty=True)
|
||||||
|
_clean_text(current.get("instruction"), "instruction", max_chars=max_instruction)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE generation_guidelines SET status = ?, updated = datetime('now') WHERE id = ?",
|
||||||
|
(STATUS_ACTIVE, guideline_id),
|
||||||
|
)
|
||||||
|
return get_guideline(guideline_id, include_instruction=False)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_guideline(guideline_id: str) -> dict:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("status") == STATUS_DRAFT:
|
||||||
|
raise CatalogError("Drafts werden gelöscht, nicht archiviert.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
with get_db() as conn:
|
||||||
|
remaining = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS n FROM generation_guidelines
|
||||||
|
WHERE purpose = ? AND slot = ? AND status = ? AND id != ?
|
||||||
|
""",
|
||||||
|
(current["purpose"], current["slot"], STATUS_ACTIVE, guideline_id),
|
||||||
|
).fetchone()["n"]
|
||||||
|
if int(remaining or 0) < 1:
|
||||||
|
raise CatalogError(
|
||||||
|
"Die letzte aktive Ausprägung dieser Dimension kann nicht archiviert werden.",
|
||||||
|
code="guideline_last_active",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET status = ?, is_default = 0, updated = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(STATUS_ARCHIVED, guideline_id),
|
||||||
|
)
|
||||||
|
return get_guideline(guideline_id, include_instruction=False)
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_guideline(guideline_id: str) -> dict:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("status") != STATUS_ACTIVE:
|
||||||
|
raise CatalogError("Nur aktive Ausprägungen können Standard sein.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
if current["slot"] not in USER_SLOTS:
|
||||||
|
raise CatalogError("Nur die vier Gestaltungsdimensionen haben einen Nutzerstandard.", code="invalid_generation_guideline", status_code=400)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET is_default = CASE WHEN id = ? THEN 1 ELSE 0 END, updated = datetime('now')
|
||||||
|
WHERE purpose = ? AND slot = ?
|
||||||
|
""",
|
||||||
|
(guideline_id, current["purpose"], current["slot"]),
|
||||||
|
)
|
||||||
|
return get_guideline(guideline_id, include_instruction=False)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_guideline(guideline_id: str) -> None:
|
||||||
|
current = _fetch(guideline_id)
|
||||||
|
if not current:
|
||||||
|
raise CatalogError("Ausprägung nicht gefunden.", code="guideline_missing", status_code=404)
|
||||||
|
if current.get("status") != STATUS_DRAFT:
|
||||||
|
raise CatalogError("Nur unverwendete Drafts können gelöscht werden.", code="guideline_immutable", status_code=409)
|
||||||
|
if current.get("used_at"):
|
||||||
|
raise CatalogError("Verwendete Drafts können nicht gelöscht werden.", code="guideline_in_use", status_code=409)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("DELETE FROM generation_guidelines WHERE id = ?", (guideline_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def reset_seed_drafts(purpose: str = PURPOSE_JOURNAL) -> dict:
|
||||||
|
"""Create new drafts from the current seed. Never overwrite published variants."""
|
||||||
|
seed = load_seed_document()
|
||||||
|
created = []
|
||||||
|
slots = seed.get("slots") or {}
|
||||||
|
for slot in ALLOWED_SLOTS:
|
||||||
|
for index, item in enumerate((slots.get(slot) or {}).get("variants") or []):
|
||||||
|
created.append(
|
||||||
|
create_guideline(
|
||||||
|
purpose,
|
||||||
|
slot,
|
||||||
|
{
|
||||||
|
"guideline_key": item.get("guideline_key") or item.get("variant_key"),
|
||||||
|
"label": item.get("label") or "",
|
||||||
|
"summary": item.get("summary") or "",
|
||||||
|
"instruction": item.get("instruction") or "",
|
||||||
|
"sort_order": index,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
overview = overview_payload(purpose)
|
||||||
|
overview["reset_drafts"] = created
|
||||||
|
return overview
|
||||||
|
|
||||||
|
|
||||||
|
def preview_selection(selection: dict[str, str], *, purpose: str = PURPOSE_JOURNAL) -> dict:
|
||||||
|
from engine import load_active_prompt, preview_prompt
|
||||||
|
|
||||||
|
compiled = compile_selection(selection, purpose=purpose)
|
||||||
|
prompt = load_active_prompt("mvp.journal_generate")
|
||||||
|
assert_journal_prompt_contract(prompt.get("template") or "")
|
||||||
|
rendered = preview_prompt(
|
||||||
|
prompt,
|
||||||
|
{
|
||||||
|
**compiled.instructions,
|
||||||
|
"writing_profile": "(nicht enthalten)",
|
||||||
|
"style_examples": "(nicht enthalten)",
|
||||||
|
"reconstruction": "(nicht enthalten)",
|
||||||
|
"existing_text": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert_template_resolved(rendered.get("rendered") or "")
|
||||||
|
return {
|
||||||
|
**compiled.instructions,
|
||||||
|
"rendered": rendered.get("rendered") or "",
|
||||||
|
"prompt_slug": prompt.get("slug") or "",
|
||||||
|
"prompt_revision": prompt.get("seed_revision") or "",
|
||||||
|
"selection": {
|
||||||
|
"ids": dict(compiled.ids),
|
||||||
|
"keys": dict(compiled.keys),
|
||||||
|
"labels": dict(compiled.labels),
|
||||||
|
"revisions": dict(compiled.revisions),
|
||||||
|
},
|
||||||
|
"seed_revision": compiled.seed_revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_rows(seed: dict, purpose: str) -> list[tuple]:
|
||||||
|
revision = seed.get("seed_revision") or ""
|
||||||
|
rows = []
|
||||||
|
slots = seed.get("slots") or {}
|
||||||
|
for slot in ALLOWED_SLOTS:
|
||||||
|
variants = (slots.get(slot) or {}).get("variants") or []
|
||||||
|
for index, item in enumerate(variants):
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
item.get("id") or str(uuid.uuid4()),
|
||||||
|
purpose,
|
||||||
|
slot,
|
||||||
|
item.get("guideline_key") or item.get("variant_key"),
|
||||||
|
item.get("label") or "",
|
||||||
|
item.get("summary") or "",
|
||||||
|
item.get("instruction") or "",
|
||||||
|
index,
|
||||||
|
STATUS_ACTIVE,
|
||||||
|
1,
|
||||||
|
1 if item.get("is_default") else 0,
|
||||||
|
1,
|
||||||
|
item.get("id") or "",
|
||||||
|
revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def seed_generation_instructions(conn) -> None:
|
||||||
|
"""Insert missing seed guidelines. Never overwrite published or admin-created rows."""
|
||||||
|
seed = load_seed_document()
|
||||||
|
purpose = seed.get("purpose") or PURPOSE_JOURNAL
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET status = ?, is_default = 0, updated = datetime('now')
|
||||||
|
WHERE purpose = ? AND slot = ? AND status != ?
|
||||||
|
""",
|
||||||
|
(STATUS_ARCHIVED, purpose, LEGACY_SOURCE_MODE_SLOT, STATUS_ARCHIVED),
|
||||||
|
)
|
||||||
|
existing = {
|
||||||
|
row["id"]
|
||||||
|
for row in conn.execute("SELECT id FROM generation_guidelines WHERE purpose = ?", (purpose,)).fetchall()
|
||||||
|
}
|
||||||
|
existing_seed_ids = {
|
||||||
|
row["seed_id"]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT seed_id FROM generation_guidelines WHERE purpose = ? AND seed_id != ''",
|
||||||
|
(purpose,),
|
||||||
|
).fetchall()
|
||||||
|
if row["seed_id"]
|
||||||
|
}
|
||||||
|
for row in _seed_rows(seed, purpose):
|
||||||
|
row_id = row[0]
|
||||||
|
seed_id = row[12]
|
||||||
|
if row_id in existing or seed_id in existing_seed_ids:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generation_guidelines (
|
||||||
|
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
|
||||||
|
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
|
||||||
|
created, updated
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||||
|
""",
|
||||||
|
row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_missing_settings(conn) -> None:
|
||||||
|
seed_generation_instructions(conn)
|
||||||
|
defaults = {}
|
||||||
|
for slot, key in SLOT_TO_ID_KEY.items():
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM generation_guidelines
|
||||||
|
WHERE purpose = ? AND slot = ? AND status = ? AND is_default = 1
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(PURPOSE_JOURNAL, slot, STATUS_ACTIVE),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM generation_guidelines
|
||||||
|
WHERE purpose = ? AND slot = ? AND status = ?
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(PURPOSE_JOURNAL, slot, STATUS_ACTIVE),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
defaults[key] = row["id"]
|
||||||
|
if len(defaults) != 4:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO journal_generation_selection (
|
||||||
|
profile_id, transformation_id, detail_id, voice_id, narrative_id, updated
|
||||||
|
)
|
||||||
|
SELECT id, ?, ?, ?, ?, datetime('now') FROM profiles
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
defaults["transformation_id"],
|
||||||
|
defaults["detail_id"],
|
||||||
|
defaults["voice_id"],
|
||||||
|
defaults["narrative_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -89,7 +89,19 @@ def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None:
|
||||||
def _decode_draft(conn, row: dict | None) -> dict | None:
|
def _decode_draft(conn, row: dict | None) -> dict | None:
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
|
payload = _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
|
||||||
|
raw = payload.get("generation_snapshot") or "{}"
|
||||||
|
try:
|
||||||
|
snapshot = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
snapshot = {}
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
snapshot = {}
|
||||||
|
payload["generation_snapshot"] = snapshot
|
||||||
|
from journal_generation_policy import snapshot_summary
|
||||||
|
|
||||||
|
payload["generation_summary"] = snapshot_summary(snapshot)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _decode_version(conn, row: dict | None) -> dict | None:
|
def _decode_version(conn, row: dict | None) -> dict | None:
|
||||||
|
|
@ -219,6 +231,7 @@ def insert_draft(
|
||||||
body: str,
|
body: str,
|
||||||
source_conversation_ids: list[str],
|
source_conversation_ids: list[str],
|
||||||
source_message_ids: list[str],
|
source_message_ids: list[str],
|
||||||
|
generation_snapshot: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
get_day(profile_id, journal_day_id)
|
get_day(profile_id, journal_day_id)
|
||||||
draft_id = str(uuid.uuid4())
|
draft_id = str(uuid.uuid4())
|
||||||
|
|
@ -236,8 +249,8 @@ def insert_draft(
|
||||||
"""
|
"""
|
||||||
INSERT INTO journal_drafts
|
INSERT INTO journal_drafts
|
||||||
(id, profile_id, journal_day_id, title, body,
|
(id, profile_id, journal_day_id, title, body,
|
||||||
source_conversation_ids, source_message_ids, as_of)
|
source_conversation_ids, source_message_ids, as_of, generation_snapshot)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
draft_id,
|
draft_id,
|
||||||
|
|
@ -248,6 +261,7 @@ def insert_draft(
|
||||||
"[]",
|
"[]",
|
||||||
"[]",
|
"[]",
|
||||||
as_of,
|
as_of,
|
||||||
|
json.dumps(generation_snapshot or {}, ensure_ascii=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_insert_source_refs(
|
_insert_source_refs(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from db import init_db
|
from db import init_db
|
||||||
import data_layer_dialogue # noqa: F401
|
import data_layer_dialogue # noqa: F401
|
||||||
from routers import admin, auth, dialogue, journal, placeholders, prompts, subscription, users
|
from routers import admin, auth, dialogue, generation_instructions, journal, placeholders, prompts, subscription, users
|
||||||
from version import APP_VERSION
|
from version import APP_VERSION
|
||||||
|
|
||||||
app = FastAPI(title="Kanshō", version=APP_VERSION)
|
app = FastAPI(title="Kanshō", version=APP_VERSION)
|
||||||
|
|
@ -28,6 +28,7 @@ app.include_router(prompts.router)
|
||||||
app.include_router(placeholders.router)
|
app.include_router(placeholders.router)
|
||||||
app.include_router(subscription.router)
|
app.include_router(subscription.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(generation_instructions.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|
|
||||||
|
|
@ -34,18 +34,34 @@ register(
|
||||||
)
|
)
|
||||||
register(
|
register(
|
||||||
Placeholder(
|
Placeholder(
|
||||||
key="editorial_mode",
|
key="transformation_instructions",
|
||||||
description="Lokaler redaktioneller Journalmodus. Kein zweiter Modellaufruf.",
|
description="Kompilierte Bearbeitungsstärke. Semantische Ausgabeeinstellung, keine Modelltemperatur.",
|
||||||
data_class="C",
|
data_class="C",
|
||||||
resolver=_from_ctx("editorial_mode"),
|
resolver=_from_ctx("transformation_instructions"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
register(
|
register(
|
||||||
Placeholder(
|
Placeholder(
|
||||||
key="editorial_instructions",
|
key="detail_instructions",
|
||||||
description="Modusabhängige Journalinstruktion. Kein allgemeiner Provenienzvertrag.",
|
description="Kompilierte Detailerhaltung. Semantische Ausgabeeinstellung, keine Modelltemperatur.",
|
||||||
data_class="C",
|
data_class="C",
|
||||||
resolver=_from_ctx("editorial_instructions"),
|
resolver=_from_ctx("detail_instructions"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
Placeholder(
|
||||||
|
key="voice_instructions",
|
||||||
|
description="Kompilierte Stimmanweisung aus dem Generation-Policy-Compiler.",
|
||||||
|
data_class="C",
|
||||||
|
resolver=_from_ctx("voice_instructions"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
Placeholder(
|
||||||
|
key="narrative_instructions",
|
||||||
|
description="Kompilierte Erzählgestaltung aus dem Generation-Policy-Compiler.",
|
||||||
|
data_class="C",
|
||||||
|
resolver=_from_ctx("narrative_instructions"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
register(
|
register(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Local privacy gateway. Personal generative egress is not allowed to skip this layer."""
|
"""Local privacy gateway. Personal generative egress is not allowed to skip this layer."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import contextvars
|
import contextvars
|
||||||
|
|
@ -9,7 +9,7 @@ import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from entity_detect import DetectError, detect_personal_egress, reset_detect_test_hooks, user_detect_message
|
from entity_detect import DetectError, detect_personal_egress, reset_detect_test_hooks, user_detect_message
|
||||||
from identity_store import KINSHIP, is_maskable_label
|
from identity_store import KINSHIP, is_maskable_label, mapping_spellings
|
||||||
from journal_reconstruct import claim_texts, fake_reconstruction, is_dialogue_role_line
|
from journal_reconstruct import claim_texts, fake_reconstruction, is_dialogue_role_line
|
||||||
from prompt_budget import (
|
from prompt_budget import (
|
||||||
ERROR_PROVIDER_CONTEXT_LENGTH,
|
ERROR_PROVIDER_CONTEXT_LENGTH,
|
||||||
|
|
@ -36,6 +36,8 @@ COMPACT_DIAGNOSTIC_KEYS = (
|
||||||
"provider",
|
"provider",
|
||||||
"purpose",
|
"purpose",
|
||||||
"prompt_slug",
|
"prompt_slug",
|
||||||
|
"prompt_revision",
|
||||||
|
"generate_ms",
|
||||||
"effective_context_window",
|
"effective_context_window",
|
||||||
"estimated_input_tokens",
|
"estimated_input_tokens",
|
||||||
"prompt_tokens",
|
"prompt_tokens",
|
||||||
|
|
@ -59,6 +61,11 @@ COMPACT_DIAGNOSTIC_KEYS = (
|
||||||
"pre_egress_validation",
|
"pre_egress_validation",
|
||||||
"response_validation",
|
"response_validation",
|
||||||
"response_validation_retry",
|
"response_validation_retry",
|
||||||
|
"response_normalization",
|
||||||
|
"normalized_cleartext_count",
|
||||||
|
"generate_calls",
|
||||||
|
"model_text_accepted",
|
||||||
|
"provenance_decision",
|
||||||
"full_detection_coverage",
|
"full_detection_coverage",
|
||||||
"detect_provider",
|
"detect_provider",
|
||||||
"detect_model",
|
"detect_model",
|
||||||
|
|
@ -124,10 +131,18 @@ class ActiveReplacement:
|
||||||
occurrence_count: int
|
occurrence_count: int
|
||||||
local_label: str
|
local_label: str
|
||||||
demask_label: str = ""
|
demask_label: str = ""
|
||||||
|
source: str = ""
|
||||||
|
span_based: bool = False
|
||||||
|
labels: tuple[str, ...] = ()
|
||||||
|
|
||||||
def restore_label(self) -> str:
|
def restore_label(self) -> str:
|
||||||
return self.demask_label or self.local_label
|
return self.demask_label or self.local_label
|
||||||
|
|
||||||
|
def all_labels(self) -> tuple[str, ...]:
|
||||||
|
if self.labels:
|
||||||
|
return self.labels
|
||||||
|
return (self.local_label,) if self.local_label else ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MaskingManifest:
|
class MaskingManifest:
|
||||||
|
|
@ -380,6 +395,53 @@ def _active_replacements(text: str, mappings: list[dict]) -> tuple[ActiveReplace
|
||||||
return tuple(found)
|
return tuple(found)
|
||||||
|
|
||||||
|
|
||||||
|
_SPAN_TYPE_PRIORITY = {"PERSON": 0, "PROJECT": 1, "ORG": 2, "PLACE": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_of(token: str) -> str:
|
||||||
|
raw = (token or "").strip()
|
||||||
|
if raw.startswith("[[") and raw.endswith("]]"):
|
||||||
|
return f"[[{_canonical_token(raw)}]]"
|
||||||
|
return f"[[{_canonical_token(raw)}]]"
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_mapping_span(text: str, item: dict) -> tuple[int, int] | None:
|
||||||
|
label = (item.get("local_label") or "").strip()
|
||||||
|
if not label or "start" not in item or "end" not in item:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
start = int(item.get("start"))
|
||||||
|
end = int(item.get("end"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if start < 0 or end > len(text or "") or start >= end:
|
||||||
|
return None
|
||||||
|
slice_text = (text or "")[start:end]
|
||||||
|
if slice_text == label or slice_text.casefold() == label.casefold():
|
||||||
|
return start, end
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_span_mappings(items: list[dict]) -> list[dict]:
|
||||||
|
ordered = sorted(
|
||||||
|
items,
|
||||||
|
key=lambda row: (
|
||||||
|
-(int(row["end"]) - int(row["start"])),
|
||||||
|
int(row["start"]),
|
||||||
|
_SPAN_TYPE_PRIORITY.get((row.get("entity_type") or "").upper(), 9),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
kept: list[dict] = []
|
||||||
|
occupied: list[tuple[int, int]] = []
|
||||||
|
for item in ordered:
|
||||||
|
start, end = int(item["start"]), int(item["end"])
|
||||||
|
if any(start < right and end > left for left, right in occupied):
|
||||||
|
continue
|
||||||
|
kept.append(item)
|
||||||
|
occupied.append((start, end))
|
||||||
|
return sorted(kept, key=lambda row: int(row["start"]))
|
||||||
|
|
||||||
|
|
||||||
def _mask_body(text: str, mappings: list[dict]) -> str:
|
def _mask_body(text: str, mappings: list[dict]) -> str:
|
||||||
masked = text
|
masked = text
|
||||||
for item in sorted(mappings, key=lambda row: len(row.get("local_label") or ""), reverse=True):
|
for item in sorted(mappings, key=lambda row: len(row.get("local_label") or ""), reverse=True):
|
||||||
|
|
@ -387,7 +449,7 @@ def _mask_body(text: str, mappings: list[dict]) -> str:
|
||||||
token = (item.get("token") or "").strip()
|
token = (item.get("token") or "").strip()
|
||||||
if not label or not token or not is_maskable_label(label):
|
if not label or not token or not is_maskable_label(label):
|
||||||
continue
|
continue
|
||||||
placeholder = token if token.startswith("[[") else f"[[{token}]]"
|
placeholder = _placeholder_of(token)
|
||||||
pattern = _label_pattern(label)
|
pattern = _label_pattern(label)
|
||||||
|
|
||||||
def repl(match: re.Match, *, _token=token, _ph=placeholder) -> str:
|
def repl(match: re.Match, *, _token=token, _ph=placeholder) -> str:
|
||||||
|
|
@ -413,12 +475,6 @@ def _mask(text: str, mappings: list[dict], *, personal_lines_only: bool = False)
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
IDENTITY_LEAK_RETRY = (
|
|
||||||
"Korrektur: Keine Klartext-Identität. Kopiere die im Auftragstext bereits "
|
|
||||||
"vorhandenen Platzhalter zeichengetreu. Keine Klarnamen, keine Klarorte, "
|
|
||||||
"keine neuen Platzhalter, keine Auslassungspunkte."
|
|
||||||
)
|
|
||||||
|
|
||||||
PLACEHOLDER_RE = re.compile(r"\[\[\s*([^\[\]]+?)\s*\]\]")
|
PLACEHOLDER_RE = re.compile(r"\[\[\s*([^\[\]]+?)\s*\]\]")
|
||||||
GENERIC_PLACEHOLDER_INNER = frozenset({"…", "...", "..", "...."})
|
GENERIC_PLACEHOLDER_INNER = frozenset({"…", "...", "..", "...."})
|
||||||
|
|
||||||
|
|
@ -468,15 +524,74 @@ def _restore_map_from_mappings(mappings: list[dict] | None) -> dict[str, str]:
|
||||||
|
|
||||||
|
|
||||||
def mask_prompt(rendered: str, mappings: list[dict], purpose: str) -> MaskingManifest:
|
def mask_prompt(rendered: str, mappings: list[dict], purpose: str) -> MaskingManifest:
|
||||||
"""Request-scoped mask result. Active mappings are those actually replaced."""
|
"""Request-scoped mask result. Detected spans replace only those offsets.
|
||||||
masked = rendered or ""
|
|
||||||
replacements: list[ActiveReplacement] = []
|
Request-local detections never globally replace a label. Confirmed-registry
|
||||||
for item in sorted(mappings or [], key=lambda row: len(row.get("local_label") or ""), reverse=True):
|
rows without spans remain a separate identity-mention safety net.
|
||||||
|
"""
|
||||||
|
text = rendered or ""
|
||||||
|
replacements: dict[str, ActiveReplacement] = {}
|
||||||
|
|
||||||
|
def _merge_labels(existing: tuple[str, ...], item: dict) -> tuple[str, ...]:
|
||||||
|
labels = list(existing)
|
||||||
|
seen = {label.casefold() for label in labels}
|
||||||
|
for label in mapping_spellings(item):
|
||||||
|
key = label.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
labels.append(label)
|
||||||
|
return tuple(labels)
|
||||||
|
|
||||||
|
def remember(item: dict, count: int, *, span_based: bool) -> None:
|
||||||
|
if count <= 0:
|
||||||
|
return
|
||||||
|
token = _canonical_token(item.get("token") or "")
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
existing = replacements.get(token)
|
||||||
|
replacements[token] = ActiveReplacement(
|
||||||
|
token=token,
|
||||||
|
entity_type=_entity_type_of(item),
|
||||||
|
occurrence_count=(existing.occurrence_count if existing else 0) + count,
|
||||||
|
local_label=(item.get("local_label") or (existing.local_label if existing else "")).strip(),
|
||||||
|
demask_label=(
|
||||||
|
item.get("demask_label")
|
||||||
|
or item.get("canonical_label")
|
||||||
|
or (existing.demask_label if existing else "")
|
||||||
|
or item.get("local_label")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
source=(item.get("source") or (existing.source if existing else "")),
|
||||||
|
span_based=bool((existing.span_based if existing else False) or span_based),
|
||||||
|
labels=_merge_labels(existing.labels if existing else (), item),
|
||||||
|
)
|
||||||
|
|
||||||
|
span_rows: list[dict] = []
|
||||||
|
label_rows: list[dict] = []
|
||||||
|
for item in mappings or []:
|
||||||
label = (item.get("local_label") or "").strip()
|
label = (item.get("local_label") or "").strip()
|
||||||
token = (item.get("token") or "").strip()
|
token = (item.get("token") or "").strip()
|
||||||
if not label or not token or not is_maskable_label(label):
|
if not label or not token or not is_maskable_label(label):
|
||||||
continue
|
continue
|
||||||
placeholder = token if token.startswith("[[") else f"[[{_canonical_token(token)}]]"
|
has_span_fields = item.get("start") is not None and item.get("end") is not None
|
||||||
|
grounded = _ground_mapping_span(text, item) if has_span_fields else None
|
||||||
|
if grounded is not None:
|
||||||
|
span_rows.append({**item, "start": grounded[0], "end": grounded[1]})
|
||||||
|
elif not has_span_fields:
|
||||||
|
label_rows.append(item)
|
||||||
|
|
||||||
|
applied = _resolve_span_mappings(span_rows)
|
||||||
|
masked = text
|
||||||
|
for item in sorted(applied, key=lambda row: int(row["start"]), reverse=True):
|
||||||
|
start, end = int(item["start"]), int(item["end"])
|
||||||
|
masked = masked[:start] + _placeholder_of(item.get("token") or "") + masked[end:]
|
||||||
|
remember(item, 1, span_based=True)
|
||||||
|
|
||||||
|
for item in sorted(label_rows, key=lambda row: len(row.get("local_label") or ""), reverse=True):
|
||||||
|
label = (item.get("local_label") or "").strip()
|
||||||
|
token = (item.get("token") or "").strip()
|
||||||
|
placeholder = _placeholder_of(token)
|
||||||
pattern = _label_pattern(label)
|
pattern = _label_pattern(label)
|
||||||
current = masked
|
current = masked
|
||||||
count = 0
|
count = 0
|
||||||
|
|
@ -489,23 +604,22 @@ def mask_prompt(rendered: str, mappings: list[dict], purpose: str) -> MaskingMan
|
||||||
return _ph
|
return _ph
|
||||||
|
|
||||||
masked = pattern.sub(repl, current)
|
masked = pattern.sub(repl, current)
|
||||||
if count <= 0:
|
remember(item, count, span_based=False)
|
||||||
continue
|
|
||||||
replacements.append(
|
|
||||||
ActiveReplacement(
|
|
||||||
token=_canonical_token(token),
|
|
||||||
entity_type=_entity_type_of(item),
|
|
||||||
occurrence_count=count,
|
|
||||||
local_label=label,
|
|
||||||
demask_label=(item.get("demask_label") or item.get("canonical_label") or label),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if purpose == "dialogue_turn":
|
if purpose == "dialogue_turn":
|
||||||
masked = bind_user_lines(masked)
|
masked = bind_user_lines(masked)
|
||||||
|
for token, item in list(replacements.items()):
|
||||||
|
labels = item.labels
|
||||||
|
for row in mappings or []:
|
||||||
|
if _canonical_token(row.get("token") or "") != token:
|
||||||
|
continue
|
||||||
|
labels = _merge_labels(labels, row)
|
||||||
|
if labels != item.labels:
|
||||||
|
replacements[token] = replace(item, labels=labels)
|
||||||
return MaskingManifest(
|
return MaskingManifest(
|
||||||
masked_text=masked,
|
masked_text=masked,
|
||||||
available_mapping_count=len(mappings or []),
|
available_mapping_count=len(mappings or []),
|
||||||
replacements=tuple(replacements),
|
replacements=tuple(replacements.values()),
|
||||||
restore_by_token=_restore_map_from_mappings(mappings),
|
restore_by_token=_restore_map_from_mappings(mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -516,14 +630,30 @@ def mask_for_egress(rendered: str, mappings: list[dict], purpose: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def validate_pre_egress(masked_text: str, manifest: MaskingManifest) -> None:
|
def validate_pre_egress(masked_text: str, manifest: MaskingManifest) -> None:
|
||||||
"""Fail closed before the provider if an active identity remains as plaintext."""
|
"""Fail closed before the provider if a confirmed identity remains as plaintext.
|
||||||
|
|
||||||
|
Request-local span replacements may leave the same wording unmasked where it
|
||||||
|
was not detected as identity. That remaining wording is not an egress leak.
|
||||||
|
"""
|
||||||
leaked_tokens: list[str] = []
|
leaked_tokens: list[str] = []
|
||||||
leaked_types: list[str] = []
|
leaked_types: list[str] = []
|
||||||
|
body = masked_text or ""
|
||||||
for item in manifest.replacements:
|
for item in manifest.replacements:
|
||||||
for match in _label_pattern(item.local_label).finditer(masked_text or ""):
|
if item.span_based and item.source != "confirmed_registry":
|
||||||
if _is_identity_mention(masked_text, match.start(), match.end(), item.token):
|
placeholder = f"[[{canonical_token(item.token)}]]"
|
||||||
|
if placeholder not in body:
|
||||||
leaked_tokens.append(item.token)
|
leaked_tokens.append(item.token)
|
||||||
leaked_types.append(item.entity_type)
|
leaked_types.append(item.entity_type)
|
||||||
|
continue
|
||||||
|
leaked = False
|
||||||
|
for label in item.all_labels():
|
||||||
|
for match in _label_pattern(label).finditer(body):
|
||||||
|
if _is_identity_mention(body, match.start(), match.end(), item.token):
|
||||||
|
leaked_tokens.append(item.token)
|
||||||
|
leaked_types.append(item.entity_type)
|
||||||
|
leaked = True
|
||||||
|
break
|
||||||
|
if leaked:
|
||||||
break
|
break
|
||||||
if leaked_tokens:
|
if leaked_tokens:
|
||||||
raise PrivacyGatewayError(
|
raise PrivacyGatewayError(
|
||||||
|
|
@ -553,39 +683,63 @@ def _demask(text: str, manifest: MaskingManifest | None) -> str:
|
||||||
return PLACEHOLDER_RE.sub(repl, result)
|
return PLACEHOLDER_RE.sub(repl, result)
|
||||||
|
|
||||||
|
|
||||||
def _validate_response(content: str, manifest: MaskingManifest | None = None) -> str:
|
def _normalize_active_cleartext(text: str, manifest: MaskingManifest | None) -> tuple[str, int]:
|
||||||
|
"""Map active identity cleartext back to the request token. Local only, no retry."""
|
||||||
|
if not manifest or not manifest.replacements:
|
||||||
|
return text or "", 0
|
||||||
|
hits: list[tuple[int, int, str]] = []
|
||||||
|
occupied: list[tuple[int, int]] = []
|
||||||
|
labeled: list[tuple[ActiveReplacement, str]] = []
|
||||||
|
for item in manifest.replacements:
|
||||||
|
for label in item.all_labels():
|
||||||
|
if label:
|
||||||
|
labeled.append((item, label))
|
||||||
|
items = sorted(labeled, key=lambda row: len(row[1]), reverse=True)
|
||||||
|
for item, label in items:
|
||||||
|
placeholder = _placeholder_of(item.token)
|
||||||
|
for match in _label_pattern(label).finditer(text or ""):
|
||||||
|
if not _is_identity_mention(text, match.start(), match.end(), item.token):
|
||||||
|
continue
|
||||||
|
if any(match.start() < right and match.end() > left for left, right in occupied):
|
||||||
|
continue
|
||||||
|
occupied.append((match.start(), match.end()))
|
||||||
|
hits.append((match.start(), match.end(), placeholder))
|
||||||
|
result = text or ""
|
||||||
|
for start, end, placeholder in sorted(hits, key=lambda row: row[0], reverse=True):
|
||||||
|
result = result[:start] + placeholder + result[end:]
|
||||||
|
return result, len(hits)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_response(content: str, manifest: MaskingManifest | None = None) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Integrity check after the model reply. Does not undo egress and does not retry."""
|
||||||
text = (content or "").strip()
|
text = (content or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise PrivacyGatewayError("empty_provider_response", "Der Provider lieferte keine Antwort.")
|
raise PrivacyGatewayError("empty_provider_response", "Der Provider lieferte keine Antwort.")
|
||||||
leaked_tokens: list[str] = []
|
normalized, count = _normalize_active_cleartext(text, manifest)
|
||||||
leaked_types: list[str] = []
|
meta = {
|
||||||
for item in manifest.replacements if manifest else ():
|
"response_validation": "ok",
|
||||||
for match in _label_pattern(item.local_label).finditer(text):
|
"response_normalization": "active_cleartext_normalized" if count else "none",
|
||||||
if _is_identity_mention(text, match.start(), match.end(), item.token):
|
"normalized_cleartext_count": count,
|
||||||
leaked_tokens.append(item.token)
|
}
|
||||||
leaked_types.append(item.entity_type)
|
return normalized, meta
|
||||||
break
|
|
||||||
if leaked_tokens:
|
|
||||||
raise PrivacyGatewayError(
|
def _validate_response(content: str, manifest: MaskingManifest | None = None) -> str:
|
||||||
"response_validation_failed",
|
"""Compatibility wrapper: normalize active cleartext, then return tokenized text."""
|
||||||
"Antwort enthielt Klartext-Identität vor der Demaskierung.",
|
text, _meta = _process_response(content, manifest)
|
||||||
diagnostics={
|
|
||||||
"response_validation": "failed",
|
|
||||||
"leak_tokens": leaked_tokens,
|
|
||||||
"leak_entity_types": leaked_types,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _fake_journal_from_sources(rendered: str) -> str:
|
def _fake_journal_from_sources(rendered: str) -> str:
|
||||||
"""Deterministic fake body from labeled CURRENT_DAY_SOURCES. Not a quality claim."""
|
"""Deterministic fake body from labeled CURRENT_DAY_SOURCES. Not a quality claim."""
|
||||||
text = rendered or ""
|
text = rendered or ""
|
||||||
marker = "CURRENT_DAY_SOURCES"
|
marker = "\nCURRENT_DAY_SOURCES\n"
|
||||||
if marker not in text:
|
if marker not in text:
|
||||||
return ""
|
marker = "CURRENT_DAY_SOURCES"
|
||||||
|
if marker not in text:
|
||||||
|
return ""
|
||||||
after = text.split(marker, 1)[1]
|
after = text.split(marker, 1)[1]
|
||||||
for stop in ("WRITING_PROFILE", "STYLE_EXAMPLES", "EXISTING_TEXT", "EDITORIAL_MODE"):
|
for stop in ("WRITING_PROFILE", "STYLE_EXAMPLES", "EXISTING_TEXT", "EDITORIAL_MODE", "AUSGABE", "DATENSCHUTZ"):
|
||||||
if f"\n{stop}" in after:
|
if f"\n{stop}" in after:
|
||||||
after = after.split(f"\n{stop}", 1)[0]
|
after = after.split(f"\n{stop}", 1)[0]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
@ -602,6 +756,20 @@ def _fake_journal_from_sources(rendered: str) -> str:
|
||||||
if current:
|
if current:
|
||||||
parts.append(" ".join(current).strip())
|
parts.append(" ".join(current).strip())
|
||||||
return " ".join(part for part in parts if part).strip()
|
return " ".join(part for part in parts if part).strip()
|
||||||
|
parts: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
for line in after.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("[u") and stripped.endswith("]"):
|
||||||
|
if current:
|
||||||
|
parts.append(" ".join(current).strip())
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
if stripped and not stripped.startswith("(") and "einzige Tatsachen" not in stripped:
|
||||||
|
current.append(stripped)
|
||||||
|
if current:
|
||||||
|
parts.append(" ".join(current).strip())
|
||||||
|
return " ".join(part for part in parts if part).strip()
|
||||||
|
|
||||||
|
|
||||||
def _fake_complete(purpose: str, rendered: str) -> str:
|
def _fake_complete(purpose: str, rendered: str) -> str:
|
||||||
|
|
@ -793,12 +961,14 @@ def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
diagnostics["provider"] = result.provider
|
diagnostics["provider"] = result.provider
|
||||||
diagnostics["purpose"] = request.purpose
|
diagnostics["purpose"] = request.purpose
|
||||||
diagnostics["prompt_slug"] = (request.payload or {}).get("prompt_slug")
|
diagnostics["prompt_slug"] = (request.payload or {}).get("prompt_slug")
|
||||||
|
diagnostics["prompt_revision"] = (request.payload or {}).get("prompt_revision")
|
||||||
diagnostics["model"] = config.model if config else None
|
diagnostics["model"] = config.model if config else None
|
||||||
request_trace = {
|
request_trace = {
|
||||||
"purpose": request.purpose,
|
"purpose": request.purpose,
|
||||||
"layer": layer,
|
"layer": layer,
|
||||||
"data_class": request.data_class,
|
"data_class": request.data_class,
|
||||||
"prompt_slug": (request.payload or {}).get("prompt_slug"),
|
"prompt_slug": (request.payload or {}).get("prompt_slug"),
|
||||||
|
"prompt_revision": (request.payload or {}).get("prompt_revision"),
|
||||||
"rendered": rendered,
|
"rendered": rendered,
|
||||||
"masked": masked,
|
"masked": masked,
|
||||||
"mask_input": rendered,
|
"mask_input": rendered,
|
||||||
|
|
@ -860,14 +1030,26 @@ def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
leak_entity_types=leak.get("leak_entity_types"),
|
leak_entity_types=leak.get("leak_entity_types"),
|
||||||
)
|
)
|
||||||
failed = merge_usage(
|
failed = merge_usage(
|
||||||
{**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error"},
|
{**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error", "generate_calls": 0},
|
||||||
None,
|
None,
|
||||||
config.model if config else None,
|
config.model if config else None,
|
||||||
)
|
)
|
||||||
|
request_trace["abort_reason"] = exc.code
|
||||||
|
request_trace["generate_calls"] = 0
|
||||||
|
request_trace["model_text_accepted"] = False
|
||||||
|
request_trace["budget"] = compact_diagnostics(failed)
|
||||||
last_compact = compact_diagnostics(failed)
|
last_compact = compact_diagnostics(failed)
|
||||||
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
|
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
|
||||||
_raise_with_log(exc, events, purpose=request.purpose)
|
_raise_with_log(
|
||||||
|
exc,
|
||||||
|
events,
|
||||||
|
purpose=request.purpose,
|
||||||
|
extra={**failed, "trace": public_trace(request_trace)},
|
||||||
|
)
|
||||||
usages: list[dict] = []
|
usages: list[dict] = []
|
||||||
|
raw = ""
|
||||||
|
validated = ""
|
||||||
|
chat = None
|
||||||
try:
|
try:
|
||||||
_log_event(
|
_log_event(
|
||||||
events,
|
events,
|
||||||
|
|
@ -880,75 +1062,53 @@ def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
chat = complete_model([{"role": "user", "content": masked}], model_policy)
|
chat = complete_model([{"role": "user", "content": masked}], model_policy)
|
||||||
usages.append(chat.usage or {})
|
usages.append(chat.usage or {})
|
||||||
diagnostics["generate_called"] = True
|
diagnostics["generate_called"] = True
|
||||||
|
diagnostics["generate_calls"] = 1
|
||||||
request_trace["generate_called"] = True
|
request_trace["generate_called"] = True
|
||||||
|
request_trace["generate_calls"] = 1
|
||||||
_log_event(events, started, "model_result", attempt=1, **_usage_bits(chat.usage))
|
_log_event(events, started, "model_result", attempt=1, **_usage_bits(chat.usage))
|
||||||
raw = chat.content
|
raw = chat.content
|
||||||
try:
|
validated, process_meta = _process_response(raw, manifest)
|
||||||
validated = _validate_response(raw, manifest)
|
diagnostics["response_validation"] = process_meta["response_validation"]
|
||||||
diagnostics["response_validation"] = "ok"
|
diagnostics["response_normalization"] = process_meta["response_normalization"]
|
||||||
_log_event(events, started, "response_validation", attempt=1, status="ok")
|
diagnostics["normalized_cleartext_count"] = process_meta["normalized_cleartext_count"]
|
||||||
except PrivacyGatewayError as exc:
|
request_trace["response_validation"] = process_meta["response_validation"]
|
||||||
if exc.code != "response_validation_failed":
|
request_trace["response_normalization"] = process_meta["response_normalization"]
|
||||||
raise
|
request_trace["normalized_cleartext_count"] = process_meta["normalized_cleartext_count"]
|
||||||
leak = dict(exc.diagnostics or {})
|
diagnostics["model_text_accepted"] = True
|
||||||
diagnostics["response_validation"] = "failed"
|
_log_event(
|
||||||
_log_event(
|
events,
|
||||||
events,
|
started,
|
||||||
started,
|
"response_validation",
|
||||||
"response_validation",
|
attempt=1,
|
||||||
attempt=1,
|
status="ok",
|
||||||
status="failed",
|
response_normalization=process_meta["response_normalization"],
|
||||||
code=exc.code,
|
|
||||||
leak_tokens=leak.get("leak_tokens"),
|
|
||||||
leak_entity_types=leak.get("leak_entity_types"),
|
|
||||||
)
|
|
||||||
_log_event(events, started, "retry", attempt=2, reason="response_validation_failed")
|
|
||||||
diagnostics["response_validation_retry"] = 1
|
|
||||||
request_trace["response_validation_retry"] = 1
|
|
||||||
_log_event(
|
|
||||||
events,
|
|
||||||
started,
|
|
||||||
"model_call",
|
|
||||||
attempt=2,
|
|
||||||
purpose=request.purpose,
|
|
||||||
model=config.model if config else None,
|
|
||||||
)
|
|
||||||
chat = complete_model(
|
|
||||||
[{"role": "user", "content": masked + "\n\n" + IDENTITY_LEAK_RETRY}],
|
|
||||||
model_policy,
|
|
||||||
)
|
|
||||||
usages.append(chat.usage or {})
|
|
||||||
_log_event(events, started, "model_result", attempt=2, **_usage_bits(chat.usage))
|
|
||||||
raw = chat.content
|
|
||||||
validated = _validate_response(raw, manifest)
|
|
||||||
diagnostics["response_validation"] = "ok"
|
|
||||||
_log_event(events, started, "response_validation", attempt=2, status="ok")
|
|
||||||
except PrivacyGatewayError as exc:
|
|
||||||
if exc.code == "response_validation_failed":
|
|
||||||
leak = dict(exc.diagnostics or {})
|
|
||||||
diagnostics["response_validation"] = "failed"
|
|
||||||
_log_event(
|
|
||||||
events,
|
|
||||||
started,
|
|
||||||
"response_validation",
|
|
||||||
attempt=2 if diagnostics.get("response_validation_retry") else 1,
|
|
||||||
status="failed",
|
|
||||||
code=exc.code,
|
|
||||||
leak_tokens=leak.get("leak_tokens"),
|
|
||||||
leak_entity_types=leak.get("leak_entity_types"),
|
|
||||||
)
|
|
||||||
failed = merge_usage(
|
|
||||||
{**diagnostics, "budget_ok": False, "abort_reason": exc.code, "status": "error"},
|
|
||||||
sum_usages(usages) or None,
|
|
||||||
config.model if config else None,
|
|
||||||
)
|
)
|
||||||
|
except PrivacyGatewayError as exc:
|
||||||
|
failed = merge_usage(
|
||||||
|
{
|
||||||
|
**diagnostics,
|
||||||
|
"budget_ok": False,
|
||||||
|
"abort_reason": exc.code,
|
||||||
|
"status": "error",
|
||||||
|
"generate_calls": len(usages),
|
||||||
|
"generate_ms": int((time.perf_counter() - started) * 1000),
|
||||||
|
},
|
||||||
|
sum_usages(usages) or None,
|
||||||
|
(chat.model if chat else None) or (config.model if config else None),
|
||||||
|
)
|
||||||
|
request_trace["raw"] = raw
|
||||||
|
request_trace["abort_reason"] = exc.code
|
||||||
|
request_trace["generate_calls"] = len(usages)
|
||||||
|
request_trace["model_text_accepted"] = False
|
||||||
|
request_trace["budget"] = compact_diagnostics(failed)
|
||||||
|
request_trace["log"] = events
|
||||||
last_compact = compact_diagnostics(failed)
|
last_compact = compact_diagnostics(failed)
|
||||||
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
|
_record_test({"purpose": request.purpose, "ok": False, "code": exc.code})
|
||||||
_raise_with_log(
|
_raise_with_log(
|
||||||
exc,
|
exc,
|
||||||
events,
|
events,
|
||||||
purpose=request.purpose,
|
purpose=request.purpose,
|
||||||
extra={"response_validation_retry": diagnostics.get("response_validation_retry")},
|
extra={**failed, "trace": public_trace(request_trace)},
|
||||||
)
|
)
|
||||||
diagnostics = merge_usage(
|
diagnostics = merge_usage(
|
||||||
{
|
{
|
||||||
|
|
@ -957,6 +1117,9 @@ def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
"budget_ok": True,
|
"budget_ok": True,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"response_validation": "ok",
|
"response_validation": "ok",
|
||||||
|
"generate_ms": int((time.perf_counter() - started) * 1000),
|
||||||
|
"generate_calls": 1,
|
||||||
|
"model_text_accepted": True,
|
||||||
},
|
},
|
||||||
sum_usages(usages) or chat.usage,
|
sum_usages(usages) or chat.usage,
|
||||||
chat.model or (config.model if config else None),
|
chat.model or (config.model if config else None),
|
||||||
|
|
@ -965,10 +1128,11 @@ def complete(request: GatewayRequest) -> GatewayResult:
|
||||||
result.allowed = True
|
result.allowed = True
|
||||||
result.local_identities = local_identities
|
result.local_identities = local_identities
|
||||||
result.diagnostics = compact_diagnostics(diagnostics)
|
result.diagnostics = compact_diagnostics(diagnostics)
|
||||||
request_trace["raw"] = validated
|
request_trace["raw"] = raw
|
||||||
request_trace["reply"] = result.content
|
request_trace["reply"] = result.content
|
||||||
request_trace["model"] = chat.model or request_trace.get("model")
|
request_trace["model"] = chat.model or request_trace.get("model")
|
||||||
request_trace["response_validation"] = "ok"
|
request_trace["response_validation"] = "ok"
|
||||||
|
request_trace["model_text_accepted"] = True
|
||||||
request_trace["budget"] = compact_diagnostics(diagnostics)
|
request_trace["budget"] = compact_diagnostics(diagnostics)
|
||||||
request_trace["log"] = events
|
request_trace["log"] = events
|
||||||
result.trace = public_trace(request_trace)
|
result.trace = public_trace(request_trace)
|
||||||
|
|
@ -994,6 +1158,7 @@ def public_trace(trace: dict | None) -> dict | None:
|
||||||
"layer": trace.get("layer"),
|
"layer": trace.get("layer"),
|
||||||
"data_class": trace.get("data_class"),
|
"data_class": trace.get("data_class"),
|
||||||
"prompt_slug": trace.get("prompt_slug"),
|
"prompt_slug": trace.get("prompt_slug"),
|
||||||
|
"prompt_revision": trace.get("prompt_revision"),
|
||||||
"provider": trace.get("provider"),
|
"provider": trace.get("provider"),
|
||||||
"model": trace.get("model"),
|
"model": trace.get("model"),
|
||||||
"detect_provider": trace.get("detect_provider"),
|
"detect_provider": trace.get("detect_provider"),
|
||||||
|
|
@ -1007,6 +1172,7 @@ def public_trace(trace: dict | None) -> dict | None:
|
||||||
"request_local_hits": trace.get("request_local_hits"),
|
"request_local_hits": trace.get("request_local_hits"),
|
||||||
"detect_calls": trace.get("detect_calls"),
|
"detect_calls": trace.get("detect_calls"),
|
||||||
"detect_ms": trace.get("detect_ms"),
|
"detect_ms": trace.get("detect_ms"),
|
||||||
|
"generate_ms": (trace.get("budget") or {}).get("generate_ms") if isinstance(trace.get("budget"), dict) else trace.get("generate_ms"),
|
||||||
"generate_called": trace.get("generate_called"),
|
"generate_called": trace.get("generate_called"),
|
||||||
"mapping_count": trace.get("mapping_count"),
|
"mapping_count": trace.get("mapping_count"),
|
||||||
"available_mapping_count": trace.get("available_mapping_count"),
|
"available_mapping_count": trace.get("available_mapping_count"),
|
||||||
|
|
@ -1016,6 +1182,12 @@ def public_trace(trace: dict | None) -> dict | None:
|
||||||
"active_entity_types": trace.get("active_entity_types"),
|
"active_entity_types": trace.get("active_entity_types"),
|
||||||
"pre_egress_validation": trace.get("pre_egress_validation"),
|
"pre_egress_validation": trace.get("pre_egress_validation"),
|
||||||
"response_validation": trace.get("response_validation"),
|
"response_validation": trace.get("response_validation"),
|
||||||
|
"response_normalization": trace.get("response_normalization"),
|
||||||
|
"normalized_cleartext_count": trace.get("normalized_cleartext_count"),
|
||||||
|
"generate_calls": trace.get("generate_calls"),
|
||||||
|
"model_text_accepted": trace.get("model_text_accepted"),
|
||||||
|
"provenance_decision": trace.get("provenance_decision"),
|
||||||
|
"abort_reason": trace.get("abort_reason"),
|
||||||
"leak_tokens": trace.get("leak_tokens"),
|
"leak_tokens": trace.get("leak_tokens"),
|
||||||
"leak_entity_types": trace.get("leak_entity_types"),
|
"leak_entity_types": trace.get("leak_entity_types"),
|
||||||
"intern": trace.get("rendered") or trace.get("intern"),
|
"intern": trace.get("rendered") or trace.get("intern"),
|
||||||
|
|
|
||||||
151
backend/routers/generation_instructions.py
Normal file
151
backend/routers/generation_instructions.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import require_admin_dep
|
||||||
|
from journal_generation_policy import (
|
||||||
|
PURPOSE_JOURNAL,
|
||||||
|
CatalogError,
|
||||||
|
GenerationPolicyError,
|
||||||
|
archive_guideline,
|
||||||
|
clone_guideline,
|
||||||
|
create_guideline,
|
||||||
|
default_selection_ids,
|
||||||
|
delete_guideline,
|
||||||
|
get_guideline,
|
||||||
|
overview_payload,
|
||||||
|
preview_selection,
|
||||||
|
publish_guideline,
|
||||||
|
reset_seed_drafts,
|
||||||
|
set_default_guideline,
|
||||||
|
update_guideline,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/generation-instructions", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
class GuidelineWrite(BaseModel):
|
||||||
|
slot: str | None = None
|
||||||
|
guideline_key: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
instruction: str | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(BaseModel):
|
||||||
|
generation_selection: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _http(exc: GenerationPolicyError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
detail={"code": exc.code, "message": exc.message},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _purpose(purpose: str) -> str:
|
||||||
|
if purpose != PURPOSE_JOURNAL:
|
||||||
|
raise HTTPException(404, "Unbekannter Generation-Purpose")
|
||||||
|
return purpose
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{purpose}")
|
||||||
|
def list_overview(purpose: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
return overview_payload(_purpose(purpose))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/reset")
|
||||||
|
def reset_items(purpose: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
try:
|
||||||
|
return reset_seed_drafts(_purpose(purpose))
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/preview")
|
||||||
|
def preview_items(purpose: str, body: PreviewRequest, session: dict = Depends(require_admin_dep)):
|
||||||
|
checked = _purpose(purpose)
|
||||||
|
selection = body.generation_selection or default_selection_ids(checked)
|
||||||
|
try:
|
||||||
|
return preview_selection(selection, purpose=checked)
|
||||||
|
except GenerationPolicyError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}")
|
||||||
|
def create_item(purpose: str, body: GuidelineWrite, session: dict = Depends(require_admin_dep)):
|
||||||
|
try:
|
||||||
|
return create_guideline(_purpose(purpose), body.slot or "", body.model_dump())
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{purpose}/{guideline_id}")
|
||||||
|
def read_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
item = get_guideline(guideline_id, include_instruction=True)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
if item.get("purpose") != purpose:
|
||||||
|
raise HTTPException(404, "Ausprägung nicht gefunden.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{purpose}/{guideline_id}")
|
||||||
|
def write_item(purpose: str, guideline_id: str, body: GuidelineWrite, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
payload = {key: value for key, value in body.model_dump().items() if value is not None}
|
||||||
|
payload.pop("slot", None)
|
||||||
|
return update_guideline(guideline_id, payload)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/{guideline_id}/clone")
|
||||||
|
def clone_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
return clone_guideline(guideline_id)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/{guideline_id}/publish")
|
||||||
|
def publish_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
return publish_guideline(guideline_id)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/{guideline_id}/archive")
|
||||||
|
def archive_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
return archive_guideline(guideline_id)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{purpose}/{guideline_id}/default")
|
||||||
|
def default_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
return set_default_guideline(guideline_id)
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{purpose}/{guideline_id}")
|
||||||
|
def delete_item(purpose: str, guideline_id: str, session: dict = Depends(require_admin_dep)):
|
||||||
|
_purpose(purpose)
|
||||||
|
try:
|
||||||
|
delete_guideline(guideline_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except CatalogError as exc:
|
||||||
|
_http(exc)
|
||||||
|
|
@ -10,6 +10,7 @@ from dialogue_turn import run_turn, visible_for_role
|
||||||
from privacy_gateway import GatewayRequest, inspect
|
from privacy_gateway import GatewayRequest, inspect
|
||||||
from engine import EngineError
|
from engine import EngineError
|
||||||
from journal_generate import generate_draft
|
from journal_generate import generate_draft
|
||||||
|
from journal_generation_policy import settings_payload
|
||||||
from journal_opening import maybe_open_journal_conversation
|
from journal_opening import maybe_open_journal_conversation
|
||||||
from journal_policy import PolicyError
|
from journal_policy import PolicyError
|
||||||
from journal_store import (
|
from journal_store import (
|
||||||
|
|
@ -102,6 +103,8 @@ class TurnWrite(BaseModel):
|
||||||
class GenerateWrite(BaseModel):
|
class GenerateWrite(BaseModel):
|
||||||
conversation_ids: list[str] | None = None
|
conversation_ids: list[str] | None = None
|
||||||
include_existing: bool = False
|
include_existing: bool = False
|
||||||
|
generation_selection: dict | None = None
|
||||||
|
remember_generation_selection: bool = False
|
||||||
|
|
||||||
|
|
||||||
class EntryWrite(BaseModel):
|
class EntryWrite(BaseModel):
|
||||||
|
|
@ -309,6 +312,11 @@ def conversation_turn(conversation_id: str, body: TurnWrite, session: dict = Dep
|
||||||
_http(exc)
|
_http(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generation-settings")
|
||||||
|
def read_generation_settings(session: dict = Depends(require_auth)):
|
||||||
|
return settings_payload(session["profile_id"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/days/{journal_day_id}/generate")
|
@router.post("/days/{journal_day_id}/generate")
|
||||||
def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(require_auth)):
|
def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(require_auth)):
|
||||||
try:
|
try:
|
||||||
|
|
@ -319,6 +327,8 @@ def generate(journal_day_id: str, body: GenerateWrite, session: dict = Depends(r
|
||||||
conversation_ids=body.conversation_ids,
|
conversation_ids=body.conversation_ids,
|
||||||
include_existing=body.include_existing,
|
include_existing=body.include_existing,
|
||||||
explicit=True,
|
explicit=True,
|
||||||
|
generation_selection=body.generation_selection,
|
||||||
|
remember_generation_selection=body.remember_generation_selection,
|
||||||
),
|
),
|
||||||
session.get("role"),
|
session.get("role"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ CREATE TABLE IF NOT EXISTS journal_drafts (
|
||||||
source_message_ids TEXT NOT NULL DEFAULT '[]',
|
source_message_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
as_of TEXT NOT NULL,
|
as_of TEXT NOT NULL,
|
||||||
superseded_at TEXT,
|
superseded_at TEXT,
|
||||||
|
generation_snapshot TEXT NOT NULL DEFAULT '{}',
|
||||||
created TEXT NOT NULL DEFAULT (datetime('now'))
|
created TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -463,3 +464,33 @@ CREATE TABLE IF NOT EXISTS provider_settings (
|
||||||
no_train INTEGER NOT NULL DEFAULT 1,
|
no_train INTEGER NOT NULL DEFAULT 1,
|
||||||
updated TEXT NOT NULL DEFAULT (datetime('now'))
|
updated TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS journal_generation_selection (
|
||||||
|
profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
transformation_id TEXT NOT NULL,
|
||||||
|
detail_id TEXT NOT NULL,
|
||||||
|
voice_id TEXT NOT NULL,
|
||||||
|
narrative_id TEXT NOT NULL,
|
||||||
|
updated TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_guidelines (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
guideline_key TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
instruction TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cloned_from TEXT,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_system_seed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seed_id TEXT NOT NULL DEFAULT '',
|
||||||
|
seed_revision TEXT NOT NULL DEFAULT '',
|
||||||
|
used_at TEXT,
|
||||||
|
created TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -223,8 +223,10 @@ def main() -> None:
|
||||||
"space_title",
|
"space_title",
|
||||||
"writing_profile",
|
"writing_profile",
|
||||||
"style_examples",
|
"style_examples",
|
||||||
"editorial_mode",
|
"transformation_instructions",
|
||||||
"editorial_instructions",
|
"detail_instructions",
|
||||||
|
"voice_instructions",
|
||||||
|
"narrative_instructions",
|
||||||
"interaction_hint",
|
"interaction_hint",
|
||||||
},
|
},
|
||||||
"system and mvp context keys",
|
"system and mvp context keys",
|
||||||
|
|
|
||||||
|
|
@ -874,7 +874,7 @@ def test_generate_flow(client: TestClient, headers: dict) -> None:
|
||||||
expect("assistant:" not in (gen.json().get("body") or ""), "draft body has no assistant lines")
|
expect("assistant:" not in (gen.json().get("body") or ""), "draft body has no assistant lines")
|
||||||
expect("WRITING_PROFILE" in narrate or "Neutraler Journalstil" in narrate or "Core:" in narrate, "writing profile reaches generate")
|
expect("WRITING_PROFILE" in narrate or "Neutraler Journalstil" in narrate or "Core:" in narrate, "writing profile reaches generate")
|
||||||
expect("STYLE_EXAMPLES" in narrate, "style examples are labeled separately from day facts")
|
expect("STYLE_EXAMPLES" in narrate, "style examples are labeled separately from day facts")
|
||||||
expect("Erzählmerkmale" not in (narrate.split("CURRENT_DAY_SOURCES")[0] if "CURRENT_DAY_SOURCES" in narrate else narrate), "day dialogue is not a style brief")
|
expect("Erzählmerkmale" not in (narrate.split("\nCURRENT_DAY_SOURCES\n")[0] if "\nCURRENT_DAY_SOURCES\n" in narrate else narrate), "day dialogue is not a style brief")
|
||||||
|
|
||||||
reset_debug()
|
reset_debug()
|
||||||
short_recorder = install_test_recorder()
|
short_recorder = install_test_recorder()
|
||||||
|
|
@ -995,7 +995,7 @@ def test_generate_flow(client: TestClient, headers: dict) -> None:
|
||||||
reset_catalog()
|
reset_catalog()
|
||||||
|
|
||||||
|
|
||||||
def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict, profile_id: str) -> None:
|
def test_generate_attested_cleartext_is_normalized(client: TestClient, headers: dict, profile_id: str) -> None:
|
||||||
from identity_store import remember_mapping
|
from identity_store import remember_mapping
|
||||||
from providers import ChatResult
|
from providers import ChatResult
|
||||||
|
|
||||||
|
|
@ -1016,13 +1016,15 @@ def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"body": "Heute war ich mit Anna am Markt, danach Kirschen."},
|
json={"body": "Heute war ich mit Anna am Markt, danach Kirschen."},
|
||||||
)
|
)
|
||||||
expect(turn.status_code == 200, f"identity-leak setup turn {turn.status_code}")
|
expect(turn.status_code == 200, f"identity-cleartext setup turn {turn.status_code}")
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
def leak(_messages, _policy):
|
def leak(_messages, _policy):
|
||||||
|
calls["n"] += 1
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
content="Anna stand den ganzen Nachmittag am Markt.",
|
content="Anna stand den ganzen Nachmittag am Markt.",
|
||||||
model="fake",
|
model="fake",
|
||||||
usage={},
|
usage={"prompt_tokens": 11, "completion_tokens": 9, "total_tokens": 20, "cost": 0.004},
|
||||||
context_compression="disabled",
|
context_compression="disabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1032,24 +1034,20 @@ def test_generate_identity_leak_local_fallback(client: TestClient, headers: dict
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"conversation_ids": [conv.json()["id"]]},
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
)
|
)
|
||||||
expect(gen.status_code == 200, f"identity leak still yields a local draft {gen.text}")
|
expect(gen.status_code == 200, f"attested cleartext is accepted locally {gen.text}")
|
||||||
payload = gen.json()
|
payload = gen.json()
|
||||||
leak_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or []
|
leak_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or []
|
||||||
expect(any(item.get("kind") == "retry" for item in leak_log), "identity leak records a retry")
|
expect(calls["n"] == 1, "attested cleartext does not retry")
|
||||||
expect(
|
expect(not any(item.get("kind") == "retry" for item in leak_log), "no privacy retry event")
|
||||||
any(item.get("reason") == "identity_leak_blocked" for item in leak_log),
|
|
||||||
"identity leak records local fallback",
|
|
||||||
)
|
|
||||||
body = payload.get("body") or ""
|
body = payload.get("body") or ""
|
||||||
expect("Markt" in body and "Kirschen" in body, "local draft keeps attested user wording")
|
expect("Anna" in body, "source-attested name is demasked")
|
||||||
expect("Anna" in body, "local draft may keep names that the user actually wrote")
|
expect("Markt" in body, "model wording is kept after normalization")
|
||||||
stages = {item.get("purpose"): item for item in (payload.get("trace") or {}).get("stages") or []}
|
stages = {item.get("purpose"): item for item in (payload.get("trace") or {}).get("stages") or []}
|
||||||
reconstruct = stages.get("local_source_artifact") or {}
|
reconstruct = stages.get("local_source_artifact") or {}
|
||||||
narrate = stages.get("journal_generate") or {}
|
narrate = stages.get("journal_generate") or {}
|
||||||
expect(reconstruct.get("status") == "local_ok", "stage 1 stays local and does not call reconstruct")
|
expect(reconstruct.get("status") == "local_ok", "stage 1 stays local and does not call reconstruct")
|
||||||
expect(narrate.get("guard") == "identity_leak_blocked", "stage 2 does not use the leaking reply")
|
expect(narrate.get("model_text_accepted") is True, "stage 2 accepts the normalized model text")
|
||||||
expect(sum(1 for item in leak_log if item.get("kind") == "retry") == 1, "active leak retries at most once")
|
expect((payload.get("trace") or {}).get("generate_calls") == 1 or (narrate.get("generate_calls") == 1), "exactly one generate call")
|
||||||
expect(sum(1 for item in leak_log if item.get("kind") == "model_call") <= 2, "retry is the only extra model call")
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers: dict, profile_id: str) -> None:
|
def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers: dict, profile_id: str) -> None:
|
||||||
|
|
@ -1091,17 +1089,29 @@ def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers:
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"conversation_ids": [conv.json()["id"]]},
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
)
|
)
|
||||||
expect(gen.status_code == 200, f"inactive mapping generate {gen.text}")
|
expect(gen.status_code == 409, f"inactive mapping generate {gen.text}")
|
||||||
payload = gen.json()
|
detail = gen.json().get("detail") or {}
|
||||||
run_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or []
|
expect(detail.get("code") == "journal_generation_not_accepted", "historical-only name is not stored as a draft")
|
||||||
|
expect(detail.get("message") == "Generierung nicht übernommen.", "API names the rejection")
|
||||||
|
diag = detail.get("diagnostics") or {}
|
||||||
|
run_log = diag.get("log") or (diag.get("trace") or {}).get("log") or []
|
||||||
expect(calls["n"] == 1, "inactive mapping does not trigger a retry")
|
expect(calls["n"] == 1, "inactive mapping does not trigger a retry")
|
||||||
expect(not any(item.get("kind") == "retry" for item in run_log), "inactive mapping does not retry")
|
expect(not any(item.get("kind") == "retry" for item in run_log), "inactive mapping does not retry")
|
||||||
expect(
|
expect(
|
||||||
any(item.get("reason") == "unattested_identity" for item in run_log),
|
any(item.get("reason") == "unattested_identity" for item in run_log),
|
||||||
"invented historical name is unattested journal content",
|
"invented historical name is unattested journal content",
|
||||||
)
|
)
|
||||||
expect("Clarissa" not in (payload.get("body") or ""), "unattested name is not accepted as narration")
|
expect(diag.get("model_text_accepted") is False, "unattested model text is not accepted")
|
||||||
expect("Markt" in (payload.get("body") or "") and "Kirschen" in (payload.get("body") or ""), "fallback keeps user sources")
|
day_after = client.get(f"/api/journal/days/{day.json()['day']['id']}", headers=headers)
|
||||||
|
expect(not (day_after.json().get("current_draft")), "rejected generate does not insert a draft")
|
||||||
|
trace = diag.get("trace") or {}
|
||||||
|
expect(trace.get("abort_reason") == "unattested_identity", "abort reason remains on the error trace")
|
||||||
|
expect(
|
||||||
|
(trace.get("generate_calls") == 1)
|
||||||
|
or ((trace.get("budget") or {}).get("generate_calls") == 1)
|
||||||
|
or diag.get("generate_calls") == 1,
|
||||||
|
"error trace keeps the generate-call count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_traces_are_request_scoped(client: TestClient, headers: dict) -> None:
|
def test_traces_are_request_scoped(client: TestClient, headers: dict) -> None:
|
||||||
|
|
@ -1247,7 +1257,7 @@ def main() -> None:
|
||||||
profile_id = setup.json()["profile_id"]
|
profile_id = setup.json()["profile_id"]
|
||||||
test_task_brief_and_dedupe(client, headers, profile_id)
|
test_task_brief_and_dedupe(client, headers, profile_id)
|
||||||
test_generate_flow(client, headers)
|
test_generate_flow(client, headers)
|
||||||
test_generate_identity_leak_local_fallback(client, headers, profile_id)
|
test_generate_attested_cleartext_is_normalized(client, headers, profile_id)
|
||||||
test_generate_inactive_mapping_keeps_model_text(client, headers, profile_id)
|
test_generate_inactive_mapping_keeps_model_text(client, headers, profile_id)
|
||||||
test_traces_are_request_scoped(client, headers)
|
test_traces_are_request_scoped(client, headers)
|
||||||
test_two_conversations_same_day(client, headers)
|
test_two_conversations_same_day(client, headers)
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,9 @@ from db import get_db, init_db
|
||||||
from engine import load_active_prompt
|
from engine import load_active_prompt
|
||||||
from identity_store import remember_mapping
|
from identity_store import remember_mapping
|
||||||
from journal_editorial import (
|
from journal_editorial import (
|
||||||
NOTES_TO_JOURNAL,
|
GENERATE_SEED_REVISION,
|
||||||
PROSE_EDIT,
|
|
||||||
choose_editorial_mode,
|
|
||||||
editorial_instructions,
|
|
||||||
format_style_examples,
|
format_style_examples,
|
||||||
|
incomplete_syntax_markers,
|
||||||
lexical_similarity,
|
lexical_similarity,
|
||||||
narration_sources_text,
|
narration_sources_text,
|
||||||
select_journal_style_examples,
|
select_journal_style_examples,
|
||||||
|
|
@ -43,7 +41,13 @@ from writing_profile_store import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SEED_REVISION = "2026-08-27-journal-placeholders-v1"
|
SEED_REVISION = GENERATE_SEED_REVISION
|
||||||
|
MIXED_SOURCES_INSTRUCTION = (
|
||||||
|
"Die Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. "
|
||||||
|
"Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, "
|
||||||
|
"verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. "
|
||||||
|
"Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def expect(ok: bool, message: str) -> None:
|
def expect(ok: bool, message: str) -> None:
|
||||||
|
|
@ -56,43 +60,36 @@ def header(token: str) -> dict:
|
||||||
return {"X-Auth-Token": token}
|
return {"X-Auth-Token": token}
|
||||||
|
|
||||||
|
|
||||||
def test_mode_choice() -> None:
|
def test_syntax_diagnostics() -> None:
|
||||||
expect(choose_editorial_mode(["Ich ging zum Markt. Es war voll."]) == PROSE_EDIT, "narrative sentences select prose_edit")
|
expect(incomplete_syntax_markers("Danach sprach ich mit dem.") > 0, "dangling determiner is diagnostic")
|
||||||
expect(choose_editorial_mode(["markt", "kirschen", "hafen"]) == NOTES_TO_JOURNAL, "fragments select notes_to_journal")
|
expect(incomplete_syntax_markers("Danach sprach ich und kam zurück.") == 0, "complete sentence is not flagged")
|
||||||
expect(
|
|
||||||
choose_editorial_mode(["Ich war am Markt.", "Kirschen gekauft.", "hafen später"]) == PROSE_EDIT,
|
|
||||||
"mixed default is prose_edit when at least half the blocks have sentence punctuation",
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
choose_editorial_mode(["markt", "kirschen", "Später der Hafen."]) == NOTES_TO_JOURNAL,
|
|
||||||
"mixed default is notes_to_journal when the majority lacks sentence punctuation",
|
|
||||||
)
|
|
||||||
prose = editorial_instructions(PROSE_EDIT)
|
|
||||||
notes = editorial_instructions(NOTES_TO_JOURNAL)
|
|
||||||
expect("Gute Formulierungen bewahren" in prose, "prose_edit keeps good wording")
|
|
||||||
expect("zusammenhängende Journalprosa" in notes, "notes_to_journal asks for connected prose")
|
|
||||||
expect(prose != notes, "modes produce different instructions")
|
|
||||||
expect("nicht inklusive ihrer Fehler" in notes or "Fehler hintereinanderkopieren" in notes, "notes must not be concatenated with errors")
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_contract() -> None:
|
def test_prompt_contract() -> None:
|
||||||
init_db()
|
init_db()
|
||||||
prompt = load_active_prompt("mvp.journal_generate")
|
prompt = load_active_prompt("mvp.journal_generate")
|
||||||
text = prompt.get("template") or ""
|
text = prompt.get("template") or ""
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in text, "prompt separates fact fidelity from wording")
|
expect("INHALTSTREUE" in text, "prompt keeps hard content rules")
|
||||||
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts")
|
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts")
|
||||||
expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
|
|
||||||
expect("STYLE_EXAMPLES" in text, "prompt labels style examples")
|
expect("STYLE_EXAMPLES" in text, "prompt labels style examples")
|
||||||
expect("EDITORIAL_MODE" in text, "prompt exposes editorial mode")
|
expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
|
||||||
expect("keine geschützten Fakten" in text, "prompt says typos are not protected facts")
|
expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation instructions")
|
||||||
expect("{{editorial_instructions}}" in text, "prompt injects mode-specific instructions")
|
expect("{{detail_instructions}}" in text, "prompt injects compiled detail instructions")
|
||||||
|
expect("{{voice_instructions}}" in text, "prompt injects compiled voice instructions")
|
||||||
|
expect("{{narrative_instructions}}" in text, "prompt injects compiled narrative instructions")
|
||||||
|
expect("{{source_mode_instructions}}" not in text, "prompt has no source-mode placeholder")
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in text, "prompt contains the mixed-source instruction")
|
||||||
|
expect("{{writing_profile}}" in text, "prompt injects writing profile")
|
||||||
expect("{{style_examples}}" in text, "prompt injects style examples")
|
expect("{{style_examples}}" in text, "prompt injects style examples")
|
||||||
|
expect("{{reconstruction}}" in text, "prompt injects current-day sources")
|
||||||
|
expect("{{existing_text}}" in text, "prompt injects existing text")
|
||||||
expect("[[…" not in text and "[[..." not in text, "prompt must not teach ellipsis placeholders")
|
expect("[[…" not in text and "[[..." not in text, "prompt must not teach ellipsis placeholders")
|
||||||
expect("zeichengetreu" in text, "prompt asks to copy existing placeholders unchanged")
|
expect("ich gieng zum laden" not in text, "synthetic prose_edit example is gone")
|
||||||
expect("ich gieng zum laden" in text, "prompt includes a synthetic prose_edit example")
|
expect("nachbarhund im garten" not in text, "synthetic notes_to_journal example is gone")
|
||||||
expect("Im Laden holte ich Brot" in text, "prompt includes a synthetic notes_to_journal example")
|
|
||||||
expect("Nur den verifizierten Nutzerwortlaut" not in text, "old wording-as-output rule is gone")
|
expect("Nur den verifizierten Nutzerwortlaut" not in text, "old wording-as-output rule is gone")
|
||||||
expect("Unsicherheiten im Wortlaut" not in text, "old keep-uncertainty-in-wording rule is gone")
|
expect("Unsicherheiten im Wortlaut" not in text, "old keep-uncertainty-in-wording rule is gone")
|
||||||
|
expect("EDITORIAL_MODE" not in text, "source mode is no longer a labeled user setting")
|
||||||
|
expect("Faktentreue ist nicht Wortlauttreue" not in text, "old redundant fidelity lecture is gone")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
||||||
|
|
@ -163,8 +160,6 @@ def test_budget_pack_drops_examples_first() -> None:
|
||||||
assembled = {
|
assembled = {
|
||||||
"writing_profile": "Core: kurze Sätze.",
|
"writing_profile": "Core: kurze Sätze.",
|
||||||
"reconstruction": "Heute Markt.",
|
"reconstruction": "Heute Markt.",
|
||||||
"editorial_mode": PROSE_EDIT,
|
|
||||||
"editorial_instructions": "x",
|
|
||||||
"style_examples": "",
|
"style_examples": "",
|
||||||
"existing_text": "",
|
"existing_text": "",
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +204,7 @@ def intern_of(payload: dict) -> str:
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
test_mode_choice()
|
test_syntax_diagnostics()
|
||||||
test_prompt_contract()
|
test_prompt_contract()
|
||||||
test_sources_and_examples_are_separated()
|
test_sources_and_examples_are_separated()
|
||||||
test_unattested_covers_title_and_body()
|
test_unattested_covers_title_and_body()
|
||||||
|
|
@ -256,16 +251,22 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
expect(gen.status_code == 200, f"generate {gen.status_code}")
|
expect(gen.status_code == 200, f"generate {gen.status_code}")
|
||||||
intern = intern_of(gen.json())
|
intern = intern_of(gen.json())
|
||||||
expect("EDITORIAL_MODE: prose_edit" in intern, "narrative source selects prose_edit")
|
expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model")
|
||||||
expect("Gute Formulierungen bewahren" in intern, "prose_edit instructions reach the model")
|
|
||||||
expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled")
|
expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled")
|
||||||
expect("STYLE_EXAMPLES" in intern, "style examples are labeled")
|
expect("STYLE_EXAMPLES" in intern, "style examples are labeled")
|
||||||
expect("WRITING_PROFILE" in intern, "writing profile is labeled")
|
expect("WRITING_PROFILE" in intern, "writing profile is labeled")
|
||||||
|
expect("Überarbeite den Text substanziell" in intern, "default transformation policy reaches the prompt")
|
||||||
|
expect("Erhalte sämtliche belegten Ereignisse" in intern, "default detail policy reaches the prompt")
|
||||||
expect("Neutraler Journalstil" in intern, "without a confirmed profile the neutral fallback is used")
|
expect("Neutraler Journalstil" in intern, "without a confirmed profile the neutral fallback is used")
|
||||||
expect("zimlich" in intern.split("CURRENT_DAY_SOURCES")[-1], "today remains content, including typos")
|
expect("zimlich" in intern.split("\nCURRENT_DAY_SOURCES\n")[-1], "today remains content, including typos")
|
||||||
expect("zimlich" not in intern.split("CURRENT_DAY_SOURCES")[0], "today is not a style authority")
|
expect("zimlich" not in intern.split("\nCURRENT_DAY_SOURCES\n")[0], "today is not a style authority")
|
||||||
expect(sum(1 for item in (gen.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "normal path is one generate call")
|
expect(sum(1 for item in (gen.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "normal path is one generate call")
|
||||||
expect((gen.json().get("trace") or {}).get("editorial_mode") == PROSE_EDIT, "editorial mode is in the admin trace")
|
expect("editorial_mode" not in (gen.json().get("trace") or {}), "trace has no abandoned editorial mode")
|
||||||
|
expect((gen.json().get("trace") or {}).get("narration_source") == "model", "accepted model path is recorded")
|
||||||
|
expect((gen.json().get("trace") or {}).get("prompt_revision") == SEED_REVISION, "prompt revision is in the admin trace")
|
||||||
|
expect((gen.json().get("trace") or {}).get("writing_profile", {}).get("neutral_fallback") is True, "unconfirmed profile is visible as fallback")
|
||||||
|
expect((gen.json().get("trace") or {}).get("style_examples", {}).get("count") == 0, "no historical examples yet")
|
||||||
|
expect((gen.json().get("trace") or {}).get("dropped_optional_blocks") == [], "nothing dropped on a small day")
|
||||||
|
|
||||||
notes_day = client.post(
|
notes_day = client.post(
|
||||||
f"/api/journal/spaces/{space.json()['id']}/days",
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
|
@ -289,9 +290,8 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
expect(notes_gen.status_code == 200, f"notes generate {notes_gen.text}")
|
expect(notes_gen.status_code == 200, f"notes generate {notes_gen.text}")
|
||||||
notes_intern = intern_of(notes_gen.json())
|
notes_intern = intern_of(notes_gen.json())
|
||||||
expect("EDITORIAL_MODE: notes_to_journal" in notes_intern, "fragments select notes_to_journal")
|
expect(MIXED_SOURCES_INSTRUCTION in notes_intern, "notes use the same mixed-source instruction")
|
||||||
expect("zusammenhängende Journalprosa" in notes_intern, "notes mode reaches the model")
|
expect("markt" in notes_intern.lower() and "kirschen" in notes_intern.lower(), "notes sources remain complete")
|
||||||
expect("Gute Formulierungen bewahren" not in notes_intern, "prose_edit instructions are not used for notes")
|
|
||||||
|
|
||||||
import_text(
|
import_text(
|
||||||
profile_id,
|
profile_id,
|
||||||
|
|
@ -360,7 +360,7 @@ def main() -> None:
|
||||||
expect(body.lower().count("markt") <= 2, "repetition is allowed to be reduced")
|
expect(body.lower().count("markt") <= 2, "repetition is allowed to be reduced")
|
||||||
expect("traurig" not in body.lower() and "weil" not in body.lower(), "no invented feeling or cause in the patched rewrite")
|
expect("traurig" not in body.lower() and "weil" not in body.lower(), "no invented feeling or cause in the patched rewrite")
|
||||||
intern2 = intern_of(second.json())
|
intern2 = intern_of(second.json())
|
||||||
expect("trockener Schnitt" in intern2.split("CURRENT_DAY_SOURCES")[0], "confirmed writing profile reaches generate")
|
expect("trockener Schnitt" in intern2.split("\nCURRENT_DAY_SOURCES\n")[0], "confirmed writing profile reaches generate")
|
||||||
expect("Hafen blieb hinter der Fähre" not in body, "historical style facts are not copied into today")
|
expect("Hafen blieb hinter der Fähre" not in body, "historical style facts are not copied into today")
|
||||||
expect(
|
expect(
|
||||||
sum(1 for item in (second.json().get("run_log") or []) if item.get("kind") == "model_call") == 1,
|
sum(1 for item in (second.json().get("run_log") or []) if item.get("kind") == "model_call") == 1,
|
||||||
|
|
@ -386,6 +386,103 @@ def main() -> None:
|
||||||
expect("Am Markt holte ich Kirschen" in notes_body, "notes become connected prose")
|
expect("Am Markt holte ich Kirschen" in notes_body, "notes become connected prose")
|
||||||
expect("kirschen\nspäter" not in notes_body.lower(), "notes are not concatenated as fragments")
|
expect("kirschen\nspäter" not in notes_body.lower(), "notes are not concatenated as fragments")
|
||||||
|
|
||||||
|
broken_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-24"},
|
||||||
|
)
|
||||||
|
broken_conv = client.post(
|
||||||
|
f"/api/journal/days/{broken_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Bruch"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{broken_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "ich gieng zum laden. danach sprach ich mit dem und kam zurück. es war kald."},
|
||||||
|
)
|
||||||
|
|
||||||
|
def rebuilt(_messages, _policy):
|
||||||
|
return ChatResult(
|
||||||
|
content="Ladengang\n\nIch ging zum Laden. Danach sprach ich und kam zurück. Es war kalt.",
|
||||||
|
model="fake",
|
||||||
|
usage={},
|
||||||
|
context_compression="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.complete_model", rebuilt):
|
||||||
|
broken_out = client.post(
|
||||||
|
f"/api/journal/days/{broken_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [broken_conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
broken_body = broken_out.json().get("body") or ""
|
||||||
|
expect("gieng" not in broken_body.lower(), "general spelling error is not kept")
|
||||||
|
expect("kald" not in broken_body.lower(), "second general spelling error is not kept")
|
||||||
|
expect("mit dem" not in broken_body.lower(), "dangling determiner is not kept")
|
||||||
|
expect("kam zurück" in broken_body, "attested continuation stays")
|
||||||
|
expect(incomplete_syntax_markers(broken_body) == 0, "patched rewrite has no incomplete syntax")
|
||||||
|
expect((broken_out.json().get("trace") or {}).get("incomplete_syntax") == 0, "incomplete syntax is a diagnostic")
|
||||||
|
|
||||||
|
weight_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-23"},
|
||||||
|
)
|
||||||
|
weight_conv = client.post(
|
||||||
|
f"/api/journal/days/{weight_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Notizen"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{weight_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "morgens tee\nspäter markt\ngegen abend unerwartet der nachbarhund im garten"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def weighted(_messages, _policy):
|
||||||
|
return ChatResult(
|
||||||
|
content=(
|
||||||
|
"Nachbarhund\n\n"
|
||||||
|
"Morgens trank ich Tee, später war ich am Markt. "
|
||||||
|
"Es war ein gewöhnlicher Tag – bis gegen Abend unerwartet der Nachbarhund im Garten war."
|
||||||
|
),
|
||||||
|
model="fake",
|
||||||
|
usage={},
|
||||||
|
context_compression="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.complete_model", weighted):
|
||||||
|
weight_out = client.post(
|
||||||
|
f"/api/journal/days/{weight_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [weight_conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
weight_intern = intern_of(weight_out.json())
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in weight_intern, "fragment notes still use the unified instruction")
|
||||||
|
weight_body = weight_out.json().get("body") or ""
|
||||||
|
expect("bis gegen Abend" in weight_body or "unerwartet" in weight_body, "attested standout may be weighted")
|
||||||
|
expect("morgens tee\nspäter markt" not in weight_body.lower(), "notes are not a concatenated list")
|
||||||
|
expect("glücklich" not in weight_body.lower() and "weil" not in weight_body.lower(), "weighting does not invent feeling or cause")
|
||||||
|
|
||||||
|
update_facet(profile_id, "core", value="Lange, ruhig fließende Sätze, behutsame Wortwahl, leise Reflexion.")
|
||||||
|
set_lifecycle(profile_id, "confirmed")
|
||||||
|
brief_b = compile_task_brief(profile_id)
|
||||||
|
expect("fließende Sätze" in brief_b, "second confirmed core compiles")
|
||||||
|
with patch("privacy_gateway.complete_model", rewritten):
|
||||||
|
third = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
intern3 = intern_of(third.json())
|
||||||
|
style3 = intern3.split("\nCURRENT_DAY_SOURCES\n")[0]
|
||||||
|
expect("fließende Sätze" in style3, "second writing profile reaches the rendered prompt")
|
||||||
|
expect("trockener Schnitt" not in style3, "replaced core is not still the style authority")
|
||||||
|
expect((third.json().get("trace") or {}).get("writing_profile", {}).get("has_core") is True, "core presence is in the trace")
|
||||||
|
expect((third.json().get("trace") or {}).get("writing_profile", {}).get("present") is True, "confirmed profile is marked present")
|
||||||
|
expect(sum(1 for item in (third.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "profile A/B still uses one generate call")
|
||||||
|
|
||||||
def invent_title(_messages, _policy):
|
def invent_title(_messages, _policy):
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
content="Hanna am Hafen\n\nIch ging zum Markt, auf dem es ziemlich voll war.",
|
content="Hanna am Hafen\n\nIch ging zum Markt, auf dem es ziemlich voll war.",
|
||||||
|
|
@ -400,8 +497,13 @@ def main() -> None:
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"conversation_ids": [conv.json()["id"]]},
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
)
|
)
|
||||||
expect(any(item.get("reason") == "unattested_identity" for item in (blocked.json().get("run_log") or [])), "title identity uses local fallback")
|
expect(blocked.status_code == 409, f"unattested title {blocked.text}")
|
||||||
expect("Hanna" not in (blocked.json().get("title") or "") and "Hanna" not in (blocked.json().get("body") or ""), "unattested title identity is not kept")
|
detail = blocked.json().get("detail") or {}
|
||||||
|
expect(detail.get("code") == "journal_generation_not_accepted", "unattested title is not stored as a draft")
|
||||||
|
expect(detail.get("message") == "Generierung nicht übernommen.", "API names the rejection")
|
||||||
|
log = (detail.get("diagnostics") or {}).get("log") or []
|
||||||
|
expect(any(item.get("reason") == "unattested_identity" for item in log), "title identity is a provenance reject")
|
||||||
|
expect((detail.get("diagnostics") or {}).get("trace", {}).get("model_text_accepted") is False, "model text is not accepted")
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -416,7 +518,7 @@ def main() -> None:
|
||||||
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
|
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
|
||||||
("mvp.journal_generate",),
|
("mvp.journal_generate",),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in (row["default_template"] or ""), "default template still tracks the seed")
|
expect("INHALTSTREUE" in (row["default_template"] or ""), "default template still tracks the seed")
|
||||||
expect(row["seed_revision"] == SEED_REVISION, "revision updates even when template is custom")
|
expect(row["seed_revision"] == SEED_REVISION, "revision updates even when template is custom")
|
||||||
|
|
||||||
print("journal editorial tests passed.")
|
print("journal editorial tests passed.")
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,19 @@ os.environ["KANSHO_FAKE_DETECT"] = "1"
|
||||||
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
||||||
|
|
||||||
from db import init_db
|
from db import init_db
|
||||||
|
from journal_editorial import GENERATE_SEED_REVISION
|
||||||
from journal_eval import (
|
from journal_eval import (
|
||||||
|
FIXTURES,
|
||||||
|
PROFILE_A,
|
||||||
|
PROFILE_B,
|
||||||
SYNTHETIC_PROSE,
|
SYNTHETIC_PROSE,
|
||||||
SYNTHETIC_TYPOS,
|
SYNTHETIC_TYPOS,
|
||||||
VARIANT_BASELINE,
|
VARIANT_BASELINE,
|
||||||
VARIANT_CURRENT,
|
VARIANT_CURRENT,
|
||||||
VARIANT_PREVIOUS,
|
VARIANT_PREVIOUS,
|
||||||
|
compare_synthetic,
|
||||||
|
fixture_context,
|
||||||
score_output,
|
score_output,
|
||||||
synthetic_context,
|
|
||||||
variant_templates,
|
variant_templates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,12 +42,33 @@ def main() -> None:
|
||||||
init_db()
|
init_db()
|
||||||
templates = variant_templates()
|
templates = variant_templates()
|
||||||
expect(set(templates) == {VARIANT_BASELINE, VARIANT_PREVIOUS, VARIANT_CURRENT}, "three comparison variants exist")
|
expect(set(templates) == {VARIANT_BASELINE, VARIANT_PREVIOUS, VARIANT_CURRENT}, "three comparison variants exist")
|
||||||
expect("Überarbeite diesen Rohtext" in templates[VARIANT_BASELINE], "baseline is a simple rewrite prompt")
|
expect("Erstelle aus diesen Angaben einen ansprechenden persönlichen Tagebucheintrag" in templates[VARIANT_BASELINE], "baseline is a simple rewrite prompt")
|
||||||
expect("CURRENT_DAY_SOURCES" in templates[VARIANT_CURRENT], "current variant uses the production prompt")
|
expect("CURRENT_DAY_SOURCES" in templates[VARIANT_CURRENT], "current variant uses the production prompt")
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in templates[VARIANT_CURRENT], "current variant has the new contract")
|
expect("INHALTSTREUE" in templates[VARIANT_CURRENT], "current variant has the new contract")
|
||||||
|
expect("{{transformation_instructions}}" in templates[VARIANT_CURRENT], "current variant compiles transformation policy")
|
||||||
|
expect("{{source_mode_instructions}}" not in templates[VARIANT_CURRENT], "current variant has no source-mode placeholder")
|
||||||
|
expect("Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus." in templates[VARIANT_CURRENT], "current variant has mixed-source instruction")
|
||||||
|
expect("ich gieng zum laden" not in templates[VARIANT_CURRENT], "current variant has no synthetic examples")
|
||||||
|
expect(GENERATE_SEED_REVISION == "2026-08-27-journal-mixed-sources-v1", "eval tracks the seeded revision constant")
|
||||||
|
|
||||||
context = synthetic_context(SYNTHETIC_PROSE)
|
required_classes = {
|
||||||
expect(context["editorial_mode"] in {"prose_edit", "notes_to_journal"}, "eval context has an editorial mode")
|
"already_narrative_with_errors",
|
||||||
|
"bullet_points_and_fragments",
|
||||||
|
"plan_versus_completion",
|
||||||
|
"negation_and_uncertainty",
|
||||||
|
"correction_of_earlier_claim",
|
||||||
|
"imprecise_time",
|
||||||
|
"outstanding_event_among_everyday",
|
||||||
|
"recurring_people_and_projects",
|
||||||
|
"incomplete_source_rebuildable",
|
||||||
|
}
|
||||||
|
got = {item["class"] for item in FIXTURES}
|
||||||
|
expect(required_classes <= got, f"all required fixture classes exist, missing {required_classes - got}")
|
||||||
|
expect(all("example.test" not in item["source"].lower() for item in FIXTURES), "fixtures stay synthetic")
|
||||||
|
|
||||||
|
context = fixture_context(SYNTHETIC_PROSE)
|
||||||
|
expect("source_mode_instructions" not in context, "eval context has no source-mode instruction")
|
||||||
|
expect("editorial_mode" not in context, "eval context has no editorial mode")
|
||||||
copied = score_output(SYNTHETIC_PROSE, SYNTHETIC_PROSE, typos=SYNTHETIC_TYPOS)
|
copied = score_output(SYNTHETIC_PROSE, SYNTHETIC_PROSE, typos=SYNTHETIC_TYPOS)
|
||||||
expect(copied["lexical_similarity"] == 1.0, "identical text is similarity 1")
|
expect(copied["lexical_similarity"] == 1.0, "identical text is similarity 1")
|
||||||
expect(copied["spelling_typos_remaining"] == list(SYNTHETIC_TYPOS) or "zimlich" in copied["spelling_typos_remaining"], "copy keeps typos")
|
expect(copied["spelling_typos_remaining"] == list(SYNTHETIC_TYPOS) or "zimlich" in copied["spelling_typos_remaining"], "copy keeps typos")
|
||||||
|
|
@ -55,6 +81,28 @@ def main() -> None:
|
||||||
expect(improved["lexical_similarity"] < 1.0, "rewrite is not identical")
|
expect(improved["lexical_similarity"] < 1.0, "rewrite is not identical")
|
||||||
expect("markt" in [item.lower() for item in improved["lost_info_tokens"]] or improved["fact_token_keep"] > 0.2, "fact keep is scored")
|
expect("markt" in [item.lower() for item in improved["lost_info_tokens"]] or improved["fact_token_keep"] > 0.2, "fact keep is scored")
|
||||||
expect("private" not in str(improved).lower(), "synthetic scores contain no private fixtures")
|
expect("private" not in str(improved).lower(), "synthetic scores contain no private fixtures")
|
||||||
|
|
||||||
|
notes = next(item for item in FIXTURES if item["id"] == "notes_fragments")
|
||||||
|
expect("markt" in notes["source"].lower(), "notes fixture stays notes-shaped")
|
||||||
|
incomplete = next(item for item in FIXTURES if item["id"] == "incomplete_clause")
|
||||||
|
expect("danach sprach ich mit dem" in incomplete["source"].lower(), "incomplete fixture stays incomplete prose")
|
||||||
|
|
||||||
|
report = compare_synthetic(live=False)
|
||||||
|
expect(report["live"] is False, "default eval is offline")
|
||||||
|
expect(report["live_quality_confirmed"] is False, "offline run does not confirm live quality")
|
||||||
|
expect(report["winner_declared"] is False, "harness does not declare a winner")
|
||||||
|
expect(len(report["fixtures"]) == len(FIXTURES), "offline report covers every fixture")
|
||||||
|
first = report["fixtures"][0]
|
||||||
|
expect("human_blind" in first and "prompt_1" in first["human_blind"], "each fixture has a blind pair")
|
||||||
|
expect(first["human_blind"]["hidden_mapping"]["prompt_1"] == VARIANT_CURRENT, "mapping stays machine-side")
|
||||||
|
expect("Kanshō habe gewonnen" not in report["note"], "no victory claim")
|
||||||
|
expect(any(item["variant"] == VARIANT_CURRENT and item["fake_provider"] for item in first["variants"]), "offline current variant is fake")
|
||||||
|
|
||||||
|
ab = compare_synthetic(live=False, profile_ab=True)
|
||||||
|
expect(ab["profile_ab"]["same_facts"] is True, "profile A/B keeps facts identical")
|
||||||
|
expect(ab["profile_ab"]["prompts_differ"] is True, "rendered prompts contain different style briefs")
|
||||||
|
expect(PROFILE_A[:20] != PROFILE_B[:20], "synthetic profiles are distinct")
|
||||||
|
expect("Live-Prosa" in (ab["profile_ab"]["note"] or ""), "offline A/B does not claim live prose")
|
||||||
print("journal eval tests passed.")
|
print("journal eval tests passed.")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
961
backend/tests/test_journal_generation_policy.py
Normal file
961
backend/tests/test_journal_generation_policy.py
Normal file
|
|
@ -0,0 +1,961 @@
|
||||||
|
"""Named journal generation guidelines: selection, snapshot, mixed sources."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FRONTEND = ROOT.parent / "frontend"
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-journal-guidelines-test.sqlite")
|
||||||
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
||||||
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
||||||
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from db import get_db, init_db
|
||||||
|
from engine import load_active_prompt
|
||||||
|
from identity_store import confirm_identity, remember_mapping
|
||||||
|
from journal_editorial import GENERATE_SEED_REVISION
|
||||||
|
from journal_generation_policy import (
|
||||||
|
PURPOSE_JOURNAL,
|
||||||
|
SELECTION_KEYS,
|
||||||
|
SLOT_TO_ID_KEY,
|
||||||
|
CatalogError,
|
||||||
|
GenerationPolicyError,
|
||||||
|
archive_guideline,
|
||||||
|
clone_guideline,
|
||||||
|
compile_selection,
|
||||||
|
create_guideline,
|
||||||
|
default_selection_ids,
|
||||||
|
get_guideline,
|
||||||
|
get_or_create_selection,
|
||||||
|
list_guidelines,
|
||||||
|
load_seed_document,
|
||||||
|
load_selection,
|
||||||
|
overview_payload,
|
||||||
|
publish_guideline,
|
||||||
|
save_selection,
|
||||||
|
seed_generation_instructions,
|
||||||
|
snapshot_summary,
|
||||||
|
update_guideline,
|
||||||
|
validate_selection,
|
||||||
|
)
|
||||||
|
from journal_generate import unattested_journal_content
|
||||||
|
from journal_store import current_draft
|
||||||
|
from main import app
|
||||||
|
from privacy_gateway import install_test_recorder, reset_debug
|
||||||
|
from providers import ChatResult
|
||||||
|
|
||||||
|
|
||||||
|
PREVIOUS_PROMPT_CHARS = 3468
|
||||||
|
HARD_FACTS = (
|
||||||
|
"keine neuen Tatsachen",
|
||||||
|
"Plan und Vollzug",
|
||||||
|
"Verneinungen",
|
||||||
|
"Unsicherheiten",
|
||||||
|
"STYLE_EXAMPLES dienen ausschließlich als Stilreferenz",
|
||||||
|
"[[PERSON:01]]",
|
||||||
|
)
|
||||||
|
BANNED_CODE_PHRASES = (
|
||||||
|
"Überarbeite den Text substanziell",
|
||||||
|
"Erhalte sämtliche belegten Ereignisse",
|
||||||
|
"künstlich zu literarisieren",
|
||||||
|
"Modus prose_edit",
|
||||||
|
"Modus notes_to_journal",
|
||||||
|
"Korrigiere nur Rechtschreibung, Grammatik und Zeichensetzung",
|
||||||
|
)
|
||||||
|
BANNED_RUNTIME_TOKENS = (
|
||||||
|
"transformation_strength",
|
||||||
|
"detail_retention",
|
||||||
|
"voice_strength",
|
||||||
|
"narrative_shaping",
|
||||||
|
"min_value",
|
||||||
|
"max_value",
|
||||||
|
"remember_generation_policy",
|
||||||
|
"generation_policy",
|
||||||
|
)
|
||||||
|
SEED = load_seed_document()
|
||||||
|
MIXED_SOURCES_INSTRUCTION = (
|
||||||
|
"Die Quellen können aus Fließtext, Stichpunkten und Satzfragmenten bestehen. "
|
||||||
|
"Behandle jede Passage entsprechend ihrer Form: Redigiere vorhandenen Fließtext, "
|
||||||
|
"verwandle Stichpunkte und Fragmente in vollständige Prosa und verbinde beides zu einem einheitlichen Eintrag. "
|
||||||
|
"Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus."
|
||||||
|
)
|
||||||
|
REQUIRED_KEYS = {
|
||||||
|
("transformation", "correction"),
|
||||||
|
("transformation", "copyedit"),
|
||||||
|
("transformation", "reshape"),
|
||||||
|
("transformation", "substantial"),
|
||||||
|
("detail", "compact"),
|
||||||
|
("detail", "selected"),
|
||||||
|
("detail", "broad"),
|
||||||
|
("detail", "complete"),
|
||||||
|
("voice", "neutral"),
|
||||||
|
("voice", "light"),
|
||||||
|
("voice", "noticeable"),
|
||||||
|
("voice", "clear"),
|
||||||
|
("narrative", "chronicle"),
|
||||||
|
("narrative", "structured"),
|
||||||
|
("narrative", "weighted"),
|
||||||
|
("narrative", "emphasized"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_token(text: str, token: str) -> bool:
|
||||||
|
return re.search(rf"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])", text) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def expect(ok: bool, message: str) -> None:
|
||||||
|
if not ok:
|
||||||
|
raise SystemExit(f"FAIL: {message}")
|
||||||
|
print(f"OK {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def header(token: str) -> dict:
|
||||||
|
return {"X-Auth-Token": token}
|
||||||
|
|
||||||
|
|
||||||
|
def intern_of(payload: dict) -> str:
|
||||||
|
for stage in (payload.get("trace") or {}).get("stages") or []:
|
||||||
|
if stage.get("purpose") == "journal_generate":
|
||||||
|
return stage.get("intern") or ""
|
||||||
|
return (payload.get("trace") or {}).get("intern") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def sources_block(intern: str) -> str:
|
||||||
|
parts = (intern or "").split("\nCURRENT_DAY_SOURCES\n")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return intern or ""
|
||||||
|
rest = parts[-1]
|
||||||
|
for marker in ("\nEXISTING_TEXT\n", "\nAUSGABE\n"):
|
||||||
|
if marker in rest:
|
||||||
|
rest = rest.split(marker, 1)[0]
|
||||||
|
break
|
||||||
|
return rest
|
||||||
|
|
||||||
|
|
||||||
|
def seed_item(slot: str, key: str) -> dict:
|
||||||
|
for item in (SEED.get("slots") or {}).get(slot, {}).get("variants") or []:
|
||||||
|
if item.get("guideline_key") == key:
|
||||||
|
return item
|
||||||
|
raise SystemExit(f"FAIL: missing seed {slot}/{key}")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_id(slot: str, key: str) -> str:
|
||||||
|
return seed_item(slot, key)["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_instruction(slot: str, key: str) -> str:
|
||||||
|
return seed_item(slot, key).get("instruction") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def selection_of(**keys: str) -> dict[str, str]:
|
||||||
|
return {SLOT_TO_ID_KEY[slot]: seed_id(slot, key) for slot, key in keys.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def default_ids() -> dict[str, str]:
|
||||||
|
return selection_of(
|
||||||
|
transformation="substantial",
|
||||||
|
detail="complete",
|
||||||
|
voice="clear",
|
||||||
|
narrative="weighted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def low_ids() -> dict[str, str]:
|
||||||
|
return selection_of(
|
||||||
|
transformation="correction",
|
||||||
|
detail="compact",
|
||||||
|
voice="neutral",
|
||||||
|
narrative="chronicle",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def high_ids() -> dict[str, str]:
|
||||||
|
return selection_of(
|
||||||
|
transformation="substantial",
|
||||||
|
detail="complete",
|
||||||
|
voice="clear",
|
||||||
|
narrative="emphasized",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def selection_meta(payload: dict) -> dict:
|
||||||
|
return (payload.get("trace") or {}).get("generation_selection") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_has_no_numeric_policy() -> None:
|
||||||
|
files = [
|
||||||
|
ROOT / "journal_generation_policy.py",
|
||||||
|
ROOT / "journal_generate.py",
|
||||||
|
ROOT / "routers" / "journal.py",
|
||||||
|
ROOT / "routers" / "generation_instructions.py",
|
||||||
|
FRONTEND / "src" / "pages" / "JournalDayPage.jsx",
|
||||||
|
FRONTEND / "src" / "pages" / "AdminGenerationPage.jsx",
|
||||||
|
]
|
||||||
|
for path in files:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for token in BANNED_RUNTIME_TOKENS:
|
||||||
|
if token == "generation_policy" and path.name == "journal_generation_policy.py":
|
||||||
|
continue
|
||||||
|
if token == "generation_policy" and path.name == "generation_instructions.py":
|
||||||
|
continue
|
||||||
|
expect(not has_token(text, token), f"{path.name} has no {token}")
|
||||||
|
day = (FRONTEND / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8")
|
||||||
|
expect('type="range"' not in day, "journal page has no sliders")
|
||||||
|
expect("policy-slider" not in day, "journal page has no slider class")
|
||||||
|
expect("SLOT_SELECTS" in day, "journal page names the four selects")
|
||||||
|
expect(day.count("slot:") >= 4, "journal page has four independent slots")
|
||||||
|
expect("<select" in day, "journal page uses select fields")
|
||||||
|
expect("Diese Auswahl als Standard merken" in day, "journal page can remember explicitly")
|
||||||
|
expect("Faktenregeln und Datenschutz bleiben unabhängig" in day, "facts/privacy note remains")
|
||||||
|
editor = (FRONTEND / "src" / "pages" / "JournalEditorPage.jsx").read_text(encoding="utf-8")
|
||||||
|
expect("generation_summary" in editor, "editor can show the generation snapshot")
|
||||||
|
admin = (FRONTEND / "src" / "pages" / "AdminGenerationPage.jsx").read_text(encoding="utf-8")
|
||||||
|
expect("<textarea" not in admin.split("generation-admin-editor")[0], "admin overview has no textareas")
|
||||||
|
expect(admin.count("<textarea") == 1, "admin opens one instruction editor at a time")
|
||||||
|
expect("Öffnen" in admin, "admin overview has open action")
|
||||||
|
expect("backend/config/" not in admin, "admin copy does not advertise seed file paths")
|
||||||
|
expect("Quellenmodus" not in admin, "admin has no source-mode management")
|
||||||
|
expect("source_mode" not in admin, "admin source has no source_mode slot")
|
||||||
|
expect("prose_edit" not in admin, "admin has no prose_edit control")
|
||||||
|
expect("notes_to_journal" not in admin, "admin has no notes_to_journal control")
|
||||||
|
expect("Quellenmodus" not in day, "journal page has no source-mode control")
|
||||||
|
expect("source_mode" not in day, "journal page has no source_mode field")
|
||||||
|
expect("editorial_mode" not in day, "journal page has no editorial_mode field")
|
||||||
|
trace_ui = (FRONTEND / "src" / "components" / "CallTrace.jsx").read_text(encoding="utf-8")
|
||||||
|
expect("Editorial Mode" not in trace_ui, "trace UI does not present source mode as a run decision")
|
||||||
|
expect("source_mode" not in trace_ui, "trace UI has no source_mode label")
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_and_compiler() -> None:
|
||||||
|
init_db()
|
||||||
|
for rel in ("journal_generation_policy.py", "journal_editorial.py"):
|
||||||
|
source = (ROOT / rel).read_text(encoding="utf-8")
|
||||||
|
for phrase in BANNED_CODE_PHRASES:
|
||||||
|
expect(phrase not in source, f"{rel} does not hardcode {phrase!r}")
|
||||||
|
items = list_guidelines(PURPOSE_JOURNAL, include_instruction=True)
|
||||||
|
got = {(item["slot"], item["guideline_key"]) for item in items}
|
||||||
|
expect(REQUIRED_KEYS <= got, "seed creates every required guideline")
|
||||||
|
expect("source_mode" not in (SEED.get("slots") or {}), "seed document has no source_mode slot")
|
||||||
|
expect(sum(1 for item in items if item["slot"] == "source_mode") == 0, "source modes are not seeded")
|
||||||
|
expect(all(item["status"] == "active" for item in items if item["is_system_seed"]), "seed rows start active")
|
||||||
|
|
||||||
|
defaults = compile_selection(default_selection_ids())
|
||||||
|
expect(defaults.ids["transformation"] == seed_id("transformation", "substantial"), "default transformation is substantial")
|
||||||
|
expect(
|
||||||
|
defaults.instructions["transformation_instructions"] == seed_instruction("transformation", "substantial"),
|
||||||
|
"default transformation instruction matches the seed",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
defaults.instructions["detail_instructions"] == seed_instruction("detail", "complete"),
|
||||||
|
"default detail instruction matches the seed",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
defaults.instructions["voice_instructions"] == seed_instruction("voice", "clear"),
|
||||||
|
"default voice instruction matches the seed",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
defaults.instructions["narrative_instructions"] == seed_instruction("narrative", "weighted"),
|
||||||
|
"default narrative instruction matches the seed",
|
||||||
|
)
|
||||||
|
expect(defaults.seed_revision == SEED["seed_revision"], "compiled policy names the seed revision")
|
||||||
|
expect("source_mode_instructions" not in defaults.instructions, "compiler has no source-mode instruction")
|
||||||
|
|
||||||
|
low = compile_selection(low_ids())
|
||||||
|
high = compile_selection(high_ids())
|
||||||
|
expect(low.keys["transformation"] == "correction", "low transformation is named")
|
||||||
|
expect(high.keys["narrative"] == "emphasized", "high narrative is named")
|
||||||
|
expect("source_mode_instructions" not in low.instructions, "low compile has no source mode")
|
||||||
|
expect("source_mode_instructions" not in high.instructions, "high compile has no source mode")
|
||||||
|
expect("{{" not in "".join(low.instructions.values()), "compiled instructions contain no placeholders")
|
||||||
|
|
||||||
|
for bad in (None, [], "75", True, {"transformation_strength": 75}, {"transformation_id": seed_id("transformation", "substantial")}):
|
||||||
|
try:
|
||||||
|
validate_selection(bad)
|
||||||
|
expect(False, "incomplete or numeric selection must be rejected")
|
||||||
|
except GenerationPolicyError as exc:
|
||||||
|
expect(exc.code == "invalid_generation_selection", "rejection uses invalid_generation_selection")
|
||||||
|
|
||||||
|
draft = create_guideline(
|
||||||
|
PURPOSE_JOURNAL,
|
||||||
|
"transformation",
|
||||||
|
{
|
||||||
|
"guideline_key": "drafty",
|
||||||
|
"label": "Entwurf",
|
||||||
|
"summary": "nicht wählbar",
|
||||||
|
"instruction": "Nur ein Draft.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_selection({**default_ids(), "transformation_id": draft["id"]})
|
||||||
|
expect(False, "drafts must not be selectable")
|
||||||
|
except GenerationPolicyError:
|
||||||
|
expect(True, "drafts are not selectable")
|
||||||
|
|
||||||
|
cloned = clone_guideline(seed_id("transformation", "correction"))
|
||||||
|
original = get_guideline(seed_id("transformation", "correction"), include_instruction=True)
|
||||||
|
update_guideline(
|
||||||
|
cloned["id"],
|
||||||
|
{
|
||||||
|
"guideline_key": original["guideline_key"],
|
||||||
|
"label": original["label"],
|
||||||
|
"summary": original["summary"],
|
||||||
|
"instruction": "CHANGED CLONE TEXT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
get_guideline(original["id"], include_instruction=True)["instruction"] == original["instruction"],
|
||||||
|
"cloning does not change the original",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
update_guideline(original["id"], {"label": "still active", "instruction": original["instruction"], "guideline_key": original["guideline_key"]})
|
||||||
|
expect(False, "published guidelines must be immutable")
|
||||||
|
except CatalogError as exc:
|
||||||
|
expect(exc.code == "guideline_immutable", "published guidelines are not silently overwritten")
|
||||||
|
|
||||||
|
published_clone = publish_guideline(cloned["id"])
|
||||||
|
archived = archive_guideline(published_clone["id"])
|
||||||
|
expect(archived["status"] == "archived", "published clones can be archived")
|
||||||
|
try:
|
||||||
|
validate_selection({**default_ids(), "transformation_id": published_clone["id"]})
|
||||||
|
expect(False, "archived guidelines must not be selectable for new runs")
|
||||||
|
except GenerationPolicyError:
|
||||||
|
expect(True, "archived guidelines are not selectable for new runs")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
seed_generation_instructions(conn)
|
||||||
|
expect(
|
||||||
|
get_guideline(original["id"], include_instruction=True)["instruction"] == original["instruction"],
|
||||||
|
"seed does not overwrite published guidelines",
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generation_guidelines (
|
||||||
|
id, purpose, slot, guideline_key, label, summary, instruction, sort_order,
|
||||||
|
status, revision, cloned_from, is_default, is_system_seed, seed_id, seed_revision,
|
||||||
|
created, updated
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'legacy-source-mode', ?, 'source_mode', 'prose_edit', 'Fließtext', '',
|
||||||
|
'LEGACY MODE TEXT', 0, 'active', 1, NULL, 0, 0, '', '', datetime('now'), datetime('now')
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(PURPOSE_JOURNAL,),
|
||||||
|
)
|
||||||
|
seed_generation_instructions(conn)
|
||||||
|
archived_legacy = conn.execute(
|
||||||
|
"SELECT status FROM generation_guidelines WHERE id = 'legacy-source-mode'"
|
||||||
|
).fetchone()
|
||||||
|
expect(archived_legacy["status"] == "archived", "persisted source modes are archived")
|
||||||
|
expect("source_mode" not in overview_payload()["slots"], "admin overview hides archived source modes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_contract() -> None:
|
||||||
|
init_db()
|
||||||
|
prompt = load_active_prompt("mvp.journal_generate")
|
||||||
|
text = prompt.get("template") or ""
|
||||||
|
expect(len(text) < PREVIOUS_PROMPT_CHARS, "new prompt is shorter than the previous default")
|
||||||
|
print(f"OK prompt length {len(text)} after, {PREVIOUS_PROMPT_CHARS} before")
|
||||||
|
for key in (
|
||||||
|
"{{transformation_instructions}}",
|
||||||
|
"{{detail_instructions}}",
|
||||||
|
"{{voice_instructions}}",
|
||||||
|
"{{narrative_instructions}}",
|
||||||
|
"{{writing_profile}}",
|
||||||
|
"{{style_examples}}",
|
||||||
|
"{{reconstruction}}",
|
||||||
|
"{{existing_text}}",
|
||||||
|
):
|
||||||
|
expect(key in text, f"prompt contains {key}")
|
||||||
|
expect("{{source_mode_instructions}}" not in text, "prompt has no source-mode placeholder")
|
||||||
|
expect("{{editorial_mode}}" not in text, "prompt has no editorial_mode placeholder")
|
||||||
|
expect("{{editorial_instructions}}" not in text, "prompt has no editorial_instructions placeholder")
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in text, "prompt contains the mixed-source instruction")
|
||||||
|
for fact in HARD_FACTS:
|
||||||
|
expect(fact in text, f"hard fact rule remains: {fact}")
|
||||||
|
expect("ich gieng zum laden" not in text, "no synthetic prose example")
|
||||||
|
expect("nachbarhund im garten" not in text, "no synthetic notes example")
|
||||||
|
expect("EDITORIAL_MODE" not in text, "source mode is not a user-facing prompt heading")
|
||||||
|
expect(GENERATE_SEED_REVISION == "2026-08-27-journal-mixed-sources-v1", "seed revision constant tracks mixed sources")
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
||||||
|
("mvp.journal_generate",),
|
||||||
|
).fetchone()
|
||||||
|
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "untouched default is updated to mixed sources")
|
||||||
|
expect(row["template"] == row["default_template"], "untouched template matches the seed")
|
||||||
|
expect("source_mode" not in (SEED.get("slots") or {}), "source modes stay out of the seed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unattested_alias() -> None:
|
||||||
|
mappings = [
|
||||||
|
{
|
||||||
|
"local_label": "Clarissa",
|
||||||
|
"canonical_label": "Clarissa",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"aliases": ["Sushi"],
|
||||||
|
"demask_label": "Clarissa",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
unattested_journal_content(
|
||||||
|
"Clarissa kam ins Wohnzimmer.",
|
||||||
|
["Heute war Sushi im Wohnzimmer."],
|
||||||
|
mappings,
|
||||||
|
["PERSON:01"],
|
||||||
|
)
|
||||||
|
is None,
|
||||||
|
"canonical demask of an attested alias is not unattested_identity",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
unattested_journal_content(
|
||||||
|
"Hanna kam ins Wohnzimmer.",
|
||||||
|
["Heute war Sushi im Wohnzimmer."],
|
||||||
|
mappings + [{"local_label": "Hanna", "token": "PERSON:99"}],
|
||||||
|
["PERSON:01"],
|
||||||
|
)
|
||||||
|
== "unattested_identity",
|
||||||
|
"a different person remains unattested",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
test_runtime_has_no_numeric_policy()
|
||||||
|
test_seed_and_compiler()
|
||||||
|
test_prompt_contract()
|
||||||
|
test_unattested_alias()
|
||||||
|
|
||||||
|
reset_debug()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
setup = client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"email": "ada@example.test", "name": "Ada", "password": "test-pass"},
|
||||||
|
)
|
||||||
|
headers = header(setup.json()["token"])
|
||||||
|
profile_id = setup.json()["profile_id"]
|
||||||
|
|
||||||
|
settings = client.get("/api/journal/generation-settings", headers=headers)
|
||||||
|
expect(settings.status_code == 200, f"generation-settings {settings.text}")
|
||||||
|
body = settings.json()
|
||||||
|
expect(body["selection"] == default_ids(), "profile defaults are the named system standards")
|
||||||
|
expect(set(body["selection"]) == set(SELECTION_KEYS), "settings expose the four guideline ids")
|
||||||
|
for slot, options in body["options"].items():
|
||||||
|
expect(options, f"{slot} has active options")
|
||||||
|
expect(all("instruction" not in item for item in options), f"{slot} options hide prompt text")
|
||||||
|
expect(all(item["id"] != "draft" for item in options), f"{slot} options are real ids")
|
||||||
|
stored = get_or_create_selection(profile_id)
|
||||||
|
expect({key: stored[key] for key in SELECTION_KEYS} == default_ids(), "persisted defaults match system standards")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO profiles (id, email, name, password_hash, role) VALUES (?, ?, ?, ?, 'user')",
|
||||||
|
("other-profile", "other@example.test", "Other", "x"),
|
||||||
|
)
|
||||||
|
save_selection("other-profile", high_ids())
|
||||||
|
save_selection(profile_id, low_ids())
|
||||||
|
expect(load_selection(profile_id)["transformation_id"] == seed_id("transformation", "correction"), "profile A keeps its own selection")
|
||||||
|
expect(load_selection("other-profile")["transformation_id"] == seed_id("transformation", "substantial"), "profile B is isolated")
|
||||||
|
save_selection(profile_id, default_ids())
|
||||||
|
|
||||||
|
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Policy"})
|
||||||
|
day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-27"},
|
||||||
|
)
|
||||||
|
conv = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Tag"},
|
||||||
|
)
|
||||||
|
turn = client.post(
|
||||||
|
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "Ich war am Markt. Vielleicht bleibe ich kürzer. Brot holen wollte ich noch, habe es aber nicht gemacht."},
|
||||||
|
)
|
||||||
|
expect(turn.status_code == 200, f"turn {turn.status_code}")
|
||||||
|
|
||||||
|
rejected = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv.json()["id"]], "generation_selection": {"transformation_id": "missing"}},
|
||||||
|
)
|
||||||
|
expect(rejected.status_code == 400, "unknown selection is rejected")
|
||||||
|
expect((rejected.json().get("detail") or {}).get("code") == "invalid_generation_selection", "rejection uses the selection code")
|
||||||
|
after_reject = client.get("/api/journal/generation-settings", headers=headers).json()
|
||||||
|
expect(after_reject["selection"] == default_ids(), "rejected values are not stored")
|
||||||
|
|
||||||
|
draft = client.post(
|
||||||
|
"/api/admin/generation-instructions/journal_generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"slot": "voice",
|
||||||
|
"guideline_key": "hidden_draft",
|
||||||
|
"label": "versteckt",
|
||||||
|
"summary": "Draft",
|
||||||
|
"instruction": "Nicht für neue Läufe.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(draft.status_code == 200, f"admin draft {draft.text}")
|
||||||
|
blocked_draft = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"conversation_ids": [conv.json()["id"]],
|
||||||
|
"generation_selection": {**default_ids(), "voice_id": draft.json()["id"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(blocked_draft.status_code == 400, "drafts are rejected for new runs")
|
||||||
|
|
||||||
|
reset_debug()
|
||||||
|
recorder = install_test_recorder()
|
||||||
|
snapshot_run = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"conversation_ids": [conv.json()["id"]],
|
||||||
|
"generation_selection": low_ids(),
|
||||||
|
"remember_generation_selection": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(snapshot_run.status_code == 200, f"low generate {snapshot_run.text}")
|
||||||
|
intern = intern_of(snapshot_run.json())
|
||||||
|
meta = selection_meta(snapshot_run.json())
|
||||||
|
expect(meta.get("source") == "request", "trace names the request snapshot")
|
||||||
|
expect(meta.get("remembered") is False, "unremembered snapshot is marked")
|
||||||
|
expect(meta.get("ids") == {
|
||||||
|
"transformation": seed_id("transformation", "correction"),
|
||||||
|
"detail": seed_id("detail", "compact"),
|
||||||
|
"voice": seed_id("voice", "neutral"),
|
||||||
|
"narrative": seed_id("narrative", "chronicle"),
|
||||||
|
}, "trace keeps the used ids")
|
||||||
|
expect(meta.get("keys", {}).get("transformation") == "correction", "trace keeps guideline keys")
|
||||||
|
expect(meta.get("revisions", {}).get("transformation") == 1, "trace keeps revisions")
|
||||||
|
expect("editorial_mode" not in meta, "trace has no abandoned editorial mode")
|
||||||
|
expect("source_mode" not in meta, "trace has no abandoned source mode")
|
||||||
|
expect("Korrigiere nur" not in str(meta), "trace does not store compiled instruction text")
|
||||||
|
expect(
|
||||||
|
client.get("/api/journal/generation-settings", headers=headers).json()["selection"] == default_ids(),
|
||||||
|
"unremembered snapshot does not change profile defaults",
|
||||||
|
)
|
||||||
|
snapshot = snapshot_run.json().get("generation_snapshot") or {}
|
||||||
|
expect(snapshot.get("transformation", {}).get("key") == "correction", "draft stores transformation key")
|
||||||
|
expect(snapshot.get("prompt_slug") == "mvp.journal_generate", "draft stores prompt slug")
|
||||||
|
expect("instruction" not in str(snapshot), "draft snapshot omits instruction text")
|
||||||
|
expect("source_mode" not in snapshot, "draft snapshot has no source_mode")
|
||||||
|
expect("editorial_mode" not in snapshot, "draft snapshot has no editorial_mode")
|
||||||
|
summary = snapshot_run.json().get("generation_summary") or ""
|
||||||
|
expect(summary.startswith("Erzeugt mit:"), "draft summary is user-readable")
|
||||||
|
expect("Quellenmodus:" not in summary, "draft summary does not name a source mode")
|
||||||
|
expect("{{transformation_instructions}}" not in intern, "transformation placeholder is resolved")
|
||||||
|
expect("{{detail_instructions}}" not in intern, "detail placeholder is resolved")
|
||||||
|
expect("{{voice_instructions}}" not in intern, "voice placeholder is resolved")
|
||||||
|
expect("{{narrative_instructions}}" not in intern, "narrative placeholder is resolved")
|
||||||
|
expect("{{source_mode_instructions}}" not in intern, "source-mode placeholder is absent")
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in intern, "mixed-source instruction reaches the prompt")
|
||||||
|
expect(seed_instruction("transformation", "correction") in intern, "selected transformation reaches the prompt")
|
||||||
|
expect(seed_instruction("detail", "compact") in intern, "selected detail reaches the prompt")
|
||||||
|
expect(seed_instruction("voice", "neutral") in intern, "selected voice reaches the prompt")
|
||||||
|
expect(seed_instruction("narrative", "chronicle") in intern, "selected narrative reaches the prompt")
|
||||||
|
expect([item.get("purpose") for item in recorder] == ["journal_generate"], "exactly one generate call")
|
||||||
|
expect(sum(1 for item in (snapshot_run.json().get("run_log") or []) if item.get("kind") == "model_call") == 1, "run log records one model call")
|
||||||
|
expect(
|
||||||
|
not any("Korrigiere nur Rechtschreibung" in str(item) for item in (snapshot_run.json().get("run_log") or [])),
|
||||||
|
"run log omits compiled prompt instructions",
|
||||||
|
)
|
||||||
|
for fact in HARD_FACTS:
|
||||||
|
expect(fact in intern, f"hard fact remains at low policy: {fact}")
|
||||||
|
|
||||||
|
notes_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-28"},
|
||||||
|
)
|
||||||
|
notes_conv = client.post(
|
||||||
|
f"/api/journal/days/{notes_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Notizen"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{notes_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "- Markt\n- Brot nicht geholt\n- vielleicht kürzer bleiben"},
|
||||||
|
)
|
||||||
|
notes_run = client.post(
|
||||||
|
f"/api/journal/days/{notes_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [notes_conv.json()["id"]], "generation_selection": default_ids()},
|
||||||
|
)
|
||||||
|
expect(notes_run.status_code == 200, f"notes generate {notes_run.text}")
|
||||||
|
notes_intern = intern_of(notes_run.json())
|
||||||
|
notes_snap = notes_run.json().get("generation_snapshot") or {}
|
||||||
|
expect("source_mode" not in notes_snap, "notes snapshot has no source mode")
|
||||||
|
expect("editorial_mode" not in (notes_run.json().get("trace") or {}), "notes trace has no editorial mode")
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in notes_intern, "notes use the unified mixed-source instruction")
|
||||||
|
expect("Markt" in sources_block(notes_intern), "notes sources remain complete")
|
||||||
|
|
||||||
|
def generate_shapes(date: str, bodies: list[str], label: str) -> None:
|
||||||
|
shaped_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": date},
|
||||||
|
)
|
||||||
|
shaped_conv = client.post(
|
||||||
|
f"/api/journal/days/{shaped_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": label},
|
||||||
|
)
|
||||||
|
for body in bodies:
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{shaped_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
|
reset_debug()
|
||||||
|
rec = install_test_recorder()
|
||||||
|
run = client.post(
|
||||||
|
f"/api/journal/days/{shaped_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [shaped_conv.json()["id"]], "generation_selection": default_ids()},
|
||||||
|
)
|
||||||
|
expect(run.status_code == 200, f"{label} generate {run.text}")
|
||||||
|
shaped_intern = intern_of(run.json())
|
||||||
|
shaped_sources = sources_block(shaped_intern)
|
||||||
|
shaped_snap = run.json().get("generation_snapshot") or {}
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in shaped_intern, f"{label} has mixed-source instruction")
|
||||||
|
expect("{{source_mode_instructions}}" not in shaped_intern, f"{label} has no source-mode placeholder")
|
||||||
|
expect("source_mode" not in shaped_snap, f"{label} snapshot has no source_mode")
|
||||||
|
expect("editorial_mode" not in shaped_snap, f"{label} snapshot has no editorial_mode")
|
||||||
|
expect("editorial_mode" not in (run.json().get("trace") or {}), f"{label} trace has no editorial_mode")
|
||||||
|
expect([item.get("purpose") for item in rec] == ["journal_generate"], f"{label} is exactly one generate call")
|
||||||
|
for body in bodies:
|
||||||
|
for line in body.splitlines():
|
||||||
|
token = line.lstrip("- ").strip()
|
||||||
|
if token:
|
||||||
|
expect(token in shaped_sources, f"{label} keeps source {token!r}")
|
||||||
|
|
||||||
|
generate_shapes("2026-08-11", ["Ich ging zum Markt. Es war voll. Vielleicht bleibe ich kürzer."], "prose-only")
|
||||||
|
generate_shapes("2026-08-12", ["- Kirschen\n- Brot nicht geholt\n- später Hafen"], "notes-only")
|
||||||
|
generate_shapes(
|
||||||
|
"2026-08-13",
|
||||||
|
["Ich ging zum Markt. Es war voll.", "- Kirschen\n- später Hafen"],
|
||||||
|
"prose-then-notes",
|
||||||
|
)
|
||||||
|
generate_shapes(
|
||||||
|
"2026-08-14",
|
||||||
|
["- Kirschen\n- später Hafen", "Ich ging zum Markt. Es war voll."],
|
||||||
|
"notes-then-prose",
|
||||||
|
)
|
||||||
|
generate_shapes(
|
||||||
|
"2026-08-15",
|
||||||
|
["Ich ging zum Markt. Es war voll.\n- Kirschen\n- später Hafen"],
|
||||||
|
"mixed-in-message",
|
||||||
|
)
|
||||||
|
|
||||||
|
high_run = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"conversation_ids": [conv.json()["id"]],
|
||||||
|
"generation_selection": high_ids(),
|
||||||
|
"remember_generation_selection": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(high_run.status_code == 200, f"high generate {high_run.text}")
|
||||||
|
high_intern = intern_of(high_run.json())
|
||||||
|
expect("Überarbeite den Text substanziell" in high_intern, "high transformation reaches the prompt")
|
||||||
|
expect("Korrigiere nur Rechtschreibung" not in high_intern, "low and high prompt blocks differ")
|
||||||
|
expect(intern != high_intern, "low and high snapshots render different prompt blocks")
|
||||||
|
expect(selection_meta(high_run.json()).get("remembered") is True, "remembered snapshot is marked")
|
||||||
|
remembered = client.get("/api/journal/generation-settings", headers=headers).json()
|
||||||
|
expect(remembered["selection"] == high_ids(), "remembered snapshot becomes the profile default")
|
||||||
|
for fact in HARD_FACTS:
|
||||||
|
expect(fact in high_intern, f"hard fact remains at high policy: {fact}")
|
||||||
|
|
||||||
|
profile_run = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
expect(profile_run.status_code == 200, f"profile generate {profile_run.text}")
|
||||||
|
expect(selection_meta(profile_run.json()).get("source") == "profile", "omitted snapshot uses stored values")
|
||||||
|
expect(seed_instruction("narrative", "emphasized") in intern_of(profile_run.json()), "stored high narrative is reused")
|
||||||
|
|
||||||
|
confirm_identity(profile_id, "Clarissa", aliases=["Sushi"])
|
||||||
|
alias_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-21"},
|
||||||
|
)
|
||||||
|
alias_conv = client.post(
|
||||||
|
f"/api/journal/days/{alias_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Alias"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{alias_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "Sushi kam ins Wohnzimmer."},
|
||||||
|
)
|
||||||
|
|
||||||
|
def canonical_reply(_messages, _policy):
|
||||||
|
return ChatResult(
|
||||||
|
content="Wohnzimmer\n\nClarissa kam ins Wohnzimmer.",
|
||||||
|
model="fake",
|
||||||
|
usage={},
|
||||||
|
context_compression="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.complete_model", canonical_reply):
|
||||||
|
alias_out = client.post(
|
||||||
|
f"/api/journal/days/{alias_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [alias_conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
expect(alias_out.status_code == 200, f"confirmed alias generate {alias_out.text}")
|
||||||
|
expect("Clarissa" in (alias_out.json().get("body") or ""), "canonical spelling is kept after demask")
|
||||||
|
expect((alias_out.json().get("trace") or {}).get("model_text_accepted") is True, "alias demask is not rejected as unattested")
|
||||||
|
|
||||||
|
remember_mapping(profile_id, "Hanna", "PERSON:99")
|
||||||
|
|
||||||
|
def invent(_messages, _policy):
|
||||||
|
return ChatResult(
|
||||||
|
content="Hanna am Hafen\n\nIch war am Markt.",
|
||||||
|
model="fake",
|
||||||
|
usage={},
|
||||||
|
context_compression="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.complete_model", invent):
|
||||||
|
blocked = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
|
)
|
||||||
|
expect(blocked.status_code == 409, "unattested person is still rejected")
|
||||||
|
expect((blocked.json().get("detail") or {}).get("code") == "journal_generation_not_accepted", "409 provenance rule remains")
|
||||||
|
|
||||||
|
created_user = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=headers,
|
||||||
|
json={"email": "policy-user@example.test", "name": "User", "password": "user-pass", "role": "user"},
|
||||||
|
)
|
||||||
|
expect(created_user.status_code == 200, f"create user {created_user.text}")
|
||||||
|
user_login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": "policy-user@example.test", "password": "user-pass"},
|
||||||
|
)
|
||||||
|
user_headers = header(user_login.json()["token"])
|
||||||
|
denied = client.get("/api/admin/generation-instructions/journal_generate", headers=user_headers)
|
||||||
|
expect(denied.status_code == 403, "admin catalog is protected")
|
||||||
|
denied_put = client.put(
|
||||||
|
"/api/admin/generation-instructions/journal_generate/" + seed_id("transformation", "substantial"),
|
||||||
|
headers=user_headers,
|
||||||
|
json={"label": "nein"},
|
||||||
|
)
|
||||||
|
expect(denied_put.status_code == 403, "admin catalog write is protected")
|
||||||
|
|
||||||
|
catalog = client.get("/api/admin/generation-instructions/journal_generate", headers=headers)
|
||||||
|
expect(catalog.status_code == 200, f"admin catalog {catalog.text}")
|
||||||
|
expect("source_mode" not in catalog.json()["slots"], "admin catalog has no source-mode slot")
|
||||||
|
expect("instruction" not in str(catalog.json()["slots"]["transformation"]), "admin overview omits prompt bodies")
|
||||||
|
cloned = client.post(
|
||||||
|
f"/api/admin/generation-instructions/journal_generate/{seed_id('transformation', 'substantial')}/clone",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
expect(cloned.status_code == 200, f"clone {cloned.text}")
|
||||||
|
saved = client.put(
|
||||||
|
f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"guideline_key": "substantial",
|
||||||
|
"label": "substanziell",
|
||||||
|
"summary": "Klon",
|
||||||
|
"instruction": "ADMIN_CUSTOM_TRANSFORMATION_BLOCK",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(saved.status_code == 200, f"admin draft save {saved.text}")
|
||||||
|
published = client.post(
|
||||||
|
f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}/publish",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
expect(published.status_code == 200, f"publish {published.text}")
|
||||||
|
immutable = client.put(
|
||||||
|
f"/api/admin/generation-instructions/journal_generate/{seed_id('transformation', 'substantial')}",
|
||||||
|
headers=headers,
|
||||||
|
json={"label": "überschreiben", "instruction": "should not stick", "guideline_key": "substantial"},
|
||||||
|
)
|
||||||
|
expect(immutable.status_code == 409, "active guidelines cannot be overwritten")
|
||||||
|
preview = client.post(
|
||||||
|
"/api/admin/generation-instructions/journal_generate/preview",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(preview.status_code == 200, f"admin preview {preview.text}")
|
||||||
|
expect(preview.json()["transformation_instructions"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", "preview compiles locally")
|
||||||
|
expect("(nicht enthalten)" in (preview.json().get("rendered") or ""), "preview has no personal sources")
|
||||||
|
expect("{{transformation_instructions}}" not in (preview.json().get("rendered") or ""), "preview resolves placeholders")
|
||||||
|
|
||||||
|
custom_run = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"conversation_ids": [conv.json()["id"]],
|
||||||
|
"generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]},
|
||||||
|
"remember_generation_selection": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(custom_run.status_code == 200, f"custom generate {custom_run.text}")
|
||||||
|
expect("ADMIN_CUSTOM_TRANSFORMATION_BLOCK" in intern_of(custom_run.json()), "admin change reaches the next rendered prompt")
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
after_seed = overview_payload()
|
||||||
|
substantial = next(
|
||||||
|
item for item in after_seed["slots"]["transformation"] if item["id"] == seed_id("transformation", "substantial")
|
||||||
|
)
|
||||||
|
expect(get_guideline(substantial["id"], include_instruction=True)["instruction"] == seed_instruction("transformation", "substantial"), "seed does not overwrite the original")
|
||||||
|
expect(get_guideline(cloned.json()["id"], include_instruction=True)["instruction"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK", "seed does not overwrite admin clones")
|
||||||
|
|
||||||
|
restored = client.post(
|
||||||
|
"/api/admin/generation-instructions/journal_generate/reset",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
expect(restored.status_code == 200, f"reset {restored.text}")
|
||||||
|
drafts = [item for item in restored.json()["slots"]["transformation"] if item["status"] == "draft"]
|
||||||
|
expect(drafts, "reset adds seed drafts")
|
||||||
|
expect(
|
||||||
|
get_guideline(cloned.json()["id"], include_instruction=True)["instruction"] == "ADMIN_CUSTOM_TRANSFORMATION_BLOCK",
|
||||||
|
"reset does not overwrite historical variants",
|
||||||
|
)
|
||||||
|
|
||||||
|
archived = client.post(
|
||||||
|
f"/api/admin/generation-instructions/journal_generate/{cloned.json()['id']}/archive",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
expect(archived.status_code == 200, f"archive {archived.text}")
|
||||||
|
archived_run = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"conversation_ids": [conv.json()["id"]],
|
||||||
|
"generation_selection": {**high_ids(), "transformation_id": cloned.json()["id"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(archived_run.status_code == 400, "archived guidelines are rejected for new runs")
|
||||||
|
|
||||||
|
fresh_day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-16"},
|
||||||
|
)
|
||||||
|
fresh_conv = client.post(
|
||||||
|
f"/api/journal/days/{fresh_day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Legacy"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/journal/conversations/{fresh_conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": "Ich war am Markt."},
|
||||||
|
)
|
||||||
|
with get_db() as conn:
|
||||||
|
current_template = conn.execute(
|
||||||
|
"SELECT template FROM ai_prompts WHERE slug = ?",
|
||||||
|
("mvp.journal_generate",),
|
||||||
|
).fetchone()["template"]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ai_prompts SET template = ? WHERE slug = ?",
|
||||||
|
((current_template or "") + "\n{{source_mode_instructions}}\n", "mvp.journal_generate"),
|
||||||
|
)
|
||||||
|
reset_debug()
|
||||||
|
legacy_recorder = install_test_recorder()
|
||||||
|
legacy = client.post(
|
||||||
|
f"/api/journal/days/{fresh_day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [fresh_conv.json()["id"]], "generation_selection": default_ids()},
|
||||||
|
)
|
||||||
|
expect(legacy.status_code == 409, "legacy custom prompt is refused before the provider")
|
||||||
|
legacy_detail = legacy.json().get("detail") or {}
|
||||||
|
expect(legacy_detail.get("code") == "prompt_contract_incompatible", "legacy prompt uses prompt_contract_incompatible")
|
||||||
|
expect("veralteten Platzhalter" in (legacy_detail.get("message") or ""), "admin hint names the retired placeholder")
|
||||||
|
expect(legacy_recorder == [], "legacy custom prompt does not call the provider")
|
||||||
|
expect(current_draft(profile_id, fresh_day.json()["day"]["id"]) is None, "legacy custom prompt inserts no raw draft")
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ai_prompts SET template = default_template WHERE slug = ?",
|
||||||
|
("mvp.journal_generate",),
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET instruction = ''
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(seed_id("transformation", "substantial"),),
|
||||||
|
)
|
||||||
|
reset_debug()
|
||||||
|
broken_recorder = install_test_recorder()
|
||||||
|
broken = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv.json()["id"]], "generation_selection": default_ids()},
|
||||||
|
)
|
||||||
|
expect(broken.status_code == 409, "invalid catalog refuses generate")
|
||||||
|
expect((broken.json().get("detail") or {}).get("code") == "generation_policy_invalid", "invalid catalog uses generation_policy_invalid")
|
||||||
|
expect(broken_recorder == [], "invalid catalog does not call the provider")
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE generation_guidelines
|
||||||
|
SET instruction = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(seed_instruction("transformation", "substantial"), seed_id("transformation", "substantial")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ai_prompts SET template = 'CUSTOM POLICY PROMPT {{reconstruction}}' WHERE slug = ?",
|
||||||
|
("mvp.journal_generate",),
|
||||||
|
)
|
||||||
|
init_db()
|
||||||
|
custom = load_active_prompt("mvp.journal_generate")
|
||||||
|
expect(custom["template"] == "CUSTOM POLICY PROMPT {{reconstruction}}", "independently edited prompt is not overwritten")
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
|
||||||
|
("mvp.journal_generate",),
|
||||||
|
).fetchone()
|
||||||
|
expect("{{transformation_instructions}}" in (row["default_template"] or ""), "default template still tracks mixed sources")
|
||||||
|
expect(MIXED_SOURCES_INSTRUCTION in (row["default_template"] or ""), "default template has mixed-source instruction")
|
||||||
|
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "revision updates even when template is custom")
|
||||||
|
|
||||||
|
readable = snapshot_summary(
|
||||||
|
{
|
||||||
|
"transformation": {"label": "spürbar"},
|
||||||
|
"detail": {"label": "weitgehend"},
|
||||||
|
"voice": {"label": "deutlich"},
|
||||||
|
"narrative": {"label": "gewichtet"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect("Erzeugt mit:\nSpürbar · Weitgehend · Deutlich · Gewichtet" in readable, "snapshot summary is readable")
|
||||||
|
expect("Quellenmodus" not in readable, "snapshot summary does not name a source mode")
|
||||||
|
|
||||||
|
print("journal generation policy tests passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -49,21 +49,25 @@ def test_active_prompt_contract() -> None:
|
||||||
init_db()
|
init_db()
|
||||||
prompt = load_active_prompt("mvp.journal_generate")
|
prompt = load_active_prompt("mvp.journal_generate")
|
||||||
text = prompt.get("template") or ""
|
text = prompt.get("template") or ""
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in text, "active prompt separates fact fidelity from wording")
|
expect("INHALTSTREUE" in text, "active prompt keeps hard content rules")
|
||||||
expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts")
|
expect("CURRENT_DAY_SOURCES" in text, "active prompt labels current-day facts")
|
||||||
expect("STYLE_EXAMPLES" in text, "active prompt labels style examples")
|
expect("STYLE_EXAMPLES" in text, "active prompt labels style examples")
|
||||||
expect("Rechtschreibung" in text or "korrigieren" in text, "active prompt allows spelling and grammar fixes")
|
expect("Rechtschreibung" in text or "korrigieren" in text, "active prompt allows spelling and grammar fixes")
|
||||||
expect("Unsicherheit bleibt Unsicherheit" in text, "active prompt keeps semantic uncertainty")
|
expect("Verneinungen" in text and "Unsicherheiten" in text, "active prompt keeps semantic uncertainty")
|
||||||
expect("keine neuen Informationen" in text.lower() or "Keine neuen Informationen" in text, "active prompt still forbids new facts")
|
expect("keine neuen tatsachen" in text.lower(), "active prompt still forbids new facts")
|
||||||
|
expect("Plan und Vollzug" in text, "active prompt keeps plan versus completion")
|
||||||
expect("[[…" not in text and "[[..." not in text, "active prompt must not teach ellipsis placeholders")
|
expect("[[…" not in text and "[[..." not in text, "active prompt must not teach ellipsis placeholders")
|
||||||
expect("Nur den verifizierten Nutzerwortlaut" not in text, "old wording-as-output rule is gone")
|
expect("Nur den verifizierten Nutzerwortlaut" not in text, "old wording-as-output rule is gone")
|
||||||
expect("Unsicherheiten im Wortlaut" not in text, "old keep-uncertainty-in-wording rule is gone")
|
expect("Unsicherheiten im Wortlaut" not in text, "old keep-uncertainty-in-wording rule is gone")
|
||||||
|
expect("{{transformation_instructions}}" in text, "active prompt uses compiled transformation instructions")
|
||||||
|
expect("{{source_mode_instructions}}" not in text, "active prompt has no source-mode placeholder")
|
||||||
|
expect("Zwinge nicht den gesamten Tag in einen einzigen Quellenmodus." in text, "active prompt has mixed-source instruction")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
||||||
("mvp.journal_generate",),
|
("mvp.journal_generate",),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
expect(row["seed_revision"] == "2026-08-27-journal-placeholders-v1", "system prompt revision is stored")
|
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "system prompt revision is stored")
|
||||||
expect(row["template"] == row["default_template"], "untouched install uses the seeded template")
|
expect(row["template"] == row["default_template"], "untouched install uses the seeded template")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,6 +122,24 @@ def test_unattested_identity_is_journal_not_privacy() -> None:
|
||||||
is None,
|
is None,
|
||||||
"food homonym of an unused mapping is not unattested identity",
|
"food homonym of an unused mapping is not unattested identity",
|
||||||
)
|
)
|
||||||
|
expect(
|
||||||
|
unattested_journal_content(
|
||||||
|
"Clarissa kam ins Wohnzimmer.",
|
||||||
|
["Heute war Sushi im Wohnzimmer."],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"local_label": "Clarissa",
|
||||||
|
"canonical_label": "Clarissa",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"aliases": ["Sushi"],
|
||||||
|
"demask_label": "Clarissa",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["PERSON:01"],
|
||||||
|
)
|
||||||
|
is None,
|
||||||
|
"confirmed alias in the source attests the canonical demasked spelling",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
@ -165,11 +187,11 @@ def main() -> None:
|
||||||
for stage in (gen.json().get("trace") or {}).get("stages") or []:
|
for stage in (gen.json().get("trace") or {}).get("stages") or []:
|
||||||
if stage.get("purpose") == "journal_generate":
|
if stage.get("purpose") == "journal_generate":
|
||||||
intern = stage.get("intern") or ""
|
intern = stage.get("intern") or ""
|
||||||
style = intern.split("CURRENT_DAY_SOURCES")[0]
|
style = intern.split("\nCURRENT_DAY_SOURCES\n")[0]
|
||||||
expect("Neutraler Journalstil" in style, "neutral style reaches the generate prompt")
|
expect("Neutraler Journalstil" in style, "neutral style reaches the generate prompt")
|
||||||
expect("Erzählmerkmale" not in style, "current day dialogue is not a style brief")
|
expect("Erzählmerkmale" not in style, "current day dialogue is not a style brief")
|
||||||
expect("zimlich" not in style, "today's typo is not a style exemplar")
|
expect("zimlich" not in style, "today's typo is not a style exemplar")
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in intern, "runtime intern uses the new narration contract")
|
expect("INHALTSTREUE" in intern, "runtime intern uses the new narration contract")
|
||||||
expect("CURRENT_DAY_SOURCES" in intern, "runtime intern labels current-day facts")
|
expect("CURRENT_DAY_SOURCES" in intern, "runtime intern labels current-day facts")
|
||||||
expect("Nur den verifizierten Nutzerwortlaut" not in intern, "old output-wording rule is not in the runtime prompt")
|
expect("Nur den verifizierten Nutzerwortlaut" not in intern, "old output-wording rule is not in the runtime prompt")
|
||||||
expect("Unsicherheiten im Wortlaut" not in intern, "old uncertainty-in-wording rule is not in the runtime prompt")
|
expect("Unsicherheiten im Wortlaut" not in intern, "old uncertainty-in-wording rule is not in the runtime prompt")
|
||||||
|
|
@ -252,7 +274,7 @@ def main() -> None:
|
||||||
for stage in (second.json().get("trace") or {}).get("stages") or []:
|
for stage in (second.json().get("trace") or {}).get("stages") or []:
|
||||||
if stage.get("purpose") == "journal_generate":
|
if stage.get("purpose") == "journal_generate":
|
||||||
intern2 = stage.get("intern") or ""
|
intern2 = stage.get("intern") or ""
|
||||||
expect("trockener Schnitt" in intern2.split("CURRENT_DAY_SOURCES")[0], "confirmed writing profile reaches generate")
|
expect("trockener Schnitt" in intern2.split("\nCURRENT_DAY_SOURCES\n")[0], "confirmed writing profile reaches generate")
|
||||||
|
|
||||||
def invent(_messages, _policy):
|
def invent(_messages, _policy):
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
|
|
@ -268,13 +290,14 @@ def main() -> None:
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"conversation_ids": [conv.json()["id"]]},
|
json={"conversation_ids": [conv.json()["id"]]},
|
||||||
)
|
)
|
||||||
expect(blocked.status_code == 200, f"unattested generate {blocked.text}")
|
expect(blocked.status_code == 409, f"unattested generate {blocked.text}")
|
||||||
log = blocked.json().get("run_log") or []
|
detail = blocked.json().get("detail") or {}
|
||||||
expect(any(item.get("reason") == "unattested_identity" for item in log), "unattested person uses local fallback")
|
expect(detail.get("code") == "journal_generation_not_accepted", "unattested person is not stored as a draft")
|
||||||
expect(any(item.get("status") == "local_fallback" for item in log), "fallback path is distinct from the model path")
|
expect(detail.get("message") == "Generierung nicht übernommen.", "API names the rejection")
|
||||||
expect("Clarissa" not in (blocked.json().get("body") or ""), "unattested person is not in the accepted draft")
|
log = (detail.get("diagnostics") or {}).get("log") or []
|
||||||
expect("markt" in (blocked.json().get("body") or "").lower(), "fallback still has the user sources")
|
expect(any(item.get("reason") == "unattested_identity" for item in log), "unattested person is a provenance reject")
|
||||||
expect("rote tasche" in (blocked.json().get("body") or "").lower(), "fallback keeps the unique source detail")
|
expect(any(item.get("status") == "not_accepted" for item in log), "reject path is distinct from the model path")
|
||||||
|
expect("Clarissa" not in json.dumps((detail.get("diagnostics") or {}).get("log") or []), "clear labels stay out of compact logs")
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -289,8 +312,8 @@ def main() -> None:
|
||||||
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
|
"SELECT default_template, seed_revision FROM ai_prompts WHERE slug = ?",
|
||||||
("mvp.journal_generate",),
|
("mvp.journal_generate",),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
expect("Faktentreue ist nicht Wortlauttreue" in (row["default_template"] or ""), "default template still tracks the seed")
|
expect("INHALTSTREUE" in (row["default_template"] or ""), "default template still tracks the seed")
|
||||||
expect(row["seed_revision"] == "2026-08-27-journal-placeholders-v1", "revision updates even when template is custom")
|
expect(row["seed_revision"] == "2026-08-27-journal-mixed-sources-v1", "revision updates even when template is custom")
|
||||||
|
|
||||||
print("journal narration tests passed.")
|
print("journal narration tests passed.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ def main() -> None:
|
||||||
"A style brief reaches generate",
|
"A style brief reaches generate",
|
||||||
)
|
)
|
||||||
expect("STYLE_EXAMPLES" in narrate_intern, "A style examples block is labeled")
|
expect("STYLE_EXAMPLES" in narrate_intern, "A style examples block is labeled")
|
||||||
style_part = narrate_intern.split("CURRENT_DAY_SOURCES")[0]
|
style_part = narrate_intern.split("\nCURRENT_DAY_SOURCES\n")[0]
|
||||||
expect("Erzählmerkmale" not in style_part, "A current day dialogue is not appended as style signals")
|
expect("Erzählmerkmale" not in style_part, "A current day dialogue is not appended as style signals")
|
||||||
expect("Neutraler Journalstil" in style_part or "Core:" in style_part, "A empty profile uses the neutral journal voice")
|
expect("Neutraler Journalstil" in style_part or "Core:" in style_part, "A empty profile uses the neutral journal voice")
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -503,12 +503,9 @@ def main() -> None:
|
||||||
_demask("[[ person:01 ]] war ruhig.", anna_manifest) == "Anna war ruhig.",
|
_demask("[[ person:01 ]] war ruhig.", anna_manifest) == "Anna war ruhig.",
|
||||||
"demask is case- and space-insensitive",
|
"demask is case- and space-insensitive",
|
||||||
)
|
)
|
||||||
blocked_plain = False
|
tokenized = _validate_response("Anna war ruhig.", anna_manifest)
|
||||||
try:
|
expect(tokenized.startswith("[[PERSON:01]]"), "active cleartext is normalized, not discarded")
|
||||||
_validate_response("Anna war ruhig.", anna_manifest)
|
expect(_demask(tokenized, anna_manifest) == "Anna war ruhig.", "normalized plaintext demasks")
|
||||||
except PrivacyGatewayError as exc:
|
|
||||||
blocked_plain = exc.code == "response_validation_failed"
|
|
||||||
expect(blocked_plain, "plaintext identity is blocked before demask")
|
|
||||||
|
|
||||||
no_user = client.get("/api/admin/identities")
|
no_user = client.get("/api/admin/identities")
|
||||||
expect(no_user.status_code in {401, 403}, "identity registry requires auth")
|
expect(no_user.status_code in {401, 403}, "identity registry requires auth")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
os.environ.setdefault("KANSHO_FAKE_DETECT", "1")
|
os.environ.setdefault("KANSHO_FAKE_DETECT", "1")
|
||||||
|
|
||||||
from entity_detect_eval import run_fake
|
from entity_detect_eval import CASES, run_fake, score_spans
|
||||||
|
|
||||||
|
|
||||||
def expect(ok: bool, message: str) -> None:
|
def expect(ok: bool, message: str) -> None:
|
||||||
|
|
@ -22,7 +22,22 @@ def main() -> None:
|
||||||
payload = run_fake()
|
payload = run_fake()
|
||||||
expect(payload["mode"] == "fake", "eval default is fake")
|
expect(payload["mode"] == "fake", "eval default is fake")
|
||||||
expect(payload["live_quality"] == "unconfirmed", "live quality stays unconfirmed")
|
expect(payload["live_quality"] == "unconfirmed", "live quality stays unconfirmed")
|
||||||
expect(len(payload["cases"]) >= 3, "synthetic cases exist")
|
expect(payload["detect_model_unconfirmed"] == "openai/gpt-4.1-nano", "configured detect model stays unconfirmed")
|
||||||
|
expect(len(payload["cases"]) >= 8, "synthetic span cases exist")
|
||||||
|
person = next(item for item in CASES if item["id"] == "no_sentence_span")
|
||||||
|
metrics = score_spans(
|
||||||
|
[{"start": 9, "end": 13, "text": "Anna", "entity_type": "PERSON"}],
|
||||||
|
person["expected"],
|
||||||
|
)
|
||||||
|
expect(metrics["expected_found"] == 1, "exact expected span is counted")
|
||||||
|
expect(metrics["precision"] == 1.0 and metrics["recall"] == 1.0, "exact span match is precision 1")
|
||||||
|
wrong = score_spans(
|
||||||
|
[{"start": 0, "end": 28, "text": "Ich traf Anna am Nachmittag.", "entity_type": "PERSON"}],
|
||||||
|
person["expected"],
|
||||||
|
)
|
||||||
|
expect(wrong["unexpected"] == 1, "sentence-sized span is unexpected")
|
||||||
|
food = next(item for item in payload["cases"] if item["id"] == "same_word_two_roles")
|
||||||
|
expect(food["entities"]["metrics"]["expected_found"] == 1, "fake contract finds only the identity sushi")
|
||||||
print("All detect eval harness tests passed.")
|
print("All detect eval harness tests passed.")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ from identity_store import remember_mapping
|
||||||
from main import app
|
from main import app
|
||||||
from privacy_gateway import (
|
from privacy_gateway import (
|
||||||
ERROR_EGRESS_VALIDATION,
|
ERROR_EGRESS_VALIDATION,
|
||||||
IDENTITY_LEAK_RETRY,
|
|
||||||
ActiveReplacement,
|
ActiveReplacement,
|
||||||
GatewayRequest,
|
GatewayRequest,
|
||||||
MaskingManifest,
|
MaskingManifest,
|
||||||
PrivacyGatewayError,
|
PrivacyGatewayError,
|
||||||
_demask,
|
_demask,
|
||||||
|
_process_response,
|
||||||
_validate_response,
|
_validate_response,
|
||||||
complete,
|
complete,
|
||||||
last_trace,
|
last_trace,
|
||||||
|
|
@ -90,14 +90,10 @@ def main() -> None:
|
||||||
|
|
||||||
kinship = mask_prompt("Meine Frau Sushi kam später.", sushi, "journal_generate")
|
kinship = mask_prompt("Meine Frau Sushi kam später.", sushi, "journal_generate")
|
||||||
expect(len(kinship.replacements) == 1, "identity mention of the same word is active")
|
expect(len(kinship.replacements) == 1, "identity mention of the same word is active")
|
||||||
blocked = False
|
normalized, meta = _process_response("Sushi kam vorbei.", kinship)
|
||||||
try:
|
expect(normalized.startswith("[[PERSON:01]]"), "active cleartext is remapped to the request token")
|
||||||
_validate_response("Sushi kam vorbei.", kinship)
|
expect(meta.get("response_normalization") == "active_cleartext_normalized", "normalization is named")
|
||||||
except PrivacyGatewayError as exc:
|
expect(_demask(normalized, kinship) == "Sushi kam vorbei.", "normalized reply demasks locally")
|
||||||
blocked = exc.code == "response_validation_failed"
|
|
||||||
expect(exc.diagnostics.get("leak_tokens") == ["PERSON:01"], "block reports the token, not the label")
|
|
||||||
expect("Sushi" not in json.dumps(exc.diagnostics), "validation diagnostics omit the clear name")
|
|
||||||
expect(blocked, "active clear name in the raw reply is a leak")
|
|
||||||
homonym_ok = _validate_response("Danach Sushi essen.", kinship)
|
homonym_ok = _validate_response("Danach Sushi essen.", kinship)
|
||||||
expect("Sushi essen" in homonym_ok, "homonym in the reply uses the same classification rule")
|
expect("Sushi essen" in homonym_ok, "homonym in the reply uses the same classification rule")
|
||||||
|
|
||||||
|
|
@ -107,7 +103,9 @@ def main() -> None:
|
||||||
leftover = _demask("[[PERSON:01]] und [[PERSON:99]]", kinship)
|
leftover = _demask("[[PERSON:01]] und [[PERSON:99]]", kinship)
|
||||||
expect("Sushi" in leftover and "[[PERSON:99]]" in leftover, "inactive placeholder is not rematerialized")
|
expect("Sushi" in leftover and "[[PERSON:99]]" in leftover, "inactive placeholder is not rematerialized")
|
||||||
expect("Clarissa" not in leftover, "inactive mapping label is not introduced by demask")
|
expect("Clarissa" not in leftover, "inactive mapping label is not introduced by demask")
|
||||||
expect("[[" not in IDENTITY_LEAK_RETRY, "retry instruction must not teach bracket placeholders")
|
import privacy_gateway as gw
|
||||||
|
|
||||||
|
expect(not hasattr(gw, "IDENTITY_LEAK_RETRY"), "automatic identity-leak retry instruction is gone")
|
||||||
expect(
|
expect(
|
||||||
_demask("[[ person:01 ]] war ruhig.", kinship) == "Sushi war ruhig.",
|
_demask("[[ person:01 ]] war ruhig.", kinship) == "Sushi war ruhig.",
|
||||||
"demask ignores case and inner spacing",
|
"demask ignores case and inner spacing",
|
||||||
|
|
@ -121,6 +119,25 @@ def main() -> None:
|
||||||
"generic ellipsis placeholders are not rematerialized",
|
"generic ellipsis placeholders are not rematerialized",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
alias_rows = [
|
||||||
|
{
|
||||||
|
"local_label": "Sushi",
|
||||||
|
"token": "PERSON:07",
|
||||||
|
"canonical_label": "Clarissa",
|
||||||
|
"demask_label": "Clarissa",
|
||||||
|
"aliases": ["Sushi"],
|
||||||
|
"labels": ["Clarissa", "Sushi"],
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
aliased = mask_prompt("Sushi kam ins Wohnzimmer.", alias_rows, "journal_generate")
|
||||||
|
expect(len(aliased.replacements) == 1, "confirmed alias activates its token")
|
||||||
|
spellings = {label.casefold() for label in aliased.replacements[0].all_labels()}
|
||||||
|
expect(spellings == {"sushi", "clarissa"}, "request manifest keeps every spelling of the active token")
|
||||||
|
expect(_demask("[[PERSON:07]] kam.", aliased) == "Clarissa kam.", "confirmed alias demasks to the canonical spelling")
|
||||||
|
canonical_clear, _meta = _process_response("Clarissa kam vorbei.", aliased)
|
||||||
|
expect(canonical_clear.startswith("[[PERSON:07]]"), "canonical spelling of an active alias token is remapped")
|
||||||
|
|
||||||
overlap = mask_prompt(
|
overlap = mask_prompt(
|
||||||
"Anna-Lena kam vorbei.",
|
"Anna-Lena kam vorbei.",
|
||||||
[
|
[
|
||||||
|
|
@ -133,6 +150,54 @@ def main() -> None:
|
||||||
expect("[[PERSON:08]]" in overlap.masked_text, "longer label is the one replaced")
|
expect("[[PERSON:08]]" in overlap.masked_text, "longer label is the one replaced")
|
||||||
expect("Anna-Lena" not in overlap.masked_text, "replaced longer label is gone")
|
expect("Anna-Lena" not in overlap.masked_text, "replaced longer label is gone")
|
||||||
|
|
||||||
|
mixed_word = "Ich aß Sushi. Sushi kam später."
|
||||||
|
second = mixed_word.find("Sushi", mixed_word.find("Sushi") + 1)
|
||||||
|
span_only = mask_prompt(
|
||||||
|
mixed_word,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"local_label": "Sushi",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": second,
|
||||||
|
"end": second + 5,
|
||||||
|
"text": "Sushi",
|
||||||
|
"source": "request_local",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"journal_generate",
|
||||||
|
)
|
||||||
|
expect(span_only.masked_text.startswith("Ich aß Sushi."), "undetected same wording stays unmasked")
|
||||||
|
expect("[[PERSON:01]] kam später." in span_only.masked_text, "only the detected span is masked")
|
||||||
|
validate_pre_egress(span_only.masked_text, span_only)
|
||||||
|
|
||||||
|
overlapping_spans = mask_prompt(
|
||||||
|
"Anna-Lena kam vorbei.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"local_label": "Anna",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": 0,
|
||||||
|
"end": 4,
|
||||||
|
"text": "Anna",
|
||||||
|
"source": "request_local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_label": "Anna-Lena",
|
||||||
|
"token": "PERSON:08",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": 0,
|
||||||
|
"end": 9,
|
||||||
|
"text": "Anna-Lena",
|
||||||
|
"source": "request_local",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"journal_generate",
|
||||||
|
)
|
||||||
|
expect("[[PERSON:08]]" in overlapping_spans.masked_text, "longest overlapping span wins")
|
||||||
|
expect("[[PERSON:01]]" not in overlapping_spans.masked_text, "shorter overlapping span is dropped")
|
||||||
|
|
||||||
subject = mask_prompt(
|
subject = mask_prompt(
|
||||||
"Clarissa kaufte Kirschen.",
|
"Clarissa kaufte Kirschen.",
|
||||||
[{"local_label": "Clarissa", "token": "PERSON:99"}],
|
[{"local_label": "Clarissa", "token": "PERSON:99"}],
|
||||||
|
|
@ -166,7 +231,7 @@ def main() -> None:
|
||||||
second = pool.submit(check, "Heute nur Markt.", anna + lars, "Anna kam.")
|
second = pool.submit(check, "Heute nur Markt.", anna + lars, "Anna kam.")
|
||||||
code_a, _text_a, tokens_a = first.result()
|
code_a, _text_a, tokens_a = first.result()
|
||||||
code_b, text_b, tokens_b = second.result()
|
code_b, text_b, tokens_b = second.result()
|
||||||
expect(code_a == "response_validation_failed", "parallel request A still blocks its own active leak")
|
expect(code_a == "ok" and "[[PERSON:01]]" in (_text_a or ""), "parallel request A normalizes its own active cleartext")
|
||||||
expect(tokens_a == ["PERSON:01"], "parallel request A keeps its own active set")
|
expect(tokens_a == ["PERSON:01"], "parallel request A keeps its own active set")
|
||||||
expect(code_b == "ok" and "Anna kam" in (text_b or ""), "parallel request B is not blocked by A's mapping")
|
expect(code_b == "ok" and "Anna kam" in (text_b or ""), "parallel request B is not blocked by A's mapping")
|
||||||
expect(tokens_b == [], "parallel request B does not inherit A's active set")
|
expect(tokens_b == [], "parallel request B does not inherit A's active set")
|
||||||
|
|
@ -218,30 +283,26 @@ def main() -> None:
|
||||||
|
|
||||||
seen = []
|
seen = []
|
||||||
|
|
||||||
def leak_then_ok(messages, _policy):
|
def leak_once(messages, _policy):
|
||||||
seen.append(messages[0].get("content") or "")
|
seen.append(messages[0].get("content") or "")
|
||||||
if len(seen) == 1:
|
|
||||||
return ChatResult(
|
|
||||||
content="Anna stand den ganzen Nachmittag am Markt.",
|
|
||||||
model="fake",
|
|
||||||
usage={"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18, "cost": 0.01},
|
|
||||||
context_compression="not_applicable",
|
|
||||||
)
|
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
content="[[PERSON:01]] stand den ganzen Nachmittag am Markt.",
|
content="Anna stand den ganzen Nachmittag am Markt.",
|
||||||
model="fake",
|
model="fake",
|
||||||
usage={"prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20, "cost": 0.02},
|
usage={"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18, "cost": 0.01},
|
||||||
context_compression="not_applicable",
|
context_compression="not_applicable",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("privacy_gateway.complete_model", leak_then_ok):
|
with patch("privacy_gateway.complete_model", leak_once):
|
||||||
repaired = _run_gateway(profile_id, "Ich war mit Anna am Markt.")
|
repaired = _run_gateway(profile_id, "Ich war mit Anna am Markt.")
|
||||||
expect(len(seen) == 2, "a real leak retries exactly once")
|
expect(len(seen) == 1, "active cleartext does not start a second generate call")
|
||||||
expect(IDENTITY_LEAK_RETRY in seen[1], "retry uses the generic correction instruction")
|
expect("[[PERSON:01]]" in seen[0] and "Anna" not in seen[0].replace("[[PERSON:01]]", ""), "egress stays masked")
|
||||||
expect("Anna stand den ganzen Nachmittag" not in seen[1], "discarded raw reply is not part of the retry prompt")
|
expect("Anna" in (repaired.content or ""), "normalized cleartext is demasked locally")
|
||||||
expect("[[PERSON:01]]" in seen[0] and "Anna" not in seen[0].replace("[[PERSON:01]]", ""), "retry keeps the already masked prompt")
|
expect(repaired.diagnostics.get("generate_calls") == 1, "exactly one generate call")
|
||||||
expect("Anna" in (repaired.content or ""), "successful retry is demasked locally")
|
expect(
|
||||||
expect(repaired.diagnostics.get("response_validation_retry") == 1, "retry is recorded without the raw reply")
|
repaired.diagnostics.get("response_normalization") == "active_cleartext_normalized",
|
||||||
|
"active cleartext is recorded as local normalization",
|
||||||
|
)
|
||||||
|
expect("Anna" not in json.dumps(repaired.diagnostics), "compact diagnostics omit the clear name")
|
||||||
|
|
||||||
def spaced_token(_messages, _policy):
|
def spaced_token(_messages, _policy):
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
|
|
@ -258,10 +319,10 @@ def main() -> None:
|
||||||
"gateway demasks case and spacing variants",
|
"gateway demasks case and spacing variants",
|
||||||
)
|
)
|
||||||
expect("[[" not in (restored.content or ""), "no leftover placeholder after a successful demask")
|
expect("[[" not in (restored.content or ""), "no leftover placeholder after a successful demask")
|
||||||
expect(repaired.diagnostics.get("prompt_tokens") == 22, "retry aggregates prompt tokens")
|
expect(repaired.diagnostics.get("prompt_tokens") == 10, "single call keeps prompt tokens")
|
||||||
expect(repaired.diagnostics.get("completion_tokens") == 16, "retry aggregates completion tokens")
|
expect(repaired.diagnostics.get("completion_tokens") == 8, "single call keeps completion tokens")
|
||||||
expect(repaired.diagnostics.get("total_tokens") == 38, "retry aggregates total tokens")
|
expect(repaired.diagnostics.get("total_tokens") == 18, "single call keeps total tokens")
|
||||||
expect(abs(float(repaired.diagnostics.get("cost") or 0) - 0.03) < 1e-9, "retry aggregates cost")
|
expect(abs(float(repaired.diagnostics.get("cost") or 0) - 0.01) < 1e-9, "single call keeps cost")
|
||||||
|
|
||||||
inactive_calls = {"n": 0}
|
inactive_calls = {"n": 0}
|
||||||
|
|
||||||
|
|
@ -287,20 +348,17 @@ def main() -> None:
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
content="Anna stand den ganzen Nachmittag am Markt.",
|
content="Anna stand den ganzen Nachmittag am Markt.",
|
||||||
model="fake",
|
model="fake",
|
||||||
usage={},
|
usage={"prompt_tokens": 4, "completion_tokens": 6, "total_tokens": 10, "cost": 0.002},
|
||||||
context_compression="not_applicable",
|
context_compression="not_applicable",
|
||||||
)
|
)
|
||||||
|
|
||||||
failed = False
|
|
||||||
with patch("privacy_gateway.complete_model", always_leak):
|
with patch("privacy_gateway.complete_model", always_leak):
|
||||||
try:
|
accepted = _run_gateway(profile_id, "Ich war mit Anna am Markt.")
|
||||||
_run_gateway(profile_id, "Ich war mit Anna am Markt.")
|
expect(len(retry_fail) == 1, "cleartext in the reply does not start a second model call")
|
||||||
except PrivacyGatewayError as exc:
|
expect("Anna" in (accepted.content or ""), "source-attested name is demasked after local normalization")
|
||||||
failed = exc.code == "response_validation_failed"
|
expect(accepted.diagnostics.get("generate_calls") == 1, "still exactly one generate call")
|
||||||
expect(exc.diagnostics.get("response_validation_retry") == 1, "failed retry is still only one extra call")
|
expect(accepted.trace.get("model") == "fake" or accepted.diagnostics.get("model") == "fake", "error-free trace keeps the model")
|
||||||
expect("Anna stand" not in json.dumps(exc.diagnostics.get("log") or []), "discarded reply is not persisted in the log")
|
expect(accepted.diagnostics.get("completion_tokens") == 6, "token usage of the only call remains")
|
||||||
expect(failed, "second leak stays fail-closed")
|
|
||||||
expect(len(retry_fail) == 2, "gateway stops after one retry")
|
|
||||||
|
|
||||||
print("All privacy manifest tests passed.")
|
print("All privacy manifest tests passed.")
|
||||||
|
|
||||||
|
|
|
||||||
353
backend/tests/test_privacy_response_integrity.py
Normal file
353
backend/tests/test_privacy_response_integrity.py
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
"""Response integrity, span masking, journal rejection, and diagnosis contract."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPO = ROOT.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-privacy-response-test.sqlite")
|
||||||
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
||||||
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
||||||
|
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from identity_store import list_mappings, remember_mapping
|
||||||
|
from journal_generate import JOURNAL_NOT_ACCEPTED, JOURNAL_NOT_ACCEPTED_MESSAGE
|
||||||
|
from journal_store import current_draft
|
||||||
|
from main import app
|
||||||
|
from privacy_gateway import (
|
||||||
|
ERROR_EGRESS_VALIDATION,
|
||||||
|
ActiveReplacement,
|
||||||
|
GatewayRequest,
|
||||||
|
MaskingManifest,
|
||||||
|
PrivacyGatewayError,
|
||||||
|
complete,
|
||||||
|
mask_prompt,
|
||||||
|
reset_debug,
|
||||||
|
validate_pre_egress,
|
||||||
|
)
|
||||||
|
from providers import ChatResult
|
||||||
|
|
||||||
|
|
||||||
|
def expect(ok: bool, message: str) -> None:
|
||||||
|
if not ok:
|
||||||
|
raise SystemExit(f"FAIL: {message}")
|
||||||
|
print(f"OK {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def header(token: str) -> dict:
|
||||||
|
return {"X-Auth-Token": token}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_gateway(profile_id: str, rendered: str, *, purpose: str = "dialogue_turn"):
|
||||||
|
return complete(
|
||||||
|
GatewayRequest(
|
||||||
|
prompt_id="response-integrity",
|
||||||
|
purpose=purpose,
|
||||||
|
data_class="B",
|
||||||
|
profile_id=profile_id,
|
||||||
|
payload={"rendered": rendered, "source_text": rendered, "prompt_slug": "mvp.journal_generate"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _day_with_turn(client: TestClient, headers: dict, body: str, title: str = "Tag") -> tuple[str, str]:
|
||||||
|
space = client.post("/api/journal/spaces", headers=headers, json={"title": title})
|
||||||
|
day = client.post(
|
||||||
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
||||||
|
headers=headers,
|
||||||
|
json={"calendar_date": "2026-08-27"},
|
||||||
|
)
|
||||||
|
conv = client.post(
|
||||||
|
f"/api/journal/days/{day.json()['day']['id']}/conversations",
|
||||||
|
headers=headers,
|
||||||
|
json={"title": "Gespräch"},
|
||||||
|
)
|
||||||
|
turn = client.post(
|
||||||
|
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
||||||
|
headers=headers,
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
|
if turn.status_code != 200:
|
||||||
|
raise SystemExit(f"FAIL: setup turn {turn.status_code}")
|
||||||
|
print("OK setup turn 200")
|
||||||
|
return day.json()["day"]["id"], conv.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(client: TestClient, headers: dict, day_id: str, conv_id: str, reply: str, usage=None):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake(_messages, _policy):
|
||||||
|
calls["n"] += 1
|
||||||
|
return ChatResult(
|
||||||
|
content=reply,
|
||||||
|
model="fake-gpt",
|
||||||
|
usage=usage or {"prompt_tokens": 7, "completion_tokens": 5, "total_tokens": 12, "cost": 0.001},
|
||||||
|
context_compression="disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.complete_model", fake):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/journal/days/{day_id}/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"conversation_ids": [conv_id]},
|
||||||
|
)
|
||||||
|
return response, calls["n"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_masking_and_pre_egress() -> None:
|
||||||
|
text = "Ich aß Sushi. Sushi kam später."
|
||||||
|
second = text.find("Sushi", text.find("Sushi") + 1)
|
||||||
|
manifest = mask_prompt(
|
||||||
|
text,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"local_label": "Sushi",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": second,
|
||||||
|
"end": second + 5,
|
||||||
|
"text": "Sushi",
|
||||||
|
"source": "request_local",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"journal_generate",
|
||||||
|
)
|
||||||
|
expect(manifest.masked_text == "Ich aß Sushi. [[PERSON:01]] kam später.", "only the detected span is masked")
|
||||||
|
validate_pre_egress(manifest.masked_text, manifest)
|
||||||
|
|
||||||
|
overlapping = mask_prompt(
|
||||||
|
"Anna-Lena kam vorbei.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"local_label": "Anna",
|
||||||
|
"token": "PERSON:01",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": 0,
|
||||||
|
"end": 4,
|
||||||
|
"text": "Anna",
|
||||||
|
"source": "request_local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_label": "Anna-Lena",
|
||||||
|
"token": "PERSON:08",
|
||||||
|
"entity_type": "PERSON",
|
||||||
|
"start": 0,
|
||||||
|
"end": 9,
|
||||||
|
"text": "Anna-Lena",
|
||||||
|
"source": "request_local",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"journal_generate",
|
||||||
|
)
|
||||||
|
expect(overlapping.masked_text.startswith("[[PERSON:08]]"), "overlapping spans pick the longest")
|
||||||
|
|
||||||
|
leaky = MaskingManifest(
|
||||||
|
masked_text="Anna war am Markt.",
|
||||||
|
available_mapping_count=1,
|
||||||
|
replacements=(ActiveReplacement("PERSON:01", "PERSON", 1, "Anna"),),
|
||||||
|
)
|
||||||
|
blocked = False
|
||||||
|
try:
|
||||||
|
validate_pre_egress("Anna war am Markt.", leaky)
|
||||||
|
except PrivacyGatewayError as exc:
|
||||||
|
blocked = exc.code == ERROR_EGRESS_VALIDATION
|
||||||
|
expect(blocked, "privacy stays fail-closed before the provider")
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_has_no_incident_wordlists() -> None:
|
||||||
|
forbidden = (
|
||||||
|
"Kinder",
|
||||||
|
"Delfine",
|
||||||
|
"delphine",
|
||||||
|
"Wohnzimmer",
|
||||||
|
"Balkon",
|
||||||
|
"Tagesreflektion",
|
||||||
|
"Lebenwesen",
|
||||||
|
"Baguette",
|
||||||
|
)
|
||||||
|
runtime = []
|
||||||
|
for path in ROOT.glob("*.py"):
|
||||||
|
if path.name.startswith("entity_detect_eval") or path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for word in forbidden:
|
||||||
|
if word in text:
|
||||||
|
runtime.append(f"{path.name}:{word}")
|
||||||
|
expect(not runtime, f"incident words must not be hardcoded in runtime: {runtime}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_degraded_copy() -> None:
|
||||||
|
page = (REPO / "frontend" / "src" / "pages" / "JournalDayPage.jsx").read_text(encoding="utf-8")
|
||||||
|
trace = (REPO / "frontend" / "src" / "components" / "CallTrace.jsx").read_text(encoding="utf-8")
|
||||||
|
log = (REPO / "frontend" / "src" / "components" / "RunLogPopup.jsx").read_text(encoding="utf-8")
|
||||||
|
expect("Generierung nicht übernommen" in page, "journal page names a rejected generate")
|
||||||
|
expect("Generierung nicht übernommen" in trace, "trace names a rejected generate")
|
||||||
|
expect("Generierung nicht übernommen" in log, "run log names a rejected generate")
|
||||||
|
expect("nur user-Zeilen" not in trace, "masking copy is not limited to user lines")
|
||||||
|
expect("zwei Modellaufrufe nacheinander" not in log, "running copy does not claim two model calls")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
reset_debug()
|
||||||
|
test_span_masking_and_pre_egress()
|
||||||
|
test_runtime_has_no_incident_wordlists()
|
||||||
|
test_frontend_degraded_copy()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
setup = client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"email": "response@example.test", "name": "Response", "password": "test-pass"},
|
||||||
|
)
|
||||||
|
headers = header(setup.json()["token"])
|
||||||
|
profile_id = setup.json()["profile_id"]
|
||||||
|
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def false_positive_cleartext(_messages, _policy):
|
||||||
|
calls["n"] += 1
|
||||||
|
return ChatResult(
|
||||||
|
content="Danach Sushi essen am Markt.",
|
||||||
|
model="fake-gpt",
|
||||||
|
usage={"prompt_tokens": 8, "completion_tokens": 6, "total_tokens": 14, "cost": 0.002},
|
||||||
|
context_compression="not_applicable",
|
||||||
|
)
|
||||||
|
|
||||||
|
remember_mapping(profile_id, "Sushi", "PERSON:01")
|
||||||
|
with patch("privacy_gateway.complete_model", false_positive_cleartext):
|
||||||
|
result = _run_gateway(profile_id, "Ich aß Sushi. Meine Frau Sushi kam später.")
|
||||||
|
expect(calls["n"] == 1, "false-positive cleartext still uses exactly one generate call")
|
||||||
|
expect("Sushi" in (result.content or ""), "reconstructed common noun is kept after local normalization")
|
||||||
|
expect(result.diagnostics.get("generate_calls") == 1, "gateway reports one generate call")
|
||||||
|
expect(
|
||||||
|
result.diagnostics.get("response_normalization") in {"active_cleartext_normalized", "none"},
|
||||||
|
"normalization is recorded without a retry",
|
||||||
|
)
|
||||||
|
|
||||||
|
remember_mapping(profile_id, "Anna", "PERSON:02")
|
||||||
|
day_id, conv_id = _day_with_turn(client, headers, "Heute war ich mit Anna am Markt.")
|
||||||
|
gen, n = _generate(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
day_id,
|
||||||
|
conv_id,
|
||||||
|
"Ein Markttag\n\nAnna stand den ganzen Nachmittag am Markt.",
|
||||||
|
)
|
||||||
|
expect(gen.status_code == 200, f"attested person cleartext {gen.status_code}")
|
||||||
|
expect(n == 1, "attested person cleartext does not retry")
|
||||||
|
expect("Anna" in (gen.json().get("body") or ""), "attested person is demasked")
|
||||||
|
expect((gen.json().get("trace") or {}).get("model_text_accepted") is True, "attested model text is accepted")
|
||||||
|
|
||||||
|
remember_mapping(profile_id, "Clarissa", "PERSON:99")
|
||||||
|
hist_day, hist_conv = _day_with_turn(client, headers, "Heute nur Markt und Kirschen.", title="Historisch")
|
||||||
|
blocked, n = _generate(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
hist_day,
|
||||||
|
hist_conv,
|
||||||
|
"Ein Markttag\n\nClarissa kaufte Kirschen am Markt.",
|
||||||
|
)
|
||||||
|
expect(blocked.status_code == 409, f"style-only person {blocked.status_code}")
|
||||||
|
detail = blocked.json().get("detail") or {}
|
||||||
|
expect(detail.get("code") == JOURNAL_NOT_ACCEPTED, "historical-only name is rejected")
|
||||||
|
expect(detail.get("message") == JOURNAL_NOT_ACCEPTED_MESSAGE, "API message is explicit")
|
||||||
|
expect(n == 1, "provenance reject does not retry")
|
||||||
|
expect(current_draft(profile_id, hist_day) is None, "rejected generate inserts no draft")
|
||||||
|
diag = detail.get("diagnostics") or {}
|
||||||
|
trace = diag.get("trace") or {}
|
||||||
|
expect(trace.get("abort_reason") == "unattested_identity", "provenance abort reason remains")
|
||||||
|
expect(trace.get("model_text_accepted") is False, "model text is marked not accepted")
|
||||||
|
expect(
|
||||||
|
trace.get("model") == "fake-gpt" or (trace.get("budget") or {}).get("model") == "fake-gpt",
|
||||||
|
"error trace keeps the model",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(trace.get("generate_calls") == 1)
|
||||||
|
or ((trace.get("budget") or {}).get("generate_calls") == 1)
|
||||||
|
or diag.get("generate_calls") == 1,
|
||||||
|
"error trace keeps generate_calls",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(trace.get("budget") or {}).get("completion_tokens") == 5 or diag.get("completion_tokens") == 5,
|
||||||
|
"error trace keeps completion tokens",
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown_day, unknown_conv = _day_with_turn(client, headers, "Heute nur der Markt.", title="Platzhalter")
|
||||||
|
unknown, n = _generate(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
unknown_day,
|
||||||
|
unknown_conv,
|
||||||
|
"Ein Markttag\n\n[[PERSON:99]] stand am Markt.",
|
||||||
|
)
|
||||||
|
expect(unknown.status_code == 409, f"unknown placeholder {unknown.status_code}")
|
||||||
|
expect((unknown.json().get("detail") or {}).get("code") == JOURNAL_NOT_ACCEPTED, "unknown placeholder is rejected")
|
||||||
|
expect(n == 1, "unknown placeholder does not retry")
|
||||||
|
expect(current_draft(profile_id, unknown_day) is None, "unknown placeholder inserts no draft")
|
||||||
|
|
||||||
|
generic_day, generic_conv = _day_with_turn(client, headers, "Heute nur der Hafen.", title="Generisch")
|
||||||
|
generic, n = _generate(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
generic_day,
|
||||||
|
generic_conv,
|
||||||
|
"Ein Hafentag\n\n[[...]] blieb am Hafen.",
|
||||||
|
)
|
||||||
|
expect(generic.status_code == 409, f"generic placeholder {generic.status_code}")
|
||||||
|
expect((generic.json().get("detail") or {}).get("code") == JOURNAL_NOT_ACCEPTED, "generic placeholder is rejected")
|
||||||
|
expect(current_draft(profile_id, generic_day) is None, "generic placeholder inserts no draft")
|
||||||
|
|
||||||
|
ok_day, ok_conv = _day_with_turn(client, headers, "Heute war ich mit Anna am Hafen.", title="Tokens")
|
||||||
|
anna_token = next(
|
||||||
|
(
|
||||||
|
item.get("token")
|
||||||
|
for item in list_mappings(profile_id)
|
||||||
|
if (item.get("canonical_label") or item.get("local_label") or "") == "Anna"
|
||||||
|
),
|
||||||
|
"PERSON:02",
|
||||||
|
)
|
||||||
|
ok_gen, n = _generate(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
ok_day,
|
||||||
|
ok_conv,
|
||||||
|
f"Ein Hafentag\n\n[[{anna_token}]] stand am Hafen.",
|
||||||
|
)
|
||||||
|
expect(ok_gen.status_code == 200, f"active tokens {ok_gen.status_code}")
|
||||||
|
expect("Anna" in (ok_gen.json().get("body") or ""), "active tokens demask normally")
|
||||||
|
expect("[[" not in (ok_gen.json().get("body") or ""), "no leftover placeholder after demask")
|
||||||
|
expect(n == 1, "normal demask uses one generate call")
|
||||||
|
|
||||||
|
provider_calls = {"n": 0}
|
||||||
|
|
||||||
|
def boom(_messages, _policy):
|
||||||
|
provider_calls["n"] += 1
|
||||||
|
raise AssertionError("provider must not be called after pre-egress failure")
|
||||||
|
|
||||||
|
def leaky_mask(rendered, mappings, purpose):
|
||||||
|
return MaskingManifest(
|
||||||
|
masked_text=rendered,
|
||||||
|
available_mapping_count=len(mappings or []),
|
||||||
|
replacements=(ActiveReplacement("PERSON:02", "PERSON", 1, "Anna"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("privacy_gateway.mask_prompt", leaky_mask), patch("privacy_gateway.complete_model", boom):
|
||||||
|
failed = False
|
||||||
|
try:
|
||||||
|
_run_gateway(profile_id, "Anna war am Markt.")
|
||||||
|
except PrivacyGatewayError as exc:
|
||||||
|
failed = exc.code == ERROR_EGRESS_VALIDATION
|
||||||
|
expect(failed, "pre-egress still fail-closed")
|
||||||
|
expect(provider_calls["n"] == 0, "provider is not called when confirmed identity remains")
|
||||||
|
|
||||||
|
print("All privacy response integrity tests passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -489,6 +489,8 @@ Erst danach erfolgt:
|
||||||
2. fachliche Weiterverarbeitung,
|
2. fachliche Weiterverarbeitung,
|
||||||
3. Anzeige beziehungsweise Speicherung.
|
3. Anzeige beziehungsweise Speicherung.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27:** Diese Prüfung ist Inhaltsintegrität nach dem Generate, nicht nachträglicher Egress-Stopp. Privacy fail-closed gilt vor dem Socket. Ein Verwerfen der Antwort macht einen bereits erfolgten Egress nicht ungeschehen und löst keinen automatischen zweiten Generate-Call aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 15. Beziehung zu Lived Experience / Digital Twin
|
# 15. Beziehung zu Lived Experience / Digital Twin
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ Kein Codefehler des Freeze Candidate, sondern Betriebsreife:
|
||||||
- Space-Kontext darf den Dialogzug stützen, nicht still die Generate-Quelle werden.
|
- Space-Kontext darf den Dialogzug stützen, nicht still die Generate-Quelle werden.
|
||||||
- Stufe 1 ist lokal (`local_source_artifact`); autoritativ sind lokale Source Registry und `VerifiedArtifact`. Es gibt keinen externen Rekonstruktions-Call im Runtime-Pfad. Abbruch nur ohne Nutzertext.
|
- Stufe 1 ist lokal (`local_source_artifact`); autoritativ sind lokale Source Registry und `VerifiedArtifact`. Es gibt keinen externen Rekonstruktions-Call im Runtime-Pfad. Abbruch nur ohne Nutzertext.
|
||||||
- Semantische Detection jedes persönlichen Egress; Detect-Treffer nicht automatisch bestätigt; Legacy-Mappings lokal reviewen (`/admin/identities`)
|
- Semantische Detection jedes persönlichen Egress; Detect-Treffer nicht automatisch bestätigt; Legacy-Mappings lokal reviewen (`/admin/identities`)
|
||||||
- Response Validation prüft nur im aktuellen Request aktiv maskierte Identitäten. Historische Mappings und Homonyme blockieren keinen gültigen Modelltext.
|
- Response Validation nach dem Generate normalisiert aktiven Klartext lokal. Das verhindert keinen bereits erfolgten Egress. Pre-Egress bleibt fail-closed.
|
||||||
- Journal-Generate muss eine inhaltstreue, redaktionell verbesserte Fassung liefern, keine Dialogkopie. Faktentreue bleibt; Wortlaut darf (und soll) lesbar werden. `prose_edit` und `notes_to_journal` sind lokal. Ähnlichkeit ist Diagnose, kein Retry-Grund.
|
- Journal-Generate muss eine inhaltstreue, redaktionell verbesserte Fassung liefern, keine Dialogkopie. Faktentreue bleibt; Wortlaut darf (und soll) lesbar werden. `prose_edit` und `notes_to_journal` sind lokal. Ein überwiegend stichpunktartiger Dialog bleibt `notes_to_journal`. Unvollständige Sätze dürfen ohne neue Tatsache umgebaut werden. Ähnlichkeit ist Diagnose, kein Retry-Grund. Ein nicht übernommener Modelltext ist kein stiller Rohtext-Entwurf.
|
||||||
- Qualitative Modellfälle nicht als automatisch vollständig bewiesen bezeichnen. Vergleich: `backend/journal_eval.py`. Live-Qualität ist unbestätigt, bis ein kontrollierter Modellvergleich läuft.
|
- **Additiv 2026-08-27 (Mischquellen):** Die lokale Zuordnung eines gesamten Tages zu `prose_edit` oder `notes_to_journal` war eine technische Zwischenlösung und gilt als ersetzt. Mischquellen sind der Normalfall und werden in einem Generate-Aufruf verarbeitet. Keine dritte Mischkategorie.
|
||||||
|
- Qualitative Modellfälle nicht als automatisch vollständig bewiesen bezeichnen. Vergleich: `backend/journal_eval.py` (Baseline, vorheriger Vertrag, aktueller Prompt; `--profile-ab` für Stilvorgaben). Live-Qualität ist unbestätigt, bis ein kontrollierter Modellvergleich mit `--live --profile-id` über das Privacy Gateway läuft. Das Harness erklärt keinen Sieger. Fake-Läufe beweisen den Vertrag, nicht die Modellprosa.
|
||||||
|
|
|
||||||
|
|
@ -101,19 +101,35 @@ Damit werden Inhaltstreue und sprachliche Gestaltung bewusst getrennt.
|
||||||
|
|
||||||
Faktentreue und Wortlauttreue sind getrennt. Unsicherheit, Verneinung, Korrekturen sowie Plan versus Vollzug müssen semantisch erhalten bleiben, nicht wortidentisch. Sprachlich nötige Übergänge sind erlaubt, solange sie keine neue Ursache, Bewertung oder Handlung behaupten. Gute Originalformulierungen und direkte Rede dürfen bleiben.
|
Faktentreue und Wortlauttreue sind getrennt. Unsicherheit, Verneinung, Korrekturen sowie Plan versus Vollzug müssen semantisch erhalten bleiben, nicht wortidentisch. Sprachlich nötige Übergänge sind erlaubt, solange sie keine neue Ursache, Bewertung oder Handlung behaupten. Gute Originalformulierungen und direkte Rede dürfen bleiben.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Narrationsvertrag, Redaktion und Evaluation):** Inhaltlich unveränderlich bleiben Ereignisse, Personen, Orte, Zeiten, Chronologie, Verneinungen, Unsicherheiten, Plan versus Vollzug, ausdrücklich genannte Gefühle und Bewertungen, Korrekturen und einmalige Details. Redaktionell erforderlich sind Rechtschreibung, Grammatik, vollständige Sätze, Übergänge, das Verbinden zusammengehöriger Angaben und das sprachliche Gewichten vorhandener Kontraste und besonderer Momente. Unvollständige Quellen dürfen ohne neue Tatsache umgebaut werden; Raten und syntaktisch kaputte Ausgabe sind unzulässig. `prose_edit` ist Endredaktion, `notes_to_journal` erzeugt zusammenhängende Journalprosa statt einer verbundenen Liste. Stilquellenpriorität: bestätigtes Writing Profile, dann finale Nutzerfassungen, dann Importe, dann Trait-Exemplare, sonst neutraler Journalstil. Der aktuelle Tagesdialog bleibt nur Inhaltsquelle. Lexikalische Ähnlichkeit und unvollständige Syntax sind Diagnose. Vergleich: `backend/journal_eval.py` (offline Standard; Live nur `--live --profile-id`; Profil-A/B mit `--profile-ab`). Das Harness erklärt keinen Sieger.
|
||||||
|
|
||||||
Zwei journal-spezifische Editorial Modes, lokal und ohne zweiten Modellaufruf:
|
Zwei journal-spezifische Editorial Modes, lokal und ohne zweiten Modellaufruf:
|
||||||
|
|
||||||
- `prose_edit`: bereits erzählerischer Rohtext wird behutsam überarbeitet.
|
- `prose_edit`: bereits erzählerischer Rohtext wird behutsam überarbeitet.
|
||||||
- `notes_to_journal`: Stichpunkte und Fragmente werden zu zusammenhängender Journalprosa. Mischfall: `prose_edit`, wenn mindestens die Hälfte der Quellenblöcke Satzzeichen `.?!` enthält, sonst `notes_to_journal`.
|
- `notes_to_journal`: Stichpunkte und Fragmente werden zu zusammenhängender Journalprosa. Die lokale Klassifikation nutzt Bullet-Anteil, kurze Fragmente, vollständige Sätze und Absatzstruktur. Ein überwiegend stichpunktartiger Dialog mit einzelnen Sätzen bleibt `notes_to_journal`; ausformulierter Fließtext bleibt `prose_edit`.
|
||||||
|
|
||||||
Das bestätigte Writing Profile ist die primäre Stilautorität (Core, Journal-Facet, aktive Traits). Historische finale, vom Nutzer akzeptierte oder bearbeitete Tagebucheinträge sind reine Stilreferenz; importierte Texte danach; sonst neutraler Journalstil. Der aktuelle Tagesdialog ist keine Stilautorität. Historische Beispiele dürfen keine aktuellen Tatsachen liefern.
|
Das bestätigte Writing Profile ist die primäre Stilautorität (Core, Journal-Facet, aktive Traits). Historische finale, vom Nutzer akzeptierte oder bearbeitete Tagebucheinträge sind reine Stilreferenz; importierte Texte danach; sonst neutraler Journalstil. Der aktuelle Tagesdialog ist keine Stilautorität. Historische Beispiele dürfen keine aktuellen Tatsachen liefern.
|
||||||
|
|
||||||
Kosten: im Normalfall genau ein externer `journal_generate`-Aufruf. Kein Rekonstruktionsmodell, keine Modusklassifikation per Modell, kein Qualitätsretry wegen Textähnlichkeit. Privacy-Retry nur bei request-spezifischem Identitätsverstoß. Textähnlichkeit ist Diagnose, keine Annahme- oder Verwerfungsregel.
|
Kosten: im Normalfall genau ein externer `journal_generate`-Aufruf. Kein Rekonstruktionsmodell, keine Modusklassifikation per Modell, kein Qualitätsretry wegen Textähnlichkeit, kein Privacy-Retry nach der Modellantwort. Textähnlichkeit ist Diagnose, keine Annahme- oder Verwerfungsregel.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Privacy vor dem Socket, Provenienz danach):** Aktiver Klartext in der Modellantwort wird lokal dem Request-Token zugeordnet und demaskiert. Journalspezifische Provenienz lässt nur in den heutigen Nutzerquellen belegte Identitäten zu. Unbekannte oder generische Platzhalter materialisieren keine Identität. Ein nicht lokal lösbarer Fehler speichert keinen `journal_draft` und liefert `journal_generation_not_accepted` / „Generierung nicht übernommen.“ Eine lokale Quellenansicht darf separat erscheinen, gilt aber nicht als generierter Entwurf.
|
||||||
|
|
||||||
Automatisierte Tests beweisen Datenfluss und Promptvertrag, nicht die Prosaqualität eines echten Modells. Vergleichbare Live-Evaluation: `backend/journal_eval.py` (manuell, nicht im Produktionslauf). Live-Qualität bleibt unbestätigt, bis ein kontrollierter Modellvergleich ausgeführt wurde.
|
Automatisierte Tests beweisen Datenfluss und Promptvertrag, nicht die Prosaqualität eines echten Modells. Vergleichbare Live-Evaluation: `backend/journal_eval.py` (manuell, nicht im Produktionslauf). Live-Qualität bleibt unbestätigt, bis ein kontrollierter Modellvergleich ausgeführt wurde.
|
||||||
|
|
||||||
Allgemeine Infrastruktur (Provenienz, Maskierung, Budget, bestätigte Profilstände, Stilreferenz-Auswahl, Traces) bleibt intent-neutral. Journalregeln (Editorial Modes, Journal-Facet, Ich-Form, Titel, Absatzform) liegen nur im Journal-Adapter.
|
Allgemeine Infrastruktur (Provenienz, Maskierung, Budget, bestätigte Profilstände, Stilreferenz-Auswahl, Traces) bleibt intent-neutral. Journalregeln (Editorial Modes, Journal-Facet, Ich-Form, Titel, Absatzform) liegen nur im Journal-Adapter.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Generation Policy):** Die redaktionelle Freiheit der Journalgenerierung ist über vier journalspezifische Ausgabeeinstellungen steuerbar, jeweils ganzzahlig von 0 bis 100. Werte außerhalb dieses Bereichs werden abgelehnt, nicht still begrenzt. Defaults für bestehende Profile: `transformation_strength` 75, `detail_retention` 95, `voice_strength` 80, `narrative_shaping` 60.
|
||||||
|
|
||||||
|
Die Zahlen sind keine Modelltemperatur und keine Sampling-Parameter. Ein lokaler, deterministischer Compiler übersetzt sie in kurze Anweisungen (`transformation_instructions`, `detail_instructions`, `voice_instructions`, `narrative_instructions`). `source_mode_instructions` bleibt automatisch aus `prose_edit` beziehungsweise `notes_to_journal`; der Nutzer stellt diesen Modus nicht selbst ein. Die Anweisungstexte selbst sind administrierbare Konfiguration (Seed und Datenbank), nicht Anwendungscode.
|
||||||
|
|
||||||
|
Die vier Parameter gehören nicht zum Writing Profile, werden nicht aus Nutzerverhalten gelernt und verändern keine Privacy-, Provenienz- oder Faktentreue-Regeln. Unveränderlich bleiben: keine erfundenen Tatsachen, Plan versus Vollzug, Verneinungen und Unsicherheiten, unbelegte Chronologie, keine Inhalte aus Writing Profile oder Stilbeispielen, vollständige Nutzung des Privacy Gateway.
|
||||||
|
|
||||||
|
Persistenz ist profilbezogen (`journal_generation_settings`). Ein Request-Snapshot gilt nur für den aktuellen Lauf; Speichern als Profilstandard nur bei ausdrücklichem `remember_generation_policy`. Das Prinzip (lokal kompilierte Ausgabeeinstellungen statt uninterpretierter Zahlen im Prompt) kann später für andere Outputs wiederverwendet werden. Dieser Schritt baut keine weiteren Intents.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (benannte Richtlinienausprägungen):** Die numerischen Werte 0–100 und die Schieberegler sind für den MVP verworfen. Die vier Dimensionen bleiben unabhängig kombinierbar: Bearbeitungsstärke, Detailerhaltung, Persönliche Stimme, Erzählgestaltung. Jede Dimension hat eine administrierbare, erweiterbare Liste benannter Ausprägungen (`generation_guidelines`) mit stabiler ID, Key, Nutzerbezeichnung, Kurzbeschreibung, Promptanweisung, Status `draft`/`active`/`archived` und Revision. Aktive Ausprägungen sind inhaltlich unveränderlich; Änderungen entstehen durch Klonen. Archivierte bleiben für alte Entwürfe lesbar, sind aber nicht mehr neu wählbar. Der Journal-Tag bietet vier kompakte Auswahllisten nur mit aktiven Ausprägungen; Promptanweisungen bleiben Nutzern verborgen. Der Generate-Request sendet `generation_selection` mit den vier IDs. Ohne explizite Auswahl gelten gespeicherte Profilwerte, sonst die aktiven Systemstandards. Unbekannte, nicht veröffentlichte oder archivierte IDs werden abgelehnt, nicht still ersetzt. `remember_generation_selection` speichert die Kombination nur auf ausdrücklichen Wunsch. Am Entwurf bleibt ein kompakter Snapshot (IDs, Keys, Bezeichnungen, Revisionen, automatischer Quellenmodus, Prompt-Slug/-Revision, Modell) ohne Promptkörper und ohne persönliche Quellen. Source Modes `prose_edit` und `notes_to_journal` bleiben automatisch und nur administrierbar.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Mischquellen, ein Generate-Aufruf):** Die frühere exklusive Unterscheidung zwischen `prose_edit` und `notes_to_journal` war eine technische Zwischenlösung und gilt als ersetzt. Mischquellen – Fließtext, Stichpunkte und Fragmente im selben Dialog, derselben Nachricht und demselben Tag – sind der Normalfall. Sie werden in genau einem Generate-Aufruf verarbeitet; das Modell behandelt jede Passage nach ihrer Form. Es gibt keine vorgeschaltete Gesamtklassifikation und keine dritte Kategorie wie `mixed` oder `hybrid`. Die vier nutzergewählten Gestaltungsdimensionen bleiben davon unabhängig. `CURRENT_DAY_SOURCES` bleibt vollständig und in Quellenreihenfolge. Ein administrativ angepasster Prompt mit veraltetem Platzhalter (`{{source_mode_instructions}}`, `{{editorial_mode}}`, `{{editorial_instructions}}`) wird vor dem Provider mit `prompt_contract_incompatible` abgelehnt, ohne stillen Rohtext-Draft.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Journal Entry als häufiger, aber nicht zwingender Abschluss einer Tagesreflexion
|
## Journal Entry als häufiger, aber nicht zwingender Abschluss einer Tagesreflexion
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,14 @@ Nicht übernehmen: Mitai-Admin für Körpertarife, Coupons, Training Types als K
|
||||||
|
|
||||||
**Additiv 2026-08-26:** Lokale Identitätsregistry unter `/admin/identities`. Detect-Vorschläge sind unbestätigt. Compact-Diagnose enthält Detect-Abdeckung, Chunks, Kosten und Laufzeit, aber keine Labels.
|
**Additiv 2026-08-26:** Lokale Identitätsregistry unter `/admin/identities`. Detect-Vorschläge sind unbestätigt. Compact-Diagnose enthält Detect-Abdeckung, Chunks, Kosten und Laufzeit, aber keine Labels.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27:** Die Journal-Testspur zeigt ohne persistente Klartextdaten Modell, Prompt-Slug und Revision, Editorial Mode, Modelltext übernommen ja/nein, Antwort-Normalisierung, Provenienz, Abbruchgrund, Writing-Profile-Präsenz, Stilquellen nach Typ, budgetentfernte optionale Blöcke, lexikalische Ähnlichkeit und unvollständige Syntax als Diagnose sowie Tokens, Kosten und Laufzeit. Maskierung prüft den vollständigen gerenderten Egress. Ein verworfener Modelltext bleibt in der Admin-Antwort als maskierter Rohoutput sichtbar, wird aber nicht als erfolgreicher Entwurf geführt.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27:** Die Journal-Testspur darf die vier Generation-Policy-Werte, die gewählten `variant_key`-Werte, die Konfigurationsrevision und den Editorial Mode zeigen (`generation_policy.values`, `generation_policy.stages`/`selection`, `config_revision`, Quelle, `remembered`). Das sind Ausgabeeinstellungen, keine Modelltemperatur. Kompilierte Anweisungstexte und Promptkörper werden dadurch nicht persistent gespeichert. Admin-Vorschau der Fragmente ist lokal und speichert keine Nutzertexte.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (benannte Ausprägungen):** Die Testspur zeigt `generation_selection` mit IDs, Keys, Revisionen, automatischem Quellenmodus, Quelle `request`/`profile` und `remembered`. Keine 0–100-Werte. Der Entwurfssnapshot ist zusätzlich am Draft persistiert und im Editor als „Erzeugt mit …“ sichtbar. Admin-Übersicht rendert nicht alle Anweisungstexte gleichzeitig.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (kein Quellenmodus in der Laufdiagnose):** Editorial Mode und automatischer Quellenmodus sind keine Laufentscheidung mehr. Die Testspur zeigt die vier Gestaltungsausprägungen, Prompt-Slug/-Revision und Modell. Veraltete Promptplatzhalter führen zu `prompt_contract_incompatible`.
|
||||||
|
|
||||||
## 5. Entscheidungsstand
|
## 5. Entscheidungsstand
|
||||||
|
|
||||||
| Thema | Stand | Status |
|
| Thema | Stand | Status |
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,33 @@ Produkt-Endpunkte hinter Session-Auth, Isolation über `profile_id`:
|
||||||
- Status: `GET /egress-status` (Provider bereit / fail-closed)
|
- Status: `GET /egress-status` (Provider bereit / fail-closed)
|
||||||
- Derselbe Zug auch unter `POST /api/dialogue/conversations/{id}/turn`
|
- Derselbe Zug auch unter `POST /api/dialogue/conversations/{id}/turn`
|
||||||
- Admin-Antwort zusätzlich `decision` und `trace` (Testphase)
|
- Admin-Antwort zusätzlich `decision` und `trace` (Testphase)
|
||||||
- Generate: `POST /days/{id}/generate` nur explizit; optionale `conversation_ids`; `include_existing` nur nach ausdrücklicher UI-Auswahl; ohne Auswahl kein stilles Mergen
|
- Generate: `POST /days/{id}/generate` nur explizit; optionale `conversation_ids`; `include_existing` nur nach ausdrücklicher UI-Auswahl; ohne Auswahl kein stilles Mergen; optionaler `generation_policy`-Snapshot und `remember_generation_policy`
|
||||||
|
- Generation Policy: `GET /generation-settings` — profilbezogene Defaults der vier Journal-Ausgabeeinstellungen, nicht Writing Profile. Die Anweisungstexte kommen aus `generation_instruction_fragments`, nicht aus dem Nutzerregler.
|
||||||
|
- **Additiv 2026-08-27:** Generate sendet `generation_selection` (`transformation_id`, `detail_id`, `voice_id`, `narrative_id`) und `remember_generation_selection` (Default false). `GET /generation-settings` liefert `selection`, aktive `options` ohne Promptanweisung und System-`defaults`. Unbekannte oder nicht aktive IDs: 400 `invalid_generation_selection`. Der gespeicherte Entwurf enthält `generation_snapshot` und `generation_summary`.
|
||||||
|
|
||||||
|
## 5.3 Implementierungsstand (Generierungsrichtlinien)
|
||||||
|
|
||||||
|
**Status: Code vorhanden.** Admin-Session. Purpose zunächst `journal_generate`.
|
||||||
|
|
||||||
|
- `GET /api/admin/generation-instructions/{purpose}`
|
||||||
|
- `PUT /api/admin/generation-instructions/{purpose}` — vollständiger Satz, transaktional, ungültige Teilstände werden abgelehnt
|
||||||
|
- `POST /api/admin/generation-instructions/{purpose}/reset`
|
||||||
|
- `POST /api/admin/generation-instructions/{purpose}/preview` — lokal, kein Provider, keine persönlichen Quellen
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (benannte Ausprägungen):** Der numerische Katalog-PUT entfällt. Übersicht ohne Promptkörper. Detail, Klon, Draft-Update, Veröffentlichen, Archivieren, Standard und Löschen nur für unverwendete Drafts:
|
||||||
|
|
||||||
|
- `GET /api/admin/generation-instructions/{purpose}` — Kartenfelder, keine Anweisungstexte
|
||||||
|
- `GET /api/admin/generation-instructions/{purpose}/{id}`
|
||||||
|
- `POST /api/admin/generation-instructions/{purpose}` — neuer Draft
|
||||||
|
- `PUT /api/admin/generation-instructions/{purpose}/{id}` — nur Draft
|
||||||
|
- `POST .../{id}/clone|publish|archive|default`
|
||||||
|
- `DELETE .../{id}` — nur ungenutzter Draft
|
||||||
|
- `POST .../reset` — neue Seed-Drafts, keine Überschreibung historischer Varianten
|
||||||
|
- `POST .../preview` — lokal gerenderter Prompt, Dummy-Kontext, kein Provider
|
||||||
|
|
||||||
|
UI: `/admin/generation` mit getrennten Dimensionen, Karten und einem Editor nach „Öffnen“. Source Modes eigener Abschnitt. Keine Seed-Dateipfade in der normalen Adminbeschreibung.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (kein Source Mode):** Preview und Admin-UI verwalten nur die vier Gestaltungsdimensionen. `source_mode` ist kein Request-Feld mehr. Ein Custom-Prompt mit veraltetem Platzhalter: 409 `prompt_contract_incompatible`, kein Provideraufruf, kein Draft.
|
||||||
- Entries: `POST /entries`, `GET /entries/{id}`, Versionen, Versions-Restore, Soft-Delete, `POST /entries/{id}/undelete`, `POST /entries/{id}/purge`. Speichern aus einem Entwurf mit `entry_id` + `origin=accepted_draft` legt eine neue Version desselben Entry an.
|
- Entries: `POST /entries`, `GET /entries/{id}`, Versionen, Versions-Restore, Soft-Delete, `POST /entries/{id}/undelete`, `POST /entries/{id}/purge`. Speichern aus einem Entwurf mit `entry_id` + `origin=accepted_draft` legt eine neue Version desselben Entry an.
|
||||||
- Media: Upload/GET/DELETE; Bilder und einzelne Videos; Position/Unterschrift im Entry-Body als Markdown ``
|
- Media: Upload/GET/DELETE; Bilder und einzelne Videos; Position/Unterschrift im Entry-Body als Markdown ``
|
||||||
- Tagesstichpunkte: `PATCH /days/{id}/scratch` — lokal, nicht Context-Builder
|
- Tagesstichpunkte: `PATCH /days/{id}/scratch` — lokal, nicht Context-Builder
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachlich
|
||||||
8. `../functional/implementation_foundation.md`
|
8. `../functional/implementation_foundation.md`
|
||||||
9. `../functional/mvp_stand_und_abgleich.md`
|
9. `../functional/mvp_stand_und_abgleich.md`
|
||||||
10. bei Gegenlesung gegen das Gesamtziel: `../functional/produktvision_und_produktidentitaet.md`
|
10. bei Gegenlesung gegen das Gesamtziel: `../functional/produktvision_und_produktidentitaet.md`
|
||||||
11. bei Bedarf: `../functional/writing_profile_and_journaling.md`, `../functional/guardrails.md`
|
11. bei Bedarf: `../functional/writing_profile_and_journaling.md`, `../functional/guardrails.md`, `../functional/mvp_freeze_candidate.md`
|
||||||
|
|
||||||
### Voice / PWA-Offline
|
### Voice / PWA-Offline
|
||||||
|
|
||||||
|
|
@ -134,5 +134,5 @@ Vor Vereinfachungen die Invariantenliste in `technische_zielarchitektur.md` §3.
|
||||||
|
|
||||||
## 5. Code-Gerüst
|
## 5. Code-Gerüst
|
||||||
|
|
||||||
Lokaler Produktrahmen in `frontend/` und `backend/`. Zusammenhängende technische Umsetzung: `mvp_implementation.md`. Auth, Nutzerverwaltung, Prompt-DB, Platzhalter, Feature-Check, Privacy Gateway. **Dialog-Layer 0:** Conversations/Messages/`usage_sessions`, Thread-/Space-Identität, Derived-Hülle mit Provenance. **MVP-Journal-Slice:** nutzersichtbare Spaces, Journal Days, Dialogzug (ein Call), explizite Draft-Generierung, versionierte Entries (Übernehmen = neue Version, Dirty-Schutz im Editor), Inline-Medien, lokale Stichpunkte ohne Egress, Writing Profile (Hülle mit dynamischen Traits, Initial Profile Build). Fit-Gap: `../functional/mvp_stand_und_abgleich.md` (2026-08-25). Konfigurierbare Prompts `mvp.dialogue_turn`, `mvp.journal_reconstruct`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Persönliche Folgestufen konsumieren ein lokal erzeugtes `VerifiedArtifact` (`provenance_verification.md`); der Journal-Adapter setzt die Policy. Journal-Generate: Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Narrations-Call (Faktentreue, eigenständige Journalprosa, nicht Wortlautkopie). Request-scoped Maskierungsmanifest und Pre-Egress: `privacy_gateway.md` §9.4. Semantische Request-Detection und bestätigte Registry: `privacy_gateway.md` §9.5. Provider-URL/Modell/Policy unter Admin → Schnittstellen; Keys nur in `backend/.env`. Schichten: Maskierung, Dialogzug, Journalentwurf, explizite Profile-Review. Admin-Testspur am Dialogzug (Egress, Antwort, Operation). Privacy-Pfad durchgängig, Security Layer nicht vollständig (`privacy_gateway.md` §9.2). Kein Stripe, keine Threads-UI, kein semantisches Retrieval. SQLite nur lokal.
|
Lokaler Produktrahmen in `frontend/` und `backend/`. Zusammenhängende technische Umsetzung: `mvp_implementation.md`. Auth, Nutzerverwaltung, Prompt-DB, Platzhalter, Feature-Check, Privacy Gateway. **Dialog-Layer 0:** Conversations/Messages/`usage_sessions`, Thread-/Space-Identität, Derived-Hülle mit Provenance. **MVP-Journal-Slice:** nutzersichtbare Spaces, Journal Days, Dialogzug (ein Call), explizite Draft-Generierung, versionierte Entries (Übernehmen = neue Version, Dirty-Schutz im Editor), Inline-Medien, lokale Stichpunkte ohne Egress, Writing Profile (Hülle mit dynamischen Traits, Initial Profile Build). Fit-Gap: `../functional/mvp_stand_und_abgleich.md` (2026-08-25). Konfigurierbare Prompts `mvp.dialogue_turn`, `mvp.journal_reconstruct`, `mvp.journal_generate`, `mvp.entity_detect`, `mvp.profile_review`. Persönliche Folgestufen konsumieren ein lokal erzeugtes `VerifiedArtifact` (`provenance_verification.md`); der Journal-Adapter setzt die Policy. Journal-Generate: Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Narrations-Call (Faktentreue, eigenständige Journalprosa, nicht Wortlautkopie). Journalspezifische Ausgabeeinstellungen lokal kompiliert (`journal_generation_policy.py`); Anweisungstexte in `generation_instruction_fragments`, nicht im Code; das ist keine Modelltemperatur. Opt-in-Vergleich: `backend/journal_eval.py` (nicht Produktionslauf; Live nur explizit über das Privacy Gateway). Request-scoped Maskierungsmanifest und Pre-Egress: `privacy_gateway.md` §9.4. Semantische Request-Detection und bestätigte Registry: `privacy_gateway.md` §9.5. Provider-URL/Modell/Policy unter Admin → Schnittstellen; Keys nur in `backend/.env`. Schichten: Maskierung, Dialogzug, Journalentwurf, explizite Profile-Review. Admin-Testspur am Dialogzug (Egress, Antwort, Operation). Privacy-Pfad durchgängig, Security Layer nicht vollständig (`privacy_gateway.md` §9.2). Kein Stripe, keine Threads-UI, kein semantisches Retrieval. SQLite nur lokal.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,12 @@ Zusätzlich zu Layer 0:
|
||||||
- `journal_entries` + `journal_entry_versions` tragen Current Validity und History; Soft-Delete über `deleted_at`. Übernehmen eines Entwurfs schreibt eine neue Version des bestehenden Entry (`origin=accepted_draft`), sofern nicht ausdrücklich ein weiterer Entry angelegt wird. Die aktuelle Fassung steht in der Versionsliste ohne Restore auf sich selbst.
|
- `journal_entries` + `journal_entry_versions` tragen Current Validity und History; Soft-Delete über `deleted_at`. Übernehmen eines Entwurfs schreibt eine neue Version des bestehenden Entry (`origin=accepted_draft`), sofern nicht ausdrücklich ein weiterer Entry angelegt wird. Die aktuelle Fassung steht in der Versionsliste ohne Restore auf sich selbst.
|
||||||
- `media_assets` verweisen auf lokale Dateien unter `backend/data/media/`. Einbettung und Unterschrift stehen im Entry-Body als Markdown, nicht als eigene Caption-Tabelle.
|
- `media_assets` verweisen auf lokale Dateien unter `backend/data/media/`. Einbettung und Unterschrift stehen im Entry-Body als Markdown, nicht als eigene Caption-Tabelle.
|
||||||
- `writing_profiles` / `writing_profile_sources` halten den Current Brief. Quellenpriorität: finale Nutzerfassungen, Importe, Dialogstil (`dialogue_style` aus `user:`-Zeilen). Quellen tragen `occurred_at` und `context_hint`. KI-Drafts sind keine Stilquelle. Der Brief ist eine abgeleitete Sicht.
|
- `writing_profiles` / `writing_profile_sources` halten den Current Brief. Quellenpriorität: finale Nutzerfassungen, Importe, Dialogstil (`dialogue_style` aus `user:`-Zeilen). Quellen tragen `occurred_at` und `context_hint`. KI-Drafts sind keine Stilquelle. Der Brief ist eine abgeleitete Sicht.
|
||||||
|
- `journal_generation_settings` hält profilbezogene Defaults der vier Journal-Ausgabeeinstellungen. Das ist keine Writing-Profile-Tabelle und keine Temperatur.
|
||||||
|
- `generation_instruction_fragments` hält die sprachlichen Anweisungstexte je Purpose und Stufe. Governance wie `ai_prompts`: JSON-Seed, Admin-Edits bleiben, Reset stellt den aktuellen Seed wieder her.
|
||||||
|
- `journal_generation_selection` hält profilbezogen die vier Ausprägungs-IDs. Das ist keine Writing-Profile-Tabelle und keine Temperatur.
|
||||||
|
- `generation_guidelines` hält versionierbare benannte Ausprägungen je Purpose und Slot. Seed aus JSON, veröffentlichte Zeilen werden nicht überschrieben, Reset erzeugt neue Drafts.
|
||||||
|
- `journal_drafts.generation_snapshot` hält den kompakten Lauf-Snapshot ohne Promptkörper und ohne persönliche Quellen.
|
||||||
|
- **Additiv 2026-08-27:** Persistierte `source_mode`-Zeilen in `generation_guidelines` werden archiviert und nicht mehr im Runtime-Pfad verwendet. `generation_snapshot` enthält keinen Quellenmodus und keinen Editorial Mode.
|
||||||
- `writing_profiles.lifecycle` (`uninitialized` | `initial_pending` | `confirmed`) trennt Korpus-Sammlung vom kontinuierlichen Lernen. `writing_profile_traits` / `writing_profile_trait_refs` sind die dynamischen semantischen Merkmale inkl. Evidence und Exemplaren. `writing_profile_facets` bleiben Layer-Hüllen (Core/context/output), kein festes Stilraster.
|
- `writing_profiles.lifecycle` (`uninitialized` | `initial_pending` | `confirmed`) trennt Korpus-Sammlung vom kontinuierlichen Lernen. `writing_profile_traits` / `writing_profile_trait_refs` sind die dynamischen semantischen Merkmale inkl. Evidence und Exemplaren. `writing_profile_facets` bleiben Layer-Hüllen (Core/context/output), kein festes Stilraster.
|
||||||
- `writing_profiles.governance` (`learning` | `advising` | `frozen`) und Locks verhindern stilles Voll-Überschreiben. `writing_profile_suggestions` trägt advising-Vorschläge (auch `trait_slug` / `action`).
|
- `writing_profiles.governance` (`learning` | `advising` | `frozen`) und Locks verhindern stilles Voll-Überschreiben. `writing_profile_suggestions` trägt advising-Vorschläge (auch `trait_slug` / `action`).
|
||||||
- `writing_profiles.version` / `review_ready` / `last_reviewed` plus `writing_profile_evidence`, `writing_profile_reviews`, `writing_profile_versions` tragen die Review-Pipeline: lokale Evidenz, gebündelte `kansho.profile_analysis_package`/`kansho.profile_analysis_result`, API- oder Paste-Kanal, Proposal vor User-Acceptance, nachvollziehbare Stände. Kein Re-Infer nach jedem Save. Der Current Brief bleibt abgeleitetes Runtime-Artefakt und wird nicht als Profil importiert.
|
- `writing_profiles.version` / `review_ready` / `last_reviewed` plus `writing_profile_evidence`, `writing_profile_reviews`, `writing_profile_versions` tragen die Review-Pipeline: lokale Evidenz, gebündelte `kansho.profile_analysis_package`/`kansho.profile_analysis_result`, API- oder Paste-Kanal, Proposal vor User-Acceptance, nachvollziehbare Stände. Kein Re-Infer nach jedem Save. Der Current Brief bleibt abgeleitetes Runtime-Artefakt und wird nicht als Profil importiert.
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ Einstieg: `backend/main.py` (Router: auth, users, dialogue, journal, prompts, pl
|
||||||
| `routers/dialogue.py` | Low-Level-Source `/api/dialogue`; derselbe Turn-Pfad |
|
| `routers/dialogue.py` | Low-Level-Source `/api/dialogue`; derselbe Turn-Pfad |
|
||||||
| `dialogue_turn.py` | Ein generativer Call, JSON `{operation, impulse}`, lokale Register-/Wächter, optional Repair |
|
| `dialogue_turn.py` | Ein generativer Call, JSON `{operation, impulse}`, lokale Register-/Wächter, optional Repair |
|
||||||
| `journal_generate.py` | Expliziter Draft: Stufe 1 lokales Quellenartefakt, Stufe 2 Narration |
|
| `journal_generate.py` | Expliziter Draft: Stufe 1 lokales Quellenartefakt, Stufe 2 Narration |
|
||||||
|
| `journal_generation_policy.py` | Journalspezifische Ausgabeeinstellungen; wählt benannte Richtlinienausprägungen, keine Modelltemperatur |
|
||||||
|
| `routers/generation_instructions.py` | Admin-API für versionierbare Generierungsrichtlinien |
|
||||||
| `journal_reconstruct.py` | Parser/Validator und `local_verified_artifact`; kein Runtime-Modellpfad |
|
| `journal_reconstruct.py` | Parser/Validator und `local_verified_artifact`; kein Runtime-Modellpfad |
|
||||||
| `prompt_budget.py` / `model_catalog.py` | Kontextfenster, Output-Reserve, konservative Tokenschätzung |
|
| `prompt_budget.py` / `model_catalog.py` | Kontextfenster, Output-Reserve, konservative Tokenschätzung |
|
||||||
| `journal_store.py` | Days, Drafts, Entries, Versionen, Scratch |
|
| `journal_store.py` | Days, Drafts, Entries, Versionen, Scratch |
|
||||||
|
|
@ -112,6 +114,10 @@ Schema: `backend/schema.sql`. Isolation über `profile_id`.
|
||||||
| `journal_draft_source_refs` / `journal_entry_version_source_refs` | Relationale Provenance (Conversation/Message-IDs). JSON-Listen sind Altbestand und werden beim Start migriert. |
|
| `journal_draft_source_refs` / `journal_entry_version_source_refs` | Relationale Provenance (Conversation/Message-IDs). JSON-Listen sind Altbestand und werden beim Start migriert. |
|
||||||
| `media_assets` | Datei + Kind; Referenz nur im Body `` |
|
| `media_assets` | Datei + Kind; Referenz nur im Body `` |
|
||||||
| `writing_profiles` / `writing_profile_sources` | Current Brief |
|
| `writing_profiles` / `writing_profile_sources` | Current Brief |
|
||||||
|
| `journal_generation_settings` | Profildefaults der vier Journal-Ausgabeeinstellungen (0–100). Nicht Writing Profile. |
|
||||||
|
| `generation_instruction_fragments` | Administrierbare Anweisungstexte je Purpose/Slot/Stufe. Seed aus JSON, gleiches Governance-Muster wie `ai_prompts`. |
|
||||||
|
| `journal_generation_selection` | Profildefaults als vier Ausprägungs-IDs. Nicht Writing Profile. |
|
||||||
|
| `generation_guidelines` | Versionierbare benannte Ausprägungen je Purpose/Slot (`draft`/`active`/`archived`). Seed aus JSON. |
|
||||||
| `writing_profile_evidence` / `writing_profile_reviews` / `writing_profile_versions` | Review-Pipeline: lokale Evidenz, Paket/Ergebnis, Versionen |
|
| `writing_profile_evidence` / `writing_profile_reviews` / `writing_profile_versions` | Review-Pipeline: lokale Evidenz, Paket/Ergebnis, Versionen |
|
||||||
|
|
||||||
Assignment: `conversations.space_id` / `journal_day_id` sind Felder, nicht Identität. `space_id` ohne `journal_day_id` ist zulässig (Space ≠ Journal-Container). `conversations.space_id` ohne FK. Kalendertag ≠ `messages.created`. Operative Dialogsignale liegen als Felder auf `conversations`, nicht als Writing Profile.
|
Assignment: `conversations.space_id` / `journal_day_id` sind Felder, nicht Identität. `space_id` ohne `journal_day_id` ist zulässig (Space ≠ Journal-Container). `conversations.space_id` ohne FK. Kalendertag ≠ `messages.created`. Operative Dialogsignale liegen als Felder auf `conversations`, nicht als Writing Profile.
|
||||||
|
|
@ -130,7 +136,7 @@ Kernpfade:
|
||||||
Context Builder → Gateway (`purpose=dialogue_turn`) → ein Generate-Call → Impuls speichern. Pronomenbindung nur hier in `user:`-Zeilen.
|
Context Builder → Gateway (`purpose=dialogue_turn`) → ein Generate-Call → Impuls speichern. Pronomenbindung nur hier in `user:`-Zeilen.
|
||||||
|
|
||||||
2. **Journalentwurf** `POST /days/{id}/generate`
|
2. **Journalentwurf** `POST /days/{id}/generate`
|
||||||
Nur explizit. Optional `conversation_ids`. Ohne Auswahl kein stilles Mergen. Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Gateway-Zweck `journal_generate`. Pronomen ungebunden. Entry-`current_version_id` bleibt unangetastet. UI öffnet `?draft=1`.
|
Nur explizit. Optional `conversation_ids`. Ohne Auswahl kein stilles Mergen. Optional `generation_policy` (vier Ganzzahlen 0–100) und `remember_generation_policy`. Ohne Snapshot gelten gespeicherte Profilwerte. Mit Snapshot gelten die Request-Werte; Persistenz nur bei `remember_generation_policy=true`. `GET /generation-settings` liest die Profildefaults. Stufe 1 lokal (`local_source_artifact`), Stufe 2 ein Gateway-Zweck `journal_generate`. Pronomen ungebunden. Entry-`current_version_id` bleibt unangetastet. UI öffnet `?draft=1`.
|
||||||
|
|
||||||
3. **Speichern** `POST /entries`
|
3. **Speichern** `POST /entries`
|
||||||
Mit `entry_id` + `origin=accepted_draft`: neue Version desselben Entry. Sonst neuer Entry oder `user_edit`.
|
Mit `entry_id` + `origin=accepted_draft`: neue Version desselben Entry. Sonst neuer Entry oder `user_edit`.
|
||||||
|
|
@ -197,6 +203,7 @@ Gateway-Verfahren und offener Security Layer: `privacy_gateway.md` §9.1–9.2 u
|
||||||
| Request-scoped Maskierungsmanifest / Pre-Egress | `backend/tests/test_privacy_manifest.py` |
|
| Request-scoped Maskierungsmanifest / Pre-Egress | `backend/tests/test_privacy_manifest.py` |
|
||||||
| Journal-Narration (Faktentreue, nicht Wortlaut) | `backend/tests/test_journal_narration.py` |
|
| Journal-Narration (Faktentreue, nicht Wortlaut) | `backend/tests/test_journal_narration.py` |
|
||||||
| Journal-Editorial Modes, Profil, Stilreferenzen | `backend/tests/test_journal_editorial.py` |
|
| Journal-Editorial Modes, Profil, Stilreferenzen | `backend/tests/test_journal_editorial.py` |
|
||||||
|
| Journal-Generation-Policy | `backend/tests/test_journal_generation_policy.py` |
|
||||||
| Journal-Eval-Vertrag (kein Live-Qualitätsbeleg) | `backend/tests/test_journal_eval.py` |
|
| Journal-Eval-Vertrag (kein Live-Qualitätsbeleg) | `backend/tests/test_journal_eval.py` |
|
||||||
| Shape / Namen | `backend/tests/test_journal_shape.py` |
|
| Shape / Namen | `backend/tests/test_journal_shape.py` |
|
||||||
| Writing Profile | `backend/tests/test_writing_profile.py` |
|
| Writing Profile | `backend/tests/test_writing_profile.py` |
|
||||||
|
|
@ -447,11 +454,21 @@ Chunk-and-Merge für übergroße Tage, Tokenizer je Modellfamilie, persistente A
|
||||||
|
|
||||||
**Additiv 2026-08-26 (Journalprosa):** `mvp.journal_generate` formuliert aus verifizierten Informationen eigenständige Journalprosa. Der Quellwortlaut ist keine Ausgabevorlage. `shape_journal` ersetzt akzeptierten Modelltext nicht durch Dialogzeilen. Der aktuelle Tagesdialog wird nicht als Stilprofil an denselben Lauf angehängt; ohne individuelles Writing Profile gilt ein neutraler Journalstil. Systemprompts mit unverändertem Template werden über `seed_revision` `2026-08-26-journal-narration-v1` idempotent aktualisiert; unabhängig editierte Prompts bleiben.
|
**Additiv 2026-08-26 (Journalprosa):** `mvp.journal_generate` formuliert aus verifizierten Informationen eigenständige Journalprosa. Der Quellwortlaut ist keine Ausgabevorlage. `shape_journal` ersetzt akzeptierten Modelltext nicht durch Dialogzeilen. Der aktuelle Tagesdialog wird nicht als Stilprofil an denselben Lauf angehängt; ohne individuelles Writing Profile gilt ein neutraler Journalstil. Systemprompts mit unverändertem Template werden über `seed_revision` `2026-08-26-journal-narration-v1` idempotent aktualisiert; unabhängig editierte Prompts bleiben.
|
||||||
|
|
||||||
**Additiv 2026-08-26 (Editorial Modes und wirksames Profil):** Journal-Generate wählt lokal `prose_edit` oder `notes_to_journal` (`journal_editorial.py`), ohne zweiten Modellaufruf. Der Narrationsprompt (`seed_revision` `2026-08-26-journal-editorial-v1`) trennt `CURRENT_DAY_SOURCES`, `WRITING_PROFILE` und `STYLE_EXAMPLES`. Stufe 2 erhält den kompakten bestätigten Task Brief (Core, Journal-Facet, höchstens sechs Traits) plus höchstens zwei historische finale Einträge oder Importe als Stilreferenz; der aktuelle Kalendertag ist ausgeschlossen. Unbestätigte Profile sind keine Stilautorität (neutraler Fallback). Textähnlichkeit steht als Diagnose im Trace, löst keinen Retry aus und verwirft keinen Text. Identitätsprüfung gilt für Titel und Textkörper. Private Gateway-Hilfsfunktionen werden über öffentliche, intent-neutrale Namen genutzt (`canonical_token`, `is_identity_mention`, `identity_occurrence_count`, `identity_label_pattern`).
|
**Additiv 2026-08-26 (Editorial Modes und wirksames Profil):** Journal-Generate wählt lokal `prose_edit` oder `notes_to_journal` (`journal_editorial.py`), ohne zweiten Modellaufruf. Der Narrationsprompt trennt `CURRENT_DAY_SOURCES`, `WRITING_PROFILE` und `STYLE_EXAMPLES`. Stufe 2 erhält den kompakten bestätigten Task Brief (Core, Journal-Facet, höchstens sechs Traits) plus höchstens zwei historische finale Einträge oder Importe als Stilreferenz; der aktuelle Kalendertag ist ausgeschlossen. Unbestätigte Profile sind keine Stilautorität (neutraler Fallback). Textähnlichkeit steht als Diagnose im Trace, löst keinen Retry aus und verwirft keinen Text. Identitätsprüfung gilt für Titel und Textkörper. Private Gateway-Hilfsfunktionen werden über öffentliche, intent-neutrale Namen genutzt (`canonical_token`, `is_identity_mention`, `identity_occurrence_count`, `identity_label_pattern`).
|
||||||
|
|
||||||
Opt-in-Vergleich: `backend/journal_eval.py` (Baseline / vorheriger Vertrag / aktueller Prompt). Nicht Teil des Produktionslaufs. Live nur mit `--live --profile-id` über das Privacy Gateway.
|
**Additiv 2026-08-27 (Narrationsvertrag v2):** `seed_revision` `2026-08-27-journal-editorial-v2` macht den redaktionellen Auftrag explizit: unveränderlicher Inhalt versus erforderliche sprachliche Gestaltung, inklusive unvollständiger Sätze und Gewichtung belegter Kontraste. Der request-scoped Journal-Trace führt ohne Klartext: Prompt-Slug und Revision, Editorial Mode, Modelltext übernommen ja/nein, Antwort-Normalisierung, Provenienz, Writing-Profile-Präsenz (Core/Facet/Traits/Briefgröße), Stilquellen nach Typ und Größe, budgetentfernte optionale Blöcke, lexikalische Ähnlichkeit, unvollständige Syntax, Tokens, Kosten, Laufzeit. Compact-Diagnose bleibt intent-neutral (`prompt_revision`, `generate_ms`, `response_normalization`, `generate_calls`, `model_text_accepted`). Opt-in-Vergleich: `backend/journal_eval.py` (Baseline / vorheriger Vertrag / aktueller Prompt; `--profile-ab`). Nicht Teil des Produktionslaufs. Live nur mit `--live --profile-id` über das Privacy Gateway. Das Harness erklärt keinen Sieger.
|
||||||
|
|
||||||
Tests: `backend/tests/test_journal_budget.py`, `backend/tests/test_journal_narration.py`, `backend/tests/test_journal_editorial.py`, `backend/tests/test_journal_eval.py`, `backend/tests/test_journal_shape.py`. Allgemeine Provenienzschicht: `provenance_verification.md`, `backend/tests/test_provenance.py`.
|
**Additiv 2026-08-27 (Privacy vor dem Socket, Provenienz danach):** Span-genaue Maskierung für request-lokale Detect-Treffer; bestätigte Registry bleibt separates Safety-Net. Response-Verarbeitung normalisiert aktiven Klartext lokal (`active_cleartext_normalized`) und startet keinen zweiten Generate-Call. Der Journal-Adapter prüft heutige Quellenbelege und speichert bei nicht lokal lösbarem Fehler keinen Draft (`journal_generation_not_accepted`). Editorial Mode klassifiziert über Bullet-/Fragment-/Satzstruktur. Detect-Eval bewertet exakte Spans; `openai/gpt-4.1-nano` bleibt konfiguriert und qualitativ unbestätigt.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Generation Policy v3):** `seed_revision` `2026-08-27-journal-policy-v3` ersetzt den unveränderten System-Default durch einen kürzeren Prompt ohne synthetische Arbeitsbeispiele. Vier journalspezifische Parameter (`transformation_strength`, `detail_retention`, `voice_strength`, `narrative_shaping`) werden lokal in Stufenanweisungen kompiliert und über die Prompt Engine als `{{transformation_instructions}}` und Geschwisterplatzhalter aufgelöst. Das sind semantische Ausgabeeinstellungen, keine Modelltemperatur und keine modellspezifischen Promptvarianten. Unveränderte Defaults werden idempotent aktualisiert; unabhängig editierte Prompts bleiben. Request-scoped Trace: Werte, Stufennamen, Quelle `request`/`profile`, `remembered`. Keine persistente Speicherung der kompilierten Anweisungstexte. Tests: `backend/tests/test_journal_generation_policy.py`.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (administrierbare Anweisungsfragmente):** Die sprachlichen Stufentexte liegen nicht im Python- oder Frontend-Code. Tabelle `generation_instruction_fragments`, Seed `backend/config/generation_instructions.seed.json`, Governance wie `ai_prompts` (unangetastet aktualisieren, Admin-Edits behalten, Reset auf aktuellen Seed). Der Compiler wählt nur. Ungültige Laufzeitkonfiguration: `generation_policy_invalid`, kein Provideraufruf, kein Draft. Admin: `/admin/generation` und `/api/admin/generation-instructions/{purpose}`. Trace zusätzlich: `selection` (`variant_key`), `config_revision`, Editorial Mode.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (benannte Richtlinienausprägungen):** Numerische 0–100-Werte und Schieberegler sind im aktiven Pfad entfernt. Tabelle `generation_guidelines` trägt versionierbare Ausprägungen (`transformation`, `detail`, `voice`, `narrative`, plus automatische `source_mode`). Seed-Texte bleiben fachlich unverändert. Aktive Ausprägungen sind unveränderlich; Klon erzeugt einen Draft, Reset legt neue Seed-Drafts an und überschreibt keine historische Variante. Generate sendet `generation_selection` und optional `remember_generation_selection` (Default false). Unbekannte oder nicht aktive IDs: `invalid_generation_selection`. Leere Anweisung oder unaufgelöster Platzhalter: `generation_policy_invalid` / `unresolved_placeholder`, kein Provideraufruf, kein Draft. `journal_drafts.generation_snapshot` speichert IDs, Keys, Labels, Revisionen, Quellenmodus, Prompt-Slug/-Revision und Modell, nicht Promptkörper. Admin-UI: Karten je Dimension, Anweisungstext erst nach Öffnen. Tests: `backend/tests/test_journal_generation_policy.py`.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27 (Mischquellen, Promptvertrag ohne Source Mode):** Die exklusive Modusentscheidung `prose_edit`/`notes_to_journal` (`choose_editorial_mode`, `EDITORIAL_MODES`, `source_mode_instructions`) war eine technische Zwischenlösung und ist aus dem aktiven Pfad entfernt. Mischquellen sind der Normalfall und werden in einem Generate-Aufruf verarbeitet; keine dritte Kategorie. Persistierte `source_mode`-Zeilen werden beim Seed archiviert und erscheinen nicht in der normalen Adminoberfläche. Runtime-Promptvertrag von `mvp.journal_generate`: `transformation_instructions`, `detail_instructions`, `voice_instructions`, `narrative_instructions`, `writing_profile`, `style_examples`, `reconstruction`, `existing_text`. `seed_revision` `2026-08-27-journal-mixed-sources-v1`. Unveränderte Systemdefaults werden idempotent aktualisiert; unabhängig editierte Prompts nicht überschrieben. Legacy-Custom-Prompt mit veraltetem Platzhalter: `prompt_contract_incompatible`, kein Provideraufruf, kein Draft. Snapshot ohne `source_mode`/`editorial_mode`. Tests: `backend/tests/test_journal_generation_policy.py`.
|
||||||
|
|
||||||
|
Tests: `backend/tests/test_journal_budget.py`, `backend/tests/test_journal_narration.py`, `backend/tests/test_journal_editorial.py`, `backend/tests/test_journal_eval.py`, `backend/tests/test_journal_shape.py`, `backend/tests/test_privacy_response_integrity.py`. Allgemeine Provenienzschicht: `provenance_verification.md`, `backend/tests/test_provenance.py`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ Noch offen im Code:
|
||||||
|
|
||||||
1. **Inhaltliche Context Minimization.** Es geht der zusammengebaute Dialog, nur durch eine Zeichenkappe begrenzt. Minimum-sufficient-context nach Aufgabe fehlt.
|
1. **Inhaltliche Context Minimization.** Es geht der zusammengebaute Dialog, nur durch eine Zeichenkappe begrenzt. Minimum-sufficient-context nach Aufgabe fehlt.
|
||||||
2. **Robuste Entity Detection.** Semantische Request-Detection mit Chunking ist gebaut (`§9.5`). Testphase: externes Detect-Modell über OpenRouter. Ziel: lokales Ollama. Fake-Provider-Tests beweisen nicht die semantische Modellqualität. Quasi-Identifikatoren (Beruf + Ort + Familie) bleiben unerkannt.
|
2. **Robuste Entity Detection.** Semantische Request-Detection mit Chunking ist gebaut (`§9.5`). Testphase: externes Detect-Modell über OpenRouter. Ziel: lokales Ollama. Fake-Provider-Tests beweisen nicht die semantische Modellqualität. Quasi-Identifikatoren (Beruf + Ort + Familie) bleiben unerkannt.
|
||||||
3. **Response Validation.** Prüfung nur gegen Identitäten, die im aktuellen Request tatsächlich als Identität maskiert wurden (`privacy_gateway.md` §9.4). Dieselbe Homonym-Regel wie beim Maskieren. Dialog-Egress maskiert den **gesamten** gerenderten Prompt. Bei einem echten Leak höchstens ein Korrekturversuch mit dem bereits maskierten Originalprompt plus generischer Anweisung; verworfene Rohantwort und Klarname gehen nicht erneut an den Provider. Der Dialogzug fällt lokal zurück statt leer zu bleiben. Journal-Generate übernimmt eine leckende Narration nicht; lokal entsteht ein Entwurf aus dem Quellenartefakt. Keine Quasi-Identifikatoren, keine vollständige Platzhalterprüfung, keine inhaltliche Minimierung. Keine pauschale Nachmaskierung der Modellantwort.
|
3. **Response Validation.** Nach dem Generate: Platzhalterintegrität, lokale Rehydrierung und Kennzeichnung. Aktiver Klartext im Reply wird dem Request-Token zugeordnet (`active_cleartext_normalized`) und danach demaskiert. Das verhindert keinen bereits erfolgten Egress und startet keinen zweiten Generate-Call. Journalspezifische Faktentreue entscheidet der Journal-Adapter. Dialog-Egress maskiert den **gesamten** gerenderten Prompt. Der Dialogzug darf lokal zurückfallen; ein verworfener Journaltext wird nicht als Entwurf gespeichert. Keine Quasi-Identifikatoren, keine inhaltliche Minimierung.
|
||||||
4. **Audit ohne Prompt-Inhalt.** Es gibt keine persistente Audit-Spur (Request-ID, Policy, Provider, ZDR-Status, maskierte Entitätstypen) ohne volle Prompts oder Mapping.
|
4. **Audit ohne Prompt-Inhalt.** Es gibt keine persistente Audit-Spur (Request-ID, Policy, Provider, ZDR-Status, maskierte Entitätstypen) ohne volle Prompts oder Mapping.
|
||||||
5. **Mapping-Härtung.** `identity_mappings` ist lokal, aber unverschlüsselt. Admin-Review unter `/admin/identities` existiert. Keine Verschlüsselung at rest. Legacy-Zeilen sind `legacy_review_required`, nicht automatisch bestätigt.
|
5. **Mapping-Härtung.** `identity_mappings` ist lokal, aber unverschlüsselt. Admin-Review unter `/admin/identities` existiert. Keine Verschlüsselung at rest. Legacy-Zeilen sind `legacy_review_required`, nicht automatisch bestätigt.
|
||||||
6. **Detect-Klartext in der Testphase.** Der Detect-Provider darf Klartext sehen. Bei externem Detect (OpenRouter) ist das ein bewusster Übergang, nicht der Zielpfad. Produktiv nur lokales Modell.
|
6. **Detect-Klartext in der Testphase.** Der Detect-Provider darf Klartext sehen. Bei externem Detect (OpenRouter) ist das ein bewusster Übergang, nicht der Zielpfad. Produktiv nur lokales Modell.
|
||||||
|
|
@ -157,25 +157,27 @@ Persönlicher Journal-Egress bleibt ausschließlich über dieses Gateway. Der no
|
||||||
|
|
||||||
Journal-Prompts werden nicht mehr durch `MAX_EGRESS_CHARS` still in der Mitte abgeschnitten. Passt Artefakt, Writing Profile und optionaler Bestand nicht ins Stufe-2-Budget, bricht Kanshō mit `prompt_budget_exceeded` ab. Stufe 2 erhält den vollständigen lokal rehydrierten Nutzerinhalt als `VerifiedArtifact` (`provenance_verification.md`); bei Übergröße ebenfalls Abbruch, kein stilles Kürzen.
|
Journal-Prompts werden nicht mehr durch `MAX_EGRESS_CHARS` still in der Mitte abgeschnitten. Passt Artefakt, Writing Profile und optionaler Bestand nicht ins Stufe-2-Budget, bricht Kanshō mit `prompt_budget_exceeded` ab. Stufe 2 erhält den vollständigen lokal rehydrierten Nutzerinhalt als `VerifiedArtifact` (`provenance_verification.md`); bei Übergröße ebenfalls Abbruch, kein stilles Kürzen.
|
||||||
|
|
||||||
Usage-Metadaten (Tokens, Kosten, Fenster, Reserve, Marge, Compression-Status) liegen in der Admin-Testspur `trace.budget` bzw. `trace.stages`. Journal-Generate setzt lokale Stufe 1 und den Gateway-Call der Stufe 2 zusammen; `run_log` sammelt Detect, Pre-Egress, Versuche, Antwortprüfung, Retries und Stufe-1-lokal ohne Promptkörper. Es gibt keine globale `debug_history` und keinen globalen Promptspeicher. Vollständige Prompts und Antworten höchstens im direkten Admin-Response des aktuellen Requests. Mapping bleibt ausgeschlossen.
|
Usage-Metadaten (Tokens, Kosten, Fenster, Reserve, Marge, Compression-Status) liegen in der Admin-Testspur `trace.budget` bzw. `trace.stages`. Journal-Generate setzt lokale Stufe 1 und den Gateway-Call der Stufe 2 zusammen; `run_log` sammelt Detect, Pre-Egress, den Generate-Aufruf, Antwortnormalisierung und Provenienzentscheidung ohne Promptkörper. Es gibt keine globale `debug_history` und keinen globalen Promptspeicher. Vollständige Prompts und Antworten höchstens im direkten Admin-Response des aktuellen Requests. Mapping bleibt ausgeschlossen.
|
||||||
|
|
||||||
## 9.4 Request-scoped Maskierungsmanifest (2026-08-26)
|
## 9.4 Request-scoped Maskierungsmanifest (2026-08-26)
|
||||||
|
|
||||||
Intent-neutral, im Gateway. Journalspezifische Fallback-Policy bleibt im Journal-Adapter.
|
Intent-neutral, im Gateway. Journalspezifische Provenienz- und Ablehnungspolicy bleibt im Journal-Adapter.
|
||||||
|
|
||||||
Ein Mapping ist nicht allein deshalb aktiv, weil es in der Profiltabelle existiert. Aktiv ist nur, was im aktuellen gerenderten Prompt nach derselben Identitätsregel wie die Maskierung tatsächlich ersetzt wurde. Homonyme, die absichtlich unmaskiert bleiben, werden dadurch nicht aktiv.
|
Ein Mapping ist nicht allein deshalb aktiv, weil es in der Profiltabelle existiert. Aktiv ist nur, was im aktuellen gerenderten Prompt nach derselben Identitätsregel wie die Maskierung tatsächlich ersetzt wurde. Homonyme, die absichtlich unmaskiert bleiben, werden dadurch nicht aktiv.
|
||||||
|
|
||||||
Das Manifest ist request-lokal: maskierter Egress-Text, aktive Tokens und Entitätstypen, optionale lokale Vorkommenszahl. `local_label` bleibt auf dem Request-Objekt und geht nicht in Logs, Compact-Diagnose oder persistente Spuren. Keine globale mutable Speicherung; parallele Requests und Profile bleiben getrennt. `mask_for_egress()` bleibt als String-Hülle.
|
Das Manifest ist request-lokal: maskierter Egress-Text, aktive Tokens und Entitätstypen, optionale lokale Vorkommenszahl. `local_label` bleibt auf dem Request-Objekt und geht nicht in Logs, Compact-Diagnose oder persistente Spuren. Keine globale mutable Speicherung; parallele Requests und Profile bleiben getrennt. `mask_for_egress()` bleibt als String-Hülle.
|
||||||
|
|
||||||
Vor dem Provider: Pre-Egress-Validierung. Für aktive Einträge darf kein als Identität klassifiziertes Klartextvorkommen im Egress bleiben. Homonyme derselben Regel dürfen bleiben. Fehlschlag: `egress_validation_failed`, Provider wird nicht aufgerufen.
|
Vor dem Provider: Pre-Egress-Validierung. Für bestätigte Registry-Einträge und labelbasierte Safety-Net-Treffer darf kein als Identität klassifiziertes Klartextvorkommen im Egress bleiben. Request-lokale Detect-Spans maskieren nur die erkannten Offsets; derselbe Wortlaut darf an anderer Stelle Allgemeinbegriff bleiben. Fehlschlag: `egress_validation_failed`, Provider wird nicht aufgerufen.
|
||||||
|
|
||||||
Response Validation prüft die Rohantwort ausschließlich gegen dieses aktive Manifest. Historische oder ungenutzte Mappings und unmaskierte Homonyme blockieren nicht. Vorhandene Platzhalter passieren und werden erst danach lokal demaskiert. Ein aktiver Klarname als Identität bleibt `response_validation_failed`. Es gibt keine pauschale Reparatur, die alle bekannten Namen in der Modellantwort durch Tokens ersetzt und die Antwort danach akzeptiert.
|
Nach dem Provider: Response-Verarbeitung ist Inhaltsintegrität, nicht nachträglicher Egress-Stopp. Aktiver Klartext, der exakt zu einem request-scoped Mapping gehört, wird lokal dem Token zugeordnet (`active_cleartext_normalized`) und anschließend demaskiert. Unbestätigte Detect-Treffer gelten nicht automatisch als bewiesene Klartextidentität. Es gibt keinen automatischen zweiten Generate-Call. Compact-Diagnose: verfügbare vs. aktive Mappings, maskierte Vorkommen, Tokens/Typen ohne Labels, Pre-Egress, `response_normalization`, `generate_calls`, `model_text_accepted`, Laufzeit und Tokenverbrauch des tatsächlich erfolgten Aufrufs, `prompt_slug`, `prompt_revision`, `generate_ms`. Demaskierung nur mit den aktiven Tokens dieses Manifests; unbekannte oder generische Platzhalter führen keinen Klarname ein. Ein Personenname als Subjekt („Clarissa kaufte …“) bleibt Identität; nur Objekt- und Infinitivkonstruktionen wie „Sushi essen“ gelten als Homonym.
|
||||||
|
|
||||||
Retry nur bei einem echten Treffer aus dem aktiven Manifest: maximal einer, bereits maskierter Originalprompt plus generische Korrektur, ohne verworfene Rohantwort und ohne gefundenen Klarnamen. Compact-Diagnose: verfügbare vs. aktive Mappings, maskierte Vorkommen, betroffene Tokens/Typen bei Blockade, Pre-Egress, Response Validation, Retry ja/nein, Modell- vs. lokaler Fallback, Laufzeit und Tokenverbrauch je tatsächlichem Modellaufruf. Tokens und Kosten beider Versuche werden aggregiert. Demaskierung nur mit den aktiven Tokens dieses Manifests; unbekannte Platzhalter führen keinen Klarname ein. Ein Personenname als Subjekt („Clarissa kaufte …“) bleibt Identität; nur Objekt- und Infinitivkonstruktionen wie „Sushi essen“ gelten als Homonym.
|
Öffentliche, intent-neutrale Identitätsprüfung: `canonical_token`, `identity_label_pattern`, `is_identity_mention`, `identity_occurrence_count`. Journalspezifische Provenienz (unbelegte heutige Fakten in Titel und Textkörper) bleibt im Journal-Adapter. Ein nicht lokal lösbarer Journalfehler speichert keinen `journal_draft` und liefert `journal_generation_not_accepted`.
|
||||||
|
|
||||||
Öffentliche, intent-neutrale Identitätsprüfung: `canonical_token`, `identity_label_pattern`, `is_identity_mention`, `identity_occurrence_count`. Journalspezifische Policy (unbelegte Personen in Titel und Textkörper) bleibt im Journal-Adapter.
|
### 9.4.1 Sicherheitsgrenze (2026-08-27)
|
||||||
|
|
||||||
Tests: `backend/tests/test_privacy_manifest.py`, `backend/tests/test_privacy_detect.py`.
|
Privacy fail-closed **vor** dem externen Socket. Faktentreue, Provenienz und Antwortintegrität **nach** der Modellantwort. Ein Verwerfen der Antwort macht einen bereits erfolgten Egress nicht ungeschehen. Detect-Ausgaben bleiben untrusted und request-scoped: sie dürfen ausgehend konservativ maskieren, erzeugen keine bestätigte Registry und überspringen keine spätere Detection.
|
||||||
|
|
||||||
|
**Additiv 2026-08-27:** Bestätigte Identitäten mit Alias bleiben tokenbasiert. Wird eine Schreibweise eines Tokens im Request maskiert, bleiben alle bekannten Schreibweisen dieses Tokens (kanonisch und Aliase) im request-scoped Manifest. Die lokale Demaskierung auf die kanonische Form ist deshalb kein `unattested_identity`, sofern irgendeine Schreibweise desselben Tokens in den heutigen Quellen belegt ist. Compact-Diagnose enthält weiterhin keine Labels.
|
||||||
|
|
||||||
## 9.5 Semantische Request-Detection und bestätigte Registry (2026-08-26)
|
## 9.5 Semantische Request-Detection und bestätigte Registry (2026-08-26)
|
||||||
|
|
||||||
|
|
@ -209,7 +211,7 @@ Nach der semantischen Detection prüft die bestätigte Registry den vollen Egres
|
||||||
|
|
||||||
### Demaskierung
|
### Demaskierung
|
||||||
|
|
||||||
Unbestätigte Request-Treffer werden in der beobachteten Form demaskiert. Bestätigte Identitäten und bestätigte Aliase werden auf die kanonische Schreibweise demaskiert. Unbekannte Platzhalter materialisieren keine Namen. Die lokale Wiederherstellung erkennt Platzhalter unabhängig von Groß-/Kleinschreibung, Innenabstand und optionaler Nullauffüllung (`PERSON:1` / `PERSON:01`). Generische Auslassungsplatzhalter (`[[…]]`, `[[...]]`) werden nicht demaskiert; im Journalentwurf gelten sie als unbestätigter Inhalt und führen zum lokalen Fallback. Die Retry-Anweisung und der Journal-Seed dürfen keine Beispiel-Klammern `[[…]]` enthalten, damit das Modell sie nicht in den Text kopiert.
|
Unbestätigte Request-Treffer werden in der beobachteten Form demaskiert. Bestätigte Identitäten und bestätigte Aliase werden auf die kanonische Schreibweise demaskiert. Unbekannte Platzhalter materialisieren keine Namen. Die lokale Wiederherstellung erkennt Platzhalter unabhängig von Groß-/Kleinschreibung, Innenabstand und optionaler Nullauffüllung (`PERSON:1` / `PERSON:01`). Generische Auslassungsplatzhalter (`[[…]]`, `[[...]]`) werden nicht demaskiert; im Journalentwurf gelten sie als unbelegter Inhalt und führen zu `journal_generation_not_accepted`, nicht zu einem stillen Rohtext-Entwurf. Der Journal-Seed darf keine Beispiel-Klammern `[[…]]` enthalten, damit das Modell sie nicht in den Text kopiert.
|
||||||
|
|
||||||
### Persistenz und Migration
|
### Persistenz und Migration
|
||||||
|
|
||||||
|
|
@ -219,11 +221,11 @@ Bestehende `identity_mappings` werden nicht gelöscht und nicht pauschal bestät
|
||||||
|
|
||||||
Mehrere Detect-Calls bei langen Prompts sind zulässig. Der Nutzer akzeptiert die Laufzeit. Compact-Trace ohne Labels: Detect-Provider/Modell, Zeichen, Chunks, `full_detection_coverage`, Entitäten nach Typ, Registry- vs. Request-Treffer, Detect-Aufrufe, Tokens/Kosten/Laufzeit, `generate_called`, Abbruchgrund.
|
Mehrere Detect-Calls bei langen Prompts sind zulässig. Der Nutzer akzeptiert die Laufzeit. Compact-Trace ohne Labels: Detect-Provider/Modell, Zeichen, Chunks, `full_detection_coverage`, Entitäten nach Typ, Registry- vs. Request-Treffer, Detect-Aufrufe, Tokens/Kosten/Laufzeit, `generate_called`, Abbruchgrund.
|
||||||
|
|
||||||
Normalfall nach erfolgreicher Detection: genau ein Generate-Call. Privacy-Retry der Narrationsantwort bleibt unverändert (nur echter Identitätsverstoß, maximal einer).
|
Normalfall nach erfolgreicher Detection: genau ein Generate-Call. Aktiver Klartext in der Modellantwort erzeugt keinen zweiten Generate-Aufruf.
|
||||||
|
|
||||||
### Tests und Live-Qualität
|
### Tests und Live-Qualität
|
||||||
|
|
||||||
Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`. Sie beweisen Schema, Fail-closed und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt.
|
Contract-Tests: `backend/tests/test_privacy_detect.py`, `backend/tests/test_identity_registry.py`, `backend/tests/test_privacy_response_integrity.py`. Sie beweisen Schema, Fail-closed, span-genaue Maskierung und Datenfluss, nicht semantische Modellleistung. Opt-in: `python entity_detect_eval.py --live` mit synthetischen Sätzen und exakten erwarteten Spans. Ohne diesen Lauf bleibt die Live-Qualität unbestätigt. Das aktuell konfigurierte `openai/gpt-4.1-nano` gilt durch reale False-Positive-Vorschläge qualitativ nicht als zuverlässig bestätigt; das Modell wird deshalb nicht stillschweigend gewechselt.
|
||||||
|
|
||||||
## 10. Offene Fragen
|
## 10. Offene Fragen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import SettingsPage from './pages/SettingsPage.jsx'
|
||||||
import AdminHomePage from './pages/AdminHomePage.jsx'
|
import AdminHomePage from './pages/AdminHomePage.jsx'
|
||||||
import AdminUsersPage from './pages/AdminUsersPage.jsx'
|
import AdminUsersPage from './pages/AdminUsersPage.jsx'
|
||||||
import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
|
import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
|
||||||
|
import AdminGenerationPage from './pages/AdminGenerationPage.jsx'
|
||||||
import AdminPlaceholdersPage from './pages/AdminPlaceholdersPage.jsx'
|
import AdminPlaceholdersPage from './pages/AdminPlaceholdersPage.jsx'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage.jsx'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage.jsx'
|
||||||
import AdminDialoguePage from './pages/AdminDialoguePage.jsx'
|
import AdminDialoguePage from './pages/AdminDialoguePage.jsx'
|
||||||
|
|
@ -145,6 +146,7 @@ export default function App() {
|
||||||
<Route index element={<AdminHomePage />} />
|
<Route index element={<AdminHomePage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="prompts" element={<AdminPromptsPage />} />
|
<Route path="prompts" element={<AdminPromptsPage />} />
|
||||||
|
<Route path="generation" element={<AdminGenerationPage />} />
|
||||||
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
|
<Route path="placeholders" element={<AdminPlaceholdersPage />} />
|
||||||
<Route path="features" element={<AdminFeaturesPage />} />
|
<Route path="features" element={<AdminFeaturesPage />} />
|
||||||
<Route path="providers" element={<AdminProvidersPage />} />
|
<Route path="providers" element={<AdminProvidersPage />} />
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,85 @@ ul.stack { padding: 0; }
|
||||||
background: #f3efe6;
|
background: #f3efe6;
|
||||||
}
|
}
|
||||||
.generate-choice p { margin: 0; }
|
.generate-choice p { margin: 0; }
|
||||||
|
.generation-policy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f3efe6;
|
||||||
|
}
|
||||||
|
.generation-policy summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.generation-policy-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
.policy-select {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9.5rem 1fr;
|
||||||
|
gap: 0.2rem 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.policy-select select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.policy-select .policy-summary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.generation-snapshot {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.generation-admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
}
|
||||||
|
.generation-admin-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin: 0.8rem 0 1.2rem;
|
||||||
|
}
|
||||||
|
.generation-admin-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f3efe6;
|
||||||
|
}
|
||||||
|
.generation-admin-card header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.generation-admin-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.generation-admin-editor textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
.generation-admin-preview {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
.generation-admin-preview-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
.dialogue-layout {
|
.dialogue-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,52 @@ function unavailable(value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileLabel(meta) {
|
||||||
|
if (!meta) return 'nicht verfügbar'
|
||||||
|
if (meta.neutral_fallback) return 'neutraler Fallback'
|
||||||
|
if (!meta.present) return 'fehlend'
|
||||||
|
const parts = []
|
||||||
|
if (meta.has_core) parts.push('Core')
|
||||||
|
if (meta.has_facet) parts.push('Facet')
|
||||||
|
if (meta.trait_count) parts.push(`${meta.trait_count} Traits`)
|
||||||
|
if (meta.brief_chars) parts.push(`${meta.brief_chars} Zeichen Brief`)
|
||||||
|
return parts.length ? `vorhanden · ${parts.join(', ')}` : 'vorhanden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleLabel(meta) {
|
||||||
|
if (!meta) return 'nicht verfügbar'
|
||||||
|
if (!meta.count) return 'keine'
|
||||||
|
const kinds = Array.isArray(meta.kinds) ? meta.kinds.join(', ') : ''
|
||||||
|
return `${meta.count} · ${meta.chars || 0} Zeichen${kinds ? ` · ${kinds}` : ''}${meta.dropped ? ' · budgetentfernt' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function droppedLabel(dropped) {
|
||||||
|
if (!dropped || (Array.isArray(dropped) && dropped.length === 0)) return 'keine'
|
||||||
|
return Array.isArray(dropped) ? dropped.join(', ') : String(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyLabel(meta) {
|
||||||
|
if (!meta) return 'nicht verfügbar'
|
||||||
|
const keys = meta.keys || meta.selection || {}
|
||||||
|
const ids = meta.ids || {}
|
||||||
|
const revisions = meta.revisions || {}
|
||||||
|
const slots = ['transformation', 'detail', 'voice', 'narrative']
|
||||||
|
const parts = slots
|
||||||
|
.filter((slot) => keys[slot] || ids[slot])
|
||||||
|
.map((slot) => {
|
||||||
|
const key = keys[slot] || '–'
|
||||||
|
const revision = revisions[slot] != null ? ` r${revisions[slot]}` : ''
|
||||||
|
const id = ids[slot] ? ` ${ids[slot]}` : ''
|
||||||
|
return `${slot}:${key}${revision}${id}`
|
||||||
|
})
|
||||||
|
const source = meta.source ? ` · ${meta.source}` : ''
|
||||||
|
const remembered = meta.remembered ? ' · gespeichert' : ''
|
||||||
|
const revision = meta.seed_revision ? ` · ${meta.seed_revision}` : ''
|
||||||
|
return parts.length
|
||||||
|
? `${parts.join(' ')}${source}${remembered}${revision}`
|
||||||
|
: `vorhanden${source}${remembered}${revision}`
|
||||||
|
}
|
||||||
|
|
||||||
function isJournalTrace(trace) {
|
function isJournalTrace(trace) {
|
||||||
if (!trace) return false
|
if (!trace) return false
|
||||||
if (Array.isArray(trace.stages) && trace.stages.length > 0) return true
|
if (Array.isArray(trace.stages) && trace.stages.length > 0) return true
|
||||||
|
|
@ -72,7 +118,7 @@ function detectLabel(trace) {
|
||||||
return trace.detect_provider
|
return trace.detect_provider
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetMeta({ budget, trace }) {
|
function BudgetMeta({ budget, trace, journal = false }) {
|
||||||
const data = budget || {}
|
const data = budget || {}
|
||||||
const model = data.actual_model || data.model || trace?.model
|
const model = data.actual_model || data.model || trace?.model
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,7 +126,54 @@ function BudgetMeta({ budget, trace }) {
|
||||||
<dt>Zweck</dt>
|
<dt>Zweck</dt>
|
||||||
<dd>{unavailable(trace?.purpose)}</dd>
|
<dd>{unavailable(trace?.purpose)}</dd>
|
||||||
<dt>Prompt</dt>
|
<dt>Prompt</dt>
|
||||||
<dd>{unavailable(trace?.prompt_slug)}</dd>
|
<dd>
|
||||||
|
{unavailable(trace?.prompt_slug)}
|
||||||
|
{trace?.prompt_revision || data.prompt_revision
|
||||||
|
? ` · ${trace?.prompt_revision || data.prompt_revision}`
|
||||||
|
: ''}
|
||||||
|
</dd>
|
||||||
|
{journal ? (
|
||||||
|
<>
|
||||||
|
<dt>Gestaltung</dt>
|
||||||
|
<dd>{policyLabel(trace?.generation_selection || trace?.generation_policy)}</dd>
|
||||||
|
<dt>Narration</dt>
|
||||||
|
<dd>
|
||||||
|
{trace?.model_text_accepted === false || trace?.narration_source === 'not_accepted'
|
||||||
|
? 'Generierung nicht übernommen'
|
||||||
|
: trace?.narration_source === 'fallback'
|
||||||
|
? 'lokale Quellenansicht, kein Modellentwurf'
|
||||||
|
: trace?.narration_source === 'model'
|
||||||
|
? 'Modelltext'
|
||||||
|
: unavailable(trace?.narration_source)}
|
||||||
|
</dd>
|
||||||
|
<dt>Writing Profile</dt>
|
||||||
|
<dd>{profileLabel(trace?.writing_profile)}</dd>
|
||||||
|
<dt>Stilquellen</dt>
|
||||||
|
<dd>{styleLabel(trace?.style_examples)}</dd>
|
||||||
|
<dt>Optionale Blöcke entfernt</dt>
|
||||||
|
<dd>{droppedLabel(trace?.dropped_optional_blocks)}</dd>
|
||||||
|
<dt>Textähnlichkeit (Diagnose)</dt>
|
||||||
|
<dd>{trace?.lexical_similarity == null ? 'nicht verfügbar' : trace.lexical_similarity}</dd>
|
||||||
|
<dt>Unvollständige Syntax (Diagnose)</dt>
|
||||||
|
<dd>{trace?.incomplete_syntax == null ? 'nicht verfügbar' : trace.incomplete_syntax}</dd>
|
||||||
|
<dt>Antwort-Normalisierung</dt>
|
||||||
|
<dd>{unavailable(trace?.response_normalization || data.response_normalization)}</dd>
|
||||||
|
<dt>Generate-Aufrufe</dt>
|
||||||
|
<dd>{unavailable(trace?.generate_calls ?? data.generate_calls)}</dd>
|
||||||
|
<dt>Modelltext übernommen</dt>
|
||||||
|
<dd>
|
||||||
|
{trace?.model_text_accepted == null && data.model_text_accepted == null
|
||||||
|
? 'nicht verfügbar'
|
||||||
|
: (trace?.model_text_accepted ?? data.model_text_accepted)
|
||||||
|
? 'ja'
|
||||||
|
: 'nein'}
|
||||||
|
</dd>
|
||||||
|
<dt>Provenienz</dt>
|
||||||
|
<dd>{unavailable(trace?.provenance_decision || data.provenance_decision)}</dd>
|
||||||
|
<dt>Abbruchgrund</dt>
|
||||||
|
<dd>{unavailable(trace?.abort_reason || data.abort_reason)}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<dt>Provider</dt>
|
<dt>Provider</dt>
|
||||||
<dd>{unavailable(trace?.provider || data.provider)}</dd>
|
<dd>{unavailable(trace?.provider || data.provider)}</dd>
|
||||||
<dt>Modell</dt>
|
<dt>Modell</dt>
|
||||||
|
|
@ -112,16 +205,19 @@ function BudgetMeta({ budget, trace }) {
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Kosten</dt>
|
<dt>Kosten</dt>
|
||||||
<dd>{unavailable(data.cost)}</dd>
|
<dd>{unavailable(data.cost)}</dd>
|
||||||
|
<dt>Laufzeit Generate</dt>
|
||||||
|
<dd>{unavailable(data.generate_ms ?? trace?.generate_ms)}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageView({ stage, index, journal }) {
|
function StageView({ stage, index, journal }) {
|
||||||
const title = journal ? stageTitle(stage, index) : layerLabel(stage)
|
const title = journal ? stageTitle(stage, index) : layerLabel(stage)
|
||||||
|
const journalStage = journal && (stage?.purpose === 'journal_generate' || stage?.layer === 'journalentwurf')
|
||||||
return (
|
return (
|
||||||
<section className="trace-stage">
|
<section className="trace-stage">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<BudgetMeta budget={stage?.budget} trace={stage} />
|
<BudgetMeta budget={stage?.budget} trace={stage} journal={journalStage} />
|
||||||
<dl className="meta">
|
<dl className="meta">
|
||||||
<dt>Schicht</dt>
|
<dt>Schicht</dt>
|
||||||
<dd>{layerLabel(stage)}</dd>
|
<dd>{layerLabel(stage)}</dd>
|
||||||
|
|
@ -132,7 +228,7 @@ function StageView({ stage, index, journal }) {
|
||||||
<dt>Masken</dt>
|
<dt>Masken</dt>
|
||||||
<dd>{stage?.mapping_count ?? 'nicht verfügbar'}</dd>
|
<dd>{stage?.mapping_count ?? 'nicht verfügbar'}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<Block title="Maskierung, Eingabe (nur user-Zeilen)" value={stage?.mask_input} />
|
<Block title="Maskierung, Eingabe (vollständiger gerenderter Egress)" value={stage?.mask_input} />
|
||||||
<Block
|
<Block
|
||||||
title={journal ? 'Intern (Klartext-Vorlage, nicht gesendet)' : 'Dialogzug intern (Klartext-Vorlage, nicht gesendet)'}
|
title={journal ? 'Intern (Klartext-Vorlage, nicht gesendet)' : 'Dialogzug intern (Klartext-Vorlage, nicht gesendet)'}
|
||||||
value={stage?.intern}
|
value={stage?.intern}
|
||||||
|
|
@ -160,19 +256,21 @@ export default function CallTrace({ decision, trace }) {
|
||||||
<h2>Testspur</h2>
|
<h2>Testspur</h2>
|
||||||
<ol className="muted">
|
<ol className="muted">
|
||||||
<li>
|
<li>
|
||||||
Maskierung sucht nur Namen (Testphase: eigener OpenRouter-Call auf user-Zeilen).
|
Maskierung prüft den vollständigen gerenderten Egress (Detect plus lokale Maskierung),
|
||||||
Sie wählt keine Operation und schreibt keinen Impuls.
|
nicht nur Nutzerzeilen. Sie wählt keine Operation und schreibt keinen Impuls.
|
||||||
</li>
|
</li>
|
||||||
{journal ? (
|
{journal ? (
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
Die Journalgenerierung bleibt logisch zweistufig: zuerst ein lokales Quellenartefakt
|
Die Journalgenerierung bleibt logisch zweistufig: zuerst ein lokales Quellenartefakt
|
||||||
(kein Provider, keine Tokens), danach ein Narrations-Call. Intern der Stufe 1 ist das
|
(kein Provider, keine Tokens), danach höchstens ein Narrations-Call. Intern der Stufe 1
|
||||||
lokale Artefakt. Egress gibt es nur in Stufe 2, maskiert.
|
ist das lokale Artefakt. Egress gibt es nur in Stufe 2, maskiert.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Response Validation prüft nur Identitäten, die in genau diesem Request maskiert wurden.
|
Nach dem Generate prüft die lokale Schicht Platzhalterintegrität und ordnet aktiven
|
||||||
Ein echter Leak löst höchstens einen Korrekturversuch aus; sonst entsteht ein lokaler Entwurf.
|
Klartext dem Request-Token zu. Das verhindert keinen bereits erfolgten Egress.
|
||||||
|
Journalspezifische Provenienz entscheidet danach, ob der Text übernommen wird.
|
||||||
|
Ein nicht übernommener Text ist kein erfolgreicher Entwurf.
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -197,6 +295,7 @@ export default function CallTrace({ decision, trace }) {
|
||||||
{stages.map((stage, index) => (
|
{stages.map((stage, index) => (
|
||||||
<StageView key={`${stage?.purpose || 'stage'}-${index}`} stage={stage} index={index} journal={journal} />
|
<StageView key={`${stage?.purpose || 'stage'}-${index}`} stage={stage} index={index} journal={journal} />
|
||||||
))}
|
))}
|
||||||
|
<Block title="Lokale Quellenansicht (kein generierter Entwurf)" value={trace?.source_preview} />
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const KIND_LABEL = {
|
||||||
retry: 'Korrekturversuch',
|
retry: 'Korrekturversuch',
|
||||||
stage1_result: 'Stufe 1 Ergebnis',
|
stage1_result: 'Stufe 1 Ergebnis',
|
||||||
narration_result: 'Stufe 2 Ergebnis',
|
narration_result: 'Stufe 2 Ergebnis',
|
||||||
|
budget_pack: 'Budgetpackung',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_LABEL = {
|
const STAGE_LABEL = {
|
||||||
|
|
@ -35,7 +36,12 @@ function detail(item) {
|
||||||
if (item?.status === 'ok') parts.push('ok')
|
if (item?.status === 'ok') parts.push('ok')
|
||||||
if (item?.status === 'failed' || item?.status === 'error') parts.push('fehlgeschlagen')
|
if (item?.status === 'failed' || item?.status === 'error') parts.push('fehlgeschlagen')
|
||||||
if (item?.status === 'model') parts.push('Modelltext übernommen')
|
if (item?.status === 'model') parts.push('Modelltext übernommen')
|
||||||
if (item?.status === 'local_fallback') parts.push('lokaler Entwurf')
|
if (item?.status === 'not_accepted') parts.push('Generierung nicht übernommen')
|
||||||
|
if (item?.status === 'local_fallback' && item?.stage === 'journal_generate') {
|
||||||
|
parts.push('Generierung nicht übernommen')
|
||||||
|
} else if (item?.status === 'local_fallback') {
|
||||||
|
parts.push('lokaler Dialog-Fallback')
|
||||||
|
}
|
||||||
if (item?.stage1 === 'model_accepted') parts.push('Modell-JSON gültig, übernommen')
|
if (item?.stage1 === 'model_accepted') parts.push('Modell-JSON gültig, übernommen')
|
||||||
if (item?.stage1 === 'local_ok') parts.push('lokal vollständig')
|
if (item?.stage1 === 'local_ok') parts.push('lokal vollständig')
|
||||||
if (item?.stage1 === 'local_source_artifact') parts.push('lokal vollständig')
|
if (item?.stage1 === 'local_source_artifact') parts.push('lokal vollständig')
|
||||||
|
|
@ -48,6 +54,8 @@ function detail(item) {
|
||||||
if (item?.detect_provider) parts.push(item.detect_provider)
|
if (item?.detect_provider) parts.push(item.detect_provider)
|
||||||
if (item?.mapping_count != null) parts.push(`${item.mapping_count} Masken`)
|
if (item?.mapping_count != null) parts.push(`${item.mapping_count} Masken`)
|
||||||
if (item?.model) parts.push(item.model)
|
if (item?.model) parts.push(item.model)
|
||||||
|
if (item?.lexical_similarity != null) parts.push(`Ähnlichkeit ${item.lexical_similarity}`)
|
||||||
|
if (item?.dropped) parts.push(`entfernt ${item.dropped}`)
|
||||||
if (item?.prompt_tokens != null) parts.push(`${item.prompt_tokens} in`)
|
if (item?.prompt_tokens != null) parts.push(`${item.prompt_tokens} in`)
|
||||||
if (item?.completion_tokens != null) parts.push(`${item.completion_tokens} out`)
|
if (item?.completion_tokens != null) parts.push(`${item.completion_tokens} out`)
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
|
|
@ -55,7 +63,9 @@ function detail(item) {
|
||||||
|
|
||||||
function tone(item) {
|
function tone(item) {
|
||||||
if (item?.status === 'failed' || item?.status === 'error' || item?.kind === 'blocked') return 'bad'
|
if (item?.status === 'failed' || item?.status === 'error' || item?.kind === 'blocked') return 'bad'
|
||||||
|
if (item?.status === 'not_accepted') return 'bad'
|
||||||
if (item?.kind === 'retry') return 'warn'
|
if (item?.kind === 'retry') return 'warn'
|
||||||
|
if (item?.stage === 'journal_generate' && item?.status === 'local_fallback') return 'bad'
|
||||||
if (item?.stage1 === 'local_fallback' || item?.status === 'local_fallback') return 'warn'
|
if (item?.stage1 === 'local_fallback' || item?.status === 'local_fallback') return 'warn'
|
||||||
if (item?.stage1 === 'model_accepted' || item?.stage1 === 'local_ok' || item?.status === 'ok' || item?.status === 'local_ok') return 'ok'
|
if (item?.stage1 === 'model_accepted' || item?.stage1 === 'local_ok' || item?.status === 'ok' || item?.status === 'local_ok') return 'ok'
|
||||||
return ''
|
return ''
|
||||||
|
|
@ -100,7 +110,11 @@ export default function RunLogPopup({
|
||||||
<div>
|
<div>
|
||||||
<h2 id="runlog-title">{title}</h2>
|
<h2 id="runlog-title">{title}</h2>
|
||||||
<p className={`runlog-status ${failed ? 'bad' : running ? 'warn' : 'ok'}`}>
|
<p className={`runlog-status ${failed ? 'bad' : running ? 'warn' : 'ok'}`}>
|
||||||
{running ? 'Läuft … zwei Modellaufrufe nacheinander' : failed ? 'Abgebrochen' : 'Fertig'}
|
{running
|
||||||
|
? 'Läuft … lokales Quellenartefakt, danach ein Generate-Aufruf'
|
||||||
|
: failed
|
||||||
|
? 'Generierung nicht übernommen'
|
||||||
|
: 'Fertig'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
|
|
@ -109,7 +123,7 @@ export default function RunLogPopup({
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{running && events.length === 0 && (
|
{running && events.length === 0 && (
|
||||||
<p className="muted">Warte auf Maskierung, Stufe 1 und Stufe 2. Retries erscheinen hier.</p>
|
<p className="muted">Warte auf Maskierung, lokales Quellenartefakt und den Generate-Aufruf.</p>
|
||||||
)}
|
)}
|
||||||
<ol className="runlog-list">
|
<ol className="runlog-list">
|
||||||
{events.map((item, index) => (
|
{events.map((item, index) => (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export function getAdminNavItems() {
|
||||||
{ to: '/admin', label: 'Übersicht', end: true },
|
{ to: '/admin', label: 'Übersicht', end: true },
|
||||||
{ to: '/admin/users', label: 'Nutzer' },
|
{ to: '/admin/users', label: 'Nutzer' },
|
||||||
{ to: '/admin/prompts', label: 'Prompts' },
|
{ to: '/admin/prompts', label: 'Prompts' },
|
||||||
|
{ to: '/admin/generation', label: 'Generierungsrichtlinien' },
|
||||||
{ to: '/admin/placeholders', label: 'Platzhalter' },
|
{ to: '/admin/placeholders', label: 'Platzhalter' },
|
||||||
{ to: '/admin/features', label: 'Kontingente' },
|
{ to: '/admin/features', label: 'Kontingente' },
|
||||||
{ to: '/admin/providers', label: 'Schnittstellen' },
|
{ to: '/admin/providers', label: 'Schnittstellen' },
|
||||||
|
|
|
||||||
399
frontend/src/pages/AdminGenerationPage.jsx
Normal file
399
frontend/src/pages/AdminGenerationPage.jsx
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import { useAuth } from '../context/AuthContext.jsx'
|
||||||
|
|
||||||
|
const PURPOSE = 'journal_generate'
|
||||||
|
const USER_SLOTS = ['transformation', 'detail', 'voice', 'narrative']
|
||||||
|
const SLOT_TITLES = {
|
||||||
|
transformation: 'Bearbeitungsstärke',
|
||||||
|
detail: 'Detailerhaltung',
|
||||||
|
voice: 'Persönliche Stimme',
|
||||||
|
narrative: 'Erzählgestaltung'
|
||||||
|
}
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
draft: 'Entwurf',
|
||||||
|
active: 'Aktiv',
|
||||||
|
archived: 'Archiviert'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLine(item) {
|
||||||
|
const parts = [STATUS_LABEL[item.status] || item.status]
|
||||||
|
if (item.is_default) parts.push('Standard')
|
||||||
|
parts.push(`Rev. ${item.revision || 1}`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminGenerationPage() {
|
||||||
|
const { session } = useAuth()
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [tab, setTab] = useState('transformation')
|
||||||
|
const [openId, setOpenId] = useState('')
|
||||||
|
const [detail, setDetail] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
const [previewSelection, setPreviewSelection] = useState(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const base = `/api/admin/generation-instructions/${PURPOSE}`
|
||||||
|
|
||||||
|
const load = async (payload) => {
|
||||||
|
const data = payload || await api(base, { token: session.token })
|
||||||
|
setCatalog(data)
|
||||||
|
const defaults = {}
|
||||||
|
USER_SLOTS.forEach((slot) => {
|
||||||
|
const items = data.slots?.[slot] || []
|
||||||
|
const preferred = items.find((item) => item.is_default && item.status === 'active')
|
||||||
|
|| items.find((item) => item.status === 'active')
|
||||||
|
if (preferred) defaults[`${slot}_id`] = preferred.id
|
||||||
|
})
|
||||||
|
setPreviewSelection((current) => current || defaults)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch((err) => setError(err.message))
|
||||||
|
}, [session.token])
|
||||||
|
|
||||||
|
const openItem = async (id) => {
|
||||||
|
setError('')
|
||||||
|
setNotice('')
|
||||||
|
setCreating(false)
|
||||||
|
const item = await api(`${base}/${id}`, { token: session.token })
|
||||||
|
setOpenId(id)
|
||||||
|
setDetail({ ...item })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
setOpenId('')
|
||||||
|
setDetail(null)
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAndKeep = async (item) => {
|
||||||
|
await load()
|
||||||
|
if (item?.id) {
|
||||||
|
setOpenId(item.id)
|
||||||
|
setDetail({ ...item })
|
||||||
|
} else {
|
||||||
|
closeDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async (action, okMessage) => {
|
||||||
|
setError('')
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
const result = await action()
|
||||||
|
setNotice(okMessage)
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDraft = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!detail) return
|
||||||
|
try {
|
||||||
|
if (creating) {
|
||||||
|
const created = await run(
|
||||||
|
() => api(base, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
slot: tab,
|
||||||
|
guideline_key: detail.guideline_key,
|
||||||
|
label: detail.label,
|
||||||
|
summary: detail.summary,
|
||||||
|
instruction: detail.instruction,
|
||||||
|
sort_order: Number(detail.sort_order || 0)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'Entwurf angelegt.'
|
||||||
|
)
|
||||||
|
setCreating(false)
|
||||||
|
await refreshAndKeep(created)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const saved = await run(
|
||||||
|
() => api(`${base}/${detail.id}`, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
guideline_key: detail.guideline_key,
|
||||||
|
label: detail.label,
|
||||||
|
summary: detail.summary,
|
||||||
|
instruction: detail.instruction,
|
||||||
|
sort_order: Number(detail.sort_order || 0)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'Entwurf gespeichert.'
|
||||||
|
)
|
||||||
|
await refreshAndKeep(saved)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneItem = async (id) => {
|
||||||
|
try {
|
||||||
|
const cloned = await run(
|
||||||
|
() => api(`${base}/${id}/clone`, { token: session.token, method: 'POST' }),
|
||||||
|
'Klon als Entwurf angelegt. Das Original bleibt unverändert.'
|
||||||
|
)
|
||||||
|
await refreshAndKeep(cloned)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishItem = async (id) => {
|
||||||
|
try {
|
||||||
|
const published = await run(
|
||||||
|
() => api(`${base}/${id}/publish`, { token: session.token, method: 'POST' }),
|
||||||
|
'Ausprägung veröffentlicht.'
|
||||||
|
)
|
||||||
|
await refreshAndKeep(published)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveItem = async (id) => {
|
||||||
|
try {
|
||||||
|
await run(
|
||||||
|
() => api(`${base}/${id}/archive`, { token: session.token, method: 'POST' }),
|
||||||
|
'Ausprägung archiviert. Alte Entwürfe bleiben lesbar.'
|
||||||
|
)
|
||||||
|
closeDetail()
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultItem = async (id) => {
|
||||||
|
try {
|
||||||
|
await run(
|
||||||
|
() => api(`${base}/${id}/default`, { token: session.token, method: 'POST' }),
|
||||||
|
'Als Standard gesetzt.'
|
||||||
|
)
|
||||||
|
await load()
|
||||||
|
if (openId === id) await openItem(id)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteItem = async (id) => {
|
||||||
|
if (!window.confirm('Diesen unverwendeten Entwurf löschen?')) return
|
||||||
|
try {
|
||||||
|
await run(
|
||||||
|
() => api(`${base}/${id}`, { token: session.token, method: 'DELETE' }),
|
||||||
|
'Entwurf gelöscht.'
|
||||||
|
)
|
||||||
|
closeDetail()
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetDefault = async () => {
|
||||||
|
setError('')
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
const restored = await api(`${base}/reset`, { token: session.token, method: 'POST' })
|
||||||
|
await load(restored)
|
||||||
|
closeDetail()
|
||||||
|
setNotice('Neue Drafts aus dem Systemstandard angelegt. Veröffentlichte Ausprägungen bleiben.')
|
||||||
|
setPreview(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
setError('')
|
||||||
|
setNotice('')
|
||||||
|
setCreating(true)
|
||||||
|
setOpenId('')
|
||||||
|
setDetail({
|
||||||
|
guideline_key: '',
|
||||||
|
label: '',
|
||||||
|
summary: '',
|
||||||
|
instruction: '',
|
||||||
|
sort_order: (catalog?.slots?.[tab] || []).length,
|
||||||
|
status: 'draft',
|
||||||
|
revision: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPreview = async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setPreview(await api(`${base}/preview`, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
generation_selection: previewSelection
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog) {
|
||||||
|
return <section className="card">{error ? <p className="error">{error}</p> : <p>Lädt …</p>}</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = catalog.slots?.[tab] || []
|
||||||
|
const editable = Boolean(detail && (creating || detail.status === 'draft'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h1>Generierungsrichtlinien</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Benannte Ausprägungen je Dimension. Aktive Texte sind unveränderlich; Änderungen entstehen als Klon.
|
||||||
|
Nutzer sehen Bezeichnung und Kurzbeschreibung, nicht die Promptanweisung.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{notice && <p className="muted">{notice}</p>}
|
||||||
|
<div className="generation-admin-tabs">
|
||||||
|
{USER_SLOTS.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot}
|
||||||
|
type="button"
|
||||||
|
className={tab === slot ? 'ghost active' : 'ghost'}
|
||||||
|
onClick={() => { setTab(slot); closeDetail(); setPreview(null) }}
|
||||||
|
>
|
||||||
|
{SLOT_TITLES[slot]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" className="ghost" onClick={startCreate}>Neue Ausprägung</button>
|
||||||
|
<button type="button" className="ghost" onClick={resetDefault}>Systemstandard als neue Drafts</button>
|
||||||
|
</div>
|
||||||
|
<div className="generation-admin-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<article key={item.id} className="generation-admin-card">
|
||||||
|
<header>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<span className="muted">{statusLine(item)}</span>
|
||||||
|
</header>
|
||||||
|
<p>{item.summary || 'Keine Kurzbeschreibung.'}</p>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" className="ghost" onClick={() => openItem(item.id)}>Öffnen</button>
|
||||||
|
<button type="button" className="ghost" onClick={() => cloneItem(item.id)}>Klonen</button>
|
||||||
|
{item.status === 'active' && (
|
||||||
|
<button type="button" className="ghost" onClick={() => archiveItem(item.id)}>Archivieren</button>
|
||||||
|
)}
|
||||||
|
{item.status === 'draft' && (
|
||||||
|
<button type="button" className="ghost" onClick={() => publishItem(item.id)}>Veröffentlichen</button>
|
||||||
|
)}
|
||||||
|
{item.status === 'active' && !item.is_default && (
|
||||||
|
<button type="button" className="ghost" onClick={() => defaultItem(item.id)}>Standard</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<form className="generation-admin-editor" onSubmit={saveDraft}>
|
||||||
|
<h2>{creating ? 'Neue Ausprägung' : detail.label || 'Ausprägung'}</h2>
|
||||||
|
<p className="muted">{creating ? 'Wird als Entwurf angelegt.' : statusLine(detail)}</p>
|
||||||
|
<label>Bezeichnung
|
||||||
|
<input
|
||||||
|
value={detail.label || ''}
|
||||||
|
onChange={(e) => setDetail((current) => ({ ...current, label: e.target.value }))}
|
||||||
|
disabled={!editable}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>Kurzbeschreibung
|
||||||
|
<input
|
||||||
|
value={detail.summary || ''}
|
||||||
|
onChange={(e) => setDetail((current) => ({ ...current, summary: e.target.value }))}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>Schlüssel
|
||||||
|
<input
|
||||||
|
value={detail.guideline_key || ''}
|
||||||
|
onChange={(e) => setDetail((current) => ({ ...current, guideline_key: e.target.value }))}
|
||||||
|
disabled={!editable}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>Anweisung
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={detail.instruction || ''}
|
||||||
|
onChange={(e) => setDetail((current) => ({ ...current, instruction: e.target.value }))}
|
||||||
|
disabled={!editable}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<details>
|
||||||
|
<summary>Technische Herkunft</summary>
|
||||||
|
<p className="muted">
|
||||||
|
ID: {detail.id || 'neu'}
|
||||||
|
{detail.revision != null ? ` · Revision ${detail.revision}` : ''}
|
||||||
|
{detail.cloned_from ? ` · geklont von ${detail.cloned_from}` : ''}
|
||||||
|
{detail.seed_revision ? ` · Systemstand ${detail.seed_revision}` : ''}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<div className="row-actions">
|
||||||
|
{editable && <button type="submit">Speichern</button>}
|
||||||
|
{!creating && detail.status === 'draft' && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="ghost" onClick={() => publishItem(detail.id)}>Veröffentlichen</button>
|
||||||
|
<button type="button" className="ghost" onClick={() => deleteItem(detail.id)}>Löschen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!creating && detail.status === 'active' && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="ghost" onClick={() => cloneItem(detail.id)}>Klonen</button>
|
||||||
|
<button type="button" className="ghost" onClick={() => archiveItem(detail.id)}>Archivieren</button>
|
||||||
|
{!detail.is_default && (
|
||||||
|
<button type="button" className="ghost" onClick={() => defaultItem(detail.id)}>Als Standard</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" className="ghost" onClick={closeDetail}>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="generation-admin-preview">
|
||||||
|
<h2>Lokale Promptvorschau</h2>
|
||||||
|
<p className="muted">Kein Provideraufruf, keine persönlichen Quellen.</p>
|
||||||
|
<div className="generation-admin-preview-controls">
|
||||||
|
{USER_SLOTS.map((slot) => (
|
||||||
|
<label key={slot}>
|
||||||
|
{SLOT_TITLES[slot]}
|
||||||
|
<select
|
||||||
|
value={previewSelection?.[`${slot}_id`] || ''}
|
||||||
|
onChange={(e) => setPreviewSelection((current) => ({
|
||||||
|
...current,
|
||||||
|
[`${slot}_id`]: e.target.value
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{(catalog.slots?.[slot] || [])
|
||||||
|
.filter((item) => item.status === 'active')
|
||||||
|
.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button type="button" className="ghost" onClick={runPreview}>Vorschau zeigen</button>
|
||||||
|
</div>
|
||||||
|
{preview && <pre className="code">{preview.rendered || JSON.stringify(preview, null, 2)}</pre>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ export default function AdminHomePage() {
|
||||||
<dt>Maskierung</dt>
|
<dt>Maskierung</dt>
|
||||||
<dd>{health.inventory?.providers?.detect?.ready ? 'bereit (semantisch)' : 'nicht bereit, fail-closed'}</dd>
|
<dd>{health.inventory?.providers?.detect?.ready ? 'bereit (semantisch)' : 'nicht bereit, fail-closed'}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p className="muted">Weiter: <Link to="/admin/users">Nutzer</Link> · <Link to="/admin/prompts">Prompts</Link> · <Link to="/admin/placeholders">Platzhalter</Link> · <Link to="/admin/features">Kontingente</Link> · <Link to="/admin/providers">Schnittstellen</Link> · <Link to="/admin/identities">Identitäten</Link> · <Link to="/admin/dialogue">Dialog</Link></p>
|
<p className="muted">Weiter: <Link to="/admin/users">Nutzer</Link> · <Link to="/admin/prompts">Prompts</Link> · <Link to="/admin/generation">Generierungsrichtlinien</Link> · <Link to="/admin/placeholders">Platzhalter</Link> · <Link to="/admin/features">Kontingente</Link> · <Link to="/admin/providers">Schnittstellen</Link> · <Link to="/admin/identities">Identitäten</Link> · <Link to="/admin/dialogue">Dialog</Link></p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ function convLabel(item, index) {
|
||||||
return `${title} · ${item.message_count || 0}`
|
return `${title} · ${item.message_count || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SLOT_SELECTS = [
|
||||||
|
{ slot: 'transformation', key: 'transformation_id', title: 'Bearbeitung' },
|
||||||
|
{ slot: 'detail', key: 'detail_id', title: 'Detailerhaltung' },
|
||||||
|
{ slot: 'voice', key: 'voice_id', title: 'Persönliche Stimme' },
|
||||||
|
{ slot: 'narrative', key: 'narrative_id', title: 'Erzählgestaltung' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY_SELECTION = {
|
||||||
|
transformation_id: '',
|
||||||
|
detail_id: '',
|
||||||
|
voice_id: '',
|
||||||
|
narrative_id: ''
|
||||||
|
}
|
||||||
|
|
||||||
export default function JournalDayPage() {
|
export default function JournalDayPage() {
|
||||||
const { spaceId, dayId } = useParams()
|
const { spaceId, dayId } = useParams()
|
||||||
const { session, isAdmin } = useAuth()
|
const { session, isAdmin } = useAuth()
|
||||||
|
|
@ -27,6 +41,10 @@ export default function JournalDayPage() {
|
||||||
const [decision, setDecision] = useState(null)
|
const [decision, setDecision] = useState(null)
|
||||||
const [chooseSources, setChooseSources] = useState(false)
|
const [chooseSources, setChooseSources] = useState(false)
|
||||||
const [includeExisting, setIncludeExisting] = useState(false)
|
const [includeExisting, setIncludeExisting] = useState(false)
|
||||||
|
const [designOpen, setDesignOpen] = useState(false)
|
||||||
|
const [generationSelection, setGenerationSelection] = useState(EMPTY_SELECTION)
|
||||||
|
const [generationOptions, setGenerationOptions] = useState({})
|
||||||
|
const [rememberSelection, setRememberSelection] = useState(false)
|
||||||
const [logOpen, setLogOpen] = useState(false)
|
const [logOpen, setLogOpen] = useState(false)
|
||||||
const [logStatus, setLogStatus] = useState('ok')
|
const [logStatus, setLogStatus] = useState('ok')
|
||||||
const [runLog, setRunLog] = useState([])
|
const [runLog, setRunLog] = useState([])
|
||||||
|
|
@ -55,6 +73,18 @@ export default function JournalDayPage() {
|
||||||
api('/api/journal/egress-status', { token: session.token })
|
api('/api/journal/egress-status', { token: session.token })
|
||||||
.then(setEgress)
|
.then(setEgress)
|
||||||
.catch(() => setEgress(null))
|
.catch(() => setEgress(null))
|
||||||
|
api('/api/journal/generation-settings', { token: session.token })
|
||||||
|
.then((data) => {
|
||||||
|
setGenerationSelection({
|
||||||
|
transformation_id: data.selection?.transformation_id || '',
|
||||||
|
detail_id: data.selection?.detail_id || '',
|
||||||
|
voice_id: data.selection?.voice_id || '',
|
||||||
|
narrative_id: data.selection?.narrative_id || ''
|
||||||
|
})
|
||||||
|
setGenerationOptions(data.options || {})
|
||||||
|
setRememberSelection(false)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}, [session.token, dayId])
|
}, [session.token, dayId])
|
||||||
|
|
||||||
const startConversation = async () => {
|
const startConversation = async () => {
|
||||||
|
|
@ -120,7 +150,10 @@ export default function JournalDayPage() {
|
||||||
setLogStatus('running')
|
setLogStatus('running')
|
||||||
setRunLog([])
|
setRunLog([])
|
||||||
try {
|
try {
|
||||||
const body = {}
|
const body = {
|
||||||
|
generation_selection: generationSelection,
|
||||||
|
remember_generation_selection: rememberSelection
|
||||||
|
}
|
||||||
if (conversationIds?.length) body.conversation_ids = conversationIds
|
if (conversationIds?.length) body.conversation_ids = conversationIds
|
||||||
if (includeExisting) body.include_existing = true
|
if (includeExisting) body.include_existing = true
|
||||||
const result = await api(`/api/journal/days/${dayId}/generate`, {
|
const result = await api(`/api/journal/days/${dayId}/generate`, {
|
||||||
|
|
@ -137,10 +170,17 @@ export default function JournalDayPage() {
|
||||||
state: { trace: result.trace || null, run_log: log }
|
state: { trace: result.trace || null, run_log: log }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const log = err.payload?.detail?.diagnostics?.log || []
|
const diag = err.payload?.detail?.diagnostics || {}
|
||||||
|
const log = diag.log || diag.trace?.log || []
|
||||||
|
const code = err.payload?.detail?.code
|
||||||
setRunLog(log)
|
setRunLog(log)
|
||||||
|
setTrace(diag.trace || null)
|
||||||
setLogStatus('error')
|
setLogStatus('error')
|
||||||
setError(err.message)
|
setError(
|
||||||
|
code === 'journal_generation_not_accepted' || err.message === 'Generierung nicht übernommen.'
|
||||||
|
? 'Generierung nicht übernommen.'
|
||||||
|
: err.message
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +199,7 @@ export default function JournalDayPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversations = payload.conversations || []
|
const conversations = payload.conversations || []
|
||||||
|
const draftSummary = payload.current_draft?.generation_summary || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card dialogue-page">
|
<section className="card dialogue-page">
|
||||||
|
|
@ -249,6 +290,47 @@ export default function JournalDayPage() {
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{draftSummary && (
|
||||||
|
<p className="muted generation-snapshot">{draftSummary}</p>
|
||||||
|
)}
|
||||||
|
<details className="generation-policy" open={designOpen} onToggle={(event) => setDesignOpen(event.currentTarget.open)}>
|
||||||
|
<summary>Gestaltung</summary>
|
||||||
|
<div className="generation-policy-body">
|
||||||
|
{SLOT_SELECTS.map((item) => {
|
||||||
|
const options = generationOptions[item.slot] || []
|
||||||
|
const selected = options.find((option) => option.id === generationSelection[item.key])
|
||||||
|
return (
|
||||||
|
<label key={item.key} className="policy-select">
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<select
|
||||||
|
value={generationSelection[item.key]}
|
||||||
|
title={selected?.summary || ''}
|
||||||
|
onChange={(event) => setGenerationSelection((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.key]: event.target.value
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} title={option.summary || ''}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selected?.summary ? <small className="muted policy-summary">{selected.summary}</small> : null}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberSelection}
|
||||||
|
onChange={(event) => setRememberSelection(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Diese Auswahl als Standard merken
|
||||||
|
</label>
|
||||||
|
<p className="muted">Faktenregeln und Datenschutz bleiben unabhängig von diesen Einstellungen aktiv.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</header>
|
</header>
|
||||||
<div className="dialogue-layout has-scratch">
|
<div className="dialogue-layout has-scratch">
|
||||||
<div className="dialogue-thread">
|
<div className="dialogue-thread">
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export default function JournalEditorPage() {
|
||||||
const [logOpen, setLogOpen] = useState(
|
const [logOpen, setLogOpen] = useState(
|
||||||
Boolean((location.state?.run_log || []).length || location.state?.trace)
|
Boolean((location.state?.run_log || []).length || location.state?.trace)
|
||||||
)
|
)
|
||||||
|
const [generationSummary, setGenerationSummary] = useState('')
|
||||||
|
|
||||||
const markClean = (nextTitle, nextBody) => {
|
const markClean = (nextTitle, nextBody) => {
|
||||||
const titleValue = entryTitle(nextTitle || '')
|
const titleValue = entryTitle(nextTitle || '')
|
||||||
|
|
@ -60,6 +61,7 @@ export default function JournalEditorPage() {
|
||||||
setVersions(entry.versions || [])
|
setVersions(entry.versions || [])
|
||||||
setMedia(entry.media || [])
|
setMedia(entry.media || [])
|
||||||
setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '')
|
setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '')
|
||||||
|
setGenerationSummary('')
|
||||||
markClean(entry.title || '', entry.body || '')
|
markClean(entry.title || '', entry.body || '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +73,7 @@ export default function JournalEditorPage() {
|
||||||
setMedia([])
|
setMedia([])
|
||||||
setSavedEntryId('')
|
setSavedEntryId('')
|
||||||
setCurrentVersionId('')
|
setCurrentVersionId('')
|
||||||
|
setGenerationSummary(draft?.generation_summary || '')
|
||||||
markClean(draft?.title || '', draft?.body || '')
|
markClean(draft?.title || '', draft?.body || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,6 +284,7 @@ export default function JournalEditorPage() {
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{notice && <p className="muted">{notice}</p>}
|
{notice && <p className="muted">{notice}</p>}
|
||||||
|
{generationSummary && <p className="muted generation-snapshot">{generationSummary}</p>}
|
||||||
<form className="stack" onSubmit={save}>
|
<form className="stack" onSubmit={save}>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user