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:
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -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}…"
|
||||
Reference in New Issue
Block a user