Ä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
+2 -1
View File
@@ -32,6 +32,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. | | Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. |
| Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. **Behörighetsnivåer**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. | | Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. **Behörighetsnivåer**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. |
| Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. | | Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. |
| Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 8095 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. |
| Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | | Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. |
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. |
| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. |
@@ -45,4 +46,4 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
## Medvetna avgränsningar ## Medvetna avgränsningar
- QR/streckkod/OCR för objektidentifiering, bildanalys och tillverkarintegrationer ingår inte ännu. - QR-/streckkodsläsning, VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen ger strukturen de kopplas in i.
+82 -4
View File
@@ -17,7 +17,9 @@ import Anthropic from "@anthropic-ai/sdk";
const PORT = Number(process.env.PORT ?? 8080); const PORT = Number(process.env.PORT ?? 8080);
const MAX_PROMPT_LANGD = 40000; 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. 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, 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). // Orkestern: en modell per uppgiftstyp (samma routing som edge-funktionen).
const ORKESTER = { const ORKESTER = {
handledning: { handledning: {
@@ -95,6 +149,16 @@ const ORKESTER = {
Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`, Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA, 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. // 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." }); return svara(res, 401, { error: "Inloggning krävs." });
} }
let uppgift, prompt; let uppgift, prompt, bild;
try { try {
({ uppgift, prompt } = await lasKropp(req)); ({ uppgift, prompt, bild } = await lasKropp(req));
} catch { } catch {
return svara(res, 400, { error: "Ogiltig förfrågan." }); 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." }); 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 { try {
const klient = new Anthropic({ apiKey: apiNyckel }); const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({ const svar = await klient.beta.messages.create({
@@ -196,7 +274,7 @@ export function skapaServer() {
betas: ["server-side-fallback-2026-07-01"], betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default", fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }], 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") { if (svar.stop_reason === "refusal") {
return svara(res, 502, { error: "AI-tjänsten avböjde förfrågan." }); 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 # 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' \ 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 -d '{"handelser":[
kontroll "kundkoden filtrerar hypoteser" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1" {"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" \ 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) -H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod)
PARTNER=$(curl -s "$BAS/api/delad/$PARTNERKOD") 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 "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" 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" \ 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) -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" \ 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"}') -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"niva":"intern"}')
+1
View File
@@ -567,6 +567,7 @@ components:
- inaktivitet_forklarad - inaktivitet_forklarad
- overlamning - overlamning
- ansvarig_satt - ansvarig_satt
- arbetsorder_skannad
- export_skapad - export_skapad
- ai_svar - ai_svar
- arende_avslutat - arende_avslutat
+1 -1
View File
@@ -222,7 +222,7 @@ export function skapaServer() {
[arendeId], [arendeId],
); );
const bortfiltrerat = 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( const handelser = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser `select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and not (handelse->>'typ' = any($2)) where arende_id = $1 and not (handelse->>'typ' = any($2))
+1 -1
View File
@@ -32,7 +32,7 @@ export function DelatArendeVy({
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
const kundposter = redanFiltrerad const kundposter = redanFiltrerad
? arende.handelser ? arende.handelser
: arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"].includes(p.handelse.typ)); : arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"].includes(p.handelse.typ));
const pagaende = b.rekommenderatNastaSteg[0]; const pagaende = b.rekommenderatNastaSteg[0];
return ( return (
+46 -3
View File
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { byggAnvandarPrompt, byggGranskningsPrompt, tolkaAiSvar } from "../ai"; import { byggAnvandarPrompt, byggGranskningsPrompt, normaliseraArbetsorder, tolkaAiSvar, ARBETSORDER_FALT } from "../ai";
import { VIBRATION_METODIK } from "../metodik"; import { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner"; import { brief } from "../projektioner";
import { byggDemoArende } from "../demo"; import { byggDemoArende } from "../demo";
@@ -10,7 +10,7 @@ const ENDPOINT = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8
describe("AI-orkestern", () => { describe("AI-orkestern", () => {
it("plattformens endpoint routar uppgifter till olika modeller", () => { it("plattformens endpoint routar uppgifter till olika modeller", () => {
// Servern äger orkestern: modellval, effort, systemprompt och schema. // Servern äger orkestern: modellval, effort, systemprompt och schema.
for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:"]) { for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:", "dokumenttolkning:"]) {
expect(ENDPOINT).toContain(uppgift); expect(ENDPOINT).toContain(uppgift);
} }
expect(ENDPOINT).toContain('"claude-sonnet-5"'); expect(ENDPOINT).toContain('"claude-sonnet-5"');
@@ -29,6 +29,7 @@ describe("AI-orkestern", () => {
"granskning:", "granskning:",
"sammanfattning:", "sammanfattning:",
"metodikval:", "metodikval:",
"dokumenttolkning:",
"Hitta aldrig på fakta", "Hitta aldrig på fakta",
"SUPABASE_JWT_SECRET", "SUPABASE_JWT_SECRET",
]) { ]) {
@@ -85,7 +86,7 @@ describe("AI-orkestern", () => {
// … och AI-endpointen + händelsetyperna är dokumenterade. // … och AI-endpointen + händelsetyperna är dokumenterade.
expect(spec).toContain("/api/ai:"); expect(spec).toContain("/api/ai:");
expect(spec).toContain("append-only"); expect(spec).toContain("append-only");
for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arende_avslutat"]) { for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arbetsorder_skannad", "arende_avslutat"]) {
expect(spec).toContain(typ); expect(spec).toContain(typ);
} }
}); });
@@ -135,3 +136,45 @@ describe("AI-orkestern", () => {
expect(() => tolkaAiSvar(null)).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("Fordon");
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");
}
});
});
+82 -8
View File
@@ -8,10 +8,11 @@
// klassificerat enligt AI-reglerna. // klassificerat enligt AI-reglerna.
// //
// Orkestern (serverns routing): // Orkestern (serverns routing):
// - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation) // - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation)
// - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget) // - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget)
// - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter) // - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter)
// - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning) // - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning)
// - dokumenttolkning → Claude Sonnet 5 (vision: skannad arbetsorder → fält)
import type { Arende } from "./domain"; import type { Arende } from "./domain";
import { handelseRubrik } from "./domain"; import { handelseRubrik } from "./domain";
@@ -20,7 +21,7 @@ import { METODIKER } from "./metodik";
import type { Brief } from "./projektioner"; import type { Brief } from "./projektioner";
import { brief } from "./projektioner"; import { brief } from "./projektioner";
export type AiUppgift = "handledning" | "granskning" | "sammanfattning" | "metodikval"; export type AiUppgift = "handledning" | "granskning" | "sammanfattning" | "metodikval" | "dokumenttolkning";
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation"; export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
@@ -89,13 +90,86 @@ export function tolkaAiSvar(data: unknown): AiSvar {
return { rader, nastaSteg: svar.nastaSteg }; return { rader, nastaSteg: svar.nastaSteg };
} }
// ---- Arbetsorderskanning (dokumenttolkning) ---------------------------
// Teknikern fotograferar arbetsorderns framsida; AI:n läser dokumentet
// (OCR + layoutförståelse) och returnerar strukturerade fält med
// konfidens per värde. Bara osäkra fält behöver granskas.
export type ArbetsorderGrupp = "Kund" | "Fordon" | "Verkstad" | "Ärende";
export interface TolkatFalt {
id: string;
etikett: string;
grupp: ArbetsorderGrupp;
varde: string;
konfidens: number; // 01
// Ungefärlig position i den skannade bilden, normaliserad 01.
omrade?: { x: number; y: number; bredd: number; hojd: number };
}
export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: string }[] = [
{ id: "kund_namn", grupp: "Kund", etikett: "Namn" },
{ id: "kund_foretag", grupp: "Kund", etikett: "Företag" },
{ id: "kund_telefon", grupp: "Kund", etikett: "Telefon" },
{ id: "kund_epost", grupp: "Kund", etikett: "E-post" },
{ id: "fordon_regnr", grupp: "Fordon", etikett: "Regnr" },
{ id: "fordon_vin", grupp: "Fordon", etikett: "VIN" },
{ id: "fordon_marke", grupp: "Fordon", etikett: "Märke" },
{ id: "fordon_modell", grupp: "Fordon", etikett: "Modell" },
{ id: "fordon_motor", grupp: "Fordon", etikett: "Motor" },
{ id: "fordon_arsmodell", grupp: "Fordon", etikett: "Årsmodell" },
{ id: "fordon_matarstallning", grupp: "Fordon", etikett: "Mätarställning" },
{ id: "fordon_motorkod", grupp: "Fordon", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Fordon", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_referens", grupp: "Verkstad", etikett: "Intern referens" },
{ id: "ao_serviceradgivare", grupp: "Verkstad", etikett: "Servicerådgivare" },
{ id: "ao_bokningsdatum", grupp: "Verkstad", etikett: "Bokningsdatum" },
{ id: "felbeskrivning", grupp: "Ärende", etikett: "Felbeskrivning" },
];
const ARBETSORDER_PROMPT =
"Tolka den bifogade arbetsordern från en fordonsverkstad och extrahera fälten.";
// Rensar AI-svaret: okända fält-id filtreras, konfidens klipps till 01,
// tomma värden tas bort och etikett/grupp slås upp ur katalogen.
export function normaliseraArbetsorder(data: unknown): TolkatFalt[] {
const rader = (data as { falt?: unknown }).falt;
if (!Array.isArray(rader)) throw new Error("Oväntat tolkningsformat");
const resultat: TolkatFalt[] = [];
for (const rad of rader as Partial<TolkatFalt>[]) {
const def = ARBETSORDER_FALT.find((f) => f.id === rad?.id);
if (!def || typeof rad.varde !== "string" || !rad.varde.trim()) continue;
if (resultat.some((f) => f.id === def.id)) continue;
const konfidens = typeof rad.konfidens === "number" ? Math.min(1, Math.max(0, rad.konfidens)) : 0;
const o = rad.omrade;
const omrade =
o && [o.x, o.y, o.bredd, o.hojd].every((v) => typeof v === "number" && v >= 0 && v <= 1)
? o
: undefined;
resultat.push({ id: def.id, etikett: def.etikett, grupp: def.grupp, varde: rad.varde.trim(), konfidens, omrade });
}
return resultat;
}
export async function tolkaArbetsorder(
dataUrl: string,
): Promise<{ falt: TolkatFalt[]; modell: string } | null> {
const resultat = await anropa("dokumenttolkning", ARBETSORDER_PROMPT, { bild: dataUrl });
return resultat ? { falt: normaliseraArbetsorder(resultat.svar), modell: resultat.modell } : null;
}
// I Kubernetes-driften pekar VITE_AI_ORKESTER_URL på orkestertjänsten // I Kubernetes-driften pekar VITE_AI_ORKESTER_URL på orkestertjänsten
// (services/ai-orkester); utan den används Supabase-edge-funktionen. // (services/ai-orkester); utan den används Supabase-edge-funktionen.
const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined)?.replace(/\/$/, ""); const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined)?.replace(/\/$/, "");
// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via // Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
// plattformens autentiserade backend. // plattformens autentiserade backend.
async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> { async function anropa(
uppgift: AiUppgift,
prompt: string,
extra?: Record<string, unknown>,
): Promise<{ modell: string; svar: unknown } | null> {
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform"); const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
let token: string | null = null; let token: string | null = null;
@@ -120,14 +194,14 @@ async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: str
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ uppgift, prompt }), body: JSON.stringify({ uppgift, prompt, ...extra }),
}); });
if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`); if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`);
resultat = await res.json(); resultat = await res.json();
} else { } else {
const { supabase } = await import("@/integrations/supabase/client"); const { supabase } = await import("@/integrations/supabase/client");
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", { const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
body: { uppgift, prompt }, body: { uppgift, prompt, ...extra },
}); });
if (error) throw error; if (error) throw error;
resultat = svar; resultat = svar;
+20
View File
@@ -4,6 +4,7 @@
// har verkligt innehåll direkt. // har verkligt innehåll direkt.
import type { Arende, Handelse, LoggPost } from "./domain"; import type { Arende, Handelse, LoggPost } from "./domain";
import type { TolkatFalt } from "./ai";
import { nyLoggPost } from "./domain"; import { nyLoggPost } from "./domain";
// Enkla platshållarbilder ritade i canvas — ersätts av riktiga foton så // Enkla platshållarbilder ritade i canvas — ersätts av riktiga foton så
@@ -119,3 +120,22 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
handelser, handelser,
}; };
} }
// Demo-tolkning av en arbetsorder — används i lokalt läge där plattformens
// dokumenttolkning inte kan nås, så att flödet (skanna → granska osäkra
// fält → starta diagnos) kan provas ändå. Märks alltid som demo i UI:t.
export function byggDemoTolkning(): TolkatFalt[] {
return [
{ id: "kund_namn", etikett: "Namn", grupp: "Kund", varde: "Anders Svensson", konfidens: 0.98 },
{ id: "kund_telefon", etikett: "Telefon", grupp: "Kund", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } },
{ id: "fordon_regnr", etikett: "Regnr", grupp: "Fordon", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } },
{ id: "fordon_vin", etikett: "VIN", grupp: "Fordon", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } },
{ id: "fordon_marke", etikett: "Märke", grupp: "Fordon", varde: "Volvo", konfidens: 0.99 },
{ id: "fordon_modell", etikett: "Modell", grupp: "Fordon", varde: "XC60 D4", konfidens: 0.97 },
{ id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Fordon", varde: "2019", konfidens: 0.96 },
{ id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Fordon", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } },
{ id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Verkstad", varde: "AO-2496", konfidens: 0.99 },
{ id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Verkstad", varde: "M. Lindqvist", konfidens: 0.82 },
{ id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Ärende", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } },
];
}
+11
View File
@@ -37,9 +37,18 @@ export interface Objekt {
kund?: string; kund?: string;
} }
// Ett fält tolkat ur en skannad arbetsorder, med AI:ns läs-säkerhet.
export interface ArbetsorderFalt {
id: string;
etikett: string;
varde: string;
konfidens: number; // 01
}
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort. // Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
export type Handelse = export type Handelse =
| { typ: "objekt_identifierat"; objekt: Objekt } | { typ: "objekt_identifierat"; objekt: Objekt }
| { typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[]; dataUrl?: string }
| { typ: "felbeskrivning"; text: string } | { typ: "felbeskrivning"; text: string }
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string } | { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
| { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string } | { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string }
@@ -98,6 +107,8 @@ export function handelseRubrik(post: LoggPost): string {
switch (h.typ) { switch (h.typ) {
case "objekt_identifierat": case "objekt_identifierat":
return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`; return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`;
case "arbetsorder_skannad":
return `Arbetsorder skannad — ${h.falt.length} fält tolkade`;
case "felbeskrivning": case "felbeskrivning":
return `Felbeskrivning registrerad`; return `Felbeskrivning registrerad`;
case "fraga_besvarad": case "fraga_besvarad":
+1 -1
View File
@@ -1014,7 +1014,7 @@ function RapportFlik({
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial // Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
// och ingår inte i det som delas med kund. // och ingår inte i det som delas med kund.
const kundposter = arende.handelser.filter( const kundposter = arende.handelser.filter(
(p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt"].includes(p.handelse.typ), (p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"].includes(p.handelse.typ),
); );
// Alla exporter bygger på samma händelselogg och versionsmärks: // Alla exporter bygger på samma händelselogg och versionsmärks:
+7 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
@@ -228,6 +228,12 @@ export default function Arendelista() {
const [namn, setNamn] = useState(""); const [namn, setNamn] = useState("");
const [filter, setFilter] = useState<Filter>("alla"); const [filter, setFilter] = useState<Filter>("alla");
// Inloggad användare tillfrågas aldrig om namn — kontot vet redan.
const kontoNamn = plattformKonto()?.namn;
useEffect(() => {
if (!anvandare && kontoNamn) sattAnvandare(kontoNamn);
}, [anvandare, kontoNamn, sattAnvandare]);
if (!anvandare) { if (!anvandare) {
return ( return (
<FelsokningSkal rubrik="Guidad Felsökning"> <FelsokningSkal rubrik="Guidad Felsökning">
+260 -6
View File
@@ -1,21 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain"; import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store"; import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik"; import { valjMetodik } from "@/felsokning/metodik";
import { valjMetodikMedAi } from "@/felsokning/ai"; import { tolkaArbetsorder, valjMetodikMedAi, type TolkatFalt, type ArbetsorderGrupp } from "@/felsokning/ai";
import { byggDemoTolkning } from "@/felsokning/demo";
import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar"; import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto } from "@/felsokning/format";
// Ingen felsökning börjar innan objektet identifierats och bekräftats. // Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna
// Vilka objekttyper och identifieringsmetoder som visas styrs av // starta ett ärende på under 15 sekunder. Primärvägen är att fotografera
// organisationens inställningar (systemadmin) — enhetens val i lokalt läge. // arbetsordern — systemet läser dokumentet och fyller i fälten; teknikern
// granskar bara osäkra värden. Manuell inmatning finns som andrahandsväg,
// styrd av organisationens inställningar.
export default function NyttArende() { export default function NyttArende() {
const navigate = useNavigate(); const navigate = useNavigate();
const skapaArende = useFelsokning((s) => s.skapaArende); const skapaArende = useFelsokning((s) => s.skapaArende);
const laggTill = useFelsokning((s) => s.laggTill);
const [inst, setInst] = useState(lastaInstallningar()); const [inst, setInst] = useState(lastaInstallningar());
const [steg, setSteg] = useState<"identifiera" | "bekrafta" | "felbeskrivning">("identifiera"); const [steg, setSteg] = useState<"start" | "granska" | "identifiera" | "bekrafta" | "felbeskrivning">("start");
const [metod, setMetod] = useState(inst.identifieringsmetoder[0]); const [metod, setMetod] = useState(inst.identifieringsmetoder[0]);
const [typ, setTyp] = useState(inst.objekttyper[0]); const [typ, setTyp] = useState(inst.objekttyper[0]);
@@ -32,6 +37,69 @@ export default function NyttArende() {
const [fel, setFel] = useState(""); const [fel, setFel] = useState("");
const [startar, setStartar] = useState(false); const [startar, setStartar] = useState(false);
const [tolkning, setTolkning] = useState<{ foto: string; falt: TolkatFalt[]; modell?: string; demo?: boolean } | null>(null);
const [skannar, setSkannar] = useState(false);
const [skanningsFel, setSkanningsFel] = useState("");
const skanRef = useRef<HTMLInputElement>(null);
// Foto → dokumenttolkning i plattformens orkester. I lokalt läge (ingen
// inloggning) visas en tydligt märkt demo-tolkning så flödet kan provas.
const skanna = async (fil: File) => {
setSkannar(true);
setSkanningsFel("");
const foto = await skalaNerFoto(fil);
try {
const resultat = await tolkaArbetsorder(foto);
if (resultat) {
if (resultat.falt.length === 0) {
setSkanningsFel("Inga fält kunde läsas ur bilden — ta ett tydligare foto eller fyll i manuellt.");
} else {
setTolkning({ foto, falt: resultat.falt, modell: resultat.modell });
setSteg("granska");
}
} else {
setTolkning({ foto, falt: byggDemoTolkning(), demo: true });
setSteg("granska");
}
} catch {
setSkanningsFel("Dokumenttolkningen kunde inte nås — försök igen eller fyll i manuellt.");
}
setSkannar(false);
};
// Granskade fält → ärende: objektet byggs ur fordonsfälten, kundens
// felbeskrivning startar metodiken och hela tolkningen loggas som en
// organisationsintern händelse (delas aldrig i kund-/partnervyer).
const startaDiagnos = async (falt: TolkatFalt[]) => {
if (!tolkning) return;
setStartar(true);
const v = (id: string) => falt.find((f) => f.id === id && f.varde.trim())?.varde.trim();
const regnr = v("fordon_regnr");
const vin = v("fordon_vin");
const beskr = [v("fordon_marke"), v("fordon_modell"), v("fordon_arsmodell")].filter(Boolean).join(" ");
const objektet: Objekt = {
typ: inst.objekttyper.includes("Personbil") ? "Personbil" : inst.objekttyper[0],
identifierare: (regnr ?? vin ?? "OKÄND").toUpperCase(),
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
beskrivning: beskr || "Se arbetsorder",
kund: v("kund_namn"),
};
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
let metodikId: string | undefined;
if (valjMetodik(felText).id === "generisk") {
metodikId = (await valjMetodikMedAi(felText)) ?? undefined;
}
const id = skapaArende(objektet, felText, metodikId);
laggTill(id, {
typ: "arbetsorder_skannad",
falt: falt
.filter((f) => f.varde.trim())
.map(({ id: faltId, etikett, varde, konfidens }) => ({ id: faltId, etikett, varde, konfidens })),
dataUrl: tolkning.foto,
});
navigate(`/felsokning/arende/${id}`);
};
const objektet: Objekt = { const objektet: Objekt = {
typ, typ,
identifierare: identifierare.trim().toUpperCase(), identifierare: identifierare.trim().toUpperCase(),
@@ -40,6 +108,68 @@ export default function NyttArende() {
kund: kund.trim() || undefined, kund: kund.trim() || undefined,
}; };
if (steg === "start") {
return (
<FelsokningSkal rubrik="Starta ärende" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Skanna arbetsorder">
<p className="mb-3 text-[#333333]">
Fota arbetsorderns framsida. Systemet läser kund-, fordons- och ärendeuppgifter automatiskt
du granskar bara osäkra fält och trycker Starta diagnos.
</p>
<input
ref={skanRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) skanna(fil);
e.target.value = "";
}}
/>
{skannar ? (
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Tolkar dokumentet </p>
) : (
<StorKnapp onClick={() => skanRef.current?.click()}>📷 Skanna arbetsorder</StorKnapp>
)}
{skanningsFel && <p className="mt-2 font-semibold text-[#8B1A1A]">{skanningsFel}</p>}
</Panel>
<Panel>
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
Fyll i manuellt
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}
if (steg === "granska" && tolkning) {
return (
<FelsokningSkal
bred
rubrik="Granska tolkad arbetsorder"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
>
{tolkning.demo && (
<p className="mb-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
Demo-tolkning plattformens dokumenttolkning kräver inloggning. Värdena nedan är exempeldata.
</p>
)}
<GranskaTolkning
foto={tolkning.foto}
falt={tolkning.falt}
startar={startar}
vidStart={startaDiagnos}
avbryt={() => {
setTolkning(null);
setSteg("start");
}}
/>
</FelsokningSkal>
);
}
if (steg === "identifiera") { if (steg === "identifiera") {
return ( return (
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}> <FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
@@ -134,3 +264,127 @@ export default function NyttArende() {
</FelsokningSkal> </FelsokningSkal>
); );
} }
// Visuell granskning: den skannade arbetsordern till vänster, tolkade
// fält till höger. Konfidensen styr vad teknikern behöver göra:
// 🟢 ≥ 95 % godkänns automatiskt
// 🟡 8095 % markerad för genomläsning
// 🔴 < 80 % kräver aktiv bekräftelse (eller redigering)
// Klick på ett fält markerar var i dokumentet värdet hittades.
function GranskaTolkning({
foto,
falt,
startar,
vidStart,
avbryt,
}: {
foto: string;
falt: TolkatFalt[];
startar: boolean;
vidStart: (falt: TolkatFalt[]) => void;
avbryt: () => void;
}) {
const [rader, setRader] = useState(falt.map((f) => ({ ...f, bekraftad: f.konfidens >= 0.8 })));
const [markerad, setMarkerad] = useState<string | null>(null);
const obekraftade = rader.filter((r) => !r.bekraftad);
const vald = rader.find((r) => r.id === markerad);
const grupper: ArbetsorderGrupp[] = ["Kund", "Fordon", "Verkstad", "Ärende"];
const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) =>
setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad)));
return (
<div className="lg:grid lg:grid-cols-2 lg:items-start lg:gap-3">
<Panel rubrik="Skannad arbetsorder">
<div className="relative">
<img src={foto} alt="Skannad arbetsorder" className="w-full rounded border border-[#C6C6C6]" />
{vald?.omrade && (
<div
className="pointer-events-none absolute rounded-sm border-2 border-[#00437A] bg-[#00437A]/15"
style={{
left: `${vald.omrade.x * 100}%`,
top: `${vald.omrade.y * 100}%`,
width: `${vald.omrade.bredd * 100}%`,
height: `${vald.omrade.hojd * 100}%`,
}}
/>
)}
</div>
<p className="mt-1 text-[11px] text-[#707070]">
{vald?.omrade
? `Markerat: ${vald.etikett} (ungefärlig position)`
: "Klicka på ett fält för att se var i dokumentet värdet hittades."}
</p>
</Panel>
<div>
{grupper.map((grupp) => {
const iGrupp = rader.filter((r) => r.grupp === grupp);
if (iGrupp.length === 0) return null;
return (
<Panel key={grupp} rubrik={grupp}>
{iGrupp.map((rad) => {
const niva = rad.konfidens >= 0.95 ? "gron" : rad.konfidens >= 0.8 ? "gul" : "rod";
return (
<div
key={rad.id}
onClick={() => setMarkerad(rad.id)}
className={`mb-1.5 cursor-pointer rounded border p-2 ${
markerad === rad.id ? "border-[#00437A]" : "border-transparent"
} ${niva === "gul" ? "bg-[#FFF8E1]" : niva === "rod" ? "bg-[#FBE9E9]" : "bg-white"}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
{rad.etikett}
</span>
<span
className={`text-[11px] font-semibold ${
niva === "gron" ? "text-[#1E6B34]" : niva === "gul" ? "text-[#9A6700]" : "text-[#8B1A1A]"
}`}
>
{niva === "gron" ? "✓" : `${Math.round(rad.konfidens * 100)} %`}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<input
value={rad.varde}
onChange={(e) => uppdatera(rad.id, { varde: e.target.value, bekraftad: true })}
className="w-full rounded border border-[#ADADAD] bg-white px-2 py-1 text-[13px] focus:border-[#00437A] focus:outline-none"
/>
{niva === "rod" && !rad.bekraftad && (
<button
onClick={(e) => {
e.stopPropagation();
uppdatera(rad.id, { bekraftad: true });
}}
className="whitespace-nowrap rounded border border-[#6E1414] bg-[#8B1A1A] px-3 py-1 text-[12px] font-semibold text-white"
>
Bekräfta
</button>
)}
</div>
</div>
);
})}
</Panel>
);
})}
{obekraftade.length > 0 && (
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
{obekraftade.length} osäkra fält kräver bekräftelse eller korrigering innan diagnosen startar.
</p>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={avbryt}>
Skanna om
</StorKnapp>
<StorKnapp disabled={obekraftade.length > 0 || startar} onClick={() => vidStart(rader)}>
{startar ? "Startar …" : "Starta diagnos"}
</StorKnapp>
</div>
</div>
</div>
);
}
+83 -2
View File
@@ -63,6 +63,60 @@ const METODIK_SCHEMA = {
additionalProperties: false, 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 { interface UppgiftKonfig {
modell: string; modell: string;
// effort utelämnas för modeller som saknar parametern (Haiku 4.5). // effort utelämnas för modeller som saknar parametern (Haiku 4.5).
@@ -70,6 +124,8 @@ interface UppgiftKonfig {
maxTokens: number; maxTokens: number;
system: string; system: string;
schema: object; schema: object;
// Uppgiften tar en bifogad bild (data-URL) i stället för ren text.
bild?: boolean;
} }
// Orkestern: en modell per uppgiftstyp. // 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.`, Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA, 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> => { serve(async (req: Request): Promise<Response> => {
@@ -128,7 +194,7 @@ serve(async (req: Request): Promise<Response> => {
if (!apiNyckel) { if (!apiNyckel) {
return svara(503, { error: "AI-tjänsten är inte konfigurerad." }); 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]; const konfig = ORKESTER[uppgift as string];
if (!konfig) { if (!konfig) {
return svara(400, { error: "Okänd uppgift." }); 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." }); 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 klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({ const svar = await klient.beta.messages.create({
model: konfig.modell, model: konfig.modell,
@@ -153,7 +234,7 @@ serve(async (req: Request): Promise<Response> => {
betas: ["server-side-fallback-2026-07-01"], betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default", fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }], 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 // deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna
} as any); } as any);
@@ -59,7 +59,7 @@ as $$
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id) select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
from felsokning_handelser h from felsokning_handelser h
where h.arende_id = a.id 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) ), '[]'::jsonb)
) )
from felsokning_arenden a from felsokning_arenden a