Files
paliad/internal/services/deadline_search_service.go
mAi 24f3baf61f mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
2026-05-25 15:36:08 +02:00

1062 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// DeadlineSearchService backs the unified Fristenrechner search bar
// (t-paliad-131 Phase C). It reads from the paliad.deadline_search
// materialised view (migration 047) and groups hits per concept, so a
// single search returns one card per legal idea (Klageerwiderung,
// Wiedereinsetzung, …) with one pill per (proceeding × rule) or per
// trigger event under that concept.
//
// Two queries per request:
// 1. Rank concept_ids by trigram similarity against name / aliases /
// legal_source / rule_code, applying optional party / proc / source
// filters.
// 2. Fetch all matview rows for those concept_ids and assemble the
// per-pill payload.
//
// v3 (t-paliad-133) extends the service to accept:
// - EventCategorySlug: drives the B1 decision-tree narrowing. When
// set, only concepts reachable from that taxonomy node (via the
// paliad.event_category_concepts junction) appear in results.
// An empty `q` is permitted when EventCategorySlug is set — the
// tree alone is enough to produce a candidate concept set.
// - Forums: a list of forum slugs from the v3 bucket map. Translated
// to proceeding_type_codes by the search service; trigger-event
// pills carry a structured legal_source citation (via mig 123)
// and narrow by the per-forum legal-source prefix set instead of
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
// 123 trigger pills bypassed the forum filter unconditionally;
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
// to narrow with the active court-system chip.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
type DeadlineSearchService struct {
db *sqlx.DB
eventCategory *EventCategoryService
}
// NewDeadlineSearchService wires the service to its DB pool. The
// EventCategoryService dependency is optional — pass nil if the v3
// taxonomy isn't needed (legacy callers).
func NewDeadlineSearchService(db *sqlx.DB) *DeadlineSearchService {
return &DeadlineSearchService{db: db}
}
// SetEventCategoryService injects the optional v3 event-category
// resolver. Wired by main.go after both services exist.
func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService) {
s.eventCategory = ec
}
// ForumToProceedingCodes maps the v3 forum buckets to proceeding_type
// codes. Lives here (rather than in the DB) because the bucket choice
// is presentation, not data — m can rebucket via code change without
// migration. m's spec lock §10 Q8 (2026-05-05): 10 buckets.
//
// Empty bucket slug = no narrowing.
var ForumToProceedingCodes = map[string][]string{
"upc_cfi": {CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim, CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery, CodeUPCAppealOrder},
"upc_coa": {CodeUPCAppealMerits, CodeUPCAppealCost},
"de_lg": {CodeDEInfringementLG},
"de_olg": {CodeDEInfringementOLG},
"de_bgh": {CodeDEInfringementBGH, CodeDENullityBGH, CodeDPMAAppealBGH},
"de_bpatg": {CodeDENullityBPatG, CodeDPMAAppealBPatG},
"epa_grant": {CodeEPAGrant},
"epa_opp": {CodeEPAOpposition},
"epa_appeal": {CodeEPAOppositionAppeal},
"dpma": {CodeDPMAOpposition},
}
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
// - DE BPatG chip → DE.PatG.* (national patent path)
// - DPMA chip → DE.PatG.* (national patent path)
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
"upc_cfi": {"UPC."},
"upc_coa": {"UPC."},
"de_lg": {"DE.ZPO."},
"de_olg": {"DE.ZPO."},
"de_bgh": {"DE.ZPO."},
"de_bpatg": {"DE.PatG."},
"epa_grant": {"EU.EPC", "EU.EPÜ"},
"epa_opp": {"EU.EPC", "EU.EPÜ"},
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
"dpma": {"DE.PatG."},
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
Party string
Proc string
Source string
// v3 (t-paliad-133):
EventCategorySlug string // drives B1 decision-tree narrowing
Forums []string // multi-select forum buckets (UNION within)
// v3 (t-paliad-134): explicit "browse everything" mode for B1 entry,
// before the user has picked any tree node. Returns every concept
// that is mapped to any leaf via paliad.event_category_concepts —
// i.e. the full landscape of B1-reachable concepts. q must be empty
// when BrowseAll is true; ignored otherwise.
BrowseAll bool
Limit int
MaxLimit int
}
// SearchFilters is the filter echo returned to the client. nil pointer
// means the facet wasn't filtered.
type SearchFilters struct {
Party *string `json:"party"`
Proc *string `json:"proc"`
Source *string `json:"source"`
}
// SearchResponse is the JSON the API hands back. See §6.1.
type SearchResponse struct {
Query string `json:"query"`
Filters SearchFilters `json:"filters"`
Cards []ConceptCard `json:"cards"`
TotalCards int `json:"total_cards"`
TotalPills int `json:"total_pills"`
}
// ConceptCard is one search hit — a concept plus its proceeding pills.
type ConceptCard struct {
Concept ConceptSummary `json:"concept"`
MatchedAliases []string `json:"matched_aliases,omitempty"`
Score float64 `json:"score"`
Pills []Pill `json:"pills"`
}
// ConceptSummary is the concept payload inside a card.
type ConceptSummary struct {
ID string `json:"id"`
Slug string `json:"slug"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Description *string `json:"description,omitempty"`
Party *string `json:"party,omitempty"`
Category string `json:"category"`
}
// PillProceeding describes the proceeding context of a rule pill. nil
// for trigger pills (cross-cutting events with no proceeding).
type PillProceeding struct {
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Jurisdiction string `json:"jurisdiction"`
}
// PillDuration is the duration spec of a rule pill. nil for trigger pills.
type PillDuration struct {
Value int `json:"value"`
Unit string `json:"unit"`
Timing *string `json:"timing,omitempty"`
}
// Pill is one row inside a concept card. Either a rule (with proceeding +
// duration) or a trigger (cross-cutting; just a code + name).
type Pill struct {
Kind string `json:"kind"`
RuleID *string `json:"rule_id,omitempty"`
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
Proceeding *PillProceeding `json:"proceeding,omitempty"`
RuleLocalCode string `json:"rule_local_code"`
RuleNameDE string `json:"rule_name_de"`
RuleNameEN string `json:"rule_name_en"`
LegalSource *string `json:"legal_source,omitempty"`
LegalSourceDisplay *string `json:"legal_source_display,omitempty"`
Duration *PillDuration `json:"duration,omitempty"`
Party string `json:"party"`
DrillURL string `json:"drill_url"`
// t-paliad-134: server-assigned ordering hint by real-world
// proceeding frequency. Frontend doesn't need to read this — the
// server already sorts before sending — but it's exposed so the
// frontend can stable-sort if it interleaves cards from multiple
// requests.
ProceedingDisplayOrder int `json:"proceeding_display_order"`
}
// rankRow is the per-concept score row from query 1.
type rankRow struct {
ConceptID string `db:"concept_id"`
Score float64 `db:"score"`
AliasHit bool `db:"alias_hit"`
ConceptSortOrder int `db:"concept_sort_order"`
ConceptNameDE string `db:"concept_name_de"`
MatchedAliases pq.StringArray `db:"matched_aliases"`
}
// pillRow is the per-(concept, context) row from query 2.
type pillRow struct {
Kind string `db:"kind"`
ConceptID string `db:"concept_id"`
ConceptSlug string `db:"concept_slug"`
ConceptNameDE string `db:"concept_name_de"`
ConceptNameEN string `db:"concept_name_en"`
ConceptDesc sql.NullString `db:"concept_description"`
ConceptParty sql.NullString `db:"concept_party"`
ConceptCategory string `db:"concept_category"`
RuleID sql.NullString `db:"rule_id"`
TriggerEventID sql.NullInt64 `db:"trigger_event_id"`
ProceedingCode sql.NullString `db:"proceeding_code"`
ProceedingNameDE sql.NullString `db:"proceeding_name_de"`
ProceedingNameEN sql.NullString `db:"proceeding_name_en"`
Jurisdiction string `db:"jurisdiction"`
// t-paliad-134: pill ordering by real-world frequency. Lower =
// shown first inside each concept card. 9999 for trigger pills
// (no proceeding context).
ProceedingDisplayOrder int `db:"proceeding_display_order"`
RuleLocalCode string `db:"rule_local_code"`
RuleNameDE string `db:"rule_name_de"`
RuleNameEN string `db:"rule_name_en"`
LegalSource sql.NullString `db:"legal_source"`
RuleCode sql.NullString `db:"rule_code"`
DurationValue sql.NullInt32 `db:"duration_value"`
DurationUnit sql.NullString `db:"duration_unit"`
Timing sql.NullString `db:"timing"`
EffectiveParty string `db:"effective_party"`
}
// Search runs the two-query pipeline and assembles the cards.
//
// q is the raw user input. Empty q returns an empty result set UNLESS
// opts.EventCategorySlug is set — that triggers v3 browse-mode where the
// taxonomy alone produces a candidate concept list (used by the B1
// decision-tree cascade in Pathway B).
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
limit := opts.Limit
maxLimit := opts.MaxLimit
// Browse mode (B1: slug-driven or all-mapped) returns the entire
// reachable concept set, which exceeds the trigram-search defaults.
// Lift the ceiling so the user sees the full landscape on entry.
if opts.BrowseAll || opts.EventCategorySlug != "" {
if limit <= 0 {
limit = 200
}
if maxLimit <= 0 {
maxLimit = 500
}
} else {
if limit <= 0 {
limit = 12
}
if maxLimit <= 0 {
maxLimit = 30
}
}
if limit > maxLimit {
limit = maxLimit
}
resp := &SearchResponse{
Query: q,
Filters: buildFilters(opts),
Cards: []ConceptCard{},
}
qNorm := normalizeQuery(q)
browseMode := qNorm == "" && (opts.EventCategorySlug != "" || opts.BrowseAll)
// v4 (t-paliad-136): resolve the event-category slug — or the
// browse-all root — to a (concept_id, proceeding_type_code) tuple
// allow-list. The previous v3 implementation collapsed this to a
// flat concept_id slice and dropped the per-leaf proc constraint,
// which leaked DE/EPA/DPMA pills under "UPC infringement opposing
// party" leaves and similar. Carrying tuples end-to-end fixes the
// bug.
var subtree *subtreeFilter
if opts.EventCategorySlug != "" && s.eventCategory != nil {
outcomes, err := s.eventCategory.ConceptsForSlug(ctx, opts.EventCategorySlug)
if err != nil {
return nil, err
}
if len(outcomes) == 0 {
// Slug resolves to no concepts; return empty without hitting
// the matview.
return resp, nil
}
subtree = newSubtreeFilter(outcomes)
} else if opts.BrowseAll && s.eventCategory != nil {
outcomes, err := s.eventCategory.AllOutcomes(ctx)
if err != nil {
return nil, err
}
if len(outcomes) == 0 {
return resp, nil
}
subtree = newSubtreeFilter(outcomes)
}
// v3: translate forum slugs to proceeding_code allow-list (rule
// pills) and t-paliad-266: parallel legal_source prefix allow-list
// for trigger pills. Empty slice for either axis = no narrowing on
// that pill kind.
forumCodes := translateForums(opts.Forums)
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
if !browseMode && qNorm == "" {
return resp, nil
}
party := nullable(opts.Party)
proc := nullable(opts.Proc)
source := nullable(opts.Source)
var ranks []rankRow
if browseMode {
// Browse mode: synthesize ranks from the allow-list directly.
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
} else {
qLow := strings.ToLower(qNorm)
var err error
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
if err != nil {
return nil, err
}
}
if len(ranks) == 0 {
return resp, nil
}
conceptIDs := make([]string, len(ranks))
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
if err != nil {
return nil, err
}
cards, totalPills := assembleCards(ranks, pills)
resp.Cards = cards
resp.TotalCards = len(cards)
resp.TotalPills = totalPills
return resp, nil
}
// subtreeFilter expresses the per-leaf (concept_id, proceeding_type_code)
// tuple constraint in a form the matview SQL can apply.
//
// Two parallel slices are passed to the SQL via unnest; each row of the
// matview must match at least one tuple to pass through. An empty
// proc_code in the slice ('') means "any proceeding for this concept" —
// it's the encoding of a junction row with proceeding_type_code IS NULL,
// which the seed uses for cross-cutting concepts (Wiedereinsetzung,
// Weiterbehandlung, Versäumnisurteil-Einspruch, Schriftsatznachreichung).
//
// SQL match clause:
//
// EXISTS (
// SELECT 1 FROM unnest($cids::uuid[], $procs::text[]) AS t(cid, pcode)
// WHERE t.cid = s.concept_id
// AND (t.pcode = '' OR t.pcode = s.proceeding_code)
// )
//
// Trigger pills (kind='trigger', proceeding_code IS NULL) only surface
// when their concept appears with proc_code='' in this filter — i.e. a
// junction row with NULL proc. That matches the seed convention.
type subtreeFilter struct {
conceptIDs []string // parallel to ProcCodes
procCodes []string // '' encodes "any proc for this concept"
}
// newSubtreeFilter builds the parallel arrays from a slice of outcomes.
// Dedup: when a concept has both a (c, NULL) row and one or more (c, X)
// rows in the junction, the NULL row subsumes — keep only the unconstrained
// entry to avoid redundant work in unnest.
func newSubtreeFilter(outcomes []ConceptOutcome) *subtreeFilter {
unconstrained := make(map[string]bool, len(outcomes))
for _, o := range outcomes {
if o.ProceedingTypeCode == nil {
unconstrained[o.ConceptID] = true
}
}
seen := make(map[string]bool, len(outcomes))
cids := make([]string, 0, len(outcomes))
procs := make([]string, 0, len(outcomes))
for _, o := range outcomes {
var pc string
if o.ProceedingTypeCode == nil {
pc = ""
} else {
if unconstrained[o.ConceptID] {
continue
}
pc = *o.ProceedingTypeCode
}
key := o.ConceptID + "\x00" + pc
if seen[key] {
continue
}
seen[key] = true
cids = append(cids, o.ConceptID)
procs = append(procs, pc)
}
return &subtreeFilter{conceptIDs: cids, procCodes: procs}
}
// args returns the two slice arguments to pass into the SQL placeholder
// pair, or two nil sentinels when no narrowing applies. Calling it on a
// nil receiver is safe.
func (f *subtreeFilter) args() (any, any) {
if f == nil || len(f.conceptIDs) == 0 {
return nil, nil
}
return pq.Array(f.conceptIDs), pq.Array(f.procCodes)
}
// translateForums maps a list of forum slugs to the union of their
// proceeding_type_codes via ForumToProceedingCodes. Unknown slugs are
// silently dropped.
func translateForums(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
codes, ok := ForumToProceedingCodes[slug]
if !ok {
continue
}
for _, c := range codes {
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
}
return out
}
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
prefixes, ok := ForumToLegalSourcePrefixes[slug]
if !ok {
continue
}
for _, p := range prefixes {
if seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
// that have at least one matching pill.
//
// v4: subtree filter enforces (concept_id, proceeding_code) tuples, not
// concept_id alone — see subtreeFilter doc.
func (s *DeadlineSearchService) browseRanks(
ctx context.Context,
subtree *subtreeFilter,
party, proc, source *string,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) []rankRow {
const sqlText = `
SELECT DISTINCT
s.concept_id,
false AS alias_hit,
1.0 AS score,
s.concept_sort_order,
s.concept_name_de,
ARRAY[]::text[] AS matched_aliases
FROM paliad.deadline_search s
WHERE EXISTS (
SELECT 1 FROM unnest($1::uuid[], $2::text[]) AS t(cid, pcode)
WHERE t.cid = s.concept_id
AND (t.pcode = '' OR t.pcode = s.proceeding_code)
)
AND ($3::text IS NULL OR s.effective_party = $3)
AND ($4::text IS NULL OR s.proceeding_code = $4)
AND ($5::text IS NULL OR s.legal_source LIKE $5 || '%')
AND (
$6::text[] IS NULL
OR cardinality($6::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($6::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
LIMIT $7
`
cidArg, procArg := subtree.args()
var rows []rankRow
if err := s.db.SelectContext(ctx, &rows, sqlText,
cidArg, procArg,
party, proc, source,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
// Browse mode failures degrade to empty (taxonomy-driven UX
// shouldn't crash on a malformed slug); log via the caller.
return nil
}
return rows
}
// nullableArray returns nil for empty input so the SQL `IS NULL OR
// cardinality = 0` short-circuit applies cleanly. pq.Array on a nil
// slice still produces a non-NULL empty array, which doesn't match
// the IS NULL test — hence the explicit nil sentinel.
func nullableArray(s []string) any {
if len(s) == 0 {
return nil
}
return pq.Array(s)
}
func (s *DeadlineSearchService) rankConcepts(
ctx context.Context,
q, qLow string,
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) ([]rankRow, error) {
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
const sqlText = `
WITH matched AS (
SELECT
s.concept_id,
s.concept_sort_order,
s.concept_name_de,
GREATEST(
similarity(s.concept_name_de, $1) * 1.0,
similarity(s.concept_name_en, $1) * 1.0,
COALESCE(similarity(s.legal_source, $1), 0) * 0.9,
COALESCE(similarity(s.rule_code, $1), 0) * 0.9,
similarity(s.rule_name_de, $1) * 0.7,
similarity(s.rule_name_en, $1) * 0.7
) AS row_score,
EXISTS (
SELECT 1 FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
) AS row_alias_hit,
ARRAY(
SELECT DISTINCT a FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
) AS row_matched_aliases
FROM paliad.deadline_search s
WHERE (
s.concept_name_de % $1
OR s.concept_name_en % $1
OR s.rule_name_de % $1
OR s.rule_name_en % $1
OR (s.legal_source IS NOT NULL AND s.legal_source % $1)
OR (s.rule_code IS NOT NULL AND s.rule_code % $1)
OR EXISTS (
SELECT 1 FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
)
)
AND ($3::text IS NULL OR s.effective_party = $3)
AND ($4::text IS NULL OR s.proceeding_code = $4)
AND ($5::text IS NULL OR s.legal_source LIKE $5 || '%')
AND (
$6::uuid[] IS NULL
OR EXISTS (
SELECT 1 FROM unnest($6::uuid[], $7::text[]) AS t(cid, pcode)
WHERE t.cid = s.concept_id
AND (t.pcode = '' OR t.pcode = s.proceeding_code)
)
)
AND (
$8::text[] IS NULL
OR cardinality($8::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($8::text[])
)
OR (
s.kind = 'trigger'
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($10::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
)
SELECT
m.concept_id,
bool_or(m.row_alias_hit) AS alias_hit,
max(m.row_score) + CASE WHEN bool_or(m.row_alias_hit)
THEN 0.2 ELSE 0 END AS score,
min(m.concept_sort_order) AS concept_sort_order,
min(m.concept_name_de) AS concept_name_de,
COALESCE(min(m.row_matched_aliases), ARRAY[]::text[]) AS matched_aliases
FROM matched m
GROUP BY m.concept_id
ORDER BY score DESC, concept_sort_order ASC, concept_name_de ASC
LIMIT $9
`
cidArg, procArg := subtree.args()
var rows []rankRow
if err := s.db.SelectContext(ctx, &rows, sqlText,
q, qLow,
party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
return rows, nil
}
func (s *DeadlineSearchService) loadPills(
ctx context.Context,
conceptIDs []string,
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
) ([]pillRow, error) {
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
const sqlText = `
SELECT
s.kind,
s.concept_id,
s.concept_slug,
s.concept_name_de,
s.concept_name_en,
s.concept_description,
s.concept_party,
s.concept_category,
s.rule_id,
s.trigger_event_id,
s.proceeding_code,
s.proceeding_name_de,
s.proceeding_name_en,
s.jurisdiction,
s.proceeding_display_order,
s.rule_local_code,
s.rule_name_de,
s.rule_name_en,
s.legal_source,
s.rule_code,
s.duration_value,
s.duration_unit,
s.timing,
s.effective_party
FROM paliad.deadline_search s
WHERE s.concept_id = ANY($1::uuid[])
AND ($2::text IS NULL OR s.effective_party = $2)
AND ($3::text IS NULL OR s.proceeding_code = $3)
AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
AND (
$5::uuid[] IS NULL
OR EXISTS (
SELECT 1 FROM unnest($5::uuid[], $6::text[]) AS t(cid, pcode)
WHERE t.cid = s.concept_id
AND (t.pcode = '' OR t.pcode = s.proceeding_code)
)
)
AND (
$7::text[] IS NULL
OR cardinality($7::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($7::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
cidArg, procArg := subtree.args()
var rows []pillRow
if err := s.db.SelectContext(ctx, &rows, sqlText,
pq.Array(conceptIDs), party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}
return rows, nil
}
// assembleCards groups pillRows under their ranked concept and builds
// the JSON cards in score order.
func assembleCards(ranks []rankRow, pills []pillRow) ([]ConceptCard, int) {
pillsByConcept := make(map[string][]pillRow, len(ranks))
for _, p := range pills {
pillsByConcept[p.ConceptID] = append(pillsByConcept[p.ConceptID], p)
}
cards := make([]ConceptCard, 0, len(ranks))
totalPills := 0
for _, r := range ranks {
ps := pillsByConcept[r.ConceptID]
if len(ps) == 0 {
continue
}
// First row carries the concept fields.
first := ps[0]
concept := ConceptSummary{
ID: first.ConceptID,
Slug: first.ConceptSlug,
NameDE: first.ConceptNameDE,
NameEN: first.ConceptNameEN,
Category: first.ConceptCategory,
}
if first.ConceptDesc.Valid {
concept.Description = &first.ConceptDesc.String
}
if first.ConceptParty.Valid {
concept.Party = &first.ConceptParty.String
}
cardPills := make([]Pill, 0, len(ps))
for _, p := range ps {
cardPills = append(cardPills, buildPill(p))
}
// Stable secondary order: rule pills before trigger pills, then by
// jurisdiction-ish ordering (UPC, EU, DE, cross-cutting).
sort.SliceStable(cardPills, func(i, j int) bool {
return pillSortKey(cardPills[i]) < pillSortKey(cardPills[j])
})
card := ConceptCard{
Concept: concept,
MatchedAliases: []string(r.MatchedAliases),
Score: roundScore(r.Score),
Pills: cardPills,
}
cards = append(cards, card)
totalPills += len(cardPills)
}
return cards, totalPills
}
func buildPill(p pillRow) Pill {
pill := Pill{
Kind: p.Kind,
RuleLocalCode: p.RuleLocalCode,
RuleNameDE: p.RuleNameDE,
RuleNameEN: p.RuleNameEN,
Party: p.EffectiveParty,
ProceedingDisplayOrder: p.ProceedingDisplayOrder,
}
if p.RuleID.Valid {
pill.RuleID = &p.RuleID.String
}
if p.TriggerEventID.Valid {
v := p.TriggerEventID.Int64
pill.TriggerEventID = &v
}
if p.ProceedingCode.Valid {
pill.Proceeding = &PillProceeding{
Code: p.ProceedingCode.String,
NameDE: p.ProceedingNameDE.String,
NameEN: p.ProceedingNameEN.String,
Jurisdiction: p.Jurisdiction,
}
}
if p.LegalSource.Valid {
ls := p.LegalSource.String
pill.LegalSource = &ls
display := FormatLegalSourceDisplay(ls)
if display != "" {
pill.LegalSourceDisplay = &display
}
}
if p.DurationValue.Valid && p.DurationUnit.Valid {
dur := &PillDuration{
Value: int(p.DurationValue.Int32),
Unit: p.DurationUnit.String,
}
if p.Timing.Valid && p.Timing.String != "" && p.Timing.String != "after" {
t := p.Timing.String
dur.Timing = &t
}
pill.Duration = dur
}
pill.DrillURL = pillDrillURL(p)
return pill
}
func pillDrillURL(p pillRow) string {
switch p.Kind {
case "rule":
if p.ProceedingCode.Valid && p.RuleLocalCode != "" {
return "/tools/fristenrechner?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
}
return "/tools/fristenrechner"
case "trigger":
if p.TriggerEventID.Valid {
return fmt.Sprintf("/tools/fristenrechner?mode=event&triggerId=%d", p.TriggerEventID.Int64)
}
return "/tools/fristenrechner?mode=event"
}
return "/tools/fristenrechner"
}
// pillSortKey orders pills inside a card. Rule pills before triggers;
// inside rules, by proceeding_display_order (real-world frequency,
// t-paliad-134) ascending; ties broken by rule_local_code so the order
// is stable across runs.
func pillSortKey(p Pill) string {
kindRank := "1"
if p.Kind == "trigger" {
kindRank = "2"
}
// Zero-pad to 5 digits so lexicographic compare matches numeric.
return fmt.Sprintf("%s%05d%s", kindRank, p.ProceedingDisplayOrder, p.RuleLocalCode)
}
func nullable(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
func buildFilters(opts SearchOptions) SearchFilters {
return SearchFilters{
Party: nullable(opts.Party),
Proc: nullable(opts.Proc),
Source: nullable(opts.Source),
}
}
// normalizeQuery strips legal-prefix noise that users naturally type but
// the structured legal_source column doesn't contain. § 82 → 82, Art.108
// → 108, RoP R.23 → RoP 23. The trigram match runs on the result.
func normalizeQuery(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return ""
}
// Drop common legal prefixes (§, Art., Section, Sec., R., Rule).
// We keep meaningful tokens like "RoP", "ZPO", "PatG" because they
// help narrow the search via trigram on legal_source / rule_code.
lowers := strings.ToLower(q)
for _, prefix := range []string{"§", "art.", "art ", "section ", "sec.", "sec ", "rule "} {
for strings.HasPrefix(lowers, prefix) {
q = strings.TrimSpace(q[len(prefix):])
lowers = strings.ToLower(q)
}
}
return q
}
// roundScore truncates to 4 decimals so JSON stays compact.
func roundScore(v float64) float64 {
return float64(int(v*10000+0.5)) / 10000
}
// FormatLegalSourceDisplay renders a structured legal_source code into
// the form HLC users read in pleadings:
//
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
// UPC.RoP.139 → "UPC RoP R.139"
// DE.PatG.82.1 → "PatG §82(1)"
// DE.ZPO.276.1 → "ZPO §276(1)"
// EU.EPÜ.108 → "EPÜ Art.108"
// EU.EPC-R.79.1 → "EPC R.79(1)"
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
//
// Returns the empty string for an empty input. Unknown jurisdictions
// fall through with the structured form preserved (caller decides
// whether to display).
func FormatLegalSourceDisplay(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
// Malformed — return as-is so the caller still has something.
return src
}
code := parts[1]
rest := parts[2:]
var prefix string
switch code {
case "RoP":
prefix = "UPC RoP R."
case "PatG":
prefix = "PatG §"
case "ZPO":
prefix = "ZPO §"
case "EPÜ":
prefix = "EPÜ Art."
case "EPC-R":
prefix = "EPC R."
case "RPBA":
prefix = "RPBA Art."
default:
prefix = code + " "
}
var b strings.Builder
b.Grow(len(prefix) + len(src))
b.WriteString(prefix)
b.WriteString(rest[0])
for _, p := range rest[1:] {
b.WriteByte('(')
b.WriteString(p)
b.WriteByte(')')
}
return b.String()
}
// BuildLegalSourceURL maps a structured legal_source code to a
// youpc.org/laws permalink when the cited body is hosted there. Today
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
// home yet, so the helper returns the empty string for those and the
// caller renders the display string as plain text.
//
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
// the law-number position are dropped; youpc resolves the page at
// <type>.<number> granularity. The law-number is zero-padded to 3
// digits to match how youpc stores law_number (laws-data.json carries
// "001" / "023" / "220" forms).
//
// URL shape uses the hash-fragment form that youpc itself emits from
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
// in-app deep link target. The `/laws/:type/:number` pretty route also
// resolves the same page but redirects to the hash form anyway.
//
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
func BuildLegalSourceURL(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
return ""
}
var lawType string
switch parts[0] + "." + parts[1] {
case "UPC.RoP":
lawType = "UPCRoP"
case "UPC.UPCA":
lawType = "UPCA"
case "UPC.UPCS":
lawType = "UPCS"
default:
return ""
}
number := padLawNumber(parts[2])
if number == "" {
return ""
}
return "https://youpc.org/laws#" + lawType + "." + number
}
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
// 112a) pass through unchanged so the URL still resolves. Empty input
// returns the empty string.
func padLawNumber(s string) string {
if s == "" {
return ""
}
for _, c := range s {
if c < '0' || c > '9' {
return s
}
}
if len(s) >= 3 {
return s
}
return strings.Repeat("0", 3-len(s)) + s
}
// RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go
// right after the migration runner finishes so search reflects any
// newly-applied seed migration.
func RefreshSearchView(ctx context.Context, db *sqlx.DB) error {
_, err := db.ExecContext(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY paliad.deadline_search`)
if err != nil {
return fmt.Errorf("refresh deadline_search: %w", err)
}
return nil
}