diff --git a/docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md b/docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md new file mode 100644 index 0000000..6cf8f5e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md @@ -0,0 +1,190 @@ +# 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 `DashAssistant` widget 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.use` permission. +- **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-invoices` verbatim. + +--- + +## 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). Optional + `AI_MONTHLY_BUDGET_USD` default 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.create` with the PDF as + a `document` content block + `output_config.format` JSON-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 on + `created_at`. +- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`. + Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd = +in*inP + out*outP`. +- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on + `company_settings` (the existing app-config singleton). Admin-editable in + Settings. +- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where + `created_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 → `extractInvoice` per 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) + +1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a + text note). +2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the + extracted fields for each. +3. 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_name` already exists. +4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**. +5. 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 `` at the top, + directly under the welcome text, **only when** the user is admin / has `ai.use` + (and only when AI is configured — a `GET /ai/usage` 503 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_usage` table. +- Add `ai_monthly_budget_usd` to `company_settings` (default 50). +- Insert the `ai.use` permission and grant it to the **admin** role (explicit + `INSERT`s in `migration.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_invoices` create. + +## Testing + +Server-side, real test DB, **Anthropic call stubbed** (no real API in CI): + +- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6; + monthly sum only counts the current month. +- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech + message; the guard reads the DB budget value. +- **Permissions:** all `/ai/*` routes return `403` without `ai.use`. +- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`. +- **extract field-mapping:** given a stubbed AI response, the route returns the + expected `received_invoices` field 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.