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 <noreply@anthropic.com>
This commit is contained in:
@@ -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?')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user