feat(advisor): v3 Clarity Gate — Case model + clarify/answer contract, [n] citations
Builds the FE against the SDK<->FE Clarity-Gate contract (board 2026-07-01 /
advisor-clarity-gate-contract). The advisor is now a CASE, not a chat:
- Request {question, context?}; response {mode: clarify|answer, clarity, general_answer,
answer, evidence, citations, visual_evidence, footnotes}.
- clarify mode: short L1 general answer (marked "allgemeine Definition, ohne Rechtsquelle")
+ domain context chips; picking a chip re-runs the case scoped (-> answer).
- answer mode: markdown answer with clickable [n] citation markers coupled to evidence
cards (highlight + scroll), evidence grouped by document family, visual_evidence
(visual_type), footnotes, honest summary counts (no trust score).
- FE never parses the answer for structure — only the deliberate [n] markers, mapped via
citations[]. New: contract.ts, useAdvisorCase, useCitationHighlight, ClarifyView,
EvidenceUnitCard, VisualEvidencePane, CaseView. Removed the v2 stream/chat components.
NOT deployed: FE shape-switch (JSON modes) must deploy TOGETHER with the SDK endpoint
delivering the contract (board deploy-coupling). Proxy/route.ts unchanged (SDK-owned).
tsc clean, 16 vitest (incl. clarify+answer fixtures), check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,50 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { AdvisorTurn } from './useAdvisorStream'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
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'
|
||||
import { CaseView, LoadingDots, ErrorBox } from './CaseView'
|
||||
import { ClarifyView } from './ClarifyView'
|
||||
import { EvidenceSummary } from './EvidenceSummary'
|
||||
import { EvidencePane } from './EvidencePane'
|
||||
import { VisualEvidencePane } from './VisualEvidencePane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
import { Markdown } from './Markdown'
|
||||
import { useCitationHighlight } from './useCitationHighlight'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Advisor body as a series of CASES.
|
||||
* - Narrow: stacked cases with a pinned last question.
|
||||
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
|
||||
*/
|
||||
export function EvidenceWorkspace({
|
||||
turns,
|
||||
cases,
|
||||
expanded,
|
||||
busy,
|
||||
exampleQuestions,
|
||||
onExample,
|
||||
onSelectContext,
|
||||
}: {
|
||||
turns: AdvisorTurn[]
|
||||
cases: AdvisorCase[]
|
||||
expanded: boolean
|
||||
busy: boolean
|
||||
exampleQuestions: string[]
|
||||
onExample: (q: string) => void
|
||||
onSelectContext: (caseId: string, ctx: 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
|
||||
const latest = cases[cases.length - 1]
|
||||
const active = cases.find((c) => c.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).
|
||||
}, [cases.length])
|
||||
useEffect(() => {
|
||||
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [turns.length, expanded])
|
||||
}, [cases.length, expanded])
|
||||
|
||||
if (turns.length === 0) {
|
||||
const answer = active?.response?.mode === 'answer' ? active.response : null
|
||||
const { highlightedId, cite } = useCitationHighlight(answer?.citations ?? [])
|
||||
|
||||
if (cases.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
||||
@@ -57,8 +61,14 @@ export function EvidenceWorkspace({
|
||||
<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} />
|
||||
{cases.map((c, i) => (
|
||||
<CaseView
|
||||
key={c.id}
|
||||
c={c}
|
||||
busy={busy}
|
||||
showQuestion={i !== cases.length - 1}
|
||||
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
@@ -66,28 +76,26 @@ export function EvidenceWorkspace({
|
||||
)
|
||||
}
|
||||
|
||||
const r = active?.response
|
||||
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 && (
|
||||
{answer && <EvidenceSummary response={answer} />}
|
||||
{cases.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
||||
Verlauf
|
||||
</div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
|
||||
<div className="space-y-1">
|
||||
{turns.map((t) => (
|
||||
{cases.map((c) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveId(t.id)}
|
||||
key={c.id}
|
||||
onClick={() => setActiveId(c.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'
|
||||
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t.question}
|
||||
{c.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,21 +103,32 @@ export function EvidenceWorkspace({
|
||||
)}
|
||||
</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} />
|
||||
{active?.status === 'loading' && <LoadingDots />}
|
||||
{active?.status === 'error' && <ErrorBox msg={active.error} />}
|
||||
{r?.mode === 'clarify' && (
|
||||
<ClarifyView
|
||||
response={r}
|
||||
busy={busy}
|
||||
onSelectContext={(ctx) => active && onSelectContext(active.id, ctx)}
|
||||
/>
|
||||
)}
|
||||
{r?.mode === 'answer' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Markdown content={r.answer || ''} citations={cite} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right: evidence */}
|
||||
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
||||
{active && (
|
||||
{answer ? (
|
||||
<>
|
||||
<EvidencePane sources={active.meta.sources} />
|
||||
<FiguresPane figures={active.meta.figures} />
|
||||
<FootnotesPane footnotes={active.meta.footnotes} />
|
||||
<EvidencePane evidence={answer.evidence} highlightedId={highlightedId} />
|
||||
<VisualEvidencePane items={answer.visual_evidence} />
|
||||
<FootnotesPane footnotes={answer.footnotes} />
|
||||
</>
|
||||
) : (
|
||||
<p className="px-1 text-[11px] text-gray-400">Evidence erscheint nach Auswahl eines Kontexts.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user