diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx index 1a795e8..95c0d33 100644 --- a/src/components/market-leads/LeadDetail.tsx +++ b/src/components/market-leads/LeadDetail.tsx @@ -5,9 +5,10 @@ import type { MarketLead } from '../../hooks/useMarketLeads' import { AgentAvatar, ObjectDeepLink } from '../team' import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { SOURCE_TYPE_LABELS } from '../../lib/constants' -import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal' +import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal' import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' +import { AiDisclaimer } from '../ui' import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' @@ -50,7 +51,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching const signalType = leadSignalTypeOf(signal) const risiko = isRisiko(signal) const rationale = signalRationaleOf(signal) - const action = recommendedActionOf(signal) // Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt. const relevant = useMemo( @@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching )} - {/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */} - - - Empfohlene nächste Handlung - - - {action} - - - {/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */} {showMatching && ( <> @@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching )} - {signal.disclaimer && ( - - {signal.disclaimer} - - )} + {/* + An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals + («Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen + KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle, + und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche + Fehler soll aber überall gleich lauten (§4, §5). + */} + ) diff --git a/src/components/research/ResearchLeadCard.tsx b/src/components/research/ResearchLeadCard.tsx index 78fab97..61c57a6 100644 --- a/src/components/research/ResearchLeadCard.tsx +++ b/src/components/research/ResearchLeadCard.tsx @@ -3,6 +3,7 @@ import { Box, Chip, Typography } from '@mui/material' import { ExternalLink, MapPin } from 'lucide-react' import type { ResearchLead } from '../../domain/researchLead' import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants' +import { AiDisclaimer } from '../ui' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' /** @@ -74,23 +75,16 @@ export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: > Warum relevant - + {lead.reason} - {lead.recommendedAction && ( - <> - - Empfohlene nächste Handlung - - - {lead.recommendedAction} - - - )} + {/* + Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen. + Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt — + entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete + beschränkt bleibt und nicht als Anweisung gelesen wird. + */} + + ) }) diff --git a/src/components/staff/MeetingRoom3D.tsx b/src/components/staff/MeetingRoom3D.tsx index be01f90..ba8f791 100644 --- a/src/components/staff/MeetingRoom3D.tsx +++ b/src/components/staff/MeetingRoom3D.tsx @@ -159,7 +159,19 @@ export function MeetingRoom3D() { } }, [positionLabels]) - // ── Maus: Parallaxe und Trefferprüfung ── + /** + * Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1). + * + * Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den + * Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig + * ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein + * Bild, das unter dem Zeiger wegkippt. + * + * Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene + * selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben + * unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf + * seinen Platz sind keine Bewegung und bleiben deshalb. + */ const handlePointerMove = useCallback((e: React.PointerEvent) => { const s = sceneRef.current const wrap = wrapRef.current @@ -169,8 +181,6 @@ export function MeetingRoom3D() { const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1 const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1) - s.applyParallax(nx, ny) - const ray = new THREE.Raycaster() ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera) const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0] @@ -187,7 +197,6 @@ export function MeetingRoom3D() { const handlePointerLeave = useCallback(() => { const s = sceneRef.current if (!s) return - s.applyParallax(0, 0) hoveredRef.current = null s.setHovered(null) setHovered(null) diff --git a/src/components/staff/meetingRoomPalette.ts b/src/components/staff/meetingRoomPalette.ts index cb70195..7f631dd 100644 --- a/src/components/staff/meetingRoomPalette.ts +++ b/src/components/staff/meetingRoomPalette.ts @@ -65,11 +65,12 @@ export const CAMERA = { maxHFov: 84, /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ lookAt: [0, 1.24, -5.2] as const, - /** - * Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die - * Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). + /* + * Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger + * um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als + * Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt + * dieselben — nur bewegt sich nichts mehr. */ - parallax: 0.075, } as const /** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */ diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts index d5d119c..d23b851 100644 --- a/src/components/staff/meetingRoomScene.ts +++ b/src/components/staff/meetingRoomScene.ts @@ -41,7 +41,6 @@ export interface MeetingRoomScene { renderer: THREE.WebGLRenderer seats: SeatHandle[] /** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */ - applyParallax: (dx: number, dy: number) => void setSize: (width: number, height: number) => void render: () => void setHovered: (id: string | null) => void @@ -549,9 +548,6 @@ export function createMeetingRoomScene( addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe), ) - const baseCam = camera.position.clone() - const lookTarget = new THREE.Vector3(...CAMERA.lookAt) - function render() { renderer.render(scene, camera) } @@ -562,15 +558,6 @@ export function createMeetingRoomScene( renderer, seats: handles, - applyParallax(dx, dy) { - camera.position.set( - baseCam.x + dx * CAMERA.parallax, - baseCam.y + dy * CAMERA.parallax * 0.6, - baseCam.z, - ) - camera.lookAt(lookTarget) - }, - setSize(width, height) { renderer.setSize(width, height, false) const aspect = width / Math.max(height, 1) diff --git a/src/components/supply/EditableObjectOverview.tsx b/src/components/supply/EditableObjectOverview.tsx index 49a7352..4883f34 100644 --- a/src/components/supply/EditableObjectOverview.tsx +++ b/src/components/supply/EditableObjectOverview.tsx @@ -4,6 +4,7 @@ import { } from '@mui/material' import { X } from 'lucide-react' import type { Property, UpdatePropertyInput } from '../../domain/property' +import { AiDisclaimer } from '../ui' import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' import { useToastStore } from '../../stores/toastStore' @@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) { ) })} + + {/* + Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb + läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre + nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige + Teil, der fix am unteren Rand klebt. + */} + {/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */} diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index a96ffae..92b60c2 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -115,7 +115,43 @@ export function PropertyTable({ return ( - +
+ + {/* Objekt */} + {/* Typ */} + {/* Standort */} + {/* Fläche */} + {/* Miete */} + {/* Aktueller Mieter */} + {/* Mietlaufzeit */} + {/* Breakoutoption */} + {/* Breakoutoption Zeitpunkt */} + {/* Datenqualität */} + {/* Aktionen */} + Objekt @@ -205,7 +241,7 @@ export function PropertyTable({ {/* Aktueller Mieter */} - + {p.currentTenant ?? –} @@ -249,7 +285,7 @@ export function PropertyTable({ title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'} placement="top" > - + )} + + )} diff --git a/src/components/team/AgentChatTranscript.tsx b/src/components/team/AgentChatTranscript.tsx new file mode 100644 index 0000000..e31baf3 --- /dev/null +++ b/src/components/team/AgentChatTranscript.tsx @@ -0,0 +1,64 @@ +import { Box, Typography } from '@mui/material' +import { useAgentChatStore } from '../../stores/agentChatStore' +import { AgentAvatar } from './AgentAvatar' +import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** + * Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3). + * + * Bewusst schlicht: Frage rechts, Antwort links mit dem Porträt des Agenten, + * keine Zeitstempel, keine Sprechblasen mit Spitzen, kein Tippindikator. Der + * Chat beantwortet drei Fragetypen aus Mockdaten — eine Oberfläche, die nach + * Messenger aussieht, verspräche mehr, als dahinter steckt. + * + * Vor der ersten Frage erscheint gar nichts. Der grosszügige Weissraum unter + * dem Eingabefeld ist Teil des Einstiegs und soll nicht durch einen leeren + * Verlaufsrahmen ersetzt werden. + */ +export function AgentChatTranscript() { + const messages = useAgentChatStore(s => s.messages) + const agent = useAgentChatStore(s => s.agent) + + if (messages.length === 0 || !agent) return null + + return ( + + {messages.map((m, i) => ( + m.from === 'user' ? ( + + + + {m.text} + + + + ) : ( + + + + + + + {m.text} + + + + ) + ))} + + ) +} diff --git a/src/components/team/AgentWorkspaceHero.tsx b/src/components/team/AgentWorkspaceHero.tsx index d27ccb4..57f698e 100644 --- a/src/components/team/AgentWorkspaceHero.tsx +++ b/src/components/team/AgentWorkspaceHero.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material' import { SendHorizonal } from 'lucide-react' import { AgentAvatar } from './AgentAvatar' +import { AgentChatTranscript } from './AgentChatTranscript' import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat' import { AGENT_CHAT_PROMPT } from '../../lib/constants' import { DS_BORDER, DS_TEXT } from '../../lib/ds' @@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent )} + + ) } diff --git a/src/components/team/WorkItemDetailDrawer.tsx b/src/components/team/WorkItemDetailDrawer.tsx index 5a32be9..f9ed601 100644 --- a/src/components/team/WorkItemDetailDrawer.tsx +++ b/src/components/team/WorkItemDetailDrawer.tsx @@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material' import { HelpCircle, X } from 'lucide-react' import { useNavigate } from 'react-router' import { useTeamStore } from '../../stores/teamStore' +import { AiDisclaimer } from '../ui' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useTeamAgents } from '../../hooks/useTeamAgents' import { AgentAvatar } from './AgentAvatar' @@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() { {item.rejectionReason} )} + + )} diff --git a/src/components/team/index.ts b/src/components/team/index.ts index 2f84bf9..e1dec67 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -87,3 +87,6 @@ export { PROTOCOL_STATUS_SURFACE, PROTOCOL_STATUS_TEXT, } from './teamTokens' + +// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg. +export { AgentChatTranscript } from './AgentChatTranscript' diff --git a/src/components/team/useAgentChat.ts b/src/components/team/useAgentChat.ts index d712b54..44e5329 100644 --- a/src/components/team/useAgentChat.ts +++ b/src/components/team/useAgentChat.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { FocusEvent, KeyboardEvent } from 'react' import { useAgentChatStore } from '../../stores/agentChatStore' -import { useToastStore } from '../../stores/toastStore' +import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer' /** * Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen @@ -29,7 +29,10 @@ interface Options { * vergessen. * * Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und - * eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung. + * Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der + * jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne + * Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl + * wäre schlimmer als ein «das habe ich nicht verstanden». */ export function useAgentChat({ active = true }: Options = {}) { const agent = useAgentChatStore(s => s.agent) @@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) { const focused = useAgentChatStore(s => s.focused) const setDraft = useAgentChatStore(s => s.setDraft) const setFocused = useAgentChatStore(s => s.setFocused) - const showToast = useToastStore(s => s.showToast) + const append = useAgentChatStore(s => s.append) + const answerFor = useAgentChatAnswer() const inputRef = useRef(null) @@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) { el.setSelectionRange(end, end) }, [active, focused]) + /** + * Frage absenden und beantworten (Runde 9, §3). + * + * Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt + * antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend + * und ohne Sprachmodell, wie es der Auftrag verlangt. + * + * Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine + * Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe + * Ereignis noch einmal meldet. + */ const send = useCallback(() => { const text = draft.trim() if (!text || !agent) return setDraft('') - showToast( - `Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, - 'info', - ) - }, [draft, agent, setDraft, showToast]) + append({ from: 'user', text }) + append({ from: 'agent', text: answerFor(agent, text) }) + }, [draft, agent, setDraft, append, answerFor]) const handleKeyDown = useCallback( (e: KeyboardEvent) => { diff --git a/src/components/ui/AiDisclaimer.tsx b/src/components/ui/AiDisclaimer.tsx new file mode 100644 index 0000000..2be0265 --- /dev/null +++ b/src/components/ui/AiDisclaimer.tsx @@ -0,0 +1,38 @@ +import { memo } from 'react' +import { Box, Typography } from '@mui/material' +import { Info } from 'lucide-react' +import { AI_DISCLAIMER } from '../../lib/constants' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** + * Der einheitliche KI-Hinweis am Fuss jeder Agenten-Detailansicht (Runde 9, §5). + * + * Eine einzige Komponente für alle sechs Detailansichten. Der Wortlaut ist + * vorgegeben und steht deshalb als Konstante in `constants.ts` — stünde er + * sechsmal im Code, hiesse er nach der ersten Korrektur an fünf Stellen anders, + * und ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner. + * + * Bewusst dezent: grau, klein, mit dünner Trennlinie darüber. Er soll gelesen + * werden können, ohne die Aussage darüber zu übertönen — ein roter Warnkasten + * an jeder Detailansicht stumpft nach dem dritten Mal ab und wird dann gar + * nicht mehr gelesen. + */ +export const AiDisclaimer = memo(function AiDisclaimer() { + return ( + + + + {AI_DISCLAIMER} + + + ) +}) diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 9173e00..f33c7b7 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider' export { ConfirmDialog } from './ConfirmDialog' export { DecisionContextPanel } from './DecisionContextPanel' export type { DecisionMetric, DecisionAction } from './DecisionContextPanel' + +// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten. +export { AiDisclaimer } from './AiDisclaimer' diff --git a/src/components/visits/VisitPreparationDrawer.tsx b/src/components/visits/VisitPreparationDrawer.tsx index 6fa9362..cee5b87 100644 --- a/src/components/visits/VisitPreparationDrawer.tsx +++ b/src/components/visits/VisitPreparationDrawer.tsx @@ -1,6 +1,7 @@ import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material' import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react' import type { VisitAssignment } from '../../domain/visitAssignment' +import { AiDisclaimer } from '../ui' import { VisitRequestType } from '../../domain/visitAssignment' import { needsReportWarning } from '../../services/visitAssignmentService' import { useRequestVisitReport } from '../../hooks/useVisitAssignments' @@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) { )} + + )} diff --git a/src/hooks/useAgentChatAnswer.ts b/src/hooks/useAgentChatAnswer.ts new file mode 100644 index 0000000..6b5a2b9 --- /dev/null +++ b/src/hooks/useAgentChatAnswer.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import { useAgentWorkItems } from './useAgentWorkItems' +import { useTeamAgents } from './useTeamAgents' +import { answerAgentQuestion } from '../services/agentChatService' +import type { AgentChatSubject } from '../stores/agentChatStore' + +/** + * Verbindet den Chat mit den Beständen, die auf der Agentenseite ohnehin + * angezeigt werden (Runde 9, §3). + * + * Der Hook holt nur die Daten; was daraus wird, entscheidet + * `agentChatService` — Zählweise, Zeitraum und Formulierungen sind fachliche + * Festlegungen und gehören nicht in die Oberflächenschicht (CLAUDE.md §4.2). + * + * Bewusst dieselbe Quelle wie der Bearbeitungsverlauf: Was der Agent im Chat + * sagt, muss sich im Verlauf nachzählen lassen. Zwei getrennte Zählungen wären + * zwei Wahrheiten. + */ +export function useAgentChatAnswer() { + const { data: workItems = [], isLoading: itemsLoading } = useAgentWorkItems() + const { data: agents = [], isLoading: agentsLoading } = useTeamAgents() + + return useCallback( + (agent: AgentChatSubject, frage: string): string => { + // Thomas ist Vorgesetzter und hat kein eigenes Personalblatt; für ihn + // zählt der gesamte Bestand, für alle anderen nur der eigene. + const eigene = agent.id === 'thomas' + ? workItems + : workItems.filter(w => w.agentId === agent.id) + + return answerAgentQuestion( + { + agentId: agent.id, + agentName: agent.name, + agentRole: agent.role, + dossier: agents.find(a => a.id === agent.id), + workItems: eigene, + loading: itemsLoading || agentsLoading, + }, + frage, + ) + }, + [workItems, agents, itemsLoading, agentsLoading], + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8ebe6d6..2324e49 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -349,6 +349,16 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?' */ export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?' +/** + * Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5). + * + * Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal + * abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und + * ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner. + */ +export const AI_DISCLAIMER = + 'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben' + // ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ────────────────── export const LEAD_SIGNAL_TYPE_LABELS: Record = { diff --git a/src/services/agentChatService.ts b/src/services/agentChatService.ts new file mode 100644 index 0000000..8599764 --- /dev/null +++ b/src/services/agentChatService.ts @@ -0,0 +1,178 @@ +import type { AgentWorkItem } from '../domain/agentWorkItem' +import { AgentWorkItemStatus } from '../domain/agentWorkItem' +import type { TeamAgent } from '../domain/teamAgent' +import { mockToday } from '../lib/constants' + +/** + * Property On — die Antworten der Agentenchats (Runde 9, §3). + * + * Bewusst eine Schlüsselwortlogik und kein Sprachmodell: Der Auftrag verlangt + * ausdrücklich keine LLM- oder RAG-Infrastruktur, und für die drei Fragetypen + * wäre sie auch nicht der Punkt. Der Punkt ist, dass die Zahlen stimmen. + * + * Jede Antwort stammt aus Beständen, die auf der Seite des jeweiligen Agenten + * ohnehin sichtbar sind. Nichts wird geschätzt, nichts hochgerechnet: Findet + * sich keine Grundlage, sagt der Agent das, statt eine plausible Zahl zu + * nennen. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort — sie + * sieht aus wie eine Auskunft. + */ + +/** Was gefragt wurde. */ +export const ChatIntent = { + /** «Womit kannst du mir helfen?» */ + CAPABILITIES: 'CAPABILITIES', + /** «Wie viele Aufgaben sind in deinem Log?» */ + LOG_COUNT: 'LOG_COUNT', + /** «Wie viele Aufträge wurden letzten Monat bearbeitet?» */ + LAST_MONTH: 'LAST_MONTH', + /** Nichts davon erkannt. */ + UNKNOWN: 'UNKNOWN', +} as const +export type ChatIntent = typeof ChatIntent[keyof typeof ChatIntent] + +/** + * Erkennung per Schlüsselwörtern. + * + * Die Reihenfolge entscheidet: «Wie viele Aufträge wurden letzten Monat + * bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach + * dem Log gelesen. Der Zeitbezug ist die speziellere Aussage und wird deshalb + * zuerst geprüft. + */ +export function detectIntent(text: string): ChatIntent { + const t = text.toLowerCase() + + const zeitbezug = /letzte[nrs]?\s+(monat|30\s*tage)|vormonat|vergangenen\s+monat/.test(t) + const menge = /wie\s*viel|anzahl|wie\s*oft/.test(t) + + if (zeitbezug && (menge || /auftr|fäll|falle|vorgäng|bearbeit|erledigt|task|aufgab/.test(t))) { + return ChatIntent.LAST_MONTH + } + if (menge && /log|aufgab|task|auftr|vorgäng|eintr|fäll|falle|pendent|offen/.test(t)) { + return ChatIntent.LOG_COUNT + } + if (/womit|was\s+kannst|wobei|wofür|hilf|helfen|kannst du|zuständig|aufgabe(n)?\s*bereich|rolle/.test(t)) { + return ChatIntent.CAPABILITIES + } + return ChatIntent.UNKNOWN +} + +export interface AgentChatInput { + agentId: string + agentName: string + agentRole: string + /** Personaldossier des Agenten, sofern vorhanden — Thomas hat keines. */ + dossier?: TeamAgent + /** + * Vorgänge aus dem Bearbeitungsverlauf. Für einen einzelnen Agenten seine + * eigenen; für Thomas als Vorgesetzten die aller Agenten. + */ + workItems: AgentWorkItem[] + /** Sind die Bestände schon geladen? Sonst wird nicht gezählt, sondern gewartet. */ + loading: boolean +} + +/** Kalendermonat vor dem Stichtag der Mockdaten. */ +function letzterMonat(): { von: Date; bis: Date; name: string } { + const heute = mockToday() + const von = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth() - 1, 1)) + const bis = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), 1)) + return { + von, + bis, + name: von.toLocaleDateString('de-CH', { month: 'long', year: 'numeric', timeZone: 'UTC' }), + } +} + +function plural(n: number, ein: string, viele: string): string { + return `${n} ${n === 1 ? ein : viele}` +} + +function antwortFaehigkeiten(input: AgentChatInput): string { + const { agentName, agentRole, dossier } = input + + // Der Kurzbeschrieb aus dem Personalblatt ist genau der Satz, der auch in der + // Personalverwaltung steht — der Chat soll nichts anderes behaupten als das + // Dossier. + if (dossier) { + const aufgaben = dossier.responsibilities.slice(0, 3) + return [ + `Ich bin ${agentName}, ${agentRole}. ${dossier.shortDescription}`, + aufgaben.length > 0 + ? `Konkret kümmere ich mich unter anderem um: ${aufgaben.join('; ')}.` + : '', + 'Frag mich zum Beispiel, wie viele Aufgaben in meinem Log stehen oder wie viele Aufträge ich letzten Monat bearbeitet habe.', + ].filter(Boolean).join('\n\n') + } + + return [ + `Ich bin ${agentName}, ${agentRole}. Ich führe die fünf digitalen Mitarbeitenden und habe ihren gesamten Bearbeitungsverlauf im Blick.`, + 'Frag mich zum Beispiel, wie viele Vorgänge im Log stehen oder wie viele letzten Monat bearbeitet wurden.', + ].join('\n\n') +} + +function antwortLog(input: AgentChatInput): string { + const { workItems, agentId } = input + const gesamt = workItems.length + + if (gesamt === 0) { + return 'In meinem Log steht derzeit kein Vorgang. Sobald einer eingeht, siehst du ihn im Bearbeitungsverlauf.' + } + + const erledigt = workItems.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length + const pendent = workItems.filter(w => w.requiresDecision).length + const wessen = agentId === 'thomas' ? 'Im Log aller Agenten stehen' : 'In meinem Log stehen' + + return [ + `${wessen} ${plural(gesamt, 'Vorgang', 'Vorgänge')}.`, + `Davon ${plural(erledigt, 'erledigt', 'erledigt')}${pendent > 0 ? ` und ${plural(pendent, 'wartet auf deine Entscheidung', 'warten auf deine Entscheidung')}` : ''}.`, + ].join(' ') +} + +function antwortLetzterMonat(input: AgentChatInput): string { + const { workItems, agentId } = input + const { von, bis, name } = letzterMonat() + + const imZeitraum = workItems.filter(w => { + const stempel = w.completedAt ?? w.createdAt + const t = new Date(stempel).getTime() + return Number.isFinite(t) && t >= von.getTime() && t < bis.getTime() + }) + + const wer = agentId === 'thomas' ? 'Die Agenten haben' : 'Ich habe' + + if (imZeitraum.length === 0) { + return [ + `Im ${name} ist in meinem Bestand kein Vorgang verzeichnet.`, + `Insgesamt liegen ${plural(workItems.length, 'Vorgang', 'Vorgänge')} im Log — die verteilen sich auf andere Monate.`, + ].join(' ') + } + + const erledigt = imZeitraum.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length + return `${wer} im ${name} ${plural(imZeitraum.length, 'Vorgang', 'Vorgänge')} bearbeitet, davon ${erledigt} abgeschlossen.` +} + +/** + * Die Antwort auf eine Chatfrage — oder eine ehrliche Auskunft darüber, dass + * die Frage nicht verstanden wurde. + */ +export function answerAgentQuestion(input: AgentChatInput, frage: string): string { + if (input.loading) { + return 'Einen Moment — ich hole gerade meine Daten.' + } + + switch (detectIntent(frage)) { + case ChatIntent.CAPABILITIES: + return antwortFaehigkeiten(input) + case ChatIntent.LOG_COUNT: + return antwortLog(input) + case ChatIntent.LAST_MONTH: + return antwortLetzterMonat(input) + default: + return [ + 'Das habe ich nicht verstanden. In dieser Demo beantworte ich drei Arten von Fragen:', + '· Womit kannst du mir helfen?', + '· Wie viele Aufgaben sind in deinem Log?', + '· Wie viele Aufträge wurden letzten Monat bearbeitet?', + ].join('\n') + } +} diff --git a/src/stores/agentChatStore.ts b/src/stores/agentChatStore.ts index eaa503f..05f1216 100644 --- a/src/stores/agentChatStore.ts +++ b/src/stores/agentChatStore.ts @@ -1,5 +1,11 @@ import { create } from 'zustand' +/** Eine Zeile im Chatverlauf. */ +export interface AgentChatMessage { + from: 'user' | 'agent' + text: string +} + export interface AgentChatSubject { id: string name: string @@ -19,12 +25,21 @@ interface AgentChatState { * Scrollen mitten im Tippen den Fokus (§9). */ focused: boolean + /** + * Der Verlauf des laufenden Gesprächs (Runde 9, §3). + * + * Bewusst nicht gespeichert: Er lebt, solange die Seite offen ist, und ist + * beim nächsten Besuch leer. Ein Verlauf, der Tage überdauert, weckte die + * Erwartung, der Agent erinnere sich — und das täte er nicht. + */ + messages: AgentChatMessage[] openFor: (agent: AgentChatSubject) => void close: () => void setDraft: (draft: string) => void setCompact: (compact: boolean) => void setFocused: (focused: boolean) => void + append: (message: AgentChatMessage) => void } /** @@ -44,16 +59,19 @@ export const useAgentChatStore = create((set) => ({ draft: '', compact: false, focused: false, + messages: [], - // Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in - // Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen. + // Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört + // nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen + // Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen. openFor: (agent) => set(s => (s.agent?.id === agent.id ? { agent } - : { agent, draft: '', compact: false, focused: false })), + : { agent, draft: '', compact: false, focused: false, messages: [] })), close: () => set({ agent: null, compact: false, focused: false }), setDraft: (draft) => set({ draft }), setCompact: (compact) => set({ compact }), setFocused: (focused) => set({ focused }), + append: (message) => set(s => ({ messages: [...s.messages, message] })), }))