"""Journal budget, two-stage generation, provider payload. Run from backend/: python tests/test_journal_budget.py""" from __future__ import annotations import json 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-budget-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 main import app from journal_reconstruct import parse_reconstruction, reconstruction_from_model, validate_reconstruction from model_catalog import ModelWindow, reset_catalog, set_metadata_override from prompt_budget import ( ERROR_MODEL_CONTEXT_TOO_SMALL, ERROR_NO_USER_SOURCES, ERROR_OUTPUT_LIMIT_UNSUPPORTED, ERROR_PROMPT_BUDGET_EXCEEDED, ERROR_RECONSTRUCTION_INVALID, JournalBudgetError, assert_input_fits, estimate_tokens, plan_journal_budget, ) from providers import ChatResult, ProviderConfig, complete_chat, is_openrouter from retrieval import pair_user_priority_messages from writing_profile_store import ( TASK_BRIEF_MAX_CHARS, compile_task_brief, get_profile, import_text, replace_trait_refs, set_lifecycle, upsert_trait, update_facet, ) 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 _window(context: int, completion: int = 4096, model: str = "test/model") -> ModelWindow: return ModelWindow( model=model, context_length=context, max_completion_tokens=completion, source="test", provider="test", ) def _openrouter_config(**kwargs) -> ProviderConfig: data = dict( role="generate", name="openrouter", mode="http", url="https://openrouter.ai/api/v1/chat/completions", model="openai/gpt-4o", key="sk-test", local=False, zdr=True, no_train=True, ) data.update(kwargs) return ProviderConfig(**data) def test_budget_math() -> None: window = _window(32_768) budget = plan_journal_budget(window, purpose="journal_generate") expect(budget.reserved_output_tokens == 4096, "journal reserves 4096 completion tokens") expect(budget.safety_margin == 0.15, "documented safety margin") expect(budget.available_input_tokens > 0, "input budget remains after output reserve") expect( budget.estimated_input_tokens + budget.reserved_output_tokens <= int(budget.effective_context_window * (1 - budget.safety_margin)) or budget.estimated_input_tokens == 0, "empty estimate still has a planned ceiling", ) typical = "user: " + ("Heute Markt. " * 40) fitted = assert_input_fits(budget, typical) expect(fitted.budget_ok is True, "typical current journal request fits 32K") expect(fitted.estimated_input_tokens >= estimate_tokens(typical), "estimate includes overhead") huge = "Wort " * 80_000 try: assert_input_fits(plan_journal_budget(window, purpose="journal_generate"), huge) raise SystemExit("FAIL: oversized input should miss the budget") except JournalBudgetError as exc: expect(exc.code == ERROR_PROMPT_BUDGET_EXCEEDED, "oversized input is prompt_budget_exceeded") try: plan_journal_budget(_window(8192), purpose="journal_generate") raise SystemExit("FAIL: 8K model should be rejected") except JournalBudgetError as exc: expect(exc.code == ERROR_MODEL_CONTEXT_TOO_SMALL, "8K model is model_context_too_small") try: plan_journal_budget(_window(32_768, completion=100), purpose="journal_generate") raise SystemExit("FAIL: tiny completion window should be rejected") except JournalBudgetError as exc: expect(exc.code == ERROR_OUTPUT_LIMIT_UNSUPPORTED, "tiny output window is output_limit_unsupported") capped = plan_journal_budget(_window(32_768, completion=2048), purpose="journal_generate") expect(capped.reserved_output_tokens == 2048, "output reserve is capped by the model") four_char_guess = 100 # 400 chars / 4 expect(estimate_tokens("abcd" * 100) >= four_char_guess, "conservative estimate is not looser than 4 chars/token") def _claim(kind: str, evidence: str) -> dict: return {"kind": kind, "evidence": evidence} def _chrono(source_id: str, *, time: str | None = None, claims: list | None = None, source: str | None = "user") -> dict: item = { "source_id": source_id, "time": time, "claims": claims or [], } if source is not None: item["source"] = source return item def _sourced(source_id: str, evidence: str) -> dict: return {"source_id": source_id, "evidence": evidence} def _expect_invalid(payload, *, messages=None, user_bodies=None, assistant_bodies=None, reason=None) -> None: try: validate_reconstruction( payload, messages=messages, user_bodies=user_bodies, assistant_bodies=assistant_bodies, ) raise SystemExit(f"FAIL: reconstruction should be invalid ({reason or 'expected invalid'})") except JournalBudgetError as exc: expect(exc.code == ERROR_RECONSTRUCTION_INVALID, f"{reason or 'invalid'} is reconstruction_invalid") if reason: actual = (exc.diagnostics or {}).get("reason") expect(actual == reason, f"reason is {reason}, got {actual}") def test_reconstruction_rules() -> None: user = [ "Heute um 6:00 Uhr Tee. Vielleicht fahren wir doch nicht.", "Um 11:00 Uhr war die Bootstour, oder doch erst später.", ] assistant = ["Was davon möchtest du festhalten? Bitte erzähl den nächsten Schritt."] messages = [ {"role": "user", "body": user[0], "conversation_id": "c1"}, {"role": "assistant", "body": assistant[0], "conversation_id": "c1"}, {"role": "user", "body": user[1], "conversation_id": "c1"}, ] good = { "source_order": ["u1", "u2"], "chronology": [ _chrono( "u1", time="6:00 Uhr", claims=[ _claim("event", "Heute um 6:00 Uhr Tee"), _claim("uncertainty", "Vielleicht fahren wir doch nicht."), ], ), _chrono( "u2", time="11:00 Uhr", claims=[ _claim("event", "Um 11:00 Uhr war die Bootstour"), _claim("uncertainty", "oder doch erst später"), ], ), ], "contradictions": [_sourced("u2", "oder doch erst später")], "uncertainties": [_sourced("u1", "Vielleicht fahren wir doch nicht.")], } validated = validate_reconstruction(good, messages=messages) blob = "\n".join(item.get("text") or "" for item in validated.get("sources") or []) expect(validated.get("kind") == "verified_artifact", "stage 2 payload is a verified artifact") expect("6:00" in blob, "canonical first source keeps its time") expect(validated["source_order"] == ["u1", "u2"], "stage 1 keeps local source order") expect("Vielleicht" in blob and "oder doch" in blob, "hedges remain as canonical wording") expect(all(item.get("role") == "user" for item in validated["sources"]), "artifact sources are user role") expect("chronology" not in validated, "untrusted chronology is not the stage 2 payload") expect("contradictions" not in validated, "unverified contradiction labels are not verified content") expect("feeling" not in json.dumps(validated.get("sources")), "source records carry no semantic kind") expect(assistant[0] not in blob, "assistant wording is not in the artifact") dropped_middle = json.loads(json.dumps(good)) dropped_middle["chronology"] = [good["chronology"][0]] _expect_invalid(dropped_middle, messages=messages, reason="incomplete_coverage") three_users = [ {"role": "user", "body": "Anfang des Tages am Markt.", "conversation_id": "c1"}, {"role": "user", "body": "Mitte: Kirschen gekauft.", "conversation_id": "c1"}, {"role": "user", "body": "Ende am Hafen um 18:00 Uhr.", "conversation_id": "c1"}, ] skip_middle = { "source_order": ["u1", "u2", "u3"], "chronology": [ _chrono("u1", claims=[_claim("event", "Anfang des Tages am Markt.")]), _chrono( "u3", time="18:00 Uhr", claims=[_claim("event", "Ende am Hafen um 18:00 Uhr")], ), ], "contradictions": [], "uncertainties": [], } _expect_invalid(skip_middle, messages=three_users, reason="incomplete_coverage") swapped = json.loads(json.dumps(good)) swapped["source_order"] = ["u2", "u1"] _expect_invalid(swapped, messages=messages, reason="source_order_mismatch") swapped_chrono = json.loads(json.dumps(good)) swapped_chrono["chronology"] = [good["chronology"][1], good["chronology"][0]] _expect_invalid(swapped_chrono, messages=messages, reason="reorder_unjustified") duplicate = json.loads(json.dumps(good)) duplicate["chronology"] = [good["chronology"][0], {**good["chronology"][0]}] _expect_invalid(duplicate, messages=messages, reason="duplicate_source_id") unknown = json.loads(json.dumps(good)) unknown["chronology"][1]["source_id"] = "u9" _expect_invalid(unknown, messages=messages, reason="unknown_source_id") as_assistant = json.loads(json.dumps(good)) as_assistant["chronology"][0]["source"] = "assistant" _expect_invalid(as_assistant, messages=messages, reason="invalid_source") bogus_source = json.loads(json.dumps(good)) bogus_source["chronology"][0]["source"] = "unknown" _expect_invalid(bogus_source, messages=messages, reason="invalid_source") missing_source = json.loads(json.dumps(good)) del missing_source["chronology"][0]["source"] _expect_invalid(missing_source, messages=messages, reason="missing_source") empty_source = json.loads(json.dumps(good)) empty_source["chronology"][0]["source"] = "" _expect_invalid(empty_source, messages=messages, reason="missing_source") assistant_id = json.loads(json.dumps(good)) assistant_id["chronology"][0]["source_id"] = "a1" assistant_id["chronology"][0]["claims"] = [_claim("event", assistant[0])] _expect_invalid(assistant_id, messages=messages, reason="assistant_as_fact") stolen = json.loads(json.dumps(good)) stolen["chronology"][0]["claims"] = [_claim("event", assistant[0])] _expect_invalid(stolen, messages=messages, reason="assistant_as_fact") missing_time = json.loads(json.dumps(good)) missing_time["chronology"][0]["time"] = None missing_time["chronology"][0]["claims"] = [_claim("event", "Tee")] missing_time["chronology"][1]["time"] = None missing_time["chronology"][1]["claims"] = [_claim("event", "Bootstour")] kept_times = validate_reconstruction(missing_time, messages=messages) kept_blob = "\n".join(item.get("text") or "" for item in kept_times["sources"]) expect("6:00" in kept_blob and "11:00" in kept_blob, "omitted clock claims do not drop canonical times") dropped_uncertainty = json.loads(json.dumps(good)) dropped_uncertainty["uncertainties"] = [] dropped_uncertainty["chronology"][0]["claims"] = [_claim("event", "Heute um 6:00 Uhr Tee")] dropped_uncertainty["chronology"][1]["claims"] = [_claim("event", "Um 11:00 Uhr war die Bootstour")] kept_hedge = validate_reconstruction(dropped_uncertainty, messages=messages) hedge_blob = "\n".join(item.get("text") or "" for item in kept_hedge["sources"]) expect("Vielleicht" in hedge_blob, "canonical uncertainty wording is rehydrated without labels") dropped_contradiction = json.loads(json.dumps(good)) dropped_contradiction["contradictions"] = [] kept_contra = validate_reconstruction(dropped_contradiction, messages=messages) expect("contradictions" not in kept_contra, "omitted contradiction labels are not required as facts") expect("oder doch" in "\n".join(item.get("text") or "" for item in kept_contra["sources"]), "canonical hedge remains") capitalized_source = json.loads(json.dumps(good)) capitalized_source["chronology"][0]["source"] = "User" _expect_invalid(capitalized_source, messages=messages, reason="invalid_source") def test_reconstruction_claim_and_time_binding() -> None: tea_messages = [ {"role": "user", "body": "Heute trank ich Tee.", "conversation_id": "c1"}, {"role": "assistant", "body": "Bist du anschließend mit Anna nach Berlin gefahren?", "conversation_id": "c1"}, ] invented = { "source_order": ["u1"], "chronology": [ { "source_id": "u1", "source": "user", "time": None, "events": ["Ich fuhr anschließend mit Anna nach Berlin."], "evidence": "Heute trank ich Tee.", } ], "contradictions": [], "uncertainties": [], } _expect_invalid(invented, messages=tea_messages, reason="unverified_claim") paraphrased = { "source_order": ["u1"], "chronology": [ _chrono( "u1", claims=[ _claim("event", "Heute trank ich Tee."), _claim("event", "Ich fuhr anschließend mit Anna nach Berlin."), ], ) ], "contradictions": [], "uncertainties": [], } _expect_invalid(paraphrased, messages=tea_messages, reason="unverified_claim") identical_assistant = { "source_order": ["u1"], "chronology": [ _chrono("u1", claims=[_claim("event", "Bist du anschließend mit Anna nach Berlin gefahren?")]) ], "contradictions": [], "uncertainties": [], } _expect_invalid(identical_assistant, messages=tea_messages, reason="assistant_as_fact") two_users = [ {"role": "user", "body": "Heute trank ich Tee.", "conversation_id": "c1"}, {"role": "user", "body": "Später ging ich zum Markt.", "conversation_id": "c1"}, ] other_source = { "source_order": ["u1", "u2"], "chronology": [ _chrono("u1", claims=[_claim("event", "Später ging ich zum Markt.")]), _chrono("u2", claims=[_claim("event", "Heute trank ich Tee.")]), ], "contradictions": [], "uncertainties": [], } _expect_invalid(other_source, messages=two_users, reason="wrong_source") each_claim_own_source = { "source_order": ["u1"], "chronology": [ _chrono( "u1", claims=[ _claim("event", "Heute trank ich Tee."), _claim("event", "Ich fuhr anschließend mit Anna nach Berlin."), ], ) ], "contradictions": [], "uncertainties": [], } _expect_invalid(each_claim_own_source, messages=tea_messages, reason="unverified_claim") clock_messages = [ {"role": "user", "body": "Um 6:00 Uhr trank ich Tee.", "conversation_id": "c1"}, {"role": "user", "body": "Um 11:00 Uhr begann die Bootstour.", "conversation_id": "c1"}, ] swapped_times = { "source_order": ["u1", "u2"], "chronology": [ _chrono( "u2", time="6:00 Uhr", claims=[_claim("event", "Um 11:00 Uhr begann die Bootstour.")], ), _chrono( "u1", time="11:00 Uhr", claims=[_claim("event", "Um 6:00 Uhr trank ich Tee.")], ), ], "contradictions": [], "uncertainties": [], } _expect_invalid(swapped_times, messages=clock_messages, reason="time_source_mismatch") polluted_time = { "source_order": ["u1", "u2"], "chronology": [ _chrono( "u1", time="6:00 Uhr mit Anna nach Berlin", claims=[_claim("event", "Um 6:00 Uhr trank ich Tee.")], ), _chrono( "u2", time="11:00 Uhr", claims=[_claim("event", "Um 11:00 Uhr begann die Bootstour.")], ), ], "contradictions": [], "uncertainties": [], } _expect_invalid(polluted_time, messages=clock_messages, reason="time_source_mismatch") justified = { "source_order": ["u1", "u2"], "chronology": [ _chrono( "u2", time="06:00", claims=[_claim("event", "Um 6:00 Uhr trank ich Tee.")], ), _chrono( "u1", time="11:00 Uhr", claims=[_claim("event", "Um 11:00 Uhr begann die Bootstour.")], ), ], "contradictions": [], "uncertainties": [], } # messages are 6:00 then 11:00 in source order; justified uses later-mentioned morning first morning_first = [ {"role": "user", "body": "Um 11:00 Uhr begann die Bootstour.", "conversation_id": "c1"}, {"role": "user", "body": "Um 6:00 Uhr trank ich Tee.", "conversation_id": "c1"}, ] accepted = validate_reconstruction(justified, messages=morning_first) expect([item["source_id"] for item in accepted["sources"]] == ["u2", "u1"], "confirmed clocks may reorder events") expect(accepted["source_order"] == ["u1", "u2"], "source_order stays the local encounter order") expect(accepted.get("order") == ["u2", "u1"], "artifact order follows justified chronology") relative_messages = [ {"role": "user", "body": "Zuerst der Markt.", "conversation_id": "c1"}, {"role": "user", "body": "Danach der Hafen.", "conversation_id": "c1"}, ] unjustified = { "source_order": ["u1", "u2"], "chronology": [ _chrono("u2", claims=[_claim("event", "Danach der Hafen.")]), _chrono("u1", claims=[_claim("event", "Zuerst der Markt.")]), ], "contradictions": [], "uncertainties": [], } _expect_invalid(unjustified, messages=relative_messages, reason="reorder_unjustified") equal_clocks = [ {"role": "user", "body": "Um 6:00 Uhr Tee.", "conversation_id": "c1"}, {"role": "user", "body": "Um 6:00 Uhr noch Brot.", "conversation_id": "c1"}, ] equal_reorder = { "source_order": ["u1", "u2"], "chronology": [ _chrono("u2", time="6:00 Uhr", claims=[_claim("event", "Um 6:00 Uhr noch Brot.")]), _chrono("u1", time="6:00 Uhr", claims=[_claim("event", "Um 6:00 Uhr Tee.")]), ], "contradictions": [], "uncertainties": [], } _expect_invalid(equal_reorder, messages=equal_clocks, reason="reorder_unjustified") def test_reconstruction_completeness_and_labels() -> None: meal = "Morgens kaufte ich Brot. Mittags traf ich Anna am Markt. Abends kochte ich Suppe." meal_messages = [ {"role": "user", "body": meal, "conversation_id": "c1"}, {"role": "assistant", "body": "War die Suppe für Gäste?", "conversation_id": "c1"}, ] morning_only = { "source_order": ["u1"], "chronology": [_chrono("u1", claims=[_claim("event", "Morgens")])], "contradictions": [], "uncertainties": [], } full = validate_reconstruction(morning_only, messages=meal_messages) blob = "\n".join(item.get("text") or "" for item in full["sources"]) expect(blob == meal, "bread/anna/soup source is rehydrated in full") expect("Mittags traf ich Anna am Markt" in blob, "anna sentence survives a short claim") expect("Abends kochte ich Suppe" in blob, "soup sentence survives a short claim") expect(full.get("kind") == "verified_artifact", "stage 2 payload is the local artifact") expect("Was die Suppe für Gäste" not in blob and "Gäste" not in blob, "assistant line is not rehydrated") rain = "Es regnete am Bahnsteig. Der Zug hatte Verspätung. Im Café bestellte ich Kaffee." rain_messages = [{"role": "user", "body": rain, "conversation_id": "c1"}] rain_only = { "source_order": ["u1"], "chronology": [_chrono("u1", claims=[_claim("event", "Es regnete")])], "contradictions": [], "uncertainties": [], } rain_full = validate_reconstruction(rain_only, messages=rain_messages) rain_blob = "\n".join(item.get("text") or "" for item in rain_full["sources"]) expect("Verspätung" in rain_blob and "Kaffee" in rain_blob, "a second long source is also fully rehydrated") feeling = { "source_order": ["u1"], "chronology": [_chrono("u1", claims=[_claim("feeling", "Morgens kaufte ich Brot.")])], "contradictions": [], "uncertainties": [], } labeled = validate_reconstruction(feeling, messages=meal_messages) dumped = json.dumps(labeled) expect("\"feeling\"" not in dumped, "kind feeling is not verified content") expect(labeled["sources"][0]["text"] == meal, "feeling label does not replace canonical wording") expect("annotations_unverified" not in labeled, "journal stage 2 does not receive unverified labels") as_contradiction = { "source_order": ["u1"], "chronology": [_chrono("u1", claims=[_claim("event", "Morgens kaufte ich Brot.")])], "contradictions": [_sourced("u1", "Morgens kaufte ich Brot.")], "uncertainties": [], } not_contra = validate_reconstruction(as_contradiction, messages=meal_messages) expect("contradictions" not in not_contra, "a normal event is not a verified contradiction") expect(not_contra["sources"][0]["text"] == meal, "canonical meal text stays intact") two_users = [ {"role": "user", "body": meal, "conversation_id": "c1"}, {"role": "user", "body": rain, "conversation_id": "c1"}, ] both = { "source_order": ["u1", "u2"], "chronology": [ _chrono("u1", claims=[_claim("event", "Morgens")]), _chrono("u2", claims=[_claim("event", "Es regnete")]), ], "contradictions": [], "uncertainties": [], } combined = validate_reconstruction(both, messages=two_users) combined_blob = "\n".join(item.get("text") or "" for item in combined["sources"]) expect(meal in combined_blob and rain in combined_blob, "every selected user source is fully present") def test_local_fallback_covers_users() -> None: messages = [ {"role": "assistant", "body": "Wenn du magst, fang einfach an – ich höre zu."}, {"role": "user", "body": "Heute Markt, Kirschen."}, {"role": "assistant", "body": "Was davon möchtest du festhalten?"}, {"role": "user", "body": "Später der Hafen."}, ] artifact, info = reconstruction_from_model("das ist kein JSON", messages) expect(info.get("stage1") == "local_fallback", "broken JSON uses local coverage") texts = " ".join(item.get("text") or "" for item in artifact.get("sources") or []) expect("Kirschen" in texts and "Hafen" in texts, "both user sources survive broken stage 1") expect(all(item.get("role") == "user" for item in artifact.get("sources") or []), "assistant is not a source") accepted, ok = reconstruction_from_model( json.dumps( { "source_order": ["u1", "u2"], "chronology": [ { "source_id": "u1", "source": "user", "time": None, "claims": [{"kind": "event", "evidence": "Heute Markt, Kirschen."}], }, { "source_id": "u2", "source": "user", "time": None, "claims": [{"kind": "event", "evidence": "Später der Hafen."}], }, ], "contradictions": [], "uncertainties": [], "plan_changes": [], "corrections": [], } ), messages, ) expect(ok.get("stage1") == "model_accepted", "valid stage 1 still accepted") expect("Kirschen" in " ".join(item.get("text") or "" for item in accepted.get("sources") or []), "accepted path keeps user text") try: reconstruction_from_model("{}", [{"role": "assistant", "body": "Hallo"}]) raise SystemExit("FAIL: expected no_user_sources") except JournalBudgetError as exc: expect(exc.code == ERROR_NO_USER_SOURCES, "assistant-only day has no journal source") def test_day_messages_keep_middle() -> None: messages = [ {"role": "assistant", "body": "Frage A"}, {"role": "user", "body": "Anfang"}, {"role": "assistant", "body": "Frage B"}, {"role": "user", "body": "Mitte des Tages, Markt"}, {"role": "assistant", "body": "Frage C"}, {"role": "user", "body": "Ende"}, ] paired = pair_user_priority_messages(messages) bodies = [item["body"] for item in paired if item["role"] == "user"] expect(bodies == ["Anfang", "Mitte des Tages, Markt", "Ende"], "middle user content stays") expect(all(item["role"] in {"user", "assistant"} for item in paired), "only dialogue roles") expect(paired[0]["role"] == "assistant" and paired[1]["body"] == "Anfang", "assistant kept only as reply context") def test_conversation_boundary_pairing() -> None: messages = [ {"role": "user", "body": "Tag in Gespräch A", "conversation_id": "A", "seq": 1}, {"role": "assistant", "body": "Frage aus A", "conversation_id": "A", "seq": 2}, {"role": "user", "body": "Tag in Gespräch B", "conversation_id": "B", "seq": 1}, ] paired = pair_user_priority_messages(messages) bodies = [item["body"] for item in paired] expect("Tag in Gespräch A" in bodies and "Tag in Gespräch B" in bodies, "all user messages remain") expect("Frage aus A" not in bodies, "trailing assistant from A is dropped at the conversation boundary") expect(bodies.index("Tag in Gespräch A") < bodies.index("Tag in Gespräch B"), "conversation order stays stable") opening = [ {"role": "assistant", "body": "Impuls A", "conversation_id": "A", "seq": 1}, {"role": "user", "body": "Tag in Gespräch A", "conversation_id": "A", "seq": 2}, {"role": "assistant", "body": "Frage aus A", "conversation_id": "A", "seq": 3}, {"role": "assistant", "body": "Impuls B", "conversation_id": "B", "seq": 1}, {"role": "user", "body": "Tag in Gespräch B", "conversation_id": "B", "seq": 2}, ] opened = pair_user_priority_messages(opening) opened_bodies = [item["body"] for item in opened] expect(opened_bodies == ["Impuls A", "Tag in Gespräch A", "Impuls B", "Tag in Gespräch B"], "opening assistant stays with its own user") expect("Frage aus A" not in opened_bodies, "trailing A reply is still dropped after an opening") for index, item in enumerate(opened): if item["conversation_id"] == "B" and item["role"] == "user" and index: prev = opened[index - 1] expect( not (prev["role"] == "assistant" and prev["conversation_id"] == "A"), "A assistant is never glued onto B's user", ) def test_provider_payload() -> None: captured = {} class Response: status_code = 200 def json(self): return { "model": "openai/gpt-4o", "choices": [{"message": {"content": "ok"}}], "usage": { "prompt_tokens": 21, "completion_tokens": 7, "total_tokens": 28, "cost": 0.0012, }, } def fake_post(url, json=None, headers=None, timeout=None): captured["url"] = url captured["json"] = json return Response() remote = _openrouter_config() with patch("providers.httpx.post", fake_post): result = complete_chat( remote, [{"role": "user", "content": "ping"}], timeout=5, max_tokens=4096, disable_context_compression=True, ) expect(isinstance(result, ChatResult), "complete_chat returns usage-aware result") expect(captured["json"]["max_tokens"] == 4096, "journal sets max_tokens") expect( captured["json"]["plugins"] == [{"id": "context-compression", "enabled": False}], "OpenRouter journal disables context compression", ) expect(result.usage.get("prompt_tokens") == 21, "usage prompt_tokens kept") expect(result.usage.get("cost") == 0.0012, "usage cost kept") expect(result.context_compression == "disabled", "compression flag recorded") local = ProviderConfig( role="generate", name="ollama", mode="http", url="http://127.0.0.1:11434/v1/chat/completions", model="llama", key="", local=True, zdr=True, no_train=True, ) captured.clear() with patch("providers.httpx.post", fake_post): complete_chat( local, [{"role": "user", "content": "ping"}], timeout=5, max_tokens=4096, disable_context_compression=True, ) expect("plugins" not in captured["json"], "local providers do not get OpenRouter plugins") expect("provider" not in captured["json"], "local providers do not get OpenRouter provider block") expect(is_openrouter(remote) is True, "openrouter url is detected") expect(is_openrouter(local) is False, "localhost is not openrouter") class Reject: status_code = 400 text = "This endpoint's maximum context length is 8192 tokens" def json(self): return {"error": {"message": self.text}} with patch("providers.httpx.post", lambda *args, **kwargs: Reject()): try: complete_chat(remote, [{"role": "user", "content": "x"}], timeout=5, max_tokens=4096) raise SystemExit("FAIL: context-length reject should raise") except Exception as exc: expect(getattr(exc, "code", "") == "provider_context_length_rejected", "provider context reject is distinct") def _ends_on_word_boundary(text: str) -> bool: if not text: return True return text[-1].isalnum() is False or text.split()[-1].isalnum() def test_task_brief_and_dedupe(client: TestClient, headers: dict, profile_id: str) -> None: long_core = ( "Lange Sätze mit konkreten Uhren und Orten, selten Pathos, oft ein trockener Schnitt. " * 20 ) update_facet(profile_id, "core", value=long_core) update_facet( profile_id, "autobiographical_journal", value="Urlaubstagebücher bleiben chronologisch und nennen Zeiten, ohne den Core zu wiederholen.", ) statements = [ ("rhythm", "Wechselt zwischen kurzen Schnitten und längeren Sätzen."), ("detail", "Behält Uhren, Orte und kleine Gegenstände."), ("chronology", "Erzählt in der Reihenfolge des Tages, ohne Rückblenden zu erfinden."), ("lexicon", "Alltagswörter, wenig Schmuck, eigene Wiederholungen erlaubt."), ("humor", "Gelegentlich trocken, nie aufgesetzt."), ("transitions", "Kommt mit danach, später, irgendwann von Szene zu Szene."), ] for slug, statement in statements: upsert_trait( profile_id, slug=slug, facet_key="autobiographical_journal", label=slug, statement=statement, origin="manual", force=True, ) replace_trait_refs( profile_id, slug, [ { "role": "exemplar", "excerpt": f"Langes Beispiel für {slug} " + ("Wortfolge " * 40), "occurred_at": "2026-08-24", } ], ) set_lifecycle(profile_id, "confirmed") before = get_profile(profile_id) brief = compile_task_brief(profile_id, "journal_generate") after = get_profile(profile_id) expect(len(brief) <= TASK_BRIEF_MAX_CHARS, f"task brief stays in budget ({len(brief)})") expect(brief == brief.strip(), "task brief is not mid-trim whitespace") expect(_ends_on_word_boundary(brief), "task brief does not cut inside a word") for slug, statement in statements: expect(slug in brief or statement.split(",")[0] in brief, f"relevant trait {slug} can be included") expect("Repräsentative Exemplare:" not in brief, "historical exemplars are not mixed into the compact brief") expect(len([line for line in brief.splitlines() if line.startswith("- ")]) >= 6, "six traits considered") expect(before["sources"] == after["sources"], "compile_task_brief does not mutate stored sources") expect( [(item.get("slug"), item.get("statement")) for item in before["traits"]] == [(item.get("slug"), item.get("statement")) for item in after["traits"]], "stored traits stay the source of truth", ) body = "Am 24.08. war der Hafen ruhig, später der Markt." import_text(profile_id, body, occurred_at="2026-08-24") import_text(profile_id, body, occurred_at="2026-08-24") imported = get_profile(profile_id) same_day = [ item for item in imported["sources"] if item.get("kind") == "imported_text" and (item.get("occurred_at") or "").startswith("2026-08-24") ] expect(len(same_day) >= 2, "duplicate originals remain stored") display = imported.get("compiled_brief") or "" expect(display.count("Hafen ruhig") == 1, "duplicate brief sources are not shown twice") def test_generate_flow(client: TestClient, headers: dict) -> None: import privacy_gateway from privacy_gateway import install_test_recorder, reset_debug expect(not hasattr(privacy_gateway, "debug_history"), "production has no global prompt history") space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-25"}, ) 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": "Heute um 7:30 Uhr Markt, danach Kirschen. Vielleicht bleibe ich kürzer."}, ) expect(turn.status_code == 200, f"turn {turn.status_code}") turn2 = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": "Später noch der Hafen, ungefähr 18:00 Uhr."}, ) expect(turn2.status_code == 200, f"second turn {turn2.status_code}") reset_debug() recorder = install_test_recorder() 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}") expect([item.get("purpose") for item in recorder] == ["journal_generate"], "runtime generate calls only narration") run_log = gen.json().get("run_log") or (gen.json().get("trace") or {}).get("log") or [] expect(any(item.get("kind") == "model_call" for item in run_log), "run log records model calls") expect( any(item.get("kind") == "stage1_result" and item.get("stage") == "local_source_artifact" for item in run_log), "run log records local stage 1", ) expect(sum(1 for item in run_log if item.get("kind") == "detect") <= 1, "at most one detect call") expect(sum(1 for item in run_log if item.get("kind") == "model_call") == 1, "exactly one generate model call") expect( not any(key in (item or {}) for item in run_log for key in ("intern", "egress", "raw", "reply", "local_label")), "run log omits prompt bodies and labels", ) stages = {item.get("purpose"): item for item in (gen.json().get("trace") or {}).get("stages") or []} expect("local_source_artifact" in stages, "stage 1 is the local source artifact") expect("journal_generate" in stages, "stage 2 ran through the gateway") expect("journal_reconstruct" not in stages, "runtime path does not call journal_reconstruct") reconstruct = stages["local_source_artifact"].get("intern") or "" narrate = stages["journal_generate"].get("intern") or "" expect(stages["local_source_artifact"].get("status") == "local_ok", "local stage 1 succeeded") expect(stages["local_source_artifact"].get("provider") in (None, ""), "local stage has no provider") expect(stages["local_source_artifact"].get("model") in (None, ""), "local stage has no model") expect((stages["local_source_artifact"].get("budget") or {}).get("prompt_tokens") == 0, "local stage has no tokens") expect("verified_artifact" in reconstruct and "u1" in reconstruct, "local artifact contains source ids") expect("assistant:" not in reconstruct, "assistant lines are not in the artifact") expect("Markt" in reconstruct and "Hafen" in reconstruct, "every selected user source is in the artifact") expect("assistant:" not in narrate, "stage 2 does not resend assistant lines") expect("user:" not in narrate, "stage 2 does not resend the raw dialogue") expect("CURRENT_DAY_SOURCES" in narrate, "stage 2 labels current-day facts") expect("[u1]" in narrate, "stage 2 lists source ids") expect("7:30" in reconstruct or "7:30" in narrate, "times survive into generation") expect("Markt" in narrate and "Hafen" in narrate, "full selected user sources reach stage 2") expect("Vielleicht bleibe ich kürzer" in narrate, "omitted labels do not drop canonical hedges") 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("STYLE_EXAMPLES" in narrate, "style examples are labeled separately from day facts") 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() short_recorder = install_test_recorder() with patch( "privacy_gateway.fake_reconstruction", lambda rendered: json.dumps( { "source_order": ["u1", "u2"], "chronology": [ { "source_id": "u1", "source": "user", "time": None, "claims": [{"kind": "feeling", "evidence": "Heute"}], }, { "source_id": "u2", "source": "user", "time": None, "claims": [{"kind": "event", "evidence": "Später"}], }, ], "contradictions": [], "uncertainties": [], } ), ): short_gen = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(short_gen.status_code == 200, f"short-claim generate {short_gen.status_code}") expect( [item.get("purpose") for item in short_recorder] == ["journal_generate"], "invalid former stage-1 JSON is irrelevant because reconstruct is not called", ) short_narrate = "" for item in (short_gen.json().get("trace") or {}).get("stages") or []: if item.get("purpose") == "journal_generate": short_narrate = item.get("intern") or "" expect("Kirschen" in short_narrate and "Hafen" in short_narrate, "local artifact cannot drop selected sources") expect("\"feeling\"" not in short_narrate, "feeling labels do not reach stage 2 as facts") budget = (gen.json().get("trace") or {}).get("budget") or stages["journal_generate"].get("budget") or {} expect(budget.get("reserved_output_tokens") == 4096, "trace records reserved output") expect(budget.get("context_compression") == "disabled", "trace records compression off") expect(budget.get("budget_ok") is True, "trace records budget ok") expect("rendered" not in budget and "masked" not in budget, "budget diagnostics omit prompt bodies") expect((gen.json().get("trace") or {}).get("stages"), "admin trace has both stages") reset_debug() skip_recorder = install_test_recorder() with patch( "privacy_gateway.fake_reconstruction", lambda rendered: json.dumps( { "source_order": ["u1", "u2"], "chronology": [ { "source_id": "u1", "source": "user", "time": "7:30 Uhr", "claims": [ {"kind": "event", "evidence": "Heute um 7:30 Uhr Markt"}, {"kind": "uncertainty", "evidence": "Vielleicht bleibe ich kürzer."}, ], } ], "contradictions": [], "uncertainties": [{"source_id": "u1", "evidence": "Vielleicht bleibe ich kürzer."}], } ), ): denied_recon = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(denied_recon.status_code == 200, f"former reconstruct JSON is unused {denied_recon.text}") fallback_body = denied_recon.json().get("body") or "" expect("Markt" in fallback_body or "Hafen" in fallback_body, "local artifact still produces a draft") fallback_stages = {item.get("purpose"): item for item in (denied_recon.json().get("trace") or {}).get("stages") or []} expect(fallback_stages.get("local_source_artifact", {}).get("stage1") == "local_ok", "stage 1 is local and successful") expect( fallback_stages.get("local_source_artifact", {}).get("coverage") == "all_selected_sources" or "verified_artifact" in ((fallback_stages.get("local_source_artifact") or {}).get("intern") or ""), "local coverage remains all selected sources", ) expect("journal_generate" in fallback_stages, "stage 2 still runs on the local artifact") narrate_fallback = fallback_stages.get("journal_generate", {}).get("intern") or "" expect("Heute um 7:30 Uhr Markt" in narrate_fallback, "first user source is rehydrated locally") expect("Später noch der Hafen" in narrate_fallback, "second user source is still in the local artifact") expect( [item.get("purpose") for item in skip_recorder] == ["journal_generate"], "no reconstruct provider call after unused model JSON", ) reset_catalog() set_metadata_override( "fake", ModelWindow( model="fake", context_length=8192, max_completion_tokens=4096, source="test", provider="fake", ), ) denied = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(denied.status_code == 422, f"8K generate is refused {denied.text}") detail = denied.json().get("detail") or {} expect(detail.get("code") == ERROR_MODEL_CONTEXT_TOO_SMALL, "8K refusal uses model_context_too_small") expect(detail.get("diagnostics"), "refusal carries diagnostics") reset_catalog() def test_generate_attested_cleartext_is_normalized(client: TestClient, headers: dict, profile_id: str) -> None: from identity_store import remember_mapping from providers import ChatResult remember_mapping(profile_id, "Anna", "PERSON:01") space = client.post("/api/journal/spaces", headers=headers, json={"title": "Identität"}) 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"}, ) turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": "Heute war ich mit Anna am Markt, danach Kirschen."}, ) expect(turn.status_code == 200, f"identity-cleartext setup turn {turn.status_code}") calls = {"n": 0} def leak(_messages, _policy): calls["n"] += 1 return ChatResult( content="Anna stand den ganzen Nachmittag am Markt.", model="fake", usage={"prompt_tokens": 11, "completion_tokens": 9, "total_tokens": 20, "cost": 0.004}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", leak): 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"attested cleartext is accepted locally {gen.text}") payload = gen.json() leak_log = payload.get("run_log") or (payload.get("trace") or {}).get("log") or [] expect(calls["n"] == 1, "attested cleartext does not retry") expect(not any(item.get("kind") == "retry" for item in leak_log), "no privacy retry event") body = payload.get("body") or "" expect("Anna" in body, "source-attested name is demasked") 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 []} reconstruct = stages.get("local_source_artifact") or {} narrate = stages.get("journal_generate") or {} expect(reconstruct.get("status") == "local_ok", "stage 1 stays local and does not call reconstruct") expect(narrate.get("model_text_accepted") is True, "stage 2 accepts the normalized model text") expect((payload.get("trace") or {}).get("generate_calls") == 1 or (narrate.get("generate_calls") == 1), "exactly one generate call") def test_generate_inactive_mapping_keeps_model_text(client: TestClient, headers: dict, profile_id: str) -> None: from identity_store import remember_mapping from providers import ChatResult remember_mapping(profile_id, "Clarissa", "PERSON:99") space = client.post("/api/journal/spaces", headers=headers, json={"title": "Inaktiv"}) 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"}, ) turn = client.post( f"/api/journal/conversations/{conv.json()['id']}/turn", headers=headers, json={"body": "Heute Markt, danach Kirschen am Hafen."}, ) expect(turn.status_code == 200, f"inactive-mapping setup {turn.status_code}") calls = {"n": 0} def keep(_messages, _policy): calls["n"] += 1 return ChatResult( content="Ein Markttag\n\nHeute Markt, danach Kirschen am Hafen. Clarissa blieb unerwähnt.", model="fake", usage={}, context_compression="disabled", ) with patch("privacy_gateway.complete_model", keep): gen = client.post( f"/api/journal/days/{day.json()['day']['id']}/generate", headers=headers, json={"conversation_ids": [conv.json()["id"]]}, ) expect(gen.status_code == 409, f"inactive mapping generate {gen.text}") detail = gen.json().get("detail") 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(not any(item.get("kind") == "retry" for item in run_log), "inactive mapping does not retry") expect( any(item.get("reason") == "unattested_identity" for item in run_log), "invented historical name is unattested journal content", ) expect(diag.get("model_text_accepted") is False, "unattested model text is not accepted") 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: from journal_generate import generate_draft other = client.post( "/api/users", headers=headers, json={"email": "second@example.test", "name": "Second", "password": "test-pass", "role": "user"}, ) expect(other.status_code == 200, f"second profile {other.text}") login = client.post("/api/auth/login", json={"email": "second@example.test", "password": "test-pass"}) other_headers = header(login.json()["token"]) profile_b = login.json()["profile_id"] space_a = client.post("/api/journal/spaces", headers=headers, json={"title": "Profil A"}) day_a = client.post( f"/api/journal/spaces/{space_a.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-26"}, ) conv_a = client.post( f"/api/journal/days/{day_a.json()['day']['id']}/conversations", headers=headers, json={"title": "A"}, ) token_a = "UNIQUA-ALPHA-KIRSCHEN-9921" client.post( f"/api/journal/conversations/{conv_a.json()['id']}/turn", headers=headers, json={"body": f"Heute {token_a} am Markt."}, ) space_b = client.post("/api/journal/spaces", headers=other_headers, json={"title": "Profil B"}) day_b = client.post( f"/api/journal/spaces/{space_b.json()['id']}/days", headers=other_headers, json={"calendar_date": "2026-08-26"}, ) conv_b = client.post( f"/api/journal/days/{day_b.json()['day']['id']}/conversations", headers=other_headers, json={"title": "B"}, ) token_b = "UNIQUB-BETA-HAFEN-7744" client.post( f"/api/journal/conversations/{conv_b.json()['id']}/turn", headers=other_headers, json={"body": f"Heute {token_b} am Hafen."}, ) draft_a = generate_draft( client.get("/api/auth/me", headers=headers).json()["id"], day_a.json()["day"]["id"], [conv_a.json()["id"]], ) draft_b = generate_draft(profile_b, day_b.json()["day"]["id"], [conv_b.json()["id"]]) intern_a = " ".join(stage.get("intern") or "" for stage in (draft_a.get("trace") or {}).get("stages") or []) intern_b = " ".join(stage.get("intern") or "" for stage in (draft_b.get("trace") or {}).get("stages") or []) expect(token_a in intern_a, "profile A reconstruct contains its own text") expect(token_b not in intern_a, "profile A trace is not mixed with profile B") expect(token_b in intern_b, "profile B reconstruct contains its own text") expect(token_a not in intern_b, "profile B trace is not mixed with profile A") def test_two_conversations_same_day(client: TestClient, headers: dict) -> None: from retrieval import format_day_messages, retrieve space = client.post("/api/journal/spaces", headers=headers, json={"title": "Zwei Gespräche"}) day = client.post( f"/api/journal/spaces/{space.json()['id']}/days", headers=headers, json={"calendar_date": "2026-08-27"}, ) conv_a = client.post( f"/api/journal/days/{day.json()['day']['id']}/conversations", headers=headers, json={"title": "A"}, ) client.post( f"/api/journal/conversations/{conv_a.json()['id']}/turn", headers=headers, json={"body": "Nur in Gespräch A: der Vormittag."}, ) conv_b = client.post( f"/api/journal/days/{day.json()['day']['id']}/conversations", headers=headers, json={"title": "B"}, ) client.post( f"/api/journal/conversations/{conv_b.json()['id']}/turn", headers=headers, json={"body": "Nur in Gespräch B: der Nachmittag."}, ) profile_id = client.get("/api/auth/me", headers=headers).json()["id"] messages = retrieve( profile_id, { "kind": "day_messages", "journal_day_id": day.json()["day"]["id"], "conversation_ids": [conv_a.json()["id"], conv_b.json()["id"]], "overflow": "abort", "max_estimated_tokens": 20_000, }, ) formatted = format_day_messages(messages, with_source_ids=True) expect("Nur in Gespräch A" in formatted and "Nur in Gespräch B" in formatted, "both conversations remain") id_a = conv_a.json()["id"] id_b = conv_b.json()["id"] a_idx = [index for index, item in enumerate(messages) if item.get("conversation_id") == id_a] b_idx = [index for index, item in enumerate(messages) if item.get("conversation_id") == id_b] user_b = [index for index, item in enumerate(messages) if item.get("conversation_id") == id_b and item.get("role") == "user"] expect(user_b, "conversation B user message is present") expect(a_idx and b_idx and max(a_idx) < min(b_idx), "conversation A stays entirely before B") a_kept = [item for item in messages if item.get("conversation_id") == id_a] expect(a_kept and a_kept[-1]["role"] == "user", "trailing assistant from A is dropped; last kept A line is the user") for index in user_b: if index == 0: continue prev = messages[index - 1] expect( not (prev.get("role") == "assistant" and prev.get("conversation_id") == id_a), "assistant from A is not glued to user text from B", ) def main() -> None: test_budget_math() test_reconstruction_rules() test_reconstruction_claim_and_time_binding() test_reconstruction_completeness_and_labels() test_local_fallback_covers_users() test_day_messages_keep_middle() test_conversation_boundary_pairing() test_provider_payload() reset_catalog() with TestClient(app) as client: setup = client.post( "/api/auth/setup", json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"}, ) headers = header(setup.json()["token"]) profile_id = setup.json()["profile_id"] test_task_brief_and_dedupe(client, headers, profile_id) test_generate_flow(client, headers) test_generate_attested_cleartext_is_normalized(client, headers, profile_id) test_generate_inactive_mapping_keeps_model_text(client, headers, profile_id) test_traces_are_request_scoped(client, headers) test_two_conversations_same_day(client, headers) print("journal budget tests passed.") if __name__ == "__main__": main()