skillguard/artifacts/api-server/src/lib/aiAnalysis.ts
amertensreplit 2236ad179d Add DE/EN/ES multilingual support to SkillGuard (Task #49)
German is source of truth; EN/ES fully translated with no German residue.
Auto-detects browser language (fallback German), persists choice, language
switcher on all pages, localized formats/Clerk/legal. Scans store their language.

Backend (T001-T003): language column on scans, openapi+codegen, ruleCatalogI18n,
language threaded scans route -> analyzeSkill -> runStaticRule -> AI calls.
Route/AI error messages localized via expanded i18n MESSAGES + reqLang(req)
(?lang query -> Accept-Language header -> "de"). No German left in routes.

Frontend (T004-T005): react-i18next framework, LanguageSwitcher, locale-aware
format.ts, Clerk localizations. All page/component strings externalized to
de/en/es locale area files across catalog, education, scan form/report/compare,
history, dashboard, admin, legal pages.

T006 verification + review-fix follow-up (this session):
- Applied formatNumber to all visible metrics in scan-report (risk score,
  severity counts, security/privacy) and scan-compare (risk score, file count,
  diff counts); PDF/HTML export numbers formatted via Intl.NumberFormat(lng).
- Fixed leftover `@workspace/n` import alias in i18n/index.ts -> real package
  `@workspace/api-client-react` (was failing workspace typecheck).
- Verified: full `pnpm run typecheck` green; api-server tests 72/72 pass;
  curl confirms localized error responses (de/en/es) on scans route.

Deviations: AI connection-test prompts left in German intentionally (sent to
the model, not user-facing). proposeFollowUpTasks already created #52.

Replit-Task-Id: 9f137230-db11-45dc-9276-4e5cbcceff03
2026-06-13 09:05:57 +00:00

286 lines
8.8 KiB
TypeScript

import type { AiProvider, Prompt } from "@workspace/db";
import type { ParsedFile, RawFinding, Severity, Axis } from "./ruleCatalog";
import { languageDirective, t, type Lang } from "./i18n";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "info"];
const AXES: Axis[] = ["security", "privacy"];
export type AiRuleConfig = {
ruleId: string;
title: string;
description: string;
axis: Axis;
severity: Severity;
};
export type AiResult = {
findings: RawFinding[];
error: string | null;
};
const FETCH_TIMEOUT_MS = 60000;
function redactSecrets(text: string, token: string | null | undefined): string {
let out = text;
if (token && token.length >= 4) {
out = out.split(token).join("[REDACTED]");
}
out = out.replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, "$1[REDACTED]");
out = out.replace(/\bsk-[A-Za-z0-9._\-]{8,}\b/g, "[REDACTED]");
return out;
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function buildSkillPayload(files: ParsedFile[]): string {
const parts: string[] = [];
let budget = 60000;
for (const f of files) {
if (f.content === "") continue;
const header = `\n===== DATEI: ${f.path} (${f.kind}) =====\n`;
const body = f.content.slice(0, 16000);
if (header.length + body.length > budget) {
parts.push(header + body.slice(0, Math.max(0, budget - header.length)));
break;
}
parts.push(header + body);
budget -= header.length + body.length;
}
return parts.join("\n");
}
function coerceFinding(
raw: unknown,
allowed: Map<string, AiRuleConfig>,
): RawFinding | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
const title = typeof o.title === "string" ? o.title.slice(0, 200) : null;
if (!title) return null;
const rule = typeof o.ruleId === "string" ? allowed.get(o.ruleId) : undefined;
if (!rule) {
return null;
}
return {
ruleId: rule.ruleId,
axis: rule.axis,
severity: rule.severity,
title,
description:
typeof o.description === "string" ? o.description.slice(0, 2000) : title,
remediation:
typeof o.remediation === "string" ? o.remediation.slice(0, 2000) : null,
file: typeof o.file === "string" ? o.file.slice(0, 400) : null,
line: typeof o.line === "number" ? o.line : null,
snippet: typeof o.snippet === "string" ? o.snippet.slice(0, 400) : null,
detectedBy: "ai",
};
}
function extractJson(text: string): unknown {
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fence ? fence[1] : text;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
throw new Error("Keine JSON-Antwort von der KI erhalten.");
}
return JSON.parse(candidate.slice(start, end + 1));
}
async function callOpenAiCompatible(
provider: AiProvider,
system: string,
user: string,
): Promise<string> {
const base = provider.baseUrl.replace(/\/$/, "");
const url = `${base}/chat/completions`;
const res = await fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${provider.apiToken ?? ""}`,
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(
`HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`,
);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
return data.choices?.[0]?.message?.content ?? "";
}
async function callAnthropic(
provider: AiProvider,
system: string,
user: string,
): Promise<string> {
const base = provider.baseUrl.replace(/\/$/, "");
const url = `${base}/messages`;
const res = await fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": provider.apiToken ?? "",
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: provider.model,
max_tokens: 4096,
system,
messages: [{ role: "user", content: user }],
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(
`HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`,
);
}
const data = (await res.json()) as { content?: { text?: string }[] };
return data.content?.[0]?.text ?? "";
}
export async function callProvider(
provider: AiProvider,
system: string,
user: string,
): Promise<string> {
if (provider.apiType === "anthropic") {
return callAnthropic(provider, system, user);
}
return callOpenAiCompatible(provider, system, user);
}
export async function listProviderModels(
provider: AiProvider,
): Promise<string[]> {
const base = provider.baseUrl.replace(/\/$/, "");
const url = `${base}/models`;
const headers: Record<string, string> =
provider.apiType === "anthropic"
? {
"x-api-key": provider.apiToken ?? "",
"anthropic-version": "2023-06-01",
}
: {
Authorization: `Bearer ${provider.apiToken ?? ""}`,
};
const res = await fetchWithTimeout(url, { method: "GET", headers });
if (!res.ok) {
const body = await res.text();
throw new Error(
`HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`,
);
}
const data = (await res.json()) as {
data?: { id?: unknown }[];
models?: { id?: unknown; name?: unknown }[];
};
const rows = Array.isArray(data.data)
? data.data
: Array.isArray(data.models)
? data.models
: [];
const ids = rows
.map((m) =>
typeof m.id === "string"
? m.id
: typeof (m as { name?: unknown }).name === "string"
? ((m as { name: string }).name)
: null,
)
.filter((id): id is string => !!id);
return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b));
}
function buildRuleMenu(aiRules: AiRuleConfig[]): string {
const lines = aiRules.map(
(r) => `- ${r.ruleId} (${r.axis}): ${r.title}${r.description}`,
);
return [
"",
"Ordne jeden Befund GENAU EINER der folgenden aktiven Kategorien zu und gib deren Kennung im Pflichtfeld \"ruleId\" zurück. Verwende ausschließlich diese Kennungen:",
...lines,
'Befunde, die zu keiner dieser Kategorien passen, lasse weg. Das Feld "severity" wird serverseitig festgelegt und kann von dir ignoriert werden.',
].join("\n");
}
export async function generateSkillDescription(
provider: AiProvider,
prompts: Prompt[],
files: ParsedFile[],
lang: Lang = "de",
): Promise<string | null> {
const descriptionPrompt =
prompts.find((p) => p.key === "description")?.content ?? "";
if (!descriptionPrompt) return null;
const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? "";
const payload = buildSkillPayload(files);
const user = `${descriptionPrompt}\n\n${languageDirective(lang)}\n\nHier ist das zu beschreibende Skill:\n${payload}`;
try {
const content = await callProvider(provider, systemPrompt, user);
const parsed = extractJson(content) as { description?: unknown };
const description =
typeof parsed.description === "string" ? parsed.description.trim() : "";
return description ? description.slice(0, 2000) : null;
} catch {
return null;
}
}
export async function runAiAnalysis(
provider: AiProvider,
prompts: Prompt[],
files: ParsedFile[],
aiRules: AiRuleConfig[],
lang: Lang = "de",
): Promise<AiResult> {
if (aiRules.length === 0) {
return { findings: [], error: null };
}
const allowed = new Map(aiRules.map((r) => [r.ruleId, r]));
const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? "";
const analysisPrompt =
prompts.find((p) => p.key === "analysis")?.content ?? "";
const payload = buildSkillPayload(files);
const user = `${analysisPrompt}\n${buildRuleMenu(aiRules)}\n\n${languageDirective(lang)}\n\nHier ist das zu prüfende Skill:\n${payload}`;
try {
const content = await callProvider(provider, systemPrompt, user);
const parsed = extractJson(content) as { findings?: unknown[] };
const findingsRaw = Array.isArray(parsed.findings) ? parsed.findings : [];
const findings = findingsRaw
.map((f) => coerceFinding(f, allowed))
.filter((f): f is RawFinding => f !== null);
return { findings, error: null };
} catch (err) {
return {
findings: [],
error: err instanceof Error ? err.message : t("aiUnknownError", lang),
};
}
}