package services import ( "context" "database/sql" "fmt" "sort" "strings" "github.com/jmoiron/sqlx" "github.com/lib/pq" lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // 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 + BuildLegalSourceURL are canonically // defined in pkg/litigationplanner — kept here as thin re-exports so // the existing in-package + handler call-sites compile unchanged. func FormatLegalSourceDisplay(src string) string { return lp.FormatLegalSourceDisplay(src) } func BuildLegalSourceURL(src string) string { return lp.BuildLegalSourceURL(src) } // 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 }