refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
1 parent
253bde1b11
commit
9aaddbed75
77 files changed
+2987
-2944
No files matched your search
@@ -0,0 +1,514 @@
|
||||
"""The conversation rows: create, find, bind to windows, flags, status, history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.backends.transcript import (
|
||||
messages_from_entries,
|
||||
render_messages,
|
||||
text_of,
|
||||
)
|
||||
from beaver_gateway.conversations.kinds import KINDS, Kind
|
||||
from beaver_gateway.conversations.state import State, iso
|
||||
from beaver_gateway.frontends.markdown.history import load_messages
|
||||
from beaver_gateway.storage.models import (
|
||||
Conversation,
|
||||
ConversationBinding,
|
||||
ConversationMessage,
|
||||
RateLimit,
|
||||
Usage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
|
||||
__all__ = [
|
||||
"MASTER_ALIAS",
|
||||
"PARENT_ALIAS",
|
||||
"STATUSES",
|
||||
"TITLE_MAX",
|
||||
"Rows",
|
||||
"context_of",
|
||||
"implied_title",
|
||||
]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
MASTER_ALIAS = "master"
|
||||
PARENT_ALIAS = "parent"
|
||||
STATUSES = ("open", "merged", "closed", "archived")
|
||||
TITLE_MAX = 80
|
||||
|
||||
|
||||
class Rows(State):
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
kind: Kind,
|
||||
agent: str,
|
||||
parent: Conversation | None = None,
|
||||
title: str | None = None,
|
||||
origin: str = "api",
|
||||
session_id: str | None = None,
|
||||
flags: dict[str, Any] | None = None,
|
||||
) -> Conversation:
|
||||
if kind not in KINDS:
|
||||
msg = f"unknown conversation kind {kind!r}"
|
||||
raise ValueError(msg)
|
||||
if not self._claude_agent(agent).serves(kind):
|
||||
msg = f"agent {agent!r} does not serve kind {kind!r}"
|
||||
raise ValueError(msg)
|
||||
now = datetime.now(UTC)
|
||||
row = Conversation(
|
||||
frontend=origin,
|
||||
external_id=str(uuid4()),
|
||||
agent_name=agent,
|
||||
kind=kind,
|
||||
parent_id=parent.id if parent is not None else None,
|
||||
title=title,
|
||||
session_id=session_id,
|
||||
flags=dict(flags or {}),
|
||||
last_activity_at=now,
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
self._bus.publish("conversation.created", **self.public(row))
|
||||
return row
|
||||
|
||||
async def get(self, public_id: str) -> Conversation | None:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(Conversation).where(Conversation.external_id == public_id)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def get_row(self, row_id: int) -> Conversation | None:
|
||||
async with self._db.session() as session:
|
||||
return await session.get(Conversation, row_id)
|
||||
|
||||
async def resolve(
|
||||
self, key: str, *, origin: Conversation | None = None
|
||||
) -> Conversation | None:
|
||||
"""By public id, or ``master`` / ``parent`` relative to ``origin``."""
|
||||
key = key.strip()
|
||||
if key == MASTER_ALIAS:
|
||||
return await self.open_master()
|
||||
if key == PARENT_ALIAS:
|
||||
if origin is None or origin.parent_id is None:
|
||||
return None
|
||||
return await self.get_row(origin.parent_id)
|
||||
return await self.get(key)
|
||||
|
||||
async def open_master(self) -> Conversation | None:
|
||||
masters = await self.find(kind="master", status="open", limit=1)
|
||||
return masters[0] if masters else None
|
||||
|
||||
async def find(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
kind: str | None = None,
|
||||
parent: Conversation | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[Conversation]:
|
||||
stmt = select(Conversation).order_by(col(Conversation.id).desc()).limit(limit)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Conversation.status == status)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Conversation.kind == kind)
|
||||
if parent is not None:
|
||||
stmt = stmt.where(Conversation.parent_id == parent.id)
|
||||
async with self._db.session() as session:
|
||||
return list((await session.exec(stmt)).all())
|
||||
|
||||
async def bindings(self, conv: Conversation) -> list[ConversationBinding]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(ConversationBinding)
|
||||
.where(ConversationBinding.conversation_id == conv.id)
|
||||
.order_by(col(ConversationBinding.id))
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
async def bind(
|
||||
self,
|
||||
conv: Conversation,
|
||||
*,
|
||||
frontend: str,
|
||||
external_id: str,
|
||||
visible: bool = True,
|
||||
) -> ConversationBinding:
|
||||
if conv.kind not in self.frontend(frontend).kinds:
|
||||
msg = f"frontend {frontend!r} does not show kind {conv.kind!r}"
|
||||
raise ValueError(msg)
|
||||
async with self._db.session() as session:
|
||||
existing = list(
|
||||
(
|
||||
await session.exec(
|
||||
select(ConversationBinding).where(
|
||||
ConversationBinding.conversation_id == conv.id,
|
||||
ConversationBinding.frontend == frontend,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
row = next((b for b in existing if b.external_id == external_id), None)
|
||||
if visible:
|
||||
for other in existing:
|
||||
if other is not row and other.visible:
|
||||
other.visible = False
|
||||
session.add(other)
|
||||
same_window = await session.exec(
|
||||
select(ConversationBinding).where(
|
||||
ConversationBinding.frontend == frontend,
|
||||
ConversationBinding.external_id == external_id,
|
||||
ConversationBinding.conversation_id != conv.id,
|
||||
col(ConversationBinding.visible).is_(True),
|
||||
)
|
||||
)
|
||||
for other in same_window.all():
|
||||
other.visible = False
|
||||
session.add(other)
|
||||
if row is None:
|
||||
row = ConversationBinding(
|
||||
conversation_id=cast("int", conv.id),
|
||||
frontend=frontend,
|
||||
external_id=external_id,
|
||||
visible=visible,
|
||||
)
|
||||
else:
|
||||
row.visible = visible
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
self._bus.publish(
|
||||
"conversation.bound",
|
||||
conversation_id=conv.external_id,
|
||||
frontend=frontend,
|
||||
external_id=external_id,
|
||||
visible=visible,
|
||||
)
|
||||
return row
|
||||
|
||||
async def find_bound(
|
||||
self, *, frontend: str, external_id: str
|
||||
) -> Conversation | None:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(Conversation)
|
||||
.join(
|
||||
ConversationBinding,
|
||||
col(ConversationBinding.conversation_id) == col(Conversation.id),
|
||||
)
|
||||
.where(
|
||||
ConversationBinding.frontend == frontend,
|
||||
ConversationBinding.external_id == external_id,
|
||||
col(ConversationBinding.visible).is_(True),
|
||||
)
|
||||
.order_by(col(Conversation.id).desc())
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def last_binding(
|
||||
self, *, frontend: str, kind: str
|
||||
) -> ConversationBinding | None:
|
||||
"""The window ``frontend`` last used for ``kind``; outlives a rotation."""
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(ConversationBinding)
|
||||
.join(
|
||||
Conversation,
|
||||
col(Conversation.id) == col(ConversationBinding.conversation_id),
|
||||
)
|
||||
.where(
|
||||
ConversationBinding.frontend == frontend, Conversation.kind == kind
|
||||
)
|
||||
.order_by(col(ConversationBinding.id).desc())
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def window_of(self, conv: Conversation) -> str | None:
|
||||
for binding in await self.bindings(conv):
|
||||
if binding.visible:
|
||||
return binding.external_id
|
||||
return None
|
||||
|
||||
async def set_flags(
|
||||
self, conv: Conversation, flags: dict[str, Any]
|
||||
) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.flags = {**row.flags, **flags}
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def set_status(self, conv: Conversation, status: str) -> Conversation:
|
||||
if status not in STATUSES:
|
||||
msg = f"unknown status {status!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.status = status
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def set_title(self, conv: Conversation, title: str) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.title = title
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def touch_user(self, conv: Conversation) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.last_user_activity_at = datetime.now(UTC)
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.parent_id = parent.id
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def _update(
|
||||
self, conv: Conversation, apply: Callable[[Conversation], Awaitable[None]]
|
||||
) -> Conversation:
|
||||
async with self._db.session() as session:
|
||||
row = await session.get(Conversation, conv.id)
|
||||
if row is None:
|
||||
msg = f"conversation {conv.external_id} vanished"
|
||||
raise LookupError(msg)
|
||||
await apply(row)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
self._bus.publish("conversation.updated", **self.public(row))
|
||||
return row
|
||||
|
||||
def public(self, conv: Conversation) -> dict[str, Any]:
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"kind": conv.kind,
|
||||
"agent": conv.agent_name,
|
||||
"title": conv.title,
|
||||
"status": conv.status,
|
||||
"parent_row": conv.parent_id,
|
||||
"session_id": conv.session_id,
|
||||
"running_turn": conv.running_turn,
|
||||
"pending_question": conv.pending_question,
|
||||
"flags": conv.flags,
|
||||
"origin": conv.frontend,
|
||||
"created_at": iso(conv.created_at),
|
||||
"last_user_activity_at": iso(conv.last_user_activity_at),
|
||||
"last_activity_at": iso(conv.last_activity_at),
|
||||
}
|
||||
|
||||
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"] = [
|
||||
{"frontend": b.frontend, "external_id": b.external_id, "visible": b.visible}
|
||||
for b in await self.bindings(conv)
|
||||
]
|
||||
live = self._pool.get(conv.external_id)
|
||||
out["live"] = live is not None
|
||||
out["busy"] = live.busy if live is not None else False
|
||||
runner = self._runners.get(cast("int", conv.id))
|
||||
out["turn"] = runner.snapshot() if runner is not None else None
|
||||
pending = self.pending_question(conv.external_id)
|
||||
out["question"] = (
|
||||
{"id": pending[0], "questions": pending[1]} if pending else None
|
||||
)
|
||||
return out
|
||||
|
||||
def pending_question(self, key: str) -> tuple[str, list[dict[str, Any]]] | None:
|
||||
for question_id, pending in self._questions.items():
|
||||
if pending.conversation_id == key and not pending.answer.done():
|
||||
return question_id, pending.questions
|
||||
return None
|
||||
|
||||
async def rate_limits(self, *, limit: int = 100) -> list[RateLimit]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(RateLimit).order_by(col(RateLimit.id).desc()).limit(limit)
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
async def context_tokens(self, conv: Conversation) -> int:
|
||||
async with self._db.session() as session:
|
||||
row = (
|
||||
await session.exec(
|
||||
select(Usage)
|
||||
.where(Usage.conversation_id == conv.external_id)
|
||||
.order_by(col(Usage.id).desc())
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
return context_of(row)
|
||||
|
||||
async def usage_tokens(self, since: datetime) -> int:
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
await session.exec(
|
||||
select(Usage).where(
|
||||
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return sum(
|
||||
r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows
|
||||
)
|
||||
|
||||
async def busy(self, conv: Conversation) -> bool:
|
||||
row = await self.get_row(cast("int", conv.id)) or conv
|
||||
if row.running_turn or row.pending_question:
|
||||
return True
|
||||
live = self._pool.get(row.external_id)
|
||||
if live is not None and live.busy:
|
||||
return True
|
||||
pending = await self._queue.pending(cast("int", row.id))
|
||||
return any(i.priority in ("user", "urgent", "wake") for i in pending)
|
||||
|
||||
@property
|
||||
def frontends(self) -> list[Frontend]:
|
||||
return list(self._frontends)
|
||||
|
||||
def frontend(self, name: str) -> Frontend:
|
||||
for fe in self._frontends:
|
||||
if fe.name == name:
|
||||
return fe
|
||||
msg = f"unknown frontend {name!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def default_agent(self, kind: Kind) -> str | None:
|
||||
for fe in self._frontends:
|
||||
if kind in fe.kinds and (agent := fe.agent_for(kind)):
|
||||
return agent
|
||||
return None
|
||||
|
||||
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||
for fe in self._frontends:
|
||||
if conv.kind not in fe.kinds:
|
||||
continue
|
||||
binding = await fe.materialize(conv)
|
||||
if binding is not None:
|
||||
return binding
|
||||
return None
|
||||
|
||||
async def mark_closed(self, conv: Conversation) -> bool:
|
||||
marked = False
|
||||
for fe in self._frontends:
|
||||
if conv.kind in fe.kinds:
|
||||
try:
|
||||
marked = await fe.mark_closed(conv) or marked
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("%s could not mark %s", fe.name, conv.external_id)
|
||||
return marked
|
||||
|
||||
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
|
||||
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 entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||
if conv.session_id is None:
|
||||
return []
|
||||
key = {**self._store_key(conv), "subpath": subpath}
|
||||
return list(await self._store.load(cast("Any", key)) or [])
|
||||
|
||||
async def subpaths(self, conv: Conversation) -> list[str]:
|
||||
if conv.session_id is None:
|
||||
return []
|
||||
return list(await self._store.list_subkeys(cast("Any", self._store_key(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 chat_name(self, conv: Conversation) -> str:
|
||||
"""What a ``[[wikilink]]`` to the chat says: the file's stem when it has one."""
|
||||
window = await self.window_of(conv)
|
||||
if window and window.endswith(".md"):
|
||||
return window.rsplit("/", 1)[-1][: -len(".md")]
|
||||
return await self.implied_title(conv) or conv.external_id
|
||||
|
||||
async def adopt(self, *, kind: Kind, first_user_text: str) -> Conversation | None:
|
||||
"""The one unbound, session-less conversation whose history starts here."""
|
||||
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
|
||||
|
||||
|
||||
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 context_of(row: Usage | None) -> int:
|
||||
"""The last API call's input, or the per-call average for older rows."""
|
||||
if row is None:
|
||||
return 0
|
||||
if row.context_tokens:
|
||||
return row.context_tokens
|
||||
total = row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
|
||||
return round(total / max(row.num_turns or 1, 1))
|
||||
Reference in new issue
Block a user