feat(ai): spend tracker + AI service (chat, extractInvoice)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
93
src/__tests__/ai.test.ts
Normal file
93
src/__tests__/ai.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
227
src/services/ai.service.ts
Normal file
227
src/services/ai.service.ts
Normal file
@@ -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<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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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<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 })),
|
||||
});
|
||||
// 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<string, unknown>` leaves), NOT `as const` —
|
||||
// a deeply-readonly literal won't assign to JSONOutputFormat.schema.
|
||||
const INVOICE_SCHEMA: Record<string, unknown> = {
|
||||
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<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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user