From 3623b441ced766cac5dca2db8f4b6ad43ac2cd1c Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 14:19:06 +0200 Subject: [PATCH] docs(ai): implementation plan for AI assistant Phase 1 (v2.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tasks: (1) foundation — SDK dep, env, ai_usage table + ai.use permission migration; (2) spend tracker + ai.service (chat, extractInvoice) with cost/budget tests; (3) /ai routes (usage, budget, chat, extract) with guard tests; (4) DashAssistant dashboard widget; (5) v2.1.0 release. Anthropic call stubbed in tests; migration apply is controller-coordinated (dev server stopped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-ai-assistant-phase1.md | 1274 +++++++++++++++++ 1 file changed, 1274 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md diff --git a/docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md b/docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md new file mode 100644 index 0000000..208abeb --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md @@ -0,0 +1,1274 @@ +# AI Assistant Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an admin-only Claude chat widget on the dashboard that can read attached received-invoice PDFs and import them into Přijaté faktury (extract → confirm → save), all bounded by a $50/month budget cap. + +**Architecture:** A new `@anthropic-ai/sdk`-backed `ai.service` (model: Claude Sonnet 4.6) exposes `chat()` and `extractInvoice()`, records every call's token cost into a new `ai_usage` table, and is gated by a pre-call budget guard reading `company_settings.ai_monthly_budget_usd`. New `/api/admin/ai/*` routes (guarded by a new `ai.use` permission) drive a `DashAssistant` dashboard widget; invoice saves reuse the existing `POST /received-invoices` endpoint. No system-data tools (Phase 2). + +**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, `@anthropic-ai/sdk`, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB; the Anthropic call is stubbed). Spec: `docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md`. + +**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. Frontend (Task 4) gates on `tsc -b` + `build`. + +> **⚠️ Migration coordination:** Task 1 writes a migration; applying it needs the dev server stopped. The controller applies it (between Task 1 and Task 2) after asking the user to stop the dev server — see the "MIGRATION APPLY" note. Tests in Tasks 2–3 require the `ai_usage` table to exist. + +--- + +## File Structure + +**Backend** + +- `package.json` — add `@anthropic-ai/sdk`. +- `src/config/env.ts` — add `anthropic.apiKey`. +- `prisma/schema.prisma` — `ai_usage` model + `ai_monthly_budget_usd` on `company_settings`. +- `prisma/migrations/20260608120000_add_ai_assistant/migration.sql` — table + column + `ai.use` permission. +- `src/services/ai.service.ts` — SDK wrapper, pricing, usage recording, budget guard. +- `src/schemas/ai.schema.ts` — Zod schemas for the chat + budget bodies. +- `src/routes/admin/ai.ts` — `/usage`, `/budget`, `/chat`, `/extract-invoices`. +- `src/server.ts` — register the AI routes. + +**Frontend** + +- `src/admin/lib/queries/ai.ts` — React Query options/mutations. +- `src/admin/components/dashboard/DashAssistant.tsx` — the chat widget. +- `src/admin/pages/Dashboard.tsx` — render the widget under the welcome text. + +**Tests** + +- `src/__tests__/ai.test.ts` — spend tracker, budget guard, route guards. + +--- + +## Task 1: Foundation — dependency, env, schema + migration + +**Files:** + +- Modify: `package.json` (+ `package-lock.json`) +- Modify: `src/config/env.ts` +- Modify: `prisma/schema.prisma` +- Create: `prisma/migrations/20260608120000_add_ai_assistant/migration.sql` + +- [ ] **Step 1: Install the SDK** + +Run: `npm install @anthropic-ai/sdk` +Expected: `package.json` dependencies gains `@anthropic-ai/sdk`, lockfile updated. + +- [ ] **Step 2: Add the API key to config** + +In `src/config/env.ts`, add an `anthropic` block to the `config` object (next to `nas`): + +```ts + anthropic: { + apiKey: process.env.ANTHROPIC_API_KEY || "", + }, +``` + +- [ ] **Step 3: Add the schema models** + +In `prisma/schema.prisma`, add the `ai_usage` model (place it near the other operational tables): + +```prisma +model ai_usage { + id Int @id @default(autoincrement()) + user_id Int? + kind String @db.VarChar(20) + model String @db.VarChar(50) + input_tokens Int @default(0) + output_tokens Int @default(0) + cost_usd Decimal @default(0) @db.Decimal(10, 6) + created_at DateTime @default(now()) @db.Timestamp(0) + + @@index([created_at], map: "idx_ai_usage_created") +} +``` + +And add this field to the `company_settings` model (anywhere in its field list): + +```prisma + ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2) +``` + +- [ ] **Step 4: Regenerate the Prisma client** + +Run: `npx prisma generate` +Expected: client regenerates; `prisma.ai_usage` and `company_settings.ai_monthly_budget_usd` are now available to TypeScript. (This does NOT touch the database.) + +- [ ] **Step 5: Hand-write the migration** + +Create `prisma/migrations/20260608120000_add_ai_assistant/migration.sql` (the folder name must sort **after** the newest existing migration folder — bump the `20260608120000` timestamp if a later one already exists). Contents: + +```sql +-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission. + +CREATE TABLE `ai_usage` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NULL, + `kind` VARCHAR(20) NOT NULL, + `model` VARCHAR(50) NOT NULL, + `input_tokens` INTEGER NOT NULL DEFAULT 0, + `output_tokens` INTEGER NOT NULL DEFAULT 0, + `cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), + PRIMARY KEY (`id`), + INDEX `idx_ai_usage_created` (`created_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `company_settings` + ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00; + +-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring +-- the warehouse-permissions migration. +INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES + ('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW()); + +INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.id, p.id +FROM `roles` r +CROSS JOIN `permissions` p +WHERE r.name = 'admin' + AND p.name = 'ai.use'; +``` + +- [ ] **Step 6: Gate (compile only — DB not yet migrated)** + +Run: `npx tsc -b --noEmit` (exit 0) and `npm run build` (success). Do NOT run vitest yet — the `ai_usage` table isn't applied to the DB (the controller applies it next). + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json src/config/env.ts prisma/schema.prisma prisma/migrations +git commit -m "feat(ai): add @anthropic-ai/sdk, ai_usage table, ai.use permission" +``` + +--- + +> ### MIGRATION APPLY (controller step — between Task 1 and Task 2) +> +> Before Task 2's tests can run, the migration must be applied to the dev/test database. The controller asks the user to **stop the dev server**, then runs: +> +> ```bash +> npx prisma migrate deploy # applies the hand-written add_ai_assistant migration +> npx prisma generate +> ``` +> +> Then the user restarts the dev server. (`migrate deploy` applies pending migrations without the dev-mode shadow-DB diff.) Tests in Tasks 2–3 hit the now-present `ai_usage` table. + +--- + +## Task 2: Spend tracker + AI service + +**Files:** + +- Create: `src/services/ai.service.ts` +- Test: `src/__tests__/ai.test.ts` + +The cost/budget logic is pure + DB-backed (fully testable). The `chat`/`extractInvoice` SDK calls are thin and not unit-tested against the real API. + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/ai.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import prisma from "../config/database"; +import { + computeCostUsd, + recordUsage, + getMonthSpendUsd, + getBudgetUsd, + assertBudgetAvailable, + isConfigured, +} from "../services/ai.service"; + +const KIND = "test_ai"; // marker so we only clean our own rows + +beforeEach(async () => { + await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); +}); +afterAll(async () => { + await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); +}); + +describe("ai.service cost + budget", () => { + it("computes Sonnet 4.6 cost from tokens", () => { + // 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18 + expect( + computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000), + ).toBeCloseTo(18, 6); + expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo( + 0.0195, + 6, + ); + }); + + it("records usage with the computed cost", async () => { + await recordUsage({ + userId: null, + kind: KIND, + model: "claude-sonnet-4-6", + inputTokens: 4000, + outputTokens: 500, + }); + const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } }); + expect(rows.length).toBe(1); + expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6); + }); + + it("sums only the current month's spend", async () => { + await recordUsage({ + userId: null, + kind: KIND, + model: "claude-sonnet-4-6", + inputTokens: 1_000_000, + outputTokens: 0, + }); // $3 this month + // An old row (last year) must NOT count. + await prisma.ai_usage.create({ + data: { + kind: KIND, + model: "claude-sonnet-4-6", + input_tokens: 1_000_000, + output_tokens: 0, + cost_usd: 3, + created_at: new Date("2000-01-01T00:00:00Z"), + }, + }); + const spend = await getMonthSpendUsd(); + expect(spend).toBeGreaterThanOrEqual(3); + expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded + }); + + it("assertBudgetAvailable blocks at/over budget, allows under", async () => { + const budget = await getBudgetUsd(); + // Under budget → null (allowed) + expect(await assertBudgetAvailable()).toBeNull(); + // Push spend over budget for this month, then it must block with 402. + await prisma.ai_usage.create({ + data: { + kind: KIND, + model: "claude-sonnet-4-6", + input_tokens: 0, + output_tokens: 0, + cost_usd: budget + 1, + created_at: new Date(), + }, + }); + const blocked = await assertBudgetAvailable(); + expect(blocked).not.toBeNull(); + expect(blocked?.status).toBe(402); + }); + + it("isConfigured reflects the API key presence", () => { + expect(typeof isConfigured()).toBe("boolean"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/__tests__/ai.test.ts` +Expected: FAIL — `../services/ai.service` does not export these functions. + +- [ ] **Step 3: Implement `ai.service.ts`** + +Create `src/services/ai.service.ts`: + +```ts +import Anthropic from "@anthropic-ai/sdk"; +import prisma from "../config/database"; +import { config } from "../config/env"; + +/** The single model this assistant uses (Phase 1). */ +export const AI_MODEL = "claude-sonnet-4-6"; + +/** Per-token USD pricing. Sonnet 4.6 = $3 / $15 per 1M (input / output). */ +const PRICING: Record = { + "claude-sonnet-4-6": { input: 3 / 1_000_000, output: 15 / 1_000_000 }, +}; + +const DEFAULT_BUDGET_USD = 50; + +export function isConfigured(): boolean { + return !!config.anthropic.apiKey; +} + +/** Lazily build the SDK client; throws a typed result upstream if unconfigured. */ +function client(): Anthropic { + return new Anthropic({ apiKey: config.anthropic.apiKey }); +} + +export function computeCostUsd( + model: string, + inputTokens: number, + outputTokens: number, +): number { + const p = PRICING[model] ?? PRICING[AI_MODEL]; + return inputTokens * p.input + outputTokens * p.output; +} + +export async function recordUsage(args: { + userId: number | null; + kind: string; + model: string; + inputTokens: number; + outputTokens: number; +}): Promise { + await prisma.ai_usage.create({ + data: { + user_id: args.userId, + kind: args.kind, + model: args.model, + input_tokens: args.inputTokens, + output_tokens: args.outputTokens, + cost_usd: computeCostUsd(args.model, args.inputTokens, args.outputTokens), + }, + }); +} + +function startOfMonthUtc(): Date { + const d = new Date(); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); +} + +export async function getMonthSpendUsd(): Promise { + const agg = await prisma.ai_usage.aggregate({ + _sum: { cost_usd: true }, + where: { created_at: { gte: startOfMonthUtc() } }, + }); + return Number(agg._sum.cost_usd ?? 0); +} + +export async function getBudgetUsd(): Promise { + const settings = await prisma.company_settings.findFirst({ + select: { ai_monthly_budget_usd: true }, + }); + const v = settings?.ai_monthly_budget_usd; + return v == null ? DEFAULT_BUDGET_USD : Number(v); +} + +export async function setBudgetUsd(value: number): Promise { + const existing = await prisma.company_settings.findFirst({ + select: { id: true }, + }); + if (existing) { + await prisma.company_settings.update({ + where: { id: existing.id }, + data: { ai_monthly_budget_usd: value }, + }); + } else { + await prisma.company_settings.create({ + data: { company_name: "", ai_monthly_budget_usd: value }, + }); + } +} + +/** Returns { error, status: 402 } when this month's spend has reached the budget. */ +export async function assertBudgetAvailable(): Promise<{ + error: string; + status: number; +} | null> { + const [spend, budget] = await Promise.all([ + getMonthSpendUsd(), + getBudgetUsd(), + ]); + if (spend >= budget) { + return { error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }; + } + return null; +} + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +const SYSTEM_PROMPT = + "Jsi asistent v interním firemním systému (česká firma). Odpovídej česky, stručně a věcně. " + + "Nemáš přístup k datům systému; pomáháš s obecnými dotazy a se čtením přiložených faktur."; + +/** Plain chat turn. Records usage. Caller must check the budget first. */ +export async function chat( + messages: ChatMessage[], + userId: number | null, +): Promise<{ reply: string }> { + const res = await client().messages.create({ + model: AI_MODEL, + max_tokens: 2048, + system: SYSTEM_PROMPT, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + }); + await recordUsage({ + userId, + kind: "chat", + model: AI_MODEL, + inputTokens: res.usage.input_tokens, + outputTokens: res.usage.output_tokens, + }); + const reply = res.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("\n"); + return { reply }; +} + +export interface ExtractedInvoice { + supplier_name: string; + invoice_number: string | null; + amount: number; + currency: string; + vat_rate: number; + issue_date: string | null; + due_date: string | null; + description: string | null; +} + +const INVOICE_SCHEMA = { + type: "object", + properties: { + supplier_name: { type: "string" }, + invoice_number: { type: ["string", "null"] }, + amount: { type: "number" }, + currency: { type: "string" }, + vat_rate: { type: "number" }, + issue_date: { type: ["string", "null"] }, + due_date: { type: ["string", "null"] }, + description: { type: ["string", "null"] }, + }, + required: ["supplier_name", "amount", "currency", "vat_rate"], + additionalProperties: false, +} as const; + +/** Vision-extract the received-invoice fields from a PDF. Records usage. */ +export async function extractInvoice( + pdfBuffer: Buffer, + userId: number | null, +): Promise { + const res = await client().messages.create({ + model: AI_MODEL, + max_tokens: 1024, + output_config: { format: { type: "json_schema", schema: INVOICE_SCHEMA } }, + messages: [ + { + role: "user", + content: [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: pdfBuffer.toString("base64"), + }, + }, + { + type: "text", + text: + "Vyčti z této přijaté faktury pole dodavatele, číslo faktury, částku (základ bez DPH), " + + "měnu (ISO kód), sazbu DPH v procentech, datum vystavení a splatnosti (YYYY-MM-DD) a krátký popis. " + + "Pokud pole chybí, vrať null.", + }, + ], + }, + ], + }); + await recordUsage({ + userId, + kind: "extract", + model: AI_MODEL, + inputTokens: res.usage.input_tokens, + outputTokens: res.usage.output_tokens, + }); + const text = res.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join(""); + return JSON.parse(text) as ExtractedInvoice; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/__tests__/ai.test.ts` +Expected: PASS (the 5 cost/budget tests). Then `npx vitest run` to confirm the full suite is green. + +- [ ] **Step 5: Gate + commit** + +```bash +npx tsc -b --noEmit +git add src/services/ai.service.ts src/__tests__/ai.test.ts +git commit -m "feat(ai): spend tracker + AI service (chat, extractInvoice)" +``` + +--- + +## Task 3: Routes + +**Files:** + +- Create: `src/schemas/ai.schema.ts` +- Create: `src/routes/admin/ai.ts` +- Modify: `src/server.ts` (register the route) +- Test: `src/__tests__/ai.test.ts` (append HTTP tests) + +- [ ] **Step 1: Write the schemas** + +Create `src/schemas/ai.schema.ts`: + +```ts +import { z } from "zod"; + +export const AiChatSchema = z.object({ + messages: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().min(1).max(8000), + }), + ) + .min(1, "Zpráva je povinná"), +}); + +export const AiBudgetSchema = z.object({ + budget_usd: z + .union([z.number(), z.string()]) + .transform((v) => Number(v)) + .refine((n) => Number.isFinite(n) && n >= 0, "Neplatný rozpočet"), +}); +``` + +- [ ] **Step 2: Write the route HTTP tests (append to `src/__tests__/ai.test.ts`)** + +The test file already builds a Fastify app pattern in `plan.test.ts`; mirror it here. Append a describe block that registers `aiRoutes` and exercises the guards. Add the imports at the top of `ai.test.ts`: + +```ts +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; +import { config as appConfig } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import aiRoutes from "../routes/admin/ai"; +``` + +Append: + +```ts +describe("HTTP /api/admin/ai", () => { + let app: Awaited>; + let adminToken: string; + let noPermToken: string; + let noPermRoleId: number; + let noPermUserId: number; + let savedKey: string; + + async function buildAiApp() { + const a = Fastify({ logger: false }); + await a.register(cookie); + await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + a.addHook("onRequest", securityHeaders); + await a.register(aiRoutes, { prefix: "/api/admin/ai" }); + return a; + } + function token(user: { + id: number; + username: string; + roleName: string | null; + }) { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + appConfig.jwt.secret, + { expiresIn: "15m" }, + ); + } + + beforeAll(async () => { + savedKey = appConfig.anthropic.apiKey; + app = await buildAiApp(); + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("admin not found"); + adminToken = token({ + id: admin.id, + username: admin.username, + roleName: admin.roles?.name ?? null, + }); + const stamp = Date.now(); + const role = await prisma.roles.create({ + data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" }, + }); + noPermRoleId = role.id; + const u = await prisma.users.create({ + data: { + username: `noperm_ai_${stamp}`, + first_name: "No", + last_name: "Perm", + email: `noperm_ai_${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + role_id: role.id, + is_active: true, + }, + }); + noPermUserId = u.id; + noPermToken = token({ + id: u.id, + username: u.username, + roleName: role.name, + }); + }); + afterAll(async () => { + if (app) await app.close(); + (appConfig.anthropic as { apiKey: string }).apiKey = savedKey; + if (noPermUserId) + await prisma.users + .deleteMany({ where: { id: noPermUserId } }) + .catch(() => {}); + if (noPermRoleId) + await prisma.roles + .deleteMany({ where: { id: noPermRoleId } }) + .catch(() => {}); + }); + + it("GET /usage requires ai.use", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/ai/usage", + headers: { Authorization: `Bearer ${noPermToken}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("GET /usage returns spend + budget for an admin", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/ai/usage", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(typeof body.data.budget_usd).toBe("number"); + expect(typeof body.data.month_spend_usd).toBe("number"); + }); + + it("POST /chat returns 503 when AI is not configured", async () => { + (appConfig.anthropic as { apiKey: string }).apiKey = ""; + const res = await app.inject({ + method: "POST", + url: "/api/admin/ai/chat", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { messages: [{ role: "user", content: "ahoj" }] }, + }); + expect(res.statusCode).toBe(503); + (appConfig.anthropic as { apiKey: string }).apiKey = savedKey; + }); +}); +``` + +- [ ] **Step 3: Run the new tests to verify they fail** + +Run: `npx vitest run src/__tests__/ai.test.ts` +Expected: FAIL — `../routes/admin/ai` does not exist. + +- [ ] **Step 4: Implement the routes** + +Create `src/routes/admin/ai.ts`: + +```ts +import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import multipart from "@fastify/multipart"; +import { requirePermission } from "../../middleware/auth"; +import { success, error } from "../../utils/response"; +import { parseBody } from "../../schemas/common"; +import { AiChatSchema, AiBudgetSchema } from "../../schemas/ai.schema"; +import { logAudit } from "../../services/audit"; +import { config } from "../../config/env"; +import { + isConfigured, + assertBudgetAvailable, + getMonthSpendUsd, + getBudgetUsd, + setBudgetUsd, + chat, + extractInvoice, +} from "../../services/ai.service"; + +export default async function aiRoutes(app: FastifyInstance): Promise { + await app.register(multipart, { + limits: { fileSize: config.nas.maxUploadSize }, + }); + + // GET /api/admin/ai/usage — current-month spend + budget + app.get( + "/usage", + { preHandler: requirePermission("ai.use") }, + async (_request: FastifyRequest, reply: FastifyReply) => { + const [month_spend_usd, budget_usd] = await Promise.all([ + getMonthSpendUsd(), + getBudgetUsd(), + ]); + return success(reply, { + configured: isConfigured(), + month_spend_usd: Math.round(month_spend_usd * 1_000_000) / 1_000_000, + budget_usd, + remaining_usd: Math.max(0, budget_usd - month_spend_usd), + }); + }, + ); + + // PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use) + app.put( + "/budget", + { preHandler: requirePermission("ai.use") }, + async (request: FastifyRequest, reply: FastifyReply) => { + const body = parseBody(AiBudgetSchema, request.body); + if ("error" in body) return error(reply, body.error, 400); + await setBudgetUsd(body.data!.budget_usd); + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "company_settings", + description: `Změněn měsíční rozpočet AI na $${body.data!.budget_usd}`, + }); + return success( + reply, + { budget_usd: body.data!.budget_usd }, + 200, + "Rozpočet uložen", + ); + }, + ); + + // POST /api/admin/ai/chat + app.post( + "/chat", + { preHandler: requirePermission("ai.use") }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503); + const body = parseBody(AiChatSchema, request.body); + if ("error" in body) return error(reply, body.error, 400); + const budgetErr = await assertBudgetAvailable(); + if (budgetErr) return error(reply, budgetErr.error, budgetErr.status); + const { reply: text } = await chat( + body.data!.messages, + request.authData!.userId, + ); + const remaining_usd = Math.max( + 0, + (await getBudgetUsd()) - (await getMonthSpendUsd()), + ); + return success(reply, { reply: text, remaining_usd }); + }, + ); + + // POST /api/admin/ai/extract-invoices — multipart PDF files + app.post( + "/extract-invoices", + { preHandler: requirePermission("ai.use") }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503); + const budgetErr = await assertBudgetAvailable(); + if (budgetErr) return error(reply, budgetErr.error, budgetErr.status); + + const parts = request.parts(); + const results: Array<{ + file_name: string; + fields?: unknown; + error?: string; + }> = []; + for await (const part of parts) { + if (part.type !== "file") continue; + const buf = await part.toBuffer(); + try { + const fields = await extractInvoice(buf, request.authData!.userId); + results.push({ file_name: part.filename || "faktura.pdf", fields }); + } catch (e) { + request.log.error(e, "extractInvoice failed"); + results.push({ + file_name: part.filename || "faktura.pdf", + error: "Nepodařilo se přečíst fakturu", + }); + } + // Re-check the budget between files so one batch can't blow far past it. + const over = await assertBudgetAvailable(); + if (over) break; + } + if (results.length === 0) + return error(reply, "Nebyl nahrán žádný soubor", 400); + return success(reply, { invoices: results }); + }, + ); +} +``` + +- [ ] **Step 5: Register the route in `server.ts`** + +In `src/server.ts`, find where the other admin routes are registered (e.g. `app.register(receivedInvoicesRoutes, { prefix: "/api/admin/received-invoices" })`) and add, alongside them: + +```ts +import aiRoutes from "./routes/admin/ai"; +// ... +await app.register(aiRoutes, { prefix: "/api/admin/ai" }); +``` + +(Match the existing import style — some route files are imported at top, some inline. Follow whatever `server.ts` already does for `receivedInvoicesRoutes`.) + +- [ ] **Step 6: Run the tests + gate** + +Run: `npx vitest run` (all pass, including the new AI HTTP tests) and `npx tsc -b --noEmit` (exit 0). + +- [ ] **Step 7: Commit** + +```bash +git add src/schemas/ai.schema.ts src/routes/admin/ai.ts src/server.ts src/__tests__/ai.test.ts +git commit -m "feat(ai): chat / extract-invoices / usage / budget routes" +``` + +--- + +## Task 4: Frontend — dashboard chat widget + +**Files:** + +- Create: `src/admin/lib/queries/ai.ts` +- Create: `src/admin/components/dashboard/DashAssistant.tsx` +- Modify: `src/admin/pages/Dashboard.tsx` + +Frontend-only; gate on `tsc -b` + `build`. + +- [ ] **Step 1: Query helpers** + +Create `src/admin/lib/queries/ai.ts`: + +```ts +import { queryOptions } from "@tanstack/react-query"; +import { jsonQuery } from "../apiAdapter"; + +export interface AiUsage { + configured: boolean; + month_spend_usd: number; + budget_usd: number; + remaining_usd: number; +} + +export interface ExtractedInvoice { + supplier_name: string; + invoice_number: string | null; + amount: number; + currency: string; + vat_rate: number; + issue_date: string | null; + due_date: string | null; + description: string | null; +} + +export const aiUsageOptions = () => + queryOptions({ + queryKey: ["ai", "usage"], + queryFn: () => jsonQuery("/api/admin/ai/usage"), + }); +``` + +- [ ] **Step 2: The widget** + +Create `src/admin/components/dashboard/DashAssistant.tsx`. It holds the conversation in component state, sends chat turns, and on file-attach calls `/extract-invoices` then renders editable review cards that save via the existing `/received-invoices` endpoint. + +```tsx +import { useState, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Card, Button, TextField } from "../../ui"; +import apiFetch from "../../utils/api"; +import { useAlert } from "../../context/AlertContext"; +import { aiUsageOptions, type ExtractedInvoice } from "../../lib/queries/ai"; + +interface ChatTurn { + role: "user" | "assistant"; + content: string; +} +interface ReviewInvoice extends ExtractedInvoice { + file_name: string; + file: File; +} + +export default function DashAssistant() { + const alert = useAlert(); + const qc = useQueryClient(); + const { data: usage } = useQuery(aiUsageOptions()); + const [turns, setTurns] = useState([]); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [review, setReview] = useState([]); + const fileRef = useRef(null); + + // AI is hidden entirely when the backend isn't configured. + if (usage && usage.configured === false) return null; + + const sendChat = async () => { + const text = input.trim(); + if (!text || busy) return; + const next = [...turns, { role: "user" as const, content: text }]; + setTurns(next); + setInput(""); + setBusy(true); + try { + const res = await apiFetch("/api/admin/ai/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: next }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba AI"); + setTurns((t) => [...t, { role: "assistant", content: body.data.reply }]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba AI"); + } finally { + setBusy(false); + } + }; + + const onFiles = async (files: FileList | null) => { + if (!files || files.length === 0 || busy) return; + setBusy(true); + try { + const fd = new FormData(); + Array.from(files).forEach((f) => fd.append("files", f)); + const res = await apiFetch("/api/admin/ai/extract-invoices", { + method: "POST", + body: fd, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur"); + const arr = Array.from(files); + const reviews: ReviewInvoice[] = ( + body.data.invoices as Array<{ + file_name: string; + fields?: ExtractedInvoice; + error?: string; + }> + ) + .map((r, i) => + r.fields + ? { ...r.fields, file_name: r.file_name, file: arr[i] } + : null, + ) + .filter((x): x is ReviewInvoice => x !== null); + setReview((prev) => [...prev, ...reviews]); + setTurns((t) => [ + ...t, + { + role: "assistant", + content: `Načetl jsem ${reviews.length} faktur k potvrzení.`, + }, + ]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba čtení faktur"); + } finally { + setBusy(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const saveInvoice = async (inv: ReviewInvoice, idx: number) => { + setBusy(true); + try { + const fd = new FormData(); + fd.append("files", inv.file, inv.file_name); + fd.append( + "invoices", + JSON.stringify([ + { + supplier_name: inv.supplier_name, + invoice_number: inv.invoice_number ?? undefined, + description: inv.description ?? undefined, + amount: inv.amount, + currency: inv.currency, + vat_rate: inv.vat_rate, + issue_date: inv.issue_date ?? undefined, + due_date: inv.due_date ?? undefined, + }, + ]), + ); + const res = await apiFetch("/api/admin/received-invoices", { + method: "POST", + body: fd, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Uložení selhalo"); + alert.success("Faktura uložena"); + setReview((r) => r.filter((_, i) => i !== idx)); + qc.invalidateQueries({ queryKey: ["received-invoices"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Uložení selhalo"); + } finally { + setBusy(false); + } + }; + + const patch = (idx: number, field: keyof ReviewInvoice, value: string) => + setReview((r) => + r.map((inv, i) => (i === idx ? { ...inv, [field]: value } : inv)), + ); + + return ( + + + + AI asistent + + {usage && ( + + Rozpočet: ${usage.month_spend_usd.toFixed(2)} / $ + {usage.budget_usd.toFixed(2)} + + )} + + + + {turns.map((t, i) => ( + + + {t.content} + + + ))} + {review.map((inv, idx) => ( + + + {inv.file_name} + + + patch(idx, "supplier_name", e.target.value)} + /> + patch(idx, "invoice_number", e.target.value)} + /> + patch(idx, "amount", e.target.value)} + /> + patch(idx, "currency", e.target.value)} + /> + patch(idx, "vat_rate", e.target.value)} + /> + patch(idx, "issue_date", e.target.value)} + /> + + + + + + + ))} + + + + onFiles(e.target.files)} + /> + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendChat(); + } + }} + /> + + + + ); +} +``` + +- [ ] **Step 3: Render it on the dashboard** + +In `src/admin/pages/Dashboard.tsx`: import the widget, and render it directly under the welcome `` (around line 232), gated on admin / `ai.use`. + +```tsx +import DashAssistant from "../components/dashboard/DashAssistant"; +``` + +and right after the welcome heading block: + +```tsx +{ + hasPermission("ai.use") && ; +} +``` + +(`hasPermission` is already destructured from `useAuth()` in this file; the admin role returns true for any permission, so admins see it. The widget self-hides if the backend reports `configured: false`.) + +- [ ] **Step 4: Gate + Chrome check** + +```bash +npx tsc -b --noEmit +npm run build +``` + +Then in Chrome on `/dashboard` (dev server already running — do not start it; `ANTHROPIC_API_KEY` must be set in dev `.env` for live testing): the "AI asistent" card shows under the welcome text for an admin; type a message → reply appears; attach an invoice PDF → review card appears pre-filled → edit → "Uložit" creates a Přijatá faktura; the budget indicator updates. (If no API key is set in dev, the widget is hidden — that's expected.) + +- [ ] **Step 5: Commit** + +```bash +git add src/admin/lib/queries/ai.ts src/admin/components/dashboard/DashAssistant.tsx src/admin/pages/Dashboard.tsx +git commit -m "feat(ai): dashboard chat widget with invoice import + budget indicator" +``` + +--- + +## Task 5: Release v2.1.0 + +**Files:** + +- Modify: `package.json` + +- [ ] **Step 1: Bump version** to `"version": "2.1.0"`. + +- [ ] **Step 2: Full gate** + +```bash +npx tsc -b --noEmit +npx vitest run +npm run build +``` + +- [ ] **Step 3: Merge to master + commit + tag** + +```bash +git checkout master +git merge --ff-only feat/ai-assistant-phase1 +git add package.json +git commit -m "chore(release): v2.1.0 — AI assistant (chat + invoice import + budget)" +git tag -a v2.1.0 -m "v2.1.0 — AI assistant Phase 1" +``` + +(If implementation happened directly on master, skip checkout/merge.) + +- [ ] **Step 4: Push to Gitea** + +```bash +git push origin master +git push origin v2.1.0 +``` + +- [ ] **Step 5: Set the production API key (one-time, before deploy)** + +On the prod server, add `ANTHROPIC_API_KEY=` to `/var/www/app-ts/.env`. Without it the assistant stays hidden (no errors). The key comes from the Anthropic Console. + +- [ ] **Step 6: Build tarball + deploy (REQUIRES explicit user confirmation)** + +```bash +tar -czf app-ts-2.1.0.tar.gz dist dist-client prisma package.json package-lock.json scripts +scp app-ts-2.1.0.tar.gz boha_admin@192.168.50.100:/tmp/ +ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.1.0.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env' +``` + +**This release DOES ship a migration** (`add_ai_assistant`), so `prisma migrate deploy` applies the `ai_usage` table + `ai_monthly_budget_usd` column + `ai.use` permission on prod. + +- [ ] **Step 7: Health check** + +```bash +ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/' # 200 +ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session' # 401 +``` + +Run the second curl a couple of seconds after restart (avoid the cold-start `000`). Confirm pm2 shows `app-ts` v2.1.0 online. + +--- + +## Notes for the implementer + +- **The Anthropic call is never made in tests.** All AI tests exercise the cost/budget/permission/not-configured logic, which short-circuits before the SDK. Do not add a test that calls `chat()`/`extractInvoice()` against the real API. +- **Reuse, don't duplicate:** invoice saves go through the existing `POST /received-invoices` (month/year/vat_amount/status are filled server-side). The AI only extracts. +- **Budget is a guard, not a hard ceiling mid-call:** the check is before each call; a single call can slightly exceed the budget. That's acceptable (calls are cheap). +- **Do not start the dev server** — the user runs it. The migration apply needs it stopped (controller coordinates). +- **Do not deploy to production without explicit confirmation** (Task 5, Steps 6–7). +- **SDK API shapes** (`messages.create`, `output_config.format`, `document` content block, `res.usage`) are from the Anthropic TypeScript SDK — if a binding differs from what's shown, check the installed `@anthropic-ai/sdk` types rather than guessing.