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 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. It exposes four read-only tools over streamable HTTP:
 
12
 
13
  - **canlex_search_legislation** — hybrid (BM25 + semantic) search with a
14
- cross-encoder reranker over federal border, customs, criminal, drug,
15
- food/health, labour and privacy legislation, CBSA D-Memoranda, Treasury Board
16
- collective agreements, and National Joint Council directives.
17
- - **canlex_get_section** fetch one provision verbatim by Act and section.
18
- - **canlex_list_acts** list the corpus and its currency.
19
- - **canlex_case** live CanLII case citator (citation graph for a case URL).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()