Kansho/backend/journal_opening.py

315 lines
11 KiB
Python
Raw Normal View History

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