Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20ec7ca8b6 | |||
| 50d123e4d2 | |||
| 417d4061d7 | |||
| 0fa30edb82 | |||
| 10f3f1bcab | |||
| 3dbd320fe9 | |||
| d1c3b7693e | |||
| 9249f92db8 | |||
| 1cdc380870 | |||
| 1c1ae9f158 | |||
| c678bf55bc | |||
| 0cf6229441 | |||
| d2f21a6d23 | |||
| 1a73e26f23 | |||
| 86e1912871 | |||
| abf9415696 | |||
| b77e21474a | |||
| 004a9bc85d | |||
| bc3e3d2797 | |||
| fde8208afe | |||
| 8f02318542 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import uuid
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
from blueprints.conversation.models import Conversation
|
||||
from blueprints.users.decorators import jwt_access_required
|
||||
|
||||
from .logic import artifact_full, artifact_summary
|
||||
from .models import Artifact
|
||||
|
||||
artifacts_blueprint = Blueprint("artifacts_api", __name__, url_prefix="/api/artifacts")
|
||||
|
||||
|
||||
def _parse_uuid(value: str) -> uuid.UUID | None:
|
||||
"""Validate ids before querying: asyncpg raises OperationalError on bad UUIDs."""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@artifacts_blueprint.get("")
|
||||
@jwt_access_required
|
||||
async def list_artifacts():
|
||||
user_uuid = get_jwt_identity()
|
||||
raw_conversation_id = request.args.get("conversation_id", "").strip()
|
||||
if not raw_conversation_id:
|
||||
return jsonify({"error": "conversation_id is required"}), 400
|
||||
conversation_id = _parse_uuid(raw_conversation_id)
|
||||
if conversation_id is None:
|
||||
return jsonify({"error": "conversation_id must be a UUID"}), 400
|
||||
|
||||
conversation = await Conversation.get_or_none(id=conversation_id, user_id=user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
artifacts = await Artifact.filter(
|
||||
conversation_id=conversation.id, user_id=user_uuid
|
||||
).order_by("created_at")
|
||||
return jsonify([artifact_summary(a) for a in artifacts])
|
||||
|
||||
|
||||
@artifacts_blueprint.get("/<artifact_id>")
|
||||
@jwt_access_required
|
||||
async def get_artifact(artifact_id: str):
|
||||
user_uuid = get_jwt_identity()
|
||||
parsed_id = _parse_uuid(artifact_id)
|
||||
artifact = (
|
||||
await Artifact.get_or_none(id=parsed_id, user_id=user_uuid)
|
||||
if parsed_id is not None
|
||||
else None
|
||||
)
|
||||
if artifact is None:
|
||||
return jsonify({"error": "artifact not found"}), 404
|
||||
return jsonify(artifact_full(artifact))
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .models import Artifact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Artifacts whose serialised `data` exceeds this are dropped (the tool text still persists).
|
||||
ARTIFACT_MAX_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def artifact_ref(artifact: Artifact) -> dict:
|
||||
"""The lightweight reference sent in SSE events and the conversation payload."""
|
||||
return {"id": str(artifact.id), "kind": artifact.kind, "title": artifact.title}
|
||||
|
||||
|
||||
def artifact_summary(artifact: Artifact) -> dict:
|
||||
"""ArtifactRef plus listing metadata, without `data`."""
|
||||
return {
|
||||
**artifact_ref(artifact),
|
||||
"tool": artifact.tool,
|
||||
"message_id": str(artifact.message_id) if artifact.message_id else None,
|
||||
"created_at": artifact.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def artifact_full(artifact: Artifact) -> dict:
|
||||
return {
|
||||
**artifact_summary(artifact),
|
||||
"data": artifact.data,
|
||||
"source_ref": artifact.source_ref,
|
||||
"conversation_id": str(artifact.conversation_id),
|
||||
}
|
||||
|
||||
|
||||
async def create_artifact_from_output(
|
||||
*, output, tool: str, user, conversation, message=None
|
||||
) -> Artifact | None:
|
||||
"""Persist `output.artifact` if it is a ToolArtifact dict; otherwise return None.
|
||||
|
||||
Tools opt in via `@tool(response_format="content_and_artifact")`. Artifacts
|
||||
without a `kind` (e.g. simba_search's raw Documents) are ignored, as are
|
||||
ones whose `data` is not JSON-serialisable or over ARTIFACT_MAX_BYTES.
|
||||
"""
|
||||
raw = getattr(output, "artifact", None)
|
||||
if not isinstance(raw, dict) or not raw.get("kind"):
|
||||
return None
|
||||
|
||||
data = raw.get("data") or {}
|
||||
source_ref = raw.get("source_ref")
|
||||
try:
|
||||
size = len(json.dumps(data).encode("utf-8"))
|
||||
json.dumps(source_ref)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Dropping %s artifact from %s: not serialisable (%s)", raw["kind"], tool, e
|
||||
)
|
||||
return None
|
||||
if size > ARTIFACT_MAX_BYTES:
|
||||
logger.warning(
|
||||
"Dropping %s artifact from %s: %d bytes exceeds %d",
|
||||
raw["kind"],
|
||||
tool,
|
||||
size,
|
||||
ARTIFACT_MAX_BYTES,
|
||||
)
|
||||
return None
|
||||
|
||||
return await Artifact.create(
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
tool=tool,
|
||||
kind=str(raw["kind"])[:64],
|
||||
title=str(raw.get("title") or "")[:255],
|
||||
data=data,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class Artifact(Model):
|
||||
"""A structured tool result (recipe, budget, note…) rendered in the artifact panel."""
|
||||
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="artifacts")
|
||||
conversation = fields.ForeignKeyField(
|
||||
"models.Conversation", related_name="artifacts", on_delete=fields.CASCADE
|
||||
)
|
||||
message = fields.ForeignKeyField(
|
||||
"models.ConversationMessage",
|
||||
related_name="artifacts",
|
||||
null=True,
|
||||
on_delete=fields.SET_NULL,
|
||||
)
|
||||
tool = fields.CharField(max_length=255)
|
||||
kind = fields.CharField(max_length=64)
|
||||
title = fields.CharField(max_length=255)
|
||||
data = fields.JSONField()
|
||||
source_ref = fields.JSONField(null=True)
|
||||
version = fields.IntField(default=1)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "artifacts"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pure shaping of LangChain tool events into SSE `tool_start` / `tool_end` payloads."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from .tool_labels import tool_label
|
||||
|
||||
INPUT_VALUE_MAX_CHARS = 200
|
||||
# Injected by LangChain rather than chosen by the model; never shown to the client.
|
||||
_HIDDEN_INPUT_KEYS = frozenset({"config", "callbacks", "run_manager"})
|
||||
|
||||
_TOOL_HEADER_RE = re.compile(r"^\[(\S+) result\]")
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
if len(text) > INPUT_VALUE_MAX_CHARS:
|
||||
return text[: INPUT_VALUE_MAX_CHARS - 1] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def summarize_tool_input(raw) -> dict[str, str | int | float | bool]:
|
||||
"""Reduce a tool's input to flat, display-safe scalars."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {"input": _truncate(str(raw))}
|
||||
summary: dict[str, str | int | float | bool] = {}
|
||||
for key, value in raw.items():
|
||||
if key in _HIDDEN_INPUT_KEYS or value is None:
|
||||
continue
|
||||
if isinstance(value, (bool, int, float)):
|
||||
summary[key] = value
|
||||
elif isinstance(value, str):
|
||||
summary[key] = _truncate(value)
|
||||
else:
|
||||
try:
|
||||
summary[key] = _truncate(json.dumps(value, default=str))
|
||||
except (TypeError, ValueError):
|
||||
summary[key] = _truncate(str(value))
|
||||
return summary
|
||||
|
||||
|
||||
def tool_start_payload(event: dict) -> dict:
|
||||
name = event["name"]
|
||||
return {
|
||||
"type": "tool_start",
|
||||
"run_id": event["run_id"],
|
||||
"tool": name,
|
||||
"label": tool_label(name),
|
||||
"input": summarize_tool_input(event.get("data", {}).get("input")),
|
||||
}
|
||||
|
||||
|
||||
def tool_end_payload(
|
||||
event: dict,
|
||||
started_at: float | None,
|
||||
artifact_ref: dict | None,
|
||||
*,
|
||||
now: float,
|
||||
status: str = "ok",
|
||||
) -> dict:
|
||||
name = event["name"]
|
||||
return {
|
||||
"type": "tool_end",
|
||||
"run_id": event["run_id"],
|
||||
"tool": name,
|
||||
"label": tool_label(name),
|
||||
"status": status,
|
||||
"ms": round((now - started_at) * 1000) if started_at is not None else 0,
|
||||
"artifact": artifact_ref,
|
||||
}
|
||||
|
||||
|
||||
def tool_name_from_message(text: str) -> str:
|
||||
"""Recover the tool name from a persisted `[tool_name result]` header."""
|
||||
match = _TOOL_HEADER_RE.match(text)
|
||||
return match.group(1) if match else "tool"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Friendly, user-facing labels for agent tool calls (shown in the activity timeline)."""
|
||||
|
||||
TOOL_LABELS: dict[str, str] = {
|
||||
"get_current_date": "Checking the date…",
|
||||
"web_search": "Searching the web…",
|
||||
"simba_search": "Searching household documents…",
|
||||
"save_user_memory": "Saving to memory…",
|
||||
# YNAB (current names)
|
||||
"ynab_budget_summary": "Checking budget…",
|
||||
"ynab_search_transactions": "Searching transactions…",
|
||||
"ynab_category_spending": "Reviewing category spending…",
|
||||
"ynab_category_allocations": "Reviewing category budgets…",
|
||||
"ynab_insights": "Analyzing spending trends…",
|
||||
# YNAB (names after the #55 mirror)
|
||||
"ynab_account_balances": "Checking account balances…",
|
||||
"ynab_spending_report": "Building spending report…",
|
||||
"ynab_category_budget": "Reviewing category budgets…",
|
||||
"ynab_recent_changes": "Checking recent budget changes…",
|
||||
"ynab_sql": "Querying budget data…",
|
||||
# Mealie
|
||||
"mealie_todays_meals": "Checking today's meals…",
|
||||
"mealie_meal_plans": "Checking the meal plan…",
|
||||
"mealie_get_recipe": "Fetching recipe…",
|
||||
"mealie_shopping_lists": "Checking shopping lists…",
|
||||
"mealie_create_meal_plan": "Adding to the meal plan…",
|
||||
# Obsidian / journal
|
||||
"obsidian_search_notes": "Searching notes…",
|
||||
"obsidian_read_note": "Reading note…",
|
||||
"obsidian_create_note": "Creating note…",
|
||||
"obsidian_create_task": "Creating task…",
|
||||
"journal_get_today": "Reading today's journal…",
|
||||
"journal_get_tasks": "Checking tasks…",
|
||||
"journal_add_task": "Adding task…",
|
||||
"journal_complete_task": "Completing task…",
|
||||
# Google
|
||||
"get_calendar_events": "Checking calendar…",
|
||||
"create_calendar_event": "Creating calendar event…",
|
||||
"gmail_search_emails": "Searching email…",
|
||||
"gmail_get_email": "Reading email…",
|
||||
# SimbaKit
|
||||
"simbakit_device_status": "Checking Simba's devices…",
|
||||
"simbakit_weight_history": "Checking Simba's weight…",
|
||||
"simbakit_litter_activity": "Checking litter box activity…",
|
||||
"simbakit_drinking_activity": "Checking water fountain activity…",
|
||||
# Beli
|
||||
"beli_search_restaurants": "Searching restaurants…",
|
||||
}
|
||||
|
||||
# MCP tools are discovered at runtime, so they match by name prefix.
|
||||
TOOL_PREFIX_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("homebox_", "Checking home inventory…"),
|
||||
("bookstack_", "Checking the wiki…"),
|
||||
)
|
||||
|
||||
|
||||
def tool_label(tool_name: str) -> str:
|
||||
"""Return the friendly label for a tool, falling back to "Using <tool>…"."""
|
||||
label = TOOL_LABELS.get(tool_name)
|
||||
if label:
|
||||
return label
|
||||
for prefix, prefix_label in TOOL_PREFIX_LABELS:
|
||||
if tool_name.startswith(prefix):
|
||||
return prefix_label
|
||||
return f"Using {tool_name}…"
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""YNAB budget mirror: Postgres models, delta sync, and agent tools."""
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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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"
|
||||
)
|
||||
@@ -24,3 +24,4 @@ nav:
|
||||
- Architecture:
|
||||
- Authentication: authentication.md
|
||||
- Desktop Client (proposal): desktop-client.md
|
||||
- Cowork UI plan: cowork-ui-plan.md
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Tests for artifact creation (blueprints/artifacts/logic.py) and the owner-scoped API."""
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.artifacts import artifacts_blueprint
|
||||
from blueprints.artifacts.logic import ARTIFACT_MAX_BYTES, create_artifact_from_output
|
||||
from blueprints.artifacts.models import Artifact
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage
|
||||
from blueprints.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:",
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.artifacts.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
yield
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner(db):
|
||||
user = await User.create(username="owner", email="owner@example.com")
|
||||
conversation = await Conversation.create(name="c", user=user)
|
||||
message = await ConversationMessage.create(
|
||||
text="[mealie_get_recipe result]\nSoup",
|
||||
conversation=conversation,
|
||||
speaker="tool",
|
||||
)
|
||||
return user, conversation, message
|
||||
|
||||
|
||||
def _output(artifact):
|
||||
return SimpleNamespace(content="text", artifact=artifact)
|
||||
|
||||
|
||||
RECIPE = {"kind": "mealie.recipe", "title": "Soup", "data": {"slug": "soup"}}
|
||||
|
||||
|
||||
class TestCreateArtifact:
|
||||
async def test_creates_and_links(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output({**RECIPE, "source_ref": {"system": "mealie"}}),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
assert artifact is not None
|
||||
saved = await Artifact.get(id=artifact.id)
|
||||
assert saved.message_id == message.id
|
||||
assert saved.kind == "mealie.recipe"
|
||||
assert saved.data == {"slug": "soup"}
|
||||
assert saved.source_ref == {"system": "mealie"}
|
||||
assert saved.version == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"artifact", [None, [{"page_content": "doc"}], {"title": "no kind"}, "str"]
|
||||
)
|
||||
async def test_ignores_non_artifacts(self, owner, artifact):
|
||||
user, conversation, message = owner
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(artifact),
|
||||
tool="simba_search",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
assert result is None
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
async def test_plain_string_output(self, owner):
|
||||
user, conversation, message = owner
|
||||
result = await create_artifact_from_output(
|
||||
output="just text", tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
|
||||
async def test_drops_oversized(self, owner):
|
||||
user, conversation, message = owner
|
||||
big = {**RECIPE, "data": {"blob": "x" * (ARTIFACT_MAX_BYTES + 1)}}
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(big), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
async def test_drops_unserialisable(self, owner):
|
||||
user, conversation, message = owner
|
||||
bad = {**RECIPE, "data": {"obj": object()}}
|
||||
result = await create_artifact_from_output(
|
||||
output=_output(bad), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
assert result is None
|
||||
|
||||
async def test_message_delete_sets_null(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="t",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
await message.delete()
|
||||
assert (await Artifact.get(id=artifact.id)).message_id is None
|
||||
|
||||
async def test_conversation_delete_cascades(self, owner):
|
||||
user, conversation, message = owner
|
||||
await create_artifact_from_output(
|
||||
output=_output(RECIPE), tool="t", user=user, conversation=conversation
|
||||
)
|
||||
await conversation.delete()
|
||||
assert await Artifact.all().count() == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(artifacts_blueprint)
|
||||
return app
|
||||
|
||||
|
||||
async def _headers(app, user):
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(user.id))
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestArtifactRoutes:
|
||||
async def _setup(self, owner):
|
||||
user, conversation, message = owner
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
other = await User.create(username="other", email="other@example.com")
|
||||
return user, other, conversation, message, artifact
|
||||
|
||||
async def test_list_for_owner(self, app, owner):
|
||||
user, _, conversation, message, artifact = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/artifacts?conversation_id={conversation.id}",
|
||||
headers=await _headers(app, user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["id"] == str(artifact.id)
|
||||
assert body[0]["kind"] == "mealie.recipe"
|
||||
assert body[0]["title"] == "Soup"
|
||||
assert body[0]["tool"] == "mealie_get_recipe"
|
||||
assert body[0]["message_id"] == str(message.id)
|
||||
assert "created_at" in body[0]
|
||||
assert "data" not in body[0]
|
||||
|
||||
async def test_get_for_owner(self, app, owner):
|
||||
user, _, conversation, message, artifact = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/artifacts/{artifact.id}", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["data"] == {"slug": "soup"}
|
||||
assert body["source_ref"] is None
|
||||
assert body["conversation_id"] == str(conversation.id)
|
||||
|
||||
async def test_other_user_gets_404(self, app, owner):
|
||||
_, other, conversation, _, artifact = await self._setup(owner)
|
||||
headers = await _headers(app, other)
|
||||
client = app.test_client()
|
||||
resp = await client.get(f"/api/artifacts/{artifact.id}", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
resp = await client.get(
|
||||
f"/api/artifacts?conversation_id={conversation.id}", headers=headers
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_id_is_404(self, app, owner):
|
||||
user, *_ = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_id_is_404_without_querying(self, app, owner, monkeypatch):
|
||||
user, *_ = await self._setup(owner)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise AssertionError("queried with an invalid id")
|
||||
|
||||
monkeypatch.setattr(Artifact, "get_or_none", _boom)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_invalid_conversation_id_is_400_without_querying(
|
||||
self, app, owner, monkeypatch
|
||||
):
|
||||
user, *_ = await self._setup(owner)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise AssertionError("queried with an invalid id")
|
||||
|
||||
monkeypatch.setattr(Conversation, "get_or_none", _boom)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts?conversation_id=garbage", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_requires_auth(self, app, owner):
|
||||
await self._setup(owner)
|
||||
resp = await app.test_client().get("/api/artifacts/whatever")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_missing_conversation_id_is_400(self, app, owner):
|
||||
user, *_ = await self._setup(owner)
|
||||
resp = await app.test_client().get(
|
||||
"/api/artifacts", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestConversationPayload:
|
||||
async def test_tool_messages_gain_tool_label_artifact(self, owner):
|
||||
from blueprints.conversation import conversation_blueprint
|
||||
|
||||
user, conversation, message = owner
|
||||
await ConversationMessage.create(
|
||||
text="[web_search result]\nresults",
|
||||
conversation=conversation,
|
||||
speaker="tool",
|
||||
)
|
||||
await ConversationMessage.create(
|
||||
text="hello", conversation=conversation, speaker="user"
|
||||
)
|
||||
artifact = await create_artifact_from_output(
|
||||
output=_output(RECIPE),
|
||||
tool="mealie_get_recipe",
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conversation_blueprint)
|
||||
resp = await app.test_client().get(
|
||||
f"/api/conversation/{conversation.id}", headers=await _headers(app, user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
by_text = {m["text"]: m for m in (await resp.get_json())["messages"]}
|
||||
|
||||
recipe = by_text["[mealie_get_recipe result]"]
|
||||
assert recipe["tool"] == "mealie_get_recipe"
|
||||
assert recipe["label"] == "Fetching recipe…"
|
||||
assert recipe["artifact"] == {
|
||||
"id": str(artifact.id),
|
||||
"kind": "mealie.recipe",
|
||||
"title": "Soup",
|
||||
}
|
||||
search = by_text["[web_search result]"]
|
||||
assert search["tool"] == "web_search"
|
||||
assert search["artifact"] is None
|
||||
assert "tool" not in by_text["hello"]
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
async def astream_events(self, payload, version, config):
|
||||
for event in self._events:
|
||||
yield event
|
||||
|
||||
|
||||
class TestStreamQuery:
|
||||
async def test_emits_v2_tool_events_and_saves_artifact(self, owner, monkeypatch):
|
||||
import json
|
||||
|
||||
import blueprints.conversation as conv
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
user, conversation, _ = owner
|
||||
events = [
|
||||
{
|
||||
"event": "on_tool_start",
|
||||
"name": "mealie_get_recipe",
|
||||
"run_id": "r1",
|
||||
"data": {"input": {"recipe_slug": "soup", "config": {}}},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_start",
|
||||
"name": "web_search",
|
||||
"run_id": "r2",
|
||||
"data": {"input": {"query": "cats"}},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_end",
|
||||
"name": "mealie_get_recipe",
|
||||
"run_id": "r1",
|
||||
"data": {
|
||||
"output": ToolMessage(
|
||||
content="Soup recipe",
|
||||
artifact=RECIPE,
|
||||
tool_call_id="c1",
|
||||
name="mealie_get_recipe",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
"event": "on_tool_error",
|
||||
"name": "web_search",
|
||||
"run_id": "r2",
|
||||
"data": {"error": RuntimeError("boom")},
|
||||
},
|
||||
{
|
||||
"event": "on_chain_end",
|
||||
"name": "agent",
|
||||
"data": {"output": {"messages": [AIMessage(content="Done")]}},
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(conv, "get_agent_for", lambda u: _FakeAgent(events))
|
||||
|
||||
async def _prompt(user_id):
|
||||
return "system"
|
||||
|
||||
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conv.conversation_blueprint)
|
||||
resp = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
json={"query": "soup?", "conversation_id": str(conversation.id)},
|
||||
headers=await _headers(app, user),
|
||||
)
|
||||
body = (await resp.get_data()).decode()
|
||||
frames = [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in body.splitlines()
|
||||
if line.startswith("data: {")
|
||||
]
|
||||
by_type = {}
|
||||
for f in frames:
|
||||
by_type.setdefault(f["type"], []).append(f)
|
||||
|
||||
starts = by_type["tool_start"]
|
||||
assert starts[0]["input"] == {"recipe_slug": "soup"}
|
||||
assert starts[0]["label"] == "Fetching recipe…"
|
||||
|
||||
ends = {f["run_id"]: f for f in by_type["tool_end"]}
|
||||
artifact = await Artifact.get(tool="mealie_get_recipe")
|
||||
assert ends["r1"]["status"] == "ok"
|
||||
assert ends["r1"]["artifact"] == {
|
||||
"id": str(artifact.id),
|
||||
"kind": "mealie.recipe",
|
||||
"title": "Soup",
|
||||
}
|
||||
assert ends["r2"]["status"] == "error"
|
||||
assert ends["r2"]["artifact"] is None
|
||||
assert by_type["response"][0]["message"] == "Done"
|
||||
|
||||
tool_message = await ConversationMessage.get(id=artifact.message_id)
|
||||
assert tool_message.text.startswith("[mealie_get_recipe result]")
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for tools returning (text, artifact) via content_and_artifact.
|
||||
|
||||
The text half must stay identical to the pre-artifact output (the LLM's view),
|
||||
and the artifact must match the contract in docs/cowork-ui-plan.md.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
os.environ.setdefault("TAVILY_API_KEY", "dummy")
|
||||
|
||||
from blueprints.conversation import agents # noqa: E402
|
||||
from blueprints.conversation.artifacts_util import ( # noqa: E402
|
||||
NOTE_MAX_CHARS,
|
||||
SNIPPET_MAX_CHARS,
|
||||
format_date_range,
|
||||
search_documents_artifact,
|
||||
)
|
||||
from utils.obsidian_service import ObsidianService # noqa: E402
|
||||
|
||||
KIND_DATA_KEYS = {
|
||||
"mealie.recipe": {
|
||||
"slug",
|
||||
"name",
|
||||
"description",
|
||||
"servings",
|
||||
"prep_time",
|
||||
"cook_time",
|
||||
"total_time",
|
||||
"ingredients",
|
||||
"instructions",
|
||||
"tags",
|
||||
},
|
||||
"mealie.meal_plan": {"start_date", "end_date", "days"},
|
||||
"mealie.shopping_lists": {"lists"},
|
||||
"obsidian.note": {"path", "title", "markdown", "tags", "frontmatter"},
|
||||
"search.documents": {"query", "source", "results"},
|
||||
}
|
||||
|
||||
|
||||
async def call(tool, args=None) -> ToolMessage:
|
||||
"""Invoke a tool the way the agent does: with a ToolCall dict."""
|
||||
msg = await tool.ainvoke(
|
||||
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
|
||||
)
|
||||
assert isinstance(msg, ToolMessage)
|
||||
return msg
|
||||
|
||||
|
||||
def assert_artifact(artifact, kind):
|
||||
assert artifact is not None
|
||||
assert artifact["kind"] == kind
|
||||
assert isinstance(artifact["title"], str) and artifact["title"]
|
||||
assert set(artifact["data"]) == KIND_DATA_KEYS[kind]
|
||||
json.dumps(artifact)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mealie(monkeypatch):
|
||||
service = MagicMock()
|
||||
monkeypatch.setattr(agents, "mealie_enabled", True)
|
||||
monkeypatch.setattr(agents, "mealie_service", service, raising=False)
|
||||
monkeypatch.setenv("MEALIE_BASE_URL", "http://mealie.local:9000/")
|
||||
monkeypatch.delenv("MEALIE_GROUP_SLUG", raising=False)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obsidian(monkeypatch, tmp_path):
|
||||
(tmp_path / "seed.md").write_text("# seed")
|
||||
monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.delenv("OBSIDIAN_VAULT_NAME", raising=False)
|
||||
service = ObsidianService()
|
||||
monkeypatch.setattr(agents, "obsidian_enabled", True)
|
||||
monkeypatch.setattr(agents, "obsidian_service", service, raising=False)
|
||||
return service
|
||||
|
||||
|
||||
# --- Mealie -----------------------------------------------------------------
|
||||
|
||||
RECIPE = {
|
||||
"name": "Chicken Tikka Masala",
|
||||
"slug": "chicken-tikka-masala",
|
||||
"description": "Creamy and spiced.",
|
||||
"ingredients": [{"display": "1 lb chicken"}, {"display": "1 cup yogurt"}],
|
||||
"instructions": [
|
||||
{"title": "Marinate", "text": "Mix chicken and yogurt."},
|
||||
{"title": "", "text": "Cook it."},
|
||||
],
|
||||
"prep_time": "20 minutes",
|
||||
"cook_time": "30 minutes",
|
||||
"total_time": "",
|
||||
"servings": "4",
|
||||
"tags": ["Indian", "Dinner"],
|
||||
}
|
||||
|
||||
RECIPE_TEXT = """**Chicken Tikka Masala**
|
||||
Creamy and spiced.
|
||||
|
||||
Servings: 4
|
||||
Prep: 20 minutes | Cook: 30 minutes
|
||||
|
||||
Ingredients:
|
||||
- 1 lb chicken
|
||||
- 1 cup yogurt
|
||||
|
||||
Instructions:
|
||||
1. Mix chicken and yogurt.
|
||||
2. Cook it.
|
||||
|
||||
Tags: Indian, Dinner"""
|
||||
|
||||
|
||||
async def test_mealie_get_recipe(mealie):
|
||||
mealie.get_recipe = AsyncMock(return_value=RECIPE)
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
|
||||
|
||||
assert msg.content == RECIPE_TEXT
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.recipe")
|
||||
assert art["title"] == "Chicken Tikka Masala"
|
||||
assert art["data"]["ingredients"] == ["1 lb chicken", "1 cup yogurt"]
|
||||
assert art["data"]["instructions"] == [
|
||||
{"title": "Marinate", "text": "Mix chicken and yogurt."},
|
||||
{"text": "Cook it."},
|
||||
]
|
||||
assert art["source_ref"] == {
|
||||
"system": "mealie",
|
||||
"id": "chicken-tikka-masala",
|
||||
"url": "http://mealie.local:9000/g/home/r/chicken-tikka-masala",
|
||||
}
|
||||
|
||||
|
||||
async def test_mealie_recipe_url_uses_group_slug(mealie, monkeypatch):
|
||||
monkeypatch.setenv("MEALIE_GROUP_SLUG", "family")
|
||||
mealie.get_recipe = AsyncMock(return_value=RECIPE)
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
|
||||
|
||||
assert msg.artifact["source_ref"]["url"].endswith(
|
||||
"/g/family/r/chicken-tikka-masala"
|
||||
)
|
||||
|
||||
|
||||
async def test_mealie_get_recipe_error(mealie):
|
||||
mealie.get_recipe = AsyncMock(side_effect=Exception("404"))
|
||||
|
||||
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "nope"})
|
||||
|
||||
assert msg.content == "Error fetching recipe: 404"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
MEAL_PLANS = {
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-19",
|
||||
"total_plans": 3,
|
||||
"plans_by_date": {
|
||||
"2026-10-13": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "",
|
||||
}
|
||||
],
|
||||
"2026-10-12": [
|
||||
{
|
||||
"entry_type": "lunch",
|
||||
"title": "Leftovers",
|
||||
"recipe_name": "",
|
||||
"recipe_slug": "",
|
||||
"note": "from Sunday",
|
||||
},
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Chicken Tikka Masala",
|
||||
"recipe_slug": "chicken-tikka-masala",
|
||||
"note": "",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
MEAL_PLANS_TEXT = """Meal plans from 2026-10-12 to 2026-10-19 (3 meals):
|
||||
|
||||
2026-10-12:
|
||||
- Lunch: Leftovers
|
||||
Note: from Sunday
|
||||
- Dinner: Chicken Tikka Masala
|
||||
|
||||
2026-10-13:
|
||||
- Dinner: Tacos"""
|
||||
|
||||
|
||||
async def test_mealie_meal_plans(mealie):
|
||||
mealie.get_meal_plans = AsyncMock(return_value=MEAL_PLANS)
|
||||
|
||||
msg = await call(agents.mealie_meal_plans, {})
|
||||
|
||||
assert msg.content == MEAL_PLANS_TEXT
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.meal_plan")
|
||||
assert art["title"] == "Meal plan · Oct 12–19"
|
||||
assert [d["date"] for d in art["data"]["days"]] == ["2026-10-12", "2026-10-13"]
|
||||
assert art["data"]["days"][0]["meals"] == [
|
||||
{"entry_type": "lunch", "name": "Leftovers", "note": "from Sunday"},
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"name": "Chicken Tikka Masala",
|
||||
"recipe_slug": "chicken-tikka-masala",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_mealie_meal_plans_empty(mealie):
|
||||
mealie.get_meal_plans = AsyncMock(
|
||||
return_value={
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-19",
|
||||
"total_plans": 0,
|
||||
"plans_by_date": {},
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_meal_plans, {})
|
||||
|
||||
assert msg.content == "No meal plans found from 2026-10-12 to 2026-10-19."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_mealie_todays_meals(mealie):
|
||||
mealie.get_todays_meals = AsyncMock(
|
||||
return_value={
|
||||
"today": "2026-10-12",
|
||||
"total_meals": 1,
|
||||
"meals": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"title": "",
|
||||
"recipe_name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "extra salsa",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_todays_meals)
|
||||
|
||||
assert msg.content == (
|
||||
"Meals planned for today (2026-10-12):\n- Dinner: Tacos\n Note: extra salsa"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.meal_plan")
|
||||
assert art["title"] == "Today's meals · Oct 12"
|
||||
assert art["data"] == {
|
||||
"start_date": "2026-10-12",
|
||||
"end_date": "2026-10-12",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"meals": [
|
||||
{
|
||||
"entry_type": "dinner",
|
||||
"name": "Tacos",
|
||||
"recipe_slug": "tacos",
|
||||
"note": "extra salsa",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_mealie_todays_meals_empty(mealie):
|
||||
mealie.get_todays_meals = AsyncMock(
|
||||
return_value={"today": "2026-10-12", "total_meals": 0, "meals": []}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_todays_meals)
|
||||
|
||||
assert msg.content == "No meals planned for today (2026-10-12)."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_mealie_shopping_lists(mealie):
|
||||
mealie.get_shopping_lists = AsyncMock(
|
||||
return_value={
|
||||
"total_lists": 1,
|
||||
"total_unchecked_items": 2,
|
||||
"shopping_lists": [
|
||||
{
|
||||
"name": "Groceries",
|
||||
"unchecked_items": [
|
||||
{"name": "Milk", "quantity": 1, "note": "", "food": None},
|
||||
{"name": "Eggs", "quantity": 12.0, "note": "", "food": None},
|
||||
],
|
||||
"checked_items": [
|
||||
{"name": "Bread", "quantity": 1, "note": "", "food": None}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_shopping_lists)
|
||||
|
||||
assert msg.content == (
|
||||
"Shopping lists (2 items needed):\n"
|
||||
"\n**Groceries** (2 items needed)\n"
|
||||
" - [ ] Milk\n"
|
||||
" - [ ] Eggs x12.0\n"
|
||||
" (1 items already checked off)"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "mealie.shopping_lists")
|
||||
assert art["data"]["lists"] == [
|
||||
{
|
||||
"name": "Groceries",
|
||||
"items": [
|
||||
{"name": "Milk", "quantity": 1, "checked": False},
|
||||
{"name": "Eggs", "quantity": 12.0, "checked": False},
|
||||
{"name": "Bread", "quantity": 1, "checked": True},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_mealie_shopping_lists_empty(mealie):
|
||||
mealie.get_shopping_lists = AsyncMock(
|
||||
return_value={
|
||||
"total_lists": 0,
|
||||
"total_unchecked_items": 0,
|
||||
"shopping_lists": [],
|
||||
}
|
||||
)
|
||||
|
||||
msg = await call(agents.mealie_shopping_lists)
|
||||
|
||||
assert msg.content == "No shopping lists found."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool,args",
|
||||
[
|
||||
(agents.mealie_get_recipe, {"recipe_slug": "x"}),
|
||||
(agents.mealie_meal_plans, {}),
|
||||
(agents.mealie_todays_meals, {}),
|
||||
(agents.mealie_shopping_lists, {}),
|
||||
],
|
||||
)
|
||||
async def test_mealie_not_configured(monkeypatch, tool, args):
|
||||
monkeypatch.setattr(agents, "mealie_enabled", False)
|
||||
|
||||
msg = await call(tool, args)
|
||||
|
||||
assert msg.content == (
|
||||
"Mealie integration is not configured. Please set MEALIE_BASE_URL and "
|
||||
"MEALIE_API_TOKEN environment variables."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- Obsidian ---------------------------------------------------------------
|
||||
|
||||
NOTE = """---
|
||||
title: Vet visit
|
||||
date: 2026-10-01
|
||||
tags: [simba]
|
||||
---
|
||||
Simba saw the [[Dr Smith|vet]] today. #simba #health #simba
|
||||
"""
|
||||
|
||||
|
||||
async def test_obsidian_read_note(obsidian, monkeypatch):
|
||||
monkeypatch.setenv("OBSIDIAN_VAULT_NAME", "Home Vault")
|
||||
os.makedirs(os.path.join(obsidian.vault_path, "notes"))
|
||||
with open(os.path.join(obsidian.vault_path, "notes", "vet visit.md"), "w") as f:
|
||||
f.write(NOTE)
|
||||
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "notes/vet visit.md"})
|
||||
|
||||
assert msg.content == (
|
||||
"File: notes/vet visit.md\n\n"
|
||||
"Frontmatter:\n{'title': 'Vet visit', 'date': datetime.date(2026, 10, 1), "
|
||||
"'tags': ['simba']}\n\n"
|
||||
"Content:\nSimba saw the vet today. #simba #health #simba\n\n"
|
||||
"Tags: simba, health, simba\n"
|
||||
"Contains 1 wikilinks and 0 embeds"
|
||||
)
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "obsidian.note")
|
||||
assert art["title"] == "Vet visit"
|
||||
assert art["data"]["frontmatter"] == {
|
||||
"title": "Vet visit",
|
||||
"date": "2026-10-01",
|
||||
"tags": ["simba"],
|
||||
}
|
||||
assert art["data"]["tags"] == ["simba", "health"]
|
||||
assert art["data"]["markdown"] == "Simba saw the vet today. #simba #health #simba"
|
||||
assert art["source_ref"] == {
|
||||
"system": "obsidian",
|
||||
"path": "notes/vet visit.md",
|
||||
"url": "obsidian://open?vault=Home%20Vault&file=notes%2Fvet%20visit.md",
|
||||
}
|
||||
|
||||
|
||||
async def test_obsidian_read_note_caps_markdown_and_omits_url(obsidian):
|
||||
with open(os.path.join(obsidian.vault_path, "big.md"), "w") as f:
|
||||
f.write("x" * (NOTE_MAX_CHARS + 500))
|
||||
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "big.md"})
|
||||
|
||||
art = msg.artifact
|
||||
assert art["title"] == "big"
|
||||
assert len(art["data"]["markdown"]) <= NOTE_MAX_CHARS
|
||||
assert art["source_ref"] == {"system": "obsidian", "path": "big.md"}
|
||||
|
||||
|
||||
async def test_obsidian_read_note_missing(obsidian):
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "nope.md"})
|
||||
|
||||
assert msg.content == (
|
||||
"Note not found at 'nope.md'. Please check the path is correct."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_journal_get_today(obsidian):
|
||||
path = obsidian.get_daily_note_path()
|
||||
full = os.path.join(obsidian.vault_path, path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
raw = "## Tasks\n- [ ] Feed Simba\n"
|
||||
with open(full, "w") as f:
|
||||
f.write(raw)
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
|
||||
assert msg.content == f"Daily note for {today}:\n\n{raw}"
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "obsidian.note")
|
||||
assert art["title"].startswith("Daily note · ")
|
||||
assert art["data"]["path"] == path
|
||||
assert art["data"]["markdown"] == "## Tasks\n- [ ] Feed Simba"
|
||||
|
||||
|
||||
async def test_journal_get_today_missing(obsidian):
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
|
||||
assert msg.content == (
|
||||
f"No daily note found for {today}. Use journal_add_task to create one."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_obsidian_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(agents, "obsidian_enabled", False)
|
||||
|
||||
for tool, args in [
|
||||
(agents.obsidian_read_note, {"relative_path": "a.md"}),
|
||||
(agents.obsidian_search_notes, {"query": "a"}),
|
||||
]:
|
||||
msg = await call(tool, args)
|
||||
assert msg.content == (
|
||||
"Obsidian integration is not configured. Please set "
|
||||
"OBSIDIAN_VAULT_PATH environment variable."
|
||||
)
|
||||
assert msg.artifact is None
|
||||
|
||||
msg = await call(agents.journal_get_today)
|
||||
assert msg.content == "Obsidian integration is not configured."
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
# --- Search -----------------------------------------------------------------
|
||||
|
||||
PAPERLESS_DOCS = [
|
||||
Document(
|
||||
page_content="Rabies vaccine\nadministered. " + "y" * 600,
|
||||
metadata={
|
||||
"source": "paperless",
|
||||
"created_date": 1760227200.0,
|
||||
"filename": "vet_invoice.pdf",
|
||||
"document_type": "Invoice",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_simba_search(monkeypatch):
|
||||
query_mock = AsyncMock(return_value=("Source: {...}\nContent: x", PAPERLESS_DOCS))
|
||||
monkeypatch.setattr(agents, "query_vector_store", query_mock)
|
||||
|
||||
msg = await call(agents.simba_search, {"query": "vet records"})
|
||||
|
||||
assert msg.content == "Source: {...}\nContent: x"
|
||||
query_mock.assert_awaited_once_with(query="vet records", source="paperless")
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "search.documents")
|
||||
assert art["title"] == "Search: vet records"
|
||||
assert art["data"]["source"] == "paperless"
|
||||
(result,) = art["data"]["results"]
|
||||
assert result["title"] == "vet_invoice"
|
||||
assert result["filename"] == "vet_invoice.pdf"
|
||||
assert result["document_type"] == "Invoice"
|
||||
assert len(result["date"]) == 10
|
||||
assert result["snippet"].startswith("Rabies vaccine administered. ")
|
||||
assert len(result["snippet"]) <= SNIPPET_MAX_CHARS
|
||||
|
||||
|
||||
async def test_simba_search_empty(monkeypatch):
|
||||
monkeypatch.setattr(agents, "query_vector_store", AsyncMock(return_value=("", [])))
|
||||
|
||||
msg = await call(agents.simba_search, {"query": "nothing"})
|
||||
|
||||
assert msg.content == ""
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
async def test_obsidian_search_notes(obsidian, monkeypatch):
|
||||
docs = [
|
||||
Document(
|
||||
page_content="Simba's vet is Dr Smith.",
|
||||
metadata={
|
||||
"source": "obsidian",
|
||||
"filepath": os.path.join(obsidian.vault_path, "pets", "simba.md"),
|
||||
"tags": ["simba"],
|
||||
"created_at": "2026-01-02T10:00:00",
|
||||
},
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
agents, "query_vector_store", AsyncMock(return_value=("serialized", docs))
|
||||
)
|
||||
|
||||
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
|
||||
|
||||
assert msg.content == "serialized"
|
||||
art = msg.artifact
|
||||
assert_artifact(art, "search.documents")
|
||||
assert art["data"]["results"] == [
|
||||
{
|
||||
"snippet": "Simba's vet is Dr Smith.",
|
||||
"title": "simba",
|
||||
"path": "pets/simba.md",
|
||||
"date": "2026-01-02",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_obsidian_search_notes_error(obsidian, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
agents, "query_vector_store", AsyncMock(side_effect=Exception("db down"))
|
||||
)
|
||||
|
||||
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
|
||||
|
||||
assert msg.content == "Error searching Obsidian notes: db down"
|
||||
assert msg.artifact is None
|
||||
|
||||
|
||||
def test_search_documents_artifact_empty():
|
||||
assert search_documents_artifact("q", "paperless", []) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end,expected",
|
||||
[
|
||||
("2026-10-12", "2026-10-19", "Oct 12–19"),
|
||||
("2026-10-28", "2026-11-03", "Oct 28–Nov 3"),
|
||||
("2026-12-28", "2027-01-03", "Dec 28, 2026–Jan 3, 2027"),
|
||||
("2026-10-12", "2026-10-12", "Oct 12"),
|
||||
("bad", "2026-10-12", "bad–2026-10-12"),
|
||||
],
|
||||
)
|
||||
def test_format_date_range(start, end, expected):
|
||||
assert format_date_range(start, end) == expected
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user