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) => (
+
+
+ {v.beskrivning}
+
+ ))}
+
+ )}
{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