591cae5ebc
Reworks the advisor toward a Compliance Case Workspace (review feedback): - Rename user-facing "Quellen" -> "Evidence". - Evidence grouped by document/regulation family (count + expandable) — no more unsorted DSK/DSK/DPF/... jumble. - Human-readable regulation names via a display registry (DSK Sdm B51 -> "DSK Standard-Datenschutzmodell (SDM)" / Kapitel B51); generic, bridges G2. - Evidence summary "Antwort basiert auf" with meaningful counts; Regelwerke = distinct FAMILIES (fixes the inflated count). NO fabricated trust score (needs a defined basis). - Expanded mode = 3-column workspace (question+summary | answer | evidence, independent scroll) + history switcher; narrow mode stays stacked. - Prompt: push aggressive markdown structure (## per aspect, numbered phases). Deferred/coordinated on board: C8 diagrams (RAG contract), answer<->evidence coupling [1] (needs LLM citation anchors — phase 2), G1 retrieval relevance + G2 metadata (RAG). tsc clean, 17 vitest, check-loc 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import type { AdvisorTurn } from './useAdvisorStream'
|
|
import { StickyQuestion } from './StickyQuestion'
|
|
import { TurnView } from './TurnView'
|
|
import { EvidenceSummary } from './EvidenceSummary'
|
|
import { AnswerPane } from './AnswerPane'
|
|
import { EvidencePane } from './EvidencePane'
|
|
import { FiguresPane } from './FiguresPane'
|
|
import { FootnotesPane } from './FootnotesPane'
|
|
import { AdvisorEmptyState } from './EmptyState'
|
|
|
|
/**
|
|
* The Evidence Workspace body.
|
|
* - Narrow (collapsed): stacked panels with a pinned last question + scrollable turn history.
|
|
* - Wide (expanded): a 3-column Compliance Case Workspace — question + summary (left, with a
|
|
* history switcher), answer (center scroll), evidence (right scroll) — each column scrolls
|
|
* independently so the user never loses the question or the evidence.
|
|
*/
|
|
export function EvidenceWorkspace({
|
|
turns,
|
|
expanded,
|
|
exampleQuestions,
|
|
onExample,
|
|
}: {
|
|
turns: AdvisorTurn[]
|
|
expanded: boolean
|
|
exampleQuestions: string[]
|
|
onExample: (q: string) => void
|
|
}) {
|
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
const endRef = useRef<HTMLDivElement>(null)
|
|
const latest = turns[turns.length - 1]
|
|
const active = turns.find((t) => t.id === activeId) ?? latest
|
|
|
|
// A new turn refocuses the latest (null = follow latest).
|
|
useEffect(() => {
|
|
setActiveId(null)
|
|
}, [turns.length])
|
|
|
|
// Autoscroll the stacked view to the newest turn (narrow mode only).
|
|
useEffect(() => {
|
|
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [turns.length, expanded])
|
|
|
|
if (turns.length === 0) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto bg-gray-50">
|
|
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!expanded) {
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
|
{latest && <StickyQuestion question={latest.question} />}
|
|
<div className="space-y-4 p-4">
|
|
{turns.map((t, i) => (
|
|
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
|
|
))}
|
|
<div ref={endRef} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
|
|
{/* Left rail: question + summary + history */}
|
|
<aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
|
|
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
|
{active && <EvidenceSummary meta={active.meta} />}
|
|
{turns.length > 1 && (
|
|
<div className="mt-4">
|
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
|
Verlauf
|
|
</div>
|
|
<div className="space-y-1">
|
|
{turns.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setActiveId(t.id)}
|
|
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
|
|
t.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{t.question}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
|
|
{/* Center: answer */}
|
|
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
|
{active && (
|
|
<AnswerPane answer={active.answer} streaming={active.status === 'streaming'} error={active.error} />
|
|
)}
|
|
</main>
|
|
|
|
{/* Right: evidence */}
|
|
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
|
{active && (
|
|
<>
|
|
<EvidencePane sources={active.meta.sources} />
|
|
<FiguresPane figures={active.meta.figures} />
|
|
<FootnotesPane footnotes={active.meta.footnotes} />
|
|
</>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
)
|
|
}
|