Files
app/docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
BOHA 3623b441ce docs(ai): implementation plan for AI assistant Phase 1 (v2.1.0)
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>
2026-06-08 14:19:06 +02:00

1275 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 23 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 23 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<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**
```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<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`:
```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:
```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<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.
```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<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`.
```tsx
import DashAssistant from "../components/dashboard/DashAssistant";
```
and right after the welcome heading block:
```tsx
{
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**
```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=<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 67).
- **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.