Ä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:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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." });
|
||||
|
||||
@@ -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"}')
|
||||
|
||||
@@ -567,6 +567,7 @@ components:
|
||||
- inaktivitet_forklarad
|
||||
- overlamning
|
||||
- ansvarig_satt
|
||||
- arbetsorder_skannad
|
||||
- export_skapad
|
||||
- ai_svar
|
||||
- arende_avslutat
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+82
-8
@@ -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<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
|
||||
// (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<string, unknown>,
|
||||
): 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;
|
||||
|
||||
@@ -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 } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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) {
|
||||
return (
|
||||
<FelsokningSkal rubrik="Guidad Felsökning">
|
||||
|
||||
@@ -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<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 = {
|
||||
typ,
|
||||
identifierare: identifierare.trim().toUpperCase(),
|
||||
@@ -40,6 +108,68 @@ export default function NyttArende() {
|
||||
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") {
|
||||
return (
|
||||
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
|
||||
@@ -134,3 +264,127 @@ export default function NyttArende() {
|
||||
</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
|
||||
// 🟡 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<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Response> => {
|
||||
@@ -128,7 +194,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
if (!apiNyckel) {
|
||||
return svara(503, { error: "AI-tjänsten är inte konfigurerad." });
|
||||
}
|
||||
const { uppgift, prompt } = await req.json();
|
||||
const { uppgift, prompt, bild } = await req.json();
|
||||
const konfig = ORKESTER[uppgift as string];
|
||||
if (!konfig) {
|
||||
return svara(400, { error: "Okänd uppgift." });
|
||||
@@ -140,6 +206,21 @@ serve(async (req: Request): Promise<Response> => {
|
||||
return svara(400, { error: "prompt är för lång." });
|
||||
}
|
||||
|
||||
// Bilduppgifter: bilden skickas som data-URL och blir ett image-block
|
||||
// före textprompten i samma användarmeddelande.
|
||||
let innehall: unknown = prompt;
|
||||
if (konfig.bild) {
|
||||
const matchning =
|
||||
typeof bild === "string" && bild.length <= MAX_BILD_LANGD
|
||||
? bild.match(/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/)
|
||||
: null;
|
||||
if (!matchning) return svara(400, { error: "bild saknas eller har fel format." });
|
||||
innehall = [
|
||||
{ type: "image", source: { type: "base64", media_type: matchning[1], data: matchning[2] } },
|
||||
{ type: "text", text: prompt },
|
||||
];
|
||||
}
|
||||
|
||||
const klient = new Anthropic({ apiKey: apiNyckel });
|
||||
const svar = await klient.beta.messages.create({
|
||||
model: konfig.modell,
|
||||
@@ -153,7 +234,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
betas: ["server-side-fallback-2026-07-01"],
|
||||
fallbacks: "default",
|
||||
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
messages: [{ role: "user", content: innehall }],
|
||||
// deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna
|
||||
} as any);
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ as $$
|
||||
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
|
||||
from felsokning_handelser h
|
||||
where h.arende_id = a.id
|
||||
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt')
|
||||
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar', 'ansvarig_satt', 'arbetsorder_skannad')
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
from felsokning_arenden a
|
||||
|
||||
Reference in New Issue
Block a user