"""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, )