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:
@@ -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");
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user