1269 lines
55 KiB
Python
1269 lines
55 KiB
Python
"""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" not in narrate, "default voice does not label a style-examples block")
|
||
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()
|