Revert "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commitf7d72ff1d3, reversing changes made to1ea983f0c7.
This commit is contained in:
@@ -25,64 +25,22 @@ import (
|
||||
// 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 bypass the forum filter (cross-cutting by design).
|
||||
//
|
||||
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
|
||||
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
|
||||
// See docs/plans/unified-fristenrechner.md §4.6 + §6.
|
||||
type DeadlineSearchService struct {
|
||||
db *sqlx.DB
|
||||
eventCategory *EventCategoryService
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewDeadlineSearchService wires the service to its DB pool. The
|
||||
// EventCategoryService dependency is optional — pass nil if the v3
|
||||
// taxonomy isn't needed (legacy callers).
|
||||
// NewDeadlineSearchService wires the service to its DB pool.
|
||||
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": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES", "UPC_DISCOVERY", "UPC_APP_ORDERS"},
|
||||
"upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
|
||||
"de_lg": {"DE_INF"},
|
||||
"de_olg": {"DE_INF_OLG"},
|
||||
"de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
|
||||
"de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
|
||||
"epa_grant": {"EP_GRANT"},
|
||||
"epa_opp": {"EPA_OPP"},
|
||||
"epa_appeal": {"EPA_APP"},
|
||||
"dpma": {"DPMA_OPP"},
|
||||
}
|
||||
|
||||
// SearchOptions carries the optional facet filters from the URL query
|
||||
// string. Empty strings / empty slices mean "no filter on this facet".
|
||||
// string. Empty strings 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)
|
||||
Limit int
|
||||
MaxLimit int
|
||||
}
|
||||
@@ -195,10 +153,9 @@ type pillRow struct {
|
||||
|
||||
// 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).
|
||||
// q is the raw user input. Empty q returns an empty result set (no
|
||||
// filtering across the entire matview — that's a "browse" surface
|
||||
// the design doc reserves for Phase D).
|
||||
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
@@ -219,45 +176,18 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
}
|
||||
|
||||
qNorm := normalizeQuery(q)
|
||||
browseMode := qNorm == "" && opts.EventCategorySlug != ""
|
||||
|
||||
// v3: resolve the event-category slug to a concept_id allow-list.
|
||||
var allowConceptIDs []string
|
||||
if opts.EventCategorySlug != "" && s.eventCategory != nil {
|
||||
ids, err := s.eventCategory.ConceptIDsForSlug(ctx, opts.EventCategorySlug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
// Slug resolves to no concepts; return empty without hitting
|
||||
// the matview.
|
||||
return resp, nil
|
||||
}
|
||||
allowConceptIDs = ids
|
||||
}
|
||||
|
||||
// v3: translate forum slugs to proceeding_code allow-list.
|
||||
forumCodes := translateForums(opts.Forums)
|
||||
|
||||
if !browseMode && qNorm == "" {
|
||||
if qNorm == "" {
|
||||
return resp, nil
|
||||
}
|
||||
qLow := strings.ToLower(qNorm)
|
||||
|
||||
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, allowConceptIDs, party, proc, source, forumCodes, limit)
|
||||
} else {
|
||||
qLow := strings.ToLower(qNorm)
|
||||
var err error
|
||||
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, allowConceptIDs, forumCodes, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranks, err := s.rankConcepts(ctx, qNorm, qLow, party, proc, source, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranks) == 0 {
|
||||
return resp, nil
|
||||
@@ -267,7 +197,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
for i, r := range ranks {
|
||||
conceptIDs[i] = r.ConceptID
|
||||
}
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, forumCodes)
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -279,99 +209,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// browseRanks synthesizes a rank list from a concept-id allow-list
|
||||
// (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.
|
||||
func (s *DeadlineSearchService) browseRanks(
|
||||
ctx context.Context,
|
||||
conceptIDs []string,
|
||||
party, proc, source *string,
|
||||
forumCodes []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 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::text[] IS NULL
|
||||
OR cardinality($5::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($5::text[])
|
||||
)
|
||||
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
|
||||
LIMIT $6
|
||||
`
|
||||
var rows []rankRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
pq.Array(conceptIDs),
|
||||
party, proc, source,
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
); 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,
|
||||
allowConceptIDs []string,
|
||||
forumCodes []string,
|
||||
limit int,
|
||||
) ([]rankRow, error) {
|
||||
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
|
||||
// $6 concept_allow uuid[]? · $7 forum_codes text[]? · $8 limit
|
||||
const sqlText = `
|
||||
WITH matched AS (
|
||||
SELECT
|
||||
@@ -410,13 +253,6 @@ WITH matched AS (
|
||||
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 s.concept_id = ANY($6::uuid[]))
|
||||
AND (
|
||||
$7::text[] IS NULL
|
||||
OR cardinality($7::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($7::text[])
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
m.concept_id,
|
||||
@@ -425,20 +261,16 @@ SELECT
|
||||
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,
|
||||
-- All rows in a concept share the same aliases; min() over identical
|
||||
-- text[] values is well-defined and returns one of them verbatim.
|
||||
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 $8
|
||||
LIMIT $6
|
||||
`
|
||||
var rows []rankRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
q, qLow,
|
||||
party, proc, source,
|
||||
nullableArray(allowConceptIDs),
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText, q, qLow, party, proc, source, limit); err != nil {
|
||||
return nil, fmt.Errorf("rank concepts: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
@@ -448,7 +280,6 @@ func (s *DeadlineSearchService) loadPills(
|
||||
ctx context.Context,
|
||||
conceptIDs []string,
|
||||
party, proc, source *string,
|
||||
forumCodes []string,
|
||||
) ([]pillRow, error) {
|
||||
const sqlText = `
|
||||
SELECT
|
||||
@@ -480,18 +311,10 @@ SELECT
|
||||
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::text[] IS NULL
|
||||
OR cardinality($5::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($5::text[])
|
||||
)
|
||||
ORDER BY s.concept_id, s.kind, s.proceeding_code NULLS LAST, s.rule_local_code
|
||||
`
|
||||
var rows []pillRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
pq.Array(conceptIDs), party, proc, source, nullableArray(forumCodes),
|
||||
); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText, pq.Array(conceptIDs), party, proc, source); err != nil {
|
||||
return nil, fmt.Errorf("load pills: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
Reference in New Issue
Block a user