Files
beaver-gateway/src/beaver_gateway/frontends/markdown/renderer.py
T
2026-05-20 21:30:10 +02:00

182 lines
6.2 KiB
Python

"""Render Anthropic ``Message`` (and individual user turns) into markdown.
The renderer is one-way: it produces the human-facing artifact in the
vault. The parser strips tool/thinking callouts when reshaping the file
for backend replay — so what we write here is purely for the human
reader (and for the cross-frontend logger, which materializes turns
from other frontends).
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
if TYPE_CHECKING:
from collections.abc import Iterable
from anthropic.types import MessageParam
__all__ = [
"FENCE",
"USER_SCAFFOLD",
"append_to_body",
"render_assistant_message",
"render_user_text",
"summarize_tool_input",
]
# Empty ``### User:`` block appended after each assistant reply so the
# human has an obvious place to type the next turn. Parser drops empty
# user blocks, so this doesn't re-trigger dispatch on its own.
USER_SCAFFOLD = "### User:\n"
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
# don't collide. JSON inputs use 3 backticks because they almost never
# contain ``` and we get language syntax highlighting in Obsidian for free.
FENCE = "````"
# Used when a tool input mentions a path/file we can dangle in the
# callout title — purely cosmetic.
_PATH_KEYS = ("path", "file", "filename", "url", "command", "query")
def render_user_text(content: str) -> str:
r"""Render one user-spoken turn as ``### User:\n\n<text>``."""
return f"### User:\n\n{content.strip()}\n"
def render_assistant_message(message: Message) -> str:
"""Render an assistant ``Message`` (with content blocks) into a turn block.
Blocks render in their original order:
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
* ``TextBlock`` → plain text (the spoken answer)
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
JSON quoted inside. Tool *results* are not persisted — see
module docstring on ``parser.py`` for why.
Blank lines separate adjacent blocks; trailing newline guarantees
the next ``---`` / ``### User:`` marker lands on its own line.
"""
parts: list[str] = ["### Assistant:", ""]
for block in message.content:
parts.extend(_render_block(block))
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def render_user_param(param: MessageParam) -> str:
"""Render a ``MessageParam`` user message into a ``### User:`` block.
Used by the cross-frontend logger when materializing turns from
other frontends. Tool_result blocks in the content list are dropped
silently — the markdown view doesn't track them (see ``parser.py``).
"""
content = param.get("content", "")
if isinstance(content, str):
text = content
else:
# The Anthropic SDK types ``content`` as a union of typed-dict
# *Param classes plus pydantic block models — both shapes appear
# in practice (raw incoming JSON yields dicts, SDK-built params
# yield BaseModels). Treat each entry as a dict-like and pull
# ``text`` opportunistically.
chunks = [
str(blk.get("text", ""))
for blk in content
if isinstance(blk, dict) and blk.get("type") == "text"
]
text = "\n\n".join(chunks)
return render_user_text(text)
def append_to_body(existing: str, new_block: str) -> str:
"""Append ``new_block`` to ``existing`` with a decorative HR separator.
Preserves the original body verbatim (whitespace, callouts, any
formatting the human added). The HR is purely visual: parser ignores
it.
"""
head = existing.rstrip()
if head:
return f"{head}\n\n---\n\n{new_block}"
return new_block
def summarize_tool_input(name: str, tool_input: object) -> str:
"""Build the ``[!tool]- <summary>`` title string.
Tries to pick a single salient field (``path``, ``command``, etc.)
from the input dict so the collapsed callout shows something
meaningful in Obsidian. Falls back to just the tool name.
"""
if not isinstance(tool_input, dict):
return name
# Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the
# SDK's runtime value is always a JSON dict (str→Any), so a local
# cast keeps the rest of the function readable without sprinkling
# per-line type narrowing on every ``.get`` call.
d = cast("dict[str, Any]", tool_input)
for key in _PATH_KEYS:
value = d.get(key)
if isinstance(value, str) and value:
short = value if len(value) <= 60 else value[:57] + "..."
return f"{name} · {short}"
return name
# ---- internals ---------------------------------------------------------
def _render_block(block: object) -> Iterable[str]:
if isinstance(block, TextBlock):
text = (block.text or "").strip()
if text:
yield text
return
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
if isinstance(block, ToolUseBlock):
yield from _render_tool_use(block)
return
# Unknown block type — skip silently rather than corrupting the file.
def _render_thinking(text: str) -> Iterable[str]:
yield "> [!thinking]-"
for line in text.strip().splitlines() or [""]:
yield f"> {line}" if line else ">"
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
title = summarize_tool_input(block.name, block.input)
yield f"> [!tool]- {title}"
yield "> **input:**"
yield "> ```json"
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
for line in pretty.splitlines():
yield f"> {line}" if line else ">"
yield "> ```"
def adaptive_fence(content: str) -> str:
"""Return a backtick fence at least one longer than the longest run in ``content``.
Currently unused (tool *results* aren't persisted yet) — kept here
so when result capture lands the rendering side already has the
primitive.
"""
longest = 0
for match in re.finditer(r"`+", content):
longest = max(longest, len(match.group(0)))
return "`" * max(3, longest + 1)