Merge: t-paliad-131 Phase C — search backend (matview + service + handler)
This commit is contained in:
@@ -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),
|
||||
|
||||
12
internal/db/migrations/047_deadline_search_view.down.sql
Normal file
12
internal/db/migrations/047_deadline_search_view.down.sql
Normal 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';
|
||||
164
internal/db/migrations/047_deadline_search_view.up.sql
Normal file
164
internal/db/migrations/047_deadline_search_view.up.sql
Normal 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.';
|
||||
49
internal/handlers/fristenrechner_search.go
Normal file
49
internal/handlers/fristenrechner_search.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
572
internal/services/deadline_search_service.go
Normal file
572
internal/services/deadline_search_service.go
Normal 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
|
||||
}
|
||||
308
internal/services/deadline_search_service_test.go
Normal file
308
internal/services/deadline_search_service_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user