feat(conversations): pre-SDK rows read from canonical messages, implied titles, markdown adopts them by first prompt
This commit is contained in:
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -38,6 +39,7 @@ from claude_agent_sdk import (
|
||||
)
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.core.conversation_store import load_messages
|
||||
from beaver_gateway.core.injects import InjectQueue, inject_header
|
||||
from beaver_gateway.core.kinds import KINDS, Kind, as_kind
|
||||
from beaver_gateway.core.transcript import (
|
||||
@@ -52,13 +54,14 @@ from beaver_gateway.frontends._accumulate import StreamAccumulator
|
||||
from beaver_gateway.storage.models import (
|
||||
Conversation,
|
||||
ConversationBinding,
|
||||
ConversationMessage,
|
||||
InjectQueueItem,
|
||||
RateLimit,
|
||||
Schedule,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
||||
|
||||
from claude_agent_sdk import SessionStore
|
||||
|
||||
@@ -453,6 +456,7 @@ class Conversations:
|
||||
|
||||
async def describe(self, conv: Conversation) -> dict[str, Any]:
|
||||
out = self.public(conv)
|
||||
out["title"] = await self.implied_title(conv)
|
||||
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
|
||||
out["parent"] = parent.external_id if parent is not None else None
|
||||
out["bindings"] = [
|
||||
@@ -629,8 +633,67 @@ class Conversations:
|
||||
return render_messages(await self.history(conv), window=window)
|
||||
|
||||
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
|
||||
if conv.session_id is None:
|
||||
async with self._db.session() as session:
|
||||
return await load_messages(
|
||||
session, conversation_id=cast("int", conv.id)
|
||||
)
|
||||
return messages_from_entries(cast("Any", await self.entries(conv)))
|
||||
|
||||
async def first_user_texts(self, ids: Iterable[int]) -> dict[int, str]:
|
||||
wanted = list(ids)
|
||||
if not wanted:
|
||||
return {}
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
await session.exec(
|
||||
select(ConversationMessage).where(
|
||||
col(ConversationMessage.conversation_id).in_(wanted),
|
||||
ConversationMessage.seq == 0,
|
||||
ConversationMessage.role == "user",
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {
|
||||
r.conversation_id: text_of(json.loads(r.content_json)).strip() for r in rows
|
||||
}
|
||||
|
||||
async def implied_title(self, conv: Conversation) -> str | None:
|
||||
if conv.title:
|
||||
return conv.title
|
||||
text = (await self.first_user_texts([cast("int", conv.id)])).get(
|
||||
cast("int", conv.id)
|
||||
)
|
||||
return implied_title(text)
|
||||
|
||||
async def adopt(self, *, kind: Kind, first_user_text: str) -> Conversation | None:
|
||||
"""The one unbound, session-less conversation whose history starts here.
|
||||
|
||||
Rows from before the SDK cut-over have canonical messages but no
|
||||
window and no session; a vault file that begins with the same
|
||||
prompt is that conversation continued.
|
||||
"""
|
||||
text = first_user_text.strip()
|
||||
if not text:
|
||||
return None
|
||||
bound = select(ConversationBinding.conversation_id).where(
|
||||
col(ConversationBinding.visible).is_(True)
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
await session.exec(
|
||||
select(Conversation).where(
|
||||
Conversation.kind == kind,
|
||||
Conversation.status == "open",
|
||||
col(Conversation.session_id).is_(None),
|
||||
col(Conversation.id).not_in(bound),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
firsts = await self.first_user_texts(cast("int", r.id) for r in rows)
|
||||
hits = [r for r in rows if firsts.get(cast("int", r.id)) == text]
|
||||
return hits[0] if len(hits) == 1 else None
|
||||
|
||||
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||
if conv.session_id is None:
|
||||
return []
|
||||
@@ -1353,6 +1416,16 @@ def _iso(value: datetime | None) -> str | None:
|
||||
return _aware(value).isoformat(timespec="seconds") if value is not None else None
|
||||
|
||||
|
||||
TITLE_MAX = 80
|
||||
|
||||
|
||||
def implied_title(text: str | None) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
line = text.strip().splitlines()[0].strip()
|
||||
return line if len(line) <= TITLE_MAX else line[: TITLE_MAX - 1] + "…"
|
||||
|
||||
|
||||
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user