Kansho/backend/tests/test_architecture_correction.py
2026-08-26 10:42:20 +02:00

263 lines
12 KiB
Python

"""Architecture correction pass. Run from backend/: python tests/test_architecture_correction.py"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.gettempdir()) / "kansho-arch-correction-test.sqlite")
os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
Path(os.environ["KANSHO_DB_PATH"]).unlink(missing_ok=True)
from fastapi.testclient import TestClient
from db import get_db, migrate_journal_source_refs
from main import app
from privacy_gateway import reset_debug
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 main() -> None:
reset_debug()
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"]
space = client.post("/api/journal/spaces", headers=headers, json={"title": "Alltag"})
expect(space.status_code == 200, "space created")
space_id = space.json()["id"]
day = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-20"},
)
day_id = day.json()["day"]["id"]
conv = client.post(
f"/api/journal/days/{day_id}/conversations",
headers=headers,
json={"title": "Quelle"},
)
conv_id = conv.json()["id"]
turn = client.post(
f"/api/journal/conversations/{conv_id}/turn",
headers=headers,
json={"body": "Heute war der Markt voll. Ich habe Kirschen gekauft."},
)
expect(turn.status_code == 200, "source turn")
gen = client.post(
f"/api/journal/days/{day_id}/generate",
headers=headers,
json={"conversation_ids": [conv_id]},
)
expect(gen.status_code == 200, "draft from conversation")
draft = gen.json()
expect(draft["source_conversation_ids"] == [conv_id], "draft reconstructs conversation ids")
expect(len(draft["source_message_ids"]) >= 2, "draft reconstructs message ids")
with get_db() as conn:
refs = conn.execute(
"SELECT source_kind, source_id FROM journal_draft_source_refs WHERE draft_id = ?",
(draft["id"],),
).fetchall()
kinds = {row["source_kind"] for row in refs}
expect("conversation" in kinds and "message" in kinds, "draft provenance is relational")
saved = client.post(
"/api/journal/entries",
headers=headers,
json={
"journal_day_id": day_id,
"title": draft.get("title") or "Markttag",
"body": draft.get("body") or "Kirschen",
"origin": "accepted_draft",
"entry_id": None,
"source_conversation_ids": draft["source_conversation_ids"],
"source_message_ids": draft["source_message_ids"],
},
)
expect(saved.status_code == 200, f"save entry {saved.text}")
entry = saved.json()
expect(entry["source_conversation_ids"] == [conv_id], "entry version reconstructs conversation ids")
expect(entry["source_message_ids"] == draft["source_message_ids"], "entry version reconstructs message ids")
versions = client.get(f"/api/journal/entries/{entry['id']}/versions", headers=headers)
expect(versions.status_code == 200, "list versions")
expect(versions.json()[0]["source_conversation_ids"] == [conv_id], "version list keeps source links")
legacy_draft_id = str(uuid.uuid4())
legacy_version_id = str(uuid.uuid4())
legacy_entry_id = str(uuid.uuid4())
mid = draft["source_message_ids"][0]
with get_db() as conn:
conn.execute(
"""
INSERT INTO journal_drafts
(id, profile_id, journal_day_id, title, body,
source_conversation_ids, source_message_ids, as_of, superseded_at)
VALUES (?, ?, ?, 'Legacy', 'alt', ?, ?, datetime('now'), datetime('now'))
""",
(legacy_draft_id, profile_id, day_id, json.dumps([conv_id]), json.dumps([mid])),
)
conn.execute(
"""
INSERT INTO journal_entries (id, profile_id, journal_day_id, space_id)
VALUES (?, ?, ?, ?)
""",
(legacy_entry_id, profile_id, day_id, space_id),
)
conn.execute(
"""
INSERT INTO journal_entry_versions
(id, profile_id, entry_id, title, body, source_conversation_ids, source_message_ids, origin)
VALUES (?, ?, ?, 'Legacy', 'alt', ?, ?, 'user_edit')
""",
(legacy_version_id, profile_id, legacy_entry_id, json.dumps([conv_id]), json.dumps([mid])),
)
migrate_journal_source_refs(conn)
draft_refs = [
(row["source_kind"], row["source_id"])
for row in conn.execute(
"SELECT source_kind, source_id FROM journal_draft_source_refs WHERE draft_id = ? ORDER BY sort_order",
(legacy_draft_id,),
).fetchall()
]
version_refs = [
(row["source_kind"], row["source_id"])
for row in conn.execute(
"SELECT source_kind, source_id FROM journal_entry_version_source_refs WHERE version_id = ? ORDER BY sort_order",
(legacy_version_id,),
).fetchall()
]
expect(("conversation", conv_id) in draft_refs, "legacy draft conversation migrated")
expect(("message", mid) in draft_refs, "legacy draft message migrated")
expect(("conversation", conv_id) in version_refs, "legacy version conversation migrated")
expect(("message", mid) in version_refs, "legacy version message migrated")
usage = client.post("/api/dialogue/sessions", headers=headers, json={"intent": "space-only"})
space_conv = client.post(
"/api/dialogue/conversations",
headers=headers,
json={"usage_session_id": usage.json()["id"], "title": "Plan", "space_id": space_id},
)
expect(space_conv.status_code == 200, f"space conversation without day {space_conv.text}")
expect(space_conv.json()["space_id"] == space_id, "conversation assigned to space")
expect(not space_conv.json().get("journal_day_id"), "conversation has no journal day")
plan_turn = client.post(
f"/api/dialogue/conversations/{space_conv.json()['id']}/turn",
headers=headers,
json={"body": "Wir wollen morgen zum Hafen, das ist geplant."},
)
expect(plan_turn.status_code == 200, "space-only turn works without journal day")
later = client.post(
f"/api/journal/spaces/{space_id}/days",
headers=headers,
json={"calendar_date": "2026-08-21"},
)
later_id = later.json()["day"]["id"]
later_conv = client.post(
f"/api/journal/days/{later_id}/conversations",
headers=headers,
json={"title": "Heute"},
)
reset_debug()
later_turn = client.post(
f"/api/journal/conversations/{later_conv.json()['id']}/turn",
headers=headers,
json={"body": "Heute früh aufgestanden."},
)
expect(later_turn.status_code == 200, "later day turn")
rendered = (later_turn.json().get("trace") or {}).get("intern") or ""
expect("Hafen" in rendered, "earlier space source can supply a known plan")
expect("Heute früh aufgestanden" in rendered, "current conversation remains primary")
sibling = client.post(
f"/api/journal/days/{later_id}/conversations",
headers=headers,
json={"title": "Anderes"},
)
reset_debug()
sibling_turn = client.post(
f"/api/journal/conversations/{sibling.json()['id']}/turn",
headers=headers,
json={"body": "Nur ein kurzer Einkauf."},
)
expect(sibling_turn.status_code == 200, "sibling turn")
sibling_rendered = (sibling_turn.json().get("trace") or {}).get("intern") or ""
expect("Heute früh aufgestanden" not in sibling_rendered, "same-day sibling is not fully loaded")
expect("Nur ein kurzer Einkauf" in sibling_rendered, "current sibling conversation is primary")
from context_builder import assemble_text, build_internal_context
ctx = build_internal_context(profile_id, conversation_id=later_conv.json()["id"], purpose="dialogue_turn")
assembled = assemble_text(ctx)
expect(not assembled.get("writing_profile"), "dialogue turn has no writing profile brief")
expect("interaction_hint" in assembled, "dialogue turn exposes interaction hint slot")
expect(assembled.get("interaction_hint"), "interaction hint is filled from labeled defaults")
expect("Produktdefault" in (assembled.get("interaction_hint") or ""), "defaults are labeled, not anonymous")
expect(any(item.get("type") == "interaction_hint" for item in ctx["items"]), "interaction slot is a context item")
expect(
not any(item.get("type") == "writing_profile" for item in ctx["items"]),
"writing profile is not a dialogue kernel dependency",
)
expect("space_recent_sources" in {item.get("type") for item in ctx["items"]}, "space source spec is selected")
journal_ctx = build_internal_context(
profile_id,
conversation_id=conv_id,
journal_day_id=day_id,
space_id=space_id,
purpose="journal_generate",
conversation_ids=[conv_id],
)
expect(
any(item.get("type") == "writing_profile" for item in journal_ctx["items"]),
"journal generate still uses writing profile",
)
expect(
not any(item.get("type") == "space_recent_sources" for item in journal_ctx["items"]),
"journal generate does not pull space conversation excerpts",
)
from conversation_signals import infer_signals, similar_enough
from journal_policy import consolidation_offer
chronicle = infer_signals(["Heute Vormittag zum Markt, danach Kirschen gekauft."])
deep = infer_signals(["Tiefer: der Spaziergang hat mich berührt."])
expect(chronicle["narrative_mode"] == "chronicle", "chronicle mode from user text")
expect(deep["narrative_mode"] == "reflective", "reflective mode from user text")
expect(not similar_enough([chronicle, deep]), "different modes are not similar")
expect(consolidation_offer([chronicle, deep]) is False, "policy does not offer unlike modes")
twin = infer_signals(["Nach dem Markt noch Brot geholt, dann heimgegangen."])
expect(similar_enough([chronicle, twin]), "two chronicles can be offered")
nav = Path(ROOT).parent / "frontend" / "src" / "config" / "appNav.js"
nav_text = nav.read_text(encoding="utf-8")
expect("/dialog" not in nav_text, "/dialog is not in normal product navigation")
expect("'/journal'" in nav_text, "journal remains in product navigation")
print("architecture correction tests passed.")
if __name__ == "__main__":
main()