diff --git a/docs/MVP.md b/docs/MVP.md index dbec8fc..e40af1b 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -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. | | 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. | +| Ä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, 🟡 80–95 % 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. | | 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`. | @@ -45,4 +46,4 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st ## 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. diff --git a/services/ai-orkester/server.mjs b/services/ai-orkester/server.mjs index c871c29..d8e4b6c 100644 --- a/services/ai-orkester/server.mjs +++ b/services/ai-orkester/server.mjs @@ -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 0–1: 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 0–1 (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." }); diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 2323786..bedf023 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -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"}') diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index cbd9d13..7721fde 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -567,6 +567,7 @@ components: - inaktivitet_forklarad - overlamning - ansvarig_satt + - arbetsorder_skannad - export_skapad - ai_svar - arende_avslutat diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 2b9acea..60286da 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -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)) diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index e3a14ba..ffcc516 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -32,7 +32,7 @@ export function DelatArendeVy({ const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); const kundposter = redanFiltrerad ? 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]; return ( diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 39a1a83..2013842 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; 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 { brief } from "../projektioner"; import { byggDemoArende } from "../demo"; @@ -10,7 +10,7 @@ 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:"]) { + for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:", "dokumenttolkning:"]) { expect(ENDPOINT).toContain(uppgift); } expect(ENDPOINT).toContain('"claude-sonnet-5"'); @@ -29,6 +29,7 @@ describe("AI-orkestern", () => { "granskning:", "sammanfattning:", "metodikval:", + "dokumenttolkning:", "Hitta aldrig på fakta", "SUPABASE_JWT_SECRET", ]) { @@ -85,7 +86,7 @@ describe("AI-orkestern", () => { // … 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", "arende_avslutat"]) { + for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arbetsorder_skannad", "arende_avslutat"]) { expect(spec).toContain(typ); } }); @@ -135,3 +136,45 @@ describe("AI-orkestern", () => { 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"); + } + }); +}); diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 2f7a0f8..777fb39 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -8,10 +8,11 @@ // klassificerat enligt AI-reglerna. // // Orkestern (serverns routing): -// - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation) -// - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget) -// - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter) -// - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning) +// - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation) +// - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget) +// - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter) +// - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning) +// - dokumenttolkning → Claude Sonnet 5 (vision: skannad arbetsorder → fält) import type { Arende } from "./domain"; import { handelseRubrik } from "./domain"; @@ -20,7 +21,7 @@ import { METODIKER } from "./metodik"; import type { 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"; @@ -89,13 +90,86 @@ export function tolkaAiSvar(data: unknown): AiSvar { 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; // 0–1 + // Ungefärlig position i den skannade bilden, normaliserad 0–1. + 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 0–1, +// 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[]) { + 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 // (services/ai-orkester); utan den används Supabase-edge-funktionen. 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 // 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, +): Promise<{ modell: string; svar: unknown } | null> { const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform"); let token: string | null = null; @@ -120,14 +194,14 @@ async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: str "Content-Type": "application/json", 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}`); resultat = await res.json(); } else { const { supabase } = await import("@/integrations/supabase/client"); const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", { - body: { uppgift, prompt }, + body: { uppgift, prompt, ...extra }, }); if (error) throw error; resultat = svar; diff --git a/src/felsokning/demo.ts b/src/felsokning/demo.ts index 9cfcc39..96a224a 100644 --- a/src/felsokning/demo.ts +++ b/src/felsokning/demo.ts @@ -4,6 +4,7 @@ // har verkligt innehåll direkt. import type { Arende, Handelse, LoggPost } from "./domain"; +import type { TolkatFalt } from "./ai"; import { nyLoggPost } from "./domain"; // 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, }; } + +// 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 } }, + ]; +} diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index 8f20a7c..8899c8b 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -37,9 +37,18 @@ export interface Objekt { 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; // 0–1 +} + // Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort. export type Handelse = | { typ: "objekt_identifierat"; objekt: Objekt } + | { typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[]; dataUrl?: string } | { typ: "felbeskrivning"; text: string } | { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: 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) { case "objekt_identifierat": return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`; + case "arbetsorder_skannad": + return `Arbetsorder skannad — ${h.falt.length} fält tolkade`; case "felbeskrivning": return `Felbeskrivning registrerad`; case "fraga_besvarad": diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 34b13be..f13305d 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -1014,7 +1014,7 @@ function RapportFlik({ // Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial // och ingår inte i det som delas med kund. 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: diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 6c4ae4c..a0abe3c 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; @@ -228,6 +228,12 @@ export default function Arendelista() { const [namn, setNamn] = useState(""); const [filter, setFilter] = useState("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) { return ( diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index 5c6f9b7..e257a60 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -1,21 +1,26 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { Objekt } from "@/felsokning/domain"; import { useFelsokning } from "@/felsokning/store"; 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 { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; +import { skalaNerFoto } from "@/felsokning/format"; -// Ingen felsökning börjar innan objektet identifierats och bekräftats. -// Vilka objekttyper och identifieringsmetoder som visas styrs av -// organisationens inställningar (systemadmin) — enhetens val i lokalt läge. +// Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna +// starta ett ärende på under 15 sekunder. Primärvägen är att fotografera +// 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() { const navigate = useNavigate(); const skapaArende = useFelsokning((s) => s.skapaArende); + const laggTill = useFelsokning((s) => s.laggTill); 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 [typ, setTyp] = useState(inst.objekttyper[0]); @@ -32,6 +37,69 @@ export default function NyttArende() { const [fel, setFel] = useState(""); 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(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 = { typ, identifierare: identifierare.trim().toUpperCase(), @@ -40,6 +108,68 @@ export default function NyttArende() { kund: kund.trim() || undefined, }; + if (steg === "start") { + return ( + + +

+ Fota arbetsorderns framsida. Systemet läser kund-, fordons- och ärendeuppgifter automatiskt — + du granskar bara osäkra fält och trycker Starta diagnos. +

+ { + const fil = e.target.files?.[0]; + if (fil) skanna(fil); + e.target.value = ""; + }} + /> + {skannar ? ( +

Tolkar dokumentet …

+ ) : ( + skanRef.current?.click()}>📷 Skanna arbetsorder + )} + {skanningsFel &&

{skanningsFel}

} +
+ + setSteg("identifiera")}> + Fyll i manuellt + + +
+ ); + } + + if (steg === "granska" && tolkning) { + return ( + + {tolkning.demo && ( +

+ Demo-tolkning — plattformens dokumenttolkning kräver inloggning. Värdena nedan är exempeldata. +

+ )} + { + setTolkning(null); + setSteg("start"); + }} + /> +
+ ); + } + if (steg === "identifiera") { return ( @@ -134,3 +264,127 @@ export default function NyttArende() { ); } + +// 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 +// 🟡 80–95 % 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(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 ( +
+ +
+ Skannad arbetsorder + {vald?.omrade && ( +
+ )} +
+

+ {vald?.omrade + ? `Markerat: ${vald.etikett} (ungefärlig position)` + : "Klicka på ett fält för att se var i dokumentet värdet hittades."} +

+ + +
+ {grupper.map((grupp) => { + const iGrupp = rader.filter((r) => r.grupp === grupp); + if (iGrupp.length === 0) return null; + return ( + + {iGrupp.map((rad) => { + const niva = rad.konfidens >= 0.95 ? "gron" : rad.konfidens >= 0.8 ? "gul" : "rod"; + return ( +
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"}`} + > +
+ + {rad.etikett} + + + {niva === "gron" ? "✓" : `${Math.round(rad.konfidens * 100)} %`} + +
+
+ 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 && ( + + )} +
+
+ ); + })} +
+ ); + })} + + {obekraftade.length > 0 && ( +

+ {obekraftade.length} osäkra fält kräver bekräftelse eller korrigering innan diagnosen startar. +

+ )} +
+ + Skanna om + + 0 || startar} onClick={() => vidStart(rader)}> + {startar ? "Startar …" : "Starta diagnos"} + +
+
+
+ ); +} diff --git a/supabase/functions/felsokning-ai/index.ts b/supabase/functions/felsokning-ai/index.ts index fd93d25..e13fdd8 100644 --- a/supabase/functions/felsokning-ai/index.ts +++ b/supabase/functions/felsokning-ai/index.ts @@ -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 0–1: 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 0–1 (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 => { @@ -128,7 +194,7 @@ serve(async (req: Request): Promise => { 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 => { 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 => { 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); diff --git a/supabase/migrations/20260802230000_guidad_felsokning.sql b/supabase/migrations/20260802230000_guidad_felsokning.sql index f811a2b..0c064b9 100644 --- a/supabase/migrations/20260802230000_guidad_felsokning.sql +++ b/supabase/migrations/20260802230000_guidad_felsokning.sql @@ -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