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:
@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react'
|
|||||||
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
|
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
|
||||||
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
|
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
|
||||||
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
|
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
|
||||||
import { useAdvisorStream } from './advisor/useAdvisorStream'
|
import { useAdvisorCase } from './advisor/useAdvisorCase'
|
||||||
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
|
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
|
||||||
|
|
||||||
interface ComplianceAdvisorWidgetProps {
|
interface ComplianceAdvisorWidgetProps {
|
||||||
@@ -15,9 +15,9 @@ type Country = 'DE' | 'AT' | 'CH' | 'EU'
|
|||||||
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
|
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compliance Advisor — Evidence Workspace as a floating widget on every SDK page.
|
* Compliance Advisor — a floating Case Workspace on every SDK page.
|
||||||
* Renders ONLY structured evidence from the SDK (answer + sources + figures + footnotes);
|
* Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text.
|
||||||
* it never parses the answer text. See memory: advisor-evidence-workspace-no-parse.
|
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
|
||||||
*/
|
*/
|
||||||
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
@@ -25,17 +25,17 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [country, setCountry] = useState<Country>('DE')
|
const [country, setCountry] = useState<Country>('DE')
|
||||||
|
|
||||||
const { turns, isStreaming, send, stop } = useAdvisorStream({ currentStep, country })
|
const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country })
|
||||||
const email = useAdvisorEmail(turns, country, currentStep)
|
const email = useAdvisorEmail(cases, country, currentStep)
|
||||||
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
(q: string) => {
|
(q: string) => {
|
||||||
if (!q.trim() || isStreaming) return
|
if (!q.trim() || busy) return
|
||||||
setInputValue('')
|
setInputValue('')
|
||||||
void send(q)
|
ask(q)
|
||||||
},
|
},
|
||||||
[isStreaming, send],
|
[busy, ask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -63,7 +63,6 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
|
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
|
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||||
@@ -87,7 +86,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{turns.length > 0 && (
|
{cases.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={email.send}
|
onClick={email.send}
|
||||||
disabled={email.sending}
|
disabled={email.sending}
|
||||||
@@ -95,13 +94,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
|
||||||
aria-label="Als Email an DSB senden"
|
aria-label="Als Email an DSB senden"
|
||||||
>
|
>
|
||||||
{email.sent ? (
|
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />}
|
||||||
<Check className="h-5 w-5" />
|
|
||||||
) : email.sending ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Mail className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -121,15 +114,15 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Evidence Workspace */}
|
|
||||||
<EvidenceWorkspace
|
<EvidenceWorkspace
|
||||||
turns={turns}
|
cases={cases}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
|
busy={busy}
|
||||||
exampleQuestions={exampleQuestions}
|
exampleQuestions={exampleQuestions}
|
||||||
onExample={submit}
|
onExample={submit}
|
||||||
|
onSelectContext={selectContext}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -138,15 +131,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Frage eingeben..."
|
placeholder="Frage eingeben..."
|
||||||
disabled={isStreaming}
|
disabled={busy}
|
||||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{isStreaming ? (
|
{busy ? (
|
||||||
<button
|
<button onClick={stop} className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600" title="Abbrechen">
|
||||||
onClick={stop}
|
|
||||||
className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
|
|
||||||
title="Generierung stoppen"
|
|
||||||
>
|
|
||||||
<Square className="h-5 w-5" />
|
<Square className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Markdown } from './Markdown'
|
|
||||||
|
|
||||||
/** The answer panel — rendered markdown (clean prose, no inline citations). */
|
|
||||||
export function AnswerPane({
|
|
||||||
answer,
|
|
||||||
streaming,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
answer: string
|
|
||||||
streaming?: boolean
|
|
||||||
error?: string
|
|
||||||
}) {
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!answer && streaming) {
|
|
||||||
return (
|
|
||||||
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird generiert">
|
|
||||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
|
|
||||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.1s' }} />
|
|
||||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.2s' }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
|
||||||
<Markdown content={answer} />
|
|
||||||
{streaming && <span className="ml-0.5 inline-block h-3 w-1.5 animate-pulse bg-indigo-400 align-middle" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { CaseView } from './CaseView'
|
||||||
|
import type { AdvisorCase } from './useAdvisorCase'
|
||||||
|
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||||
|
|
||||||
|
const clarify: AdvisorResponse = {
|
||||||
|
mode: 'clarify',
|
||||||
|
question: 'Was ist PDCA?',
|
||||||
|
clarity: {
|
||||||
|
is_underspecified: true,
|
||||||
|
concentration: 0.38,
|
||||||
|
suggested_contexts: [
|
||||||
|
{ id: 'datenschutz', label: 'Datenschutz' },
|
||||||
|
{ id: 'qm', label: 'Qualitätsmanagement' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
general_answer: 'PDCA steht für **Plan-Do-Check-Act**.',
|
||||||
|
answer: null,
|
||||||
|
evidence: [],
|
||||||
|
citations: [],
|
||||||
|
visual_evidence: [],
|
||||||
|
footnotes: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer: AdvisorResponse = {
|
||||||
|
mode: 'answer',
|
||||||
|
question: 'PDCA im Datenschutz?',
|
||||||
|
clarity: { is_underspecified: false, dominant_context: 'datenschutz', concentration: 0.88 },
|
||||||
|
answer: 'Der DSM-Zyklus [1] beschreibt den Ablauf.',
|
||||||
|
evidence: [
|
||||||
|
{ evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2', snippet: 'x' },
|
||||||
|
],
|
||||||
|
citations: [
|
||||||
|
{ citation_id: 'c1', evidence_id: 'e1', document: 'DSK Sdm B41', section: 'Art. 5', paragraph: 'Abs. 2' },
|
||||||
|
],
|
||||||
|
visual_evidence: [
|
||||||
|
{ visual_id: 'v1', visual_type: 'flowchart', caption: 'PDCA-Zyklus', document: 'DSK SDM', vision_summary: 's' },
|
||||||
|
],
|
||||||
|
footnotes: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function mk(response: AdvisorResponse): AdvisorCase {
|
||||||
|
return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CaseView — clarify mode', () => {
|
||||||
|
it('renders the L1 general answer + context chips and fires onSelectContext', () => {
|
||||||
|
const onSel = vi.fn()
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<CaseView c={mk(clarify)} busy={false} onSelectContext={onSel} />,
|
||||||
|
)
|
||||||
|
expect(container.textContent).toContain('Plan-Do-Check-Act')
|
||||||
|
expect(container.textContent).toContain('Allgemeine Definition')
|
||||||
|
fireEvent.click(getByText('Datenschutz'))
|
||||||
|
expect(onSel).toHaveBeenCalledWith('datenschutz')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CaseView — answer mode', () => {
|
||||||
|
it('renders answer with a clickable [n] citation, grouped evidence (friendly name), and visual', () => {
|
||||||
|
const { container } = render(<CaseView c={mk(answer)} busy={false} onSelectContext={() => {}} />)
|
||||||
|
expect(container.textContent).toContain('DSM-Zyklus')
|
||||||
|
expect(container.querySelector('button[title="Beleg 1 anzeigen"]')).not.toBeNull()
|
||||||
|
expect(container.textContent).toContain('DSK Standard-Datenschutzmodell')
|
||||||
|
expect(container.textContent).toContain('PDCA-Zyklus')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||||
|
import type { AdvisorCase } from './useAdvisorCase'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function LoadingDots() {
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-1 px-1 py-2" aria-label="Antwort wird erstellt">
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.1s' }} />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0.2s' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ msg }: { msg?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{msg || 'Verbindung fehlgeschlagen'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer mode body (stacked): summary + answer (with [n] coupling) + evidence/visual/footnotes. */
|
||||||
|
export function AnswerBody({ response }: { response: AdvisorResponse }) {
|
||||||
|
const { highlightedId, cite } = useCitationHighlight(response.citations)
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<EvidenceSummary response={response} />
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||||
|
<Markdown content={response.answer || ''} citations={cite} />
|
||||||
|
</div>
|
||||||
|
<EvidencePane evidence={response.evidence} highlightedId={highlightedId} />
|
||||||
|
<VisualEvidencePane items={response.visual_evidence} />
|
||||||
|
<FootnotesPane footnotes={response.footnotes} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One case rendered stacked (narrow mode). Clarify -> L1 + chips; answer -> full evidence body. */
|
||||||
|
export function CaseView({
|
||||||
|
c,
|
||||||
|
onSelectContext,
|
||||||
|
busy,
|
||||||
|
showQuestion,
|
||||||
|
}: {
|
||||||
|
c: AdvisorCase
|
||||||
|
onSelectContext: (ctx: string) => void
|
||||||
|
busy: boolean
|
||||||
|
showQuestion?: boolean
|
||||||
|
}) {
|
||||||
|
const r = c.response
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
||||||
|
{showQuestion && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<span className="font-medium text-gray-400">Frage:</span> {c.question}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.status === 'loading' && <LoadingDots />}
|
||||||
|
{c.status === 'error' && <ErrorBox msg={c.error} />}
|
||||||
|
{r && r.mode === 'clarify' && (
|
||||||
|
<ClarifyView response={r} onSelectContext={onSelectContext} busy={busy} />
|
||||||
|
)}
|
||||||
|
{r && r.mode === 'answer' && <AnswerBody response={r} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
|
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clarify mode: a short general (L1) definition — explicitly marked as general, no legal source —
|
||||||
|
* plus domain context chips. Picking a chip re-runs the case scoped to that domain (-> L2).
|
||||||
|
*/
|
||||||
|
export function ClarifyView({
|
||||||
|
response,
|
||||||
|
onSelectContext,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
response: AdvisorResponse
|
||||||
|
onSelectContext: (id: string) => void
|
||||||
|
busy: boolean
|
||||||
|
}) {
|
||||||
|
const chips = response.clarity.suggested_contexts ?? []
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1 text-[11px] font-semibold text-amber-700">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
Allgemeine Definition (ohne Rechtsquelle)
|
||||||
|
</div>
|
||||||
|
<Markdown content={response.general_answer || ''} />
|
||||||
|
</div>
|
||||||
|
{chips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-xs font-medium text-gray-700">
|
||||||
|
Meintest du einen bestimmten Kontext?
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{chips.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSelectContext(c.id)}
|
||||||
|
className="rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight, Library } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Library } from 'lucide-react'
|
||||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
|
||||||
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
||||||
import { KnowledgeUnitCard } from './KnowledgeUnitCard'
|
import { EvidenceUnitCard } from './EvidenceUnitCard'
|
||||||
import { PaneHeader } from './PaneHeader'
|
import { PaneHeader } from './PaneHeader'
|
||||||
|
|
||||||
interface EvidenceGroupData {
|
interface Group {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
units: KnowledgeUnit[]
|
units: EvidenceUnit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] {
|
function groupByFamily(units: EvidenceUnit[]): Group[] {
|
||||||
const map = new Map<string, EvidenceGroupData>()
|
const map = new Map<string, Group>()
|
||||||
for (const u of sources) {
|
for (const u of units) {
|
||||||
const d = resolveRegulation(u.regulation)
|
const d = resolveRegulation({ code: u.document, short: u.document })
|
||||||
const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] }
|
const g = map.get(d.familyKey) ?? { key: d.familyKey, label: d.familyLabel, units: [] }
|
||||||
g.units.push(u)
|
g.units.push(u)
|
||||||
map.set(d.familyKey, g)
|
map.set(d.familyKey, g)
|
||||||
@@ -24,7 +24,7 @@ function groupByFamily(sources: KnowledgeUnit[]): EvidenceGroupData[] {
|
|||||||
return [...map.values()].sort((a, b) => b.units.length - a.units.length)
|
return [...map.values()].sort((a, b) => b.units.length - a.units.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvidenceGroup({ group }: { group: EvidenceGroupData }) {
|
function EvidenceGroup({ group, highlightedId }: { group: Group; highlightedId?: string }) {
|
||||||
const [open, setOpen] = useState(group.units.length <= 3)
|
const [open, setOpen] = useState(group.units.length <= 3)
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white">
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
@@ -42,7 +42,7 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="space-y-1 border-t border-gray-100 px-2 py-2">
|
<div className="space-y-1 border-t border-gray-100 px-2 py-2">
|
||||||
{group.units.map((u) => (
|
{group.units.map((u) => (
|
||||||
<KnowledgeUnitCard key={u.id} unit={u} compact />
|
<EvidenceUnitCard key={u.evidence_id} unit={u} compact highlighted={u.evidence_id === highlightedId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -50,22 +50,24 @@ function EvidenceGroup({ group }: { group: EvidenceGroupData }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evidence pane — retrieved units grouped by document/regulation family, count + expandable. */
|
/** Evidence pane — units grouped by document/regulation family, count + expandable. */
|
||||||
export function EvidencePane({ sources }: { sources: KnowledgeUnit[] }) {
|
export function EvidencePane({
|
||||||
const groups = groupByFamily(sources)
|
evidence,
|
||||||
|
highlightedId,
|
||||||
|
}: {
|
||||||
|
evidence: EvidenceUnit[]
|
||||||
|
highlightedId?: string
|
||||||
|
}) {
|
||||||
|
const groups = groupByFamily(evidence)
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<PaneHeader
|
<PaneHeader icon={<Library className="h-3.5 w-3.5 text-gray-500" />} title="Evidence" count={evidence.length} />
|
||||||
icon={<Library className="h-3.5 w-3.5 text-gray-500" />}
|
|
||||||
title="Evidence"
|
|
||||||
count={sources.length}
|
|
||||||
/>
|
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="px-1 text-[11px] text-gray-400">Keine strukturierte Evidence zu dieser Antwort.</p>
|
<p className="px-1 text-[11px] text-gray-400">Keine strukturierte Evidence zu dieser Antwort.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<EvidenceGroup key={g.key} group={g} />
|
<EvidenceGroup key={g.key} group={g} highlightedId={highlightedId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react'
|
import { FileText, Hash, Image as ImageIcon, Library } from 'lucide-react'
|
||||||
import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence'
|
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||||
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
@@ -31,12 +31,13 @@ function Card({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Antwort basiert auf" — honest, meaningful counts (not bare badges). Regelwerke = distinct
|
* "Antwort basiert auf" — objective counts only (no fabricated trust score). Regelwerke = distinct
|
||||||
* document FAMILIES (via resolveRegulation), so multi-part works like the DSK SDM count once.
|
* document families. Leitlinien deliberately omitted until bindingness exists in the Legal-KG.
|
||||||
* No fabricated trust score — a real trust signal needs a defined basis (bindingness/coverage).
|
|
||||||
*/
|
*/
|
||||||
export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) {
|
export function EvidenceSummary({ response }: { response: AdvisorResponse }) {
|
||||||
const families = new Set(meta.sources.map((s) => resolveRegulation(s.regulation).familyKey)).size
|
const families = new Set(
|
||||||
|
response.evidence.map((e) => resolveRegulation({ code: e.document, short: e.document }).familyKey),
|
||||||
|
).size
|
||||||
const cls = 'h-4 w-4'
|
const cls = 'h-4 w-4'
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -45,9 +46,9 @@ export function EvidenceSummary({ meta }: { meta: AdvisorEvidenceMeta }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<Card icon={<Library className={cls} />} value={families} label="Regelwerke" />
|
<Card icon={<Library className={cls} />} value={families} label="Regelwerke" />
|
||||||
<Card icon={<FileText className={cls} />} value={meta.sources.length} label="Evidence Units" />
|
<Card icon={<FileText className={cls} />} value={response.evidence.length} label="Evidence Units" />
|
||||||
<Card icon={<ImageIcon className={cls} />} value={meta.figures.length} label="Abbildungen" dim={meta.figures.length === 0} />
|
<Card icon={<ImageIcon className={cls} />} value={response.visual_evidence.length} label="Diagramme" dim={response.visual_evidence.length === 0} />
|
||||||
<Card icon={<Hash className={cls} />} value={meta.footnotes.length} label="Fußnoten" dim={meta.footnotes.length === 0} />
|
<Card icon={<Hash className={cls} />} value={response.footnotes.length} label="Fußnoten" dim={response.footnotes.length === 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+21
-36
@@ -2,48 +2,40 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
import type { EvidenceUnit } from '@/lib/sdk/advisor/contract'
|
||||||
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
import { resolveRegulation } from '@/lib/sdk/advisor/regulation-display'
|
||||||
|
|
||||||
/**
|
/** One evidence unit (contract shape). Compact inside a document group: chapter/section only. */
|
||||||
* A single evidence unit. Standalone: friendly regulation name + hierarchy. Compact (inside a
|
export function EvidenceUnitCard({
|
||||||
* document group): chapter/section only (the group already names the regulation). [öffnen] opens
|
unit,
|
||||||
* the original source; the optional snippet lets the user peek the cited text.
|
compact,
|
||||||
*/
|
highlighted,
|
||||||
export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; compact?: boolean }) {
|
}: {
|
||||||
|
unit: EvidenceUnit
|
||||||
|
compact?: boolean
|
||||||
|
highlighted?: boolean
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const d = resolveRegulation(unit.regulation)
|
const d = resolveRegulation({ code: unit.document, short: unit.document })
|
||||||
const crumbs = [unit.section, unit.subsection, unit.paragraph, unit.footnoteRef].filter(
|
const crumbs = [unit.section, unit.paragraph].filter((x): x is string => Boolean(x))
|
||||||
(x): x is string => Boolean(x),
|
const canOpen = !!unit.url && /^https?:\/\//i.test(unit.url)
|
||||||
)
|
|
||||||
const href = unit.open?.originalUrl
|
|
||||||
const canOpen = !!href && /^https?:\/\//i.test(href)
|
|
||||||
|
|
||||||
let header: string
|
const header = compact ? (d.chapter ? `Kapitel ${d.chapter}` : crumbs[0] || d.familyLabel) : d.familyLabel
|
||||||
let sub: string[]
|
const sub = compact && !d.chapter && crumbs.length ? crumbs.slice(1) : crumbs
|
||||||
if (!compact) {
|
|
||||||
header = d.familyLabel
|
|
||||||
sub = crumbs
|
|
||||||
} else if (d.chapter) {
|
|
||||||
header = `Kapitel ${d.chapter}`
|
|
||||||
sub = crumbs
|
|
||||||
} else {
|
|
||||||
header = crumbs[0] || unit.label || d.familyLabel
|
|
||||||
sub = crumbs.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
id={`ev-${unit.evidence_id}`}
|
||||||
|
className={`${
|
||||||
compact
|
compact
|
||||||
? 'rounded-md border border-gray-100 bg-gray-50 p-2'
|
? 'rounded-md border border-gray-100 bg-gray-50 p-2'
|
||||||
: 'rounded-lg border border-gray-200 bg-white p-2.5'
|
: 'rounded-lg border border-gray-200 bg-white p-2.5'
|
||||||
}
|
} ${highlighted ? 'ring-2 ring-indigo-400' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-xs font-semibold text-gray-900">{header}</div>
|
<div className="truncate text-xs font-semibold text-gray-900">{header}</div>
|
||||||
{sub.length > 0 ? (
|
{sub.length > 0 && (
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1 text-[11px] text-gray-500">
|
||||||
{sub.map((c, i) => (
|
{sub.map((c, i) => (
|
||||||
<span key={i} className="flex items-center gap-1">
|
<span key={i} className="flex items-center gap-1">
|
||||||
@@ -52,17 +44,11 @@ export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; comp
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
!compact &&
|
|
||||||
unit.label &&
|
|
||||||
unit.label !== header && (
|
|
||||||
<div className="mt-0.5 text-[11px] text-gray-500">{unit.label}</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canOpen && (
|
{canOpen && (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={unit.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||||
@@ -72,7 +58,6 @@ export function KnowledgeUnitCard({ unit, compact }: { unit: KnowledgeUnit; comp
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unit.snippet && (
|
{unit.snippet && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -1,50 +1,54 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { AdvisorTurn } from './useAdvisorStream'
|
import type { AdvisorCase } from './useAdvisorCase'
|
||||||
import { StickyQuestion } from './StickyQuestion'
|
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 { 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.
|
* Advisor body as a series of CASES.
|
||||||
* - Narrow (collapsed): stacked panels with a pinned last question + scrollable turn history.
|
* - Narrow: stacked cases with a pinned last question.
|
||||||
* - Wide (expanded): a 3-column Compliance Case Workspace — question + summary (left, with a
|
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
|
||||||
* 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({
|
export function EvidenceWorkspace({
|
||||||
turns,
|
cases,
|
||||||
expanded,
|
expanded,
|
||||||
|
busy,
|
||||||
exampleQuestions,
|
exampleQuestions,
|
||||||
onExample,
|
onExample,
|
||||||
|
onSelectContext,
|
||||||
}: {
|
}: {
|
||||||
turns: AdvisorTurn[]
|
cases: AdvisorCase[]
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
|
busy: boolean
|
||||||
exampleQuestions: string[]
|
exampleQuestions: string[]
|
||||||
onExample: (q: string) => void
|
onExample: (q: string) => void
|
||||||
|
onSelectContext: (caseId: string, ctx: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const endRef = useRef<HTMLDivElement>(null)
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
const latest = turns[turns.length - 1]
|
const latest = cases[cases.length - 1]
|
||||||
const active = turns.find((t) => t.id === activeId) ?? latest
|
const active = cases.find((c) => c.id === activeId) ?? latest
|
||||||
|
|
||||||
// A new turn refocuses the latest (null = follow latest).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
}, [turns.length])
|
}, [cases.length])
|
||||||
|
|
||||||
// Autoscroll the stacked view to the newest turn (narrow mode only).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
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 (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||||
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
<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">
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
||||||
{latest && <StickyQuestion question={latest.question} />}
|
{latest && <StickyQuestion question={latest.question} />}
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{turns.map((t, i) => (
|
{cases.map((c, i) => (
|
||||||
<TurnView key={t.id} turn={t} showQuestion={i !== turns.length - 1} />
|
<CaseView
|
||||||
|
key={c.id}
|
||||||
|
c={c}
|
||||||
|
busy={busy}
|
||||||
|
showQuestion={i !== cases.length - 1}
|
||||||
|
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,28 +76,26 @@ export function EvidenceWorkspace({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const r = active?.response
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
|
<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">
|
<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="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>
|
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
||||||
{active && <EvidenceSummary meta={active.meta} />}
|
{answer && <EvidenceSummary response={answer} />}
|
||||||
{turns.length > 1 && (
|
{cases.length > 1 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
|
||||||
Verlauf
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{turns.map((t) => (
|
{cases.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={c.id}
|
||||||
onClick={() => setActiveId(t.id)}
|
onClick={() => setActiveId(c.id)}
|
||||||
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,21 +103,32 @@ export function EvidenceWorkspace({
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Center: answer */}
|
|
||||||
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
||||||
{active && (
|
{active?.status === 'loading' && <LoadingDots />}
|
||||||
<AnswerPane answer={active.answer} streaming={active.status === 'streaming'} error={active.error} />
|
{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>
|
</main>
|
||||||
|
|
||||||
{/* Right: evidence */}
|
|
||||||
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
||||||
{active && (
|
{answer ? (
|
||||||
<>
|
<>
|
||||||
<EvidencePane sources={active.meta.sources} />
|
<EvidencePane evidence={answer.evidence} highlightedId={highlightedId} />
|
||||||
<FiguresPane figures={active.meta.figures} />
|
<VisualEvidencePane items={answer.visual_evidence} />
|
||||||
<FootnotesPane footnotes={active.meta.footnotes} />
|
<FootnotesPane footnotes={answer.footnotes} />
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="px-1 text-[11px] text-gray-400">Evidence erscheint nach Auswahl eines Kontexts.</p>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Image as ImageIcon, ExternalLink } from 'lucide-react'
|
|
||||||
import type { FigureUnit } from '@/lib/sdk/advisor/evidence'
|
|
||||||
import { PaneHeader } from './PaneHeader'
|
|
||||||
|
|
||||||
function FigureCard({ fig }: { fig: FigureUnit }) {
|
|
||||||
const canOpen = !!fig.imageUrl && /^https?:\/\//i.test(fig.imageUrl)
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="text-xs font-semibold text-gray-900">
|
|
||||||
{fig.label}
|
|
||||||
{fig.caption ? <span className="font-normal text-gray-600"> — {fig.caption}</span> : null}
|
|
||||||
</div>
|
|
||||||
{canOpen && (
|
|
||||||
<a
|
|
||||||
href={fig.imageUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Original anzeigen
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-gray-500">
|
|
||||||
Quelle: {fig.source.short}
|
|
||||||
{fig.section ? ` · ${fig.section}` : ''}
|
|
||||||
</div>
|
|
||||||
{canOpen ? (
|
|
||||||
<a href={fig.imageUrl} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={fig.imageUrl}
|
|
||||||
alt={fig.caption || fig.label}
|
|
||||||
loading="lazy"
|
|
||||||
className="max-h-44 w-full rounded border border-gray-100 object-contain"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="mt-1.5 flex items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 px-3 py-5 text-[11px] text-gray-400">
|
|
||||||
Original-Abbildung folgt
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{fig.visionSummary && (
|
|
||||||
<p className="mt-1.5 text-[11px] italic text-gray-500">{fig.visionSummary}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Figures pane (C8) — original document figures, rendered only when present. */
|
|
||||||
export function FiguresPane({ figures }: { figures: FigureUnit[] }) {
|
|
||||||
if (figures.length === 0) return null
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<PaneHeader
|
|
||||||
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
|
|
||||||
title="Abbildungen & Diagramme"
|
|
||||||
count={figures.length}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{figures.map((f) => (
|
|
||||||
<FigureCard key={f.id} fig={f} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Hash } from 'lucide-react'
|
import { Hash } from 'lucide-react'
|
||||||
import type { FootnoteUnit } from '@/lib/sdk/advisor/evidence'
|
import type { Footnote } from '@/lib/sdk/advisor/contract'
|
||||||
import { PaneHeader } from './PaneHeader'
|
import { PaneHeader } from './PaneHeader'
|
||||||
|
|
||||||
/** Footnotes pane (C-FN) — rendered only when present. */
|
/** Footnotes pane (C-FN) — rendered only when present. */
|
||||||
export function FootnotesPane({ footnotes }: { footnotes: FootnoteUnit[] }) {
|
export function FootnotesPane({ footnotes }: { footnotes: Footnote[] }) {
|
||||||
if (footnotes.length === 0) return null
|
if (footnotes.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<PaneHeader icon={<Hash className="h-3.5 w-3.5 text-gray-500" />} title="Fußnoten" count={footnotes.length} />
|
<PaneHeader icon={<Hash className="h-3.5 w-3.5 text-gray-500" />} title="Fußnoten" count={footnotes.length} />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{footnotes.map((fn) => (
|
{footnotes.map((fn, i) => (
|
||||||
<div key={fn.id} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
|
<div key={fn.footnote_id || i} className="rounded-md border border-gray-200 bg-white p-2 text-[11px]">
|
||||||
<span className="font-semibold text-gray-900">{fn.ref}</span>
|
<span className="font-semibold text-gray-900">{fn.ref || `Fußnote ${i + 1}`}</span>
|
||||||
<span className="text-gray-400">
|
{(fn.document || fn.section) && (
|
||||||
{' · '}
|
<span className="text-gray-400">
|
||||||
{fn.source.short}
|
{' · '}
|
||||||
{fn.section ? ` / ${fn.section}` : ''}
|
{fn.document}
|
||||||
</span>
|
{fn.section ? ` / ${fn.section}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
|
{fn.text && <p className="mt-0.5 text-gray-600">{fn.text}</p>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { render } from '@testing-library/react'
|
|
||||||
import { KnowledgeUnitCard } from './KnowledgeUnitCard'
|
|
||||||
import type { KnowledgeUnit } from '@/lib/sdk/advisor/evidence'
|
|
||||||
|
|
||||||
describe('KnowledgeUnitCard', () => {
|
|
||||||
it('shows the friendly regulation name (not the raw code) when standalone', () => {
|
|
||||||
const unit: KnowledgeUnit = { id: 's1', regulation: { code: 'cra', short: 'CRA' } }
|
|
||||||
const { container } = render(<KnowledgeUnitCard unit={unit} />)
|
|
||||||
expect(container.textContent).toContain('Cyber Resilience Act (CRA)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the section/paragraph breadcrumb', () => {
|
|
||||||
const unit: KnowledgeUnit = {
|
|
||||||
id: 's2',
|
|
||||||
regulation: { code: 'dsgvo', short: 'DSGVO' },
|
|
||||||
section: 'Art. 5',
|
|
||||||
paragraph: 'Abs. 2',
|
|
||||||
}
|
|
||||||
const { container } = render(<KnowledgeUnitCard unit={unit} />)
|
|
||||||
expect(container.textContent).toContain('Art. 5')
|
|
||||||
expect(container.textContent).toContain('Abs. 2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('compact mode shows the chapter and omits the family name (group provides it)', () => {
|
|
||||||
const unit: KnowledgeUnit = { id: 's3', regulation: { code: 'dsk_sdm_b51', short: 'DSK Sdm B51' } }
|
|
||||||
const { container } = render(<KnowledgeUnitCard unit={unit} compact />)
|
|
||||||
expect(container.textContent).toContain('Kapitel B51')
|
|
||||||
expect(container.textContent).not.toContain('Standard-Datenschutzmodell')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
|
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
|
||||||
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
|
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
|
||||||
// (The Evidence Workspace renders citations in a separate pane, so links are rarely needed.)
|
// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure).
|
||||||
|
|
||||||
const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\))/g
|
export interface CiteHandler {
|
||||||
|
count: number
|
||||||
|
onSelect: (n: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
function renderInline(text: string, kp: string): React.ReactNode[] {
|
const INLINE_RE =
|
||||||
|
/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g
|
||||||
|
|
||||||
|
function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] {
|
||||||
const nodes: React.ReactNode[] = []
|
const nodes: React.ReactNode[] = []
|
||||||
let last = 0
|
let last = 0
|
||||||
let idx = 0
|
let idx = 0
|
||||||
@@ -30,6 +36,23 @@ function renderInline(text: string, kp: string): React.ReactNode[] {
|
|||||||
)
|
)
|
||||||
} else if (tok.startsWith('*') || tok.startsWith('_')) {
|
} else if (tok.startsWith('*') || tok.startsWith('_')) {
|
||||||
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
|
nodes.push(<em key={key}>{tok.slice(1, -1)}</em>)
|
||||||
|
} else if (/^\[\d+\]$/.test(tok)) {
|
||||||
|
const n = parseInt(tok.slice(1, -1), 10)
|
||||||
|
if (cite && n >= 1 && n <= cite.count) {
|
||||||
|
nodes.push(
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => cite.onSelect(n)}
|
||||||
|
className="mx-0.5 align-super text-[10px] font-semibold text-indigo-600 hover:underline"
|
||||||
|
title={`Beleg ${n} anzeigen`}
|
||||||
|
>
|
||||||
|
[{n}]
|
||||||
|
</button>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nodes.push(tok)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
|
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
|
||||||
if (mm && /^https?:\/\//i.test(mm[2])) {
|
if (mm && /^https?:\/\//i.test(mm[2])) {
|
||||||
@@ -54,8 +77,8 @@ function renderInline(text: string, kp: string): React.ReactNode[] {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
function Heading({ level, kp, text }: { level: number; kp: string; text: string }) {
|
function Heading({ level, kp, text, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) {
|
||||||
const children = renderInline(text, kp)
|
const children = renderInline(text, kp, cite)
|
||||||
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
|
if (level <= 1) return <h3 className="mb-1 mt-3 text-base font-bold text-gray-900">{children}</h3>
|
||||||
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
|
if (level === 2) return <h4 className="mb-1 mt-3 text-sm font-bold text-gray-900">{children}</h4>
|
||||||
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
|
return <h5 className="mb-1 mt-2 text-sm font-semibold text-gray-800">{children}</h5>
|
||||||
@@ -65,13 +88,13 @@ const UL_RE = /^\s*[-*]\s+/
|
|||||||
const OL_RE = /^\s*\d+\.\s+/
|
const OL_RE = /^\s*\d+\.\s+/
|
||||||
const H_RE = /^(#{1,6})\s+(.*)$/
|
const H_RE = /^(#{1,6})\s+(.*)$/
|
||||||
|
|
||||||
export function Markdown({ content }: { content: string }) {
|
export function Markdown({ content, citations }: { content: string; citations?: CiteHandler }) {
|
||||||
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
|
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
|
||||||
const blocks: React.ReactNode[] = []
|
const blocks: React.ReactNode[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i]
|
const line = lines[i]
|
||||||
const key = `b${blocks.length}` // unique per pushed block (blocks.length is the next index)
|
const key = `b${blocks.length}`
|
||||||
|
|
||||||
if (line.trim().startsWith('```')) {
|
if (line.trim().startsWith('```')) {
|
||||||
const buf: string[] = []
|
const buf: string[] = []
|
||||||
@@ -97,7 +120,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
}
|
}
|
||||||
const h = H_RE.exec(line)
|
const h = H_RE.exec(line)
|
||||||
if (h) {
|
if (h) {
|
||||||
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} />)
|
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} cite={citations} />)
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -110,7 +133,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
blocks.push(
|
blocks.push(
|
||||||
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
|
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
|
||||||
{items.map((it, k) => (
|
{items.map((it, k) => (
|
||||||
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
|
||||||
))}
|
))}
|
||||||
</ul>,
|
</ul>,
|
||||||
)
|
)
|
||||||
@@ -125,7 +148,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
blocks.push(
|
blocks.push(
|
||||||
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
|
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
|
||||||
{items.map((it, k) => (
|
{items.map((it, k) => (
|
||||||
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
|
||||||
))}
|
))}
|
||||||
</ol>,
|
</ol>,
|
||||||
)
|
)
|
||||||
@@ -145,7 +168,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
}
|
}
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
|
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
|
||||||
{renderInline(para.join(' '), key)}
|
{renderInline(para.join(' '), key, citations)}
|
||||||
</p>,
|
</p>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { AdvisorTurn } from './useAdvisorStream'
|
|
||||||
import { EvidenceSummary } from './EvidenceSummary'
|
|
||||||
import { AnswerPane } from './AnswerPane'
|
|
||||||
import { EvidencePane } from './EvidencePane'
|
|
||||||
import { FiguresPane } from './FiguresPane'
|
|
||||||
import { FootnotesPane } from './FootnotesPane'
|
|
||||||
|
|
||||||
/** One question/answer turn as stacked panels (collapsed / narrow layout). */
|
|
||||||
export function TurnView({ turn, showQuestion }: { turn: AdvisorTurn; showQuestion?: boolean }) {
|
|
||||||
const streaming = turn.status === 'streaming'
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
|
||||||
{showQuestion && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium text-gray-400">Frage:</span> {turn.question}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<EvidenceSummary meta={turn.meta} />
|
|
||||||
<AnswerPane answer={turn.answer} streaming={streaming} error={turn.error} />
|
|
||||||
<EvidencePane sources={turn.meta.sources} />
|
|
||||||
<FiguresPane figures={turn.meta.figures} />
|
|
||||||
<FootnotesPane footnotes={turn.meta.footnotes} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ExternalLink, Image as ImageIcon } from 'lucide-react'
|
||||||
|
import type { VisualEvidence } from '@/lib/sdk/advisor/contract'
|
||||||
|
import { PaneHeader } from './PaneHeader'
|
||||||
|
|
||||||
|
function VisualCard({ v }: { v: VisualEvidence }) {
|
||||||
|
const canOpen = !!v.image_ref && /^https?:\/\//i.test(v.image_ref)
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-gray-900">{v.caption || v.visual_type}</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-gray-500">
|
||||||
|
<span className="rounded bg-gray-100 px-1 text-[10px] uppercase tracking-wide text-gray-500">
|
||||||
|
{v.visual_type}
|
||||||
|
</span>
|
||||||
|
<span>Quelle: {v.document}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canOpen && (
|
||||||
|
<a
|
||||||
|
href={v.image_ref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
Original anzeigen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canOpen ? (
|
||||||
|
<a href={v.image_ref} target="_blank" rel="noopener noreferrer" className="mt-1.5 block">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={v.image_ref}
|
||||||
|
alt={v.caption || v.visual_type}
|
||||||
|
loading="lazy"
|
||||||
|
className="max-h-44 w-full rounded border border-gray-100 object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1.5 flex items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 px-3 py-5 text-[11px] text-gray-400">
|
||||||
|
Original-Darstellung folgt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{v.vision_summary && <p className="mt-1.5 text-[11px] italic text-gray-500">{v.vision_summary}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual evidence (C8) — diagrams/figures, rendered only when present. */
|
||||||
|
export function VisualEvidencePane({ items }: { items: VisualEvidence[] }) {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<PaneHeader
|
||||||
|
icon={<ImageIcon className="h-3.5 w-3.5 text-gray-500" />}
|
||||||
|
title="Diagramme & Abbildungen"
|
||||||
|
count={items.length}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((v) => (
|
||||||
|
<VisualCard key={v.visual_id} v={v} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||||
|
|
||||||
|
export interface AdvisorCase {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
response: AdvisorResponse | null
|
||||||
|
selectedContext: string | null
|
||||||
|
status: 'loading' | 'done' | 'error'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAdvisorCaseArgs {
|
||||||
|
currentStep: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a
|
||||||
|
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing.
|
||||||
|
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer).
|
||||||
|
*/
|
||||||
|
export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
|
||||||
|
const [cases, setCases] = useState<AdvisorCase[]>([])
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
|
||||||
|
setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (id: string, question: string, context: string | null) => {
|
||||||
|
setBusy(true)
|
||||||
|
abortRef.current = new AbortController()
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ question, context, currentStep, country }),
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
||||||
|
throw new Error(e.error || `Server-Fehler (${res.status})`)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as AdvisorResponse
|
||||||
|
patch(id, { response: data, status: 'done', selectedContext: context })
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
patch(id, { status: 'done' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patch(id, {
|
||||||
|
status: 'error',
|
||||||
|
error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentStep, country, patch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = useCallback(
|
||||||
|
(question: string) => {
|
||||||
|
const q = question.trim()
|
||||||
|
if (!q || busy) return
|
||||||
|
const id = `case-${Date.now()}`
|
||||||
|
setCases((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id, question: q, response: null, selectedContext: null, status: 'loading' },
|
||||||
|
])
|
||||||
|
void run(id, q, null)
|
||||||
|
},
|
||||||
|
[busy, run],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectContext = useCallback(
|
||||||
|
(id: string, context: string) => {
|
||||||
|
const c = cases.find((x) => x.id === id)
|
||||||
|
if (!c || busy) return
|
||||||
|
patch(id, { status: 'loading', selectedContext: context })
|
||||||
|
void run(id, c.question, context)
|
||||||
|
},
|
||||||
|
[cases, busy, run, patch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setBusy(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { cases, busy, ask, selectContext, stop }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import type { AdvisorTurn } from './useAdvisorStream'
|
import type { AdvisorCase } from './useAdvisorCase'
|
||||||
|
|
||||||
function esc(s: string): string {
|
function esc(s: string): string {
|
||||||
return s
|
return s
|
||||||
@@ -11,33 +11,34 @@ function esc(s: string): string {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourcesHtml(turn: AdvisorTurn): string {
|
function evidenceHtml(c: AdvisorCase): string {
|
||||||
if (turn.meta.sources.length === 0) return ''
|
const ev = c.response?.evidence ?? []
|
||||||
const items = turn.meta.sources
|
if (ev.length === 0) return ''
|
||||||
.map((s) => {
|
const items = ev
|
||||||
const hier = [s.section, s.subsection, s.paragraph, s.footnoteRef].filter(Boolean).join(' › ')
|
.map(
|
||||||
return `<li>${esc(s.regulation.short || '')}${hier ? ` — ${esc(hier)}` : ''}</li>`
|
(e) =>
|
||||||
})
|
`<li>${esc(e.document)}${e.section ? ` — ${esc(e.section)}` : ''}${e.paragraph ? ` ${esc(e.paragraph)}` : ''}</li>`,
|
||||||
|
)
|
||||||
.join('')
|
.join('')
|
||||||
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Quellen:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
|
return `<p style="color:#64748b;font-size:12px;margin:4px 0 0;">Evidence:</p><ul style="color:#64748b;font-size:12px;margin:2px 0;">${items}</ul>`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends the consultation transcript (question + answer + structured sources) as an email to the DSB. */
|
/** Sends the consultation cases (question + answer + evidence) as an email to the DSB. */
|
||||||
export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentStep: string) {
|
export function useAdvisorEmail(cases: AdvisorCase[], country: string, currentStep: string) {
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
const send = useCallback(async () => {
|
const send = useCallback(async () => {
|
||||||
if (turns.length === 0 || sending) return
|
if (cases.length === 0 || sending) return
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
const qaHtml = turns
|
const qaHtml = cases
|
||||||
.map(
|
.map((c) => {
|
||||||
(t) =>
|
const a = c.response?.answer || c.response?.general_answer || '(keine Antwort)'
|
||||||
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
|
return `<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${esc(
|
||||||
t.question,
|
c.question,
|
||||||
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(t.answer)}</p>${sourcesHtml(t)}</div>`,
|
)}</p><p style="color:#475569;white-space:pre-wrap;">${esc(a)}</p>${evidenceHtml(c)}</div>`
|
||||||
)
|
})
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const bodyHtml = `
|
const bodyHtml = `
|
||||||
@@ -53,7 +54,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipient: 'dsb@breakpilot.local',
|
recipient: 'dsb@breakpilot.local',
|
||||||
subject: `Compliance Advisor — ${turns.length} Fragen (${currentStep})`,
|
subject: `Compliance Advisor — ${cases.length} Fragen (${currentStep})`,
|
||||||
body_html: bodyHtml,
|
body_html: bodyHtml,
|
||||||
role: 'Datenschutzbeauftragter',
|
role: 'Datenschutzbeauftragter',
|
||||||
}),
|
}),
|
||||||
@@ -65,7 +66,7 @@ export function useAdvisorEmail(turns: AdvisorTurn[], country: string, currentSt
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
}, [turns, sending, country, currentStep])
|
}, [cases, sending, country, currentStep])
|
||||||
|
|
||||||
return { send, sending, sent }
|
return { send, sending, sent }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react'
|
|
||||||
import type { AdvisorEvidenceMeta } from '@/lib/sdk/advisor/evidence'
|
|
||||||
import { emptyStats } from '@/lib/sdk/advisor/evidence'
|
|
||||||
|
|
||||||
export interface AdvisorTurn {
|
|
||||||
id: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
meta: AdvisorEvidenceMeta
|
|
||||||
status: 'streaming' | 'done' | 'error'
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyMeta(): AdvisorEvidenceMeta {
|
|
||||||
return { stats: emptyStats(), sources: [], figures: [], footnotes: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAdvisorStreamArgs {
|
|
||||||
currentStep: string
|
|
||||||
country: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the Evidence Workspace: posts a question, parses the FIRST line of the response as
|
|
||||||
* structured `AdvisorEvidenceMeta`, then streams the remaining bytes as the markdown answer.
|
|
||||||
* The answer text is NEVER parsed for structure — sources/figures/footnotes come from the meta.
|
|
||||||
*/
|
|
||||||
export function useAdvisorStream({ currentStep, country }: UseAdvisorStreamArgs) {
|
|
||||||
const [turns, setTurns] = useState<AdvisorTurn[]>([])
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false)
|
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
|
||||||
|
|
||||||
const patch = useCallback((id: string, p: Partial<AdvisorTurn>) => {
|
|
||||||
setTurns((prev) => prev.map((t) => (t.id === id ? { ...t, ...p } : t)))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
abortRef.current?.abort()
|
|
||||||
setIsStreaming(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const send = useCallback(
|
|
||||||
async (question: string) => {
|
|
||||||
const q = question.trim()
|
|
||||||
if (!q || isStreaming) return
|
|
||||||
|
|
||||||
const id = `turn-${Date.now()}`
|
|
||||||
const history = turns.flatMap((t) => [
|
|
||||||
{ role: 'user', content: t.question },
|
|
||||||
{ role: 'assistant', content: t.answer },
|
|
||||||
])
|
|
||||||
setTurns((prev) => [...prev, { id, question: q, answer: '', meta: emptyMeta(), status: 'streaming' }])
|
|
||||||
setIsStreaming(true)
|
|
||||||
abortRef.current = new AbortController()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message: q, history, currentStep, country }),
|
|
||||||
signal: abortRef.current.signal,
|
|
||||||
})
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
|
||||||
throw new Error(e.error || `Server-Fehler (${res.status})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buf = ''
|
|
||||||
let metaEnd = -1
|
|
||||||
let meta: AdvisorEvidenceMeta | null = null
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
buf += decoder.decode(value, { stream: true })
|
|
||||||
if (metaEnd === -1) {
|
|
||||||
const nl = buf.indexOf('\n')
|
|
||||||
if (nl === -1) continue
|
|
||||||
metaEnd = nl + 1
|
|
||||||
try {
|
|
||||||
meta = JSON.parse(buf.slice(0, nl)) as AdvisorEvidenceMeta
|
|
||||||
} catch {
|
|
||||||
meta = null // no valid meta -> treat whole stream as answer
|
|
||||||
metaEnd = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
patch(id, { answer: buf.slice(metaEnd), ...(meta ? { meta } : {}) })
|
|
||||||
}
|
|
||||||
|
|
||||||
buf += decoder.decode()
|
|
||||||
patch(id, { answer: buf.slice(metaEnd === -1 ? 0 : metaEnd), status: 'done', ...(meta ? { meta } : {}) })
|
|
||||||
setIsStreaming(false)
|
|
||||||
} catch (err) {
|
|
||||||
setIsStreaming(false)
|
|
||||||
if ((err as Error).name === 'AbortError') {
|
|
||||||
patch(id, { status: 'done' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
patch(id, { status: 'error', error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isStreaming, turns, currentStep, country, patch],
|
|
||||||
)
|
|
||||||
|
|
||||||
return { turns, isStreaming, send, stop }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { Citation } from '@/lib/sdk/advisor/contract'
|
||||||
|
import type { CiteHandler } from './Markdown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couples answer [n] markers to evidence cards: clicking [n] highlights + scrolls to the referenced
|
||||||
|
* evidence unit. Works across layout columns via the card's DOM id (ev-<evidence_id>).
|
||||||
|
*/
|
||||||
|
export function useCitationHighlight(citations: Citation[]): {
|
||||||
|
highlightedId?: string
|
||||||
|
cite?: CiteHandler
|
||||||
|
} {
|
||||||
|
const [highlightedId, setHighlightedId] = useState<string | undefined>()
|
||||||
|
if (citations.length === 0) return { highlightedId }
|
||||||
|
return {
|
||||||
|
highlightedId,
|
||||||
|
cite: {
|
||||||
|
count: citations.length,
|
||||||
|
onSelect: (n: number) => {
|
||||||
|
const c = citations[n - 1]
|
||||||
|
if (!c) return
|
||||||
|
setHighlightedId(c.evidence_id)
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getElementById(`ev-${c.evidence_id}`)?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// FE-facing contract for the Compliance Advisor "Case" (Clarity Gate).
|
||||||
|
// Matches the SDK<->FE contract (board 2026-07-01 / memory advisor-clarity-gate-contract).
|
||||||
|
// The FE renders ONLY these structured fields; it never extracts structure from the answer text.
|
||||||
|
// The only exception is rendering the deliberate [n] citation markers, mapped via `citations`.
|
||||||
|
|
||||||
|
export interface SuggestedContext {
|
||||||
|
id: string // e.g. "datenschutz"
|
||||||
|
label: string // e.g. "Datenschutz"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClarityInfo {
|
||||||
|
is_underspecified: boolean
|
||||||
|
concentration: number
|
||||||
|
suggested_contexts?: SuggestedContext[] // clarify mode
|
||||||
|
dominant_context?: string // answer mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A retrieved evidence unit. (`evidence[]` item shape — confirm with SDK; see board rückfrage.) */
|
||||||
|
export interface EvidenceUnit {
|
||||||
|
evidence_id: string
|
||||||
|
document: string
|
||||||
|
section?: string
|
||||||
|
paragraph?: string
|
||||||
|
snippet?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbered [n] <-> evidence coupling, produced by the SDK (not parsed from the answer). */
|
||||||
|
export interface Citation {
|
||||||
|
citation_id: string
|
||||||
|
evidence_id: string
|
||||||
|
document: string
|
||||||
|
section?: string | null
|
||||||
|
paragraph?: string | null
|
||||||
|
footnote?: string | null
|
||||||
|
figure?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C8 / visual evidence — `visual_type` generalizes beyond figures (flowchart/bpmn/state_machine/...). */
|
||||||
|
export interface VisualEvidence {
|
||||||
|
visual_id: string
|
||||||
|
visual_type: string
|
||||||
|
caption?: string
|
||||||
|
document: string
|
||||||
|
context?: string
|
||||||
|
image_ref?: string
|
||||||
|
vision_summary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Footnote {
|
||||||
|
footnote_id?: string
|
||||||
|
ref?: string
|
||||||
|
document?: string
|
||||||
|
section?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdvisorMode = 'clarify' | 'answer'
|
||||||
|
|
||||||
|
export interface AdvisorResponse {
|
||||||
|
mode: AdvisorMode
|
||||||
|
question: string
|
||||||
|
clarity: ClarityInfo
|
||||||
|
general_answer?: string | null // L1 (clarify mode)
|
||||||
|
answer?: string | null // L2 (answer mode)
|
||||||
|
scoped_query?: string | null
|
||||||
|
evidence: EvidenceUnit[]
|
||||||
|
citations: Citation[]
|
||||||
|
visual_evidence: VisualEvidence[]
|
||||||
|
footnotes: Footnote[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvisorRequest {
|
||||||
|
question: string
|
||||||
|
context?: string | null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user