Files
breakpilot-compliance/admin-compliance/components/sdk/advisor/Markdown.tsx
T
Benjamin Admin f9b7ba2424 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>
2026-07-01 11:31:28 +02:00

177 lines
5.3 KiB
TypeScript

'use client'
// Minimal, SAFE markdown -> React renderer. No dangerouslySetInnerHTML, no dependency.
// Covers the subset LLMs emit: headings, bold, italic, inline code, fenced code, ul/ol, links.
// Plus deliberate [n] citation markers (mapped via `citations`, NOT parsed for structure).
export interface CiteHandler {
count: number
onSelect: (n: number) => void
}
const INLINE_RE =
/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|_[^_]+_|\[[^\]]+\]\([^)]+\)|\[\d+\])/g
function renderInline(text: string, kp: string, cite?: CiteHandler): React.ReactNode[] {
const nodes: React.ReactNode[] = []
let last = 0
let idx = 0
INLINE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index))
const tok = m[0]
const key = `${kp}-${idx++}`
if (tok.startsWith('`')) {
nodes.push(
<code key={key} className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.85em]">
{tok.slice(1, -1)}
</code>,
)
} else if (tok.startsWith('**')) {
nodes.push(
<strong key={key} className="font-semibold text-gray-900">
{tok.slice(2, -2)}
</strong>,
)
} else if (tok.startsWith('*') || tok.startsWith('_')) {
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 {
const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
if (mm && /^https?:\/\//i.test(mm[2])) {
nodes.push(
<a
key={key}
href={mm[2]}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 underline hover:text-indigo-800"
>
{mm[1]}
</a>,
)
} else {
nodes.push(mm ? mm[1] : tok)
}
}
last = m.index + tok.length
}
if (last < text.length) nodes.push(text.slice(last))
return nodes
}
function Heading({ level, kp, text, cite }: { level: number; kp: string; text: string; cite?: CiteHandler }) {
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 === 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>
}
const UL_RE = /^\s*[-*]\s+/
const OL_RE = /^\s*\d+\.\s+/
const H_RE = /^(#{1,6})\s+(.*)$/
export function Markdown({ content, citations }: { content: string; citations?: CiteHandler }) {
const lines = (content || '').replace(/\r\n/g, '\n').split('\n')
const blocks: React.ReactNode[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
const key = `b${blocks.length}`
if (line.trim().startsWith('```')) {
const buf: string[] = []
i++
while (i < lines.length && !lines[i].trim().startsWith('```')) {
buf.push(lines[i])
i++
}
i++
blocks.push(
<pre
key={key}
className="my-2 overflow-x-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-100"
>
<code>{buf.join('\n')}</code>
</pre>,
)
continue
}
if (line.trim() === '') {
i++
continue
}
const h = H_RE.exec(line)
if (h) {
blocks.push(<Heading key={key} kp={key} level={h[1].length} text={h[2]} cite={citations} />)
i++
continue
}
if (UL_RE.test(line)) {
const items: string[] = []
while (i < lines.length && UL_RE.test(lines[i])) {
items.push(lines[i].replace(UL_RE, ''))
i++
}
blocks.push(
<ul key={key} className="my-1.5 ml-4 list-disc space-y-1 text-gray-700">
{items.map((it, k) => (
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
))}
</ul>,
)
continue
}
if (OL_RE.test(line)) {
const items: string[] = []
while (i < lines.length && OL_RE.test(lines[i])) {
items.push(lines[i].replace(OL_RE, ''))
i++
}
blocks.push(
<ol key={key} className="my-1.5 ml-5 list-decimal space-y-1 text-gray-700">
{items.map((it, k) => (
<li key={k}>{renderInline(it, `${key}-${k}`, citations)}</li>
))}
</ol>,
)
continue
}
const para: string[] = []
while (
i < lines.length &&
lines[i].trim() !== '' &&
!H_RE.test(lines[i]) &&
!UL_RE.test(lines[i]) &&
!OL_RE.test(lines[i]) &&
!lines[i].trim().startsWith('```')
) {
para.push(lines[i])
i++
}
blocks.push(
<p key={key} className="my-1.5 leading-relaxed text-gray-700">
{renderInline(para.join(' '), key, citations)}
</p>,
)
}
return <div className="advisor-markdown text-sm">{blocks}</div>
}