Kansho/backend/dialogue_turn.py
Lars cc43fc6409
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / smoke-dev (push) Waiting to run
Test Suite / frontend-build (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
Offer kinship names in learning review when Detect skips them.
Frau/Sohn mentions in the user line must still reach the popup if OpenRouter only tags the food reading or misses the person.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:47:44 +02:00

530 lines
18 KiB
Python

"""One generative call per user turn. Source messages are persisted before and after the call."""
from __future__ import annotations
import json
import re
from context_builder import assemble_text, build_internal_context, is_closing_turn
from conversation_signals import infer_signals
from debug_store import persist_engine_error, persist_step
from detect_learning import (
MODE_LEARNING,
apply_review_decisions,
auto_resolve_ambiguous,
build_candidates,
confirm_known_identity_spans,
drop_pending,
get_detect_operating_mode,
load_pending,
save_pending,
supplement_kinship_candidates,
suppress_known_non_identity,
)
from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals
from engine import EngineError, execute_prompt, load_active_prompt, preview_prompt
from entity_detect import DETECT_DIALOGUE_FALLBACK_CODES, DetectError, detect_personal_egress
from writing_profile_store import remember_dialogue_style
from profile_review import consider_dialogue
JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
PLOT_CONTINUATION = re.compile(
r"^\s*und\s+dann\b|"
r"ihr\s+seid\s+dann|"
r"seid\s+(?:ihr|du)\s+(?:dann\s+)?(?:zur|zum|in|auf|nach)|"
r"\b(?:aufgebrochen|losgegangen|losgefahren)\b",
re.IGNORECASE,
)
INTERVIEW_OPEN = re.compile(
r"^\s*(?:was|wie|warum|weshalb|wieso|erzähl(?:st|t)?|und\s+was)\b",
re.IGNORECASE,
)
DANN_PROBE = re.compile(r"\b(?:was|wie)\b.+\bdann\b|\bdann\b.+\b(?:gezeigt|geschehen|passiert)\b", re.IGNORECASE)
COMPLETION_ASK = re.compile(
r"^\s*ob\s+(?:ihr|du|wir)\b|"
r"\blosgekommen\b|"
r"\brechtzeitig\b.+\b(?:seid|habt|wart|gekommen)\b",
re.IGNORECASE,
)
FINITE_VERB = re.compile(
r"\b(?:ist|war|sind|waren|hat|hatte|wird|wurde|bleibt|liegt|steht|"
r"kam|ging|kann|muss|soll|will)\b",
re.IGNORECASE,
)
UNEARNED_COMPLETION = re.compile(
r"\b(?:holtet|geholt|kauftet|gekauft|aßet|gegessen|unternahmt|"
r"angekommen|aufgebrochen|losgegangen|losgefahren|losgekommen|weitergegangen)\b",
re.IGNORECASE,
)
NEXT_BEAT_Q = re.compile(
r"^\s*also\s+(?:seid ihr|habt ihr|bist du)\b|"
r"(?:seid ihr|habt ihr|bist du)\s+dann\b|"
r"(?:seid ihr|habt ihr|bist du).+\b(?:gesprungen|gerannt|rüber|geschafft|losgekommen)\b",
re.IGNORECASE,
)
MACHINE_TELL = re.compile(
r"\bals ki\b|"
r"ich bin (?:eine? )?(?:ki|sprachmodell|assistent)\b|"
r"danke,? dass du (?:das )?(?:teilst|erzählst)|"
r"lass uns (?:das )?(?:gemeinsam|mal)|"
r"\bzusammengefasst\b|"
r"ich höre (?:da )?heraus|"
r"^\s*interessant\b",
re.IGNORECASE,
)
UNEARNED_STANCE = re.compile(
r"fühlte\s+sich|an(?:ge)?fühlt|"
r"schmeckte|roch\b|klang\b|"
r"anders als(?:\s+erwartet|\s+gedacht|\s+geplant)?|"
r"sicher anders|"
r"\bmusste noch\b|\bmuss noch\b|"
r"für die nächste[n]?\b|"
r"gehörte uns\b",
re.IGNORECASE,
)
CONTENT_STOP = {
"dann", "noch", "schon", "wieder", "gegen",
"waren", "wurde", "haben", "hatte", "lagen", "bereit", "durch",
"unter", "über", "nach", "beim", "eine", "einem", "einer",
"dieser", "dieses", "auch", "aber", "dass", "wenn", "dann",
"sich", "uns", "euch", "mein", "dein", "sein", "ihre",
}
OPERATIONS = {
"fortfuehren": "Fortführen",
"konkretisieren": "Konkretisieren",
"plan_aufgreifen": "Plan oder Erwartung aufgreifen",
"abweichung": "Abweichung erkunden",
"erleben_vertiefen": "Erleben vertiefen",
"bedeutung": "Bedeutung erkunden",
}
def parse_turn_payload(content: str) -> tuple[str, dict]:
text = (content or "").strip()
blob = text
if blob.startswith("```"):
blob = re.sub(r"^```(?:json)?\s*|\s*```$", "", blob, flags=re.IGNORECASE | re.DOTALL)
match = JSON_BLOCK.search(blob)
if match:
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
data = None
if isinstance(data, dict):
operation = str(data.get("operation") or "").strip().lower()
impulse = str(data.get("impulse") or "").strip()
if impulse:
label = OPERATIONS.get(operation)
return impulse, {
"operation": operation if label else "unparsed",
"label": label or "nicht erkannt",
"parsed": bool(label),
}
return text, {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
def is_plot_continuation(impulse: str) -> bool:
text = (impulse or "").strip()
if not text:
return False
return bool(PLOT_CONTINUATION.search(text))
def is_interview_question(impulse: str) -> bool:
return bool(INTERVIEW_OPEN.search((impulse or "").strip()))
def is_unearned_completion(impulse: str) -> bool:
return bool(UNEARNED_COMPLETION.search(impulse or ""))
def is_unearned_stance(impulse: str) -> bool:
return bool(UNEARNED_STANCE.search(impulse or ""))
def is_machine_tell(impulse: str) -> bool:
return bool(MACHINE_TELL.search(impulse or ""))
def last_user_text(assembled: dict | None) -> str:
ctx = (assembled or {}).get("dialogue_context") or ""
lines = [line[5:].strip() for line in ctx.splitlines() if line.startswith("user:")]
return lines[-1] if lines else ""
def earlier_user_text(assembled: dict | None) -> str:
ctx = (assembled or {}).get("dialogue_context") or ""
lines = [line[5:].strip() for line in ctx.splitlines() if line.startswith("user:")]
return " ".join(lines[:-1])
def user_closed_day(assembled: dict | None) -> bool:
return is_closing_turn(last_user_text(assembled))
def is_day_arc_recap(impulse: str, assembled: dict | None) -> bool:
last = last_user_text(assembled)
earlier = earlier_user_text(assembled)
if not last or not earlier:
return False
last_words = set(content_words(last))
earlier_only = set(content_words(earlier)) - last_words
if len(earlier_only) < 6:
return False
foreign = {word for word in content_words(impulse) if word in earlier_only}
return len(foreign) >= 3
def is_reopen_after_close(impulse: str, assembled: dict | None) -> bool:
if not user_closed_day(assembled):
return False
if "?" in (impulse or ""):
return True
return is_day_arc_recap(impulse, assembled)
def content_words(text: str) -> list[str]:
return [
word
for word in re.findall(r"[a-zäöüß]{4,}", (text or "").lower())
if word not in CONTENT_STOP
]
def is_echo(impulse: str, source: str) -> bool:
src = set(content_words(source))
imp = content_words(impulse)
if len(imp) < 3 or len(src) < 3:
return False
hits = 0
for word in imp:
if word in src or any(len(item) >= 5 and word.startswith(item[:5]) for item in src):
hits += 1
return hits / len(imp) >= 0.5
def is_verbless_echo(impulse: str, source: str) -> bool:
text = (impulse or "").strip()
if not text or "?" in text or FINITE_VERB.search(text):
return False
src = set(content_words(source))
imp = content_words(text)
if not imp or not src:
return False
return all(
word in src or any(len(item) >= 5 and word.startswith(item[:5]) for item in src)
for word in imp
)
def is_dann_probe(impulse: str) -> bool:
return bool(DANN_PROBE.search(impulse or ""))
def is_completion_ask(impulse: str) -> bool:
return bool(COMPLETION_ASK.search(impulse or ""))
def last_user_is_narrative(assembled: dict | None) -> bool:
return len(last_user_text(assembled).split()) >= 15
def is_next_beat_question(impulse: str) -> bool:
return bool(NEXT_BEAT_Q.search(impulse or ""))
def is_recap_then_ask(impulse: str, assembled: dict | None = None) -> bool:
text = (impulse or "").strip()
if "?" not in text or not last_user_is_narrative(assembled):
return False
statement = text.split("?", 1)[0].strip()
if len(content_words(statement)) < 6:
return False
return is_echo(statement, last_user_text(assembled))
def is_pure_recap(impulse: str, assembled: dict | None = None) -> bool:
text = (impulse or "").strip()
if not text or "?" in text or not last_user_is_narrative(assembled):
return False
last = last_user_text(assembled)
return is_echo(text, last) or is_verbless_echo(text, last)
def local_hold(last_user: str) -> str:
if is_closing_turn(last_user):
return "Dann ist das der Schluss."
if len((last_user or "").split()) >= 15:
return "Erzähl bitte weiter."
return "Ich bin gespannt, wie es weitergeht."
def repair_note(impulse: str, assembled: dict | None) -> str:
if is_reopen_after_close(impulse, assembled):
return (
"Korrektur: Der Tag oder die Szene ist geschlossen. "
"Halte den Schluss. Keine Frage, keinen Tagesbogen."
)
if is_unearned_stance(impulse):
return (
"Korrektur: Keine erfundene Empfindung und keinen Vergleich mit ungenannten anderen Tagen. "
"Bleib bei dem, was [[SELF]] selbst gesagt hat."
)
if is_machine_tell(impulse):
return (
"Die gesprochene Zeile klingt wie ein Mensch im Gespräch, "
"nicht wie ein System oder eine Zusammenfassung."
)
if is_next_beat_question(impulse) or is_recap_then_ask(impulse, assembled) or is_pure_recap(impulse, assembled):
return (
"Korrektur: Nicht nacherzählen. Ein kurzer Impuls oder Denkanstoß am letzten Faden. "
"Nacherzählen nur, um einen Widerspruch oder Logikbruch zu klären."
)
return (
"Korrektur: Der vorige Impuls hat den nächsten Vollzug gesetzt "
"oder danach gefragt. Bleib Vertrauter. Kein Also-seid-ihr-dann. "
"Erzähle den Tag nicht weiter."
)
def needs_repair(impulse: str, assembled: dict | None = None) -> bool:
return (
is_plot_continuation(impulse)
or is_unearned_completion(impulse)
or is_unearned_stance(impulse)
or is_next_beat_question(impulse)
or is_recap_then_ask(impulse, assembled)
or is_pure_recap(impulse, assembled)
or is_machine_tell(impulse)
or is_reopen_after_close(impulse, assembled)
)
def visible_for_role(payload: dict, role: str | None) -> dict:
if role == "admin":
return payload
cleaned = dict(payload)
cleaned.pop("trace", None)
cleaned.pop("decision", None)
opening = cleaned.get("opening")
if isinstance(opening, dict):
opening = dict(opening)
opening.pop("trace", None)
opening.pop("decision", None)
opening.pop("opening_context", None)
cleaned["opening"] = opening
return cleaned
def _finish_turn(
profile_id: str,
conversation_id: str,
user: dict,
assembled: dict,
prompt: dict,
*,
precomputed_mappings: list[dict] | None = None,
) -> dict:
calls = 0
result = None
impulse = ""
decision: dict = {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
call_traces: list[dict] = []
working = dict(assembled)
try:
while calls < 2:
result = execute_prompt(
prompt,
profile_id,
purpose="dialogue_turn",
data_class="B",
context=working,
precomputed_mappings=precomputed_mappings if calls == 0 else None,
)
calls += 1
if result.get("trace"):
call_traces.append(result.get("trace"))
content = (result.get("content") or "").strip()
if not content:
raise EngineError("empty_provider_response", "Der Provider lieferte keine Antwort.")
impulse, decision = parse_turn_payload(content)
if not needs_repair(impulse, working):
break
decision = {**decision, "guard": "impulse_rejected"}
working = dict(working)
working["dialogue_context"] = (
(working.get("dialogue_context") or "") + "\n\n" + repair_note(impulse, working)
)
if needs_repair(impulse, working):
parsed_ok = bool(decision.get("parsed"))
impulse = local_hold(last_user_text(working))
decision = {
"operation": "fortfuehren",
"label": OPERATIONS["fortfuehren"],
"parsed": parsed_ok,
"guard": "local_fallback",
}
except EngineError as exc:
if result and result.get("trace") and result.get("trace") not in call_traces:
call_traces.append(result.get("trace"))
if exc.code != "response_validation_failed" and exc.code not in DETECT_DIALOGUE_FALLBACK_CODES:
persist_engine_error(
profile_id,
purpose="dialogue_turn",
exc=exc,
subject_type="conversation",
subject_id=conversation_id,
conversation_id=conversation_id,
extra={
"calls": calls,
"call_traces": call_traces,
"user": {"id": user.get("id"), "body": user.get("body")},
},
)
raise
impulse = local_hold(last_user_text(working))
decision = {
"operation": "fortfuehren",
"label": OPERATIONS["fortfuehren"],
"parsed": False,
"guard": "identity_leak_blocked" if exc.code == "response_validation_failed" else "detect_blocked",
}
assistant = append_message(profile_id, conversation_id, impulse, role="assistant")
user_bodies = [
item.get("body") or ""
for item in list_messages(profile_id, conversation_id)
if item.get("role") == "user"
]
update_conversation_signals(
profile_id,
conversation_id,
infer_signals(user_bodies, decision.get("operation")),
)
remember_dialogue_style(profile_id)
consider_dialogue(profile_id, user.get("body") or "")
trace = result.get("trace") if result else None
persist_step(
profile_id,
purpose="dialogue_turn",
status="ok",
subject_type="conversation",
subject_id=conversation_id,
conversation_id=conversation_id,
message_id=assistant.get("id"),
decision=decision,
trace=trace,
extra={
"calls": calls,
"call_traces": call_traces,
"user": {"id": user.get("id"), "body": user.get("body")},
"assistant": {"id": assistant.get("id"), "body": assistant.get("body")},
},
)
return {
"conversation": get_conversation(profile_id, conversation_id),
"user": user,
"assistant": assistant,
"calls": calls,
"messages": list_messages(profile_id, conversation_id),
"decision": decision,
"trace": trace,
}
def _learning_pause(profile_id: str, conversation_id: str, user: dict, assembled: dict, prompt: dict) -> dict | None:
preview = preview_prompt(prompt, assembled)
rendered = preview.get("rendered") or ""
try:
outcome = detect_personal_egress(profile_id, rendered)
except DetectError:
return None
user_body = user.get("body") or ""
mappings = list(outcome.mappings or [])
candidates = build_candidates(profile_id, mappings, user_body)
candidates, mappings = supplement_kinship_candidates(
profile_id, candidates, mappings, user_body, rendered
)
candidates, mappings = auto_resolve_ambiguous(profile_id, candidates, mappings)
if not candidates:
confirm_known_identity_spans(profile_id, mappings, user_body)
mappings = suppress_known_non_identity(profile_id, mappings, user_body)
return _finish_turn(
profile_id,
conversation_id,
user,
assembled,
prompt,
precomputed_mappings=mappings,
)
review_id = save_pending(
profile_id,
conversation_id,
user.get("id") or "",
{
"mappings": mappings,
"candidates": candidates,
"user_body": user_body,
},
)
return {
"conversation": get_conversation(profile_id, conversation_id),
"user": user,
"assistant": None,
"calls": 0,
"messages": list_messages(profile_id, conversation_id),
"pending_mask_review": {
"id": review_id,
"candidates": candidates,
},
}
def run_turn(profile_id: str, conversation_id: str, body: str, message_id: str | None = None) -> dict:
conversation = get_conversation(profile_id, conversation_id)
user = append_message(profile_id, conversation_id, body, role="user", message_id=message_id)
context = build_internal_context(
profile_id,
conversation_id=conversation_id,
space_id=conversation.get("space_id"),
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
if get_detect_operating_mode() == MODE_LEARNING:
paused = _learning_pause(profile_id, conversation_id, user, assembled, prompt)
if paused is not None:
return paused
return _finish_turn(profile_id, conversation_id, user, assembled, prompt)
def continue_turn(profile_id: str, conversation_id: str, review_id: str, decisions: list[dict]) -> dict:
pending = load_pending(profile_id, review_id)
if not pending or pending.get("conversation_id") != conversation_id:
raise EngineError("mask_review_missing", "Die Maskierungsprüfung ist nicht mehr gültig.", 404)
mappings = apply_review_decisions(profile_id, pending, decisions)
drop_pending(profile_id, review_id)
conversation = get_conversation(profile_id, conversation_id)
context = build_internal_context(
profile_id,
conversation_id=conversation_id,
space_id=conversation.get("space_id"),
journal_day_id=conversation.get("journal_day_id"),
purpose="dialogue_turn",
)
assembled = assemble_text(context)
prompt = load_active_prompt("mvp.dialogue_turn")
user = {"id": pending.get("user_message_id"), "body": pending.get("user_body") or ""}
messages = list_messages(profile_id, conversation_id)
for item in messages:
if item.get("id") == pending.get("user_message_id"):
user = item
break
return _finish_turn(
profile_id,
conversation_id,
user,
assembled,
prompt,
precomputed_mappings=mappings,
)