Beemer Claude Fable 5 commited on
Commit ·
724ca05
1
Parent(s): a2770e1
Sprint tests (106 green), rewritten README, maintainer RUNBOOK, findings doc resolved
Browse files- README.md +29 -9
- RUNBOOK.md +107 -0
- precision-findings.md +8 -0
- tests/test_curated_schema.py +90 -0
- tests/test_sprint.py +118 -0
README.md
CHANGED
|
@@ -8,18 +8,38 @@ pinned: false
|
|
| 8 |
# CanLex MCP
|
| 9 |
|
| 10 |
A remote [Model Context Protocol](https://modelcontextprotocol.io) server for
|
| 11 |
-
Canadian legal research
|
|
|
|
| 12 |
|
| 13 |
- **canlex_search_legislation** — hybrid (BM25 + semantic) search with a
|
| 14 |
-
cross-encoder reranker over federal border, customs,
|
| 15 |
-
food/health, labour and privacy legislation
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
Retrieval runs fully locally inside the container and needs no API key. The
|
| 22 |
optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
|
| 23 |
-
variable.
|
|
|
|
| 24 |
|
| 25 |
-
Add this Space's `/mcp` URL as a custom connector in any Claude client
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# CanLex MCP
|
| 9 |
|
| 10 |
A remote [Model Context Protocol](https://modelcontextprotocol.io) server for
|
| 11 |
+
Canadian legal research, built for front-line border-services work. It exposes
|
| 12 |
+
seven read-only tools over streamable HTTP (or local stdio):
|
| 13 |
|
| 14 |
- **canlex_search_legislation** — hybrid (BM25 + semantic) search with a
|
| 15 |
+
cross-encoder reranker over the whole corpus: federal border, customs,
|
| 16 |
+
criminal, firearms, drug, food/health, labour and privacy legislation with
|
| 17 |
+
their Schedules; the ENF operational manuals; CBSA D-Memoranda and the AMPS
|
| 18 |
+
Master Penalty Document; the Charter; Treasury Board collective agreements;
|
| 19 |
+
NJC directives; ~110 leading decisions; delegation instruments;
|
| 20 |
+
benefit-plan booklets; and CanLex's curated commentary.
|
| 21 |
+
- **canlex_get_section** — fetch one provision verbatim by Act and section
|
| 22 |
+
(accepts 's. 34', '34(1)(c)', 'Schedule I').
|
| 23 |
+
- **canlex_us_disposition** — step 1 of the IRPA s. 36 screening chain: is a
|
| 24 |
+
US disposition a "conviction"? 51-jurisdiction state-by-state dataset.
|
| 25 |
+
- **canlex_us_equivalency** — step 2: the Canadian equivalent offence, its
|
| 26 |
+
verified maximum penalty, and the inadmissibility branch.
|
| 27 |
+
- **canlex_rehabilitation** — step 3: deemed rehabilitation / eligibility to
|
| 28 |
+
apply, under IRPA s. 36(3)(c) and IRPR ss. 17–18.1.
|
| 29 |
+
- **canlex_list_acts** — the corpus inventory and its currency.
|
| 30 |
+
- **canlex_case** — live CanLII case citator (citation graph by neutral
|
| 31 |
+
citation or URL).
|
| 32 |
+
|
| 33 |
+
The server also publishes an MCP prompt (`us_record_screening`, the guided
|
| 34 |
+
three-step screening flow) and resources (the corpus inventory and the
|
| 35 |
+
conviction/equivalency methodology texts) that any MCP client can use.
|
| 36 |
|
| 37 |
Retrieval runs fully locally inside the container and needs no API key. The
|
| 38 |
optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
|
| 39 |
+
variable. A weekly scheduled staleness check watches upstream sources for
|
| 40 |
+
drift; see RUNBOOK.md for the maintainer's refresh choreography.
|
| 41 |
|
| 42 |
+
Add this Space's `/mcp` URL as a custom connector in any Claude client, or
|
| 43 |
+
point any MCP-capable agent (Gemini in Antigravity, etc.) at it. A companion
|
| 44 |
+
Gradio webapp (`webapp/`, deployed as its own Space) gives the same corpus a
|
| 45 |
+
Gemini-powered agentic chat front-end.
|
RUNBOOK.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CanLex maintainer runbook
|
| 2 |
+
|
| 3 |
+
Everything a future maintainer needs that the code alone does not say.
|
| 4 |
+
|
| 5 |
+
## The refresh choreography
|
| 6 |
+
|
| 7 |
+
Order matters — chunks feed embeddings feed the eval:
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
|
| 11 |
+
py -m canlex.dmemo # CBSA D-Memoranda
|
| 12 |
+
py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
|
| 13 |
+
py -m canlex.amps # AMPS Master Penalty Document
|
| 14 |
+
py -m canlex.charter # Charter / Constitution HTML
|
| 15 |
+
py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
|
| 16 |
+
py -m canlex.delegation # CBSA/IRCC delegation instruments
|
| 17 |
+
py -m canlex.directive # NJC directives
|
| 18 |
+
py -m canlex.agreement # FB collective agreement
|
| 19 |
+
py -m canlex.benefits # benefit-plan booklets
|
| 20 |
+
py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
|
| 21 |
+
py -m canlex.commentary # curated datasets -> commentary chunks
|
| 22 |
+
py -m canlex.embed # re-embed EVERYTHING (run after ANY of the above)
|
| 23 |
+
py -m unittest discover -s tests # all offline
|
| 24 |
+
py -m canlex.eval # gate: no metric below the last committed run
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
`canlex.embed` is mandatory after any chunk change: the index zero-fills
|
| 28 |
+
vectors for chunks missing from embeddings.npz and quietly degrades them to
|
| 29 |
+
BM25-only (a stderr warning is the only signal).
|
| 30 |
+
|
| 31 |
+
## Deploys
|
| 32 |
+
|
| 33 |
+
- **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
|
| 34 |
+
The Space rebuilds automatically. `embeddings.npz` ships in-repo via
|
| 35 |
+
git-LFS — a fresh clone needs `git lfs install`.
|
| 36 |
+
- **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
|
| 37 |
+
`webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
|
| 38 |
+
`webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
|
| 39 |
+
it behind PRO for Docker/Gradio Spaces; git pushes work on the free tier.
|
| 40 |
+
|
| 41 |
+
## Secrets and variables (set in each Space's settings)
|
| 42 |
+
|
| 43 |
+
| Where | Name | Purpose |
|
| 44 |
+
|---|---|---|
|
| 45 |
+
| MCP Space | `CANLII_API_KEY` | optional, enables canlex_case |
|
| 46 |
+
| Webapp | `GEMINI_API_KEY` | required |
|
| 47 |
+
| Webapp | `CANLEX_WEB_AUTH` | required (`user:pass` per line); the app refuses to start without it |
|
| 48 |
+
| Webapp | `CANLEX_GEMINI_MODEL` | optional; the deployed Space sets `gemini-2.5-pro` (code default is flash) |
|
| 49 |
+
|
| 50 |
+
Locally, the CanLII key lives in `canlii_key.txt` (gitignored — never
|
| 51 |
+
commit it).
|
| 52 |
+
|
| 53 |
+
## The staleness watch
|
| 54 |
+
|
| 55 |
+
`py -m canlex.refresh` (alias `--check`) diffs upstream: Justice Laws
|
| 56 |
+
consolidation dates, CBSA/IRCC delegation instruments, NJC directive dates,
|
| 57 |
+
the D-memo index's newest Date modified, curated-dataset age (>180 days →
|
| 58 |
+
AGING) and curated-vs-chunk regen drift. Non-zero exit = something drifted.
|
| 59 |
+
A scheduled task runs it Mondays 03:01 local — if this machine is off, the
|
| 60 |
+
check silently doesn't happen that week. Note: network fetch errors on the
|
| 61 |
+
legislation rows DO fail the check (exit 1) so a broken watch can't pass
|
| 62 |
+
silently.
|
| 63 |
+
|
| 64 |
+
`_PENDING_BILLS` in canlex/refresh.py names bills heading into force that
|
| 65 |
+
affect corpus Acts — edit that list when a new one appears (source-code edit,
|
| 66 |
+
deliberate).
|
| 67 |
+
|
| 68 |
+
## Curated-dataset review protocol
|
| 69 |
+
|
| 70 |
+
`data/curated/us_dispositions.json` and `us_equivalency.json` are the only
|
| 71 |
+
hand-maintained legal content. To change them: edit → bump `"reviewed"` →
|
| 72 |
+
`py -m canlex.commentary` → `py -m canlex.embed` → eval → deploy. The refresh
|
| 73 |
+
check flags any drift between the curated `reviewed` date and the rendered
|
| 74 |
+
chunks. Provenance and the verbatim-verification log live inside the JSON
|
| 75 |
+
files themselves; keep them current.
|
| 76 |
+
|
| 77 |
+
## Backups
|
| 78 |
+
|
| 79 |
+
The HF Space remote is also production. A full-history bundle lives at
|
| 80 |
+
`C:\projects\CanLex-backup.bundle` (recreate: `git bundle create
|
| 81 |
+
../CanLex-backup.bundle --all`). For a real offsite mirror, create a private
|
| 82 |
+
GitHub repo and:
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
git remote add mirror https://github.com/<you>/CanLex.git
|
| 86 |
+
git push mirror --all
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
(GitHub needs git-lfs enabled for embeddings.npz, or add it to a
|
| 90 |
+
`.lfsconfig`-excluded push.)
|
| 91 |
+
|
| 92 |
+
## Known gaps / parked
|
| 93 |
+
|
| 94 |
+
- Hill, Brannson and Li (the FCA equivalency trilogy) have curated
|
| 95 |
+
`CANLII_CASES` entries but need their page text captured manually from
|
| 96 |
+
CanLII (DataDome blocks scripts): open the URL each stub prints, save the
|
| 97 |
+
page text to the named `data/raw/caselaw/*.txt` file, re-run
|
| 98 |
+
`py -m canlex.caselaw && py -m canlex.embed`.
|
| 99 |
+
- ENF chapters 2, 7, 9, 11, 13, 14, 16, 20 (detention), 25, 27 are not at
|
| 100 |
+
the legacy PDF URL pattern; the current-policy PDI HTML tree is the
|
| 101 |
+
eventual replacement source.
|
| 102 |
+
- Dag (FCA) and Kinhnicki (FPSLREB) parse to 2/1 chunks (paragraph-detector
|
| 103 |
+
fallthrough on those cached pages) — known, low-impact.
|
| 104 |
+
- French support is phase 1 only (accent-folded tokens + FR→EN synonym
|
| 105 |
+
bridges; the `french` eval slice tracks it). Phase 2 = multilingual
|
| 106 |
+
embedder via the `CANLEX_EMB_REPO` env hooks; phase 3 = parallel French
|
| 107 |
+
corpus.
|
precision-findings.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
| 1 |
# CanLex retrieval — precision investigation (2026-05-21)
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Investigation of the persistent eval misses, with a tested, recommended fix.
|
| 4 |
**No retrieval-algorithm change has been deployed** — this is for review.
|
| 5 |
|
|
|
|
| 1 |
# CanLex retrieval — precision investigation (2026-05-21)
|
| 2 |
|
| 3 |
+
> **STATUS (2026-07-22): RESOLVED / SUPERSEDED.** The concerns this
|
| 4 |
+
> investigation raised were addressed by the subsequent eval-gated retrieval
|
| 5 |
+
> work — `b2950fb` (reranker pool + doc-cap tuning), `d24e96c` (per-source
|
| 6 |
+
> recall cap), the 2026-06/07 sweeps in `data/eval/sweep.log`, and the
|
| 7 |
+
> 2026-07 filter-aware-recall + repealed-penalty changes. Kept as the
|
| 8 |
+
> diagnostic record; its specific recommendation should NOT be applied on
|
| 9 |
+
> top of the current knobs without a fresh sweep.
|
| 10 |
+
|
| 11 |
Investigation of the persistent eval misses, with a tested, recommended fix.
|
| 12 |
**No retrieval-algorithm change has been deployed** — this is for review.
|
| 13 |
|
tests/test_curated_schema.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Schema validation of the REAL curated datasets.
|
| 2 |
+
|
| 3 |
+
test_commentary.py deliberately exercises the renderer on a minimal in-memory
|
| 4 |
+
dataset; nothing previously validated the actual shipped JSON, so a hand-edit
|
| 5 |
+
typo in the 51-jurisdiction survey surfaced only at runtime in production.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import unittest
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
CURATED = Path(__file__).resolve().parent.parent / "data" / "curated"
|
| 12 |
+
|
| 13 |
+
VERDICTS = {"yes", "likely-yes", "likely-no", "no", "fact-specific", "depends"}
|
| 14 |
+
STATUSES = {"settled", "judicially-considered", "guidance-only", "no-authority"}
|
| 15 |
+
US_STATES = {
|
| 16 |
+
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado",
|
| 17 |
+
"Connecticut", "Delaware", "District of Columbia", "Florida", "Georgia",
|
| 18 |
+
"Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky",
|
| 19 |
+
"Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan",
|
| 20 |
+
"Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada",
|
| 21 |
+
"New Hampshire", "New Jersey", "New Mexico", "New York",
|
| 22 |
+
"North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon",
|
| 23 |
+
"Pennsylvania", "Rhode Island", "South Carolina", "South Dakota",
|
| 24 |
+
"Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington",
|
| 25 |
+
"West Virginia", "Wisconsin", "Wyoming", "general",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class DispositionsSchemaTests(unittest.TestCase):
|
| 30 |
+
@classmethod
|
| 31 |
+
def setUpClass(cls):
|
| 32 |
+
cls.data = json.loads(
|
| 33 |
+
(CURATED / "us_dispositions.json").read_text(encoding="utf-8"))
|
| 34 |
+
|
| 35 |
+
def test_reviewed_date_present(self):
|
| 36 |
+
self.assertRegex(self.data.get("reviewed", ""), r"^\d{4}-\d{2}-\d{2}$")
|
| 37 |
+
|
| 38 |
+
def test_entries_have_required_keys(self):
|
| 39 |
+
for e in self.data["dispositions"]:
|
| 40 |
+
for key in ("id", "names", "is_conviction", "status", "analysis"):
|
| 41 |
+
self.assertIn(key, e, f"{e.get('id', '?')} missing {key}")
|
| 42 |
+
self.assertTrue(e["names"], f"{e['id']}: empty names")
|
| 43 |
+
|
| 44 |
+
def test_verdicts_are_legal_values(self):
|
| 45 |
+
for e in self.data["dispositions"]:
|
| 46 |
+
self.assertIn(e["is_conviction"], VERDICTS, e["id"])
|
| 47 |
+
self.assertIn(e["status"], STATUSES, e["id"])
|
| 48 |
+
for v in e.get("state_variations", []):
|
| 49 |
+
if v.get("is_conviction"):
|
| 50 |
+
self.assertIn(v["is_conviction"], VERDICTS,
|
| 51 |
+
f"{e['id']}/{v.get('state')}")
|
| 52 |
+
|
| 53 |
+
def test_state_names_valid(self):
|
| 54 |
+
for e in self.data["dispositions"]:
|
| 55 |
+
for v in e.get("state_variations", []):
|
| 56 |
+
self.assertIn(v["state"], US_STATES,
|
| 57 |
+
f"{e['id']}: unknown state {v['state']!r}")
|
| 58 |
+
|
| 59 |
+
def test_authorities_carry_citations(self):
|
| 60 |
+
for e in self.data["dispositions"]:
|
| 61 |
+
for a in e.get("authorities", []):
|
| 62 |
+
self.assertTrue(a.get("cite"), f"{e['id']}: authority no cite")
|
| 63 |
+
|
| 64 |
+
def test_renderer_accepts_every_entry(self):
|
| 65 |
+
from canlex.commentary import _entry_text
|
| 66 |
+
for e in self.data["dispositions"]:
|
| 67 |
+
text = _entry_text(e)
|
| 68 |
+
self.assertGreater(len(text), 100, e["id"])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class EquivalencySchemaTests(unittest.TestCase):
|
| 72 |
+
@classmethod
|
| 73 |
+
def setUpClass(cls):
|
| 74 |
+
cls.data = json.loads(
|
| 75 |
+
(CURATED / "us_equivalency.json").read_text(encoding="utf-8"))
|
| 76 |
+
|
| 77 |
+
def test_pairings_have_required_keys(self):
|
| 78 |
+
for p in self.data["pairings"]:
|
| 79 |
+
for key in ("id", "us_terms", "canadian_offence", "penalty",
|
| 80 |
+
"branch", "status", "analysis"):
|
| 81 |
+
self.assertIn(key, p, f"{p.get('id', '?')} missing {key}")
|
| 82 |
+
self.assertTrue(p["us_terms"], f"{p['id']}: empty us_terms")
|
| 83 |
+
|
| 84 |
+
def test_methodology_framework_present(self):
|
| 85 |
+
ids = {m["id"] for m in self.data.get("methodology", [])}
|
| 86 |
+
self.assertIn("equivalency-framework", ids)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
unittest.main()
|
tests/test_sprint.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline tests for the sprint's parser, retrieval and server changes."""
|
| 2 |
+
import unittest
|
| 3 |
+
import xml.etree.ElementTree as ET
|
| 4 |
+
|
| 5 |
+
from canlex.ingest import _schedule_chunks, _table_text
|
| 6 |
+
from canlex.index import _fold_accents, tokenize
|
| 7 |
+
from canlex.server import _normalize_section
|
| 8 |
+
from canlex.citator import canlii_url_from_citation
|
| 9 |
+
|
| 10 |
+
_SRC = {"short": "TestAct", "name": "Test Act", "web_base": "https://x/"}
|
| 11 |
+
|
| 12 |
+
_SCHED_XML = """<Statute>
|
| 13 |
+
<Schedule>
|
| 14 |
+
<ScheduleFormHeading><Label>SCHEDULE I</Label></ScheduleFormHeading>
|
| 15 |
+
<List><Item><Label>1</Label><Text>Opium (all salts)</Text>
|
| 16 |
+
<List><Item><Label>(1)</Label><Text>Ketamine</Text></Item></List>
|
| 17 |
+
</Item></List>
|
| 18 |
+
</Schedule>
|
| 19 |
+
<Schedule>
|
| 20 |
+
<ScheduleFormHeading><Label>SCHEDULE II</Label></ScheduleFormHeading>
|
| 21 |
+
<Repealed>[Repealed]</Repealed>
|
| 22 |
+
</Schedule>
|
| 23 |
+
<Schedule id="NifProvs">
|
| 24 |
+
<ScheduleFormHeading><TitleText>AMENDMENTS NOT IN FORCE</TitleText></ScheduleFormHeading>
|
| 25 |
+
<List><Item><Text>never rendered here</Text></Item></List>
|
| 26 |
+
</Schedule>
|
| 27 |
+
<Schedule>
|
| 28 |
+
<ScheduleFormHeading><Label>SCHEDULE III</Label><TitleText>Violations</TitleText></ScheduleFormHeading>
|
| 29 |
+
<TableGroup><table><tgroup><tbody>
|
| 30 |
+
<row><entry>Provision</entry><entry>Penalty</entry></row>
|
| 31 |
+
<row><entry>s. 12</entry><entry>$500</entry></row>
|
| 32 |
+
</tbody></tgroup></table></TableGroup>
|
| 33 |
+
</Schedule>
|
| 34 |
+
</Statute>"""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ScheduleParsingTests(unittest.TestCase):
|
| 38 |
+
def setUp(self):
|
| 39 |
+
self.root = ET.fromstring(_SCHED_XML)
|
| 40 |
+
|
| 41 |
+
def chunks(self, src=None):
|
| 42 |
+
return _schedule_chunks(self.root, "T-1", src or _SRC, "2026-01-01")
|
| 43 |
+
|
| 44 |
+
def test_list_schedule_rendered_with_nested_items(self):
|
| 45 |
+
c = self.chunks()[0]
|
| 46 |
+
self.assertIn("Opium", c["text"])
|
| 47 |
+
self.assertIn("Ketamine", c["text"])
|
| 48 |
+
self.assertEqual(c["section"], "Schedule I")
|
| 49 |
+
self.assertEqual(c["status"], "in force")
|
| 50 |
+
|
| 51 |
+
def test_repealed_schedule_flagged(self):
|
| 52 |
+
c = next(x for x in self.chunks() if x["section"] == "Schedule Ii")
|
| 53 |
+
self.assertEqual(c["status"], "repealed")
|
| 54 |
+
|
| 55 |
+
def test_nifprovs_excluded(self):
|
| 56 |
+
self.assertFalse([c for c in self.chunks()
|
| 57 |
+
if "never rendered" in c["text"]])
|
| 58 |
+
|
| 59 |
+
def test_table_rows_pipe_separated(self):
|
| 60 |
+
c = next(x for x in self.chunks() if "Violations" in x["marginal_note"])
|
| 61 |
+
self.assertIn("s. 12 | $500", c["text"])
|
| 62 |
+
|
| 63 |
+
def test_skip_schedules_optout(self):
|
| 64 |
+
self.assertEqual(self.chunks({**_SRC, "skip_schedules": True}), [])
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class AccentFoldingTests(unittest.TestCase):
|
| 68 |
+
def test_fold(self):
|
| 69 |
+
self.assertEqual(_fold_accents("détention"), "detention")
|
| 70 |
+
|
| 71 |
+
def test_tokenize_french(self):
|
| 72 |
+
# Folded French stems to the same normalized token as the English word
|
| 73 |
+
# ('detention' and 'détention' both -> 'detain').
|
| 74 |
+
self.assertEqual(tokenize("détention"), tokenize("detention"))
|
| 75 |
+
|
| 76 |
+
def test_english_unchanged(self):
|
| 77 |
+
self.assertEqual(tokenize("seizure of goods"),
|
| 78 |
+
tokenize("seizure of goods"))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SectionNormalizationTests(unittest.TestCase):
|
| 82 |
+
CASES = {
|
| 83 |
+
"s. 34(1)(c)": "34", "section 20.1": "20.1", "s 34": "34",
|
| 84 |
+
"ss. 18(2)": "18", "34": "34", "Schedule I": "Schedule I",
|
| 85 |
+
"schedule 2": "schedule 2", "§34": "34",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
def test_normalization_table(self):
|
| 89 |
+
for given, want in self.CASES.items():
|
| 90 |
+
self.assertEqual(_normalize_section(given), want, given)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class CitatorCourtMapTests(unittest.TestCase):
|
| 94 |
+
def test_fpslreb(self):
|
| 95 |
+
self.assertIn("/ca/pslreb/doc/2023/2023fpslreb12/",
|
| 96 |
+
canlii_url_from_citation("2023 FPSLREB 12"))
|
| 97 |
+
|
| 98 |
+
def test_onca_provincial_segment(self):
|
| 99 |
+
self.assertIn("/on/onca/doc/2024/2024onca608/",
|
| 100 |
+
canlii_url_from_citation("2024 ONCA 608"))
|
| 101 |
+
|
| 102 |
+
def test_scc_unchanged(self):
|
| 103 |
+
self.assertIn("/ca/scc/doc/2019/2019scc65/",
|
| 104 |
+
canlii_url_from_citation("2019 SCC 65"))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class RefreshCuratedTests(unittest.TestCase):
|
| 108 |
+
def test_check_curated_runs_offline(self):
|
| 109 |
+
from canlex.refresh import check_curated
|
| 110 |
+
rows = check_curated("2026-07-22")
|
| 111 |
+
files = {r["file"] for r in rows}
|
| 112 |
+
self.assertIn("us_dispositions.json", files)
|
| 113 |
+
for r in rows:
|
| 114 |
+
self.assertIn(r["status"], ("ok", "aging", "drift", "error"))
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
unittest.main()
|