Kansho/backend/journal_opening.py

315 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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