Ä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
+83 -2
View File
@@ -63,6 +63,60 @@ const METODIK_SCHEMA = {
additionalProperties: false,
};
// Fält som kan tolkas ur en skannad arbetsorder. Klienten har samma
// katalog (src/felsokning/ai.ts) och filtrerar dessutom okända id:n.
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.`;
const MAX_BILD_LANGD = 3_000_000;
interface UppgiftKonfig {
modell: string;
// effort utelämnas för modeller som saknar parametern (Haiku 4.5).
@@ -70,6 +124,8 @@ interface UppgiftKonfig {
maxTokens: number;
system: string;
schema: object;
// Uppgiften tar en bifogad bild (data-URL) i stället för ren text.
bild?: boolean;
}
// Orkestern: en modell per uppgiftstyp.
@@ -117,6 +173,16 @@ Uppgift: En tekniker lämnar över ärendet. Komplettera överlämningen med det
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,
},
};
serve(async (req: Request): Promise<Response> => {
@@ -128,7 +194,7 @@ serve(async (req: Request): Promise<Response> => {
if (!apiNyckel) {
return svara(503, { error: "AI-tjänsten är inte konfigurerad." });
}
const { uppgift, prompt } = await req.json();
const { uppgift, prompt, bild } = await req.json();
const konfig = ORKESTER[uppgift as string];
if (!konfig) {
return svara(400, { error: "Okänd uppgift." });
@@ -140,6 +206,21 @@ serve(async (req: Request): Promise<Response> => {
return svara(400, { error: "prompt är för lång." });
}
// Bilduppgifter: bilden skickas som data-URL och blir ett image-block
// före textprompten i samma användarmeddelande.
let innehall: unknown = 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(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 },
];
}
const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({
model: konfig.modell,
@@ -153,7 +234,7 @@ serve(async (req: Request): Promise<Response> => {
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 }],
// deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna
} as any);
@@ -59,7 +59,7 @@ as $$
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
from felsokning_handelser h
where h.arende_id = a.id
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt')
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt', 'arbetsorder_skannad')
), '[]'::jsonb)
)
from felsokning_arenden a