372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""Style-application variants control which writing-profile parts reach generate."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
|
|
os.environ["KANSHO_FAKE_DETECT"] = "1"
|
|
|
|
from fastapi.testclient import TestClient
|
|
from db import get_db, init_db
|
|
from journal_editorial import (
|
|
compile_journal_style_application,
|
|
style_application_trace,
|
|
)
|
|
from journal_generation_policy import (
|
|
clone_guideline,
|
|
compile_selection,
|
|
default_selection_ids,
|
|
get_guideline,
|
|
save_selection,
|
|
seed_generation_instructions,
|
|
)
|
|
from journal_generate import pack_narration_context
|
|
from main import app
|
|
from privacy_gateway import install_test_recorder, reset_debug
|
|
from prompt_budget import JournalBudget
|
|
from writing_profile_store import (
|
|
compile_task_brief,
|
|
import_text,
|
|
set_lifecycle,
|
|
update_facet,
|
|
upsert_trait,
|
|
)
|
|
|
|
|
|
CORE_MARK = "ZXCORE_MARKER kurze trockene Saetze"
|
|
FACET_MARK = "ZXFACET_MARKER ruhige Abendreflexion"
|
|
TRAIT_MARK = "ZXTRAIT_MARKER keine rhythmischen Aufzaehlungen"
|
|
EXAMPLE_MARK = "ZXEXAMPLE_MARKER Der Hafen blieb hinter der Faehre"
|
|
TODAY_MARK = "ZXTODAY_MARKER am heutigen Markt"
|
|
|
|
|
|
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 style_region(intern: str) -> str:
|
|
return (intern or "").split("\nCURRENT_DAY_SOURCES\n")[0]
|
|
|
|
|
|
def has_heading(intern: str, title: str) -> bool:
|
|
body = intern or ""
|
|
return f"\n{title}\n" in body or body.startswith(f"{title}\n")
|
|
|
|
|
|
def seed_id(slot: str, key: str) -> str:
|
|
from journal_generation_policy import load_seed_document
|
|
|
|
seed = load_seed_document()
|
|
for item in (seed.get("slots") or {}).get(slot, {}).get("variants") or []:
|
|
if item.get("guideline_key") == key:
|
|
return item["id"]
|
|
raise SystemExit(f"FAIL: missing seed {slot}/{key}")
|
|
|
|
|
|
def selection_of(voice_key: str) -> dict[str, str]:
|
|
ids = default_selection_ids()
|
|
ids["voice_id"] = seed_id("voice", voice_key)
|
|
return ids
|
|
|
|
|
|
def ensure_local_profile(profile_id: str = "style-context-local") -> str:
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO profiles (id, email, name, password_hash, role)
|
|
VALUES (?, ?, ?, ?, 'user')
|
|
""",
|
|
(profile_id, f"{profile_id}@example.test", "Style", "x"),
|
|
)
|
|
return profile_id
|
|
|
|
|
|
def install_profile(profile_id: str) -> None:
|
|
update_facet(profile_id, "core", value=CORE_MARK)
|
|
update_facet(profile_id, "autobiographical_journal", value=FACET_MARK)
|
|
upsert_trait(
|
|
profile_id,
|
|
slug="journal-rhythm",
|
|
facet_key="autobiographical_journal",
|
|
label="Rhythmus",
|
|
statement=TRAIT_MARK,
|
|
origin="manual",
|
|
)
|
|
set_lifecycle(profile_id, "confirmed")
|
|
import_text(
|
|
profile_id,
|
|
EXAMPLE_MARK + " in knappen ruhigen Saetzen ohne Pathoswolken.",
|
|
occurred_at="2026-07-01",
|
|
)
|
|
|
|
|
|
def test_compile_flags() -> None:
|
|
init_db()
|
|
profile_id = ensure_local_profile()
|
|
install_profile(profile_id)
|
|
full = compile_task_brief(profile_id)
|
|
expect(CORE_MARK in full and FACET_MARK in full and TRAIT_MARK in full, "full brief keeps selected parts")
|
|
core_only = compile_task_brief(
|
|
profile_id,
|
|
include_core=True,
|
|
include_facet=False,
|
|
include_traits=False,
|
|
)
|
|
expect(CORE_MARK in core_only, "core-only brief keeps core")
|
|
expect(FACET_MARK not in core_only, "core-only brief omits facet")
|
|
expect(TRAIT_MARK not in core_only, "core-only brief omits traits")
|
|
none = compile_task_brief(
|
|
profile_id,
|
|
include_core=False,
|
|
include_facet=False,
|
|
include_traits=False,
|
|
)
|
|
expect(none == "", "no selected profile parts yield an empty brief")
|
|
bundle = compile_journal_style_application(profile_id, compile_selection(selection_of("neutral")).style_context)
|
|
expect(bundle["profile_block"] == "", "profile-free omits the wrapped profile block")
|
|
expect(bundle["examples_block"] == "", "profile-free omits the wrapped examples block")
|
|
|
|
|
|
def test_budget_omission_in_effective_trace() -> None:
|
|
init_db()
|
|
prompt = {
|
|
"id": "style-pack",
|
|
"slug": "mvp.journal_generate",
|
|
"prompt_type": "base",
|
|
"template": "{{writing_profile}}{{style_examples}}DAY\n{{reconstruction}}\nOLD\n{{existing_text}}\n",
|
|
}
|
|
assembled = {
|
|
"writing_profile": "WRITING_PROFILE\nCore: kurze Sätze.\n",
|
|
"reconstruction": "Heute Markt.",
|
|
"style_examples": "",
|
|
"existing_text": "",
|
|
}
|
|
huge = "Stil " + ("Beispielwort " * 400)
|
|
from engine import preview_prompt
|
|
from prompt_budget import estimate_tokens
|
|
|
|
rendered = preview_prompt(prompt, assembled)["rendered"]
|
|
budget = JournalBudget(
|
|
model="fake",
|
|
purpose="journal_generate",
|
|
effective_context_window=32_768,
|
|
reserved_output_tokens=256,
|
|
safety_margin=0.15,
|
|
available_input_tokens=estimate_tokens(rendered) + 80,
|
|
chars_per_token=2.0,
|
|
)
|
|
packed, dropped = pack_narration_context(
|
|
prompt,
|
|
budget,
|
|
assembled,
|
|
style_examples=huge,
|
|
existing_text="Bestehende Fassung",
|
|
include_existing=True,
|
|
)
|
|
expect("style_examples" in dropped, "budget drops style examples first")
|
|
expect("Beispielwort" not in (packed.get("style_examples") or ""), "dropped examples leave the packed prompt")
|
|
compiled = compile_selection(selection_of("with_examples"))
|
|
bundle = {
|
|
"requested": dict(compiled.style_context),
|
|
"brief": "Core: kurze Sätze.",
|
|
"profile_block": assembled["writing_profile"],
|
|
"examples_block": huge,
|
|
"example_rows": [{"kind": "imported_text", "excerpt": huge}],
|
|
"omitted": [],
|
|
"effective": {"neutral_fallback": False, "trait_count": 0},
|
|
}
|
|
trace = style_application_trace(compiled, bundle, dropped=dropped)
|
|
expect(trace["effective"]["style_example_count"] == 0, "effective trace has no examples after budget drop")
|
|
expect(
|
|
any(item.get("part") == "style_examples" and item.get("reason") == "budget" for item in trace["omitted"]),
|
|
"budget drop is recorded on the effective trace",
|
|
)
|
|
expect(trace["id"] == seed_id("voice", "with_examples"), "trace keeps the requested voice id")
|
|
expect(trace["revision"] == 1, "trace keeps the requested revision")
|
|
|
|
|
|
def main() -> None:
|
|
test_compile_flags()
|
|
test_budget_omission_in_effective_trace()
|
|
|
|
init_db()
|
|
reset_debug()
|
|
with TestClient(app) as client:
|
|
setup = client.post(
|
|
"/api/auth/setup",
|
|
json={"email": "voice@example.test", "name": "Ada", "password": "test-pass"},
|
|
)
|
|
headers = header(setup.json()["token"])
|
|
profile_id = setup.json()["profile_id"]
|
|
install_profile(profile_id)
|
|
|
|
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Stil"})
|
|
day = client.post(
|
|
f"/api/journal/spaces/{space.json()['id']}/days",
|
|
headers=headers,
|
|
json={"calendar_date": "2026-08-29"},
|
|
)
|
|
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": f"Ich war am Markt. {TODAY_MARK}. Vielleicht bleibe ich kürzer."},
|
|
)
|
|
expect(turn.status_code == 200, f"turn {turn.status_code}")
|
|
day_id = day.json()["day"]["id"]
|
|
conv_id = conv.json()["id"]
|
|
|
|
def generate(voice_key: str) -> dict:
|
|
reset_debug()
|
|
recorder = install_test_recorder()
|
|
result = client.post(
|
|
f"/api/journal/days/{day_id}/generate",
|
|
headers=headers,
|
|
json={
|
|
"conversation_ids": [conv_id],
|
|
"generation_selection": selection_of(voice_key),
|
|
"remember_generation_selection": False,
|
|
},
|
|
)
|
|
expect(result.status_code == 200, f"{voice_key} generate {result.status_code}")
|
|
payload = result.json()
|
|
intern = intern_of(payload)
|
|
style = style_region(intern)
|
|
app_trace = (payload.get("trace") or {}).get("style_application") or {}
|
|
calls = [item.get("purpose") for item in recorder]
|
|
expect(calls == ["journal_generate"], f"{voice_key} still uses exactly one narration call")
|
|
expect(app_trace.get("id") == seed_id("voice", voice_key), f"{voice_key} id reaches the trace")
|
|
expect(app_trace.get("key") == voice_key, f"{voice_key} key reaches the trace")
|
|
expect(app_trace.get("revision") == 1, f"{voice_key} revision reaches the trace")
|
|
expect(
|
|
(payload.get("trace") or {}).get("generation_selection", {}).get("ids", {}).get("voice")
|
|
== seed_id("voice", voice_key),
|
|
f"{voice_key} selection id matches prompt/trace",
|
|
)
|
|
expect(TODAY_MARK not in style, f"{voice_key} does not use today as a style example")
|
|
expect(TODAY_MARK in intern.split("\nCURRENT_DAY_SOURCES\n")[-1], f"{voice_key} keeps today as content")
|
|
expect("keine neuen Tatsachen" in intern, f"{voice_key} keeps fact fidelity")
|
|
expect("[[PERSON:01]]" in intern, f"{voice_key} keeps privacy placeholders")
|
|
return {"payload": payload, "intern": intern, "style": style, "trace": app_trace}
|
|
|
|
free = generate("neutral")
|
|
expect(not has_heading(free["intern"], "WRITING_PROFILE"), "profile-free omits the profile heading")
|
|
expect(not has_heading(free["intern"], "STYLE_EXAMPLES"), "profile-free omits the examples heading")
|
|
expect("STYLE_EXAMPLES" not in free["intern"], "profile-free has no orphaned STYLE_EXAMPLES reference")
|
|
expect(CORE_MARK not in free["intern"], "profile-free sends no core")
|
|
expect(FACET_MARK not in free["intern"], "profile-free sends no facet")
|
|
expect(TRAIT_MARK not in free["intern"], "profile-free sends no traits")
|
|
expect(EXAMPLE_MARK not in free["intern"], "profile-free sends no style examples")
|
|
expect(free["trace"]["effective"]["include_core"] is False, "profile-free effective core is no")
|
|
expect(free["trace"]["effective"]["style_example_count"] == 0, "profile-free effective examples are zero")
|
|
|
|
light = generate("light")
|
|
expect(CORE_MARK in light["style"], "light sends core")
|
|
expect(FACET_MARK not in light["intern"], "light does not send facet even indirectly")
|
|
expect(TRAIT_MARK not in light["intern"], "light does not send traits even indirectly")
|
|
expect(EXAMPLE_MARK not in light["intern"], "light does not send examples even indirectly")
|
|
expect(has_heading(light["intern"], "WRITING_PROFILE"), "light keeps a profile heading")
|
|
expect(not has_heading(light["intern"], "STYLE_EXAMPLES"), "light omits the examples heading")
|
|
expect("STYLE_EXAMPLES" not in light["intern"], "light has no orphaned STYLE_EXAMPLES reference")
|
|
expect(light["trace"]["effective"]["include_core"] is True, "light effective core is yes")
|
|
expect(light["trace"]["effective"]["include_facet"] is False, "light effective facet is no")
|
|
expect(light["trace"]["effective"]["trait_count"] == 0, "light effective traits are zero")
|
|
|
|
clear = generate("clear")
|
|
expect(CORE_MARK in clear["style"], "clear sends core")
|
|
expect(FACET_MARK in clear["style"], "clear sends the relevant facet")
|
|
expect(TRAIT_MARK in clear["style"], "clear sends traits")
|
|
expect(EXAMPLE_MARK not in clear["intern"], "clear does not send examples")
|
|
expect(not has_heading(clear["intern"], "STYLE_EXAMPLES"), "clear omits the examples heading")
|
|
expect("STYLE_EXAMPLES" not in clear["intern"], "clear has no orphaned STYLE_EXAMPLES reference")
|
|
expect(clear["trace"]["effective"]["include_core"] is True, "clear effective core is yes")
|
|
expect(clear["trace"]["effective"]["include_facet"] is True, "clear effective facet is yes")
|
|
expect(clear["trace"]["effective"]["trait_count"] >= 1, "clear effective traits are counted")
|
|
expect(clear["trace"]["effective"]["style_example_count"] == 0, "clear effective examples are zero")
|
|
|
|
examples = generate("with_examples")
|
|
expect(CORE_MARK in examples["style"], "with-examples sends core")
|
|
expect(FACET_MARK in examples["style"], "with-examples sends facet")
|
|
expect(TRAIT_MARK in examples["style"], "with-examples sends traits")
|
|
expect(EXAMPLE_MARK in examples["style"], "with-examples sends style examples")
|
|
expect(has_heading(examples["intern"], "STYLE_EXAMPLES"), "with-examples labels the examples block")
|
|
expect("ausschließlich als Stilreferenz" in examples["intern"] or "sprachliche" in examples["style"], "examples stay style-only")
|
|
expect("keine Tatsachen des heutigen" in examples["style"] or "nicht übernommen" in examples["style"], "example facts are forbidden")
|
|
expect(examples["trace"]["effective"]["style_example_count"] >= 1, "with-examples effective count is used")
|
|
expect(EXAMPLE_MARK not in (examples["payload"].get("body") or ""), "example facts are not copied into today's draft")
|
|
|
|
settings = client.get("/api/journal/generation-settings", headers=headers).json()
|
|
voice_options = settings["options"]["voice"]
|
|
free_opt = next(item for item in voice_options if item["id"] == seed_id("voice", "neutral"))
|
|
expect(free_opt["style_context"]["include_core"] is False, "generation-settings expose style context without prompt text")
|
|
expect("instruction" not in free_opt, "generation-settings still hide prompt wording")
|
|
|
|
save_selection(profile_id, selection_of("neutral"))
|
|
remembered = generate("noticeable")
|
|
expect(CORE_MARK in remembered["style"], "legacy noticeable remains selectable")
|
|
profile_run = client.post(
|
|
f"/api/journal/days/{day_id}/generate",
|
|
headers=headers,
|
|
json={"conversation_ids": [conv_id]},
|
|
)
|
|
expect(profile_run.status_code == 200, f"stored selection generate {profile_run.status_code}")
|
|
stored_intern = intern_of(profile_run.json())
|
|
expect(not has_heading(stored_intern, "WRITING_PROFILE"), "stored profile-free selection stays profile-free")
|
|
expect(CORE_MARK not in stored_intern, "stored profile-free selection still omits core")
|
|
|
|
cloned = clone_guideline(seed_id("voice", "clear"))
|
|
original_instruction = get_guideline(seed_id("voice", "clear"), include_instruction=True)["instruction"]
|
|
with get_db() as conn:
|
|
seed_generation_instructions(conn)
|
|
expect(
|
|
get_guideline(seed_id("voice", "clear"), include_instruction=True)["instruction"] == original_instruction,
|
|
"seed refresh does not replace the published original with a clone",
|
|
)
|
|
expect(
|
|
get_guideline(cloned["id"], include_instruction=True)["style_context"]["include_core"] is True,
|
|
"cloned voice keeps the structured style context",
|
|
)
|
|
|
|
def compile_one(key: str) -> str:
|
|
bundle = compile_journal_style_application(profile_id, compile_selection(selection_of(key)).style_context)
|
|
return (bundle.get("brief") or "") + "\n" + (bundle.get("style_examples") or "")
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
first = pool.submit(compile_one, "neutral")
|
|
second = pool.submit(compile_one, "with_examples")
|
|
free_text = first.result()
|
|
example_text = second.result()
|
|
expect(CORE_MARK not in free_text, "parallel profile-free compile stays empty")
|
|
expect(CORE_MARK in example_text and EXAMPLE_MARK in example_text, "parallel with-examples compile keeps all parts")
|
|
expect(EXAMPLE_MARK not in free_text, "parallel requests do not mix style configurations")
|
|
|
|
print("journal style context tests passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|