529 lines
26 KiB
Python
529 lines
26 KiB
Python
"""Deterministic journal editorial contract. Fake provider proves data flow, not live prose."""
|
||
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]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-journal-editorial-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 remember_mapping
|
||
from journal_editorial import (
|
||
GENERATE_SEED_REVISION,
|
||
format_style_examples,
|
||
incomplete_syntax_markers,
|
||
lexical_similarity,
|
||
narration_sources_text,
|
||
select_journal_style_examples,
|
||
)
|
||
from journal_generate import pack_narration_context, unattested_journal_content
|
||
from main import app
|
||
from privacy_gateway import reset_debug
|
||
from prompt_budget import JournalBudget
|
||
from providers import ChatResult
|
||
from writing_profile_store import (
|
||
NEUTRAL_JOURNAL_STYLE,
|
||
compile_task_brief,
|
||
import_text,
|
||
set_lifecycle,
|
||
update_facet,
|
||
)
|
||
|
||
|
||
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:
|
||
if not ok:
|
||
raise SystemExit(f"FAIL: {message}")
|
||
print(f"OK {message}")
|
||
|
||
|
||
def header(token: str) -> dict:
|
||
return {"X-Auth-Token": token}
|
||
|
||
|
||
def test_syntax_diagnostics() -> None:
|
||
expect(incomplete_syntax_markers("Danach sprach ich mit dem.") > 0, "dangling determiner is diagnostic")
|
||
expect(incomplete_syntax_markers("Danach sprach ich und kam zurück.") == 0, "complete sentence is not flagged")
|
||
|
||
|
||
def test_prompt_contract() -> None:
|
||
init_db()
|
||
prompt = load_active_prompt("mvp.journal_generate")
|
||
text = prompt.get("template") or ""
|
||
expect("INHALTSTREUE" in text, "prompt keeps hard content rules")
|
||
expect("CURRENT_DAY_SOURCES" in text, "prompt labels current-day facts")
|
||
expect("STYLE_EXAMPLES" in text, "prompt labels style examples")
|
||
expect("WRITING_PROFILE" in text, "prompt labels the writing profile")
|
||
expect("{{transformation_instructions}}" in text, "prompt injects compiled transformation 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("{{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("ich gieng zum laden" not in text, "synthetic prose_edit example is gone")
|
||
expect("nachbarhund im garten" not in text, "synthetic notes_to_journal example 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("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:
|
||
row = conn.execute(
|
||
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
||
("mvp.journal_generate",),
|
||
).fetchone()
|
||
expect(row["seed_revision"] == SEED_REVISION, "system prompt revision is stored")
|
||
expect(row["template"] == row["default_template"], "untouched install uses the seeded template")
|
||
|
||
|
||
def test_sources_and_examples_are_separated() -> None:
|
||
artifact = {
|
||
"kind": "verified_artifact",
|
||
"coverage": "all_selected_sources",
|
||
"sources": [{"source_id": "u1", "role": "user", "text": "Heute nur der Markt."}],
|
||
}
|
||
presented = narration_sources_text(artifact)
|
||
expect("[u1]" in presented and "Heute nur der Markt." in presented, "day facts are labeled source blocks")
|
||
expect("kind" not in presented, "narration does not dump artifact JSON")
|
||
formatted = format_style_examples(
|
||
[{"kind": "journal_entry", "excerpt": "Ruhige Sätze, trockener Schnitt.", "occurred_at": "2026-07-01"}]
|
||
)
|
||
expect("nicht übernommen" in formatted, "style examples forbid copying their facts")
|
||
expect("Ruhige Sätze" in formatted, "selected example text is present")
|
||
|
||
|
||
def test_unattested_covers_title_and_body() -> None:
|
||
mappings = [{"local_label": "Maren", "token": "PERSON:01"}, {"local_label": "Hanna", "token": "PERSON:99"}]
|
||
sources = ["Heute war ich mit Maren am Markt."]
|
||
expect(
|
||
unattested_journal_content("Markttag\n\nIch ging mit Maren zum Markt.", sources, mappings, ["PERSON:01"]) is None,
|
||
"attested person in title and body is allowed",
|
||
)
|
||
expect(
|
||
unattested_journal_content("Hanna am Hafen\n\nIch ging zum Markt.", sources, mappings, ["PERSON:01"])
|
||
== "unattested_identity",
|
||
"unattested person in the title is rejected",
|
||
)
|
||
expect(
|
||
unattested_journal_content("[[PERSON:99]]\n\nIch ging zum Markt.", sources, mappings, ["PERSON:01"])
|
||
== "unattested_placeholder",
|
||
"inactive placeholder in the title is unattested",
|
||
)
|
||
expect(
|
||
unattested_journal_content("[[PERSON:01]] am Markt\n\n[[PERSON:01]] kaufte Kirschen.", sources, mappings, ["PERSON:01"])
|
||
is None,
|
||
"the same attested placeholder may appear in title and body",
|
||
)
|
||
expect(
|
||
unattested_journal_content("Um 6:30 Uhr kam [[...]] ins Wohnzimmer.", sources, mappings, ["PERSON:01"])
|
||
== "unattested_placeholder",
|
||
"ellipsis placeholder leftover is unattested",
|
||
)
|
||
expect(
|
||
unattested_journal_content("Um 6:30 Uhr kam [[…]] ins Wohnzimmer.", sources, mappings, ["PERSON:01"])
|
||
== "unattested_placeholder",
|
||
"unicode ellipsis placeholder leftover is unattested",
|
||
)
|
||
|
||
|
||
def test_budget_pack_drops_examples_first() -> None:
|
||
init_db()
|
||
prompt = {
|
||
"id": "eval-pack",
|
||
"slug": "mvp.journal_generate",
|
||
"prompt_type": "base",
|
||
"template": "PROFILE\n{{writing_profile}}\nEX\n{{style_examples}}\nDAY\n{{reconstruction}}\nOLD\n{{existing_text}}\n",
|
||
}
|
||
assembled = {
|
||
"writing_profile": "Core: kurze Sätze.",
|
||
"reconstruction": "Heute Markt.",
|
||
"style_examples": "",
|
||
"existing_text": "",
|
||
}
|
||
huge_examples = "Stil " + ("Beispielwort " * 400)
|
||
existing = "Bestehende Fassung " + ("alt " * 40)
|
||
budget = JournalBudget(
|
||
model="fake",
|
||
purpose="journal_generate",
|
||
effective_context_window=32_768,
|
||
reserved_output_tokens=256,
|
||
safety_margin=0.15,
|
||
available_input_tokens=estimate_cap(assembled, prompt, extra=80),
|
||
chars_per_token=2.0,
|
||
)
|
||
packed, dropped = pack_narration_context(
|
||
prompt,
|
||
budget,
|
||
assembled,
|
||
style_examples=huge_examples,
|
||
existing_text=existing,
|
||
include_existing=True,
|
||
)
|
||
expect("style_examples" in dropped, "style examples are dropped before day sources")
|
||
expect("Heute Markt." in packed["reconstruction"], "day sources stay")
|
||
expect("kurze Sätze" in packed["writing_profile"], "writing profile stays")
|
||
|
||
|
||
def estimate_cap(assembled: dict, prompt: dict, extra: int) -> int:
|
||
from engine import preview_prompt
|
||
from prompt_budget import estimate_tokens
|
||
|
||
base = dict(assembled)
|
||
rendered = preview_prompt(prompt, base)["rendered"]
|
||
return estimate_tokens(rendered) + extra
|
||
|
||
|
||
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 main() -> None:
|
||
test_syntax_diagnostics()
|
||
test_prompt_contract()
|
||
test_sources_and_examples_are_separated()
|
||
test_unattested_covers_title_and_body()
|
||
test_budget_pack_drops_examples_first()
|
||
expect(lexical_similarity("a b c", "a b c") > 0.9, "similarity helper is diagnostic")
|
||
expect(NEUTRAL_JOURNAL_STYLE.startswith("Neutraler Journalstil"), "neutral fallback exists")
|
||
|
||
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"]
|
||
expect(compile_task_brief(profile_id) == NEUTRAL_JOURNAL_STYLE, "unconfirmed profile is not a style authority")
|
||
|
||
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Editorial"})
|
||
day = client.post(
|
||
f"/api/journal/spaces/{space.json()['id']}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-26"},
|
||
)
|
||
conv = client.post(
|
||
f"/api/journal/days/{day.json()['day']['id']}/conversations",
|
||
headers=headers,
|
||
json={"title": "Tag"},
|
||
)
|
||
prose_body = (
|
||
"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. ich bin dan zum markt gegangen."
|
||
)
|
||
turn = client.post(
|
||
f"/api/journal/conversations/{conv.json()['id']}/turn",
|
||
headers=headers,
|
||
json={"body": prose_body},
|
||
)
|
||
expect(turn.status_code == 200, f"turn {turn.status_code}")
|
||
gen = client.post(
|
||
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv.json()["id"]]},
|
||
)
|
||
expect(gen.status_code == 200, f"generate {gen.status_code}")
|
||
intern = intern_of(gen.json())
|
||
expect(MIXED_SOURCES_INSTRUCTION in intern, "unified mixed-source instruction reaches the model")
|
||
expect("CURRENT_DAY_SOURCES" in intern, "day facts are labeled")
|
||
expect("STYLE_EXAMPLES" in intern, "style examples are 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("zimlich" in intern.split("\nCURRENT_DAY_SOURCES\n")[-1], "today remains content, including typos")
|
||
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("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(
|
||
f"/api/journal/spaces/{space.json()['id']}/days",
|
||
headers=headers,
|
||
json={"calendar_date": "2026-08-25"},
|
||
)
|
||
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\nkirschen\nspäter hafen"},
|
||
)
|
||
notes_gen = client.post(
|
||
f"/api/journal/days/{notes_day.json()['day']['id']}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [notes_conv.json()["id"]]},
|
||
)
|
||
expect(notes_gen.status_code == 200, f"notes generate {notes_gen.text}")
|
||
notes_intern = intern_of(notes_gen.json())
|
||
expect(MIXED_SOURCES_INSTRUCTION in notes_intern, "notes use the same mixed-source instruction")
|
||
expect("markt" in notes_intern.lower() and "kirschen" in notes_intern.lower(), "notes sources remain complete")
|
||
|
||
import_text(
|
||
profile_id,
|
||
"Ich schreibe in kurzen, ruhigen Sätzen und lasse den Tag stehen. Der Hafen blieb hinter der Fähre.",
|
||
occurred_at="2026-07-01",
|
||
)
|
||
examples = select_journal_style_examples(profile_id, exclude_dates=["2026-08-26"])
|
||
expect(examples and "ruhigen Sätzen" in examples[0]["excerpt"], "imported text is a style example when no finals exist")
|
||
expect(all("zimlich" not in (item.get("excerpt") or "") for item in examples), "current day is not selected as style")
|
||
|
||
saved = client.post(
|
||
"/api/journal/entries",
|
||
headers=headers,
|
||
json={
|
||
"journal_day_id": notes_day.json()["day"]["id"],
|
||
"title": "Eigene Fassung",
|
||
"body": "Heute blieb ich beim klaren Schnitt und schrieb den Markt in eigenen Worten, ohne Pathos.",
|
||
"origin": "user_edit",
|
||
"source_conversation_ids": [notes_conv.json()["id"]],
|
||
},
|
||
)
|
||
expect(saved.status_code == 200, f"save {saved.text}")
|
||
ranked = select_journal_style_examples(profile_id, exclude_dates=["2026-08-26"])
|
||
expect(ranked and ranked[0]["kind"] == "journal_entry", "final journal text outranks imports")
|
||
expect("klaren Schnitt" in ranked[0]["excerpt"], "user-edited journal text is the preferred example")
|
||
expect(len(ranked) <= 2, "at most two style examples")
|
||
|
||
update_facet(profile_id, "core", value="Kurze Sätze, trockener Schnitt, keine Pathoswolken.")
|
||
expect("Core:" not in compile_task_brief(profile_id), "unconfirmed core is not a style authority")
|
||
set_lifecycle(profile_id, "confirmed")
|
||
with_core = compile_task_brief(profile_id)
|
||
expect("Core:" in with_core and "trockener Schnitt" in with_core, "confirmed core reaches the task brief")
|
||
expect("Neutraler Journalstil" not in with_core, "confirmed profile replaces the neutral fallback")
|
||
|
||
remember_mapping(profile_id, "Hanna", "PERSON:99")
|
||
|
||
def rewritten(_messages, _policy):
|
||
return ChatResult(
|
||
content=(
|
||
"Markttag\n\n"
|
||
"Ich ging dann zum Markt, auf dem es ziemlich voll war. "
|
||
"Vielleicht bleibe ich kürzer. "
|
||
"Brot holen wollte ich noch, habe es aber nicht gemacht. "
|
||
"Die rote Tasche lag im Auto."
|
||
),
|
||
model="fake",
|
||
usage={},
|
||
context_compression="disabled",
|
||
)
|
||
|
||
with patch("privacy_gateway.complete_model", rewritten):
|
||
second = client.post(
|
||
f"/api/journal/days/{day.json()['day']['id']}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [conv.json()["id"]]},
|
||
)
|
||
expect(second.status_code == 200, f"rewritten generate {second.text}")
|
||
body = second.json().get("body") or ""
|
||
expect("ziemlich voll" in body, "spelling is corrected")
|
||
expect("zimlich" not in body, "source typo is not kept")
|
||
expect("Ich ging dann zum Markt" in body, "good rewritten wording may stay")
|
||
expect("Vielleicht bleibe ich kürzer" in body, "uncertainty stays uncertainty")
|
||
expect("wollte" in body.lower() and "nicht gemacht" in body, "plan is not turned into completion")
|
||
expect("nicht gemacht" in body, "negation is kept")
|
||
expect("rote Tasche" in body, "unique attested detail is kept")
|
||
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")
|
||
intern2 = intern_of(second.json())
|
||
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(
|
||
sum(1 for item in (second.json().get("run_log") or []) if item.get("kind") == "model_call") == 1,
|
||
"high similarity does not trigger a retry",
|
||
)
|
||
expect((second.json().get("trace") or {}).get("lexical_similarity") is not None, "similarity is a diagnostic only")
|
||
|
||
def notes_prose(_messages, _policy):
|
||
return ChatResult(
|
||
content="Notizen\n\nAm Markt holte ich Kirschen. Später war ich am Hafen.",
|
||
model="fake",
|
||
usage={},
|
||
context_compression="disabled",
|
||
)
|
||
|
||
with patch("privacy_gateway.complete_model", notes_prose):
|
||
notes_out = client.post(
|
||
f"/api/journal/days/{notes_day.json()['day']['id']}/generate",
|
||
headers=headers,
|
||
json={"conversation_ids": [notes_conv.json()["id"]]},
|
||
)
|
||
notes_body = notes_out.json().get("body") or ""
|
||
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")
|
||
|
||
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):
|
||
return ChatResult(
|
||
content="Hanna am Hafen\n\nIch ging zum Markt, auf dem es ziemlich voll war.",
|
||
model="fake",
|
||
usage={},
|
||
context_compression="disabled",
|
||
)
|
||
|
||
with patch("privacy_gateway.complete_model", invent_title):
|
||
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, f"unattested title {blocked.text}")
|
||
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:
|
||
conn.execute(
|
||
"UPDATE ai_prompts SET template = 'CUSTOM JOURNAL PROMPT' WHERE slug = ?",
|
||
("mvp.journal_generate",),
|
||
)
|
||
init_db()
|
||
custom = load_active_prompt("mvp.journal_generate")
|
||
expect(custom["template"] == "CUSTOM JOURNAL PROMPT", "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("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")
|
||
|
||
print("journal editorial tests passed.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|