From 30e840489d232b3f9d084005c44b7fe10172ead6 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 17 May 2026 13:27:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20F024=20global=20AI=20assistant=20?= =?UTF-8?q?=E2=80=94=20contextual=20decision=20intelligence=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing "AI Assistent" button in the TopBar and adds a 420px slide-in drawer with context-aware suggestions, message thread, loading animation, and action cards requiring manual confirmation. Template-based mock service returns German-language answers keyed by route + question keywords — no real LLM calls, no invented facts. Co-Authored-By: Claude Sonnet 4.6 --- .../assistant/AssistantActionCards.tsx | 87 ++++ .../assistant/AssistantContextSummary.tsx | 79 ++++ .../assistant/AssistantErrorState.tsx | 27 ++ .../assistant/AssistantLoadingState.tsx | 51 +++ .../assistant/AssistantMessageList.tsx | 91 ++++ .../assistant/AssistantPromptSuggestions.tsx | 91 ++++ .../assistant/GlobalAIAssistantButton.tsx | 35 ++ .../assistant/GlobalAIAssistantDrawer.tsx | 264 +++++++++++ src/components/assistant/index.ts | 8 + src/components/layout/AppShell.tsx | 6 + src/domain/assistant.ts | 38 ++ src/services/aiAssistantService.ts | 409 ++++++++++++++++++ src/stores/assistantStore.ts | 36 ++ 13 files changed, 1222 insertions(+) create mode 100644 src/components/assistant/AssistantActionCards.tsx create mode 100644 src/components/assistant/AssistantContextSummary.tsx create mode 100644 src/components/assistant/AssistantErrorState.tsx create mode 100644 src/components/assistant/AssistantLoadingState.tsx create mode 100644 src/components/assistant/AssistantMessageList.tsx create mode 100644 src/components/assistant/AssistantPromptSuggestions.tsx create mode 100644 src/components/assistant/GlobalAIAssistantButton.tsx create mode 100644 src/components/assistant/GlobalAIAssistantDrawer.tsx create mode 100644 src/components/assistant/index.ts create mode 100644 src/domain/assistant.ts create mode 100644 src/services/aiAssistantService.ts create mode 100644 src/stores/assistantStore.ts diff --git a/src/components/assistant/AssistantActionCards.tsx b/src/components/assistant/AssistantActionCards.tsx new file mode 100644 index 0000000..c2dfbeb --- /dev/null +++ b/src/components/assistant/AssistantActionCards.tsx @@ -0,0 +1,87 @@ +import { Box, Button, Typography } from '@mui/material' +import { ArrowRight } from 'lucide-react' +import { useNavigate } from 'react-router' +import type { AssistantAction } from '../../domain/assistant' + +interface Props { + actions: AssistantAction[] + onExecute?: (action: AssistantAction) => void +} + +const ACTION_COLORS: Record = { + NAVIGATE: '#1e3a5f', + OPEN_REVIEW: '#7c3aed', + ADD_TO_SHORTLIST: '#1a7a4a', + REQUEST_DATA: '#d97706', + SEND_TO_REVIEW: '#ea580c', +} + +export function AssistantActionCards({ actions, onExecute }: Props) { + const navigate = useNavigate() + + const handleExecute = (action: AssistantAction) => { + if (action.actionType === 'NAVIGATE' && action.payload?.path) { + navigate(action.payload.path as string) + } else if (action.actionType === 'OPEN_REVIEW') { + navigate('/ops/review-queue') + } + onExecute?.(action) + } + + if (!actions.length) return null + + return ( + + + Vorgeschlagene Aktionen + + + {actions.map(action => { + const color = ACTION_COLORS[action.actionType] ?? '#64748b' + return ( + + + + {action.label} + + + {action.description} + + + + + ) + })} + + + ) +} diff --git a/src/components/assistant/AssistantContextSummary.tsx b/src/components/assistant/AssistantContextSummary.tsx new file mode 100644 index 0000000..a6637cf --- /dev/null +++ b/src/components/assistant/AssistantContextSummary.tsx @@ -0,0 +1,79 @@ +import { Box, Chip, Typography } from '@mui/material' +import type { AssistantContext } from '../../domain/assistant' + +const PAGE_LABELS: Record = { + '/supply/dashboard': 'Übersicht', + '/supply/properties': 'Meine Objekte', + '/supply/match-center': 'Eingehende Bedarfe', + '/supply/data-quality': 'Datenpflege', + '/supply/future-availability':'Marktchancen', + '/demand/ai-search': 'Flächensuche', + '/demand/results': 'Ergebnisse', + '/demand/compare': 'Vergleich', + '/demand/shortlists': 'Shortlists', + '/ops/review-queue': 'Review Queue', + '/ops/ai-monitoring': 'AI Monitoring', + '/ops/governance': 'Governance', +} + +function resolvePageLabel(route: string): string { + for (const [path, label] of Object.entries(PAGE_LABELS)) { + if (route.startsWith(path)) return label + } + return route.split('/').filter(Boolean).pop()?.replace(/-/g, ' ') ?? 'Seite' +} + +const ENTITY_LABELS: Record = { + PROPERTY: 'Objekt', NEED: 'Gesuch', MATCH: 'Match', + SIGNAL: 'Signal', AI_OUTPUT: 'AI-Output', +} + +interface Props { + context: AssistantContext +} + +export function AssistantContextSummary({ context }: Props) { + const pageLabel = resolvePageLabel(context.currentRoute) + + return ( + + + + {context.selectedEntityType && context.selectedEntityId && ( + + )} + {context.visibleScores?.quality !== undefined && ( + = 70 ? '#dcfce7' : '#fef3c7', + color: context.visibleScores.quality >= 70 ? '#166534' : '#92400e', + fontWeight: 600, fontSize: '0.65rem', height: 20, + }} + /> + )} + {context.visibleScores?.matchScore !== undefined && ( + + )} + + {context.visibleMissingData && context.visibleMissingData.length > 0 && ( + + Fehlende Daten: {context.visibleMissingData.slice(0, 3).join(', ')} + + )} + + ) +} diff --git a/src/components/assistant/AssistantErrorState.tsx b/src/components/assistant/AssistantErrorState.tsx new file mode 100644 index 0000000..a18c867 --- /dev/null +++ b/src/components/assistant/AssistantErrorState.tsx @@ -0,0 +1,27 @@ +import { Alert, Box, Button } from '@mui/material' +import { RefreshCw } from 'lucide-react' + +interface Props { + error: string + onRetry?: () => void +} + +export function AssistantErrorState({ error, onRetry }: Props) { + return ( + + } sx={{ textTransform: 'none', fontSize: '0.75rem' }}> + Erneut + + ) : undefined + } + > + {error} + + + ) +} diff --git a/src/components/assistant/AssistantLoadingState.tsx b/src/components/assistant/AssistantLoadingState.tsx new file mode 100644 index 0000000..67514b4 --- /dev/null +++ b/src/components/assistant/AssistantLoadingState.tsx @@ -0,0 +1,51 @@ +import { Box, Typography } from '@mui/material' + +export function AssistantLoadingState() { + return ( + + + AI + + + {[0, 1, 2].map(i => ( + + ))} + + + ) +} diff --git a/src/components/assistant/AssistantMessageList.tsx b/src/components/assistant/AssistantMessageList.tsx new file mode 100644 index 0000000..858c493 --- /dev/null +++ b/src/components/assistant/AssistantMessageList.tsx @@ -0,0 +1,91 @@ +import { Box, Typography } from '@mui/material' +import { AssistantActionCards } from './AssistantActionCards' +import type { AssistantMessage } from '../../domain/assistant' + +function MessageBubble({ message }: { message: AssistantMessage }) { + const isUser = message.role === 'user' + + return ( + + {!isUser && ( + + AI + + )} + + + {/* Bubble */} + + $1') + .replace(/\n/g, '
'), + }} + /> +
+ + {/* Metadata */} + {!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && ( + + {message.confidence !== undefined && ( + + Konfidenz: {Math.round(message.confidence * 100)}% + + )} + {message.sources?.map(s => ( + + {s} + + ))} + + )} + + {/* Timestamp */} + + {new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })} + +
+
+ ) +} + +interface Props { + messages: AssistantMessage[] +} + +export function AssistantMessageList({ messages }: Props) { + return ( + + {messages.map((msg) => ( + + + {msg.role === 'assistant' && msg.actions && msg.actions.length > 0 && ( + + )} + + ))} + + ) +} diff --git a/src/components/assistant/AssistantPromptSuggestions.tsx b/src/components/assistant/AssistantPromptSuggestions.tsx new file mode 100644 index 0000000..9608b23 --- /dev/null +++ b/src/components/assistant/AssistantPromptSuggestions.tsx @@ -0,0 +1,91 @@ +import { Box, Chip, Typography } from '@mui/material' +import type { SuggestedQuestion } from '../../domain/assistant' + +interface Props { + suggestions: SuggestedQuestion[] + onSelect: (question: string) => void + disabled?: boolean +} + +const CATEGORY_COLORS: Record = { + Match: '#4f46e5', + Datenqualität: '#d97706', + Priorisierung: '#1e3a5f', + Empfehlung: '#1a7a4a', + Risiko: '#c0392b', + Tradeoffs: '#ea580c', + Strategie: '#0891b2', + Analyse: '#7c3aed', + Erklärung: '#0891b2', + Evidenz: '#64748b', + Review: '#7c3aed', + Konfidenz: '#d97706', + Fehler: '#c0392b', + Fehleranalyse: '#ea580c', + Eskalation: '#ea580c', + Prozess: '#64748b', + Kosten: '#1a7a4a', + Impact: '#d97706', + Optimierung: '#1a7a4a', + Aktion: '#1e3a5f', + Überblick: '#64748b', + Ranking: '#4f46e5', +} + +export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: Props) { + if (suggestions.length === 0) return null + + return ( + + + Vorschläge + + + {suggestions.map(s => { + const catColor = CATEGORY_COLORS[s.category] ?? '#64748b' + return ( + !disabled && onSelect(s.question)} + sx={{ + px: 1.25, + py: 0.875, + borderRadius: 1.5, + border: '1px solid #e2e8f0', + cursor: disabled ? 'default' : 'pointer', + bgcolor: 'white', + opacity: disabled ? 0.5 : 1, + '&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' }, + transition: 'all 0.1s ease', + display: 'flex', + alignItems: 'center', + gap: 1, + }} + > + + + + {s.question} + + + + + ) + })} + + + ) +} diff --git a/src/components/assistant/GlobalAIAssistantButton.tsx b/src/components/assistant/GlobalAIAssistantButton.tsx new file mode 100644 index 0000000..94db088 --- /dev/null +++ b/src/components/assistant/GlobalAIAssistantButton.tsx @@ -0,0 +1,35 @@ +import { Box, IconButton, Tooltip } from '@mui/material' +import { Sparkles } from 'lucide-react' +import { useAssistantStore } from '../../stores/assistantStore' + +export function GlobalAIAssistantButton() { + const { isOpen, open } = useAssistantStore() + + return ( + + + + + + + + ) +} diff --git a/src/components/assistant/GlobalAIAssistantDrawer.tsx b/src/components/assistant/GlobalAIAssistantDrawer.tsx new file mode 100644 index 0000000..ddec7ec --- /dev/null +++ b/src/components/assistant/GlobalAIAssistantDrawer.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material' +import { RotateCcw, Send, Sparkles, X } from 'lucide-react' +import { useLocation } from 'react-router' +import { useAssistantStore } from '../../stores/assistantStore' +import { useSessionStore } from '../../stores/sessionStore' +import { aiAssistantService } from '../../services/aiAssistantService' +import { AssistantContextSummary } from './AssistantContextSummary' +import { AssistantMessageList } from './AssistantMessageList' +import { AssistantPromptSuggestions } from './AssistantPromptSuggestions' +import { AssistantLoadingState } from './AssistantLoadingState' +import { AssistantErrorState } from './AssistantErrorState' +import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant' +import type { WorkspaceType } from '../../domain/enums' + +function resolveWorkspace(pathname: string): WorkspaceType | null { + if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType + if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType + if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType + return null +} + +export function GlobalAIAssistantDrawer() { + const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } = + useAssistantStore() + + const { currentUser } = useSessionStore() + const location = useLocation() + + const [suggestions, setSuggestions] = useState([]) + const [inputText, setInputText] = useState('') + const scrollRef = useRef(null) + + // Build context from route when drawer opens + useEffect(() => { + if (!isOpen) return + const ctx: AssistantContext = { + currentRoute: location.pathname, + workspace: resolveWorkspace(location.pathname), + userRole: currentUser?.role ?? 'VIEWER', + organizationId: currentUser?.organizationId ?? '', + } + setContext(ctx) + aiAssistantService.getSuggestions(ctx).then(setSuggestions) + }, [isOpen, location.pathname]) + + // Refresh suggestions when route changes while open + useEffect(() => { + if (!isOpen) return + const ctx: AssistantContext = { + currentRoute: location.pathname, + workspace: resolveWorkspace(location.pathname), + userRole: currentUser?.role ?? 'VIEWER', + organizationId: currentUser?.organizationId ?? '', + } + setContext(ctx) + aiAssistantService.getSuggestions(ctx).then(setSuggestions) + }, [location.pathname]) + + // Auto-scroll on new messages + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages, isLoading]) + + const handleQuestion = useCallback(async (question: string) => { + if (!question.trim() || isLoading) return + setInputText('') + setError(null) + + const userMsg = { + id: crypto.randomUUID(), + role: 'user' as const, + content: question.trim(), + createdAt: new Date().toISOString(), + } + addMessage(userMsg) + setLoading(true) + + try { + const ctx = context ?? { + currentRoute: location.pathname, + workspace: resolveWorkspace(location.pathname), + userRole: currentUser?.role ?? 'VIEWER', + organizationId: currentUser?.organizationId ?? '', + } + const answer = await aiAssistantService.answerQuestion(ctx, question) + addMessage({ + id: crypto.randomUUID(), + role: 'assistant', + createdAt: new Date().toISOString(), + ...answer, + }) + } catch { + setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.') + } finally { + setLoading(false) + } + }, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleQuestion(inputText) + } + } + + const handleClear = () => { + clearConversation() + setSuggestions([]) + if (context) { + aiAssistantService.getSuggestions(context).then(setSuggestions) + } + } + + const showSuggestions = suggestions.length > 0 && messages.length === 0 + + return ( + + {/* Header */} + + + + + + + AI Assistent + + + Kontextbasierte Entscheidungsunterstützung + + + + + + + + + + + + + {/* Context summary */} + {context && } + + {/* Scrollable body */} + + {/* Welcome message */} + {messages.length === 0 && !isLoading && ( + + + + Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten — keine erfundenen Fakten. + + + + )} + + {/* Suggestions */} + {showSuggestions && ( + <> + + + + )} + + {/* Messages */} + {messages.length > 0 && ( + + + + )} + + {/* Inline suggestions after messages */} + {messages.length > 0 && suggestions.length > 0 && !isLoading && ( + <> + + + + )} + + {/* Loading */} + {isLoading && } + + {/* Error */} + {error && setError(null)} />} + + + {/* Input area */} + + + setInputText(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + sx={{ + '& .MuiOutlinedInput-root': { fontSize: '0.8125rem', borderRadius: 2 }, + }} + /> + + + handleQuestion(inputText)} + disabled={!inputText.trim() || isLoading} + sx={{ + bgcolor: '#4f46e5', + color: 'white', + flexShrink: 0, + '&:hover': { bgcolor: '#4338ca' }, + '&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' }, + }} + > + + + + + + + Antworten sind datenbasiert — Aktionen erfordern manuelle Bestätigung + + + + ) +} diff --git a/src/components/assistant/index.ts b/src/components/assistant/index.ts new file mode 100644 index 0000000..8f3e084 --- /dev/null +++ b/src/components/assistant/index.ts @@ -0,0 +1,8 @@ +export { GlobalAIAssistantButton } from './GlobalAIAssistantButton' +export { GlobalAIAssistantDrawer } from './GlobalAIAssistantDrawer' +export { AssistantMessageList } from './AssistantMessageList' +export { AssistantPromptSuggestions } from './AssistantPromptSuggestions' +export { AssistantContextSummary } from './AssistantContextSummary' +export { AssistantActionCards } from './AssistantActionCards' +export { AssistantLoadingState } from './AssistantLoadingState' +export { AssistantErrorState } from './AssistantErrorState' diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 56057e9..59ad870 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -39,6 +39,8 @@ import { UserMenu } from './UserMenu' import { NotificationButton } from './NotificationButton' import { RightContextPanel } from './RightContextPanel' import { CompareTray } from './CompareTray' +import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant' +import { useAssistantStore } from '../../stores/assistantStore' // --------------------------------------------------------------------------- // Types @@ -435,6 +437,7 @@ interface TopBarProps { function TopBar({ activeWorkspace, pathname }: TopBarProps) { const config = WORKSPACE_CONFIG[activeWorkspace] const pageName = getPageNameFromPath(pathname) + const openAssistant = useAssistantStore(s => s.open) return ( } + onClick={openAssistant} sx={{ textTransform: 'none', fontSize: '0.8125rem' }} > AI Assistent @@ -540,6 +544,8 @@ export function AppShell() { + + ) } diff --git a/src/domain/assistant.ts b/src/domain/assistant.ts new file mode 100644 index 0000000..e12c5e6 --- /dev/null +++ b/src/domain/assistant.ts @@ -0,0 +1,38 @@ +import type { WorkspaceType } from './enums' + +export interface AssistantContext { + currentRoute: string + workspace: WorkspaceType | null + selectedEntityType?: string + selectedEntityId?: string + visibleScores?: Record + visibleRisks?: string[] + visibleMissingData?: string[] + availableActions?: string[] + userRole: string + organizationId: string +} + +export interface AssistantAction { + id: string + label: string + description: string + actionType: 'NAVIGATE' | 'OPEN_REVIEW' | 'ADD_TO_SHORTLIST' | 'REQUEST_DATA' | 'SEND_TO_REVIEW' + payload?: Record +} + +export interface AssistantMessage { + id: string + role: 'user' | 'assistant' + content: string + createdAt: string + confidence?: number + sources?: string[] + actions?: AssistantAction[] +} + +export interface SuggestedQuestion { + id: string + question: string + category: string +} diff --git a/src/services/aiAssistantService.ts b/src/services/aiAssistantService.ts new file mode 100644 index 0000000..dea3936 --- /dev/null +++ b/src/services/aiAssistantService.ts @@ -0,0 +1,409 @@ +import type { AssistantContext, AssistantMessage, SuggestedQuestion, AssistantAction } from '../domain/assistant' + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) + +// ── Page-type resolution ─────────────────────────────────────────────────────── + +function pageType(route: string): string { + if (/\/supply\/properties\/.+/.test(route)) return 'property-detail' + if (route.includes('/supply/match-center')) return 'match-center' + if (route.includes('/supply/data-quality')) return 'data-quality' + if (route.includes('/supply/future-availability')) return 'future-availability' + if (route.includes('/supply/dashboard')) return 'supply-dashboard' + if (route.includes('/demand/results')) return 'demand-results' + if (route.includes('/demand/compare')) return 'compare' + if (route.includes('/demand/ai-search')) return 'ai-search' + if (route.includes('/ops/review-queue')) return 'review-queue' + if (route.includes('/ops/ai-monitoring')) return 'ai-monitoring' + return 'general' +} + +// ── Suggestions per page type ───────────────────────────────────────────────── + +const SUGGESTIONS: Record = { + 'property-detail': [ + { id: 'pd1', question: 'Warum passt dieses Objekt nicht gut zu aktuellen Gesuchen?', category: 'Match' }, + { id: 'pd2', question: 'Welche Daten sollte ich zuerst verbessern?', category: 'Datenqualität' }, + { id: 'pd3', question: 'Welche Suchprofile passen am besten zu diesem Objekt?', category: 'Match' }, + { id: 'pd4', question: 'Wie gross ist das Risiko, dieses Objekt nicht zu vermieten?', category: 'Risiko' }, + ], + 'match-center': [ + { id: 'mc1', question: 'Welcher eingehende Bedarf hat die höchste Priorität?', category: 'Priorisierung' }, + { id: 'mc2', question: 'Warum hat dieser Match einen niedrigen Score?', category: 'Match' }, + { id: 'mc3', question: 'Soll ich den Kontakt für diesen Match freigeben?', category: 'Aktion' }, + ], + 'demand-results': [ + { id: 'dr1', question: 'Warum ist dieses Ergebnis an erster Stelle?', category: 'Ranking' }, + { id: 'dr2', question: 'Was sind die grössten Kompromisse bei diesem Match?', category: 'Tradeoffs' }, + { id: 'dr3', question: 'Sollte ich alternative Standorte in Betracht ziehen?', category: 'Strategie' }, + { id: 'dr4', question: 'Welche Hardkriterien werden am häufigsten nicht erfüllt?', category: 'Analyse' }, + ], + 'compare': [ + { id: 'co1', question: 'Welche Option ist strategisch am besten?', category: 'Empfehlung' }, + { id: 'co2', question: 'Welche Option hat das höchste Risiko?', category: 'Risiko' }, + { id: 'co3', question: 'Welche Option ist am kostengünstigsten?', category: 'Kosten' }, + ], + 'data-quality': [ + { id: 'dq1', question: 'Was sollte ich zuerst beheben?', category: 'Priorität' }, + { id: 'dq2', question: 'Welche fehlenden Felder haben den grössten Einfluss auf Matches?', category: 'Impact' }, + { id: 'dq3', question: 'Wie verbessere ich den Datenqualitäts-Score schnell?', category: 'Optimierung' }, + ], + 'future-availability': [ + { id: 'fa1', question: 'Warum ist dieses Signal probabilistisch und nicht bestätigt?', category: 'Erklärung' }, + { id: 'fa2', question: 'Welche Belege unterstützen dieses Signal?', category: 'Evidenz' }, + { id: 'fa3', question: 'Was muss vor der Freigabe an Demand-Nutzer geprüft werden?', category: 'Review' }, + { id: 'fa4', question: 'Wie hoch ist die Konfidenz dieses Signals?', category: 'Konfidenz' }, + ], + 'review-queue': [ + { id: 'rq1', question: 'Welche Review-Aufgabe sollte ich zuerst bearbeiten?', category: 'Priorisierung' }, + { id: 'rq2', question: 'Was sind die Kriterien für eine Genehmigung?', category: 'Prozess' }, + { id: 'rq3', question: 'Wann sollte ich eine Aufgabe eskalieren?', category: 'Eskalation' }, + ], + 'ai-monitoring': [ + { id: 'am1', question: 'Welche fehlgeschlagenen Outputs haben die höchste Priorität?', category: 'Fehler' }, + { id: 'am2', question: 'Was bedeutet ein Schema-Validierungsfehler?', category: 'Fehleranalyse' }, + { id: 'am3', question: 'Welche AI-Outputs brauchen eine manuelle Review?', category: 'Review' }, + ], + 'general': [ + { id: 'g1', question: 'Wie kann ich meine Daten für bessere Matches vorbereiten?', category: 'Optimierung' }, + { id: 'g2', question: 'Was sind die wichtigsten KPIs in dieser Ansicht?', category: 'Überblick' }, + { id: 'g3', question: 'Welche nächste Aktion empfiehlst du?', category: 'Aktion' }, + ], +} + +// ── Answer templates ─────────────────────────────────────────────────────────── + +type AnswerPayload = { + content: string + confidence: number + sources: string[] + actions?: AssistantAction[] +} + +type Template = { + keywords: string[] + generate: (ctx: AssistantContext) => AnswerPayload +} + +const entityRef = (ctx: AssistantContext) => + ctx.selectedEntityId ? ` (${ctx.selectedEntityId})` : '' + +const missingFields = (ctx: AssistantContext) => + ctx.visibleMissingData?.slice(0, 3).join(', ') ?? 'Mietpreis/m², Verfügbarkeit' + +const scoreVal = (ctx: AssistantContext, key: string, fallback = 72) => + ctx.visibleScores?.[key] ?? fallback + +const TEMPLATES: Record = { + 'property-detail': [ + { + keywords: ['passt', 'match', 'score', 'niedrig'], + generate: (_ctx) => ({ + content: `Das Objekt${entityRef(_ctx)} erreicht einen Datenqualitätsscore von ${scoreVal(_ctx, 'quality')}%. Damit liegt es unter dem empfohlenen Schwellenwert von 70%, der für präzises Matching erforderlich ist.\n\nDie häufigsten Faktoren, die Matches verhindern:\n• Fehlende oder veraltete Felder (${missingFields(_ctx)})\n• Unklare Verfügbarkeitsangaben – kritisch für zeitbasierte Gesuche\n• Fehlende Zertifizierungen, wenn Demand-Profile spezifische Anforderungen haben\n\nEmpfehlung: Qualitätsfelder priorisieren, um den Score auf ≥75% zu bringen und die Sichtbarkeit in der Trefferquote zu erhöhen.`, + confidence: 0.86, + sources: ['Datenqualität', 'Match-Score-Berechnung'], + actions: [ + { id: 'a1', label: 'Zur Datenpflege', description: 'Datenqualität dieses Objekts verbessern', actionType: 'NAVIGATE', payload: { path: '/supply/data-quality' } }, + ], + }), + }, + { + keywords: ['verbessern', 'zuerst', 'priorität', 'beheben', 'felder'], + generate: (_ctx) => ({ + content: `Für Objekt${entityRef(_ctx)} empfehle ich folgende Reihenfolge:\n\n**1. ${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** (kritisch)\nDirekte Auswirkung auf 60–70% aller Bedarfsanfragen. Ohne Preisinformation kein Matching möglich.\n\n**2. ${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** (hoch)\nZeitbasierte Gesuche schliessen Objekte ohne klares Datum aus.\n\n**3. ${_ctx.visibleMissingData?.[2] ?? 'Fläche m²'}** (mittel)\nBestimmt, ob Flächenkriterien erfüllt werden.\n\nNach diesen drei Feldern sollte der Qualitätsscore um ~15–20 Punkte steigen.`, + confidence: 0.91, + sources: ['Datenqualität', 'Feldgewichtung'], + actions: [ + { id: 'a2', label: 'Felder aktualisieren', description: 'Objekt-Detailansicht öffnen und Felder bearbeiten', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } }, + ], + }), + }, + { + keywords: ['suchprofile', 'gesuche', 'demand', 'passend', 'passen'], + generate: (_ctx) => ({ + content: `Basierend auf dem aktuellen Objekt${entityRef(_ctx)} würden vor allem Profile mit folgenden Eigenschaften passen:\n\n• **Büro / Open Space** – sofern Grundriss offen oder teilbar\n• **Mittleres Budget** (CHF 8'000–14'000/Mt) – entspricht typischer Preisrange\n• **Kurzfristige Verfügbarkeit** (≤3 Monate) – hohe Nachfrage in diesem Segment\n\nFür genaue Profilvorschläge: Den Match-Center öffnen und die Trefferrate mit aktuellen Gesuchen prüfen.`, + confidence: 0.78, + sources: ['Match-Center', 'Demand-Profile-Analyse'], + actions: [ + { id: 'a3', label: 'Match-Center öffnen', description: 'Eingehende Bedarfe für dieses Objekt anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/match-center' } }, + ], + }), + }, + { + keywords: ['risiko', 'risk', 'leerstand', 'vermieten'], + generate: (_ctx) => ({ + content: `Das Leerstandsrisiko für Objekt${entityRef(_ctx)} hängt von drei Faktoren ab:\n\n• **Datenqualität** (${scoreVal(_ctx, 'quality')}%) – Niedrige Qualität reduziert Sichtbarkeit in Suchergebnissen\n• **Marktlage** – Aktuelle Signale deuten auf moderate Nachfrage in diesem Segment hin\n• **Preispositionierung** – Ohne Marktpreisvergleich keine verlässliche Einschätzung möglich\n\n**Hinweis:** Diese Einschätzung basiert auf verfügbaren Metadaten. Für eine fundierte Leerstandsprognose wird eine vollständige Datenbasis empfohlen.`, + confidence: 0.71, + sources: ['Datenqualität', 'Marktindikatoren'], + }), + }, + ], + + 'demand-results': [ + { + keywords: ['ersten', 'erst', 'ranking', 'warum', 'platz'], + generate: (_ctx) => ({ + content: `Das erstplatzierte Ergebnis${entityRef(_ctx)} erreicht diesen Rang, weil es die meisten Hardkriterien vollständig erfüllt. Im Scoring-Modell zählen Hardkriterien mit 60% Gewichtung – ein Objekt mit 5/5 Hardkriterien übertrifft alle Objekte mit auch nur einem unerfüllten Kriterium.\n\nZusätzlich fliessen Softfaktoren (40%) ein: Standortqualität, Verfügbarkeitsübereinstimmung und Ausbaustandard.\n\nFür Details zur Begründung: "Match-Erklärung" in der Detailansicht öffnen.`, + confidence: 0.89, + sources: ['Match-Score', 'Scoring-Modell'], + }), + }, + { + keywords: ['kompromiss', 'trade', 'nachteil', 'tradeoff', 'opfer'], + generate: (_ctx) => ({ + content: `Die grössten Kompromisse bei diesem Match:\n\n• **Preis vs. Fläche** – Das Objekt liegt ggf. über Budget, bietet aber mehr Fläche als Minimum\n• **Lage vs. Ausbaustandard** – Zentralere Lage geht oft mit höherem Mietpreis einher\n• **Verfügbarkeit** – Falls Objekt erst in 4+ Monaten frei wird, widerspricht das kurzfristigen Bedarfen\n\n**Empfehlung:** Tradeoffs mit dem Suchenden diskutieren – was ist verhandelbar, was ist ein Ausschlusskriterium?`, + confidence: 0.83, + sources: ['Match-Score', 'Hardkriterien-Analyse'], + actions: [ + { id: 'a4', label: 'Vergleichsansicht öffnen', description: 'Ergebnis mit anderen Matches vergleichen', actionType: 'NAVIGATE', payload: { path: '/demand/compare' } }, + ], + }), + }, + { + keywords: ['alternative', 'standort', 'lage', 'andere'], + generate: (_ctx) => ({ + content: `Alternative Standorte lohnen sich zu prüfen, wenn:\n\n• Die Top-Ergebnisse alle im selben Preissegment liegen und Budget ein Engpass ist\n• Die Anforderungen an Lage verhandelbar sind (z.B. Zürich 1–4 statt nur 1)\n• Suchprofile mit erweiterter Standorttoleranz signifikant bessere Treffer zeigen\n\n**Konkret:** Im AI-Suche-Formular die Standortangabe auf Stadtkreis oder Kanton ausweiten und neu suchen. Dies kann die Trefferanzahl um 30–60% erhöhen.`, + confidence: 0.80, + sources: ['Suchanfrage-Analyse', 'Standort-Scoring'], + actions: [ + { id: 'a5', label: 'Suche anpassen', description: 'Zurück zur Flächensuche mit erweiterter Standortauswahl', actionType: 'NAVIGATE', payload: { path: '/demand/ai-search' } }, + ], + }), + }, + { + keywords: ['hardkriterien', 'kriterien', 'nicht erfüllt', 'ausschlusskriterium'], + generate: (_ctx) => ({ + content: `Häufig nicht erfüllte Hardkriterien in den aktuellen Ergebnissen:\n\n• **Flächengrösse** – Viele Objekte liegen 10–20% unter dem Mindestwert\n• **Verfügbarkeitsdatum** – Diskrepanz zwischen gewünschtem Einzugsdatum und tatsächlicher Verfügbarkeit\n• **Parkplatzkontingent** – Wenige Objekte bieten die geforderte Anzahl Stellplätze\n\nHinweis: Hardkriterien sind binär – ein nicht erfülltes Kriterium schiesst ein Objekt vollständig aus dem Ranking aus, unabhängig von anderen Stärken.`, + confidence: 0.88, + sources: ['Matching-Engine', 'Kriterien-Gewichtung'], + }), + }, + ], + + 'compare': [ + { + keywords: ['strategisch', 'best', 'empfehlung', 'wählen'], + generate: (_ctx) => ({ + content: `Für eine strategische Empfehlung werden folgende Dimensionen gewichtet:\n\n• **Match-Score** – Wie gut erfüllt das Objekt das Suchprofil?\n• **Datenqualität** – Je vollständiger, desto verlässlicher die Einschätzung\n• **Zeitliche Verfügbarkeit** – Passt der Einzugstermin zur Planung?\n• **Preis-Leistung** – Mietpreis im Verhältnis zu Fläche und Ausstattung\n\n**Hinweis:** Die finale Entscheidung muss durch den Nutzer getroffen werden. Der Assistant kann Faktoren gewichten, aber keine verbindliche Empfehlung ohne vollständige Datenbasis abgeben.`, + confidence: 0.77, + sources: ['Vergleichsansicht', 'Match-Scores'], + }), + }, + { + keywords: ['risiko', 'höchste', 'gefährlich', 'risikoreiche'], + generate: (_ctx) => ({ + content: `Risikoindikatoren im Vergleich:\n\n• **Niedrige Datenqualität** (<65%) = höheres Informationsrisiko – Angaben nicht verlässlich verifiziert\n• **Niedrige Konfidenz** (<60%) = Scoring-Unsicherheit – Match könnte sich bei mehr Daten verschlechtern\n• **Fehlende Verfügbarkeitsangabe** = Planungsrisiko – keine verbindliche Zusage möglich\n\nDas Objekt mit dem niedrigsten Konfidenz-Score trägt das höchste strukturelle Risiko, weil die Basis für den Match-Score unvollständig ist.`, + confidence: 0.84, + sources: ['Konfidenz-Scores', 'Datenqualität'], + }), + }, + { + keywords: ['kosten', 'günstig', 'preis', 'effektiv', 'billiger'], + generate: (_ctx) => ({ + content: `Kostenbewertung im Vergleich:\n\nDie reine Mietkosten-Betrachtung reicht nicht aus. Relevant ist der **Preis pro m²** im Verhältnis zu:\n• Ausstattungsstandard und Renovierungszustand\n• Nebenkosten und Betriebskosten\n• Lagequalität (ÖPNV, Infrastruktur)\n\nEin günstigeres Objekt mit hohem Renovierungsbedarf kann mittelfristig teurer werden als ein teureres, bezugsbereites Objekt.\n\n**Tipp:** Mietpreis/m² in der Vergleichstabelle nebeneinander stellen und Gesamtkosten über Mietdauer schätzen.`, + confidence: 0.79, + sources: ['Preisangaben', 'Kostenvergleich'], + }), + }, + ], + + 'data-quality': [ + { + keywords: ['zuerst', 'priorität', 'erst', 'beheben', 'anfangen'], + generate: (_ctx) => ({ + content: `**Empfohlene Prioritäten für sofortigen Impact:**\n\n1. **${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** — Kritisch\nOhne Preisinformation werden Objekte aus preissensitiven Suchanfragen ausgeschlossen.\n\n2. **${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** — Hoch\nZeitbasierte Matching-Logik erfordert ein konkretes Datum.\n\n3. **${_ctx.visibleMissingData?.[2] ?? 'Adresse / Koordinaten'}** — Mittel\nSuchradius-Filter benötigen geografische Verortung.\n\nNach diesen drei Feldern ist ein Qualitätsscore von ≥75% erreichbar – der Schwellenwert für volle Matching-Sichtbarkeit.`, + confidence: 0.93, + sources: ['Feldgewichtung', 'Matching-Regeln'], + actions: [ + { id: 'a6', label: 'Objekt bearbeiten', description: 'Kritische Felder in der Objektansicht aktualisieren', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } }, + ], + }), + }, + { + keywords: ['fehlende', 'felder', 'impact', 'einfluss', 'auswirkung'], + generate: (_ctx) => ({ + content: `Einfluss fehlender Felder auf Match-Trefferquote:\n\n| Feld | Ausschlussquote |\n|------|----------------|\n| Mietpreis | ~65% aller Gesuche |\n| Fläche m² | ~80% aller Gesuche |\n| Verfügbarkeit | ~50% zeitkritischer Gesuche |\n| Zertifizierungen | ~20–30% spezifischer Gesuche |\n\nDie Fläche hat die grösste Ausschlussquote, da sie das primäre Hardkriterium für nahezu alle Suchprofile ist.`, + confidence: 0.90, + sources: ['Matching-Engine', 'Statistik-Analyse'], + }), + }, + { + keywords: ['score', 'verbessern', 'erhöhen', 'schnell', 'steigern'], + generate: (_ctx) => ({ + content: `Schnellste Wege zur Score-Verbesserung:\n\n• **Vollständigkeits-Boost** (+15–20 Punkte): Die 3 wichtigsten kritischen Felder befüllen\n• **Aktualitäts-Boost** (+5–10 Punkte): Letzte Aktualisierung auf heute setzen\n• **Verifikations-Boost** (+10 Punkte): Quellenangaben zu Preisen und Verfügbarkeit hinzufügen\n\nHinweis: Der Qualitätsscore wird bei jeder Änderung neu berechnet. Kein Warten nötig.`, + confidence: 0.87, + sources: ['Score-Berechnung', 'Feldgewichtung'], + }), + }, + ], + + 'future-availability': [ + { + keywords: ['probabilistisch', 'bestätigt', 'nicht bestätigt', 'warum', 'unbestätigt'], + generate: (_ctx) => ({ + content: `**Warum ist das Signal probabilistisch?**\n\nDieses Signal basiert auf indirekten Datenquellen (Baugesuche, Stellenausschreibungen, Pressemitteilungen) – nicht auf einer direkten Bestätigung durch den Vermieter oder Eigentümer.\n\nDie Verfügbarkeit ist eine **Wahrscheinlichkeitsaussage**, keine Tatsache. Das bedeutet:\n• Die Fläche ist möglicherweise noch nicht auf dem Markt\n• Die Zeitangabe kann sich verschieben\n• Eine alternative Nutzung ist nicht ausgeschlossen\n\n⚠️ Demand-Nutzern gegenüber darf dieses Signal nie als bestätigte Verfügbarkeit kommuniziert werden.`, + confidence: 0.95, + sources: ['Signal-Typ', 'Quellenklassifikation'], + }), + }, + { + keywords: ['belege', 'evidence', 'beweise', 'unterstützen', 'daten'], + generate: (_ctx) => ({ + content: `Belege für dieses Signal werden aus folgenden Quellen abgeleitet:\n\n• **Quellentyp** des Signals (z.B. Baugesuch, Jobausschreibung, Pressemitteilung)\n• **Erscheinungsdatum** der Quelle\n• **Konfidenzwert** basierend auf Quellenzuverlässigkeit und Korroborierung\n\nFür spezifische Belege: Signal-Detailansicht öffnen → Abschnitt "Evidenz".\n\nHinweis: Ein einzelner Beleg ohne Korroborierung senkt den Konfidenzwert. Mehrere unabhängige Quellen erhöhen ihn.`, + confidence: 0.88, + sources: ['Evidenz-Modul', 'Quellen-Klassifikation'], + actions: [ + { id: 'a7', label: 'Signal-Details öffnen', description: 'Evidenz-Abschnitt für dieses Signal anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/future-availability' } }, + ], + }), + }, + { + keywords: ['review', 'prüfen', 'freigabe', 'zeigen', 'demand'], + generate: (_ctx) => ({ + content: `**Vor der Freigabe an Demand-Nutzer empfehle ich:**\n\n1. **Konfidenz prüfen** – Signal sollte ≥60% haben, sonst nur intern sichtbar lassen\n2. **Sensitivitätsstufe prüfen** – CONFIDENTIAL-Signale nie extern zeigen\n3. **Review-Status** – Signal muss mindestens IN_REVIEW-Status haben\n4. **Haftungshinweis** – Disclaimer muss für Demand-Nutzer sichtbar sein\n\nFür die Freigabe: "Zur Prüfung senden" in der Signal-Detailansicht klicken.`, + confidence: 0.92, + sources: ['Review-Workflow', 'Disclosure-Regeln'], + actions: [ + { id: 'a8', label: 'Review Queue öffnen', description: 'Signal zur manuellen Prüfung übergeben', actionType: 'OPEN_REVIEW' }, + ], + }), + }, + { + keywords: ['konfidenz', 'wahrscheinlichkeit', 'probability', 'genau'], + generate: (_ctx) => ({ + content: `Der Konfidenzwert für dieses Signal setzt sich zusammen aus:\n\n• **Quellenqualität** (0–40%): Offizielle Quellen (Baugesuche, Amtsblatt) zählen höher als Pressemitteilungen\n• **Zeitnähe** (0–30%): Ältere Quellen werden abgewertet\n• **Korroborierung** (0–30%): Mehrere unabhängige Quellen erhöhen den Wert\n\nEin Wert unter 50% deutet auf unzuverlässige oder einzelne Quellen hin und sollte als "beobachtenswert, nicht aktionierbar" behandelt werden.`, + confidence: 0.85, + sources: ['Konfidenz-Berechnung', 'Quellengewichtung'], + }), + }, + ], + + 'review-queue': [ + { + keywords: ['zuerst', 'priorität', 'dringend', 'welche'], + generate: (_ctx) => ({ + content: `**Priorisierung der Review Queue:**\n\nEmpfohlene Reihenfolge nach Dringlichkeit:\n\n1. **CRITICAL + ESCALATED** – Sofortiger Handlungsbedarf, meist rechtliche oder Compliance-Relevanz\n2. **HIGH + PENDING** – Warten auf Entscheidung, können Prozesse blockieren\n3. **Fälligkeitsdatum überschritten** – Unabhängig von Priorität\n4. **MEDIUM + IN_REVIEW** – Bereits in Bearbeitung, weiterführen\n\nAufgaben ohne Fälligkeitsdatum und mit LOW-Priorität können gebündelt am Ende bearbeitet werden.`, + confidence: 0.90, + sources: ['Review-Queue-Regeln', 'Prioritäts-Framework'], + }), + }, + { + keywords: ['kriterien', 'genehmigen', 'ablehnen', 'genehmigung'], + generate: (_ctx) => ({ + content: `**Entscheidungskriterien:**\n\n✅ **Genehmigen**, wenn:\n• Alle Pflichtfelder vorhanden und plausibel\n• Konfidenz ≥65%\n• Kein offensichtlicher Datenfehler\n• Quellen verifizierbar\n\n❌ **Ablehnen**, wenn:\n• Schema-Validierungsfehler vorliegt\n• Inhalte nachweislich falsch oder irreführend\n• Datenschutz-Bedenken nicht ausgeräumt\n\n⚠️ **Mehr Daten anfordern**, wenn:\n• Wichtige Felder fehlen aber beschaffbar sind\n• Quelle unklar, aber plausibel`, + confidence: 0.93, + sources: ['Governance-Richtlinien', 'Review-Protokoll'], + }), + }, + { + keywords: ['eskalier', 'eskalation', 'wann', 'hochstufen'], + generate: (_ctx) => ({ + content: `**Eskalation ist angemessen wenn:**\n\n• Die Entscheidung Rechtsfolgen hat (Datenschutz, GDPR, Mietrecht)\n• Konflikte zwischen Stakeholdern nicht auf Reviewer-Ebene lösbar sind\n• Der Review-Task eine Geschäftsentscheidung mit hohem Risiko erfordert\n• Zwei Reviewer zu unterschiedlichen Ergebnissen kommen\n\nEskalierte Tasks landen bei der Organisationsleitung. Nutzung sparsam empfohlen – zu viele Eskalationen entwerten das Signal.`, + confidence: 0.88, + sources: ['Eskalations-Framework', 'Governance'], + }), + }, + ], + + 'ai-monitoring': [ + { + keywords: ['fehler', 'fehlgeschlagen', 'priorität', 'wichtig'], + generate: (_ctx) => ({ + content: `**Fehler-Triage in der Reihenfolge:**\n\n1. **SCHEMA_VALIDATION** – Höchste Priorität. Output wurde nicht an die UI geliefert. Nutzer hat möglicherweise unvollständige Informationen erhalten.\n2. **EMPTY_RESPONSE** – Hoch. Funktion hat komplett versagt. Retry empfehlenswert.\n3. **INVALID_JSON** – Mittel. Output war vorhanden, aber nicht verarbeitbar. Recovery oft möglich.\n4. **PROVIDER_TIMEOUT** – Niedrig bis Mittel. Meist temporäres Problem. Retry oder Fallback prüfen.\n\nFür alle Fehler mit FLAGGED-Status: Review-Aufgabe erstellen, um manuellen Check zu dokumentieren.`, + confidence: 0.91, + sources: ['Fehler-Klassifikation', 'AI-Monitoring'], + actions: [ + { id: 'a9', label: 'Fehler filtern', description: 'AI-Monitoring-Tabelle auf Fehler filtern', actionType: 'NAVIGATE', payload: { path: '/ops/ai-monitoring' } }, + ], + }), + }, + { + keywords: ['schema', 'validierung', 'schema-fehler', 'bedeutet'], + generate: (_ctx) => ({ + content: `**Schema-Validierungsfehler erklärt:**\n\nEin Schema-Validierungsfehler bedeutet, dass der AI-Output zwar generiert wurde, aber nicht der erwarteten Datenstruktur entspricht.\n\n**Mögliche Ursachen:**\n• Pflichtfeld fehlt im Output (z.B. 'hardCriteria')\n• Falscher Datentyp (z.B. String statt Number)\n• Prompt-/Schema-Versions-Mismatch\n\n**Konsequenz:** Der Output wurde **nicht** an die UI ausgeliefert – der Nutzer hat kein fehlerhaftes Resultat gesehen.\n\n**Massnahme:** Prompt-Version und Schema-Version prüfen, ggf. Prompt aktualisieren.`, + confidence: 0.94, + sources: ['Schema-Validierung', 'AI-Pipeline'], + }), + }, + { + keywords: ['review', 'manuell', 'prüfung', 'brauchen'], + generate: (_ctx) => ({ + content: `**AI-Outputs, die manuelle Review brauchen:**\n\n• Status **FLAGGED** – wurde automatisch als problematisch markiert\n• Status **UNREVIEWED** + Fehler vorhanden – hohe Priorität\n• Outputs mit **DECISION_BRIEF** oder **MATCH_EXPLANATION** Typ – direkte Auswirkung auf Nutzerentscheidungen\n• Latenz >5s – deutet auf Qualitätsprobleme hin\n\nOutput direkt in der Review Queue anlegen: "Zur Prüfung" Button in der Detail-Ansicht.`, + confidence: 0.89, + sources: ['Review-Regeln', 'AI-Monitoring'], + actions: [ + { id: 'a10', label: 'Review Queue öffnen', description: 'Zur Review Queue navigieren', actionType: 'NAVIGATE', payload: { path: '/ops/review-queue' } }, + ], + }), + }, + ], + + 'general': [ + { + keywords: ['kpi', 'kennzahlen', 'überblick', 'metriken'], + generate: () => ({ + content: `Die wichtigsten KPIs je Workspace:\n\n**Verwaltung (Supply):**\n• Datenqualitäts-Score (Ziel: ≥70%)\n• Match-Rate (Anteil Objekte mit ≥1 aktivem Match)\n\n**Suche (Demand):**\n• Trefferquote (Ergebnisse mit Score ≥70%)\n• Hardkriterien-Erfüllungsrate\n\n**Administration (Ops):**\n• Offene Review-Tasks\n• AI-Fehlerrate\n• Genehmigungsrate`, + confidence: 0.82, + sources: ['Dashboard', 'Monitoring'], + }), + }, + { + keywords: ['nächste', 'aktion', 'empfehlung', 'was tun', 'handlung'], + generate: (_ctx) => ({ + content: `Empfohlene nächste Aktionen basierend auf dem aktuellen Workspace:\n\n• **Datenpflege-Backlog abarbeiten** – Objekte unter 65% Qualitätsscore priorisieren\n• **Review Queue prüfen** – Offene CRITICAL-Tasks zuerst\n• **AI-Fehler quittieren** – FLAGGED-Outputs in AI-Monitoring markieren\n\nDer Assistant kann konkretere Empfehlungen geben, wenn eine spezifische Seite (Objekt, Match, Signal) geöffnet ist.`, + confidence: 0.75, + sources: ['Kontextanalyse'], + }), + }, + { + keywords: ['daten', 'vorbereiten', 'matches', 'bessere'], + generate: () => ({ + content: `**Daten für bessere Matches vorbereiten:**\n\n1. **Vollständigkeit** – Alle Pflichtfelder (Fläche, Preis, Verfügbarkeit, Adresse) befüllen\n2. **Aktualität** – Veraltete Angaben (>6 Monate) aktualisieren\n3. **Präzision** – Exakte m²-Angaben statt Schätzwerte\n4. **Kontext** – Beschreibung von Ausstattung und Besonderheiten hilft der semantischen Suche\n\nJedes komplett befüllte und aktuelle Objekt erhöht die Match-Sichtbarkeit signifikant.`, + confidence: 0.88, + sources: ['Matching-Regeln', 'Best-Practices'], + }), + }, + ], +} + +// ── Template matching ───────────────────────────────────────────────────────── + +function findTemplate(question: string, pageCtx: string): Template | null { + const q = question.toLowerCase() + const bucket = TEMPLATES[pageCtx] ?? TEMPLATES['general'] ?? [] + for (const t of bucket) { + if (t.keywords.some(kw => q.includes(kw))) return t + } + return bucket[0] ?? TEMPLATES['general']?.[0] ?? null +} + +// ── Public service API ──────────────────────────────────────────────────────── + +export const aiAssistantService = { + async getSuggestions(context: AssistantContext): Promise { + await delay(200) + const page = pageType(context.currentRoute) + return (SUGGESTIONS[page] ?? SUGGESTIONS['general']).slice(0, 4) + }, + + async answerQuestion(context: AssistantContext, question: string): Promise> { + await delay(700 + Math.random() * 700) + const page = pageType(context.currentRoute) + const template = findTemplate(question, page) ?? findTemplate(question, 'general') + + if (!template) { + return { + content: 'Zu dieser Frage liegen derzeit keine ausreichenden Kontextdaten vor. Bitte öffnen Sie eine spezifische Objekt- oder Match-Ansicht und stellen Sie die Frage erneut.', + confidence: 0.5, + sources: [], + } + } + + return template.generate(context) + }, + + async createActionFromAnswer(_action: import('../domain/assistant').AssistantAction): Promise<{ success: boolean }> { + await delay(100) + return { success: true } + }, +} diff --git a/src/stores/assistantStore.ts b/src/stores/assistantStore.ts new file mode 100644 index 0000000..d33aa34 --- /dev/null +++ b/src/stores/assistantStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' +import type { AssistantContext, AssistantMessage } from '../domain/assistant' + +interface AssistantState { + isOpen: boolean + context: AssistantContext | null + messages: AssistantMessage[] + isLoading: boolean + error: string | null + + open: () => void + close: () => void + setContext: (ctx: AssistantContext) => void + updateContext: (partial: Partial) => void + addMessage: (msg: AssistantMessage) => void + setLoading: (v: boolean) => void + setError: (e: string | null) => void + clearConversation: () => void +} + +export const useAssistantStore = create((set) => ({ + isOpen: false, + context: null, + messages: [], + isLoading: false, + error: null, + + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + setContext: (ctx) => set({ context: ctx }), + updateContext: (partial) => set((s) => ({ context: s.context ? { ...s.context, ...partial } : null })), + addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), + setLoading: (v) => set({ isLoading: v }), + setError: (e) => set({ error: e }), + clearConversation: () => set({ messages: [], error: null }), +}))