From e8ea17922800b4261635c0c7fe99d1fa0cf347c1 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 1 Jul 2026 18:51:17 +0200 Subject: [PATCH] feat(advisor): topic threads, per-question delete/copy, fullscreen Adds case management to the Compliance Advisor widget. - topic threads: cases group into threads; the left menu shows each thread's first question as the Thema with expandable follow-ups. Send = follow-up to the active thread (carries the thread's prior Q&A as history for contextual answers); "+" starts a new topic. - delete: a trash action per question (menu + stacked view). - copy: single Q&A (question + answer + evidence + footnotes) or a whole thread, as Markdown to the clipboard (pure formatters in copy.ts). - fullscreen: compact -> panel -> fullscreen view. - route.ts consumes an optional bounded `history` so follow-ups are contextual for both the widget and the workspace consumer. Tests: copy formatter unit tests + Playwright specs (threads/new-topic, delete, fullscreen, copy affordance). No deploy. Co-Authored-By: Claude Opus 4.7 --- .../api/sdk/compliance-advisor/chat/route.ts | 18 +++ .../sdk/ComplianceAdvisorWidget.tsx | 102 ++++++++++--- .../components/sdk/advisor/CaseView.test.tsx | 2 +- .../components/sdk/advisor/CaseView.tsx | 42 +++++- .../sdk/advisor/EvidenceWorkspace.tsx | 58 ++++---- .../components/sdk/advisor/ThreadMenu.tsx | 139 ++++++++++++++++++ .../components/sdk/advisor/useAdvisorCase.ts | 119 +++++++++++++-- .../components/sdk/advisor/useClipboard.ts | 19 +++ .../specs/compliance-advisor-widget.spec.ts | 70 +++++++++ .../lib/sdk/__tests__/advisor-copy.test.ts | 65 ++++++++ admin-compliance/lib/sdk/advisor/copy.ts | 48 ++++++ 11 files changed, 611 insertions(+), 71 deletions(-) create mode 100644 admin-compliance/components/sdk/advisor/ThreadMenu.tsx create mode 100644 admin-compliance/components/sdk/advisor/useClipboard.ts create mode 100644 admin-compliance/e2e/specs/compliance-advisor-widget.spec.ts create mode 100644 admin-compliance/lib/sdk/__tests__/advisor-copy.test.ts create mode 100644 admin-compliance/lib/sdk/advisor/copy.ts diff --git a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts index 4cd8647e..e5dcde17 100644 --- a/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts +++ b/admin-compliance/app/api/sdk/compliance-advisor/chat/route.ts @@ -75,12 +75,28 @@ function answerSystem( return s } +// Prior thread turns for contextual follow-ups. Validated + bounded (last 8 turns ~ 4 Q&A). +function parseHistory(raw: unknown): ChatMessage[] { + if (!Array.isArray(raw)) return [] + const turns: ChatMessage[] = [] + for (const t of raw) { + if (!t || typeof t !== 'object') continue + const role = (t as { role?: unknown }).role + const content = (t as { content?: unknown }).content + if ((role === 'user' || role === 'assistant') && typeof content === 'string' && content.trim()) { + turns.push({ role, content }) + } + } + return turns.slice(-8) +} + export async function POST(request: NextRequest) { try { const body = await request.json() const question = String(body.question ?? body.message ?? '').trim() const context: string | null = body.context ?? null const audience = typeof body.audience === 'string' ? body.audience.trim() : '' + const history = parseHistory(body.history) const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country) ? (body.country as Country) : undefined @@ -98,6 +114,7 @@ export async function POST(request: NextRequest) { const legacySoul = await readSoulFile('compliance-advisor') const legacyStream = await streamAdvisorAnswer([ { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) }, + ...history, { role: 'user', content: question }, ]) if (!legacyStream) { @@ -141,6 +158,7 @@ export async function POST(request: NextRequest) { const soul = await readSoulFile('compliance-advisor') const messages: ChatMessage[] = [ { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) }, + ...history, { role: 'user', content: question }, ] const answer = await completeAdvisorAnswer(messages) diff --git a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx index 90b41e8c..9a1af52d 100644 --- a/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx +++ b/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx @@ -1,7 +1,20 @@ 'use client' import { useCallback, useState } from 'react' -import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react' +import { + Check, + Expand, + Loader2, + Mail, + Maximize2, + MessagesSquare, + Minimize2, + Plus, + Send, + Shrink, + Square, + X, +} from 'lucide-react' import { EXAMPLE_QUESTIONS } from './advisor/EmptyState' import { EvidenceWorkspace } from './advisor/EvidenceWorkspace' import { useAdvisorCase } from './advisor/useAdvisorCase' @@ -14,20 +27,29 @@ interface ComplianceAdvisorWidgetProps { type Country = 'DE' | 'AT' | 'CH' | 'EU' const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU'] +type View = 'compact' | 'panel' | 'fullscreen' +const SIZE: Record = { + compact: 'bottom-6 right-6 h-[560px] w-[420px] rounded-2xl', + panel: 'bottom-6 right-6 h-[85vh] w-[960px] rounded-2xl', + fullscreen: 'inset-0 h-full w-full', +} + /** - * Compliance Advisor — a floating Case Workspace on every SDK page. + * Compliance Advisor — a floating Case Workspace on every SDK page (compact / panel / fullscreen). * Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text. * See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract. */ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { const [isOpen, setIsOpen] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) + const [view, setView] = useState('compact') const [inputValue, setInputValue] = useState('') const [country, setCountry] = useState('DE') - const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country }) + const { cases, threads, busy, activeCaseId, ask, newTopic, selectContext, selectCase, remove, stop } = + useAdvisorCase({ currentStep, country }) const email = useAdvisorEmail(cases, country, currentStep) const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default + const expanded = view !== 'compact' const submit = useCallback( (q: string) => { @@ -38,6 +60,15 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA [busy, ask], ) + const submitNewTopic = useCallback( + (q: string) => { + if (!q.trim() || busy) return + setInputValue('') + newTopic(q) + }, + [busy, newTopic], + ) + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() @@ -57,13 +88,14 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA ) } + const headRound = view === 'fullscreen' ? '' : 'rounded-t-2xl' + const footRound = view === 'fullscreen' ? '' : 'rounded-b-2xl' + return (
-
+
@@ -97,12 +129,22 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA {email.sent ? : email.sending ? : } )} + {view !== 'fullscreen' && ( + + )} ) : ( - + <> + {cases.length > 0 && ( + + )} + + )}
diff --git a/admin-compliance/components/sdk/advisor/CaseView.test.tsx b/admin-compliance/components/sdk/advisor/CaseView.test.tsx index acc4f8ed..fbc468e1 100644 --- a/admin-compliance/components/sdk/advisor/CaseView.test.tsx +++ b/admin-compliance/components/sdk/advisor/CaseView.test.tsx @@ -41,7 +41,7 @@ const answer: AdvisorResponse = { } function mk(response: AdvisorResponse): AdvisorCase { - return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' } + return { id: 'case1', threadId: 'thread1', question: response.question, response, selectedContext: null, status: 'done' } } describe('CaseView — clarify mode', () => { diff --git a/admin-compliance/components/sdk/advisor/CaseView.tsx b/admin-compliance/components/sdk/advisor/CaseView.tsx index 4c7c0d8f..1776f77c 100644 --- a/admin-compliance/components/sdk/advisor/CaseView.tsx +++ b/admin-compliance/components/sdk/advisor/CaseView.tsx @@ -1,6 +1,8 @@ 'use client' +import { Check, Copy, Trash2 } from 'lucide-react' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' +import { formatCaseForCopy } from '@/lib/sdk/advisor/copy' import type { AdvisorCase } from './useAdvisorCase' import { ClarifyView } from './ClarifyView' import { EvidenceSummary } from './EvidenceSummary' @@ -9,6 +11,7 @@ import { VisualEvidencePane } from './VisualEvidencePane' import { FootnotesPane } from './FootnotesPane' import { Markdown } from './Markdown' import { useCitationHighlight } from './useCitationHighlight' +import { useClipboard } from './useClipboard' export function LoadingDots() { return ( @@ -50,20 +53,49 @@ export function CaseView({ onSelectContext, busy, showQuestion, + onRemove, }: { c: AdvisorCase onSelectContext: (ctx: string) => void busy: boolean showQuestion?: boolean + onRemove?: () => void }) { const r = c.response + const { copiedKey, copy } = useClipboard() return ( -
- {showQuestion && ( -
- Frage: {c.question} +
+
+ {showQuestion ? ( +
+ Frage: {c.question} +
+ ) : ( + + )} +
+ + {onRemove && ( + + )}
- )} +
{c.status === 'loading' && } {c.status === 'error' && } {r && r.mode === 'clarify' && ( diff --git a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx index cd32ec3f..3ddb5ad4 100644 --- a/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx +++ b/admin-compliance/components/sdk/advisor/EvidenceWorkspace.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import type { AdvisorCase } from './useAdvisorCase' +import { useEffect, useRef } from 'react' +import type { AdvisorCase, AdvisorThread } from './useAdvisorCase' import { StickyQuestion } from './StickyQuestion' import { AdvisorEmptyState } from './EmptyState' import { CaseView, LoadingDots, ErrorBox } from './CaseView' @@ -11,36 +11,41 @@ import { EvidencePane } from './EvidencePane' import { VisualEvidencePane } from './VisualEvidencePane' import { FootnotesPane } from './FootnotesPane' import { Markdown } from './Markdown' +import { ThreadMenu } from './ThreadMenu' import { useCitationHighlight } from './useCitationHighlight' /** - * 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). + * Advisor body as topic THREADS of cases. + * - Narrow: stacked cases with a pinned last question; per-case copy + delete. + * - Wide/fullscreen: 3-column Case Workspace — topic tree (left) | answer/clarify (center) | evidence (right). */ export function EvidenceWorkspace({ cases, + threads, expanded, busy, + activeCaseId, exampleQuestions, onExample, onSelectContext, + onSelectCase, + onRemove, }: { cases: AdvisorCase[] + threads: AdvisorThread[] expanded: boolean busy: boolean + activeCaseId: string | null exampleQuestions: string[] onExample: (q: string) => void onSelectContext: (caseId: string, ctx: string) => void + onSelectCase: (id: string) => void + onRemove: (id: string) => void }) { - const [activeId, setActiveId] = useState(null) const endRef = useRef(null) const latest = cases[cases.length - 1] - const active = cases.find((c) => c.id === activeId) ?? latest + const active = cases.find((c) => c.id === activeCaseId) ?? latest - useEffect(() => { - setActiveId(null) - }, [cases.length]) useEffect(() => { if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [cases.length, expanded]) @@ -68,6 +73,7 @@ export function EvidenceWorkspace({ busy={busy} showQuestion={i !== cases.length - 1} onSelectContext={(ctx) => onSelectContext(c.id, ctx)} + onRemove={() => onRemove(c.id)} /> ))}
@@ -78,27 +84,19 @@ export function EvidenceWorkspace({ const r = active?.response return ( -
+
diff --git a/admin-compliance/components/sdk/advisor/ThreadMenu.tsx b/admin-compliance/components/sdk/advisor/ThreadMenu.tsx new file mode 100644 index 00000000..a3448945 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/ThreadMenu.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useState } from 'react' +import { Check, ChevronDown, ChevronRight, Copy, Files, Trash2 } from 'lucide-react' +import type { AdvisorThread } from './useAdvisorCase' +import { formatCaseForCopy, formatThreadForCopy } from '@/lib/sdk/advisor/copy' +import { useClipboard } from './useClipboard' + +function IconBtn({ + title, + onClick, + children, +}: { + title: string + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +/** + * Left-menu topic tree: each thread's first question is the Thema; follow-ups nest underneath and + * expand/collapse. Per row: copy (single Q&A) + delete; per topic: copy the whole thread. + */ +export function ThreadMenu({ + threads, + activeCaseId, + onSelectCase, + onRemove, +}: { + threads: AdvisorThread[] + activeCaseId: string | null + onSelectCase: (id: string) => void + onRemove: (id: string) => void +}) { + const { copiedKey, copy } = useClipboard() + const [collapsed, setCollapsed] = useState>({}) + const ic = 'h-3.5 w-3.5' + + return ( +
+
Themen
+
+ {threads.map((t) => { + const first = t.cases[0] + const followups = t.cases.slice(1) + const open = !collapsed[t.id] + const activeInThread = t.cases.some((c) => c.id === activeCaseId) + return ( +
+
+ {followups.length > 0 ? ( + + ) : ( + + )} + +
+ copy(`thread:${t.id}`, formatThreadForCopy(t.title, t.cases))}> + {copiedKey === `thread:${t.id}` ? : } + + copy(`case:${first.id}`, formatCaseForCopy(first))}> + {copiedKey === `case:${first.id}` ? : } + + onRemove(first.id)}> + + +
+
+ + {open && + followups.map((c) => ( +
+ + +
+ copy(`case:${c.id}`, formatCaseForCopy(c))}> + {copiedKey === `case:${c.id}` ? : } + + onRemove(c.id)}> + + +
+
+ ))} +
+ ) + })} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/advisor/useAdvisorCase.ts b/admin-compliance/components/sdk/advisor/useAdvisorCase.ts index cdc59abd..8515dd9a 100644 --- a/admin-compliance/components/sdk/advisor/useAdvisorCase.ts +++ b/admin-compliance/components/sdk/advisor/useAdvisorCase.ts @@ -1,10 +1,11 @@ 'use client' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' export interface AdvisorCase { id: string + threadId: string question: string response: AdvisorResponse | null selectedContext: string | null @@ -12,34 +13,68 @@ export interface AdvisorCase { error?: string } +/** A topic: the first case's question is the title; follow-ups are the rest, in order. */ +export interface AdvisorThread { + id: string + title: string + cases: AdvisorCase[] +} + +interface HistoryTurn { + role: 'user' | 'assistant' + content: string +} + interface UseAdvisorCaseArgs { currentStep: string country: string } +let counter = 0 +const uid = (p: string) => `${p}-${Date.now()}-${counter++}` + /** - * 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). + * Drives the Advisor as topic THREADS of CASES. Each ask posts {question, context?, history} and + * receives a structured AdvisorResponse (clarify | answer) — no streaming, no answer-text parsing. + * A follow-up appends to the active thread (and carries the thread's prior Q&A as history); + * newTopic() starts a fresh thread. selectContext() re-runs a case scoped to a chosen domain. */ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) { const [cases, setCases] = useState([]) const [busy, setBusy] = useState(false) + const [activeCaseId, setActiveCaseId] = useState(null) + const [activeThreadId, setActiveThreadId] = useState(null) const abortRef = useRef(null) const patch = useCallback((id: string, p: Partial) => { setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c))) }, []) + // Prior answered turns of a thread, up to (but excluding) `beforeId`, for contextual follow-ups. + const buildHistory = useCallback( + (threadId: string, beforeId?: string): HistoryTurn[] => { + const turns: HistoryTurn[] = [] + for (const c of cases) { + if (c.threadId !== threadId) continue + if (beforeId && c.id === beforeId) break + const a = c.response?.answer ?? c.response?.general_answer + if (!a) continue + turns.push({ role: 'user', content: c.question }, { role: 'assistant', content: a }) + } + return turns + }, + [cases], + ) + const run = useCallback( - async (id: string, question: string, context: string | null) => { + async (id: string, question: string, context: string | null, history: HistoryTurn[]) => { 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 }), + body: JSON.stringify({ question, context, history, currentStep, country }), signal: abortRef.current.signal, }) if (!res.ok) { @@ -65,33 +100,89 @@ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) { ) const ask = useCallback( - (question: string) => { + (question: string, opts?: { newThread?: boolean }) => { const q = question.trim() if (!q || busy) return - const id = `case-${Date.now()}` + const startNew = opts?.newThread || !activeThreadId || cases.length === 0 + const threadId = startNew ? uid('thread') : activeThreadId! + const id = uid('case') + const history = startNew ? [] : buildHistory(threadId) setCases((prev) => [ ...prev, - { id, question: q, response: null, selectedContext: null, status: 'loading' }, + { id, threadId, question: q, response: null, selectedContext: null, status: 'loading' }, ]) - void run(id, q, null) + setActiveThreadId(threadId) + setActiveCaseId(id) + void run(id, q, null, history) }, - [busy, run], + [busy, activeThreadId, cases.length, buildHistory, run], ) + const newTopic = useCallback((question: string) => ask(question, { newThread: true }), [ask]) + 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) + void run(id, c.question, context, buildHistory(c.threadId, id)) }, - [cases, busy, run, patch], + [cases, busy, run, patch, buildHistory], ) + const remove = useCallback((id: string) => { + setCases((prev) => prev.filter((c) => c.id !== id)) + }, []) + + const selectCase = useCallback((id: string) => { + setActiveCaseId(id) + setCases((prev) => { + const c = prev.find((x) => x.id === id) + if (c) setActiveThreadId(c.threadId) + return prev + }) + }, []) + const stop = useCallback(() => { abortRef.current?.abort() setBusy(false) }, []) - return { cases, busy, ask, selectContext, stop } + // Keep the active selection valid after deletions. + useEffect(() => { + if (activeCaseId && !cases.some((c) => c.id === activeCaseId)) { + const last = cases[cases.length - 1] ?? null + setActiveCaseId(last?.id ?? null) + setActiveThreadId(last?.threadId ?? null) + } + }, [cases, activeCaseId]) + + const threads = useMemo(() => { + const order: string[] = [] + const byId = new Map() + for (const c of cases) { + let t = byId.get(c.threadId) + if (!t) { + t = { id: c.threadId, title: c.question, cases: [] } + byId.set(c.threadId, t) + order.push(c.threadId) + } + t.cases.push(c) + } + return order.map((id) => byId.get(id)!) + }, [cases]) + + return { + cases, + threads, + busy, + activeCaseId, + activeThreadId, + ask, + newTopic, + selectContext, + selectCase, + remove, + stop, + } } diff --git a/admin-compliance/components/sdk/advisor/useClipboard.ts b/admin-compliance/components/sdk/advisor/useClipboard.ts new file mode 100644 index 00000000..0ea86d59 --- /dev/null +++ b/admin-compliance/components/sdk/advisor/useClipboard.ts @@ -0,0 +1,19 @@ +'use client' + +import { useCallback, useState } from 'react' + +/** Writes text to the clipboard and flags the copied key briefly (for a check-mark affordance). */ +export function useClipboard(resetMs = 1500) { + const [copiedKey, setCopiedKey] = useState(null) + + const copy = useCallback( + (key: string, text: string) => { + void navigator.clipboard?.writeText(text) + setCopiedKey(key) + window.setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), resetMs) + }, + [resetMs], + ) + + return { copiedKey, copy } +} diff --git a/admin-compliance/e2e/specs/compliance-advisor-widget.spec.ts b/admin-compliance/e2e/specs/compliance-advisor-widget.spec.ts new file mode 100644 index 00000000..ab64abe4 --- /dev/null +++ b/admin-compliance/e2e/specs/compliance-advisor-widget.spec.ts @@ -0,0 +1,70 @@ +/** + * E2E: Compliance Advisor widget UX — topic threads, new-topic vs follow-up, delete, copy, fullscreen. + * Stubs the chat endpoint with an answer fixture so every ask yields a finished case. + */ + +import { test, expect } from '../fixtures/sdk-fixtures' + +const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat' +const openAdvisor = 'Compliance Advisor oeffnen' + +const ANSWER = { + mode: 'answer', + question: '', + clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.9 }, + general_answer: null, + answer: 'Musterantwort [1].', + scoped_query: null, + evidence: [{ evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5', bindingness: 'binding' }], + citations: [{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5' }], + visual_evidence: [], + footnotes: [], +} + +async function stub(page: import('@playwright/test').Page) { + await page.route(CHAT_ROUTE, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }), + ) +} + +test('new topic creates a second thread; copy control + fullscreen available', async ({ sdkPage }) => { + await stub(sdkPage) + await sdkPage.getByRole('button', { name: openAdvisor }).click() + + const input = sdkPage.getByPlaceholder('Frage eingeben...') + await input.fill('Erste Frage') + await input.press('Enter') + await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible() + + // expand -> the topic tree ("Themen") appears in the left menu + await sdkPage.getByRole('button', { name: 'Vergroessern' }).click() + await expect(sdkPage.getByText('Themen')).toBeVisible() + await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible() + + // a second, separate topic + await sdkPage.getByPlaceholder('Folgefrage eingeben...').fill('Zweites Thema') + await sdkPage.getByRole('button', { name: 'Neues Thema' }).click() + await expect(sdkPage.getByText('Zweites Thema').first()).toBeVisible() + await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible() + + // copy affordance + fullscreen toggle + await expect(sdkPage.getByRole('button', { name: 'Diese Frage kopieren' }).first()).toBeVisible() + await sdkPage.getByRole('button', { name: 'Vollbild' }).click() + await expect(sdkPage.getByRole('button', { name: 'Vollbild verlassen' })).toBeVisible() +}) + +test('delete removes a question from the thread', async ({ sdkPage }) => { + await stub(sdkPage) + await sdkPage.getByRole('button', { name: openAdvisor }).click() + + const input = sdkPage.getByPlaceholder('Frage eingeben...') + await input.fill('Zu löschen') + await input.press('Enter') + await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible() + + await sdkPage.getByRole('button', { name: 'Vergroessern' }).click() + await expect(sdkPage.getByText('Zu löschen').first()).toBeVisible() + + await sdkPage.getByRole('button', { name: 'Frage löschen' }).first().click() + await expect(sdkPage.getByText('Zu löschen')).toHaveCount(0) +}) diff --git a/admin-compliance/lib/sdk/__tests__/advisor-copy.test.ts b/admin-compliance/lib/sdk/__tests__/advisor-copy.test.ts new file mode 100644 index 00000000..a0b07a94 --- /dev/null +++ b/admin-compliance/lib/sdk/__tests__/advisor-copy.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { formatCaseForCopy, formatThreadForCopy } from '../advisor/copy' +import type { AdvisorResponse } from '../advisor/contract' + +const answer: AdvisorResponse = { + mode: 'answer', + question: 'CRA Meldefrist', + clarity: { is_underspecified: false, concentration: 0.9 }, + general_answer: null, + answer: 'Unverzüglich melden [1].', + scoped_query: 'cyber', + evidence: [{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', url: 'https://x' }], + citations: [], + visual_evidence: [], + footnotes: [{ footnote_id: 'f1', ref: 'Fußnote 3', document: 'EDPB', section: 'Kap III', text: 'Detail' }], +} + +const clarify: AdvisorResponse = { + mode: 'clarify', + question: 'Was ist PDCA?', + clarity: { is_underspecified: true, concentration: 0.3 }, + general_answer: 'PDCA = Plan-Do-Check-Act.', + answer: null, + scoped_query: null, + evidence: [], + citations: [], + visual_evidence: [], + footnotes: [], +} + +describe('formatCaseForCopy', () => { + it('includes question, answer, resolved evidence and footnotes', () => { + const s = formatCaseForCopy({ question: 'CRA Meldefrist', response: answer }) + expect(s).toContain('### Frage\nCRA Meldefrist') + expect(s).toContain('### Antwort\nUnverzüglich melden [1].') + expect(s).toContain('### Belege') + expect(s).toContain('Cyber Resilience Act (CRA), Art. 14 Abs. 1 (https://x)') + expect(s).toContain('### Fußnoten') + expect(s).toContain('Fußnote 3 — EDPB / Kap III: Detail') + }) + + it('falls back to the general definition for a clarify case', () => { + const s = formatCaseForCopy({ question: 'Was ist PDCA?', response: clarify }) + expect(s).toContain('### Antwort\nPDCA = Plan-Do-Check-Act.') + expect(s).not.toContain('### Belege') + }) + + it('handles a case without a response', () => { + const s = formatCaseForCopy({ question: 'offen', response: null }) + expect(s).toContain('### Antwort\n(keine Antwort)') + }) +}) + +describe('formatThreadForCopy', () => { + it('renders a title heading + every case separated by a rule', () => { + const s = formatThreadForCopy('CRA Meldefrist', [ + { question: 'CRA Meldefrist', response: answer }, + { question: 'Und für KMU?', response: clarify }, + ]) + expect(s.startsWith('# CRA Meldefrist')).toBe(true) + expect(s).toContain('\n---\n') + expect(s).toContain('CRA Meldefrist') + expect(s).toContain('Und für KMU?') + }) +}) diff --git a/admin-compliance/lib/sdk/advisor/copy.ts b/admin-compliance/lib/sdk/advisor/copy.ts new file mode 100644 index 00000000..6ae4fd87 --- /dev/null +++ b/admin-compliance/lib/sdk/advisor/copy.ts @@ -0,0 +1,48 @@ +// Pure formatters for "copy to clipboard": a single case (Q + answer + evidence) or a whole thread. +// No DOM/clipboard here (that is the caller's side effect) so this stays testable. + +import type { AdvisorResponse, EvidenceUnit } from './contract' +import { resolveRegulation } from './regulation-display' + +export interface CopyableCase { + question: string + response: AdvisorResponse | null +} + +function evidenceLine(e: EvidenceUnit): string { + const { familyLabel } = resolveRegulation({ code: e.regulation_code || e.document, short: e.document }) + const loc = [e.section, e.paragraph].filter(Boolean).join(' ') + const ref = [familyLabel, loc].filter(Boolean).join(', ') + return e.url ? `- ${ref} (${e.url})` : `- ${ref}` +} + +/** One case as portable Markdown: question, answer (or general definition), and its evidence. */ +export function formatCaseForCopy(c: CopyableCase): string { + const parts: string[] = [`### Frage`, c.question.trim()] + const r = c.response + const answer = (r?.answer || r?.general_answer || '').trim() + parts.push('', '### Antwort', answer || '(keine Antwort)') + + if (r && r.evidence.length > 0) { + parts.push('', '### Belege', ...r.evidence.map(evidenceLine)) + } + if (r && r.footnotes.length > 0) { + parts.push( + '', + '### Fußnoten', + ...r.footnotes.map((f, i) => { + const head = f.ref || `Fußnote ${i + 1}` + const src = [f.document, f.section].filter(Boolean).join(' / ') + return `- ${[head, src].filter(Boolean).join(' — ')}${f.text ? `: ${f.text}` : ''}` + }), + ) + } + return parts.join('\n') +} + +/** A whole topic thread: a title heading followed by every case, separated by rules. */ +export function formatThreadForCopy(title: string, cases: CopyableCase[]): string { + const header = `# ${title.trim() || 'Compliance-Advisor-Verlauf'}` + const body = cases.map(formatCaseForCopy).join('\n\n---\n\n') + return `${header}\n\n${body}` +}