5a513181cc
Completes the advisor stack (FE + orchestration; /retrieve is SDK/RAG-owned). The route
now returns the FE contract instead of a text stream:
- retrieveFull() calls /retrieve with {query, context}; consumes clarity/evidence/
visual_evidence/footnotes (exact shape per board 2026-07-01 12:25).
- mode-routing (resolveMode): clarify unless a context was chosen and /retrieve's
clarity.mode says so. clarify -> L1 general answer (completeAdvisorAnswer, ungrounded,
no sources). answer -> L2 answer over numbered evidence with [n] markers.
- citations generated here ([n] -> nth evidence unit); footnotes remapped; evidence /
visual_evidence passed through.
- advisor-llm: non-streaming completeAdvisorAnswer(). Pure mappings in retrieve-mapping.ts
(+ tests). Removed the dead v2 evidence.ts/evidence-adapter (RegulationRef moved to
regulation-display). controls-augmentation kept (tested; re-integrable later).
NOT deployed: joint deploy with the SDK /retrieve endpoint (deploy-coupling). tsc clean,
25 vitest (mapping/clarify/answer/markdown/registry/rag), check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
/**
|
|
* Compliance-Advisor RAG-Suche.
|
|
*
|
|
* Fragt den Authority Router der ai-compliance-sdk (`/sdk/v1/rag/retrieve`) mit NUR der
|
|
* Query ab — der Router waehlt selbst die Collections (Broad-Authority-Base + KB-2026.1-Slice
|
|
* bei in-scope), embeddet mit bge-m3 (prod: ollama-embed), merged + authority-ranked. Der
|
|
* Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
|
|
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
|
|
*
|
|
* `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).
|
|
*/
|
|
|
|
import type { RetrieveResponse } from '@/lib/sdk/advisor/retrieve-mapping'
|
|
|
|
const SDK_URL =
|
|
process.env.SDK_API_URL || process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
|
|
|
const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
|
const DEFAULT_TENANT =
|
|
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
|
|
|
export interface SdkRagResult {
|
|
text?: string
|
|
regulation_code?: string
|
|
regulation_name?: string
|
|
regulation_short?: string
|
|
article_label?: string
|
|
article?: string
|
|
paragraph?: string
|
|
sub?: string
|
|
citation_style?: string
|
|
is_recital?: boolean
|
|
category?: string
|
|
source_url?: string
|
|
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} (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.
|
|
source:
|
|
(r.article_label && r.article_label.trim()) ||
|
|
[r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub]
|
|
.filter(Boolean)
|
|
.join(' ') ||
|
|
'Unbekannt',
|
|
score: typeof r.score === 'number' ? r.score : 0,
|
|
}))
|
|
.filter((p) => p.content)
|
|
}
|
|
|
|
/** 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',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-ID': DEFAULT_USER,
|
|
'X-Tenant-ID': DEFAULT_TENANT,
|
|
},
|
|
body: JSON.stringify({ query, top_k: 8 }),
|
|
signal: AbortSignal.timeout(15000),
|
|
})
|
|
if (res.ok) return ((await res.json()) as SdkRagResponse) || {}
|
|
} catch {
|
|
// graceful: keine Verbindung -> Antwort ohne RAG-Kontext
|
|
}
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Voller `/retrieve`-Aufruf fuer die Clarity-Gate-Orchestrierung: liefert die strukturierte
|
|
* SDK/RAG-Response (evidence/visual_evidence/footnotes/tables/clarity/results). `context` scopet
|
|
* den 2. Aufruf auf die gewaehlte Domaene. Fehler -> leeres Ergebnis (graceful).
|
|
*/
|
|
export async function retrieveFull(query: string, context?: string | null): Promise<RetrieveResponse> {
|
|
try {
|
|
const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-ID': DEFAULT_USER,
|
|
'X-Tenant-ID': DEFAULT_TENANT,
|
|
},
|
|
body: JSON.stringify({ query, top_k: 8, ...(context ? { context } : {}) }),
|
|
signal: AbortSignal.timeout(15000),
|
|
})
|
|
if (res.ok) return ((await res.json()) as RetrieveResponse) || {}
|
|
} catch {
|
|
// graceful: keine Verbindung -> leeres Ergebnis
|
|
}
|
|
return {}
|
|
}
|