feat(ai-sdk): legal-corpus structure endpoint + coverage page

Expose GET /sdk/v1/rag/legal-corpus, which scrolls the eur-lex legal
corpus (filtered to a few hundred points regardless of total size) and
aggregates each ingested act's composition: distinct articles, annexes,
recitals and chunk count. Surface it as a new section on /sdk/coverage so
the ingested corpus is no longer a black box — a developer SEES what each
act actually contains, not only its name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-23 19:47:17 +02:00
parent b83c3e6e00
commit 4c99773fa1
6 changed files with 352 additions and 3 deletions
+83 -3
View File
@@ -3,6 +3,7 @@ import Link from 'next/link'
import {
type UseCaseRow,
type CorpusOverview,
type LegalCorpus,
licenseTierBadgeClass,
commercialBadgeClass,
groupUseCases,
@@ -11,28 +12,46 @@ import {
const BACKEND_URL =
process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
// The legal-corpus structure comes from the Go SDK (it owns the vector store).
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export const dynamic = 'force-dynamic'
// Fetched from the SDK and isolated in its own try/catch so a vector-store
// hiccup degrades to "no structure shown" instead of blanking the whole page.
async function fetchLegalCorpus(): Promise<LegalCorpus | null> {
try {
const res = await fetch(`${SDK_URL}/sdk/v1/rag/legal-corpus`, {
cache: 'no-store',
})
return res.ok ? await res.json() : null
} catch {
return null
}
}
async function getData(): Promise<{
useCases: UseCaseRow[]
corpus: CorpusOverview | null
legalCorpus: LegalCorpus | null
}> {
try {
const [ucRes, corpusRes] = await Promise.all([
const [ucRes, corpusRes, legalCorpus] = await Promise.all([
fetch(`${BACKEND_URL}/api/compliance/v1/controls/use-cases`, {
cache: 'no-store',
}),
fetch(`${BACKEND_URL}/api/compliance/v1/controls/corpus`, {
cache: 'no-store',
}),
fetchLegalCorpus(),
])
return {
useCases: ucRes.ok ? await ucRes.json() : [],
corpus: corpusRes.ok ? await corpusRes.json() : null,
legalCorpus,
}
} catch {
return { useCases: [], corpus: null }
return { useCases: [], corpus: null, legalCorpus: null }
}
}
@@ -46,7 +65,7 @@ function Stat({ label, value }: { label: string; value: string | number }) {
}
export default async function CoveragePage() {
const { useCases, corpus } = await getData()
const { useCases, corpus, legalCorpus } = await getData()
const groups = groupUseCases(useCases)
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
@@ -221,6 +240,67 @@ export default async function CoveragePage() {
</div>
</section>
{legalCorpus?.regulations?.length ? (
<section className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900">
Ingestierter Rechtskorpus Struktur ({legalCorpus.totals.regulations}{' '}
Rechtsakte)
</h2>
<p className="text-xs text-gray-500">
Woraus jeder ingestierte eur-lex-Rechtsakt tatsächlich besteht:
Artikel (§), Anhänge, Erwägungsgründe und retrievbare Chunks direkt
aus dem Vektorspeicher, damit kein Black-Box-Korpus entsteht.
</p>
<div className="overflow-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-2">Rechtsakt</th>
<th className="px-4 py-2 text-right">Artikel (§)</th>
<th className="px-4 py-2 text-right">Anhänge</th>
<th className="px-4 py-2 text-right">Erwägungsgründe</th>
<th className="px-4 py-2 text-right">Chunks</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{legalCorpus.regulations.map((r) => (
<tr key={r.regulation_short}>
<td className="px-4 py-2 text-gray-900">
<span className="font-medium">{r.regulation_short}</span>
{r.regulation_name !== r.regulation_short ? (
<span className="ml-2 text-xs text-gray-500">
{r.regulation_name}
</span>
) : null}
</td>
<td className="px-4 py-2 text-right font-semibold">
{r.articles.toLocaleString('de-DE')}
</td>
<td className="px-4 py-2 text-right">
{r.annexes > 0 ? (
r.annexes.toLocaleString('de-DE')
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500">
{r.recitals > 0 ? (
r.recitals.toLocaleString('de-DE')
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500">
{r.chunks.toLocaleString('de-DE')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
{corpus?.license_catalog?.length ? (
<section className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900">