refactor: add markdown frontend
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
"""``MarkdownFrontend`` — chat-via-markdown-files frontend.
|
||||
|
||||
Wires:
|
||||
|
||||
* ``POST /chat {filename, content?, agent?}`` — bearer-authenticated
|
||||
trigger. The plugin in Obsidian fires this after the user edits a
|
||||
``.md`` and the file gets synced (or with ``content`` to short-circuit
|
||||
the sync delay). We parse the file, check the last turn — assistant
|
||||
→ no-op, user → run the agent and append.
|
||||
* ``GET /healthz`` — liveness.
|
||||
|
||||
Concurrency model: an in-memory ``set[Path]`` of files currently in
|
||||
flight. Two concurrent requests for the same file → the second gets
|
||||
409. The set is single-process (one gateway instance) — that's by
|
||||
design; the markdown frontend is the only writer in its vault from
|
||||
the gateway side.
|
||||
|
||||
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
|
||||
registers a handler on ``runtime.turn_log_handlers`` so every other
|
||||
frontend's completed turns also land in the vault. The handler logic
|
||||
lives in :mod:`.crossfront` so this module stays focused on the HTTP
|
||||
shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiofile
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
from beaver_gateway.frontends._accumulate import accumulate
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.frontends.markdown import parser, renderer
|
||||
from beaver_gateway.frontends.markdown.crossfront import (
|
||||
CrossFrontendLogger,
|
||||
fingerprint_messages,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.markdown")
|
||||
|
||||
|
||||
__all__ = ["MarkdownFrontend"]
|
||||
|
||||
|
||||
class MarkdownFrontend(Frontend):
|
||||
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vault_path: Path | str,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8003,
|
||||
default_agent: str | None = None,
|
||||
log_all_chats: bool = False,
|
||||
logged_subdir: str = "_logs",
|
||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
||||
public_base_url: str | None = None,
|
||||
) -> None:
|
||||
self.vault_path = Path(vault_path).expanduser().resolve()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.default_agent = default_agent
|
||||
self.log_all_chats = log_all_chats
|
||||
self.logged_subdir = logged_subdir
|
||||
self.log_path = log_path
|
||||
# External URL prefix when behind a reverse proxy — same role as
|
||||
# on the other bearer frontends. Trailing slash trimmed for
|
||||
# idempotent concatenation; ``None`` means "no proxy / advertise
|
||||
# raw host:port".
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
# Files currently being processed by an in-flight ``POST /chat``.
|
||||
# Checked-and-added atomically in the request handler (no
|
||||
# ``await`` between the check and the insert) so a concurrent
|
||||
# request reliably loses the race to 409.
|
||||
self._busy: set[Path] = set()
|
||||
self._crossfront: CrossFrontendLogger | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||||
if self.log_all_chats:
|
||||
self._crossfront = CrossFrontendLogger(
|
||||
vault_path=self.vault_path,
|
||||
logged_subdir=self.logged_subdir,
|
||||
log_path=self.log_path,
|
||||
)
|
||||
# Scan the existing logged files synchronously here so the
|
||||
# fingerprint→path map is populated before the first
|
||||
# cross-frontend turn arrives. Cheap: frontmatter-only read.
|
||||
self._crossfront.warm_index()
|
||||
runtime.turn_log_handlers.append(self._crossfront.handle)
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
# ---- app builder ---------------------------------------------------
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
|
||||
app = FastAPI(title="beaver-gateway / Markdown")
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(request: Request) -> Any:
|
||||
token_name = await require_token(request, runtime, scope="messages")
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
|
||||
) from exc
|
||||
|
||||
filename = body.get("filename")
|
||||
if not isinstance(filename, str) or not filename.strip():
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "missing or non-string `filename`"
|
||||
)
|
||||
content_override = body.get("content")
|
||||
agent_override = body.get("agent")
|
||||
if agent_override is not None and not isinstance(agent_override, str):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "`agent` must be a string"
|
||||
)
|
||||
|
||||
file_path = self._resolve_path(filename)
|
||||
|
||||
# Atomic check-and-claim: both ops run between awaits, so a
|
||||
# second request can't slip into the same file slot.
|
||||
if file_path in self._busy:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"status": "in_progress", "filename": filename},
|
||||
)
|
||||
self._busy.add(file_path)
|
||||
try:
|
||||
return await self._handle_chat(
|
||||
runtime=runtime,
|
||||
token_name=token_name,
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
content_override=content_override,
|
||||
agent_override=agent_override,
|
||||
)
|
||||
finally:
|
||||
self._busy.discard(file_path)
|
||||
|
||||
return app
|
||||
|
||||
# ---- dispatch ------------------------------------------------------
|
||||
|
||||
async def _handle_chat(
|
||||
self,
|
||||
*,
|
||||
runtime: GatewayRuntime,
|
||||
token_name: str,
|
||||
filename: str,
|
||||
file_path: Path,
|
||||
content_override: Any,
|
||||
agent_override: str | None,
|
||||
) -> Any:
|
||||
if isinstance(content_override, str):
|
||||
await _write_atomic(file_path, content_override)
|
||||
file_text = content_override
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
|
||||
)
|
||||
|
||||
parsed = parser.parse(file_text)
|
||||
agent_name = parser.resolve_agent(
|
||||
metadata=parsed.metadata,
|
||||
request_override=agent_override,
|
||||
default=self.default_agent,
|
||||
)
|
||||
if not agent_name:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"no agent specified: pass `agent`, set frontmatter, "
|
||||
"or configure `default_agent`",
|
||||
)
|
||||
|
||||
# When the parser produced no messages (file empty / only
|
||||
# frontmatter), there's nothing to dispatch.
|
||||
if not parsed.messages:
|
||||
return {"status": "nothing_to_do", "reason": "empty file"}
|
||||
|
||||
if parser.last_role(parsed.messages) == "assistant":
|
||||
return {"status": "nothing_to_do", "reason": "last turn is assistant"}
|
||||
|
||||
agent = runtime.agents.get(agent_name)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"unknown agent: {agent_name!r}"
|
||||
)
|
||||
backend = runtime.backends.get(agent.name)
|
||||
if backend is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"no backend configured for agent {agent.name!r}",
|
||||
)
|
||||
|
||||
_log.info(
|
||||
"chat: actor=%s agent=%s file=%s msgs=%d",
|
||||
token_name,
|
||||
agent.name,
|
||||
filename,
|
||||
len(parsed.messages),
|
||||
)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token_name}",
|
||||
kind="markdown_chat",
|
||||
agent_name=agent.name,
|
||||
filename=filename,
|
||||
msgs=len(parsed.messages),
|
||||
)
|
||||
|
||||
try:
|
||||
events = backend.complete(
|
||||
agent=agent, messages=parsed.messages, system=None
|
||||
)
|
||||
message = await accumulate(events, model=agent.model or agent.name)
|
||||
except Exception as exc:
|
||||
_log.exception("backend failed for %s", filename)
|
||||
error_block = _render_error_block(exc)
|
||||
new_body = renderer.append_to_body(parsed.body, error_block)
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR, f"backend error: {exc}"
|
||||
) from exc
|
||||
|
||||
rendered = renderer.render_assistant_message(message)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
|
||||
# Recompute fingerprint so a future cross-frontend hit on this
|
||||
# same conversation can find it. Stored as hex string in
|
||||
# frontmatter — only the markdown frontend reads it.
|
||||
assistant_param: MessageParam = {
|
||||
"role": "assistant",
|
||||
"content": _flatten_assistant_text(message),
|
||||
}
|
||||
updated_messages: list[MessageParam] = [*parsed.messages, assistant_param]
|
||||
updated_metadata = dict(parsed.metadata)
|
||||
updated_metadata["agent"] = agent.name
|
||||
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(updated_metadata, new_body)
|
||||
)
|
||||
|
||||
# Broadcast our own turn so other handlers (none today, but the
|
||||
# symmetry is worth keeping) see what happened. ``source`` marks
|
||||
# the origin so ``CrossFrontendLogger`` can skip its own files.
|
||||
record = TurnRecord(
|
||||
agent_name=agent.name,
|
||||
input_messages=list(parsed.messages),
|
||||
output_message=message,
|
||||
system=None,
|
||||
source="markdown",
|
||||
)
|
||||
for handler in runtime.turn_log_handlers:
|
||||
try:
|
||||
await handler(record)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("turn_log_handler raised; continuing")
|
||||
|
||||
return {"status": "ok", "turns_appended": 1, "agent": agent.name}
|
||||
|
||||
# ---- helpers -------------------------------------------------------
|
||||
|
||||
def _resolve_path(self, filename: str) -> Path:
|
||||
"""Resolve ``filename`` under the vault; reject escapes."""
|
||||
# ``filename`` may be relative or absolute; we always anchor
|
||||
# under ``vault_path`` so absolute paths from outside the vault
|
||||
# don't sneak through. ``Path("/foo/bar")`` combined with a
|
||||
# vault path keeps the absolute side; we strip leading slashes
|
||||
# to coerce the rooted form into a relative path before joining.
|
||||
rel = filename.lstrip("/")
|
||||
if not rel.endswith(".md"):
|
||||
rel = rel + ".md"
|
||||
candidate = (self.vault_path / rel).resolve()
|
||||
try:
|
||||
candidate.relative_to(self.vault_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"filename escapes vault: {filename!r}"
|
||||
) from exc
|
||||
return candidate
|
||||
|
||||
|
||||
# ---- module-level utilities ----------------------------------------------
|
||||
|
||||
|
||||
async def _read_or_empty(path: Path) -> str:
|
||||
"""Return file contents, or empty string if the file doesn't exist."""
|
||||
# ``path.exists()`` here is a metadata stat — microseconds — and
|
||||
# gating an async read on whether the file is there is exactly the
|
||||
# check we want. Switching to anyio.Path / aiofiles.os just to
|
||||
# silence the async-pathlib lint would cost a dep edge for no
|
||||
# practical win.
|
||||
if not path.exists(): # noqa: ASYNC240
|
||||
return ""
|
||||
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
|
||||
return await f.read()
|
||||
|
||||
|
||||
async def _write_atomic(path: Path, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` via tmp + ``os.replace`` (atomic)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ``NamedTemporaryFile`` keeps the file open which complicates
|
||||
# ``os.replace`` on some platforms. Build the tmp name manually,
|
||||
# write+fsync, then rename. Same-directory so the rename is atomic.
|
||||
tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||
)
|
||||
fd, tmp_path = tmp_name
|
||||
try:
|
||||
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
|
||||
await f.write(content)
|
||||
os.close(fd)
|
||||
# ``os.replace`` is the atomic primitive — ``Path.replace`` is a
|
||||
# thin wrapper around the same syscall; either works, ``os.`` is
|
||||
# the one Linux/POSIX docs reach for.
|
||||
os.replace(tmp_path, path) # noqa: PTH105
|
||||
except BaseException:
|
||||
# Cleanup on failure: close fd, remove tmp.
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(fd)
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path) # noqa: PTH108
|
||||
raise
|
||||
|
||||
|
||||
def _reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
|
||||
r"""Re-emit a ``.md`` file with YAML frontmatter at the top.
|
||||
|
||||
Empty metadata → no frontmatter block (avoid littering every file
|
||||
with a hollow ``---\n---``).
|
||||
"""
|
||||
if not metadata:
|
||||
return body if body.endswith("\n") else body + "\n"
|
||||
import frontmatter as _fm
|
||||
|
||||
post = _fm.Post(content=body, **metadata)
|
||||
return _fm.dumps(post) + "\n"
|
||||
|
||||
|
||||
def _flatten_assistant_text(message: Any) -> str:
|
||||
"""Pull all text blocks from an assistant ``Message`` and join them.
|
||||
|
||||
Used when we need the assistant content as a plain string for
|
||||
fingerprinting / equality with a parser-shaped history (parser
|
||||
already drops thinking + tool_use from assistant turns).
|
||||
"""
|
||||
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_error_block(exc: BaseException) -> str:
|
||||
"""Render a backend failure as an Assistant turn with a ``[!error]-`` callout."""
|
||||
msg = str(exc) or exc.__class__.__name__
|
||||
safe = msg.replace("\n", " ").strip()
|
||||
return f"### Assistant:\n\n> [!error]-\n> {safe}\n"
|
||||
Reference in New Issue
Block a user