Kör Claude som AI-modell i Guidad Felsökning
- AI-motor (ai.ts): Claude Opus 5 via officiella TypeScript-SDK:n. Teknikerns bekräftade dokumentation skickas med ärendebriefen som kontext; svaret är schema-bundet (structured outputs) och strikt klassificerat enligt AI-reglerna: observation, verifierat, hypotes (kräver alltid verifiering) och rekommendation, plus ETT konkret nästa steg. Fryst systemprompt med promptcache; automatisk server-side fallback till Anthropics rekommenderade reservmodell när säkerhetsklassificerarna avböjer; refusal hanteras innan innehållet läses. - Onboarding enligt direktivet: organisationens Claude API-nyckel läggs in på startsidan (maskerat fält). Utan nyckel guidar den deterministiska metodiken ensam. MVP-varning: nyckeln lagras på enheten och anropet görs från webbläsaren — i produktion via backend. - AI-svaren loggas som händelser med modellnamn och visas i guiden; de är arbetsmaterial och filtreras ur kundrapport, Live Share och databasens delningsfunktion, precis som hypoteser. - Master Prompt uppdaterad: AI-wrapper ovanpå Claude API, standard- modell claude-opus-5. Verifierat: 25 vitest-tester gröna (inkl. promptbygge, svarstolkning och systemprompt-regler), produktionsbygge ok, Playwright-röktest genom hela flödet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -10,7 +10,7 @@ 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å en LLM-leverantörs API och fungerar som ett professionellt arbetsverktyg för mekaniker och servicetekniker. 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.
|
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. 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.
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Första gången en kund loggar in:
|
|||||||
1. Skapa företag
|
1. Skapa företag
|
||||||
2. Lägg till logotyp
|
2. Lägg till logotyp
|
||||||
3. Lägg till användare
|
3. Lägg till användare
|
||||||
4. Lägg till API-nyckel för AI-tjänsten
|
4. Lägg till Claude API-nyckel
|
||||||
5. Lägg till eventuella integrationer
|
5. Lägg till eventuella integrationer
|
||||||
|
|
||||||
Klart. Hela onboarding ska ta mindre än fem minuter.
|
Klart. Hela onboarding ska ta mindre än fem minuter.
|
||||||
|
|||||||
+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 |
|
| 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, stora knappar. Två metodiker (vibration + generisk) som väljs utifrån felbeskrivningen. LLM-integrationen ansluter i samma motor senare. |
|
| AI-guidad felsökning | ✅ Deterministisk metodikmotor (en fråga i taget, tre metodiker) **plus Claude-handledning**: med organisationens Claude API-nyckel (läggs in på startsidan) svarar Claude Opus 5 skriftligt på varje bekräftad dokumentation — klassificerat som observation/verifierat/hypotes/rekommendation enligt AI-reglerna, schema-bundet så svaret aldrig kan läsas som fritext, med automatisk fallback till Anthropics rekommenderade reservmodell vid avböjd förfrågan. Svaren loggas som händelser (interna — delas aldrig i kundvyer). Utan nyckel guidar metodiken ensam. MVP: nyckeln lagras på enheten och anropet görs från webbläsaren; i produktion går det via backend. |
|
||||||
| 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. |
|
||||||
|
|||||||
Generated
+63
@@ -8,6 +8,7 @@
|
|||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
@@ -105,6 +106,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||||
|
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -2537,6 +2559,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@stripe/react-stripe-js": {
|
"node_modules/@stripe/react-stripe-js": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-6.2.0.tgz",
|
||||||
@@ -4915,6 +4943,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||||
@@ -5568,6 +5602,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -7318,6 +7365,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@@ -7706,6 +7763,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: strin
|
|||||||
const bilder = foton(arende);
|
const bilder = foton(arende);
|
||||||
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
|
||||||
const kundposter = arende.handelser.filter(
|
const kundposter = arende.handelser.filter(
|
||||||
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
|
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
||||||
);
|
);
|
||||||
const pagaende = b.rekommenderatNastaSteg[0];
|
const pagaende = b.rekommenderatNastaSteg[0];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AI_MODELL, SYSTEM_PROMPT, byggAnvandarPrompt, 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("systemprompten kodar AI-reglerna", () => {
|
||||||
|
expect(SYSTEM_PROMPT).toContain("Hitta aldrig på fakta");
|
||||||
|
expect(SYSTEM_PROMPT).toContain("aldrig en hypotes som ett konstaterat fel");
|
||||||
|
expect(SYSTEM_PROMPT).toContain("KRÄVER verifiering");
|
||||||
|
expect(SYSTEM_PROMPT).toContain("kortfattat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
|
||||||
|
const arende = byggDemoArende(1);
|
||||||
|
const prompt = byggAnvandarPrompt(
|
||||||
|
brief(arende, VIBRATION_METODIK),
|
||||||
|
VIBRATION_METODIK.namn,
|
||||||
|
"Reläet klickar inte.",
|
||||||
|
);
|
||||||
|
expect(prompt).toContain("Volvo XC60 D4 2019");
|
||||||
|
expect(prompt).toContain("Felbeskrivning:");
|
||||||
|
expect(prompt).toContain("Kontrollera lufttryck");
|
||||||
|
expect(prompt).toContain("Ej kontrollerat enligt metodiken:");
|
||||||
|
expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolkar giltiga svar och kastar på ogiltiga", () => {
|
||||||
|
const svar = tolkaAiSvar({
|
||||||
|
rader: [
|
||||||
|
{ typ: "verifierat", text: "Matningsspänning finns på stift 14." },
|
||||||
|
{ typ: "okand_typ", text: "ska filtreras bort" },
|
||||||
|
],
|
||||||
|
nastaSteg: "Kontrollera jordanslutningen på stift 7.",
|
||||||
|
});
|
||||||
|
expect(svar.rader).toHaveLength(1);
|
||||||
|
expect(svar.rader[0].typ).toBe("verifierat");
|
||||||
|
expect(svar.nastaSteg).toContain("stift 7");
|
||||||
|
|
||||||
|
expect(() => tolkaAiSvar({ rader: "inte en lista", nastaSteg: "x" })).toThrow();
|
||||||
|
expect(() => tolkaAiSvar(null)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// AI-motor: Claude som diagnostikhandledare.
|
||||||
|
//
|
||||||
|
// Kommunikationsmodellen är text in, text ut: teknikerns (bekräftade)
|
||||||
|
// inmatning skickas tillsammans med ärendebriefen, och Claude svarar
|
||||||
|
// kortfattat och klassificerat enligt AI-reglerna i Master Prompt —
|
||||||
|
// observation / verifierat / hypotes / rekommendation. Svaret är
|
||||||
|
// schema-bundet (structured outputs), så det kan aldrig komma tillbaka
|
||||||
|
// som fritext som råkar läsas som ett konstaterat fel.
|
||||||
|
//
|
||||||
|
// MVP: tenantens egen API-nyckel används direkt från klienten
|
||||||
|
// (dangerouslyAllowBrowser). I produktionsversionen flyttas anropet
|
||||||
|
// bakom plattformens backend så att nycklar aldrig lämnar servern.
|
||||||
|
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import type { Brief } from "./projektioner";
|
||||||
|
|
||||||
|
export const AI_MODELL = "claude-opus-5";
|
||||||
|
|
||||||
|
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
|
||||||
|
|
||||||
|
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
|
||||||
|
observation: "Observation",
|
||||||
|
verifierat: "Verifierat",
|
||||||
|
hypotes: "🔴 Hypotes",
|
||||||
|
rekommendation: "Rekommendation",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AiRad {
|
||||||
|
typ: AiRadTyp;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSvar {
|
||||||
|
rader: AiRad[];
|
||||||
|
nastaSteg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVARS_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
rader: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
typ: { type: "string", enum: ["observation", "verifierat", "hypotes", "rekommendation"] },
|
||||||
|
text: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["typ", "text"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nastaSteg: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["rader", "nastaSteg"],
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Fryst systemprompt (cachas mellan anrop) — all ärendespecifik kontext
|
||||||
|
// ligger i användarmeddelandet.
|
||||||
|
export 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.
|
||||||
|
|
||||||
|
Absoluta regler:
|
||||||
|
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig.
|
||||||
|
- Presentera aldrig en hypotes som ett konstaterat fel.
|
||||||
|
- Skilj strikt mellan raderna du returnerar:
|
||||||
|
- "verifierat": endast det som är belagt av mätvärden eller dokumenterade kontroller i underlaget.
|
||||||
|
- "observation": det som konstaterats utan slutsats.
|
||||||
|
- "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.`;
|
||||||
|
|
||||||
|
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})`);
|
||||||
|
if (brief.felbeskrivning) rader.push(`Felbeskrivning: ${brief.felbeskrivning}`);
|
||||||
|
if (brief.utfordaKontroller.length > 0) {
|
||||||
|
rader.push("Utförda kontroller:");
|
||||||
|
for (const k of brief.utfordaKontroller) rader.push(`- ${k.text}${k.resultat ? `: ${k.resultat}` : ""}`);
|
||||||
|
}
|
||||||
|
if (brief.observationer.length > 0) {
|
||||||
|
rader.push("Observationer och mätvärden:");
|
||||||
|
for (const o of brief.observationer) rader.push(`- ${o}`);
|
||||||
|
}
|
||||||
|
if (brief.ejKontrollerat.length > 0) {
|
||||||
|
rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`);
|
||||||
|
}
|
||||||
|
rader.push("");
|
||||||
|
rader.push(`Teknikerns nya inmatning: ${inmatning}`);
|
||||||
|
return rader.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tolkaAiSvar(data: unknown): AiSvar {
|
||||||
|
const svar = data as AiSvar;
|
||||||
|
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") {
|
||||||
|
throw new Error("Oväntat AI-svarsformat");
|
||||||
|
}
|
||||||
|
const giltiga: AiRadTyp[] = ["observation", "verifierat", "hypotes", "rekommendation"];
|
||||||
|
const rader = svar.rader.filter(
|
||||||
|
(rad): rad is AiRad => !!rad && giltiga.includes(rad.typ) && typeof rad.text === "string",
|
||||||
|
);
|
||||||
|
return { rader, nastaSteg: svar.nastaSteg };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fragaAi(
|
||||||
|
apiNyckel: string,
|
||||||
|
brief: Brief,
|
||||||
|
metodikNamn: string,
|
||||||
|
inmatning: string,
|
||||||
|
): Promise<AiSvar> {
|
||||||
|
const klient = new Anthropic({ apiKey: apiNyckel, dangerouslyAllowBrowser: true });
|
||||||
|
const svar = await klient.beta.messages.create({
|
||||||
|
model: AI_MODELL,
|
||||||
|
max_tokens: 1024,
|
||||||
|
// Verkstadsgolvet är latenskänsligt och uppgiften är väl avgränsad
|
||||||
|
// av briefen — medium balanserar svarstid mot kvalitet.
|
||||||
|
output_config: {
|
||||||
|
effort: "medium",
|
||||||
|
format: { type: "json_schema", schema: SVARS_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" } }],
|
||||||
|
messages: [{ role: "user", content: byggAnvandarPrompt(brief, metodikNamn, inmatning) }],
|
||||||
|
} as Parameters<typeof klient.beta.messages.create>[0]);
|
||||||
|
|
||||||
|
if (svar.stop_reason === "refusal") {
|
||||||
|
throw new Error("AI-tjänsten avböjde förfrågan.");
|
||||||
|
}
|
||||||
|
const textBlock = svar.content.find((block) => block.type === "text");
|
||||||
|
if (!textBlock || textBlock.type !== "text") {
|
||||||
|
throw new Error("AI-svaret saknade innehåll.");
|
||||||
|
}
|
||||||
|
return tolkaAiSvar(JSON.parse(textBlock.text));
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ export type Handelse =
|
|||||||
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||||
| { typ: "overlamning"; fran: string; till?: string }
|
| { typ: "overlamning"; fran: string; till?: string }
|
||||||
| { typ: "export_skapad"; format: string; version: number }
|
| { typ: "export_skapad"; format: string; version: number }
|
||||||
|
| {
|
||||||
|
typ: "ai_svar";
|
||||||
|
rader: { typ: "observation" | "verifierat" | "hypotes" | "rekommendation"; text: string }[];
|
||||||
|
nastaSteg: string;
|
||||||
|
modell: string;
|
||||||
|
}
|
||||||
| { typ: "arende_avslutat" };
|
| { typ: "arende_avslutat" };
|
||||||
|
|
||||||
export interface LoggPost {
|
export interface LoggPost {
|
||||||
@@ -112,6 +118,10 @@ export function handelseRubrik(post: LoggPost): string {
|
|||||||
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
|
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
|
||||||
case "export_skapad":
|
case "export_skapad":
|
||||||
return `Export skapad: ${h.format}, version ${h.version}`;
|
return `Export skapad: ${h.format}, version ${h.version}`;
|
||||||
|
case "ai_svar": {
|
||||||
|
const forsta = h.rader[0];
|
||||||
|
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
|
||||||
|
}
|
||||||
case "arende_avslutat":
|
case "arende_avslutat":
|
||||||
return "Felsökning avslutad";
|
return "Felsökning avslutad";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
|
|||||||
|
|
||||||
interface FelsokningState {
|
interface FelsokningState {
|
||||||
anvandare: string;
|
anvandare: string;
|
||||||
|
// Tenantens Claude API-nyckel (MVP: lagras endast på enheten; i
|
||||||
|
// produktionsversionen hanteras nycklar av backend, aldrig i klienten).
|
||||||
|
aiNyckel: string;
|
||||||
arenden: Record<string, Arende>;
|
arenden: Record<string, Arende>;
|
||||||
nastaNummer: number;
|
nastaNummer: number;
|
||||||
sattAnvandare: (namn: string) => void;
|
sattAnvandare: (namn: string) => void;
|
||||||
|
sattAiNyckel: (nyckel: string) => void;
|
||||||
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
|
skapaArende: (objekt: Objekt, felbeskrivningText: 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.
|
||||||
@@ -34,10 +38,12 @@ export const useFelsokning = create<FelsokningState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
anvandare: "",
|
anvandare: "",
|
||||||
|
aiNyckel: "",
|
||||||
arenden: {},
|
arenden: {},
|
||||||
nastaNummer: 1,
|
nastaNummer: 1,
|
||||||
|
|
||||||
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
|
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
|
||||||
|
sattAiNyckel: (nyckel) => set({ aiNyckel: nyckel.trim() }),
|
||||||
|
|
||||||
skapaArende: (objekt, felbeskrivningText) => {
|
skapaArende: (objekt, felbeskrivningText) => {
|
||||||
const { anvandare, nastaNummer } = get();
|
const { anvandare, nastaNummer } = get();
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export function TextFalt({
|
|||||||
platshallare,
|
platshallare,
|
||||||
flerRad,
|
flerRad,
|
||||||
rost,
|
rost,
|
||||||
|
losenord,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
varde: string;
|
varde: string;
|
||||||
@@ -99,6 +100,7 @@ export function TextFalt({
|
|||||||
platshallare?: string;
|
platshallare?: string;
|
||||||
flerRad?: boolean;
|
flerRad?: boolean;
|
||||||
rost?: boolean;
|
rost?: boolean;
|
||||||
|
losenord?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Tal-till-text skriver in i samma redigerbara fält — användaren
|
// Tal-till-text skriver in i samma redigerbara fält — användaren
|
||||||
// granskar och bekräftar alltid innan något sparas i loggen.
|
// granskar och bekräftar alltid innan något sparas i loggen.
|
||||||
@@ -119,7 +121,13 @@ export function TextFalt({
|
|||||||
{flerRad ? (
|
{flerRad ? (
|
||||||
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
|
||||||
) : (
|
) : (
|
||||||
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
|
<input
|
||||||
|
type={losenord ? "password" : "text"}
|
||||||
|
value={varde}
|
||||||
|
onChange={(e) => satt(e.target.value)}
|
||||||
|
placeholder={platshallare}
|
||||||
|
className={klass}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +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 { 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";
|
||||||
|
|
||||||
@@ -184,6 +185,25 @@ function GuideFlik({
|
|||||||
}) {
|
}) {
|
||||||
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
|
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
|
||||||
const [visaOverlamning, setVisaOverlamning] = useState(false);
|
const [visaOverlamning, setVisaOverlamning] = useState(false);
|
||||||
|
const aiNyckel = useFelsokning((s) => s.aiNyckel);
|
||||||
|
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
|
||||||
|
|
||||||
|
// AI:n svarar skriftligt på varje bekräftad dokumentation: klassificerat
|
||||||
|
// (observation/verifierat/hypotes/rekommendation) och loggat som händelse.
|
||||||
|
// Utan nyckel guidar den deterministiska metodiken ensam.
|
||||||
|
const fragaAiOmDokumentation = async (inmatning: string) => {
|
||||||
|
if (!aiNyckel) return;
|
||||||
|
setAiStatus("arbetar");
|
||||||
|
try {
|
||||||
|
const svar = await fragaAi(aiNyckel, brief(arende, metodik, nu), metodik.namn, inmatning);
|
||||||
|
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL });
|
||||||
|
setAiStatus("vilar");
|
||||||
|
} catch {
|
||||||
|
setAiStatus("fel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
|
||||||
|
|
||||||
if (avslutat) {
|
if (avslutat) {
|
||||||
return (
|
return (
|
||||||
@@ -217,7 +237,29 @@ function GuideFlik({
|
|||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<SnabbDokumentation skicka={skicka} />
|
{(aiStatus !== "vilar" || senasteAiSvar) && (
|
||||||
|
<Panel rubrik={`AI-handledning (${AI_MODELL})`}>
|
||||||
|
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar …</p>}
|
||||||
|
{aiStatus === "fel" && (
|
||||||
|
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas — kontrollera nyckel och nät. Metodiken fortsätter som vanligt.</p>
|
||||||
|
)}
|
||||||
|
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
|
||||||
|
<>
|
||||||
|
{senasteAiSvar.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-2 text-lg">
|
||||||
|
<span className="font-extrabold text-amber-400">Nästa steg:</span>{" "}
|
||||||
|
{senasteAiSvar.handelse.nastaSteg}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||||
@@ -380,7 +422,15 @@ const DOKTYPER = [
|
|||||||
|
|
||||||
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
|
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
|
||||||
// ej verifierade — de kan aldrig loggas som konstaterade fel.
|
// ej verifierade — de kan aldrig loggas som konstaterade fel.
|
||||||
function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
// paSparad anropas med en textsammanfattning EFTER att användaren bekräftat
|
||||||
|
// med Spara — det är den texten AI:n får (aldrig obekräftad inmatning).
|
||||||
|
function SnabbDokumentation({
|
||||||
|
skicka,
|
||||||
|
paSparad,
|
||||||
|
}: {
|
||||||
|
skicka: (h: Handelse) => void;
|
||||||
|
paSparad?: (sammanfattning: string) => void;
|
||||||
|
}) {
|
||||||
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
|
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [varde, setVarde] = useState("");
|
const [varde, setVarde] = useState("");
|
||||||
@@ -394,10 +444,22 @@ function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
|
|||||||
|
|
||||||
const spara = () => {
|
const spara = () => {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (typ === "observation" && t) skicka({ typ: "observation", text: t });
|
if (typ === "observation" && t) {
|
||||||
if (typ === "kommentar" && t) skicka({ typ: "kommentar", text: t });
|
skicka({ typ: "observation", text: t });
|
||||||
if (typ === "hypotes" && t) skicka({ typ: "hypotes", text: t, niva: "lag" });
|
paSparad?.(`Observation: ${t}`);
|
||||||
if (typ === "matvarde" && t && varde.trim()) skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
|
}
|
||||||
|
if (typ === "kommentar" && t) {
|
||||||
|
skicka({ typ: "kommentar", text: t });
|
||||||
|
paSparad?.(t);
|
||||||
|
}
|
||||||
|
if (typ === "hypotes" && t) {
|
||||||
|
skicka({ typ: "hypotes", text: t, niva: "lag" });
|
||||||
|
paSparad?.(`Teknikerns hypotes (ej verifierad): ${t}`);
|
||||||
|
}
|
||||||
|
if (typ === "matvarde" && t && varde.trim()) {
|
||||||
|
skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
|
||||||
|
paSparad?.(`Mätvärde: ${t} = ${varde.trim()}`);
|
||||||
|
}
|
||||||
aterstall();
|
aterstall();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -606,10 +668,10 @@ function RapportFlik({
|
|||||||
const b = brief(arende, metodik, nu);
|
const b = brief(arende, metodik, nu);
|
||||||
const bilder = foton(arende);
|
const bilder = foton(arende);
|
||||||
const fordelning = tidsfordelningsRader(arende, nu);
|
const fordelning = tidsfordelningsRader(arende, nu);
|
||||||
// Kategoribyten är interna; hypoteser är arbetsmaterial och ingår inte
|
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
|
||||||
// i det som delas med kund.
|
// och ingår inte i det som delas med kund.
|
||||||
const kundposter = arende.handelser.filter(
|
const kundposter = arende.handelser.filter(
|
||||||
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
|
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Alla exporter bygger på samma händelselogg och versionsmärks:
|
// Alla exporter bygger på samma händelselogg och versionsmärks:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ type Filter = "alla" | "pagaende" | "klara";
|
|||||||
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
||||||
// pågående, klara och starta nytt ärende.
|
// pågående, klara och starta nytt ärende.
|
||||||
export default function Arendelista() {
|
export default function Arendelista() {
|
||||||
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
|
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer, aiNyckel, sattAiNyckel } = useFelsokning();
|
||||||
const [namn, setNamn] = useState("");
|
const [namn, setNamn] = useState("");
|
||||||
const [filter, setFilter] = useState<Filter>("alla");
|
const [filter, setFilter] = useState<Filter>("alla");
|
||||||
|
const [visaAi, setVisaAi] = useState(false);
|
||||||
|
const [nyckel, setNyckel] = useState("");
|
||||||
|
|
||||||
if (!anvandare) {
|
if (!anvandare) {
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +93,42 @@ 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">
|
||||||
|
<p className="mb-2 text-zinc-300">
|
||||||
|
{aiNyckel
|
||||||
|
? `Aktiv — Claude (${AI_MODELL}) svarar klassificerat på dokumenterade observationer och mätvärden.`
|
||||||
|
: "Inte aktiverad — den deterministiska metodiken guidar. Lägg till organisationens Claude API-nyckel för AI-handledning."}
|
||||||
|
</p>
|
||||||
|
{!visaAi ? (
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => setVisaAi(true)}>
|
||||||
|
{aiNyckel ? "Ändra API-nyckel" : "Lägg till Claude API-nyckel"}
|
||||||
|
</StorKnapp>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextFalt label="Claude API-nyckel" varde={nyckel} satt={setNyckel} platshallare="sk-ant-…" losenord />
|
||||||
|
<p className="mb-3 text-sm text-zinc-500">
|
||||||
|
MVP: nyckeln sparas endast på den här enheten och anropen görs direkt från webbläsaren. I
|
||||||
|
produktionsversionen hanteras nycklar av plattformens backend.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StorKnapp variant="sekundar" onClick={() => { setVisaAi(false); setNyckel(""); }}>
|
||||||
|
Avbryt
|
||||||
|
</StorKnapp>
|
||||||
|
<StorKnapp
|
||||||
|
disabled={!nyckel.trim() && !aiNyckel}
|
||||||
|
onClick={() => {
|
||||||
|
sattAiNyckel(nyckel);
|
||||||
|
setVisaAi(false);
|
||||||
|
setNyckel("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nyckel.trim() ? "Spara nyckel" : "Ta bort nyckel"}
|
||||||
|
</StorKnapp>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{lista.map((arende) => {
|
{lista.map((arende) => {
|
||||||
const o = objekt(arende);
|
const o = objekt(arende);
|
||||||
const fel = felbeskrivning(arende);
|
const fel = felbeskrivning(arende);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ as $$
|
|||||||
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
|
select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id)
|
||||||
from felsokning_handelser h
|
from felsokning_handelser h
|
||||||
where h.arende_id = a.id
|
where h.arende_id = a.id
|
||||||
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes')
|
and h.handelse->>'typ' not in ('kategori_byte', 'hypotes', 'ai_svar')
|
||||||
), '[]'::jsonb)
|
), '[]'::jsonb)
|
||||||
)
|
)
|
||||||
from felsokning_arenden a
|
from felsokning_arenden a
|
||||||
|
|||||||
Reference in New Issue
Block a user