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:
parent
487d1f3d3c
commit
9415e184dc
5 changed files with 316 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,20 +167,39 @@ export default function ScanReport() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{data.description && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
||||
Was macht dieser Skill?
|
||||
</CardTitle>
|
||||
<CardDescription>KI-generierte Beschreibung des Zwecks und der Funktionsweise.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
||||
Was macht dieser Skill?
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
) : (
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue