a37cc027ae
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
182 lines
7.7 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|