From e8cf6db236477da1f96fd6e9faf8b0b874bc0747 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 3 Aug 2026 09:05:04 +0000
Subject: [PATCH] Evidensmotor (ECM v1.0): ingen slutsats utan underlag
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Regelmotorn kodar plattformens viktigaste princip: systemet får aldrig
anta att en kontroll är utförd eller att dokumentation finns. Varje
påstående måste kunna härledas till evidens i händelseloggen.
- Nytt versionshanterat regelbibliotek (src/felsokning/ecm.ts, ECM v1.0):
evidensnivåer E0–E6 härledda ur loggen, fullbordansregler och
kvalitetsgrind — skilt från applikationslogiken
- Fullbordansregel i guiden: en kontroll slutförs med evidens ELLER
uttryckligt undantag "Underlag kan inte tas fram" med obligatorisk
orsak — loggas och flaggas ⚠ i brief, överlämning och rapport
- Kvalitetsgrind före slutrapport: utskriften spärrad tills
objektidentifiering, metodikens kontroller, fotokrav och evidensnivå
är gröna; varje röd rad visar exakt vad som saknas
- Orkesterns grundprompt utökad: aldrig "OK/kontrollerad/inga fel" utan
evidens — skriv "Evidens saknas" och begär rätt underlag (foto/video/
mätvärde/skärmfoto)
- Visual-first instrumentavläsning: ny vision-uppgift läser multimetrar,
diagnosskärmar m.m. — värden/enheter/felkoder med konfidens, teknikern
bekräftar, originalbilden loggas alltid bredvid strukturerad data;
kameran är integrationslagret, inga verktygsintegrationer krävs
- Terminologi: "AI" ersatt med systemspråk i hela gränssnittet
(Beslutsstöd, Systemet analyserar, Granskning av underlaget …)
- Dokumentation: docs/moduler/evidensmotor.md; 41 vitest-tester
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
---
docs/MVP.md | 6 +-
docs/moduler/evidensmotor.md | 108 ++++++++++
services/ai-orkester/server.mjs | 44 ++++
src/felsokning/__tests__/ecm.test.ts | 108 ++++++++++
src/felsokning/ai.ts | 55 ++++-
src/felsokning/demo.ts | 14 +-
src/felsokning/domain.ts | 5 +-
src/felsokning/ecm.ts | 161 +++++++++++++++
src/felsokning/projektioner.ts | 7 +-
src/pages/felsokning/ArendeSida.tsx | 233 ++++++++++++++++++++--
src/pages/felsokning/Arendelista.tsx | 8 +-
supabase/functions/felsokning-ai/index.ts | 44 ++++
12 files changed, 761 insertions(+), 32 deletions(-)
create mode 100644 docs/moduler/evidensmotor.md
create mode 100644 src/felsokning/__tests__/ecm.test.ts
create mode 100644 src/felsokning/ecm.ts
diff --git a/docs/MVP.md b/docs/MVP.md
index e40af1b..0decc5b 100644
--- a/docs/MVP.md
+++ b/docs/MVP.md
@@ -34,7 +34,9 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| 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. |
+| Evidensmotor (ECM) | ✅ Versionshanterat regelbibliotek ([moduler/evidensmotor.md](moduler/evidensmotor.md), `src/felsokning/ecm.ts`, ECM v1.0): evidensnivåer E0–E6 härledda ur loggen, fullbordansregeln *evidens eller dokumenterat undantag med obligatorisk orsak* ("Underlag kan inte tas fram" i guiden, flaggas ⚠ i brief/rapport), och **kvalitetsgrind före slutrapport** — utskrift spärrad tills objektidentifiering, kontroller, fotokrav och evidensnivå är gröna. Regeln "skriv aldrig OK/kontrollerad/inga fel utan evidens — skriv Evidens saknas" är kodad i orkesterns grundprompt. |
+| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
+| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
| Ö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`. |
## Arkitekturprinciper i koden
@@ -42,6 +44,8 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
- **Händelseloggen är enda sanningskällan.** `src/felsokning/domain.ts` definierar händelsetyperna; poster läggs endast till.
- **Alla vyer är projektioner.** `src/felsokning/projektioner.ts` — brief, tidsfördelning, överlämningstext och kundrapport är rena funktioner av loggen och kan alltid regenereras. Testerna i `src/felsokning/__tests__/` låser detta.
- **Metodikmotorn är deterministisk.** `src/felsokning/metodik.ts` — nästa steg härleds ur vad som redan dokumenterats. Det är här den framtida AI:n ansluter, utan att logg eller projektioner ändras.
+- **Ingen slutsats utan evidens.** `src/felsokning/ecm.ts` — regelmotorn (ECM) validerar varje påstående mot händelseloggen: fullbordansregler, evidensnivåer och kvalitetsgrind. Kameran är integrationslagret (visual-first) — det som syns på en skärm eller ett instrument fotograferas och tolkas i stället för att integreras.
+- **Terminologi.** Produkten beskrivs som ett evidensbaserat diagnossystem/intelligent beslutsstöd — i UI och kundkommunikation används *systemet/analysen/bedömningen/beslutsstödet*, aldrig "AI" om det inte är tekniskt nödvändigt.
- **Industriellt verkstads-UI (ETKA-inspirerat).** `src/felsokning/ui.tsx` — plana ljusgrå ytor (#ECECEC/#F7F7F7), skarpa kanter, djup marinblå som primärfärg, tät typografi (11–15 px), rektangulära knappar (max 4 px radie), verktygsrad ~44 px. Ärendesidan har klassisk trekolumnslayout på skrivbord: navigationsträd (vyer + metodikstegens status) till vänster, arbetsyta i mitten, kontextpanel (teknisk information, tillförlitlighet, teknisk rekommendation) till höger; en kolumn med flikrad på smala skärmar.
## Medvetna avgränsningar
diff --git a/docs/moduler/evidensmotor.md b/docs/moduler/evidensmotor.md
new file mode 100644
index 0000000..8e7f64e
--- /dev/null
+++ b/docs/moduler/evidensmotor.md
@@ -0,0 +1,108 @@
+# Modul: Evidensmotorn (ECM — Evidence & Compliance Matrix)
+
+**Version: ECM v1.0** · Regelbiblioteket är versionshanterat och skilt från
+applikationslogiken (`src/felsokning/ecm.ts`). Vyerna anropar bara motorns
+rena funktioner — regler kan uppdateras utan att applikationen byggs om.
+
+## Grundprincip
+
+**Inget underlag = ingen slutsats.**
+
+Systemet får aldrig anta att en kontroll är utförd eller påstå att
+dokumentation finns om den inte faktiskt är insamlad. Varje påstående i
+diagnos, brief och slutrapport ska kunna härledas till minst en
+evidenspost i händelseloggen. Systemet skriver aldrig "OK",
+"kontrollerad", "inga fel" eller "åtgärdad" utan evidens — i stället:
+**"Evidens saknas."** Regeln är kodad i orkesterns grundprompt och kan
+inte kringgås från klienten.
+
+## Evidensnivåer
+
+| Nivå | Typ | Bevisvärde |
+| --- | --- | --- |
+| E0 | Inget underlag | 0 % |
+| E1 | Teknikerns observation | Lågt |
+| E2 | Foto | Medel |
+| E3 | Video | Högt |
+| E4 | Mätvärde | Högt |
+| E5 | Diagnosdata/dokument (t.ex. skannad arbetsorder) | Mycket högt |
+| E6 | Flera oberoende källor | Högsta |
+
+Ärendets nivå härleds ur loggens faktiska innehåll (`evidensNiva`) och
+visas i kvalitetsgrinden.
+
+## Fullbordansregler
+
+En kontrollpunkt kan bara slutföras när något av följande är sant:
+
+1. **Krävd evidens är insamlad** — foto för det synliga, mätvärde för det
+ som mäts, kommentar där inget bättre är möjligt (metodikens
+ `krav`-fält per kontroll).
+2. **Teknikern dokumenterar ett undantag**: *"Underlag kan inte tas
+ fram"* med **obligatorisk orsak** (komponenten oåtkomlig, fordonet kan
+ inte lyftas säkert, kunden avböjde demontering, dålig sikt, utrustning
+ saknas — eller fri text). Undantaget loggas i händelseloggen och
+ flaggas ⚠ i brief, överlämning och rapport — det redovisas aldrig som
+ "utförd".
+
+## Kvalitetsgrind före slutrapport
+
+Slutrapporten kan inte genereras förrän grinden är godkänd
+(`kvalitetsgrind`/`grindGodkand`):
+
+| Kontroll | Krav |
+| --- | --- |
+| Fordons-/objektidentifiering verifierad | Obligatorisk |
+| Arbetsorder inläst | Rekommenderas |
+| Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk |
+| Foton finns för fotokrävande kontroller | Obligatorisk |
+| Hypoteser redovisas som ej verifierade | Informativ (alltid sant per konstruktion) |
+| Evidensnivå över E0 | Obligatorisk |
+
+Utskriftsknappen är spärrad tills varje obligatorisk rad är grön; varje
+röd rad visar exakt vad som saknas.
+
+## Visual-first: kameran är integrationslagret
+
+Plattformen prioriterar visuell insamling framför systemintegrationer.
+När information redan visas på en skärm, ett instrument, en utskrift
+eller en etikett fotograferas den — bildtolkningen extraherar, validerar
+och strukturerar informationen automatiskt. Ingen specialintegration
+behövs mot Bosch, TEXA, Autel, Launch, Hella Gutmann m.fl. så länge en
+människa kan läsa informationen.
+
+- **Arbetsorder** → ärendestartens dokumenttolkning (fält + konfidens).
+- **Instrument/diagnosskärmar** → `📷 Instrument` i Dokumentera-panelen:
+ foto → typidentifiering (multimeter, diagnosdator, batteritestare,
+ mätarkluster, manometer …) → värden med enhet och konfidens → teknikern
+ bekräftar → **originalbilden loggas alltid tillsammans med de
+ strukturerade värdena** — strukturerad data ersätter aldrig
+ originalevidensen.
+
+Värden med hög säkerhet godkänns automatiskt; osäkra markeras för
+granskning — samma konfidensmodell som arbetsorderskanningen
+(🟢 ≥95 %, 🟡 80–95 %, 🔴 <80 %).
+
+## Spårbarhet
+
+Varje evidenspost är en händelse i den append-only-loggen med tidpunkt,
+tekniker, ärende och innehåll — omöjlig att ändra i efterhand (triggers i
+databasen). Det ger varje slutsats juridiskt spårbart underlag för kund,
+försäkringsbolag eller domstol.
+
+## Terminologi
+
+Produkten beskrivs aldrig som en "AI-app" utan som ett **evidensbaserat
+diagnossystem** / **intelligent beslutsstöd**. I användargränssnitt och
+dokument används *systemet, analysen, bedömningen, tolkningen,
+bildtolkningen, beslutsstödet, regelmotorn* — inte "AI", om det inte är
+tekniskt nödvändigt (t.ex. i arkitekturdokumentation om modellorkestern).
+
+## Kommande (regelbibliotekets väg framåt)
+
+- Områdesregler per komponent (däck: fyra bilder + DOT + dimension;
+ bromsar: närbild per ok; motorljud: video med ljud …) som
+ serverdistribuerade, versionerade regelpaket.
+- Video- och ljudevidens (E3) med analys.
+- Garanti-/försäkrings-/reklamationsprofiler med egna obligatoriska
+ fält i kvalitetsgrinden.
diff --git a/services/ai-orkester/server.mjs b/services/ai-orkester/server.mjs
index d8e4b6c..739dec5 100644
--- a/services/ai-orkester/server.mjs
+++ b/services/ai-orkester/server.mjs
@@ -32,6 +32,7 @@ Absoluta regler:
- "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
- "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
+- Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen.
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
const SVARS_SCHEMA = {
@@ -116,6 +117,40 @@ Regler:
- 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.`;
+
+// Visual-first: instrument och diagnosskärmar fotograferas i stället för
+// att integreras — kameran är det universella gränssnittet.
+const INSTRUMENT_SCHEMA = {
+ type: "object",
+ properties: {
+ instrumenttyp: { type: "string" },
+ varden: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ beskrivning: { type: "string" },
+ varde: { type: "string" },
+ enhet: { type: "string" },
+ konfidens: { type: "number", minimum: 0, maximum: 1 },
+ },
+ required: ["beskrivning", "varde", "konfidens"],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ["instrumenttyp", "varden"],
+ additionalProperties: false,
+};
+
+const INSTRUMENT_REGLER = `Du läser av ett foto av ett instrument eller en skärm i en fordonsverkstad: multimeter, diagnosdator, oscilloskop, batteritestare, mätarkluster, manometer, utskrift eller etikett.
+
+Regler:
+- Identifiera instrumenttypen och extrahera ENDAST värden som faktiskt går att läsa i bilden. Hitta aldrig på värden.
+- Varje värde får en beskrivning (t.ex. "Batterispänning", "Felkod P0301 – misständning cylinder 1"), värdet som text och enhet när den syns (V, A, Ω, bar, °C, rpm …).
+- Felkoder returneras som egna värden med kod och klartext om skärmen visar den.
+- "konfidens" är din läs-säkerhet 0–1; sänk vid oskarp bild, reflexer eller delvis skymda siffror.`;
+
// Orkestern: en modell per uppgiftstyp (samma routing som edge-funktionen).
const ORKESTER = {
handledning: {
@@ -151,6 +186,15 @@ Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik
},
// Ärendestart: teknikern fotograferar arbetsordern; vi läser dokumentet
// och returnerar strukturerade fält med konfidens per värde.
+ // Visual-first: foto av instrument/diagnosskärm → strukturerade värden.
+ instrumentavlasning: {
+ modell: "claude-sonnet-5",
+ effort: "low",
+ maxTokens: 1024,
+ system: INSTRUMENT_REGLER,
+ schema: INSTRUMENT_SCHEMA,
+ bild: true,
+ },
dokumenttolkning: {
modell: "claude-sonnet-5",
effort: "low",
diff --git a/src/felsokning/__tests__/ecm.test.ts b/src/felsokning/__tests__/ecm.test.ts
new file mode 100644
index 0000000..125c88b
--- /dev/null
+++ b/src/felsokning/__tests__/ecm.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import type { Arende, Handelse } from "../domain";
+import { nyLoggPost } from "../domain";
+import { VIBRATION_METODIK } from "../metodik";
+import { evidensNiva, grindGodkand, kvalitetsgrind } from "../ecm";
+import { byggDemoArende } from "../demo";
+
+function byggArende(handelser: Handelse[]): Arende {
+ return {
+ id: "ecm-test",
+ nummer: 1,
+ skapad: "2026-08-03T08:00:00Z",
+ handelser: handelser.map((h) => nyLoggPost("Anna", h, "2026-08-03T08:00:00Z")),
+ };
+}
+
+const OBJEKT: Handelse = {
+ typ: "objekt_identifierat",
+ objekt: { typ: "Personbil", identifierare: "ABC123", identifieringsmetod: "Regnr", beskrivning: "Volvo XC60" },
+};
+
+describe("evidensnivåer", () => {
+ it("härleder nivån ur loggens faktiska innehåll", () => {
+ expect(evidensNiva(byggArende([]))).toBe("E0");
+ expect(evidensNiva(byggArende([{ typ: "observation", text: "x" }]))).toBe("E1");
+ expect(evidensNiva(byggArende([{ typ: "foto", beskrivning: "x", dataUrl: "data:" }]))).toBe("E2");
+ expect(evidensNiva(byggArende([{ typ: "matvarde", beskrivning: "U", varde: "12" }]))).toBe("E4");
+ expect(evidensNiva(byggArende([{ typ: "arbetsorder_skannad", falt: [] }]))).toBe("E5");
+ expect(
+ evidensNiva(
+ byggArende([
+ { typ: "foto", beskrivning: "x", dataUrl: "data:" },
+ { typ: "matvarde", beskrivning: "U", varde: "12" },
+ ]),
+ ),
+ ).toBe("E6");
+ });
+});
+
+describe("kvalitetsgrind före slutrapport", () => {
+ it("utan evidens är grinden stängd — inga påståenden utan underlag", () => {
+ const tomt = byggArende([]);
+ expect(grindGodkand(tomt, VIBRATION_METODIK)).toBe(false);
+ const rader = kvalitetsgrind(tomt, VIBRATION_METODIK);
+ expect(rader.find((r) => r.id === "objekt")?.ok).toBe(false);
+ expect(rader.find((r) => r.id === "kontroller")?.ok).toBe(false);
+ expect(rader.find((r) => r.id === "kontroller")?.detalj).toContain("Evidens saknas");
+ });
+
+ it("ett dokumenterat undantag räknas som hanterat men flaggas med orsak", () => {
+ // Alla kontroller får undantag med orsak — grinden öppnas, men
+ // undantagen redovisas i detaljraden (aldrig som "utförd").
+ const undantag: Handelse[] = [OBJEKT, { typ: "foto", beskrivning: "Översikt", dataUrl: "data:" }, { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" }];
+ for (const steg of VIBRATION_METODIK.steg) {
+ for (const kontroll of steg.kontroller ?? []) {
+ undantag.push({
+ typ: "kontroll_utford",
+ stegId: steg.id,
+ kontrollId: kontroll.id,
+ text: kontroll.text,
+ undantag: "Kunden avböjde demontering",
+ });
+ }
+ }
+ const arende = byggArende(undantag);
+ expect(grindGodkand(arende, VIBRATION_METODIK)).toBe(true);
+ const rad = kvalitetsgrind(arende, VIBRATION_METODIK).find((r) => r.id === "kontroller");
+ expect(rad?.ok).toBe(true);
+ expect(rad?.detalj).toContain("Undantag med orsak");
+ expect(rad?.detalj).toContain("Kunden avböjde demontering");
+ });
+
+ it("demoärendet blockeras tills alla kontroller har evidens eller undantag", () => {
+ const demo = byggDemoArende(1);
+ expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
+
+ const komplett: Arende = {
+ ...demo,
+ handelser: [...demo.handelser],
+ };
+ const hanterade = new Set(
+ demo.handelser
+ .filter((p) => p.handelse.typ === "kontroll_utford")
+ .map((p) => {
+ const h = p.handelse;
+ return h.typ === "kontroll_utford" ? `${h.stegId}/${h.kontrollId}` : "";
+ }),
+ );
+ for (const steg of VIBRATION_METODIK.steg) {
+ for (const kontroll of steg.kontroller ?? []) {
+ if (!hanterade.has(`${steg.id}/${kontroll.id}`)) {
+ komplett.handelser.push(
+ nyLoggPost("Johan", {
+ typ: "kontroll_utford",
+ stegId: steg.id,
+ kontrollId: kontroll.id,
+ text: kontroll.text,
+ undantag: "Utrustning saknas",
+ }),
+ );
+ }
+ }
+ }
+ expect(grindGodkand(komplett, VIBRATION_METODIK)).toBe(true);
+ // Demoärendet har foto + mätvärden + flera källor → högsta evidensnivån.
+ expect(evidensNiva(komplett)).toBe("E6");
+ });
+});
diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts
index 777fb39..6a930f1 100644
--- a/src/felsokning/ai.ts
+++ b/src/felsokning/ai.ts
@@ -21,7 +21,13 @@ import { METODIKER } from "./metodik";
import type { Brief } from "./projektioner";
import { brief } from "./projektioner";
-export type AiUppgift = "handledning" | "granskning" | "sammanfattning" | "metodikval" | "dokumenttolkning";
+export type AiUppgift =
+ | "handledning"
+ | "granskning"
+ | "sammanfattning"
+ | "metodikval"
+ | "dokumenttolkning"
+ | "instrumentavlasning";
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
@@ -159,6 +165,53 @@ export async function tolkaArbetsorder(
return resultat ? { falt: normaliseraArbetsorder(resultat.svar), modell: resultat.modell } : null;
}
+// ---- Instrumentavläsning (visual-first) -------------------------------
+// Kameran är det universella gränssnittet: när ett instrument, en
+// diagnosdator eller ett dokument visar information fotograferas det —
+// systemet extraherar värden, enheter och felkoder med konfidens per
+// värde. Originalbilden bevaras alltid som evidens.
+
+export interface InstrumentVarde {
+ beskrivning: string;
+ varde: string;
+ enhet?: string;
+ konfidens: number; // 0–1
+}
+
+export interface InstrumentTolkning {
+ instrumenttyp: string;
+ varden: InstrumentVarde[];
+}
+
+export function normaliseraInstrument(data: unknown): InstrumentTolkning {
+ const d = data as Partial;
+ if (!d || typeof d.instrumenttyp !== "string" || !Array.isArray(d.varden)) {
+ throw new Error("Oväntat avläsningsformat");
+ }
+ const varden: InstrumentVarde[] = [];
+ for (const rad of d.varden as Partial[]) {
+ if (typeof rad?.beskrivning !== "string" || typeof rad.varde !== "string" || !rad.varde.trim()) continue;
+ varden.push({
+ beskrivning: rad.beskrivning.trim(),
+ varde: rad.varde.trim(),
+ enhet: typeof rad.enhet === "string" && rad.enhet.trim() ? rad.enhet.trim() : undefined,
+ konfidens: typeof rad.konfidens === "number" ? Math.min(1, Math.max(0, rad.konfidens)) : 0,
+ });
+ }
+ return { instrumenttyp: d.instrumenttyp.trim() || "instrument", varden };
+}
+
+export async function lasAvInstrument(
+ dataUrl: string,
+): Promise<{ tolkning: InstrumentTolkning; modell: string } | null> {
+ const resultat = await anropa(
+ "instrumentavlasning",
+ "Läs av instrumentet/skärmen i den bifogade bilden.",
+ { bild: dataUrl },
+ );
+ return resultat ? { tolkning: normaliseraInstrument(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(/\/$/, "");
diff --git a/src/felsokning/demo.ts b/src/felsokning/demo.ts
index 96a224a..1c8a86b 100644
--- a/src/felsokning/demo.ts
+++ b/src/felsokning/demo.ts
@@ -4,7 +4,7 @@
// har verkligt innehåll direkt.
import type { Arende, Handelse, LoggPost } from "./domain";
-import type { TolkatFalt } from "./ai";
+import type { InstrumentTolkning, TolkatFalt } from "./ai";
import { nyLoggPost } from "./domain";
// Enkla platshållarbilder ritade i canvas — ersätts av riktiga foton så
@@ -139,3 +139,15 @@ export function byggDemoTolkning(): TolkatFalt[] {
{ 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 } },
];
}
+
+// Demo-avläsning av ett instrument — används i lokalt läge där
+// bildtolkningen inte kan nås. Märks alltid som demo i UI:t.
+export function byggDemoInstrument(): InstrumentTolkning {
+ return {
+ instrumenttyp: "multimeter",
+ varden: [
+ { beskrivning: "Batterispänning (vila)", varde: "12,4", enhet: "V", konfidens: 0.97 },
+ { beskrivning: "Spänning vid start", varde: "9,8", enhet: "V", konfidens: 0.84 },
+ ],
+ };
+}
diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts
index 8899c8b..08dc1ea 100644
--- a/src/felsokning/domain.ts
+++ b/src/felsokning/domain.ts
@@ -51,7 +51,9 @@ export type Handelse =
| { 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 }
+ // undantag: underlaget kunde inte tas fram — obligatorisk orsak i stället
+ // för evidens. Kontrollen räknas som hanterad men flaggas i brief/rapport.
+ | { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string; undantag?: string }
| { typ: "observation"; text: string }
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
| { typ: "hypotes"; text: string; niva: Exclude }
@@ -114,6 +116,7 @@ export function handelseRubrik(post: LoggPost): string {
case "fraga_besvarad":
return `${h.fraga} — ${h.svar}`;
case "kontroll_utford":
+ if (h.undantag) return `${h.text} — underlag kunde inte tas fram: ${h.undantag}`;
return h.resultat ? `${h.text} — ${h.resultat}` : `${h.text} — utförd`;
case "observation":
return `Observation: ${h.text}`;
diff --git a/src/felsokning/ecm.ts b/src/felsokning/ecm.ts
new file mode 100644
index 0000000..995b9bf
--- /dev/null
+++ b/src/felsokning/ecm.ts
@@ -0,0 +1,161 @@
+// Evidence & Compliance Matrix (ECM) — plattformens regelmotor.
+//
+// Grundprincip: varje påstående måste kunna härledas till evidens i
+// händelseloggen. Systemet får aldrig skriva "kontrollerad", "OK" eller
+// "inga fel" utan underlag — utan evidens skrivs "Evidens saknas".
+//
+// Regelbiblioteket är versionshanterat och skilt från applikationslogiken:
+// evidensnivåer, krav och kvalitetsgrind ändras här (och i kommande
+// versioner via serverdistribuerade regler) utan att vyerna byggs om.
+// Vyerna anropar bara de rena funktionerna nedan.
+
+import type { Arende } from "./domain";
+import type { Metodik } from "./metodik";
+
+export const ECM_VERSION = "1.0";
+
+// Evidensnivåer: bevisvärdet för ett påstående, härlett ur loggen.
+// E0 inget underlag · E1 teknikerns observation · E2 foto · E3 video
+// E4 mätvärde · E5 diagnosdata/dokument · E6 flera oberoende källor
+export type EvidensNiva = "E0" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
+
+export const EVIDENS_LABEL: Record = {
+ E0: "E0 · Inget underlag",
+ E1: "E1 · Teknikerns observation",
+ E2: "E2 · Foto",
+ E3: "E3 · Video",
+ E4: "E4 · Mätvärde",
+ E5: "E5 · Diagnosdata/dokument",
+ E6: "E6 · Flera oberoende källor",
+};
+
+// Godkända orsaker när underlag inte kan tas fram. Fri text tillåts
+// också — men en orsak är alltid obligatorisk.
+export const UNDANTAGSORSAKER = [
+ "Komponenten är oåtkomlig",
+ "Fordonet kan inte lyftas säkert",
+ "Kunden avböjde demontering",
+ "Dålig sikt/åtkomst",
+ "Utrustning saknas",
+];
+
+// Evidensnivå för ärendet som helhet: den starkaste kombination som
+// loggen faktiskt innehåller.
+export function evidensNiva(arende: Arende): EvidensNiva {
+ let foto = false;
+ let matvarde = false;
+ let dokument = false;
+ let observation = false;
+ for (const post of arende.handelser) {
+ const h = post.handelse;
+ if (h.typ === "foto") foto = true;
+ if (h.typ === "matvarde") matvarde = true;
+ if (h.typ === "arbetsorder_skannad") dokument = true;
+ if (h.typ === "observation" || h.typ === "kontroll_utford") observation = true;
+ }
+ const kallor = [foto, matvarde, dokument].filter(Boolean).length;
+ if (kallor >= 2) return "E6";
+ if (dokument) return "E5";
+ if (matvarde) return "E4";
+ if (foto) return "E2";
+ if (observation) return "E1";
+ return "E0";
+}
+
+export interface GrindRad {
+ id: string;
+ rubrik: string;
+ ok: boolean;
+ // Obligatorisk: blockerar slutrapporten tills den är grön.
+ kravs: boolean;
+ detalj?: string;
+}
+
+// Kvalitetsgrind före slutrapport: varje rad är en verifierbar kontroll
+// mot händelseloggen. Rapporten kan inte genereras förrän alla
+// obligatoriska rader är gröna — evidens eller dokumenterat undantag.
+export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
+ const rader: GrindRad[] = [];
+ const handelser = arende.handelser.map((p) => p.handelse);
+
+ const objektFinns = handelser.some((h) => h.typ === "objekt_identifierat");
+ rader.push({
+ id: "objekt",
+ rubrik: "Fordons-/objektidentifiering verifierad",
+ ok: objektFinns,
+ kravs: true,
+ detalj: objektFinns ? undefined : "Evidens saknas — identifiera objektet.",
+ });
+
+ const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad");
+ rader.push({
+ id: "arbetsorder",
+ rubrik: "Arbetsorder inläst",
+ ok: arbetsorder,
+ kravs: false,
+ detalj: arbetsorder ? undefined : "Ärendet startades utan skannad arbetsorder.",
+ });
+
+ // Metodikens kontroller: evidens eller dokumenterat undantag per kontroll.
+ const hanterade = new Map();
+ for (const h of handelser) {
+ if (h.typ === "kontroll_utford") hanterade.set(`${h.stegId}/${h.kontrollId}`, { undantag: h.undantag });
+ }
+ const fotonFinns = handelser.filter((h) => h.typ === "foto").length;
+ const saknade: string[] = [];
+ const undantagna: string[] = [];
+ let fotoKravUtanFoto = 0;
+ for (const steg of metodik.steg) {
+ for (const kontroll of steg.kontroller ?? []) {
+ const status = hanterade.get(`${steg.id}/${kontroll.id}`);
+ if (!status) saknade.push(kontroll.text);
+ else if (status.undantag) undantagna.push(`${kontroll.text} (${status.undantag})`);
+ else if (kontroll.krav === "foto" && fotonFinns === 0) fotoKravUtanFoto += 1;
+ }
+ }
+ rader.push({
+ id: "kontroller",
+ rubrik: "Metodikens kontroller: evidens eller dokumenterat undantag",
+ ok: saknade.length === 0,
+ kravs: true,
+ detalj:
+ saknade.length > 0
+ ? `Evidens saknas: ${saknade.join("; ")}`
+ : undantagna.length > 0
+ ? `Undantag med orsak: ${undantagna.join("; ")}`
+ : undefined,
+ });
+
+ rader.push({
+ id: "fotokrav",
+ rubrik: "Foton finns för fotokrävande kontroller",
+ ok: fotoKravUtanFoto === 0,
+ kravs: true,
+ detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined,
+ });
+
+ const hypoteser = handelser.filter((h) => h.typ === "hypotes").length;
+ rader.push({
+ id: "hypoteser",
+ rubrik: "Hypoteser redovisas som ej verifierade",
+ ok: true,
+ kravs: false,
+ detalj: hypoteser > 0 ? `${hypoteser} hypotes(er) markeras 🔴 i rapporten — aldrig som konstaterade fel.` : undefined,
+ });
+
+ rader.push({
+ id: "evidensniva",
+ rubrik: `Evidensnivå: ${EVIDENS_LABEL[evidensNiva(arende)]}`,
+ ok: evidensNiva(arende) !== "E0",
+ kravs: true,
+ detalj: evidensNiva(arende) === "E0" ? "Ingen evidens i loggen ännu." : undefined,
+ });
+
+ return rader;
+}
+
+// Sant när alla obligatoriska grindrader är gröna — först då kan
+// slutrapporten genereras.
+export function grindGodkand(arende: Arende, metodik: Metodik): boolean {
+ return kvalitetsgrind(arende, metodik).every((rad) => rad.ok || !rad.kravs);
+}
diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts
index 20d60e1..26861de 100644
--- a/src/felsokning/projektioner.ts
+++ b/src/felsokning/projektioner.ts
@@ -40,6 +40,8 @@ export function arAvslutat(arende: Arende): boolean {
export interface UtfordKontroll {
text: string;
resultat?: string;
+ // Satt när underlaget inte kunde tas fram (dokumenterad orsak).
+ undantag?: string;
tidpunkt: string;
anvandare: string;
}
@@ -49,7 +51,7 @@ export function utfordaKontroller(arende: Arende): UtfordKontroll[] {
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "kontroll_utford") {
- resultat.push({ text: h.text, resultat: h.resultat, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
+ resultat.push({ text: h.text, resultat: h.resultat, undantag: h.undantag, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
}
}
return resultat;
@@ -226,7 +228,8 @@ export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string):
rader.push("");
rader.push("Utförda kontroller:");
if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
- for (const k of b.utfordaKontroller) rader.push(` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`);
+ for (const k of b.utfordaKontroller)
+ rader.push(k.undantag ? ` ⚠ ${k.text} — underlag saknas: ${k.undantag}` : ` ✓ ${k.text}${k.resultat ? ` — ${k.resultat}` : ""}`);
rader.push("");
rader.push("Observationer:");
if (b.observationer.length === 0) rader.push(" (inga ännu)");
diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx
index f13305d..a52673e 100644
--- a/src/pages/felsokning/ArendeSida.tsx
+++ b/src/pages/felsokning/ArendeSida.tsx
@@ -26,7 +26,16 @@ import {
type Delning,
type DelningsNiva,
} from "@/felsokning/plattform";
-import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai";
+import {
+ AI_RADTYP_LABEL,
+ fragaAi,
+ granskaUnderlag,
+ lasAvInstrument,
+ sammanfattaOverlamning,
+ type InstrumentTolkning,
+} from "@/felsokning/ai";
+import { byggDemoInstrument } from "@/felsokning/demo";
+import { ECM_VERSION, UNDANTAGSORSAKER, grindGodkand, kvalitetsgrind } from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -273,7 +282,7 @@ function KontextPanel({
Nästa steg: {senasteAi.handelse.nastaSteg}
- AI-underlag · presenteras aldrig som konstaterat fel
+ Beslutsstöd · presenteras aldrig som konstaterat fel
)}
@@ -387,13 +396,13 @@ function GuideFlik({
- {aiStatus === "arbetar" && AI analyserar …
}
+ {aiStatus === "arbetar" && Systemet analyserar …
}
{aiStatus === "fel" && (
- AI-svaret kunde inte hämtas — försök igen. Metodiken fortsätter som vanligt.
+ Analysen kunde inte hämtas — försök igen. Metodiken fortsätter som vanligt.
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
@@ -560,14 +569,74 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
>
)}
+
+ skicka({
+ typ: "kontroll_utford",
+ stegId: steg.steg.id,
+ kontrollId: kontroll.id,
+ text: kontroll.text,
+ undantag: orsak,
+ })
+ }
+ />
>
);
}
+// ECM-regeln: en kontroll kan bara slutföras med evidens ELLER ett
+// uttryckligt undantag — "underlaget kan inte tas fram" — där orsaken
+// alltid är obligatorisk och loggas i händelseloggen.
+function Undantag({ vidUndantag }: { vidUndantag: (orsak: string) => void }) {
+ const [oppen, setOppen] = useState(false);
+ const [orsak, setOrsak] = useState("");
+
+ if (!oppen) {
+ return (
+
+ );
+ }
+ return (
+
+
+ Kontrollen dokumenteras utan underlag — ange orsak (obligatoriskt). Detta flaggas i brief och rapport.
+
+
+ {UNDANTAGSORSAKER.map((val) => (
+
+ ))}
+
+
+
+ setOppen(false)}>
+ Avbryt
+
+ vidUndantag(orsak.trim())}>
+ Dokumentera undantag
+
+
+
+ );
+}
+
const DOKTYPER = [
{ id: "observation", label: "Observation" },
{ id: "matvarde", label: "Mätvärde" },
{ id: "foto", label: "Foto" },
+ { id: "instrument", label: "📷 Instrument" },
{ id: "hypotes", label: "Hypotes" },
{ id: "kommentar", label: "Kommentar" },
] as const;
@@ -587,6 +656,9 @@ function SnabbDokumentation({
const [text, setText] = useState("");
const [varde, setVarde] = useState("");
const filRef = useRef(null);
+ const instRef = useRef(null);
+ const [avlasning, setAvlasning] = useState<{ foto: string; tolkning: InstrumentTolkning; demo?: boolean } | null>(null);
+ const [laserAv, setLaserAv] = useState(false);
const aterstall = () => {
setTyp(null);
@@ -594,6 +666,44 @@ function SnabbDokumentation({
setVarde("");
};
+ // Visual-first: instrumentet/skärmen fotograferas, bildtolkningen
+ // extraherar värdena och teknikern bekräftar innan något loggas.
+ // Originalbilden bevaras alltid som evidens bredvid de strukturerade
+ // värdena. I lokalt läge visas en tydligt märkt demo-avläsning.
+ const lasAv = async (fil: File) => {
+ setLaserAv(true);
+ const foto = await skalaNerFoto(fil);
+ try {
+ const resultat = await lasAvInstrument(foto);
+ if (resultat && resultat.tolkning.varden.length > 0) {
+ setAvlasning({ foto, tolkning: resultat.tolkning });
+ } else if (!resultat) {
+ setAvlasning({ foto, tolkning: byggDemoInstrument(), demo: true });
+ } else {
+ skicka({ typ: "foto", beskrivning: "Instrumentfoto — inga värden kunde läsas", dataUrl: foto });
+ }
+ } catch {
+ skicka({ typ: "foto", beskrivning: "Instrumentfoto — avläsningen kunde inte nås", dataUrl: foto });
+ }
+ setLaserAv(false);
+ };
+
+ const sparaAvlasning = () => {
+ if (!avlasning) return;
+ // Originalet först, sedan varje bekräftat värde som eget mätvärde —
+ // strukturerad data ersätter aldrig originalevidensen.
+ skicka({ typ: "foto", beskrivning: `Instrumentavläsning (${avlasning.tolkning.instrumenttyp})`, dataUrl: avlasning.foto });
+ for (const v of avlasning.tolkning.varden) {
+ skicka({ typ: "matvarde", beskrivning: v.beskrivning, varde: v.varde, enhet: v.enhet });
+ }
+ paSparad?.(
+ `Instrumentavläsning (${avlasning.tolkning.instrumenttyp}): ${avlasning.tolkning.varden
+ .map((v) => `${v.beskrivning} = ${v.varde}${v.enhet ? ` ${v.enhet}` : ""}`)
+ .join("; ")}`,
+ );
+ setAvlasning(null);
+ };
+
const spara = () => {
const t = text.trim();
if (typ === "observation" && t) {
@@ -621,7 +731,13 @@ function SnabbDokumentation({
{DOKTYPER.map((d) => (
)}
@@ -945,12 +1102,18 @@ function BriefFlik({
{b.utfordaKontroller.length === 0 && Inga ännu.
}
- {b.utfordaKontroller.map((k, i) => (
-
- ✓ {k.text}
- {k.resultat && — {k.resultat}}
-
- ))}
+ {b.utfordaKontroller.map((k, i) =>
+ k.undantag ? (
+
+ ⚠ {k.text} — underlag saknas: {k.undantag}
+
+ ) : (
+
+ ✓ {k.text}
+ {k.resultat && — {k.resultat}}
+
+ ),
+ )}
{b.observationer.length === 0 && Inga ännu.
}
@@ -1035,15 +1198,41 @@ function RapportFlik({
skicka({ typ: "export_skapad", format: "JSON", version });
};
+ const grind = kvalitetsgrind(arende, metodik);
+ const godkand = grindGodkand(arende, metodik);
+
return (
<>
+ {/* ECM-kvalitetsgrinden: slutrapporten kan inte genereras förrän
+ varje obligatoriskt påstående har evidens eller dokumenterat
+ undantag i händelseloggen. */}
+
+ {grind.map((rad) => (
+
+
+
+ {rad.ok ? "✅" : rad.kravs ? "❌" : "⚠️"}
+ {" "}
+ {rad.rubrik}
+ {!rad.kravs && (rekommenderas)}
+
+ {rad.detalj &&
{rad.detalj}
}
+
+ ))}
+ {!godkand && (
+
+ Rapporten kan inte genereras förrän varje ❌ har evidens eller ett dokumenterat undantag
+ (”Underlag kan inte tas fram” i guiden).
+
+ )}
+
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas — bilder kan
innehålla uppgifter om andra kunder.
-
window.print()}>
+ window.print()}>
Skriv ut / PDF
diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx
index a0abe3c..fc586cd 100644
--- a/src/pages/felsokning/Arendelista.tsx
+++ b/src/pages/felsokning/Arendelista.tsx
@@ -45,7 +45,7 @@ function PlattformInloggning() {
{konto.namn} · {ROLL_LABEL[konto.roll]} ·{" "}
- {konto.organisation} — synk och AI aktiva.
+ {konto.organisation} — synk och beslutsstöd aktiva.