diff --git a/docs/MASTER-PROMPT.md b/docs/MASTER-PROMPT.md index cc035d6..9672a9c 100644 --- a/docs/MASTER-PROMPT.md +++ b/docs/MASTER-PROMPT.md @@ -10,9 +10,20 @@ Det här är ett produktdirektiv, inte en teknisk specifikation. Visionen och re Bygg en produktionsredo SaaS-plattform med namnet **Guidad Felsökning**. -Plattformen är en AI-wrapper ovanpå Claude API (Anthropic) och fungerar som ett professionellt arbetsverktyg för mekaniker och servicetekniker. Standardmodell: Claude Opus 5 (`claude-opus-5`), med Anthropics rekommenderade reservmodell som automatisk fallback när en förfrågan avböjs av säkerhetsklassificerarna. +Plattformen är en AI-wrapper ovanpå Claude API (Anthropic) och fungerar som ett professionellt arbetsverktyg för mekaniker och servicetekniker. -**AI:n drivs av plattformen, inte av kunden.** Claude API-nycklarna är plattformshemligheter i backend och exponeras aldrig för kunder eller klienter — AI-handledningen ingår i tjänsten. Backend äger systemprompt, modellval och svarsschema, så AI-reglerna kan inte kringgås från klientsidan. Den ska inte ersätta teknisk kompetens eller tillverkarens dokumentation, utan vägleda användaren genom en strukturerad felsökningsprocess, dokumentera allt arbete och skapa full spårbarhet. +**AI:n drivs av plattformen, inte av kunden.** Claude API-nycklarna är plattformshemligheter i backend och exponeras aldrig för kunder eller klienter — AI-handledningen ingår i tjänsten. Backend äger systemprompt, modellval och svarsschema, så AI-reglerna kan inte kringgås från klientsidan. + +**Modellorkestern.** Vi kör flera Claude-modeller i vår infrastruktur och routar per uppgift — backend äger routingtabellen, så den kan justeras utan klientändringar: + +| Uppgift | Modell | Motiv | +| --- | --- | --- | +| Handledning (svar på varje dokumentation) | Claude Sonnet 5 | Många anrop, latenskänsligt på verkstadsgolvet | +| Granskning (motsägelser/luckor i hela underlaget) | Claude Opus 5, hög effort | Djupaste resonemanget — kvalitet före latens | +| Överlämningssammanfattning (risker & osäkerheter) | Claude Sonnet 5, låg effort | Balans | +| Metodikklassificering av felbeskrivning | Claude Haiku 4.5 | Ren klassificering — snabbast och billigast | + +Alla uppgifter delar samma grundregler (AI-reglerna nedan) och samma klassificerade svarsschema. Vid avböjd förfrågan faller anropet automatiskt tillbaka till Anthropics rekommenderade reservmodell. Den ska inte ersätta teknisk kompetens eller tillverkarens dokumentation, utan vägleda användaren genom en strukturerad felsökningsprocess, dokumentera allt arbete och skapa full spårbarhet. Målet är att lansera en stabil, enkel och köpvärdig beta-version. diff --git a/docs/MVP.md b/docs/MVP.md index 35dd2a7..af97655 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -17,7 +17,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Direktivets kärna | Status i MVP | | --- | --- | | Objektidentifiering först | ✅ Manuell inmatning (reg.nr/VIN/serienummer/maskinnummer) med bekräftelsesteg. QR/streckkod/OCR är markerade som kommande. | -| AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-handledning driven av plattformen**: edge-funktionen `felsokning-ai` äger Claude API-nyckeln (serverhemligheten `ANTHROPIC_API_KEY`), systemprompten, modellen (Claude Opus 5) och svarsschemat — kunden hanterar inga AI-nycklar. Claude svarar på varje bekräftad dokumentation, klassificerat som observation/verifierat/hypotes/rekommendation enligt AI-reglerna, schema-bundet, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan. Kräver inloggad användare; svaren loggas som händelser (interna — delas aldrig i kundvyer). I lokalt läge guidar metodiken ensam. | +| 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. | diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index c89dd08..5f9606c 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -1,25 +1,29 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { AI_MODELL, byggAnvandarPrompt, tolkaAiSvar } from "../ai"; +import { byggAnvandarPrompt, byggGranskningsPrompt, tolkaAiSvar } from "../ai"; import { VIBRATION_METODIK } from "../metodik"; import { brief } from "../projektioner"; import { byggDemoArende } from "../demo"; -describe("AI-motorn", () => { - it("använder Claude Opus 5", () => { - expect(AI_MODELL).toBe("claude-opus-5"); +const ENDPOINT = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8"); + +describe("AI-orkestern", () => { + it("plattformens endpoint routar uppgifter till olika modeller", () => { + // Servern äger orkestern: modellval, effort, systemprompt och schema. + for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:"]) { + expect(ENDPOINT).toContain(uppgift); + } + expect(ENDPOINT).toContain('"claude-sonnet-5"'); + expect(ENDPOINT).toContain('"claude-opus-5"'); + expect(ENDPOINT).toContain('"claude-haiku-4-5"'); + expect(ENDPOINT).toContain("ANTHROPIC_API_KEY"); }); - it("plattformens AI-endpoint äger systemprompten och kodar AI-reglerna", () => { - // AI:n drivs av plattformen: systemprompt, modell och schema ligger i - // backend (edge-funktionen), inte i klienten. - const endpoint = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8"); - expect(endpoint).toContain('const AI_MODELL = "claude-opus-5"'); - expect(endpoint).toContain("Hitta aldrig på fakta"); - expect(endpoint).toContain("aldrig en hypotes som ett konstaterat fel"); - expect(endpoint).toContain("KRÄVER verifiering"); - expect(endpoint).toContain("ANTHROPIC_API_KEY"); - expect(endpoint).toContain('"json_schema"'); + it("endpointen kodar AI-reglerna i grundprompten", () => { + expect(ENDPOINT).toContain("Hitta aldrig på fakta"); + expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel"); + expect(ENDPOINT).toContain("KRÄVER verifiering"); + expect(ENDPOINT).toContain('"json_schema"'); }); it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => { @@ -36,6 +40,14 @@ describe("AI-motorn", () => { expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte."); }); + it("granskningsprompten innehåller hela arbetsloggen men ingen ny inmatning", () => { + const arende = byggDemoArende(1); + const prompt = byggGranskningsPrompt(arende, VIBRATION_METODIK); + expect(prompt).toContain("Fullständig arbetslogg:"); + expect(prompt).toContain("Arbete överlämnat från Anna till Johan"); + expect(prompt).not.toContain("Teknikerns nya inmatning"); + }); + it("tolkar giltiga svar och kastar på ogiltiga", () => { const svar = tolkaAiSvar({ rader: [ diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 15cc995..7ba34ed 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -1,18 +1,26 @@ -// AI-handledning: Claude, driven av plattformen. +// AI-handledning: Claude-orkestern, driven av plattformen. // -// AI:n hostas av tjänsten — inte av kunden. Klienten skickar ärendekontexten -// till plattformens backend (edge-funktionen felsokning-ai), som äger -// Claude API-nyckeln, systemprompten, modellvalet och svarsschemat. -// Inga AI-nycklar hanteras eller lagras någonsin i klienten. +// Vi kör flera modeller i plattformens infrastruktur och routar per +// uppgift. Klienten skickar bara uppgiftstyp + underlag till backend +// (edge-funktionen felsokning-ai); servern äger modellval, effort, +// systemprompt och svarsschema. Inga AI-nycklar hanteras eller lagras +// någonsin i klienten, och svaret kommer alltid tillbaka strikt +// klassificerat enligt AI-reglerna. // -// Kommunikationsmodellen är text in, text ut: teknikerns bekräftade -// inmatning skickas med ärendebriefen, och svaret kommer tillbaka strikt -// klassificerat enligt AI-reglerna — observation / verifierat / hypotes / -// rekommendation — plus ETT konkret nästa steg. +// Orkestern (serverns routing): +// - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation) +// - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget) +// - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter) +// - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning) +import type { Arende } from "./domain"; +import { handelseRubrik } from "./domain"; +import type { Metodik } from "./metodik"; +import { METODIKER } from "./metodik"; import type { Brief } from "./projektioner"; +import { brief } from "./projektioner"; -export const AI_MODELL = "claude-opus-5"; +export type AiUppgift = "handledning" | "granskning" | "sammanfattning" | "metodikval"; export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation"; @@ -33,7 +41,11 @@ export interface AiSvar { nastaSteg: string; } -export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning: string): string { +export interface AiSvarMedModell extends AiSvar { + modell: string; +} + +export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning?: string): string { const rader: string[] = []; rader.push(`Metodik: ${metodikNamn}`); if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`); @@ -49,11 +61,22 @@ export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning: if (brief.ejKontrollerat.length > 0) { rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`); } - rader.push(""); - rader.push(`Teknikerns nya inmatning: ${inmatning}`); + if (inmatning) { + rader.push(""); + rader.push(`Teknikerns nya inmatning: ${inmatning}`); + } return rader.join("\n"); } +// Granskningen får hela arbetsloggen, inte bara briefen. +export function byggGranskningsPrompt(arende: Arende, metodik: Metodik): string { + const kontext = byggAnvandarPrompt(brief(arende, metodik), metodik.namn); + const logg = arende.handelser + .map((p) => `${p.tidpunkt.slice(11, 16)} ${p.anvandare}: ${handelseRubrik(p)}`) + .join("\n"); + return `${kontext}\n\nFullständig arbetslogg:\n${logg}`; +} + export function tolkaAiSvar(data: unknown): AiSvar { const svar = data as AiSvar; if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") { @@ -66,20 +89,53 @@ export function tolkaAiSvar(data: unknown): AiSvar { return { rader, nastaSteg: svar.nastaSteg }; } -// Returnerar null i lokalt läge (ej inloggad) — AI:n är en del av tjänsten -// och nås via plattformens autentiserade backend. -export async function fragaAi( - brief: Brief, - metodikNamn: string, - inmatning: string, -): Promise { +// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via +// plattformens autentiserade backend. +async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> { const { supabase } = await import("@/integrations/supabase/client"); const { data } = await supabase.auth.getSession(); if (!data.session) return null; - const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", { - body: { prompt: byggAnvandarPrompt(brief, metodikNamn, inmatning) }, + const { data: resultat, error } = await supabase.functions.invoke("felsokning-ai", { + body: { uppgift, prompt }, }); if (error) throw error; - return tolkaAiSvar(svar); + const { modell, svar } = resultat as { modell?: string; svar?: unknown }; + if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen"); + return { modell, svar }; +} + +export async function fragaAi( + briefData: Brief, + metodikNamn: string, + inmatning: string, +): Promise { + const resultat = await anropa("handledning", byggAnvandarPrompt(briefData, metodikNamn, inmatning)); + return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null; +} + +export async function granskaUnderlag(arende: Arende, metodik: Metodik): Promise { + const resultat = await anropa("granskning", byggGranskningsPrompt(arende, metodik)); + return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null; +} + +export async function sammanfattaOverlamning( + arende: Arende, + metodik: Metodik, +): Promise { + const resultat = await anropa("sammanfattning", byggGranskningsPrompt(arende, metodik)); + return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null; +} + +// Klassificerar felbeskrivningen till en metodik; null om lokalt läge, +// anropet misslyckas eller svaret inte är en känd metodik. +export async function valjMetodikMedAi(felbeskrivning: string): Promise { + try { + const resultat = await anropa("metodikval", felbeskrivning); + if (!resultat) return null; + const { metodikId } = resultat.svar as { metodikId?: string }; + return METODIKER.some((m) => m.id === metodikId) ? (metodikId as string) : null; + } catch { + return null; + } } diff --git a/src/felsokning/domain.ts b/src/felsokning/domain.ts index b0e77ab..ffc3e97 100644 --- a/src/felsokning/domain.ts +++ b/src/felsokning/domain.ts @@ -74,6 +74,9 @@ export interface Arende { // Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig // ändras (ärenderaden är append-only även i databasen). delningskod?: string; + // Explicit vald metodik (t.ex. via AI-klassificering vid skapandet). + // Saknas den härleds metodiken ur felbeskrivningen. + metodikId?: string; handelser: LoggPost[]; } diff --git a/src/felsokning/store.ts b/src/felsokning/store.ts index 200f76c..66a16ff 100644 --- a/src/felsokning/store.ts +++ b/src/felsokning/store.ts @@ -8,14 +8,14 @@ import { persist } from "zustand/middleware"; import type { Arende, Handelse, LoggPost, Objekt } from "./domain"; import { nyLoggPost } from "./domain"; import { felbeskrivning } from "./projektioner"; -import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik"; +import { GENERISK_METODIK, metodikForId, valjMetodik, type Metodik } from "./metodik"; interface FelsokningState { anvandare: string; arenden: Record; nastaNummer: number; sattAnvandare: (namn: string) => void; - skapaArende: (objekt: Objekt, felbeskrivningText: string) => string; + skapaArende: (objekt: Objekt, felbeskrivningText: string, metodikId?: string) => string; laggTill: (arendeId: string, handelse: Handelse) => void; // Används endast av synken: ersätter listan med den ihopflätade versionen. // Semantiken är fortfarande append-only — flätningen lägger bara till. @@ -39,7 +39,7 @@ export const useFelsokning = create()( sattAnvandare: (namn) => set({ anvandare: namn.trim() }), - skapaArende: (objekt, felbeskrivningText) => { + skapaArende: (objekt, felbeskrivningText, metodikId) => { const { anvandare, nastaNummer } = get(); const id = `arende-${Date.now().toString(36)}`; const arende: Arende = { @@ -47,6 +47,7 @@ export const useFelsokning = create()( nummer: nastaNummer, skapad: new Date().toISOString(), delningskod: nyDelningskod(), + metodikId, handelser: [ nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }), nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }), @@ -99,6 +100,7 @@ export const useFelsokning = create()( export function metodikForArende(arende: Arende | undefined): Metodik { if (!arende) return GENERISK_METODIK; + if (arende.metodikId) return metodikForId(arende.metodikId); const text = felbeskrivning(arende); return text ? valjMetodik(text) : GENERISK_METODIK; } diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 6ed76fa..1957687 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -18,7 +18,7 @@ import { } from "@/felsokning/projektioner"; import { metodikForArende, useFelsokning } from "@/felsokning/store"; import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; -import { AI_MODELL, AI_RADTYP_LABEL, fragaAi } from "@/felsokning/ai"; +import { AI_RADTYP_LABEL, fragaAi, granskaUnderlag, sammanfattaOverlamning } from "@/felsokning/ai"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; @@ -129,7 +129,7 @@ export default function ArendeSida() { )} {flik === "logg" && } - {flik === "brief" && } + {flik === "brief" && } {flik === "rapport" && } ); @@ -187,16 +187,15 @@ function GuideFlik({ const [visaOverlamning, setVisaOverlamning] = useState(false); const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar"); - // AI:n drivs av plattformen och svarar skriftligt på varje bekräftad - // dokumentation: klassificerat (observation/verifierat/hypotes/ - // rekommendation) och loggat som händelse. I lokalt läge (ej inloggad) - // guidar den deterministiska metodiken ensam. + // AI-orkestern drivs av plattformen: handledningen (Sonnet 5) svarar på + // varje bekräftad dokumentation, klassificerat och loggat som händelse. + // I lokalt läge (ej inloggad) guidar den deterministiska metodiken ensam. const fragaAiOmDokumentation = async (inmatning: string) => { setAiStatus("arbetar"); try { const svar = await fragaAi(brief(arende, metodik, nu), metodik.namn, inmatning); if (svar) { - skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL }); + skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell }); } setAiStatus("vilar"); } catch { @@ -240,9 +239,11 @@ function GuideFlik({ {(aiStatus !== "vilar" || senasteAiSvar) && ( {aiStatus === "arbetar" &&

AI analyserar …

} {aiStatus === "fel" && ( @@ -538,8 +539,28 @@ function OverlamningDialog({ }) { const anvandare = useFelsokning((s) => s.anvandare); const [till, setTill] = useState(""); + const [aiLage, setAiLage] = useState<"vilar" | "arbetar" | "fel" | "klar">("vilar"); + const [aiRader, setAiRader] = useState<{ typ: keyof typeof AI_RADTYP_LABEL; text: string }[]>([]); const text = overlamningstext(arende, metodik, nu); + // Orkesterns sammanfattningsuppgift: risker och osäkerheter som inte + // syns i checklistorna — loggas som AI-svar och visas i överlämningen. + const komplettera = async () => { + setAiLage("arbetar"); + try { + const svar = await sammanfattaOverlamning(arende, metodik); + if (svar) { + skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell }); + setAiRader(svar.rader); + setAiLage("klar"); + } else { + setAiLage("vilar"); + } + } catch { + setAiLage("fel"); + } + }; + return (
@@ -547,6 +568,24 @@ function OverlamningDialog({
           {text}
         
+ {aiLage === "vilar" && ( + + ✨ AI-komplettera: risker & osäkerheter + + )} + {aiLage === "arbetar" &&

AI sammanfattar …

} + {aiLage === "fel" && ( +

Kunde inte hämtas — kräver inloggning. Överlämningen fungerar ändå.

+ )} + {aiLage === "klar" && aiRader.length > 0 && ( +
+ {aiRader.map((rad, i) => ( +

+ {AI_RADTYP_LABEL[rad.typ]}: {rad.text} +

+ ))} +
+ )}
@@ -587,10 +626,64 @@ function LoggFlik({ arende }: { arende: Arende }) { ); } -function BriefFlik({ arende, metodik, nu }: { arende: Arende; metodik: Metodik; nu: string }) { +function BriefFlik({ + arende, + metodik, + nu, + skicka, +}: { + arende: Arende; + metodik: Metodik; + nu: string; + skicka: (h: Handelse) => void; +}) { const b = brief(arende, metodik, nu); + const [granskning, setGranskning] = useState<"vilar" | "arbetar" | "fel">("vilar"); + const senasteAi = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar"); + + // Djupgranskningen går till orkesterns tyngsta modell (Opus 5, hög + // effort): motsägelser, luckor och förhastade slutsatser i hela loggen. + const granska = async () => { + setGranskning("arbetar"); + try { + const svar = await granskaUnderlag(arende, metodik); + if (svar) skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell }); + setGranskning("vilar"); + } catch { + setGranskning("fel"); + } + }; + return ( <> + {!b.avslutat && ( + +

+ Låt orkesterns djupaste modell granska hela underlaget: motsägelser mellan observationer, + luckor i dokumentationen och slutsatser som saknar stöd. +

+ {granskning === "arbetar" &&

AI granskar underlaget …

} + {granskning === "fel" && ( +

Granskningen kunde inte hämtas — kräver inloggning. Försök igen.

+ )} + {senasteAi && senasteAi.handelse.typ === "ai_svar" && granskning === "vilar" && ( +
+ {senasteAi.handelse.rader.map((rad, i) => ( +

+ {AI_RADTYP_LABEL[rad.typ]}: {rad.text} +

+ ))} +

+ Nästa steg: {senasteAi.handelse.nastaSteg} +

+

Senaste AI-svar ({senasteAi.handelse.modell})

+
+ )} + + 🔍 AI-granska underlaget + +
+ )} {b.objekt ? ( <> diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index bf0cb18..368ccbe 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -3,7 +3,6 @@ import { Link } from "react-router-dom"; import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; import { byggDemoArende } from "@/felsokning/demo"; -import { AI_MODELL } from "@/felsokning/ai"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { tidDatum, tidKlockslag } from "@/felsokning/format"; @@ -91,12 +90,13 @@ export default function Arendelista() {

Inga ärenden i det här filtret.

)} - +

- Ingår i tjänsten och drivs av plattformen — Claude ({AI_MODELL}) svarar klassificerat på - dokumenterade observationer och mätvärden. Anropen går via plattformens backend; inga AI-nycklar - hanteras av verkstaden. Aktiveras automatiskt för inloggade användare; i lokalt läge guidar den - deterministiska metodiken ensam. + Ingår i tjänsten och drivs av plattformen — flera Claude-modeller med olika roller: handledning i + realtid på varje dokumentation, djupgranskning av hela underlaget, överlämningssammanfattning och + metodikklassificering. Anropen går via plattformens backend; inga AI-nycklar hanteras av + verkstaden. Aktiveras automatiskt för inloggade användare; i lokalt läge guidar den deterministiska + metodiken ensam.

diff --git a/src/pages/felsokning/NyttArende.tsx b/src/pages/felsokning/NyttArende.tsx index ad9ee99..932fab6 100644 --- a/src/pages/felsokning/NyttArende.tsx +++ b/src/pages/felsokning/NyttArende.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import type { Objekt } from "@/felsokning/domain"; import { useFelsokning } from "@/felsokning/store"; import { valjMetodik } from "@/felsokning/metodik"; +import { valjMetodikMedAi } from "@/felsokning/ai"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"]; @@ -20,6 +21,7 @@ export default function NyttArende() { const [beskrivning, setBeskrivning] = useState(""); const [kund, setKund] = useState(""); const [fel, setFel] = useState(""); + const [startar, setStartar] = useState(false); const objektet: Objekt = { typ, @@ -96,6 +98,7 @@ export default function NyttArende() { satt={setFel} platshallare="T.ex. Bilen vibrerar runt 88 km/h" flerRad + rost /> {metodik && (

@@ -103,13 +106,20 @@ export default function NyttArende() {

)} { - const id = skapaArende(objektet, fel.trim()); + disabled={!fel.trim() || startar} + onClick={async () => { + setStartar(true); + // Träffar nyckelordsvalet ingen specifik metodik får orkesterns + // klassificerare (Haiku 4.5) välja; i lokalt läge blir det generisk. + let metodikId: string | undefined; + if (valjMetodik(fel).id === "generisk") { + metodikId = (await valjMetodikMedAi(fel.trim())) ?? undefined; + } + const id = skapaArende(objektet, fel.trim(), metodikId); navigate(`/felsokning/arende/${id}`); }} > - Starta arbetslogg + {startar ? "Startar …" : "Starta arbetslogg"}
diff --git a/supabase/functions/felsokning-ai/index.ts b/supabase/functions/felsokning-ai/index.ts index 65cc045..fd93d25 100644 --- a/supabase/functions/felsokning-ai/index.ts +++ b/supabase/functions/felsokning-ai/index.ts @@ -1,9 +1,11 @@ -// Plattformens AI-endpoint för Guidad Felsökning. +// Plattformens AI-orkester för Guidad Felsökning. // -// AI:n drivs av plattformen, inte av kunden: Claude API-nyckeln är en -// serverhemlighet (ANTHROPIC_API_KEY) och lämnar aldrig backend. Klienten -// skickar ärendekontexten som text; servern äger systemprompt, modellval -// och svarsschema, så AI-reglerna kan inte kringgås från klientsidan. +// AI:n drivs av plattformen: Claude API-nyckeln är en serverhemlighet +// (ANTHROPIC_API_KEY) och lämnar aldrig backend. Vi kör flera modeller i +// vår infrastruktur och routar per uppgift — servern äger hela orkestern +// (modellval, effort, systemprompt, svarsschema), så routingen kan +// justeras utan klientändringar och AI-reglerna kan inte kringgås från +// klientsidan. // // Kräver inloggad användare (verify_jwt = true i config.toml). @@ -16,10 +18,9 @@ const corsHeaders = { "authorization, x-client-info, apikey, content-type", }; -const AI_MODELL = "claude-opus-5"; -const MAX_PROMPT_LANGD = 20000; +const MAX_PROMPT_LANGD = 40000; -const SYSTEM_PROMPT = `Du är en digital felsökningshandledare för professionella tekniker i plattformen Guidad Felsökning. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation — du säkerställer att arbetet sker metodiskt och dokumenteras korrekt. +const GRUND_REGLER = `Du arbetar i Guidad Felsökning, en professionell diagnostikplattform för tekniker. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation. Absoluta regler: - Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig. @@ -30,8 +31,7 @@ Absoluta regler: - "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den. - "rekommendation": nästa verifierbara kontroll eller mätning. - Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras. - -Arbetssätt: en kontroll i taget, inte långa utläggningar. Svara på svenska, konsekvent, kortfattat och metodiskt — max fyra rader plus nästa steg. "nastaSteg" är EN konkret, verifierbar åtgärd.`; +- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`; const SVARS_SCHEMA = { type: "object", @@ -54,6 +54,71 @@ const SVARS_SCHEMA = { additionalProperties: false, }; +const METODIK_SCHEMA = { + type: "object", + properties: { + metodikId: { type: "string", enum: ["vibration", "elsystem", "generisk"] }, + }, + required: ["metodikId"], + additionalProperties: false, +}; + +interface UppgiftKonfig { + modell: string; + // effort utelämnas för modeller som saknar parametern (Haiku 4.5). + effort?: "low" | "medium" | "high"; + maxTokens: number; + system: string; + schema: object; +} + +// Orkestern: en modell per uppgiftstyp. +// - handledning körs vid varje dokumentation och är latenskänslig på +// verkstadsgolvet → Sonnet 5 (nära Opus-kvalitet i realtid). +// - granskning läser hela underlaget och är kvalitetskritisk → Opus 5 +// med hög effort. +// - sammanfattning (överlämning) → Sonnet 5, låg effort. +// - metodikval är ren klassificering → Haiku 4.5. +const ORKESTER: Record = { + handledning: { + modell: "claude-sonnet-5", + effort: "medium", + maxTokens: 1024, + system: `${GRUND_REGLER} + +Uppgift: Du är teknikerns digitala felsökningshandledare. Teknikern har just dokumenterat något nytt — svara på det i ljuset av ärendebriefen. En kontroll i taget, inte långa utläggningar: max fyra rader plus nästa steg.`, + schema: SVARS_SCHEMA, + }, + granskning: { + modell: "claude-opus-5", + effort: "high", + maxTokens: 2048, + system: `${GRUND_REGLER} + +Uppgift: Granska HELA underlaget i ärendet. Leta specifikt efter (1) motsägelser mellan observationer eller mätvärden, (2) luckor — kontroller som borde vara gjorda givet symptombilden men saknas, (3) förhastade slutsatser som saknar stöd. Rapportera motsägelser och luckor som "observation", möjliga felorsaker som underlaget antyder som "hypotes", och det viktigaste att åtgärda som "rekommendation". Max sex rader.`, + schema: SVARS_SCHEMA, + }, + sammanfattning: { + modell: "claude-sonnet-5", + effort: "low", + maxTokens: 1024, + system: `${GRUND_REGLER} + +Uppgift: En tekniker lämnar över ärendet. Komplettera överlämningen med det som inte syns i checklistorna: risker och osäkerheter i underlaget, klassificerade som rader. "nastaSteg" är det första nästa tekniker bör göra. Max fyra rader.`, + schema: SVARS_SCHEMA, + }, + metodikval: { + modell: "claude-haiku-4-5", + maxTokens: 256, + system: `Du klassificerar en felbeskrivning från en verkstad till EN felsökningsmetodik: +- "vibration": vibrationer, skakningar eller obalans under körning. +- "elsystem": elektriska fel — reläer, säkringar, spänning, batteri, belysning, givare, strömförsörjning. +- "generisk": allt annat, eller när det är oklart. +Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`, + schema: METODIK_SCHEMA, + }, +}; + serve(async (req: Request): Promise => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); @@ -63,7 +128,11 @@ serve(async (req: Request): Promise => { if (!apiNyckel) { return svara(503, { error: "AI-tjänsten är inte konfigurerad." }); } - const { prompt } = await req.json(); + const { uppgift, prompt } = await req.json(); + const konfig = ORKESTER[uppgift as string]; + if (!konfig) { + return svara(400, { error: "Okänd uppgift." }); + } if (typeof prompt !== "string" || prompt.trim().length === 0) { return svara(400, { error: "prompt saknas." }); } @@ -73,19 +142,17 @@ serve(async (req: Request): Promise => { const klient = new Anthropic({ apiKey: apiNyckel }); const svar = await klient.beta.messages.create({ - model: AI_MODELL, - max_tokens: 1024, - // Verkstadsgolvet är latenskänsligt och uppgiften väl avgränsad - // av ärendebriefen — medium balanserar svarstid mot kvalitet. + model: konfig.modell, + max_tokens: konfig.maxTokens, output_config: { - effort: "medium", - format: { type: "json_schema", schema: SVARS_SCHEMA }, + ...(konfig.effort ? { effort: konfig.effort } : {}), + format: { type: "json_schema", schema: konfig.schema }, }, // Avböjer säkerhetsklassificerarna faller anropet automatiskt // tillbaka till Anthropics rekommenderade reservmodell. betas: ["server-side-fallback-2026-07-01"], fallbacks: "default", - system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: prompt }], // deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna } as any); @@ -97,8 +164,9 @@ serve(async (req: Request): Promise => { if (!textBlock) { return svara(502, { error: "AI-svaret saknade innehåll." }); } - return new Response((textBlock as { text: string }).text, { - headers: { ...corsHeaders, "Content-Type": "application/json" }, + return svara(200, { + modell: konfig.modell, + svar: JSON.parse((textBlock as { text: string }).text), }); } catch (fel) { console.error("felsokning-ai:", fel);