feat: add stateful conversation storage

This commit is contained in:
hh
2026-05-21 12:27:11 +02:00
parent 4a405faf25
commit a83bec709d
6 changed files with 994 additions and 94 deletions
+66 -7
View File
@@ -1,15 +1,19 @@
"""SQLModel tables.
Two tables, both flat, no relationships modelled yet (``actor`` and
Four tables, all flat, no FK relationships modelled (``actor`` and
``agent_name`` are stored as strings — joining audit→token by name is
fine at this volume; we'll introduce FKs when the admin UI actually
demands them).
A ``Session`` table originally lived here for live-session
observability. It was dropped after we decided the gateway stays
stateless about identity (claude-code-api's in-memory fingerprint pool
is the source of truth) and that conversation persistence belongs in a
future Obsidian-sync frontend, not a sessions table.
The ``Conversation`` + ``ConversationMessage`` pair persists chat
history per frontend so we can survive cache misses without losing
tool-call memory. The gateway is now stateful about conversation
content (we keep the raw Anthropic-shape message list including
``tool_use`` / ``tool_result`` blocks); the live ``claude-code-api``
session pool stays the source of truth for *fingerprints*, and the DB
mirrors what we'd want to re-seed if a session evicts. See
``core/conversation_store.py`` for the diff-and-fork logic and
``frontends/markdown/frontend.py`` for the integration point.
Datetimes are stored UTC; we set ``default_factory`` rather than relying
on DB defaults so SQLite + Postgres behave identically. Every row that
@@ -20,6 +24,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
@@ -66,4 +71,58 @@ class AuditLog(SQLModel, table=True):
detail_json: str = Field(default="{}")
__all__ = ["AuditLog", "Token"]
class Conversation(SQLModel, table=True):
"""One chat thread, scoped to a frontend.
``external_id`` is the identifier the frontend uses to find this
thread again on the next request — for the markdown frontend it's a
uuid we mint and persist into the file's frontmatter, for the
anthropic frontend it'd be the same metadata.conversation_id the
client passes. Unique per ``(frontend, external_id)`` because two
frontends sharing a uuid is fine; the same frontend reusing one is
a bug.
"""
__tablename__ = "conversations"
__table_args__ = (
UniqueConstraint("frontend", "external_id", name="uq_conv_frontend_extid"),
)
id: int | None = Field(default=None, primary_key=True)
frontend: str = Field(index=True)
external_id: str = Field(index=True)
agent_name: str = Field(index=True)
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)
class ConversationMessage(SQLModel, table=True):
"""One raw Anthropic-shape message in a conversation's transcript.
A single user/assistant exchange visible in Obsidian can occupy
multiple rows when claude ran a tool cycle: ``assistant``
(tool_use), ``user`` (tool_result), ``assistant`` (final text) all
live as separate rows with the same conversation_id and monotonic
``seq``. ``content_json`` is the canonical Anthropic content payload
(string or list-of-blocks) — exactly what we'll feed back to the
backend so its session-pool fingerprint matches.
``seq`` is per-conversation 0-based monotonic; the unique
constraint catches the trivial bug of two writers racing on the
same conversation.
"""
__tablename__ = "conversation_messages"
__table_args__ = (
UniqueConstraint("conversation_id", "seq", name="uq_msg_conv_seq"),
)
id: int | None = Field(default=None, primary_key=True)
conversation_id: int = Field(index=True)
seq: int
role: str
content_json: str
created_at: datetime = Field(default_factory=_utcnow)
__all__ = ["AuditLog", "Conversation", "ConversationMessage", "Token"]