diff --git a/src/components/ai-monitoring/AIErrorBadge.tsx b/src/components/ai-monitoring/AIErrorBadge.tsx new file mode 100644 index 0000000..d063d8b --- /dev/null +++ b/src/components/ai-monitoring/AIErrorBadge.tsx @@ -0,0 +1,23 @@ +import { Chip, Tooltip } from '@mui/material' +import type { AIOutputError } from '../../domain/aiOutput' + +const ERROR_CONFIG: Record = { + 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 ( + + + + ) +} diff --git a/src/components/ai-monitoring/AIMonitoringEmptyState.tsx b/src/components/ai-monitoring/AIMonitoringEmptyState.tsx new file mode 100644 index 0000000..3c598d7 --- /dev/null +++ b/src/components/ai-monitoring/AIMonitoringEmptyState.tsx @@ -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 ( + + + {title} + {desc} + + ) +} diff --git a/src/components/ai-monitoring/AIMonitoringMetrics.tsx b/src/components/ai-monitoring/AIMonitoringMetrics.tsx new file mode 100644 index 0000000..8c051b0 --- /dev/null +++ b/src/components/ai-monitoring/AIMonitoringMetrics.tsx @@ -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 ( + + + {label} + + + {value} + + + ) +} + +function mostCommon(arr: string[]): string { + if (!arr.length) return '–' + const freq = arr.reduce>((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 ( + + + 0 ? '#c0392b' : undefined} /> + 0 ? '#d97706' : undefined} /> + = 70 ? '#1a7a4a' : '#d97706'} /> + + + + ) +} diff --git a/src/components/ai-monitoring/AIOutputDetailPanel.tsx b/src/components/ai-monitoring/AIOutputDetailPanel.tsx new file mode 100644 index 0000000..d5869f5 --- /dev/null +++ b/src/components/ai-monitoring/AIOutputDetailPanel.tsx @@ -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 = { + 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 = { + 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 = { + '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 = { + 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 ( + + + {label} + + {children} + + ) +} + +export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitting }: Props) { + const handleCopyJson = () => { + navigator.clipboard.writeText(output.outputPreview).catch(() => {}) + } + + return ( + + {/* Header */} + + + + + + {output.error && } + + + {TYPE_LABELS[output.type] ?? output.type} + + + + + + + + + {/* Scrollable body */} + + {/* Metadata */} + + + {output.id} + + + + + {new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })} + + + + + {MODEL_LABELS[output.model] ?? output.model} + + + + + {output.provider} + + + + + + + + {output.inputHash} + + + + + {ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '} + + {output.relatedEntityId} + + + + {output.latencyMs != null && ( + + 5000 ? '#c0392b' : '#334155', fontWeight: output.latencyMs > 5000 ? 700 : 400 }}> + {(output.latencyMs / 1000).toFixed(2)}s + + + )} + {output.costEstimate != null && ( + + + ${output.costEstimate.toFixed(4)} + + + )} + + + + {/* Error details */} + {output.error && ( + + + {ERROR_TYPE_LABELS[output.error.type] ?? output.error.type} +
+ {output.error.message} + {output.error.recoverable && ( + + Wiederholbar — kann erneut ausgelöst werden. + + )} +
+
+ )} + + {/* Output preview */} + + + Output-Vorschau + + + {output.outputPreview || '(kein Output)'} + + + + + + {/* Actions */} + +
+
+ ) +} diff --git a/src/components/ai-monitoring/AIOutputStatusBadge.tsx b/src/components/ai-monitoring/AIOutputStatusBadge.tsx new file mode 100644 index 0000000..409d3c0 --- /dev/null +++ b/src/components/ai-monitoring/AIOutputStatusBadge.tsx @@ -0,0 +1,21 @@ +import { Chip } from '@mui/material' +import type { ReviewStatus } from '../../domain/enums' + +const CONFIG: Record = { + 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 ( + + ) +} diff --git a/src/components/ai-monitoring/AIOutputTable.tsx b/src/components/ai-monitoring/AIOutputTable.tsx new file mode 100644 index 0000000..1a6e9a0 --- /dev/null +++ b/src/components/ai-monitoring/AIOutputTable.tsx @@ -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 = { + 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 = { + NEED_PARSE: '#1e3a5f', + FOLLOW_UP_QUESTIONS: '#0891b2', + MATCH_EXPLANATION: '#4f46e5', + COMPARE_SUMMARY: '#1a7a4a', + DECISION_BRIEF: '#7c3aed', + DATA_QUALITY_SUMMARY: '#d97706', +} + +const MODEL_SHORT: Record = { + '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 + } + if (outputs.length === 0) { + return + } + + return ( + + + + Zeitpunkt + Typ + Modell + Version + Status + Latenz + Fehler + + + + {outputs.map(output => { + const isSelected = selectedId === output.id + const color = TYPE_COLORS[output.type] ?? '#64748b' + return ( + 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' }, + }} + > + + + {shortTime(output.createdAt)} + + + + + + {TYPE_LABELS[output.type] ?? output.type} + + + + + + {MODEL_SHORT[output.model] ?? output.model} + + + + + + + + + + 5000 ? '#c0392b' : '#64748b' }}> + {output.latencyMs != null ? `${(output.latencyMs / 1000).toFixed(1)}s` : '–'} + + + + {output.error ? : ( + + )} + + + ) + })} + +
+ ) +} diff --git a/src/components/ai-monitoring/AIReviewActionToolbar.tsx b/src/components/ai-monitoring/AIReviewActionToolbar.tsx new file mode 100644 index 0000000..8ab9a59 --- /dev/null +++ b/src/components/ai-monitoring/AIReviewActionToolbar.tsx @@ -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 ( + + {canSendToReview && ( + + )} + {canApprove && ( + + )} + {canReject && ( + + )} + + + + + ) +} diff --git a/src/components/ai-monitoring/PromptVersionBadge.tsx b/src/components/ai-monitoring/PromptVersionBadge.tsx new file mode 100644 index 0000000..f675db7 --- /dev/null +++ b/src/components/ai-monitoring/PromptVersionBadge.tsx @@ -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 = ( + + + {promptVersion} + + {schemaVersion && ( + <> + + + {schemaVersion} + + + )} + + ) + + return schemaVersion ? ( + {badge} + ) : badge +} diff --git a/src/components/ai-monitoring/index.ts b/src/components/ai-monitoring/index.ts new file mode 100644 index 0000000..27ab0b5 --- /dev/null +++ b/src/components/ai-monitoring/index.ts @@ -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' diff --git a/src/domain/aiOutput.ts b/src/domain/aiOutput.ts index ba1515f..cb84101 100644 --- a/src/domain/aiOutput.ts +++ b/src/domain/aiOutput.ts @@ -1,25 +1,44 @@ import type { ReviewStatus } from './enums' export const AIOutputType = { - MATCH_SCORE: 'MATCH_SCORE', - EXPLAINABILITY: 'EXPLAINABILITY', - SIGNAL_EXTRACTION: 'SIGNAL_EXTRACTION', - NEED_PARSING: 'NEED_PARSING', - SUMMARY: 'SUMMARY', - RECOMMENDATION: 'RECOMMENDATION', + NEED_PARSE: 'NEED_PARSE', + FOLLOW_UP_QUESTIONS: 'FOLLOW_UP_QUESTIONS', + MATCH_EXPLANATION: 'MATCH_EXPLANATION', + COMPARE_SUMMARY: 'COMPARE_SUMMARY', + DECISION_BRIEF: 'DECISION_BRIEF', + DATA_QUALITY_SUMMARY: 'DATA_QUALITY_SUMMARY', } as const 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 { id: string type: AIOutputType - inputHash: string // hash of the input for cache/dedup - outputJson: unknown // raw output — typed per consumer - provider: string // e.g. "openai", "anthropic" - model: string // e.g. "gpt-4o", "claude-3-5-sonnet" - promptVersion: string // semver of the prompt template used - schemaVersion: string // semver of expected output schema + provider: string + model: string + promptVersion: string + schemaVersion: string + inputHash: string + outputPreview: string createdAt: string - reviewedBy?: string - reviewStatus?: ReviewStatus + latencyMs?: number + costEstimate?: number + reviewStatus: ReviewStatus + relatedEntityType: string + relatedEntityId: string + error?: AIOutputError } diff --git a/src/hooks/useAIMonitoring.ts b/src/hooks/useAIMonitoring.ts new file mode 100644 index 0000000..48b7555 --- /dev/null +++ b/src/hooks/useAIMonitoring.ts @@ -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] }) + }, + }) +} diff --git a/src/mock-data/aiOutputs.ts b/src/mock-data/aiOutputs.ts new file mode 100644 index 0000000..6c04ac0 --- /dev/null +++ b/src/mock-data/aiOutputs.ts @@ -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', + }, +] diff --git a/src/pages/ops/AIMonitoring.tsx b/src/pages/ops/AIMonitoring.tsx index 50d3ff3..8e9ef09 100644 --- a/src/pages/ops/AIMonitoring.tsx +++ b/src/pages/ops/AIMonitoring.tsx @@ -1,279 +1,225 @@ +import { useState } from 'react' +import { Box, Chip, MenuItem, Select, Typography } from '@mui/material' +import { Bot } from 'lucide-react' import { - Box, - Card, - Chip, - Typography, - LinearProgress, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Alert, - CircularProgress, -} from '@mui/material' -import { useQuery } from '@tanstack/react-query' -import { propertyService } from '../../services/propertyService' + AIMonitoringMetrics, + AIOutputTable, + AIOutputDetailPanel, + AIMonitoringEmptyState, +} from '../../components/ai-monitoring' +import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring' +import type { AIOutput, AIOutputType } from '../../domain/aiOutput' +import type { ReviewStatus } from '../../domain/enums' -interface MetricCard { - label: string - value: string - color: string - note: string +interface Filters { + type: AIOutputType | '' + reviewStatus: ReviewStatus | '' + hasError: boolean | null } -const HEALTH_METRICS: MetricCard[] = [ - { label: 'Extraktionsgenauigkeit', value: '94%', color: '#1a7a4a', note: 'Ø letzte 30 Tage' }, - { label: 'Konfidenz-Ø', value: '73%', color: '#d97706', note: 'Alle Objekte' }, - { label: 'Validierungsrate', value: '88%', color: '#1a7a4a', note: 'Menschliche Bestätigung' }, - { label: 'Fehlerrate', value: '2.1%', color: '#1a7a4a', note: 'Kritische Fehler' }, -] - -interface AIDecision { - timestamp: string - type: string - confidence: string - result: string - impact: string +const TYPE_LABELS: Record = { + NEED_PARSE: 'Bedarf-Parsing', + FOLLOW_UP_QUESTIONS: 'Rückfragen', + MATCH_EXPLANATION: 'Match-Begründung', + COMPARE_SUMMARY: 'Vergleich', + DECISION_BRIEF: 'Entscheidungs-Brief', + DATA_QUALITY_SUMMARY: 'Datenqualität', } -const RECENT_DECISIONS: AIDecision[] = [ - { - timestamp: '15.05.2025 14:32', - type: 'Bedarfsextraktion', - confidence: '91%', - result: 'Kriterien extrahiert', - impact: 'Suche ausgelöst', - }, - { - timestamp: '15.05.2025 11:15', - type: 'Match-Scoring', - confidence: '88%', - result: '3 Matches berechnet', - impact: 'Review ausgelöst', - }, - { - timestamp: '14.05.2025 16:40', - type: 'Signal-Erkennung', - confidence: '72%', - result: 'Expansion erkannt', - impact: 'Signal erstellt', - }, - { - timestamp: '14.05.2025 09:00', - type: 'Datenqualitätsprüfung', - confidence: '95%', - result: '2 Warnungen erkannt', - impact: 'Meldung erstellt', - }, - { - timestamp: '13.05.2025 15:22', - type: 'Match-Scoring', - confidence: '84%', - result: '2 Matches berechnet', - impact: 'Review ausgelöst', - }, - { - timestamp: '12.05.2025 10:11', - type: 'Signal-Erkennung', - confidence: '65%', - result: 'Möglicher Auszug erkannt', - impact: 'Signal erstellt', - }, +const AI_OUTPUT_TYPES: AIOutputType[] = [ + 'NEED_PARSE', + 'FOLLOW_UP_QUESTIONS', + 'MATCH_EXPLANATION', + 'COMPARE_SUMMARY', + 'DECISION_BRIEF', + 'DATA_QUALITY_SUMMARY', ] -function getConfidenceBadge(pct: string) { - const n = parseInt(pct) - const color = n >= 85 ? '#1a7a4a' : n >= 70 ? '#d97706' : '#c0392b' - return ( - - ) +const REVIEW_STATUSES: ReviewStatus[] = ['UNREVIEWED', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'FLAGGED'] +const STATUS_LABELS: Record = { + UNREVIEWED: 'Ungeprüft', + IN_REVIEW: 'In Prüfung', + APPROVED: 'Genehmigt', + REJECTED: 'Abgelehnt', + FLAGGED: 'Markiert', +} + +function applyFilters(outputs: AIOutput[], filters: Filters): AIOutput[] { + return outputs.filter(o => { + if (filters.type && o.type !== filters.type) return false + if (filters.reviewStatus && o.reviewStatus !== filters.reviewStatus) return false + if (filters.hasError === true && !o.error) return false + if (filters.hasError === false && !!o.error) return false + return true + }) } export default function AIMonitoring() { - const { data: propResp, isLoading } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), - }) + const [filters, setFilters] = useState({ type: '', reviewStatus: '', hasError: null }) + const [selectedOutput, setSelectedOutput] = useState(null) - const properties = propResp?.data ?? [] + const { data: allOutputs = [], isLoading } = useAIOutputs() + const updateStatus = useUpdateAIOutputReviewStatus() - const highConf = properties.filter(p => p.confidenceScore > 0.85).length - const midConf = properties.filter(p => p.confidenceScore >= 0.65 && p.confidenceScore <= 0.85).length - const lowConf = properties.filter(p => p.confidenceScore < 0.65).length - const total = properties.length || 1 + const filtered = applyFilters(allOutputs, filters) + + const failedCount = allOutputs.filter(o => !!o.error).length + const pendingCount = allOutputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length + + const handleUpdateStatus = (status: ReviewStatus) => { + if (!selectedOutput) return + updateStatus.mutate( + { id: selectedOutput.id, status }, + { onSuccess: (res) => setSelectedOutput(res.data) } + ) + } + + const activeFilterCount = [filters.type, filters.reviewStatus, filters.hasError !== null].filter(Boolean).length return ( - - {/* Page Header */} - - - - - AI Monitoring - - - - - AI-Layer Gesundheit und Entscheidungsqualität + + {/* Header */} + + + + + AI Monitoring + + {failedCount > 0 && ( + + )} + {pendingCount > 0 && ( + + )} + + + Transparenz und Governance für KI-generierte Outputs + - - {/* Health Metrics */} - - {HEALTH_METRICS.map(m => ( - - - {m.label} - - - {m.value} - - - {m.note} - - + {/* Metrics strip */} + {!isLoading && } + + {/* Filter bar */} + + + + + + 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 && ( + setFilters({ type: '', reviewStatus: '', hasError: null })} + sx={{ cursor: 'pointer', fontSize: '0.75rem', color: '#64748b' }} + /> + )} + + + {activeFilterCount > 0 ? `${filtered.length} / ${allOutputs.length}` : `${allOutputs.length} Outputs`} + + + + {/* Body */} + + {/* Left: table */} + + {isLoading ? ( + + Laden… + + ) : ( + + )} - - {/* Confidence Distribution */} - - - Konfidenzverteilung - - - {isLoading ? ( - - - - ) : ( - - - - Hoch (>85%) - {highConf} Objekte - - - - - - Mittel (65–85%) - {midConf} Objekte - - - - - - Niedrig (<65%) - {lowConf} Objekte - - - - - )} - - - {/* Anomaly Alerts */} - - - Anomalien - - - - Mietpreisangaben für prop-004 weichen von Marktdurchschnitt ab (±31%). Manuelle Prüfung empfohlen. - - - Keine kritischen Anomalien erkannt. System läuft stabil. - - - + {/* Right: detail panel */} + + {selectedOutput ? ( + setSelectedOutput(null)} + onUpdateStatus={handleUpdateStatus} + isSubmitting={updateStatus.isPending} + /> + ) : null} - {/* Recent AI Decisions */} - - - - Letzte KI-Entscheidungen - + {/* Empty selection hint when no panel open */} + {!selectedOutput && filtered.length > 0 && ( + + - - - - Zeitpunkt - Entscheidungstyp - Konfidenz - Ergebnis - Einfluss - - - - {RECENT_DECISIONS.map((d, i) => ( - - - {d.timestamp} - - - {d.type} - - {getConfidenceBadge(d.confidence)} - - {d.result} - - - {d.impact} - - - ))} - -
-
+ )}
) diff --git a/src/provider/IAIMonitoringProvider.ts b/src/provider/IAIMonitoringProvider.ts new file mode 100644 index 0000000..2e40a43 --- /dev/null +++ b/src/provider/IAIMonitoringProvider.ts @@ -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 + getOutput(id: string): Promise + updateReviewStatus(id: string, status: ReviewStatus): Promise +} diff --git a/src/provider/MockupAIMonitoringProvider.ts b/src/provider/MockupAIMonitoringProvider.ts new file mode 100644 index 0000000..79672fa --- /dev/null +++ b/src/provider/MockupAIMonitoringProvider.ts @@ -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 { + 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] + }, +} diff --git a/src/services/aiMonitoringService.ts b/src/services/aiMonitoringService.ts new file mode 100644 index 0000000..3977961 --- /dev/null +++ b/src/services/aiMonitoringService.ts @@ -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> { + const data = await provider.getOutputs(filters) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + + async getOutput(id: string): Promise> { + const data = await provider.getOutput(id) + return { data } + }, + + async updateReviewStatus(id: string, status: ReviewStatus): Promise> { + const data = await provider.updateReviewStatus(id, status) + return { data } + }, +}