# 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.