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:
@@ -7,8 +7,9 @@
|
||||
* Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
|
||||
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
*
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
* Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06.
|
||||
* `retrieveAdvisorEvidence` liefert die STRUKTURIERTEN Treffer (fuer das Evidence-Workspace-
|
||||
* Frontend, das nur strukturierte Daten rendert und nie den Antworttext parst) UND den
|
||||
* vorformatierten Kontext-Block fuer den LLM-Prompt. Fehler werden geschluckt (graceful).
|
||||
*/
|
||||
|
||||
const SDK_URL =
|
||||
@@ -18,7 +19,7 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
||||
const DEFAULT_TENANT =
|
||||
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
interface SdkRagResult {
|
||||
export interface SdkRagResult {
|
||||
text?: string
|
||||
regulation_code?: string
|
||||
regulation_name?: string
|
||||
@@ -34,20 +35,27 @@ interface SdkRagResult {
|
||||
score?: number
|
||||
}
|
||||
|
||||
/** Raw RAG response. `figures`/`footnotes` (C8 / C-FN) are passed through untyped until the
|
||||
* RAG-ingestion contract is finalized (board), then mapped in the evidence-adapter. */
|
||||
interface SdkRagResponse {
|
||||
results?: SdkRagResult[]
|
||||
figures?: unknown[]
|
||||
footnotes?: unknown[]
|
||||
}
|
||||
|
||||
interface ScoredPassage {
|
||||
content: string
|
||||
source: string
|
||||
score: number
|
||||
}
|
||||
|
||||
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score}. */
|
||||
/** Normalisiert eine ai-sdk-RAG-Antwort auf {content, source, score} (fuer den Prompt-Kontext). */
|
||||
export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassage[] {
|
||||
return (results || [])
|
||||
.map((r) => ({
|
||||
content: r.text || '',
|
||||
// Fundstelle: article_label ist die fertig formatierte, druckbare Quelle aus der
|
||||
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern
|
||||
// (bzw. alt-ingestierte Chunks ohne Legal-Metadaten). Siehe rag_reingest_spec.md §2/§7.
|
||||
// Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern.
|
||||
source:
|
||||
(r.article_label && r.article_label.trim()) ||
|
||||
[r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub]
|
||||
@@ -59,15 +67,16 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag
|
||||
.filter((p) => p.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`).
|
||||
* Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope),
|
||||
* merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts
|
||||
* mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
||||
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
|
||||
*/
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
let passages: ScoredPassage[] = []
|
||||
/** Formatiert die Top-Passagen als Kontext-Block fuer den System-Prompt. */
|
||||
function formatContext(passages: ScoredPassage[]): string {
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
/** EIN collection-agnostischer Aufruf an die ai-sdk. Fehler -> leeres Ergebnis (graceful). */
|
||||
async function fetchRag(query: string): Promise<SdkRagResponse> {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
||||
method: 'POST',
|
||||
@@ -79,16 +88,37 @@ export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
body: JSON.stringify({ query, top_k: 8 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
passages = mapSdkResults(data.results)
|
||||
}
|
||||
if (res.ok) return ((await res.json()) as SdkRagResponse) || {}
|
||||
} catch {
|
||||
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
|
||||
}
|
||||
// Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
|
||||
if (passages.length === 0) return ''
|
||||
return passages
|
||||
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
return {}
|
||||
}
|
||||
|
||||
export interface AdvisorEvidenceRaw {
|
||||
contextText: string
|
||||
results: SdkRagResult[]
|
||||
figures?: unknown[]
|
||||
footnotes?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Strukturierte Evidence + Prompt-Kontext aus EINEM Retrieval. Das Frontend bekommt die
|
||||
* `results` (und kuenftig `figures`/`footnotes`) als Daten; der `contextText` geht in den
|
||||
* LLM-Prompt. Reihenfolge der authority-geordneten Top-K bleibt erhalten.
|
||||
*/
|
||||
export async function retrieveAdvisorEvidence(query: string): Promise<AdvisorEvidenceRaw> {
|
||||
const data = await fetchRag(query)
|
||||
const results = data.results || []
|
||||
return {
|
||||
contextText: formatContext(mapSdkResults(results)),
|
||||
results,
|
||||
figures: Array.isArray(data.figures) ? data.figures : undefined,
|
||||
footnotes: Array.isArray(data.footnotes) ? data.footnotes : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Abwaertskompatibel: nur der Prompt-Kontext als String. */
|
||||
export async function queryAdvisorRAG(query: string): Promise<string> {
|
||||
return (await retrieveAdvisorEvidence(query)).contextText
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user