feat(agent): MC scorecard + audit drill-down + tenant trend (A1-A6)
Now that all 1874 MCs run per check (Task #30 cap removal), the report was about to drown in noise. This commit adds the full aggregation / persistence / drill-down stack so each MC is actionable, not just counted. A1 mc_scorecard.py (new): build_scorecard(checks) -> per-regulation PASS/FAIL/SKIP + severity top_fails(checks, n) -> N most severe failed MCs full_audit_records(...) -> flat rows ready for sidecar SQLite A2 Email rendering: agent_doc_check_scorecard.py (new) builds an HTML scorecard table (regulation × passed/failed/HIGH/MEDIUM/score) shown at the top of the email. agent_doc_check_report._render_document now collapses the 500-MC L2 forest into 'X/Y bestanden (Z Fail)' summary plus a top-10 fails block per doc — old verbose render is gone. A3 compliance_audit_log.py (new) — sidecar SQLite at /data/compliance_audits.db (separate from compliance Postgres schema to comply with the no-new-migrations rule in CLAUDE.md): check_runs(check_id, ts, tenant_id, site_name, base_domain, doc_count, scorecard json, vvt_summary json) mc_results(check_id, doc_type, mc_id, label, passed, skipped, severity, regulation, matched_text, hint) Route persists every run after the email is sent. docker-compose.yml adds compliance-audit volume + env. A4 backfill_mc_regulation_llm.py (new) — Qwen-tagged backfill for the 1636 MCs the regex pass couldn't classify. Batches of 25, format=json, output constrained to the canonical regulation list. Run manually: docker exec bp-compliance-backend python3 \ /app/scripts/backfill_mc_regulation_llm.py [--dry-run] A5 Admin audit tab — GET /api/compliance/agent/audit/<check_id> proxied via /api/sdk/v1/agent/audit/<id>. New page /sdk/agent/audit/[checkId] renders scorecard + filterable MC table (status / doc_type / regulation, expandable rows with matched_text + hint). ComplianceCheckTab now shows 'Voll-Audit oeffnen' link. A6 Trend per tenant — GET /api/compliance/agent/audit/tenant/<id> returns recent runs. Email scorecard shows per-regulation delta badges ('(+12%)', '(-3%)') compared with the previous run for the same tenant + base_domain. Lookup is one SQLite query. Plumbing: rag_document_checker.py — SELECT now includes 'article'; MC results carry 'regulation' + 'article' through to CheckItem. agent_doc_check_routes.CheckItem schema gains regulation + article fields (defaults '') so old clients still parse. agent_compliance_check_routes — response gains 'check_id' so the frontend can build the audit link.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/audit/<checkId>
|
||||
* -> backend GET /api/compliance/agent/audit/<checkId>
|
||||
*
|
||||
* Forwards optional query params (doc_type, regulation, only_failed).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audit-Abfrage fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ interface HistoryEntry {
|
||||
docCount: number
|
||||
findings: number
|
||||
resultKey: string
|
||||
checkId?: string
|
||||
}
|
||||
|
||||
export function ComplianceCheckTab() {
|
||||
@@ -454,13 +455,21 @@ export function ComplianceCheckTab() {
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email status */}
|
||||
{results.email_status && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{/* Email status + Full-audit link */}
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
{results.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && (
|
||||
<a href={`/sdk/agent/audit/${results.check_id}`} target="_blank" rel="noopener"
|
||||
className="text-xs text-blue-700 hover:text-blue-900 underline">
|
||||
Voll-Audit oeffnen (alle MCs) →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { use as useUnwrap } from 'react'
|
||||
|
||||
type MCRow = {
|
||||
id: number
|
||||
doc_type: string
|
||||
mc_id: string
|
||||
label: string
|
||||
passed: number
|
||||
skipped: number
|
||||
severity: string
|
||||
regulation: string
|
||||
matched_text: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
type ScorecardRow = {
|
||||
regulation: string
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
pct: number
|
||||
severity: Record<string, number>
|
||||
}
|
||||
|
||||
type AuditResponse = {
|
||||
found: boolean
|
||||
run?: {
|
||||
check_id: string
|
||||
ts: string
|
||||
site_name: string
|
||||
base_domain: string
|
||||
doc_count: number
|
||||
scorecard: { by_regulation: ScorecardRow[]; totals: any }
|
||||
vvt_summary: { total?: number; internal?: number; external?: number }
|
||||
}
|
||||
mc_count?: number
|
||||
results?: MCRow[]
|
||||
}
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600 text-white',
|
||||
HIGH: 'bg-red-100 text-red-800',
|
||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||||
LOW: 'bg-blue-100 text-blue-800',
|
||||
INFO: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'failed', label: 'Nur Fail' },
|
||||
{ value: 'passed', label: 'Nur Pass' },
|
||||
{ value: 'skipped', label: 'Nur Skipped' },
|
||||
] as const
|
||||
|
||||
export default function AuditPage(
|
||||
{ params }: { params: Promise<{ checkId: string }> },
|
||||
) {
|
||||
const { checkId } = useUnwrap(params)
|
||||
const [data, setData] = useState<AuditResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('failed')
|
||||
const [filterReg, setFilterReg] = useState<string>('')
|
||||
const [filterDoc, setFilterDoc] = useState<string>('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetch(`/api/sdk/v1/agent/audit/${checkId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId])
|
||||
|
||||
const allRows = data?.results ?? []
|
||||
const docTypes = useMemo(
|
||||
() => Array.from(new Set(allRows.map(r => r.doc_type))).sort(),
|
||||
[allRows],
|
||||
)
|
||||
const regulations = useMemo(
|
||||
() => Array.from(new Set(allRows.map(r => r.regulation).filter(Boolean))).sort(),
|
||||
[allRows],
|
||||
)
|
||||
|
||||
const filtered = allRows.filter(r => {
|
||||
if (filterStatus === 'failed' && (r.passed || r.skipped)) return false
|
||||
if (filterStatus === 'passed' && !r.passed) return false
|
||||
if (filterStatus === 'skipped' && !r.skipped) return false
|
||||
if (filterReg && r.regulation !== filterReg) return false
|
||||
if (filterDoc && r.doc_type !== filterDoc) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-gray-500">Lade Audit…</div>
|
||||
}
|
||||
if (error || !data?.found) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-600">
|
||||
Audit nicht gefunden{error ? `: ${error}` : ''}.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const run = data.run!
|
||||
const scorecard = run.scorecard?.by_regulation ?? []
|
||||
const totals = run.scorecard?.totals ?? { total: 0, passed: 0, failed: 0, pct: 0 }
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
MC-Audit: {run.site_name}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
check_id <code className="bg-gray-100 px-1 rounded">{checkId}</code> ·{' '}
|
||||
{new Date(run.ts).toLocaleString('de-DE')} · {run.doc_count} Dokumente ·{' '}
|
||||
{data.mc_count} MC-Eintraege
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scorecard */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
|
||||
<h2 className="text-sm font-medium text-blue-900">
|
||||
Compliance-Scorecard nach Regulation
|
||||
<span className="ml-2 text-blue-700 font-semibold text-base">
|
||||
{totals.pct}%
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-blue-600">
|
||||
({totals.passed} bestanden, {totals.failed} Fail,{' '}
|
||||
{totals.skipped} skipped — {totals.total} gesamt)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-center">Passed</th>
|
||||
<th className="px-3 py-2 text-center">Failed</th>
|
||||
<th className="px-3 py-2 text-center">HIGH</th>
|
||||
<th className="px-3 py-2 text-center">MEDIUM</th>
|
||||
<th className="px-3 py-2 text-right">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scorecard.map(row => (
|
||||
<tr key={row.regulation} className="border-t hover:bg-blue-50/30 cursor-pointer"
|
||||
onClick={() => setFilterReg(row.regulation === filterReg ? '' : row.regulation)}>
|
||||
<td className="px-3 py-2 font-medium">{row.regulation}</td>
|
||||
<td className="px-3 py-2 text-center text-green-700">{row.passed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">{row.failed}</td>
|
||||
<td className="px-3 py-2 text-center text-red-700">
|
||||
{(row.severity.HIGH || 0) + (row.severity.CRITICAL || 0)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-amber-700">
|
||||
{row.severity.MEDIUM || 0}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-semibold ${
|
||||
row.pct >= 80 ? 'text-green-700' :
|
||||
row.pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||||
}`}>{row.pct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center text-xs">
|
||||
<div className="flex gap-1">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button key={f.value}
|
||||
onClick={() => setFilterStatus(f.value)}
|
||||
className={`px-2.5 py-1 rounded-full border ${
|
||||
filterStatus === f.value
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<select value={filterDoc} onChange={e => setFilterDoc(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Doc-Types</option>
|
||||
{docTypes.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select value={filterReg} onChange={e => setFilterReg(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="">Alle Regulations</option>
|
||||
{regulations.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<span className="text-gray-500">
|
||||
{filtered.length} von {allRows.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Doc</th>
|
||||
<th className="px-3 py-2 text-left">Regulation</th>
|
||||
<th className="px-3 py-2 text-left">MC</th>
|
||||
<th className="px-3 py-2 text-left">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(row => (
|
||||
<React.Fragment key={row.id}>
|
||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
||||
<td className="px-3 py-2">
|
||||
{row.passed ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : row.skipped ? (
|
||||
<span className="text-gray-400">—</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.doc_type}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{row.regulation || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
|
||||
SEVERITY_COLOR[row.severity] || 'bg-gray-100'
|
||||
}`}>{row.severity || '—'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === row.id && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={5} className="px-3 py-3 text-xs">
|
||||
<div className="text-gray-500 mb-1">
|
||||
MC-ID: <code>{row.mc_id}</code>
|
||||
</div>
|
||||
{row.matched_text && (
|
||||
<div className="mb-2">
|
||||
<span className="text-green-700 font-medium">Treffer: </span>
|
||||
<span className="font-mono text-gray-700">
|
||||
"{row.matched_text}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{row.hint && (
|
||||
<div className="text-amber-700 bg-amber-50 border-l-2 border-amber-200 pl-2 py-1">
|
||||
{row.hint}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
Keine MCs entsprechen den aktuellen Filtern.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user