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
@@ -0,0 +1,137 @@
import {
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material'
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge'
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
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 TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f',
FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a',
DECISION_BRIEF: '#7c3aed',
DATA_QUALITY_SUMMARY: '#d97706',
}
const MODEL_SHORT: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'Sonnet 3.5',
'claude-3-haiku-20240307': 'Haiku 3',
'claude-3-opus-20240229': 'Opus 3',
}
function shortTime(iso: string) {
return new Date(iso).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })
}
interface Props {
outputs: AIOutput[]
selectedId: string | null
onSelect: (output: AIOutput) => void
isEmpty: boolean
}
export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) {
if (isEmpty && outputs.length === 0) {
return <AIMonitoringEmptyState context="no-outputs" />
}
if (outputs.length === 0) {
return <AIMonitoringEmptyState context="filtered-empty" />
}
return (
<Table size="small" stickyHeader>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: '#f8fafc', fontSize: '0.7rem', fontWeight: 700, color: '#64748b', py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
<TableCell sx={{ minWidth: 110 }}>Zeitpunkt</TableCell>
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
<TableCell sx={{ minWidth: 140 }}>Version</TableCell>
<TableCell sx={{ minWidth: 95 }}>Status</TableCell>
<TableCell sx={{ minWidth: 65 }}>Latenz</TableCell>
<TableCell sx={{ minWidth: 80 }}>Fehler</TableCell>
</TableRow>
</TableHead>
<TableBody>
{outputs.map(output => {
const isSelected = selectedId === output.id
const color = TYPE_COLORS[output.type] ?? '#64748b'
return (
<TableRow
key={output.id}
hover
onClick={() => onSelect(output)}
sx={{
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : undefined,
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
'& td': { py: 0.75, borderBottom: '1px solid #f1f5f9' },
}}
>
<TableCell>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{shortTime(output.createdAt)}
</Typography>
</TableCell>
<TableCell>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
px: 0.75,
py: 0.2,
borderRadius: 1,
bgcolor: `${color}12`,
}}
>
<Typography variant="caption" sx={{ color, fontWeight: 700, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{TYPE_LABELS[output.type] ?? output.type}
</Typography>
</Box>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: '#334155' }}>
{MODEL_SHORT[output.model] ?? output.model}
</Typography>
</TableCell>
<TableCell>
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</TableCell>
<TableCell>
<AIOutputStatusBadge status={output.reviewStatus} />
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: output.latencyMs && output.latencyMs > 5000 ? '#c0392b' : '#64748b' }}>
{output.latencyMs != null ? `${(output.latencyMs / 1000).toFixed(1)}s` : ''}
</Typography>
</TableCell>
<TableCell>
{output.error ? <AIErrorBadge error={output.error} /> : (
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}></Typography>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}