From 07d9c8d9f740efe9c25bb0066654adf0eaab9334 Mon Sep 17 00:00:00 2001 From: BOHA Date: Thu, 11 Jun 2026 15:07:58 +0200 Subject: [PATCH] feat(odin): forgiving lookups + per-customer aggregation + count awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPZ matching normalized on both sides (4SY7039 finds '4SY 7039') in list_vehicles and the list_trips vehicle filter; JS-side name matching diacritic-folded to keep utf8mb4_unicode_ci parity; ICO typed with spaces matches the space-less stored value (suppliers + customers) - get_top_customers: whole-table per-customer counts of invoices (sans drafts) / orders / projects, top 20 + customers_with_records, optional year, per-type permission re-checks — answers 'pro koho pracujeme nejvic' exactly instead of refusing - find_employee, find_supplier and stock search now return total_matching (stock search also gained its missing deterministic orderBy); system prompt teaches the model to answer counts from total_matching and use aggregation tools instead of summing 20-row list pages Co-Authored-By: Claude Fable 5 --- src/__tests__/ai-tools.test.ts | 95 +++++++++++ src/services/ai-tools.ts | 302 +++++++++++++++++++++++++-------- src/services/ai.service.ts | 1 + 3 files changed, 327 insertions(+), 71 deletions(-) diff --git a/src/__tests__/ai-tools.test.ts b/src/__tests__/ai-tools.test.ts index 8aae250..4854a71 100644 --- a/src/__tests__/ai-tools.test.ts +++ b/src/__tests__/ai-tools.test.ts @@ -189,9 +189,11 @@ beforeAll(async () => { const FIX_OFFER_NUMBER = "2098/NA/99999"; const FIX_SUPPLIER = "AiTest Dodavatel s.r.o."; const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o."; +const FIX_CUSTOMER = "AiTest Zákazník s.r.o."; let fixOfferId = 0; let fixSupplierId = 0; let fixRiId = 0; +let fixCustomerId = 0; beforeAll(async () => { await prisma.quotations.deleteMany({ @@ -205,6 +207,21 @@ beforeAll(async () => { data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" }, }); fixSupplierId = sup.id; + // Customer + two projects for the per-customer aggregation tool. + await prisma.projects.deleteMany({ + where: { name: { startsWith: "AiTest Projekt" } }, + }); + await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } }); + const cust = await prisma.customers.create({ + data: { name: FIX_CUSTOMER }, + }); + fixCustomerId = cust.id; + await prisma.projects.createMany({ + data: [ + { name: "AiTest Projekt 1", customer_id: fixCustomerId }, + { name: "AiTest Projekt 2", customer_id: fixCustomerId }, + ], + }); const ri = await prisma.received_invoices.create({ data: { month: 6, @@ -265,6 +282,9 @@ afterAll(async () => { await prisma.quotations.deleteMany({ where: { id: fixOfferId } }); await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } }); await prisma.received_invoices.deleteMany({ where: { id: fixRiId } }); + // Projects before the customer (Restrict FK on projects.customer_id). + await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } }); + await prisma.customers.deleteMany({ where: { id: fixCustomerId } }); }); afterAll(async () => { @@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { ).suppliers.find((x) => x.name === FIX_SUPPLIER); expect(s).toMatchObject({ ico: "99999998" }); + // IČO typed with spaces finds the space-less stored value. + const spacedIco = await executeTool( + "find_supplier", + { search: "999 99 998" }, + { userId: 999999998, roleName: "viewer", permissions: ["orders.view"] }, + ); + expect( + (spacedIco.result as { suppliers: { name: string }[] }).suppliers.some( + (x) => x.name === FIX_SUPPLIER, + ), + ).toBe(true); + const veh = await executeTool( "list_vehicles", { search: FIX.spz }, @@ -669,6 +701,25 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { // Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips. expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 }); + // SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999", + // in list_vehicles AND in the list_trips vehicle filter. + const spaced = await executeTool( + "list_vehicles", + { search: "aitest 999" }, + { userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] }, + ); + expect( + (spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz, + ).toBe(FIX.spz); + const tripsByPlate = await executeTool( + "list_trips", + { vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" }, + { userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] }, + ); + expect( + (tripsByPlate.result as { total_matching: number }).total_matching, + ).toBe(2); + expect( (await executeTool("find_supplier", { search: "x" }, NOBODY)).ok, ).toBe(false); @@ -714,6 +765,50 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => { ); }); + it("get_top_customers aggregates per customer across the whole table", async () => { + const res = await executeTool( + "get_top_customers", + { type: "projects" }, + ADMIN, + ); + expect(res.ok).toBe(true); + const r = res.result as { + customers_with_records: number; + top: { customer: string; count: number }[]; + }; + const mine = r.top.find((t) => t.customer === FIX_CUSTOMER); + expect(mine).toBeDefined(); + expect(mine!.count).toBe(2); + expect(r.customers_with_records).toBeGreaterThanOrEqual(1); + + // invoices.view alone cannot aggregate projects (per-type re-check). + const denied = await executeTool( + "get_top_customers", + { type: "projects" }, + VIEWER_INVOICES, + ); + expect(denied.ok).toBe(false); + }); + + it("find_employee and find_supplier report total_matching", async () => { + const emp = await executeTool( + "find_employee", + { search: FIX.last }, + RECORDER, + ); + expect( + (emp.result as { total_matching: number }).total_matching, + ).toBeGreaterThanOrEqual(1); + const sup = await executeTool( + "find_supplier", + { search: FIX_SUPPLIER }, + ADMIN, + ); + expect( + (sup.result as { total_matching: number }).total_matching, + ).toBeGreaterThanOrEqual(1); + }); + it("get_received_invoice_detail returns the gross-amount detail", async () => { const res = await executeTool( "get_received_invoice_detail", diff --git a/src/services/ai-tools.ts b/src/services/ai-tools.ts index e1a57ad..8bb3493 100644 --- a/src/services/ai-tools.ts +++ b/src/services/ai-tools.ts @@ -94,6 +94,14 @@ const hhmm = (d: Date | null): string | null => ? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` : null; const round2 = (n: number): number => Math.round(n * 100) / 100; +/** SPZ normalization: users type plates with/without spaces or dashes. */ +const normPlate = (s: string): string => s.replace(/[\s-]/g, "").toUpperCase(); +/** + * Diacritic-folded lowercase for JS-side matching — parity with the DB's + * utf8mb4_unicode_ci contains ("dodavka" must match "Dodávka"). + */ +const normText = (s: string): string => + s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); /** Quill/HTML rich text → compact plain text for model context. */ const stripHtml = (html: string | null | undefined): string => @@ -472,7 +480,7 @@ const TOOLS: ToolSpec[] = [ definition: { name: "list_customers", description: - "Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.", + "Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících; total_matching = CELKOVÝ počet zákazníků odpovídajících filtru (bez search = všichni).", input_schema: { type: "object", properties: { @@ -485,12 +493,13 @@ const TOOLS: ToolSpec[] = [ }, handler: async (input) => { // Mirrors the customers route's read (search on name/company_id). + // IČO is stored without spaces but users type "123 45 678". const search = str(input.search); const where = search ? { OR: [ { name: { contains: search } }, - { company_id: { contains: search } }, + { company_id: { contains: search.replace(/\s+/g, "") } }, ], } : {}; @@ -554,26 +563,32 @@ const TOOLS: ToolSpec[] = [ } // Stock math mirrors the service's total-stock rule: sum of // unconsumed batch quantities. - const rows = await prisma.sklad_items.findMany({ - where: { - is_active: true, - OR: [ - { name: { contains: search } }, - { item_number: { contains: search } }, - ], - }, - take: 20, - select: { - item_number: true, - name: true, - unit: true, - batches: { - where: { is_consumed: false }, - select: { quantity: true }, + const where = { + is_active: true, + OR: [ + { name: { contains: search } }, + { item_number: { contains: search } }, + ], + }; + const [rows, total] = await Promise.all([ + prisma.sklad_items.findMany({ + where, + orderBy: [{ name: "asc" }, { id: "asc" }], + take: 20, + select: { + item_number: true, + name: true, + unit: true, + batches: { + where: { is_consumed: false }, + select: { quantity: true }, + }, }, - }, - }); + }), + prisma.sklad_items.count({ where }), + ]); return { + total_matching: total, shown: rows.length, items: rows.map((r) => ({ item_number: r.item_number, @@ -789,20 +804,24 @@ const TOOLS: ToolSpec[] = [ if (!fullDirectory && populations.length === 0) { return { error: DENIED("seznam zaměstnanců") }; } - const rows = await prisma.users.findMany({ - where: fullDirectory - ? { OR: or } - : { AND: [{ OR: or }, { is_active: true }, { OR: populations }] }, - orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }], - take: 10, - select: { - id: true, - first_name: true, - last_name: true, - username: true, - is_active: true, - }, - }); + const where = fullDirectory + ? { OR: or } + : { AND: [{ OR: or }, { is_active: true }, { OR: populations }] }; + const [rows, total] = await Promise.all([ + prisma.users.findMany({ + where, + orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }], + take: 10, + select: { + id: true, + first_name: true, + last_name: true, + username: true, + is_active: true, + }, + }), + prisma.users.count({ where }), + ]); // GET /trips/users deliberately omits usernames (login identifiers); // only the attendance picker and the users admin expose them. const showUsername = @@ -810,6 +829,7 @@ const TOOLS: ToolSpec[] = [ ctxCan(ctx, "attendance.record") || ctxCan(ctx, "attendance.manage"); return { + total_matching: total, shown: rows.length, employees: rows.map((u) => ({ user_id: u.id, @@ -883,14 +903,28 @@ const TOOLS: ToolSpec[] = [ } const vehicle = str(input.vehicle); if (vehicle) { - where.vehicles = { - is: { - OR: [ - { name: { contains: vehicle } }, - { spz: { contains: vehicle } }, - ], - }, - }; + // Resolve in JS so SPZ spacing is normalized on both sides + // ("4SY7039" must match the stored "4SY 7039") — SQL contains is + // literal. The vehicles table is tiny. + const allVehicles = await prisma.vehicles.findMany({ + select: { id: true, name: true, spz: true }, + }); + const ids = allVehicles + .filter( + (v) => + normText(v.name).includes(normText(vehicle)) || + normPlate(v.spz).includes(normPlate(vehicle)), + ) + .map((v) => v.id); + if (ids.length === 0) { + return { + total_matching: 0, + shown: 0, + trips: [], + note: "Žádné vozidlo neodpovídá hledání (zkontroluj název nebo SPZ).", + }; + } + where.vehicle_id = { in: ids }; } const [rows, all] = await Promise.all([ @@ -1531,29 +1565,36 @@ const TOOLS: ToolSpec[] = [ handler: async (input, ctx) => { const search = str(input.search); if (!search) return { error: "Zadej název, IČO nebo město dodavatele." }; + // IČO is stored without spaces but users type "123 45 678". + const icoDigits = search.replace(/\s+/g, ""); // Route parity: the orders.view picker (GET /issued-orders/suppliers) // filters is_active: true — only the warehouse.manage supplier admin // sees deactivated (soft-deleted) suppliers. - const rows = await prisma.sklad_suppliers.findMany({ - where: { - ...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }), - OR: [ - { name: { contains: search } }, - { ico: { contains: search } }, - { city: { contains: search } }, - ], - }, - orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }], - take: 10, - select: { - name: true, - city: true, - ico: true, - dic: true, - is_active: true, - }, - }); + const where = { + ...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }), + OR: [ + { name: { contains: search } }, + { ico: { contains: icoDigits } }, + { city: { contains: search } }, + ], + }; + const [rows, total] = await Promise.all([ + prisma.sklad_suppliers.findMany({ + where, + orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }], + take: 10, + select: { + name: true, + city: true, + ico: true, + dic: true, + is_active: true, + }, + }), + prisma.sklad_suppliers.count({ where }), + ]); return { + total_matching: total, shown: rows.length, suppliers: rows.map((s) => ({ name: s.name, @@ -1589,17 +1630,12 @@ const TOOLS: ToolSpec[] = [ }, handler: async (input) => { const search = str(input.search); - const where = search - ? { - OR: [{ name: { contains: search } }, { spz: { contains: search } }], - } - : {}; - // Same odometer rule as GET /vehicles: max(initial_km, max trip end_km). - const [vehicles, total, tripStats] = await Promise.all([ + // The vehicles table is tiny — fetch all and match in JS so SPZ + // spacing can be normalized on BOTH sides ("4SY7039" must find the + // stored "4SY 7039" and vice versa); a SQL contains is literal. + const [allVehicles, tripStats] = await Promise.all([ prisma.vehicles.findMany({ - where, orderBy: [{ name: "asc" }, { id: "asc" }], - take: 20, select: { id: true, name: true, @@ -1610,13 +1646,21 @@ const TOOLS: ToolSpec[] = [ is_active: true, }, }), - prisma.vehicles.count({ where }), prisma.trips.groupBy({ by: ["vehicle_id"], _max: { end_km: true }, _count: { id: true }, }), ]); + const matched = search + ? allVehicles.filter( + (v) => + normText(v.name).includes(normText(search)) || + normPlate(v.spz).includes(normPlate(search)), + ) + : allVehicles; + const total = matched.length; + const vehicles = matched.slice(0, 20); const statsMap = new Map( tripStats.map((s) => [ s.vehicle_id, @@ -1787,6 +1831,121 @@ const TOOLS: ToolSpec[] = [ }; }, }, + { + // Coarse gate any-of; the handler re-checks the exact permission per + // aggregation source. Counts only — no amounts beyond what the lists + // already expose to the same permission. + permission: ["invoices.view", "orders.view", "projects.view"], + definition: { + name: "get_top_customers", + description: + "Agregace podle ZÁKAZNÍKA přes celou databázi: počet vydaných faktur / přijatých objednávek / projektů na zákazníka (TOP 20 + počet zákazníků se záznamem). Volej na dotazy 'pro koho pracujeme nejvíc', 'největší zákazník'. Celkový počet zákazníků zjistíš z list_customers (total_matching).", + input_schema: { + type: "object", + properties: { + type: { + type: "string", + enum: ["invoices", "orders", "projects"], + description: "Podle čeho agregovat", + }, + year: { + type: "number", + description: "Jen daný rok (vynech pro celou historii)", + }, + }, + required: ["type"], + }, + }, + handler: async (input, ctx) => { + const type = str(input.type); + const year = num(input.year); + // Year ranges as UTC midnights (issue_date is @db.Date; created_at + // DateTime comparisons are unaffected by the choice). + const range = year + ? { + gte: new Date(Date.UTC(year, 0, 1)), + lt: new Date(Date.UTC(year + 1, 0, 1)), + } + : undefined; + // Normalized per branch — Prisma's groupBy generics don't survive a + // shared pre-declared variable type. + let groups: { customer_id: number | null; count: number }[]; + let note: string; + if (type === "invoices") { + if (!ctxCan(ctx, "invoices.view")) return { error: DENIED("faktury") }; + const g = await prisma.invoices.groupBy({ + by: ["customer_id"], + where: { + status: { not: "draft" }, + ...(range ? { issue_date: range } : {}), + }, + _count: { _all: true }, + }); + groups = g.map((x) => ({ + customer_id: x.customer_id, + count: x._count._all, + })); + note = "Počty vydaných faktur (bez konceptů)."; + } else if (type === "orders") { + if (!ctxCan(ctx, "orders.view")) { + return { error: DENIED("objednávky") }; + } + const g = await prisma.orders.groupBy({ + by: ["customer_id"], + where: range ? { created_at: range } : {}, + _count: { _all: true }, + }); + groups = g.map((x) => ({ + customer_id: x.customer_id, + count: x._count._all, + })); + note = "Počty přijatých objednávek (všechny stavy)."; + } else if (type === "projects") { + if (!ctxCan(ctx, "projects.view")) { + return { error: DENIED("projekty") }; + } + const g = await prisma.projects.groupBy({ + by: ["customer_id"], + where: range ? { created_at: range } : {}, + _count: { _all: true }, + }); + groups = g.map((x) => ({ + customer_id: x.customer_id, + count: x._count._all, + })); + note = "Počty projektů (všechny stavy)."; + } else { + return { error: "Neznámý typ agregace." }; + } + const assigned = groups + .filter((g) => g.customer_id != null) + .sort( + (a, b) => + b.count - a.count || (a.customer_id ?? 0) - (b.customer_id ?? 0), + ); + const unassigned = groups.find((g) => g.customer_id == null)?.count ?? 0; + const top = assigned.slice(0, 20); + const names = await prisma.customers.findMany({ + where: { id: { in: top.map((g) => g.customer_id as number) } }, + select: { id: true, name: true }, + }); + const nameById = new Map(names.map((c) => [c.id, c.name])); + return { + type, + ...(year ? { year } : {}), + customers_with_records: assigned.length, + shown: top.length, + top: top.map((g) => ({ + customer: + nameById.get(g.customer_id as number) ?? + `Zákazník #${g.customer_id}`, + count: g.count, + })), + ...(unassigned ? { records_without_customer: unassigned } : {}), + note, + }; + }, + }, ]; /** Tool definitions the caller may use — filtered by their permissions. */ @@ -1861,4 +2020,5 @@ export const TOOL_LABELS: Record = { list_vehicles: "Vozidla", get_document_totals: "Součty dokumentů", get_received_invoice_detail: "Detail přijaté faktury", + get_top_customers: "TOP zákazníci", }; diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 2cb6002..e63cde8 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -291,6 +291,7 @@ function agentSystemPrompt( "Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " + (tools.length > 0 ? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " + + "Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " + (hasFindEmployee ? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. " : "") +