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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 (600–750m²). 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 (500–600m²). 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
@@ -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 (>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 (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>
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user