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

41 KiB
Raw Permalink Blame History

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.prismaai_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):

  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 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:

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