feat: F019 AI monitoring layer — output transparency & governance workspace

Full 2-panel workspace at /ops/ai-monitoring: metrics strip (total, failed,
pending, approval rate, top prompt version, active model), filterable output
table by type/status/error, and detail panel with output preview, prompt
versioning, error details, and role-aware review actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 13:11:35 +02:00
parent 5d3323617c
commit 71f4b1eeb6
16 changed files with 1164 additions and 265 deletions
+197 -251
View File
@@ -1,279 +1,225 @@
import { useState } from 'react'
import { Box, Chip, MenuItem, Select, Typography } from '@mui/material'
import { Bot } from 'lucide-react'
import {
Box,
Card,
Chip,
Typography,
LinearProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Alert,
CircularProgress,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { propertyService } from '../../services/propertyService'
AIMonitoringMetrics,
AIOutputTable,
AIOutputDetailPanel,
AIMonitoringEmptyState,
} from '../../components/ai-monitoring'
import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
interface MetricCard {
label: string
value: string
color: string
note: string
interface Filters {
type: AIOutputType | ''
reviewStatus: ReviewStatus | ''
hasError: boolean | null
}
const HEALTH_METRICS: MetricCard[] = [
{ label: 'Extraktionsgenauigkeit', value: '94%', color: '#1a7a4a', note: 'Ø letzte 30 Tage' },
{ label: 'Konfidenz-Ø', value: '73%', color: '#d97706', note: 'Alle Objekte' },
{ label: 'Validierungsrate', value: '88%', color: '#1a7a4a', note: 'Menschliche Bestätigung' },
{ label: 'Fehlerrate', value: '2.1%', color: '#1a7a4a', note: 'Kritische Fehler' },
]
interface AIDecision {
timestamp: string
type: string
confidence: string
result: string
impact: string
const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing',
FOLLOW_UP_QUESTIONS: 'Rückfragen',
MATCH_EXPLANATION: 'Match-Begründung',
COMPARE_SUMMARY: 'Vergleich',
DECISION_BRIEF: 'Entscheidungs-Brief',
DATA_QUALITY_SUMMARY: 'Datenqualität',
}
const RECENT_DECISIONS: AIDecision[] = [
{
timestamp: '15.05.2025 14:32',
type: 'Bedarfsextraktion',
confidence: '91%',
result: 'Kriterien extrahiert',
impact: 'Suche ausgelöst',
},
{
timestamp: '15.05.2025 11:15',
type: 'Match-Scoring',
confidence: '88%',
result: '3 Matches berechnet',
impact: 'Review ausgelöst',
},
{
timestamp: '14.05.2025 16:40',
type: 'Signal-Erkennung',
confidence: '72%',
result: 'Expansion erkannt',
impact: 'Signal erstellt',
},
{
timestamp: '14.05.2025 09:00',
type: 'Datenqualitätsprüfung',
confidence: '95%',
result: '2 Warnungen erkannt',
impact: 'Meldung erstellt',
},
{
timestamp: '13.05.2025 15:22',
type: 'Match-Scoring',
confidence: '84%',
result: '2 Matches berechnet',
impact: 'Review ausgelöst',
},
{
timestamp: '12.05.2025 10:11',
type: 'Signal-Erkennung',
confidence: '65%',
result: 'Möglicher Auszug erkannt',
impact: 'Signal erstellt',
},
const AI_OUTPUT_TYPES: AIOutputType[] = [
'NEED_PARSE',
'FOLLOW_UP_QUESTIONS',
'MATCH_EXPLANATION',
'COMPARE_SUMMARY',
'DECISION_BRIEF',
'DATA_QUALITY_SUMMARY',
]
function getConfidenceBadge(pct: string) {
const n = parseInt(pct)
const color = n >= 85 ? '#1a7a4a' : n >= 70 ? '#d97706' : '#c0392b'
return (
<Chip
label={pct}
size="small"
sx={{ bgcolor: color, color: 'white', fontWeight: 700, fontSize: 11 }}
/>
)
const REVIEW_STATUSES: ReviewStatus[] = ['UNREVIEWED', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'FLAGGED']
const STATUS_LABELS: Record<ReviewStatus, string> = {
UNREVIEWED: 'Ungeprüft',
IN_REVIEW: 'In Prüfung',
APPROVED: 'Genehmigt',
REJECTED: 'Abgelehnt',
FLAGGED: 'Markiert',
}
function applyFilters(outputs: AIOutput[], filters: Filters): AIOutput[] {
return outputs.filter(o => {
if (filters.type && o.type !== filters.type) return false
if (filters.reviewStatus && o.reviewStatus !== filters.reviewStatus) return false
if (filters.hasError === true && !o.error) return false
if (filters.hasError === false && !!o.error) return false
return true
})
}
export default function AIMonitoring() {
const { data: propResp, isLoading } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const [filters, setFilters] = useState<Filters>({ type: '', reviewStatus: '', hasError: null })
const [selectedOutput, setSelectedOutput] = useState<AIOutput | null>(null)
const properties = propResp?.data ?? []
const { data: allOutputs = [], isLoading } = useAIOutputs()
const updateStatus = useUpdateAIOutputReviewStatus()
const highConf = properties.filter(p => p.confidenceScore > 0.85).length
const midConf = properties.filter(p => p.confidenceScore >= 0.65 && p.confidenceScore <= 0.85).length
const lowConf = properties.filter(p => p.confidenceScore < 0.65).length
const total = properties.length || 1
const filtered = applyFilters(allOutputs, filters)
const failedCount = allOutputs.filter(o => !!o.error).length
const pendingCount = allOutputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
const handleUpdateStatus = (status: ReviewStatus) => {
if (!selectedOutput) return
updateStatus.mutate(
{ id: selectedOutput.id, status },
{ onSuccess: (res) => setSelectedOutput(res.data) }
)
}
const activeFilterCount = [filters.type, filters.reviewStatus, filters.hasError !== null].filter(Boolean).length
return (
<Box>
{/* Page Header */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
AI Monitoring
</Typography>
<Chip
label="Live"
size="small"
sx={{
bgcolor: '#1a7a4a',
color: 'white',
fontWeight: 700,
fontSize: 11,
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.6 },
},
}}
/>
</Box>
<Typography variant="body2" color="text.secondary">
AI-Layer Gesundheit und Entscheidungsqualität
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 2.5, py: 1.5, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Bot size={18} color="#4f46e5" />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b' }}>
AI Monitoring
</Typography>
<Box sx={{ display: 'flex', gap: 0.75 }}>
{failedCount > 0 && (
<Chip
label={`${failedCount} Fehler`}
size="small"
sx={{ bgcolor: '#fee2e2', color: '#991b1b', fontWeight: 600, fontSize: '0.7rem' }}
/>
)}
{pendingCount > 0 && (
<Chip
label={`${pendingCount} ausstehend`}
size="small"
sx={{ bgcolor: '#fef3c7', color: '#92400e', fontWeight: 600, fontSize: '0.7rem' }}
/>
)}
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
Transparenz und Governance für KI-generierte Outputs
</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }}>
{/* Health Metrics */}
<Box className="grid grid-cols-4 gap-4" sx={{ mb: 3 }}>
{HEALTH_METRICS.map(m => (
<Card key={m.label} sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.4, display: 'block' }}>
{m.label}
</Typography>
<Typography variant="h3" sx={{ fontWeight: 800, color: m.color }}>
{m.value}
</Typography>
<Typography variant="caption" color="text.secondary">
{m.note}
</Typography>
</Card>
{/* Metrics strip */}
{!isLoading && <AIMonitoringMetrics outputs={allOutputs} />}
{/* Filter bar */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', px: 2, py: 0.875, borderBottom: '1px solid #e2e8f0', bgcolor: '#fafafa', flexShrink: 0, flexWrap: 'wrap' }}>
<Select
size="small"
value={filters.type}
onChange={e => setFilters(f => ({ ...f, type: e.target.value as AIOutputType | '' }))}
displayEmpty
sx={{ fontSize: '0.75rem', minWidth: 155 }}
>
<MenuItem value="">Alle Typen</MenuItem>
{AI_OUTPUT_TYPES.map(t => (
<MenuItem key={t} value={t} sx={{ fontSize: '0.75rem' }}>{TYPE_LABELS[t]}</MenuItem>
))}
</Select>
<Select
size="small"
value={filters.reviewStatus}
onChange={e => setFilters(f => ({ ...f, reviewStatus: e.target.value as ReviewStatus | '' }))}
displayEmpty
sx={{ fontSize: '0.75rem', minWidth: 130 }}
>
<MenuItem value="">Alle Status</MenuItem>
{REVIEW_STATUSES.map(s => (
<MenuItem key={s} value={s} sx={{ fontSize: '0.75rem' }}>{STATUS_LABELS[s]}</MenuItem>
))}
</Select>
<Chip
label="Nur Fehler"
size="small"
onClick={() => setFilters(f => ({ ...f, hasError: f.hasError === true ? null : true }))}
sx={{
cursor: 'pointer',
bgcolor: filters.hasError === true ? '#fee2e2' : '#f1f5f9',
color: filters.hasError === true ? '#991b1b' : '#64748b',
fontWeight: filters.hasError === true ? 700 : 400,
fontSize: '0.75rem',
}}
/>
{activeFilterCount > 0 && (
<Chip
label="Filter zurücksetzen"
size="small"
onClick={() => setFilters({ type: '', reviewStatus: '', hasError: null })}
sx={{ cursor: 'pointer', fontSize: '0.75rem', color: '#64748b' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto', whiteSpace: 'nowrap' }}>
{activeFilterCount > 0 ? `${filtered.length} / ${allOutputs.length}` : `${allOutputs.length} Outputs`}
</Typography>
</Box>
{/* Body */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: table */}
<Box sx={{ flex: 1, minWidth: 0, overflowY: 'auto', overflowX: 'auto' }}>
{isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200 }}>
<Typography variant="caption" color="text.secondary">Laden</Typography>
</Box>
) : (
<AIOutputTable
outputs={filtered}
selectedId={selectedOutput?.id ?? null}
onSelect={setSelectedOutput}
isEmpty={allOutputs.length === 0}
/>
)}
</Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
{/* Confidence Distribution */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Konfidenzverteilung
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={32} />
</Box>
) : (
<Stack spacing={2}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>Hoch (&gt;85%)</Typography>
<Typography variant="body2" color="text.secondary">{highConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(highConf / total) * 100}
color="success"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>Mittel (6585%)</Typography>
<Typography variant="body2" color="text.secondary">{midConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(midConf / total) * 100}
color="warning"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>Niedrig (&lt;65%)</Typography>
<Typography variant="body2" color="text.secondary">{lowConf} Objekte</Typography>
</Box>
<LinearProgress
variant="determinate"
value={(lowConf / total) * 100}
color="error"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
</Stack>
)}
</Card>
{/* Anomaly Alerts */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Anomalien
</Typography>
<Stack spacing={1.5}>
<Alert severity="warning">
Mietpreisangaben für prop-004 weichen von Marktdurchschnitt ab (±31%). Manuelle Prüfung empfohlen.
</Alert>
<Alert severity="success">
Keine kritischen Anomalien erkannt. System läuft stabil.
</Alert>
</Stack>
</Card>
{/* Right: detail panel */}
<Box
sx={{
width: selectedOutput ? 380 : 0,
flexShrink: 0,
borderLeft: selectedOutput ? '1px solid #e2e8f0' : 'none',
overflow: 'hidden',
transition: 'width 0.15s ease',
bgcolor: 'white',
}}
>
{selectedOutput ? (
<AIOutputDetailPanel
output={selectedOutput}
onClose={() => setSelectedOutput(null)}
onUpdateStatus={handleUpdateStatus}
isSubmitting={updateStatus.isPending}
/>
) : null}
</Box>
{/* Recent AI Decisions */}
<Card>
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Letzte KI-Entscheidungen
</Typography>
{/* Empty selection hint when no panel open */}
{!selectedOutput && filtered.length > 0 && (
<Box
sx={{
width: 260,
flexShrink: 0,
borderLeft: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#fafafa',
}}
>
<AIMonitoringEmptyState context="no-selection" />
</Box>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Entscheidungstyp</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Konfidenz</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Ergebnis</TableCell>
<TableCell sx={{ fontSize: 12, fontWeight: 600, color: '#64748b' }}>Einfluss</TableCell>
</TableRow>
</TableHead>
<TableBody>
{RECENT_DECISIONS.map((d, i) => (
<TableRow key={i} hover>
<TableCell>
<Typography variant="caption" color="text.secondary">{d.timestamp}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{d.type}</Typography>
</TableCell>
<TableCell>{getConfidenceBadge(d.confidence)}</TableCell>
<TableCell>
<Typography variant="body2">{d.result}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">{d.impact}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</Box>
</Box>
)