- agentic chat loop (max 6 round-trips, budget re-checked between iterations) over 10 read-only, permission-delegated tools in src/services/ai-tools.ts - persist assistant tool trace in ai_chat_messages.content_json (+ migration) - consulted-tools chips in OdinThread; header now shows month spend only (budget cap hidden from the UI, server-side 402 guard unchanged) - seed: ai.use permission; Settings exposes the ai permission module - docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
import multipart from "@fastify/multipart";
|
|
import { requirePermission } from "../../middleware/auth";
|
|
import { success, error, parseId } from "../../utils/response";
|
|
import { parseBody } from "../../schemas/common";
|
|
import {
|
|
AiChatSchema,
|
|
AiBudgetSchema,
|
|
AiHistoryAppendSchema,
|
|
AiCreateConversationSchema,
|
|
AiRenameConversationSchema,
|
|
} from "../../schemas/ai.schema";
|
|
import { logAudit } from "../../services/audit";
|
|
import { config } from "../../config/env";
|
|
import {
|
|
isConfigured,
|
|
assertBudgetAvailable,
|
|
getMonthSpendUsd,
|
|
getBudgetUsd,
|
|
setBudgetUsd,
|
|
agenticChat,
|
|
extractInvoice,
|
|
listConversations,
|
|
createConversation,
|
|
getConversationMessages,
|
|
appendConversationMessages,
|
|
renameConversation,
|
|
deleteConversation,
|
|
type ExtractedInvoice,
|
|
} from "../../services/ai.service";
|
|
|
|
export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
|
await app.register(multipart, {
|
|
// Cap files per request: the budget re-check already bounds *spend* mid-batch,
|
|
// but this bounds the work (one vision call per file) of a single request.
|
|
limits: { fileSize: config.nas.maxUploadSize, files: 20 },
|
|
});
|
|
|
|
// 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);
|
|
const { budget_usd } = body.data;
|
|
await setBudgetUsd(budget_usd);
|
|
await logAudit({
|
|
request,
|
|
authData: request.authData,
|
|
action: "update",
|
|
entityType: "company_settings",
|
|
description: `Změněn měsíční rozpočet AI na $${budget_usd}`,
|
|
newValues: { ai_monthly_budget_usd: budget_usd },
|
|
});
|
|
return success(reply, { budget_usd }, 200, "Rozpočet uložen");
|
|
},
|
|
);
|
|
|
|
// POST /api/admin/ai/chat — agentic turn with read-only tools (Phase 2a).
|
|
// The tools execute AS this user: the authData context (permissions +
|
|
// admin bypass) is what each tool handler checks — see ai-tools.ts.
|
|
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 auth = request.authData!;
|
|
const { reply: text, toolTrace } = await agenticChat(body.data.messages, {
|
|
userId: auth.userId,
|
|
// AuthData.roleName is nullable; a null role simply never matches the
|
|
// admin bypass and holds only its explicit permissions.
|
|
roleName: auth.roleName ?? "",
|
|
permissions: auth.permissions,
|
|
});
|
|
const [budgetAfter, spendAfter] = await Promise.all([
|
|
getBudgetUsd(),
|
|
getMonthSpendUsd(),
|
|
]);
|
|
const remaining_usd = Math.max(0, budgetAfter - spendAfter);
|
|
return success(reply, {
|
|
reply: text,
|
|
tool_trace: toolTrace,
|
|
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?: ExtractedInvoice;
|
|
error?: string;
|
|
}> = [];
|
|
// True when the loop stops early because the budget was hit mid-batch —
|
|
// surfaced to the client so it knows trailing files were NOT processed.
|
|
let truncated = false;
|
|
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) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
if (results.length === 0)
|
|
return error(reply, "Nebyl nahrán žádný soubor", 400);
|
|
return success(reply, { invoices: results, truncated });
|
|
},
|
|
);
|
|
|
|
// GET /conversations — this user's conversations (stable order)
|
|
app.get(
|
|
"/conversations",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const conversations = await listConversations(request.authData!.userId);
|
|
return success(reply, { conversations });
|
|
},
|
|
);
|
|
|
|
// POST /conversations — create
|
|
app.post(
|
|
"/conversations",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const body = parseBody(AiCreateConversationSchema, request.body);
|
|
if ("error" in body) return error(reply, body.error, 400);
|
|
const conversation = await createConversation(
|
|
request.authData!.userId,
|
|
body.data.title,
|
|
);
|
|
return success(reply, { conversation });
|
|
},
|
|
);
|
|
|
|
// GET /conversations/:id/messages
|
|
app.get(
|
|
"/conversations/:id/messages",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const id = parseId((request.params as { id: string }).id, reply);
|
|
if (id === null) return;
|
|
const result = await getConversationMessages(
|
|
request.authData!.userId,
|
|
id,
|
|
);
|
|
if ("error" in result) return error(reply, result.error, result.status);
|
|
return success(reply, { messages: result.data });
|
|
},
|
|
);
|
|
|
|
// POST /conversations/:id/messages — append turns
|
|
app.post(
|
|
"/conversations/:id/messages",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const id = parseId((request.params as { id: string }).id, reply);
|
|
if (id === null) return;
|
|
const body = parseBody(AiHistoryAppendSchema, request.body);
|
|
if ("error" in body) return error(reply, body.error, 400);
|
|
const result = await appendConversationMessages(
|
|
request.authData!.userId,
|
|
id,
|
|
body.data.messages,
|
|
);
|
|
if ("error" in result) return error(reply, result.error, result.status);
|
|
return success(reply, result.data);
|
|
},
|
|
);
|
|
|
|
// PATCH /conversations/:id — rename
|
|
app.patch(
|
|
"/conversations/:id",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const id = parseId((request.params as { id: string }).id, reply);
|
|
if (id === null) return;
|
|
const body = parseBody(AiRenameConversationSchema, request.body);
|
|
if ("error" in body) return error(reply, body.error, 400);
|
|
const result = await renameConversation(
|
|
request.authData!.userId,
|
|
id,
|
|
body.data.title,
|
|
);
|
|
if ("error" in result) return error(reply, result.error, result.status);
|
|
return success(reply, { conversation: result.data });
|
|
},
|
|
);
|
|
|
|
// DELETE /conversations/:id
|
|
app.delete(
|
|
"/conversations/:id",
|
|
{ preHandler: requirePermission("ai.use") },
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const id = parseId((request.params as { id: string }).id, reply);
|
|
if (id === null) return;
|
|
const result = await deleteConversation(request.authData!.userId, id);
|
|
if ("error" in result) return error(reply, result.error, result.status);
|
|
return success(reply, result.data);
|
|
},
|
|
);
|
|
}
|