"""Layer-0 dialogue store. Profile-scoped. Auth sessions table is untouched.""" from __future__ import annotations import json import uuid from datetime import datetime, timezone from db import get_db, row_to_dict from derived_kinds import require_kind SUBJECT_TYPES = {"conversation", "thread", "space", "usage_session"} VISIBILITIES = {"internal", "user"} MESSAGE_ROLES = {"user", "assistant", "system"} HANDOFF_TARGETS = {"journal", "memory", "knowledge", "action", "mindnet", "obsidian"} class StoreError(Exception): def __init__(self, code: str, message: str, status_code: int = 400): super().__init__(message) self.code = code self.message = message self.status_code = status_code def _now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def _parse_ids(raw: str | None) -> list[str]: if not raw: return [] data = json.loads(raw) if not isinstance(data, list): return [] return [str(item) for item in data] def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None: return row_to_dict( conn.execute( f"SELECT * FROM {table} WHERE id = ? AND profile_id = ?", (record_id, profile_id), ).fetchone() ) def start_usage_session(profile_id: str, intent: str = "") -> dict: session_id = str(uuid.uuid4()) with get_db() as conn: conn.execute( "INSERT INTO usage_sessions (id, profile_id, intent) VALUES (?, ?, ?)", (session_id, profile_id, intent or ""), ) return row_to_dict(conn.execute("SELECT * FROM usage_sessions WHERE id = ?", (session_id,)).fetchone()) def end_usage_session(profile_id: str, usage_session_id: str) -> dict: with get_db() as conn: current = _owned(conn, "usage_sessions", usage_session_id, profile_id) if not current: raise StoreError("not_found", "Nutzungssitzung nicht gefunden", 404) if not current.get("ended_at"): conn.execute( "UPDATE usage_sessions SET ended_at = ? WHERE id = ? AND profile_id = ?", (_now(), usage_session_id, profile_id), ) return row_to_dict( conn.execute( "SELECT * FROM usage_sessions WHERE id = ? AND profile_id = ?", (usage_session_id, profile_id), ).fetchone() ) def thread_snapshot(profile_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( "SELECT id, status, visibility, resurfacing_at FROM threads WHERE profile_id = ? ORDER BY created", (profile_id,), ).fetchall() return [row_to_dict(row) for row in rows] def create_conversation( profile_id: str, usage_session_id: str | None = None, title: str = "", space_id: str | None = None, journal_day_id: str | None = None, ) -> dict: conversation_id = str(uuid.uuid4()) with get_db() as conn: if usage_session_id: session = _owned(conn, "usage_sessions", usage_session_id, profile_id) if not session: raise StoreError("not_found", "Nutzungssitzung nicht gefunden", 404) if space_id and not _owned(conn, "spaces", space_id, profile_id): raise StoreError("not_found", "Reflection Space nicht gefunden", 404) if journal_day_id: day = _owned(conn, "journal_days", journal_day_id, profile_id) if not day: raise StoreError("not_found", "Journal Day nicht gefunden", 404) if space_id and day["space_id"] != space_id: raise StoreError("assignment_mismatch", "Journal Day gehört zu einem anderen Space") space_id = space_id or day["space_id"] conn.execute( """ INSERT INTO conversations (id, profile_id, usage_session_id, title, space_id, journal_day_id, narrative_mode, reflection_depth, current_focus) VALUES (?, ?, ?, ?, ?, ?, '', '', '') """, (conversation_id, profile_id, usage_session_id, title or "", space_id, journal_day_id), ) return row_to_dict(conn.execute("SELECT * FROM conversations WHERE id = ?", (conversation_id,)).fetchone()) def get_conversation(profile_id: str, conversation_id: str) -> dict: with get_db() as conn: conv = _owned(conn, "conversations", conversation_id, profile_id) if not conv: raise StoreError("not_found", "Conversation nicht gefunden", 404) return conv def update_conversation_signals(profile_id: str, conversation_id: str, signals: dict) -> dict: get_conversation(profile_id, conversation_id) with get_db() as conn: conn.execute( """ UPDATE conversations SET narrative_mode = ?, reflection_depth = ?, current_focus = ?, emotional_intensity = ?, long_story = ?, updated = datetime('now') WHERE id = ? AND profile_id = ? """, ( (signals.get("narrative_mode") or "")[:40], (signals.get("reflection_depth") or "")[:40], (signals.get("current_focus") or "")[:200], (signals.get("emotional_intensity") or "")[:40], 1 if signals.get("long_story") else 0, conversation_id, profile_id, ), ) return get_conversation(profile_id, conversation_id) def list_conversations(profile_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count, (SELECT COUNT(*) FROM derived_records d WHERE d.profile_id = c.profile_id AND d.subject_type = 'conversation' AND d.subject_id = c.id ) AS derived_count FROM conversations c WHERE c.profile_id = ? ORDER BY c.created DESC """, (profile_id,), ).fetchall() return [row_to_dict(row) for row in rows] def append_message( profile_id: str, conversation_id: str, body: str, role: str = "user", message_id: str | None = None, ) -> dict: if role not in MESSAGE_ROLES: raise StoreError("invalid_role", "Nachrichtenrolle muss user, assistant oder system sein") if not (body or "").strip(): raise StoreError("empty_body", "Nachricht darf nicht leer sein") requested_id = message_id or str(uuid.uuid4()) with get_db() as conn: conv = _owned(conn, "conversations", conversation_id, profile_id) if not conv: raise StoreError("not_found", "Conversation nicht gefunden", 404) existing = row_to_dict(conn.execute("SELECT * FROM messages WHERE id = ?", (requested_id,)).fetchone()) if existing: if existing["profile_id"] != profile_id or existing["conversation_id"] != conversation_id: raise StoreError("id_conflict", "Message-ID gehört zu einer anderen Conversation", 409) return existing seq_row = conn.execute( "SELECT COALESCE(MAX(seq), 0) AS n FROM messages WHERE conversation_id = ?", (conversation_id,), ).fetchone() seq = int(seq_row["n"]) + 1 conn.execute( """ INSERT INTO messages (id, profile_id, conversation_id, seq, role, body) VALUES (?, ?, ?, ?, ?, ?) """, (requested_id, profile_id, conversation_id, seq, role, body.strip()), ) conn.execute( "UPDATE conversations SET updated = datetime('now') WHERE id = ?", (conversation_id,), ) return row_to_dict(conn.execute("SELECT * FROM messages WHERE id = ?", (requested_id,)).fetchone()) def list_messages(profile_id: str, conversation_id: str) -> list[dict]: with get_db() as conn: conv = _owned(conn, "conversations", conversation_id, profile_id) if not conv: raise StoreError("not_found", "Conversation nicht gefunden", 404) rows = conn.execute( """ SELECT * FROM messages WHERE conversation_id = ? AND profile_id = ? ORDER BY seq """, (conversation_id, profile_id), ).fetchall() return [row_to_dict(row) for row in rows] def create_thread(profile_id: str, title: str = "", status: str = "open", visibility: str = "internal") -> dict: if visibility not in VISIBILITIES: raise StoreError("invalid_visibility", "visibility muss internal oder user sein") thread_id = str(uuid.uuid4()) with get_db() as conn: conn.execute( """ INSERT INTO threads (id, profile_id, title, status, visibility) VALUES (?, ?, ?, ?, ?) """, (thread_id, profile_id, title or "", status or "open", visibility), ) return row_to_dict(conn.execute("SELECT * FROM threads WHERE id = ?", (thread_id,)).fetchone()) def link_conversation_thread(profile_id: str, conversation_id: str, thread_id: str) -> dict: with get_db() as conn: if not _owned(conn, "conversations", conversation_id, profile_id): raise StoreError("not_found", "Conversation nicht gefunden", 404) if not _owned(conn, "threads", thread_id, profile_id): raise StoreError("not_found", "Thread nicht gefunden", 404) conn.execute( """ INSERT OR IGNORE INTO conversation_threads (conversation_id, thread_id, profile_id) VALUES (?, ?, ?) """, (conversation_id, thread_id, profile_id), ) return {"conversation_id": conversation_id, "thread_id": thread_id} def create_space(profile_id: str, title: str = "", visibility: str = "internal") -> dict: if visibility not in VISIBILITIES: raise StoreError("invalid_visibility", "visibility muss internal oder user sein") space_id = str(uuid.uuid4()) with get_db() as conn: conn.execute( "INSERT INTO spaces (id, profile_id, title, visibility) VALUES (?, ?, ?, ?)", (space_id, profile_id, title or "", visibility), ) return row_to_dict(conn.execute("SELECT * FROM spaces WHERE id = ?", (space_id,)).fetchone()) def get_space(profile_id: str, space_id: str) -> dict: with get_db() as conn: space = _owned(conn, "spaces", space_id, profile_id) if not space: raise StoreError("not_found", "Reflection Space nicht gefunden", 404) return space def list_spaces(profile_id: str, visibility: str | None = "user") -> list[dict]: with get_db() as conn: if visibility: rows = conn.execute( """ SELECT * FROM spaces WHERE profile_id = ? AND visibility = ? ORDER BY updated DESC, created DESC """, (profile_id, visibility), ).fetchall() else: rows = conn.execute( "SELECT * FROM spaces WHERE profile_id = ? ORDER BY updated DESC, created DESC", (profile_id,), ).fetchall() return [row_to_dict(row) for row in rows] def rename_space(profile_id: str, space_id: str, title: str) -> dict: if not (title or "").strip(): raise StoreError("empty_title", "Space-Name darf nicht leer sein") with get_db() as conn: if not _owned(conn, "spaces", space_id, profile_id): raise StoreError("not_found", "Reflection Space nicht gefunden", 404) conn.execute( "UPDATE spaces SET title = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?", (title.strip(), space_id, profile_id), ) return row_to_dict( conn.execute("SELECT * FROM spaces WHERE id = ? AND profile_id = ?", (space_id, profile_id)).fetchone() ) def delete_conversation(profile_id: str, conversation_id: str) -> dict: with get_db() as conn: conv = _owned(conn, "conversations", conversation_id, profile_id) if not conv: raise StoreError("not_found", "Conversation nicht gefunden", 404) conn.execute( """ UPDATE handoffs SET source_conversation_id = NULL WHERE profile_id = ? AND source_conversation_id = ? """, (profile_id, conversation_id), ) conn.execute( """ DELETE FROM derived_records WHERE profile_id = ? AND subject_type = 'conversation' AND subject_id = ? """, (profile_id, conversation_id), ) conn.execute( "DELETE FROM conversations WHERE id = ? AND profile_id = ?", (conversation_id, profile_id), ) return {"id": conversation_id, "deleted": True} def list_conversations_for_day(profile_id: str, journal_day_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count FROM conversations c WHERE c.profile_id = ? AND c.journal_day_id = ? ORDER BY c.created """, (profile_id, journal_day_id), ).fetchall() return [row_to_dict(row) for row in rows] def link_thread_space(profile_id: str, thread_id: str, space_id: str, confidence: float | None = None) -> dict: with get_db() as conn: if not _owned(conn, "threads", thread_id, profile_id): raise StoreError("not_found", "Thread nicht gefunden", 404) if not _owned(conn, "spaces", space_id, profile_id): raise StoreError("not_found", "Space nicht gefunden", 404) conn.execute( """ INSERT OR IGNORE INTO thread_spaces (thread_id, space_id, profile_id, confidence) VALUES (?, ?, ?, ?) """, (thread_id, space_id, profile_id, confidence), ) return {"thread_id": thread_id, "space_id": space_id, "confidence": confidence} def insert_derived( profile_id: str, kind: str, subject_type: str, subject_id: str, source_message_ids: list[str], body: str = "", visibility: str = "internal", confidence: float | None = None, ) -> dict: try: require_kind(kind) except ValueError as exc: raise StoreError("unknown_kind", f"Derived-Kind ist nicht registriert: {kind}") from exc if subject_type not in SUBJECT_TYPES: raise StoreError("invalid_subject", "Ungültiger subject_type") if visibility not in VISIBILITIES: raise StoreError("invalid_visibility", "visibility muss internal oder user sein") ids = [item for item in source_message_ids if item] if not ids: raise StoreError("provenance_required", "Derived records brauchen source_message_ids") record_id = str(uuid.uuid4()) as_of = _now() with get_db() as conn: for message_id in ids: msg = _owned(conn, "messages", message_id, profile_id) if not msg: raise StoreError("not_found", f"Quellnachricht nicht gefunden: {message_id}", 404) conn.execute( """ INSERT INTO derived_records (id, profile_id, kind, subject_type, subject_id, source_message_ids, as_of, confidence, visibility, body) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( record_id, profile_id, kind, subject_type, subject_id, json.dumps(ids), as_of, confidence, visibility, body or "", ), ) row = row_to_dict(conn.execute("SELECT * FROM derived_records WHERE id = ?", (record_id,)).fetchone()) row["source_message_ids"] = ids return row def latest_derived(profile_id: str, subject_type: str, subject_id: str, kind: str) -> dict | None: with get_db() as conn: row = row_to_dict( conn.execute( """ SELECT * FROM derived_records WHERE profile_id = ? AND subject_type = ? AND subject_id = ? AND kind = ? ORDER BY created DESC LIMIT 1 """, (profile_id, subject_type, subject_id, kind), ).fetchone() ) if not row: return None row["source_message_ids"] = _parse_ids(row.get("source_message_ids")) return row def list_derived_for_conversation(profile_id: str, conversation_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT * FROM derived_records WHERE profile_id = ? AND subject_type = 'conversation' AND subject_id = ? ORDER BY created """, (profile_id, conversation_id), ).fetchall() result = [] for row in rows: item = row_to_dict(row) item["source_message_ids"] = _parse_ids(item.get("source_message_ids")) result.append(item) return result def create_handoff(profile_id: str, target: str, source_conversation_id: str | None = None, payload: dict | None = None) -> dict: if target not in HANDOFF_TARGETS: raise StoreError("invalid_target", "Ungültiges Handoff-Ziel") handoff_id = str(uuid.uuid4()) with get_db() as conn: if source_conversation_id and not _owned(conn, "conversations", source_conversation_id, profile_id): raise StoreError("not_found", "Conversation nicht gefunden", 404) conn.execute( """ INSERT INTO handoffs (id, profile_id, target, source_conversation_id, payload_json) VALUES (?, ?, ?, ?, ?) """, (handoff_id, profile_id, target, source_conversation_id, json.dumps(payload or {})), ) return row_to_dict(conn.execute("SELECT * FROM handoffs WHERE id = ?", (handoff_id,)).fetchone()) def inventory(profile_id: str | None = None) -> dict: params = (profile_id,) if profile_id else () with get_db() as conn: def count(table: str) -> int: if profile_id: return conn.execute(f"SELECT COUNT(*) AS n FROM {table} WHERE profile_id = ?", params).fetchone()["n"] return conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"] return { "usage_sessions": count("usage_sessions"), "conversations": count("conversations"), "messages": count("messages"), "threads": count("threads"), "spaces": count("spaces"), "derived_records": count("derived_records"), "handoffs": count("handoffs"), }