Cowork B: Mealie, Obsidian and search tools return artifacts #61
@@ -70,6 +70,8 @@ YNAB_SYNC_INTERVAL=900
|
||||
MEALIE_BASE_URL=http://192.168.1.5:9000
|
||||
# Get your API token from Mealie's user settings page
|
||||
MEALIE_API_TOKEN=your-mealie-api-token
|
||||
# Group slug used in recipe page links (/g/<slug>/r/<recipe>); defaults to "home"
|
||||
MEALIE_GROUP_SLUG=home
|
||||
|
||||
# Email Integration
|
||||
# Email Encryption Key (32-byte URL-safe base64)
|
||||
@@ -167,6 +169,8 @@ OBSIDIAN_DEVICE_NAME=simbarag
|
||||
OBSIDIAN_CONTINUOUS_SYNC=false
|
||||
# Local path to Obsidian vault (where files are synced)
|
||||
OBSIDIAN_VAULT_PATH=/app/data/obsidian
|
||||
# Vault name for obsidian://open links in the artifact panel (optional; no link if unset)
|
||||
OBSIDIAN_VAULT_NAME=
|
||||
|
||||
# LangGraph checkpoint store (SQLite). Must be on a persistent volume.
|
||||
# Both blue and green containers share this volume via docker-compose.
|
||||
|
||||
@@ -11,6 +11,19 @@ from langchain_core.runnables import RunnableConfig, RunnableLambda
|
||||
from langchain_openai import ChatOpenAI
|
||||
from tavily import AsyncTavilyClient
|
||||
|
||||
from blueprints.conversation.artifacts_util import (
|
||||
NOTE_MAX_CHARS,
|
||||
format_date_range,
|
||||
format_day,
|
||||
json_safe,
|
||||
make_artifact,
|
||||
mealie_source_ref,
|
||||
note_title,
|
||||
obsidian_source_ref,
|
||||
search_documents_artifact,
|
||||
truncate,
|
||||
unique,
|
||||
)
|
||||
from blueprints.conversation.memory import save_memory
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from blueprints.rag.logic import query_vector_store
|
||||
@@ -131,20 +144,36 @@ async def simba_search(query: str):
|
||||
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
|
||||
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
|
||||
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
|
||||
return serialized, docs
|
||||
return serialized, search_documents_artifact(query, "paperless", docs)
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_todays_meals() -> str:
|
||||
MEALIE_NOT_CONFIGURED = "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
|
||||
|
||||
def _meal_entry(meal: dict) -> dict:
|
||||
"""Meal plan entry in the `mealie.meal_plan` artifact shape."""
|
||||
entry = {
|
||||
"entry_type": meal["entry_type"],
|
||||
"name": meal["recipe_name"] or meal["title"] or "Untitled",
|
||||
}
|
||||
if meal.get("recipe_slug"):
|
||||
entry["recipe_slug"] = meal["recipe_slug"]
|
||||
if meal.get("note"):
|
||||
entry["note"] = meal["note"]
|
||||
return entry
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_todays_meals():
|
||||
"""Get today's planned meals from Mealie."""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_todays_meals()
|
||||
|
||||
if result["total_meals"] == 0:
|
||||
return f"No meals planned for today ({result['today']})."
|
||||
return f"No meals planned for today ({result['today']}).", None
|
||||
|
||||
lines = [f"Meals planned for today ({result['today']}):"]
|
||||
for meal in result["meals"]:
|
||||
@@ -152,13 +181,29 @@ async def mealie_todays_meals() -> str:
|
||||
lines.append(f"- {meal['entry_type'].capitalize()}: {name}")
|
||||
if meal["note"]:
|
||||
lines.append(f" Note: {meal['note']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
today = result["today"]
|
||||
artifact = make_artifact(
|
||||
"mealie.meal_plan",
|
||||
f"Today's meals · {format_day(today)}",
|
||||
{
|
||||
"start_date": today,
|
||||
"end_date": today,
|
||||
"days": [
|
||||
{
|
||||
"date": today,
|
||||
"meals": [_meal_entry(m) for m in result["meals"]],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching today's meals: {str(e)}"
|
||||
return f"Error fetching today's meals: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_meal_plans(start_date: str = "", end_date: str = ""):
|
||||
"""Get Mealie meal plans for a date range.
|
||||
|
||||
Args:
|
||||
@@ -166,7 +211,7 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
end_date: YYYY-MM-DD (default: 7 days out)
|
||||
"""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_meal_plans(
|
||||
@@ -175,11 +220,15 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
)
|
||||
|
||||
if result["total_plans"] == 0:
|
||||
return f"No meal plans found from {result['start_date']} to {result['end_date']}."
|
||||
return (
|
||||
f"No meal plans found from {result['start_date']} to {result['end_date']}.",
|
||||
None,
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):"
|
||||
]
|
||||
days = []
|
||||
for date, plans in sorted(result["plans_by_date"].items()):
|
||||
lines.append(f"\n{date}:")
|
||||
for plan in plans:
|
||||
@@ -187,20 +236,31 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
lines.append(f" - {plan['entry_type'].capitalize()}: {name}")
|
||||
if plan["note"]:
|
||||
lines.append(f" Note: {plan['note']}")
|
||||
return "\n".join(lines)
|
||||
days.append({"date": date, "meals": [_meal_entry(p) for p in plans]})
|
||||
|
||||
artifact = make_artifact(
|
||||
"mealie.meal_plan",
|
||||
f"Meal plan · {format_date_range(result['start_date'], result['end_date'])}",
|
||||
{
|
||||
"start_date": result["start_date"],
|
||||
"end_date": result["end_date"],
|
||||
"days": days,
|
||||
},
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching meal plans: {str(e)}"
|
||||
return f"Error fetching meal plans: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_get_recipe(recipe_slug: str) -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_get_recipe(recipe_slug: str):
|
||||
"""Get full recipe details (ingredients, instructions, times) from Mealie.
|
||||
|
||||
Args:
|
||||
recipe_slug: Recipe slug from meal plan data
|
||||
"""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
recipe = await mealie_service.get_recipe(recipe_slug)
|
||||
@@ -236,22 +296,49 @@ async def mealie_get_recipe(recipe_slug: str) -> str:
|
||||
if recipe["tags"]:
|
||||
lines.append(f"\nTags: {', '.join(recipe['tags'])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
instructions = []
|
||||
for step in recipe["instructions"]:
|
||||
instruction = {"text": step.get("text", "")}
|
||||
if step.get("title"):
|
||||
instruction["title"] = step["title"]
|
||||
instructions.append(instruction)
|
||||
|
||||
slug = recipe.get("slug") or recipe_slug
|
||||
artifact = make_artifact(
|
||||
"mealie.recipe",
|
||||
recipe["name"],
|
||||
json_safe(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": recipe["name"],
|
||||
"description": recipe["description"],
|
||||
"servings": recipe["servings"],
|
||||
"prep_time": recipe["prep_time"],
|
||||
"cook_time": recipe["cook_time"],
|
||||
"total_time": recipe["total_time"],
|
||||
"ingredients": [ing["display"] for ing in recipe["ingredients"]],
|
||||
"instructions": instructions,
|
||||
"tags": recipe["tags"],
|
||||
}
|
||||
),
|
||||
mealie_source_ref(slug),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching recipe: {str(e)}"
|
||||
return f"Error fetching recipe: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_shopping_lists() -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_shopping_lists():
|
||||
"""Get Mealie shopping lists with needed and checked-off items."""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_shopping_lists()
|
||||
|
||||
if result["total_lists"] == 0:
|
||||
return "No shopping lists found."
|
||||
return "No shopping lists found.", None
|
||||
|
||||
lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"]
|
||||
for sl in result["shopping_lists"]:
|
||||
@@ -270,9 +357,37 @@ async def mealie_shopping_lists() -> str:
|
||||
lines.append(
|
||||
f" ({len(sl['checked_items'])} items already checked off)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lists = [
|
||||
{
|
||||
"name": sl["name"],
|
||||
"items": [
|
||||
{
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"checked": False,
|
||||
}
|
||||
for item in sl["unchecked_items"]
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"checked": True,
|
||||
}
|
||||
for item in sl["checked_items"]
|
||||
],
|
||||
}
|
||||
for sl in result["shopping_lists"]
|
||||
]
|
||||
artifact = make_artifact(
|
||||
"mealie.shopping_lists",
|
||||
"Shopping lists",
|
||||
json_safe({"lists": lists}),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching shopping lists: {str(e)}"
|
||||
return f"Error fetching shopping lists: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
@@ -309,34 +424,55 @@ async def mealie_create_meal_plan(
|
||||
return f"Error creating meal plan: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
async def obsidian_search_notes(query: str) -> str:
|
||||
OBSIDIAN_NOT_CONFIGURED = "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
|
||||
|
||||
def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict:
|
||||
"""`obsidian.note` artifact from ObsidianService.parse_markdown output."""
|
||||
frontmatter = json_safe(parsed["metadata"])
|
||||
title = title or note_title(path, frontmatter)
|
||||
return make_artifact(
|
||||
"obsidian.note",
|
||||
title,
|
||||
{
|
||||
"path": path,
|
||||
"title": title,
|
||||
"markdown": truncate(parsed["content"], NOTE_MAX_CHARS),
|
||||
"tags": unique(parsed["tags"]),
|
||||
"frontmatter": frontmatter,
|
||||
},
|
||||
obsidian_source_ref(path),
|
||||
)
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_search_notes(query: str):
|
||||
"""Search personal Obsidian vault notes.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
# Query vector store filtered to obsidian source only
|
||||
serialized, docs = await query_vector_store(query=query, source="obsidian")
|
||||
return serialized
|
||||
return serialized, search_documents_artifact(query, "obsidian", docs)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error searching Obsidian notes: {str(e)}"
|
||||
return f"Error searching Obsidian notes: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def obsidian_read_note(relative_path: str) -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_read_note(relative_path: str):
|
||||
"""Read the full content of a specific Obsidian note by path.
|
||||
|
||||
Args:
|
||||
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
note = obsidian_service.read_note(relative_path)
|
||||
@@ -348,12 +484,15 @@ async def obsidian_read_note(relative_path: str) -> str:
|
||||
result += f"Tags: {', '.join(content_data['tags'])}\n"
|
||||
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
|
||||
|
||||
return result
|
||||
return result, _note_artifact(note["path"], content_data)
|
||||
|
||||
except FileNotFoundError:
|
||||
return f"Note not found at '{relative_path}'. Please check the path is correct."
|
||||
return (
|
||||
f"Note not found at '{relative_path}'. Please check the path is correct.",
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error reading note: {str(e)}"
|
||||
return f"Error reading note: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
@@ -391,19 +530,27 @@ async def obsidian_create_note(
|
||||
return f"Error creating note: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
def journal_get_today() -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
def journal_get_today():
|
||||
"""Get today's full daily journal note including tasks and log entries."""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured."
|
||||
return "Obsidian integration is not configured.", None
|
||||
|
||||
try:
|
||||
note = obsidian_service.get_daily_note()
|
||||
if not note["found"]:
|
||||
return f"No daily note found for {note['date']}. Use journal_add_task to create one."
|
||||
return f"Daily note for {note['date']}:\n\n{note['content']}"
|
||||
return (
|
||||
f"No daily note found for {note['date']}. Use journal_add_task to create one.",
|
||||
None,
|
||||
)
|
||||
artifact = _note_artifact(
|
||||
note["path"],
|
||||
obsidian_service.parse_markdown(note["content"]),
|
||||
title=f"Daily note · {format_day(note['date'])}",
|
||||
)
|
||||
return f"Daily note for {note['date']}:\n\n{note['content']}", artifact
|
||||
except Exception as e:
|
||||
return f"Error reading daily note: {str(e)}"
|
||||
return f"Error reading daily note: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Helpers for building ToolArtifact dicts returned by agent tools.
|
||||
|
||||
See docs/cowork-ui-plan.md ("Artifacts") for the contract. Artifacts must be
|
||||
plain JSON-serialisable dicts: they ride along in ToolMessage.artifact and are
|
||||
stored in the LangGraph checkpoint.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
SNIPPET_MAX_CHARS = 400
|
||||
NOTE_MAX_CHARS = 50_000
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
def make_artifact(
|
||||
kind: str, title: str, data: dict, source_ref: dict | None = None
|
||||
) -> dict:
|
||||
artifact = {"kind": kind, "title": title, "data": data}
|
||||
if source_ref:
|
||||
artifact["source_ref"] = source_ref
|
||||
return artifact
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
"""Cap text at `limit` chars, ending in an ellipsis when cut."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
|
||||
|
||||
|
||||
def snippet(text: str, limit: int = SNIPPET_MAX_CHARS) -> str:
|
||||
"""Collapse whitespace and cap at the snippet limit."""
|
||||
return truncate(" ".join((text or "").split()), limit)
|
||||
|
||||
|
||||
def json_safe(value):
|
||||
"""Convert a value (e.g. parsed YAML frontmatter) to JSON-safe types."""
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [json_safe(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): json_safe(v) for k, v in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def unique(items) -> list:
|
||||
"""Deduplicate while keeping first-seen order."""
|
||||
return list(dict.fromkeys(items))
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_day(value: str) -> str:
|
||||
"""'2026-10-12' -> 'Oct 12'. Unparseable input is returned unchanged."""
|
||||
d = _parse_date(value)
|
||||
return f"{d:%b} {d.day}" if d else str(value)
|
||||
|
||||
|
||||
def format_month(value: str) -> str:
|
||||
"""'2026-10' or '2026-10-01' -> 'October 2026'. Unparseable input is returned unchanged."""
|
||||
d = _parse_date(str(value)[:7] + "-01")
|
||||
return f"{d:%B %Y}" if d else str(value)
|
||||
|
||||
|
||||
def format_date_range(start: str, end: str) -> str:
|
||||
"""Human date range: 'Oct 12–19', 'Oct 28–Nov 3', 'Dec 28, 2026–Jan 3, 2027'."""
|
||||
s, e = _parse_date(start), _parse_date(end)
|
||||
if not s or not e:
|
||||
return f"{start}–{end}"
|
||||
if s == e:
|
||||
return f"{s:%b} {s.day}"
|
||||
if s.year != e.year:
|
||||
return f"{s:%b} {s.day}, {s.year}–{e:%b} {e.day}, {e.year}"
|
||||
if s.month == e.month:
|
||||
return f"{s:%b} {s.day}–{e.day}"
|
||||
return f"{s:%b} {s.day}–{e:%b} {e.day}"
|
||||
|
||||
|
||||
def epoch_to_date(value) -> str | None:
|
||||
"""Epoch seconds -> 'YYYY-MM-DD' (None if not a number)."""
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d")
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def mealie_recipe_url(slug: str) -> str | None:
|
||||
"""Recipe page URL for Mealie v2 (`/g/<group-slug>/r/<slug>`)."""
|
||||
base_url = os.getenv("MEALIE_BASE_URL", "").rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
group = os.getenv("MEALIE_GROUP_SLUG", "") or "home"
|
||||
return f"{base_url}/g/{quote(group)}/r/{quote(slug)}"
|
||||
|
||||
|
||||
def mealie_source_ref(slug: str) -> dict:
|
||||
ref = {"system": "mealie", "id": slug}
|
||||
url = mealie_recipe_url(slug)
|
||||
if url:
|
||||
ref["url"] = url
|
||||
return ref
|
||||
|
||||
|
||||
def obsidian_source_ref(path: str) -> dict:
|
||||
"""Obsidian ref; the obsidian:// URL needs OBSIDIAN_VAULT_NAME."""
|
||||
ref = {"system": "obsidian", "path": path}
|
||||
vault = os.getenv("OBSIDIAN_VAULT_NAME", "")
|
||||
if vault:
|
||||
ref["url"] = "obsidian://open?" + urlencode(
|
||||
{"vault": vault, "file": path}, quote_via=quote
|
||||
)
|
||||
return ref
|
||||
|
||||
|
||||
def note_title(path: str, frontmatter: dict | None = None) -> str:
|
||||
"""Frontmatter `title` if present, else the filename without extension."""
|
||||
title = (frontmatter or {}).get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title.strip()
|
||||
return Path(path).stem or path
|
||||
|
||||
|
||||
def vault_relative_path(filepath: str) -> str:
|
||||
"""Strip OBSIDIAN_VAULT_PATH from an absolute indexed filepath."""
|
||||
vault = os.getenv("OBSIDIAN_VAULT_PATH", "/app/data/obsidian")
|
||||
try:
|
||||
return str(Path(filepath).relative_to(vault))
|
||||
except ValueError:
|
||||
return filepath
|
||||
|
||||
|
||||
def search_documents_artifact(query: str, source: str, docs) -> dict | None:
|
||||
"""Build a `search.documents` artifact from vector-store Documents."""
|
||||
if not docs:
|
||||
return None
|
||||
results = []
|
||||
for doc in docs:
|
||||
meta = doc.metadata or {}
|
||||
result = {"snippet": snippet(doc.page_content)}
|
||||
if source == "obsidian":
|
||||
filepath = meta.get("filepath") or ""
|
||||
path = vault_relative_path(filepath) if filepath else ""
|
||||
result["title"] = note_title(path, meta) if path else "Untitled note"
|
||||
if path:
|
||||
result["path"] = path
|
||||
created = meta.get("created_at")
|
||||
if isinstance(created, str) and created:
|
||||
result["date"] = created[:10]
|
||||
else:
|
||||
filename = meta.get("filename") or ""
|
||||
result["title"] = Path(filename).stem or filename or "Document"
|
||||
if filename:
|
||||
result["filename"] = filename
|
||||
if meta.get("document_type"):
|
||||
result["document_type"] = str(meta["document_type"])
|
||||
created = epoch_to_date(meta.get("created_date"))
|
||||
if created:
|
||||
result["date"] = created
|
||||
results.append(result)
|
||||
return make_artifact(
|
||||
"search.documents",
|
||||
f"Search: {query}",
|
||||
{"query": query, "source": source, "results": results},
|
||||
)
|
||||
+130
-20
@@ -14,6 +14,13 @@ from zoneinfo import ZoneInfo
|
||||
from langchain.tools import tool
|
||||
from tortoise import connections
|
||||
|
||||
from blueprints.conversation.artifacts_util import (
|
||||
format_date_range,
|
||||
format_month,
|
||||
json_safe,
|
||||
make_artifact,
|
||||
)
|
||||
|
||||
from .sync import ensure_fresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,6 +30,8 @@ NOT_SYNCED = "YNAB data is unavailable: the budget has not been synced yet."
|
||||
# Keep in step with TOOL_RESULT_MAX_CHARS in blueprints/conversation/logic.py;
|
||||
# longer results get truncated there anyway and crowd the 32k context.
|
||||
MAX_TRANSACTIONS = 40
|
||||
# Rows in the ynab.transactions artifact (the side panel, not the LLM)
|
||||
ARTIFACT_MAX_TRANSACTIONS = 500
|
||||
|
||||
# ynab_recent_changes accepts the names it prints as well as stored types
|
||||
ENTITY_TYPE_ALIASES = {
|
||||
@@ -64,6 +73,19 @@ def ynab_tool_errors(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
def ynab_artifact_tool(fn):
|
||||
"""For content_and_artifact tools: errors become (text, None), and bare
|
||||
strings (not-synced, bad input, empty results) get no artifact."""
|
||||
wrapped = ynab_tool_errors(fn)
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = await wrapped(*args, **kwargs)
|
||||
return (result, None) if isinstance(result, str) else result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Every transaction line, with split transactions expanded into their
|
||||
# subtransactions so category/payee filters see the real allocation.
|
||||
LINES_CTE = """
|
||||
@@ -114,6 +136,11 @@ def _usd(value: Decimal | float | None) -> str:
|
||||
return f"{sign}${abs(value):,.2f}"
|
||||
|
||||
|
||||
def _amount(value: Decimal | float | None) -> float:
|
||||
"""Dollar amount for artifacts (JSON has no Decimal)."""
|
||||
return float(value or 0)
|
||||
|
||||
|
||||
def _parse_date(value: str, default: date | None) -> date | None:
|
||||
return date.fromisoformat(value) if value else default
|
||||
|
||||
@@ -128,9 +155,9 @@ async def _query(sql: str, *params) -> list[dict]:
|
||||
return await connections.get("default").execute_query_dict(sql, list(params))
|
||||
|
||||
|
||||
@tool
|
||||
@ynab_tool_errors
|
||||
async def ynab_budget_summary(month: str = "") -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
@ynab_artifact_tool
|
||||
async def ynab_budget_summary(month: str = ""):
|
||||
"""Get YNAB budget overview for a month: Ready to Assign, income, assigned, spent, age of money, overspent categories, and net worth.
|
||||
|
||||
Args:
|
||||
@@ -191,12 +218,29 @@ async def ynab_budget_summary(month: str = "") -> str:
|
||||
)
|
||||
else:
|
||||
lines.append("No overspent categories.")
|
||||
net_worth = sum(by_budget.values(), Decimal(0))
|
||||
lines.append(
|
||||
f"Net worth: {_usd(sum(by_budget.values(), Decimal(0)))} "
|
||||
f"Net worth: {_usd(net_worth)} "
|
||||
f"(budget accounts {_usd(by_budget.get(True))}, "
|
||||
f"tracking accounts {_usd(by_budget.get(False))})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
data = {
|
||||
"month": f"{start:%Y-%m}",
|
||||
"ready_to_assign": _amount(m["to_be_budgeted"]),
|
||||
"income": _amount(m["income"]),
|
||||
"assigned": _amount(m["budgeted"]),
|
||||
"spent": _amount(spent[0]["spent"]),
|
||||
"net_worth": _amount(net_worth),
|
||||
"overspent": [
|
||||
{"name": r["name"], "available": _amount(r["balance"])} for r in overspent
|
||||
],
|
||||
}
|
||||
if m["age_of_money"] is not None:
|
||||
data["age_of_money"] = m["age_of_money"]
|
||||
artifact = make_artifact(
|
||||
"ynab.summary", f"Budget · {format_month(data['month'])}", json_safe(data)
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
|
||||
|
||||
@tool
|
||||
@@ -230,8 +274,8 @@ async def ynab_account_balances(include_closed: bool = False) -> str:
|
||||
return "\n".join(lines) + f"\n\nNet worth (open accounts): {_usd(total)}"
|
||||
|
||||
|
||||
@tool
|
||||
@ynab_tool_errors
|
||||
@tool(response_format="content_and_artifact")
|
||||
@ynab_artifact_tool
|
||||
async def ynab_search_transactions(
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
@@ -242,7 +286,7 @@ async def ynab_search_transactions(
|
||||
min_amount: float = 0,
|
||||
max_amount: float = 0,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
):
|
||||
"""Search YNAB transactions. Split transactions are expanded into their category lines.
|
||||
|
||||
Args:
|
||||
@@ -305,12 +349,16 @@ async def ynab_search_transactions(
|
||||
return f"No transactions found from {range_text}."
|
||||
|
||||
limit = min(max(1, limit), MAX_TRANSACTIONS)
|
||||
rows = await _query(
|
||||
# One query feeds both the text (first `limit` rows) and the artifact; the
|
||||
# id tie-break keeps the order stable regardless of LIMIT.
|
||||
all_rows = await _query(
|
||||
f"""{LINES_CTE}
|
||||
SELECT * FROM lines l WHERE {where}
|
||||
ORDER BY l.date DESC, abs(l.amount) DESC LIMIT {limit}""",
|
||||
ORDER BY l.date DESC, abs(l.amount) DESC, l.id
|
||||
LIMIT {ARTIFACT_MAX_TRANSACTIONS}""",
|
||||
*params,
|
||||
)
|
||||
rows = all_rows[:limit]
|
||||
lines = [
|
||||
f"Found {count} transactions from {range_text}. "
|
||||
f"Outflows {_usd(totals[0]['outflow'])}, inflows {_usd(totals[0]['inflow'])}."
|
||||
@@ -327,11 +375,38 @@ async def ynab_search_transactions(
|
||||
lines.append(line)
|
||||
if count > len(rows):
|
||||
lines.append(f"(Showing {len(rows)} most recent of {count})")
|
||||
return "\n".join(lines)
|
||||
|
||||
transactions = []
|
||||
for r in all_rows:
|
||||
txn = {
|
||||
"date": r["date"],
|
||||
"payee": r["payee_name"] or "Unknown payee",
|
||||
"category": r["category_name"] or "Uncategorized",
|
||||
"account": r["account_name"],
|
||||
"amount": _amount(r["amount"]),
|
||||
}
|
||||
if r["memo"]:
|
||||
txn["memo"] = r["memo"]
|
||||
transactions.append(txn)
|
||||
title_range = format_date_range(str(start), str(end)) if start else f"through {end}"
|
||||
artifact = make_artifact(
|
||||
"ynab.transactions",
|
||||
f"Transactions · {title_range}",
|
||||
json_safe(
|
||||
{
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"count": count,
|
||||
"total": _amount(totals[0]["outflow"] + totals[0]["inflow"]),
|
||||
"transactions": transactions,
|
||||
}
|
||||
),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
|
||||
|
||||
@tool
|
||||
@ynab_tool_errors
|
||||
@tool(response_format="content_and_artifact")
|
||||
@ynab_artifact_tool
|
||||
async def ynab_spending_report(
|
||||
group_by: str = "category",
|
||||
start_date: str = "",
|
||||
@@ -339,7 +414,7 @@ async def ynab_spending_report(
|
||||
category: str = "",
|
||||
payee: str = "",
|
||||
top: int = 15,
|
||||
) -> str:
|
||||
):
|
||||
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
|
||||
|
||||
Args:
|
||||
@@ -377,7 +452,7 @@ async def ynab_spending_report(
|
||||
JOIN ynab_accounts a ON a.id = l.account_id
|
||||
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
||||
LEFT JOIN ynab_category_groups g ON g.id = c.category_group_id
|
||||
WHERE {' AND '.join(conditions)}
|
||||
WHERE {" AND ".join(conditions)}
|
||||
GROUP BY 1 ORDER BY {order}""",
|
||||
*params,
|
||||
)
|
||||
@@ -394,12 +469,29 @@ async def ynab_spending_report(
|
||||
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
||||
if group_by == "month" and len(rows) > 1:
|
||||
lines.append(f"Monthly average: {_usd(total / len(rows))}")
|
||||
return "\n".join(lines)
|
||||
artifact = make_artifact(
|
||||
"ynab.spending_report",
|
||||
f"Spending by {group_by.replace('_', ' ')} · "
|
||||
f"{format_date_range(str(start), str(end))}",
|
||||
json_safe(
|
||||
{
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"group_by": group_by,
|
||||
"rows": [
|
||||
{"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]}
|
||||
for r in rows
|
||||
],
|
||||
"total": _amount(total),
|
||||
}
|
||||
),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
|
||||
|
||||
@tool
|
||||
@ynab_tool_errors
|
||||
async def ynab_category_budget(month: str = "", category_group: str = "") -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
@ynab_artifact_tool
|
||||
async def ynab_category_budget(month: str = "", category_group: str = ""):
|
||||
"""Get YNAB categories for a month grouped by category group: assigned, activity (negative = spent), available, and goal target. Flags overspending.
|
||||
|
||||
Args:
|
||||
@@ -432,12 +524,23 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str
|
||||
|
||||
lines = [f"Categories for {start:%Y-%m}:"]
|
||||
current = None
|
||||
groups = []
|
||||
for r in rows:
|
||||
if not (r["budgeted"] or r["activity"] or r["balance"]):
|
||||
continue
|
||||
if r["group_name"] != current:
|
||||
current = r["group_name"]
|
||||
lines.append(f"\n{current}")
|
||||
groups.append({"name": current, "categories": []})
|
||||
category = {
|
||||
"name": r["name"],
|
||||
"assigned": _amount(r["budgeted"]),
|
||||
"activity": _amount(r["activity"]),
|
||||
"available": _amount(r["balance"]),
|
||||
}
|
||||
if r["goal_target"]:
|
||||
category["goal_target"] = _amount(r["goal_target"])
|
||||
groups[-1]["categories"].append(category)
|
||||
line = (
|
||||
f" {r['name']}: assigned {_usd(r['budgeted'])}, "
|
||||
f"activity {_usd(r['activity'])}, available {_usd(r['balance'])}"
|
||||
@@ -447,7 +550,14 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str
|
||||
if r["balance"] < 0:
|
||||
line += " [OVERSPENT]"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
artifact = None
|
||||
if groups:
|
||||
artifact = make_artifact(
|
||||
"ynab.category_budget",
|
||||
f"Category budget · {format_month(f'{start:%Y-%m}')}",
|
||||
json_safe({"month": f"{start:%Y-%m}", "groups": groups}),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
|
||||
|
||||
@tool
|
||||
|
||||
@@ -27,6 +27,7 @@ x-raggr-common: &raggr-common
|
||||
- YNAB_SYNC_INTERVAL=${YNAB_SYNC_INTERVAL:-900}
|
||||
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
|
||||
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
|
||||
- MEALIE_GROUP_SLUG=${MEALIE_GROUP_SLUG:-home}
|
||||
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
||||
@@ -40,6 +41,7 @@ x-raggr-common: &raggr-common
|
||||
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
||||
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
|
||||
- OBSIDIAN_VAULT_NAME=${OBSIDIAN_VAULT_NAME:-}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for tools returning (text, artifact) via content_and_artifact.
|
||||
|
||||
The text half must stay identical to the pre-artifact output (the LLM's view),
|
||||
and the artifact must match the contract in docs/cowork-ui-plan.md.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
os.environ.setdefault("TAVILY_API_KEY", "dummy")
|
||||
|
||||
from blueprints.conversation import agents # noqa: E402
|
||||
from blueprints.conversation.artifacts_util import ( # noqa: E402
|
||||
NOTE_MAX_CHARS,
|
||||
SNIPPET_MAX_CHARS,
|
||||
format_date_range,
|
||||
search_documents_artifact,
|
||||
)
|
||||
from utils.obsidian_service import ObsidianService # noqa: E402
|
||||
|
||||
KIND_DATA_KEYS = {
|
||||
"mealie.recipe": {
|
||||
"slug",
|
||||
"name",
|
||||
"description",
|
||||
"servings",
|
||||
"prep_time",
|
||||
"cook_time",
|
||||
"total_time",
|
||||
"ingredients",
|
||||
"instructions",
|
||||
"tags",
|
||||
},
|
||||
"mealie.meal_plan": {"start_date", "end_date", "days"},
|
||||
"mealie.shopping_lists": {"lists"},
|
||||
"obsidian.note": {"path", "title", "markdown", "tags", "frontmatter"},
|
||||
"search.documents": {"query", "source", "results"},
|
||||
}
|
||||
|
||||
|
||||
async def call(tool, args=None) -> ToolMessage:
|
||||
"""Invoke a tool the way the agent does: with a ToolCall dict."""
|
||||
msg = await tool.ainvoke(
|
||||
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
|
||||
)
|
||||
assert isinstance(msg, ToolMessage)
|
||||
return msg
|
||||
|
||||
|
||||
def assert_artifact(artifact, kind):
|
||||
assert artifact is not None
|
||||
assert artifact["kind"] == kind
|
||||
assert isinstance(artifact["title"], str) and artifact["title"]
|
||||
assert set(artifact["data"]) == KIND_DATA_KEYS[kind]
|
||||
json.dumps(artifact)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mealie(monkeypatch):
|
||||
service = MagicMock()
|
||||
monkeypatch.setattr(agents, "mealie_enabled", True)
|
||||
monkeypatch.setattr(agents, "mealie_service", service, raising=False)
|
||||
monkeypatch.setenv("MEALIE_BASE_URL", "http://mealie.local:9000/")
|
||||
monkeypatch.delenv("MEALIE_GROUP_SLUG", raising=False)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obsidian(monkeypatch, tmp_path):
|
||||
(tmp_path / "seed.md").write_text("# seed")
|
||||
monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.delenv("OBSIDIAN_VAULT_NAME", raising=False)
|
||||
service = ObsidianService()
|
||||
monkeypatch.setattr(agents, "obsidian_enabled", True)
|
||||
monkeypatch.setattr(agents, "obsidian_service", service, raising=False)
|
||||
return service
|
||||
|
||||
|
||||
# --- Mealie -----------------------------------------------------------------
|
||||
|
||||
RECIPE = {
|
||||
"name": "Chicken Tikka Masala",
|
||||
"slug": "chicken-tikka-masala",
|
||||
"description": "Creamy and spiced.",
|
||||
"ingredients": [{"display": "1 lb chicken"}, {"display": "1 cup yogurt"}],
|
||||
"instructions": [
|
||||
{"title": "Marinate", "text": "Mix chicken and yogurt."},
|
||||
{"title": "", "text": "Cook it."},
|
||||
],
|
||||
"prep_time": "20 minutes",
|
||||
"cook_time": "30 minutes",
|
||||
"total_time": "",
|
||||
"servings": "4",
|
||||
"tags": ["Indian", "Dinner"],
|
||||
}
|
||||
|
||||
RECIPE_TEXT = """**Chicken Tikka Masala**
|
||||
Creamy and spiced.
|
||||
|
||||
Servings: 4
|
||||
Prep: 20 minutes | Cook: 30 minutes
|
||||
|
||||
Ingredients:
|
||||
- 1 lb chicken
|
||||
- 1 cup yogurt
|
||||
|
||||
Instructions:
|
||||
1. Mix chicken and yogurt.
|
||||
2. Cook it.
|
||||
|
||||
Tags: Indian, Dinner"""
|
||||
|
||||
|
||||
async def test_mealie_get_recipe(mealie):
|
||||
mealie.get_recipe = AsyncMock(return_value=RECIPE)
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
|
||||
|
||||
assert msg.content == RECIPE_TEXT
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.recipe")
|
||||
assert art["title"] == "Chicken Tikka Masala"
|
||||
assert art["data"]["ingredients"] == ["1 lb chicken", "1 cup yogurt"]
|
||||
assert art["data"]["instructions"] == [
|
||||
{"title": "Marinate", "text": "Mix chicken and yogurt."},
|
||||
{"text": "Cook it."},
|
||||
]
|
||||
assert art["source_ref"] == {
|
||||
"system": "mealie",
|
||||
"id": "chicken-tikka-masala",
|
||||
"url": "http://mealie.local:9000/g/home/r/chicken-tikka-masala",
|
||||
}
|
||||
|
||||
|
||||
async def test_mealie_recipe_url_uses_group_slug(mealie, monkeypatch):
|
||||
monkeypatch.setenv("MEALIE_GROUP_SLUG", "family")
|
||||
mealie.get_recipe = AsyncMock(return_value=RECIPE)
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
|
||||
|
||||
assert msg.artifact["source_ref"]["url"].endswith(
|
||||
"/g/family/r/chicken-tikka-masala"
|
||||
)
|
||||
|
||||
|
||||
async def test_mealie_get_recipe_error(mealie):
|
||||
mealie.get_recipe = AsyncMock(side_effect=Exception("404"))
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "nope"})
|
||||
|
||||
assert msg.content == "Error fetching recipe: 404"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
MEAL_PLANS = {
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-19",
|
||||
"total_plans": 3,
|
||||
"plans_by_date": {
|
||||
"2026-10-13": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "",
|
||||
}
|
||||
],
|
||||
"2026-10-12": [
|
||||
{
|
||||
"entry_type": "lunch",
|
||||
"title": "Leftovers",
|
||||
"recipe_name": "",
|
||||
"recipe_slug": "",
|
||||
"note": "from Sunday",
|
||||
},
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Chicken Tikka Masala",
|
||||
"recipe_slug": "chicken-tikka-masala",
|
||||
"note": "",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
MEAL_PLANS_TEXT = """Meal plans from 2026-10-12 to 2026-10-19 (3 meals):
|
||||
|
||||
2026-10-12:
|
||||
- Lunch: Leftovers
|
||||
Note: from Sunday
|
||||
- Dinner: Chicken Tikka Masala
|
||||
|
||||
2026-10-13:
|
||||
- Dinner: Tacos"""
|
||||
|
||||
|
||||
async def test_mealie_meal_plans(mealie):
|
||||
mealie.get_meal_plans = AsyncMock(return_value=MEAL_PLANS)
|
||||
|
||||
msg = await call(agents.mealie_meal_plans, {})
|
||||
|
||||
assert msg.content == MEAL_PLANS_TEXT
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.meal_plan")
|
||||
assert art["title"] == "Meal plan · Oct 12–19"
|
||||
assert [d["date"] for d in art["data"]["days"]] == ["2026-10-12", "2026-10-13"]
|
||||
assert art["data"]["days"][0]["meals"] == [
|
||||
{"entry_type": "lunch", "name": "Leftovers", "note": "from Sunday"},
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"name": "Chicken Tikka Masala",
|
||||
"recipe_slug": "chicken-tikka-masala",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_mealie_meal_plans_empty(mealie):
|
||||
mealie.get_meal_plans = AsyncMock(
|
||||
return_value={
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-19",
|
||||
"total_plans": 0,
|
||||
"plans_by_date": {},
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_meal_plans, {})
|
||||
|
||||
assert msg.content == "No meal plans found from 2026-10-12 to 2026-10-19."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_mealie_todays_meals(mealie):
|
||||
mealie.get_todays_meals = AsyncMock(
|
||||
return_value={
|
||||
"today": "2026-10-12",
|
||||
"total_meals": 1,
|
||||
"meals": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "extra salsa",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_todays_meals)
|
||||
|
||||
assert msg.content == (
|
||||
"Meals planned for today (2026-10-12):\n- Dinner: Tacos\n Note: extra salsa"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.meal_plan")
|
||||
assert art["title"] == "Today's meals · Oct 12"
|
||||
assert art["data"] == {
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-12",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"meals": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "extra salsa",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_mealie_todays_meals_empty(mealie):
|
||||
mealie.get_todays_meals = AsyncMock(
|
||||
return_value={"today": "2026-10-12", "total_meals": 0, "meals": []}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_todays_meals)
|
||||
|
||||
assert msg.content == "No meals planned for today (2026-10-12)."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_mealie_shopping_lists(mealie):
|
||||
mealie.get_shopping_lists = AsyncMock(
|
||||
return_value={
|
||||
"total_lists": 1,
|
||||
"total_unchecked_items": 2,
|
||||
"shopping_lists": [
|
||||
{
|
||||
"name": "Groceries",
|
||||
"unchecked_items": [
|
||||
{"name": "Milk", "quantity": 1, "note": "", "food": None},
|
||||
{"name": "Eggs", "quantity": 12.0, "note": "", "food": None},
|
||||
],
|
||||
"checked_items": [
|
||||
{"name": "Bread", "quantity": 1, "note": "", "food": None}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_shopping_lists)
|
||||
|
||||
assert msg.content == (
|
||||
"Shopping lists (2 items needed):\n"
|
||||
"\n**Groceries** (2 items needed)\n"
|
||||
" - [ ] Milk\n"
|
||||
" - [ ] Eggs x12.0\n"
|
||||
" (1 items already checked off)"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.shopping_lists")
|
||||
assert art["data"]["lists"] == [
|
||||
{
|
||||
"name": "Groceries",
|
||||
"items": [
|
||||
{"name": "Milk", "quantity": 1, "checked": False},
|
||||
{"name": "Eggs", "quantity": 12.0, "checked": False},
|
||||
{"name": "Bread", "quantity": 1, "checked": True},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_mealie_shopping_lists_empty(mealie):
|
||||
mealie.get_shopping_lists = AsyncMock(
|
||||
return_value={
|
||||
"total_lists": 0,
|
||||
"total_unchecked_items": 0,
|
||||
"shopping_lists": [],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_shopping_lists)
|
||||
|
||||
assert msg.content == "No shopping lists found."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool,args",
|
||||
[
|
||||
(agents.mealie_get_recipe, {"recipe_slug": "x"}),
|
||||
(agents.mealie_meal_plans, {}),
|
||||
(agents.mealie_todays_meals, {}),
|
||||
(agents.mealie_shopping_lists, {}),
|
||||
],
|
||||
)
|
||||
async def test_mealie_not_configured(monkeypatch, tool, args):
|
||||
monkeypatch.setattr(agents, "mealie_enabled", False)
|
||||
|
||||
msg = await call(tool, args)
|
||||
|
||||
assert msg.content == (
|
||||
"Mealie integration is not configured. Please set MEALIE_BASE_URL and "
|
||||
"MEALIE_API_TOKEN environment variables."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- Obsidian ---------------------------------------------------------------
|
||||
|
||||
NOTE = """---
|
||||
title: Vet visit
|
||||
date: 2026-10-01
|
||||
tags: [simba]
|
||||
---
|
||||
Simba saw the [[Dr Smith|vet]] today. #simba #health #simba
|
||||
"""
|
||||
|
||||
|
||||
async def test_obsidian_read_note(obsidian, monkeypatch):
|
||||
monkeypatch.setenv("OBSIDIAN_VAULT_NAME", "Home Vault")
|
||||
os.makedirs(os.path.join(obsidian.vault_path, "notes"))
|
||||
with open(os.path.join(obsidian.vault_path, "notes", "vet visit.md"), "w") as f:
|
||||
f.write(NOTE)
|
||||
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "notes/vet visit.md"})
|
||||
|
||||
assert msg.content == (
|
||||
"File: notes/vet visit.md\n\n"
|
||||
"Frontmatter:\n{'title': 'Vet visit', 'date': datetime.date(2026, 10, 1), "
|
||||
"'tags': ['simba']}\n\n"
|
||||
"Content:\nSimba saw the vet today. #simba #health #simba\n\n"
|
||||
"Tags: simba, health, simba\n"
|
||||
"Contains 1 wikilinks and 0 embeds"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "obsidian.note")
|
||||
assert art["title"] == "Vet visit"
|
||||
assert art["data"]["frontmatter"] == {
|
||||
"title": "Vet visit",
|
||||
"date": "2026-10-01",
|
||||
"tags": ["simba"],
|
||||
}
|
||||
assert art["data"]["tags"] == ["simba", "health"]
|
||||
assert art["data"]["markdown"] == "Simba saw the vet today. #simba #health #simba"
|
||||
assert art["source_ref"] == {
|
||||
"system": "obsidian",
|
||||
"path": "notes/vet visit.md",
|
||||
"url": "obsidian://open?vault=Home%20Vault&file=notes%2Fvet%20visit.md",
|
||||
}
|
||||
|
||||
|
||||
async def test_obsidian_read_note_caps_markdown_and_omits_url(obsidian):
|
||||
with open(os.path.join(obsidian.vault_path, "big.md"), "w") as f:
|
||||
f.write("x" * (NOTE_MAX_CHARS + 500))
|
||||
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "big.md"})
|
||||
|
||||
art = msg.artifact
|
||||
assert art["title"] == "big"
|
||||
assert len(art["data"]["markdown"]) <= NOTE_MAX_CHARS
|
||||
assert art["source_ref"] == {"system": "obsidian", "path": "big.md"}
|
||||
|
||||
|
||||
async def test_obsidian_read_note_missing(obsidian):
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "nope.md"})
|
||||
|
||||
assert msg.content == (
|
||||
"Note not found at 'nope.md'. Please check the path is correct."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_journal_get_today(obsidian):
|
||||
path = obsidian.get_daily_note_path()
|
||||
full = os.path.join(obsidian.vault_path, path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
raw = "## Tasks\n- [ ] Feed Simba\n"
|
||||
with open(full, "w") as f:
|
||||
f.write(raw)
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
|
||||
assert msg.content == f"Daily note for {today}:\n\n{raw}"
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "obsidian.note")
|
||||
assert art["title"].startswith("Daily note · ")
|
||||
assert art["data"]["path"] == path
|
||||
assert art["data"]["markdown"] == "## Tasks\n- [ ] Feed Simba"
|
||||
|
||||
|
||||
async def test_journal_get_today_missing(obsidian):
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
|
||||
assert msg.content == (
|
||||
f"No daily note found for {today}. Use journal_add_task to create one."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_obsidian_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(agents, "obsidian_enabled", False)
|
||||
|
||||
for tool, args in [
|
||||
(agents.obsidian_read_note, {"relative_path": "a.md"}),
|
||||
(agents.obsidian_search_notes, {"query": "a"}),
|
||||
]:
|
||||
msg = await call(tool, args)
|
||||
assert msg.content == (
|
||||
"Obsidian integration is not configured. Please set "
|
||||
"OBSIDIAN_VAULT_PATH environment variable."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
assert msg.content == "Obsidian integration is not configured."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- Search -----------------------------------------------------------------
|
||||
|
||||
PAPERLESS_DOCS = [
|
||||
Document(
|
||||
page_content="Rabies vaccine\nadministered. " + "y" * 600,
|
||||
metadata={
|
||||
"source": "paperless",
|
||||
"created_date": 1760227200.0,
|
||||
"filename": "vet_invoice.pdf",
|
||||
"document_type": "Invoice",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_simba_search(monkeypatch):
|
||||
query_mock = AsyncMock(return_value=("Source: {...}\nContent: x", PAPERLESS_DOCS))
|
||||
monkeypatch.setattr(agents, "query_vector_store", query_mock)
|
||||
|
||||
msg = await call(agents.simba_search, {"query": "vet records"})
|
||||
|
||||
assert msg.content == "Source: {...}\nContent: x"
|
||||
query_mock.assert_awaited_once_with(query="vet records", source="paperless")
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "search.documents")
|
||||
assert art["title"] == "Search: vet records"
|
||||
assert art["data"]["source"] == "paperless"
|
||||
(result,) = art["data"]["results"]
|
||||
assert result["title"] == "vet_invoice"
|
||||
assert result["filename"] == "vet_invoice.pdf"
|
||||
assert result["document_type"] == "Invoice"
|
||||
assert len(result["date"]) == 10
|
||||
assert result["snippet"].startswith("Rabies vaccine administered. ")
|
||||
assert len(result["snippet"]) <= SNIPPET_MAX_CHARS
|
||||
|
||||
|
||||
async def test_simba_search_empty(monkeypatch):
|
||||
monkeypatch.setattr(agents, "query_vector_store", AsyncMock(return_value=("", [])))
|
||||
|
||||
msg = await call(agents.simba_search, {"query": "nothing"})
|
||||
|
||||
assert msg.content == ""
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_obsidian_search_notes(obsidian, monkeypatch):
|
||||
docs = [
|
||||
Document(
|
||||
page_content="Simba's vet is Dr Smith.",
|
||||
metadata={
|
||||
"source": "obsidian",
|
||||
"filepath": os.path.join(obsidian.vault_path, "pets", "simba.md"),
|
||||
"tags": ["simba"],
|
||||
"created_at": "2026-01-02T10:00:00",
|
||||
},
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
agents, "query_vector_store", AsyncMock(return_value=("serialized", docs))
|
||||
)
|
||||
|
||||
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
|
||||
|
||||
assert msg.content == "serialized"
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "search.documents")
|
||||
assert art["data"]["results"] == [
|
||||
{
|
||||
"snippet": "Simba's vet is Dr Smith.",
|
||||
"title": "simba",
|
||||
"path": "pets/simba.md",
|
||||
"date": "2026-01-02",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_obsidian_search_notes_error(obsidian, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
agents, "query_vector_store", AsyncMock(side_effect=Exception("db down"))
|
||||
)
|
||||
|
||||
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
|
||||
|
||||
assert msg.content == "Error searching Obsidian notes: db down"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
def test_search_documents_artifact_empty():
|
||||
assert search_documents_artifact("q", "paperless", []) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end,expected",
|
||||
[
|
||||
("2026-10-12", "2026-10-19", "Oct 12–19"),
|
||||
("2026-10-28", "2026-11-03", "Oct 28–Nov 3"),
|
||||
("2026-12-28", "2027-01-03", "Dec 28, 2026–Jan 3, 2027"),
|
||||
("2026-10-12", "2026-10-12", "Oct 12"),
|
||||
("bad", "2026-10-12", "bad–2026-10-12"),
|
||||
],
|
||||
)
|
||||
def test_format_date_range(start, end, expected):
|
||||
assert format_date_range(start, end) == expected
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Tests for the YNAB tools' content_and_artifact output.
|
||||
|
||||
`ensure_fresh` and the `_query` DB helper are mocked; the fake answers each
|
||||
query by matching a fragment of its SQL.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
from blueprints.ynab import tools
|
||||
|
||||
KIND_DATA_KEYS = {
|
||||
"ynab.summary": {
|
||||
"month",
|
||||
"ready_to_assign",
|
||||
"income",
|
||||
"assigned",
|
||||
"spent",
|
||||
"net_worth",
|
||||
"overspent",
|
||||
},
|
||||
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
|
||||
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
|
||||
"ynab.category_budget": {"month", "groups"},
|
||||
}
|
||||
|
||||
|
||||
async def call(tool, args=None) -> ToolMessage:
|
||||
"""Invoke a tool the way the agent does: with a ToolCall dict."""
|
||||
msg = await tool.ainvoke(
|
||||
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
|
||||
)
|
||||
assert isinstance(msg, ToolMessage)
|
||||
return msg
|
||||
|
||||
|
||||
def assert_artifact(artifact, kind, optional=()):
|
||||
assert artifact is not None
|
||||
assert artifact["kind"] == kind
|
||||
assert isinstance(artifact["title"], str) and artifact["title"]
|
||||
assert KIND_DATA_KEYS[kind] <= set(artifact["data"])
|
||||
assert set(artifact["data"]) <= KIND_DATA_KEYS[kind] | set(optional)
|
||||
json.dumps(artifact)
|
||||
|
||||
|
||||
def fake_db(monkeypatch, responses: dict, synced=True):
|
||||
"""Route `_query` calls to `responses` by SQL fragment; honour LIMIT."""
|
||||
calls = []
|
||||
|
||||
async def fake_query(sql, *params):
|
||||
calls.append((sql, params))
|
||||
for fragment, rows in responses.items():
|
||||
if fragment in sql:
|
||||
limit = re.search(r"LIMIT (\d+)", sql)
|
||||
return rows[: int(limit.group(1))] if limit else rows
|
||||
raise AssertionError(f"unexpected query: {sql}")
|
||||
|
||||
async def fake_ensure_fresh():
|
||||
return SimpleNamespace(budget_name="Home") if synced else None
|
||||
|
||||
monkeypatch.setattr(tools, "_query", fake_query)
|
||||
monkeypatch.setattr(tools, "ensure_fresh", fake_ensure_fresh)
|
||||
monkeypatch.setattr(tools, "_today", lambda: date(2026, 10, 12))
|
||||
return calls
|
||||
|
||||
|
||||
# --- ynab_budget_summary ----------------------------------------------------
|
||||
|
||||
SUMMARY_DB = {
|
||||
"FROM ynab_months": [
|
||||
{
|
||||
"to_be_budgeted": Decimal("120.50"),
|
||||
"income": Decimal("5000.00"),
|
||||
"budgeted": Decimal("4800.00"),
|
||||
"age_of_money": 42,
|
||||
}
|
||||
],
|
||||
"mc.balance < 0": [{"name": "Dining Out", "balance": Decimal("-23.10")}],
|
||||
"AS spent": [{"spent": Decimal("3100.25")}],
|
||||
"GROUP BY on_budget": [
|
||||
{"on_budget": True, "total": Decimal("10000.00")},
|
||||
{"on_budget": False, "total": Decimal("-2500.00")},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_budget_summary(monkeypatch):
|
||||
fake_db(monkeypatch, SUMMARY_DB)
|
||||
|
||||
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
|
||||
|
||||
assert msg.content == (
|
||||
"YNAB budget 'Home' for 2026-10:\n"
|
||||
"Ready to Assign: $120.50\n"
|
||||
"Income: $5,000.00\n"
|
||||
"Assigned: $4,800.00\n"
|
||||
"Spent: $3,100.25\n"
|
||||
"Age of money: 42 days\n"
|
||||
"Overspent: Dining Out (-$23.10)\n"
|
||||
"Net worth: $7,500.00 (budget accounts $10,000.00, "
|
||||
"tracking accounts -$2,500.00)"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "ynab.summary", optional={"age_of_money"})
|
||||
assert art["title"] == "Budget · October 2026"
|
||||
assert art["data"] == {
|
||||
"month": "2026-10",
|
||||
"ready_to_assign": 120.5,
|
||||
"income": 5000.0,
|
||||
"assigned": 4800.0,
|
||||
"spent": 3100.25,
|
||||
"age_of_money": 42,
|
||||
"net_worth": 7500.0,
|
||||
"overspent": [{"name": "Dining Out", "available": -23.1}],
|
||||
}
|
||||
|
||||
|
||||
async def test_budget_summary_without_age_of_money(monkeypatch):
|
||||
db = dict(SUMMARY_DB)
|
||||
db["FROM ynab_months"] = [
|
||||
{**SUMMARY_DB["FROM ynab_months"][0], "age_of_money": None}
|
||||
]
|
||||
fake_db(monkeypatch, db)
|
||||
|
||||
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
|
||||
|
||||
assert "Age of money" not in msg.content
|
||||
assert "age_of_money" not in msg.artifact["data"]
|
||||
|
||||
|
||||
async def test_budget_summary_no_month_data(monkeypatch):
|
||||
fake_db(monkeypatch, {"FROM ynab_months": []})
|
||||
|
||||
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
|
||||
|
||||
assert msg.content == "No YNAB budget data for 2026-10."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- ynab_search_transactions -----------------------------------------------
|
||||
|
||||
|
||||
def _txn(i, amount, **extra):
|
||||
return {
|
||||
"id": f"t{i}",
|
||||
"date": date(2026, 10, 12 - i % 10),
|
||||
"amount": Decimal(amount),
|
||||
"memo": None,
|
||||
"approved": True,
|
||||
"account_name": "Checking",
|
||||
"payee_name": "Costco",
|
||||
"category_name": "Groceries",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
TXNS = [
|
||||
_txn(0, "-54.20", memo="milk"),
|
||||
_txn(1, "-12.00", approved=False, payee_name=None, category_name=None),
|
||||
_txn(2, "100.00"),
|
||||
]
|
||||
TXN_TOTALS = {
|
||||
"count(*) AS n": [
|
||||
{"n": 3, "outflow": Decimal("-66.20"), "inflow": Decimal("100.00")}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_search_transactions(monkeypatch):
|
||||
calls = fake_db(monkeypatch, {**TXN_TOTALS, "SELECT * FROM lines": TXNS})
|
||||
|
||||
msg = await call(tools.ynab_search_transactions, {"limit": 2})
|
||||
|
||||
assert msg.content == (
|
||||
"Found 3 transactions from 2026-09-12 to 2026-10-12. "
|
||||
"Outflows -$66.20, inflows $100.00.\n"
|
||||
"- 2026-10-12: Costco -$54.20 (Groceries; Checking) — milk\n"
|
||||
"- 2026-10-11: Unknown payee -$12.00 (Uncategorized; Checking) [unapproved]\n"
|
||||
"(Showing 2 most recent of 3)"
|
||||
)
|
||||
assert "LIMIT 500" in calls[-1][0]
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "ynab.transactions")
|
||||
assert art["title"] == "Transactions · Sep 12–Oct 12"
|
||||
data = art["data"]
|
||||
assert data["start_date"] == "2026-09-12"
|
||||
assert data["end_date"] == "2026-10-12"
|
||||
assert data["count"] == 3
|
||||
assert data["total"] == pytest.approx(33.8)
|
||||
# the artifact keeps every row, not just the `limit` shown to the model
|
||||
assert len(data["transactions"]) == 3
|
||||
assert data["transactions"][0] == {
|
||||
"date": "2026-10-12",
|
||||
"payee": "Costco",
|
||||
"category": "Groceries",
|
||||
"account": "Checking",
|
||||
"memo": "milk",
|
||||
"amount": -54.2,
|
||||
}
|
||||
assert "memo" not in data["transactions"][1]
|
||||
|
||||
|
||||
async def test_search_transactions_caps_artifact_rows(monkeypatch):
|
||||
many = [_txn(i, "-1.00") for i in range(600)]
|
||||
totals = {"count(*) AS n": [{"n": 600, "outflow": Decimal("-600"), "inflow": 0}]}
|
||||
fake_db(monkeypatch, {**totals, "SELECT * FROM lines": many})
|
||||
|
||||
msg = await call(tools.ynab_search_transactions, {"payee": "costco"})
|
||||
|
||||
assert msg.content.endswith("(Showing 20 most recent of 600)")
|
||||
assert len(msg.artifact["data"]["transactions"]) == tools.ARTIFACT_MAX_TRANSACTIONS
|
||||
assert msg.artifact["data"]["start_date"] is None
|
||||
assert msg.artifact["title"] == "Transactions · through 2026-10-12"
|
||||
|
||||
|
||||
async def test_search_transactions_empty(monkeypatch):
|
||||
fake_db(monkeypatch, {"count(*) AS n": [{"n": 0, "outflow": 0, "inflow": 0}]})
|
||||
|
||||
msg = await call(tools.ynab_search_transactions, {})
|
||||
|
||||
assert msg.content == "No transactions found from 2026-09-12 to 2026-10-12."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_search_transactions_bad_date(monkeypatch):
|
||||
fake_db(monkeypatch, {})
|
||||
|
||||
msg = await call(tools.ynab_search_transactions, {"start_date": "nope"})
|
||||
|
||||
assert msg.content == "Dates must be YYYY-MM-DD."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- ynab_spending_report ---------------------------------------------------
|
||||
|
||||
SPENDING_ROWS = [
|
||||
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
|
||||
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
|
||||
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
|
||||
]
|
||||
|
||||
|
||||
async def test_spending_report(monkeypatch):
|
||||
fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS})
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {"top": 2})
|
||||
|
||||
assert msg.content == (
|
||||
"Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n"
|
||||
"- Groceries: $400.00 (10 txns)\n"
|
||||
"- Dining Out: $150.50 (5 txns)\n"
|
||||
"- 1 others: $60.00"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "ynab.spending_report")
|
||||
assert art["title"] == "Spending by category · Oct 1–12"
|
||||
assert art["data"] == {
|
||||
"start_date": "2026-10-01",
|
||||
"end_date": "2026-10-12",
|
||||
"group_by": "category",
|
||||
"rows": [
|
||||
{"key": "Groceries", "amount": 400.0, "count": 10},
|
||||
{"key": "Dining Out", "amount": 150.5, "count": 5},
|
||||
{"key": "Gas", "amount": 60.0, "count": 2},
|
||||
],
|
||||
"total": 610.5,
|
||||
}
|
||||
|
||||
|
||||
async def test_spending_report_by_month(monkeypatch):
|
||||
rows = [
|
||||
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
|
||||
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
|
||||
]
|
||||
fake_db(monkeypatch, {"GROUP BY 1": rows})
|
||||
|
||||
msg = await call(
|
||||
tools.ynab_spending_report,
|
||||
{"group_by": "month", "start_date": "2026-09-01"},
|
||||
)
|
||||
|
||||
assert msg.content.endswith("Monthly average: $150.00")
|
||||
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
|
||||
|
||||
|
||||
async def test_spending_report_empty_and_invalid(monkeypatch):
|
||||
fake_db(monkeypatch, {"GROUP BY 1": []})
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
|
||||
assert msg.artifact is None
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {"group_by": "vibes"})
|
||||
assert msg.content == (
|
||||
"group_by must be one of: category, category_group, payee, account, month."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- ynab_category_budget ---------------------------------------------------
|
||||
|
||||
|
||||
def _cat(group, name, budgeted, activity, balance, goal=None):
|
||||
return {
|
||||
"group_name": group,
|
||||
"name": name,
|
||||
"goal_target": Decimal(goal) if goal else None,
|
||||
"budgeted": Decimal(budgeted),
|
||||
"activity": Decimal(activity),
|
||||
"balance": Decimal(balance),
|
||||
}
|
||||
|
||||
|
||||
async def test_category_budget(monkeypatch):
|
||||
rows = [
|
||||
_cat("Bills", "Rent", "2000", "-2000", "0"),
|
||||
_cat("Bills", "Unused", "0", "0", "0"),
|
||||
_cat("Food", "Dining Out", "100", "-123.10", "-23.10", goal="150"),
|
||||
]
|
||||
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
|
||||
|
||||
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
|
||||
|
||||
assert msg.content == (
|
||||
"Categories for 2026-10:\n"
|
||||
"\nBills\n"
|
||||
" Rent: assigned $2,000.00, activity -$2,000.00, available $0.00\n"
|
||||
"\nFood\n"
|
||||
" Dining Out: assigned $100.00, activity -$123.10, available -$23.10, "
|
||||
"goal $150.00 [OVERSPENT]"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "ynab.category_budget")
|
||||
assert art["title"] == "Category budget · October 2026"
|
||||
assert art["data"] == {
|
||||
"month": "2026-10",
|
||||
"groups": [
|
||||
{
|
||||
"name": "Bills",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Rent",
|
||||
"assigned": 2000.0,
|
||||
"activity": -2000.0,
|
||||
"available": 0.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Food",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Dining Out",
|
||||
"assigned": 100.0,
|
||||
"activity": -123.1,
|
||||
"available": -23.1,
|
||||
"goal_target": 150.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_category_budget_all_zero_rows(monkeypatch):
|
||||
rows = [_cat("Bills", "Unused", "0", "0", "0")]
|
||||
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
|
||||
|
||||
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
|
||||
|
||||
assert msg.content == "Categories for 2026-10:"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_category_budget_empty(monkeypatch):
|
||||
fake_db(monkeypatch, {"FROM ynab_month_categories mc": []})
|
||||
|
||||
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
|
||||
|
||||
assert msg.content == "No YNAB category data for 2026-10."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- shared paths -----------------------------------------------------------
|
||||
|
||||
ARTIFACT_TOOLS = [
|
||||
tools.ynab_budget_summary,
|
||||
tools.ynab_search_transactions,
|
||||
tools.ynab_spending_report,
|
||||
tools.ynab_category_budget,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
|
||||
async def test_not_synced(monkeypatch, tool):
|
||||
fake_db(monkeypatch, {}, synced=False)
|
||||
|
||||
msg = await call(tool)
|
||||
|
||||
assert msg.content == tools.NOT_SYNCED
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
|
||||
async def test_db_error(monkeypatch, tool):
|
||||
fake_db(monkeypatch, {})
|
||||
|
||||
async def boom(sql, *params):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(tools, "_query", boom)
|
||||
|
||||
msg = await call(tool)
|
||||
|
||||
assert msg.content == f"YNAB error in {tool.name}: RuntimeError: db down"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
def test_text_only_tools_unchanged():
|
||||
assert tools.ynab_account_balances.response_format == "content"
|
||||
assert tools.ynab_recent_changes.response_format == "content"
|
||||
Reference in New Issue
Block a user