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.
1062 lines
35 KiB
Go
1062 lines
35 KiB
Go
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
|
||
}
|