Master Prompt v2.0 + röstinmatning, verifierade checklistor och export

Dokumentation:
- MASTER-PROMPT.md uppdaterad till v2.0 (SaaS/AI-wrapper, roller,
  multi-tenant, onboarding, beta-fokus).
- Nya moduler: kommunikationsmodell (tal in/text ut, Push-to-Talk,
  redigering före skick), Live Share (behörighetsstyrd realtidslänk,
  versionsmärkta exporter), verifierade checklistor (bevis och kontext
  per kontrolltyp).

Implementation i MVP:t:
- Push-to-Talk-röstinmatning via webbläsarens taligenkänning (sv-SE):
  lyssnar endast efter aktivt tryck, röd indikator med realtids-
  transkript, texten hamnar i redigerbart fält och skickas aldrig
  automatiskt. Döljs i webbläsare utan talstöd.
- Verifierade checklistor: varje kontroll i metodikerna har minimikrav
  (foto/mätvärde/kommentar); fotokontroller verifieras med bild och
  mätkontroller kan inte markeras verifierade utan värde.
- Versionsmärkt JSON-export ur händelseloggen; exporten loggas själv
  som händelse med format, version och användare.

Verifierat: 12 vitest-tester gröna, produktionsbygge ok, utökat
Playwright-röktest genom hela flödet inkl. fotokravet.

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-02 22:40:47 +00:00
parent 92fa8d6716
commit 7b6619133f
13 changed files with 600 additions and 210 deletions
+65
View File
@@ -0,0 +1,65 @@
// Push-to-Talk-mikrofon: lyssnar ENDAST efter aktivt tryck, visar tydligt
// när inspelning pågår och skriver in transkriberingen i ett redigerbart
// fält. Skicka sker aldrig automatiskt.
import { useEffect, useRef, useState } from "react";
import { startaIgenkanning, stodRost } from "./rost";
export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) {
const [lyssnar, setLyssnar] = useState(false);
const [interim, setInterim] = useState("");
const igenkanningRef = useRef<ReturnType<typeof startaIgenkanning>>(undefined);
const paTextRef = useRef(paText);
paTextRef.current = paText;
useEffect(() => () => igenkanningRef.current?.abort(), []);
if (!stodRost()) return null;
const stoppa = () => {
igenkanningRef.current?.stop();
igenkanningRef.current = undefined;
setLyssnar(false);
setInterim("");
};
const starta = () => {
setLyssnar(true);
igenkanningRef.current = startaIgenkanning(
({ transkript, slutgiltigt }) => {
if (slutgiltigt) {
setInterim("");
paTextRef.current(transkript.trim());
} else {
setInterim(transkript);
}
},
() => {
setLyssnar(false);
setInterim("");
},
);
};
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => (lyssnar ? stoppa() : starta())}
aria-label={lyssnar ? "Stoppa inspelning" : "Starta röstinmatning"}
className={`min-h-11 min-w-11 rounded-full border-2 px-3 text-lg font-bold transition-colors ${
lyssnar
? "animate-pulse border-red-500 bg-red-600 text-white"
: "border-zinc-600 bg-zinc-800 text-zinc-100 hover:border-amber-400"
}`}
>
{lyssnar ? "■" : "🎤"}
</button>
{lyssnar && (
<span className="text-sm font-bold text-red-400">
Inspelning pågår{interim ? ` — ”${interim}` : "…"}
</span>
)}
</span>
);
}
@@ -26,6 +26,19 @@ const OBJEKT: Handelse = {
},
};
describe("verifierade checklistor", () => {
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
const { GENERISK_METODIK } = await import("../metodik");
for (const metodik of [VIBRATION_METODIK, GENERISK_METODIK]) {
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
}
}
}
});
});
describe("metodikval", () => {
it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
+3
View File
@@ -51,6 +51,7 @@ export type Handelse =
| { typ: "kategori_byte"; kategori: TidKategori }
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "export_skapad"; format: string; version: number }
| { typ: "arende_avslutat" };
export interface LoggPost {
@@ -106,6 +107,8 @@ export function handelseRubrik(post: LoggPost): string {
return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`;
case "overlamning":
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`;
case "arende_avslutat":
return "Felsökning avslutad";
}
+28 -23
View File
@@ -15,9 +15,14 @@ export interface Fraga {
val?: string[];
}
// Minimikrav enligt modulen Verifierade checklistor: en kontrollpunkt är
// inte slutförd enbart genom en kryssruta — den samlar bevis och kontext.
export type KontrollKrav = "matvarde" | "kommentar" | "foto";
export interface Kontroll {
id: string;
text: string;
krav?: KontrollKrav;
}
export interface MetodikSteg {
@@ -67,23 +72,23 @@ export const VIBRATION_METODIK: Metodik = {
rubrik: "Visuell kontroll",
beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
kontroller: [
{ id: "foto_vf", text: "Fotografera vänster framhjul" },
{ id: "foto_hf", text: "Fotografera höger framhjul" },
{ id: "foto_vb", text: "Fotografera vänster bakhjul" },
{ id: "foto_hb", text: "Fotografera höger bakhjul" },
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum" },
{ id: "slitage", text: "Kontrollera däckslitage" },
{ id: "foto_vf", text: "Fotografera vänster framhjul", krav: "foto" },
{ id: "foto_hf", text: "Fotografera höger framhjul", krav: "foto" },
{ id: "foto_vb", text: "Fotografera vänster bakhjul", krav: "foto" },
{ id: "foto_hb", text: "Fotografera höger bakhjul", krav: "foto" },
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum", krav: "kommentar" },
{ id: "slitage", text: "Kontrollera däckslitage", krav: "kommentar" },
],
},
{
id: "kontroller",
rubrik: "Rekommenderade kontroller",
kontroller: [
{ id: "lufttryck", text: "Kontrollera lufttryck" },
{ id: "hjulmoment", text: "Kontrollera hjulmoment" },
{ id: "kast", text: "Kontrollera radial- och sidokast" },
{ id: "balansering", text: "Kontrollera hjulbalansering" },
{ id: "bussningar", text: "Kontrollera bussningar och leder" },
{ id: "lufttryck", text: "Kontrollera lufttryck", krav: "matvarde" },
{ id: "hjulmoment", text: "Kontrollera hjulmoment", krav: "matvarde" },
{ id: "kast", text: "Kontrollera radial- och sidokast", krav: "matvarde" },
{ id: "balansering", text: "Kontrollera hjulbalansering", krav: "kommentar" },
{ id: "bussningar", text: "Kontrollera bussningar och leder", krav: "kommentar" },
],
},
{
@@ -91,11 +96,11 @@ export const VIBRATION_METODIK: Metodik = {
rubrik: "Provkörning",
beskrivning: "Verifiera under provkörning:",
kontroller: [
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår" },
{ id: "pk_acc", text: "Förändring vid acceleration" },
{ id: "pk_motorbroms", text: "Förändring vid motorbroms" },
{ id: "pk_kurva", text: "Förändring vid kurvtagning" },
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross" },
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår", krav: "kommentar" },
{ id: "pk_acc", text: "Förändring vid acceleration", krav: "kommentar" },
{ id: "pk_motorbroms", text: "Förändring vid motorbroms", krav: "kommentar" },
{ id: "pk_kurva", text: "Förändring vid kurvtagning", krav: "kommentar" },
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross", krav: "kommentar" },
],
},
],
@@ -124,25 +129,25 @@ export const GENERISK_METODIK: Metodik = {
id: "visuell",
rubrik: "Visuell kontroll",
kontroller: [
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området" },
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar" },
{ id: "typskylt", text: "Dokumentera typskylt/märkning" },
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området", krav: "foto" },
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar", krav: "kommentar" },
{ id: "typskylt", text: "Dokumentera typskylt/märkning", krav: "foto" },
],
},
{
id: "grundkontroller",
rubrik: "Grundkontroller",
kontroller: [
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)" },
{ id: "sakringar", text: "Kontrollera säkringar/skydd" },
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar" },
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)", krav: "matvarde" },
{ id: "sakringar", text: "Kontrollera säkringar/skydd", krav: "kommentar" },
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar", krav: "kommentar" },
],
},
{
id: "funktionstest",
rubrik: "Funktionstest",
kontroller: [
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet" },
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
],
},
],
+65
View File
@@ -0,0 +1,65 @@
// Tal-till-text enligt kommunikationsmodellen: rösten är ett inmatningssätt,
// inte ett separat gränssnitt. All logik bygger på text — transkriberingen
// hamnar i ett redigerbart fält och ingenting skickas utan bekräftelse.
//
// MVP:t använder webbläsarens SpeechRecognition (sv-SE). I produktions-
// versionen ersätts motorn av leverantörens Voice-to-Text-API bakom samma
// gränssnitt.
interface IgenkanningsResultat {
transkript: string;
slutgiltigt: boolean;
}
interface Igenkanning {
start(): void;
stop(): void;
abort(): void;
}
type SpeechRecognitionCtor = new () => {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: { resultIndex: number; results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
onend: (() => void) | null;
onerror: ((event: { error: string }) => void) | null;
start(): void;
stop(): void;
abort(): void;
};
function ctor(): SpeechRecognitionCtor | undefined {
if (typeof window === "undefined") return undefined;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
}
export function stodRost(): boolean {
return ctor() !== undefined;
}
export function startaIgenkanning(
paResultat: (r: IgenkanningsResultat) => void,
paSlut: () => void,
): Igenkanning | undefined {
const Ctor = ctor();
if (!Ctor) return undefined;
const igenkanning = new Ctor();
igenkanning.lang = "sv-SE";
igenkanning.continuous = true;
igenkanning.interimResults = true;
igenkanning.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
const resultat = event.results[i];
paResultat({ transkript: resultat[0].transcript, slutgiltigt: resultat.isFinal });
}
};
igenkanning.onend = paSlut;
igenkanning.onerror = paSlut;
igenkanning.start();
return igenkanning;
}
+16 -2
View File
@@ -3,9 +3,10 @@
// träffytor (handskvänligt), få val per skärm, inga distraktioner.
import { Link } from "react-router-dom";
import type { ReactNode } from "react";
import { useRef, type ReactNode } from "react";
import type { Tillforlitlighet } from "./domain";
import { TILLFORLITLIGHET_LABEL } from "./domain";
import { MikrofonKnapp } from "./Mikrofon";
export function FelsokningSkal({
rubrik,
@@ -90,18 +91,31 @@ export function TextFalt({
satt,
platshallare,
flerRad,
rost,
}: {
label: string;
varde: string;
satt: (v: string) => void;
platshallare?: string;
flerRad?: boolean;
rost?: boolean;
}) {
// Tal-till-text skriver in i samma redigerbara fält — användaren
// granskar och bekräftar alltid innan något sparas i loggen.
const vardeRef = useRef(varde);
vardeRef.current = varde;
const klass =
"w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none";
return (
<label className="mb-3 block">
<span className="mb-1 block text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
<span className="mb-1 flex min-h-11 items-center justify-between gap-2">
<span className="text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
{rost && (
<MikrofonKnapp
paText={(text) => satt(vardeRef.current ? `${vardeRef.current.trimEnd()} ${text}` : text)}
/>
)}
</span>
{flerRad ? (
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
) : (
+76 -13
View File
@@ -88,7 +88,7 @@ export default function ArendeSida() {
)}
{flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
</FelsokningSkal>
);
}
@@ -114,7 +114,7 @@ function InaktivitetsBanner({
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
</p>
<p className="mb-3 text-zinc-300">Beskriv kort vad som gjorts under denna period.</p>
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." />
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." rost />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
@@ -245,7 +245,7 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
)}
{fraga.svarstyp === "text" && (
<>
<TextFalt label="Svar" varde={text} satt={setText} />
<TextFalt label="Svar" varde={text} satt={setText} rost />
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
Spara svar
</StorKnapp>
@@ -255,11 +255,14 @@ function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) =>
);
}
// Verifierad checklista: en kontrollpunkt är inte slutförd enbart genom en
// kryssruta — minimikravet (foto, mätvärde eller kort observation) styrs av
// kontrolltypen i metodiken.
function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
const kontroll = steg.kontroll!;
const [resultat, setResultat] = useState("");
const filRef = useRef<HTMLInputElement>(null);
const arFoto = /fotografera|foto/i.test(kontroll.text);
const krav = kontroll.krav;
const utford = () =>
skicka({
@@ -270,11 +273,13 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
resultat: resultat.trim() || undefined,
});
const kravUppfyllt = krav === "foto" || !krav || resultat.trim().length > 0;
return (
<>
{steg.steg.beskrivning && <p className="mb-2 text-zinc-400">{steg.steg.beskrivning}</p>}
<p className="mb-4 text-2xl font-extrabold leading-snug">{kontroll.text}</p>
{arFoto && (
{krav === "foto" ? (
<>
<input
ref={filRef}
@@ -290,13 +295,36 @@ function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse)
utford();
}}
/>
<StorKnapp className="mb-2" onClick={() => filRef.current?.click()}>
📷 Ta / välj foto
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
<StorKnapp onClick={() => filRef.current?.click()}>📷 Ta foto verifierar kontrollen</StorKnapp>
<p className="mt-2 text-sm text-zinc-500">Denna kontroll verifieras med foto.</p>
</>
) : (
<>
<TextFalt
label={
krav === "matvarde"
? "Uppmätt värde (krävs för verifiering)"
: krav === "kommentar"
? "Vad observerades? (krävs för verifiering)"
: "Resultat / avläst värde (valfritt)"
}
varde={resultat}
satt={setResultat}
platshallare={krav === "matvarde" ? "T.ex. 2,4 bar samtliga hjul" : "T.ex. Säkringen är hel, spänning på båda sidor"}
rost
/>
{!kravUppfyllt && (
<p className="mb-3 text-sm font-bold text-amber-400">
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats lägg till en kort{" "}
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
</p>
)}
<StorKnapp disabled={!kravUppfyllt} onClick={utford}>
Markera verifierad
</StorKnapp>
</>
)}
<TextFalt label="Resultat / avläst värde (valfritt)" varde={resultat} satt={setResultat} platshallare="T.ex. 2,4 bar samtliga hjul" />
<StorKnapp onClick={utford}>Markera utförd</StorKnapp>
</>
);
}
@@ -375,6 +403,7 @@ function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
varde={text}
satt={setText}
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
rost
/>
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
@@ -521,12 +550,41 @@ function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik;
// Kundrapporten: en tydlig tidslinje i stället för "Felsökning 2,5 timmar".
// Interna poster (kategoribyten) visas inte för kund.
function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) {
function RapportFlik({
arende,
metodik,
nu,
skicka,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
skicka: (h: Handelse) => void;
}) {
const anvandare = useFelsokning((s) => s.anvandare);
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const fordelning = tidsfordelningsRader(arende, nu);
const kundposter = arende.handelser.filter((p) => p.handelse.typ !== "kategori_byte");
// Alla exporter bygger på samma händelselogg och versionsmärks:
// version = antal händelser vid exporttillfället.
const exporteraJson = () => {
const version = arende.handelser.length;
const data = {
export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() },
arende,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const lank = document.createElement("a");
lank.href = url;
lank.download = `arende-${arende.nummer}-v${version}.json`;
lank.click();
URL.revokeObjectURL(url);
skicka({ typ: "export_skapad", format: "JSON", version });
};
return (
<>
<Panel rubrik="Kundrapport">
@@ -534,9 +592,14 @@ function RapportFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas bilder kan
innehålla uppgifter om andra kunder.
</p>
<StorKnapp variant="sekundar" onClick={() => window.print()}>
Skriv ut / spara som PDF
</StorKnapp>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => window.print()}>
Skriv ut / PDF
</StorKnapp>
<StorKnapp variant="sekundar" onClick={exporteraJson}>
Exportera JSON
</StorKnapp>
</div>
</Panel>
<Panel rubrik={`Ärende #${arende.nummer}`}>
{b.objekt && (