Kansho/backend/dialogue_store.py
Lars ea38528ecf Fix Postgres 500s on days and entries by dropping SQLite rowid.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:50:53 +02:00

488 lines
19 KiB
Python

"""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, c.id
""",
(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"),
}