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
+26 -14
View File
@@ -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
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
// 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;
}
}
+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
// ä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[];
}
+5 -3
View File
@@ -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;
}