feat(litigationplanner): multi-axis catalog query API (Slice B2, m/paliad#124 §18.2)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

New Catalog.LookupEvents(ctx, axes, depth) method exposes a unified
graph query over paliad.deadline_rules + paliad.proceeding_types + the
deadline_concept_event_types junction. Used by the Determinator
cascade, the scenarios surface (Slice D), and any future "show me
events matching X" query — centralises a fan-out that today is
duplicated across multiple client-side paths.

Package additions (pkg/litigationplanner):
  - EventLookupAxes: optional Jurisdiction / *ProceedingTypeID / Party
    / *EventCategoryID / AppealTarget. All fields optional; the empty
    value (or nil pointer) is "no filter on this axis". Multiple
    non-zero axes apply as AND.
  - EventLookupDepth: "next" (1 hop downstream) or "all-following"
    (full chain).
  - EventMatch: Rule + ProceedingType + Priority + DepthFromAnchor +
    *ParentRuleID (populated only when the parent itself is in the
    returned set, so the frontend can render a tree).
  - Catalog interface gains LookupEvents.

paliad-side implementation (internal/services/fristenrechner.go):
  - SQL pass with progressively-built WHERE clauses (one $N
    placeholder per non-zero axis). EventCategoryID uses an EXISTS
    subquery against paliad.event_category_concepts joined via
    concept_id.
  - Post-fetch parent_id graph walk in Go for depth control. Loads
    the per-proceeding rule corpus via DeadlineRuleService.List so
    children whose parent_id is in the anchor set can be added even
    when those children don't match the axes themselves. AllFollowing
    iterates to fixpoint; Next stops after one pass.
  - DepthFromAnchor computed by walking each result row up the
    parent_id chain until it hits an anchor (iteration-bounded to
    prevent infinite loops on hypothetical cycles).
  - Unknown axis values (jurisdiction="XX", party="foo",
    appealTarget="invalid") silently fall through as "no filter on
    this axis" — a stale frontend chip should not drop the entire
    result set.
  - "published + active" gate (lifecycle_state='published' AND
    is_active=true) matches LoadProceeding's WHERE clause.
  - Results ordered by (proceeding_type_id, sequence_order) so the
    frontend can render without re-sorting.

Tests (internal/services/lookup_events_test.go):
  - Live-DB driven (skipped without TEST_DATABASE_URL, matches the
    existing TestCalculateRule pattern).
  - Cases: UPC-jurisdiction returns the UPC corpus only;
    party=defendant scopes anchor matches to defendant rules;
    unknown jurisdiction falls through; appeal_target=endentscheidung
    returns the merits rules from B1 mig 134;
    appeal_target=schadensbemessung returns empty (no rules seeded).

No schema delta. No frontend wiring (the new HTTP endpoint at
GET /api/tools/lookup-events can land in a follow-up slice — the
package + paliad-side impl are the deliverable here).
This commit is contained in:
mAi
2026-05-26 13:54:57 +02:00
parent 426b90bb88
commit d5bf82314a
4 changed files with 545 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// TestLookupEvents covers the multi-axis catalog query API from Slice
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
// mirroring TestCalculateRule.
//
// Cases:
// - jurisdiction=UPC, depth=all-following → every active+published
// UPC rule, anchor depth=1 for all (no parent_id outside the
// filtered set lights up depth>1 because the entire UPC subset is
// a single anchor cohort).
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
// → defendant rules in upc.inf.cfi at depth=1 + direct children
// of those at depth=2.
// - unknown jurisdiction value → silently ignored, no filter applied.
// - empty axes → all rules (no filter on any axis).
func TestLookupEvents(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
catalog := &paliadCatalog{rules: rules}
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected non-empty UPC corpus")
}
// Every match must be a UPC rule.
for _, m := range matches {
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
}
if m.DepthFromAnchor < 1 {
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
}
}
})
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
Party: "defendant",
}, lp.EventLookupDepthNext)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected at least one defendant rule across the UPC corpus")
}
// Anchor matches (depth=1) must be primary_party=defendant.
// Depth=2 children appear under EventLookupDepthNext only as
// expansion from anchors — they may carry any party.
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
m.Rule.Name, m.Rule.PrimaryParty)
}
}
})
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (all): %v", err)
}
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "XX-not-a-real-jurisdiction",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (unknown): %v", err)
}
if len(matchesAll) != len(matchesUnknown) {
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
len(matchesUnknown), len(matchesAll))
}
})
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetEndentscheidung,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// Should hit the 7 rules under the unified upc.apl that
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
if len(matches) == 0 {
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue // children of anchors may be from other targets
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetEndentscheidung {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing endentscheidung target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl" {
t.Errorf("anchor row %s came from %s, want upc.apl",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetSchadensbemessung,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) != 0 {
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
}
})
}