Compare commits

...

21 Commits

Author SHA1 Message Date
Ryan Chen 20ec7ca8b6 Return artifacts from YNAB budget, transaction and spending tools
Convert ynab_budget_summary, ynab_search_transactions, ynab_spending_report
and ynab_category_budget to content_and_artifact (ynab.summary,
ynab.transactions, ynab.spending_report, ynab.category_budget). The text
the LLM sees is unchanged. Not-synced, bad-input, empty and error paths
return no artifact; Decimals become floats and dates ISO strings.

ynab_search_transactions now fetches up to 500 rows in one query (with an
id tie-break so ordering is stable) and lists the first `limit` in the
text; the artifact carries all of them. ynab_account_balances,
ynab_recent_changes and ynab_sql stay text-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:12:23 -04:00
Ryan Chen 50d123e4d2 Return artifacts from Mealie, Obsidian and search tools
Convert mealie_get_recipe, mealie_meal_plans, mealie_todays_meals,
mealie_shopping_lists, obsidian_read_note, journal_get_today,
obsidian_search_notes and simba_search to content_and_artifact. The text
the LLM sees is unchanged; the artifact is a JSON-safe ToolArtifact dict
per the Cowork UI contract (mealie.recipe, mealie.meal_plan,
mealie.shopping_lists, obsidian.note, search.documents). Error, empty and
not-configured paths return no artifact.

simba_search's artifact changes from a list of Documents (unused) to a
search.documents dict.

New optional env vars: MEALIE_GROUP_SLUG (default "home") for Mealie v2
recipe links, OBSIDIAN_VAULT_NAME for obsidian://open links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:10:07 -04:00
ryan 417d4061d7 Merge pull request 'Mirror YNAB budget into Postgres with delta sync and change log' (#55) from feat/ynab-mirror into main 2026-10-12 18:07:32 -04:00
Ryan Chen 0fa30edb82 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>
2026-10-12 18:07:26 -04:00
Ryan Chen 10f3f1bcab Merge origin/main into feat/ynab-mirror
Resolve the Makefile .PHONY conflict (desktop + ynab targets) and
regenerate the YNAB migration with aerich as 17 on top of main's
16_add_oidc_states, replacing the colliding 16_add_ynab_mirror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:06:47 -04:00
ryan 3dbd320fe9 Merge pull request 'Cowork A: SSE v2 tool events, artifacts table + API' (#60) from feat/cowork-stream into main 2026-10-12 18:06:02 -04:00
Ryan Chen d1c3b7693e Fix second-round YNAB review findings
- Failure backoff is re-checked under the sync lock, so parallel tool
  calls share one failed YNAB request instead of retrying in turn.
- Budget id: YNAB_BUDGET_ID aliases ("last-used", "default") count as
  unset; state is always keyed by the real budget id, a mismatched
  response errors instead of silently skipping every sync, and more
  than one stored budget is an error rather than an arbitrary pick.
- Change log renames only id fields that resolve to names (account,
  payee, category, group); subtransaction transaction_id stays as is.
- ynab_recent_changes: correct singular for "categories", multi-word
  types, and the "month" type.
- The SQL wrap test now parses the wrapped query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:03:48 -04:00
Ryan Chen 9249f92db8 Fix YNAB mirror review findings
- Full resyncs mark mirror rows missing from the download as deleted;
  YNAB omits deleted entities from non-delta responses, so deletions
  made just before a full sync never reached the mirror.
- YNAB tools return errors as text instead of raising, since ToolNode
  re-raises and would fail the whole chat turn. ynab_sql catches all
  sqlglot errors (e.g. an unbalanced quote) and runs the SQL regenerated
  from the validated tree, which also drops comments that broke the
  LIMIT wrapper. Results keep duplicate column names.
- Sync fetches from YNAB before opening the DB transaction, discards the
  payload if the other container synced meanwhile, re-checks freshness
  under the lock so parallel tool calls share one sync, and backs off
  for 60s after a failure.
- The mirror is pinned to one budget; a changed YNAB_BUDGET_ID errors
  instead of mixing budgets, and an unset id stays on the budget the
  first sync resolved from "last-used".
- Change log: no entries for empty month rollover rows, and id fields
  are logged as names (category, payee, account). ynab_recent_changes
  accepts the names it prints ("assignment") and plurals.
- Outputs sized to the 4000-char tool result cap; ynab_sql reuses the
  shared admin message.
- YNAB API client moves back to utils/ynab_service.py, and the frontend
  tool status labels list the new tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:58:14 -04:00
Ryan Chen 1cdc380870 Add artifacts table migration
Generated with aerich against a fresh Postgres after applying 1-16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:56:26 -04:00
Ryan Chen 1c1ae9f158 Validate artifact ids before querying; match artifacts by str id
asyncpg raises OperationalError (not ValueError) on malformed UUIDs, so
bad artifact ids now 404 and a bad conversation_id 400s before any query.
get_conversation keys artifacts by str(message_id) so matching doesn't
depend on the driver's UUID types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:56:26 -04:00
Ryan Chen c678bf55bc Stream v2 tool events, save artifacts, enrich conversation payload
stream_query emits the new tool_start/tool_end payloads, maps
on_tool_error to tool_end status=error, and saves an artifact linked to
the tool message. GET /api/conversation/<id> adds tool, label and
artifact to tool messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:22 -04:00
Ryan Chen 0cf6229441 Add Artifact model and owner-scoped artifacts API
GET /api/artifacts?conversation_id= lists ArtifactRefs without data;
GET /api/artifacts/<id> returns the full artifact. Both 404 for other
users. create_artifact_from_output persists ToolArtifact dicts and drops
ones over 256 KB serialised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:22 -04:00
Ryan Chen d2f21a6d23 Add tool label registry and SSE v2 tool event shaping
Pure functions build tool_start/tool_end payloads with run_id, friendly
label, truncated input (config dropped), status, duration and artifact ref.
Labels cover every agent tool, both old and #55 YNAB names, and the
homebox_/bookstack_ MCP prefixes, falling back to "Using <tool>…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:22 -04:00
ryan 1a73e26f23 Merge pull request 'Frontend: send access token, refresh once on 401' (#59) from fix/access-token-auth into main 2026-10-12 17:49:18 -04:00
ryan 86e1912871 Merge pull request 'Cowork UI (phase 2) plan' (#58) from plan/cowork-ui into main 2026-10-12 17:47:52 -04:00
Ryan Chen abf9415696 Add Cowork UI (phase 2) plan and drop quick-ask window
Defines the shared contract (SSE v2 tool events, artifact kinds and
storage, artifacts API, uploads), five parallel tasks with file
ownership, merge order and verification, and removes the quick-ask
window from the desktop plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:47:43 -04:00
ryan b77e21474a Merge pull request 'Accept access tokens on protected routes; persist OIDC state' (#57) from fix/access-token-auth-backend into main 2026-10-12 17:44:56 -04:00
Ryan Chen 004a9bc85d Frontend: send access token, refresh once on 401
Collapse fetchWithRefreshToken/fetchWithAuth into a single fetchWithAuth
that sends the access token, refreshes up front when none is stored, and
on a 401 refreshes once (deduplicated across concurrent calls) and retries.
The refresh token is now only sent to /api/user/refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:34 -04:00
Ryan Chen bc3e3d2797 Accept access tokens on protected routes; persist OIDC state
- Add transitional @jwt_access_required (accepts access tokens, and for now
  also refresh tokens) and use it on every protected route and inside
  admin_required. /api/user/refresh stays refresh-only.
- Move pending OIDC state/PKCE verifiers from an in-process dict to an
  oidc_states table (10 min TTL, single-use) so logins survive a blue/green
  flip. Drop the unused redirect_after_login.
- Unit tests for the decorator and the state store.

Deploy this commit on its own, before the access-token frontend: the old
backend answers access tokens with 422, so the new frontend only works once
this backend is live on both colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:34 -04:00
ryan fde8208afe Merge pull request 'Desktop client: exploration doc + Tauri shell spike' (#56) from explore/desktop-client into main 2026-10-12 17:42:25 -04:00
Ryan Chen 8f02318542 Mirror YNAB budget into Postgres with delta sync and change log
YNAB has no event feed, so keep a local copy in ynab_* tables synced via
server_knowledge delta requests: full download on first run, deltas every
YNAB_SYNC_INTERVAL seconds and on demand before each tool call, full
resync daily. Each sync diffs against the mirror and appends real edits
(transactions, assignments, renames) to ynab_changes, the edit history
YNAB doesn't keep.

Agent YNAB tools now answer from the mirror: budget summary (spending
computed from transactions, since YNAB's month activity nets in credit
card payment categories), account balances, transaction search with
splits expanded, spending reports by category/payee/account/month,
category budget, and recent changes. Adds an admin-only read-only
ynab_sql tool guarded by a sqlglot allowlist (scope-resolved tables, no
dangerous functions), a READ ONLY transaction, a timeout, and a row cap.

Replaces utils/ynab_service.py and drops the unused ynab SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:32:38 -04:00
50 changed files with 5454 additions and 1074 deletions
+6
View File
@@ -62,12 +62,16 @@ OIDC_USE_DISCOVERY=true
YNAB_ACCESS_TOKEN=your-ynab-personal-access-token
# Optional: Specify a budget ID, or leave empty to use the default/first budget
YNAB_BUDGET_ID=
# Seconds between background YNAB delta syncs (agent tools also sync on demand)
YNAB_SYNC_INTERVAL=900
# Mealie Configuration
# Base URL for your Mealie instance (e.g., http://192.168.1.5:9000 or https://mealie.example.com)
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)
@@ -165,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.
+3 -2
View File
@@ -80,6 +80,7 @@ make redeploy # git pull + scripts/deploy.sh (build new color, wait healthy,
- `imessage/` - Twilio webhook receiver for iMessage (HMAC-validated, number allowlist)
- `whatsapp/` - Twilio webhook receiver for WhatsApp (TWIML replies, SendBlue send support)
- `scheduled_messages/` - Scheduled message delivery with background scheduler
- `ynab/` - Postgres mirror of the YNAB budget (`ynab_*` tables), delta-synced via `server_knowledge` (background loop + on-demand before each tool call, full resync daily that also marks rows missing from the download as deleted); `ynab_changes` is the edit history YNAB itself doesn't keep. Agent tools in `tools.py`; admin-only read-only `ynab_sql` in `sql_tool.py`. Manual sync: `make ynab-sync` / `make ynab-resync`
- `config/` - Configuration modules
- `db.py` - Database setup
- `oidc_config.py` - OIDC authentication configuration
@@ -88,7 +89,7 @@ make redeploy # git pull + scripts/deploy.sh (build new color, wait healthy,
- `cleaner.py` - PDF cleaning and summarization
- `image_process.py` - Image description with LLM
- `request.py` - Paperless-NGX API client
- `obsidian_service.py`, `mealie_service.py`, `ynab_service.py`, `s3_client.py`, `simbakit_service.py` - integrations with Obsidian notes, Mealie recipes, YNAB budgets, S3, and SimbaKit
- `obsidian_service.py`, `mealie_service.py`, `ynab_service.py`, `s3_client.py`, `simbakit_service.py` - integrations with Obsidian notes, Mealie recipes, YNAB budgets (API client for the `blueprints/ynab/` mirror), S3, and SimbaKit
- `homebox_mcp.py` - Homebox inventory tools via the `homebox-mcp` stdio MCP server; admin-only (compiled into `app.admin_agent`, picked per user by `get_agent_for`)
- `bookstack_mcp.py` - BookStack wiki tools (read/create/update, no deletes) via the `bookstack-mcp` npm stdio server; admin-only like Homebox
- `mcp_tools.py` - shared allowlist + admin-gate wrapping for stdio MCP integrations
@@ -100,7 +101,7 @@ make redeploy # git pull + scripts/deploy.sh (build new color, wait healthy,
- `link_imessage.py` - Link iMessage contacts to users
- `deploy.sh` - Blue-green deployment script
- `migrations/` - Database migration files
- `tests/` - pytest test suite (`tests/unit/` covers email/crypto, YNAB, Obsidian, SimbaKit, rate limiting, user model)
- `tests/` - pytest test suite (`tests/unit/` covers email/crypto, YNAB mirror, Obsidian, SimbaKit, rate limiting, user model)
**simbakit/** (standalone subproject):
- Self-hosted PetKit device poller + web dashboard for Simba's smart devices (feeder, litter box, fountain, purifier)
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend desktop desktop-dev test test-cov reindex reindex-obsidian
.PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend desktop desktop-dev test test-cov reindex reindex-obsidian ynab-sync ynab-resync
# Blue-green: detect the running color (default blue when nothing is running)
ACTIVE_COLOR := $(shell docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 | cut -d- -f2)
@@ -50,6 +50,13 @@ reindex-obsidian:
asyncio.run(index_obsidian_documents()); \
print('Obsidian reindex complete')"
# YNAB mirror: delta sync, or full re-download
ynab-sync:
$(COMPOSE) exec $(RAGGR) python -m blueprints.ynab
ynab-resync:
$(COMPOSE) exec $(RAGGR) python -m blueprints.ynab --full
# Database migrations
migrate:
$(COMPOSE) exec $(RAGGR) aerich upgrade
+16 -7
View File
@@ -13,9 +13,10 @@ from quart import (
request,
send_from_directory,
)
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
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
@@ -27,9 +28,12 @@ import blueprints.scheduled_messages
import blueprints.cron
import blueprints.beli
import blueprints.users.models
from blueprints.users.decorators import jwt_access_required
from config.db import TORTOISE_CONFIG
from metrics import render_metrics
from blueprints.conversation.agents import build_agent
from blueprints.ynab.sql_tool import ynab_sql
from blueprints.ynab.sync import ynab_configured, ynab_sync_loop
from utils.bookstack_mcp import load_bookstack_tools
from utils.homebox_mcp import load_homebox_tools
@@ -44,7 +48,7 @@ logging.basicConfig(
)
# Ensure YNAB and Mealie loggers are visible
logging.getLogger("utils.ynab_service").setLevel(logging.INFO)
logging.getLogger("blueprints.ynab.sync").setLevel(logging.INFO)
logging.getLogger("utils.mealie_service").setLevel(logging.INFO)
logging.getLogger("blueprints.conversation.agents").setLevel(logging.INFO)
@@ -70,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():
@@ -109,6 +114,8 @@ async def lifespan():
await checkpointer.setup()
app.main_agent = build_agent(checkpointer)
admin_tools = await load_homebox_tools() + await load_bookstack_tools()
if ynab_configured():
admin_tools.append(ynab_sql)
app.admin_agent = (
build_agent(checkpointer, admin_tools) if admin_tools else app.main_agent
)
@@ -118,14 +125,16 @@ async def lifespan():
await ensure_beli_nightly_event()
watcher_task = None
background_tasks = []
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
watcher_task = asyncio.create_task(_obsidian_sync_loop())
background_tasks.append(asyncio.create_task(_obsidian_sync_loop()))
if ynab_configured():
background_tasks.append(asyncio.create_task(ynab_sync_loop()))
yield
if watcher_task is not None:
watcher_task.cancel()
for task in background_tasks:
task.cancel()
logging.info("Closing Tortoise ORM connections...")
await Tortoise.close_connections()
@@ -205,7 +214,7 @@ async def health():
@app.route("/api/messages", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def get_messages():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
+56
View File
@@ -0,0 +1,56 @@
import uuid
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity
from blueprints.conversation.models import Conversation
from blueprints.users.decorators import jwt_access_required
from .logic import artifact_full, artifact_summary
from .models import Artifact
artifacts_blueprint = Blueprint("artifacts_api", __name__, url_prefix="/api/artifacts")
def _parse_uuid(value: str) -> uuid.UUID | None:
"""Validate ids before querying: asyncpg raises OperationalError on bad UUIDs."""
try:
return uuid.UUID(value)
except (ValueError, AttributeError, TypeError):
return None
@artifacts_blueprint.get("")
@jwt_access_required
async def list_artifacts():
user_uuid = get_jwt_identity()
raw_conversation_id = request.args.get("conversation_id", "").strip()
if not raw_conversation_id:
return jsonify({"error": "conversation_id is required"}), 400
conversation_id = _parse_uuid(raw_conversation_id)
if conversation_id is None:
return jsonify({"error": "conversation_id must be a UUID"}), 400
conversation = await Conversation.get_or_none(id=conversation_id, user_id=user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
artifacts = await Artifact.filter(
conversation_id=conversation.id, user_id=user_uuid
).order_by("created_at")
return jsonify([artifact_summary(a) for a in artifacts])
@artifacts_blueprint.get("/<artifact_id>")
@jwt_access_required
async def get_artifact(artifact_id: str):
user_uuid = get_jwt_identity()
parsed_id = _parse_uuid(artifact_id)
artifact = (
await Artifact.get_or_none(id=parsed_id, user_id=user_uuid)
if parsed_id is not None
else None
)
if artifact is None:
return jsonify({"error": "artifact not found"}), 404
return jsonify(artifact_full(artifact))
+78
View File
@@ -0,0 +1,78 @@
import json
import logging
from .models import Artifact
logger = logging.getLogger(__name__)
# Artifacts whose serialised `data` exceeds this are dropped (the tool text still persists).
ARTIFACT_MAX_BYTES = 256 * 1024
def artifact_ref(artifact: Artifact) -> dict:
"""The lightweight reference sent in SSE events and the conversation payload."""
return {"id": str(artifact.id), "kind": artifact.kind, "title": artifact.title}
def artifact_summary(artifact: Artifact) -> dict:
"""ArtifactRef plus listing metadata, without `data`."""
return {
**artifact_ref(artifact),
"tool": artifact.tool,
"message_id": str(artifact.message_id) if artifact.message_id else None,
"created_at": artifact.created_at.isoformat(),
}
def artifact_full(artifact: Artifact) -> dict:
return {
**artifact_summary(artifact),
"data": artifact.data,
"source_ref": artifact.source_ref,
"conversation_id": str(artifact.conversation_id),
}
async def create_artifact_from_output(
*, output, tool: str, user, conversation, message=None
) -> Artifact | None:
"""Persist `output.artifact` if it is a ToolArtifact dict; otherwise return None.
Tools opt in via `@tool(response_format="content_and_artifact")`. Artifacts
without a `kind` (e.g. simba_search's raw Documents) are ignored, as are
ones whose `data` is not JSON-serialisable or over ARTIFACT_MAX_BYTES.
"""
raw = getattr(output, "artifact", None)
if not isinstance(raw, dict) or not raw.get("kind"):
return None
data = raw.get("data") or {}
source_ref = raw.get("source_ref")
try:
size = len(json.dumps(data).encode("utf-8"))
json.dumps(source_ref)
except (TypeError, ValueError) as e:
logger.warning(
"Dropping %s artifact from %s: not serialisable (%s)", raw["kind"], tool, e
)
return None
if size > ARTIFACT_MAX_BYTES:
logger.warning(
"Dropping %s artifact from %s: %d bytes exceeds %d",
raw["kind"],
tool,
size,
ARTIFACT_MAX_BYTES,
)
return None
return await Artifact.create(
user=user,
conversation=conversation,
message=message,
tool=tool,
kind=str(raw["kind"])[:64],
title=str(raw.get("title") or "")[:255],
data=data,
source_ref=source_ref,
)
+29
View File
@@ -0,0 +1,29 @@
from tortoise import fields
from tortoise.models import Model
class Artifact(Model):
"""A structured tool result (recipe, budget, note…) rendered in the artifact panel."""
id = fields.UUIDField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="artifacts")
conversation = fields.ForeignKeyField(
"models.Conversation", related_name="artifacts", on_delete=fields.CASCADE
)
message = fields.ForeignKeyField(
"models.ConversationMessage",
related_name="artifacts",
null=True,
on_delete=fields.SET_NULL,
)
tool = fields.CharField(max_length=255)
kind = fields.CharField(max_length=64)
title = fields.CharField(max_length=255)
data = fields.JSONField()
source_ref = fields.JSONField(null=True)
version = fields.IntField(default=1)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "artifacts"
+9 -7
View File
@@ -3,7 +3,9 @@ import logging
import os
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity, jwt_refresh_token_required
from quart_jwt_extended import get_jwt_identity
from blueprints.users.decorators import jwt_access_required
from .logic import (
MAX_PROFILES_PER_USER,
@@ -60,7 +62,7 @@ def _serialize_restaurant(r: BeliRestaurant) -> dict:
@beli_blueprint.route("/profiles", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def list_profiles():
user_id = get_jwt_identity()
profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all()
@@ -68,7 +70,7 @@ async def list_profiles():
@beli_blueprint.route("/profiles", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def create_profile():
user_id = get_jwt_identity()
@@ -112,7 +114,7 @@ async def create_profile():
@beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"])
@jwt_refresh_token_required
@jwt_access_required
async def delete_profile(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -125,7 +127,7 @@ async def delete_profile(profile_id: str):
@beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def refresh_profile_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -137,7 +139,7 @@ async def refresh_profile_endpoint(profile_id: str):
@beli_blueprint.route("/restaurants", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def list_restaurants():
user_id = get_jwt_identity()
profile_ids = [
@@ -161,7 +163,7 @@ async def list_restaurants():
@beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"])
@jwt_refresh_token_required
@jwt_access_required
async def refresh_friends_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
+66 -33
View File
@@ -7,12 +7,12 @@ import time
import uuid
from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import (
get_jwt_identity,
jwt_refresh_token_required,
)
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
from utils.strip_markdown import extract_agent_text
@@ -34,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"))
@@ -126,7 +128,7 @@ def _build_user_message(
@conversation_blueprint.post("/query")
@jwt_refresh_token_required
@jwt_access_required
async def query():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -170,7 +172,7 @@ async def query():
@conversation_blueprint.post("/upload-image")
@jwt_refresh_token_required
@jwt_access_required
async def upload_image():
current_user_uuid = get_jwt_identity()
await blueprints.users.models.User.get(id=current_user_uuid)
@@ -202,14 +204,14 @@ async def upload_image():
@conversation_blueprint.get("/image/<path:image_key>")
@jwt_refresh_token_required
@jwt_access_required
async def serve_image(image_key: str):
url = await s3_presigned_url(image_key)
return jsonify({"url": url})
@conversation_blueprint.post("/stream-query")
@jwt_refresh_token_required
@jwt_access_required
async def stream_query():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -342,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
@@ -448,29 +469,41 @@ async def stream_query():
@conversation_blueprint.route("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def get_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
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),
@@ -483,7 +516,7 @@ async def get_conversation(conversation_id: str):
@conversation_blueprint.post("/")
@jwt_refresh_token_required
@jwt_access_required
async def create_conversation():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
@@ -497,7 +530,7 @@ async def create_conversation():
@conversation_blueprint.get("/")
@jwt_refresh_token_required
@jwt_access_required
async def get_all_conversations():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
@@ -523,7 +556,7 @@ async def get_all_conversations():
@conversation_blueprint.patch("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def rename_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
body = await request.get_json()
@@ -543,7 +576,7 @@ async def rename_conversation(conversation_id: str):
@conversation_blueprint.delete("/<conversation_id>")
@jwt_refresh_token_required
@jwt_access_required
async def delete_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
+188 -244
View File
@@ -11,14 +11,28 @@ 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
from blueprints.ynab.sync import ynab_configured
from blueprints.ynab.tools import YNAB_TOOLS
from utils.google_service import GoogleService
from utils.mealie_service import MealieService
from utils.obsidian_service import ObsidianService
from utils.simbakit_service import SimbaKitService
from utils.ynab_service import YNABService
# Load environment variables
load_dotenv()
@@ -51,14 +65,6 @@ model_with_fallback = cast(
)
client = AsyncTavilyClient(api_key=os.getenv("TAVILY_API_KEY", ""))
# Initialize YNAB service (will only work if YNAB_ACCESS_TOKEN is set)
try:
ynab_service = YNABService()
ynab_enabled = True
except (ValueError, Exception) as e:
print(f"YNAB service not initialized: {e}")
ynab_enabled = False
# Initialize SimbaKit service (will only work if SIMBAKIT_URL is set)
try:
simbakit_service = SimbaKitService()
@@ -138,208 +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
def ynab_budget_summary() -> str:
"""Get overall YNAB budget health: to-be-budgeted, total budgeted, and available amounts."""
if not ynab_enabled:
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
try:
summary = ynab_service.get_budget_summary()
return summary["summary"]
except Exception as e:
return f"Error fetching budget summary: {str(e)}"
MEALIE_NOT_CONFIGURED = "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
@tool
def ynab_search_transactions(
start_date: str = "",
end_date: str = "",
category_name: str = "",
payee_name: str = "",
) -> str:
"""Search YNAB transactions by date range, category, or payee.
Args:
start_date: YYYY-MM-DD (default: 30 days ago)
end_date: YYYY-MM-DD (default: today)
category_name: Partial match on category name
payee_name: Partial match on payee/store name
"""
if not ynab_enabled:
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
try:
result = ynab_service.get_transactions(
start_date=start_date or None,
end_date=end_date or None,
category_name=category_name or None,
payee_name=payee_name or None,
)
if result["count"] == 0:
return "No transactions found matching the specified criteria."
# Format transactions for readability
txn_list = []
for txn in result["transactions"][:10]: # Limit to 10 for readability
txn_list.append(
f"- {txn['date']}: {txn['payee']} - ${abs(txn['amount']):.2f} ({txn['category'] or 'Uncategorized'})"
)
return (
f"Found {result['count']} transactions from {result['start_date']} to {result['end_date']}. "
f"Total: ${abs(result['total_amount']):.2f}\n\n"
+ "\n".join(txn_list)
+ (
f"\n\n(Showing first 10 of {result['count']} transactions)"
if result["count"] > 10
else ""
)
)
except Exception as e:
return f"Error searching transactions: {str(e)}"
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
def ynab_category_spending(month: str = "") -> str:
"""Get YNAB spending breakdown by category, including overspent categories.
Args:
month: YYYY-MM (default: current month)
"""
if not ynab_enabled:
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
try:
result = ynab_service.get_category_spending(month=month or None)
summary = (
f"Budget spending for {result['month']}:\n"
f"Total budgeted: ${result['total_budgeted']:.2f}\n"
f"Total spent: ${result['total_spent']:.2f}\n"
f"Total available: ${result['total_available']:.2f}\n"
)
if result["overspent_categories"]:
summary += (
f"\nOverspent categories ({len(result['overspent_categories'])}):\n"
)
for cat in result["overspent_categories"][:5]:
summary += f"- {cat['name']}: Budgeted ${cat['budgeted']:.2f}, Spent ${cat['spent']:.2f}, Over by ${cat['overspent_by']:.2f}\n"
# All categories with spending activity
spending_cats = [c for c in result["categories"] if c["activity"] < 0]
if spending_cats:
summary += "\nSpending by category:\n"
for cat in spending_cats:
summary += f"- {cat['category']}: ${abs(cat['activity']):.2f} spent (budgeted: ${cat['budgeted']:.2f}, available: ${cat['available']:.2f})\n"
return summary
except Exception as e:
return f"Error fetching category spending: {str(e)}"
@tool
def ynab_category_allocations(month: str = "") -> str:
"""Get all YNAB category allocations grouped by group, with assigned and available amounts.
Args:
month: YYYY-MM (default: current month)
"""
if not ynab_enabled:
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
try:
result = ynab_service.get_category_allocations(month=month or None)
lines = [
f"Category allocations for {result['month']} "
f"(to be budgeted: ${result['to_be_budgeted']:.2f}):\n"
]
for group_name in result["group_order"]:
categories = result["groups"].get(group_name, [])
group_assigned = sum(c["assigned"] for c in categories)
group_available = sum(c["available"] for c in categories)
if group_assigned == 0 and group_available == 0:
continue
lines.append(
f"{group_name} [assigned ${group_assigned:.2f} / available ${group_available:.2f}]"
)
for cat in categories:
if cat["assigned"] == 0 and cat["available"] == 0:
continue
avail = cat["available"]
avail_str = f"+${avail:.2f}" if avail >= 0 else f"-${abs(avail):.2f}"
lines.append(
f" {cat['name']}: assigned ${cat['assigned']:.2f}, available {avail_str}"
)
lines.append("")
lines.append(
f"Totals — assigned: ${result['total_assigned']:.2f}, available: ${result['total_available']:.2f}"
)
return "\n".join(lines)
except Exception as e:
return f"Error fetching category allocations: {str(e)}"
@tool
def ynab_insights(months_back: int = 3) -> str:
"""Get YNAB spending trends, frequently overspent categories, and budget recommendations.
Args:
months_back: Months to analyze (default 3, max 6)
"""
if not ynab_enabled:
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
try:
# Limit to reasonable range
months_back = min(max(1, months_back), 6)
result = ynab_service.get_spending_insights(months_back=months_back)
if "error" in result:
return result["error"]
summary = (
f"Spending insights for the last {months_back} months:\n\n"
f"Average monthly spending: ${result['average_monthly_spending']:.2f}\n"
f"Current month spending: ${result['current_month_spending']:.2f}\n"
f"Spending trend: {result['spending_trend']}\n"
)
if result["frequently_overspent_categories"]:
summary += "\nFrequently overspent categories:\n"
for cat in result["frequently_overspent_categories"][:5]:
summary += f"- {cat['category']}: overspent in {cat['months_overspent']} of {months_back} months\n"
if result["recommendations"]:
summary += "\nRecommendations:\n"
for rec in result["recommendations"]:
summary += f"- {rec}\n"
return summary
except Exception as e:
return f"Error generating insights: {str(e)}"
@tool
async def mealie_todays_meals() -> str:
@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"]:
@@ -347,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:
@@ -361,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(
@@ -370,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:
@@ -382,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)
@@ -431,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"]:
@@ -465,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
@@ -504,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)
@@ -543,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
@@ -586,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
@@ -978,16 +930,8 @@ if simbakit_enabled:
simbakit_drinking_activity,
]
)
if ynab_enabled:
tools.extend(
[
ynab_budget_summary,
ynab_search_transactions,
ynab_category_spending,
ynab_category_allocations,
ynab_insights,
]
)
if ynab_configured():
tools.extend(YNAB_TOOLS)
if mealie_enabled:
tools.extend(
[
+178
View File
@@ -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},
)
+5 -3
View File
@@ -34,10 +34,12 @@ IMPORTANT: When users ask factual questions about Simba's health, medical histor
BUDGET & FINANCE (YNAB Integration):
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
- Use ynab_budget_summary for overall budget health and status questions
- Use ynab_budget_summary for a month's overview (Ready to Assign, income, spent, overspending, net worth)
- Use ynab_account_balances for account and credit card balances
- Use ynab_search_transactions to find specific purchases or spending at particular stores
- Use ynab_category_spending to analyze spending by category for a month
- Use ynab_insights to provide spending trends, patterns, and recommendations
- Use ynab_spending_report for totals by category, payee, or account, and group_by='month' for trends
- Use ynab_category_budget for what's assigned/available per category in a month
- Use ynab_recent_changes for what was added, edited, or re-assigned recently
Always use these tools when asked about budgets, spending, transactions, or financial health.
MEAL PLANNING (Mealie Integration):
+77
View File
@@ -0,0 +1,77 @@
"""Pure shaping of LangChain tool events into SSE `tool_start` / `tool_end` payloads."""
import json
import re
from .tool_labels import tool_label
INPUT_VALUE_MAX_CHARS = 200
# Injected by LangChain rather than chosen by the model; never shown to the client.
_HIDDEN_INPUT_KEYS = frozenset({"config", "callbacks", "run_manager"})
_TOOL_HEADER_RE = re.compile(r"^\[(\S+) result\]")
def _truncate(text: str) -> str:
if len(text) > INPUT_VALUE_MAX_CHARS:
return text[: INPUT_VALUE_MAX_CHARS - 1] + "…"
return text
def summarize_tool_input(raw) -> dict[str, str | int | float | bool]:
"""Reduce a tool's input to flat, display-safe scalars."""
if raw is None:
return {}
if not isinstance(raw, dict):
return {"input": _truncate(str(raw))}
summary: dict[str, str | int | float | bool] = {}
for key, value in raw.items():
if key in _HIDDEN_INPUT_KEYS or value is None:
continue
if isinstance(value, (bool, int, float)):
summary[key] = value
elif isinstance(value, str):
summary[key] = _truncate(value)
else:
try:
summary[key] = _truncate(json.dumps(value, default=str))
except (TypeError, ValueError):
summary[key] = _truncate(str(value))
return summary
def tool_start_payload(event: dict) -> dict:
name = event["name"]
return {
"type": "tool_start",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"input": summarize_tool_input(event.get("data", {}).get("input")),
}
def tool_end_payload(
event: dict,
started_at: float | None,
artifact_ref: dict | None,
*,
now: float,
status: str = "ok",
) -> dict:
name = event["name"]
return {
"type": "tool_end",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"status": status,
"ms": round((now - started_at) * 1000) if started_at is not None else 0,
"artifact": artifact_ref,
}
def tool_name_from_message(text: str) -> str:
"""Recover the tool name from a persisted `[tool_name result]` header."""
match = _TOOL_HEADER_RE.match(text)
return match.group(1) if match else "tool"
+64
View File
@@ -0,0 +1,64 @@
"""Friendly, user-facing labels for agent tool calls (shown in the activity timeline)."""
TOOL_LABELS: dict[str, str] = {
"get_current_date": "Checking the date…",
"web_search": "Searching the web…",
"simba_search": "Searching household documents…",
"save_user_memory": "Saving to memory…",
# YNAB (current names)
"ynab_budget_summary": "Checking budget…",
"ynab_search_transactions": "Searching transactions…",
"ynab_category_spending": "Reviewing category spending…",
"ynab_category_allocations": "Reviewing category budgets…",
"ynab_insights": "Analyzing spending trends…",
# YNAB (names after the #55 mirror)
"ynab_account_balances": "Checking account balances…",
"ynab_spending_report": "Building spending report…",
"ynab_category_budget": "Reviewing category budgets…",
"ynab_recent_changes": "Checking recent budget changes…",
"ynab_sql": "Querying budget data…",
# Mealie
"mealie_todays_meals": "Checking today's meals…",
"mealie_meal_plans": "Checking the meal plan…",
"mealie_get_recipe": "Fetching recipe…",
"mealie_shopping_lists": "Checking shopping lists…",
"mealie_create_meal_plan": "Adding to the meal plan…",
# Obsidian / journal
"obsidian_search_notes": "Searching notes…",
"obsidian_read_note": "Reading note…",
"obsidian_create_note": "Creating note…",
"obsidian_create_task": "Creating task…",
"journal_get_today": "Reading today's journal…",
"journal_get_tasks": "Checking tasks…",
"journal_add_task": "Adding task…",
"journal_complete_task": "Completing task…",
# Google
"get_calendar_events": "Checking calendar…",
"create_calendar_event": "Creating calendar event…",
"gmail_search_emails": "Searching email…",
"gmail_get_email": "Reading email…",
# SimbaKit
"simbakit_device_status": "Checking Simba's devices…",
"simbakit_weight_history": "Checking Simba's weight…",
"simbakit_litter_activity": "Checking litter box activity…",
"simbakit_drinking_activity": "Checking water fountain activity…",
# Beli
"beli_search_restaurants": "Searching restaurants…",
}
# MCP tools are discovered at runtime, so they match by name prefix.
TOOL_PREFIX_LABELS: tuple[tuple[str, str], ...] = (
("homebox_", "Checking home inventory…"),
("bookstack_", "Checking the wiki…"),
)
def tool_label(tool_name: str) -> str:
"""Return the friendly label for a tool, falling back to "Using <tool>…"."""
label = TOOL_LABELS.get(tool_name)
if label:
return label
for prefix, prefix_label in TOOL_PREFIX_LABELS:
if tool_name.startswith(prefix):
return prefix_label
return f"Using {tool_name}…"
+2 -3
View File
@@ -1,5 +1,4 @@
from quart import Blueprint, jsonify
from quart_jwt_extended import jwt_refresh_token_required
from .logic import (
delete_all_documents,
@@ -7,13 +6,13 @@ from .logic import (
index_documents,
sync_obsidian_documents,
)
from blueprints.users.decorators import admin_required
from blueprints.users.decorators import admin_required, jwt_access_required
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
@rag_blueprint.get("/stats")
@jwt_refresh_token_required
@jwt_access_required
async def get_stats():
"""Get vector store statistics."""
stats = get_vector_store_stats()
+7 -15
View File
@@ -7,7 +7,8 @@ from quart_jwt_extended import (
)
from .models import User
from .oidc_service import OIDCUserService
from .decorators import admin_required
from .oidc_state import consume_oidc_state, save_oidc_state
from .decorators import admin_required, jwt_access_required
from config.oidc_config import oidc_config
import os
import secrets
@@ -19,10 +20,6 @@ import base64
user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user")
# In-memory storage for OIDC state/PKCE (production: use Redis or database)
# Format: {state: {"pkce_verifier": str, "redirect_after_login": str}}
_oidc_sessions = {}
@user_blueprint.route("/oidc/login", methods=["GET"])
async def oidc_login():
@@ -47,11 +44,8 @@ async def oidc_login():
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Store PKCE verifier and state for callback validation
_oidc_sessions[state] = {
"pkce_verifier": code_verifier,
"redirect_after_login": request.args.get("redirect", "/"),
}
# Persist PKCE verifier keyed by state for callback validation
await save_oidc_state(state, code_verifier)
# Get authorization endpoint from discovery
discovery = await oidc_config.get_discovery_document()
@@ -93,12 +87,10 @@ async def oidc_callback():
return jsonify({"error": "Missing code or state"}), 400
# Validate state and retrieve PKCE verifier
session = _oidc_sessions.pop(state, None)
if not session:
pkce_verifier = await consume_oidc_state(state)
if not pkce_verifier:
return jsonify({"error": "Invalid or expired state"}), 400
pkce_verifier = session["pkce_verifier"]
# Exchange authorization code for tokens
discovery = await oidc_config.get_discovery_document()
token_endpoint = discovery.get("token_endpoint")
@@ -206,7 +198,7 @@ async def login():
@user_blueprint.route("/me", methods=["GET"])
@jwt_refresh_token_required
@jwt_access_required
async def me():
user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id)
+39 -2
View File
@@ -4,10 +4,47 @@ Authentication decorators for role-based access control.
from functools import wraps
from quart import jsonify
from quart_jwt_extended import jwt_refresh_token_required, get_jwt_identity
from quart_jwt_extended import get_jwt_identity
from quart_jwt_extended.exceptions import WrongTokenError
from quart_jwt_extended.view_decorators import (
verify_jwt_in_request,
verify_jwt_refresh_token_in_request,
)
from .models import User
def jwt_access_required(fn):
"""
Protect an endpoint with the (1h) access token.
TRANSITIONAL: this also accepts the refresh token, so an old frontend
(which sends the refresh token on every call) keeps working against this
backend, including tabs that stay open across deploys.
It does NOT make a new frontend work against an older backend: routes
still on `@jwt_refresh_token_required` answer an access token with 422,
which the frontend does not retry. So this backend must be live on both
colors before the access-token frontend ships (deploy in two releases).
TODO: once the access-token frontend has been live for a while, replace
the body with `await verify_jwt_in_request()` (i.e. plain `@jwt_required`)
and map WrongTokenError to 401, so a stale tab still sending the refresh
token refreshes and retries instead of failing with 422.
"""
@wraps(fn)
async def wrapper(*args, **kwargs):
try:
await verify_jwt_in_request()
except WrongTokenError:
# Legacy client sent the refresh token. Expired/invalid tokens
# still raise from verify_jwt_in_request and are not retried here.
await verify_jwt_refresh_token_in_request()
return await fn(*args, **kwargs)
return wrapper
def admin_required(fn):
"""
Decorator that requires the user to be an admin (member of lldap_admin group).
@@ -15,7 +52,7 @@ def admin_required(fn):
"""
@wraps(fn)
@jwt_refresh_token_required
@jwt_access_required
async def wrapper(*args, **kwargs):
user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id)
+16
View File
@@ -56,3 +56,19 @@ class User(Model):
if not self.password:
return False
return bcrypt.checkpw(plain_password.encode("utf-8"), self.password)
class OIDCState(Model):
"""Pending OIDC login: maps the `state` param to its PKCE verifier.
Stored in Postgres (not process memory) so a login that starts on one
blue/green color and finishes on the other still validates.
"""
state = fields.CharField(max_length=128, primary_key=True)
pkce_verifier = fields.CharField(max_length=128)
expires_at = fields.DatetimeField(index=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "oidc_states"
+36
View File
@@ -0,0 +1,36 @@
"""
Persistent, single-use store for pending OIDC logins (state -> PKCE verifier).
Lives in Postgres so a login started on one blue/green color can finish on
the other.
"""
from datetime import datetime, timedelta, timezone
from .models import OIDCState
OIDC_STATE_TTL = timedelta(minutes=10)
async def save_oidc_state(
state: str, pkce_verifier: str, ttl: timedelta = OIDC_STATE_TTL
) -> None:
now = datetime.now(timezone.utc)
# Opportunistic cleanup of abandoned logins
await OIDCState.filter(expires_at__lte=now).delete()
await OIDCState.create(
state=state, pkce_verifier=pkce_verifier, expires_at=now + ttl
)
async def consume_oidc_state(state: str) -> str | None:
"""Return the PKCE verifier for `state` and delete it, or None if the
state is unknown, expired, or was already consumed."""
row = await OIDCState.get_or_none(state=state)
if row is None:
return None
# The delete is the single-use guard: only one concurrent caller wins.
deleted = await OIDCState.filter(state=state).delete()
if not deleted or row.expires_at <= datetime.now(timezone.utc):
return None
return row.pkce_verifier
+1
View File
@@ -0,0 +1 @@
"""YNAB budget mirror: Postgres models, delta sync, and agent tools."""
+22
View File
@@ -0,0 +1,22 @@
"""Run one YNAB sync: ``python -m blueprints.ynab [--full]``."""
import asyncio
import sys
from tortoise import Tortoise
from config.db import TORTOISE_CONFIG
from .sync import sync_ynab
async def main(full: bool) -> None:
await Tortoise.init(config=TORTOISE_CONFIG)
try:
print(await sync_ynab(full=full))
finally:
await Tortoise.close_connections()
if __name__ == "__main__":
asyncio.run(main(full="--full" in sys.argv))
+173
View File
@@ -0,0 +1,173 @@
"""Local Postgres mirror of the YNAB budget.
Rows are keyed by YNAB's own ids and kept current by ``blueprints.ynab.sync``
using YNAB delta requests. Money is stored in dollars with 3 decimal places,
which is exactly YNAB's milliunits. Deleted YNAB entities are kept with
``deleted=True`` rather than removed.
"""
from tortoise import fields
from tortoise.models import Model
def money(**kwargs):
return fields.DecimalField(max_digits=15, decimal_places=3, **kwargs)
class YnabAccount(Model):
id = fields.CharField(max_length=36, primary_key=True)
name = fields.CharField(max_length=255)
type = fields.CharField(max_length=50)
on_budget = fields.BooleanField()
closed = fields.BooleanField()
note = fields.TextField(null=True)
balance = money()
cleared_balance = money()
uncleared_balance = money()
last_reconciled_at = fields.DatetimeField(null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_accounts"
class YnabPayee(Model):
id = fields.CharField(max_length=36, primary_key=True)
name = fields.CharField(max_length=500)
transfer_account_id = fields.CharField(max_length=36, null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_payees"
class YnabCategoryGroup(Model):
id = fields.CharField(max_length=36, primary_key=True)
name = fields.CharField(max_length=255)
hidden = fields.BooleanField()
internal = fields.BooleanField(default=False)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_category_groups"
class YnabCategory(Model):
id = fields.CharField(max_length=36, primary_key=True)
category_group_id = fields.CharField(max_length=36, db_index=True)
name = fields.CharField(max_length=255)
hidden = fields.BooleanField()
internal = fields.BooleanField(default=False)
note = fields.TextField(null=True)
goal_type = fields.CharField(max_length=20, null=True)
goal_target = money(null=True)
goal_target_month = fields.DateField(null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_categories"
class YnabMonth(Model):
# ISO month start, e.g. "2026-10-01"
id = fields.CharField(max_length=10, primary_key=True)
month = fields.DateField(db_index=True)
note = fields.TextField(null=True)
income = money()
budgeted = money()
activity = money()
to_be_budgeted = money()
age_of_money = fields.IntField(null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_months"
class YnabMonthCategory(Model):
"""Per-month category amounts. ``budgeted`` is what YNAB calls "Assigned"."""
# "<month>:<category_id>"
id = fields.CharField(max_length=47, primary_key=True)
month = fields.DateField(db_index=True)
category_id = fields.CharField(max_length=36, db_index=True)
budgeted = money()
activity = money()
balance = money()
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_month_categories"
class YnabTransaction(Model):
id = fields.CharField(max_length=64, primary_key=True)
date = fields.DateField(db_index=True)
amount = money()
memo = fields.TextField(null=True)
cleared = fields.CharField(max_length=20)
approved = fields.BooleanField()
flag_color = fields.CharField(max_length=20, null=True)
account_id = fields.CharField(max_length=36, db_index=True)
payee_id = fields.CharField(max_length=36, null=True, db_index=True)
category_id = fields.CharField(max_length=36, null=True, db_index=True)
transfer_account_id = fields.CharField(max_length=36, null=True)
import_payee_name = fields.CharField(max_length=500, null=True)
# Denormalized from payees/categories/accounts after each sync for easy querying
account_name = fields.CharField(max_length=255, null=True)
payee_name = fields.CharField(max_length=500, null=True)
category_name = fields.CharField(max_length=255, null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_transactions"
class YnabSubtransaction(Model):
"""Lines of a split transaction; the parent's category is "Split"."""
id = fields.CharField(max_length=64, primary_key=True)
transaction_id = fields.CharField(max_length=64, db_index=True)
amount = money()
memo = fields.TextField(null=True)
payee_id = fields.CharField(max_length=36, null=True)
category_id = fields.CharField(max_length=36, null=True, db_index=True)
transfer_account_id = fields.CharField(max_length=36, null=True)
deleted = fields.BooleanField(default=False)
class Meta:
table = "ynab_subtransactions"
class YnabChange(Model):
"""Append-only log of what each sync changed, built by diffing against the mirror.
YNAB's API has no history, so this is the only record of edits (e.g. an
assignment going from $400 to $500). It starts at the first sync.
"""
id = fields.BigIntField(primary_key=True)
synced_at = fields.DatetimeField(auto_now_add=True, db_index=True)
server_knowledge = fields.BigIntField()
entity_type = fields.CharField(max_length=30, db_index=True)
entity_id = fields.CharField(max_length=64, db_index=True)
# created / updated / deleted
action = fields.CharField(max_length=10)
label = fields.CharField(max_length=500)
# {field: [old, new]}; for "created", old is null
changes = fields.JSONField()
class Meta:
table = "ynab_changes"
class YnabSyncState(Model):
budget_id = fields.CharField(max_length=64, primary_key=True)
budget_name = fields.CharField(max_length=255, null=True)
server_knowledge = fields.BigIntField(null=True)
last_synced_at = fields.DatetimeField(null=True)
last_full_sync_at = fields.DatetimeField(null=True)
last_error = fields.TextField(null=True)
class Meta:
table = "ynab_sync_state"
+172
View File
@@ -0,0 +1,172 @@
"""Admin-only read-only SQL over the YNAB mirror tables.
Defense in depth: the query is parsed and must be a single SELECT touching
only ``ynab_*`` tables with no dangerous functions, then runs in a READ ONLY
transaction with a statement timeout and a row cap.
"""
import re
from decimal import Decimal
import sqlglot
from langchain_core.runnables import RunnableConfig
from langchain.tools import tool
from sqlglot import exp
from sqlglot.optimizer.scope import traverse_scope
from tortoise.transactions import in_transaction
from utils.mcp_tools import admin_only_message
from . import models
from .sync import ensure_fresh
from .tools import NOT_SYNCED, ynab_tool_errors
MAX_ROWS = 100
# Tool results are cut at 4000 chars in blueprints/conversation/logic.py
MAX_OUTPUT_CHARS = 3800
STATEMENT_TIMEOUT = "10s"
ALLOWED_TABLES = frozenset(
m._meta.db_table
for m in vars(models).values()
if isinstance(m, type) and issubclass(m, models.Model) and m is not models.Model
)
FORBIDDEN_NODES = (
exp.Insert,
exp.Update,
exp.Delete,
exp.Merge,
exp.Create,
exp.Drop,
exp.Alter,
exp.Command,
exp.Into,
)
FORBIDDEN_FUNCTIONS = re.compile(
r"^(pg_|lo_|dblink|set_config|current_setting|"
r"(query|table|cursor|schema|database)_to_xml|txid_|nextval|setval)",
re.IGNORECASE,
)
def validate_sql(query: str) -> str | None:
"""Return an error message if ``query`` isn't allowed, else None."""
return _check(query)[0]
def _check(query: str) -> tuple[str | None, str | None]:
"""Validate ``query``; return ``(error, sql_to_run)``.
The SQL that runs is regenerated from the validated parse tree (minus
comments), so Postgres executes exactly what was checked.
"""
try:
statements = [
s
for s in sqlglot.parse(query, read="postgres")
# a trailing "; -- comment" parses as a bare Semicolon
if s and not isinstance(s, exp.Semicolon)
]
except sqlglot.errors.SqlglotError as e:
return f"Could not parse SQL: {e}", None
if len(statements) != 1:
return "Exactly one SQL statement is allowed.", None
root = statements[0]
if not isinstance(root, exp.Query):
return "Only SELECT queries are allowed.", None
if root.find(*FORBIDDEN_NODES) or any(
s.args.get("locks") for s in root.find_all(exp.Select)
):
return "Only read-only SELECT queries are allowed.", None
# A name matching a CTE isn't necessarily a CTE reference (e.g. inside the
# CTE's own body, or outside the scope that defines it), so resolve each
# FROM source through sqlglot's scopes. Any table node that isn't a CTE
# name at all is checked too, in case scope analysis misses a construct.
try:
scopes = traverse_scope(root)
except Exception as e:
return f"Could not analyze SQL: {e}", None
cte_names = {cte.alias_or_name for cte in root.find_all(exp.CTE)}
tables = [
source
for scope in scopes
for source in scope.sources.values()
if isinstance(source, exp.Table)
]
tables += [t for t in root.find_all(exp.Table) if t.name not in cte_names]
for table in tables:
if isinstance(table.this, exp.Func): # e.g. generate_series(...)
continue
if table.catalog or table.db not in ("", "public"):
return f"Schema-qualified table '{table.sql()}' is not allowed.", None
if table.name not in ALLOWED_TABLES:
return (
f"Table '{table.name}' is not allowed. "
f"Available: {', '.join(sorted(ALLOWED_TABLES))}",
None,
)
for func in root.find_all(exp.Func):
name = func.name if isinstance(func, exp.Anonymous) else func.sql_name()
if FORBIDDEN_FUNCTIONS.match(name or ""):
return f"Function '{name}' is not allowed.", None
return None, root.sql(dialect="postgres", comments=False).rstrip("; ")
def _format_value(value) -> str:
if value is None:
return ""
if isinstance(value, Decimal):
value = (
format(value.normalize(), "f") if value == value.to_integral() else value
)
return str(value).replace("\t", " ").replace("\n", " ")
def format_rows(columns: list[str], rows: list[tuple]) -> str:
if not rows:
return "Query returned no rows."
lines = ["\t".join(columns)]
lines += ["\t".join(_format_value(v) for v in row) for row in rows]
text = "\n".join(lines)
if len(text) > MAX_OUTPUT_CHARS:
text = text[:MAX_OUTPUT_CHARS] + "\n...(output truncated; aggregate more)"
elif len(rows) == MAX_ROWS:
text += f"\n(capped at {MAX_ROWS} rows; aggregate or add LIMIT)"
return text
@tool
@ynab_tool_errors
async def ynab_sql(query: str, config: RunnableConfig) -> str:
"""Run a read-only Postgres SELECT over the YNAB mirror for questions the other YNAB tools can't answer. Tables: ynab_transactions (date, amount, payee_name, category_name, account_name, memo, transfer_account_id, deleted), ynab_subtransactions (split lines), ynab_accounts, ynab_categories, ynab_category_groups, ynab_payees, ynab_months, ynab_month_categories (month, category_id, budgeted=assigned, activity, balance=available), ynab_changes (edit history: synced_at, entity_type, action, label, changes JSON). Amounts are dollars; outflows negative. Filter NOT deleted.
Args:
query: A single PostgreSQL SELECT statement
"""
if not (config or {}).get("configurable", {}).get("is_admin"):
return admin_only_message("YNAB SQL")
error, sql = _check(query)
if error:
return error
if await ensure_fresh() is None:
return NOT_SYNCED
try:
async with in_transaction() as conn:
await conn.execute_query("SET TRANSACTION READ ONLY")
await conn.execute_query(
f"SET LOCAL statement_timeout = '{STATEMENT_TIMEOUT}'"
)
# Raw records rather than dicts, which would merge same-named
# columns (e.g. c.name and g.name)
_, records = await conn.execute_query(
f"SELECT * FROM ({sql}) AS q LIMIT {MAX_ROWS}"
)
except Exception as e:
return f"SQL error: {e}"
columns = list(records[0].keys()) if records else []
return format_rows(columns, [tuple(r.values()) for r in records])
+640
View File
@@ -0,0 +1,640 @@
"""Sync the YNAB budget into the local Postgres mirror.
YNAB has no event feed, but every budget response carries a
``server_knowledge`` counter; passing it back as ``last_knowledge_of_server``
returns only entities changed since then. The first sync downloads the whole
budget, later syncs are deltas, and a full resync runs once a day as a safety
net (deltas can't express things like month rollovers).
Each sync diffs incoming entities against the mirror and appends what changed
to ``ynab_changes``, which is the only history of edits we get.
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Callable
from tortoise.models import Model
from tortoise.transactions import in_transaction
from utils.ynab_service import YNABService
from .models import (
YnabAccount,
YnabCategory,
YnabCategoryGroup,
YnabChange,
YnabMonth,
YnabMonthCategory,
YnabPayee,
YnabSubtransaction,
YnabSyncState,
YnabTransaction,
)
logger = logging.getLogger(__name__)
FULL_RESYNC_INTERVAL = timedelta(hours=24)
# After a failed sync, tools answer from the mirror without retrying YNAB
FAILURE_BACKOFF_SECONDS = 60
# Cap on rows passed to a single ``id IN (...)`` lookup
LOOKUP_CHUNK = 2000
_sync_lock = asyncio.Lock()
_last_failure = float("-inf")
def ynab_configured() -> bool:
return bool(os.getenv("YNAB_ACCESS_TOKEN"))
# YNAB accepts these in place of a budget id; they can point at different
# budgets over time, so the mirror resolves them once and keeps the real id
BUDGET_ID_ALIASES = ("last-used", "default")
def _configured_budget_id() -> str | None:
"""YNAB_BUDGET_ID if it names a specific budget, else None."""
budget_id = os.getenv("YNAB_BUDGET_ID", "").strip()
return None if budget_id in ("", *BUDGET_ID_ALIASES) else budget_id
def _money(milliunits: int | None) -> Decimal | None:
return None if milliunits is None else Decimal(milliunits).scaleb(-3)
def _date(value: str | None) -> date | None:
return date.fromisoformat(value) if value else None
def _datetime(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _norm(value: Any) -> Any:
"""Normalize values so DB rows and freshly mapped rows compare equal."""
if isinstance(value, datetime) and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _jsonable(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
def _map_account(a: dict) -> dict:
return {
"id": a["id"],
"name": a["name"],
"type": a["type"],
"on_budget": a["on_budget"],
"closed": a["closed"],
"note": a.get("note"),
"balance": _money(a["balance"]),
"cleared_balance": _money(a["cleared_balance"]),
"uncleared_balance": _money(a["uncleared_balance"]),
"last_reconciled_at": _datetime(a.get("last_reconciled_at")),
"deleted": a["deleted"],
}
def _map_payee(p: dict) -> dict:
return {
"id": p["id"],
"name": p["name"],
"transfer_account_id": p.get("transfer_account_id"),
"deleted": p["deleted"],
}
def _map_category_group(g: dict) -> dict:
return {
"id": g["id"],
"name": g["name"],
"hidden": g["hidden"],
"internal": g.get("internal") or False,
"deleted": g["deleted"],
}
def _map_category(c: dict) -> dict:
return {
"id": c["id"],
"category_group_id": c["category_group_id"],
"name": c["name"],
"hidden": c["hidden"],
"internal": c.get("internal") or False,
"note": c.get("note"),
"goal_type": c.get("goal_type"),
"goal_target": _money(c.get("goal_target")),
"goal_target_month": _date(c.get("goal_target_month")),
"deleted": c["deleted"],
}
def _map_month(m: dict) -> dict:
return {
"id": m["month"],
"month": _date(m["month"]),
"note": m.get("note"),
"income": _money(m["income"]),
"budgeted": _money(m["budgeted"]),
"activity": _money(m["activity"]),
"to_be_budgeted": _money(m["to_be_budgeted"]),
"age_of_money": m.get("age_of_money"),
"deleted": m["deleted"],
}
def _map_month_category(c: dict) -> dict:
return {
"id": f"{c['month']}:{c['id']}",
"month": _date(c["month"]),
"category_id": c["id"],
"budgeted": _money(c["budgeted"]),
"activity": _money(c["activity"]),
"balance": _money(c["balance"]),
"deleted": c["deleted"],
}
def _map_transaction(t: dict) -> dict:
return {
"id": t["id"],
"date": _date(t["date"]),
"amount": _money(t["amount"]),
"memo": t.get("memo"),
"cleared": t["cleared"],
"approved": t["approved"],
"flag_color": t.get("flag_color"),
"account_id": t["account_id"],
"payee_id": t.get("payee_id"),
"category_id": t.get("category_id"),
"transfer_account_id": t.get("transfer_account_id"),
"import_payee_name": t.get("import_payee_name"),
"deleted": t["deleted"],
}
def _map_subtransaction(s: dict) -> dict:
return {
"id": s["id"],
"transaction_id": s["transaction_id"],
"amount": _money(s["amount"]),
"memo": s.get("memo"),
"payee_id": s.get("payee_id"),
"category_id": s.get("category_id"),
"transfer_account_id": s.get("transfer_account_id"),
"deleted": s["deleted"],
}
# Filled in from payees/categories/accounts after each sync, not from YNAB
DENORMALIZED_FIELDS = ("account_name", "payee_name", "category_name")
@dataclass
class EntitySpec:
entity_type: str
model: type[Model]
mapper: Callable[[dict], dict]
# Fields worth recording in the change log. Derived fields (balances,
# activity) change with every transaction and would drown out real edits.
logged_fields: tuple[str, ...]
def _fields(model: type[Model], exclude: tuple[str, ...] = ()) -> tuple[str, ...]:
return tuple(f for f in model._meta.fields_map if f != "id" and f not in exclude)
SPECS = {
"accounts": EntitySpec(
"account",
YnabAccount,
_map_account,
("name", "type", "on_budget", "closed", "note", "deleted"),
),
"payees": EntitySpec("payee", YnabPayee, _map_payee, ("name", "deleted")),
"category_groups": EntitySpec(
"category_group",
YnabCategoryGroup,
_map_category_group,
("name", "hidden", "deleted"),
),
"categories": EntitySpec(
"category", YnabCategory, _map_category, _fields(YnabCategory)
),
"months": EntitySpec("month", YnabMonth, _map_month, ("note",)),
"month_categories": EntitySpec(
"month_category", YnabMonthCategory, _map_month_category, ("budgeted",)
),
"transactions": EntitySpec(
"transaction",
YnabTransaction,
_map_transaction,
_fields(YnabTransaction, exclude=DENORMALIZED_FIELDS),
),
"subtransactions": EntitySpec(
"subtransaction",
YnabSubtransaction,
_map_subtransaction,
_fields(YnabSubtransaction),
),
}
def _budget_entities(budget: dict) -> dict[str, list[dict]]:
"""Split a budget payload into raw entity lists keyed like ``SPECS``."""
month_categories = [
{**c, "month": m["month"]}
for m in budget.get("months") or []
for c in m.get("categories") or []
]
return {
"accounts": budget.get("accounts") or [],
"payees": budget.get("payees") or [],
"category_groups": budget.get("category_groups") or [],
"categories": budget.get("categories") or [],
"months": budget.get("months") or [],
"month_categories": month_categories,
"transactions": budget.get("transactions") or [],
"subtransactions": budget.get("subtransactions") or [],
}
# Id fields that reference a named entity (account, payee, category, group);
# the change log records these by name
NAMED_ID_FIELDS = (
"account_id",
"payee_id",
"category_id",
"category_group_id",
"transfer_account_id",
)
class _Labeler:
"""Resolves ids to names for change log labels and diffs."""
def __init__(self, names: dict[str, str]):
self.names = names
def name(self, entity_id: str | None) -> str | None:
return None if entity_id is None else self.names.get(entity_id, entity_id)
def __call__(self, entity_type: str, row: dict) -> str:
if entity_type == "transaction":
payee = self.name(row.get("payee_id")) or row.get("import_payee_name") or ""
return f"{row['date']} {payee} {row['amount']:+.2f}"
if entity_type == "subtransaction":
category = self.name(row.get("category_id")) or ""
return f"split line {category} {row['amount']:+.2f}"
if entity_type == "month_category":
return f"{self.name(row['category_id'])} {row['month']:%Y-%m}"
if entity_type == "month":
return row["id"][:7]
return row.get("name") or row["id"]
def diff_entry(self, field: str, old: Any, new: Any) -> tuple[str, list]:
"""Log e.g. ``category_id`` changes as ``category`` names, not ids."""
if field in NAMED_ID_FIELDS:
return field.removesuffix("_id"), [self.name(old), self.name(new)]
return field, [_jsonable(old), _jsonable(new)]
async def _load_names(conn) -> dict[str, str]:
names: dict[str, str] = {}
for model in (YnabAccount, YnabPayee, YnabCategory, YnabCategoryGroup):
for row in await model.all().using_db(conn).values("id", "name"):
names[row["id"]] = row["name"]
return names
async def _fetch_existing(spec: EntitySpec, ids: list[str], conn, fields) -> dict:
existing: dict[str, dict] = {}
for i in range(0, len(ids), LOOKUP_CHUNK):
rows = (
await spec.model.filter(id__in=ids[i : i + LOOKUP_CHUNK])
.using_db(conn)
.values("id", *fields)
)
existing.update({row["id"]: row for row in rows})
return existing
async def _apply(
spec: EntitySpec,
raw_rows: list[dict],
conn,
server_knowledge: int,
log_changes: bool,
labeler: _Labeler,
complete: bool = False,
) -> int:
"""Upsert rows that differ from the mirror; return how many changed.
``complete`` means ``raw_rows`` is every live entity of this type (a full
download). Full downloads omit deleted entities, so mirror rows missing
from it are marked deleted here; otherwise deletions that happened right
before a full sync would never reach the mirror.
"""
incoming = {row["id"]: row for row in map(spec.mapper, raw_rows)}
# Only YNAB-sourced columns; denormalized names are filled in afterwards
field_names = tuple(f for f in _fields(spec.model) if f not in DENORMALIZED_FIELDS)
existing = await _fetch_existing(spec, list(incoming), conn, field_names)
to_write: list[Model] = []
changes: list[YnabChange] = []
def log(entity_id: str, action: str, diff: dict, row: dict) -> None:
changes.append(
YnabChange(
server_knowledge=server_knowledge,
entity_type=spec.entity_type,
entity_id=entity_id,
action=action,
label=labeler(spec.entity_type, row)[:500],
changes=diff,
)
)
for entity_id, new in incoming.items():
old = existing.get(entity_id)
if old is not None and all(_norm(old[f]) == _norm(new[f]) for f in field_names):
continue
to_write.append(spec.model(**new))
if not log_changes or not spec.logged_fields:
continue
if old is None:
# Created and deleted between two syncs: nothing worth logging
if new.get("deleted"):
continue
diff = dict(
labeler.diff_entry(f, None, new[f])
for f in spec.logged_fields
if new[f] is not None
)
# e.g. a month rollover creates every category's month row with
# nothing assigned; that's not an edit anyone made
if not any(values[1] for values in diff.values()):
continue
log(entity_id, "created", diff, new)
else:
diff = dict(
labeler.diff_entry(f, old[f], new[f])
for f in spec.logged_fields
if _norm(old[f]) != _norm(new[f])
)
if diff:
deleted = diff.get("deleted") == [False, True]
log(entity_id, "deleted" if deleted else "updated", diff, new)
removed = 0
if complete:
live_ids = (
await spec.model.filter(deleted=False)
.using_db(conn)
.values_list("id", flat=True)
)
missing = [i for i in live_ids if i not in incoming]
if missing:
if log_changes and "deleted" in spec.logged_fields:
gone = await _fetch_existing(spec, missing, conn, field_names)
for entity_id, row in gone.items():
log(entity_id, "deleted", {"deleted": [False, True]}, row)
for i in range(0, len(missing), LOOKUP_CHUNK):
removed += (
await spec.model.filter(id__in=missing[i : i + LOOKUP_CHUNK])
.using_db(conn)
.update(deleted=True)
)
if to_write:
await spec.model.bulk_create(
to_write,
batch_size=500,
on_conflict=["id"],
update_fields=list(field_names),
using_db=conn,
)
if changes:
await YnabChange.bulk_create(changes, batch_size=500, using_db=conn)
return len(to_write) + removed
DENORMALIZE_SQL = (
"""UPDATE ynab_transactions AS t SET payee_name = p.name FROM ynab_payees p
WHERE t.payee_id = p.id AND t.payee_name IS DISTINCT FROM p.name""",
"""UPDATE ynab_transactions AS t SET category_name = c.name FROM ynab_categories c
WHERE t.category_id = c.id AND t.category_name IS DISTINCT FROM c.name""",
"""UPDATE ynab_transactions AS t SET account_name = a.name FROM ynab_accounts a
WHERE t.account_id = a.id AND t.account_name IS DISTINCT FROM a.name""",
"""UPDATE ynab_transactions SET payee_name = NULL
WHERE payee_id IS NULL AND payee_name IS NOT NULL""",
"""UPDATE ynab_transactions SET category_name = NULL
WHERE category_id IS NULL AND category_name IS NOT NULL""",
)
# Entity types whose changes can affect the denormalized name columns
NAME_SOURCES = ("accounts", "payees", "categories", "transactions")
REFERENCE_KEYS = ("accounts", "payees", "category_groups", "categories")
DATA_KEYS = ("months", "month_categories", "transactions", "subtransactions")
async def apply_budget(
budget: dict,
server_knowledge: int,
conn,
log_changes: bool,
full: bool = False,
) -> dict[str, int]:
"""Write a full or delta budget payload into the mirror.
With ``full``, entity types present in the payload are treated as
complete and anything missing from them is marked deleted.
"""
entities = _budget_entities(budget)
present = {key for key in SPECS if budget.get(key) is not None}
if budget.get("months") is not None:
present.add("month_categories")
labeler = _Labeler({})
if log_changes and any(entities.values()):
labeler.names = await _load_names(conn)
counts: dict[str, int] = {}
for key in REFERENCE_KEYS + DATA_KEYS:
counts[key] = await _apply(
SPECS[key],
entities[key],
conn,
server_knowledge,
log_changes,
labeler,
complete=full and key in present,
)
if key in REFERENCE_KEYS:
# Later labels (e.g. a new payee on a new transaction) need these
labeler.names.update({row["id"]: row["name"] for row in entities[key]})
if any(counts[key] for key in NAME_SOURCES):
for sql in DENORMALIZE_SQL:
await conn.execute_query(sql)
return counts
async def _current_state() -> YnabSyncState | None:
"""The sync state for the mirrored budget.
The mirror holds a single budget, keyed by its real YNAB id. With
``YNAB_BUDGET_ID`` unset or an alias, the first sync resolves "last-used"
and the mirror stays pinned to that budget, so switching budgets in YNAB
can't mix two budgets' rows.
"""
configured = _configured_budget_id()
states = await YnabSyncState.all().order_by("budget_id")
ids = [s.budget_id for s in states]
if len(ids) > 1 or (configured and ids and ids[0] != configured):
raise ValueError(
f"YNAB mirror holds budget(s) {', '.join(ids)} but YNAB_BUDGET_ID "
f"is {configured or 'unset'}; clear the ynab_* tables to switch budgets"
)
return states[0] if states else None
def _backing_off() -> bool:
return time.monotonic() - _last_failure < FAILURE_BACKOFF_SECONDS
def _is_fresh(state: YnabSyncState | None, max_age_seconds: int) -> bool:
return (
state is not None
and state.last_synced_at is not None
and datetime.now(timezone.utc) - _norm(state.last_synced_at)
<= timedelta(seconds=max_age_seconds)
)
async def sync_ynab(full: bool = False, max_age_seconds: int | None = None) -> dict:
"""Sync YNAB into Postgres. Returns per-entity counts of changed rows.
``max_age_seconds`` (on-demand syncs from tools) skips the sync if the
mirror is already that fresh or YNAB failed recently. Both are checked
under the lock, so parallel tool calls share one sync or one failure.
The YNAB request runs before the DB transaction. An asyncio lock
serializes syncs in this process; a Postgres advisory lock plus a
server_knowledge check discards a payload if the other blue/green
container synced in the meantime.
"""
global _last_failure
async with _sync_lock:
state = await _current_state()
if max_age_seconds is not None and (
_is_fresh(state, max_age_seconds) or _backing_off()
):
return {"skipped": True}
now = datetime.now(timezone.utc)
base_knowledge = state.server_knowledge if state else None
first_sync = base_knowledge is None
full = (
full
or first_sync
or state.last_full_sync_at is None
or now - _norm(state.last_full_sync_at) > FULL_RESYNC_INTERVAL
)
budget_id = state.budget_id if state else _configured_budget_id()
budget_id = budget_id or "last-used"
try:
data = await YNABService().fetch_budget(
budget_id, None if full else base_knowledge
)
budget = data["budget"]
knowledge = data["server_knowledge"]
if state is not None and budget["id"] != state.budget_id:
raise ValueError(
f"YNAB returned budget {budget['id']} for {state.budget_id}"
)
async with in_transaction() as conn:
await conn.execute_query(
"SELECT pg_advisory_xact_lock(hashtext('ynab_sync'))"
)
latest = await YnabSyncState.get_or_none(
budget_id=budget["id"]
).using_db(conn)
if (latest.server_knowledge if latest else None) != base_knowledge:
logger.info("YNAB sync skipped: mirror changed during fetch")
return {"skipped": True}
counts = await apply_budget(
budget, knowledge, conn, log_changes=not first_sync, full=full
)
await YnabSyncState.update_or_create(
budget_id=budget["id"],
defaults={
"budget_name": budget.get("name"),
"server_knowledge": knowledge,
"last_synced_at": now,
"last_error": None,
**({"last_full_sync_at": now} if full else {}),
},
using_db=conn,
)
except Exception as e:
_last_failure = time.monotonic()
if state is not None:
await YnabSyncState.filter(budget_id=state.budget_id).update(
last_error=f"{type(e).__name__}: {e}"[:2000]
)
raise
changed = {k: v for k, v in counts.items() if v}
if changed:
logger.info(f"YNAB {'full' if full else 'delta'} sync @{knowledge}: {changed}")
return {"full": full, "server_knowledge": knowledge, "changed": changed}
async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None:
"""Sync if the mirror is older than ``max_age_seconds``.
Used by agent tools before answering. A failed sync is logged and the
(possibly stale) mirror is used anyway, and YNAB isn't retried for
``FAILURE_BACKOFF_SECONDS``. Returns None only if the mirror has never
been populated.
"""
state = await _current_state()
if not _is_fresh(state, max_age_seconds) and not _backing_off():
try:
await sync_ynab(max_age_seconds=max_age_seconds)
except Exception:
logger.exception("YNAB sync failed; answering from existing mirror")
state = await _current_state()
if state is None or state.server_knowledge is None:
return None
return state
async def ynab_sync_loop() -> None:
"""Background task: delta-sync YNAB every ``YNAB_SYNC_INTERVAL`` seconds."""
interval = int(os.getenv("YNAB_SYNC_INTERVAL", "900"))
logger.info(f"YNAB sync loop started (interval={interval}s)")
while True:
try:
await sync_ynab()
except Exception:
logger.exception("YNAB sync error")
await asyncio.sleep(interval)
+615
View File
@@ -0,0 +1,615 @@
"""LangChain agent tools that answer YNAB questions from the local mirror.
Every tool calls ``ensure_fresh`` first, which runs a cheap delta sync when
the mirror is more than a couple of minutes old.
"""
import functools
import json
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
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__)
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 = {
"assignment": "month_category",
"transfer": "transaction",
"split": "subtransaction",
"group": "category_group",
}
ENTITY_TYPES = (
"transaction",
"month",
"subtransaction",
"month_category",
"category",
"category_group",
"payee",
"account",
)
def _singular(word: str) -> str:
if word.endswith("ies"):
return word[:-3] + "y" # categories -> category
return word.removesuffix("s")
def ynab_tool_errors(fn):
"""Return errors to the model as text: LangGraph's ToolNode re-raises
anything else, which would fail the whole chat turn."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except Exception as e:
logger.exception(f"{fn.__name__} failed")
return f"YNAB error in {fn.__name__}: {type(e).__name__}: {e}"
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 = """
WITH lines AS (
SELECT t.id, t.date, t.amount, t.memo, t.cleared, t.approved,
t.account_id, t.account_name, t.payee_name, t.category_id,
t.category_name, t.transfer_account_id
FROM ynab_transactions t
WHERE NOT t.deleted AND NOT EXISTS (
SELECT 1 FROM ynab_subtransactions s
WHERE s.transaction_id = t.id AND NOT s.deleted
)
UNION ALL
SELECT s.id, t.date, s.amount, COALESCE(s.memo, t.memo), t.cleared, t.approved,
t.account_id, t.account_name, COALESCE(sp.name, t.payee_name),
s.category_id, sc.name, s.transfer_account_id
FROM ynab_subtransactions s
JOIN ynab_transactions t ON t.id = s.transaction_id
LEFT JOIN ynab_payees sp ON sp.id = s.payee_id
LEFT JOIN ynab_categories sc ON sc.id = s.category_id
WHERE NOT s.deleted AND NOT t.deleted
)
"""
# Spending = outflows (net of refunds) on budget accounts, excluding transfers
# (credit card payments, moves to savings) and income.
SPENDING_FILTER = """
a.on_budget AND l.transfer_account_id IS NULL
AND NOT COALESCE(c.internal, false)
"""
GROUP_BY_EXPR = {
"category": "COALESCE(l.category_name, 'Uncategorized')",
"category_group": "COALESCE(g.name, 'Uncategorized')",
"payee": "COALESCE(l.payee_name, 'Unknown payee')",
"account": "l.account_name",
"month": "to_char(l.date, 'YYYY-MM')",
}
def _today() -> date:
return datetime.now(ZoneInfo("America/New_York")).date()
def _usd(value: Decimal | float | None) -> str:
value = Decimal(value or 0)
sign = "-" if value < 0 else ""
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
def _month_start(month: str) -> date:
if not month:
return _today().replace(day=1)
return date.fromisoformat(month[:7] + "-01")
async def _query(sql: str, *params) -> list[dict]:
return await connections.get("default").execute_query_dict(sql, list(params))
@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:
month: YYYY-MM (default: current month)
"""
state = await ensure_fresh()
if state is None:
return NOT_SYNCED
try:
start = _month_start(month)
except ValueError:
return "Month must be YYYY-MM."
months = await _query("SELECT * FROM ynab_months WHERE month = $1", start)
if not months:
return f"No YNAB budget data for {start:%Y-%m}."
m = months[0]
overspent = await _query(
"""SELECT c.name, mc.balance FROM ynab_month_categories mc
JOIN ynab_categories c ON c.id = mc.category_id
WHERE mc.month = $1 AND mc.balance < 0 AND NOT mc.deleted
AND NOT c.deleted AND NOT c.internal
ORDER BY mc.balance""",
start,
)
# YNAB's month "activity" nets in credit card payment categories, which
# go up whenever a card is used, so compute spending from transactions.
next_month = (start.replace(day=28) + timedelta(days=4)).replace(day=1)
spent = await _query(
f"""{LINES_CTE}
SELECT COALESCE(-SUM(l.amount), 0) AS spent FROM lines l
JOIN ynab_accounts a ON a.id = l.account_id
LEFT JOIN ynab_categories c ON c.id = l.category_id
WHERE {SPENDING_FILTER} AND l.date >= $1 AND l.date < $2""",
start,
next_month,
)
accounts = await _query(
"""SELECT on_budget, SUM(balance) AS total FROM ynab_accounts
WHERE NOT deleted AND NOT closed GROUP BY on_budget"""
)
by_budget = {r["on_budget"]: r["total"] for r in accounts}
lines = [
f"YNAB budget '{state.budget_name}' for {start:%Y-%m}:",
f"Ready to Assign: {_usd(m['to_be_budgeted'])}",
f"Income: {_usd(m['income'])}",
f"Assigned: {_usd(m['budgeted'])}",
f"Spent: {_usd(spent[0]['spent'])}",
]
if m["age_of_money"] is not None:
lines.append(f"Age of money: {m['age_of_money']} days")
if overspent:
lines.append(
"Overspent: "
+ ", ".join(f"{r['name']} ({_usd(r['balance'])})" for r in overspent)
)
else:
lines.append("No overspent categories.")
net_worth = sum(by_budget.values(), Decimal(0))
lines.append(
f"Net worth: {_usd(net_worth)} "
f"(budget accounts {_usd(by_budget.get(True))}, "
f"tracking accounts {_usd(by_budget.get(False))})"
)
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
@ynab_tool_errors
async def ynab_account_balances(include_closed: bool = False) -> str:
"""List YNAB accounts (checking, savings, credit cards, investments) with current balances.
Args:
include_closed: Also list closed accounts
"""
if await ensure_fresh() is None:
return NOT_SYNCED
rows = await _query(
"""SELECT name, type, on_budget, closed, balance, cleared_balance
FROM ynab_accounts WHERE NOT deleted AND (NOT closed OR $1)
ORDER BY on_budget DESC, type, name""",
include_closed,
)
if not rows:
return "No YNAB accounts found."
lines = []
for r in rows:
tags = [r["type"]] + ([] if r["on_budget"] else ["tracking"])
if r["closed"]:
tags.append("closed")
line = f"- {r['name']} [{', '.join(tags)}]: {_usd(r['balance'])}"
if r["cleared_balance"] != r["balance"]:
line += f" (cleared {_usd(r['cleared_balance'])})"
lines.append(line)
total = sum((r["balance"] for r in rows if not r["closed"]), Decimal(0))
return "\n".join(lines) + f"\n\nNet worth (open accounts): {_usd(total)}"
@tool(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_search_transactions(
start_date: str = "",
end_date: str = "",
payee: str = "",
category: str = "",
account: str = "",
memo: str = "",
min_amount: float = 0,
max_amount: float = 0,
limit: int = 20,
):
"""Search YNAB transactions. Split transactions are expanded into their category lines.
Args:
start_date: YYYY-MM-DD (default: 30 days ago if no other filter, else all time)
end_date: YYYY-MM-DD (default: today)
payee: Partial match on payee/store name
category: Partial match on category name
account: Partial match on account name
memo: Partial match on memo
min_amount: Minimum absolute dollar amount
max_amount: Maximum absolute dollar amount
limit: Max transactions to list (default 20, max 40)
"""
if await ensure_fresh() is None:
return NOT_SYNCED
has_filter = any([payee, category, account, memo, min_amount, max_amount])
try:
default_start = (
None if has_filter else date.fromordinal(_today().toordinal() - 30)
)
start = _parse_date(start_date, default_start)
end = _parse_date(end_date, _today())
except ValueError:
return "Dates must be YYYY-MM-DD."
conditions, params = ["l.date <= $1"], [end]
def add(condition: str, value) -> None:
params.append(value)
conditions.append(condition.replace("?", f"${len(params)}"))
if start:
add("l.date >= ?", start)
if payee:
add("l.payee_name ILIKE ?", f"%{payee}%")
if category:
add("l.category_name ILIKE ?", f"%{category}%")
if account:
add("l.account_name ILIKE ?", f"%{account}%")
if memo:
add("l.memo ILIKE ?", f"%{memo}%")
if min_amount:
add("abs(l.amount) >= ?", Decimal(str(min_amount)))
if max_amount:
add("abs(l.amount) <= ?", Decimal(str(max_amount)))
where = " AND ".join(conditions)
totals = await _query(
f"""{LINES_CTE}
SELECT count(*) AS n,
COALESCE(SUM(amount) FILTER (WHERE amount < 0), 0) AS outflow,
COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS inflow
FROM lines l WHERE {where}""",
*params,
)
count = totals[0]["n"]
range_text = f"{start or 'the beginning'} to {end}"
if not count:
return f"No transactions found from {range_text}."
limit = min(max(1, limit), MAX_TRANSACTIONS)
# 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, 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'])}."
]
for r in rows:
line = (
f"- {r['date']}: {r['payee_name'] or 'Unknown payee'} {_usd(r['amount'])} "
f"({r['category_name'] or 'Uncategorized'}; {r['account_name']})"
)
if r["memo"]:
line += f" — {r['memo']}"
if not r["approved"]:
line += " [unapproved]"
lines.append(line)
if count > len(rows):
lines.append(f"(Showing {len(rows)} most recent of {count})")
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(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_spending_report(
group_by: str = "category",
start_date: str = "",
end_date: str = "",
category: str = "",
payee: str = "",
top: int = 15,
):
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
Args:
group_by: category, category_group, payee, account, or month
start_date: YYYY-MM-DD (default: first of current month)
end_date: YYYY-MM-DD (default: today)
category: Partial match on category name
payee: Partial match on payee name
top: Max groups to list (default 15)
"""
if await ensure_fresh() is None:
return NOT_SYNCED
key = GROUP_BY_EXPR.get(group_by)
if key is None:
return f"group_by must be one of: {', '.join(GROUP_BY_EXPR)}."
try:
start = _parse_date(start_date, _today().replace(day=1))
end = _parse_date(end_date, _today())
except ValueError:
return "Dates must be YYYY-MM-DD."
conditions, params = [SPENDING_FILTER, "l.date >= $1", "l.date <= $2"], [start, end]
if category:
params.append(f"%{category}%")
conditions.append(f"l.category_name ILIKE ${len(params)}")
if payee:
params.append(f"%{payee}%")
conditions.append(f"l.payee_name ILIKE ${len(params)}")
order = "key" if group_by == "month" else "spent DESC"
rows = await _query(
f"""{LINES_CTE}
SELECT {key} AS key, -SUM(l.amount) AS spent, count(*) AS n
FROM lines l
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)}
GROUP BY 1 ORDER BY {order}""",
*params,
)
if not rows:
return f"No spending found from {start} to {end}."
total = sum((r["spent"] for r in rows), Decimal(0))
top = max(1, top)
shown = rows if group_by == "month" else rows[:top]
lines = [f"Spending by {group_by} from {start} to {end}: total {_usd(total)}"]
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
if len(rows) > len(shown):
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
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))}")
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(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:
month: YYYY-MM (default: current month)
category_group: Partial match to show only one group
"""
if await ensure_fresh() is None:
return NOT_SYNCED
try:
start = _month_start(month)
except ValueError:
return "Month must be YYYY-MM."
rows = await _query(
"""SELECT g.name AS group_name, c.name, c.goal_target,
mc.budgeted, mc.activity, mc.balance
FROM ynab_month_categories mc
JOIN ynab_categories c ON c.id = mc.category_id
JOIN ynab_category_groups g ON g.id = c.category_group_id
WHERE mc.month = $1 AND NOT mc.deleted
AND NOT c.deleted AND NOT c.hidden AND NOT c.internal
AND NOT g.deleted AND NOT g.hidden
AND g.name ILIKE $2
ORDER BY g.name, c.name""",
start,
f"%{category_group}%",
)
if not rows:
return f"No YNAB category data for {start:%Y-%m}."
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'])}"
)
if r["goal_target"]:
line += f", goal {_usd(r['goal_target'])}"
if r["balance"] < 0:
line += " [OVERSPENT]"
lines.append(line)
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
@ynab_tool_errors
async def ynab_recent_changes(days: int = 7, entity_type: str = "") -> str:
"""Show what changed in YNAB recently: new/edited/deleted transactions, assignment changes, renamed categories or payees.
Args:
days: Look back this many days (default 7)
entity_type: Optional filter: transaction, assignment, category, payee, account, month
"""
if await ensure_fresh() is None:
return NOT_SYNCED
entity_type = _singular(entity_type.strip().lower().replace(" ", "_"))
entity_type = ENTITY_TYPE_ALIASES.get(entity_type, entity_type)
if entity_type and entity_type not in ENTITY_TYPES:
return f"Unknown entity_type '{entity_type}'. Use one of: transaction, assignment, category, category_group, payee, account, month."
rows = await _query(
"""SELECT synced_at, entity_type, action, label, changes FROM ynab_changes
WHERE synced_at >= now() - make_interval(days => $1)
AND ($2 = '' OR entity_type = $2)
ORDER BY id DESC LIMIT 40""",
max(1, days),
entity_type,
)
if not rows:
return f"No YNAB changes recorded in the last {days} days."
lines = []
for r in rows:
when = r["synced_at"].astimezone(ZoneInfo("America/New_York"))
changes = r["changes"]
if isinstance(changes, str):
changes = json.loads(changes)
if r["action"] == "updated":
detail = "; ".join(
f"{field}: {old} → {new}" for field, (old, new) in changes.items()
)
else:
detail = ""
entity = r["entity_type"].replace("month_category", "assignment")
line = f"- {when:%Y-%m-%d %H:%M} {r['action']} {entity} '{r['label']}'"
lines.append(line + (f": {detail}" if detail else ""))
note = " (newest 40)" if len(rows) == 40 else ""
return f"YNAB changes in the last {days} days{note}:\n" + "\n".join(lines)
YNAB_TOOLS = [
ynab_budget_summary,
ynab_account_balances,
ynab_search_transactions,
ynab_spending_report,
ynab_category_budget,
ynab_recent_changes,
]
+2
View File
@@ -19,6 +19,8 @@ TORTOISE_CONFIG = {
"blueprints.scheduled_messages.models",
"blueprints.cron.models",
"blueprints.beli.models",
"blueprints.artifacts.models",
"blueprints.ynab.models",
"aerich.models",
],
"default_connection": "default",
+3
View File
@@ -24,8 +24,10 @@ x-raggr-common: &raggr-common
- TAVILY_API_KEY=${TAVILIY_API_KEY}
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
- 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}
@@ -39,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}
+5 -5
View File
@@ -36,11 +36,11 @@
## Backlog
- [ ] YNAB API client module
- [ ] YNAB data models (Budget, Transaction, Category, Account)
- [ ] Database schema for financial data
- [ ] YNAB sync background job/scheduler
- [ ] Financial insights LangChain tools
- [x] YNAB API client module
- [x] YNAB data models (Budget, Transaction, Category, Account)
- [x] Database schema for financial data
- [x] YNAB sync background job/scheduler
- [x] Financial insights LangChain tools
- [ ] Admin UI for YNAB configuration
- [ ] Frontend components for money insights display
+229
View File
@@ -0,0 +1,229 @@
# Cowork UI (phase 2) plan
Status: **proposal, awaiting approval.** Parent doc: [desktop-client.md](desktop-client.md).
Goal: make the agent's work visible and useful. Tool calls appear as a live
activity timeline. Results that are more than text (budget breakdowns, recipes,
notes, documents) open in a side panel. Files can be dropped into the
conversation. This is all built in `raggr-frontend` and the Quart backend, so
the web app gets it too; the Tauri shell needs only a one-line drag-drop
setting.
## Where we are
- `stream-query` SSE sends `tool_start {tool}` and `tool_end {tool}` and nothing
else: no id, inputs, duration or result. The UI shows a one-line pill whose
label comes from a stale `TOOL_MESSAGES` map in `useChat.ts`.
- Tools return formatted strings. `on_tool_end` saves them as `speaker="tool"`
messages, and `GET /conversation/<id>` returns only their first line
(`[tool result]`).
- Only `simba_search` uses `response_format="content_and_artifact"`, and its
artifact is a list of LangChain `Document`s that nothing reads.
- Auth: the backend accepting access tokens is merged (#57). The frontend
half (`fix/access-token-auth`) is waiting on a deploy.
- **#55 (YNAB mirror) is open.** It replaces every YNAB tool (new
`blueprints/ynab/tools.py`: `ynab_budget_summary`, `ynab_account_balances`,
`ynab_search_transactions`, `ynab_spending_report`, `ynab_category_budget`,
`ynab_recent_changes`). ⚠️ Its migration is numbered `16_…`, which clashes
with #57's `16_…_add_oidc_states` already on main, so it must be
regenerated before #55 merges.
## Contract
Every task codes against this. It lands on main with this doc before any
worker starts, so all workers share it.
### SSE events (additive; old clients ignore new fields)
```ts
// tool_start
{ type: "tool_start", run_id: string, tool: string, label: string,
input: Record<string, string | number | boolean> } // values truncated to 200 chars, `config` dropped
// tool_end
{ type: "tool_end", run_id: string, tool: string, label: string,
status: "ok" | "error", ms: number,
artifact: ArtifactRef | null }
// unchanged: thinking, thinking_delta, content, response, error, [DONE]
```
- `label` comes from a server-side registry
(`blueprints/conversation/tool_labels.py`): `{tool_name: "Checking budget…"}`,
with the fallback `"Using <tool>…"`. MCP tools match by prefix (`homebox_`,
`bookstack_`). `TOOL_MESSAGES` in `useChat.ts` is deleted.
- `status="error"` comes from `on_tool_error`. Most tools catch exceptions and
return an error string, so they report `ok`; that's acceptable.
- Parallel tool calls are paired by `run_id`.
### Artifacts
A tool opts in by returning `(text, artifact)` with
`@tool(response_format="content_and_artifact")`. The LLM sees only `text`. The
artifact is a plain JSON-serialisable dict:
```ts
type ToolArtifact = { // what a tool returns
kind: ArtifactKind;
title: string; // "October spending by category"
data: object; // per-kind shape below; ≤ 256 KB serialised or it is dropped
source_ref?: { system: "mealie" | "obsidian" | "paperless" | "ynab" | "web";
id?: string; path?: string; url?: string };
};
type ArtifactRef = { id: string; kind: ArtifactKind; title: string }; // in tool_end + conversation payload
type Artifact = ArtifactRef & { data: object; source_ref: object | null; tool: string;
conversation_id: string; message_id: string | null; created_at: string };
```
V1 kinds and their `data` shapes:
| kind | produced by | data |
|---|---|---|
| `mealie.recipe` | `mealie_get_recipe` | `{slug, name, description, servings, prep_time, cook_time, total_time, ingredients: string[], instructions: {title?, text}[], tags: string[], image_url?}` |
| `mealie.meal_plan` | `mealie_meal_plans`, `mealie_todays_meals` | `{start_date, end_date, days: {date, meals: {entry_type, name, recipe_slug?, note?}[]}[]}` |
| `mealie.shopping_lists` | `mealie_shopping_lists` | `{lists: {name, items: {name, quantity, checked}[]}[]}` |
| `obsidian.note` | `obsidian_read_note`, `journal_get_today` | `{path, title, markdown (≤ 50k chars), tags: string[], frontmatter: object}` |
| `search.documents` | `simba_search`, `obsidian_search_notes` | `{query, source, results: {title, snippet (≤ 400), filename?, path?, document_type?, date?}[]}` |
| `ynab.summary` | `ynab_budget_summary` | `{month, ready_to_assign, income, assigned, spent, age_of_money?, net_worth, overspent: {name, available}[]}` |
| `ynab.transactions` | `ynab_search_transactions` | `{start_date, end_date, count, total, transactions: {date, payee, category, account, memo?, amount}[]}` (≤ 500 rows) |
| `ynab.spending_report` | `ynab_spending_report` | `{start_date, end_date, group_by, rows: {key, amount, count?}[], total}` |
| `ynab.category_budget` | `ynab_category_budget` | `{month, groups: {name, categories: {name, assigned, activity, available, goal_target?}[]}[]}` |
YNAB kinds are built against #55's tools, so they wait for #55.
`source_ref.url` is filled where the backend knows it (Mealie base URL plus the
recipe path). Obsidian gets `obsidian://open?vault=<OBSIDIAN_VAULT_NAME>&file=<path>`
only if that env var is set; it's added to `.env`, `.env.example` and
`docker-compose.yml`.
### Storage and API
- New Tortoise model `Artifact` (`blueprints/artifacts/models.py`, table
`artifacts`): `id UUID, user FK, conversation FK (cascade), message FK →
conversation_messages (nullable, set null), tool, kind, title, data JSON,
source_ref JSON null, version int default 1, created_at, updated_at`.
Migration generated with `make migrate-new` / aerich, adds a table only.
- Created in `stream_query`'s `on_tool_end` when `output.artifact` is a dict
with a `kind`, right after the tool message is saved and linked to it.
Non-web channels (iMessage, WhatsApp, `/query`) don't create artifacts.
- `GET /api/artifacts?conversation_id=…` returns `ArtifactRef[]` plus
`{tool, message_id, created_at}`, without `data`.
`GET /api/artifacts/<id>` returns the full `Artifact`. Both require an access
token and are scoped to the owner (404 otherwise).
- `GET /api/conversation/<id>`: tool messages gain
`tool: string, label: string, artifact: ArtifactRef | null`. `text` keeps
the header line, for older clients.
- Write-back (`PATCH`) is **out of scope** for phase 2.
### Uploads
- `POST /api/conversation/upload` (multipart `file`, `conversation_id`):
- images go through the existing `process_image` and return
`{kind: "image", image_key}`
- `application/pdf`, `text/plain`, `text/markdown`, `text/csv` (by MIME type
or extension) are stored at `conversations/<id>/files/<uuid>.<ext>` and
return `{kind: "file", file_key, name, chars}`. PDF text is extracted with
PyMuPDF (`fitz`, already a dependency).
- max 10 MB; anything else gets a 415
- `upload-image` stays as is
- `stream-query` accepts an optional `file_key`. The extracted text (capped at
12k chars, with a `[truncated]` marker) is appended to the user turn as
`<attached file "name">…</attached>`. The saved user message gets
`📎 name` appended. Saved user messages get no new column.
## Tasks
Each task has its own branch off main (after the contract lands), its own herdr
worktree and pane, and its own Claude session started from a brief.
| # | Branch / pane | Scope | Owns (files) | Depends on |
|---|---|---|---|---|
| **A** | `feat/cowork-stream` | SSE v2 events + label registry; `Artifact` model, migration, API, creation in `on_tool_end`; conversation payload fields | `blueprints/conversation/__init__.py`, new `tool_labels.py`, new `blueprints/artifacts/`, `config/db.py`, `app.py` (register), a migration | contract on main. **Migration last, after rebasing** (see sequencing) |
| **B** | `feat/cowork-tool-artifacts` | `content_and_artifact` for the Mealie, Obsidian and search tools; then a second commit for the YNAB tools once #55 merges | `blueprints/conversation/agents.py`, `blueprints/ynab/tools.py` (2nd commit), tests | contract; #55 for the YNAB part |
| **C** | `feat/cowork-timeline` | Activity timeline replaces `ToolBubble`: grouped "Simba worked · N steps · 4.2 s" block per turn, rows with icon, label, input summary, spinner/✓/✗, duration and an "Open" chip that calls an `onOpenArtifact(ref)` prop; restored from conversation payload on reload | `useChat.ts`, new `ActivityTimeline.tsx`, `ToolBubble.tsx` (delete), `conversationService.ts` types, `ChatScreen.tsx` (message list only) | frontend auth PR merged |
| **D** | `feat/cowork-artifact-panel` | Right-side artifact panel: tabs for opened artifacts, `artifactService.ts`, a renderer per kind (CSS bars, no chart library), "Open in Mealie/Obsidian", a sheet below `lg`; auto-opens the newest artifact of a turn when the window is ≥ 1280 px wide, otherwise just the chip | new `components/artifacts/*`, `api/artifactService.ts`, `ChatScreen.tsx` (layout + wiring `onOpenArtifact`) | C merged first (wires its prop) |
| **E** | `feat/cowork-file-drop` | Upload endpoint + `file_key` in `stream-query`; drop overlay over the whole window, attachment chip in `MessageInput`; Tauri `disable_drag_drop_handler()` so HTML5 drops reach the webview | `blueprints/conversation/__init__.py` (new route + small `stream_query` hunk), new `utils/file_extract.py`, `MessageInput.tsx`, `ChatScreen.tsx` (overlay), `useChat.ts` (pending file), `desktop/src-tauri/src/lib.rs` | rebase after A and C (shared files) |
### Parallelism and sequencing
```
now ──► contract PR (this doc) merged
frontend-auth PR merged (after your deploy)
│
├── A feat/cowork-stream ─────────────┐ (merges 1st)
├── B feat/cowork-tool-artifacts ─────┤ (Mealie/Obsidian/search: merges anytime; YNAB commit after #55)
├── C feat/cowork-timeline ───────────┤ (merges after A)
├── D feat/cowork-artifact-panel ─────┤ (merges after C)
└── E feat/cowork-file-drop ──────────┘ (merges last)
```
- **All five start in parallel.** A and B are pure backend. C and D build
against a mock `/api` server that speaks the contract, so they don't wait for
A.
- **Migration order.** #55's migration needs regenerating (it clashes with
#57). A's migration must be generated *after* whatever lands before it. A
writes the model and code first, then rebases onto the latest main and runs
`make migrate-new` as its final step. If #55 merges while A is in review, A
rebases and regenerates.
- **Shared-file conflicts** (`ChatScreen.tsx`, `useChat.ts`,
`conversation/__init__.py`) are resolved by the merge order A → C → D → E. Each
later branch rebases onto main before its review.
- B's Mealie/Obsidian/search commit is safe to merge before A: without A,
artifacts ride along in `ToolMessage.artifact` and are ignored.
### Verification per task
- **Backend (A, B, E):** unit tests in `tests/unit/`, run with
`TAVILY_API_KEY=dummy .venv/bin/pytest tests/ -q` (create the venv with
`uv sync --all-extras`).
- A: event shaping becomes pure functions (`tool_start_payload(event)`,
`tool_end_payload(event, started_at, artifact_ref)`), which are tested,
along with the 256 KB cap and owner scoping.
- B: each converted tool, with its service mocked, returns `(str, dict)`
matching the kind's shape.
- E: extraction and the 415 case.
- **Migrations:** a throwaway Postgres container + aerich, the same approach
the auth fix used.
- **Frontend (C, D, E):** `yarn build`, plus the mock-server + Playwright recipe
(Node `http` mock serving `dist/` and SSE fixtures, Playwright on installed
Chrome, `addInitScript` to seed tokens). Screenshots are checked by eye.
Fixtures live in `raggr-frontend/test-fixtures/cowork/*.json`, and D owns one
per kind. Don't commit the Playwright devDep or a rewritten
`package-lock.json`.
## Process
1. Approve this plan. I commit it on `plan/cowork-ui`, open the PR and merge it
so the contract is on main.
2. Wait for the frontend-auth PR to merge after your deploy. A and B can start
right away; C, D and E start once it's in, so they use the new fetch helper.
3. For each task: `herdr worktree create … --base origin/main`, a
`.claude-brief.md` with that task's slice of this doc, and
`herdr agent start cowork-<x> … -- claude "Read .claude-brief.md…"`.
Workers commit locally and never push.
4. When a worker goes idle with its task done, I review:
- **pass 1:** I read the diff against the contract, run the tests and
build, and send numbered findings to the worker.
- **pass 2:** same again after its fixes.
- Then I push the branch and open the PR (`tea pr create`).
- I don't merge; you merge, or tell me to.
5. Before each review, the branch rebases on main per the merge order.
## Decisions I made (say if you disagree)
- **No write-back in phase 2.** Editing a recipe or note in the panel and
saving it to Mealie/Obsidian is phase 2.5.
- **No chart library.** CSS bars and tables are enough for budgets. Revisit if
trends need real line charts.
- **The panel auto-opens on wide windows**, otherwise shows a chip.
- **One attached file per message** (like images today). Dropped PDFs aren't
filed into Paperless yet.
- **Out of scope for now:** the bridge, notifications, the Keychain, and
detached runs. They're phases 3–4 in the parent doc.
## Open questions
1. Is **#55** close to merging? It decides when the YNAB artifacts happen, and
its migration needs regenerating against main either way.
2. What's the **Obsidian vault name** (for `obsidian://` links), or is the
link not worth having?
3. What's the **Mealie recipe URL** format on your instance
(`/g/home/r/<slug>` or `/recipe/<slug>`)? Worker B can check against the
API otherwise.
+6 -9
View File
@@ -9,7 +9,7 @@ user rather than a chat box in a browser tab. "Cowork-like" means:
- side panels for artifacts: budget reports, recipes, notes, documents
- drag files into the agent
- desktop notifications (scheduled messages, cron jobs, finished runs)
- a menu-bar icon and a global-hotkey quick-ask window
- a menu-bar icon and a global hotkey to summon the window
## Decisions (2026-10-12)
@@ -110,13 +110,12 @@ feature detection, so the web app gets them too.**
┌──────────────────────── Simba.app (Tauri v2) ─────────────────────────┐
│ Rust core (small) │
│ • tray icon + menu (Ask…, New chat, recent convos, Quit) │
│ • global shortcut ⌥Space → quick-ask panel (positioner, always-on-top)│
│ • global shortcut ⌥Space → show/hide main window │
│ • notifications (plugin-notification) │
│ • keychain token store, deep-link `simba://`, single-instance │
│ • window-state, autostart │
│ │
│ Main window ──loads──▶ https://<simba-host>/ (full UI) │
│ Quick panel ──loads──▶ https://<simba-host>/quick (compact route) │
│ Fallback ──bundled─▶ offline.html (server unreachable / retry) │
└───────────────────────────────┬────────────────────────────────────────┘
│ HTTPS: REST + SSE (same origin, no CORS)
@@ -140,8 +139,8 @@ Why load the remote origin rather than bundle:
`offline.html` covers "server down / not on VPN".
The frontend detects the shell with `window.__TAURI_INTERNALS__` (or a
`?shell=desktop` query param) and turns on the native bits: notifications,
drag-drop paths, and the "open in quick panel" route. Everything else renders
`?shell=desktop` query param) and turns on the native bits: notifications
and drag-drop paths. Everything else renders
the same in a browser.
### Cowork UI (in raggr-frontend, shared by web and desktop)
@@ -159,8 +158,6 @@ the same in a browser.
Paperless), `photo` (Immich), `device` (SimbaKit status/weight chart).
- **Drop zone** over the whole window: images go to the existing vision path;
PDFs and text go to the new upload endpoint (below).
- **Quick-ask route** (`/quick`): input + streamed answer only; "Open in Simba"
hands the conversation to the main window.
### Local tool bridge (Simba acts on the Mac)
@@ -314,8 +311,8 @@ blue/green stays safe: the old frontend ignores the new fields.
|---|---|---|---|
| **0a. Auth fix** (own PR) | access token on every call, single fetch helper, OIDC state in Postgres | auth fix (+ migration if state goes in a table) | Web works through a blue/green flip; the refresh token is only ever sent to `/refresh` |
| **0b. Spike** (½ day) | `desktop/` Tauri v2 app: one window loading `<simba-host>`, tray icon with Show/Quit, ⌥Space toggles the window, `offline.html` fallback | none | Log in through Authelia inside the webview, stream an answer, survive a `make redeploy` |
| **1. Native shell** | quick-ask panel (`/quick` route), notifications, window-state, single-instance, autostart, refresh token in the Keychain | none | Hotkey → ask → answer without touching the main window |
| **2. Cowork UI** | activity timeline, artifact panel + renderers (budget, recipe, note, document first), whole-window drop zone | items 1–3 (`artifacts` table) | Ask "how's the grocery budget" → YNAB artifact opens in the side panel; drop a PDF → summarised |
| **1. Native shell** | notifications, window-state, autostart, refresh token in the Keychain (no quick-ask window: dropped 2026-10-12) | none | A scheduled-message notification arrives; the login survives an app restart |
| **2. Cowork UI** ([plan](cowork-ui-plan.md)) | activity timeline, artifact panel + renderers (budget, recipe, note, document first), whole-window drop zone | items 1–3 (`artifacts` table) | Ask "how's the grocery budget" → YNAB artifact opens in the side panel; drop a PDF → summarised |
| **3. Local tools** | Rust bridge client, filesystem MCP scoped to chosen folders, write-confirm dialog, folder picker | item 7 | "Summarise the PDFs in ~/Downloads/taxes" works; a write prompts first; quitting the app removes the tools |
| **4. Background agent** | detached runs, notifications feed, desktop channel for scheduled messages, system-browser auth handoff | items 4–6 | Close the window mid-run → notification when done → reopen shows the full timeline |
@@ -0,0 +1,101 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "oidc_states" (
"state" VARCHAR(128) NOT NULL PRIMARY KEY,
"pkce_verifier" VARCHAR(128) NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "idx_oidc_states_expires_c461d9" ON "oidc_states" ("expires_at");
COMMENT ON TABLE "oidc_states" IS 'Pending OIDC login: maps the `state` param to its PKCE verifier.';
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::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 "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
DROP TABLE IF EXISTS "oidc_states";"""
MODELS_STATE = (
"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"
"wS81"
)
@@ -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="
)
@@ -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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"
)
+1
View File
@@ -24,3 +24,4 @@ nav:
- Architecture:
- Authentication: authentication.md
- Desktop Client (proposal): desktop-client.md
- Cowork UI plan: cowork-ui-plan.md
+1 -1
View File
@@ -36,7 +36,6 @@ dependencies = [
"aiosqlite>=0.20.0",
"jq>=1.10.0",
"tavily-python>=0.7.17",
"ynab>=1.3.0",
"aioimaplib>=2.0.1",
"html2text>=2025.4.15",
"ollama>=0.6.1",
@@ -47,6 +46,7 @@ dependencies = [
"google-auth-oauthlib>=1.0.0",
"google-auth-httplib2>=0.2.0",
"langchain-mcp-adapters>=0.3.2",
"sqlglot>=30.22.0",
]
[project.optional-dependencies]
+10 -10
View File
@@ -41,7 +41,7 @@ class ConversationService {
conversation_id: string,
signal?: AbortSignal,
): Promise<QueryResponse> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/query`,
{
method: "POST",
@@ -58,7 +58,7 @@ class ConversationService {
}
async getMessages(): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.baseUrl}/messages`,
{
method: "GET",
@@ -73,7 +73,7 @@ class ConversationService {
}
async getConversation(conversationId: string): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "GET",
@@ -88,7 +88,7 @@ class ConversationService {
}
async createConversation(): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/`,
{
method: "POST",
@@ -106,7 +106,7 @@ class ConversationService {
conversationId: string,
name: string,
): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "PATCH",
@@ -122,7 +122,7 @@ class ConversationService {
}
async deleteConversation(conversationId: string): Promise<void> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "DELETE",
@@ -145,7 +145,7 @@ class ConversationService {
if (params?.search) search.set("search", params.search);
const qs = search.toString();
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
{
method: "GET",
@@ -167,7 +167,7 @@ class ConversationService {
formData.append("file", file);
formData.append("conversation_id", conversationId);
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/upload-image`,
{
method: "POST",
@@ -185,7 +185,7 @@ class ConversationService {
}
async getPresignedImageUrl(imageKey: string): Promise<string> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/image/${imageKey}`,
);
if (!response.ok) {
@@ -207,7 +207,7 @@ class ConversationService {
body.image_key = imageKey;
}
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/stream-query`,
{
method: "POST",
+5 -8
View File
@@ -24,14 +24,11 @@ class OIDCService {
* Initiate OIDC login flow
* Returns authorization URL to redirect user to
*/
async initiateLogin(redirectAfterLogin: string = "/"): Promise<string> {
const response = await fetch(
`${this.baseUrl}/login?redirect=${encodeURIComponent(redirectAfterLogin)}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
async initiateLogin(): Promise<string> {
const response = await fetch(`${this.baseUrl}/login`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
throw new Error("Failed to initiate OIDC login");
@@ -29,13 +29,13 @@ class ScheduledMessageService {
private baseUrl = "/api/scheduled-messages";
async list(): Promise<ScheduledMessage[]> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`);
const response = await userService.fetchWithAuth(`${this.baseUrl}/`);
if (!response.ok) throw new Error("Failed to list scheduled messages");
return response.json();
}
async create(data: CreateScheduledMessage): Promise<ScheduledMessage> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, {
const response = await userService.fetchWithAuth(`${this.baseUrl}/`, {
method: "POST",
body: JSON.stringify(data),
});
@@ -47,7 +47,7 @@ class ScheduledMessageService {
}
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
@@ -59,7 +59,7 @@ class ScheduledMessageService {
}
async remove(id: string): Promise<void> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
method: "DELETE",
});
if (!response.ok) {
+50 -55
View File
@@ -29,7 +29,22 @@ class UserService {
return await response.json();
}
private refreshInFlight: Promise<string> | null = null;
/**
* Exchange the refresh token for a new access token. The refresh token is
* only ever sent here. Concurrent callers share one in-flight request.
*/
async refreshToken(): Promise<string> {
if (!this.refreshInFlight) {
this.refreshInFlight = this.doRefresh().finally(() => {
this.refreshInFlight = null;
});
}
return this.refreshInFlight;
}
private async doRefresh(): Promise<string> {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
@@ -71,85 +86,65 @@ class UserService {
}
}
/**
* Authenticated fetch: sends the access token and, on a 401 (expired or
* missing access token), refreshes once and retries. With no stored access
* token it refreshes up front instead of making a doomed request.
*/
async fetchWithAuth(
url: string,
options: RequestInit = {},
): Promise<Response> {
const accessToken = localStorage.getItem("access_token");
// Add authorization header
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
let response = await fetch(url, { ...options, headers });
// If unauthorized, try refreshing the token
if (response.status === 401) {
try {
const newAccessToken = await this.refreshToken();
// Retry the request with new token
headers.Authorization = `Bearer ${newAccessToken}`;
response = await fetch(url, { ...options, headers });
} catch (error) {
// Refresh failed, redirect to login or throw error
throw new Error("Session expired. Please log in again.");
}
}
return response;
}
async fetchWithRefreshToken(
url: string,
options: RequestInit = {},
{ skipContentType = false }: { skipContentType?: boolean } = {},
): Promise<Response> {
const refreshToken = localStorage.getItem("refresh_token");
// Add authorization header
const headers: Record<string, string> = {
const buildHeaders = (token: string | null): Record<string, string> => ({
...(skipContentType ? {} : { "Content-Type": "application/json" }),
...((options.headers as Record<string, string>) || {}),
...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}),
};
...(token ? { Authorization: `Bearer ${token}` } : {}),
});
let response = await fetch(url, { ...options, headers });
// If unauthorized, try refreshing the token
if (response.status === 401) {
const refreshOrExpire = async (): Promise<string> => {
try {
const newAccessToken = await this.refreshToken();
// Retry the request with new token
headers.Authorization = `Bearer ${newAccessToken}`;
response = await fetch(url, { ...options, headers });
} catch (error) {
// Refresh failed, redirect to login or throw error
return await this.refreshToken();
} catch {
throw new Error("Session expired. Please log in again.");
}
};
let accessToken = localStorage.getItem("access_token");
if (!accessToken) {
accessToken = await refreshOrExpire();
}
let response = await fetch(url, {
...options,
headers: buildHeaders(accessToken),
});
if (response.status === 401) {
accessToken = await refreshOrExpire();
response = await fetch(url, {
...options,
headers: buildHeaders(accessToken),
});
}
return response;
}
async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`);
const response = await this.fetchWithAuth(`${this.baseUrl}/me`);
if (!response.ok) throw new Error("Failed to fetch user profile");
return response.json();
}
async adminListUsers(): Promise<AdminUserRecord[]> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`);
const response = await this.fetchWithAuth(`${this.baseUrl}/admin/users`);
if (!response.ok) throw new Error("Failed to list users");
return response.json();
}
async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken(
const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "PUT", body: JSON.stringify({ whatsapp_number: number }) },
);
@@ -162,7 +157,7 @@ class UserService {
}
async adminUnlinkWhatsapp(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken(
const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "DELETE" },
);
@@ -170,7 +165,7 @@ class UserService {
}
async adminToggleEmail(userId: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken(
const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/email`,
{ method: "PUT" },
);
@@ -179,7 +174,7 @@ class UserService {
}
async adminDisableEmail(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken(
const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/email`,
{ method: "DELETE" },
);
+5 -2
View File
@@ -14,9 +14,12 @@ const TOOL_MESSAGES: Record<string, string> = {
web_search: "Searching the web...",
get_current_date: "Checking today's date...",
ynab_budget_summary: "Checking budget summary...",
ynab_account_balances: "Checking account balances...",
ynab_search_transactions: "Looking up transactions...",
ynab_category_spending: "Analyzing category spending...",
ynab_insights: "Generating budget insights...",
ynab_spending_report: "Adding up spending...",
ynab_category_budget: "Checking category budgets...",
ynab_recent_changes: "Checking recent budget changes...",
ynab_sql: "Querying budget data...",
obsidian_search_notes: "Searching notes...",
obsidian_read_note: "Reading note...",
obsidian_create_note: "Saving note...",
+389
View File
@@ -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]")
+119
View File
@@ -0,0 +1,119 @@
"""Tests for jwt_access_required / admin_required in blueprints/users/decorators.py."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from quart import Quart, jsonify
from quart_jwt_extended import (
JWTManager,
create_access_token,
create_refresh_token,
get_jwt_identity,
jwt_refresh_token_required,
)
from blueprints.users.decorators import admin_required, jwt_access_required
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
JWTManager(app)
@app.route("/protected")
@jwt_access_required
async def protected():
return jsonify(user=get_jwt_identity())
@app.route("/admin")
@admin_required
async def admin():
return jsonify(ok=True)
@app.route("/refresh", methods=["POST"])
@jwt_refresh_token_required
async def refresh():
return jsonify(user=get_jwt_identity())
return app
async def _token(app, kind, identity="user-1", expires_delta=None):
async with app.app_context():
if kind == "access":
return create_access_token(identity=identity, expires_delta=expires_delta)
return create_refresh_token(identity=identity, expires_delta=expires_delta)
def _auth(token):
return {"Authorization": f"Bearer {token}"}
class TestJwtAccessRequired:
async def test_accepts_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_transitionally_accepts_refresh_token(self, app):
token = await _token(app, "refresh")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_missing_token_is_401(self, app):
resp = await app.test_client().get("/protected")
assert resp.status_code == 401
async def test_expired_access_token_is_401(self, app):
token = await _token(app, "access", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_expired_refresh_token_is_401(self, app):
token = await _token(app, "refresh", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_garbage_token_rejected(self, app):
resp = await app.test_client().get("/protected", headers=_auth("not.a.jwt"))
assert resp.status_code == 422
async def test_refresh_endpoint_still_rejects_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().post("/refresh", headers=_auth(token))
assert resp.status_code == 422
class TestAdminRequired:
def _user(self, is_admin):
user = MagicMock()
user.is_admin.return_value = is_admin
return user
async def test_admin_with_access_token(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(True)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 200
async def test_non_admin_is_403(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(False)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 403
async def test_admin_without_token_is_401(self, app):
resp = await app.test_client().get("/admin")
assert resp.status_code == 401
+57
View File
@@ -0,0 +1,57 @@
"""Tests for the persistent OIDC state store in blueprints/users/oidc_state.py."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from tortoise import Tortoise
from blueprints.users.models import OIDCState
from blueprints.users.oidc_state import consume_oidc_state, save_oidc_state
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={"models": ["blueprints.users.models"]},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
class TestOIDCStateStore:
async def test_roundtrip(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
async def test_single_use(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
assert await consume_oidc_state("state-1") is None
async def test_unknown_state(self, db):
assert await consume_oidc_state("nope") is None
async def test_expired_state_rejected_and_removed(self, db):
await save_oidc_state("state-1", "verifier-1", ttl=timedelta(seconds=-1))
assert await consume_oidc_state("state-1") is None
assert await OIDCState.filter(state="state-1").count() == 0
async def test_save_purges_expired_rows(self, db):
await OIDCState.create(
state="old",
pkce_verifier="v",
expires_at=datetime.now(timezone.utc) - timedelta(minutes=1),
)
await save_oidc_state("new", "verifier")
assert await OIDCState.filter(state="old").count() == 0
assert await OIDCState.filter(state="new").count() == 1
async def test_concurrent_consume_only_one_wins(self, db):
await save_oidc_state("state-1", "verifier-1")
results = await asyncio.gather(
consume_oidc_state("state-1"), consume_oidc_state("state-1")
)
assert sorted(results, key=str) == [None, "verifier-1"]
+591
View File
@@ -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
+110
View File
@@ -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"
+434
View File
@@ -0,0 +1,434 @@
"""Tests for the YNAB Postgres mirror: delta application, change log, SQL guard."""
import asyncio
from decimal import Decimal
import pytest
import sqlglot
from tortoise import Tortoise
from tortoise.transactions import in_transaction
from blueprints.ynab.models import YnabChange, YnabMonthCategory, YnabTransaction
from blueprints.ynab.sql_tool import _check, format_rows, validate_sql
from blueprints.ynab.sync import _money, apply_budget
ACCOUNT = {
"id": "acct-1",
"name": "Checking",
"type": "checking",
"on_budget": True,
"closed": False,
"note": None,
"balance": 1_000_000,
"cleared_balance": 1_000_000,
"uncleared_balance": 0,
"last_reconciled_at": None,
"deleted": False,
}
PAYEE = {
"id": "payee-1",
"name": "Costco",
"transfer_account_id": None,
"deleted": False,
}
GROUP = {"id": "grp-1", "name": "Bills", "hidden": False, "deleted": False}
CATEGORY = {
"id": "cat-1",
"category_group_id": "grp-1",
"name": "Groceries",
"hidden": False,
"note": None,
"goal_type": None,
"goal_target": 0,
"goal_target_month": None,
"deleted": False,
}
def _txn(**overrides):
txn = {
"id": "txn-1",
"date": "2026-10-03",
"amount": -54_150,
"memo": None,
"cleared": "cleared",
"approved": True,
"flag_color": None,
"account_id": "acct-1",
"payee_id": "payee-1",
"category_id": "cat-1",
"transfer_account_id": None,
"import_payee_name": None,
"deleted": False,
}
return {**txn, **overrides}
def _month(budgeted=400_000, activity=-54_150):
return {
"month": "2026-10-01",
"note": "",
"income": 0,
"budgeted": budgeted,
"activity": activity,
"to_be_budgeted": 0,
"age_of_money": 30,
"deleted": False,
"categories": [
{
**CATEGORY,
"budgeted": budgeted,
"activity": activity,
"balance": budgeted + activity,
}
],
}
def _budget(transactions, month=None):
return {
"accounts": [ACCOUNT],
"payees": [PAYEE],
"category_groups": [GROUP],
"categories": [CATEGORY],
"months": [month or _month()],
"transactions": transactions,
"subtransactions": [],
}
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:", modules={"models": ["blueprints.ynab.models"]}
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
async def _apply(budget, knowledge, log_changes=True, full=False):
async with in_transaction() as conn:
return await apply_budget(
budget, knowledge, conn, log_changes=log_changes, full=full
)
def test_money_is_exact():
assert _money(-54_150) == Decimal("-54.15")
assert _money(1) == Decimal("0.001")
assert _money(None) is None
async def test_initial_sync_writes_rows_without_logging(db):
counts = await _apply(_budget([_txn()]), 1, log_changes=False)
assert counts["transactions"] == 1
txn = await YnabTransaction.get(id="txn-1")
assert txn.amount == Decimal("-54.15")
# Names are denormalized onto the transaction
assert (txn.payee_name, txn.category_name, txn.account_name) == (
"Costco",
"Groceries",
"Checking",
)
assert await YnabChange.all().count() == 0
async def test_unchanged_delta_is_a_noop(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
counts = await _apply(_budget([_txn()]), 2)
assert not any(counts.values())
assert await YnabChange.all().count() == 0
async def test_edit_logs_field_diff(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
await _apply(_budget([_txn(amount=-60_000, memo="bulk")]), 2)
change = await YnabChange.get(entity_type="transaction")
assert change.action == "updated"
assert change.server_knowledge == 2
assert change.changes == {"amount": [-54.15, -60.0], "memo": [None, "bulk"]}
assert "Costco" in change.label
async def test_assignment_change_is_logged_but_activity_is_not(db):
await _apply(_budget([]), 1, log_changes=False)
# Only activity moves: derived, so not logged
await _apply(_budget([], _month(activity=-80_000)), 2)
assert await YnabChange.filter(entity_type="month_category").count() == 0
await _apply(_budget([], _month(budgeted=500_000, activity=-80_000)), 3)
change = await YnabChange.get(entity_type="month_category")
assert change.changes == {"budgeted": [400.0, 500.0]}
assert change.label == "Groceries 2026-10"
mc = await YnabMonthCategory.get(id="2026-10-01:cat-1")
assert mc.balance == Decimal("420")
async def test_new_and_deleted_transactions(db):
await _apply(_budget([_txn()]), 1, log_changes=False)
await _apply(_budget([_txn(deleted=True), _txn(id="txn-2")]), 2)
actions = {
c.entity_id: c.action
for c in await YnabChange.filter(entity_type="transaction")
}
assert actions == {"txn-1": "deleted", "txn-2": "created"}
assert (await YnabTransaction.get(id="txn-1")).deleted is True
async def test_created_then_deleted_between_syncs_is_not_logged(db):
await _apply(_budget([]), 1, log_changes=False)
await _apply(_budget([_txn(id="txn-9", deleted=True)]), 2)
assert await YnabChange.filter(entity_type="transaction").count() == 0
class TestValidateSql:
@pytest.mark.parametrize(
"query",
[
"SELECT payee_name, sum(amount) FROM ynab_transactions WHERE NOT deleted GROUP BY 1",
"WITH x AS (SELECT * FROM ynab_payees) SELECT * FROM x",
"SELECT date_trunc('month', date) m, count(*) FROM ynab_transactions GROUP BY 1;",
"SELECT * FROM generate_series(1, 3) g",
"WITH RECURSIVE r AS (SELECT 1 n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT * FROM r",
"WITH ynab_payees AS (SELECT * FROM ynab_payees WHERE NOT deleted) SELECT * FROM ynab_payees",
"SELECT * FROM ynab_transactions UNION ALL SELECT * FROM ynab_transactions",
],
)
def test_allows_read_queries(self, query):
assert validate_sql(query) is None
@pytest.mark.parametrize(
"query, error",
[
("DELETE FROM ynab_payees", "Only SELECT"),
("SELECT 1; SELECT 2", "Exactly one"),
("SELECT * FROM users", "not allowed"),
("SELECT * FROM public.conversations", "not allowed"),
("SELECT * FROM pg_catalog.pg_user", "not allowed"),
("SELECT * INTO evil FROM ynab_payees", "read-only"),
("SELECT * FROM ynab_payees FOR UPDATE", "read-only"),
(
"WITH x AS (DELETE FROM ynab_payees RETURNING *) SELECT * FROM x",
"read-only",
),
("SELECT pg_read_file('/etc/passwd')", "pg_read_file"),
("SELECT (SELECT lo_import('/etc/passwd'))", "lo_import"),
("SELECT current_setting('is_superuser')", "current_setting"),
# CTE named like a real table: its body still reads the real table
("WITH users AS (SELECT * FROM users) SELECT * FROM users", "users"),
# CTE defined in a subquery doesn't cover a reference outside it
("SELECT * FROM (WITH users AS (SELECT 1) SELECT 1) x, users", "users"),
("SELECT * FROM ynab_payees WHERE id IN (SELECT id FROM users)", "users"),
],
)
def test_rejects(self, query, error):
result = validate_sql(query)
assert result is not None and error in result
def test_format_rows_keeps_duplicate_column_names():
rows = [("Groceries", "Bills", Decimal("-54.150")), ("Rent", None, Decimal("10"))]
assert format_rows(["name", "name", "total"], rows) == (
"name\tname\ttotal\nGroceries\tBills\t-54.150\nRent\t\t10"
)
assert format_rows([], []) == "Query returned no rows."
def test_unbalanced_quote_is_reported_not_raised():
error = validate_sql("SELECT * FROM ynab_payees WHERE name = 'Trader Joe's'")
assert error and error.startswith("Could not parse SQL")
def test_executed_sql_drops_comments_and_semicolon():
error, sql = _check(
"SELECT payee_name, sum(amount) FROM ynab_transactions GROUP BY 1; -- top"
)
assert error is None
assert "--" not in sql and not sql.endswith(";")
# The LIMIT wrapper must still parse: a leftover line comment would
# swallow its closing parenthesis
wrapped = sqlglot.parse_one(
f"SELECT * FROM ({sql}) AS q LIMIT 100", read="postgres"
)
assert wrapped.args["limit"] is not None
async def test_full_sync_marks_missing_rows_deleted(db):
await _apply(_budget([_txn(), _txn(id="txn-2")]), 1, log_changes=False, full=True)
# A full download omits deleted entities entirely
counts = await _apply(_budget([_txn(id="txn-2")]), 2, full=True)
assert counts["transactions"] == 1
assert (await YnabTransaction.get(id="txn-1")).deleted is True
assert (await YnabTransaction.get(id="txn-2")).deleted is False
change = await YnabChange.get(entity_id="txn-1")
assert change.action == "deleted" and "Costco" in change.label
async def test_delta_sync_does_not_treat_missing_rows_as_deleted(db):
await _apply(_budget([_txn(), _txn(id="txn-2")]), 1, log_changes=False)
await _apply(_budget([_txn(id="txn-2")]), 2)
assert (await YnabTransaction.get(id="txn-1")).deleted is False
async def test_full_sync_skips_entity_types_absent_from_payload(db):
await _apply(_budget([_txn()]), 1, log_changes=False, full=True)
budget = _budget([])
del budget["transactions"]
await _apply(budget, 2, full=True)
assert (await YnabTransaction.get(id="txn-1")).deleted is False
async def test_new_month_with_nothing_assigned_is_not_logged(db):
await _apply(_budget([]), 1, log_changes=False)
next_month = {**_month(budgeted=0, activity=0), "month": "2026-11-01"}
await _apply(_budget([], next_month), 2)
assert await YnabChange.filter(entity_type="month_category").count() == 0
async def test_recategorization_logs_names_not_ids(db):
household = {**CATEGORY, "id": "cat-2", "name": "Household"}
budget = _budget([_txn()])
budget["categories"].append(household)
await _apply(budget, 1, log_changes=False)
budget = _budget([_txn(category_id="cat-2")])
budget["categories"].append(household)
await _apply(budget, 2)
change = await YnabChange.get(entity_type="transaction")
assert change.changes == {"category": ["Groceries", "Household"]}
assert (await YnabTransaction.get(id="txn-1")).category_name == "Household"
async def test_tool_errors_are_returned_as_text(monkeypatch):
from blueprints.ynab import tools
async def fresh():
return object()
async def broken_query(*args):
raise ConnectionError("db went away")
monkeypatch.setattr(tools, "ensure_fresh", fresh)
monkeypatch.setattr(tools, "_query", broken_query)
result = await tools.ynab_account_balances.ainvoke({})
assert "db went away" in result and result.startswith("YNAB error")
async def test_recent_changes_accepts_display_names(monkeypatch):
from blueprints.ynab import tools
seen = []
async def fresh():
return object()
async def fake_query(sql, *params):
seen.append(params)
return []
monkeypatch.setattr(tools, "ensure_fresh", fresh)
monkeypatch.setattr(tools, "_query", fake_query)
for given, stored in [
("Assignments", "month_category"),
("categories", "category"),
("category groups", "category_group"),
("month", "month"),
]:
await tools.ynab_recent_changes.ainvoke({"entity_type": given})
assert seen[-1][1] == stored
result = await tools.ynab_recent_changes.ainvoke({"entity_type": "bogus"})
assert result.startswith("Unknown entity_type")
async def test_budget_summary_rejects_bad_month(monkeypatch):
from blueprints.ynab import tools
async def fresh():
return object()
monkeypatch.setattr(tools, "ensure_fresh", fresh)
for month in ("October", "2026-13"):
assert await tools.ynab_budget_summary.ainvoke({"month": month}) == (
"Month must be YYYY-MM."
)
async def test_subtransaction_parent_id_is_not_renamed(db):
split = {
"id": "sub-1",
"transaction_id": "txn-1",
"amount": -10_000,
"memo": None,
"payee_id": None,
"category_id": "cat-1",
"transfer_account_id": None,
"deleted": False,
}
budget = _budget([_txn()])
budget["subtransactions"] = [split]
await _apply(budget, 1, log_changes=False)
budget["subtransactions"] = [{**split, "amount": -12_000}]
await _apply(budget, 2)
change = await YnabChange.get(entity_type="subtransaction")
assert change.changes == {"amount": [-10.0, -12.0]}
class TestBudgetPinning:
@pytest.fixture(autouse=True)
async def _db(self, db):
yield
async def _state(self, budget_id):
from blueprints.ynab.models import YnabSyncState
await YnabSyncState.create(budget_id=budget_id, server_knowledge=1)
@pytest.mark.parametrize("configured", ["", "last-used", "default"])
async def test_alias_or_unset_uses_stored_budget(self, monkeypatch, configured):
from blueprints.ynab.sync import _current_state
monkeypatch.setenv("YNAB_BUDGET_ID", configured)
await self._state("uuid-a")
assert (await _current_state()).budget_id == "uuid-a"
async def test_different_configured_budget_is_an_error(self, monkeypatch):
from blueprints.ynab.sync import _current_state
monkeypatch.setenv("YNAB_BUDGET_ID", "uuid-b")
await self._state("uuid-a")
with pytest.raises(ValueError, match="clear the ynab_"):
await _current_state()
async def test_multiple_stored_budgets_is_an_error(self, monkeypatch):
from blueprints.ynab.sync import _current_state
monkeypatch.delenv("YNAB_BUDGET_ID", raising=False)
await self._state("uuid-a")
await self._state("uuid-b")
with pytest.raises(ValueError):
await _current_state()
async def test_parallel_syncs_share_one_failure(db, monkeypatch):
from blueprints.ynab import sync
calls = []
class FailingYNAB:
async def fetch_budget(self, budget_id, since):
calls.append(budget_id)
raise TimeoutError("ynab down")
monkeypatch.setattr(sync, "YNABService", FailingYNAB)
monkeypatch.setattr(sync, "_last_failure", float("-inf"))
await asyncio.gather(
*(sync.ensure_fresh() for _ in range(3)), return_exceptions=True
)
assert len(calls) == 1
-254
View File
@@ -1,254 +0,0 @@
"""Tests for YNAB service data formatting and filtering logic."""
import os
from unittest.mock import MagicMock, patch
import pytest
def _mock_category(
name, budgeted, activity, balance, deleted=False, hidden=False, goal_type=None
):
cat = MagicMock()
cat.name = name
cat.budgeted = budgeted
cat.activity = activity
cat.balance = balance
cat.deleted = deleted
cat.hidden = hidden
cat.goal_type = goal_type
return cat
def _mock_transaction(
var_date, payee_name, category_name, amount, memo="", deleted=False, approved=True
):
txn = MagicMock()
txn.var_date = var_date
txn.payee_name = payee_name
txn.category_name = category_name
txn.amount = amount
txn.memo = memo
txn.deleted = deleted
txn.approved = approved
return txn
@pytest.fixture
def ynab_service():
"""Create a YNABService with mocked API client."""
with patch.dict(
os.environ, {"YNAB_ACCESS_TOKEN": "fake-token", "YNAB_BUDGET_ID": "test-budget"}
):
with patch("utils.ynab_service.ynab") as mock_ynab:
# Mock the configuration and API client chain
mock_ynab.Configuration.return_value = MagicMock()
mock_ynab.ApiClient.return_value = MagicMock()
mock_ynab.PlansApi.return_value = MagicMock()
mock_ynab.TransactionsApi.return_value = MagicMock()
mock_ynab.MonthsApi.return_value = MagicMock()
mock_ynab.CategoriesApi.return_value = MagicMock()
from utils.ynab_service import YNABService
service = YNABService()
yield service
class TestGetBudgetSummary:
def test_calculates_totals(self, ynab_service):
categories = [
_mock_category("Groceries", 500_000, -350_000, 150_000),
_mock_category("Rent", 1_500_000, -1_500_000, 0),
]
mock_month = MagicMock()
mock_month.to_be_budgeted = 200_000
mock_budget = MagicMock()
mock_budget.name = "My Budget"
mock_budget.months = [mock_month]
mock_budget.categories = categories
mock_budget.currency_format = MagicMock(iso_code="USD")
mock_response = MagicMock()
mock_response.data.budget = mock_budget
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
result = ynab_service.get_budget_summary()
assert result["budget_name"] == "My Budget"
assert result["to_be_budgeted"] == 200.0
assert result["total_budgeted"] == 2000.0 # (500k + 1500k) / 1000
assert result["total_activity"] == -1850.0
assert result["currency_format"] == "USD"
def test_skips_deleted_and_hidden(self, ynab_service):
categories = [
_mock_category("Active", 100_000, -50_000, 50_000),
_mock_category("Deleted", 999_000, -999_000, 0, deleted=True),
_mock_category("Hidden", 999_000, -999_000, 0, hidden=True),
]
mock_month = MagicMock()
mock_month.to_be_budgeted = 0
mock_budget = MagicMock()
mock_budget.name = "Budget"
mock_budget.months = [mock_month]
mock_budget.categories = categories
mock_budget.currency_format = None
mock_response = MagicMock()
mock_response.data.budget = mock_budget
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
result = ynab_service.get_budget_summary()
assert result["total_budgeted"] == 100.0
assert result["currency_format"] == "USD" # Default fallback
class TestGetTransactions:
def test_filters_by_date_range(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-15", "Gas", "Transport", -40_000),
_mock_transaction(
"2026-02-01", "Store", "Groceries", -30_000
), # Out of range
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["count"] == 2
assert result["total_amount"] == -65.0
def test_filters_by_category(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Gas", "Transport", -40_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01",
end_date="2026-01-31",
category_name="groceries", # Case insensitive
)
assert result["count"] == 1
assert result["transactions"][0]["category"] == "Groceries"
def test_filters_by_payee(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Whole Foods", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Shell Gas", "Transport", -40_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01",
end_date="2026-01-31",
payee_name="whole",
)
assert result["count"] == 1
assert result["transactions"][0]["payee"] == "Whole Foods"
def test_skips_deleted(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
_mock_transaction("2026-01-06", "Deleted", "Other", -10_000, deleted=True),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["count"] == 1
def test_converts_milliunits(self, ynab_service):
transactions = [
_mock_transaction("2026-01-05", "Store", "Groceries", -12_340),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
assert result["transactions"][0]["amount"] == -12.34
def test_sorts_by_date_descending(self, ynab_service):
transactions = [
_mock_transaction("2026-01-01", "A", "Cat", -10_000),
_mock_transaction("2026-01-15", "B", "Cat", -20_000),
_mock_transaction("2026-01-10", "C", "Cat", -30_000),
]
mock_response = MagicMock()
mock_response.data.transactions = transactions
ynab_service.transactions_api.get_transactions.return_value = mock_response
result = ynab_service.get_transactions(
start_date="2026-01-01", end_date="2026-01-31"
)
dates = [t["date"] for t in result["transactions"]]
assert dates == sorted(dates, reverse=True)
class TestGetCategorySpending:
def test_month_format_normalization(self, ynab_service):
"""Passing YYYY-MM should be normalized to YYYY-MM-01."""
categories = [_mock_category("Food", 100_000, -50_000, 50_000)]
mock_month = MagicMock()
mock_month.categories = categories
mock_month.to_be_budgeted = 0
mock_response = MagicMock()
mock_response.data.month = mock_month
ynab_service.months_api.get_plan_month.return_value = mock_response
result = ynab_service.get_category_spending("2026-03")
assert result["month"] == "2026-03"
def test_identifies_overspent(self, ynab_service):
categories = [
_mock_category("Dining", 200_000, -300_000, -100_000), # Overspent
_mock_category("Groceries", 500_000, -400_000, 100_000), # Fine
]
mock_month = MagicMock()
mock_month.categories = categories
mock_month.to_be_budgeted = 0
mock_response = MagicMock()
mock_response.data.month = mock_month
ynab_service.months_api.get_plan_month.return_value = mock_response
result = ynab_service.get_category_spending("2026-03")
assert len(result["overspent_categories"]) == 1
assert result["overspent_categories"][0]["name"] == "Dining"
assert result["overspent_categories"][0]["overspent_by"] == 100.0
+427
View File
@@ -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"
+21 -389
View File
@@ -1,405 +1,37 @@
"""YNAB API service for querying budget data."""
"""YNAB API client used by the budget mirror (``blueprints.ynab.sync``)."""
import os
from datetime import datetime, timedelta
from typing import Any, Optional
from dotenv import load_dotenv
import ynab
import httpx
# Load environment variables
load_dotenv()
YNAB_API_URL = "https://api.ynab.com/v1"
class YNABService:
"""Service for interacting with YNAB API."""
"""Thin async client for the YNAB endpoints the mirror needs."""
def __init__(self):
"""Initialize YNAB API client."""
self.access_token = os.getenv("YNAB_ACCESS_TOKEN", "")
self.budget_id = os.getenv("YNAB_BUDGET_ID", "")
if not self.access_token:
raise ValueError("YNAB_ACCESS_TOKEN environment variable is required")
# Configure API client
configuration = ynab.Configuration(access_token=self.access_token)
self.api_client = ynab.ApiClient(configuration)
async def fetch_budget(self, budget_id: str, last_knowledge: int | None) -> dict:
"""GET a whole budget, or only what changed since ``last_knowledge``.
# Initialize API endpoints
self.plans_api = ynab.PlansApi(self.api_client)
self.transactions_api = ynab.TransactionsApi(self.api_client)
self.months_api = ynab.MonthsApi(self.api_client)
self.categories_api = ynab.CategoriesApi(self.api_client)
# Get budget ID if not provided, fall back to last-used
if not self.budget_id:
self.budget_id = "last-used"
def get_budget_summary(self) -> dict[str, Any]:
"""Get overall budget summary and health status.
Returns:
Dictionary containing budget summary with to-be-budgeted amount,
total budgeted, total activity, and overall budget health.
Returns ``{"budget": {...}, "server_knowledge": int}``. Full responses
omit deleted entities; delta responses include them with
``deleted: true``.
"""
budget_response = self.plans_api.get_plan_by_id(self.budget_id)
budget_data = budget_response.data.budget
# Calculate totals from categories
to_be_budgeted = (
budget_data.months[0].to_be_budgeted / 1000 if budget_data.months else 0
)
total_budgeted = 0
total_activity = 0
total_available = 0
for category in budget_data.categories or []:
if category.deleted or category.hidden:
continue
total_budgeted += category.budgeted / 1000
total_activity += category.activity / 1000
total_available += category.balance / 1000
return {
"budget_name": budget_data.name,
"to_be_budgeted": round(to_be_budgeted, 2),
"total_budgeted": round(total_budgeted, 2),
"total_activity": round(total_activity, 2),
"total_available": round(total_available, 2),
"currency_format": budget_data.currency_format.iso_code
if budget_data.currency_format
else "USD",
"summary": f"Budget '{budget_data.name}' has ${abs(to_be_budgeted):.2f} {'to be budgeted' if to_be_budgeted > 0 else 'overbudgeted'}. "
f"Total budgeted: ${total_budgeted:.2f}, Total spent: ${abs(total_activity):.2f}, "
f"Total available: ${total_available:.2f}.",
}
def get_transactions(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
category_name: Optional[str] = None,
payee_name: Optional[str] = None,
) -> dict[str, Any]:
"""Get transactions filtered by date range, category, or payee.
Args:
start_date: Start date in YYYY-MM-DD format (defaults to 30 days ago)
end_date: End date in YYYY-MM-DD format (defaults to today)
category_name: Filter by category name (case-insensitive partial match)
payee_name: Filter by payee name (case-insensitive partial match)
Returns:
Dictionary containing matching transactions and summary statistics.
"""
# Set default date range if not provided
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
# Get transactions (SDK v2 requires datetime.date, not string)
since_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
transactions_response = self.transactions_api.get_transactions(
self.budget_id, since_date=since_date_obj
)
transactions = transactions_response.data.transactions or []
# Filter by date range, category, and payee
filtered_transactions = []
total_amount = 0
for txn in transactions:
# Skip if deleted or before start date or after end date
if txn.deleted:
continue
txn_date = str(txn.var_date)
if txn_date < start_date or txn_date > end_date:
continue
# Filter by category if specified
if category_name and txn.category_name:
if category_name.lower() not in txn.category_name.lower():
continue
# Filter by payee if specified
if payee_name and txn.payee_name:
if payee_name.lower() not in txn.payee_name.lower():
continue
amount = txn.amount / 1000 # Convert milliunits to dollars
filtered_transactions.append(
{
"date": str(txn.var_date),
"payee": txn.payee_name,
"category": txn.category_name,
"memo": txn.memo,
"amount": round(amount, 2),
"approved": txn.approved,
}
params = {}
if last_knowledge is not None:
params["last_knowledge_of_server"] = last_knowledge
async with httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0)
) as client:
response = await client.get(
f"{YNAB_API_URL}/budgets/{budget_id}",
params=params,
headers={"Authorization": f"Bearer {self.access_token}"},
)
total_amount += amount
# Sort by date (most recent first)
filtered_transactions.sort(key=lambda x: x["date"], reverse=True)
return {
"transactions": filtered_transactions,
"count": len(filtered_transactions),
"total_amount": round(total_amount, 2),
"start_date": start_date,
"end_date": end_date,
"filters": {"category": category_name, "payee": payee_name},
}
def get_category_spending(self, month: Optional[str] = None) -> dict[str, Any]:
"""Get spending breakdown by category for a specific month.
Args:
month: Month in YYYY-MM format (defaults to current month)
Returns:
Dictionary containing spending by category and summary.
"""
if not month:
month = datetime.now().strftime("%Y-%m-01")
else:
# Ensure format is YYYY-MM-01
if len(month) == 7: # YYYY-MM
month = f"{month}-01"
# Get budget month (SDK v2 requires datetime.date, not string)
month_date_obj = datetime.strptime(month, "%Y-%m-%d").date()
month_response = self.months_api.get_plan_month(self.budget_id, month_date_obj)
month_data = month_response.data.month
categories_spending = []
total_budgeted = 0
total_activity = 0
total_available = 0
overspent_categories = []
for category in month_data.categories or []:
if category.deleted or category.hidden:
continue
budgeted = category.budgeted / 1000
activity = category.activity / 1000
available = category.balance / 1000
total_budgeted += budgeted
total_activity += activity
total_available += available
# Track overspent categories
if available < 0:
overspent_categories.append(
{
"name": category.name,
"budgeted": round(budgeted, 2),
"spent": round(abs(activity), 2),
"overspent_by": round(abs(available), 2),
}
)
# Only include categories with activity
if activity != 0:
categories_spending.append(
{
"category": category.name,
"budgeted": round(budgeted, 2),
"activity": round(activity, 2),
"available": round(available, 2),
"goal_type": category.goal_type
if hasattr(category, "goal_type")
else None,
}
)
# Sort by absolute activity (highest spending first)
categories_spending.sort(key=lambda x: abs(x["activity"]), reverse=True)
return {
"month": month[:7], # Return YYYY-MM format
"categories": categories_spending,
"total_budgeted": round(total_budgeted, 2),
"total_spent": round(abs(total_activity), 2),
"total_available": round(total_available, 2),
"overspent_categories": overspent_categories,
"to_be_budgeted": round(month_data.to_be_budgeted / 1000, 2)
if month_data.to_be_budgeted
else 0,
}
def get_category_allocations(self, month: Optional[str] = None) -> dict[str, Any]:
"""Get all category allocations (assigned and available) grouped by category group.
Args:
month: Month in YYYY-MM format (defaults to current month)
Returns:
Dictionary with all categories grouped, showing assigned and available amounts.
"""
if not month:
month = datetime.now().strftime("%Y-%m-01")
else:
if len(month) == 7:
month = f"{month}-01"
month_date_obj = datetime.strptime(month, "%Y-%m-%d").date()
# Get category groups so we know which group each category belongs to
categories_response = self.categories_api.get_categories(self.budget_id)
category_group_map: dict[str, str] = {}
group_order: list[str] = []
for group in categories_response.data.category_groups or []:
if group.deleted or group.hidden:
continue
if group.name not in group_order:
group_order.append(group.name)
for cat in group.categories or []:
category_group_map[cat.id] = group.name
# Get month data for assigned/available amounts
month_response = self.months_api.get_plan_month(self.budget_id, month_date_obj)
month_data = month_response.data.month
groups: dict[str, list] = {g: [] for g in group_order}
total_assigned = 0.0
total_available = 0.0
for category in month_data.categories or []:
if category.deleted or category.hidden:
continue
assigned = category.budgeted / 1000
activity = category.activity / 1000
available = category.balance / 1000
group_name = category_group_map.get(category.id, "Other")
if group_name not in groups:
groups[group_name] = []
groups[group_name].append(
{
"name": category.name,
"assigned": round(assigned, 2),
"activity": round(activity, 2),
"available": round(available, 2),
}
)
total_assigned += assigned
total_available += available
return {
"month": month[:7],
"group_order": group_order,
"groups": groups,
"total_assigned": round(total_assigned, 2),
"total_available": round(total_available, 2),
"to_be_budgeted": round(month_data.to_be_budgeted / 1000, 2)
if month_data.to_be_budgeted
else 0,
}
def get_spending_insights(self, months_back: int = 3) -> dict[str, Any]:
"""Generate insights about spending patterns and budget health.
Args:
months_back: Number of months to analyze (default 3)
Returns:
Dictionary containing spending insights, trends, and recommendations.
"""
current_month = datetime.now()
monthly_data = []
# Collect data for the last N months
for i in range(months_back):
month_date = current_month - timedelta(days=30 * i)
month_str = month_date.strftime("%Y-%m-01")
try:
month_spending = self.get_category_spending(month_str)
monthly_data.append(
{
"month": month_str[:7],
"total_spent": month_spending["total_spent"],
"total_budgeted": month_spending["total_budgeted"],
"overspent_categories": month_spending["overspent_categories"],
}
)
except Exception:
# Skip months that don't have data yet
continue
if not monthly_data:
return {"error": "No spending data available for analysis"}
# Calculate average spending
avg_spending = sum(m["total_spent"] for m in monthly_data) / len(monthly_data)
current_spending = monthly_data[0]["total_spent"] if monthly_data else 0
# Identify spending trend
if len(monthly_data) >= 2:
recent_avg = sum(m["total_spent"] for m in monthly_data[:2]) / 2
older_avg = sum(m["total_spent"] for m in monthly_data[1:]) / (
len(monthly_data) - 1
)
trend = (
"increasing"
if recent_avg > older_avg * 1.1
else "decreasing"
if recent_avg < older_avg * 0.9
else "stable"
)
else:
trend = "insufficient data"
# Find frequently overspent categories
overspent_frequency = {}
for month in monthly_data:
for cat in month["overspent_categories"]:
cat_name = cat["name"]
if cat_name not in overspent_frequency:
overspent_frequency[cat_name] = 0
overspent_frequency[cat_name] += 1
frequently_overspent = [
{"category": cat, "months_overspent": count}
for cat, count in overspent_frequency.items()
if count > 1
]
frequently_overspent.sort(key=lambda x: x["months_overspent"], reverse=True)
# Generate recommendations
recommendations = []
if current_spending > avg_spending * 1.2:
recommendations.append(
f"Current month spending (${current_spending:.2f}) is significantly higher than your {months_back}-month average (${avg_spending:.2f})"
)
if frequently_overspent:
top_overspent = frequently_overspent[0]
recommendations.append(
f"'{top_overspent['category']}' has been overspent in {top_overspent['months_overspent']} of the last {months_back} months"
)
if trend == "increasing":
recommendations.append(
"Your spending trend is increasing. Consider reviewing your budget allocations."
)
return {
"analysis_period": f"Last {months_back} months",
"average_monthly_spending": round(avg_spending, 2),
"current_month_spending": round(current_spending, 2),
"spending_trend": trend,
"frequently_overspent_categories": frequently_overspent,
"recommendations": recommendations,
"monthly_breakdown": monthly_data,
}
response.raise_for_status()
return response.json()["data"]
Generated
+11 -20
View File
@@ -2627,12 +2627,12 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "quart" },
{ name = "quart-jwt-extended" },
{ name = "sqlglot" },
{ name = "tavily-python" },
{ name = "tomlkit" },
{ name = "tortoise-orm" },
{ name = "tortoise-orm-stubs" },
{ name = "twilio" },
{ name = "ynab" },
]
[package.optional-dependencies]
@@ -2683,12 +2683,12 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "quart", specifier = ">=0.20.0" },
{ name = "quart-jwt-extended", specifier = ">=0.1.0" },
{ name = "sqlglot", specifier = ">=30.22.0" },
{ name = "tavily-python", specifier = ">=0.7.17" },
{ name = "tomlkit", specifier = ">=0.13.3" },
{ name = "tortoise-orm", specifier = ">=0.25.1,<1.0.0" },
{ name = "tortoise-orm-stubs", specifier = ">=1.0.2" },
{ name = "twilio", specifier = ">=9.10.2" },
{ name = "ynab", specifier = ">=1.3.0" },
]
provides-extras = ["test"]
@@ -2955,6 +2955,15 @@ asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sqlglot"
version = "30.22.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e0/db58fbf2527426758dc1e862ce538736978e100e4e78fc9657e9661826ee/sqlglot-30.22.0.tar.gz", hash = "sha256:ec4b83ca8236ea8867f574a382dc15ce35b071c977fecfcc66482d9a3f500661", size = 6088770, upload-time = "2026-10-09T16:09:01.04Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/4c/b8474b02b572d9c7a2903e364335d566d52b6128b834b92a7cdfe5597823/sqlglot-30.22.0-py3-none-any.whl", hash = "sha256:90aa461490fcd95d14ec3842a97506ae20f6d3e9313307ad31be793d479cca65", size = 777816, upload-time = "2026-10-09T16:08:59.07Z" },
]
[[package]]
name = "sqlite-vec"
version = "0.1.9"
@@ -3531,24 +3540,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]
[[package]]
name = "ynab"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "pydantic", version = "2.11.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "pydantic", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "python-dateutil" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/3e/36599ae876db3e1d32e393ab0934547df75bab70373c14ca5805246f99bc/ynab-1.9.0.tar.gz", hash = "sha256:fa50bdff641b3a273661e9f6e8a210f5ad98991a998dc09dec0a8122d734d1c6", size = 64898, upload-time = "2025-10-06T19:14:32.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl", hash = "sha256:72ac0219605b4280149684ecd0fec3bd75d938772d65cdeea9b3e66a1b2f470d", size = 208674, upload-time = "2025-10-06T19:14:31.719Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"