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:
+197
-251
@@ -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 (>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 (65–85%)</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 (<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user