Ärendestart via arbetsorderskanning med konfidensgranskning

Primärvägen när ett ärende startas: teknikern fotar arbetsorderns
framsida, orkesterns nya dokumenttolkningsuppgift (Sonnet 5, vision)
läser dokumentet layoutoberoende och returnerar strukturerade fält
(kund, fordon, verkstad, felbeskrivning) med konfidens per värde.

- Konfidensvalidering: ≥95 % godkänns automatiskt, 80–95 % markeras
  för genomläsning, <80 % kräver aktiv bekräftelse — teknikern
  granskar bara osäkra fält
- Visuell granskning: dokumentet bredvid fälten; klick på ett fält
  markerar ungefärlig position i bilden
- "Starta diagnos" skapar hela ärendet: objekt ur fordonsfälten,
  metodikval ur felbeskrivningen, tolkningen loggad som ny
  organisationsintern händelse arbetsorder_skannad (filtreras ur
  kund- och partnerdelningar i server, RPC och klient)
- Schema-bunden vision-uppgift i båda orkestertjänsterna; bild som
  data-URL, validerad på servern
- Inloggade användare tillfrågas aldrig om namn — kontot används
- Manuell inmatning kvar som andrahandsväg; tydligt märkt
  demo-tolkning i lokalt läge
- 37 vitest-tester; integrationstestet verifierar filtreringen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 08:54:53 +00:00
parent c3e831b478
commit a197ee10ca
15 changed files with 605 additions and 33 deletions
+82 -4
View File
@@ -17,7 +17,9 @@ import Anthropic from "@anthropic-ai/sdk";
const PORT = Number(process.env.PORT ?? 8080);
const MAX_PROMPT_LANGD = 40000;
const MAX_KROPP = 256 * 1024;
// Rymmer en nedskalad arbetsorderbild som data-URL.
const MAX_KROPP = 4 * 1024 * 1024;
const MAX_BILD_LANGD = 3_000_000;
const GRUND_REGLER = `Du arbetar i Guidad Felsökning, en professionell diagnostikplattform för tekniker. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation.
@@ -62,6 +64,58 @@ const METODIK_SCHEMA = {
additionalProperties: false,
};
// Fält som kan tolkas ur en skannad arbetsorder (samma katalog som
// edge-funktionen och klienten).
const ARBETSORDER_FALT_ID = [
"kund_namn", "kund_foretag", "kund_telefon", "kund_epost",
"fordon_regnr", "fordon_vin", "fordon_marke", "fordon_modell",
"fordon_motor", "fordon_arsmodell", "fordon_matarstallning",
"fordon_motorkod", "fordon_vaxellada",
"ao_nummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"felbeskrivning",
];
const ARBETSORDER_SCHEMA = {
type: "object",
properties: {
falt: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", enum: ARBETSORDER_FALT_ID },
varde: { type: "string" },
konfidens: { type: "number", minimum: 0, maximum: 1 },
omrade: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
bredd: { type: "number" },
hojd: { type: "number" },
},
required: ["x", "y", "bredd", "hojd"],
additionalProperties: false,
},
},
required: ["id", "varde", "konfidens"],
additionalProperties: false,
},
},
},
required: ["falt"],
additionalProperties: false,
};
const DOKUMENT_REGLER = `Du tolkar ett foto av en arbetsorder från en fordonsverkstad (OCR + layoutförståelse). Layouter varierar mellan verkstäder — identifiera fälten oavsett var de står, i tabeller såväl som fritext.
Regler:
- Returnera ENDAST fält som faktiskt går att läsa i dokumentet. Hitta aldrig på värden.
- "konfidens" är din läs-säkerhet 01: 1.0 endast vid helt entydig läsning; sänk vid oskarp text, handstil eller tvetydig layout.
- "omrade" anger ungefär var värdet står i bilden, normaliserat 01 (x, y = övre vänstra hörnet).
- Normalisera: registreringsnummer i versaler utan mellanslag, VIN med 17 tecken, datum som ÅÅÅÅ-MM-DD, mätarställning med enhet.
- "felbeskrivning" är kundens beskrivna problem/arbetsbegäran om en sådan finns i dokumentet.`;
// Orkestern: en modell per uppgiftstyp (samma routing som edge-funktionen).
const ORKESTER = {
handledning: {
@@ -95,6 +149,16 @@ const ORKESTER = {
Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA,
},
// Ärendestart: teknikern fotograferar arbetsordern; vi läser dokumentet
// och returnerar strukturerade fält med konfidens per värde.
dokumenttolkning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 2048,
system: DOKUMENT_REGLER,
schema: ARBETSORDER_SCHEMA,
bild: true,
},
};
// Minimal HS256-JWT-verifiering (Supabase legacy JWT secret) utan beroenden.
@@ -169,9 +233,9 @@ export function skapaServer() {
return svara(res, 401, { error: "Inloggning krävs." });
}
let uppgift, prompt;
let uppgift, prompt, bild;
try {
({ uppgift, prompt } = await lasKropp(req));
({ uppgift, prompt, bild } = await lasKropp(req));
} catch {
return svara(res, 400, { error: "Ogiltig förfrågan." });
}
@@ -184,6 +248,20 @@ export function skapaServer() {
return svara(res, 400, { error: "prompt är för lång." });
}
// Bilduppgifter: data-URL → image-block före textprompten.
let innehall = prompt;
if (konfig.bild) {
const matchning =
typeof bild === "string" && bild.length <= MAX_BILD_LANGD
? bild.match(/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/)
: null;
if (!matchning) return svara(res, 400, { error: "bild saknas eller har fel format." });
innehall = [
{ type: "image", source: { type: "base64", media_type: matchning[1], data: matchning[2] } },
{ type: "text", text: prompt },
];
}
try {
const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({
@@ -196,7 +274,7 @@ export function skapaServer() {
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: prompt }],
messages: [{ role: "user", content: innehall }],
});
if (svar.stop_reason === "refusal") {
return svara(res, 502, { error: "AI-tjänsten avböjde förfrågan." });
+7 -4
View File
@@ -141,19 +141,22 @@ kontroll "tomma listor avvisas" "$KOD" "400"
# 10. Live Share-behörighetsnivåer: kund/partner/intern + återkallelse
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}}]}' >/dev/null
kontroll "kundkoden filtrerar hypoteser" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1"
-d '{"handelser":[
{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}},
{"id":"h4","tidpunkt":"2026-08-03T08:04:00Z","anvandare":"Anna","handelse":{"typ":"arbetsorder_skannad","falt":[{"id":"kund_namn","etikett":"Namn","varde":"Kalle Kund","konfidens":0.97}]}}
]}' >/dev/null
kontroll "kundkoden filtrerar hypoteser och arbetsorder" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1"
PARTNERKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod)
PARTNER=$(curl -s "$BAS/api/delad/$PARTNERKOD")
kontroll "partnernivån visar hypoteser" "$(echo "$PARTNER" | falt .handelser.length)" "2"
kontroll "partnernivån visar hypoteser men inte arbetsordern" "$(echo "$PARTNER" | falt .handelser.length)" "2"
kontroll "partnernivån döljer kategoribyten" "$(echo "$PARTNER" | falt '.handelser.some(h=>h.handelse.typ==="kategori_byte")')" "false"
kontroll "nivån följer med svaret" "$(echo "$PARTNER" | falt .niva)" "partner"
INTERNKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"intern"}' | falt .kod)
kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "4"
kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "5"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/delningar" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"niva":"intern"}')
+1
View File
@@ -567,6 +567,7 @@ components:
- inaktivitet_forklarad
- overlamning
- ansvarig_satt
- arbetsorder_skannad
- export_skapad
- ai_svar
- arende_avslutat
+1 -1
View File
@@ -222,7 +222,7 @@ export function skapaServer() {
[arendeId],
);
const bortfiltrerat =
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"];
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
const handelser = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and not (handelse->>'typ' = any($2))