989d9f6f91
CI / detect-changes (pull_request) Successful in 14s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 16s
CI / secret-scan (pull_request) Successful in 18s
CI / dep-audit (pull_request) Failing after 1m2s
CI / sbom-scan (pull_request) Failing after 1m10s
CI / build-sha-integrity (pull_request) Successful in 13s
CI / validate-canonical-controls (pull_request) Successful in 14s
CI / loc-budget (pull_request) Successful in 23s
CI / go-lint (pull_request) Successful in 50s
CI / python-lint (pull_request) Failing after 18s
CI / nodejs-lint (pull_request) Failing after 1m8s
CI / nodejs-build (pull_request) Successful in 3m7s
CI / test-go (pull_request) Successful in 1m6s
CI / iace-gt-coverage (pull_request) Successful in 26s
CI / test-python-backend (pull_request) Successful in 33s
CI / test-python-document-crawler (pull_request) Successful in 21s
CI / test-python-dsms-gateway (pull_request) Successful in 21s
Add an `assessment` object to the legal RAG search response: primary norm, connected norms (from the citation graph references_out/in of the primary), cross_regime, human_review_flag, a norm-level winner_margin and a short reasoning string. The margin is computed over DISTINCT norms, so a long article split into several chunks no longer fabricates uncertainty. The per-result schema stays frozen — graph fields are internal (json:"-"). Also wire optional citation-graph expansion (RAG_GRAPH_EXPANSION=true, default off): top hits pull their referenced norms into the candidate pool via the precise edge (e.g. Art. 13 CRA -> Anhang I). Measured to add no rank gain over the existing binding-law augmentation, with +1 Qdrant call per search and reverse-edge fan-out risk, so it ships off-by-default as a recall safety net. The graph EXPLAINS retrieval (assessment), it does not expand it by default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
2.8 KiB
Go
90 lines
2.8 KiB
Go
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)
|
||
}
|
||
}
|