424 lines
15 KiB
Python
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
|