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:
@@ -16,6 +16,7 @@ from quart import (
|
||||
from quart_jwt_extended import JWTManager, get_jwt_identity
|
||||
from tortoise import Tortoise
|
||||
|
||||
import blueprints.artifacts
|
||||
import blueprints.conversation
|
||||
import blueprints.conversation.logic
|
||||
import blueprints.email
|
||||
@@ -73,6 +74,7 @@ app.register_blueprint(blueprints.imessage.imessage_blueprint)
|
||||
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
|
||||
app.register_blueprint(blueprints.cron.cron_blueprint)
|
||||
app.register_blueprint(blueprints.beli.beli_blueprint)
|
||||
app.register_blueprint(blueprints.artifacts.artifacts_blueprint)
|
||||
|
||||
|
||||
async def _obsidian_sync_loop():
|
||||
|
||||
@@ -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}…"
|
||||
@@ -19,6 +19,7 @@ TORTOISE_CONFIG = {
|
||||
"blueprints.scheduled_messages.models",
|
||||
"blueprints.cron.models",
|
||||
"blueprints.beli.models",
|
||||
"blueprints.artifacts.models",
|
||||
"blueprints.ynab.models",
|
||||
"aerich.models",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "artifacts" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"tool" VARCHAR(255) NOT NULL,
|
||||
"kind" VARCHAR(64) NOT NULL,
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"source_ref" JSONB,
|
||||
"version" INT NOT NULL DEFAULT 1,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"conversation_id" UUID NOT NULL REFERENCES "conversations" ("id") ON DELETE CASCADE,
|
||||
"message_id" UUID REFERENCES "conversation_messages" ("id") ON DELETE SET NULL,
|
||||
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "artifacts" IS 'A structured tool result (recipe, budget, note…) rendered in the artifact panel.';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "artifacts";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
"D1XXXUg="
|
||||
)
|
||||
@@ -1,249 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "ynab_accounts" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"type" VARCHAR(50) NOT NULL,
|
||||
"on_budget" BOOL NOT NULL,
|
||||
"closed" BOOL NOT NULL,
|
||||
"note" TEXT,
|
||||
"balance" DECIMAL(15,3) NOT NULL,
|
||||
"cleared_balance" DECIMAL(15,3) NOT NULL,
|
||||
"uncleared_balance" DECIMAL(15,3) NOT NULL,
|
||||
"last_reconciled_at" TIMESTAMPTZ,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_categories" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"category_group_id" VARCHAR(36) NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"hidden" BOOL NOT NULL,
|
||||
"internal" BOOL NOT NULL DEFAULT False,
|
||||
"note" TEXT,
|
||||
"goal_type" VARCHAR(20),
|
||||
"goal_target" DECIMAL(15,3),
|
||||
"goal_target_month" DATE,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_catego_categor_2fc4cc" ON "ynab_categories" ("category_group_id");
|
||||
CREATE TABLE IF NOT EXISTS "ynab_category_groups" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"hidden" BOOL NOT NULL,
|
||||
"internal" BOOL NOT NULL DEFAULT False,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_changes" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"synced_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"server_knowledge" BIGINT NOT NULL,
|
||||
"entity_type" VARCHAR(30) NOT NULL,
|
||||
"entity_id" VARCHAR(64) NOT NULL,
|
||||
"action" VARCHAR(10) NOT NULL,
|
||||
"label" VARCHAR(500) NOT NULL,
|
||||
"changes" JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_synced__77ae75" ON "ynab_changes" ("synced_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__c12b5b" ON "ynab_changes" ("entity_type");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__9fb442" ON "ynab_changes" ("entity_id");
|
||||
COMMENT ON TABLE "ynab_changes" IS 'Append-only log of what each sync changed, built by diffing against the mirror.';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_months" (
|
||||
"id" VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||
"month" DATE NOT NULL,
|
||||
"note" TEXT,
|
||||
"income" DECIMAL(15,3) NOT NULL,
|
||||
"budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"activity" DECIMAL(15,3) NOT NULL,
|
||||
"to_be_budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"age_of_money" INT,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_months_month_483dcb" ON "ynab_months" ("month");
|
||||
CREATE TABLE IF NOT EXISTS "ynab_month_categories" (
|
||||
"id" VARCHAR(47) NOT NULL PRIMARY KEY,
|
||||
"month" DATE NOT NULL,
|
||||
"category_id" VARCHAR(36) NOT NULL,
|
||||
"budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"activity" DECIMAL(15,3) NOT NULL,
|
||||
"balance" DECIMAL(15,3) NOT NULL,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_month__month_f13a79" ON "ynab_month_categories" ("month");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_month__categor_a62299" ON "ynab_month_categories" ("category_id");
|
||||
COMMENT ON TABLE "ynab_month_categories" IS 'Per-month category amounts. ``budgeted`` is what YNAB calls \"Assigned\".';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_payees" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(500) NOT NULL,
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_subtransactions" (
|
||||
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"transaction_id" VARCHAR(64) NOT NULL,
|
||||
"amount" DECIMAL(15,3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"payee_id" VARCHAR(36),
|
||||
"category_id" VARCHAR(36),
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_transac_0571ba" ON "ynab_subtransactions" ("transaction_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_categor_c16c94" ON "ynab_subtransactions" ("category_id");
|
||||
COMMENT ON TABLE "ynab_subtransactions" IS 'Lines of a split transaction; the parent''s category is \"Split\".';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_sync_state" (
|
||||
"budget_id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"budget_name" VARCHAR(255),
|
||||
"server_knowledge" BIGINT,
|
||||
"last_synced_at" TIMESTAMPTZ,
|
||||
"last_full_sync_at" TIMESTAMPTZ,
|
||||
"last_error" TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_transactions" (
|
||||
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"date" DATE NOT NULL,
|
||||
"amount" DECIMAL(15,3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"cleared" VARCHAR(20) NOT NULL,
|
||||
"approved" BOOL NOT NULL,
|
||||
"flag_color" VARCHAR(20),
|
||||
"account_id" VARCHAR(36) NOT NULL,
|
||||
"payee_id" VARCHAR(36),
|
||||
"category_id" VARCHAR(36),
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"import_payee_name" VARCHAR(500),
|
||||
"account_name" VARCHAR(255),
|
||||
"payee_name" VARCHAR(500),
|
||||
"category_name" VARCHAR(255),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_date_a71ead" ON "ynab_transactions" ("date");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_account_414dfe" ON "ynab_transactions" ("account_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_payee_i_4146bc" ON "ynab_transactions" ("payee_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_categor_232d47" ON "ynab_transactions" ("category_id");
|
||||
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
|
||||
DROP TABLE IF EXISTS "ynab_payees";
|
||||
DROP TABLE IF EXISTS "ynab_sync_state";
|
||||
DROP TABLE IF EXISTS "ynab_subtransactions";
|
||||
DROP TABLE IF EXISTS "ynab_changes";
|
||||
DROP TABLE IF EXISTS "ynab_categories";
|
||||
DROP TABLE IF EXISTS "ynab_month_categories";
|
||||
DROP TABLE IF EXISTS "ynab_accounts";
|
||||
DROP TABLE IF EXISTS "ynab_transactions";
|
||||
DROP TABLE IF EXISTS "ynab_months";
|
||||
DROP TABLE IF EXISTS "ynab_category_groups";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "ynab_accounts" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"type" VARCHAR(50) NOT NULL,
|
||||
"on_budget" BOOL NOT NULL,
|
||||
"closed" BOOL NOT NULL,
|
||||
"note" TEXT,
|
||||
"balance" DECIMAL(15,3) NOT NULL,
|
||||
"cleared_balance" DECIMAL(15,3) NOT NULL,
|
||||
"uncleared_balance" DECIMAL(15,3) NOT NULL,
|
||||
"last_reconciled_at" TIMESTAMPTZ,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_categories" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"category_group_id" VARCHAR(36) NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"hidden" BOOL NOT NULL,
|
||||
"internal" BOOL NOT NULL DEFAULT False,
|
||||
"note" TEXT,
|
||||
"goal_type" VARCHAR(20),
|
||||
"goal_target" DECIMAL(15,3),
|
||||
"goal_target_month" DATE,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_catego_categor_2fc4cc" ON "ynab_categories" ("category_group_id");
|
||||
CREATE TABLE IF NOT EXISTS "ynab_category_groups" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"hidden" BOOL NOT NULL,
|
||||
"internal" BOOL NOT NULL DEFAULT False,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_changes" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"synced_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"server_knowledge" BIGINT NOT NULL,
|
||||
"entity_type" VARCHAR(30) NOT NULL,
|
||||
"entity_id" VARCHAR(64) NOT NULL,
|
||||
"action" VARCHAR(10) NOT NULL,
|
||||
"label" VARCHAR(500) NOT NULL,
|
||||
"changes" JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_synced__77ae75" ON "ynab_changes" ("synced_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__c12b5b" ON "ynab_changes" ("entity_type");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__9fb442" ON "ynab_changes" ("entity_id");
|
||||
COMMENT ON TABLE "ynab_changes" IS 'Append-only log of what each sync changed, built by diffing against the mirror.';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_months" (
|
||||
"id" VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||
"month" DATE NOT NULL,
|
||||
"note" TEXT,
|
||||
"income" DECIMAL(15,3) NOT NULL,
|
||||
"budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"activity" DECIMAL(15,3) NOT NULL,
|
||||
"to_be_budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"age_of_money" INT,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_months_month_483dcb" ON "ynab_months" ("month");
|
||||
CREATE TABLE IF NOT EXISTS "ynab_month_categories" (
|
||||
"id" VARCHAR(47) NOT NULL PRIMARY KEY,
|
||||
"month" DATE NOT NULL,
|
||||
"category_id" VARCHAR(36) NOT NULL,
|
||||
"budgeted" DECIMAL(15,3) NOT NULL,
|
||||
"activity" DECIMAL(15,3) NOT NULL,
|
||||
"balance" DECIMAL(15,3) NOT NULL,
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_month__month_f13a79" ON "ynab_month_categories" ("month");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_month__categor_a62299" ON "ynab_month_categories" ("category_id");
|
||||
COMMENT ON TABLE "ynab_month_categories" IS 'Per-month category amounts. ``budgeted`` is what YNAB calls \"Assigned\".';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_payees" (
|
||||
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(500) NOT NULL,
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_subtransactions" (
|
||||
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"transaction_id" VARCHAR(64) NOT NULL,
|
||||
"amount" DECIMAL(15,3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"payee_id" VARCHAR(36),
|
||||
"category_id" VARCHAR(36),
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_transac_0571ba" ON "ynab_subtransactions" ("transaction_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_categor_c16c94" ON "ynab_subtransactions" ("category_id");
|
||||
COMMENT ON TABLE "ynab_subtransactions" IS 'Lines of a split transaction; the parent''s category is \"Split\".';
|
||||
CREATE TABLE IF NOT EXISTS "ynab_sync_state" (
|
||||
"budget_id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"budget_name" VARCHAR(255),
|
||||
"server_knowledge" BIGINT,
|
||||
"last_synced_at" TIMESTAMPTZ,
|
||||
"last_full_sync_at" TIMESTAMPTZ,
|
||||
"last_error" TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "ynab_transactions" (
|
||||
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
"date" DATE NOT NULL,
|
||||
"amount" DECIMAL(15,3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"cleared" VARCHAR(20) NOT NULL,
|
||||
"approved" BOOL NOT NULL,
|
||||
"flag_color" VARCHAR(20),
|
||||
"account_id" VARCHAR(36) NOT NULL,
|
||||
"payee_id" VARCHAR(36),
|
||||
"category_id" VARCHAR(36),
|
||||
"transfer_account_id" VARCHAR(36),
|
||||
"import_payee_name" VARCHAR(500),
|
||||
"account_name" VARCHAR(255),
|
||||
"payee_name" VARCHAR(500),
|
||||
"category_name" VARCHAR(255),
|
||||
"deleted" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_date_a71ead" ON "ynab_transactions" ("date");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_account_414dfe" ON "ynab_transactions" ("account_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_payee_i_4146bc" ON "ynab_transactions" ("payee_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_categor_232d47" ON "ynab_transactions" ("category_id");
|
||||
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
|
||||
ALTER TABLE "artifacts" ALTER COLUMN "data" TYPE JSONB USING "data"::JSONB;
|
||||
ALTER TABLE "artifacts" ALTER COLUMN "source_ref" TYPE JSONB USING "source_ref"::JSONB;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
|
||||
ALTER TABLE "artifacts" ALTER COLUMN "data" TYPE JSONB USING "data"::JSONB;
|
||||
ALTER TABLE "artifacts" ALTER COLUMN "source_ref" TYPE JSONB USING "source_ref"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
|
||||
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
|
||||
DROP TABLE IF EXISTS "ynab_category_groups";
|
||||
DROP TABLE IF EXISTS "ynab_month_categories";
|
||||
DROP TABLE IF EXISTS "ynab_accounts";
|
||||
DROP TABLE IF EXISTS "ynab_sync_state";
|
||||
DROP TABLE IF EXISTS "ynab_categories";
|
||||
DROP TABLE IF EXISTS "ynab_months";
|
||||
DROP TABLE IF EXISTS "ynab_subtransactions";
|
||||
DROP TABLE IF EXISTS "ynab_changes";
|
||||
DROP TABLE IF EXISTS "ynab_payees";
|
||||
DROP TABLE IF EXISTS "ynab_transactions";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests for artifact creation (blueprints/artifacts/logic.py) and the owner-scoped API."""
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.artifacts import artifacts_blueprint
|
||||
from blueprints.artifacts.logic import ARTIFACT_MAX_BYTES, create_artifact_from_output
|
||||
from blueprints.artifacts.models import Artifact
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage
|
||||
from blueprints.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:",
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.artifacts.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
yield
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner(db):
|
||||
user = await User.create(username="owner", email="owner@example.com")
|
||||
conversation = await Conversation.create(name="c", user=user)
|
||||
message = await ConversationMessage.create(
|
||||
text="[mealie_get_recipe result]\nSoup",
|
||||
conversation=conversation,
|
||||
speaker="tool",
|
||||
)
|
||||
return user, conversation, message
|
||||
|
||||
|
||||
def _output(artifact):
|
||||
return SimpleNamespace(content="text", artifact=artifact)
|
||||
|
||||
|
||||
RECIPE = {"kind": "mealie.recipe", "title": "Soup", "data": {"slug": "soup"}}
|
||||
|
||||
|
||||
class TestCreateArtifact:
|
||||
async def test_creates_and_links(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output({**RECIPE, "source_ref": {"system": "mealie"}}),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
assert artifact is not None
|
||||
saved = await Artifact.get(id=artifact.id)
|
||||
assert saved.message_id == message.id
|
||||
assert saved.kind == "mealie.recipe"
|
||||
assert saved.data == {"slug": "soup"}
|
||||
assert saved.source_ref == {"system": "mealie"}
|
||||
assert saved.version == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"artifact", [None, [{"page_content": "doc"}], {"title": "no kind"}, "str"]
|
||||
)
|
||||
async def test_ignores_non_artifacts(self, owner, artifact):
|
||||
user, conversation, message = owner
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(artifact),
|
||||
tool="simba_search",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
assert result is None
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
async def test_plain_string_output(self, owner):
|
||||
user, conversation, message = owner
|
||||
result = await create_artifact_from_output(
|
||||
output="just text", tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
|
||||
async def test_drops_oversized(self, owner):
|
||||
user, conversation, message = owner
|
||||
big = {**RECIPE, "data": {"blob": "x" * (ARTIFACT_MAX_BYTES + 1)}}
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(big), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
async def test_drops_unserialisable(self, owner):
|
||||
user, conversation, message = owner
|
||||
bad = {**RECIPE, "data": {"obj": object()}}
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(bad), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
|
||||
async def test_message_delete_sets_null(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="t",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
await message.delete()
|
||||
assert (await Artifact.get(id=artifact.id)).message_id is None
|
||||
|
||||
async def test_conversation_delete_cascades(self, owner):
|
||||
user, conversation, message = owner
|
||||
await create_artifact_from_output(
|
||||
output=_output(RECIPE), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
await conversation.delete()
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(artifacts_blueprint)
|
||||
return app
|
||||
|
||||
|
||||
async def _headers(app, user):
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(user.id))
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestArtifactRoutes:
|
||||
async def _setup(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
other = await User.create(username="other", email="other@example.com")
|
||||
return user, other, conversation, message, artifact
|
||||
|
||||
async def test_list_for_owner(self, app, owner):
|
||||
user, _, conversation, message, artifact = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/artifacts?conversation_id={conversation.id}",
|
||||
headers=await _headers(app, user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["id"] == str(artifact.id)
|
||||
assert body[0]["kind"] == "mealie.recipe"
|
||||
assert body[0]["title"] == "Soup"
|
||||
assert body[0]["tool"] == "mealie_get_recipe"
|
||||
assert body[0]["message_id"] == str(message.id)
|
||||
assert "created_at" in body[0]
|
||||
assert "data" not in body[0]
|
||||
|
||||
async def test_get_for_owner(self, app, owner):
|
||||
user, _, conversation, message, artifact = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/artifacts/{artifact.id}", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["data"] == {"slug": "soup"}
|
||||
assert body["source_ref"] is None
|
||||
assert body["conversation_id"] == str(conversation.id)
|
||||
|
||||
async def test_other_user_gets_404(self, app, owner):
|
||||
_, other, conversation, _, artifact = await self._setup(owner)
|
||||
headers = await _headers(app, other)
|
||||
client = app.test_client()
|
||||
resp = await client.get(f"/api/artifacts/{artifact.id}", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
resp = await client.get(
|
||||
f"/api/artifacts?conversation_id={conversation.id}", headers=headers
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_id_is_404(self, app, owner):
|
||||
user, *_ = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_id_is_404_without_querying(self, app, owner, monkeypatch):
|
||||
user, *_ = await self._setup(owner)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise AssertionError("queried with an invalid id")
|
||||
|
||||
monkeypatch.setattr(Artifact, "get_or_none", _boom)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_conversation_id_is_400_without_querying(
|
||||
self, app, owner, monkeypatch
|
||||
):
|
||||
user, *_ = await self._setup(owner)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise AssertionError("queried with an invalid id")
|
||||
|
||||
monkeypatch.setattr(Conversation, "get_or_none", _boom)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts?conversation_id=garbage", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_requires_auth(self, app, owner):
|
||||
await self._setup(owner)
|
||||
resp = await app.test_client().get("/api/artifacts/whatever")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_missing_conversation_id_is_400(self, app, owner):
|
||||
user, *_ = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestConversationPayload:
|
||||
async def test_tool_messages_gain_tool_label_artifact(self, owner):
|
||||
from blueprints.conversation import conversation_blueprint
|
||||
|
||||
user, conversation, message = owner
|
||||
await ConversationMessage.create(
|
||||
text="[web_search result]\nresults",
|
||||
conversation=conversation,
|
||||
speaker="tool",
|
||||
)
|
||||
await ConversationMessage.create(
|
||||
text="hello", conversation=conversation, speaker="user"
|
||||
)
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conversation_blueprint)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/conversation/{conversation.id}", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
by_text = {m["text"]: m for m in (await resp.get_json())["messages"]}
|
||||
|
||||
recipe = by_text["[mealie_get_recipe result]"]
|
||||
assert recipe["tool"] == "mealie_get_recipe"
|
||||
assert recipe["label"] == "Fetching recipe…"
|
||||
assert recipe["artifact"] == {
|
||||
"id": str(artifact.id),
|
||||
"kind": "mealie.recipe",
|
||||
"title": "Soup",
|
||||
}
|
||||
search = by_text["[web_search result]"]
|
||||
assert search["tool"] == "web_search"
|
||||
assert search["artifact"] is None
|
||||
assert "tool" not in by_text["hello"]
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
async def astream_events(self, payload, version, config):
|
||||
for event in self._events:
|
||||
yield event
|
||||
|
||||
|
||||
class TestStreamQuery:
|
||||
async def test_emits_v2_tool_events_and_saves_artifact(self, owner, monkeypatch):
|
||||
import json
|
||||
|
||||
import blueprints.conversation as conv
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
user, conversation, _ = owner
|
||||
events = [
|
||||
{
|
||||
"event": "on_tool_start",
|
||||
"name": "mealie_get_recipe",
|
||||
"run_id": "r1",
|
||||
"data": {"input": {"recipe_slug": "soup", "config": {}}},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_start",
|
||||
"name": "web_search",
|
||||
"run_id": "r2",
|
||||
"data": {"input": {"query": "cats"}},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_end",
|
||||
"name": "mealie_get_recipe",
|
||||
"run_id": "r1",
|
||||
"data": {
|
||||
"output": ToolMessage(
|
||||
content="Soup recipe",
|
||||
artifact=RECIPE,
|
||||
tool_call_id="c1",
|
||||
name="mealie_get_recipe",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_error",
|
||||
"name": "web_search",
|
||||
"run_id": "r2",
|
||||
"data": {"error": RuntimeError("boom")},
|
||||
},
|
||||
{
|
||||
"event": "on_chain_end",
|
||||
"name": "agent",
|
||||
"data": {"output": {"messages": [AIMessage(content="Done")]}},
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(conv, "get_agent_for", lambda u: _FakeAgent(events))
|
||||
|
||||
async def _prompt(user_id):
|
||||
return "system"
|
||||
|
||||
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conv.conversation_blueprint)
|
||||
resp = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
json={"query": "soup?", "conversation_id": str(conversation.id)},
|
||||
headers=await _headers(app, user),
|
||||
)
|
||||
body = (await resp.get_data()).decode()
|
||||
frames = [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in body.splitlines()
|
||||
if line.startswith("data: {")
|
||||
]
|
||||
by_type = {}
|
||||
for f in frames:
|
||||
by_type.setdefault(f["type"], []).append(f)
|
||||
|
||||
starts = by_type["tool_start"]
|
||||
assert starts[0]["input"] == {"recipe_slug": "soup"}
|
||||
assert starts[0]["label"] == "Fetching recipe…"
|
||||
|
||||
ends = {f["run_id"]: f for f in by_type["tool_end"]}
|
||||
artifact = await Artifact.get(tool="mealie_get_recipe")
|
||||
assert ends["r1"]["status"] == "ok"
|
||||
assert ends["r1"]["artifact"] == {
|
||||
"id": str(artifact.id),
|
||||
"kind": "mealie.recipe",
|
||||
"title": "Soup",
|
||||
}
|
||||
assert ends["r2"]["status"] == "error"
|
||||
assert ends["r2"]["artifact"] is None
|
||||
assert by_type["response"][0]["message"] == "Done"
|
||||
|
||||
tool_message = await ConversationMessage.get(id=artifact.message_id)
|
||||
assert tool_message.text.startswith("[mealie_get_recipe result]")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for SSE tool-event shaping and the tool label registry."""
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from blueprints.conversation import agents
|
||||
from blueprints.conversation.tool_events import (
|
||||
INPUT_VALUE_MAX_CHARS,
|
||||
summarize_tool_input,
|
||||
tool_end_payload,
|
||||
tool_name_from_message,
|
||||
tool_start_payload,
|
||||
)
|
||||
from blueprints.conversation.tool_labels import TOOL_LABELS, tool_label
|
||||
|
||||
|
||||
def _event(name="web_search", run_id="run-1", input=None):
|
||||
return {"name": name, "run_id": run_id, "data": {"input": input or {}}}
|
||||
|
||||
|
||||
class TestToolLabel:
|
||||
def test_known_tool(self):
|
||||
assert tool_label("web_search") == "Searching the web…"
|
||||
|
||||
def test_fallback(self):
|
||||
assert tool_label("mystery_tool") == "Using mystery_tool…"
|
||||
|
||||
def test_mcp_prefixes(self):
|
||||
assert tool_label("homebox_search_items") == "Checking home inventory…"
|
||||
assert tool_label("bookstack_get_page") == "Checking the wiki…"
|
||||
|
||||
def test_old_and_new_ynab_names(self):
|
||||
for name in (
|
||||
"ynab_category_spending",
|
||||
"ynab_insights",
|
||||
"ynab_account_balances",
|
||||
"ynab_spending_report",
|
||||
"ynab_category_budget",
|
||||
"ynab_recent_changes",
|
||||
"ynab_sql",
|
||||
):
|
||||
assert name in TOOL_LABELS
|
||||
|
||||
def test_every_agent_tool_has_a_label(self):
|
||||
names = [v.name for v in vars(agents).values() if isinstance(v, BaseTool)]
|
||||
assert len(names) > 20
|
||||
assert [n for n in names if n not in TOOL_LABELS] == []
|
||||
|
||||
|
||||
class TestSummarizeToolInput:
|
||||
def test_drops_config_and_none(self):
|
||||
raw = {"content": "hi", "config": {"configurable": {}}, "date": None}
|
||||
assert summarize_tool_input(raw) == {"content": "hi"}
|
||||
|
||||
def test_truncates_long_strings(self):
|
||||
out = summarize_tool_input({"query": "x" * 500})
|
||||
assert len(out["query"]) == INPUT_VALUE_MAX_CHARS
|
||||
assert out["query"].endswith("…")
|
||||
|
||||
def test_keeps_scalars(self):
|
||||
assert summarize_tool_input({"days": 7, "flag": True, "f": 1.5}) == {
|
||||
"days": 7,
|
||||
"flag": True,
|
||||
"f": 1.5,
|
||||
}
|
||||
|
||||
def test_serialises_nested_values(self):
|
||||
assert summarize_tool_input({"tags": ["a", "b"]}) == {"tags": '["a", "b"]'}
|
||||
|
||||
def test_non_dict_input(self):
|
||||
assert summarize_tool_input("raw") == {"input": "raw"}
|
||||
assert summarize_tool_input(None) == {}
|
||||
|
||||
|
||||
class TestPayloads:
|
||||
def test_tool_start(self):
|
||||
payload = tool_start_payload(_event(input={"query": "cats"}))
|
||||
assert payload == {
|
||||
"type": "tool_start",
|
||||
"run_id": "run-1",
|
||||
"tool": "web_search",
|
||||
"label": "Searching the web…",
|
||||
"input": {"query": "cats"},
|
||||
}
|
||||
|
||||
def test_tool_end_ok(self):
|
||||
ref = {"id": "a1", "kind": "mealie.recipe", "title": "Soup"}
|
||||
payload = tool_end_payload(_event(), 10.0, ref, now=10.25)
|
||||
assert payload == {
|
||||
"type": "tool_end",
|
||||
"run_id": "run-1",
|
||||
"tool": "web_search",
|
||||
"label": "Searching the web…",
|
||||
"status": "ok",
|
||||
"ms": 250,
|
||||
"artifact": ref,
|
||||
}
|
||||
|
||||
def test_tool_end_error_without_start(self):
|
||||
payload = tool_end_payload(_event(), None, None, now=5.0, status="error")
|
||||
assert payload["status"] == "error"
|
||||
assert payload["ms"] == 0
|
||||
assert payload["artifact"] is None
|
||||
|
||||
|
||||
class TestToolNameFromMessage:
|
||||
def test_parses_header(self):
|
||||
assert tool_name_from_message("[web_search result]\nbody") == "web_search"
|
||||
|
||||
def test_unknown_header(self):
|
||||
assert tool_name_from_message("something else") == "tool"
|
||||
Reference in New Issue
Block a user