Evidensmotor (ECM v1.0): ingen slutsats utan underlag
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
+54
-1
@@ -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<InstrumentTolkning>;
|
||||
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<InstrumentVarde>[]) {
|
||||
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(/\/$/, "");
|
||||
|
||||
+13
-1
@@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Tillforlitlighet, "hog"> }
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<EvidensNiva, string> = {
|
||||
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<string, { undantag?: string }>();
|
||||
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);
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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({
|
||||
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] uppercase tracking-wide text-[#707070]">
|
||||
AI-underlag · presenteras aldrig som konstaterat fel
|
||||
Beslutsstöd · presenteras aldrig som konstaterat fel
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
@@ -387,13 +396,13 @@ function GuideFlik({
|
||||
<Panel
|
||||
rubrik={
|
||||
senasteAiSvar?.handelse.typ === "ai_svar"
|
||||
? `AI-handledning (${senasteAiSvar.handelse.modell})`
|
||||
: "AI-handledning"
|
||||
? `Handledning (${senasteAiSvar.handelse.modell})`
|
||||
: "Handledning"
|
||||
}
|
||||
>
|
||||
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">AI analyserar …</p>}
|
||||
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet analyserar …</p>}
|
||||
{aiStatus === "fel" && (
|
||||
<p className="mb-2 font-semibold text-[#8B1A1A]">AI-svaret kunde inte hämtas — försök igen. Metodiken fortsätter som vanligt.</p>
|
||||
<p className="mb-2 font-semibold text-[#8B1A1A]">Analysen kunde inte hämtas — försök igen. Metodiken fortsätter som vanligt.</p>
|
||||
)}
|
||||
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
|
||||
<>
|
||||
@@ -560,14 +569,74 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
|
||||
</StorKnapp>
|
||||
</>
|
||||
)}
|
||||
<Undantag
|
||||
vidUndantag={(orsak) =>
|
||||
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 (
|
||||
<button
|
||||
onClick={() => setOppen(true)}
|
||||
className="mt-2 w-full py-1 text-[12px] font-medium text-[#707070] underline-offset-2 hover:text-[#8B1A1A] hover:underline print:hidden"
|
||||
>
|
||||
Underlag kan inte tas fram …
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2">
|
||||
<p className="mb-2 text-[12px] font-semibold text-[#9A6700]">
|
||||
Kontrollen dokumenteras utan underlag — ange orsak (obligatoriskt). Detta flaggas i brief och rapport.
|
||||
</p>
|
||||
<div className="mb-2 grid grid-cols-1 gap-1">
|
||||
{UNDANTAGSORSAKER.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setOrsak(val)}
|
||||
className={`min-h-8 rounded border px-2 text-left text-[12px] font-medium ${
|
||||
orsak === val ? "border-[#00437A] bg-[#D6E4F2]" : "border-[#C6C6C6] bg-white"
|
||||
}`}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TextFalt label="Eller egen orsak" varde={orsak && !UNDANTAGSORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
|
||||
Avbryt
|
||||
</StorKnapp>
|
||||
<StorKnapp variant="fara" disabled={!orsak.trim()} onClick={() => vidUndantag(orsak.trim())}>
|
||||
Dokumentera undantag
|
||||
</StorKnapp>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(null);
|
||||
const instRef = useRef<HTMLInputElement>(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) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => (d.id === "foto" ? filRef.current?.click() : setTyp(typ === d.id ? null : d.id))}
|
||||
onClick={() =>
|
||||
d.id === "foto"
|
||||
? filRef.current?.click()
|
||||
: d.id === "instrument"
|
||||
? instRef.current?.click()
|
||||
: setTyp(typ === d.id ? null : d.id)
|
||||
}
|
||||
className={`min-h-9 rounded border text-[12px] font-semibold transition-colors ${
|
||||
typ === d.id
|
||||
? "border-[#00437A] bg-[#00437A] text-white"
|
||||
@@ -646,7 +762,48 @@ function SnabbDokumentation({
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{typ && typ !== "foto" && (
|
||||
<input
|
||||
ref={instRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const fil = e.target.files?.[0];
|
||||
if (fil) lasAv(fil);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av instrumentet …</p>}
|
||||
{avlasning && (
|
||||
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2">
|
||||
{avlasning.demo && (
|
||||
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-1.5 text-[11px] font-semibold text-[#9A6700]">
|
||||
Demo-avläsning — bildtolkningen kräver inloggning. Värdena är exempeldata.
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
|
||||
Avläst: {avlasning.tolkning.instrumenttyp} — bekräfta innan något loggas
|
||||
</p>
|
||||
{avlasning.tolkning.varden.map((v, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
|
||||
<span>
|
||||
{v.beskrivning}: <span className="font-semibold">{v.varde}{v.enhet ? ` ${v.enhet}` : ""}</span>
|
||||
</span>
|
||||
<span className={`text-[11px] font-semibold ${v.konfidens >= 0.95 ? "text-[#1E6B34]" : v.konfidens >= 0.8 ? "text-[#9A6700]" : "text-[#8B1A1A]"}`}>
|
||||
{v.konfidens >= 0.95 ? "✓" : `${Math.round(v.konfidens * 100)} %`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => setAvlasning(null)}>
|
||||
Förkasta
|
||||
</StorKnapp>
|
||||
<StorKnapp onClick={sparaAvlasning}>Spara foto + värden</StorKnapp>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typ && typ !== "foto" && typ !== "instrument" && (
|
||||
<div className="mt-3">
|
||||
{typ === "hypotes" && (
|
||||
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
|
||||
@@ -716,10 +873,10 @@ function OverlamningDialog({
|
||||
</pre>
|
||||
{aiLage === "vilar" && (
|
||||
<StorKnapp variant="sekundar" className="mb-3" onClick={komplettera}>
|
||||
✨ AI-komplettera: risker & osäkerheter
|
||||
✨ Komplettera analysen: risker & osäkerheter
|
||||
</StorKnapp>
|
||||
)}
|
||||
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">AI sammanfattar …</p>}
|
||||
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">Systemet sammanfattar …</p>}
|
||||
{aiLage === "fel" && (
|
||||
<p className="mb-3 font-semibold text-[#8B1A1A]">Kunde inte hämtas — kräver inloggning. Överlämningen fungerar ändå.</p>
|
||||
)}
|
||||
@@ -901,12 +1058,12 @@ function BriefFlik({
|
||||
return (
|
||||
<>
|
||||
{!b.avslutat && (
|
||||
<Panel rubrik="AI-granskning">
|
||||
<Panel rubrik="Granskning av underlaget">
|
||||
<p className="mb-2 text-[#333333]">
|
||||
Låt AI:n gå igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
|
||||
Låt systemet gå igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
|
||||
observationer, luckor i dokumentationen och slutsatser som saknar stöd.
|
||||
</p>
|
||||
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">AI granskar underlaget …</p>}
|
||||
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet granskar underlaget …</p>}
|
||||
{granskning === "fel" && (
|
||||
<p className="mb-2 font-semibold text-[#8B1A1A]">Granskningen kunde inte hämtas — kräver inloggning. Försök igen.</p>
|
||||
)}
|
||||
@@ -920,11 +1077,11 @@ function BriefFlik({
|
||||
<p className="mt-1 text-[14px]">
|
||||
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-[#707070]">Senaste AI-svar ({senasteAi.handelse.modell})</p>
|
||||
<p className="mt-1 text-[11px] text-[#707070]">Senaste analys ({senasteAi.handelse.modell})</p>
|
||||
</div>
|
||||
)}
|
||||
<StorKnapp variant="sekundar" disabled={granskning === "arbetar"} onClick={granska}>
|
||||
🔍 AI-granska underlaget
|
||||
🔍 Granska underlaget
|
||||
</StorKnapp>
|
||||
</Panel>
|
||||
)}
|
||||
@@ -945,12 +1102,18 @@ function BriefFlik({
|
||||
</Panel>
|
||||
<Panel rubrik="Utförda kontroller">
|
||||
{b.utfordaKontroller.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
|
||||
{b.utfordaKontroller.map((k, i) => (
|
||||
<p key={i} className="py-0.5 text-[14px]">
|
||||
✓ {k.text}
|
||||
{k.resultat && <span className="text-[#4A5560]"> — {k.resultat}</span>}
|
||||
</p>
|
||||
))}
|
||||
{b.utfordaKontroller.map((k, i) =>
|
||||
k.undantag ? (
|
||||
<p key={i} className="py-0.5 text-[14px] text-[#9A6700]">
|
||||
⚠ {k.text} — underlag saknas: {k.undantag}
|
||||
</p>
|
||||
) : (
|
||||
<p key={i} className="py-0.5 text-[14px]">
|
||||
✓ {k.text}
|
||||
{k.resultat && <span className="text-[#4A5560]"> — {k.resultat}</span>}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</Panel>
|
||||
<Panel rubrik="Observationer">
|
||||
{b.observationer.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
|
||||
@@ -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. */}
|
||||
<Panel rubrik={`Kvalitetsgrind — ECM v${ECM_VERSION}`}>
|
||||
{grind.map((rad) => (
|
||||
<div key={rad.id} className="border-b border-[#EBEBEB] py-1 last:border-0">
|
||||
<p className="text-[13px]">
|
||||
<span className={rad.ok ? "text-[#1E6B34]" : rad.kravs ? "text-[#8B1A1A]" : "text-[#9A6700]"}>
|
||||
{rad.ok ? "✅" : rad.kravs ? "❌" : "⚠️"}
|
||||
</span>{" "}
|
||||
{rad.rubrik}
|
||||
{!rad.kravs && <span className="text-[11px] text-[#707070]"> (rekommenderas)</span>}
|
||||
</p>
|
||||
{rad.detalj && <p className="pl-6 text-[12px] text-[#707070]">{rad.detalj}</p>}
|
||||
</div>
|
||||
))}
|
||||
{!godkand && (
|
||||
<p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">
|
||||
Rapporten kan inte genereras förrän varje ❌ har evidens eller ett dokumenterat undantag
|
||||
(”Underlag kan inte tas fram” i guiden).
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel rubrik="Kundrapport">
|
||||
<p className="mb-2 text-[#333333]">
|
||||
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas — bilder kan
|
||||
innehålla uppgifter om andra kunder.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => window.print()}>
|
||||
<StorKnapp variant="sekundar" disabled={!godkand} onClick={() => window.print()}>
|
||||
Skriv ut / PDF
|
||||
</StorKnapp>
|
||||
<StorKnapp variant="sekundar" onClick={exporteraJson}>
|
||||
|
||||
@@ -45,7 +45,7 @@ function PlattformInloggning() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[14px]">
|
||||
<span className="font-semibold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
|
||||
{konto.organisation} — synk och AI aktiva.
|
||||
{konto.organisation} — synk och beslutsstöd aktiva.
|
||||
</p>
|
||||
<button
|
||||
className="whitespace-nowrap rounded border border-[#ADADAD] px-4 py-2 font-semibold text-[#333333] hover:border-[#8FA8C0]"
|
||||
@@ -78,7 +78,7 @@ function PlattformInloggning() {
|
||||
{lage === "stangd" ? (
|
||||
<>
|
||||
<p className="mb-2 text-[#333333]">
|
||||
Logga in för synk mellan enheter, samarbete i ärenden och AI-stöd. Utan inloggning
|
||||
Logga in för synk mellan enheter, samarbete i ärenden och beslutsstöd. Utan inloggning
|
||||
arbetar appen i lokalt läge. Ny organisation? Skapa konto — du blir systemadministratör.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -317,9 +317,9 @@ export default function Arendelista() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Panel rubrik="AI-stöd">
|
||||
<Panel rubrik="Beslutsstöd">
|
||||
<p className="text-[#333333]">
|
||||
När du är inloggad hjälper AI:n dig medan du arbetar: föreslår nästa steg utifrån det du
|
||||
När du är inloggad hjälper systemet dig medan du arbetar: föreslår nästa steg utifrån det du
|
||||
dokumenterar, granskar hela ärendet på begäran och skriver utkast till överlämningen. Den
|
||||
skiljer alltid på verifierat och hypotes — och slår aldrig fast en felorsak som inte är
|
||||
bekräftad. Inget att installera eller ställa in; allt ingår i tjänsten. Utan inloggning
|
||||
|
||||
Reference in New Issue
Block a user