Admin-only Claude (Sonnet 4.6) chat embedded on the dashboard under the welcome text. Attach received-invoice PDFs → extract (vision + structured output) → confirm/edit → save via the existing received-invoices endpoint. $50/month admin-editable budget enforced via an ai_usage table + pre-call guard. New @anthropic-ai/sdk dep, ANTHROPIC_API_KEY, ai.use permission; one migration. No system-data tools (Phase 2). Ships as v2.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.6 KiB
Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation plan Phase 1 of 2. Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
Goal
Add the app's first AI capability: an admin-only chat assistant embedded on the dashboard where you can talk to Claude and, crucially, attach received-invoice PDFs that the AI reads and imports into Přijaté faktury (extract → you confirm → save). All AI usage is bounded by an admin-editable monthly budget (default $50) that hard-stops calls when exhausted.
Decisions (locked in brainstorming)
- Scope (Phase 1): chat + invoice import + budget cap. No system-data access (the AI cannot query your invoices/projects/attendance — that's Phase 2).
- Placement: a
DashAssistantwidget on the dashboard, at the top under the welcome text — not a separate page, not a floating bubble. - Access: admins only, via a new
ai.usepermission. - Model: Claude Sonnet 4.6 (strong vision, low cost).
- Save flow: extract → you confirm/edit → save (never silent auto-save).
- Budget: $50/month, resets each calendar month, admin-editable in Settings; AI calls are blocked once the month's spend reaches the budget.
- Chat: non-streaming (request → full reply) and in-browser history only (not persisted server-side) for v1 — both are easy later upgrades.
Non-goals / out of scope (Phase 1)
- No system-data query tools / function-calling (Phase 2).
- No server-side conversation persistence, no streaming.
- No new invoice storage path — reuses
POST /received-invoicesverbatim.
Architecture
Greenfield AI integration; the rest of the app is unaffected and works unchanged
if ANTHROPIC_API_KEY is absent (the assistant widget simply doesn't render and
the AI routes return a clear "not configured" error).
- Dependency:
@anthropic-ai/sdk. - Env:
ANTHROPIC_API_KEY(required for the feature). OptionalAI_MONTHLY_BUDGET_USDdefault seed (the live value lives in the DB, below). src/services/ai.service.ts— wraps the SDK, owns the model choice + system prompt + cost accounting. Two operations:chat(messages)→client.messages.create({ model: "claude-sonnet-4-6", … }), non-streaming, returns{ reply: string, usage }.extractInvoice(pdfBuffer, fileName)→client.messages.createwith the PDF as adocumentcontent block +output_config.formatJSON-schema for the received-invoice fields, returns{ fields, usage }.- Both call the spend tracker to record usage and are gated by the budget guard (below) BEFORE the API call.
- Isolated so tests can stub the Anthropic call (no real API in CI).
Spend cap (the $50/month budget)
- New table
ai_usage(migration):id,user_id?,kind("chat" | "extract"),model,input_tokens,output_tokens,cost_usd@db.Decimal(10,6),created_at @db.Timestamp(0), with an index oncreated_at. - Cost map (in
ai.service.ts): model →{ inputPerToken, outputPerToken }. Sonnet 4.6 = $3 / $15 per 1M →0.000003/0.000015per token.cost_usd = in*inP + out*outP. - Budget value: a new
ai_monthly_budget_usd Decimal @default(50)column oncompany_settings(the existing app-config singleton). Admin-editable in Settings. - Budget guard
assertBudgetAvailable(): sumcost_usdfor rows wherecreated_at >= startOfMonth(now); if>= ai_monthly_budget_usd, return{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }. Called at the top of every AI route BEFORE the Claude call. - Usage read:
GET /api/admin/ai/usage→{ month_spend_usd, budget_usd, remaining_usd }for the chat's budget indicator and the Settings page.
Backend routes — src/routes/admin/ai.ts
All guarded by requirePermission("ai.use").
POST /api/admin/ai/chat— body{ messages: {role, content}[] }. Budget guard →ai.service.chat→ log usage →success(reply: string, remaining_usd).POST /api/admin/ai/extract-invoices— multipart PDF files (≤MAX_UPLOAD_SIZE, PDF/image only). Budget guard →extractInvoiceper file → returns the extracted fields per file (NOT saved) + usage. The files are not persisted here.GET /api/admin/ai/usage— current-month spend + budget + remaining.
If ANTHROPIC_API_KEY is unset, the POST routes return
{ error: "AI není nakonfigurováno", status: 503 }.
Invoice import flow (extract → confirm → save)
- Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a text note).
- Frontend posts the files to
POST /ai/extract-invoices; the AI returns the extracted fields for each. - The chat renders an editable review card per invoice (dodavatel, číslo
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
pre-filled with the AI's values, plus a duplicate hint if
invoice_number+supplier_namealready exists. - Admin glances/edits → "Uložit" (per card) or "Uložit vše".
- The frontend submits the original File + the confirmed metadata to the
existing
POST /api/admin/received-invoices(multipart) — same NAS storage, same server-side VAT recompute, same audit. The AI never writes invoices directly. (The PDF is uploaded twice — once to extract, once to save — which is fine for invoice-sized files and avoids any server-side temp-file handling.)
Frontend
src/admin/components/dashboard/DashAssistant.tsx— the chat widget: message list, input box, attach-file button, and a small budget indicator ("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only. Renders the invoice review cards inline (reusing the received-invoice field inputs / kit components).src/admin/pages/Dashboard.tsx— render<DashAssistant />at the top, directly under the welcome text, only when the user is admin / hasai.use(and only when AI is configured — aGET /ai/usage503 hides it gracefully).src/admin/lib/queries/ai.ts— React Query options/mutations for chat, extract, and usage. On a successful invoice save, invalidate["received-invoices"].- Settings — an admin field to set the monthly budget (writes
company_settings.ai_monthly_budget_usd).
Permissions & migration
One Prisma migration:
- Create
ai_usagetable. - Add
ai_monthly_budget_usdtocompany_settings(default 50). - Insert the
ai.usepermission and grant it to the admin role (explicitINSERTs inmigration.sql, per the project's migration policy).
(The admin role bypasses permission checks via roleName === "admin", but the
explicit ai.use permission lets the access be widened later without code changes
and documents intent.)
Security / privacy
- Phase 1 has no tools, so the AI cannot take actions on your data — its only effect is the human-confirmed invoice save. A malicious/auto-generated invoice PDF therefore can't trigger anything (the injection-to-action surface arrives in Phase 2 and is designed there).
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
.env(server-side only), never exposed to the browser. - Standard sanitization already applies on the received-invoice render/PDF paths;
AI-extracted text flows through the same validated
received_invoicescreate.
Testing
Server-side, real test DB, Anthropic call stubbed (no real API in CI):
- Spend tracker:
cost_usdcomputed correctly from tokens for Sonnet 4.6; monthly sum only counts the current month. - Budget guard: under budget → allowed; at/over budget →
402with the Czech message; the guard reads the DB budget value. - Permissions: all
/ai/*routes return403withoutai.use. - Not-configured: with no
ANTHROPIC_API_KEY, POST routes return503. - extract field-mapping: given a stubbed AI response, the route returns the
expected
received_invoicesfield shape (and computes nothing that the existing create doesn't already).
Frontend has no component-test harness → gated by tsc -b + build + a manual
check (the dashboard widget renders for admins, hidden otherwise).
Gates: npx tsc -b --noEmit, npm run build, npx vitest run.
Rollout
Ships as v2.1.0 (notable new capability) via the standard process. Requires a
migration, so the dev server must be stopped for prisma migrate dev (ask first),
and the release runs prisma migrate deploy on prod. ANTHROPIC_API_KEY must be
set in the production .env before the feature works.
Deferred: Phase 2 (system-data access) — captured
A later, separate spec + cycle. The AI gains read-only tools (function calling) to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme fakturovali v květnu?". Key design work for Phase 2:
- A curated set of read tools (search invoices, overdue list, project lookup, attendance summary, …), each scoped to the calling user's permissions so the AI never returns data the user can't see.
- The agentic tool-loop (Claude requests a tool → app executes against Prisma → returns results → Claude continues), with a per-conversation tool-call cap to bound cost (within the same monthly budget).
- Prompt-injection hardening: because the AI will both read untrusted invoice content and hold tools, tools stay read-only, results are treated as data, and any state-changing action remains human-confirmed.