From 786e2159f51600a1df3286d834d5aef1e024cedc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 10:05:41 +0000 Subject: [PATCH] Videoevidens (E3) och signerat avslut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video med ljud är nu en riktig evidenstyp — den sista luckan i evidensskalan E0–E6: - Ny händelse video (beskrivning + dataUrl): Video-knappen i Dokumentera-panelen spelar in/väljer ett kort klipp via kameran, beskrivningen är obligatorisk ("det som låter eller rör sig") och hård storleksgräns avvisar för långa klipp med tydligt besked - Evidence Engine: video katalogiseras som E3 och räknas som oberoende källa mot E6; underlagskällan "Video" i felorsaksanalysen valideras nu mot loggen precis som foto/mätresultat - Originalfilen bevaras och visas i arbetsloggen, kundrapporten och Live Share-vyn Signerat avslut (ur Completion-checklistan): "Avsluta felsökning" loggar teknikerns signatur — "Felsökning avslutad — signerad av …" — och kvalitetsgrinden redovisar signaturen. 61 vitest-tester, integrationstest, OpenAPI-validering och preview-klicktest (beskrivningskrav, storleksgräns, videospelare i loggen, signerat avslut) gröna. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- docs/MVP.md | 2 +- docs/moduler/evidensmotor.md | 3 +- services/plattform/openapi.yaml | 1 + src/felsokning/DelatArendeVy.tsx | 13 ++++- src/felsokning/__tests__/ecm.test.ts | 28 ++++++++++ src/felsokning/domain.ts | 11 +++- src/felsokning/ecm.ts | 22 +++++++- src/felsokning/format.ts | 17 ++++++ src/felsokning/projektioner.ts | 10 ++++ src/pages/felsokning/ArendeSida.tsx | 80 +++++++++++++++++++++++++--- 10 files changed, 174 insertions(+), 13 deletions(-) diff --git a/docs/MVP.md b/docs/MVP.md index adcc4a1..d2d485c 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -20,7 +20,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-orkestern driven av plattformen**: edge-funktionen `felsokning-ai` äger Claude API-nyckeln (serverhemligheten `ANTHROPIC_API_KEY`) och routar per uppgift — handledning i realtid (Sonnet 5), djupgranskning av hela underlaget via knapp i briefen (Opus 5, hög effort), AI-komplettering av överlämningen med risker & osäkerheter (Sonnet 5) och metodikklassificering av felbeskrivningen (Haiku 4.5). Alla svar är schema-bundna och klassificerade enligt AI-reglerna, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan; modellen som svarade loggas i varje händelse. Kräver inloggad användare; svaren är interna och delas aldrig i kundvyer. I lokalt läge guidar metodiken ensam. | | Arbetslogg | ✅ Append-only händelselogg med tidsstämpel och användare på varje post. Ingenting skrivs över. | | Tidredovisning | ✅ Kategorier (aktiv felsökning, väntetid, provkörning …) via kategoribyten i loggen; paus räknas inte i total tid. Inaktivitetsfråga efter 20 min utan händelser. | -| Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), kommentarer och hypoteser. Hypoteser märks alltid 🔴 och kan aldrig loggas som konstaterade fel. | +| Dokumentation | ✅ Observationer, mätvärden, foton (nedskalade), **video med ljud** (E3-evidens för det som låter eller rör sig — kort klipp med obligatorisk beskrivning, hård storleksgräns, originalfilen bevaras och visas i logg, rapport och Live Share), kommentarer och hypoteser. Hypoteser märks alltid som ej verifierade och kan aldrig loggas som konstaterade fel. **Avslutet signeras automatiskt** av teknikern (”Felsökning avslutad — signerad av …”), redovisat i kvalitetsgrinden. | | Ärendebrief | ✅ Regenereras ur loggen vid varje visning: utförda kontroller, observationer, **ej kontrollerat**, rekommenderat nästa steg, tillförlitlighet, total arbetstid. | | Överlämning | ✅ ”Lämna över arbete” genererar överlämningsrapport ur briefen och loggar överlämningen. | | Kundrapport | ✅ Tidslinjevy utan interna poster, med bilder och tidsfördelning. Utskrift/PDF via webbläsaren, med påminnelse om granskning före delning. | diff --git a/docs/moduler/evidensmotor.md b/docs/moduler/evidensmotor.md index e72232c..5a7e009 100644 --- a/docs/moduler/evidensmotor.md +++ b/docs/moduler/evidensmotor.md @@ -23,7 +23,7 @@ omöjligt. | E0 | Inget underlag | 0 % | | E1 | Teknikerns observation | Lågt | | E2 | Foto | Medel | -| E3 | Video | Högt | +| E3 | Video (med ljud — för det som låter eller rör sig) | Högt | | E4 | Mätvärde | Högt | | E5 | Diagnosdata/dokument | Mycket högt | | E6 | Flera oberoende källor | Högsta | @@ -80,6 +80,7 @@ alla obligatoriska rader är gröna: | Metodikens kontroller: evidens eller dokumenterat undantag | Obligatorisk | | Foton för fotokrävande kontroller | Obligatorisk | | Ärendetypens compliance-krav | Obligatoriska | +| Teknikerns slutsats signerad | Automatisk vid avslut | | Evidensnivå över E0 | Obligatorisk | ### 6. Traceability Engine diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index 88683a6..a3d6062 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -653,6 +653,7 @@ components: - arendetyp_satt - historik_kontrollerad - matarstallning + - video - reproducering - felorsak - export_skapad diff --git a/src/felsokning/DelatArendeVy.tsx b/src/felsokning/DelatArendeVy.tsx index d621209..9dc5242 100644 --- a/src/felsokning/DelatArendeVy.tsx +++ b/src/felsokning/DelatArendeVy.tsx @@ -6,7 +6,7 @@ import type { Arende } from "./domain"; import { handelseRubrik } from "./domain"; -import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader } from "./projektioner"; +import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner"; import { metodikForArende } from "./store"; import { tidDatum, tidKlockslag } from "./format"; import { FelsokningSkal, Panel } from "./ui"; @@ -58,6 +58,7 @@ export function DelatArendeVy({ const b = brief(arende, metodik, nu); const avslutat = arAvslutat(arende); const bilder = foton(arende); + const klipp = videor(arende); const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde"); const kundposter = redanFiltrerad ? arende.handelser @@ -102,6 +103,16 @@ export function DelatArendeVy({ ))} + {klipp.length > 0 && ( + + {klipp.map((v, i) => ( +
+
+ ))} +
+ )} {bilder.length > 0 && (
diff --git a/src/felsokning/__tests__/ecm.test.ts b/src/felsokning/__tests__/ecm.test.ts index 0f6610c..e4f8496 100644 --- a/src/felsokning/__tests__/ecm.test.ts +++ b/src/felsokning/__tests__/ecm.test.ts @@ -335,3 +335,31 @@ describe("Fordonshistorik (lokal projektion)", () => { expect(lokalFordonshistorik({ [tidigare.id]: tidigare }, "ZZZ111")).toHaveLength(0); }); }); + +describe("Videoevidens (E3) och signerat avslut", () => { + it("video höjer evidensnivån till E3 och räknas som oberoende källa", () => { + const medVideo = byggArende([{ typ: "video", beskrivning: "Motorljud vid tomgång", dataUrl: "data:video/webm;base64,x" }]); + expect(evidensNiva(medVideo)).toBe("E3"); + expect(evidensposter(medVideo)[0].kategori).toBe("video"); + const tvaKallor = byggArende([ + { typ: "video", beskrivning: "Motorljud", dataUrl: "data:video/webm;base64,x" }, + { typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar" }, + ]); + expect(evidensNiva(tvaKallor)).toBe("E6"); + }); + + it("underlagskällan Video valideras mot loggen i felorsaksanalysen", async () => { + const { underlagFinns } = await import("../ecm"); + expect(underlagFinns(byggArende([OBJEKT]), "Video")).toBe(false); + expect(underlagFinns(byggArende([{ typ: "video", beskrivning: "x", dataUrl: "data:" }]), "Video")).toBe(true); + }); + + it("avslutet signeras och grinden redovisar signaturen", async () => { + const { handelseRubrik, nyLoggPost: ny } = await import("../domain"); + expect(handelseRubrik(ny("Erik", { typ: "arende_avslutat", signatur: "Erik" }))).toContain("signerad av Erik"); + const stangd = byggArende([OBJEKT, ...PREDIAG, { typ: "arende_avslutat", signatur: "Erik" }]); + const rad = kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "signering"); + expect(rad?.ok).toBe(true); + expect(rad?.detalj).toContain("Erik"); + }); +}); diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index a123dfa..cbe04af 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -67,6 +67,10 @@ export type Handelse = | { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string } | { typ: "hypotes"; text: string; niva: Exclude } | { typ: "foto"; beskrivning: string; dataUrl: string } + // Video med ljud (E3-evidens): för det som låter eller rör sig — + // motorljud, hjullager, fjädringsrörelse. Kort och komprimerad; + // originalfilen bevaras som evidens precis som foton. + | { typ: "video"; beskrivning: string; dataUrl: string } | { typ: "kommentar"; text: string } | { typ: "kategori_byte"; kategori: TidKategori } | { typ: "inaktivitet_forklarad"; text: string; minuter: number } @@ -104,7 +108,8 @@ export type Handelse = nastaSteg: string; modell: string; } - | { typ: "arende_avslutat" }; + // Avslutet signeras av teknikern — slutsatsen får en ansvarig avsändare. + | { typ: "arende_avslutat"; signatur?: string }; export interface LoggPost { id: string; @@ -160,6 +165,8 @@ export function handelseRubrik(post: LoggPost): string { return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`; case "foto": return `Foto: ${h.beskrivning}`; + case "video": + return `Video: ${h.beskrivning}`; case "kommentar": return h.text; case "kategori_byte": @@ -194,6 +201,6 @@ export function handelseRubrik(post: LoggPost): string { return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`; } case "arende_avslutat": - return "Felsökning avslutad"; + return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Felsökning avslutad"; } } diff --git a/src/felsokning/ecm.ts b/src/felsokning/ecm.ts index 036a33f..d4dc7d5 100644 --- a/src/felsokning/ecm.ts +++ b/src/felsokning/ecm.ts @@ -77,6 +77,7 @@ export function evidensposter(arende: Arende): Evidenspost[] { for (const post of arende.handelser) { const h = post.handelse; if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning); + if (h.typ === "video") lagg(post, "video", "E3", h.beskrivning); if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`); if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`); if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`); @@ -90,10 +91,11 @@ export function evidensposter(arende: Arende): Evidenspost[] { export function evidensNiva(arende: Arende): EvidensNiva { const poster = evidensposter(arende); const har = (n: string) => poster.some((p) => p.niva === n); - const kallor = [har("E2"), har("E4"), har("E5")].filter(Boolean).length; + const kallor = [har("E2"), har("E3"), har("E4"), har("E5")].filter(Boolean).length; if (kallor >= 2) return "E6"; if (har("E5")) return "E5"; if (har("E4")) return "E4"; + if (har("E3")) return "E3"; if (har("E2")) return "E2"; if (har("E1")) return "E1"; return "E0"; @@ -173,6 +175,8 @@ export function underlagFinns(arende: Arende, kalla: string): boolean { switch (kalla) { case "Foto": return h.some((x) => x.typ === "foto"); + case "Video": + return h.some((x) => x.typ === "video"); case "Mätresultat": return h.some((x) => x.typ === "matvarde"); case "Diagnosutläsning": @@ -183,7 +187,7 @@ export function underlagFinns(arende: Arende, kalla: string): boolean { case "Direkt observation": return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford"); default: - // Video/teknisk dokumentation: kan ännu inte verifieras maskinellt. + // Teknisk dokumentation: kan ännu inte verifieras maskinellt. return true; } } @@ -585,6 +589,20 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] { }); } + const avslutEvent = handelser.find((h) => h.typ === "arende_avslutat"); + rader.push({ + id: "signering", + rubrik: "Teknikerns slutsats signerad", + ok: !avslutat || (avslutEvent?.typ === "arende_avslutat" && !!avslutEvent.signatur), + kravs: false, + detalj: + avslutat && avslutEvent?.typ === "arende_avslutat" && avslutEvent.signatur + ? `Signerad av ${avslutEvent.signatur}.` + : avslutat + ? "Avslutet saknar signatur (äldre ärende)." + : "Signeras automatiskt när ärendet avslutas.", + }); + const hypoteser = handelser.filter((h) => h.typ === "hypotes").length; rader.push({ id: "hypoteser", diff --git a/src/felsokning/format.ts b/src/felsokning/format.ts index 2fbca62..5f34dc5 100644 --- a/src/felsokning/format.ts +++ b/src/felsokning/format.ts @@ -29,3 +29,20 @@ export function skalaNerFoto(fil: File, maxSida = 1024): Promise { lasare.readAsDataURL(fil); }); } + +// Video läses rå (ingen transkodning i webbläsaren) — därför hård +// storleksgräns: kort och komprimerad klipp, ca 10–15 sekunder. +export const MAX_VIDEO_BYTE = 2_500_000; + +export function lasVideo(fil: File): Promise { + return new Promise((los, avvisa) => { + if (fil.size > MAX_VIDEO_BYTE) { + avvisa(new Error("Videon är för stor — spela in ett kort klipp (ca 10–15 sekunder).")); + return; + } + const lasare = new FileReader(); + lasare.onload = () => los(lasare.result as string); + lasare.onerror = () => avvisa(new Error("Videon kunde inte läsas.")); + lasare.readAsDataURL(fil); + }); +} diff --git a/src/felsokning/projektioner.ts b/src/felsokning/projektioner.ts index 8c8744d..9ca7f54 100644 --- a/src/felsokning/projektioner.ts +++ b/src/felsokning/projektioner.ts @@ -182,6 +182,16 @@ export function foton(arende: Arende): { beskrivning: string; dataUrl: string; t return resultat; } +export function videor(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] { + const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = []; + for (const post of arende.handelser) { + if (post.handelse.typ === "video") { + resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt }); + } + } + return resultat; +} + // Tidsredovisning: varje intervall mellan två händelser tillhör den kategori // som var aktiv när intervallet började. Kategori styrs av kategori_byte- // händelser; standard är aktiv felsökning. Paus räknas inte in i total tid. diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 629b2d7..0063ca4 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -11,6 +11,7 @@ import { felbeskrivning, formateraTid, foton, + videor, lokalFordonshistorik, objekt, overlamningstext, @@ -58,7 +59,7 @@ import { underlagFinns, } from "@/felsokning/ecm"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; -import { skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format"; +import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format"; import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner"; const FLIKAR = [ @@ -872,6 +873,7 @@ function GuideFlik({ nu: string; }) { const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]); + const anvandare = useFelsokning((s) => s.anvandare); const [visaOverlamning, setVisaOverlamning] = useState(false); const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar"); @@ -929,7 +931,7 @@ function GuideFlik({ Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet med rekommenderade nästa steg.

- skicka({ typ: "arende_avslutat" })}> + skicka({ typ: "arende_avslutat", signatur: anvandare })}> Avsluta felsökning {!kanAvslutas && ( @@ -1001,7 +1003,7 @@ function GuideFlik({ setVisaOverlamning(true)}> Lämna över arbete - skicka({ typ: "arende_avslutat" })}> + skicka({ typ: "arende_avslutat", signatur: anvandare })}> Avsluta felsökning
@@ -1211,6 +1213,7 @@ const DOKTYPER = [ { id: "observation", label: "Observation" }, { id: "matvarde", label: "Mätvärde" }, { id: "foto", label: "Foto" }, + { id: "video", label: "Video" }, { id: "instrument", label: "Instrument" }, { id: "hypotes", label: "Hypotes" }, { id: "kommentar", label: "Kommentar" }, @@ -1232,6 +1235,9 @@ function SnabbDokumentation({ const [varde, setVarde] = useState(""); const filRef = useRef(null); const instRef = useRef(null); + const videoRef = useRef(null); + const [video, setVideo] = useState<{ dataUrl: string; beskrivning: string } | null>(null); + const [videoFel, setVideoFel] = useState(""); const [avlasning, setAvlasning] = useState<{ foto: string; tolkning: InstrumentTolkning; demo?: boolean } | null>(null); const [laserAv, setLaserAv] = useState(false); @@ -1309,9 +1315,11 @@ function SnabbDokumentation({ onClick={() => d.id === "foto" ? filRef.current?.click() - : d.id === "instrument" - ? instRef.current?.click() - : setTyp(typ === d.id ? null : d.id) + : d.id === "video" + ? videoRef.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 @@ -1349,6 +1357,52 @@ function SnabbDokumentation({ e.target.value = ""; }} /> + { + const fil = e.target.files?.[0]; + e.target.value = ""; + if (!fil) return; + setVideoFel(""); + try { + setVideo({ dataUrl: await lasVideo(fil), beskrivning: "" }); + } catch (misslyckande) { + setVideoFel(misslyckande instanceof Error ? misslyckande.message : "Videon kunde inte läsas."); + } + }} + /> + {videoFel &&

{videoFel}

} + {video && ( +
+
+ )} {laserAv &&

Systemet läser av instrumentet …

} {avlasning && (
@@ -1594,6 +1648,9 @@ function LoggFlik({ arende }: { arende: Arende }) { {post.handelse.typ === "foto" && ( {post.handelse.beskrivning} )} + {post.handelse.typ === "video" && ( +
))} @@ -1748,6 +1805,7 @@ function RapportFlik({ const anvandare = useFelsokning((s) => s.anvandare); const b = brief(arende, metodik, nu); const bilder = foton(arende); + const klipp = videor(arende); const fordelning = tidsfordelningsRader(arende, nu); const idn = arendeidentitet(arende); const typ = arendetyp(arende); @@ -1927,6 +1985,16 @@ function RapportFlik({

))}
+ {klipp.length > 0 && ( + + {klipp.map((v, i) => ( +
+
+ ))} +
+ )} {bilder.length > 0 && (