Add on-demand AI description generation for existing scans

Task #24: Older scans created before description generation existed showed an
empty "Was macht dieser Skill?" section. Users can now trigger description
generation for any existing scan from the report.

Changes:
- OpenAPI: added POST /scans/{id}/description (operationId generateScanDescription)
  returning ScanDetail (200), ApiError (404 not found, 422 cannot generate).
  Regenerated api-zod and api-client-react via codegen.
- api-server (routes/scans.ts): new route loads the scan, its stored files, the
  enabled provider and prompts, reconstructs ParsedFile[] from scan_files
  (binary files -> empty content/isBinary), calls existing
  generateSkillDescription(), persists description and returns full ScanDetail.
  Clean 422 errors when no provider / no token / generation yields nothing; the
  scan is never mutated on failure.
- skillguard (scan-report.tsx): the description card now always renders; when no
  description exists it shows a "Beschreibung erzeugen" button wired to the new
  mutation, with loading state, toast feedback, and query cache update on success.

Incidental fix: the dev/test database was missing the `scans.description` column
(schema drift from the earlier description task). Ran drizzle-kit push to sync;
this unblocked 5 previously failing api-server tests. All 59 tests now pass and
full typecheck is green.

Rebase: one conflict in scan-report.tsx import line — main added the `ShieldAlert`
icon (new KI-disclaimer Alert), this branch added `Loader2`. Resolved by keeping
both icons; the rest of the file (disclaimer Alert + new description card) merged
cleanly. No semantic divergence.

Replit-Task-Id: 0610af4f-aa62-434e-abcd-d742081b6459
This commit is contained in:
amertensreplit 2026-06-11 01:25:35 +00:00
parent 487d1f3d3c
commit 9415e184dc
5 changed files with 316 additions and 14 deletions

View file

@ -8,6 +8,9 @@ import {
type ScanFile,
type Finding,
type ScanRelation,
aiProvidersTable,
promptsTable,
type Prompt,
} from "@workspace/db";
import { eq, desc, count } from "drizzle-orm";
import {
@ -28,6 +31,7 @@ import {
} from "../lib/skillParser";
import { analyzeSkill, type EngineResult } from "../lib/scanEngine";
import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog";
import { generateSkillDescription } from "../lib/aiAnalysis";
import { computeFingerprint } from "../lib/skillFingerprint";
import { lineDiff, lineSimilarity } from "../lib/lineDiff";
import { logger } from "../lib/logger";
@ -732,4 +736,81 @@ router.delete("/scans/:id", async (req, res) => {
return res.status(204).send();
});
// Generate the AI description for an existing scan that has none yet (older
// scans were created before description generation existed). Reuses the same
// generateSkillDescription() helper and the configured provider. A failure must
// never alter the stored scan.
router.post("/scans/:id/description", async (req, res) => {
const params = GetScanParams.safeParse(req.params);
if (!params.success)
return res.status(400).json({ error: "Ungültige ID" });
const [scan] = await db
.select()
.from(scansTable)
.where(eq(scansTable.id, params.data.id));
if (!scan) return res.status(404).json({ error: "Scan nicht gefunden" });
const storedFiles = await db
.select()
.from(scanFilesTable)
.where(eq(scanFilesTable.scanId, scan.id));
const [provider] = await db
.select()
.from(aiProvidersTable)
.where(eq(aiProvidersTable.enabled, true))
.limit(1);
if (!provider) {
return res.status(422).json({
error:
"Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.",
});
}
if (!provider.apiToken) {
return res.status(422).json({
error: `Für den Provider "${provider.name}" ist kein API-Token hinterlegt.`,
});
}
const prompts: Prompt[] = await db.select().from(promptsTable);
// Reconstruct ParsedFile inputs from the stored scan files. Binary files have
// no stored content; generateSkillDescription skips empty content anyway.
const files: ParsedFile[] = storedFiles.map((f) => ({
path: f.path,
kind: f.kind as ParsedFile["kind"],
language: f.language,
content: f.content ?? "",
size: f.size,
hash: f.hash,
isBinary: f.content === null,
}));
const description = await generateSkillDescription(provider, prompts, files);
if (!description) {
return res.status(422).json({
error:
"Die Beschreibung konnte nicht erzeugt werden. Bitte Provider-Konfiguration und KI-Prompts prüfen.",
});
}
const [updated] = await db
.update(scansTable)
.set({ description })
.where(eq(scansTable.id, scan.id))
.returning();
const findings = await db
.select()
.from(findingsTable)
.where(eq(findingsTable.scanId, scan.id))
.orderBy(findingsTable.id);
return res.json(
GetScanResponse.parse(await buildScanDetail(updated, storedFiles, findings)),
);
});
export default router;

View file

@ -1,11 +1,14 @@
import { useState, useMemo } from "react";
import { useRoute, Link } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import {
useGetScan,
getGetScanQueryKey,
useGetScanLineage,
getGetScanLineageQueryKey,
useGenerateScanDescription,
} from "@workspace/api-client-react";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@ -16,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format";
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles } from "lucide-react";
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2 } from "lucide-react";
import type { ScanDetail } from "@workspace/api-client-react";
export default function ScanReport() {
@ -33,6 +36,27 @@ export default function ScanReport() {
const [filterAxis, setFilterAxis] = useState<string>("all");
const [filterSeverity, setFilterSeverity] = useState<string>("all");
const queryClient = useQueryClient();
const { toast } = useToast();
const generateDescription = useGenerateScanDescription({
mutation: {
onSuccess: (updated) => {
queryClient.setQueryData(getGetScanQueryKey(updated.id), updated);
toast({ title: "Beschreibung erzeugt" });
},
onError: (err) => {
const message =
(err as { data?: { error?: string } })?.data?.error ??
"Die Beschreibung konnte nicht erzeugt werden.";
toast({
title: "Fehler",
description: message,
variant: "destructive",
});
},
},
});
const filteredFindings = useMemo(() => {
if (!data?.findings) return [];
return data.findings.filter(f => {
@ -143,7 +167,6 @@ export default function ScanReport() {
</Alert>
)}
{data.description && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
@ -153,10 +176,30 @@ export default function ScanReport() {
<CardDescription>KI-generierte Beschreibung des Zwecks und der Funktionsweise.</CardDescription>
</CardHeader>
<CardContent>
{data.description ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
) : (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
Für diesen Scan wurde noch keine Beschreibung erzeugt. Sie können sie jetzt
mit dem konfigurierten KI-Provider nachträglich anfordern.
</p>
<Button
onClick={() => generateDescription.mutate({ id: data.id })}
disabled={generateDescription.isPending}
className="gap-2"
>
{generateDescription.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{generateDescription.isPending ? "Wird erzeugt …" : "Beschreibung erzeugen"}
</Button>
</div>
)}
</CardContent>
</Card>
)}
<Alert className="bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/40 dark:text-blue-200 dark:border-blue-900">
<ShieldAlert className="h-4 w-4" />

View file

@ -666,6 +666,76 @@ export const useDeleteScan = <TError = ErrorType<unknown>,
return useMutation(getDeleteScanMutationOptions(options));
}
export const getGenerateScanDescriptionUrl = (id: number,) => {
return `/api/scans/${id}/description`
}
/**
* @summary Generate the AI description for an existing scan
*/
export const generateScanDescription = async (id: number, options?: RequestInit): Promise<ScanDetail> => {
return customFetch<ScanDetail>(getGenerateScanDescriptionUrl(id),
{
...options,
method: 'POST'
}
);}
export const getGenerateScanDescriptionMutationOptions = <TError = ErrorType<ApiError>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext> => {
const mutationKey = ['generateScanDescription'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof generateScanDescription>>, {id: number}> = (props) => {
const {id} = props ?? {};
return generateScanDescription(id,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type GenerateScanDescriptionMutationResult = NonNullable<Awaited<ReturnType<typeof generateScanDescription>>>
export type GenerateScanDescriptionMutationError = ErrorType<ApiError>
/**
* @summary Generate the AI description for an existing scan
*/
export const useGenerateScanDescription = <TError = ErrorType<ApiError>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof generateScanDescription>>,
TError,
{id: number},
TContext
> => {
return useMutation(getGenerateScanDescriptionMutationOptions(options));
}
export const getListProvidersUrl = () => {

View file

@ -195,6 +195,37 @@ paths:
"204":
description: Deleted
/scans/{id}/description:
post:
operationId: generateScanDescription
tags: [scans]
summary: Generate the AI description for an existing scan
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Scan report with the newly generated description
content:
application/json:
schema:
$ref: "#/components/schemas/ScanDetail"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
"422":
description: Description could not be generated
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
/providers:
get:
operationId: listProviders

View file

@ -277,6 +277,83 @@ export const DeleteScanParams = zod.object({
})
/**
* @summary Generate the AI description for an existing scan
*/
export const GenerateScanDescriptionParams = zod.object({
"id": zod.coerce.number()
})
export const GenerateScanDescriptionResponse = zod.object({
"id": zod.number(),
"name": zod.string(),
"description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'),
"source": zod.enum(['zip', 'file', 'text']),
"status": zod.enum(['completed', 'failed']),
"verdict": zod.enum(['pass', 'review', 'block']),
"riskScore": zod.number(),
"fileCount": zod.number(),
"aiUsed": zod.boolean(),
"aiError": zod.string().nullish(),
"findingCounts": zod.object({
"critical": zod.number(),
"high": zod.number(),
"medium": zod.number(),
"low": zod.number(),
"info": zod.number(),
"security": zod.number(),
"privacy": zod.number(),
"total": zod.number()
}),
"fingerprint": zod.string().describe('Deterministic hash over all files (path + per-file hash)'),
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
"createdAt": zod.string()
}).and(zod.object({
"files": zod.array(zod.object({
"path": zod.string(),
"kind": zod.enum(['instruction', 'script', 'resource']),
"language": zod.string().nullish(),
"size": zod.number(),
"hash": zod.string().describe('SHA-256 hash of the file content'),
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)')
})),
"findings": zod.array(zod.object({
"id": zod.number(),
"ruleId": zod.string(),
"axis": zod.enum(['security', 'privacy']),
"severity": zod.enum(['critical', 'high', 'medium', 'low', 'info']),
"title": zod.string(),
"description": zod.string(),
"remediation": zod.string().nullish(),
"file": zod.string().nullish(),
"line": zod.number().nullish(),
"snippet": zod.string().nullish(),
"detectedBy": zod.enum(['static', 'ai'])
})),
"checkpoints": zod.array(zod.object({
"id": zod.string(),
"label": zod.string(),
"category": zod.string(),
"axis": zod.union([zod.literal('security'),zod.literal('privacy'),zod.literal(null)]).nullish(),
"severity": zod.union([zod.literal('critical'),zod.literal('high'),zod.literal('medium'),zod.literal('low'),zod.literal('info'),zod.literal(null)]).nullish(),
"status": zod.enum(['pass', 'flagged', 'skipped', 'error']),
"findingCount": zod.number(),
"scoreDelta": zod.number(),
"detectedBy": zod.union([zod.literal('static'),zod.literal('ai'),zod.literal(null)]).nullish()
}).describe('A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).')),
"checkCount": zod.number().describe('How often a skill with this exact fingerprint was scanned'),
"comparedScan": zod.union([zod.object({
"id": zod.number(),
"name": zod.string(),
"verdict": zod.enum(['pass', 'review', 'block']),
"riskScore": zod.number(),
"createdAt": zod.string()
}),zod.null()])
}))
/**
* @summary List configured AI providers
*/