Merge origin/main into feat/ynab-mirror

Resolve config/db.py (artifacts + ynab models) and regenerate the YNAB
migration with aerich as 18 on top of main's 17_add_artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:07:26 -04:00
13 changed files with 1228 additions and 269 deletions
+56
View File
@@ -0,0 +1,56 @@
import uuid
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity
from blueprints.conversation.models import Conversation
from blueprints.users.decorators import jwt_access_required
from .logic import artifact_full, artifact_summary
from .models import Artifact
artifacts_blueprint = Blueprint("artifacts_api", __name__, url_prefix="/api/artifacts")
def _parse_uuid(value: str) -> uuid.UUID | None:
"""Validate ids before querying: asyncpg raises OperationalError on bad UUIDs."""
try:
return uuid.UUID(value)
except (ValueError, AttributeError, TypeError):
return None
@artifacts_blueprint.get("")
@jwt_access_required
async def list_artifacts():
user_uuid = get_jwt_identity()
raw_conversation_id = request.args.get("conversation_id", "").strip()
if not raw_conversation_id:
return jsonify({"error": "conversation_id is required"}), 400
conversation_id = _parse_uuid(raw_conversation_id)
if conversation_id is None:
return jsonify({"error": "conversation_id must be a UUID"}), 400
conversation = await Conversation.get_or_none(id=conversation_id, user_id=user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
artifacts = await Artifact.filter(
conversation_id=conversation.id, user_id=user_uuid
).order_by("created_at")
return jsonify([artifact_summary(a) for a in artifacts])
@artifacts_blueprint.get("/<artifact_id>")
@jwt_access_required
async def get_artifact(artifact_id: str):
user_uuid = get_jwt_identity()
parsed_id = _parse_uuid(artifact_id)
artifact = (
await Artifact.get_or_none(id=parsed_id, user_id=user_uuid)
if parsed_id is not None
else None
)
if artifact is None:
return jsonify({"error": "artifact not found"}), 404
return jsonify(artifact_full(artifact))
+78
View File
@@ -0,0 +1,78 @@
import json
import logging
from .models import Artifact
logger = logging.getLogger(__name__)
# Artifacts whose serialised `data` exceeds this are dropped (the tool text still persists).
ARTIFACT_MAX_BYTES = 256 * 1024
def artifact_ref(artifact: Artifact) -> dict:
"""The lightweight reference sent in SSE events and the conversation payload."""
return {"id": str(artifact.id), "kind": artifact.kind, "title": artifact.title}
def artifact_summary(artifact: Artifact) -> dict:
"""ArtifactRef plus listing metadata, without `data`."""
return {
**artifact_ref(artifact),
"tool": artifact.tool,
"message_id": str(artifact.message_id) if artifact.message_id else None,
"created_at": artifact.created_at.isoformat(),
}
def artifact_full(artifact: Artifact) -> dict:
return {
**artifact_summary(artifact),
"data": artifact.data,
"source_ref": artifact.source_ref,
"conversation_id": str(artifact.conversation_id),
}
async def create_artifact_from_output(
*, output, tool: str, user, conversation, message=None
) -> Artifact | None:
"""Persist `output.artifact` if it is a ToolArtifact dict; otherwise return None.
Tools opt in via `@tool(response_format="content_and_artifact")`. Artifacts
without a `kind` (e.g. simba_search's raw Documents) are ignored, as are
ones whose `data` is not JSON-serialisable or over ARTIFACT_MAX_BYTES.
"""
raw = getattr(output, "artifact", None)
if not isinstance(raw, dict) or not raw.get("kind"):
return None
data = raw.get("data") or {}
source_ref = raw.get("source_ref")
try:
size = len(json.dumps(data).encode("utf-8"))
json.dumps(source_ref)
except (TypeError, ValueError) as e:
logger.warning(
"Dropping %s artifact from %s: not serialisable (%s)", raw["kind"], tool, e
)
return None
if size > ARTIFACT_MAX_BYTES:
logger.warning(
"Dropping %s artifact from %s: %d bytes exceeds %d",
raw["kind"],
tool,
size,
ARTIFACT_MAX_BYTES,
)
return None
return await Artifact.create(
user=user,
conversation=conversation,
message=message,
tool=tool,
kind=str(raw["kind"])[:64],
title=str(raw.get("title") or "")[:255],
data=data,
source_ref=source_ref,
)
+29
View File
@@ -0,0 +1,29 @@
from tortoise import fields
from tortoise.models import Model
class Artifact(Model):
"""A structured tool result (recipe, budget, note…) rendered in the artifact panel."""
id = fields.UUIDField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="artifacts")
conversation = fields.ForeignKeyField(
"models.Conversation", related_name="artifacts", on_delete=fields.CASCADE
)
message = fields.ForeignKeyField(
"models.ConversationMessage",
related_name="artifacts",
null=True,
on_delete=fields.SET_NULL,
)
tool = fields.CharField(max_length=255)
kind = fields.CharField(max_length=64)
title = fields.CharField(max_length=255)
data = fields.JSONField()
source_ref = fields.JSONField(null=True)
version = fields.IntField(default=1)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "artifacts"
+55 -20
View File
@@ -10,6 +10,8 @@ from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import get_jwt_identity
import blueprints.users.models
from blueprints.artifacts.logic import artifact_ref, create_artifact_from_output
from blueprints.artifacts.models import Artifact
from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block
@@ -32,6 +34,8 @@ from .models import (
PydListConversation,
Speaker,
)
from .tool_events import tool_end_payload, tool_name_from_message, tool_start_payload
from .tool_labels import tool_label
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
@@ -340,28 +344,47 @@ async def stream_query():
if event_type == "on_tool_start":
tool_timers[event["run_id"]] = time.monotonic()
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
yield f"data: {json.dumps(tool_start_payload(event))}\n\n"
elif event_type == "on_tool_end":
elif event_type in ("on_tool_end", "on_tool_error"):
now = time.monotonic()
start = tool_timers.pop(event["run_id"], None)
tool_spans.append(
{
"tool": event["name"],
"ms": round((time.monotonic() - start) * 1000)
if start
else None,
"ms": round((now - start) * 1000) if start else None,
}
)
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
ref = None
# Persist tool output so later turns keep the fetched context
output = event.get("data", {}).get("output")
if output is not None:
await add_message_to_conversation(
if event_type == "on_tool_end" and output is not None:
tool_message = await add_message_to_conversation(
conversation=conversation,
message=tool_result_text(event["name"], output),
speaker="tool",
user=user,
)
try:
artifact = await create_artifact_from_output(
output=output,
tool=event["name"],
user=user,
conversation=conversation,
message=tool_message,
)
except Exception as e:
logging.error(
f"Failed to save artifact from {event['name']}: {e}"
)
artifact = None
if artifact is not None:
ref = artifact_ref(artifact)
status = "error" if event_type == "on_tool_error" else "ok"
end_payload = tool_end_payload(
event, start, ref, now=now, status=status
)
yield f"data: {json.dumps(end_payload)}\n\n"
elif event_type == "on_chat_model_start":
turn_has_content = False
@@ -453,22 +476,34 @@ async def get_conversation(conversation_id: str):
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.fetch_related("messages")
# Keyed by str so the match doesn't depend on the driver's UUID types.
artifacts_by_message = {
str(a.message_id): a
for a in await Artifact.filter(
conversation_id=conversation.id, message_id__isnull=False
).order_by("created_at")
}
# Manually serialize the conversation with messages
messages = []
for msg in conversation.messages:
# Tool messages hold raw fetched context for the LLM; only surface
# their header line (e.g. "[web_search result]") in the UI.
text = msg.text.split("\n", 1)[0] if msg.speaker == Speaker.TOOL else msg.text
messages.append(
{
"id": str(msg.id),
"text": text,
"speaker": msg.speaker.value,
"created_at": msg.created_at.isoformat(),
"image_key": msg.image_key,
}
)
item = {
"id": str(msg.id),
"text": msg.text,
"speaker": msg.speaker.value,
"created_at": msg.created_at.isoformat(),
"image_key": msg.image_key,
}
if msg.speaker == Speaker.TOOL:
# Tool messages hold raw fetched context for the LLM; only surface
# their header line (e.g. "[web_search result]") in the UI.
item["text"] = msg.text.split("\n", 1)[0]
tool = tool_name_from_message(msg.text)
artifact = artifacts_by_message.get(str(msg.id))
item["tool"] = tool
item["label"] = tool_label(tool)
item["artifact"] = artifact_ref(artifact) if artifact else None
messages.append(item)
return jsonify(
{
"id": str(conversation.id),
+77
View File
@@ -0,0 +1,77 @@
"""Pure shaping of LangChain tool events into SSE `tool_start` / `tool_end` payloads."""
import json
import re
from .tool_labels import tool_label
INPUT_VALUE_MAX_CHARS = 200
# Injected by LangChain rather than chosen by the model; never shown to the client.
_HIDDEN_INPUT_KEYS = frozenset({"config", "callbacks", "run_manager"})
_TOOL_HEADER_RE = re.compile(r"^\[(\S+) result\]")
def _truncate(text: str) -> str:
if len(text) > INPUT_VALUE_MAX_CHARS:
return text[: INPUT_VALUE_MAX_CHARS - 1] + "…"
return text
def summarize_tool_input(raw) -> dict[str, str | int | float | bool]:
"""Reduce a tool's input to flat, display-safe scalars."""
if raw is None:
return {}
if not isinstance(raw, dict):
return {"input": _truncate(str(raw))}
summary: dict[str, str | int | float | bool] = {}
for key, value in raw.items():
if key in _HIDDEN_INPUT_KEYS or value is None:
continue
if isinstance(value, (bool, int, float)):
summary[key] = value
elif isinstance(value, str):
summary[key] = _truncate(value)
else:
try:
summary[key] = _truncate(json.dumps(value, default=str))
except (TypeError, ValueError):
summary[key] = _truncate(str(value))
return summary
def tool_start_payload(event: dict) -> dict:
name = event["name"]
return {
"type": "tool_start",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"input": summarize_tool_input(event.get("data", {}).get("input")),
}
def tool_end_payload(
event: dict,
started_at: float | None,
artifact_ref: dict | None,
*,
now: float,
status: str = "ok",
) -> dict:
name = event["name"]
return {
"type": "tool_end",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"status": status,
"ms": round((now - started_at) * 1000) if started_at is not None else 0,
"artifact": artifact_ref,
}
def tool_name_from_message(text: str) -> str:
"""Recover the tool name from a persisted `[tool_name result]` header."""
match = _TOOL_HEADER_RE.match(text)
return match.group(1) if match else "tool"
+64
View File
@@ -0,0 +1,64 @@
"""Friendly, user-facing labels for agent tool calls (shown in the activity timeline)."""
TOOL_LABELS: dict[str, str] = {
"get_current_date": "Checking the date…",
"web_search": "Searching the web…",
"simba_search": "Searching household documents…",
"save_user_memory": "Saving to memory…",
# YNAB (current names)
"ynab_budget_summary": "Checking budget…",
"ynab_search_transactions": "Searching transactions…",
"ynab_category_spending": "Reviewing category spending…",
"ynab_category_allocations": "Reviewing category budgets…",
"ynab_insights": "Analyzing spending trends…",
# YNAB (names after the #55 mirror)
"ynab_account_balances": "Checking account balances…",
"ynab_spending_report": "Building spending report…",
"ynab_category_budget": "Reviewing category budgets…",
"ynab_recent_changes": "Checking recent budget changes…",
"ynab_sql": "Querying budget data…",
# Mealie
"mealie_todays_meals": "Checking today's meals…",
"mealie_meal_plans": "Checking the meal plan…",
"mealie_get_recipe": "Fetching recipe…",
"mealie_shopping_lists": "Checking shopping lists…",
"mealie_create_meal_plan": "Adding to the meal plan…",
# Obsidian / journal
"obsidian_search_notes": "Searching notes…",
"obsidian_read_note": "Reading note…",
"obsidian_create_note": "Creating note…",
"obsidian_create_task": "Creating task…",
"journal_get_today": "Reading today's journal…",
"journal_get_tasks": "Checking tasks…",
"journal_add_task": "Adding task…",
"journal_complete_task": "Completing task…",
# Google
"get_calendar_events": "Checking calendar…",
"create_calendar_event": "Creating calendar event…",
"gmail_search_emails": "Searching email…",
"gmail_get_email": "Reading email…",
# SimbaKit
"simbakit_device_status": "Checking Simba's devices…",
"simbakit_weight_history": "Checking Simba's weight…",
"simbakit_litter_activity": "Checking litter box activity…",
"simbakit_drinking_activity": "Checking water fountain activity…",
# Beli
"beli_search_restaurants": "Searching restaurants…",
}
# MCP tools are discovered at runtime, so they match by name prefix.
TOOL_PREFIX_LABELS: tuple[tuple[str, str], ...] = (
("homebox_", "Checking home inventory…"),
("bookstack_", "Checking the wiki…"),
)
def tool_label(tool_name: str) -> str:
"""Return the friendly label for a tool, falling back to "Using <tool>…"."""
label = TOOL_LABELS.get(tool_name)
if label:
return label
for prefix, prefix_label in TOOL_PREFIX_LABELS:
if tool_name.startswith(prefix):
return prefix_label
return f"Using {tool_name}…"