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} />
) : (