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