Compare commits

...

4 Commits

Author SHA1 Message Date
Benjamin Admin 24499a25e9 fix(ai-sdk): make interpretation-intent override reliably win
CI / detect-changes (pull_request) Successful in 5s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 7s
CI / dep-audit (pull_request) Failing after 53s
CI / sbom-scan (pull_request) Failing after 51s
CI / build-sha-integrity (pull_request) Successful in 5s
CI / validate-canonical-controls (pull_request) Successful in 3s
CI / loc-budget (pull_request) Successful in 15s
CI / go-lint (pull_request) Successful in 53s
CI / python-lint (pull_request) Failing after 15s
CI / nodejs-lint (pull_request) Failing after 1m13s
CI / nodejs-build (pull_request) Successful in 3m8s
CI / test-go (pull_request) Successful in 58s
CI / iace-gt-coverage (pull_request) Successful in 15s
CI / test-python-backend (pull_request) Successful in 27s
CI / test-python-document-crawler (pull_request) Successful in 19s
CI / test-python-dsms-gateway (pull_request) Successful in 13s
PR #34's fixed +0.25 guidance gain was too small live: for "Was empfiehlt der
EDPB zum DSB?" the binding Art. 37 (1.381) still edged out the boosted EDPB
guidance (1.348), because the live authority score gives the binding article a
topic/domain bonus the (partly English) guidance chunk does not match.

Replace the fixed gain with a deterministic lift: a semantically competitive
guideline (raw semantic >= best_binding_semantic - 0.05) is lifted just ABOVE
the best binding FINAL score (ordered by semantic), so authority/topic/domain
bonuses can no longer edge it out. Obligation questions (no intent signal) are
untouched — binding stays Top-1; off-topic guidance stays demoted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 11:21:59 +02:00
Benjamin_Boenisch f11b2e035f feat(ai-sdk): controlled interpretation-intent guidance override (#34)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 57s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 09:01:25 +00:00
Benjamin_Boenisch 230dc05287 feat(ai-sdk): legal-corpus coverage + Phase-2 citation-graph assessment (#33)
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 6s
CI / go-lint (push) Has been skipped
CI / loc-budget (push) Successful in 19s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m1s
CI / test-go (push) Successful in 59s
CI / iace-gt-coverage (push) Successful in 22s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 06:37:22 +00:00
Benjamin_Boenisch b83c3e6e00 ci(go-lint): golangci-lint v1.64.8 (go1.24) + new-from-merge-base (#32)
CI / detect-changes (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / build-sha-integrity (push) Successful in 11s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 57s
CI / iace-gt-coverage (push) Successful in 16s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-23 10:58:48 +00:00
17 changed files with 1092 additions and 22 deletions
+4 -2
View File
@@ -136,12 +136,14 @@ jobs:
runs-on: docker
needs: detect-changes
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
container: golangci/golangci-lint:v1.62-alpine
container: golangci/golangci-lint:v1.64.8-alpine
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
# Full clone so `main` is a local ref — new-from-merge-base needs the merge base.
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
git checkout ${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
- name: Lint ai-compliance-sdk
run: |
[ -d "ai-compliance-sdk" ] || exit 0
@@ -46,6 +46,28 @@ export interface CorpusOverview {
totals: { documents: number; catalog_sources: number }
}
// --- Ingested legal-corpus structure (from the vector store, via the Go SDK).
// Shows WHAT each eur-lex act consists of (articles/annexes/recitals), so the
// ingested corpus is not a black box for developers. ---
export interface LegalActStructure {
regulation_short: string
regulation_name: string
articles: number
annexes: number
recitals: number
chunks: number
}
export interface LegalCorpus {
regulations: LegalActStructure[]
totals: {
regulations: number
articles: number
annexes: number
recitals: number
}
}
// --- Korpus-Dokumente: gruppieren nach Art (Gesetz/Leitfaden/Standard/Urteil)
// + Herausgeber-Familie (DSK, EDPB, OWASP, NIST …). Deterministisch, pure. ---
interface DocCat {
+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">
+4 -5
View File
@@ -55,8 +55,7 @@ linters-settings:
rules:
- name: exported
arguments:
- checkPrivateReceivers: false
- disableStutteringCheck: true
- disableStutteringCheck
- name: error-return
- name: increment-decrement
- name: var-declaration
@@ -83,6 +82,6 @@ issues:
max-issues-per-linter: 50
max-same-issues: 5
# New code only: don't fail on pre-existing issues in files we haven't touched.
# Remove this once a clean baseline is established.
new: false
# New code only: lint lines changed vs main, so pre-existing debt doesn't fail CI.
# Needs the go-lint job to clone with a local `main` ref (see .gitea/workflows/ci.yaml).
new-from-merge-base: main
@@ -75,9 +75,10 @@ func (h *RAGHandlers) Search(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"query": req.Query,
"results": results,
"count": len(results),
"query": req.Query,
"results": results,
"count": len(results),
"assessment": ucca.Assess(results),
})
}
@@ -206,3 +207,32 @@ func (h *RAGHandlers) HandleScrollChunks(c *gin.Context) {
"total": len(chunks),
})
}
// LegalCorpusStructure returns the composition (distinct articles, annexes,
// recitals + chunk count) of every ingested eur-lex legal act, so the coverage
// page can show WHAT was ingested instead of just the act name.
// GET /sdk/v1/rag/legal-corpus
func (h *RAGHandlers) LegalCorpusStructure(c *gin.Context) {
acts, err := h.ragClient.CorpusStructure(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate legal corpus: " + err.Error()})
return
}
arts, anns, recs := 0, 0, 0
for _, a := range acts {
arts += a.Articles
anns += a.Annexes
recs += a.Recitals
}
c.JSON(http.StatusOK, gin.H{
"regulations": acts,
"totals": gin.H{
"regulations": len(acts),
"articles": arts,
"annexes": anns,
"recitals": recs,
},
})
}
+1
View File
@@ -161,6 +161,7 @@ func registerRAGRoutes(v1 *gin.RouterGroup, h *handlers.RAGHandlers) {
ragRoutes.GET("/corpus-status", h.CorpusStatus)
ragRoutes.GET("/corpus-versions/:collection", h.CorpusVersionHistory)
ragRoutes.GET("/scroll", h.HandleScrollChunks)
ragRoutes.GET("/legal-corpus", h.LegalCorpusStructure)
}
}
@@ -1,25 +1,73 @@
package ucca
import "sort"
import (
"sort"
"strings"
)
// Re-ranking coefficients (validated in the offline golden harness; Phase A — conservative).
const (
authorityCoef = 0.40 // * weight/100
jurisdictionGain = 0.05 // binding/guidance from DE or EU
foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed)
unknownPenalty = 0.08
domainMatchGain = 0.15
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
topicGain = 0.18 // amplifier only
authorityCoef = 0.40 // * weight/100
jurisdictionGain = 0.05 // binding/guidance from DE or EU
foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed)
unknownPenalty = 0.08
domainMatchGain = 0.15
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
topicGain = 0.18 // amplifier only
supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt
guidanceIntentGain = 0.10 // epsilon a qualifying guideline is lifted ABOVE the best binding hit
guidanceIntentMargin = 0.05 // ...only if the guideline is semantically competitive with binding
)
// guidanceIntentSignals mark a query that EXPLICITLY asks for an interpretation /
// recommendation by a guidance body, rather than for the binding obligation. Only
// then may a (semantically competitive) guideline outrank the binding norm.
var guidanceIntentSignals = []string{
"edpb", "europäischer datenschutzausschuss", "europaeischer datenschutzausschuss",
"dsk", "enisa", "bsi", "leitlinie", "guideline", "orientierungshilfe",
"auslegung", "empfiehlt", "empfehlung", "sagt", "laut",
}
// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation.
func queryWantsGuidance(query string) bool {
q := strings.ToLower(query)
for _, sig := range guidanceIntentSignals {
if strings.Contains(q, sig) {
return true
}
}
return false
}
// bestBindingSemantic returns the highest RAW semantic score among binding-law
// results (0 if none / intent not requested). Used as the guard threshold so an
// off-topic guideline cannot ride the interpretation-intent boost.
func bestBindingSemantic(results []LegalSearchResult, wantsGuidance bool) float64 {
if !wantsGuidance {
return 0
}
best := 0.0
for _, r := range results {
if r.SourceClass == "binding_law" && r.Score > best {
best = r.Score
}
}
return best
}
// authorityScore computes the normative relevance of a result for a query. It augments the
// semantic score with authority/jurisdiction/domain/scope/topic signals. Exposed for tests.
func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign bool) float64 {
info := classifyAuthority(r)
score := r.Score + authorityCoef*float64(info.weight)/100.0
if r.Superseded {
// Alt-Quelle (pre-eu-v1): Default-Fragen sollen die eu-v1-Norm sehen. Demoted,
// nicht entfernt — fuer Historie/Uebergangsfragen bleibt sie auffindbar.
score -= supersededPenalty
}
if info.jurisdiction == "CH" && !qForeign {
score -= foreignPenalty // Fremdrecht bei DE/EU-Frage: demoted, nicht geloescht
} else {
@@ -55,14 +103,43 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR
}
qDomain := queryDomain(query)
qForeign := queryIsForeign(query)
wantsGuidance := queryWantsGuidance(query)
bestBindingSem := bestBindingSemantic(results, wantsGuidance)
out := make([]LegalSearchResult, len(results))
copy(out, results)
for i := range out {
out[i].Score = authorityScore(query, out[i], qDomain, qForeign)
}
if wantsGuidance {
applyGuidanceIntent(out, results, bestBindingSem)
}
sort.SliceStable(out, func(a, b int) bool {
return out[a].Score > out[b].Score
})
return out
}
// applyGuidanceIntent lifts semantically-competitive guidance just ABOVE the best
// binding hit (ordered by semantic), so an EXPLICIT interpretation question can
// return guidance Top-1. Obligation questions (no intent → not called) keep
// binding on top. Guidance below the semantic margin is left untouched, so an
// off-topic guideline can never ride the override — and the lift is computed from
// the binding FINAL score, so authority/topic/domain bonuses cannot edge it out.
func applyGuidanceIntent(out, raw []LegalSearchResult, bestBindingSem float64) {
bestBindingFinal := 0.0
for i := range out {
if out[i].SourceClass == "binding_law" && out[i].Score > bestBindingFinal {
bestBindingFinal = out[i].Score
}
}
for i := range out {
if out[i].SourceClass != "supervisory_guidance" || raw[i].Score < bestBindingSem-guidanceIntentMargin {
continue
}
lifted := bestBindingFinal + guidanceIntentGain + (raw[i].Score - bestBindingSem)
if lifted > out[i].Score {
out[i].Score = lifted
}
}
}
@@ -0,0 +1,167 @@
package ucca
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
)
// LegalActStructure is the composition of one ingested eur-lex legal act — how
// many distinct articles, annexes and recitals it consists of (plus the raw
// chunk count). Backs the coverage page so the ingested corpus is not a black
// box: a developer SEES what each act actually contains, not only its name.
type LegalActStructure struct {
RegulationShort string `json:"regulation_short"`
RegulationName string `json:"regulation_name"`
Articles int `json:"articles"`
Annexes int `json:"annexes"`
Recitals int `json:"recitals"`
Chunks int `json:"chunks"`
}
const eurlexSource = "eur-lex.europa.eu"
// legalStructureCollections hold the clean eur-lex legal corpus (chunks tagged
// with chunk_scope = section | annex | recital).
var legalStructureCollections = []string{"bp_compliance_ce", "bp_compliance_datenschutz"}
// chunkScopeBucket maps a Qdrant chunk_scope to the structure field it feeds.
var chunkScopeBucket = map[string]string{"section": "articles", "annex": "annexes", "recital": "recitals"}
// CorpusStructure scrolls the eur-lex legal corpus across the legal collections
// and aggregates the per-act composition. The source filter keeps it to a few
// hundred points regardless of total corpus size. Read-only; a collection that
// fails to scroll is skipped rather than failing the whole call.
func (c *LegalRAGClient) CorpusStructure(ctx context.Context) ([]LegalActStructure, error) {
var all []qdrantScrollPoint
for _, coll := range legalStructureCollections {
pts, err := c.scrollLegalCorpus(ctx, coll)
if err != nil {
continue
}
all = append(all, pts...)
}
return aggregateStructure(all), nil
}
// aggregateStructure counts distinct article labels per (regulation, scope).
// Pure → unit-testable without a vector store.
func aggregateStructure(points []qdrantScrollPoint) []LegalActStructure {
distinct := map[string]map[string]map[string]struct{}{}
names := map[string]string{}
chunks := map[string]int{}
order := []string{}
for _, pt := range points {
reg := getString(pt.Payload, "regulation_short")
if reg == "" {
continue
}
if _, seen := names[reg]; !seen {
name := getString(pt.Payload, "regulation_name_de")
if name == "" {
name = reg
}
names[reg] = name
distinct[reg] = map[string]map[string]struct{}{}
order = append(order, reg)
}
chunks[reg]++
bucket, ok := chunkScopeBucket[getString(pt.Payload, "chunk_scope")]
article := getString(pt.Payload, "article")
if !ok || article == "" {
continue
}
if distinct[reg][bucket] == nil {
distinct[reg][bucket] = map[string]struct{}{}
}
distinct[reg][bucket][article] = struct{}{}
}
out := make([]LegalActStructure, 0, len(order))
for _, reg := range order {
out = append(out, LegalActStructure{
RegulationShort: reg,
RegulationName: names[reg],
Articles: len(distinct[reg]["articles"]),
Annexes: len(distinct[reg]["annexes"]),
Recitals: len(distinct[reg]["recitals"]),
Chunks: chunks[reg],
})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Articles != out[j].Articles {
return out[i].Articles > out[j].Articles
}
return out[i].RegulationShort < out[j].RegulationShort
})
return out
}
// scrollLegalCorpus pages through one collection, filtered to the eur-lex legal
// corpus, returning minimal-payload points (no text/vectors).
func (c *LegalRAGClient) scrollLegalCorpus(ctx context.Context, collection string) ([]qdrantScrollPoint, error) {
var all []qdrantScrollPoint
var offset interface{}
for {
points, next, err := c.scrollLegalPage(ctx, collection, offset)
if err != nil {
return nil, err
}
all = append(all, points...)
if next == nil {
break
}
offset = next
}
return all, nil
}
// scrollLegalPage fetches one page of the filtered scroll and returns the
// points plus the next-page offset (nil when exhausted).
func (c *LegalRAGClient) scrollLegalPage(ctx context.Context, collection string, offset interface{}) ([]qdrantScrollPoint, interface{}, error) {
reqBody := map[string]interface{}{
"limit": 500,
"with_payload": map[string]interface{}{"include": []string{"regulation_short", "regulation_name_de", "chunk_scope", "article"}},
"with_vectors": false,
"filter": map[string]interface{}{
"must": []map[string]interface{}{
{"key": "source", "match": map[string]interface{}{"value": eurlexSource}},
},
},
}
if offset != nil {
reqBody["offset"] = offset
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, nil, err
}
url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.qdrantAPIKey != "" {
req.Header.Set("api-key", c.qdrantAPIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, nil, fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body))
}
var scrollResp qdrantScrollResponse
if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil {
return nil, nil, err
}
return scrollResp.Result.Points, scrollResp.Result.NextPageOffset, nil
}
@@ -0,0 +1,50 @@
package ucca
import "testing"
func structPoint(reg, name, scope, article string) qdrantScrollPoint {
return qdrantScrollPoint{Payload: map[string]interface{}{
"regulation_short": reg,
"regulation_name_de": name,
"chunk_scope": scope,
"article": article,
}}
}
func TestAggregateStructure_CountsDistinctPerScope(t *testing.T) {
points := []qdrantScrollPoint{
structPoint("CRA", "Cyber Resilience Act", "section", "13"),
structPoint("CRA", "Cyber Resilience Act", "section", "13"), // duplicate article → still 1
structPoint("CRA", "Cyber Resilience Act", "section", "14"),
structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-I"),
structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-VII"),
structPoint("DORA", "", "section", "6"), // first sighting has no name →
structPoint("DORA", "", "section", "19"), // regulation_name falls back to short
structPoint("DORA", "", "recital", ""), // empty article → ignored for distinct
structPoint("", "x", "section", "1"), // missing regulation → skipped entirely
}
got := aggregateStructure(points)
if len(got) != 2 {
t.Fatalf("want 2 acts, got %d (%+v)", len(got), got)
}
// CRA has more articles → sorts first.
cra := got[0]
if cra.RegulationShort != "CRA" || cra.Articles != 2 || cra.Annexes != 2 || cra.Recitals != 0 || cra.Chunks != 5 {
t.Errorf("CRA wrong: %+v", cra)
}
dora := got[1]
if dora.RegulationShort != "DORA" || dora.Articles != 2 || dora.Chunks != 3 {
t.Errorf("DORA wrong: %+v", dora)
}
if dora.RegulationName != "DORA" {
t.Errorf("DORA name fallback failed: %q", dora.RegulationName)
}
}
func TestAggregateStructure_Empty(t *testing.T) {
if got := aggregateStructure(nil); len(got) != 0 {
t.Errorf("want empty, got %+v", got)
}
}
@@ -0,0 +1,134 @@
package ucca
import (
"fmt"
"strings"
)
const (
assessConnectedCap = 12 // cap connected norms surfaced in the assessment
assessCrossRegimeTopN = 5 // window over which "cross regime" is judged
assessReviewMargin = 0.05 // a tighter winner gap → recommend human review
)
// Assess builds the auditable explanation layer over a ranked result set:
// primary norm, the norms it connects to (citation graph), cross-regime, a
// human-review flag, the winner margin and a short reasoning string. Pure →
// unit-testable. It EXPLAINS the ranking, it does not change it. Returns nil for
// an empty result set.
func Assess(results []LegalSearchResult) *LegalAssessment {
if len(results) == 0 {
return nil
}
// Norm-level view: collapse multiple chunks of the same article/annex so the
// margin and cross-regime are judged between DISTINCT norms, not near-identical
// chunks of one norm (which would make every winner margin ~0).
norms := distinctNorms(results)
p := norms[0]
primary := primaryLabel(p)
connected := dedupStrings(p.ReferencesOut, p.ReferencesIn, p.CitationUnit)
if len(connected) > assessConnectedCap {
connected = connected[:assessConnectedCap]
}
window := norms
if len(window) > assessCrossRegimeTopN {
window = window[:assessCrossRegimeTopN]
}
regimes := make(map[string]bool)
for _, r := range window {
if r.RegulationShort != "" {
regimes[r.RegulationShort] = true
}
}
crossRegime := len(regimes) > 1
margin := 0.0
if len(norms) > 1 {
margin = norms[0].Score - norms[1].Score
}
primaryBinding := p.SourceClass == "binding_law"
humanReview := margin < assessReviewMargin || crossRegime || !primaryBinding
return &LegalAssessment{
PrimaryNorm: primary,
PrimaryRegulation: p.RegulationShort,
ConnectedNorms: connected,
CrossRegime: crossRegime,
HumanReviewFlag: humanReview,
WinnerMargin: margin,
ScoreReasoning: assessReasoning(p, margin, crossRegime, primaryBinding),
}
}
func primaryLabel(p LegalSearchResult) string {
if p.CitationUnit != "" {
return p.CitationUnit
}
if p.ArticleLabel != "" {
return p.ArticleLabel
}
return strings.TrimSpace(p.RegulationShort + " " + p.Article)
}
// assessReasoning renders a short, human-readable justification (German).
func assessReasoning(p LegalSearchResult, margin float64, crossRegime, primaryBinding bool) string {
label := primaryLabel(p)
parts := make([]string, 0, 4)
if primaryBinding {
parts = append(parts, fmt.Sprintf("Primärtreffer %s: bindendes Recht (Autorität %d).", label, p.AuthorityWeight))
} else {
parts = append(parts, fmt.Sprintf("Primärtreffer %s ist keine bindende Norm (Leitlinie/Standard) — Quelle prüfen.", label))
}
if margin > 0 {
parts = append(parts, fmt.Sprintf("Vorsprung %.2f vor #2.", margin))
}
if margin < assessReviewMargin {
parts = append(parts, "Knapper Vorsprung — Alternativtreffer prüfen.")
}
if crossRegime {
parts = append(parts, "Mehrere Regime betroffen — Querbezug prüfen.")
}
return strings.Join(parts, " ")
}
// distinctNorms collapses results that share a citation (multiple chunks of the
// same article/annex) to the first — i.e. highest-ranked — occurrence. Results
// without any citation identity are each kept, since they cannot be matched.
func distinctNorms(results []LegalSearchResult) []LegalSearchResult {
seen := make(map[string]bool, len(results))
out := make([]LegalSearchResult, 0, len(results))
for _, r := range results {
key := r.CitationUnit
if key == "" {
key = r.ArticleLabel
}
if key != "" {
if seen[key] {
continue
}
seen[key] = true
}
out = append(out, r)
}
return out
}
// dedupStrings concatenates out+in, drops empties and the excluded value, and
// returns a stable de-duplicated slice (insertion order preserved).
func dedupStrings(out, in []string, exclude string) []string {
seen := map[string]bool{exclude: true}
res := make([]string, 0, len(out)+len(in))
for _, list := range [][]string{out, in} {
for _, s := range list {
if s == "" || seen[s] {
continue
}
seen[s] = true
res = append(res, s)
}
}
return res
}
@@ -0,0 +1,112 @@
package ucca
import "testing"
func ares(reg, cu, sc string, score float64, weight int, out, in []string) LegalSearchResult {
return LegalSearchResult{
RegulationShort: reg, CitationUnit: cu, SourceClass: sc, Score: score,
AuthorityWeight: weight, ReferencesOut: out, ReferencesIn: in,
}
}
func TestAssess_Empty(t *testing.T) {
if Assess(nil) != nil {
t.Error("empty results → nil assessment")
}
}
func TestAssess_BindingPrimary_NoReview(t *testing.T) {
results := []LegalSearchResult{
ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100,
[]string{"CRA Anhang I", "Art. 14 CRA"}, []string{"Art. 12 CRA"}),
ares("CRA", "Art. 14 CRA", "binding_law", 0.80, 100, nil, nil),
}
a := Assess(results)
if a == nil {
t.Fatal("nil assessment")
}
if a.PrimaryNorm != "Art. 13 CRA" || a.PrimaryRegulation != "CRA" {
t.Errorf("primary wrong: %+v", a)
}
if len(a.ConnectedNorms) != 3 { // out(2) + in(1), self excluded, deduped
t.Errorf("connected norms: %v", a.ConnectedNorms)
}
if a.CrossRegime {
t.Error("single regime must not be cross-regime")
}
if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 {
t.Errorf("margin = %v, want ~0.25", a.WinnerMargin)
}
if a.HumanReviewFlag {
t.Error("clean binding + healthy margin + single regime → no review")
}
}
func TestAssess_CrossRegimeFlagsReview(t *testing.T) {
a := Assess([]LegalSearchResult{
ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100, nil, nil),
ares("DORA", "Art. 6 DORA", "binding_law", 0.70, 100, nil, nil),
})
if !a.CrossRegime || !a.HumanReviewFlag {
t.Errorf("cross-regime must flag review: %+v", a)
}
}
func TestAssess_NonBindingFlagsReview(t *testing.T) {
a := Assess([]LegalSearchResult{
ares("ENISA", "ENISA SBOM", "supervisory_guidance", 0.90, 70, nil, nil),
ares("ENISA", "ENISA X", "supervisory_guidance", 0.40, 70, nil, nil),
})
if !a.HumanReviewFlag {
t.Error("non-binding primary → review")
}
}
func TestAssess_TightMarginFlagsReview(t *testing.T) {
a := Assess([]LegalSearchResult{
ares("CRA", "Art. 13 CRA", "binding_law", 1.00, 100, nil, nil),
ares("CRA", "Art. 14 CRA", "binding_law", 0.98, 100, nil, nil),
})
if a.WinnerMargin >= 0.05 || !a.HumanReviewFlag {
t.Errorf("tight margin → review: %+v", a)
}
}
func TestAssess_MarginIsNormLevelNotChunkLevel(t *testing.T) {
// Two near-identical chunks of the SAME norm at the top, then a distinct norm.
results := []LegalSearchResult{
ares("CRA", "Art. 13 CRA", "binding_law", 1.050, 100, []string{"CRA Anhang I"}, nil),
ares("CRA", "Art. 13 CRA", "binding_law", 1.049, 100, nil, nil), // same norm
ares("CRA", "Art. 14 CRA", "binding_law", 0.800, 100, nil, nil),
}
a := Assess(results)
if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 { // Art.13 vs Art.14, not chunk vs chunk
t.Errorf("margin must be norm-level (~0.25), got %v", a.WinnerMargin)
}
if a.HumanReviewFlag {
t.Error("healthy norm-level margin → no review")
}
}
func TestDistinctNorms(t *testing.T) {
got := distinctNorms([]LegalSearchResult{
{CitationUnit: "Art. 13 CRA"},
{CitationUnit: "Art. 13 CRA"}, // duplicate norm → collapsed
{CitationUnit: "Art. 14 CRA"},
{CitationUnit: ""}, // no identity → kept
{CitationUnit: ""}, // no identity → kept
})
if len(got) != 4 {
t.Errorf("want 4 (2 distinct + 2 unidentified), got %d", len(got))
}
}
func TestDedupStrings(t *testing.T) {
got := dedupStrings([]string{"a", "b", "", "a"}, []string{"b", "c"}, "self")
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Errorf("dedup: %v", got)
}
if len(dedupStrings([]string{"self"}, nil, "self")) != 0 {
t.Error("excluded value must be dropped")
}
}
@@ -20,6 +20,7 @@ type LegalRAGClient struct {
httpClient *http.Client
textIndexEnsured map[string]bool
hybridEnabled bool
graphEnabled bool
}
// NewLegalRAGClient creates a new Legal RAG client using Ollama bge-m3 embeddings.
@@ -38,6 +39,11 @@ func NewLegalRAGClient() *LegalRAGClient {
}
hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false"
// Graph-Expansion ist OPT-IN: kein gemessener Rang-Nutzen ggue. der Binding-Augmentation,
// +1 Qdrant-Call/Suche, Flutungsrisiko ueber Reverse-Kanten. Bleibt als Recall-Sicherheitsnetz
// fuer spaetere Luecken (RAG_GRAPH_EXPANSION=true). Die Graph-Kanten werden in der Response
// zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default).
graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true"
return &LegalRAGClient{
qdrantURL: qdrantURL,
@@ -47,6 +53,7 @@ func NewLegalRAGClient() *LegalRAGClient {
collection: "bp_compliance_ce",
textIndexEnsured: make(map[string]bool),
hybridEnabled: hybridEnabled,
graphEnabled: graphEnabled,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
@@ -100,6 +107,13 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
hits = mergeDedupHits(hits, bindingHits)
}
// Graph-Augmentation: verbundene Normen (references_out/in) der Top-Hits ueber die
// praezise Zitations-Kante in den Pool ziehen — z.B. Art. 13 CRA zieht Anhang I (die
// eigentliche Pflichtquelle). Pool-Augmentation only; Re-Rank + topK bleiben.
if c.graphEnabled {
hits = c.expandViaGraph(ctx, collection, hits)
}
results := make([]LegalSearchResult, len(hits))
for i, hit := range hits {
// Legal-Metadaten nach rag_reingest_spec.md §2: bevorzugt die normalisierten Felder
@@ -131,6 +145,10 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
AuthorityWeight: getInt(hit.Payload, "authority_weight"),
SourceClass: getString(hit.Payload, "source_class"),
Jurisdiction: getString(hit.Payload, "jurisdiction"),
CitationUnit: getString(hit.Payload, "citation_unit"),
ReferencesOut: getStringSlice(hit.Payload, "references_out"),
ReferencesIn: getStringSlice(hit.Payload, "references_in"),
Superseded: getString(hit.Payload, "status") == "superseded",
}
}
@@ -0,0 +1,162 @@
package ucca
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
)
// Graph-augmented retrieval: when a top hit cites an annex/article (references_out)
// or is cited by one (references_in), pull that connected norm into the candidate
// pool via the PRECISE citation graph instead of hoping semantic search surfaces
// it. E.g. a hit on CRA Art. 13 pulls in CRA Anhang I (the actual requirement).
// Pool-augmentation only — authority re-rank + topK slice still apply, so the
// response schema is unchanged.
const (
graphSeedCount = 5 // only the top hits seed the expansion
graphMaxExpand = 15 // cap connected norms pulled in (avoid pool explosion)
graphHopPenalty = 0.05 // a one-hop neighbour ranks just below its seed
)
// expandViaGraph augments hits with the norms they cite and the norms that cite
// them. Best-effort: on any error (or nothing to expand) the original hits are
// returned unchanged.
func (c *LegalRAGClient) expandViaGraph(ctx context.Context, collection string, hits []qdrantSearchHit) []qdrantSearchHit {
if len(hits) == 0 {
return hits
}
present := make(map[string]bool, len(hits))
for _, h := range hits {
if cu := getString(h.Payload, "citation_unit"); cu != "" {
present[cu] = true
}
}
seeds := hits
if len(seeds) > graphSeedCount {
seeds = seeds[:graphSeedCount]
}
// Forward edges only (references_out = the detail a hit explicitly points to,
// e.g. Art. 13 → Anhang I). Reverse (references_in) has high fan-out for popular
// annexes (Anhang I is cited by 23 articles) → pool flooding; it is surfaced as
// connected-norm metadata in the Phase 2 response instead of expanding the pool.
want := make(map[string]float64) // connected citation_unit -> best seeding score
for _, h := range seeds {
for _, cu := range getStringSlice(h.Payload, "references_out") {
if cu == "" || present[cu] {
continue
}
if s, ok := want[cu]; !ok || h.Score > s {
want[cu] = h.Score
}
}
}
if len(want) == 0 {
return hits
}
units := topByScore(want, graphMaxExpand)
fetched, err := c.fetchByCitationUnits(ctx, collection, units)
if err != nil || len(fetched) == 0 {
return hits
}
neighbours := make([]qdrantSearchHit, 0, len(fetched))
for cu, pt := range fetched {
neighbours = append(neighbours, qdrantSearchHit{ID: pt.ID, Score: want[cu] - graphHopPenalty, Payload: pt.Payload})
}
return mergeDedupHits(hits, neighbours)
}
// topByScore returns up to n keys with the highest values. Deterministic: ties
// broken by the key string so the cap is stable across runs.
func topByScore(m map[string]float64, n int) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if m[keys[i]] != m[keys[j]] {
return m[keys[i]] > m[keys[j]]
}
return keys[i] < keys[j]
})
if len(keys) > n {
keys = keys[:n]
}
return keys
}
// fetchByCitationUnits loads one representative point (the first chunk) per
// citation_unit from the given collection.
func (c *LegalRAGClient) fetchByCitationUnits(ctx context.Context, collection string, units []string) (map[string]qdrantScrollPoint, error) {
should := make([]map[string]interface{}, 0, len(units))
for _, cu := range units {
should = append(should, map[string]interface{}{"key": "citation_unit", "match": map[string]interface{}{"value": cu}})
}
reqBody := map[string]interface{}{
"limit": len(units) * 4,
"with_payload": true,
"with_vectors": false,
"filter": map[string]interface{}{"should": should},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.qdrantAPIKey != "" {
req.Header.Set("api-key", c.qdrantAPIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("qdrant scroll returned %d: %s", resp.StatusCode, string(body))
}
var scrollResp qdrantScrollResponse
if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil {
return nil, err
}
out := make(map[string]qdrantScrollPoint, len(units))
for _, pt := range scrollResp.Result.Points {
cu := getString(pt.Payload, "citation_unit")
if cu != "" {
if _, seen := out[cu]; !seen {
out[cu] = pt
}
}
}
return out, nil
}
// getStringSlice extracts a []string from a Qdrant payload list field
// (references_out / references_in are stored as JSON arrays of strings).
func getStringSlice(m map[string]interface{}, key string) []string {
v, ok := m[key]
if !ok {
return nil
}
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
@@ -0,0 +1,89 @@
package ucca
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetStringSlice(t *testing.T) {
m := map[string]interface{}{
"refs": []interface{}{"a", "b", 3, "c"}, // non-strings are skipped
"str": "not-a-list",
}
got := getStringSlice(m, "refs")
if len(got) != 3 || got[0] != "a" || got[2] != "c" {
t.Errorf("refs: %v", got)
}
if getStringSlice(m, "missing") != nil {
t.Error("missing key should be nil")
}
if getStringSlice(m, "str") != nil {
t.Error("non-list should be nil")
}
}
func TestTopByScore_DeterministicCap(t *testing.T) {
m := map[string]float64{"x": 0.5, "y": 0.9, "z": 0.5, "w": 0.7}
got := topByScore(m, 2)
if len(got) != 2 || got[0] != "y" || got[1] != "w" {
t.Errorf("want [y w], got %v", got)
}
all := topByScore(m, 10)
if all[2] != "x" || all[3] != "z" { // tie 0.5 broken by key string
t.Errorf("tie-break not deterministic: %v", all)
}
}
func TestExpandViaGraph_NoSeedsOrRefs(t *testing.T) {
c := &LegalRAGClient{} // nil httpClient → must not be called on these paths
if out := c.expandViaGraph(context.Background(), "x", nil); out != nil {
t.Error("empty hits should return nil")
}
hits := []qdrantSearchHit{{ID: 1, Score: 0.8, Payload: map[string]interface{}{"citation_unit": "Art. 1 CRA"}}}
if out := c.expandViaGraph(context.Background(), "x", hits); len(out) != 1 {
t.Errorf("no references → unchanged, got %d", len(out))
}
}
func TestExpandViaGraph_PullsConnectedNorm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"result": map[string]interface{}{
"points": []map[string]interface{}{
{"id": 99, "payload": map[string]interface{}{
"citation_unit": "CRA Anhang I", "chunk_text": "Sicherheitsanforderungen",
"source_class": "binding_law", "authority_weight": 100, "regulation_short": "CRA",
}},
},
"next_page_offset": nil,
},
})
}))
defer srv.Close()
c := &LegalRAGClient{qdrantURL: srv.URL, httpClient: srv.Client()}
hits := []qdrantSearchHit{
{ID: 1, Score: 0.70, Payload: map[string]interface{}{
"citation_unit": "Art. 13 CRA", "references_out": []interface{}{"CRA Anhang I"},
}},
}
out := c.expandViaGraph(context.Background(), "bp_compliance_ce", hits)
if len(out) != 2 {
t.Fatalf("want 2 hits (seed + connected annex), got %d", len(out))
}
var found *qdrantSearchHit
for i := range out {
if getString(out[i].Payload, "citation_unit") == "CRA Anhang I" {
found = &out[i]
}
}
if found == nil {
t.Fatal("connected norm CRA Anhang I was not pulled into the pool")
}
if found.Score < 0.64 || found.Score > 0.66 { // 0.70 seed 0.05 hop penalty
t.Errorf("connected score = %v, want ~0.65", found.Score)
}
}
@@ -0,0 +1,72 @@
package ucca
import "testing"
func intentRes(reg, sourceClass string, sem float64, weight int) LegalSearchResult {
return LegalSearchResult{
RegulationShort: reg, SourceClass: sourceClass, Score: sem,
AuthorityWeight: weight, Jurisdiction: "EU",
}
}
func TestQueryWantsGuidance(t *testing.T) {
wants := []string{
"Was empfiehlt der EDPB zum DSB?",
"Was sagt die ENISA zu Security Updates?",
"laut DSK ...",
"Orientierungshilfe zur DSFA",
"Welche BSI-Empfehlung gilt?",
"Auslegung der Aufsichtsbehörde",
}
plain := []string{
"Ab wann braucht man einen Datenschutzbeauftragten?",
"Welche Anforderungen bestehen an Security Updates?",
}
for _, q := range wants {
if !queryWantsGuidance(q) {
t.Errorf("should detect interpretation intent: %q", q)
}
}
for _, q := range plain {
if queryWantsGuidance(q) {
t.Errorf("should NOT detect intent (norm question): %q", q)
}
}
}
func TestRerank_NormQuestion_BindingStaysTop(t *testing.T) {
// No intent signal → binding wins even though guidance is semantically higher.
results := []LegalSearchResult{
intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70),
intentRes("DSGVO", "binding_law", 0.58, 100),
}
out := rerankByAuthority("Ab wann braucht man einen Datenschutzbeauftragten?", results)
if out[0].SourceClass != "binding_law" {
t.Errorf("norm question: binding must stay Top-1, got %s", out[0].SourceClass)
}
}
func TestRerank_InterpretationQuestion_GuidanceMayWin(t *testing.T) {
// Explicit intent + guidance semantically competitive → guidance wins.
results := []LegalSearchResult{
intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70),
intentRes("DSGVO", "binding_law", 0.58, 100),
}
out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results)
if out[0].SourceClass != "supervisory_guidance" {
t.Errorf("interpretation question: guidance should win Top-1, got %s", out[0].SourceClass)
}
}
func TestRerank_OffTopicGuidance_BlockedByGuard(t *testing.T) {
// Intent present, but guidance semantic is far below the best binding hit →
// the margin guard keeps binding on top (no off-topic guideline override).
results := []LegalSearchResult{
intentRes("EDPB DPO", "supervisory_guidance", 0.40, 70),
intentRes("DSGVO", "binding_law", 0.58, 100),
}
out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results)
if out[0].SourceClass != "binding_law" {
t.Errorf("off-topic guidance must not win even with intent, got %s", out[0].SourceClass)
}
}
@@ -0,0 +1,30 @@
package ucca
import "testing"
// A superseded alt-source must rank below the same result when it is NOT
// superseded (the eu-v1 norm), but only demoted — the penalty is finite, so it
// stays in the pool and remains findable for history/transition questions.
func TestAuthorityScore_SupersededIsDemotedNotRemoved(t *testing.T) {
fresh := LegalSearchResult{
Score: 0.65, SourceClass: "binding_law", AuthorityWeight: 100,
Jurisdiction: "EU", RegulationShort: "CRA", Article: "13",
}
old := fresh
old.Superseded = true
sFresh := authorityScore("CRA Sicherheitsupdates Hersteller", fresh, "", false)
sOld := authorityScore("CRA Sicherheitsupdates Hersteller", old, "", false)
if sOld >= sFresh {
t.Errorf("superseded must score lower: fresh=%.3f superseded=%.3f", sFresh, sOld)
}
gap := sFresh - sOld
if gap < supersededPenalty-0.001 || gap > supersededPenalty+0.001 {
t.Errorf("demotion should equal supersededPenalty (%.2f), got %.3f", supersededPenalty, gap)
}
// Still a positive, finite score → present in the pool, not hidden.
if sOld <= -1 {
t.Errorf("superseded score collapsed (%.3f) — must remain findable", sOld)
}
}
@@ -27,6 +27,31 @@ type LegalSearchResult struct {
AuthorityWeight int `json:"-"`
SourceClass string `json:"-"`
Jurisdiction string `json:"-"`
// Zitations-Graph (Phase 2) — intern, speist nur die Assessment-Berechnung
// (verbundene Normen, Begruendung). Pro-Result-Schema bleibt eingefroren.
CitationUnit string `json:"-"`
ReferencesOut []string `json:"-"`
ReferencesIn []string `json:"-"`
// Supersede-Status (status="superseded", use_for_primary=false) — Alt-Quelle,
// die fuer Default-Fragen demoted wird (nicht versteckt; fuer Historie auffindbar).
Superseded bool `json:"-"`
}
// LegalAssessment is the auditable explanation layer over a ranked result set:
// which norm is primary, which norms connect to it via the citation graph,
// whether the answer crosses regulatory regimes, and whether a human should
// review. Computed from the already-ranked results — it EXPLAINS retrieval, it
// does not change it (graph edges for reasoning/completeness, not pool-expansion).
type LegalAssessment struct {
PrimaryNorm string `json:"primary_norm"`
PrimaryRegulation string `json:"primary_regulation"`
ConnectedNorms []string `json:"connected_norms"`
CrossRegime bool `json:"cross_regime"`
HumanReviewFlag bool `json:"human_review_flag"`
WinnerMargin float64 `json:"winner_margin"`
ScoreReasoning string `json:"score_reasoning"`
}
// LegalContext represents aggregated legal context for an assessment.