Kansho/backend/context_builder.py

266 lines
10 KiB
Python

"""Internal context for later Gateway use. Never sends originals to a provider.
Selection goes through retrieval specs. Recency/SQL limits live in retrieval.py.
"""
from __future__ import annotations
import re
from data_layer import read
from dialogue_store import get_conversation
from retrieval import retrieve
from journal_body import plain_text
PURPOSES = {"dialogue_turn", "journal_generate", "journal_reconstruct"}
CLOSURE = re.compile(
r"\bauf den heimweg\b|"
r"\bheimweg\b|"
r"\bauf den rückweg\b|"
r"\bdamit (?:war|ist) der tag\b|"
r"\bdas war (?:der|so ein|unser) tag\b|"
r"\bende des tages\b|"
r"\bgenug für heute\b|"
r"\bso endete\b|"
r"müde.{0,80}(?:heimweg|rückweg)|"
r"(?:eindr[uü]cke|erschöpft).{0,80}(?:heimweg|rückweg)|"
r"machten uns.{0,40}auf den (?:heimweg|rückweg)|"
r"\bbald ins bett\b|"
r"\blegten uns.{0,40}ins bett\b|"
r"\bgleich ins bett\b",
re.I,
)
def is_closing_turn(text: str) -> bool:
return bool(CLOSURE.search((text or "").strip()))
def infer_register(user_bodies: list[str]) -> str:
last = (user_bodies[-1] if user_bodies else "").strip()
if not last:
return "Noch kein Erzählstand. Warte auf den ersten Satz, setze keine Szene."
if is_closing_turn(last):
return (
"Die Szene oder der Abschnitt ist zu Ende. Halte den Schluss. "
"Keine Frage, keinen Bogen, keine neue Station."
)
words = last.split()
emotion = re.search(r"aufgeregt|traurig|berührt|ängst|wütend|freude|ruhig|unsicher", last, re.I)
plan = re.search(r"geplant|wollen|werde|sollten|vorhaben", last, re.I)
dense = len(words) >= 40 or last.count(".") + last.count("!") >= 3
if dense:
return (
"[[SELF]] erzählt gerade ausführlich. Am letzten erzählten Punkt bleiben. "
"Nicht nacherzählen. Nacherzählen nur bei Widerspruch oder Logikbruch. "
"Keine erfundene Empfindung, keine erfundene nächste Szene."
)
if emotion:
return (
"[[SELF]] hat Erleben geöffnet. Bei Bedeutung oder Wirkung bleiben, "
"die schon benannte Empfindung nicht nachplappern."
)
if plan:
return "Ein Vorhaben ist genannt. Als Plan behandeln, nicht als vollzogen."
return "Kurz und natürlich antworten, am offenen Punkt, wie jemand der zugehört hat."
def build_internal_context(
profile_id: str,
conversation_id: str | None = None,
space_id: str | None = None,
journal_day_id: str | None = None,
purpose: str = "dialogue_turn",
include_existing: bool = False,
conversation_ids: list[str] | None = None,
existing_text: str = "",
reconstruction: str = "",
day_spec: dict | None = None,
style_examples: str = "",
editorial_mode: str = "",
editorial_instructions: str = "",
) -> dict:
purpose = purpose if purpose in PURPOSES else "dialogue_turn"
if conversation_id and not space_id:
conv = get_conversation(profile_id, conversation_id)
space_id = conv.get("space_id")
journal_day_id = journal_day_id or conv.get("journal_day_id")
items: list[dict] = []
space_title = ""
if space_id:
from dialogue_store import get_space
try:
space_title = get_space(profile_id, space_id).get("title") or ""
except Exception:
space_title = ""
items.append({"type": "space", "space_id": space_id, "title": space_title})
selected_ids = list(conversation_ids or [])
if conversation_id and conversation_id not in selected_ids and purpose == "dialogue_turn":
selected_ids.append(conversation_id)
day_messages = []
if journal_day_id and purpose != "journal_generate":
spec = {
"kind": "day_messages",
"journal_day_id": journal_day_id,
"conversation_ids": selected_ids or None,
}
if day_spec:
spec.update({key: value for key, value in day_spec.items() if key != "kind"})
day_messages = retrieve(profile_id, spec)
items.append({"type": "day_messages", "messages": day_messages})
elif conversation_id and purpose == "dialogue_turn":
day_messages = read("conversation_messages", profile_id=profile_id, context={"conversation_id": conversation_id})
items.append({"type": "day_messages", "messages": day_messages})
if purpose == "dialogue_turn" and space_id:
prior = retrieve(
profile_id,
{
"kind": "space_entries",
"space_id": space_id,
"exclude_day_id": journal_day_id,
},
)
items.append(
{
"type": "prior_entries",
"entries": [
{
"entry_id": item["entry_id"],
"calendar_date": item.get("calendar_date"),
"excerpt": item.get("excerpt") or plain_text(item.get("body") or ""),
}
for item in prior
],
}
)
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,
},
)
items.append({"type": "space_recent_sources", "sources": recent_sources})
if purpose == "journal_generate":
from writing_profile_store import compile_task_brief
brief = compile_task_brief(profile_id, "journal_generate")
items.append({"type": "writing_profile", "compiled_brief": brief})
items.append({"type": "style_examples", "body": style_examples or ""})
items.append({"type": "editorial_mode", "text": editorial_mode or ""})
items.append({"type": "editorial_instructions", "text": editorial_instructions or ""})
if reconstruction:
items.append({"type": "reconstruction", "body": reconstruction})
if include_existing and existing_text:
items.append({"type": "existing_text", "body": plain_text(existing_text)})
elif purpose == "dialogue_turn":
items.append({"type": "interaction_hint", "text": ""})
return {
"profile_id": profile_id,
"conversation_id": conversation_id,
"space_id": space_id,
"space_title": space_title,
"journal_day_id": journal_day_id,
"purpose": purpose,
"items": items,
"messages": day_messages,
"egress": False,
"note": "Volltext bleibt lokal. Minimierung und Egress nur über das Privacy Gateway.",
}
def assemble_text(context: dict) -> dict[str, str]:
dialogue_parts: list[str] = []
prior_parts: list[str] = []
source_parts: list[str] = []
writing_profile = ""
interaction_hint = ""
existing_text = ""
reconstruction = ""
style_examples = ""
editorial_mode = ""
editorial_instructions = ""
user_bodies: list[str] = []
purpose = context.get("purpose") or ""
for item in context.get("items") or []:
kind = item.get("type")
if kind == "day_messages":
day_messages = item.get("messages") or []
if purpose == "journal_reconstruct":
from journal_reconstruct import assign_source_ids
day_messages = assign_source_ids(day_messages)
for message in day_messages:
role = message.get("role") or "user"
body = message.get("body") or ""
source_id = message.get("source_id")
if purpose == "journal_reconstruct" and source_id:
dialogue_parts.append(f"[{source_id}] {role}: {body}")
else:
dialogue_parts.append(f"{role}: {body}")
if role == "user":
user_bodies.append(body)
elif kind == "prior_entries":
for entry in item.get("entries") or []:
date = entry.get("calendar_date") or ""
excerpt = entry.get("excerpt") or ""
if excerpt:
prior_parts.append(f"{date}: {excerpt}")
elif kind == "space_recent_sources":
for source in item.get("sources") or []:
excerpt = source.get("excerpt") or ""
if excerpt:
source_parts.append(excerpt)
elif kind == "writing_profile":
writing_profile = item.get("compiled_brief") or ""
elif kind == "interaction_hint":
interaction_hint = item.get("text") or ""
elif kind == "existing_text":
existing_text = item.get("body") or ""
elif kind == "reconstruction":
reconstruction = item.get("body") or ""
elif kind == "style_examples":
style_examples = item.get("body") or ""
elif kind == "editorial_mode":
editorial_mode = item.get("text") or ""
elif kind == "editorial_instructions":
editorial_instructions = item.get("text") or ""
elif kind == "opening":
pass
opening_hint = ""
if purpose == "dialogue_turn":
from conversation_signals import infer_signals
from interaction_profile_store import assemble_interaction_hint
interaction_hint = assemble_interaction_hint(
context.get("profile_id"),
infer_signals(user_bodies),
)
if prior_parts:
dialogue_parts.append("Frühere Einträge im Space (nur Hinweise, keine Gewissheit):")
dialogue_parts.extend(prior_parts)
if source_parts and purpose == "dialogue_turn":
dialogue_parts.append("Frühere Originalgespräche im Space (Ausschnitt, keine Gewissheit):")
dialogue_parts.extend(source_parts)
return {
"dialogue_context": "\n".join(dialogue_parts).strip(),
"writing_profile": writing_profile,
"style_examples": style_examples,
"editorial_mode": editorial_mode,
"editorial_instructions": editorial_instructions,
"interaction_hint": interaction_hint,
"existing_text": existing_text,
"reconstruction": reconstruction,
"space_title": context.get("space_title") or "",
"register_hint": infer_register(user_bodies),
"opening_hint": opening_hint,
}