Revert "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"

This reverts commit f7d72ff1d3, reversing
changes made to 1ea983f0c7.
This commit is contained in:
m
2026-05-05 11:17:58 +02:00
parent f7d72ff1d3
commit 5bd17de732
19 changed files with 64 additions and 3683 deletions

View File

@@ -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