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.
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
-- Down migration for 123_cross_cutting_filter_legal_source.up.sql.
|
||||||
|
--
|
||||||
|
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
|
||||||
|
-- rows) and removes the trigger-207 backfill row. Two steps in
|
||||||
|
-- forward-reverse order so the matview drop doesn't trip on the
|
||||||
|
-- deadline_rules delete.
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 123 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
|
||||||
|
true);
|
||||||
|
|
||||||
|
-- 1. Drop the matview before pulling rows underneath it.
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||||
|
|
||||||
|
-- 2. Delete the trigger 207 backfill row.
|
||||||
|
DELETE FROM paliad.deadline_rules
|
||||||
|
WHERE trigger_event_id = 207
|
||||||
|
AND sequence_order = 1207;
|
||||||
|
|
||||||
|
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
|
||||||
|
-- trigger rows).
|
||||||
|
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||||
|
SELECT
|
||||||
|
'rule'::text AS kind,
|
||||||
|
'r:' || dr.id::text AS row_key,
|
||||||
|
dc.id AS concept_id,
|
||||||
|
dc.slug AS concept_slug,
|
||||||
|
dc.name_de AS concept_name_de,
|
||||||
|
dc.name_en AS concept_name_en,
|
||||||
|
dc.description AS concept_description,
|
||||||
|
dc.aliases AS concept_aliases,
|
||||||
|
dc.party AS concept_party,
|
||||||
|
dc.category AS concept_category,
|
||||||
|
dc.sort_order AS concept_sort_order,
|
||||||
|
dr.id AS rule_id,
|
||||||
|
NULL::bigint AS trigger_event_id,
|
||||||
|
pt.code AS proceeding_code,
|
||||||
|
pt.name AS proceeding_name_de,
|
||||||
|
pt.name_en AS proceeding_name_en,
|
||||||
|
pt.jurisdiction AS jurisdiction,
|
||||||
|
pt.display_order AS proceeding_display_order,
|
||||||
|
dr.submission_code AS rule_local_code,
|
||||||
|
dr.name AS rule_name_de,
|
||||||
|
dr.name_en AS rule_name_en,
|
||||||
|
dr.legal_source AS legal_source,
|
||||||
|
dr.rule_code AS rule_code,
|
||||||
|
dr.duration_value,
|
||||||
|
dr.duration_unit,
|
||||||
|
dr.timing,
|
||||||
|
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||||
|
WHERE dr.is_active
|
||||||
|
AND pt.is_active
|
||||||
|
AND pt.category = 'fristenrechner'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'trigger'::text,
|
||||||
|
't:' || te.id::text,
|
||||||
|
dc.id,
|
||||||
|
dc.slug,
|
||||||
|
dc.name_de,
|
||||||
|
dc.name_en,
|
||||||
|
dc.description,
|
||||||
|
dc.aliases,
|
||||||
|
dc.party,
|
||||||
|
dc.category,
|
||||||
|
dc.sort_order,
|
||||||
|
NULL::uuid,
|
||||||
|
te.id,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
'cross-cutting'::text,
|
||||||
|
9999::int AS proceeding_display_order,
|
||||||
|
te.code,
|
||||||
|
te.name_de,
|
||||||
|
te.name,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
NULL::int,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
dc.party
|
||||||
|
FROM paliad.trigger_events te
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||||
|
WHERE te.is_active;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||||
|
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||||
|
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||||
|
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||||
|
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||||
|
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
|
||||||
|
-- by court system in the event-type / Fristen search modal.
|
||||||
|
--
|
||||||
|
-- Two things land here:
|
||||||
|
--
|
||||||
|
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
|
||||||
|
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
|
||||||
|
-- trigger_event but never seeded its event_deadlines counterpart;
|
||||||
|
-- mig 092 then dropped event_deadlines after copying the four
|
||||||
|
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
|
||||||
|
-- so trigger 207 stayed orphaned with no duration / legal_source.
|
||||||
|
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
|
||||||
|
-- par with the four siblings (2 months from removal of obstacle,
|
||||||
|
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
|
||||||
|
-- matview a legal_source to surface for the UPC trigger pill.
|
||||||
|
-- Pattern mirrors the four sibling rows mig 085 inserted.
|
||||||
|
--
|
||||||
|
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
|
||||||
|
-- paliad.deadline_rules for trigger pills, exposing the trigger's
|
||||||
|
-- legal_source on the row. The cross-cutting concept card pills
|
||||||
|
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
|
||||||
|
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
|
||||||
|
-- match against the active forum-bucket filter — see
|
||||||
|
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
|
||||||
|
-- (added in this same change). Without the matview surfacing
|
||||||
|
-- legal_source for trigger rows, every cross-cutting sub-row
|
||||||
|
-- ignored the court-system chip selection (the bug m reported).
|
||||||
|
--
|
||||||
|
-- The materialised view paliad.deadline_search refreshes on the next
|
||||||
|
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
|
||||||
|
-- the new legal_source column for triggers becomes searchable as soon
|
||||||
|
-- as the deploy restarts the process. No matview refresh from the
|
||||||
|
-- migration itself.
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 123: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
|
||||||
|
true);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. Backfill: deadline_rules row for trigger 207.
|
||||||
|
--
|
||||||
|
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
|
||||||
|
-- mig 085's guard so re-runs are no-ops once the row is present.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO paliad.deadline_rules (
|
||||||
|
id,
|
||||||
|
proceeding_type_id,
|
||||||
|
parent_id,
|
||||||
|
trigger_event_id,
|
||||||
|
spawn_proceeding_type_id,
|
||||||
|
submission_code,
|
||||||
|
name,
|
||||||
|
name_en,
|
||||||
|
primary_party,
|
||||||
|
event_type,
|
||||||
|
is_mandatory,
|
||||||
|
is_optional,
|
||||||
|
is_court_set,
|
||||||
|
is_spawn,
|
||||||
|
duration_value,
|
||||||
|
duration_unit,
|
||||||
|
timing,
|
||||||
|
alt_duration_value,
|
||||||
|
alt_duration_unit,
|
||||||
|
combine_op,
|
||||||
|
rule_code,
|
||||||
|
deadline_notes,
|
||||||
|
deadline_notes_en,
|
||||||
|
legal_source,
|
||||||
|
condition_expr,
|
||||||
|
condition_flag,
|
||||||
|
sequence_order,
|
||||||
|
is_active,
|
||||||
|
priority,
|
||||||
|
lifecycle_state,
|
||||||
|
draft_of,
|
||||||
|
published_at,
|
||||||
|
concept_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
NULL::integer,
|
||||||
|
NULL::uuid,
|
||||||
|
207,
|
||||||
|
NULL::integer,
|
||||||
|
NULL::text,
|
||||||
|
'Wiedereinsetzungsantrag (UPC R.320)',
|
||||||
|
'Petition for re-establishment of rights (UPC R.320)',
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
'months',
|
||||||
|
'after',
|
||||||
|
NULL::integer,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
|
||||||
|
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
|
||||||
|
'UPC.RoP.320',
|
||||||
|
NULL::jsonb,
|
||||||
|
NULL::text[],
|
||||||
|
1207,
|
||||||
|
true,
|
||||||
|
'mandatory',
|
||||||
|
'published',
|
||||||
|
NULL::uuid,
|
||||||
|
now(),
|
||||||
|
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
WHERE dr.trigger_event_id = 207
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
|
||||||
|
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
|
||||||
|
-- verbatim from mig 098 §5.
|
||||||
|
--
|
||||||
|
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
|
||||||
|
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
|
||||||
|
-- row with proceeding_type_id IS NULL. A trigger event without that
|
||||||
|
-- row leaves legal_source NULL and the trigger pill keeps its current
|
||||||
|
-- "no jurisdiction filter match" semantics — same shape as before this
|
||||||
|
-- migration, just structurally surfaceable.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||||
|
SELECT
|
||||||
|
'rule'::text AS kind,
|
||||||
|
'r:' || dr.id::text AS row_key,
|
||||||
|
dc.id AS concept_id,
|
||||||
|
dc.slug AS concept_slug,
|
||||||
|
dc.name_de AS concept_name_de,
|
||||||
|
dc.name_en AS concept_name_en,
|
||||||
|
dc.description AS concept_description,
|
||||||
|
dc.aliases AS concept_aliases,
|
||||||
|
dc.party AS concept_party,
|
||||||
|
dc.category AS concept_category,
|
||||||
|
dc.sort_order AS concept_sort_order,
|
||||||
|
dr.id AS rule_id,
|
||||||
|
NULL::bigint AS trigger_event_id,
|
||||||
|
pt.code AS proceeding_code,
|
||||||
|
pt.name AS proceeding_name_de,
|
||||||
|
pt.name_en AS proceeding_name_en,
|
||||||
|
pt.jurisdiction AS jurisdiction,
|
||||||
|
pt.display_order AS proceeding_display_order,
|
||||||
|
dr.submission_code AS rule_local_code,
|
||||||
|
dr.name AS rule_name_de,
|
||||||
|
dr.name_en AS rule_name_en,
|
||||||
|
dr.legal_source AS legal_source,
|
||||||
|
dr.rule_code AS rule_code,
|
||||||
|
dr.duration_value,
|
||||||
|
dr.duration_unit,
|
||||||
|
dr.timing,
|
||||||
|
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||||
|
WHERE dr.is_active
|
||||||
|
AND pt.is_active
|
||||||
|
AND pt.category = 'fristenrechner'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'trigger'::text,
|
||||||
|
't:' || te.id::text,
|
||||||
|
dc.id,
|
||||||
|
dc.slug,
|
||||||
|
dc.name_de,
|
||||||
|
dc.name_en,
|
||||||
|
dc.description,
|
||||||
|
dc.aliases,
|
||||||
|
dc.party,
|
||||||
|
dc.category,
|
||||||
|
dc.sort_order,
|
||||||
|
NULL::uuid,
|
||||||
|
te.id,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
'cross-cutting'::text,
|
||||||
|
9999::int AS proceeding_display_order,
|
||||||
|
te.code,
|
||||||
|
te.name_de,
|
||||||
|
te.name,
|
||||||
|
dr_trig.legal_source AS legal_source,
|
||||||
|
NULL::text,
|
||||||
|
NULL::int,
|
||||||
|
NULL::text,
|
||||||
|
NULL::text,
|
||||||
|
dc.party
|
||||||
|
FROM paliad.trigger_events te
|
||||||
|
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||||
|
LEFT JOIN paliad.deadline_rules dr_trig
|
||||||
|
ON dr_trig.trigger_event_id = te.id
|
||||||
|
AND dr_trig.proceeding_type_id IS NULL
|
||||||
|
AND dr_trig.is_active
|
||||||
|
AND dr_trig.lifecycle_state = 'published'
|
||||||
|
WHERE te.is_active;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||||
|
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||||
|
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||||
|
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||||
|
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||||
|
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||||
|
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||||
@@ -33,7 +33,12 @@ import (
|
|||||||
// tree alone is enough to produce a candidate concept set.
|
// tree alone is enough to produce a candidate concept set.
|
||||||
// - Forums: a list of forum slugs from the v3 bucket map. Translated
|
// - Forums: a list of forum slugs from the v3 bucket map. Translated
|
||||||
// to proceeding_type_codes by the search service; trigger-event
|
// to proceeding_type_codes by the search service; trigger-event
|
||||||
// pills bypass the forum filter (cross-cutting by design).
|
// 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
|
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
|
||||||
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
|
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
|
||||||
@@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{
|
|||||||
"dpma": {CodeDPMAOpposition},
|
"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
|
// SearchOptions carries the optional facet filters from the URL query
|
||||||
// string. Empty strings / empty slices mean "no filter on this facet".
|
// string. Empty strings / empty slices mean "no filter on this facet".
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
@@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
|||||||
subtree = newSubtreeFilter(outcomes)
|
subtree = newSubtreeFilter(outcomes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3: translate forum slugs to proceeding_code allow-list.
|
// 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)
|
forumCodes := translateForums(opts.Forums)
|
||||||
|
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
|
||||||
|
|
||||||
if !browseMode && qNorm == "" {
|
if !browseMode && qNorm == "" {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
|||||||
var ranks []rankRow
|
var ranks []rankRow
|
||||||
if browseMode {
|
if browseMode {
|
||||||
// Browse mode: synthesize ranks from the allow-list directly.
|
// Browse mode: synthesize ranks from the allow-list directly.
|
||||||
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
|
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
|
||||||
} else {
|
} else {
|
||||||
qLow := strings.ToLower(qNorm)
|
qLow := strings.ToLower(qNorm)
|
||||||
var err error
|
var err error
|
||||||
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
|
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -310,7 +353,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
|||||||
for i, r := range ranks {
|
for i, r := range ranks {
|
||||||
conceptIDs[i] = r.ConceptID
|
conceptIDs[i] = r.ConceptID
|
||||||
}
|
}
|
||||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
|
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -418,6 +461,33 @@ func translateForums(slugs []string) []string {
|
|||||||
return out
|
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
|
// browseRanks synthesizes a rank list from a subtree-filter tuple set
|
||||||
// (v3 B1 browse mode). No trigram scoring — order is by concept
|
// (v3 B1 browse mode). No trigram scoring — order is by concept
|
||||||
// sort_order then name. Forum filter applies post-hoc to keep concepts
|
// sort_order then name. Forum filter applies post-hoc to keep concepts
|
||||||
@@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks(
|
|||||||
subtree *subtreeFilter,
|
subtree *subtreeFilter,
|
||||||
party, proc, source *string,
|
party, proc, source *string,
|
||||||
forumCodes []string,
|
forumCodes []string,
|
||||||
|
forumLegalPrefixes []string,
|
||||||
limit int,
|
limit int,
|
||||||
) []rankRow {
|
) []rankRow {
|
||||||
const sqlText = `
|
const sqlText = `
|
||||||
@@ -452,8 +523,18 @@ SELECT DISTINCT
|
|||||||
AND (
|
AND (
|
||||||
$6::text[] IS NULL
|
$6::text[] IS NULL
|
||||||
OR cardinality($6::text[]) = 0
|
OR cardinality($6::text[]) = 0
|
||||||
OR s.kind = 'trigger'
|
OR (
|
||||||
OR s.proceeding_code = ANY($6::text[])
|
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
|
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
|
||||||
LIMIT $7
|
LIMIT $7
|
||||||
@@ -465,6 +546,7 @@ SELECT DISTINCT
|
|||||||
party, proc, source,
|
party, proc, source,
|
||||||
nullableArray(forumCodes),
|
nullableArray(forumCodes),
|
||||||
limit,
|
limit,
|
||||||
|
nullableArray(forumLegalPrefixes),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
// Browse mode failures degrade to empty (taxonomy-driven UX
|
// Browse mode failures degrade to empty (taxonomy-driven UX
|
||||||
// shouldn't crash on a malformed slug); log via the caller.
|
// shouldn't crash on a malformed slug); log via the caller.
|
||||||
@@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts(
|
|||||||
party, proc, source *string,
|
party, proc, source *string,
|
||||||
subtree *subtreeFilter,
|
subtree *subtreeFilter,
|
||||||
forumCodes []string,
|
forumCodes []string,
|
||||||
|
forumLegalPrefixes []string,
|
||||||
limit int,
|
limit int,
|
||||||
) ([]rankRow, error) {
|
) ([]rankRow, error) {
|
||||||
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
|
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
|
||||||
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
|
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
|
||||||
// $8 forum_codes text[]? · $9 limit
|
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
|
||||||
const sqlText = `
|
const sqlText = `
|
||||||
WITH matched AS (
|
WITH matched AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -544,8 +627,18 @@ WITH matched AS (
|
|||||||
AND (
|
AND (
|
||||||
$8::text[] IS NULL
|
$8::text[] IS NULL
|
||||||
OR cardinality($8::text[]) = 0
|
OR cardinality($8::text[]) = 0
|
||||||
OR s.kind = 'trigger'
|
OR (
|
||||||
OR s.proceeding_code = ANY($8::text[])
|
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
|
SELECT
|
||||||
@@ -569,6 +662,7 @@ SELECT
|
|||||||
cidArg, procArg,
|
cidArg, procArg,
|
||||||
nullableArray(forumCodes),
|
nullableArray(forumCodes),
|
||||||
limit,
|
limit,
|
||||||
|
nullableArray(forumLegalPrefixes),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("rank concepts: %w", err)
|
return nil, fmt.Errorf("rank concepts: %w", err)
|
||||||
}
|
}
|
||||||
@@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills(
|
|||||||
party, proc, source *string,
|
party, proc, source *string,
|
||||||
subtree *subtreeFilter,
|
subtree *subtreeFilter,
|
||||||
forumCodes []string,
|
forumCodes []string,
|
||||||
|
forumLegalPrefixes []string,
|
||||||
) ([]pillRow, error) {
|
) ([]pillRow, error) {
|
||||||
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
|
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
|
||||||
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
|
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
|
||||||
// $7 forum_codes text[]?
|
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
|
||||||
const sqlText = `
|
const sqlText = `
|
||||||
SELECT
|
SELECT
|
||||||
s.kind,
|
s.kind,
|
||||||
@@ -627,8 +722,18 @@ SELECT
|
|||||||
AND (
|
AND (
|
||||||
$7::text[] IS NULL
|
$7::text[] IS NULL
|
||||||
OR cardinality($7::text[]) = 0
|
OR cardinality($7::text[]) = 0
|
||||||
OR s.kind = 'trigger'
|
OR (
|
||||||
OR s.proceeding_code = ANY($7::text[])
|
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
|
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
|
||||||
`
|
`
|
||||||
@@ -638,6 +743,7 @@ SELECT
|
|||||||
pq.Array(conceptIDs), party, proc, source,
|
pq.Array(conceptIDs), party, proc, source,
|
||||||
cidArg, procArg,
|
cidArg, procArg,
|
||||||
nullableArray(forumCodes),
|
nullableArray(forumCodes),
|
||||||
|
nullableArray(forumLegalPrefixes),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("load pills: %w", err)
|
return nil, fmt.Errorf("load pills: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
|
|||||||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
|
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
|
||||||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("search: %v", err)
|
t.Fatalf("search: %v", err)
|
||||||
}
|
}
|
||||||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||||
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
||||||
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
|
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
|
||||||
// 200..203 from migration 046.
|
// 200..203 from mig 046 plus 207 from mig 063.
|
||||||
triggerIDs := []int64{}
|
triggerIDs := []int64{}
|
||||||
for _, p := range card.Pills {
|
for _, p := range card.Pills {
|
||||||
if p.Kind != "trigger" {
|
if p.Kind != "trigger" {
|
||||||
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
|
|||||||
triggerIDs = append(triggerIDs, *p.TriggerEventID)
|
triggerIDs = append(triggerIDs, *p.TriggerEventID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
|
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
|
||||||
if len(triggerIDs) != 4 {
|
if len(triggerIDs) != 5 {
|
||||||
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
|
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
|
||||||
}
|
}
|
||||||
for _, id := range triggerIDs {
|
for _, id := range triggerIDs {
|
||||||
if !want[id] {
|
if !want[id] {
|
||||||
@@ -195,6 +195,107 @@ func TestDeadlineSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// t-paliad-266 / m/paliad#97 — court-system filter narrows
|
||||||
|
// cross-cutting trigger pills via legal_source inference.
|
||||||
|
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
|
||||||
|
// Each pair is (forum slug, expected trigger_event_ids).
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
forum string
|
||||||
|
wantTrigIDs []int64
|
||||||
|
}{
|
||||||
|
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
|
||||||
|
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
|
||||||
|
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
|
||||||
|
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
|
||||||
|
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
|
||||||
|
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
|
||||||
|
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
|
||||||
|
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
|
||||||
|
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
|
||||||
|
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||||||
|
Forums: []string{tc.forum},
|
||||||
|
Limit: 12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||||
|
got := map[int64]bool{}
|
||||||
|
for _, p := range card.Pills {
|
||||||
|
if p.TriggerEventID != nil {
|
||||||
|
got[*p.TriggerEventID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := map[int64]bool{}
|
||||||
|
for _, id := range tc.wantTrigIDs {
|
||||||
|
want[id] = true
|
||||||
|
}
|
||||||
|
for id := range got {
|
||||||
|
if !want[id] {
|
||||||
|
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if !got[id] {
|
||||||
|
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
|
||||||
|
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
|
||||||
|
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||||||
|
Forums: []string{"upc_cfi", "de_lg"},
|
||||||
|
Limit: 12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||||
|
got := map[int64]bool{}
|
||||||
|
for _, p := range card.Pills {
|
||||||
|
if p.TriggerEventID != nil {
|
||||||
|
got[*p.TriggerEventID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := map[int64]bool{201: true, 207: true}
|
||||||
|
for id := range got {
|
||||||
|
if !want[id] {
|
||||||
|
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if !got[id] {
|
||||||
|
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
|
||||||
|
// No forum chips = all 5 triggers stay visible.
|
||||||
|
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||||
|
count := 0
|
||||||
|
for _, p := range card.Pills {
|
||||||
|
if p.Kind == "trigger" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 5 {
|
||||||
|
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
|
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
|
||||||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
|
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user