Files
paliad/internal/db/migrations/047_deadline_search_view.up.sql
m b45278b060 feat(t-paliad-131): Phase C — search backend (matview + service + handler)
Closes the search half of the unified Fristenrechner. Phase D (concept-card
UI on /tools/fristenrechner) follows in a subsequent shift.

Migration 047:
  - Seed the missing `wiedereinsetzung` concept and re-point the four
    Wiedereinsetzung trigger_events (200..203) at it. PR-7 referenced
    the slug `re-establishment-of-rights` but never seeded the concept,
    so the four cross-cutting triggers were dropping out of any concept-
    JOINing query. Per m's slug rule (Q1: shared cross-cutting concepts
    use DE slug because German term dominates HLC vocabulary).
  - Create paliad.deadline_search materialised view: UNION ALL of
    (deadline_rules joined to deadline_concepts) and (trigger_events
    joined to deadline_concepts via slug). Trigram GIN indexes on
    legal_source / concept_name_de / concept_name_en / rule_name_de /
    rule_name_en / rule_code; gin (concept_aliases) for array
    containment; UNIQUE INDEX on a synthetic row_key so refresh can
    run CONCURRENTLY.

Refresh strategy: data only mutates via migration files at server
startup, so no AFTER triggers and no pg_cron — main.go calls
services.RefreshSearchView right after db.ApplyMigrations. CONCURRENTLY
keeps reads online and stays well under 100 ms at < 1k rows.

Service `internal/services/deadline_search_service.go`:
  - Two-query pipeline per request: (1) rank concept_ids by
    GREATEST(similarity()) across name / aliases / legal_source / rule_code
    plus a 0.2 alias-hit boost; (2) load all matview rows for the top-N
    concepts and assemble per-pill JSON.
  - normalizeQuery strips legal-prefix noise (`§`, `Art.`, `Section`,
    `Rule `) so users typing `§ 82` find DE.PatG.82.1 even though the
    structured legal_source column doesn't carry the prefix.
  - FormatLegalSourceDisplay renders structured codes back to the
    pleading form HLC users expect:
        UPC.RoP.23.1   → "UPC RoP R.23(1)"
        DE.PatG.82.1   → "PatG §82(1)"
        EU.EPÜ.108     → "EPÜ Art.108"
        EU.EPC-R.79.1  → "EPC R.79(1)"
        EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
  - Drill URLs route per kind: rule pills → ?proc=…&focus=…, trigger
    pills → ?mode=event&triggerId=…

Handler `GET /api/tools/fristenrechner/search?q=&party=&proc=&source=&limit=`:
  - Returns the JSON shape from design §6.1 (cards-with-pills).
  - 503 with friendly DE message when DATABASE_URL is unset, mirroring
    the other Fristenrechner endpoints.
  - Empty q returns an empty cards array (browse surface is Phase D).

Tests:
  - Pure-Go: TestFormatLegalSourceDisplay (12 cases across all known
    prefixes) + TestNormalizeQuery (8 cases).
  - Integration (skipped without TEST_DATABASE_URL): golden table
    pinning the design's binding queries — Klageerwiderung returns the
    statement-of-defence card with UPC.RoP.23.1, DE.ZPO.276.1,
    DE.PatG.82.1, EU.EPC-R.79.1, DE.PatG.59.3 pills; "RoP 23" returns
    the same card; "§ 82" → normalized "82" → BPatG hit; Wiedereinsetzung
    returns one card with exactly 4 trigger pills (ids 200..203);
    party / source filters narrow as expected; limit cap honoured.
  - SQL semantics validated against live data via supabase MCP using a
    CTE-inlined matview definition with the slug fix simulated; results
    match the golden table.

Per design doc `docs/plans/unified-fristenrechner.md` §4.6 (matview
shape) + §6 (search ranking + API).
2026-05-05 04:32:50 +02:00

165 lines
6.9 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- t-paliad-131 Phase C: search backend.
--
-- Two artefacts in one migration:
--
-- 1. Fix a dangling concept slug from PR-7 (Phase B6). The four
-- Wiedereinsetzung trigger_events were inserted referencing concept
-- slug `re-establishment-of-rights`, but the concept was never
-- seeded — the slug was English-style while the concept-naming
-- convention (m's Q1, design §11) requires DE slugs for shared
-- cross-cutting concepts where the German term dominates HLC
-- vocabulary. Without the fix, the four Wiedereinsetzung rows
-- drop out of the unified search view's INNER JOIN and the
-- golden test `search "Wiedereinsetzung" → 1 concept × 4 pills`
-- cannot pass.
--
-- 2. Create paliad.deadline_search — a materialised view that
-- flattens (deadline_concepts × deadline_rules) and (deadline_concepts
-- × trigger_events via slug) into one searchable shape. Indexed
-- with pg_trgm GIN on the columns the search query probes. See
-- design doc §4.6 + §6.
--
-- Refresh strategy: data only mutates via migration files at server
-- startup, so a refresh after migrations apply (in main.go) is enough.
-- No AFTER triggers, no pg_cron — keeps the migration pure SQL.
-- ============================================================================
-- 1. Wiedereinsetzung concept (fixes the PR-7 dangling slug)
-- ============================================================================
INSERT INTO paliad.deadline_concepts (slug, name_de, name_en, description, aliases, party, category, sort_order)
VALUES (
'wiedereinsetzung',
'Wiedereinsetzung in den vorigen Stand',
'Re-establishment of Rights',
'Antrag auf Wiedereinsetzung in den vorigen Stand bei unverschuldeter Versäumung einer gesetzlichen Frist. Die Frist beträgt zwei Monate ab Wegfall des Hindernisses (PatG §123, ZPO §233, EPÜ Art.122).',
ARRAY['Wiedereinsetzung', 'Wiedereinsetzungsantrag', 'Re-establishment of Rights', 'Re-establishment', 'Antrag auf Wiedereinsetzung', 'Restitutio', 'restitutio in integrum'],
'both',
'submission',
35
);
UPDATE paliad.trigger_events
SET concept_id = 'wiedereinsetzung'
WHERE id IN (200, 201, 202, 203)
AND concept_id = 're-establishment-of-rights';
-- ============================================================================
-- 2. Materialised search view + indexes
-- ============================================================================
--
-- One row per (concept × context). Two contexts:
-- kind='rule' — concept-linked deadline_rule under an active
-- fristenrechner-category proceeding_type
-- kind='trigger' — concept-linked trigger_event (cross-cutting; no
-- proceeding context, no rule_id, no duration)
--
-- Search ranks rows per concept_id, then per concept fans out to all its
-- rule + trigger pills. row_key is a synthetic UNIQUE column required for
-- REFRESH MATERIALIZED VIEW CONCURRENTLY.
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,
dr.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,
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;
-- Required for REFRESH MATERIALIZED VIEW CONCURRENTLY.
CREATE UNIQUE INDEX deadline_search_row_key
ON paliad.deadline_search (row_key);
-- Btree for filter narrowing.
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);
-- Trigram GIN for the LIKE / similarity()-driven WHERE clause.
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);
-- Array containment for `aliases @> ARRAY[…]`.
CREATE INDEX deadline_search_aliases
ON paliad.deadline_search USING gin (concept_aliases);
COMMENT ON MATERIALIZED VIEW paliad.deadline_search IS
'Phase C unified search backend. Refreshed CONCURRENTLY by main.go '
'after migrations apply at server startup. Source data: '
'deadline_rules trigger_events, joined to deadline_concepts. '
'See docs/plans/unified-fristenrechner.md §4.6 + §6.';