382 lines
13 KiB
Python
382 lines
13 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 dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals
|
|
from engine import EngineError, execute_prompt, load_active_prompt
|
|
from entity_detect import DETECT_DIALOGUE_FALLBACK_CODES
|
|
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 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")
|
|
calls = 0
|
|
result = None
|
|
impulse = ""
|
|
decision: dict = {"operation": "unparsed", "label": "nicht erkannt", "parsed": False}
|
|
try:
|
|
while calls < 2:
|
|
result = execute_prompt(
|
|
prompt,
|
|
profile_id,
|
|
purpose="dialogue_turn",
|
|
data_class="B",
|
|
context=assembled,
|
|
)
|
|
calls += 1
|
|
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, assembled):
|
|
break
|
|
decision = {**decision, "guard": "impulse_rejected"}
|
|
assembled = dict(assembled)
|
|
assembled["dialogue_context"] = (
|
|
(assembled.get("dialogue_context") or "") + "\n\n" + repair_note(impulse, assembled)
|
|
)
|
|
if needs_repair(impulse, assembled):
|
|
parsed_ok = bool(decision.get("parsed"))
|
|
impulse = local_hold(last_user_text(assembled))
|
|
decision = {
|
|
"operation": "fortfuehren",
|
|
"label": OPERATIONS["fortfuehren"],
|
|
"parsed": parsed_ok,
|
|
"guard": "local_fallback",
|
|
}
|
|
except EngineError as exc:
|
|
if exc.code != "response_validation_failed" and exc.code not in DETECT_DIALOGUE_FALLBACK_CODES:
|
|
raise
|
|
impulse = local_hold(last_user_text(assembled))
|
|
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, 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": result.get("trace") if result else None,
|
|
}
|