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
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
This commit was merged in pull request #33.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user