314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""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 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":
|
||
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",
|
||
},
|
||
"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
|