49171e841f
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>
146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
// 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) }
|
|
}
|