From 24f3baf61f5aa676719fcd0e3ddc24b50880c1bc Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 15:36:08 +0200 Subject: [PATCH 1/2] =?UTF-8?q?mAi:=20#97=20-=20t-paliad-266=20=E2=80=94?= =?UTF-8?q?=20event-type=20modal:=20narrow=20cross-cutting=20trigger=20pil?= =?UTF-8?q?ls=20by=20court=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...cross_cutting_filter_legal_source.down.sql | 103 ++++++++ ...3_cross_cutting_filter_legal_source.up.sql | 222 ++++++++++++++++++ internal/services/deadline_search_service.go | 132 ++++++++++- .../services/deadline_search_service_test.go | 115 ++++++++- 4 files changed, 552 insertions(+), 20 deletions(-) create mode 100644 internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql create mode 100644 internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql diff --git a/internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql b/internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql new file mode 100644 index 0000000..406b8a2 --- /dev/null +++ b/internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql @@ -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); diff --git a/internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql b/internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql new file mode 100644 index 0000000..e56f8cf --- /dev/null +++ b/internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql @@ -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); diff --git a/internal/services/deadline_search_service.go b/internal/services/deadline_search_service.go index f16c602..6a0f749 100644 --- a/internal/services/deadline_search_service.go +++ b/internal/services/deadline_search_service.go @@ -33,7 +33,12 @@ import ( // 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). +// 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). @@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{ "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 { @@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc 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) + forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums) if !browseMode && qNorm == "" { return resp, nil @@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc var ranks []rankRow if browseMode { // 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 { qLow := strings.ToLower(qNorm) 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 { return nil, err } @@ -310,7 +353,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, subtree, forumCodes) + pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes) if err != nil { return nil, err } @@ -418,6 +461,33 @@ func translateForums(slugs []string) []string { 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 @@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks( subtree *subtreeFilter, party, proc, source *string, forumCodes []string, + forumLegalPrefixes []string, limit int, ) []rankRow { const sqlText = ` @@ -452,8 +523,18 @@ SELECT DISTINCT AND ( $6::text[] IS NULL OR cardinality($6::text[]) = 0 - OR s.kind = 'trigger' - OR s.proceeding_code = ANY($6::text[]) + 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 @@ -465,6 +546,7 @@ SELECT DISTINCT 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. @@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts( 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 + // $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]? const sqlText = ` WITH matched AS ( SELECT @@ -544,8 +627,18 @@ WITH matched AS ( AND ( $8::text[] IS NULL OR cardinality($8::text[]) = 0 - OR s.kind = 'trigger' - OR s.proceeding_code = ANY($8::text[]) + 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 @@ -569,6 +662,7 @@ SELECT cidArg, procArg, nullableArray(forumCodes), limit, + nullableArray(forumLegalPrefixes), ); err != nil { return nil, fmt.Errorf("rank concepts: %w", err) } @@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills( 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[]? + // $7 forum_codes text[]? · $8 forum_legal_prefixes text[]? const sqlText = ` SELECT s.kind, @@ -627,8 +722,18 @@ SELECT AND ( $7::text[] IS NULL OR cardinality($7::text[]) = 0 - OR s.kind = 'trigger' - OR s.proceeding_code = ANY($7::text[]) + 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 ` @@ -638,6 +743,7 @@ SELECT pq.Array(conceptIDs), party, proc, source, cidArg, procArg, nullableArray(forumCodes), + nullableArray(forumLegalPrefixes), ); err != nil { return nil, fmt.Errorf("load pills: %w", err) } diff --git a/internal/services/deadline_search_service_test.go b/internal/services/deadline_search_service_test.go index 13338c8..8035fdb 100644 --- a/internal/services/deadline_search_service_test.go +++ b/internal/services/deadline_search_service_test.go @@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) { 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}) if err != nil { t.Fatalf("search: %v", err) } card := findCardBySlug(t, resp, "wiedereinsetzung") - // Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ - // Art.122 (EU), DPMA §123 — corresponding to trigger_event ids - // 200..203 from migration 046. + // Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ + // Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids + // 200..203 from mig 046 plus 207 from mig 063. triggerIDs := []int64{} for _, p := range card.Pills { if p.Kind != "trigger" { @@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) { triggerIDs = append(triggerIDs, *p.TriggerEventID) } } - want := map[int64]bool{200: true, 201: true, 202: true, 203: true} - if len(triggerIDs) != 4 { - t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs)) + want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true} + if len(triggerIDs) != 5 { + t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs)) } for _, id := range triggerIDs { 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) { resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12}) if err != nil { From 90f5dd4b1bdfefd22082f9302a4a3dc94462022b Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 15:40:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20t-paliad-266=20=E2=80=94=20bump=20mi?= =?UTF-8?q?gration=20to=20slot=20125=20(123=20taken=20by=20cronus=20#77=20?= =?UTF-8?q?backups)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...own.sql => 125_cross_cutting_filter_legal_source.down.sql} | 4 ++-- ...ce.up.sql => 125_cross_cutting_filter_legal_source.up.sql} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename internal/db/migrations/{123_cross_cutting_filter_legal_source.down.sql => 125_cross_cutting_filter_legal_source.down.sql} (97%) rename internal/db/migrations/{123_cross_cutting_filter_legal_source.up.sql => 125_cross_cutting_filter_legal_source.up.sql} (99%) diff --git a/internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql b/internal/db/migrations/125_cross_cutting_filter_legal_source.down.sql similarity index 97% rename from internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql rename to internal/db/migrations/125_cross_cutting_filter_legal_source.down.sql index 406b8a2..2447816 100644 --- a/internal/db/migrations/123_cross_cutting_filter_legal_source.down.sql +++ b/internal/db/migrations/125_cross_cutting_filter_legal_source.down.sql @@ -1,4 +1,4 @@ --- Down migration for 123_cross_cutting_filter_legal_source.up.sql. +-- Down migration for 125_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 @@ -7,7 +7,7 @@ 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).', + 'mig 125 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. diff --git a/internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql b/internal/db/migrations/125_cross_cutting_filter_legal_source.up.sql similarity index 99% rename from internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql rename to internal/db/migrations/125_cross_cutting_filter_legal_source.up.sql index e56f8cf..ac73f4c 100644 --- a/internal/db/migrations/123_cross_cutting_filter_legal_source.up.sql +++ b/internal/db/migrations/125_cross_cutting_filter_legal_source.up.sql @@ -34,7 +34,7 @@ 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).', + 'mig 125: 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); -- =============================================================================