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,23 @@
import { Chip, Tooltip } from '@mui/material'
import type { AIOutputError } from '../../domain/aiOutput'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' },
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' },
INVALID_JSON: { label: 'JSON', color: '#c0392b' },
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' },
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' },
}
export function AIErrorBadge({ error }: { error: AIOutputError }) {
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
return (
<Tooltip title={error.message} arrow>
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
/>
</Tooltip>
)
}
@@ -0,0 +1,38 @@
import { Box, Typography } from '@mui/material'
import { Bot, Filter, MousePointer } from 'lucide-react'
interface Props {
context: 'no-outputs' | 'filtered-empty' | 'no-selection'
}
const CONFIG = {
'no-outputs': {
icon: Bot,
color: '#94a3b8',
title: 'Keine AI-Outputs',
desc: 'Es wurden noch keine AI-Outputs generiert.',
},
'filtered-empty': {
icon: Filter,
color: '#94a3b8',
title: 'Keine Ergebnisse',
desc: 'Kein AI-Output entspricht den aktiven Filtern.',
},
'no-selection': {
icon: MousePointer,
color: '#94a3b8',
title: 'Output auswählen',
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
},
}
export function AIMonitoringEmptyState({ context }: Props) {
const { icon: Icon, color, title, desc } = CONFIG[context]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
<Icon size={36} color={color} style={{ marginBottom: 12, opacity: 0.6 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
</Box>
)
}
@@ -0,0 +1,51 @@
import { Box, Typography } from '@mui/material'
import type { AIOutput } from '../../domain/aiOutput'
interface Props {
outputs: AIOutput[]
}
function MetricCell({ label, value, color }: { label: string; value: string | number; color?: string }) {
return (
<Box sx={{ px: 2, py: 1.25, borderRight: '1px solid #e2e8f0', '&:last-child': { borderRight: 'none' }, minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', whiteSpace: 'nowrap', mb: 0.25 }}>
{label}
</Typography>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontSize: '0.9375rem', color: color ?? '#1e293b', lineHeight: 1.2 }}
>
{value}
</Typography>
</Box>
)
}
function mostCommon(arr: string[]): string {
if (!arr.length) return ''
const freq = arr.reduce<Record<string, number>>((acc, v) => ({ ...acc, [v]: (acc[v] ?? 0) + 1 }), {})
return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]
}
export function AIMonitoringMetrics({ outputs }: Props) {
const total = outputs.length
const failed = outputs.filter(o => !!o.error).length
const needsReview = outputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
const approved = outputs.filter(o => o.reviewStatus === 'APPROVED').length
const approvalRate = total > 0 ? Math.round((approved / total) * 100) : 0
const topPrompt = mostCommon(outputs.map(o => o.promptVersion))
const latestModel = outputs.length > 0
? outputs.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0].model
: ''
return (
<Box sx={{ display: 'flex', bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<MetricCell label="AI-Outputs total" value={total} />
<MetricCell label="Fehlgeschlagen" value={failed} color={failed > 0 ? '#c0392b' : undefined} />
<MetricCell label="Prüfung ausstehend" value={needsReview} color={needsReview > 0 ? '#d97706' : undefined} />
<MetricCell label="Genehmigungsrate" value={`${approvalRate}%`} color={approvalRate >= 70 ? '#1a7a4a' : '#d97706'} />
<MetricCell label="Häufigste Version" value={topPrompt} />
<MetricCell label="Aktives Modell" value={latestModel.replace('claude-3-5-sonnet-20241022', 'sonnet-3.5').replace('claude-3-haiku-20240307', 'haiku-3').replace('claude-3-opus-20240229', 'opus-3')} />
</Box>
)
}
@@ -0,0 +1,191 @@
import { Alert, Box, Divider, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge'
import { AIReviewActionToolbar } from './AIReviewActionToolbar'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
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 ERROR_TYPE_LABELS: Record<string, string> = {
SCHEMA_VALIDATION: 'Schema-Validierungsfehler',
PROVIDER_TIMEOUT: 'Provider-Timeout',
INVALID_JSON: 'Ungültiges JSON',
EMPTY_RESPONSE: 'Leere Antwort',
RATE_LIMIT: 'Rate-Limit erreicht',
}
const MODEL_LABELS: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
'claude-3-haiku-20240307': 'Claude 3 Haiku',
'claude-3-opus-20240229': 'Claude 3 Opus',
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
NEED: 'Gesuch', MATCH: 'Match', PROPERTY: 'Objekt', SIGNAL: 'Signal',
}
interface Props {
output: AIOutput
onClose: () => void
onUpdateStatus: (status: ReviewStatus) => void
isSubmitting?: boolean
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', gap: 1, mb: 0.625, alignItems: 'flex-start' }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 116, flexShrink: 0, pt: 0.125 }}>
{label}
</Typography>
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Box>
)
}
export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitting }: Props) {
const handleCopyJson = () => {
navigator.clipboard.writeText(output.outputPreview).catch(() => {})
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5, alignItems: 'center' }}>
<AIOutputStatusBadge status={output.reviewStatus} />
{output.error && <AIErrorBadge error={output.error} />}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem' }}>
{TYPE_LABELS[output.type] ?? output.type}
</Typography>
</Box>
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, mt: -0.25 }}>
<X size={16} />
</IconButton>
</Box>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
{/* Metadata */}
<MetaRow label="Output-ID">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569' }}>
{output.id}
</Typography>
</MetaRow>
<MetaRow label="Erstellt">
<Typography variant="caption" sx={{ color: '#334155' }}>
{new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
</Typography>
</MetaRow>
<MetaRow label="Modell">
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155' }}>
{MODEL_LABELS[output.model] ?? output.model}
</Typography>
</MetaRow>
<MetaRow label="Provider">
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}>
{output.provider}
</Typography>
</MetaRow>
<MetaRow label="Prompt-Version">
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</MetaRow>
<MetaRow label="Input-Hash">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
{output.inputHash}
</Typography>
</MetaRow>
<MetaRow label="Bezug">
<Typography variant="caption" sx={{ color: '#334155' }}>
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '}
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
{output.relatedEntityId}
</span>
</Typography>
</MetaRow>
{output.latencyMs != null && (
<MetaRow label="Latenz">
<Typography variant="caption" sx={{ color: output.latencyMs > 5000 ? '#c0392b' : '#334155', fontWeight: output.latencyMs > 5000 ? 700 : 400 }}>
{(output.latencyMs / 1000).toFixed(2)}s
</Typography>
</MetaRow>
)}
{output.costEstimate != null && (
<MetaRow label="Kostenschätzung">
<Typography variant="caption" sx={{ color: '#334155' }}>
${output.costEstimate.toFixed(4)}
</Typography>
</MetaRow>
)}
<Divider sx={{ my: 1.5 }} />
{/* Error details */}
{output.error && (
<Box sx={{ mb: 1.5 }}>
<Alert
severity="error"
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' }, mb: 1 }}
>
<strong>{ERROR_TYPE_LABELS[output.error.type] ?? output.error.type}</strong>
<br />
{output.error.message}
{output.error.recoverable && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#92400e' }}>
Wiederholbar kann erneut ausgelöst werden.
</Typography>
)}
</Alert>
</Box>
)}
{/* Output preview */}
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b', display: 'block', mb: 0.5 }}>
Output-Vorschau
</Typography>
<Box
sx={{
p: 1.25,
bgcolor: '#f8fafc',
borderRadius: 1,
border: '1px solid #e2e8f0',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: '#334155',
lineHeight: 1.6,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{output.outputPreview || '(kein Output)'}
</Box>
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Actions */}
<AIReviewActionToolbar
output={output}
onUpdateStatus={onUpdateStatus}
onCopyJson={handleCopyJson}
isSubmitting={isSubmitting}
/>
</Box>
</Box>
)
}
@@ -0,0 +1,21 @@
import { Chip } from '@mui/material'
import type { ReviewStatus } from '../../domain/enums'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
}
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
return (
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
/>
)
}
@@ -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>
)
}
@@ -0,0 +1,71 @@
import { Box, Button, Tooltip } from '@mui/material'
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
import type { AIOutput } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
interface Props {
output: AIOutput
onUpdateStatus: (status: ReviewStatus) => void
onCopyJson: () => void
isSubmitting?: boolean
}
export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSubmitting }: Props) {
const { reviewStatus } = output
const canSendToReview = reviewStatus === 'UNREVIEWED' || reviewStatus === 'FLAGGED'
const canApprove = reviewStatus === 'IN_REVIEW' || reviewStatus === 'UNREVIEWED'
const canReject = reviewStatus !== 'REJECTED'
return (
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
{canSendToReview && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<Send size={13} />}
onClick={() => onUpdateStatus('IN_REVIEW')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#d97706', borderColor: '#d97706', '&:hover': { bgcolor: '#fffbeb', borderColor: '#b45309' } }}
>
Zur Prüfung
</Button>
)}
{canApprove && (
<Button
size="small"
variant="contained"
disabled={isSubmitting}
startIcon={<CheckCircle size={13} />}
onClick={() => onUpdateStatus('APPROVED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
>
Genehmigen
</Button>
)}
{canReject && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<XCircle size={13} />}
onClick={() => onUpdateStatus('REJECTED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#c0392b', borderColor: '#c0392b', '&:hover': { bgcolor: '#fef2f2', borderColor: '#a93226' } }}
>
Ablehnen
</Button>
)}
<Tooltip title="Output-JSON kopieren">
<Button
size="small"
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
>
JSON
</Button>
</Tooltip>
</Box>
)
}
@@ -0,0 +1,46 @@
import { Box, Tooltip, Typography } from '@mui/material'
interface Props {
promptVersion: string
schemaVersion?: string
}
export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
const badge = (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.75,
py: 0.2,
bgcolor: '#f1f5f9',
borderRadius: 1,
border: '1px solid #e2e8f0',
cursor: schemaVersion ? 'default' : undefined,
}}
>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', fontWeight: 600, lineHeight: 1.4 }}
>
{promptVersion}
</Typography>
{schemaVersion && (
<>
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} />
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#94a3b8', lineHeight: 1.4 }}
>
{schemaVersion}
</Typography>
</>
)}
</Box>
)
return schemaVersion ? (
<Tooltip title={`Prompt: ${promptVersion} · Schema: ${schemaVersion}`}>{badge}</Tooltip>
) : badge
}
+8
View File
@@ -0,0 +1,8 @@
export { AIOutputStatusBadge } from './AIOutputStatusBadge'
export { PromptVersionBadge } from './PromptVersionBadge'
export { AIErrorBadge } from './AIErrorBadge'
export { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
export { AIMonitoringMetrics } from './AIMonitoringMetrics'
export { AIOutputTable } from './AIOutputTable'
export { AIReviewActionToolbar } from './AIReviewActionToolbar'
export { AIOutputDetailPanel } from './AIOutputDetailPanel'
+33 -14
View File
@@ -1,25 +1,44 @@
import type { ReviewStatus } from './enums' import type { ReviewStatus } from './enums'
export const AIOutputType = { export const AIOutputType = {
MATCH_SCORE: 'MATCH_SCORE', NEED_PARSE: 'NEED_PARSE',
EXPLAINABILITY: 'EXPLAINABILITY', FOLLOW_UP_QUESTIONS: 'FOLLOW_UP_QUESTIONS',
SIGNAL_EXTRACTION: 'SIGNAL_EXTRACTION', MATCH_EXPLANATION: 'MATCH_EXPLANATION',
NEED_PARSING: 'NEED_PARSING', COMPARE_SUMMARY: 'COMPARE_SUMMARY',
SUMMARY: 'SUMMARY', DECISION_BRIEF: 'DECISION_BRIEF',
RECOMMENDATION: 'RECOMMENDATION', DATA_QUALITY_SUMMARY: 'DATA_QUALITY_SUMMARY',
} as const } as const
export type AIOutputType = typeof AIOutputType[keyof typeof AIOutputType] export type AIOutputType = typeof AIOutputType[keyof typeof AIOutputType]
export const AIErrorType = {
SCHEMA_VALIDATION: 'SCHEMA_VALIDATION',
PROVIDER_TIMEOUT: 'PROVIDER_TIMEOUT',
INVALID_JSON: 'INVALID_JSON',
EMPTY_RESPONSE: 'EMPTY_RESPONSE',
RATE_LIMIT: 'RATE_LIMIT',
} as const
export type AIErrorType = typeof AIErrorType[keyof typeof AIErrorType]
export interface AIOutputError {
type: AIErrorType
message: string
recoverable: boolean
}
export interface AIOutput { export interface AIOutput {
id: string id: string
type: AIOutputType type: AIOutputType
inputHash: string // hash of the input for cache/dedup provider: string
outputJson: unknown // raw output — typed per consumer model: string
provider: string // e.g. "openai", "anthropic" promptVersion: string
model: string // e.g. "gpt-4o", "claude-3-5-sonnet" schemaVersion: string
promptVersion: string // semver of the prompt template used inputHash: string
schemaVersion: string // semver of expected output schema outputPreview: string
createdAt: string createdAt: string
reviewedBy?: string latencyMs?: number
reviewStatus?: ReviewStatus costEstimate?: number
reviewStatus: ReviewStatus
relatedEntityType: string
relatedEntityId: string
error?: AIOutputError
} }
+37
View File
@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { aiMonitoringService } from '../services/aiMonitoringService'
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
import type { ReviewStatus } from '../domain/enums'
const QK = 'aiOutputs'
const STALE = 30_000
export function useAIOutputs(filters?: AIMonitoringFilters) {
return useQuery({
queryKey: [QK, filters ?? {}],
queryFn: () => aiMonitoringService.getOutputs(filters),
staleTime: STALE,
select: (res) => res.data ?? [],
})
}
export function useAIOutput(id: string | null) {
return useQuery({
queryKey: [QK, 'detail', id],
queryFn: () => aiMonitoringService.getOutput(id!),
staleTime: STALE,
enabled: !!id,
select: (res) => res.data ?? null,
})
}
export function useUpdateAIOutputReviewStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
aiMonitoringService.updateReviewStatus(id, status),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [QK] })
},
})
}
+243
View File
@@ -0,0 +1,243 @@
import type { AIOutput } from '../domain/aiOutput'
export const mockAIOutputs: AIOutput[] = [
{
id: 'aio-001',
type: 'NEED_PARSE',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'need-parse-v2.3',
schemaVersion: 'schema-v4',
inputHash: 'a7f3b2c1',
outputPreview: '{"criteria":{"area":{"min":300,"max":800},"location":"Zürich","type":"Büro","budget":{"max":12000}},"confidence":0.94}',
createdAt: '2026-05-17T10:24:00Z',
latencyMs: 1240,
costEstimate: 0.0034,
reviewStatus: 'UNREVIEWED',
relatedEntityType: 'NEED',
relatedEntityId: 'need-001',
},
{
id: 'aio-002',
type: 'MATCH_EXPLANATION',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'match-explain-v1.8',
schemaVersion: 'schema-v3',
inputHash: 'c8d4e9f2',
outputPreview: 'Dieses Objekt erfüllt 4 von 5 Hardkriterien: Fläche 450m² (✓), Lage Zürich-Innenstadt (✓), Budget CHF 9\'500/Mt (✓), Parkplätze 2/3 (✗), Verfügbarkeit Q3 2026 (✓).',
createdAt: '2026-05-17T09:15:00Z',
latencyMs: 2100,
costEstimate: 0.0089,
reviewStatus: 'APPROVED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-003',
},
{
id: 'aio-003',
type: 'DATA_QUALITY_SUMMARY',
provider: 'anthropic',
model: 'claude-3-haiku-20240307',
promptVersion: 'dq-summary-v1.2',
schemaVersion: 'schema-v2',
inputHash: 'f1a2b3c4',
outputPreview: '[FEHLER: Antwort nach 8.5s unterbrochen]',
createdAt: '2026-05-17T08:45:00Z',
latencyMs: 8500,
reviewStatus: 'FLAGGED',
relatedEntityType: 'PROPERTY',
relatedEntityId: 'prop-007',
error: {
type: 'PROVIDER_TIMEOUT',
message: 'Request timed out after 8500ms. Provider did not respond within the allowed window.',
recoverable: true,
},
},
{
id: 'aio-004',
type: 'FOLLOW_UP_QUESTIONS',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'follow-up-v1.5',
schemaVersion: 'schema-v3',
inputHash: 'b2c9d7e3',
outputPreview: '["Welche Nutzungsart bevorzugen Sie: Open Space oder Einzelbüros?","Ist ein Außenbereich oder Dachterrasse gewünscht?","Bis wann benötigen Sie die Fläche?"]',
createdAt: '2026-05-17T08:02:00Z',
latencyMs: 890,
costEstimate: 0.0021,
reviewStatus: 'UNREVIEWED',
relatedEntityType: 'NEED',
relatedEntityId: 'need-002',
},
{
id: 'aio-005',
type: 'COMPARE_SUMMARY',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'compare-v2.0',
schemaVersion: 'schema-v4',
inputHash: 'e4f5a6b7',
outputPreview: 'Vergleich prop-001 vs prop-003: prop-001 bietet 15% mehr Fläche (+90m²), jedoch CHF 800/Mt höhere Mietkosten. prop-003 überzeugt durch Lage und Ausbaustandard.',
createdAt: '2026-05-16T16:30:00Z',
latencyMs: 3200,
costEstimate: 0.0122,
reviewStatus: 'IN_REVIEW',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-001',
},
{
id: 'aio-006',
type: 'DECISION_BRIEF',
provider: 'anthropic',
model: 'claude-3-opus-20240229',
promptVersion: 'decision-v1.1',
schemaVersion: 'schema-v2',
inputHash: 'c3d2e1f0',
outputPreview: 'Empfehlung: prop-002 priorisieren. Höchste Gesamtkongruenz (87%), einziges Objekt mit Außenfläche (250m²). Risiko: Mietpreiserhöhung +5% ab 2027 gemäß Mietvertrag.',
createdAt: '2026-05-16T14:10:00Z',
latencyMs: 4800,
costEstimate: 0.0341,
reviewStatus: 'UNREVIEWED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-005',
},
{
id: 'aio-007',
type: 'MATCH_EXPLANATION',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'match-explain-v1.8',
schemaVersion: 'schema-v3',
inputHash: 'd9e8f7a6',
outputPreview: '[Schema-Validierung fehlgeschlagen: Pflichtfeld \'hardCriteria\' nicht vorhanden im Output]',
createdAt: '2026-05-16T11:55:00Z',
latencyMs: 1850,
reviewStatus: 'REJECTED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-002',
error: {
type: 'SCHEMA_VALIDATION',
message: 'Output schema validation failed: required field \'hardCriteria\' missing. Output was not delivered to UI.',
recoverable: false,
},
},
{
id: 'aio-008',
type: 'NEED_PARSE',
provider: 'anthropic',
model: 'claude-3-haiku-20240307',
promptVersion: 'need-parse-v2.2',
schemaVersion: 'schema-v4',
inputHash: 'a1b2c3d4',
outputPreview: '{"criteria":{"area":{"min":500},"location":"Bern","type":"Logistik","budget":{"max":8000}},"confidence":0.91}',
createdAt: '2026-05-16T09:40:00Z',
latencyMs: 560,
costEstimate: 0.0009,
reviewStatus: 'APPROVED',
relatedEntityType: 'NEED',
relatedEntityId: 'need-003',
},
{
id: 'aio-009',
type: 'DATA_QUALITY_SUMMARY',
provider: 'anthropic',
model: 'claude-3-haiku-20240307',
promptVersion: 'dq-summary-v1.2',
schemaVersion: 'schema-v2',
inputHash: 'f8e7d6c5',
outputPreview: 'Qualitätsscore: 72%. Fehlende Felder: Mietpreis/m² (kritisch), letzte Aktualisierung > 6 Monate. Empfehlung: Aktualisierung anfordern.',
createdAt: '2026-05-16T08:15:00Z',
latencyMs: 1100,
costEstimate: 0.0018,
reviewStatus: 'UNREVIEWED',
relatedEntityType: 'PROPERTY',
relatedEntityId: 'prop-003',
},
{
id: 'aio-010',
type: 'COMPARE_SUMMARY',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'compare-v2.0',
schemaVersion: 'schema-v4',
inputHash: 'b5c4d3e2',
outputPreview: '[Invalid JSON: unexpected token at position 142 — output truncated mid-generation]',
createdAt: '2026-05-15T17:20:00Z',
latencyMs: 2900,
reviewStatus: 'FLAGGED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-007',
error: {
type: 'INVALID_JSON',
message: 'Response contained malformed JSON: unexpected token at position 142. Likely caused by mid-stream truncation.',
recoverable: true,
},
},
{
id: 'aio-011',
type: 'FOLLOW_UP_QUESTIONS',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'follow-up-v1.5',
schemaVersion: 'schema-v3',
inputHash: 'c6d5e4f3',
outputPreview: '["Welche ÖPNV-Anbindung ist Mindestanforderung?","Benötigen Sie eigene Ladeinfrastruktur für E-Fahrzeuge?","Ist Co-Working-Anteil vorstellbar?"]',
createdAt: '2026-05-15T14:50:00Z',
latencyMs: 1450,
costEstimate: 0.0028,
reviewStatus: 'IN_REVIEW',
relatedEntityType: 'NEED',
relatedEntityId: 'need-004',
},
{
id: 'aio-012',
type: 'DECISION_BRIEF',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'decision-v1.2',
schemaVersion: 'schema-v2',
inputHash: 'd7e6f5a4',
outputPreview: 'Empfehlung: Angebot annehmen. Match-Score 91%, alle Hardkriterien erfüllt. Fläche 680m² entspricht Profil (600750m²). Nächste Schritte: Kontaktfreigabe beantragen.',
createdAt: '2026-05-15T11:30:00Z',
latencyMs: 3900,
costEstimate: 0.0198,
reviewStatus: 'APPROVED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-004',
},
{
id: 'aio-013',
type: 'NEED_PARSE',
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
promptVersion: 'need-parse-v2.3',
schemaVersion: 'schema-v4',
inputHash: 'e8f7a6b5',
outputPreview: '[Leere Antwort empfangen — kein Output generiert]',
createdAt: '2026-05-15T09:05:00Z',
reviewStatus: 'FLAGGED',
relatedEntityType: 'NEED',
relatedEntityId: 'need-005',
error: {
type: 'EMPTY_RESPONSE',
message: 'Provider returned an empty response body. No tokens were generated. Request may have been filtered.',
recoverable: true,
},
},
{
id: 'aio-014',
type: 'MATCH_EXPLANATION',
provider: 'anthropic',
model: 'claude-3-opus-20240229',
promptVersion: 'match-explain-v1.9',
schemaVersion: 'schema-v3',
inputHash: 'f9a8b7c6',
outputPreview: 'Detailbegründung: Bürofläche 520m² entspricht exakt dem Suchprofil (500600m²). Mietpreis CHF 11\'200/Mt liegt 6.7% über Budget, jedoch kompensiert durch Lagequalität Zürich City.',
createdAt: '2026-05-15T08:00:00Z',
latencyMs: 5200,
costEstimate: 0.0412,
reviewStatus: 'UNREVIEWED',
relatedEntityType: 'MATCH',
relatedEntityId: 'match-009',
},
]
+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 { import {
Box, AIMonitoringMetrics,
Card, AIOutputTable,
Chip, AIOutputDetailPanel,
Typography, AIMonitoringEmptyState,
LinearProgress, } from '../../components/ai-monitoring'
Stack, import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring'
Table, import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
TableBody, import type { ReviewStatus } from '../../domain/enums'
TableCell,
TableHead,
TableRow,
Alert,
CircularProgress,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { propertyService } from '../../services/propertyService'
interface MetricCard { interface Filters {
label: string type: AIOutputType | ''
value: string reviewStatus: ReviewStatus | ''
color: string hasError: boolean | null
note: string
} }
const HEALTH_METRICS: MetricCard[] = [ const TYPE_LABELS: Record<AIOutputType, string> = {
{ label: 'Extraktionsgenauigkeit', value: '94%', color: '#1a7a4a', note: 'Ø letzte 30 Tage' }, NEED_PARSE: 'Bedarf-Parsing',
{ label: 'Konfidenz-Ø', value: '73%', color: '#d97706', note: 'Alle Objekte' }, FOLLOW_UP_QUESTIONS: 'Rückfragen',
{ label: 'Validierungsrate', value: '88%', color: '#1a7a4a', note: 'Menschliche Bestätigung' }, MATCH_EXPLANATION: 'Match-Begründung',
{ label: 'Fehlerrate', value: '2.1%', color: '#1a7a4a', note: 'Kritische Fehler' }, COMPARE_SUMMARY: 'Vergleich',
] DECISION_BRIEF: 'Entscheidungs-Brief',
DATA_QUALITY_SUMMARY: 'Datenqualität',
interface AIDecision {
timestamp: string
type: string
confidence: string
result: string
impact: string
} }
const RECENT_DECISIONS: AIDecision[] = [ const AI_OUTPUT_TYPES: AIOutputType[] = [
{ 'NEED_PARSE',
timestamp: '15.05.2025 14:32', 'FOLLOW_UP_QUESTIONS',
type: 'Bedarfsextraktion', 'MATCH_EXPLANATION',
confidence: '91%', 'COMPARE_SUMMARY',
result: 'Kriterien extrahiert', 'DECISION_BRIEF',
impact: 'Suche ausgelöst', 'DATA_QUALITY_SUMMARY',
},
{
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',
},
] ]
function getConfidenceBadge(pct: string) { const REVIEW_STATUSES: ReviewStatus[] = ['UNREVIEWED', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'FLAGGED']
const n = parseInt(pct) const STATUS_LABELS: Record<ReviewStatus, string> = {
const color = n >= 85 ? '#1a7a4a' : n >= 70 ? '#d97706' : '#c0392b' UNREVIEWED: 'Ungeprüft',
return ( IN_REVIEW: 'In Prüfung',
<Chip APPROVED: 'Genehmigt',
label={pct} REJECTED: 'Abgelehnt',
size="small" FLAGGED: 'Markiert',
sx={{ bgcolor: color, color: 'white', fontWeight: 700, fontSize: 11 }} }
/>
) 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() { export default function AIMonitoring() {
const { data: propResp, isLoading } = useQuery({ const [filters, setFilters] = useState<Filters>({ type: '', reviewStatus: '', hasError: null })
queryKey: ['properties'], const [selectedOutput, setSelectedOutput] = useState<AIOutput | null>(null)
queryFn: () => propertyService.getAll(),
})
const properties = propResp?.data ?? [] const { data: allOutputs = [], isLoading } = useAIOutputs()
const updateStatus = useUpdateAIOutputReviewStatus()
const highConf = properties.filter(p => p.confidenceScore > 0.85).length const filtered = applyFilters(allOutputs, filters)
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 failedCount = allOutputs.filter(o => !!o.error).length
const total = properties.length || 1 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 ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
{/* Page Header */} {/* Header */}
<Box <Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 2.5, py: 1.5, flexShrink: 0 }}>
sx={{ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
bgcolor: 'white', <Bot size={18} color="#4f46e5" />
borderBottom: '1px solid #e2e8f0', <Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b' }}>
px: 3, AI Monitoring
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
</Typography> </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> </Box>
<Typography variant="caption" color="text.secondary">
Transparenz und Governance für KI-generierte Outputs
</Typography>
</Box> </Box>
<Box sx={{ px: 3, py: 3 }}> {/* Metrics strip */}
{/* Health Metrics */} {!isLoading && <AIMonitoringMetrics outputs={allOutputs} />}
<Box className="grid grid-cols-4 gap-4" sx={{ mb: 3 }}>
{HEALTH_METRICS.map(m => ( {/* Filter bar */}
<Card key={m.label} sx={{ p: 2.5 }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', px: 2, py: 0.875, borderBottom: '1px solid #e2e8f0', bgcolor: '#fafafa', flexShrink: 0, flexWrap: 'wrap' }}>
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.4, display: 'block' }}> <Select
{m.label} size="small"
</Typography> value={filters.type}
<Typography variant="h3" sx={{ fontWeight: 800, color: m.color }}> onChange={e => setFilters(f => ({ ...f, type: e.target.value as AIOutputType | '' }))}
{m.value} displayEmpty
</Typography> sx={{ fontSize: '0.75rem', minWidth: 155 }}
<Typography variant="caption" color="text.secondary"> >
{m.note} <MenuItem value="">Alle Typen</MenuItem>
</Typography> {AI_OUTPUT_TYPES.map(t => (
</Card> <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>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}> {/* Right: detail panel */}
{/* Confidence Distribution */} <Box
<Card sx={{ p: 2.5 }}> sx={{
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}> width: selectedOutput ? 380 : 0,
Konfidenzverteilung flexShrink: 0,
</Typography> borderLeft: selectedOutput ? '1px solid #e2e8f0' : 'none',
overflow: 'hidden',
{isLoading ? ( transition: 'width 0.15s ease',
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> bgcolor: 'white',
<CircularProgress size={32} /> }}
</Box> >
) : ( {selectedOutput ? (
<Stack spacing={2}> <AIOutputDetailPanel
<Box> output={selectedOutput}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> onClose={() => setSelectedOutput(null)}
<Typography variant="body2" sx={{ fontWeight: 500 }}>Hoch (&gt;85%)</Typography> onUpdateStatus={handleUpdateStatus}
<Typography variant="body2" color="text.secondary">{highConf} Objekte</Typography> isSubmitting={updateStatus.isPending}
</Box> />
<LinearProgress ) : null}
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>
</Box> </Box>
{/* Recent AI Decisions */} {/* Empty selection hint when no panel open */}
<Card> {!selectedOutput && filtered.length > 0 && (
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}> <Box
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> sx={{
Letzte KI-Entscheidungen width: 260,
</Typography> flexShrink: 0,
borderLeft: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#fafafa',
}}
>
<AIMonitoringEmptyState context="no-selection" />
</Box> </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>
</Box> </Box>
) )
+15
View File
@@ -0,0 +1,15 @@
import type { AIOutput, AIOutputType } from '../domain/aiOutput'
import type { ReviewStatus } from '../domain/enums'
export interface AIMonitoringFilters {
type?: AIOutputType
reviewStatus?: ReviewStatus
hasError?: boolean
model?: string
}
export interface IAIMonitoringProvider {
getOutputs(filters?: AIMonitoringFilters): Promise<AIOutput[]>
getOutput(id: string): Promise<AIOutput | null>
updateReviewStatus(id: string, status: ReviewStatus): Promise<AIOutput>
}
@@ -0,0 +1,29 @@
import type { IAIMonitoringProvider, AIMonitoringFilters } from './IAIMonitoringProvider'
import type { AIOutput } from '../domain/aiOutput'
import type { ReviewStatus } from '../domain/enums'
import { mockAIOutputs } from '../mock-data/aiOutputs'
const store: AIOutput[] = [...mockAIOutputs]
export const MockupAIMonitoringProvider: IAIMonitoringProvider = {
async getOutputs(filters?: AIMonitoringFilters) {
let results = [...store]
if (filters?.type) results = results.filter(o => o.type === filters.type)
if (filters?.reviewStatus) results = results.filter(o => o.reviewStatus === filters.reviewStatus)
if (filters?.hasError === true) results = results.filter(o => !!o.error)
if (filters?.hasError === false) results = results.filter(o => !o.error)
if (filters?.model) results = results.filter(o => o.model === filters.model)
return results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
},
async getOutput(id: string) {
return store.find(o => o.id === id) ?? null
},
async updateReviewStatus(id: string, status: ReviewStatus): Promise<AIOutput> {
const idx = store.findIndex(o => o.id === id)
if (idx === -1) throw new Error(`AIOutput ${id} not found`)
store[idx] = { ...store[idx], reviewStatus: status }
return store[idx]
},
}
+24
View File
@@ -0,0 +1,24 @@
import { MockupAIMonitoringProvider } from '../provider/MockupAIMonitoringProvider'
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
import type { AIOutput } from '../domain/aiOutput'
import type { ReviewStatus } from '../domain/enums'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupAIMonitoringProvider
export const aiMonitoringService = {
async getOutputs(filters?: AIMonitoringFilters): Promise<ListResponse<AIOutput>> {
const data = await provider.getOutputs(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getOutput(id: string): Promise<ItemResponse<AIOutput | null>> {
const data = await provider.getOutput(id)
return { data }
},
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<AIOutput>> {
const data = await provider.updateReviewStatus(id, status)
return { data }
},
}