Kansho/backend/journal_store.py
2026-08-25 13:57:23 +02:00

475 lines
16 KiB
Python

"""Journal Day, Draft, Entry and Space product store. Source messages stay in dialogue_store."""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from dialogue_store import (
StoreError,
create_conversation,
create_space,
get_space,
list_conversations_for_day,
list_spaces,
rename_space,
start_usage_session,
)
from db import _insert_source_refs, get_db, row_to_dict
from journal_policy import consolidation_offer, require_origin
from journal_body import clean_title, to_markdown
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SCRATCH_MAX_ITEMS = 40
SCRATCH_MAX_TEXT = 400
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _parse_ids(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
return [str(item) for item in data]
def _load_source_ids(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> tuple[list[str], list[str]]:
refs = conn.execute(
f"""
SELECT source_kind, source_id, sort_order
FROM {table}
WHERE {owner_col} = ?
ORDER BY source_kind, sort_order
""",
(owner_id,),
).fetchall()
conversations: list[str] = []
messages: list[str] = []
for item in refs:
kind = item["source_kind"]
source_id = item["source_id"]
if kind == "conversation":
conversations.append(source_id)
elif kind == "message":
messages.append(source_id)
if conversations or messages:
return conversations, messages
row = row or {}
return _parse_ids(row.get("source_conversation_ids")), _parse_ids(row.get("source_message_ids"))
def _attach_sources(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> dict | None:
if not row:
return None
conversations, messages = _load_source_ids(conn, table, owner_col, owner_id, row)
row["source_conversation_ids"] = conversations
row["source_message_ids"] = messages
row["title"] = clean_title(row.get("title") or "")
return row
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 _decode_draft(conn, row: dict | None) -> dict | None:
if not row:
return None
return _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
def _decode_version(conn, row: dict | None) -> dict | None:
if not row:
return None
owner_id = row.get("current_version_id") or row.get("id")
return _attach_sources(conn, "journal_entry_version_source_refs", "version_id", owner_id, row)
def create_user_space(profile_id: str, title: str) -> dict:
if not (title or "").strip():
raise StoreError("empty_title", "Space-Name darf nicht leer sein")
return create_space(profile_id, title.strip(), visibility="user")
def list_user_spaces(profile_id: str) -> list[dict]:
return list_spaces(profile_id, visibility="user")
def get_user_space(profile_id: str, space_id: str) -> dict:
space = get_space(profile_id, space_id)
if space.get("visibility") != "user":
raise StoreError("not_found", "Reflection Space nicht gefunden", 404)
return space
def rename_user_space(profile_id: str, space_id: str, title: str) -> dict:
get_user_space(profile_id, space_id)
return rename_space(profile_id, space_id, title)
def get_or_create_day(profile_id: str, space_id: str, calendar_date: str) -> dict:
get_user_space(profile_id, space_id)
if not DATE_RE.match(calendar_date or ""):
raise StoreError("invalid_date", "calendar_date muss YYYY-MM-DD sein")
with get_db() as conn:
existing = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_days
WHERE profile_id = ? AND space_id = ? AND calendar_date = ?
""",
(profile_id, space_id, calendar_date),
).fetchone()
)
if existing:
return existing
day_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO journal_days (id, profile_id, space_id, calendar_date)
VALUES (?, ?, ?, ?)
""",
(day_id, profile_id, space_id, calendar_date),
)
return row_to_dict(conn.execute("SELECT * FROM journal_days WHERE id = ?", (day_id,)).fetchone())
def get_day(profile_id: str, journal_day_id: str) -> dict:
with get_db() as conn:
day = _owned(conn, "journal_days", journal_day_id, profile_id)
if not day:
raise StoreError("not_found", "Journal Day nicht gefunden", 404)
return day
def list_days(profile_id: str, space_id: str) -> list[dict]:
get_user_space(profile_id, space_id)
with get_db() as conn:
rows = conn.execute(
"""
SELECT d.*,
(SELECT COUNT(*) FROM conversations c
WHERE c.journal_day_id = d.id AND c.profile_id = d.profile_id) AS conversation_count,
(SELECT COUNT(*) FROM journal_entries e
WHERE e.journal_day_id = d.id AND e.profile_id = d.profile_id AND e.deleted_at IS NULL
) AS entry_count
FROM journal_days d
WHERE d.profile_id = ? AND d.space_id = ?
ORDER BY d.calendar_date DESC
""",
(profile_id, space_id),
).fetchall()
result = []
for row in rows:
item = row_to_dict(row)
entry = current_entries(profile_id, item["id"])
item["entry_title"] = (entry[-1].get("title") if entry else "") or ""
item["entry_id"] = entry[-1]["id"] if entry else None
result.append(item)
return result
def create_day_conversation(profile_id: str, journal_day_id: str, title: str = "") -> dict:
day = get_day(profile_id, journal_day_id)
usage = start_usage_session(profile_id, intent="journal")
return create_conversation(
profile_id,
usage_session_id=usage["id"],
title=title or "Gespräch",
space_id=day["space_id"],
journal_day_id=day["id"],
)
def current_draft(profile_id: str, journal_day_id: str) -> dict | None:
get_day(profile_id, journal_day_id)
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_drafts
WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL
ORDER BY created DESC
LIMIT 1
""",
(profile_id, journal_day_id),
).fetchone()
)
return _decode_draft(conn, row)
def insert_draft(
profile_id: str,
journal_day_id: str,
title: str,
body: str,
source_conversation_ids: list[str],
source_message_ids: list[str],
) -> dict:
get_day(profile_id, journal_day_id)
draft_id = str(uuid.uuid4())
as_of = _now()
with get_db() as conn:
conn.execute(
"""
UPDATE journal_drafts
SET superseded_at = ?
WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL
""",
(as_of, profile_id, journal_day_id),
)
conn.execute(
"""
INSERT INTO journal_drafts
(id, profile_id, journal_day_id, title, body,
source_conversation_ids, source_message_ids, as_of)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
draft_id,
profile_id,
journal_day_id,
clean_title(title),
body or "",
"[]",
"[]",
as_of,
),
)
_insert_source_refs(
conn,
"journal_draft_source_refs",
"draft_id",
draft_id,
profile_id,
source_conversation_ids or [],
source_message_ids or [],
)
row = row_to_dict(conn.execute("SELECT * FROM journal_drafts WHERE id = ?", (draft_id,)).fetchone())
return _decode_draft(conn, row)
def current_entries(profile_id: str, journal_day_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"""
SELECT e.*, v.title, v.body, v.origin, v.source_conversation_ids, v.source_message_ids, v.created AS version_created
FROM journal_entries e
LEFT JOIN journal_entry_versions v ON v.id = e.current_version_id
WHERE e.profile_id = ? AND e.journal_day_id = ? AND e.deleted_at IS NULL
ORDER BY e.created
""",
(profile_id, journal_day_id),
).fetchall()
return [_decode_version(conn, row_to_dict(row)) for row in rows]
def get_entry(profile_id: str, entry_id: str, include_deleted: bool = False) -> dict:
with get_db() as conn:
entry = _owned(conn, "journal_entries", entry_id, profile_id)
if not entry:
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
if entry.get("deleted_at") and not include_deleted:
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
version = None
if entry.get("current_version_id"):
version = _decode_version(
conn,
row_to_dict(
conn.execute(
"SELECT * FROM journal_entry_versions WHERE id = ? AND profile_id = ?",
(entry["current_version_id"], profile_id),
).fetchone()
),
)
entry["version"] = version
if version:
entry["title"] = version.get("title") or ""
entry["body"] = version.get("body") or ""
entry["origin"] = version.get("origin")
entry["source_conversation_ids"] = version.get("source_conversation_ids") or []
entry["source_message_ids"] = version.get("source_message_ids") or []
return entry
def list_versions(profile_id: str, entry_id: str) -> list[dict]:
get_entry(profile_id, entry_id, include_deleted=True)
with get_db() as conn:
rows = conn.execute(
"""
SELECT * FROM journal_entry_versions
WHERE profile_id = ? AND entry_id = ?
ORDER BY created
""",
(profile_id, entry_id),
).fetchall()
return [_decode_version(conn, row_to_dict(row)) for row in rows]
def save_entry(
profile_id: str,
journal_day_id: str,
title: str,
body: str,
origin: str,
entry_id: str | None = None,
source_conversation_ids: list[str] | None = None,
source_message_ids: list[str] | None = None,
) -> dict:
require_origin(origin)
day = get_day(profile_id, journal_day_id)
version_id = str(uuid.uuid4())
with get_db() as conn:
if entry_id:
entry = _owned(conn, "journal_entries", entry_id, profile_id)
if not entry or entry.get("deleted_at"):
raise StoreError("not_found", "Journal Entry nicht gefunden", 404)
if entry["journal_day_id"] != journal_day_id:
raise StoreError("assignment_mismatch", "Entry gehört zu einem anderen Journal Day")
else:
entry_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO journal_entries (id, profile_id, journal_day_id, space_id)
VALUES (?, ?, ?, ?)
""",
(entry_id, profile_id, journal_day_id, day["space_id"]),
)
conn.execute(
"""
INSERT INTO journal_entry_versions
(id, profile_id, entry_id, title, body, source_conversation_ids, source_message_ids, origin, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version_id,
profile_id,
entry_id,
clean_title(title),
to_markdown(body or ""),
"[]",
"[]",
origin,
_now(),
),
)
_insert_source_refs(
conn,
"journal_entry_version_source_refs",
"version_id",
version_id,
profile_id,
source_conversation_ids or [],
source_message_ids or [],
)
conn.execute(
"""
UPDATE journal_entries
SET current_version_id = ?, updated = datetime('now'), deleted_at = NULL
WHERE id = ? AND profile_id = ?
""",
(version_id, entry_id, profile_id),
)
return get_entry(profile_id, entry_id)
def restore_entry(profile_id: str, entry_id: str, version_id: str) -> dict:
entry = get_entry(profile_id, entry_id)
with get_db() as conn:
version = row_to_dict(
conn.execute(
"""
SELECT * FROM journal_entry_versions
WHERE id = ? AND entry_id = ? AND profile_id = ?
""",
(version_id, entry_id, profile_id),
).fetchone()
)
if not version:
raise StoreError("not_found", "Version nicht gefunden", 404)
decoded = _decode_version(conn, version)
return save_entry(
profile_id,
entry["journal_day_id"],
decoded.get("title") or "",
decoded.get("body") or "",
origin="restore",
entry_id=entry_id,
source_conversation_ids=decoded.get("source_conversation_ids") or [],
source_message_ids=decoded.get("source_message_ids") or [],
)
def soft_delete_entry(profile_id: str, entry_id: str) -> dict:
get_entry(profile_id, entry_id)
with get_db() as conn:
conn.execute(
"UPDATE journal_entries SET deleted_at = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?",
(_now(), entry_id, profile_id),
)
return {"id": entry_id, "deleted": True}
def _parse_scratch(raw) -> list[dict]:
if isinstance(raw, list):
data = raw
else:
try:
data = json.loads(raw or "[]")
except json.JSONDecodeError:
data = []
if not isinstance(data, list):
return []
items: list[dict] = []
for item in data[:SCRATCH_MAX_ITEMS]:
if not isinstance(item, dict):
continue
text = str(item.get("text") or "")[:SCRATCH_MAX_TEXT]
items.append(
{
"id": str(item.get("id") or uuid.uuid4()),
"text": text,
"done": bool(item.get("done")),
}
)
return items
def save_day_scratch(profile_id: str, journal_day_id: str, items: list[dict]) -> list[dict]:
get_day(profile_id, journal_day_id)
cleaned = _parse_scratch(items)
with get_db() as conn:
conn.execute(
"UPDATE journal_days SET scratch_json = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?",
(json.dumps(cleaned, ensure_ascii=False), journal_day_id, profile_id),
)
return cleaned
def day_payload(profile_id: str, journal_day_id: str) -> dict:
day = get_day(profile_id, journal_day_id)
space = get_user_space(profile_id, day["space_id"])
conversations = list_conversations_for_day(profile_id, journal_day_id)
return {
"day": {key: value for key, value in day.items() if key != "scratch_json"},
"space": space,
"conversations": conversations,
"current_draft": current_draft(profile_id, journal_day_id),
"entries": current_entries(profile_id, journal_day_id),
"scratch": _parse_scratch(day.get("scratch_json")),
"consolidation_offer": consolidation_offer(conversations),
}