From 06bfbd1dca1f7cf8d948bd3f7fb8a770af1fac5e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 12 May 2026 13:49:16 +0200 Subject: [PATCH] feat(use-case-compiler): MC-based compliance questionnaires with scoring Implements the Use-Case Compiler that turns Master Controls into interactive compliance audits. 5 templates (Vendor Check, SAST/DAST, DSGVO, NIS2, CRA), deterministic + LLM question generation, scoring engine with regulation/severity breakdown, and gap detection. - Backend: 9 API endpoints, 22 unit tests (all pass) - Frontend: Template selector, questionnaire, result dashboard - Migration 027: usecase_audits + usecase_answers tables Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/sdk/v1/use-case/[[...path]]/route.ts | 91 +++++ .../app/sdk/use-case-audit/[auditId]/page.tsx | 247 +++++++++++++ .../_components/AuditResult.tsx | 142 +++++++ .../_components/QuestionnaireView.tsx | 215 +++++++++++ .../_components/UseCaseSelector.tsx | 138 +++++++ .../app/sdk/use-case-audit/page.tsx | 208 +++++++++++ .../sdk/Sidebar/SidebarModuleList.tsx | 13 + .../internal/api/handlers/usecase_handler.go | 315 ++++++++++++++++ ai-compliance-sdk/internal/app/app.go | 5 +- ai-compliance-sdk/internal/app/routes.go | 17 + .../internal/usecase/compiler.go | 191 ++++++++++ .../internal/usecase/compiler_llm.go | 134 +++++++ .../internal/usecase/compiler_llm_test.go | 102 +++++ .../internal/usecase/compiler_test.go | 186 ++++++++++ .../internal/usecase/gap_detector.go | 185 ++++++++++ ai-compliance-sdk/internal/usecase/models.go | 146 ++++++++ .../internal/usecase/question_generator.go | 67 ++++ ai-compliance-sdk/internal/usecase/scoring.go | 92 +++++ .../internal/usecase/scoring_test.go | 173 +++++++++ ai-compliance-sdk/internal/usecase/store.go | 347 ++++++++++++++++++ .../internal/usecase/templates.go | 105 ++++++ .../migrations/027_usecase_audits.sql | 39 ++ 22 files changed, 3157 insertions(+), 1 deletion(-) create mode 100644 admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts create mode 100644 admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx create mode 100644 admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx create mode 100644 admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx create mode 100644 admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx create mode 100644 admin-compliance/app/sdk/use-case-audit/page.tsx create mode 100644 ai-compliance-sdk/internal/api/handlers/usecase_handler.go create mode 100644 ai-compliance-sdk/internal/usecase/compiler.go create mode 100644 ai-compliance-sdk/internal/usecase/compiler_llm.go create mode 100644 ai-compliance-sdk/internal/usecase/compiler_llm_test.go create mode 100644 ai-compliance-sdk/internal/usecase/compiler_test.go create mode 100644 ai-compliance-sdk/internal/usecase/gap_detector.go create mode 100644 ai-compliance-sdk/internal/usecase/models.go create mode 100644 ai-compliance-sdk/internal/usecase/question_generator.go create mode 100644 ai-compliance-sdk/internal/usecase/scoring.go create mode 100644 ai-compliance-sdk/internal/usecase/scoring_test.go create mode 100644 ai-compliance-sdk/internal/usecase/store.go create mode 100644 ai-compliance-sdk/internal/usecase/templates.go create mode 100644 ai-compliance-sdk/migrations/027_usecase_audits.sql diff --git a/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts new file mode 100644 index 0000000..497fe3f --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts @@ -0,0 +1,91 @@ +/** + * Use-Case Compiler API Proxy - Catch-all route + * Proxies all /api/sdk/v1/use-case/* requests to ai-compliance-sdk backend + */ + +import { NextRequest, NextResponse } from 'next/server' + +const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090' + +async function proxyRequest( + request: NextRequest, + pathSegments: string[] | undefined, + method: string +) { + const pathStr = pathSegments?.join('/') || '' + const searchParams = request.nextUrl.searchParams.toString() + const basePath = `${SDK_BACKEND_URL}/sdk/v1/use-case` + const url = pathStr + ? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}` + : `${basePath}${searchParams ? `?${searchParams}` : ''}` + + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + + const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '00000000-0000-0000-0000-000000000001' + const DEFAULT_USER = '00000000-0000-0000-0000-000000000001' + + headers['X-Tenant-Id'] = request.headers.get('x-tenant-id') || DEFAULT_TENANT + headers['X-User-Id'] = request.headers.get('x-user-id') || DEFAULT_USER + + const fetchOptions: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(30000), + } + + if (['POST', 'PUT', 'PATCH'].includes(method)) { + try { + const text = await request.text() + if (text && text.trim()) { + fetchOptions.body = text + } + } catch { + // Empty body + } + } + + const response = await fetch(url, fetchOptions) + + if (!response.ok) { + const errorText = await response.text() + let errorJson + try { + errorJson = JSON.parse(errorText) + } catch { + errorJson = { error: errorText } + } + return NextResponse.json( + { error: `Backend Error: ${response.status}`, ...errorJson }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Use-Case API proxy error:', error) + return NextResponse.json( + { error: 'Verbindung zum SDK Backend fehlgeschlagen' }, + { status: 503 } + ) + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'GET') +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'POST') +} diff --git a/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx b/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx new file mode 100644 index 0000000..c0356f2 --- /dev/null +++ b/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx @@ -0,0 +1,247 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { useParams } from 'next/navigation' +import { QuestionnaireView } from '../_components/QuestionnaireView' +import { AuditResult } from '../_components/AuditResult' + +interface Question { + id: string + mc_id: string + mc_name: string + question: string + question_type: string + evidence_required: boolean + pass_criteria: string[] + fail_criteria: string[] + severity: string + regulation: string + depends_on?: string +} + +interface Audit { + id: string + tenant_id: string + template_id: string + name: string + target_name: string + status: string + total_questions: number + answered_questions: number + compliance_score: number + questions: Question[] + created_at: string + completed_at: string | null +} + +interface Answer { + id: string + question_id: string + mc_id: string + value: unknown + comment: string + evidence_ids: string[] + status: string + answered_at: string +} + +interface ScoreResult { + audit_id: string + total_questions: number + answered: number + passed: number + failed: number + skipped: number + compliance_score: number + by_regulation: Record + by_severity: Record +} + +const TENANT_ID = '00000000-0000-0000-0000-000000000001' + +export default function AuditDetailPage() { + const params = useParams() + const auditId = params.auditId as string + + const [audit, setAudit] = useState(null) + const [answers, setAnswers] = useState([]) + const [score, setScore] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const loadAudit = useCallback(async () => { + try { + const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}`, { + headers: { 'X-Tenant-ID': TENANT_ID }, + }) + if (!res.ok) throw new Error('Audit nicht gefunden') + const data = await res.json() + setAudit(data.audit) + setAnswers(data.answers || []) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler') + } finally { + setLoading(false) + } + }, [auditId]) + + useEffect(() => { loadAudit() }, [loadAudit]) + + const handleAnswer = async (questionId: string, value: unknown, comment: string) => { + if (!audit) return + try { + const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}/answer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': TENANT_ID, + }, + body: JSON.stringify({ + question_id: questionId, + value, + comment, + status: 'answered', + }), + }) + if (!res.ok) throw new Error('Antwort konnte nicht gespeichert werden') + const data = await res.json() + + // Update local state + setAnswers(prev => { + const idx = prev.findIndex(a => a.question_id === questionId) + const newAnswer = data.answer + if (idx >= 0) { + const copy = [...prev] + copy[idx] = newAnswer + return copy + } + return [...prev, newAnswer] + }) + + if (data.progress) { + setAudit(prev => prev ? { + ...prev, + answered_questions: data.progress.answered, + compliance_score: data.progress.compliance_score, + status: data.progress.answered >= prev.total_questions ? 'completed' : 'in_progress', + } : null) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler') + } + } + + const handleSkip = async (questionId: string) => { + if (!audit) return + try { + await fetch(`/api/sdk/v1/use-case/audits/${auditId}/answer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': TENANT_ID, + }, + body: JSON.stringify({ + question_id: questionId, + value: null, + status: 'skipped', + }), + }) + await loadAudit() + } catch { /* ignore */ } + } + + const loadScore = async () => { + try { + const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}/score`, { + headers: { 'X-Tenant-ID': TENANT_ID }, + }) + if (res.ok) { + const data = await res.json() + setScore(data) + } + } catch { /* ignore */ } + } + + if (loading) { + return ( +
+
Lade Audit...
+
+ ) + } + + if (error || !audit) { + return ( +
+
+
+

{error || 'Audit nicht gefunden'}

+ + Zurueck zur Liste + +
+
+
+ ) + } + + const showResult = audit.status === 'completed' || score !== null + + return ( +
+
+ {/* Header */} +
+
+ + ← Alle Audits + +

{audit.name}

+ {audit.target_name && ( +

{audit.target_name}

+ )} +
+
+ {/* Progress indicator */} +
+
+ {audit.answered_questions} / {audit.total_questions} beantwortet +
+
+
0 ? (audit.answered_questions / audit.total_questions) * 100 : 0}%` }} + /> +
+
+ {audit.answered_questions > 0 && ( + + )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + {showResult && score ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx b/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx new file mode 100644 index 0000000..8901566 --- /dev/null +++ b/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx @@ -0,0 +1,142 @@ +'use client' + +import React from 'react' + +interface Audit { + id: string + name: string + target_name: string + total_questions: number + answered_questions: number +} + +interface ScoreResult { + audit_id: string + total_questions: number + answered: number + passed: number + failed: number + skipped: number + compliance_score: number + by_regulation: Record + by_severity: Record +} + +interface Props { + audit: Audit + score: ScoreResult +} + +function scoreColor(score: number): string { + if (score >= 80) return 'text-green-600' + if (score >= 50) return 'text-yellow-600' + return 'text-red-600' +} + +function scoreBg(score: number): string { + if (score >= 80) return 'bg-green-100 border-green-200' + if (score >= 50) return 'bg-yellow-100 border-yellow-200' + return 'bg-red-100 border-red-200' +} + +export function AuditResult({ audit, score }: Props) { + const regEntries = Object.entries(score.by_regulation) + const sevEntries = Object.entries(score.by_severity) + + return ( +
+ {/* Score Overview */} +
+
+ {Math.round(score.compliance_score)}% +
+
Compliance Score
+
+ {audit.name}{audit.target_name ? ` — ${audit.target_name}` : ''} +
+
+ + {/* Summary Stats */} +
+
+
{score.answered}
+
Beantwortet
+
+
+
{score.passed}
+
Bestanden
+
+
+
{score.failed}
+
Nicht bestanden
+
+
+
{score.skipped}
+
Uebersprungen
+
+
+ + {/* By Regulation */} + {regEntries.length > 0 && ( +
+

Nach Regulierung

+
+ {regEntries.map(([reg, data]) => ( +
+ {reg} +
+
= 80 ? 'bg-green-500' : + data.score >= 50 ? 'bg-yellow-500' : 'bg-red-500' + }`} + style={{ width: `${data.score}%` }} + /> +
+ + {Math.round(data.score)}% + + + {data.passed}/{data.total} + +
+ ))} +
+
+ )} + + {/* By Severity */} + {sevEntries.length > 0 && ( +
+

Nach Schweregrad

+
+ {sevEntries.map(([sev, data]) => ( +
+
+ {sev} +
+
{data.passed}/{data.total}
+
+ {data.failed} nicht bestanden +
+
+ ))} +
+
+ )} + + {/* Actions */} + +
+ ) +} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx b/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx new file mode 100644 index 0000000..5de9720 --- /dev/null +++ b/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx @@ -0,0 +1,215 @@ +'use client' + +import React, { useState, useMemo } from 'react' + +interface Question { + id: string + mc_id: string + mc_name: string + question: string + question_type: string + evidence_required: boolean + pass_criteria: string[] + fail_criteria: string[] + severity: string + regulation: string + depends_on?: string +} + +interface Answer { + question_id: string + value: unknown + comment: string + status: string +} + +interface Props { + questions: Question[] + answers: Answer[] + onAnswer: (questionId: string, value: unknown, comment: string) => void + onSkip: (questionId: string) => void +} + +const SEVERITY_COLORS: Record = { + HIGH: 'bg-red-100 text-red-700 border-red-200', + MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200', + LOW: 'bg-green-100 text-green-700 border-green-200', +} + +export function QuestionnaireView({ questions, answers, onAnswer, onSkip }: Props) { + const [comments, setComments] = useState>({}) + const [showCriteria, setShowCriteria] = useState(null) + + const answerMap = useMemo(() => { + const m: Record = {} + for (const a of answers) { m[a.question_id] = a } + return m + }, [answers]) + + // Filter visible questions (dependency check) + const visibleQuestions = useMemo(() => { + return questions.filter(q => { + if (!q.depends_on) return true + const depAnswer = answerMap[q.depends_on] + if (!depAnswer) return false + return depAnswer.value === true || depAnswer.value === 'yes' || depAnswer.value === 'ja' + }) + }, [questions, answerMap]) + + // Group by regulation + const grouped = useMemo(() => { + const groups: Record = {} + for (const q of visibleQuestions) { + const key = q.regulation || 'Allgemein' + if (!groups[key]) groups[key] = [] + groups[key].push(q) + } + return groups + }, [visibleQuestions]) + + return ( +
+ {Object.entries(grouped).map(([reg, qs]) => ( +
+

+ + {reg} + + + {qs.length} Fragen + +

+ +
+ {qs.map((q, idx) => { + const existing = answerMap[q.id] + const isAnswered = !!existing && existing.status !== 'skipped' + const isSkipped = existing?.status === 'skipped' + const isPassed = existing?.value === true || existing?.value === 'yes' + + return ( +
+
+ + {q.id} + +
+
+

{q.question}

+
+ + {q.severity} + + {q.evidence_required && ( + + Nachweis + + )} +
+
+ + {q.mc_name && ( +

+ MC: {q.mc_name} {q.mc_id && `(${q.mc_id})`} +

+ )} + + {/* Criteria toggle */} + + + {showCriteria === q.id && ( +
+ {q.pass_criteria?.length > 0 && ( +
+
Pass
+ {q.pass_criteria.map((c, i) => ( +
{c}
+ ))} +
+ )} + {q.fail_criteria?.length > 0 && ( +
+
Fail
+ {q.fail_criteria.map((c, i) => ( +
{c}
+ ))} +
+ )} +
+ )} + + {/* Answer controls */} + {!isAnswered && !isSkipped && ( +
+ + + + setComments(prev => ({ ...prev, [q.id]: e.target.value }))} + className="flex-1 px-3 py-1.5 border border-gray-200 rounded-lg text-sm focus:ring-1 focus:ring-blue-500" + /> +
+ )} + + {/* Already answered */} + {isAnswered && ( +
+ + {isPassed ? 'Ja' : 'Nein'} + + {existing.comment && ( + — {existing.comment} + )} +
+ )} + + {isSkipped && ( +
Uebersprungen
+ )} +
+
+
+ ) + })} +
+
+ ))} +
+ ) +} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx b/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx new file mode 100644 index 0000000..1612c60 --- /dev/null +++ b/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx @@ -0,0 +1,138 @@ +'use client' + +import React, { useState } from 'react' + +interface Template { + id: string + name: string + description: string + mc_filters: string[] + regulations: string[] +} + +interface Props { + templates: Template[] + onCreateAudit: (templateId: string, name: string, targetName: string) => void + loading: boolean +} + +const REG_COLORS: Record = { + dsgvo: 'bg-purple-100 text-purple-700', + nis2: 'bg-orange-100 text-orange-700', + cra: 'bg-red-100 text-red-700', + owasp: 'bg-yellow-100 text-yellow-700', +} + +export function UseCaseSelector({ templates, onCreateAudit, loading }: Props) { + const [selected, setSelected] = useState