488 lines
19 KiB
Python
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"),
|
|
}
|