Compare commits
6 Commits
mai/cronus
...
mai/artemi
| Author | SHA1 | Date | |
|---|---|---|---|
| f72e8a7b85 | |||
| df2a1275cb | |||
| 3700d68c68 | |||
| e0c8401482 | |||
| 90f5dd4b1b | |||
| 24f3baf61f |
@@ -42,5 +42,14 @@ services:
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# Backup Mode (m/paliad#77 Slice A). Local-disk export target; the
|
||||
# paliad_exports named volume below persists it across container
|
||||
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
|
||||
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
volumes:
|
||||
- paliad_exports:/var/lib/paliad/exports
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
paliad_exports:
|
||||
|
||||
@@ -1117,6 +1117,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Termin ge\u00e4ndert",
|
||||
"event.title.appointment_deleted": "Termin gel\u00f6scht",
|
||||
"event.title.appointment_project_changed": "Termin verschoben",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Genehmigung entschieden",
|
||||
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
|
||||
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
|
||||
// a paired card with the original lifecycle event (e.g.
|
||||
// "Frist angelegt" + "Genehmigung erteilt von Bert").
|
||||
@@ -4143,6 +4147,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Appointment updated",
|
||||
"event.title.appointment_deleted": "Appointment deleted",
|
||||
"event.title.appointment_project_changed": "Appointment moved",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Approval decided",
|
||||
"event.title.member_role_changed": "Team role changed",
|
||||
// 4-eye approval lifecycle (t-paliad-138).
|
||||
"event.title.deadline_approval_requested": "Approval requested",
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
|
||||
@@ -1604,6 +1604,7 @@ export type I18nKey =
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.approval_decided"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
| "event.title.checklist_linked"
|
||||
@@ -1622,6 +1623,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.member_role_changed"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
-- 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
|
||||
-- forward-reverse order so the matview drop doesn't trip on the
|
||||
-- deadline_rules delete.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'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.
|
||||
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 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);
|
||||
|
||||
-- =============================================================================
|
||||
-- 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.
|
||||
// - 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user