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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user