PL ↔ UK Live Comparison Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: One research question (NL or chips) → the same base-schema filter run for PL and UK → side-by-side value distributions with per-jurisdiction coverage, saveable as a paired collection, exportable as long CSV, addressable by permalink.
Architecture: The 300-line WHERE clause of filter_documents_by_extracted_data moves into one shared plpgsql set-returning function (list_extracted_filter_matches) that returns matching (id, jurisdiction); the existing RPC becomes a thin wrapper (signature unchanged) and a new RPC get_extracted_facet_counts_by_jurisdiction aggregates one field over that set with GROUP BY jurisdiction, returning counts plus total/covered. A new FastAPI package app/compare/ turns rows into a CompareResponse (tiers, shares, coverage flags live in pure Python so UI and CSV agree), and a collection_pairs table plus /collections/pairs router persists "PL/UK" pairs. A new Next.js route /compare (and /compare/[pairId]) renders the response with the existing Plotly BivariateBarChart, promoted to components/charts/.
Tech Stack: Postgres/plpgsql (Supabase), FastAPI + Pydantic v2, supabase-py, pandas (CSV), Next.js 15 App Router, React Query, Plotly via lib/charts/editorial-plot.ts, Jest + Testing Library, pytest (unit and db tiers).
Spec: Spec C (scratchpad specs/spec-C.md, mirrored below under Spec summary). Depends on Spec B (specs/spec-B.md) and Spec A AC1 (extraction over a collection). Known data defects: docs/reference/APP_STATUS_2026-08-21.md §4.
Spec summary (Spec C)¶
- On
/compare, a filter (NL or chips) without a jurisdiction runs for PL and UK; page shows N_PL, N_UK and enum/boolean fields (appeal_outcome,offender_gender,sentence_serve,plea_point, …) as side-by-side bars with %. - A field with coverage < 80 % in either jurisdiction gets a badge "coverage PL x % / UK y %" and is never in the first row; a field at 0 % in one jurisdiction is hidden behind a message, never rendered as "0".
- "Save as collection pair" creates "
— PL" and " — UK" linked by a comparison id; /collectionsshows them as a pair. - After an extended-schema extraction over both collections (Spec A),
/compare/{comparison_id}shows the new fields side by side. - "Export" gives long CSV (
field, value, jurisdiction, count, share, coverage) and a permalink encoding the filter. - Regression: static
/dataset-comparisonkeeps working; existing facet RPCs keep their signature (new function alongside).
Out of scope: significance tests, PL↔UK topic taxonomy alignment, fixing case_type/court_level.
Global Constraints¶
- Branch
feat/compare-pl-ukfromorigin/mainafter Spec B has merged; work in.worktrees/feat-compare-pl-uk(superpowers:using-git-worktrees).frontend/node_modulesis absent in a worktree:ln -s ../../../frontend/node_modules frontend/node_modulesbefore the firstnpx. - Commit messages: Conventional Commits, footer
Refs #<C>where#<C>is the issue created from Spec C; alsoRefs #537on tasks touching seed/empty states. No Claude/co-author footers (CLAUDE.md). docs/superpowers/is gitignored; this plan is not committed.- Required CI checks must stay green:
Backend Lint,Backend Unit Tests,Frontend Lint,Frontend Unit Tests,Frontend E2E Smoke (UI-only),Database Contract,Frontend Route Contract (Chromium). - Every new
.rpc("…")/.table("…")call must land in the same PR as its migration (backend/tests/app/test_db_contract_static.py). - New RLS policies use
(SELECT auth.uid()), never bareauth.uid()(supabase/migrations/20260623000002_optimize_rls_auth_init_plan.sql). - Design system: only
@/components/editorialprimitives andlib/charts/editorial-plot.tscolours (editorialSeries.pl = #9A342D,editorialSeries.uk = #000000). No gradients, no rounded cards, nobg-indigo/purple/violet-*. Numbers infont-monotabular. - i18n: every user-visible string on the new pages goes through
t('compare.*')(frontend/lib/i18n/types.ts,translations/en.ts,translations/pl.ts— all three). - Coverage threshold constant
LOW_COVERAGE_THRESHOLD = 0.80exists in exactly one place (backend/app/compare/layout.py); the frontend never re-derives tiers. - Migrations sort after Spec B's: use prefix
20260921(verify withls supabase/migrations | tail -5before creating). - Dev commands: backend
cd backend && poetry run pytest -m unit -q, db tierDB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres poetry run pytest -m db -q(needsdocker run -d --name db-contract -e POSTGRES_PASSWORD=postgres -p 5432:5432 pgvector/pgvector:pg17), frontendcd frontend && npx jest <path>,npm run validate && npm run typecheck. rm/cpare interactive aliases on this machine →command rm -f,command cp -f.
Interface assumed from Spec B (must be true before Task 1)¶
Spec B lands first. This plan assumes, and Task 1 Step 1 verifies:
| Layer | Assumed contract |
|---|---|
RPC filter_documents_by_extracted_data(p_filters JSONB, p_text_query TEXT, p_limit INT, p_offset INT) |
signature unchanged; p_filters additionally honours "jurisdiction": ["PL","UK"] (j.jurisdiction = ANY(...)) and "decision_date": {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"} (j.decision_date >= from AND j.decision_date <= to). |
backend/app/extraction_domain/nl_filter_generator.py::BaseSchemaFilter |
jurisdiction: list[Literal["PL","UK"]] \| None, decision_date: DateRange \| None; to_rpc_payload() still returns {"filters": {...}, "text_query": ...}. |
frontend/types/base-schema-filter.ts::BaseSchemaFilters |
jurisdiction?: ("PL" \| "UK")[], decision_date?: DateRange. |
frontend/lib/extractions/use-extracted-data-filters.ts |
unchanged: encodeFilters, decodeFilters, useExtractedDataFilters() (URL ?f=<base64url json>&q=<text>). |
"Save as collection" on /search/extractions |
uses POST /collections + POST /collections/{id}/documents/batch (≤ 100 ids/request, frontend/lib/api/collections.ts::addDocumentsToCollection, chunkDocumentIds). Spec C does not reuse that UI; pair creation is server-side (Task 9) so it works for up to 5 000 ids per side in one request. |
If B's migration names its clauses differently, copy B's exact clause text into the shared function in Task 1 — the JSON keys are the contract, not the SQL text.
Decisions¶
Route: new /compare, keep /dataset-comparison¶
/dataset-comparison is a corpus-level research artifact (text lengths, judges, vocabulary — data a live filter cannot produce) linked from the footer, command palette, docs/reference/sidebar-map.md, ROUTES_AUDIT.md, and pinned by NAV_LINKED_ROUTES in frontend/tests/unit/navigation/route-reachability.test.ts:56-67. Repurposing it would break AC6 and mix two data regimes. /compare is a query-scoped tool. The static page gets a one-line "Live comparison →" link (Task 19); a redirect can be decided later, out of scope.
Shared SQL: one set-returning plpgsql function, not a row predicate¶
list_extracted_filter_matches(p_filters JSONB, p_text_query TEXT) RETURNS TABLE (id UUID, jurisdiction TEXT) receives the whole DECLARE block and WHERE clause verbatim from 20260505000001_extend_base_schema_filterable_searchable.sql:141-449. filter_documents_by_extracted_data becomes SELECT … FROM list_extracted_filter_matches(...) m JOIN judgments j ON j.id = m.id ORDER BY … LIMIT/OFFSET — same signature, same rows, same order. The facet RPC joins the same function. A per-row boolean predicate function was rejected: it hides the WHERE from the planner and disables the Tier-1 indexes; an inlinable LANGUAGE sql rewrite was rejected because the parsing block is plpgsql and a 300-line rewrite is where regressions would come from. Cost of materialising the id set: the corpus is 12 307 rows (APP_STATUS_2026-08-21.md §4), so ≤ 12 307 (uuid, text) tuples per call. Exposed as a public RPC because Task 9 (pair creation) needs the id set server-side.
p_filters gains one additive key, "collection_ids": ["<uuid>", …] → EXISTS (SELECT 1 FROM collection_judgments cj WHERE cj.collection_id = ANY(v_collection_ids) AND cj.judgment_id = j.id::text) (judgment_id is TEXT: 20260509000003_create_collections_tables.sql:19-20). This is what lets /compare/{pairId} compute base facets over the actual collection membership.
Facet RPC shape¶
get_extracted_facet_counts_by_jurisdiction(p_filters JSONB, field_path TEXT, p_text_query TEXT) RETURNS TABLE (jurisdiction TEXT, value TEXT, count BIGINT, total BIGINT, covered BIGINT, coverage NUMERIC). p_text_query is added to the spec's (p_filters, field_path) because the NL dialog splits free text out of filters and the comparison must honour it. Totals LEFT JOIN values, so a jurisdiction that matched documents but has no filled value yields one row with value IS NULL, count IS NULL carrying total/covered=0 — that is how the backend learns N_PL/N_UK without a second RPC.
Coverage semantics (precise)¶
total(J)= number of judgments in jurisdiction J matched byp_filters/p_text_query(always withbase_extraction_status = 'completed', as today).covered(J, field)= number of those where the column is filled:text[]→EXISTS (SELECT 1 FROM unnest(col) e WHERE e IS NOT NULL AND e <> '');text→NULLIF(BTRIM(col), '') IS NOT NULL;boolean/numeric/date →col IS NOT NULL.coverage(J, field)=covered / total,NULLwhentotal = 0.share(J, field, value)=count / covered(J, field)— "% of documents where the field is coded". Denominator iscovered, nottotal, so a coverage gap does not masquerade as a distribution difference; coverage is shown separately (that is AC2's point). CSV carriescount,share,coverage,covered,totalso any other ratio is recomputable.- Tiers (policy, in
backend/app/compare/layout.py):unavailableifcovered = 0in ≥ 1 jurisdiction (chart hidden, message names the jurisdiction(s) and0 of total);emptyiftotal = 0everywhere;partialifmin(coverage) < 0.80(badge, second section);primaryotherwise. - Where the logic lives: counts in SQL (only place with rows; one scan; auditable integers), ratios + tiers in the backend (pure functions, unit-testable without a DB, shared by JSON response and CSV export so they cannot disagree), frontend renders flags only.
Pairing: collection_pairs table, not a column¶
A comparison_id column on collections cannot store the originating filter (needed for AC5's permalink and for re-running), needs a second column for the role, and makes "pair" a convention. A row in collection_pairs (id, user_id, name, filters, text_query, pl_collection_id, uk_collection_id) has its own identity, ON DELETE CASCADE from either collection, UNIQUE on each side (a collection belongs to at most one pair), and its own RLS. GET /collections adds pair: {id, role, partner_collection_id} | null per collection.
DELETE /collections/pairs/{id} unlinks only (collections survive); deleting either collection cascades the pair row.
Jurisdiction key in the compare filter¶
/compare always splits by jurisdiction. If the incoming filter carries jurisdiction, the backend strips it and reports ignored_filter_keys: ["jurisdiction"]; the UI shows a notice chip. Deterministic, no silent one-sided results.
Field selection¶
Comparable = enum (enum on the property or on items.enum) or boolean. Derived from the base JSON Schema (backend/packages/juddges_search/config/schema/base_legal_schema.json) via BaseSchemaExtractor().schema["properties"] → 17 fields. Order: appeal_outcome, offender_gender, sentence_serve, plea_point first (spec), then x-ui-order. The same derivation runs on any extension schema (extraction_schemas.text) for AC4. Free-text arrays (keywords, convict_offences) are excluded: PL-only topics and high cardinality make them incomparable (risk section).
CSV + permalink¶
CSV: UTF-8 with BOM, text/csv; charset=utf-8, Content-Disposition: attachment; filename="compare_<YYYY-MM-DD>.csv", X-Rows-Count (same as results_router.py:243-281). Columns exactly: field, value, jurisdiction, count, share, coverage, covered, total — the six from the spec first, two denominators appended. One row per (field, value, jurisdiction) for both jurisdictions (count 0 when absent). Permalink: /compare?f=<encodeFilters(filters)>&q=<text> (reuses use-extracted-data-filters.ts:47-73, so a link is portable to /search/extractions?f=…&q=…); pairs: /compare/<pairId>.
File structure¶
supabase/migrations/
20260921000001_shared_extracted_filter_matches.sql # Task 1
20260921000002_facet_counts_by_jurisdiction.sql # Task 2
20260921000003_create_collection_pairs.sql # Task 3
backend/app/compare/
__init__.py
models.py # Pydantic response models Task 4
layout.py # LOW_COVERAGE_THRESHOLD, share, tier, build_field Task 4
fields.py # FieldSpec, base_compare_fields, from_schema Task 5
service.py # CompareService (RPC → CompareResponse) Task 6
router.py # POST /compare/facets, /compare/export, GET pairs Tasks 7, 8, 11
csv_export.py # to_csv_rows, csv_bytes Task 8
schema_tally.py # extension-schema tally from extraction_jobs.results Task 11
backend/packages/juddges_search/juddges_search/db/collection_pairs_db.py Task 9
backend/app/collection_pairs.py # /collections/pairs router Task 9
backend/app/collections.py # + pair field on list Task 10
backend/app/server.py # router registration Tasks 7, 9
backend/tests/db/test_compare_rpcs_contract.py Tasks 1, 2
backend/tests/db/test_migration_chain.py, test_rls_isolation.py Tasks 2, 3
backend/tests/app/test_compare_layout.py, test_compare_fields.py,
test_compare_service.py, test_compare_router.py, test_compare_csv.py,
test_collection_pairs_router.py, test_collections_pair_field.py,
test_compare_pair_view.py
frontend/components/charts/BivariateBarChart.tsx Task 12
frontend/app/dataset-comparison/_components/BivariateBarChart.tsx (re-export)
frontend/lib/compare/{types.ts,api.ts,chart-data.ts,permalink.ts} Task 13
frontend/lib/extractions/drawer-adapter.ts Task 16
frontend/app/api/compare/facets/route.ts, export/route.ts,
pairs/[pairId]/route.ts; frontend/app/api/collections/pairs/route.ts,
pairs/[pairId]/route.ts Task 14
frontend/lib/i18n/{types.ts,translations/en.ts,translations/pl.ts} Task 15
frontend/app/compare/page.tsx, _components/*, [pairId]/page.tsx Tasks 16–18
frontend/app/collections/page.tsx, components/app-sidebar.tsx,
components/command-palette.tsx, app/dataset-comparison/page.tsx Task 19
docs/how-to/compare-pl-uk.md, docs/reference/sidebar-map.md,
docs/api/API_REFERENCE.md Task 20
Task 1: Shared filter function + thin wrapper (migration 1)¶
Files:
- Create: supabase/migrations/20260921000001_shared_extracted_filter_matches.sql
- Create: backend/tests/db/test_compare_rpcs_contract.py
- Reference (copy from): supabase/migrations/20260505000001_extend_base_schema_filterable_searchable.sql:141-449 and Spec B's migration.
Interfaces:
- Produces RPC public.list_extracted_filter_matches(p_filters JSONB DEFAULT '{}', p_text_query TEXT DEFAULT NULL) RETURNS TABLE (id UUID, jurisdiction TEXT) — every key of p_filters accepted today (+ B's jurisdiction, decision_date) plus collection_ids: string[].
- Keeps RPC public.filter_documents_by_extracted_data(p_filters, p_text_query, p_limit, p_offset) byte-compatible in signature and result columns.
- Step 1: Verify Spec B has landed and pick the migration prefix
cd /home/laugustyniak/github/legal-ai/juddges-project/juddges-app
git log --oneline -5 origin/main
ls supabase/migrations | tail -5
grep -ln "'jurisdiction'" supabase/migrations/*.sql | tail -2
filter_documents_by_extracted_data with p_filters -> 'jurisdiction' and p_filters -> 'decision_date'. Note that file name as <B_MIGRATION>; our files must sort after it.
- Step 2: Write the failing db-tier test
backend/tests/db/test_compare_rpcs_contract.py:
"""Compare RPCs: shared filter set, per-jurisdiction facets (Spec C).
`filter_documents_by_extracted_data` and the new facet RPC must agree on which
rows match, so both are asserted against the same seeded judgments. Seeds are
isolated by a per-test keyword token because the scratch database is shared by
the whole `db` tier.
"""
from __future__ import annotations
import json
import uuid
import pytest
pytestmark = pytest.mark.db
def _exec(conn, sql: str, params: tuple = ()):
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall() if cur.description else []
def _seed_judgment(conn, token: str, jurisdiction: str, **base) -> str:
"""Insert one completed-extraction judgment tagged with `token` in keywords."""
jid = str(uuid.uuid4())
columns = ["id", "case_number", "jurisdiction", "full_text",
"base_extraction_status", "base_keywords"]
values: list = [jid, f"CMP/{jid[:8]}", jurisdiction, "text", "completed", [token]]
for col, val in base.items():
columns.append(f"base_{col}")
values.append(val)
placeholders = ", ".join(["%s"] * len(values))
_exec(conn,
f"INSERT INTO public.judgments ({', '.join(columns)}) VALUES ({placeholders})",
tuple(values))
return jid
@pytest.fixture
def corpus(conn):
"""2 PL + 2 UK judgments; one PL row has no appeal_outcome (coverage 1/2)."""
token = f"cmp-{uuid.uuid4()}"
ids = {
"pl_a": _seed_judgment(conn, token, "PL",
appeal_outcome=["outcome_dismissed_or_refused"],
did_offender_confess=True, plea_point="before_trial"),
"pl_b": _seed_judgment(conn, token, "PL",
appeal_outcome=None, did_offender_confess=False),
"uk_a": _seed_judgment(conn, token, "UK",
appeal_outcome=["outcome_conviction_quashed",
"outcome_other"],
did_offender_confess=True),
"uk_b": _seed_judgment(conn, token, "UK",
appeal_outcome=["outcome_dismissed_or_refused"],
did_offender_confess=None, plea_point=""),
}
return token, ids
def _matches(conn, filters: dict, text_query: str | None = None) -> set[tuple[str, str]]:
rows = _exec(
conn,
"SELECT id::text, jurisdiction FROM public.list_extracted_filter_matches(%s::jsonb, %s)",
(json.dumps(filters), text_query),
)
return {(r[0], r[1]) for r in rows}
def test_shared_function_returns_id_and_jurisdiction_for_every_match(conn, corpus):
token, ids = corpus
got = _matches(conn, {"keywords": [token]})
assert got == {(ids["pl_a"], "PL"), (ids["pl_b"], "PL"),
(ids["uk_a"], "UK"), (ids["uk_b"], "UK")}
def test_shared_function_applies_existing_enum_filters(conn, corpus):
token, ids = corpus
got = _matches(conn, {"keywords": [token],
"appeal_outcome": ["outcome_dismissed_or_refused"]})
assert {g[0] for g in got} == {ids["pl_a"], ids["uk_b"]}
def test_shared_function_honours_spec_b_jurisdiction_key(conn, corpus):
token, ids = corpus
got = _matches(conn, {"keywords": [token], "jurisdiction": ["UK"]})
assert {g[0] for g in got} == {ids["uk_a"], ids["uk_b"]}
def test_wrapper_returns_the_same_rows_as_the_shared_function(conn, corpus):
"""The refactor must be behaviour-preserving: same ids, same total_count."""
token, ids = corpus
shared = {g[0] for g in _matches(conn, {"keywords": [token]})}
rows = _exec(
conn,
"SELECT id::text, jurisdiction, total_count FROM "
"public.filter_documents_by_extracted_data(%s::jsonb, NULL, 50, 0)",
(json.dumps({"keywords": [token]}),),
)
assert {r[0] for r in rows} == shared
assert {r[2] for r in rows} == {4}
def test_wrapper_pagination_and_order_are_unchanged(conn, corpus):
token, _ = corpus
page1 = _exec(conn,
"SELECT id::text FROM public.filter_documents_by_extracted_data(%s::jsonb, NULL, 3, 0)",
(json.dumps({"keywords": [token]}),))
page2 = _exec(conn,
"SELECT id::text FROM public.filter_documents_by_extracted_data(%s::jsonb, NULL, 3, 3)",
(json.dumps({"keywords": [token]}),))
assert len(page1) == 3 and len(page2) == 1
assert not ({r[0] for r in page1} & {r[0] for r in page2})
def test_collection_ids_key_restricts_to_membership(conn, corpus, make_user):
token, ids = corpus
user = make_user(str(uuid.uuid4()))
cid = str(uuid.uuid4())
_exec(conn, "INSERT INTO public.collections (id, user_id, name) VALUES (%s, %s, 'c')",
(cid, user))
_exec(conn, "INSERT INTO public.collection_judgments (collection_id, judgment_id) VALUES (%s, %s)",
(cid, ids["pl_a"]))
got = _matches(conn, {"keywords": [token], "collection_ids": [cid]})
assert got == {(ids["pl_a"], "PL")}
- Step 3: Run the test to verify it fails
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db/test_compare_rpcs_contract.py -q -m db -k "shared_function or wrapper or collection_ids"
function public.list_extracted_filter_matches(jsonb, unknown) does not exist.
- Step 4: Write the migration
supabase/migrations/20260921000001_shared_extracted_filter_matches.sql. The DECLARE block and WHERE clause are copied verbatim from the current (post-Spec-B) filter_documents_by_extracted_data; the only additions are marked -- Spec C. The wrapper is rewritten around the shared function.
-- =============================================================================
-- Migration: shared base-schema filter set + thin wrapper (Spec C, Refs #<C>)
-- =============================================================================
-- `filter_documents_by_extracted_data` carried a ~300-line WHERE clause that the
-- PL/UK comparison also needs. It moves, verbatim, into
-- `list_extracted_filter_matches`, which returns the matching (id, jurisdiction)
-- set; the original RPC becomes a wrapper with an unchanged signature and result
-- shape. New in p_filters: `collection_ids` (restrict to collection membership).
-- The corpus is ~12k rows, so materialising the id set per call is cheap.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.list_extracted_filter_matches(
p_filters JSONB DEFAULT '{}'::jsonb,
p_text_query TEXT DEFAULT NULL
)
RETURNS TABLE (
id UUID,
jurisdiction TEXT
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
-- === BEGIN verbatim copy of the DECLARE block of
-- === filter_documents_by_extracted_data (20260505000001:158-260 + Spec B)
v_appellant TEXT[] := public._jsonb_to_text_array(p_filters -> 'appellant');
v_appeal_against TEXT[] := public._jsonb_to_text_array(p_filters -> 'appeal_against');
v_appeal_outcome TEXT[] := public._jsonb_to_text_array(p_filters -> 'appeal_outcome');
v_plea_point TEXT[] := public._jsonb_to_text_array(p_filters -> 'plea_point');
v_remand_decision TEXT[] := public._jsonb_to_text_array(p_filters -> 'remand_decision');
v_sentence_serve TEXT[] := public._jsonb_to_text_array(p_filters -> 'sentence_serve');
v_offender_gender TEXT[] := public._jsonb_to_text_array(p_filters -> 'offender_gender');
v_offender_intox_offence TEXT[] := public._jsonb_to_text_array(p_filters -> 'offender_intox_offence');
v_victim_gender TEXT[] := public._jsonb_to_text_array(p_filters -> 'victim_gender');
v_victim_intox_offence TEXT[] := public._jsonb_to_text_array(p_filters -> 'victim_intox_offence');
v_victim_type TEXT[] := public._jsonb_to_text_array(p_filters -> 'victim_type');
v_pre_sent_report TEXT[] := public._jsonb_to_text_array(p_filters -> 'pre_sent_report');
v_offender_job_offence TEXT[] := public._jsonb_to_text_array(p_filters -> 'offender_job_offence');
v_offender_home_offence TEXT[] := public._jsonb_to_text_array(p_filters -> 'offender_home_offence');
v_offender_victim_relationship TEXT[] := public._jsonb_to_text_array(p_filters -> 'offender_victim_relationship');
v_keywords TEXT[] := public._jsonb_to_text_array(p_filters -> 'keywords');
v_convict_offences TEXT[] := public._jsonb_to_text_array(p_filters -> 'convict_offences');
v_acquit_offences TEXT[] := public._jsonb_to_text_array(p_filters -> 'acquit_offences');
v_appeal_ground TEXT[] := public._jsonb_to_text_array(p_filters -> 'appeal_ground');
v_sentences_received TEXT[] := public._jsonb_to_text_array(p_filters -> 'sentences_received');
v_what_ancilliary_orders TEXT[] := public._jsonb_to_text_array(p_filters -> 'what_ancilliary_orders');
v_pros_evid_type_trial TEXT[] := public._jsonb_to_text_array(p_filters -> 'pros_evid_type_trial');
v_def_evid_type_trial TEXT[] := public._jsonb_to_text_array(p_filters -> 'def_evid_type_trial');
v_agg_fact_sent TEXT[] := public._jsonb_to_text_array(p_filters -> 'agg_fact_sent');
v_mit_fact_sent TEXT[] := public._jsonb_to_text_array(p_filters -> 'mit_fact_sent');
v_sent_guide_which TEXT[] := public._jsonb_to_text_array(p_filters -> 'sent_guide_which');
v_reason_quash_conv TEXT[] := public._jsonb_to_text_array(p_filters -> 'reason_quash_conv');
v_reason_sent_excessive TEXT[] := public._jsonb_to_text_array(p_filters -> 'reason_sent_excessive');
v_reason_sent_lenient TEXT[] := public._jsonb_to_text_array(p_filters -> 'reason_sent_lenient');
v_reason_dismiss TEXT[] := public._jsonb_to_text_array(p_filters -> 'reason_dismiss');
v_convict_plea_dates TEXT[] := public._jsonb_to_text_array(p_filters -> 'convict_plea_dates');
v_did_offender_confess BOOLEAN := NULL;
v_vic_impact_statement BOOLEAN := NULL;
v_num_victims_eq NUMERIC := NULL; v_num_victims_min NUMERIC := NULL; v_num_victims_max NUMERIC := NULL;
v_case_number_eq NUMERIC := NULL; v_case_number_min NUMERIC := NULL; v_case_number_max NUMERIC := NULL;
v_victim_age_eq NUMERIC := NULL; v_victim_age_min NUMERIC := NULL; v_victim_age_max NUMERIC := NULL;
v_co_def_acc_num_eq NUMERIC := NULL; v_co_def_acc_num_min NUMERIC := NULL; v_co_def_acc_num_max NUMERIC := NULL;
v_date_eq DATE := NULL; v_date_from DATE := NULL; v_date_to DATE := NULL;
v_case_name_like TEXT := NULL;
v_neutral_citation_like TEXT := NULL;
v_judges_like TEXT := NULL;
v_offender_rep_like TEXT := NULL;
-- Spec B (copy B's exact declarations if they differ):
v_jurisdiction TEXT[] := public._jsonb_to_text_array(p_filters -> 'jurisdiction');
v_decision_from DATE := NULL;
v_decision_to DATE := NULL;
-- === END verbatim copy
-- Spec C: restrict to collection membership (collection_judgments.judgment_id is TEXT)
v_collection_ids UUID[] := (
SELECT array_agg(x::uuid)
FROM unnest(public._jsonb_to_text_array(p_filters -> 'collection_ids')) AS x
);
BEGIN
-- === BEGIN verbatim copy of the parsing block (20260505000001:261-338 + Spec B)
IF p_filters ? 'did_offender_confess' THEN
v_did_offender_confess := (p_filters ->> 'did_offender_confess')::BOOLEAN;
END IF;
IF p_filters ? 'vic_impact_statement' THEN
v_vic_impact_statement := (p_filters ->> 'vic_impact_statement')::BOOLEAN;
END IF;
IF p_filters ? 'num_victims' THEN
IF jsonb_typeof(p_filters -> 'num_victims') = 'object' THEN
IF (p_filters -> 'num_victims') ? 'min' THEN v_num_victims_min := (p_filters -> 'num_victims' ->> 'min')::NUMERIC; END IF;
IF (p_filters -> 'num_victims') ? 'max' THEN v_num_victims_max := (p_filters -> 'num_victims' ->> 'max')::NUMERIC; END IF;
ELSE
v_num_victims_eq := (p_filters ->> 'num_victims')::NUMERIC;
END IF;
END IF;
IF p_filters ? 'case_number' THEN
IF jsonb_typeof(p_filters -> 'case_number') = 'object' THEN
IF (p_filters -> 'case_number') ? 'min' THEN v_case_number_min := (p_filters -> 'case_number' ->> 'min')::NUMERIC; END IF;
IF (p_filters -> 'case_number') ? 'max' THEN v_case_number_max := (p_filters -> 'case_number' ->> 'max')::NUMERIC; END IF;
ELSE
v_case_number_eq := (p_filters ->> 'case_number')::NUMERIC;
END IF;
END IF;
IF p_filters ? 'victim_age_offence' THEN
IF jsonb_typeof(p_filters -> 'victim_age_offence') = 'object' THEN
IF (p_filters -> 'victim_age_offence') ? 'min' THEN v_victim_age_min := (p_filters -> 'victim_age_offence' ->> 'min')::NUMERIC; END IF;
IF (p_filters -> 'victim_age_offence') ? 'max' THEN v_victim_age_max := (p_filters -> 'victim_age_offence' ->> 'max')::NUMERIC; END IF;
ELSE
v_victim_age_eq := (p_filters ->> 'victim_age_offence')::NUMERIC;
END IF;
END IF;
IF p_filters ? 'co_def_acc_num' THEN
IF jsonb_typeof(p_filters -> 'co_def_acc_num') = 'object' THEN
IF (p_filters -> 'co_def_acc_num') ? 'min' THEN v_co_def_acc_num_min := (p_filters -> 'co_def_acc_num' ->> 'min')::NUMERIC; END IF;
IF (p_filters -> 'co_def_acc_num') ? 'max' THEN v_co_def_acc_num_max := (p_filters -> 'co_def_acc_num' ->> 'max')::NUMERIC; END IF;
ELSE
v_co_def_acc_num_eq := (p_filters ->> 'co_def_acc_num')::NUMERIC;
END IF;
END IF;
IF p_filters ? 'date_of_appeal_court_judgment' THEN
IF jsonb_typeof(p_filters -> 'date_of_appeal_court_judgment') = 'object' THEN
IF (p_filters -> 'date_of_appeal_court_judgment') ? 'from' THEN
v_date_from := (p_filters -> 'date_of_appeal_court_judgment' ->> 'from')::DATE;
ELSIF (p_filters -> 'date_of_appeal_court_judgment') ? 'min' THEN
v_date_from := (p_filters -> 'date_of_appeal_court_judgment' ->> 'min')::DATE;
END IF;
IF (p_filters -> 'date_of_appeal_court_judgment') ? 'to' THEN
v_date_to := (p_filters -> 'date_of_appeal_court_judgment' ->> 'to')::DATE;
ELSIF (p_filters -> 'date_of_appeal_court_judgment') ? 'max' THEN
v_date_to := (p_filters -> 'date_of_appeal_court_judgment' ->> 'max')::DATE;
END IF;
ELSE
v_date_eq := (p_filters ->> 'date_of_appeal_court_judgment')::DATE;
END IF;
END IF;
IF p_filters ? 'case_name' THEN v_case_name_like := NULLIF(TRIM(p_filters ->> 'case_name'), ''); END IF;
IF p_filters ? 'neutral_citation_number' THEN v_neutral_citation_like := NULLIF(TRIM(p_filters ->> 'neutral_citation_number'), ''); END IF;
IF p_filters ? 'appeal_court_judges_names' THEN v_judges_like := NULLIF(TRIM(p_filters ->> 'appeal_court_judges_names'), ''); END IF;
IF p_filters ? 'offender_representative_name' THEN v_offender_rep_like := NULLIF(TRIM(p_filters ->> 'offender_representative_name'), ''); END IF;
-- Spec B (copy B's exact parsing if it differs):
IF p_filters ? 'decision_date' AND jsonb_typeof(p_filters -> 'decision_date') = 'object' THEN
IF (p_filters -> 'decision_date') ? 'from' THEN v_decision_from := (p_filters -> 'decision_date' ->> 'from')::DATE; END IF;
IF (p_filters -> 'decision_date') ? 'to' THEN v_decision_to := (p_filters -> 'decision_date' ->> 'to')::DATE; END IF;
END IF;
-- === END verbatim copy
RETURN QUERY
SELECT j.id, j.jurisdiction
FROM public.judgments j
WHERE
j.base_extraction_status = 'completed'
-- === BEGIN verbatim copy of the WHERE predicates (20260505000001:350-438 + Spec B)
AND (v_appellant IS NULL OR j.base_appellant = ANY(v_appellant))
AND (v_appeal_against IS NULL OR j.base_appeal_against && v_appeal_against)
AND (v_appeal_outcome IS NULL OR j.base_appeal_outcome && v_appeal_outcome)
AND (v_plea_point IS NULL OR j.base_plea_point = ANY(v_plea_point))
AND (v_remand_decision IS NULL OR j.base_remand_decision = ANY(v_remand_decision))
AND (v_sentence_serve IS NULL OR j.base_sentence_serve && v_sentence_serve)
AND (v_offender_gender IS NULL OR j.base_offender_gender && v_offender_gender)
AND (v_offender_intox_offence IS NULL OR j.base_offender_intox_offence && v_offender_intox_offence)
AND (v_victim_gender IS NULL OR j.base_victim_gender && v_victim_gender)
AND (v_victim_intox_offence IS NULL OR j.base_victim_intox_offence && v_victim_intox_offence)
AND (v_victim_type IS NULL OR j.base_victim_type = ANY(v_victim_type))
AND (v_pre_sent_report IS NULL OR j.base_pre_sent_report = ANY(v_pre_sent_report))
AND (v_offender_job_offence IS NULL OR j.base_offender_job_offence = ANY(v_offender_job_offence))
AND (v_offender_home_offence IS NULL OR j.base_offender_home_offence = ANY(v_offender_home_offence))
AND (v_offender_victim_relationship IS NULL OR j.base_offender_victim_relationship = ANY(v_offender_victim_relationship))
AND (v_did_offender_confess IS NULL OR j.base_did_offender_confess = v_did_offender_confess)
AND (v_vic_impact_statement IS NULL OR j.base_vic_impact_statement = v_vic_impact_statement)
AND (v_keywords IS NULL OR j.base_keywords && v_keywords)
AND (v_convict_offences IS NULL OR j.base_convict_offences && v_convict_offences)
AND (v_acquit_offences IS NULL OR j.base_acquit_offences && v_acquit_offences)
AND (v_appeal_ground IS NULL OR j.base_appeal_ground && v_appeal_ground)
AND (v_sentences_received IS NULL OR j.base_sentences_received && v_sentences_received)
AND (v_what_ancilliary_orders IS NULL OR j.base_what_ancilliary_orders && v_what_ancilliary_orders)
AND (v_pros_evid_type_trial IS NULL OR j.base_pros_evid_type_trial && v_pros_evid_type_trial)
AND (v_def_evid_type_trial IS NULL OR j.base_def_evid_type_trial && v_def_evid_type_trial)
AND (v_agg_fact_sent IS NULL OR j.base_agg_fact_sent && v_agg_fact_sent)
AND (v_mit_fact_sent IS NULL OR j.base_mit_fact_sent && v_mit_fact_sent)
AND (v_sent_guide_which IS NULL OR j.base_sent_guide_which && v_sent_guide_which)
AND (v_reason_quash_conv IS NULL OR j.base_reason_quash_conv && v_reason_quash_conv)
AND (v_reason_sent_excessive IS NULL OR j.base_reason_sent_excessive && v_reason_sent_excessive)
AND (v_reason_sent_lenient IS NULL OR j.base_reason_sent_lenient && v_reason_sent_lenient)
AND (v_reason_dismiss IS NULL OR j.base_reason_dismiss && v_reason_dismiss)
AND (v_convict_plea_dates IS NULL OR j.base_convict_plea_dates && v_convict_plea_dates)
AND ((v_num_victims_eq IS NULL OR j.base_num_victims = v_num_victims_eq)
AND (v_num_victims_min IS NULL OR j.base_num_victims >= v_num_victims_min)
AND (v_num_victims_max IS NULL OR j.base_num_victims <= v_num_victims_max))
AND ((v_case_number_eq IS NULL OR j.base_case_number = v_case_number_eq)
AND (v_case_number_min IS NULL OR j.base_case_number >= v_case_number_min)
AND (v_case_number_max IS NULL OR j.base_case_number <= v_case_number_max))
AND ((v_victim_age_eq IS NULL OR j.base_victim_age_offence = v_victim_age_eq)
AND (v_victim_age_min IS NULL OR j.base_victim_age_offence >= v_victim_age_min)
AND (v_victim_age_max IS NULL OR j.base_victim_age_offence <= v_victim_age_max))
AND ((v_co_def_acc_num_eq IS NULL OR j.base_co_def_acc_num = v_co_def_acc_num_eq)
AND (v_co_def_acc_num_min IS NULL OR j.base_co_def_acc_num >= v_co_def_acc_num_min)
AND (v_co_def_acc_num_max IS NULL OR j.base_co_def_acc_num <= v_co_def_acc_num_max))
AND ((v_date_eq IS NULL OR j.base_date_of_appeal_court_judgment = v_date_eq)
AND (v_date_from IS NULL OR j.base_date_of_appeal_court_judgment >= v_date_from)
AND (v_date_to IS NULL OR j.base_date_of_appeal_court_judgment <= v_date_to))
AND (v_case_name_like IS NULL OR j.base_case_name ILIKE '%' || v_case_name_like || '%')
AND (v_neutral_citation_like IS NULL OR j.base_neutral_citation_number ILIKE '%' || v_neutral_citation_like || '%')
AND (v_judges_like IS NULL OR j.base_appeal_court_judges_names ILIKE '%' || v_judges_like || '%')
AND (v_offender_rep_like IS NULL OR j.base_offender_representative_name ILIKE '%' || v_offender_rep_like || '%')
AND (p_text_query IS NULL OR TRIM(p_text_query) = '' OR
j.base_search_tsv @@ websearch_to_tsquery('simple', p_text_query))
-- Spec B:
AND (v_jurisdiction IS NULL OR j.jurisdiction = ANY(v_jurisdiction))
AND (v_decision_from IS NULL OR j.decision_date >= v_decision_from)
AND (v_decision_to IS NULL OR j.decision_date <= v_decision_to)
-- === END verbatim copy
-- Spec C:
AND (v_collection_ids IS NULL OR EXISTS (
SELECT 1 FROM public.collection_judgments cj
WHERE cj.collection_id = ANY(v_collection_ids)
AND cj.judgment_id = j.id::text
));
END;
$$;
-- Thin wrapper: identical signature and result columns; same ORDER BY.
CREATE OR REPLACE FUNCTION public.filter_documents_by_extracted_data(
p_filters JSONB DEFAULT '{}'::jsonb,
p_text_query TEXT DEFAULT NULL,
p_limit INT DEFAULT 50,
p_offset INT DEFAULT 0
)
RETURNS TABLE (
id UUID,
case_number TEXT,
title TEXT,
jurisdiction TEXT,
decision_date DATE,
extracted_data JSONB,
total_count BIGINT
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN QUERY
SELECT
j.id,
j.case_number,
j.title,
j.jurisdiction,
j.decision_date,
COALESCE(j.base_raw_extraction, '{}'::jsonb) AS extracted_data,
COUNT(*) OVER()::BIGINT AS total_count
FROM public.list_extracted_filter_matches(p_filters, p_text_query) m
JOIN public.judgments j ON j.id = m.id
ORDER BY j.decision_date DESC NULLS LAST, j.id
LIMIT GREATEST(COALESCE(p_limit, 50), 1)
OFFSET GREATEST(COALESCE(p_offset, 0), 0);
END;
$$;
GRANT EXECUTE ON FUNCTION public.list_extracted_filter_matches(JSONB, TEXT)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.filter_documents_by_extracted_data(JSONB, TEXT, INT, INT)
TO anon, authenticated, service_role;
Before saving, diff the three "verbatim" regions against <B_MIGRATION> (sed -n the ranges) — any drift is a bug in this task, not in B.
- Step 5: Run the db tier
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db -q -m db
test_compare_rpcs_contract.py selected in Step 3 PASS; test_migration_chain.py still PASS (chain applies).
- Step 6: Commit
git add supabase/migrations/20260921000001_shared_extracted_filter_matches.sql backend/tests/db/test_compare_rpcs_contract.py
git commit -m "feat(db): shared list_extracted_filter_matches; filter RPC becomes a wrapper
Refs #<C>"
Task 2: Facet counts by jurisdiction RPC (migration 2)¶
Files:
- Create: supabase/migrations/20260921000002_facet_counts_by_jurisdiction.sql
- Modify: backend/tests/db/test_compare_rpcs_contract.py (append)
- Modify: backend/tests/db/test_migration_chain.py:90-115 (EXPECTED_RPC_ARGS)
Interfaces:
- Consumes: public.list_extracted_filter_matches(JSONB, TEXT) (Task 1), public._base_field_to_column(TEXT) (20260226000001:437).
- Produces RPC public.get_extracted_facet_counts_by_jurisdiction(p_filters JSONB, field_path TEXT, p_text_query TEXT) RETURNS TABLE (jurisdiction TEXT, value TEXT, count BIGINT, total BIGINT, covered BIGINT, coverage NUMERIC). Rows with value IS NULL are per-jurisdiction summary rows (no filled value in that jurisdiction).
- Step 1: Append failing tests
Append to backend/tests/db/test_compare_rpcs_contract.py:
def _facets(conn, filters: dict, field: str, text_query: str | None = None):
rows = _exec(
conn,
"SELECT jurisdiction, value, count, total, covered, coverage "
"FROM public.get_extracted_facet_counts_by_jurisdiction(%s::jsonb, %s, %s) "
"ORDER BY jurisdiction, value NULLS LAST",
(json.dumps(filters), field, text_query),
)
return [tuple(r) for r in rows]
def test_array_field_counts_each_element_per_jurisdiction(conn, corpus):
token, _ = corpus
rows = _facets(conn, {"keywords": [token]}, "appeal_outcome")
by = {(r[0], r[1]): r for r in rows}
# PL: pl_a has one value, pl_b has NULL -> total 2, covered 1
assert by[("PL", "outcome_dismissed_or_refused")][2:5] == (1, 2, 1)
assert float(by[("PL", "outcome_dismissed_or_refused")][5]) == 0.5
# UK: uk_a two elements, uk_b one -> counts 1/1/1, total 2, covered 2
assert by[("UK", "outcome_conviction_quashed")][2:5] == (1, 2, 2)
assert by[("UK", "outcome_other")][2:5] == (1, 2, 2)
assert by[("UK", "outcome_dismissed_or_refused")][2:5] == (1, 2, 2)
assert float(by[("UK", "outcome_other")][5]) == 1.0
def test_boolean_field_values_are_text_true_false(conn, corpus):
token, _ = corpus
rows = _facets(conn, {"keywords": [token]}, "did_offender_confess")
values = {(r[0], r[1]): r[2] for r in rows}
assert values[("PL", "true")] == 1 and values[("PL", "false")] == 1
assert values[("UK", "true")] == 1
assert ("UK", "false") not in values
uk = next(r for r in rows if r[0] == "UK")
assert uk[3:5] == (2, 1) # total 2, covered 1 (uk_b is NULL)
def test_scalar_text_field_treats_empty_string_as_not_covered(conn, corpus):
token, _ = corpus
rows = _facets(conn, {"keywords": [token]}, "plea_point")
pl = [r for r in rows if r[0] == "PL"]
uk = [r for r in rows if r[0] == "UK"]
assert pl == [("PL", "before_trial", 1, 2, 1, pl[0][5])]
# UK has no filled plea_point (one NULL, one ''): a single summary row
assert len(uk) == 1
assert uk[0][1] is None and uk[0][2] is None
assert uk[0][3:5] == (2, 0)
assert float(uk[0][5]) == 0.0
def test_jurisdiction_with_no_matches_yields_no_rows(conn, corpus):
token, _ = corpus
rows = _facets(conn, {"keywords": [token], "jurisdiction": ["PL"]}, "appeal_outcome")
assert {r[0] for r in rows} == {"PL"}
def test_unknown_field_raises(conn, corpus):
token, _ = corpus
with pytest.raises(Exception, match="Unknown extracted field"):
_facets(conn, {"keywords": [token]}, "no_such_field")
And in backend/tests/db/test_migration_chain.py, inside EXPECTED_RPC_ARGS (after the create_document_version entry):
# backend/app/compare/service.py, backend/app/collection_pairs.py
"list_extracted_filter_matches": ["p_filters", "p_text_query"],
# backend/app/compare/service.py
"get_extracted_facet_counts_by_jurisdiction": [
"p_filters",
"field_path",
"p_text_query",
],
- Step 2: Run to verify failure
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db/test_compare_rpcs_contract.py tests/db/test_migration_chain.py -q -m db -k "facet or boolean or scalar_text or no_matches or unknown_field or rpc_exists"
get_extracted_facet_counts_by_jurisdiction does not exist after the migrations.
- Step 3: Write the migration
supabase/migrations/20260921000002_facet_counts_by_jurisdiction.sql:
-- =============================================================================
-- Migration: per-jurisdiction facet counts with coverage (Spec C, Refs #<C>)
-- =============================================================================
-- Sibling of get_extracted_facet_counts(field_path), which stays untouched.
-- Filters via list_extracted_filter_matches; groups by judgments.jurisdiction.
--
-- Coverage semantics:
-- total = matched judgments in the jurisdiction
-- covered = those whose column is "filled":
-- text[] -> at least one element that is NOT NULL and <> ''
-- text -> NULLIF(BTRIM(col), '') IS NOT NULL
-- other -> col IS NOT NULL
-- coverage = covered / total (NULL when total = 0)
-- A jurisdiction with total > 0 and covered = 0 yields ONE row with
-- value IS NULL and count IS NULL (LEFT JOIN) so callers still learn `total`.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_extracted_facet_counts_by_jurisdiction(
p_filters JSONB DEFAULT '{}'::jsonb,
field_path TEXT DEFAULT NULL,
p_text_query TEXT DEFAULT NULL
)
RETURNS TABLE (
jurisdiction TEXT,
value TEXT,
count BIGINT,
total BIGINT,
covered BIGINT,
coverage NUMERIC
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_column TEXT;
v_udt_name TEXT;
v_filled_sql TEXT;
v_values_sql TEXT;
BEGIN
v_column := public._base_field_to_column(field_path);
IF v_column IS NULL THEN
RAISE EXCEPTION 'Unknown extracted field: %', field_path;
END IF;
SELECT c.udt_name INTO v_udt_name
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = 'judgments' AND c.column_name = v_column;
IF v_udt_name IS NULL THEN
RAISE EXCEPTION 'Column not found for extracted field: %', field_path;
END IF;
IF v_udt_name = '_text' THEN
v_filled_sql := format(
$f$EXISTS (SELECT 1 FROM unnest(j.%1$I) AS e WHERE e IS NOT NULL AND e <> '')$f$,
v_column);
v_values_sql := format(
$v$SELECT j.jurisdiction, e::text AS value
FROM matched m
JOIN public.judgments j ON j.id = m.id
CROSS JOIN LATERAL unnest(j.%1$I) AS e
WHERE e IS NOT NULL AND e <> ''$v$,
v_column);
ELSIF v_udt_name = 'text' THEN
v_filled_sql := format($f$NULLIF(BTRIM(j.%1$I), '') IS NOT NULL$f$, v_column);
v_values_sql := format(
$v$SELECT j.jurisdiction, j.%1$I AS value
FROM matched m
JOIN public.judgments j ON j.id = m.id
WHERE NULLIF(BTRIM(j.%1$I), '') IS NOT NULL$v$,
v_column);
ELSE
v_filled_sql := format($f$j.%1$I IS NOT NULL$f$, v_column);
v_values_sql := format(
$v$SELECT j.jurisdiction, j.%1$I::text AS value
FROM matched m
JOIN public.judgments j ON j.id = m.id
WHERE j.%1$I IS NOT NULL$v$,
v_column);
END IF;
RETURN QUERY EXECUTE format(
$q$
WITH matched AS (
SELECT m.id, m.jurisdiction
FROM public.list_extracted_filter_matches($1, $2) AS m
),
totals AS (
SELECT
m.jurisdiction,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE %1$s)::bigint AS covered
FROM matched m
JOIN public.judgments j ON j.id = m.id
GROUP BY m.jurisdiction
),
vals AS (
SELECT v.jurisdiction, v.value, COUNT(*)::bigint AS count
FROM (%2$s) AS v
GROUP BY v.jurisdiction, v.value
)
SELECT
t.jurisdiction,
v.value,
v.count,
t.total,
t.covered,
ROUND(t.covered::numeric / NULLIF(t.total, 0), 4) AS coverage
FROM totals t
LEFT JOIN vals v ON v.jurisdiction = t.jurisdiction
ORDER BY t.jurisdiction, v.count DESC NULLS LAST, v.value
$q$,
v_filled_sql,
v_values_sql
) USING p_filters, p_text_query;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_extracted_facet_counts_by_jurisdiction(JSONB, TEXT, TEXT)
TO anon, authenticated, service_role;
- Step 4: Run the db tier
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db -q -m db
- Step 5: Commit
git add supabase/migrations/20260921000002_facet_counts_by_jurisdiction.sql backend/tests/db/test_compare_rpcs_contract.py backend/tests/db/test_migration_chain.py
git commit -m "feat(db): get_extracted_facet_counts_by_jurisdiction with coverage
Refs #<C>"
Task 3: collection_pairs table (migration 3)¶
Files:
- Create: supabase/migrations/20260921000003_create_collection_pairs.sql
- Modify: backend/tests/db/test_migration_chain.py:15-34 (EXPECTED_TABLES), :151-158 (EXPECTED_FKS if present)
- Modify: backend/tests/db/test_rls_isolation.py:36-101 (_seed_owned_row), :104-112 (OWNER_SCOPED_TABLES)
Interfaces:
- Produces table public.collection_pairs (id UUID PK, user_id UUID, name TEXT, filters JSONB, text_query TEXT, pl_collection_id UUID UNIQUE FK, uk_collection_id UUID UNIQUE FK, created_at, updated_at).
- Step 1: Extend the contract tests (failing)
In backend/tests/db/test_migration_chain.py add "collection_pairs", to EXPECTED_TABLES.
In backend/tests/db/test_rls_isolation.py, add a branch to _seed_owned_row before the final raise AssertionError:
elif table == "collection_pairs":
pl_id, uk_id = str(uuid.uuid4()), str(uuid.uuid4())
for cid, label in ((pl_id, "PL"), (uk_id, "UK")):
_exec(
conn,
"INSERT INTO public.collections (id, user_id, name) VALUES (%s, %s, %s)",
(cid, owner, f"pair — {label}"),
)
_exec(
conn,
"INSERT INTO public.collection_pairs "
"(id, user_id, name, filters, pl_collection_id, uk_collection_id) "
"VALUES (%s, %s, 'pair', '{}'::jsonb, %s, %s)",
(row_id, owner, pl_id, uk_id),
)
"collection_pairs", to OWNER_SCOPED_TABLES.
- Step 2: Run to verify failure
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db/test_migration_chain.py tests/db/test_rls_isolation.py -q -m db -k "collection_pairs or expected_tables"
these tables are absent: ['collection_pairs'], and the RLS seed fails with relation "public.collection_pairs" does not exist.
- Step 3: Write the migration
supabase/migrations/20260921000003_create_collection_pairs.sql:
-- =============================================================================
-- Migration: collection_pairs — a PL/UK pair of collections (Spec C, Refs #<C>)
-- =============================================================================
-- A pair has its own identity so it can carry the filter that produced it
-- (permalink, re-run) and cascade when either side is deleted. A collection
-- belongs to at most one pair (UNIQUE on each side). RLS mirrors collections;
-- the backend uses service_role and bypasses it.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.collection_pairs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
filters JSONB NOT NULL DEFAULT '{}'::jsonb,
text_query TEXT,
pl_collection_id UUID NOT NULL UNIQUE REFERENCES public.collections(id) ON DELETE CASCADE,
uk_collection_id UUID NOT NULL UNIQUE REFERENCES public.collections(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT collection_pairs_distinct_sides CHECK (pl_collection_id <> uk_collection_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_pairs_user_created
ON public.collection_pairs(user_id, created_at DESC);
DROP TRIGGER IF EXISTS trg_collection_pairs_set_updated_at ON public.collection_pairs;
CREATE TRIGGER trg_collection_pairs_set_updated_at
BEFORE UPDATE ON public.collection_pairs
FOR EACH ROW EXECUTE FUNCTION public.tg_collections_set_updated_at();
ALTER TABLE public.collection_pairs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS collection_pairs_owner_select ON public.collection_pairs;
CREATE POLICY collection_pairs_owner_select ON public.collection_pairs
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS collection_pairs_owner_insert ON public.collection_pairs;
CREATE POLICY collection_pairs_owner_insert ON public.collection_pairs
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS collection_pairs_owner_update ON public.collection_pairs;
CREATE POLICY collection_pairs_owner_update ON public.collection_pairs
FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS collection_pairs_owner_delete ON public.collection_pairs;
CREATE POLICY collection_pairs_owner_delete ON public.collection_pairs
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.collection_pairs TO authenticated;
GRANT ALL ON public.collection_pairs TO service_role;
COMMENT ON TABLE public.collection_pairs IS
'PL/UK pair of collections created from one base-schema filter on /compare. `filters`/`text_query` reproduce the comparison.';
- Step 4: Run the db tier
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \
poetry run pytest tests/db -q -m db
test_owner_sees_their_row_and_the_other_user_does_not[collection_pairs] and the DELETE isolation variant.
- Step 5: Commit
git add supabase/migrations/20260921000003_create_collection_pairs.sql backend/tests/db/test_migration_chain.py backend/tests/db/test_rls_isolation.py
git commit -m "feat(db): collection_pairs table with owner RLS
Refs #<C>"
Task 4: Compare models + layout policy (pure Python)¶
Files:
- Create: backend/app/compare/__init__.py (empty), backend/app/compare/models.py, backend/app/compare/layout.py
- Test: backend/tests/app/test_compare_layout.py
Interfaces:
- Produces (models.py):
Jurisdiction = Literal["PL", "UK"]
class CoverageStat(BaseModel): covered: int; total: int; ratio: float | None
class CompareValue(BaseModel): value: str; counts: dict[str, int]; shares: dict[str, float | None]
Tier = Literal["primary", "partial", "unavailable", "empty"]
class CompareField(BaseModel): field: str; label: str; source: str; kind: Literal["enum","enum_array","boolean"]; coverage: dict[str, CoverageStat]; tier: Tier; missing_in: list[str]; values: list[CompareValue]
class PairSummary(BaseModel): id: str; name: str; pl_collection_id: str; uk_collection_id: str
class CompareResponse(BaseModel): jurisdictions: list[str] = ["PL","UK"]; totals: dict[str, int]; fields: list[CompareField]; ignored_filter_keys: list[str] = []; filters: dict[str, Any]; text_query: str | None = None; pair: PairSummary | None = None
layout.py): LOW_COVERAGE_THRESHOLD = 0.80, JURISDICTIONS = ("PL", "UK"), coverage_ratio(covered, total) -> float | None, share(count, covered) -> float | None, tier_for(coverage: dict[str, CoverageStat]) -> tuple[Tier, list[str]], build_field(spec, coverage, counts) -> CompareField where spec has .field/.label/.kind/.source (Task 5's FieldSpec) and counts: dict[str, dict[str, int]] = {jurisdiction: {value: count}}.
- Step 1: Write the failing tests
backend/tests/app/test_compare_layout.py:
"""Coverage/tier policy for the PL-UK comparison (Spec C AC2).
The threshold and hide rules live here, in one place, so the JSON response and
the CSV export cannot disagree. No DB, no HTTP.
"""
from __future__ import annotations
from dataclasses import dataclass
import pytest
from app.compare.layout import (
LOW_COVERAGE_THRESHOLD,
build_field,
coverage_ratio,
share,
tier_for,
)
from app.compare.models import CoverageStat
pytestmark = pytest.mark.unit
@dataclass(frozen=True)
class _Spec:
field: str = "appeal_outcome"
label: str = "Appeal Outcome"
kind: str = "enum_array"
source: str = "base"
def _cov(pl: tuple[int, int], uk: tuple[int, int]) -> dict[str, CoverageStat]:
return {
"PL": CoverageStat(covered=pl[0], total=pl[1], ratio=coverage_ratio(*pl)),
"UK": CoverageStat(covered=uk[0], total=uk[1], ratio=coverage_ratio(*uk)),
}
def test_threshold_is_eighty_percent():
assert LOW_COVERAGE_THRESHOLD == 0.80
def test_coverage_ratio_is_none_when_total_is_zero():
assert coverage_ratio(0, 0) is None
assert coverage_ratio(3, 4) == 0.75
def test_share_uses_covered_as_denominator():
assert share(1, 4) == 0.25
assert share(1, 0) is None
def test_primary_when_both_jurisdictions_at_or_above_threshold():
assert tier_for(_cov((80, 100), (198, 200))) == ("primary", [])
def test_partial_when_either_jurisdiction_below_threshold():
assert tier_for(_cov((153, 200), (198, 200))) == ("partial", [])
assert tier_for(_cov((200, 200), (159, 200))) == ("partial", [])
def test_unavailable_names_the_jurisdiction_with_zero_coverage():
assert tier_for(_cov((200, 200), (0, 300))) == ("unavailable", ["UK"])
assert tier_for(_cov((0, 200), (0, 300))) == ("unavailable", ["PL", "UK"])
def test_unavailable_when_a_jurisdiction_matched_nothing():
assert tier_for(_cov((10, 10), (0, 0))) == ("unavailable", ["UK"])
def test_empty_when_nothing_matched_anywhere():
assert tier_for(_cov((0, 0), (0, 0))) == ("empty", ["PL", "UK"])
def test_build_field_aligns_values_across_jurisdictions_and_orders_by_total_count():
field = build_field(
_Spec(),
_cov((2, 2), (2, 2)),
{"PL": {"a": 2}, "UK": {"a": 1, "b": 1}},
)
assert [v.value for v in field.values] == ["a", "b"]
a, b = field.values
assert a.counts == {"PL": 2, "UK": 1} and a.shares == {"PL": 1.0, "UK": 0.5}
assert b.counts == {"PL": 0, "UK": 1} and b.shares == {"PL": 0.0, "UK": 0.5}
assert field.tier == "primary" and field.missing_in == []
def test_build_field_share_is_none_where_nothing_is_covered():
field = build_field(_Spec(), _cov((0, 5), (1, 1)), {"UK": {"a": 1}})
assert field.tier == "unavailable" and field.missing_in == ["PL"]
assert field.values[0].shares == {"PL": None, "UK": 1.0}
- Step 2: Run to verify failure
ModuleNotFoundError: No module named 'app.compare'.
- Step 3: Implement
backend/app/compare/__init__.py: empty file (one docstring line: """PL/UK live comparison over base-schema fields (Spec C).""").
backend/app/compare/models.py:
"""Response models for the PL/UK comparison API."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
Jurisdiction = Literal["PL", "UK"]
Tier = Literal["primary", "partial", "unavailable", "empty"]
FieldKind = Literal["enum", "enum_array", "boolean"]
class CoverageStat(BaseModel):
"""How many matched judgments have the field filled, per jurisdiction."""
covered: int
total: int
ratio: float | None = Field(description="covered / total; null when total is 0")
class CompareValue(BaseModel):
value: str
counts: dict[str, int]
shares: dict[str, float | None] = Field(
description="count / covered per jurisdiction; null where covered is 0"
)
class CompareField(BaseModel):
field: str
label: str
source: str = Field(description="'base' or 'schema:<extraction_schema_id>'")
kind: FieldKind
coverage: dict[str, CoverageStat]
tier: Tier
missing_in: list[str] = []
values: list[CompareValue]
class PairSummary(BaseModel):
id: str
name: str
pl_collection_id: str
uk_collection_id: str
class CompareResponse(BaseModel):
jurisdictions: list[str] = ["PL", "UK"]
totals: dict[str, int]
fields: list[CompareField]
ignored_filter_keys: list[str] = []
filters: dict[str, Any]
text_query: str | None = None
pair: PairSummary | None = None
backend/app/compare/layout.py:
"""Coverage ratios, shares and display tiers for the comparison (Spec C AC2).
Single home for the 80 % rule and the 0 %-hide rule. The frontend renders the
`tier`/`missing_in` flags and never re-derives them; the CSV export reads the
same numbers.
"""
from __future__ import annotations
from typing import Protocol
from app.compare.models import CompareField, CompareValue, CoverageStat, Tier
LOW_COVERAGE_THRESHOLD = 0.80
JURISDICTIONS: tuple[str, ...] = ("PL", "UK")
class FieldSpecLike(Protocol):
field: str
label: str
kind: str
source: str
def coverage_ratio(covered: int, total: int) -> float | None:
return None if total == 0 else round(covered / total, 4)
def share(count: int, covered: int) -> float | None:
return None if covered == 0 else round(count / covered, 4)
def tier_for(coverage: dict[str, CoverageStat]) -> tuple[Tier, list[str]]:
"""Classify a field.
empty — no judgment matched in any jurisdiction
unavailable — at least one jurisdiction has covered == 0 (hide chart, say why)
partial — every jurisdiction covered, but one is below the threshold (badge)
primary — all jurisdictions at or above the threshold
"""
stats = [coverage.get(j, CoverageStat(covered=0, total=0, ratio=None)) for j in JURISDICTIONS]
if all(s.total == 0 for s in stats):
return "empty", list(JURISDICTIONS)
missing = [j for j, s in zip(JURISDICTIONS, stats, strict=True) if s.covered == 0]
if missing:
return "unavailable", missing
if any((s.ratio or 0.0) < LOW_COVERAGE_THRESHOLD for s in stats):
return "partial", []
return "primary", []
def build_field(
spec: FieldSpecLike,
coverage: dict[str, CoverageStat],
counts: dict[str, dict[str, int]],
) -> CompareField:
"""Align value rows across jurisdictions and attach shares + tier.
`counts` is {jurisdiction: {value: count}}; a value absent in one
jurisdiction gets count 0 there. Values are ordered by summed count, then
alphabetically, so the bar order is stable between PL and UK.
"""
all_values = {v for per in counts.values() for v in per}
ordered = sorted(
all_values,
key=lambda v: (-sum(per.get(v, 0) for per in counts.values()), v),
)
values = [
CompareValue(
value=v,
counts={j: counts.get(j, {}).get(v, 0) for j in JURISDICTIONS},
shares={
j: share(counts.get(j, {}).get(v, 0), coverage.get(j, CoverageStat(covered=0, total=0, ratio=None)).covered)
for j in JURISDICTIONS
},
)
for v in ordered
]
tier, missing = tier_for(coverage)
return CompareField(
field=spec.field,
label=spec.label,
source=spec.source,
kind=spec.kind, # type: ignore[arg-type]
coverage={j: coverage.get(j, CoverageStat(covered=0, total=0, ratio=None)) for j in JURISDICTIONS},
tier=tier,
missing_in=missing,
values=values,
)
- Step 4: Run tests
cd backend && poetry run pytest tests/app/test_compare_layout.py -q && poetry run ruff check app/compare tests/app/test_compare_layout.py && poetry run ruff format --check app/compare
- Step 5: Commit
git add backend/app/compare backend/tests/app/test_compare_layout.py
git commit -m "feat(compare): response models and coverage tier policy
Refs #<C>"
Task 5: Comparable field registry (base schema + any JSON Schema)¶
Files:
- Create: backend/app/compare/fields.py
- Test: backend/tests/app/test_compare_fields.py
Interfaces: - Produces:
@dataclass(frozen=True)
class FieldSpec: field: str; label: str; kind: Literal["enum","enum_array","boolean"]; source: str = "base"; order: int = 999
DEFAULT_FIRST = ("appeal_outcome", "offender_gender", "sentence_serve", "plea_point")
def comparable_fields_from_schema(schema: dict, source: str) -> list[FieldSpec]
def base_compare_fields() -> list[FieldSpec] # cached; from BaseSchemaExtractor().schema
def select_base_fields(requested: list[str] | None) -> list[FieldSpec] # raises ValueError on unknown/non-comparable names
- Step 1: Write the failing tests
backend/tests/app/test_compare_fields.py:
from __future__ import annotations
import pytest
from app.compare.fields import (
DEFAULT_FIRST,
base_compare_fields,
comparable_fields_from_schema,
select_base_fields,
)
pytestmark = pytest.mark.unit
def test_comparable_fields_are_enum_array_enum_or_boolean_only():
schema = {
"properties": {
"outcome": {"type": "string", "enum": ["a", "b"], "x-ui-label": "Outcome", "x-ui-order": 2},
"flags": {"type": "array", "items": {"enum": ["x", "y"]}, "x-ui-order": 1},
"confessed": {"type": "boolean", "x-ui-order": 3},
"summary": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"age": {"type": "number"},
}
}
specs = comparable_fields_from_schema(schema, source="schema:abc")
assert [(s.field, s.kind) for s in specs] == [
("flags", "enum_array"), ("outcome", "enum"), ("confessed", "boolean"),
]
assert specs[1].label == "Outcome"
assert specs[2].label == "confessed" # falls back to the property name
assert all(s.source == "schema:abc" for s in specs)
def test_base_fields_come_from_the_shipped_base_schema():
names = [s.field for s in base_compare_fields()]
assert names[:4] == list(DEFAULT_FIRST)
assert set(names) == {
"appeal_outcome", "offender_gender", "sentence_serve", "plea_point",
"did_offender_confess", "remand_decision", "offender_job_offence",
"offender_home_offence", "offender_intox_offence", "offender_victim_relationship",
"victim_type", "victim_gender", "victim_intox_offence", "pre_sent_report",
"vic_impact_statement", "appellant", "appeal_against",
}
assert "keywords" not in names and "convict_offences" not in names
def test_select_base_fields_defaults_to_all_and_validates_names():
assert select_base_fields(None) == base_compare_fields()
assert [s.field for s in select_base_fields(["plea_point", "appellant"])] == ["plea_point", "appellant"]
with pytest.raises(ValueError, match="keywords"):
select_base_fields(["keywords"])
- Step 2: Run to verify failure
cannot import name ... from 'app.compare.fields'.
- Step 3: Implement
backend/app/compare/fields.py:
"""Which fields can be compared side by side, and in what order.
Comparable = a closed value set: `enum` on the property, `enum` on array items,
or `boolean`. Free-text arrays (keywords, convict_offences) are excluded on
purpose — they are high-cardinality and PL-only in practice (APP_STATUS §4).
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal
from juddges_search.info_extraction import BaseSchemaExtractor
FieldKind = Literal["enum", "enum_array", "boolean"]
DEFAULT_FIRST: tuple[str, ...] = (
"appeal_outcome",
"offender_gender",
"sentence_serve",
"plea_point",
)
@dataclass(frozen=True)
class FieldSpec:
field: str
label: str
kind: FieldKind
source: str = "base"
order: int = 999
def _kind_of(prop: dict[str, Any]) -> FieldKind | None:
if prop.get("type") == "boolean":
return "boolean"
if "enum" in prop:
return "enum"
if prop.get("type") == "array" and isinstance(prop.get("items"), dict) and "enum" in prop["items"]:
return "enum_array"
return None
def comparable_fields_from_schema(schema: dict[str, Any], source: str) -> list[FieldSpec]:
specs: list[FieldSpec] = []
for name, prop in (schema.get("properties") or {}).items():
kind = _kind_of(prop)
if kind is None:
continue
specs.append(
FieldSpec(
field=name,
label=prop.get("x-ui-label") or prop.get("title") or name,
kind=kind,
source=source,
order=int(prop.get("x-ui-order", 999)),
)
)
specs.sort(key=lambda s: (s.order, s.field))
return specs
@lru_cache(maxsize=1)
def _base_fields_tuple() -> tuple[FieldSpec, ...]:
specs = comparable_fields_from_schema(BaseSchemaExtractor().schema, source="base")
first = [s for name in DEFAULT_FIRST for s in specs if s.field == name]
rest = [s for s in specs if s.field not in DEFAULT_FIRST]
return tuple(first + rest)
def base_compare_fields() -> list[FieldSpec]:
return list(_base_fields_tuple())
def select_base_fields(requested: list[str] | None) -> list[FieldSpec]:
"""Resolve requested field names against the base registry, keeping request order."""
if not requested:
return base_compare_fields()
by_name = {s.field: s for s in base_compare_fields()}
unknown = [f for f in requested if f not in by_name]
if unknown:
raise ValueError(f"Not comparable base fields: {', '.join(unknown)}")
return [by_name[f] for f in requested]
- Step 4: Run tests
cd backend && poetry run pytest tests/app/test_compare_fields.py tests/app/test_compare_layout.py -q && poetry run ruff check app/compare && poetry run ruff format --check app/compare
- Step 5: Commit
git add backend/app/compare/fields.py backend/tests/app/test_compare_fields.py
git commit -m "feat(compare): comparable field registry from JSON Schema
Refs #<C>"
Task 6: CompareService — RPC rows → CompareResponse¶
Files:
- Create: backend/app/compare/service.py
- Test: backend/tests/app/test_compare_service.py
Interfaces:
- Consumes: list_extracted_filter_matches (Task 1), get_extracted_facet_counts_by_jurisdiction (Task 2) via a supabase-py client's .rpc(name, params).execute().data; build_field, coverage_ratio (Task 4); FieldSpec (Task 5).
- Produces:
IGNORED_FILTER_KEYS = ("jurisdiction",)
class CompareService:
def __init__(self, client) -> None
def compare(self, filters: dict, text_query: str | None, specs: list[FieldSpec]) -> CompareResponse
def facet_rows(self, filters: dict, field: str, text_query: str | None) -> list[dict]
def strip_ignored(filters: dict) -> tuple[dict, list[str]]
def rows_to_coverage_and_counts(rows: list[dict]) -> tuple[dict[str, CoverageStat], dict[str, dict[str, int]]]
- Step 1: Write the failing tests
backend/tests/app/test_compare_service.py:
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.compare.fields import FieldSpec
from app.compare.service import (
CompareService,
rows_to_coverage_and_counts,
strip_ignored,
)
pytestmark = pytest.mark.unit
class _FakeClient:
"""Records .rpc() calls and returns canned rows per field_path."""
def __init__(self, rows_by_field: dict[str, list[dict]]):
self.rows_by_field = rows_by_field
self.calls: list[tuple[str, dict]] = []
def rpc(self, name: str, params: dict):
self.calls.append((name, params))
data = self.rows_by_field.get(params.get("field_path"), [])
return SimpleNamespace(execute=lambda: SimpleNamespace(data=data))
def _row(j, value, count, total, covered):
return {"jurisdiction": j, "value": value, "count": count, "total": total,
"covered": covered, "coverage": None if total == 0 else covered / total}
def test_strip_ignored_removes_jurisdiction_and_reports_it():
filters, ignored = strip_ignored({"jurisdiction": ["PL"], "plea_point": ["before_trial"]})
assert filters == {"plea_point": ["before_trial"]} and ignored == ["jurisdiction"]
assert strip_ignored({"a": 1}) == ({"a": 1}, [])
def test_rows_to_coverage_and_counts_handles_summary_rows():
rows = [
_row("PL", "a", 3, 5, 4), _row("PL", "b", 1, 5, 4),
_row("UK", None, None, 7, 0), # matched 7, none filled
]
coverage, counts = rows_to_coverage_and_counts(rows)
assert coverage["PL"].covered == 4 and coverage["PL"].total == 5 and coverage["PL"].ratio == 0.8
assert coverage["UK"].covered == 0 and coverage["UK"].total == 7 and coverage["UK"].ratio == 0.0
assert counts == {"PL": {"a": 3, "b": 1}}
def test_compare_calls_rpc_once_per_field_and_builds_totals():
client = _FakeClient({
"appeal_outcome": [_row("PL", "x", 2, 2, 2), _row("UK", "x", 1, 3, 3), _row("UK", "y", 2, 3, 3)],
"plea_point": [_row("PL", None, None, 2, 0), _row("UK", "before_trial", 3, 3, 3)],
})
specs = [FieldSpec("appeal_outcome", "Appeal Outcome", "enum_array"),
FieldSpec("plea_point", "Plea Point", "enum")]
resp = CompareService(client).compare({"jurisdiction": ["UK"], "appellant": ["offender"]}, "fraud", specs)
assert [c[0] for c in client.calls] == ["get_extracted_facet_counts_by_jurisdiction"] * 2
assert client.calls[0][1] == {"p_filters": {"appellant": ["offender"]},
"field_path": "appeal_outcome", "p_text_query": "fraud"}
assert resp.totals == {"PL": 2, "UK": 3}
assert resp.ignored_filter_keys == ["jurisdiction"]
assert resp.filters == {"appellant": ["offender"]} and resp.text_query == "fraud"
outcome, plea = resp.fields
assert outcome.tier == "primary" and [v.value for v in outcome.values] == ["x", "y"]
assert plea.tier == "unavailable" and plea.missing_in == ["PL"]
def test_compare_with_no_matches_reports_zero_totals_and_empty_fields():
client = _FakeClient({"appeal_outcome": []})
resp = CompareService(client).compare({}, None, [FieldSpec("appeal_outcome", "Appeal Outcome", "enum_array")])
assert resp.totals == {"PL": 0, "UK": 0}
assert resp.fields[0].tier == "empty"
- Step 2: Run to verify failure
app.compare.service not found.
- Step 3: Implement
backend/app/compare/service.py:
"""Turn per-jurisdiction facet rows into a CompareResponse."""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from loguru import logger
from app.compare.fields import FieldSpec
from app.compare.layout import JURISDICTIONS, build_field, coverage_ratio
from app.compare.models import CompareField, CompareResponse, CoverageStat
IGNORED_FILTER_KEYS: tuple[str, ...] = ("jurisdiction",)
FACET_RPC = "get_extracted_facet_counts_by_jurisdiction"
def strip_ignored(filters: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""/compare always splits by jurisdiction; a jurisdiction filter is dropped, not honoured."""
ignored = [k for k in IGNORED_FILTER_KEYS if k in filters]
return {k: v for k, v in filters.items() if k not in IGNORED_FILTER_KEYS}, ignored
def rows_to_coverage_and_counts(
rows: list[dict[str, Any]],
) -> tuple[dict[str, CoverageStat], dict[str, dict[str, int]]]:
"""Split RPC rows into coverage per jurisdiction and {jurisdiction: {value: count}}.
Summary rows (value IS NULL) carry total/covered only.
"""
coverage: dict[str, CoverageStat] = {}
counts: dict[str, dict[str, int]] = defaultdict(dict)
for row in rows:
j = row["jurisdiction"]
total, covered = int(row["total"]), int(row["covered"])
coverage[j] = CoverageStat(covered=covered, total=total, ratio=coverage_ratio(covered, total))
if row.get("value") is not None:
counts[j][str(row["value"])] = int(row["count"])
return coverage, dict(counts)
class CompareService:
def __init__(self, client) -> None:
self._client = client
def facet_rows(self, filters: dict[str, Any], field: str, text_query: str | None) -> list[dict[str, Any]]:
response = self._client.rpc(
FACET_RPC,
{"p_filters": filters, "field_path": field, "p_text_query": text_query},
).execute()
return list(response.data or [])
def compare(
self,
filters: dict[str, Any],
text_query: str | None,
specs: list[FieldSpec],
) -> CompareResponse:
clean, ignored = strip_ignored(filters)
totals: dict[str, int] = {j: 0 for j in JURISDICTIONS}
fields: list[CompareField] = []
for spec in specs:
rows = self.facet_rows(clean, spec.field, text_query)
coverage, counts = rows_to_coverage_and_counts(rows)
for j, stat in coverage.items():
totals[j] = stat.total # identical for every field; last write wins harmlessly
fields.append(build_field(spec, coverage, counts))
logger.info("compare: {} fields, totals={}", len(fields), totals)
return CompareResponse(
totals=totals,
fields=fields,
ignored_filter_keys=ignored,
filters=clean,
text_query=text_query,
)
- Step 4: Run tests + lint
cd backend && poetry run pytest tests/app/test_compare_service.py -q && poetry run ruff check app/compare tests/app && poetry run ruff format --check app/compare
- Step 5: Commit
git add backend/app/compare/service.py backend/tests/app/test_compare_service.py
git commit -m "feat(compare): CompareService over the by-jurisdiction facet RPC
Refs #<C>"
Task 7: POST /compare/facets endpoint + registration¶
Files:
- Create: backend/app/compare/router.py
- Modify: backend/app/server.py:684-710 (add compare_router to API_KEY_PROTECTED_ROUTERS; import near line 45)
- Test: backend/tests/app/test_compare_router.py
Interfaces:
- Consumes: CompareService (Task 6), select_base_fields (Task 5), app.core.supabase.supabase_client, app.core.auth_jwt.get_current_user.
- Produces: POST /compare/facets body CompareRequest {filters: dict = {}, text_query: str | None, fields: list[str] | None} → CompareResponse (Task 4). Errors: 503 DATABASE_UNAVAILABLE, 400 UNKNOWN_FIELD, 500 COMPARE_FAILED — detail shape {"error","message","code"} like results_router.py.
- Step 1: Write the failing tests
backend/tests/app/test_compare_router.py:
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
pytestmark = [pytest.mark.anyio, pytest.mark.unit, pytest.mark.api]
def _rows(field: str):
return [
{"jurisdiction": "PL", "value": "a", "count": 4, "total": 5, "covered": 5, "coverage": 1.0},
{"jurisdiction": "UK", "value": "a", "count": 1, "total": 2, "covered": 2, "coverage": 1.0},
]
def _client_returning(rows_by_field):
client = MagicMock()
def _rpc(name, params):
m = MagicMock()
m.execute.return_value = MagicMock(data=rows_by_field.get(params["field_path"], []))
return m
client.rpc.side_effect = _rpc
return client
async def test_facets_requires_bearer_user(client, valid_api_headers):
response = await client.post("/compare/facets", json={"filters": {}}, headers=valid_api_headers)
assert response.status_code in (401, 403)
async def test_facets_returns_compare_response(authenticated_client):
fake = _client_returning({"appeal_outcome": _rows("appeal_outcome")})
with patch("app.compare.router.supabase_client", fake):
response = await authenticated_client.post(
"/compare/facets",
json={"filters": {"appellant": ["offender"]}, "text_query": None,
"fields": ["appeal_outcome"]},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["totals"] == {"PL": 5, "UK": 2}
assert body["fields"][0]["field"] == "appeal_outcome"
assert body["fields"][0]["values"][0]["shares"] == {"PL": 0.8, "UK": 0.5}
fake.rpc.assert_called_once_with(
"get_extracted_facet_counts_by_jurisdiction",
{"p_filters": {"appellant": ["offender"]}, "field_path": "appeal_outcome", "p_text_query": None},
)
async def test_facets_defaults_to_all_base_fields(authenticated_client):
fake = _client_returning({})
with patch("app.compare.router.supabase_client", fake):
response = await authenticated_client.post("/compare/facets", json={"filters": {}})
assert response.status_code == 200
assert len(response.json()["fields"]) == 17
assert fake.rpc.call_count == 17
async def test_facets_rejects_non_comparable_field(authenticated_client):
with patch("app.compare.router.supabase_client", MagicMock()):
response = await authenticated_client.post(
"/compare/facets", json={"filters": {}, "fields": ["keywords"]})
assert response.status_code == 400
assert response.json()["detail"]["code"] == "UNKNOWN_FIELD"
async def test_facets_503_without_database(authenticated_client):
with patch("app.compare.router.supabase_client", None):
response = await authenticated_client.post("/compare/facets", json={"filters": {}})
assert response.status_code == 503
- Step 2: Run to verify failure
/compare/facets (router not registered).
- Step 3: Implement the router and register it
backend/app/compare/router.py:
"""HTTP surface of the PL/UK comparison: /compare/facets (+ export, pairs later)."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from app.compare.fields import select_base_fields
from app.compare.models import CompareResponse
from app.compare.service import CompareService
from app.core.auth_jwt import AuthenticatedUser, get_current_user
from app.core.supabase import supabase_client
router = APIRouter(prefix="/compare", tags=["compare"])
class CompareRequest(BaseModel):
filters: dict[str, Any] = Field(default_factory=dict)
text_query: str | None = None
fields: list[str] | None = Field(
default=None, description="Base-schema field names; defaults to all comparable fields"
)
def _require_db():
if not supabase_client:
raise HTTPException(
status_code=503,
detail={"error": "Database Unavailable",
"message": "Database connection not available.",
"code": "DATABASE_UNAVAILABLE"},
)
return supabase_client
def _run_compare(request: CompareRequest) -> CompareResponse:
client = _require_db()
try:
specs = select_base_fields(request.fields)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail={"error": "Unknown Field", "message": str(exc), "code": "UNKNOWN_FIELD"},
) from exc
try:
return CompareService(client).compare(request.filters, request.text_query, specs)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001 — surface as a typed 500 like sibling routers
logger.exception("compare failed: {}", exc)
raise HTTPException(
status_code=500,
detail={"error": "Compare Failed", "message": str(exc), "code": "COMPARE_FAILED"},
) from exc
@router.post(
"/facets",
response_model=CompareResponse,
summary="Side-by-side PL/UK value distributions for base-schema fields",
)
async def compare_facets(
request: CompareRequest,
user: AuthenticatedUser = Depends(get_current_user),
) -> CompareResponse:
return _run_compare(request)
backend/app/server.py: add from app.compare.router import router as compare_router next to the other router imports (line ≈ 45), and compare_router, inside API_KEY_PROTECTED_ROUTERS after collections_router.
- Step 4: Run tests + lint + static DB contract
cd backend && poetry run pytest tests/app/test_compare_router.py tests/app/test_db_contract_static.py -q && poetry run ruff check app && poetry run ruff format --check app
get_extracted_facet_counts_by_jurisdiction declared in migration 2).
- Step 5: Commit
git add backend/app/compare/router.py backend/app/server.py backend/tests/app/test_compare_router.py
git commit -m "feat(api): POST /compare/facets
Refs #<C>"
Task 8: CSV export — POST /compare/export¶
Files:
- Create: backend/app/compare/csv_export.py
- Modify: backend/app/compare/router.py (add endpoint)
- Test: backend/tests/app/test_compare_csv.py
Interfaces:
- Produces: CSV_COLUMNS = ("field","value","jurisdiction","count","share","coverage","covered","total"), to_csv_rows(response: CompareResponse) -> list[dict], csv_bytes(rows) -> bytes (UTF-8 BOM); POST /compare/export (same body as /compare/facets) → StreamingResponse text/csv; charset=utf-8, Content-Disposition: attachment; filename="compare_<YYYY-MM-DD>.csv", X-Rows-Count.
- Step 1: Write the failing tests
backend/tests/app/test_compare_csv.py:
from __future__ import annotations
import csv
import io
from unittest.mock import MagicMock, patch
import pytest
from app.compare.csv_export import CSV_COLUMNS, csv_bytes, to_csv_rows
from app.compare.models import CompareField, CompareResponse, CompareValue, CoverageStat
pytestmark = [pytest.mark.unit]
def _response() -> CompareResponse:
return CompareResponse(
totals={"PL": 4, "UK": 2},
filters={}, text_query=None,
fields=[
CompareField(
field="plea_point", label="Plea Point", source="base", kind="enum",
coverage={"PL": CoverageStat(covered=4, total=4, ratio=1.0),
"UK": CoverageStat(covered=1, total=2, ratio=0.5)},
tier="partial", missing_in=[],
values=[CompareValue(value="before_trial", counts={"PL": 3, "UK": 0},
shares={"PL": 0.75, "UK": 0.0})],
),
CompareField(
field="appellant", label="Appellant", source="base", kind="enum",
coverage={"PL": CoverageStat(covered=0, total=4, ratio=0.0),
"UK": CoverageStat(covered=0, total=2, ratio=0.0)},
tier="unavailable", missing_in=["PL", "UK"], values=[],
),
],
)
def test_long_format_has_one_row_per_field_value_jurisdiction():
rows = to_csv_rows(_response())
assert [tuple(r.keys()) for r in rows] == [CSV_COLUMNS] * 2
assert rows[0] == {"field": "plea_point", "value": "before_trial", "jurisdiction": "PL",
"count": 3, "share": 0.75, "coverage": 1.0, "covered": 4, "total": 4}
assert rows[1]["jurisdiction"] == "UK" and rows[1]["count"] == 0 and rows[1]["coverage"] == 0.5
def test_fields_without_values_produce_no_rows():
assert all(r["field"] != "appellant" for r in to_csv_rows(_response()))
def test_csv_bytes_has_bom_and_header():
data = csv_bytes(to_csv_rows(_response()))
assert data.startswith(b"\xef\xbb\xbf")
reader = csv.reader(io.StringIO(data.decode("utf-8-sig")))
assert next(reader) == list(CSV_COLUMNS)
@pytest.mark.anyio
async def test_export_endpoint_streams_csv(authenticated_client):
fake = MagicMock()
fake.rpc.return_value.execute.return_value = MagicMock(data=[
{"jurisdiction": "PL", "value": "a", "count": 1, "total": 1, "covered": 1, "coverage": 1.0},
])
with patch("app.compare.router.supabase_client", fake):
response = await authenticated_client.post(
"/compare/export", json={"filters": {}, "fields": ["appellant"]})
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert response.headers["content-disposition"].startswith('attachment; filename="compare_')
assert response.headers["x-rows-count"] == "2" # PL row + UK row (count 0)
body = response.content.decode("utf-8-sig").splitlines()
assert body[0] == ",".join(CSV_COLUMNS)
- Step 2: Run to verify failure
app.compare.csv_export not found.
- Step 3: Implement
backend/app/compare/csv_export.py:
"""Long-format CSV of a CompareResponse (Spec C AC5)."""
from __future__ import annotations
import csv
import io
from typing import Any
from app.compare.layout import JURISDICTIONS
from app.compare.models import CompareResponse
CSV_COLUMNS: tuple[str, ...] = (
"field", "value", "jurisdiction", "count", "share", "coverage", "covered", "total",
)
def to_csv_rows(response: CompareResponse) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for field in response.fields:
for value in field.values:
for j in JURISDICTIONS:
cov = field.coverage[j]
rows.append({
"field": field.field,
"value": value.value,
"jurisdiction": j,
"count": value.counts.get(j, 0),
"share": value.shares.get(j),
"coverage": cov.ratio,
"covered": cov.covered,
"total": cov.total,
})
return rows
def csv_bytes(rows: list[dict[str, Any]]) -> bytes:
"""UTF-8 with BOM so Excel opens Polish labels correctly (same as results export)."""
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=list(CSV_COLUMNS), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
return b"\xef\xbb\xbf" + buffer.getvalue().encode("utf-8")
Append to backend/app/compare/router.py:
from datetime import UTC, datetime
from io import BytesIO
from fastapi.responses import StreamingResponse
from app.compare.csv_export import csv_bytes, to_csv_rows
@router.post("/export", summary="Long-format CSV of the comparison")
async def compare_export(
request: CompareRequest,
user: AuthenticatedUser = Depends(get_current_user),
) -> StreamingResponse:
response = _run_compare(request)
rows = to_csv_rows(response)
filename = f"compare_{datetime.now(UTC).strftime('%Y-%m-%d')}.csv"
return StreamingResponse(
BytesIO(csv_bytes(rows)),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Rows-Count": str(len(rows)),
},
)
- Step 4: Run tests + lint
cd backend && poetry run pytest tests/app/test_compare_csv.py tests/app/test_compare_router.py -q && poetry run ruff check app/compare && poetry run ruff format --check app/compare
- Step 5: Commit
git add backend/app/compare/csv_export.py backend/app/compare/router.py backend/tests/app/test_compare_csv.py
git commit -m "feat(api): POST /compare/export long CSV
Refs #<C>"
Task 9: Collection pairs — DB layer + /collections/pairs router¶
Files:
- Create: backend/packages/juddges_search/juddges_search/db/collection_pairs_db.py
- Create: backend/app/collection_pairs.py
- Modify: backend/app/server.py:684-710 (register before collections_router)
- Test: backend/tests/app/test_collection_pairs_router.py
Interfaces:
- Consumes: SupabaseClientMixin (juddges_search/db/_base.py), CollectionsDB.create_collection/bulk_add_documents (collections_db.py:158,251), RPC list_extracted_filter_matches (Task 1), strip_ignored (Task 6).
- Produces DB layer:
class CollectionPairsDB(SupabaseClientMixin):
async def create_pair(self, user_id, name, filters, text_query, pl_collection_id, uk_collection_id) -> dict
async def list_pairs(self, user_id) -> list[dict]
async def find_pair(self, pair_id, user_id) -> dict | None
async def delete_pair(self, pair_id, user_id) -> bool
async def pairs_by_collection(self, user_id) -> dict[str, dict] # collection_id -> {"id","name","role","partner_collection_id"}
def get_collection_pairs_db() -> CollectionPairsDB
/collections/pairs):
- POST "" body CreatePairRequest {name: str(1..200), filters: dict, text_query: str|None} → 201 CollectionPair {id, user_id, name, filters, text_query, created_at, sides: [PairSide {jurisdiction, collection_id, document_count}]}; 413 PAIR_SIDE_TOO_LARGE when a side > MAX_PAIR_SIDE = 5000.
- GET "" → list[CollectionPair]; GET "/{pair_id}" → CollectionPair (404); DELETE "/{pair_id}" → 204 (unlink only).
- Collection names: f"{name} — PL", f"{name} — UK"; description f"Created from /compare · filter: {json.dumps(filters, ensure_ascii=False)[:900]}".
- Step 1: Write the failing tests
backend/tests/app/test_collection_pairs_router.py:
"""/collections/pairs: creation from a filter, listing, precedence over /collections/{id}."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from app.collection_pairs import MAX_PAIR_SIDE, get_collection_pairs_db
from app.core.auth_jwt import get_current_user as jwt_get_current_user
from app.server import app
from juddges_search.db.supabase_db import get_collections_db
pytestmark = [pytest.mark.anyio, pytest.mark.unit, pytest.mark.collections]
USER = "11111111-1111-4111-8111-111111111111"
class _StubCollectionsDb:
def __init__(self):
self.created: list[dict] = []
self.bulk: list[tuple[str, list[str]]] = []
async def create_collection(self, user_id, name, description=None):
cid = f"col-{len(self.created) + 1}"
row = {"id": cid, "user_id": user_id, "name": name, "description": description,
"created_at": "2026-09-21T00:00:00Z", "updated_at": "2026-09-21T00:00:00Z"}
self.created.append(row)
return row
async def bulk_add_documents(self, collection_id, judgment_ids, user_id):
self.bulk.append((collection_id, list(judgment_ids)))
return {"added": list(judgment_ids), "failed": []}
class _StubPairsDb:
def __init__(self):
self.rows: list[dict] = []
async def create_pair(self, user_id, name, filters, text_query, pl_collection_id, uk_collection_id):
row = {"id": f"pair-{len(self.rows) + 1}", "user_id": user_id, "name": name,
"filters": filters, "text_query": text_query,
"pl_collection_id": pl_collection_id, "uk_collection_id": uk_collection_id,
"created_at": "2026-09-21T00:00:00Z", "updated_at": "2026-09-21T00:00:00Z"}
self.rows.append(row)
return row
async def list_pairs(self, user_id):
return [r for r in self.rows if r["user_id"] == user_id]
async def find_pair(self, pair_id, user_id):
return next((r for r in self.rows if r["id"] == pair_id and r["user_id"] == user_id), None)
async def delete_pair(self, pair_id, user_id):
before = len(self.rows)
self.rows = [r for r in self.rows if not (r["id"] == pair_id and r["user_id"] == user_id)]
return len(self.rows) < before
def _matches_client(pl: int, uk: int):
rows = [{"id": f"pl-{i}", "jurisdiction": "PL"} for i in range(pl)] + \
[{"id": f"uk-{i}", "jurisdiction": "UK"} for i in range(uk)]
client = MagicMock()
client.rpc.return_value.execute.return_value = SimpleNamespace(data=rows)
return client
@pytest.fixture
def stubs():
cols, pairs = _StubCollectionsDb(), _StubPairsDb()
async def _user():
from app.core.auth_jwt import AuthenticatedUser
return AuthenticatedUser(user_data={"id": USER, "email": "u@x.test", "role": "authenticated"},
access_token="t")
app.dependency_overrides[jwt_get_current_user] = _user
app.dependency_overrides[get_collections_db] = lambda: cols
app.dependency_overrides[get_collection_pairs_db] = lambda: pairs
try:
yield cols, pairs
finally:
for dep in (jwt_get_current_user, get_collections_db, get_collection_pairs_db):
app.dependency_overrides.pop(dep, None)
@pytest.fixture
async def http():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test",
headers={"X-API-Key": "test-api-key-12345"}) as ac:
yield ac
async def test_create_pair_builds_two_collections_from_the_filter(http, stubs):
cols, pairs = stubs
with patch("app.collection_pairs.supabase_client", _matches_client(pl=3, uk=2)) as fake:
response = await http.post("/collections/pairs", json={
"name": "Fraud, suspended", "filters": {"jurisdiction": ["PL"], "appellant": ["offender"]},
"text_query": "fraud"})
assert response.status_code == 201, response.text
fake.rpc.assert_called_once_with("list_extracted_filter_matches",
{"p_filters": {"appellant": ["offender"]}, "p_text_query": "fraud"})
body = response.json()
assert [c["name"] for c in cols.created] == ["Fraud, suspended — PL", "Fraud, suspended — UK"]
assert cols.bulk == [("col-1", ["pl-0", "pl-1", "pl-2"]), ("col-2", ["uk-0", "uk-1"])]
assert body["sides"] == [
{"jurisdiction": "PL", "collection_id": "col-1", "document_count": 3},
{"jurisdiction": "UK", "collection_id": "col-2", "document_count": 2},
]
assert body["filters"] == {"appellant": ["offender"]}
assert pairs.rows[0]["pl_collection_id"] == "col-1"
async def test_create_pair_refuses_oversized_side(http, stubs):
cols, _ = stubs
with patch("app.collection_pairs.supabase_client", _matches_client(pl=MAX_PAIR_SIDE + 1, uk=1)):
response = await http.post("/collections/pairs", json={"name": "big", "filters": {}})
assert response.status_code == 413
assert response.json()["detail"]["code"] == "PAIR_SIDE_TOO_LARGE"
assert cols.created == []
async def test_create_pair_chunks_bulk_adds_in_1000s(http, stubs):
cols, _ = stubs
with patch("app.collection_pairs.supabase_client", _matches_client(pl=2500, uk=0)):
response = await http.post("/collections/pairs", json={"name": "many", "filters": {}})
assert response.status_code == 201
assert [len(ids) for cid, ids in cols.bulk if cid == "col-1"] == [1000, 1000, 500]
async def test_list_get_delete_pair(http, stubs):
_, pairs = stubs
await pairs.create_pair(USER, "p", {}, None, "a", "b")
listed = await http.get("/collections/pairs")
assert listed.status_code == 200 and listed.json()[0]["id"] == "pair-1"
one = await http.get("/collections/pairs/pair-1")
assert one.status_code == 200 and one.json()["name"] == "p"
gone = await http.delete("/collections/pairs/pair-1")
assert gone.status_code == 204
assert (await http.get("/collections/pairs/pair-1")).status_code == 404
async def test_pairs_route_wins_over_collection_id_catch_all(http, stubs):
"""/collections/{collection_id} would otherwise swallow 'pairs' and answer 404."""
response = await http.get("/collections/pairs")
assert response.status_code == 200
assert response.json() == []
- Step 2: Run to verify failure
ModuleNotFoundError: app.collection_pairs.
- Step 3: Implement the DB layer
backend/packages/juddges_search/juddges_search/db/collection_pairs_db.py:
"""Persistence for PL/UK collection pairs (table public.collection_pairs)."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from loguru import logger
from postgrest.exceptions import APIError as PostgrestAPIError
from juddges_search.db._base import SupabaseClientMixin
_COLS = "id, user_id, name, filters, text_query, pl_collection_id, uk_collection_id, created_at, updated_at"
class CollectionPairsDB(SupabaseClientMixin):
def __init__(self):
self._init_client("CollectionPairsDB")
async def create_pair(self, user_id: str, name: str, filters: dict[str, Any], text_query: str | None,
pl_collection_id: str, uk_collection_id: str) -> dict[str, Any]:
try:
response = (
self.client.table("collection_pairs")
.insert({"user_id": user_id, "name": name, "filters": filters, "text_query": text_query,
"pl_collection_id": pl_collection_id, "uk_collection_id": uk_collection_id})
.execute()
)
return response.data[0]
except PostgrestAPIError as e:
logger.exception(f"Error creating collection pair: {e}")
raise HTTPException(status_code=500, detail=f"Database error: {e!s}")
async def list_pairs(self, user_id: str) -> list[dict[str, Any]]:
response = (
self.client.table("collection_pairs").select(_COLS)
.eq("user_id", user_id).order("created_at", desc=True).execute()
)
return list(response.data or [])
async def find_pair(self, pair_id: str, user_id: str) -> dict[str, Any] | None:
response = (
self.client.table("collection_pairs").select(_COLS)
.eq("id", pair_id).eq("user_id", user_id).limit(1).execute()
)
return response.data[0] if response.data else None
async def delete_pair(self, pair_id: str, user_id: str) -> bool:
response = (
self.client.table("collection_pairs").delete()
.eq("id", pair_id).eq("user_id", user_id).execute()
)
return bool(response.data)
async def pairs_by_collection(self, user_id: str) -> dict[str, dict[str, Any]]:
"""collection_id -> {id, name, role, partner_collection_id} for the user's pairs."""
out: dict[str, dict[str, Any]] = {}
for pair in await self.list_pairs(user_id):
pl, uk = pair["pl_collection_id"], pair["uk_collection_id"]
out[pl] = {"id": pair["id"], "name": pair["name"], "role": "PL", "partner_collection_id": uk}
out[uk] = {"id": pair["id"], "name": pair["name"], "role": "UK", "partner_collection_id": pl}
return out
_pairs_db: CollectionPairsDB | None = None
def get_collection_pairs_db() -> CollectionPairsDB:
global _pairs_db
if _pairs_db is None:
try:
_pairs_db = CollectionPairsDB()
except ValueError as e:
logger.error(f"Collection pairs database not configured: {e}")
raise HTTPException(status_code=500, detail=f"Database configuration error: {e!s}")
return _pairs_db
_base.py for the exact PostgrestAPIError import used by collections_db.py and mirror it.)
- Step 4: Implement the router
backend/app/collection_pairs.py:
"""PL/UK collection pairs: create both sides from one base-schema filter (Spec C AC3)."""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Response
from juddges_search.db.collection_pairs_db import get_collection_pairs_db
from juddges_search.db.supabase_db import get_collections_db
from loguru import logger
from pydantic import BaseModel, Field
from app.compare.service import strip_ignored
from app.core.auth_jwt import AuthenticatedUser, get_current_user
from app.core.supabase import supabase_client
from app.models import validate_id_format
from app.services.audit_service import log_audit_background
router = APIRouter(prefix="/collections/pairs", tags=["collections"])
MAX_PAIR_SIDE = 5000
BULK_CHUNK = 1000
SIDES: tuple[str, ...] = ("PL", "UK")
class CreatePairRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
filters: dict[str, Any] = Field(default_factory=dict)
text_query: str | None = None
class PairSide(BaseModel):
jurisdiction: str
collection_id: str
document_count: int
class CollectionPair(BaseModel):
id: str
user_id: str
name: str
filters: dict[str, Any]
text_query: str | None = None
created_at: str
sides: list[PairSide]
def _to_model(row: dict[str, Any], counts: dict[str, int] | None = None) -> CollectionPair:
counts = counts or {}
return CollectionPair(
id=row["id"], user_id=row["user_id"], name=row["name"], filters=row.get("filters") or {},
text_query=row.get("text_query"), created_at=row["created_at"],
sides=[
PairSide(jurisdiction="PL", collection_id=row["pl_collection_id"], document_count=counts.get("PL", 0)),
PairSide(jurisdiction="UK", collection_id=row["uk_collection_id"], document_count=counts.get("UK", 0)),
],
)
def _matching_ids(filters: dict[str, Any], text_query: str | None) -> dict[str, list[str]]:
if not supabase_client:
raise HTTPException(status_code=503, detail={"error": "Database Unavailable",
"message": "Database connection not available.",
"code": "DATABASE_UNAVAILABLE"})
response = supabase_client.rpc(
"list_extracted_filter_matches", {"p_filters": filters, "p_text_query": text_query}
).execute()
ids: dict[str, list[str]] = {j: [] for j in SIDES}
for row in response.data or []:
if row["jurisdiction"] in ids:
ids[row["jurisdiction"]].append(str(row["id"]))
return ids
@router.post("", response_model=CollectionPair, status_code=201)
async def create_pair(
request: CreatePairRequest,
background_tasks: BackgroundTasks,
collections_db=Depends(get_collections_db),
pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user),
):
filters, _ignored = strip_ignored(request.filters)
ids = _matching_ids(filters, request.text_query)
too_large = [j for j, v in ids.items() if len(v) > MAX_PAIR_SIDE]
if too_large:
raise HTTPException(
status_code=413,
detail={"error": "Pair Too Large",
"message": f"{', '.join(too_large)} matched more than {MAX_PAIR_SIDE} judgments; narrow the filter.",
"code": "PAIR_SIDE_TOO_LARGE"},
)
description = f"Created from /compare · filter: {json.dumps(filters, ensure_ascii=False)[:900]}"
created: dict[str, str] = {}
for j in SIDES:
collection = await collections_db.create_collection(user.id, f"{request.name} — {j}", description)
created[j] = collection["id"]
for start in range(0, len(ids[j]), BULK_CHUNK):
await collections_db.bulk_add_documents(collection["id"], ids[j][start:start + BULK_CHUNK], user.id)
row = await pairs_db.create_pair(user.id, request.name, filters, request.text_query, created["PL"], created["UK"])
log_audit_background(background_tasks, user_id=user.id, action_type="collection_pair_created",
resource_type="collection_pair", resource_id=row["id"],
input_data={"pl": created["PL"], "uk": created["UK"]})
logger.info("pair {} created: PL={} UK={}", row["id"], len(ids["PL"]), len(ids["UK"]))
return _to_model(row, {j: len(ids[j]) for j in SIDES})
@router.get("", response_model=list[CollectionPair])
async def list_pairs(pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user)):
return [_to_model(r) for r in await pairs_db.list_pairs(user.id)]
@router.get("/{pair_id}", response_model=CollectionPair)
async def get_pair(pair_id: str = Path(...), pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user)):
try:
pair_id = validate_id_format(pair_id, "pair_id")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
row = await pairs_db.find_pair(pair_id, user.id)
if not row:
raise HTTPException(404, "Collection pair not found")
return _to_model(row)
@router.delete("/{pair_id}", status_code=204)
async def delete_pair(pair_id: str = Path(...), pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user)):
if not await pairs_db.delete_pair(pair_id, user.id):
raise HTTPException(404, "Collection pair not found")
return Response(status_code=204)
backend/app/server.py: from app.collection_pairs import router as collection_pairs_router and insert collection_pairs_router, immediately before collections_router, in API_KEY_PROTECTED_ROUTERS (FastAPI matches in registration order; /collections/{collection_id} would otherwise capture pairs). Add a comment saying so.
audit_service.log_audit_background: check its action_type/resource_type validation (grep ALLOWED_ACTION_TYPES or similar in backend/app/services/audit_service.py); if there is an allow-list, add collection_pair_created and collection_pair there.
- Step 5: Run tests + lint + static contract
cd backend && poetry run pytest tests/app/test_collection_pairs_router.py tests/app/test_collections_batch_cap.py tests/app/test_db_contract_static.py -q && poetry run ruff check app packages/juddges_search/juddges_search/db && poetry run ruff format --check app packages/juddges_search/juddges_search/db
collection_pairs table declared by migration 3; list_extracted_filter_matches by migration 1).
- Step 6: Commit
git add backend/app/collection_pairs.py backend/app/server.py backend/packages/juddges_search/juddges_search/db/collection_pairs_db.py backend/tests/app/test_collection_pairs_router.py backend/app/services/audit_service.py
git commit -m "feat(api): /collections/pairs — PL/UK pair from one filter
Refs #<C>"
Task 10: GET /collections exposes the pair¶
Files:
- Modify: backend/app/collections.py:23-25 (CollectionWithDocuments), :47-69 (transform_collection), :72-78 (list_collections)
- Test: backend/tests/app/test_collections_pair_field.py
Interfaces:
- Produces: CollectionWithDocuments.pair: CollectionPairRef | None where class CollectionPairRef(BaseModel): id: str; name: str; role: Literal["PL","UK"]; partner_collection_id: str. transform_collection(data, pair=None).
- Step 1: Write the failing test
backend/tests/app/test_collections_pair_field.py:
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.collection_pairs import get_collection_pairs_db
from app.core.auth_jwt import AuthenticatedUser, get_current_user as jwt_get_current_user
from app.server import app
from juddges_search.db.supabase_db import get_collections_db
pytestmark = [pytest.mark.anyio, pytest.mark.unit, pytest.mark.collections]
USER = "22222222-2222-4222-8222-222222222222"
_ROW = {"user_id": USER, "description": None,
"created_at": "2026-09-21T00:00:00Z", "updated_at": "2026-09-21T00:00:00Z"}
class _Cols:
async def get_user_collections(self, user_id):
return [
{**_ROW, "id": "c-pl", "name": "Q — PL", "collection_judgments": [{"judgment_id": "a"}], "document_count": 1},
{**_ROW, "id": "c-uk", "name": "Q — UK", "collection_judgments": [], "document_count": 0},
{**_ROW, "id": "c-solo", "name": "solo", "collection_judgments": [], "document_count": 0},
]
class _Pairs:
async def pairs_by_collection(self, user_id):
return {
"c-pl": {"id": "p1", "name": "Q", "role": "PL", "partner_collection_id": "c-uk"},
"c-uk": {"id": "p1", "name": "Q", "role": "UK", "partner_collection_id": "c-pl"},
}
async def test_list_collections_marks_paired_collections():
async def _user():
return AuthenticatedUser(user_data={"id": USER, "email": "u@x.test", "role": "authenticated"}, access_token="t")
app.dependency_overrides[jwt_get_current_user] = _user
app.dependency_overrides[get_collections_db] = lambda: _Cols()
app.dependency_overrides[get_collection_pairs_db] = lambda: _Pairs()
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test",
headers={"X-API-Key": "test-api-key-12345"}) as ac:
response = await ac.get("/collections")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
by_id = {c["id"]: c for c in response.json()}
assert by_id["c-pl"]["pair"] == {"id": "p1", "name": "Q", "role": "PL", "partner_collection_id": "c-uk"}
assert by_id["c-uk"]["pair"]["role"] == "UK"
assert by_id["c-solo"]["pair"] is None
- Step 2: Run to verify failure
KeyError: 'pair'.
- Step 3: Implement
In backend/app/collections.py:
from typing import Literal
from juddges_search.db.collection_pairs_db import get_collection_pairs_db
class CollectionPairRef(BaseModel):
id: str
name: str
role: Literal["PL", "UK"]
partner_collection_id: str
class CollectionWithDocuments(Collection):
documents: list[str] = []
document_count: int = 0
pair: CollectionPairRef | None = None
transform_collection(data, pair: dict | None = None) → add pair=CollectionPairRef(**pair) if pair else None to the constructor call.
@router.get("", response_model=list[CollectionWithDocuments])
async def list_collections(
db=Depends(get_collections_db),
pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user),
):
collections = await db.get_user_collections(user.id)
pairs = await pairs_db.pairs_by_collection(user.id)
return [transform_collection(c, pairs.get(c["id"])) for c in collections]
- Step 4: Run tests
cd backend && poetry run pytest tests/app/test_collections_pair_field.py tests/app/test_collections_batch_cap.py tests/app/test_collections_bearer_auth.py -q && poetry run ruff check app/collections.py
test_collections_bearer_auth.py builds a client without overriding get_collection_pairs_db and now fails on SUPABASE_URL, override it there with a stub returning {} from pairs_by_collection.
- Step 5: Commit
git add backend/app/collections.py backend/tests/app/test_collections_pair_field.py backend/tests/app/test_collections_bearer_auth.py
git commit -m "feat(api): GET /collections carries the PL/UK pair reference
Refs #<C>"
Task 11: Pair view — GET /compare/pairs/{pair_id} (base fields over membership + extension-schema fields)¶
Files:
- Create: backend/app/compare/schema_tally.py
- Modify: backend/app/compare/router.py (add endpoint), backend/app/compare/service.py (add compare_collections)
- Test: backend/tests/app/test_compare_pair_view.py
Interfaces:
- Consumes: collection_pairs row (Task 9 find_pair), extraction_jobs rows (id, collection_id, schema_id, status, results, completed_at; results = [{document_id, status, extracted_data}], status = 'SUCCESS'), extraction_schemas.text (JSON Schema), comparable_fields_from_schema (Task 5), build_field (Task 4).
- Produces:
# service.py
CompareService.compare_collections(self, pl_collection_id: str, uk_collection_id: str, specs) -> CompareResponse
# = compare({"collection_ids": [pl, uk]}, None, specs)
# schema_tally.py
def tally_schema_fields(results_by_jurisdiction: dict[str, list[dict]], specs: list[FieldSpec]) -> list[CompareField]
def latest_success_jobs(client, collection_ids: list[str]) -> list[dict] # one job per collection: newest SUCCESS
GET /compare/pairs/{pair_id} → CompareResponse with pair: PairSummary, fields = base fields + schema fields (schema fields only when both sides have a SUCCESS job with the same schema_id; source = f"schema:{schema_id}").
- Step 1: Write the failing tests
backend/tests/app/test_compare_pair_view.py:
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.compare.fields import FieldSpec
from app.compare.schema_tally import tally_schema_fields
pytestmark = [pytest.mark.unit]
def _results(*items):
return [{"document_id": f"d{i}", "status": "completed", "extracted_data": data}
for i, data in enumerate(items)]
def test_tally_counts_enum_boolean_and_array_values_with_coverage():
specs = [FieldSpec("verdict", "Verdict", "enum", source="schema:s1"),
FieldSpec("appealed", "Appealed", "boolean", source="schema:s1"),
FieldSpec("grounds", "Grounds", "enum_array", source="schema:s1")]
fields = tally_schema_fields(
{"PL": _results({"verdict": "guilty", "appealed": True, "grounds": ["a", "b"]},
{"verdict": "", "appealed": None, "grounds": []}),
"UK": _results({"verdict": "acquitted", "appealed": False, "grounds": ["a"]})},
specs,
)
verdict, appealed, grounds = fields
assert verdict.coverage["PL"].covered == 1 and verdict.coverage["PL"].total == 2
assert verdict.coverage["UK"].covered == 1 and verdict.coverage["UK"].total == 1
assert {v.value: v.counts for v in verdict.values} == {"guilty": {"PL": 1, "UK": 0},
"acquitted": {"PL": 0, "UK": 1}}
assert {v.value: v.counts for v in appealed.values} == {"true": {"PL": 1, "UK": 0}, "false": {"PL": 0, "UK": 1}}
assert {v.value: v.counts for v in grounds.values} == {"a": {"PL": 1, "UK": 1}, "b": {"PL": 1, "UK": 0}}
assert grounds.coverage["PL"].covered == 1 # the empty list is not covered
assert verdict.source == "schema:s1"
def test_tally_skips_failed_documents():
fields = tally_schema_fields(
{"PL": [{"document_id": "x", "status": "failed", "extracted_data": {"verdict": "guilty"}}], "UK": []},
[FieldSpec("verdict", "Verdict", "enum", source="schema:s1")],
)
assert fields[0].coverage["PL"].total == 0 and fields[0].tier == "empty"
@pytest.mark.anyio
async def test_pair_endpoint_merges_base_and_schema_fields(authenticated_client, mock_user):
pair_row = {"id": "p1", "user_id": mock_user["id"], "name": "Q", "filters": {}, "text_query": None,
"pl_collection_id": "c-pl", "uk_collection_id": "c-uk",
"created_at": "2026-09-21T00:00:00Z", "updated_at": "2026-09-21T00:00:00Z"}
class _Pairs:
async def find_pair(self, pair_id, user_id):
return pair_row if pair_id == "p1" else None
fake = MagicMock()
fake.rpc.return_value.execute.return_value = MagicMock(data=[
{"jurisdiction": "PL", "value": "x", "count": 1, "total": 1, "covered": 1, "coverage": 1.0},
{"jurisdiction": "UK", "value": "x", "count": 1, "total": 1, "covered": 1, "coverage": 1.0},
])
jobs = [
{"id": "j1", "collection_id": "c-pl", "schema_id": "s1", "status": "SUCCESS", "completed_at": "2026-09-20",
"results": [{"document_id": "a", "status": "completed", "extracted_data": {"verdict": "guilty"}}]},
{"id": "j2", "collection_id": "c-uk", "schema_id": "s1", "status": "SUCCESS", "completed_at": "2026-09-20",
"results": [{"document_id": "b", "status": "completed", "extracted_data": {"verdict": "acquitted"}}]},
]
schema = {"id": "s1", "text": {"properties": {"verdict": {"type": "string", "enum": ["guilty", "acquitted"]}}}}
from app.collection_pairs import get_collection_pairs_db
from app.server import app
app.dependency_overrides[get_collection_pairs_db] = lambda: _Pairs()
with patch("app.compare.router.supabase_client", fake), \
patch("app.compare.router.latest_success_jobs", return_value=jobs), \
patch("app.compare.router.load_schema", return_value=schema):
response = await authenticated_client.get("/compare/pairs/p1")
missing = await authenticated_client.get("/compare/pairs/nope")
app.dependency_overrides.pop(get_collection_pairs_db, None)
assert missing.status_code == 404
assert response.status_code == 200, response.text
body = response.json()
assert body["pair"] == {"id": "p1", "name": "Q", "pl_collection_id": "c-pl", "uk_collection_id": "c-uk"}
assert body["filters"] == {"collection_ids": ["c-pl", "c-uk"]}
sources = {f["source"] for f in body["fields"]}
assert sources == {"base", "schema:s1"}
verdict = next(f for f in body["fields"] if f["field"] == "verdict")
assert {v["value"]: v["counts"] for v in verdict["values"]} == {"guilty": {"PL": 1, "UK": 0}, "acquitted": {"PL": 0, "UK": 1}}
# base fields were computed over the pair's membership
assert fake.rpc.call_args_list[0].args[1]["p_filters"] == {"collection_ids": ["c-pl", "c-uk"]}
- Step 2: Run to verify failure
app.compare.schema_tally not found.
- Step 3: Implement
backend/app/compare/schema_tally.py:
"""Side-by-side counts for an extension schema, from extraction_jobs.results (Spec C AC4).
Extension-schema results are JSONB per job, not typed columns, so this is a
Python tally. Coverage uses the same "filled" rule as the SQL: non-empty string,
non-empty list, non-null boolean.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from app.compare.fields import FieldSpec
from app.compare.layout import JURISDICTIONS, build_field, coverage_ratio
from app.compare.models import CompareField, CoverageStat
_OK_STATUSES = {"completed", "success", "partially_completed"}
def _filled(value: Any) -> bool:
if value is None:
return False
if isinstance(value, str):
return value.strip() != ""
if isinstance(value, list):
return any(_filled(v) for v in value)
return True
def _values_of(value: Any, kind: str) -> list[str]:
if kind == "boolean":
return ["true" if value else "false"]
if kind == "enum_array":
return [str(v) for v in value if _filled(v)] if isinstance(value, list) else [str(value)]
return [str(value)]
def tally_schema_fields(
results_by_jurisdiction: dict[str, list[dict[str, Any]]],
specs: list[FieldSpec],
) -> list[CompareField]:
fields: list[CompareField] = []
for spec in specs:
coverage: dict[str, CoverageStat] = {}
counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for j in JURISDICTIONS:
total = covered = 0
for result in results_by_jurisdiction.get(j, []):
if str(result.get("status", "")).lower() not in _OK_STATUSES:
continue
total += 1
value = (result.get("extracted_data") or {}).get(spec.field)
if not _filled(value):
continue
covered += 1
for v in _values_of(value, spec.kind):
counts[j][v] += 1
coverage[j] = CoverageStat(covered=covered, total=total, ratio=coverage_ratio(covered, total))
fields.append(build_field(spec, coverage, {j: dict(c) for j, c in counts.items()}))
return fields
def latest_success_jobs(client, collection_ids: list[str]) -> list[dict[str, Any]]:
"""Newest SUCCESS job per collection (one row each)."""
response = (
client.table("extraction_jobs")
.select("id, collection_id, schema_id, status, results, completed_at")
.in_("collection_id", collection_ids)
.eq("status", "SUCCESS")
.order("completed_at", desc=True)
.execute()
)
newest: dict[str, dict[str, Any]] = {}
for row in response.data or []:
newest.setdefault(row["collection_id"], row)
return list(newest.values())
def load_schema(client, schema_id: str) -> dict[str, Any] | None:
response = client.table("extraction_schemas").select("id, name, text").eq("id", schema_id).limit(1).execute()
return response.data[0] if response.data else None
In backend/app/compare/service.py add:
def compare_collections(self, pl_collection_id: str, uk_collection_id: str, specs: list[FieldSpec]) -> CompareResponse:
"""Base fields over the pair's actual membership; jurisdiction split comes from the rows."""
return self.compare({"collection_ids": [pl_collection_id, uk_collection_id]}, None, specs)
In backend/app/compare/router.py add:
from juddges_search.db.collection_pairs_db import get_collection_pairs_db
from app.compare.fields import base_compare_fields, comparable_fields_from_schema
from app.compare.models import PairSummary
from app.compare.schema_tally import latest_success_jobs, load_schema, tally_schema_fields
@router.get("/pairs/{pair_id}", response_model=CompareResponse,
summary="Comparison of a saved PL/UK collection pair (base + extension schema fields)")
async def compare_pair(
pair_id: str,
pairs_db=Depends(get_collection_pairs_db),
user: AuthenticatedUser = Depends(get_current_user),
) -> CompareResponse:
client = _require_db()
pair = await pairs_db.find_pair(pair_id, user.id)
if not pair:
raise HTTPException(404, "Collection pair not found")
pl_id, uk_id = pair["pl_collection_id"], pair["uk_collection_id"]
response = CompareService(client).compare_collections(pl_id, uk_id, base_compare_fields())
jobs = {j["collection_id"]: j for j in latest_success_jobs(client, [pl_id, uk_id])}
pl_job, uk_job = jobs.get(pl_id), jobs.get(uk_id)
if pl_job and uk_job and pl_job.get("schema_id") and pl_job["schema_id"] == uk_job["schema_id"]:
schema = load_schema(client, pl_job["schema_id"])
if schema and isinstance(schema.get("text"), dict):
specs = comparable_fields_from_schema(schema["text"], source=f"schema:{schema['id']}")
response.fields.extend(tally_schema_fields(
{"PL": pl_job.get("results") or [], "UK": uk_job.get("results") or []}, specs))
response.pair = PairSummary(id=pair["id"], name=pair["name"], pl_collection_id=pl_id, uk_collection_id=uk_id)
return response
- Step 4: Run tests + lint + static contract
cd backend && poetry run pytest tests/app/test_compare_pair_view.py tests/app/test_compare_router.py tests/app/test_db_contract_static.py -q && poetry run ruff check app/compare && poetry run ruff format --check app/compare
extraction_jobs, extraction_schemas tables are declared by migrations; the static test also verifies the .select(...) column names exist for .table() calls — keep them to real columns).
- Step 5: Commit
git add backend/app/compare backend/tests/app/test_compare_pair_view.py
git commit -m "feat(api): GET /compare/pairs/{id} with extension-schema fields
Refs #<C>"
Task 12: Promote BivariateBarChart to components/charts/ (+ yTickSuffix)¶
Files:
- Create: frontend/components/charts/BivariateBarChart.tsx (moved from frontend/app/dataset-comparison/_components/BivariateBarChart.tsx)
- Modify: frontend/app/dataset-comparison/_components/BivariateBarChart.tsx → one-line re-export
- Test: frontend/tests/unit/components/charts/bivariate-bar-chart.test.tsx
Interfaces:
- Produces: export interface BivariateBarChartProps { categories: Array<string|number>; ukData: number[]; plData: number[]; ukName?; plName?; xAxisTitle?; yAxisTitle?; height?; tickAngle?; dtick?: number|string; showCounts?: boolean; yTickSuffix?: string; hoverTemplate?: string } and export function BivariateBarChart(props) (+ default export). Existing callers unchanged.
- Step 1: Write the failing test
frontend/tests/unit/components/charts/bivariate-bar-chart.test.tsx:
/**
* BivariateBarChart is the shared PL/UK grouped-bar figure. Plotly is
* dynamically imported and useless under jsdom, so the dynamic loader is
* mocked with a probe that dumps the props it receives.
*/
import { render, screen } from '@testing-library/react';
jest.mock('next/dynamic', () => () => {
const Probe = (props: Record<string, unknown>) => (
<pre data-testid="plot">{JSON.stringify({ data: props.data, layout: props.layout })}</pre>
);
Probe.displayName = 'PlotProbe';
return Probe;
});
import { BivariateBarChart } from '@/components/charts/BivariateBarChart';
import { editorialSeries } from '@/lib/charts/editorial-plot';
function plotProps() {
return JSON.parse(screen.getByTestId('plot').textContent ?? '{}');
}
describe('BivariateBarChart', () => {
it('renders one UK trace and one PL trace with editorial colours', () => {
render(<BivariateBarChart categories={['a', 'b']} ukData={[1, 2]} plData={[3, 4]} />);
const { data } = plotProps();
expect(data).toHaveLength(2);
expect(data[0].name).toBe('UK');
expect(data[0].marker.color).toBe(editorialSeries.uk);
expect(data[1].name).toBe('Poland');
expect(data[1].marker.color).toBe(editorialSeries.pl);
});
it('applies yTickSuffix and hoverTemplate for percentage charts', () => {
render(
<BivariateBarChart
categories={['a']} ukData={[50]} plData={[25]}
yTickSuffix="%" hoverTemplate="%{y:.1f}%<extra>%{fullData.name}</extra>"
/>,
);
const { data, layout } = plotProps();
expect(layout.yaxis.ticksuffix).toBe('%');
expect(data[0].hovertemplate).toContain('%{y:.1f}%');
});
it('is still importable from the dataset-comparison private folder', async () => {
const legacy = await import('@/app/dataset-comparison/_components/BivariateBarChart');
expect(legacy.BivariateBarChart).toBe(BivariateBarChart);
});
});
- Step 2: Run to verify failure
@/components/charts/BivariateBarChart.
- Step 3: Move and extend
mkdir -p frontend/components/charts
git mv frontend/app/dataset-comparison/_components/BivariateBarChart.tsx frontend/components/charts/BivariateBarChart.tsx
Edit frontend/components/charts/BivariateBarChart.tsx: export the props interface (export interface BivariateBarChartProps), add
/** Suffix for Y tick labels, e.g. "%" for share charts. */
yTickSuffix?: string;
/** Plotly hovertemplate applied to both traces. */
hoverTemplate?: string;
...(hoverTemplate ? { hovertemplate: hoverTemplate } : {}); in layout yaxis: { ...editorialPlotLayout.yaxis, title: { text: yAxisTitle }, ...(yTickSuffix ? { ticksuffix: yTickSuffix } : {}) } (keep the existing merge pattern used in the file — read it first; only add the two spreads).
Create frontend/app/dataset-comparison/_components/BivariateBarChart.tsx:
// Moved to the shared charts folder; kept so the static page's imports stay valid.
export { BivariateBarChart as default, BivariateBarChart } from '@/components/charts/BivariateBarChart';
export type { BivariateBarChartProps } from '@/components/charts/BivariateBarChart';
- Step 4: Run tests + typecheck
cd frontend && npx jest tests/unit/components/charts/bivariate-bar-chart.test.tsx && npm run typecheck
app/dataset-comparison/page.tsx still compiles.
- Step 5: Commit
git add frontend/components/charts/BivariateBarChart.tsx frontend/app/dataset-comparison/_components/BivariateBarChart.tsx frontend/tests/unit/components/charts/bivariate-bar-chart.test.tsx
git commit -m "refactor(charts): share BivariateBarChart; add yTickSuffix/hoverTemplate
Refs #<C>"
Task 13: lib/compare — types, API hooks, chart-data transform, permalink¶
Files:
- Create: frontend/lib/compare/types.ts, frontend/lib/compare/api.ts, frontend/lib/compare/chart-data.ts, frontend/lib/compare/permalink.ts
- Test: frontend/tests/unit/lib/compare/chart-data.test.ts, frontend/tests/unit/lib/compare/permalink.test.ts
Interfaces:
- types.ts mirrors Task 4 models:
export type Jurisdiction = 'PL' | 'UK';
export type Tier = 'primary' | 'partial' | 'unavailable' | 'empty';
export interface CoverageStat { covered: number; total: number; ratio: number | null }
export interface CompareValue { value: string; counts: Record<string, number>; shares: Record<string, number | null> }
export interface CompareField { field: string; label: string; source: string; kind: 'enum'|'enum_array'|'boolean'; coverage: Record<string, CoverageStat>; tier: Tier; missing_in: string[]; values: CompareValue[] }
export interface PairSummary { id: string; name: string; pl_collection_id: string; uk_collection_id: string }
export interface CompareResponse { jurisdictions: string[]; totals: Record<string, number>; fields: CompareField[]; ignored_filter_keys: string[]; filters: BaseSchemaFilters; text_query: string | null; pair: PairSummary | null }
export interface CompareRequest { filters: BaseSchemaFilters; text_query?: string | null; fields?: string[] }
export interface CollectionPair { id: string; user_id: string; name: string; filters: BaseSchemaFilters; text_query: string | null; created_at: string; sides: { jurisdiction: Jurisdiction; collection_id: string; document_count: number }[] }
api.ts: fetchCompare(req, signal?), useCompare(req, enabled) (queryKey: ['compare', req], staleTime: 60_000), fetchComparePair(pairId, signal?), useComparePair(pairId), createCollectionPair({name, filters, text_query}) → CollectionPair, downloadCompareCsv(req) (POST /api/compare/export, blob → anchor click with the Content-Disposition filename).
- chart-data.ts: toShareChart(field, labelOf) → { categories: string[]; plData: number[]; ukData: number[] } (percent 0–100, one decimal) and formatCoverage(stat) → 'PL 76 % (153/200)'-style pieces: coveragePercent(stat) → number | null.
- permalink.ts: buildComparePermalink(filters, textQuery, origin?) → string using encodeFilters from @/lib/extractions/use-extracted-data-filters; buildPairPermalink(pairId, origin?).
- Step 1: Write the failing tests
frontend/tests/unit/lib/compare/chart-data.test.ts:
import { coveragePercent, toShareChart } from '@/lib/compare/chart-data';
import type { CompareField } from '@/lib/compare/types';
const field: CompareField = {
field: 'appeal_outcome', label: 'Appeal Outcome', source: 'base', kind: 'enum_array',
coverage: { PL: { covered: 153, total: 200, ratio: 0.765 }, UK: { covered: 198, total: 200, ratio: 0.99 } },
tier: 'partial', missing_in: [],
values: [
{ value: 'outcome_dismissed_or_refused', counts: { PL: 100, UK: 99 }, shares: { PL: 0.6536, UK: 0.5 } },
{ value: 'outcome_other', counts: { PL: 0, UK: 5 }, shares: { PL: 0, UK: 0.0253 } },
],
};
describe('toShareChart', () => {
it('turns shares into percentages with human labels, keeping backend order', () => {
const chart = toShareChart(field, (f, v) => `${f}:${v}`);
expect(chart.categories).toEqual(['appeal_outcome:outcome_dismissed_or_refused', 'appeal_outcome:outcome_other']);
expect(chart.plData).toEqual([65.4, 0]);
expect(chart.ukData).toEqual([50, 2.5]);
});
it('maps a null share to 0 so Plotly draws no bar rather than NaN', () => {
const chart = toShareChart({ ...field, values: [{ value: 'x', counts: { PL: 0, UK: 1 }, shares: { PL: null, UK: 1 } }] }, (_f, v) => v);
expect(chart.plData).toEqual([0]);
});
});
describe('coveragePercent', () => {
it('rounds to a whole percent and passes null through', () => {
expect(coveragePercent({ covered: 153, total: 200, ratio: 0.765 })).toBe(77);
expect(coveragePercent({ covered: 0, total: 0, ratio: null })).toBeNull();
});
});
frontend/tests/unit/lib/compare/permalink.test.ts:
import { buildComparePermalink, buildPairPermalink } from '@/lib/compare/permalink';
import { decodeFilters } from '@/lib/extractions/use-extracted-data-filters';
describe('compare permalinks', () => {
it('encodes filters with the same blob /search/extractions uses', () => {
const url = new URL(buildComparePermalink({ appellant: ['offender'] }, 'fraud', 'https://juddges.com'));
expect(url.pathname).toBe('/compare');
expect(url.searchParams.get('q')).toBe('fraud');
expect(decodeFilters(url.searchParams.get('f'))).toEqual({ appellant: ['offender'] });
});
it('omits empty params', () => {
expect(buildComparePermalink({}, '', 'https://juddges.com')).toBe('https://juddges.com/compare');
});
it('builds the pair permalink', () => {
expect(buildPairPermalink('p1', 'https://juddges.com')).toBe('https://juddges.com/compare/p1');
});
});
- Step 2: Run to verify failure
- Step 3: Implement
frontend/lib/compare/types.ts: the interfaces listed above (import BaseSchemaFilters from @/types/base-schema-filter).
frontend/lib/compare/chart-data.ts:
import type { CompareField, CoverageStat } from './types';
const pct = (share: number | null | undefined) => (share == null ? 0 : Math.round(share * 1000) / 10);
/** Grouped-bar input: one category per value, share as percent (0–100, one decimal). */
export function toShareChart(
field: CompareField,
labelOf: (field: string, value: string) => string,
): { categories: string[]; plData: number[]; ukData: number[] } {
return {
categories: field.values.map((v) => labelOf(field.field, v.value)),
plData: field.values.map((v) => pct(v.shares.PL)),
ukData: field.values.map((v) => pct(v.shares.UK)),
};
}
export function coveragePercent(stat: CoverageStat): number | null {
return stat.ratio == null ? null : Math.round(stat.ratio * 100);
}
frontend/lib/compare/permalink.ts:
import { encodeFilters } from '@/lib/extractions/use-extracted-data-filters';
import type { BaseSchemaFilters } from '@/types/base-schema-filter';
function base(origin?: string): string {
return origin ?? (typeof window === 'undefined' ? '' : window.location.origin);
}
/** `/compare?f=<blob>&q=<text>` — same params as /search/extractions, so links are portable. */
export function buildComparePermalink(filters: BaseSchemaFilters, textQuery: string, origin?: string): string {
const params = new URLSearchParams();
const blob = encodeFilters(filters);
if (blob) params.set('f', blob);
if (textQuery.trim()) params.set('q', textQuery.trim());
const qs = params.toString();
return `${base(origin)}/compare${qs ? `?${qs}` : ''}`;
}
export function buildPairPermalink(pairId: string, origin?: string): string {
return `${base(origin)}/compare/${encodeURIComponent(pairId)}`;
}
frontend/lib/compare/api.ts (pattern: frontend/lib/extractions/base-schema-filter-api.ts:60-80):
'use client';
import { useQuery } from '@tanstack/react-query';
import type { CollectionPair, CompareRequest, CompareResponse } from './types';
async function postJson<T>(url: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? `HTTP ${res.status}`);
return res.json() as Promise<T>;
}
export function fetchCompare(req: CompareRequest, signal?: AbortSignal) {
return postJson<CompareResponse>('/api/compare/facets', req, signal);
}
export function useCompare(req: CompareRequest, enabled: boolean) {
return useQuery({
queryKey: ['compare', req],
queryFn: ({ signal }) => fetchCompare(req, signal),
enabled,
staleTime: 60_000,
placeholderData: (prev) => prev,
});
}
export async function fetchComparePair(pairId: string, signal?: AbortSignal): Promise<CompareResponse> {
const res = await fetch(`/api/compare/pairs/${encodeURIComponent(pairId)}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useComparePair(pairId: string) {
return useQuery({ queryKey: ['compare-pair', pairId], queryFn: ({ signal }) => fetchComparePair(pairId, signal), staleTime: 60_000 });
}
export function createCollectionPair(body: { name: string; filters: CompareRequest['filters']; text_query: string | null }) {
return postJson<CollectionPair>('/api/collections/pairs', body);
}
export async function downloadCompareCsv(req: CompareRequest): Promise<void> {
const res = await fetch('/api/compare/export', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const name = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'compare.csv';
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement('a'), { href: url, download: name });
a.click();
URL.revokeObjectURL(url);
}
- Step 4: Run tests + typecheck
- Step 5: Commit
git add frontend/lib/compare frontend/tests/unit/lib/compare
git commit -m "feat(compare): client types, hooks, share transform, permalink
Refs #<C>"
Task 14: BFF proxy routes¶
Files:
- Create: frontend/app/api/compare/facets/route.ts (POST), frontend/app/api/compare/export/route.ts (POST, streams bytes), frontend/app/api/compare/pairs/[pairId]/route.ts (GET), frontend/app/api/collections/pairs/route.ts (GET, POST), frontend/app/api/collections/pairs/[pairId]/route.ts (GET, DELETE)
- Test: frontend/tests/unit/api/compare-bff.test.ts
Interfaces:
- Consumes: createClient from @/lib/supabase/server, getBackendUrl from @/app/api/utils/backend-url, UnauthorizedError/AppError/ErrorCode from @/lib/errors, logger from @/lib/logger — exactly as frontend/app/api/extractions/base-schema/facets/[field]/route.ts.
- Produces: same JSON passthrough; export route forwards content-type, content-disposition, x-rows-count headers and the raw body.
- Step 1: Write the failing tests
frontend/tests/unit/api/compare-bff.test.ts (pattern: tests/unit/api/ai-bff-contracts.test.ts):
/** @jest-environment node */
import { NextRequest } from 'next/server';
const mockGetUser = jest.fn();
const mockGetSession = jest.fn();
jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({ auth: { getUser: mockGetUser, getSession: mockGetSession } })),
}));
jest.mock('@/lib/logger', () => ({ __esModule: true, default: { child: () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn() }) } }));
const fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;
function signedIn() {
mockGetUser.mockResolvedValue({ data: { user: { id: 'u1' } }, error: null });
mockGetSession.mockResolvedValue({ data: { session: { access_token: 'jwt' } } });
}
beforeEach(() => {
jest.clearAllMocks();
process.env.API_BASE_URL = 'http://backend.test';
process.env.BACKEND_API_KEY = 'k';
});
describe('POST /api/compare/facets', () => {
it('rejects anonymous callers before touching the backend', async () => {
mockGetUser.mockResolvedValue({ data: { user: null }, error: null });
const { POST } = await import('@/app/api/compare/facets/route');
const res = await POST(new NextRequest('http://x/api/compare/facets', { method: 'POST', body: '{}' }));
expect(res.status).toBe(401);
expect(fetchMock).not.toHaveBeenCalled();
});
it('forwards the body with API key and bearer', async () => {
signedIn();
fetchMock.mockResolvedValue(new Response(JSON.stringify({ totals: { PL: 1, UK: 2 } }), { status: 200 }));
const { POST } = await import('@/app/api/compare/facets/route');
const res = await POST(new NextRequest('http://x/api/compare/facets', {
method: 'POST', body: JSON.stringify({ filters: { appellant: ['offender'] }, fields: ['appellant'] }),
}));
expect(res.status).toBe(200);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://backend.test/compare/facets');
expect(init.headers['X-API-Key']).toBe('k');
expect(init.headers.Authorization).toBe('Bearer jwt');
expect(JSON.parse(init.body)).toEqual({ filters: { appellant: ['offender'] }, text_query: null, fields: ['appellant'] });
});
});
describe('POST /api/compare/export', () => {
it('streams the CSV with its headers', async () => {
signedIn();
fetchMock.mockResolvedValue(new Response('field,value\n', {
status: 200,
headers: { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="compare_2026-09-21.csv"', 'x-rows-count': '1' },
}));
const { POST } = await import('@/app/api/compare/export/route');
const res = await POST(new NextRequest('http://x/api/compare/export', { method: 'POST', body: '{"filters":{}}' }));
expect(res.status).toBe(200);
expect(res.headers.get('content-disposition')).toContain('compare_2026-09-21.csv');
expect(res.headers.get('x-rows-count')).toBe('1');
expect(await res.text()).toContain('field,value');
});
});
describe('/api/collections/pairs', () => {
it('POST forwards to the backend pairs route', async () => {
signedIn();
fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'p1' }), { status: 201 }));
const { POST } = await import('@/app/api/collections/pairs/route');
const res = await POST(new NextRequest('http://x/api/collections/pairs', {
method: 'POST', body: JSON.stringify({ name: 'n', filters: {}, text_query: null }),
}));
expect(res.status).toBe(201);
expect(fetchMock.mock.calls[0][0]).toBe('http://backend.test/collections/pairs');
});
it('GET /api/compare/pairs/[pairId] passes the upstream status through', async () => {
signedIn();
fetchMock.mockResolvedValue(new Response(JSON.stringify({ detail: 'Collection pair not found' }), { status: 404 }));
const { GET } = await import('@/app/api/compare/pairs/[pairId]/route');
const res = await GET(new NextRequest('http://x/api/compare/pairs/p9'), { params: Promise.resolve({ pairId: 'p9' }) });
expect(res.status).toBe(404);
expect(fetchMock.mock.calls[0][0]).toBe('http://backend.test/compare/pairs/p9');
});
});
- Step 2: Run to verify failure
- Step 3: Implement the routes
Each file follows frontend/app/api/extractions/base-schema/facets/[field]/route.ts verbatim in structure (requestId, createClient(), getUser, getSession, UnauthorizedError, AppError catch). Differences only:
frontend/app/api/compare/facets/route.ts — POST(request: NextRequest); body parse:
const { filters = {}, text_query = null, fields } = await request.json();
const response = await fetch(`${API_BASE_URL}/compare/facets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY, Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ filters, text_query, ...(fields ? { fields } : {}) }),
});
'compare-facets-api'; error fallback code "COMPARE_ERROR".
frontend/app/api/compare/export/route.ts — same auth; forward body unchanged; on success return the stream:
const headers = new Headers();
for (const h of ['content-type', 'content-disposition', 'x-rows-count']) {
const v = response.headers.get(h);
if (v) headers.set(h, v);
}
return new NextResponse(response.body, { status: 200, headers });
frontend/app/api/compare/pairs/[pairId]/route.ts — GET(request, { params }: { params: Promise<{ pairId: string }> }) → ${API_BASE_URL}/compare/pairs/${encodeURIComponent(pairId)}; on !response.ok return the upstream JSON with the upstream status (as the facets route does).
frontend/app/api/collections/pairs/route.ts — GET → ${API_BASE_URL}/collections/pairs; POST → same URL, JSON body passthrough, return upstream status (201).
frontend/app/api/collections/pairs/[pairId]/route.ts — GET and DELETE (204 → new NextResponse(null, { status: 204 })).
- Step 4: Run tests + lint
- Step 5: Commit
git add frontend/app/api/compare frontend/app/api/collections/pairs frontend/tests/unit/api/compare-bff.test.ts
git commit -m "feat(bff): proxy routes for /compare and /collections/pairs
Refs #<C>"
Task 15: i18n namespace compare + navigation key¶
Files:
- Modify: frontend/lib/i18n/types.ts:108-183 (NavigationTranslations: add compare: string;), :670-683 (Translations: add compare: CompareTranslations;), :689-701 (TranslationKey union), new export interface CompareTranslations
- Modify: frontend/lib/i18n/translations/en.ts, frontend/lib/i18n/translations/pl.ts
- Test: frontend/tests/unit/lib/i18n/compare-namespace.test.ts
Interfaces:
- Produces keys (all string): compare.pageTitle, pageSubtitle, emptyTitle, emptyBody, askQuestion, matching, matchingPl, matchingUk, shareAxis, coverageBadge ('coverage PL {{pl}} % / UK {{uk}} %'), sectionPrimary, sectionPartial, sectionUnavailable, unavailableOne ('{{field}} is not filled for {{jurisdiction}} documents in this result ({{covered}} of {{total}}).'), unavailableBoth, noMatchesTitle, noMatchesBody, jurisdictionIgnored, savePair, savePairTitle, savePairName, savePairHint ('Creates "{{name}} — PL" and "{{name}} — UK".'), savePairTooLarge, saved, openCollections, exportCsv, copyLink, linkCopied, pairEyebrow, pairSchemaSection, pairNoSchema, extractOnBoth, dataNote; navigation.compare.
- Step 1: Write the failing test
frontend/tests/unit/lib/i18n/compare-namespace.test.ts:
import { en } from '@/lib/i18n/translations/en';
import { pl } from '@/lib/i18n/translations/pl';
describe('compare i18n namespace', () => {
it('exists in both locales with identical keys', () => {
expect(Object.keys(en.compare).sort()).toEqual(Object.keys(pl.compare).sort());
expect(Object.keys(en.compare).length).toBeGreaterThanOrEqual(30);
});
it('keeps interpolation placeholders in sync', () => {
for (const key of Object.keys(en.compare) as Array<keyof typeof en.compare>) {
const holes = (s: string) => (s.match(/\{\{\w+\}\}/g) ?? []).sort();
expect(holes(pl.compare[key])).toEqual(holes(en.compare[key]));
}
});
it('has the sidebar label', () => {
expect(en.navigation.compare).toBe('Compare PL / UK');
expect(pl.navigation.compare).toBe('Porównaj PL / UK');
});
});
- Step 2: Run to verify failure
en.compare undefined.
- Step 3: Add the namespace
types.ts: export interface CompareTranslations { pageTitle: string; pageSubtitle: string; … dataNote: string; } (all keys from Interfaces), compare: CompareTranslations; in Translations, | `compare.${keyof CompareTranslations}` in TranslationKey, compare: string; in NavigationTranslations (under "Analysis section").
en.ts (compare: block; navigation.compare: 'Compare PL / UK'):
compare: {
pageTitle: 'Compare PL / UK',
pageSubtitle: 'One research question, the same structured filter run for Polish and UK judgments, coded fields side by side.',
emptyTitle: 'Ask a research question',
emptyBody: 'Describe the cases in plain language or pick filters. Jurisdiction is always split here — leave it out.',
askQuestion: 'Describe the cases',
matching: 'Matching judgments',
matchingPl: 'Poland',
matchingUk: 'United Kingdom',
shareAxis: '% of coded documents',
coverageBadge: 'coverage PL {{pl}} % / UK {{uk}} %',
sectionPrimary: 'Comparable fields',
sectionPartial: 'Partial coverage — read with care',
sectionUnavailable: 'Not available for one jurisdiction',
unavailableOne: '{{field}} is not filled for {{jurisdiction}} documents in this result ({{covered}} of {{total}}).',
unavailableBoth: '{{field}} is not filled for any matching document.',
noMatchesTitle: 'Nothing matched',
noMatchesBody: 'No completed judgment matches this filter in either jurisdiction. Loosen a condition.',
jurisdictionIgnored: 'The jurisdiction condition was ignored — this page always shows PL and UK.',
savePair: 'Save as collection pair',
savePairTitle: 'Save both result sets',
savePairName: 'Name',
savePairHint: 'Creates "{{name}} — PL" and "{{name}} — UK".',
savePairTooLarge: 'One side exceeds 5 000 judgments. Narrow the filter first.',
saved: 'Pair saved',
openCollections: 'Open collections',
exportCsv: 'Export CSV',
copyLink: 'Copy link',
linkCopied: 'Link copied',
pairEyebrow: 'Saved pair',
pairSchemaSection: 'Extended schema fields',
pairNoSchema: 'Run the same extraction schema on both collections to compare its fields here.',
extractOnBoth: 'Extract on {{jurisdiction}} collection',
dataNote: 'Shares are computed over documents where the field is coded. Coverage differs between jurisdictions; free-text topics are Polish-only and are not compared.',
},
pl.ts — the same keys in Polish (navigation.compare: 'Porównaj PL / UK'), e.g. coverageBadge: 'pokrycie PL {{pl}} % / UK {{uk}} %', unavailableOne: 'Pole {{field}} nie jest wypełnione dla orzeczeń {{jurisdiction}} w tym wyniku ({{covered}} z {{total}}).', savePair: 'Zapisz jako parę kolekcji', savePairHint: 'Utworzy „{{name}} — PL” i „{{name}} — UK”.', exportCsv: 'Eksportuj CSV', copyLink: 'Kopiuj link', dataNote: 'Udziały liczone są wśród dokumentów, w których pole zostało zakodowane. Pokrycie różni się między jurysdykcjami; tematy tekstowe są tylko polskie i nie są porównywane.'.
- Step 4: Run tests + typecheck
- Step 5: Commit
git add frontend/lib/i18n frontend/tests/unit/lib/i18n/compare-namespace.test.ts
git commit -m "feat(i18n): compare namespace (en, pl)
Refs #<C>"
Task 16: /compare page — filter bar, totals, figures, coverage tiers, export¶
Files:
- Create: frontend/lib/extractions/drawer-adapter.ts (extracted from frontend/app/search/extractions/page.tsx:35-120)
- Modify: frontend/app/search/extractions/page.tsx:35-120 → import the two functions
- Create: frontend/app/compare/page.tsx, frontend/app/compare/_components/CompareContent.tsx, CompareFilterBar.tsx, CompareTotals.tsx, FieldComparisonFigure.tsx, CoverageBadge.tsx, UnavailableFields.tsx, ExportMenu.tsx
- Test: frontend/tests/unit/lib/extractions/drawer-adapter.test.ts, frontend/tests/unit/app/compare/compare-content.test.tsx
Interfaces:
- drawer-adapter.ts: export function toDrawerFilters(filters: BaseSchemaFilters): BaseFilters and export function applyDrawerChange(filters: BaseSchemaFilters, field: string, value: BaseFilterValue | undefined): BaseSchemaFilters — bodies moved verbatim from the search page.
- CompareContent props: none (reads useExtractedDataFilters()); renders CompareFilterBar, then CompareView.
- CompareView (inside CompareContent.tsx, exported for tests): { data: CompareResponse; onSavePair?: () => void; request: CompareRequest }.
- CompareFilterBar: { filters: BaseSchemaFilters; textQuery: string; onApply(filters, textQuery); onRemove(field); onClearText(); onClearAll(); onDrawerChange(field, value) }.
- CompareTotals: { totals: Record<string, number> } → DualStatCard label={t('compare.matching')} ukValue plValue leftLabel="UK" rightLabel="PL".
- FieldComparisonFigure: { field: CompareField; index: number } → ChartFigure figure={String(index).padStart(2,'0')} eyebrow={field.source === 'base' ? 'Base schema' : 'Extended schema'} title={field.label} caption={<CoverageBadge …/> when tier==='partial'} source={…} wrapping BivariateBarChart with toShareChart(field, formatEnumOptionLabel) (@/lib/extractions/base-schema-filter-config), yAxisTitle={t('compare.shareAxis')}, yTickSuffix="%", hoverTemplate="%{y:.1f}%<extra>%{fullData.name}</extra>", showCounts.
- CoverageBadge: { coverage: Record<string, CoverageStat> } → <Eyebrow tone="oxblood" noRule>{t('compare.coverageBadge', { pl, uk })}</Eyebrow> with coveragePercent.
- UnavailableFields: { fields: CompareField[] } → list of t('compare.unavailableOne'|'unavailableBoth', …) lines in a bg-parchment-deep border border-rule panel.
- ExportMenu: { request: CompareRequest; permalink: string } → two EditorialButtons: CSV (downloadCompareCsv), copy link (navigator.clipboard.writeText → toast t('compare.linkCopied')).
- Step 1: Failing test for the adapter extraction
frontend/tests/unit/lib/extractions/drawer-adapter.test.ts:
import { applyDrawerChange, toDrawerFilters } from '@/lib/extractions/drawer-adapter';
describe('drawer adapter', () => {
it('round-trips enum, boolean and numeric range filters', () => {
const filters = {
appellant: ['offender' as const],
did_offender_confess: true,
num_victims: { min: 1, max: 3 },
};
const drawer = toDrawerFilters(filters);
expect(drawer.appellant).toEqual({ kind: 'enum_multi', values: ['offender'] });
expect(drawer.did_offender_confess).toEqual({ kind: 'boolean_tri', value: true });
expect(drawer.num_victims?.kind).toBe('numeric_range');
expect(applyDrawerChange(filters, 'appellant', undefined)).toEqual({ did_offender_confess: true, num_victims: { min: 1, max: 3 } });
expect(applyDrawerChange({}, 'appellant', { kind: 'enum_multi', values: ['other'] })).toEqual({ appellant: ['other'] });
});
});
Run: cd frontend && npx jest tests/unit/lib/extractions/drawer-adapter.test.ts → FAIL (module missing).
- Step 2: Extract the adapter
Create frontend/lib/extractions/drawer-adapter.ts with the two functions cut from frontend/app/search/extractions/page.tsx:35-120 (keep their bodies; add the imports they need: BaseSchemaFilters from @/types/base-schema-filter, BaseFilters, BaseFilterValue from @/lib/store/searchStore, FILTER_FIELD_BY_NAME if referenced). In the search page replace the local definitions with import { applyDrawerChange, toDrawerFilters } from '@/lib/extractions/drawer-adapter';. Adjust the test's expectations to what the moved code actually returns for num_victims if the range shape differs (read the code; the test must describe real behaviour, not invent it).
Run: npx jest tests/unit/lib/extractions/drawer-adapter.test.ts tests/unit/lib/extractions && npm run typecheck → PASS.
- Step 3: Failing test for the compare view
frontend/tests/unit/app/compare/compare-content.test.tsx:
import { render, screen } from '@testing-library/react';
jest.mock('@/components/charts/BivariateBarChart', () => ({
BivariateBarChart: (props: { categories: string[]; plData: number[]; ukData: number[] }) => (
<div data-testid="bivariate" data-categories={props.categories.join('|')} data-pl={props.plData.join('|')} data-uk={props.ukData.join('|')} />
),
}));
jest.mock('@/contexts/LanguageContext', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) =>
values ? `${key} ${Object.entries(values).map(([k, v]) => `${k}=${v}`).join(' ')}` : key,
locale: 'en',
}),
}));
jest.mock('@/lib/compare/api', () => ({ downloadCompareCsv: jest.fn(), createCollectionPair: jest.fn() }));
import { CompareView } from '@/app/compare/_components/CompareContent';
import type { CompareResponse } from '@/lib/compare/types';
const data: CompareResponse = {
jurisdictions: ['PL', 'UK'],
totals: { PL: 812, UK: 430 },
ignored_filter_keys: ['jurisdiction'],
filters: { appellant: ['offender'] }, text_query: null, pair: null,
fields: [
{ field: 'appeal_outcome', label: 'Appeal Outcome', source: 'base', kind: 'enum_array',
coverage: { PL: { covered: 812, total: 812, ratio: 1 }, UK: { covered: 430, total: 430, ratio: 1 } },
tier: 'primary', missing_in: [],
values: [{ value: 'outcome_dismissed_or_refused', counts: { PL: 500, UK: 200 }, shares: { PL: 0.6158, UK: 0.4651 } }] },
{ field: 'sentence_serve', label: 'Sentence Type', source: 'base', kind: 'enum_array',
coverage: { PL: { covered: 600, total: 812, ratio: 0.7389 }, UK: { covered: 420, total: 430, ratio: 0.9767 } },
tier: 'partial', missing_in: [],
values: [{ value: 'serve_concurrent', counts: { PL: 300, UK: 200 }, shares: { PL: 0.5, UK: 0.4762 } }] },
{ field: 'plea_point', label: 'Plea Point', source: 'base', kind: 'enum',
coverage: { PL: { covered: 700, total: 812, ratio: 0.8621 }, UK: { covered: 0, total: 430, ratio: 0 } },
tier: 'unavailable', missing_in: ['UK'], values: [] },
],
};
describe('CompareView', () => {
beforeEach(() => render(<CompareView data={data} request={{ filters: data.filters, text_query: null }} />));
it('shows N_PL and N_UK', () => {
expect(screen.getByText('812')).toBeInTheDocument();
expect(screen.getByText('430')).toBeInTheDocument();
});
it('renders primary fields as share charts in percent', () => {
const charts = screen.getAllByTestId('bivariate');
expect(charts[0]).toHaveAttribute('data-pl', '61.6');
expect(charts[0]).toHaveAttribute('data-uk', '46.5');
});
it('badges partial-coverage fields and keeps them out of the first section', () => {
expect(screen.getByText('compare.coverageBadge pl=74 uk=98')).toBeInTheDocument();
const primary = screen.getByRole('region', { name: 'compare.sectionPrimary' });
const partial = screen.getByRole('region', { name: 'compare.sectionPartial' });
expect(primary).not.toHaveTextContent('Sentence Type');
expect(partial).toHaveTextContent('Sentence Type');
});
it('hides zero-coverage fields behind a message instead of drawing 0', () => {
expect(screen.getAllByTestId('bivariate')).toHaveLength(2);
expect(screen.getByText('compare.unavailableOne field=Plea Point jurisdiction=UK covered=0 total=430')).toBeInTheDocument();
});
it('tells the user the jurisdiction condition was ignored', () => {
expect(screen.getByText('compare.jurisdictionIgnored')).toBeInTheDocument();
});
});
Run: npx jest tests/unit/app/compare → FAIL (module missing).
- Step 4: Build the page and components
frontend/app/compare/page.tsx:
import { Suspense } from 'react';
import { CompareContent } from './_components/CompareContent';
export const metadata = { title: 'Compare PL / UK' };
export default function ComparePage() {
return (
<Suspense fallback={<div className="p-8 text-sm text-ink-soft">…</div>}>
<CompareContent />
</Suspense>
);
}
frontend/app/compare/_components/CompareContent.tsx (client):
'use client';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { ChartFigure, DualStatCard, Eyebrow, Headline, Masthead, PaperBackground, Rule } from '@/components/editorial';
import { useTranslation } from '@/contexts/LanguageContext';
import { useCompare } from '@/lib/compare/api';
import { buildComparePermalink } from '@/lib/compare/permalink';
import type { CompareField, CompareRequest, CompareResponse } from '@/lib/compare/types';
import { applyDrawerChange } from '@/lib/extractions/drawer-adapter';
import { countActive, useExtractedDataFilters } from '@/lib/extractions/use-extracted-data-filters';
import { CompareFilterBar } from './CompareFilterBar';
import { CompareTotals } from './CompareTotals';
import { ExportMenu } from './ExportMenu';
import { FieldComparisonFigure } from './FieldComparisonFigure';
import { SavePairDialog } from './SavePairDialog';
import { UnavailableFields } from './UnavailableFields';
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<section aria-labelledby={id} role="region" className="space-y-6">
<Eyebrow id={id} as="p">{title}</Eyebrow>
{children}
</section>
);
}
export function CompareView({ data, request, onSavePair }: { data: CompareResponse; request: CompareRequest; onSavePair?: () => void }) {
const { t } = useTranslation();
const byTier = (tier: CompareField['tier']) => data.fields.filter((f) => f.tier === tier);
const primary = byTier('primary');
const partial = byTier('partial');
const unavailable = [...byTier('unavailable'), ...byTier('empty')];
const nothing = data.totals.PL === 0 && data.totals.UK === 0;
let figure = 0;
return (
<div className="space-y-12">
{data.ignored_filter_keys.includes('jurisdiction') && (
<p className="border border-rule bg-parchment-deep px-3 py-2 text-xs text-ink">{t('compare.jurisdictionIgnored')}</p>
)}
<CompareTotals totals={data.totals} />
<div className="flex flex-wrap gap-3">
<ExportMenu request={request} permalink={buildComparePermalink(request.filters, request.text_query ?? '')} />
{onSavePair && !nothing && (
<button type="button" onClick={onSavePair} className="border border-ink px-3 py-1.5 font-mono text-xs uppercase tracking-wider text-ink hover:bg-ink hover:text-parchment">
{t('compare.savePair')}
</button>
)}
</div>
{nothing ? (
<div className="border border-rule p-6">
<Headline as="h2" size="sm">{t('compare.noMatchesTitle')}</Headline>
<p className="mt-2 text-sm text-ink-soft">{t('compare.noMatchesBody')}</p>
</div>
) : (
<>
{primary.length > 0 && (
<Section id="compare-primary" title={t('compare.sectionPrimary')}>
<div className="grid gap-8 lg:grid-cols-2">
{primary.map((f) => <FieldComparisonFigure key={f.field} field={f} index={++figure} />)}
</div>
</Section>
)}
{partial.length > 0 && (
<Section id="compare-partial" title={t('compare.sectionPartial')}>
<div className="grid gap-8 lg:grid-cols-2">
{partial.map((f) => <FieldComparisonFigure key={f.field} field={f} index={++figure} />)}
</div>
</Section>
)}
{unavailable.length > 0 && (
<Section id="compare-unavailable" title={t('compare.sectionUnavailable')}>
<UnavailableFields fields={unavailable} />
</Section>
)}
</>
)}
<Rule spaced />
<p className="text-xs text-ink-soft">{t('compare.dataNote')}</p>
</div>
);
}
export function CompareContent() {
const { t } = useTranslation();
const { filters, textQuery, setFilters, setTextQuery, removeFilter, clearAll } = useExtractedDataFilters();
const [saveOpen, setSaveOpen] = useState(false);
const active = countActive(filters) > 0 || textQuery.trim() !== '';
const request = useMemo<CompareRequest>(() => ({ filters, text_query: textQuery.trim() || null }), [filters, textQuery]);
const query = useCompare(request, active);
return (
<PaperBackground grain>
<div className="mx-auto max-w-6xl space-y-10 px-4 py-10 sm:px-6">
<Masthead badge={<Eyebrow noRule>PL · UK</Eyebrow>}>
<Headline as="h1" size="lg">{t('compare.pageTitle')}</Headline>
<p className="mt-2 max-w-2xl text-sm text-ink-soft">{t('compare.pageSubtitle')}</p>
</Masthead>
<CompareFilterBar
filters={filters} textQuery={textQuery}
onApply={(f, q) => { setFilters(f); setTextQuery(q); }}
onRemove={removeFilter} onClearText={() => setTextQuery('')} onClearAll={clearAll}
onDrawerChange={(field, value) => setFilters(applyDrawerChange(filters, field, value))}
/>
{!active && (
<div className="border border-rule p-8 text-center">
<Headline as="h2" size="sm">{t('compare.emptyTitle')}</Headline>
<p className="mx-auto mt-2 max-w-md text-sm text-ink-soft">{t('compare.emptyBody')}</p>
</div>
)}
{query.isError && <p role="alert" className="text-sm text-oxblood">{String(query.error)}</p>}
{query.data && <CompareView data={query.data} request={request} onSavePair={() => setSaveOpen(true)} />}
<SavePairDialog open={saveOpen} onOpenChange={setSaveOpen} request={request}
onSaved={(pair) => { toast.success(t('compare.saved')); setSaveOpen(false); window.location.assign(`/compare/${pair.id}`); }} />
</div>
</PaperBackground>
);
}
SavePairDialog is Task 17; for this task create a stub file exporting a component that renders null, replaced in Task 17.)
CompareFilterBar.tsx: composes NlFilterDialog (@/components/search/NlFilterDialog, onApply), ActiveFilterChips (@/components/filters/extracted-search-filters, props filters, textQuery, onRemove, onClearText, onClearAll), and a "Filters" toggle that mounts BaseFiltersDrawer (@/components/search/BaseFiltersDrawer) with filters={toDrawerFilters(filters)} onChange={onDrawerChange} onReset={onClearAll} hideToggle defaultOpen. Hide the jurisdiction control in the drawer if B added one (pass filters without the key).
CompareTotals.tsx:
<DualStatCard label={t('compare.matching')} ukValue={totals.UK ?? 0} plValue={totals.PL ?? 0} leftLabel={t('compare.matchingUk')} rightLabel={t('compare.matchingPl')} />
FieldComparisonFigure.tsx, CoverageBadge.tsx, UnavailableFields.tsx, ExportMenu.tsx: as specified under Interfaces. UnavailableFields emits, per field, missing_in.length === 2 ? t('compare.unavailableBoth', { field: label }) : t('compare.unavailableOne', { field: label, jurisdiction: missing_in[0], covered: coverage[missing_in[0]].covered, total: coverage[missing_in[0]].total }).
- Step 5: Run tests, lint, typecheck
cd frontend && npx jest tests/unit/app/compare tests/unit/lib && npm run validate && npm run typecheck
npm run validate runs lint:banned-classes — no banned classes may appear in the new files.
- Step 6: Commit
git add frontend/app/compare frontend/lib/extractions/drawer-adapter.ts frontend/app/search/extractions/page.tsx frontend/tests/unit/app/compare frontend/tests/unit/lib/extractions/drawer-adapter.test.ts
git commit -m "feat(compare): /compare page with side-by-side share charts and coverage tiers
Refs #<C>"
Task 17: Save as collection pair (dialog)¶
Files:
- Modify: frontend/app/compare/_components/SavePairDialog.tsx (replace stub)
- Test: frontend/tests/unit/app/compare/save-pair-dialog.test.tsx
Interfaces:
- SavePairDialog props: { open: boolean; onOpenChange(open: boolean): void; request: CompareRequest; onSaved(pair: CollectionPair): void }. Default name = request.text_query or a joined chip summary (Object.keys(filters).join(', ')), editable. Calls createCollectionPair({ name, filters, text_query }); 413 → t('compare.savePairTooLarge') inline error.
- Step 1: Failing test
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
jest.mock('@/contexts/LanguageContext', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en' }) }));
const createCollectionPair = jest.fn();
jest.mock('@/lib/compare/api', () => ({ createCollectionPair: (...a: unknown[]) => createCollectionPair(...a) }));
import { SavePairDialog } from '@/app/compare/_components/SavePairDialog';
describe('SavePairDialog', () => {
it('pre-fills the name from the question and posts filters + text', async () => {
createCollectionPair.mockResolvedValue({ id: 'p1', sides: [] });
const onSaved = jest.fn();
render(<SavePairDialog open onOpenChange={() => {}} onSaved={onSaved}
request={{ filters: { appellant: ['offender'] }, text_query: 'fraud suspended' }} />);
const input = screen.getByLabelText('compare.savePairName') as HTMLInputElement;
expect(input.value).toBe('fraud suspended');
fireEvent.change(input, { target: { value: 'Fraud' } });
fireEvent.click(screen.getByRole('button', { name: 'compare.savePair' }));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({ id: 'p1', sides: [] }));
expect(createCollectionPair).toHaveBeenCalledWith({ name: 'Fraud', filters: { appellant: ['offender'] }, text_query: 'fraud suspended' });
});
it('shows the too-large message on 413', async () => {
createCollectionPair.mockRejectedValue(Object.assign(new Error('too large'), { status: 413 }));
render(<SavePairDialog open onOpenChange={() => {}} onSaved={() => {}} request={{ filters: {}, text_query: 'x' }} />);
fireEvent.click(screen.getByRole('button', { name: 'compare.savePair' }));
expect(await screen.findByText('compare.savePairTooLarge')).toBeInTheDocument();
});
});
npx jest tests/unit/app/compare/save-pair-dialog.test.tsx → FAIL (stub renders null).
- Step 2: Implement
Use Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription from @/components/ui/dialog, Input + Label from @/components/ui/* (as frontend/app/collections/page.tsx:8,14 does). Body: <Label htmlFor="pair-name">{t('compare.savePairName')}</Label><Input id="pair-name" …/>, hint t('compare.savePairHint', { name }), submit button text t('compare.savePair'). In api.ts make postJson attach status to the thrown error (Object.assign(new Error(msg), { status: res.status })) so the dialog can branch on 413.
Run: npx jest tests/unit/app/compare && npm run typecheck → PASS.
- Step 3: Commit
git add frontend/app/compare/_components/SavePairDialog.tsx frontend/lib/compare/api.ts frontend/tests/unit/app/compare/save-pair-dialog.test.tsx
git commit -m "feat(compare): save result as PL/UK collection pair
Refs #<C>"
Task 18: /compare/[pairId] page¶
Files:
- Create: frontend/app/compare/[pairId]/page.tsx, frontend/app/compare/_components/PairContent.tsx
- Test: frontend/tests/unit/app/compare/pair-content.test.tsx
Interfaces:
- PairContent props { pairId: string }; uses useComparePair(pairId); renders Masthead with Eyebrow t('compare.pairEyebrow') + pair name, links to both collections (/collections/<id>), CompareView (Task 16) with request = { filters: data.filters, text_query: null } — the backend returns filters = {"collection_ids": [pl, uk]} for a pair and list_extracted_filter_matches accepts that key, so the CSV export and permalink of a pair view run over the same membership. Below the base fields, section t('compare.pairSchemaSection'): if no field has source.startsWith('schema:'), show t('compare.pairNoSchema') plus two links t('compare.extractOnBoth', { jurisdiction }) → /extract?collection=<id> (param name verified in frontend/app/extract/_components/useExtract.ts:74-105).
- Step 1: Failing test
import { render, screen } from '@testing-library/react';
jest.mock('@/contexts/LanguageContext', () => ({ useTranslation: () => ({ t: (k: string, v?: Record<string, unknown>) => (v ? `${k} ${JSON.stringify(v)}` : k), locale: 'en' }) }));
jest.mock('@/components/charts/BivariateBarChart', () => ({ BivariateBarChart: () => <div data-testid="bivariate" /> }));
const useComparePair = jest.fn();
jest.mock('@/lib/compare/api', () => ({ useComparePair: (id: string) => useComparePair(id), downloadCompareCsv: jest.fn() }));
import { PairContent } from '@/app/compare/_components/PairContent';
const base = { jurisdictions: ['PL', 'UK'], totals: { PL: 3, UK: 2 }, ignored_filter_keys: [], text_query: null,
filters: { collection_ids: ['c-pl', 'c-uk'] }, pair: { id: 'p1', name: 'Fraud', pl_collection_id: 'c-pl', uk_collection_id: 'c-uk' } };
describe('PairContent', () => {
it('links both collections and invites extraction when no schema fields exist', () => {
useComparePair.mockReturnValue({ data: { ...base, fields: [] }, isLoading: false, isError: false });
render(<PairContent pairId="p1" />);
expect(screen.getByRole('link', { name: /Fraud — PL/ })).toHaveAttribute('href', '/collections/c-pl');
expect(screen.getByText('compare.pairNoSchema')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /extractOnBoth.*PL/ })).toHaveAttribute('href', '/extract?collection=c-pl');
});
it('renders extension-schema fields in their own section', () => {
useComparePair.mockReturnValue({ data: { ...base, fields: [
{ field: 'verdict', label: 'Verdict', source: 'schema:s1', kind: 'enum', tier: 'primary', missing_in: [],
coverage: { PL: { covered: 3, total: 3, ratio: 1 }, UK: { covered: 2, total: 2, ratio: 1 } },
values: [{ value: 'guilty', counts: { PL: 2, UK: 1 }, shares: { PL: 0.67, UK: 0.5 } }] },
] }, isLoading: false, isError: false });
render(<PairContent pairId="p1" />);
expect(screen.getByRole('region', { name: 'compare.pairSchemaSection' })).toHaveTextContent('Verdict');
});
});
- Step 2: Implement
frontend/app/compare/[pairId]/page.tsx:
import { PairContent } from '../_components/PairContent';
export default async function ComparePairPage({ params }: { params: Promise<{ pairId: string }> }) {
const { pairId } = await params;
return <PairContent pairId={pairId} />;
}
PairContent.tsx: client component; split data.fields into base = fields.filter(f => f.source === 'base') and schema = fields.filter(f => f.source.startsWith('schema:')); render <CompareView data={{ ...data, fields: base }} request={{ filters: data.filters, text_query: null }} /> then <section role="region" aria-labelledby="pair-schema"> with the schema fields through FieldComparisonFigure (same tier grouping — reuse CompareView with {...data, fields: schema} inside the section when schema.length > 0), else the pairNoSchema text and the two /extract?collection= links. Loading → EditorialCardSkeleton; error → role="alert".
Run: npx jest tests/unit/app/compare && npm run typecheck → PASS.
- Step 3: Commit
git add frontend/app/compare
git commit -m "feat(compare): /compare/[pairId] with extension-schema section
Refs #<C>"
Task 19: Navigation, /collections pair badge, static-page link¶
Files:
- Modify: frontend/components/app-sidebar.tsx:328-356 (Phase 3 — Analyze group), :16-33 (icon import GitCompareArrows)
- Modify: frontend/components/command-palette.tsx:96-104 (add entry after dataset-comparison)
- Modify: frontend/app/dataset-comparison/page.tsx (one Link to /compare under the masthead)
- Modify: frontend/types/collection.ts (pair?: CollectionPairRef | null), frontend/app/collections/page.tsx:445-500 (badge + link)
- Modify: frontend/tests/unit/navigation/route-reachability.test.ts:56-67 (NAV_LINKED_ROUTES += '/compare')
- Test: frontend/__tests__/components/app-sidebar.public-menu.test.tsx (extend), frontend/tests/unit/app/collections/pair-badge.test.tsx
Interfaces:
- frontend/types/collection.ts: export interface CollectionPairRef { id: string; name: string; role: 'PL' | 'UK'; partner_collection_id: string }; CollectionWithDocuments.pair?: CollectionPairRef | null.
- Step 1: Failing tests
In route-reachability.test.ts add '/compare', to NAV_LINKED_ROUTES. In app-sidebar.public-menu.test.tsx, in the authenticated-user it (line ≈ 130-140) add '/compare' to the list of hrefs expected present; in the anonymous it (≈ 91-95) add expect(container.querySelector('a[href="/compare"]')).not.toBeInTheDocument();.
frontend/tests/unit/app/collections/pair-badge.test.tsx:
import { render, screen } from '@testing-library/react';
import { CollectionPairBadge } from '@/app/collections/_components/CollectionPairBadge';
describe('CollectionPairBadge', () => {
it('names the role and links to the comparison', () => {
render(<CollectionPairBadge pair={{ id: 'p1', name: 'Fraud', role: 'PL', partner_collection_id: 'c-uk' }} />);
const link = screen.getByRole('link', { name: /Fraud.*PL/ });
expect(link).toHaveAttribute('href', '/compare/p1');
});
});
Run: cd frontend && npx jest tests/unit/navigation tests/unit/app/collections __tests__/components/app-sidebar.public-menu.test.tsx → FAIL (route not linked; badge missing).
- Step 2: Implement
Sidebar — after the /reasoning-lines item (line 345-354), same markup:
<SidebarMenuItem>
<ConditionalTooltip content={t('navigation.compare')} isIconMode={isIconMode}>
<SidebarMenuButton asChild isActive={pathname.startsWith("/compare")}>
<Link href="/compare">
<GitCompareArrows />
<span>{t('navigation.compare')}</span>
</Link>
</SidebarMenuButton>
</ConditionalTooltip>
</SidebarMenuItem>
/collections uses — check how /collections is gated at line ≈ 240 and mirror it).
Command palette entry:
{
id: "compare",
label: "Compare PL / UK",
description: "Run one structured filter for Polish and UK judgments side by side",
icon: <GitCompareArrows className="h-4 w-4" />,
href: "/compare",
keywords: ["compare", "comparison", "pl", "uk", "jurisdiction", "coverage", "porównaj"],
category: "navigation",
},
frontend/app/collections/_components/CollectionPairBadge.tsx:
import Link from 'next/link';
import { GitCompareArrows } from 'lucide-react';
import type { CollectionPairRef } from '@/types/collection';
export function CollectionPairBadge({ pair }: { pair: CollectionPairRef }) {
return (
<Link href={`/compare/${pair.id}`} onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 border border-oxblood px-2 py-0.5 font-mono text-xs text-oxblood hover:bg-oxblood hover:text-parchment">
<GitCompareArrows className="h-3 w-3" />
{pair.name} · {pair.role}
</Link>
);
}
collections/page.tsx inside the badge row (<div className="flex gap-2 flex-wrap">, line ≈ 473) add {collection.pair && <CollectionPairBadge pair={collection.pair} />}.
dataset-comparison/page.tsx: under the Masthead, <Link href="/compare" className="font-mono text-xs uppercase tracking-wider text-oxblood hover:underline">Live comparison on your own filter →</Link>.
- Step 3: Run
cd frontend && npx jest tests/unit/navigation tests/unit/app __tests__/components && npm run validate && npm run typecheck
- Step 4: Commit
git add frontend/components/app-sidebar.tsx frontend/components/command-palette.tsx frontend/app/dataset-comparison/page.tsx frontend/types/collection.ts frontend/app/collections frontend/tests/unit/navigation/route-reachability.test.ts frontend/tests/unit/app/collections frontend/__tests__/components/app-sidebar.public-menu.test.tsx
git commit -m "feat(nav): /compare in sidebar and palette; pair badge on collections
Refs #<C>"
Task 20: Docs + final verification¶
Files:
- Create: docs/how-to/compare-pl-uk.md
- Modify: docs/reference/sidebar-map.md:11-17 (new row), docs/api/API_REFERENCE.md (new endpoints), docs/tutorials/first-30-minutes.md:131-135 (point the "Comparative" pattern at /compare)
- Step 1: Write the how-to
docs/how-to/compare-pl-uk.md — sections: Goal, Steps (1 open /compare; 2 describe the question or pick chips; 3 read N_PL/N_UK and the three sections; 4 badge meaning — coverage = documents where the field is coded / matched documents, 80 % rule, "not available" rule; 5 Save as pair → /collections shows name · PL/UK; 6 run the same extension schema on both via Extract on … collection, revisit /compare/<id>; 7 Export CSV — column dictionary field, value, jurisdiction, count, share (= count/covered), coverage (= covered/total), covered, total; 8 Copy link — ?f= blob is identical to /search/extractions), Caveats (case_type/court_level defects; free-text topics PL-only; shares are descriptive, no significance test).
sidebar-map.md: row | **Compare PL / UK** | \/compare` | Same base-schema filter for PL and UK, coded fields side by side with coverage; save as a collection pair. | How-to |`.
API_REFERENCE.md: POST /compare/facets, POST /compare/export, GET /compare/pairs/{pair_id}, POST|GET /collections/pairs, GET|DELETE /collections/pairs/{pair_id} with request/response shapes from Tasks 7-11.
first-30-minutes.md:133-135: replace the manual "run identical searches under each filter" sentence with a pointer to /compare and the how-to.
- Step 2: Full verification
cd backend && poetry run poe check-all
cd backend && DB_CONTRACT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres poetry run pytest -m db -q
cd frontend && npm run validate && npm run typecheck && npx jest
cd frontend && npm run test:e2e:smoke # UI-only smoke; must not regress on the new sidebar item
/compare locally (npm run dev + backend on :8004), enter "kobiety skazane za oszustwo" via the NL dialog, confirm N_PL/N_UK, a badge on sentence_serve/convict-adjacent fields, CSV download, permalink round-trip, pair creation and /collections badge.
- Step 3: Commit
git add docs/how-to/compare-pl-uk.md docs/reference/sidebar-map.md docs/api/API_REFERENCE.md docs/tutorials/first-30-minutes.md
git commit -m "docs(compare): how-to, sidebar map, API reference
Refs #<C>"
Open the PR against main (gh pr create), body: Spec C acceptance criteria as a checklist, links to #<C>, #537, Spec B's PR.
Risks and open issues¶
| Risk | Impact | Mitigation in this plan |
|---|---|---|
case_type = 'Civil' on every UK row, court_level = 'Crown Court' (APP_STATUS §4 defects 2-3) |
Any comparison on those columns is wrong | They are judgments columns, not base fields; /compare never exposes them (field registry is base-schema-only). Documented in the how-to caveats. Out of scope to fix. |
legal_topics/keywords PL-only (0/300 UK) |
Topic comparison would show UK at 0 % | keywords is a free-text array → excluded from the registry by construction; dataNote says so. If someone requests fields=["keywords"], select_base_fields returns 400. |
Coverage asymmetry (base_convict_offences PL 153/200 vs UK 198/200, sentences_received PL 153 vs UK 179) |
Users read distribution differences that are coverage artefacts | Shares use covered as denominator; partial tier + badge at < 80 %; CSV carries both denominators. |
| Spec B drift (clause text / key names) | Task 1's verbatim copy diverges | Step 1 verifies B; Step 4 diffs the three regions; db-tier test test_shared_function_honours_spec_b_jurisdiction_key. |
Wrapper refactor changes filter_documents_by_extracted_data behaviour |
Regression on /search/extractions |
Same ORDER BY/LIMIT/OFFSET; test_wrapper_returns_the_same_rows_as_the_shared_function, test_wrapper_pagination_and_order_are_unchanged; test_base_schema_route_regressions.py stays green. |
| 17 RPC calls per compare | Latency on a busy DB | 12k rows; each call is an index-backed filter + one GROUP BY; measured target < 1.5 s total. If it grows, add p_fields TEXT[] variant later (interface already isolated in CompareService.facet_rows). |
Route order /collections/pairs vs /collections/{collection_id} |
404 on every pairs call | Separate router registered first; test_pairs_route_wins_over_collection_id_catch_all. |
extraction_schemas empty in production (#537) |
AC4 has nothing to show until seeds exist | pairNoSchema empty state with deep links to /extract?collection=; link #537 in the PR. |
Audit action_type allow-list |
collection_pair_created rejected at runtime |
Task 9 Step 4 checks audit_service.py. |
| Route reachability + sidebar tests | Frontend Unit Tests red | Task 19 updates both lists. |
| Frontend E2E Smoke crawls the sidebar | New item breaks a smoke assertion | Task 20 runs test:e2e:smoke locally before the PR. |
Issues to link: #<C> (this spec), Spec B's issue, #537 (schema seed — AC4 demo data), #507 (end-to-end pipeline), #583/#575 (extractions Playwright coverage — a /compare E2E belongs in the same follow-up).
Shared building blocks¶
Honest split: (S) genuinely shareable now, (F) feature-specific.
| Block | Where | Interface sketch | Reusable by NL-filter→collection (/search/extractions) |
Reusable by research-flow epic (collection→extract→sample→export) |
|---|---|---|---|---|
(S) RPC list_extracted_filter_matches |
migration 1 | (p_filters JSONB, p_text_query TEXT) → TABLE(id UUID, jurisdiction TEXT); accepts every base-filter key + collection_ids |
Yes — "save result as collection" can fetch all matching ids server-side in one call instead of paging filter_documents_by_extracted_data 200 at a time |
Yes — "sample N from a collection" / "facets over a collection" via collection_ids |
(S) RPC get_extracted_facet_counts_by_jurisdiction |
migration 2 | (p_filters, field_path, p_text_query) → (jurisdiction, value, count, total, covered, coverage); summary row when value IS NULL |
Yes — filtered facet counts for chips (jurisdiction split is free; sum both for a global count) |
Yes — coverage-per-field of a collection before choosing an extraction schema |
(S) collection_ids key in p_filters |
migration 1 | {"collection_ids": ["uuid", …]} → membership predicate |
Indirectly (after B) | Yes — every "within this collection" query |
(S) backend/app/compare/fields.py |
Task 5 | comparable_fields_from_schema(schema, source) → list[FieldSpec], base_compare_fields(), select_base_fields(names) |
Partly — a registry of enum/boolean base fields for chip rendering | Yes — "which fields of this schema can be aggregated" for the sample/export step |
(S) backend/app/compare/layout.py |
Task 4 | coverage_ratio, share, tier_for, build_field(spec, coverage, counts) → CompareField; LOW_COVERAGE_THRESHOLD |
No (single-population UI has no tiers) — but coverage_ratio is generic |
Yes — coverage badges on extraction results per field |
(S) backend/app/compare/schema_tally.py |
Task 11 | tally_schema_fields({"PL": results, "UK": results}, specs) → list[CompareField]; latest_success_jobs(client, collection_ids); load_schema(client, id) |
No | Yes — per-field value counts of any extraction job (results JSONB), single population by passing one key |
(S) backend/app/compare/csv_export.py |
Task 8 | to_csv_rows(CompareResponse) → rows, csv_bytes(rows) → bytes (BOM) |
No | Yes — long-format aggregate export of any CompareField list; csv_bytes is generic |
(S) collection_pairs table + CollectionPairsDB + /collections/pairs |
Tasks 3, 9 | create_pair, list_pairs, find_pair, delete_pair, pairs_by_collection(user_id); POST /collections/pairs {name, filters, text_query} |
Yes — B's "save as collection" could call POST /collections/pairs when the NL filter names both jurisdictions |
Yes — a pair is a first-class input for a comparative research flow |
(S) Server-side "collections from a filter" in collection_pairs.create_pair |
Task 9 | _matching_ids(filters, text_query) → {PL: ids, UK: ids} + chunked bulk_add_documents |
Yes, directly — extract into app/collections_from_filter.py::create_collection_from_filter(user, name, filters, text_query) → collection and let B's single-collection save reuse it (5 000 cap, 1 000-row chunks) |
Yes — step 1 of the flow |
(S) frontend/components/charts/BivariateBarChart |
Task 12 | {categories, plData, ukData, yTickSuffix?, hoverTemplate?, …} |
No | Yes — any PL/UK grouped bar (statistics, reasoning lines) |
(S) frontend/lib/extractions/drawer-adapter.ts |
Task 16 | toDrawerFilters(BaseSchemaFilters) → BaseFilters, applyDrawerChange(filters, field, value) → BaseSchemaFilters |
Yes — it is the search page's own code, now importable by any page mounting BaseFiltersDrawer |
Yes — filter step of the flow |
(S) frontend/lib/compare/permalink.ts |
Task 13 | buildComparePermalink(filters, text, origin?) on top of encodeFilters |
Yes — B's "URL encodes the filter" is the same blob; a /search/extractions permalink builder is one line away |
Yes |
(S) frontend/lib/compare/chart-data.ts |
Task 13 | toShareChart(field, labelOf), coveragePercent(stat) |
No | Yes — with schema_tally output |
(F) CompareService.compare/compare_collections, router.py |
Tasks 6-11 | POST /compare/facets, POST /compare/export, GET /compare/pairs/{id} |
No | Partly — GET /compare/pairs/{id} is the "compare" terminal step of a comparative flow |
(F) /compare pages and _components/* (CompareView, CoverageBadge, UnavailableFields, SavePairDialog, PairContent) |
Tasks 16-18 | React components bound to CompareResponse |
No | Partly — CoverageBadge and UnavailableFields are presentational and could be lifted to components/editorial if a second consumer appears; not before |
(F) i18n compare.* |
Task 15 | — | No | No |
Not shared on purpose: the 80 % threshold and the hide rule stay in layout.py — consumers that want them import the module; nobody copies the number.