feat(advisor): Evidence Workspace — structured panes, markdown, sources as knowledge units

Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.

Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.

- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-07-01 07:46:37 +02:00
parent f0120b237e
commit 49171e841f
22 changed files with 1379 additions and 421 deletions
@@ -0,0 +1,145 @@
// Adapter: RAG/compiler output -> the structured AdvisorEvidenceMeta the Evidence Workspace renders.
// This is the ONLY place that maps backend shapes to the frontend envelope. The frontend never
// parses the answer text — all structure originates here from structured fields.
import type {
AdvisorEvidenceMeta,
FigureUnit,
FootnoteUnit,
KnowledgeUnit,
RegulationRef,
} from './evidence'
import { deriveStats } from './evidence'
import type { SdkRagResult } from '../agents/advisor-rag'
/** Provisional raw figure (C8) shape — reconcile with the RAG-ingestion contract (board). */
export interface RawFigure {
figure_id?: string
id?: string
label?: string // "Abbildung 3"
caption?: string
topic?: string
regulation_code?: string
regulation_short?: string
regulation_name?: string
section?: string
vision_summary?: string
description?: string
image_url?: string
url?: string
}
/** Provisional raw footnote (C-FN) shape — reconcile with the RAG-ingestion contract (board). */
export interface RawFootnote {
id?: string
ref?: string
number?: string | number
regulation_code?: string
regulation_short?: string
regulation_name?: string
section?: string
text?: string
}
export interface RawEvidenceInput {
results?: SdkRagResult[]
figures?: RawFigure[]
footnotes?: RawFootnote[]
}
function regulationRef(
code?: string,
name?: string,
short?: string,
): RegulationRef {
return {
code: (code || short || name || 'unknown').toLowerCase().replace(/\s+/g, '_'),
name: name || undefined,
short: short || name || code || 'Quelle',
}
}
function truncate(text: string, max = 240): string {
const t = text.trim().replace(/\s+/g, ' ')
return t.length > max ? `${t.slice(0, max - 1)}` : t
}
function toKnowledgeUnit(r: SdkRagResult, idx: number): KnowledgeUnit | null {
const regulation = regulationRef(r.regulation_code, r.regulation_name, r.regulation_short)
const section = r.is_recital
? `Erwägungsgrund ${r.article ?? ''}`.trim()
: r.article || undefined
const label = r.article_label?.trim() || undefined
// Drop empty placeholders: a unit needs at least a label or a section to be meaningful.
if (!label && !section && !regulation.name && regulation.short === 'Quelle') return null
return {
id: `src-${idx}`,
regulation,
section,
paragraph: r.paragraph || undefined,
subsection: r.sub || undefined,
label,
score: typeof r.score === 'number' ? r.score : undefined,
snippet: r.text ? truncate(r.text) : undefined,
open: r.source_url ? { originalUrl: r.source_url } : undefined,
}
}
function dedupeKey(u: KnowledgeUnit): string {
return [u.regulation.code, u.section, u.paragraph, u.subsection, u.label]
.map((x) => x || '')
.join('|')
}
function toFigureUnit(f: RawFigure, idx: number): FigureUnit | null {
const id = f.figure_id || f.id
const imageUrl = f.image_url || f.url
if (!id && !imageUrl && !f.label) return null
return {
id: id || `fig-${idx}`,
label: f.label || `Abbildung ${idx + 1}`,
caption: f.caption || undefined,
topic: f.topic || undefined,
source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short),
section: f.section || undefined,
visionSummary: f.vision_summary || f.description || undefined,
imageUrl: imageUrl || undefined,
}
}
function toFootnoteUnit(f: RawFootnote, idx: number): FootnoteUnit | null {
const ref = f.ref || (f.number != null ? `Fußnote ${f.number}` : undefined)
if (!ref && !f.text) return null
return {
id: f.id || `fn-${idx}`,
ref: ref || `Fußnote ${idx + 1}`,
source: regulationRef(f.regulation_code, f.regulation_name, f.regulation_short),
section: f.section || undefined,
text: f.text || undefined,
}
}
/**
* Build the structured evidence meta. Sources are deduped (same citation retrieved multiple
* times collapses to one, keeping the highest score) and order is preserved by score.
*/
export function adaptEvidence(input: RawEvidenceInput): AdvisorEvidenceMeta {
const seen = new Map<string, KnowledgeUnit>()
;(input.results || []).forEach((r, i) => {
const unit = toKnowledgeUnit(r, i)
if (!unit) return
const key = dedupeKey(unit)
const existing = seen.get(key)
if (!existing || (unit.score ?? 0) > (existing.score ?? 0)) seen.set(key, unit)
})
const sources = [...seen.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
const figures = (input.figures || [])
.map(toFigureUnit)
.filter((x): x is FigureUnit => x !== null)
const footnotes = (input.footnotes || [])
.map(toFootnoteUnit)
.filter((x): x is FootnoteUnit => x !== null)
const meta = { sources, figures, footnotes }
return { ...meta, stats: deriveStats(meta) }
}
@@ -0,0 +1,103 @@
// Structured evidence contract for the Compliance Advisor "Evidence Workspace".
//
// HARD RULE (architecture): the frontend renders ONLY these structured fields and
// NEVER parses the answer text. All structure (sources, figures, footnotes) is owned
// by the SDK/compiler (C-stages) and surfaced as data. The proxy is the adapter that
// fills this envelope from RAG/compiler output. See memory: advisor-evidence-workspace-no-parse.
/** A regulation / document reference (CRA, EDPB WP248, MaschinenVO, ...). */
export interface RegulationRef {
code: string // canonical id, e.g. "cra", "edpb_wp248", "maschinenvo"
name?: string // full name
short?: string // short label shown in the card header
}
/** Openable targets for an evidence item — present only when the SDK can resolve them. */
export interface OpenTargets {
originalUrl?: string // original text / source_url
chunkId?: string // retrieved chunk
footnoteId?: string // C-FN
figureId?: string // C8
}
/**
* A retrieved source as a hierarchical Knowledge Unit, mirroring the compiler:
* Regelwerk -> Section (C1/C2) -> Paragraph -> Footnote (C-FN).
* Rendered as a card, not a text-list line. E.g. "EDPB WP248 / Kapitel III.B / Fußnote 17".
*/
export interface KnowledgeUnit {
id: string
regulation: RegulationRef
section?: string // "Annex I" / "Kapitel III.B" / "Anhang III"
subsection?: string // "Abschnitt 2.3"
paragraph?: string // Absatz / paragraph
footnoteRef?: string // "Fußnote 17" when this unit IS a footnote-backed source
label?: string // pre-formatted citation fallback, e.g. "BDSG § 38 Abs. 1"
score?: number // retrieval score (optional)
snippet?: string // short passage preview (optional) — lets the user peek the cited text
open?: OpenTargets
}
/** A figure (C8) as a Knowledge Unit — never a bare image. Only present when figures exist. */
export interface FigureUnit {
id: string // figure_id
label: string // "Abbildung 3"
caption?: string // "PDCA-Zyklus"
topic?: string
source: RegulationRef // "EDPB ..."
section?: string
visionSummary?: string // vision/LLM description of the figure
imageUrl?: string // Playwright PNG; undefined until the RAG-ingestion contract delivers it
}
/** A footnote (C-FN) as a first-class evidence item. */
export interface FootnoteUnit {
id: string
ref: string // "Fußnote 17"
source: RegulationRef
section?: string
text?: string
}
/** Counts for the stats bar above the answer ("Diese Antwort basiert auf N Quellen"). */
export interface AdvisorStats {
sources: number
regulations: number // distinct Regelwerke
figures: number
footnotes: number
}
/**
* Meta sent by the proxy FIRST (one JSON line), then the answer streams as tokens.
* RAG runs before the LLM, so all evidence is known up front and the panes render
* immediately while the answer streams in.
*/
export interface AdvisorEvidenceMeta {
stats: AdvisorStats
sources: KnowledgeUnit[]
figures: FigureUnit[]
footnotes: FootnoteUnit[]
relatedDocs?: KnowledgeUnit[]
}
/** The full evidence a single answer turn holds (meta + the streamed answer markdown). */
export interface AdvisorEvidence extends AdvisorEvidenceMeta {
answer: string // markdown prose, NO inline citations (sources live in the pane)
}
export function emptyStats(): AdvisorStats {
return { sources: 0, regulations: 0, figures: 0, footnotes: 0 }
}
/** Pure derivation of the stats bar from the evidence items (no parsing of answer text). */
export function deriveStats(
e: Pick<AdvisorEvidenceMeta, 'sources' | 'figures' | 'footnotes'>,
): AdvisorStats {
const regulations = new Set(e.sources.map((s) => s.regulation.code))
return {
sources: e.sources.length,
regulations: regulations.size,
figures: e.figures.length,
footnotes: e.footnotes.length,
}
}