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:
Claude
2026-08-03 09:05:04 +00:00
parent a197ee10ca
commit e8cf6db236
12 changed files with 761 additions and 32 deletions
+108
View File
@@ -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
View File
@@ -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; // 01
}
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
View File
@@ -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 },
],
};
}
+4 -1
View File
@@ -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}`;
+161
View File
@@ -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);
}
+5 -2
View File
@@ -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)");
+211 -22
View File
@@ -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 &amp; osäkerheter
Komplettera analysen: risker &amp; 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 igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
Låt systemet 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}>
+4 -4
View File
@@ -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 begäran och skriver utkast till överlämningen. Den
skiljer alltid 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