Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.
Package contents (~1850 LoC):
- doc.go package docstring + reuse manifesto
- types.go Rule, ProceedingType, NullableJSON, AdjustmentReason,
HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
TimelineEntry, RuleCalculation*, FristenrechnerType,
ProjectHint, sentinel errors
- catalog.go Catalog interface (proceeding + rule lookups)
- holidays.go HolidayCalendar interface
- courts.go CourtRegistry interface + DefaultsForJurisdiction +
country/regime constants
- expr.go EvalConditionExpr + HasConditionExpr +
ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go MapLitigationToFristenrechner + code constants
(CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go Calculate + CalculateRule + the trigger-event
branch + applyRuleOverrides (the big move)
paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
(thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
become type aliases to litigationplanner.* — every sqlx scan and
every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
+ BuildLegalSourceURL replaced with delegating wrappers to lp.
Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.
Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.
Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.
Refs: docs/design-litigation-planner-2026-05-26.md
146 lines
4.6 KiB
Go
146 lines
4.6 KiB
Go
package litigationplanner
|
|
|
|
import "encoding/json"
|
|
|
|
// allFlagsSet returns true when every element of `required` is present in
|
|
// `set`. Empty `required` returns true (no condition). Retained as the
|
|
// fallback predicate used by EvalConditionExpr when condition_expr is
|
|
// NULL but the legacy condition_flag text[] is set — preserves
|
|
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
|
// but defensive).
|
|
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
|
for _, f := range required {
|
|
if _, ok := set[f]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// EvalConditionExpr returns true iff the rule's gate predicate is
|
|
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
|
// + flag-conditional alt-swap throughout the calculator.
|
|
//
|
|
// Grammar (design §2.4 long form, mig 084 backfill):
|
|
//
|
|
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
|
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
|
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
|
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
|
//
|
|
// NULL / empty / "null" expression → true (unconditional). Malformed
|
|
// JSON → true (defensive: the rule still renders, the lawyer sees
|
|
// it even if the gate is broken).
|
|
//
|
|
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
|
// text[] column; the fallback that AND'd over it is gone. Any future
|
|
// row needing array-of-flags semantics writes the equivalent
|
|
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
|
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
|
if len(expr) == 0 || string(expr) == "null" {
|
|
return true
|
|
}
|
|
return EvalConditionExprNode(expr, flags)
|
|
}
|
|
|
|
// EvalConditionExprNode walks one node of the condition_expr jsonb
|
|
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
|
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
|
// 2-arg AND (mig 084).
|
|
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
|
var node struct {
|
|
Flag string `json:"flag"`
|
|
Op string `json:"op"`
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
if err := json.Unmarshal(raw, &node); err != nil {
|
|
// Malformed → unconditional. The Slice 11 editor's validation
|
|
// will block such writes; in the live corpus today mig 084's
|
|
// jsonb_build_object output is well-formed by construction.
|
|
return true
|
|
}
|
|
if node.Flag != "" {
|
|
_, ok := flags[node.Flag]
|
|
return ok
|
|
}
|
|
switch node.Op {
|
|
case "and":
|
|
for _, a := range node.Args {
|
|
if !EvalConditionExprNode(a, flags) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case "or":
|
|
for _, a := range node.Args {
|
|
if EvalConditionExprNode(a, flags) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "not":
|
|
if len(node.Args) != 1 {
|
|
// Malformed NOT — fall through to unconditional rather
|
|
// than risk suppressing a rule the lawyer expects to see.
|
|
return true
|
|
}
|
|
return !EvalConditionExprNode(node.Args[0], flags)
|
|
}
|
|
// Unknown op (forward-compat with editor extensions): treat as
|
|
// unconditional so the rule still renders.
|
|
return true
|
|
}
|
|
|
|
// HasConditionExpr returns true when the rule carries a non-empty,
|
|
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
|
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
|
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
|
// when the gate flips to met, swap to alt".
|
|
func HasConditionExpr(expr NullableJSON) bool {
|
|
if len(expr) == 0 {
|
|
return false
|
|
}
|
|
s := string(expr)
|
|
return s != "null" && s != "{}"
|
|
}
|
|
|
|
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
|
|
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
|
// CalculateRule's response (FlagsRequired) so the result-card calc
|
|
// panel can render flag checkboxes for each gate input. Replaces the
|
|
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
|
// expression or one that contains no flag leaves.
|
|
func ExtractFlagsFromExpr(expr NullableJSON) []string {
|
|
if !HasConditionExpr(expr) {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
walkFlagLeaves([]byte(expr), seen)
|
|
if len(seen) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for f := range seen {
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
|
var node struct {
|
|
Flag string `json:"flag"`
|
|
Op string `json:"op"`
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
if err := json.Unmarshal(raw, &node); err != nil {
|
|
return
|
|
}
|
|
if node.Flag != "" {
|
|
into[node.Flag] = struct{}{}
|
|
return
|
|
}
|
|
for _, a := range node.Args {
|
|
walkFlagLeaves(a, into)
|
|
}
|
|
}
|