From bcf63d00d117386e90aeb3fd9d688718e549c0f1 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 14:36:24 +0200 Subject: [PATCH] feat(ai): spend tracker + AI service (chat, extractInvoice) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/ai.test.ts | 93 +++++++++++++++ src/services/ai.service.ts | 227 +++++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/__tests__/ai.test.ts create mode 100644 src/services/ai.service.ts diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts new file mode 100644 index 0000000..8208aa2 --- /dev/null +++ b/src/__tests__/ai.test.ts @@ -0,0 +1,93 @@ +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"); + }); +}); diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts new file mode 100644 index 0000000..78c7b28 --- /dev/null +++ b/src/services/ai.service.ts @@ -0,0 +1,227 @@ +import Anthropic from "@anthropic-ai/sdk"; +import prisma from "../config/database"; +import { config } from "../config/env"; + +/** The single model this assistant uses (Phase 1). */ +export const AI_MODEL = "claude-sonnet-4-6"; + +/** Per-token USD pricing. Sonnet 4.6 = $3 / $15 per 1M (input / output). */ +const PRICING: Record = { + "claude-sonnet-4-6": { input: 3 / 1_000_000, output: 15 / 1_000_000 }, +}; + +const DEFAULT_BUDGET_USD = 50; + +export function isConfigured(): boolean { + return !!config.anthropic.apiKey; +} + +/** Lazily build the SDK client; throws a typed result upstream if unconfigured. */ +function client(): Anthropic { + return new Anthropic({ apiKey: config.anthropic.apiKey }); +} + +export function computeCostUsd( + model: string, + inputTokens: number, + outputTokens: number, +): number { + const p = PRICING[model] ?? PRICING[AI_MODEL]; + return inputTokens * p.input + outputTokens * p.output; +} + +export async function recordUsage(args: { + userId: number | null; + kind: string; + model: string; + inputTokens: number; + outputTokens: number; +}): Promise { + await prisma.ai_usage.create({ + data: { + user_id: args.userId, + kind: args.kind, + model: args.model, + input_tokens: args.inputTokens, + output_tokens: args.outputTokens, + cost_usd: computeCostUsd(args.model, args.inputTokens, args.outputTokens), + }, + }); +} + +// Budget window edge uses the UTC month boundary (ai_usage.created_at is a UTC +// @db.Timestamp). At month turnover this is offset from Prague local time by the +// UTC offset for ~1-2h — acceptable for a soft monthly budget, and stable. +function startOfMonthUtc(): Date { + const d = new Date(); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); +} + +export async function getMonthSpendUsd(): Promise { + const agg = await prisma.ai_usage.aggregate({ + _sum: { cost_usd: true }, + where: { created_at: { gte: startOfMonthUtc() } }, + }); + return Number(agg._sum.cost_usd ?? 0); +} + +export async function getBudgetUsd(): Promise { + const settings = await prisma.company_settings.findFirst({ + select: { ai_monthly_budget_usd: true }, + }); + const v = settings?.ai_monthly_budget_usd; + return v == null ? DEFAULT_BUDGET_USD : Number(v); +} + +export async function setBudgetUsd(value: number): Promise { + const existing = await prisma.company_settings.findFirst({ + select: { id: true }, + }); + if (existing) { + await prisma.company_settings.update({ + where: { id: existing.id }, + data: { ai_monthly_budget_usd: value }, + }); + } else { + await prisma.company_settings.create({ + data: { company_name: "", ai_monthly_budget_usd: value }, + }); + } +} + +/** Returns { error, status: 402 } when this month's spend has reached the budget. */ +export async function assertBudgetAvailable(): Promise<{ + error: string; + status: number; +} | null> { + const [spend, budget] = await Promise.all([ + getMonthSpendUsd(), + getBudgetUsd(), + ]); + if (spend >= budget) { + return { error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }; + } + return null; +} + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +const SYSTEM_PROMPT = + "Jsi asistent v interním firemním systému (česká firma). Odpovídej česky, stručně a věcně. " + + "Nemáš přístup k datům systému; pomáháš s obecnými dotazy a se čtením přiložených faktur."; + +/** Plain chat turn. Records usage. Caller must check the budget first. */ +export async function chat( + messages: ChatMessage[], + userId: number | null, +): Promise<{ reply: string }> { + const res = await client().messages.create({ + model: AI_MODEL, + max_tokens: 2048, + system: SYSTEM_PROMPT, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + }); + // Best-effort usage logging — a ledger-write blip must not fail the user's + // call or vanish silently (CLAUDE.md: never swallow non-fatal failures). + try { + await recordUsage({ + userId, + kind: "chat", + model: AI_MODEL, + inputTokens: res.usage.input_tokens, + outputTokens: res.usage.output_tokens, + }); + } catch (e) { + console.error("[ai.service] recordUsage failed (chat)", e); + } + 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; +} + +// JSON schema for the structured extraction. Typed as the SDK's mutable +// index-signature shape (`Record` leaves), NOT `as const` — +// a deeply-readonly literal won't assign to JSONOutputFormat.schema. +const INVOICE_SCHEMA: Record = { + 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, +}; + +/** Vision-extract the received-invoice fields from a PDF. Records usage. */ +export async function extractInvoice( + pdfBuffer: Buffer, + userId: number | null, +): Promise { + const res = await client().messages.create({ + model: AI_MODEL, + max_tokens: 1024, + output_config: { + format: { type: "json_schema", schema: INVOICE_SCHEMA }, + }, + messages: [ + { + role: "user", + content: [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: pdfBuffer.toString("base64"), + }, + }, + { + type: "text", + text: + "Vyčti z této přijaté faktury pole dodavatele, číslo faktury, částku (základ bez DPH), " + + "měnu (ISO kód), sazbu DPH v procentech, datum vystavení a splatnosti (YYYY-MM-DD) a krátký popis. " + + "Pokud pole chybí, vrať null.", + }, + ], + }, + ], + }); + try { + await recordUsage({ + userId, + kind: "extract", + model: AI_MODEL, + inputTokens: res.usage.input_tokens, + outputTokens: res.usage.output_tokens, + }); + } catch (e) { + console.error("[ai.service] recordUsage failed (extract)", e); + } + const text = res.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join(""); + return JSON.parse(text) as ExtractedInvoice; +}