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) <noreply@anthropic.com>
41 KiB
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_usagetable to exist.
File Structure
Backend
package.json— add@anthropic-ai/sdk.src/config/env.ts— addanthropic.apiKey.prisma/schema.prisma—ai_usagemodel +ai_monthly_budget_usdoncompany_settings.prisma/migrations/20260608120000_add_ai_assistant/migration.sql— table + column +ai.usepermission.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):
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):
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):
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:
-- 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
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:
npx prisma migrate deploy # applies the hand-written add_ai_assistant migration npx prisma generateThen the user restarts the dev server. (
migrate deployapplies pending migrations without the dev-mode shadow-DB diff.) Tests in Tasks 2–3 hit the now-presentai_usagetable.
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:
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:
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<string, { input: number; output: number }> = {
"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<void> {
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<number> {
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<number> {
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<void> {
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<ExtractedInvoice> {
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
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:
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:
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:
describe("HTTP /api/admin/ai", () => {
let app: Awaited<ReturnType<typeof buildAiApp>>;
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:
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<void> {
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:
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
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:
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<AiUsage>("/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.
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<ChatTurn[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [review, setReview] = useState<ReviewInvoice[]>([]);
const fileRef = useRef<HTMLInputElement>(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 (
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1,
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
AI asistent
</Typography>
{usage && (
<Typography variant="caption" color="text.secondary">
Rozpočet: ${usage.month_spend_usd.toFixed(2)} / $
{usage.budget_usd.toFixed(2)}
</Typography>
)}
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1,
maxHeight: 280,
overflowY: "auto",
mb: 1,
}}
>
{turns.map((t, i) => (
<Box
key={i}
sx={{
alignSelf: t.role === "user" ? "flex-end" : "flex-start",
maxWidth: "85%",
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: t.role === "user" ? "primary.main" : "action.hover",
color: t.role === "user" ? "common.white" : "text.primary",
}}
>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{t.content}
</Typography>
</Box>
))}
{review.map((inv, idx) => (
<Card key={`rev-${idx}`} variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{inv.file_name}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 1,
mt: 0.5,
}}
>
<TextField
label="Dodavatel"
value={inv.supplier_name}
onChange={(e) => patch(idx, "supplier_name", e.target.value)}
/>
<TextField
label="Číslo faktury"
value={inv.invoice_number ?? ""}
onChange={(e) => patch(idx, "invoice_number", e.target.value)}
/>
<TextField
label="Částka"
value={String(inv.amount)}
onChange={(e) => patch(idx, "amount", e.target.value)}
/>
<TextField
label="Měna"
value={inv.currency}
onChange={(e) => patch(idx, "currency", e.target.value)}
/>
<TextField
label="Sazba DPH %"
value={String(inv.vat_rate)}
onChange={(e) => patch(idx, "vat_rate", e.target.value)}
/>
<TextField
label="Datum vystavení"
value={inv.issue_date ?? ""}
onChange={(e) => patch(idx, "issue_date", e.target.value)}
/>
</Box>
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Button onClick={() => saveInvoice(inv, idx)} disabled={busy}>
Uložit
</Button>
<Button
variant="outlined"
color="inherit"
onClick={() => setReview((r) => r.filter((_, i) => i !== idx))}
disabled={busy}
>
Zahodit
</Button>
</Box>
</Card>
))}
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<input
ref={fileRef}
type="file"
accept="application/pdf,image/*"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
<Button
variant="outlined"
color="inherit"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
Přiložit fakturu
</Button>
<TextField
fullWidth
placeholder="Napište zprávu…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendChat();
}
}}
/>
<Button onClick={sendChat} disabled={busy || !input.trim()}>
Odeslat
</Button>
</Box>
</Card>
);
}
- Step 3: Render it on the dashboard
In src/admin/pages/Dashboard.tsx: import the widget, and render it directly under the welcome <Typography variant="h4"> (around line 232), gated on admin / ai.use.
import DashAssistant from "../components/dashboard/DashAssistant";
and right after the welcome heading block:
{
hasPermission("ai.use") && <DashAssistant />;
}
(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
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
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
npx tsc -b --noEmit
npx vitest run
npm run build
- Step 3: Merge to master + commit + tag
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
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=<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)
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
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,documentcontent block,res.usage) are from the Anthropic TypeScript SDK — if a binding differs from what's shown, check the installed@anthropic-ai/sdktypes rather than guessing.