Files
beaver-gateway/src/beaver_gateway/frontends/markdown/crossfront.py
T

307 lines
12 KiB
Python

"""Cross-frontend chat logger.
When ``MarkdownFrontend(log_all_chats=True)`` is configured, every turn
completed by any other frontend (currently the Anthropic Messages
frontend) is mirrored into the vault as a ``.md`` file. Subsequent
turns of the same conversation append to the same file — matched by a
content-hash fingerprint stored in YAML frontmatter.
The fingerprint hashes the message history *before* the new assistant
reply. So the next request's input history (which now includes the
prior assistant reply) hashes to the value we just persisted —
``hash(prev_input + [assistant_reply])`` — and the lookup hits the
same file. New conversations (no prior fingerprint match) get a fresh
file under ``{vault_path}/{logged_subdir}/{agent_name}/``.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import frontmatter
from beaver_gateway.frontends.markdown import renderer
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from pathlib import Path
from anthropic.types import MessageParam
from beaver_gateway.core.turn_record import TurnRecord
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
__all__ = [
"ChatPathFn",
"CrossFrontendLogger",
"fingerprint_messages",
"strip_trailing_user_scaffold",
]
ChatPathFn = "Callable[[str, str, Path], Path]"
"""``(title, agent, vault) -> path`` of a new chat file; relative = under the
vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``."""
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
"""Stable, short hex hash of a conversation prefix.
Built from ``(role, normalized_content)`` pairs only — so the
Markdown frontend's parser-shaped messages (text-only) and the
Anthropic frontend's raw ``messages`` payload (which may also be
string-only at v1) hash compatibly when they represent the same
conversation. Tool blocks / images would diverge, but those aren't
in the v1 ingest path.
"""
h = hashlib.sha1(usedforsecurity=False)
for msg in messages:
role = str(msg.get("role", ""))
content = msg.get("content", "")
if isinstance(content, str):
text = content
else:
parts: list[str] = []
for blk in content:
if not isinstance(blk, dict):
continue
btype = blk.get("type")
if btype == "text":
parts.append(str(blk.get("text", "")))
text = "\n".join(parts)
h.update(role.encode("utf-8"))
h.update(b"\x00")
h.update(text.strip().encode("utf-8"))
h.update(b"\x01")
return h.hexdigest()[:16]
class CrossFrontendLogger:
"""Maintains the fingerprint→file map and writes turns to disk.
The map is in-process; on startup ``warm_index`` rebuilds it from
YAML frontmatter of every file under ``logged_subdir``. A miss
creates a new file, a hit appends to the existing one. All disk
work funnels through one ``asyncio.Lock`` because the writes are
cheap and serializing them sidesteps a class of races we don't need
to think about.
"""
def __init__(
self,
*,
vault_path: Path,
logged_subdir: str,
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
self._vault = vault_path
self._root = vault_path / logged_subdir
self._index: dict[str, Path] = {}
self._lock = asyncio.Lock()
self._chat_path_fn = chat_path
# When the user supplies a custom path function, files can land
# anywhere in the vault — so we have to scan the whole vault on
# startup to rebuild the fingerprint→path map. With the default
# layout we can bound the scan to ``_logs/``.
self._scan_root = vault_path if chat_path is not None else self._root
def warm_index(self) -> None:
"""Scan logged files synchronously, populating the fingerprint map.
Called from ``MarkdownFrontend.configure`` so the map is ready
before any cross-frontend turn arrives. ``frontmatter.load``
reads only enough of the file to parse the YAML head, so the
scan is cheap even on large vaults — but a custom ``log_path``
forces a full-vault walk; mention that in the constructor doc.
"""
if not self._scan_root.exists():
return
for path in self._scan_root.rglob("*.md"):
try:
post = frontmatter.load(str(path))
except Exception: # noqa: BLE001
_log.warning("could not read frontmatter from %s — skipping", path)
continue
fp = post.metadata.get("fingerprint")
if isinstance(fp, str) and fp:
self._index[fp] = path
_log.info(
"crossfront index warmed: %d logged file(s) under %s",
len(self._index),
self._scan_root,
)
async def handle(self, record: TurnRecord) -> None:
"""Append or create a logged file for ``record``.
Records that the markdown frontend itself produced
(``source=="markdown"``) are skipped — those already live in the
user's hand-written file and shouldn't be duplicated into the
``_logs`` shadow tree.
"""
if record.source == "markdown":
return
async with self._lock:
# ``input_messages`` is the *full* history sent to the backend
# (last entry is the new user turn). Match against the prefix
# that excludes the new user turn — that's what the previous
# write stored as its fingerprint. Empty prefix is the
# well-known "brand new chat" sentinel.
prefix = record.input_messages[:-1]
prev_fp = fingerprint_messages(prefix) if prefix else None
target = self._index.get(prev_fp) if prev_fp else None
if target is None:
target = self._new_file_path(record)
# Build the full history including the assistant reply; the
# new fingerprint matches *that* prefix, so the next user
# turn (history grows by one user msg) will hit this file.
assistant_msg: MessageParam = {
"role": "assistant",
"content": _flatten_text(record.output_message),
}
full_history = [*record.input_messages, assistant_msg]
new_fp = fingerprint_messages(full_history)
if target.exists():
existing = target.read_text(encoding="utf-8")
parsed = frontmatter.loads(existing)
body = strip_trailing_user_scaffold(parsed.content)
# We append only the *new* user turn (the last one in
# input_messages, since prior turns are already on disk)
# plus the assistant reply.
new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user)
new_block = renderer.append_to_body(
new_block, renderer.render_assistant_message(record.output_message)
)
new_body = renderer.append_to_body(body, new_block)
metadata = dict(parsed.metadata)
else:
# Materialize the whole conversation from scratch.
new_body = _render_full_history(
record.input_messages, record.output_message
)
metadata = {}
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
metadata["agent"] = record.agent_name
metadata["fingerprint"] = new_fp
metadata["source"] = record.source
self._write(target, metadata, new_body)
# Maintain the index: drop the old fp (it's stale once we
# write the new turn), add the new one.
if prev_fp:
self._index.pop(prev_fp, None)
self._index[new_fp] = target
# ---- internals -----------------------------------------------------
def _new_file_path(self, record: TurnRecord) -> Path:
"""Pick a fresh filename for a brand-new conversation.
With a user-supplied ``chat_path`` we delegate to it (joining a
relative result with the vault root). Without one, we fall back
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
``.md`` suffix in case the user picks a non-md extension by hand.
"""
if self._chat_path_fn is not None:
result = self._chat_path_fn(
record.first_user_text, record.agent_name, self._vault
)
if not result.is_absolute():
result = self._vault / result
if result.suffix != ".md":
result = result.with_suffix(".md")
result.parent.mkdir(parents=True, exist_ok=True)
return result
day = datetime.now(UTC).strftime("%Y-%m-%d")
# Short hex from the input hash so two same-day chats sort
# stably and don't collide.
salt = fingerprint_messages(record.input_messages)[:8]
agent_dir = self._root / record.agent_name
agent_dir.mkdir(parents=True, exist_ok=True)
return agent_dir / f"{day}_{salt}.md"
def _write(self, path: Path, metadata: dict[str, Any], body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if metadata:
post = frontmatter.Post(content=body, **metadata)
text = frontmatter.dumps(post) + "\n"
else:
text = body if body.endswith("\n") else body + "\n"
# Sync write inside the lock — keeps the implementation tiny;
# individual logged turns are small enough that the blocking
# write doesn't matter at human conversation rates.
path.write_text(text, encoding="utf-8")
def _flatten_text(message: Any) -> str:
"""Same as ``frontend._flatten_assistant_text`` but local to break a cycle."""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
"""Render an entire conversation (used when materializing a new logged file)."""
blocks: list[str] = []
for msg in messages:
role = msg.get("role")
if role == "user":
blocks.append(renderer.render_user_param(msg))
elif role == "assistant":
content = msg.get("content", "")
text = content if isinstance(content, str) else _content_to_text(content)
blocks.append(f"### Assistant:\n\n{text.strip()}\n" if text.strip() else "")
blocks.append(renderer.render_assistant_message(assistant))
body = ""
for block in blocks:
if not block:
continue
body = renderer.append_to_body(body, block)
return body
def strip_trailing_user_scaffold(body: str) -> str:
"""Drop a trailing empty ``### User:`` block if present.
Cross-frontend turns aren't typed into the file by the human — they
arrive whole from another frontend. If we leave the previous run's
scaffold in place, we'd write the new user turn right after an
empty marker (visual noise, two ``### User:`` headers in a row).
Trim it and let the append flow add a fresh scaffold at the end.
"""
stripped = body.rstrip()
marker = "### User:"
if not stripped.endswith(marker):
return body
# Walk back: the scaffold is the marker preceded by either start-of-file
# or an HR/blank line. Find the last newline before the marker, cut.
head = stripped[: -len(marker)].rstrip()
if head.endswith("---"):
head = head[: -len("---")].rstrip()
return head
def _content_to_text(content: Any) -> str:
if not isinstance(content, list):
return ""
chunks = [
str(blk.get("text", ""))
for blk in content
if isinstance(blk, dict) and blk.get("type") == "text"
]
return "\n\n".join(chunks)