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:
+13
-2
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+79
-23
@@ -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<AiSvar | null> {
|
||||
// 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Arende>;
|
||||
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<FelsokningState>()(
|
||||
|
||||
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<FelsokningState>()(
|
||||
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<FelsokningState>()(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
|
||||
)}
|
||||
{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} />}
|
||||
</FelsokningSkal>
|
||||
);
|
||||
@@ -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) && (
|
||||
<Panel
|
||||
rubrik={`AI-handledning (${
|
||||
senasteAiSvar?.handelse.typ === "ai_svar" ? senasteAiSvar.handelse.modell : AI_MODELL
|
||||
})`}
|
||||
rubrik={
|
||||
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 === "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 (
|
||||
<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">
|
||||
@@ -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">
|
||||
{text}
|
||||
</pre>
|
||||
{aiLage === "vilar" && (
|
||||
<StorKnapp variant="sekundar" className="mb-3" onClick={komplettera}>
|
||||
✨ AI-komplettera: risker & 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" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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 [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 && (
|
||||
<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">
|
||||
{b.objekt ? (
|
||||
<>
|
||||
|
||||
@@ -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() {
|
||||
<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">
|
||||
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.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<p className="mb-3 text-sm font-bold text-zinc-400">
|
||||
@@ -103,13 +106,20 @@ export default function NyttArende() {
|
||||
</p>
|
||||
)}
|
||||
<StorKnapp
|
||||
disabled={!fel.trim()}
|
||||
onClick={() => {
|
||||
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"}
|
||||
</StorKnapp>
|
||||
</Panel>
|
||||
</FelsokningSkal>
|
||||
|
||||
@@ -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<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> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
@@ -63,7 +128,11 @@ serve(async (req: Request): Promise<Response> => {
|
||||
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<Response> => {
|
||||
|
||||
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<Response> => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user