Files
alva/src/felsokning/__tests__/ai.test.ts
T
Claude a37cc027ae Personbil-etikett i granskningen + ECM Knowledge Library
Arbetsordergranskningens fältgrupp heter nu Personbil (tidigare Fordon),
liksom raden i rapportens fordonsinformation.

ECM Knowledge Library: regelbiblioteket är nu serverdistribuerat —
regler uppdateras i driften utan appändring, precis som rekommenderat:

- Compliance-reglerna är deklarativ data (krav-typ i stället för kod):
  services/plattform/ecm-regler.json serveras på GET /api/ecm/regler,
  i klustret utbytbar via ConfigMap (ECM_REGLER_FIL)
- Klienten hämtar paketet vid sidladdning, cachar det och faller
  tillbaka till inbyggt standardpaket offline; trasiga paket och okända
  krav-typer filtreras (normaliseraRegelpaket)
- Kvalitetsgrinden, orsakskategorierna, evidenskällorna och
  undantagsorsakerna läses ur det aktiva paketet; spårbarhetspaketet
  bär regelpaketets version
- Testat: paritet server/standardpaket, fallback, att ett uppdaterat
  paket ändrar grinden utan appändring; integrationstestet verifierar
  serveringen (57 vitest-tester gröna)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-03 09:38:44 +00:00

182 lines
7.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { byggAnvandarPrompt, byggGranskningsPrompt, normaliseraArbetsorder, tolkaAiSvar, ARBETSORDER_FALT } from "../ai";
import { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner";
import { byggDemoArende } from "../demo";
const ENDPOINT = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8");
describe("AI-orkestern", () => {
it("plattformens endpoint routar uppgifter till olika modeller", () => {
// Servern äger orkestern: modellval, effort, systemprompt och schema.
for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:", "dokumenttolkning:"]) {
expect(ENDPOINT).toContain(uppgift);
}
expect(ENDPOINT).toContain('"claude-sonnet-5"');
expect(ENDPOINT).toContain('"claude-opus-5"');
expect(ENDPOINT).toContain('"claude-haiku-4-5"');
expect(ENDPOINT).toContain("ANTHROPIC_API_KEY");
});
it("K8s-tjänsten (services/ai-orkester) kör samma orkester som edge-funktionen", () => {
const tjanst = readFileSync("services/ai-orkester/server.mjs", "utf8");
for (const bit of [
'"claude-sonnet-5"',
'"claude-opus-5"',
'"claude-haiku-4-5"',
"handledning:",
"granskning:",
"sammanfattning:",
"metodikval:",
"dokumenttolkning:",
"Hitta aldrig på fakta",
"SUPABASE_JWT_SECRET",
]) {
expect(tjanst).toContain(bit);
}
});
it("självhostade stacken: plattformstjänst + append-only i databasen", () => {
const plattform = readFileSync("services/plattform/server.mjs", "utf8");
for (const bit of [
"/api/auth/logga-in",
"/api/auth/registrera",
"/api/arenden",
String.raw`\/api\/delad\/`,
"gen_salt('bf')",
"JWT_SECRET",
]) {
expect(plattform).toContain(bit);
}
// API:t exponerar medvetet inga update/delete-operationer.
expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i);
// Multi-tenant: all ärendedata är organisationsknuten.
expect(plattform).toContain("organisation_id");
expect(plattform).toContain("arendeIOrg");
const schema = readFileSync("infra/k8s/postgres-init.sql", "utf8");
expect(schema).toContain("before update or delete on felsokning_handelser");
expect(schema).toContain("before update or delete on felsokning_arenden");
expect(schema).toContain("create table if not exists organisationer");
expect(schema).toContain("check (roll in ('tekniker', 'arbetsledare', 'admin'))");
});
it("OpenAPI-specen och plattformsservern täcker samma endpoints", () => {
const spec = readFileSync("services/plattform/openapi.yaml", "utf8");
const server = readFileSync("services/plattform/server.mjs", "utf8");
// Varje dokumenterad väg finns i servern …
const vagar: [string, string][] = [
["/halsa", "/halsa"],
["/api/openapi.yaml", "/api/openapi.yaml"],
["/api/auth/registrera", "/api/auth/registrera"],
["/api/auth/logga-in", "/api/auth/logga-in"],
["/api/anvandare", "/api/anvandare"],
["/api/ecm/regler", "/api/ecm/regler"],
["/api/organisation", "/api/organisation"],
["/api/organisation/installningar", "/api/organisation/installningar"],
["/api/arenden", "/api/arenden"],
["/api/arenden/{arendeId}/handelser", "handelser"],
["/api/oversikt", "/api/oversikt"],
["/api/delad/{delningskod}", "delad"],
];
for (const [iSpec, iServer] of vagar) {
expect(spec).toContain(`${iSpec}:`);
expect(server).toContain(iServer);
}
// … och AI-endpointen + händelsetyperna är dokumenterade.
expect(spec).toContain("/api/ai:");
expect(spec).toContain("append-only");
for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arbetsorder_skannad", "arende_avslutat"]) {
expect(spec).toContain(typ);
}
});
it("endpointen kodar AI-reglerna i grundprompten", () => {
expect(ENDPOINT).toContain("Hitta aldrig på fakta");
expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
expect(ENDPOINT).toContain("KRÄVER verifiering");
expect(ENDPOINT).toContain('"json_schema"');
});
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
const arende = byggDemoArende(1);
const prompt = byggAnvandarPrompt(
brief(arende, VIBRATION_METODIK),
VIBRATION_METODIK.namn,
"Reläet klickar inte.",
);
expect(prompt).toContain("Volvo XC60 D4 2019");
expect(prompt).toContain("Felbeskrivning:");
expect(prompt).toContain("Kontrollera lufttryck");
expect(prompt).toContain("Ej kontrollerat enligt metodiken:");
expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte.");
});
it("granskningsprompten innehåller hela arbetsloggen men ingen ny inmatning", () => {
const arende = byggDemoArende(1);
const prompt = byggGranskningsPrompt(arende, VIBRATION_METODIK);
expect(prompt).toContain("Fullständig arbetslogg:");
expect(prompt).toContain("Arbete överlämnat från Anna till Johan");
expect(prompt).not.toContain("Teknikerns nya inmatning");
});
it("tolkar giltiga svar och kastar på ogiltiga", () => {
const svar = tolkaAiSvar({
rader: [
{ typ: "verifierat", text: "Matningsspänning finns på stift 14." },
{ typ: "okand_typ", text: "ska filtreras bort" },
],
nastaSteg: "Kontrollera jordanslutningen på stift 7.",
});
expect(svar.rader).toHaveLength(1);
expect(svar.rader[0].typ).toBe("verifierat");
expect(svar.nastaSteg).toContain("stift 7");
expect(() => tolkaAiSvar({ rader: "inte en lista", nastaSteg: "x" })).toThrow();
expect(() => tolkaAiSvar(null)).toThrow();
});
});
describe("arbetsorderskanning", () => {
it("normaliserar tolkningen: okända fält bort, konfidens klipps, tomma värden bort", () => {
const falt = normaliseraArbetsorder({
falt: [
{ id: "fordon_regnr", varde: " abc123 ", konfidens: 1.7 },
{ id: "pahittat_falt", varde: "x", konfidens: 0.9 },
{ id: "kund_namn", varde: " ", konfidens: 0.9 },
{ id: "fordon_vin", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.5, y: 0.2, bredd: 0.4, hojd: 0.05 } },
{ id: "fordon_vin", varde: "DUBBLETT", konfidens: 0.5 },
],
});
expect(falt.map((f) => f.id)).toEqual(["fordon_regnr", "fordon_vin"]);
expect(falt[0].konfidens).toBe(1);
expect(falt[0].varde).toBe("abc123");
expect(falt[0].grupp).toBe("Personbil");
expect(falt[1].omrade?.x).toBe(0.5);
expect(() => normaliseraArbetsorder({})).toThrow();
});
it("demo-tolkningen använder bara kända fält och alla konfidensnivåer", async () => {
const { byggDemoTolkning } = await import("../demo");
const falt = byggDemoTolkning();
for (const f of falt) {
expect(ARBETSORDER_FALT.some((def) => def.id === f.id), f.id).toBe(true);
}
// Flödet ska kunna demonstrera alla tre nivåerna: auto-godkänd,
// granska och kräver bekräftelse.
expect(falt.some((f) => f.konfidens >= 0.95)).toBe(true);
expect(falt.some((f) => f.konfidens >= 0.8 && f.konfidens < 0.95)).toBe(true);
expect(falt.some((f) => f.konfidens < 0.8)).toBe(true);
});
it("händelsetypen arbetsorder_skannad är organisationsintern i alla delningsvägar", () => {
const server = readFileSync("services/plattform/server.mjs", "utf8");
const migration = readFileSync("supabase/migrations/20260802230000_guidad_felsokning.sql", "utf8");
const delatVy = readFileSync("src/felsokning/DelatArendeVy.tsx", "utf8");
for (const innehall of [server, migration, delatVy]) {
expect(innehall).toContain("arbetsorder_skannad");
}
});
});