AI-orkester: flera Claude-modeller routade per uppgift

Plattformens AI-endpoint är nu en modellorkester — servern äger hela
routingtabellen (modell, effort, systemprompt, schema per uppgift), så
den justeras utan klientändringar:

- handledning    → Claude Sonnet 5 (medium): realtidssvar på varje
                   bekräftad dokumentation — latenskänsligt på golvet.
- granskning     → Claude Opus 5 (hög effort): motsägelser, luckor och
                   förhastade slutsatser i hela arbetsloggen; ny knapp
                   'AI-granska underlaget' i briefen.
- sammanfattning → Claude Sonnet 5 (låg effort): AI-komplettering av
                   överlämningen med risker & osäkerheter; ny knapp i
                   överlämningsdialogen.
- metodikval     → Claude Haiku 4.5: klassificerar felbeskrivningen
                   när nyckelordsvalet inte träffar en specifik metodik;
                   valet sparas på ärendet (nytt fält metodikId).

Alla uppgifter delar AI-reglerna och det klassificerade svarsschemat;
modellen som svarade loggas i varje ai_svar-händelse och visas i UI:t.
Felbeskrivningsfältet fick även röstinmatning.

Verifierat: 26 vitest-tester gröna (inkl. att endpointen routar till
tre distinkta modeller och att granskningsprompten innehåller hela
loggen), produktionsbygge ok, båda Playwright-sviterna gröna.

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-03 01:23:04 +00:00
parent ed483774fb
commit 8b79e3dfd5
10 changed files with 339 additions and 84 deletions
+13 -2
View File
@@ -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**. 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. Målet är att lansera en stabil, enkel och köpvärdig beta-version.
+1 -1
View File
@@ -17,7 +17,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Direktivets kärna | Status i MVP | | 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. | | 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. | | 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. | | 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), kommentarer och hypoteser. Hypoteser märks alltid 🔴 och kan aldrig loggas som konstaterade fel. |
+26 -14
View File
@@ -1,25 +1,29 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; 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 { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner"; import { brief } from "../projektioner";
import { byggDemoArende } from "../demo"; import { byggDemoArende } from "../demo";
describe("AI-motorn", () => { const ENDPOINT = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8");
it("använder Claude Opus 5", () => {
expect(AI_MODELL).toBe("claude-opus-5"); 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", () => { it("endpointen kodar AI-reglerna i grundprompten", () => {
// AI:n drivs av plattformen: systemprompt, modell och schema ligger i expect(ENDPOINT).toContain("Hitta aldrig på fakta");
// backend (edge-funktionen), inte i klienten. expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
const endpoint = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8"); expect(ENDPOINT).toContain("KRÄVER verifiering");
expect(endpoint).toContain('const AI_MODELL = "claude-opus-5"'); expect(ENDPOINT).toContain('"json_schema"');
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("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => { 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."); 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", () => { it("tolkar giltiga svar och kastar på ogiltiga", () => {
const svar = tolkaAiSvar({ const svar = tolkaAiSvar({
rader: [ rader: [
+79 -23
View File
@@ -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 // Vi kör flera modeller i plattformens infrastruktur och routar per
// till plattformens backend (edge-funktionen felsokning-ai), som äger // uppgift. Klienten skickar bara uppgiftstyp + underlag till backend
// Claude API-nyckeln, systemprompten, modellvalet och svarsschemat. // (edge-funktionen felsokning-ai); servern äger modellval, effort,
// Inga AI-nycklar hanteras eller lagras någonsin i klienten. // 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 // Orkestern (serverns routing):
// inmatning skickas med ärendebriefen, och svaret kommer tillbaka strikt // - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation)
// klassificerat enligt AI-reglerna — observation / verifierat / hypotes / // - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget)
// rekommendation — plus ETT konkret nästa steg. // - 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 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"; export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
@@ -33,7 +41,11 @@ export interface AiSvar {
nastaSteg: string; 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[] = []; const rader: string[] = [];
rader.push(`Metodik: ${metodikNamn}`); rader.push(`Metodik: ${metodikNamn}`);
if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`); 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) { if (brief.ejKontrollerat.length > 0) {
rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`); rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`);
} }
rader.push(""); if (inmatning) {
rader.push(`Teknikerns nya inmatning: ${inmatning}`); rader.push("");
rader.push(`Teknikerns nya inmatning: ${inmatning}`);
}
return rader.join("\n"); 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 { export function tolkaAiSvar(data: unknown): AiSvar {
const svar = data as AiSvar; const svar = data as AiSvar;
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") { 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 }; return { rader, nastaSteg: svar.nastaSteg };
} }
// Returnerar null i lokalt läge (ej inloggad) — AI:n är en del av tjänsten // Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
// och nås via plattformens autentiserade backend. // plattformens autentiserade backend.
export async function fragaAi( async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> {
brief: Brief,
metodikNamn: string,
inmatning: string,
): Promise<AiSvar | null> {
const { supabase } = await import("@/integrations/supabase/client"); const { supabase } = await import("@/integrations/supabase/client");
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession();
if (!data.session) return null; if (!data.session) return null;
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", { const { data: resultat, error } = await supabase.functions.invoke("felsokning-ai", {
body: { prompt: byggAnvandarPrompt(brief, metodikNamn, inmatning) }, body: { uppgift, prompt },
}); });
if (error) throw error; 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<AiSvarMedModell | null> {
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<AiSvarMedModell | null> {
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<AiSvarMedModell | null> {
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<string | null> {
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;
}
} }
+3
View File
@@ -74,6 +74,9 @@ export interface Arende {
// Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig // Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig
// ändras (ärenderaden är append-only även i databasen). // ändras (ärenderaden är append-only även i databasen).
delningskod?: string; delningskod?: string;
// Explicit vald metodik (t.ex. via AI-klassificering vid skapandet).
// Saknas den härleds metodiken ur felbeskrivningen.
metodikId?: string;
handelser: LoggPost[]; handelser: LoggPost[];
} }
+5 -3
View File
@@ -8,14 +8,14 @@ import { persist } from "zustand/middleware";
import type { Arende, Handelse, LoggPost, Objekt } from "./domain"; import type { Arende, Handelse, LoggPost, Objekt } from "./domain";
import { nyLoggPost } from "./domain"; import { nyLoggPost } from "./domain";
import { felbeskrivning } from "./projektioner"; import { felbeskrivning } from "./projektioner";
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik"; import { GENERISK_METODIK, metodikForId, valjMetodik, type Metodik } from "./metodik";
interface FelsokningState { interface FelsokningState {
anvandare: string; anvandare: string;
arenden: Record<string, Arende>; arenden: Record<string, Arende>;
nastaNummer: number; nastaNummer: number;
sattAnvandare: (namn: string) => void; sattAnvandare: (namn: string) => void;
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string; skapaArende: (objekt: Objekt, felbeskrivningText: string, metodikId?: string) => string;
laggTill: (arendeId: string, handelse: Handelse) => void; laggTill: (arendeId: string, handelse: Handelse) => void;
// Används endast av synken: ersätter listan med den ihopflätade versionen. // Används endast av synken: ersätter listan med den ihopflätade versionen.
// Semantiken är fortfarande append-only — flätningen lägger bara till. // Semantiken är fortfarande append-only — flätningen lägger bara till.
@@ -39,7 +39,7 @@ export const useFelsokning = create<FelsokningState>()(
sattAnvandare: (namn) => set({ anvandare: namn.trim() }), sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
skapaArende: (objekt, felbeskrivningText) => { skapaArende: (objekt, felbeskrivningText, metodikId) => {
const { anvandare, nastaNummer } = get(); const { anvandare, nastaNummer } = get();
const id = `arende-${Date.now().toString(36)}`; const id = `arende-${Date.now().toString(36)}`;
const arende: Arende = { const arende: Arende = {
@@ -47,6 +47,7 @@ export const useFelsokning = create<FelsokningState>()(
nummer: nastaNummer, nummer: nastaNummer,
skapad: new Date().toISOString(), skapad: new Date().toISOString(),
delningskod: nyDelningskod(), delningskod: nyDelningskod(),
metodikId,
handelser: [ handelser: [
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }), nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }), nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
@@ -99,6 +100,7 @@ export const useFelsokning = create<FelsokningState>()(
export function metodikForArende(arende: Arende | undefined): Metodik { export function metodikForArende(arende: Arende | undefined): Metodik {
if (!arende) return GENERISK_METODIK; if (!arende) return GENERISK_METODIK;
if (arende.metodikId) return metodikForId(arende.metodikId);
const text = felbeskrivning(arende); const text = felbeskrivning(arende);
return text ? valjMetodik(text) : GENERISK_METODIK; return text ? valjMetodik(text) : GENERISK_METODIK;
} }
+104 -11
View File
@@ -18,7 +18,7 @@ import {
} from "@/felsokning/projektioner"; } from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store"; import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk"; 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 { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format"; import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -129,7 +129,7 @@ export default function ArendeSida() {
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} /> <GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
)} )}
{flik === "logg" && <LoggFlik arende={arende} />} {flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} />} {flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />} {flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
</FelsokningSkal> </FelsokningSkal>
); );
@@ -187,16 +187,15 @@ function GuideFlik({
const [visaOverlamning, setVisaOverlamning] = useState(false); const [visaOverlamning, setVisaOverlamning] = useState(false);
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar"); const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
// AI:n drivs av plattformen och svarar skriftligt på varje bekräftad // AI-orkestern drivs av plattformen: handledningen (Sonnet 5) svarar på
// dokumentation: klassificerat (observation/verifierat/hypotes/ // varje bekräftad dokumentation, klassificerat och loggat som händelse.
// rekommendation) och loggat som händelse. I lokalt läge (ej inloggad) // I lokalt läge (ej inloggad) guidar den deterministiska metodiken ensam.
// guidar den deterministiska metodiken ensam.
const fragaAiOmDokumentation = async (inmatning: string) => { const fragaAiOmDokumentation = async (inmatning: string) => {
setAiStatus("arbetar"); setAiStatus("arbetar");
try { try {
const svar = await fragaAi(brief(arende, metodik, nu), metodik.namn, inmatning); const svar = await fragaAi(brief(arende, metodik, nu), metodik.namn, inmatning);
if (svar) { 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"); setAiStatus("vilar");
} catch { } catch {
@@ -240,9 +239,11 @@ function GuideFlik({
{(aiStatus !== "vilar" || senasteAiSvar) && ( {(aiStatus !== "vilar" || senasteAiSvar) && (
<Panel <Panel
rubrik={`AI-handledning (${ rubrik={
senasteAiSvar?.handelse.typ === "ai_svar" ? senasteAiSvar.handelse.modell : AI_MODELL senasteAiSvar?.handelse.typ === "ai_svar"
})`} ? `AI-handledning (${senasteAiSvar.handelse.modell})`
: "AI-handledning"
}
> >
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar </p>} {aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar </p>}
{aiStatus === "fel" && ( {aiStatus === "fel" && (
@@ -538,8 +539,28 @@ function OverlamningDialog({
}) { }) {
const anvandare = useFelsokning((s) => s.anvandare); const anvandare = useFelsokning((s) => s.anvandare);
const [till, setTill] = useState(""); 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); 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 ( return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center"> <div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded-lg border-2 border-zinc-600 bg-zinc-900 p-4"> <div className="max-h-full w-full max-w-2xl overflow-y-auto rounded-lg border-2 border-zinc-600 bg-zinc-900 p-4">
@@ -547,6 +568,24 @@ function OverlamningDialog({
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg bg-zinc-950 p-4 text-sm text-zinc-200"> <pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg bg-zinc-950 p-4 text-sm text-zinc-200">
{text} {text}
</pre> </pre>
{aiLage === "vilar" && (
<StorKnapp variant="sekundar" className="mb-3" onClick={komplettera}>
AI-komplettera: risker &amp; osäkerheter
</StorKnapp>
)}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-bold text-amber-400">AI sammanfattar </p>}
{aiLage === "fel" && (
<p className="mb-3 font-bold text-red-400">Kunde inte hämtas kräver inloggning. Överlämningen fungerar ändå.</p>
)}
{aiLage === "klar" && aiRader.length > 0 && (
<div className="mb-3 rounded-lg bg-zinc-950 p-4">
{aiRader.map((rad, i) => (
<p key={i} className="py-0.5 text-sm text-zinc-200">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
</div>
)}
<TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" /> <TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={stang}> <StorKnapp variant="sekundar" onClick={stang}>
@@ -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 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 ( return (
<> <>
{!b.avslutat && (
<Panel rubrik="AI-granskning">
<p className="mb-2 text-zinc-300">
Låt orkesterns djupaste modell granska hela underlaget: motsägelser mellan observationer,
luckor i dokumentationen och slutsatser som saknar stöd.
</p>
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI granskar underlaget </p>}
{granskning === "fel" && (
<p className="mb-2 font-bold text-red-400">Granskningen kunde inte hämtas kräver inloggning. Försök igen.</p>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && granskning === "vilar" && (
<div className="mb-2">
{senasteAi.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-lg">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-1 text-lg">
<span className="font-extrabold text-amber-400">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-xs text-zinc-500">Senaste AI-svar ({senasteAi.handelse.modell})</p>
</div>
)}
<StorKnapp variant="sekundar" disabled={granskning === "arbetar"} onClick={granska}>
🔍 AI-granska underlaget
</StorKnapp>
</Panel>
)}
<Panel rubrik="Objekt"> <Panel rubrik="Objekt">
{b.objekt ? ( {b.objekt ? (
<> <>
+6 -6
View File
@@ -3,7 +3,6 @@ import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
import { byggDemoArende } from "@/felsokning/demo"; import { byggDemoArende } from "@/felsokning/demo";
import { AI_MODELL } from "@/felsokning/ai";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { tidDatum, tidKlockslag } from "@/felsokning/format"; import { tidDatum, tidKlockslag } from "@/felsokning/format";
@@ -91,12 +90,13 @@ export default function Arendelista() {
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p> <p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
)} )}
<Panel rubrik="AI-handledning"> <Panel rubrik="AI-orkestern">
<p className="text-zinc-300"> <p className="text-zinc-300">
Ingår i tjänsten och drivs av plattformen Claude ({AI_MODELL}) svarar klassificerat Ingår i tjänsten och drivs av plattformen flera Claude-modeller med olika roller: handledning i
dokumenterade observationer och mätvärden. Anropen går via plattformens backend; inga AI-nycklar realtid varje dokumentation, djupgranskning av hela underlaget, överlämningssammanfattning och
hanteras av verkstaden. Aktiveras automatiskt för inloggade användare; i lokalt läge guidar den metodikklassificering. Anropen går via plattformens backend; inga AI-nycklar hanteras av
deterministiska metodiken ensam. verkstaden. Aktiveras automatiskt för inloggade användare; i lokalt läge guidar den deterministiska
metodiken ensam.
</p> </p>
</Panel> </Panel>
+14 -4
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain"; import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store"; import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik"; import { valjMetodik } from "@/felsokning/metodik";
import { valjMetodikMedAi } from "@/felsokning/ai";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"]; const IDENTIFIERINGSMETODER = ["Registreringsnummer", "VIN", "Serienummer", "Maskinnummer", "Manuell inmatning"];
@@ -20,6 +21,7 @@ export default function NyttArende() {
const [beskrivning, setBeskrivning] = useState(""); const [beskrivning, setBeskrivning] = useState("");
const [kund, setKund] = useState(""); const [kund, setKund] = useState("");
const [fel, setFel] = useState(""); const [fel, setFel] = useState("");
const [startar, setStartar] = useState(false);
const objektet: Objekt = { const objektet: Objekt = {
typ, typ,
@@ -96,6 +98,7 @@ export default function NyttArende() {
satt={setFel} satt={setFel}
platshallare="T.ex. Bilen vibrerar runt 88 km/h" platshallare="T.ex. Bilen vibrerar runt 88 km/h"
flerRad flerRad
rost
/> />
{metodik && ( {metodik && (
<p className="mb-3 text-sm font-bold text-zinc-400"> <p className="mb-3 text-sm font-bold text-zinc-400">
@@ -103,13 +106,20 @@ export default function NyttArende() {
</p> </p>
)} )}
<StorKnapp <StorKnapp
disabled={!fel.trim()} disabled={!fel.trim() || startar}
onClick={() => { onClick={async () => {
const id = skapaArende(objektet, fel.trim()); 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}`); navigate(`/felsokning/arende/${id}`);
}} }}
> >
Starta arbetslogg {startar ? "Startar …" : "Starta arbetslogg"}
</StorKnapp> </StorKnapp>
</Panel> </Panel>
</FelsokningSkal> </FelsokningSkal>
+88 -20
View File
@@ -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 // AI:n drivs av plattformen: Claude API-nyckeln är en serverhemlighet
// serverhemlighet (ANTHROPIC_API_KEY) och lämnar aldrig backend. Klienten // (ANTHROPIC_API_KEY) och lämnar aldrig backend. Vi kör flera modeller i
// skickar ärendekontexten som text; servern äger systemprompt, modellval // vår infrastruktur och routar per uppgift — servern äger hela orkestern
// och svarsschema, så AI-reglerna kan inte kringgås från klientsidan. // (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). // Kräver inloggad användare (verify_jwt = true i config.toml).
@@ -16,10 +18,9 @@ const corsHeaders = {
"authorization, x-client-info, apikey, content-type", "authorization, x-client-info, apikey, content-type",
}; };
const AI_MODELL = "claude-opus-5"; const MAX_PROMPT_LANGD = 40000;
const MAX_PROMPT_LANGD = 20000;
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: Absoluta regler:
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig. - 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. - "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
- "rekommendation": nästa verifierbara kontroll eller mätning. - "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. - Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
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.`;
const SVARS_SCHEMA = { const SVARS_SCHEMA = {
type: "object", type: "object",
@@ -54,6 +54,71 @@ const SVARS_SCHEMA = {
additionalProperties: false, 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<string, UppgiftKonfig> = {
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<Response> => { serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); return new Response(null, { headers: corsHeaders });
@@ -63,7 +128,11 @@ serve(async (req: Request): Promise<Response> => {
if (!apiNyckel) { if (!apiNyckel) {
return svara(503, { error: "AI-tjänsten är inte konfigurerad." }); 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) { if (typeof prompt !== "string" || prompt.trim().length === 0) {
return svara(400, { error: "prompt saknas." }); return svara(400, { error: "prompt saknas." });
} }
@@ -73,19 +142,17 @@ serve(async (req: Request): Promise<Response> => {
const klient = new Anthropic({ apiKey: apiNyckel }); const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({ const svar = await klient.beta.messages.create({
model: AI_MODELL, model: konfig.modell,
max_tokens: 1024, max_tokens: konfig.maxTokens,
// Verkstadsgolvet är latenskänsligt och uppgiften väl avgränsad
// av ärendebriefen — medium balanserar svarstid mot kvalitet.
output_config: { output_config: {
effort: "medium", ...(konfig.effort ? { effort: konfig.effort } : {}),
format: { type: "json_schema", schema: SVARS_SCHEMA }, format: { type: "json_schema", schema: konfig.schema },
}, },
// Avböjer säkerhetsklassificerarna faller anropet automatiskt // Avböjer säkerhetsklassificerarna faller anropet automatiskt
// tillbaka till Anthropics rekommenderade reservmodell. // tillbaka till Anthropics rekommenderade reservmodell.
betas: ["server-side-fallback-2026-07-01"], betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default", 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 }], messages: [{ role: "user", content: prompt }],
// deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna // deno-lint-ignore no-explicit-any -- fallbacks/output_config ligger före SDK-typerna
} as any); } as any);
@@ -97,8 +164,9 @@ serve(async (req: Request): Promise<Response> => {
if (!textBlock) { if (!textBlock) {
return svara(502, { error: "AI-svaret saknade innehåll." }); return svara(502, { error: "AI-svaret saknade innehåll." });
} }
return new Response((textBlock as { text: string }).text, { return svara(200, {
headers: { ...corsHeaders, "Content-Type": "application/json" }, modell: konfig.modell,
svar: JSON.parse((textBlock as { text: string }).text),
}); });
} catch (fel) { } catch (fel) {
console.error("felsokning-ai:", fel); console.error("felsokning-ai:", fel);