2026-08-26 10:42:20 +02:00
|
|
|
|
"""Journal-specific first impulse. Not a generic continuation or intent engine.
|
|
|
|
|
|
|
|
|
|
|
|
Uses the existing Privacy Gateway, dialogue prompt, guards and request-scoped
|
|
|
|
|
|
trace. Attested plans and open day points come only from user-role sources.
|
|
|
|
|
|
Assistant text is conversation context, never a user fact. Recency is not a pattern.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
from context_builder import assemble_text, build_internal_context
|
|
|
|
|
|
from dialogue_store import StoreError, append_message, get_conversation, list_conversations_for_day, list_messages
|
|
|
|
|
|
from dialogue_turn import OPERATIONS, needs_repair, parse_turn_payload, repair_note
|
|
|
|
|
|
from engine import EngineError, execute_prompt, load_active_prompt
|
2026-08-27 10:03:05 +02:00
|
|
|
|
from entity_detect import DETECT_DIALOGUE_FALLBACK_CODES
|
2026-08-26 10:42:20 +02:00
|
|
|
|
from journal_store import get_day
|
|
|
|
|
|
from retrieval import retrieve
|
|
|
|
|
|
|
|
|
|
|
|
NEUTRAL_OPENING = "Wenn du magst, fang einfach an – ich höre zu."
|
|
|
|
|
|
OPENING_POLICY = (
|
|
|
|
|
|
"Dies ist der erste Impuls dieses Gesprächs. Es gibt hier noch keine Nutzerzeile. "
|
|
|
|
|
|
"Nur belegte Vorhaben aus user-Quellen oder offene user-Punkte desselben Journal Day aufgreifen. "
|
|
|
|
|
|
"Ein Recency-Treffer ist kein bewiesenes wiederkehrendes Muster. "
|
|
|
|
|
|
"Nicht erwähnt ist nicht geschehen. Ein Plan ist kein Vollzug. "
|
|
|
|
|
|
"Assistententext ist Gesprächskontext, kein Nutzerfakt. "
|
|
|
|
|
|
"Keine Behauptung, ein Vorhaben sei ausgeführt worden."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
FUTURE_INTENT = re.compile(
|
|
|
|
|
|
r"(?:"
|
|
|
|
|
|
r"\b("
|
|
|
|
|
|
r"morgen|übermorgen|"
|
|
|
|
|
|
r"wollen(?: wir| sie)?|will(?:st)?|werde|werden wir|"
|
|
|
|
|
|
r"vorhaben|geplant|"
|
|
|
|
|
|
r"steht(?: heute| morgen)? an|"
|
|
|
|
|
|
r"nächste[nrs]?\s+(?:woche|monat|tag|tage)"
|
|
|
|
|
|
r")\b"
|
|
|
|
|
|
r"|(?:habe|haben|hat|habt)\s+vor\b"
|
|
|
|
|
|
r")",
|
|
|
|
|
|
re.I,
|
|
|
|
|
|
)
|
|
|
|
|
|
UNEARNED_PATTERN = re.compile(
|
|
|
|
|
|
r"\b(die letzten tage|häufig|immer|jedes mal|wiederkehr|typischerweise|muster)\b",
|
|
|
|
|
|
re.I,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_attested_plan(text: str) -> bool:
|
|
|
|
|
|
"""User wording that marks a plan or intention. Not past completion, not overlap."""
|
|
|
|
|
|
body = (text or "").strip()
|
|
|
|
|
|
if not body:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return bool(FUTURE_INTENT.search(body))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def attested_plans_from_user_texts(texts: list[str]) -> list[str]:
|
|
|
|
|
|
plans: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for text in texts:
|
|
|
|
|
|
body = (text or "").strip()
|
|
|
|
|
|
if not body or body in seen or not is_attested_plan(body):
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(body)
|
|
|
|
|
|
plans.append(body)
|
|
|
|
|
|
return plans
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user_bodies(messages: list[dict]) -> list[str]:
|
|
|
|
|
|
return [
|
|
|
|
|
|
(item.get("body") or "").strip()
|
|
|
|
|
|
for item in messages
|
|
|
|
|
|
if item.get("role") == "user" and (item.get("body") or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assistant_bodies(messages: list[dict]) -> list[str]:
|
|
|
|
|
|
return [
|
|
|
|
|
|
(item.get("body") or "").strip()
|
|
|
|
|
|
for item in messages
|
|
|
|
|
|
if item.get("role") == "assistant" and (item.get("body") or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_opening_context(profile_id: str, conversation_id: str) -> dict:
|
|
|
|
|
|
conversation = get_conversation(profile_id, conversation_id)
|
|
|
|
|
|
space_id = conversation.get("space_id")
|
|
|
|
|
|
journal_day_id = conversation.get("journal_day_id")
|
|
|
|
|
|
if not space_id or not journal_day_id:
|
|
|
|
|
|
raise StoreError("not_journal_conversation", "Erster Impuls nur für einen Journal-Dialog.")
|
|
|
|
|
|
get_day(profile_id, journal_day_id)
|
|
|
|
|
|
|
|
|
|
|
|
day_conversations = list_conversations_for_day(profile_id, journal_day_id)
|
|
|
|
|
|
open_points: list[str] = []
|
|
|
|
|
|
day_assistant: list[str] = []
|
|
|
|
|
|
for item in day_conversations:
|
|
|
|
|
|
messages = list_messages(profile_id, item["id"])
|
|
|
|
|
|
if item["id"] == conversation_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
open_points.extend(_user_bodies(messages))
|
|
|
|
|
|
day_assistant.extend(_assistant_bodies(messages))
|
|
|
|
|
|
|
|
|
|
|
|
prior_entries = retrieve(
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
{"kind": "space_entries", "space_id": space_id, "exclude_day_id": journal_day_id},
|
|
|
|
|
|
)
|
|
|
|
|
|
recent_sources = retrieve(
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "space_recent_sources",
|
|
|
|
|
|
"space_id": space_id,
|
|
|
|
|
|
"exclude_conversation_id": conversation_id,
|
|
|
|
|
|
"exclude_journal_day_id": journal_day_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
recency_user: list[str] = []
|
|
|
|
|
|
for entry in prior_entries:
|
|
|
|
|
|
excerpt = (entry.get("excerpt") or "").strip()
|
|
|
|
|
|
if excerpt:
|
|
|
|
|
|
recency_user.append(excerpt)
|
|
|
|
|
|
for source in recent_sources:
|
|
|
|
|
|
excerpt = (source.get("excerpt") or "").strip()
|
|
|
|
|
|
if excerpt:
|
|
|
|
|
|
recency_user.append(excerpt)
|
|
|
|
|
|
|
|
|
|
|
|
user_pool = [*open_points, *recency_user]
|
|
|
|
|
|
plans = attested_plans_from_user_texts(user_pool)
|
|
|
|
|
|
has_relevant = bool(plans or open_points)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"space_id": space_id,
|
|
|
|
|
|
"journal_day_id": journal_day_id,
|
|
|
|
|
|
"conversation_id": conversation_id,
|
|
|
|
|
|
"user_texts": user_pool,
|
|
|
|
|
|
"assistant_texts": day_assistant,
|
|
|
|
|
|
"attested_plans": plans,
|
|
|
|
|
|
"open_day_points": open_points,
|
|
|
|
|
|
"recency_excerpts": recency_user,
|
|
|
|
|
|
"has_relevant_context": has_relevant,
|
|
|
|
|
|
"recency_is_not_pattern": True,
|
|
|
|
|
|
"assistant_is_not_user_fact": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_opening_hint(facts: dict) -> str:
|
|
|
|
|
|
lines = [OPENING_POLICY]
|
|
|
|
|
|
plans = facts.get("attested_plans") or []
|
|
|
|
|
|
points = facts.get("open_day_points") or []
|
|
|
|
|
|
if plans:
|
|
|
|
|
|
lines.append("Belegte Vorhaben (nur user-Quellen):")
|
|
|
|
|
|
lines.extend(f"- {item}" for item in plans)
|
|
|
|
|
|
else:
|
|
|
|
|
|
lines.append("Belegte Vorhaben: keine.")
|
|
|
|
|
|
if points:
|
|
|
|
|
|
lines.append("Offene Punkte dieses Journal Day (nur user):")
|
|
|
|
|
|
lines.extend(f"- {item}" for item in points)
|
|
|
|
|
|
else:
|
|
|
|
|
|
lines.append("Offene Punkte dieses Journal Day: keine.")
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
"Recency-Ausschnitte dürfen den Impuls nicht als Muster oder als Vollzug begründen."
|
|
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _store_opening(profile_id: str, conversation_id: str, impulse: str, extra: dict) -> dict:
|
|
|
|
|
|
assistant = append_message(profile_id, conversation_id, impulse, role="assistant")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"conversation": get_conversation(profile_id, conversation_id),
|
|
|
|
|
|
"assistant": assistant,
|
|
|
|
|
|
"messages": list_messages(profile_id, conversation_id),
|
|
|
|
|
|
**extra,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_journal_opening(profile_id: str, conversation_id: str) -> dict:
|
|
|
|
|
|
"""Create the first Kanshō line. Invalid model output does not write a message."""
|
|
|
|
|
|
existing = list_messages(profile_id, conversation_id)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"opened": False,
|
|
|
|
|
|
"reason": "already_started",
|
|
|
|
|
|
"conversation": get_conversation(profile_id, conversation_id),
|
|
|
|
|
|
"messages": existing,
|
|
|
|
|
|
}
|
|
|
|
|
|
facts = collect_opening_context(profile_id, conversation_id)
|
|
|
|
|
|
if not facts["has_relevant_context"]:
|
|
|
|
|
|
return _store_opening(
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
conversation_id,
|
|
|
|
|
|
NEUTRAL_OPENING,
|
|
|
|
|
|
{
|
|
|
|
|
|
"opened": True,
|
|
|
|
|
|
"kind": "local_neutral",
|
|
|
|
|
|
"calls": 0,
|
|
|
|
|
|
"opening_context": facts,
|
|
|
|
|
|
"decision": {
|
|
|
|
|
|
"operation": "fortfuehren",
|
|
|
|
|
|
"label": OPERATIONS["fortfuehren"],
|
|
|
|
|
|
"parsed": True,
|
|
|
|
|
|
"guard": "local_neutral_opening",
|
|
|
|
|
|
},
|
|
|
|
|
|
"trace": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
conversation = get_conversation(profile_id, conversation_id)
|
|
|
|
|
|
day_ids = [item["id"] for item in list_conversations_for_day(profile_id, conversation["journal_day_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",
|
|
|
|
|
|
conversation_ids=day_ids,
|
|
|
|
|
|
)
|
|
|
|
|
|
assembled = assemble_text(context)
|
|
|
|
|
|
assembled = dict(assembled)
|
|
|
|
|
|
assembled["opening_hint"] = format_opening_hint(facts)
|
|
|
|
|
|
assembled["register_hint"] = (
|
|
|
|
|
|
"Erster Impuls. Noch keine Nutzerzeile in diesem Gespräch. "
|
|
|
|
|
|
"Nur belegte Vorhaben oder offene user-Punkte. Kein Muster aus Recency."
|
|
|
|
|
|
)
|
|
|
|
|
|
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 UNEARNED_PATTERN.search(impulse or ""):
|
|
|
|
|
|
decision = {**decision, "guard": "pattern_rejected"}
|
|
|
|
|
|
assembled = dict(assembled)
|
|
|
|
|
|
assembled["dialogue_context"] = (
|
|
|
|
|
|
(assembled.get("dialogue_context") or "")
|
|
|
|
|
|
+ "\n\nKorrektur: Kein Recency-Treffer als wiederkehrendes Muster."
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
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) or UNEARNED_PATTERN.search(impulse or "") or not impulse:
|
|
|
|
|
|
impulse = NEUTRAL_OPENING
|
|
|
|
|
|
decision = {
|
|
|
|
|
|
"operation": "fortfuehren",
|
|
|
|
|
|
"label": OPERATIONS["fortfuehren"],
|
|
|
|
|
|
"parsed": bool(decision.get("parsed")),
|
|
|
|
|
|
"guard": "local_fallback",
|
|
|
|
|
|
}
|
|
|
|
|
|
except EngineError as exc:
|
2026-08-27 10:03:05 +02:00
|
|
|
|
if exc.code != "response_validation_failed" and exc.code not in DETECT_DIALOGUE_FALLBACK_CODES:
|
2026-08-26 10:42:20 +02:00
|
|
|
|
raise
|
|
|
|
|
|
return _store_opening(
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
conversation_id,
|
|
|
|
|
|
NEUTRAL_OPENING,
|
|
|
|
|
|
{
|
|
|
|
|
|
"opened": True,
|
|
|
|
|
|
"kind": "local_neutral",
|
|
|
|
|
|
"calls": calls,
|
|
|
|
|
|
"opening_context": facts,
|
|
|
|
|
|
"decision": {
|
|
|
|
|
|
"operation": "fortfuehren",
|
|
|
|
|
|
"label": OPERATIONS["fortfuehren"],
|
|
|
|
|
|
"parsed": False,
|
2026-08-27 10:03:05 +02:00
|
|
|
|
"guard": "identity_leak_blocked" if exc.code == "response_validation_failed" else "detect_blocked",
|
2026-08-26 10:42:20 +02:00
|
|
|
|
},
|
|
|
|
|
|
"trace": result.get("trace") if result else None,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return _store_opening(
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
conversation_id,
|
|
|
|
|
|
impulse,
|
|
|
|
|
|
{
|
|
|
|
|
|
"opened": True,
|
|
|
|
|
|
"kind": "model",
|
|
|
|
|
|
"calls": calls,
|
|
|
|
|
|
"opening_context": facts,
|
|
|
|
|
|
"decision": decision,
|
|
|
|
|
|
"trace": result.get("trace") if result else None,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def maybe_open_journal_conversation(profile_id: str, conversation: dict) -> dict:
|
|
|
|
|
|
payload = dict(conversation)
|
|
|
|
|
|
try:
|
|
|
|
|
|
opening = start_journal_opening(profile_id, conversation["id"])
|
|
|
|
|
|
except EngineError as exc:
|
|
|
|
|
|
payload["opening"] = {
|
|
|
|
|
|
"opened": False,
|
|
|
|
|
|
"reason": exc.code,
|
|
|
|
|
|
"message": exc.message,
|
|
|
|
|
|
"kind": "failed_closed",
|
|
|
|
|
|
}
|
|
|
|
|
|
payload["messages"] = list_messages(profile_id, conversation["id"])
|
|
|
|
|
|
return payload
|
|
|
|
|
|
payload["opening"] = opening
|
|
|
|
|
|
payload["messages"] = opening.get("messages") or []
|
|
|
|
|
|
return payload
|