Kansho/backend/interaction_profile_store.py
2026-08-25 13:57:23 +02:00

424 lines
15 KiB
Python

"""Interaction profile: stable preferences, never learned from silence.
Strong sources only: explicit instruction, explicit feedback, manual setting,
accepted suggestion. Observations may be stored as suggestions, never auto-applied
from a dialogue turn.
"""
from __future__ import annotations
import uuid
from db import get_db, row_to_dict
from dialogue_store import StoreError
from interaction_defaults import DEFAULT_GOVERNANCE, DEFAULTS, GOVERNANCE, PREF_KEYS, SCOPES
from profile_documents import (
FORMAT_VERSION,
KIND_INTERACTION,
ORIGIN_INTERACTION,
exported_at,
interaction_payload,
)
__all__ = [
"GOVERNANCE",
"SCOPES",
"get_profile",
"set_governance",
"upsert_preference",
"accept_suggestion",
"reject_suggestion",
"propose_observation",
"assemble_interaction_hint",
"export_document",
"restore_document",
]
def ensure_profile(profile_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute("SELECT * FROM interaction_profiles WHERE profile_id = ?", (profile_id,)).fetchone()
)
if row:
return row
conn.execute(
"""
INSERT INTO interaction_profiles (profile_id, governance)
VALUES (?, ?)
""",
(profile_id, DEFAULT_GOVERNANCE),
)
return row_to_dict(
conn.execute("SELECT * FROM interaction_profiles WHERE profile_id = ?", (profile_id,)).fetchone()
)
def get_profile(profile_id: str) -> dict:
row = ensure_profile(profile_id)
with get_db() as conn:
prefs = [
row_to_dict(item)
for item in conn.execute(
"""
SELECT * FROM interaction_preferences
WHERE profile_id = ?
ORDER BY CASE scope WHEN 'global' THEN 0 ELSE 1 END, pref_key
""",
(profile_id,),
).fetchall()
]
suggestions = [
row_to_dict(item)
for item in conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE profile_id = ? AND status = 'pending'
ORDER BY created DESC
""",
(profile_id,),
).fetchall()
]
for item in prefs:
item["locked"] = bool(item.get("locked"))
item["title"] = _title(item.get("scope") or "global", item.get("pref_key") or "")
defaults = [
{
**item,
"source": "product_default",
"overridden": any(
pref.get("scope") == item["scope"] and pref.get("pref_key") == item["pref_key"]
for pref in prefs
),
}
for item in DEFAULTS
]
return {
"profile_id": profile_id,
"governance": (row or {}).get("governance") or DEFAULT_GOVERNANCE,
"updated": (row or {}).get("updated"),
"preferences": prefs,
"defaults": defaults,
"suggestions": suggestions,
"note": (
"Persönliche Interaction-Präferenzen entstehen nur durch ausdrückliche Angabe, "
"Feedback, manuelle Einstellung oder angenommene Vorschläge. "
"Ein Dialog ohne Widerspruch ist kein Lernsignal."
),
}
def set_governance(profile_id: str, governance: str) -> dict:
mode = (governance or "").strip()
if mode not in GOVERNANCE:
raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein")
ensure_profile(profile_id)
with get_db() as conn:
conn.execute(
"UPDATE interaction_profiles SET governance = ?, updated = datetime('now') WHERE profile_id = ?",
(mode, profile_id),
)
return get_profile(profile_id)
def upsert_preference(
profile_id: str,
*,
scope: str,
pref_key: str,
value: str,
origin: str = "manual",
locked: bool | None = None,
evidence: str = "manuelle Einstellung",
) -> dict:
if scope not in SCOPES:
raise StoreError("unknown_scope", "Unbekannter Interaction-Scope.")
if pref_key not in PREF_KEYS:
raise StoreError("unknown_pref", "Unbekannte Interaction-Präferenz.")
if origin not in {"explicit", "manual", "accepted_suggestion"}:
raise StoreError("invalid_origin", "Nur explizite, manuelle oder angenommene Herkunft.")
ensure_profile(profile_id)
with get_db() as conn:
current = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_preferences
WHERE profile_id = ? AND scope = ? AND pref_key = ?
""",
(profile_id, scope, pref_key),
).fetchone()
)
next_locked = int(locked) if locked is not None else int((current or {}).get("locked") or 0)
if current:
conn.execute(
"""
UPDATE interaction_preferences
SET value = ?, evidence = ?, origin = ?, locked = ?, updated = datetime('now')
WHERE id = ?
""",
(value, evidence, origin, next_locked, current["id"]),
)
else:
conn.execute(
"""
INSERT INTO interaction_preferences
(id, profile_id, scope, pref_key, value, evidence, origin, locked)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(str(uuid.uuid4()), profile_id, scope, pref_key, value, evidence, origin, next_locked),
)
conn.execute(
"UPDATE interaction_profiles SET updated = datetime('now') WHERE profile_id = ?",
(profile_id,),
)
return get_profile(profile_id)
def propose_observation(
profile_id: str,
*,
scope: str,
pref_key: str,
proposed_value: str,
evidence: str,
) -> dict | None:
"""Candidate only. Never applied here. Not called from the dialogue turn."""
if scope not in SCOPES or pref_key not in PREF_KEYS:
return None
ensure_profile(profile_id)
row = ensure_profile(profile_id)
if (row.get("governance") or DEFAULT_GOVERNANCE) == "frozen":
return None
with get_db() as conn:
current = row_to_dict(
conn.execute(
"""
SELECT locked FROM interaction_preferences
WHERE profile_id = ? AND scope = ? AND pref_key = ?
""",
(profile_id, scope, pref_key),
).fetchone()
)
if current and int(current.get("locked") or 0):
return None
pending = row_to_dict(
conn.execute(
"""
SELECT id, proposed_value FROM interaction_suggestions
WHERE profile_id = ? AND scope = ? AND pref_key = ? AND status = 'pending'
ORDER BY created DESC LIMIT 1
""",
(profile_id, scope, pref_key),
).fetchone()
)
if pending and (pending.get("proposed_value") or "") == proposed_value:
return get_profile(profile_id)
if pending:
conn.execute(
"UPDATE interaction_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?",
(pending["id"],),
)
conn.execute(
"""
INSERT INTO interaction_suggestions
(id, profile_id, scope, pref_key, proposed_value, evidence, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
""",
(str(uuid.uuid4()), profile_id, scope, pref_key, proposed_value, evidence),
)
return get_profile(profile_id)
def accept_suggestion(profile_id: str, suggestion_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE id = ? AND profile_id = ? AND status = 'pending'
""",
(suggestion_id, profile_id),
).fetchone()
)
if not row:
raise StoreError("not_found", "Vorschlag nicht gefunden", 404)
upsert_preference(
profile_id,
scope=row.get("scope") or "global",
pref_key=row["pref_key"],
value=row.get("proposed_value") or "",
origin="accepted_suggestion",
evidence=row.get("evidence") or "angenommener Vorschlag",
)
with get_db() as conn:
conn.execute(
"UPDATE interaction_suggestions SET status = 'accepted', resolved = datetime('now') WHERE id = ?",
(suggestion_id,),
)
return get_profile(profile_id)
def reject_suggestion(profile_id: str, suggestion_id: str) -> dict:
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM interaction_suggestions
WHERE id = ? AND profile_id = ? AND status = 'pending'
""",
(suggestion_id, profile_id),
).fetchone()
)
if not row:
raise StoreError("not_found", "Vorschlag nicht gefunden", 404)
conn.execute(
"UPDATE interaction_suggestions SET status = 'rejected', resolved = datetime('now') WHERE id = ?",
(suggestion_id,),
)
return get_profile(profile_id)
def export_document(profile_id: str) -> dict:
profile = get_profile(profile_id)
return {
"kind": KIND_INTERACTION,
"format_version": FORMAT_VERSION,
"exported_at": exported_at(),
"governance": profile.get("governance") or DEFAULT_GOVERNANCE,
"preferences": [
{
"scope": item.get("scope") or "global",
"pref_key": item.get("pref_key"),
"value": item.get("value") or "",
"evidence": item.get("evidence") or "",
"origin": item.get("origin") or "manual",
"locked": bool(item.get("locked")),
}
for item in profile.get("preferences") or []
if item.get("pref_key")
],
}
def restore_document(profile_id: str, document: dict) -> dict:
payload = interaction_payload(document)
governance = (payload.get("governance") or DEFAULT_GOVERNANCE).strip()
if governance not in GOVERNANCE:
raise StoreError("invalid_governance", "governance muss learning, advising oder frozen sein")
prefs = payload.get("preferences")
if prefs is None:
raise StoreError("invalid_document", "Interaction-Profile-Dokument braucht preferences.")
if not isinstance(prefs, list):
raise StoreError("invalid_document", "preferences muss eine Liste sein.")
prepared: list[dict] = []
seen: set[tuple[str, str]] = set()
for item in prefs:
if not isinstance(item, dict):
raise StoreError("invalid_document", "Jede Präferenz muss ein Objekt sein.")
scope = (item.get("scope") or "global").strip()
pref_key = (item.get("pref_key") or "").strip()
if scope not in SCOPES:
raise StoreError("unknown_scope", f"Unbekannter Interaction-Scope: {scope}")
if pref_key not in PREF_KEYS:
raise StoreError("unknown_pref", f"Unbekannte Interaction-Präferenz: {pref_key or ''}")
key = (scope, pref_key)
if key in seen:
raise StoreError("duplicate_pref", f"Präferenz {scope}/{pref_key} ist doppelt.")
origin = (item.get("origin") or "manual").strip()
if origin not in ORIGIN_INTERACTION:
origin = "manual"
seen.add(key)
prepared.append(
{
"scope": scope,
"pref_key": pref_key,
"value": item.get("value") or "",
"evidence": item.get("evidence") or "aus Profil-Dokument",
"origin": origin,
"locked": 1 if item.get("locked") else 0,
}
)
ensure_profile(profile_id)
with get_db() as conn:
conn.execute(
"UPDATE interaction_profiles SET governance = ?, updated = datetime('now') WHERE profile_id = ?",
(governance, profile_id),
)
conn.execute("DELETE FROM interaction_preferences WHERE profile_id = ?", (profile_id,))
conn.execute(
"""
UPDATE interaction_suggestions
SET status = 'rejected', resolved = datetime('now')
WHERE profile_id = ? AND status = 'pending'
""",
(profile_id,),
)
for item in prepared:
conn.execute(
"""
INSERT INTO interaction_preferences
(id, profile_id, scope, pref_key, value, evidence, origin, locked)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
item["scope"],
item["pref_key"],
item["value"],
item["evidence"],
item["origin"],
item["locked"],
),
)
return get_profile(profile_id)
def assemble_interaction_hint(profile_id: str | None, state: dict | None = None) -> str:
"""Visible hint for the dialogue turn. Product defaults plus personal overrides plus current state."""
prefs: dict[tuple[str, str], dict] = {}
if profile_id:
profile = get_profile(profile_id)
for item in profile.get("preferences") or []:
prefs[(item.get("scope") or "global", item.get("pref_key") or "")] = item
lines = [
"Interaction (sichtbar, überschreibbar; Nicht-Widersprechen ist kein Lernsignal):",
]
for item in DEFAULTS:
key = (item["scope"], item["pref_key"])
personal = prefs.get(key)
if item["scope"] != "global":
continue
value = (personal.get("value") if personal else None) or item["value"]
source = "persönlich" if personal else "Produktdefault"
lines.append(f"- {item['title']}: {value} [{source}]")
auto = next((item for item in DEFAULTS if item["pref_key"] == "listening"), None)
if auto:
personal = prefs.get(("autobiographical", "listening"))
value = (personal.get("value") if personal else None) or auto["value"]
source = "persönlich" if personal else "Produktdefault"
if state and (state.get("long_story") or (state.get("narrative_mode") or "") == "chronicle"):
lines.append(f"- {auto['title']}: {value} [{source}]")
elif personal:
lines.append(f"- {auto['title']}: {value} [{source}]")
if state:
mode = (state.get("narrative_mode") or "open").strip() or "open"
depth = (state.get("reflection_depth") or "surface").strip() or "surface"
intensity = (state.get("emotional_intensity") or "low").strip() or "low"
long_story = "ja" if state.get("long_story") else "nein"
focus = (state.get("current_focus") or "").strip()
lines.append(
"Aktueller Dialogue State (operativ, kein Profil, ändert sich von Zug zu Zug): "
f"narrative_mode={mode}; reflection_depth={depth}; "
f"emotional_intensity={intensity}; long_story={long_story}."
)
if focus:
lines.append(f"current_focus={focus}")
return "\n".join(lines)
def _title(scope: str, pref_key: str) -> str:
for item in DEFAULTS:
if item["scope"] == scope and item["pref_key"] == pref_key:
return item["title"]
return pref_key