Merge: t-paliad-131 Phase C — search backend (matview + service + handler)

This commit is contained in:
m
2026-05-05 04:42:40 +02:00
8 changed files with 1119 additions and 0 deletions

View File

@@ -80,6 +80,15 @@ func main() {
if err != nil {
log.Fatalf("open db pool: %v", err)
}
// Refresh paliad.deadline_search whenever migrations run, so
// search reflects any newly-seeded rule / concept / trigger.
// Migration 047 created the matview already-populated; this
// is only a no-op for the boot that introduced it. CONCURRENTLY
// keeps reads online and stays well under 100 ms at < 1k rows.
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
log.Printf("refresh deadline_search: %v", err)
}
holidays := services.NewHolidayService(pool)
users := services.NewUserService(pool)
projectSvc := services.NewProjectService(pool, users)
@@ -133,6 +142,7 @@ func main() {
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays),
DeadlineSearch: services.NewDeadlineSearchService(pool),
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),

View File

@@ -0,0 +1,12 @@
-- t-paliad-131 Phase C rollback.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- Reverse the dangling-slug fix only if the wiedereinsetzung concept
-- exists (otherwise this migration was already partially rolled back).
UPDATE paliad.trigger_events
SET concept_id = 're-establishment-of-rights'
WHERE id IN (200, 201, 202, 203)
AND concept_id = 'wiedereinsetzung';
DELETE FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung';

View File

@@ -0,0 +1,164 @@
-- 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.';

View File

@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"strconv"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/tools/fristenrechner/search — unified search across the
// Fristenrechner concept layer (t-paliad-131 Phase C). Returns at most
// `limit` concept cards, each with its proceeding pills. Supports
// optional facet filters: party, proc (proceeding code), source
// (legal_source prefix).
//
// Returns an empty cards array (not 400) when q is empty — that lets
// the frontend boot the search input without a server round-trip.
func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.deadlineSearch == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Suche vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
q := r.URL.Query().Get("q")
opts := services.SearchOptions{
Party: r.URL.Query().Get("party"),
Proc: r.URL.Query().Get("proc"),
Source: r.URL.Query().Get("source"),
Limit: parseLimit(r.URL.Query().Get("limit")),
}
resp, err := dbSvc.deadlineSearch.Search(r.Context(), q, opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Suche fehlgeschlagen: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}
func parseLimit(raw string) int {
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}

View File

@@ -48,6 +48,7 @@ type Services struct {
Users *services.UserService
Fristenrechner *services.FristenrechnerService
EventDeadline *services.EventDeadlineService
DeadlineSearch *services.DeadlineSearchService
EventType *services.EventTypeService
Dashboard *services.DashboardService
Note *services.NoteService
@@ -79,6 +80,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
users: svc.Users,
fristenrechner: svc.Fristenrechner,
eventDeadline: svc.EventDeadline,
deadlineSearch: svc.DeadlineSearch,
eventType: svc.EventType,
dashboard: svc.Dashboard,
note: svc.Note,
@@ -133,6 +135,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
protected.HandleFunc("GET /downloads", handleDownloadsPage)
protected.HandleFunc("GET /glossary", handleGlossaryPage)
protected.HandleFunc("GET /api/glossary", handleGlossaryAPI)

View File

@@ -28,6 +28,7 @@ type dbServices struct {
users *services.UserService
fristenrechner *services.FristenrechnerService
eventDeadline *services.EventDeadlineService
deadlineSearch *services.DeadlineSearchService
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService

View File

@@ -0,0 +1,572 @@
package services
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// DeadlineSearchService backs the unified Fristenrechner search bar
// (t-paliad-131 Phase C). It reads from the paliad.deadline_search
// materialised view (migration 047) and groups hits per concept, so a
// single search returns one card per legal idea (Klageerwiderung,
// Wiedereinsetzung, …) with one pill per (proceeding × rule) or per
// trigger event under that concept.
//
// Two queries per request:
// 1. Rank concept_ids by trigram similarity against name / aliases /
// legal_source / rule_code, applying optional party / proc / source
// filters.
// 2. Fetch all matview rows for those concept_ids and assemble the
// per-pill payload.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6.
type DeadlineSearchService struct {
db *sqlx.DB
}
// NewDeadlineSearchService wires the service to its DB pool.
func NewDeadlineSearchService(db *sqlx.DB) *DeadlineSearchService {
return &DeadlineSearchService{db: db}
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings mean "no filter on this facet".
type SearchOptions struct {
Party string
Proc string
Source string
Limit int
MaxLimit int
}
// SearchFilters is the filter echo returned to the client. nil pointer
// means the facet wasn't filtered.
type SearchFilters struct {
Party *string `json:"party"`
Proc *string `json:"proc"`
Source *string `json:"source"`
}
// SearchResponse is the JSON the API hands back. See §6.1.
type SearchResponse struct {
Query string `json:"query"`
Filters SearchFilters `json:"filters"`
Cards []ConceptCard `json:"cards"`
TotalCards int `json:"total_cards"`
TotalPills int `json:"total_pills"`
}
// ConceptCard is one search hit — a concept plus its proceeding pills.
type ConceptCard struct {
Concept ConceptSummary `json:"concept"`
MatchedAliases []string `json:"matched_aliases,omitempty"`
Score float64 `json:"score"`
Pills []Pill `json:"pills"`
}
// ConceptSummary is the concept payload inside a card.
type ConceptSummary struct {
ID string `json:"id"`
Slug string `json:"slug"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Description *string `json:"description,omitempty"`
Party *string `json:"party,omitempty"`
Category string `json:"category"`
}
// PillProceeding describes the proceeding context of a rule pill. nil
// for trigger pills (cross-cutting events with no proceeding).
type PillProceeding struct {
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Jurisdiction string `json:"jurisdiction"`
}
// PillDuration is the duration spec of a rule pill. nil for trigger pills.
type PillDuration struct {
Value int `json:"value"`
Unit string `json:"unit"`
Timing *string `json:"timing,omitempty"`
}
// Pill is one row inside a concept card. Either a rule (with proceeding +
// duration) or a trigger (cross-cutting; just a code + name).
type Pill struct {
Kind string `json:"kind"`
RuleID *string `json:"rule_id,omitempty"`
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
Proceeding *PillProceeding `json:"proceeding,omitempty"`
RuleLocalCode string `json:"rule_local_code"`
RuleNameDE string `json:"rule_name_de"`
RuleNameEN string `json:"rule_name_en"`
LegalSource *string `json:"legal_source,omitempty"`
LegalSourceDisplay *string `json:"legal_source_display,omitempty"`
Duration *PillDuration `json:"duration,omitempty"`
Party string `json:"party"`
DrillURL string `json:"drill_url"`
}
// rankRow is the per-concept score row from query 1.
type rankRow struct {
ConceptID string `db:"concept_id"`
Score float64 `db:"score"`
AliasHit bool `db:"alias_hit"`
ConceptSortOrder int `db:"concept_sort_order"`
ConceptNameDE string `db:"concept_name_de"`
MatchedAliases pq.StringArray `db:"matched_aliases"`
}
// pillRow is the per-(concept, context) row from query 2.
type pillRow struct {
Kind string `db:"kind"`
ConceptID string `db:"concept_id"`
ConceptSlug string `db:"concept_slug"`
ConceptNameDE string `db:"concept_name_de"`
ConceptNameEN string `db:"concept_name_en"`
ConceptDesc sql.NullString `db:"concept_description"`
ConceptParty sql.NullString `db:"concept_party"`
ConceptCategory string `db:"concept_category"`
RuleID sql.NullString `db:"rule_id"`
TriggerEventID sql.NullInt64 `db:"trigger_event_id"`
ProceedingCode sql.NullString `db:"proceeding_code"`
ProceedingNameDE sql.NullString `db:"proceeding_name_de"`
ProceedingNameEN sql.NullString `db:"proceeding_name_en"`
Jurisdiction string `db:"jurisdiction"`
RuleLocalCode string `db:"rule_local_code"`
RuleNameDE string `db:"rule_name_de"`
RuleNameEN string `db:"rule_name_en"`
LegalSource sql.NullString `db:"legal_source"`
RuleCode sql.NullString `db:"rule_code"`
DurationValue sql.NullInt32 `db:"duration_value"`
DurationUnit sql.NullString `db:"duration_unit"`
Timing sql.NullString `db:"timing"`
EffectiveParty string `db:"effective_party"`
}
// Search runs the two-query pipeline and assembles the cards.
//
// q is the raw user input. Empty q returns an empty result set (no
// filtering across the entire matview — that's a "browse" surface
// the design doc reserves for Phase D).
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
limit := opts.Limit
if limit <= 0 {
limit = 12
}
maxLimit := opts.MaxLimit
if maxLimit <= 0 {
maxLimit = 30
}
if limit > maxLimit {
limit = maxLimit
}
resp := &SearchResponse{
Query: q,
Filters: buildFilters(opts),
Cards: []ConceptCard{},
}
qNorm := normalizeQuery(q)
if qNorm == "" {
return resp, nil
}
qLow := strings.ToLower(qNorm)
party := nullable(opts.Party)
proc := nullable(opts.Proc)
source := nullable(opts.Source)
ranks, err := s.rankConcepts(ctx, qNorm, qLow, party, proc, source, limit)
if err != nil {
return nil, err
}
if len(ranks) == 0 {
return resp, nil
}
conceptIDs := make([]string, len(ranks))
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source)
if err != nil {
return nil, err
}
cards, totalPills := assembleCards(ranks, pills)
resp.Cards = cards
resp.TotalCards = len(cards)
resp.TotalPills = totalPills
return resp, nil
}
func (s *DeadlineSearchService) rankConcepts(
ctx context.Context,
q, qLow string,
party, proc, source *string,
limit int,
) ([]rankRow, error) {
const sqlText = `
WITH matched AS (
SELECT
s.concept_id,
s.concept_sort_order,
s.concept_name_de,
GREATEST(
similarity(s.concept_name_de, $1) * 1.0,
similarity(s.concept_name_en, $1) * 1.0,
COALESCE(similarity(s.legal_source, $1), 0) * 0.9,
COALESCE(similarity(s.rule_code, $1), 0) * 0.9,
similarity(s.rule_name_de, $1) * 0.7,
similarity(s.rule_name_en, $1) * 0.7
) AS row_score,
EXISTS (
SELECT 1 FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
) AS row_alias_hit,
ARRAY(
SELECT DISTINCT a FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
) AS row_matched_aliases
FROM paliad.deadline_search s
WHERE (
s.concept_name_de % $1
OR s.concept_name_en % $1
OR s.rule_name_de % $1
OR s.rule_name_en % $1
OR (s.legal_source IS NOT NULL AND s.legal_source % $1)
OR (s.rule_code IS NOT NULL AND s.rule_code % $1)
OR EXISTS (
SELECT 1 FROM unnest(s.concept_aliases) a
WHERE lower(a) = $2 OR a % $1
)
)
AND ($3::text IS NULL OR s.effective_party = $3)
AND ($4::text IS NULL OR s.proceeding_code = $4)
AND ($5::text IS NULL OR s.legal_source LIKE $5 || '%')
)
SELECT
m.concept_id,
bool_or(m.row_alias_hit) AS alias_hit,
max(m.row_score) + CASE WHEN bool_or(m.row_alias_hit)
THEN 0.2 ELSE 0 END AS score,
min(m.concept_sort_order) AS concept_sort_order,
min(m.concept_name_de) AS concept_name_de,
-- All rows in a concept share the same aliases; min() over identical
-- text[] values is well-defined and returns one of them verbatim.
COALESCE(min(m.row_matched_aliases), ARRAY[]::text[]) AS matched_aliases
FROM matched m
GROUP BY m.concept_id
ORDER BY score DESC, concept_sort_order ASC, concept_name_de ASC
LIMIT $6
`
var rows []rankRow
if err := s.db.SelectContext(ctx, &rows, sqlText, q, qLow, party, proc, source, limit); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
return rows, nil
}
func (s *DeadlineSearchService) loadPills(
ctx context.Context,
conceptIDs []string,
party, proc, source *string,
) ([]pillRow, error) {
const sqlText = `
SELECT
s.kind,
s.concept_id,
s.concept_slug,
s.concept_name_de,
s.concept_name_en,
s.concept_description,
s.concept_party,
s.concept_category,
s.rule_id,
s.trigger_event_id,
s.proceeding_code,
s.proceeding_name_de,
s.proceeding_name_en,
s.jurisdiction,
s.rule_local_code,
s.rule_name_de,
s.rule_name_en,
s.legal_source,
s.rule_code,
s.duration_value,
s.duration_unit,
s.timing,
s.effective_party
FROM paliad.deadline_search s
WHERE s.concept_id = ANY($1::uuid[])
AND ($2::text IS NULL OR s.effective_party = $2)
AND ($3::text IS NULL OR s.proceeding_code = $3)
AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
ORDER BY s.concept_id, s.kind, s.proceeding_code NULLS LAST, s.rule_local_code
`
var rows []pillRow
if err := s.db.SelectContext(ctx, &rows, sqlText, pq.Array(conceptIDs), party, proc, source); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}
return rows, nil
}
// assembleCards groups pillRows under their ranked concept and builds
// the JSON cards in score order.
func assembleCards(ranks []rankRow, pills []pillRow) ([]ConceptCard, int) {
pillsByConcept := make(map[string][]pillRow, len(ranks))
for _, p := range pills {
pillsByConcept[p.ConceptID] = append(pillsByConcept[p.ConceptID], p)
}
cards := make([]ConceptCard, 0, len(ranks))
totalPills := 0
for _, r := range ranks {
ps := pillsByConcept[r.ConceptID]
if len(ps) == 0 {
continue
}
// First row carries the concept fields.
first := ps[0]
concept := ConceptSummary{
ID: first.ConceptID,
Slug: first.ConceptSlug,
NameDE: first.ConceptNameDE,
NameEN: first.ConceptNameEN,
Category: first.ConceptCategory,
}
if first.ConceptDesc.Valid {
concept.Description = &first.ConceptDesc.String
}
if first.ConceptParty.Valid {
concept.Party = &first.ConceptParty.String
}
cardPills := make([]Pill, 0, len(ps))
for _, p := range ps {
cardPills = append(cardPills, buildPill(p))
}
// Stable secondary order: rule pills before trigger pills, then by
// jurisdiction-ish ordering (UPC, EU, DE, cross-cutting).
sort.SliceStable(cardPills, func(i, j int) bool {
return pillSortKey(cardPills[i]) < pillSortKey(cardPills[j])
})
card := ConceptCard{
Concept: concept,
MatchedAliases: []string(r.MatchedAliases),
Score: roundScore(r.Score),
Pills: cardPills,
}
cards = append(cards, card)
totalPills += len(cardPills)
}
return cards, totalPills
}
func buildPill(p pillRow) Pill {
pill := Pill{
Kind: p.Kind,
RuleLocalCode: p.RuleLocalCode,
RuleNameDE: p.RuleNameDE,
RuleNameEN: p.RuleNameEN,
Party: p.EffectiveParty,
}
if p.RuleID.Valid {
pill.RuleID = &p.RuleID.String
}
if p.TriggerEventID.Valid {
v := p.TriggerEventID.Int64
pill.TriggerEventID = &v
}
if p.ProceedingCode.Valid {
pill.Proceeding = &PillProceeding{
Code: p.ProceedingCode.String,
NameDE: p.ProceedingNameDE.String,
NameEN: p.ProceedingNameEN.String,
Jurisdiction: p.Jurisdiction,
}
}
if p.LegalSource.Valid {
ls := p.LegalSource.String
pill.LegalSource = &ls
display := FormatLegalSourceDisplay(ls)
if display != "" {
pill.LegalSourceDisplay = &display
}
}
if p.DurationValue.Valid && p.DurationUnit.Valid {
dur := &PillDuration{
Value: int(p.DurationValue.Int32),
Unit: p.DurationUnit.String,
}
if p.Timing.Valid && p.Timing.String != "" && p.Timing.String != "after" {
t := p.Timing.String
dur.Timing = &t
}
pill.Duration = dur
}
pill.DrillURL = pillDrillURL(p)
return pill
}
func pillDrillURL(p pillRow) string {
switch p.Kind {
case "rule":
if p.ProceedingCode.Valid && p.RuleLocalCode != "" {
return "/tools/fristenrechner?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
}
return "/tools/fristenrechner"
case "trigger":
if p.TriggerEventID.Valid {
return fmt.Sprintf("/tools/fristenrechner?mode=event&triggerId=%d", p.TriggerEventID.Int64)
}
return "/tools/fristenrechner?mode=event"
}
return "/tools/fristenrechner"
}
// pillSortKey orders pills inside a card: rule pills before triggers,
// jurisdictions in HLC working order (UPC > EU > DE > DPMA > other),
// then by rule_local_code.
func pillSortKey(p Pill) string {
kindRank := "1"
if p.Kind == "trigger" {
kindRank = "2"
}
jurRank := "9"
if p.Proceeding != nil {
switch p.Proceeding.Jurisdiction {
case "UPC":
jurRank = "1"
case "EU":
jurRank = "2"
case "DE":
jurRank = "3"
case "DPMA":
jurRank = "4"
case "cross-cutting":
jurRank = "8"
}
}
return kindRank + jurRank + p.RuleLocalCode
}
func nullable(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
func buildFilters(opts SearchOptions) SearchFilters {
return SearchFilters{
Party: nullable(opts.Party),
Proc: nullable(opts.Proc),
Source: nullable(opts.Source),
}
}
// normalizeQuery strips legal-prefix noise that users naturally type but
// the structured legal_source column doesn't contain. § 82 → 82, Art.108
// → 108, RoP R.23 → RoP 23. The trigram match runs on the result.
func normalizeQuery(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return ""
}
// Drop common legal prefixes (§, Art., Section, Sec., R., Rule).
// We keep meaningful tokens like "RoP", "ZPO", "PatG" because they
// help narrow the search via trigram on legal_source / rule_code.
lowers := strings.ToLower(q)
for _, prefix := range []string{"§", "art.", "art ", "section ", "sec.", "sec ", "rule "} {
for strings.HasPrefix(lowers, prefix) {
q = strings.TrimSpace(q[len(prefix):])
lowers = strings.ToLower(q)
}
}
return q
}
// roundScore truncates to 4 decimals so JSON stays compact.
func roundScore(v float64) float64 {
return float64(int(v*10000+0.5)) / 10000
}
// FormatLegalSourceDisplay renders a structured legal_source code into
// the form HLC users read in pleadings:
//
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
// UPC.RoP.139 → "UPC RoP R.139"
// DE.PatG.82.1 → "PatG §82(1)"
// DE.ZPO.276.1 → "ZPO §276(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)"
//
// Returns the empty string for an empty input. Unknown jurisdictions
// fall through with the structured form preserved (caller decides
// whether to display).
func FormatLegalSourceDisplay(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
// Malformed — return as-is so the caller still has something.
return src
}
code := parts[1]
rest := parts[2:]
var prefix string
switch code {
case "RoP":
prefix = "UPC RoP R."
case "PatG":
prefix = "PatG §"
case "ZPO":
prefix = "ZPO §"
case "EPÜ":
prefix = "EPÜ Art."
case "EPC-R":
prefix = "EPC R."
case "RPBA":
prefix = "RPBA Art."
default:
prefix = code + " "
}
var b strings.Builder
b.Grow(len(prefix) + len(src))
b.WriteString(prefix)
b.WriteString(rest[0])
for _, p := range rest[1:] {
b.WriteByte('(')
b.WriteString(p)
b.WriteByte(')')
}
return b.String()
}
// RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go
// right after the migration runner finishes so search reflects any
// newly-applied seed migration.
func RefreshSearchView(ctx context.Context, db *sqlx.DB) error {
_, err := db.ExecContext(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY paliad.deadline_search`)
if err != nil {
return fmt.Errorf("refresh deadline_search: %w", err)
}
return nil
}

View File

@@ -0,0 +1,308 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestFormatLegalSourceDisplay covers the structured-form → display-form
// conversion the search API exposes alongside legal_source.
func TestFormatLegalSourceDisplay(t *testing.T) {
cases := []struct {
in, want string
}{
{"UPC.RoP.23.1", "UPC RoP R.23(1)"},
{"UPC.RoP.139", "UPC RoP R.139"},
{"UPC.RoP.220.1", "UPC RoP R.220(1)"},
{"DE.PatG.82.1", "PatG §82(1)"},
{"DE.PatG.111.1", "PatG §111(1)"},
{"DE.PatG.59.3", "PatG §59(3)"},
{"DE.ZPO.276.1", "ZPO §276(1)"},
{"DE.ZPO.517", "ZPO §517"},
{"EU.EPÜ.108", "EPÜ Art.108"},
{"EU.EPÜ.112a", "EPÜ Art.112a"},
{"EU.EPC-R.79.1", "EPC R.79(1)"},
{"EU.RPBA.12.1.c", "RPBA Art.12(1)(c)"},
{"", ""},
}
for _, c := range cases {
got := FormatLegalSourceDisplay(c.in)
if got != c.want {
t.Errorf("FormatLegalSourceDisplay(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix.
func TestNormalizeQuery(t *testing.T) {
cases := []struct {
in, want string
}{
{"§ 82", "82"},
{"§82", "82"},
{"Art. 108", "108"},
{"art.108", "108"},
{"Section 276", "276"},
{" Wiedereinsetzung ", "Wiedereinsetzung"},
{"RoP 23", "RoP 23"}, // RoP is meaningful, kept verbatim
{"", ""},
}
for _, c := range cases {
got := normalizeQuery(c.in)
if got != c.want {
t.Errorf("normalizeQuery(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestDeadlineSearch is the Phase C golden table — the binding spec for
// what the search backend must answer for a fixed set of HLC vocabulary
// queries. Skipped when TEST_DATABASE_URL is unset, mirroring the other
// live-DB tests in this package.
func TestDeadlineSearch(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
if err := RefreshSearchView(ctx, pool); err != nil {
t.Fatalf("refresh search view: %v", err)
}
svc := NewDeadlineSearchService(pool)
t.Run("Klageerwiderung returns the statement-of-defence concept with multi-jurisdiction pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "statement-of-defence")
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
// The actual data has 9 rule rows (UPC_INF, UPC_REV, UPC_PI,
// UPC_DAMAGES, UPC_DISCOVERY, DE_INF, DE_NULL, EPA_OPP, DPMA_OPP).
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
mustHaveLegalSource(t, card, "DE.PatG.59.3")
mustHaveProceedingCodes(t, card, "UPC_INF", "DE_INF", "DE_NULL", "EPA_OPP", "DPMA_OPP")
})
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
resp, err := svc.Search(ctx, "RoP 23", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) == 0 {
t.Fatalf("expected at least one card for 'RoP 23', got 0")
}
// Statement-of-defence is the canonical R.23 concept; it must be
// among the cards (not necessarily rank 1 because rule_code-based
// trigram only weighs 0.9, and several rules with adjacent codes
// also hit).
card := findCardBySlug(t, resp, "statement-of-defence")
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
})
t.Run("§ 82 returns the BPatG nullity-defence card", func(t *testing.T) {
resp, err := svc.Search(ctx, "§ 82", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
// PatG §82(1) is the BPatG Klageerwiderung anchor — should fall
// under the statement-of-defence concept.
card := findCardBySlug(t, resp, "statement-of-defence")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 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.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
t.Errorf("Wiedereinsetzung card has non-trigger pill: kind=%q proc=%v", p.Kind, p.Proceeding)
}
if p.TriggerEventID != nil {
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))
}
for _, id := range triggerIDs {
if !want[id] {
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
}
}
})
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 {
t.Fatalf("search: %v", err)
}
// Statement-of-defence is filed by the defendant. Filtering
// party=claimant should NOT drop the concept entirely — the
// effective_party can vary per pill (e.g. EPA_OPP Erwiderung
// is owed by the patentee/claimant). At least it must not
// return any card with EVERY pill on defendant side.
for _, c := range resp.Cards {
allDefendant := true
for _, p := range c.Pills {
if p.Party != "defendant" {
allDefendant = false
break
}
}
if allDefendant {
t.Errorf("party=claimant filter returned a card whose every pill is defendant: %q", c.Concept.Slug)
}
}
})
t.Run("source filter narrows to UPC.RoP-prefixed pills only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Source: "UPC.RoP", Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.LegalSource != nil && !startsWith(*p.LegalSource, "UPC.RoP") {
t.Errorf("source=UPC.RoP filter leaked a non-UPC.RoP pill: %q", *p.LegalSource)
}
}
}
})
t.Run("empty query returns empty cards (browse surface is Phase D)", func(t *testing.T) {
resp, err := svc.Search(ctx, "", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) != 0 {
t.Errorf("empty q should return 0 cards, got %d", len(resp.Cards))
}
})
t.Run("limit is capped at MaxLimit", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 1000, MaxLimit: 5})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) > 5 {
t.Errorf("limit cap not enforced: got %d cards, want ≤ 5", len(resp.Cards))
}
})
t.Run("legal_source_display is rendered for rule pills with a legal_source", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "statement-of-defence")
for _, p := range card.Pills {
if p.LegalSource == nil {
continue
}
if p.LegalSourceDisplay == nil || *p.LegalSourceDisplay == "" {
t.Errorf("rule pill with legal_source=%q is missing legal_source_display", *p.LegalSource)
}
}
})
}
func findCardBySlug(t *testing.T, resp *SearchResponse, slug string) ConceptCard {
t.Helper()
for _, c := range resp.Cards {
if c.Concept.Slug == slug {
return c
}
}
t.Fatalf("expected concept slug %q in cards, got: %v", slug, conceptSlugs(resp.Cards))
return ConceptCard{}
}
func conceptSlugs(cards []ConceptCard) []string {
out := make([]string, len(cards))
for i, c := range cards {
out[i] = c.Concept.Slug
}
return out
}
func mustHaveLegalSource(t *testing.T, card ConceptCard, want string) {
t.Helper()
for _, p := range card.Pills {
if p.LegalSource != nil && *p.LegalSource == want {
return
}
}
t.Errorf("concept %q card missing pill with legal_source=%q. Got: %v", card.Concept.Slug, want, pillSources(card.Pills))
}
func mustHaveProceedingCodes(t *testing.T, card ConceptCard, codes ...string) {
t.Helper()
have := map[string]bool{}
for _, p := range card.Pills {
if p.Proceeding != nil {
have[p.Proceeding.Code] = true
}
}
for _, c := range codes {
if !have[c] {
t.Errorf("concept %q card missing proceeding pill %q. Got: %v", card.Concept.Slug, c, mapKeys(have))
}
}
}
func pillSources(pills []Pill) []string {
out := []string{}
for _, p := range pills {
if p.LegalSource != nil {
out = append(out, *p.LegalSource)
} else {
out = append(out, "(null)")
}
}
return out
}
func mapKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func startsWith(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
return s[:len(prefix)] == prefix
}