Squash history to reclaim LFS storage (1 GB cap); tree unchanged
Browse filesCo-authored-by: Beemer <Beemer@users.noreply.huggingface.co>
Co-authored-by: claude fable 5 <claude fable 5@users.noreply.huggingface.co>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +18 -0
- .gitattributes +2 -0
- .github/workflows/ci.yml +26 -0
- .gitignore +9 -0
- .mcp.json +11 -0
- Dockerfile +58 -0
- README.md +56 -0
- RUNBOOK.md +230 -0
- canlex/__init__.py +1 -0
- canlex/_common.py +198 -0
- canlex/agreement.py +196 -0
- canlex/amps.py +154 -0
- canlex/benefits.py +346 -0
- canlex/caselaw.py +1279 -0
- canlex/charter.py +119 -0
- canlex/citator.py +175 -0
- canlex/ciu.py +239 -0
- canlex/commentary.py +272 -0
- canlex/config.py +336 -0
- canlex/delegation.py +506 -0
- canlex/directive.py +298 -0
- canlex/dmemo.py +368 -0
- canlex/embed.py +165 -0
- canlex/enf.py +295 -0
- canlex/eval.py +171 -0
- canlex/index.py +1079 -0
- canlex/ingest.py +481 -0
- canlex/irb_guidelines.py +149 -0
- canlex/loganalyze.py +81 -0
- canlex/pdi.py +250 -0
- canlex/refresh.py +753 -0
- canlex/rehab.py +143 -0
- canlex/rerank.py +119 -0
- canlex/server.py +1237 -0
- canlex/sweep.py +132 -0
- canlex/synonyms.py +125 -0
- canlex/tariff_schedule.py +285 -0
- data/curated/drafts/equivalency-pairings-draft.json +52 -0
- data/curated/us_dispositions.json +0 -0
- data/curated/us_equivalency.json +285 -0
- data/eval/questions.json +2042 -0
- data/eval/sweep.json +272 -0
- data/eval/sweep.log +33 -0
- data/processed/A-8.8-fr.json +942 -0
- data/processed/A-8.8.json +933 -0
- data/processed/C-1.4-fr.json +0 -0
- data/processed/C-1.4.json +743 -0
- data/processed/C-24.5-fr.json +0 -0
- data/processed/C-24.5.json +0 -0
- data/processed/C-29-fr.json +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the build context small and keep secrets / regenerated files out.
|
| 2 |
+
.git/
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
__pycache__/
|
| 6 |
+
**/__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
|
| 9 |
+
# Raw downloads are not needed -- only data/processed/ is.
|
| 10 |
+
data/raw/
|
| 11 |
+
|
| 12 |
+
# Local-only: the CanLII key (injected as a runtime secret) and disk caches.
|
| 13 |
+
canlii_key.txt
|
| 14 |
+
data/citator_dbmap.json
|
| 15 |
+
data/citator_cache.json
|
| 16 |
+
|
| 17 |
+
# Local MCP client config; not used by the remote server.
|
| 18 |
+
.mcp.json
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data/processed/caselaw.json filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
data/processed/embeddings.npz filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CI for the GitHub mirror (create the mirror per RUNBOOK.md; this workflow
|
| 2 |
+
# activates the moment the repo exists). Runs the offline test suite and the
|
| 3 |
+
# curated-dataset schema validation on every push -- the cheap failures that
|
| 4 |
+
# should never reach the production Space. The full retrieval eval needs the
|
| 5 |
+
# ONNX models and LFS embeddings, so it stays a manual/scheduled job.
|
| 6 |
+
name: tests
|
| 7 |
+
on:
|
| 8 |
+
push:
|
| 9 |
+
branches: [main]
|
| 10 |
+
pull_request:
|
| 11 |
+
workflow_dispatch:
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
offline-tests:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v4
|
| 18 |
+
with:
|
| 19 |
+
lfs: false # tests are offline; embeddings not needed
|
| 20 |
+
- uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: "3.12"
|
| 23 |
+
- name: Install minimal deps (no ONNX/model downloads)
|
| 24 |
+
run: pip install snowballstemmer pydantic "mcp>=1.0" beautifulsoup4 pypdf
|
| 25 |
+
- name: Offline test suite
|
| 26 |
+
run: python -m unittest discover -s tests -v
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
data/raw/
|
| 6 |
+
canlii_key.txt
|
| 7 |
+
data/citator_*.json
|
| 8 |
+
US-Dispositions-Reference.pdf
|
| 9 |
+
*.bundle
|
.mcp.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mcpServers": {
|
| 3 |
+
"canlex": {
|
| 4 |
+
"command": "C:\\projects\\CanLex\\.venv\\Scripts\\python.exe",
|
| 5 |
+
"args": ["C:\\projects\\CanLex\\canlex\\server.py"],
|
| 6 |
+
"env": {
|
| 7 |
+
"CANLEX_LOG_DIR": "C:\\projects\\CanLex\\data\\logs"
|
| 8 |
+
}
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CanLex MCP server -- remote (streamable-HTTP) image.
|
| 2 |
+
#
|
| 3 |
+
# Builds anywhere: Hugging Face Spaces, Google Cloud Run, Fly.io, plain Docker.
|
| 4 |
+
# Retrieval is fully local and key-free; the optional CanLII case citator reads
|
| 5 |
+
# its key from the CANLII_API_KEY environment variable (supplied as a host
|
| 6 |
+
# secret -- the key is never copied into the image).
|
| 7 |
+
FROM python:3.12-slim
|
| 8 |
+
|
| 9 |
+
# libgomp1 is the OpenMP runtime that onnxruntime (the reranker) links against.
|
| 10 |
+
RUN apt-get update \
|
| 11 |
+
&& apt-get install -y --no-install-recommends libgomp1 \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Run as a non-root user (UID 1000) -- required by Hugging Face Spaces.
|
| 15 |
+
RUN useradd --create-home --home-dir /app --uid 1000 app
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Python dependencies first, so this layer caches across code changes.
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Application code, the processed corpus (section-chunk JSON), and the prebuilt
|
| 23 |
+
# semantic embeddings (embeddings.npz, committed via git-LFS). Shipping the
|
| 24 |
+
# embeddings as an artifact keeps the build fast: embedding ~20k chunks on the
|
| 25 |
+
# build host took ~33 min and was tripping Hugging Face's job timeout as the
|
| 26 |
+
# corpus grew. Regenerate with 'python -m canlex.embed' after any corpus change
|
| 27 |
+
# and commit the result.
|
| 28 |
+
COPY --chown=app:app canlex/ ./canlex/
|
| 29 |
+
COPY --chown=app:app data/processed/*.json ./data/processed/
|
| 30 |
+
COPY --chown=app:app data/processed/embeddings.npz ./data/processed/
|
| 31 |
+
# Curated commentary datasets (the canlex_us_disposition tool reads the
|
| 32 |
+
# structured source file directly, not just its processed chunks).
|
| 33 |
+
COPY --chown=app:app data/curated/ ./data/curated/
|
| 34 |
+
|
| 35 |
+
USER app
|
| 36 |
+
# CANLEX_LOG_DIR turns on the opt-in query log (query text + outcome only;
|
| 37 |
+
# loganalyze.py mines it for corpus gaps). On the free tier this path is
|
| 38 |
+
# ephemeral -- lost on rebuild/sleep -- so treat it as best-effort telemetry;
|
| 39 |
+
# the durable log is the local server's (see .mcp.json).
|
| 40 |
+
ENV HOME=/app \
|
| 41 |
+
HF_HOME=/app/.hf_cache \
|
| 42 |
+
CANLEX_HTTP=1 \
|
| 43 |
+
CANLEX_LOG_DIR=/tmp/canlex-logs \
|
| 44 |
+
PORT=7860 \
|
| 45 |
+
PYTHONUNBUFFERED=1
|
| 46 |
+
|
| 47 |
+
# Pre-fetch the embedder and cross-encoder models so the cache is baked into the
|
| 48 |
+
# image and the first request needs no network. The corpus is NOT re-embedded
|
| 49 |
+
# here -- the committed embeddings.npz is used as-is; only the bge query model
|
| 50 |
+
# (for embedding live queries) and the reranker are downloaded.
|
| 51 |
+
RUN python -c "from canlex.embed import Embedder; Embedder()" \
|
| 52 |
+
&& python -c "from canlex.rerank import Reranker; Reranker()"
|
| 53 |
+
|
| 54 |
+
# From here on, model files are served from the baked cache, never fetched.
|
| 55 |
+
ENV HF_HUB_OFFLINE=1
|
| 56 |
+
|
| 57 |
+
EXPOSE 7860
|
| 58 |
+
CMD ["python", "-m", "canlex.server"]
|
README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CanLex MCP
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
pinned: false
|
| 6 |
+
---
|
| 7 |
+
|
| 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. The
|
| 12 |
+
legislation corpus is **bilingual**: every Act and regulation also carries its
|
| 13 |
+
parallel French text, and a French-language query searches both languages
|
| 14 |
+
automatically. Ten read-only tools over streamable HTTP (or local stdio):
|
| 15 |
+
|
| 16 |
+
- **canlex_search_legislation** — hybrid (BM25 + semantic) search with a
|
| 17 |
+
cross-encoder reranker over the whole corpus: federal border, customs,
|
| 18 |
+
criminal, firearms, drug, food/health, labour and privacy legislation with
|
| 19 |
+
their Schedules; the ENF operational manuals; CBSA D-Memoranda and the AMPS
|
| 20 |
+
Master Penalty Document; the Charter; Treasury Board collective agreements;
|
| 21 |
+
NJC directives; ~110 leading decisions; delegation instruments;
|
| 22 |
+
benefit-plan booklets; and CanLex's curated commentary.
|
| 23 |
+
- **canlex_get_section** — fetch one provision verbatim by Act and section
|
| 24 |
+
(accepts 's. 34', '34(1)(c)', 'Schedule I').
|
| 25 |
+
- **canlex_us_disposition** — step 1 of the IRPA s. 36 screening chain: is a
|
| 26 |
+
US disposition a "conviction"? 51-jurisdiction state-by-state dataset.
|
| 27 |
+
- **canlex_us_equivalency** — step 2: the Canadian equivalent offence, its
|
| 28 |
+
verified maximum penalty, and the inadmissibility branch.
|
| 29 |
+
- **canlex_rehabilitation** — step 3: deemed rehabilitation / eligibility to
|
| 30 |
+
apply, under IRPA s. 36(3)(c) and IRPR ss. 17–18.1.
|
| 31 |
+
- **canlex_list_acts** — the corpus inventory and its currency.
|
| 32 |
+
- **canlex_whats_new** — provisions amended or newly in force since a date.
|
| 33 |
+
- **canlex_citing** — reverse cross-references: what cites a given section.
|
| 34 |
+
- **canlex_toc** — an Act's table of contents (Parts and marginal notes).
|
| 35 |
+
- **canlex_case** — live CanLII case citator (citation graph by neutral
|
| 36 |
+
citation or URL).
|
| 37 |
+
|
| 38 |
+
The server also publishes an MCP prompt (`us_record_screening`, the guided
|
| 39 |
+
three-step screening flow) and resources (the corpus inventory and the
|
| 40 |
+
conviction/equivalency methodology texts) that any MCP client can use.
|
| 41 |
+
|
| 42 |
+
The labour side now carries the full union stack alongside the FB collective
|
| 43 |
+
agreement: the CIU National By-Laws (EN/FR), CIU Regulations, Branch Rules
|
| 44 |
+
and local modifications, the Branch Elections Kit, the PSAC Constitution,
|
| 45 |
+
the union's member guides, and every published site VSSA — all bannered as
|
| 46 |
+
bargaining-agent documents, distinct from employer policy.
|
| 47 |
+
|
| 48 |
+
Retrieval runs fully locally inside the container and needs no API key. The
|
| 49 |
+
optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
|
| 50 |
+
variable. A weekly scheduled staleness check watches upstream sources for
|
| 51 |
+
drift; see RUNBOOK.md for the maintainer's refresh choreography.
|
| 52 |
+
|
| 53 |
+
Add this Space's `/mcp` URL as a custom connector in any Claude client, or
|
| 54 |
+
point any MCP-capable agent (Gemini in Antigravity, etc.) at it. A companion
|
| 55 |
+
Gradio webapp (`webapp/`, deployed as its own Space) gives the same corpus a
|
| 56 |
+
Gemini-powered agentic chat front-end.
|
RUNBOOK.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
|
| 12 |
+
py -m canlex.pdi # IRCC PDI trees (TR / refugee / standard /
|
| 13 |
+
# identity / PR-card; capped BFS, drops logged)
|
| 14 |
+
py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
|
| 15 |
+
py -m canlex.dmemo [--fr] # CBSA D-Memoranda (EN; --fr = Mémorandums D)
|
| 16 |
+
py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
|
| 17 |
+
py -m canlex.amps # AMPS Master Penalty Document
|
| 18 |
+
py -m canlex.charter # Charter / Constitution HTML
|
| 19 |
+
py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
|
| 20 |
+
py -m canlex.delegation # CBSA/IRCC delegation instruments
|
| 21 |
+
py -m canlex.directive [--fr] # NJC directives (EN; --fr = éditions françaises)
|
| 22 |
+
py -m canlex.agreement # FB collective agreement
|
| 23 |
+
py -m canlex.benefits # benefit-plan booklets
|
| 24 |
+
py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
|
| 25 |
+
py -m canlex.commentary # curated datasets -> commentary chunks
|
| 26 |
+
py -m canlex.embed # re-embed EVERYTHING (run after ANY of the above)
|
| 27 |
+
py -m unittest discover -s tests # all offline
|
| 28 |
+
py -m canlex.eval # gate: no metric below the last committed run
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
`canlex.embed` is mandatory after any chunk change: the index zero-fills
|
| 32 |
+
vectors for chunks missing from embeddings.npz and quietly degrades them to
|
| 33 |
+
BM25-only (a stderr warning is the only signal).
|
| 34 |
+
|
| 35 |
+
### The corpus-write guard
|
| 36 |
+
|
| 37 |
+
Every ingester above rebuilds its whole processed file from a fresh scrape, so
|
| 38 |
+
an upstream page that changes shape turns into data loss at the write. All of
|
| 39 |
+
them now go through `_common.write_corpus`, which **refuses to write a corpus
|
| 40 |
+
below 80% of the stored one** and makes the process exit non-zero:
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
REFUSING to write: 0 chunks would replace 1870 already in dmemos.json. ...
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
That is not a transient error — it means the scrape broke. Diagnose it, don't
|
| 47 |
+
retry. Once the drop is understood and intended (a source genuinely shrank),
|
| 48 |
+
re-run the same module with `--allow-shrink`. Because the runbook chains these
|
| 49 |
+
as `py -m canlex.X && py -m canlex.embed`, a refusal now correctly stops the
|
| 50 |
+
embed instead of propagating a gutted corpus.
|
| 51 |
+
|
| 52 |
+
Most ingesters also **preserve on failure**: an item that errors keeps its
|
| 53 |
+
last-good chunks (`_common.preserve_dropped`), restricted to items that
|
| 54 |
+
actually failed, so anything removed on purpose stays gone. Deliberate
|
| 55 |
+
exception: `enf.py` treats an HTTP 404 as IRCC retiring a chapter — its
|
| 56 |
+
documented policy — so a 404 drops the chapter while any other error preserves
|
| 57 |
+
it. Writes are atomic (tmp + `os.replace`): a killed run cannot leave truncated
|
| 58 |
+
JSON that would read as an empty corpus and disable the guard next time.
|
| 59 |
+
|
| 60 |
+
## Deploys
|
| 61 |
+
|
| 62 |
+
- **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
|
| 63 |
+
The Space rebuilds automatically. `embeddings.npz` ships in-repo via
|
| 64 |
+
git-LFS — a fresh clone needs `git lfs install`.
|
| 65 |
+
- **Webapp Space** (`Beemer0/Canlex-web`): separate repo. Clone it, copy
|
| 66 |
+
`webapp/app.py` + `webapp/requirements.txt` + `webapp/Dockerfile` +
|
| 67 |
+
`webapp/README.md` over, commit, push. Do NOT use `hf upload` — HF gates
|
| 68 |
+
it behind PRO for Docker/Gradio Spaces; git pushes work on the free tier.
|
| 69 |
+
|
| 70 |
+
## Secrets and variables (set in each Space's settings)
|
| 71 |
+
|
| 72 |
+
| Where | Name | Purpose |
|
| 73 |
+
|---|---|---|
|
| 74 |
+
| MCP Space | `CANLII_API_KEY` | optional, enables canlex_case |
|
| 75 |
+
| Webapp | `GEMINI_API_KEY` | required |
|
| 76 |
+
| Webapp | `CANLEX_WEB_AUTH` | required (`user:pass` per line); the app refuses to start without it |
|
| 77 |
+
| Webapp | `CANLEX_GEMINI_MODEL` | optional; the deployed Space sets `gemini-2.5-pro` (code default is flash) |
|
| 78 |
+
|
| 79 |
+
Locally, the CanLII key lives in `canlii_key.txt` (gitignored — never
|
| 80 |
+
commit it).
|
| 81 |
+
|
| 82 |
+
## The staleness watch
|
| 83 |
+
|
| 84 |
+
`py -m canlex.refresh` (alias `--check`) diffs upstream: Justice Laws
|
| 85 |
+
consolidation dates, CBSA/IRCC delegation instruments, NJC directive dates,
|
| 86 |
+
the newest per-memo `LAST_UPDATED` in the D-memo datasource, curated-dataset
|
| 87 |
+
age (>180 days → AGING) and curated-vs-chunk regen drift. Non-zero exit =
|
| 88 |
+
something drifted.
|
| 89 |
+
A scheduled task runs it Mondays 03:01 local — if this machine is off, the
|
| 90 |
+
check silently doesn't happen that week. Note: network fetch errors on the
|
| 91 |
+
legislation rows DO fail the check (exit 1) so a broken watch can't pass
|
| 92 |
+
silently.
|
| 93 |
+
|
| 94 |
+
`_PENDING_BILLS` in canlex/refresh.py names bills heading into force that
|
| 95 |
+
affect corpus Acts — edit that list when a new one appears (source-code edit,
|
| 96 |
+
deliberate). A bill stays listed after its in-force date until Justice Laws
|
| 97 |
+
consolidates it: C-16 came into force 2026-07-18 but the 2026-06-14
|
| 98 |
+
consolidation predates it, so the coercive-control provisions are NOT in the
|
| 99 |
+
corpus yet and the notice correctly keeps printing.
|
| 100 |
+
|
| 101 |
+
## The CBSA D-memo index (rebuilt 2026-07-10)
|
| 102 |
+
|
| 103 |
+
CBSA replaced the static index with a WET DataTable: the page ships **no memo
|
| 104 |
+
links at all**, the rows come from `dmemoranda-datasource-eng.json` beside it.
|
| 105 |
+
`dmemo.memo_urls` reads that datasource and falls back to scraping the page
|
| 106 |
+
only if it disappears. Two consequences worth knowing:
|
| 107 |
+
|
| 108 |
+
- Cancelled memos (`STATUS: "Cancelled"`, 34 of 398) are skipped — retired
|
| 109 |
+
guidance must not retrieve as current. D10-14-48 left the corpus this way.
|
| 110 |
+
- Eight memos are listed Active but their pages 404 (D8-2-2, D8-9-3, D10-2-3,
|
| 111 |
+
D10-14-3, D10-14-18, D10-14-65, D10-15-16, D10-15-25) — CBSA's own broken
|
| 112 |
+
links. `dmemo.preserve_failed` re-attaches the last-good chunks of any memo
|
| 113 |
+
that fails to fetch, so a 404 (or a dropped connection) can no longer delete
|
| 114 |
+
still-in-force guidance. The two D8s were never ingestable and are absent.
|
| 115 |
+
|
| 116 |
+
This is the incident the write guard above exists for: the first run after the
|
| 117 |
+
rebuild scraped zero memos and wrote an empty dmemos.json straight over 1,870
|
| 118 |
+
good chunks, recovered from git. Every ingester now carries the guard.
|
| 119 |
+
|
| 120 |
+
## Curated-dataset review protocol
|
| 121 |
+
|
| 122 |
+
`data/curated/us_dispositions.json` and `us_equivalency.json` are the only
|
| 123 |
+
hand-maintained legal content. To change them: edit → bump `"reviewed"` →
|
| 124 |
+
`py -m canlex.commentary` → `py -m canlex.embed` → eval → deploy. The refresh
|
| 125 |
+
check flags any drift between the curated `reviewed` date and the rendered
|
| 126 |
+
chunks. Provenance and the verbatim-verification log live inside the JSON
|
| 127 |
+
files themselves; keep them current.
|
| 128 |
+
|
| 129 |
+
## Backups
|
| 130 |
+
|
| 131 |
+
The HF Space remote is also production. A full-history bundle lives at
|
| 132 |
+
`C:\projects\CanLex-backup.bundle` (recreate: `git bundle create
|
| 133 |
+
../CanLex-backup.bundle --all`). For a real offsite mirror, create a private
|
| 134 |
+
GitHub repo and:
|
| 135 |
+
|
| 136 |
+
```
|
| 137 |
+
git remote add mirror https://github.com/<you>/CanLex.git
|
| 138 |
+
git push mirror --all
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
(GitHub needs git-lfs enabled for embeddings.npz, or add it to a
|
| 142 |
+
`.lfsconfig`-excluded push.)
|
| 143 |
+
|
| 144 |
+
## Known gaps / parked
|
| 145 |
+
|
| 146 |
+
- ENF coverage (researched and URL-verified 2026-07-22): ENF 20 Detention
|
| 147 |
+
and the current ENF 3 edition live under non-standard filenames — handled
|
| 148 |
+
by `_URL_OVERRIDES` in canlex/enf.py (the old enf03-eng.pdf still serves a
|
| 149 |
+
STALE 2015-era copy; never remove the override). ENF 7 was never publicly
|
| 150 |
+
released; ENF 13/16/25 never existed; ENF 9 (Judicial Reviews) and ENF 11
|
| 151 |
+
(Verifying Departure) are retired with no successor (Wayback copies exist
|
| 152 |
+
but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
|
| 153 |
+
replaced by the PDI section at
|
| 154 |
+
operational-bulletins-manuals/permanent-residence/card.html — ingested by
|
| 155 |
+
`canlex.pdi` (the `pdi-prcard` set). ENF 2 (Evaluating Inadmissibility)
|
| 156 |
+
and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
|
| 157 |
+
ATIP-released copies hosted by a private law firm — NOT ingested pending
|
| 158 |
+
the maintainer's call on unofficial sources; the Tran PDI page now comes
|
| 159 |
+
in with the whole `standard-requirements` tree and partially covers
|
| 160 |
+
ENF 2's ground.
|
| 161 |
+
- PDI scope: `canlex.pdi` crawls six category trees breadth-first
|
| 162 |
+
(`_MAX_DEPTH` 2, `_MAX_PAGES` 160 per tree). Deliberately out of scope:
|
| 163 |
+
citizenship, fees, office service delivery, and the year-by-year
|
| 164 |
+
operational-bulletin archives (mostly expired instructions). If a tree
|
| 165 |
+
ever hits the page cap the run PRINTS how many in-scope pages it dropped
|
| 166 |
+
— raise `_MAX_PAGES` rather than letting the silence read as coverage.
|
| 167 |
+
- CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
|
| 168 |
+
2026-07-22): open the URL a missing-file stub prints in a real browser,
|
| 169 |
+
copy the page text to the named `data/raw/caselaw/*.txt`, re-run
|
| 170 |
+
`py -m canlex.caselaw && py -m canlex.embed`.
|
| 171 |
+
- Dag (FCA) parses to 2 chunks because it genuinely is a 9-paragraph bench
|
| 172 |
+
judgment — correct, not a bug.
|
| 173 |
+
- A weekly citator sweep over the ~110 corpus decisions (negative-treatment
|
| 174 |
+
detection) was considered and deliberately skipped: ~6 minutes of
|
| 175 |
+
rate-limited CanLII API per run for marginal signal. Run `canlex_case` on
|
| 176 |
+
a decision before relying on it instead.
|
| 177 |
+
- French support is now phase 3: the full parallel French legislation corpus
|
| 178 |
+
is ingested (`--fr`; lang='fr' chunks, '-fr-' id infix, 'art.'/'annexe'
|
| 179 |
+
citations). Retrieval is language-routed: English queries never see French
|
| 180 |
+
chunks; French queries (detected by accents or French function words)
|
| 181 |
+
search both languages. get_section/xref/list_acts stay English-canonical.
|
| 182 |
+
Phase 2 (a multilingual embedder) was tried and REJECTED 2026-07-22 —
|
| 183 |
+
regressed every slice and broke confidence calibration; don't retry
|
| 184 |
+
without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
|
| 185 |
+
published bilingually) is the remaining bilingual frontier.
|
| 186 |
+
- ENF 2 and ENF 14/OP 19 are both ingested from their ATIP releases
|
| 187 |
+
(authorized 2026-07-23, bannered UNOFFICIAL on every chunk). ENF 14's
|
| 188 |
+
image-scan PDF was recovered by OCR (pymupdf rasterization + Windows OCR;
|
| 189 |
+
cached at data/raw/enf/enf14-ocr.txt — enf.py auto-falls-back to
|
| 190 |
+
`*-ocr.txt` when a PDF extracts under 500 chars).
|
| 191 |
+
- Typo robustness, two layers: unknown STEMS get the trigram bridge in
|
| 192 |
+
`index._fuzzy_term` (BM25 recall only), and misspelled WORDS are repaired
|
| 193 |
+
by `index._correct_query` (trigram candidates, Damerau-Levenshtein <= 2,
|
| 194 |
+
shared 2-char prefix, frequency floor 5 so OCR debris can't win) -- the
|
| 195 |
+
repaired text feeds the semantic embedder and the reranker. Ultra-rare
|
| 196 |
+
stems (df <= 2) get the correction APPENDED, never substituted, so rare
|
| 197 |
+
legitimate terms keep their recall. The `typo` eval slice tracks it.
|
| 198 |
+
- Bilingual guidance: the D-memos and NJC directives now carry their French
|
| 199 |
+
editions (`--fr` modes; dmemos-fr.json / directives-fr.json) alongside
|
| 200 |
+
the French legislation, all lang-routed. Remaining English-only: case
|
| 201 |
+
law, ENF/AMPS/PDI, benefits booklets, and the curated commentary.
|
| 202 |
+
|
| 203 |
+
## Two retrieval invariants worth not re-breaking (2026-07-29)
|
| 204 |
+
|
| 205 |
+
Ingesting the French guidance and the wider PDI trees exposed both of these.
|
| 206 |
+
Each was measured question-by-question against the previous corpus, not
|
| 207 |
+
guessed at — the harness is a per-question rank dump over two corpus
|
| 208 |
+
variants, which is how a 5-question shuffle gets attributed instead of
|
| 209 |
+
argued about.
|
| 210 |
+
|
| 211 |
+
- **BM25 collection statistics are language-scoped.** `_build_bm25` keeps
|
| 212 |
+
`idf`/`avgdl` (whole corpus) AND `idf_en`/`avgdl_en` (English scope);
|
| 213 |
+
every English query scores with the English pair. An English query can
|
| 214 |
+
never retrieve the French twins, but a single corpus-wide idf let those
|
| 215 |
+
unreachable chunks set the rarity of every shared token (numbers, act
|
| 216 |
+
codes, folded cognates) and drag the average length: adding the French
|
| 217 |
+
D-memos and directives alone moved legacy Hit@1 0.767 → 0.750 without a
|
| 218 |
+
single French chunk being retrievable. French queries still use the
|
| 219 |
+
whole-corpus pair — they search both languages.
|
| 220 |
+
- **The diversity cap keys memoranda on the parent DOCUMENT.** `_source_key`
|
| 221 |
+
used `section` for every memorandum, which is the document identity only
|
| 222 |
+
for the flat families (`_FLAT_MEMO_CODES`: one act_code across ~360
|
| 223 |
+
D-memos / ~1,000 AMPS contraventions). ENF chapters, PDI pages and IRB
|
| 224 |
+
guidelines carry a per-document act_code and use `section` to subdivide
|
| 225 |
+
it, so each ENF section and each PDI page was its own "source" and
|
| 226 |
+
neither RECALL_CAP nor SOURCE_CAP ever bound: the ~150-page PDI refugee
|
| 227 |
+
tree filled the candidate pool and pushed IRPA s. 112 out of the top 20
|
| 228 |
+
of "what is a pre-removal risk assessment" entirely. If a new corpus
|
| 229 |
+
family lands, check which field carries its document identity before
|
| 230 |
+
trusting the caps.
|
canlex/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""CanLex - Canadian legal retrieval for Claude."""
|
canlex/_common.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared helpers for the ingestion modules.
|
| 2 |
+
|
| 3 |
+
The ingesters grew organically and each carried private copies of the same
|
| 4 |
+
plumbing; the copies were AST-diffed and consolidated here (the audit found the
|
| 5 |
+
download helper in 8 near-identical copies, the line splitter in 5, and the
|
| 6 |
+
whitespace collapser in 13). Deliberate per-source differences -- a politeness
|
| 7 |
+
UA, a longer timeout, a different split target -- stay at the call sites as
|
| 8 |
+
arguments. caselaw.py keeps its own fetch (retry/throttle for Lexum's CAPTCHA)
|
| 9 |
+
and its own splitter (sentence-boundary windowing, a different algorithm).
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import subprocess
|
| 15 |
+
import time
|
| 16 |
+
import urllib.request
|
| 17 |
+
|
| 18 |
+
# The browser User-Agent used for government sites whose CDN denylists obvious
|
| 19 |
+
# non-browser clients (the pages are public; politeness comes from caching and
|
| 20 |
+
# the pauses below). The polite UA is for hosts that accept ordinary clients.
|
| 21 |
+
BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 22 |
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
| 23 |
+
POLITE_UA = "CanLex/0.1"
|
| 24 |
+
|
| 25 |
+
# Month-name lookup for parsing prose dates ("June 21, 2026").
|
| 26 |
+
MONTHS = {m: i for i, m in enumerate(
|
| 27 |
+
["january", "february", "march", "april", "may", "june", "july", "august",
|
| 28 |
+
"september", "october", "november", "december"], start=1)}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def norm_ws(text):
|
| 32 |
+
"""Collapse all whitespace to single spaces and trim.
|
| 33 |
+
|
| 34 |
+
\\s in a Python 3 str pattern already matches NBSP, en/em/thin spaces and
|
| 35 |
+
friends, so no pre-replacement is needed (several old copies carried a
|
| 36 |
+
redundant .replace("\\xa0", " ")).
|
| 37 |
+
"""
|
| 38 |
+
return re.sub(r"\s+", " ", text or "").strip()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def fetch_cached(url, dest, *, ua=BROWSER_UA, timeout=60, pause=0.5,
|
| 42 |
+
force=False, powershell=False):
|
| 43 |
+
"""Download a URL to `dest` once and reuse the cached bytes thereafter.
|
| 44 |
+
|
| 45 |
+
powershell=True routes the request through PowerShell's (.NET) HTTP stack,
|
| 46 |
+
which canada.ca accepts while blocking Python's client at the TLS layer.
|
| 47 |
+
`pause` is a politeness sleep after an actual download (0 to skip);
|
| 48 |
+
`timeout` applies to the urllib path (the PowerShell path keeps the fixed
|
| 49 |
+
180s subprocess budget every prior copy used).
|
| 50 |
+
"""
|
| 51 |
+
if dest.exists() and not force:
|
| 52 |
+
return dest.read_bytes()
|
| 53 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
if powershell:
|
| 55 |
+
command = (f"Invoke-WebRequest -Uri '{url}' -OutFile '{dest}' "
|
| 56 |
+
f"-UseBasicParsing -UserAgent '{ua}'")
|
| 57 |
+
subprocess.run(
|
| 58 |
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
|
| 59 |
+
check=True, capture_output=True, timeout=180)
|
| 60 |
+
else:
|
| 61 |
+
req = urllib.request.Request(url, headers={"User-Agent": ua})
|
| 62 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 63 |
+
dest.write_bytes(resp.read())
|
| 64 |
+
if pause:
|
| 65 |
+
time.sleep(pause)
|
| 66 |
+
return dest.read_bytes()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def split_lines(text, target=1800, fold_stubs=False):
|
| 70 |
+
"""Split text into ~target-sized pieces at line boundaries.
|
| 71 |
+
|
| 72 |
+
fold_stubs=True additionally folds a small (<200-char) leading or trailing
|
| 73 |
+
remnant into its neighbour so a section never yields a stub piece -- the
|
| 74 |
+
behaviour ingest.py and benefits.py always had. The other callers
|
| 75 |
+
deliberately keep fold_stubs=False: enabling it would change their chunk
|
| 76 |
+
boundaries, which is an eval-gated change, not a refactor.
|
| 77 |
+
"""
|
| 78 |
+
if len(text) <= target:
|
| 79 |
+
return [text]
|
| 80 |
+
pieces, buf, size = [], [], 0
|
| 81 |
+
for line in text.split("\n"):
|
| 82 |
+
if size + len(line) > target and buf:
|
| 83 |
+
pieces.append("\n".join(buf))
|
| 84 |
+
buf, size = [], 0
|
| 85 |
+
buf.append(line)
|
| 86 |
+
size += len(line) + 1
|
| 87 |
+
if buf:
|
| 88 |
+
pieces.append("\n".join(buf))
|
| 89 |
+
if fold_stubs:
|
| 90 |
+
if len(pieces) > 1 and len(pieces[-1]) < 200:
|
| 91 |
+
pieces[-2] = pieces[-2] + "\n" + pieces[-1]
|
| 92 |
+
pieces.pop()
|
| 93 |
+
if len(pieces) > 1 and len(pieces[0]) < 200:
|
| 94 |
+
pieces[1] = pieces[0] + "\n" + pieces[1]
|
| 95 |
+
pieces.pop(0)
|
| 96 |
+
return pieces
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# Every ingester rebuilds its whole processed file from a fresh scrape, so an
|
| 100 |
+
# upstream page that changes shape -- or a run that loses its network halfway --
|
| 101 |
+
# shows up as a collapse in chunk count, and the write turns that into data
|
| 102 |
+
# loss. On 2026-07-27 CBSA's rebuilt D-memo index scraped to zero memos and the
|
| 103 |
+
# write replaced 1,870 good chunks with an empty list; one line of stdout was
|
| 104 |
+
# the only warning. Refuse to write below this share of what is already stored.
|
| 105 |
+
CORPUS_SHRINK_GUARD = 0.8
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def stored_chunks(path):
|
| 109 |
+
"""The chunks already written to `path`, or [] if absent or unreadable."""
|
| 110 |
+
if not path.exists():
|
| 111 |
+
return []
|
| 112 |
+
try:
|
| 113 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 114 |
+
except (ValueError, OSError):
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def safe_to_write(new_count, old_count, ratio=CORPUS_SHRINK_GUARD):
|
| 119 |
+
"""False when writing `new_count` chunks would gut an existing corpus.
|
| 120 |
+
|
| 121 |
+
Pure, for tests. The write is the irreversible step, so it is the one to
|
| 122 |
+
gate -- a first-ever run (old_count 0) is always allowed.
|
| 123 |
+
"""
|
| 124 |
+
return old_count == 0 or new_count >= old_count * ratio
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def preserve_dropped(chunks, stored, *, key=lambda c: c["section"], only=None):
|
| 128 |
+
"""Re-attach the last-good chunks of items this run failed to produce.
|
| 129 |
+
|
| 130 |
+
`key` maps a chunk to the item it belongs to; `only` restricts preservation
|
| 131 |
+
to a set of item identities (pass the ones that actually errored, so items
|
| 132 |
+
dropped ON PURPOSE -- cancelled memos, retired manuals -- are not
|
| 133 |
+
resurrected). Returns (chunks, preserved identities).
|
| 134 |
+
"""
|
| 135 |
+
def identity(chunk):
|
| 136 |
+
# A chunk written by an older schema may not carry the identity field
|
| 137 |
+
# at all. It simply is not preservable -- but crashing here would take
|
| 138 |
+
# down the rebuild at the exact moment the guard exists to protect it.
|
| 139 |
+
try:
|
| 140 |
+
return key(chunk)
|
| 141 |
+
except (KeyError, IndexError, TypeError):
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
fresh = {identity(c) for c in chunks}
|
| 145 |
+
by_item = {}
|
| 146 |
+
for chunk in stored:
|
| 147 |
+
item = identity(chunk)
|
| 148 |
+
if item is not None:
|
| 149 |
+
by_item.setdefault(item, []).append(chunk)
|
| 150 |
+
dropped = sorted(item for item in by_item
|
| 151 |
+
if item not in fresh and (only is None or item in only))
|
| 152 |
+
for item in dropped:
|
| 153 |
+
chunks.extend(by_item[item])
|
| 154 |
+
return chunks, dropped
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def write_corpus(path, chunks, *, indent=2, allow_shrink=False,
|
| 158 |
+
ratio=CORPUS_SHRINK_GUARD):
|
| 159 |
+
"""Write `chunks` to `path`, unless that would gut the stored corpus.
|
| 160 |
+
|
| 161 |
+
Returns True if written, False if refused -- callers exit non-zero on False
|
| 162 |
+
so a broken scrape fails loudly instead of quietly emptying a file.
|
| 163 |
+
"""
|
| 164 |
+
stored = stored_chunks(path)
|
| 165 |
+
if not allow_shrink and not safe_to_write(len(chunks), len(stored), ratio):
|
| 166 |
+
print(f" REFUSING to write: {len(chunks)} chunks would replace "
|
| 167 |
+
f"{len(stored)} already in {path.name}. The upstream source "
|
| 168 |
+
f"likely changed shape -- check the scrape before writing "
|
| 169 |
+
f"(re-run with --allow-shrink once the drop is understood).")
|
| 170 |
+
return False
|
| 171 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
# Write-then-rename, not write-in-place: a run killed mid-write (Ctrl-C on
|
| 173 |
+
# the scheduled refresh, a full disk) would otherwise leave truncated JSON
|
| 174 |
+
# behind, and stored_chunks reads unparseable as empty -- which disables
|
| 175 |
+
# the guard above on the very next run, when it is needed most.
|
| 176 |
+
tmp = path.with_name(path.name + ".tmp")
|
| 177 |
+
tmp.write_text(json.dumps(chunks, ensure_ascii=False, indent=indent),
|
| 178 |
+
encoding="utf-8")
|
| 179 |
+
os.replace(tmp, path)
|
| 180 |
+
return True
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def uniquify_ids(chunks):
|
| 184 |
+
"""Rename duplicate chunk ids in place (second occurrence -> id-b, -c...).
|
| 185 |
+
|
| 186 |
+
Duplicate ids silently orphan chunks downstream: the embeddings loader
|
| 187 |
+
keys rows by id, so one twin gets vectors and the other degrades to
|
| 188 |
+
BM25-only with nothing but a stderr note. Generators whose source
|
| 189 |
+
numbering can repeat (a manual's TOC line and body heading both reading
|
| 190 |
+
'5.1'; delegation items re-using an instrument number) run their output
|
| 191 |
+
through this before writing."""
|
| 192 |
+
seen = {}
|
| 193 |
+
for c in chunks:
|
| 194 |
+
n = seen.get(c["id"], 0)
|
| 195 |
+
seen[c["id"]] = n + 1
|
| 196 |
+
if n:
|
| 197 |
+
c["id"] = f"{c['id']}-{chr(ord('a') + n)}"
|
| 198 |
+
return chunks
|
canlex/agreement.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest Treasury Board collective agreements (HTML) into Article-level chunks.
|
| 2 |
+
|
| 3 |
+
A collective agreement is a binding contract between the Treasury Board and a
|
| 4 |
+
bargaining agent for one occupational group. Chunks are tagged
|
| 5 |
+
doc_type="agreement" so CanLex keeps them distinct from legislation and guidance.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
from bs4 import BeautifulSoup
|
| 11 |
+
|
| 12 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 13 |
+
split_lines, stored_chunks, write_corpus)
|
| 14 |
+
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
+
|
| 16 |
+
AGREEMENT_DIR = RAW_DIR / "agreements"
|
| 17 |
+
OUT_FILE = PROCESSED_DIR / "agreements.json"
|
| 18 |
+
|
| 19 |
+
# Treasury Board collective agreements to ingest. Add an entry to ingest more.
|
| 20 |
+
AGREEMENTS = {
|
| 21 |
+
"FB": {
|
| 22 |
+
"short": "FB Agreement",
|
| 23 |
+
"name": "FB Group Collective Agreement (Border Services)",
|
| 24 |
+
"cite": "FB Collective Agreement",
|
| 25 |
+
"url": "https://www.canada.ca/en/treasury-board-secretariat/topics/pay/"
|
| 26 |
+
"collective-agreements/fb.html",
|
| 27 |
+
},
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_SKIP_HEADINGS = {"table of contents", "note to readers", "page details",
|
| 31 |
+
"on this page"}
|
| 32 |
+
_CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
|
| 33 |
+
_ARTICLE = re.compile(r"Article\s+(\S+?)\s*[:–-]\s*(.+)", re.I)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _fetch(url, dest, force=False):
|
| 37 |
+
"""Download a page. canada.ca blocks Python's HTTP client at the TLS layer,
|
| 38 |
+
so fetch via PowerShell's (.NET) HTTP stack, which the site accepts."""
|
| 39 |
+
return fetch_cached(url, dest, force=force, powershell=True)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
_PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _split(text, target=_PIECE_TARGET):
|
| 46 |
+
"""Split long text into ~target-sized pieces at line boundaries."""
|
| 47 |
+
return split_lines(text, target)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _block_text(heading):
|
| 51 |
+
"""Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
|
| 52 |
+
lines = []
|
| 53 |
+
for sib in heading.find_next_siblings():
|
| 54 |
+
if sib.name in ("h2", "h3"):
|
| 55 |
+
break
|
| 56 |
+
if sib.name in ("ul", "ol"):
|
| 57 |
+
for li in sib.find_all("li", recursive=False):
|
| 58 |
+
item = _norm(li.get_text(" ", strip=True))
|
| 59 |
+
if item:
|
| 60 |
+
lines.append(f"- {item}")
|
| 61 |
+
elif sib.name in _CONTENT_TAGS:
|
| 62 |
+
text = _norm(sib.get_text(" ", strip=True))
|
| 63 |
+
if text:
|
| 64 |
+
lines.append(text)
|
| 65 |
+
return "\n".join(lines)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def parse_agreement(html, code):
|
| 69 |
+
"""Parse a collective agreement page into one chunk per Article / Appendix."""
|
| 70 |
+
meta = AGREEMENTS[code]
|
| 71 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 72 |
+
main = soup.find("main")
|
| 73 |
+
if main is None:
|
| 74 |
+
return []
|
| 75 |
+
|
| 76 |
+
# The first <time datetime> is the agreement's expiry; the dateModified one
|
| 77 |
+
# is the page's date.
|
| 78 |
+
expiry = ""
|
| 79 |
+
for t in main.find_all("time"):
|
| 80 |
+
if t.get("datetime") and t.get("property") != "dateModified":
|
| 81 |
+
expiry = _norm(t.get("datetime"))
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
for tag in main.find_all(["section", "div"]):
|
| 85 |
+
tag.unwrap() # flatten so each heading and its content become siblings
|
| 86 |
+
|
| 87 |
+
chunks = []
|
| 88 |
+
current_part = ""
|
| 89 |
+
sec_idx = 0
|
| 90 |
+
for h in main.find_all(["h2", "h3"]):
|
| 91 |
+
if "wb-inv" in (h.get("class") or []):
|
| 92 |
+
continue
|
| 93 |
+
text = _norm(h.get_text(" ", strip=True)).lstrip("*").strip()
|
| 94 |
+
if not text or text.lower() in _SKIP_HEADINGS:
|
| 95 |
+
continue
|
| 96 |
+
if h.name == "h2":
|
| 97 |
+
current_part = text
|
| 98 |
+
body = _block_text(h)
|
| 99 |
+
if not body:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
article = _ARTICLE.match(text)
|
| 103 |
+
if article:
|
| 104 |
+
number = article.group(1).rstrip(":.")
|
| 105 |
+
note = article.group(2).strip()
|
| 106 |
+
citation = f"{meta['cite']}, Article {number}"
|
| 107 |
+
elif re.match(r"Appendix\b", text, re.I):
|
| 108 |
+
number, note = text, ""
|
| 109 |
+
citation = f"{meta['cite']}, {text}"
|
| 110 |
+
else:
|
| 111 |
+
# Memoranda of Agreement/Understanding have long titles: keep them in
|
| 112 |
+
# the note only, so the rendered header does not repeat them.
|
| 113 |
+
number, note = "", text
|
| 114 |
+
citation = meta["cite"]
|
| 115 |
+
|
| 116 |
+
sec_idx += 1
|
| 117 |
+
# Split a long article so each piece fits the embed window; pieces share
|
| 118 |
+
# the base id and take a '-p<k>' suffix so the diversity cap groups them.
|
| 119 |
+
pieces = _split(body)
|
| 120 |
+
for k, piece in enumerate(pieces, start=1):
|
| 121 |
+
base = f"agreement-{code}-{sec_idx}"
|
| 122 |
+
chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
|
| 123 |
+
pnote = note if len(pieces) == 1 else f"{note} (part {k})".strip()
|
| 124 |
+
chunks.append({
|
| 125 |
+
"id": chunk_id,
|
| 126 |
+
"doc_type": "agreement",
|
| 127 |
+
"act_code": code,
|
| 128 |
+
"act_short": meta["short"],
|
| 129 |
+
"act_name": meta["name"],
|
| 130 |
+
"section": number,
|
| 131 |
+
"marginal_note": pnote,
|
| 132 |
+
"part": current_part if h.name == "h3" else "",
|
| 133 |
+
"division": "",
|
| 134 |
+
"heading": "",
|
| 135 |
+
"text": piece,
|
| 136 |
+
"history": "",
|
| 137 |
+
"last_amended": "",
|
| 138 |
+
"current_to": expiry,
|
| 139 |
+
"citation": citation,
|
| 140 |
+
"source_url": meta["url"],
|
| 141 |
+
})
|
| 142 |
+
return chunks
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def preserve_failed(chunks, failures, stored):
|
| 146 |
+
"""Re-attach the last-good chunks of agreements this run could not rebuild.
|
| 147 |
+
|
| 148 |
+
Ingestion is a full rebuild, so an agreement whose page fails to fetch --
|
| 149 |
+
or whose canada.ca layout changes so that nothing parses out of it --
|
| 150 |
+
disappears from the corpus. An agreement that is still in force is better
|
| 151 |
+
served from the copy we already had than not at all. Restricted to the
|
| 152 |
+
codes that actually failed, so one removed from AGREEMENTS on purpose is
|
| 153 |
+
not resurrected. Returns (chunks, preserved codes).
|
| 154 |
+
"""
|
| 155 |
+
failed = {code for code, _why in failures}
|
| 156 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 157 |
+
only=failed)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def ingest(force=False, allow_shrink=False):
|
| 161 |
+
all_chunks, failures = [], []
|
| 162 |
+
for code, meta in AGREEMENTS.items():
|
| 163 |
+
print(f"Ingesting {code} ({meta['short']})...")
|
| 164 |
+
try:
|
| 165 |
+
html = _fetch(meta["url"], AGREEMENT_DIR / f"{code}.html", force=force)
|
| 166 |
+
chunks = parse_agreement(html, code)
|
| 167 |
+
print(f" {len(chunks)} chunks")
|
| 168 |
+
all_chunks.extend(chunks)
|
| 169 |
+
if not chunks:
|
| 170 |
+
# A page that parses to nothing is a redesign upstream, not an
|
| 171 |
+
# empty agreement -- same class of failure as a dead fetch.
|
| 172 |
+
failures.append((code, "no content parsed"))
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
failures.append((code, f"{type(exc).__name__}: {exc}"))
|
| 175 |
+
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 176 |
+
|
| 177 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 178 |
+
stored_chunks(OUT_FILE))
|
| 179 |
+
if preserved:
|
| 180 |
+
print(f" kept the last-good copy of {len(preserved)} agreement(s): "
|
| 181 |
+
f"{', '.join(preserved)}")
|
| 182 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 183 |
+
allow_shrink=allow_shrink):
|
| 184 |
+
return False
|
| 185 |
+
print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
|
| 186 |
+
return True
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def main():
|
| 190 |
+
ok = ingest(force="--force" in sys.argv,
|
| 191 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 192 |
+
sys.exit(0 if ok else 1)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
canlex/amps.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the CBSA Master Penalty Document (AMPS contraventions).
|
| 2 |
+
|
| 3 |
+
The Master Penalty Document is the public per-contravention reference for the
|
| 4 |
+
Administrative Monetary Penalty System: each page (C001 onward) gives the
|
| 5 |
+
contravention text, the penalty amounts by occurrence level, and the
|
| 6 |
+
designated provision it enforces. Together with the Designated Provisions
|
| 7 |
+
(Customs) Regulations schedule, it answers the trade-compliance staple
|
| 8 |
+
"what does contravention X cost on a second occurrence?" -- which the corpus
|
| 9 |
+
previously could not.
|
| 10 |
+
|
| 11 |
+
Same fetch/cache/parse pattern as dmemo.py: index page -> per-contravention
|
| 12 |
+
pages, cached under data/raw/amps, one chunk per contravention (they are
|
| 13 |
+
short), doc_type='memorandum' (administrative guidance, not law).
|
| 14 |
+
|
| 15 |
+
python -m canlex.amps
|
| 16 |
+
"""
|
| 17 |
+
import re
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 21 |
+
stored_chunks, write_corpus)
|
| 22 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
+
|
| 24 |
+
INDEX_URL = "https://www.cbsa-asfc.gc.ca/trade-commerce/amps/mpd-dmi-eng.html"
|
| 25 |
+
BASE = "https://www.cbsa-asfc.gc.ca"
|
| 26 |
+
RAW = RAW_DIR / "amps"
|
| 27 |
+
|
| 28 |
+
# Hrefs on the index may be absolute or relative; key on the c-number page
|
| 29 |
+
# name alone.
|
| 30 |
+
_LINK = re.compile(r'href="([^"]*?contraventions-infractions/'
|
| 31 |
+
r'([a-z]\d{3})-eng\.html)"')
|
| 32 |
+
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
| 33 |
+
_TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _strip(html):
|
| 37 |
+
"""Readable text: table cells joined with ' | ', block ends -> newlines."""
|
| 38 |
+
html = re.sub(r"</t[dh]>", " | ", html)
|
| 39 |
+
html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
|
| 40 |
+
html = re.sub(r"<[^>]+>", " ", html)
|
| 41 |
+
lines = [_norm(l).strip("| ").strip() for l in html.split("\n")]
|
| 42 |
+
return "\n".join(l for l in lines if l)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _main_content(html):
|
| 46 |
+
"""The page's main block, shorn of nav/footer boilerplate."""
|
| 47 |
+
m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
|
| 48 |
+
return m.group(1) if m else html
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def parse_page(html, code, url):
|
| 52 |
+
content = _main_content(html)
|
| 53 |
+
title_m = _TITLE.search(content)
|
| 54 |
+
title = _norm(re.sub(r"<[^>]+>", " ", title_m.group(1))) if title_m else code.upper()
|
| 55 |
+
body = _strip(content[title_m.end():] if title_m else content)
|
| 56 |
+
mod = _MODIFIED.search(html)
|
| 57 |
+
modified = mod.group(1) if mod else ""
|
| 58 |
+
# The first non-empty line after the title is the contravention text
|
| 59 |
+
# itself -- promote it to the marginal note so retrieval titles carry it.
|
| 60 |
+
first = next((l for l in body.split("\n") if len(l) > 30), "")
|
| 61 |
+
return {
|
| 62 |
+
"id": f"amps-{code}",
|
| 63 |
+
"doc_type": "memorandum",
|
| 64 |
+
"act_code": "AMPS",
|
| 65 |
+
"act_short": "AMPS",
|
| 66 |
+
"act_name": "AMPS Master Penalty Document",
|
| 67 |
+
"section": code.upper(),
|
| 68 |
+
"marginal_note": first[:160],
|
| 69 |
+
"part": "Master Penalty Document",
|
| 70 |
+
"division": "",
|
| 71 |
+
"heading": title,
|
| 72 |
+
"text": body,
|
| 73 |
+
"history": "",
|
| 74 |
+
"last_amended": modified,
|
| 75 |
+
"current_to": modified,
|
| 76 |
+
"citation": f"AMPS contravention {code.upper()}",
|
| 77 |
+
"source_url": url,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _fetch(url, dest):
|
| 82 |
+
"""cbsa-asfc.gc.ca serves this section empty to Python's HTTP client;
|
| 83 |
+
route through PowerShell (like canada.ca fetches) and treat an empty
|
| 84 |
+
cache as a miss."""
|
| 85 |
+
if dest.exists() and dest.stat().st_size == 0:
|
| 86 |
+
dest.unlink()
|
| 87 |
+
data = fetch_cached(url, dest, powershell=True, pause=0.4)
|
| 88 |
+
if not data.strip():
|
| 89 |
+
raise ValueError(f"empty response for {url}")
|
| 90 |
+
return data
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def preserve_failed(chunks, failed, stored):
|
| 94 |
+
"""Re-attach the last-good chunks of contraventions this run could not fetch.
|
| 95 |
+
|
| 96 |
+
Ingestion is a full rebuild, so a page that 404s or times out silently
|
| 97 |
+
leaves the corpus -- and a penalty still in force is worse missing than
|
| 98 |
+
slightly stale. `failed` carries the (code, why) pairs build() collects;
|
| 99 |
+
codes are lower-case on the index but upper-case in a chunk's `section`,
|
| 100 |
+
so match on the upper-cased form. Restricted to codes that actually
|
| 101 |
+
errored, so a contravention CBSA has genuinely retired stays gone.
|
| 102 |
+
Returns (chunks, preserved codes).
|
| 103 |
+
"""
|
| 104 |
+
codes = {code.upper() for code, _why in failed}
|
| 105 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["section"].upper(),
|
| 106 |
+
only=codes)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def build(limit=None, allow_shrink=False):
|
| 110 |
+
html = _fetch(INDEX_URL, RAW / "index.html").decode("utf-8", "replace")
|
| 111 |
+
links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
|
| 112 |
+
key=lambda t: t[1])
|
| 113 |
+
if limit:
|
| 114 |
+
links = links[:limit]
|
| 115 |
+
print(f"amps: {len(links)} contravention pages on the index")
|
| 116 |
+
chunks, failed = [], []
|
| 117 |
+
for path, code in links:
|
| 118 |
+
if path.startswith("http"):
|
| 119 |
+
url = path
|
| 120 |
+
elif path.startswith("/"):
|
| 121 |
+
url = BASE + path
|
| 122 |
+
else:
|
| 123 |
+
url = INDEX_URL.rsplit("/", 1)[0] + "/" + path
|
| 124 |
+
try:
|
| 125 |
+
page = _fetch(url, RAW / f"{code}.html").decode("utf-8", "replace")
|
| 126 |
+
chunks.append(parse_page(page, code, url))
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
failed.append((code, f"{type(exc).__name__}: {exc}"))
|
| 129 |
+
out = PROCESSED_DIR / "amps.json"
|
| 130 |
+
# Printed before the write so a refusal still shows what went wrong.
|
| 131 |
+
if failed:
|
| 132 |
+
print(f"amps: {len(failed)} failed: {failed[:5]}")
|
| 133 |
+
if not limit:
|
| 134 |
+
chunks, preserved = preserve_failed(chunks, failed, stored_chunks(out))
|
| 135 |
+
if preserved:
|
| 136 |
+
print(f"amps: kept the last-good copy of {len(preserved)} "
|
| 137 |
+
f"unfetchable contravention(s): {', '.join(preserved)}")
|
| 138 |
+
# The guard applies to --limit runs too: a smoke test over five
|
| 139 |
+
# contraventions must not be able to write a five-page corpus over the real
|
| 140 |
+
# one. indent=2 matches what is already on disk -- changing it would
|
| 141 |
+
# rewrite every line of the file.
|
| 142 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 143 |
+
return False
|
| 144 |
+
print(f"amps: {len(chunks)} contraventions -> {out.name}")
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main():
|
| 149 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 150 |
+
sys.exit(0 if ok else 1)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
main()
|
canlex/benefits.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the member booklets of the federal public-service benefit plans.
|
| 2 |
+
|
| 3 |
+
Four plans cover federal employees (and a Border Services Officer over a
|
| 4 |
+
career), by population:
|
| 5 |
+
|
| 6 |
+
- PSHCP -- Public Service Health Care Plan (health; active + pensioners)
|
| 7 |
+
- PSDCP -- Public Service Dental Care Plan (dental; active employees)
|
| 8 |
+
- PDSP -- Pensioners' Dental Services Plan (dental; pensioners)
|
| 9 |
+
- PSMIP -- Public Service Management Insurance Plan (life / AD&D / disability
|
| 10 |
+
insurance; management-category and excluded employees -- so EX and
|
| 11 |
+
excluded CBSA staff rather than the represented FB group, who carry
|
| 12 |
+
the separate Disability Insurance Plan)
|
| 13 |
+
|
| 14 |
+
Each plan's *member booklet* is the plain-language guide the plan administrator
|
| 15 |
+
or Treasury Board publishes -- what is covered, who is eligible, how to claim,
|
| 16 |
+
the appeals process. The booklets are guidance: the governing terms live in the
|
| 17 |
+
PSHCP Directive (already ingested as doc_type='directive') and the respective
|
| 18 |
+
plan rules, which prevail in any discrepancy. They are tagged doc_type=
|
| 19 |
+
'benefits' and labelled as such in results.
|
| 20 |
+
|
| 21 |
+
Sources: the Canada Life PDFs (welcome.canadalife.com / canadalife.com), which
|
| 22 |
+
-- unlike canada.ca -- serve Python's urllib fine; and the PSMIP booklet, an
|
| 23 |
+
HTML page on canada.ca (which blocks urllib at the TLS layer, so it is fetched
|
| 24 |
+
via PowerShell, as agreement.py does). A PDF booklet carries a table of
|
| 25 |
+
contents whose headings are located in the body to segment it; the HTML booklet
|
| 26 |
+
is segmented at its h2/h3 headings. Either way a retrieved chunk is a coherent
|
| 27 |
+
benefit topic.
|
| 28 |
+
|
| 29 |
+
py -m canlex.benefits
|
| 30 |
+
"""
|
| 31 |
+
import io
|
| 32 |
+
import re
|
| 33 |
+
import sys
|
| 34 |
+
|
| 35 |
+
from bs4 import BeautifulSoup
|
| 36 |
+
from pypdf import PdfReader
|
| 37 |
+
|
| 38 |
+
from ._common import (fetch_cached, norm_ws, preserve_dropped, split_lines,
|
| 39 |
+
stored_chunks, write_corpus)
|
| 40 |
+
|
| 41 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 42 |
+
|
| 43 |
+
RAW = RAW_DIR / "benefits"
|
| 44 |
+
OUT = PROCESSED_DIR / "benefits.json"
|
| 45 |
+
|
| 46 |
+
_CHUNK_CHARS = 1800 # target characters per chunk
|
| 47 |
+
|
| 48 |
+
# Each plan: its booklet, and how to read it. PDF booklets give the page
|
| 49 |
+
# indices of the table of contents and the first body page (everything before
|
| 50 |
+
# it is cover + TOC). The HTML booklet is parsed at its h2/h3 headings.
|
| 51 |
+
# 'edition' is the booklet's own date (for HTML it is read from the page's
|
| 52 |
+
# "Date modified"); bump a PDF edition and re-run when a new booklet is posted.
|
| 53 |
+
SOURCES = {
|
| 54 |
+
"PSHCP": {
|
| 55 |
+
"code": "PSHCP", "format": "pdf",
|
| 56 |
+
"name": "Public Service Health Care Plan",
|
| 57 |
+
"url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
|
| 58 |
+
"welcome-sites/pshcp/PSHCP-member-booklet.pdf"),
|
| 59 |
+
"label": "Member Booklet",
|
| 60 |
+
"toc_pages": [2, 3, 4],
|
| 61 |
+
"body_start": 5,
|
| 62 |
+
"edition": "June 2024",
|
| 63 |
+
},
|
| 64 |
+
"PSDCP": {
|
| 65 |
+
"code": "PSDCP", "format": "pdf",
|
| 66 |
+
"name": "Public Service Dental Care Plan",
|
| 67 |
+
"url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
|
| 68 |
+
"welcome-sites/psdcp/psdcp-member-booklet.pdf"),
|
| 69 |
+
"label": "Member Booklet",
|
| 70 |
+
"toc_pages": [2, 3],
|
| 71 |
+
"body_start": 4,
|
| 72 |
+
"edition": "May 2026",
|
| 73 |
+
},
|
| 74 |
+
"PDSP": {
|
| 75 |
+
"code": "PDSP", "format": "pdf",
|
| 76 |
+
"name": "Pensioners' Dental Services Plan",
|
| 77 |
+
"url": ("https://www.canadalife.com/content/dam/rfp/welcome-sites/"
|
| 78 |
+
"pdsp/pdsp-member-booklet.pdf"),
|
| 79 |
+
"label": "Member Booklet",
|
| 80 |
+
"toc_pages": [2, 3],
|
| 81 |
+
"body_start": 4,
|
| 82 |
+
"edition": "November 2024",
|
| 83 |
+
},
|
| 84 |
+
"PSMIP": {
|
| 85 |
+
"code": "PSMIP", "format": "html",
|
| 86 |
+
"name": "Public Service Management Insurance Plan",
|
| 87 |
+
"url": ("https://www.canada.ca/en/treasury-board-secretariat/services/"
|
| 88 |
+
"benefit-plans/management-insurance-plan/main-plan-booklet.html"),
|
| 89 |
+
"label": "Main Plan Booklet",
|
| 90 |
+
# The canada.ca page exposes no machine-readable date (its <time> tags are
|
| 91 |
+
# content dates, not a page revision). Rather than render currency as
|
| 92 |
+
# 'n/a', state plainly that the source is undated so a reader knows to
|
| 93 |
+
# verify rather than assume staleness.
|
| 94 |
+
"edition": "undated (TBS web edition)",
|
| 95 |
+
},
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# canada.ca section labels that are navigation/footer, not plan content.
|
| 99 |
+
_SKIP_HEADINGS = {"on this page", "page details", "from:", "table of contents"}
|
| 100 |
+
|
| 101 |
+
# A table-of-contents line: optional dot-leaders, the heading text, then the
|
| 102 |
+
# printed page number. The heading must end in a letter or ')' so a bare page
|
| 103 |
+
# number or a dot run alone is not mistaken for a heading.
|
| 104 |
+
_TOC_LINE = re.compile(r"^[\s.]*(.*?[A-Za-z)])\s*\.*\s*(\d{1,3})\s*$")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _fetch(url, dest):
|
| 108 |
+
return fetch_cached(url, dest, timeout=90, pause=0)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _clean(text):
|
| 112 |
+
"""Tidy extracted PDF text: turn bullets into dashes, collapse runs of
|
| 113 |
+
spaces, and trim excess blank lines. Apostrophes, accents and en-dashes
|
| 114 |
+
extract as proper Unicode, so no transliteration is needed."""
|
| 115 |
+
text = text.replace("•", "\n- ").replace("\xa0", " ")
|
| 116 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 117 |
+
text = re.sub(r"\n[ \t]+", "\n", text)
|
| 118 |
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _page_texts(pdf_bytes):
|
| 122 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 123 |
+
return [p.extract_text() or "" for p in reader.pages]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def parse_toc(page_texts, toc_pages):
|
| 127 |
+
"""Return the ordered, de-duplicated list of section headings from the
|
| 128 |
+
table of contents."""
|
| 129 |
+
seen, headings = set(), []
|
| 130 |
+
for i in toc_pages:
|
| 131 |
+
if i >= len(page_texts):
|
| 132 |
+
continue
|
| 133 |
+
for line in page_texts[i].split("\n"):
|
| 134 |
+
line = line.strip()
|
| 135 |
+
if not line or "Table of Contents" in line:
|
| 136 |
+
continue
|
| 137 |
+
m = _TOC_LINE.match(line)
|
| 138 |
+
if not m:
|
| 139 |
+
continue
|
| 140 |
+
title = re.sub(r"\s+", " ", m.group(1)).strip(" .")
|
| 141 |
+
if len(title) > 2 and title.lower() not in seen:
|
| 142 |
+
seen.add(title.lower())
|
| 143 |
+
headings.append(title)
|
| 144 |
+
return headings
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def segment(body, headings):
|
| 148 |
+
"""Split the body text at each heading, in order, returning a list of
|
| 149 |
+
(heading, section_text). A heading the extractor split across lines is
|
| 150 |
+
matched with flexible whitespace; a heading not found in the body is
|
| 151 |
+
skipped (its text folds into the preceding section)."""
|
| 152 |
+
cuts = []
|
| 153 |
+
pos = 0
|
| 154 |
+
for h in headings:
|
| 155 |
+
pattern = re.compile(r"\s*".join(re.escape(w) for w in h.split()), re.I)
|
| 156 |
+
m = pattern.search(body, pos)
|
| 157 |
+
if m:
|
| 158 |
+
cuts.append((h, m.start()))
|
| 159 |
+
pos = m.end()
|
| 160 |
+
sections = []
|
| 161 |
+
for j, (h, start) in enumerate(cuts):
|
| 162 |
+
end = cuts[j + 1][1] if j + 1 < len(cuts) else len(body)
|
| 163 |
+
sections.append((h, body[start:end].strip()))
|
| 164 |
+
return sections
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _split(text, target=_CHUNK_CHARS):
|
| 168 |
+
"""Split a long section into ~target-sized pieces at line boundaries,
|
| 169 |
+
folding small leading/trailing remnants into a neighbour so a section
|
| 170 |
+
never yields a stub chunk."""
|
| 171 |
+
return split_lines(text, target, fold_stubs=True)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _slug(heading, used):
|
| 175 |
+
"""A short, unique, citable section locator from a heading."""
|
| 176 |
+
base = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "section"
|
| 177 |
+
slug, n = base, 2
|
| 178 |
+
while slug in used:
|
| 179 |
+
slug = f"{base}-{n}"
|
| 180 |
+
n += 1
|
| 181 |
+
used.add(slug)
|
| 182 |
+
return slug
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _emit(sections, src):
|
| 186 |
+
"""Build CanLex chunk dicts from a list of (heading, section_text), shared
|
| 187 |
+
by the PDF and HTML paths. Skips pure section-divider headings and
|
| 188 |
+
sub-splits any section over the chunk budget."""
|
| 189 |
+
chunks = []
|
| 190 |
+
used_slugs = set()
|
| 191 |
+
label = src["label"]
|
| 192 |
+
for heading, text in sections:
|
| 193 |
+
# Skip pure section dividers -- a heading whose only content is the
|
| 194 |
+
# heading itself (its substance lives in the child sections below it).
|
| 195 |
+
# Compare with whitespace collapsed, since a PDF extractor often breaks
|
| 196 |
+
# a heading across lines ("...Plan \n(PSDCP)").
|
| 197 |
+
norm_text = norm_ws(text)
|
| 198 |
+
norm_head = norm_ws(heading)
|
| 199 |
+
remainder = (norm_text[len(norm_head):]
|
| 200 |
+
if norm_text.lower().startswith(norm_head.lower())
|
| 201 |
+
else norm_text)
|
| 202 |
+
if len(remainder.strip()) < 40:
|
| 203 |
+
continue
|
| 204 |
+
slug = _slug(heading, used_slugs)
|
| 205 |
+
pieces = _split(text)
|
| 206 |
+
for k, piece in enumerate(pieces, start=1):
|
| 207 |
+
section = slug if len(pieces) == 1 else f"{slug}-{k}"
|
| 208 |
+
note = heading if len(pieces) == 1 else f"{heading} (part {k})"
|
| 209 |
+
chunks.append({
|
| 210 |
+
"id": f"benefits-{src['code']}-{section}",
|
| 211 |
+
"doc_type": "benefits",
|
| 212 |
+
"act_code": src["code"],
|
| 213 |
+
"act_short": src["code"],
|
| 214 |
+
"act_name": f"{src['name']} — {label}",
|
| 215 |
+
"section": section,
|
| 216 |
+
"marginal_note": note,
|
| 217 |
+
"part": f"{src['name']} {label}",
|
| 218 |
+
"division": "",
|
| 219 |
+
"heading": "",
|
| 220 |
+
"text": piece,
|
| 221 |
+
"history": "",
|
| 222 |
+
"last_amended": "",
|
| 223 |
+
"current_to": src["edition"],
|
| 224 |
+
"citation": (f"{src['code']} {label}"
|
| 225 |
+
+ (f" ({src['edition']})" if src["edition"] else "")
|
| 226 |
+
+ f", “{heading}”"),
|
| 227 |
+
"source_url": src["url"],
|
| 228 |
+
})
|
| 229 |
+
return chunks
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def parse_pdf_booklet(pdf_bytes, src):
|
| 233 |
+
"""Segment a PDF booklet by its table-of-contents headings."""
|
| 234 |
+
pages = _page_texts(pdf_bytes)
|
| 235 |
+
headings = parse_toc(pages, src["toc_pages"])
|
| 236 |
+
body = _clean("\n".join(pages[src["body_start"]:]))
|
| 237 |
+
return _emit(segment(body, headings), src)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _html_block_text(heading):
|
| 241 |
+
"""Readable text from an h2/h3 up to the next heading of the same or higher
|
| 242 |
+
level; lists become dash items."""
|
| 243 |
+
lines = []
|
| 244 |
+
for sib in heading.find_next_siblings():
|
| 245 |
+
if sib.name in ("h2", "h3"):
|
| 246 |
+
break
|
| 247 |
+
if sib.name in ("ul", "ol"):
|
| 248 |
+
for li in sib.find_all("li", recursive=False):
|
| 249 |
+
item = norm_ws(li.get_text(" ", strip=True))
|
| 250 |
+
if item:
|
| 251 |
+
lines.append(f"- {item}")
|
| 252 |
+
elif sib.name in ("p", "div", "table", "section", "h4", "h5"):
|
| 253 |
+
text = norm_ws(sib.get_text(" ", strip=True))
|
| 254 |
+
if text:
|
| 255 |
+
lines.append(text)
|
| 256 |
+
return "\n".join(lines)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def parse_html_booklet(html, src):
|
| 260 |
+
"""Segment the canada.ca HTML booklet at its h2/h3 headings."""
|
| 261 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 262 |
+
main = soup.find("main") or soup
|
| 263 |
+
# Read the page's "Date modified" so the booklet's currency is recorded.
|
| 264 |
+
if not src["edition"]:
|
| 265 |
+
t = main.find("time", property="dateModified") or soup.find(
|
| 266 |
+
"time", property="dateModified")
|
| 267 |
+
if t:
|
| 268 |
+
src = {**src, "edition": t.get_text(strip=True)}
|
| 269 |
+
sections = []
|
| 270 |
+
for h in main.find_all(["h2", "h3"]):
|
| 271 |
+
title = norm_ws(h.get_text(" ", strip=True))
|
| 272 |
+
if not title or title.lower() in _SKIP_HEADINGS:
|
| 273 |
+
continue
|
| 274 |
+
body = _html_block_text(h)
|
| 275 |
+
if body:
|
| 276 |
+
sections.append((title, f"{title}\n{body}"))
|
| 277 |
+
return _emit(sections, src)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _fetch_html(url, dest):
|
| 281 |
+
"""Fetch a canada.ca page via PowerShell -- the site blocks Python's HTTP
|
| 282 |
+
client at the TLS layer, but accepts PowerShell's .NET stack."""
|
| 283 |
+
return fetch_cached(url, dest, powershell=True)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def preserve_failed(chunks, failures, stored):
|
| 287 |
+
"""Re-attach the last-good chunks of plans this run could not rebuild.
|
| 288 |
+
|
| 289 |
+
Ingestion is a full rebuild of all four booklets into one file, so one plan
|
| 290 |
+
failing -- Canada Life moves a PDF, canada.ca times out, a re-typeset
|
| 291 |
+
booklet no longer segments -- silently deletes that plan's coverage while
|
| 292 |
+
the other three look healthy. A plan's chunks all carry its code in
|
| 293 |
+
act_code, so the stored copy can be restored intact. Restricted to plans
|
| 294 |
+
that actually failed, so a plan removed from SOURCES on purpose (a wound-up
|
| 295 |
+
plan) is not resurrected. Returns (chunks, preserved plan codes).
|
| 296 |
+
"""
|
| 297 |
+
failed = {code for code, _why in failures}
|
| 298 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 299 |
+
only=failed)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def build(allow_shrink=False):
|
| 303 |
+
all_chunks, failures = [], []
|
| 304 |
+
for src in SOURCES.values():
|
| 305 |
+
print(f"Ingesting {src['code']} {src['label'].lower()} ...")
|
| 306 |
+
try:
|
| 307 |
+
if src["format"] == "pdf":
|
| 308 |
+
pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf")
|
| 309 |
+
chunks = parse_pdf_booklet(pdf, src)
|
| 310 |
+
else:
|
| 311 |
+
html = _fetch_html(src["url"], RAW / f"{src['code']}.html"
|
| 312 |
+
).decode("utf-8", "replace")
|
| 313 |
+
chunks = parse_html_booklet(html, src)
|
| 314 |
+
except Exception as exc:
|
| 315 |
+
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 316 |
+
failures.append((src["code"], f"{type(exc).__name__}: {exc}"))
|
| 317 |
+
continue
|
| 318 |
+
if not chunks:
|
| 319 |
+
# A booklet that downloads fine but segments to nothing has failed
|
| 320 |
+
# just as surely as one that 404s -- that is how the CBSA index
|
| 321 |
+
# loss looked -- so it counts as a failure rather than a plan that
|
| 322 |
+
# legitimately has no content.
|
| 323 |
+
print(f" !! {src['code']}: no sections parsed")
|
| 324 |
+
failures.append((src["code"], "no sections parsed"))
|
| 325 |
+
continue
|
| 326 |
+
all_chunks.extend(chunks)
|
| 327 |
+
print(f" {len(chunks)} chunks")
|
| 328 |
+
|
| 329 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 330 |
+
stored_chunks(OUT))
|
| 331 |
+
if preserved:
|
| 332 |
+
print(f" kept the last-good copy of {len(preserved)} booklet(s): "
|
| 333 |
+
f"{', '.join(preserved)}")
|
| 334 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 335 |
+
return False
|
| 336 |
+
print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
|
| 337 |
+
f"booklet(s) -> {OUT}")
|
| 338 |
+
return True
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def main():
|
| 342 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
if __name__ == "__main__":
|
| 346 |
+
main()
|
canlex/caselaw.py
ADDED
|
@@ -0,0 +1,1279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest leading Canadian court and tribunal decisions as section-level chunks.
|
| 2 |
+
|
| 3 |
+
Sources: the official Lexum decisions databases of the Supreme Court of Canada
|
| 4 |
+
(decisions.scc-csc.ca), the Federal Court of Appeal (decisions.fca-caf.gc.ca)
|
| 5 |
+
and the Federal Court (decisions.fct-cf.gc.ca) -- a decision's text sits inside
|
| 6 |
+
an iframe, fetched by appending ?iframe=true to its URL -- plus the in-force
|
| 7 |
+
jurisprudential guides the Immigration and Refugee Board publishes on its own
|
| 8 |
+
site (irb-cisr.gc.ca). This ingests a *curated* set of leading decisions; it is
|
| 9 |
+
deliberately not a comprehensive scrape.
|
| 10 |
+
|
| 11 |
+
py -m canlex.caselaw
|
| 12 |
+
"""
|
| 13 |
+
import re
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
import urllib.error
|
| 17 |
+
import urllib.request
|
| 18 |
+
|
| 19 |
+
from bs4 import BeautifulSoup
|
| 20 |
+
|
| 21 |
+
from ._common import (MONTHS as _MONTHS, norm_ws as _norm, preserve_dropped,
|
| 22 |
+
stored_chunks, write_corpus)
|
| 23 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 24 |
+
|
| 25 |
+
# Each court or tribunal's Lexum decisions database: (display name, item-URL
|
| 26 |
+
# template). All five run the same Lexum platform -- same iframe trick, metadata
|
| 27 |
+
# block and bracketed paragraph numbers -- so one parser serves them all.
|
| 28 |
+
COURTS = {
|
| 29 |
+
"scc": ("Supreme Court of Canada",
|
| 30 |
+
"https://decisions.scc-csc.ca/scc-csc/scc-csc/en/item/{id}/index.do"),
|
| 31 |
+
"fca": ("Federal Court of Appeal",
|
| 32 |
+
"https://decisions.fca-caf.gc.ca/fca-caf/decisions/en/item/{id}/index.do"),
|
| 33 |
+
"fc": ("Federal Court",
|
| 34 |
+
"https://decisions.fct-cf.gc.ca/fc-cf/decisions/en/item/{id}/index.do"),
|
| 35 |
+
"fpslreb": ("Federal Public Sector Labour Relations and Employment Board",
|
| 36 |
+
"https://decisions.fpslreb-crtespf.gc.ca/fpslreb-crtespf/d/en/"
|
| 37 |
+
"item/{id}/index.do"),
|
| 38 |
+
"onca": ("Court of Appeal for Ontario",
|
| 39 |
+
"https://coadecisions.ontariocourts.ca/coa/coa/en/item/{id}/"
|
| 40 |
+
"index.do"),
|
| 41 |
+
"cirb": ("Canada Industrial Relations Board",
|
| 42 |
+
"https://decisia.lexum.com/cirb-ccri/cirb-ccri/en/item/{id}/index.do"),
|
| 43 |
+
}
|
| 44 |
+
_RAW = RAW_DIR / "caselaw"
|
| 45 |
+
OUT = PROCESSED_DIR / "caselaw.json"
|
| 46 |
+
|
| 47 |
+
# A normal browser User-Agent: the courts' Lexum sites denylist a few crawler
|
| 48 |
+
# UAs, while robots.txt otherwise permits the decision pages. Politeness comes
|
| 49 |
+
# from the throttle below and from caching every fetched page on disk.
|
| 50 |
+
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 51 |
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
| 52 |
+
_THROTTLE = 30.0 # seconds between fetches -- Lexum Decisia CAPTCHA-gates high-volume access
|
| 53 |
+
_CHUNK_CHARS = 1800 # target characters per chunk
|
| 54 |
+
|
| 55 |
+
# Marks the post-reasons apparatus (appended legislation, solicitors list),
|
| 56 |
+
# which is not part of the judgment's reasons.
|
| 57 |
+
_APPARATUS = re.compile(
|
| 58 |
+
r"^\s*(APPENDIX\b|Solicitors?\s+for\b|NAMES\s+OF\s+COUNSEL\b)", re.I)
|
| 59 |
+
|
| 60 |
+
# Curated leading cases on Canadian border / immigration / customs / Charter
|
| 61 |
+
# law. 'court' selects a database in COURTS; 'id' is the verified Lexum item ID;
|
| 62 |
+
# 'short' and 'topic' are curated. The case name, citation and date are parsed
|
| 63 |
+
# from the page. FCA decisions later reversed by the SCC are deliberately
|
| 64 |
+
# omitted -- CanLex serves good law, and the SCC versions are already listed.
|
| 65 |
+
CASES = [
|
| 66 |
+
# --- Supreme Court of Canada ---
|
| 67 |
+
{"court": "scc", "id": 18078, "short": "Vavilov",
|
| 68 |
+
"topic": "Standard of review on judicial review; the reasonableness "
|
| 69 |
+
"standard for administrative decisions"},
|
| 70 |
+
{"court": "scc", "id": 20081, "short": "Mason",
|
| 71 |
+
"topic": "Inadmissibility under IRPA s. 34(1)(e) for acts of violence "
|
| 72 |
+
"endangering safety in Canada; reasonableness review"},
|
| 73 |
+
{"court": "scc", "id": 18510, "short": "Fraser",
|
| 74 |
+
"topic": "Adverse-effect discrimination under Charter s. 15(1); the "
|
| 75 |
+
"RCMP job-sharing pension rules' disproportionate impact on "
|
| 76 |
+
"women; substantive equality methodology"},
|
| 77 |
+
{"court": "scc", "id": 16146, "short": "Igloo Vikski",
|
| 78 |
+
"topic": "Customs tariff classification methodology; how the General "
|
| 79 |
+
"Rules for the Interpretation of the Harmonized System apply "
|
| 80 |
+
"(Rules 1 and 2(b)); the SCC anchor for tariff-classification "
|
| 81 |
+
"disputes"},
|
| 82 |
+
{"court": "scc", "id": 16803, "short": "Tran",
|
| 83 |
+
"topic": "Serious criminality and inadmissibility under IRPA s. 36; the "
|
| 84 |
+
"meaning of a term of imprisonment and an offence punishable by"},
|
| 85 |
+
{"court": "scc", "id": 15647, "short": "B010",
|
| 86 |
+
"topic": "Inadmissibility for people smuggling under IRPA s. 37(1)(b); "
|
| 87 |
+
"organized criminality"},
|
| 88 |
+
{"court": "scc", "id": 15648, "short": "Appulonappa",
|
| 89 |
+
"topic": "The human smuggling offence in IRPA s. 117; constitutional "
|
| 90 |
+
"overbreadth and humanitarian aid to asylum seekers"},
|
| 91 |
+
{"court": "scc", "id": 14419, "short": "Febles",
|
| 92 |
+
"topic": "Exclusion from refugee protection for a serious non-political "
|
| 93 |
+
"crime under Article 1F(b) of the Refugee Convention"},
|
| 94 |
+
{"court": "scc", "id": 13184, "short": "Ezokola",
|
| 95 |
+
"topic": "Complicity and exclusion from refugee protection for "
|
| 96 |
+
"international crimes under Article 1F(a)"},
|
| 97 |
+
{"court": "scc", "id": 15665, "short": "Kanthasamy",
|
| 98 |
+
"topic": "Humanitarian and compassionate relief under IRPA s. 25; the "
|
| 99 |
+
"best interests of a child"},
|
| 100 |
+
{"court": "scc", "id": 13137, "short": "Agraira",
|
| 101 |
+
"topic": "Ministerial relief from inadmissibility on security grounds; "
|
| 102 |
+
"the national interest under IRPA"},
|
| 103 |
+
{"court": "scc", "id": 6901, "short": "Khosa",
|
| 104 |
+
"topic": "Standard of review of immigration decisions; judicial review "
|
| 105 |
+
"of a removal order"},
|
| 106 |
+
{"court": "scc", "id": 2345, "short": "Charkaoui",
|
| 107 |
+
"topic": "Security certificates and immigration detention; the Charter "
|
| 108 |
+
"and procedural fairness"},
|
| 109 |
+
{"court": "scc", "id": 1937, "short": "Suresh",
|
| 110 |
+
"topic": "Deportation to a risk of torture; Charter s. 7 and removal on "
|
| 111 |
+
"security grounds"},
|
| 112 |
+
{"court": "scc", "id": 17759, "short": "Chhina",
|
| 113 |
+
"topic": "Habeas corpus as a remedy for immigration detention; review of "
|
| 114 |
+
"lengthy detention"},
|
| 115 |
+
{"court": "scc", "id": 1717, "short": "Baker",
|
| 116 |
+
"topic": "Procedural fairness in administrative decisions; humanitarian "
|
| 117 |
+
"and compassionate review; the duty to give reasons"},
|
| 118 |
+
{"court": "scc", "id": 39, "short": "Singh",
|
| 119 |
+
"topic": "Charter s. 7 rights of refugee claimants; the right to an oral "
|
| 120 |
+
"hearing"},
|
| 121 |
+
{"court": "scc", "id": 377, "short": "Simmons",
|
| 122 |
+
"topic": "Customs searches at the border; Charter s. 8 and the reasonable "
|
| 123 |
+
"expectation of privacy on entry to Canada"},
|
| 124 |
+
{"court": "scc", "id": 1694, "short": "Monney",
|
| 125 |
+
"topic": "Border detention for a customs search; reasonable suspicion "
|
| 126 |
+
"and the Customs Act"},
|
| 127 |
+
{"court": "scc", "id": 986, "short": "Dehghani",
|
| 128 |
+
"topic": "Charter rights at a port of entry; secondary examination and "
|
| 129 |
+
"the right to counsel"},
|
| 130 |
+
{"court": "scc", "id": 1627, "short": "Pushpanathan",
|
| 131 |
+
"topic": "Exclusion from refugee protection under Article 1F(c) for acts "
|
| 132 |
+
"contrary to the purposes of the United Nations"},
|
| 133 |
+
{"court": "scc", "id": 1023, "short": "Ward",
|
| 134 |
+
"topic": "The refugee definition; a particular social group; the "
|
| 135 |
+
"availability of state protection"},
|
| 136 |
+
{"court": "scc", "id": 855, "short": "Chiarelli",
|
| 137 |
+
"topic": "Charter s. 7 fundamental justice and the deportation of "
|
| 138 |
+
"permanent residents for serious criminality"},
|
| 139 |
+
{"court": "scc", "id": 1940, "short": "Chieu",
|
| 140 |
+
"topic": "Immigration Appeal Division appeals from a removal order; "
|
| 141 |
+
"foreign hardship is a relevant consideration"},
|
| 142 |
+
{"court": "scc", "id": 2284, "short": "Medovarski",
|
| 143 |
+
"topic": "The objectives of IRPA; a non-citizen has no Charter s. 7 "
|
| 144 |
+
"right to remain in Canada"},
|
| 145 |
+
{"court": "scc", "id": 2273, "short": "Mugesera",
|
| 146 |
+
"topic": "Inadmissibility for crimes against humanity; incitement to "
|
| 147 |
+
"genocide and the elements of the offence"},
|
| 148 |
+
{"court": "scc", "id": 13643, "short": "Harkat",
|
| 149 |
+
"topic": "Constitutionality of the IRPA security-certificate regime and "
|
| 150 |
+
"the special advocate scheme"},
|
| 151 |
+
{"court": "scc", "id": 7899, "short": "Németh",
|
| 152 |
+
"topic": "Extradition and the refugee principle of non-refoulement; the "
|
| 153 |
+
"Minister's duty when surrendering a protected person"},
|
| 154 |
+
{"court": "scc", "id": 7944, "short": "Mavi",
|
| 155 |
+
"topic": "Sponsorship-undertaking debt; the procedural fairness owed "
|
| 156 |
+
"before the Crown enforces a sponsorship debt"},
|
| 157 |
+
{"court": "scc", "id": 12904, "short": "Pham",
|
| 158 |
+
"topic": "Collateral immigration consequences as a factor in fixing a "
|
| 159 |
+
"criminal sentence"},
|
| 160 |
+
{"court": "scc", "id": 1299, "short": "Chan",
|
| 161 |
+
"topic": "Refugee protection; membership in a particular social group "
|
| 162 |
+
"and a well-founded fear of persecution"},
|
| 163 |
+
{"court": "scc", "id": 1425, "short": "Jacques",
|
| 164 |
+
"topic": "Border searches; reasonable suspicion for a vehicle stop near "
|
| 165 |
+
"the border under the Customs Act"},
|
| 166 |
+
{"court": "scc", "id": 2198, "short": "Martineau",
|
| 167 |
+
"topic": "Whether an ascertained-forfeiture proceeding under the Customs "
|
| 168 |
+
"Act is penal and engages the right against self-incrimination"},
|
| 169 |
+
{"court": "scc", "id": 21317, "short": "Carignan",
|
| 170 |
+
"topic": "Powers of arrest without warrant under Criminal Code s. 495; an "
|
| 171 |
+
"arrest that contravenes the s. 495(2) limits can ground a breach "
|
| 172 |
+
"of the Charter s. 9 right against arbitrary arrest, despite "
|
| 173 |
+
"s. 495(3)"},
|
| 174 |
+
{"court": "scc", "id": 14502, "short": "Fearon",
|
| 175 |
+
"topic": "Searches of cell phones incident to arrest; the tailored "
|
| 176 |
+
"common-law framework under Charter s. 8 -- the doctrinal "
|
| 177 |
+
"companion to the border device-search cases (Canfield, Pike)"},
|
| 178 |
+
{"court": "scc", "id": 19957, "short": "CCRf",
|
| 179 |
+
"topic": "Constitutionality of the Safe Third Country Agreement; Charter "
|
| 180 |
+
"ss. 7 and 15 review of designating the United States as a safe "
|
| 181 |
+
"country for refugee claimants"},
|
| 182 |
+
{"court": "scc", "id": 2507, "short": "Hydro-Quebec",
|
| 183 |
+
"topic": "Duty to accommodate and undue hardship; the employer must "
|
| 184 |
+
"arrange the workplace and duties to allow the employee to work, "
|
| 185 |
+
"but is not required to fundamentally alter the conditions of work"},
|
| 186 |
+
{"court": "scc", "id": 16679, "short": "Elk Valley",
|
| 187 |
+
"topic": "Duty to accommodate addiction in safety-sensitive workplaces; "
|
| 188 |
+
"the prima facie discrimination test and dismissal for breach of "
|
| 189 |
+
"a no-free-accident drug and alcohol policy"},
|
| 190 |
+
{"court": "scc", "id": 910, "short": "Renaud",
|
| 191 |
+
"topic": "Tripartite duty to accommodate in unionized workplaces; the "
|
| 192 |
+
"shared obligations of the employer, the union and the employee "
|
| 193 |
+
"to the point of undue hardship"},
|
| 194 |
+
{"court": "scc", "id": 13106, "short": "Irving",
|
| 195 |
+
"topic": "Random alcohol testing in safety-sensitive workplaces; balancing "
|
| 196 |
+
"the employer's safety interests against employee privacy under a "
|
| 197 |
+
"collective agreement"},
|
| 198 |
+
{"court": "scc", "id": 14577, "short": "MPAO",
|
| 199 |
+
"topic": "Charter s. 2(d) freedom of association and collective bargaining; "
|
| 200 |
+
"the exclusion of RCMP members from a meaningful labour relations "
|
| 201 |
+
"regime is unconstitutional"},
|
| 202 |
+
{"court": "scc", "id": 14610, "short": "SFL",
|
| 203 |
+
"topic": "Constitutional right to strike under Charter s. 2(d); essential "
|
| 204 |
+
"services legislation that prohibits strike action without a "
|
| 205 |
+
"meaningful alternative dispute-resolution mechanism"},
|
| 206 |
+
{"court": "scc", "id": 2306, "short": "Ferras",
|
| 207 |
+
"topic": "Extradition under the record-of-the-case procedure; Charter s. 7 "
|
| 208 |
+
"requires the extradition judge to weigh the sufficiency and "
|
| 209 |
+
"reliability of the evidence before committal"},
|
| 210 |
+
|
| 211 |
+
# --- Federal Court of Appeal ---
|
| 212 |
+
{"court": "fca", "id": 345651, "short": "Jane Doe",
|
| 213 |
+
"topic": "Judicial review of 2017 PSLREB 55; compensation for a "
|
| 214 |
+
"victim of workplace sexual assault in the federal public "
|
| 215 |
+
"service; remedial authority of the Board"},
|
| 216 |
+
{"court": "fca", "id": 127201, "short": "Patanguli",
|
| 217 |
+
"topic": "A de novo hearing before the Board cures procedural defects "
|
| 218 |
+
"in the employer's investigation (applying Tipple); dismissal "
|
| 219 |
+
"of a CIC employee for cheating in a staffing process"},
|
| 220 |
+
{"court": "fc", "id": 525537, "short": "Marentette",
|
| 221 |
+
"topic": "Workplace violence and harassment investigations under the "
|
| 222 |
+
"Canada Labour Code; procedural fairness owed to the "
|
| 223 |
+
"complainant in the investigation process"},
|
| 224 |
+
{"court": "fca", "id": 33278, "short": "Cha",
|
| 225 |
+
"topic": "Scope of s. 44(1) inadmissibility reports and Minister's "
|
| 226 |
+
"delegate removal orders at the port of entry; officers and "
|
| 227 |
+
"delegates have no discretion to weigh humanitarian factors "
|
| 228 |
+
"for a foreign national's ordinary criminality; the content of "
|
| 229 |
+
"procedural fairness at the report stage"},
|
| 230 |
+
{"court": "fca", "id": 143136, "short": "Huruglica",
|
| 231 |
+
"topic": "The Refugee Appeal Division reviews the Refugee Protection "
|
| 232 |
+
"Division for correctness and makes its own independent assessment"},
|
| 233 |
+
{"court": "fca", "id": 143152, "short": "Singh (new evidence)",
|
| 234 |
+
"topic": "Admitting new evidence at the Refugee Appeal Division under "
|
| 235 |
+
"IRPA s. 110(4)"},
|
| 236 |
+
{"court": "fca", "id": 37663, "short": "Hernandez Febles",
|
| 237 |
+
"topic": "Exclusion from refugee protection for a serious non-political "
|
| 238 |
+
"crime, Refugee Convention Article 1F(b)"},
|
| 239 |
+
{"court": "fca", "id": 36253, "short": "Jayasekara",
|
| 240 |
+
"topic": "The test for a serious crime under Article 1F(b) exclusion "
|
| 241 |
+
"from refugee protection"},
|
| 242 |
+
{"court": "fca", "id": 35786, "short": "Raza",
|
| 243 |
+
"topic": "Pre-removal risk assessment; the test for new evidence under "
|
| 244 |
+
"IRPA s. 113(a)"},
|
| 245 |
+
{"court": "fca", "id": 99694, "short": "Najafi",
|
| 246 |
+
"topic": "Security inadmissibility under IRPA s. 34(1)(b), subversion "
|
| 247 |
+
"against a government"},
|
| 248 |
+
{"court": "fca", "id": 108889, "short": "Kanagendren",
|
| 249 |
+
"topic": "Security inadmissibility for membership under IRPA s. 34(1)(f); "
|
| 250 |
+
"membership requires no complicity analysis"},
|
| 251 |
+
{"court": "fca", "id": 35313, "short": "Sittampalam",
|
| 252 |
+
"topic": "Organized criminality under IRPA s. 37(1)(a); the broad meaning "
|
| 253 |
+
"of a criminal organization"},
|
| 254 |
+
{"court": "fca", "id": 31607, "short": "Thanabalasingham",
|
| 255 |
+
"topic": "Immigration detention review; the Minister's onus and the "
|
| 256 |
+
"weight owed to prior detention decisions"},
|
| 257 |
+
{"court": "fca", "id": 305100, "short": "Lunyamila",
|
| 258 |
+
"topic": "Immigration detention where a detainee will not cooperate with "
|
| 259 |
+
"their own removal"},
|
| 260 |
+
{"court": "fca", "id": 483607, "short": "Brown",
|
| 261 |
+
"topic": "The constitutionality of immigration detention; the Charter "
|
| 262 |
+
"and lengthy detention"},
|
| 263 |
+
{"court": "fca", "id": 36347, "short": "Baron",
|
| 264 |
+
"topic": "Deferral of removal by an enforcement officer; the narrow "
|
| 265 |
+
"scope of the discretion to defer"},
|
| 266 |
+
{"court": "fca", "id": 520921, "short": "Galindo Camayo",
|
| 267 |
+
"topic": "Reasonableness review of an immigration decision and the role "
|
| 268 |
+
"of certified questions on appeal"},
|
| 269 |
+
{"court": "fca", "id": 501244, "short": "Best Buy",
|
| 270 |
+
"topic": "Customs tariff classification; methodology and appeals from "
|
| 271 |
+
"the Canadian International Trade Tribunal"},
|
| 272 |
+
{"court": "fca", "id": 466027, "short": "Honey Fashions",
|
| 273 |
+
"topic": "Customs; judicial review of the Canada Border Services "
|
| 274 |
+
"Agency's exercise of discretion"},
|
| 275 |
+
{"court": "fca", "id": 419470, "short": "Hociung",
|
| 276 |
+
"topic": "Whether gold coins are currency or monetary instruments that "
|
| 277 |
+
"must be reported on import under the PCMLTFA cross-border regime"},
|
| 278 |
+
{"court": "fca", "id": 32333, "short": "Poshteh",
|
| 279 |
+
"topic": "Inadmissibility for membership in a terrorist organization "
|
| 280 |
+
"under IRPA s. 34(1)(f); relevance of the claimant's age"},
|
| 281 |
+
{"court": "fca", "id": 35576, "short": "Hinzman",
|
| 282 |
+
"topic": "Refugee and humanitarian and compassionate claims by United "
|
| 283 |
+
"States military deserters"},
|
| 284 |
+
{"court": "fca", "id": 35611, "short": "Thamotharem",
|
| 285 |
+
"topic": "Refugee Protection Division hearing procedure; order of "
|
| 286 |
+
"questioning and fettering of discretion by guidelines"},
|
| 287 |
+
{"court": "fca", "id": 37227, "short": "Toussaint",
|
| 288 |
+
"topic": "Access to the Interim Federal Health Program for an indigent "
|
| 289 |
+
"applicant without immigration status"},
|
| 290 |
+
{"court": "fca", "id": 31447, "short": "Rahaman",
|
| 291 |
+
"topic": "Refugee claims and the meaning of a 'no credible basis' "
|
| 292 |
+
"finding under IRPA"},
|
| 293 |
+
{"court": "fca", "id": 521840, "short": "Rodriguez Anzola",
|
| 294 |
+
"topic": "Criminal inadmissibility under IRPA s. 36(1)(b) for a "
|
| 295 |
+
"conviction abroad; whether an immigration decision-maker may "
|
| 296 |
+
"consider that the defence of duress was effectively unavailable "
|
| 297 |
+
"in the foreign jurisdiction"},
|
| 298 |
+
|
| 299 |
+
# --- Federal Court ---
|
| 300 |
+
{"court": "fc", "id": 64594, "short": "Goburdhun",
|
| 301 |
+
"topic": "Inadmissibility for misrepresentation under IRPA s. 40; a "
|
| 302 |
+
"frequently-cited summary of the governing principles"},
|
| 303 |
+
{"court": "fc", "id": 492842, "short": "Garcia",
|
| 304 |
+
"topic": "Criminal inadmissibility; the effect of withdrawn charges and "
|
| 305 |
+
"a claim of self-defence"},
|
| 306 |
+
{"court": "fc", "id": 483303, "short": "Kaur",
|
| 307 |
+
"topic": "Procedural fairness; the right to a meaningful opportunity to "
|
| 308 |
+
"answer a visa officer's concerns"},
|
| 309 |
+
{"court": "fc", "id": 56900, "short": "Nguyen",
|
| 310 |
+
"topic": "Customs Act; judicial review of the amount set for a penalty "
|
| 311 |
+
"or ascertained forfeiture"},
|
| 312 |
+
{"court": "fc", "id": 57473, "short": "Almrei",
|
| 313 |
+
"topic": "Reasonableness of an IRPA security certificate after "
|
| 314 |
+
"Charkaoui; the test on re-determination"},
|
| 315 |
+
{"court": "fc", "id": 48199, "short": "Wang",
|
| 316 |
+
"topic": "Innocent-misrepresentation exception to IRPA s. 40(1)(a); the "
|
| 317 |
+
"section reaches indirect misrepresentations made by another "
|
| 318 |
+
"party, subject to a narrow carve-out where the applicant had no "
|
| 319 |
+
"knowledge of and no control over the misrepresentation"},
|
| 320 |
+
{"court": "fc", "id": 47039, "short": "Bellido",
|
| 321 |
+
"topic": "Materiality of a misrepresentation under IRPA s. 40(1)(a); a "
|
| 322 |
+
"misrepresentation need not be intentional, deliberate or "
|
| 323 |
+
"negligent so long as it could have induced an error in the "
|
| 324 |
+
"administration of the Act"},
|
| 325 |
+
|
| 326 |
+
# --- Federal courts: PCMLTFA cross-border currency forfeiture ---
|
| 327 |
+
{"court": "fca", "id": 36092, "short": "Sellathurai",
|
| 328 |
+
"topic": "PCMLTFA cross-border currency forfeiture; on a s. 30 appeal the "
|
| 329 |
+
"issue is whether the s. 12 reporting duty was contravened, and "
|
| 330 |
+
"the claimant must satisfy the Minister that the seized funds "
|
| 331 |
+
"are of legitimate origin"},
|
| 332 |
+
{"court": "fca", "id": 35921, "short": "Dag",
|
| 333 |
+
"topic": "PCMLTFA currency forfeiture; the limited scope of review of "
|
| 334 |
+
"the Minister's decision and the privative clause"},
|
| 335 |
+
{"court": "fca", "id": 35595, "short": "Tourki",
|
| 336 |
+
"topic": "PCMLTFA; the structure of the s. 30 appeal to the Federal "
|
| 337 |
+
"Court against the forfeiture of seized currency"},
|
| 338 |
+
# Williams, 2017 FCA 252 (item 304112) interpreted PCMLTFA s. 13, the
|
| 339 |
+
# 'election not to proceed' exception to the reporting duty. Parliament
|
| 340 |
+
# repealed s. 13 in 2018 (S.C. 2018, c. 27, s. 174), so Williams is no
|
| 341 |
+
# longer good law on a live provision and is excluded from the corpus.
|
| 342 |
+
{"court": "fc", "id": 37898, "short": "Dokaj",
|
| 343 |
+
"topic": "PCMLTFA; the Federal Court's jurisdiction on a s. 30 appeal is "
|
| 344 |
+
"limited to whether the s. 12 reporting duty was contravened"},
|
| 345 |
+
{"court": "fc", "id": 62413, "short": "Da Huang",
|
| 346 |
+
"topic": "PCMLTFA currency forfeiture; partial return of seized funds "
|
| 347 |
+
"where only part is shown to be of legitimate origin"},
|
| 348 |
+
|
| 349 |
+
# --- Federal Public Sector Labour Relations and Employment Board ---
|
| 350 |
+
# 2026-07 gap audit: the governing authorities the corpus's own
|
| 351 |
+
# decisions apply, identified per doctrinal domain and verified
|
| 352 |
+
# against the issuing courts' records before ingestion.
|
| 353 |
+
{"court": "scc", "id": 7799, "short": "Grant",
|
| 354 |
+
"topic": "The governing s. 24(2) exclusion framework and the "
|
| 355 |
+
"controlling definition of physical and psychological "
|
| 356 |
+
"detention"},
|
| 357 |
+
{"court": "scc", "id": 2015, "short": "Jarvis",
|
| 358 |
+
"topic": "The predominant-purpose test: when a regulatory audit "
|
| 359 |
+
"becomes a penal investigation engaging full Charter "
|
| 360 |
+
"protections"},
|
| 361 |
+
{"court": "scc", "id": 1924, "short": "Golden",
|
| 362 |
+
"topic": "Strip searches are never routine: independent reasonable "
|
| 363 |
+
"grounds and a reasonable manner required"},
|
| 364 |
+
{"court": "scc", "id": 13264, "short": "Chehil",
|
| 365 |
+
"topic": "The reasonable-suspicion standard: an objectively "
|
| 366 |
+
"discernible constellation of factors assessed in their "
|
| 367 |
+
"totality"},
|
| 368 |
+
{"court": "scc", "id": 5274, "short": "Hunter",
|
| 369 |
+
"topic": "The s. 8 foundation: reasonable expectation of privacy and "
|
| 370 |
+
"the presumptive warrant requirement"},
|
| 371 |
+
{"court": "scc", "id": 7800, "short": "Suberu",
|
| 372 |
+
"topic": "Section 10(b) attaches immediately on detention; when an "
|
| 373 |
+
"investigative encounter crystallizes into detention"},
|
| 374 |
+
{"court": "scc", "id": 4627, "short": "Kang-Brown",
|
| 375 |
+
"topic": "Sniffer-dog deployment is a s. 8 search, grounded on "
|
| 376 |
+
"reasonable suspicion"},
|
| 377 |
+
{"court": "scc", "id": 1492, "short": "Collins",
|
| 378 |
+
"topic": "The tripartite s. 8 reasonableness test: authorized by "
|
| 379 |
+
"law, a reasonable law, conducted reasonably"},
|
| 380 |
+
{"court": "scc", "id": 15478, "short": "Guindon",
|
| 381 |
+
"topic": "When an administrative monetary penalty engages Charter s. "
|
| 382 |
+
"11 protections (it ordinarily does not)"},
|
| 383 |
+
{"court": "scc", "id": 13327, "short": "Vu",
|
| 384 |
+
"topic": "Computers attract a distinct, heightened privacy interest "
|
| 385 |
+
"requiring specific search authorization"},
|
| 386 |
+
{"court": "scc", "id": 13265, "short": "MacKenzie",
|
| 387 |
+
"topic": "Reasonable suspicion applied to a vehicle sniffer-dog "
|
| 388 |
+
"search on traveller-profile indicators"},
|
| 389 |
+
{"court": "scc", "id": 21114, "short": "Pepa",
|
| 390 |
+
"topic": "IAD appeal rights under s. 63(2) for a foreign national "
|
| 391 |
+
"whose permanent resident visa expired after entry"},
|
| 392 |
+
{"court": "scc", "id": 1874, "short": "Mattel",
|
| 393 |
+
"topic": "Customs valuation under s. 48: royalties and licence fees "
|
| 394 |
+
"are dutiable only as a condition of the sale"},
|
| 395 |
+
{"court": "scc", "id": 266, "short": "Wigglesworth",
|
| 396 |
+
"topic": "The two-part test for Charter s. 11: proceedings criminal "
|
| 397 |
+
"by nature or carrying a true penal consequence"},
|
| 398 |
+
{"court": "scc", "id": 1276, "short": "Weber",
|
| 399 |
+
"topic": "Exclusive jurisdiction: disputes arising from the "
|
| 400 |
+
"collective agreement go to grievance arbitration, not the "
|
| 401 |
+
"courts"},
|
| 402 |
+
{"court": "scc", "id": 2211, "short": "Vaughan",
|
| 403 |
+
"topic": "Courts decline jurisdiction over federal public-service "
|
| 404 |
+
"workplace disputes covered by the statutory grievance "
|
| 405 |
+
"scheme"},
|
| 406 |
+
{"court": "scc", "id": 1876, "short": "McKinley",
|
| 407 |
+
"topic": "Dishonesty is not automatically just cause for dismissal: "
|
| 408 |
+
"the contextual, proportional approach"},
|
| 409 |
+
{"court": "scc", "id": 5271, "short": "Gagnon",
|
| 410 |
+
"topic": "The five governing principles of the union's duty of fair "
|
| 411 |
+
"representation"},
|
| 412 |
+
{"court": "scc", "id": 1877, "short": "Noel (DFR)",
|
| 413 |
+
"topic": "The duty of fair representation restated: arbitrary, "
|
| 414 |
+
"discriminatory, bad-faith or seriously negligent conduct"},
|
| 415 |
+
{"court": "scc", "id": 1724, "short": "Meiorin",
|
| 416 |
+
"topic": "The unified three-step bona fide occupational requirement "
|
| 417 |
+
"test for workplace standards and accommodation"},
|
| 418 |
+
{"court": "scc", "id": 96, "short": "Fraser (loyalty)",
|
| 419 |
+
"topic": "The federal public servant's duty of loyalty balanced "
|
| 420 |
+
"against freedom of expression"},
|
| 421 |
+
{"court": "scc", "id": 106, "short": "Cardinal",
|
| 422 |
+
"topic": "Procedural fairness binds every public authority; a breach "
|
| 423 |
+
"voids the decision regardless of the likely outcome"},
|
| 424 |
+
{"court": "scc", "id": 2574, "short": "CJL v NEB",
|
| 425 |
+
"topic": "The reasonable-apprehension-of-bias test: what an informed "
|
| 426 |
+
"person, viewing the matter realistically, would conclude"},
|
| 427 |
+
{"court": "scc", "id": 2660, "short": "Harrison",
|
| 428 |
+
"topic": "The Carltona doctrine: ministerial powers are "
|
| 429 |
+
"presumptively exercisable by responsible departmental "
|
| 430 |
+
"officials"},
|
| 431 |
+
{"court": "scc", "id": 2446, "short": "Maple Lodge",
|
| 432 |
+
"topic": "Fettering of discretion: policy guidelines cannot be "
|
| 433 |
+
"applied as binding rules excluding relevant considerations"},
|
| 434 |
+
{"court": "scc", "id": 1879, "short": "Mount Sinai",
|
| 435 |
+
"topic": "Legitimate expectations are procedural only; public-law "
|
| 436 |
+
"estoppel against a minister is narrow and yields to "
|
| 437 |
+
"statute"},
|
| 438 |
+
{"court": "scc", "id": 1107, "short": "Mobil Oil",
|
| 439 |
+
"topic": "The futility exception: relief for a fairness breach may "
|
| 440 |
+
"be withheld where the outcome was legally inevitable"},
|
| 441 |
+
{"court": "scc", "id": 15438, "short": "Strickland",
|
| 442 |
+
"topic": "Discretionary refusal of judicial review and the adequate- "
|
| 443 |
+
"alternative-remedy framework"},
|
| 444 |
+
{"court": "scc", "id": 1808, "short": "Blencoe",
|
| 445 |
+
"topic": "Administrative delay is an abuse of process only on "
|
| 446 |
+
"significant prejudice from unacceptable delay"},
|
| 447 |
+
{"court": "scc", "id": 19439, "short": "Abrametz",
|
| 448 |
+
"topic": "The modern three-part test for inordinate administrative "
|
| 449 |
+
"delay, with proportionate remedies"},
|
| 450 |
+
{"court": "scc", "id": 20730, "short": "Auer",
|
| 451 |
+
"topic": "Vavilov reasonableness, with a presumption of validity, "
|
| 452 |
+
"governs vires challenges to subordinate legislation"},
|
| 453 |
+
{"court": "scc", "id": 431, "short": "Prassad",
|
| 454 |
+
"topic": "Administrative tribunals are masters of their own "
|
| 455 |
+
"procedure, subject only to fairness"},
|
| 456 |
+
{"court": "fca", "id": 214260, "short": "Sharma",
|
| 457 |
+
"topic": "Section 44 reports and referrals for permanent residents: "
|
| 458 |
+
"very limited discretion, no duty to weigh H&C factors"},
|
| 459 |
+
{"court": "fca", "id": 407446, "short": "Sidhu",
|
| 460 |
+
"topic": "Misrepresentation under s. 40: the duty of candour and the "
|
| 461 |
+
"narrow innocent-misrepresentation exception"},
|
| 462 |
+
{"court": "fca", "id": 33323, "short": "Ragupathy",
|
| 463 |
+
"topic": "The s. 115(2)(a) danger-opinion framework: danger "
|
| 464 |
+
"assessment, then risk, then balancing"},
|
| 465 |
+
{"court": "fca", "id": 521220, "short": "Obazughanmwen",
|
| 466 |
+
"topic": "Section 44 officers and delegates have no mandate for "
|
| 467 |
+
"detailed H&C or abuse-of-process inquiries"},
|
| 468 |
+
{"court": "fca", "id": 35977, "short": "Nagalingam",
|
| 469 |
+
"topic": "The s. 115(2)(b) refoulement branch: assessing the nature "
|
| 470 |
+
"and severity of the acts committed"},
|
| 471 |
+
{"court": "fca", "id": 423316, "short": "Revell",
|
| 472 |
+
"topic": "An inadmissibility determination and s. 44 referral do not "
|
| 473 |
+
"engage Charter s. 7, even for long-term permanent "
|
| 474 |
+
"residents"},
|
| 475 |
+
{"court": "fca", "id": 143934, "short": "Bermudez",
|
| 476 |
+
"topic": "Cessation applications: the hearings officer has no "
|
| 477 |
+
"discretion to weigh H&C factors or a child's best "
|
| 478 |
+
"interests"},
|
| 479 |
+
{"court": "fca", "id": 31594, "short": "Coomaraswamy",
|
| 480 |
+
"topic": "Vacation of refugee protection under s. 109: establishing "
|
| 481 |
+
"misrepresentation; only untainted evidence on the second "
|
| 482 |
+
"branch"},
|
| 483 |
+
{"court": "fca", "id": 36788, "short": "Zeng",
|
| 484 |
+
"topic": "The three-question test for Article 1E exclusion: status "
|
| 485 |
+
"substantially similar to nationals of a third country"},
|
| 486 |
+
{"court": "fca", "id": 32011, "short": "Li (s. 97)",
|
| 487 |
+
"topic": "The s. 97 protected-person standard is balance of "
|
| 488 |
+
"probabilities, unlike the lower s. 96 threshold"},
|
| 489 |
+
{"court": "fca", "id": 36349, "short": "Li (detention)",
|
| 490 |
+
"topic": "Anticipated future length of detention is assessed on "
|
| 491 |
+
"proceedings conducted expeditiously"},
|
| 492 |
+
{"court": "fca", "id": 36729, "short": "C.B. Powell",
|
| 493 |
+
"topic": "Prematurity: the Customs Act re-determination and CITT "
|
| 494 |
+
"route must be exhausted before judicial review"},
|
| 495 |
+
{"court": "fca", "id": 181427, "short": "Bri-Chem",
|
| 496 |
+
"topic": "CBSA must follow CITT precedent when processing "
|
| 497 |
+
"corrections; relitigating settled rulings is an abuse of "
|
| 498 |
+
"process"},
|
| 499 |
+
{"court": "fca", "id": 407467, "short": "Chen",
|
| 500 |
+
"topic": "A s. 135 Customs Act action tests only whether the "
|
| 501 |
+
"contravention occurred; officer conduct belongs to other "
|
| 502 |
+
"recourse"},
|
| 503 |
+
{"court": "fca", "id": 36351, "short": "Fritz Marketing",
|
| 504 |
+
"topic": "Challenges to Detailed Adjustment Statements proceed "
|
| 505 |
+
"through the Customs Act appeal route, not judicial review"},
|
| 506 |
+
{"court": "fca", "id": 108541, "short": "Skechers",
|
| 507 |
+
"topic": "Transaction value: research cost-sharing payments to the "
|
| 508 |
+
"vendor form part of the price paid or payable"},
|
| 509 |
+
{"court": "fca", "id": 231365, "short": "Heyser",
|
| 510 |
+
"topic": "Termination on revocation of reliability status must rest "
|
| 511 |
+
"on legitimate grounds, reviewable by the Board"},
|
| 512 |
+
{"court": "fca", "id": 308603, "short": "CPR (fairness)",
|
| 513 |
+
"topic": "Procedural fairness on judicial review: whether the party "
|
| 514 |
+
"knew the case to meet and had a full chance to respond"},
|
| 515 |
+
{"court": "fc", "id": 308992, "short": "McAlpin",
|
| 516 |
+
"topic": "The s. 44 discretion synthesis: any H&C discretion in "
|
| 517 |
+
"criminality cases is very limited, if it exists at all"},
|
| 518 |
+
{"court": "fc", "id": 54573, "short": "Frazee",
|
| 519 |
+
"topic": "Whether an employer measure labelled administrative is in "
|
| 520 |
+
"substance discipline: corrective intent and impact"},
|
| 521 |
+
{"court": "onca", "id": 14196, "short": "Chaudhary",
|
| 522 |
+
"topic": "Habeas corpus is available for lengthy immigration "
|
| 523 |
+
"detention of uncertain duration"},
|
| 524 |
+
# Second tier from the 2026-07 gap audit, added at the maintainer's
|
| 525 |
+
# direction: the strongest of each reviewer's deliberate exclusions.
|
| 526 |
+
{"court": "scc", "id": 7865, "short": "Nolet",
|
| 527 |
+
"topic": "Dual-purpose vehicle searches: a valid regulatory search may "
|
| 528 |
+
"shift to criminal investigation; the Charter consequences of "
|
| 529 |
+
"each step assessed on its own footing"},
|
| 530 |
+
{"court": "scc", "id": 19043, "short": "Horrocks",
|
| 531 |
+
"topic": "Exclusive labour-arbitration jurisdiction over human-rights "
|
| 532 |
+
"disputes arising from the collective agreement; the Weber "
|
| 533 |
+
"framework applied to competing statutory tribunals"},
|
| 534 |
+
{"court": "scc", "id": 2075, "short": "Parry Sound",
|
| 535 |
+
"topic": "Statutory employment and human-rights protections are read "
|
| 536 |
+
"into every collective agreement and enforceable at "
|
| 537 |
+
"arbitration"},
|
| 538 |
+
{"court": "scc", "id": 16827, "short": "AJC v Canada",
|
| 539 |
+
"topic": "Management rights are exercised subject to reasonableness "
|
| 540 |
+
"and consistency with the collective agreement (standby "
|
| 541 |
+
"scheduling of counsel)"},
|
| 542 |
+
{"court": "scc", "id": 2291, "short": "Hilewitz",
|
| 543 |
+
"topic": "Medical inadmissibility for excessive demand requires "
|
| 544 |
+
"individualized assessment of the applicant's actual "
|
| 545 |
+
"circumstances, including family support"},
|
| 546 |
+
{"court": "fca", "id": 363658, "short": "Tapambwa",
|
| 547 |
+
"topic": "Restricted PRRA under s. 112(3): no revisiting of the "
|
| 548 |
+
"exclusion finding; the scope of protection for excluded "
|
| 549 |
+
"persons"},
|
| 550 |
+
# The CIU's published "Important Decisions" list (ciu-sdi.ca), ingested
|
| 551 |
+
# 2026-07-23; topics adapted from the union's own subject lines.
|
| 552 |
+
{"court": "fpslreb", "id": 358837, "short": "Bartlett",
|
| 553 |
+
"topic": "Hours of work and variable shift scheduling under the FB "
|
| 554 |
+
"collective agreement (Border Services officers)"},
|
| 555 |
+
{"court": "fpslreb", "id": 359582, "short": "Legros",
|
| 556 |
+
"topic": "Age discrimination grievance against the CBSA"},
|
| 557 |
+
{"court": "fpslreb", "id": 359605, "short": "Bétournay",
|
| 558 |
+
"topic": "Breach of trust, disguised disciplinary action and "
|
| 559 |
+
"termination (CRA)"},
|
| 560 |
+
{"court": "fpslreb", "id": 359635, "short": "Frezza",
|
| 561 |
+
"topic": "Bad faith and disguised discipline in the federal public "
|
| 562 |
+
"service (DND)"},
|
| 563 |
+
{"court": "fpslreb", "id": 360491, "short": "PSAC v TB (CBSA) 2018-66",
|
| 564 |
+
"topic": "Policy grievance: interpretation of the FB collective "
|
| 565 |
+
"agreement (PSAC v Treasury Board / CBSA)"},
|
| 566 |
+
{"court": "fpslreb", "id": 361700, "short": "PSAC v TB (CBSA) 2018-82",
|
| 567 |
+
"topic": "Policy grievance: interpretation of the FB collective "
|
| 568 |
+
"agreement (PSAC v Treasury Board / CBSA)"},
|
| 569 |
+
{"court": "fpslreb", "id": 361701, "short": "Michaud",
|
| 570 |
+
"topic": "Suspension grievance in the federal public service (CRA)"},
|
| 571 |
+
{"court": "fpslreb", "id": 407654, "short": "Hyslop",
|
| 572 |
+
"topic": "Suspension grievance of a CBSA employee"},
|
| 573 |
+
{"court": "fpslreb", "id": 407686, "short": "Girard",
|
| 574 |
+
"topic": "Suspension and the effective date of termination (CRA)"},
|
| 575 |
+
{"court": "fpslreb", "id": 420718, "short": "Edwards",
|
| 576 |
+
"topic": "Sick leave, personal-needs leave and disability "
|
| 577 |
+
"discrimination under the FB collective agreement (CBSA)"},
|
| 578 |
+
{"court": "fpslreb", "id": 420308, "short": "Touchette",
|
| 579 |
+
"topic": "Proportionality of discipline; suspension of a CBSA "
|
| 580 |
+
"employee reduced"},
|
| 581 |
+
{"court": "fpslreb", "id": 521306, "short": "Guay-Bassett",
|
| 582 |
+
"topic": "Scheduling, designated paid holidays and sick leave under "
|
| 583 |
+
"the FB collective agreement (CBSA)"},
|
| 584 |
+
{"court": "fpslreb", "id": 520990, "short": "Menzies",
|
| 585 |
+
"topic": "Progressive discipline of a CBSA border services officer; the "
|
| 586 |
+
"lock-step approach to discipline rejected"},
|
| 587 |
+
{"court": "fpslreb", "id": 521231, "short": "Kline",
|
| 588 |
+
"topic": "Bad-faith termination of a CBSA employee; reinstatement and "
|
| 589 |
+
"damages"},
|
| 590 |
+
{"court": "fpslreb", "id": 521195, "short": "Sousa Dias",
|
| 591 |
+
"topic": "Discipline and termination grievance of a CBSA employee"},
|
| 592 |
+
{"court": "fpslreb", "id": 521082, "short": "Anderson",
|
| 593 |
+
"topic": "CBSA grievance; interpretation of the FB-group collective "
|
| 594 |
+
"agreement"},
|
| 595 |
+
{"court": "fpslreb", "id": 520948, "short": "Burlacu",
|
| 596 |
+
"topic": "CBSA; occupational health and safety and staffing"},
|
| 597 |
+
{"court": "fpslreb", "id": 483604, "short": "Malik",
|
| 598 |
+
"topic": "Discipline and termination grievance of a CBSA employee"},
|
| 599 |
+
{"court": "fpslreb", "id": 500554, "short": "Andruszkiewicz",
|
| 600 |
+
"topic": "Unfair labour practice complaint involving the CBSA"},
|
| 601 |
+
{"court": "fpslreb", "id": 359013, "short": "PSAC v TB (CBSA)",
|
| 602 |
+
"topic": "Policy grievance; collective agreement interpretation at the "
|
| 603 |
+
"CBSA"},
|
| 604 |
+
{"court": "fpslreb", "id": 359065, "short": "Martin-Ivie",
|
| 605 |
+
"topic": "Occupational health and safety; the arming and safety of CBSA "
|
| 606 |
+
"border officers"},
|
| 607 |
+
{"court": "fpslreb", "id": 358886, "short": "Basra (2012)",
|
| 608 |
+
"topic": "Discipline and termination grievance; a later proceeding in "
|
| 609 |
+
"the leading Basra line"},
|
| 610 |
+
{"court": "fpslreb", "id": 358025, "short": "Basra (2007)",
|
| 611 |
+
"topic": "The foundational Basra decision on discipline and the burden "
|
| 612 |
+
"of proof in a grievance"},
|
| 613 |
+
{"court": "fpslreb", "id": 358150, "short": "Quadrini",
|
| 614 |
+
"topic": "Unfair labour practice and freedom of expression in the "
|
| 615 |
+
"federal public service"},
|
| 616 |
+
{"court": "fpslreb", "id": 358180, "short": "Pepper",
|
| 617 |
+
"topic": "Discipline and termination; frequently-cited principles on "
|
| 618 |
+
"just cause"},
|
| 619 |
+
{"court": "fpslreb", "id": 358097, "short": "Richmond",
|
| 620 |
+
"topic": "Classification grievance in the federal public service"},
|
| 621 |
+
{"court": "fpslreb", "id": 358890, "short": "Baldasaro and Thiessen",
|
| 622 |
+
"topic": "Hours of work and overtime under a collective agreement"},
|
| 623 |
+
{"court": "fpslreb", "id": 358203, "short": "PSAC v TB (pay)",
|
| 624 |
+
"topic": "Collective agreement and pay administration policy grievance"},
|
| 625 |
+
{"court": "fpslreb", "id": 360456, "short": "Kinhnicki",
|
| 626 |
+
"topic": "Occupational health and safety; a refusal to work in a customs "
|
| 627 |
+
"context"},
|
| 628 |
+
|
| 629 |
+
# --- Court of Appeal for Ontario (own Decisia database) ---
|
| 630 |
+
{"court": "onca", "id": 22592, "short": "Pike",
|
| 631 |
+
"topic": "Border searches of personal electronic devices; following and "
|
| 632 |
+
"extending Canfield, Customs Act s. 99(1)(a) is unconstitutional "
|
| 633 |
+
"under Charter s. 8 insofar as it permits suspicionless device "
|
| 634 |
+
"searches -- a reasonable-suspicion requirement is read in "
|
| 635 |
+
"(suspended declaration of invalidity)"},
|
| 636 |
+
|
| 637 |
+
# --- Canada Industrial Relations Board ---
|
| 638 |
+
{"court": "cirb", "id": 519772, "short": "Watson",
|
| 639 |
+
"topic": "Duty of fair representation and a mandatory vaccination policy "
|
| 640 |
+
"under the Canada Labour Code"},
|
| 641 |
+
{"court": "cirb", "id": 5478, "short": "McRaeJackson",
|
| 642 |
+
"topic": "The leading test for the duty of fair representation under "
|
| 643 |
+
"s. 37 of the Canada Labour Code"},
|
| 644 |
+
{"court": "cirb", "id": 5491, "short": "Securicor",
|
| 645 |
+
"topic": "Certification and bargaining-unit determination under the "
|
| 646 |
+
"Canada Labour Code"},
|
| 647 |
+
{"court": "cirb", "id": 5593, "short": "Dover Industries",
|
| 648 |
+
"topic": "Successor rights on the sale of a business under the Canada "
|
| 649 |
+
"Labour Code"},
|
| 650 |
+
{"court": "cirb", "id": 301063, "short": "Swissport",
|
| 651 |
+
"topic": "Unfair labour practice complaint under the Canada Labour Code"},
|
| 652 |
+
{"court": "cirb", "id": 5599, "short": "Cooney Transport",
|
| 653 |
+
"topic": "Related-employer (common-employer) declaration under the "
|
| 654 |
+
"Canada Labour Code"},
|
| 655 |
+
]
|
| 656 |
+
|
| 657 |
+
# Provincial appellate decisions, sourced from CanLII as cached plain text.
|
| 658 |
+
# CanLII fronts its pages with DataDome bot protection, so these CANNOT be
|
| 659 |
+
# fetched automatically: open the CanLII page in a real browser, select-all,
|
| 660 |
+
# and save the page text to data/raw/caselaw/<file>. Because there is no
|
| 661 |
+
# parseable metadata block, the name / citation / date / court are curated
|
| 662 |
+
# here rather than parsed. The paragraph text still parses the same way --
|
| 663 |
+
# CanLII renders the same bracketed [N] paragraph numbers as Lexum.
|
| 664 |
+
CANLII_CASES = [
|
| 665 |
+
{"file": "canlii-2020abca383.txt",
|
| 666 |
+
"canlii_path": "en/ab/abca/doc/2020/2020abca383/2020abca383.html",
|
| 667 |
+
"short": "Canfield",
|
| 668 |
+
"name": "R v Canfield",
|
| 669 |
+
"cite": "2020 ABCA 383",
|
| 670 |
+
"report": "395 CCC (3d) 483",
|
| 671 |
+
"date": "2020-10-29",
|
| 672 |
+
"court_name": "Court of Appeal of Alberta",
|
| 673 |
+
"topic": "Border searches of personal electronic devices; Customs Act "
|
| 674 |
+
"s. 99(1)(a) held unconstitutional under Charter s. 8 insofar as "
|
| 675 |
+
"it imposes no limits on device searches at the border (leave to "
|
| 676 |
+
"appeal to the SCC refused; binding in Alberta and persuasive "
|
| 677 |
+
"elsewhere)"},
|
| 678 |
+
{"file": "canlii-2021canlii95825.txt",
|
| 679 |
+
"canlii_path": "en/ca/irb/doc/2021/2021canlii95825/2021canlii95825.html",
|
| 680 |
+
"short": "TC1-05038",
|
| 681 |
+
"name": "IRB Jurisprudential Guide TC1-05038 — X (Re)",
|
| 682 |
+
"cite": "2021 CanLII 95825 (CA IRB)",
|
| 683 |
+
"report": "",
|
| 684 |
+
"date": "2021-08-30",
|
| 685 |
+
"court_name": "Immigration and Refugee Board — Refugee Appeal Division",
|
| 686 |
+
"topic": "RAD jurisprudential guide on the 'generalized risk' exception "
|
| 687 |
+
"to protection under IRPA s. 97(1)(b)(ii) in claims involving "
|
| 688 |
+
"gang targeting, extortion or other criminality (in force; "
|
| 689 |
+
"identified as a guide July 2022)"},
|
| 690 |
+
# The foreign-conviction equivalency line -- the FCA authorities the
|
| 691 |
+
# curated us_equivalency dataset applies. Pre-Lexum era, so CanLII-only:
|
| 692 |
+
# the raw text arrives via the manual browser-capture workflow and
|
| 693 |
+
# ingestion skips gracefully until each file exists.
|
| 694 |
+
{"file": "canlii-1981-brannson.txt",
|
| 695 |
+
"canlii_path": "en/ca/fca/doc/1980/1980canlii4197/1980canlii4197.html",
|
| 696 |
+
"short": "Brannson",
|
| 697 |
+
"name": "Brannson v Canada (Minister of Employment and Immigration)",
|
| 698 |
+
"cite": "[1981] 2 FC 141 (CA)",
|
| 699 |
+
"report": "1980 CanLII 4197",
|
| 700 |
+
"date": "1980-06-05",
|
| 701 |
+
"court_name": "Federal Court of Appeal",
|
| 702 |
+
"topic": "Foreign-conviction equivalency: compare the essential elements "
|
| 703 |
+
"of the foreign and Canadian offences; the foreign offence's "
|
| 704 |
+
"wording and the evidence may both be examined (first of the "
|
| 705 |
+
"equivalency trilogy)"},
|
| 706 |
+
{"file": "canlii-1987-hill.txt",
|
| 707 |
+
"canlii_path": "en/ca/fca/doc/1987/1987canlii9881/1987canlii9881.html",
|
| 708 |
+
"short": "Hill",
|
| 709 |
+
"name": "Hill v Canada (Minister of Employment and Immigration)",
|
| 710 |
+
"cite": "(1987), 73 NR 315 (FCA)",
|
| 711 |
+
"report": "1987 CanLII 9881; 1 Imm LR (2d) 1",
|
| 712 |
+
"date": "1987-01-29",
|
| 713 |
+
"court_name": "Federal Court of Appeal",
|
| 714 |
+
"topic": "The governing three-branch equivalency test: (1) compare the "
|
| 715 |
+
"precise wording and essential elements of the two provisions; "
|
| 716 |
+
"(2) examine the evidence of what was actually done against the "
|
| 717 |
+
"Canadian offence's elements; or (3) both combined"},
|
| 718 |
+
# 2026-07 gap audit, pre-Decisia authorities: text arrives via the
|
| 719 |
+
# browser-capture workflow (or a direct PDF fetch for Wm. Scott);
|
| 720 |
+
# ingestion skips gracefully until each file exists.
|
| 721 |
+
{"file": "canlii-1991-rasaratnam.txt",
|
| 722 |
+
"canlii_path": "en/ca/fca/doc/1991/1991canlii13517/1991canlii13517.html",
|
| 723 |
+
"short": "Rasaratnam", "name": "Rasaratnam v Canada (MEI)",
|
| 724 |
+
"cite": "[1992] 1 FC 706 (CA)", "report": "1991 CanLII 13517",
|
| 725 |
+
"date": "1991-12-05", "court_name": "Federal Court of Appeal",
|
| 726 |
+
"topic": "The two-prong internal flight alternative test: no serious "
|
| 727 |
+
"possibility of persecution in the IFA, and reasonableness of "
|
| 728 |
+
"relocation"},
|
| 729 |
+
{"file": "canlii-1993-thirunavukkarasu.txt",
|
| 730 |
+
"canlii_path": "en/ca/fca/doc/1993/1993canlii3011/1993canlii3011.html",
|
| 731 |
+
"short": "Thirunavukkarasu", "name": "Thirunavukkarasu v Canada (MEI)",
|
| 732 |
+
"cite": "[1994] 1 FC 589 (CA)", "report": "1993 CanLII 3011",
|
| 733 |
+
"date": "1993-11-10", "court_name": "Federal Court of Appeal",
|
| 734 |
+
"topic": "The reasonableness prong of the IFA test: an objective "
|
| 735 |
+
"standard, with notice required before an IFA is raised"},
|
| 736 |
+
{"file": "canlii-2000-ranganathan.txt",
|
| 737 |
+
"canlii_path": "en/ca/fca/doc/2000/2000canlii16789/2000canlii16789.html",
|
| 738 |
+
"short": "Ranganathan", "name": "Ranganathan v Canada (MCI)",
|
| 739 |
+
"cite": "[2001] 2 FC 164 (CA)", "report": "2000 CanLII 16789",
|
| 740 |
+
"date": "2000-12-21", "court_name": "Federal Court of Appeal",
|
| 741 |
+
"topic": "The very high threshold for an unreasonable IFA: conditions "
|
| 742 |
+
"jeopardizing life and safety in travelling or relocating"},
|
| 743 |
+
{"file": "canlii-1994-sahin.txt",
|
| 744 |
+
"canlii_path": "en/ca/fct/doc/1994/1994canlii3521/1994canlii3521.html",
|
| 745 |
+
"short": "Sahin", "name": "Sahin v Canada (MCI)",
|
| 746 |
+
"cite": "[1995] 1 FC 214 (TD)", "report": "1994 CanLII 3521",
|
| 747 |
+
"date": "1994-10-14", "court_name": "Federal Court (Trial Division)",
|
| 748 |
+
"topic": "The s. 7 factors for continued immigration detention -- the "
|
| 749 |
+
"source of IRPR s. 248: reasons, length, future duration, "
|
| 750 |
+
"delay attribution, alternatives"},
|
| 751 |
+
{"file": "canlii-1979-maldonado.txt",
|
| 752 |
+
"canlii_path": "en/ca/fca/doc/1979/1979canlii4098/1979canlii4098.html",
|
| 753 |
+
"short": "Maldonado", "name": "Maldonado v Canada (MEI)",
|
| 754 |
+
"cite": "[1980] 2 FC 302 (CA)", "report": "1979 CanLII 4098",
|
| 755 |
+
"date": "1979-11-19", "court_name": "Federal Court of Appeal",
|
| 756 |
+
"topic": "The presumption that a claimant's sworn testimony is true "
|
| 757 |
+
"unless there is reason to doubt it"},
|
| 758 |
+
{"file": "canlii-1989-adjei.txt",
|
| 759 |
+
"canlii_path": "en/ca/fca/doc/1989/1989canlii5184/1989canlii5184.html",
|
| 760 |
+
"short": "Adjei", "name": "Adjei v Canada (MEI)",
|
| 761 |
+
"cite": "[1989] 2 FC 680 (CA)", "report": "1989 CanLII 9466",
|
| 762 |
+
"date": "1989-01-27", "court_name": "Federal Court of Appeal",
|
| 763 |
+
"topic": "The s. 96 standard of proof: a reasonable chance or serious "
|
| 764 |
+
"possibility of persecution, less than balance of probabilities"},
|
| 765 |
+
{"file": "canlii-1988-toth.txt",
|
| 766 |
+
"canlii_path": "en/ca/fca/doc/1988/1988canlii1420/1988canlii1420.html",
|
| 767 |
+
"short": "Toth", "name": "Toth v Canada (MEI)",
|
| 768 |
+
"cite": "(1988), 86 NR 302 (FCA)", "report": "1988 CanLII 1420",
|
| 769 |
+
"date": "1988-06-21", "court_name": "Federal Court of Appeal",
|
| 770 |
+
"topic": "The tripartite stay test on motions to stay removal: serious "
|
| 771 |
+
"issue, irreparable harm, balance of convenience"},
|
| 772 |
+
{"file": "canlii-1980-burchill.txt",
|
| 773 |
+
"canlii_path": "en/ca/fca/doc/1980/1980canlii4207/1980canlii4207.html",
|
| 774 |
+
"short": "Burchill", "name": "Burchill v Canada (Attorney General)",
|
| 775 |
+
"cite": "[1981] 1 FC 109 (CA)", "report": "1980 CanLII 4207",
|
| 776 |
+
"date": "1980-05-20", "court_name": "Federal Court of Appeal",
|
| 777 |
+
"topic": "A grievor cannot change the nature of a grievance when it "
|
| 778 |
+
"reaches adjudication"},
|
| 779 |
+
{"file": "canlii-1992-lidder.txt",
|
| 780 |
+
"canlii_path": "en/ca/fca/doc/1992/1992canlii14712/1992canlii14712.html",
|
| 781 |
+
"short": "Lidder", "name": "Canada (MEI) v Lidder",
|
| 782 |
+
"cite": "[1992] 2 FC 621 (CA)", "report": "1992 CanLII 14712",
|
| 783 |
+
"date": "1992-03-10", "court_name": "Federal Court of Appeal",
|
| 784 |
+
"topic": "Estoppel and legitimate expectations cannot defeat mandatory "
|
| 785 |
+
"statutory requirements: an officer's representation cannot "
|
| 786 |
+
"confer a status or bypass conditions the Act imposes"},
|
| 787 |
+
# Tipple ([1985] FCJ No 818) is QuickLaw-only -- no CanLII record
|
| 788 |
+
# exists. Its de-novo-cures-defects rule enters the corpus through
|
| 789 |
+
# Patanguli, 2015 FCA 291 (CASES entry), which quotes and applies it.
|
| 790 |
+
{"file": "canlii-1976-wmscott.txt",
|
| 791 |
+
"canlii_path": "en/bc/bclrb/doc/1976/1976canlii1017/1976canlii1017.html",
|
| 792 |
+
"short": "Wm. Scott", "name": "Wm. Scott & Company Ltd v CFAWU Local P-162",
|
| 793 |
+
"cite": "[1977] 1 Can LRBR 1 (BCLRB)", "report": "",
|
| 794 |
+
"date": "1976-10-26",
|
| 795 |
+
"court_name": "British Columbia Labour Relations Board",
|
| 796 |
+
"topic": "The three-question discipline framework: was there cause; was "
|
| 797 |
+
"the penalty excessive; what lesser penalty should substitute"},
|
| 798 |
+
{"file": "canlii-1996-li.txt",
|
| 799 |
+
"canlii_path": "en/ca/fca/doc/1996/1996canlii4086/1996canlii4086.html",
|
| 800 |
+
"short": "Li",
|
| 801 |
+
"name": "Li v Canada (Minister of Citizenship and Immigration)",
|
| 802 |
+
"cite": "[1997] 1 FC 235 (CA)",
|
| 803 |
+
"report": "1996 CanLII 4086",
|
| 804 |
+
"date": "1996-08-07",
|
| 805 |
+
"court_name": "Federal Court of Appeal",
|
| 806 |
+
"topic": "Equivalency compares the essential elements of the offences, "
|
| 807 |
+
"not procedural or evidentiary rules; defences available under "
|
| 808 |
+
"the foreign law are part of the comparison"},
|
| 809 |
+
]
|
| 810 |
+
|
| 811 |
+
# In-force jurisprudential guides of the Immigration and Refugee Board's Refugee
|
| 812 |
+
# Appeal Division -- decisions the Board designates as models that members apply
|
| 813 |
+
# to similar cases or explain why not. Full text is hosted on the IRB's own
|
| 814 |
+
# site; 'topic' is curated, while the date and paragraphs are parsed from the
|
| 815 |
+
# page. (A fourth in-force guide, TC1-05038, is hosted only on CanLII.)
|
| 816 |
+
IRB_GUIDES = [
|
| 817 |
+
{"file": "MB8-00025",
|
| 818 |
+
"url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/mb8-00025.aspx",
|
| 819 |
+
"topic": "Exclusion from refugee protection under Article 1E where the "
|
| 820 |
+
"claimant has protected status in a third country (Haiti / Brazil)"},
|
| 821 |
+
{"file": "TB7-01837",
|
| 822 |
+
"url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/TB7-01837.aspx",
|
| 823 |
+
"topic": "Persecution of Ahmadis in Pakistan; state protection and the "
|
| 824 |
+
"availability of an internal flight alternative"},
|
| 825 |
+
{"file": "TB4-05778",
|
| 826 |
+
"url": "https://www.irb-cisr.gc.ca/en/decisions/Pages/TB4-05778.aspx",
|
| 827 |
+
"topic": "Whether a North Korean refugee claimant has deemed citizenship "
|
| 828 |
+
"of South Korea and protection available there"},
|
| 829 |
+
]
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
def _get(url, cache_name):
|
| 833 |
+
"""Fetch a page, caching the raw HTML under data/raw/caselaw.
|
| 834 |
+
|
| 835 |
+
Retries once on HTTP 403/429 -- the Lexum hosts rate-limit by IP.
|
| 836 |
+
"""
|
| 837 |
+
cache = _RAW / cache_name
|
| 838 |
+
if cache.exists():
|
| 839 |
+
return cache.read_text(encoding="utf-8")
|
| 840 |
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
| 841 |
+
text = None
|
| 842 |
+
for attempt in range(2):
|
| 843 |
+
time.sleep(_THROTTLE if attempt == 0 else 25.0)
|
| 844 |
+
try:
|
| 845 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 846 |
+
text = resp.read().decode("utf-8", errors="replace")
|
| 847 |
+
break
|
| 848 |
+
except urllib.error.HTTPError as exc:
|
| 849 |
+
if exc.code in (403, 429) and attempt == 0:
|
| 850 |
+
continue
|
| 851 |
+
raise
|
| 852 |
+
_RAW.mkdir(parents=True, exist_ok=True)
|
| 853 |
+
cache.write_text(text, encoding="utf-8")
|
| 854 |
+
return text
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _fetch(court, item_id):
|
| 858 |
+
"""Return a Lexum court decision's iframe HTML."""
|
| 859 |
+
url = COURTS[court][1].format(id=item_id) + "?iframe=true"
|
| 860 |
+
return _get(url, f"{court}-{item_id}.html")
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
def _irb_date(texts):
|
| 864 |
+
"""Pull the ISO 'Date of decision' from an IRB decision's front matter.
|
| 865 |
+
|
| 866 |
+
The label and the date sometimes sit in separate elements, so the search
|
| 867 |
+
runs over the joined text rather than block by block.
|
| 868 |
+
"""
|
| 869 |
+
m = re.search(r"Date of decision:?\s*([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})",
|
| 870 |
+
" ".join(texts))
|
| 871 |
+
if m and m.group(1).lower() in _MONTHS:
|
| 872 |
+
return (f"{m.group(3)}-{_MONTHS[m.group(1).lower()]:02d}-"
|
| 873 |
+
f"{int(m.group(2)):02d}")
|
| 874 |
+
return ""
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def _metadata(soup):
|
| 878 |
+
"""Return (case_name, {label: value}) from the decision's metadata block."""
|
| 879 |
+
box = soup.find("div", class_="metadata")
|
| 880 |
+
if not box:
|
| 881 |
+
return "", {}
|
| 882 |
+
title = box.find("h3", class_="title")
|
| 883 |
+
name = _norm(title.get_text()) if title else ""
|
| 884 |
+
fields = {}
|
| 885 |
+
for row in box.find_all("tr"):
|
| 886 |
+
label = row.find("td", class_="label")
|
| 887 |
+
value = row.find("td", class_="metadata")
|
| 888 |
+
if label and value:
|
| 889 |
+
fields[_norm(label.get_text()).lower()] = _norm(value.get_text())
|
| 890 |
+
return name, fields
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def _body(soup):
|
| 894 |
+
"""Locate the container holding the decision text.
|
| 895 |
+
|
| 896 |
+
Handles the Lexum court pages and the IRB's SharePoint pages, whose text
|
| 897 |
+
sits in a 'RichHtmlField' rich-text div.
|
| 898 |
+
"""
|
| 899 |
+
return (soup.find(id="document-content")
|
| 900 |
+
or soup.find("div", class_="documentcontent")
|
| 901 |
+
or soup.find("div", class_="WordSection1")
|
| 902 |
+
or soup.find("div", id=lambda v: v and "RichHtmlField" in v)
|
| 903 |
+
or soup.find("div", class_="ms-rtestate-field")
|
| 904 |
+
or soup.body or soup)
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
def _paragraphs(soup):
|
| 908 |
+
"""Return (is_numbered, [(label, text), ...]) for the judgment body.
|
| 909 |
+
|
| 910 |
+
Modern judgments open each paragraph with a bracketed number "[N]". They are
|
| 911 |
+
detected by content -- a run of sequentially numbered <p> blocks -- so the
|
| 912 |
+
parser does not depend on Word style names, which vary by court and era.
|
| 913 |
+
Every <p> between one numbered opener and the next belongs to that paragraph.
|
| 914 |
+
Older, unnumbered decisions fall back to taking every <p> in document order.
|
| 915 |
+
"""
|
| 916 |
+
body = _body(soup)
|
| 917 |
+
for aside in body.find_all("aside", class_="wb-fnote"):
|
| 918 |
+
aside.decompose() # drop IRB/WET footnote blocks
|
| 919 |
+
blocks = [p for p in body.find_all("p")
|
| 920 |
+
if "MsoFootnoteText" not in (p.get("class") or [])]
|
| 921 |
+
texts = [p.get_text() for p in blocks]
|
| 922 |
+
|
| 923 |
+
# Some pre-2009 board pages (e.g. PSSRB 2003) carry the reasons in bare
|
| 924 |
+
# <div> blocks with no <p> markup at all, so the <p> strategy collects
|
| 925 |
+
# only header furniture. When it captured almost none of the body's
|
| 926 |
+
# text, hand the whole body to the plain-text parser instead -- the same
|
| 927 |
+
# one the CanLII paste captures use, with its own numbering detection
|
| 928 |
+
# and apparatus cutoff.
|
| 929 |
+
if sum(len(t) for t in texts) < 0.2 * len(body.get_text()):
|
| 930 |
+
return _text_paragraphs(body.get_text("\n"))
|
| 931 |
+
|
| 932 |
+
# Drop the post-reasons apparatus (appended legislation, solicitors list);
|
| 933 |
+
# it is not part of the reasons and would otherwise swell the last paragraph.
|
| 934 |
+
for i, raw in enumerate(texts):
|
| 935 |
+
if _APPARATUS.match(raw):
|
| 936 |
+
texts = texts[:i]
|
| 937 |
+
break
|
| 938 |
+
|
| 939 |
+
# A paragraph opens with its number: "[12]" (most decisions) or a bare "12"
|
| 940 |
+
# followed by wide tab spacing (pre-2009 decisions). The brackets are
|
| 941 |
+
# self-identifying; a bare number must have 2+ trailing spaces, which
|
| 942 |
+
# rejects quoted enumerations ("2. ..."). The sequential check rejects
|
| 943 |
+
# stray bracketed years like "[1998]".
|
| 944 |
+
openers = {} # block index -> paragraph number
|
| 945 |
+
expected = 1
|
| 946 |
+
for i, raw in enumerate(texts):
|
| 947 |
+
match = (re.match(r"\s*\[\s*(\d+)\s*\]", raw)
|
| 948 |
+
or re.match(r"\s*(\d+)\s{2,}\S", raw))
|
| 949 |
+
if match:
|
| 950 |
+
n = int(match.group(1))
|
| 951 |
+
if expected <= n <= expected + 2: # sequential, small-gap tolerant
|
| 952 |
+
openers[i] = n
|
| 953 |
+
expected = n + 1
|
| 954 |
+
|
| 955 |
+
if len(openers) < 5: # unnumbered: take every <p>
|
| 956 |
+
paras = [(str(j), _norm(t)) for j, t in enumerate(texts, start=1)]
|
| 957 |
+
return False, [(n, t) for n, t in paras if len(t) > 1]
|
| 958 |
+
|
| 959 |
+
paras, num, buf = [], None, []
|
| 960 |
+
for i, raw in enumerate(texts):
|
| 961 |
+
if i in openers:
|
| 962 |
+
if num is not None:
|
| 963 |
+
paras.append((str(num), _norm(" ".join(buf))))
|
| 964 |
+
num = openers[i]
|
| 965 |
+
buf = [re.sub(r"^\s*\[?\s*\d+\s*\]?\s*", "", raw)]
|
| 966 |
+
elif num is not None:
|
| 967 |
+
buf.append(raw)
|
| 968 |
+
if num is not None:
|
| 969 |
+
paras.append((str(num), _norm(" ".join(buf))))
|
| 970 |
+
return True, [(n, t) for n, t in paras if t]
|
| 971 |
+
|
| 972 |
+
|
| 973 |
+
def _split_text(text, limit):
|
| 974 |
+
"""Split text longer than `limit` into pieces, breaking on a sentence or
|
| 975 |
+
word boundary so no single chunk blows the retrieval/reranker budget."""
|
| 976 |
+
if len(text) <= limit:
|
| 977 |
+
return [text]
|
| 978 |
+
pieces, start = [], 0
|
| 979 |
+
while start < len(text):
|
| 980 |
+
if len(text) - start <= limit:
|
| 981 |
+
pieces.append(text[start:])
|
| 982 |
+
break
|
| 983 |
+
window = text[start:start + limit]
|
| 984 |
+
cut = window.rfind(". ")
|
| 985 |
+
cut = cut + 1 if cut > limit // 2 else window.rfind(" ")
|
| 986 |
+
if cut <= 0:
|
| 987 |
+
cut = limit
|
| 988 |
+
pieces.append(text[start:start + cut])
|
| 989 |
+
start += cut
|
| 990 |
+
return [p.strip() for p in pieces if p.strip()]
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
def _chunk(paras):
|
| 994 |
+
"""Group consecutive paragraphs into ~_CHUNK_CHARS-sized chunks, first
|
| 995 |
+
splitting any single paragraph that on its own exceeds the budget."""
|
| 996 |
+
units = []
|
| 997 |
+
for label, text in paras:
|
| 998 |
+
for piece in _split_text(text, _CHUNK_CHARS):
|
| 999 |
+
units.append((label, piece))
|
| 1000 |
+
chunks, buf, size = [], [], 0
|
| 1001 |
+
for label, text in units:
|
| 1002 |
+
if buf and size + len(text) > _CHUNK_CHARS:
|
| 1003 |
+
chunks.append(buf)
|
| 1004 |
+
buf, size = [], 0
|
| 1005 |
+
buf.append((label, text))
|
| 1006 |
+
size += len(text)
|
| 1007 |
+
if buf:
|
| 1008 |
+
chunks.append(buf)
|
| 1009 |
+
return chunks
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
# The post-reasons apparatus in plain-text (CanLII) decisions: the hearing
|
| 1013 |
+
# date, filing formula, counsel list, or signature block -- and everything
|
| 1014 |
+
# after them (endnotes, the CanLII page footer). The "(signed)" marker is how
|
| 1015 |
+
# IRB decisions end; their endnotes reuse bracketed [N] numbering and would
|
| 1016 |
+
# otherwise collide with the paragraph sequence once N catches up.
|
| 1017 |
+
_TEXT_APPARATUS = re.compile(
|
| 1018 |
+
r"^\s*(Appeal heard on\b|Memorandum filed\b|Appearances:|\(signed\)|"
|
| 1019 |
+
r"APPENDIX\b|Solicitors?\s+for\b|NAMES\s+OF\s+COUNSEL\b)", re.I)
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
def _text_paragraphs(text):
|
| 1023 |
+
"""Return (is_numbered, [(label, text), ...]) for a plain-text judgment.
|
| 1024 |
+
|
| 1025 |
+
The text analogue of _paragraphs() for decisions cached as page text
|
| 1026 |
+
rather than HTML: physical lines replace <p> blocks. A line opening with a
|
| 1027 |
+
sequential bracketed number "[N]" starts paragraph N; lines before [1]
|
| 1028 |
+
(style of cause, coram) and everything from the first apparatus marker on
|
| 1029 |
+
(counsel, endnotes, site footer) are dropped."""
|
| 1030 |
+
lines = [ln.strip() for ln in text.splitlines()]
|
| 1031 |
+
|
| 1032 |
+
for i, ln in enumerate(lines):
|
| 1033 |
+
if _TEXT_APPARATUS.match(ln):
|
| 1034 |
+
lines = lines[:i]
|
| 1035 |
+
break
|
| 1036 |
+
|
| 1037 |
+
openers = {} # line index -> paragraph number
|
| 1038 |
+
expected = 1
|
| 1039 |
+
for i, ln in enumerate(lines):
|
| 1040 |
+
match = re.match(r"\[\s*(\d+)\s*\]", ln)
|
| 1041 |
+
if match:
|
| 1042 |
+
n = int(match.group(1))
|
| 1043 |
+
if expected <= n <= expected + 2:
|
| 1044 |
+
openers[i] = n
|
| 1045 |
+
expected = n + 1
|
| 1046 |
+
|
| 1047 |
+
def _unnumbered():
|
| 1048 |
+
paras = [(str(j), _norm(ln)) for j, ln in enumerate(lines, start=1)]
|
| 1049 |
+
return False, [(n, t) for n, t in paras if len(t) > 1]
|
| 1050 |
+
|
| 1051 |
+
if len(openers) < 5:
|
| 1052 |
+
return _unnumbered()
|
| 1053 |
+
|
| 1054 |
+
paras, num, buf = [], None, []
|
| 1055 |
+
for i, ln in enumerate(lines):
|
| 1056 |
+
if i in openers:
|
| 1057 |
+
if num is not None:
|
| 1058 |
+
paras.append((str(num), _norm(" ".join(buf))))
|
| 1059 |
+
num = openers[i]
|
| 1060 |
+
buf = [re.sub(r"^\[\s*\d+\s*\]\s*", "", ln)]
|
| 1061 |
+
elif num is not None and ln:
|
| 1062 |
+
buf.append(ln)
|
| 1063 |
+
if num is not None:
|
| 1064 |
+
paras.append((str(num), _norm(" ".join(buf))))
|
| 1065 |
+
paras = [(n, t) for n, t in paras if t]
|
| 1066 |
+
|
| 1067 |
+
# A pre-2001 report-style decision has unnumbered prose reasons but a
|
| 1068 |
+
# trailing footnote block whose own [1]..[N] sequence the detector will
|
| 1069 |
+
# happily latch onto (Li: 26 footnotes, 4% of the text). If the
|
| 1070 |
+
# "numbered paragraphs" cover only a sliver of the document, the real
|
| 1071 |
+
# reasons are the unnumbered prose -- fall back to it.
|
| 1072 |
+
if sum(len(t) for _, t in paras) < 0.2 * len(text):
|
| 1073 |
+
return _unnumbered()
|
| 1074 |
+
return True, paras
|
| 1075 |
+
|
| 1076 |
+
|
| 1077 |
+
def _canlii_chunks(case, text):
|
| 1078 |
+
"""Build CanLex chunk dicts for one CanLII cached-text decision."""
|
| 1079 |
+
citation = f"{case['name']}, {case['cite']}"
|
| 1080 |
+
source_url = f"https://www.canlii.org/{case['canlii_path']}"
|
| 1081 |
+
modern, paras = _text_paragraphs(text)
|
| 1082 |
+
chunks = []
|
| 1083 |
+
for i, group in enumerate(_chunk(paras), start=1):
|
| 1084 |
+
if modern:
|
| 1085 |
+
first, last = group[0][0], group[-1][0]
|
| 1086 |
+
locator = (f"para {first}" if first == last
|
| 1087 |
+
else f"paras {first}–{last}")
|
| 1088 |
+
else:
|
| 1089 |
+
locator = f"excerpt {i}"
|
| 1090 |
+
chunks.append({
|
| 1091 |
+
"id": f"canlii-{case['cite'].replace(' ', '').lower()}-{i}",
|
| 1092 |
+
"doc_type": "caselaw",
|
| 1093 |
+
"act_code": case["cite"],
|
| 1094 |
+
"act_short": case["short"],
|
| 1095 |
+
"act_name": case["name"],
|
| 1096 |
+
"section": "",
|
| 1097 |
+
"citation": citation,
|
| 1098 |
+
"marginal_note": locator,
|
| 1099 |
+
"heading": case["topic"],
|
| 1100 |
+
"part": case["court_name"],
|
| 1101 |
+
"division": "",
|
| 1102 |
+
"text": "\n\n".join(t for _, t in group),
|
| 1103 |
+
"current_to": case["date"],
|
| 1104 |
+
"last_amended": "",
|
| 1105 |
+
"history": case.get("report", ""),
|
| 1106 |
+
"source_url": source_url,
|
| 1107 |
+
})
|
| 1108 |
+
return chunks, citation, len(paras)
|
| 1109 |
+
|
| 1110 |
+
|
| 1111 |
+
def _decision_chunks(case, soup):
|
| 1112 |
+
"""Build CanLex chunk dicts for one decision."""
|
| 1113 |
+
court_name, item_tmpl = COURTS[case["court"]]
|
| 1114 |
+
name, fields = _metadata(soup)
|
| 1115 |
+
name = name or case["short"]
|
| 1116 |
+
cite = (fields.get("neutral citation") or fields.get("citation")
|
| 1117 |
+
or fields.get("report") or "")
|
| 1118 |
+
report = fields.get("report", "")
|
| 1119 |
+
date = fields.get("date") or fields.get("decision rendered") or ""
|
| 1120 |
+
citation = f"{name}, {cite}" if cite else name
|
| 1121 |
+
item_url = item_tmpl.format(id=case["id"])
|
| 1122 |
+
modern, paras = _paragraphs(soup)
|
| 1123 |
+
chunks = []
|
| 1124 |
+
for i, group in enumerate(_chunk(paras), start=1):
|
| 1125 |
+
if modern:
|
| 1126 |
+
first, last = group[0][0], group[-1][0]
|
| 1127 |
+
locator = (f"para {first}" if first == last
|
| 1128 |
+
else f"paras {first}–{last}")
|
| 1129 |
+
else:
|
| 1130 |
+
locator = f"excerpt {i}"
|
| 1131 |
+
chunks.append({
|
| 1132 |
+
"id": f"{case['court']}-{case['id']}-{i}",
|
| 1133 |
+
"doc_type": "caselaw",
|
| 1134 |
+
"act_code": cite or f"{case['court'].upper()} item {case['id']}",
|
| 1135 |
+
"act_short": case["short"],
|
| 1136 |
+
"act_name": name,
|
| 1137 |
+
"section": "",
|
| 1138 |
+
"citation": citation,
|
| 1139 |
+
"marginal_note": locator,
|
| 1140 |
+
"heading": case["topic"],
|
| 1141 |
+
"part": court_name,
|
| 1142 |
+
"division": "",
|
| 1143 |
+
"text": "\n\n".join(t for _, t in group),
|
| 1144 |
+
"current_to": date,
|
| 1145 |
+
"last_amended": "",
|
| 1146 |
+
"history": report if report and report != cite else "",
|
| 1147 |
+
"source_url": item_url,
|
| 1148 |
+
})
|
| 1149 |
+
return chunks, citation, len(paras)
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
def _irb_chunks(guide, soup):
|
| 1153 |
+
"""Build CanLex chunk dicts for one IRB jurisprudential guide."""
|
| 1154 |
+
cite = f"IRB Jurisprudential Guide {guide['file']}"
|
| 1155 |
+
date = _irb_date(p.get_text() for p in _body(soup).find_all("p"))
|
| 1156 |
+
modern, paras = _paragraphs(soup)
|
| 1157 |
+
chunks = []
|
| 1158 |
+
for i, group in enumerate(_chunk(paras), start=1):
|
| 1159 |
+
if modern:
|
| 1160 |
+
first, last = group[0][0], group[-1][0]
|
| 1161 |
+
locator = (f"para {first}" if first == last
|
| 1162 |
+
else f"paras {first}–{last}")
|
| 1163 |
+
else:
|
| 1164 |
+
locator = f"excerpt {i}"
|
| 1165 |
+
chunks.append({
|
| 1166 |
+
"id": f"irb-{guide['file']}-{i}",
|
| 1167 |
+
"doc_type": "caselaw",
|
| 1168 |
+
"act_code": guide["file"],
|
| 1169 |
+
"act_short": guide["file"],
|
| 1170 |
+
"act_name": cite,
|
| 1171 |
+
"section": "",
|
| 1172 |
+
"citation": cite,
|
| 1173 |
+
"marginal_note": locator,
|
| 1174 |
+
"heading": guide["topic"],
|
| 1175 |
+
"part": "Immigration and Refugee Board — Refugee Appeal Division",
|
| 1176 |
+
"division": "",
|
| 1177 |
+
"text": "\n\n".join(t for _, t in group),
|
| 1178 |
+
"current_to": date,
|
| 1179 |
+
"last_amended": "",
|
| 1180 |
+
"history": "",
|
| 1181 |
+
"source_url": guide["url"],
|
| 1182 |
+
})
|
| 1183 |
+
return chunks, cite, len(paras)
|
| 1184 |
+
|
| 1185 |
+
|
| 1186 |
+
def build(allow_shrink=False):
|
| 1187 |
+
"""Fetch, parse and chunk every curated decision into caselaw.json.
|
| 1188 |
+
|
| 1189 |
+
Returns True when the corpus was written, False when the write was refused
|
| 1190 |
+
-- a Lexum CAPTCHA wall or an upstream re-platforming makes every fetch
|
| 1191 |
+
fail at once, and the rebuild-then-write shape would turn that into an
|
| 1192 |
+
emptied caselaw.json.
|
| 1193 |
+
"""
|
| 1194 |
+
# Every path that drops a decision records it here, keyed the way the
|
| 1195 |
+
# chunks are (act_short): Lexum throttles, CanLII captures go missing and
|
| 1196 |
+
# court templates shift, so on any given run some decisions fail while the
|
| 1197 |
+
# rest succeed -- and the rebuild-then-write shape would quietly delete
|
| 1198 |
+
# exactly those. `failed` is what preserve_dropped is allowed to restore,
|
| 1199 |
+
# so decisions removed on purpose (deleted from CASES) stay gone.
|
| 1200 |
+
all_chunks, failed = [], set()
|
| 1201 |
+
for case in CASES:
|
| 1202 |
+
try:
|
| 1203 |
+
soup = BeautifulSoup(_fetch(case["court"], case["id"]),
|
| 1204 |
+
"html.parser")
|
| 1205 |
+
except Exception as exc:
|
| 1206 |
+
print(f" !! {case['short']}: fetch failed -- "
|
| 1207 |
+
f"{type(exc).__name__}: {exc}")
|
| 1208 |
+
failed.add(case["short"])
|
| 1209 |
+
continue
|
| 1210 |
+
chunks, citation, n_paras = _decision_chunks(case, soup)
|
| 1211 |
+
if not chunks:
|
| 1212 |
+
print(f" !! {case['short']} ({case['court']} item "
|
| 1213 |
+
f"{case['id']}): 0 chunks -- check parsing")
|
| 1214 |
+
failed.add(case["short"])
|
| 1215 |
+
continue
|
| 1216 |
+
all_chunks.extend(chunks)
|
| 1217 |
+
print(f" {case['court']:4s} {case['short']:20s} {n_paras:4d} paras -> "
|
| 1218 |
+
f"{len(chunks):3d} chunks {citation}")
|
| 1219 |
+
for case in CANLII_CASES:
|
| 1220 |
+
cache = _RAW / case["file"]
|
| 1221 |
+
if not cache.exists():
|
| 1222 |
+
print(f" !! {case['short']}: {cache} missing -- CanLII blocks "
|
| 1223 |
+
f"automated fetches (DataDome). Open https://www.canlii.org/"
|
| 1224 |
+
f"{case['canlii_path']} in a browser and save the page text "
|
| 1225 |
+
f"to that path, then re-run.")
|
| 1226 |
+
failed.add(case["short"])
|
| 1227 |
+
continue
|
| 1228 |
+
text = cache.read_text(encoding="utf-8-sig")
|
| 1229 |
+
chunks, citation, n_paras = _canlii_chunks(case, text)
|
| 1230 |
+
if not chunks:
|
| 1231 |
+
print(f" !! {case['short']} ({case['file']}): 0 chunks -- "
|
| 1232 |
+
f"check parsing")
|
| 1233 |
+
failed.add(case["short"])
|
| 1234 |
+
continue
|
| 1235 |
+
all_chunks.extend(chunks)
|
| 1236 |
+
print(f" cnli {case['short']:20s} {n_paras:4d} paras -> "
|
| 1237 |
+
f"{len(chunks):3d} chunks {citation}")
|
| 1238 |
+
for guide in IRB_GUIDES:
|
| 1239 |
+
try:
|
| 1240 |
+
soup = BeautifulSoup(_get(guide["url"], f"irb-{guide['file']}.html"),
|
| 1241 |
+
"html.parser")
|
| 1242 |
+
except Exception as exc:
|
| 1243 |
+
print(f" !! {guide['file']}: fetch failed -- "
|
| 1244 |
+
f"{type(exc).__name__}: {exc}")
|
| 1245 |
+
failed.add(guide["file"])
|
| 1246 |
+
continue
|
| 1247 |
+
chunks, citation, n_paras = _irb_chunks(guide, soup)
|
| 1248 |
+
if not chunks:
|
| 1249 |
+
print(f" !! {guide['file']}: 0 chunks -- check parsing")
|
| 1250 |
+
failed.add(guide["file"])
|
| 1251 |
+
continue
|
| 1252 |
+
all_chunks.extend(chunks)
|
| 1253 |
+
print(f" irb {guide['file']:20s} {n_paras:4d} paras -> "
|
| 1254 |
+
f"{len(chunks):3d} chunks {citation}")
|
| 1255 |
+
# act_short is the per-decision identity in this corpus (section is "" for
|
| 1256 |
+
# every case-law chunk), and it is unique across all three lists.
|
| 1257 |
+
all_chunks, preserved = preserve_dropped(all_chunks, stored_chunks(OUT),
|
| 1258 |
+
key=lambda c: c["act_short"],
|
| 1259 |
+
only=failed)
|
| 1260 |
+
if preserved:
|
| 1261 |
+
print(f" kept the last-good copy of {len(preserved)} decision(s) "
|
| 1262 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 1263 |
+
# indent=1 is what this corpus was written with; changing it would rewrite
|
| 1264 |
+
# every line of caselaw.json for no content change.
|
| 1265 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 1266 |
+
return False
|
| 1267 |
+
print(f"\n{len(all_chunks)} case-law chunks from "
|
| 1268 |
+
f"{len(CASES) + len(CANLII_CASES) + len(IRB_GUIDES)} decisions "
|
| 1269 |
+
f"-> {OUT}")
|
| 1270 |
+
return True
|
| 1271 |
+
|
| 1272 |
+
|
| 1273 |
+
def main():
|
| 1274 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 1275 |
+
sys.exit(0 if ok else 1)
|
| 1276 |
+
|
| 1277 |
+
|
| 1278 |
+
if __name__ == "__main__":
|
| 1279 |
+
main()
|
canlex/charter.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the Charter and border-relevant Constitution Act, 1867 provisions.
|
| 2 |
+
|
| 3 |
+
The Constitution has no Justice Laws XML, so the corpus long had a hole a
|
| 4 |
+
large fraction of its own case law turns on: Simmons, Canfield, Pike and the
|
| 5 |
+
detention line all apply Charter ss. 7-10 and 24, yet "what does s. 10(b)
|
| 6 |
+
actually say?" returned nothing. Justice Laws publishes the Constitution Acts
|
| 7 |
+
as stable public HTML (laws-lois.justice.gc.ca/eng/const/FullText.html) with
|
| 8 |
+
a regular MarginalNote/Section markup, parsed here into legislation-shaped
|
| 9 |
+
chunks. Scope: the whole Charter (1982 ss. 1-34), Aboriginal rights s. 35,
|
| 10 |
+
the supremacy clause s. 52, and the 1867 heads-of-power ss. 91-92.
|
| 11 |
+
|
| 12 |
+
python -m canlex.charter
|
| 13 |
+
"""
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
from ._common import fetch_cached, norm_ws as _norm, write_corpus
|
| 18 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 19 |
+
|
| 20 |
+
URL = "https://laws-lois.justice.gc.ca/eng/const/FullText.html"
|
| 21 |
+
RAW = RAW_DIR / "charter"
|
| 22 |
+
OUT_FILE = PROCESSED_DIR / "charter.json"
|
| 23 |
+
|
| 24 |
+
# (act filter, sections wanted). The 1982 Act's Charter is ss. 1-34; s. 35 is
|
| 25 |
+
# Part II (Aboriginal rights) and s. 52 the supremacy clause -- both cited
|
| 26 |
+
# constantly alongside Charter arguments. From 1867, only the ss. 91-92
|
| 27 |
+
# division of powers earns its place in a border-law corpus.
|
| 28 |
+
_WANT_1982 = {str(n) for n in range(1, 36)} | {"52"}
|
| 29 |
+
_WANT_1867 = {"91", "92"}
|
| 30 |
+
|
| 31 |
+
_MN = re.compile(
|
| 32 |
+
r'<p class="MarginalNote">(?:<span[^>]*>.*?</span>)?(.*?)</p>', re.S)
|
| 33 |
+
_SEC_ID = re.compile(r'<span class="sectionLabel">([0-9.]+)</span>')
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _strip(html):
|
| 37 |
+
"""Readable text from a fragment: block ends become newlines, tags go."""
|
| 38 |
+
html = re.sub(r"</(?:p|li|h\d)>", "\n", html)
|
| 39 |
+
html = re.sub(r"<[^>]+>", " ", html)
|
| 40 |
+
return "\n".join(p for p in (_norm(line) for line in html.split("\n")) if p)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _sections(segment_html):
|
| 44 |
+
"""Yield (number, marginal_note, text) for each section in a segment."""
|
| 45 |
+
notes = list(_MN.finditer(segment_html))
|
| 46 |
+
for i, m in enumerate(notes):
|
| 47 |
+
end = notes[i + 1].start() if i + 1 < len(notes) else len(segment_html)
|
| 48 |
+
body_html = segment_html[m.end():end]
|
| 49 |
+
num_m = _SEC_ID.search(body_html)
|
| 50 |
+
if not num_m:
|
| 51 |
+
continue
|
| 52 |
+
yield num_m.group(1), _norm(_strip(m.group(1))), _strip(body_html)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def parse(html):
|
| 56 |
+
"""Chunks for every in-scope section of both Acts. Pure, for tests.
|
| 57 |
+
|
| 58 |
+
Justice Laws could re-shape this page the way CBSA re-shaped its D-memo
|
| 59 |
+
index, in which case the MarginalNote/Section markup stops matching and
|
| 60 |
+
this returns []; the write guard in build() is what keeps that from
|
| 61 |
+
emptying the corpus.
|
| 62 |
+
"""
|
| 63 |
+
# The page is both Acts in sequence; split at the 1982 Act's title so
|
| 64 |
+
# section numbers (which restart) land in the right instrument.
|
| 65 |
+
cut = html.find("CONSTITUTION ACT, 1982")
|
| 66 |
+
if cut < 0:
|
| 67 |
+
raise ValueError("could not locate the Constitution Act, 1982 heading")
|
| 68 |
+
parts = [
|
| 69 |
+
("CONST-1867", "Constitution Act, 1867", "Constitution Act, 1867",
|
| 70 |
+
html[:cut], _WANT_1867),
|
| 71 |
+
("CONST-1982", "Charter", "Constitution Act, 1982 (incl. the Canadian "
|
| 72 |
+
"Charter of Rights and Freedoms)", html[cut:], _WANT_1982),
|
| 73 |
+
]
|
| 74 |
+
chunks = []
|
| 75 |
+
for code, short, name, segment, want in parts:
|
| 76 |
+
for num, note, text in _sections(segment):
|
| 77 |
+
if num not in want or not text:
|
| 78 |
+
continue
|
| 79 |
+
label = f"s. {num}"
|
| 80 |
+
chunks.append({
|
| 81 |
+
"id": f"{code}-s{num}",
|
| 82 |
+
"doc_type": "legislation",
|
| 83 |
+
"act_code": code,
|
| 84 |
+
"act_short": short,
|
| 85 |
+
"act_name": name,
|
| 86 |
+
"section": num,
|
| 87 |
+
"marginal_note": note,
|
| 88 |
+
"part": "",
|
| 89 |
+
"division": "",
|
| 90 |
+
"heading": "",
|
| 91 |
+
"text": text,
|
| 92 |
+
"history": "",
|
| 93 |
+
"last_amended": "",
|
| 94 |
+
"in_force": "",
|
| 95 |
+
"status": "in force",
|
| 96 |
+
"current_to": "",
|
| 97 |
+
"citation": f"{short}, {label}",
|
| 98 |
+
"source_url": URL,
|
| 99 |
+
})
|
| 100 |
+
return chunks
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build(allow_shrink=False):
|
| 104 |
+
html = fetch_cached(URL, RAW / "FullText.html").decode("utf-8", "replace")
|
| 105 |
+
chunks = parse(html)
|
| 106 |
+
# indent=2 is what charter.json already carries -- changing it would
|
| 107 |
+
# rewrite every line of the file for nothing.
|
| 108 |
+
if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
|
| 109 |
+
return False
|
| 110 |
+
print(f"charter: {len(chunks)} sections -> {OUT_FILE.name}")
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
canlex/citator.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CanLII case citator -- live lookup of a Canadian case's citation graph.
|
| 2 |
+
|
| 3 |
+
Uses the CanLII API (key in canlii_key.txt). The API has no name/topic search,
|
| 4 |
+
so a case is identified by its full canlii.org URL. Responses are cached on disk
|
| 5 |
+
and calls are throttled, because the API rate-limits aggressively.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
import time
|
| 12 |
+
import urllib.error
|
| 13 |
+
import urllib.request
|
| 14 |
+
|
| 15 |
+
from .config import ROOT, DATA_DIR
|
| 16 |
+
|
| 17 |
+
API = "https://api.canlii.org/v1"
|
| 18 |
+
KEY_FILE = ROOT / "canlii_key.txt"
|
| 19 |
+
_PLACEHOLDER = "PASTE-YOUR-CANLII-API-KEY-ON-THIS-LINE"
|
| 20 |
+
_DBMAP_FILE = DATA_DIR / "citator_dbmap.json"
|
| 21 |
+
_CACHE_FILE = DATA_DIR / "citator_cache.json"
|
| 22 |
+
_THROTTLE = 3.0 # seconds between CanLII API calls (the API rate-limits hard)
|
| 23 |
+
_MAX_LIST = 20 # items shown per citator list (lists can run to thousands)
|
| 24 |
+
_CASE_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/doc/\d+/([a-z0-9-]+)")
|
| 25 |
+
_DB_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/?$")
|
| 26 |
+
|
| 27 |
+
# A neutral citation, e.g. "2019 SCC 65", and the CanLII URL segment per
|
| 28 |
+
# court/tribunal. The tribunal entries cover the boards whose decisions the
|
| 29 |
+
# corpus itself carries, so the natural loop (search result -> canlex_case)
|
| 30 |
+
# works for every citation a result can display.
|
| 31 |
+
_NEUTRAL = re.compile(
|
| 32 |
+
r"\b(\d{4})\s+(SCC|FCA|FC|FPSLREB|CIRB|ONCA|ABCA)\s+(\d+)\b",
|
| 33 |
+
re.IGNORECASE)
|
| 34 |
+
_CANLII_SEG = {"scc": "scc", "fca": "fca", "fc": "fct",
|
| 35 |
+
"fpslreb": "pslreb", "cirb": "cirb",
|
| 36 |
+
"onca": "onca", "abca": "abca"}
|
| 37 |
+
_PROVINCE_SEG = {"onca": "on", "abca": "ab"} # others are federal ('ca')
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def canlii_url_from_citation(text):
|
| 41 |
+
"""Build a canlii.org case URL from a neutral citation, or '' if none found.
|
| 42 |
+
|
| 43 |
+
Works for the Supreme Court, Federal Court of Appeal, Federal Court, the
|
| 44 |
+
FPSLREB and CIRB labour boards, and the Ontario and Alberta Courts of
|
| 45 |
+
Appeal -- e.g. "2019 SCC 65" -> .../en/ca/scc/doc/2019/2019scc65/...
|
| 46 |
+
"""
|
| 47 |
+
m = _NEUTRAL.search(text)
|
| 48 |
+
if not m:
|
| 49 |
+
return ""
|
| 50 |
+
year, court, num = m.group(1), m.group(2).lower(), m.group(3)
|
| 51 |
+
doc = f"{year}{court}{num}"
|
| 52 |
+
juris = _PROVINCE_SEG.get(court, "ca")
|
| 53 |
+
return (f"https://www.canlii.org/en/{juris}/{_CANLII_SEG[court]}/doc/"
|
| 54 |
+
f"{year}/{doc}/{doc}.html")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def api_key():
|
| 58 |
+
"""Return the configured CanLII API key, or '' if not set.
|
| 59 |
+
|
| 60 |
+
The CANLII_API_KEY environment variable is checked first -- the remote
|
| 61 |
+
deployment injects the key as a secret rather than shipping the file.
|
| 62 |
+
A local run falls back to canlii_key.txt.
|
| 63 |
+
"""
|
| 64 |
+
env_key = os.environ.get("CANLII_API_KEY", "").strip()
|
| 65 |
+
if env_key and env_key != _PLACEHOLDER:
|
| 66 |
+
return env_key
|
| 67 |
+
if not KEY_FILE.exists():
|
| 68 |
+
return ""
|
| 69 |
+
key = KEY_FILE.read_text(encoding="utf-8").strip()
|
| 70 |
+
return "" if not key or key == _PLACEHOLDER else key
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _load_json(path):
|
| 74 |
+
try:
|
| 75 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 76 |
+
except Exception:
|
| 77 |
+
return {}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class Citator:
|
| 81 |
+
"""Live CanLII citator with on-disk caching and rate-limit throttling."""
|
| 82 |
+
|
| 83 |
+
def __init__(self):
|
| 84 |
+
self.key = api_key()
|
| 85 |
+
if not self.key:
|
| 86 |
+
raise RuntimeError(f"No CanLII API key -- put your key in {KEY_FILE}.")
|
| 87 |
+
self._dbmap = _load_json(_DBMAP_FILE) # URL court segment -> databaseId
|
| 88 |
+
self._cache = _load_json(_CACHE_FILE) # case URL -> report
|
| 89 |
+
|
| 90 |
+
def _get(self, path):
|
| 91 |
+
"""Throttled GET against the CanLII API, retrying on HTTP 429."""
|
| 92 |
+
for attempt in range(3):
|
| 93 |
+
time.sleep(_THROTTLE if attempt == 0 else 15.0)
|
| 94 |
+
sep = "&" if "?" in path else "?"
|
| 95 |
+
url = f"{API}/{path}{sep}api_key={self.key}"
|
| 96 |
+
try:
|
| 97 |
+
with urllib.request.urlopen(url, timeout=45) as resp:
|
| 98 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 99 |
+
except urllib.error.HTTPError as exc:
|
| 100 |
+
if exc.code == 429 and attempt < 2:
|
| 101 |
+
continue
|
| 102 |
+
raise
|
| 103 |
+
raise RuntimeError("CanLII API rate limit reached; retry shortly.")
|
| 104 |
+
|
| 105 |
+
def _ensure_dbmap(self):
|
| 106 |
+
if self._dbmap:
|
| 107 |
+
return
|
| 108 |
+
data = self._get("caseBrowse/en/")
|
| 109 |
+
dbmap = {}
|
| 110 |
+
for db in data.get("caseDatabases", []):
|
| 111 |
+
match = _DB_URL.search(db.get("url") or "")
|
| 112 |
+
if match and db.get("databaseId"):
|
| 113 |
+
dbmap[match.group(1)] = db["databaseId"]
|
| 114 |
+
self._dbmap = dbmap
|
| 115 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
_DBMAP_FILE.write_text(json.dumps(dbmap), encoding="utf-8")
|
| 117 |
+
|
| 118 |
+
def _citator(self, db, case_id, kind):
|
| 119 |
+
data = self._get(f"caseCitator/en/{db}/{case_id}/{kind}")
|
| 120 |
+
items = data.get(kind, [])
|
| 121 |
+
return {"total": len(items), "items": items[:_MAX_LIST]}
|
| 122 |
+
|
| 123 |
+
def case_report(self, case_url):
|
| 124 |
+
"""Return a citation-graph report for a case.
|
| 125 |
+
|
| 126 |
+
Accepts a full canlii.org case URL, or a neutral citation (e.g.
|
| 127 |
+
"2019 SCC 65") for a Supreme Court / Federal Court of Appeal / Federal
|
| 128 |
+
Court decision.
|
| 129 |
+
"""
|
| 130 |
+
if not _CASE_URL.search(case_url):
|
| 131 |
+
case_url = canlii_url_from_citation(case_url) or case_url
|
| 132 |
+
if case_url in self._cache:
|
| 133 |
+
return self._cache[case_url]
|
| 134 |
+
match = _CASE_URL.search(case_url)
|
| 135 |
+
if not match:
|
| 136 |
+
return {"error": "Provide a full canlii.org case URL, or a neutral "
|
| 137 |
+
"citation such as '2019 SCC 65' (Supreme Court, Federal "
|
| 138 |
+
"Court of Appeal, or Federal Court)."}
|
| 139 |
+
self._ensure_dbmap()
|
| 140 |
+
segment, case_id = match.group(1), match.group(2)
|
| 141 |
+
db = self._dbmap.get(segment)
|
| 142 |
+
if not db:
|
| 143 |
+
return {"error": f"Unrecognized CanLII court segment '{segment}'."}
|
| 144 |
+
report = {
|
| 145 |
+
"meta": self._get(f"caseBrowse/en/{db}/{case_id}/"),
|
| 146 |
+
"citingCases": self._citator(db, case_id, "citingCases"),
|
| 147 |
+
"citedCases": self._citator(db, case_id, "citedCases"),
|
| 148 |
+
"citedLegislations": self._citator(db, case_id, "citedLegislations"),
|
| 149 |
+
}
|
| 150 |
+
self._cache[case_url] = report
|
| 151 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
_CACHE_FILE.write_text(json.dumps(self._cache, ensure_ascii=False),
|
| 153 |
+
encoding="utf-8")
|
| 154 |
+
return report
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def main():
|
| 158 |
+
if len(sys.argv) < 2:
|
| 159 |
+
print('usage: python -m canlex.citator "<canlii-case-url>"')
|
| 160 |
+
return
|
| 161 |
+
report = Citator().case_report(sys.argv[1])
|
| 162 |
+
if "error" in report:
|
| 163 |
+
print("ERROR:", report["error"])
|
| 164 |
+
return
|
| 165 |
+
meta = report["meta"]
|
| 166 |
+
print(f"{meta.get('title')} -- {meta.get('citation')} ({meta.get('decisionDate')})")
|
| 167 |
+
print(f" cited by: {report['citingCases']['total']}")
|
| 168 |
+
print(f" cites: {report['citedCases']['total']}")
|
| 169 |
+
print(f" legislation cited: {report['citedLegislations']['total']}")
|
| 170 |
+
for item in report["citedLegislations"]["items"][:6]:
|
| 171 |
+
print(f" - {item.get('title')} ({item.get('citation')})")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|
canlex/ciu.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest CIU and PSAC union documents (doc_type='union').
|
| 2 |
+
|
| 3 |
+
The Customs and Immigration Union is the PSAC component representing the FB
|
| 4 |
+
bargaining unit; its governance instruments and published reference material
|
| 5 |
+
are the union-side counterpart to the FB collective agreement already in the
|
| 6 |
+
corpus. Ingested here, all as public documents from ciu-sdi.ca / psacunion.ca:
|
| 7 |
+
|
| 8 |
+
- the CIU National By-Laws (EN + FR), CIU Regulations, Branch Rules and the
|
| 9 |
+
Local Modifications to Branch Rules -- the union's internal law;
|
| 10 |
+
- the PSAC Constitution and Regulations (the parent instrument the by-laws
|
| 11 |
+
operate under: dues, discipline and appeals bottom out there);
|
| 12 |
+
- the Branch Elections Kit and the members' guides (grievance handling,
|
| 13 |
+
employment equity, workers in transition);
|
| 14 |
+
- every site-specific Variable Shift Schedule Agreement (VSSA) the union
|
| 15 |
+
publishes -- the locally negotiated schedule grids for ports of entry.
|
| 16 |
+
|
| 17 |
+
None of this is employer policy or part of the collective agreement itself;
|
| 18 |
+
_format_section banners doc_type='union' accordingly.
|
| 19 |
+
|
| 20 |
+
python -m canlex.ciu
|
| 21 |
+
"""
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
import urllib.request
|
| 25 |
+
|
| 26 |
+
from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
|
| 27 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 28 |
+
|
| 29 |
+
RAW = RAW_DIR / "ciu"
|
| 30 |
+
VSSA_PAGE = "https://ciu-sdi.ca/en/vssa/"
|
| 31 |
+
_DOCS_BASE = "https://www.ciu-sdi.ca/wp-content/uploads/documents/"
|
| 32 |
+
|
| 33 |
+
# (code, act_short, act_name, url, lang)
|
| 34 |
+
DOCS = [
|
| 35 |
+
("ciu-bylaws", "CIU By-Laws",
|
| 36 |
+
"CIU National By-Laws (adopted by CIU National Conventions)",
|
| 37 |
+
_DOCS_BASE + "bylaws_en.pdf", "en"),
|
| 38 |
+
("ciu-bylaws", "CIU By-Laws",
|
| 39 |
+
"Règlements nationaux du SDI (adoptés par les congrès)",
|
| 40 |
+
_DOCS_BASE + "bylaws_fr.pdf", "fr"),
|
| 41 |
+
("ciu-regs", "CIU Regulations",
|
| 42 |
+
"CIU Regulations (adopted by the CIU National Board of Directors)",
|
| 43 |
+
_DOCS_BASE + "ciu-regs-en.pdf", "en"),
|
| 44 |
+
("ciu-branch-rules", "CIU Branch Rules",
|
| 45 |
+
"CIU Branch Rules (adopted by the CIU National Board of Directors)",
|
| 46 |
+
_DOCS_BASE + "branch-rules-en.pdf", "en"),
|
| 47 |
+
("ciu-local-mods", "CIU Local Modifications",
|
| 48 |
+
"Local Modifications to CIU Branch Rules",
|
| 49 |
+
_DOCS_BASE + "local_mod_branch_rules_en.pdf", "en"),
|
| 50 |
+
("ciu-elections-kit", "CIU Elections Kit",
|
| 51 |
+
"CIU Branch Elections Kit",
|
| 52 |
+
"https://www.ciu-sdi.ca/wp-content/uploads/elections/2023/EN/"
|
| 53 |
+
"elections-kit.pdf", "en"),
|
| 54 |
+
("psac-constitution", "PSAC Constitution",
|
| 55 |
+
"PSAC Constitution and Regulations (November 2024 edition)",
|
| 56 |
+
"https://psacunion.ca/sites/psac/files/"
|
| 57 |
+
"psac.constitution.november_2024_edition.eng_v002.pdf", "en"),
|
| 58 |
+
("ciu-guide-grievance", "CIU Grievance Guide",
|
| 59 |
+
"CIU Guide to Grievance Handling",
|
| 60 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 61 |
+
"guide_to_grievance_handling-e.pdf", "en"),
|
| 62 |
+
("ciu-guide-ee", "CIU EE Toolkit",
|
| 63 |
+
"CIU Employment Equity Toolkit",
|
| 64 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 65 |
+
"EMPLOYMENT-EQUITY-Tool-Kit-e.pdf", "en"),
|
| 66 |
+
("ciu-guide-transition", "CIU Transition Guide",
|
| 67 |
+
"Workers in Transition: a practical guide about gender transition",
|
| 68 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 69 |
+
"workers_in_transition_guide_-_en_-_web.pdf", "en"),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
# Governance headings: "BY-LAW 5", "REGULATION 12", "RULE 3", "ARTICLE 7",
|
| 73 |
+
# "SECTION 25", "PART B" -- at line start, optionally followed by a title.
|
| 74 |
+
_HEADING = re.compile(
|
| 75 |
+
r"^(BY-?LAW|R[EÈ]GLEMENT|REGULATION|RULE|ARTICLE|SECTION|PART|ANNEX(?:E)?"
|
| 76 |
+
r"|APPENDIX)\s+([0-9]+[0-9A-Za-z.\-]*|[A-Z])\b\s*[-–—:]?\s*(.{0,80})",
|
| 77 |
+
re.IGNORECASE)
|
| 78 |
+
_DATE = re.compile(r"\b(20\d{2}|19\d{2})\b")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _pdf_pages(path):
|
| 82 |
+
from pypdf import PdfReader
|
| 83 |
+
pages = []
|
| 84 |
+
for page in PdfReader(str(path)).pages:
|
| 85 |
+
try:
|
| 86 |
+
pages.append(page.extract_text() or "")
|
| 87 |
+
except Exception:
|
| 88 |
+
pages.append("")
|
| 89 |
+
return pages
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _currency(pages):
|
| 93 |
+
"""Newest adoption/amendment year on the cover pages."""
|
| 94 |
+
years = _DATE.findall("\n".join(pages[:3]))
|
| 95 |
+
return max(years) if years else ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _doc_chunks(code, short, name, url, lang, pages):
|
| 99 |
+
text = "\n".join(pages)
|
| 100 |
+
lines = text.split("\n")
|
| 101 |
+
currency = _currency(pages)
|
| 102 |
+
marks = []
|
| 103 |
+
for i, l in enumerate(lines):
|
| 104 |
+
m = _HEADING.match(l.strip())
|
| 105 |
+
if m:
|
| 106 |
+
marks.append((i, f"{m.group(1).title()} {m.group(2)}",
|
| 107 |
+
_norm(m.group(3)) or ""))
|
| 108 |
+
segments = []
|
| 109 |
+
if len(marks) >= 6:
|
| 110 |
+
for j, (i, label, title) in enumerate(marks):
|
| 111 |
+
end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
|
| 112 |
+
body = "\n".join(p for p in (_norm(x) for x in lines[i:end]) if p)
|
| 113 |
+
if len(body) > 120:
|
| 114 |
+
segments.append((label, title, body))
|
| 115 |
+
if not segments:
|
| 116 |
+
for k, piece in enumerate(split_lines(text, 1800), start=1):
|
| 117 |
+
body = "\n".join(p for p in (_norm(x) for x in piece.split("\n")) if p)
|
| 118 |
+
if len(body) > 120:
|
| 119 |
+
segments.append((f"part {k}", "", body))
|
| 120 |
+
chunks = []
|
| 121 |
+
fr = "-fr" if lang == "fr" else ""
|
| 122 |
+
for n, (label, title, body) in enumerate(segments, start=1):
|
| 123 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 124 |
+
total = len(pieces)
|
| 125 |
+
for k, piece in enumerate(pieces, start=1):
|
| 126 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 127 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 128 |
+
chunk = {
|
| 129 |
+
"id": f"{code}{fr}-{n}{suffix}",
|
| 130 |
+
"doc_type": "union",
|
| 131 |
+
"act_code": code.upper(),
|
| 132 |
+
"act_short": short,
|
| 133 |
+
"act_name": name,
|
| 134 |
+
"section": label,
|
| 135 |
+
"marginal_note": title,
|
| 136 |
+
"part": short,
|
| 137 |
+
"division": "",
|
| 138 |
+
"heading": "",
|
| 139 |
+
"text": piece,
|
| 140 |
+
"history": "",
|
| 141 |
+
"last_amended": currency,
|
| 142 |
+
"in_force": "",
|
| 143 |
+
"status": "in force",
|
| 144 |
+
"current_to": currency,
|
| 145 |
+
"citation": f"{short}, {label}"
|
| 146 |
+
+ (f" ({title})" if title else "") + note,
|
| 147 |
+
"source_url": url,
|
| 148 |
+
}
|
| 149 |
+
if lang == "fr":
|
| 150 |
+
chunk["lang"] = "fr"
|
| 151 |
+
chunks.append(chunk)
|
| 152 |
+
return chunks
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _vssa_links():
|
| 156 |
+
req = urllib.request.Request(VSSA_PAGE, headers={"User-Agent": "Mozilla/5.0"})
|
| 157 |
+
with urllib.request.urlopen(req, timeout=60) as r:
|
| 158 |
+
html = r.read().decode("utf-8", "replace")
|
| 159 |
+
links = sorted(set(re.findall(
|
| 160 |
+
r'href="(https?://[^"]*?/uploads/vssa/([^"/]+)\.pdf)"', html)))
|
| 161 |
+
return links
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _vssa_chunks(url, stem, pages):
|
| 165 |
+
site = _norm(stem.replace("-", " "))
|
| 166 |
+
text = "\n".join(p for p in (_norm(x) for x in "\n".join(pages).split("\n")) if p)
|
| 167 |
+
if len(text) < 60:
|
| 168 |
+
return []
|
| 169 |
+
out = []
|
| 170 |
+
pieces = split_lines(text, 1800)
|
| 171 |
+
total = len(pieces)
|
| 172 |
+
for k, piece in enumerate(pieces, start=1):
|
| 173 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 174 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 175 |
+
out.append({
|
| 176 |
+
"id": f"vssa-{stem.lower()}{suffix}",
|
| 177 |
+
"doc_type": "union",
|
| 178 |
+
"act_code": "VSSA",
|
| 179 |
+
"act_short": "VSSA",
|
| 180 |
+
"act_name": "Variable Shift Schedule Agreements (by work site)",
|
| 181 |
+
"section": site,
|
| 182 |
+
"marginal_note": f"VSSA — {site}",
|
| 183 |
+
"part": "Variable Shift Schedule Agreements",
|
| 184 |
+
"division": "",
|
| 185 |
+
"heading": "",
|
| 186 |
+
"text": piece,
|
| 187 |
+
"history": "",
|
| 188 |
+
"last_amended": "",
|
| 189 |
+
"in_force": "",
|
| 190 |
+
"status": "in force",
|
| 191 |
+
"current_to": "",
|
| 192 |
+
"citation": f"VSSA — {site}{note}",
|
| 193 |
+
"source_url": url,
|
| 194 |
+
})
|
| 195 |
+
return out
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def build():
|
| 199 |
+
RAW.mkdir(parents=True, exist_ok=True)
|
| 200 |
+
all_chunks = []
|
| 201 |
+
for code, short, name, url, lang in DOCS:
|
| 202 |
+
fr = "-fr" if lang == "fr" else ""
|
| 203 |
+
dest = RAW / f"{code}{fr}.pdf"
|
| 204 |
+
try:
|
| 205 |
+
fetch_cached(url, dest, pause=0.8)
|
| 206 |
+
chunks = _doc_chunks(code, short, name, url, lang,
|
| 207 |
+
_pdf_pages(dest))
|
| 208 |
+
except Exception as exc:
|
| 209 |
+
print(f" !! {short} ({lang}): {type(exc).__name__}: {exc}")
|
| 210 |
+
continue
|
| 211 |
+
print(f" {short:24} ({lang}): {len(chunks)} chunks")
|
| 212 |
+
all_chunks.extend(chunks)
|
| 213 |
+
try:
|
| 214 |
+
links = _vssa_links()
|
| 215 |
+
except Exception as exc:
|
| 216 |
+
links = []
|
| 217 |
+
print(f" !! VSSA index: {type(exc).__name__}: {exc}")
|
| 218 |
+
n_vssa = 0
|
| 219 |
+
for url, stem in links:
|
| 220 |
+
dest = RAW / f"vssa-{stem.lower()}.pdf"
|
| 221 |
+
try:
|
| 222 |
+
fetch_cached(url, dest, pause=0.6)
|
| 223 |
+
chunks = _vssa_chunks(url, stem, _pdf_pages(dest))
|
| 224 |
+
except Exception as exc:
|
| 225 |
+
print(f" !! VSSA {stem}: {type(exc).__name__}: {exc}")
|
| 226 |
+
continue
|
| 227 |
+
all_chunks.extend(chunks)
|
| 228 |
+
n_vssa += 1
|
| 229 |
+
print(f" VSSA sites: {n_vssa}")
|
| 230 |
+
uniquify_ids(all_chunks)
|
| 231 |
+
out = PROCESSED_DIR / "ciu.json"
|
| 232 |
+
out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
|
| 233 |
+
encoding="utf-8")
|
| 234 |
+
print(f"ciu: {len(all_chunks)} chunks -> {out.name}")
|
| 235 |
+
return all_chunks
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
if __name__ == "__main__":
|
| 239 |
+
build()
|
canlex/commentary.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest CanLex's curated commentary datasets into searchable chunks.
|
| 2 |
+
|
| 3 |
+
Commentary is the one doc_type CanLex AUTHORS rather than mirrors: structured
|
| 4 |
+
legal-analysis datasets (currently the US-dispositions helper -- whether each
|
| 5 |
+
kind of US state criminal disposition is a "conviction" for IRPA s. 36
|
| 6 |
+
purposes). Every chunk is banner-labelled as commentary, every proposition
|
| 7 |
+
carries its authorities, and entries with no authority say so explicitly and
|
| 8 |
+
flag their reasoning as interpretation. The source of truth is
|
| 9 |
+
data/curated/us_dispositions.json, which is reviewed by the user before it
|
| 10 |
+
ships; this module just renders it into corpus chunks.
|
| 11 |
+
|
| 12 |
+
py -m canlex.commentary [--allow-shrink]
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
from ._common import write_corpus
|
| 18 |
+
from .config import DATA_DIR, PROCESSED_DIR
|
| 19 |
+
|
| 20 |
+
CURATED = DATA_DIR / "curated" / "us_dispositions.json"
|
| 21 |
+
EQUIV = DATA_DIR / "curated" / "us_equivalency.json"
|
| 22 |
+
OUT = PROCESSED_DIR / "commentary.json"
|
| 23 |
+
|
| 24 |
+
ACT_CODE = "US-DISP"
|
| 25 |
+
ACT_SHORT = "US Dispositions Helper"
|
| 26 |
+
ACT_NAME = ("US criminal dispositions and the IRPA 'conviction' concept "
|
| 27 |
+
"(curated CanLex commentary)")
|
| 28 |
+
|
| 29 |
+
BANNER = ("CURATED ANALYSIS -- commentary compiled for CanLex, not a source "
|
| 30 |
+
"of law. Verify against the cited authorities before relying on it.")
|
| 31 |
+
|
| 32 |
+
_STATUS_LABEL = {
|
| 33 |
+
"settled": "Settled by binding authority",
|
| 34 |
+
"judicially-considered": "Judicially considered (persuasive authority)",
|
| 35 |
+
"guidance-only": "IRCC guidance only -- no judicial authority located",
|
| 36 |
+
"no-authority": "NO AUTHORITY LOCATED -- reasoned interpretation only",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Verdicts assess the COMPLETED disposition; the methodology chunk explains
|
| 40 |
+
# the rule each one derives from. 'depends' is the legacy spelling of
|
| 41 |
+
# fact-specific, kept for backwards compatibility.
|
| 42 |
+
_VERDICT_LABEL = {
|
| 43 |
+
"yes": "YES", "likely-yes": "LIKELY YES", "likely-no": "LIKELY NO",
|
| 44 |
+
"no": "NO", "fact-specific": "FACT-SPECIFIC",
|
| 45 |
+
"depends": "FACT-SPECIFIC",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _vlabel(v):
|
| 50 |
+
return _VERDICT_LABEL.get(v, str(v).upper())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _entry_text(e, max_states=None):
|
| 54 |
+
"""Render one disposition entry as a readable, retrieval-friendly block.
|
| 55 |
+
|
| 56 |
+
With the 51-jurisdiction survey an entry can carry dozens of state rows;
|
| 57 |
+
max_states caps how many render inline (the corpus keeps full per-state
|
| 58 |
+
detail in separate per-state chunks so retrieval still reaches every
|
| 59 |
+
row). None renders everything -- the tool uses that for direct lookups."""
|
| 60 |
+
lines = [BANNER, ""]
|
| 61 |
+
lines.append(f"Disposition: {'; '.join(e['names'])}")
|
| 62 |
+
lines.append(f"Is the COMPLETED disposition a conviction for IRPA s. 36: "
|
| 63 |
+
f"{_vlabel(e['is_conviction'])}")
|
| 64 |
+
lines.append(f"Authority status: {_STATUS_LABEL[e['status']]}")
|
| 65 |
+
if e.get("bottom_line"):
|
| 66 |
+
lines.append("")
|
| 67 |
+
lines.append(f"BOTTOM LINE: {e['bottom_line']}")
|
| 68 |
+
lines.append("")
|
| 69 |
+
lines.append(e["analysis"])
|
| 70 |
+
if e.get("state_variations"):
|
| 71 |
+
named = [v for v in e["state_variations"]
|
| 72 |
+
if v["state"].lower() != "general"]
|
| 73 |
+
shown = named if max_states is None else named[:max_states]
|
| 74 |
+
lines.append("")
|
| 75 |
+
if max_states is not None and len(named) > len(shown):
|
| 76 |
+
from collections import Counter
|
| 77 |
+
counts = Counter(v.get("is_conviction", "depends") for v in named)
|
| 78 |
+
lines.append(f"State-by-state coverage: {len(named)} jurisdictions "
|
| 79 |
+
f"({', '.join(f'{k}: {n}' for k, n in counts.most_common())}) "
|
| 80 |
+
f"-- full per-state detail in the per-state entries.")
|
| 81 |
+
else:
|
| 82 |
+
lines.append("State variations:")
|
| 83 |
+
for v in shown:
|
| 84 |
+
flag = (f" (conviction: {_vlabel(v['is_conviction'])})"
|
| 85 |
+
if v.get("is_conviction") else "")
|
| 86 |
+
lines.append(f"- {v['state']}{flag}: {v['note']}")
|
| 87 |
+
for v in e["state_variations"]:
|
| 88 |
+
if v["state"].lower() == "general":
|
| 89 |
+
lines.append(f"- General: {v['note']}")
|
| 90 |
+
if e.get("authorities"):
|
| 91 |
+
lines.append("")
|
| 92 |
+
lines.append("Authorities:")
|
| 93 |
+
for a in e["authorities"]:
|
| 94 |
+
pin = f", {a['pin']}" if a.get("pin") else ""
|
| 95 |
+
lines.append(f"- {a['cite']} ({a['court']}{pin}): {a['holding']}")
|
| 96 |
+
if e.get("guidance"):
|
| 97 |
+
lines.append("")
|
| 98 |
+
lines.append("IRCC guidance (cited by reference; not reproduced here):")
|
| 99 |
+
for g in e["guidance"]:
|
| 100 |
+
lines.append(f"- {g['ref']}: {g['note']}")
|
| 101 |
+
if e.get("interpretation"):
|
| 102 |
+
lines.append("")
|
| 103 |
+
lines.append("INTERPRETATION (no direct authority -- this is CanLex's "
|
| 104 |
+
"reasoned view from the governing principles; treat it as "
|
| 105 |
+
"a starting point, not an answer): "
|
| 106 |
+
+ e["interpretation"])
|
| 107 |
+
return "\n".join(lines)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def build(allow_shrink=False):
|
| 111 |
+
data = json.loads(CURATED.read_text(encoding="utf-8"))
|
| 112 |
+
chunks = []
|
| 113 |
+
for e in data.get("methodology", []):
|
| 114 |
+
chunks.append({
|
| 115 |
+
"id": f"commentary-method-{e['id']}",
|
| 116 |
+
"doc_type": "commentary",
|
| 117 |
+
"act_code": ACT_CODE,
|
| 118 |
+
"act_short": ACT_SHORT,
|
| 119 |
+
"act_name": ACT_NAME,
|
| 120 |
+
"section": e["id"],
|
| 121 |
+
"marginal_note": e["title"],
|
| 122 |
+
"part": "Methodology",
|
| 123 |
+
"division": "",
|
| 124 |
+
"heading": e["title"],
|
| 125 |
+
"text": BANNER + "\n\n" + e["text"],
|
| 126 |
+
"history": "",
|
| 127 |
+
"last_amended": "",
|
| 128 |
+
"current_to": data.get("reviewed", ""),
|
| 129 |
+
"citation": f"{ACT_SHORT} — {e['title']}",
|
| 130 |
+
"source_url": "",
|
| 131 |
+
})
|
| 132 |
+
for e in data.get("dispositions", []):
|
| 133 |
+
chunks.append({
|
| 134 |
+
"id": f"commentary-disp-{e['id']}",
|
| 135 |
+
"doc_type": "commentary",
|
| 136 |
+
"act_code": ACT_CODE,
|
| 137 |
+
"act_short": ACT_SHORT,
|
| 138 |
+
"act_name": ACT_NAME,
|
| 139 |
+
"section": e["id"],
|
| 140 |
+
"marginal_note": e["names"][0],
|
| 141 |
+
"part": "US dispositions",
|
| 142 |
+
"division": "",
|
| 143 |
+
"heading": (f"Is a US {e['names'][0]} a conviction for IRPA "
|
| 144 |
+
f"s. 36? ({_vlabel(e['is_conviction'])})"),
|
| 145 |
+
"text": _entry_text(e, max_states=6),
|
| 146 |
+
"history": "",
|
| 147 |
+
"last_amended": "",
|
| 148 |
+
"current_to": data.get("reviewed", ""),
|
| 149 |
+
"citation": f"{ACT_SHORT} — {e['names'][0]}",
|
| 150 |
+
"source_url": "",
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
# One chunk per jurisdiction: every disposition row for that state, so a
|
| 154 |
+
# query naming a state ("Georgia first offender act", "Missouri SIS")
|
| 155 |
+
# retrieves that state's page directly.
|
| 156 |
+
by_state = {}
|
| 157 |
+
for e in data.get("dispositions", []):
|
| 158 |
+
for v in e.get("state_variations", []):
|
| 159 |
+
st = v["state"]
|
| 160 |
+
if st.lower() == "general":
|
| 161 |
+
continue
|
| 162 |
+
flag = _vlabel(v.get("is_conviction") or e["is_conviction"])
|
| 163 |
+
by_state.setdefault(st, []).append(
|
| 164 |
+
f"- {e['names'][0]} (conviction: {flag}): {v['note']}")
|
| 165 |
+
for st in sorted(by_state):
|
| 166 |
+
body = (BANNER + "\n\n"
|
| 167 |
+
+ f"US dispositions — {st}: whether each disposition type is a "
|
| 168 |
+
f"conviction for IRPA s. 36, under {st} law.\n\n"
|
| 169 |
+
+ "\n".join(by_state[st])
|
| 170 |
+
+ "\n\nThe act branch (IRPA s. 36(1)(c)/(2)(c)) can apply even "
|
| 171 |
+
"where a disposition is not a conviction. See the "
|
| 172 |
+
"per-disposition entries for the governing analysis and "
|
| 173 |
+
"authorities.")
|
| 174 |
+
slug = st.lower().replace(" ", "-")
|
| 175 |
+
chunks.append({
|
| 176 |
+
"id": f"commentary-state-{slug}",
|
| 177 |
+
"doc_type": "commentary",
|
| 178 |
+
"act_code": ACT_CODE,
|
| 179 |
+
"act_short": ACT_SHORT,
|
| 180 |
+
"act_name": ACT_NAME,
|
| 181 |
+
"section": f"state-{slug}",
|
| 182 |
+
"marginal_note": f"US dispositions — {st}",
|
| 183 |
+
"part": "US dispositions by state",
|
| 184 |
+
"division": "",
|
| 185 |
+
"heading": (f"{st}: criminal dispositions vs the IRPA "
|
| 186 |
+
f"'conviction' concept"),
|
| 187 |
+
"text": body,
|
| 188 |
+
"history": "",
|
| 189 |
+
"last_amended": "",
|
| 190 |
+
"current_to": data.get("reviewed", ""),
|
| 191 |
+
"citation": f"{ACT_SHORT} — {st}",
|
| 192 |
+
"source_url": "",
|
| 193 |
+
})
|
| 194 |
+
# --- equivalency pairings (step 2), same banner discipline
|
| 195 |
+
n_pairings = 0
|
| 196 |
+
if EQUIV.exists():
|
| 197 |
+
eq = json.loads(EQUIV.read_text(encoding="utf-8"))
|
| 198 |
+
for m in eq.get("methodology", []):
|
| 199 |
+
chunks.append({
|
| 200 |
+
"id": f"commentary-method-{m['id']}",
|
| 201 |
+
"doc_type": "commentary",
|
| 202 |
+
"act_code": ACT_CODE,
|
| 203 |
+
"act_short": ACT_SHORT,
|
| 204 |
+
"act_name": ACT_NAME,
|
| 205 |
+
"section": m["id"],
|
| 206 |
+
"marginal_note": m["title"],
|
| 207 |
+
"part": "Methodology",
|
| 208 |
+
"division": "",
|
| 209 |
+
"heading": m["title"],
|
| 210 |
+
"text": BANNER + "\n\n" + m["text"],
|
| 211 |
+
"history": "",
|
| 212 |
+
"last_amended": "",
|
| 213 |
+
"current_to": eq.get("reviewed", ""),
|
| 214 |
+
"citation": f"{ACT_SHORT} — {m['title']}",
|
| 215 |
+
"source_url": "",
|
| 216 |
+
})
|
| 217 |
+
for p in eq.get("pairings", []):
|
| 218 |
+
n_pairings += 1
|
| 219 |
+
lines = [BANNER, "",
|
| 220 |
+
f"US offence: {'; '.join(p['us_terms'][:5])}",
|
| 221 |
+
f"Canadian equivalent: {p['canadian_offence']}",
|
| 222 |
+
f"Maximum penalty (verified): {p['penalty']}",
|
| 223 |
+
f"Inadmissibility branch: {p['branch']}", "",
|
| 224 |
+
p["analysis"]]
|
| 225 |
+
if p.get("caveats"):
|
| 226 |
+
lines.append("")
|
| 227 |
+
lines.append("Caveats:")
|
| 228 |
+
lines += [f"- {c}" for c in p["caveats"]]
|
| 229 |
+
if p.get("authorities"):
|
| 230 |
+
lines.append("")
|
| 231 |
+
lines.append("Authorities:")
|
| 232 |
+
lines += [f"- {a['cite']} ({a['court']}): {a['holding']}"
|
| 233 |
+
for a in p["authorities"]]
|
| 234 |
+
chunks.append({
|
| 235 |
+
"id": f"commentary-equiv-{p['id']}",
|
| 236 |
+
"doc_type": "commentary",
|
| 237 |
+
"act_code": ACT_CODE,
|
| 238 |
+
"act_short": ACT_SHORT,
|
| 239 |
+
"act_name": ACT_NAME,
|
| 240 |
+
"section": f"equiv-{p['id']}",
|
| 241 |
+
"marginal_note": f"Equivalency: {p['us_terms'][0]}",
|
| 242 |
+
"part": "US offence equivalency",
|
| 243 |
+
"division": "",
|
| 244 |
+
"heading": (f"What does a US {p['us_terms'][0]} conviction "
|
| 245 |
+
f"equate to in Canada?"),
|
| 246 |
+
"text": "\n".join(lines),
|
| 247 |
+
"history": "",
|
| 248 |
+
"last_amended": "",
|
| 249 |
+
"current_to": eq.get("reviewed", ""),
|
| 250 |
+
"citation": f"{ACT_SHORT} — equivalency: {p['us_terms'][0]}",
|
| 251 |
+
"source_url": "",
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
# A full re-render of the curated files, so a truncated or half-edited
|
| 255 |
+
# us_dispositions.json (or an equivalency file that moved) collapses the
|
| 256 |
+
# chunk count and the write would replace good analysis with the remnant.
|
| 257 |
+
# indent=1 is the stored file's format -- changing it rewrites every line.
|
| 258 |
+
if not write_corpus(OUT, chunks, indent=1, allow_shrink=allow_shrink):
|
| 259 |
+
return False
|
| 260 |
+
print(f"{len(chunks)} commentary chunks "
|
| 261 |
+
f"({len(data.get('dispositions', []))} dispositions, "
|
| 262 |
+
f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
|
| 263 |
+
f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
|
| 264 |
+
return True
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def main():
|
| 268 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
if __name__ == "__main__":
|
| 272 |
+
main()
|
canlex/config.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paths and legislation sources for CanLex."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 5 |
+
DATA_DIR = ROOT / "data"
|
| 6 |
+
RAW_DIR = DATA_DIR / "raw"
|
| 7 |
+
PROCESSED_DIR = DATA_DIR / "processed"
|
| 8 |
+
|
| 9 |
+
# Justice Laws Website XML sources. Each key is the Justice Laws consolidated
|
| 10 |
+
# identifier, used as the XML filename and the local raw/processed filename.
|
| 11 |
+
# To add an instrument, add an entry with a verified xml_url.
|
| 12 |
+
#
|
| 13 |
+
# The Constitution Acts, 1867 to 1982 are intentionally absent: Justice Laws
|
| 14 |
+
# publishes them only as HTML/PDF, with no XML version available.
|
| 15 |
+
SOURCES = {
|
| 16 |
+
# --- Immigration ---
|
| 17 |
+
"I-2.5": {
|
| 18 |
+
"short": "IRPA", "name": "Immigration and Refugee Protection Act", "kind": "act",
|
| 19 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-2.5.xml",
|
| 20 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-2.5/"},
|
| 21 |
+
"SOR-2002-227": {
|
| 22 |
+
"short": "IRPR", "name": "Immigration and Refugee Protection Regulations",
|
| 23 |
+
"kind": "regulation",
|
| 24 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-227.xml",
|
| 25 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-227/"},
|
| 26 |
+
"C-29": {
|
| 27 |
+
"short": "Citizenship Act", "name": "Citizenship Act", "kind": "act",
|
| 28 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-29.xml",
|
| 29 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-29/"},
|
| 30 |
+
# IRB tribunal procedural rules. (SOR-2002-230 was repealed because the
|
| 31 |
+
# Immigration Appeal Division Rules, 2022 -- SOR-2022-277, below --
|
| 32 |
+
# replaced it in January 2023.)
|
| 33 |
+
"SOR-2022-277": {
|
| 34 |
+
"short": "IAD Rules", "name": "Immigration Appeal Division Rules, 2022",
|
| 35 |
+
"kind": "regulation",
|
| 36 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2022-277.xml",
|
| 37 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2022-277/"},
|
| 38 |
+
"SOR-2002-229": {
|
| 39 |
+
"short": "Immigration Division Rules", "name": "Immigration Division Rules",
|
| 40 |
+
"kind": "regulation",
|
| 41 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-229.xml",
|
| 42 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-229/"},
|
| 43 |
+
"SOR-2012-256": {
|
| 44 |
+
"short": "RPD Rules", "name": "Refugee Protection Division Rules",
|
| 45 |
+
"kind": "regulation",
|
| 46 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-256.xml",
|
| 47 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-256/"},
|
| 48 |
+
"SOR-2012-257": {
|
| 49 |
+
"short": "RAD Rules", "name": "Refugee Appeal Division Rules",
|
| 50 |
+
"kind": "regulation",
|
| 51 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-257.xml",
|
| 52 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-257/"},
|
| 53 |
+
|
| 54 |
+
# --- Customs & trade ---
|
| 55 |
+
"C-52.6": {
|
| 56 |
+
"short": "Customs Act", "name": "Customs Act", "kind": "act",
|
| 57 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-52.6.xml",
|
| 58 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-52.6/"},
|
| 59 |
+
# skip_schedules: the Customs Tariff's schedule is the entire tariff
|
| 60 |
+
# nomenclature -- tariff_schedule.py serves chapters 98/99 from the CBSA
|
| 61 |
+
# edition instead, so the XML schedule would only duplicate it at scale.
|
| 62 |
+
"C-54.011": {
|
| 63 |
+
"short": "Customs Tariff", "name": "Customs Tariff", "kind": "act",
|
| 64 |
+
"skip_schedules": True,
|
| 65 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-54.011.xml",
|
| 66 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-54.011/"},
|
| 67 |
+
# Traveller/trade instruments under the Customs Act and EIPA. The two
|
| 68 |
+
# control lists are almost entirely Schedules, so they depend on the
|
| 69 |
+
# schedule parser; Presentation of Persons is the legal basis for NEXUS
|
| 70 |
+
# and every other alternative-presentation program.
|
| 71 |
+
"E-14.1": {
|
| 72 |
+
"short": "Excise Act, 2001", "name": "Excise Act, 2001", "kind": "act",
|
| 73 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-14.1.xml",
|
| 74 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-14.1/"},
|
| 75 |
+
"CRC-604": {
|
| 76 |
+
"short": "Import Control List", "name": "Import Control List",
|
| 77 |
+
"kind": "regulation",
|
| 78 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.,_c._604.xml",
|
| 79 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.,_c._604/"},
|
| 80 |
+
"SOR-89-202": {
|
| 81 |
+
"short": "Export Control List", "name": "Export Control List",
|
| 82 |
+
"kind": "regulation",
|
| 83 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-89-202.xml",
|
| 84 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-89-202/"},
|
| 85 |
+
"SOR-2003-323": {
|
| 86 |
+
"short": "Presentation of Persons Regs",
|
| 87 |
+
"name": "Presentation of Persons (2003) Regulations", "kind": "regulation",
|
| 88 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-323.xml",
|
| 89 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-323/"},
|
| 90 |
+
"SOR-86-1072": {
|
| 91 |
+
"short": "Duty Free Shop Regs", "name": "Duty Free Shop Regulations",
|
| 92 |
+
"kind": "regulation",
|
| 93 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1072.xml",
|
| 94 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1072/"},
|
| 95 |
+
"SOR-86-1065": {
|
| 96 |
+
"short": "Sufferance Warehouses Regs",
|
| 97 |
+
"name": "Customs Sufferance Warehouses Regulations", "kind": "regulation",
|
| 98 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1065.xml",
|
| 99 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1065/"},
|
| 100 |
+
"S-8.35": {
|
| 101 |
+
"short": "Luxury Tax Act", "name": "Select Luxury Items Tax Act", "kind": "act",
|
| 102 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-8.35.xml",
|
| 103 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-8.35/"},
|
| 104 |
+
"C-1.4": {
|
| 105 |
+
"short": "CBSA Act", "name": "Canada Border Services Agency Act", "kind": "act",
|
| 106 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-1.4.xml",
|
| 107 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"},
|
| 108 |
+
"S-15": {
|
| 109 |
+
"short": "SIMA", "name": "Special Import Measures Act", "kind": "act",
|
| 110 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-15.xml",
|
| 111 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-15/"},
|
| 112 |
+
"E-19": {
|
| 113 |
+
"short": "EIPA", "name": "Export and Import Permits Act", "kind": "act",
|
| 114 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-19.xml",
|
| 115 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-19/"},
|
| 116 |
+
"P-19.32": {
|
| 117 |
+
"short": "Preclearance Act", "name": "Preclearance Act, 2016", "kind": "act",
|
| 118 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-19.32.xml",
|
| 119 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-19.32/"},
|
| 120 |
+
# Customs Act operational regulations -- the procedural detail behind the
|
| 121 |
+
# bare Act (reporting, accounting, exemptions, and the designated-provisions
|
| 122 |
+
# regulation that is the legal hook for every AMPS monetary penalty).
|
| 123 |
+
"SOR-86-873": {
|
| 124 |
+
"short": "Reporting of Imported Goods Regs",
|
| 125 |
+
"name": "Reporting of Imported Goods Regulations", "kind": "regulation",
|
| 126 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-873.xml",
|
| 127 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-873/"},
|
| 128 |
+
"SOR-86-1062": {
|
| 129 |
+
"short": "Accounting for Imported Goods Regs",
|
| 130 |
+
"name": "Accounting for Imported Goods and Payment of Duties Regulations",
|
| 131 |
+
"kind": "regulation",
|
| 132 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1062.xml",
|
| 133 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1062/"},
|
| 134 |
+
"SOR-2002-336": {
|
| 135 |
+
"short": "Designated Provisions (Customs) Regs",
|
| 136 |
+
"name": "Designated Provisions (Customs) Regulations", "kind": "regulation",
|
| 137 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-336.xml",
|
| 138 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-336/"},
|
| 139 |
+
"SOR-96-46": {
|
| 140 |
+
"short": "Customs Bonded Warehouses Regs",
|
| 141 |
+
"name": "Customs Bonded Warehouses Regulations", "kind": "regulation",
|
| 142 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-46.xml",
|
| 143 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-46/"},
|
| 144 |
+
"SOR-98-58": {
|
| 145 |
+
"short": "Returning Persons Exemption Regs",
|
| 146 |
+
"name": "Returning Persons Exemption Regulations", "kind": "regulation",
|
| 147 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-58.xml",
|
| 148 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-58/"},
|
| 149 |
+
|
| 150 |
+
# --- Criminal & financial crime ---
|
| 151 |
+
"C-46": {
|
| 152 |
+
"short": "Criminal Code", "name": "Criminal Code", "kind": "act",
|
| 153 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-46.xml",
|
| 154 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-46/"},
|
| 155 |
+
"F-11.6": {
|
| 156 |
+
"short": "Firearms Act", "name": "Firearms Act", "kind": "act",
|
| 157 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.6.xml",
|
| 158 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11.6/"},
|
| 159 |
+
# Made under Criminal Code s. 117.15: the model-by-model prohibited/
|
| 160 |
+
# restricted/non-restricted classifications (incl. the May 2020
|
| 161 |
+
# SOR/2020-96 prohibitions). Nearly all substance is in its Schedules --
|
| 162 |
+
# this entry is why the schedule parser exists.
|
| 163 |
+
"SOR-98-462": {
|
| 164 |
+
"short": "Firearms Classification Regs",
|
| 165 |
+
"name": ("Regulations Prescribing Certain Firearms and Other Weapons, "
|
| 166 |
+
"Components and Parts of Weapons, Accessories, Cartridge "
|
| 167 |
+
"Magazines, Ammunition and Projectiles as Prohibited, "
|
| 168 |
+
"Restricted or Non-Restricted"), "kind": "regulation",
|
| 169 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-462.xml",
|
| 170 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-462/"},
|
| 171 |
+
"P-24.501": {
|
| 172 |
+
"short": "PCMLTFA",
|
| 173 |
+
"name": "Proceeds of Crime (Money Laundering) and Terrorist Financing Act",
|
| 174 |
+
"kind": "act",
|
| 175 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-24.501.xml",
|
| 176 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-24.501/"},
|
| 177 |
+
"SOR-2002-412": {
|
| 178 |
+
"short": "Cross-border Currency Reporting Regs",
|
| 179 |
+
"name": "Cross-border Currency and Monetary Instruments Reporting Regulations",
|
| 180 |
+
"kind": "regulation",
|
| 181 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-412.xml",
|
| 182 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-412/"},
|
| 183 |
+
"S-6.9": {
|
| 184 |
+
"short": "SCIDA", "name": "Security of Canada Information Disclosure Act",
|
| 185 |
+
"kind": "act",
|
| 186 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.9.xml",
|
| 187 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.9/"},
|
| 188 |
+
|
| 189 |
+
# --- Controlled substances & cannabis ---
|
| 190 |
+
"C-38.8": {
|
| 191 |
+
"short": "CDSA", "name": "Controlled Drugs and Substances Act", "kind": "act",
|
| 192 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.8.xml",
|
| 193 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.8/"},
|
| 194 |
+
"SOR-97-234": {
|
| 195 |
+
"short": "CDSA Police Enforcement Regs",
|
| 196 |
+
"name": "Controlled Drugs and Substances Act (Police Enforcement) Regulations",
|
| 197 |
+
"kind": "regulation",
|
| 198 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-234.xml",
|
| 199 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-234/"},
|
| 200 |
+
"SOR-2000-217": {
|
| 201 |
+
"short": "Benzodiazepines Regs",
|
| 202 |
+
"name": "Benzodiazepines and Other Targeted Substances Regulations",
|
| 203 |
+
"kind": "regulation",
|
| 204 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2000-217.xml",
|
| 205 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2000-217/"},
|
| 206 |
+
"C.R.C.,_c._1041": {
|
| 207 |
+
"short": "Narcotic Control Regs", "name": "Narcotic Control Regulations",
|
| 208 |
+
"kind": "regulation",
|
| 209 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._1041.xml",
|
| 210 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._1041/"},
|
| 211 |
+
"SOR-2012-230": {
|
| 212 |
+
"short": "New Classes of Practitioners Regs",
|
| 213 |
+
"name": "New Classes of Practitioners Regulations", "kind": "regulation",
|
| 214 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-230.xml",
|
| 215 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-230/"},
|
| 216 |
+
"SOR-2002-359": {
|
| 217 |
+
"short": "Precursor Control Regs", "name": "Precursor Control Regulations",
|
| 218 |
+
"kind": "regulation",
|
| 219 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-359.xml",
|
| 220 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-359/"},
|
| 221 |
+
"SOR-97-229": {
|
| 222 |
+
"short": "Precursor & Substance Exemption Regs",
|
| 223 |
+
"name": "Regulations Exempting Certain Precursors and Controlled Substances "
|
| 224 |
+
"from the Application of the Controlled Drugs and Substances Act",
|
| 225 |
+
"kind": "regulation",
|
| 226 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-229.xml",
|
| 227 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-229/"},
|
| 228 |
+
"C-24.5": {
|
| 229 |
+
"short": "Cannabis Act", "name": "Cannabis Act", "kind": "act",
|
| 230 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-24.5.xml",
|
| 231 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-24.5/"},
|
| 232 |
+
"SOR-2018-144": {
|
| 233 |
+
"short": "Cannabis Regs", "name": "Cannabis Regulations", "kind": "regulation",
|
| 234 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-144.xml",
|
| 235 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-144/"},
|
| 236 |
+
|
| 237 |
+
# --- Food, health, agriculture ---
|
| 238 |
+
"F-27": {
|
| 239 |
+
"short": "Food and Drugs Act", "name": "Food and Drugs Act", "kind": "act",
|
| 240 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-27.xml",
|
| 241 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-27/"},
|
| 242 |
+
"C.R.C.,_c._870": {
|
| 243 |
+
"short": "Food and Drug Regs", "name": "Food and Drug Regulations",
|
| 244 |
+
"kind": "regulation",
|
| 245 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._870.xml",
|
| 246 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._870/"},
|
| 247 |
+
"H-3.3": {
|
| 248 |
+
"short": "Health of Animals Act", "name": "Health of Animals Act", "kind": "act",
|
| 249 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-3.3.xml",
|
| 250 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-3.3/"},
|
| 251 |
+
"C.R.C.,_c._296": {
|
| 252 |
+
"short": "Health of Animals Regs", "name": "Health of Animals Regulations",
|
| 253 |
+
"kind": "regulation",
|
| 254 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._296.xml",
|
| 255 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._296/"},
|
| 256 |
+
"P-14.8": {
|
| 257 |
+
"short": "Plant Protection Act", "name": "Plant Protection Act", "kind": "act",
|
| 258 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-14.8.xml",
|
| 259 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-14.8/"},
|
| 260 |
+
"SOR-95-212": {
|
| 261 |
+
"short": "Plant Protection Regs", "name": "Plant Protection Regulations",
|
| 262 |
+
"kind": "regulation",
|
| 263 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-95-212.xml",
|
| 264 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-95-212/"},
|
| 265 |
+
"SOR-2018-108": {
|
| 266 |
+
"short": "Safe Food for Canadians Regs",
|
| 267 |
+
"name": "Safe Food for Canadians Regulations", "kind": "regulation",
|
| 268 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-108.xml",
|
| 269 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-108/"},
|
| 270 |
+
"A-8.8": {
|
| 271 |
+
"short": "AAAMPA",
|
| 272 |
+
"name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 273 |
+
"kind": "act",
|
| 274 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-8.8.xml",
|
| 275 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/"},
|
| 276 |
+
"Q-1.1": {
|
| 277 |
+
"short": "Quarantine Act", "name": "Quarantine Act", "kind": "act",
|
| 278 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Q-1.1.xml",
|
| 279 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/q-1.1/"},
|
| 280 |
+
# CITES enforcement at the border -- the basis for seizures of protected
|
| 281 |
+
# wildlife, ivory and exotic-species goods.
|
| 282 |
+
"W-8.5": {
|
| 283 |
+
"short": "WAPPRIITA",
|
| 284 |
+
"name": "Wild Animal and Plant Protection and Regulation of "
|
| 285 |
+
"International and Interprovincial Trade Act", "kind": "act",
|
| 286 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/W-8.5.xml",
|
| 287 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/w-8.5/"},
|
| 288 |
+
"SOR-96-263": {
|
| 289 |
+
"short": "WAPTR",
|
| 290 |
+
"name": "Wild Animal and Plant Trade Regulations", "kind": "regulation",
|
| 291 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-263.xml",
|
| 292 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-263/"},
|
| 293 |
+
|
| 294 |
+
# --- Labour & public service ---
|
| 295 |
+
# CHRA grounds the accommodation/discrimination case law already in the
|
| 296 |
+
# corpus (Renaud, Hydro-Québec, Elk Valley); GECA governs injury-on-duty
|
| 297 |
+
# compensation interlocking with the FB agreement's IOD leave.
|
| 298 |
+
"H-6": {
|
| 299 |
+
"short": "CHRA", "name": "Canadian Human Rights Act", "kind": "act",
|
| 300 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-6.xml",
|
| 301 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-6/"},
|
| 302 |
+
"G-5": {
|
| 303 |
+
"short": "GECA", "name": "Government Employees Compensation Act",
|
| 304 |
+
"kind": "act",
|
| 305 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/G-5.xml",
|
| 306 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/g-5/"},
|
| 307 |
+
"L-2": {
|
| 308 |
+
"short": "Canada Labour Code", "name": "Canada Labour Code", "kind": "act",
|
| 309 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/L-2.xml",
|
| 310 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/l-2/"},
|
| 311 |
+
"P-33.3": {
|
| 312 |
+
"short": "FPSLRA", "name": "Federal Public Sector Labour Relations Act",
|
| 313 |
+
"kind": "act",
|
| 314 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.3.xml",
|
| 315 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.3/"},
|
| 316 |
+
"P-33.35": {
|
| 317 |
+
"short": "FPSLREB Act",
|
| 318 |
+
"name": "Federal Public Sector Labour Relations and Employment Board Act",
|
| 319 |
+
"kind": "act",
|
| 320 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.35.xml",
|
| 321 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.35/"},
|
| 322 |
+
"P-33.01": {
|
| 323 |
+
"short": "PSEA", "name": "Public Service Employment Act", "kind": "act",
|
| 324 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.01.xml",
|
| 325 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.01/"},
|
| 326 |
+
"P-36": {
|
| 327 |
+
"short": "PSSA", "name": "Public Service Superannuation Act", "kind": "act",
|
| 328 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-36.xml",
|
| 329 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-36/"},
|
| 330 |
+
|
| 331 |
+
# --- Privacy ---
|
| 332 |
+
"P-21": {
|
| 333 |
+
"short": "Privacy Act", "name": "Privacy Act", "kind": "act",
|
| 334 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-21.xml",
|
| 335 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-21/"},
|
| 336 |
+
}
|
canlex/delegation.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest instruments of delegation and designation under IRPA / IRPR.
|
| 2 |
+
|
| 3 |
+
These instruments record which officer positions the Minister has delegated
|
| 4 |
+
powers to, or designated for functions, under the Immigration and Refugee
|
| 5 |
+
Protection Act and its Regulations. They are administrative instruments -- not
|
| 6 |
+
enacted law, and not guidance -- so every chunk is tagged doc_type="delegation".
|
| 7 |
+
|
| 8 |
+
Sources:
|
| 9 |
+
- the CBSA "Delegation of Authority and Designations of Officers ..."
|
| 10 |
+
instrument (HTML, cbsa-asfc.gc.ca);
|
| 11 |
+
- the IRCC "IL3 -- Instrument of Designation and Delegation" (PDF, canada.ca).
|
| 12 |
+
|
| 13 |
+
py -m canlex.delegation
|
| 14 |
+
"""
|
| 15 |
+
import io
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
|
| 19 |
+
from bs4 import BeautifulSoup
|
| 20 |
+
from pypdf import PdfReader
|
| 21 |
+
|
| 22 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 23 |
+
stored_chunks, uniquify_ids, write_corpus)
|
| 24 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 25 |
+
|
| 26 |
+
RAW = RAW_DIR / "delegation"
|
| 27 |
+
OUT = PROCESSED_DIR / "delegation.json"
|
| 28 |
+
|
| 29 |
+
# The CBSA IRPA delegation has been a moving target: the November 28, 2017
|
| 30 |
+
# instrument was expressly superseded by a full restatement on May 8, 2023
|
| 31 |
+
# (signed by Mendicino), which has itself been amended five times since. CBSA
|
| 32 |
+
# does not publish a consolidated version, so the current effective state is the
|
| 33 |
+
# 2023 restatement read together with its later amendments; we ingest each one
|
| 34 |
+
# as a separate "act" so a user (or the LLM) sees the base item and any
|
| 35 |
+
# amendments that touch it side-by-side in retrieval.
|
| 36 |
+
_CBSA_DELEG_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
|
| 37 |
+
"delegation/")
|
| 38 |
+
_CBSA_DELEG_NAME = ("Delegation of Authority and Designations of Officers by "
|
| 39 |
+
"the Minister of Public Safety and Emergency Preparedness "
|
| 40 |
+
"under the Immigration and Refugee Protection Act and the "
|
| 41 |
+
"Immigration and Refugee Protection Regulations")
|
| 42 |
+
|
| 43 |
+
SOURCES = {
|
| 44 |
+
# The 2023 restatement -- the current base instrument.
|
| 45 |
+
"cbsa-2023-05": {
|
| 46 |
+
"code": "cbsa-2023-05",
|
| 47 |
+
"kind": "html-cbsa",
|
| 48 |
+
"act_code": "CBSA-IRPA-DELEG-2023-05",
|
| 49 |
+
"act_short": "CBSA Deleg 2023-05-08",
|
| 50 |
+
"act_name": _CBSA_DELEG_NAME,
|
| 51 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2023-05-08-eng.html",
|
| 52 |
+
"effective": "2023-05-08",
|
| 53 |
+
},
|
| 54 |
+
# Amendments to the 2023 restatement, in chronological order.
|
| 55 |
+
"cbsa-2023-09": {
|
| 56 |
+
"code": "cbsa-2023-09",
|
| 57 |
+
"kind": "html-cbsa",
|
| 58 |
+
"act_code": "CBSA-IRPA-DELEG-AMEND-2023-09-08",
|
| 59 |
+
"act_short": "CBSA Deleg Amend 2023-09-08",
|
| 60 |
+
"act_name": "Amendment to the " + _CBSA_DELEG_NAME,
|
| 61 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2023-09-08-eng.html",
|
| 62 |
+
"effective": "2023-09-08",
|
| 63 |
+
},
|
| 64 |
+
"cbsa-2023-11": {
|
| 65 |
+
"code": "cbsa-2023-11",
|
| 66 |
+
"kind": "html-cbsa",
|
| 67 |
+
"act_code": "CBSA-IRPA-DELEG-AMEND-2023-11-17",
|
| 68 |
+
"act_short": "CBSA Deleg Amend 2023-11-17",
|
| 69 |
+
"act_name": "Amendment to the " + _CBSA_DELEG_NAME,
|
| 70 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2023-11-17-eng.html",
|
| 71 |
+
"effective": "2023-11-17",
|
| 72 |
+
},
|
| 73 |
+
"cbsa-2024-03-05": {
|
| 74 |
+
"code": "cbsa-2024-03-05",
|
| 75 |
+
"kind": "html-cbsa",
|
| 76 |
+
"act_code": "CBSA-IRPA-DELEG-AMEND-2024-03-05",
|
| 77 |
+
"act_short": "CBSA Deleg Amend 2024-03-05",
|
| 78 |
+
"act_name": "Amendment to the " + _CBSA_DELEG_NAME,
|
| 79 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2024-03-05-eng.html",
|
| 80 |
+
"effective": "2024-03-05",
|
| 81 |
+
},
|
| 82 |
+
"cbsa-2024-03-15": {
|
| 83 |
+
"code": "cbsa-2024-03-15",
|
| 84 |
+
"kind": "html-cbsa",
|
| 85 |
+
"act_code": "CBSA-IRPA-DELEG-AMEND-2024-03-15",
|
| 86 |
+
"act_short": "CBSA Deleg Amend 2024-03-15",
|
| 87 |
+
"act_name": "Amendment to the " + _CBSA_DELEG_NAME,
|
| 88 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2024-03-15-eng.html",
|
| 89 |
+
"effective": "2024-03-15",
|
| 90 |
+
},
|
| 91 |
+
"cbsa-2025-07": {
|
| 92 |
+
"code": "cbsa-2025-07",
|
| 93 |
+
"kind": "html-cbsa",
|
| 94 |
+
"act_code": "CBSA-IRPA-DELEG-AMEND-2025-07-10",
|
| 95 |
+
"act_short": "CBSA Deleg Amend 2025-07-10",
|
| 96 |
+
"act_name": "Amendment to the " + _CBSA_DELEG_NAME,
|
| 97 |
+
"url": _CBSA_DELEG_URL + "irpa-lipr-2025-07-10-eng.html",
|
| 98 |
+
"effective": "2025-07-10",
|
| 99 |
+
},
|
| 100 |
+
# Separate authority: a peace-officer designation under IRPA s. 138(1).
|
| 101 |
+
# Narrative prose, not a Schedule table -- needs its own parser.
|
| 102 |
+
"cbsa-peaceofficer": {
|
| 103 |
+
"code": "cbsa-peaceofficer",
|
| 104 |
+
"kind": "html-cbsa-narrative",
|
| 105 |
+
"act_code": "CBSA-IRPA-PEACEOFF-2022-08",
|
| 106 |
+
"act_short": "CBSA Peace Officer Auth 2022-08-18",
|
| 107 |
+
"act_name": ("Authorization to have the Authority and Powers of a "
|
| 108 |
+
"Peace Officer under the Immigration and Refugee "
|
| 109 |
+
"Protection Act (subsection 138(1))"),
|
| 110 |
+
"url": _CBSA_DELEG_URL + "desig/po-ag_2022-08-eng.html",
|
| 111 |
+
"effective": "2022-08-18",
|
| 112 |
+
},
|
| 113 |
+
"ircc": {
|
| 114 |
+
"code": "ircc",
|
| 115 |
+
"kind": "pdf-ircc",
|
| 116 |
+
"act_code": "IRCC-IL3-DELEG",
|
| 117 |
+
"act_short": "IRCC IL3",
|
| 118 |
+
"act_name": ("IL3 — Instrument of Designation and Delegation, "
|
| 119 |
+
"Immigration and Refugee Protection Act and Regulations"),
|
| 120 |
+
"url": ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 121 |
+
"resources/manuals/il/il3-eng.pdf"),
|
| 122 |
+
},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _normalize_refs(text):
|
| 127 |
+
"""Expand the instruments' provision shorthand so the section numbers are
|
| 128 |
+
searchable as tokens: 'A55(1)' -> 'IRPA 55(1)', 'R39' -> 'IRPR 39'. Both
|
| 129 |
+
instruments write 'A' for the Act and 'R' for the Regulations."""
|
| 130 |
+
text = re.sub(r"\bA(?=\d)", "IRPA ", text)
|
| 131 |
+
text = re.sub(r"\bR(?=\d)", "IRPR ", text)
|
| 132 |
+
return _norm(text)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _fetch(url, dest, powershell=False):
|
| 136 |
+
"""Fetch a page or file, caching the raw bytes under data/raw/delegation.
|
| 137 |
+
canada.ca blocks Python's HTTP client, so its PDF is fetched via PowerShell.
|
| 138 |
+
(refresh.py also calls this for its IL3 version check.)"""
|
| 139 |
+
return fetch_cached(url, dest, powershell=powershell)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# --- CBSA instrument (HTML) ---------------------------------------------------
|
| 143 |
+
|
| 144 |
+
def _delegates(cell):
|
| 145 |
+
"""Flatten a 'Delegates / Designated officials' table cell into readable
|
| 146 |
+
lines. The cell groups officer positions under an organisation header
|
| 147 |
+
(<p class="h4">: CBSA, RCMP, IRCC) and an optional sub-heading
|
| 148 |
+
(<p class="h5">: a region or a headquarters branch), each followed by a
|
| 149 |
+
<ul> of position titles."""
|
| 150 |
+
lines, org, sub, org_used = [], "", "", False
|
| 151 |
+
for el in cell.find_all(["p", "ul"], recursive=False):
|
| 152 |
+
if el.name == "p":
|
| 153 |
+
classes = " ".join(el.get("class") or [])
|
| 154 |
+
text = _norm(el.get_text())
|
| 155 |
+
if not text:
|
| 156 |
+
continue
|
| 157 |
+
if "h4" in classes:
|
| 158 |
+
if org and not org_used: # a previous org with no list of its own
|
| 159 |
+
lines.append(org)
|
| 160 |
+
org, sub, org_used = text.rstrip(": "), "", False
|
| 161 |
+
elif "h5" in classes:
|
| 162 |
+
sub = text
|
| 163 |
+
else:
|
| 164 |
+
lines.append(text) # a free-standing note
|
| 165 |
+
else: # <ul> of position titles
|
| 166 |
+
positions = "; ".join(
|
| 167 |
+
t for t in (_norm(li.get_text())
|
| 168 |
+
for li in el.find_all("li", recursive=False)) if t)
|
| 169 |
+
if positions:
|
| 170 |
+
label = f"{org} — {sub}" if sub else org
|
| 171 |
+
lines.append(f"{label}: {positions}" if label else positions)
|
| 172 |
+
org_used = True
|
| 173 |
+
if org and not org_used: # a trailing org with no list
|
| 174 |
+
lines.append(org)
|
| 175 |
+
return "\n".join(lines)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def parse_cbsa(html, src):
|
| 179 |
+
"""Parse the CBSA delegation instrument into one chunk per Schedule item,
|
| 180 |
+
plus one chunk for the preamble."""
|
| 181 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 182 |
+
main = soup.find("main")
|
| 183 |
+
if main is None:
|
| 184 |
+
return []
|
| 185 |
+
for sup in main.find_all("sup"): # drop footnote-reference superscripts
|
| 186 |
+
sup.decompose()
|
| 187 |
+
|
| 188 |
+
# The effective date comes from SOURCES, not the first <time> in <main>:
|
| 189 |
+
# amendment pages quote the base instrument's date ("signed on May 8, 2023")
|
| 190 |
+
# in their preamble, so the first <time> on an amendment page is the base
|
| 191 |
+
# instrument's date, not the amendment's own.
|
| 192 |
+
date = src["effective"]
|
| 193 |
+
|
| 194 |
+
chunks = []
|
| 195 |
+
|
| 196 |
+
# Preamble: the paragraphs between the title and the Schedule, which set out
|
| 197 |
+
# the tiers of delegation and designation and how the columns are read.
|
| 198 |
+
schedule = main.find("h2", id="sch")
|
| 199 |
+
if schedule:
|
| 200 |
+
paras = [_norm(p.get_text())
|
| 201 |
+
for p in reversed(schedule.find_previous_siblings("p"))]
|
| 202 |
+
body = "\n".join(p for p in paras if p)
|
| 203 |
+
if body:
|
| 204 |
+
chunks.append({
|
| 205 |
+
"id": f"delegation-{src['code']}-preamble",
|
| 206 |
+
"doc_type": "delegation",
|
| 207 |
+
"act_code": src["act_code"],
|
| 208 |
+
"act_short": src["act_short"],
|
| 209 |
+
"act_name": src["act_name"],
|
| 210 |
+
"section": "",
|
| 211 |
+
"marginal_note": "Tiers of delegation and designation",
|
| 212 |
+
"part": "",
|
| 213 |
+
"division": "",
|
| 214 |
+
"heading": "Instrument of delegation and designation under IRPA",
|
| 215 |
+
"text": body,
|
| 216 |
+
"history": "",
|
| 217 |
+
"last_amended": "",
|
| 218 |
+
"current_to": date,
|
| 219 |
+
"citation": f"{src['act_short']} — Preamble",
|
| 220 |
+
"source_url": src["url"],
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
# One chunk per Schedule item. Two row shapes are accepted:
|
| 224 |
+
# (a) four <td> cells: Item | Refs | Power | Delegates (the 2023-05
|
| 225 |
+
# restatement and the 2023-09, 2023-11, 2024-03 amendments).
|
| 226 |
+
# (b) one <th> + two or three <td> cells: the <th> carries the item
|
| 227 |
+
# number and the <td>s carry Refs | Power [| Delegates]. The
|
| 228 |
+
# 2025-07-10 amendment uses this layout, and may omit the Delegates
|
| 229 |
+
# column when an amendment changes only references or descriptions.
|
| 230 |
+
# Each topical <h3>, if present, names the schedule section the table belongs to.
|
| 231 |
+
for table in main.find_all("table", class_="table-bordered"):
|
| 232 |
+
h3 = table.find_previous_sibling("h3")
|
| 233 |
+
section_name = _norm(h3.get_text()) if h3 else ""
|
| 234 |
+
for tr in table.find_all("tr"):
|
| 235 |
+
th_cells = tr.find_all("th", recursive=False)
|
| 236 |
+
td_cells = tr.find_all("td", recursive=False)
|
| 237 |
+
if not th_cells and len(td_cells) == 4:
|
| 238 |
+
item_cell, refs_cell, power_cell, deleg_cell = td_cells
|
| 239 |
+
elif len(th_cells) == 1 and len(td_cells) in (2, 3):
|
| 240 |
+
item_cell, refs_cell, power_cell = th_cells[0], td_cells[0], td_cells[1]
|
| 241 |
+
deleg_cell = td_cells[2] if len(td_cells) == 3 else None
|
| 242 |
+
else:
|
| 243 |
+
continue # header row or a stray row
|
| 244 |
+
item_no = _norm(item_cell.get_text()).rstrip(".")
|
| 245 |
+
refs = _normalize_refs(_norm(refs_cell.get_text()))
|
| 246 |
+
power = " ".join(_norm(p.get_text())
|
| 247 |
+
for p in power_cell.find_all("p")) \
|
| 248 |
+
or _norm(power_cell.get_text())
|
| 249 |
+
delegates = _delegates(deleg_cell) if deleg_cell is not None else ""
|
| 250 |
+
if not item_no or not (power or refs):
|
| 251 |
+
continue
|
| 252 |
+
text = power
|
| 253 |
+
if refs:
|
| 254 |
+
text += f"\n\nProvisions (IRPA / IRPR): {refs}."
|
| 255 |
+
if delegates:
|
| 256 |
+
text += "\n\nDelegated / designated to:\n" + delegates
|
| 257 |
+
chunks.append({
|
| 258 |
+
"id": f"delegation-{src['code']}-{item_no}",
|
| 259 |
+
"doc_type": "delegation",
|
| 260 |
+
"act_code": src["act_code"],
|
| 261 |
+
"act_short": src["act_short"],
|
| 262 |
+
"act_name": src["act_name"],
|
| 263 |
+
"section": "",
|
| 264 |
+
"marginal_note": refs or f"Item {item_no}",
|
| 265 |
+
"part": section_name,
|
| 266 |
+
"division": "",
|
| 267 |
+
"heading": section_name,
|
| 268 |
+
"text": text,
|
| 269 |
+
"history": "",
|
| 270 |
+
"last_amended": "",
|
| 271 |
+
"current_to": date,
|
| 272 |
+
"citation": f"{src['act_short']}, Item {item_no}",
|
| 273 |
+
"source_url": src["url"],
|
| 274 |
+
})
|
| 275 |
+
return chunks
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# --- CBSA narrative-prose instrument (e.g. the peace-officer designation) -----
|
| 279 |
+
|
| 280 |
+
def parse_cbsa_narrative(html, src):
|
| 281 |
+
"""Parse a narrative-prose CBSA designation instrument into a single chunk.
|
| 282 |
+
|
| 283 |
+
Used for the peace-officer authorization under IRPA s. 138(1) -- plain prose
|
| 284 |
+
listing 18 designated officer positions, not a four-column Schedule table,
|
| 285 |
+
so parse_cbsa's table walker would yield nothing. The whole operative text
|
| 286 |
+
is a few hundred words, well within a single chunk."""
|
| 287 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 288 |
+
main = soup.find("main")
|
| 289 |
+
if main is None:
|
| 290 |
+
return []
|
| 291 |
+
for sup in main.find_all("sup"):
|
| 292 |
+
sup.decompose()
|
| 293 |
+
|
| 294 |
+
date = src["effective"]
|
| 295 |
+
|
| 296 |
+
# Skip the breadcrumb/title/footer chrome -- only paragraphs and lists in
|
| 297 |
+
# <main> that carry real content. Lists are rendered as "; "-joined items.
|
| 298 |
+
parts = []
|
| 299 |
+
for el in main.find_all(["p", "ul", "ol"]):
|
| 300 |
+
if el.find_parent(["ul", "ol"]):
|
| 301 |
+
continue # nested lists are picked up by their parent
|
| 302 |
+
if el.name in ("ul", "ol"):
|
| 303 |
+
items = [_norm(li.get_text()) for li in el.find_all("li")]
|
| 304 |
+
joined = "; ".join(t for t in items if t)
|
| 305 |
+
if joined:
|
| 306 |
+
parts.append(joined)
|
| 307 |
+
else:
|
| 308 |
+
text = _norm(el.get_text())
|
| 309 |
+
if text:
|
| 310 |
+
parts.append(text)
|
| 311 |
+
body = "\n".join(parts)
|
| 312 |
+
if not body:
|
| 313 |
+
return []
|
| 314 |
+
|
| 315 |
+
return [{
|
| 316 |
+
"id": f"delegation-{src['code']}",
|
| 317 |
+
"doc_type": "delegation",
|
| 318 |
+
"act_code": src["act_code"],
|
| 319 |
+
"act_short": src["act_short"],
|
| 320 |
+
"act_name": src["act_name"],
|
| 321 |
+
"section": "",
|
| 322 |
+
"marginal_note": "Peace-officer authorization — IRPA s. 138(1)",
|
| 323 |
+
"part": "",
|
| 324 |
+
"division": "",
|
| 325 |
+
"heading": src["act_name"],
|
| 326 |
+
"text": body,
|
| 327 |
+
"history": "",
|
| 328 |
+
"last_amended": "",
|
| 329 |
+
"current_to": date,
|
| 330 |
+
"citation": src["act_short"],
|
| 331 |
+
"source_url": src["url"],
|
| 332 |
+
}]
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# --- IRCC IL3 instrument (PDF) ------------------------------------------------
|
| 336 |
+
|
| 337 |
+
# A topical part heading -- a line in full upper case (MINISTERIAL INSTRUCTIONS,
|
| 338 |
+
# MEDICAL, MISCELLANEOUS). Organisation acronyms (CI, CBSA, RCMP) are shorter
|
| 339 |
+
# than the 6-character floor and so are not mistaken for headings.
|
| 340 |
+
_IL3_PART = re.compile(r"[A-Z][A-Z &/,()'.\-]{5,}")
|
| 341 |
+
# An item opens "<n>. <A/R provision>" -- the number must be followed by a
|
| 342 |
+
# provision reference, which rejects ordinary numbered prose.
|
| 343 |
+
_IL3_ITEM = re.compile(r"(\d+)\.\s+(?=[AR]\d)")
|
| 344 |
+
# The word that introduces an item's power description, after the provisions.
|
| 345 |
+
_IL3_POWER = re.compile(r"(?:Delegation|Designation)\s*[-–—]\s*")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _pdf_pages(pdf_bytes):
|
| 349 |
+
"""Extract each page's text, dropping the printed page number that pypdf
|
| 350 |
+
emits as the page's first line (roman in the front matter, arabic later)."""
|
| 351 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 352 |
+
pages = []
|
| 353 |
+
for page in reader.pages:
|
| 354 |
+
lines = (page.extract_text() or "").split("\n")
|
| 355 |
+
if lines and re.fullmatch(r"\s*[ivxlcdm\d]+\s*", lines[0] or "", re.I):
|
| 356 |
+
lines = lines[1:]
|
| 357 |
+
pages.append("\n".join(lines))
|
| 358 |
+
return pages
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def parse_ircc(pdf_bytes, src):
|
| 362 |
+
"""Parse the IRCC IL3 instrument into one chunk per Schedule item, plus a
|
| 363 |
+
preamble chunk. The PDF flattens the four-column table into a linear text
|
| 364 |
+
stream, so each item runs from its numbered marker to the next; the power
|
| 365 |
+
description and the delegated positions are kept together within the item."""
|
| 366 |
+
pages = _pdf_pages(pdf_bytes)
|
| 367 |
+
if not pages:
|
| 368 |
+
return []
|
| 369 |
+
version_match = re.search(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}", pages[0])
|
| 370 |
+
version = version_match.group(0) if version_match else ""
|
| 371 |
+
|
| 372 |
+
chunks = []
|
| 373 |
+
preamble = _norm(pages[1]) if len(pages) > 1 else ""
|
| 374 |
+
if preamble:
|
| 375 |
+
chunks.append({
|
| 376 |
+
"id": f"delegation-{src['code']}-preamble",
|
| 377 |
+
"doc_type": "delegation",
|
| 378 |
+
"act_code": src["act_code"],
|
| 379 |
+
"act_short": src["act_short"],
|
| 380 |
+
"act_name": src["act_name"],
|
| 381 |
+
"section": "",
|
| 382 |
+
"marginal_note": "Preamble — designation and delegation under IRPA s. 6",
|
| 383 |
+
"part": "",
|
| 384 |
+
"division": "",
|
| 385 |
+
"heading": "Instrument of designation and delegation under IRPA",
|
| 386 |
+
"text": preamble,
|
| 387 |
+
"history": "",
|
| 388 |
+
"last_amended": "",
|
| 389 |
+
"current_to": version,
|
| 390 |
+
"citation": f"{src['act_short']} — Preamble",
|
| 391 |
+
"source_url": src["url"],
|
| 392 |
+
})
|
| 393 |
+
|
| 394 |
+
# Walk every line: an all-caps line is a topical part heading; a line that
|
| 395 |
+
# opens "<n>. <A/R provision>" starts a new item. Lines before the first
|
| 396 |
+
# item (the preamble, definitions and table of contents) are skipped.
|
| 397 |
+
items, part = [], ""
|
| 398 |
+
cur_no = cur_part = None
|
| 399 |
+
cur_lines = []
|
| 400 |
+
for line in "\n".join(pages).split("\n"):
|
| 401 |
+
s = _norm(line)
|
| 402 |
+
if not s:
|
| 403 |
+
continue
|
| 404 |
+
if _IL3_PART.fullmatch(s):
|
| 405 |
+
part = s.title()
|
| 406 |
+
continue
|
| 407 |
+
m = _IL3_ITEM.match(s)
|
| 408 |
+
if m:
|
| 409 |
+
if cur_no is not None:
|
| 410 |
+
items.append((cur_part, cur_no, cur_lines))
|
| 411 |
+
cur_no, cur_part, cur_lines = m.group(1), part, [s]
|
| 412 |
+
elif cur_no is not None:
|
| 413 |
+
cur_lines.append(s)
|
| 414 |
+
if cur_no is not None:
|
| 415 |
+
items.append((cur_part, cur_no, cur_lines))
|
| 416 |
+
|
| 417 |
+
for n, (item_part, item_no, lines) in enumerate(items, start=1):
|
| 418 |
+
body = re.sub(r"^\d+\.\s*", "", "\n".join(lines)).strip()
|
| 419 |
+
if not body:
|
| 420 |
+
continue
|
| 421 |
+
power = _IL3_POWER.search(body)
|
| 422 |
+
refs = _normalize_refs(body[:power.start()]) if power else ""
|
| 423 |
+
chunks.append({
|
| 424 |
+
"id": f"delegation-{src['code']}-{n}",
|
| 425 |
+
"doc_type": "delegation",
|
| 426 |
+
"act_code": src["act_code"],
|
| 427 |
+
"act_short": src["act_short"],
|
| 428 |
+
"act_name": src["act_name"],
|
| 429 |
+
"section": "",
|
| 430 |
+
"marginal_note": refs or f"Item {item_no}",
|
| 431 |
+
"part": item_part or "",
|
| 432 |
+
"division": "",
|
| 433 |
+
"heading": item_part or "",
|
| 434 |
+
"text": _normalize_refs(body),
|
| 435 |
+
"history": "",
|
| 436 |
+
"last_amended": "",
|
| 437 |
+
"current_to": version,
|
| 438 |
+
"citation": (f"{src['act_short']} — {item_part}, Item {item_no}"
|
| 439 |
+
if item_part else f"{src['act_short']}, Item {item_no}"),
|
| 440 |
+
"source_url": src["url"],
|
| 441 |
+
})
|
| 442 |
+
return chunks
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def preserve_failed(chunks, failures, stored):
|
| 446 |
+
"""Re-attach the last-good chunks of instruments this run could not build.
|
| 447 |
+
|
| 448 |
+
Ingestion is a full rebuild, so an instrument that fails its fetch -- or
|
| 449 |
+
that parses to nothing because CBSA changed the page's shape again -- would
|
| 450 |
+
silently vanish from the corpus. An instrument withdrawn on purpose is
|
| 451 |
+
removed from SOURCES instead, and so never lands in `failures`, which is
|
| 452 |
+
why preservation is restricted to the codes that actually failed here.
|
| 453 |
+
act_code is the per-instrument identity every parser stamps on its chunks.
|
| 454 |
+
Returns (chunks, preserved act_codes).
|
| 455 |
+
"""
|
| 456 |
+
failed = {act_code for act_code, _why in failures}
|
| 457 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 458 |
+
only=failed)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def build(allow_shrink=False):
|
| 462 |
+
"""Fetch, parse and chunk every delegation instrument into delegation.json."""
|
| 463 |
+
all_chunks, failures = [], []
|
| 464 |
+
for src in SOURCES.values():
|
| 465 |
+
print(f"Ingesting {src['act_short']} ...")
|
| 466 |
+
try:
|
| 467 |
+
if src["kind"] == "html-cbsa":
|
| 468 |
+
html = _fetch(src["url"], RAW / f"{src['code']}.html")
|
| 469 |
+
chunks = parse_cbsa(html, src)
|
| 470 |
+
elif src["kind"] == "html-cbsa-narrative":
|
| 471 |
+
html = _fetch(src["url"], RAW / f"{src['code']}.html")
|
| 472 |
+
chunks = parse_cbsa_narrative(html, src)
|
| 473 |
+
elif src["kind"] == "pdf-ircc":
|
| 474 |
+
pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
|
| 475 |
+
powershell=True)
|
| 476 |
+
chunks = parse_ircc(pdf, src)
|
| 477 |
+
else:
|
| 478 |
+
chunks = []
|
| 479 |
+
except Exception as exc:
|
| 480 |
+
print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
|
| 481 |
+
failures.append((src["act_code"], f"{type(exc).__name__}: {exc}"))
|
| 482 |
+
continue
|
| 483 |
+
if not chunks:
|
| 484 |
+
failures.append((src["act_code"], "no content parsed"))
|
| 485 |
+
all_chunks.extend(chunks)
|
| 486 |
+
print(f" {len(chunks)} chunks")
|
| 487 |
+
uniquify_ids(all_chunks)
|
| 488 |
+
|
| 489 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 490 |
+
stored_chunks(OUT))
|
| 491 |
+
if preserved:
|
| 492 |
+
print(f" kept the last-good copy of {len(preserved)} instrument(s): "
|
| 493 |
+
f"{', '.join(preserved)}")
|
| 494 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 495 |
+
return False
|
| 496 |
+
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
| 497 |
+
f"instrument(s) -> {OUT}")
|
| 498 |
+
return True
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def main():
|
| 502 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
if __name__ == "__main__":
|
| 506 |
+
main()
|
canlex/directive.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest National Joint Council directives (HTML) into section-level chunks.
|
| 2 |
+
|
| 3 |
+
NJC directives are negotiated by employer and bargaining-agent representatives;
|
| 4 |
+
their provisions form part of collective agreements (and the rate tables in
|
| 5 |
+
their appendices apply too). Chunks are tagged doc_type="directive".
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
from bs4 import BeautifulSoup
|
| 11 |
+
|
| 12 |
+
from ._common import (MONTHS, fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 13 |
+
split_lines, stored_chunks, write_corpus)
|
| 14 |
+
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
+
|
| 16 |
+
INDEX_URL = "https://www.njc-cnm.gc.ca/directive/en"
|
| 17 |
+
BASE = "https://www.njc-cnm.gc.ca"
|
| 18 |
+
DIRECTIVE_DIR = RAW_DIR / "directives"
|
| 19 |
+
OUT_FILE = PROCESSED_DIR / "directives.json"
|
| 20 |
+
_CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
|
| 21 |
+
_NUMBERED = re.compile(r"^(\d+(?:\.\d+)*)\s+(.+)")
|
| 22 |
+
_CODE = re.compile(r"/directive/([^/]+)/")
|
| 23 |
+
|
| 24 |
+
_PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
|
| 25 |
+
_EFFECTIVE = re.compile(
|
| 26 |
+
r"effective[^.<:]{0,20}?([A-Z][a-z]+)\s+(\d{1,2}),?\s+(\d{4})", re.I)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _split(text, target=_PIECE_TARGET):
|
| 30 |
+
"""Split long text into ~target-sized pieces at line boundaries."""
|
| 31 |
+
return split_lines(text, target)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _page_effective_date(main):
|
| 35 |
+
"""Latest 'effective <Month D, YYYY>' date on a directive page, as ISO.
|
| 36 |
+
Used as a currency fallback when the index gives no date (e.g. the Foreign
|
| 37 |
+
Service Directives, whose chunks otherwise render currency as 'n/a')."""
|
| 38 |
+
best = ""
|
| 39 |
+
for mon, day, year in _EFFECTIVE.findall(main.get_text(" ", strip=True)):
|
| 40 |
+
if mon.lower() in MONTHS:
|
| 41 |
+
iso = f"{int(year):04d}-{MONTHS[mon.lower()]:02d}-{int(day):02d}"
|
| 42 |
+
best = max(best, iso)
|
| 43 |
+
return best
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _fetch(url, dest, force=False):
|
| 47 |
+
"""Fetch via PowerShell's (.NET) HTTP stack -- the NJC site blocks
|
| 48 |
+
Python's HTTP client at the TLS layer."""
|
| 49 |
+
return fetch_cached(url, dest, force=force, powershell=True)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def directive_links(force=False):
|
| 53 |
+
"""Return [(url, title, date), ...] for the current NJC directives."""
|
| 54 |
+
html = _fetch(INDEX_URL, DIRECTIVE_DIR / "_index.html", force=force)
|
| 55 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 56 |
+
ul = soup.find("ul", class_="directive-list")
|
| 57 |
+
if ul is None:
|
| 58 |
+
return []
|
| 59 |
+
out = []
|
| 60 |
+
for li in ul.find_all("li", recursive=False):
|
| 61 |
+
a = li.find("a", href=True) # the first <a> is the current directive
|
| 62 |
+
if not a:
|
| 63 |
+
continue
|
| 64 |
+
url = a["href"] if a["href"].startswith("http") else BASE + a["href"]
|
| 65 |
+
span = li.find("span", class_="date")
|
| 66 |
+
out.append((url, _norm(a.get_text(" ", strip=True)),
|
| 67 |
+
_norm(span.get_text()) if span else ""))
|
| 68 |
+
return out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _block_text(heading):
|
| 72 |
+
"""Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
|
| 73 |
+
lines = []
|
| 74 |
+
for sib in heading.find_next_siblings():
|
| 75 |
+
if sib.name in ("h2", "h3"):
|
| 76 |
+
break
|
| 77 |
+
if sib.name in ("ul", "ol"):
|
| 78 |
+
for li in sib.find_all("li", recursive=False):
|
| 79 |
+
item = _norm(li.get_text(" ", strip=True))
|
| 80 |
+
if item:
|
| 81 |
+
lines.append(f"- {item}")
|
| 82 |
+
elif sib.name in _CONTENT_TAGS:
|
| 83 |
+
text = _norm(sib.get_text(" ", strip=True))
|
| 84 |
+
if text:
|
| 85 |
+
lines.append(text)
|
| 86 |
+
return "\n".join(lines)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def parse_directive(html, url, title, date):
|
| 90 |
+
"""Parse one NJC directive page into one chunk per h2/h3 section."""
|
| 91 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 92 |
+
main = soup.find("main")
|
| 93 |
+
if main is None:
|
| 94 |
+
return []
|
| 95 |
+
for tag in main.find_all(["section", "div"]):
|
| 96 |
+
tag.unwrap() # flatten so each heading and its content become siblings
|
| 97 |
+
|
| 98 |
+
code_match = _CODE.search(url)
|
| 99 |
+
code = code_match.group(1) if code_match else url
|
| 100 |
+
if not date: # recover a currency date from the page
|
| 101 |
+
date = _page_effective_date(main)
|
| 102 |
+
chunks = []
|
| 103 |
+
current_part = ""
|
| 104 |
+
sec_idx = 0
|
| 105 |
+
for h in main.find_all(["h2", "h3"]):
|
| 106 |
+
text = _norm(h.get_text(" ", strip=True))
|
| 107 |
+
if not text:
|
| 108 |
+
continue
|
| 109 |
+
if h.name == "h2":
|
| 110 |
+
current_part = text
|
| 111 |
+
body = _block_text(h)
|
| 112 |
+
if not body:
|
| 113 |
+
continue
|
| 114 |
+
numbered = _NUMBERED.match(text)
|
| 115 |
+
if numbered:
|
| 116 |
+
number, note = numbered.group(1), numbered.group(2).strip()
|
| 117 |
+
citation = f"{title}, s. {number}"
|
| 118 |
+
else:
|
| 119 |
+
# Un-numbered heading: keep the text in the note only, so the rendered
|
| 120 |
+
# header (citation + note) does not repeat it.
|
| 121 |
+
number, note = "", text
|
| 122 |
+
citation = title
|
| 123 |
+
sec_idx += 1
|
| 124 |
+
# Split a long section so each piece fits the embed window; pieces of one
|
| 125 |
+
# section share the base id 'directive-{code}-{sec_idx}' and take a
|
| 126 |
+
# '-p<k>' suffix, so the diversity cap treats them as one source.
|
| 127 |
+
pieces = _split(body)
|
| 128 |
+
for k, piece in enumerate(pieces, start=1):
|
| 129 |
+
base = f"directive-{code}-{sec_idx}"
|
| 130 |
+
chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
|
| 131 |
+
pnote = note if len(pieces) == 1 else f"{note} (part {k})"
|
| 132 |
+
chunks.append({
|
| 133 |
+
"id": chunk_id,
|
| 134 |
+
"doc_type": "directive",
|
| 135 |
+
"act_code": code,
|
| 136 |
+
"act_short": title,
|
| 137 |
+
"act_name": f"NJC {title}",
|
| 138 |
+
"section": number or text,
|
| 139 |
+
"marginal_note": pnote,
|
| 140 |
+
"part": current_part if h.name == "h3" else "",
|
| 141 |
+
"division": "",
|
| 142 |
+
"heading": "",
|
| 143 |
+
"text": piece,
|
| 144 |
+
"history": "",
|
| 145 |
+
"last_amended": "",
|
| 146 |
+
"current_to": date,
|
| 147 |
+
"citation": citation,
|
| 148 |
+
"source_url": url,
|
| 149 |
+
})
|
| 150 |
+
return chunks
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _print_link(html):
|
| 154 |
+
"""Find a 'Print Full Directive' / 'Print all FSDs' link on a TOC page
|
| 155 |
+
(or its French label on the /fr editions)."""
|
| 156 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 157 |
+
for a in soup.find_all("a", href=True):
|
| 158 |
+
text = _norm(a.get_text(" ", strip=True)).lower()
|
| 159 |
+
if ("print full directive" in text or "print all fsd" in text
|
| 160 |
+
or "imprimer la directive" in text
|
| 161 |
+
or "imprimer toutes" in text or "imprimez toutes" in text):
|
| 162 |
+
return a["href"] if a["href"].startswith("http") else BASE + a["href"]
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def preserve_failed(chunks, failures, stored):
|
| 167 |
+
"""Re-attach the last-good chunks of directives this run could not parse.
|
| 168 |
+
|
| 169 |
+
Ingestion is a full rebuild, so a directive whose page 404s or is restyled
|
| 170 |
+
vanishes from the corpus entirely -- and a directive that is still in force
|
| 171 |
+
is better served from the copy we already had than not at all. Keyed on
|
| 172 |
+
act_code (the /directive/<code>/ slug), which survives the title rewordings
|
| 173 |
+
the NJC index goes through; restricted to the directives that actually
|
| 174 |
+
failed, so any the NJC has genuinely retired stay gone.
|
| 175 |
+
Returns (chunks, preserved directive codes).
|
| 176 |
+
"""
|
| 177 |
+
failed = {code for code, _title, _why in failures}
|
| 178 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 179 |
+
only=failed)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def ingest(force=False, limit=None, allow_shrink=False):
|
| 183 |
+
directives = directive_links(force=force)
|
| 184 |
+
if limit:
|
| 185 |
+
directives = directives[:limit]
|
| 186 |
+
print(f"Ingesting {len(directives)} NJC directives...")
|
| 187 |
+
all_chunks, failures = [], []
|
| 188 |
+
for url, title, date in directives:
|
| 189 |
+
code_match = _CODE.search(url)
|
| 190 |
+
code = code_match.group(1) if code_match else "x"
|
| 191 |
+
try:
|
| 192 |
+
html = _fetch(url, DIRECTIVE_DIR / f"{code}.html", force=force)
|
| 193 |
+
chunks = parse_directive(html, url, title, date)
|
| 194 |
+
if not chunks:
|
| 195 |
+
# Multi-page directive: the landing page is only a table of
|
| 196 |
+
# contents -- follow its "Print Full Directive" link.
|
| 197 |
+
print_url = _print_link(html)
|
| 198 |
+
if print_url:
|
| 199 |
+
full = _fetch(print_url, DIRECTIVE_DIR / f"{code}-full.html",
|
| 200 |
+
force=force)
|
| 201 |
+
chunks = parse_directive(full, url, title, date)
|
| 202 |
+
if chunks:
|
| 203 |
+
all_chunks.extend(chunks)
|
| 204 |
+
print(f" {title}: {len(chunks)} chunks")
|
| 205 |
+
else:
|
| 206 |
+
failures.append((code, title, "no content parsed"))
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
failures.append((code, title, f"{type(exc).__name__}: {exc}"))
|
| 209 |
+
if not limit:
|
| 210 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 211 |
+
stored_chunks(OUT_FILE))
|
| 212 |
+
if preserved:
|
| 213 |
+
print(f" kept the last-good copy of {len(preserved)} unparseable "
|
| 214 |
+
f"directive(s): {', '.join(preserved)}")
|
| 215 |
+
# Before the write, so a refusal does not bury why the run came up short.
|
| 216 |
+
for _code, title, why in failures:
|
| 217 |
+
print(f" FAILED {title}: {why}")
|
| 218 |
+
# The guard applies to --limit runs too: a smoke test over two directives
|
| 219 |
+
# must not be able to write a two-directive corpus over the real one.
|
| 220 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 221 |
+
allow_shrink=allow_shrink):
|
| 222 |
+
return False
|
| 223 |
+
print(f"\n{len(all_chunks)} chunks from {len(directives) - len(failures)} "
|
| 224 |
+
f"directives -> {OUT_FILE.name}")
|
| 225 |
+
return True
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
OUT_FILE_FR = OUT_FILE.with_name("directives-fr.json")
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def ingest_fr(force=False, limit=None, allow_shrink=False):
|
| 232 |
+
"""The French editions of the NJC directives, mirroring ingest().
|
| 233 |
+
|
| 234 |
+
The NJC publishes each directive at .../directive/<code>/fr with the
|
| 235 |
+
same markup as /en, so the parser is reused as-is. The EN index supplies
|
| 236 |
+
the directive list and identity (act_short stays the English title so
|
| 237 |
+
act filters behave one way regardless of language); chunks get
|
| 238 |
+
lang='fr' and a -fr id infix, and language-routed retrieval serves them
|
| 239 |
+
only to French queries."""
|
| 240 |
+
directives = directive_links(force=force)
|
| 241 |
+
if limit:
|
| 242 |
+
directives = directives[:limit]
|
| 243 |
+
print(f"Ingesting {len(directives)} directives du CNM (français)...")
|
| 244 |
+
all_chunks, failures = [], []
|
| 245 |
+
for url, title, date in directives:
|
| 246 |
+
code_match = _CODE.search(url)
|
| 247 |
+
code = code_match.group(1) if code_match else "x"
|
| 248 |
+
fr_url = re.sub(r"/en$", "/fr", url)
|
| 249 |
+
try:
|
| 250 |
+
html = _fetch(fr_url, DIRECTIVE_DIR / f"{code}-fr.html",
|
| 251 |
+
force=force)
|
| 252 |
+
chunks = parse_directive(html, fr_url, title, date)
|
| 253 |
+
if not chunks:
|
| 254 |
+
print_url = _print_link(html)
|
| 255 |
+
if print_url:
|
| 256 |
+
full = _fetch(print_url,
|
| 257 |
+
DIRECTIVE_DIR / f"{code}-fr-full.html",
|
| 258 |
+
force=force)
|
| 259 |
+
chunks = parse_directive(full, fr_url, title, date)
|
| 260 |
+
if chunks:
|
| 261 |
+
all_chunks.extend(chunks)
|
| 262 |
+
print(f" {title} (fr): {len(chunks)} chunks")
|
| 263 |
+
else:
|
| 264 |
+
failures.append((code, title, "no content parsed"))
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
failures.append((code, title, f"{type(exc).__name__}: {exc}"))
|
| 267 |
+
for c in all_chunks:
|
| 268 |
+
c["id"] = c["id"].replace("directive-", "directive-fr-", 1)
|
| 269 |
+
c["lang"] = "fr"
|
| 270 |
+
if not limit:
|
| 271 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 272 |
+
stored_chunks(OUT_FILE_FR))
|
| 273 |
+
if preserved:
|
| 274 |
+
print(f" kept the last-good copy of {len(preserved)} "
|
| 275 |
+
f"unparseable directive(s)")
|
| 276 |
+
for _code, title, why in failures:
|
| 277 |
+
print(f" FAILED {title} (fr): {why}")
|
| 278 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, indent=2,
|
| 279 |
+
allow_shrink=allow_shrink):
|
| 280 |
+
return False
|
| 281 |
+
print(f"\n{len(all_chunks)} chunks from "
|
| 282 |
+
f"{len(directives) - len(failures)} directives -> "
|
| 283 |
+
f"{OUT_FILE_FR.name}")
|
| 284 |
+
return True
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def main():
|
| 288 |
+
force = "--force" in sys.argv
|
| 289 |
+
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 290 |
+
if a.startswith("--limit=")), None)
|
| 291 |
+
runner = ingest_fr if "--fr" in sys.argv else ingest
|
| 292 |
+
ok = runner(force=force, limit=limit,
|
| 293 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 294 |
+
sys.exit(0 if ok else 1)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
if __name__ == "__main__":
|
| 298 |
+
main()
|
canlex/dmemo.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest CBSA D-Memoranda (HTML) into structured, section-level chunks.
|
| 2 |
+
|
| 3 |
+
D-Memoranda are CBSA's administrative guidance on how it applies the Customs Act
|
| 4 |
+
and related law. They are persuasive, not binding -- every chunk is tagged
|
| 5 |
+
doc_type="memorandum" so the rest of CanLex can keep them distinct from statute.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
from urllib.parse import urljoin
|
| 12 |
+
|
| 13 |
+
from bs4 import BeautifulSoup
|
| 14 |
+
from pypdf import PdfReader
|
| 15 |
+
|
| 16 |
+
from ._common import (POLITE_UA, fetch_cached, norm_ws as _norm,
|
| 17 |
+
preserve_dropped, split_lines, stored_chunks,
|
| 18 |
+
write_corpus)
|
| 19 |
+
from .config import RAW_DIR, PROCESSED_DIR
|
| 20 |
+
|
| 21 |
+
INDEX_URL = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
|
| 22 |
+
# CBSA rebuilt the index on 2026-07-10: the page now ships an empty WET
|
| 23 |
+
# DataTable and NO memo links at all -- the rows arrive from this JSON
|
| 24 |
+
# datasource. Each row's NUMBER holds the anchor
|
| 25 |
+
# ('<a href="/publications/dm-md/d1/d1-2-1-eng.html">D1-2-1</a>'), and
|
| 26 |
+
# cancelled memos carry STATUS "Cancelled" (usually with the link stripped).
|
| 27 |
+
# Cancelled memos are deliberately not ingested -- same rule the ENF manuals
|
| 28 |
+
# follow: retired guidance must not surface as if it were current.
|
| 29 |
+
DATASOURCE_URL = ("https://www.cbsa-asfc.gc.ca/publications/dm-md/"
|
| 30 |
+
"dmemoranda-datasource-eng.json")
|
| 31 |
+
DMEMO_DIR = RAW_DIR / "dmemos"
|
| 32 |
+
OUT_FILE = PROCESSED_DIR / "dmemos.json"
|
| 33 |
+
|
| 34 |
+
# <h2> headings that are page boilerplate rather than memo content.
|
| 35 |
+
_SKIP_HEADINGS = {"contact us", "related links",
|
| 36 |
+
"contactez-nous", "liens connexes"}
|
| 37 |
+
# Bilingual: the French editions live at the same paths with -fra.html, so
|
| 38 |
+
# one pattern serves both ingest passes.
|
| 39 |
+
_MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-(?:eng|fra)\.html")
|
| 40 |
+
_URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-(?:eng|fra)\.html")
|
| 41 |
+
_DS_HREF = re.compile(r'href="([^"]+)"')
|
| 42 |
+
DATASOURCE_URL_FR = DATASOURCE_URL.replace("-eng.json", "-fra.json")
|
| 43 |
+
OUT_FILE_FR = PROCESSED_DIR / "dmemos-fr.json"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _fetch(url, dest, force=False):
|
| 47 |
+
return fetch_cached(url, dest, ua=POLITE_UA, force=force)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def datasource_urls(raw):
|
| 51 |
+
"""Active D-memo URLs from the index's JSON datasource. Pure, for tests."""
|
| 52 |
+
urls, seen = [], set()
|
| 53 |
+
for row in json.loads(raw).get("data", []):
|
| 54 |
+
if _norm(row.get("STATUS", "")).lower() in ("cancelled", "annulé"):
|
| 55 |
+
continue
|
| 56 |
+
match = _DS_HREF.search(row.get("NUMBER", ""))
|
| 57 |
+
if not match:
|
| 58 |
+
continue
|
| 59 |
+
full = urljoin(DATASOURCE_URL, match.group(1))
|
| 60 |
+
if full not in seen:
|
| 61 |
+
seen.add(full)
|
| 62 |
+
urls.append(full)
|
| 63 |
+
return urls
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def index_urls(html):
|
| 67 |
+
"""D-memo URLs linked from the index page itself. Pure, for tests.
|
| 68 |
+
|
| 69 |
+
How the index worked before 2026-07 and how it would work again if CBSA
|
| 70 |
+
reverts; kept as the fallback for datasource_urls.
|
| 71 |
+
"""
|
| 72 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 73 |
+
urls, seen = [], set()
|
| 74 |
+
for a in soup.find_all("a", href=True):
|
| 75 |
+
if _MEMO_HREF.search(a["href"]):
|
| 76 |
+
full = urljoin(INDEX_URL, a["href"])
|
| 77 |
+
if full not in seen:
|
| 78 |
+
seen.add(full)
|
| 79 |
+
urls.append(full)
|
| 80 |
+
return urls
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def memo_urls(force=False):
|
| 84 |
+
"""All individual active D-memo URLs, from the CBSA index's datasource,
|
| 85 |
+
falling back to links on the index page if the datasource is gone."""
|
| 86 |
+
try:
|
| 87 |
+
urls = datasource_urls(_fetch(DATASOURCE_URL,
|
| 88 |
+
DMEMO_DIR / "_datasource-eng.json",
|
| 89 |
+
force=force))
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
print(f" datasource unavailable ({type(exc).__name__}: {exc}); "
|
| 92 |
+
f"falling back to index page links")
|
| 93 |
+
urls = []
|
| 94 |
+
if not urls:
|
| 95 |
+
urls = index_urls(_fetch(INDEX_URL, DMEMO_DIR / "_index.html",
|
| 96 |
+
force=force))
|
| 97 |
+
return urls
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _render_section(h2):
|
| 101 |
+
"""Readable text from an <h2> up to the next <h2> (sections already unwrapped)."""
|
| 102 |
+
lines = []
|
| 103 |
+
for sib in h2.find_next_siblings():
|
| 104 |
+
if sib.name == "h2" or sib.get("id") == "wb-dtmd":
|
| 105 |
+
break
|
| 106 |
+
if sib.name in ("ul", "ol"):
|
| 107 |
+
for li in sib.find_all("li", recursive=False):
|
| 108 |
+
item = _norm(li.get_text(" ", strip=True))
|
| 109 |
+
if item:
|
| 110 |
+
lines.append(f"- {item}")
|
| 111 |
+
else:
|
| 112 |
+
text = _norm(sib.get_text(" ", strip=True))
|
| 113 |
+
if text:
|
| 114 |
+
lines.append(text)
|
| 115 |
+
return "\n".join(lines)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def parse_memo(html, url):
|
| 119 |
+
"""Parse one D-memo HTML page into one chunk per <h2> content section."""
|
| 120 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 121 |
+
main = soup.find("main")
|
| 122 |
+
if main is None:
|
| 123 |
+
return []
|
| 124 |
+
for section in main.find_all("section"):
|
| 125 |
+
section.unwrap() # flatten so each <h2> and its content become siblings
|
| 126 |
+
|
| 127 |
+
match = _URL_NUMBER.search(url)
|
| 128 |
+
number = match.group(1).upper() if match else url
|
| 129 |
+
h1 = main.find("h1")
|
| 130 |
+
topic = ""
|
| 131 |
+
if h1:
|
| 132 |
+
# Pages vary: most carry the memo title in <h1><small>, others as plain
|
| 133 |
+
# "Memorandum DNN-N-N: Title" h1 text. Use the <small> if present, else
|
| 134 |
+
# the h1 text, and strip any leading memo-number prefix either way.
|
| 135 |
+
small = h1.find("small")
|
| 136 |
+
raw = (small.get_text(" ", strip=True) if small
|
| 137 |
+
else h1.get_text(" ", strip=True))
|
| 138 |
+
topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "",
|
| 139 |
+
_norm(raw), flags=re.I)
|
| 140 |
+
dm = main.find("time", attrs={"property": "dateModified"})
|
| 141 |
+
date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
|
| 142 |
+
|
| 143 |
+
chunks = []
|
| 144 |
+
for h2 in main.find_all("h2"):
|
| 145 |
+
heading = _norm(h2.get_text(" ", strip=True))
|
| 146 |
+
if not heading or heading.lower() in _SKIP_HEADINGS:
|
| 147 |
+
continue
|
| 148 |
+
body = _render_section(h2)
|
| 149 |
+
if not body:
|
| 150 |
+
continue
|
| 151 |
+
# One chunk per h2 section. (Splitting long memos to the embed window
|
| 152 |
+
# was trialled and reverted: it tripled memo chunk-count and gave
|
| 153 |
+
# D-memoranda enough extra retrieval surface to out-rank the statutes
|
| 154 |
+
# and sibling memos they relate to -- a measured eval regression. The
|
| 155 |
+
# memo-tail recall gap is better addressed later by multi-vector
|
| 156 |
+
# embedding than by more competing chunks.)
|
| 157 |
+
chunks.append({
|
| 158 |
+
"id": f"dmemo-{number}-{len(chunks) + 1}",
|
| 159 |
+
"doc_type": "memorandum",
|
| 160 |
+
"act_code": "D-Memo",
|
| 161 |
+
"act_short": "D-Memo",
|
| 162 |
+
"act_name": "CBSA D-Memoranda",
|
| 163 |
+
"section": number,
|
| 164 |
+
"marginal_note": heading,
|
| 165 |
+
"part": topic,
|
| 166 |
+
"division": "",
|
| 167 |
+
"heading": "",
|
| 168 |
+
"text": body,
|
| 169 |
+
"history": "",
|
| 170 |
+
"last_amended": date,
|
| 171 |
+
"current_to": date,
|
| 172 |
+
"citation": f"Memorandum {number}",
|
| 173 |
+
"source_url": url,
|
| 174 |
+
})
|
| 175 |
+
return chunks
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _pdf_clean(text):
|
| 179 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 180 |
+
text = re.sub(r"\n[ \t]+", "\n", text)
|
| 181 |
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _pdf_text(pdf_bytes):
|
| 185 |
+
try:
|
| 186 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 187 |
+
return _pdf_clean("\n".join((p.extract_text() or "") for p in reader.pages))
|
| 188 |
+
except Exception:
|
| 189 |
+
return ""
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _split(text, target=3000):
|
| 193 |
+
"""Split long PDF text into ~target-sized pieces at line boundaries."""
|
| 194 |
+
return split_lines(text, target)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def parse_pdf_memo(html, url):
|
| 198 |
+
"""Fallback for memos whose HTML page is only a stub linking to a PDF."""
|
| 199 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 200 |
+
main = soup.find("main")
|
| 201 |
+
if main is None:
|
| 202 |
+
return []
|
| 203 |
+
pdf_href = next((a["href"] for a in main.find_all("a", href=True)
|
| 204 |
+
if a["href"].lower().endswith(".pdf")), None)
|
| 205 |
+
if not pdf_href:
|
| 206 |
+
return []
|
| 207 |
+
pdf_url = urljoin(url, pdf_href)
|
| 208 |
+
|
| 209 |
+
match = _URL_NUMBER.search(url)
|
| 210 |
+
number = match.group(1).upper() if match else url
|
| 211 |
+
h1 = main.find("h1")
|
| 212 |
+
topic = _norm(h1.get_text(" ", strip=True)) if h1 else ""
|
| 213 |
+
topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "", topic, flags=re.I)
|
| 214 |
+
dm = main.find("time", attrs={"property": "dateModified"})
|
| 215 |
+
date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
|
| 216 |
+
|
| 217 |
+
pdf_bytes = _fetch(pdf_url, DMEMO_DIR / "pdf" / pdf_url.rsplit("/", 1)[-1])
|
| 218 |
+
text = _pdf_text(pdf_bytes)
|
| 219 |
+
if not text:
|
| 220 |
+
return []
|
| 221 |
+
|
| 222 |
+
parts = _split(text)
|
| 223 |
+
chunks = []
|
| 224 |
+
for i, part in enumerate(parts, 1):
|
| 225 |
+
label = topic or number
|
| 226 |
+
if len(parts) > 1:
|
| 227 |
+
label = f"{label} (part {i})"
|
| 228 |
+
chunks.append({
|
| 229 |
+
"id": f"dmemo-{number}-pdf{i}",
|
| 230 |
+
"doc_type": "memorandum",
|
| 231 |
+
"act_code": "D-Memo",
|
| 232 |
+
"act_short": "D-Memo",
|
| 233 |
+
"act_name": "CBSA D-Memoranda",
|
| 234 |
+
"section": number,
|
| 235 |
+
"marginal_note": label,
|
| 236 |
+
"part": topic,
|
| 237 |
+
"division": "",
|
| 238 |
+
"heading": "",
|
| 239 |
+
"text": part,
|
| 240 |
+
"history": "",
|
| 241 |
+
"last_amended": date,
|
| 242 |
+
"current_to": date,
|
| 243 |
+
"citation": f"Memorandum {number}",
|
| 244 |
+
"source_url": url,
|
| 245 |
+
})
|
| 246 |
+
return chunks
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def preserve_failed(chunks, failures, stored):
|
| 250 |
+
"""Re-attach the last-good chunks of memos that could not be fetched.
|
| 251 |
+
|
| 252 |
+
Ingestion is a full rebuild, so anything that fails a fetch disappears from
|
| 253 |
+
the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
|
| 254 |
+
whose pages 404, and a transient network error looks the same -- in both
|
| 255 |
+
cases dropping guidance that is still in force is worse than serving the
|
| 256 |
+
copy we already had. Restricted to memos that actually failed, so the
|
| 257 |
+
cancelled ones (filtered out before fetching) are not resurrected.
|
| 258 |
+
Returns (chunks, preserved memo numbers).
|
| 259 |
+
"""
|
| 260 |
+
failed = set()
|
| 261 |
+
for url, _why in failures:
|
| 262 |
+
match = _URL_NUMBER.search(url)
|
| 263 |
+
if match:
|
| 264 |
+
failed.add(match.group(1).upper())
|
| 265 |
+
return preserve_dropped(chunks, stored,
|
| 266 |
+
key=lambda c: c["section"].upper(), only=failed)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def ingest(force=False, limit=None, allow_shrink=False):
|
| 270 |
+
urls = memo_urls(force=force)
|
| 271 |
+
if limit:
|
| 272 |
+
urls = urls[:limit]
|
| 273 |
+
print(f"Ingesting {len(urls)} D-Memoranda...")
|
| 274 |
+
all_chunks, failures = [], []
|
| 275 |
+
for i, url in enumerate(urls, 1):
|
| 276 |
+
try:
|
| 277 |
+
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
|
| 278 |
+
chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
|
| 279 |
+
if chunks:
|
| 280 |
+
all_chunks.extend(chunks)
|
| 281 |
+
else:
|
| 282 |
+
failures.append((url, "no content parsed"))
|
| 283 |
+
except Exception as exc:
|
| 284 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 285 |
+
if i % 50 == 0:
|
| 286 |
+
print(f" {i}/{len(urls)} ...")
|
| 287 |
+
if failures:
|
| 288 |
+
print(f" {len(failures)} memos with no content / errors:")
|
| 289 |
+
for url, why in failures[:15]:
|
| 290 |
+
print(f" - {url.rsplit('/', 1)[-1]}: {why}")
|
| 291 |
+
|
| 292 |
+
if not limit:
|
| 293 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 294 |
+
stored_chunks(OUT_FILE))
|
| 295 |
+
if preserved:
|
| 296 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 297 |
+
f"memo(s): {', '.join(preserved)}")
|
| 298 |
+
# The guard applies to --limit runs too: a smoke test over five memos must
|
| 299 |
+
# not be able to write a five-memo corpus over the real one.
|
| 300 |
+
if not write_corpus(OUT_FILE, all_chunks, allow_shrink=allow_shrink):
|
| 301 |
+
return False
|
| 302 |
+
print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
|
| 303 |
+
f"-> {OUT_FILE.name}")
|
| 304 |
+
return True
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def ingest_fr(force=False, limit=None, allow_shrink=False):
|
| 308 |
+
"""The French editions (Mémorandums D), mirroring ingest().
|
| 309 |
+
|
| 310 |
+
Same pages at -fra.html, same WET markup, same parser; the chunks are
|
| 311 |
+
post-processed with lang='fr', a dmemo-fr- id prefix and the French
|
| 312 |
+
citation so language-routed retrieval (index.query_lang) serves them
|
| 313 |
+
only to French queries."""
|
| 314 |
+
try:
|
| 315 |
+
urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
|
| 316 |
+
DMEMO_DIR / "_datasource-fra.json",
|
| 317 |
+
force=force))
|
| 318 |
+
except Exception as exc:
|
| 319 |
+
print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
|
| 320 |
+
urls = []
|
| 321 |
+
if limit:
|
| 322 |
+
urls = urls[:limit]
|
| 323 |
+
print(f"Ingesting {len(urls)} Mémorandums D (français)...")
|
| 324 |
+
all_chunks, failures = [], []
|
| 325 |
+
for i, url in enumerate(urls, 1):
|
| 326 |
+
try:
|
| 327 |
+
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
|
| 328 |
+
chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
|
| 329 |
+
if chunks:
|
| 330 |
+
all_chunks.extend(chunks)
|
| 331 |
+
else:
|
| 332 |
+
failures.append((url, "no content parsed"))
|
| 333 |
+
except Exception as exc:
|
| 334 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 335 |
+
if i % 50 == 0:
|
| 336 |
+
print(f" {i}/{len(urls)} ...")
|
| 337 |
+
for c in all_chunks:
|
| 338 |
+
c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
|
| 339 |
+
c["lang"] = "fr"
|
| 340 |
+
c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
|
| 341 |
+
if failures:
|
| 342 |
+
print(f" {len(failures)} mémorandums with no content / errors "
|
| 343 |
+
f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
|
| 344 |
+
if not limit:
|
| 345 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 346 |
+
stored_chunks(OUT_FILE_FR))
|
| 347 |
+
if preserved:
|
| 348 |
+
print(f" kept the last-good copy of {len(preserved)} "
|
| 349 |
+
f"unfetchable mémorandum(s)")
|
| 350 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
|
| 351 |
+
return False
|
| 352 |
+
print(f" {len(all_chunks)} section-chunks from "
|
| 353 |
+
f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
|
| 354 |
+
return True
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def main():
|
| 358 |
+
force = "--force" in sys.argv
|
| 359 |
+
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 360 |
+
if a.startswith("--limit=")), None)
|
| 361 |
+
runner = ingest_fr if "--fr" in sys.argv else ingest
|
| 362 |
+
ok = runner(force=force, limit=limit,
|
| 363 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 364 |
+
sys.exit(0 if ok else 1)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
if __name__ == "__main__":
|
| 368 |
+
main()
|
canlex/embed.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build semantic embeddings for ingested chunks (local, key-free).
|
| 2 |
+
|
| 3 |
+
Uses BAAI's bge-small-en-v1.5 sentence-embedding model as ONNX, run on CPU via
|
| 4 |
+
onnxruntime -- no API key. A transformer embedding has far stronger retrieval
|
| 5 |
+
recall than a static one: it can connect a natural-language question to a
|
| 6 |
+
provision even when the two share few exact words.
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import onnxruntime as ort
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
from tokenizers import Tokenizer
|
| 15 |
+
|
| 16 |
+
from .config import PROCESSED_DIR
|
| 17 |
+
|
| 18 |
+
# The embedding model is env-configurable so an alternative (e.g. a multilingual
|
| 19 |
+
# model for French support) can be trialled without code edits; the defaults
|
| 20 |
+
# reproduce the production bge-small-en-v1.5 setup exactly. A model swap means
|
| 21 |
+
# matching three things to that model's training: the HF repo, the pooling
|
| 22 |
+
# ('cls' for BGE, 'mean' for E5/MiniLM), and the query/passage prefixes (BGE
|
| 23 |
+
# prefixes only the query; E5 prefixes both with 'query: ' / 'passage: ').
|
| 24 |
+
EMB_REPO = os.environ.get("CANLEX_EMB_REPO", "Xenova/bge-small-en-v1.5")
|
| 25 |
+
_POOLING = os.environ.get("CANLEX_EMB_POOLING", "cls").lower()
|
| 26 |
+
_QUERY_PREFIX = os.environ.get(
|
| 27 |
+
"CANLEX_EMB_QUERY_PREFIX",
|
| 28 |
+
"Represent this sentence for searching relevant passages: ")
|
| 29 |
+
_PASSAGE_PREFIX = os.environ.get("CANLEX_EMB_PASSAGE_PREFIX", "")
|
| 30 |
+
EMB_PATH = PROCESSED_DIR / "embeddings.npz"
|
| 31 |
+
_MAX_TOKENS = 512
|
| 32 |
+
_MAX_BODY = 2000 # cap embedded body text so long sections stay topically focused
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_chunks():
|
| 36 |
+
chunks = []
|
| 37 |
+
for path in sorted(PROCESSED_DIR.glob("*.json")):
|
| 38 |
+
chunks.extend(json.loads(path.read_text(encoding="utf-8")))
|
| 39 |
+
return chunks
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# A chunk longer than the embed window gets additional vectors for its tail,
|
| 43 |
+
# up to this many per chunk (an 87K-char D-memo section would otherwise need
|
| 44 |
+
# 40+; eight windows cover 16K chars, past which BM25 carries the tail).
|
| 45 |
+
_MAX_WINDOWS = 8
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def embed_text(chunk):
|
| 49 |
+
"""Compact, retrieval-focused representation of one section (head only)."""
|
| 50 |
+
# The section title is the strongest topical signal, so it is repeated to
|
| 51 |
+
# emphasise it. Title selection is doc_type-aware (see index.topical_title):
|
| 52 |
+
# a D-memo's marginal_note is a generic banner so its actual subject in
|
| 53 |
+
# 'part' is used; a case-law chunk's marginal_note is just the paragraph
|
| 54 |
+
# range so the case proposition in 'heading' is used.
|
| 55 |
+
from .index import topical_title
|
| 56 |
+
note = topical_title(chunk)
|
| 57 |
+
body = chunk["text"][:_MAX_BODY]
|
| 58 |
+
parts = [chunk["act_short"], note, note, chunk["heading"], body]
|
| 59 |
+
return " . ".join(p for p in parts if p)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def embed_texts(chunk):
|
| 63 |
+
"""One representation per embedding window of a chunk.
|
| 64 |
+
|
| 65 |
+
The first is the classic head representation; a chunk whose body exceeds
|
| 66 |
+
the embed window additionally gets tail windows (same topical-title
|
| 67 |
+
anchor, successive body slices), so a fact buried mid-memo is reachable
|
| 68 |
+
through semantic recall instead of BM25 alone. All windows share the
|
| 69 |
+
chunk's id -- the index scores a chunk by its best window."""
|
| 70 |
+
from .index import topical_title
|
| 71 |
+
texts = [embed_text(chunk)]
|
| 72 |
+
body = chunk["text"]
|
| 73 |
+
if len(body) > _MAX_BODY:
|
| 74 |
+
note = topical_title(chunk)
|
| 75 |
+
anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
|
| 76 |
+
for start in range(_MAX_BODY, len(body), _MAX_BODY):
|
| 77 |
+
if len(texts) >= _MAX_WINDOWS:
|
| 78 |
+
break
|
| 79 |
+
texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
|
| 80 |
+
return texts
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class Embedder:
|
| 84 |
+
"""Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
|
| 85 |
+
|
| 86 |
+
No API key; the model is downloaded once and cached. Produces L2-normalized
|
| 87 |
+
vectors, so a dot product between them is cosine similarity.
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def __init__(self):
|
| 91 |
+
model_path = None
|
| 92 |
+
for name in ("onnx/model_quantized.onnx", "onnx/model.onnx"):
|
| 93 |
+
try:
|
| 94 |
+
model_path = hf_hub_download(EMB_REPO, name)
|
| 95 |
+
break
|
| 96 |
+
except Exception:
|
| 97 |
+
continue
|
| 98 |
+
if model_path is None:
|
| 99 |
+
raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
|
| 100 |
+
tok_path = hf_hub_download(EMB_REPO, "tokenizer.json")
|
| 101 |
+
from .rerank import make_ort_session
|
| 102 |
+
self.session = make_ort_session(model_path)
|
| 103 |
+
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 104 |
+
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 105 |
+
self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
|
| 106 |
+
|
| 107 |
+
def _run(self, texts):
|
| 108 |
+
"""Tokenize, run the encoder, CLS-pool and L2-normalize one batch."""
|
| 109 |
+
encs = self.tokenizer.encode_batch(list(texts))
|
| 110 |
+
width = max(len(e.ids) for e in encs)
|
| 111 |
+
input_ids = np.zeros((len(encs), width), dtype=np.int64)
|
| 112 |
+
attention = np.zeros((len(encs), width), dtype=np.int64)
|
| 113 |
+
type_ids = np.zeros((len(encs), width), dtype=np.int64)
|
| 114 |
+
for row, enc in enumerate(encs):
|
| 115 |
+
n = len(enc.ids)
|
| 116 |
+
input_ids[row, :n] = enc.ids
|
| 117 |
+
attention[row, :n] = enc.attention_mask
|
| 118 |
+
type_ids[row, :n] = enc.type_ids
|
| 119 |
+
feed = {"input_ids": input_ids, "attention_mask": attention}
|
| 120 |
+
if "token_type_ids" in self.input_names:
|
| 121 |
+
feed["token_type_ids"] = type_ids
|
| 122 |
+
hidden = np.asarray(self.session.run(None, feed)[0], dtype=np.float32)
|
| 123 |
+
if hidden.ndim != 3:
|
| 124 |
+
pooled = hidden # already pooled
|
| 125 |
+
elif _POOLING == "mean": # E5 / MiniLM
|
| 126 |
+
mask = attention[:, :, None].astype(np.float32)
|
| 127 |
+
pooled = (hidden * mask).sum(1) / np.maximum(mask.sum(1), 1e-9)
|
| 128 |
+
else: # BGE: CLS token
|
| 129 |
+
pooled = hidden[:, 0, :]
|
| 130 |
+
norms = np.linalg.norm(pooled, axis=1, keepdims=True)
|
| 131 |
+
return pooled / np.maximum(norms, 1e-9)
|
| 132 |
+
|
| 133 |
+
def encode(self, texts, batch_size=32):
|
| 134 |
+
"""Return L2-normalized embeddings for passages, one row per text."""
|
| 135 |
+
texts = [_PASSAGE_PREFIX + t for t in texts]
|
| 136 |
+
if not texts:
|
| 137 |
+
return np.zeros((0, 384), dtype=np.float32)
|
| 138 |
+
rows = [self._run(texts[i:i + batch_size])
|
| 139 |
+
for i in range(0, len(texts), batch_size)]
|
| 140 |
+
return np.vstack(rows)
|
| 141 |
+
|
| 142 |
+
def encode_query(self, text):
|
| 143 |
+
"""Return the L2-normalized embedding for one search query."""
|
| 144 |
+
return self._run([_QUERY_PREFIX + text])[0]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def build():
|
| 148 |
+
chunks = load_chunks()
|
| 149 |
+
if not chunks:
|
| 150 |
+
print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 151 |
+
return
|
| 152 |
+
texts, ids = [], []
|
| 153 |
+
for c in chunks:
|
| 154 |
+
for t in embed_texts(c):
|
| 155 |
+
texts.append(t)
|
| 156 |
+
ids.append(c["id"])
|
| 157 |
+
print(f"Embedding {len(chunks)} sections as {len(texts)} windows "
|
| 158 |
+
f"with {EMB_REPO} ...")
|
| 159 |
+
vectors = Embedder().encode(texts)
|
| 160 |
+
np.savez(EMB_PATH, ids=np.array(ids), vectors=vectors)
|
| 161 |
+
print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
build()
|
canlex/enf.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the IRCC/CBSA ENF operational-manual chapters.
|
| 2 |
+
|
| 3 |
+
The ENF chapters are the operational layer between the statute and the
|
| 4 |
+
officer: ENF 4 (port-of-entry examinations), ENF 5 (writing A44(1) reports),
|
| 5 |
+
ENF 6 (Minister's-delegate review), ENF 12 (search and seizure), ENF 20
|
| 6 |
+
(detention) and their siblings are what an immigration-secondary BSO actually
|
| 7 |
+
opens. The legacy PDFs live under the canada.ca manuals directory that
|
| 8 |
+
delegation.py already fetches IL3 from (same PowerShell TLS workaround), and
|
| 9 |
+
are parsed with pypdf, TOC-segmented by their internal numbered headings.
|
| 10 |
+
|
| 11 |
+
Chapters are probed rather than hardcoded: the numbering has gaps and IRCC
|
| 12 |
+
retires chapters, so anything that 404s is skipped with a note. doc_type is
|
| 13 |
+
'memorandum' -- operational guidance, persuasive not binding, exactly like
|
| 14 |
+
the D-memoranda.
|
| 15 |
+
|
| 16 |
+
python -m canlex.enf [--allow-shrink]
|
| 17 |
+
"""
|
| 18 |
+
import re
|
| 19 |
+
import sys
|
| 20 |
+
import urllib.error
|
| 21 |
+
|
| 22 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 23 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 24 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 25 |
+
|
| 26 |
+
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 27 |
+
"resources/manuals/enf/enf{n:02d}-eng.pdf")
|
| 28 |
+
RAW = RAW_DIR / "enf"
|
| 29 |
+
_CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
|
| 30 |
+
|
| 31 |
+
# Chapters whose current edition lives under a non-standard filename in the
|
| 32 |
+
# same directory (verified 2026-07-22 against the OBM Active-manuals index).
|
| 33 |
+
# ENF 3's old enf03-eng.pdf still returns 200 but is a STALE 2015-era copy;
|
| 34 |
+
# ENF 20 (Detention) was renamed enf20a-en.pdf (Aug 2024 edition).
|
| 35 |
+
_URL_OVERRIDES = {
|
| 36 |
+
3: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 37 |
+
"resources/manuals/enf/enf-3-admissibility-(en)-final.pdf"),
|
| 38 |
+
20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 39 |
+
"resources/manuals/enf/enf20a-en.pdf"),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Chapters IRCC withdrew from public distribution whose only public text is
|
| 43 |
+
# an ATIP-released copy hosted by a third party (meurrensonimmigration.com,
|
| 44 |
+
# verified live 2026-07-22). Ingested at the maintainer's decision
|
| 45 |
+
# (2026-07-23) with an unmistakable UNOFFICIAL banner on every chunk: the
|
| 46 |
+
# text may lag the internal current version, and nothing here is an official
|
| 47 |
+
# publication. ENF 14/OP 19 is the operational manual behind criminal
|
| 48 |
+
# rehabilitation -- directly relevant to canlex_rehabilitation.
|
| 49 |
+
UNOFFICIAL = {
|
| 50 |
+
2: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2023/06/"
|
| 51 |
+
"ENF2-3.pdf",
|
| 52 |
+
"title": "Evaluating Inadmissibility (unofficial ATIP copy)",
|
| 53 |
+
"note": "ATIP-released copy dated 2021-08-25; retired from canada.ca "
|
| 54 |
+
"in 2018 with no public successor"},
|
| 55 |
+
# ENF 14's ATIP release is a 56-page image scan with no text layer; its
|
| 56 |
+
# text comes from a Windows-OCR pass cached as enf14-ocr.txt (see the
|
| 57 |
+
# OCR fallback in build()). Regenerate the cache with pymupdf
|
| 58 |
+
# rasterization + Windows.Media.Ocr if the source PDF ever changes.
|
| 59 |
+
14: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2025/03/"
|
| 60 |
+
"Enforcement-Manual-14-%E2%80%93-Rehabilitation.pdf",
|
| 61 |
+
"title": "Criminal Rehabilitation (ENF 14/OP 19, unofficial ATIP "
|
| 62 |
+
"copy)",
|
| 63 |
+
"note": "withdrawn from public distribution ~2007; ATIP-released "
|
| 64 |
+
"scan, text recovered by OCR (last internal change "
|
| 65 |
+
"2008-04-21) -- OCR artifacts possible"},
|
| 66 |
+
}
|
| 67 |
+
_UNOFFICIAL_BANNER = (
|
| 68 |
+
"[UNOFFICIAL ATIP COPY — this chapter was withdrawn from public "
|
| 69 |
+
"distribution; this text comes from an access-to-information release "
|
| 70 |
+
"hosted by a third party and may lag the current internal version. "
|
| 71 |
+
"Treat as historical guidance; verify against internal sources.] ")
|
| 72 |
+
|
| 73 |
+
# Curated titles for the core chapters; anything else gets a parsed or
|
| 74 |
+
# generic title. Only used for display -- retrieval works off the text.
|
| 75 |
+
TITLES = {
|
| 76 |
+
1: "Inadmissibility",
|
| 77 |
+
2: "Evaluating inadmissibility",
|
| 78 |
+
3: "Admissibility hearings and detention review proceedings",
|
| 79 |
+
4: "Port of entry examinations",
|
| 80 |
+
5: "Writing 44(1) reports",
|
| 81 |
+
6: "Review of reports under A44(1)",
|
| 82 |
+
7: "Investigations and arrests",
|
| 83 |
+
8: "Deposits and guarantees",
|
| 84 |
+
9: "Judicial review",
|
| 85 |
+
10: "Removals",
|
| 86 |
+
11: "Verifying departure",
|
| 87 |
+
12: "Search, seizure, fingerprinting and photographing",
|
| 88 |
+
13: "Examining bank drafts and other negotiable instruments",
|
| 89 |
+
14: "Investigations and arrests (war crimes)",
|
| 90 |
+
15: "Obligations of transporters",
|
| 91 |
+
17: "Maintaining confidence in the immigration program",
|
| 92 |
+
18: "War crimes and crimes against humanity",
|
| 93 |
+
19: "Appeals before the Immigration Appeal Division",
|
| 94 |
+
20: "Detention",
|
| 95 |
+
21: "Recovery of removal costs",
|
| 96 |
+
23: "Loss of permanent resident status",
|
| 97 |
+
27: "Permanent resident card",
|
| 98 |
+
28: "Ministerial opinions on danger to the public",
|
| 99 |
+
34: "Alternatives to prosecution",
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
_DATE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
|
| 103 |
+
# A manual heading line: "5. Departmental policy" / "5.1 Authority to ..."
|
| 104 |
+
_HEADING = re.compile(r"^(\d{1,2}(?:\.\d{1,2})?)\.?\s+([A-Z][^\n]{2,90})$")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _pdf_text(path):
|
| 108 |
+
from pypdf import PdfReader
|
| 109 |
+
reader = PdfReader(str(path))
|
| 110 |
+
pages = []
|
| 111 |
+
for page in reader.pages:
|
| 112 |
+
try:
|
| 113 |
+
pages.append(page.extract_text() or "")
|
| 114 |
+
except Exception:
|
| 115 |
+
pages.append("")
|
| 116 |
+
return pages
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _chunks_for(n, pages, url):
|
| 120 |
+
title = TITLES.get(n, f"ENF {n}")
|
| 121 |
+
head = "\n".join(pages[:3])
|
| 122 |
+
dates = _DATE.findall(head)
|
| 123 |
+
updated = max(dates) if dates else ""
|
| 124 |
+
text = "\n".join(pages)
|
| 125 |
+
lines = text.split("\n")
|
| 126 |
+
# Segment on the manual's own numbered headings when enough are found;
|
| 127 |
+
# PDF extraction is uneven, so fall back to plain size-splitting.
|
| 128 |
+
marks = [(i, m.group(1), _norm(m.group(2)))
|
| 129 |
+
for i, l in enumerate(lines) if (m := _HEADING.match(l.strip()))]
|
| 130 |
+
segments = []
|
| 131 |
+
if len(marks) >= 6:
|
| 132 |
+
for j, (i, num, heading) in enumerate(marks):
|
| 133 |
+
end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
|
| 134 |
+
body = _norm_block(lines[i:end])
|
| 135 |
+
if len(body) > 120:
|
| 136 |
+
segments.append((f"{num}", heading, body))
|
| 137 |
+
if not segments:
|
| 138 |
+
for k, piece in enumerate(split_lines(text, 1800), start=1):
|
| 139 |
+
body = _norm_block(piece.split("\n"))
|
| 140 |
+
if len(body) > 120:
|
| 141 |
+
segments.append((str(k), f"{title} (part {k})", body))
|
| 142 |
+
out = []
|
| 143 |
+
for num, heading, body in segments:
|
| 144 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 145 |
+
total = len(pieces)
|
| 146 |
+
for k, piece in enumerate(pieces, start=1):
|
| 147 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 148 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 149 |
+
out.append({
|
| 150 |
+
"id": f"enf-{n}-{num.replace('.', '-')}{suffix}",
|
| 151 |
+
"doc_type": "memorandum",
|
| 152 |
+
"act_code": f"ENF-{n}",
|
| 153 |
+
"act_short": f"ENF {n}",
|
| 154 |
+
"act_name": f"ENF {n} — {title}",
|
| 155 |
+
"section": f"ENF {n} s. {num}",
|
| 156 |
+
"marginal_note": heading,
|
| 157 |
+
"part": title,
|
| 158 |
+
"division": "",
|
| 159 |
+
"heading": "",
|
| 160 |
+
"text": piece,
|
| 161 |
+
"history": "",
|
| 162 |
+
"last_amended": updated,
|
| 163 |
+
"current_to": updated,
|
| 164 |
+
"citation": f"ENF {n}, s. {num} ({heading}){note}",
|
| 165 |
+
"source_url": url,
|
| 166 |
+
})
|
| 167 |
+
return out
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _norm_block(lines):
|
| 171 |
+
return "\n".join(p for p in (_norm(l) for l in lines) if p)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
_HTTP_404 = re.compile(r"\b404\b")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def is_missing_upstream(exc):
|
| 178 |
+
"""True when a fetch failed because the chapter simply is not there (404).
|
| 179 |
+
|
| 180 |
+
_CHAPTERS is a blind range(1, 41) probe, so on a healthy run most numbers
|
| 181 |
+
404 -- and a chapter IRCC RETIRES starts 404ing too. That is this module's
|
| 182 |
+
documented signal to drop a chapter, so a 404 must not be preserved
|
| 183 |
+
through: doing so would pin every withdrawn chapter in the corpus forever.
|
| 184 |
+
Every other fetch error (TLS, timeout, a dropped connection) is a real
|
| 185 |
+
failure the stored copy should survive. The two fetch paths report
|
| 186 |
+
differently -- urllib raises HTTPError with a status, the PowerShell
|
| 187 |
+
fallback raises CalledProcessError with the status only in its stderr
|
| 188 |
+
prose ("The remote server returned an error: (404) Not Found."), so that
|
| 189 |
+
side is a text match.
|
| 190 |
+
"""
|
| 191 |
+
if isinstance(exc, urllib.error.HTTPError):
|
| 192 |
+
return exc.code == 404
|
| 193 |
+
stderr = getattr(exc, "stderr", "") or ""
|
| 194 |
+
if isinstance(stderr, bytes):
|
| 195 |
+
stderr = stderr.decode("utf-8", "replace")
|
| 196 |
+
return bool(_HTTP_404.search(stderr))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def preserve_failed(chunks, failures, stored):
|
| 200 |
+
"""Re-attach the last-good chunks of chapters this run could not produce.
|
| 201 |
+
|
| 202 |
+
A chapter that stops fetching for any reason OTHER than a 404 -- a dropped
|
| 203 |
+
network, a TLS refusal, a PDF that suddenly has no text layer -- has not
|
| 204 |
+
been retired, it just did not arrive, and losing guidance that is still in
|
| 205 |
+
force is the worse error. So the stored copy stays and the run says so
|
| 206 |
+
loudly enough to be investigated. Chapters that 404 (retired, or never
|
| 207 |
+
existed) never enter `failures` and are not resurrected.
|
| 208 |
+
Returns (chunks, preserved act_codes).
|
| 209 |
+
"""
|
| 210 |
+
failed = {f"ENF-{n}" for n, _why in failures}
|
| 211 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 212 |
+
only=failed)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def build(chapters=_CHAPTERS, allow_shrink=False):
|
| 216 |
+
# Returns the chunks written, or False if the write was refused.
|
| 217 |
+
all_chunks, skipped, failures = [], [], []
|
| 218 |
+
for n in chapters:
|
| 219 |
+
unofficial = UNOFFICIAL.get(n)
|
| 220 |
+
url = (unofficial["url"] if unofficial
|
| 221 |
+
else _URL_OVERRIDES.get(n, BASE.format(n=n)))
|
| 222 |
+
dest = RAW / f"enf{n:02d}.pdf"
|
| 223 |
+
try:
|
| 224 |
+
# The ATIP host is an ordinary WordPress site; canada.ca needs
|
| 225 |
+
# the PowerShell TLS workaround.
|
| 226 |
+
fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0)
|
| 227 |
+
except Exception as exc:
|
| 228 |
+
skipped.append(n)
|
| 229 |
+
if not is_missing_upstream(exc):
|
| 230 |
+
# Not a 404: the chapter may well still be published, so it
|
| 231 |
+
# counts as a failure and its stored chunks are preserved.
|
| 232 |
+
failures.append((n, f"fetch failed: {type(exc).__name__}: {exc}"))
|
| 233 |
+
if dest.exists():
|
| 234 |
+
dest.unlink() # don't cache an error page as a PDF
|
| 235 |
+
continue
|
| 236 |
+
try:
|
| 237 |
+
pages = _pdf_text(dest)
|
| 238 |
+
if sum(len(p) for p in pages) < 500:
|
| 239 |
+
# Image-scan PDF: fall back to the OCR cache (form-feed
|
| 240 |
+
# separated pages), produced offline via Windows OCR.
|
| 241 |
+
ocr = RAW / f"enf{n:02d}-ocr.txt"
|
| 242 |
+
if ocr.exists():
|
| 243 |
+
pages = ocr.read_text(encoding="utf-8").split("\f")
|
| 244 |
+
print(f" ENF {n:2d}: using OCR cache "
|
| 245 |
+
f"({sum(len(p) for p in pages)} chars)")
|
| 246 |
+
else:
|
| 247 |
+
print(f" !! ENF {n}: no text layer and no OCR cache "
|
| 248 |
+
f"({ocr.name}); skipping")
|
| 249 |
+
failures.append((n, f"no text layer and no {ocr.name}"))
|
| 250 |
+
continue
|
| 251 |
+
chunks = _chunks_for(n, pages, url)
|
| 252 |
+
if not chunks:
|
| 253 |
+
# Fetched fine, parsed to nothing: the PDF's heading structure
|
| 254 |
+
# changed under us. That is the shape the D-memo loss took, so
|
| 255 |
+
# treat it as a failure and keep the stored chapter.
|
| 256 |
+
print(f" !! ENF {n}: no chunks parsed; keeping stored copy")
|
| 257 |
+
failures.append((n, "no content parsed"))
|
| 258 |
+
continue
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
| 261 |
+
failures.append((n, f"parse failed: {type(exc).__name__}: {exc}"))
|
| 262 |
+
continue
|
| 263 |
+
if unofficial:
|
| 264 |
+
for c in chunks:
|
| 265 |
+
c["act_name"] = f"ENF {n} — {unofficial['title']}"
|
| 266 |
+
c["text"] = _UNOFFICIAL_BANNER + c["text"]
|
| 267 |
+
c["citation"] += " (unofficial ATIP copy)"
|
| 268 |
+
c["history"] = unofficial["note"]
|
| 269 |
+
print(f" ENF {n:2d}: {len(chunks)} chunks"
|
| 270 |
+
+ (" [UNOFFICIAL ATIP]" if unofficial else ""))
|
| 271 |
+
all_chunks.extend(chunks)
|
| 272 |
+
uniquify_ids(all_chunks)
|
| 273 |
+
out = PROCESSED_DIR / "enf.json"
|
| 274 |
+
# Preserved chunks keep the ids they were stored with, so they are added
|
| 275 |
+
# after uniquify_ids -- ids embed the chapter number, and a preserved
|
| 276 |
+
# chapter is by definition absent from this run's chunks.
|
| 277 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 278 |
+
stored_chunks(out))
|
| 279 |
+
if preserved:
|
| 280 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s) "
|
| 281 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 282 |
+
if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
|
| 283 |
+
return False
|
| 284 |
+
print(f"enf: {len(all_chunks)} chunks -> {out.name} "
|
| 285 |
+
f"(chapters absent upstream: {skipped})")
|
| 286 |
+
return all_chunks
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def main():
|
| 290 |
+
chunks = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 291 |
+
sys.exit(1 if chunks is False else 0)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
if __name__ == "__main__":
|
| 295 |
+
main()
|
canlex/eval.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure CanLex retrieval quality against a curated question set.
|
| 2 |
+
|
| 3 |
+
Each item in data/eval/questions.json pairs a realistic legal question with the
|
| 4 |
+
provision(s) or case(s) that answer it. This runs every question through the
|
| 5 |
+
retrieval index and reports Hit@k and MRR. Re-run it after any retrieval change
|
| 6 |
+
-- a new reranker, different embeddings, a chunking tweak -- to see whether
|
| 7 |
+
quality moved, and read the "Misses" list to see exactly what to fix.
|
| 8 |
+
|
| 9 |
+
Question schema (per item):
|
| 10 |
+
query the question text
|
| 11 |
+
answers [[act_short, section], ...]; empty section matches any chunk
|
| 12 |
+
style optional tag ('standard' when absent): 'fact-pattern',
|
| 13 |
+
'keyword', 'typo', 'french', 'no-answer', ... -- metrics are
|
| 14 |
+
reported per style so a hard new slice cannot silently drag
|
| 15 |
+
the comparable legacy numbers
|
| 16 |
+
holdout optional bool; holdout questions are excluded from parameter
|
| 17 |
+
sweeps (sweep.py) so knob choices cannot fit their noise --
|
| 18 |
+
eval.py reports them separately
|
| 19 |
+
'no-answer' style items carry no answers: they exist to calibrate the
|
| 20 |
+
confidence signal (a good corpus-coverage score should be LOW on them),
|
| 21 |
+
reported as a separate table, never as misses.
|
| 22 |
+
|
| 23 |
+
py -m canlex.eval
|
| 24 |
+
"""
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import sys
|
| 28 |
+
from collections import defaultdict
|
| 29 |
+
|
| 30 |
+
from .config import ROOT
|
| 31 |
+
from .index import LegislationIndex
|
| 32 |
+
|
| 33 |
+
QUESTIONS = ROOT / "data" / "eval" / "questions.json"
|
| 34 |
+
EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
|
| 35 |
+
|
| 36 |
+
# The historical baseline slice: styles that existed before the 2026-07
|
| 37 |
+
# expansion. Numbers over this slice stay comparable across commits.
|
| 38 |
+
_LEGACY_STYLES = {"standard"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_questions():
|
| 42 |
+
"""The question set with defaults applied; None if the file is absent."""
|
| 43 |
+
if not QUESTIONS.exists():
|
| 44 |
+
return None
|
| 45 |
+
items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
|
| 46 |
+
for item in items:
|
| 47 |
+
item.setdefault("style", "standard")
|
| 48 |
+
item.setdefault("holdout", False)
|
| 49 |
+
return items
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def sweep_questions():
|
| 53 |
+
"""The slice parameter sweeps may see (sweep.py calls this): everything
|
| 54 |
+
except holdout items and the no-answer calibration set."""
|
| 55 |
+
items = load_questions() or []
|
| 56 |
+
return [i for i in items
|
| 57 |
+
if not i["holdout"] and i["style"] != "no-answer"]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _matches(result, answers):
|
| 61 |
+
"""True if a search result is one of the gold answers (act + section).
|
| 62 |
+
|
| 63 |
+
A gold answer is [act, section]; an empty section matches any chunk of that
|
| 64 |
+
act/case (used for case-law answers, whose chunks carry no section number).
|
| 65 |
+
"""
|
| 66 |
+
r_acts = {result.get("act_short", "").lower(),
|
| 67 |
+
result.get("act_code", "").lower()}
|
| 68 |
+
r_sec = result.get("section", "")
|
| 69 |
+
for act, section in answers:
|
| 70 |
+
if act.lower() in r_acts and (section == r_sec or section == ""):
|
| 71 |
+
return True
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _metrics(ranks):
|
| 76 |
+
n = len(ranks) or 1
|
| 77 |
+
hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
|
| 78 |
+
mrr = sum(1.0 / r for r in ranks if r) / n
|
| 79 |
+
return hit(1), hit(3), hit(5), hit(10), mrr
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _report_line(label, ranks):
|
| 83 |
+
h1, h3, h5, h10, mrr = _metrics(ranks)
|
| 84 |
+
print(f" {label:24} n={len(ranks):<4} Hit@1 {h1:.2f} Hit@3 {h3:.2f} "
|
| 85 |
+
f"Hit@5 {h5:.2f} Hit@10 {h10:.2f} MRR {mrr:.2f}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def run():
|
| 89 |
+
items = load_questions()
|
| 90 |
+
if items is None:
|
| 91 |
+
print(f"No question set at {QUESTIONS}.", file=sys.stderr)
|
| 92 |
+
return
|
| 93 |
+
# Sweep mode (CANLEX_EVAL_SWEEP=1, set by sweep.py): score only the
|
| 94 |
+
# sweep-visible slice and print the legacy vertical metric block its
|
| 95 |
+
# parser expects -- holdout questions stay blind to every knob choice.
|
| 96 |
+
sweep_mode = bool(os.environ.get("CANLEX_EVAL_SWEEP"))
|
| 97 |
+
if sweep_mode:
|
| 98 |
+
items = [i for i in items
|
| 99 |
+
if not i["holdout"] and i["style"] != "no-answer"]
|
| 100 |
+
index = LegislationIndex()
|
| 101 |
+
ranks = [] # (item, rank); rank 0 = miss
|
| 102 |
+
misses = []
|
| 103 |
+
no_answer = [] # (query, top confidence) for the calibration table
|
| 104 |
+
for item in items:
|
| 105 |
+
if item["style"] == "no-answer":
|
| 106 |
+
results = index.search(item["query"], top_k=3)
|
| 107 |
+
conf = results[0].get("confidence") if results else None
|
| 108 |
+
no_answer.append((item["query"], conf))
|
| 109 |
+
continue
|
| 110 |
+
answers = [tuple(a) for a in item["answers"]]
|
| 111 |
+
results = index.search(item["query"], top_k=EVAL_TOP_K)
|
| 112 |
+
rank = 0
|
| 113 |
+
for i, result in enumerate(results, start=1):
|
| 114 |
+
if _matches(result, answers):
|
| 115 |
+
rank = i
|
| 116 |
+
break
|
| 117 |
+
ranks.append((item, rank))
|
| 118 |
+
if rank == 0 or rank > 5:
|
| 119 |
+
top = results[0] if results else None
|
| 120 |
+
misses.append((item, answers, rank, top))
|
| 121 |
+
|
| 122 |
+
all_ranks = [r for _, r in ranks]
|
| 123 |
+
if sweep_mode:
|
| 124 |
+
h1, h3, h5, h10, mrr = _metrics(all_ranks)
|
| 125 |
+
print(f"CanLex retrieval evaluation -- {len(all_ranks)} questions "
|
| 126 |
+
f"(sweep slice)\n")
|
| 127 |
+
print(f" Hit@1: {h1:.2f}")
|
| 128 |
+
print(f" Hit@3: {h3:.2f}")
|
| 129 |
+
print(f" Hit@5: {h5:.2f}")
|
| 130 |
+
print(f" Hit@10: {h10:.2f}")
|
| 131 |
+
print(f" MRR: {mrr:.2f}")
|
| 132 |
+
return
|
| 133 |
+
legacy = [r for it, r in ranks if it["style"] in _LEGACY_STYLES]
|
| 134 |
+
held = [r for it, r in ranks if it["holdout"]]
|
| 135 |
+
swept = [r for it, r in ranks if not it["holdout"]]
|
| 136 |
+
by_style = defaultdict(list)
|
| 137 |
+
for it, r in ranks:
|
| 138 |
+
by_style[it["style"]].append(r)
|
| 139 |
+
|
| 140 |
+
print(f"CanLex retrieval evaluation -- {len(all_ranks)} scored questions\n")
|
| 141 |
+
_report_line("ALL", all_ranks)
|
| 142 |
+
_report_line("legacy (comparable)", legacy)
|
| 143 |
+
for style in sorted(by_style):
|
| 144 |
+
if style not in _LEGACY_STYLES:
|
| 145 |
+
_report_line(f"style: {style}", by_style[style])
|
| 146 |
+
if held:
|
| 147 |
+
_report_line("holdout (sweep-blind)", held)
|
| 148 |
+
_report_line("sweep-visible", swept)
|
| 149 |
+
|
| 150 |
+
if no_answer:
|
| 151 |
+
print("\nNo-answer calibration (confidence should be LOW here; the "
|
| 152 |
+
"weak-match hedge fires below 0.72):")
|
| 153 |
+
for query, conf in no_answer:
|
| 154 |
+
shown = f"{conf:.3f}" if conf is not None else "n/a"
|
| 155 |
+
flag = "" if (conf is None or conf < 0.72) else " <-- OVER-CONFIDENT"
|
| 156 |
+
print(f" {shown} {query[:70]}{flag}")
|
| 157 |
+
|
| 158 |
+
if misses:
|
| 159 |
+
print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
|
| 160 |
+
for item, answers, rank, top in misses:
|
| 161 |
+
gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
|
| 162 |
+
where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
|
| 163 |
+
got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
|
| 164 |
+
if top else "nothing")
|
| 165 |
+
print(f" [{where}] ({item['style']}) {item['query']}")
|
| 166 |
+
print(f" gold: {gold} | top result: {got}")
|
| 167 |
+
print()
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
run()
|
canlex/index.py
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid retrieval (BM25 + semantic) with cross-encoder reranking."""
|
| 2 |
+
import json
|
| 3 |
+
import math
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
import unicodedata
|
| 8 |
+
from collections import Counter, defaultdict
|
| 9 |
+
|
| 10 |
+
import snowballstemmer
|
| 11 |
+
|
| 12 |
+
from .config import PROCESSED_DIR
|
| 13 |
+
from .synonyms import expand_query
|
| 14 |
+
|
| 15 |
+
K1 = 1.5
|
| 16 |
+
B = 0.75
|
| 17 |
+
RRF_K = 60 # reciprocal-rank-fusion damping constant
|
| 18 |
+
W_SEM = 2.0 # weight on the semantic retriever in the fusion (1.0 = equal; eval-tuned)
|
| 19 |
+
CANDIDATES = 80 # hits each retriever contributes to the fusion
|
| 20 |
+
RERANK_POOL = int(os.environ.get("CANLEX_RERANK_POOL", "16"))
|
| 21 |
+
# top fused candidates the cross-encoder rescores. Profiled
|
| 22 |
+
# at ~96% of query latency (the pool is cross-encoded on
|
| 23 |
+
# CPU), so this is THE latency knob; env-sweepable, and any
|
| 24 |
+
# change must be eval-gated (a smaller pool limits which
|
| 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)
|
| 31 |
+
MN_CAP = float(os.environ.get("CANLEX_MN_CAP", "0.012"))
|
| 32 |
+
# ceiling on the title-match boost -- it nudges the ranking
|
| 33 |
+
# without overriding a strong base score
|
| 34 |
+
REG_PENALTY = float(os.environ.get("CANLEX_REG_PENALTY", "0.004"))
|
| 35 |
+
# small fusion penalty on regulation sections, so the Act
|
| 36 |
+
# that creates a duty outranks the regulation elaborating it
|
| 37 |
+
# (sweep-tuned 2026-05-23 from 0.008 -> 0.004; see sweep.log)
|
| 38 |
+
NIF_PENALTY = float(os.environ.get("CANLEX_NIF_PENALTY", "0.012"))
|
| 39 |
+
# fusion penalty on not-in-force amendment chunks, so
|
| 40 |
+
# pending law never outranks the live provision it amends
|
| 41 |
+
REPEALED_PENALTY = float(os.environ.get("CANLEX_REPEALED_PENALTY", "0.008"))
|
| 42 |
+
# likewise for repealed provisions (kept so lookups still
|
| 43 |
+
# inform): a repealed section's on-topic title must not
|
| 44 |
+
# beat live law in a close contest -- the CDSA-to-Cannabis
|
| 45 |
+
# -Act migration is the live risk zone
|
| 46 |
+
BACKMATTER_PENALTY = float(os.environ.get("CANLEX_BACKMATTER_PENALTY", "0.004"))
|
| 47 |
+
# likewise for a collective agreement's back-matter
|
| 48 |
+
# (memoranda, letters of understanding) vs its numbered articles
|
| 49 |
+
# (sweep-tuned 2026-05-23 from 0.008 -> 0.004)
|
| 50 |
+
SOURCE_CAP = 2 # max chunks one case or memorandum may contribute
|
| 51 |
+
RECALL_CAP = int(os.environ.get("CANLEX_RECALL_CAP", "20"))
|
| 52 |
+
# max chunks one capped source (anything _source_key keys:
|
| 53 |
+
# a decision, a memorandum) may contribute to EACH
|
| 54 |
+
# retriever's CANDIDATES slice at the recall stage. Without
|
| 55 |
+
# it, a deep decision whose every paragraph matches a
|
| 56 |
+
# specific query (Canfield: 94 paragraphs on device border
|
| 57 |
+
# searches) fills the entire fusion pool, so the result-
|
| 58 |
+
# stage SOURCE_CAP defers its surplus with nothing left to
|
| 59 |
+
# promote and _ensure_primary finds no statute to pull in.
|
| 60 |
+
# Generous by design: SOURCE_CAP=2 governs display; this
|
| 61 |
+
# only has to leave room for other sources to survive
|
| 62 |
+
# recall. Uncapped sources (_source_key None) are unlimited.
|
| 63 |
+
# Swept 2026-07-10 over {12,16,20,24} on the 159-Q eval:
|
| 64 |
+
# 12 and 20 tie at 0.80/0.94/0.97/0.99/0.87 (4 misses),
|
| 65 |
+
# both above the uncapped baseline; 20 chosen as the
|
| 66 |
+
# lightest-touch winner (24 regresses Hit@3/@5).
|
| 67 |
+
APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
|
| 68 |
+
|
| 69 |
+
# Primary instruments -- enacted law, collective agreements, the NJC directives
|
| 70 |
+
# incorporated into them, and the IRPA delegation instruments. Their sections or
|
| 71 |
+
# items are distinct provisions, so (like legislation) they are never collapsed
|
| 72 |
+
# under the diversity cap.
|
| 73 |
+
PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
|
| 74 |
+
# Memorandum families whose act_code is ONE shared constant over many
|
| 75 |
+
# independent items, so the diversity cap keys on `section` instead (see
|
| 76 |
+
# _source_key): a D-memo number, an AMPS contravention code.
|
| 77 |
+
_FLAT_MEMO_CODES = frozenset({"D-Memo", "AMPS"})
|
| 78 |
+
|
| 79 |
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
| 80 |
+
# A chunk id ending '-p<k>' marks one embeddable piece of a long section that
|
| 81 |
+
# ingest.py split (see _split_text); same-section pieces are capped together.
|
| 82 |
+
_PIECE_ID = re.compile(r"-p\d+$")
|
| 83 |
+
_SECTION_REF = re.compile(r"\bs(?:ec(?:tion)?)?s?\.?\s*(\d+(?:\.\d+)?)")
|
| 84 |
+
# A cross-reference to another provision -- "section 34", "subsection 25(1)",
|
| 85 |
+
# "paragraph 36(1)(a)", "s. 34" -- capturing the top-level section number.
|
| 86 |
+
_XREF = re.compile(
|
| 87 |
+
r"\b(?:sections?|subsections?|paragraphs?|ss?\.)\s*(\d+(?:\.\d+)?)",
|
| 88 |
+
re.IGNORECASE)
|
| 89 |
+
|
| 90 |
+
# A line opening with "(1)", "(a)" or "(b.1)" -- a citable subdivision
|
| 91 |
+
# (subsection, paragraph or subparagraph) of a provision.
|
| 92 |
+
_MARKER = re.compile(r"(?m)^\(([0-9a-zA-Z]+(?:\.\d+)?)\)")
|
| 93 |
+
|
| 94 |
+
# A D-memorandum's reference to a provision -- "section 32 of the Customs Act",
|
| 95 |
+
# or "section 32 of the Act" (the Act a D-memo administers -- the Customs Act).
|
| 96 |
+
_MEMO_CITE = re.compile(
|
| 97 |
+
r"\b(?:sub)?sections?\s+(\d+(?:\.\d+)?)(?:\([^)]+\))*\s+of\s+the\s+"
|
| 98 |
+
r"(Customs Act|Customs Tariff|Act)\b", re.IGNORECASE)
|
| 99 |
+
|
| 100 |
+
# A directive section's reference to an appendix of the same directive --
|
| 101 |
+
# "as specified in Appendix C". A trailing "of" ("Appendix C of the NJC Travel
|
| 102 |
+
# Directive") marks a cross-directive citation and is deliberately left alone.
|
| 103 |
+
_APPENDIX_REF = re.compile(r"\bAppendi(?:x|ces)\s+([A-Za-z])\b(?!\s+of\b)",
|
| 104 |
+
re.IGNORECASE)
|
| 105 |
+
# A directive chunk that *is* an appendix: its title opens "Appendix C ...".
|
| 106 |
+
_APPENDIX_HEAD = re.compile(r"Appendix\s+([A-Za-z])\b", re.IGNORECASE)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
_STEMMER = snowballstemmer.stemmer("english")
|
| 110 |
+
_STEM_CACHE = {}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# Stem pairs Snowball does not merge but that share a legal meaning, so a
|
| 114 |
+
# query naming the verb still matches a provision titled with the noun (or
|
| 115 |
+
# vice versa). Mapped to the verb form on both index and query sides, which
|
| 116 |
+
# is consistent and arbitrary -- the merge is what matters.
|
| 117 |
+
_STEM_NORMALIZE = {
|
| 118 |
+
"seizur": "seiz", # seizure -> seize
|
| 119 |
+
"forfeitur": "forfeit", # forfeiture -> forfeit
|
| 120 |
+
"appel": "appeal", # appellate/appellant -> appeal
|
| 121 |
+
"detent": "detain", # detention -> detain
|
| 122 |
+
"exclus": "exclud", # exclusion -> exclude
|
| 123 |
+
"admiss": "admit", # admission/admissibility -> admit
|
| 124 |
+
"applic": "appli", # application -> apply
|
| 125 |
+
"complianc": "compli", # compliance -> comply
|
| 126 |
+
"grievanc": "griev", # grievance -> grieve
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _stem(word):
|
| 131 |
+
"""Snowball-stem a word, memoised -- legal text repeats terms heavily.
|
| 132 |
+
A small post-stem normalization merges a few verb/noun pairs Snowball
|
| 133 |
+
leaves apart ('seize'/'seizure', 'forfeit'/'forfeiture')."""
|
| 134 |
+
stemmed = _STEM_CACHE.get(word)
|
| 135 |
+
if stemmed is None:
|
| 136 |
+
stemmed = _STEMMER.stemWord(word)
|
| 137 |
+
stemmed = _STEM_NORMALIZE.get(stemmed, stemmed)
|
| 138 |
+
_STEM_CACHE[word] = stemmed
|
| 139 |
+
return stemmed
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# Query-language detection for the bilingual corpus: accented characters or
|
| 143 |
+
# two-plus French function words mark a French query. French queries may draw
|
| 144 |
+
# on BOTH languages (guidance and case law exist only in English); English
|
| 145 |
+
# queries never surface French chunks (the English corpus is complete, and a
|
| 146 |
+
# French twin would only duplicate results).
|
| 147 |
+
_FR_HINT = re.compile(r"[àâçéèêëîïôùûüÿœæ]", re.IGNORECASE)
|
| 148 |
+
# ASCII-only entries: tokens are matched pre-folding with [a-zA-Z']+, and any
|
| 149 |
+
# accented query already short-circuits through _FR_HINT above.
|
| 150 |
+
_FR_WORDS = frozenset(
|
| 151 |
+
"le la les de des une du et est dans pour que qui aux au sur par ne pas "
|
| 152 |
+
"peut doit selon lors quelle quel cette ces son ses leur d'un d'une "
|
| 153 |
+
"interdiction fouille saisie renvoi territoire".split())
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def query_lang(query):
|
| 157 |
+
"""'fr' when the query reads as French, else 'en'."""
|
| 158 |
+
if _FR_HINT.search(query):
|
| 159 |
+
return "fr"
|
| 160 |
+
tokens = re.findall(r"[a-zA-Z']+", query.lower())
|
| 161 |
+
return "fr" if sum(1 for t in tokens if t in _FR_WORDS) >= 2 else "en"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _fold_accents(text):
|
| 165 |
+
"""ASCII-fold accented characters so a French or mixed query tokenizes
|
| 166 |
+
usefully instead of shattering: the [a-z0-9] token pattern used to split
|
| 167 |
+
'détention' into ['d','tention']. NFKD + ASCII-encode drops the combining
|
| 168 |
+
marks ('détention' -> 'detention'), which both matches the English corpus
|
| 169 |
+
directly and lets the FR->EN synonym bridges fire."""
|
| 170 |
+
return unicodedata.normalize("NFKD", text).encode(
|
| 171 |
+
"ascii", "ignore").decode("ascii")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def tokenize(text):
|
| 175 |
+
"""Lower-case, accent-fold, split on word characters, and Snowball-stem
|
| 176 |
+
each token, so a query matches a provision even when their word forms
|
| 177 |
+
differ -- 'possession' vs 'possess', 'reporting' vs 'report',
|
| 178 |
+
'importation' vs 'import', 'détention' vs 'detention'."""
|
| 179 |
+
return [_stem(w) for w in _TOKEN.findall(_fold_accents(text.lower()))]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _section_refs(query):
|
| 183 |
+
"""Pull explicit section numbers from a query, e.g. 'section 34', 's. 20.1'."""
|
| 184 |
+
return set(_SECTION_REF.findall(query.lower()))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def topical_title(chunk):
|
| 188 |
+
"""Return the chunk's topic-bearing string, used wherever a section's
|
| 189 |
+
'title' is weighted for retrieval -- BM25 indexing, the title-match boost,
|
| 190 |
+
and the semantic embedding. Differs by doc_type because the field that
|
| 191 |
+
carries the topic differs: legislation/agreement/directive/delegation use
|
| 192 |
+
the marginal_note (section heading); D-memoranda use 'part' because their
|
| 193 |
+
marginal_note is a generic banner; case-law uses 'heading' because its
|
| 194 |
+
marginal_note is just the paragraph range ('paras 11-13') and the case
|
| 195 |
+
proposition lives in heading."""
|
| 196 |
+
doc_type = chunk.get("doc_type")
|
| 197 |
+
if doc_type == "memorandum":
|
| 198 |
+
return chunk.get("part") or chunk["marginal_note"]
|
| 199 |
+
if doc_type == "caselaw":
|
| 200 |
+
return chunk.get("heading") or chunk["marginal_note"]
|
| 201 |
+
return chunk["marginal_note"]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _provision_units(text):
|
| 205 |
+
"""Citable parts of a provision, for pinpoint scoring -- a list of
|
| 206 |
+
(citation_suffix, scoring_text, snippet). One entry per paragraph, with its
|
| 207 |
+
subsection chapeau prepended to scoring_text for context, plus one per
|
| 208 |
+
paragraph-less subsection. Returns [] when the provision is too flat to
|
| 209 |
+
pinpoint (fewer than two subdivisions)."""
|
| 210 |
+
marks = list(_MARKER.finditer(text))
|
| 211 |
+
if len(marks) < 2:
|
| 212 |
+
return []
|
| 213 |
+
spans = []
|
| 214 |
+
for i, m in enumerate(marks):
|
| 215 |
+
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
|
| 216 |
+
spans.append((m.group(1), text[m.start():end].strip()))
|
| 217 |
+
units, cur_sub, cur_intro = [], "", ""
|
| 218 |
+
for j, (token, body) in enumerate(spans):
|
| 219 |
+
if "[Repealed" in body[:40]:
|
| 220 |
+
if token[0].isdigit():
|
| 221 |
+
cur_sub, cur_intro = f"({token})", ""
|
| 222 |
+
continue
|
| 223 |
+
if token[0].isdigit():
|
| 224 |
+
cur_sub, cur_intro = f"({token})", body
|
| 225 |
+
nxt = spans[j + 1][0] if j + 1 < len(spans) else ""
|
| 226 |
+
if not nxt or nxt[0].isdigit():
|
| 227 |
+
units.append((cur_sub, body, body)) # subsection has no paragraphs
|
| 228 |
+
# otherwise the chapeau is emitted via its paragraphs below
|
| 229 |
+
else:
|
| 230 |
+
label = f"{cur_sub}({token})" if cur_sub else f"({token})"
|
| 231 |
+
units.append((label, f"{cur_intro} {body}".strip(), body))
|
| 232 |
+
return units
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class LegislationIndex:
|
| 236 |
+
def __init__(self):
|
| 237 |
+
self.chunks = []
|
| 238 |
+
for path in sorted(PROCESSED_DIR.glob("*.json")):
|
| 239 |
+
self.chunks.extend(json.loads(path.read_text(encoding="utf-8")))
|
| 240 |
+
if not self.chunks:
|
| 241 |
+
raise RuntimeError(
|
| 242 |
+
f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 243 |
+
self._tri_index = None # lazy; see _fuzzy_term
|
| 244 |
+
self._word_vocab = None # lazy; see _correct_word (typo repair)
|
| 245 |
+
self._word_tri = None
|
| 246 |
+
# Cached recall mask for the common case (English query, no
|
| 247 |
+
# filters): everything but the French twins.
|
| 248 |
+
self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
|
| 249 |
+
self._build_bm25()
|
| 250 |
+
self._build_note_tokens()
|
| 251 |
+
self._build_xref()
|
| 252 |
+
self._build_appendix_index()
|
| 253 |
+
self._load_semantic()
|
| 254 |
+
self._load_reranker()
|
| 255 |
+
|
| 256 |
+
def _build_bm25(self):
|
| 257 |
+
"""BM25 postings plus TWO sets of collection statistics: one over the
|
| 258 |
+
whole corpus, one over the English scope alone.
|
| 259 |
+
|
| 260 |
+
An English query never retrieves the French twins (see _en_mask), yet
|
| 261 |
+
a single corpus-wide idf/avgdl let those unreachable chunks set the
|
| 262 |
+
rarity of every shared token (numbers, act codes, proper nouns, folded
|
| 263 |
+
cognates) and drag the average document length. Ingesting the French
|
| 264 |
+
D-memos and NJC directives moved legacy Hit@1 0.767 -> 0.750 that way,
|
| 265 |
+
without a single French chunk ever being retrievable -- the statistics
|
| 266 |
+
must be scoped to what the query can actually reach.
|
| 267 |
+
"""
|
| 268 |
+
self.doc_len = []
|
| 269 |
+
self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
|
| 270 |
+
df = defaultdict(int)
|
| 271 |
+
df_en = defaultdict(int)
|
| 272 |
+
en_docs = en_total_len = 0
|
| 273 |
+
for idx, c in enumerate(self.chunks):
|
| 274 |
+
# The topical title is repeated to weight it above body text;
|
| 275 |
+
# the Act name, code and section are indexed too, so an Act's own
|
| 276 |
+
# terminology (e.g. "controlled substance") and its codes/numbers
|
| 277 |
+
# are searchable even when a section's text omits them. The title
|
| 278 |
+
# is doc_type-aware via topical_title -- for case-law it picks
|
| 279 |
+
# the case proposition (heading), not the paragraph range
|
| 280 |
+
# (marginal_note), so a leading case surfaces on a topical query.
|
| 281 |
+
title = topical_title(c)
|
| 282 |
+
blob = " ".join((title, title, c["heading"],
|
| 283 |
+
c["part"], c["division"], c["act_name"], c["act_code"],
|
| 284 |
+
c["section"], c["text"]))
|
| 285 |
+
counts = Counter(tokenize(blob))
|
| 286 |
+
self.doc_len.append(sum(counts.values()))
|
| 287 |
+
is_en = c.get("lang", "en") != "fr"
|
| 288 |
+
if is_en:
|
| 289 |
+
en_docs += 1
|
| 290 |
+
en_total_len += self.doc_len[-1]
|
| 291 |
+
for term, tf in counts.items():
|
| 292 |
+
self.postings[term].append((idx, tf))
|
| 293 |
+
df[term] += 1
|
| 294 |
+
if is_en:
|
| 295 |
+
df_en[term] += 1
|
| 296 |
+
n = len(self.chunks)
|
| 297 |
+
self.avgdl = sum(self.doc_len) / n
|
| 298 |
+
self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
|
| 299 |
+
# The English-scoped twins. A term the English corpus never uses is
|
| 300 |
+
# absent from idf_en rather than zero-weighted: it can only match
|
| 301 |
+
# chunks the mask already excludes.
|
| 302 |
+
self.avgdl_en = en_total_len / (en_docs or 1)
|
| 303 |
+
self.idf_en = {t: math.log(1 + (en_docs - d + 0.5) / (d + 0.5))
|
| 304 |
+
for t, d in df_en.items()}
|
| 305 |
+
|
| 306 |
+
def _build_note_tokens(self):
|
| 307 |
+
"""Pre-tokenise each chunk's topical title (see topical_title) for the
|
| 308 |
+
title-match boost in search(). Each chunk is also flagged as a
|
| 309 |
+
regulation (act codes beginning SOR/C.R.C.) for the Act-over-regulation
|
| 310 |
+
preference, as collective-agreement back-matter (memoranda and letters
|
| 311 |
+
with no article number) for the back-matter penalty, and as a
|
| 312 |
+
not-in-force amendment for the pending-law penalty."""
|
| 313 |
+
self._note_tokens = []
|
| 314 |
+
self._is_regulation = []
|
| 315 |
+
self._is_backmatter = []
|
| 316 |
+
self._is_nif = []
|
| 317 |
+
self._is_repealed = []
|
| 318 |
+
for c in self.chunks:
|
| 319 |
+
self._note_tokens.append(set(tokenize(topical_title(c))))
|
| 320 |
+
self._is_regulation.append(
|
| 321 |
+
c.get("doc_type", "legislation") == "legislation"
|
| 322 |
+
and c["act_code"].startswith(("SOR", "C.R.C")))
|
| 323 |
+
self._is_backmatter.append(
|
| 324 |
+
c.get("doc_type") == "agreement"
|
| 325 |
+
and not str(c["section"])[:1].isdigit())
|
| 326 |
+
self._is_nif.append(c.get("status") == "not-in-force")
|
| 327 |
+
self._is_repealed.append(c.get("status") == "repealed")
|
| 328 |
+
|
| 329 |
+
def _build_appendix_index(self):
|
| 330 |
+
"""Index directive appendices by (act_code, letter), so a directive
|
| 331 |
+
section that cites 'Appendix C' can pull that appendix into the result
|
| 332 |
+
set -- a directive's rate-table appendices are bare numbers and rank
|
| 333 |
+
poorly on a natural-language query, yet the section citing them is of
|
| 334 |
+
little use without them."""
|
| 335 |
+
self._appendix = defaultdict(list)
|
| 336 |
+
for idx, c in enumerate(self.chunks):
|
| 337 |
+
if c.get("doc_type") != "directive":
|
| 338 |
+
continue
|
| 339 |
+
m = _APPENDIX_HEAD.match(c["marginal_note"])
|
| 340 |
+
if m:
|
| 341 |
+
self._appendix[(c["act_code"], m.group(1).upper())].append(idx)
|
| 342 |
+
|
| 343 |
+
def _load_semantic(self):
|
| 344 |
+
"""Load precomputed embeddings and the query embedder.
|
| 345 |
+
|
| 346 |
+
Any failure (missing embeddings, or numpy/model unavailable) degrades the
|
| 347 |
+
index to BM25-only rather than breaking retrieval.
|
| 348 |
+
"""
|
| 349 |
+
self.semantic = False
|
| 350 |
+
emb_path = PROCESSED_DIR / "embeddings.npz"
|
| 351 |
+
if not emb_path.exists():
|
| 352 |
+
print("CanLex index: no embeddings.npz; using BM25 only "
|
| 353 |
+
"(run 'canlex.embed' to enable semantic search).", file=sys.stderr)
|
| 354 |
+
return
|
| 355 |
+
try:
|
| 356 |
+
import numpy as np
|
| 357 |
+
from .embed import Embedder
|
| 358 |
+
# Multi-vector layout: rows share a chunk id when the chunk was
|
| 359 |
+
# embedded as several windows (long D-memo sections etc.); a
|
| 360 |
+
# chunk's semantic score is the max over its windows. Rows whose
|
| 361 |
+
# id matches no loaded chunk are dropped; chunks with no rows
|
| 362 |
+
# count as missing (BM25-only for them).
|
| 363 |
+
chunk_ordinal = {c["id"]: i for i, c in enumerate(self.chunks)}
|
| 364 |
+
with np.load(emb_path) as data:
|
| 365 |
+
all_ids = data["ids"].tolist()
|
| 366 |
+
all_vecs = data["vectors"]
|
| 367 |
+
keep = [(chunk_ordinal[i], r) for r, i in enumerate(all_ids)
|
| 368 |
+
if i in chunk_ordinal]
|
| 369 |
+
self._np = np
|
| 370 |
+
self.vectors = all_vecs[[r for _, r in keep]]
|
| 371 |
+
self._row_chunk = np.array([o for o, _ in keep], dtype=np.int64)
|
| 372 |
+
covered = set(self._row_chunk.tolist())
|
| 373 |
+
missing = len(self.chunks) - len(covered)
|
| 374 |
+
self.embedder = Embedder()
|
| 375 |
+
self.semantic = True
|
| 376 |
+
if missing:
|
| 377 |
+
print(f"CanLex index: {missing}/{len(self.chunks)} sections lack "
|
| 378 |
+
f"embeddings; re-run 'canlex.embed' to refresh.", file=sys.stderr)
|
| 379 |
+
except Exception as exc:
|
| 380 |
+
print(f"CanLex index: semantic search disabled ({type(exc).__name__}: "
|
| 381 |
+
f"{exc}); using BM25 only.", file=sys.stderr)
|
| 382 |
+
self.semantic = False
|
| 383 |
+
|
| 384 |
+
def _load_reranker(self):
|
| 385 |
+
"""Load the cross-encoder reranker; degrade to the fusion order on failure."""
|
| 386 |
+
self.reranker = None
|
| 387 |
+
try:
|
| 388 |
+
from .rerank import Reranker
|
| 389 |
+
self.reranker = Reranker()
|
| 390 |
+
except Exception as exc:
|
| 391 |
+
print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
|
| 392 |
+
f"using hybrid fusion order.", file=sys.stderr)
|
| 393 |
+
|
| 394 |
+
def _fuzzy_term(self, term):
|
| 395 |
+
"""The closest vocabulary term for a token the corpus has never seen
|
| 396 |
+
-- a typo bridge ('detension' -> 'detention'), scored by character-
|
| 397 |
+
trigram Dice similarity with a strict 0.6 floor and a length guard.
|
| 398 |
+
Only ever consulted for UNKNOWN tokens, so correctly-spelled queries
|
| 399 |
+
are untouched. The trigram index is built lazily on first miss."""
|
| 400 |
+
if len(term) < 4:
|
| 401 |
+
return None
|
| 402 |
+
if self._tri_index is None:
|
| 403 |
+
tri = defaultdict(set)
|
| 404 |
+
for vocab_term in self.idf:
|
| 405 |
+
for i in range(len(vocab_term) - 2):
|
| 406 |
+
tri[vocab_term[i:i + 3]].add(vocab_term)
|
| 407 |
+
self._tri_index = tri
|
| 408 |
+
tris = {term[i:i + 3] for i in range(len(term) - 2)}
|
| 409 |
+
counts = Counter()
|
| 410 |
+
for t in tris:
|
| 411 |
+
counts.update(self._tri_index.get(t, ()))
|
| 412 |
+
best, best_score = None, 0.6
|
| 413 |
+
for cand, n in counts.most_common(50):
|
| 414 |
+
# Typos rarely corrupt a word's opening letters; requiring a
|
| 415 |
+
# shared 2-char prefix kills drift like 'detens' -> 'pretens'
|
| 416 |
+
# while keeping 'seisur' -> 'seizur'.
|
| 417 |
+
if abs(len(cand) - len(term)) > 2 or cand[:2] != term[:2]:
|
| 418 |
+
continue
|
| 419 |
+
score = 2 * n / (len(tris) + len(cand) - 2)
|
| 420 |
+
if score > best_score:
|
| 421 |
+
best, best_score = cand, score
|
| 422 |
+
return best
|
| 423 |
+
|
| 424 |
+
@staticmethod
|
| 425 |
+
def _edit_distance(a, b, cap=3):
|
| 426 |
+
"""Damerau-Levenshtein distance, early-exited past `cap` -- typos
|
| 427 |
+
are substitutions, insertions, deletions and transpositions, and a
|
| 428 |
+
transposition ('undeclraed') must cost 1, not 2."""
|
| 429 |
+
if abs(len(a) - len(b)) > cap:
|
| 430 |
+
return cap + 1
|
| 431 |
+
prev2, prev = None, list(range(len(b) + 1))
|
| 432 |
+
for i, ca in enumerate(a, start=1):
|
| 433 |
+
cur = [i]
|
| 434 |
+
best_row = i
|
| 435 |
+
for j, cb in enumerate(b, start=1):
|
| 436 |
+
cost = 0 if ca == cb else 1
|
| 437 |
+
d = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
| 438 |
+
if (prev2 is not None and i > 1 and j > 1
|
| 439 |
+
and ca == b[j - 2] and a[i - 2] == cb):
|
| 440 |
+
d = min(d, prev2[j - 2] + 1)
|
| 441 |
+
cur.append(d)
|
| 442 |
+
best_row = min(best_row, d)
|
| 443 |
+
if best_row > cap:
|
| 444 |
+
return cap + 1
|
| 445 |
+
prev2, prev = prev, cur
|
| 446 |
+
return prev[-1]
|
| 447 |
+
|
| 448 |
+
def _correct_word(self, word):
|
| 449 |
+
"""Nearest corpus WORD (not stem) for a misspelling, or None.
|
| 450 |
+
|
| 451 |
+
Trigram index generates candidates; Damerau-Levenshtein picks the
|
| 452 |
+
winner (distance <= 2, shared 2-char prefix, frequency floor of 5
|
| 453 |
+
so OCR debris in the corpus can never be a 'correction'). The
|
| 454 |
+
result is real text: it feeds the semantic embedder and the
|
| 455 |
+
cross-encoder, which score words, not stems."""
|
| 456 |
+
if self._word_vocab is None:
|
| 457 |
+
words = Counter()
|
| 458 |
+
for c in self.chunks:
|
| 459 |
+
words.update(re.findall(
|
| 460 |
+
r"[a-z]{4,}", _fold_accents(c["text"].lower())))
|
| 461 |
+
tri = defaultdict(set)
|
| 462 |
+
for w, n in words.items():
|
| 463 |
+
if n >= 5: # junk never enters the index
|
| 464 |
+
for i in range(len(w) - 2):
|
| 465 |
+
tri[w[i:i + 3]].add(w)
|
| 466 |
+
self._word_vocab, self._word_tri = words, tri
|
| 467 |
+
tris = {word[i:i + 3] for i in range(len(word) - 2)}
|
| 468 |
+
candidates = set()
|
| 469 |
+
for t in tris:
|
| 470 |
+
candidates |= self._word_tri.get(t, set())
|
| 471 |
+
best, best_key = None, (3, 0) # (distance, -frequency)
|
| 472 |
+
for cand in candidates:
|
| 473 |
+
if abs(len(cand) - len(word)) > 2 or cand[:2] != word[:2]:
|
| 474 |
+
continue
|
| 475 |
+
dist = self._edit_distance(word, cand, cap=2)
|
| 476 |
+
if dist > 2:
|
| 477 |
+
continue
|
| 478 |
+
key = (dist, -self._word_vocab[cand])
|
| 479 |
+
if key < best_key:
|
| 480 |
+
best, best_key = cand, key
|
| 481 |
+
return best
|
| 482 |
+
|
| 483 |
+
def _correct_query(self, query):
|
| 484 |
+
"""The query with misspelled words replaced by their nearest corpus
|
| 485 |
+
words; the query itself when nothing needed fixing. A word is only
|
| 486 |
+
'misspelled' when its stem is absent from the whole BM25 vocabulary,
|
| 487 |
+
so correctly-spelled queries pass through untouched by
|
| 488 |
+
construction."""
|
| 489 |
+
out, changed = [], False
|
| 490 |
+
for tok in query.split():
|
| 491 |
+
word = _fold_accents(tok.lower()).strip(".,;:!?()'\"")
|
| 492 |
+
if len(word) >= 4 and word.isalpha():
|
| 493 |
+
stem = _stem(word)
|
| 494 |
+
df = len(self.postings.get(stem, ()))
|
| 495 |
+
if df == 0:
|
| 496 |
+
# Unknown everywhere: replace outright.
|
| 497 |
+
fix = self._correct_word(word)
|
| 498 |
+
if fix and fix != word:
|
| 499 |
+
out.append(fix)
|
| 500 |
+
changed = True
|
| 501 |
+
continue
|
| 502 |
+
elif df <= 2:
|
| 503 |
+
# Nearly unknown -- often a typo that happens to collide
|
| 504 |
+
# with OCR debris in the corpus. APPEND a strong
|
| 505 |
+
# correction rather than replacing, so a legitimately
|
| 506 |
+
# rare term (a chemical in a CDSA schedule, a case name)
|
| 507 |
+
# never loses its own recall.
|
| 508 |
+
fix = self._correct_word(word)
|
| 509 |
+
if (fix and fix != word
|
| 510 |
+
and self._word_vocab[fix] >= 100):
|
| 511 |
+
out.append(f"{tok} {fix}")
|
| 512 |
+
changed = True
|
| 513 |
+
continue
|
| 514 |
+
out.append(tok)
|
| 515 |
+
return " ".join(out) if changed else query
|
| 516 |
+
|
| 517 |
+
def _bm25_scores(self, query, allowed=None, en_only=False):
|
| 518 |
+
"""allowed: optional boolean list by chunk index. Filtered searches
|
| 519 |
+
pass it so recall competes only within scope -- filtering after
|
| 520 |
+
recall meant a doc_type='caselaw' search drew from whatever case law
|
| 521 |
+
happened to survive the open-corpus top-N competition.
|
| 522 |
+
|
| 523 |
+
en_only: score with the English-scoped collection statistics (see
|
| 524 |
+
_build_bm25). Set for every English query, whose mask already bars
|
| 525 |
+
the French twins from the results they would otherwise be weighting.
|
| 526 |
+
"""
|
| 527 |
+
idf_map = self.idf_en if en_only else self.idf
|
| 528 |
+
avgdl = self.avgdl_en if en_only else self.avgdl
|
| 529 |
+
scores = defaultdict(float)
|
| 530 |
+
for term in set(tokenize(query)):
|
| 531 |
+
idf = idf_map.get(term)
|
| 532 |
+
weight = 1.0
|
| 533 |
+
if idf is None:
|
| 534 |
+
# Unknown token: try the typo bridge at a discount, so a
|
| 535 |
+
# misspelled query still reaches the provision while a real
|
| 536 |
+
# match always outranks a fuzzy one.
|
| 537 |
+
alt = self._fuzzy_term(term)
|
| 538 |
+
if alt is None or alt not in idf_map:
|
| 539 |
+
continue
|
| 540 |
+
term, idf, weight = alt, idf_map[alt], 0.7
|
| 541 |
+
for idx, tf in self.postings[term]:
|
| 542 |
+
if allowed is not None and not allowed[idx]:
|
| 543 |
+
continue
|
| 544 |
+
dl = self.doc_len[idx]
|
| 545 |
+
denom = tf + K1 * (1 - B + B * dl / avgdl)
|
| 546 |
+
scores[idx] += weight * idf * tf * (K1 + 1) / denom
|
| 547 |
+
return scores
|
| 548 |
+
|
| 549 |
+
def _semantic_ranking(self, query, allowed=None):
|
| 550 |
+
np = self._np
|
| 551 |
+
qv = self.embedder.encode_query(query)
|
| 552 |
+
row_sims = self.vectors @ qv
|
| 553 |
+
# Aggregate windows to chunks by max: a long section scores as its
|
| 554 |
+
# best-matching window, so a fact buried mid-memo is reachable.
|
| 555 |
+
sims = np.full(len(self.chunks), -1.0, dtype=np.float32)
|
| 556 |
+
np.maximum.at(sims, self._row_chunk, row_sims)
|
| 557 |
+
if allowed is not None:
|
| 558 |
+
# Mask out-of-scope chunks so a filtered search ranks (and reports
|
| 559 |
+
# confidence) within its own scope, not corpus-wide.
|
| 560 |
+
sims = np.where(np.asarray(allowed, dtype=bool), sims, -1.0)
|
| 561 |
+
# Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
|
| 562 |
+
# surplus chunks of any one deep source, and the extra depth is what
|
| 563 |
+
# lets other sources backfill the freed CANDIDATES slots.
|
| 564 |
+
order = np.argsort(sims)[::-1][:CANDIDATES * 4]
|
| 565 |
+
# The top cosine similarity doubles as a corpus-coverage signal: a query
|
| 566 |
+
# the corpus cannot answer has no passage close to it.
|
| 567 |
+
return [int(i) for i in order], float(sims.max())
|
| 568 |
+
|
| 569 |
+
def _rerank_doc(self, idx):
|
| 570 |
+
c = self.chunks[idx]
|
| 571 |
+
return f"{c['citation']} — {c['marginal_note']}\n{c['text']}"
|
| 572 |
+
|
| 573 |
+
def _source_key(self, idx):
|
| 574 |
+
"""The parent document a chunk belongs to, for diversity capping. Returns
|
| 575 |
+
None for primary instruments -- legislation, collective agreements and
|
| 576 |
+
directives -- whose sections are distinct provisions and are never
|
| 577 |
+
capped; case law is keyed by citation, memoranda by memo number.
|
| 578 |
+
|
| 579 |
+
Benefit-plan booklets also return None: a booklet's sections are
|
| 580 |
+
distinct topics (eligibility, coverage, claims, appeals...) like
|
| 581 |
+
statute sections, so capping the whole booklet to SOURCE_CAP chunks
|
| 582 |
+
would starve a detailed benefits query. They are not in
|
| 583 |
+
PRIMARY_DOC_TYPES, though -- they are guidance, not governing
|
| 584 |
+
instruments, so _ensure_primary does not pull them in as if they were
|
| 585 |
+
enacted law.
|
| 586 |
+
|
| 587 |
+
Exception: the pieces of one long section/article that was split for
|
| 588 |
+
embedding (ids ending '-p<k>') share a key, so the diversity cap stops a
|
| 589 |
+
single multi-part provision from filling the result set with its own
|
| 590 |
+
fragments. This applies to the otherwise-uncapped primary types
|
| 591 |
+
(legislation, agreement, directive, delegation): pieces are keyed by
|
| 592 |
+
their shared base id, distinct sections keep distinct keys. (Memoranda
|
| 593 |
+
and case law are already capped at the parent level, so their pieces are
|
| 594 |
+
bounded without a separate key.)"""
|
| 595 |
+
c = self.chunks[idx]
|
| 596 |
+
doc_type = c.get("doc_type", "legislation")
|
| 597 |
+
m = _PIECE_ID.search(c.get("id", ""))
|
| 598 |
+
if m and doc_type in PRIMARY_DOC_TYPES:
|
| 599 |
+
return ("piece", c["id"][:m.start()]) # pieces of one chunk together
|
| 600 |
+
# Commentary entries are like benefits chunks: each is a distinct
|
| 601 |
+
# curated topic, so capping the dataset as one source would starve a
|
| 602 |
+
# multi-entry question; not PRIMARY, so never force-pulled as law.
|
| 603 |
+
if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
|
| 604 |
+
return None
|
| 605 |
+
if doc_type == "memorandum":
|
| 606 |
+
# The key must be the parent DOCUMENT, and which field carries that
|
| 607 |
+
# identity differs across the memorandum families. ENF chapters,
|
| 608 |
+
# PDI pages and the IRB guidelines each have their own act_code and
|
| 609 |
+
# use `section` to subdivide it, so act_code is the document.
|
| 610 |
+
# _FLAT_MEMO_CODES are the two families where one act_code covers
|
| 611 |
+
# many independent items -- ~360 D-memoranda and ~1,000 AMPS
|
| 612 |
+
# contraventions -- and `section` (memo number / contravention
|
| 613 |
+
# code) is the unit a reader would cite.
|
| 614 |
+
#
|
| 615 |
+
# Keying every memorandum on `section` (as this did) gave each ENF
|
| 616 |
+
# section and each PDI page its own source key, so neither cap ever
|
| 617 |
+
# bound at the document level: the ~150-page PDI refugee tree could
|
| 618 |
+
# fill the whole candidate pool and push IRPA s. 112 out of a PRRA
|
| 619 |
+
# query entirely.
|
| 620 |
+
code = c["act_code"]
|
| 621 |
+
return ("memorandum",
|
| 622 |
+
c["section"] if code in _FLAT_MEMO_CODES else code)
|
| 623 |
+
return (doc_type, c["act_code"]) # one decision, keyed by citation
|
| 624 |
+
|
| 625 |
+
def _capped_top(self, ordered):
|
| 626 |
+
"""The first CANDIDATES indices of `ordered`, keeping at most
|
| 627 |
+
RECALL_CAP per capped source. The recall-stage counterpart of
|
| 628 |
+
_diversify: where _diversify reorders the fused candidates so a deep
|
| 629 |
+
source cannot monopolise the visible results, this stops that source
|
| 630 |
+
from monopolising the candidate pool itself -- skipped surplus frees
|
| 631 |
+
slots that later, weaker-ranked chunks of OTHER sources backfill."""
|
| 632 |
+
out, counts = [], defaultdict(int)
|
| 633 |
+
for idx in ordered:
|
| 634 |
+
key = self._source_key(idx)
|
| 635 |
+
if key is not None:
|
| 636 |
+
counts[key] += 1
|
| 637 |
+
if counts[key] > RECALL_CAP:
|
| 638 |
+
continue
|
| 639 |
+
out.append(idx)
|
| 640 |
+
if len(out) >= CANDIDATES:
|
| 641 |
+
break
|
| 642 |
+
return out
|
| 643 |
+
|
| 644 |
+
def _diversify(self, ordered):
|
| 645 |
+
"""Reorder so no single case, memorandum, agreement or directive can
|
| 646 |
+
monopolise the results: once a source has contributed SOURCE_CAP chunks,
|
| 647 |
+
its remaining chunks are deferred below every other candidate. This stops
|
| 648 |
+
a heavily paragraph-chunked decision from crowding out the statute it
|
| 649 |
+
interprets. Legislation is never capped."""
|
| 650 |
+
kept, deferred, counts = [], [], defaultdict(int)
|
| 651 |
+
for idx in ordered:
|
| 652 |
+
key = self._source_key(idx)
|
| 653 |
+
if key is None:
|
| 654 |
+
kept.append(idx)
|
| 655 |
+
continue
|
| 656 |
+
counts[key] += 1
|
| 657 |
+
(kept if counts[key] <= SOURCE_CAP else deferred).append(idx)
|
| 658 |
+
return kept + deferred
|
| 659 |
+
|
| 660 |
+
def _ensure_primary(self, ordered, top_k, q_tokens):
|
| 661 |
+
"""Guarantee the governing primary instrument is surfaced: when the
|
| 662 |
+
natural top_k is monopolised by case law or D-memoranda that interpret
|
| 663 |
+
a statute, pull the most topically-on-target Act/agreement/directive/
|
| 664 |
+
delegation section into the top_k, displacing the lowest-ranked
|
| 665 |
+
secondary sources. The single best match is always kept in place.
|
| 666 |
+
|
| 667 |
+
Two changes from the older 'ensure_legislation' guarantee: (i) all
|
| 668 |
+
primary instruments count, not only legislation -- so an FB-Agreement
|
| 669 |
+
query that surfaces only FPSLREB case law gets the agreement article
|
| 670 |
+
pulled in too; (ii) the candidate to promote is chosen by title-match
|
| 671 |
+
against the query (the section whose marginal_note covers the most of
|
| 672 |
+
the query's distinctive vocabulary), not by raw fusion rank. The
|
| 673 |
+
fusion rank surfaces tangentially-on-topic sections that share the
|
| 674 |
+
Act's general vocabulary; the title-match scorer surfaces the section
|
| 675 |
+
actually about the topic ('Seizure and forfeiture' over 'Report to
|
| 676 |
+
President' for a 'seize currency' query)."""
|
| 677 |
+
if top_k < 3:
|
| 678 |
+
return ordered
|
| 679 |
+
def is_primary(i):
|
| 680 |
+
# A not-in-force amendment is pending law, not a governing
|
| 681 |
+
# provision -- never promote it as "the statute on point".
|
| 682 |
+
return (self.chunks[i].get("doc_type", "legislation") in PRIMARY_DOC_TYPES
|
| 683 |
+
and self.chunks[i].get("status") != "not-in-force")
|
| 684 |
+
top, rest = ordered[:top_k], ordered[top_k:]
|
| 685 |
+
need = 2 - sum(1 for i in top if is_primary(i))
|
| 686 |
+
if need <= 0:
|
| 687 |
+
return ordered
|
| 688 |
+
primary_in_rest = [i for i in rest if is_primary(i)]
|
| 689 |
+
if not primary_in_rest:
|
| 690 |
+
return ordered
|
| 691 |
+
if q_tokens:
|
| 692 |
+
def title_score(idx):
|
| 693 |
+
note_tokens = self._note_tokens[idx]
|
| 694 |
+
if not note_tokens:
|
| 695 |
+
return 0.0
|
| 696 |
+
matched = sum(self.idf.get(t, 0.0)
|
| 697 |
+
for t in note_tokens if t in q_tokens)
|
| 698 |
+
total = sum(self.idf.get(t, 0.0) for t in note_tokens) or 1.0
|
| 699 |
+
score = matched * matched / total
|
| 700 |
+
# Mirror the fusion-stage hierarchy preferences for tiebreaks:
|
| 701 |
+
# the governing Act beats its regulation, and numbered
|
| 702 |
+
# agreement articles beat their back-matter, when both have
|
| 703 |
+
# identical titles (e.g. IRPA s. 112 and IRPR s. 160 both
|
| 704 |
+
# marginal-noted 'Application for protection').
|
| 705 |
+
if self._is_regulation[idx]:
|
| 706 |
+
score -= REG_PENALTY
|
| 707 |
+
if self._is_backmatter[idx]:
|
| 708 |
+
score -= BACKMATTER_PENALTY
|
| 709 |
+
return score
|
| 710 |
+
# Sort by title-match descending, then by original fusion order as
|
| 711 |
+
# a tiebreak (stable sort: keep the original rest order).
|
| 712 |
+
primary_in_rest.sort(key=title_score, reverse=True)
|
| 713 |
+
promote = primary_in_rest[:need]
|
| 714 |
+
drop = [i for i in reversed(top) if not is_primary(i)][:len(promote)]
|
| 715 |
+
if not drop:
|
| 716 |
+
return ordered
|
| 717 |
+
promote = promote[:len(drop)]
|
| 718 |
+
dropped, promoted = set(drop), set(promote)
|
| 719 |
+
kept = [i for i in top if i not in dropped]
|
| 720 |
+
return kept[:1] + promote + kept[1:] + drop + [
|
| 721 |
+
i for i in rest if i not in promoted]
|
| 722 |
+
|
| 723 |
+
def _cosurface_appendices(self, top):
|
| 724 |
+
"""Append the appendices the directive results cite but that retrieval
|
| 725 |
+
missed. A directive's rate tables ('Appendix C') rank poorly on a
|
| 726 |
+
natural-language query, yet a section that cites them is of little use
|
| 727 |
+
without them -- so the appendix travels with it. When more appendices
|
| 728 |
+
are cited than APPENDIX_CAP allows, the ones cited by the most results
|
| 729 |
+
win, so a lone off-topic result cannot outvote the relevant ones.
|
| 730 |
+
Returns `top` extended by up to APPENDIX_CAP appendix chunks."""
|
| 731 |
+
have = set(top)
|
| 732 |
+
cited = Counter()
|
| 733 |
+
for idx in top:
|
| 734 |
+
c = self.chunks[idx]
|
| 735 |
+
if c.get("doc_type") != "directive":
|
| 736 |
+
continue
|
| 737 |
+
seen = set() # count an appendix once per citing result
|
| 738 |
+
for m in _APPENDIX_REF.finditer(c["text"]):
|
| 739 |
+
key = (c["act_code"], m.group(1).upper())
|
| 740 |
+
for app in self._appendix.get(key, ()):
|
| 741 |
+
if app not in have and app not in seen:
|
| 742 |
+
seen.add(app)
|
| 743 |
+
cited[app] += 1
|
| 744 |
+
return top + [app for app, _ in cited.most_common(APPENDIX_CAP)]
|
| 745 |
+
|
| 746 |
+
def _highlight(self, query, indices):
|
| 747 |
+
"""For each result chunk, the subsection or paragraph most on point for
|
| 748 |
+
the query: {result_position: (citation_suffix, snippet)}. Uses the
|
| 749 |
+
cross-encoder; returns {} if it is unavailable or nothing is structured.
|
| 750 |
+
Only the first results are scored -- a pinpoint deep in the list is not
|
| 751 |
+
worth the cross-encoder cost."""
|
| 752 |
+
if not self.reranker:
|
| 753 |
+
return {}
|
| 754 |
+
jobs = [] # (result_position, label, scoring_text, snippet)
|
| 755 |
+
for pos, idx in enumerate(indices[:8]):
|
| 756 |
+
c = self.chunks[idx]
|
| 757 |
+
if c.get("doc_type", "legislation") != "legislation":
|
| 758 |
+
continue
|
| 759 |
+
note = c["marginal_note"]
|
| 760 |
+
for label, scoring, snippet in _provision_units(c["text"]):
|
| 761 |
+
jobs.append((pos, label, f"{note}. {scoring}", snippet))
|
| 762 |
+
if not jobs:
|
| 763 |
+
return {}
|
| 764 |
+
best = {} # result_position -> (score, label, snippet)
|
| 765 |
+
for (pos, label, _, snippet), score in zip(
|
| 766 |
+
jobs, self.reranker.score(query, [j[2] for j in jobs])):
|
| 767 |
+
if pos not in best or score > best[pos][0]:
|
| 768 |
+
best[pos] = (score, label, snippet)
|
| 769 |
+
return {pos: (label, " ".join(snippet[:240].split()))
|
| 770 |
+
for pos, (score, label, snippet) in best.items()}
|
| 771 |
+
|
| 772 |
+
def _filter_ok(self, c, act, doc_type, qlang="en", court=None,
|
| 773 |
+
decided_after=None):
|
| 774 |
+
"""One predicate for both recall masking and the late result filter.
|
| 775 |
+
The act filter matches short name, code, or full name -- an agent
|
| 776 |
+
passing 'Immigration and Refugee Protection Act' should not get
|
| 777 |
+
silence. English queries never see French chunks; French queries see
|
| 778 |
+
both languages (guidance/case law exist only in English). court /
|
| 779 |
+
decided_after constrain CASE-LAW chunks only (a case-law chunk's
|
| 780 |
+
'part' carries the deciding court and current_to the decision date);
|
| 781 |
+
non-caselaw chunks pass those filters untouched so a court-filtered
|
| 782 |
+
mixed search still surfaces the governing statute."""
|
| 783 |
+
if qlang == "en" and c.get("lang", "en") == "fr":
|
| 784 |
+
return False
|
| 785 |
+
if act:
|
| 786 |
+
a = act.lower()
|
| 787 |
+
if a not in (c["act_short"].lower(), c["act_code"].lower(),
|
| 788 |
+
c.get("act_name", "").lower()):
|
| 789 |
+
return False
|
| 790 |
+
if doc_type and c.get("doc_type", "legislation") != doc_type:
|
| 791 |
+
return False
|
| 792 |
+
if (court or decided_after) and c.get("doc_type") == "caselaw":
|
| 793 |
+
if court and court.lower() not in (c.get("part") or "").lower():
|
| 794 |
+
return False
|
| 795 |
+
if decided_after:
|
| 796 |
+
year = (c.get("current_to") or "")[:4]
|
| 797 |
+
if not year.isdigit() or int(year) < decided_after:
|
| 798 |
+
return False
|
| 799 |
+
return True
|
| 800 |
+
|
| 801 |
+
def search(self, query, top_k=6, act=None, doc_type=None, court=None,
|
| 802 |
+
decided_after=None):
|
| 803 |
+
"""Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
|
| 804 |
+
# Repair misspellings first (no-op for correctly-spelled queries),
|
| 805 |
+
# then expand legal abbreviations (PRRA, H&C, ...) into statutory
|
| 806 |
+
# wording for the recall stages. The corrected text is what feeds
|
| 807 |
+
# the semantic embedder and the cross-encoder -- the stem-level
|
| 808 |
+
# fuzzy bridge in _bm25_scores only ever helped BM25, which is why
|
| 809 |
+
# typo queries used to reach the pool but not the top ranks.
|
| 810 |
+
corrected = self._correct_query(query)
|
| 811 |
+
expanded = expand_query(corrected)
|
| 812 |
+
qlang = query_lang(query)
|
| 813 |
+
confidence = None
|
| 814 |
+
fused = defaultdict(float)
|
| 815 |
+
# A filtered search competes only within its scope: the mask reaches
|
| 816 |
+
# both recall stages, so results come from the best of the whole
|
| 817 |
+
# filtered corpus rather than whatever survived open recall. An
|
| 818 |
+
# unfiltered English query uses the cached English-only mask; a
|
| 819 |
+
# French query searches both languages unmasked.
|
| 820 |
+
if act or doc_type or court or decided_after:
|
| 821 |
+
mask = [self._filter_ok(c, act, doc_type, qlang, court,
|
| 822 |
+
decided_after)
|
| 823 |
+
for c in self.chunks]
|
| 824 |
+
elif qlang == "en":
|
| 825 |
+
mask = self._en_mask
|
| 826 |
+
else:
|
| 827 |
+
mask = None
|
| 828 |
+
bm25 = self._bm25_scores(expanded, allowed=mask,
|
| 829 |
+
en_only=(qlang == "en"))
|
| 830 |
+
for rank, idx in enumerate(self._capped_top(
|
| 831 |
+
sorted(bm25, key=bm25.get, reverse=True))):
|
| 832 |
+
fused[idx] += 1.0 / (RRF_K + rank)
|
| 833 |
+
if self.semantic:
|
| 834 |
+
sem_order, confidence = self._semantic_ranking(expanded, allowed=mask)
|
| 835 |
+
for rank, idx in enumerate(self._capped_top(sem_order)):
|
| 836 |
+
fused[idx] += W_SEM / (RRF_K + rank)
|
| 837 |
+
|
| 838 |
+
# Ensure explicitly-referenced sections are retrieved even if recall
|
| 839 |
+
# missed them -- but only for Acts the query actually names. A query
|
| 840 |
+
# like "IRPA s. 40 misrepresentation defence" uses the section number
|
| 841 |
+
# topically; pulling every Act's s. 40 into the pool would drown out
|
| 842 |
+
# the case law that interprets the section the user meant. Substring
|
| 843 |
+
# check rather than token-overlap because act_codes split into trivial
|
| 844 |
+
# tokens ("A-8.8" -> {a, 8}) that spuriously match common query words.
|
| 845 |
+
refs = _section_refs(query)
|
| 846 |
+
q_lc = query.lower()
|
| 847 |
+
def _act_in_query(c):
|
| 848 |
+
short = c["act_short"].lower()
|
| 849 |
+
code = c["act_code"].lower()
|
| 850 |
+
return ((short and short in q_lc)
|
| 851 |
+
or (code and len(code) >= 3 and code in q_lc))
|
| 852 |
+
if refs:
|
| 853 |
+
for idx, c in enumerate(self.chunks):
|
| 854 |
+
if (c["section"] in refs and idx not in fused
|
| 855 |
+
and _act_in_query(c)):
|
| 856 |
+
fused[idx] = 0.0
|
| 857 |
+
|
| 858 |
+
# Title-match boost: the marginal note is a section's canonical subject.
|
| 859 |
+
# Reward a candidate by how completely and how specifically the query
|
| 860 |
+
# matches its marginal note. The overlap is idf-weighted (matching a
|
| 861 |
+
# distinctive title like "hours of work" counts far more than a generic
|
| 862 |
+
# one like "Decision"), scaled by coverage, and capped -- so it nudges
|
| 863 |
+
# ranking toward the provision a question names by topic without
|
| 864 |
+
# overriding a strong base score.
|
| 865 |
+
q_tokens = set(tokenize(expanded))
|
| 866 |
+
for idx in list(fused):
|
| 867 |
+
note_tokens = self._note_tokens[idx]
|
| 868 |
+
total = sum(self.idf.get(t, 0.0) for t in note_tokens)
|
| 869 |
+
if total <= 0:
|
| 870 |
+
continue
|
| 871 |
+
matched = sum(self.idf.get(t, 0.0)
|
| 872 |
+
for t in note_tokens if t in q_tokens)
|
| 873 |
+
if matched > 0:
|
| 874 |
+
fused[idx] += min(MN_WEIGHT * matched * matched / total, MN_CAP)
|
| 875 |
+
|
| 876 |
+
# Hierarchy penalties: a topical question should surface the governing
|
| 877 |
+
# provision, not the supplementary material around it. An Act creates a
|
| 878 |
+
# duty while a regulation only elaborates procedure; a collective
|
| 879 |
+
# agreement's numbered articles are its substance while its memoranda
|
| 880 |
+
# and letters of understanding are back-matter; a not-in-force
|
| 881 |
+
# amendment is pending law, not the law. Each takes a small fusion
|
| 882 |
+
# penalty so the governing provision wins a close contest (the NIF
|
| 883 |
+
# penalty is larger: pending text should surface only when it is the
|
| 884 |
+
# clearly best match for the subject, never beat the live provision).
|
| 885 |
+
for idx in list(fused):
|
| 886 |
+
if self._is_nif[idx]:
|
| 887 |
+
fused[idx] -= NIF_PENALTY
|
| 888 |
+
elif self._is_repealed[idx]:
|
| 889 |
+
fused[idx] -= REPEALED_PENALTY
|
| 890 |
+
elif self._is_regulation[idx]:
|
| 891 |
+
fused[idx] -= REG_PENALTY
|
| 892 |
+
elif self._is_backmatter[idx]:
|
| 893 |
+
fused[idx] -= BACKMATTER_PENALTY
|
| 894 |
+
|
| 895 |
+
candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
|
| 896 |
+
if self._filter_ok(self.chunks[i], act, doc_type, qlang,
|
| 897 |
+
court, decided_after)]
|
| 898 |
+
if not candidates:
|
| 899 |
+
return []
|
| 900 |
+
scores = {i: fused[i] for i in candidates}
|
| 901 |
+
|
| 902 |
+
# Precision stage: the cross-encoder rescores the top candidate pool, but
|
| 903 |
+
# may only PROMOTE -- each pooled candidate is placed at the better of its
|
| 904 |
+
# fusion rank and its rerank rank, never below its fusion rank. The
|
| 905 |
+
# reranker reliably surfaces a strong answer the fusion ranked low, yet is
|
| 906 |
+
# unreliable on long statutory text (it can score the right section
|
| 907 |
+
# negative), so its power to demote a candidate is deliberately removed.
|
| 908 |
+
if self.reranker:
|
| 909 |
+
pool = candidates[:RERANK_POOL]
|
| 910 |
+
docs = [self._rerank_doc(i) for i in pool]
|
| 911 |
+
ce_orig = self.reranker.score(query, docs)
|
| 912 |
+
# A repaired query is scored alongside the original and each
|
| 913 |
+
# candidate keeps its better score -- the cross-encoder cannot
|
| 914 |
+
# ground 'detension' against text saying 'detention'. Applies
|
| 915 |
+
# only when a correction fired, so legacy queries never take
|
| 916 |
+
# the second pass.
|
| 917 |
+
if corrected != query:
|
| 918 |
+
ce_fix = self.reranker.score(corrected, docs)
|
| 919 |
+
ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_fix)]
|
| 920 |
+
# Optional (CANLEX_RERANK_DUAL=1): likewise for the
|
| 921 |
+
# synonym-expanded form. Off by default: the 2026-07 eval could
|
| 922 |
+
# not attribute its effect cleanly on the grown corpus.
|
| 923 |
+
if expanded != corrected and os.environ.get("CANLEX_RERANK_DUAL"):
|
| 924 |
+
ce_exp = self.reranker.score(expanded, docs)
|
| 925 |
+
ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
|
| 926 |
+
ce = dict(zip(pool, ce_orig))
|
| 927 |
+
fusion_rank = {idx: r for r, idx in enumerate(pool)}
|
| 928 |
+
rerank_rank = {idx: r for r, idx in enumerate(
|
| 929 |
+
sorted(pool, key=ce.get, reverse=True))}
|
| 930 |
+
pool.sort(key=lambda i: (min(fusion_rank[i], rerank_rank[i]),
|
| 931 |
+
fusion_rank[i]))
|
| 932 |
+
candidates = pool + candidates[RERANK_POOL:]
|
| 933 |
+
|
| 934 |
+
# Explicit section references are pinned to the very top -- using the
|
| 935 |
+
# same Act-mentioned constraint as the recall step above, for the same
|
| 936 |
+
# reason: a bare "s. 40" without an Act name is usually topical
|
| 937 |
+
# (e.g. "the IRPA s. 40 misrepresentation defence"), not a lookup.
|
| 938 |
+
if refs:
|
| 939 |
+
pinned = [i for i in candidates
|
| 940 |
+
if self.chunks[i]["section"] in refs
|
| 941 |
+
and _act_in_query(self.chunks[i])]
|
| 942 |
+
if pinned:
|
| 943 |
+
pinned_set = set(pinned)
|
| 944 |
+
candidates = pinned + [i for i in candidates if i not in pinned_set]
|
| 945 |
+
|
| 946 |
+
# Cap one-source monopolies, then guarantee a primary instrument on
|
| 947 |
+
# the topic is represented. The guarantee operates on a fixed visible
|
| 948 |
+
# window of min(top_k, 5), not the full top_k -- with top_k=20 (the
|
| 949 |
+
# eval default) the larger window almost always contains incidental
|
| 950 |
+
# legislation, so the guarantee never fires even when the governing
|
| 951 |
+
# provision is buried at rank 10+.
|
| 952 |
+
candidates = self._diversify(candidates)
|
| 953 |
+
candidates = self._ensure_primary(candidates, min(top_k, 5), q_tokens)
|
| 954 |
+
|
| 955 |
+
top = self._cosurface_appendices(candidates[:top_k])
|
| 956 |
+
highlights = self._highlight(query, top)
|
| 957 |
+
results = []
|
| 958 |
+
for pos, i in enumerate(top):
|
| 959 |
+
result = {**self.chunks[i], "score": round(scores.get(i, 0.0), 4),
|
| 960 |
+
"confidence": confidence}
|
| 961 |
+
if pos in highlights:
|
| 962 |
+
result["highlight"] = highlights[pos]
|
| 963 |
+
results.append(result)
|
| 964 |
+
return results
|
| 965 |
+
|
| 966 |
+
def get_section(self, act, section, lang="en"):
|
| 967 |
+
"""The section chunk in the requested language, falling back to the
|
| 968 |
+
other language rather than returning nothing."""
|
| 969 |
+
act = act.lower()
|
| 970 |
+
fallback = None
|
| 971 |
+
for c in self.chunks:
|
| 972 |
+
if c["section"] == section and act in (
|
| 973 |
+
c["act_short"].lower(), c["act_code"].lower(),
|
| 974 |
+
c.get("act_name", "").lower()):
|
| 975 |
+
if c.get("lang", "en") == lang:
|
| 976 |
+
return c
|
| 977 |
+
fallback = fallback or c
|
| 978 |
+
return fallback
|
| 979 |
+
|
| 980 |
+
def _build_xref(self):
|
| 981 |
+
"""Index legislation by (act, section); find each Act's definitions
|
| 982 |
+
section; link every regulation to its enabling Act and every
|
| 983 |
+
D-memorandum to the provisions it cites -- all for cross-referencing."""
|
| 984 |
+
self._by_section = {}
|
| 985 |
+
self._defs_section = {}
|
| 986 |
+
acts, regs = {}, {} # act_code -> (act_short, act_name)
|
| 987 |
+
for c in self.chunks:
|
| 988 |
+
if c.get("doc_type", "legislation") != "legislation":
|
| 989 |
+
continue
|
| 990 |
+
if c.get("lang", "en") == "fr":
|
| 991 |
+
continue # xref keys are per (act, section); English canonical
|
| 992 |
+
self._by_section[(c["act_code"], c["section"])] = c
|
| 993 |
+
if c["act_code"] not in self._defs_section and (
|
| 994 |
+
c["marginal_note"].strip().lower() in (
|
| 995 |
+
"definitions", "definition", "interpretation")):
|
| 996 |
+
self._defs_section[c["act_code"]] = c
|
| 997 |
+
bucket = regs if c["act_code"].startswith(("SOR", "C.R.C")) else acts
|
| 998 |
+
bucket.setdefault(c["act_code"], (c["act_short"], c["act_name"]))
|
| 999 |
+
|
| 1000 |
+
# Link a regulation to the Act it is made under by matching their names
|
| 1001 |
+
# ("X Regulations" <-> "X Act").
|
| 1002 |
+
self._enabling_act = {} # reg code -> (act_short, act_name)
|
| 1003 |
+
self._regulations = defaultdict(list) # act code -> [(reg_short, reg_name)]
|
| 1004 |
+
def base(name):
|
| 1005 |
+
return re.sub(r"\b(?:Act|Regulations)\b", "", name).strip().lower()
|
| 1006 |
+
act_by_base = {base(n): (code, s, n) for code, (s, n) in acts.items()}
|
| 1007 |
+
for rcode, (rshort, rname) in regs.items():
|
| 1008 |
+
hit = act_by_base.get(base(rname))
|
| 1009 |
+
if hit:
|
| 1010 |
+
self._enabling_act[rcode] = (hit[1], hit[2])
|
| 1011 |
+
self._regulations[hit[0]].append((rshort, rname))
|
| 1012 |
+
|
| 1013 |
+
# Link D-memoranda to the Customs Act / Customs Tariff provisions they
|
| 1014 |
+
# cite; an unqualified "the Act" in a D-memo means the Customs Act.
|
| 1015 |
+
by_short = {s.lower(): code for code, (s, n) in acts.items()}
|
| 1016 |
+
customs, tariff = by_short.get("customs act"), by_short.get("customs tariff")
|
| 1017 |
+
self._memos_for_section = defaultdict(set) # (act_code, section) -> memos
|
| 1018 |
+
for c in self.chunks:
|
| 1019 |
+
if c.get("doc_type") != "memorandum":
|
| 1020 |
+
continue
|
| 1021 |
+
for num, which in _MEMO_CITE.findall(c["text"]):
|
| 1022 |
+
code = tariff if which.lower() == "customs tariff" else customs
|
| 1023 |
+
if code:
|
| 1024 |
+
self._memos_for_section[(code, num)].add(c["section"])
|
| 1025 |
+
|
| 1026 |
+
def related(self, chunk):
|
| 1027 |
+
"""Cross-references for a legislation result, as a dict: 'provisions'
|
| 1028 |
+
(intra-Act sections it cites, plus the definitions section),
|
| 1029 |
+
'regulations' (made under this Act), 'enabling_act' (for a regulation,
|
| 1030 |
+
the Act it is made under) and 'memoranda' (D-memo numbers citing this
|
| 1031 |
+
section). Empty dict for case law, memoranda, etc."""
|
| 1032 |
+
if chunk.get("doc_type", "legislation") != "legislation":
|
| 1033 |
+
return {}
|
| 1034 |
+
act = chunk["act_code"]
|
| 1035 |
+
provisions, seen = [], {chunk["section"]}
|
| 1036 |
+
defs = self._defs_section.get(act)
|
| 1037 |
+
if defs and defs["section"] not in seen:
|
| 1038 |
+
provisions.append((defs["section"], defs["marginal_note"]))
|
| 1039 |
+
seen.add(defs["section"])
|
| 1040 |
+
for match in _XREF.finditer(chunk["text"]):
|
| 1041 |
+
sec = match.group(1)
|
| 1042 |
+
if sec in seen:
|
| 1043 |
+
continue
|
| 1044 |
+
target = self._by_section.get((act, sec))
|
| 1045 |
+
if target:
|
| 1046 |
+
provisions.append((sec, target["marginal_note"]))
|
| 1047 |
+
seen.add(sec)
|
| 1048 |
+
if len(provisions) >= 8:
|
| 1049 |
+
break
|
| 1050 |
+
return {
|
| 1051 |
+
"provisions": provisions,
|
| 1052 |
+
"regulations": self._regulations.get(act, []),
|
| 1053 |
+
"enabling_act": self._enabling_act.get(act),
|
| 1054 |
+
"memoranda": sorted(self._memos_for_section.get(
|
| 1055 |
+
(act, chunk["section"]), []))[:6],
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def main():
|
| 1060 |
+
if len(sys.argv) < 2:
|
| 1061 |
+
print('usage: python -m canlex.index "your query"')
|
| 1062 |
+
return
|
| 1063 |
+
query = " ".join(sys.argv[1:])
|
| 1064 |
+
index = LegislationIndex()
|
| 1065 |
+
if index.reranker:
|
| 1066 |
+
mode = "hybrid + cross-encoder rerank"
|
| 1067 |
+
elif index.semantic:
|
| 1068 |
+
mode = "hybrid (BM25 + semantic)"
|
| 1069 |
+
else:
|
| 1070 |
+
mode = "BM25 only"
|
| 1071 |
+
print(f"{len(index.chunks)} sections indexed - {mode}. Query: {query!r}\n")
|
| 1072 |
+
for r in index.search(query):
|
| 1073 |
+
print(f"[{r['score']:.3f}] {r['citation']} - {r['marginal_note']}")
|
| 1074 |
+
print(f" {r['text'].replace(chr(10), ' ')[:160]}")
|
| 1075 |
+
print()
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
if __name__ == "__main__":
|
| 1079 |
+
main()
|
canlex/ingest.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest Justice Laws XML into structured, section-level JSON chunks."""
|
| 2 |
+
import re
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
import urllib.request
|
| 6 |
+
import xml.etree.ElementTree as ET
|
| 7 |
+
|
| 8 |
+
from ._common import norm_ws as _norm, split_lines, write_corpus
|
| 9 |
+
from .config import SOURCES, RAW_DIR, PROCESSED_DIR
|
| 10 |
+
|
| 11 |
+
LIMS = "{http://justice.gc.ca/lims}"
|
| 12 |
+
BLOCK_TAGS = {"Subsection", "Paragraph", "Subparagraph", "Clause", "Definition"}
|
| 13 |
+
|
| 14 |
+
# A section whose entire rendered body is a placeholder marker -- "30 [Amendment]",
|
| 15 |
+
# "162 [Amendments]", "5 [Related provision]" -- left where an enacting Act amended
|
| 16 |
+
# another Act. Content-free; dropped at ingest. ([Repealed, ...] is deliberately
|
| 17 |
+
# excluded here so repealed sections are kept and flagged instead.)
|
| 18 |
+
_AMENDMENT_STUB = re.compile(
|
| 19 |
+
r"^\S+\s*\[(?:Amendments?|Related provisions?)\]\s*$", re.IGNORECASE)
|
| 20 |
+
|
| 21 |
+
# The semantic embedder (embed.py) only reads the first _MAX_BODY=2000 chars of
|
| 22 |
+
# a chunk, so the tail of a long section is invisible to semantic search. A
|
| 23 |
+
# section whose rendered text exceeds _SPLIT_THRESHOLD is therefore emitted as
|
| 24 |
+
# several piece-chunks, each <= _PIECE_TARGET chars (under the embed window) and
|
| 25 |
+
# split on subsection/line boundaries, so every part is embeddable. Sections at
|
| 26 |
+
# or below the threshold are emitted unchanged -- same id, same embedding --
|
| 27 |
+
# which keeps ~90% of legislation chunks (and their retrieval behaviour) intact.
|
| 28 |
+
_SPLIT_THRESHOLD = 2400
|
| 29 |
+
_PIECE_TARGET = 1800
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _split_text(text, target=_PIECE_TARGET):
|
| 33 |
+
"""Split rendered section text into <=target pieces at line boundaries,
|
| 34 |
+
folding small leading/trailing remnants into a neighbour so no piece is a
|
| 35 |
+
stub."""
|
| 36 |
+
return split_lines(text, target, fold_stubs=True)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _itertext(el):
|
| 40 |
+
return _norm("".join(el.itertext()))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def fetch_xml(code, force=False):
|
| 44 |
+
src = SOURCES[code]
|
| 45 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
dest = RAW_DIR / f"{code}.xml"
|
| 47 |
+
if dest.exists() and not force:
|
| 48 |
+
return dest
|
| 49 |
+
print(f" downloading {src['xml_url']}")
|
| 50 |
+
req = urllib.request.Request(src["xml_url"], headers={"User-Agent": "CanLex/0.1"})
|
| 51 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 52 |
+
dest.write_bytes(resp.read())
|
| 53 |
+
time.sleep(1.0) # be polite to the Justice Laws server between downloads
|
| 54 |
+
return dest
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _heading_text(h):
|
| 58 |
+
"""Render a <Heading> as 'LABEL - TITLE' when both parts are present."""
|
| 59 |
+
label, title = h.find("Label"), h.find("TitleText")
|
| 60 |
+
if label is not None or title is not None:
|
| 61 |
+
parts = [_itertext(e) for e in (label, title) if e is not None]
|
| 62 |
+
return " - ".join(p for p in parts if p)
|
| 63 |
+
return _itertext(h)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _render_block(el, is_section=False):
|
| 67 |
+
"""Recursively render a provision element into readable, structured text."""
|
| 68 |
+
label = note = ""
|
| 69 |
+
inline, blocks = [], []
|
| 70 |
+
for child in el:
|
| 71 |
+
tag = child.tag
|
| 72 |
+
if tag == "Label":
|
| 73 |
+
label = _itertext(child)
|
| 74 |
+
elif tag == "MarginalNote":
|
| 75 |
+
if not is_section: # a section's own marginal note is its title
|
| 76 |
+
note = _itertext(child)
|
| 77 |
+
elif tag == "Text":
|
| 78 |
+
inline.append(_itertext(child))
|
| 79 |
+
elif tag == "HistoricalNote":
|
| 80 |
+
continue
|
| 81 |
+
elif tag in BLOCK_TAGS:
|
| 82 |
+
blocks.append(_render_block(child))
|
| 83 |
+
else:
|
| 84 |
+
nested = _render_block(child)
|
| 85 |
+
if nested:
|
| 86 |
+
blocks.append(nested)
|
| 87 |
+
head = " ".join(p for p in (label, f"[{note}]" if note else "", " ".join(inline)) if p)
|
| 88 |
+
if blocks:
|
| 89 |
+
return (head + "\n" if head else "") + "\n".join(b for b in blocks if b)
|
| 90 |
+
return head
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _table_text(tablegroup):
|
| 94 |
+
"""Render a CALS <TableGroup> as caption/title lines plus one
|
| 95 |
+
pipe-separated line per row. Column structure survives as ' | '
|
| 96 |
+
separators, which is enough for BM25/semantic matching and readable in a
|
| 97 |
+
result pane; no attempt is made to preserve spanned cells."""
|
| 98 |
+
out = []
|
| 99 |
+
for t in (tablegroup.find("Caption"), tablegroup.find("table/title")):
|
| 100 |
+
if t is not None:
|
| 101 |
+
txt = _itertext(t)
|
| 102 |
+
if txt:
|
| 103 |
+
out.append(txt)
|
| 104 |
+
for row in tablegroup.iter("row"):
|
| 105 |
+
cells = [_itertext(e) for e in row.findall("entry")]
|
| 106 |
+
line = " | ".join(cells).strip(" |")
|
| 107 |
+
if line:
|
| 108 |
+
out.append(line)
|
| 109 |
+
return "\n".join(out)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _sched_body(el):
|
| 113 |
+
"""Render a <Schedule>'s content elements into readable text.
|
| 114 |
+
|
| 115 |
+
Observed shapes across the corpus: List/Item trees (CDSA I-IV, IRPR
|
| 116 |
+
1.1), DocumentInternal/Group/Provision prose (CDSA V-VI, IRPA's Refugee
|
| 117 |
+
Convention), and TableGroup grids (IRPR Schedule 2 violations, the AMPS
|
| 118 |
+
designated-provisions tables). Lists, groups and provisions reuse
|
| 119 |
+
_render_block, whose Label/Text recursion matches their structure;
|
| 120 |
+
tables get the dedicated renderer above so rows keep their shape."""
|
| 121 |
+
parts = []
|
| 122 |
+
for child in el:
|
| 123 |
+
tag = child.tag
|
| 124 |
+
if tag in ("ScheduleFormHeading", "HistoricalNote"):
|
| 125 |
+
continue
|
| 126 |
+
if tag == "TableGroup":
|
| 127 |
+
parts.append(_table_text(child))
|
| 128 |
+
elif tag == "GroupHeading":
|
| 129 |
+
parts.append(_heading_text(child))
|
| 130 |
+
elif tag in ("DocumentInternal", "Group", "RegulationPiece", "BillPiece"):
|
| 131 |
+
parts.append(_sched_body(child))
|
| 132 |
+
else:
|
| 133 |
+
rendered = _render_block(child)
|
| 134 |
+
if rendered:
|
| 135 |
+
parts.append(rendered)
|
| 136 |
+
return "\n".join(p for p in parts if p)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# Schedule ids with dedicated handling: NifProvs is ingested separately as
|
| 140 |
+
# not-in-force chunks; RelatedProvs (transitional provisions of past
|
| 141 |
+
# amendments) is deliberately excluded -- see _nif_chunks.
|
| 142 |
+
_SCHED_SKIP_IDS = {"NifProvs", "RelatedProvs"}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _schedule_chunks(root, code, src, current_to):
|
| 146 |
+
"""Chunks for an Act's substantive Schedules.
|
| 147 |
+
|
| 148 |
+
Previously every Schedule except NifProvs was silently dropped -- which
|
| 149 |
+
meant the CDSA's controlled-substance lists, the AMPS designated-
|
| 150 |
+
provisions tables and IRPA's Refugee Convention articles were absent
|
| 151 |
+
from the corpus even though the sections that depend on them were
|
| 152 |
+
ingested. A source can opt out with 'skip_schedules': True (the Customs
|
| 153 |
+
Tariff does: its schedule is the full tariff nomenclature, served
|
| 154 |
+
separately by tariff_schedule.py from the CBSA edition)."""
|
| 155 |
+
if src.get("skip_schedules"):
|
| 156 |
+
return []
|
| 157 |
+
chunks = []
|
| 158 |
+
for n, sched in enumerate(root.findall("Schedule"), start=1):
|
| 159 |
+
if sched.get("id") in _SCHED_SKIP_IDS:
|
| 160 |
+
continue
|
| 161 |
+
h = sched.find("ScheduleFormHeading")
|
| 162 |
+
label = _norm(h.findtext("Label") or "") if h is not None else ""
|
| 163 |
+
title = _norm(h.findtext("TitleText") or "") if h is not None else ""
|
| 164 |
+
oref = _norm(h.findtext("OriginatingRef") or "") if h is not None else ""
|
| 165 |
+
label = label or (f"SCHEDULE ({n})" if not title else title)
|
| 166 |
+
repealed = sched.find("Repealed") is not None
|
| 167 |
+
body = "[Repealed]" if repealed else _sched_body(sched)
|
| 168 |
+
if not body:
|
| 169 |
+
continue
|
| 170 |
+
header = " — ".join(p for p in (title, oref) if p)
|
| 171 |
+
text = (header + "\n" if header else "") + body
|
| 172 |
+
base = {
|
| 173 |
+
"act_code": code,
|
| 174 |
+
"act_short": src["short"],
|
| 175 |
+
"act_name": src["name"],
|
| 176 |
+
"section": label.title(),
|
| 177 |
+
"marginal_note": title or label.title(),
|
| 178 |
+
"part": "SCHEDULES",
|
| 179 |
+
"division": "",
|
| 180 |
+
"heading": label.title(),
|
| 181 |
+
"history": "",
|
| 182 |
+
"last_amended": sched.get(f"{LIMS}lastAmendedDate", ""),
|
| 183 |
+
"in_force": sched.get(f"{LIMS}inforce-start-date", ""),
|
| 184 |
+
"status": "repealed" if repealed else "in force",
|
| 185 |
+
"current_to": current_to,
|
| 186 |
+
"citation": f"{src['short']}, {label.title()}",
|
| 187 |
+
"source_url": src["web_base"],
|
| 188 |
+
}
|
| 189 |
+
pieces = (_split_text(text)
|
| 190 |
+
if len(text) > _SPLIT_THRESHOLD else [text])
|
| 191 |
+
total = len(pieces)
|
| 192 |
+
for k, piece in enumerate(pieces, start=1):
|
| 193 |
+
if total == 1:
|
| 194 |
+
chunks.append({**base, "id": f"{code}-sched{n}", "text": piece})
|
| 195 |
+
else:
|
| 196 |
+
chunks.append({
|
| 197 |
+
**base, "id": f"{code}-sched{n}-p{k}", "text": piece,
|
| 198 |
+
"citation": f"{base['citation']} (part {k} of {total})"})
|
| 199 |
+
return chunks
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _history(section):
|
| 203 |
+
note = section.find("HistoricalNote")
|
| 204 |
+
if note is None:
|
| 205 |
+
return ""
|
| 206 |
+
return "; ".join(_itertext(i) for i in note.iter("HistoricalNoteSubItem"))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def parse_legislation(xml_path, code):
|
| 210 |
+
src = SOURCES[code]
|
| 211 |
+
data = xml_path.read_bytes()
|
| 212 |
+
if data[:3] == b"\xef\xbb\xbf": # some Justice Laws XML files carry a UTF-8 BOM
|
| 213 |
+
data = data[3:]
|
| 214 |
+
root = ET.fromstring(data)
|
| 215 |
+
current_to = root.get(f"{LIMS}current-date", "")
|
| 216 |
+
body = root.find("Body")
|
| 217 |
+
if body is None:
|
| 218 |
+
# A schedule-only instrument (e.g. the Import Control List) has no
|
| 219 |
+
# <Body> at all -- its entire substance is in Schedules.
|
| 220 |
+
if not root.findall("Schedule"):
|
| 221 |
+
raise ValueError(f"{code}: no <Body> element (root <{root.tag}>)")
|
| 222 |
+
body = ()
|
| 223 |
+
|
| 224 |
+
headings, chunks = {}, []
|
| 225 |
+
for el in body:
|
| 226 |
+
if el.tag == "Heading":
|
| 227 |
+
try:
|
| 228 |
+
level = int(el.get("level", "0"))
|
| 229 |
+
except ValueError:
|
| 230 |
+
level = 0
|
| 231 |
+
headings = {k: v for k, v in headings.items() if k < level}
|
| 232 |
+
headings[level] = _heading_text(el)
|
| 233 |
+
continue
|
| 234 |
+
if el.tag != "Section":
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
label_el = el.find("Label")
|
| 238 |
+
number = _itertext(label_el) if label_el is not None else ""
|
| 239 |
+
note_el = el.find("MarginalNote")
|
| 240 |
+
marginal = _itertext(note_el) if note_el is not None else ""
|
| 241 |
+
body_text = _render_block(el, is_section=True)
|
| 242 |
+
|
| 243 |
+
# Drop content-free amendment/related-provision stubs. When an Act
|
| 244 |
+
# amends OTHER Acts, the consolidation keeps a placeholder section whose
|
| 245 |
+
# whole body is just a marker -- e.g. "30 [Amendment]" -- the actual
|
| 246 |
+
# text living in the Act being amended. These carry no substance, yet
|
| 247 |
+
# were being indexed and rendered with an 'in force' currency line as if
|
| 248 |
+
# they were live provisions (117 in the CBSA Act alone). Skip them.
|
| 249 |
+
# (Repealed sections, "<n> [Repealed, ...]", are NOT dropped -- they are
|
| 250 |
+
# flagged status='repealed' and kept so a section lookup still informs.)
|
| 251 |
+
if _AMENDMENT_STUB.match(body_text):
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
+
if number:
|
| 255 |
+
chunk_id = f"{code}-s{number}"
|
| 256 |
+
citation = f"{src['short']}, s. {number}"
|
| 257 |
+
source_url = f"{src['web_base']}section-{number}.html"
|
| 258 |
+
else:
|
| 259 |
+
# An unnumbered <Section> is a preamble or enacting recital. Keep it
|
| 260 |
+
# unless it has no text; the LIMS id keeps the chunk id unique when
|
| 261 |
+
# an Act contains more than one such block.
|
| 262 |
+
if not body_text:
|
| 263 |
+
continue
|
| 264 |
+
number = marginal or "Preamble"
|
| 265 |
+
marginal = marginal or "Preamble"
|
| 266 |
+
lims_id = el.get(f"{LIMS}id") or str(len(chunks))
|
| 267 |
+
chunk_id = f"{code}-pre-{lims_id}"
|
| 268 |
+
citation = f"{src['short']}, {number}"
|
| 269 |
+
source_url = src["web_base"]
|
| 270 |
+
|
| 271 |
+
part = next((v for _, v in sorted(headings.items())
|
| 272 |
+
if v.upper().startswith("PART")), "")
|
| 273 |
+
division = next((v for _, v in sorted(headings.items())
|
| 274 |
+
if v.upper().startswith("DIVISION")), "")
|
| 275 |
+
nearest = headings[max(headings)] if headings else ""
|
| 276 |
+
|
| 277 |
+
# Per-section coming-into-force date (lims:inforce-start-date is present
|
| 278 |
+
# on every section) and repeal status. A wholly-repealed section is a
|
| 279 |
+
# <Section> whose own <Text> is a <Repealed> marker (e.g. "[Repealed,
|
| 280 |
+
# 2018, c. 27, s. 174]"); a live section with a repealed subsection has
|
| 281 |
+
# the <Repealed> nested deeper, so we key on the direct Text/Repealed to
|
| 282 |
+
# flag only sections that are themselves no longer law.
|
| 283 |
+
in_force = el.get(f"{LIMS}inforce-start-date", "")
|
| 284 |
+
status = "repealed" if el.find("Text/Repealed") is not None else "in force"
|
| 285 |
+
|
| 286 |
+
base = {
|
| 287 |
+
"act_code": code,
|
| 288 |
+
"act_short": src["short"],
|
| 289 |
+
"act_name": src["name"],
|
| 290 |
+
"section": number,
|
| 291 |
+
"marginal_note": marginal,
|
| 292 |
+
"part": part,
|
| 293 |
+
"division": division,
|
| 294 |
+
"heading": nearest,
|
| 295 |
+
"history": _history(el),
|
| 296 |
+
"last_amended": el.get(f"{LIMS}lastAmendedDate", ""),
|
| 297 |
+
"in_force": in_force,
|
| 298 |
+
"status": status,
|
| 299 |
+
"current_to": current_to,
|
| 300 |
+
"source_url": source_url,
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
# Short sections (the large majority) stay one chunk with their
|
| 304 |
+
# original id and citation -- unchanged embedding and retrieval. Long
|
| 305 |
+
# ones are split into embeddable pieces; each piece keeps the section
|
| 306 |
+
# number (so citation lookups, section-ref pinning and the eval still
|
| 307 |
+
# match) and notes its part in the citation.
|
| 308 |
+
pieces = (_split_text(body_text)
|
| 309 |
+
if len(body_text) > _SPLIT_THRESHOLD else [body_text])
|
| 310 |
+
n = len(pieces)
|
| 311 |
+
for k, piece in enumerate(pieces, start=1):
|
| 312 |
+
if n == 1:
|
| 313 |
+
chunks.append({**base, "id": chunk_id, "text": piece,
|
| 314 |
+
"citation": citation})
|
| 315 |
+
else:
|
| 316 |
+
chunks.append({**base, "id": f"{chunk_id}-p{k}", "text": piece,
|
| 317 |
+
"citation": f"{citation} (part {k} of {n})"})
|
| 318 |
+
|
| 319 |
+
chunks.extend(_schedule_chunks(root, code, src, current_to))
|
| 320 |
+
chunks.extend(_nif_chunks(root, code, src, current_to))
|
| 321 |
+
return chunks
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _nif_chunks(root, code, src, current_to):
|
| 325 |
+
"""Chunks for the Act's AMENDMENTS NOT IN FORCE schedule.
|
| 326 |
+
|
| 327 |
+
Justice Laws consolidations carry enacted-but-not-yet-in-force amendments
|
| 328 |
+
in a Schedule with id='NifProvs' (each entry a RelatedOrNotInForce node:
|
| 329 |
+
a citation heading for the amending Act plus the amendment text as regular
|
| 330 |
+
Section elements). Previously the parser dropped these silently -- the
|
| 331 |
+
corpus's biggest currency blind spot, since a Royal-Assented amendment
|
| 332 |
+
days from changing the law was invisible until consolidation. They are
|
| 333 |
+
ingested with status='not-in-force': retrieval down-weights them below
|
| 334 |
+
live law and the server banners them, but a search for their subject
|
| 335 |
+
matter now surfaces the pending law instead of nothing.
|
| 336 |
+
|
| 337 |
+
The sibling Schedule id='RelatedProvs' (transitional related provisions)
|
| 338 |
+
is deliberately NOT ingested here: those are in-force companion rules of
|
| 339 |
+
past amendments, not pending law, and would need their own treatment.
|
| 340 |
+
|
| 341 |
+
Note the Label inside an entry is the AMENDING Act's section number, so
|
| 342 |
+
the chunk's 'section' is left empty -- these must never satisfy a lookup
|
| 343 |
+
or pin for the target Act's own section numbers.
|
| 344 |
+
"""
|
| 345 |
+
nif = next((s for s in root.findall("Schedule")
|
| 346 |
+
if s.get("id") == "NifProvs"), None)
|
| 347 |
+
if nif is None:
|
| 348 |
+
return []
|
| 349 |
+
chunks = []
|
| 350 |
+
for n, entry in enumerate(nif.iter("RelatedOrNotInForce"), start=1):
|
| 351 |
+
title = entry.find("Heading/TitleText")
|
| 352 |
+
cite = _norm("".join(title.itertext())) if title is not None else ""
|
| 353 |
+
cite = cite.lstrip("—- ").strip()
|
| 354 |
+
sections = entry.findall("Section")
|
| 355 |
+
body = "\n".join(p for p in (_render_block(s, is_section=True)
|
| 356 |
+
for s in sections) if p)
|
| 357 |
+
if not body:
|
| 358 |
+
continue
|
| 359 |
+
note_el = sections[0].find("MarginalNote") if sections else None
|
| 360 |
+
marginal = _norm("".join(note_el.itertext())) if note_el is not None else ""
|
| 361 |
+
pieces = (_split_text(body)
|
| 362 |
+
if len(body) > _SPLIT_THRESHOLD else [body])
|
| 363 |
+
total = len(pieces)
|
| 364 |
+
for k, piece in enumerate(pieces, start=1):
|
| 365 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 366 |
+
part_note = "" if total == 1 else f" (part {k} of {total})"
|
| 367 |
+
chunks.append({
|
| 368 |
+
"id": f"{code}-nif-{n}{suffix}",
|
| 369 |
+
"act_code": code,
|
| 370 |
+
"act_short": src["short"],
|
| 371 |
+
"act_name": src["name"],
|
| 372 |
+
"section": "",
|
| 373 |
+
"marginal_note": marginal or f"Amendment not in force — {cite}",
|
| 374 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 375 |
+
"division": "",
|
| 376 |
+
"heading": "Amendments not in force",
|
| 377 |
+
"text": piece,
|
| 378 |
+
"history": cite,
|
| 379 |
+
"last_amended": "",
|
| 380 |
+
"in_force": "",
|
| 381 |
+
"status": "not-in-force",
|
| 382 |
+
"current_to": current_to,
|
| 383 |
+
"citation": (f"{src['short']} — amendment not in force "
|
| 384 |
+
f"({cite}){part_note}"),
|
| 385 |
+
"source_url": src["web_base"],
|
| 386 |
+
})
|
| 387 |
+
return chunks
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def ingest(code, force=False, allow_shrink=False):
|
| 391 |
+
print(f"Ingesting {code} ({SOURCES[code]['short']})...")
|
| 392 |
+
xml_path = fetch_xml(code, force=force)
|
| 393 |
+
chunks = parse_legislation(xml_path, code)
|
| 394 |
+
out = PROCESSED_DIR / f"{code}.json"
|
| 395 |
+
# An Act whose XML schema shifts parses to a fraction of its sections; the
|
| 396 |
+
# guard turns that into a refusal instead of a quietly gutted statute.
|
| 397 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 398 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 399 |
+
print(f" {len(chunks)} sections -> {out.name}")
|
| 400 |
+
return chunks
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# --- French corpus (phase 3 of bilingual support) ----------------------------
|
| 404 |
+
# Justice Laws publishes every consolidation as parallel French XML in the
|
| 405 |
+
# SAME schema (English element names, French text, identical section
|
| 406 |
+
# numbering), so the whole parser -- schedules included -- works unchanged.
|
| 407 |
+
# French chunks carry lang='fr', a '-fr-' id infix, French citation style
|
| 408 |
+
# ('art.' / 'annexe') and the French web URL; retrieval filters by query
|
| 409 |
+
# language (index.py), and get_section/xref/list_acts stay English-first.
|
| 410 |
+
|
| 411 |
+
def _fr_xml_url(src):
|
| 412 |
+
return (src["xml_url"]
|
| 413 |
+
.replace("/eng/XML/", "/fra/XML/")
|
| 414 |
+
.replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
|
| 415 |
+
.replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _frenchify(chunks, code):
|
| 419 |
+
out = []
|
| 420 |
+
for c in chunks:
|
| 421 |
+
c = dict(c)
|
| 422 |
+
c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
|
| 423 |
+
c["lang"] = "fr"
|
| 424 |
+
c["citation"] = (c["citation"]
|
| 425 |
+
.replace(", s. ", ", art. ")
|
| 426 |
+
.replace(", Schedule", ", annexe"))
|
| 427 |
+
c["source_url"] = (c["source_url"]
|
| 428 |
+
.replace("/eng/acts/", "/fra/lois/")
|
| 429 |
+
.replace("/eng/regulations/", "/fra/reglements/"))
|
| 430 |
+
out.append(c)
|
| 431 |
+
return out
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def ingest_fr(code, force=False, allow_shrink=False):
|
| 435 |
+
src = SOURCES[code]
|
| 436 |
+
dest = RAW_DIR / f"{code}-fr.xml"
|
| 437 |
+
if not dest.exists() or force:
|
| 438 |
+
url = _fr_xml_url(src)
|
| 439 |
+
print(f" downloading {url}")
|
| 440 |
+
req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
|
| 441 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 442 |
+
dest.write_bytes(resp.read())
|
| 443 |
+
time.sleep(1.0)
|
| 444 |
+
chunks = _frenchify(parse_legislation(dest, code), code)
|
| 445 |
+
out = PROCESSED_DIR / f"{code}-fr.json"
|
| 446 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 447 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 448 |
+
print(f" {len(chunks)} articles -> {out.name}")
|
| 449 |
+
return chunks
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def main():
|
| 453 |
+
force = "--force" in sys.argv
|
| 454 |
+
french = "--fr" in sys.argv
|
| 455 |
+
allow_shrink = "--allow-shrink" in sys.argv
|
| 456 |
+
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 457 |
+
codes = only or list(SOURCES)
|
| 458 |
+
failures = []
|
| 459 |
+
for code in codes:
|
| 460 |
+
if code not in SOURCES:
|
| 461 |
+
print(f" SKIP {code}: not a known source")
|
| 462 |
+
continue
|
| 463 |
+
try:
|
| 464 |
+
if french:
|
| 465 |
+
print(f"Ingesting {code} (français)...")
|
| 466 |
+
ingest_fr(code, force=force, allow_shrink=allow_shrink)
|
| 467 |
+
else:
|
| 468 |
+
ingest(code, force=force, allow_shrink=allow_shrink)
|
| 469 |
+
except Exception as exc:
|
| 470 |
+
failures.append((code, exc))
|
| 471 |
+
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 472 |
+
print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
|
| 473 |
+
for code, exc in failures:
|
| 474 |
+
print(f" FAILED {code}: {exc}")
|
| 475 |
+
# One collapsed Act must not pass as a clean run: refresh.py and the
|
| 476 |
+
# RUNBOOK chain this into `&& py -m canlex.embed`.
|
| 477 |
+
sys.exit(1 if failures else 0)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
if __name__ == "__main__":
|
| 481 |
+
main()
|
canlex/irb_guidelines.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the IRB Chairperson's Guidelines the border workflow actually meets.
|
| 2 |
+
|
| 3 |
+
Guideline 2 (Detention) is what Immigration Division members apply at every
|
| 4 |
+
48-hour/7-day/30-day review an officer attends; Guidelines 3 (child
|
| 5 |
+
claimants) and 8 (vulnerable persons) govern how detained or referred minors
|
| 6 |
+
and vulnerable people are handled. Soft law: issued under IRPA
|
| 7 |
+
s. 159(1)(h), members are expected to apply them or explain why not --
|
| 8 |
+
ingested as doc_type='memorandum' (guidance), like the D-memoranda.
|
| 9 |
+
|
| 10 |
+
Pages are public HTML on irb-cisr.gc.ca (same host caselaw.py already
|
| 11 |
+
parses for jurisprudential guides).
|
| 12 |
+
|
| 13 |
+
python -m canlex.irb_guidelines
|
| 14 |
+
"""
|
| 15 |
+
import re
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from bs4 import BeautifulSoup
|
| 19 |
+
|
| 20 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 21 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 22 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
+
|
| 24 |
+
RAW = RAW_DIR / "irb_guidelines"
|
| 25 |
+
|
| 26 |
+
GUIDELINES = [
|
| 27 |
+
{"num": "2", "file": "guideline2",
|
| 28 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir02.aspx",
|
| 29 |
+
"title": "Detention"},
|
| 30 |
+
{"num": "3", "file": "guideline3",
|
| 31 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir03.aspx",
|
| 32 |
+
"title": "Child Refugee Claimants: Procedural and Evidentiary Issues"},
|
| 33 |
+
{"num": "8", "file": "guideline8",
|
| 34 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir08.aspx",
|
| 35 |
+
"title": "Accessibility to IRB Proceedings — Procedural Accommodations "
|
| 36 |
+
"and Substantive Considerations"},
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# "Effective date: May 31, 2021" (or amended/revised) in the page prose.
|
| 40 |
+
_EFFECTIVE = re.compile(
|
| 41 |
+
r"(?:effective|amended|revised)[^.:\n]*:?\s*((?:January|February|March|"
|
| 42 |
+
r"April|May|June|July|August|September|October|November|December)\s+"
|
| 43 |
+
r"\d{1,2},\s+\d{4})", re.I)
|
| 44 |
+
_MONTHS = {m: i for i, m in enumerate(
|
| 45 |
+
("January", "February", "March", "April", "May", "June", "July",
|
| 46 |
+
"August", "September", "October", "November", "December"), start=1)}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _iso(prose):
|
| 50 |
+
m = re.match(r"(\w+)\s+(\d{1,2}),\s+(\d{4})", prose or "")
|
| 51 |
+
if not m or m.group(1) not in _MONTHS:
|
| 52 |
+
return ""
|
| 53 |
+
return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _sections(main):
|
| 57 |
+
"""Yield (label, heading, text) per numbered heading in the page body."""
|
| 58 |
+
heads = main.find_all(["h2", "h3"])
|
| 59 |
+
for i, h in enumerate(heads):
|
| 60 |
+
heading = _norm(h.get_text())
|
| 61 |
+
m = re.match(r"(\d+(?:\.\d+)*)\.?\s+(.*)", heading)
|
| 62 |
+
label = m.group(1) if m else str(i + 1)
|
| 63 |
+
title = m.group(2) if m else heading
|
| 64 |
+
parts = []
|
| 65 |
+
for sib in h.next_siblings:
|
| 66 |
+
if getattr(sib, "name", None) in ("h2", "h3"):
|
| 67 |
+
break
|
| 68 |
+
if getattr(sib, "get_text", None):
|
| 69 |
+
parts.append(sib.get_text("\n"))
|
| 70 |
+
body = "\n".join(p for p in (_norm(x) for x in
|
| 71 |
+
"\n".join(parts).split("\n")) if p)
|
| 72 |
+
if len(body) > 80:
|
| 73 |
+
yield label, title, body
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build(allow_shrink=False):
|
| 77 |
+
chunks, failures = [], []
|
| 78 |
+
for g in GUIDELINES:
|
| 79 |
+
try:
|
| 80 |
+
html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
|
| 81 |
+
pause=1.0).decode("utf-8", "replace")
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
print(f" !! Guideline {g['num']}: fetch failed "
|
| 84 |
+
f"({type(exc).__name__}: {exc})")
|
| 85 |
+
failures.append(f"IRB-G{g['num']}")
|
| 86 |
+
continue
|
| 87 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 88 |
+
main = soup.find("main") or soup
|
| 89 |
+
eff = _EFFECTIVE.search(main.get_text())
|
| 90 |
+
effective = _iso(eff.group(1)) if eff else ""
|
| 91 |
+
n = 0
|
| 92 |
+
for label, title, body in _sections(main):
|
| 93 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 94 |
+
total = len(pieces)
|
| 95 |
+
for k, piece in enumerate(pieces, start=1):
|
| 96 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 97 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 98 |
+
chunks.append({
|
| 99 |
+
"id": f"irbg-{g['num']}-{label.replace('.', '-')}{suffix}",
|
| 100 |
+
"doc_type": "memorandum",
|
| 101 |
+
"act_code": f"IRB-G{g['num']}",
|
| 102 |
+
"act_short": f"IRB Guideline {g['num']}",
|
| 103 |
+
"act_name": f"IRB Chairperson's Guideline {g['num']} — "
|
| 104 |
+
f"{g['title']}",
|
| 105 |
+
"section": f"G{g['num']} s. {label}",
|
| 106 |
+
"marginal_note": title,
|
| 107 |
+
"part": g["title"],
|
| 108 |
+
"division": "",
|
| 109 |
+
"heading": "",
|
| 110 |
+
"text": piece,
|
| 111 |
+
"history": "",
|
| 112 |
+
"last_amended": effective,
|
| 113 |
+
"current_to": effective,
|
| 114 |
+
"citation": f"Chairperson's Guideline {g['num']}, "
|
| 115 |
+
f"s. {label} ({title}){note}",
|
| 116 |
+
"source_url": g["url"],
|
| 117 |
+
})
|
| 118 |
+
n += 1
|
| 119 |
+
if not n:
|
| 120 |
+
# Fetched, parsed to nothing: the IRB page template moved.
|
| 121 |
+
print(f" !! Guideline {g['num']}: no content parsed; "
|
| 122 |
+
f"keeping stored copy")
|
| 123 |
+
failures.append(f"IRB-G{g['num']}")
|
| 124 |
+
continue
|
| 125 |
+
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 126 |
+
f"effective {effective or '?'}")
|
| 127 |
+
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 128 |
+
chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
|
| 129 |
+
key=lambda c: c["act_code"],
|
| 130 |
+
only=set(failures))
|
| 131 |
+
if preserved:
|
| 132 |
+
print(f" kept the last-good copy of {len(preserved)} guideline(s) "
|
| 133 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 134 |
+
uniquify_ids(chunks)
|
| 135 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 136 |
+
return False
|
| 137 |
+
print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
|
| 138 |
+
return chunks
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def main():
|
| 142 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 143 |
+
# empty corpus succeeded, a refused write did not.
|
| 144 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
|
| 145 |
+
sys.exit(0 if ok else 1)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
canlex/loganalyze.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mine the query log for real-world retrieval gaps.
|
| 2 |
+
|
| 3 |
+
The MCP server appends one JSON line per search to CANLEX_LOG_DIR/queries.jsonl
|
| 4 |
+
when CANLEX_LOG_DIR is set (see server._log_query). Real user queries -- the
|
| 5 |
+
ones that returned nothing or scored a weak match -- are the only unbiased
|
| 6 |
+
source of corpus and synonym gaps, since the 155-question eval set is authored
|
| 7 |
+
by the maintainer. This tool surfaces the weakest and most frequent of them as
|
| 8 |
+
candidates for new eval questions, synonyms, or corpus additions.
|
| 9 |
+
|
| 10 |
+
py -m canlex.loganalyze [path/to/queries.jsonl] # defaults to CANLEX_LOG_DIR
|
| 11 |
+
|
| 12 |
+
By default it lists no-result queries, then the lowest-confidence queries, then
|
| 13 |
+
the most-repeated ones. Privacy: query text may contain personal facts -- treat
|
| 14 |
+
the log as sensitive and do not commit it.
|
| 15 |
+
"""
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
from collections import Counter
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
# Mirror server.HEDGE_THRESHOLD's intent without importing the server: a query
|
| 23 |
+
# whose best result scored below this is a weak match worth reviewing.
|
| 24 |
+
_WEAK = 0.72
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _load(path):
|
| 28 |
+
rows = []
|
| 29 |
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
| 30 |
+
line = line.strip()
|
| 31 |
+
if not line:
|
| 32 |
+
continue
|
| 33 |
+
try:
|
| 34 |
+
rows.append(json.loads(line))
|
| 35 |
+
except ValueError:
|
| 36 |
+
continue
|
| 37 |
+
return rows
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def report(path):
|
| 41 |
+
rows = _load(path)
|
| 42 |
+
if not rows:
|
| 43 |
+
print(f"No logged queries in {path}.")
|
| 44 |
+
return
|
| 45 |
+
print(f"{len(rows)} logged queries in {path}\n")
|
| 46 |
+
|
| 47 |
+
no_result = [r for r in rows if r.get("n_results", 0) == 0]
|
| 48 |
+
weak = sorted((r for r in rows
|
| 49 |
+
if r.get("confidence") is not None
|
| 50 |
+
and r["confidence"] < _WEAK and r.get("n_results")),
|
| 51 |
+
key=lambda r: r["confidence"])
|
| 52 |
+
freq = Counter(r.get("query", "").strip().lower() for r in rows)
|
| 53 |
+
|
| 54 |
+
print(f"== No results ({len(no_result)}) -- likely corpus or vocabulary gaps ==")
|
| 55 |
+
for r in no_result[:25]:
|
| 56 |
+
print(f" {r.get('query','')}")
|
| 57 |
+
|
| 58 |
+
print(f"\n== Weak matches (confidence < {_WEAK}; {len(weak)}) -- candidate "
|
| 59 |
+
f"eval questions / synonyms ==")
|
| 60 |
+
for r in weak[:25]:
|
| 61 |
+
top = (r.get("top_citations") or ["?"])[0]
|
| 62 |
+
print(f" {r['confidence']:.3f} {r.get('query','')[:70]:70} -> {top}")
|
| 63 |
+
|
| 64 |
+
print("\n== Most repeated queries (demand signal) ==")
|
| 65 |
+
for q, n in freq.most_common(15):
|
| 66 |
+
if n > 1:
|
| 67 |
+
print(f" {n:>3}x {q}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def main():
|
| 71 |
+
path = (sys.argv[1] if len(sys.argv) > 1
|
| 72 |
+
else os.path.join(os.environ.get("CANLEX_LOG_DIR", "."), "queries.jsonl"))
|
| 73 |
+
if not Path(path).exists():
|
| 74 |
+
print(f"No query log at {path}. Set CANLEX_LOG_DIR on the server (and "
|
| 75 |
+
f"pass the path here) to collect one.", file=sys.stderr)
|
| 76 |
+
sys.exit(1)
|
| 77 |
+
report(path)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
canlex/pdi.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest scoped IRCC Program Delivery Instructions (PDI) pages.
|
| 2 |
+
|
| 3 |
+
The PDIs are the current-policy successor to the legacy chapter manuals.
|
| 4 |
+
This module ingests the specific PDI sets that replace retired ENF chapters
|
| 5 |
+
the corpus needs: the PR-card/status section (successor to ENF 27) and the
|
| 6 |
+
Tran serious-criminality page (partial successor to ENF 2). Each named
|
| 7 |
+
index page is fetched, its same-section sub-pages discovered, and every
|
| 8 |
+
page chunked by heading -- doc_type='memorandum', like all guidance.
|
| 9 |
+
|
| 10 |
+
python -m canlex.pdi [--allow-shrink]
|
| 11 |
+
"""
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 16 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 17 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 18 |
+
|
| 19 |
+
RAW = RAW_DIR / "pdi"
|
| 20 |
+
OUT_FILE = PROCESSED_DIR / "pdi.json"
|
| 21 |
+
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 22 |
+
"publications-manuals/operational-bulletins-manuals/")
|
| 23 |
+
|
| 24 |
+
# Each set is one PDI category tree, crawled breadth-first from its index to
|
| 25 |
+
# _MAX_DEPTH, capped at _MAX_PAGES pages (dropped pages are LOGGED, never
|
| 26 |
+
# silently skipped). Scope fragments keep the crawl inside the category. The
|
| 27 |
+
# selection is the border-relevant slice of the OBM tree: temporary
|
| 28 |
+
# residents (visitors/eTA/TRV/TRPs), refugee protection (port-of-entry
|
| 29 |
+
# claims), the cross-cutting standard requirements (includes the Tran
|
| 30 |
+
# serious-criminality page), officer designation/delegation, biometrics and
|
| 31 |
+
# identity, and the PR-card section (ENF 27's successor). Deliberately out:
|
| 32 |
+
# citizenship, fees, office service-delivery, and the year-by-year
|
| 33 |
+
# operational-bulletin archives (mostly expired instructions).
|
| 34 |
+
_MAX_DEPTH = 2
|
| 35 |
+
_MAX_PAGES = 160 # 60 capped out: the Temporary Residents tree alone has
|
| 36 |
+
# ~150 in-scope pages (2026-07 crawl)
|
| 37 |
+
|
| 38 |
+
SETS = [
|
| 39 |
+
{"code": "pdi-prcard",
|
| 40 |
+
"short": "PDI PR Card",
|
| 41 |
+
"name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
|
| 42 |
+
"index": _OBM + "permanent-residence/card.html",
|
| 43 |
+
"scope": "/permanent-residence/card"},
|
| 44 |
+
{"code": "pdi-tr",
|
| 45 |
+
"short": "PDI Temporary Residents",
|
| 46 |
+
"name": "PDI — Temporary Resident Program (visitors, eTA, TRV, permits)",
|
| 47 |
+
"index": _OBM + "temporary-residents.html",
|
| 48 |
+
"scope": "/temporary-residents"},
|
| 49 |
+
{"code": "pdi-refugee",
|
| 50 |
+
"short": "PDI Refugee Protection",
|
| 51 |
+
"name": "PDI — Refugee Protection (claims, eligibility, port of entry)",
|
| 52 |
+
"index": _OBM + "refugee-protection.html",
|
| 53 |
+
"scope": "/refugee-protection"},
|
| 54 |
+
{"code": "pdi-standard",
|
| 55 |
+
"short": "PDI Standard Requirements",
|
| 56 |
+
"name": "PDI — Standard requirements (incl. Tran serious-criminality "
|
| 57 |
+
"assessment)",
|
| 58 |
+
"index": _OBM + "standard-requirements.html",
|
| 59 |
+
"scope": "/standard-requirements"},
|
| 60 |
+
{"code": "pdi-delegation",
|
| 61 |
+
"short": "PDI Designation-Delegation",
|
| 62 |
+
"name": "PDI — Designation of officers and delegation of authority",
|
| 63 |
+
"index": _OBM + "legislation.html",
|
| 64 |
+
"scope": "operational-bulletins-manuals/legislation"},
|
| 65 |
+
{"code": "pdi-identity",
|
| 66 |
+
"short": "PDI Biometrics-Identity",
|
| 67 |
+
"name": "PDI — Biometrics and identity management",
|
| 68 |
+
"index": _OBM + "identity-management.html",
|
| 69 |
+
"scope": "/identity-management"},
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
| 73 |
+
_TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
|
| 74 |
+
_LINK = re.compile(r'href="([^"#?]*?\.html)"')
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _strip_tags(html):
|
| 78 |
+
html = re.sub(r"</t[dh]>", " | ", html)
|
| 79 |
+
html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
|
| 80 |
+
html = re.sub(r"<[^>]+>", " ", html)
|
| 81 |
+
return [l for l in (_norm(x).strip("| ").strip()
|
| 82 |
+
for x in html.split("\n")) if l]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _main(html):
|
| 86 |
+
m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
|
| 87 |
+
return m.group(1) if m else html
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _page_chunks(src, page_url, html, page_no):
|
| 91 |
+
content = _main(html)
|
| 92 |
+
title_m = _TITLE.search(content)
|
| 93 |
+
title = (_norm(re.sub(r"<[^>]+>", " ", title_m.group(1)))
|
| 94 |
+
if title_m else src["short"])
|
| 95 |
+
mod = _MODIFIED.search(html)
|
| 96 |
+
modified = mod.group(1) if mod else ""
|
| 97 |
+
# Split on h2 headings; each becomes a section-like unit.
|
| 98 |
+
parts = re.split(r"<h2[^>]*>", content[title_m.end():] if title_m else content)
|
| 99 |
+
chunks = []
|
| 100 |
+
for i, part in enumerate(parts):
|
| 101 |
+
if i == 0:
|
| 102 |
+
heading, body_html = title, part
|
| 103 |
+
else:
|
| 104 |
+
head_end = part.find("</h2>")
|
| 105 |
+
heading = _norm(re.sub(r"<[^>]+>", " ", part[:head_end]))
|
| 106 |
+
body_html = part[head_end:]
|
| 107 |
+
body = "\n".join(_strip_tags(body_html))
|
| 108 |
+
if len(body) < 120:
|
| 109 |
+
continue
|
| 110 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 111 |
+
total = len(pieces)
|
| 112 |
+
for k, piece in enumerate(pieces, start=1):
|
| 113 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 114 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 115 |
+
chunks.append({
|
| 116 |
+
"id": f"{src['code']}-{page_no}-{i}{suffix}",
|
| 117 |
+
"doc_type": "memorandum",
|
| 118 |
+
"act_code": src["code"].upper(),
|
| 119 |
+
"act_short": src["short"],
|
| 120 |
+
"act_name": src["name"],
|
| 121 |
+
"section": f"{title[:60]} — {heading[:60]}" if i else title[:80],
|
| 122 |
+
"marginal_note": heading,
|
| 123 |
+
"part": src["name"],
|
| 124 |
+
"division": "",
|
| 125 |
+
"heading": "",
|
| 126 |
+
"text": piece,
|
| 127 |
+
"history": "",
|
| 128 |
+
"last_amended": modified,
|
| 129 |
+
"current_to": modified,
|
| 130 |
+
"citation": f"{src['short']}: {heading}{note}",
|
| 131 |
+
"source_url": page_url,
|
| 132 |
+
})
|
| 133 |
+
return chunks
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def preserve_failed(chunks, failures, stored):
|
| 137 |
+
"""Re-attach the last-good chunks of sub-pages this run could not fetch.
|
| 138 |
+
|
| 139 |
+
Ingestion is a full rebuild, so a sub-page that 404s or drops its
|
| 140 |
+
connection silently vanishes from the corpus; serving the copy we already
|
| 141 |
+
had beats losing instructions that are still in force. Keyed on source_url,
|
| 142 |
+
the only per-page identity a PDI chunk carries that survives pages coming
|
| 143 |
+
and going -- the chunk id embeds the page's position in the index listing,
|
| 144 |
+
which shifts whenever one page drops out. Restricted to pages that actually
|
| 145 |
+
errored, so a sub-page IRCC retired on purpose is not resurrected.
|
| 146 |
+
Returns (chunks, preserved page URLs).
|
| 147 |
+
"""
|
| 148 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["source_url"],
|
| 149 |
+
only={url for url, _why in failures})
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _crawl(src, failures):
|
| 153 |
+
"""Breadth-first crawl of one PDI category: index -> in-scope links ->
|
| 154 |
+
their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
|
| 155 |
+
Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
|
| 156 |
+
failures land in `failures` so preserve_failed keeps their stored
|
| 157 |
+
copies."""
|
| 158 |
+
index_html = fetch_cached(
|
| 159 |
+
src["index"], RAW / f"{src['code']}-index.html",
|
| 160 |
+
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 161 |
+
pages = [(src["index"], index_html)]
|
| 162 |
+
if not src["scope"]:
|
| 163 |
+
return pages
|
| 164 |
+
seen = {src["index"]}
|
| 165 |
+
frontier = [(src["index"], index_html)]
|
| 166 |
+
dropped = 0
|
| 167 |
+
for _depth in range(_MAX_DEPTH):
|
| 168 |
+
next_frontier = []
|
| 169 |
+
for _url, html in frontier:
|
| 170 |
+
for href in _LINK.findall(_main(html)):
|
| 171 |
+
if src["scope"] not in href:
|
| 172 |
+
continue
|
| 173 |
+
url = href if href.startswith("http") else \
|
| 174 |
+
"https://www.canada.ca" + href
|
| 175 |
+
if url in seen:
|
| 176 |
+
continue
|
| 177 |
+
seen.add(url)
|
| 178 |
+
if len(pages) >= _MAX_PAGES:
|
| 179 |
+
dropped += 1
|
| 180 |
+
continue
|
| 181 |
+
name = url.rsplit("/", 1)[1].replace(".html", "")
|
| 182 |
+
try:
|
| 183 |
+
page = fetch_cached(
|
| 184 |
+
url, RAW / f"{src['code']}-{name}.html",
|
| 185 |
+
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 186 |
+
except Exception as exc:
|
| 187 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 188 |
+
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 189 |
+
continue
|
| 190 |
+
pages.append((url, page))
|
| 191 |
+
next_frontier.append((url, page))
|
| 192 |
+
frontier = next_frontier
|
| 193 |
+
if dropped:
|
| 194 |
+
print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
|
| 195 |
+
f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
|
| 196 |
+
return pages
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def build(allow_shrink=False):
|
| 200 |
+
"""Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
|
| 201 |
+
all_chunks, failures = [], []
|
| 202 |
+
for src in SETS:
|
| 203 |
+
try:
|
| 204 |
+
pages = _crawl(src, failures)
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
|
| 207 |
+
print(f" !! {src['short']}: index fetch failed "
|
| 208 |
+
f"({type(exc).__name__}: {exc})")
|
| 209 |
+
continue
|
| 210 |
+
n = 0
|
| 211 |
+
for page_no, (url, html) in enumerate(pages):
|
| 212 |
+
chunks = _page_chunks(src, url, html, page_no)
|
| 213 |
+
if not chunks:
|
| 214 |
+
# Downloaded fine, parsed to nothing: the page template moved
|
| 215 |
+
# under us. That is exactly how the D-memo corpus was lost, so
|
| 216 |
+
# it counts as a failure and the stored page is kept.
|
| 217 |
+
print(f" !! {url.rsplit('/', 1)[-1]}: no content parsed; "
|
| 218 |
+
f"keeping stored copy")
|
| 219 |
+
failures.append((url, "no content parsed"))
|
| 220 |
+
continue
|
| 221 |
+
all_chunks.extend(chunks)
|
| 222 |
+
n += len(chunks)
|
| 223 |
+
print(f" {src['short']}: {len(pages)} pages, {n} chunks")
|
| 224 |
+
|
| 225 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 226 |
+
stored_chunks(OUT_FILE))
|
| 227 |
+
if preserved:
|
| 228 |
+
names = ", ".join(u.rsplit("/", 1)[-1] for u in preserved)
|
| 229 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 230 |
+
f"page(s): {names}")
|
| 231 |
+
# After preservation, not before: a preserved page brings back the ids it
|
| 232 |
+
# was written with, and this run may have handed those ids to other pages
|
| 233 |
+
# (an id embeds the page's position in the index listing).
|
| 234 |
+
uniquify_ids(all_chunks)
|
| 235 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 236 |
+
allow_shrink=allow_shrink):
|
| 237 |
+
return False
|
| 238 |
+
print(f"pdi: {len(all_chunks)} chunks -> {OUT_FILE.name}")
|
| 239 |
+
return all_chunks
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def main():
|
| 243 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 244 |
+
# empty corpus succeeded, a refused write did not.
|
| 245 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
|
| 246 |
+
sys.exit(0 if ok else 1)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
main()
|
canlex/refresh.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Detect when CanLex's ingested sources have gone stale upstream.
|
| 2 |
+
|
| 3 |
+
py -m canlex.refresh --check
|
| 4 |
+
|
| 5 |
+
For every Justice Laws instrument in config.SOURCES, this fetches just the
|
| 6 |
+
opening of the XML (the root <Statute>/<Regulation> element carries the
|
| 7 |
+
consolidation date in its lims:current-date attribute, so a few KB is enough)
|
| 8 |
+
and compares that upstream date against the current_to stored in the matching
|
| 9 |
+
data/processed/<code>.json. Anything where upstream is newer is reported as
|
| 10 |
+
drift, and the process exits non-zero -- so this can gate a deploy or run on a
|
| 11 |
+
schedule.
|
| 12 |
+
|
| 13 |
+
It also checks the two delegation instruments against upstream (check_delegation):
|
| 14 |
+
the CBSA IRPA delegation -- by diffing the dated 'irpa-lipr-YYYY-MM-DD' instruments
|
| 15 |
+
the CBSA legislation index publishes against those delegation.SOURCES ingests, so
|
| 16 |
+
a newly-published amendment is flagged -- and the IRCC IL3 instrument, by comparing
|
| 17 |
+
the version stamp in the live PDF against the stored one. The NJC directives get
|
| 18 |
+
the same treatment (check_directives): the effective date each chunk stores in
|
| 19 |
+
current_to comes from the NJC directive index (the span.date beside each entry),
|
| 20 |
+
so one fresh index fetch re-reads it for nearly every directive, with the
|
| 21 |
+
'effective <Month D, YYYY>' prose on the directive's own page as fallback for
|
| 22 |
+
entries the index leaves undated (the Foreign Service Directives) -- exactly the
|
| 23 |
+
two signals ingestion uses. Finally it prints the stored currency of the
|
| 24 |
+
remaining non-XML sources (D-memos, case law, agreements, benefits, tariff
|
| 25 |
+
schedule), which expose no comparable upstream signal, with the command to
|
| 26 |
+
refresh each; and flags any tracked pending bill whose coming-into-force date
|
| 27 |
+
has passed.
|
| 28 |
+
|
| 29 |
+
This is the staleness detection the corpus previously lacked: legislation was
|
| 30 |
+
refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
|
| 31 |
+
|
| 32 |
+
py -m canlex.refresh --check # human-readable; exit 1 on drift
|
| 33 |
+
py -m canlex.refresh --check --json # machine-readable for CI
|
| 34 |
+
"""
|
| 35 |
+
import datetime
|
| 36 |
+
import json
|
| 37 |
+
import re
|
| 38 |
+
import sys
|
| 39 |
+
import tempfile
|
| 40 |
+
import time
|
| 41 |
+
import urllib.request
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
|
| 44 |
+
from bs4 import BeautifulSoup
|
| 45 |
+
|
| 46 |
+
from . import delegation, directive, dmemo
|
| 47 |
+
from ._common import BROWSER_UA, MONTHS
|
| 48 |
+
from .config import SOURCES, PROCESSED_DIR
|
| 49 |
+
|
| 50 |
+
_UA = "CanLex-refresh/0.1"
|
| 51 |
+
_HEAD_BYTES = 16384 # the root element's attributes sit at the very top
|
| 52 |
+
_CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
|
| 53 |
+
|
| 54 |
+
# Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
|
| 55 |
+
# command, description). These carry no single upstream consolidation date, so
|
| 56 |
+
# the check reports their STORED currency (newest item) and how to refresh them
|
| 57 |
+
# rather than diffing against upstream. (Delegation and the NJC directives are
|
| 58 |
+
# checked against upstream separately, by check_delegation and check_directives,
|
| 59 |
+
# because their sources DO expose a comparable signal -- the CBSA instrument
|
| 60 |
+
# index and IL3 version string, and the NJC directive index dates.)
|
| 61 |
+
_NON_XML = [
|
| 62 |
+
("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
|
| 63 |
+
("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
|
| 64 |
+
("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
|
| 65 |
+
("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
|
| 66 |
+
("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
|
| 67 |
+
"Customs Tariff Schedule ch. 98-99"),
|
| 68 |
+
("enf", "enf.json", "py -m canlex.enf", "ENF operational manuals"),
|
| 69 |
+
("amps", "amps.json", "py -m canlex.amps", "AMPS Master Penalty Document"),
|
| 70 |
+
("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
|
| 71 |
+
("commentary", "commentary.json", "py -m canlex.commentary",
|
| 72 |
+
"Curated commentary chunks"),
|
| 73 |
+
("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
|
| 74 |
+
("pdi", "pdi.json", "py -m canlex.pdi",
|
| 75 |
+
"IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
|
| 76 |
+
("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
|
| 77 |
+
"Mémorandums D (français)"),
|
| 78 |
+
("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
|
| 79 |
+
"Directives du CNM (français)"),
|
| 80 |
+
("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
|
| 81 |
+
"IRB Chairperson's Guidelines"),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
_CIU_DECISIONS = "https://ciu-sdi.ca/en/important-decisions/"
|
| 86 |
+
_CIU_DEC_MARKER = PROCESSED_DIR.parent / "raw" / "ciu" / "decisions-page-date.txt"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def check_ciu_decisions():
|
| 90 |
+
"""New-case-law tripwire: the CIU curates its Important Decisions page,
|
| 91 |
+
so its dateModified moving is a cheap signal that a new decision worth
|
| 92 |
+
ingesting has issued. This is the only automated freshness signal the
|
| 93 |
+
case-law corpus has -- courts publish no 'new leading case' feed -- and
|
| 94 |
+
it complements the quarterly gap-audit ritual rather than replacing it."""
|
| 95 |
+
try:
|
| 96 |
+
req = urllib.request.Request(
|
| 97 |
+
_CIU_DECISIONS, headers={"User-Agent": BROWSER_UA})
|
| 98 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 99 |
+
html = resp.read().decode("utf-8", "replace")
|
| 100 |
+
m = re.search(r'"dateModified":"(\d{4}-\d{2}-\d{2})', html)
|
| 101 |
+
remote = m.group(1) if m else ""
|
| 102 |
+
except Exception as exc:
|
| 103 |
+
return {"stored": "", "remote": "", "status": "error",
|
| 104 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 105 |
+
if not remote:
|
| 106 |
+
return {"stored": "", "remote": "", "status": "no-date", "error": ""}
|
| 107 |
+
stored = (_CIU_DEC_MARKER.read_text(encoding="utf-8").strip()
|
| 108 |
+
if _CIU_DEC_MARKER.exists() else "")
|
| 109 |
+
if not stored:
|
| 110 |
+
# First run: record the baseline rather than alarming.
|
| 111 |
+
_CIU_DEC_MARKER.parent.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
_CIU_DEC_MARKER.write_text(remote, encoding="utf-8")
|
| 113 |
+
stored = remote
|
| 114 |
+
status = "stale" if remote > stored else "ok"
|
| 115 |
+
return {"stored": stored, "remote": remote, "status": status, "error": ""}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def check_union():
|
| 119 |
+
"""Upstream drift for the CIU/PSAC documents (incl. every site VSSA).
|
| 120 |
+
|
| 121 |
+
The union reposts amended documents at the same URLs, so the drift
|
| 122 |
+
signal is each PDF's Last-Modified header against our cached copy's
|
| 123 |
+
fetch time. New/removed VSSAs surface as fetch errors or new links on
|
| 124 |
+
the next canlex.ciu run; this check covers content changes to what we
|
| 125 |
+
already hold."""
|
| 126 |
+
import email.utils
|
| 127 |
+
from . import ciu
|
| 128 |
+
rows = []
|
| 129 |
+
targets = [(short, lang, ciu.RAW / f"{code}{'-fr' if lang == 'fr' else ''}.pdf", url)
|
| 130 |
+
for code, short, name, url, lang in ciu.DOCS]
|
| 131 |
+
try:
|
| 132 |
+
targets += [(f"VSSA {stem}", "en",
|
| 133 |
+
ciu.RAW / f"vssa-{stem.lower()}.pdf", url)
|
| 134 |
+
for url, stem in ciu._vssa_links()]
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
rows.append({"short": "VSSA index", "lang": "en", "status": "error",
|
| 137 |
+
"remote": "", "error": f"{type(exc).__name__}: {exc}"})
|
| 138 |
+
for short, lang, cache, url in targets:
|
| 139 |
+
try:
|
| 140 |
+
req = urllib.request.Request(url, method="HEAD",
|
| 141 |
+
headers={"User-Agent": BROWSER_UA})
|
| 142 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 143 |
+
lm = resp.headers.get("Last-Modified", "")
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
rows.append({"short": short, "lang": lang, "status": "error",
|
| 146 |
+
"remote": "", "error": f"{type(exc).__name__}: {exc}"})
|
| 147 |
+
continue
|
| 148 |
+
if not lm:
|
| 149 |
+
rows.append({"short": short, "lang": lang, "status": "no-date",
|
| 150 |
+
"remote": "", "error": ""})
|
| 151 |
+
continue
|
| 152 |
+
remote_ts = email.utils.parsedate_to_datetime(lm).timestamp()
|
| 153 |
+
local_ts = cache.stat().st_mtime if cache.exists() else 0
|
| 154 |
+
rows.append({"short": short, "lang": lang,
|
| 155 |
+
"status": "stale" if remote_ts > local_ts else "ok",
|
| 156 |
+
"remote": lm[:25], "error": ""})
|
| 157 |
+
time.sleep(0.3)
|
| 158 |
+
return rows
|
| 159 |
+
|
| 160 |
+
# Curated datasets age: state legislatures amend deferred-adjudication
|
| 161 |
+
# statutes every session, so a reviewed date older than this many days earns
|
| 162 |
+
# a warning even though nothing upstream can be diffed automatically.
|
| 163 |
+
_CURATED_MAX_AGE_DAYS = 180
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def check_curated(today):
|
| 167 |
+
"""Age-check the curated datasets and detect edit-without-regen drift.
|
| 168 |
+
|
| 169 |
+
Two signals: (1) each data/curated/*.json 'reviewed' date older than
|
| 170 |
+
_CURATED_MAX_AGE_DAYS -> aging (re-verify against the state sources);
|
| 171 |
+
(2) the newest commentary.json current_to differing from the newest
|
| 172 |
+
curated 'reviewed' -> the curated file was edited without rerunning
|
| 173 |
+
canlex.commentary + canlex.embed, so production serves stale chunks."""
|
| 174 |
+
from .config import DATA_DIR
|
| 175 |
+
rows = []
|
| 176 |
+
newest_reviewed = ""
|
| 177 |
+
for path in sorted((DATA_DIR / "curated").glob("*.json")):
|
| 178 |
+
try:
|
| 179 |
+
reviewed = json.loads(
|
| 180 |
+
path.read_text(encoding="utf-8")).get("reviewed", "")
|
| 181 |
+
except Exception as exc:
|
| 182 |
+
rows.append({"file": path.name, "reviewed": "",
|
| 183 |
+
"status": "error", "error": f"{type(exc).__name__}: {exc}"})
|
| 184 |
+
continue
|
| 185 |
+
newest_reviewed = max(newest_reviewed, reviewed or "")
|
| 186 |
+
status = "ok"
|
| 187 |
+
if reviewed:
|
| 188 |
+
age = (datetime.date.fromisoformat(today)
|
| 189 |
+
- datetime.date.fromisoformat(reviewed)).days
|
| 190 |
+
if age > _CURATED_MAX_AGE_DAYS:
|
| 191 |
+
status = "aging"
|
| 192 |
+
rows.append({"file": path.name, "reviewed": reviewed,
|
| 193 |
+
"status": status, "error": ""})
|
| 194 |
+
commentary = PROCESSED_DIR / "commentary.json"
|
| 195 |
+
if newest_reviewed and commentary.exists():
|
| 196 |
+
chunks = json.loads(commentary.read_text(encoding="utf-8"))
|
| 197 |
+
newest_chunk = max((c.get("current_to", "") for c in chunks), default="")
|
| 198 |
+
if newest_chunk != newest_reviewed:
|
| 199 |
+
rows.append({"file": "commentary.json",
|
| 200 |
+
"reviewed": newest_chunk, "status": "drift",
|
| 201 |
+
"error": f"curated reviewed {newest_reviewed} but "
|
| 202 |
+
f"chunks say {newest_chunk or '?'} -- rerun "
|
| 203 |
+
f"canlex.commentary && canlex.embed"})
|
| 204 |
+
return rows
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def check_tariff_edition(today):
|
| 208 |
+
"""Probe whether CBSA has published a Customs Tariff edition newer than
|
| 209 |
+
the ingested one. The tariff pages live under a year-stamped path
|
| 210 |
+
(.../tariff-tarif/<year>/html/...), so probing the ingested-edition-plus-
|
| 211 |
+
one URL (and the current calendar year's) is a reliable new-edition
|
| 212 |
+
signal without parsing anything."""
|
| 213 |
+
from . import tariff_schedule
|
| 214 |
+
src = next(iter(tariff_schedule.SOURCES.values()))
|
| 215 |
+
have = int(src["edition"])
|
| 216 |
+
candidates = sorted({have + 1, int(today[:4])} - {have})
|
| 217 |
+
for year in candidates:
|
| 218 |
+
if year <= have:
|
| 219 |
+
continue
|
| 220 |
+
probe = src["url"].replace(f"/{src['edition']}/", f"/{year}/")
|
| 221 |
+
try:
|
| 222 |
+
req = urllib.request.Request(
|
| 223 |
+
probe, method="HEAD", headers={"User-Agent": BROWSER_UA})
|
| 224 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 225 |
+
if resp.status == 200:
|
| 226 |
+
return {"stored": str(have), "remote": str(year),
|
| 227 |
+
"status": "stale", "error": ""}
|
| 228 |
+
except Exception:
|
| 229 |
+
continue # 404 = that edition doesn't exist yet
|
| 230 |
+
return {"stored": str(have), "remote": "", "status": "ok", "error": ""}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def dmemo_drift(raw, stored):
|
| 234 |
+
"""(newest active LAST_UPDATED, memos newer than `stored`) from the CBSA
|
| 235 |
+
D-memo datasource JSON. Pure, for testability."""
|
| 236 |
+
rows = [r for r in json.loads(raw).get("data", [])
|
| 237 |
+
if str(r.get("STATUS", "")).strip().lower() != "cancelled"]
|
| 238 |
+
remote = max((r.get("LAST_UPDATED", "") for r in rows), default="")
|
| 239 |
+
drifted = sorted(_dmemo_number(r) for r in rows
|
| 240 |
+
if stored and r.get("LAST_UPDATED", "") > stored)
|
| 241 |
+
return remote, drifted
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _dmemo_number(row):
|
| 245 |
+
"""'D1-2-1' out of a datasource NUMBER cell (an anchor, or bare text)."""
|
| 246 |
+
cell = str(row.get("NUMBER", ""))
|
| 247 |
+
inner = re.search(r">([^<]+)<", cell)
|
| 248 |
+
return (inner.group(1) if inner else re.sub(r"<[^>]+>", "", cell)).strip()
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def check_dmemo_index():
|
| 252 |
+
"""Newest per-memo 'Last updated' upstream vs stored currency.
|
| 253 |
+
|
| 254 |
+
This reads the JSON datasource behind the index table, NOT dates in the
|
| 255 |
+
index HTML: since CBSA rebuilt the page (2026-07-10) the newest date in the
|
| 256 |
+
markup is the page's own 'Date modified', which moves when the page is
|
| 257 |
+
rebuilt and no memo has changed -- that would report stale forever. The
|
| 258 |
+
datasource carries a LAST_UPDATED per memo, so drift is both true and named.
|
| 259 |
+
"""
|
| 260 |
+
stored = ""
|
| 261 |
+
path = PROCESSED_DIR / "dmemos.json"
|
| 262 |
+
if path.exists():
|
| 263 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 264 |
+
stored = max((c.get("current_to", "") for c in chunks), default="")
|
| 265 |
+
try:
|
| 266 |
+
req = urllib.request.Request(
|
| 267 |
+
dmemo.DATASOURCE_URL, headers={"User-Agent": BROWSER_UA})
|
| 268 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 269 |
+
remote, drifted = dmemo_drift(resp.read(), stored)
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
return {"stored": stored, "remote": "", "drifted": [], "status": "error",
|
| 272 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 273 |
+
status = "stale" if (remote and stored and remote > stored) else \
|
| 274 |
+
("ok" if remote else "no-date")
|
| 275 |
+
return {"stored": stored, "remote": remote, "drifted": drifted,
|
| 276 |
+
"status": status, "error": ""}
|
| 277 |
+
|
| 278 |
+
# NJC directive dates as published: the index prints prose ('June 1, 1993' --
|
| 279 |
+
# double spaces occur in the raw markup) while chunks may store either that
|
| 280 |
+
# prose or the ISO fallback directive.py derives from page text, so both sides
|
| 281 |
+
# are normalized to ISO before comparing.
|
| 282 |
+
_NJC_PROSE_DATE = re.compile(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})")
|
| 283 |
+
_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
| 284 |
+
|
| 285 |
+
# CBSA publishes its IRPA delegation as a base instrument plus incremental
|
| 286 |
+
# amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
|
| 287 |
+
# linked from the CBSA legislation index. A date on the index that we have not
|
| 288 |
+
# ingested means a new amendment to add to delegation.SOURCES.
|
| 289 |
+
_CBSA_INDEX_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
|
| 290 |
+
"legislation-eng.html")
|
| 291 |
+
_CBSA_INSTRUMENT_RE = re.compile(r"irpa-lipr-(\d{4}-\d{2}-\d{2})-eng\.html")
|
| 292 |
+
# The IRCC IL3 instrument is reissued with a seasonal version stamp on page 1.
|
| 293 |
+
_IL3_VERSION_RE = re.compile(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}")
|
| 294 |
+
|
| 295 |
+
# Bills known to be heading into force that affect the corpus. Surfaced once
|
| 296 |
+
# their in-force date has passed, with the codes to re-ingest. (The legislation
|
| 297 |
+
# drift check above will independently flag the affected Act once Justice Laws
|
| 298 |
+
# consolidates the amendment; this list just names the cause.)
|
| 299 |
+
_PENDING_BILLS = [
|
| 300 |
+
{"bill": "C-16", "name": "Protecting Victims Act",
|
| 301 |
+
"in_force": "2026-07-18", "affects": ["C-46"],
|
| 302 |
+
"note": "Criminal Code: coercive control, child-protection reporting "
|
| 303 |
+
"(coercive-control provisions come into force later)."},
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _extract_current_date(raw):
|
| 308 |
+
"""Pull the lims:current-date (YYYY-MM-DD) from the head of a Justice Laws
|
| 309 |
+
XML document. Pure and offline, for testability. Returns '' if absent."""
|
| 310 |
+
if raw[:3] == b"\xef\xbb\xbf":
|
| 311 |
+
raw = raw[3:]
|
| 312 |
+
m = _CURRENT_DATE.search(raw)
|
| 313 |
+
return m.group(1).decode("ascii") if m else ""
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _local_current_to(code):
|
| 317 |
+
"""The current_to stored in data/processed/<code>.json (first chunk), or ''."""
|
| 318 |
+
path = PROCESSED_DIR / f"{code}.json"
|
| 319 |
+
if not path.exists():
|
| 320 |
+
return None
|
| 321 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 322 |
+
return chunks[0].get("current_to", "") if chunks else ""
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _remote_current_date(url):
|
| 326 |
+
"""Fetch only the head of the XML and extract its consolidation date.
|
| 327 |
+
Returns (date, error): date is '' on a clean fetch that lacked the
|
| 328 |
+
attribute, error is a short string when the fetch itself failed."""
|
| 329 |
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
| 330 |
+
try:
|
| 331 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 332 |
+
head = resp.read(_HEAD_BYTES)
|
| 333 |
+
return _extract_current_date(head), ""
|
| 334 |
+
except Exception as exc: # network / HTTP / timeout
|
| 335 |
+
return "", f"{type(exc).__name__}: {exc}"
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def check_legislation():
|
| 339 |
+
"""Compare each Justice Laws source's upstream date to the stored corpus.
|
| 340 |
+
Returns a list of row dicts."""
|
| 341 |
+
rows = []
|
| 342 |
+
for code, src in SOURCES.items():
|
| 343 |
+
local = _local_current_to(code)
|
| 344 |
+
remote, error = _remote_current_date(src["xml_url"])
|
| 345 |
+
if error:
|
| 346 |
+
status = "error"
|
| 347 |
+
elif local is None:
|
| 348 |
+
status = "missing-local" # configured but never ingested
|
| 349 |
+
elif remote and remote > local:
|
| 350 |
+
status = "stale" # upstream newer than our corpus
|
| 351 |
+
elif not remote:
|
| 352 |
+
status = "no-date" # fetched, but no date found (unusual)
|
| 353 |
+
else:
|
| 354 |
+
status = "ok"
|
| 355 |
+
rows.append({"code": code, "short": src["short"], "local": local or "",
|
| 356 |
+
"remote": remote, "status": status, "error": error})
|
| 357 |
+
time.sleep(0.3) # be polite to Justice Laws
|
| 358 |
+
return rows
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def non_xml_currency():
|
| 362 |
+
"""Stored currency (newest item) of the aggregate sources."""
|
| 363 |
+
rows = []
|
| 364 |
+
for label, fname, cmd, desc in _NON_XML:
|
| 365 |
+
path = PROCESSED_DIR / fname
|
| 366 |
+
if not path.exists():
|
| 367 |
+
rows.append({"label": label, "desc": desc, "cmd": cmd,
|
| 368 |
+
"newest": "", "chunks": 0, "present": False})
|
| 369 |
+
continue
|
| 370 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 371 |
+
newest = max((c.get("current_to", "") for c in chunks), default="")
|
| 372 |
+
rows.append({"label": label, "desc": desc, "cmd": cmd,
|
| 373 |
+
"newest": newest, "chunks": len(chunks), "present": True})
|
| 374 |
+
return rows
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def _cbsa_ingested_dates():
|
| 378 |
+
"""The effective dates of the CBSA dated delegation instruments we ingest."""
|
| 379 |
+
return sorted({s["effective"] for s in delegation.SOURCES.values()
|
| 380 |
+
if s.get("kind") == "html-cbsa" and s.get("effective")})
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _ircc_stored_version():
|
| 384 |
+
"""The IL3 version string recorded in the ingested corpus, or ''."""
|
| 385 |
+
path = PROCESSED_DIR / "delegation.json"
|
| 386 |
+
if not path.exists():
|
| 387 |
+
return ""
|
| 388 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 389 |
+
versions = [c.get("current_to", "") for c in chunks
|
| 390 |
+
if c.get("act_short") == "IRCC IL3" and c.get("current_to")]
|
| 391 |
+
return max(versions) if versions else ""
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def check_delegation():
|
| 395 |
+
"""Compare the delegation instruments against upstream. Returns row dicts.
|
| 396 |
+
|
| 397 |
+
CBSA: diff the dated instruments published on the legislation index against
|
| 398 |
+
those we ingest -- a new date means an un-ingested amendment. IRCC: compare
|
| 399 |
+
the IL3 version stamp published in the live PDF against the stored one."""
|
| 400 |
+
rows = []
|
| 401 |
+
|
| 402 |
+
# --- CBSA: index diff (cheap; one HTML fetch) ---
|
| 403 |
+
have = _cbsa_ingested_dates()
|
| 404 |
+
try:
|
| 405 |
+
req = urllib.request.Request(_CBSA_INDEX_URL,
|
| 406 |
+
headers={"User-Agent": BROWSER_UA})
|
| 407 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 408 |
+
html = resp.read().decode("utf-8", "replace")
|
| 409 |
+
published = sorted(set(_CBSA_INSTRUMENT_RE.findall(html)))
|
| 410 |
+
new = sorted(set(published) - set(have))
|
| 411 |
+
rows.append({"name": "CBSA IRPA delegation (+ amendments)",
|
| 412 |
+
"status": "stale" if new else "ok",
|
| 413 |
+
"have": have, "published": published, "new": new,
|
| 414 |
+
"error": ""})
|
| 415 |
+
except Exception as exc:
|
| 416 |
+
rows.append({"name": "CBSA IRPA delegation (+ amendments)",
|
| 417 |
+
"status": "error", "have": have, "published": [],
|
| 418 |
+
"new": [], "error": f"{type(exc).__name__}: {exc}"})
|
| 419 |
+
|
| 420 |
+
# --- IRCC IL3: version-stamp compare (re-fetches the PDF via PowerShell) ---
|
| 421 |
+
stored = _ircc_stored_version()
|
| 422 |
+
try:
|
| 423 |
+
with tempfile.TemporaryDirectory() as td:
|
| 424 |
+
data = delegation._fetch(delegation.SOURCES["ircc"]["url"],
|
| 425 |
+
Path(td) / "il3.pdf", powershell=True)
|
| 426 |
+
pages = delegation._pdf_pages(data)
|
| 427 |
+
m = _IL3_VERSION_RE.search(pages[0]) if pages else None
|
| 428 |
+
published = m.group(0) if m else ""
|
| 429 |
+
if not published:
|
| 430 |
+
status = "no-version"
|
| 431 |
+
elif published != stored:
|
| 432 |
+
status = "stale"
|
| 433 |
+
else:
|
| 434 |
+
status = "ok"
|
| 435 |
+
rows.append({"name": "IRCC IL3 instrument", "status": status,
|
| 436 |
+
"have": [stored], "published": [published], "new": [],
|
| 437 |
+
"error": ""})
|
| 438 |
+
except Exception as exc:
|
| 439 |
+
rows.append({"name": "IRCC IL3 instrument", "status": "error",
|
| 440 |
+
"have": [stored], "published": [], "new": [],
|
| 441 |
+
"error": f"{type(exc).__name__}: {exc}"})
|
| 442 |
+
return rows
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _stored_directives():
|
| 446 |
+
"""Per-directive stored currency from data/processed/directives.json:
|
| 447 |
+
{code: (title, current_to, source_url)}, one entry per act_code."""
|
| 448 |
+
path = PROCESSED_DIR / "directives.json"
|
| 449 |
+
if not path.exists():
|
| 450 |
+
return {}
|
| 451 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 452 |
+
out = {}
|
| 453 |
+
for c in chunks:
|
| 454 |
+
code = c.get("act_code")
|
| 455 |
+
if code and code not in out:
|
| 456 |
+
out[code] = (c.get("act_short", ""), c.get("current_to", ""),
|
| 457 |
+
c.get("source_url", ""))
|
| 458 |
+
return out
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _directive_iso(text):
|
| 462 |
+
"""Normalize an NJC directive date ('June 1, 1993' or already ISO) to
|
| 463 |
+
YYYY-MM-DD. Pure, for testability. Returns '' when not a recognizable date."""
|
| 464 |
+
text = (text or "").strip()
|
| 465 |
+
if _ISO_DATE.fullmatch(text):
|
| 466 |
+
return text
|
| 467 |
+
m = _NJC_PROSE_DATE.fullmatch(text)
|
| 468 |
+
if m and m.group(1).lower() in MONTHS:
|
| 469 |
+
return (f"{int(m.group(3)):04d}-{MONTHS[m.group(1).lower()]:02d}-"
|
| 470 |
+
f"{int(m.group(2)):02d}")
|
| 471 |
+
return ""
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _njc_index_dates(html):
|
| 475 |
+
"""{code: date text} parsed from the NJC directive index, mirroring
|
| 476 |
+
directive.directive_links: the first <a> in each top-level li of
|
| 477 |
+
ul.directive-list is the current directive (nested lis are archived
|
| 478 |
+
versions), and the sibling span.date its effective date ('' if absent)."""
|
| 479 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 480 |
+
ul = soup.find("ul", class_="directive-list")
|
| 481 |
+
out = {}
|
| 482 |
+
if ul is None:
|
| 483 |
+
return out
|
| 484 |
+
for li in ul.find_all("li", recursive=False):
|
| 485 |
+
a = li.find("a", href=True)
|
| 486 |
+
if not a:
|
| 487 |
+
continue
|
| 488 |
+
m = directive._CODE.search(a["href"])
|
| 489 |
+
if not m:
|
| 490 |
+
continue
|
| 491 |
+
span = li.find("span", class_="date")
|
| 492 |
+
out[m.group(1)] = span.get_text(strip=True) if span else ""
|
| 493 |
+
return out
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _njc_page_effective(url, td):
|
| 497 |
+
"""Fresh-fetch a directive's own page and pull its 'effective <Month D,
|
| 498 |
+
YYYY>' prose (directive._page_effective_date), following the 'Print Full
|
| 499 |
+
Directive' link when the landing page is only a table of contents (the
|
| 500 |
+
FSDs). Returns (iso_date, error)."""
|
| 501 |
+
try:
|
| 502 |
+
html = directive._fetch(url, td / "probe.html", force=True)
|
| 503 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 504 |
+
main = soup.find("main")
|
| 505 |
+
date = directive._page_effective_date(main) if main else ""
|
| 506 |
+
if not date:
|
| 507 |
+
print_url = directive._print_link(html)
|
| 508 |
+
if print_url:
|
| 509 |
+
full = directive._fetch(print_url, td / "probe-full.html",
|
| 510 |
+
force=True)
|
| 511 |
+
fmain = BeautifulSoup(full, "html.parser").find("main")
|
| 512 |
+
date = directive._page_effective_date(fmain) if fmain else ""
|
| 513 |
+
return date, ""
|
| 514 |
+
except Exception as exc:
|
| 515 |
+
return "", f"{type(exc).__name__}: {exc}"
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def check_directives():
|
| 519 |
+
"""Compare each ingested NJC directive's effective date against upstream.
|
| 520 |
+
Returns row dicts.
|
| 521 |
+
|
| 522 |
+
The date directive.py stores in current_to comes from the NJC directive
|
| 523 |
+
index (the span.date beside each entry), so one fresh index fetch covers
|
| 524 |
+
nearly every directive; entries the index leaves undated (the Foreign
|
| 525 |
+
Service Directives) fall back to the 'effective <Month D, YYYY>' prose on
|
| 526 |
+
the directive's own page -- the same two signals ingestion uses. Probe
|
| 527 |
+
fetches go to a temp dir, never data/raw, so a stale probe can't mask a
|
| 528 |
+
later real refresh; directive._fetch already sleeps 0.5s after each
|
| 529 |
+
download, so the probes stay polite without extra pauses here."""
|
| 530 |
+
stored = _stored_directives()
|
| 531 |
+
rows = []
|
| 532 |
+
if not stored:
|
| 533 |
+
return rows
|
| 534 |
+
with tempfile.TemporaryDirectory() as td:
|
| 535 |
+
td = Path(td)
|
| 536 |
+
try:
|
| 537 |
+
html = directive._fetch(directive.INDEX_URL, td / "_index.html",
|
| 538 |
+
force=True)
|
| 539 |
+
index_dates, index_error = _njc_index_dates(html), ""
|
| 540 |
+
except Exception as exc:
|
| 541 |
+
index_dates, index_error = {}, f"{type(exc).__name__}: {exc}"
|
| 542 |
+
for code, (title, stored_date, url) in sorted(stored.items()):
|
| 543 |
+
upstream, error = index_dates.get(code, ""), index_error
|
| 544 |
+
if not upstream and not error:
|
| 545 |
+
# Undated on the index: read the date off the page itself.
|
| 546 |
+
upstream, error = _njc_page_effective(url, td)
|
| 547 |
+
local, remote = _directive_iso(stored_date), _directive_iso(upstream)
|
| 548 |
+
if error:
|
| 549 |
+
status = "error"
|
| 550 |
+
elif not remote:
|
| 551 |
+
status = "no-date"
|
| 552 |
+
elif remote != local:
|
| 553 |
+
status = "stale" # NJC reissues in place: any drift counts
|
| 554 |
+
else:
|
| 555 |
+
status = "ok"
|
| 556 |
+
rows.append({"code": code, "title": title, "stored": stored_date,
|
| 557 |
+
"local": local, "remote": remote, "status": status,
|
| 558 |
+
"error": error})
|
| 559 |
+
return rows
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def pending_in_force(today):
|
| 563 |
+
"""Pending bills whose in-force date has passed as of `today` (ISO str)."""
|
| 564 |
+
return [b for b in _PENDING_BILLS if b["in_force"] <= today]
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def run(as_json=False):
|
| 568 |
+
today = datetime.date.today().isoformat()
|
| 569 |
+
leg = check_legislation()
|
| 570 |
+
deleg = check_delegation()
|
| 571 |
+
dirs = check_directives()
|
| 572 |
+
nonxml = non_xml_currency()
|
| 573 |
+
curated = check_curated(today)
|
| 574 |
+
dmemo_idx = check_dmemo_index()
|
| 575 |
+
tariff = check_tariff_edition(today)
|
| 576 |
+
union = check_union()
|
| 577 |
+
ciu_dec = check_ciu_decisions()
|
| 578 |
+
pending = pending_in_force(today)
|
| 579 |
+
stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
|
| 580 |
+
errors = [r for r in leg if r["status"] == "error"]
|
| 581 |
+
deleg_stale = [r for r in deleg if r["status"] == "stale"]
|
| 582 |
+
dirs_stale = [r for r in dirs if r["status"] == "stale"]
|
| 583 |
+
curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
|
| 584 |
+
dmemo_stale = dmemo_idx["status"] == "stale"
|
| 585 |
+
tariff_stale = tariff["status"] == "stale"
|
| 586 |
+
union_stale = [r for r in union if r["status"] == "stale"]
|
| 587 |
+
ciu_dec_stale = ciu_dec["status"] == "stale"
|
| 588 |
+
|
| 589 |
+
if as_json:
|
| 590 |
+
print(json.dumps({"checked": today, "legislation": leg,
|
| 591 |
+
"delegation": deleg, "directives": dirs,
|
| 592 |
+
"non_xml": nonxml, "curated": curated,
|
| 593 |
+
"dmemo_index": dmemo_idx, "tariff": tariff,
|
| 594 |
+
"union": union, "ciu_decisions": ciu_dec,
|
| 595 |
+
"pending_in_force": pending,
|
| 596 |
+
"stale_count": (len(stale) + len(deleg_stale)
|
| 597 |
+
+ len(dirs_stale)
|
| 598 |
+
+ len(curated_flag)
|
| 599 |
+
+ len(union_stale)
|
| 600 |
+
+ (1 if dmemo_stale else 0)
|
| 601 |
+
+ (1 if tariff_stale else 0)),
|
| 602 |
+
"error_count": len(errors)}, indent=2))
|
| 603 |
+
else:
|
| 604 |
+
print(f"CanLex corpus staleness check — {today}\n")
|
| 605 |
+
print(f"Justice Laws legislation ({len(leg)} instruments):")
|
| 606 |
+
for r in sorted(leg, key=lambda x: (x["status"] != "stale", x["short"])):
|
| 607 |
+
if r["status"] == "stale":
|
| 608 |
+
print(f" STALE {r['short']:34} local {r['local']} "
|
| 609 |
+
f"upstream {r['remote']}")
|
| 610 |
+
elif r["status"] == "missing-local":
|
| 611 |
+
print(f" ABSENT {r['short']:34} configured but not ingested "
|
| 612 |
+
f"(upstream {r['remote']})")
|
| 613 |
+
elif r["status"] == "error":
|
| 614 |
+
print(f" ERROR {r['short']:34} {r['error'][:50]}")
|
| 615 |
+
elif r["status"] == "no-date":
|
| 616 |
+
print(f" ? {r['short']:34} no current-date found upstream")
|
| 617 |
+
if not stale and not errors:
|
| 618 |
+
print(f" all {len(leg)} current (local matches or exceeds upstream)")
|
| 619 |
+
if stale:
|
| 620 |
+
codes = " ".join(r["code"] for r in stale)
|
| 621 |
+
print(f"\n -> {len(stale)} drifted. Refresh: "
|
| 622 |
+
f"py -m canlex.ingest --force {codes}\n"
|
| 623 |
+
f" and the French twins: py -m canlex.ingest --fr "
|
| 624 |
+
f"--force {codes}\n"
|
| 625 |
+
f" then: py -m canlex.embed && py -m canlex.eval")
|
| 626 |
+
|
| 627 |
+
print("\nDelegation instruments (upstream check):")
|
| 628 |
+
for r in deleg:
|
| 629 |
+
if r["status"] == "stale" and r["new"]:
|
| 630 |
+
print(f" STALE {r['name']}: new upstream {', '.join(r['new'])} "
|
| 631 |
+
f"not ingested")
|
| 632 |
+
elif r["status"] == "stale":
|
| 633 |
+
print(f" STALE {r['name']}: upstream {r['published'][0] or '?'} "
|
| 634 |
+
f"!= stored {r['have'][0] or '?'}")
|
| 635 |
+
elif r["status"] == "error":
|
| 636 |
+
print(f" ERROR {r['name']}: {r['error'][:50]}")
|
| 637 |
+
elif r["status"] == "no-version":
|
| 638 |
+
print(f" ? {r['name']}: no version stamp found upstream")
|
| 639 |
+
elif len(r["published"]) > 1: # CBSA: a set of dated instruments
|
| 640 |
+
print(f" ok {r['name']}: current "
|
| 641 |
+
f"({len(r['published'])} instruments, latest "
|
| 642 |
+
f"{max(r['published'])})")
|
| 643 |
+
else: # IRCC: a single version stamp
|
| 644 |
+
shown = r["published"][0] if r["published"] else "current"
|
| 645 |
+
print(f" ok {r['name']}: current ({shown})")
|
| 646 |
+
if deleg_stale:
|
| 647 |
+
print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
|
| 648 |
+
"then py -m canlex.delegation && py -m canlex.embed")
|
| 649 |
+
|
| 650 |
+
print(f"\nNJC directives (upstream check, {len(dirs)} ingested):")
|
| 651 |
+
for r in dirs:
|
| 652 |
+
if r["status"] == "stale":
|
| 653 |
+
print(f" STALE {r['title']}: upstream {r['remote']} "
|
| 654 |
+
f"!= stored {r['local'] or r['stored'] or '?'}")
|
| 655 |
+
elif r["status"] == "error":
|
| 656 |
+
print(f" ERROR {r['title']}: {r['error'][:50]}")
|
| 657 |
+
elif r["status"] == "no-date":
|
| 658 |
+
print(f" ? {r['title']}: no effective date found upstream")
|
| 659 |
+
else:
|
| 660 |
+
print(f" ok {r['title']}: current ({r['remote']})")
|
| 661 |
+
if dirs_stale:
|
| 662 |
+
print(" -> re-ingest: py -m canlex.directive --force && "
|
| 663 |
+
"py -m canlex.embed")
|
| 664 |
+
|
| 665 |
+
print("\nOther sources (stored currency; re-run the ingester to refresh):")
|
| 666 |
+
for r in nonxml:
|
| 667 |
+
mark = " " if r["present"] else "!"
|
| 668 |
+
print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
|
| 669 |
+
f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
|
| 670 |
+
|
| 671 |
+
print("\nCurated datasets (age + regen-drift check):")
|
| 672 |
+
for r in curated:
|
| 673 |
+
if r["status"] == "aging":
|
| 674 |
+
print(f" AGING {r['file']}: reviewed {r['reviewed']} — older "
|
| 675 |
+
f"than {_CURATED_MAX_AGE_DAYS} days; re-verify the state "
|
| 676 |
+
f"rows against their statutes and bump 'reviewed'")
|
| 677 |
+
elif r["status"] == "drift":
|
| 678 |
+
print(f" DRIFT {r['file']}: {r['error']}")
|
| 679 |
+
elif r["status"] == "error":
|
| 680 |
+
print(f" ERROR {r['file']}: {r['error'][:60]}")
|
| 681 |
+
else:
|
| 682 |
+
print(f" ok {r['file']}: reviewed {r['reviewed'] or '?'}")
|
| 683 |
+
|
| 684 |
+
if dmemo_idx["status"] == "stale":
|
| 685 |
+
drifted = dmemo_idx.get("drifted") or []
|
| 686 |
+
named = (f" ({', '.join(drifted[:6])}"
|
| 687 |
+
f"{', …' if len(drifted) > 6 else ''})" if drifted else "")
|
| 688 |
+
print(f"\nD-memo index: STALE — newest upstream 'Last updated' "
|
| 689 |
+
f"{dmemo_idx['remote']} > stored {dmemo_idx['stored']}"
|
| 690 |
+
f"{named}; "
|
| 691 |
+
f"run py -m canlex.dmemo --force && py -m canlex.embed")
|
| 692 |
+
elif dmemo_idx["status"] == "error":
|
| 693 |
+
print(f"\nD-memo index: ERROR {dmemo_idx['error'][:60]}")
|
| 694 |
+
else:
|
| 695 |
+
print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
|
| 696 |
+
f"<= stored {dmemo_idx['stored'] or '?'})")
|
| 697 |
+
|
| 698 |
+
if tariff["status"] == "stale":
|
| 699 |
+
print(f"\nCustoms Tariff edition: STALE — CBSA has published the "
|
| 700 |
+
f"{tariff['remote']} edition (ingested: {tariff['stored']}); "
|
| 701 |
+
f"bump the year in tariff_schedule.SOURCES and re-ingest")
|
| 702 |
+
else:
|
| 703 |
+
print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
|
| 704 |
+
f"current)")
|
| 705 |
+
|
| 706 |
+
n_union_ok = sum(1 for r in union if r["status"] == "ok")
|
| 707 |
+
print(f"\nCIU/PSAC union documents (upstream Last-Modified check, "
|
| 708 |
+
f"{len(union)} documents):")
|
| 709 |
+
for r in union:
|
| 710 |
+
if r["status"] == "stale":
|
| 711 |
+
print(f" STALE {r['short']} ({r['lang']}): upstream "
|
| 712 |
+
f"modified {r['remote']} after our fetch — run "
|
| 713 |
+
f"py -m canlex.ciu && py -m canlex.embed")
|
| 714 |
+
elif r["status"] == "error":
|
| 715 |
+
print(f" ERROR {r['short']}: {r['error'][:55]}")
|
| 716 |
+
if not union_stale and not any(r["status"] == "error" for r in union):
|
| 717 |
+
print(f" all {n_union_ok} current (upstream unchanged since fetch)")
|
| 718 |
+
|
| 719 |
+
if ciu_dec["status"] == "stale":
|
| 720 |
+
print(f"\nCIU Important-Decisions page: UPDATED "
|
| 721 |
+
f"({ciu_dec['remote']} > {ciu_dec['stored']}) — the union "
|
| 722 |
+
f"may have flagged a new decision; review "
|
| 723 |
+
f"{_CIU_DECISIONS} and, after ingesting anything new, "
|
| 724 |
+
f"update {_CIU_DEC_MARKER.name} to {ciu_dec['remote']}")
|
| 725 |
+
elif ciu_dec["status"] == "error":
|
| 726 |
+
print(f"\nCIU Important-Decisions page: ERROR "
|
| 727 |
+
f"{ciu_dec['error'][:55]}")
|
| 728 |
+
else:
|
| 729 |
+
print(f"\nCIU Important-Decisions page: ok (last modified "
|
| 730 |
+
f"{ciu_dec['remote'] or '?'})")
|
| 731 |
+
|
| 732 |
+
if pending:
|
| 733 |
+
print("\nPending bills now in force (re-ingest the affected Acts):")
|
| 734 |
+
for b in pending:
|
| 735 |
+
print(f" Bill {b['bill']} ({b['name']}) in force {b['in_force']} "
|
| 736 |
+
f"-> {', '.join(b['affects'])}")
|
| 737 |
+
print(f" {b['note']}")
|
| 738 |
+
print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
|
| 739 |
+
|
| 740 |
+
return 1 if (stale or errors or deleg_stale or dirs_stale
|
| 741 |
+
or curated_flag or dmemo_stale or tariff_stale
|
| 742 |
+
or union_stale or ciu_dec_stale) else 0
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def main():
|
| 746 |
+
args = sys.argv[1:]
|
| 747 |
+
as_json = "--json" in args
|
| 748 |
+
# --check is the only mode; accepted (and default) for forward-compatibility.
|
| 749 |
+
sys.exit(run(as_json=as_json))
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
if __name__ == "__main__":
|
| 753 |
+
main()
|
canlex/rehab.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Criminal-rehabilitation assessment under IRPA s. 36(3)(c) and IRPR ss. 17-18.1.
|
| 2 |
+
|
| 3 |
+
Pure rule logic, verified against the corpus text of IRPR ss. 17, 18 and 18.1
|
| 4 |
+
(SOR-2002-227, current consolidation) and IRPA s. 36(3). The rules:
|
| 5 |
+
|
| 6 |
+
Deemed rehabilitation (IRPR s. 18(2)) -- automatic, no application:
|
| 7 |
+
(a) ONE foreign conviction, indictable-equivalent, punishable in Canada by
|
| 8 |
+
a maximum of LESS THAN 10 years: 10 years after sentence completion,
|
| 9 |
+
with the cleanliness conditions (i)-(vii).
|
| 10 |
+
(b) TWO OR MORE foreign convictions that would all be summary offences:
|
| 11 |
+
5 years after the sentences were completed, with conditions.
|
| 12 |
+
(c) ONE committed act (s. 36(1)(c)/(2)(c)), <10-year maximum: 10 years
|
| 13 |
+
after the commission, with conditions.
|
| 14 |
+
Serious criminality (>=10-year maximum equivalent) can NEVER be deemed
|
| 15 |
+
rehabilitated -- only an individual application.
|
| 16 |
+
|
| 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
|
| 24 |
+
s. 18.1 prescribed class: inadmissible SOLELY for two or more in-Canada
|
| 25 |
+
summary-only convictions, exempt from s. 36(2)(a) five years after the
|
| 26 |
+
sentences were completed.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
BASES = ("foreign-conviction", "foreign-act", "canada-conviction")
|
| 30 |
+
COUNTS = ("one", "two-plus-summary-only", "multiple")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record):
|
| 34 |
+
"""Assess rehabilitation status. Returns a dict:
|
| 35 |
+
{status, provision, explanation, caveats}.
|
| 36 |
+
|
| 37 |
+
basis: how the inadmissibility arises (BASES).
|
| 38 |
+
max_penalty_lt_10: True if the equivalent Canadian offence's maximum term
|
| 39 |
+
is under 10 years (False = serious criminality territory).
|
| 40 |
+
count: 'one' | 'two-plus-summary-only' | 'multiple' (COUNTS).
|
| 41 |
+
years_elapsed: full years since sentence completion (conviction bases)
|
| 42 |
+
or since commission (act basis).
|
| 43 |
+
clean_record: True if there are no other convictions or committed acts
|
| 44 |
+
(the s. 18(2) (i)-(vii) conditions, simplified -- see caveats).
|
| 45 |
+
"""
|
| 46 |
+
caveats = [
|
| 47 |
+
"The 'clean record' answer stands in for the detailed conditions in "
|
| 48 |
+
"IRPR s. 18(2)(i)-(vii) (no Canadian indictable conviction ever; no "
|
| 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) remains available at any "
|
| 53 |
+
"time regardless of rehabilitation status.",
|
| 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:
|
| 62 |
+
return _r("exempt", "IRPR s. 18.1",
|
| 63 |
+
"Inadmissible solely for two or more in-Canada "
|
| 64 |
+
"summary-only convictions, and at least five years "
|
| 65 |
+
"have passed since the sentences were completed: "
|
| 66 |
+
"the person is in the prescribed class exempt from "
|
| 67 |
+
"IRPA s. 36(2)(a).", caveats)
|
| 68 |
+
return _r("not-yet", "IRPR s. 18.1",
|
| 69 |
+
"The in-Canada two-summary-only exemption requires five "
|
| 70 |
+
"years since the sentences were completed "
|
| 71 |
+
f"({years_elapsed} elapsed).", caveats)
|
| 72 |
+
return _r("record-suspension-route", "IRPA s. 36(3)(b)",
|
| 73 |
+
"Rehabilitation does not apply to in-Canada convictions. "
|
| 74 |
+
"The cure is a record suspension under the Criminal Records "
|
| 75 |
+
"Act (IRPA s. 36(3)(b)); once ordered and not revoked, the "
|
| 76 |
+
"conviction cannot ground inadmissibility.", caveats)
|
| 77 |
+
|
| 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 _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
|
| 85 |
+
"The equivalent Canadian offence carries a maximum of "
|
| 86 |
+
"10 years or more (serious criminality), so deemed "
|
| 87 |
+
"rehabilitation is NOT available -- but at least five "
|
| 88 |
+
f"years have passed since {since}, so the person is "
|
| 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 "
|
| 94 |
+
f"for individual rehabilitation has not yet run "
|
| 95 |
+
f"({years_elapsed} of 5 years since {since}).", caveats)
|
| 96 |
+
|
| 97 |
+
if count == "multiple":
|
| 98 |
+
if years_elapsed >= 5:
|
| 99 |
+
return _r("eligible-to-apply", "IRPA s. 36(3)(c); IRPR s. 17",
|
| 100 |
+
"More than one indictable-equivalent conviction/act: "
|
| 101 |
+
"deemed rehabilitation is not available (IRPR s. 18(2) "
|
| 102 |
+
"covers only a single such conviction or act), but the "
|
| 103 |
+
"five-year period to apply for individual "
|
| 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 "
|
| 108 |
+
f"period has not run ({years_elapsed} of 5 years).", caveats)
|
| 109 |
+
|
| 110 |
+
if count == "two-plus-summary-only":
|
| 111 |
+
para = "IRPR s. 18(2)(b)"
|
| 112 |
+
need = 5
|
| 113 |
+
elif basis == "foreign-act":
|
| 114 |
+
para = "IRPR s. 18(2)(c)"
|
| 115 |
+
need = 10
|
| 116 |
+
else:
|
| 117 |
+
para = "IRPR s. 18(2)(a)"
|
| 118 |
+
need = 10
|
| 119 |
+
|
| 120 |
+
if years_elapsed >= need and clean_record:
|
| 121 |
+
return _r("deemed-rehabilitated", para,
|
| 122 |
+
f"All {para} requirements appear met: {years_elapsed} years "
|
| 123 |
+
f"since {since} (needs {need}), a qualifying single/summary "
|
| 124 |
+
"profile, and a clean record. The person is deemed "
|
| 125 |
+
"rehabilitated -- the matter cannot ground inadmissibility "
|
| 126 |
+
"under IRPA s. 36(3)(c).", caveats)
|
| 127 |
+
if years_elapsed >= 5:
|
| 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 _r("eligible-to-apply", f"{para}; IRPR s. 17",
|
| 132 |
+
f"Deemed rehabilitation is not (yet) made out ({why}), but "
|
| 133 |
+
"five years have passed, so the person is eligible to APPLY "
|
| 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 "
|
| 138 |
+
"reached.", caveats)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _r(status, provision, explanation, caveats):
|
| 142 |
+
return {"status": status, "provision": provision,
|
| 143 |
+
"explanation": explanation, "caveats": caveats}
|
canlex/rerank.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-encoder reranker over ONNX Runtime (local, key-free)."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import onnxruntime as ort
|
| 7 |
+
from huggingface_hub import hf_hub_download
|
| 8 |
+
from tokenizers import Tokenizer
|
| 9 |
+
|
| 10 |
+
RERANK_REPO = "Xenova/bge-reranker-base"
|
| 11 |
+
RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
|
| 12 |
+
MAX_TOKENS = 512
|
| 13 |
+
_MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "2000"))
|
| 14 |
+
# 1000 -> 2000 adopted 2026-07-23: the doubled reading
|
| 15 |
+
# window held every 203-Q eval metric and lifted Hit@5
|
| 16 |
+
# +0.01; the cross-encoder now judges a full-size piece
|
| 17 |
+
# rather than 60% of one.
|
| 18 |
+
# cap doc text before tokenizing. Cross-encoder cost is
|
| 19 |
+
# ~linear in tokens per doc, so halving this ~halves the
|
| 20 |
+
# dominant query cost; env-sweepable, eval-gated (a
|
| 21 |
+
# tighter cap scores long sections on less of their text
|
| 22 |
+
# -- fine in practice: the median section is ~520 chars,
|
| 23 |
+
# and sub-chunked pieces cap at 1800). Swept 2026-06:
|
| 24 |
+
# 3000 -> 1000 (with pool 50 -> 16) held the eval exactly.
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def make_ort_session(model_path):
|
| 28 |
+
"""A CPU InferenceSession tuned for shared, small-vCPU hosts.
|
| 29 |
+
|
| 30 |
+
Two deviations from onnxruntime defaults: (1) spin-waiting between requests
|
| 31 |
+
is disabled -- by default ORT worker threads busy-spin after an inference
|
| 32 |
+
for a microsecond-scale latency win, which on the Space's shared 2 vCPUs
|
| 33 |
+
burns quota between queries; (2) the intra-op thread count is
|
| 34 |
+
env-configurable (CANLEX_ORT_THREADS) so concurrent sessions can be capped
|
| 35 |
+
below core count if contention is ever observed. Unset, ORT's default
|
| 36 |
+
(all cores) is kept, so single-query numerics and speed are unchanged."""
|
| 37 |
+
opts = ort.SessionOptions()
|
| 38 |
+
threads = int(os.environ.get("CANLEX_ORT_THREADS", "0"))
|
| 39 |
+
if threads:
|
| 40 |
+
opts.intra_op_num_threads = threads
|
| 41 |
+
opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
|
| 42 |
+
opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
|
| 43 |
+
return ort.InferenceSession(model_path, sess_options=opts,
|
| 44 |
+
providers=["CPUExecutionProvider"])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Reranker:
|
| 48 |
+
"""Cross-encoder that scores (query, section) pairs for relevance.
|
| 49 |
+
|
| 50 |
+
Loads a BGE reranker as ONNX and runs it on CPU -- no API key; the model is
|
| 51 |
+
downloaded once and cached. A cross-encoder reads the query and section
|
| 52 |
+
jointly, so it judges true relevance far better than the BM25/embedding
|
| 53 |
+
similarities used to build the candidate pool.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self):
|
| 57 |
+
model_path = hf_hub_download(RERANK_REPO, RERANK_ONNX)
|
| 58 |
+
tok_path = hf_hub_download(RERANK_REPO, "tokenizer.json")
|
| 59 |
+
cfg_path = hf_hub_download(RERANK_REPO, "config.json")
|
| 60 |
+
with open(cfg_path, encoding="utf-8") as fh:
|
| 61 |
+
self.pad_id = json.load(fh).get("pad_token_id", 0)
|
| 62 |
+
|
| 63 |
+
self.session = make_ort_session(model_path)
|
| 64 |
+
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 65 |
+
|
| 66 |
+
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 67 |
+
# only_second keeps the query intact and truncates the (longer) section.
|
| 68 |
+
self.tokenizer.enable_truncation(max_length=MAX_TOKENS, strategy="only_second")
|
| 69 |
+
|
| 70 |
+
def score(self, query, documents):
|
| 71 |
+
"""Return a relevance logit for each document paired with the query.
|
| 72 |
+
|
| 73 |
+
Higher means more relevant. The returned list is aligned with `documents`.
|
| 74 |
+
"""
|
| 75 |
+
if not documents:
|
| 76 |
+
return []
|
| 77 |
+
encs = self.tokenizer.encode_batch(
|
| 78 |
+
[(query, doc[:_MAX_DOC_CHARS]) for doc in documents])
|
| 79 |
+
width = max(len(e.ids) for e in encs)
|
| 80 |
+
input_ids = np.full((len(encs), width), self.pad_id, dtype=np.int64)
|
| 81 |
+
attention = np.zeros((len(encs), width), dtype=np.int64)
|
| 82 |
+
type_ids = np.zeros((len(encs), width), dtype=np.int64)
|
| 83 |
+
for row, enc in enumerate(encs):
|
| 84 |
+
n = len(enc.ids)
|
| 85 |
+
input_ids[row, :n] = enc.ids
|
| 86 |
+
attention[row, :n] = enc.attention_mask
|
| 87 |
+
type_ids[row, :n] = enc.type_ids
|
| 88 |
+
|
| 89 |
+
feed = {"input_ids": input_ids, "attention_mask": attention}
|
| 90 |
+
if "token_type_ids" in self.input_names:
|
| 91 |
+
feed["token_type_ids"] = type_ids
|
| 92 |
+
logits = self.session.run(None, feed)[0]
|
| 93 |
+
return np.asarray(logits, dtype=np.float32).reshape(-1).tolist()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def main():
|
| 97 |
+
import time
|
| 98 |
+
print(f"Loading {RERANK_REPO} ({RERANK_ONNX}) ...")
|
| 99 |
+
reranker = Reranker()
|
| 100 |
+
query = "powers of arrest without warrant"
|
| 101 |
+
docs = [
|
| 102 |
+
"Arrest without warrant. A peace officer may arrest without warrant a "
|
| 103 |
+
"person who has committed a criminal offence.",
|
| 104 |
+
"Definitions. In this Act, fish means any fish and includes shellfish, "
|
| 105 |
+
"crustaceans and marine animals.",
|
| 106 |
+
"Importation. It is prohibited to import cannabis except as authorized "
|
| 107 |
+
"under this Act.",
|
| 108 |
+
]
|
| 109 |
+
start = time.perf_counter()
|
| 110 |
+
scores = reranker.score(query, docs)
|
| 111 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 112 |
+
print(f"\nQuery: {query!r}")
|
| 113 |
+
for doc, score in sorted(zip(docs, scores), key=lambda x: x[1], reverse=True):
|
| 114 |
+
print(f" {score:8.3f} {doc[:62]}")
|
| 115 |
+
print(f"\n{elapsed:.0f} ms for {len(docs)} documents")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
canlex/server.py
ADDED
|
@@ -0,0 +1,1237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""MCP server exposing CanLex Canadian legislation retrieval as tools.
|
| 3 |
+
|
| 4 |
+
Runs over stdio. Launched by an MCP client (Claude Code / Claude Desktop);
|
| 5 |
+
it requires no API keys -- retrieval is fully local.
|
| 6 |
+
"""
|
| 7 |
+
import datetime
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
# Allow this file to be launched directly by an MCP client, cwd-independent.
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from typing import Annotated, Literal
|
| 20 |
+
|
| 21 |
+
from pydantic import Field
|
| 22 |
+
from mcp.server.fastmcp import FastMCP
|
| 23 |
+
|
| 24 |
+
from canlex.index import LegislationIndex
|
| 25 |
+
|
| 26 |
+
mcp = FastMCP("canlex_mcp", host="0.0.0.0",
|
| 27 |
+
port=int(os.environ.get("PORT", "8000")))
|
| 28 |
+
|
| 29 |
+
_READONLY = {
|
| 30 |
+
"readOnlyHint": True,
|
| 31 |
+
"destructiveHint": False,
|
| 32 |
+
"idempotentHint": True,
|
| 33 |
+
"openWorldHint": False,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# Prepended to every result so the model grounds, cites, and hedges correctly.
|
| 37 |
+
GROUNDING_NOTE = (
|
| 38 |
+
"ANSWERING INSTRUCTIONS: Base the answer only on the material below. Cite "
|
| 39 |
+
"specific provisions and quote key operative words (e.g. 'IRPA s. 34(1)(c)'). "
|
| 40 |
+
"When a result lists related provisions, regulations or D-memoranda, fetch "
|
| 41 |
+
"any that bear on the question -- the definitions section, an exception, a "
|
| 42 |
+
"cross-referenced rule, the regulation that adds detail -- with "
|
| 43 |
+
"canlex_get_section or canlex_search_legislation before answering. "
|
| 44 |
+
"Distinguish the kinds of source: enacted law (Acts and regulations) is binding; "
|
| 45 |
+
"CBSA D-Memoranda are administrative guidance -- persuasive only, not binding, "
|
| 46 |
+
"and a court may disagree with them; collective agreements and the National "
|
| 47 |
+
"Joint Council directives they incorporate are binding employment-terms "
|
| 48 |
+
"instruments for a bargaining unit; court decisions interpret and apply the "
|
| 49 |
+
"law and are binding precedent depending on the court and jurisdiction -- "
|
| 50 |
+
"name the deciding court and the date, and do not assume a decision is still "
|
| 51 |
+
"good law if it may have been overtaken (the canlex_case tool checks a "
|
| 52 |
+
"decision's later treatment on CanLII -- give it the neutral citation). "
|
| 53 |
+
"Always state the date the source is current to, and that the answer "
|
| 54 |
+
"reflects the law only as of that date -- for a time-sensitive matter, tell "
|
| 55 |
+
"the reader to verify no amendment has come into force since. If the material "
|
| 56 |
+
"below does not fully resolve the question -- including where it turns on case "
|
| 57 |
+
"law or facts not present here -- say so explicitly. This is legal information, "
|
| 58 |
+
"not legal advice."
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
HEDGE_THRESHOLD = 0.72 # max semantic similarity below which results are weak
|
| 62 |
+
|
| 63 |
+
WEAK_MATCH_NOTE = (
|
| 64 |
+
"RETRIEVAL CAUTION: the material below is only a weak match for this query "
|
| 65 |
+
"— CanLex may not contain a provision or decision that directly answers it. "
|
| 66 |
+
"Read it critically; if it does not actually address the question, say so "
|
| 67 |
+
"plainly rather than stretching it to fit, and consider canlex_list_acts to "
|
| 68 |
+
"check what the corpus covers."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
_INDEX: Optional[LegislationIndex] = None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _index() -> LegislationIndex:
|
| 75 |
+
"""Load and cache the legislation index on first use."""
|
| 76 |
+
global _INDEX
|
| 77 |
+
if _INDEX is None:
|
| 78 |
+
_INDEX = LegislationIndex()
|
| 79 |
+
return _INDEX
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
_GUIDANCE_AGE_YEARS = 5 # warn when guidance is older than this
|
| 83 |
+
_YEAR = re.compile(r"\b(?:19|20)\d{2}\b")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _log_query(query, top_k, act, doc_type, results):
|
| 87 |
+
"""Append one JSON line per search to CANLEX_LOG_DIR/queries.jsonl, if that
|
| 88 |
+
env var is set. Real user queries -- especially weak-match or no-result ones
|
| 89 |
+
-- are the only unbiased source of corpus gaps (the eval set is
|
| 90 |
+
maintainer-authored); scripts/loganalyze.py mines them. Logs the query text
|
| 91 |
+
and outcome only, never anything else; opt-in via the env var, off by
|
| 92 |
+
default. Best-effort: a logging failure never affects the response."""
|
| 93 |
+
log_dir = os.environ.get("CANLEX_LOG_DIR")
|
| 94 |
+
if not log_dir:
|
| 95 |
+
return
|
| 96 |
+
try:
|
| 97 |
+
top = results[0] if results else None
|
| 98 |
+
record = {
|
| 99 |
+
"ts": int(time.time()),
|
| 100 |
+
"query": query,
|
| 101 |
+
"top_k": top_k,
|
| 102 |
+
"act": act,
|
| 103 |
+
"doc_type": doc_type,
|
| 104 |
+
"n_results": len(results),
|
| 105 |
+
"confidence": (round(top["confidence"], 4)
|
| 106 |
+
if top and top.get("confidence") is not None else None),
|
| 107 |
+
"top_citations": [r.get("citation", "") for r in results[:3]],
|
| 108 |
+
}
|
| 109 |
+
path = Path(log_dir)
|
| 110 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
with (path / "queries.jsonl").open("a", encoding="utf-8") as fh:
|
| 112 |
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 113 |
+
except Exception:
|
| 114 |
+
pass
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _stated(value: str) -> str:
|
| 118 |
+
"""A currency value for display, or an honest placeholder when the source
|
| 119 |
+
states none -- so a reader sees 'not stated by the source' rather than a
|
| 120 |
+
bare 'n/a' that reads as an oversight."""
|
| 121 |
+
return value or "not stated by the source"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _guidance_age_note(current_to: str) -> str:
|
| 125 |
+
"""A warning when administrative guidance is several years old, since its
|
| 126 |
+
figures and procedures may have changed (e.g. duty-free limits in a 2013
|
| 127 |
+
D-memo). Only fires when a year can be read from the currency value."""
|
| 128 |
+
years = _YEAR.findall(current_to or "")
|
| 129 |
+
if not years:
|
| 130 |
+
return ""
|
| 131 |
+
newest = max(int(y) for y in years)
|
| 132 |
+
age = datetime.date.today().year - newest
|
| 133 |
+
if age >= _GUIDANCE_AGE_YEARS:
|
| 134 |
+
return (f"_⚠ This guidance is about {age} years old (dated {newest}); "
|
| 135 |
+
"its figures or procedures may have changed -- verify against "
|
| 136 |
+
"the current published version._")
|
| 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")
|
| 143 |
+
header = f"### {c['citation']} — {c['marginal_note']}".rstrip(" —")
|
| 144 |
+
location = " > ".join(p for p in (c["part"], c["division"]) if p)
|
| 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'])})")
|
| 151 |
+
age = _guidance_age_note(c["current_to"])
|
| 152 |
+
if age:
|
| 153 |
+
lines.append(age)
|
| 154 |
+
elif doc_type == "agreement":
|
| 155 |
+
lines.append("_Treasury Board collective agreement — a binding contract for "
|
| 156 |
+
"this bargaining unit._")
|
| 157 |
+
lines.append(f"(in force to {_stated(c['current_to'])})")
|
| 158 |
+
elif doc_type == "directive":
|
| 159 |
+
lines.append("_National Joint Council directive — forms part of collective "
|
| 160 |
+
"agreements; binding for the matters it covers._")
|
| 161 |
+
lines.append(f"(effective {_stated(c['current_to'])})")
|
| 162 |
+
age = _guidance_age_note(c["current_to"])
|
| 163 |
+
if age:
|
| 164 |
+
lines.append(age)
|
| 165 |
+
elif doc_type == "caselaw":
|
| 166 |
+
if "Immigration and Refugee Board" in c["part"]:
|
| 167 |
+
lines.append("_Immigration and Refugee Board jurisprudential guide "
|
| 168 |
+
"— IRB members apply its reasoning to similar cases or "
|
| 169 |
+
"explain why not; persuasive, and subject to revocation "
|
| 170 |
+
"or to review by the Federal Court._")
|
| 171 |
+
elif "Board" in c["part"]:
|
| 172 |
+
lines.append("_Labour-board decision — a federal administrative "
|
| 173 |
+
"tribunal's ruling; persuasive within the board's own "
|
| 174 |
+
"jurisprudence, and subject to judicial review by the "
|
| 175 |
+
"Federal Court of Appeal._")
|
| 176 |
+
else:
|
| 177 |
+
lines.append("_Court decision — binding precedent depending on the "
|
| 178 |
+
"court and jurisdiction; confirm it has not been "
|
| 179 |
+
"overturned on appeal or overtaken by later authority._")
|
| 180 |
+
lines.append(f"(decided {c['current_to'] or 'n/a'})")
|
| 181 |
+
if c["heading"]:
|
| 182 |
+
lines.append(f"Subject: {c['heading']}")
|
| 183 |
+
elif doc_type == "delegation":
|
| 184 |
+
lines.append("_Instrument of delegation and designation — it records "
|
| 185 |
+
"which officials the Minister has delegated powers to, or "
|
| 186 |
+
"designated for functions, under IRPA and the IRPR. "
|
| 187 |
+
"Administrative; confirm it is still the current version._")
|
| 188 |
+
lines.append(f"(dated {c['current_to'] or 'n/a'})")
|
| 189 |
+
elif doc_type == "benefits":
|
| 190 |
+
lines.append("_Benefit-plan member booklet — a plain-language summary "
|
| 191 |
+
"published by the plan administrator (Canada Life). The "
|
| 192 |
+
"governing terms are in the plan's directive or rules, "
|
| 193 |
+
"which prevail in any discrepancy; quote the booklet for "
|
| 194 |
+
"what it says but flag that the plan rules control._")
|
| 195 |
+
lines.append(f"(booklet edition: {_stated(c['current_to'])})")
|
| 196 |
+
age = _guidance_age_note(c["current_to"])
|
| 197 |
+
if age:
|
| 198 |
+
lines.append(age)
|
| 199 |
+
elif doc_type == "union":
|
| 200 |
+
lines.append("_Union document (CIU/PSAC) — internal governance "
|
| 201 |
+
"rules, member guides or locally negotiated scheduling "
|
| 202 |
+
"instruments published by the bargaining agent. They "
|
| 203 |
+
"bind the union and its members under the union's own "
|
| 204 |
+
"rules; they are NOT employer policy and NOT part of "
|
| 205 |
+
"the collective agreement itself (a VSSA operates "
|
| 206 |
+
"under the FB agreement's variable-shift provisions). "
|
| 207 |
+
"The union site may lag renegotiations — verify "
|
| 208 |
+
"currency with the Branch._")
|
| 209 |
+
lines.append(f"(document currency: {_stated(c['current_to'])})")
|
| 210 |
+
age = _guidance_age_note(c["current_to"])
|
| 211 |
+
if age:
|
| 212 |
+
lines.append(age)
|
| 213 |
+
elif doc_type == "commentary":
|
| 214 |
+
lines.append("_**CURATED ANALYSIS — CanLex commentary, not a source "
|
| 215 |
+
"of law.** A curated synthesis of the cited authorities; "
|
| 216 |
+
"where it says no authority exists, the analysis is "
|
| 217 |
+
"reasoned interpretation only. Always verify against the "
|
| 218 |
+
"cited cases and guidance before relying on it._")
|
| 219 |
+
lines.append(f"(entry reviewed {_stated(c['current_to'])})")
|
| 220 |
+
elif c.get("status") == "not-in-force":
|
| 221 |
+
# Enacted but not yet in force: pending law from the Act's AMENDMENTS
|
| 222 |
+
# NOT IN FORCE schedule. Make it impossible to mistake for current law.
|
| 223 |
+
lines.append("_**⚠ NOT YET IN FORCE — this is an enacted amendment "
|
| 224 |
+
"that has not come into force.** It is NOT current law; "
|
| 225 |
+
"the provision it amends (if any) continues to read as "
|
| 226 |
+
"consolidated. Cite it only as pending law, naming the "
|
| 227 |
+
f"amending Act: {c['history'] or 'see text'}._ "
|
| 228 |
+
f"(consolidation current to {c['current_to'] or 'n/a'})")
|
| 229 |
+
elif c.get("status") == "repealed":
|
| 230 |
+
# A repealed provision is no longer law; say so unmistakably so the
|
| 231 |
+
# model never presents the (near-empty) stub as if it were in force.
|
| 232 |
+
lines.append("_**⚠ REPEALED — this provision is no longer in force.** "
|
| 233 |
+
"The repeal citation is in the text below; do not rely on "
|
| 234 |
+
"it as current law, and tell the reader it has been "
|
| 235 |
+
f"repealed._ (text current to {c['current_to'] or 'n/a'})")
|
| 236 |
+
else:
|
| 237 |
+
meta = [f"in force; text current to {c['current_to'] or 'n/a'}"]
|
| 238 |
+
if c.get("in_force"):
|
| 239 |
+
meta.append(f"this provision in force since {c['in_force']}")
|
| 240 |
+
if c["last_amended"]:
|
| 241 |
+
meta.append(f"last amended {c['last_amended']}")
|
| 242 |
+
lines.append(f"**Currency:** {'; '.join(meta)}. Does not reflect any "
|
| 243 |
+
f"amendment that came into force after the 'current to' date.")
|
| 244 |
+
hl = c.get("highlight")
|
| 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("")
|
| 252 |
+
if related:
|
| 253 |
+
provisions = related.get("provisions")
|
| 254 |
+
if provisions:
|
| 255 |
+
refs = "; ".join(f"s. {s} ({n})" if n else f"s. {s}"
|
| 256 |
+
for s, n in provisions)
|
| 257 |
+
lines.append(f"Related provisions in this Act: {refs}")
|
| 258 |
+
regs = related.get("regulations")
|
| 259 |
+
if regs:
|
| 260 |
+
lines.append("Regulations made under this Act: "
|
| 261 |
+
+ "; ".join(f"{n} ({s})" for s, n in regs))
|
| 262 |
+
enabling = related.get("enabling_act")
|
| 263 |
+
if enabling:
|
| 264 |
+
lines.append(f"Made under: {enabling[1]} ({enabling[0]})")
|
| 265 |
+
memos = related.get("memoranda")
|
| 266 |
+
if memos:
|
| 267 |
+
lines.append("CBSA D-memoranda citing this section (guidance, not "
|
| 268 |
+
"binding): " + ", ".join(memos))
|
| 269 |
+
if c["history"]:
|
| 270 |
+
if doc_type == "caselaw":
|
| 271 |
+
lines.append(f"Also reported: {c['history']}")
|
| 272 |
+
elif doc_type == "legislation":
|
| 273 |
+
lines.append(f"Amendment history: {c['history']}")
|
| 274 |
+
else:
|
| 275 |
+
lines.append(f"History: {c['history']}")
|
| 276 |
+
lines.append(f"Source: {c['source_url']}")
|
| 277 |
+
return "\n".join(lines)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# The eight doc_type values, as a Literal so every client schema carries them
|
| 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",
|
| 284 |
+
"caselaw", "delegation", "benefits", "commentary", "union"]
|
| 285 |
+
|
| 286 |
+
# Longest alternative first, and 's'/'ss' only at a word boundary or before
|
| 287 |
+
# a dotted number -- so 'section 20.1' loses the whole word (not its first
|
| 288 |
+
# letter) and a real section value like 'Schedule I' is left alone.
|
| 289 |
+
_SECTION_PREFIX = re.compile(
|
| 290 |
+
r"^(?:sections?\b|ss?\.(?=\s*\d)|ss?\b|§+)\s*", re.IGNORECASE)
|
| 291 |
+
|
| 292 |
+
|
| 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 (the full section
|
| 297 |
+
text contains its subsections anyway)."""
|
| 298 |
+
s = _SECTION_PREFIX.sub("", section.strip())
|
| 299 |
+
s = s.split("(", 1)[0].strip().rstrip(".")
|
| 300 |
+
return s or section.strip()
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@mcp.tool(name="canlex_search_legislation",
|
| 304 |
+
annotations={"title": "Search Canadian Legislation", **_READONLY})
|
| 305 |
+
def canlex_search_legislation(
|
| 306 |
+
query: Annotated[str, Field(
|
| 307 |
+
description="Natural-language legal question or keywords, e.g. "
|
| 308 |
+
"'detention review timelines' or 'inadmissibility for serious "
|
| 309 |
+
"criminality'.", min_length=2, max_length=500)],
|
| 310 |
+
top_k: Annotated[int, Field(
|
| 311 |
+
description="Number of sections to return (1-20). Use more for broad "
|
| 312 |
+
"questions.", ge=1, le=20)] = 6,
|
| 313 |
+
act: Annotated[Optional[str], Field(
|
| 314 |
+
description="Optional filter to a single Act, by short name, code or "
|
| 315 |
+
"full name (e.g. 'IRPA', 'I-2.5', 'Customs Act'). Omit to search "
|
| 316 |
+
"every loaded instrument.")] = None,
|
| 317 |
+
doc_type: Annotated[Optional[DocType], Field(
|
| 318 |
+
description="Optional filter by source type: 'legislation' (Acts and "
|
| 319 |
+
"regulations, incl. their Schedules), 'memorandum' (CBSA D-Memoranda "
|
| 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), or 'commentary' "
|
| 324 |
+
"(CanLex's curated analysis datasets, e.g. US dispositions vs the "
|
| 325 |
+
"IRPA 'conviction' concept). Omit to search all.")] = None,
|
| 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 "
|
| 329 |
+
"of Appeal', 'Immigration and Refugee Board', 'FPSLREB'. Other "
|
| 330 |
+
"source types are unaffected.", max_length=60)] = None,
|
| 331 |
+
decided_after: Annotated[Optional[int], Field(
|
| 332 |
+
description="Optional: restrict CASE-LAW results to decisions from "
|
| 333 |
+
"this year onward, e.g. 2015. Other source types are unaffected.",
|
| 334 |
+
ge=1950, le=2100)] = None,
|
| 335 |
+
) -> str:
|
| 336 |
+
"""Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
|
| 337 |
+
and leading court decisions.
|
| 338 |
+
|
| 339 |
+
The CanLex corpus has nine kinds of source: federal Acts and regulations
|
| 340 |
+
with their Schedules (immigration, customs, criminal, firearms, drugs,
|
| 341 |
+
food/health, labour, privacy and more); CBSA D-Memoranda (administrative
|
| 342 |
+
guidance on how the CBSA applies customs and border law); Treasury Board
|
| 343 |
+
collective agreements (currently the FB / Border Services group); National
|
| 344 |
+
Joint Council directives (travel, relocation, isolated posts and more);
|
| 345 |
+
leading decisions of the courts and federal tribunals (Supreme Court,
|
| 346 |
+
Federal Court of Appeal and Federal Court, the IRB, and the FPSLREB and
|
| 347 |
+
CIRB labour boards); instruments of delegation and designation under IRPA
|
| 348 |
+
and the IRPR; member booklets of the public-service benefit plans;
|
| 349 |
+
CanLex's curated commentary (US-disposition and equivalency analysis);
|
| 350 |
+
and union documents (the CIU by-laws/regulations/branch rules, the PSAC
|
| 351 |
+
Constitution, member guides, and every published site VSSA).
|
| 352 |
+
Use this for ANY question about that material. It ranks results by
|
| 353 |
+
relevance and returns their full text so the answer can cite the actual
|
| 354 |
+
wording; an explicit section reference (e.g. "section 34") is always
|
| 355 |
+
surfaced. Each result is marked with its source type.
|
| 356 |
+
|
| 357 |
+
Returns:
|
| 358 |
+
str: Markdown with answering instructions followed by the matching
|
| 359 |
+
sections -- citation, Part/Division, currency dates, full text,
|
| 360 |
+
amendment history and source URL per result. Returns a "No ...
|
| 361 |
+
matched" message if nothing is found.
|
| 362 |
+
|
| 363 |
+
Examples:
|
| 364 |
+
- "What are the security grounds for inadmissibility?" -> query about s. 34.
|
| 365 |
+
- "detention review 48 hours" -> finds the detention-review provisions.
|
| 366 |
+
- Don't use to look up a known section number verbatim -- use canlex_get_section.
|
| 367 |
+
"""
|
| 368 |
+
index = _index()
|
| 369 |
+
results = index.search(query, top_k=top_k, act=act, doc_type=doc_type,
|
| 370 |
+
court=court, decided_after=decided_after)
|
| 371 |
+
_log_query(query, top_k, act, doc_type, results)
|
| 372 |
+
if not results:
|
| 373 |
+
if act:
|
| 374 |
+
known = sorted({c["act_short"] for c in index.chunks})
|
| 375 |
+
act_lc = act.strip().lower()
|
| 376 |
+
if not any(act_lc in (c["act_short"].lower(), c["act_code"].lower(),
|
| 377 |
+
c.get("act_name", "").lower())
|
| 378 |
+
for c in index.chunks):
|
| 379 |
+
return (f"No results: the act filter '{act}' matches no loaded "
|
| 380 |
+
f"instrument (short names accepted: "
|
| 381 |
+
f"{', '.join(known)}). Retry without the filter, or "
|
| 382 |
+
f"with one of those names.")
|
| 383 |
+
scope = f" in '{act}'" if act else ""
|
| 384 |
+
return (f"No results matched '{query}'{scope}. "
|
| 385 |
+
f"Try broader or different keywords, or call canlex_list_acts to see "
|
| 386 |
+
f"what is currently loaded.")
|
| 387 |
+
blocks = []
|
| 388 |
+
weak = results[0].get("confidence")
|
| 389 |
+
if weak is not None and weak < HEDGE_THRESHOLD:
|
| 390 |
+
blocks += [WEAK_MATCH_NOTE, ""]
|
| 391 |
+
blocks += [GROUNDING_NOTE, "",
|
| 392 |
+
f'{len(results)} relevant section(s) for: "{query}"']
|
| 393 |
+
for c in results:
|
| 394 |
+
blocks.append("")
|
| 395 |
+
blocks.append("---")
|
| 396 |
+
blocks.append("")
|
| 397 |
+
blocks.append(_format_section(c, index.related(c)))
|
| 398 |
+
return "\n".join(blocks)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@mcp.tool(name="canlex_get_section",
|
| 402 |
+
annotations={"title": "Get a Legislation Section", **_READONLY})
|
| 403 |
+
def canlex_get_section(
|
| 404 |
+
act: Annotated[str, Field(
|
| 405 |
+
description="Act short name or code, e.g. 'IRPA' or 'I-2.5'.",
|
| 406 |
+
min_length=1, max_length=60)],
|
| 407 |
+
section: Annotated[str, Field(
|
| 408 |
+
description="Section number, e.g. '34', '20.1', 's. 34' or "
|
| 409 |
+
"'34(1)(c)' -- prefixes and subsection parentheticals are accepted "
|
| 410 |
+
"and normalized.", min_length=1, max_length=40)],
|
| 411 |
+
) -> str:
|
| 412 |
+
"""Retrieve one specific section of legislation verbatim, by Act and section number.
|
| 413 |
+
|
| 414 |
+
Use this when the exact citation is already known (e.g. the user asks about
|
| 415 |
+
"IRPA section 34" or a provision cross-references "subsection 25.1(1)").
|
| 416 |
+
|
| 417 |
+
Returns:
|
| 418 |
+
str: Markdown with answering instructions followed by the section's citation,
|
| 419 |
+
Part/Division, 'current to' and 'last amended' dates, full provision text,
|
| 420 |
+
amendment history, and source URL. Returns an actionable error message (listing
|
| 421 |
+
the loaded Acts) if the Act or section is not found.
|
| 422 |
+
|
| 423 |
+
Examples:
|
| 424 |
+
- "Show me IRPA s. 34" -> act='IRPA', section='34'.
|
| 425 |
+
- "What does subsection 20.1 say" -> act='IRPA', section='20.1'.
|
| 426 |
+
"""
|
| 427 |
+
index = _index()
|
| 428 |
+
result = index.get_section(act, section)
|
| 429 |
+
if result is None and section != _normalize_section(section):
|
| 430 |
+
result = index.get_section(act, _normalize_section(section))
|
| 431 |
+
if result is None:
|
| 432 |
+
acts = sorted({c["act_short"] for c in index.chunks})
|
| 433 |
+
return (f"Error: no section '{section}' found in '{act}'. "
|
| 434 |
+
f"Loaded Acts: {', '.join(acts) or 'none'}. Check the section number, "
|
| 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():
|
| 440 |
+
"""The curated US-dispositions dataset (data/curated/us_dispositions.json)."""
|
| 441 |
+
from canlex.config import DATA_DIR
|
| 442 |
+
path = DATA_DIR / "curated" / "us_dispositions.json"
|
| 443 |
+
if not path.exists():
|
| 444 |
+
return None
|
| 445 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
_STATE_NAMES = {
|
| 449 |
+
"al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas",
|
| 450 |
+
"ca": "california", "co": "colorado", "ct": "connecticut", "de": "delaware",
|
| 451 |
+
"fl": "florida", "ga": "georgia", "hi": "hawaii", "id": "idaho",
|
| 452 |
+
"il": "illinois", "in": "indiana", "ia": "iowa", "ks": "kansas",
|
| 453 |
+
"ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland",
|
| 454 |
+
"ma": "massachusetts", "mi": "michigan", "mn": "minnesota",
|
| 455 |
+
"ms": "mississippi", "mo": "missouri", "mt": "montana", "ne": "nebraska",
|
| 456 |
+
"nv": "nevada", "nh": "new hampshire", "nj": "new jersey",
|
| 457 |
+
"nm": "new mexico", "ny": "new york", "nc": "north carolina",
|
| 458 |
+
"nd": "north dakota", "oh": "ohio", "ok": "oklahoma", "or": "oregon",
|
| 459 |
+
"pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina",
|
| 460 |
+
"sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah",
|
| 461 |
+
"vt": "vermont", "va": "virginia", "wa": "washington",
|
| 462 |
+
"wv": "west virginia", "wi": "wisconsin", "wy": "wyoming",
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _match_dispositions(data, disposition, state):
|
| 467 |
+
"""Score dataset entries against the query; return the best matches.
|
| 468 |
+
|
| 469 |
+
Token-overlap over the entry's names/aliases, with a bonus when the
|
| 470 |
+
entry's state_variations cover the requested state. Deliberately simple:
|
| 471 |
+
the vocabulary is small and controlled."""
|
| 472 |
+
q_tokens = set(re.findall(r"[a-z0-9]+", disposition.lower()))
|
| 473 |
+
state_lc = (state or "").strip().lower()
|
| 474 |
+
state_name = _STATE_NAMES.get(state_lc, state_lc)
|
| 475 |
+
scored = []
|
| 476 |
+
for e in data.get("dispositions", []):
|
| 477 |
+
best = 0.0
|
| 478 |
+
for name in e["names"]:
|
| 479 |
+
n_tokens = set(re.findall(r"[a-z0-9]+", name.lower()))
|
| 480 |
+
if not n_tokens:
|
| 481 |
+
continue
|
| 482 |
+
overlap = len(q_tokens & n_tokens) / len(n_tokens)
|
| 483 |
+
best = max(best, overlap)
|
| 484 |
+
if state_name and any(
|
| 485 |
+
state_name in (v.get("state", "").lower())
|
| 486 |
+
or v.get("state", "").lower() in (state_lc, state_name)
|
| 487 |
+
for v in e.get("state_variations", [])):
|
| 488 |
+
best += 0.15
|
| 489 |
+
if best > 0.3:
|
| 490 |
+
scored.append((best, e))
|
| 491 |
+
scored.sort(key=lambda t: -t[0])
|
| 492 |
+
return [e for _, e in scored[:2]]
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def _load_equivalency():
|
| 496 |
+
from canlex.config import DATA_DIR
|
| 497 |
+
path = DATA_DIR / "curated" / "us_equivalency.json"
|
| 498 |
+
if not path.exists():
|
| 499 |
+
return None
|
| 500 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
# Tool registration order mirrors the s. 36 screening chain (step 1
|
| 504 |
+
# disposition, step 2 equivalency, step 3 rehabilitation) so clients that
|
| 505 |
+
# list tools in registration order present the workflow forwards.
|
| 506 |
+
@mcp.tool(name="canlex_us_disposition",
|
| 507 |
+
annotations={"title": "US Disposition vs IRPA 'Conviction'", **_READONLY})
|
| 508 |
+
def canlex_us_disposition(
|
| 509 |
+
disposition: Annotated[str, Field(
|
| 510 |
+
description="The US disposition, in the traveller's or record's own "
|
| 511 |
+
"words, e.g. 'deferred adjudication', 'nolo contendere plea', "
|
| 512 |
+
"'withheld adjudication', 'expunged conviction', 'ACD' -- or 'all' "
|
| 513 |
+
"for the complete breakdown of every covered disposition type and "
|
| 514 |
+
"state variation.", min_length=1, max_length=200)],
|
| 515 |
+
state: Annotated[Optional[str], Field(
|
| 516 |
+
description="Optional US state (name or 2-letter code), e.g. 'TX' or "
|
| 517 |
+
"'Florida' -- the analysis can vary by state.",
|
| 518 |
+
max_length=30)] = None,
|
| 519 |
+
) -> str:
|
| 520 |
+
"""Assess whether a US criminal disposition is a "conviction" for IRPA
|
| 521 |
+
s. 36 inadmissibility purposes, from CanLex's curated, authority-cited
|
| 522 |
+
dataset of US disposition types (deferred adjudication, withheld
|
| 523 |
+
adjudication, nolo pleas, expungements, state pardons, diversion, and
|
| 524 |
+
more), with state-by-state variations.
|
| 525 |
+
|
| 526 |
+
This is STEP 1 of a s. 36(1)(b)/(2)(b) screening (is there a conviction
|
| 527 |
+
at all?); step 2 is offence equivalency (canlex_us_equivalency) and step
|
| 528 |
+
3 is rehabilitation (canlex_rehabilitation). Every answer is CURATED
|
| 529 |
+
COMMENTARY, not law: entries cite their authorities, and entries with no
|
| 530 |
+
authority say so and label their reasoning as interpretation.
|
| 531 |
+
|
| 532 |
+
Returns:
|
| 533 |
+
str: Markdown -- the matching entry/entries with authorities and
|
| 534 |
+
status flags, or the governing principles plus an honest no-entry
|
| 535 |
+
statement when nothing matches.
|
| 536 |
+
"""
|
| 537 |
+
from canlex.commentary import BANNER, _entry_text
|
| 538 |
+
data = _load_dispositions()
|
| 539 |
+
if data is None:
|
| 540 |
+
return ("The curated US-dispositions dataset is not present in this "
|
| 541 |
+
"deployment. Use canlex_search_legislation (doc_type "
|
| 542 |
+
"'caselaw') for the governing principles: Canada (MCI) v "
|
| 543 |
+
"Saini, 2001 FCA 311, and IRPA s. 36(3).")
|
| 544 |
+
if disposition.strip().lower() in ("all", "list", "list all", "*"):
|
| 545 |
+
lines = [BANNER, "",
|
| 546 |
+
"Every covered disposition type (and state variation), with "
|
| 547 |
+
"its conviction verdict for IRPA s. 36. Query a specific "
|
| 548 |
+
"disposition for the full analysis and authorities.", ""]
|
| 549 |
+
for e in data.get("dispositions", []):
|
| 550 |
+
lines.append(f"- {e['names'][0]} -- conviction: "
|
| 551 |
+
f"{e['is_conviction'].upper()} "
|
| 552 |
+
f"[{e['status']}]")
|
| 553 |
+
for v in e.get("state_variations", []):
|
| 554 |
+
if v.get("state", "").lower() == "general":
|
| 555 |
+
continue
|
| 556 |
+
flag = (v.get("is_conviction") or e["is_conviction"]).upper()
|
| 557 |
+
lines.append(f" - {v['state']}: {flag}")
|
| 558 |
+
lines.append("")
|
| 559 |
+
lines.append("Reminder: even a non-conviction disposition can ground "
|
| 560 |
+
"inadmissibility under the act branch, IRPA "
|
| 561 |
+
"s. 36(1)(c)/(2)(c).")
|
| 562 |
+
return "\n".join(lines)
|
| 563 |
+
matches = _match_dispositions(data, disposition, state)
|
| 564 |
+
parts = []
|
| 565 |
+
if matches:
|
| 566 |
+
for e in matches:
|
| 567 |
+
parts.append(_entry_text(e))
|
| 568 |
+
else:
|
| 569 |
+
parts.append(BANNER)
|
| 570 |
+
parts.append(
|
| 571 |
+
f"No curated entry matches '{disposition}'"
|
| 572 |
+
+ (f" ({state})" if state else "") + ". "
|
| 573 |
+
"That does not mean the disposition is or is not a conviction -- "
|
| 574 |
+
"it means CanLex has not analyzed this disposition type. Apply "
|
| 575 |
+
"the governing principles (below) to the disposition's actual "
|
| 576 |
+
"features: was guilt admitted or found? is the disposition final? "
|
| 577 |
+
"does an analogous Canadian non-conviction regime exist?")
|
| 578 |
+
for m in data.get("methodology", []):
|
| 579 |
+
if m.get("id") == "conviction-framework":
|
| 580 |
+
parts.append("---\n\nGoverning framework:\n\n" + m["text"])
|
| 581 |
+
break
|
| 582 |
+
parts.append("Confirm any cited decision's current status with "
|
| 583 |
+
"canlex_case, and retrieve its full text with "
|
| 584 |
+
"canlex_search_legislation (doc_type 'caselaw').")
|
| 585 |
+
return "\n\n".join(parts)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
@mcp.tool(name="canlex_us_equivalency",
|
| 589 |
+
annotations={"title": "US Offence Equivalency", **_READONLY})
|
| 590 |
+
def canlex_us_equivalency(
|
| 591 |
+
offense: Annotated[str, Field(
|
| 592 |
+
description="The US offence as charged or described, e.g. 'DUI', "
|
| 593 |
+
"'petty theft', 'aggravated assault', 'possession of a controlled "
|
| 594 |
+
"substance', 'disorderly conduct'.",
|
| 595 |
+
min_length=2, max_length=200)],
|
| 596 |
+
) -> str:
|
| 597 |
+
"""Map a common US offence to its Canadian equivalent and the resulting
|
| 598 |
+
IRPA s. 36 inadmissibility branch (serious criminality vs criminality vs
|
| 599 |
+
none), from CanLex's curated pairing table with corpus-verified maximum
|
| 600 |
+
penalties.
|
| 601 |
+
|
| 602 |
+
This is step 2 of a s. 36 analysis (step 1: is the disposition a
|
| 603 |
+
conviction -- canlex_us_disposition; step 3: rehabilitation --
|
| 604 |
+
canlex_rehabilitation, which takes this tool's max-penalty answer as
|
| 605 |
+
input). Covers the COMMON pairings (DUI, theft, assault, fraud, drugs,
|
| 606 |
+
cannabis, disorderly conduct...); anything unusual gets the Hill
|
| 607 |
+
methodology and an honest no-entry statement.
|
| 608 |
+
|
| 609 |
+
Returns:
|
| 610 |
+
str: Markdown -- the pairing with penalty, branch, caveats and
|
| 611 |
+
authorities, or the Hill framework when nothing matches.
|
| 612 |
+
"""
|
| 613 |
+
from canlex.commentary import BANNER
|
| 614 |
+
data = _load_equivalency()
|
| 615 |
+
if data is None:
|
| 616 |
+
return ("The equivalency dataset is not present in this deployment. "
|
| 617 |
+
"Apply Hill v Canada (MEI) (1987), 73 NR 315 (FCA): compare "
|
| 618 |
+
"essential elements, or the evidence of what was done, or "
|
| 619 |
+
"both; remember IRPA s. 36(3)(a) hybrid deeming.")
|
| 620 |
+
q = set(re.findall(r"[a-z0-9]+", offense.lower()))
|
| 621 |
+
scored = []
|
| 622 |
+
for p in data.get("pairings", []):
|
| 623 |
+
best = 0.0
|
| 624 |
+
for t in p["us_terms"]:
|
| 625 |
+
toks = set(re.findall(r"[a-z0-9]+", t.lower()))
|
| 626 |
+
if toks:
|
| 627 |
+
best = max(best, len(q & toks) / len(toks))
|
| 628 |
+
if best > 0.4:
|
| 629 |
+
scored.append((best, p))
|
| 630 |
+
scored.sort(key=lambda x: -x[0])
|
| 631 |
+
parts = [BANNER]
|
| 632 |
+
if scored:
|
| 633 |
+
for _, p in scored[:2]:
|
| 634 |
+
lines = [f"US offence: {'; '.join(p['us_terms'][:5])}",
|
| 635 |
+
f"Canadian equivalent: {p['canadian_offence']}",
|
| 636 |
+
f"Maximum penalty (verified): {p['penalty']}",
|
| 637 |
+
f"Inadmissibility branch: {p['branch']}", "",
|
| 638 |
+
p["analysis"]]
|
| 639 |
+
if p.get("caveats"):
|
| 640 |
+
lines.append("")
|
| 641 |
+
lines += [f"- CAVEAT: {c}" for c in p["caveats"]]
|
| 642 |
+
if p.get("authorities"):
|
| 643 |
+
lines += ["", "Authorities:"]
|
| 644 |
+
lines += [f"- {a['cite']} ({a['court']}): {a['holding']}"
|
| 645 |
+
for a in p["authorities"]]
|
| 646 |
+
parts.append("\n".join(lines))
|
| 647 |
+
else:
|
| 648 |
+
parts.append(
|
| 649 |
+
f"No curated pairing matches '{offense}'. Apply the Hill "
|
| 650 |
+
"framework (below) to the actual foreign statute -- compare "
|
| 651 |
+
"essential elements, or the evidence of what was done, or both.")
|
| 652 |
+
for m in data.get("methodology", []):
|
| 653 |
+
if m["id"] == "equivalency-framework":
|
| 654 |
+
parts.append("---\n\nGoverning framework:\n\n" + m["text"])
|
| 655 |
+
break
|
| 656 |
+
parts.append("Retrieve the Canadian provisions with canlex_get_section "
|
| 657 |
+
"to verify elements and penalties; feed the max-penalty "
|
| 658 |
+
"answer into canlex_rehabilitation for step 3.")
|
| 659 |
+
return "\n\n".join(parts)
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
@mcp.tool(name="canlex_rehabilitation",
|
| 663 |
+
annotations={"title": "Criminal Rehabilitation Assessment", **_READONLY})
|
| 664 |
+
def canlex_rehabilitation(
|
| 665 |
+
basis: Annotated[Literal["foreign-conviction", "foreign-act",
|
| 666 |
+
"canada-conviction"], Field(
|
| 667 |
+
description="How the inadmissibility arises: 'foreign-conviction' "
|
| 668 |
+
"(IRPA s. 36(1)(b)/(2)(b)), 'foreign-act' (s. 36(1)(c)/(2)(c)), or "
|
| 669 |
+
"'canada-conviction' (s. 36(1)(a)/(2)(a)).")],
|
| 670 |
+
max_penalty_lt_10: Annotated[bool, Field(
|
| 671 |
+
description="True if the equivalent Canadian offence's maximum term "
|
| 672 |
+
"of imprisonment is LESS THAN 10 years (remember IRPA s. 36(3)(a): "
|
| 673 |
+
"hybrid offences count as indictable at their indictable maximum).")],
|
| 674 |
+
count: Annotated[Literal["one", "two-plus-summary-only", "multiple"], Field(
|
| 675 |
+
description="'one' (a single conviction or act), "
|
| 676 |
+
"'two-plus-summary-only' (two or more, all summary-equivalent), or "
|
| 677 |
+
"'multiple' (more than one indictable-equivalent matter).")],
|
| 678 |
+
years_elapsed: Annotated[int, Field(
|
| 679 |
+
ge=0, le=100,
|
| 680 |
+
description="Full years since the day after completion of the ENTIRE "
|
| 681 |
+
"imposed sentence (conviction bases) or since the act was committed "
|
| 682 |
+
"(act basis).")],
|
| 683 |
+
clean_record: Annotated[bool, Field(
|
| 684 |
+
description="True if the person has no other convictions or "
|
| 685 |
+
"committed acts anywhere (the IRPR s. 18(2)(i)-(vii) conditions, "
|
| 686 |
+
"simplified -- the answer carries a caveat to confirm in detail).")],
|
| 687 |
+
) -> str:
|
| 688 |
+
"""Assess whether a person is deemed rehabilitated, eligible to apply for
|
| 689 |
+
individual rehabilitation, or neither, under IRPA s. 36(3)(c) and IRPR
|
| 690 |
+
ss. 17-18.1 -- the time-based cures for criminal inadmissibility.
|
| 691 |
+
|
| 692 |
+
Rules verified against the current IRPR consolidation: deemed
|
| 693 |
+
rehabilitation (automatic) for a single <10-year-maximum foreign
|
| 694 |
+
conviction after 10 clean years, two-plus summary-only convictions after
|
| 695 |
+
5 years, or a single committed act after 10 years; individual
|
| 696 |
+
rehabilitation applications after 5 years (available even for serious
|
| 697 |
+
criminality, which can never be deemed); record suspensions for in-Canada
|
| 698 |
+
convictions. This is step 3 of a s. 36 screening: step 1 is whether the
|
| 699 |
+
disposition is a conviction (canlex_us_disposition), step 2 is offence
|
| 700 |
+
equivalency (which also supplies the max-penalty input here).
|
| 701 |
+
|
| 702 |
+
Returns:
|
| 703 |
+
str: The assessment with the governing provision, explanation and
|
| 704 |
+
mandatory caveats. Curated logic -- verify against the cited
|
| 705 |
+
provisions via canlex_get_section.
|
| 706 |
+
"""
|
| 707 |
+
from canlex.rehab import assess
|
| 708 |
+
from canlex.commentary import BANNER
|
| 709 |
+
r = assess(basis, max_penalty_lt_10, count, years_elapsed, clean_record)
|
| 710 |
+
lines = [BANNER, "",
|
| 711 |
+
f"REHABILITATION STATUS: {r['status'].upper().replace('-', ' ')}",
|
| 712 |
+
f"Governing provision(s): {r['provision']}", "",
|
| 713 |
+
r["explanation"], "", "Caveats:"]
|
| 714 |
+
lines += [f"- {c}" for c in r["caveats"]]
|
| 715 |
+
lines.append("")
|
| 716 |
+
lines.append("Retrieve the provisions with canlex_get_section (act "
|
| 717 |
+
"'IRPR', sections '17'/'18'/'18.1'; act 'IRPA', section "
|
| 718 |
+
"'36').")
|
| 719 |
+
return "\n".join(lines)
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
@mcp.tool(name="canlex_list_acts",
|
| 723 |
+
annotations={"title": "List Loaded Legislation", **_READONLY})
|
| 724 |
+
def canlex_list_acts() -> str:
|
| 725 |
+
"""List what the CanLex corpus contains -- Acts and regulations, CBSA
|
| 726 |
+
D-Memoranda, collective agreements, NJC directives, leading cases,
|
| 727 |
+
delegation instruments, benefit-plan booklets, and CanLex's curated
|
| 728 |
+
commentary datasets.
|
| 729 |
+
|
| 730 |
+
Use this to learn the scope and currency of the corpus before searching, or to
|
| 731 |
+
report it to the user.
|
| 732 |
+
|
| 733 |
+
Returns:
|
| 734 |
+
str: Markdown grouped by source type.
|
| 735 |
+
"""
|
| 736 |
+
index = _index()
|
| 737 |
+
acts: dict[str, dict] = {}
|
| 738 |
+
agreements: dict[str, dict] = {}
|
| 739 |
+
directives: dict[str, dict] = {}
|
| 740 |
+
cases: dict[str, dict] = {}
|
| 741 |
+
delegations: dict[str, dict] = {}
|
| 742 |
+
benefits: dict[str, dict] = {}
|
| 743 |
+
commentary: dict[str, dict] = {}
|
| 744 |
+
union_docs: dict[str, dict] = {}
|
| 745 |
+
memo_numbers: set[str] = set()
|
| 746 |
+
memo_chunks = 0
|
| 747 |
+
memo_date = ""
|
| 748 |
+
fr_chunks = 0
|
| 749 |
+
fr_acts: set[str] = set()
|
| 750 |
+
for c in index.chunks:
|
| 751 |
+
if c.get("lang", "en") == "fr":
|
| 752 |
+
# French twins share their English instrument's identity; count
|
| 753 |
+
# them once as coverage rather than doubling every act's rows.
|
| 754 |
+
fr_chunks += 1
|
| 755 |
+
fr_acts.add(c["act_code"])
|
| 756 |
+
continue
|
| 757 |
+
doc_type = c.get("doc_type", "legislation")
|
| 758 |
+
if doc_type == "memorandum":
|
| 759 |
+
memo_numbers.add(c["section"])
|
| 760 |
+
memo_chunks += 1
|
| 761 |
+
memo_date = max(memo_date, c["current_to"] or "")
|
| 762 |
+
elif doc_type == "agreement":
|
| 763 |
+
entry = agreements.setdefault(c["act_code"], {
|
| 764 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 765 |
+
"current_to": c["current_to"], "count": 0,
|
| 766 |
+
})
|
| 767 |
+
entry["count"] += 1
|
| 768 |
+
elif doc_type == "directive":
|
| 769 |
+
entry = directives.setdefault(c["act_code"], {
|
| 770 |
+
"short": c["act_short"], "current_to": c["current_to"], "count": 0,
|
| 771 |
+
})
|
| 772 |
+
entry["count"] += 1
|
| 773 |
+
elif doc_type == "caselaw":
|
| 774 |
+
entry = cases.setdefault(c["act_code"], {
|
| 775 |
+
"name": c["act_name"], "decided": c["current_to"], "count": 0,
|
| 776 |
+
})
|
| 777 |
+
entry["count"] += 1
|
| 778 |
+
elif doc_type == "delegation":
|
| 779 |
+
entry = delegations.setdefault(c["act_code"], {
|
| 780 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 781 |
+
"current_to": c["current_to"], "count": 0,
|
| 782 |
+
})
|
| 783 |
+
entry["count"] += 1
|
| 784 |
+
elif doc_type == "benefits":
|
| 785 |
+
entry = benefits.setdefault(c["act_code"], {
|
| 786 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 787 |
+
"current_to": c["current_to"], "count": 0,
|
| 788 |
+
})
|
| 789 |
+
entry["count"] += 1
|
| 790 |
+
elif doc_type == "commentary":
|
| 791 |
+
entry = commentary.setdefault(c["act_code"], {
|
| 792 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 793 |
+
"current_to": c["current_to"], "count": 0,
|
| 794 |
+
})
|
| 795 |
+
entry["count"] += 1
|
| 796 |
+
elif doc_type == "union":
|
| 797 |
+
entry = union_docs.setdefault(c["act_code"], {
|
| 798 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 799 |
+
"current_to": c["current_to"], "count": 0,
|
| 800 |
+
})
|
| 801 |
+
entry["count"] += 1
|
| 802 |
+
else:
|
| 803 |
+
entry = acts.setdefault(c["act_code"], {
|
| 804 |
+
"short": c["act_short"], "name": c["act_name"],
|
| 805 |
+
"code": c["act_code"], "current_to": c["current_to"], "count": 0,
|
| 806 |
+
})
|
| 807 |
+
entry["count"] += 1
|
| 808 |
+
lines = ["# CanLex corpus", "", "## Enacted law"]
|
| 809 |
+
if fr_chunks:
|
| 810 |
+
lines.insert(1, f"_Bilingual: {len(fr_acts)} instruments also carry "
|
| 811 |
+
f"their parallel French text ({fr_chunks} articles); "
|
| 812 |
+
f"a French-language query searches both._")
|
| 813 |
+
for a in sorted(acts.values(), key=lambda x: x["short"]):
|
| 814 |
+
lines.append(f"- **{a['short']}** — {a['name']} ({a['code']}): "
|
| 815 |
+
f"{a['count']} sections, current to {a['current_to'] or 'n/a'}")
|
| 816 |
+
if memo_numbers:
|
| 817 |
+
lines += ["", "## CBSA guidance",
|
| 818 |
+
f"- **D-Memoranda** — {len(memo_numbers)} memoranda "
|
| 819 |
+
f"({memo_chunks} sections), modified up to {memo_date or 'n/a'}. "
|
| 820 |
+
f"Administrative guidance on customs and border law; "
|
| 821 |
+
f"persuasive, not binding."]
|
| 822 |
+
if agreements:
|
| 823 |
+
lines += ["", "## Collective agreements"]
|
| 824 |
+
for a in sorted(agreements.values(), key=lambda x: x["short"]):
|
| 825 |
+
lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} articles, "
|
| 826 |
+
f"in force to {a['current_to'] or 'n/a'}")
|
| 827 |
+
if directives:
|
| 828 |
+
lines += ["", "## NJC directives"]
|
| 829 |
+
for a in sorted(directives.values(), key=lambda x: x["short"]):
|
| 830 |
+
lines.append(f"- **{a['short']}**: {a['count']} sections, "
|
| 831 |
+
f"effective {a['current_to'] or 'n/a'}")
|
| 832 |
+
if cases:
|
| 833 |
+
lines += ["", "## Case law"]
|
| 834 |
+
for cite, a in sorted(cases.items(), key=lambda kv: kv[1]["decided"]):
|
| 835 |
+
lines.append(f"- **{a['name']}**, {cite}: {a['count']} excerpts, "
|
| 836 |
+
f"decided {a['decided'] or 'n/a'}")
|
| 837 |
+
if delegations:
|
| 838 |
+
lines += ["", "## Delegation instruments"]
|
| 839 |
+
for a in sorted(delegations.values(), key=lambda x: x["short"]):
|
| 840 |
+
lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} items, "
|
| 841 |
+
f"dated {a['current_to'] or 'n/a'}")
|
| 842 |
+
if benefits:
|
| 843 |
+
lines += ["", "## Benefit plans"]
|
| 844 |
+
for a in sorted(benefits.values(), key=lambda x: x["short"]):
|
| 845 |
+
lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
|
| 846 |
+
f"sections, booklet edition {a['current_to'] or 'n/a'}. "
|
| 847 |
+
f"Plain-language member guide; the plan rules govern.")
|
| 848 |
+
if union_docs:
|
| 849 |
+
lines += ["", "## Union documents (CIU/PSAC)"]
|
| 850 |
+
for a in sorted(union_docs.values(), key=lambda x: x["short"]):
|
| 851 |
+
lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
|
| 852 |
+
f"sections, currency {a['current_to'] or 'n/a'}. "
|
| 853 |
+
f"Bargaining-agent document; not employer policy.")
|
| 854 |
+
if commentary:
|
| 855 |
+
lines += ["", "## Curated commentary (not law)"]
|
| 856 |
+
for a in sorted(commentary.values(), key=lambda x: x["short"]):
|
| 857 |
+
lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
|
| 858 |
+
f"entries, reviewed {a['current_to'] or 'n/a'}. "
|
| 859 |
+
f"CanLex-authored analysis of the cited authorities; "
|
| 860 |
+
f"clearly bannered, never a source of law.")
|
| 861 |
+
lines += ["", "Search with canlex_search_legislation; filter by doc_type "
|
| 862 |
+
"(legislation / memorandum / agreement / directive / caselaw / "
|
| 863 |
+
"delegation / benefits / commentary). Fetch a known provision "
|
| 864 |
+
"with canlex_get_section, or a case's citations with canlex_case."]
|
| 865 |
+
return "\n".join(lines)
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
@mcp.tool(name="canlex_whats_new",
|
| 869 |
+
annotations={"title": "What Changed Recently", **_READONLY})
|
| 870 |
+
def canlex_whats_new(
|
| 871 |
+
since: Annotated[str, Field(
|
| 872 |
+
description="ISO date (YYYY-MM-DD): report amendments and additions "
|
| 873 |
+
"on or after this date.", pattern=r"^\d{4}-\d{2}-\d{2}$")],
|
| 874 |
+
) -> str:
|
| 875 |
+
"""List what has changed in the corpus's law since a date: provisions
|
| 876 |
+
amended or newly in force on or after it, plus each guidance source's
|
| 877 |
+
newest modification date.
|
| 878 |
+
|
| 879 |
+
Use for questions like "has IRPA changed since March?" or "what did the
|
| 880 |
+
last refresh pick up?". Changes reflect the ingested consolidations --
|
| 881 |
+
an amendment newer than the corpus's 'current to' dates is invisible
|
| 882 |
+
here; say so when the date asked about is very recent.
|
| 883 |
+
|
| 884 |
+
Returns:
|
| 885 |
+
str: Markdown -- amended/new provisions grouped by Act, then
|
| 886 |
+
guidance sources' newest dates.
|
| 887 |
+
"""
|
| 888 |
+
index = _index()
|
| 889 |
+
amended: dict[str, list] = {}
|
| 890 |
+
guidance_newest: dict[str, str] = {}
|
| 891 |
+
for c in index.chunks:
|
| 892 |
+
if c.get("lang", "en") == "fr":
|
| 893 |
+
continue
|
| 894 |
+
if c.get("doc_type", "legislation") == "legislation":
|
| 895 |
+
stamp = max(c.get("last_amended") or "", c.get("in_force") or "")
|
| 896 |
+
if stamp >= since and c.get("section"):
|
| 897 |
+
amended.setdefault(c["act_short"], []).append(
|
| 898 |
+
(c["section"], c.get("marginal_note", ""), stamp))
|
| 899 |
+
else:
|
| 900 |
+
key = c.get("act_short") or c.get("doc_type")
|
| 901 |
+
cur = c.get("current_to") or ""
|
| 902 |
+
if cur > guidance_newest.get(key, ""):
|
| 903 |
+
guidance_newest[key] = cur
|
| 904 |
+
lines = [f"# Changes since {since}", ""]
|
| 905 |
+
if amended:
|
| 906 |
+
lines.append("## Provisions amended or newly in force")
|
| 907 |
+
for act in sorted(amended):
|
| 908 |
+
secs = sorted({(s, n, d) for s, n, d in amended[act]},
|
| 909 |
+
key=lambda t: t[2], reverse=True)
|
| 910 |
+
lines.append(f"**{act}** ({len(secs)} provisions):")
|
| 911 |
+
for s, n, d in secs[:25]:
|
| 912 |
+
lines.append(f"- s. {s} ({n}) — {d}")
|
| 913 |
+
if len(secs) > 25:
|
| 914 |
+
lines.append(f"- ...and {len(secs) - 25} more.")
|
| 915 |
+
else:
|
| 916 |
+
lines.append("No ingested provision shows an amendment or "
|
| 917 |
+
"coming-into-force date on or after that date.")
|
| 918 |
+
recent_guidance = {k: v for k, v in guidance_newest.items() if v >= since}
|
| 919 |
+
if recent_guidance:
|
| 920 |
+
lines += ["", "## Guidance sources modified since then"]
|
| 921 |
+
for k, v in sorted(recent_guidance.items(), key=lambda kv: -ord(kv[1][0])
|
| 922 |
+
if kv[1] else 0):
|
| 923 |
+
lines.append(f"- {k}: {v}")
|
| 924 |
+
lines += ["", "_Reflects the ingested consolidations only; verify "
|
| 925 |
+
"anything newer than the corpus's 'current to' dates upstream._"]
|
| 926 |
+
return "\n".join(lines)
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
@mcp.tool(name="canlex_citing",
|
| 930 |
+
annotations={"title": "What Cites This Provision", **_READONLY})
|
| 931 |
+
def canlex_citing(
|
| 932 |
+
act: Annotated[str, Field(
|
| 933 |
+
description="Act short name or code, e.g. 'IRPA'.",
|
| 934 |
+
min_length=1, max_length=60)],
|
| 935 |
+
section: Annotated[str, Field(
|
| 936 |
+
description="Section number, e.g. '34' (prefixes accepted).",
|
| 937 |
+
min_length=1, max_length=40)],
|
| 938 |
+
) -> str:
|
| 939 |
+
"""Reverse cross-reference lookup: everything in the corpus that cites a
|
| 940 |
+
given provision -- related provisions in the same Act, regulations made
|
| 941 |
+
under it, and the CBSA D-memoranda/guidance citing the section --
|
| 942 |
+
without fetching the section's own text first.
|
| 943 |
+
|
| 944 |
+
Returns:
|
| 945 |
+
str: Markdown lists of citing/related material, or a not-found
|
| 946 |
+
message.
|
| 947 |
+
"""
|
| 948 |
+
index = _index()
|
| 949 |
+
result = index.get_section(act, section)
|
| 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 (f"No section '{section}' in '{act}'. Use canlex_list_acts "
|
| 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 "
|
| 960 |
+
"binding): " + ", ".join(memos))
|
| 961 |
+
provisions = related.get("provisions")
|
| 962 |
+
if provisions:
|
| 963 |
+
lines.append("**Related provisions in this Act:** "
|
| 964 |
+
+ "; ".join(f"s. {s} ({n})" if n else f"s. {s}"
|
| 965 |
+
for s, n in provisions))
|
| 966 |
+
regs = related.get("regulations")
|
| 967 |
+
if regs:
|
| 968 |
+
lines.append("**Regulations made under this Act:** "
|
| 969 |
+
+ "; ".join(f"{n} ({s})" for s, n in regs))
|
| 970 |
+
enabling = related.get("enabling_act")
|
| 971 |
+
if enabling:
|
| 972 |
+
lines.append(f"**Made under:** {enabling[1]} ({enabling[0]})")
|
| 973 |
+
if len(lines) == 2:
|
| 974 |
+
lines.append("Nothing in the corpus cross-references this section.")
|
| 975 |
+
lines += ["", "For case law interpreting it, search with "
|
| 976 |
+
"canlex_search_legislation (doc_type 'caselaw')."]
|
| 977 |
+
return "\n".join(lines)
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
@mcp.tool(name="canlex_toc",
|
| 981 |
+
annotations={"title": "Act Table of Contents", **_READONLY})
|
| 982 |
+
def canlex_toc(
|
| 983 |
+
act: Annotated[str, Field(
|
| 984 |
+
description="Act short name or code, e.g. 'IRPA' or 'Customs Act'.",
|
| 985 |
+
min_length=1, max_length=60)],
|
| 986 |
+
) -> str:
|
| 987 |
+
"""Browse an Act's structure: its Parts/Divisions and the sections under
|
| 988 |
+
each, with their marginal notes. Use when you know the Act but not the
|
| 989 |
+
section number, or to survey what an Act covers.
|
| 990 |
+
|
| 991 |
+
Returns:
|
| 992 |
+
str: Markdown outline. Very large Acts (e.g. the Criminal Code)
|
| 993 |
+
return Parts with section ranges rather than every section.
|
| 994 |
+
"""
|
| 995 |
+
index = _index()
|
| 996 |
+
a = act.strip().lower()
|
| 997 |
+
secs = [c for c in index.chunks
|
| 998 |
+
if c.get("doc_type", "legislation") == "legislation"
|
| 999 |
+
and c.get("lang", "en") == "en"
|
| 1000 |
+
and a in (c["act_short"].lower(), c["act_code"].lower(),
|
| 1001 |
+
c.get("act_name", "").lower())
|
| 1002 |
+
and c.get("section") and not c["id"].endswith(tuple(
|
| 1003 |
+
f"-p{k}" for k in range(2, 40)))]
|
| 1004 |
+
if not secs:
|
| 1005 |
+
known = sorted({c["act_short"] for c in index.chunks
|
| 1006 |
+
if c.get("doc_type", "legislation") == "legislation"})
|
| 1007 |
+
return (f"No legislation matches '{act}'. Loaded: "
|
| 1008 |
+
+ ", ".join(known))
|
| 1009 |
+
by_part: dict[str, list] = {}
|
| 1010 |
+
seen = set()
|
| 1011 |
+
for c in secs:
|
| 1012 |
+
if c["section"] in seen:
|
| 1013 |
+
continue
|
| 1014 |
+
seen.add(c["section"])
|
| 1015 |
+
by_part.setdefault(c.get("part") or "(no Part)", []).append(
|
| 1016 |
+
(c["section"], c.get("marginal_note", "")))
|
| 1017 |
+
name = secs[0]["act_name"]
|
| 1018 |
+
total = sum(len(v) for v in by_part.values())
|
| 1019 |
+
lines = [f"# {name} — contents ({total} sections)", ""]
|
| 1020 |
+
detailed = total <= 150
|
| 1021 |
+
for part, entries in by_part.items():
|
| 1022 |
+
lines.append(f"## {part}" if part != "(no Part)" else "## —")
|
| 1023 |
+
if detailed:
|
| 1024 |
+
for s, n in entries:
|
| 1025 |
+
lines.append(f"- s. {s}" + (f" — {n}" if n else ""))
|
| 1026 |
+
else:
|
| 1027 |
+
first, last = entries[0][0], entries[-1][0]
|
| 1028 |
+
lines.append(f"- ss. {first}–{last} ({len(entries)} sections)")
|
| 1029 |
+
lines += ["", "Fetch any section with canlex_get_section."]
|
| 1030 |
+
return "\n".join(lines)
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
_CITATOR = None
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
def _citator():
|
| 1037 |
+
"""Load and cache the CanLII citator on first use."""
|
| 1038 |
+
global _CITATOR
|
| 1039 |
+
if _CITATOR is None:
|
| 1040 |
+
from canlex.citator import Citator
|
| 1041 |
+
_CITATOR = Citator()
|
| 1042 |
+
return _CITATOR
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
def _format_case(report: dict) -> str:
|
| 1046 |
+
"""Render a CanLII citator report as Markdown."""
|
| 1047 |
+
meta = report["meta"]
|
| 1048 |
+
lines = [f"## {meta.get('title', '(untitled case)')}",
|
| 1049 |
+
meta.get("citation", "").strip()]
|
| 1050 |
+
facts = []
|
| 1051 |
+
if meta.get("decisionDate"):
|
| 1052 |
+
facts.append(f"decided {meta['decisionDate']}")
|
| 1053 |
+
if meta.get("docketNumber"):
|
| 1054 |
+
facts.append(f"docket {meta['docketNumber']}")
|
| 1055 |
+
if facts:
|
| 1056 |
+
lines.append("(" + "; ".join(facts) + ")")
|
| 1057 |
+
if meta.get("keywords"):
|
| 1058 |
+
lines.append(f"**Keywords:** {meta['keywords']}")
|
| 1059 |
+
if meta.get("topics"):
|
| 1060 |
+
lines.append(f"**Topics:** {meta['topics']}")
|
| 1061 |
+
if meta.get("url"):
|
| 1062 |
+
lines.append(f"CanLII: {meta['url']}")
|
| 1063 |
+
lines.append("")
|
| 1064 |
+
lines.append("_CanLII citator data (live). This is case metadata and the "
|
| 1065 |
+
"citation graph only -- not the judgment text; follow the CanLII "
|
| 1066 |
+
"link to read the decision. A case is binding precedent depending "
|
| 1067 |
+
"on the court and jurisdiction._")
|
| 1068 |
+
|
| 1069 |
+
def case_list(label, block):
|
| 1070 |
+
rows = [f"\n### {label}: {block['total']}"]
|
| 1071 |
+
for item in block["items"]:
|
| 1072 |
+
rows.append(f"- {item.get('title', '')} — {item.get('citation', '')}")
|
| 1073 |
+
extra = block["total"] - len(block["items"])
|
| 1074 |
+
if extra > 0:
|
| 1075 |
+
rows.append(f" ...and {extra} more.")
|
| 1076 |
+
return rows
|
| 1077 |
+
|
| 1078 |
+
lines += case_list("Cited by (later cases citing this one)", report["citingCases"])
|
| 1079 |
+
lines += case_list("Cites (authorities this case relies on)", report["citedCases"])
|
| 1080 |
+
|
| 1081 |
+
legis = report["citedLegislations"]
|
| 1082 |
+
lines.append(f"\n### Legislation cited: {legis['total']}")
|
| 1083 |
+
for item in legis["items"]:
|
| 1084 |
+
kind = item.get("type", "")
|
| 1085 |
+
lines.append(f"- {item.get('title', '')} — {item.get('citation', '')}"
|
| 1086 |
+
+ (f" [{kind}]" if kind else ""))
|
| 1087 |
+
extra = legis["total"] - len(legis["items"])
|
| 1088 |
+
if extra > 0:
|
| 1089 |
+
lines.append(f" ...and {extra} more.")
|
| 1090 |
+
return "\n".join(lines)
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
@mcp.tool(name="canlex_case",
|
| 1094 |
+
annotations={"title": "CanLII Case Citator", "readOnlyHint": True,
|
| 1095 |
+
"destructiveHint": False, "idempotentHint": True,
|
| 1096 |
+
"openWorldHint": True})
|
| 1097 |
+
def canlex_case(
|
| 1098 |
+
case_url: Annotated[str, Field(
|
| 1099 |
+
description="A Canadian case, given either as a full canlii.org URL "
|
| 1100 |
+
"or -- for a Supreme Court, Federal Court of Appeal, Federal Court, "
|
| 1101 |
+
"FPSLREB or CIRB decision -- its neutral citation (e.g. '2019 SCC "
|
| 1102 |
+
"65', '2016 FCA 93', '2023 FPSLREB 12'). For other courts, supply "
|
| 1103 |
+
"the canlii.org URL; find it by web search if you only have the "
|
| 1104 |
+
"case name.", min_length=8, max_length=400)],
|
| 1105 |
+
) -> str:
|
| 1106 |
+
"""Look up a Canadian case on CanLII and return its citation graph.
|
| 1107 |
+
|
| 1108 |
+
Returns the case's metadata plus its citator: the cases it cites, the cases
|
| 1109 |
+
that cite it (its treatment and how leading it is), and the legislation it
|
| 1110 |
+
cites -- live from the CanLII API. Use it to gauge whether a decision is
|
| 1111 |
+
still good law -- how heavily and how recently it has been cited.
|
| 1112 |
+
|
| 1113 |
+
Supply either a canlii.org URL or, for a Supreme Court / Federal Court of
|
| 1114 |
+
Appeal / Federal Court decision, its neutral citation (e.g. '2019 SCC 65') --
|
| 1115 |
+
the citation a canlex_search_legislation result already shows. This returns
|
| 1116 |
+
metadata and the citation graph only, NOT the judgment text -- follow the
|
| 1117 |
+
CanLII link for that. A call takes ~15-20 seconds (the API is rate-limited).
|
| 1118 |
+
|
| 1119 |
+
Args:
|
| 1120 |
+
params (CaseInput): contains case_url -- a canlii.org URL or a neutral
|
| 1121 |
+
citation.
|
| 1122 |
+
|
| 1123 |
+
Returns:
|
| 1124 |
+
str: Markdown -- the case's title, neutral citation, date, docket and
|
| 1125 |
+
topics; how many cases cite it (with examples); how many it cites; and the
|
| 1126 |
+
legislation it cites. Returns an error message if the URL is not a
|
| 1127 |
+
recognized canlii.org case URL, or if the CanLII API is unavailable.
|
| 1128 |
+
"""
|
| 1129 |
+
try:
|
| 1130 |
+
citator = _citator()
|
| 1131 |
+
except Exception:
|
| 1132 |
+
return ("This deployment has no citator configured (the live CanLII "
|
| 1133 |
+
"lookup is unavailable here). Fall back to a web search for "
|
| 1134 |
+
"the case's CanLII page and note that its later treatment "
|
| 1135 |
+
"was not verified.")
|
| 1136 |
+
try:
|
| 1137 |
+
report = citator.case_report(case_url)
|
| 1138 |
+
except Exception as exc:
|
| 1139 |
+
return f"CanLII lookup failed: {type(exc).__name__}: {exc}"
|
| 1140 |
+
if "error" in report:
|
| 1141 |
+
return f"Error: {report['error']}"
|
| 1142 |
+
return _format_case(report)
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
@mcp.prompt(name="us_record_screening",
|
| 1146 |
+
description="Guided IRPA s. 36 screening of a US criminal record: "
|
| 1147 |
+
"disposition -> equivalency -> rehabilitation, using the three "
|
| 1148 |
+
"CanLex screening tools in order with the right hand-offs.")
|
| 1149 |
+
def us_record_screening(disposition: str, state: str = "",
|
| 1150 |
+
offense: str = "", sentence_completed_year: str = "",
|
| 1151 |
+
) -> str:
|
| 1152 |
+
"""The three-step s. 36 screening chain as a reusable client prompt."""
|
| 1153 |
+
facts = [f"Disposition: {disposition}"]
|
| 1154 |
+
if state:
|
| 1155 |
+
facts.append(f"State: {state}")
|
| 1156 |
+
if offense:
|
| 1157 |
+
facts.append(f"Offence: {offense}")
|
| 1158 |
+
if sentence_completed_year:
|
| 1159 |
+
facts.append(f"Sentence completed: {sentence_completed_year}")
|
| 1160 |
+
return (
|
| 1161 |
+
"Screen this US criminal record for inadmissibility under IRPA "
|
| 1162 |
+
"s. 36, using CanLex's three screening tools IN ORDER and carrying "
|
| 1163 |
+
"each step's output into the next:\n\n" + "\n".join(facts) + "\n\n"
|
| 1164 |
+
"STEP 1 — canlex_us_disposition (pass the disposition and state): is "
|
| 1165 |
+
"this a 'conviction' at all? If the verdict is NO or LIKELY NO, note "
|
| 1166 |
+
"that the act branch (s. 36(1)(c)/(2)(c)) may still apply and say "
|
| 1167 |
+
"so; a NO verdict usually ends the conviction-branch analysis.\n"
|
| 1168 |
+
"STEP 2 — canlex_us_equivalency (pass the offence): what Canadian "
|
| 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: compute full years since sentence "
|
| 1173 |
+
"completion from today's date, pass basis='foreign-conviction', the "
|
| 1174 |
+
"max-penalty answer from step 2, the count, the elapsed years, and "
|
| 1175 |
+
"whether the record is otherwise clean.\n\n"
|
| 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 "
|
| 1179 |
+
"determination governs at the port of entry.")
|
| 1180 |
+
|
| 1181 |
+
|
| 1182 |
+
@mcp.resource("canlex://grounding-note",
|
| 1183 |
+
name="CanLex answering instructions",
|
| 1184 |
+
description="The grounding/citation/hedging instructions "
|
| 1185 |
+
"prepended to every search result.")
|
| 1186 |
+
def _res_grounding() -> str:
|
| 1187 |
+
return GROUNDING_NOTE
|
| 1188 |
+
|
| 1189 |
+
|
| 1190 |
+
@mcp.resource("canlex://corpus",
|
| 1191 |
+
name="CanLex corpus inventory",
|
| 1192 |
+
description="Everything the corpus contains, grouped by source "
|
| 1193 |
+
"type, with currency dates.")
|
| 1194 |
+
def _res_corpus() -> str:
|
| 1195 |
+
return canlex_list_acts()
|
| 1196 |
+
|
| 1197 |
+
|
| 1198 |
+
@mcp.resource("canlex://methodology/conviction",
|
| 1199 |
+
name="US disposition conviction framework",
|
| 1200 |
+
description="The governing framework for whether a US "
|
| 1201 |
+
"disposition is an IRPA s. 36 'conviction'.")
|
| 1202 |
+
def _res_conviction_framework() -> str:
|
| 1203 |
+
data = _load_dispositions()
|
| 1204 |
+
if data:
|
| 1205 |
+
for m in data.get("methodology", []):
|
| 1206 |
+
if m.get("id") == "conviction-framework":
|
| 1207 |
+
return m["text"]
|
| 1208 |
+
return "The dispositions dataset is not present in this deployment."
|
| 1209 |
+
|
| 1210 |
+
|
| 1211 |
+
@mcp.resource("canlex://methodology/equivalency",
|
| 1212 |
+
name="Offence equivalency framework (Hill)",
|
| 1213 |
+
description="The Hill v MEI methodology for equating a foreign "
|
| 1214 |
+
"offence to a Canadian one, with the s. 36(3)(a) and Tran rules.")
|
| 1215 |
+
def _res_equivalency_framework() -> str:
|
| 1216 |
+
data = _load_equivalency()
|
| 1217 |
+
if data:
|
| 1218 |
+
for m in data.get("methodology", []):
|
| 1219 |
+
if m.get("id") == "equivalency-framework":
|
| 1220 |
+
return m["text"]
|
| 1221 |
+
return "The equivalency dataset is not present in this deployment."
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
if __name__ == "__main__":
|
| 1225 |
+
try:
|
| 1226 |
+
idx = _index()
|
| 1227 |
+
print(f"CanLex MCP: {len(idx.chunks)} sections loaded.", file=sys.stderr)
|
| 1228 |
+
except Exception as exc: # surfaced in the MCP client's server logs
|
| 1229 |
+
print(f"CanLex MCP: failed to load legislation index: {exc}\n"
|
| 1230 |
+
f"Run 'py -m canlex.ingest' first.", file=sys.stderr)
|
| 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 |
+
mcp.run(transport="streamable-http")
|
| 1236 |
+
else:
|
| 1237 |
+
mcp.run()
|
canlex/sweep.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coordinate-descent tuning sweep for the four retrieval knobs.
|
| 2 |
+
|
| 3 |
+
Runs the 141-question eval repeatedly, varying one knob at a time while holding
|
| 4 |
+
the others at their current best. Picks the value that maximises Hit@5 (with
|
| 5 |
+
MRR as the tiebreak) and continues to the next knob. One pass through all four
|
| 6 |
+
knobs is usually enough to settle.
|
| 7 |
+
|
| 8 |
+
The knobs are read from env vars at index-load time (see canlex/index.py), so
|
| 9 |
+
each combination is exercised in a fresh subprocess of canlex.eval. Outputs go
|
| 10 |
+
to data/eval/sweep.log and a compact JSON summary to data/eval/sweep.json.
|
| 11 |
+
|
| 12 |
+
py -m canlex.sweep
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
LOG = ROOT / "data" / "eval" / "sweep.log"
|
| 24 |
+
SUMMARY = ROOT / "data" / "eval" / "sweep.json"
|
| 25 |
+
|
| 26 |
+
# (env var, list of candidate values, current default). The defaults match the
|
| 27 |
+
# literals in canlex/index.py; values bracket each on a roughly geometric grid.
|
| 28 |
+
KNOBS = [
|
| 29 |
+
("CANLEX_MN_WEIGHT", [0.0012, 0.0024, 0.005, 0.01], 0.0024),
|
| 30 |
+
("CANLEX_MN_CAP", [0.006, 0.012, 0.024, 0.05], 0.012),
|
| 31 |
+
("CANLEX_REG_PENALTY", [0.004, 0.008, 0.016, 0.032], 0.008),
|
| 32 |
+
("CANLEX_BACKMATTER_PENALTY",[0.004, 0.008, 0.016, 0.032], 0.008),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
_METRIC_RE = re.compile(
|
| 37 |
+
r"Hit@1:\s*([\d.]+).*?Hit@3:\s*([\d.]+).*?Hit@5:\s*([\d.]+).*?"
|
| 38 |
+
r"Hit@10:\s*([\d.]+).*?MRR:\s*([\d.]+)", re.S)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
|
| 42 |
+
"""Run canlex.eval once with the given env overrides; return metrics dict."""
|
| 43 |
+
env = dict(os.environ)
|
| 44 |
+
env["CANLEX_EVAL_SWEEP"] = "1" # holdout questions stay sweep-blind
|
| 45 |
+
for k, v in env_overrides.items():
|
| 46 |
+
env[k] = f"{v}"
|
| 47 |
+
proc = subprocess.run(
|
| 48 |
+
[sys.executable, "-u", "-m", "canlex.eval"],
|
| 49 |
+
capture_output=True, text=True, env=env, cwd=ROOT,
|
| 50 |
+
)
|
| 51 |
+
if proc.returncode != 0:
|
| 52 |
+
raise RuntimeError(f"eval failed (exit {proc.returncode}):\n"
|
| 53 |
+
f"{proc.stderr[-800:]}")
|
| 54 |
+
m = _METRIC_RE.search(proc.stdout)
|
| 55 |
+
if not m:
|
| 56 |
+
raise RuntimeError(f"could not parse eval output:\n{proc.stdout[-800:]}")
|
| 57 |
+
h1, h3, h5, h10, mrr = (float(x) for x in m.groups())
|
| 58 |
+
n_misses = proc.stdout.count("miss(es)") # 0 if we end up at 100
|
| 59 |
+
return {"hit1": h1, "hit3": h3, "hit5": h5, "hit10": h10, "mrr": mrr,
|
| 60 |
+
"stdout": proc.stdout}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _score(metrics: dict[str, float]) -> tuple[float, float]:
|
| 64 |
+
"""Order by Hit@5 then MRR (both higher is better)."""
|
| 65 |
+
return (metrics["hit5"], metrics["mrr"])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def main():
|
| 69 |
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
log = LOG.open("w", encoding="utf-8")
|
| 71 |
+
log.write(f"# CanLex tuning sweep -- {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 72 |
+
print(f"# CanLex tuning sweep -- writing log to {LOG}\n")
|
| 73 |
+
|
| 74 |
+
current = {name: default for name, _values, default in KNOBS}
|
| 75 |
+
all_runs: list[dict] = []
|
| 76 |
+
|
| 77 |
+
# Baseline at current defaults.
|
| 78 |
+
print("Baseline:", current)
|
| 79 |
+
log.write(f"\nBaseline: {current}\n")
|
| 80 |
+
baseline = _run_eval(current)
|
| 81 |
+
print(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}")
|
| 82 |
+
log.write(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}\n")
|
| 83 |
+
all_runs.append({"values": dict(current), "metrics":
|
| 84 |
+
{k: baseline[k] for k in ("hit1", "hit3", "hit5", "hit10", "mrr")}})
|
| 85 |
+
best_metrics = baseline
|
| 86 |
+
|
| 87 |
+
for name, values, _default in KNOBS:
|
| 88 |
+
print(f"\nSweeping {name} in {values} (others held at {current})...")
|
| 89 |
+
log.write(f"\nSweeping {name} in {values} (others held at {current})\n")
|
| 90 |
+
local_best = (current[name], _score(best_metrics), best_metrics)
|
| 91 |
+
for v in values:
|
| 92 |
+
if v == current[name]:
|
| 93 |
+
# Re-use the already-measured baseline at this knob value.
|
| 94 |
+
metrics = best_metrics
|
| 95 |
+
else:
|
| 96 |
+
run_values = dict(current, **{name: v})
|
| 97 |
+
metrics = _run_eval(run_values)
|
| 98 |
+
row = (f" {name}={v!r:<8s} -> Hit@1={metrics['hit1']:.3f} "
|
| 99 |
+
f"Hit@3={metrics['hit3']:.3f} Hit@5={metrics['hit5']:.3f} "
|
| 100 |
+
f"Hit@10={metrics['hit10']:.3f} MRR={metrics['mrr']:.3f}")
|
| 101 |
+
print(row); log.write(row + "\n"); log.flush()
|
| 102 |
+
all_runs.append({"values": dict(current, **{name: v}),
|
| 103 |
+
"metrics": {k: metrics[k] for k in
|
| 104 |
+
("hit1", "hit3", "hit5", "hit10", "mrr")}})
|
| 105 |
+
score = _score(metrics)
|
| 106 |
+
if score > local_best[1]:
|
| 107 |
+
local_best = (v, score, metrics)
|
| 108 |
+
if local_best[0] != current[name]:
|
| 109 |
+
print(f" ! {name}: {current[name]} -> {local_best[0]} "
|
| 110 |
+
f"(Hit@5 {best_metrics['hit5']:.3f} -> {local_best[2]['hit5']:.3f})")
|
| 111 |
+
log.write(f" ! {name}: {current[name]} -> {local_best[0]}\n")
|
| 112 |
+
current[name] = local_best[0]
|
| 113 |
+
best_metrics = local_best[2]
|
| 114 |
+
|
| 115 |
+
print(f"\nBest: {current}")
|
| 116 |
+
print(f" Hit@1={best_metrics['hit1']:.3f} Hit@3={best_metrics['hit3']:.3f} "
|
| 117 |
+
f"Hit@5={best_metrics['hit5']:.3f} Hit@10={best_metrics['hit10']:.3f} "
|
| 118 |
+
f"MRR={best_metrics['mrr']:.3f}")
|
| 119 |
+
log.write(f"\nBest: {current}\n Hit@1={best_metrics['hit1']:.3f} "
|
| 120 |
+
f"Hit@5={best_metrics['hit5']:.3f} MRR={best_metrics['mrr']:.3f}\n")
|
| 121 |
+
log.close()
|
| 122 |
+
SUMMARY.write_text(json.dumps({
|
| 123 |
+
"best": current,
|
| 124 |
+
"best_metrics": {k: best_metrics[k] for k in
|
| 125 |
+
("hit1", "hit3", "hit5", "hit10", "mrr")},
|
| 126 |
+
"runs": all_runs,
|
| 127 |
+
}, indent=2), encoding="utf-8")
|
| 128 |
+
print(f"\nLog: {LOG}\nSummary: {SUMMARY}")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
main()
|
canlex/synonyms.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query-side expansion of legal abbreviations and informal terms.
|
| 2 |
+
|
| 3 |
+
Statutes use formal wording -- "application for protection", "removal order" --
|
| 4 |
+
but users (and a model drafting a search) reach for everyday shorthand: "PRRA",
|
| 5 |
+
"H&C", "deportation". Before retrieval, expand_query() appends the canonical
|
| 6 |
+
statutory terms for any abbreviation or nickname it recognises, so the BM25 and
|
| 7 |
+
semantic stages can match the provision's actual language. It only ever ADDS
|
| 8 |
+
words -- the user's own phrasing is left untouched -- and the cross-encoder
|
| 9 |
+
reranker still sees the original query, so precision is unaffected.
|
| 10 |
+
|
| 11 |
+
python -m canlex.synonyms "PRRA eligibility and an H&C application"
|
| 12 |
+
"""
|
| 13 |
+
import re
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
# (trigger, canonical terms to append). The trigger is a regex fragment matched
|
| 17 |
+
# case-insensitively as a whole word. Keep this list high-precision: an entry
|
| 18 |
+
# earns its place only when the shorthand is unambiguous in Canadian border,
|
| 19 |
+
# immigration, customs, financial-crime or labour law.
|
| 20 |
+
_SYNONYMS = [
|
| 21 |
+
# Immigration and refugee law
|
| 22 |
+
(r"prra", "pre-removal risk assessment application for protection"),
|
| 23 |
+
(r"pre[- ]removal risk assessment", "application for protection"),
|
| 24 |
+
(r"h\s*&\s*c", "humanitarian and compassionate"),
|
| 25 |
+
(r"rad", "refugee appeal division"),
|
| 26 |
+
(r"rpd", "refugee protection division"),
|
| 27 |
+
(r"iad", "immigration appeal division"),
|
| 28 |
+
(r"irb", "immigration and refugee board"),
|
| 29 |
+
(r"trp", "temporary resident permit"),
|
| 30 |
+
(r"deportation", "removal order"),
|
| 31 |
+
# "No right to appeal (a removal order)" is how users ask about IRPA
|
| 32 |
+
# s. 64, but the section itself never says "removal order" or "right" --
|
| 33 |
+
# its vocabulary is "no appeal may be made ... inadmissible on grounds
|
| 34 |
+
# of ... serious criminality". Bridge the gap.
|
| 35 |
+
(r"no (?:right (?:to|of) )?appeal",
|
| 36 |
+
"no appeal may be made inadmissibility serious criminality"),
|
| 37 |
+
# "Standard of proof" is how users ask about IRPA s. 33, whose own words
|
| 38 |
+
# are "reasonable grounds to believe" that the facts "have occurred".
|
| 39 |
+
(r"standard of proof", "reasonable grounds to believe facts constitute"),
|
| 40 |
+
# "Apply for (criminal) rehabilitation" points at IRPR s. 17, a tiny
|
| 41 |
+
# section titled only "Prescribed period" whose vocabulary is the
|
| 42 |
+
# five-year period after "completion of an imposed sentence".
|
| 43 |
+
(r"appl(?:y|ication) for (?:criminal )?rehabilitation",
|
| 44 |
+
"prescribed period five years completion of imposed sentence"),
|
| 45 |
+
(r"misrep", "misrepresentation"),
|
| 46 |
+
(r"ircc", "immigration refugees and citizenship canada"),
|
| 47 |
+
(r"eta", "electronic travel authorization"),
|
| 48 |
+
(r"trv", "temporary resident visa"),
|
| 49 |
+
(r"arc", "authorization to return to canada removal order"),
|
| 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.
|
| 56 |
+
(r"(?:dwi|dui|drunk driving)",
|
| 57 |
+
"operation while impaired blood alcohol concentration conveyance"),
|
| 58 |
+
(r"personal exemption",
|
| 59 |
+
"returning persons exemption duty free allowance"),
|
| 60 |
+
(r"(?:nexus|trusted traveller)",
|
| 61 |
+
"presentation of persons alternative manner authorization"),
|
| 62 |
+
# Border and customs
|
| 63 |
+
(r"cbsa", "canada border services agency"),
|
| 64 |
+
(r"bsos?", "border services officer"),
|
| 65 |
+
(r"amps", "administrative monetary penalty system"),
|
| 66 |
+
# Financial-crime and labour
|
| 67 |
+
(r"fintrac", "financial transactions and reports analysis centre"),
|
| 68 |
+
(r"njc", "national joint council"),
|
| 69 |
+
# French -> English bridges. The corpus is English-only, but officers work
|
| 70 |
+
# bilingually and drafting agents emit French terms; with the tokenizer's
|
| 71 |
+
# accent folding these triggers match either accented or folded forms.
|
| 72 |
+
# High-precision statutory anchors only.
|
| 73 |
+
(r"erar", "pre-removal risk assessment application for protection"),
|
| 74 |
+
(r"asfc", "canada border services agency"),
|
| 75 |
+
(r"interdiction de territoire", "inadmissibility inadmissible"),
|
| 76 |
+
(r"grande criminalit[ée]", "serious criminality"),
|
| 77 |
+
(r"criminalit[ée]", "criminality convicted offence"),
|
| 78 |
+
(r"renvoi", "removal order"),
|
| 79 |
+
(r"saisie?s?", "seizure seized goods"),
|
| 80 |
+
# 'devises' is the everyday word for currency; the PCMLTFA's French text
|
| 81 |
+
# says 'espèces et effets' -- bridge to both statutory vocabularies.
|
| 82 |
+
(r"devises", "currency monetary instruments espèces effets"),
|
| 83 |
+
(r"r[ée]sidents? permanents?", "permanent resident"),
|
| 84 |
+
(r"[ée]trangers?", "foreign national"),
|
| 85 |
+
(r"d[ée]tention", "detention detained review"),
|
| 86 |
+
(r"r[ée]fugi[ée]s?", "refugee protection convention"),
|
| 87 |
+
(r"douanes?", "customs duties"),
|
| 88 |
+
(r"marchandises?", "goods imported"),
|
| 89 |
+
(r"d[ée]claration", "report declaration"),
|
| 90 |
+
(r"armes? [àa] feu", "firearm weapon"),
|
| 91 |
+
(r"contr[ôo]le", "examination search"),
|
| 92 |
+
(r"agents? des services frontaliers", "border services officer"),
|
| 93 |
+
(r"permis de travail", "work permit"),
|
| 94 |
+
(r"permis d'[ée]tudes", "study permit"),
|
| 95 |
+
(r"visa de r[ée]sident temporaire", "temporary resident visa"),
|
| 96 |
+
(r"r[ée]adaptation", "rehabilitation prescribed period"),
|
| 97 |
+
(r"expulsion", "deportation removal order"),
|
| 98 |
+
(r"passeurs?", "human smuggling organized entry"),
|
| 99 |
+
(r"fouille", "search of the person strip search"),
|
| 100 |
+
(r"griefs?", "grievance adjudication"),
|
| 101 |
+
(r"convention collective", "collective agreement"),
|
| 102 |
+
(r"heures suppl[ée]mentaires", "overtime hours of work"),
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
_COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)
|
| 106 |
+
for trigger, expansion in _SYNONYMS]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def expand_query(query):
|
| 110 |
+
"""Return `query` with canonical statutory terms appended for every legal
|
| 111 |
+
abbreviation it contains; return it unchanged if it contains none."""
|
| 112 |
+
additions = [exp for pattern, exp in _COMPILED if pattern.search(query)]
|
| 113 |
+
if not additions:
|
| 114 |
+
return query
|
| 115 |
+
return f"{query} {' '.join(additions)}"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
query = " ".join(sys.argv[1:]) or "PRRA eligibility and an H&C application"
|
| 120 |
+
print(f"query: {query}")
|
| 121 |
+
print(f"expanded: {expand_query(query)}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|
canlex/tariff_schedule.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest Chapters 98 and 99 of the Customs Tariff Schedule.
|
| 2 |
+
|
| 3 |
+
The Customs Tariff's Schedule is the Harmonized System classification of goods
|
| 4 |
+
-- chapters 1-97 categorise every imported good for duty purposes. Those 97
|
| 5 |
+
chapters are huge and outside CanLex's scope, but **chapters 98 and 99 are
|
| 6 |
+
different**: they carry Canada's "special classification" provisions, which
|
| 7 |
+
matter for almost every CBSA border interaction:
|
| 8 |
+
|
| 9 |
+
- Chapter 98 (non-commercial): traveller exemptions, settler's effects,
|
| 10 |
+
Canadian goods returned, ancestral household effects, conveyances
|
| 11 |
+
temporarily imported by a resident, etc.
|
| 12 |
+
- Chapter 99 (commercial): temporary importations, end-use programs,
|
| 13 |
+
government imports, reduced-rate goods for specific industries.
|
| 14 |
+
|
| 15 |
+
Source: the CBSA's HTML edition of the current Customs Tariff. The Justice
|
| 16 |
+
Laws XML for the Act (C-54.011) does NOT include the Schedule.
|
| 17 |
+
|
| 18 |
+
Chunking is one chunk per 4-digit HEADING (98.01, 98.02, ...) plus one chunk
|
| 19 |
+
per chapter for its Notes and Subheading Notes -- a heading is the natural
|
| 20 |
+
unit of legal classification (the eight- and ten-digit items below it are the
|
| 21 |
+
same rule with finer rate granularity).
|
| 22 |
+
|
| 23 |
+
py -m canlex.tariff_schedule [--allow-shrink]
|
| 24 |
+
"""
|
| 25 |
+
import re
|
| 26 |
+
import sys
|
| 27 |
+
from collections import defaultdict
|
| 28 |
+
|
| 29 |
+
from bs4 import BeautifulSoup
|
| 30 |
+
|
| 31 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 32 |
+
stored_chunks, write_corpus)
|
| 33 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 34 |
+
|
| 35 |
+
RAW = RAW_DIR / "tariff_schedule"
|
| 36 |
+
OUT = PROCESSED_DIR / "tariff_schedule.json"
|
| 37 |
+
|
| 38 |
+
# The 2026 edition is the current Customs Tariff at the time of writing. To
|
| 39 |
+
# refresh: bump the year in the URL and `edition` once CBSA publishes the next.
|
| 40 |
+
SOURCES = {
|
| 41 |
+
"ch98": {
|
| 42 |
+
"code": "ch98",
|
| 43 |
+
"chapter": "98",
|
| 44 |
+
"title": "Special classification provisions — non-commercial",
|
| 45 |
+
"url": ("https://www.cbsa-asfc.gc.ca/trade-commerce/tariff-tarif/"
|
| 46 |
+
"2026/html/00/ch98-eng.html"),
|
| 47 |
+
"edition": "2026",
|
| 48 |
+
},
|
| 49 |
+
"ch99": {
|
| 50 |
+
"code": "ch99",
|
| 51 |
+
"chapter": "99",
|
| 52 |
+
"title": "Special classification provisions — commercial",
|
| 53 |
+
"url": ("https://www.cbsa-asfc.gc.ca/trade-commerce/tariff-tarif/"
|
| 54 |
+
"2026/html/00/ch99-eng.html"),
|
| 55 |
+
"edition": "2026",
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _fetch(url, dest):
|
| 61 |
+
return fetch_cached(url, dest)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _heading_of(item):
|
| 65 |
+
"""Map a tariff-item code to its 4-digit heading. The schedule uses three
|
| 66 |
+
levels:
|
| 67 |
+
- '98.01' (4 digits) -- heading
|
| 68 |
+
- '9801.10' (6 digits) -- subheading
|
| 69 |
+
- '9801.10.10' (8 digits) -- tariff item
|
| 70 |
+
All three roll up to heading '98.01'."""
|
| 71 |
+
digits = re.sub(r"\D", "", item)
|
| 72 |
+
if len(digits) < 4:
|
| 73 |
+
return None
|
| 74 |
+
return f"{digits[:2]}.{digits[2:4]}"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _chapter_part(chapter):
|
| 78 |
+
"""The `part` every chunk of a chapter carries -- and, because nothing else
|
| 79 |
+
on a chunk names its chapter, the per-chapter identity preserve_failed
|
| 80 |
+
matches on. Derived in one place so the two stay in step."""
|
| 81 |
+
return f"Schedule, Chapter {chapter}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _collect_notes(main):
|
| 85 |
+
"""Return the chapter's Notes + Subheading Notes as a single text block."""
|
| 86 |
+
out = []
|
| 87 |
+
for label in ("Notes", "Subheading Notes"):
|
| 88 |
+
h2 = main.find(
|
| 89 |
+
"h2",
|
| 90 |
+
string=lambda s, lbl=label: s and lbl in s and (
|
| 91 |
+
lbl != "Notes" or "Subheading" not in s))
|
| 92 |
+
if not h2:
|
| 93 |
+
continue
|
| 94 |
+
parts = []
|
| 95 |
+
sib = h2.find_next_sibling()
|
| 96 |
+
while sib and sib.name != "h2":
|
| 97 |
+
t = _norm(sib.get_text(" ", strip=True))
|
| 98 |
+
if t:
|
| 99 |
+
parts.append(t)
|
| 100 |
+
sib = sib.find_next_sibling()
|
| 101 |
+
if parts:
|
| 102 |
+
out.append(f"{label}:\n" + "\n".join(parts))
|
| 103 |
+
return "\n\n".join(out)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def parse_chapter(html, src):
|
| 107 |
+
"""Parse one Customs Tariff Schedule chapter into chunks."""
|
| 108 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 109 |
+
main = soup.find("main")
|
| 110 |
+
if main is None:
|
| 111 |
+
return []
|
| 112 |
+
for sup in main.find_all("sup"):
|
| 113 |
+
sup.decompose()
|
| 114 |
+
|
| 115 |
+
chunks = []
|
| 116 |
+
chapter = src["chapter"]
|
| 117 |
+
citation_root = f"Customs Tariff, Sched., Ch. {chapter}"
|
| 118 |
+
|
| 119 |
+
# Chapter Notes + Subheading Notes -- one chunk per chapter.
|
| 120 |
+
notes_body = _collect_notes(main)
|
| 121 |
+
if notes_body:
|
| 122 |
+
chunks.append({
|
| 123 |
+
"id": f"tariff-sched-ch{chapter}-notes",
|
| 124 |
+
"doc_type": "legislation",
|
| 125 |
+
"act_code": "C-54.011",
|
| 126 |
+
"act_short": "Customs Tariff",
|
| 127 |
+
"act_name": "Customs Tariff",
|
| 128 |
+
"section": f"Sch-Ch{chapter}-Notes",
|
| 129 |
+
"marginal_note": (f"Chapter {chapter} Notes — "
|
| 130 |
+
f"{src['title']}"),
|
| 131 |
+
"part": _chapter_part(chapter),
|
| 132 |
+
"division": "",
|
| 133 |
+
"heading": src["title"],
|
| 134 |
+
"text": notes_body,
|
| 135 |
+
"history": "",
|
| 136 |
+
"last_amended": "",
|
| 137 |
+
"current_to": src["edition"],
|
| 138 |
+
"citation": f"{citation_root}, Notes",
|
| 139 |
+
"source_url": src["url"],
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
# Walk every row in the schedule table, grouping by 4-digit heading.
|
| 143 |
+
table = main.find("table")
|
| 144 |
+
if table is None:
|
| 145 |
+
return chunks
|
| 146 |
+
|
| 147 |
+
rows_by_heading = defaultdict(list) # heading -> list of (item, ss, desc, unit, mfn, pref)
|
| 148 |
+
heading_desc = {} # heading -> the 4-digit row's description
|
| 149 |
+
|
| 150 |
+
for tr in table.find_all("tr"):
|
| 151 |
+
cells = tr.find_all(["td", "th"], recursive=False)
|
| 152 |
+
if not cells:
|
| 153 |
+
continue
|
| 154 |
+
first = _norm(cells[0].get_text(" ", strip=True))
|
| 155 |
+
if not first or first == "Tariff Item":
|
| 156 |
+
continue # header row or blank
|
| 157 |
+
heading = _heading_of(first)
|
| 158 |
+
if heading is None:
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
def col(i):
|
| 162 |
+
return _norm(cells[i].get_text(" ", strip=True)) if i < len(cells) else ""
|
| 163 |
+
|
| 164 |
+
ss, desc, unit, mfn, pref = col(1), col(2), col(3), col(4), col(5)
|
| 165 |
+
|
| 166 |
+
# A 4-digit row carries only the heading number and description -- store
|
| 167 |
+
# it once. Otherwise it's a subheading/item row; remember its rate cells.
|
| 168 |
+
digits = re.sub(r"\D", "", first)
|
| 169 |
+
if len(digits) == 4 and desc and heading not in heading_desc:
|
| 170 |
+
heading_desc[heading] = desc
|
| 171 |
+
rows_by_heading[heading].append((first, ss, desc, unit, mfn, pref))
|
| 172 |
+
|
| 173 |
+
for heading in sorted(rows_by_heading):
|
| 174 |
+
desc = heading_desc.get(heading, "")
|
| 175 |
+
rows = rows_by_heading[heading]
|
| 176 |
+
if not desc:
|
| 177 |
+
# No bare 4-digit row -- fall back to the first row's description.
|
| 178 |
+
desc = next((r[2] for r in rows if r[2]), "")
|
| 179 |
+
|
| 180 |
+
# Format the chunk: heading description first, then the items as a
|
| 181 |
+
# readable list. Suppress description on item/subheading rows that just
|
| 182 |
+
# repeat the heading desc verbatim (very common in this Schedule).
|
| 183 |
+
lines = []
|
| 184 |
+
for item, ss, item_desc, unit, mfn, pref in rows:
|
| 185 |
+
d = re.sub(r"\D", "", item)
|
| 186 |
+
if len(d) == 4:
|
| 187 |
+
continue # the heading row, already in desc
|
| 188 |
+
label = f" {item}"
|
| 189 |
+
if ss:
|
| 190 |
+
label += f" (SS {ss})"
|
| 191 |
+
extras = []
|
| 192 |
+
if item_desc and item_desc != desc:
|
| 193 |
+
extras.append(item_desc)
|
| 194 |
+
rate = []
|
| 195 |
+
if mfn:
|
| 196 |
+
rate.append(f"MFN {mfn}")
|
| 197 |
+
if pref:
|
| 198 |
+
rate.append(f"Pref: {pref}")
|
| 199 |
+
if unit and unit != "-":
|
| 200 |
+
rate.append(f"Unit {unit}")
|
| 201 |
+
if rate:
|
| 202 |
+
extras.append(" | ".join(rate))
|
| 203 |
+
tail = " — " + "; ".join(extras) if extras else ""
|
| 204 |
+
lines.append(label + tail)
|
| 205 |
+
|
| 206 |
+
body = f"Heading {heading} — {desc}"
|
| 207 |
+
if lines:
|
| 208 |
+
body += "\n\nTariff items:\n" + "\n".join(lines)
|
| 209 |
+
|
| 210 |
+
chunks.append({
|
| 211 |
+
"id": f"tariff-sched-{heading.replace('.', '-')}",
|
| 212 |
+
"doc_type": "legislation",
|
| 213 |
+
"act_code": "C-54.011",
|
| 214 |
+
"act_short": "Customs Tariff",
|
| 215 |
+
"act_name": "Customs Tariff",
|
| 216 |
+
"section": f"Sch-{heading}",
|
| 217 |
+
"marginal_note": desc[:200],
|
| 218 |
+
"part": _chapter_part(chapter),
|
| 219 |
+
"division": "",
|
| 220 |
+
"heading": src["title"],
|
| 221 |
+
"text": body,
|
| 222 |
+
"history": "",
|
| 223 |
+
"last_amended": "",
|
| 224 |
+
"current_to": src["edition"],
|
| 225 |
+
"citation": f"Customs Tariff, Sched., heading {heading}",
|
| 226 |
+
"source_url": src["url"],
|
| 227 |
+
})
|
| 228 |
+
|
| 229 |
+
return chunks
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def preserve_failed(chunks, failures, stored):
|
| 233 |
+
"""Re-attach the last-good chunks of chapters this run could not rebuild.
|
| 234 |
+
|
| 235 |
+
Only two chapters make up this corpus, so losing one is losing a third to
|
| 236 |
+
two thirds of it -- and a CBSA page that has been reshaped (or a fetch that
|
| 237 |
+
died mid-run) is indistinguishable from a chapter that legitimately
|
| 238 |
+
vanished until a human looks. Serving last week's copy of ch. 98 beats
|
| 239 |
+
serving no traveller exemptions at all. Restricted to the chapters that
|
| 240 |
+
actually failed. Returns (chunks, preserved chapter identities).
|
| 241 |
+
"""
|
| 242 |
+
failed = {_chapter_part(chapter) for chapter, _why in failures}
|
| 243 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["part"],
|
| 244 |
+
only=failed)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def build(allow_shrink=False):
|
| 248 |
+
all_chunks, failures = [], []
|
| 249 |
+
for src in SOURCES.values():
|
| 250 |
+
print(f"Ingesting Customs Tariff Schedule {src['code']} ...")
|
| 251 |
+
try:
|
| 252 |
+
html = _fetch(src["url"], RAW / f"{src['code']}.html")
|
| 253 |
+
chunks = parse_chapter(html, src)
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 256 |
+
failures.append((src["chapter"], f"{type(exc).__name__}: {exc}"))
|
| 257 |
+
continue
|
| 258 |
+
if not chunks:
|
| 259 |
+
# A chapter never parses to nothing legitimately -- an empty result
|
| 260 |
+
# means the page moved or changed shape, so treat it as a failure
|
| 261 |
+
# rather than letting it silently shrink the corpus.
|
| 262 |
+
print(f" !! {src['code']}: no chunks parsed")
|
| 263 |
+
failures.append((src["chapter"], "no chunks parsed"))
|
| 264 |
+
continue
|
| 265 |
+
all_chunks.extend(chunks)
|
| 266 |
+
print(f" {len(chunks)} chunks")
|
| 267 |
+
|
| 268 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 269 |
+
stored_chunks(OUT))
|
| 270 |
+
if preserved:
|
| 271 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s): "
|
| 272 |
+
f"{', '.join(preserved)}")
|
| 273 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 274 |
+
return False
|
| 275 |
+
print(f"\n{len(all_chunks)} tariff-schedule chunks from "
|
| 276 |
+
f"{len(SOURCES) - len(failures)} chapter(s) -> {OUT}")
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def main():
|
| 281 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
if __name__ == "__main__":
|
| 285 |
+
main()
|
data/curated/drafts/equivalency-pairings-draft.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_draft": "DRAFT FOR MAINTAINER REVIEW 2026-07-23 — three new pairings for data/curated/us_equivalency.json. Every Canadian maximum verified against the corpus text of the current C-46/IRPA consolidations. NOT deployed: append to the 'pairings' array, bump 'reviewed', then py -m canlex.commentary && py -m canlex.embed && eval && push.",
|
| 3 |
+
"pairings": [
|
| 4 |
+
{
|
| 5 |
+
"id": "weapons-carry",
|
| 6 |
+
"us_terms": ["unlawful carrying of a weapon", "CCW violation", "carrying a concealed weapon", "unlawful possession of a firearm", "felon in possession", "UCW"],
|
| 7 |
+
"canadian_offence": "Criminal Code s. 90 (carrying a concealed weapon); s. 91 (unauthorized possession of a firearm); s. 92 (possession knowing possession is unauthorized); s. 88 (possession of a weapon for a purpose dangerous to the public peace)",
|
| 8 |
+
"penalty": "Hybrid throughout; indictable maximums verified: s. 90 five years; s. 91 five years; s. 92 ten years; s. 88 ten years",
|
| 9 |
+
"branch": "SPLITS ON THE EQUIVALENT: ss. 90/91 (5 years) -> CRIMINALITY, IRPA s. 36(2)(b); ss. 92/88 (10 years) -> SERIOUS CRIMINALITY, s. 36(1)(b)",
|
| 10 |
+
"status": "guidance-only",
|
| 11 |
+
"analysis": "The most common US weapons conviction — carrying without a permit — usually equates to s. 90 or s. 91 (five-year maximums): criminality, deemed rehabilitation available after 10 years. The branch escalates to serious criminality where the record shows KNOWLEDGE the possession was unauthorized in a s. 92 sense, or a dangerous purpose (s. 88). 'Felon in possession' convictions typically map to s. 91/92 — the s. 92 knowledge element is often satisfied on those facts. The Hill evidence branch matters more here than for most pairings: US statutes bundle conduct Canada splits across four provisions.",
|
| 12 |
+
"caveats": [
|
| 13 |
+
"Constitutional-carry states: lawful-there conduct can still equate to a Canadian offence, but with no US conviction only the act branch (s. 36(2)(c)) is in play.",
|
| 14 |
+
"A weapon lawful in the US but prohibited in Canada (e.g. brass knuckles) engages the classification regulations (SOR/98-462) — check the device, not just the conduct.",
|
| 15 |
+
"Using a firearm in the commission of an offence (s. 85) carries consecutive mandatory minimums — an aggravated US charge deserves its own equivalency analysis."
|
| 16 |
+
],
|
| 17 |
+
"authorities": []
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "sexual-assault",
|
| 21 |
+
"us_terms": ["sexual assault", "criminal sexual conduct", "sexual battery", "rape", "statutory rape", "sexual abuse"],
|
| 22 |
+
"canadian_offence": "Criminal Code s. 271 (sexual assault); s. 151 (sexual interference, complainant under 16); ss. 272/273 (weapon/bodily harm/aggravated)",
|
| 23 |
+
"penalty": "s. 271 hybrid, indictable maximum 10 years (14 where the complainant is under 16); s. 151 hybrid, 14 years with a one-year mandatory minimum; verified from the corpus text",
|
| 24 |
+
"branch": "SERIOUS CRIMINALITY — IRPA s. 36(1)(b) in effectively every configuration (every plausible equivalent carries a 10-year-or-greater maximum)",
|
| 25 |
+
"status": "guidance-only",
|
| 26 |
+
"analysis": "Any US sexual-offence conviction equates to a 10-years-or-more Canadian offence: serious criminality, no deemed rehabilitation ever, individual rehabilitation application (5 years post-sentence) or TRP the only routes. 'Statutory rape' variants map to s. 151 (14-year maximum) — age thresholds differ by state, so verify the victim's age against Canada's under-16 line (and the close-in-age exceptions in s. 150.1) via the Hill evidence branch.",
|
| 27 |
+
"caveats": [
|
| 28 |
+
"US registration-only dispositions (e.g. a deferred adjudication that still requires registration) remain step-1 questions — registration does not itself make a disposition a conviction.",
|
| 29 |
+
"Canada's s. 150.1 close-in-age defences can defeat equivalency for peer-age statutory-rape convictions — this is the classic Li point that available Canadian defences form part of the comparison.",
|
| 30 |
+
"Misdemeanor 'sexual battery' in some states covers non-consensual touching that still equates to s. 271 — the 10-year branch does not depend on the US felony label."
|
| 31 |
+
],
|
| 32 |
+
"authorities": [
|
| 33 |
+
{"cite": "Li v Canada (MCI), [1997] 1 FC 235 (CA)", "court": "Federal Court of Appeal", "holding": "Equivalency compares essential elements including available defences; no retrial of the foreign case."}
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"id": "immigration-fraud",
|
| 38 |
+
"us_terms": ["visa fraud", "immigration fraud", "false statement on immigration application", "alien smuggling", "harboring an alien", "marriage fraud"],
|
| 39 |
+
"canadian_offence": "IRPA s. 127 (misrepresentation offence; penalty s. 128); IRPA ss. 122-123 (document offences); IRPA s. 117 (organizing entry — smuggling); Criminal Code s. 368 (use of forged document)",
|
| 40 |
+
"penalty": "s. 128: hybrid, indictable maximum 5 years; s. 123: up to 14 years for document use/trafficking; s. 117: up to life (with mandatory minimums where aggravated); verified from the corpus text",
|
| 41 |
+
"branch": "SPLITS: false statements/marriage fraud -> s. 127/128 (5 years) = CRIMINALITY, s. 36(2)(b); document fraud with use/trafficking -> s. 123 (14 years) = SERIOUS CRIMINALITY; alien smuggling -> s. 117 = SERIOUS CRIMINALITY",
|
| 42 |
+
"status": "guidance-only",
|
| 43 |
+
"analysis": "A US conviction for lying on an immigration form (18 USC 1546 false-statement branch) generally equates to IRPA s. 127: five-year maximum, criminality only — deemed rehabilitation available. The same US statute's document-use branch reaches s. 123's 14-year maximum: serious criminality. Alien smuggling (8 USC 1324) equates to IRPA s. 117 — serious criminality, and s. 37 organized-criminality may also be in play. NOTE: a misrepresentation to CANADIAN authorities separately grounds s. 40 inadmissibility without any conviction; this pairing addresses foreign convictions only.",
|
| 44 |
+
"caveats": [
|
| 45 |
+
"18 USC 1546 is a classic Hill problem: one US provision straddles two Canadian branches — the record of what was actually done decides between s. 127 (5 years) and s. 123 (14 years).",
|
| 46 |
+
"'Harboring' convictions vary widely; minor-assistance facts may equate to s. 117's lower tiers or fail equivalency entirely — evidence branch required.",
|
| 47 |
+
"Marriage fraud: the US conviction equates to s. 127 (criminality), but the underlying facts may independently engage s. 40 misrepresentation if made to IRCC/CBSA."
|
| 48 |
+
],
|
| 49 |
+
"authorities": []
|
| 50 |
+
}
|
| 51 |
+
]
|
| 52 |
+
}
|
data/curated/us_dispositions.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/curated/us_equivalency.json
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"reviewed": "2026-07-15",
|
| 3 |
+
"_provenance": "Authored 2026-07-12. Every Canadian maximum penalty verified against the corpus text of the Criminal Code (C-46) and CDSA (C-38.8), current consolidations; branch classifications apply IRPA s. 36(3)(a) (hybrid offences deemed indictable). Tran v Canada, 2017 SCC 50 (in-corpus) supplies the commission-date rule for assessing maximum penalties. Equivalency methodology per Hill v Canada (MEI) (1987), 73 NR 315 (FCA), as set out in IRB Legal Services, Sponsorship Appeals Ch. 2 (Criminal Refusals). These are the COMMON pairings only -- an unusual offence needs the full Hill analysis against the actual foreign statute.",
|
| 4 |
+
"methodology": [
|
| 5 |
+
{
|
| 6 |
+
"id": "equivalency-framework",
|
| 7 |
+
"title": "How offence equivalency works (step 2 of the s. 36 analysis)",
|
| 8 |
+
"text": "Equivalency asks whether the foreign offence, if committed in Canada, would constitute a federal offence of the required seriousness. The governing test is Hill v Canada (MEI) (1987), 73 NR 315 (FCA): equivalency can be established (1) by comparing the precise wording and essential elements of the two provisions; (2) by examining the evidence of what the person actually did, to see whether it would satisfy the essential elements of the Canadian offence; or (3) by a combination of both. Key rules: (a) HYBRID DEEMING -- IRPA s. 36(3)(a): an offence prosecutable either way in Canada is deemed indictable, at its indictable maximum, even if it would likely proceed summarily; (b) TIMING -- Tran v Canada, 2017 SCC 50: the maximum penalty is assessed as of the DATE OF COMMISSION, so a later increase in the Canadian maximum does not retroactively convert criminality into serious criminality; (c) THRESHOLDS -- Canadian property offences split at $5,000 (theft, fraud, mischief), so the value in the foreign record often decides the branch; (d) the comparison is with the CANADIAN offence's elements -- a foreign offence broader than its Canadian counterpart requires the evidence branch of Hill to see what was actually done; (e) purely SUMMARY Canadian equivalents (e.g. causing a disturbance, s. 175) cannot ground s. 36(2)(b) inadmissibility on a single conviction -- only the two-or-more-convictions branch reaches them."
|
| 9 |
+
}
|
| 10 |
+
],
|
| 11 |
+
"pairings": [
|
| 12 |
+
{
|
| 13 |
+
"id": "dui",
|
| 14 |
+
"us_terms": [
|
| 15 |
+
"DUI",
|
| 16 |
+
"DWI",
|
| 17 |
+
"OWI",
|
| 18 |
+
"OUI",
|
| 19 |
+
"driving under the influence",
|
| 20 |
+
"driving while intoxicated",
|
| 21 |
+
"operating under the influence",
|
| 22 |
+
"drunk driving",
|
| 23 |
+
"impaired driving"
|
| 24 |
+
],
|
| 25 |
+
"canadian_offence": "Criminal Code s. 320.14 (operation while impaired / over the legal limit); punishment s. 320.19",
|
| 26 |
+
"penalty": "Hybrid; indictable maximum 10 years (s. 320.19(1)(a): 'liable to imprisonment for a term of not more than 10 years')",
|
| 27 |
+
"branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b): via s. 36(3)(a) hybrid deeming, a single ordinary US DUI conviction equates to an offence punishable by 10 years",
|
| 28 |
+
"status": "judicially-considered",
|
| 29 |
+
"analysis": "The single most consequential everyday pairing: one US misdemeanor DUI grounds serious criminality, because Canada's impaired-driving offence is hybrid with a 10-year indictable maximum since December 18, 2018. Deemed rehabilitation is therefore unavailable (10-year-or-more maximum); only individual rehabilitation or a TRP cures it.",
|
| 30 |
+
"caveats": [
|
| 31 |
+
"TIMING (Tran, 2017 SCC 50): for offences COMMITTED before December 18, 2018, the equivalent was the former s. 253 (maximum 5 years) -- criminality under s. 36(2)(b) only, not serious criminality, and deemed rehabilitation remains available.",
|
| 32 |
+
"Zero-tolerance administrative per-se findings are not convictions (see the DUI entry in the dispositions helper); this pairing addresses criminal DUI convictions.",
|
| 33 |
+
"A US 'wet reckless' plea-down maps to careless/dangerous driving analysis, not s. 320.14 -- check what was actually convicted."
|
| 34 |
+
],
|
| 35 |
+
"authorities": [
|
| 36 |
+
{
|
| 37 |
+
"cite": "Tran v Canada (Public Safety), 2017 SCC 50",
|
| 38 |
+
"court": "Supreme Court of Canada",
|
| 39 |
+
"holding": "The maximum term for equivalency/seriousness purposes is assessed as of the date of commission; subsequent increases do not apply retrospectively."
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"id": "theft-under",
|
| 45 |
+
"us_terms": [
|
| 46 |
+
"petty theft",
|
| 47 |
+
"petit larceny",
|
| 48 |
+
"shoplifting",
|
| 49 |
+
"misdemeanor theft",
|
| 50 |
+
"theft under"
|
| 51 |
+
],
|
| 52 |
+
"canadian_offence": "Criminal Code s. 334(b) (theft not exceeding $5,000)",
|
| 53 |
+
"penalty": "Hybrid; indictable maximum 2 years (s. 334(b)(i))",
|
| 54 |
+
"branch": "CRIMINALITY -- IRPA s. 36(2)(b) (foreign national); a permanent resident is NOT inadmissible on this alone",
|
| 55 |
+
"status": "guidance-only",
|
| 56 |
+
"analysis": "Ordinary shoplifting equates to theft under $5,000: hybrid, deemed indictable, 2-year maximum. One conviction makes a foreign national inadmissible for criminality; two convictions not arising from a single occurrence also engage s. 36(2)(b). Deemed rehabilitation after 10 years (single) per IRPR s. 18(2)(a).",
|
| 57 |
+
"caveats": [
|
| 58 |
+
"Convert the stolen value at the offence date: a US 'felony grand theft' at a low state threshold (some states: $500+) may still be theft UNDER $5,000 in Canadian terms -- the CANADIAN threshold controls the branch.",
|
| 59 |
+
"Value over $5,000 -> s. 334(a), 10-year maximum -> serious criminality (see theft-over)."
|
| 60 |
+
],
|
| 61 |
+
"authorities": []
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "theft-over",
|
| 65 |
+
"us_terms": [
|
| 66 |
+
"grand theft",
|
| 67 |
+
"grand larceny",
|
| 68 |
+
"felony theft",
|
| 69 |
+
"auto theft",
|
| 70 |
+
"grand theft auto"
|
| 71 |
+
],
|
| 72 |
+
"canadian_offence": "Criminal Code s. 334(a) (theft exceeding $5,000); motor vehicle theft s. 333.1",
|
| 73 |
+
"penalty": "Indictable maximum 10 years (s. 334(a): 'not more than 10 years'); motor vehicle theft (s. 333.1) hybrid, 10 years",
|
| 74 |
+
"branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b), where the value genuinely exceeds $5,000 (or the subject is a motor vehicle under s. 333.1)",
|
| 75 |
+
"status": "guidance-only",
|
| 76 |
+
"analysis": "Theft of property over $5,000 (Canadian dollars, at the offence date) is a 10-year offence: serious criminality; no deemed rehabilitation.",
|
| 77 |
+
"caveats": [
|
| 78 |
+
"The US felony label does not control -- verify the actual value against the Canadian $5,000 line; borderline values with exchange-rate movement deserve the evidence branch of Hill."
|
| 79 |
+
],
|
| 80 |
+
"authorities": []
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "assault-simple",
|
| 84 |
+
"us_terms": [
|
| 85 |
+
"simple assault",
|
| 86 |
+
"misdemeanor assault",
|
| 87 |
+
"battery",
|
| 88 |
+
"simple battery",
|
| 89 |
+
"assault in the fourth degree"
|
| 90 |
+
],
|
| 91 |
+
"canadian_offence": "Criminal Code s. 266 (assault)",
|
| 92 |
+
"penalty": "Hybrid; indictable maximum 5 years (s. 266(a): 'not exceeding five years')",
|
| 93 |
+
"branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
|
| 94 |
+
"status": "guidance-only",
|
| 95 |
+
"analysis": "US simple assault/battery generally equates to s. 266: hybrid, 5-year maximum -- criminality, not serious criminality. Deemed rehabilitation available after 10 years (single conviction).",
|
| 96 |
+
"caveats": [
|
| 97 |
+
"Bodily injury, a weapon, or choking moves the equivalent to s. 267 (10 years) -> serious criminality (see assault-aggravated).",
|
| 98 |
+
"Domestic-violence variants equate to the same assault provisions; the DV character matters to sentencing and to some US dispositions, not to the Canadian equivalency.",
|
| 99 |
+
"Some US assault statutes cover mere threats -- threat-only conduct maps to s. 264.1 (uttering threats, 5 years), same branch."
|
| 100 |
+
],
|
| 101 |
+
"authorities": []
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"id": "assault-aggravated",
|
| 105 |
+
"us_terms": [
|
| 106 |
+
"aggravated assault",
|
| 107 |
+
"assault with a deadly weapon",
|
| 108 |
+
"ADW",
|
| 109 |
+
"felony assault",
|
| 110 |
+
"assault causing injury",
|
| 111 |
+
"domestic battery with injury"
|
| 112 |
+
],
|
| 113 |
+
"canadian_offence": "Criminal Code s. 267 (assault with a weapon / causing bodily harm / choking); s. 268 (aggravated assault)",
|
| 114 |
+
"penalty": "s. 267: hybrid, indictable maximum 10 years; s. 268: indictable, maximum 14 years",
|
| 115 |
+
"branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b)",
|
| 116 |
+
"status": "guidance-only",
|
| 117 |
+
"analysis": "Weapon involvement or bodily harm lifts the Canadian equivalent to s. 267 (10 years) or s. 268 (wounding/maiming, 14 years): serious criminality either way; no deemed rehabilitation.",
|
| 118 |
+
"caveats": [
|
| 119 |
+
"US 'aggravated' labels sometimes rest only on the victim's status (e.g. assault on an officer) -- if no weapon/injury, the closer equivalent may be s. 270 (assaulting an officer, hybrid 5 years) -> criminality only. Use the evidence branch."
|
| 120 |
+
],
|
| 121 |
+
"authorities": []
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"id": "fraud",
|
| 125 |
+
"us_terms": [
|
| 126 |
+
"fraud",
|
| 127 |
+
"bad checks",
|
| 128 |
+
"check fraud",
|
| 129 |
+
"insufficient funds",
|
| 130 |
+
"credit card fraud",
|
| 131 |
+
"identity theft",
|
| 132 |
+
"wire fraud"
|
| 133 |
+
],
|
| 134 |
+
"canadian_offence": "Criminal Code s. 380 (fraud); s. 342 (credit card offences); s. 402.2 (identity theft)",
|
| 135 |
+
"penalty": "s. 380(1)(a): indictable maximum 14 years where value exceeds $5,000; s. 380(1)(b): hybrid, 2 years where value $5,000 or less",
|
| 136 |
+
"branch": "SERIOUS CRIMINALITY over $5,000 (36(1)(b)); CRIMINALITY at or under $5,000 (36(2)(b))",
|
| 137 |
+
"status": "guidance-only",
|
| 138 |
+
"analysis": "Fraud splits on the $5,000 line exactly like theft. Bad-check convictions are usually small-value: hybrid 2-year equivalents -> criminality. Larger frauds (14 years) are serious criminality.",
|
| 139 |
+
"caveats": [
|
| 140 |
+
"Many US bad-check statutes criminalize mere non-payment without dishonesty elements -- where intent to defraud is not an element, the Canadian equivalent may fail on essential elements; use the Hill evidence branch."
|
| 141 |
+
],
|
| 142 |
+
"authorities": []
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"id": "drug-possession",
|
| 146 |
+
"us_terms": [
|
| 147 |
+
"drug possession",
|
| 148 |
+
"possession of a controlled substance",
|
| 149 |
+
"simple possession",
|
| 150 |
+
"cocaine possession",
|
| 151 |
+
"meth possession",
|
| 152 |
+
"narcotics possession"
|
| 153 |
+
],
|
| 154 |
+
"canadian_offence": "CDSA s. 4(1) (possession); punishment s. 4(3)-(6) by schedule",
|
| 155 |
+
"penalty": "Schedule I (cocaine, heroin, meth, fentanyl): hybrid, indictable maximum 7 years (s. 4(3)(a): 'not exceeding seven years'); Schedule III: hybrid, 3 years",
|
| 156 |
+
"branch": "CRIMINALITY -- IRPA s. 36(2)(b) (7-year maximum is below the 10-year serious-criminality line)",
|
| 157 |
+
"status": "guidance-only",
|
| 158 |
+
"analysis": "Simple possession of hard drugs equates to CDSA s. 4(1): hybrid, 7-year maximum for Schedule I -- criminality, NOT serious criminality. Deemed rehabilitation available after 10 clean years for a single conviction.",
|
| 159 |
+
"caveats": [
|
| 160 |
+
"CANNABIS IS DIFFERENT: see the cannabis entry -- simple possession within legal limits is no longer an offence in Canada at all.",
|
| 161 |
+
"Possession WITH INTENT to distribute equates to trafficking (see drug-trafficking): a completely different branch."
|
| 162 |
+
],
|
| 163 |
+
"authorities": []
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"id": "cannabis-possession",
|
| 167 |
+
"us_terms": [
|
| 168 |
+
"marijuana possession",
|
| 169 |
+
"cannabis possession",
|
| 170 |
+
"possession of marijuana",
|
| 171 |
+
"weed possession"
|
| 172 |
+
],
|
| 173 |
+
"canadian_offence": "None for simple possession within legal limits -- the Cannabis Act legalized adult possession of up to 30 g of dried cannabis (public)",
|
| 174 |
+
"penalty": "Not an offence in Canada (within limits); over-limit possession: Cannabis Act s. 8, hybrid, maximum 5 years less a day",
|
| 175 |
+
"branch": "NO INADMISSIBILITY for simple possession equivalents: with no equivalent Canadian offence, neither s. 36(1)(b) nor s. 36(2)(b) can be made out",
|
| 176 |
+
"status": "guidance-only",
|
| 177 |
+
"analysis": "A US conviction for simple marijuana possession (an amount lawful in Canada) has no Canadian equivalent offence since October 17, 2018 -- there is nothing to equate, so the conviction cannot ground criminal inadmissibility. This is the clearest 'NO' in the pairing table.",
|
| 178 |
+
"caveats": [
|
| 179 |
+
"Quantities beyond Canadian legal limits, distribution, sale, cultivation beyond limits, and import/export DO have Cannabis Act equivalents (ss. 8-14; import/export max 14 years -> serious criminality).",
|
| 180 |
+
"The equivalency is assessed against Canadian law at the ADMISSIBILITY DECISION date for the would-it-be-an-offence question -- post-legalization assessments of old possession convictions find no equivalent offence.",
|
| 181 |
+
"US felony marijuana TRAFFICKING remains serious criminality (Cannabis Act s. 9/10 or CDSA analysis)."
|
| 182 |
+
],
|
| 183 |
+
"authorities": []
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"id": "drug-trafficking",
|
| 187 |
+
"us_terms": [
|
| 188 |
+
"drug trafficking",
|
| 189 |
+
"possession with intent to distribute",
|
| 190 |
+
"PWID",
|
| 191 |
+
"drug distribution",
|
| 192 |
+
"sale of a controlled substance",
|
| 193 |
+
"drug dealing"
|
| 194 |
+
],
|
| 195 |
+
"canadian_offence": "CDSA s. 5 (trafficking / possession for the purpose)",
|
| 196 |
+
"penalty": "Schedule I or II: indictable, maximum LIFE (s. 5(3)(a): 'liable to imprisonment for life'); Schedule III/V: hybrid, 10 years",
|
| 197 |
+
"branch": "SERIOUS CRIMINALITY -- IRPA s. 36(1)(b) for any schedule (all branches reach at least 10 years)",
|
| 198 |
+
"status": "guidance-only",
|
| 199 |
+
"analysis": "Any trafficking or possession-with-intent conviction equates to CDSA s. 5: serious criminality at every schedule level. No deemed rehabilitation; individual rehabilitation after 5 years or TRP only.",
|
| 200 |
+
"caveats": [
|
| 201 |
+
"Also engages potential organized-criminality analysis (s. 37) where a criminal organization is involved."
|
| 202 |
+
],
|
| 203 |
+
"authorities": []
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "disorderly-conduct",
|
| 207 |
+
"us_terms": [
|
| 208 |
+
"disorderly conduct",
|
| 209 |
+
"disturbing the peace",
|
| 210 |
+
"public disturbance",
|
| 211 |
+
"breach of the peace",
|
| 212 |
+
"public intoxication"
|
| 213 |
+
],
|
| 214 |
+
"canadian_offence": "Criminal Code s. 175 (causing a disturbance)",
|
| 215 |
+
"penalty": "SUMMARY ONLY -- s. 175 is a pure summary conviction offence",
|
| 216 |
+
"branch": "A SINGLE conviction grounds NO inadmissibility: s. 36(2)(b) requires an indictable-equivalent, and s. 175 is not one. TWO OR MORE convictions (not from a single occurrence) engage s. 36(2)(b)'s second branch",
|
| 217 |
+
"status": "guidance-only",
|
| 218 |
+
"analysis": "The most common harmless record: one US disorderly conduct/disturbing-the-peace conviction, equated to s. 175 (summary only), makes NOBODY inadmissible -- not even a foreign national. The two-or-more branch changes that, and IRPR s. 18(2)(b) then offers deemed rehabilitation five years after the sentences.",
|
| 219 |
+
"caveats": [
|
| 220 |
+
"Check what the conviction actually covers: some US disorderly statutes bundle assaultive or threatening conduct that would equate to hybrid offences -- the evidence branch of Hill controls where the statute is broader than s. 175.",
|
| 221 |
+
"Public intoxication alone typically has no federal Canadian equivalent at all (provincial regulatory matter) -- even cleaner than s. 175."
|
| 222 |
+
],
|
| 223 |
+
"authorities": []
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"id": "resisting-arrest",
|
| 227 |
+
"us_terms": [
|
| 228 |
+
"resisting arrest",
|
| 229 |
+
"obstruction",
|
| 230 |
+
"obstructing an officer",
|
| 231 |
+
"evading arrest",
|
| 232 |
+
"failure to comply with police"
|
| 233 |
+
],
|
| 234 |
+
"canadian_offence": "Criminal Code s. 129 (resisting or obstructing a peace officer)",
|
| 235 |
+
"penalty": "Hybrid; indictable maximum 2 years (s. 129(d): 'not exceeding two years')",
|
| 236 |
+
"branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
|
| 237 |
+
"status": "guidance-only",
|
| 238 |
+
"analysis": "Non-violent resisting/obstruction equates to s. 129: hybrid, 2-year maximum -- criminality only.",
|
| 239 |
+
"caveats": [
|
| 240 |
+
"Vehicle flight equates to s. 320.17 (flight from police: hybrid, 10 years) -> SERIOUS criminality -- distinguish on-foot obstruction from vehicular flight.",
|
| 241 |
+
"Assaulting the officer moves it to s. 270 or s. 267 territory."
|
| 242 |
+
],
|
| 243 |
+
"authorities": []
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"id": "threats",
|
| 247 |
+
"us_terms": [
|
| 248 |
+
"criminal threatening",
|
| 249 |
+
"terroristic threats",
|
| 250 |
+
"menacing",
|
| 251 |
+
"harassment threats",
|
| 252 |
+
"intimidation"
|
| 253 |
+
],
|
| 254 |
+
"canadian_offence": "Criminal Code s. 264.1 (uttering threats)",
|
| 255 |
+
"penalty": "Hybrid; indictable maximum 5 years for threats to cause death or bodily harm (s. 264.1(2)(a))",
|
| 256 |
+
"branch": "CRIMINALITY -- IRPA s. 36(2)(b)",
|
| 257 |
+
"status": "guidance-only",
|
| 258 |
+
"analysis": "Threat offences equate to s. 264.1: hybrid, 5-year maximum -- criminality. Criminal harassment courses of conduct equate to s. 264 (10 years) -> serious criminality instead.",
|
| 259 |
+
"caveats": [
|
| 260 |
+
"'Terroristic threats' is a state-law label with no terrorism content -- do not confuse with IRPA s. 34 security grounds absent actual terrorism facts.",
|
| 261 |
+
"Repeated/stalking conduct: s. 264 criminal harassment (10 years) -> serious criminality."
|
| 262 |
+
],
|
| 263 |
+
"authorities": []
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"id": "mischief-vandalism",
|
| 267 |
+
"us_terms": [
|
| 268 |
+
"vandalism",
|
| 269 |
+
"criminal mischief",
|
| 270 |
+
"malicious mischief",
|
| 271 |
+
"property damage",
|
| 272 |
+
"graffiti"
|
| 273 |
+
],
|
| 274 |
+
"canadian_offence": "Criminal Code s. 430 (mischief)",
|
| 275 |
+
"penalty": "Value over $5,000: hybrid, indictable maximum 10 years (s. 430(3)); $5,000 or under: hybrid, 2 years (s. 430(4))",
|
| 276 |
+
"branch": "CRIMINALITY at or under $5,000 (36(2)(b)); SERIOUS CRIMINALITY over $5,000 (36(1)(b))",
|
| 277 |
+
"status": "guidance-only",
|
| 278 |
+
"analysis": "Vandalism splits on the $5,000 damage value like the other property offences. Ordinary graffiti/vandalism records are 2-year equivalents -> criminality.",
|
| 279 |
+
"caveats": [
|
| 280 |
+
"The damage figure in the US record (restitution ordered is a good proxy) decides the branch -- convert to CAD at the offence date."
|
| 281 |
+
],
|
| 282 |
+
"authorities": []
|
| 283 |
+
}
|
| 284 |
+
]
|
| 285 |
+
}
|
data/eval/questions.json
ADDED
|
@@ -0,0 +1,2042 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"query": "How soon must the Immigration Division review the detention of a foreign national?",
|
| 4 |
+
"answers": [
|
| 5 |
+
[
|
| 6 |
+
"IRPA",
|
| 7 |
+
"57"
|
| 8 |
+
]
|
| 9 |
+
],
|
| 10 |
+
"holdout": true
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"query": "On what security grounds is a foreign national inadmissible to Canada?",
|
| 14 |
+
"answers": [
|
| 15 |
+
[
|
| 16 |
+
"IRPA",
|
| 17 |
+
"34"
|
| 18 |
+
]
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"query": "When is a permanent resident inadmissible for serious criminality?",
|
| 23 |
+
"answers": [
|
| 24 |
+
[
|
| 25 |
+
"IRPA",
|
| 26 |
+
"36"
|
| 27 |
+
]
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"query": "What makes a person inadmissible for organized criminality?",
|
| 32 |
+
"answers": [
|
| 33 |
+
[
|
| 34 |
+
"IRPA",
|
| 35 |
+
"37"
|
| 36 |
+
]
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"query": "Can someone be found inadmissible to Canada for misrepresentation?",
|
| 41 |
+
"answers": [
|
| 42 |
+
[
|
| 43 |
+
"IRPA",
|
| 44 |
+
"40"
|
| 45 |
+
]
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"query": "Is a foreign national inadmissible on health grounds?",
|
| 50 |
+
"answers": [
|
| 51 |
+
[
|
| 52 |
+
"IRPA",
|
| 53 |
+
"38"
|
| 54 |
+
]
|
| 55 |
+
]
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"query": "Inadmissibility for violating human or international rights",
|
| 59 |
+
"answers": [
|
| 60 |
+
[
|
| 61 |
+
"IRPA",
|
| 62 |
+
"35"
|
| 63 |
+
]
|
| 64 |
+
],
|
| 65 |
+
"holdout": true
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"query": "Can a person be inadmissible to Canada for financial reasons?",
|
| 69 |
+
"answers": [
|
| 70 |
+
[
|
| 71 |
+
"IRPA",
|
| 72 |
+
"39"
|
| 73 |
+
]
|
| 74 |
+
]
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"query": "Is someone inadmissible because an accompanying family member is inadmissible?",
|
| 78 |
+
"answers": [
|
| 79 |
+
[
|
| 80 |
+
"IRPA",
|
| 81 |
+
"42"
|
| 82 |
+
]
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"query": "What humanitarian and compassionate relief can the Minister grant a foreign national?",
|
| 87 |
+
"answers": [
|
| 88 |
+
[
|
| 89 |
+
"IRPA",
|
| 90 |
+
"25"
|
| 91 |
+
],
|
| 92 |
+
[
|
| 93 |
+
"IRPA",
|
| 94 |
+
"25.1"
|
| 95 |
+
]
|
| 96 |
+
]
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"query": "When can an officer arrest and detain a foreign national without a warrant?",
|
| 100 |
+
"answers": [
|
| 101 |
+
[
|
| 102 |
+
"IRPA",
|
| 103 |
+
"55"
|
| 104 |
+
]
|
| 105 |
+
]
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"query": "Who prepares a report that a permanent resident is inadmissible?",
|
| 109 |
+
"answers": [
|
| 110 |
+
[
|
| 111 |
+
"IRPA",
|
| 112 |
+
"44"
|
| 113 |
+
]
|
| 114 |
+
]
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"query": "Must a person appear for an examination when seeking to enter Canada?",
|
| 118 |
+
"answers": [
|
| 119 |
+
[
|
| 120 |
+
"IRPA",
|
| 121 |
+
"18"
|
| 122 |
+
]
|
| 123 |
+
],
|
| 124 |
+
"holdout": true
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"query": "What must a person establish to be allowed to enter Canada?",
|
| 128 |
+
"answers": [
|
| 129 |
+
[
|
| 130 |
+
"IRPA",
|
| 131 |
+
"20"
|
| 132 |
+
]
|
| 133 |
+
]
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"query": "What is the definition of a Convention refugee?",
|
| 137 |
+
"answers": [
|
| 138 |
+
[
|
| 139 |
+
"IRPA",
|
| 140 |
+
"96"
|
| 141 |
+
]
|
| 142 |
+
]
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"query": "Who qualifies as a person in need of protection?",
|
| 146 |
+
"answers": [
|
| 147 |
+
[
|
| 148 |
+
"IRPA",
|
| 149 |
+
"97"
|
| 150 |
+
]
|
| 151 |
+
]
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"query": "When is a refugee claim ineligible to be referred to the Refugee Protection Division?",
|
| 155 |
+
"answers": [
|
| 156 |
+
[
|
| 157 |
+
"IRPA",
|
| 158 |
+
"101"
|
| 159 |
+
]
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"query": "What is a pre-removal risk assessment and who can apply for one?",
|
| 164 |
+
"answers": [
|
| 165 |
+
[
|
| 166 |
+
"IRPA",
|
| 167 |
+
"112"
|
| 168 |
+
]
|
| 169 |
+
]
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"query": "When does a removal order become enforceable?",
|
| 173 |
+
"answers": [
|
| 174 |
+
[
|
| 175 |
+
"IRPA",
|
| 176 |
+
"48"
|
| 177 |
+
]
|
| 178 |
+
],
|
| 179 |
+
"holdout": true
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"query": "Is it an offence to organize the illegal entry of people into Canada?",
|
| 183 |
+
"answers": [
|
| 184 |
+
[
|
| 185 |
+
"IRPA",
|
| 186 |
+
"117"
|
| 187 |
+
]
|
| 188 |
+
]
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"query": "Must a person report to a customs officer when arriving in Canada?",
|
| 192 |
+
"answers": [
|
| 193 |
+
[
|
| 194 |
+
"Customs Act",
|
| 195 |
+
"11"
|
| 196 |
+
]
|
| 197 |
+
]
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"query": "What is the duty to report goods imported into Canada?",
|
| 201 |
+
"answers": [
|
| 202 |
+
[
|
| 203 |
+
"Customs Act",
|
| 204 |
+
"12"
|
| 205 |
+
]
|
| 206 |
+
]
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"query": "Can a customs officer examine imported goods?",
|
| 210 |
+
"answers": [
|
| 211 |
+
[
|
| 212 |
+
"Customs Act",
|
| 213 |
+
"99"
|
| 214 |
+
]
|
| 215 |
+
]
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"query": "When can a customs officer search a person at the border?",
|
| 219 |
+
"answers": [
|
| 220 |
+
[
|
| 221 |
+
"Customs Act",
|
| 222 |
+
"98"
|
| 223 |
+
]
|
| 224 |
+
]
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"query": "What happens when goods are seized for a customs contravention?",
|
| 228 |
+
"answers": [
|
| 229 |
+
[
|
| 230 |
+
"Customs Act",
|
| 231 |
+
"110"
|
| 232 |
+
]
|
| 233 |
+
],
|
| 234 |
+
"holdout": true
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"query": "What is ascertained forfeiture under the Customs Act?",
|
| 238 |
+
"answers": [
|
| 239 |
+
[
|
| 240 |
+
"Customs Act",
|
| 241 |
+
"124"
|
| 242 |
+
]
|
| 243 |
+
]
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"query": "When do imported goods become forfeit after a customs contravention?",
|
| 247 |
+
"answers": [
|
| 248 |
+
[
|
| 249 |
+
"Customs Act",
|
| 250 |
+
"122"
|
| 251 |
+
]
|
| 252 |
+
]
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"query": "How can a person appeal a customs seizure or penalty decision to the Federal Court?",
|
| 256 |
+
"answers": [
|
| 257 |
+
[
|
| 258 |
+
"Customs Act",
|
| 259 |
+
"135"
|
| 260 |
+
]
|
| 261 |
+
]
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"query": "Advance information about commercial goods before they arrive in Canada",
|
| 265 |
+
"answers": [
|
| 266 |
+
[
|
| 267 |
+
"Customs Act",
|
| 268 |
+
"12.1"
|
| 269 |
+
]
|
| 270 |
+
]
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"query": "How is the value for duty of imported goods determined?",
|
| 274 |
+
"answers": [
|
| 275 |
+
[
|
| 276 |
+
"Customs Act",
|
| 277 |
+
"46"
|
| 278 |
+
],
|
| 279 |
+
[
|
| 280 |
+
"Customs Act",
|
| 281 |
+
"47"
|
| 282 |
+
],
|
| 283 |
+
[
|
| 284 |
+
"Customs Act",
|
| 285 |
+
"48"
|
| 286 |
+
]
|
| 287 |
+
]
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"query": "How are imported goods classified under the Customs Tariff?",
|
| 291 |
+
"answers": [
|
| 292 |
+
[
|
| 293 |
+
"Customs Tariff",
|
| 294 |
+
"10"
|
| 295 |
+
]
|
| 296 |
+
],
|
| 297 |
+
"holdout": true
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"query": "Which tariff heading in the Customs Tariff Schedule covers a settler's effects on first arrival?",
|
| 301 |
+
"answers": [
|
| 302 |
+
[
|
| 303 |
+
"Customs Tariff",
|
| 304 |
+
"Sch-98.07"
|
| 305 |
+
]
|
| 306 |
+
]
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"query": "What allowance of alcohol and tobacco may a traveller bring back to Canada duty free?",
|
| 310 |
+
"answers": [
|
| 311 |
+
[
|
| 312 |
+
"Customs Tariff",
|
| 313 |
+
"Sch-98.27"
|
| 314 |
+
]
|
| 315 |
+
]
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"query": "Conveyance temporarily imported by a non-resident visiting Canada -- customs tariff treatment",
|
| 319 |
+
"answers": [
|
| 320 |
+
[
|
| 321 |
+
"Customs Tariff",
|
| 322 |
+
"Sch-98.03"
|
| 323 |
+
]
|
| 324 |
+
]
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"query": "Tariff classification of prohibited firearms and weapons under the Customs Tariff",
|
| 328 |
+
"answers": [
|
| 329 |
+
[
|
| 330 |
+
"Customs Tariff",
|
| 331 |
+
"Sch-98.98"
|
| 332 |
+
]
|
| 333 |
+
]
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
"query": "Tariff treatment of articles imported for personal use of representatives of foreign countries",
|
| 337 |
+
"answers": [
|
| 338 |
+
[
|
| 339 |
+
"Customs Tariff",
|
| 340 |
+
"Sch-98.08"
|
| 341 |
+
]
|
| 342 |
+
]
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"query": "Must travellers report large amounts of currency when crossing the border?",
|
| 346 |
+
"answers": [
|
| 347 |
+
[
|
| 348 |
+
"PCMLTFA",
|
| 349 |
+
"12"
|
| 350 |
+
]
|
| 351 |
+
],
|
| 352 |
+
"holdout": true
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"query": "Can an officer seize currency that was not reported at the border?",
|
| 356 |
+
"answers": [
|
| 357 |
+
[
|
| 358 |
+
"PCMLTFA",
|
| 359 |
+
"18"
|
| 360 |
+
]
|
| 361 |
+
]
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"query": "How does someone appeal the forfeiture of seized currency to the Federal Court?",
|
| 365 |
+
"answers": [
|
| 366 |
+
[
|
| 367 |
+
"PCMLTFA",
|
| 368 |
+
"30"
|
| 369 |
+
]
|
| 370 |
+
]
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"query": "Is simple possession of a controlled substance an offence?",
|
| 374 |
+
"answers": [
|
| 375 |
+
[
|
| 376 |
+
"CDSA",
|
| 377 |
+
"4"
|
| 378 |
+
]
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"query": "What is the offence of trafficking in a controlled substance?",
|
| 383 |
+
"answers": [
|
| 384 |
+
[
|
| 385 |
+
"CDSA",
|
| 386 |
+
"5"
|
| 387 |
+
]
|
| 388 |
+
]
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"query": "Is it an offence to import or export a controlled substance?",
|
| 392 |
+
"answers": [
|
| 393 |
+
[
|
| 394 |
+
"CDSA",
|
| 395 |
+
"6"
|
| 396 |
+
]
|
| 397 |
+
]
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
"query": "Is possession of cannabis an offence?",
|
| 401 |
+
"answers": [
|
| 402 |
+
[
|
| 403 |
+
"Cannabis Act",
|
| 404 |
+
"8"
|
| 405 |
+
]
|
| 406 |
+
],
|
| 407 |
+
"holdout": true
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"query": "Can cannabis be imported into or exported from Canada?",
|
| 411 |
+
"answers": [
|
| 412 |
+
[
|
| 413 |
+
"Cannabis Act",
|
| 414 |
+
"11"
|
| 415 |
+
]
|
| 416 |
+
]
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"query": "When can a peace officer arrest a person without a warrant?",
|
| 420 |
+
"answers": [
|
| 421 |
+
[
|
| 422 |
+
"Criminal Code",
|
| 423 |
+
"495"
|
| 424 |
+
]
|
| 425 |
+
]
|
| 426 |
+
},
|
| 427 |
+
{
|
| 428 |
+
"query": "What right does a person have to access their own personal information held by a government institution?",
|
| 429 |
+
"answers": [
|
| 430 |
+
[
|
| 431 |
+
"Privacy Act",
|
| 432 |
+
"12"
|
| 433 |
+
]
|
| 434 |
+
]
|
| 435 |
+
},
|
| 436 |
+
{
|
| 437 |
+
"query": "When may a government institution disclose someone's personal information?",
|
| 438 |
+
"answers": [
|
| 439 |
+
[
|
| 440 |
+
"Privacy Act",
|
| 441 |
+
"8"
|
| 442 |
+
]
|
| 443 |
+
]
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"query": "Can an employee refuse to do work that presents a danger?",
|
| 447 |
+
"answers": [
|
| 448 |
+
[
|
| 449 |
+
"Canada Labour Code",
|
| 450 |
+
"128"
|
| 451 |
+
]
|
| 452 |
+
]
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"query": "What are the standard hours of work for an employee?",
|
| 456 |
+
"answers": [
|
| 457 |
+
[
|
| 458 |
+
"Canada Labour Code",
|
| 459 |
+
"169"
|
| 460 |
+
]
|
| 461 |
+
],
|
| 462 |
+
"holdout": true
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"query": "What is the standard of review of an administrative decision on judicial review?",
|
| 466 |
+
"answers": [
|
| 467 |
+
[
|
| 468 |
+
"Vavilov",
|
| 469 |
+
""
|
| 470 |
+
]
|
| 471 |
+
]
|
| 472 |
+
},
|
| 473 |
+
{
|
| 474 |
+
"query": "How does the Refugee Appeal Division review a decision of the Refugee Protection Division?",
|
| 475 |
+
"answers": [
|
| 476 |
+
[
|
| 477 |
+
"Huruglica",
|
| 478 |
+
""
|
| 479 |
+
]
|
| 480 |
+
]
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"query": "To get back currency seized at the border, what must the claimant show about the money?",
|
| 484 |
+
"answers": [
|
| 485 |
+
[
|
| 486 |
+
"Sellathurai",
|
| 487 |
+
""
|
| 488 |
+
]
|
| 489 |
+
]
|
| 490 |
+
},
|
| 491 |
+
{
|
| 492 |
+
"query": "What factors shape the content of the duty of procedural fairness in an administrative decision?",
|
| 493 |
+
"answers": [
|
| 494 |
+
[
|
| 495 |
+
"Baker",
|
| 496 |
+
""
|
| 497 |
+
]
|
| 498 |
+
]
|
| 499 |
+
},
|
| 500 |
+
{
|
| 501 |
+
"query": "Can Canada remove a person to a country where they face a substantial risk of torture?",
|
| 502 |
+
"answers": [
|
| 503 |
+
[
|
| 504 |
+
"Suresh",
|
| 505 |
+
""
|
| 506 |
+
]
|
| 507 |
+
]
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"query": "Is the security certificate regime for detaining and removing non-citizens consistent with the Charter?",
|
| 511 |
+
"answers": [
|
| 512 |
+
[
|
| 513 |
+
"Charkaoui",
|
| 514 |
+
""
|
| 515 |
+
]
|
| 516 |
+
],
|
| 517 |
+
"holdout": true
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"query": "Do refugee claimants have a right to an oral hearing under the Charter?",
|
| 521 |
+
"answers": [
|
| 522 |
+
[
|
| 523 |
+
"Singh",
|
| 524 |
+
""
|
| 525 |
+
]
|
| 526 |
+
]
|
| 527 |
+
},
|
| 528 |
+
{
|
| 529 |
+
"query": "What is a particular social group in the definition of a Convention refugee?",
|
| 530 |
+
"answers": [
|
| 531 |
+
[
|
| 532 |
+
"Ward",
|
| 533 |
+
""
|
| 534 |
+
]
|
| 535 |
+
]
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"query": "When is a person excluded from refugee protection for acts contrary to the purposes of the United Nations?",
|
| 539 |
+
"answers": [
|
| 540 |
+
[
|
| 541 |
+
"Pushpanathan",
|
| 542 |
+
""
|
| 543 |
+
]
|
| 544 |
+
]
|
| 545 |
+
},
|
| 546 |
+
{
|
| 547 |
+
"query": "What degree of involvement makes a person complicit in international crimes and excluded from refugee protection?",
|
| 548 |
+
"answers": [
|
| 549 |
+
[
|
| 550 |
+
"Ezokola",
|
| 551 |
+
""
|
| 552 |
+
]
|
| 553 |
+
]
|
| 554 |
+
},
|
| 555 |
+
{
|
| 556 |
+
"query": "Can a person be denied refugee protection for a serious crime committed abroad before claiming asylum?",
|
| 557 |
+
"answers": [
|
| 558 |
+
[
|
| 559 |
+
"Febles",
|
| 560 |
+
""
|
| 561 |
+
]
|
| 562 |
+
]
|
| 563 |
+
},
|
| 564 |
+
{
|
| 565 |
+
"query": "How must a decision-maker weigh the best interests of a child in a humanitarian and compassionate application?",
|
| 566 |
+
"answers": [
|
| 567 |
+
[
|
| 568 |
+
"Kanthasamy",
|
| 569 |
+
""
|
| 570 |
+
]
|
| 571 |
+
],
|
| 572 |
+
"holdout": true
|
| 573 |
+
},
|
| 574 |
+
{
|
| 575 |
+
"query": "What does the national interest mean when the Minister grants relief from security inadmissibility?",
|
| 576 |
+
"answers": [
|
| 577 |
+
[
|
| 578 |
+
"Agraira",
|
| 579 |
+
""
|
| 580 |
+
]
|
| 581 |
+
]
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
"query": "Does helping fellow asylum seekers enter a country illegally amount to people smuggling for inadmissibility?",
|
| 585 |
+
"answers": [
|
| 586 |
+
[
|
| 587 |
+
"B010",
|
| 588 |
+
""
|
| 589 |
+
]
|
| 590 |
+
]
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"query": "Is the offence of human smuggling unconstitutionally overbroad if it captures humanitarian aid?",
|
| 594 |
+
"answers": [
|
| 595 |
+
[
|
| 596 |
+
"Appulonappa",
|
| 597 |
+
""
|
| 598 |
+
]
|
| 599 |
+
]
|
| 600 |
+
},
|
| 601 |
+
{
|
| 602 |
+
"query": "Does a conditional sentence count as a term of imprisonment for serious criminality?",
|
| 603 |
+
"answers": [
|
| 604 |
+
[
|
| 605 |
+
"Tran",
|
| 606 |
+
""
|
| 607 |
+
]
|
| 608 |
+
]
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"query": "Can an immigration detainee challenge their detention through habeas corpus?",
|
| 612 |
+
"answers": [
|
| 613 |
+
[
|
| 614 |
+
"Chhina",
|
| 615 |
+
""
|
| 616 |
+
]
|
| 617 |
+
]
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"query": "What kinds of border search engage the Charter protection against unreasonable search and seizure?",
|
| 621 |
+
"answers": [
|
| 622 |
+
[
|
| 623 |
+
"Simmons",
|
| 624 |
+
""
|
| 625 |
+
]
|
| 626 |
+
],
|
| 627 |
+
"holdout": true
|
| 628 |
+
},
|
| 629 |
+
{
|
| 630 |
+
"query": "Does inadmissibility for membership in a terrorist organization require a complicity analysis?",
|
| 631 |
+
"answers": [
|
| 632 |
+
[
|
| 633 |
+
"Kanagendren",
|
| 634 |
+
""
|
| 635 |
+
]
|
| 636 |
+
]
|
| 637 |
+
},
|
| 638 |
+
{
|
| 639 |
+
"query": "How broadly is a criminal organization interpreted for organized criminality inadmissibility?",
|
| 640 |
+
"answers": [
|
| 641 |
+
[
|
| 642 |
+
"Sittampalam",
|
| 643 |
+
""
|
| 644 |
+
]
|
| 645 |
+
]
|
| 646 |
+
},
|
| 647 |
+
{
|
| 648 |
+
"query": "What principles govern a finding of inadmissibility for misrepresentation?",
|
| 649 |
+
"answers": [
|
| 650 |
+
[
|
| 651 |
+
"Goburdhun",
|
| 652 |
+
""
|
| 653 |
+
]
|
| 654 |
+
]
|
| 655 |
+
},
|
| 656 |
+
{
|
| 657 |
+
"query": "What is the innocent-misrepresentation defence to inadmissibility under IRPA s. 40?",
|
| 658 |
+
"answers": [
|
| 659 |
+
[
|
| 660 |
+
"Wang",
|
| 661 |
+
""
|
| 662 |
+
]
|
| 663 |
+
]
|
| 664 |
+
},
|
| 665 |
+
{
|
| 666 |
+
"query": "Must a misrepresentation under IRPA s. 40 be intentional, deliberate or negligent to be material?",
|
| 667 |
+
"answers": [
|
| 668 |
+
[
|
| 669 |
+
"Bellido",
|
| 670 |
+
""
|
| 671 |
+
]
|
| 672 |
+
]
|
| 673 |
+
},
|
| 674 |
+
{
|
| 675 |
+
"query": "At an immigration detention review, who bears the onus and how are earlier detention rulings treated?",
|
| 676 |
+
"answers": [
|
| 677 |
+
[
|
| 678 |
+
"Thanabalasingham",
|
| 679 |
+
""
|
| 680 |
+
]
|
| 681 |
+
],
|
| 682 |
+
"holdout": true
|
| 683 |
+
},
|
| 684 |
+
{
|
| 685 |
+
"query": "What is the test for admitting new evidence in a pre-removal risk assessment?",
|
| 686 |
+
"answers": [
|
| 687 |
+
[
|
| 688 |
+
"Raza",
|
| 689 |
+
""
|
| 690 |
+
]
|
| 691 |
+
]
|
| 692 |
+
},
|
| 693 |
+
{
|
| 694 |
+
"query": "Are gold coins currency or monetary instruments that must be reported when imported?",
|
| 695 |
+
"answers": [
|
| 696 |
+
[
|
| 697 |
+
"Hociung",
|
| 698 |
+
""
|
| 699 |
+
]
|
| 700 |
+
]
|
| 701 |
+
},
|
| 702 |
+
{
|
| 703 |
+
"query": "How do the courts review a customs tariff classification decision?",
|
| 704 |
+
"answers": [
|
| 705 |
+
[
|
| 706 |
+
"Best Buy",
|
| 707 |
+
""
|
| 708 |
+
]
|
| 709 |
+
]
|
| 710 |
+
},
|
| 711 |
+
{
|
| 712 |
+
"query": "What does CBSA policy say about how the value for duty of imported goods is established?",
|
| 713 |
+
"answers": [
|
| 714 |
+
[
|
| 715 |
+
"D-Memo",
|
| 716 |
+
"D13-1-1"
|
| 717 |
+
]
|
| 718 |
+
]
|
| 719 |
+
},
|
| 720 |
+
{
|
| 721 |
+
"query": "What are CBSA's requirements for marking imported goods with their country of origin?",
|
| 722 |
+
"answers": [
|
| 723 |
+
[
|
| 724 |
+
"D-Memo",
|
| 725 |
+
"D11-3-1"
|
| 726 |
+
]
|
| 727 |
+
]
|
| 728 |
+
},
|
| 729 |
+
{
|
| 730 |
+
"query": "What is CBSA's guidance on importing or exporting cannabis and controlled substances?",
|
| 731 |
+
"answers": [
|
| 732 |
+
[
|
| 733 |
+
"D-Memo",
|
| 734 |
+
"D19-9-2"
|
| 735 |
+
]
|
| 736 |
+
],
|
| 737 |
+
"holdout": true
|
| 738 |
+
},
|
| 739 |
+
{
|
| 740 |
+
"query": "What personal exemptions can a resident claim when returning to Canada, per CBSA guidance?",
|
| 741 |
+
"answers": [
|
| 742 |
+
[
|
| 743 |
+
"D-Memo",
|
| 744 |
+
"D2-3-1"
|
| 745 |
+
]
|
| 746 |
+
]
|
| 747 |
+
},
|
| 748 |
+
{
|
| 749 |
+
"query": "What is CBSA's guidance on cross-border currency and monetary instruments reporting?",
|
| 750 |
+
"answers": [
|
| 751 |
+
[
|
| 752 |
+
"D-Memo",
|
| 753 |
+
"D19-14-1"
|
| 754 |
+
]
|
| 755 |
+
]
|
| 756 |
+
},
|
| 757 |
+
{
|
| 758 |
+
"query": "How does CBSA decide whether imported material is obscene?",
|
| 759 |
+
"answers": [
|
| 760 |
+
[
|
| 761 |
+
"D-Memo",
|
| 762 |
+
"D9-1-1"
|
| 763 |
+
]
|
| 764 |
+
]
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"query": "What proof of origin does CBSA require for imported goods?",
|
| 768 |
+
"answers": [
|
| 769 |
+
[
|
| 770 |
+
"D-Memo",
|
| 771 |
+
"D11-4-2"
|
| 772 |
+
]
|
| 773 |
+
]
|
| 774 |
+
},
|
| 775 |
+
{
|
| 776 |
+
"query": "What is the Canadian Goods Abroad Program for goods sent outside Canada for repair?",
|
| 777 |
+
"answers": [
|
| 778 |
+
[
|
| 779 |
+
"D-Memo",
|
| 780 |
+
"D8-2-1"
|
| 781 |
+
]
|
| 782 |
+
]
|
| 783 |
+
},
|
| 784 |
+
{
|
| 785 |
+
"query": "How does the FB Border Services collective agreement deal with discipline of an employee?",
|
| 786 |
+
"answers": [
|
| 787 |
+
[
|
| 788 |
+
"FB Agreement",
|
| 789 |
+
"17"
|
| 790 |
+
]
|
| 791 |
+
],
|
| 792 |
+
"holdout": true
|
| 793 |
+
},
|
| 794 |
+
{
|
| 795 |
+
"query": "What is the grievance procedure under the FB collective agreement?",
|
| 796 |
+
"answers": [
|
| 797 |
+
[
|
| 798 |
+
"FB Agreement",
|
| 799 |
+
"18"
|
| 800 |
+
]
|
| 801 |
+
]
|
| 802 |
+
},
|
| 803 |
+
{
|
| 804 |
+
"query": "What are the hours of work under the FB Border Services collective agreement?",
|
| 805 |
+
"answers": [
|
| 806 |
+
[
|
| 807 |
+
"FB Agreement",
|
| 808 |
+
"25"
|
| 809 |
+
]
|
| 810 |
+
]
|
| 811 |
+
},
|
| 812 |
+
{
|
| 813 |
+
"query": "How is overtime compensated under the FB collective agreement?",
|
| 814 |
+
"answers": [
|
| 815 |
+
[
|
| 816 |
+
"FB Agreement",
|
| 817 |
+
"28"
|
| 818 |
+
]
|
| 819 |
+
]
|
| 820 |
+
},
|
| 821 |
+
{
|
| 822 |
+
"query": "How much vacation leave with pay do FB-group employees earn?",
|
| 823 |
+
"answers": [
|
| 824 |
+
[
|
| 825 |
+
"FB Agreement",
|
| 826 |
+
"34"
|
| 827 |
+
]
|
| 828 |
+
]
|
| 829 |
+
},
|
| 830 |
+
{
|
| 831 |
+
"query": "What is the bilingualism bonus and who is eligible to receive it?",
|
| 832 |
+
"answers": [
|
| 833 |
+
[
|
| 834 |
+
"Bilingualism Bonus Directive",
|
| 835 |
+
""
|
| 836 |
+
]
|
| 837 |
+
]
|
| 838 |
+
},
|
| 839 |
+
{
|
| 840 |
+
"query": "What assistance is available to a federal employee who faces unusual daily commuting costs?",
|
| 841 |
+
"answers": [
|
| 842 |
+
[
|
| 843 |
+
"Commuting Assistance Directive",
|
| 844 |
+
""
|
| 845 |
+
]
|
| 846 |
+
],
|
| 847 |
+
"holdout": true
|
| 848 |
+
},
|
| 849 |
+
{
|
| 850 |
+
"query": "When may the Immigration Division order the release of a detained person?",
|
| 851 |
+
"answers": [
|
| 852 |
+
[
|
| 853 |
+
"IRPA",
|
| 854 |
+
"58"
|
| 855 |
+
]
|
| 856 |
+
]
|
| 857 |
+
},
|
| 858 |
+
{
|
| 859 |
+
"query": "When is there no right to appeal a removal order to the Immigration Appeal Division?",
|
| 860 |
+
"answers": [
|
| 861 |
+
[
|
| 862 |
+
"IRPA",
|
| 863 |
+
"64"
|
| 864 |
+
]
|
| 865 |
+
]
|
| 866 |
+
},
|
| 867 |
+
{
|
| 868 |
+
"query": "Is distributing cannabis an offence?",
|
| 869 |
+
"answers": [
|
| 870 |
+
[
|
| 871 |
+
"Cannabis Act",
|
| 872 |
+
"9"
|
| 873 |
+
]
|
| 874 |
+
]
|
| 875 |
+
},
|
| 876 |
+
{
|
| 877 |
+
"query": "What is the offence of smuggling goods into Canada under the Customs Act?",
|
| 878 |
+
"answers": [
|
| 879 |
+
[
|
| 880 |
+
"Customs Act",
|
| 881 |
+
"159"
|
| 882 |
+
]
|
| 883 |
+
]
|
| 884 |
+
},
|
| 885 |
+
{
|
| 886 |
+
"query": "Is an employee entitled to medical leave under the Canada Labour Code?",
|
| 887 |
+
"answers": [
|
| 888 |
+
[
|
| 889 |
+
"Canada Labour Code",
|
| 890 |
+
"239"
|
| 891 |
+
]
|
| 892 |
+
]
|
| 893 |
+
},
|
| 894 |
+
{
|
| 895 |
+
"query": "How should an employer apply progressive discipline to a border services officer?",
|
| 896 |
+
"answers": [
|
| 897 |
+
[
|
| 898 |
+
"Menzies",
|
| 899 |
+
""
|
| 900 |
+
]
|
| 901 |
+
],
|
| 902 |
+
"holdout": true
|
| 903 |
+
},
|
| 904 |
+
{
|
| 905 |
+
"query": "What is the burden and standard of proof when an employer defends a disciplinary grievance?",
|
| 906 |
+
"answers": [
|
| 907 |
+
[
|
| 908 |
+
"Basra (2007)",
|
| 909 |
+
""
|
| 910 |
+
]
|
| 911 |
+
]
|
| 912 |
+
},
|
| 913 |
+
{
|
| 914 |
+
"query": "What factors determine whether an employer had just cause to discipline or terminate an employee?",
|
| 915 |
+
"answers": [
|
| 916 |
+
[
|
| 917 |
+
"Pepper",
|
| 918 |
+
""
|
| 919 |
+
]
|
| 920 |
+
]
|
| 921 |
+
},
|
| 922 |
+
{
|
| 923 |
+
"query": "What is the test for whether a union breached its duty of fair representation?",
|
| 924 |
+
"answers": [
|
| 925 |
+
[
|
| 926 |
+
"McRaeJackson",
|
| 927 |
+
""
|
| 928 |
+
]
|
| 929 |
+
]
|
| 930 |
+
},
|
| 931 |
+
{
|
| 932 |
+
"query": "Can a union breach the duty of fair representation in how it handles a vaccination-policy grievance?",
|
| 933 |
+
"answers": [
|
| 934 |
+
[
|
| 935 |
+
"Watson",
|
| 936 |
+
""
|
| 937 |
+
]
|
| 938 |
+
]
|
| 939 |
+
},
|
| 940 |
+
{
|
| 941 |
+
"query": "Is it consistent with the Charter of Rights to deport a permanent resident convicted of serious crimes?",
|
| 942 |
+
"answers": [
|
| 943 |
+
[
|
| 944 |
+
"Chiarelli",
|
| 945 |
+
""
|
| 946 |
+
]
|
| 947 |
+
]
|
| 948 |
+
},
|
| 949 |
+
{
|
| 950 |
+
"query": "When the Immigration Appeal Division hears an appeal from a removal order, may it weigh the hardship the person would face in the country of removal?",
|
| 951 |
+
"answers": [
|
| 952 |
+
[
|
| 953 |
+
"Chieu",
|
| 954 |
+
""
|
| 955 |
+
]
|
| 956 |
+
],
|
| 957 |
+
"holdout": true
|
| 958 |
+
},
|
| 959 |
+
{
|
| 960 |
+
"query": "Does a non-citizen have a Charter right to remain in Canada?",
|
| 961 |
+
"answers": [
|
| 962 |
+
[
|
| 963 |
+
"Medovarski",
|
| 964 |
+
""
|
| 965 |
+
]
|
| 966 |
+
]
|
| 967 |
+
},
|
| 968 |
+
{
|
| 969 |
+
"query": "What must be established for inadmissibility for incitement to genocide or a crime against humanity?",
|
| 970 |
+
"answers": [
|
| 971 |
+
[
|
| 972 |
+
"Mugesera",
|
| 973 |
+
""
|
| 974 |
+
]
|
| 975 |
+
]
|
| 976 |
+
},
|
| 977 |
+
{
|
| 978 |
+
"query": "Is the IRPA security-certificate regime, with its special advocate scheme, consistent with the Charter?",
|
| 979 |
+
"answers": [
|
| 980 |
+
[
|
| 981 |
+
"Harkat",
|
| 982 |
+
""
|
| 983 |
+
]
|
| 984 |
+
]
|
| 985 |
+
},
|
| 986 |
+
{
|
| 987 |
+
"query": "How does the principle of non-refoulement constrain Canada when it extradites a person who holds refugee protection?",
|
| 988 |
+
"answers": [
|
| 989 |
+
[
|
| 990 |
+
"Németh",
|
| 991 |
+
""
|
| 992 |
+
]
|
| 993 |
+
]
|
| 994 |
+
},
|
| 995 |
+
{
|
| 996 |
+
"query": "What procedural fairness is owed to a sponsor before the government enforces a sponsorship-undertaking debt?",
|
| 997 |
+
"answers": [
|
| 998 |
+
[
|
| 999 |
+
"Mavi",
|
| 1000 |
+
""
|
| 1001 |
+
]
|
| 1002 |
+
]
|
| 1003 |
+
},
|
| 1004 |
+
{
|
| 1005 |
+
"query": "Can a sentencing court take the immigration consequences of a sentence into account?",
|
| 1006 |
+
"answers": [
|
| 1007 |
+
[
|
| 1008 |
+
"Pham",
|
| 1009 |
+
""
|
| 1010 |
+
]
|
| 1011 |
+
],
|
| 1012 |
+
"holdout": true
|
| 1013 |
+
},
|
| 1014 |
+
{
|
| 1015 |
+
"query": "Can a fear of coerced sterilization under a one-child population-control policy support a refugee claim?",
|
| 1016 |
+
"answers": [
|
| 1017 |
+
[
|
| 1018 |
+
"Chan",
|
| 1019 |
+
""
|
| 1020 |
+
]
|
| 1021 |
+
]
|
| 1022 |
+
},
|
| 1023 |
+
{
|
| 1024 |
+
"query": "Can a customs officer stop and search a vehicle near the border on reasonable suspicion of a customs offence?",
|
| 1025 |
+
"answers": [
|
| 1026 |
+
[
|
| 1027 |
+
"Jacques",
|
| 1028 |
+
""
|
| 1029 |
+
]
|
| 1030 |
+
]
|
| 1031 |
+
},
|
| 1032 |
+
{
|
| 1033 |
+
"query": "Is an ascertained-forfeiture proceeding under the Customs Act penal enough to engage the right against self-incrimination?",
|
| 1034 |
+
"answers": [
|
| 1035 |
+
[
|
| 1036 |
+
"Martineau",
|
| 1037 |
+
""
|
| 1038 |
+
]
|
| 1039 |
+
]
|
| 1040 |
+
},
|
| 1041 |
+
{
|
| 1042 |
+
"query": "Does a person's age at the time of involvement matter to inadmissibility for membership in a terrorist organization?",
|
| 1043 |
+
"answers": [
|
| 1044 |
+
[
|
| 1045 |
+
"Poshteh",
|
| 1046 |
+
""
|
| 1047 |
+
]
|
| 1048 |
+
]
|
| 1049 |
+
},
|
| 1050 |
+
{
|
| 1051 |
+
"query": "Can a deserter from the United States military succeed in a refugee or humanitarian and compassionate claim in Canada?",
|
| 1052 |
+
"answers": [
|
| 1053 |
+
[
|
| 1054 |
+
"Hinzman",
|
| 1055 |
+
""
|
| 1056 |
+
]
|
| 1057 |
+
]
|
| 1058 |
+
},
|
| 1059 |
+
{
|
| 1060 |
+
"query": "Does a Refugee Protection Division guideline that sets the order of questioning improperly fetter a member's discretion?",
|
| 1061 |
+
"answers": [
|
| 1062 |
+
[
|
| 1063 |
+
"Thamotharem",
|
| 1064 |
+
""
|
| 1065 |
+
]
|
| 1066 |
+
],
|
| 1067 |
+
"holdout": true
|
| 1068 |
+
},
|
| 1069 |
+
{
|
| 1070 |
+
"query": "Can a person with no immigration status obtain coverage under the Interim Federal Health Program?",
|
| 1071 |
+
"answers": [
|
| 1072 |
+
[
|
| 1073 |
+
"Toussaint",
|
| 1074 |
+
""
|
| 1075 |
+
]
|
| 1076 |
+
]
|
| 1077 |
+
},
|
| 1078 |
+
{
|
| 1079 |
+
"query": "What does a finding of no credible basis mean for a refugee claim?",
|
| 1080 |
+
"answers": [
|
| 1081 |
+
[
|
| 1082 |
+
"Rahaman",
|
| 1083 |
+
""
|
| 1084 |
+
]
|
| 1085 |
+
]
|
| 1086 |
+
},
|
| 1087 |
+
{
|
| 1088 |
+
"query": "How is the reasonableness of a security certificate assessed on a re-determination after Charkaoui?",
|
| 1089 |
+
"answers": [
|
| 1090 |
+
[
|
| 1091 |
+
"Almrei",
|
| 1092 |
+
""
|
| 1093 |
+
]
|
| 1094 |
+
]
|
| 1095 |
+
},
|
| 1096 |
+
{
|
| 1097 |
+
"query": "Can a permanent resident be found inadmissible for acts of violence that endanger safety in Canada even without a conviction?",
|
| 1098 |
+
"answers": [
|
| 1099 |
+
[
|
| 1100 |
+
"Mason",
|
| 1101 |
+
""
|
| 1102 |
+
]
|
| 1103 |
+
]
|
| 1104 |
+
},
|
| 1105 |
+
{
|
| 1106 |
+
"query": "How much deference does the Immigration Appeal Division receive when it declines to grant discretionary relief from removal?",
|
| 1107 |
+
"answers": [
|
| 1108 |
+
[
|
| 1109 |
+
"Khosa",
|
| 1110 |
+
""
|
| 1111 |
+
]
|
| 1112 |
+
]
|
| 1113 |
+
},
|
| 1114 |
+
{
|
| 1115 |
+
"query": "Is lengthy immigration detention consistent with the Charter protection against arbitrary detention?",
|
| 1116 |
+
"answers": [
|
| 1117 |
+
[
|
| 1118 |
+
"Brown",
|
| 1119 |
+
""
|
| 1120 |
+
]
|
| 1121 |
+
],
|
| 1122 |
+
"holdout": true
|
| 1123 |
+
},
|
| 1124 |
+
{
|
| 1125 |
+
"query": "Is the designation of the United States as a safe third country for refugee claimants constitutional?",
|
| 1126 |
+
"answers": [
|
| 1127 |
+
[
|
| 1128 |
+
"CCRf",
|
| 1129 |
+
""
|
| 1130 |
+
]
|
| 1131 |
+
]
|
| 1132 |
+
},
|
| 1133 |
+
{
|
| 1134 |
+
"query": "Can an employee with an addiction be dismissed for breach of a drug and alcohol policy in a safety-sensitive workplace?",
|
| 1135 |
+
"answers": [
|
| 1136 |
+
[
|
| 1137 |
+
"Elk Valley",
|
| 1138 |
+
""
|
| 1139 |
+
]
|
| 1140 |
+
]
|
| 1141 |
+
},
|
| 1142 |
+
{
|
| 1143 |
+
"query": "When is random alcohol testing permitted in a unionized safety-sensitive workplace?",
|
| 1144 |
+
"answers": [
|
| 1145 |
+
[
|
| 1146 |
+
"Irving",
|
| 1147 |
+
""
|
| 1148 |
+
]
|
| 1149 |
+
]
|
| 1150 |
+
},
|
| 1151 |
+
{
|
| 1152 |
+
"query": "Do RCMP members have a Charter right to collective bargaining?",
|
| 1153 |
+
"answers": [
|
| 1154 |
+
[
|
| 1155 |
+
"MPAO",
|
| 1156 |
+
""
|
| 1157 |
+
]
|
| 1158 |
+
]
|
| 1159 |
+
},
|
| 1160 |
+
{
|
| 1161 |
+
"query": "Is the right to strike protected by the Charter?",
|
| 1162 |
+
"answers": [
|
| 1163 |
+
[
|
| 1164 |
+
"SFL",
|
| 1165 |
+
""
|
| 1166 |
+
]
|
| 1167 |
+
]
|
| 1168 |
+
},
|
| 1169 |
+
{
|
| 1170 |
+
"query": "What meal and incidental allowances can a federal employee claim while travelling on government business?",
|
| 1171 |
+
"answers": [
|
| 1172 |
+
[
|
| 1173 |
+
"Travel Directive",
|
| 1174 |
+
""
|
| 1175 |
+
]
|
| 1176 |
+
],
|
| 1177 |
+
"holdout": true
|
| 1178 |
+
},
|
| 1179 |
+
{
|
| 1180 |
+
"query": "What is a federal employee entitled to when the employer requires them to wear a uniform?",
|
| 1181 |
+
"answers": [
|
| 1182 |
+
[
|
| 1183 |
+
"Uniforms Directive",
|
| 1184 |
+
""
|
| 1185 |
+
]
|
| 1186 |
+
]
|
| 1187 |
+
},
|
| 1188 |
+
{
|
| 1189 |
+
"query": "What protections does an indeterminate employee have when their position is declared surplus?",
|
| 1190 |
+
"answers": [
|
| 1191 |
+
[
|
| 1192 |
+
"Work Force Adjustment Directive",
|
| 1193 |
+
""
|
| 1194 |
+
]
|
| 1195 |
+
]
|
| 1196 |
+
},
|
| 1197 |
+
{
|
| 1198 |
+
"query": "Is there an allowance for an employee required to administer first aid to the general public?",
|
| 1199 |
+
"answers": [
|
| 1200 |
+
[
|
| 1201 |
+
"First Aid to the General Public - Allowance for Employees",
|
| 1202 |
+
""
|
| 1203 |
+
]
|
| 1204 |
+
]
|
| 1205 |
+
},
|
| 1206 |
+
{
|
| 1207 |
+
"query": "What allowances are available to a federal employee posted at an isolated post?",
|
| 1208 |
+
"answers": [
|
| 1209 |
+
[
|
| 1210 |
+
"Isolated Posts and Government Housing Directive",
|
| 1211 |
+
""
|
| 1212 |
+
]
|
| 1213 |
+
]
|
| 1214 |
+
},
|
| 1215 |
+
{
|
| 1216 |
+
"query": "What relocation expenses are reimbursed when a federal employee must move for work?",
|
| 1217 |
+
"answers": [
|
| 1218 |
+
[
|
| 1219 |
+
"NJC Relocation Directive",
|
| 1220 |
+
""
|
| 1221 |
+
]
|
| 1222 |
+
]
|
| 1223 |
+
},
|
| 1224 |
+
{
|
| 1225 |
+
"query": "What occupational health and safety obligations does the NJC directive place on the employer?",
|
| 1226 |
+
"answers": [
|
| 1227 |
+
[
|
| 1228 |
+
"Occupational Health and Safety Directive",
|
| 1229 |
+
""
|
| 1230 |
+
]
|
| 1231 |
+
],
|
| 1232 |
+
"holdout": true
|
| 1233 |
+
},
|
| 1234 |
+
{
|
| 1235 |
+
"query": "What coverage does the Public Service Health Care Plan provide?",
|
| 1236 |
+
"answers": [
|
| 1237 |
+
[
|
| 1238 |
+
"Public Service Health Care Plan Directive",
|
| 1239 |
+
""
|
| 1240 |
+
],
|
| 1241 |
+
[
|
| 1242 |
+
"PSHCP",
|
| 1243 |
+
""
|
| 1244 |
+
]
|
| 1245 |
+
]
|
| 1246 |
+
},
|
| 1247 |
+
{
|
| 1248 |
+
"query": "What does the PSHCP member booklet say about prescription drug coverage?",
|
| 1249 |
+
"answers": [
|
| 1250 |
+
[
|
| 1251 |
+
"PSHCP",
|
| 1252 |
+
""
|
| 1253 |
+
]
|
| 1254 |
+
]
|
| 1255 |
+
},
|
| 1256 |
+
{
|
| 1257 |
+
"query": "What dental services are covered under the Public Service Dental Care Plan for employees?",
|
| 1258 |
+
"answers": [
|
| 1259 |
+
[
|
| 1260 |
+
"PSDCP",
|
| 1261 |
+
""
|
| 1262 |
+
]
|
| 1263 |
+
]
|
| 1264 |
+
},
|
| 1265 |
+
{
|
| 1266 |
+
"query": "What dental coverage do federal pensioners get under the Pensioners' Dental Services Plan?",
|
| 1267 |
+
"answers": [
|
| 1268 |
+
[
|
| 1269 |
+
"PDSP",
|
| 1270 |
+
""
|
| 1271 |
+
]
|
| 1272 |
+
]
|
| 1273 |
+
},
|
| 1274 |
+
{
|
| 1275 |
+
"query": "What life and disability insurance does the Public Service Management Insurance Plan provide for excluded employees?",
|
| 1276 |
+
"answers": [
|
| 1277 |
+
[
|
| 1278 |
+
"PSMIP",
|
| 1279 |
+
""
|
| 1280 |
+
]
|
| 1281 |
+
]
|
| 1282 |
+
},
|
| 1283 |
+
{
|
| 1284 |
+
"query": "What support do the Foreign Service Directives give an employee posted outside Canada?",
|
| 1285 |
+
"answers": [
|
| 1286 |
+
[
|
| 1287 |
+
"Foreign Service Directives",
|
| 1288 |
+
""
|
| 1289 |
+
]
|
| 1290 |
+
],
|
| 1291 |
+
"holdout": true
|
| 1292 |
+
},
|
| 1293 |
+
{
|
| 1294 |
+
"query": "Who has a right to appeal a decision to the Immigration Appeal Division?",
|
| 1295 |
+
"answers": [
|
| 1296 |
+
[
|
| 1297 |
+
"IRPA",
|
| 1298 |
+
"63"
|
| 1299 |
+
]
|
| 1300 |
+
]
|
| 1301 |
+
},
|
| 1302 |
+
{
|
| 1303 |
+
"query": "What are the objectives of the Immigration and Refugee Protection Act?",
|
| 1304 |
+
"answers": [
|
| 1305 |
+
[
|
| 1306 |
+
"IRPA",
|
| 1307 |
+
"3"
|
| 1308 |
+
]
|
| 1309 |
+
]
|
| 1310 |
+
},
|
| 1311 |
+
{
|
| 1312 |
+
"query": "Can a non-unionized federally regulated employee complain that they were unjustly dismissed?",
|
| 1313 |
+
"answers": [
|
| 1314 |
+
[
|
| 1315 |
+
"Canada Labour Code",
|
| 1316 |
+
"240"
|
| 1317 |
+
]
|
| 1318 |
+
]
|
| 1319 |
+
},
|
| 1320 |
+
{
|
| 1321 |
+
"query": "What does CBSA disclose about normal values and export prices under the Special Import Measures Act?",
|
| 1322 |
+
"answers": [
|
| 1323 |
+
[
|
| 1324 |
+
"D-Memo",
|
| 1325 |
+
"D14-1-2"
|
| 1326 |
+
]
|
| 1327 |
+
]
|
| 1328 |
+
},
|
| 1329 |
+
{
|
| 1330 |
+
"query": "What are CBSA's requirements for importing or exporting cultural property?",
|
| 1331 |
+
"answers": [
|
| 1332 |
+
[
|
| 1333 |
+
"D-Memo",
|
| 1334 |
+
"D19-4-1"
|
| 1335 |
+
]
|
| 1336 |
+
]
|
| 1337 |
+
},
|
| 1338 |
+
{
|
| 1339 |
+
"query": "When assessing criminal inadmissibility for a conviction abroad, must a decision-maker consider whether the defence of duress was practically available in that country?",
|
| 1340 |
+
"answers": [
|
| 1341 |
+
[
|
| 1342 |
+
"Rodriguez Anzola",
|
| 1343 |
+
""
|
| 1344 |
+
]
|
| 1345 |
+
],
|
| 1346 |
+
"holdout": true
|
| 1347 |
+
},
|
| 1348 |
+
{
|
| 1349 |
+
"query": "What is the mandate of the Canada Border Services Agency?",
|
| 1350 |
+
"answers": [
|
| 1351 |
+
[
|
| 1352 |
+
"CBSA Act",
|
| 1353 |
+
""
|
| 1354 |
+
]
|
| 1355 |
+
]
|
| 1356 |
+
},
|
| 1357 |
+
{
|
| 1358 |
+
"query": "How is a grievance referred to adjudication in the federal public service?",
|
| 1359 |
+
"answers": [
|
| 1360 |
+
[
|
| 1361 |
+
"FPSLRA",
|
| 1362 |
+
""
|
| 1363 |
+
]
|
| 1364 |
+
]
|
| 1365 |
+
},
|
| 1366 |
+
{
|
| 1367 |
+
"query": "What is the Federal Public Sector Labour Relations and Employment Board?",
|
| 1368 |
+
"answers": [
|
| 1369 |
+
[
|
| 1370 |
+
"FPSLREB Act",
|
| 1371 |
+
""
|
| 1372 |
+
],
|
| 1373 |
+
[
|
| 1374 |
+
"FPSLRA",
|
| 1375 |
+
""
|
| 1376 |
+
]
|
| 1377 |
+
]
|
| 1378 |
+
},
|
| 1379 |
+
{
|
| 1380 |
+
"query": "What are the requirements to become a Canadian citizen by grant?",
|
| 1381 |
+
"answers": [
|
| 1382 |
+
[
|
| 1383 |
+
"Citizenship Act",
|
| 1384 |
+
""
|
| 1385 |
+
]
|
| 1386 |
+
]
|
| 1387 |
+
},
|
| 1388 |
+
{
|
| 1389 |
+
"query": "How are dumping and subsidizing of imported goods investigated and what duties apply?",
|
| 1390 |
+
"answers": [
|
| 1391 |
+
[
|
| 1392 |
+
"SIMA",
|
| 1393 |
+
""
|
| 1394 |
+
]
|
| 1395 |
+
]
|
| 1396 |
+
},
|
| 1397 |
+
{
|
| 1398 |
+
"query": "When is a permit required to export goods on the Export Control List?",
|
| 1399 |
+
"answers": [
|
| 1400 |
+
[
|
| 1401 |
+
"EIPA",
|
| 1402 |
+
""
|
| 1403 |
+
]
|
| 1404 |
+
],
|
| 1405 |
+
"holdout": true
|
| 1406 |
+
},
|
| 1407 |
+
{
|
| 1408 |
+
"query": "What authorization is required to import a firearm into Canada?",
|
| 1409 |
+
"answers": [
|
| 1410 |
+
[
|
| 1411 |
+
"Firearms Act",
|
| 1412 |
+
""
|
| 1413 |
+
]
|
| 1414 |
+
]
|
| 1415 |
+
},
|
| 1416 |
+
{
|
| 1417 |
+
"query": "What are the procedures for an admissibility hearing before the Immigration Division?",
|
| 1418 |
+
"answers": [
|
| 1419 |
+
[
|
| 1420 |
+
"Immigration Division Rules",
|
| 1421 |
+
""
|
| 1422 |
+
],
|
| 1423 |
+
[
|
| 1424 |
+
"ENF 3",
|
| 1425 |
+
""
|
| 1426 |
+
]
|
| 1427 |
+
]
|
| 1428 |
+
},
|
| 1429 |
+
{
|
| 1430 |
+
"query": "What rules govern the hearing of a refugee protection claim before the RPD?",
|
| 1431 |
+
"answers": [
|
| 1432 |
+
[
|
| 1433 |
+
"RPD Rules",
|
| 1434 |
+
""
|
| 1435 |
+
]
|
| 1436 |
+
]
|
| 1437 |
+
},
|
| 1438 |
+
{
|
| 1439 |
+
"query": "What are the Refugee Appeal Division Rules for filing and perfecting an appeal?",
|
| 1440 |
+
"answers": [
|
| 1441 |
+
[
|
| 1442 |
+
"RAD Rules",
|
| 1443 |
+
""
|
| 1444 |
+
]
|
| 1445 |
+
]
|
| 1446 |
+
},
|
| 1447 |
+
{
|
| 1448 |
+
"query": "What is the right to withdraw from a preclearance area, and what powers does a US preclearance officer have in Canada?",
|
| 1449 |
+
"answers": [
|
| 1450 |
+
[
|
| 1451 |
+
"Preclearance Act",
|
| 1452 |
+
""
|
| 1453 |
+
]
|
| 1454 |
+
]
|
| 1455 |
+
},
|
| 1456 |
+
{
|
| 1457 |
+
"query": "What does WAPPRIITA prohibit for international trade in protected wild animals and plants?",
|
| 1458 |
+
"answers": [
|
| 1459 |
+
[
|
| 1460 |
+
"WAPPRIITA",
|
| 1461 |
+
""
|
| 1462 |
+
]
|
| 1463 |
+
],
|
| 1464 |
+
"holdout": true
|
| 1465 |
+
},
|
| 1466 |
+
{
|
| 1467 |
+
"query": "How must imported goods be reported to a customs officer?",
|
| 1468 |
+
"answers": [
|
| 1469 |
+
[
|
| 1470 |
+
"Reporting of Imported Goods Regs",
|
| 1471 |
+
""
|
| 1472 |
+
],
|
| 1473 |
+
[
|
| 1474 |
+
"Customs Act",
|
| 1475 |
+
"12"
|
| 1476 |
+
]
|
| 1477 |
+
]
|
| 1478 |
+
},
|
| 1479 |
+
{
|
| 1480 |
+
"query": "Returning Persons Exemption Regulations prescribed value of goods for a returning resident",
|
| 1481 |
+
"answers": [
|
| 1482 |
+
[
|
| 1483 |
+
"Returning Persons Exemption Regs",
|
| 1484 |
+
""
|
| 1485 |
+
],
|
| 1486 |
+
[
|
| 1487 |
+
"D-Memo",
|
| 1488 |
+
"D2-3-1"
|
| 1489 |
+
]
|
| 1490 |
+
]
|
| 1491 |
+
},
|
| 1492 |
+
{
|
| 1493 |
+
"query": "What tariff item covers goods acquired abroad by a returning resident of Canada for personal use?",
|
| 1494 |
+
"answers": [
|
| 1495 |
+
[
|
| 1496 |
+
"Customs Tariff",
|
| 1497 |
+
"Sch-98.04"
|
| 1498 |
+
]
|
| 1499 |
+
]
|
| 1500 |
+
},
|
| 1501 |
+
{
|
| 1502 |
+
"query": "Under what tariff heading are a settler's household effects imported free of duty?",
|
| 1503 |
+
"answers": [
|
| 1504 |
+
[
|
| 1505 |
+
"Customs Tariff",
|
| 1506 |
+
"Sch-98.07"
|
| 1507 |
+
]
|
| 1508 |
+
]
|
| 1509 |
+
},
|
| 1510 |
+
{
|
| 1511 |
+
"query": "Can goods classified in Chapter 99 also qualify under Chapter 98 of the tariff schedule?",
|
| 1512 |
+
"answers": [
|
| 1513 |
+
[
|
| 1514 |
+
"Customs Tariff",
|
| 1515 |
+
"Sch-Ch98-Notes"
|
| 1516 |
+
],
|
| 1517 |
+
[
|
| 1518 |
+
"Customs Tariff",
|
| 1519 |
+
"Sch-Ch99-Notes"
|
| 1520 |
+
]
|
| 1521 |
+
]
|
| 1522 |
+
},
|
| 1523 |
+
{
|
| 1524 |
+
"query": "What does the public service health care plan cover for out-of-province emergency travel?",
|
| 1525 |
+
"answers": [
|
| 1526 |
+
[
|
| 1527 |
+
"PSHCP",
|
| 1528 |
+
""
|
| 1529 |
+
]
|
| 1530 |
+
],
|
| 1531 |
+
"holdout": true
|
| 1532 |
+
},
|
| 1533 |
+
{
|
| 1534 |
+
"query": "Can border officers search a traveller's cell phone without any grounds under the Customs Act?",
|
| 1535 |
+
"answers": [
|
| 1536 |
+
[
|
| 1537 |
+
"Canfield",
|
| 1538 |
+
""
|
| 1539 |
+
],
|
| 1540 |
+
[
|
| 1541 |
+
"Pike",
|
| 1542 |
+
""
|
| 1543 |
+
]
|
| 1544 |
+
]
|
| 1545 |
+
},
|
| 1546 |
+
{
|
| 1547 |
+
"query": "What limits did the Supreme Court place on searching a cell phone incident to arrest?",
|
| 1548 |
+
"answers": [
|
| 1549 |
+
[
|
| 1550 |
+
"Fearon",
|
| 1551 |
+
""
|
| 1552 |
+
]
|
| 1553 |
+
]
|
| 1554 |
+
},
|
| 1555 |
+
{
|
| 1556 |
+
"query": "What did the Ontario Court of Appeal decide about suspicionless searches of personal electronic devices at the border?",
|
| 1557 |
+
"answers": [
|
| 1558 |
+
[
|
| 1559 |
+
"Pike",
|
| 1560 |
+
""
|
| 1561 |
+
]
|
| 1562 |
+
]
|
| 1563 |
+
},
|
| 1564 |
+
{
|
| 1565 |
+
"query": "How does the RAD jurisprudential guide interpret the generalized risk exception for gang extortion claims?",
|
| 1566 |
+
"answers": [
|
| 1567 |
+
[
|
| 1568 |
+
"TC1-05038",
|
| 1569 |
+
""
|
| 1570 |
+
]
|
| 1571 |
+
]
|
| 1572 |
+
},
|
| 1573 |
+
{
|
| 1574 |
+
"query": "Which CBSA officers are designated to form an inadmissibility report under IRPA section 44?",
|
| 1575 |
+
"answers": [
|
| 1576 |
+
[
|
| 1577 |
+
"CBSA Deleg 2023-05-08",
|
| 1578 |
+
""
|
| 1579 |
+
],
|
| 1580 |
+
[
|
| 1581 |
+
"CBSA Deleg Amend 2025-07-10",
|
| 1582 |
+
""
|
| 1583 |
+
],
|
| 1584 |
+
[
|
| 1585 |
+
"CBSA Deleg Amend 2023-09-08",
|
| 1586 |
+
""
|
| 1587 |
+
]
|
| 1588 |
+
]
|
| 1589 |
+
},
|
| 1590 |
+
{
|
| 1591 |
+
"query": "Is a Texas deferred adjudication a conviction for Canadian immigration purposes?",
|
| 1592 |
+
"answers": [
|
| 1593 |
+
[
|
| 1594 |
+
"US Dispositions Helper",
|
| 1595 |
+
"deferred-adjudication"
|
| 1596 |
+
],
|
| 1597 |
+
[
|
| 1598 |
+
"US Dispositions Helper",
|
| 1599 |
+
"state-texas"
|
| 1600 |
+
]
|
| 1601 |
+
],
|
| 1602 |
+
"holdout": true
|
| 1603 |
+
},
|
| 1604 |
+
{
|
| 1605 |
+
"query": "Does a Florida withheld adjudication make someone inadmissible to Canada?",
|
| 1606 |
+
"answers": [
|
| 1607 |
+
[
|
| 1608 |
+
"US Dispositions Helper",
|
| 1609 |
+
"withheld-adjudication"
|
| 1610 |
+
],
|
| 1611 |
+
[
|
| 1612 |
+
"US Dispositions Helper",
|
| 1613 |
+
"state-florida"
|
| 1614 |
+
]
|
| 1615 |
+
]
|
| 1616 |
+
},
|
| 1617 |
+
{
|
| 1618 |
+
"query": "How does Canada treat a US state pardon for immigration inadmissibility?",
|
| 1619 |
+
"answers": [
|
| 1620 |
+
[
|
| 1621 |
+
"US Dispositions Helper",
|
| 1622 |
+
"state-pardon"
|
| 1623 |
+
]
|
| 1624 |
+
]
|
| 1625 |
+
},
|
| 1626 |
+
{
|
| 1627 |
+
"query": "What US criminal dispositions does Georgia law treat as non-convictions?",
|
| 1628 |
+
"answers": [
|
| 1629 |
+
[
|
| 1630 |
+
"US Dispositions Helper",
|
| 1631 |
+
"state-georgia"
|
| 1632 |
+
]
|
| 1633 |
+
]
|
| 1634 |
+
},
|
| 1635 |
+
{
|
| 1636 |
+
"query": "Is a juvenile delinquency adjudication in the United States a conviction under IRPA?",
|
| 1637 |
+
"answers": [
|
| 1638 |
+
[
|
| 1639 |
+
"US Dispositions Helper",
|
| 1640 |
+
"juvenile-adjudication"
|
| 1641 |
+
]
|
| 1642 |
+
]
|
| 1643 |
+
},
|
| 1644 |
+
{
|
| 1645 |
+
"query": "What is the standard of proof for criminal inadmissibility findings under IRPA?",
|
| 1646 |
+
"answers": [
|
| 1647 |
+
[
|
| 1648 |
+
"IRPA",
|
| 1649 |
+
"33"
|
| 1650 |
+
],
|
| 1651 |
+
[
|
| 1652 |
+
"US Dispositions Helper",
|
| 1653 |
+
"conviction-framework"
|
| 1654 |
+
]
|
| 1655 |
+
]
|
| 1656 |
+
},
|
| 1657 |
+
{
|
| 1658 |
+
"query": "Does a single US DUI conviction make a person inadmissible to Canada for serious criminality?",
|
| 1659 |
+
"answers": [
|
| 1660 |
+
[
|
| 1661 |
+
"US Dispositions Helper",
|
| 1662 |
+
"equiv-dui"
|
| 1663 |
+
]
|
| 1664 |
+
],
|
| 1665 |
+
"holdout": true
|
| 1666 |
+
},
|
| 1667 |
+
{
|
| 1668 |
+
"query": "What does a US shoplifting conviction equate to under Canadian law for inadmissibility?",
|
| 1669 |
+
"answers": [
|
| 1670 |
+
[
|
| 1671 |
+
"US Dispositions Helper",
|
| 1672 |
+
"equiv-theft-under"
|
| 1673 |
+
]
|
| 1674 |
+
]
|
| 1675 |
+
},
|
| 1676 |
+
{
|
| 1677 |
+
"query": "Is an old US marijuana possession conviction still a ground of inadmissibility to Canada?",
|
| 1678 |
+
"answers": [
|
| 1679 |
+
[
|
| 1680 |
+
"US Dispositions Helper",
|
| 1681 |
+
"equiv-cannabis-possession"
|
| 1682 |
+
]
|
| 1683 |
+
]
|
| 1684 |
+
},
|
| 1685 |
+
{
|
| 1686 |
+
"query": "Does one disorderly conduct conviction make a foreign national criminally inadmissible?",
|
| 1687 |
+
"answers": [
|
| 1688 |
+
[
|
| 1689 |
+
"US Dispositions Helper",
|
| 1690 |
+
"equiv-disorderly-conduct"
|
| 1691 |
+
]
|
| 1692 |
+
]
|
| 1693 |
+
},
|
| 1694 |
+
{
|
| 1695 |
+
"query": "When is a person deemed rehabilitated after a single foreign conviction?",
|
| 1696 |
+
"answers": [
|
| 1697 |
+
[
|
| 1698 |
+
"IRPR",
|
| 1699 |
+
"18"
|
| 1700 |
+
]
|
| 1701 |
+
]
|
| 1702 |
+
},
|
| 1703 |
+
{
|
| 1704 |
+
"query": "How long after completing a sentence can someone apply for criminal rehabilitation?",
|
| 1705 |
+
"answers": [
|
| 1706 |
+
[
|
| 1707 |
+
"IRPR",
|
| 1708 |
+
"17"
|
| 1709 |
+
]
|
| 1710 |
+
]
|
| 1711 |
+
},
|
| 1712 |
+
{
|
| 1713 |
+
"query": "Traveller referred to secondary arriving from Amsterdam; a white powder in their bag field-tests as ketamine. Which CDSA schedule lists ketamine and what does the possession offence say?",
|
| 1714 |
+
"answers": [
|
| 1715 |
+
[
|
| 1716 |
+
"CDSA",
|
| 1717 |
+
"Schedule I"
|
| 1718 |
+
],
|
| 1719 |
+
[
|
| 1720 |
+
"CDSA",
|
| 1721 |
+
"4"
|
| 1722 |
+
]
|
| 1723 |
+
],
|
| 1724 |
+
"style": "fact-pattern"
|
| 1725 |
+
},
|
| 1726 |
+
{
|
| 1727 |
+
"query": "Secondary examination turns up a 2019 Texas deferred adjudication for misdemeanor theft, successfully completed in 2021. Is the traveller criminally inadmissible on the conviction branch?",
|
| 1728 |
+
"answers": [
|
| 1729 |
+
[
|
| 1730 |
+
"IRPA",
|
| 1731 |
+
"36"
|
| 1732 |
+
],
|
| 1733 |
+
[
|
| 1734 |
+
"US Dispositions Helper",
|
| 1735 |
+
""
|
| 1736 |
+
]
|
| 1737 |
+
],
|
| 1738 |
+
"style": "fact-pattern"
|
| 1739 |
+
},
|
| 1740 |
+
{
|
| 1741 |
+
"query": "A permanent resident was detained at the port of entry on Tuesday morning. When must the Immigration Division review the detention, and when are the later reviews?",
|
| 1742 |
+
"answers": [
|
| 1743 |
+
[
|
| 1744 |
+
"IRPA",
|
| 1745 |
+
"57"
|
| 1746 |
+
]
|
| 1747 |
+
],
|
| 1748 |
+
"style": "fact-pattern"
|
| 1749 |
+
},
|
| 1750 |
+
{
|
| 1751 |
+
"query": "Traveller declares nothing but a currency detector dog indicates; officers find US$12,000 in an envelope. What reporting duty was breached and what can be seized?",
|
| 1752 |
+
"answers": [
|
| 1753 |
+
[
|
| 1754 |
+
"PCMLTFA",
|
| 1755 |
+
"12"
|
| 1756 |
+
],
|
| 1757 |
+
[
|
| 1758 |
+
"PCMLTFA",
|
| 1759 |
+
"18"
|
| 1760 |
+
]
|
| 1761 |
+
],
|
| 1762 |
+
"style": "fact-pattern"
|
| 1763 |
+
},
|
| 1764 |
+
{
|
| 1765 |
+
"query": "A NEXUS member used the members-only lane while transporting a firearm part they failed to declare. What regulations govern presentation in an alternative manner and cancellation of the authorization?",
|
| 1766 |
+
"answers": [
|
| 1767 |
+
[
|
| 1768 |
+
"Presentation of Persons Regs",
|
| 1769 |
+
""
|
| 1770 |
+
]
|
| 1771 |
+
],
|
| 1772 |
+
"style": "fact-pattern"
|
| 1773 |
+
},
|
| 1774 |
+
{
|
| 1775 |
+
"query": "Baggage exam finds brass knuckles bought as a souvenir. Where are they classified as a prohibited weapon?",
|
| 1776 |
+
"answers": [
|
| 1777 |
+
[
|
| 1778 |
+
"Firearms Classification Regs",
|
| 1779 |
+
""
|
| 1780 |
+
]
|
| 1781 |
+
],
|
| 1782 |
+
"style": "fact-pattern"
|
| 1783 |
+
},
|
| 1784 |
+
{
|
| 1785 |
+
"query": "A traveller detained for a strip search at secondary asks to call a lawyer before it happens. What Charter right is engaged?",
|
| 1786 |
+
"answers": [
|
| 1787 |
+
[
|
| 1788 |
+
"Charter",
|
| 1789 |
+
"10"
|
| 1790 |
+
],
|
| 1791 |
+
[
|
| 1792 |
+
"Dehghani",
|
| 1793 |
+
""
|
| 1794 |
+
]
|
| 1795 |
+
],
|
| 1796 |
+
"style": "fact-pattern"
|
| 1797 |
+
},
|
| 1798 |
+
{
|
| 1799 |
+
"query": "An officer believes a foreign national in secondary is inadmissible for serious criminality and wants to write the report. Which operational manual covers writing A44(1) reports?",
|
| 1800 |
+
"answers": [
|
| 1801 |
+
[
|
| 1802 |
+
"ENF 5",
|
| 1803 |
+
""
|
| 1804 |
+
]
|
| 1805 |
+
],
|
| 1806 |
+
"style": "fact-pattern"
|
| 1807 |
+
},
|
| 1808 |
+
{
|
| 1809 |
+
"query": "currency seizure threshold",
|
| 1810 |
+
"answers": [
|
| 1811 |
+
[
|
| 1812 |
+
"PCMLTFA",
|
| 1813 |
+
"12"
|
| 1814 |
+
],
|
| 1815 |
+
[
|
| 1816 |
+
"PCMLTFA",
|
| 1817 |
+
"18"
|
| 1818 |
+
]
|
| 1819 |
+
],
|
| 1820 |
+
"style": "keyword"
|
| 1821 |
+
},
|
| 1822 |
+
{
|
| 1823 |
+
"query": "detention review timelines",
|
| 1824 |
+
"answers": [
|
| 1825 |
+
[
|
| 1826 |
+
"IRPA",
|
| 1827 |
+
"57"
|
| 1828 |
+
]
|
| 1829 |
+
],
|
| 1830 |
+
"style": "keyword"
|
| 1831 |
+
},
|
| 1832 |
+
{
|
| 1833 |
+
"query": "ketamine schedule",
|
| 1834 |
+
"answers": [
|
| 1835 |
+
[
|
| 1836 |
+
"CDSA",
|
| 1837 |
+
"Schedule I"
|
| 1838 |
+
]
|
| 1839 |
+
],
|
| 1840 |
+
"style": "keyword"
|
| 1841 |
+
},
|
| 1842 |
+
{
|
| 1843 |
+
"query": "personal exemption returning resident",
|
| 1844 |
+
"answers": [
|
| 1845 |
+
[
|
| 1846 |
+
"Customs Tariff",
|
| 1847 |
+
""
|
| 1848 |
+
],
|
| 1849 |
+
[
|
| 1850 |
+
"D-Memo",
|
| 1851 |
+
"D2-3-1"
|
| 1852 |
+
]
|
| 1853 |
+
],
|
| 1854 |
+
"style": "keyword"
|
| 1855 |
+
},
|
| 1856 |
+
{
|
| 1857 |
+
"query": "AMPS contravention penalty amounts",
|
| 1858 |
+
"answers": [
|
| 1859 |
+
[
|
| 1860 |
+
"AMPS",
|
| 1861 |
+
""
|
| 1862 |
+
]
|
| 1863 |
+
],
|
| 1864 |
+
"style": "keyword"
|
| 1865 |
+
},
|
| 1866 |
+
{
|
| 1867 |
+
"query": "IAD appeal rules",
|
| 1868 |
+
"answers": [
|
| 1869 |
+
[
|
| 1870 |
+
"IAD Rules",
|
| 1871 |
+
""
|
| 1872 |
+
],
|
| 1873 |
+
[
|
| 1874 |
+
"ENF 19",
|
| 1875 |
+
""
|
| 1876 |
+
]
|
| 1877 |
+
],
|
| 1878 |
+
"style": "keyword"
|
| 1879 |
+
},
|
| 1880 |
+
{
|
| 1881 |
+
"query": "detension review timlines permanent resident",
|
| 1882 |
+
"answers": [
|
| 1883 |
+
[
|
| 1884 |
+
"IRPA",
|
| 1885 |
+
"57"
|
| 1886 |
+
]
|
| 1887 |
+
],
|
| 1888 |
+
"style": "typo"
|
| 1889 |
+
},
|
| 1890 |
+
{
|
| 1891 |
+
"query": "innadmissibility for serius criminality",
|
| 1892 |
+
"answers": [
|
| 1893 |
+
[
|
| 1894 |
+
"IRPA",
|
| 1895 |
+
"36"
|
| 1896 |
+
]
|
| 1897 |
+
],
|
| 1898 |
+
"style": "typo"
|
| 1899 |
+
},
|
| 1900 |
+
{
|
| 1901 |
+
"query": "seisure of undeclraed goods",
|
| 1902 |
+
"answers": [
|
| 1903 |
+
[
|
| 1904 |
+
"Customs Act",
|
| 1905 |
+
"110"
|
| 1906 |
+
]
|
| 1907 |
+
],
|
| 1908 |
+
"style": "typo"
|
| 1909 |
+
},
|
| 1910 |
+
{
|
| 1911 |
+
"query": "criminal rehabilitaton aplication period",
|
| 1912 |
+
"answers": [
|
| 1913 |
+
[
|
| 1914 |
+
"IRPR",
|
| 1915 |
+
"17"
|
| 1916 |
+
]
|
| 1917 |
+
],
|
| 1918 |
+
"style": "typo"
|
| 1919 |
+
},
|
| 1920 |
+
{
|
| 1921 |
+
"query": "délai de contrôle de la détention d'un résident permanent",
|
| 1922 |
+
"answers": [
|
| 1923 |
+
[
|
| 1924 |
+
"IRPA",
|
| 1925 |
+
"57"
|
| 1926 |
+
]
|
| 1927 |
+
],
|
| 1928 |
+
"style": "french"
|
| 1929 |
+
},
|
| 1930 |
+
{
|
| 1931 |
+
"query": "interdiction de territoire pour grande criminalité",
|
| 1932 |
+
"answers": [
|
| 1933 |
+
[
|
| 1934 |
+
"IRPA",
|
| 1935 |
+
"36"
|
| 1936 |
+
]
|
| 1937 |
+
],
|
| 1938 |
+
"style": "french"
|
| 1939 |
+
},
|
| 1940 |
+
{
|
| 1941 |
+
"query": "saisie de devises non déclarées à la frontière",
|
| 1942 |
+
"answers": [
|
| 1943 |
+
[
|
| 1944 |
+
"PCMLTFA",
|
| 1945 |
+
"18"
|
| 1946 |
+
]
|
| 1947 |
+
],
|
| 1948 |
+
"style": "french"
|
| 1949 |
+
},
|
| 1950 |
+
{
|
| 1951 |
+
"query": "renvoi d'un étranger interdit de territoire",
|
| 1952 |
+
"answers": [
|
| 1953 |
+
[
|
| 1954 |
+
"IRPA",
|
| 1955 |
+
"48"
|
| 1956 |
+
],
|
| 1957 |
+
[
|
| 1958 |
+
"ENF 10",
|
| 1959 |
+
""
|
| 1960 |
+
]
|
| 1961 |
+
],
|
| 1962 |
+
"style": "french"
|
| 1963 |
+
},
|
| 1964 |
+
{
|
| 1965 |
+
"query": "fouille d'une personne à la frontière par l'agent des douanes",
|
| 1966 |
+
"answers": [
|
| 1967 |
+
[
|
| 1968 |
+
"Customs Act",
|
| 1969 |
+
"98"
|
| 1970 |
+
]
|
| 1971 |
+
],
|
| 1972 |
+
"style": "french"
|
| 1973 |
+
},
|
| 1974 |
+
{
|
| 1975 |
+
"query": "How many demerit points for speeding in Ontario?",
|
| 1976 |
+
"answers": [],
|
| 1977 |
+
"style": "no-answer"
|
| 1978 |
+
},
|
| 1979 |
+
{
|
| 1980 |
+
"query": "What is the process to renew a US H-1B visa?",
|
| 1981 |
+
"answers": [],
|
| 1982 |
+
"style": "no-answer"
|
| 1983 |
+
},
|
| 1984 |
+
{
|
| 1985 |
+
"query": "How is child custody decided after divorce in Quebec?",
|
| 1986 |
+
"answers": [],
|
| 1987 |
+
"style": "no-answer"
|
| 1988 |
+
},
|
| 1989 |
+
{
|
| 1990 |
+
"query": "What notice must an Ontario landlord give to evict a tenant?",
|
| 1991 |
+
"answers": [],
|
| 1992 |
+
"style": "no-answer"
|
| 1993 |
+
},
|
| 1994 |
+
{
|
| 1995 |
+
"query": "What do the CIU by-laws say about how branch elections are conducted?",
|
| 1996 |
+
"answers": [
|
| 1997 |
+
[
|
| 1998 |
+
"CIU By-Laws",
|
| 1999 |
+
""
|
| 2000 |
+
],
|
| 2001 |
+
[
|
| 2002 |
+
"CIU Elections Kit",
|
| 2003 |
+
""
|
| 2004 |
+
],
|
| 2005 |
+
[
|
| 2006 |
+
"CIU Branch Rules",
|
| 2007 |
+
""
|
| 2008 |
+
]
|
| 2009 |
+
],
|
| 2010 |
+
"style": "union"
|
| 2011 |
+
},
|
| 2012 |
+
{
|
| 2013 |
+
"query": "What is the variable shift schedule agreement for Windsor Bridge?",
|
| 2014 |
+
"answers": [
|
| 2015 |
+
[
|
| 2016 |
+
"VSSA",
|
| 2017 |
+
""
|
| 2018 |
+
]
|
| 2019 |
+
],
|
| 2020 |
+
"style": "union"
|
| 2021 |
+
},
|
| 2022 |
+
{
|
| 2023 |
+
"query": "How does the PSAC constitution deal with membership discipline and expulsion?",
|
| 2024 |
+
"answers": [
|
| 2025 |
+
[
|
| 2026 |
+
"PSAC Constitution",
|
| 2027 |
+
""
|
| 2028 |
+
]
|
| 2029 |
+
],
|
| 2030 |
+
"style": "union"
|
| 2031 |
+
},
|
| 2032 |
+
{
|
| 2033 |
+
"query": "What are the timelines for presenting a grievance according to the union's grievance guide?",
|
| 2034 |
+
"answers": [
|
| 2035 |
+
[
|
| 2036 |
+
"CIU Grievance Guide",
|
| 2037 |
+
""
|
| 2038 |
+
]
|
| 2039 |
+
],
|
| 2040 |
+
"style": "union"
|
| 2041 |
+
}
|
| 2042 |
+
]
|
data/eval/sweep.json
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"best": {
|
| 3 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 4 |
+
"CANLEX_MN_CAP": 0.012,
|
| 5 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 6 |
+
"CANLEX_BACKMATTER_PENALTY": 0.004
|
| 7 |
+
},
|
| 8 |
+
"best_metrics": {
|
| 9 |
+
"hit1": 0.79,
|
| 10 |
+
"hit3": 0.96,
|
| 11 |
+
"hit5": 0.99,
|
| 12 |
+
"hit10": 0.99,
|
| 13 |
+
"mrr": 0.88
|
| 14 |
+
},
|
| 15 |
+
"runs": [
|
| 16 |
+
{
|
| 17 |
+
"values": {
|
| 18 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 19 |
+
"CANLEX_MN_CAP": 0.012,
|
| 20 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 21 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 22 |
+
},
|
| 23 |
+
"metrics": {
|
| 24 |
+
"hit1": 0.79,
|
| 25 |
+
"hit3": 0.96,
|
| 26 |
+
"hit5": 0.98,
|
| 27 |
+
"hit10": 0.99,
|
| 28 |
+
"mrr": 0.87
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"values": {
|
| 33 |
+
"CANLEX_MN_WEIGHT": 0.0012,
|
| 34 |
+
"CANLEX_MN_CAP": 0.012,
|
| 35 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 36 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 37 |
+
},
|
| 38 |
+
"metrics": {
|
| 39 |
+
"hit1": 0.78,
|
| 40 |
+
"hit3": 0.94,
|
| 41 |
+
"hit5": 0.98,
|
| 42 |
+
"hit10": 0.99,
|
| 43 |
+
"mrr": 0.86
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"values": {
|
| 48 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 49 |
+
"CANLEX_MN_CAP": 0.012,
|
| 50 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 51 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 52 |
+
},
|
| 53 |
+
"metrics": {
|
| 54 |
+
"hit1": 0.79,
|
| 55 |
+
"hit3": 0.96,
|
| 56 |
+
"hit5": 0.98,
|
| 57 |
+
"hit10": 0.99,
|
| 58 |
+
"mrr": 0.87
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"values": {
|
| 63 |
+
"CANLEX_MN_WEIGHT": 0.005,
|
| 64 |
+
"CANLEX_MN_CAP": 0.012,
|
| 65 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 66 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 67 |
+
},
|
| 68 |
+
"metrics": {
|
| 69 |
+
"hit1": 0.78,
|
| 70 |
+
"hit3": 0.94,
|
| 71 |
+
"hit5": 0.97,
|
| 72 |
+
"hit10": 0.99,
|
| 73 |
+
"mrr": 0.86
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"values": {
|
| 78 |
+
"CANLEX_MN_WEIGHT": 0.01,
|
| 79 |
+
"CANLEX_MN_CAP": 0.012,
|
| 80 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 81 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 82 |
+
},
|
| 83 |
+
"metrics": {
|
| 84 |
+
"hit1": 0.75,
|
| 85 |
+
"hit3": 0.94,
|
| 86 |
+
"hit5": 0.96,
|
| 87 |
+
"hit10": 0.99,
|
| 88 |
+
"mrr": 0.84
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"values": {
|
| 93 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 94 |
+
"CANLEX_MN_CAP": 0.006,
|
| 95 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 96 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 97 |
+
},
|
| 98 |
+
"metrics": {
|
| 99 |
+
"hit1": 0.77,
|
| 100 |
+
"hit3": 0.93,
|
| 101 |
+
"hit5": 0.96,
|
| 102 |
+
"hit10": 0.99,
|
| 103 |
+
"mrr": 0.85
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"values": {
|
| 108 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 109 |
+
"CANLEX_MN_CAP": 0.012,
|
| 110 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 111 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 112 |
+
},
|
| 113 |
+
"metrics": {
|
| 114 |
+
"hit1": 0.79,
|
| 115 |
+
"hit3": 0.96,
|
| 116 |
+
"hit5": 0.98,
|
| 117 |
+
"hit10": 0.99,
|
| 118 |
+
"mrr": 0.87
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"values": {
|
| 123 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 124 |
+
"CANLEX_MN_CAP": 0.024,
|
| 125 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 126 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 127 |
+
},
|
| 128 |
+
"metrics": {
|
| 129 |
+
"hit1": 0.77,
|
| 130 |
+
"hit3": 0.94,
|
| 131 |
+
"hit5": 0.98,
|
| 132 |
+
"hit10": 0.99,
|
| 133 |
+
"mrr": 0.86
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"values": {
|
| 138 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 139 |
+
"CANLEX_MN_CAP": 0.05,
|
| 140 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 141 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 142 |
+
},
|
| 143 |
+
"metrics": {
|
| 144 |
+
"hit1": 0.77,
|
| 145 |
+
"hit3": 0.94,
|
| 146 |
+
"hit5": 0.97,
|
| 147 |
+
"hit10": 0.99,
|
| 148 |
+
"mrr": 0.86
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"values": {
|
| 153 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 154 |
+
"CANLEX_MN_CAP": 0.012,
|
| 155 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 156 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 157 |
+
},
|
| 158 |
+
"metrics": {
|
| 159 |
+
"hit1": 0.79,
|
| 160 |
+
"hit3": 0.96,
|
| 161 |
+
"hit5": 0.98,
|
| 162 |
+
"hit10": 0.99,
|
| 163 |
+
"mrr": 0.88
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"values": {
|
| 168 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 169 |
+
"CANLEX_MN_CAP": 0.012,
|
| 170 |
+
"CANLEX_REG_PENALTY": 0.008,
|
| 171 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 172 |
+
},
|
| 173 |
+
"metrics": {
|
| 174 |
+
"hit1": 0.79,
|
| 175 |
+
"hit3": 0.96,
|
| 176 |
+
"hit5": 0.98,
|
| 177 |
+
"hit10": 0.99,
|
| 178 |
+
"mrr": 0.87
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"values": {
|
| 183 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 184 |
+
"CANLEX_MN_CAP": 0.012,
|
| 185 |
+
"CANLEX_REG_PENALTY": 0.016,
|
| 186 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 187 |
+
},
|
| 188 |
+
"metrics": {
|
| 189 |
+
"hit1": 0.79,
|
| 190 |
+
"hit3": 0.96,
|
| 191 |
+
"hit5": 0.98,
|
| 192 |
+
"hit10": 0.99,
|
| 193 |
+
"mrr": 0.88
|
| 194 |
+
}
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"values": {
|
| 198 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 199 |
+
"CANLEX_MN_CAP": 0.012,
|
| 200 |
+
"CANLEX_REG_PENALTY": 0.032,
|
| 201 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 202 |
+
},
|
| 203 |
+
"metrics": {
|
| 204 |
+
"hit1": 0.79,
|
| 205 |
+
"hit3": 0.96,
|
| 206 |
+
"hit5": 0.98,
|
| 207 |
+
"hit10": 0.99,
|
| 208 |
+
"mrr": 0.88
|
| 209 |
+
}
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"values": {
|
| 213 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 214 |
+
"CANLEX_MN_CAP": 0.012,
|
| 215 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 216 |
+
"CANLEX_BACKMATTER_PENALTY": 0.004
|
| 217 |
+
},
|
| 218 |
+
"metrics": {
|
| 219 |
+
"hit1": 0.79,
|
| 220 |
+
"hit3": 0.96,
|
| 221 |
+
"hit5": 0.99,
|
| 222 |
+
"hit10": 0.99,
|
| 223 |
+
"mrr": 0.88
|
| 224 |
+
}
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"values": {
|
| 228 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 229 |
+
"CANLEX_MN_CAP": 0.012,
|
| 230 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 231 |
+
"CANLEX_BACKMATTER_PENALTY": 0.008
|
| 232 |
+
},
|
| 233 |
+
"metrics": {
|
| 234 |
+
"hit1": 0.79,
|
| 235 |
+
"hit3": 0.96,
|
| 236 |
+
"hit5": 0.98,
|
| 237 |
+
"hit10": 0.99,
|
| 238 |
+
"mrr": 0.88
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"values": {
|
| 243 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 244 |
+
"CANLEX_MN_CAP": 0.012,
|
| 245 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 246 |
+
"CANLEX_BACKMATTER_PENALTY": 0.016
|
| 247 |
+
},
|
| 248 |
+
"metrics": {
|
| 249 |
+
"hit1": 0.79,
|
| 250 |
+
"hit3": 0.96,
|
| 251 |
+
"hit5": 0.98,
|
| 252 |
+
"hit10": 0.99,
|
| 253 |
+
"mrr": 0.87
|
| 254 |
+
}
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
"values": {
|
| 258 |
+
"CANLEX_MN_WEIGHT": 0.0024,
|
| 259 |
+
"CANLEX_MN_CAP": 0.012,
|
| 260 |
+
"CANLEX_REG_PENALTY": 0.004,
|
| 261 |
+
"CANLEX_BACKMATTER_PENALTY": 0.032
|
| 262 |
+
},
|
| 263 |
+
"metrics": {
|
| 264 |
+
"hit1": 0.79,
|
| 265 |
+
"hit3": 0.96,
|
| 266 |
+
"hit5": 0.99,
|
| 267 |
+
"hit10": 0.99,
|
| 268 |
+
"mrr": 0.87
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
]
|
| 272 |
+
}
|
data/eval/sweep.log
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CanLex tuning sweep -- 2026-05-23 12:59:35
|
| 2 |
+
|
| 3 |
+
Baseline: {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008}
|
| 4 |
+
Hit@5=0.980 MRR=0.870
|
| 5 |
+
|
| 6 |
+
Sweeping CANLEX_MN_WEIGHT in [0.0012, 0.0024, 0.005, 0.01] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
|
| 7 |
+
CANLEX_MN_WEIGHT=0.0012 -> Hit@1=0.780 Hit@3=0.940 Hit@5=0.980 Hit@10=0.990 MRR=0.860
|
| 8 |
+
CANLEX_MN_WEIGHT=0.0024 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
|
| 9 |
+
CANLEX_MN_WEIGHT=0.005 -> Hit@1=0.780 Hit@3=0.940 Hit@5=0.970 Hit@10=0.990 MRR=0.860
|
| 10 |
+
CANLEX_MN_WEIGHT=0.01 -> Hit@1=0.750 Hit@3=0.940 Hit@5=0.960 Hit@10=0.990 MRR=0.840
|
| 11 |
+
|
| 12 |
+
Sweeping CANLEX_MN_CAP in [0.006, 0.012, 0.024, 0.05] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
|
| 13 |
+
CANLEX_MN_CAP=0.006 -> Hit@1=0.770 Hit@3=0.930 Hit@5=0.960 Hit@10=0.990 MRR=0.850
|
| 14 |
+
CANLEX_MN_CAP=0.012 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
|
| 15 |
+
CANLEX_MN_CAP=0.024 -> Hit@1=0.770 Hit@3=0.940 Hit@5=0.980 Hit@10=0.990 MRR=0.860
|
| 16 |
+
CANLEX_MN_CAP=0.05 -> Hit@1=0.770 Hit@3=0.940 Hit@5=0.970 Hit@10=0.990 MRR=0.860
|
| 17 |
+
|
| 18 |
+
Sweeping CANLEX_REG_PENALTY in [0.004, 0.008, 0.016, 0.032] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.008, 'CANLEX_BACKMATTER_PENALTY': 0.008})
|
| 19 |
+
CANLEX_REG_PENALTY=0.004 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
|
| 20 |
+
CANLEX_REG_PENALTY=0.008 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
|
| 21 |
+
CANLEX_REG_PENALTY=0.016 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
|
| 22 |
+
CANLEX_REG_PENALTY=0.032 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
|
| 23 |
+
! CANLEX_REG_PENALTY: 0.008 -> 0.004
|
| 24 |
+
|
| 25 |
+
Sweeping CANLEX_BACKMATTER_PENALTY in [0.004, 0.008, 0.016, 0.032] (others held at {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.004, 'CANLEX_BACKMATTER_PENALTY': 0.008})
|
| 26 |
+
CANLEX_BACKMATTER_PENALTY=0.004 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.990 Hit@10=0.990 MRR=0.880
|
| 27 |
+
CANLEX_BACKMATTER_PENALTY=0.008 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.880
|
| 28 |
+
CANLEX_BACKMATTER_PENALTY=0.016 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.980 Hit@10=0.990 MRR=0.870
|
| 29 |
+
CANLEX_BACKMATTER_PENALTY=0.032 -> Hit@1=0.790 Hit@3=0.960 Hit@5=0.990 Hit@10=0.990 MRR=0.870
|
| 30 |
+
! CANLEX_BACKMATTER_PENALTY: 0.008 -> 0.004
|
| 31 |
+
|
| 32 |
+
Best: {'CANLEX_MN_WEIGHT': 0.0024, 'CANLEX_MN_CAP': 0.012, 'CANLEX_REG_PENALTY': 0.004, 'CANLEX_BACKMATTER_PENALTY': 0.004}
|
| 33 |
+
Hit@1=0.790 Hit@5=0.990 MRR=0.880
|
data/processed/A-8.8-fr.json
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"act_code": "A-8.8",
|
| 4 |
+
"act_short": "AAAMPA",
|
| 5 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 6 |
+
"section": "1",
|
| 7 |
+
"marginal_note": "Titre abrégé",
|
| 8 |
+
"part": "",
|
| 9 |
+
"division": "",
|
| 10 |
+
"heading": "Titre abrégé",
|
| 11 |
+
"history": "",
|
| 12 |
+
"last_amended": "2002-12-31",
|
| 13 |
+
"in_force": "2002-12-31",
|
| 14 |
+
"status": "in force",
|
| 15 |
+
"current_to": "2022-03-22",
|
| 16 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-1.html",
|
| 17 |
+
"id": "A-8.8-fr-s1",
|
| 18 |
+
"text": "1 Loi sur les sanctions administratives pécuniaires en matière d’agriculture et d’agroalimentaire.",
|
| 19 |
+
"citation": "AAAMPA, art. 1",
|
| 20 |
+
"lang": "fr"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"act_code": "A-8.8",
|
| 24 |
+
"act_short": "AAAMPA",
|
| 25 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 26 |
+
"section": "2",
|
| 27 |
+
"marginal_note": "Définitions",
|
| 28 |
+
"part": "",
|
| 29 |
+
"division": "",
|
| 30 |
+
"heading": "Définitions",
|
| 31 |
+
"history": "1995, ch. 40, art. 2; 1997, ch. 21, art. 30; 2002, ch. 28, art. 82; 2005, ch. 38, art. 30 et 145; 2012, ch. 24, art. 98; 2015, ch. 2, art. 113",
|
| 32 |
+
"last_amended": "2019-01-15",
|
| 33 |
+
"in_force": "2015-02-27",
|
| 34 |
+
"status": "in force",
|
| 35 |
+
"current_to": "2022-03-22",
|
| 36 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-2.html",
|
| 37 |
+
"id": "A-8.8-fr-s2",
|
| 38 |
+
"text": "2 Les définitions qui suivent s’appliquent à la présente loi.\nCommission La Commission de révision prorogée par le paragraphe 27(1). (Tribunal)\nloi agroalimentaire La Loi sur la médiation en matière d’endettement agricole, la Loi relative aux aliments du bétail, la Loi sur les engrais, la Loi sur la santé des animaux, la Loi sur les produits antiparasitaires, la Loi sur la protection des végétaux, la Loi sur la salubrité des aliments au Canada ou la Loi sur les semences. (agri-food Act)\nministre Le ministre de l’Agriculture et de l’Agroalimentaire ou :\na) le ministre de la Santé, pour toute violation relative à une contravention :\n(i) soit à la Loi sur les produits antiparasitaires,\n(ii) soit à une disposition portant sur la salubrité alimentaire dans une loi agroalimentaire ou dans un règlement pris en vertu d’une telle loi;\nb) le ministre de la Sécurité publique et de la Protection civile, pour toute question relative aux procès-verbaux relatifs aux contraventions à la législation frontalière visée au paragraphe 11(5) de la Loi sur l’Agence canadienne d’inspection des aliments. (Minister)\nsanction Sanction administrative pécuniaire infligée pour une violation. (penalty)",
|
| 39 |
+
"citation": "AAAMPA, art. 2",
|
| 40 |
+
"lang": "fr"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"act_code": "A-8.8",
|
| 44 |
+
"act_short": "AAAMPA",
|
| 45 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 46 |
+
"section": "3",
|
| 47 |
+
"marginal_note": "Principe",
|
| 48 |
+
"part": "",
|
| 49 |
+
"division": "",
|
| 50 |
+
"heading": "Objet",
|
| 51 |
+
"history": "",
|
| 52 |
+
"last_amended": "2002-12-31",
|
| 53 |
+
"in_force": "2002-12-31",
|
| 54 |
+
"status": "in force",
|
| 55 |
+
"current_to": "2022-03-22",
|
| 56 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-3.html",
|
| 57 |
+
"id": "A-8.8-fr-s3",
|
| 58 |
+
"text": "3 La présente loi a pour objet d’établir, comme solution de rechange au régime pénal et complément aux autres mesures d’application des lois agroalimentaires déjà en vigueur, un régime juste et efficace de sanctions administratives pécuniaires.",
|
| 59 |
+
"citation": "AAAMPA, art. 3",
|
| 60 |
+
"lang": "fr"
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"act_code": "A-8.8",
|
| 64 |
+
"act_short": "AAAMPA",
|
| 65 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 66 |
+
"section": "4",
|
| 67 |
+
"marginal_note": "Pouvoir réglementaire",
|
| 68 |
+
"part": "",
|
| 69 |
+
"division": "",
|
| 70 |
+
"heading": "Attributions du ministre",
|
| 71 |
+
"history": "1995, ch. 40, art. 4; 2012, ch. 24, art. 99; 2015, ch. 2, art. 114; 2016, ch. 9, art. 70 et 72",
|
| 72 |
+
"last_amended": "2019-01-15",
|
| 73 |
+
"in_force": "2016-12-12",
|
| 74 |
+
"status": "in force",
|
| 75 |
+
"current_to": "2022-03-22",
|
| 76 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-4.html",
|
| 77 |
+
"id": "A-8.8-fr-s4",
|
| 78 |
+
"text": "4\n(1) Le ministre peut, par règlement :\na) désigner comme violation punissable au titre de la présente loi la contravention — si elle constitue une infraction à une loi agroalimentaire :\n(i) aux dispositions spécifiées d’une loi agroalimentaire ou de ses règlements,\n(ii) à tout arrêté spécifié pris par le ministre au titre de la Loi sur la protection des végétaux, ou à toute catégorie de ces arrêtés,\n(iii) à toute obligation ou catégorie d’obligations spécifiée — par refus ou omission de l’accomplir — découlant de la Loi sur la santé des animaux, de la Loi sur la protection des végétaux, de la Loi sur les produits antiparasitaires ou de la Loi sur la salubrité des aliments au Canada;\nb) qualifier les violations, selon le cas, de mineures, de graves ou de très graves;\nb.1) établir le sommaire caractérisant la violation dans les procès-verbaux;\nc) fixer le montant — notamment par barème — de la sanction applicable à chaque violation;\nd) prévoir les critères de majoration ou de minoration — notamment pour les transactions — de ce montant, ainsi que les modalités de cette opération;\ne) régir la détermination d’un montant inférieur à la sanction infligée dont le paiement, dans le délai et selon les modalités réglementaires, vaut règlement;\nf) prévoir les cas dans lesquels la Commission peut procéder, dans le cadre du paragraphe 14(1), par écrit ou par la tenue d’une audience;\ng) régir, notamment par l’établissement de présomptions et de règles de preuve, la notification des documents autorisés ou exigés par la présente loi;\nh) prendre toute mesure d’ordre réglementaire prévue par la présente loi;\ni) prendre toute autre mesure d’application de la présente loi.\n(2) [Plafond de la sanction] Le plafond de la sanction est de 2 000 $ pour toute violation commise par une personne physique, sauf dans le cadre d’une entreprise ou à des fins lucratives, et, dans les autres cas, de 5 000 $, 15 000 $ ou 25 000 $, selon que la violation est mineure, grave ou très grave.\n(3) [Critères] Figurent parmi les critères prévus par le ministre au titre de l’alinéa (1)d) notamment :\na) la nature de l’intention ou de la négligence du contrevenant;\nb) la gravité du tort causé par la violation;\nc) les antécédents du contrevenant relatifs aux violations d’une loi agroalimentaire ou aux condamnations pour infraction à une telle loi dans les cinq ans précédant la violation.",
|
| 79 |
+
"citation": "AAAMPA, art. 4",
|
| 80 |
+
"lang": "fr"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"act_code": "A-8.8",
|
| 84 |
+
"act_short": "AAAMPA",
|
| 85 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 86 |
+
"section": "5",
|
| 87 |
+
"marginal_note": "Précision",
|
| 88 |
+
"part": "",
|
| 89 |
+
"division": "",
|
| 90 |
+
"heading": "Attributions du ministre",
|
| 91 |
+
"history": "",
|
| 92 |
+
"last_amended": "2002-12-31",
|
| 93 |
+
"in_force": "2002-12-31",
|
| 94 |
+
"status": "in force",
|
| 95 |
+
"current_to": "2022-03-22",
|
| 96 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-5.html",
|
| 97 |
+
"id": "A-8.8-fr-s5",
|
| 98 |
+
"text": "5 Tout acte ou omission qualifiable à la fois de violation et d’infraction peut être réprimé soit comme violation par le ministre, soit, sur sa recommandation, comme infraction, les poursuites pour violation et celles pour infraction s’excluant toutefois mutuellement.",
|
| 99 |
+
"citation": "AAAMPA, art. 5",
|
| 100 |
+
"lang": "fr"
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"act_code": "A-8.8",
|
| 104 |
+
"act_short": "AAAMPA",
|
| 105 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 106 |
+
"section": "6",
|
| 107 |
+
"marginal_note": "Pouvoir du ministre : agents verbalisateurs",
|
| 108 |
+
"part": "",
|
| 109 |
+
"division": "",
|
| 110 |
+
"heading": "Attributions du ministre",
|
| 111 |
+
"history": "1995, ch. 40, art. 6; 2015, ch. 2, art. 115",
|
| 112 |
+
"last_amended": "2015-02-27",
|
| 113 |
+
"in_force": "2015-02-27",
|
| 114 |
+
"status": "in force",
|
| 115 |
+
"current_to": "2022-03-22",
|
| 116 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-6.html",
|
| 117 |
+
"id": "A-8.8-fr-s6",
|
| 118 |
+
"text": "6 Le ministre peut désigner, individuellement ou par catégorie, les agents verbalisateurs.",
|
| 119 |
+
"citation": "AAAMPA, art. 6",
|
| 120 |
+
"lang": "fr"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"act_code": "A-8.8",
|
| 124 |
+
"act_short": "AAAMPA",
|
| 125 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 126 |
+
"section": "7",
|
| 127 |
+
"marginal_note": "Violation",
|
| 128 |
+
"part": "",
|
| 129 |
+
"division": "",
|
| 130 |
+
"heading": "Ouverture de la poursuite",
|
| 131 |
+
"history": "1995, ch. 40, art. 7; 2012, ch. 24, art. 100(A); 2015, ch. 2, art. 116(A); 2016, ch. 9, art. 71(A) et 72(A)",
|
| 132 |
+
"last_amended": "2019-01-15",
|
| 133 |
+
"in_force": "2016-12-12",
|
| 134 |
+
"status": "in force",
|
| 135 |
+
"current_to": "2022-03-22",
|
| 136 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-7.html",
|
| 137 |
+
"id": "A-8.8-fr-s7",
|
| 138 |
+
"text": "7\n(1) Toute contravention désignée au titre de l’alinéa 4(1)a) constitue une violation pour laquelle le contrevenant s’expose à l’avertissement ou à la sanction prévus par la présente loi.\n(2) [Verbalisation] L’agent verbalisateur qui a des motifs raisonnables de croire qu’une violation a été commise peut dresser un procès-verbal qu’il fait notifier au contrevenant. Le procès-verbal comporte, outre le nom du contrevenant et les faits reprochés, soit un avertissement, soit le montant, établi en application du règlement, de la sanction à payer — auquel cas il précise le délai et les modalités de paiement — et, sous réserve des règlements, le montant inférieur de la sanction infligée prévu au procès-verbal dont le paiement, dans le délai et selon les modalités, vaut règlement.\n(3) [Sommaire des droits] Figurent aussi au procès-verbal en langage clair un sommaire des droits et obligations du contrevenant prévus par la présente loi, notamment le droit de contester les faits reprochés auprès du ministre ou de la Commission et la procédure pour le faire.",
|
| 139 |
+
"citation": "AAAMPA, art. 7",
|
| 140 |
+
"lang": "fr"
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"act_code": "A-8.8",
|
| 144 |
+
"act_short": "AAAMPA",
|
| 145 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 146 |
+
"section": "8",
|
| 147 |
+
"marginal_note": "Option",
|
| 148 |
+
"part": "",
|
| 149 |
+
"division": "",
|
| 150 |
+
"heading": "Avertissements",
|
| 151 |
+
"history": "",
|
| 152 |
+
"last_amended": "2002-12-31",
|
| 153 |
+
"in_force": "2002-12-31",
|
| 154 |
+
"status": "in force",
|
| 155 |
+
"current_to": "2022-03-22",
|
| 156 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-8.html",
|
| 157 |
+
"id": "A-8.8-fr-s8",
|
| 158 |
+
"text": "8\n(1) Si le procès-verbal comporte un avertissement, le contrevenant peut, dans le délai et selon les modalités réglementaires, contester les faits reprochés auprès du ministre ou de la Commission.\n(2) [Présomption] Le défaut du contrevenant d’exercer l’option dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
|
| 159 |
+
"citation": "AAAMPA, art. 8",
|
| 160 |
+
"lang": "fr"
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"act_code": "A-8.8",
|
| 164 |
+
"act_short": "AAAMPA",
|
| 165 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 166 |
+
"section": "9",
|
| 167 |
+
"marginal_note": "Paiement",
|
| 168 |
+
"part": "",
|
| 169 |
+
"division": "",
|
| 170 |
+
"heading": "Sanctions",
|
| 171 |
+
"history": "",
|
| 172 |
+
"last_amended": "2002-12-31",
|
| 173 |
+
"in_force": "2002-12-31",
|
| 174 |
+
"status": "in force",
|
| 175 |
+
"current_to": "2022-03-22",
|
| 176 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-9.html",
|
| 177 |
+
"id": "A-8.8-fr-s9",
|
| 178 |
+
"text": "9\n(1) Si le procès-verbal inflige une sanction et que le contrevenant paie, dans le délai et selon les modalités réglementaires, le montant de celle-ci — ou, sous réserve des règlements, le montant inférieur prévu au procès-verbal — , le paiement, que le ministre accepte en règlement, vaut déclaration de responsabilité à l’égard de la violation et met fin à la poursuite.\n(2) [Option] À défaut d’effectuer le paiement, le contrevenant peut, dans le délai et selon les modalités réglementaires :\na) si la sanction est de 2 000 $ ou plus, demander au ministre de conclure une transaction en vue de la bonne application de la loi agroalimentaire ou du règlement en cause;\nb) contester auprès du ministre les faits reprochés;\nc) demander à la Commission de l’entendre sur les faits reprochés.\n(3) [Présomption] Le défaut du contrevenant d’exercer l’option visée au paragraphe (2) dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
|
| 179 |
+
"citation": "AAAMPA, art. 9",
|
| 180 |
+
"lang": "fr"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"act_code": "A-8.8",
|
| 184 |
+
"act_short": "AAAMPA",
|
| 185 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 186 |
+
"section": "10",
|
| 187 |
+
"marginal_note": "Conclusion d’une transaction",
|
| 188 |
+
"part": "",
|
| 189 |
+
"division": "",
|
| 190 |
+
"heading": "Transactions",
|
| 191 |
+
"history": "",
|
| 192 |
+
"last_amended": "2002-12-31",
|
| 193 |
+
"in_force": "2002-12-31",
|
| 194 |
+
"status": "in force",
|
| 195 |
+
"current_to": "2022-03-22",
|
| 196 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-10.html",
|
| 197 |
+
"id": "A-8.8-fr-s10",
|
| 198 |
+
"text": "10\n(1) Sur demande du contrevenant, le ministre peut conclure une transaction qui, d’une part, est subordonnée aux conditions qu’il estime indiquées, notamment au dépôt d’une caution raisonnable — dont le montant et la nature doivent lui agréer — , en garantie de l’exécution de la transaction et, d’autre part, peut prévoir la réduction partielle ou complète du montant de la sanction.\n(2) [Présomption] La conclusion de la transaction par le ministre vaut déclaration de responsabilité à l’égard de la violation.\n(3) [Avis d’exécution] La notification à l’intéressé d’un avis du ministre déclarant que celui-ci estime la transaction exécutée met fin à la poursuite; dès lors la caution est remise à l’intéressé.\n(4) [Avis de défaut d’exécution] S’il estime la transaction inexécutée, le ministre fait notifier à l’intéressé un avis de défaut qui l’informe soit qu’il doit payer, au lieu du montant de la sanction initiale et sans qu’il soit tenu compte du plafond fixé au paragraphe 4(2), le double de ce montant, soit qu’il y aura confiscation de la caution au profit de Sa Majesté du chef du Canada.\n(5) [Effet de l’inexécution] Sur notification de l’avis, l’intéressé perd tout droit à la compensation pour les sommes exposées dans le cadre de la transaction. Aux termes de l’avis, il est tenu de payer le montant mentionné, ou la confiscation de la caution s’opère au profit de Sa Majesté du chef du Canada, ce qui met fin à la poursuite.\n(6) [Paiement] Le paiement, que le ministre accepte en règlement, met fin à la poursuite.",
|
| 199 |
+
"citation": "AAAMPA, art. 10",
|
| 200 |
+
"lang": "fr"
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"act_code": "A-8.8",
|
| 204 |
+
"act_short": "AAAMPA",
|
| 205 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 206 |
+
"section": "11",
|
| 207 |
+
"marginal_note": "Option en cas de refus de transiger",
|
| 208 |
+
"part": "",
|
| 209 |
+
"division": "",
|
| 210 |
+
"heading": "Transactions",
|
| 211 |
+
"history": "1995, ch. 40, art. 11; 2015, ch. 2, art. 117(A)",
|
| 212 |
+
"last_amended": "2015-02-27",
|
| 213 |
+
"in_force": "2015-02-27",
|
| 214 |
+
"status": "in force",
|
| 215 |
+
"current_to": "2022-03-22",
|
| 216 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-11.html",
|
| 217 |
+
"id": "A-8.8-fr-s11",
|
| 218 |
+
"text": "11\n(1) Si le ministre refuse de transiger, le contrevenant peut, dans le délai et selon les modalités réglementaires, soit payer le montant de la sanction infligée initialement, soit demander à la Commission de l’entendre sur les faits reprochés.\n(2) [Paiement] Le paiement, que le ministre accepte en règlement, vaut déclaration de responsabilité à l’égard de la violation et met fin à la poursuite.\n(3) [Présomption] Le défaut du contrevenant d’exercer l’option dans le délai et selon les modalités prévus vaut déclaration de responsabilité à l’égard de la violation.",
|
| 219 |
+
"citation": "AAAMPA, art. 11",
|
| 220 |
+
"lang": "fr"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"act_code": "A-8.8",
|
| 224 |
+
"act_short": "AAAMPA",
|
| 225 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 226 |
+
"section": "12",
|
| 227 |
+
"marginal_note": "Décision du ministre : avertissement",
|
| 228 |
+
"part": "",
|
| 229 |
+
"division": "",
|
| 230 |
+
"heading": "Contestation devant le ministre",
|
| 231 |
+
"history": "",
|
| 232 |
+
"last_amended": "2002-12-31",
|
| 233 |
+
"in_force": "2002-12-31",
|
| 234 |
+
"status": "in force",
|
| 235 |
+
"current_to": "2022-03-22",
|
| 236 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-12.html",
|
| 237 |
+
"id": "A-8.8-fr-s12",
|
| 238 |
+
"text": "12\n(1) Saisi d’une contestation au titre de l’article 8, le ministre détermine la responsabilité du contrevenant et lui fait notifier sa décision.\n(2) [Demande de révision] Le contrevenant peut alors, dans le délai et selon les modalités réglementaires, demander à la Commission de l’entendre sur la décision du ministre.",
|
| 239 |
+
"citation": "AAAMPA, art. 12",
|
| 240 |
+
"lang": "fr"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"act_code": "A-8.8",
|
| 244 |
+
"act_short": "AAAMPA",
|
| 245 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 246 |
+
"section": "13",
|
| 247 |
+
"marginal_note": "Décision du ministre : sanction",
|
| 248 |
+
"part": "",
|
| 249 |
+
"division": "",
|
| 250 |
+
"heading": "Contestation devant le ministre",
|
| 251 |
+
"history": "",
|
| 252 |
+
"last_amended": "2002-12-31",
|
| 253 |
+
"in_force": "2002-12-31",
|
| 254 |
+
"status": "in force",
|
| 255 |
+
"current_to": "2022-03-22",
|
| 256 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-13.html",
|
| 257 |
+
"id": "A-8.8-fr-s13",
|
| 258 |
+
"text": "13\n(1) Saisi d’une contestation au titre de l’alinéa 9(2)b), le ministre détermine la responsabilité du contrevenant et lui fait notifier sa décision. S’il juge que le montant de la sanction n’a pas été établi en application des règlements, il y substitue le montant qu’il estime conforme.\n(2) [Option] Le contrevenant peut, dans le délai et selon les modalités réglementaires, soit payer le montant mentionné — paiement que le ministre accepte en règlement et qui met fin à la poursuite — , soit demander à la Commission de l’entendre sur la décision du ministre.",
|
| 259 |
+
"citation": "AAAMPA, art. 13",
|
| 260 |
+
"lang": "fr"
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"act_code": "A-8.8",
|
| 264 |
+
"act_short": "AAAMPA",
|
| 265 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 266 |
+
"section": "14",
|
| 267 |
+
"marginal_note": "Pouvoir de la Commission",
|
| 268 |
+
"part": "",
|
| 269 |
+
"division": "",
|
| 270 |
+
"heading": "Révision par la Commission",
|
| 271 |
+
"history": "",
|
| 272 |
+
"last_amended": "2002-12-31",
|
| 273 |
+
"in_force": "2002-12-31",
|
| 274 |
+
"status": "in force",
|
| 275 |
+
"current_to": "2022-03-22",
|
| 276 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-14.html",
|
| 277 |
+
"id": "A-8.8-fr-s14",
|
| 278 |
+
"text": "14\n(1) Saisie d’une affaire au titre de la présente loi, la Commission, par ordonnance et selon le cas, soit confirme, modifie ou annule la décision du ministre, soit détermine la responsabilité du contrevenant; en outre, si elle estime que le montant de la sanction n’a pas été établi en application des règlements, elle y substitue le montant qu’elle juge conforme. Elle fait notifier l’ordonnance à l’intéressé et au ministre.\n(2) [Paiement] Le paiement du montant conformément à l’ordonnance, que le ministre accepte en règlement, met fin à la poursuite.",
|
| 279 |
+
"citation": "AAAMPA, art. 14",
|
| 280 |
+
"lang": "fr"
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"act_code": "A-8.8",
|
| 284 |
+
"act_short": "AAAMPA",
|
| 285 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 286 |
+
"section": "15",
|
| 287 |
+
"marginal_note": "Créance de Sa Majesté",
|
| 288 |
+
"part": "",
|
| 289 |
+
"division": "",
|
| 290 |
+
"heading": "Exécution des sanctions",
|
| 291 |
+
"history": "1995, ch. 40, art. 15; 2012, ch. 24, art. 101; 2015, ch. 2, art. 118(F)",
|
| 292 |
+
"last_amended": "2019-01-15",
|
| 293 |
+
"in_force": "2015-02-27",
|
| 294 |
+
"status": "in force",
|
| 295 |
+
"current_to": "2022-03-22",
|
| 296 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-15.html",
|
| 297 |
+
"id": "A-8.8-fr-s15",
|
| 298 |
+
"text": "15\n(1) Constitue une créance de Sa Majesté du chef du Canada, dont le recouvrement peut être poursuivi à ce titre devant la Cour fédérale :\na) le montant de la sanction, à compter de la date de notification du procès-verbal;\nb) tout montant prévu dans une transaction conclue au titre du paragraphe 10(1), à compter de la date de sa conclusion;\nc) le montant mentionné dans l’avis de défaut notifié au titre du paragraphe 10(4), à compter de la date de sa notification;\nd) le montant mentionné dans la décision notifiée au titre du paragraphe 13(1), à compter de la date de sa notification;\ne) le montant mentionné dans l’ordonnance visée au paragraphe 14(1), à compter de l’expiration du délai fixé par la Commission pour le payer;\nf) le montant des frais raisonnables visés à l’article 22, à compter de la date où ils ont été faits.\n(2) [Prescription] Le recouvrement de la créance se prescrit par cinq ans à compter de la date à laquelle elle est devenue exigible.\n(3) [Conditions de révision] La créance est définitive et n’est susceptible de contestation ou de révision que dans la mesure et selon les modalités prévues aux articles 9 à 14.",
|
| 299 |
+
"citation": "AAAMPA, art. 15",
|
| 300 |
+
"lang": "fr"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"act_code": "A-8.8",
|
| 304 |
+
"act_short": "AAAMPA",
|
| 305 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 306 |
+
"section": "16",
|
| 307 |
+
"marginal_note": "Certificat de non-paiement",
|
| 308 |
+
"part": "",
|
| 309 |
+
"division": "",
|
| 310 |
+
"heading": "Exécution des sanctions",
|
| 311 |
+
"history": "",
|
| 312 |
+
"last_amended": "2002-12-31",
|
| 313 |
+
"in_force": "2002-12-31",
|
| 314 |
+
"status": "in force",
|
| 315 |
+
"current_to": "2022-03-22",
|
| 316 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-16.html",
|
| 317 |
+
"id": "A-8.8-fr-s16",
|
| 318 |
+
"text": "16\n(1) Le ministre peut établir un certificat de non-paiement pour la partie impayée des créances visées au paragraphe 15(1).\n(2) [Enregistrement en Cour fédérale] L’enregistrement à la Cour fédérale confère au certificat la valeur d’un jugement de cette juridiction pour la somme visée et les frais afférents.",
|
| 319 |
+
"citation": "AAAMPA, art. 16",
|
| 320 |
+
"lang": "fr"
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"act_code": "A-8.8",
|
| 324 |
+
"act_short": "AAAMPA",
|
| 325 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 326 |
+
"section": "17",
|
| 327 |
+
"marginal_note": "Précision",
|
| 328 |
+
"part": "",
|
| 329 |
+
"division": "",
|
| 330 |
+
"heading": "Règles spécifiques aux violations",
|
| 331 |
+
"history": "",
|
| 332 |
+
"last_amended": "2002-12-31",
|
| 333 |
+
"in_force": "2002-12-31",
|
| 334 |
+
"status": "in force",
|
| 335 |
+
"current_to": "2022-03-22",
|
| 336 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-17.html",
|
| 337 |
+
"id": "A-8.8-fr-s17",
|
| 338 |
+
"text": "17 Les violations n’ont pas valeur d’infractions; en conséquence nul ne peut être poursuivi à ce titre sur le fondement de l’article 126 du Code criminel.",
|
| 339 |
+
"citation": "AAAMPA, art. 17",
|
| 340 |
+
"lang": "fr"
|
| 341 |
+
},
|
| 342 |
+
{
|
| 343 |
+
"act_code": "A-8.8",
|
| 344 |
+
"act_short": "AAAMPA",
|
| 345 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 346 |
+
"section": "18",
|
| 347 |
+
"marginal_note": "Exclusion de certains moyens de défense",
|
| 348 |
+
"part": "",
|
| 349 |
+
"division": "",
|
| 350 |
+
"heading": "Règles spécifiques aux violations",
|
| 351 |
+
"history": "",
|
| 352 |
+
"last_amended": "2002-12-31",
|
| 353 |
+
"in_force": "2002-12-31",
|
| 354 |
+
"status": "in force",
|
| 355 |
+
"current_to": "2022-03-22",
|
| 356 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-18.html",
|
| 357 |
+
"id": "A-8.8-fr-s18",
|
| 358 |
+
"text": "18\n(1) Le contrevenant ne peut invoquer en défense le fait qu’il a pris les mesures nécessaires pour empêcher la violation ou qu’il croyait raisonnablement et en toute honnêteté à l’existence de faits qui, avérés, l’exonéreraient.\n(2) [Principes de la common law] Les règles et principes de la common law qui font d’une circonstance une justification ou une excuse dans le cadre d’une poursuite pour infraction à une loi agroalimentaire s’appliquent à l’égard d’une violation sauf dans la mesure où ils sont incompatibles avec la présente loi.",
|
| 359 |
+
"citation": "AAAMPA, art. 18",
|
| 360 |
+
"lang": "fr"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"act_code": "A-8.8",
|
| 364 |
+
"act_short": "AAAMPA",
|
| 365 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 366 |
+
"section": "19",
|
| 367 |
+
"marginal_note": "Charge de la preuve",
|
| 368 |
+
"part": "",
|
| 369 |
+
"division": "",
|
| 370 |
+
"heading": "Règles spécifiques aux violations",
|
| 371 |
+
"history": "",
|
| 372 |
+
"last_amended": "2002-12-31",
|
| 373 |
+
"in_force": "2002-12-31",
|
| 374 |
+
"status": "in force",
|
| 375 |
+
"current_to": "2022-03-22",
|
| 376 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-19.html",
|
| 377 |
+
"id": "A-8.8-fr-s19",
|
| 378 |
+
"text": "19 En cas de contestation devant le ministre ou de révision par la Commission, portant sur les faits, il appartient au ministre d’établir, selon la prépondérance des probabilités, la responsabilité du contrevenant.",
|
| 379 |
+
"citation": "AAAMPA, art. 19",
|
| 380 |
+
"lang": "fr"
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"act_code": "A-8.8",
|
| 384 |
+
"act_short": "AAAMPA",
|
| 385 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 386 |
+
"section": "20",
|
| 387 |
+
"marginal_note": "Responsabilité indirecte : titulaires",
|
| 388 |
+
"part": "",
|
| 389 |
+
"division": "",
|
| 390 |
+
"heading": "Règles spécifiques aux violations",
|
| 391 |
+
"history": "",
|
| 392 |
+
"last_amended": "2002-12-31",
|
| 393 |
+
"in_force": "2002-12-31",
|
| 394 |
+
"status": "in force",
|
| 395 |
+
"current_to": "2022-03-22",
|
| 396 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-20.html",
|
| 397 |
+
"id": "A-8.8-fr-s20",
|
| 398 |
+
"text": "20\n(1) Le titulaire d’un agrément — licence, permis ou autre type d’autorisation — délivré en vertu d’une loi agroalimentaire est responsable de la violation commise dans le cadre des activités ou des obligations visées par l’agrément, que l’auteur de la contravention soit ou non connu ou poursuivi aux termes de la présente loi.\n(2) [Responsabilité indirecte : employeurs et mandants] L’employeur ou le mandant est responsable de la violation commise, dans le cadre de son emploi ou du mandat, par un employé ou un mandataire, que l’auteur de la contravention soit ou non connu ou poursuivi aux termes de la présente loi.",
|
| 399 |
+
"citation": "AAAMPA, art. 20",
|
| 400 |
+
"lang": "fr"
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"act_code": "A-8.8",
|
| 404 |
+
"act_short": "AAAMPA",
|
| 405 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 406 |
+
"section": "21",
|
| 407 |
+
"marginal_note": "Violation continue",
|
| 408 |
+
"part": "",
|
| 409 |
+
"division": "",
|
| 410 |
+
"heading": "Règles spécifiques aux violations",
|
| 411 |
+
"history": "",
|
| 412 |
+
"last_amended": "2002-12-31",
|
| 413 |
+
"in_force": "2002-12-31",
|
| 414 |
+
"status": "in force",
|
| 415 |
+
"current_to": "2022-03-22",
|
| 416 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-21.html",
|
| 417 |
+
"id": "A-8.8-fr-s21",
|
| 418 |
+
"text": "21 Il est compté une violation distincte pour chacun des jours au cours desquels se continue une violation.",
|
| 419 |
+
"citation": "AAAMPA, art. 21",
|
| 420 |
+
"lang": "fr"
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"act_code": "A-8.8",
|
| 424 |
+
"act_short": "AAAMPA",
|
| 425 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 426 |
+
"section": "22",
|
| 427 |
+
"marginal_note": "Confiscation",
|
| 428 |
+
"part": "",
|
| 429 |
+
"division": "",
|
| 430 |
+
"heading": "Règles spécifiques aux violations",
|
| 431 |
+
"history": "",
|
| 432 |
+
"last_amended": "2002-12-31",
|
| 433 |
+
"in_force": "2002-12-31",
|
| 434 |
+
"status": "in force",
|
| 435 |
+
"current_to": "2022-03-22",
|
| 436 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-22.html",
|
| 437 |
+
"id": "A-8.8-fr-s22",
|
| 438 |
+
"text": "22 Il y a confiscation au profit de Sa Majesté du chef du Canada — si elle en décide ainsi — de tout objet détenu ou saisi, relativement à une violation, au titre d’une loi agroalimentaire dès lors que le contrevenant est déclaré ou réputé être responsable de la violation et que, dans ce dernier cas, il n’a pas, dans le délai et selon les modalités réglementaires, saisi la Commission d’une demande de révision; il en est alors disposé, aux frais du saisi, conformément, sous réserve des instructions du ministre, au règlement pris au titre de la loi agroalimentaire en cause.",
|
| 439 |
+
"citation": "AAAMPA, art. 22",
|
| 440 |
+
"lang": "fr"
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"act_code": "A-8.8",
|
| 444 |
+
"act_short": "AAAMPA",
|
| 445 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 446 |
+
"section": "23",
|
| 447 |
+
"marginal_note": "Dossiers",
|
| 448 |
+
"part": "",
|
| 449 |
+
"division": "",
|
| 450 |
+
"heading": "Dispositions générales",
|
| 451 |
+
"history": "",
|
| 452 |
+
"last_amended": "2002-12-31",
|
| 453 |
+
"in_force": "2002-12-31",
|
| 454 |
+
"status": "in force",
|
| 455 |
+
"current_to": "2022-03-22",
|
| 456 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-23.html",
|
| 457 |
+
"id": "A-8.8-fr-s23",
|
| 458 |
+
"text": "23\n(1) Sur demande du contrevenant, toute mention relative à une violation est rayée du dossier que le ministre tient à son égard cinq ans après la date soit du paiement de toute créance visée au paragraphe 15(1), soit de la notification d’un procès-verbal comportant un avertissement, à moins que celui-ci estime que ce serait contraire à l’intérêt public ou qu’une autre mention ait été portée au dossier au sujet de l’intéressé par la suite, mais n’ait pas été rayée.\n(2) [Notification] Le ministre fait notifier un avis de radiation à l’intéressé.",
|
| 459 |
+
"citation": "AAAMPA, art. 23",
|
| 460 |
+
"lang": "fr"
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"act_code": "A-8.8",
|
| 464 |
+
"act_short": "AAAMPA",
|
| 465 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 466 |
+
"section": "24",
|
| 467 |
+
"marginal_note": "Notification",
|
| 468 |
+
"part": "",
|
| 469 |
+
"division": "",
|
| 470 |
+
"heading": "Dispositions générales",
|
| 471 |
+
"history": "",
|
| 472 |
+
"last_amended": "2002-12-31",
|
| 473 |
+
"in_force": "2002-12-31",
|
| 474 |
+
"status": "in force",
|
| 475 |
+
"current_to": "2022-03-22",
|
| 476 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-24.html",
|
| 477 |
+
"id": "A-8.8-fr-s24",
|
| 478 |
+
"text": "24 Toute notification autorisée ou exigée par la présente loi s’effectue conformément au règlement, par remise à personne ou de toute autre manière qui y est autorisée.",
|
| 479 |
+
"citation": "AAAMPA, art. 24",
|
| 480 |
+
"lang": "fr"
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"act_code": "A-8.8",
|
| 484 |
+
"act_short": "AAAMPA",
|
| 485 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 486 |
+
"section": "25",
|
| 487 |
+
"marginal_note": "Admissibilité du procès-verbal de violation",
|
| 488 |
+
"part": "",
|
| 489 |
+
"division": "",
|
| 490 |
+
"heading": "Dispositions générales",
|
| 491 |
+
"history": "",
|
| 492 |
+
"last_amended": "2002-12-31",
|
| 493 |
+
"in_force": "2002-12-31",
|
| 494 |
+
"status": "in force",
|
| 495 |
+
"current_to": "2022-03-22",
|
| 496 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-25.html",
|
| 497 |
+
"id": "A-8.8-fr-s25",
|
| 498 |
+
"text": "25 Dans les poursuites pour violation ou pour infraction, le procès-verbal censé délivré en application de la présente loi est admissible en preuve sans qu’il soit nécessaire de prouver l’authenticité de la signature ni la qualité officielle du signataire.",
|
| 499 |
+
"citation": "AAAMPA, art. 25",
|
| 500 |
+
"lang": "fr"
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"act_code": "A-8.8",
|
| 504 |
+
"act_short": "AAAMPA",
|
| 505 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 506 |
+
"section": "26",
|
| 507 |
+
"marginal_note": "Prescription",
|
| 508 |
+
"part": "",
|
| 509 |
+
"division": "",
|
| 510 |
+
"heading": "Dispositions générales",
|
| 511 |
+
"history": "1995, ch. 40, art. 26; 2015, ch. 2, art. 119",
|
| 512 |
+
"last_amended": "2015-02-27",
|
| 513 |
+
"in_force": "2015-02-27",
|
| 514 |
+
"status": "in force",
|
| 515 |
+
"current_to": "2022-03-22",
|
| 516 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-26.html",
|
| 517 |
+
"id": "A-8.8-fr-s26",
|
| 518 |
+
"text": "26 Les poursuites pour violation se prescrivent par six mois à compter de la date à laquelle elle a été commise, lorsque celle-ci est mineure, et par deux ans, lorsqu’elle est grave ou très grave.",
|
| 519 |
+
"citation": "AAAMPA, art. 26",
|
| 520 |
+
"lang": "fr"
|
| 521 |
+
},
|
| 522 |
+
{
|
| 523 |
+
"act_code": "A-8.8",
|
| 524 |
+
"act_short": "AAAMPA",
|
| 525 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 526 |
+
"section": "27",
|
| 527 |
+
"marginal_note": "Prorogation de la Commission",
|
| 528 |
+
"part": "",
|
| 529 |
+
"division": "",
|
| 530 |
+
"heading": "Composition",
|
| 531 |
+
"history": "1995, ch. 40, art. 27; 2012, ch. 24, art. 102",
|
| 532 |
+
"last_amended": "2019-01-15",
|
| 533 |
+
"in_force": "2019-01-15",
|
| 534 |
+
"status": "in force",
|
| 535 |
+
"current_to": "2022-03-22",
|
| 536 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-27.html",
|
| 537 |
+
"id": "A-8.8-fr-s27",
|
| 538 |
+
"text": "27\n(1) Est prorogée la Commission de révision prorogée par le paragraphe 4.1(1) la Loi sur les produits agricoles au Canada, chapitre 20 du 4e supplément des Lois révisées du Canada (1985).\n(2) [Composition] La Commission est composée des membres, dont le président, nommés par le gouverneur en conseil.",
|
| 539 |
+
"citation": "AAAMPA, art. 27",
|
| 540 |
+
"lang": "fr"
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"act_code": "A-8.8",
|
| 544 |
+
"act_short": "AAAMPA",
|
| 545 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 546 |
+
"section": "28",
|
| 547 |
+
"marginal_note": "Compétences",
|
| 548 |
+
"part": "",
|
| 549 |
+
"division": "",
|
| 550 |
+
"heading": "Composition",
|
| 551 |
+
"history": "1995, ch. 40, art. 28; 2012, ch. 24, art. 102",
|
| 552 |
+
"last_amended": "2019-01-15",
|
| 553 |
+
"in_force": "2019-01-15",
|
| 554 |
+
"status": "in force",
|
| 555 |
+
"current_to": "2022-03-22",
|
| 556 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-28.html",
|
| 557 |
+
"id": "A-8.8-fr-s28",
|
| 558 |
+
"text": "28 Les membres sont nommés en raison de leurs connaissances ou de leur expérience dans le domaine de l’agriculture ou de l’agroalimentaire et le président et au moins un autre membre sont obligatoirement choisis parmi les avocats ou notaires inscrits respectivement, depuis au moins dix ans, au barreau d’une province ou à la Chambre des notaires du Québec.",
|
| 559 |
+
"citation": "AAAMPA, art. 28",
|
| 560 |
+
"lang": "fr"
|
| 561 |
+
},
|
| 562 |
+
{
|
| 563 |
+
"act_code": "A-8.8",
|
| 564 |
+
"act_short": "AAAMPA",
|
| 565 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 566 |
+
"section": "29",
|
| 567 |
+
"marginal_note": "Exercice des fonctions",
|
| 568 |
+
"part": "",
|
| 569 |
+
"division": "",
|
| 570 |
+
"heading": "Composition",
|
| 571 |
+
"history": "1995, ch. 40, art. 29; 2012, ch. 24, art. 102",
|
| 572 |
+
"last_amended": "2019-01-15",
|
| 573 |
+
"in_force": "2019-01-15",
|
| 574 |
+
"status": "in force",
|
| 575 |
+
"current_to": "2022-03-22",
|
| 576 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-29.html",
|
| 577 |
+
"id": "A-8.8-fr-s29",
|
| 578 |
+
"text": "29 Le président exerce ses fonctions à temps plein; les autres membres, à temps plein ou à temps partiel.",
|
| 579 |
+
"citation": "AAAMPA, art. 29",
|
| 580 |
+
"lang": "fr"
|
| 581 |
+
},
|
| 582 |
+
{
|
| 583 |
+
"act_code": "A-8.8",
|
| 584 |
+
"act_short": "AAAMPA",
|
| 585 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 586 |
+
"section": "30",
|
| 587 |
+
"marginal_note": "Mandat",
|
| 588 |
+
"part": "",
|
| 589 |
+
"division": "",
|
| 590 |
+
"heading": "Composition",
|
| 591 |
+
"history": "1995, ch. 40, art. 30; 2012, ch. 24, art. 102",
|
| 592 |
+
"last_amended": "2019-01-15",
|
| 593 |
+
"in_force": "2019-01-15",
|
| 594 |
+
"status": "in force",
|
| 595 |
+
"current_to": "2022-03-22",
|
| 596 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-30.html",
|
| 597 |
+
"id": "A-8.8-fr-s30",
|
| 598 |
+
"text": "30\n(1) Les membres occupent leur poste à titre inamovible pour un mandat maximal de cinq ans, sous réserve de révocation motivée prononcée par le gouverneur en conseil.\n(2) [Nouveau mandat] Les membres peuvent recevoir un nouveau mandat, aux fonctions identiques ou non.",
|
| 599 |
+
"citation": "AAAMPA, art. 30",
|
| 600 |
+
"lang": "fr"
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
"act_code": "A-8.8",
|
| 604 |
+
"act_short": "AAAMPA",
|
| 605 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 606 |
+
"section": "31",
|
| 607 |
+
"marginal_note": "Incompatibilité de fonctions",
|
| 608 |
+
"part": "",
|
| 609 |
+
"division": "",
|
| 610 |
+
"heading": "Composition",
|
| 611 |
+
"history": "1995, ch. 40, art. 31; 2012, ch. 24, art. 102",
|
| 612 |
+
"last_amended": "2019-01-15",
|
| 613 |
+
"in_force": "2019-01-15",
|
| 614 |
+
"status": "in force",
|
| 615 |
+
"current_to": "2022-03-22",
|
| 616 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-31.html",
|
| 617 |
+
"id": "A-8.8-fr-s31",
|
| 618 |
+
"text": "31 La charge de membre est incompatible avec d’autres fonctions dans l’administration publique fédérale.",
|
| 619 |
+
"citation": "AAAMPA, art. 31",
|
| 620 |
+
"lang": "fr"
|
| 621 |
+
},
|
| 622 |
+
{
|
| 623 |
+
"act_code": "A-8.8",
|
| 624 |
+
"act_short": "AAAMPA",
|
| 625 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 626 |
+
"section": "32",
|
| 627 |
+
"marginal_note": "Conflits d’intérêts",
|
| 628 |
+
"part": "",
|
| 629 |
+
"division": "",
|
| 630 |
+
"heading": "Composition",
|
| 631 |
+
"history": "1995, ch. 40, art. 32; 2012, ch. 24, art. 102",
|
| 632 |
+
"last_amended": "2019-01-15",
|
| 633 |
+
"in_force": "2019-01-15",
|
| 634 |
+
"status": "in force",
|
| 635 |
+
"current_to": "2022-03-22",
|
| 636 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-32.html",
|
| 637 |
+
"id": "A-8.8-fr-s32",
|
| 638 |
+
"text": "32 Les membres ne peuvent accepter ni occuper de charge ou d’emploi incompatibles avec leurs fonctions, ni se saisir d’une affaire dans laquelle ils ont un intérêt.",
|
| 639 |
+
"citation": "AAAMPA, art. 32",
|
| 640 |
+
"lang": "fr"
|
| 641 |
+
},
|
| 642 |
+
{
|
| 643 |
+
"act_code": "A-8.8",
|
| 644 |
+
"act_short": "AAAMPA",
|
| 645 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 646 |
+
"section": "33",
|
| 647 |
+
"marginal_note": "Fonctions du président",
|
| 648 |
+
"part": "",
|
| 649 |
+
"division": "",
|
| 650 |
+
"heading": "Président",
|
| 651 |
+
"history": "1995, ch. 40, art. 33; 2012, ch. 24, art. 102; 2014, ch. 20, art. 480",
|
| 652 |
+
"last_amended": "2019-01-15",
|
| 653 |
+
"in_force": "2019-01-15",
|
| 654 |
+
"status": "in force",
|
| 655 |
+
"current_to": "2022-03-22",
|
| 656 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-33.html",
|
| 657 |
+
"id": "A-8.8-fr-s33",
|
| 658 |
+
"text": "33\n(1) Le président de la Commission répartit les tâches entre les membres.\n(2) [Absence ou empêchement] En cas d’absence ou d’empêchement du président, ou de vacance de son poste, les autres membres confient l’intérim à l’un des membres dotés de la compétence juridique prévue à l’article 28. Cependant, l’intérim ne peut dépasser soixante jours sans l’approbation du gouverneur en conseil.",
|
| 659 |
+
"citation": "AAAMPA, art. 33",
|
| 660 |
+
"lang": "fr"
|
| 661 |
+
},
|
| 662 |
+
{
|
| 663 |
+
"act_code": "A-8.8",
|
| 664 |
+
"act_short": "AAAMPA",
|
| 665 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 666 |
+
"section": "34",
|
| 667 |
+
"marginal_note": "Indemnités",
|
| 668 |
+
"part": "",
|
| 669 |
+
"division": "",
|
| 670 |
+
"heading": "Indemnités et frais",
|
| 671 |
+
"history": "1995, ch. 40, art. 34; 2012, ch. 24, art. 102",
|
| 672 |
+
"last_amended": "2019-01-15",
|
| 673 |
+
"in_force": "2019-01-15",
|
| 674 |
+
"status": "in force",
|
| 675 |
+
"current_to": "2022-03-22",
|
| 676 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-34.html",
|
| 677 |
+
"id": "A-8.8-fr-s34",
|
| 678 |
+
"text": "34\n(1) Les membres à temps plein reçoivent le traitement, et les autres membres reçoivent les honoraires ou toute autre rémunération, que fixe le gouverneur en conseil.\n(2) [Frais] Les membres sont indemnisés des frais de déplacement et de séjour entraînés par l’exercice de leurs attributions hors de leur lieu habituel soit de travail, s’ils sont à temps plein, soit de résidence, s’ils sont à temps partiel, conformément aux directives du Conseil du Trésor.",
|
| 679 |
+
"citation": "AAAMPA, art. 34",
|
| 680 |
+
"lang": "fr"
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
"act_code": "A-8.8",
|
| 684 |
+
"act_short": "AAAMPA",
|
| 685 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 686 |
+
"section": "35",
|
| 687 |
+
"marginal_note": "",
|
| 688 |
+
"part": "",
|
| 689 |
+
"division": "",
|
| 690 |
+
"heading": "Indemnités et frais",
|
| 691 |
+
"history": "",
|
| 692 |
+
"last_amended": "2019-01-15",
|
| 693 |
+
"in_force": "2019-01-15",
|
| 694 |
+
"status": "repealed",
|
| 695 |
+
"current_to": "2022-03-22",
|
| 696 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-35.html",
|
| 697 |
+
"id": "A-8.8-fr-s35",
|
| 698 |
+
"text": "35 [Abrogé, 2014, ch. 20, art. 480]",
|
| 699 |
+
"citation": "AAAMPA, art. 35",
|
| 700 |
+
"lang": "fr"
|
| 701 |
+
},
|
| 702 |
+
{
|
| 703 |
+
"act_code": "A-8.8",
|
| 704 |
+
"act_short": "AAAMPA",
|
| 705 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 706 |
+
"section": "36",
|
| 707 |
+
"marginal_note": "",
|
| 708 |
+
"part": "",
|
| 709 |
+
"division": "",
|
| 710 |
+
"heading": "Indemnités et frais",
|
| 711 |
+
"history": "",
|
| 712 |
+
"last_amended": "2019-01-15",
|
| 713 |
+
"in_force": "2019-01-15",
|
| 714 |
+
"status": "repealed",
|
| 715 |
+
"current_to": "2022-03-22",
|
| 716 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-36.html",
|
| 717 |
+
"id": "A-8.8-fr-s36",
|
| 718 |
+
"text": "36 [Abrogé, 2014, ch. 20, art. 480]",
|
| 719 |
+
"citation": "AAAMPA, art. 36",
|
| 720 |
+
"lang": "fr"
|
| 721 |
+
},
|
| 722 |
+
{
|
| 723 |
+
"act_code": "A-8.8",
|
| 724 |
+
"act_short": "AAAMPA",
|
| 725 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 726 |
+
"section": "37",
|
| 727 |
+
"marginal_note": "Siège",
|
| 728 |
+
"part": "",
|
| 729 |
+
"division": "",
|
| 730 |
+
"heading": "Siège",
|
| 731 |
+
"history": "1995, ch. 40, art. 37; 2012, ch. 24, art. 102",
|
| 732 |
+
"last_amended": "2019-01-15",
|
| 733 |
+
"in_force": "2019-01-15",
|
| 734 |
+
"status": "in force",
|
| 735 |
+
"current_to": "2022-03-22",
|
| 736 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-37.html",
|
| 737 |
+
"id": "A-8.8-fr-s37",
|
| 738 |
+
"text": "37\n(1) Le siège de la Commission est fixé dans la région de la capitale nationale au sens de l’article 2 de la Loi sur la capitale nationale.\n(2) [Réunions] La Commission siège en tout lieu du Canada fixé par le gouverneur en conseil.",
|
| 739 |
+
"citation": "AAAMPA, art. 37",
|
| 740 |
+
"lang": "fr"
|
| 741 |
+
},
|
| 742 |
+
{
|
| 743 |
+
"act_code": "A-8.8",
|
| 744 |
+
"act_short": "AAAMPA",
|
| 745 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 746 |
+
"section": "38",
|
| 747 |
+
"marginal_note": "Commission",
|
| 748 |
+
"part": "",
|
| 749 |
+
"division": "",
|
| 750 |
+
"heading": "Compétence",
|
| 751 |
+
"history": "1995, ch. 40, art. 38; 2012, ch. 24, art. 102",
|
| 752 |
+
"last_amended": "2019-01-15",
|
| 753 |
+
"in_force": "2019-01-15",
|
| 754 |
+
"status": "in force",
|
| 755 |
+
"current_to": "2022-03-22",
|
| 756 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-38.html",
|
| 757 |
+
"id": "A-8.8-fr-s38",
|
| 758 |
+
"text": "38\n(1) La Commission a compétence exclusive pour les affaires relevant des domaines qui lui sont attribués sous le régime de la présente loi ou de toute autre loi fédérale.\n(2) [Révision en Cour fédérale] Les ordonnances de la Commission ne sont susceptibles de révision qu’au titre de la Loi sur les Cours fédérales.",
|
| 759 |
+
"citation": "AAAMPA, art. 38",
|
| 760 |
+
"lang": "fr"
|
| 761 |
+
},
|
| 762 |
+
{
|
| 763 |
+
"act_code": "A-8.8",
|
| 764 |
+
"act_short": "AAAMPA",
|
| 765 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 766 |
+
"section": "39",
|
| 767 |
+
"marginal_note": "Président",
|
| 768 |
+
"part": "",
|
| 769 |
+
"division": "",
|
| 770 |
+
"heading": "Compétence",
|
| 771 |
+
"history": "1995, ch. 40, art. 39; 2012, ch. 24, art. 102",
|
| 772 |
+
"last_amended": "2019-01-15",
|
| 773 |
+
"in_force": "2019-01-15",
|
| 774 |
+
"status": "in force",
|
| 775 |
+
"current_to": "2022-03-22",
|
| 776 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-39.html",
|
| 777 |
+
"id": "A-8.8-fr-s39",
|
| 778 |
+
"text": "39\n(1) Le président de la Commission a seul compétence pour entendre :\na) les demandes touchant les avertissements formées au titre des paragraphes 8(1) ou 12(2);\nb) les demandes touchant les sanctions de moins de 2 000 $ formées au titre de l’alinéa 9(2)c) ou du paragraphe 13(2).\n(2) [Délégation] Le président peut déléguer aux autres membres ses attributions relativement aux demandes mentionnées au paragraphe (1) s’ils sont dotés de la compétence juridique prévue à l’article 28.",
|
| 779 |
+
"citation": "AAAMPA, art. 39",
|
| 780 |
+
"lang": "fr"
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
"act_code": "A-8.8",
|
| 784 |
+
"act_short": "AAAMPA",
|
| 785 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 786 |
+
"section": "40",
|
| 787 |
+
"marginal_note": "Révision",
|
| 788 |
+
"part": "",
|
| 789 |
+
"division": "",
|
| 790 |
+
"heading": "Compétence",
|
| 791 |
+
"history": "1995, ch. 40, art. 40; 2012, ch. 24, art. 102",
|
| 792 |
+
"last_amended": "2019-01-15",
|
| 793 |
+
"in_force": "2019-01-15",
|
| 794 |
+
"status": "in force",
|
| 795 |
+
"current_to": "2022-03-22",
|
| 796 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-40.html",
|
| 797 |
+
"id": "A-8.8-fr-s40",
|
| 798 |
+
"text": "40 Les demandes de révision sont entendues par un membre seul.",
|
| 799 |
+
"citation": "AAAMPA, art. 40",
|
| 800 |
+
"lang": "fr"
|
| 801 |
+
},
|
| 802 |
+
{
|
| 803 |
+
"act_code": "A-8.8",
|
| 804 |
+
"act_short": "AAAMPA",
|
| 805 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 806 |
+
"section": "41",
|
| 807 |
+
"marginal_note": "Cour d’archives",
|
| 808 |
+
"part": "",
|
| 809 |
+
"division": "",
|
| 810 |
+
"heading": "Pouvoirs",
|
| 811 |
+
"history": "1995, ch. 40, art. 41; 2012, ch. 24, art. 102",
|
| 812 |
+
"last_amended": "2019-01-15",
|
| 813 |
+
"in_force": "2019-01-15",
|
| 814 |
+
"status": "in force",
|
| 815 |
+
"current_to": "2022-03-22",
|
| 816 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-41.html",
|
| 817 |
+
"id": "A-8.8-fr-s41",
|
| 818 |
+
"text": "41\n(1) La Commission est une cour d’archives; elle a un sceau officiel, dont l’authenticité est admise d’office.\n(2) [Interrogatoire des témoins] En outre, la Commission a, pour la comparution, la prestation des serments et l’interrogatoire des témoins, ainsi que pour la production et l’examen des documents et autres pièces, l’exécution de ses ordonnances et toutes autres questions relevant de sa compétence, les attributions d’une juridiction supérieure d’archives. Elle peut notamment :\na) par citation adressée aux personnes ayant connaissance de faits se rapportant à l’affaire dont elle est saisie, leur enjoindre de comparaître comme témoins aux date, heure et lieu indiqués et d’apporter et de produire tous documents, livres ou pièces utiles à l’affaire, dont elles ont la possession ou la responsabilité;\nb) faire prêter serment et interroger sous serment;\nc) recevoir en cours d’audition les éléments de preuve supplémentaires qu’elle estime utiles et dignes de foi.",
|
| 819 |
+
"citation": "AAAMPA, art. 41",
|
| 820 |
+
"lang": "fr"
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"act_code": "A-8.8",
|
| 824 |
+
"act_short": "AAAMPA",
|
| 825 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 826 |
+
"section": "42",
|
| 827 |
+
"marginal_note": "Règles",
|
| 828 |
+
"part": "",
|
| 829 |
+
"division": "",
|
| 830 |
+
"heading": "Règles",
|
| 831 |
+
"history": "1995, ch. 40, art. 42; 2012, ch. 24, art. 102",
|
| 832 |
+
"last_amended": "2019-01-15",
|
| 833 |
+
"in_force": "2019-01-15",
|
| 834 |
+
"status": "in force",
|
| 835 |
+
"current_to": "2022-03-22",
|
| 836 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-42.html",
|
| 837 |
+
"id": "A-8.8-fr-s42",
|
| 838 |
+
"text": "42 La Commission peut, avec l’agrément du gouverneur en conseil, établir des règles régissant :\na) la pratique et la procédure des audiences;\nb) les modalités, y compris les délais, d’établissement des demandes et des avis à donner;\nc) de façon générale, l’exercice de ses activités sous le régime de la présente loi ou de toute autre loi fédérale.",
|
| 839 |
+
"citation": "AAAMPA, art. 42",
|
| 840 |
+
"lang": "fr"
|
| 841 |
+
},
|
| 842 |
+
{
|
| 843 |
+
"act_code": "A-8.8",
|
| 844 |
+
"act_short": "AAAMPA",
|
| 845 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 846 |
+
"section": "43",
|
| 847 |
+
"marginal_note": "Consultations",
|
| 848 |
+
"part": "",
|
| 849 |
+
"division": "",
|
| 850 |
+
"heading": "Généralités",
|
| 851 |
+
"history": "1995, ch. 40, art. 43; 2012, ch. 24, art. 102",
|
| 852 |
+
"last_amended": "2019-01-15",
|
| 853 |
+
"in_force": "2019-01-15",
|
| 854 |
+
"status": "in force",
|
| 855 |
+
"current_to": "2022-03-22",
|
| 856 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-43.html",
|
| 857 |
+
"id": "A-8.8-fr-s43",
|
| 858 |
+
"text": "43 Les membres peuvent, dans le cadre des affaires dont la Commission est saisie, consulter d’autres membres.",
|
| 859 |
+
"citation": "AAAMPA, art. 43",
|
| 860 |
+
"lang": "fr"
|
| 861 |
+
},
|
| 862 |
+
{
|
| 863 |
+
"act_code": "A-8.8",
|
| 864 |
+
"act_short": "AAAMPA",
|
| 865 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 866 |
+
"section": "44",
|
| 867 |
+
"marginal_note": "Règles en matière de preuve",
|
| 868 |
+
"part": "",
|
| 869 |
+
"division": "",
|
| 870 |
+
"heading": "Généralités",
|
| 871 |
+
"history": "1995, ch. 40, art. 44; 2012, ch. 24, art. 102",
|
| 872 |
+
"last_amended": "2019-01-15",
|
| 873 |
+
"in_force": "2019-01-15",
|
| 874 |
+
"status": "in force",
|
| 875 |
+
"current_to": "2022-03-22",
|
| 876 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-44.html",
|
| 877 |
+
"id": "A-8.8-fr-s44",
|
| 878 |
+
"text": "44 La Commission n’est pas liée par les règles juridiques ou techniques applicables en matière de preuve lors des audiences. Dans la mesure où les circonstances, l’équité et la justice naturelle le permettent, il lui appartient d’agir rapidement et sans formalités.",
|
| 879 |
+
"citation": "AAAMPA, art. 44",
|
| 880 |
+
"lang": "fr"
|
| 881 |
+
},
|
| 882 |
+
{
|
| 883 |
+
"act_code": "A-8.8",
|
| 884 |
+
"act_short": "AAAMPA",
|
| 885 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 886 |
+
"section": "45",
|
| 887 |
+
"marginal_note": "Exception en matière de preuve",
|
| 888 |
+
"part": "",
|
| 889 |
+
"division": "",
|
| 890 |
+
"heading": "Généralités",
|
| 891 |
+
"history": "1995, ch. 40, art. 45; 2012, ch. 24, art. 102",
|
| 892 |
+
"last_amended": "2019-01-15",
|
| 893 |
+
"in_force": "2019-01-15",
|
| 894 |
+
"status": "in force",
|
| 895 |
+
"current_to": "2022-03-22",
|
| 896 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-45.html",
|
| 897 |
+
"id": "A-8.8-fr-s45",
|
| 898 |
+
"text": "45 La Commission ne peut recevoir ni admettre en preuve les éléments protégés par le droit de la preuve et rendus, de ce fait, inadmissibles en justice devant un tribunal judiciaire.",
|
| 899 |
+
"citation": "AAAMPA, art. 45",
|
| 900 |
+
"lang": "fr"
|
| 901 |
+
},
|
| 902 |
+
{
|
| 903 |
+
"act_code": "A-8.8",
|
| 904 |
+
"act_short": "AAAMPA",
|
| 905 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 906 |
+
"section": "46 à 89",
|
| 907 |
+
"marginal_note": "",
|
| 908 |
+
"part": "",
|
| 909 |
+
"division": "",
|
| 910 |
+
"heading": "Généralités",
|
| 911 |
+
"history": "",
|
| 912 |
+
"last_amended": "2019-01-15",
|
| 913 |
+
"in_force": "2019-01-15",
|
| 914 |
+
"status": "repealed",
|
| 915 |
+
"current_to": "2022-03-22",
|
| 916 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-46 à 89.html",
|
| 917 |
+
"id": "A-8.8-fr-s46 à 89",
|
| 918 |
+
"text": "46 à 89 [Abrogés, 2012, ch. 24, art. 102]",
|
| 919 |
+
"citation": "AAAMPA, art. 46 à 89",
|
| 920 |
+
"lang": "fr"
|
| 921 |
+
},
|
| 922 |
+
{
|
| 923 |
+
"act_code": "A-8.8",
|
| 924 |
+
"act_short": "AAAMPA",
|
| 925 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 926 |
+
"section": "90",
|
| 927 |
+
"marginal_note": "",
|
| 928 |
+
"part": "",
|
| 929 |
+
"division": "",
|
| 930 |
+
"heading": "Généralités",
|
| 931 |
+
"history": "",
|
| 932 |
+
"last_amended": "2019-01-15",
|
| 933 |
+
"in_force": "2019-01-15",
|
| 934 |
+
"status": "repealed",
|
| 935 |
+
"current_to": "2022-03-22",
|
| 936 |
+
"source_url": "https://laws-lois.justice.gc.ca/fra/lois/a-8.8/section-90.html",
|
| 937 |
+
"id": "A-8.8-fr-s90",
|
| 938 |
+
"text": "90 [Abrogé, 2012, ch. 24, art. 102]",
|
| 939 |
+
"citation": "AAAMPA, art. 90",
|
| 940 |
+
"lang": "fr"
|
| 941 |
+
}
|
| 942 |
+
]
|
data/processed/A-8.8.json
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"act_code": "A-8.8",
|
| 4 |
+
"act_short": "AAAMPA",
|
| 5 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 6 |
+
"section": "1",
|
| 7 |
+
"marginal_note": "Short title",
|
| 8 |
+
"part": "",
|
| 9 |
+
"division": "",
|
| 10 |
+
"heading": "Short Title",
|
| 11 |
+
"history": "",
|
| 12 |
+
"last_amended": "2002-12-31",
|
| 13 |
+
"in_force": "2002-12-31",
|
| 14 |
+
"status": "in force",
|
| 15 |
+
"current_to": "2022-03-22",
|
| 16 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-1.html",
|
| 17 |
+
"id": "A-8.8-s1",
|
| 18 |
+
"text": "1 This Act may be cited as the Agriculture and Agri-Food Administrative Monetary Penalties Act.",
|
| 19 |
+
"citation": "AAAMPA, s. 1"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"act_code": "A-8.8",
|
| 23 |
+
"act_short": "AAAMPA",
|
| 24 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 25 |
+
"section": "2",
|
| 26 |
+
"marginal_note": "Definitions",
|
| 27 |
+
"part": "",
|
| 28 |
+
"division": "",
|
| 29 |
+
"heading": "Interpretation",
|
| 30 |
+
"history": "1995, c. 40, s. 2; 1997, c. 21, s. 30; 2002, c. 28, s. 82; 2005, c. 38, ss. 30, 145; 2012, c. 24, s. 98; 2015, c. 2, s. 113",
|
| 31 |
+
"last_amended": "2019-01-15",
|
| 32 |
+
"in_force": "2015-02-27",
|
| 33 |
+
"status": "in force",
|
| 34 |
+
"current_to": "2022-03-22",
|
| 35 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-2.html",
|
| 36 |
+
"id": "A-8.8-s2",
|
| 37 |
+
"text": "2 In this Act,\nagri-food Act means the Farm Debt Mediation Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Pest Control Products Act, the Plant Protection Act, the Safe Food for Canadians Act or the Seeds Act; (loi agroalimentaire)\nMinister means the Minister of Agriculture and Agri-Food, except that\n(a) it means the Minister of Health in relation to a violation involving a contravention of\n(i) the Pest Control Products Act, or\n(ii) a provision relating to food safety of an agri-food Act or of a regulation made under such an Act, and\n(b) it means the Minister of Public Safety and Emergency Preparedness in relation to a notice of violation issued in respect of the contravention of program legislation referred to in subsection 11(5) of the Canadian Food Inspection Agency Act; (ministre)\npenalty means an administrative monetary penalty imposed under this Act for a violation; (sanction)\nprescribed means prescribed by regulation; (Version anglaise seulement)\nTribunal means the Review Tribunal continued by subsection 27(1); (Commission)",
|
| 38 |
+
"citation": "AAAMPA, s. 2"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"act_code": "A-8.8",
|
| 42 |
+
"act_short": "AAAMPA",
|
| 43 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 44 |
+
"section": "3",
|
| 45 |
+
"marginal_note": "Purpose of Act",
|
| 46 |
+
"part": "",
|
| 47 |
+
"division": "",
|
| 48 |
+
"heading": "Purpose of Act",
|
| 49 |
+
"history": "",
|
| 50 |
+
"last_amended": "2002-12-31",
|
| 51 |
+
"in_force": "2002-12-31",
|
| 52 |
+
"status": "in force",
|
| 53 |
+
"current_to": "2022-03-22",
|
| 54 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-3.html",
|
| 55 |
+
"id": "A-8.8-s3",
|
| 56 |
+
"text": "3 The purpose of this Act is to establish, as an alternative to the existing penal system and as a supplement to existing enforcement measures, a fair and efficient administrative monetary penalty system for the enforcement of the agri-food Acts.",
|
| 57 |
+
"citation": "AAAMPA, s. 3"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"act_code": "A-8.8",
|
| 61 |
+
"act_short": "AAAMPA",
|
| 62 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 63 |
+
"section": "4",
|
| 64 |
+
"marginal_note": "Regulations",
|
| 65 |
+
"part": "",
|
| 66 |
+
"division": "",
|
| 67 |
+
"heading": "Powers of Minister",
|
| 68 |
+
"history": "1995, c. 40, s. 4; 2012, c. 24, s. 99; 2015, c. 2, s. 114; 2016, c. 9, ss. 70, 72",
|
| 69 |
+
"last_amended": "2019-01-15",
|
| 70 |
+
"in_force": "2016-12-12",
|
| 71 |
+
"status": "in force",
|
| 72 |
+
"current_to": "2022-03-22",
|
| 73 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-4.html",
|
| 74 |
+
"id": "A-8.8-s4-p1",
|
| 75 |
+
"text": "4\n(1) The Minister may make regulations\n(a) designating as a violation that may be proceeded with in accordance with this Act\n(i) the contravention of any specified provision of an agri-food Act or of a regulation made under an agri-food Act,\n(ii) the contravention of any specified order, or class of orders, made by the Minister under the Plant Protection Act, or\n(iii) the refusal or neglect to perform any specified duty, or class of duties, imposed by or under the Plant Protection Act, the Health of Animals Act, the Pest Control Products Act or the Safe Food for Canadians Act;\nif the contravention, or the failure or neglect to perform the duty, as the case may be, is an offence under an agri-food Act;\n(b) classifying each violation as a minor violation, a serious violation or a very serious violation;\n(b.1) establishing, in respect of each violation, a short-form description to be used in notices of violation;\n(c) fixing a penalty, or a range of penalties, in respect of each violation;\n(d) respecting the circumstances under which, the criteria by which and the manner in which a penalty may be increased or reduced, including the reduction of a penalty pursuant to a compliance agreement under subsection 10(1);\n(e) respecting the determination of a lesser amount that may be paid in complete satisfaction of a penalty if paid within the prescribed time and manner;\n(f) respecting the circumstances under which reviews under this Act by the Tribunal shall be oral or in writing;",
|
| 76 |
+
"citation": "AAAMPA, s. 4 (part 1 of 2)"
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"act_code": "A-8.8",
|
| 80 |
+
"act_short": "AAAMPA",
|
| 81 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 82 |
+
"section": "4",
|
| 83 |
+
"marginal_note": "Regulations",
|
| 84 |
+
"part": "",
|
| 85 |
+
"division": "",
|
| 86 |
+
"heading": "Powers of Minister",
|
| 87 |
+
"history": "1995, c. 40, s. 4; 2012, c. 24, s. 99; 2015, c. 2, s. 114; 2016, c. 9, ss. 70, 72",
|
| 88 |
+
"last_amended": "2019-01-15",
|
| 89 |
+
"in_force": "2016-12-12",
|
| 90 |
+
"status": "in force",
|
| 91 |
+
"current_to": "2022-03-22",
|
| 92 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-4.html",
|
| 93 |
+
"id": "A-8.8-s4-p2",
|
| 94 |
+
"text": "(g) respecting the service of documents required or authorized to be served under this Act including, without restricting the generality of the foregoing, the manner of serving such documents, the proof of their service and the circumstances under which such documents shall be deemed to have been served;\n(h) prescribing anything that by this Act is to be prescribed; and\n(i) generally, for carrying out the purposes and provisions of this Act.\n(2) [Maximum penalties] The maximum penalty for a violation is\n(a) in the case of a violation that is committed by an individual otherwise than in the course of a business and that is not committed to obtain a financial benefit, $2,000; and\n(b) in any other case, $5,000 for a minor violation, $15,000 for a serious violation and $25,000 for a very serious violation.\n(3) [Criteria] Without restricting the generality of paragraph (1)(d), in making regulations respecting the criteria for increasing or reducing the amount of the penalty for a violation, the Minister shall include the following in any such criteria:\n(a) the degree of intention or negligence on the part of the person who committed the violation;\n(b) the harm done by the violation; and\n(c) the history of the person who committed the violation of prior violations or convictions under agri-food Acts within the five year period immediately before the violation.",
|
| 95 |
+
"citation": "AAAMPA, s. 4 (part 2 of 2)"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"act_code": "A-8.8",
|
| 99 |
+
"act_short": "AAAMPA",
|
| 100 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 101 |
+
"section": "5",
|
| 102 |
+
"marginal_note": "How act or omission may be proceeded with",
|
| 103 |
+
"part": "",
|
| 104 |
+
"division": "",
|
| 105 |
+
"heading": "Powers of Minister",
|
| 106 |
+
"history": "",
|
| 107 |
+
"last_amended": "2002-12-31",
|
| 108 |
+
"in_force": "2002-12-31",
|
| 109 |
+
"status": "in force",
|
| 110 |
+
"current_to": "2022-03-22",
|
| 111 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-5.html",
|
| 112 |
+
"id": "A-8.8-s5",
|
| 113 |
+
"text": "5 Where any act or omission can be proceeded with as a violation or as an offence, the Minister may commence proceedings in respect of that act or omission as a violation or recommend that it be proceeded with as an offence, but proceeding with it as a violation precludes proceeding with it as an offence, and proceeding with it as an offence precludes proceeding with it as a violation.",
|
| 114 |
+
"citation": "AAAMPA, s. 5"
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"act_code": "A-8.8",
|
| 118 |
+
"act_short": "AAAMPA",
|
| 119 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 120 |
+
"section": "6",
|
| 121 |
+
"marginal_note": "Power of Minister — notices of violation",
|
| 122 |
+
"part": "",
|
| 123 |
+
"division": "",
|
| 124 |
+
"heading": "Powers of Minister",
|
| 125 |
+
"history": "1995, c. 40, s. 6; 2015, c. 2, s. 115",
|
| 126 |
+
"last_amended": "2015-02-27",
|
| 127 |
+
"in_force": "2015-02-27",
|
| 128 |
+
"status": "in force",
|
| 129 |
+
"current_to": "2022-03-22",
|
| 130 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-6.html",
|
| 131 |
+
"id": "A-8.8-s6",
|
| 132 |
+
"text": "6 The Minister may designate persons, or classes of persons, who are authorized to issue notices of violation.",
|
| 133 |
+
"citation": "AAAMPA, s. 6"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"act_code": "A-8.8",
|
| 137 |
+
"act_short": "AAAMPA",
|
| 138 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 139 |
+
"section": "7",
|
| 140 |
+
"marginal_note": "Commission of violation",
|
| 141 |
+
"part": "",
|
| 142 |
+
"division": "",
|
| 143 |
+
"heading": "Proceedings",
|
| 144 |
+
"history": "1995, c. 40, s. 7; 2012, c. 24, s. 100(E); 2015, c. 2, s. 116(E); 2016, c. 9, ss. 71(E), 72(E)",
|
| 145 |
+
"last_amended": "2019-01-15",
|
| 146 |
+
"in_force": "2016-12-12",
|
| 147 |
+
"status": "in force",
|
| 148 |
+
"current_to": "2022-03-22",
|
| 149 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-7.html",
|
| 150 |
+
"id": "A-8.8-s7",
|
| 151 |
+
"text": "7\n(1) Every person who\n(a) contravenes any provision of an agri-food Act or of a regulation made under an agri-food Act,\n(b) contravenes any order made by the Minister under the Plant Protection Act, or\n(c) refuses or neglects to perform any duty imposed by or under the Plant Protection Act, the Health of Animals Act, the Pest Control Products Act or the Safe Food for Canadians Act\nthe contravention of which, or the refusal or neglect of which, is designated to be a violation by a regulation made under paragraph 4(1)(a) commits a violation and is liable to a warning or to a penalty in accordance with this Act.\n(2) [Issuance of notice of violation] If a person designated under section 6 has reasonable grounds to believe that a person has committed a violation, the designated person may issue, and shall cause to be served on the person, a notice of violation that names the person, identifies the violation and\n(a) contains a warning that the person has committed a violation; or\n(b) sets out\n(i) the penalty, established in accordance with the regulations, for the violation that the person is liable to pay,\n(ii) particulars concerning the time for paying and the manner of paying the penalty, and\n(iii) subject to the regulations, a lesser amount that may be paid in complete satisfaction of the penalty if paid within the time and manner specified in the notice.\n(3) [Summary of rights] A notice of violation must clearly summarize, in plain language, the rights and obligations under this Act of the person on whom it is served, including the right to have the facts of the violation reviewed by the Minister or the Tribunal, and the procedure for requesting such a review.",
|
| 152 |
+
"citation": "AAAMPA, s. 7"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"act_code": "A-8.8",
|
| 156 |
+
"act_short": "AAAMPA",
|
| 157 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 158 |
+
"section": "8",
|
| 159 |
+
"marginal_note": "Notices with warning — request for review",
|
| 160 |
+
"part": "",
|
| 161 |
+
"division": "",
|
| 162 |
+
"heading": "Notices of Violation with Warning",
|
| 163 |
+
"history": "",
|
| 164 |
+
"last_amended": "2002-12-31",
|
| 165 |
+
"in_force": "2002-12-31",
|
| 166 |
+
"status": "in force",
|
| 167 |
+
"current_to": "2022-03-22",
|
| 168 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-8.html",
|
| 169 |
+
"id": "A-8.8-s8",
|
| 170 |
+
"text": "8\n(1) Where a notice of violation contains a warning, the person named in the notice may, in the prescribed time and manner, request a review of the facts of the violation by the Minister or the Tribunal.\n(2) [Deeming] Where a person who is served with a notice of violation that contains a warning does not request a review under subsection (1) in the prescribed time and manner, the person is deemed to have committed the violation identified in the notice of violation.",
|
| 171 |
+
"citation": "AAAMPA, s. 8"
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"act_code": "A-8.8",
|
| 175 |
+
"act_short": "AAAMPA",
|
| 176 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 177 |
+
"section": "9",
|
| 178 |
+
"marginal_note": "Notices with penalty — payment",
|
| 179 |
+
"part": "",
|
| 180 |
+
"division": "",
|
| 181 |
+
"heading": "Notices of Violation with Penalty",
|
| 182 |
+
"history": "",
|
| 183 |
+
"last_amended": "2002-12-31",
|
| 184 |
+
"in_force": "2002-12-31",
|
| 185 |
+
"status": "in force",
|
| 186 |
+
"current_to": "2022-03-22",
|
| 187 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-9.html",
|
| 188 |
+
"id": "A-8.8-s9",
|
| 189 |
+
"text": "9\n(1) Where a notice of violation sets out a penalty and the person named in the notice pays, in the prescribed time and manner, the amount of the penalty or, subject to the regulations, the lesser amount set out in the notice that may be paid in lieu of the penalty,\n(a) the person is deemed to have committed the violation in respect of which the amount is paid;\n(b) the Minister shall accept that amount as and in complete satisfaction of the penalty; and\n(c) the proceedings commenced in respect of the violation under section 7 are ended.\n(2) [Alternatives to payment] Instead of paying the penalty set out in a notice of violation or, where applicable, the lesser amount that may be paid in lieu of the penalty, the person named in the notice may, in the prescribed time and manner,\n(a) if the penalty is $2,000 or more, request to enter into a compliance agreement with the Minister that ensures the person’s compliance with the agri-food Act or regulation to which the violation relates;\n(b) request a review by the Minister of the facts of the violation; or\n(c) request a review by the Tribunal of the facts of the violation.\n(3) [Deeming] Where a person who is served with a notice of violation that sets out a penalty does not pay the penalty in the prescribed time and manner or, where applicable, the lesser amount that may be paid in lieu of the penalty, and does not exercise any right referred to in subsection (2) in the prescribed time and manner, the person is deemed to have committed the violation identified in the notice.",
|
| 190 |
+
"citation": "AAAMPA, s. 9"
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"act_code": "A-8.8",
|
| 194 |
+
"act_short": "AAAMPA",
|
| 195 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 196 |
+
"section": "10",
|
| 197 |
+
"marginal_note": "Compliance agreements",
|
| 198 |
+
"part": "",
|
| 199 |
+
"division": "",
|
| 200 |
+
"heading": "Compliance Agreements",
|
| 201 |
+
"history": "",
|
| 202 |
+
"last_amended": "2002-12-31",
|
| 203 |
+
"in_force": "2002-12-31",
|
| 204 |
+
"status": "in force",
|
| 205 |
+
"current_to": "2022-03-22",
|
| 206 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-10.html",
|
| 207 |
+
"id": "A-8.8-s10-p1",
|
| 208 |
+
"text": "10\n(1) After considering a request under paragraph 9(2)(a), the Minister may enter into a compliance agreement, as described in that paragraph, with the person making the request on such terms and conditions as are satisfactory to the Minister, which terms may\n(a) include a provision for the giving of reasonable security, in a form and in an amount satisfactory to the Minister, as a guarantee that the person will comply with the compliance agreement; and\n(b) provide for the reduction, in whole or in part, of the penalty for the violation.\n(2) [Deeming] A person who makes a request under paragraph 9(2)(a) and who enters into a compliance agreement with the Minister shall, on entering into the compliance agreement, be deemed to have committed the violation in respect of which the compliance agreement was entered into.\n(3) [Where compliance agreement complied with] Where the Minister is satisfied that a person who has entered into a compliance agreement has complied with the agreement, the Minister shall cause a notice to that effect to be served on the person and, on the service of that notice,\n(a) the proceedings commenced in respect of the violation under section 7 are ended; and\n(b) any security given under the compliance agreement by the person shall be returned to the person.\n(4) [Where compliance agreement not complied with] Where the Minister is of the opinion that a person who has entered into a compliance agreement has not complied with the agreement, the Minister shall cause a notice of default to be served on the person to the effect that",
|
| 209 |
+
"citation": "AAAMPA, s. 10 (part 1 of 2)"
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"act_code": "A-8.8",
|
| 213 |
+
"act_short": "AAAMPA",
|
| 214 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 215 |
+
"section": "10",
|
| 216 |
+
"marginal_note": "Compliance agreements",
|
| 217 |
+
"part": "",
|
| 218 |
+
"division": "",
|
| 219 |
+
"heading": "Compliance Agreements",
|
| 220 |
+
"history": "",
|
| 221 |
+
"last_amended": "2002-12-31",
|
| 222 |
+
"in_force": "2002-12-31",
|
| 223 |
+
"status": "in force",
|
| 224 |
+
"current_to": "2022-03-22",
|
| 225 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-10.html",
|
| 226 |
+
"id": "A-8.8-s10-p2",
|
| 227 |
+
"text": "(a) instead of the penalty set out in the notice of violation in respect of which the compliance agreement was entered into, the person is liable to pay twice the amount of that penalty and, for greater certainty, subsection 4(2) does not apply in respect of that amount; or\n(b) the security, if any, given under the compliance agreement by the person shall be forfeited to Her Majesty in right of Canada.\n(5) [Effect of notice of default] On the service of a notice under subsection (4), the person served has no right of set-off against any amount spent by the person under the compliance agreement and\n(a) the person served is liable to pay the amount set out in the notice; or\n(b) where the notice of default provides for the forfeiture of the security given under the compliance agreement, that security is forfeited to Her Majesty in right of Canada and the proceedings commenced in respect of the violation under section 7 are ended.\n(6) [Effect of payment] Where a person pays the amount set out in a notice of default under subsection (4) in the prescribed time and manner,\n(a) the Minister shall accept the amount as and in complete satisfaction of the amount owing; and\n(b) the proceedings commenced in respect of the violation under section 7 are ended.",
|
| 228 |
+
"citation": "AAAMPA, s. 10 (part 2 of 2)"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"act_code": "A-8.8",
|
| 232 |
+
"act_short": "AAAMPA",
|
| 233 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 234 |
+
"section": "11",
|
| 235 |
+
"marginal_note": "Refusal to enter into compliance agreement",
|
| 236 |
+
"part": "",
|
| 237 |
+
"division": "",
|
| 238 |
+
"heading": "Compliance Agreements",
|
| 239 |
+
"history": "1995, c. 40, s. 11; 2015, c. 2, s. 117(E)",
|
| 240 |
+
"last_amended": "2015-02-27",
|
| 241 |
+
"in_force": "2015-02-27",
|
| 242 |
+
"status": "in force",
|
| 243 |
+
"current_to": "2022-03-22",
|
| 244 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-11.html",
|
| 245 |
+
"id": "A-8.8-s11",
|
| 246 |
+
"text": "11\n(1) Where the Minister refuses to enter into a compliance agreement pursuant to a request under paragraph 9(2)(a), the person who made the request may, in the prescribed time and manner,\n(a) pay the amount of the penalty; or\n(b) request a review by the Tribunal of the facts of the violation.\n(2) [Effect of payment] Where a person pays the amount referred to in paragraph (1)(a),\n(a) the person is deemed to have committed the violation in respect of which the payment is made;\n(b) the Minister shall accept the amount as and in complete satisfaction of the penalty; and\n(c) the proceedings commenced in respect of the violation under section 7 are ended.\n(3) [Deeming] If a person does not, in the prescribed time and manner, either pay the amount referred to in paragraph (1)(a) or request a review under paragraph (1)(b), the person is deemed to have committed the violation identified in the notice of violation.",
|
| 247 |
+
"citation": "AAAMPA, s. 11"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"act_code": "A-8.8",
|
| 251 |
+
"act_short": "AAAMPA",
|
| 252 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 253 |
+
"section": "12",
|
| 254 |
+
"marginal_note": "Review under section 8",
|
| 255 |
+
"part": "",
|
| 256 |
+
"division": "",
|
| 257 |
+
"heading": "Review by Minister",
|
| 258 |
+
"history": "",
|
| 259 |
+
"last_amended": "2002-12-31",
|
| 260 |
+
"in_force": "2002-12-31",
|
| 261 |
+
"status": "in force",
|
| 262 |
+
"current_to": "2022-03-22",
|
| 263 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-12.html",
|
| 264 |
+
"id": "A-8.8-s12",
|
| 265 |
+
"text": "12\n(1) After concluding a review requested under section 8, the Minister shall determine whether or not the person committed the violation, and the Minister shall cause a notice of any decision under this subsection to be served on the person who requested the review.\n(2) [Right to review] Where the Minister decides under subsection (1) that a person has committed a violation, the person may, in the prescribed time and manner, request a review of the Minister’s decision by the Tribunal.",
|
| 266 |
+
"citation": "AAAMPA, s. 12"
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"act_code": "A-8.8",
|
| 270 |
+
"act_short": "AAAMPA",
|
| 271 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 272 |
+
"section": "13",
|
| 273 |
+
"marginal_note": "Review under paragraph 9(2)(b)",
|
| 274 |
+
"part": "",
|
| 275 |
+
"division": "",
|
| 276 |
+
"heading": "Review by Minister",
|
| 277 |
+
"history": "",
|
| 278 |
+
"last_amended": "2002-12-31",
|
| 279 |
+
"in_force": "2002-12-31",
|
| 280 |
+
"status": "in force",
|
| 281 |
+
"current_to": "2022-03-22",
|
| 282 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-13.html",
|
| 283 |
+
"id": "A-8.8-s13",
|
| 284 |
+
"text": "13\n(1) After concluding a review requested under paragraph 9(2)(b), the Minister shall determine whether or not the person requesting the review committed a violation and, where the Minister decides that the person committed a violation but considers that the amount of the penalty for the violation was not established in accordance with the regulations, the Minister shall correct the amount of the penalty for the violation, and the Minister shall cause a notice of any decision under this subsection to be served on the person who requested the review.\n(2) [Payment or right to review] Where the Minister decides under subsection (1) that a person has committed a violation, the person may, in the prescribed time and manner,\n(a) pay the amount of the penalty set out in the notice referred to in subsection (1), in which case\n(i) the Minister shall accept the amount as and in complete satisfaction of the penalty, and\n(ii) the proceedings commenced in respect of the violation under section 7 are ended; or\n(b) request a review of the Minister’s decision by the Tribunal.",
|
| 285 |
+
"citation": "AAAMPA, s. 13"
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
"act_code": "A-8.8",
|
| 289 |
+
"act_short": "AAAMPA",
|
| 290 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 291 |
+
"section": "14",
|
| 292 |
+
"marginal_note": "Review by Tribunal",
|
| 293 |
+
"part": "",
|
| 294 |
+
"division": "",
|
| 295 |
+
"heading": "Review by Tribunal",
|
| 296 |
+
"history": "",
|
| 297 |
+
"last_amended": "2002-12-31",
|
| 298 |
+
"in_force": "2002-12-31",
|
| 299 |
+
"status": "in force",
|
| 300 |
+
"current_to": "2022-03-22",
|
| 301 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-14.html",
|
| 302 |
+
"id": "A-8.8-s14",
|
| 303 |
+
"text": "14\n(1) After concluding a review requested under this Act, the Tribunal shall, by order, as the case may be,\n(a) confirm, vary or set aside any decision of the Minister under section 12 or 13, or\n(b) determine whether or not the person requesting the review committed a violation and, where the Tribunal decides that the person committed a violation but considers that the amount of the penalty for the violation, if any, was not established in accordance with the regulations, the Tribunal shall correct the amount of the penalty,\nand the Tribunal shall cause a notice of any order made under this subsection to be served on the person who requested the review, and on the Minister.\n(2) [Payment] Where the Tribunal decides under subsection (1) that a person has committed a violation, the person is liable for the amount of the penalty as set out in the order of the Tribunal and, on the payment of that amount in the time and manner specified in the order,\n(a) the Minister shall accept the amount as and in complete satisfaction of the penalty; and\n(b) the proceedings commenced in respect of the violation under section 7 are ended.",
|
| 304 |
+
"citation": "AAAMPA, s. 14"
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"act_code": "A-8.8",
|
| 308 |
+
"act_short": "AAAMPA",
|
| 309 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 310 |
+
"section": "15",
|
| 311 |
+
"marginal_note": "Debts to Her Majesty",
|
| 312 |
+
"part": "",
|
| 313 |
+
"division": "",
|
| 314 |
+
"heading": "Enforcement",
|
| 315 |
+
"history": "1995, c. 40, s. 15; 2012, c. 24, s. 101; 2015, c. 2, s. 118(F)",
|
| 316 |
+
"last_amended": "2019-01-15",
|
| 317 |
+
"in_force": "2015-02-27",
|
| 318 |
+
"status": "in force",
|
| 319 |
+
"current_to": "2022-03-22",
|
| 320 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-15.html",
|
| 321 |
+
"id": "A-8.8-s15",
|
| 322 |
+
"text": "15\n(1) The following amounts constitute debts due to Her Majesty in right of Canada that may be recovered as such in the Federal Court:\n(a) the amount of a penalty, from the time the notice of violation setting out the penalty is served;\n(b) every amount undertaken to be paid pursuant to a compliance agreement entered into with the Minister under subsection 10(1), from the time the compliance agreement is entered into;\n(c) the amount set out in a notice of default referred to in subsection 10(4), from the time the notice is served;\n(d) the amount of a penalty as set out in a decision of the Minister under subsection 13(1), from the time the notice under that subsection is served;\n(e) the amount of a penalty as set out in an order of the Tribunal under subsection 14(1), from the expiration of the time specified in the order for the payment of that amount; and\n(f) the amount of any reasonable expenses incurred pursuant to section 22, from the date they are incurred.\n(2) [Time limit] No proceedings to recover a debt referred to in subsection (1) may be commenced later than five years after the debt became payable.\n(3) [Debt final] A debt referred to in subsection (1) is final and not subject to review or to be restrained, prohibited, removed, set aside or otherwise dealt with except to the extent and in the manner provided by sections 9 to 14.",
|
| 323 |
+
"citation": "AAAMPA, s. 15"
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"act_code": "A-8.8",
|
| 327 |
+
"act_short": "AAAMPA",
|
| 328 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 329 |
+
"section": "16",
|
| 330 |
+
"marginal_note": "Certificate of default",
|
| 331 |
+
"part": "",
|
| 332 |
+
"division": "",
|
| 333 |
+
"heading": "Enforcement",
|
| 334 |
+
"history": "",
|
| 335 |
+
"last_amended": "2002-12-31",
|
| 336 |
+
"in_force": "2002-12-31",
|
| 337 |
+
"status": "in force",
|
| 338 |
+
"current_to": "2022-03-22",
|
| 339 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-16.html",
|
| 340 |
+
"id": "A-8.8-s16",
|
| 341 |
+
"text": "16\n(1) Any debt referred to in subsection 15(1) in respect of which there is a default of payment, or the part of any such debt that has not been paid, may be certified by the Minister.\n(2) [Judgments] On production to the Federal Court, a certificate made under subsection (1) shall be registered in that Court and, when registered, has the same force and effect, and all proceedings may be taken on the certificate, as if it were a judgment obtained in that Court for a debt of the amount specified in the certificate and all reasonable costs and charges attendant in the registration of the certificate.",
|
| 342 |
+
"citation": "AAAMPA, s. 16"
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"act_code": "A-8.8",
|
| 346 |
+
"act_short": "AAAMPA",
|
| 347 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 348 |
+
"section": "17",
|
| 349 |
+
"marginal_note": "Violations not offences",
|
| 350 |
+
"part": "",
|
| 351 |
+
"division": "",
|
| 352 |
+
"heading": "Rules of Law About Violations",
|
| 353 |
+
"history": "",
|
| 354 |
+
"last_amended": "2002-12-31",
|
| 355 |
+
"in_force": "2002-12-31",
|
| 356 |
+
"status": "in force",
|
| 357 |
+
"current_to": "2022-03-22",
|
| 358 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-17.html",
|
| 359 |
+
"id": "A-8.8-s17",
|
| 360 |
+
"text": "17 For greater certainty, a violation is not an offence and, accordingly, section 126 of the Criminal Code does not apply.",
|
| 361 |
+
"citation": "AAAMPA, s. 17"
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"act_code": "A-8.8",
|
| 365 |
+
"act_short": "AAAMPA",
|
| 366 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 367 |
+
"section": "18",
|
| 368 |
+
"marginal_note": "Certain defences not available",
|
| 369 |
+
"part": "",
|
| 370 |
+
"division": "",
|
| 371 |
+
"heading": "Rules of Law About Violations",
|
| 372 |
+
"history": "",
|
| 373 |
+
"last_amended": "2002-12-31",
|
| 374 |
+
"in_force": "2002-12-31",
|
| 375 |
+
"status": "in force",
|
| 376 |
+
"current_to": "2022-03-22",
|
| 377 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-18.html",
|
| 378 |
+
"id": "A-8.8-s18",
|
| 379 |
+
"text": "18\n(1) A person named in a notice of violation does not have a defence by reason that the person\n(a) exercised due diligence to prevent the violation; or\n(b) reasonably and honestly believed in the existence of facts that, if true, would exonerate the person.\n(2) [Common law principles] Every rule and principle of the common law that renders any circumstance a justification or excuse in relation to a charge for an offence under an agri-food Act applies in respect of a violation to the extent that it is not inconsistent with this Act.",
|
| 380 |
+
"citation": "AAAMPA, s. 18"
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"act_code": "A-8.8",
|
| 384 |
+
"act_short": "AAAMPA",
|
| 385 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 386 |
+
"section": "19",
|
| 387 |
+
"marginal_note": "Burden of proof",
|
| 388 |
+
"part": "",
|
| 389 |
+
"division": "",
|
| 390 |
+
"heading": "Rules of Law About Violations",
|
| 391 |
+
"history": "",
|
| 392 |
+
"last_amended": "2002-12-31",
|
| 393 |
+
"in_force": "2002-12-31",
|
| 394 |
+
"status": "in force",
|
| 395 |
+
"current_to": "2022-03-22",
|
| 396 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-19.html",
|
| 397 |
+
"id": "A-8.8-s19",
|
| 398 |
+
"text": "19 In every case where the facts of a violation are reviewed by the Minister or by the Tribunal, the Minister must establish, on a balance of probabilities, that the person named in the notice of violation committed the violation identified in the notice.",
|
| 399 |
+
"citation": "AAAMPA, s. 19"
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"act_code": "A-8.8",
|
| 403 |
+
"act_short": "AAAMPA",
|
| 404 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 405 |
+
"section": "20",
|
| 406 |
+
"marginal_note": "Vicarious liability — licence holders, etc.",
|
| 407 |
+
"part": "",
|
| 408 |
+
"division": "",
|
| 409 |
+
"heading": "Rules of Law About Violations",
|
| 410 |
+
"history": "",
|
| 411 |
+
"last_amended": "2002-12-31",
|
| 412 |
+
"in_force": "2002-12-31",
|
| 413 |
+
"status": "in force",
|
| 414 |
+
"current_to": "2022-03-22",
|
| 415 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-20.html",
|
| 416 |
+
"id": "A-8.8-s20",
|
| 417 |
+
"text": "20\n(1) The holder of a licence, certificate, letter of accreditation, permit, notice or other document issued under an agri-food Act is liable for a violation that is committed in respect of any matter relating to any activity or requirement under that document, whether or not the person who actually committed the violation is identified or proceeded against in accordance with this Act.\n(2) [Vicarious liability — acts of employees and agents] A person is liable for a violation that is committed by any employee or agent of the person acting in the course of the employee’s employment or the scope of the agent’s authority, whether or not the employee or agent who actually committed the violation is identified or proceeded against in accordance with this Act.",
|
| 418 |
+
"citation": "AAAMPA, s. 20"
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"act_code": "A-8.8",
|
| 422 |
+
"act_short": "AAAMPA",
|
| 423 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 424 |
+
"section": "21",
|
| 425 |
+
"marginal_note": "Continuing violation",
|
| 426 |
+
"part": "",
|
| 427 |
+
"division": "",
|
| 428 |
+
"heading": "Rules of Law About Violations",
|
| 429 |
+
"history": "",
|
| 430 |
+
"last_amended": "2002-12-31",
|
| 431 |
+
"in_force": "2002-12-31",
|
| 432 |
+
"status": "in force",
|
| 433 |
+
"current_to": "2022-03-22",
|
| 434 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-21.html",
|
| 435 |
+
"id": "A-8.8-s21",
|
| 436 |
+
"text": "21 A violation that is continued on more than one day constitutes a separate violation in respect of each day during which it is continued.",
|
| 437 |
+
"citation": "AAAMPA, s. 21"
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"act_code": "A-8.8",
|
| 441 |
+
"act_short": "AAAMPA",
|
| 442 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 443 |
+
"section": "22",
|
| 444 |
+
"marginal_note": "Forfeiture",
|
| 445 |
+
"part": "",
|
| 446 |
+
"division": "",
|
| 447 |
+
"heading": "Rules of Law About Violations",
|
| 448 |
+
"history": "",
|
| 449 |
+
"last_amended": "2002-12-31",
|
| 450 |
+
"in_force": "2002-12-31",
|
| 451 |
+
"status": "in force",
|
| 452 |
+
"current_to": "2022-03-22",
|
| 453 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-22.html",
|
| 454 |
+
"id": "A-8.8-s22",
|
| 455 |
+
"text": "22 Where\n(a) a person is deemed by this Act to have committed a violation, or\n(b) the Minister, pursuant to a review under this Act, has decided that a person has committed a violation and no request to review the Minister’s decision has been made to the Tribunal in the prescribed time and manner,\nanything seized and detained under an agri-food Act in relation to the violation is, at the election of Her Majesty in right of Canada, immediately forfeited to Her Majesty in right of Canada and may be disposed of, at the expense of the person from whom it was seized, in accordance with the regulations made under the applicable agri-food Act unless the Minister directs otherwise.",
|
| 456 |
+
"citation": "AAAMPA, s. 22"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"act_code": "A-8.8",
|
| 460 |
+
"act_short": "AAAMPA",
|
| 461 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 462 |
+
"section": "23",
|
| 463 |
+
"marginal_note": "Notations removed",
|
| 464 |
+
"part": "",
|
| 465 |
+
"division": "",
|
| 466 |
+
"heading": "General Provisions",
|
| 467 |
+
"history": "",
|
| 468 |
+
"last_amended": "2002-12-31",
|
| 469 |
+
"in_force": "2002-12-31",
|
| 470 |
+
"status": "in force",
|
| 471 |
+
"current_to": "2022-03-22",
|
| 472 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-23.html",
|
| 473 |
+
"id": "A-8.8-s23",
|
| 474 |
+
"text": "23\n(1) Any notation of a violation shall, on application by the person who committed the violation, be removed from any records that may be kept by the Minister respecting that person after the expiration of five years from\n(a) where the notice of violation contained a warning, the date the notice was served, or\n(b) in any other case, the payment of any debt referred to in subsection 15(1),\nunless the removal from the record would not in the opinion of the Minister be in the public interest or another notation of a violation has been recorded by the Minister in respect of that person after that date and has not been removed in accordance with this subsection.\n(2) [Duty to notify] The Minister shall cause a notice of removal to be served on the person in respect of whom a notation is removed pursuant to subsection (1).",
|
| 475 |
+
"citation": "AAAMPA, s. 23"
|
| 476 |
+
},
|
| 477 |
+
{
|
| 478 |
+
"act_code": "A-8.8",
|
| 479 |
+
"act_short": "AAAMPA",
|
| 480 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 481 |
+
"section": "24",
|
| 482 |
+
"marginal_note": "Service of documents",
|
| 483 |
+
"part": "",
|
| 484 |
+
"division": "",
|
| 485 |
+
"heading": "General Provisions",
|
| 486 |
+
"history": "",
|
| 487 |
+
"last_amended": "2002-12-31",
|
| 488 |
+
"in_force": "2002-12-31",
|
| 489 |
+
"status": "in force",
|
| 490 |
+
"current_to": "2022-03-22",
|
| 491 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-24.html",
|
| 492 |
+
"id": "A-8.8-s24",
|
| 493 |
+
"text": "24 Every document required or authorized to be served under this Act shall be served in accordance with the regulations, either personally or in such other manner as may be authorized in the regulations.",
|
| 494 |
+
"citation": "AAAMPA, s. 24"
|
| 495 |
+
},
|
| 496 |
+
{
|
| 497 |
+
"act_code": "A-8.8",
|
| 498 |
+
"act_short": "AAAMPA",
|
| 499 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 500 |
+
"section": "25",
|
| 501 |
+
"marginal_note": "Evidence",
|
| 502 |
+
"part": "",
|
| 503 |
+
"division": "",
|
| 504 |
+
"heading": "General Provisions",
|
| 505 |
+
"history": "",
|
| 506 |
+
"last_amended": "2002-12-31",
|
| 507 |
+
"in_force": "2002-12-31",
|
| 508 |
+
"status": "in force",
|
| 509 |
+
"current_to": "2022-03-22",
|
| 510 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-25.html",
|
| 511 |
+
"id": "A-8.8-s25",
|
| 512 |
+
"text": "25 In any proceeding for a violation or for a prosecution for an offence, a notice of violation purporting to be issued pursuant to this Act is admissible in evidence without proof of the signature or official character of the person appearing to have signed the notice of violation.",
|
| 513 |
+
"citation": "AAAMPA, s. 25"
|
| 514 |
+
},
|
| 515 |
+
{
|
| 516 |
+
"act_code": "A-8.8",
|
| 517 |
+
"act_short": "AAAMPA",
|
| 518 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 519 |
+
"section": "26",
|
| 520 |
+
"marginal_note": "Limitation period",
|
| 521 |
+
"part": "",
|
| 522 |
+
"division": "",
|
| 523 |
+
"heading": "General Provisions",
|
| 524 |
+
"history": "1995, c. 40, s. 26; 2015, c. 2, s. 119",
|
| 525 |
+
"last_amended": "2015-02-27",
|
| 526 |
+
"in_force": "2015-02-27",
|
| 527 |
+
"status": "in force",
|
| 528 |
+
"current_to": "2022-03-22",
|
| 529 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-26.html",
|
| 530 |
+
"id": "A-8.8-s26",
|
| 531 |
+
"text": "26 No proceedings in respect of a violation may be commenced later than\n(a) six months after the day on which the subject matter of the proceedings arises, in the case of a minor violation; or\n(b) two years after the day on which the subject matter of the proceedings arises, in the case of a serious violation or a very serious violation.",
|
| 532 |
+
"citation": "AAAMPA, s. 26"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"act_code": "A-8.8",
|
| 536 |
+
"act_short": "AAAMPA",
|
| 537 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 538 |
+
"section": "27",
|
| 539 |
+
"marginal_note": "Review Tribunal continued",
|
| 540 |
+
"part": "",
|
| 541 |
+
"division": "",
|
| 542 |
+
"heading": "Composition",
|
| 543 |
+
"history": "1995, c. 40, s. 27; 2012, c. 24, s. 102",
|
| 544 |
+
"last_amended": "2019-01-15",
|
| 545 |
+
"in_force": "2019-01-15",
|
| 546 |
+
"status": "in force",
|
| 547 |
+
"current_to": "2022-03-22",
|
| 548 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-27.html",
|
| 549 |
+
"id": "A-8.8-s27",
|
| 550 |
+
"text": "27\n(1) The Review Tribunal, continued by subsection 4.1(1) of the Canada Agricultural Products Act, chapter 20 of the 4th Supplement to the Revised Statutes of Canada, 1985, is continued.\n(2) [Composition] The Tribunal consists of members to be appointed by the Governor in Council, one of whom is to be appointed as Chairperson.",
|
| 551 |
+
"citation": "AAAMPA, s. 27"
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"act_code": "A-8.8",
|
| 555 |
+
"act_short": "AAAMPA",
|
| 556 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 557 |
+
"section": "28",
|
| 558 |
+
"marginal_note": "Qualifications",
|
| 559 |
+
"part": "",
|
| 560 |
+
"division": "",
|
| 561 |
+
"heading": "Composition",
|
| 562 |
+
"history": "1995, c. 40, s. 28; 2012, c. 24, s. 102",
|
| 563 |
+
"last_amended": "2019-01-15",
|
| 564 |
+
"in_force": "2019-01-15",
|
| 565 |
+
"status": "in force",
|
| 566 |
+
"current_to": "2022-03-22",
|
| 567 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-28.html",
|
| 568 |
+
"id": "A-8.8-s28",
|
| 569 |
+
"text": "28 A person is not eligible to be appointed as a member unless the person is knowledgeable about or has experience related to agriculture or agri-food and the Chairperson and at least one other member must, in addition, be a lawyer of at least ten years’ standing at the bar of any province or a notary of at least ten years’ standing at the Chambre des notaires du Québec.",
|
| 570 |
+
"citation": "AAAMPA, s. 28"
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
"act_code": "A-8.8",
|
| 574 |
+
"act_short": "AAAMPA",
|
| 575 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 576 |
+
"section": "29",
|
| 577 |
+
"marginal_note": "Membership",
|
| 578 |
+
"part": "",
|
| 579 |
+
"division": "",
|
| 580 |
+
"heading": "Composition",
|
| 581 |
+
"history": "1995, c. 40, s. 29; 2012, c. 24, s. 102",
|
| 582 |
+
"last_amended": "2019-01-15",
|
| 583 |
+
"in_force": "2019-01-15",
|
| 584 |
+
"status": "in force",
|
| 585 |
+
"current_to": "2022-03-22",
|
| 586 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-29.html",
|
| 587 |
+
"id": "A-8.8-s29",
|
| 588 |
+
"text": "29 The Chairperson is to be appointed as a full-time member and the other members are to be appointed as either full-time members or part-time members.",
|
| 589 |
+
"citation": "AAAMPA, s. 29"
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
"act_code": "A-8.8",
|
| 593 |
+
"act_short": "AAAMPA",
|
| 594 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 595 |
+
"section": "30",
|
| 596 |
+
"marginal_note": "Tenure",
|
| 597 |
+
"part": "",
|
| 598 |
+
"division": "",
|
| 599 |
+
"heading": "Composition",
|
| 600 |
+
"history": "1995, c. 40, s. 30; 2012, c. 24, s. 102",
|
| 601 |
+
"last_amended": "2019-01-15",
|
| 602 |
+
"in_force": "2019-01-15",
|
| 603 |
+
"status": "in force",
|
| 604 |
+
"current_to": "2022-03-22",
|
| 605 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-30.html",
|
| 606 |
+
"id": "A-8.8-s30",
|
| 607 |
+
"text": "30\n(1) Each member is to be appointed for a term of not more than five years and holds office during good behaviour, but may be removed by the Governor in Council for cause.\n(2) [Re-appointment] Each member may be re-appointed as a member in the same or another capacity.",
|
| 608 |
+
"citation": "AAAMPA, s. 30"
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"act_code": "A-8.8",
|
| 612 |
+
"act_short": "AAAMPA",
|
| 613 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 614 |
+
"section": "31",
|
| 615 |
+
"marginal_note": "No other federal public administration",
|
| 616 |
+
"part": "",
|
| 617 |
+
"division": "",
|
| 618 |
+
"heading": "Composition",
|
| 619 |
+
"history": "1995, c. 40, s. 31; 2012, c. 24, s. 102",
|
| 620 |
+
"last_amended": "2019-01-15",
|
| 621 |
+
"in_force": "2019-01-15",
|
| 622 |
+
"status": "in force",
|
| 623 |
+
"current_to": "2022-03-22",
|
| 624 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-31.html",
|
| 625 |
+
"id": "A-8.8-s31",
|
| 626 |
+
"text": "31 A member must not hold any other office in the federal public administration.",
|
| 627 |
+
"citation": "AAAMPA, s. 31"
|
| 628 |
+
},
|
| 629 |
+
{
|
| 630 |
+
"act_code": "A-8.8",
|
| 631 |
+
"act_short": "AAAMPA",
|
| 632 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 633 |
+
"section": "32",
|
| 634 |
+
"marginal_note": "Conflict of interest",
|
| 635 |
+
"part": "",
|
| 636 |
+
"division": "",
|
| 637 |
+
"heading": "Composition",
|
| 638 |
+
"history": "1995, c. 40, s. 32; 2012, c. 24, s. 102",
|
| 639 |
+
"last_amended": "2019-01-15",
|
| 640 |
+
"in_force": "2019-01-15",
|
| 641 |
+
"status": "in force",
|
| 642 |
+
"current_to": "2022-03-22",
|
| 643 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-32.html",
|
| 644 |
+
"id": "A-8.8-s32",
|
| 645 |
+
"text": "32 A member must not accept or hold any office or employment that is inconsistent with the member’s duties or take part in any matter before the Tribunal in which the member has an interest.",
|
| 646 |
+
"citation": "AAAMPA, s. 32"
|
| 647 |
+
},
|
| 648 |
+
{
|
| 649 |
+
"act_code": "A-8.8",
|
| 650 |
+
"act_short": "AAAMPA",
|
| 651 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 652 |
+
"section": "33",
|
| 653 |
+
"marginal_note": "Duties of Chairperson",
|
| 654 |
+
"part": "",
|
| 655 |
+
"division": "",
|
| 656 |
+
"heading": "Chairperson",
|
| 657 |
+
"history": "1995, c. 40, s. 33; 2012, c. 24, s. 102; 2014, c. 20, s. 480",
|
| 658 |
+
"last_amended": "2019-01-15",
|
| 659 |
+
"in_force": "2019-01-15",
|
| 660 |
+
"status": "in force",
|
| 661 |
+
"current_to": "2022-03-22",
|
| 662 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-33.html",
|
| 663 |
+
"id": "A-8.8-s33",
|
| 664 |
+
"text": "33\n(1) The Chairperson apportions work among the Tribunal’s members.\n(2) [Absence or incapacity of Chairperson] If the Chairperson is absent or unable to act or the Chairperson’s position becomes vacant, the members must designate a member with the legal qualifications described in section 28 to act as Chairperson pending the appointment of a replacement, but no person may so act for a period exceeding 60 days without the approval of the Governor in Council.",
|
| 665 |
+
"citation": "AAAMPA, s. 33"
|
| 666 |
+
},
|
| 667 |
+
{
|
| 668 |
+
"act_code": "A-8.8",
|
| 669 |
+
"act_short": "AAAMPA",
|
| 670 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 671 |
+
"section": "34",
|
| 672 |
+
"marginal_note": "Remuneration",
|
| 673 |
+
"part": "",
|
| 674 |
+
"division": "",
|
| 675 |
+
"heading": "Remuneration and Expenses",
|
| 676 |
+
"history": "1995, c. 40, s. 34; 2012, c. 24, s. 102",
|
| 677 |
+
"last_amended": "2019-01-15",
|
| 678 |
+
"in_force": "2019-01-15",
|
| 679 |
+
"status": "in force",
|
| 680 |
+
"current_to": "2022-03-22",
|
| 681 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-34.html",
|
| 682 |
+
"id": "A-8.8-s34",
|
| 683 |
+
"text": "34\n(1) Each full-time member is to be paid the salary that is fixed by the Governor in Council and each part-time member is entitled to be paid the fees or other remuneration that is fixed by the Governor in Council.\n(2) [Travel and living expenses] Members are entitled to be paid, in accordance with Treasury Board directives, reasonable travel and living expenses incurred in the performance of their duties and functions while absent from their ordinary place of work, in the case of full-time members, or from their ordinary place of residence, in the case of part-time members.",
|
| 684 |
+
"citation": "AAAMPA, s. 34"
|
| 685 |
+
},
|
| 686 |
+
{
|
| 687 |
+
"act_code": "A-8.8",
|
| 688 |
+
"act_short": "AAAMPA",
|
| 689 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 690 |
+
"section": "35",
|
| 691 |
+
"marginal_note": "",
|
| 692 |
+
"part": "",
|
| 693 |
+
"division": "",
|
| 694 |
+
"heading": "Remuneration and Expenses",
|
| 695 |
+
"history": "",
|
| 696 |
+
"last_amended": "2019-01-15",
|
| 697 |
+
"in_force": "2019-01-15",
|
| 698 |
+
"status": "repealed",
|
| 699 |
+
"current_to": "2022-03-22",
|
| 700 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-35.html",
|
| 701 |
+
"id": "A-8.8-s35",
|
| 702 |
+
"text": "35 [Repealed, 2014, c. 20, s. 480]",
|
| 703 |
+
"citation": "AAAMPA, s. 35"
|
| 704 |
+
},
|
| 705 |
+
{
|
| 706 |
+
"act_code": "A-8.8",
|
| 707 |
+
"act_short": "AAAMPA",
|
| 708 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 709 |
+
"section": "36",
|
| 710 |
+
"marginal_note": "",
|
| 711 |
+
"part": "",
|
| 712 |
+
"division": "",
|
| 713 |
+
"heading": "Remuneration and Expenses",
|
| 714 |
+
"history": "",
|
| 715 |
+
"last_amended": "2019-01-15",
|
| 716 |
+
"in_force": "2019-01-15",
|
| 717 |
+
"status": "repealed",
|
| 718 |
+
"current_to": "2022-03-22",
|
| 719 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-36.html",
|
| 720 |
+
"id": "A-8.8-s36",
|
| 721 |
+
"text": "36 [Repealed, 2014, c. 20, s. 480]",
|
| 722 |
+
"citation": "AAAMPA, s. 36"
|
| 723 |
+
},
|
| 724 |
+
{
|
| 725 |
+
"act_code": "A-8.8",
|
| 726 |
+
"act_short": "AAAMPA",
|
| 727 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 728 |
+
"section": "37",
|
| 729 |
+
"marginal_note": "Head office",
|
| 730 |
+
"part": "",
|
| 731 |
+
"division": "",
|
| 732 |
+
"heading": "Head Office",
|
| 733 |
+
"history": "1995, c. 40, s. 37; 2012, c. 24, s. 102",
|
| 734 |
+
"last_amended": "2019-01-15",
|
| 735 |
+
"in_force": "2019-01-15",
|
| 736 |
+
"status": "in force",
|
| 737 |
+
"current_to": "2022-03-22",
|
| 738 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-37.html",
|
| 739 |
+
"id": "A-8.8-s37",
|
| 740 |
+
"text": "37\n(1) The head office of the Tribunal is to be in the National Capital Region as defined in section 2 of the National Capital Act.\n(2) [Sittings] The Tribunal is to sit at the places in Canada that may be specified by the Governor in Council.",
|
| 741 |
+
"citation": "AAAMPA, s. 37"
|
| 742 |
+
},
|
| 743 |
+
{
|
| 744 |
+
"act_code": "A-8.8",
|
| 745 |
+
"act_short": "AAAMPA",
|
| 746 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 747 |
+
"section": "38",
|
| 748 |
+
"marginal_note": "Tribunal",
|
| 749 |
+
"part": "",
|
| 750 |
+
"division": "",
|
| 751 |
+
"heading": "Jurisdiction",
|
| 752 |
+
"history": "1995, c. 40, s. 38; 2012, c. 24, s. 102",
|
| 753 |
+
"last_amended": "2019-01-15",
|
| 754 |
+
"in_force": "2019-01-15",
|
| 755 |
+
"status": "in force",
|
| 756 |
+
"current_to": "2022-03-22",
|
| 757 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-38.html",
|
| 758 |
+
"id": "A-8.8-s38",
|
| 759 |
+
"text": "38\n(1) The Tribunal has sole and exclusive jurisdiction to hear and determine all questions of fact or law in relation to any matter over which it is given jurisdiction under this Act or any other Act of Parliament.\n(2) [Review by Federal Court] An order of the Tribunal may only be reviewed under the Federal Courts Act.",
|
| 760 |
+
"citation": "AAAMPA, s. 38"
|
| 761 |
+
},
|
| 762 |
+
{
|
| 763 |
+
"act_code": "A-8.8",
|
| 764 |
+
"act_short": "AAAMPA",
|
| 765 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 766 |
+
"section": "39",
|
| 767 |
+
"marginal_note": "Chairperson",
|
| 768 |
+
"part": "",
|
| 769 |
+
"division": "",
|
| 770 |
+
"heading": "Jurisdiction",
|
| 771 |
+
"history": "1995, c. 40, s. 39; 2012, c. 24, s. 102",
|
| 772 |
+
"last_amended": "2019-01-15",
|
| 773 |
+
"in_force": "2019-01-15",
|
| 774 |
+
"status": "in force",
|
| 775 |
+
"current_to": "2022-03-22",
|
| 776 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-39.html",
|
| 777 |
+
"id": "A-8.8-s39",
|
| 778 |
+
"text": "39\n(1) The jurisdiction of the Tribunal in relation to the following matters is to be exercised by the Chairperson:\n(a) requests under subsection 8(1) or 12(2) for a review in respect of a notice of violation that contains a warning; and\n(b) requests under paragraph 9(2)(c) or 13(2)(b) for a review in respect of a notice of violation that sets out a penalty of less than $2,000.\n(2) [Other legally qualified members] The jurisdiction of the Tribunal in relation to a matter referred to in subsection (1) may be exercised, if the Chairperson so directs, by any member of the Tribunal with the legal qualifications described in section 28.",
|
| 779 |
+
"citation": "AAAMPA, s. 39"
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
"act_code": "A-8.8",
|
| 783 |
+
"act_short": "AAAMPA",
|
| 784 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 785 |
+
"section": "40",
|
| 786 |
+
"marginal_note": "Reviews",
|
| 787 |
+
"part": "",
|
| 788 |
+
"division": "",
|
| 789 |
+
"heading": "Jurisdiction",
|
| 790 |
+
"history": "1995, c. 40, s. 40; 2012, c. 24, s. 102",
|
| 791 |
+
"last_amended": "2019-01-15",
|
| 792 |
+
"in_force": "2019-01-15",
|
| 793 |
+
"status": "in force",
|
| 794 |
+
"current_to": "2022-03-22",
|
| 795 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-40.html",
|
| 796 |
+
"id": "A-8.8-s40",
|
| 797 |
+
"text": "40 Reviews by the Tribunal are to be heard by a single member.",
|
| 798 |
+
"citation": "AAAMPA, s. 40"
|
| 799 |
+
},
|
| 800 |
+
{
|
| 801 |
+
"act_code": "A-8.8",
|
| 802 |
+
"act_short": "AAAMPA",
|
| 803 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 804 |
+
"section": "41",
|
| 805 |
+
"marginal_note": "Court of record",
|
| 806 |
+
"part": "",
|
| 807 |
+
"division": "",
|
| 808 |
+
"heading": "Powers",
|
| 809 |
+
"history": "1995, c. 40, s. 41; 2012, c. 24, s. 102",
|
| 810 |
+
"last_amended": "2019-01-15",
|
| 811 |
+
"in_force": "2019-01-15",
|
| 812 |
+
"status": "in force",
|
| 813 |
+
"current_to": "2022-03-22",
|
| 814 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-41.html",
|
| 815 |
+
"id": "A-8.8-s41",
|
| 816 |
+
"text": "41\n(1) The Tribunal is a court of record with an official seal that must be judicially noticed.\n(2) [Examination of witnesses, etc.] In addition to the powers conferred by subsection (1), the Tribunal has, with respect to the appearance, swearing and examination of witnesses, the production and inspection of documents and other things, the enforcement of its orders and other matters necessary or proper for the due exercise of its jurisdiction, all the powers, rights and privileges that are vested in a superior court of record and, without limiting the generality of the foregoing, it may\n(a) issue a summons requiring a person\n(i) to appear at the time and place stated in the summons to testify to all matters within the person’s knowledge relative to any subject matter before the Tribunal, and\n(ii) to bring and produce any document, book or paper in the person’s possession or under the person’s control relative to that subject matter;\n(b) administer oaths and examine any person on oath; and\n(c) during a hearing, receive any evidence that it considers relevant and trustworthy.",
|
| 817 |
+
"citation": "AAAMPA, s. 41"
|
| 818 |
+
},
|
| 819 |
+
{
|
| 820 |
+
"act_code": "A-8.8",
|
| 821 |
+
"act_short": "AAAMPA",
|
| 822 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 823 |
+
"section": "42",
|
| 824 |
+
"marginal_note": "Rules",
|
| 825 |
+
"part": "",
|
| 826 |
+
"division": "",
|
| 827 |
+
"heading": "Rules",
|
| 828 |
+
"history": "1995, c. 40, s. 42; 2012, c. 24, s. 102",
|
| 829 |
+
"last_amended": "2019-01-15",
|
| 830 |
+
"in_force": "2019-01-15",
|
| 831 |
+
"status": "in force",
|
| 832 |
+
"current_to": "2022-03-22",
|
| 833 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-42.html",
|
| 834 |
+
"id": "A-8.8-s42",
|
| 835 |
+
"text": "42 The Tribunal may, with the approval of the Governor in Council, make rules governing\n(a) the practice and procedure in respect of hearings;\n(b) the time and manner in which applications and notices must be made or given; and\n(c) the work of the Tribunal under this or any other Act of Parliament.",
|
| 836 |
+
"citation": "AAAMPA, s. 42"
|
| 837 |
+
},
|
| 838 |
+
{
|
| 839 |
+
"act_code": "A-8.8",
|
| 840 |
+
"act_short": "AAAMPA",
|
| 841 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 842 |
+
"section": "43",
|
| 843 |
+
"marginal_note": "Consultations",
|
| 844 |
+
"part": "",
|
| 845 |
+
"division": "",
|
| 846 |
+
"heading": "General",
|
| 847 |
+
"history": "1995, c. 40, s. 43; 2012, c. 24, s. 102",
|
| 848 |
+
"last_amended": "2019-01-15",
|
| 849 |
+
"in_force": "2019-01-15",
|
| 850 |
+
"status": "in force",
|
| 851 |
+
"current_to": "2022-03-22",
|
| 852 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-43.html",
|
| 853 |
+
"id": "A-8.8-s43",
|
| 854 |
+
"text": "43 The members of the Tribunal may consult with other members of the Tribunal in respect of any matter before it.",
|
| 855 |
+
"citation": "AAAMPA, s. 43"
|
| 856 |
+
},
|
| 857 |
+
{
|
| 858 |
+
"act_code": "A-8.8",
|
| 859 |
+
"act_short": "AAAMPA",
|
| 860 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 861 |
+
"section": "44",
|
| 862 |
+
"marginal_note": "Rules of evidence",
|
| 863 |
+
"part": "",
|
| 864 |
+
"division": "",
|
| 865 |
+
"heading": "General",
|
| 866 |
+
"history": "1995, c. 40, s. 44; 2012, c. 24, s. 102",
|
| 867 |
+
"last_amended": "2019-01-15",
|
| 868 |
+
"in_force": "2019-01-15",
|
| 869 |
+
"status": "in force",
|
| 870 |
+
"current_to": "2022-03-22",
|
| 871 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-44.html",
|
| 872 |
+
"id": "A-8.8-s44",
|
| 873 |
+
"text": "44 The Tribunal is not bound by any legal or technical rules of evidence in conducting any matter that comes before it. It must deal with matters that come before it as informally and expeditiously as the circumstances and considerations of fairness and natural justice permit.",
|
| 874 |
+
"citation": "AAAMPA, s. 44"
|
| 875 |
+
},
|
| 876 |
+
{
|
| 877 |
+
"act_code": "A-8.8",
|
| 878 |
+
"act_short": "AAAMPA",
|
| 879 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 880 |
+
"section": "45",
|
| 881 |
+
"marginal_note": "Privileged evidence not receivable",
|
| 882 |
+
"part": "",
|
| 883 |
+
"division": "",
|
| 884 |
+
"heading": "General",
|
| 885 |
+
"history": "1995, c. 40, s. 45; 2012, c. 24, s. 102",
|
| 886 |
+
"last_amended": "2019-01-15",
|
| 887 |
+
"in_force": "2019-01-15",
|
| 888 |
+
"status": "in force",
|
| 889 |
+
"current_to": "2022-03-22",
|
| 890 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-45.html",
|
| 891 |
+
"id": "A-8.8-s45",
|
| 892 |
+
"text": "45 The Tribunal is not entitled to receive or accept as evidence anything that would be inadmissible in a court by reason of any privilege under the law of evidence.",
|
| 893 |
+
"citation": "AAAMPA, s. 45"
|
| 894 |
+
},
|
| 895 |
+
{
|
| 896 |
+
"act_code": "A-8.8",
|
| 897 |
+
"act_short": "AAAMPA",
|
| 898 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 899 |
+
"section": "46 to 89",
|
| 900 |
+
"marginal_note": "",
|
| 901 |
+
"part": "",
|
| 902 |
+
"division": "",
|
| 903 |
+
"heading": "General",
|
| 904 |
+
"history": "",
|
| 905 |
+
"last_amended": "2019-01-15",
|
| 906 |
+
"in_force": "2019-01-15",
|
| 907 |
+
"status": "in force",
|
| 908 |
+
"current_to": "2022-03-22",
|
| 909 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-46 to 89.html",
|
| 910 |
+
"id": "A-8.8-s46 to 89",
|
| 911 |
+
"text": "46 to 89 [Repealed, 2012, c. 24, s. 102]",
|
| 912 |
+
"citation": "AAAMPA, s. 46 to 89"
|
| 913 |
+
},
|
| 914 |
+
{
|
| 915 |
+
"act_code": "A-8.8",
|
| 916 |
+
"act_short": "AAAMPA",
|
| 917 |
+
"act_name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 918 |
+
"section": "90",
|
| 919 |
+
"marginal_note": "",
|
| 920 |
+
"part": "",
|
| 921 |
+
"division": "",
|
| 922 |
+
"heading": "General",
|
| 923 |
+
"history": "",
|
| 924 |
+
"last_amended": "2019-01-15",
|
| 925 |
+
"in_force": "2019-01-15",
|
| 926 |
+
"status": "repealed",
|
| 927 |
+
"current_to": "2022-03-22",
|
| 928 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/section-90.html",
|
| 929 |
+
"id": "A-8.8-s90",
|
| 930 |
+
"text": "90 [Repealed, 2012, c. 24, s. 102]",
|
| 931 |
+
"citation": "AAAMPA, s. 90"
|
| 932 |
+
}
|
| 933 |
+
]
|
data/processed/C-1.4-fr.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/processed/C-1.4.json
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"act_code": "C-1.4",
|
| 4 |
+
"act_short": "CBSA Act",
|
| 5 |
+
"act_name": "Canada Border Services Agency Act",
|
| 6 |
+
"section": "1",
|
| 7 |
+
"marginal_note": "Short title",
|
| 8 |
+
"part": "",
|
| 9 |
+
"division": "",
|
| 10 |
+
"heading": "Short Title",
|
| 11 |
+
"history": "",
|
| 12 |
+
"last_amended": "2005-12-12",
|
| 13 |
+
"in_force": "2005-12-12",
|
| 14 |
+
"status": "in force",
|
| 15 |
+
"current_to": "2024-11-11",
|
| 16 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-1.html",
|
| 17 |
+
"id": "C-1.4-s1",
|
| 18 |
+
"text": "1 This Act may be cited as the Canada Border Services Agency Act.",
|
| 19 |
+
"citation": "CBSA Act, s. 1"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"act_code": "C-1.4",
|
| 23 |
+
"act_short": "CBSA Act",
|
| 24 |
+
"act_name": "Canada Border Services Agency Act",
|
| 25 |
+
"section": "2",
|
| 26 |
+
"marginal_note": "Definitions",
|
| 27 |
+
"part": "",
|
| 28 |
+
"division": "",
|
| 29 |
+
"heading": "Interpretation",
|
| 30 |
+
"history": "2005, c. 38, ss. 2, 145; 2012, c. 24, s. 107; 2022, c. 10, s. 167",
|
| 31 |
+
"last_amended": "2022-09-01",
|
| 32 |
+
"in_force": "2005-12-12",
|
| 33 |
+
"status": "in force",
|
| 34 |
+
"current_to": "2024-11-11",
|
| 35 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-2.html",
|
| 36 |
+
"id": "C-1.4-s2",
|
| 37 |
+
"text": "2 The following definitions apply in this Act.\nAgency means the Canada Border Services Agency established under subsection 3(1). (Agence)\nMinister means the Minister of Public Safety and Emergency Preparedness. (ministre)\nPresident means the President of the Agency appointed under subsection 7(1). (président)\nprogram legislation means any other Act of Parliament or any instrument made under it, or any part of such an Act or instrument,\n(a) that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to administer and enforce, including the Excise Act, the Special Import Measures Act, the Customs Act, the Customs Tariff, the Immigration and Refugee Protection Act, the Excise Act, 2001 and the Select Luxury Items Tax Act;\n(b) that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to enforce, including the Agriculture and Agri-Food Administrative Monetary Penalties Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Plant Protection Act, the Safe Food for Canadians Act and the Seeds Act;\n(c) under which the Minister or another minister authorizes the Agency, the President or an employee of the Agency to administer a program or carry out an activity; or\n(d) under which duties or taxes collected and paid pursuant to the Customs Act are imposed. (législation frontalière)",
|
| 38 |
+
"citation": "CBSA Act, s. 2"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"act_code": "C-1.4",
|
| 42 |
+
"act_short": "CBSA Act",
|
| 43 |
+
"act_name": "Canada Border Services Agency Act",
|
| 44 |
+
"section": "3",
|
| 45 |
+
"marginal_note": "Agency established",
|
| 46 |
+
"part": "",
|
| 47 |
+
"division": "",
|
| 48 |
+
"heading": "Establishment and Mandate of the Agency",
|
| 49 |
+
"history": "",
|
| 50 |
+
"last_amended": "2005-12-12",
|
| 51 |
+
"in_force": "2005-12-12",
|
| 52 |
+
"status": "in force",
|
| 53 |
+
"current_to": "2024-11-11",
|
| 54 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-3.html",
|
| 55 |
+
"id": "C-1.4-s3",
|
| 56 |
+
"text": "3\n(1) The Canada Border Services Agency is established as a body corporate.\n(2) [Agent of Her Majesty] The Agency is for all purposes an agent of Her Majesty in right of Canada.",
|
| 57 |
+
"citation": "CBSA Act, s. 3"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"act_code": "C-1.4",
|
| 61 |
+
"act_short": "CBSA Act",
|
| 62 |
+
"act_name": "Canada Border Services Agency Act",
|
| 63 |
+
"section": "4",
|
| 64 |
+
"marginal_note": "Head office",
|
| 65 |
+
"part": "",
|
| 66 |
+
"division": "",
|
| 67 |
+
"heading": "Establishment and Mandate of the Agency",
|
| 68 |
+
"history": "",
|
| 69 |
+
"last_amended": "2005-12-12",
|
| 70 |
+
"in_force": "2005-12-12",
|
| 71 |
+
"status": "in force",
|
| 72 |
+
"current_to": "2024-11-11",
|
| 73 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-4.html",
|
| 74 |
+
"id": "C-1.4-s4",
|
| 75 |
+
"text": "4 The head office of the Agency is to be in the National Capital Region described in the schedule to the National Capital Act.",
|
| 76 |
+
"citation": "CBSA Act, s. 4"
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"act_code": "C-1.4",
|
| 80 |
+
"act_short": "CBSA Act",
|
| 81 |
+
"act_name": "Canada Border Services Agency Act",
|
| 82 |
+
"section": "5",
|
| 83 |
+
"marginal_note": "Mandate of Agency",
|
| 84 |
+
"part": "",
|
| 85 |
+
"division": "",
|
| 86 |
+
"heading": "Establishment and Mandate of the Agency",
|
| 87 |
+
"history": "",
|
| 88 |
+
"last_amended": "2005-12-12",
|
| 89 |
+
"in_force": "2005-12-12",
|
| 90 |
+
"status": "in force",
|
| 91 |
+
"current_to": "2024-11-11",
|
| 92 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-5.html",
|
| 93 |
+
"id": "C-1.4-s5",
|
| 94 |
+
"text": "5\n(1) The Agency is responsible for providing integrated border services that support national security and public safety priorities and facilitate the free flow of persons and goods, including animals and plants, that meet all requirements under the program legislation, by\n(a) supporting the administration or enforcement, or both, as the case may be, of the program legislation;\n(b) implementing agreements between the Government of Canada or the Agency and a foreign state or a public body performing a function of government in a foreign state to carry out an activity, provide a service or administer a tax or program;\n(c) implementing agreements between the Government of Canada or the Agency and the government of a province or other public body performing a function of the Government in Canada to carry out an activity, provide a service or administer a tax or program;\n(d) implementing agreements or arrangements between the Agency and departments or agencies of the Government of Canada to carry out an activity, provide a service or administer a program; and\n(e) providing cooperation and support, including advice and information, to other departments and agencies of the Government of Canada to assist them in developing, evaluating and implementing policies and decisions in relation to program legislation for which they have responsibility.\n(2) [Support] The Agency may provide support, through the provision of services, to departments and agencies for which the Minister is responsible, in accordance with agreements or arrangements entered into with those departments and agencies.",
|
| 95 |
+
"citation": "CBSA Act, s. 5"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"act_code": "C-1.4",
|
| 99 |
+
"act_short": "CBSA Act",
|
| 100 |
+
"act_name": "Canada Border Services Agency Act",
|
| 101 |
+
"section": "6",
|
| 102 |
+
"marginal_note": "Minister responsible",
|
| 103 |
+
"part": "",
|
| 104 |
+
"division": "",
|
| 105 |
+
"heading": "Minister",
|
| 106 |
+
"history": "",
|
| 107 |
+
"last_amended": "2005-12-12",
|
| 108 |
+
"in_force": "2005-12-12",
|
| 109 |
+
"status": "in force",
|
| 110 |
+
"current_to": "2024-11-11",
|
| 111 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-6.html",
|
| 112 |
+
"id": "C-1.4-s6",
|
| 113 |
+
"text": "6\n(1) The Minister is responsible for the Agency.\n(2) [Delegation by Minister] The Minister may delegate to any person any power, duty or function conferred on the Minister under this Act or under the program legislation.\n(3) [Exception] Subsection (2) does not apply if an Act of Parliament other than this Act authorizes the Minister to delegate the power, duty or function to any person or authorizes any person to exercise or perform it.\n(4) [Limitation] Subsection (2) does not apply in respect of a power to make regulations.",
|
| 114 |
+
"citation": "CBSA Act, s. 6"
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"act_code": "C-1.4",
|
| 118 |
+
"act_short": "CBSA Act",
|
| 119 |
+
"act_name": "Canada Border Services Agency Act",
|
| 120 |
+
"section": "7",
|
| 121 |
+
"marginal_note": "Appointment",
|
| 122 |
+
"part": "",
|
| 123 |
+
"division": "",
|
| 124 |
+
"heading": "President and Executive Vice-president",
|
| 125 |
+
"history": "",
|
| 126 |
+
"last_amended": "2005-12-12",
|
| 127 |
+
"in_force": "2005-12-12",
|
| 128 |
+
"status": "in force",
|
| 129 |
+
"current_to": "2024-11-11",
|
| 130 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-7.html",
|
| 131 |
+
"id": "C-1.4-s7",
|
| 132 |
+
"text": "7\n(1) The Governor in Council shall appoint a President of the Agency to hold office during pleasure for a term of not more than five years, which term may be renewed for one or more further terms.\n(2) [Executive Vice-president] The Governor in Council may appoint an Executive Vice-president of the Agency to hold office during pleasure for a term of not more than five years, which term may be renewed for one or more further terms.",
|
| 133 |
+
"citation": "CBSA Act, s. 7"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"act_code": "C-1.4",
|
| 137 |
+
"act_short": "CBSA Act",
|
| 138 |
+
"act_name": "Canada Border Services Agency Act",
|
| 139 |
+
"section": "8",
|
| 140 |
+
"marginal_note": "Role of President",
|
| 141 |
+
"part": "",
|
| 142 |
+
"division": "",
|
| 143 |
+
"heading": "President and Executive Vice-president",
|
| 144 |
+
"history": "",
|
| 145 |
+
"last_amended": "2005-12-12",
|
| 146 |
+
"in_force": "2005-12-12",
|
| 147 |
+
"status": "in force",
|
| 148 |
+
"current_to": "2024-11-11",
|
| 149 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-8.html",
|
| 150 |
+
"id": "C-1.4-s8",
|
| 151 |
+
"text": "8\n(1) The President, under the direction of the Minister, has the control and management of the Agency and all matters connected with it.\n(2) [Rank of deputy head] The President has the rank and all the powers of a deputy head of a department.\n(3) [Executive Vice-president’s powers] The Executive Vice-president shall exercise the powers and perform the duties and functions that the President may assign and shall act as President if that office is vacant or if the President is absent or incapacitated.",
|
| 152 |
+
"citation": "CBSA Act, s. 8"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"act_code": "C-1.4",
|
| 156 |
+
"act_short": "CBSA Act",
|
| 157 |
+
"act_name": "Canada Border Services Agency Act",
|
| 158 |
+
"section": "9",
|
| 159 |
+
"marginal_note": "Delegation by President",
|
| 160 |
+
"part": "",
|
| 161 |
+
"division": "",
|
| 162 |
+
"heading": "President and Executive Vice-president",
|
| 163 |
+
"history": "2005, c. 38, s. 9; 2012, c. 24, s. 108",
|
| 164 |
+
"last_amended": "2019-01-15",
|
| 165 |
+
"in_force": "2005-12-12",
|
| 166 |
+
"status": "in force",
|
| 167 |
+
"current_to": "2024-11-11",
|
| 168 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-9.html",
|
| 169 |
+
"id": "C-1.4-s9",
|
| 170 |
+
"text": "9\n(1) The President may delegate to any person any power, duty or function that the President is authorized to exercise or perform under this Act or any other enactment.\n(2) [Designation of officers] The President may designate any person, or person within a class of persons,\n(a) as an officer as defined in subsection 2(1) of the Customs Act to exercise any powers or perform any duties and functions of an officer under that Act that the President may specify; or\n(b) as an inspector or a veterinary inspector or other officer for the enforcement of any Act or instrument made under it, or any part of an Act or instrument, that the Governor in Council or Parliament authorizes the Minister, the Agency, the President or an employee of the Agency to enforce, including the Agriculture and Agri-Food Administrative Monetary Penalties Act, the Feeds Act, the Fertilizers Act, the Health of Animals Act, the Plant Protection Act, the Safe Food for Canadians Act and the Seeds Act.\n(3) [Designation power] The President may exercise any power that the Minister has to designate officers under subsection 6(1) of the Immigration and Refugee Protection Act.",
|
| 171 |
+
"citation": "CBSA Act, s. 9"
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"act_code": "C-1.4",
|
| 175 |
+
"act_short": "CBSA Act",
|
| 176 |
+
"act_name": "Canada Border Services Agency Act",
|
| 177 |
+
"section": "10",
|
| 178 |
+
"marginal_note": "Remuneration",
|
| 179 |
+
"part": "",
|
| 180 |
+
"division": "",
|
| 181 |
+
"heading": "President and Executive Vice-president",
|
| 182 |
+
"history": "2005, c. 38, ss. 10, 144(E)",
|
| 183 |
+
"last_amended": "2005-12-12",
|
| 184 |
+
"in_force": "2005-12-12",
|
| 185 |
+
"status": "in force",
|
| 186 |
+
"current_to": "2024-11-11",
|
| 187 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-10.html",
|
| 188 |
+
"id": "C-1.4-s10",
|
| 189 |
+
"text": "10\n(1) The President and the Executive Vice-president shall be paid the remuneration that is fixed by the Governor in Council.\n(2) [Expenses] The President and the Executive Vice-president are entitled to be paid reasonable travel and living expenses incurred by them in the course of performing their duties while absent from their ordinary place of work.\n(3) [Deemed employment] The President and the Executive Vice-president are deemed to be employed in the public service for the purposes of the Public Service Superannuation Act and to be employed in the federal public administration for the purposes of the Government Employees Compensation Act and any regulations made under section 9 of the Aeronautics Act.",
|
| 190 |
+
"citation": "CBSA Act, s. 10"
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"act_code": "C-1.4",
|
| 194 |
+
"act_short": "CBSA Act",
|
| 195 |
+
"act_name": "Canada Border Services Agency Act",
|
| 196 |
+
"section": "11",
|
| 197 |
+
"marginal_note": "Officers and employees",
|
| 198 |
+
"part": "",
|
| 199 |
+
"division": "",
|
| 200 |
+
"heading": "Human Resources",
|
| 201 |
+
"history": "",
|
| 202 |
+
"last_amended": "2005-12-12",
|
| 203 |
+
"in_force": "2005-12-12",
|
| 204 |
+
"status": "in force",
|
| 205 |
+
"current_to": "2024-11-11",
|
| 206 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-11.html",
|
| 207 |
+
"id": "C-1.4-s11",
|
| 208 |
+
"text": "11 Officers and employees necessary for the proper conduct of the work of the Agency shall be appointed in accordance with the Public Service Employment Act.",
|
| 209 |
+
"citation": "CBSA Act, s. 11"
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"act_code": "C-1.4",
|
| 213 |
+
"act_short": "CBSA Act",
|
| 214 |
+
"act_name": "Canada Border Services Agency Act",
|
| 215 |
+
"section": "12",
|
| 216 |
+
"marginal_note": "Exercise of powers conferred on Minister",
|
| 217 |
+
"part": "",
|
| 218 |
+
"division": "",
|
| 219 |
+
"heading": "Powers of the Agency",
|
| 220 |
+
"history": "",
|
| 221 |
+
"last_amended": "2005-12-12",
|
| 222 |
+
"in_force": "2005-12-12",
|
| 223 |
+
"status": "in force",
|
| 224 |
+
"current_to": "2024-11-11",
|
| 225 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-12.html",
|
| 226 |
+
"id": "C-1.4-s12",
|
| 227 |
+
"text": "12\n(1) Subject to any direction given by the Minister, the Agency may exercise the powers, and shall perform the duties and functions, that relate to the program legislation and that are conferred on, or delegated, assigned or transferred to, the Minister under any Act or regulation.\n(2) [Officers and employees] An officer or employee of the Agency may exercise any power or perform any duty or function referred to in subsection (1) if the officer or employee is appointed to serve in the Agency in a capacity appropriate to the exercise of the power or the performance of the duty or function, and, in so doing, shall comply with any general or special direction given by the Minister.\n(3) [Exception] Subsection (1) does not include\n(a) any power, duty or function of the Minister under this Act; or\n(b) a power to make regulations.\n(4) [Non-application of Statutory Instruments Act] A direction given by the Minister under subsection (1) or (2) is not a statutory instrument for the purposes of the Statutory Instruments Act.",
|
| 228 |
+
"citation": "CBSA Act, s. 12"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"act_code": "C-1.4",
|
| 232 |
+
"act_short": "CBSA Act",
|
| 233 |
+
"act_name": "Canada Border Services Agency Act",
|
| 234 |
+
"section": "13",
|
| 235 |
+
"marginal_note": "Agreements",
|
| 236 |
+
"part": "",
|
| 237 |
+
"division": "",
|
| 238 |
+
"heading": "Powers of the Agency",
|
| 239 |
+
"history": "2005, c. 38, s. 13; 2006, c. 12, s. 46",
|
| 240 |
+
"last_amended": "2007-02-10",
|
| 241 |
+
"in_force": "2007-02-10",
|
| 242 |
+
"status": "in force",
|
| 243 |
+
"current_to": "2024-11-11",
|
| 244 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-13.html",
|
| 245 |
+
"id": "C-1.4-s13",
|
| 246 |
+
"text": "13\n(1) Subject to sections 38 and 38.1 of the Proceeds of Crime (Money Laundering) and Terrorist Financing Act, the Agency may, with the approval of the Governor in Council, on the recommendation of the Minister and the Minister of Foreign Affairs, enter into an agreement with a foreign state or an international organization, for the purposes of carrying out the mandate of the Agency.\n(2) [Arrangements and agreements] The Agency may, for the purposes of carrying out its mandate,\n(a) enter into an arrangement with a foreign state or an international organization; or\n(b) enter into an agreement or arrangement with the government of a province, a department or agency of the Government of Canada or any person or organization.",
|
| 247 |
+
"citation": "CBSA Act, s. 13"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"act_code": "C-1.4",
|
| 251 |
+
"act_short": "CBSA Act",
|
| 252 |
+
"act_name": "Canada Border Services Agency Act",
|
| 253 |
+
"section": "14",
|
| 254 |
+
"marginal_note": "Agreements to administer a tax",
|
| 255 |
+
"part": "",
|
| 256 |
+
"division": "",
|
| 257 |
+
"heading": "Powers of the Agency",
|
| 258 |
+
"history": "",
|
| 259 |
+
"last_amended": "2005-12-12",
|
| 260 |
+
"in_force": "2005-12-12",
|
| 261 |
+
"status": "in force",
|
| 262 |
+
"current_to": "2024-11-11",
|
| 263 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-14.html",
|
| 264 |
+
"id": "C-1.4-s14",
|
| 265 |
+
"text": "14\n(1) The Agency may enter into or amend an agreement with a provincial or territorial government to administer a tax or other fiscal measure if the agreement is in accordance with guidelines relating to agreements of that kind established jointly by the Minister and the Minister of Finance.\n(2) [Application of the Federal-Provincial Fiscal Arrangements Act] Parts III and III.1 of the Federal-Provincial Fiscal Arrangements Act do not apply to an agreement entered into or amended under subsection (1).",
|
| 266 |
+
"citation": "CBSA Act, s. 14"
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"act_code": "C-1.4",
|
| 270 |
+
"act_short": "CBSA Act",
|
| 271 |
+
"act_name": "Canada Border Services Agency Act",
|
| 272 |
+
"section": "15",
|
| 273 |
+
"marginal_note": "Appropriation Acts",
|
| 274 |
+
"part": "",
|
| 275 |
+
"division": "",
|
| 276 |
+
"heading": "Expenditures",
|
| 277 |
+
"history": "",
|
| 278 |
+
"last_amended": "2005-12-12",
|
| 279 |
+
"in_force": "2005-12-12",
|
| 280 |
+
"status": "in force",
|
| 281 |
+
"current_to": "2024-11-11",
|
| 282 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-15.html",
|
| 283 |
+
"id": "C-1.4-s15",
|
| 284 |
+
"text": "15 An appropriation Act may provide that the balance of money appropriated by Parliament for the use of the Agency that remains unexpended at the end of the fiscal year, after the adjustments referred to in section 37 of the Financial Administration Act are made, lapses at the end of the following fiscal year.",
|
| 285 |
+
"citation": "CBSA Act, s. 15"
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
"act_code": "C-1.4",
|
| 289 |
+
"act_short": "CBSA Act",
|
| 290 |
+
"act_name": "Canada Border Services Agency Act",
|
| 291 |
+
"section": "15.1",
|
| 292 |
+
"marginal_note": "Annual report",
|
| 293 |
+
"part": "",
|
| 294 |
+
"division": "",
|
| 295 |
+
"heading": "Annual Report",
|
| 296 |
+
"history": "",
|
| 297 |
+
"last_amended": "2005-12-12",
|
| 298 |
+
"in_force": "2005-12-12",
|
| 299 |
+
"status": "in force",
|
| 300 |
+
"current_to": "2024-11-11",
|
| 301 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-15.1.html",
|
| 302 |
+
"id": "C-1.4-s15.1",
|
| 303 |
+
"text": "15.1\n(1) The Minister shall, as soon as possible after the end of each fiscal year but no later than the end of the calendar year in which that fiscal year ends, cause to be laid before each House of Parliament a report of the operations and performance of the Agency for that fiscal year.\n(2) [Reports required by Treasury Board] The obligation imposed by subsection (1) may be satisfied by the tabling of any reports of the operations and performance of the Agency required by the Treasury Board that contain the information required by that subsection.",
|
| 304 |
+
"citation": "CBSA Act, s. 15.1"
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"act_code": "C-1.4",
|
| 308 |
+
"act_short": "CBSA Act",
|
| 309 |
+
"act_name": "Canada Border Services Agency Act",
|
| 310 |
+
"section": "16",
|
| 311 |
+
"marginal_note": "Definitions",
|
| 312 |
+
"part": "",
|
| 313 |
+
"division": "",
|
| 314 |
+
"heading": "Definitions",
|
| 315 |
+
"history": "2005, c. 38, ss. 16, 144(E)",
|
| 316 |
+
"last_amended": "2005-12-12",
|
| 317 |
+
"in_force": "2005-12-12",
|
| 318 |
+
"status": "in force",
|
| 319 |
+
"current_to": "2024-11-11",
|
| 320 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-16.html",
|
| 321 |
+
"id": "C-1.4-s16",
|
| 322 |
+
"text": "16 The following definitions apply in sections 17 to 19 and 21 to 28.\nformer agency means the portion of the federal public administration known as the Canada Border Services Agency. (ancienne agence)\nnew agency means the Canada Border Services Agency established under subsection 3(1). (nouvelle agence)\norder P.C. 2003-2064 means Order in Council P.C. 2003-2064 of December 12, 2003, registered as SI/2003-216. (décret C.P. 2003-2064)",
|
| 323 |
+
"citation": "CBSA Act, s. 16"
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"act_code": "C-1.4",
|
| 327 |
+
"act_short": "CBSA Act",
|
| 328 |
+
"act_name": "Canada Border Services Agency Act",
|
| 329 |
+
"section": "17",
|
| 330 |
+
"marginal_note": "President and Executive Vice-president",
|
| 331 |
+
"part": "",
|
| 332 |
+
"division": "",
|
| 333 |
+
"heading": "Former Agency",
|
| 334 |
+
"history": "",
|
| 335 |
+
"last_amended": "2005-12-12",
|
| 336 |
+
"in_force": "2005-12-12",
|
| 337 |
+
"status": "in force",
|
| 338 |
+
"current_to": "2024-11-11",
|
| 339 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-17.html",
|
| 340 |
+
"id": "C-1.4-s17",
|
| 341 |
+
"text": "17\n(1) The persons occupying the positions of President and Executive Vice-president of the former agency on the day on which this section comes into force become the President and Executive Vice-president of the new agency on that day and are deemed to have been appointed under section 7.\n(2) [Positions] Nothing in this Act is to be construed as affecting the status of an employee who, immediately before the coming into force of this section, occupied a position in the former agency, except that the employee shall, on the coming into force of this section, occupy his or her position in the new agency under the direction of the President.\n(3) [Definition of employee] In subsection (2), employee has the same meaning as in subsection 2(1) of the Public Service Employment Act.",
|
| 342 |
+
"citation": "CBSA Act, s. 17"
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"act_code": "C-1.4",
|
| 346 |
+
"act_short": "CBSA Act",
|
| 347 |
+
"act_name": "Canada Border Services Agency Act",
|
| 348 |
+
"section": "18",
|
| 349 |
+
"marginal_note": "Transfer of appropriations",
|
| 350 |
+
"part": "",
|
| 351 |
+
"division": "",
|
| 352 |
+
"heading": "Former Agency",
|
| 353 |
+
"history": "2005, c. 38, ss. 18, 144(E)",
|
| 354 |
+
"last_amended": "2005-12-12",
|
| 355 |
+
"in_force": "2005-12-12",
|
| 356 |
+
"status": "in force",
|
| 357 |
+
"current_to": "2024-11-11",
|
| 358 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-18.html",
|
| 359 |
+
"id": "C-1.4-s18-p1",
|
| 360 |
+
"text": "18\n(1) Any amount appropriated, for the fiscal year in which this section comes into force, by an appropriation Act based on the Estimates for that year for defraying the charges and expenses of the federal public administration for the former agency that, on the day on which this section comes into force, is unexpended is deemed, on that day, to be an amount appropriated for defraying the charges and expenses of the federal public administration for the new agency.\n(2) [Transfer of powers, duties and functions] Wherever under any Act, order, rule or regulation, or any contract, lease, licence or other document, any power, duty or function is vested in or exercisable by the President of the former agency or an employee of the former agency, the power, duty or function is vested in and shall be exercised by the President of the new agency or an employee of the new agency unless the Governor in Council by order designates a deputy minister or an officer of the federal public administration to exercise that power or perform that duty or function.\n(3) [Continuation of proceedings] Any action, suit or other legal or administrative proceeding to which the former agency or its President is a party that is pending on the coming into force of this section may be continued by or against the new agency or its President in a similar manner and to the same extent as it would have been continued by or against the former agency or its President.\n(4) [Deeming] Decisions made by the President of the former agency are deemed to be decisions made by the President of the new agency.",
|
| 361 |
+
"citation": "CBSA Act, s. 18 (part 1 of 2)"
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"act_code": "C-1.4",
|
| 365 |
+
"act_short": "CBSA Act",
|
| 366 |
+
"act_name": "Canada Border Services Agency Act",
|
| 367 |
+
"section": "18",
|
| 368 |
+
"marginal_note": "Transfer of appropriations",
|
| 369 |
+
"part": "",
|
| 370 |
+
"division": "",
|
| 371 |
+
"heading": "Former Agency",
|
| 372 |
+
"history": "2005, c. 38, ss. 18, 144(E)",
|
| 373 |
+
"last_amended": "2005-12-12",
|
| 374 |
+
"in_force": "2005-12-12",
|
| 375 |
+
"status": "in force",
|
| 376 |
+
"current_to": "2024-11-11",
|
| 377 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-18.html",
|
| 378 |
+
"id": "C-1.4-s18-p2",
|
| 379 |
+
"text": "(5) [Validity of documents] All orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that are made or issued by the President of the former agency or any person under his or her authority continue in force as if they were made or issued by the President of the new agency or a person under his or her authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.\n(6) [Continuation of evidentiary presumption] Every affidavit sworn, or document purporting to be certified, by an employee of the former agency before the day on which this section comes into force has the same probative value as if it were sworn or certified by an employee of the new agency after that day.",
|
| 380 |
+
"citation": "CBSA Act, s. 18 (part 2 of 2)"
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"act_code": "C-1.4",
|
| 384 |
+
"act_short": "CBSA Act",
|
| 385 |
+
"act_name": "Canada Border Services Agency Act",
|
| 386 |
+
"section": "19",
|
| 387 |
+
"marginal_note": "References",
|
| 388 |
+
"part": "",
|
| 389 |
+
"division": "",
|
| 390 |
+
"heading": "Former Agency",
|
| 391 |
+
"history": "",
|
| 392 |
+
"last_amended": "2005-12-12",
|
| 393 |
+
"in_force": "2005-12-12",
|
| 394 |
+
"status": "in force",
|
| 395 |
+
"current_to": "2024-11-11",
|
| 396 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-19.html",
|
| 397 |
+
"id": "C-1.4-s19",
|
| 398 |
+
"text": "19\n(1) A reference to the former agency in any of the following is deemed to be a reference to the new agency:\n(a) Schedule I to the Access to Information Act under the heading “Other Government Institutions”;\n(b) the schedule to the Privacy Act;\n(c) Part I of Schedule I to the Public Service Staff Relations Act;\n(d) any order of the Governor in Council made under paragraph (b) of the definition head in section 3 of the Access to Information Act;\n(e) any direction of the Governor in Council made under subsection 24(3) of the Auditor General Act;\n(f) any order of the Governor in Council made under paragraph 29(e) of the Canadian Security Intelligence Service Act;\n(g) any order of the Governor in Council made under paragraph (b) of the definition head in section 3 of the Privacy Act; and\n(h) any order of the Governor in Council made under the definition department in subsection 2(1) of the Public Service Employment Act.\n(2) [Deputy head] The designation of a person as deputy head of the former agency in any of the following is deemed to be a designation of the President of the new agency as deputy head of that agency:\n(a) any order of the Governor in Council made under paragraph 29(e) of the Canadian Security Intelligence Service Act; and\n(b) any order of the Governor in Council made under the definition deputy head in subsection 2(1) of the Public Service Employment Act.",
|
| 399 |
+
"citation": "CBSA Act, s. 19"
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"act_code": "C-1.4",
|
| 403 |
+
"act_short": "CBSA Act",
|
| 404 |
+
"act_name": "Canada Border Services Agency Act",
|
| 405 |
+
"section": "20",
|
| 406 |
+
"marginal_note": "Positions",
|
| 407 |
+
"part": "",
|
| 408 |
+
"division": "",
|
| 409 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 410 |
+
"history": "",
|
| 411 |
+
"last_amended": "2005-12-12",
|
| 412 |
+
"in_force": "2005-12-12",
|
| 413 |
+
"status": "in force",
|
| 414 |
+
"current_to": "2024-11-11",
|
| 415 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-20.html",
|
| 416 |
+
"id": "C-1.4-s20",
|
| 417 |
+
"text": "20 Nothing in this Act shall be construed as affecting the status of an employee who, immediately before the coming into force of this section, occupied a position in the Canada Customs and Revenue Agency, except that the employee shall occupy that position in the Canada Revenue Agency.",
|
| 418 |
+
"citation": "CBSA Act, s. 20"
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"act_code": "C-1.4",
|
| 422 |
+
"act_short": "CBSA Act",
|
| 423 |
+
"act_name": "Canada Border Services Agency Act",
|
| 424 |
+
"section": "21",
|
| 425 |
+
"marginal_note": "Continuation of rights and property",
|
| 426 |
+
"part": "",
|
| 427 |
+
"division": "",
|
| 428 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 429 |
+
"history": "",
|
| 430 |
+
"last_amended": "2005-12-12",
|
| 431 |
+
"in_force": "2005-12-12",
|
| 432 |
+
"status": "in force",
|
| 433 |
+
"current_to": "2024-11-11",
|
| 434 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-21.html",
|
| 435 |
+
"id": "C-1.4-s21",
|
| 436 |
+
"text": "21\n(1) Subject to subsection (2), all rights and property of the Canada Customs and Revenue Agency continue as the rights and property of the Canada Revenue Agency.\n(2) [Transfer to new agency] All rights and property of the Canada Customs and Revenue Agency that are in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the new agency.",
|
| 437 |
+
"citation": "CBSA Act, s. 21"
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"act_code": "C-1.4",
|
| 441 |
+
"act_short": "CBSA Act",
|
| 442 |
+
"act_name": "Canada Border Services Agency Act",
|
| 443 |
+
"section": "22",
|
| 444 |
+
"marginal_note": "Continuation of obligations and liabilities",
|
| 445 |
+
"part": "",
|
| 446 |
+
"division": "",
|
| 447 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 448 |
+
"history": "",
|
| 449 |
+
"last_amended": "2005-12-12",
|
| 450 |
+
"in_force": "2005-12-12",
|
| 451 |
+
"status": "in force",
|
| 452 |
+
"current_to": "2024-11-11",
|
| 453 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-22.html",
|
| 454 |
+
"id": "C-1.4-s22",
|
| 455 |
+
"text": "22\n(1) Subject to subsection (2), all obligations and liabilities of the Canada Customs and Revenue Agency continue as obligations and liabilities of the Canada Revenue Agency.\n(2) [Transfer to new agency] All obligations and liabilities of the Canada Customs and Revenue Agency that were incurred in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the new agency.",
|
| 456 |
+
"citation": "CBSA Act, s. 22"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"act_code": "C-1.4",
|
| 460 |
+
"act_short": "CBSA Act",
|
| 461 |
+
"act_name": "Canada Border Services Agency Act",
|
| 462 |
+
"section": "23",
|
| 463 |
+
"marginal_note": "Real property, immovables and licences",
|
| 464 |
+
"part": "",
|
| 465 |
+
"division": "",
|
| 466 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 467 |
+
"history": "",
|
| 468 |
+
"last_amended": "2005-12-12",
|
| 469 |
+
"in_force": "2005-12-12",
|
| 470 |
+
"status": "in force",
|
| 471 |
+
"current_to": "2024-11-11",
|
| 472 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-23.html",
|
| 473 |
+
"id": "C-1.4-s23",
|
| 474 |
+
"text": "23\n(1) The administration of any real property or immovable, and the administrative responsibility for any licence in respect of any real property or immovable, that was under the administration or administrative responsibility of the Canada Customs and Revenue Agency immediately before the coming into force of this section and that was used for or in support of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 are transferred to the Minister.\n(2) [List] As soon as practicable after the coming into force of this section, the Minister of National Revenue shall publish in the Canada Gazette a list of the real property and immovables the administration of which was transferred under subsection (1) in such a way that each is sufficiently identified.\n(3) [Title] Where the title of any real property or immovable was held in the name of the Canada Customs and Revenue Agency immediately before the coming into force of this section and that real property or immovable was used for or in support of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064, the title to that real property or immovable is deemed to be held in the name of Her Majesty in right of Canada.\n(4) [Other real property, immovables and licences — Canada Revenue Agency] The administration of any real property or immovable, and the administrative responsibility for any licence in respect of any real property or immovable, that is not referred to in subsection (1) and that was under the administration or administrative responsibility of the Canada Customs and Revenue Agency immediately before the coming into force of this section continues under the administration or administrative responsibility, as the case may be, of the Canada Revenue Agency.",
|
| 475 |
+
"citation": "CBSA Act, s. 23"
|
| 476 |
+
},
|
| 477 |
+
{
|
| 478 |
+
"act_code": "C-1.4",
|
| 479 |
+
"act_short": "CBSA Act",
|
| 480 |
+
"act_name": "Canada Border Services Agency Act",
|
| 481 |
+
"section": "24",
|
| 482 |
+
"marginal_note": "Continuation of legal proceedings: Canada Revenue Agency",
|
| 483 |
+
"part": "",
|
| 484 |
+
"division": "",
|
| 485 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 486 |
+
"history": "",
|
| 487 |
+
"last_amended": "2005-12-12",
|
| 488 |
+
"in_force": "2005-12-12",
|
| 489 |
+
"status": "in force",
|
| 490 |
+
"current_to": "2024-11-11",
|
| 491 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-24.html",
|
| 492 |
+
"id": "C-1.4-s24",
|
| 493 |
+
"text": "24\n(1) Subject to subsection (2), any action, suit or other legal or administrative proceeding to which the Canada Customs and Revenue Agency is a party that is pending on the coming into force of this section may be continued by or against the Canada Revenue Agency in the same manner and to the same extent as it could have been continued by or against the Canada Customs and Revenue Agency.\n(2) [Continuation of legal proceedings: new agency] Any action, suit or other legal or administrative proceeding to which the Canada Customs and Revenue Agency is a party that is pending on the coming into force of this section may be continued by or against the new agency in the same manner and to the same extent as it could have been continued by or against the Canada Customs and Revenue Agency in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064.",
|
| 494 |
+
"citation": "CBSA Act, s. 24"
|
| 495 |
+
},
|
| 496 |
+
{
|
| 497 |
+
"act_code": "C-1.4",
|
| 498 |
+
"act_short": "CBSA Act",
|
| 499 |
+
"act_name": "Canada Border Services Agency Act",
|
| 500 |
+
"section": "25",
|
| 501 |
+
"marginal_note": "Validity of documents: Canada Revenue Agency",
|
| 502 |
+
"part": "",
|
| 503 |
+
"division": "",
|
| 504 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 505 |
+
"history": "",
|
| 506 |
+
"last_amended": "2005-12-12",
|
| 507 |
+
"in_force": "2005-12-12",
|
| 508 |
+
"status": "in force",
|
| 509 |
+
"current_to": "2024-11-11",
|
| 510 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-25.html",
|
| 511 |
+
"id": "C-1.4-s25",
|
| 512 |
+
"text": "25\n(1) Subject to subsection (2), all orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that were made or issued by the Minister of National Revenue or by the Commissioner of Customs and Revenue or any person under their authority continue in force as if they were made or issued by the Minister of National Revenue or the Commissioner of Revenue or any person under their authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.\n(2) [Validity of documents] All orders, rules, regulations, decisions, determinations and re-determinations, directions, licences, authorizations, certificates, consents, approvals, declarations, designations, permits, registrations, rates or other documents that are in force on the coming into force of this section and that were made or issued by the Minister of National Revenue or by the Commissioner of Customs and Revenue or any person under their authority that are in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 continue in force as if they were made or issued by the Minister, the President of the new agency or a person under their authority, as the case may be, until they expire or are repealed, replaced, rescinded or altered.",
|
| 513 |
+
"citation": "CBSA Act, s. 25"
|
| 514 |
+
},
|
| 515 |
+
{
|
| 516 |
+
"act_code": "C-1.4",
|
| 517 |
+
"act_short": "CBSA Act",
|
| 518 |
+
"act_name": "Canada Border Services Agency Act",
|
| 519 |
+
"section": "26",
|
| 520 |
+
"marginal_note": "Continuation of evidentiary presumption: Canada Revenue Agency",
|
| 521 |
+
"part": "",
|
| 522 |
+
"division": "",
|
| 523 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 524 |
+
"history": "",
|
| 525 |
+
"last_amended": "2005-12-12",
|
| 526 |
+
"in_force": "2005-12-12",
|
| 527 |
+
"status": "in force",
|
| 528 |
+
"current_to": "2024-11-11",
|
| 529 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-26.html",
|
| 530 |
+
"id": "C-1.4-s26",
|
| 531 |
+
"text": "26\n(1) Subject to subsection (2), every affidavit sworn, or document purporting to be certified, by an employee of the Canada Customs and Revenue Agency before the day on which this section comes into force has the same probative value as if it were sworn or certified by an employee of the Canada Revenue Agency after that day.\n(2) [Continuation of evidentiary presumption: Canada Border Services Agency] Every affidavit sworn, or document purporting to be certified, by an employee of the Canada Customs and Revenue Agency before the day on which this section comes into force that was sworn or was purported to be certified in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 has the same probative value as if it were sworn or certified by an employee of the new agency after that day.",
|
| 532 |
+
"citation": "CBSA Act, s. 26"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"act_code": "C-1.4",
|
| 536 |
+
"act_short": "CBSA Act",
|
| 537 |
+
"act_name": "Canada Border Services Agency Act",
|
| 538 |
+
"section": "27",
|
| 539 |
+
"marginal_note": "References",
|
| 540 |
+
"part": "",
|
| 541 |
+
"division": "",
|
| 542 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 543 |
+
"history": "",
|
| 544 |
+
"last_amended": "2005-12-12",
|
| 545 |
+
"in_force": "2005-12-12",
|
| 546 |
+
"status": "in force",
|
| 547 |
+
"current_to": "2024-11-11",
|
| 548 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-27.html",
|
| 549 |
+
"id": "C-1.4-s27",
|
| 550 |
+
"text": "27\n(1) Subject to subsection (2), every reference to the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue, the Deputy Commissioner of Customs and Revenue or any person under their authority in a document issued in the name of the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue or the Deputy Commissioner of Customs and Revenue is to be read, unless the context otherwise requires, as a reference to the Canada Revenue Agency, the Commissioner of Revenue, the Deputy Commissioner of Revenue or a person under their authority, as the case may be.\n(2) [References] Every reference to the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue, the Deputy Commissioner of Customs and Revenue or any person under their authority in a document issued in the name of the Canada Customs and Revenue Agency, the Commissioner of Customs and Revenue or the Deputy Commissioner of Customs and Revenue is to be read in respect of those documents that relate to those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064, unless the context otherwise requires, as a reference to the new agency, the President of the new agency, the Executive Vice-president of the new agency or a person under their authority, as the case may be.",
|
| 551 |
+
"citation": "CBSA Act, s. 27"
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"act_code": "C-1.4",
|
| 555 |
+
"act_short": "CBSA Act",
|
| 556 |
+
"act_name": "Canada Border Services Agency Act",
|
| 557 |
+
"section": "28",
|
| 558 |
+
"marginal_note": "References in documents and other provisions: Canada Revenue Agency",
|
| 559 |
+
"part": "",
|
| 560 |
+
"division": "",
|
| 561 |
+
"heading": "Canada Customs and Revenue Agency",
|
| 562 |
+
"history": "",
|
| 563 |
+
"last_amended": "2005-12-12",
|
| 564 |
+
"in_force": "2005-12-12",
|
| 565 |
+
"status": "in force",
|
| 566 |
+
"current_to": "2024-11-11",
|
| 567 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-28.html",
|
| 568 |
+
"id": "C-1.4-s28",
|
| 569 |
+
"text": "28\n(1) Subject to subsection (2), any expression referring to the Deputy Minister of National Revenue, the Department of National Revenue, the Commissioner of Customs and Revenue or the Canada Customs and Revenue Agency in any document, any instrument made under an Act of Parliament or any provision of an Act of Parliament not amended by this Act is to be read, unless the context otherwise requires, as a reference to the Commissioner of Revenue or the Canada Revenue Agency, as the case may be.\n(2) [References in documents and other provisions: Canada Border Services Agency] Any expression referring to the Deputy Minister of National Revenue, the Department of National Revenue, the Commissioner of Customs and Revenue or the Canada Customs and Revenue Agency in any document, any instrument made under an Act of Parliament or any provision of an Act of Parliament not amended by this Act and the document, instrument or provision is in respect of those portions of the Canada Customs and Revenue Agency the control and supervision of which were transferred to the former agency by order P.C. 2003-2064 is to be read, unless the context otherwise requires, as a reference to the President of the new agency or the new agency, as the case may be.",
|
| 570 |
+
"citation": "CBSA Act, s. 28"
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
"act_code": "C-1.4",
|
| 574 |
+
"act_short": "CBSA Act",
|
| 575 |
+
"act_name": "Canada Border Services Agency Act",
|
| 576 |
+
"section": "29",
|
| 577 |
+
"marginal_note": "Royal Canadian Mint Act",
|
| 578 |
+
"part": "",
|
| 579 |
+
"division": "",
|
| 580 |
+
"heading": "Royal Canadian Mint Act",
|
| 581 |
+
"history": "",
|
| 582 |
+
"last_amended": "2005-12-12",
|
| 583 |
+
"in_force": "2005-12-12",
|
| 584 |
+
"status": "in force",
|
| 585 |
+
"current_to": "2024-11-11",
|
| 586 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-29.html",
|
| 587 |
+
"id": "C-1.4-s29",
|
| 588 |
+
"text": "29 The Minister of National Revenue is the Minister for the purposes of the Royal Canadian Mint Act until another member of the Queen’s Privy Council for Canada is designated under section 2.1 of that Act, as enacted by section 130 of this Act.",
|
| 589 |
+
"citation": "CBSA Act, s. 29"
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
"act_code": "C-1.4",
|
| 593 |
+
"act_short": "CBSA Act",
|
| 594 |
+
"act_name": "Canada Border Services Agency Act",
|
| 595 |
+
"section": "*147",
|
| 596 |
+
"marginal_note": "Order in council",
|
| 597 |
+
"part": "",
|
| 598 |
+
"division": "",
|
| 599 |
+
"heading": "Coming into Force",
|
| 600 |
+
"history": "",
|
| 601 |
+
"last_amended": "2005-12-12",
|
| 602 |
+
"in_force": "2005-12-12",
|
| 603 |
+
"status": "in force",
|
| 604 |
+
"current_to": "2024-11-11",
|
| 605 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/section-*147.html",
|
| 606 |
+
"id": "C-1.4-s*147",
|
| 607 |
+
"text": "*147 This Act, except for sections 144 to 146, comes into force on a day to be fixed by order of the Governor in Council.\n* [Note: Sections 144 to 146 in force on assent November 3, 2005; Act, except sections 144 to 146, in force December 12, 2005, see SI/2005-119.]",
|
| 608 |
+
"citation": "CBSA Act, s. *147"
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"id": "C-1.4-nif-1",
|
| 612 |
+
"act_code": "C-1.4",
|
| 613 |
+
"act_short": "CBSA Act",
|
| 614 |
+
"act_name": "Canada Border Services Agency Act",
|
| 615 |
+
"section": "",
|
| 616 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 108",
|
| 617 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 618 |
+
"division": "",
|
| 619 |
+
"heading": "Amendments not in force",
|
| 620 |
+
"text": "108 Section 2 of the Canada Border Services Agency Act is amended by adding the following in alphabetical order:\nCommission means the Public Complaints and Review Commission established by subsection 3(1) of the Public Complaints and Review Commission Act. (Commission)",
|
| 621 |
+
"history": "2024, c. 25, s. 108",
|
| 622 |
+
"last_amended": "",
|
| 623 |
+
"in_force": "",
|
| 624 |
+
"status": "not-in-force",
|
| 625 |
+
"current_to": "2024-11-11",
|
| 626 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 108)",
|
| 627 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 628 |
+
},
|
| 629 |
+
{
|
| 630 |
+
"id": "C-1.4-nif-2",
|
| 631 |
+
"act_code": "C-1.4",
|
| 632 |
+
"act_short": "CBSA Act",
|
| 633 |
+
"act_name": "Canada Border Services Agency Act",
|
| 634 |
+
"section": "",
|
| 635 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 109",
|
| 636 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 637 |
+
"division": "",
|
| 638 |
+
"heading": "Amendments not in force",
|
| 639 |
+
"text": "109 Subsection 9(1) of the Act is replaced by the following:\n9 [Delegation by President]\n(1) Subject to subsection (1.1), the President may delegate to any person any power, duty or function that the President is authorized to exercise or perform under this Act or any other enactment.\n(1.1) [Exception] The President shall not delegate the power to make rules under section 39 of the Public Complaints and Review Commission Act and the power to enter into a memorandum of understanding under subsection 17(7) of that Act.",
|
| 640 |
+
"history": "2024, c. 25, s. 109",
|
| 641 |
+
"last_amended": "",
|
| 642 |
+
"in_force": "",
|
| 643 |
+
"status": "not-in-force",
|
| 644 |
+
"current_to": "2024-11-11",
|
| 645 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 109)",
|
| 646 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 647 |
+
},
|
| 648 |
+
{
|
| 649 |
+
"id": "C-1.4-nif-3",
|
| 650 |
+
"act_code": "C-1.4",
|
| 651 |
+
"act_short": "CBSA Act",
|
| 652 |
+
"act_name": "Canada Border Services Agency Act",
|
| 653 |
+
"section": "",
|
| 654 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 110",
|
| 655 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 656 |
+
"division": "",
|
| 657 |
+
"heading": "Amendments not in force",
|
| 658 |
+
"text": "110 Section 13 of the Act is amended by adding the following after subsection (2):\n(3) [Restriction] The Agency may enter into an agreement or arrangement with the government of a province respecting the detention of persons on behalf of the Agency only if the Minister is of the opinion that there is in the province an independent individual or body that is empowered to receive and deal with complaints about the treatment and conditions of detention of detained persons.\n(4) [Exception] If the Minister is of the opinion that there is an urgent need to provide for the detention of persons on a temporary basis, the Agency may, with the Minister’s approval, enter into an agreement or arrangement referred to in subsection (3) with the government of a province even if the Minister is of the opinion that there is no independent individual or body referred to in that subsection in the province.\n(5) [Duty to provide information] If the Agency is notified that a person who is or has been detained on behalf of the Agency under an agreement or arrangement referred to in subsection (3) has made a complaint respecting their treatment during detention — or their conditions of detention — to any competent authority in the province where they are or were detained, the Agency shall, as soon as feasible, provide the Commission with all of the information that the Agency has received or receives in relation to the complaint.\n(6) [Duty to provide copy] If the Agency enters into an agreement or arrangement respecting the monitoring of the treatment and of the conditions of detention of persons detained by or on behalf of the Agency or the inspection of any facility where persons are detained by or on behalf of the Agency, the Agency shall, as soon as feasible, provide the Commission with a copy of every document containing findings or recommendations that it receives under that agreement or arrangement.",
|
| 659 |
+
"history": "2024, c. 25, s. 110",
|
| 660 |
+
"last_amended": "",
|
| 661 |
+
"in_force": "",
|
| 662 |
+
"status": "not-in-force",
|
| 663 |
+
"current_to": "2024-11-11",
|
| 664 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 110)",
|
| 665 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 666 |
+
},
|
| 667 |
+
{
|
| 668 |
+
"id": "C-1.4-nif-4-p1",
|
| 669 |
+
"act_code": "C-1.4",
|
| 670 |
+
"act_short": "CBSA Act",
|
| 671 |
+
"act_name": "Canada Border Services Agency Act",
|
| 672 |
+
"section": "",
|
| 673 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
|
| 674 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 675 |
+
"division": "",
|
| 676 |
+
"heading": "Amendments not in force",
|
| 677 |
+
"text": "111 The Act is amended by adding the following after section 14:\n14.1 [Definitions]\n(1) The following definitions apply in this section and sections 14.2 to 14.9.\nemployee, in relation to the Agency, includes any person who assists the Agency in the exercise of any of its powers or the performance of any of its duties and functions under this Act. (employé)\nserious incident means an incident in which the actions of an officer or employee of the Agency in the exercise of its powers or the performance of its duties and functions under this Act\n(a) may have resulted in serious injury to or the death of any person; or\n(b) may have constituted an offence under federal or provincial law that the Minister or the President decides would be in the public interest to be investigated by the Agency. (incident grave)\nserious injury means a prescribed physical or psychological injury. (blessure grave)\n(2) [Regulations] The Governor in Council may make regulations prescribing physical or psychological injuries for the purposes of the definition serious injury in subsection (1).\n14.2 [Notification] As soon as feasible after becoming aware that a serious incident is alleged to have occurred, the President shall notify the police force that has jurisdiction at the location where it is alleged to have occurred and the Commission of the alleged serious incident.\n14.3 [Investigation]\n(1) Subject to subsection (2), as soon as feasible after the President becomes aware that a serious incident is alleged to have occurred, the Agency shall investigate the alleged serious incident.",
|
| 678 |
+
"history": "2024, c. 25, s. 111",
|
| 679 |
+
"last_amended": "",
|
| 680 |
+
"in_force": "",
|
| 681 |
+
"status": "not-in-force",
|
| 682 |
+
"current_to": "2024-11-11",
|
| 683 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 1 of 4)",
|
| 684 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 685 |
+
},
|
| 686 |
+
{
|
| 687 |
+
"id": "C-1.4-nif-4-p2",
|
| 688 |
+
"act_code": "C-1.4",
|
| 689 |
+
"act_short": "CBSA Act",
|
| 690 |
+
"act_name": "Canada Border Services Agency Act",
|
| 691 |
+
"section": "",
|
| 692 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
|
| 693 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 694 |
+
"division": "",
|
| 695 |
+
"heading": "Amendments not in force",
|
| 696 |
+
"text": "(2) [Limit on investigation] If the alleged serious incident involves the actions of one or more persons, the Agency shall not, in the investigation, investigate the actions of any person who assists the Agency in the exercise of any of its powers or the performance of any of its duties and functions under this Act by reason only of an agreement or arrangement referred to in subsection 13(3).\n14.4 [Copy of records] As soon as feasible after completing the investigation, the President shall provide the Commission with a copy of any records in its possession relating to the investigation. The President shall also provide the Commission with a copy of any record in respect of every other lawfully authorized investigation conducted into the alleged serious incident that the Agency receives after the completion of its own investigation.\n14.5 [Observer]\n(1) The Agency shall permit an observer appointed by the Commission under subsection (2) to assess the impartiality of the investigation.\n(2) [Appointment of observer] The Commission may appoint an observer to assess the impartiality of the investigation.\n(3) [Observers are compellable] Despite subsection 11(3) of the Public Complaints and Review Commission Act, but subject to section 14.8(1), every observer is a compellable witness in every criminal, civil or administrative action or proceeding, or inquiry, in respect of any matter coming to the knowledge of the observer as a result of exercising a power or performing a duty or function under this Part.",
|
| 697 |
+
"history": "2024, c. 25, s. 111",
|
| 698 |
+
"last_amended": "",
|
| 699 |
+
"in_force": "",
|
| 700 |
+
"status": "not-in-force",
|
| 701 |
+
"current_to": "2024-11-11",
|
| 702 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 2 of 4)",
|
| 703 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 704 |
+
},
|
| 705 |
+
{
|
| 706 |
+
"id": "C-1.4-nif-4-p3",
|
| 707 |
+
"act_code": "C-1.4",
|
| 708 |
+
"act_short": "CBSA Act",
|
| 709 |
+
"act_name": "Canada Border Services Agency Act",
|
| 710 |
+
"section": "",
|
| 711 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
|
| 712 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 713 |
+
"division": "",
|
| 714 |
+
"heading": "Amendments not in force",
|
| 715 |
+
"text": "(4) [No observer appointed] If no observer is appointed to an investigation of a serious incident under subsection (2), the President shall provide the Chairperson of the Commission with a report that sets out all measures that have been or will be taken by the Agency to ensure the impartiality of the investigation.\n14.6 [Recommendations] If the observer has concerns with the impartiality of the investigation, the observer may inform the Agency of their concerns and may make any recommendations to the Agency that they consider appropriate to address those concerns.\n14.7 [Report]\n(1) The observer shall, in accordance with the regulations, provide a report respecting the impartiality of the investigation of a serious incident to the Chairperson of the Commission and the President.\n(2) [Response] If the observer’s report identifies concerns with respect to the impartiality of an investigation, the President shall provide to the Chairperson of the Commission a written response to the observer’s report that includes a description of what actions have or will be taken by the Agency to address those concerns.\n(3) [Report on response] If the Chairperson of the Commission is not satisfied with the response of the President, the Chairperson shall issue a report to that effect to the Minister.\n14.8 [Information subject to privilege]\n(1) Nothing in this Act authorizes a person to disclose to an observer privileged information, as defined in subsection 17(1) of the Public Complaints and Review Commission Act, and an observer shall not use or disclose that information if it is disclosed.\n(2) [Offence to disclose certain information] Every person who contravenes subsection (1) is\n(a) guilty of an indictable offence and liable to imprisonment for a term of not more than five years; or",
|
| 716 |
+
"history": "2024, c. 25, s. 111",
|
| 717 |
+
"last_amended": "",
|
| 718 |
+
"in_force": "",
|
| 719 |
+
"status": "not-in-force",
|
| 720 |
+
"current_to": "2024-11-11",
|
| 721 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 3 of 4)",
|
| 722 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 723 |
+
},
|
| 724 |
+
{
|
| 725 |
+
"id": "C-1.4-nif-4-p4",
|
| 726 |
+
"act_code": "C-1.4",
|
| 727 |
+
"act_short": "CBSA Act",
|
| 728 |
+
"act_name": "Canada Border Services Agency Act",
|
| 729 |
+
"section": "",
|
| 730 |
+
"marginal_note": "Amendment not in force — 2024, c. 25, s. 111",
|
| 731 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 732 |
+
"division": "",
|
| 733 |
+
"heading": "Amendments not in force",
|
| 734 |
+
"text": "(b) guilty of an offence punishable on summary conviction and liable to a fine of not more than $5,000 or to imprisonment for a term of not more than six months, or to both.\n(3) [Limitation period] Summary conviction proceedings in respect of an offence under subsection (2) may be instituted at any time within but not later than two years after the time when the subject matter of the proceedings arose.\n14.9 [Regulations] The Governor in Council may make regulations\n(a) respecting the criteria and procedures for the appointment of an observer;\n(b) respecting the scope of an observer’s role;\n(c) respecting an observer’s reporting obligations;\n(d) respecting the access to, and use of, the notes, reports or other material prepared by an observer in relation to an investigation;\n(e) prescribing the period within which the President is to provide a response under subsection 14.7(2); and\n(f) generally for carrying out the purposes of sections 14.5 to 14.8.",
|
| 735 |
+
"history": "2024, c. 25, s. 111",
|
| 736 |
+
"last_amended": "",
|
| 737 |
+
"in_force": "",
|
| 738 |
+
"status": "not-in-force",
|
| 739 |
+
"current_to": "2024-11-11",
|
| 740 |
+
"citation": "CBSA Act — amendment not in force (2024, c. 25, s. 111) (part 4 of 4)",
|
| 741 |
+
"source_url": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"
|
| 742 |
+
}
|
| 743 |
+
]
|
data/processed/C-24.5-fr.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/processed/C-24.5.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/processed/C-29-fr.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|