Beemer commited on
Commit ·
7a77fc8
1
Parent(s): 16ed7d1
Deploy: audit round 1 -- whole-section get_section, Latin-1 request bodies, rehab s. 17 proviso, rerank pool cap, applicability caveats
Browse files- .gitignore +1 -0
- RUNBOOK.md +24 -3
- canlex/index.py +115 -2
- canlex/rehab.py +68 -20
- canlex/server.py +189 -23
- canlex/synonyms.py +11 -0
- data/eval/questions.json +75 -1
- tests/test_rehab.py +22 -1
- tests/test_server.py +146 -0
.gitignore
CHANGED
|
@@ -7,3 +7,4 @@ canlii_key.txt
|
|
| 7 |
data/citator_*.json
|
| 8 |
US-Dispositions-Reference.pdf
|
| 9 |
*.bundle
|
|
|
|
|
|
| 7 |
data/citator_*.json
|
| 8 |
US-Dispositions-Reference.pdf
|
| 9 |
*.bundle
|
| 10 |
+
data/logs/
|
RUNBOOK.md
CHANGED
|
@@ -114,9 +114,13 @@ JSON that would read as an empty corpus and disable the guard next time.
|
|
| 114 |
|
| 115 |
## Deploys
|
| 116 |
|
| 117 |
-
- **MCP Space** (`Beemer0/CanLex`): commit, then
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
- **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
|
| 121 |
`webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
|
| 122 |
`webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
|
|
@@ -283,3 +287,20 @@ argued about.
|
|
| 283 |
of "what is a pre-removal risk assessment" entirely. If a new corpus
|
| 284 |
family lands, check which field carries its document identity before
|
| 285 |
trusting the caps.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
## Deploys
|
| 116 |
|
| 117 |
+
- **MCP Space** (`Beemer0/CanLex`): commit on `main`, then ship it with the
|
| 118 |
+
squashed `deploy`-branch recipe in "Deploying to the Hugging Face Space"
|
| 119 |
+
above -- never `git push space main`: the Space's history diverged at the
|
| 120 |
+
2026-08-31 squash, so that push is rejected, and forcing it would re-upload
|
| 121 |
+
every historical `embeddings.npz`. The Space rebuilds automatically after
|
| 122 |
+
the push. `embeddings.npz` ships in-repo via git-LFS — a fresh clone needs
|
| 123 |
+
`git lfs install`.
|
| 124 |
- **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
|
| 125 |
`webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
|
| 126 |
`webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
|
|
|
|
| 287 |
of "what is a pre-removal risk assessment" entirely. If a new corpus
|
| 288 |
family lands, check which field carries its document identity before
|
| 289 |
trusting the caps.
|
| 290 |
+
- **The rerank pool is source-capped (`POOL_CAP`, 2026-09-06).** The
|
| 291 |
+
cross-encoder only ever sees `RERANK_POOL` (16) fused candidates, and
|
| 292 |
+
`RECALL_CAP` (20) let one deep decision take 14 of those slots: on "can an
|
| 293 |
+
officer search a traveller's cell phone at the port of entry without a
|
| 294 |
+
warrant" the pool was 14 Fearon paragraphs plus two unrelated "without
|
| 295 |
+
warrant" marginal notes, while Canfield, Pike and ENF 12 s. 6.15 sat at
|
| 296 |
+
fused ranks 19-32 and were never scored. `_fill_pool` now admits at most
|
| 297 |
+
`POOL_CAP` (5) chunks per capped source while filling the pool; `_diversify`
|
| 298 |
+
still governs display afterwards. Eval-gated: no slice moved. The
|
| 299 |
+
device-vocabulary bridge in synonyms.py is the other half of that fix.
|
| 300 |
+
- **Some law in the corpus does not bind its audience (`_INAPPLICABLE`).**
|
| 301 |
+
Canada Labour Code Part III excludes federal departments (s. 167(1)(d)),
|
| 302 |
+
and FPSLRA Part 2.1 is RCMP-only, yet both out-BM25'd the FB agreement on
|
| 303 |
+
steward questions (CLC s. 173.1 over FB art. 25.21 for shift-change
|
| 304 |
+
notice). Those chunks take `INAPPLICABLE_PENALTY` in the fusion and carry
|
| 305 |
+
an APPLICABILITY caveat whenever they are rendered. Extend the table when a
|
| 306 |
+
new instrument with a public-service carve-out lands.
|
canlex/index.py
CHANGED
|
@@ -25,6 +25,24 @@ RERANK_POOL = int(os.environ.get("CANLEX_RERANK_POOL", "16"))
|
|
| 25 |
# low-fusion candidates the promote-only rerank can lift).
|
| 26 |
# Swept 2026-06: 50 -> 16 (with doc cap 3000 -> 1000) held
|
| 27 |
# the 159-Q eval exactly (MRR +0.01) at ~5.5x lower latency.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
MN_WEIGHT = float(os.environ.get("CANLEX_MN_WEIGHT", "0.0024"))
|
| 29 |
# title-match boost per unit of idf-weighted overlap between
|
| 30 |
# the query and a candidate's marginal note (section title)
|
|
@@ -50,6 +68,14 @@ REPEALED_PENALTY = float(os.environ.get("CANLEX_REPEALED_PENALTY", "0.008"))
|
|
| 50 |
# inform): a repealed section's on-topic title must not
|
| 51 |
# beat live law in a close contest -- the CDSA-to-Cannabis
|
| 52 |
# -Act migration is the live risk zone
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
|
| 54 |
# likewise for a collective agreement's back-matter
|
| 55 |
# (memoranda, letters of understanding) vs its numbered articles
|
|
@@ -112,6 +138,45 @@ _APPENDIX_REF = re.compile(r"\bAppendi(?:x|ces)\s+([A-Za-z])\b(?!\s+of\b)",
|
|
| 112 |
# A directive chunk that *is* an appendix: its title opens "Appendix C ...".
|
| 113 |
_APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
_STEMMER = snowballstemmer.stemmer("english")
|
| 117 |
_STEM_CACHE = {}
|
|
@@ -322,8 +387,13 @@ class LegislationIndex:
|
|
| 322 |
self._is_backmatter = []
|
| 323 |
self._is_nif = []
|
| 324 |
self._is_repealed = []
|
|
|
|
| 325 |
for c in self.chunks:
|
| 326 |
self._note_tokens.append(set(tokenize(topical_title(c))))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
self._is_regulation.append(
|
| 328 |
c.get("doc_type", "legislation") == "legislation"
|
| 329 |
and c["act_code"].startswith(("SOR", "C.R.C")))
|
|
@@ -649,6 +719,27 @@ class LegislationIndex:
|
|
| 649 |
break
|
| 650 |
return out
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
def _diversify(self, ordered):
|
| 653 |
"""Reorder so no single case, memorandum, agreement or directive can
|
| 654 |
monopolise the results: once a source has contributed SOURCE_CAP chunks,
|
|
@@ -714,6 +805,8 @@ class LegislationIndex:
|
|
| 714 |
score -= REG_PENALTY
|
| 715 |
if self._is_backmatter[idx]:
|
| 716 |
score -= BACKMATTER_PENALTY
|
|
|
|
|
|
|
| 717 |
return score
|
| 718 |
# Sort by title-match descending, then by original fusion order as
|
| 719 |
# a tiebreak (stable sort: keep the original rest order).
|
|
@@ -895,6 +988,8 @@ class LegislationIndex:
|
|
| 895 |
fused[idx] -= NIF_PENALTY
|
| 896 |
elif self._is_repealed[idx]:
|
| 897 |
fused[idx] -= REPEALED_PENALTY
|
|
|
|
|
|
|
| 898 |
elif self._is_regulation[idx]:
|
| 899 |
fused[idx] -= REG_PENALTY
|
| 900 |
elif self.chunks[idx].get("doc_type") == "memorandum":
|
|
@@ -916,7 +1011,7 @@ class LegislationIndex:
|
|
| 916 |
# unreliable on long statutory text (it can score the right section
|
| 917 |
# negative), so its power to demote a candidate is deliberately removed.
|
| 918 |
if self.reranker:
|
| 919 |
-
pool = candidates
|
| 920 |
docs = [self._rerank_doc(i) for i in pool]
|
| 921 |
ce_orig = self.reranker.score(query, docs)
|
| 922 |
# A repaired query is scored alongside the original and each
|
|
@@ -939,7 +1034,8 @@ class LegislationIndex:
|
|
| 939 |
sorted(pool, key=ce.get, reverse=True))}
|
| 940 |
pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
|
| 941 |
fusion_rank[i]))
|
| 942 |
-
|
|
|
|
| 943 |
|
| 944 |
# Explicit section references are pinned to the very top -- using the
|
| 945 |
# same Act-mentioned constraint as the recall step above, for the same
|
|
@@ -987,6 +1083,23 @@ class LegislationIndex:
|
|
| 987 |
fallback = fallback or c
|
| 988 |
return fallback
|
| 989 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
def _build_xref(self):
|
| 991 |
"""Index legislation by (act, section); find each Act's definitions
|
| 992 |
section; link every regulation to its enabling Act and every
|
|
|
|
| 25 |
# low-fusion candidates the promote-only rerank can lift).
|
| 26 |
# Swept 2026-06: 50 -> 16 (with doc cap 3000 -> 1000) held
|
| 27 |
# the 159-Q eval exactly (MRR +0.01) at ~5.5x lower latency.
|
| 28 |
+
POOL_CAP = int(os.environ.get("CANLEX_POOL_CAP", "8"))
|
| 29 |
+
# max chunks one capped source (a decision, a memorandum)
|
| 30 |
+
# may hold in the rerank pool. The pool is the cross-
|
| 31 |
+
# encoder's whole world: with RECALL_CAP=20 a deep
|
| 32 |
+
# decision can take 14 of 16 slots (Fearon, 88 paragraphs
|
| 33 |
+
# on cell-phone searches, for a device-search query), so
|
| 34 |
+
# the governing border authorities sitting at fused ranks
|
| 35 |
+
# 19-32 (Canfield, Pike, ENF 12 s. 6.15) are never scored
|
| 36 |
+
# at all -- _diversify runs after the rerank and can only
|
| 37 |
+
# reorder what was scored. Capping while the pool fills
|
| 38 |
+
# backfills those slots with the next sources. Swept
|
| 39 |
+
# 2026-09-06 over {5,6,8,10,12,16,off}: 5 let two
|
| 40 |
+
# incidental FB articles into the top 5 of the
|
| 41 |
+
# discipline question, so _ensure_primary no longer
|
| 42 |
+
# pulled art. 17 in (absent from the top 20); 6-16 keep
|
| 43 |
+
# it at 3 with Canfield at 4 on the device query. 8 is
|
| 44 |
+
# the comfortable middle. Env-sweepable; eval-gate any
|
| 45 |
+
# change.
|
| 46 |
MN_WEIGHT = float(os.environ.get("CANLEX_MN_WEIGHT", "0.0024"))
|
| 47 |
# title-match boost per unit of idf-weighted overlap between
|
| 48 |
# the query and a candidate's marginal note (section title)
|
|
|
|
| 68 |
# inform): a repealed section's on-topic title must not
|
| 69 |
# beat live law in a close contest -- the CDSA-to-Cannabis
|
| 70 |
# -Act migration is the live risk zone
|
| 71 |
+
INAPPLICABLE_PENALTY = float(os.environ.get("CANLEX_INAPPLICABLE_PENALTY", "0.004"))
|
| 72 |
+
# fusion penalty on provisions the corpus's own audience
|
| 73 |
+
# is excluded from (see _INAPPLICABLE): Canada Labour
|
| 74 |
+
# Code Part III does not apply to federal departments
|
| 75 |
+
# (s. 167(1)(d)), yet its hours-of-work sections out-
|
| 76 |
+
# score the FB agreement article that actually governs
|
| 77 |
+
# a shift-change grievance. The chunks stay retrievable
|
| 78 |
+
# and carry an applicability caveat when shown.
|
| 79 |
BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
|
| 80 |
# likewise for a collective agreement's back-matter
|
| 81 |
# (memoranda, letters of understanding) vs its numbered articles
|
|
|
|
| 138 |
# A directive chunk that *is* an appendix: its title opens "Appendix C ...".
|
| 139 |
_APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
|
| 140 |
|
| 141 |
+
# Provisions that are law, but not law binding the corpus's audience --
|
| 142 |
+
# federal public-service (FB) employees. Keyed (act_code, Part prefixes);
|
| 143 |
+
# the value is the caveat rendered with every such chunk. These stay
|
| 144 |
+
# retrievable (the question may be about them) but take INAPPLICABLE_PENALTY
|
| 145 |
+
# in the fusion and can never be handed to a steward as the governing text.
|
| 146 |
+
_INAPPLICABLE = {
|
| 147 |
+
("L-2", ("PART III", "PARTIE III")): (
|
| 148 |
+
"Part III of the Canada Labour Code (standard hours, wages, "
|
| 149 |
+
"vacations, holidays, leave, unjust dismissal) does NOT apply to "
|
| 150 |
+
"federal departments and agencies such as the CBSA -- s. 167(1)(d) "
|
| 151 |
+
"excludes 'a department as defined in the Financial Administration "
|
| 152 |
+
"Act'. For FB employees the governing texts are the FB collective "
|
| 153 |
+
"agreement (hours of work art. 25, leave arts. 34-52, grievances "
|
| 154 |
+
"art. 18) and the FPSLRA; only Part II (occupational health and "
|
| 155 |
+
"safety) of the Code reaches the public service."),
|
| 156 |
+
("P-33.3", ("PART 2.1", "PARTIE 2.1")): (
|
| 157 |
+
"Part 2.1 of the FPSLRA applies only to RCMP members and reservists "
|
| 158 |
+
"(ss. 238.01-238.29). It does not govern FB employees, whose "
|
| 159 |
+
"grievance rights are in Part 2 (ss. 208-238) and the FB collective "
|
| 160 |
+
"agreement art. 18."),
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _applicability(chunk):
|
| 165 |
+
"""The _INAPPLICABLE caveat for a legislation chunk, or None."""
|
| 166 |
+
if chunk.get("doc_type", "legislation") != "legislation":
|
| 167 |
+
return None
|
| 168 |
+
part = (chunk.get("part") or "").upper()
|
| 169 |
+
for (code, prefixes), note in _INAPPLICABLE.items():
|
| 170 |
+
if chunk.get("act_code") == code and part.startswith(prefixes):
|
| 171 |
+
return note
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _piece_no(chunk):
|
| 176 |
+
"""Ordinal of a split section's piece ('-p3' -> 3; unsplit -> 0)."""
|
| 177 |
+
m = _PIECE_ID.search(chunk.get("id", ""))
|
| 178 |
+
return int(m.group()[2:]) if m else 0
|
| 179 |
+
|
| 180 |
|
| 181 |
_STEMMER = snowballstemmer.stemmer("english")
|
| 182 |
_STEM_CACHE = {}
|
|
|
|
| 387 |
self._is_backmatter = []
|
| 388 |
self._is_nif = []
|
| 389 |
self._is_repealed = []
|
| 390 |
+
self._inapplicable = []
|
| 391 |
for c in self.chunks:
|
| 392 |
self._note_tokens.append(set(tokenize(topical_title(c))))
|
| 393 |
+
note = _applicability(c)
|
| 394 |
+
self._inapplicable.append(bool(note))
|
| 395 |
+
if note:
|
| 396 |
+
c["applicability"] = note # rendered by server._format_section
|
| 397 |
self._is_regulation.append(
|
| 398 |
c.get("doc_type", "legislation") == "legislation"
|
| 399 |
and c["act_code"].startswith(("SOR", "C.R.C")))
|
|
|
|
| 719 |
break
|
| 720 |
return out
|
| 721 |
|
| 722 |
+
def _fill_pool(self, candidates):
|
| 723 |
+
"""The rerank pool: the first RERANK_POOL fused candidates, holding
|
| 724 |
+
at most POOL_CAP chunks of any one capped source. Skipped surplus
|
| 725 |
+
frees slots that the next-ranked chunks of OTHER sources backfill,
|
| 726 |
+
so the cross-encoder sees the field, not one deep decision."""
|
| 727 |
+
pool, counts = [], defaultdict(int)
|
| 728 |
+
for idx in candidates:
|
| 729 |
+
key = self._source_key(idx)
|
| 730 |
+
# Pieces of ONE long provision (FB art. 17 is eight) are not a
|
| 731 |
+
# monopoly to break: the reranker must see every piece to find
|
| 732 |
+
# the one that answers, and _diversify shows only two of them
|
| 733 |
+
# anyway. Capping them here lost the discipline article outright.
|
| 734 |
+
if key is not None and key[0] != "piece":
|
| 735 |
+
counts[key] += 1
|
| 736 |
+
if counts[key] > POOL_CAP:
|
| 737 |
+
continue
|
| 738 |
+
pool.append(idx)
|
| 739 |
+
if len(pool) >= RERANK_POOL:
|
| 740 |
+
break
|
| 741 |
+
return pool
|
| 742 |
+
|
| 743 |
def _diversify(self, ordered):
|
| 744 |
"""Reorder so no single case, memorandum, agreement or directive can
|
| 745 |
monopolise the results: once a source has contributed SOURCE_CAP chunks,
|
|
|
|
| 805 |
score -= REG_PENALTY
|
| 806 |
if self._is_backmatter[idx]:
|
| 807 |
score -= BACKMATTER_PENALTY
|
| 808 |
+
if self._inapplicable[idx]:
|
| 809 |
+
score -= INAPPLICABLE_PENALTY
|
| 810 |
return score
|
| 811 |
# Sort by title-match descending, then by original fusion order as
|
| 812 |
# a tiebreak (stable sort: keep the original rest order).
|
|
|
|
| 988 |
fused[idx] -= NIF_PENALTY
|
| 989 |
elif self._is_repealed[idx]:
|
| 990 |
fused[idx] -= REPEALED_PENALTY
|
| 991 |
+
elif self._inapplicable[idx]:
|
| 992 |
+
fused[idx] -= INAPPLICABLE_PENALTY
|
| 993 |
elif self._is_regulation[idx]:
|
| 994 |
fused[idx] -= REG_PENALTY
|
| 995 |
elif self.chunks[idx].get("doc_type") == "memorandum":
|
|
|
|
| 1011 |
# unreliable on long statutory text (it can score the right section
|
| 1012 |
# negative), so its power to demote a candidate is deliberately removed.
|
| 1013 |
if self.reranker:
|
| 1014 |
+
pool = self._fill_pool(candidates)
|
| 1015 |
docs = [self._rerank_doc(i) for i in pool]
|
| 1016 |
ce_orig = self.reranker.score(query, docs)
|
| 1017 |
# A repaired query is scored alongside the original and each
|
|
|
|
| 1034 |
sorted(pool, key=ce.get, reverse=True))}
|
| 1035 |
pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
|
| 1036 |
fusion_rank[i]))
|
| 1037 |
+
in_pool = set(pool)
|
| 1038 |
+
candidates = pool + [i for i in candidates if i not in in_pool]
|
| 1039 |
|
| 1040 |
# Explicit section references are pinned to the very top -- using the
|
| 1041 |
# same Act-mentioned constraint as the recall step above, for the same
|
|
|
|
| 1083 |
fallback = fallback or c
|
| 1084 |
return fallback
|
| 1085 |
|
| 1086 |
+
def get_section_parts(self, act, section, lang="en"):
|
| 1087 |
+
"""Every stored piece of one section, in order -- the chunk
|
| 1088 |
+
get_section returns plus its '-p2', '-p3', ... siblings (same base
|
| 1089 |
+
id, same language). Long provisions are split for embedding, and
|
| 1090 |
+
serving only the first piece silently dropped IRPA s. 36(3) and
|
| 1091 |
+
IRPR s. 18(2): the subsections the screening tools point at.
|
| 1092 |
+
Returns [] when the section is not found."""
|
| 1093 |
+
first = self.get_section(act, section, lang)
|
| 1094 |
+
if first is None:
|
| 1095 |
+
return []
|
| 1096 |
+
base = _PIECE_ID.sub("", first["id"])
|
| 1097 |
+
first_lang = first.get("lang", "en")
|
| 1098 |
+
parts = [c for c in self.chunks
|
| 1099 |
+
if _PIECE_ID.sub("", c.get("id", "")) == base
|
| 1100 |
+
and c.get("lang", "en") == first_lang]
|
| 1101 |
+
return sorted(parts, key=_piece_no)
|
| 1102 |
+
|
| 1103 |
def _build_xref(self):
|
| 1104 |
"""Index legislation by (act, section); find each Act's definitions
|
| 1105 |
section; link every regulation to its enabling Act and every
|
canlex/rehab.py
CHANGED
|
@@ -17,7 +17,19 @@ Pure rule logic, verified against the corpus text of IRPR ss. 17, 18 and 18.1
|
|
| 17 |
Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17):
|
| 18 |
eligible to APPLY 5 years after sentence completion (conviction branches)
|
| 19 |
or 5 years after commission (act branches). Available for serious
|
| 20 |
-
criminality too.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
In-Canada convictions are outside the rehabilitation scheme entirely --
|
| 23 |
the cure is a record suspension (IRPA s. 36(3)(b)) -- EXCEPT the IRPR
|
|
@@ -49,13 +61,41 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
|
|
| 49 |
"recent summary convictions; at most one older summary conviction; no "
|
| 50 |
"other committed acts) -- confirm each condition against the full "
|
| 51 |
"record before relying on deemed rehabilitation.",
|
| 52 |
-
"A temporary resident permit (IRPA s. 24)
|
| 53 |
-
"
|
|
|
|
|
|
|
| 54 |
"Time runs from the day AFTER completion of the ENTIRE sentence -- "
|
| 55 |
"including probation, fines and driving prohibitions -- not from the "
|
| 56 |
"conviction date.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
]
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
if basis == "canada-conviction":
|
| 60 |
if count == "two-plus-summary-only":
|
| 61 |
if years_elapsed >= 5:
|
|
@@ -78,16 +118,26 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
|
|
| 78 |
since = ("sentence completion" if basis == "foreign-conviction"
|
| 79 |
else "the commission of the act")
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if not max_penalty_lt_10:
|
| 82 |
# Serious criminality: deemed rehabilitation is never available.
|
| 83 |
if years_elapsed >= 5:
|
| 84 |
-
return
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
"eligible to APPLY for individual rehabilitation.",
|
| 90 |
-
caveats)
|
| 91 |
return _r("not-yet", "IRPR s. 17",
|
| 92 |
"Serious criminality: deemed rehabilitation is not "
|
| 93 |
"available, and the five-year eligibility period to apply "
|
|
@@ -96,12 +146,11 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
|
|
| 96 |
|
| 97 |
if count == "multiple":
|
| 98 |
if years_elapsed >= 5:
|
| 99 |
-
return
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"rehabilitation has run.", caveats)
|
| 105 |
return _r("not-yet", "IRPR s. 17",
|
| 106 |
"Multiple indictable-equivalent matters: only individual "
|
| 107 |
"rehabilitation is possible, and its five-year eligibility "
|
|
@@ -128,10 +177,9 @@ def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
|
|
| 128 |
why = (f"only {years_elapsed} of {need} years have run"
|
| 129 |
if years_elapsed < need else
|
| 130 |
"the record is not clean, which defeats deemed rehabilitation")
|
| 131 |
-
return
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
"for individual rehabilitation.", caveats)
|
| 135 |
return _r("not-yet", f"{para}; IRPR s. 17",
|
| 136 |
f"Neither deemed rehabilitation ({years_elapsed} of {need} "
|
| 137 |
"years) nor application eligibility (five years) has been "
|
|
|
|
| 17 |
Individual rehabilitation (IRPA s. 36(3)(c), period per IRPR s. 17):
|
| 18 |
eligible to APPLY 5 years after sentence completion (conviction branches)
|
| 19 |
or 5 years after commission (act branches). Available for serious
|
| 20 |
+
criminality too. BUT s. 17 prescribes that period only "if the person has
|
| 21 |
+
not been convicted of a subsequent offence" (other than a contravention or
|
| 22 |
+
a YCJA matter): a later conviction restarts the clock from completion of
|
| 23 |
+
ITS sentence. The tool cannot tell whether the other matters on a record
|
| 24 |
+
pre- or post-date the one being screened, so whenever the record is not
|
| 25 |
+
clean it reports 'apply-conditional' and says what decides it, rather
|
| 26 |
+
than affirming an eligibility s. 17 may deny.
|
| 27 |
+
|
| 28 |
+
Act branch, summary-only acts: IRPA s. 36(1)(c)/(2)(c) reach only an act
|
| 29 |
+
that would be an INDICTABLE offence in Canada. There is no two-summary-acts
|
| 30 |
+
rule -- that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)) -- so two
|
| 31 |
+
or more summary-only committed acts ground no criminal inadmissibility and
|
| 32 |
+
there is nothing to rehabilitate.
|
| 33 |
|
| 34 |
In-Canada convictions are outside the rehabilitation scheme entirely --
|
| 35 |
the cure is a record suspension (IRPA s. 36(3)(b)) -- EXCEPT the IRPR
|
|
|
|
| 61 |
"recent summary convictions; at most one older summary conviction; no "
|
| 62 |
"other committed acts) -- confirm each condition against the full "
|
| 63 |
"record before relying on deemed rehabilitation.",
|
| 64 |
+
"A temporary resident permit (IRPA s. 24) may be available to a "
|
| 65 |
+
"foreign national regardless of rehabilitation status -- at the "
|
| 66 |
+
"officer's discretion and subject to the s. 24(3.1)-(5) bars (e.g. a "
|
| 67 |
+
"failed refugee claimant within 12 months of the claim's rejection).",
|
| 68 |
"Time runs from the day AFTER completion of the ENTIRE sentence -- "
|
| 69 |
"including probation, fines and driving prohibitions -- not from the "
|
| 70 |
"conviction date.",
|
| 71 |
+
"IRPR s. 17 prescribes the five-year application period only 'if the "
|
| 72 |
+
"person has not been convicted of a subsequent offence' (other than a "
|
| 73 |
+
"contravention or a Youth Criminal Justice Act matter): where a later "
|
| 74 |
+
"conviction exists, the five years run from completion of the "
|
| 75 |
+
"sentence for the MOST RECENT conviction, not from the matter being "
|
| 76 |
+
"screened.",
|
| 77 |
]
|
| 78 |
|
| 79 |
+
def _application(provision, lead):
|
| 80 |
+
"""Eligibility to apply under s. 17 -- which a subsequent conviction
|
| 81 |
+
defeats. The record is described by one bool, so a dirty record is
|
| 82 |
+
reported as conditional and the deciding fact is named."""
|
| 83 |
+
if clean_record:
|
| 84 |
+
return _r("eligible-to-apply", provision,
|
| 85 |
+
f"{lead} at least five years have passed since {since}, "
|
| 86 |
+
"so the person is eligible to APPLY for individual "
|
| 87 |
+
"rehabilitation.", caveats)
|
| 88 |
+
return _r("apply-conditional", provision,
|
| 89 |
+
f"{lead} five years have passed since {since} -- but the "
|
| 90 |
+
"record is not clean, and IRPR s. 17 prescribes the "
|
| 91 |
+
"five-year period only 'if the person has not been "
|
| 92 |
+
"convicted of a subsequent offence'. If any other "
|
| 93 |
+
"conviction POSTDATES this matter, eligibility to apply "
|
| 94 |
+
"runs from completion of the sentence for the MOST RECENT "
|
| 95 |
+
"conviction: recompute years_elapsed from that date. If "
|
| 96 |
+
"every other matter PREDATES this one, the person is "
|
| 97 |
+
"eligible to apply now.", caveats)
|
| 98 |
+
|
| 99 |
if basis == "canada-conviction":
|
| 100 |
if count == "two-plus-summary-only":
|
| 101 |
if years_elapsed >= 5:
|
|
|
|
| 118 |
since = ("sentence completion" if basis == "foreign-conviction"
|
| 119 |
else "the commission of the act")
|
| 120 |
|
| 121 |
+
if basis == "foreign-act" and count == "two-plus-summary-only":
|
| 122 |
+
return _r("not-inadmissible", "IRPA s. 36(2)(c)",
|
| 123 |
+
"A committed act grounds inadmissibility only where it "
|
| 124 |
+
"would be an INDICTABLE offence in Canada (IRPA "
|
| 125 |
+
"s. 36(1)(c)/(2)(c)); there is no two-summary-acts rule -- "
|
| 126 |
+
"that rule exists only for CONVICTIONS (s. 36(2)(a)/(b)). "
|
| 127 |
+
"Two or more summary-only committed acts ground no "
|
| 128 |
+
"criminal inadmissibility, so there is nothing to "
|
| 129 |
+
"rehabilitate. Verify that none of the acts has an "
|
| 130 |
+
"indictable (including hybrid) Canadian equivalent.",
|
| 131 |
+
caveats)
|
| 132 |
+
|
| 133 |
if not max_penalty_lt_10:
|
| 134 |
# Serious criminality: deemed rehabilitation is never available.
|
| 135 |
if years_elapsed >= 5:
|
| 136 |
+
return _application(
|
| 137 |
+
"IRPA s. 36(3)(c); IRPR s. 17",
|
| 138 |
+
"The equivalent Canadian offence carries a maximum of 10 "
|
| 139 |
+
"years or more (serious criminality), so deemed "
|
| 140 |
+
"rehabilitation is NOT available --")
|
|
|
|
|
|
|
| 141 |
return _r("not-yet", "IRPR s. 17",
|
| 142 |
"Serious criminality: deemed rehabilitation is not "
|
| 143 |
"available, and the five-year eligibility period to apply "
|
|
|
|
| 146 |
|
| 147 |
if count == "multiple":
|
| 148 |
if years_elapsed >= 5:
|
| 149 |
+
return _application(
|
| 150 |
+
"IRPA s. 36(3)(c); IRPR s. 17",
|
| 151 |
+
"More than one indictable-equivalent conviction/act: deemed "
|
| 152 |
+
"rehabilitation is not available (IRPR s. 18(2) covers only "
|
| 153 |
+
"a single such conviction or act), but")
|
|
|
|
| 154 |
return _r("not-yet", "IRPR s. 17",
|
| 155 |
"Multiple indictable-equivalent matters: only individual "
|
| 156 |
"rehabilitation is possible, and its five-year eligibility "
|
|
|
|
| 177 |
why = (f"only {years_elapsed} of {need} years have run"
|
| 178 |
if years_elapsed < need else
|
| 179 |
"the record is not clean, which defeats deemed rehabilitation")
|
| 180 |
+
return _application(
|
| 181 |
+
f"{para}; IRPR s. 17",
|
| 182 |
+
f"Deemed rehabilitation is not (yet) made out ({why}), but")
|
|
|
|
| 183 |
return _r("not-yet", f"{para}; IRPR s. 17",
|
| 184 |
f"Neither deemed rehabilitation ({years_elapsed} of {need} "
|
| 185 |
"years) nor application eligibility (five years) has been "
|
canlex/server.py
CHANGED
|
@@ -137,6 +137,78 @@ def _guidance_age_note(current_to: str) -> str:
|
|
| 137 |
return ""
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def _format_section(c: dict, related=None) -> str:
|
| 141 |
"""Render one chunk (legislation, D-Memo, or agreement) as cited Markdown."""
|
| 142 |
doc_type = c.get("doc_type", "legislation")
|
|
@@ -145,6 +217,11 @@ def _format_section(c: dict, related=None) -> str:
|
|
| 145 |
lines = [header]
|
| 146 |
if location:
|
| 147 |
lines.append(f"*{location}*")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
if doc_type == "memorandum":
|
| 149 |
lines.append("_CBSA administrative guidance — persuasive, not binding law._")
|
| 150 |
lines.append(f"(modified {_stated(c['current_to'])})")
|
|
@@ -245,7 +322,7 @@ def _format_section(c: dict, related=None) -> str:
|
|
| 245 |
if hl:
|
| 246 |
label, snippet = hl
|
| 247 |
lines.append(f"**Most on point for this query:** "
|
| 248 |
-
f"{c['citation']}{label} — {snippet}")
|
| 249 |
lines.append("")
|
| 250 |
lines.append(c["text"])
|
| 251 |
lines.append("")
|
|
@@ -277,7 +354,7 @@ def _format_section(c: dict, related=None) -> str:
|
|
| 277 |
return "\n".join(lines)
|
| 278 |
|
| 279 |
|
| 280 |
-
# The
|
| 281 |
# as an enum rather than free text (regex 'pattern' constraints are dropped by
|
| 282 |
# several function-calling translators, notably Gemini's).
|
| 283 |
DocType = Literal["legislation", "memorandum", "agreement", "directive",
|
|
@@ -293,8 +370,9 @@ _SECTION_PREFIX = re.compile(
|
|
| 293 |
def _normalize_section(section: str) -> str:
|
| 294 |
"""Accept the forms other tool outputs teach a client to write -- 's. 34',
|
| 295 |
'section 34', '34(1)(c)' -- and reduce them to the bare section number the
|
| 296 |
-
index keys on. Subsection parentheticals are truncated
|
| 297 |
-
|
|
|
|
| 298 |
s = _SECTION_PREFIX.sub("", section.strip())
|
| 299 |
s = s.split("(", 1)[0].strip().rstrip(".")
|
| 300 |
return s or section.strip()
|
|
@@ -320,9 +398,11 @@ def canlex_search_legislation(
|
|
| 320 |
"and operational guidance), 'agreement' (collective agreements), "
|
| 321 |
"'directive' (NJC directives), 'caselaw' (court and tribunal "
|
| 322 |
"decisions), 'delegation' (IRPA/IRPR delegation instruments), "
|
| 323 |
-
"'benefits' (benefit-plan member booklets),
|
| 324 |
"(CanLex's curated analysis datasets, e.g. US dispositions vs the "
|
| 325 |
-
"IRPA 'conviction' concept)
|
|
|
|
|
|
|
| 326 |
court: Annotated[Optional[str], Field(
|
| 327 |
description="Optional: restrict CASE-LAW results to a deciding "
|
| 328 |
"court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court "
|
|
@@ -425,15 +505,13 @@ def canlex_get_section(
|
|
| 425 |
- "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
|
| 426 |
"""
|
| 427 |
index = _index()
|
| 428 |
-
|
| 429 |
-
if
|
| 430 |
-
|
| 431 |
-
if
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
f"or use canlex_search_legislation to locate the provision by topic.")
|
| 436 |
-
return GROUNDING_NOTE + "\n\n" + _format_section(result, index.related(result))
|
| 437 |
|
| 438 |
|
| 439 |
def _load_dispositions():
|
|
@@ -950,10 +1028,9 @@ def canlex_citing(
|
|
| 950 |
if result is None and section != _normalize_section(section):
|
| 951 |
result = index.get_section(act, _normalize_section(section))
|
| 952 |
if result is None:
|
| 953 |
-
return (
|
| 954 |
-
f"for the loaded instruments.")
|
| 955 |
related = index.related(result) or {}
|
| 956 |
-
lines = [f"# Material citing {result['citation']}", ""]
|
| 957 |
memos = related.get("memoranda")
|
| 958 |
if memos:
|
| 959 |
lines.append("**Guidance citing this section** (persuasive, not "
|
|
@@ -1169,10 +1246,14 @@ def us_record_screening(disposition: str, state: str = "",
|
|
| 1169 |
"offence does it equate to, and is the branch serious criminality "
|
| 1170 |
"(s. 36(1)(b)) or criminality (s. 36(2)(b))? Note the maximum "
|
| 1171 |
"penalty — whether it is under 10 years feeds step 3.\n"
|
| 1172 |
-
"STEP 3 — canlex_rehabilitation:
|
| 1173 |
-
"
|
| 1174 |
-
"
|
| 1175 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
"Then give a single bottom-line assessment with every authority "
|
| 1177 |
"cited, every uncertainty flagged, and the reminder that this is "
|
| 1178 |
"legal information, not legal advice, and that an officer's "
|
|
@@ -1221,6 +1302,88 @@ def _res_equivalency_framework() -> str:
|
|
| 1221 |
return "The equivalency dataset is not present in this deployment."
|
| 1222 |
|
| 1223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1224 |
if __name__ == "__main__":
|
| 1225 |
try:
|
| 1226 |
idx = _index()
|
|
@@ -1231,7 +1394,10 @@ if __name__ == "__main__":
|
|
| 1231 |
sys.exit(1)
|
| 1232 |
# CANLEX_HTTP switches on the remote (web) transport; default is local stdio.
|
| 1233 |
if os.environ.get("CANLEX_HTTP"):
|
|
|
|
| 1234 |
print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr)
|
| 1235 |
-
|
|
|
|
|
|
|
| 1236 |
else:
|
| 1237 |
mcp.run()
|
|
|
|
| 137 |
return ""
|
| 138 |
|
| 139 |
|
| 140 |
+
# The '(part k of n)' / '(part k)' marker the ingesters append to a split
|
| 141 |
+
# section's citation or title. Stripped wherever a whole section is served
|
| 142 |
+
# or a pin-cite is built, so the client never emits 'IRPA, s. 36 (part 1 of
|
| 143 |
+
# 2)(1)(c)' as a citation.
|
| 144 |
+
_PART_NOTE = re.compile(
|
| 145 |
+
r"\s*\((?:part|partie)\s+\d+(?:\s+(?:of|de)\s+\d+)?\)", re.IGNORECASE)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _clean_citation(citation: str) -> str:
|
| 149 |
+
return _PART_NOTE.sub("", citation)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _assemble(parts):
|
| 153 |
+
"""One chunk carrying the whole section. Long provisions are stored as
|
| 154 |
+
'-p1', '-p2', ... pieces (IRPA s. 36 is two, IRPR s. 18 three, the CDSA's
|
| 155 |
+
Schedule I ten) and get_section used to serve only the first piece -- so
|
| 156 |
+
IRPA s. 36(3), the hybrid-deeming and rehabilitation rules the screening
|
| 157 |
+
tools point the client at, was unreachable by any tool."""
|
| 158 |
+
if len(parts) == 1:
|
| 159 |
+
return parts[0]
|
| 160 |
+
whole = dict(parts[0])
|
| 161 |
+
whole["citation"] = _clean_citation(parts[0]["citation"])
|
| 162 |
+
whole["marginal_note"] = _PART_NOTE.sub("", parts[0]["marginal_note"])
|
| 163 |
+
whole["text"] = "\n\n".join(p["text"] for p in parts)
|
| 164 |
+
whole["assembled_parts"] = len(parts)
|
| 165 |
+
whole.pop("highlight", None)
|
| 166 |
+
return whole
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _not_found(index, act: str, section: str) -> str:
|
| 170 |
+
"""A get_section/citing miss: name the closest instruments, not the whole
|
| 171 |
+
inventory (~325 names, 195 of them decisions get_section cannot serve --
|
| 172 |
+
the old dump cost ~1.1k tokens per miss and taught the client that
|
| 173 |
+
'Baker' is an Act)."""
|
| 174 |
+
import difflib
|
| 175 |
+
known = {}
|
| 176 |
+
for c in index.chunks:
|
| 177 |
+
if c.get("doc_type", "legislation") == "legislation":
|
| 178 |
+
known.setdefault(c["act_short"], c.get("act_name", ""))
|
| 179 |
+
act_lc = act.strip().lower()
|
| 180 |
+
loaded = any(act_lc in (short.lower(), name.lower())
|
| 181 |
+
for short, name in known.items()) or any(
|
| 182 |
+
act_lc == c["act_code"].lower() for c in index.chunks)
|
| 183 |
+
if loaded:
|
| 184 |
+
return (f"Error: '{act}' is loaded but has no section '{section}'. "
|
| 185 |
+
"Check the number (a subsection form like '36(1)(a)' is "
|
| 186 |
+
"fine -- the whole section is returned), or call "
|
| 187 |
+
f"canlex_toc(act='{act}') to list its sections.")
|
| 188 |
+
# Whole-word overlap first, character similarity second: 'Immigration
|
| 189 |
+
# Act' must find IRPA and the Immigration Division Rules, not whichever
|
| 190 |
+
# short names happen to share letters with it.
|
| 191 |
+
stop = {"act", "the", "and", "of", "on", "regulations", "regs", "rules"}
|
| 192 |
+
q_words = {w for w in re.findall(r"[a-z0-9]+", act_lc) if w not in stop}
|
| 193 |
+
scored = []
|
| 194 |
+
for short, name in known.items():
|
| 195 |
+
words = {w for w in re.findall(r"[a-z0-9]+", f"{short} {name}".lower())
|
| 196 |
+
if w not in stop}
|
| 197 |
+
overlap = (len(q_words & words) / len(q_words)) if q_words else 0.0
|
| 198 |
+
ratio = max(difflib.SequenceMatcher(None, act_lc, short.lower()).ratio(),
|
| 199 |
+
difflib.SequenceMatcher(None, act_lc, name.lower()).ratio())
|
| 200 |
+
score = overlap + ratio
|
| 201 |
+
if score >= 0.8:
|
| 202 |
+
scored.append((score, short))
|
| 203 |
+
shorts = [short for _, short in sorted(scored, reverse=True)[:3]]
|
| 204 |
+
hint = (" Did you mean: " + "; ".join(
|
| 205 |
+
f"{sh} ({known[sh]})" if known[sh] else sh for sh in shorts) + "?"
|
| 206 |
+
if shorts else "")
|
| 207 |
+
return (f"Error: no loaded instrument is named '{act}'.{hint} "
|
| 208 |
+
"canlex_list_acts gives the full inventory; "
|
| 209 |
+
"canlex_search_legislation finds a provision by topic.")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
def _format_section(c: dict, related=None) -> str:
|
| 213 |
"""Render one chunk (legislation, D-Memo, or agreement) as cited Markdown."""
|
| 214 |
doc_type = c.get("doc_type", "legislation")
|
|
|
|
| 217 |
lines = [header]
|
| 218 |
if location:
|
| 219 |
lines.append(f"*{location}*")
|
| 220 |
+
if c.get("assembled_parts", 0) > 1:
|
| 221 |
+
lines.append(f"_Complete text, assembled from "
|
| 222 |
+
f"{c['assembled_parts']} stored parts._")
|
| 223 |
+
if c.get("applicability"):
|
| 224 |
+
lines.append(f"_**⚠ APPLICABILITY:** {c['applicability']}_")
|
| 225 |
if doc_type == "memorandum":
|
| 226 |
lines.append("_CBSA administrative guidance — persuasive, not binding law._")
|
| 227 |
lines.append(f"(modified {_stated(c['current_to'])})")
|
|
|
|
| 322 |
if hl:
|
| 323 |
label, snippet = hl
|
| 324 |
lines.append(f"**Most on point for this query:** "
|
| 325 |
+
f"{_clean_citation(c['citation'])}{label} — {snippet}")
|
| 326 |
lines.append("")
|
| 327 |
lines.append(c["text"])
|
| 328 |
lines.append("")
|
|
|
|
| 354 |
return "\n".join(lines)
|
| 355 |
|
| 356 |
|
| 357 |
+
# The nine doc_type values, as a Literal so every client schema carries them
|
| 358 |
# as an enum rather than free text (regex 'pattern' constraints are dropped by
|
| 359 |
# several function-calling translators, notably Gemini's).
|
| 360 |
DocType = Literal["legislation", "memorandum", "agreement", "directive",
|
|
|
|
| 370 |
def _normalize_section(section: str) -> str:
|
| 371 |
"""Accept the forms other tool outputs teach a client to write -- 's. 34',
|
| 372 |
'section 34', '34(1)(c)' -- and reduce them to the bare section number the
|
| 373 |
+
index keys on. Subsection parentheticals are truncated: the section is
|
| 374 |
+
served whole (every stored part, see _assemble), so '36(3)(a)' resolves
|
| 375 |
+
to the text that contains subsection (3)."""
|
| 376 |
s = _SECTION_PREFIX.sub("", section.strip())
|
| 377 |
s = s.split("(", 1)[0].strip().rstrip(".")
|
| 378 |
return s or section.strip()
|
|
|
|
| 398 |
"and operational guidance), 'agreement' (collective agreements), "
|
| 399 |
"'directive' (NJC directives), 'caselaw' (court and tribunal "
|
| 400 |
"decisions), 'delegation' (IRPA/IRPR delegation instruments), "
|
| 401 |
+
"'benefits' (benefit-plan member booklets), 'commentary' "
|
| 402 |
"(CanLex's curated analysis datasets, e.g. US dispositions vs the "
|
| 403 |
+
"IRPA 'conviction' concept), or 'union' (CIU/PSAC by-laws, "
|
| 404 |
+
"regulations, branch rules, member guides and site VSSAs). Omit to "
|
| 405 |
+
"search all.")] = None,
|
| 406 |
court: Annotated[Optional[str], Field(
|
| 407 |
description="Optional: restrict CASE-LAW results to a deciding "
|
| 408 |
"court/tribunal by name fragment, e.g. 'Supreme', 'Federal Court "
|
|
|
|
| 505 |
- "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
|
| 506 |
"""
|
| 507 |
index = _index()
|
| 508 |
+
parts = index.get_section_parts(act, section)
|
| 509 |
+
if not parts and section != _normalize_section(section):
|
| 510 |
+
parts = index.get_section_parts(act, _normalize_section(section))
|
| 511 |
+
if not parts:
|
| 512 |
+
return _not_found(index, act, section)
|
| 513 |
+
return (GROUNDING_NOTE + "\n\n"
|
| 514 |
+
+ _format_section(_assemble(parts), index.related(parts[0])))
|
|
|
|
|
|
|
| 515 |
|
| 516 |
|
| 517 |
def _load_dispositions():
|
|
|
|
| 1028 |
if result is None and section != _normalize_section(section):
|
| 1029 |
result = index.get_section(act, _normalize_section(section))
|
| 1030 |
if result is None:
|
| 1031 |
+
return _not_found(index, act, section)
|
|
|
|
| 1032 |
related = index.related(result) or {}
|
| 1033 |
+
lines = [f"# Material citing {_clean_citation(result['citation'])}", ""]
|
| 1034 |
memos = related.get("memoranda")
|
| 1035 |
if memos:
|
| 1036 |
lines.append("**Guidance citing this section** (persuasive, not "
|
|
|
|
| 1246 |
"offence does it equate to, and is the branch serious criminality "
|
| 1247 |
"(s. 36(1)(b)) or criminality (s. 36(2)(b))? Note the maximum "
|
| 1248 |
"penalty — whether it is under 10 years feeds step 3.\n"
|
| 1249 |
+
"STEP 3 — canlex_rehabilitation: if step 1 found a conviction (YES "
|
| 1250 |
+
"or LIKELY YES), pass basis='foreign-conviction' with full years "
|
| 1251 |
+
"since completion of the ENTIRE sentence, computed from today's "
|
| 1252 |
+
"date; if step 1 found NO conviction and the act branch is in play, "
|
| 1253 |
+
"pass basis='foreign-act' with full years since the act was "
|
| 1254 |
+
"COMMITTED. Pass the max-penalty answer from step 2, the count, and "
|
| 1255 |
+
"whether the record is otherwise clean (any later conviction makes "
|
| 1256 |
+
"eligibility to apply conditional -- IRPR s. 17).\n\n"
|
| 1257 |
"Then give a single bottom-line assessment with every authority "
|
| 1258 |
"cited, every uncertainty flagged, and the reminder that this is "
|
| 1259 |
"legal information, not legal advice, and that an officer's "
|
|
|
|
| 1302 |
return "The equivalency dataset is not present in this deployment."
|
| 1303 |
|
| 1304 |
|
| 1305 |
+
def _utf8_body(body: bytes):
|
| 1306 |
+
"""(body, changed): the request body re-encoded to UTF-8 when it was not.
|
| 1307 |
+
|
| 1308 |
+
The Space's run log showed a real client posting a Latin-1 body -- 'é'
|
| 1309 |
+
as 0xE9 -- and the MCP transport answering HTTP 500 with a
|
| 1310 |
+
UnicodeDecodeError, i.e. every accented (French) query from that client
|
| 1311 |
+
failed. JSON-RPC bodies are text, so a body that is not valid UTF-8 is
|
| 1312 |
+
re-read as cp1252 (Latin-1's superset on Windows clients) and re-encoded.
|
| 1313 |
+
"""
|
| 1314 |
+
try:
|
| 1315 |
+
body.decode("utf-8")
|
| 1316 |
+
return body, False
|
| 1317 |
+
except UnicodeDecodeError:
|
| 1318 |
+
pass
|
| 1319 |
+
for enc in ("cp1252", "latin-1"):
|
| 1320 |
+
try:
|
| 1321 |
+
return body.decode(enc).encode("utf-8"), True
|
| 1322 |
+
except UnicodeDecodeError:
|
| 1323 |
+
continue
|
| 1324 |
+
return body, False
|
| 1325 |
+
|
| 1326 |
+
|
| 1327 |
+
_seen_clients = set()
|
| 1328 |
+
|
| 1329 |
+
|
| 1330 |
+
def _note_client(body: bytes) -> None:
|
| 1331 |
+
"""One stderr line per distinct MCP client (name + version) at
|
| 1332 |
+
initialize -- the only way to tell which client sent a malformed body."""
|
| 1333 |
+
if b'"initialize"' not in body:
|
| 1334 |
+
return
|
| 1335 |
+
try:
|
| 1336 |
+
info = json.loads(body).get("params", {}).get("clientInfo", {})
|
| 1337 |
+
except Exception:
|
| 1338 |
+
return
|
| 1339 |
+
tag = f"{info.get('name', '?')} {info.get('version', '')}".strip()
|
| 1340 |
+
if tag not in _seen_clients:
|
| 1341 |
+
_seen_clients.add(tag)
|
| 1342 |
+
print(f"CanLex MCP: client {tag}", file=sys.stderr)
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
+
class _Utf8Body:
|
| 1346 |
+
"""ASGI wrapper around the streamable-HTTP app: buffers each POST body,
|
| 1347 |
+
re-encodes it to UTF-8 if needed (see _utf8_body) and logs the client at
|
| 1348 |
+
initialize. Non-POST requests pass straight through."""
|
| 1349 |
+
|
| 1350 |
+
def __init__(self, app):
|
| 1351 |
+
self.app = app
|
| 1352 |
+
|
| 1353 |
+
async def __call__(self, scope, receive, send):
|
| 1354 |
+
if scope["type"] != "http" or scope.get("method") != "POST":
|
| 1355 |
+
await self.app(scope, receive, send)
|
| 1356 |
+
return
|
| 1357 |
+
chunks, queued = [], []
|
| 1358 |
+
while True:
|
| 1359 |
+
message = await receive()
|
| 1360 |
+
if message["type"] != "http.request":
|
| 1361 |
+
queued.append(message) # e.g. http.disconnect
|
| 1362 |
+
break
|
| 1363 |
+
chunks.append(message.get("body", b""))
|
| 1364 |
+
if not message.get("more_body"):
|
| 1365 |
+
break
|
| 1366 |
+
body, changed = _utf8_body(b"".join(chunks))
|
| 1367 |
+
if changed:
|
| 1368 |
+
scope = dict(scope)
|
| 1369 |
+
scope["headers"] = [
|
| 1370 |
+
(k, v) for k, v in scope.get("headers", [])
|
| 1371 |
+
if k.lower() != b"content-length"
|
| 1372 |
+
] + [(b"content-length", str(len(body)).encode())]
|
| 1373 |
+
print("CanLex MCP: re-encoded a non-UTF-8 request body as UTF-8",
|
| 1374 |
+
file=sys.stderr)
|
| 1375 |
+
_note_client(body)
|
| 1376 |
+
replay = [{"type": "http.request", "body": body,
|
| 1377 |
+
"more_body": False}] + queued
|
| 1378 |
+
|
| 1379 |
+
async def receive_replay():
|
| 1380 |
+
if replay:
|
| 1381 |
+
return replay.pop(0)
|
| 1382 |
+
return await receive()
|
| 1383 |
+
|
| 1384 |
+
await self.app(scope, receive_replay, send)
|
| 1385 |
+
|
| 1386 |
+
|
| 1387 |
if __name__ == "__main__":
|
| 1388 |
try:
|
| 1389 |
idx = _index()
|
|
|
|
| 1394 |
sys.exit(1)
|
| 1395 |
# CANLEX_HTTP switches on the remote (web) transport; default is local stdio.
|
| 1396 |
if os.environ.get("CANLEX_HTTP"):
|
| 1397 |
+
import uvicorn
|
| 1398 |
print("CanLex MCP: serving over streamable-HTTP.", file=sys.stderr)
|
| 1399 |
+
uvicorn.run(_Utf8Body(mcp.streamable_http_app()),
|
| 1400 |
+
host=mcp.settings.host, port=mcp.settings.port,
|
| 1401 |
+
log_level=mcp.settings.log_level.lower())
|
| 1402 |
else:
|
| 1403 |
mcp.run()
|
canlex/synonyms.py
CHANGED
|
@@ -50,6 +50,17 @@ _SYNONYMS = [
|
|
| 50 |
(r"danger opinion", "danger to the public refoulement protection"),
|
| 51 |
(r"ministerial relief", "national interest exception inadmissibility"),
|
| 52 |
(r"poe", "port of entry"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
(r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
|
| 54 |
# The single most common US-screening term: bridge DUI shorthand to the
|
| 55 |
# Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
|
|
|
|
| 50 |
(r"danger opinion", "danger to the public refoulement protection"),
|
| 51 |
(r"ministerial relief", "national interest exception inadmissibility"),
|
| 52 |
(r"poe", "port of entry"),
|
| 53 |
+
# Device examinations: officers say 'cell phone' / 'laptop'; Customs Act
|
| 54 |
+
# s. 99 says 'goods' and ENF 12 s. 6.15 'digital devices'. Without the
|
| 55 |
+
# bridge an arrest-context SCC case (Fearon) and two unrelated 'without
|
| 56 |
+
# warrant' marginal notes outrank the governing border authorities.
|
| 57 |
+
(r"(?:cell ?phones?|smart ?phones?|mobile (?:phones?|devices?)|laptops?"
|
| 58 |
+
r"|tablets?|digital devices?|electronic devices?)",
|
| 59 |
+
"examination of goods digital device electronic device"),
|
| 60 |
+
# Money: 'cash', 'USD', '10k' never reach PCMLTFA s. 12's own words
|
| 61 |
+
# ('currency or monetary instruments ... prescribed amount').
|
| 62 |
+
(r"(?:cash|us ?dollars|usd|10 ?k|ten thousand dollars)",
|
| 63 |
+
"currency or monetary instruments report prescribed amount"),
|
| 64 |
(r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
|
| 65 |
# The single most common US-screening term: bridge DUI shorthand to the
|
| 66 |
# Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
|
data/eval/questions.json
CHANGED
|
@@ -457,6 +457,10 @@
|
|
| 457 |
[
|
| 458 |
"Canada Labour Code",
|
| 459 |
"169"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
]
|
| 461 |
],
|
| 462 |
"holdout": true
|
|
@@ -888,6 +892,10 @@
|
|
| 888 |
[
|
| 889 |
"Canada Labour Code",
|
| 890 |
"239"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
]
|
| 892 |
]
|
| 893 |
},
|
|
@@ -2674,5 +2682,71 @@
|
|
| 2674 |
]
|
| 2675 |
],
|
| 2676 |
"style": "french"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2677 |
}
|
| 2678 |
-
]
|
|
|
|
| 457 |
[
|
| 458 |
"Canada Labour Code",
|
| 459 |
"169"
|
| 460 |
+
],
|
| 461 |
+
[
|
| 462 |
+
"FB Agreement",
|
| 463 |
+
"25"
|
| 464 |
]
|
| 465 |
],
|
| 466 |
"holdout": true
|
|
|
|
| 892 |
[
|
| 893 |
"Canada Labour Code",
|
| 894 |
"239"
|
| 895 |
+
],
|
| 896 |
+
[
|
| 897 |
+
"FB Agreement",
|
| 898 |
+
"35"
|
| 899 |
]
|
| 900 |
]
|
| 901 |
},
|
|
|
|
| 2682 |
]
|
| 2683 |
],
|
| 2684 |
"style": "french"
|
| 2685 |
+
},
|
| 2686 |
+
{
|
| 2687 |
+
"query": "can an officer search a traveller's cell phone at the port of entry without a warrant",
|
| 2688 |
+
"answers": [
|
| 2689 |
+
[
|
| 2690 |
+
"Canfield",
|
| 2691 |
+
""
|
| 2692 |
+
],
|
| 2693 |
+
[
|
| 2694 |
+
"Pike",
|
| 2695 |
+
""
|
| 2696 |
+
],
|
| 2697 |
+
[
|
| 2698 |
+
"ENF 12",
|
| 2699 |
+
"ENF 12 s. 6.15"
|
| 2700 |
+
],
|
| 2701 |
+
[
|
| 2702 |
+
"Customs Act",
|
| 2703 |
+
"99"
|
| 2704 |
+
]
|
| 2705 |
+
],
|
| 2706 |
+
"style": "fact-pattern"
|
| 2707 |
+
},
|
| 2708 |
+
{
|
| 2709 |
+
"query": "how much notice does the employer have to give before changing my shift schedule, and what is the premium if less",
|
| 2710 |
+
"answers": [
|
| 2711 |
+
[
|
| 2712 |
+
"FB Agreement",
|
| 2713 |
+
"25"
|
| 2714 |
+
]
|
| 2715 |
+
],
|
| 2716 |
+
"style": "standard"
|
| 2717 |
+
},
|
| 2718 |
+
{
|
| 2719 |
+
"query": "time limit to present a grievance at the first level under the FB collective agreement",
|
| 2720 |
+
"answers": [
|
| 2721 |
+
[
|
| 2722 |
+
"FB Agreement",
|
| 2723 |
+
"18"
|
| 2724 |
+
]
|
| 2725 |
+
],
|
| 2726 |
+
"style": "standard"
|
| 2727 |
+
},
|
| 2728 |
+
{
|
| 2729 |
+
"query": "traveller has USD 9,500 in cash, does it have to be reported and how is the CAD 10,000 threshold converted",
|
| 2730 |
+
"answers": [
|
| 2731 |
+
[
|
| 2732 |
+
"PCMLTFA",
|
| 2733 |
+
"12"
|
| 2734 |
+
],
|
| 2735 |
+
[
|
| 2736 |
+
"Cross-border Currency Reporting Regs",
|
| 2737 |
+
"2"
|
| 2738 |
+
]
|
| 2739 |
+
],
|
| 2740 |
+
"style": "fact-pattern"
|
| 2741 |
+
},
|
| 2742 |
+
{
|
| 2743 |
+
"query": "refugee claimant at the land border arriving from the US: Safe Third Country exceptions for family members",
|
| 2744 |
+
"answers": [
|
| 2745 |
+
[
|
| 2746 |
+
"IRPR",
|
| 2747 |
+
"159.5"
|
| 2748 |
+
]
|
| 2749 |
+
],
|
| 2750 |
+
"style": "fact-pattern"
|
| 2751 |
}
|
| 2752 |
+
]
|
tests/test_rehab.py
CHANGED
|
@@ -19,10 +19,26 @@ class DeemedRehabTests(unittest.TestCase):
|
|
| 19 |
r = assess("foreign-conviction", True, "one", 9, True)
|
| 20 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 21 |
|
| 22 |
-
def
|
|
|
|
|
|
|
|
|
|
| 23 |
r = assess("foreign-conviction", True, "one", 12, False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def test_two_summary_convictions_deemed_after_5_years(self):
|
| 27 |
r = assess("foreign-conviction", True, "two-plus-summary-only", 5, True)
|
| 28 |
self.assertEqual(r["status"], "deemed-rehabilitated")
|
|
@@ -40,6 +56,11 @@ class SeriousCriminalityTests(unittest.TestCase):
|
|
| 40 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 41 |
self.assertIn("17", r["provision"])
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def test_under_5_years_nothing_available(self):
|
| 44 |
r = assess("foreign-conviction", False, "one", 3, True)
|
| 45 |
self.assertEqual(r["status"], "not-yet")
|
|
|
|
| 19 |
r = assess("foreign-conviction", True, "one", 9, True)
|
| 20 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 21 |
|
| 22 |
+
def test_dirty_record_defeats_deemed_and_makes_applying_conditional(self):
|
| 23 |
+
# IRPR s. 17: the five-year period runs only 'if the person has not
|
| 24 |
+
# been convicted of a subsequent offence' -- a dirty record cannot be
|
| 25 |
+
# reported as a clean 'eligible to apply'.
|
| 26 |
r = assess("foreign-conviction", True, "one", 12, False)
|
| 27 |
+
self.assertEqual(r["status"], "apply-conditional")
|
| 28 |
+
self.assertIn("subsequent offence", r["explanation"])
|
| 29 |
+
self.assertTrue(any("subsequent offence" in c for c in r["caveats"]))
|
| 30 |
+
|
| 31 |
+
def test_clean_record_short_of_deemed_is_plainly_eligible(self):
|
| 32 |
+
r = assess("foreign-conviction", True, "one", 7, True)
|
| 33 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 34 |
|
| 35 |
+
def test_two_summary_only_acts_are_not_inadmissible(self):
|
| 36 |
+
# s. 36(2)(c) needs an indictable-equivalent act; the two-summary
|
| 37 |
+
# rule exists only for convictions.
|
| 38 |
+
r = assess("foreign-act", True, "two-plus-summary-only", 2, True)
|
| 39 |
+
self.assertEqual(r["status"], "not-inadmissible")
|
| 40 |
+
self.assertIn("36(2)(c)", r["provision"])
|
| 41 |
+
|
| 42 |
def test_two_summary_convictions_deemed_after_5_years(self):
|
| 43 |
r = assess("foreign-conviction", True, "two-plus-summary-only", 5, True)
|
| 44 |
self.assertEqual(r["status"], "deemed-rehabilitated")
|
|
|
|
| 56 |
self.assertEqual(r["status"], "eligible-to-apply")
|
| 57 |
self.assertIn("17", r["provision"])
|
| 58 |
|
| 59 |
+
def test_serious_criminality_dirty_record_is_conditional(self):
|
| 60 |
+
r = assess("foreign-conviction", False, "one", 30, False)
|
| 61 |
+
self.assertEqual(r["status"], "apply-conditional")
|
| 62 |
+
self.assertIn("MOST RECENT", r["explanation"])
|
| 63 |
+
|
| 64 |
def test_under_5_years_nothing_available(self):
|
| 65 |
r = assess("foreign-conviction", False, "one", 3, True)
|
| 66 |
self.assertEqual(r["status"], "not-yet")
|
tests/test_server.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the MCP tool layer (canlex/server.py).
|
| 2 |
+
|
| 3 |
+
Offline: the tools are exercised against a stub LegislationIndex built via
|
| 4 |
+
__new__ (no corpus, embeddings or reranker), and the ASGI body wrapper is
|
| 5 |
+
driven with hand-made scope/receive callables.
|
| 6 |
+
|
| 7 |
+
python -m unittest discover -s tests
|
| 8 |
+
"""
|
| 9 |
+
import asyncio
|
| 10 |
+
import unittest
|
| 11 |
+
|
| 12 |
+
from canlex import server
|
| 13 |
+
from canlex.index import LegislationIndex
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def chunk(id, section, text, marginal_note="Title", citation=None,
|
| 17 |
+
act_short="IRPA", act_code="I-2.5",
|
| 18 |
+
act_name="Immigration and Refugee Protection Act", **extra):
|
| 19 |
+
c = {"id": id, "doc_type": "legislation", "act_code": act_code,
|
| 20 |
+
"act_short": act_short, "act_name": act_name, "section": section,
|
| 21 |
+
"marginal_note": marginal_note, "part": "", "division": "",
|
| 22 |
+
"heading": "", "text": text, "history": "", "last_amended": "",
|
| 23 |
+
"in_force": "", "status": "in force", "current_to": "2026-03-31",
|
| 24 |
+
"citation": citation or f"{act_short}, s. {section}",
|
| 25 |
+
"source_url": "https://example.test"}
|
| 26 |
+
c.update(extra)
|
| 27 |
+
return c
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def stub_index(chunks):
|
| 31 |
+
idx = LegislationIndex.__new__(LegislationIndex)
|
| 32 |
+
idx.chunks = chunks
|
| 33 |
+
idx.related = lambda c: {}
|
| 34 |
+
return idx
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class GetSectionTests(unittest.TestCase):
|
| 38 |
+
def setUp(self):
|
| 39 |
+
server._INDEX = stub_index([
|
| 40 |
+
chunk("I-2.5-s35", "35", "35 (1) A permanent resident ..."),
|
| 41 |
+
chunk("I-2.5-s36-p1", "36",
|
| 42 |
+
"36 (1) A permanent resident or a foreign national is "
|
| 43 |
+
"inadmissible on grounds of serious criminality ...",
|
| 44 |
+
marginal_note="Serious criminality",
|
| 45 |
+
citation="IRPA, s. 36 (part 1 of 2)"),
|
| 46 |
+
chunk("I-2.5-s36-p2", "36",
|
| 47 |
+
"(3) The following provisions govern subsections (1) and (2): "
|
| 48 |
+
"(a) an offence that may be prosecuted either summarily or by "
|
| 49 |
+
"way of indictment is deemed to be an indictable offence ...",
|
| 50 |
+
marginal_note="Serious criminality",
|
| 51 |
+
citation="IRPA, s. 36 (part 2 of 2)"),
|
| 52 |
+
])
|
| 53 |
+
|
| 54 |
+
def tearDown(self):
|
| 55 |
+
server._INDEX = None
|
| 56 |
+
|
| 57 |
+
def test_every_stored_part_is_served(self):
|
| 58 |
+
out = server.canlex_get_section("IRPA", "36(3)(a)")
|
| 59 |
+
self.assertIn("(3) The following provisions govern", out)
|
| 60 |
+
self.assertIn("assembled from 2 stored parts", out)
|
| 61 |
+
self.assertIn("### IRPA, s. 36 — Serious criminality", out)
|
| 62 |
+
self.assertNotIn("(part 1 of 2)", out)
|
| 63 |
+
|
| 64 |
+
def test_unsplit_section_is_unchanged(self):
|
| 65 |
+
out = server.canlex_get_section("IRPA", "s. 35")
|
| 66 |
+
self.assertIn("### IRPA, s. 35", out)
|
| 67 |
+
self.assertNotIn("assembled", out)
|
| 68 |
+
|
| 69 |
+
def test_unknown_act_gets_closest_match_not_the_inventory(self):
|
| 70 |
+
out = server.canlex_get_section("Immigration Act", "34")
|
| 71 |
+
self.assertIn("Did you mean", out)
|
| 72 |
+
self.assertIn("IRPA", out)
|
| 73 |
+
self.assertNotIn("Loaded Acts:", out)
|
| 74 |
+
|
| 75 |
+
def test_known_act_missing_section_says_so(self):
|
| 76 |
+
out = server.canlex_get_section("IRPA", "999")
|
| 77 |
+
self.assertIn("is loaded but has no section '999'", out)
|
| 78 |
+
self.assertIn("canlex_toc", out)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class CitationCleaningTests(unittest.TestCase):
|
| 82 |
+
def test_part_marker_is_stripped(self):
|
| 83 |
+
self.assertEqual(server._clean_citation("IRPA, s. 36 (part 1 of 2)"),
|
| 84 |
+
"IRPA, s. 36")
|
| 85 |
+
self.assertEqual(server._clean_citation("IRPA, art. 36 (partie 2 de 2)"),
|
| 86 |
+
"IRPA, art. 36")
|
| 87 |
+
self.assertEqual(server._clean_citation("FB Agreement, art. 25"),
|
| 88 |
+
"FB Agreement, art. 25")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _run(app, scope, messages):
|
| 92 |
+
seen = {}
|
| 93 |
+
|
| 94 |
+
async def inner(scope, receive, send):
|
| 95 |
+
msg = await receive()
|
| 96 |
+
seen["body"] = msg["body"]
|
| 97 |
+
seen["headers"] = dict(scope.get("headers", []))
|
| 98 |
+
seen["next"] = await receive()
|
| 99 |
+
|
| 100 |
+
async def receive():
|
| 101 |
+
return messages.pop(0)
|
| 102 |
+
|
| 103 |
+
asyncio.run(app(inner)(scope, receive, None))
|
| 104 |
+
return seen
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Utf8BodyTests(unittest.TestCase):
|
| 108 |
+
def test_latin1_body_is_reencoded_and_length_fixed(self):
|
| 109 |
+
body = "détention à la frontière".encode("latin-1")
|
| 110 |
+
scope = {"type": "http", "method": "POST",
|
| 111 |
+
"headers": [(b"content-length", str(len(body)).encode())]}
|
| 112 |
+
seen = _run(server._Utf8Body, scope, [
|
| 113 |
+
{"type": "http.request", "body": body, "more_body": False},
|
| 114 |
+
{"type": "http.disconnect"}])
|
| 115 |
+
text = seen["body"].decode("utf-8")
|
| 116 |
+
self.assertEqual(text, "détention à la frontière")
|
| 117 |
+
self.assertEqual(seen["headers"][b"content-length"],
|
| 118 |
+
str(len(text.encode("utf-8"))).encode())
|
| 119 |
+
self.assertEqual(seen["next"]["type"], "http.disconnect")
|
| 120 |
+
|
| 121 |
+
def test_utf8_body_passes_untouched_across_chunks(self):
|
| 122 |
+
body = "détention".encode("utf-8")
|
| 123 |
+
scope = {"type": "http", "method": "POST",
|
| 124 |
+
"headers": [(b"content-length", str(len(body)).encode())]}
|
| 125 |
+
seen = _run(server._Utf8Body, scope, [
|
| 126 |
+
{"type": "http.request", "body": body[:3], "more_body": True},
|
| 127 |
+
{"type": "http.request", "body": body[3:], "more_body": False},
|
| 128 |
+
{"type": "http.disconnect"}])
|
| 129 |
+
self.assertEqual(seen["body"], body)
|
| 130 |
+
|
| 131 |
+
def test_get_requests_pass_straight_through(self):
|
| 132 |
+
calls = []
|
| 133 |
+
|
| 134 |
+
async def inner(scope, receive, send):
|
| 135 |
+
calls.append(receive)
|
| 136 |
+
|
| 137 |
+
async def receive():
|
| 138 |
+
raise AssertionError("must not be read by the wrapper")
|
| 139 |
+
|
| 140 |
+
asyncio.run(server._Utf8Body(inner)(
|
| 141 |
+
{"type": "http", "method": "GET", "headers": []}, receive, None))
|
| 142 |
+
self.assertIs(calls[0], receive)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
unittest.main()
|