Commit ·
ed26ce9
0
Parent(s):
Super-squash branch 'main' using huggingface_hub
Browse filesCo-authored-by: Beemer <Beemer@users.noreply.huggingface.co>
Co-authored-by: claude opus 5 (1m context) <claude opus 5 (1m context)@users.noreply.huggingface.co>
Co-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 +4 -0
- .github/workflows/ci.yml +30 -0
- .gitignore +15 -0
- .mcp.json +12 -0
- Dockerfile +81 -0
- README.md +147 -0
- RUNBOOK.md +0 -0
- canlex/__init__.py +1 -0
- canlex/_common.py +503 -0
- canlex/agreement.py +242 -0
- canlex/airports.py +237 -0
- canlex/amps.py +157 -0
- canlex/authorities.py +541 -0
- canlex/benefits.py +352 -0
- canlex/bm25.py +326 -0
- canlex/calllog.py +833 -0
- canlex/caselaw.py +0 -0
- canlex/charter.py +121 -0
- canlex/citator.py +340 -0
- canlex/ciu.py +292 -0
- canlex/commentary.py +482 -0
- canlex/conduct.py +401 -0
- canlex/config.py +518 -0
- canlex/curated_checks.py +578 -0
- canlex/delegation.py +509 -0
- canlex/directive.py +298 -0
- canlex/dmemo.py +374 -0
- canlex/embed.py +267 -0
- canlex/enf.py +1023 -0
- canlex/eval.py +310 -0
- canlex/index.py +0 -0
- canlex/ingest.py +671 -0
- canlex/irb_guidelines.py +165 -0
- canlex/irb_papers.py +480 -0
- canlex/loganalyze.py +381 -0
- canlex/narrative.py +731 -0
- canlex/pdi.py +321 -0
- canlex/ratelimit.py +238 -0
- canlex/readnext.py +224 -0
- canlex/refresh.py +1653 -0
- canlex/rehab.py +0 -0
- canlex/rerank.py +183 -0
- canlex/reviewed_code.py +0 -0
- canlex/screening.py +0 -0
- canlex/server.py +0 -0
- canlex/sweep.py +141 -0
- canlex/synonyms.py +208 -0
- canlex/tariff_schedule.py +287 -0
- canlex/transborder.py +778 -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,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data/processed/caselaw.json filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
data/processed/embeddings.npz filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
data/processed/I-3.3.json filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
data/processed/I-3.3-fr.json filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# The pinned set the Space builds with: the suite imports numpy,
|
| 24 |
+
# onnxruntime, huggingface_hub and tokenizers at collection time
|
| 25 |
+
# (test_embed, test_calllog), so a shorter list errors 25 tests before
|
| 26 |
+
# they run. No model is downloaded by any test (2026-09-21 audit).
|
| 27 |
+
- name: Install the pinned dependencies (no model downloads)
|
| 28 |
+
run: pip install -r requirements.txt
|
| 29 |
+
- name: Offline test suite
|
| 30 |
+
run: python -m unittest discover -s tests -v
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
data/raw/
|
| 6 |
+
# point-in-time section pages cached by tools/pit_history.py (build-time
|
| 7 |
+
# research only; already covered by data/raw/, listed so it stays ignored)
|
| 8 |
+
data/raw/pit/
|
| 9 |
+
canlii_key.txt
|
| 10 |
+
data/citator_*.json
|
| 11 |
+
US-Dispositions-Reference.pdf
|
| 12 |
+
*.bundle
|
| 13 |
+
data/logs/
|
| 14 |
+
# derived caches rebuilt on demand (canlex/bm25.py: the BM25 postings)
|
| 15 |
+
data/cache/
|
.mcp.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"HF_HUB_OFFLINE": "1"
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. The
|
| 19 |
+
# image installs requirements.lock: the whole set, transitive dependencies
|
| 20 |
+
# included, each pinned with its sha256 hashes -- `uv pip compile` of
|
| 21 |
+
# requirements.txt for Linux / Python 3.12 (RUNBOOK.md, "Audit follow-up
|
| 22 |
+
# implementation (2026-09-21): group C"). Hash-checking mode refuses any
|
| 23 |
+
# distribution but the locked one, so every build installs the same set (until
|
| 24 |
+
# the 2026-09-21 audit only the 8 direct pins were fixed and starlette, anyio
|
| 25 |
+
# and the other transitives were re-resolved at each build).
|
| 26 |
+
COPY requirements.lock .
|
| 27 |
+
RUN pip install --no-cache-dir --require-hashes -r requirements.lock
|
| 28 |
+
|
| 29 |
+
# Application code.
|
| 30 |
+
COPY --chown=app:app canlex/ ./canlex/
|
| 31 |
+
|
| 32 |
+
# Pre-fetch the embedder and cross-encoder models so the cache is baked into the
|
| 33 |
+
# image and the first request needs no network. The corpus is NOT re-embedded
|
| 34 |
+
# here -- the committed embeddings.npz is used as-is; only the bge query model
|
| 35 |
+
# (for embedding live queries) and the reranker are downloaded.
|
| 36 |
+
#
|
| 37 |
+
# Layer order (the code audit of 2026-09-21, optimization 9): this step reads
|
| 38 |
+
# canlex/ and the network only -- Embedder() and Reranker() read nothing under
|
| 39 |
+
# data/ at init -- so it runs BEFORE the corpus and curated COPYs below. Docker
|
| 40 |
+
# rebuilds every layer after the first one whose input changed; after those
|
| 41 |
+
# COPYs, every corpus refresh, curated change and composed release (which
|
| 42 |
+
# rewrites embeddings.npz) re-ran this step and re-downloaded the 331 MB of model
|
| 43 |
+
# files. Here only a change under canlex/ or to requirements.lock does. (Whether
|
| 44 |
+
# the Hugging Face builder keeps layers between builds is outside our control.)
|
| 45 |
+
# The user and environment it needs come first; the image's final user,
|
| 46 |
+
# environment and files are what they were before the move.
|
| 47 |
+
USER app
|
| 48 |
+
# CANLEX_LOG_DIR turns on the local call log (canlex/calllog.py: one JSON line
|
| 49 |
+
# per tool call, arguments included; loganalyze.py mines it). On the Space this
|
| 50 |
+
# path is ephemeral -- lost on every restart or rebuild. The durable log is the
|
| 51 |
+
# private Hugging Face dataset the server pushes to when the Space secrets
|
| 52 |
+
# CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN are set (RUNBOOK.md, "Call log"); the
|
| 53 |
+
# HF_HUB_OFFLINE=1 below still refuses every other Hub request, model files
|
| 54 |
+
# included -- calllog lets only that dataset's requests through.
|
| 55 |
+
ENV HOME=/app \
|
| 56 |
+
HF_HOME=/app/.hf_cache \
|
| 57 |
+
CANLEX_HTTP=1 \
|
| 58 |
+
CANLEX_LOG_DIR=/tmp/canlex-logs \
|
| 59 |
+
PORT=7860 \
|
| 60 |
+
PYTHONUNBUFFERED=1
|
| 61 |
+
|
| 62 |
+
RUN python -c "from canlex.embed import Embedder; Embedder()" \
|
| 63 |
+
&& python -c "from canlex.rerank import Reranker; Reranker()"
|
| 64 |
+
|
| 65 |
+
# The processed corpus (section-chunk JSON) and the prebuilt semantic
|
| 66 |
+
# embeddings (embeddings.npz, committed via git-LFS). Shipping the embeddings
|
| 67 |
+
# as an artifact keeps the build fast: embedding ~20k chunks on the build host
|
| 68 |
+
# took ~33 min and was tripping Hugging Face's job timeout as the corpus grew.
|
| 69 |
+
# Regenerate with 'python -m canlex.embed' after any corpus change and commit
|
| 70 |
+
# the result.
|
| 71 |
+
COPY --chown=app:app data/processed/*.json ./data/processed/
|
| 72 |
+
COPY --chown=app:app data/processed/embeddings.npz ./data/processed/
|
| 73 |
+
# Curated commentary datasets (the canlex_us_disposition tool reads the
|
| 74 |
+
# structured source file directly, not just its processed chunks).
|
| 75 |
+
COPY --chown=app:app data/curated/ ./data/curated/
|
| 76 |
+
|
| 77 |
+
# From here on, model files are served from the baked cache, never fetched.
|
| 78 |
+
ENV HF_HUB_OFFLINE=1
|
| 79 |
+
|
| 80 |
+
EXPOSE 7860
|
| 81 |
+
CMD ["python", "-m", "canlex.server"]
|
README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. Fourteen 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 (80 Justice Laws instruments, from IRPA and the Customs Act to the
|
| 20 |
+
sanctions Acts, the Criminal Records Act, the Income Tax Act and the
|
| 21 |
+
public-service labour statutes); the ENF operational manuals and IRCC program delivery
|
| 22 |
+
instructions; CBSA D-Memoranda and the AMPS Master Penalty Document; the
|
| 23 |
+
Charter; the Safe Third Country Agreement; Treasury Board collective
|
| 24 |
+
agreements; NJC directives; the CBSA Code of Conduct and the Treasury
|
| 25 |
+
Board people-management instruments; CBSA detention policy and the IRB
|
| 26 |
+
Chairperson's Guidelines and the IRB Legal Services papers on criminal
|
| 27 |
+
equivalency and criminal refusals; IRCC's rehabilitation application guide;
|
| 28 |
+
~245 leading decisions; delegation instruments; benefit-plan booklets; and
|
| 29 |
+
CanLex's curated commentary.
|
| 30 |
+
- **canlex_get_section** — fetch one provision verbatim by Act and section
|
| 31 |
+
(accepts 's. 34', '34(1)(c)', 'Schedule I').
|
| 32 |
+
- **canlex_us_disposition** — step 1 of the IRPA s. 36 screening chain: is a
|
| 33 |
+
foreign disposition a "conviction"? Curated for US records, with a
|
| 34 |
+
51-jurisdiction state-by-state dataset; a non-US record (the optional
|
| 35 |
+
`country`, or a state value that is not a US state or territory) gets the
|
| 36 |
+
framework and the entry-level verdict, labelled "non-US record: curated
|
| 37 |
+
coverage is thin". The tool names keep their `us_` prefix so saved client
|
| 38 |
+
configs keep working. The answer ends with a READ NEXT block: the official
|
| 39 |
+
guidance and statute on the point, from the corpus (chunk id, status and a
|
| 40 |
+
verbatim quote; unofficial ATIP copies of withdrawn chapters flagged and
|
| 41 |
+
listed after the official sources).
|
| 42 |
+
- **canlex_us_equivalency** — step 2: the Canadian equivalent of a foreign
|
| 43 |
+
offence (US-curated pairings), its verified maximum penalty, and the
|
| 44 |
+
inadmissibility branch, with the pairing's READ NEXT block.
|
| 45 |
+
- **canlex_rehabilitation** — step 3: deemed rehabilitation / eligibility to
|
| 46 |
+
apply, under IRPA s. 36(3)(c) and IRPR ss. 17–18.1 — from dates, with the
|
| 47 |
+
earliest eligibility dates computed.
|
| 48 |
+
- **canlex_screen_record** — the three steps in one call for a foreign record
|
| 49 |
+
(US state rows where they exist; optional `country`), from the record's
|
| 50 |
+
own words and dates: a screening memo with the draft allegation paragraph
|
| 51 |
+
(both paragraphs when the disposition or a tie is unsettled), the next
|
| 52 |
+
steps at the port of entry (s. 44 report, the removal path by paragraph,
|
| 53 |
+
TRP, rehabilitation application with the IRPR fees, family members by
|
| 54 |
+
application type), what the chain assumed, an "s. 37 also to consider"
|
| 55 |
+
block where the offence or the optional `organization` fact raises
|
| 56 |
+
organized criminality, a "Read next" line per step, and one structured
|
| 57 |
+
result line. Where the record's words reach curated content still held for
|
| 58 |
+
review that the deployment lacks, the memo says so ("HELD FOR REVIEW -- not
|
| 59 |
+
in this deployment") and lists it among what the chain assumed. A conviction in
|
| 60 |
+
Canada (country 'Canada' or a province) is screened on s. 36(1)(a)/(2)(a)
|
| 61 |
+
instead: discharges, conditional sentences, s. 18.1, the record suspension
|
| 62 |
+
as the cure, the removal path and a permanent resident's appeal line. An
|
| 63 |
+
optional `date_of_birth` (or `age_at_offence`) prints the youth block,
|
| 64 |
+
statute and IRCC policy kept apart. At the top, **THE ANSWER CHANGES WITH
|
| 65 |
+
THESE FACTS** lists each missing fact that changes the branch, the allegation
|
| 66 |
+
paragraph or the rehabilitation status (the chain re-run with every plausible
|
| 67 |
+
value; CANLEX_RESULT `decisive_missing`). An optional `conviction_date`
|
| 68 |
+
bounds the maximum at commission; `sentence_imposed` with `sentence_date`
|
| 69 |
+
adds a completion date **IF SERVED EXACTLY AS IMPOSED** (Guide 5312's rules;
|
| 70 |
+
never a verdict, and a fine blocks it); `detail: "card"` returns a result
|
| 71 |
+
card of about ten lines instead of the memo. The full memo ends with a
|
| 72 |
+
**DRAFT s. 44(1) narrative skeleton** in the ENF 5 s. 13.1 form, in English,
|
| 73 |
+
French or both (`language`): the proposed IRPA paragraph with its words, the
|
| 74 |
+
offence as recorded, the Canadian provision (the pairing's curated report
|
| 75 |
+
provision, chosen by the matched limb and the maximum at commission) and the
|
| 76 |
+
maximum in the statute's own words. Every bracket is to be verified; what
|
| 77 |
+
CanLex does not hold (name, place, the conviction date unless given, the
|
| 78 |
+
status evidence) stays `[CONFIRM: ...]`, and a result that is not settled
|
| 79 |
+
gets one allegation per reading, never a settled one.
|
| 80 |
+
- **canlex_screen_history** — a whole record at once: every entry through the
|
| 81 |
+
chain (foreign or Canadian), then the record-level count for IRPR s. 18(2)
|
| 82 |
+
(equal or missing offence dates on a two-offence count give both readings),
|
| 83 |
+
deemed rehabilitation tested limb by limb (IRPR s. 18(2)(a)-(c) and s. 18.1:
|
| 84 |
+
a table with the entry that decides each limb and the date it starts or stops
|
| 85 |
+
holding; both readings where an unsettled reading decides it; an optional
|
| 86 |
+
`conviction_date` per entry, which also bounds the maximum at commission)
|
| 87 |
+
and the s. 17 clock from the most recent conviction, Canadian ones
|
| 88 |
+
included. The decisive facts, `detail: "card"` and the narrative skeleton
|
| 89 |
+
(one allegation per entry that grounds one; the two-offence limb over the
|
| 90 |
+
summary-only convictions) as for canlex_screen_record; an entry's `relief`
|
| 91 |
+
(a pardon, expungement, sealing or record suspension, or 'none') is read as
|
| 92 |
+
the disposition's later history.
|
| 93 |
+
- **canlex_screen** — the same record pipeline, with the entries as typed
|
| 94 |
+
objects (one per conviction, disposition or charge, in the record's own
|
| 95 |
+
words; an unknown key is refused, so a misspelt key never silently changes
|
| 96 |
+
an answer). There is no free-text rap-sheet parsing. A record-level
|
| 97 |
+
`relief` applies to every entry without its own; 'none' confirms there is
|
| 98 |
+
none. Returns the result card by default (`detail: "full"` for everything,
|
| 99 |
+
the narrative skeleton included). Every screening tool's CANLEX_RESULT carries
|
| 100 |
+
`confidence`: what each step rests on (the entry's and pairing's authority
|
| 101 |
+
status, whether the state row was verified, how many cited decisions are in
|
| 102 |
+
the corpus, where the maximum came from, the s. 18(2) limbs left unknown).
|
| 103 |
+
- **canlex_transborder** — an offence committed on entering Canada (an
|
| 104 |
+
undeclared firearm, cannabis or drugs): transborder criminality under IRPA
|
| 105 |
+
s. 36(2.1) and IRPR s. 19 — whether the offence is prescribed, whether the
|
| 106 |
+
Minister's delegate or the Immigration Division makes the deportation
|
| 107 |
+
order (IRPR ss. 228(1)(a.01), 229(1)(d.1)), no rehabilitation, the
|
| 108 |
+
contraventions check, TRP and a draft allegation. Foreign nationals only.
|
| 109 |
+
- **canlex_list_acts** — the corpus inventory and its currency.
|
| 110 |
+
- **canlex_whats_new** — provisions amended or newly in force since a date.
|
| 111 |
+
- **canlex_citing** — reverse cross-references: what cites a given section.
|
| 112 |
+
- **canlex_toc** — an Act's table of contents (Parts and marginal notes).
|
| 113 |
+
- **canlex_case** — live CanLII case citator (citation graph by neutral
|
| 114 |
+
citation or URL).
|
| 115 |
+
|
| 116 |
+
The server also publishes an MCP prompt (`us_record_screening`, the guided
|
| 117 |
+
three-step screening flow) and resources (the corpus inventory and the
|
| 118 |
+
conviction/equivalency methodology texts) that any MCP client can use.
|
| 119 |
+
|
| 120 |
+
The labour side now carries the full union stack alongside the FB collective
|
| 121 |
+
agreement: the CIU National By-Laws (EN/FR), CIU Regulations, Branch Rules
|
| 122 |
+
and local modifications, the Branch Elections Kit, the PSAC Constitution,
|
| 123 |
+
the union's member guides, and every published site VSSA — all bannered as
|
| 124 |
+
bargaining-agent documents, distinct from employer policy.
|
| 125 |
+
|
| 126 |
+
Retrieval runs fully locally inside the container and needs no API key. The
|
| 127 |
+
optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
|
| 128 |
+
variable. A weekly staleness check (`canlex.refresh --check`, run every
|
| 129 |
+
Monday by a Claude Code scheduled task on the maintainer's machine --
|
| 130 |
+
RUNBOOK.md, "The staleness watch") watches upstream sources for drift; see
|
| 131 |
+
RUNBOOK.md for the maintainer's refresh choreography.
|
| 132 |
+
|
| 133 |
+
Add this Space's `/mcp` URL (`https://beemer0-canlex.hf.space/mcp`) as a
|
| 134 |
+
custom connector in any Claude client, or point any other MCP-capable agent
|
| 135 |
+
at it. CanLex is an MCP server only: it has no chat page of its own, and it
|
| 136 |
+
calls no hosted model API.
|
| 137 |
+
|
| 138 |
+
**Call log (2026-09-16):** tool calls to this server, including their
|
| 139 |
+
arguments, are logged by the operator, privately, to improve CanLex
|
| 140 |
+
(RUNBOOK.md, "Call log"). The server says the same to every MCP client when it
|
| 141 |
+
connects (its MCP instructions) and at its root URL,
|
| 142 |
+
`https://beemer0-canlex.hf.space/`.
|
| 143 |
+
|
| 144 |
+
**2026-09-16:** the companion Gradio chat page (the separate `Canlex-web`
|
| 145 |
+
Space) and its Google Gemini API integration were retired. The `webapp/`
|
| 146 |
+
directory, its tests and the predeploy parity step were removed; the git
|
| 147 |
+
history keeps them.
|
RUNBOOK.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""CanLex - Canadian legal retrieval for Claude."""
|
canlex/_common.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 base64
|
| 12 |
+
import contextlib
|
| 13 |
+
import http.client
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
import time
|
| 19 |
+
import urllib.error
|
| 20 |
+
import urllib.request
|
| 21 |
+
from collections import Counter
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# The browser User-Agent used for government sites whose CDN denylists obvious
|
| 25 |
+
# non-browser clients (the pages are public; politeness comes from caching and
|
| 26 |
+
# the pauses below). The polite UA is for hosts that accept ordinary clients.
|
| 27 |
+
BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 28 |
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
| 29 |
+
POLITE_UA = "CanLex/0.1"
|
| 30 |
+
|
| 31 |
+
# Month-name lookup for parsing prose dates ("June 21, 2026").
|
| 32 |
+
MONTHS = {m: i for i, m in enumerate(
|
| 33 |
+
["january", "february", "march", "april", "may", "june", "july", "august",
|
| 34 |
+
"september", "october", "november", "december"], start=1)}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Invisible format characters (Unicode category Cf) that sources leave INSIDE
|
| 38 |
+
# words: the soft hyphen U+00AD ('perform\xadance', C-46 s. 176), the zero-width
|
| 39 |
+
# space, non-joiner and joiner U+200B-U+200D (after the IRB guidelines' heading
|
| 40 |
+
# numbers; inside the French CITES names), the word joiner U+2060 and a stray
|
| 41 |
+
# byte-order mark U+FEFF. None is whitespace, so \s never matched them: they
|
| 42 |
+
# split a word into two tokens for BM25 and broke any curated verify phrase
|
| 43 |
+
# spanning one. Measured on the 2026-09-21 corpus: 237 chunks carried one.
|
| 44 |
+
FORMAT_CHARS = re.compile("[\u00ad\u200b\u200c\u200d\u2060\ufeff]")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def strip_format_chars(text):
|
| 48 |
+
"""`text` without the invisible in-word format characters (FORMAT_CHARS)."""
|
| 49 |
+
return FORMAT_CHARS.sub("", text or "")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def norm_ws(text):
|
| 53 |
+
"""Collapse all whitespace to single spaces and trim, after dropping the
|
| 54 |
+
invisible in-word format characters (FORMAT_CHARS, 2026-09-22 audit
|
| 55 |
+
follow-up: 'assist\\xadance' now reads 'assistance').
|
| 56 |
+
|
| 57 |
+
\\s in a Python 3 str pattern already matches NBSP, en/em/thin spaces and
|
| 58 |
+
friends, so no pre-replacement is needed (several old copies carried a
|
| 59 |
+
redundant .replace("\\xa0", " ")).
|
| 60 |
+
"""
|
| 61 |
+
return re.sub(r"\s+", " ", strip_format_chars(text)).strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --- line-break hyphens in PDF text ------------------------------------------
|
| 65 |
+
# A PDF's text layer keeps the typesetter's line breaks, so a word hyphenated
|
| 66 |
+
# at a line end reaches the chunk as 'cross-\nexamine' and the lexical leg
|
| 67 |
+
# never sees 'cross-examine' (374 PDF chunks, 2026-09-21 audit). Only 63 of
|
| 68 |
+
# the 398 breaks measured have an un-hyphenated form attested elsewhere in the
|
| 69 |
+
# corpus, so the hyphen is never deleted blindly: by default only the line
|
| 70 |
+
# break goes ('cross-examine'), and the hyphen is dropped only when the
|
| 71 |
+
# document itself uses the joined word ('immi-\ngration' where 'immigration'
|
| 72 |
+
# occurs). Inside a URL the hyphen always stays ('laws-\nlois.justice.gc.ca').
|
| 73 |
+
_WORD = re.compile(r"[^\W\d_]+")
|
| 74 |
+
# a word and its hyphen (a printed one or a soft hyphen) closing a line
|
| 75 |
+
_BREAK_TAIL = re.compile(r"([^\W\d_]+)[-\u00ad][ \t]*$")
|
| 76 |
+
# the continuation that opens the next line: letters, then anything up to a space
|
| 77 |
+
_BREAK_HEAD = re.compile(r"[ \t]*(([^\W\d_]+)\S*)")
|
| 78 |
+
_URL_TOKEN = re.compile(r"://|^www\.|\.(?:ca|com|org|gov|gc|net|int)\b|/", re.I)
|
| 79 |
+
# a soft hyphen that ends a line: the printed hyphen of a broken word
|
| 80 |
+
# (WinAnsiEncoding maps the hyphen glyph to 0xAD, which pypdf reads as U+00AD)
|
| 81 |
+
_SOFT_BREAK = re.compile("\u00ad(?=[ \t]*(?:\n|$))")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def document_words(texts):
|
| 85 |
+
"""Lower-cased word counts over `texts` -- a document's own vocabulary,
|
| 86 |
+
for dehyphenate."""
|
| 87 |
+
words = Counter()
|
| 88 |
+
for text in texts:
|
| 89 |
+
words.update(w.lower() for w in _WORD.findall(text or ""))
|
| 90 |
+
return words
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def mark_soft_breaks(text):
|
| 94 |
+
"""A soft hyphen that ends a line of PDF text becomes '-', so the break
|
| 95 |
+
survives norm_ws (which drops every U+00AD) for dehyphenate to read."""
|
| 96 |
+
return _SOFT_BREAK.sub("-", text or "")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _joined(prev, tail, fragment, letters, words):
|
| 100 |
+
"""`prev` (which ends in `tail` and a hyphen) with its broken word closed
|
| 101 |
+
up with `fragment` (whose leading letters are `letters`)."""
|
| 102 |
+
stem = prev.rstrip(" \t")[:-1] # without the hyphen
|
| 103 |
+
token = stem.split()[-1] if stem.split() else ""
|
| 104 |
+
keep = (bool(_URL_TOKEN.search(token)) or not words
|
| 105 |
+
or not letters[0].islower()
|
| 106 |
+
or not words.get((tail + letters).lower()))
|
| 107 |
+
return stem + ("-" if keep else "") + fragment
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def dehyphenate(text, words=None):
|
| 111 |
+
"""`text` with each line-end hyphen break closed up (see above).
|
| 112 |
+
|
| 113 |
+
The continuation -- the next line's first run of non-space characters --
|
| 114 |
+
moves up onto the hyphenated line and the rest of that line stays where
|
| 115 |
+
it was, so line structure (and anything keyed on it) is untouched; a line
|
| 116 |
+
left empty is dropped. `words` is the document's vocabulary
|
| 117 |
+
(document_words): the hyphen goes only when the joined word is in it and
|
| 118 |
+
the continuation opens lower-case, never inside a URL. With no `words`
|
| 119 |
+
every hyphen is kept. A printed hyphen or a soft hyphen (U+00AD) counts;
|
| 120 |
+
the kept one is always '-'. Pure, for tests."""
|
| 121 |
+
out = []
|
| 122 |
+
for line in (text or "").split("\n"):
|
| 123 |
+
prev = out[-1] if out else ""
|
| 124 |
+
tail = _BREAK_TAIL.search(prev)
|
| 125 |
+
head = _BREAK_HEAD.match(line) if tail else None
|
| 126 |
+
if head:
|
| 127 |
+
out[-1] = _joined(prev, tail.group(1), head.group(1), head.group(2), words)
|
| 128 |
+
line = line[head.end():].lstrip(" \t")
|
| 129 |
+
if not line:
|
| 130 |
+
continue
|
| 131 |
+
out.append(line)
|
| 132 |
+
return "\n".join(out)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _ps_literal(text):
|
| 136 |
+
"""`text` as a single-quoted PowerShell string literal: the only escape
|
| 137 |
+
inside single quotes is a doubled quote, so a URL or a checkout path with
|
| 138 |
+
an apostrophe (C:\\Users\\O'Brien) cannot end the literal early."""
|
| 139 |
+
return "'" + str(text).replace("'", "''") + "'"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def powershell_download(url, dest, *, ua=BROWSER_UA, timeout=180):
|
| 143 |
+
"""Fetch `url` into `dest` through PowerShell's (.NET) HTTP stack, which
|
| 144 |
+
canada.ca accepts while blocking Python's client at the TLS layer.
|
| 145 |
+
|
| 146 |
+
The script goes to PowerShell as -EncodedCommand (base64 of UTF-16LE),
|
| 147 |
+
the way refresh._powershell_probe already does, so no quoting survives
|
| 148 |
+
the Windows command line to be re-parsed; url, dest and ua are quoted
|
| 149 |
+
as PowerShell literals. Until 2026-09-21 the request was spliced into a
|
| 150 |
+
single-quoted -Command string, and one apostrophe in either was a
|
| 151 |
+
PowerShell parse error, not an HTTP error. Raises CalledProcessError or
|
| 152 |
+
TimeoutExpired; the caller decides what happens to `dest`.
|
| 153 |
+
|
| 154 |
+
When the server answered with an error status, the script writes
|
| 155 |
+
'CANLEX_HTTP_STATUS <n>' (PS_STATUS_MARKER) on a stderr line of its own
|
| 156 |
+
before rethrowing, so is_transient reads the status PowerShell got, not
|
| 157 |
+
the error page's text (verification 2026-09-22, D-2)."""
|
| 158 |
+
script = (f"$ProgressPreference = 'SilentlyContinue'\n"
|
| 159 |
+
"try {\n"
|
| 160 |
+
f" Invoke-WebRequest -Uri {_ps_literal(url)} -OutFile {_ps_literal(dest)} "
|
| 161 |
+
f"-UseBasicParsing -UserAgent {_ps_literal(ua)}\n"
|
| 162 |
+
"} catch {\n"
|
| 163 |
+
" if ($_.Exception.Response) {\n"
|
| 164 |
+
f" [Console]::Error.WriteLine('{PS_STATUS_MARKER} ' + "
|
| 165 |
+
"[int]$_.Exception.Response.StatusCode.value__)\n"
|
| 166 |
+
" }\n"
|
| 167 |
+
" throw\n"
|
| 168 |
+
"}\n")
|
| 169 |
+
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
| 170 |
+
subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
|
| 171 |
+
"-EncodedCommand", encoded],
|
| 172 |
+
check=True, capture_output=True, timeout=timeout)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def fetch_cached(url, dest, *, ua=BROWSER_UA, timeout=60, pause=0.5,
|
| 176 |
+
force=False, powershell=False):
|
| 177 |
+
"""Download a URL to `dest` once and reuse the cached bytes thereafter.
|
| 178 |
+
|
| 179 |
+
powershell=True routes the request through PowerShell's (.NET) HTTP stack,
|
| 180 |
+
which canada.ca accepts while blocking Python's client at the TLS layer.
|
| 181 |
+
`pause` is a politeness sleep after an actual download (0 to skip);
|
| 182 |
+
`timeout` applies to the urllib path (the PowerShell path keeps the fixed
|
| 183 |
+
180s subprocess budget every prior copy used).
|
| 184 |
+
|
| 185 |
+
The download lands in `<dest>.part` and is renamed into place only once
|
| 186 |
+
it succeeded, on both paths: a fetch that times out or errors leaves
|
| 187 |
+
nothing at `dest`, so the next call fetches again instead of serving a
|
| 188 |
+
truncated file as the cached copy (2026-09-21 audit: a stalled canada.ca
|
| 189 |
+
response left a 0-byte page in data/raw that every later plain run
|
| 190 |
+
reused, and a PDI page silently dropped from the corpus).
|
| 191 |
+
|
| 192 |
+
A transient failure (a 5xx, a dropped connection, a timeout) is retried
|
| 193 |
+
once (with_retry); a 404 is not, so enf.is_missing_upstream still reads
|
| 194 |
+
a retired chapter on the first answer.
|
| 195 |
+
"""
|
| 196 |
+
if dest.exists() and not force:
|
| 197 |
+
return dest.read_bytes()
|
| 198 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 199 |
+
part = dest.with_name(dest.name + ".part")
|
| 200 |
+
try:
|
| 201 |
+
with_retry(lambda: _download(url, part, ua=ua, timeout=timeout,
|
| 202 |
+
powershell=powershell), what=url)
|
| 203 |
+
os.replace(part, dest)
|
| 204 |
+
finally:
|
| 205 |
+
with contextlib.suppress(OSError):
|
| 206 |
+
part.unlink()
|
| 207 |
+
if pause:
|
| 208 |
+
time.sleep(pause)
|
| 209 |
+
return dest.read_bytes()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _download(url, part, *, ua, timeout, powershell):
|
| 213 |
+
"""One attempt at fetching `url` into `part` (see fetch_cached)."""
|
| 214 |
+
if powershell:
|
| 215 |
+
powershell_download(url, part, ua=ua)
|
| 216 |
+
if not part.exists():
|
| 217 |
+
raise OSError(f"{url}: PowerShell returned without writing {part.name}")
|
| 218 |
+
return
|
| 219 |
+
req = urllib.request.Request(url, headers={"User-Agent": ua})
|
| 220 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 221 |
+
part.write_bytes(resp.read())
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# --- transient fetch failures ------------------------------------------------
|
| 225 |
+
# One 5xx or connection reset used to fail an ingest item outright (and, with
|
| 226 |
+
# the preserve-on-failure rule, pin its stale copy until the next run). Two
|
| 227 |
+
# attempts in all; the pause before the second grows with the attempt number.
|
| 228 |
+
# A 4xx is an answer, not a hiccup -- enf reads a 404 as IRCC retiring a
|
| 229 |
+
# chapter (enf.is_missing_upstream) -- so it is never retried.
|
| 230 |
+
FETCH_ATTEMPTS = 2
|
| 231 |
+
FETCH_RETRY_DELAY = 5.0 # seconds before the second attempt
|
| 232 |
+
# The PowerShell path's HTTP status, as powershell_download's script writes it
|
| 233 |
+
# on a stderr line of its own. Until the 2026-09-22 verification (D-2) the
|
| 234 |
+
# status was read as '(NNN)' anywhere in stderr, but Windows PowerShell 5.1
|
| 235 |
+
# reports a failed Invoke-WebRequest as CLIXML carrying the error PAGE's text:
|
| 236 |
+
# a 503 page with a body was never retried, and a 404 page quoting a phone
|
| 237 |
+
# number '(514) 555-0100' was. CLIXML escapes the page's own line breaks
|
| 238 |
+
# (_x000D__x000A_), so a page cannot put the marker at the start of a line.
|
| 239 |
+
PS_STATUS_MARKER = "CANLEX_HTTP_STATUS"
|
| 240 |
+
_PS_STATUS = re.compile(rf"^{PS_STATUS_MARKER} (\d{{3}})\r?$", re.M)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def powershell_status(stderr):
|
| 244 |
+
"""The HTTP status the PowerShell fetch's marker line reports
|
| 245 |
+
(PS_STATUS_MARKER), or None when the stderr carries no marker (no HTTP
|
| 246 |
+
answer, or a script that predates the marker). Only the marker line is
|
| 247 |
+
read, never the error page's own text."""
|
| 248 |
+
if isinstance(stderr, bytes):
|
| 249 |
+
stderr = stderr.decode("utf-8", "replace")
|
| 250 |
+
m = _PS_STATUS.search(stderr or "")
|
| 251 |
+
return int(m.group(1)) if m else None
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# .NET's own words for a failure with no HTTP answer (so no page, and no marker)
|
| 255 |
+
_PS_TRANSIENT = re.compile(r"timed out|timeout|unable to connect|connection was closed"
|
| 256 |
+
r"|forcibly closed|temporar", re.I)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def is_transient(exc):
|
| 260 |
+
"""True when a fetch failure is worth one more try: an HTTP 5xx, a
|
| 261 |
+
URLError without a status (DNS, refused, reset), a timeout, a dropped
|
| 262 |
+
connection, a truncated body, or a PowerShell failure whose status
|
| 263 |
+
marker (PS_STATUS_MARKER) is a 5xx or which got no HTTP answer and
|
| 264 |
+
reports a connection-level error. Never a 4xx, and never a status read
|
| 265 |
+
from the error page's text. Pure, for tests."""
|
| 266 |
+
if isinstance(exc, urllib.error.HTTPError):
|
| 267 |
+
return exc.code >= 500
|
| 268 |
+
if isinstance(exc, (urllib.error.URLError, subprocess.TimeoutExpired,
|
| 269 |
+
TimeoutError, ConnectionError, http.client.IncompleteRead)):
|
| 270 |
+
return True
|
| 271 |
+
if isinstance(exc, subprocess.CalledProcessError):
|
| 272 |
+
stderr = exc.stderr or ""
|
| 273 |
+
if isinstance(stderr, bytes):
|
| 274 |
+
stderr = stderr.decode("utf-8", "replace")
|
| 275 |
+
status = _PS_STATUS.search(stderr)
|
| 276 |
+
if status:
|
| 277 |
+
return status.group(1).startswith("5")
|
| 278 |
+
return bool(_PS_TRANSIENT.search(stderr))
|
| 279 |
+
return False
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def with_retry(fetch, *, what="", attempts=None, delay=None):
|
| 283 |
+
"""fetch() -- again after a pause when it fails transiently (is_transient),
|
| 284 |
+
up to FETCH_ATTEMPTS attempts in all. The last failure, or any
|
| 285 |
+
non-transient one, propagates unchanged."""
|
| 286 |
+
attempts = FETCH_ATTEMPTS if attempts is None else attempts
|
| 287 |
+
delay = FETCH_RETRY_DELAY if delay is None else delay
|
| 288 |
+
for attempt in range(1, attempts + 1):
|
| 289 |
+
try:
|
| 290 |
+
return fetch()
|
| 291 |
+
except Exception as exc:
|
| 292 |
+
if attempt >= attempts or not is_transient(exc):
|
| 293 |
+
raise
|
| 294 |
+
print(f" retrying {what or 'fetch'} in {delay * attempt:g}s "
|
| 295 |
+
f"after {type(exc).__name__}: {str(exc)[:80]}")
|
| 296 |
+
time.sleep(delay * attempt)
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# Windows refuses to rename onto a file another process holds open without
|
| 301 |
+
# FILE_SHARE_DELETE -- Python's own open(), most scanners, indexers and
|
| 302 |
+
# editors -- with [WinError 5] Access is denied, where the plain write_text
|
| 303 |
+
# the atomic helpers replaced succeeds (verification 2026-09-22, D-3). Such a
|
| 304 |
+
# hold is usually brief, so the rename is tried REPLACE_ATTEMPTS times in all,
|
| 305 |
+
# REPLACE_RETRY_DELAY apart, before the PermissionError propagates.
|
| 306 |
+
REPLACE_ATTEMPTS = 5
|
| 307 |
+
REPLACE_RETRY_DELAY = 0.2 # seconds between rename attempts
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _replace(tmp, path):
|
| 311 |
+
"""os.replace(tmp, path), retried on PermissionError (REPLACE_ATTEMPTS
|
| 312 |
+
in all); the last PermissionError, or any other OSError, propagates."""
|
| 313 |
+
for attempt in range(1, REPLACE_ATTEMPTS + 1):
|
| 314 |
+
try:
|
| 315 |
+
os.replace(tmp, path)
|
| 316 |
+
return
|
| 317 |
+
except PermissionError:
|
| 318 |
+
if attempt >= REPLACE_ATTEMPTS:
|
| 319 |
+
raise
|
| 320 |
+
time.sleep(REPLACE_RETRY_DELAY)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def atomic_write_text(path, text, *, encoding="utf-8"):
|
| 324 |
+
"""Write `text` to `path` through a sibling temporary file renamed into
|
| 325 |
+
place, so a run killed mid-write leaves the old file (or none), never a
|
| 326 |
+
truncated one. For the small marker and cache files the ingesters and
|
| 327 |
+
the staleness watch keep (the guidance last-seen marker, the CIU
|
| 328 |
+
decisions date, the raw case-law pages). A rename refused because
|
| 329 |
+
another process briefly holds `path` open is retried (_replace)."""
|
| 330 |
+
path = Path(path)
|
| 331 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 332 |
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
| 333 |
+
try:
|
| 334 |
+
tmp.write_text(text, encoding=encoding)
|
| 335 |
+
_replace(tmp, path)
|
| 336 |
+
finally:
|
| 337 |
+
with contextlib.suppress(OSError):
|
| 338 |
+
tmp.unlink()
|
| 339 |
+
return path
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def atomic_write_bytes(path, data):
|
| 343 |
+
"""atomic_write_text for bytes (the Justice Laws XML cache)."""
|
| 344 |
+
path = Path(path)
|
| 345 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 346 |
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
| 347 |
+
try:
|
| 348 |
+
tmp.write_bytes(data)
|
| 349 |
+
_replace(tmp, path)
|
| 350 |
+
finally:
|
| 351 |
+
with contextlib.suppress(OSError):
|
| 352 |
+
tmp.unlink()
|
| 353 |
+
return path
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def split_lines(text, target=1800, fold_stubs=False):
|
| 357 |
+
"""Split text into ~target-sized pieces at line boundaries.
|
| 358 |
+
|
| 359 |
+
fold_stubs=True additionally folds a small (<200-char) leading or trailing
|
| 360 |
+
remnant into its neighbour so a section never yields a stub piece -- the
|
| 361 |
+
behaviour ingest.py and benefits.py always had. The other callers
|
| 362 |
+
deliberately keep fold_stubs=False: enabling it would change their chunk
|
| 363 |
+
boundaries, which is an eval-gated change, not a refactor.
|
| 364 |
+
"""
|
| 365 |
+
if len(text) <= target:
|
| 366 |
+
return [text]
|
| 367 |
+
pieces, buf, size = [], [], 0
|
| 368 |
+
for line in text.split("\n"):
|
| 369 |
+
if size + len(line) > target and buf:
|
| 370 |
+
pieces.append("\n".join(buf))
|
| 371 |
+
buf, size = [], 0
|
| 372 |
+
buf.append(line)
|
| 373 |
+
size += len(line) + 1
|
| 374 |
+
if buf:
|
| 375 |
+
pieces.append("\n".join(buf))
|
| 376 |
+
if fold_stubs:
|
| 377 |
+
if len(pieces) > 1 and len(pieces[-1]) < 200:
|
| 378 |
+
pieces[-2] = pieces[-2] + "\n" + pieces[-1]
|
| 379 |
+
pieces.pop()
|
| 380 |
+
if len(pieces) > 1 and len(pieces[0]) < 200:
|
| 381 |
+
pieces[1] = pieces[0] + "\n" + pieces[1]
|
| 382 |
+
pieces.pop(0)
|
| 383 |
+
return pieces
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# Every ingester rebuilds its whole processed file from a fresh scrape, so an
|
| 387 |
+
# upstream page that changes shape -- or a run that loses its network halfway --
|
| 388 |
+
# shows up as a collapse in chunk count, and the write turns that into data
|
| 389 |
+
# loss. On 2026-07-27 CBSA's rebuilt D-memo index scraped to zero memos and the
|
| 390 |
+
# write replaced 1,870 good chunks with an empty list; one line of stdout was
|
| 391 |
+
# the only warning. Refuse to write below this share of what is already stored.
|
| 392 |
+
CORPUS_SHRINK_GUARD = 0.8
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def stored_chunks(path):
|
| 396 |
+
"""The chunks already written to `path`, or [] if absent or unreadable."""
|
| 397 |
+
if not path.exists():
|
| 398 |
+
return []
|
| 399 |
+
try:
|
| 400 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 401 |
+
except (ValueError, OSError):
|
| 402 |
+
return []
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def safe_to_write(new_count, old_count, ratio=CORPUS_SHRINK_GUARD):
|
| 406 |
+
"""False when writing `new_count` chunks would gut an existing corpus.
|
| 407 |
+
|
| 408 |
+
Pure, for tests. The write is the irreversible step, so it is the one to
|
| 409 |
+
gate -- a first-ever run (old_count 0) is always allowed.
|
| 410 |
+
"""
|
| 411 |
+
return old_count == 0 or new_count >= old_count * ratio
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def preserve_dropped(chunks, stored, *, key=lambda c: c["section"], only=None):
|
| 415 |
+
"""Re-attach the last-good chunks of items this run failed to produce.
|
| 416 |
+
|
| 417 |
+
`key` maps a chunk to the item it belongs to; `only` restricts preservation
|
| 418 |
+
to a set of item identities (pass the ones that actually errored, so items
|
| 419 |
+
dropped ON PURPOSE -- cancelled memos, retired manuals -- are not
|
| 420 |
+
resurrected). Returns (chunks, preserved identities).
|
| 421 |
+
"""
|
| 422 |
+
def identity(chunk):
|
| 423 |
+
# A chunk written by an older schema may not carry the identity field
|
| 424 |
+
# at all. It simply is not preservable -- but crashing here would take
|
| 425 |
+
# down the rebuild at the exact moment the guard exists to protect it.
|
| 426 |
+
try:
|
| 427 |
+
return key(chunk)
|
| 428 |
+
except (KeyError, IndexError, TypeError):
|
| 429 |
+
return None
|
| 430 |
+
|
| 431 |
+
fresh = {identity(c) for c in chunks}
|
| 432 |
+
by_item = {}
|
| 433 |
+
for chunk in stored:
|
| 434 |
+
item = identity(chunk)
|
| 435 |
+
if item is not None:
|
| 436 |
+
by_item.setdefault(item, []).append(chunk)
|
| 437 |
+
dropped = sorted(item for item in by_item
|
| 438 |
+
if item not in fresh and (only is None or item in only))
|
| 439 |
+
for item in dropped:
|
| 440 |
+
chunks.extend(by_item[item])
|
| 441 |
+
return chunks, dropped
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def write_corpus(path, chunks, *, indent=2, allow_shrink=False,
|
| 445 |
+
ratio=CORPUS_SHRINK_GUARD):
|
| 446 |
+
"""Write `chunks` to `path`, unless that would gut the stored corpus.
|
| 447 |
+
|
| 448 |
+
Returns True if written, False if refused -- callers exit non-zero on False
|
| 449 |
+
so a broken scrape fails loudly instead of quietly emptying a file.
|
| 450 |
+
"""
|
| 451 |
+
stored = stored_chunks(path)
|
| 452 |
+
if not allow_shrink and not safe_to_write(len(chunks), len(stored), ratio):
|
| 453 |
+
print(f" REFUSING to write: {len(chunks)} chunks would replace "
|
| 454 |
+
f"{len(stored)} already in {path.name}. The upstream source "
|
| 455 |
+
f"likely changed shape -- check the scrape before writing "
|
| 456 |
+
f"(re-run with --allow-shrink once the drop is understood).")
|
| 457 |
+
return False
|
| 458 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 459 |
+
# Write-then-rename, not write-in-place: a run killed mid-write (Ctrl-C on
|
| 460 |
+
# the scheduled refresh, a full disk) would otherwise leave truncated JSON
|
| 461 |
+
# behind, and stored_chunks reads unparseable as empty -- which disables
|
| 462 |
+
# the guard above on the very next run, when it is needed most.
|
| 463 |
+
tmp = path.with_name(path.name + ".tmp")
|
| 464 |
+
tmp.write_text(json.dumps(chunks, ensure_ascii=False, indent=indent),
|
| 465 |
+
encoding="utf-8")
|
| 466 |
+
os.replace(tmp, path)
|
| 467 |
+
return True
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def dup_suffix(n):
|
| 471 |
+
"""The letter suffix for the n-th repeat of an id (n >= 1).
|
| 472 |
+
|
| 473 |
+
Bijective base 26 over a..z on n + 1: 1 -> 'b', ..., 25 -> 'z', then
|
| 474 |
+
26 -> 'aa', 27 -> 'ab', ... 'az', 'ba', ... The first 25 are exactly the
|
| 475 |
+
single letters uniquify_ids has always produced, so every id already in a
|
| 476 |
+
corpus keeps its spelling; only repeats past 'z' change, and those used to
|
| 477 |
+
run on into '{', '|', DEL and C1 control characters (chr(ord('a') + n))."""
|
| 478 |
+
if n < 1:
|
| 479 |
+
raise ValueError("a first occurrence takes no suffix")
|
| 480 |
+
k, out = n + 1, ""
|
| 481 |
+
while k:
|
| 482 |
+
k, rem = divmod(k - 1, 26)
|
| 483 |
+
out = chr(ord("a") + rem) + out
|
| 484 |
+
return out
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def uniquify_ids(chunks):
|
| 488 |
+
"""Rename duplicate chunk ids in place (second occurrence -> id-b, -c...,
|
| 489 |
+
-z, then -aa, -ab, ...; see dup_suffix).
|
| 490 |
+
|
| 491 |
+
Duplicate ids silently orphan chunks downstream: the embeddings loader
|
| 492 |
+
keys rows by id, so one twin gets vectors and the other degrades to
|
| 493 |
+
BM25-only with nothing but a stderr note. Generators whose source
|
| 494 |
+
numbering can repeat (a manual's TOC line and body heading both reading
|
| 495 |
+
'5.1'; delegation items re-using an instrument number) run their output
|
| 496 |
+
through this before writing."""
|
| 497 |
+
seen = {}
|
| 498 |
+
for c in chunks:
|
| 499 |
+
n = seen.get(c["id"], 0)
|
| 500 |
+
seen[c["id"]] = n + 1
|
| 501 |
+
if n:
|
| 502 |
+
c["id"] = f"{c['id']}-{dup_suffix(n)}"
|
| 503 |
+
return chunks
|
canlex/agreement.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# TBS publishes the agreement bilingually; the French edition is the
|
| 28 |
+
# lang='fr' twin (agreements-fr.json), reachable by French queries only.
|
| 29 |
+
"url_fr": "https://www.canada.ca/fr/secretariat-conseil-tresor/sujets/"
|
| 30 |
+
"remuneration/conventions-collectives/fb.html",
|
| 31 |
+
"cite_fr": "Convention collective FB",
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
OUT_FILE_FR = OUT_FILE.parent / "agreements-fr.json"
|
| 35 |
+
|
| 36 |
+
_SKIP_HEADINGS = {"table of contents", "note to readers", "page details",
|
| 37 |
+
"on this page", "table des matières", "note aux lecteurs",
|
| 38 |
+
"détails de la page", "sur cette page"}
|
| 39 |
+
_CONTENT_TAGS = {"p", "dl", "table", "h4", "h5", "h6", "blockquote"}
|
| 40 |
+
_ARTICLE = re.compile(r"Article\s+(\S+?)\s*[:–-]\s*(.+)", re.I)
|
| 41 |
+
_APPENDIX = re.compile(r"(?:Appendix|Annexe)\b", re.I)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _fetch(url, dest, force=False):
|
| 45 |
+
"""Download a page. canada.ca blocks Python's HTTP client at the TLS layer,
|
| 46 |
+
so fetch via PowerShell's (.NET) HTTP stack, which the site accepts."""
|
| 47 |
+
return fetch_cached(url, dest, force=force, powershell=True)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
_PIECE_TARGET = 1800 # keep pieces under the embedder's ~2000-char window
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _split(text, target=_PIECE_TARGET):
|
| 54 |
+
"""Split long text into ~target-sized pieces at line boundaries."""
|
| 55 |
+
return split_lines(text, target)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _block_text(heading):
|
| 59 |
+
"""Readable text from a heading up to the next h2/h3 (sections unwrapped)."""
|
| 60 |
+
lines = []
|
| 61 |
+
for sib in heading.find_next_siblings():
|
| 62 |
+
if sib.name in ("h2", "h3"):
|
| 63 |
+
break
|
| 64 |
+
if sib.name in ("ul", "ol"):
|
| 65 |
+
for li in sib.find_all("li", recursive=False):
|
| 66 |
+
item = _norm(li.get_text(" ", strip=True))
|
| 67 |
+
if item:
|
| 68 |
+
lines.append(f"- {item}")
|
| 69 |
+
elif sib.name in _CONTENT_TAGS:
|
| 70 |
+
text = _norm(sib.get_text(" ", strip=True))
|
| 71 |
+
if text:
|
| 72 |
+
lines.append(text)
|
| 73 |
+
return "\n".join(lines)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def parse_agreement(html, code, lang="en"):
|
| 77 |
+
"""Parse a collective agreement page into one chunk per Article / Appendix.
|
| 78 |
+
lang='fr' parses the French edition: same article numbers, French titles
|
| 79 |
+
and citation, '-fr-' id infix and lang='fr' on every chunk."""
|
| 80 |
+
meta = AGREEMENTS[code]
|
| 81 |
+
cite = meta["cite_fr"] if lang == "fr" else meta["cite"]
|
| 82 |
+
url = meta["url_fr"] if lang == "fr" else meta["url"]
|
| 83 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 84 |
+
main = soup.find("main")
|
| 85 |
+
if main is None:
|
| 86 |
+
return []
|
| 87 |
+
|
| 88 |
+
# The first <time datetime> is the agreement's expiry; the dateModified one
|
| 89 |
+
# is the page's date.
|
| 90 |
+
expiry = ""
|
| 91 |
+
for t in main.find_all("time"):
|
| 92 |
+
if t.get("datetime") and t.get("property") != "dateModified":
|
| 93 |
+
expiry = _norm(t.get("datetime"))
|
| 94 |
+
break
|
| 95 |
+
|
| 96 |
+
for tag in main.find_all(["section", "div"]):
|
| 97 |
+
tag.unwrap() # flatten so each heading and its content become siblings
|
| 98 |
+
|
| 99 |
+
chunks = []
|
| 100 |
+
current_part = ""
|
| 101 |
+
sec_idx = 0
|
| 102 |
+
for h in main.find_all(["h2", "h3"]):
|
| 103 |
+
if "wb-inv" in (h.get("class") or []):
|
| 104 |
+
continue
|
| 105 |
+
text = _norm(h.get_text(" ", strip=True)).lstrip("*").strip()
|
| 106 |
+
if not text or text.lower() in _SKIP_HEADINGS:
|
| 107 |
+
continue
|
| 108 |
+
if h.name == "h2":
|
| 109 |
+
current_part = text
|
| 110 |
+
body = _block_text(h)
|
| 111 |
+
if not body:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
article = _ARTICLE.match(text)
|
| 115 |
+
if article:
|
| 116 |
+
number = article.group(1).rstrip(":.")
|
| 117 |
+
note = article.group(2).strip()
|
| 118 |
+
citation = (f"{cite}, article {number}" if lang == "fr"
|
| 119 |
+
else f"{cite}, Article {number}")
|
| 120 |
+
elif _APPENDIX.match(text):
|
| 121 |
+
number, note = text, ""
|
| 122 |
+
citation = f"{cite}, {text}"
|
| 123 |
+
else:
|
| 124 |
+
# Memoranda of Agreement/Understanding have long titles: keep them in
|
| 125 |
+
# the note only, so the rendered header does not repeat them.
|
| 126 |
+
number, note = "", text
|
| 127 |
+
citation = cite
|
| 128 |
+
|
| 129 |
+
sec_idx += 1
|
| 130 |
+
# Split a long article so each piece fits the embed window; pieces share
|
| 131 |
+
# the base id and take a '-p<k>' suffix so the diversity cap groups them.
|
| 132 |
+
pieces = _split(body)
|
| 133 |
+
for k, piece in enumerate(pieces, start=1):
|
| 134 |
+
base = (f"agreement-fr-{code}-{sec_idx}" if lang == "fr"
|
| 135 |
+
else f"agreement-{code}-{sec_idx}")
|
| 136 |
+
chunk_id = base if len(pieces) == 1 else f"{base}-p{k}"
|
| 137 |
+
pnote = note if len(pieces) == 1 else (
|
| 138 |
+
f"{note} (partie {k})" if lang == "fr" else f"{note} (part {k})").strip()
|
| 139 |
+
chunks.append({
|
| 140 |
+
"id": chunk_id,
|
| 141 |
+
"doc_type": "agreement",
|
| 142 |
+
"act_code": code,
|
| 143 |
+
"act_short": meta["short"],
|
| 144 |
+
"act_name": meta["name"],
|
| 145 |
+
"section": number,
|
| 146 |
+
"marginal_note": pnote,
|
| 147 |
+
"part": current_part if h.name == "h3" else "",
|
| 148 |
+
"division": "",
|
| 149 |
+
"heading": "",
|
| 150 |
+
"text": piece,
|
| 151 |
+
"history": "",
|
| 152 |
+
"last_amended": "",
|
| 153 |
+
"current_to": expiry,
|
| 154 |
+
"citation": citation,
|
| 155 |
+
"source_url": url,
|
| 156 |
+
**({"lang": "fr"} if lang == "fr" else {}),
|
| 157 |
+
})
|
| 158 |
+
return chunks
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def preserve_failed(chunks, failures, stored):
|
| 162 |
+
"""Re-attach the last-good chunks of agreements this run could not rebuild.
|
| 163 |
+
|
| 164 |
+
Ingestion is a full rebuild, so an agreement whose page fails to fetch --
|
| 165 |
+
or whose canada.ca layout changes so that nothing parses out of it --
|
| 166 |
+
disappears from the corpus. An agreement that is still in force is better
|
| 167 |
+
served from the copy we already had than not at all. Restricted to the
|
| 168 |
+
codes that actually failed, so one removed from AGREEMENTS on purpose is
|
| 169 |
+
not resurrected. Returns (chunks, preserved codes).
|
| 170 |
+
"""
|
| 171 |
+
failed = {code for code, _why in failures}
|
| 172 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 173 |
+
only=failed)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def ingest(force=False, allow_shrink=False):
|
| 177 |
+
all_chunks, failures = [], []
|
| 178 |
+
for code, meta in AGREEMENTS.items():
|
| 179 |
+
print(f"Ingesting {code} ({meta['short']})...")
|
| 180 |
+
try:
|
| 181 |
+
html = _fetch(meta["url"], AGREEMENT_DIR / f"{code}.html", force=force)
|
| 182 |
+
chunks = parse_agreement(html, code)
|
| 183 |
+
print(f" {len(chunks)} chunks")
|
| 184 |
+
all_chunks.extend(chunks)
|
| 185 |
+
if not chunks:
|
| 186 |
+
# A page that parses to nothing is a redesign upstream, not an
|
| 187 |
+
# empty agreement -- same class of failure as a dead fetch.
|
| 188 |
+
failures.append((code, "no content parsed"))
|
| 189 |
+
except Exception as exc:
|
| 190 |
+
failures.append((code, f"{type(exc).__name__}: {exc}"))
|
| 191 |
+
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 192 |
+
|
| 193 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 194 |
+
stored_chunks(OUT_FILE))
|
| 195 |
+
if preserved:
|
| 196 |
+
print(f" kept the last-good copy of {len(preserved)} agreement(s): "
|
| 197 |
+
f"{', '.join(preserved)}")
|
| 198 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 199 |
+
allow_shrink=allow_shrink):
|
| 200 |
+
return False
|
| 201 |
+
print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
|
| 202 |
+
return True
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def ingest_fr(force=False, allow_shrink=False):
|
| 206 |
+
"""The French editions -> agreements-fr.json (same shape as ingest)."""
|
| 207 |
+
all_chunks, failures = [], []
|
| 208 |
+
for code, meta in AGREEMENTS.items():
|
| 209 |
+
if not meta.get("url_fr"):
|
| 210 |
+
continue
|
| 211 |
+
print(f"Ingesting {code} (French edition)...")
|
| 212 |
+
try:
|
| 213 |
+
html = _fetch(meta["url_fr"], AGREEMENT_DIR / f"{code}-fr.html",
|
| 214 |
+
force=force)
|
| 215 |
+
chunks = parse_agreement(html, code, lang="fr")
|
| 216 |
+
print(f" {len(chunks)} chunks")
|
| 217 |
+
all_chunks.extend(chunks)
|
| 218 |
+
if not chunks:
|
| 219 |
+
failures.append((code, "no content parsed"))
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
failures.append((code, f"{type(exc).__name__}: {exc}"))
|
| 222 |
+
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 223 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 224 |
+
stored_chunks(OUT_FILE_FR))
|
| 225 |
+
if preserved:
|
| 226 |
+
print(f" kept the last-good copy of: {', '.join(preserved)}")
|
| 227 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, indent=2,
|
| 228 |
+
allow_shrink=allow_shrink):
|
| 229 |
+
return False
|
| 230 |
+
print(f"\n{len(all_chunks)} French chunks -> {OUT_FILE_FR.name}")
|
| 231 |
+
return True
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def main():
|
| 235 |
+
run = ingest_fr if "--fr" in sys.argv else ingest
|
| 236 |
+
ok = run(force="--force" in sys.argv,
|
| 237 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 238 |
+
sys.exit(0 if ok else 1)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
if __name__ == "__main__":
|
| 242 |
+
main()
|
canlex/airports.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aerodromes by the names officers use (2026-10-15).
|
| 2 |
+
|
| 3 |
+
The Canadian Aviation Security Regulations, 2012 (SOR/2011-318) list the 89
|
| 4 |
+
aerodromes of Schedules 1-3 by city and ICAO location indicator only ('Vancouver |
|
| 5 |
+
CYVR'), so a search for 'YVR', 'Pearson' or 'Billy Bishop' never met the row that
|
| 6 |
+
answers it. airport_terms() reads an IATA code or an airport's name in a query and
|
| 7 |
+
returns the Schedules' own words for each aerodrome named ('CYVR Vancouver'), which
|
| 8 |
+
LegislationIndex.search appends for its recall stages. The statute text is never
|
| 9 |
+
changed: this table only decides which schedule row a query can reach.
|
| 10 |
+
|
| 11 |
+
Sources. The ICAO indicator, the city as written and the class are the Regulations'
|
| 12 |
+
own (Schedules 1-3, consolidation current to 2025-04-14); tests/test_airports.py holds
|
| 13 |
+
this table to the ingested schedules, so an amendment to them fails the test until the
|
| 14 |
+
table follows. The IATA codes and the airports' names: Wikidata (CC0), retrieved
|
| 15 |
+
2026-10-15, every code and name checked against a second source the same day.
|
| 16 |
+
|
| 17 |
+
Three kinds of words reach an aerodrome:
|
| 18 |
+
- its IATA code, as a whole word in either case -- except the codes that are also
|
| 19 |
+
words ('yam', 'yay', 'yow'), which count only in capitals;
|
| 20 |
+
- a DISTINCTIVE name, on its own ('Billy Bishop', 'Toronto Pearson', 'CFB Gander');
|
| 21 |
+
- a city or a name that also means a person or a place ('Pearson', 'Trudeau',
|
| 22 |
+
'Regina'), only beside an airport word ('Pearson airport', 'aéroport de Québec',
|
| 23 |
+
'is Pearson class 1', 'Calgary International').
|
| 24 |
+
A city alone never counts, and a more specific name beats the city it contains:
|
| 25 |
+
'Toronto Pearson airport' reaches CYYZ, not the other two Toronto aerodromes.
|
| 26 |
+
"""
|
| 27 |
+
import re
|
| 28 |
+
import unicodedata
|
| 29 |
+
from typing import NamedTuple
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Aerodrome(NamedTuple):
|
| 33 |
+
icao: str
|
| 34 |
+
iata: str
|
| 35 |
+
city: str # as Schedules 1-3 write it
|
| 36 |
+
cls: int # the Schedule: 1, 2 or 3
|
| 37 |
+
places: tuple = () # how a query names the place; counts beside an airport word
|
| 38 |
+
names: tuple = () # the airport's own names that need an airport word too
|
| 39 |
+
distinct: tuple = () # names that mean this aerodrome on their own
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
AERODROMES = (
|
| 43 |
+
# --- Schedule 1: Class 1 aerodromes ---
|
| 44 |
+
Aerodrome("CYYC", "YYC", "Calgary", 1, ("calgary",)),
|
| 45 |
+
Aerodrome("CYEG", "YEG", "Edmonton", 1, ("edmonton",)),
|
| 46 |
+
Aerodrome("CYHZ", "YHZ", "Halifax", 1, ("halifax",),
|
| 47 |
+
("stanfield", "robert l stanfield"), ("halifax stanfield",)),
|
| 48 |
+
Aerodrome("CYMX", "YMX", "Montréal", 1, ("montreal",), ("mirabel",),
|
| 49 |
+
("montreal mirabel",)),
|
| 50 |
+
Aerodrome("CYUL", "YUL", "Montréal", 1, ("montreal",),
|
| 51 |
+
("trudeau", "pierre elliott trudeau", "dorval"), ("montreal trudeau",)),
|
| 52 |
+
Aerodrome("CYOW", "YOW", "Ottawa", 1, ("ottawa",), ("macdonald cartier",),
|
| 53 |
+
("ottawa macdonald cartier",)),
|
| 54 |
+
Aerodrome("CYYZ", "YYZ", "Toronto", 1, ("toronto",),
|
| 55 |
+
("pearson", "lester b pearson"), ("toronto pearson",)),
|
| 56 |
+
Aerodrome("CYVR", "YVR", "Vancouver", 1, ("vancouver",)),
|
| 57 |
+
Aerodrome("CYWG", "YWG", "Winnipeg", 1, ("winnipeg",),
|
| 58 |
+
("richardson", "james armstrong richardson", "j a richardson"),
|
| 59 |
+
("winnipeg richardson", "cfb winnipeg", "17 wing")),
|
| 60 |
+
# --- Schedule 2: Class 2 aerodromes ---
|
| 61 |
+
Aerodrome("CYYG", "YYG", "Charlottetown", 2, ("charlottetown",),
|
| 62 |
+
("alexander b campbell",)), # renamed May 2026
|
| 63 |
+
Aerodrome("CYFC", "YFC", "Fredericton", 2, ("fredericton",)),
|
| 64 |
+
Aerodrome("CYQX", "YQX", "Gander", 2, ("gander",), (), ("cfb gander", "9 wing")),
|
| 65 |
+
Aerodrome("CYFB", "YFB", "Iqaluit", 2, ("iqaluit",), ("frobisher bay",)),
|
| 66 |
+
Aerodrome("CYLW", "YLW", "Kelowna", 2, ("kelowna",)),
|
| 67 |
+
Aerodrome("CYXU", "YXU", "London", 2, ("london ontario", "london ont", "london on")),
|
| 68 |
+
Aerodrome("CYQM", "YQM", "Moncton", 2, ("moncton",),
|
| 69 |
+
("romeo leblanc",), ("greater moncton",)), # Roméo LeBlanc since 2016
|
| 70 |
+
Aerodrome("CYXS", "YXS", "Prince George", 2, ("prince george",)),
|
| 71 |
+
Aerodrome("CYQB", "YQB", "Québec", 2, ("quebec", "quebec city", "ville de quebec"),
|
| 72 |
+
("jean lesage",)),
|
| 73 |
+
Aerodrome("CYQR", "YQR", "Regina", 2, ("regina",)),
|
| 74 |
+
Aerodrome("CYSJ", "YSJ", "Saint John", 2, ("saint john",)),
|
| 75 |
+
Aerodrome("CYYT", "YYT", "St. John’s", 2, ("st john's", "st johns", "saint john's")),
|
| 76 |
+
Aerodrome("CYXE", "YXE", "Saskatoon", 2, ("saskatoon",),
|
| 77 |
+
("diefenbaker", "john g diefenbaker")),
|
| 78 |
+
Aerodrome("CYSB", "YSB", "Sudbury", 2, ("sudbury", "greater sudbury")),
|
| 79 |
+
Aerodrome("CYQT", "YQT", "Thunder Bay", 2, ("thunder bay",)),
|
| 80 |
+
Aerodrome("CYTZ", "YTZ", "Toronto", 2, ("toronto",),
|
| 81 |
+
("toronto island", "toronto city", "toronto city centre"),
|
| 82 |
+
("billy bishop",)),
|
| 83 |
+
Aerodrome("CYYJ", "YYJ", "Victoria", 2, ("victoria",)),
|
| 84 |
+
Aerodrome("CYXY", "YXY", "Whitehorse", 2, ("whitehorse",), (), ("erik nielsen",)),
|
| 85 |
+
Aerodrome("CYQG", "YQG", "Windsor", 2, ("windsor",)),
|
| 86 |
+
Aerodrome("CYZF", "YZF", "Yellowknife", 2, ("yellowknife",)),
|
| 87 |
+
# --- Schedule 3: Class 3 aerodromes ---
|
| 88 |
+
Aerodrome("CYXX", "YXX", "Abbotsford", 3, ("abbotsford",)),
|
| 89 |
+
Aerodrome("CYTF", "YTF", "Alma", 3, ("alma",)),
|
| 90 |
+
Aerodrome("CYBG", "YBG", "Bagotville", 3, ("bagotville",), ("saguenay",),
|
| 91 |
+
("cfb bagotville", "3 wing")),
|
| 92 |
+
Aerodrome("CYBC", "YBC", "Baie-Comeau", 3, ("baie comeau",)),
|
| 93 |
+
Aerodrome("CZBF", "ZBF", "Bathurst", 3, ("bathurst",)),
|
| 94 |
+
Aerodrome("CYBR", "YBR", "Brandon", 3, ("brandon",)),
|
| 95 |
+
Aerodrome("CYBL", "YBL", "Campbell River", 3, ("campbell river",)),
|
| 96 |
+
Aerodrome("CYCG", "YCG", "Castlegar", 3, ("castlegar",), ("west kootenay",)),
|
| 97 |
+
Aerodrome("CYCL", "YCL", "Charlo", 3, ("charlo",)),
|
| 98 |
+
Aerodrome("CYMT", "YMT", "Chibougamau/Chapais", 3, ("chibougamau", "chapais")),
|
| 99 |
+
Aerodrome("CZUM", "ZUM", "Churchill Falls", 3, ("churchill falls",)),
|
| 100 |
+
Aerodrome("CYQQ", "YQQ", "Comox", 3, ("comox", "comox valley"), (),
|
| 101 |
+
("cfb comox", "19 wing")),
|
| 102 |
+
Aerodrome("CYXC", "YXC", "Cranbrook", 3, ("cranbrook",), ("canadian rockies",)),
|
| 103 |
+
Aerodrome("CYDQ", "YDQ", "Dawson Creek", 3, ("dawson creek",)),
|
| 104 |
+
Aerodrome("CYDF", "YDF", "Deer Lake", 3, ("deer lake",)),
|
| 105 |
+
Aerodrome("CYMM", "YMM", "Fort McMurray", 3, ("fort mcmurray",)),
|
| 106 |
+
Aerodrome("CYXJ", "YXJ", "Fort St. John", 3, ("fort st john", "fort saint john"),
|
| 107 |
+
("north peace",)),
|
| 108 |
+
Aerodrome("CYGP", "YGP", "Gaspé", 3, ("gaspe",), ("michel pouliot",)),
|
| 109 |
+
Aerodrome("CYYR", "YYR", "Goose Bay", 3, ("goose bay", "happy valley goose bay"), (),
|
| 110 |
+
("cfb goose bay", "5 wing")),
|
| 111 |
+
Aerodrome("CYQU", "YQU", "Grande Prairie", 3, ("grande prairie",)),
|
| 112 |
+
Aerodrome("CYHM", "YHM", "Hamilton", 3, ("hamilton",), ("munro", "john c munro")),
|
| 113 |
+
Aerodrome("CYGR", "YGR", "Îles-de-la-Madeleine", 3,
|
| 114 |
+
("iles de la madeleine", "magdalen islands")),
|
| 115 |
+
Aerodrome("CYKA", "YKA", "Kamloops", 3, ("kamloops",)),
|
| 116 |
+
Aerodrome("CYGK", "YGK", "Kingston", 3, ("kingston ontario", "kingston ont", "kingston on"),
|
| 117 |
+
("norman rogers",)),
|
| 118 |
+
Aerodrome("CYKF", "YKF", "Kitchener/Waterloo", 3,
|
| 119 |
+
("kitchener", "waterloo", "kitchener waterloo"), (), ("region of waterloo",)),
|
| 120 |
+
Aerodrome("CYVP", "YVP", "Kuujjuaq", 3, ("kuujjuaq",)),
|
| 121 |
+
Aerodrome("CYGW", "YGW", "Kuujjuarapik", 3, ("kuujjuarapik",)),
|
| 122 |
+
Aerodrome("CYGL", "YGL", "La Grande Rivière", 3,
|
| 123 |
+
("la grande riviere", "radisson", "grande riviere")),
|
| 124 |
+
Aerodrome("CYFJ", "YTM", "La Macaza / Mont-Tremblant", 3, ("la macaza", "mont tremblant")),
|
| 125 |
+
Aerodrome("CYQL", "YQL", "Lethbridge", 3, ("lethbridge", "lethbridge county")),
|
| 126 |
+
Aerodrome("CYLL", "YLL", "Lloydminster", 3, ("lloydminster",)),
|
| 127 |
+
Aerodrome("CYBX", "YBX", "Lourdes-de-Blanc-Sablon", 3,
|
| 128 |
+
("lourdes de blanc sablon", "blanc sablon")),
|
| 129 |
+
Aerodrome("CYXH", "YXH", "Medicine Hat", 3, ("medicine hat",)),
|
| 130 |
+
Aerodrome("CYYY", "YYY", "Mont-Joli", 3, ("mont joli",)),
|
| 131 |
+
Aerodrome("CYCD", "YCD", "Nanaimo", 3, ("nanaimo",)),
|
| 132 |
+
Aerodrome("CYYB", "YYB", "North Bay", 3, ("north bay",), ("jack garland",)),
|
| 133 |
+
Aerodrome("CYYF", "YYF", "Penticton", 3, ("penticton",)),
|
| 134 |
+
Aerodrome("CYPA", "YPA", "Prince Albert", 3, ("prince albert",), ("glass field",)),
|
| 135 |
+
Aerodrome("CYPR", "YPR", "Prince Rupert", 3, ("prince rupert",)),
|
| 136 |
+
Aerodrome("CYQZ", "YQZ", "Quesnel", 3, ("quesnel",)),
|
| 137 |
+
Aerodrome("CYQF", "YQF", "Red Deer", 3, ("red deer",)),
|
| 138 |
+
Aerodrome("CYRJ", "YRJ", "Roberval", 3, ("roberval",)),
|
| 139 |
+
Aerodrome("CYUY", "YUY", "Rouyn-Noranda", 3, ("rouyn noranda",)),
|
| 140 |
+
Aerodrome("CYAY", "YAY", "St. Anthony", 3, ("st anthony", "saint anthony")),
|
| 141 |
+
Aerodrome("CYSL", "YSL", "Saint-Léonard", 3, ("saint leonard", "st leonard")),
|
| 142 |
+
Aerodrome("CYZP", "YZP", "Sandspit", 3, ("sandspit",), (),
|
| 143 |
+
("k'il kun xidgwangs daanaay",)),
|
| 144 |
+
Aerodrome("CYZR", "YZR", "Sarnia", 3, ("sarnia",), ("chris hadfield",)),
|
| 145 |
+
Aerodrome("CYAM", "YAM", "Sault Ste. Marie", 3, ("sault ste marie", "sault sainte marie")),
|
| 146 |
+
Aerodrome("CYZV", "YZV", "Sept-Îles", 3, ("sept iles",)),
|
| 147 |
+
Aerodrome("CYYD", "YYD", "Smithers", 3, ("smithers",)),
|
| 148 |
+
Aerodrome("CYJT", "YJT", "Stephenville", 3, ("stephenville",)),
|
| 149 |
+
Aerodrome("CYQY", "YQY", "Sydney", 3, ("sydney nova scotia", "sydney ns", "sydney n s"),
|
| 150 |
+
("mccurdy", "douglas mccurdy", "j a douglas mccurdy")),
|
| 151 |
+
Aerodrome("CYXT", "YXT", "Terrace", 3, ("terrace",), ("northwest regional",)),
|
| 152 |
+
Aerodrome("CYTH", "YTH", "Thompson", 3, ("thompson",)),
|
| 153 |
+
Aerodrome("CYTS", "YTS", "Timmins", 3, ("timmins",), ("victor m power",)),
|
| 154 |
+
# Buttonville closed on 2023-11-30 and IATA has withdrawn YKZ; the Regulations
|
| 155 |
+
# still list it, so its name still reaches the row
|
| 156 |
+
Aerodrome("CYKZ", "", "Toronto", 3, ("toronto",), (), ("buttonville",)),
|
| 157 |
+
Aerodrome("CYVO", "YVO", "Val-d’Or", 3, ("val d'or",)),
|
| 158 |
+
Aerodrome("CYWK", "YWK", "Wabush", 3, ("wabush",)),
|
| 159 |
+
Aerodrome("CYWL", "YWL", "Williams Lake", 3, ("williams lake",)),
|
| 160 |
+
Aerodrome("CYQI", "YQI", "Yarmouth", 3, ("yarmouth",)),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# IATA codes that are also words an officer may type ('can I import a yam'): they
|
| 164 |
+
# count only as written in capitals.
|
| 165 |
+
_WORD_CODES = frozenset({"YAM", "YAY", "YOW"})
|
| 166 |
+
|
| 167 |
+
# Words that make a place or a person's name mean an airport. 'International',
|
| 168 |
+
# 'Regional' and 'Municipal' count only right after the name ('Calgary International',
|
| 169 |
+
# 'North Peace Regional'): 'Vancouver international students' is not an airport.
|
| 170 |
+
# 'Terminal' is not one ('Vancouver cruise terminal').
|
| 171 |
+
_CONTEXT = re.compile(r"(?<![\w'])(?:airports?|aerodromes?|airfields?|aeroports?|aerogares?"
|
| 172 |
+
r"|class [123]|classe [123]|categorie [123])(?![\w'])")
|
| 173 |
+
_INTERNATIONAL = re.compile(r"\s*(?:internationale?|regionale?|municipale?)(?![\w'])")
|
| 174 |
+
_WINDOW = 3 # words either side of a name in which an airport word must sit
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _fold(text):
|
| 178 |
+
"""Lower case, accents and curly quotes folded, hyphens, slashes and other
|
| 179 |
+
punctuation as spaces ('Montréal-Trudeau' -> 'montreal trudeau', 'St. John’s'
|
| 180 |
+
-> "st john's")."""
|
| 181 |
+
text = unicodedata.normalize("NFKD", text or "")
|
| 182 |
+
text = "".join(ch for ch in text if not unicodedata.combining(ch)).lower()
|
| 183 |
+
text = text.replace("’", "'").replace("‘", "'")
|
| 184 |
+
return " ".join(re.sub(r"[^\w']+", " ", text).split())
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _phrase(words):
|
| 188 |
+
return re.compile(r"(?<![\w'])" + re.escape(words) + r"(?![\w'])")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
_ENTRIES = tuple(
|
| 192 |
+
(a, tuple(_phrase(p) for p in a.distinct),
|
| 193 |
+
tuple(_phrase(p) for p in a.names), tuple(_phrase(p) for p in a.places))
|
| 194 |
+
for a in AERODROMES)
|
| 195 |
+
_BY_IATA = {a.iata: a for a in AERODROMES if a.iata}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _in_context(folded, start, end):
|
| 199 |
+
"""True when an airport word sits within _WINDOW words of folded[start:end]."""
|
| 200 |
+
before = " ".join(folded[:start].split()[-_WINDOW:])
|
| 201 |
+
after = folded[end:]
|
| 202 |
+
return bool(_CONTEXT.search(before) or _CONTEXT.search(" ".join(after.split()[:_WINDOW]))
|
| 203 |
+
or _INTERNATIONAL.match(after))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def airport_terms(query):
|
| 207 |
+
"""['CYVR Vancouver', ...]: the Schedules' words for every aerodrome `query` names
|
| 208 |
+
by IATA code, a distinctive name, or a city or name beside an airport word, in
|
| 209 |
+
the order of the table; [] when it names none."""
|
| 210 |
+
found = {}
|
| 211 |
+
for token in re.findall(r"[A-Za-z]+", query or ""):
|
| 212 |
+
a = _BY_IATA.get(token.upper())
|
| 213 |
+
if a and (token.isupper() or a.iata not in _WORD_CODES):
|
| 214 |
+
found[a.icao] = a
|
| 215 |
+
folded = _fold(query)
|
| 216 |
+
covered = [] # spans a distinctive or a named match took: no city inside them
|
| 217 |
+
for a, distinct, names, _ in _ENTRIES:
|
| 218 |
+
for pattern in distinct:
|
| 219 |
+
for m in pattern.finditer(folded):
|
| 220 |
+
found[a.icao] = a
|
| 221 |
+
covered.append(m.span())
|
| 222 |
+
for pattern in names:
|
| 223 |
+
for m in pattern.finditer(folded):
|
| 224 |
+
if _in_context(folded, *m.span()):
|
| 225 |
+
found[a.icao] = a
|
| 226 |
+
covered.append(m.span())
|
| 227 |
+
# longest place first, so a place inside a longer one ('saint john' inside 'fort
|
| 228 |
+
# saint john') yields to it, as a city yields to a name that contains it
|
| 229 |
+
places = sorted(((m.span(), a) for a, _, _, patterns in _ENTRIES for p in patterns
|
| 230 |
+
for m in p.finditer(folded)), key=lambda x: x[0][0] - x[0][1])
|
| 231 |
+
for (s, e), a in places:
|
| 232 |
+
if any(cs <= s and e <= ce and (cs, ce) != (s, e) for cs, ce in covered):
|
| 233 |
+
continue
|
| 234 |
+
if _in_context(folded, s, e):
|
| 235 |
+
found[a.icao] = a
|
| 236 |
+
covered.append((s, e))
|
| 237 |
+
return [f"{a.icao} {a.city}" for a in AERODROMES if a.icao in found]
|
canlex/amps.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, force=False):
|
| 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, force=force)
|
| 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, force=False):
|
| 110 |
+
html = _fetch(INDEX_URL, RAW / "index.html",
|
| 111 |
+
force=force).decode("utf-8", "replace")
|
| 112 |
+
links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
|
| 113 |
+
key=lambda t: t[1])
|
| 114 |
+
if limit:
|
| 115 |
+
links = links[:limit]
|
| 116 |
+
print(f"amps: {len(links)} contravention pages on the index")
|
| 117 |
+
chunks, failed = [], []
|
| 118 |
+
for path, code in links:
|
| 119 |
+
if path.startswith("http"):
|
| 120 |
+
url = path
|
| 121 |
+
elif path.startswith("/"):
|
| 122 |
+
url = BASE + path
|
| 123 |
+
else:
|
| 124 |
+
url = INDEX_URL.rsplit("/", 1)[0] + "/" + path
|
| 125 |
+
try:
|
| 126 |
+
page = _fetch(url, RAW / f"{code}.html",
|
| 127 |
+
force=force).decode("utf-8", "replace")
|
| 128 |
+
chunks.append(parse_page(page, code, url))
|
| 129 |
+
except Exception as exc:
|
| 130 |
+
failed.append((code, f"{type(exc).__name__}: {exc}"))
|
| 131 |
+
out = PROCESSED_DIR / "amps.json"
|
| 132 |
+
# Printed before the write so a refusal still shows what went wrong.
|
| 133 |
+
if failed:
|
| 134 |
+
print(f"amps: {len(failed)} failed: {failed[:5]}")
|
| 135 |
+
if not limit:
|
| 136 |
+
chunks, preserved = preserve_failed(chunks, failed, stored_chunks(out))
|
| 137 |
+
if preserved:
|
| 138 |
+
print(f"amps: kept the last-good copy of {len(preserved)} "
|
| 139 |
+
f"unfetchable contravention(s): {', '.join(preserved)}")
|
| 140 |
+
# The guard applies to --limit runs too: a smoke test over five
|
| 141 |
+
# contraventions must not be able to write a five-page corpus over the real
|
| 142 |
+
# one. indent=2 matches what is already on disk -- changing it would
|
| 143 |
+
# rewrite every line of the file.
|
| 144 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 145 |
+
return False
|
| 146 |
+
print(f"amps: {len(chunks)} contraventions -> {out.name}")
|
| 147 |
+
return True
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def main():
|
| 151 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 152 |
+
force="--force" in sys.argv)
|
| 153 |
+
sys.exit(0 if ok else 1)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
main()
|
canlex/authorities.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The authority registry (roadmap T3.2; track A step A12, 2026-09-14).
|
| 2 |
+
|
| 3 |
+
data/curated/authorities.json (HELD FOR REVIEW) links every decision the
|
| 4 |
+
curated datasets and the code cite to the corpus: a stable key per authority,
|
| 5 |
+
an alias table of the exact cite strings used, the caselaw.json decision key
|
| 6 |
+
(act_short / act_code, exact), pinned chunks each with a verbatim 'contains'
|
| 7 |
+
phrase, an ordered list of the secondary chunks that describe an authority
|
| 8 |
+
the corpus does not hold (official guidance before archived IRB papers,
|
| 9 |
+
before withdrawn ATIP copies, before guidance that cites the reversed trial
|
| 10 |
+
decision in Saini), a status (in-corpus / secondary-only / unavailable) and
|
| 11 |
+
the subsequent history. resolve() reads a cite and returns the pinned chunk
|
| 12 |
+
and its excerpt; the pointer lines tell an officer where the text is:
|
| 13 |
+
|
| 14 |
+
full text in CanLex: canlex_search_legislation doc_type caselaw, act=...
|
| 15 |
+
not in the CanLex corpus; quoted in <secondary>
|
| 16 |
+
|
| 17 |
+
Never canlex_case -- it needs a CanLII key and returns the citator, not the
|
| 18 |
+
decision. The file is optional: a composed release ships without held files,
|
| 19 |
+
and every renderer then prints HELD_NOTE instead of pointers.
|
| 20 |
+
|
| 21 |
+
Rendering is gated on the registry's own top-level "render": true (2026-09-15,
|
| 22 |
+
wave-3 verifier). The approved f5cf54d registry has no such flag: its notes
|
| 23 |
+
record corrections to the curated holdings that were never applied, so a
|
| 24 |
+
release composed on that baseline prints no pointers (and keeps
|
| 25 |
+
server.SUPERSEDED_CITES) rather than a 'full text in CanLex' pointer beside an
|
| 26 |
+
uncorrected holding. The corrected registry sets the flag.
|
| 27 |
+
|
| 28 |
+
verify_lines() prints, wherever a tool shows a pairing's verified maximum, the
|
| 29 |
+
sentence of the statute that contains each verify phrase, tagged with the Act,
|
| 30 |
+
section, current_to and chunk id -- read from data/processed, never typed.
|
| 31 |
+
Where the phrase occurs more than once (s. 344's 'imprisonment for life' is in
|
| 32 |
+
the firearm clause (a) and in '(b) in any other case'), the general clause is
|
| 33 |
+
quoted.
|
| 34 |
+
"""
|
| 35 |
+
import functools
|
| 36 |
+
import json
|
| 37 |
+
import re
|
| 38 |
+
|
| 39 |
+
from .config import DATA_DIR, PROCESSED_DIR
|
| 40 |
+
|
| 41 |
+
FILE = "authorities.json"
|
| 42 |
+
PATH = DATA_DIR / "curated" / FILE
|
| 43 |
+
|
| 44 |
+
STATUSES = ("in-corpus", "secondary-only", "unavailable")
|
| 45 |
+
# The order secondary chunks are listed in (official first; the reversed
|
| 46 |
+
# trial decision's citers last).
|
| 47 |
+
SECONDARY_ORDER = ("in-corpus decision", "official", "archived IRB paper",
|
| 48 |
+
"withdrawn (unofficial ATIP copy)", "cites trial-level Saini (reversed)")
|
| 49 |
+
|
| 50 |
+
# ---- officer-facing wording (registered in canlex/reviewed_code.py) ----------
|
| 51 |
+
HELD_NOTE = ("Authority registry (data/curated/authorities.json): HELD FOR REVIEW -- not "
|
| 52 |
+
"in this deployment, so the cites above are not linked to the corpus. Where a "
|
| 53 |
+
"decision is in the corpus, canlex_search_legislation (doc_type 'caselaw') "
|
| 54 |
+
"finds it by name; canlex_case needs a CanLII key and returns the citator, not "
|
| 55 |
+
"the decision.")
|
| 56 |
+
READ_NOTE = ("Each authority above says where its text is: 'full text in CanLex' means "
|
| 57 |
+
"canlex_search_legislation (doc_type 'caselaw', act=<name>) returns the "
|
| 58 |
+
"decision; 'not in the CanLex corpus' means only the listed guidance or later "
|
| 59 |
+
"decisions describe it. The registry's subsequent-history line is the only "
|
| 60 |
+
"treatment check CanLex holds -- confirm a decision's currency elsewhere "
|
| 61 |
+
"before relying on it.")
|
| 62 |
+
# The tail printed when this deployment's registry is not rendered (the
|
| 63 |
+
# approved f5cf54d baseline): no pointers above, and still never canlex_case.
|
| 64 |
+
NO_POINTER_NOTE = ("Retrieve a cited decision's text with canlex_search_legislation "
|
| 65 |
+
"(doc_type 'caselaw', the decision's name) where the CanLex corpus "
|
| 66 |
+
"holds it. CanLex holds no treatment check beyond the SUBSEQUENT "
|
| 67 |
+
"HISTORY notes it prints -- confirm a decision's currency elsewhere "
|
| 68 |
+
"before relying on it.")
|
| 69 |
+
UNLINKED = "not in the CanLex authority registry (unlinked cite; see data/curated/authorities.json)"
|
| 70 |
+
STATUS_TEXT = {
|
| 71 |
+
"in-corpus": "full text in CanLex: canlex_search_legislation doc_type caselaw, act={short!r}",
|
| 72 |
+
"absent": ("not in this deployment's corpus (the registry lists it as in-corpus, "
|
| 73 |
+
"act={short!r}); quoted in {where}"),
|
| 74 |
+
"secondary-only": "not in the CanLex corpus; quoted in {where}",
|
| 75 |
+
"unavailable": "not in the CanLex corpus ({why}); described in {where}",
|
| 76 |
+
}
|
| 77 |
+
NO_SECONDARY = "no corpus source describes it"
|
| 78 |
+
VERIFY_LABEL = "VERIFIED TEXT"
|
| 79 |
+
VERIFY_MISSING = ("{act} s. {section}: corpus text not available in this deployment "
|
| 80 |
+
"(phrase: {phrase!r})")
|
| 81 |
+
VERIFY_NOT_FOUND = ("{act} s. {section}{when}: phrase {phrase!r} NOT FOUND in the corpus "
|
| 82 |
+
"text -- the quoted maximum needs re-verification")
|
| 83 |
+
|
| 84 |
+
_QUOTES = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'",
|
| 85 |
+
" ": " "})
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _norm(s):
|
| 89 |
+
return re.sub(r"\s+", " ", (s or "").translate(_QUOTES)).strip()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _fold(cite):
|
| 93 |
+
"""A cite string as compared: case, spacing and dotted initials folded, so
|
| 94 |
+
'Canada (M.C.I.) v Saini' still finds 'Canada (MCI) v Saini'."""
|
| 95 |
+
s = _norm(cite).lower().replace(".", "")
|
| 96 |
+
return re.sub(r"\s+", " ", s)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---- the registry ---------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
@functools.lru_cache(maxsize=4)
|
| 102 |
+
def _load(path_str):
|
| 103 |
+
try:
|
| 104 |
+
with open(path_str, encoding="utf-8") as fh:
|
| 105 |
+
data = json.loads(fh.read())
|
| 106 |
+
except (OSError, ValueError):
|
| 107 |
+
return None
|
| 108 |
+
if not isinstance(data, dict) or not isinstance(data.get("authorities"), dict):
|
| 109 |
+
return None
|
| 110 |
+
folded = {_fold(cite): key for cite, key in (data.get("aliases") or {}).items()}
|
| 111 |
+
data["_folded"] = folded
|
| 112 |
+
return data
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def load(path=None):
|
| 116 |
+
"""The registry, or None when this deployment lacks the (held) file."""
|
| 117 |
+
return _load(str(path or PATH))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def renderable(registry=None):
|
| 121 |
+
"""True when the registry says its rows may be printed beside the curated
|
| 122 |
+
holdings (top-level "render": true -- see the module docstring)."""
|
| 123 |
+
reg = registry if registry is not None else load()
|
| 124 |
+
return bool(reg and reg.get("render") is True)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def read_note(registry=None):
|
| 128 |
+
"""The closing line of an answer that cites decisions: READ_NOTE when the
|
| 129 |
+
registry's pointers are printed, NO_POINTER_NOTE otherwise."""
|
| 130 |
+
return READ_NOTE if renderable(registry) else NO_POINTER_NOTE
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def reload():
|
| 134 |
+
_load.cache_clear()
|
| 135 |
+
_corpus_file.cache_clear()
|
| 136 |
+
_act_sections.cache_clear()
|
| 137 |
+
statute_sentence.cache_clear()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def lookup(cite, registry=None):
|
| 141 |
+
"""(key, row) for an exact cite string (folded), or (None, None)."""
|
| 142 |
+
reg = registry if registry is not None else load()
|
| 143 |
+
if not reg or not cite:
|
| 144 |
+
return None, None
|
| 145 |
+
key = (reg.get("aliases") or {}).get(cite) or reg["_folded"].get(_fold(cite))
|
| 146 |
+
if key is None and cite in reg["authorities"]:
|
| 147 |
+
key = cite
|
| 148 |
+
if key is None:
|
| 149 |
+
return None, None
|
| 150 |
+
return key, reg["authorities"].get(key)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---- the corpus: caselaw keys and the chunks the registry names ------------------
|
| 154 |
+
|
| 155 |
+
def _file_for(chunk_id):
|
| 156 |
+
if chunk_id.startswith("enf-fr-"):
|
| 157 |
+
return "enf-fr.json"
|
| 158 |
+
if chunk_id.startswith("enf-"):
|
| 159 |
+
return "enf.json"
|
| 160 |
+
if chunk_id.startswith("irb-ls-"):
|
| 161 |
+
return "irb_papers.json"
|
| 162 |
+
if chunk_id.startswith("irb-"):
|
| 163 |
+
return "irb_guidelines.json"
|
| 164 |
+
if chunk_id.startswith("pdi-"):
|
| 165 |
+
return "pdi.json"
|
| 166 |
+
if chunk_id.startswith("commentary-"):
|
| 167 |
+
return "commentary.json"
|
| 168 |
+
if chunk_id.startswith("policy-"):
|
| 169 |
+
return "policy.json"
|
| 170 |
+
return "caselaw.json"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _wanted_ids(registry):
|
| 174 |
+
ids = set()
|
| 175 |
+
for row in (registry or {}).get("authorities", {}).values():
|
| 176 |
+
ids.update(p["chunk"] for p in row.get("pinned") or [])
|
| 177 |
+
ids.update(s["chunk"] for s in row.get("secondary") or [])
|
| 178 |
+
return frozenset(ids)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@functools.lru_cache(maxsize=8)
|
| 182 |
+
def _corpus_file(name, wanted):
|
| 183 |
+
"""(decision keys, citations, {id: chunk}) of one processed file, keeping
|
| 184 |
+
only the chunks the registry names (caselaw.json is 20 MB)."""
|
| 185 |
+
keys, citations, chunks = set(), set(), {}
|
| 186 |
+
try:
|
| 187 |
+
data = json.loads((PROCESSED_DIR / name).read_text(encoding="utf-8"))
|
| 188 |
+
except (OSError, ValueError):
|
| 189 |
+
return None
|
| 190 |
+
for c in data:
|
| 191 |
+
if c.get("doc_type") == "caselaw":
|
| 192 |
+
keys.add((c.get("act_short"), c.get("act_code")))
|
| 193 |
+
citations.add(c.get("citation") or "")
|
| 194 |
+
if c.get("id") in wanted:
|
| 195 |
+
chunks[c["id"]] = c
|
| 196 |
+
return keys, citations, chunks
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _corpus(name, registry=None):
|
| 200 |
+
reg = registry if registry is not None else load()
|
| 201 |
+
return _corpus_file(name, _wanted_ids(reg))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def chunk(chunk_id, registry=None):
|
| 205 |
+
"""A chunk the registry names, from the processed corpus, or None."""
|
| 206 |
+
got = _corpus(_file_for(chunk_id), registry)
|
| 207 |
+
return (got[2].get(chunk_id) if got else None)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def decision_present(decision_key, index_chunks=None):
|
| 211 |
+
"""True / False when the corpus does / does not hold the decision the key
|
| 212 |
+
names (the loaded index's chunks when given, else data/processed);
|
| 213 |
+
None when neither is readable."""
|
| 214 |
+
if not decision_key:
|
| 215 |
+
return False
|
| 216 |
+
want = (decision_key.get("act_short"), decision_key.get("act_code"))
|
| 217 |
+
if index_chunks is not None:
|
| 218 |
+
return want in _decision_keys(index_chunks)
|
| 219 |
+
got = _corpus("caselaw.json")
|
| 220 |
+
if got is None:
|
| 221 |
+
return None
|
| 222 |
+
return want in got[0]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
_KEYS_SEEN = {}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _decision_keys(index_chunks):
|
| 229 |
+
"""{(act_short, act_code)} of the caselaw chunks of a loaded index,
|
| 230 |
+
computed once per chunk list (a tool answer asks for every authority)."""
|
| 231 |
+
got = _KEYS_SEEN.get(id(index_chunks))
|
| 232 |
+
if got is None or got[0] != len(index_chunks):
|
| 233 |
+
keys = {(c.get("act_short"), c.get("act_code")) for c in index_chunks
|
| 234 |
+
if c.get("doc_type") == "caselaw"}
|
| 235 |
+
_KEYS_SEEN.clear()
|
| 236 |
+
_KEYS_SEEN[id(index_chunks)] = got = (len(index_chunks), keys)
|
| 237 |
+
return got[1]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def excerpt(chunk_id, phrase, registry=None, width=280):
|
| 241 |
+
"""The sentence of the chunk that contains the phrase (the phrase itself
|
| 242 |
+
when the chunk is not readable here)."""
|
| 243 |
+
c = chunk(chunk_id, registry)
|
| 244 |
+
if c is None:
|
| 245 |
+
return None
|
| 246 |
+
return _sentence(c.get("text") or "", phrase, width)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# A period that ends one of these does not end a sentence ('Mr. Drake',
|
| 250 |
+
# 'para. 24', 'J. dissenting', 's. 36', 'No. 363').
|
| 251 |
+
_ABBREV = re.compile(r"(?:\b(?:Mr|Mrs|Ms|Dr|Hon|J|JJ|JA|No|Nos|v|vs|para|paras|s|ss|subs|p|pp|"
|
| 252 |
+
r"art|arts|c|ch|cf|e\.g|i\.e|R\.S\.C|S\.C|F\.C|D\.L\.R|S\.C\.R|F\.T\.R|"
|
| 253 |
+
r"Imm\. L\.R|N\.R|F\.C\.J)|\b[A-Z])$")
|
| 254 |
+
_BOUNDARY = re.compile(r"[.;:](?=\s+[A-Z(\[\"]|\s*$)")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _boundaries(t):
|
| 258 |
+
"""Offsets just after each sentence end in t (abbreviations skipped)."""
|
| 259 |
+
out = []
|
| 260 |
+
for m in _BOUNDARY.finditer(t):
|
| 261 |
+
if m.group(0) == "." and _ABBREV.search(t[:m.start()]):
|
| 262 |
+
continue
|
| 263 |
+
out.append(m.end())
|
| 264 |
+
return out
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# The general limb of a punishment clause: where a maximum phrase occurs more
|
| 268 |
+
# than once in a section ('(a) if a restricted firearm ... is used ..., to
|
| 269 |
+
# imprisonment for life ...; and (b) in any other case, to imprisonment for
|
| 270 |
+
# life', ss. 273 and 344), the occurrence after this formula is the one that
|
| 271 |
+
# states the maximum for every case (wave-3 verifier, 2026-09-15).
|
| 272 |
+
_GENERAL_CLAUSE = re.compile(r"in any other case,?\s+(?:to\s+|by\s+)?$", re.I)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
_CLAUSE_MARK = re.compile(r"\([a-z](?:\.\d+)?\)\s*in any other case", re.I)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _general_clause_quote(t, first, i, n):
|
| 279 |
+
"""'<the punishment sentence's lead-in> ... (b) in any other case, to
|
| 280 |
+
<maximum>': the words that make the clause a maximum, and the clause,
|
| 281 |
+
without the special cases between them (each part verbatim)."""
|
| 282 |
+
ends = _boundaries(t)
|
| 283 |
+
s0 = max([e for e in ends if e <= first], default=0)
|
| 284 |
+
mark = re.search(r"\(a\)", t[s0:first])
|
| 285 |
+
clause = list(_CLAUSE_MARK.finditer(t, max(0, i - 80), i))
|
| 286 |
+
if not mark or not clause:
|
| 287 |
+
return None
|
| 288 |
+
end = next((e for e in ends if e >= i + n), len(t))
|
| 289 |
+
return f"{t[s0:s0 + mark.start()].strip()} ... {t[clause[-1].start():end].strip()}"
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _sentence(text, phrase, width=280, general=False):
|
| 293 |
+
t = _norm(text)
|
| 294 |
+
p = _norm(phrase)
|
| 295 |
+
low, pl = t.lower(), p.lower()
|
| 296 |
+
hits = [m.start() for m in re.finditer(re.escape(pl), low)] if pl else []
|
| 297 |
+
if not hits:
|
| 298 |
+
return None
|
| 299 |
+
i = hits[0]
|
| 300 |
+
if general:
|
| 301 |
+
i = next((h for h in hits if _GENERAL_CLAUSE.search(t[max(0, h - 40):h])), i)
|
| 302 |
+
if i != hits[0]:
|
| 303 |
+
lead = _general_clause_quote(t, hits[0], i, len(p))
|
| 304 |
+
if lead:
|
| 305 |
+
return lead
|
| 306 |
+
ends = _boundaries(t)
|
| 307 |
+
before = [e for e in ends if e <= i]
|
| 308 |
+
start = before[-1] if before else 0
|
| 309 |
+
after = [e for e in ends if e >= i + len(p)]
|
| 310 |
+
end = after[0] if after else len(t)
|
| 311 |
+
s = t[start:end].strip()
|
| 312 |
+
if len(s) > 2 * width + len(p):
|
| 313 |
+
a, b = max(start, i - width), min(end, i + len(p) + width)
|
| 314 |
+
s = ("..." if a > start else "") + t[a:b].strip() + ("..." if b < end else "")
|
| 315 |
+
return s
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---- resolve --------------------------------------------------------------------
|
| 319 |
+
|
| 320 |
+
def resolve(cite, registry=None, index_chunks=None, with_excerpts=True):
|
| 321 |
+
"""What the registry knows about a cite: key, name, status, whether this
|
| 322 |
+
corpus holds the decision, the pinned chunks with their excerpts, the
|
| 323 |
+
ordered secondary chunks, the subsequent history. None when the registry
|
| 324 |
+
is absent; {'unlinked': True} when the cite is not in it."""
|
| 325 |
+
reg = registry if registry is not None else load()
|
| 326 |
+
if reg is None:
|
| 327 |
+
return None
|
| 328 |
+
key, row = lookup(cite, reg)
|
| 329 |
+
if row is None:
|
| 330 |
+
return {"cite": cite, "unlinked": True}
|
| 331 |
+
present = (decision_present(row.get("decision_key"), index_chunks)
|
| 332 |
+
if row.get("status") == "in-corpus" else False)
|
| 333 |
+
pinned = []
|
| 334 |
+
for p in row.get("pinned") or []:
|
| 335 |
+
e = dict(p)
|
| 336 |
+
if with_excerpts:
|
| 337 |
+
c = chunk(p["chunk"], reg)
|
| 338 |
+
e["excerpt"] = _sentence(c.get("text") or "", p["contains"]) if c else None
|
| 339 |
+
e["marginal_note"] = (c or {}).get("marginal_note")
|
| 340 |
+
pinned.append(e)
|
| 341 |
+
return {"cite": cite, "key": key, "name": row.get("name"), "status": row.get("status"),
|
| 342 |
+
"present": present, "decision_key": row.get("decision_key"),
|
| 343 |
+
"citation": row.get("citation"), "court": row.get("court"),
|
| 344 |
+
"decided": row.get("decided"), "pinned": pinned,
|
| 345 |
+
"secondary": list(row.get("secondary") or []),
|
| 346 |
+
"subsequent_history": row.get("subsequent_history"),
|
| 347 |
+
"unavailable_reason": row.get("unavailable_reason"),
|
| 348 |
+
"parallel_cites": list(row.get("parallel_cites") or []),
|
| 349 |
+
"notes": list(row.get("notes") or [])}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _where(res, limit=4):
|
| 353 |
+
secs = res.get("secondary") or []
|
| 354 |
+
if not secs:
|
| 355 |
+
return NO_SECONDARY
|
| 356 |
+
parts = []
|
| 357 |
+
for s in secs[:limit]:
|
| 358 |
+
dated = f", {s['current_to']}" if s.get("current_to") else ""
|
| 359 |
+
parts.append(f"{s['label']} [{s['chunk']}; {s['status']}{dated}]")
|
| 360 |
+
more = len(secs) - limit
|
| 361 |
+
return "; ".join(parts) + (f"; and {more} more" if more > 0 else "")
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _officer_pin(p):
|
| 365 |
+
"""A pin's label as an officer may read it, or None. Reviewer notes live
|
| 366 |
+
in a pin's 'review_note' (never printed); a label that still talks about
|
| 367 |
+
the curated data ('the curated (para 11) is wrong' -- the approved
|
| 368 |
+
registry's style) is a reviewer note too, and is not printed."""
|
| 369 |
+
label = (p or {}).get("pin") or ""
|
| 370 |
+
return None if (not label or re.search(r"curated|->", label)) else label
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _pick_pin(res, pin):
|
| 374 |
+
"""The registry pin that matches the curated pin's first number, else the
|
| 375 |
+
first pinned chunk that is not a heading-only pin."""
|
| 376 |
+
pins = res.get("pinned") or []
|
| 377 |
+
if not pins:
|
| 378 |
+
return None
|
| 379 |
+
nums = re.findall(r"\d+", pin or "")
|
| 380 |
+
if nums:
|
| 381 |
+
for p in pins:
|
| 382 |
+
if re.search(rf"\b{nums[0]}\b", p.get("pin") or "") and not p.get("note"):
|
| 383 |
+
return p
|
| 384 |
+
return next((p for p in pins if not p.get("note")), pins[0])
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def pointer(res, pin=None, brief=False):
|
| 388 |
+
"""The officer-facing line under a cited authority (resolve()'s result)."""
|
| 389 |
+
if res is None:
|
| 390 |
+
return HELD_NOTE
|
| 391 |
+
if res.get("unlinked"):
|
| 392 |
+
return UNLINKED
|
| 393 |
+
short = (res.get("decision_key") or {}).get("act_short")
|
| 394 |
+
status = res["status"]
|
| 395 |
+
if status == "in-corpus" and res.get("present") is not False:
|
| 396 |
+
line = STATUS_TEXT["in-corpus"].format(short=short)
|
| 397 |
+
if res.get("present") is None:
|
| 398 |
+
line += " (presence in this deployment not checked)"
|
| 399 |
+
p = _pick_pin(res, pin)
|
| 400 |
+
if p:
|
| 401 |
+
label = _officer_pin(p)
|
| 402 |
+
line += (f"; pinned {label}, chunk {p['chunk']}" if label
|
| 403 |
+
else f"; pinned chunk {p['chunk']}")
|
| 404 |
+
if not brief and p.get("excerpt"):
|
| 405 |
+
line += f': "{p["excerpt"]}"'
|
| 406 |
+
if brief:
|
| 407 |
+
return line
|
| 408 |
+
if res.get("subsequent_history") and res["subsequent_history"] != "None found.":
|
| 409 |
+
line += f"\n subsequent history: {res['subsequent_history']}"
|
| 410 |
+
return line
|
| 411 |
+
where = _where(res)
|
| 412 |
+
if status == "in-corpus":
|
| 413 |
+
line = STATUS_TEXT["absent"].format(short=short, where=where)
|
| 414 |
+
elif status == "unavailable":
|
| 415 |
+
why = res.get("unavailable_reason") or "unavailable"
|
| 416 |
+
line = (STATUS_TEXT["unavailable"].format(why=why, where=where)
|
| 417 |
+
if res.get("secondary") else f"not in the CanLex corpus ({why}); {NO_SECONDARY}")
|
| 418 |
+
else:
|
| 419 |
+
line = STATUS_TEXT["secondary-only"].format(where=where)
|
| 420 |
+
if not brief and res.get("subsequent_history") and res["subsequent_history"] != "None found.":
|
| 421 |
+
line += f"\n subsequent history: {res['subsequent_history']}"
|
| 422 |
+
return line
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def authority_lines(auths, registry=None, brief=False, index_chunks=None):
|
| 426 |
+
"""The 'Authorities:' bullets of an entry or pairing, each followed by its
|
| 427 |
+
registry pointer (or HELD_NOTE once when the registry is absent; the bare
|
| 428 |
+
bullets when the registry is not rendered -- see renderable())."""
|
| 429 |
+
reg = registry if registry is not None else load()
|
| 430 |
+
lines = []
|
| 431 |
+
show = reg is not None and renderable(reg)
|
| 432 |
+
for a in auths or []:
|
| 433 |
+
pin = f", {a['pin']}" if a.get("pin") else ""
|
| 434 |
+
lines.append(f"- {a['cite']} ({a.get('court', '')}{pin}): {a.get('holding', '')}")
|
| 435 |
+
if not show:
|
| 436 |
+
continue
|
| 437 |
+
res = resolve(a["cite"], reg, index_chunks=index_chunks, with_excerpts=not brief)
|
| 438 |
+
text = pointer(res, a.get("pin"), brief=brief)
|
| 439 |
+
lines += [" " + t for t in text.split("\n")]
|
| 440 |
+
if auths and reg is None:
|
| 441 |
+
lines.append(f" ({HELD_NOTE})" if brief else f" {HELD_NOTE}")
|
| 442 |
+
return lines
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ---- subsequent history: the reversed decisions guidance still cites -------------
|
| 446 |
+
|
| 447 |
+
def superseded_entries(registry=None):
|
| 448 |
+
"""SUPERSEDED_CITES-shaped entries from the registry rows that carry a
|
| 449 |
+
'superseded' block (the reversed trial decision in Saini), or [] when the
|
| 450 |
+
registry is absent or not rendered -- canlex/server.py then uses its own
|
| 451 |
+
constant."""
|
| 452 |
+
reg = registry if registry is not None else load()
|
| 453 |
+
if not renderable(reg):
|
| 454 |
+
return []
|
| 455 |
+
out = []
|
| 456 |
+
for key, row in sorted((reg or {}).get("authorities", {}).items()):
|
| 457 |
+
s = row.get("superseded")
|
| 458 |
+
if not s:
|
| 459 |
+
continue
|
| 460 |
+
try:
|
| 461 |
+
pattern = re.compile(s["pattern"], re.S)
|
| 462 |
+
except (re.error, KeyError):
|
| 463 |
+
continue
|
| 464 |
+
out.append({"id": key, "pattern": pattern, "note": s.get("note", ""),
|
| 465 |
+
"appellate_citation": s.get("appellate_citation", ""),
|
| 466 |
+
"read_present": s.get("read_present", ""),
|
| 467 |
+
"read_absent": s.get("read_absent", ""),
|
| 468 |
+
"read_unknown": s.get("read_unknown", "")})
|
| 469 |
+
return out
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
# ---- the statute sentence behind each verified maximum --------------------------
|
| 473 |
+
|
| 474 |
+
@functools.lru_cache(maxsize=16)
|
| 475 |
+
def _act_sections(act):
|
| 476 |
+
"""{section: [chunk]} of an Act's English chunks, or None when the
|
| 477 |
+
processed file is absent here."""
|
| 478 |
+
try:
|
| 479 |
+
data = json.loads((PROCESSED_DIR / f"{act}.json").read_text(encoding="utf-8"))
|
| 480 |
+
except (OSError, ValueError):
|
| 481 |
+
return None
|
| 482 |
+
out = {}
|
| 483 |
+
for c in data:
|
| 484 |
+
if c.get("lang", "en") != "en":
|
| 485 |
+
continue
|
| 486 |
+
out.setdefault(str(c.get("section")), []).append(c)
|
| 487 |
+
return out
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
@functools.lru_cache(maxsize=512)
|
| 491 |
+
def statute_sentence(act, section, phrase):
|
| 492 |
+
"""{act, section, current_to, chunk, sentence} for the corpus sentence
|
| 493 |
+
that contains a verify phrase; {'missing': True} when the file is absent;
|
| 494 |
+
{'not_found': True, 'current_to': ...} when the phrase is gone (a corpus
|
| 495 |
+
refresh changed the text)."""
|
| 496 |
+
sections = _act_sections(act)
|
| 497 |
+
if sections is None:
|
| 498 |
+
return {"act": act, "section": section, "phrase": phrase, "missing": True}
|
| 499 |
+
chunks = sections.get(str(section)) or []
|
| 500 |
+
for c in chunks:
|
| 501 |
+
s = _sentence(c.get("text") or "", phrase, general=True)
|
| 502 |
+
if s:
|
| 503 |
+
return {"act": act, "section": section, "phrase": phrase,
|
| 504 |
+
"current_to": c.get("current_to") or "", "chunk": c.get("id"),
|
| 505 |
+
"sentence": s}
|
| 506 |
+
return {"act": act, "section": section, "phrase": phrase, "not_found": True,
|
| 507 |
+
"current_to": (chunks[0].get("current_to") if chunks else "") or ""}
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def verify_rows(pairing, limb=None):
|
| 511 |
+
"""The pairing's verify rows plus the selected limb's, without repeats."""
|
| 512 |
+
rows, seen = [], set()
|
| 513 |
+
for v in list(pairing.get("verify") or []) + (
|
| 514 |
+
[v for l in pairing.get("limbs") or [] if l.get("key") == limb
|
| 515 |
+
for v in l.get("verify") or []] if limb else []):
|
| 516 |
+
key = (v.get("act"), v.get("section"), v.get("contains"))
|
| 517 |
+
if key in seen or not all(key):
|
| 518 |
+
continue
|
| 519 |
+
seen.add(key)
|
| 520 |
+
rows.append(v)
|
| 521 |
+
return rows
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def verify_lines(pairing, limb=None):
|
| 525 |
+
"""The VERIFIED TEXT lines printed under a pairing's maximum: for each
|
| 526 |
+
verify phrase, the statute sentence that contains it, with the Act, the
|
| 527 |
+
section, the corpus 'current to' date and the chunk id."""
|
| 528 |
+
lines = []
|
| 529 |
+
for v in verify_rows(pairing, limb):
|
| 530 |
+
r = statute_sentence(v["act"], str(v["section"]), v["contains"])
|
| 531 |
+
if r.get("missing"):
|
| 532 |
+
lines.append(f"{VERIFY_LABEL}: " + VERIFY_MISSING.format(
|
| 533 |
+
act=v["act"], section=v["section"], phrase=v["contains"]))
|
| 534 |
+
elif r.get("not_found"):
|
| 535 |
+
when = f" (current to {r['current_to']})" if r.get("current_to") else ""
|
| 536 |
+
lines.append(f"{VERIFY_LABEL}: " + VERIFY_NOT_FOUND.format(
|
| 537 |
+
act=v["act"], section=v["section"], when=when, phrase=v["contains"]))
|
| 538 |
+
else:
|
| 539 |
+
lines.append(f"{VERIFY_LABEL}: {r['act']} s. {r['section']} (current to "
|
| 540 |
+
f"{r['current_to'] or 'n/a'}, chunk {r['chunk']}): \"{r['sentence']}\"")
|
| 541 |
+
return lines
|
canlex/benefits.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (dehyphenate, document_words, fetch_cached, norm_ws,
|
| 39 |
+
preserve_dropped, split_lines, stored_chunks,
|
| 40 |
+
write_corpus)
|
| 41 |
+
|
| 42 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 43 |
+
|
| 44 |
+
RAW = RAW_DIR / "benefits"
|
| 45 |
+
OUT = PROCESSED_DIR / "benefits.json"
|
| 46 |
+
|
| 47 |
+
_CHUNK_CHARS = 1800 # target characters per chunk
|
| 48 |
+
|
| 49 |
+
# Each plan: its booklet, and how to read it. PDF booklets give the page
|
| 50 |
+
# indices of the table of contents and the first body page (everything before
|
| 51 |
+
# it is cover + TOC). The HTML booklet is parsed at its h2/h3 headings.
|
| 52 |
+
# 'edition' is the booklet's own date (for HTML it is read from the page's
|
| 53 |
+
# "Date modified"); bump a PDF edition and re-run when a new booklet is posted.
|
| 54 |
+
SOURCES = {
|
| 55 |
+
"PSHCP": {
|
| 56 |
+
"code": "PSHCP", "format": "pdf",
|
| 57 |
+
"name": "Public Service Health Care Plan",
|
| 58 |
+
"url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
|
| 59 |
+
"welcome-sites/pshcp/PSHCP-member-booklet.pdf"),
|
| 60 |
+
"label": "Member Booklet",
|
| 61 |
+
"toc_pages": [2, 3, 4],
|
| 62 |
+
"body_start": 5,
|
| 63 |
+
"edition": "June 2024",
|
| 64 |
+
},
|
| 65 |
+
"PSDCP": {
|
| 66 |
+
"code": "PSDCP", "format": "pdf",
|
| 67 |
+
"name": "Public Service Dental Care Plan",
|
| 68 |
+
"url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
|
| 69 |
+
"welcome-sites/psdcp/psdcp-member-booklet.pdf"),
|
| 70 |
+
"label": "Member Booklet",
|
| 71 |
+
"toc_pages": [2, 3],
|
| 72 |
+
"body_start": 4,
|
| 73 |
+
"edition": "May 2026",
|
| 74 |
+
},
|
| 75 |
+
"PDSP": {
|
| 76 |
+
"code": "PDSP", "format": "pdf",
|
| 77 |
+
"name": "Pensioners' Dental Services Plan",
|
| 78 |
+
"url": ("https://www.canadalife.com/content/dam/rfp/welcome-sites/"
|
| 79 |
+
"pdsp/pdsp-member-booklet.pdf"),
|
| 80 |
+
"label": "Member Booklet",
|
| 81 |
+
"toc_pages": [2, 3],
|
| 82 |
+
"body_start": 4,
|
| 83 |
+
"edition": "November 2024",
|
| 84 |
+
},
|
| 85 |
+
"PSMIP": {
|
| 86 |
+
"code": "PSMIP", "format": "html",
|
| 87 |
+
"name": "Public Service Management Insurance Plan",
|
| 88 |
+
"url": ("https://www.canada.ca/en/treasury-board-secretariat/services/"
|
| 89 |
+
"benefit-plans/management-insurance-plan/main-plan-booklet.html"),
|
| 90 |
+
"label": "Main Plan Booklet",
|
| 91 |
+
# The canada.ca page exposes no machine-readable date (its <time> tags are
|
| 92 |
+
# content dates, not a page revision). Rather than render currency as
|
| 93 |
+
# 'n/a', state plainly that the source is undated so a reader knows to
|
| 94 |
+
# verify rather than assume staleness.
|
| 95 |
+
"edition": "undated (TBS web edition)",
|
| 96 |
+
},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# canada.ca section labels that are navigation/footer, not plan content.
|
| 100 |
+
_SKIP_HEADINGS = {"on this page", "page details", "from:", "table of contents"}
|
| 101 |
+
|
| 102 |
+
# A table-of-contents line: optional dot-leaders, the heading text, then the
|
| 103 |
+
# printed page number. The heading must end in a letter or ')' so a bare page
|
| 104 |
+
# number or a dot run alone is not mistaken for a heading.
|
| 105 |
+
_TOC_LINE = re.compile(r"^[\s.]*(.*?[A-Za-z)])\s*\.*\s*(\d{1,3})\s*$")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _fetch(url, dest, force=False):
|
| 109 |
+
return fetch_cached(url, dest, timeout=90, pause=0, force=force)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _clean(text):
|
| 113 |
+
"""Tidy extracted PDF text: turn bullets into dashes, collapse runs of
|
| 114 |
+
spaces, and trim excess blank lines. Apostrophes, accents and en-dashes
|
| 115 |
+
extract as proper Unicode, so no transliteration is needed."""
|
| 116 |
+
text = text.replace("•", "\n- ").replace("\xa0", " ")
|
| 117 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 118 |
+
text = re.sub(r"\n[ \t]+", "\n", text)
|
| 119 |
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _page_texts(pdf_bytes):
|
| 123 |
+
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 124 |
+
return [p.extract_text() or "" for p in reader.pages]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def parse_toc(page_texts, toc_pages):
|
| 128 |
+
"""Return the ordered, de-duplicated list of section headings from the
|
| 129 |
+
table of contents."""
|
| 130 |
+
seen, headings = set(), []
|
| 131 |
+
for i in toc_pages:
|
| 132 |
+
if i >= len(page_texts):
|
| 133 |
+
continue
|
| 134 |
+
for line in page_texts[i].split("\n"):
|
| 135 |
+
line = line.strip()
|
| 136 |
+
if not line or "Table of Contents" in line:
|
| 137 |
+
continue
|
| 138 |
+
m = _TOC_LINE.match(line)
|
| 139 |
+
if not m:
|
| 140 |
+
continue
|
| 141 |
+
title = re.sub(r"\s+", " ", m.group(1)).strip(" .")
|
| 142 |
+
if len(title) > 2 and title.lower() not in seen:
|
| 143 |
+
seen.add(title.lower())
|
| 144 |
+
headings.append(title)
|
| 145 |
+
return headings
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def segment(body, headings):
|
| 149 |
+
"""Split the body text at each heading, in order, returning a list of
|
| 150 |
+
(heading, section_text). A heading the extractor split across lines is
|
| 151 |
+
matched with flexible whitespace; a heading not found in the body is
|
| 152 |
+
skipped (its text folds into the preceding section)."""
|
| 153 |
+
cuts = []
|
| 154 |
+
pos = 0
|
| 155 |
+
for h in headings:
|
| 156 |
+
pattern = re.compile(r"\s*".join(re.escape(w) for w in h.split()), re.I)
|
| 157 |
+
m = pattern.search(body, pos)
|
| 158 |
+
if m:
|
| 159 |
+
cuts.append((h, m.start()))
|
| 160 |
+
pos = m.end()
|
| 161 |
+
sections = []
|
| 162 |
+
for j, (h, start) in enumerate(cuts):
|
| 163 |
+
end = cuts[j + 1][1] if j + 1 < len(cuts) else len(body)
|
| 164 |
+
sections.append((h, body[start:end].strip()))
|
| 165 |
+
return sections
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _split(text, target=_CHUNK_CHARS):
|
| 169 |
+
"""Split a long section into ~target-sized pieces at line boundaries,
|
| 170 |
+
folding small leading/trailing remnants into a neighbour so a section
|
| 171 |
+
never yields a stub chunk."""
|
| 172 |
+
return split_lines(text, target, fold_stubs=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _slug(heading, used):
|
| 176 |
+
"""A short, unique, citable section locator from a heading."""
|
| 177 |
+
base = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "section"
|
| 178 |
+
slug, n = base, 2
|
| 179 |
+
while slug in used:
|
| 180 |
+
slug = f"{base}-{n}"
|
| 181 |
+
n += 1
|
| 182 |
+
used.add(slug)
|
| 183 |
+
return slug
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _emit(sections, src):
|
| 187 |
+
"""Build CanLex chunk dicts from a list of (heading, section_text), shared
|
| 188 |
+
by the PDF and HTML paths. Skips pure section-divider headings and
|
| 189 |
+
sub-splits any section over the chunk budget."""
|
| 190 |
+
chunks = []
|
| 191 |
+
used_slugs = set()
|
| 192 |
+
label = src["label"]
|
| 193 |
+
for heading, text in sections:
|
| 194 |
+
# Skip pure section dividers -- a heading whose only content is the
|
| 195 |
+
# heading itself (its substance lives in the child sections below it).
|
| 196 |
+
# Compare with whitespace collapsed, since a PDF extractor often breaks
|
| 197 |
+
# a heading across lines ("...Plan \n(PSDCP)").
|
| 198 |
+
norm_text = norm_ws(text)
|
| 199 |
+
norm_head = norm_ws(heading)
|
| 200 |
+
remainder = (norm_text[len(norm_head):]
|
| 201 |
+
if norm_text.lower().startswith(norm_head.lower())
|
| 202 |
+
else norm_text)
|
| 203 |
+
if len(remainder.strip()) < 40:
|
| 204 |
+
continue
|
| 205 |
+
slug = _slug(heading, used_slugs)
|
| 206 |
+
pieces = _split(text)
|
| 207 |
+
for k, piece in enumerate(pieces, start=1):
|
| 208 |
+
section = slug if len(pieces) == 1 else f"{slug}-{k}"
|
| 209 |
+
note = heading if len(pieces) == 1 else f"{heading} (part {k})"
|
| 210 |
+
chunks.append({
|
| 211 |
+
"id": f"benefits-{src['code']}-{section}",
|
| 212 |
+
"doc_type": "benefits",
|
| 213 |
+
"act_code": src["code"],
|
| 214 |
+
"act_short": src["code"],
|
| 215 |
+
"act_name": f"{src['name']} — {label}",
|
| 216 |
+
"section": section,
|
| 217 |
+
"marginal_note": note,
|
| 218 |
+
"part": f"{src['name']} {label}",
|
| 219 |
+
"division": "",
|
| 220 |
+
"heading": "",
|
| 221 |
+
"text": piece,
|
| 222 |
+
"history": "",
|
| 223 |
+
"last_amended": "",
|
| 224 |
+
"current_to": src["edition"],
|
| 225 |
+
"citation": (f"{src['code']} {label}"
|
| 226 |
+
+ (f" ({src['edition']})" if src["edition"] else "")
|
| 227 |
+
+ f", “{heading}”"),
|
| 228 |
+
"source_url": src["url"],
|
| 229 |
+
})
|
| 230 |
+
return chunks
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def parse_pdf_booklet(pdf_bytes, src):
|
| 234 |
+
"""Segment a PDF booklet by its table-of-contents headings."""
|
| 235 |
+
pages = _page_texts(pdf_bytes)
|
| 236 |
+
headings = parse_toc(pages, src["toc_pages"])
|
| 237 |
+
body = _clean("\n".join(pages[src["body_start"]:]))
|
| 238 |
+
# line-break hyphens closed up (keeping the hyphen unless the booklet
|
| 239 |
+
# itself uses the joined word; 2026-09-22)
|
| 240 |
+
body = dehyphenate(body, document_words(pages))
|
| 241 |
+
return _emit(segment(body, headings), src)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _html_block_text(heading):
|
| 245 |
+
"""Readable text from an h2/h3 up to the next heading of the same or higher
|
| 246 |
+
level; lists become dash items."""
|
| 247 |
+
lines = []
|
| 248 |
+
for sib in heading.find_next_siblings():
|
| 249 |
+
if sib.name in ("h2", "h3"):
|
| 250 |
+
break
|
| 251 |
+
if sib.name in ("ul", "ol"):
|
| 252 |
+
for li in sib.find_all("li", recursive=False):
|
| 253 |
+
item = norm_ws(li.get_text(" ", strip=True))
|
| 254 |
+
if item:
|
| 255 |
+
lines.append(f"- {item}")
|
| 256 |
+
elif sib.name in ("p", "div", "table", "section", "h4", "h5"):
|
| 257 |
+
text = norm_ws(sib.get_text(" ", strip=True))
|
| 258 |
+
if text:
|
| 259 |
+
lines.append(text)
|
| 260 |
+
return "\n".join(lines)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def parse_html_booklet(html, src):
|
| 264 |
+
"""Segment the canada.ca HTML booklet at its h2/h3 headings."""
|
| 265 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 266 |
+
main = soup.find("main") or soup
|
| 267 |
+
# Read the page's "Date modified" so the booklet's currency is recorded.
|
| 268 |
+
if not src["edition"]:
|
| 269 |
+
t = main.find("time", property="dateModified") or soup.find(
|
| 270 |
+
"time", property="dateModified")
|
| 271 |
+
if t:
|
| 272 |
+
src = {**src, "edition": t.get_text(strip=True)}
|
| 273 |
+
sections = []
|
| 274 |
+
for h in main.find_all(["h2", "h3"]):
|
| 275 |
+
title = norm_ws(h.get_text(" ", strip=True))
|
| 276 |
+
if not title or title.lower() in _SKIP_HEADINGS:
|
| 277 |
+
continue
|
| 278 |
+
body = _html_block_text(h)
|
| 279 |
+
if body:
|
| 280 |
+
sections.append((title, f"{title}\n{body}"))
|
| 281 |
+
return _emit(sections, src)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _fetch_html(url, dest, force=False):
|
| 285 |
+
"""Fetch a canada.ca page via PowerShell -- the site blocks Python's HTTP
|
| 286 |
+
client at the TLS layer, but accepts PowerShell's .NET stack."""
|
| 287 |
+
return fetch_cached(url, dest, powershell=True, force=force)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def preserve_failed(chunks, failures, stored):
|
| 291 |
+
"""Re-attach the last-good chunks of plans this run could not rebuild.
|
| 292 |
+
|
| 293 |
+
Ingestion is a full rebuild of all four booklets into one file, so one plan
|
| 294 |
+
failing -- Canada Life moves a PDF, canada.ca times out, a re-typeset
|
| 295 |
+
booklet no longer segments -- silently deletes that plan's coverage while
|
| 296 |
+
the other three look healthy. A plan's chunks all carry its code in
|
| 297 |
+
act_code, so the stored copy can be restored intact. Restricted to plans
|
| 298 |
+
that actually failed, so a plan removed from SOURCES on purpose (a wound-up
|
| 299 |
+
plan) is not resurrected. Returns (chunks, preserved plan codes).
|
| 300 |
+
"""
|
| 301 |
+
failed = {code for code, _why in failures}
|
| 302 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 303 |
+
only=failed)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def build(allow_shrink=False, force=False):
|
| 307 |
+
all_chunks, failures = [], []
|
| 308 |
+
for src in SOURCES.values():
|
| 309 |
+
print(f"Ingesting {src['code']} {src['label'].lower()} ...")
|
| 310 |
+
try:
|
| 311 |
+
if src["format"] == "pdf":
|
| 312 |
+
pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
|
| 313 |
+
force=force)
|
| 314 |
+
chunks = parse_pdf_booklet(pdf, src)
|
| 315 |
+
else:
|
| 316 |
+
html = _fetch_html(src["url"], RAW / f"{src['code']}.html",
|
| 317 |
+
force=force).decode("utf-8", "replace")
|
| 318 |
+
chunks = parse_html_booklet(html, src)
|
| 319 |
+
except Exception as exc:
|
| 320 |
+
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 321 |
+
failures.append((src["code"], f"{type(exc).__name__}: {exc}"))
|
| 322 |
+
continue
|
| 323 |
+
if not chunks:
|
| 324 |
+
# A booklet that downloads fine but segments to nothing has failed
|
| 325 |
+
# just as surely as one that 404s -- that is how the CBSA index
|
| 326 |
+
# loss looked -- so it counts as a failure rather than a plan that
|
| 327 |
+
# legitimately has no content.
|
| 328 |
+
print(f" !! {src['code']}: no sections parsed")
|
| 329 |
+
failures.append((src["code"], "no sections parsed"))
|
| 330 |
+
continue
|
| 331 |
+
all_chunks.extend(chunks)
|
| 332 |
+
print(f" {len(chunks)} chunks")
|
| 333 |
+
|
| 334 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 335 |
+
stored_chunks(OUT))
|
| 336 |
+
if preserved:
|
| 337 |
+
print(f" kept the last-good copy of {len(preserved)} booklet(s): "
|
| 338 |
+
f"{', '.join(preserved)}")
|
| 339 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 340 |
+
return False
|
| 341 |
+
print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
|
| 342 |
+
f"booklet(s) -> {OUT}")
|
| 343 |
+
return True
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def main():
|
| 347 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
|
| 348 |
+
force="--force" in sys.argv) else 1)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
if __name__ == "__main__":
|
| 352 |
+
main()
|
canlex/bm25.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BM25 postings in array (CSR) form, and their on-disk cache.
|
| 2 |
+
|
| 3 |
+
Audit follow-up 2026-09-21 (Optimization 5, 6 and 8). The postings were a
|
| 4 |
+
dict of Python lists of (chunk, tf) tuples -- 7.04 M tuples over 62,016
|
| 5 |
+
terms, about +522 MB of RSS -- rebuilt from 137,894 tokenize calls on every
|
| 6 |
+
start (8.5-18 s of LegislationIndex()'s 12-31 s), and scored one tuple at a
|
| 7 |
+
time in Python. Here each term's postings are two slices of two arrays
|
| 8 |
+
(chunk indices ascending, term frequencies), so the scorer in
|
| 9 |
+
canlex/index.py adds a whole term at once with numpy, and the arrays -- with
|
| 10 |
+
the corpus statistics and the typo corrector's word counts -- are cached
|
| 11 |
+
under data/cache/ (gitignored).
|
| 12 |
+
|
| 13 |
+
The cache key is the processed files' (name, size, mtime_ns) AND a
|
| 14 |
+
fingerprint of the code that produced the arrays (code_fingerprint): keyed
|
| 15 |
+
by data alone it would keep serving stale postings after a tokenizer or
|
| 16 |
+
stemmer change. A cache that cannot be read or written is rebuilt in
|
| 17 |
+
process -- retrieval never depends on it.
|
| 18 |
+
"""
|
| 19 |
+
import hashlib
|
| 20 |
+
import inspect
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
import time
|
| 25 |
+
from array import array
|
| 26 |
+
from collections import Counter
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import NamedTuple
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
|
| 32 |
+
from .config import DATA_DIR
|
| 33 |
+
|
| 34 |
+
# Bump when the cached layout or the meaning of a cached array changes in a
|
| 35 |
+
# way the code fingerprint cannot see (a dtype, a field's semantics).
|
| 36 |
+
CACHE_VERSION = 1
|
| 37 |
+
CACHE_DIR = Path(os.environ.get("CANLEX_CACHE_DIR") or DATA_DIR / "cache")
|
| 38 |
+
_CACHE_PREFIX = "bm25-"
|
| 39 |
+
# A writer's bm25-<key>.<pid>.tmp older than this is an orphan of a killed
|
| 40 |
+
# process and save() removes it; a live writer's file is seconds old.
|
| 41 |
+
_TMP_STALE_SECONDS = 600
|
| 42 |
+
_SCOPES = ("en", "all", "fr") # the corrector vocabularies (Bm25Data.words)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Postings:
|
| 46 |
+
"""term -> (chunk indices ascending, term frequencies) in CSR form: the
|
| 47 |
+
postings of the i-th term are docs[offsets[i]:offsets[i + 1]] and
|
| 48 |
+
tfs[...] alike. Terms keep their first-seen corpus order, the order the
|
| 49 |
+
dict of lists had, so every vocabulary built by iterating them is
|
| 50 |
+
unchanged."""
|
| 51 |
+
|
| 52 |
+
__slots__ = ("terms", "offsets", "docs", "tfs", "_ids")
|
| 53 |
+
|
| 54 |
+
def __init__(self, terms, offsets, docs, tfs):
|
| 55 |
+
self.terms = terms
|
| 56 |
+
self.offsets, self.docs, self.tfs = offsets, docs, tfs
|
| 57 |
+
self._ids = {t: i for i, t in enumerate(terms)}
|
| 58 |
+
|
| 59 |
+
def span(self, term):
|
| 60 |
+
"""(chunk indices, term frequencies) array views, or None."""
|
| 61 |
+
i = self._ids.get(term)
|
| 62 |
+
if i is None:
|
| 63 |
+
return None
|
| 64 |
+
a, b = int(self.offsets[i]), int(self.offsets[i + 1])
|
| 65 |
+
return self.docs[a:b], self.tfs[a:b]
|
| 66 |
+
|
| 67 |
+
def get(self, term, default=None):
|
| 68 |
+
"""The chunk indices holding `term` -- len() is its document
|
| 69 |
+
frequency -- or `default`: the dict.get the dict of lists offered."""
|
| 70 |
+
hit = self.span(term)
|
| 71 |
+
return default if hit is None else hit[0]
|
| 72 |
+
|
| 73 |
+
def __contains__(self, term):
|
| 74 |
+
return term in self._ids
|
| 75 |
+
|
| 76 |
+
def __len__(self):
|
| 77 |
+
return len(self.terms)
|
| 78 |
+
|
| 79 |
+
def __iter__(self):
|
| 80 |
+
return iter(self.terms)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class Bm25Data(NamedTuple):
|
| 84 |
+
"""What build() derives from the chunks (and the cache stores): the
|
| 85 |
+
postings, each chunk's length in tokens, per-term document frequencies
|
| 86 |
+
over the whole corpus and over the English chunks, the English scope's
|
| 87 |
+
size and total length, and the typo corrector's word counts by scope
|
| 88 |
+
('en': chunks whose lang is not 'fr'; 'all': every chunk; 'fr': the
|
| 89 |
+
French chunks' words with their accents)."""
|
| 90 |
+
postings: Postings
|
| 91 |
+
doc_len: np.ndarray
|
| 92 |
+
df: np.ndarray
|
| 93 |
+
df_en: np.ndarray
|
| 94 |
+
en_docs: int
|
| 95 |
+
en_total_len: int
|
| 96 |
+
words: dict
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build(chunks, blob, tokenize, words, fr_words):
|
| 100 |
+
"""Bm25Data for `chunks`. `blob(chunk)` is the text BM25 indexes,
|
| 101 |
+
`tokenize` turns it into terms, `words(text)` lists the corrector's
|
| 102 |
+
vocabulary words of a chunk's text and `fr_words(text)` a French
|
| 103 |
+
chunk's accented ones. A chunk is English unless its lang is 'fr' (the
|
| 104 |
+
scope of the English statistics and of the 'en' words)."""
|
| 105 |
+
ids = {} # term -> id, in first-seen order
|
| 106 |
+
term_id = ids.setdefault
|
| 107 |
+
col_term, col_doc, col_tf = array("i"), array("i"), array("i")
|
| 108 |
+
doc_len = array("q")
|
| 109 |
+
words_all, words_en, words_fr = Counter(), Counter(), Counter()
|
| 110 |
+
english = np.array([c.get("lang", "en") != "fr" for c in chunks], dtype=bool)
|
| 111 |
+
for idx, c in enumerate(chunks):
|
| 112 |
+
tokens = tokenize(blob(c))
|
| 113 |
+
counts = Counter(tokens)
|
| 114 |
+
doc_len.append(len(tokens))
|
| 115 |
+
col_term.extend([term_id(t, len(ids)) for t in counts])
|
| 116 |
+
col_tf.extend(counts.values())
|
| 117 |
+
col_doc.extend([idx] * len(counts))
|
| 118 |
+
found = words(c["text"])
|
| 119 |
+
words_all.update(found)
|
| 120 |
+
if english[idx]:
|
| 121 |
+
words_en.update(found)
|
| 122 |
+
else:
|
| 123 |
+
words_fr.update(fr_words(c["text"]))
|
| 124 |
+
postings, df, df_en = _csr(list(ids), col_term, col_doc, col_tf, english)
|
| 125 |
+
lengths = np.frombuffer(doc_len, dtype=np.int64).copy()
|
| 126 |
+
return Bm25Data(postings, lengths, df, df_en, int(english.sum()),
|
| 127 |
+
int(lengths[english].sum()),
|
| 128 |
+
{"en": words_en, "all": words_all, "fr": words_fr})
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _csr(terms, col_term, col_doc, col_tf, english):
|
| 132 |
+
"""(Postings, df, df_en) from the (term, chunk, tf) columns, which run
|
| 133 |
+
in chunk order: a stable sort by term keeps each term's chunks
|
| 134 |
+
ascending, as the dict of lists appended them."""
|
| 135 |
+
term = np.frombuffer(col_term, dtype=np.intc)
|
| 136 |
+
doc = np.frombuffer(col_doc, dtype=np.intc)
|
| 137 |
+
order = np.argsort(term, kind="stable")
|
| 138 |
+
df = np.bincount(term, minlength=len(terms)).astype(np.int64)
|
| 139 |
+
df_en = np.bincount(term[english[doc]], minlength=len(terms)).astype(np.int64)
|
| 140 |
+
offsets = np.zeros(len(terms) + 1, dtype=np.int64)
|
| 141 |
+
np.cumsum(df, out=offsets[1:])
|
| 142 |
+
tfs = np.frombuffer(col_tf, dtype=np.intc)[order]
|
| 143 |
+
if tfs.size and int(tfs.max()) < 2 ** 16:
|
| 144 |
+
tfs = tfs.astype(np.uint16)
|
| 145 |
+
docs = doc[order].astype(np.int32)
|
| 146 |
+
return Postings(terms, offsets, docs, tfs), df, df_en
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def first_seen(parts):
|
| 150 |
+
"""The distinct values of the int arrays `parts`, in order of first
|
| 151 |
+
appearance -- the insertion order a dict filled term by term had."""
|
| 152 |
+
if not parts:
|
| 153 |
+
return np.zeros(0, dtype=np.int64)
|
| 154 |
+
flat = np.concatenate(parts)
|
| 155 |
+
uniq, first = np.unique(flat, return_index=True)
|
| 156 |
+
return uniq[np.argsort(first, kind="stable")]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---- the cache ---------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
def _source(obj):
|
| 162 |
+
"""What of `obj` the fingerprint hashes: a function's source (its code
|
| 163 |
+
object when no source is available), anything else's repr."""
|
| 164 |
+
if callable(obj):
|
| 165 |
+
try:
|
| 166 |
+
return inspect.getsource(obj)
|
| 167 |
+
except (OSError, TypeError):
|
| 168 |
+
code = getattr(obj, "__code__", None)
|
| 169 |
+
return repr((code.co_code, code.co_consts)) if code else repr(obj)
|
| 170 |
+
return repr(obj)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def code_fingerprint(*parts):
|
| 174 |
+
"""sha256 over CACHE_VERSION, build()'s own source and every part (the
|
| 175 |
+
tokenizer, the stemmer and its table, the blob and word functions, the
|
| 176 |
+
stemmer's version -- whatever shapes the cached arrays)."""
|
| 177 |
+
h = hashlib.sha256()
|
| 178 |
+
for part in (CACHE_VERSION, build, _csr, *parts):
|
| 179 |
+
h.update(_source(part).encode("utf-8"))
|
| 180 |
+
h.update(b"\x00")
|
| 181 |
+
return h.hexdigest()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def file_stamps(paths):
|
| 185 |
+
"""[(name, size, mtime_ns)] of the processed files, in the given order."""
|
| 186 |
+
out = []
|
| 187 |
+
for p in paths:
|
| 188 |
+
st = Path(p).stat()
|
| 189 |
+
out.append((Path(p).name, st.st_size, st.st_mtime_ns))
|
| 190 |
+
return out
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def cache_key(stamps, code):
|
| 194 |
+
"""The cache key: the data stamps and the code fingerprint together."""
|
| 195 |
+
return json.dumps({"version": CACHE_VERSION, "code": code, "files": stamps},
|
| 196 |
+
separators=(",", ":"))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def cache_path(key, cache_dir=None):
|
| 200 |
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
| 201 |
+
return Path(cache_dir or CACHE_DIR) / f"{_CACHE_PREFIX}{digest}.npz"
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _joined(strings):
|
| 205 |
+
return np.frombuffer("\n".join(strings).encode("utf-8"), dtype=np.uint8)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _split(arr):
|
| 209 |
+
text = arr.tobytes().decode("utf-8")
|
| 210 |
+
return text.split("\n") if text else []
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _drop_stale_tmp(directory):
|
| 214 |
+
"""Remove the bm25-*.tmp files in `directory` last written more than
|
| 215 |
+
_TMP_STALE_SECONDS ago: what a writer killed between open() and
|
| 216 |
+
os.replace() leaves. A file that cannot be read or removed is left.
|
| 217 |
+
Verification 2026-09-22 (A-CACHE-TMP): save() swept only *.npz, so each
|
| 218 |
+
killed writer's partial cache stayed on disk for good."""
|
| 219 |
+
now = time.time()
|
| 220 |
+
for tmp in Path(directory).glob(f"{_CACHE_PREFIX}*.tmp"):
|
| 221 |
+
try:
|
| 222 |
+
if now - tmp.stat().st_mtime > _TMP_STALE_SECONDS:
|
| 223 |
+
tmp.unlink()
|
| 224 |
+
except OSError:
|
| 225 |
+
pass # gone already, or still held open by its writer
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def save(key, data, cache_dir=None):
|
| 229 |
+
"""Write `data` under `key` atomically and drop the directory's other
|
| 230 |
+
BM25 caches and orphaned partial writes (_drop_stale_tmp); False, with
|
| 231 |
+
a note on stderr, when it cannot be written."""
|
| 232 |
+
path = cache_path(key, cache_dir)
|
| 233 |
+
tmp = path.with_name(f"{path.stem}.{os.getpid()}.tmp")
|
| 234 |
+
p = data.postings
|
| 235 |
+
try:
|
| 236 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
_drop_stale_tmp(path.parent)
|
| 238 |
+
with open(tmp, "wb") as fh:
|
| 239 |
+
np.savez(fh, key=np.array(key), terms=_joined(p.terms),
|
| 240 |
+
offsets=p.offsets, docs=p.docs, tfs=p.tfs,
|
| 241 |
+
doc_len=data.doc_len, df=data.df, df_en=data.df_en,
|
| 242 |
+
scope=np.array([data.en_docs, data.en_total_len], dtype=np.int64),
|
| 243 |
+
**{f"words_{s}": _joined(data.words[s]) for s in _SCOPES},
|
| 244 |
+
**{f"counts_{s}": np.fromiter(data.words[s].values(), np.int64,
|
| 245 |
+
len(data.words[s]))
|
| 246 |
+
for s in _SCOPES})
|
| 247 |
+
os.replace(tmp, path)
|
| 248 |
+
except OSError as exc:
|
| 249 |
+
print(f"CanLex index: BM25 cache not written ({exc}); "
|
| 250 |
+
f"built in process.", file=sys.stderr)
|
| 251 |
+
tmp.unlink(missing_ok=True)
|
| 252 |
+
return False
|
| 253 |
+
for old in path.parent.glob(f"{_CACHE_PREFIX}*.npz"):
|
| 254 |
+
if old != path:
|
| 255 |
+
try:
|
| 256 |
+
old.unlink()
|
| 257 |
+
except OSError:
|
| 258 |
+
pass # another process may still be reading it
|
| 259 |
+
return True
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _consistent(n_chunks, n_terms, offsets, docs, tfs, doc_len, df, df_en):
|
| 263 |
+
"""Whether cached arrays can index a corpus of `n_chunks` chunks and
|
| 264 |
+
`n_terms` terms: integer arrays; offsets from 0, never decreasing, up to
|
| 265 |
+
len(docs); one tf per posting; every posting's chunk index inside the
|
| 266 |
+
corpus; one length per chunk; one df and one df_en per term.
|
| 267 |
+
Verification 2026-09-22 (SEC-3): a cache whose postings named chunks
|
| 268 |
+
999999 and -5 was accepted -- an IndexError on every search, or a
|
| 269 |
+
silently wrong ranking (-5 is the fifth-last chunk)."""
|
| 270 |
+
arrays = (offsets, docs, tfs, doc_len, df, df_en)
|
| 271 |
+
if any(a.ndim != 1 or a.dtype.kind not in "iu" for a in arrays):
|
| 272 |
+
return False
|
| 273 |
+
if (len(offsets) != n_terms + 1 or int(offsets[0]) != 0
|
| 274 |
+
or int(offsets[-1]) != len(docs) or np.any(np.diff(offsets) < 0)):
|
| 275 |
+
return False
|
| 276 |
+
if len(docs) != len(tfs) or len(doc_len) != n_chunks:
|
| 277 |
+
return False
|
| 278 |
+
if docs.size and (int(docs.min()) < 0 or int(docs.max()) >= n_chunks):
|
| 279 |
+
return False
|
| 280 |
+
return len(df) == n_terms and len(df_en) == n_terms
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def load(key, n_chunks, cache_dir=None):
|
| 284 |
+
"""The cached Bm25Data for `key`, or None when there is none, it is
|
| 285 |
+
empty, it was written for another key or corpus size, its arrays do
|
| 286 |
+
not fit together (_consistent), or it cannot be read.
|
| 287 |
+
|
| 288 |
+
A damaged file raises many types inside np.load -- EOFError on zero
|
| 289 |
+
bytes, BadZipFile on a truncated one, NotImplementedError on a flipped
|
| 290 |
+
compression field, KeyError, ValueError -- and every one of them means
|
| 291 |
+
the same: rebuild. Verification 2026-09-22 (A-CACHE-EOF): a zero-byte
|
| 292 |
+
cache raised EOFError, which the except clause did not name, so
|
| 293 |
+
LegislationIndex() and every search tool failed on each start and
|
| 294 |
+
nothing ever rewrote the file."""
|
| 295 |
+
path = cache_path(key, cache_dir)
|
| 296 |
+
if not path.exists():
|
| 297 |
+
return None
|
| 298 |
+
try:
|
| 299 |
+
if path.stat().st_size == 0:
|
| 300 |
+
print("CanLex index: BM25 cache empty (an interrupted write); "
|
| 301 |
+
"rebuilding.", file=sys.stderr)
|
| 302 |
+
return None
|
| 303 |
+
with np.load(path, allow_pickle=False) as z:
|
| 304 |
+
if z["key"].item() != key or len(z["doc_len"]) != n_chunks:
|
| 305 |
+
return None
|
| 306 |
+
terms = _split(z["terms"])
|
| 307 |
+
offsets, docs, tfs = z["offsets"], z["docs"], z["tfs"]
|
| 308 |
+
doc_len, df, df_en = z["doc_len"], z["df"], z["df_en"]
|
| 309 |
+
words = {s: (_split(z[f"words_{s}"]), z[f"counts_{s}"].tolist())
|
| 310 |
+
for s in _SCOPES}
|
| 311 |
+
scope = z["scope"].tolist()
|
| 312 |
+
if (not _consistent(n_chunks, len(terms), offsets, docs, tfs, doc_len,
|
| 313 |
+
df, df_en)
|
| 314 |
+
or len(scope) != 2
|
| 315 |
+
or any(len(w) != len(c) for w, c in words.values())):
|
| 316 |
+
print("CanLex index: BM25 cache inconsistent; rebuilding.",
|
| 317 |
+
file=sys.stderr)
|
| 318 |
+
return None
|
| 319 |
+
en_docs, en_total = scope
|
| 320 |
+
return Bm25Data(Postings(terms, offsets, docs, tfs), doc_len, df, df_en,
|
| 321 |
+
en_docs, en_total,
|
| 322 |
+
{s: Counter(dict(zip(w, c))) for s, (w, c) in words.items()})
|
| 323 |
+
except Exception as exc: # noqa: BLE001 -- any damage means rebuild
|
| 324 |
+
print(f"CanLex index: BM25 cache unreadable ({type(exc).__name__}); "
|
| 325 |
+
f"rebuilding.", file=sys.stderr)
|
| 326 |
+
return None
|
canlex/calllog.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The MCP call log (2026-09-16): one record per tool call, opt-in, best-effort.
|
| 2 |
+
|
| 3 |
+
The maintainer wants to see what people ask CanLex through the MCP tools.
|
| 4 |
+
install() wraps the FastMCP tool manager's call_tool ONCE at startup, so every
|
| 5 |
+
tool call -- whichever tool, whether it returns or raises -- yields one JSON
|
| 6 |
+
record. No tool function is touched (the review gate fingerprints those, see
|
| 7 |
+
canlex/reviewed_code.py); nothing here carries legal wording or routing.
|
| 8 |
+
|
| 9 |
+
A record holds:
|
| 10 |
+
ts UTC, ISO 8601
|
| 11 |
+
tool the tool name the client called
|
| 12 |
+
arguments the arguments exactly as the client sent them; a string over
|
| 13 |
+
MAX_STRING characters is cut and marked, and its path is listed
|
| 14 |
+
in 'truncated'
|
| 15 |
+
duration_ms, ok, output_chars
|
| 16 |
+
error the exception's TYPE NAME only (never its message or traceback)
|
| 17 |
+
error_id for a tool's own unexpected exception: the reference id the
|
| 18 |
+
client was given and the stderr traceback carries (the error
|
| 19 |
+
guard, audit follow-up 2026-09-21)
|
| 20 |
+
n_results, confidence, top_citations
|
| 21 |
+
canlex_search_legislation only (server._log_query -> note())
|
| 22 |
+
result branch / rehab_status / status / verdict / needs, read from a
|
| 23 |
+
tool's trailing CANLEX_RESULT line (the screening tools)
|
| 24 |
+
dropped_before
|
| 25 |
+
how many calls went unrecorded (queue full) before this one
|
| 26 |
+
A record never holds an IP address, a header, a user agent, a session id or
|
| 27 |
+
any auth material: the wrapper sees only the tool name and its arguments, and
|
| 28 |
+
the record is built from nothing else.
|
| 29 |
+
|
| 30 |
+
Sinks, each turned on by the environment (nothing set = no wrapper, no thread,
|
| 31 |
+
no logging, no notice -- the local stdio server is unchanged):
|
| 32 |
+
CANLEX_LOG_DIR local JSONL, appended to CANLEX_LOG_DIR/calls.jsonl
|
| 33 |
+
CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN (BOTH)
|
| 34 |
+
durable: huggingface_hub.CommitScheduler pushes
|
| 35 |
+
logs/YYYY-MM-DD/<uuid>.jsonl to that dataset repo every
|
| 36 |
+
CANLEX_LOG_EVERY_MIN minutes (default 10), and once more
|
| 37 |
+
at shutdown. The dataset must be private: one that is
|
| 38 |
+
not is never pushed to. Half-configured, a token the Hub
|
| 39 |
+
refuses, or a public dataset: one stderr warning, and
|
| 40 |
+
serving continues.
|
| 41 |
+
|
| 42 |
+
The notice: while logging is on, the server tells MCP clients so in its MCP
|
| 43 |
+
`instructions` (NOTICE, a plain statement that gives no advice about what to
|
| 44 |
+
send) and answers GET / on the HTTP transport with the same statement.
|
| 45 |
+
|
| 46 |
+
The error guard (audit follow-up 2026-09-21, missing 8): FastMCP answers a
|
| 47 |
+
tool's own exception with str(exc) ('int' object has no attribute 'lower'), and
|
| 48 |
+
the record keeps only the type, so the maintainer saw neither message nor
|
| 49 |
+
stack. The wrapper now prints the traceback to stderr under a fresh uuid, notes
|
| 50 |
+
the uuid in the record (error_id) and answers the client with one line naming
|
| 51 |
+
the exception's type and the reference. An unknown tool, arguments that failed
|
| 52 |
+
validation (the caller's own words, which it needs to see) and a cancellation
|
| 53 |
+
pass through unchanged.
|
| 54 |
+
|
| 55 |
+
Best-effort throughout: the wrapper only puts a small tuple on a bounded queue
|
| 56 |
+
(put_nowait -- on overflow the call is dropped and counted); a background
|
| 57 |
+
thread builds the records and writes them. A sink that raises or hangs cannot
|
| 58 |
+
slow, change or break a tool response; a batch a sink refuses is retried one
|
| 59 |
+
record at a time, and a sink's failures are reported on stderr once per error
|
| 60 |
+
type and then at most once every WARN_EVERY seconds. A lone UTF-16 surrogate
|
| 61 |
+
in an argument (valid JSON, not UTF-8) is recorded as its backslash escape.
|
| 62 |
+
The token is never printed or logged.
|
| 63 |
+
"""
|
| 64 |
+
import atexit
|
| 65 |
+
import contextvars
|
| 66 |
+
import datetime
|
| 67 |
+
import json
|
| 68 |
+
import os
|
| 69 |
+
import queue
|
| 70 |
+
import sys
|
| 71 |
+
import tempfile
|
| 72 |
+
import threading
|
| 73 |
+
import time
|
| 74 |
+
import traceback
|
| 75 |
+
import uuid
|
| 76 |
+
from pathlib import Path
|
| 77 |
+
|
| 78 |
+
LOG_FILE = "calls.jsonl"
|
| 79 |
+
MAX_STRING = 4000 # a longer argument string is cut and marked
|
| 80 |
+
MAX_ITEMS = 1000 # a longer argument list or object is cut and marked
|
| 81 |
+
ARGS_BUDGET = 64000 # characters of argument text one record keeps in all
|
| 82 |
+
# (the code audit of 2026-09-21: MAX_STRING and MAX_ITEMS bound each node, not the
|
| 83 |
+
# record -- a wide extra key of 1,000 4,000-character strings passed validation, since
|
| 84 |
+
# FastMCP ignores unknown top-level keys, and wrote a 4 MB line; past the budget the
|
| 85 |
+
# rest of the arguments is dropped and the path noted in `truncated`)
|
| 86 |
+
QUEUE_SIZE = 1000 # calls waiting for the writer; beyond it, dropped
|
| 87 |
+
PENDING_CAP = 10000 # dataset lines held while the scheduler starts
|
| 88 |
+
DEFAULT_EVERY_MIN = 10.0
|
| 89 |
+
PATH_IN_REPO = "logs"
|
| 90 |
+
RETRY_DELAYS = (30, 120, 600) # seconds between attempts to open the dataset
|
| 91 |
+
RESULT_MARK = "CANLEX_RESULT: "
|
| 92 |
+
RESULT_KEYS = ("branch", "rehab_status", "status", "verdict", "needs",
|
| 93 |
+
# step 1 ids (disposition roadmap T4.4, the review of batch 1): which
|
| 94 |
+
# entry answered, the footing, how the act branch stands, the wording flags
|
| 95 |
+
"disposition_entry", "entry", "footing", "act_branch", "question_flag",
|
| 96 |
+
"disposition_flags", "allegation_paragraphs")
|
| 97 |
+
SEARCH_KEYS = ("n_results", "confidence", "top_citations")
|
| 98 |
+
ERROR_KEYS = ("error_id",)
|
| 99 |
+
NEEDS_CAP = 10
|
| 100 |
+
NEED_CHARS = 500
|
| 101 |
+
WARN_EVERY = 3600.0 # seconds between repeats of one sink's same failure warning
|
| 102 |
+
NOTICE = ("Tool calls to this server, including their arguments, are logged by "
|
| 103 |
+
"the operator.")
|
| 104 |
+
|
| 105 |
+
_STOP = object()
|
| 106 |
+
_extra = contextvars.ContextVar("canlex_call_extra", default=None)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _warn(message):
|
| 110 |
+
try:
|
| 111 |
+
print(f"CanLex call log: {message}", file=sys.stderr, flush=True)
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def note(**fields):
|
| 117 |
+
"""Attach search fields (n_results, confidence, top_citations) or the
|
| 118 |
+
error guard's error_id to the call being recorded. A no-op when no call
|
| 119 |
+
is being recorded."""
|
| 120 |
+
extra = _extra.get()
|
| 121 |
+
if extra is not None:
|
| 122 |
+
extra.update((k, v) for k, v in fields.items() if k in SEARCH_KEYS + ERROR_KEYS)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def recording():
|
| 126 |
+
"""True inside a tool call the call log is recording."""
|
| 127 |
+
return _extra.get() is not None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---- building a record (writer thread) --------------------------------------
|
| 131 |
+
|
| 132 |
+
def _iso(ts):
|
| 133 |
+
return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
|
| 134 |
+
.isoformat(timespec="milliseconds").replace("+00:00", "Z"))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _cut(text, path, cut):
|
| 138 |
+
"""A string for the record: cut past MAX_STRING characters (and marked),
|
| 139 |
+
and always encodable as UTF-8 -- a lone UTF-16 surrogate, which a client
|
| 140 |
+
can send as valid JSON (a string cut inside an emoji), becomes its
|
| 141 |
+
backslash escape, so the line it is on can be written."""
|
| 142 |
+
if len(text) > MAX_STRING:
|
| 143 |
+
cut.append(path)
|
| 144 |
+
text = text[:MAX_STRING] + f" [truncated: {len(text)} chars]"
|
| 145 |
+
if not text.isascii():
|
| 146 |
+
try:
|
| 147 |
+
text.encode("utf-8")
|
| 148 |
+
except UnicodeEncodeError:
|
| 149 |
+
text = text.encode("utf-8", "backslashreplace").decode("utf-8")
|
| 150 |
+
return text
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _clean(value, path, cut, depth=0, budget=None):
|
| 154 |
+
"""A JSON-serialisable copy of an argument value: strings over MAX_STRING
|
| 155 |
+
characters and containers over MAX_ITEMS items are cut, their paths listed
|
| 156 |
+
in `cut`; anything that is not JSON is recorded as its repr. `budget` is
|
| 157 |
+
the characters the whole record may still keep (ARGS_BUDGET at the top):
|
| 158 |
+
every string, key and scalar is charged to it, and once it is spent the
|
| 159 |
+
rest is dropped -- a container stops where it stands and its path is
|
| 160 |
+
noted in `cut` once."""
|
| 161 |
+
if budget is None:
|
| 162 |
+
budget = [ARGS_BUDGET]
|
| 163 |
+
|
| 164 |
+
def spent(where):
|
| 165 |
+
if budget[0] <= 0:
|
| 166 |
+
if where not in cut:
|
| 167 |
+
cut.append(where)
|
| 168 |
+
return True
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
def charge(text):
|
| 172 |
+
# the text this record keeps, cut to what the budget still allows
|
| 173 |
+
if len(text) > budget[0]:
|
| 174 |
+
kept = text[:max(budget[0], 0)] + f" [truncated: {len(text)} chars]"
|
| 175 |
+
budget[0] = 0
|
| 176 |
+
if path not in cut:
|
| 177 |
+
cut.append(path)
|
| 178 |
+
return kept
|
| 179 |
+
budget[0] -= len(text)
|
| 180 |
+
return text
|
| 181 |
+
|
| 182 |
+
if isinstance(value, str):
|
| 183 |
+
if spent(path):
|
| 184 |
+
return "[truncated: record budget]"
|
| 185 |
+
return charge(_cut(value, path, cut))
|
| 186 |
+
if value is None or isinstance(value, (bool, int)):
|
| 187 |
+
budget[0] -= len(str(value))
|
| 188 |
+
return value
|
| 189 |
+
if isinstance(value, float):
|
| 190 |
+
budget[0] -= len(repr(value))
|
| 191 |
+
return value if value == value and abs(value) != float("inf") else repr(value)
|
| 192 |
+
if depth >= 32:
|
| 193 |
+
cut.append(path)
|
| 194 |
+
return "[nested too deep]"
|
| 195 |
+
if isinstance(value, dict):
|
| 196 |
+
items = list(value.items())
|
| 197 |
+
if len(items) > MAX_ITEMS:
|
| 198 |
+
cut.append(path)
|
| 199 |
+
items = items[:MAX_ITEMS]
|
| 200 |
+
out = {}
|
| 201 |
+
for k, v in items:
|
| 202 |
+
if spent(path):
|
| 203 |
+
break
|
| 204 |
+
key = charge(_cut(str(k), f"{path}.<key>", cut))
|
| 205 |
+
out[key] = _clean(v, f"{path}.{key[:40]}", cut, depth + 1, budget)
|
| 206 |
+
return out
|
| 207 |
+
if isinstance(value, (list, tuple)):
|
| 208 |
+
items = list(value)
|
| 209 |
+
if len(items) > MAX_ITEMS:
|
| 210 |
+
cut.append(path)
|
| 211 |
+
items = items[:MAX_ITEMS]
|
| 212 |
+
out = []
|
| 213 |
+
for i, v in enumerate(items):
|
| 214 |
+
if spent(path):
|
| 215 |
+
break
|
| 216 |
+
out.append(_clean(v, f"{path}[{i}]", cut, depth + 1, budget))
|
| 217 |
+
return out
|
| 218 |
+
if spent(path):
|
| 219 |
+
return "[truncated: record budget]"
|
| 220 |
+
return charge(_cut(repr(value), path, cut))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _output_text(result):
|
| 224 |
+
"""The text a tool call returned: FastMCP hands back a content list, a
|
| 225 |
+
(content list, structured) tuple, or a CallToolResult."""
|
| 226 |
+
if isinstance(result, tuple) and result:
|
| 227 |
+
result = result[0]
|
| 228 |
+
content = getattr(result, "content", result)
|
| 229 |
+
if isinstance(content, str):
|
| 230 |
+
return content
|
| 231 |
+
if isinstance(content, (list, tuple)):
|
| 232 |
+
return "".join(t for t in (getattr(b, "text", None) for b in content)
|
| 233 |
+
if isinstance(t, str))
|
| 234 |
+
return ""
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _result_line(text):
|
| 238 |
+
"""The JSON after the last CANLEX_RESULT marker, unparsed ('' if none)."""
|
| 239 |
+
at = text.rfind(RESULT_MARK)
|
| 240 |
+
if at < 0:
|
| 241 |
+
return ""
|
| 242 |
+
return text[at + len(RESULT_MARK):].split("\n", 1)[0]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _need(item):
|
| 246 |
+
if isinstance(item, dict):
|
| 247 |
+
item = item.get("question") or item.get("id") or item.get("parameter") or item
|
| 248 |
+
text = item if isinstance(item, str) else json.dumps(item, ensure_ascii=False,
|
| 249 |
+
default=repr)
|
| 250 |
+
return text[:NEED_CHARS]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _result_fields(line):
|
| 254 |
+
"""branch / rehab_status / status / verdict / needs from a CANLEX_RESULT
|
| 255 |
+
line, or None when there is none or it does not parse."""
|
| 256 |
+
if not line:
|
| 257 |
+
return None
|
| 258 |
+
try:
|
| 259 |
+
data = json.loads(line)
|
| 260 |
+
except ValueError:
|
| 261 |
+
return None
|
| 262 |
+
if not isinstance(data, dict):
|
| 263 |
+
return None
|
| 264 |
+
out = {}
|
| 265 |
+
for key in RESULT_KEYS:
|
| 266 |
+
if key not in data:
|
| 267 |
+
continue
|
| 268 |
+
value = data[key]
|
| 269 |
+
if key == "needs":
|
| 270 |
+
value = ([_need(n) for n in value[:NEEDS_CAP]] if isinstance(value, list)
|
| 271 |
+
else None)
|
| 272 |
+
out[key] = _clean(value, key, [])
|
| 273 |
+
return out or None
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _error_name(exc):
|
| 277 |
+
"""The raised exception's type name; FastMCP wraps a tool's own exception
|
| 278 |
+
in ToolError, so the wrapped one is named when there is one."""
|
| 279 |
+
cause = exc.__cause__
|
| 280 |
+
if type(exc).__name__ == "ToolError" and cause is not None:
|
| 281 |
+
exc = cause
|
| 282 |
+
return type(exc).__name__
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def build_record(started, tool, arguments, duration_ms, error, output_chars,
|
| 286 |
+
result_line, extra):
|
| 287 |
+
"""One call's record (see the module docstring for the fields)."""
|
| 288 |
+
cut = []
|
| 289 |
+
record = {
|
| 290 |
+
"ts": _iso(started),
|
| 291 |
+
"tool": _cut(str(tool), "tool", cut),
|
| 292 |
+
"arguments": _clean(arguments if arguments is not None else {}, "arguments", cut),
|
| 293 |
+
"duration_ms": round(duration_ms, 1),
|
| 294 |
+
"ok": error is None,
|
| 295 |
+
}
|
| 296 |
+
if error is not None:
|
| 297 |
+
record["error"] = _cut(str(error), "error", cut)
|
| 298 |
+
record["output_chars"] = output_chars
|
| 299 |
+
for key in SEARCH_KEYS + ERROR_KEYS:
|
| 300 |
+
if extra and key in extra:
|
| 301 |
+
record[key] = _clean(extra[key], key, cut)
|
| 302 |
+
fields = _result_fields(result_line)
|
| 303 |
+
if fields:
|
| 304 |
+
record["result"] = fields
|
| 305 |
+
if cut:
|
| 306 |
+
record["truncated"] = cut
|
| 307 |
+
return record
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ---- the queue and the writer thread ---------------------------------------
|
| 311 |
+
|
| 312 |
+
class CallLog:
|
| 313 |
+
"""A bounded queue of tool calls and the background thread that turns
|
| 314 |
+
them into records and hands them to the sinks."""
|
| 315 |
+
|
| 316 |
+
def __init__(self, sinks, maxsize=QUEUE_SIZE):
|
| 317 |
+
self.sinks = list(sinks)
|
| 318 |
+
self.dropped = 0 # calls not recorded because the queue was full
|
| 319 |
+
self.written = 0 # records at least one sink accepted
|
| 320 |
+
self.unwritten = 0 # records built that no sink accepted
|
| 321 |
+
self._unreported = 0 # drops not yet carried on a record
|
| 322 |
+
self._lock = threading.Lock()
|
| 323 |
+
self._warned = {} # (what, error type) -> [last reported, failures since]
|
| 324 |
+
self._closed = False
|
| 325 |
+
self._queue = queue.Queue(maxsize=maxsize)
|
| 326 |
+
self._thread = threading.Thread(target=self._run, name="canlex-call-log",
|
| 327 |
+
daemon=True)
|
| 328 |
+
self._thread.start()
|
| 329 |
+
|
| 330 |
+
def submit(self, *call):
|
| 331 |
+
"""Queue one call without ever blocking; False when it was dropped."""
|
| 332 |
+
if self._closed:
|
| 333 |
+
return False
|
| 334 |
+
try:
|
| 335 |
+
self._queue.put_nowait(call)
|
| 336 |
+
return True
|
| 337 |
+
except queue.Full:
|
| 338 |
+
with self._lock:
|
| 339 |
+
self.dropped += 1
|
| 340 |
+
self._unreported += 1
|
| 341 |
+
except Exception:
|
| 342 |
+
pass
|
| 343 |
+
return False
|
| 344 |
+
|
| 345 |
+
def _run(self):
|
| 346 |
+
while True:
|
| 347 |
+
batch, stop = [], False
|
| 348 |
+
item = self._queue.get()
|
| 349 |
+
while True:
|
| 350 |
+
if item is _STOP:
|
| 351 |
+
stop = True
|
| 352 |
+
break
|
| 353 |
+
batch.append(item)
|
| 354 |
+
if len(batch) >= 200:
|
| 355 |
+
break
|
| 356 |
+
try:
|
| 357 |
+
item = self._queue.get_nowait()
|
| 358 |
+
except queue.Empty:
|
| 359 |
+
break
|
| 360 |
+
if batch:
|
| 361 |
+
self._write(batch)
|
| 362 |
+
if stop:
|
| 363 |
+
return
|
| 364 |
+
|
| 365 |
+
def _write(self, batch):
|
| 366 |
+
records, lines = [], []
|
| 367 |
+
for call in batch:
|
| 368 |
+
try:
|
| 369 |
+
record = build_record(*call)
|
| 370 |
+
line = json.dumps(record, ensure_ascii=False)
|
| 371 |
+
except Exception as exc:
|
| 372 |
+
self._report("record", "a record could not be built", exc)
|
| 373 |
+
continue
|
| 374 |
+
with self._lock:
|
| 375 |
+
if self._unreported:
|
| 376 |
+
record["dropped_before"] = self._unreported
|
| 377 |
+
self._unreported = 0
|
| 378 |
+
line = json.dumps(record, ensure_ascii=False)
|
| 379 |
+
lines.append(line)
|
| 380 |
+
records.append(record)
|
| 381 |
+
if not records:
|
| 382 |
+
return
|
| 383 |
+
stored = [False] * len(records)
|
| 384 |
+
for sink in self.sinks:
|
| 385 |
+
try:
|
| 386 |
+
sink.write(records, lines)
|
| 387 |
+
stored = [True] * len(records)
|
| 388 |
+
continue
|
| 389 |
+
except Exception as exc:
|
| 390 |
+
self._report(id(sink), f"the {sink.name} failed to write", exc)
|
| 391 |
+
# one bad record (or a transient fault) must not cost the whole
|
| 392 |
+
# batch, other people's calls included: retry one record at a time
|
| 393 |
+
for i, (record, line) in enumerate(zip(records, lines)):
|
| 394 |
+
try:
|
| 395 |
+
sink.write([record], [line])
|
| 396 |
+
stored[i] = True
|
| 397 |
+
except Exception as exc:
|
| 398 |
+
self._report(id(sink), f"the {sink.name} failed to write", exc)
|
| 399 |
+
kept = sum(stored)
|
| 400 |
+
self.written += kept
|
| 401 |
+
self.unwritten += len(records) - kept
|
| 402 |
+
|
| 403 |
+
def _report(self, what, message, exc):
|
| 404 |
+
"""A failure on stderr, by exception type only: the first of each
|
| 405 |
+
type at once, then at most once every WARN_EVERY seconds, with how
|
| 406 |
+
many went unreported in between."""
|
| 407 |
+
key, now = (what, type(exc).__name__), time.monotonic()
|
| 408 |
+
seen = self._warned.get(key)
|
| 409 |
+
if seen is not None and now - seen[0] < WARN_EVERY:
|
| 410 |
+
seen[1] += 1
|
| 411 |
+
return
|
| 412 |
+
since = seen[1] if seen is not None else 0
|
| 413 |
+
self._warned[key] = [now, 0]
|
| 414 |
+
more = f"; {since} more since the last report" if since else ""
|
| 415 |
+
_warn(f"{message} ({type(exc).__name__}){more}; a repeat is reported at most "
|
| 416 |
+
f"every {WARN_EVERY / 60:g} min")
|
| 417 |
+
|
| 418 |
+
def close(self, timeout=5.0):
|
| 419 |
+
"""Write what is queued, then close the sinks (the dataset sink's
|
| 420 |
+
final push). Safe to call more than once; run at interpreter exit."""
|
| 421 |
+
if self._closed:
|
| 422 |
+
return
|
| 423 |
+
self._closed = True
|
| 424 |
+
try:
|
| 425 |
+
self._queue.put(_STOP, timeout=timeout)
|
| 426 |
+
self._thread.join(timeout)
|
| 427 |
+
except Exception:
|
| 428 |
+
pass
|
| 429 |
+
for sink in self.sinks:
|
| 430 |
+
try:
|
| 431 |
+
sink.close()
|
| 432 |
+
except Exception:
|
| 433 |
+
pass
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ---- sinks ------------------------------------------------------------------
|
| 437 |
+
|
| 438 |
+
class LocalSink:
|
| 439 |
+
"""Appends each record to CANLEX_LOG_DIR/calls.jsonl."""
|
| 440 |
+
name = "local call log"
|
| 441 |
+
|
| 442 |
+
def __init__(self, directory):
|
| 443 |
+
self.path = Path(directory) / LOG_FILE
|
| 444 |
+
|
| 445 |
+
def write(self, records, lines):
|
| 446 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 447 |
+
with self.path.open("a", encoding="utf-8") as fh:
|
| 448 |
+
fh.write("".join(line + "\n" for line in lines))
|
| 449 |
+
|
| 450 |
+
def close(self):
|
| 451 |
+
pass
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _status(exc):
|
| 455 |
+
return getattr(getattr(exc, "response", None), "status_code", None)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def _describe(exc):
|
| 459 |
+
"""An exception's type name and HTTP status -- never its message, which
|
| 460 |
+
could echo a request."""
|
| 461 |
+
status = _status(exc)
|
| 462 |
+
return type(exc).__name__ + (f", HTTP {status}" if status else "")
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
class DatasetSink:
|
| 466 |
+
"""Pushes the records to a private Hugging Face dataset repo with
|
| 467 |
+
huggingface_hub.CommitScheduler: this process writes
|
| 468 |
+
<folder>/YYYY-MM-DD/<uuid>.jsonl, the scheduler uploads the folder to
|
| 469 |
+
logs/ every `every` minutes. The scheduler is opened on a background
|
| 470 |
+
thread (it reaches the Hub), so startup never waits on the network; lines
|
| 471 |
+
written before it is up are held in memory (up to PENDING_CAP)."""
|
| 472 |
+
name = "dataset call log"
|
| 473 |
+
|
| 474 |
+
def __init__(self, repo_id, token, every=DEFAULT_EVERY_MIN, folder=None):
|
| 475 |
+
self.repo_id = repo_id
|
| 476 |
+
self.every = every
|
| 477 |
+
self._token = token
|
| 478 |
+
self.file_id = uuid.uuid4().hex
|
| 479 |
+
self.folder = (Path(folder) if folder else
|
| 480 |
+
Path(tempfile.gettempdir()) / "canlex-call-log" / self.file_id)
|
| 481 |
+
self.scheduler = None
|
| 482 |
+
self.failed = False
|
| 483 |
+
self.ready = threading.Event() # set once the scheduler is up or has failed
|
| 484 |
+
self._pending = []
|
| 485 |
+
self._lock = threading.Lock()
|
| 486 |
+
self._stop = threading.Event()
|
| 487 |
+
self._closed = False
|
| 488 |
+
self._thread = None
|
| 489 |
+
|
| 490 |
+
def __repr__(self):
|
| 491 |
+
return f"<DatasetSink {self.repo_id}>"
|
| 492 |
+
|
| 493 |
+
def start(self):
|
| 494 |
+
"""Open the scheduler on a background thread. Under HF_HUB_OFFLINE the
|
| 495 |
+
Hub client is replaced first, here, before the server takes requests
|
| 496 |
+
(see allow_dataset_requests), so a model load can never be using the
|
| 497 |
+
client that set_client_factory closes."""
|
| 498 |
+
if self._thread is not None:
|
| 499 |
+
return
|
| 500 |
+
try:
|
| 501 |
+
from huggingface_hub import constants
|
| 502 |
+
if constants.is_offline_mode():
|
| 503 |
+
allow_dataset_requests(self.repo_id)
|
| 504 |
+
except Exception:
|
| 505 |
+
pass # the scheduler's first request then fails and says so
|
| 506 |
+
self._thread = threading.Thread(target=self._open, name="canlex-call-log-hub",
|
| 507 |
+
daemon=True)
|
| 508 |
+
self._thread.start()
|
| 509 |
+
|
| 510 |
+
def _open(self):
|
| 511 |
+
last, public = None, False
|
| 512 |
+
for delay in (0,) + tuple(RETRY_DELAYS):
|
| 513 |
+
if delay and self._stop.wait(delay):
|
| 514 |
+
break
|
| 515 |
+
scheduler = None
|
| 516 |
+
try:
|
| 517 |
+
from huggingface_hub import CommitScheduler
|
| 518 |
+
scheduler = CommitScheduler(
|
| 519 |
+
repo_id=self.repo_id, repo_type="dataset", folder_path=self.folder,
|
| 520 |
+
path_in_repo=PATH_IN_REPO, every=self.every, private=True,
|
| 521 |
+
token=self._token, allow_patterns=["*.jsonl"])
|
| 522 |
+
# private=True only applies when the scheduler CREATES the repo;
|
| 523 |
+
# an existing public dataset would publish every record. Checked
|
| 524 |
+
# before any record reaches the scheduler's folder.
|
| 525 |
+
info = scheduler.api.repo_info(self.repo_id, repo_type="dataset")
|
| 526 |
+
public = getattr(info, "private", None) is not True
|
| 527 |
+
except Exception as exc:
|
| 528 |
+
self._halt(scheduler)
|
| 529 |
+
last = exc
|
| 530 |
+
status = _status(exc)
|
| 531 |
+
retry = (not isinstance(exc, (ImportError, ValueError, TypeError))
|
| 532 |
+
and (status is None or status == 429 or status >= 500))
|
| 533 |
+
if not retry:
|
| 534 |
+
break
|
| 535 |
+
continue
|
| 536 |
+
if public:
|
| 537 |
+
self._halt(scheduler)
|
| 538 |
+
break
|
| 539 |
+
with self._lock:
|
| 540 |
+
self.scheduler = scheduler
|
| 541 |
+
pending, self._pending = self._pending, []
|
| 542 |
+
self._append(pending)
|
| 543 |
+
_warn(f"the dataset call log is open: {self.repo_id!r} (private); records "
|
| 544 |
+
f"are pushed every {self.every:g} min")
|
| 545 |
+
self.ready.set()
|
| 546 |
+
return
|
| 547 |
+
self.failed = True
|
| 548 |
+
with self._lock:
|
| 549 |
+
self._pending = []
|
| 550 |
+
if public:
|
| 551 |
+
_warn(f"the durable call log is off: the dataset {self.repo_id!r} is not "
|
| 552 |
+
"private, so nothing is pushed to it -- make it private in its "
|
| 553 |
+
"settings and restart; tool calls are still served")
|
| 554 |
+
else:
|
| 555 |
+
hint = (" -- check CANLEX_LOG_TOKEN (a fine-grained token with read and write "
|
| 556 |
+
"access to that dataset) and CANLEX_LOG_DATASET") if last is not None else ""
|
| 557 |
+
_warn(f"the durable call log is off: could not open the dataset "
|
| 558 |
+
f"{self.repo_id!r} ({_describe(last) if last else 'stopped'}){hint}; "
|
| 559 |
+
"tool calls are still served")
|
| 560 |
+
self.ready.set()
|
| 561 |
+
|
| 562 |
+
@staticmethod
|
| 563 |
+
def _halt(scheduler):
|
| 564 |
+
"""Stop a scheduler that must not push (its folder is still empty)."""
|
| 565 |
+
if scheduler is None:
|
| 566 |
+
return
|
| 567 |
+
try:
|
| 568 |
+
scheduler.stop()
|
| 569 |
+
except Exception:
|
| 570 |
+
pass
|
| 571 |
+
|
| 572 |
+
def write(self, records, lines):
|
| 573 |
+
if self.failed or self._closed:
|
| 574 |
+
return
|
| 575 |
+
items = [(r["ts"][:10], line) for r, line in zip(records, lines)]
|
| 576 |
+
with self._lock:
|
| 577 |
+
if self.scheduler is None:
|
| 578 |
+
room = max(PENDING_CAP - len(self._pending), 0)
|
| 579 |
+
self._pending.extend(items[:room])
|
| 580 |
+
return
|
| 581 |
+
self._append(items)
|
| 582 |
+
|
| 583 |
+
def _append(self, items):
|
| 584 |
+
if not items:
|
| 585 |
+
return
|
| 586 |
+
by_day = {}
|
| 587 |
+
for day, line in items:
|
| 588 |
+
by_day.setdefault(day, []).append(line)
|
| 589 |
+
with self.scheduler.lock:
|
| 590 |
+
for day, day_lines in by_day.items():
|
| 591 |
+
path = self.folder / day / f"{self.file_id}.jsonl"
|
| 592 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 593 |
+
with path.open("a", encoding="utf-8") as fh:
|
| 594 |
+
fh.write("".join(line + "\n" for line in day_lines))
|
| 595 |
+
|
| 596 |
+
def close(self):
|
| 597 |
+
"""The final push at shutdown (best-effort)."""
|
| 598 |
+
if self._closed:
|
| 599 |
+
return
|
| 600 |
+
self._closed = True
|
| 601 |
+
self._stop.set()
|
| 602 |
+
scheduler = self.scheduler
|
| 603 |
+
if scheduler is None:
|
| 604 |
+
return
|
| 605 |
+
try:
|
| 606 |
+
scheduler.push_to_hub()
|
| 607 |
+
except Exception as exc:
|
| 608 |
+
_warn(f"the final push to {self.repo_id!r} failed ({_describe(exc)})")
|
| 609 |
+
try:
|
| 610 |
+
scheduler.stop()
|
| 611 |
+
except Exception:
|
| 612 |
+
pass
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def allow_dataset_requests(repo_id):
|
| 616 |
+
"""Let the call log reach its dataset under HF_HUB_OFFLINE=1.
|
| 617 |
+
|
| 618 |
+
The Space's Dockerfile sets HF_HUB_OFFLINE=1 so the embedder and reranker
|
| 619 |
+
are only ever served from the model cache baked into the image. That flag
|
| 620 |
+
blocks EVERY huggingface_hub request, the scheduler's pushes included, in
|
| 621 |
+
the library's request hook. This installs a client (huggingface_hub's
|
| 622 |
+
public set_client_factory; otherwise the library default) whose hook
|
| 623 |
+
still refuses every Hub request EXCEPT repo creation and requests for
|
| 624 |
+
this dataset repo. Requests to other hosts pass: they are storage URLs a
|
| 625 |
+
Hub response handed out, and a model download cannot get one, because its
|
| 626 |
+
first request (to the Hub's /resolve/ path) is still refused."""
|
| 627 |
+
import httpx
|
| 628 |
+
from huggingface_hub import constants, set_client_factory
|
| 629 |
+
from huggingface_hub.errors import OfflineModeIsEnabled
|
| 630 |
+
|
| 631 |
+
hub_host = httpx.URL(constants.ENDPOINT).host
|
| 632 |
+
repo = repo_id.strip("/").lower()
|
| 633 |
+
exact = {f"/api/datasets/{repo}", "/api/repos/create"}
|
| 634 |
+
prefixes = (f"/api/datasets/{repo}/", f"/datasets/{repo}/", f"/datasets/{repo}.git/")
|
| 635 |
+
|
| 636 |
+
def allowed(url):
|
| 637 |
+
if url.host != hub_host:
|
| 638 |
+
return True
|
| 639 |
+
path = url.path.lower()
|
| 640 |
+
return path in exact or path.startswith(prefixes)
|
| 641 |
+
|
| 642 |
+
def hook(request):
|
| 643 |
+
if constants.is_offline_mode() and not allowed(request.url):
|
| 644 |
+
raise OfflineModeIsEnabled(
|
| 645 |
+
f"Cannot reach {request.url}: offline mode is enabled. To disable it, "
|
| 646 |
+
"please unset the `HF_HUB_OFFLINE` environment variable.")
|
| 647 |
+
if "X-Amzn-Trace-Id" not in request.headers:
|
| 648 |
+
request.headers["X-Amzn-Trace-Id"] = (request.headers.get("x-request-id")
|
| 649 |
+
or str(uuid.uuid4()))
|
| 650 |
+
|
| 651 |
+
def factory():
|
| 652 |
+
return httpx.Client(event_hooks={"request": [hook]}, follow_redirects=True,
|
| 653 |
+
timeout=None)
|
| 654 |
+
|
| 655 |
+
set_client_factory(factory)
|
| 656 |
+
return factory
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
# ---- configuration and the wrapper -----------------------------------------
|
| 660 |
+
|
| 661 |
+
def _every(env):
|
| 662 |
+
raw = (env.get("CANLEX_LOG_EVERY_MIN") or "").strip()
|
| 663 |
+
if not raw:
|
| 664 |
+
return DEFAULT_EVERY_MIN
|
| 665 |
+
try:
|
| 666 |
+
value = float(raw)
|
| 667 |
+
if value > 0 and value == value and value != float("inf"):
|
| 668 |
+
return value
|
| 669 |
+
except ValueError:
|
| 670 |
+
pass
|
| 671 |
+
_warn(f"CANLEX_LOG_EVERY_MIN={raw!r} is not a positive number of minutes; "
|
| 672 |
+
f"using {DEFAULT_EVERY_MIN:g}")
|
| 673 |
+
return DEFAULT_EVERY_MIN
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def configure(environ=None, dataset_folder=None):
|
| 677 |
+
"""The sinks the environment turns on ([] = logging off). A dataset sink
|
| 678 |
+
needs BOTH CANLEX_LOG_DATASET and CANLEX_LOG_TOKEN; one without the other
|
| 679 |
+
gets one stderr warning (which never includes the token)."""
|
| 680 |
+
env = os.environ if environ is None else environ
|
| 681 |
+
sinks = []
|
| 682 |
+
log_dir = (env.get("CANLEX_LOG_DIR") or "").strip()
|
| 683 |
+
if log_dir:
|
| 684 |
+
sinks.append(LocalSink(log_dir))
|
| 685 |
+
dataset = (env.get("CANLEX_LOG_DATASET") or "").strip()
|
| 686 |
+
token = (env.get("CANLEX_LOG_TOKEN") or "").strip()
|
| 687 |
+
if dataset and token:
|
| 688 |
+
sinks.append(DatasetSink(dataset, token, _every(env), folder=dataset_folder))
|
| 689 |
+
elif dataset:
|
| 690 |
+
_warn("CANLEX_LOG_DATASET is set but CANLEX_LOG_TOKEN is not: the durable call "
|
| 691 |
+
"log is off; tool calls are still served")
|
| 692 |
+
elif token:
|
| 693 |
+
_warn("CANLEX_LOG_TOKEN is set but CANLEX_LOG_DATASET is not: the durable call "
|
| 694 |
+
"log is off; tool calls are still served")
|
| 695 |
+
return sinks
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def _guard(name, exc):
|
| 699 |
+
"""The ToolError the client gets instead of `exc`, or None to re-raise
|
| 700 |
+
`exc` unchanged (the error guard, audit follow-up 2026-09-21). Only a
|
| 701 |
+
tool's own exception is guarded -- FastMCP's ToolError whose cause is an
|
| 702 |
+
Exception other than a pydantic ValidationError: its traceback goes to
|
| 703 |
+
stderr under a fresh uuid, the uuid goes into the record (note), and the
|
| 704 |
+
client reads 'Error: CanLex could not complete this call (<type>);
|
| 705 |
+
reference <uuid>'. Never raises itself."""
|
| 706 |
+
try:
|
| 707 |
+
from mcp.server.fastmcp.exceptions import ToolError
|
| 708 |
+
from pydantic import ValidationError
|
| 709 |
+
cause = exc.__cause__
|
| 710 |
+
if (not isinstance(exc, ToolError) or not isinstance(cause, Exception)
|
| 711 |
+
or isinstance(cause, ValidationError)):
|
| 712 |
+
return None
|
| 713 |
+
ref = str(uuid.uuid4())
|
| 714 |
+
stack = "".join(traceback.format_exception(type(cause), cause, cause.__traceback__))
|
| 715 |
+
print(f"CanLex MCP: tool {_cut(str(name), 'tool', [])} failed; reference {ref}\n"
|
| 716 |
+
f"{stack}", file=sys.stderr, flush=True)
|
| 717 |
+
note(error_id=ref)
|
| 718 |
+
return ToolError(f"Error: CanLex could not complete this call "
|
| 719 |
+
f"({type(cause).__name__}); reference {ref}")
|
| 720 |
+
except Exception:
|
| 721 |
+
return None
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def wrap(manager, log):
|
| 725 |
+
"""Wrap a FastMCP ToolManager's call_tool (mcp 1.27.1: FastMCP.call_tool ->
|
| 726 |
+
ToolManager.call_tool(name, arguments, context=, convert_result=) ->
|
| 727 |
+
Tool.run) so every call is submitted to `log`, and a tool's own exception
|
| 728 |
+
reaches the client as a reference id (_guard). Once per manager."""
|
| 729 |
+
if getattr(manager, "_canlex_call_log", None) is not None:
|
| 730 |
+
return manager
|
| 731 |
+
original = manager.call_tool
|
| 732 |
+
|
| 733 |
+
async def call_tool(name, arguments, context=None, convert_result=False):
|
| 734 |
+
started, clock = time.time(), time.perf_counter()
|
| 735 |
+
extra = {}
|
| 736 |
+
context_token = _extra.set(extra)
|
| 737 |
+
error, result = None, None
|
| 738 |
+
try:
|
| 739 |
+
result = await original(name, arguments, context=context,
|
| 740 |
+
convert_result=convert_result)
|
| 741 |
+
return result
|
| 742 |
+
except Exception as exc:
|
| 743 |
+
error = exc
|
| 744 |
+
guarded = _guard(name, exc)
|
| 745 |
+
if guarded is None:
|
| 746 |
+
raise
|
| 747 |
+
raise guarded from exc
|
| 748 |
+
except BaseException as exc:
|
| 749 |
+
error = exc
|
| 750 |
+
raise
|
| 751 |
+
finally:
|
| 752 |
+
try:
|
| 753 |
+
_extra.reset(context_token)
|
| 754 |
+
except Exception:
|
| 755 |
+
pass
|
| 756 |
+
try:
|
| 757 |
+
duration = (time.perf_counter() - clock) * 1000
|
| 758 |
+
if error is None:
|
| 759 |
+
text = _output_text(result)
|
| 760 |
+
log.submit(started, name, arguments, duration, None, len(text),
|
| 761 |
+
_result_line(text), extra)
|
| 762 |
+
else:
|
| 763 |
+
log.submit(started, name, arguments, duration, _error_name(error), 0,
|
| 764 |
+
"", extra)
|
| 765 |
+
except Exception:
|
| 766 |
+
pass
|
| 767 |
+
|
| 768 |
+
manager.call_tool = call_tool
|
| 769 |
+
manager._canlex_call_log = log
|
| 770 |
+
return manager
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
def announce(server):
|
| 774 |
+
"""Tell the server's users that calls are logged: NOTICE in the MCP
|
| 775 |
+
`instructions` every client receives at initialize (after any existing
|
| 776 |
+
instructions), and a plain-text GET / on the HTTP transport, where the
|
| 777 |
+
Space page's frame otherwise shows a 404. A statement of fact only -- it
|
| 778 |
+
gives no advice about which arguments to send (see RUNBOOK.md, "Call
|
| 779 |
+
log"). Best-effort: a failure is one stderr line."""
|
| 780 |
+
try:
|
| 781 |
+
low = server._mcp_server
|
| 782 |
+
current = low.instructions
|
| 783 |
+
if not current:
|
| 784 |
+
low.instructions = NOTICE
|
| 785 |
+
elif NOTICE not in current:
|
| 786 |
+
low.instructions = f"{current}\n\n{NOTICE}"
|
| 787 |
+
except Exception as exc:
|
| 788 |
+
_warn(f"the notice could not be added to the MCP instructions "
|
| 789 |
+
f"({type(exc).__name__})")
|
| 790 |
+
try:
|
| 791 |
+
if any(getattr(r, "path", None) == "/" for r in server._custom_starlette_routes):
|
| 792 |
+
return
|
| 793 |
+
from starlette.responses import PlainTextResponse
|
| 794 |
+
endpoint = getattr(getattr(server, "settings", None), "streamable_http_path", "/mcp")
|
| 795 |
+
page = (f"CanLex MCP server: connect an MCP client to {endpoint} on this "
|
| 796 |
+
f"host.\n\n{NOTICE}\n")
|
| 797 |
+
|
| 798 |
+
async def root(request):
|
| 799 |
+
return PlainTextResponse(page)
|
| 800 |
+
|
| 801 |
+
server.custom_route("/", methods=["GET"])(root)
|
| 802 |
+
except Exception as exc:
|
| 803 |
+
_warn(f"the notice page at / could not be added ({type(exc).__name__})")
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def install(server, environ=None, dataset_folder=None):
|
| 807 |
+
"""Turn the call log on for a FastMCP server when the environment asks
|
| 808 |
+
for it: wrap its tool manager, publish the notice (announce), start the
|
| 809 |
+
sinks, and write what is queued (and push) at interpreter exit. Returns
|
| 810 |
+
the CallLog, or None when nothing is configured -- then nothing is
|
| 811 |
+
wrapped, no notice is added and no thread starts."""
|
| 812 |
+
existing = getattr(server._tool_manager, "_canlex_call_log", None)
|
| 813 |
+
if existing is not None:
|
| 814 |
+
return existing
|
| 815 |
+
sinks = configure(environ, dataset_folder=dataset_folder)
|
| 816 |
+
if not sinks:
|
| 817 |
+
return None
|
| 818 |
+
log = CallLog(sinks)
|
| 819 |
+
wrap(server._tool_manager, log)
|
| 820 |
+
announce(server)
|
| 821 |
+
atexit.register(log.close)
|
| 822 |
+
# printed before any sink starts, so it always comes before the dataset
|
| 823 |
+
# sink's own "is open" or "is off" line
|
| 824 |
+
names = ", ".join(s.name + (f" ({s.repo_id}, every {s.every:g} min; opening in "
|
| 825 |
+
"the background)"
|
| 826 |
+
if isinstance(s, DatasetSink) else f" ({s.path})")
|
| 827 |
+
for s in sinks)
|
| 828 |
+
_warn(f"recording every tool call to: {names}")
|
| 829 |
+
for sink in sinks:
|
| 830 |
+
start = getattr(sink, "start", None)
|
| 831 |
+
if start is not None:
|
| 832 |
+
start()
|
| 833 |
+
return log
|
canlex/caselaw.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/charter.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, force=False):
|
| 104 |
+
html = fetch_cached(URL, RAW / "FullText.html",
|
| 105 |
+
force=force).decode("utf-8", "replace")
|
| 106 |
+
chunks = parse(html)
|
| 107 |
+
# indent=2 is what charter.json already carries -- changing it would
|
| 108 |
+
# rewrite every line of the file for nothing.
|
| 109 |
+
if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
|
| 110 |
+
return False
|
| 111 |
+
print(f"charter: {len(chunks)} sections -> {OUT_FILE.name}")
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main():
|
| 116 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
|
| 117 |
+
force="--force" in sys.argv) else 1)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
main()
|
canlex/citator.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Build-time only (roadmap T5.5): `python -m canlex.citator --snapshot-authorities`
|
| 8 |
+
records, for each curated authority (data/curated/authorities.json), how many
|
| 9 |
+
decisions cite it on CanLII and the three most recent FC/FCA/SCC ones. It
|
| 10 |
+
reads the key only when the maintainer runs it, and writes
|
| 11 |
+
data/citator_authorities.json, which .gitignore keeps out of the repo and the
|
| 12 |
+
Space. See snapshot_authorities.
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
import urllib.error
|
| 20 |
+
import urllib.request
|
| 21 |
+
from datetime import date
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from .config import ROOT, DATA_DIR
|
| 25 |
+
|
| 26 |
+
API = "https://api.canlii.org/v1"
|
| 27 |
+
KEY_FILE = ROOT / "canlii_key.txt"
|
| 28 |
+
_PLACEHOLDER = "PASTE-YOUR-CANLII-API-KEY-ON-THIS-LINE"
|
| 29 |
+
_DBMAP_FILE = DATA_DIR / "citator_dbmap.json"
|
| 30 |
+
_CACHE_FILE = DATA_DIR / "citator_cache.json"
|
| 31 |
+
_THROTTLE = 3.0 # seconds between CanLII API calls (the API rate-limits hard)
|
| 32 |
+
_MAX_LIST = 20 # items shown per citator list (lists can run to thousands)
|
| 33 |
+
_CASE_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/doc/\d+/([a-z0-9-]+)")
|
| 34 |
+
_DB_URL = re.compile(r"canlii\.org/[a-z]{2}/[a-z]+/([a-z0-9-]+)/?$")
|
| 35 |
+
|
| 36 |
+
# A neutral citation, e.g. "2019 SCC 65", and the CanLII URL segment per
|
| 37 |
+
# court/tribunal. The tribunal entries cover the boards whose decisions the
|
| 38 |
+
# corpus itself carries, so the natural loop (search result -> canlex_case)
|
| 39 |
+
# works for every citation a result can display.
|
| 40 |
+
# 'FCT' is the Federal Court's neutral citation for 2001-2003 ('2002 FCT
|
| 41 |
+
# 123'); CanLII files those decisions under 'fct' with that document id.
|
| 42 |
+
_NEUTRAL = re.compile(
|
| 43 |
+
r"\b(\d{4})\s+(SCC|FCA|FCT|FC|FPSLREB|CIRB|ONCA|ABCA)\s+(\d+)\b",
|
| 44 |
+
re.IGNORECASE)
|
| 45 |
+
_CANLII_SEG = {"scc": "scc", "fca": "fca", "fc": "fct", "fct": "fct",
|
| 46 |
+
"fpslreb": "pslreb", "cirb": "cirb",
|
| 47 |
+
"onca": "onca", "abca": "abca"}
|
| 48 |
+
_PROVINCE_SEG = {"onca": "on", "abca": "ab"} # others are federal ('ca')
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def canlii_url_from_citation(text):
|
| 52 |
+
"""Build a canlii.org case URL from a neutral citation, or '' if none found.
|
| 53 |
+
|
| 54 |
+
Works for the Supreme Court, Federal Court of Appeal, Federal Court, the
|
| 55 |
+
FPSLREB and CIRB labour boards, and the Ontario and Alberta Courts of
|
| 56 |
+
Appeal -- e.g. "2019 SCC 65" -> .../en/ca/scc/doc/2019/2019scc65/...
|
| 57 |
+
"""
|
| 58 |
+
m = _NEUTRAL.search(text)
|
| 59 |
+
if not m:
|
| 60 |
+
return ""
|
| 61 |
+
year, court, num = m.group(1), m.group(2).lower(), m.group(3)
|
| 62 |
+
doc = f"{year}{court}{num}"
|
| 63 |
+
juris = _PROVINCE_SEG.get(court, "ca")
|
| 64 |
+
return (f"https://www.canlii.org/en/{juris}/{_CANLII_SEG[court]}/doc/"
|
| 65 |
+
f"{year}/{doc}/{doc}.html")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def api_key():
|
| 69 |
+
"""Return the configured CanLII API key, or '' if not set.
|
| 70 |
+
|
| 71 |
+
The CANLII_API_KEY environment variable is checked first -- the remote
|
| 72 |
+
deployment injects the key as a secret rather than shipping the file.
|
| 73 |
+
A local run falls back to canlii_key.txt.
|
| 74 |
+
"""
|
| 75 |
+
env_key = os.environ.get("CANLII_API_KEY", "").strip()
|
| 76 |
+
if env_key and env_key != _PLACEHOLDER:
|
| 77 |
+
return env_key
|
| 78 |
+
if not KEY_FILE.exists():
|
| 79 |
+
return ""
|
| 80 |
+
key = KEY_FILE.read_text(encoding="utf-8").strip()
|
| 81 |
+
return "" if not key or key == _PLACEHOLDER else key
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _load_json(path):
|
| 85 |
+
try:
|
| 86 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 87 |
+
except Exception:
|
| 88 |
+
return {}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Citator:
|
| 92 |
+
"""Live CanLII citator with on-disk caching and rate-limit throttling."""
|
| 93 |
+
|
| 94 |
+
def __init__(self):
|
| 95 |
+
self.key = api_key()
|
| 96 |
+
if not self.key:
|
| 97 |
+
raise RuntimeError(f"No CanLII API key -- put your key in {KEY_FILE}.")
|
| 98 |
+
self._dbmap = _load_json(_DBMAP_FILE) # URL court segment -> databaseId
|
| 99 |
+
self._cache = _load_json(_CACHE_FILE) # case URL -> report
|
| 100 |
+
|
| 101 |
+
def _get(self, path):
|
| 102 |
+
"""Throttled GET against the CanLII API, retrying on HTTP 429."""
|
| 103 |
+
for attempt in range(3):
|
| 104 |
+
time.sleep(_THROTTLE if attempt == 0 else 15.0)
|
| 105 |
+
sep = "&" if "?" in path else "?"
|
| 106 |
+
url = f"{API}/{path}{sep}api_key={self.key}"
|
| 107 |
+
try:
|
| 108 |
+
with urllib.request.urlopen(url, timeout=45) as resp:
|
| 109 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 110 |
+
except urllib.error.HTTPError as exc:
|
| 111 |
+
if exc.code == 429 and attempt < 2:
|
| 112 |
+
continue
|
| 113 |
+
raise
|
| 114 |
+
raise RuntimeError("CanLII API rate limit reached; retry shortly.")
|
| 115 |
+
|
| 116 |
+
def _ensure_dbmap(self):
|
| 117 |
+
if self._dbmap:
|
| 118 |
+
return
|
| 119 |
+
data = self._get("caseBrowse/en/")
|
| 120 |
+
dbmap = {}
|
| 121 |
+
for db in data.get("caseDatabases", []):
|
| 122 |
+
match = _DB_URL.search(db.get("url") or "")
|
| 123 |
+
if match and db.get("databaseId"):
|
| 124 |
+
dbmap[match.group(1)] = db["databaseId"]
|
| 125 |
+
self._dbmap = dbmap
|
| 126 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 127 |
+
_DBMAP_FILE.write_text(json.dumps(dbmap), encoding="utf-8")
|
| 128 |
+
|
| 129 |
+
def _citator(self, db, case_id, kind):
|
| 130 |
+
data = self._get(f"caseCitator/en/{db}/{case_id}/{kind}")
|
| 131 |
+
items = data.get(kind, [])
|
| 132 |
+
return {"total": len(items), "items": items[:_MAX_LIST]}
|
| 133 |
+
|
| 134 |
+
def case_report(self, case_url):
|
| 135 |
+
"""Return a citation-graph report for a case.
|
| 136 |
+
|
| 137 |
+
Accepts a full canlii.org case URL, or a neutral citation (e.g.
|
| 138 |
+
"2019 SCC 65") for a Supreme Court / Federal Court of Appeal / Federal
|
| 139 |
+
Court decision.
|
| 140 |
+
"""
|
| 141 |
+
if not _CASE_URL.search(case_url):
|
| 142 |
+
case_url = canlii_url_from_citation(case_url) or case_url
|
| 143 |
+
if case_url in self._cache:
|
| 144 |
+
return self._cache[case_url]
|
| 145 |
+
match = _CASE_URL.search(case_url)
|
| 146 |
+
if not match:
|
| 147 |
+
return {"error": "Provide a full canlii.org case URL, or a neutral "
|
| 148 |
+
"citation such as '2019 SCC 65' (Supreme Court, Federal "
|
| 149 |
+
"Court of Appeal, or Federal Court)."}
|
| 150 |
+
self._ensure_dbmap()
|
| 151 |
+
segment, case_id = match.group(1), match.group(2)
|
| 152 |
+
db = self._dbmap.get(segment)
|
| 153 |
+
if not db:
|
| 154 |
+
return {"error": f"Unrecognized CanLII court segment '{segment}'."}
|
| 155 |
+
report = {
|
| 156 |
+
"meta": self._get(f"caseBrowse/en/{db}/{case_id}/"),
|
| 157 |
+
"citingCases": self._citator(db, case_id, "citingCases"),
|
| 158 |
+
"citedCases": self._citator(db, case_id, "citedCases"),
|
| 159 |
+
"citedLegislations": self._citator(db, case_id, "citedLegislations"),
|
| 160 |
+
}
|
| 161 |
+
self._cache[case_url] = report
|
| 162 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 163 |
+
_CACHE_FILE.write_text(json.dumps(self._cache, ensure_ascii=False),
|
| 164 |
+
encoding="utf-8")
|
| 165 |
+
return report
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def citing_cases(self, case_url):
|
| 169 |
+
"""Every CanLII citingCases item for a canlii.org case URL, uncapped
|
| 170 |
+
(case_report keeps _MAX_LIST), or None when the URL is not a case
|
| 171 |
+
URL or its court is not in the database map. Not cached."""
|
| 172 |
+
match = _CASE_URL.search(case_url or "")
|
| 173 |
+
if not match:
|
| 174 |
+
return None
|
| 175 |
+
self._ensure_dbmap()
|
| 176 |
+
db = self._dbmap.get(match.group(1))
|
| 177 |
+
if not db:
|
| 178 |
+
return None
|
| 179 |
+
data = self._get(f"caseCitator/en/{db}/{match.group(2)}/citingCases")
|
| 180 |
+
return data.get("citingCases", [])
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --- Build-time authority snapshot (roadmap T5.5) ----------------------------
|
| 184 |
+
# canlex_case needs a key the Space may not have. So the maintainer runs,
|
| 185 |
+
# locally and with his own key,
|
| 186 |
+
# python -m canlex.citator --snapshot-authorities
|
| 187 |
+
# which records, for every curated authority with a CanLII case URL, how
|
| 188 |
+
# many decisions cite it on CanLII and the three most recent FC/FCA/SCC
|
| 189 |
+
# ones. The file is data/citator_authorities.json, which .gitignore keeps
|
| 190 |
+
# out of the repo and the Space: whether derived CanLII API data may ship
|
| 191 |
+
# is the maintainer's open question. A citation count is not a treatment
|
| 192 |
+
# check -- it cannot say whether a later court followed, distinguished or
|
| 193 |
+
# reversed the authority, and it would not have caught the reversal of the
|
| 194 |
+
# trial-level Saini decision -- so the registry's manual subsequent_history
|
| 195 |
+
# stays the only 'reversed' signal.
|
| 196 |
+
SNAPSHOT_FILE = DATA_DIR / "citator_authorities.json"
|
| 197 |
+
SNAPSHOT_AGING_DAYS = 180
|
| 198 |
+
_APEX_COURTS = ("SCC", "FCA", "FC", "FCT")
|
| 199 |
+
_REGISTRY_FILE = DATA_DIR / "curated" / "authorities.json"
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def authority_case_url(row):
|
| 203 |
+
"""The CanLII case URL for an authority-registry row: its source_url
|
| 204 |
+
when that is a canlii.org case URL, else one built from a neutral
|
| 205 |
+
citation in its citation, decision key or cites; '' when it has none
|
| 206 |
+
(pre-2001 reports held on Lexum, secondary-only rows)."""
|
| 207 |
+
url = row.get("source_url") or ""
|
| 208 |
+
if _CASE_URL.search(url):
|
| 209 |
+
return url
|
| 210 |
+
texts = [row.get("citation"), (row.get("decision_key") or {}).get("act_code")]
|
| 211 |
+
for text in texts + list(row.get("cites") or []):
|
| 212 |
+
built = canlii_url_from_citation(text or "")
|
| 213 |
+
if built:
|
| 214 |
+
return built
|
| 215 |
+
return ""
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _neutral_order(citation):
|
| 219 |
+
m = _NEUTRAL.search(citation or "")
|
| 220 |
+
return (int(m.group(1)), int(m.group(3))) if m else (0, 0)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def apex_citing(items):
|
| 224 |
+
"""The FC, FCA and SCC decisions among CanLII citingCases items, by
|
| 225 |
+
their neutral citation, most recent first (year, then number)."""
|
| 226 |
+
out = []
|
| 227 |
+
for item in items or []:
|
| 228 |
+
cite = item.get("citation") or ""
|
| 229 |
+
m = _NEUTRAL.search(cite)
|
| 230 |
+
if m and m.group(2).upper() in _APEX_COURTS:
|
| 231 |
+
out.append({"title": item.get("title", ""), "citation": cite})
|
| 232 |
+
out.sort(key=lambda r: _neutral_order(r["citation"]), reverse=True)
|
| 233 |
+
return out
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def snapshot_authorities(citer, registry=None, today=None, path=SNAPSHOT_FILE,
|
| 237 |
+
write=True):
|
| 238 |
+
"""Build the authority snapshot with `citer` (a Citator, or anything with
|
| 239 |
+
citing_cases(url)) and write it to `path` unless write=False. Each row
|
| 240 |
+
that resolves: {url, as_of, citing_total, citing_apex, recent_apex (the
|
| 241 |
+
three most recent FC/FCA/SCC citing decisions)}. A row with no CanLII
|
| 242 |
+
URL, or whose lookup fails, lands in 'skipped' with the reason -- an
|
| 243 |
+
error by its type and HTTP code only, never its text, because the
|
| 244 |
+
request URL carries the API key. A run in which no row resolved (every
|
| 245 |
+
lookup failed: a rate limit, the network) is not written, so it never
|
| 246 |
+
overwrites a good earlier snapshot with an all-skipped one."""
|
| 247 |
+
if registry is None:
|
| 248 |
+
registry = json.loads(_REGISTRY_FILE.read_text(encoding="utf-8"))
|
| 249 |
+
today = today or date.today().isoformat()
|
| 250 |
+
out = {"as_of": today,
|
| 251 |
+
"source": "CanLII API caseCitator citingCases (python -m canlex.citator "
|
| 252 |
+
"--snapshot-authorities)",
|
| 253 |
+
"note": "a citation count, not a treatment check; the registry's "
|
| 254 |
+
"subsequent_history is the only 'reversed' signal",
|
| 255 |
+
"authorities": {}, "skipped": {}}
|
| 256 |
+
for key, row in sorted((registry.get("authorities") or {}).items()):
|
| 257 |
+
url = authority_case_url(row)
|
| 258 |
+
if not url:
|
| 259 |
+
out["skipped"][key] = ("no CanLII case URL (no canlii.org source_url "
|
| 260 |
+
"and no neutral citation)")
|
| 261 |
+
continue
|
| 262 |
+
try:
|
| 263 |
+
items = citer.citing_cases(url)
|
| 264 |
+
except Exception as exc: # network or API failure: keep going
|
| 265 |
+
code = getattr(exc, "code", "")
|
| 266 |
+
out["skipped"][key] = f"lookup failed: {type(exc).__name__} {code}".strip()
|
| 267 |
+
continue
|
| 268 |
+
if items is None:
|
| 269 |
+
out["skipped"][key] = "court not in the CanLII database map"
|
| 270 |
+
continue
|
| 271 |
+
apex = apex_citing(items)
|
| 272 |
+
out["authorities"][key] = {"url": url, "as_of": today,
|
| 273 |
+
"citing_total": len(items),
|
| 274 |
+
"citing_apex": len(apex),
|
| 275 |
+
"recent_apex": apex[:3]}
|
| 276 |
+
if write and out["authorities"]:
|
| 277 |
+
Path(path).write_text(json.dumps(out, indent=1, ensure_ascii=False),
|
| 278 |
+
encoding="utf-8")
|
| 279 |
+
return out
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def load_snapshot(path=SNAPSHOT_FILE):
|
| 283 |
+
"""The snapshot, or None where it was never built (every deployment:
|
| 284 |
+
the file is gitignored)."""
|
| 285 |
+
try:
|
| 286 |
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
| 287 |
+
except (OSError, ValueError):
|
| 288 |
+
return None
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def snapshot_line(entry, today=None):
|
| 292 |
+
"""One authority's snapshot as the officer-facing line:
|
| 293 |
+
'Cited by N later FC/FCA/SCC decisions on CanLII as of <date>; this is
|
| 294 |
+
not a treatment check', the most recent of them, and AGING once the
|
| 295 |
+
snapshot is older than SNAPSHOT_AGING_DAYS."""
|
| 296 |
+
line = (f"Cited by {entry['citing_apex']} later FC/FCA/SCC decisions on "
|
| 297 |
+
f"CanLII as of {entry['as_of']}; this is not a treatment check")
|
| 298 |
+
recent = "; ".join(r["citation"] for r in entry.get("recent_apex") or [])
|
| 299 |
+
if recent:
|
| 300 |
+
line += f" (most recent: {recent})"
|
| 301 |
+
age = (date.fromisoformat(today or date.today().isoformat())
|
| 302 |
+
- date.fromisoformat(entry["as_of"])).days
|
| 303 |
+
if age > SNAPSHOT_AGING_DAYS:
|
| 304 |
+
line += (f" -- AGING: the snapshot is {age} days old; re-run "
|
| 305 |
+
f"python -m canlex.citator --snapshot-authorities")
|
| 306 |
+
return line + "."
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def main():
|
| 310 |
+
if sys.argv[1:2] == ["--snapshot-authorities"]:
|
| 311 |
+
snap = snapshot_authorities(Citator())
|
| 312 |
+
for key, entry in snap["authorities"].items():
|
| 313 |
+
print(f"{key}: {snapshot_line(entry)}")
|
| 314 |
+
for key, why in snap["skipped"].items():
|
| 315 |
+
print(f"{key}: skipped -- {why}")
|
| 316 |
+
if snap["authorities"]:
|
| 317 |
+
print(f"-> {SNAPSHOT_FILE} (gitignored; not shipped)")
|
| 318 |
+
else:
|
| 319 |
+
print(f"no authority resolved -- nothing written; {SNAPSHOT_FILE} "
|
| 320 |
+
f"(if any) is kept")
|
| 321 |
+
return
|
| 322 |
+
if len(sys.argv) < 2:
|
| 323 |
+
print('usage: python -m canlex.citator "<canlii-case-url>"\n'
|
| 324 |
+
' python -m canlex.citator --snapshot-authorities')
|
| 325 |
+
return
|
| 326 |
+
report = Citator().case_report(sys.argv[1])
|
| 327 |
+
if "error" in report:
|
| 328 |
+
print("ERROR:", report["error"])
|
| 329 |
+
return
|
| 330 |
+
meta = report["meta"]
|
| 331 |
+
print(f"{meta.get('title')} -- {meta.get('citation')} ({meta.get('decisionDate')})")
|
| 332 |
+
print(f" cited by: {report['citingCases']['total']}")
|
| 333 |
+
print(f" cites: {report['citedCases']['total']}")
|
| 334 |
+
print(f" legislation cited: {report['citedLegislations']['total']}")
|
| 335 |
+
for item in report["citedLegislations"]["items"][:6]:
|
| 336 |
+
print(f" - {item.get('title')} ({item.get('citation')})")
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
if __name__ == "__main__":
|
| 340 |
+
main()
|
canlex/ciu.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [--force] [--allow-shrink]
|
| 21 |
+
|
| 22 |
+
--force re-downloads every cached PDF instead of reusing data/raw/ciu.
|
| 23 |
+
The union reposts amended documents at the same URLs, so a plain run can
|
| 24 |
+
never notice an upstream revision -- see canlex.refresh.check_union.
|
| 25 |
+
--allow-shrink lets a run that yields fewer than 80% of the stored chunks
|
| 26 |
+
write (the corpus-write guard, _common.write_corpus); a document or VSSA
|
| 27 |
+
that fails to fetch keeps its last-good chunks (_common.preserve_dropped).
|
| 28 |
+
"""
|
| 29 |
+
import re
|
| 30 |
+
import sys
|
| 31 |
+
import urllib.request
|
| 32 |
+
|
| 33 |
+
from ._common import (dehyphenate, document_words, fetch_cached,
|
| 34 |
+
mark_soft_breaks, norm_ws as _norm, preserve_dropped,
|
| 35 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 36 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 37 |
+
|
| 38 |
+
RAW = RAW_DIR / "ciu"
|
| 39 |
+
VSSA_PAGE = "https://ciu-sdi.ca/en/vssa/"
|
| 40 |
+
_DOCS_BASE = "https://www.ciu-sdi.ca/wp-content/uploads/documents/"
|
| 41 |
+
|
| 42 |
+
# (code, act_short, act_name, url, lang)
|
| 43 |
+
DOCS = [
|
| 44 |
+
("ciu-bylaws", "CIU By-Laws",
|
| 45 |
+
"CIU National By-Laws (adopted by CIU National Conventions)",
|
| 46 |
+
_DOCS_BASE + "bylaws_en.pdf", "en"),
|
| 47 |
+
("ciu-bylaws", "CIU By-Laws",
|
| 48 |
+
"Règlements nationaux du SDI (adoptés par les congrès)",
|
| 49 |
+
_DOCS_BASE + "bylaws_fr.pdf", "fr"),
|
| 50 |
+
("ciu-regs", "CIU Regulations",
|
| 51 |
+
"CIU Regulations (adopted by the CIU National Board of Directors)",
|
| 52 |
+
_DOCS_BASE + "ciu-regs-en.pdf", "en"),
|
| 53 |
+
("ciu-branch-rules", "CIU Branch Rules",
|
| 54 |
+
"CIU Branch Rules (adopted by the CIU National Board of Directors)",
|
| 55 |
+
_DOCS_BASE + "branch-rules-en.pdf", "en"),
|
| 56 |
+
("ciu-local-mods", "CIU Local Modifications",
|
| 57 |
+
"Local Modifications to CIU Branch Rules",
|
| 58 |
+
_DOCS_BASE + "local_mod_branch_rules_en.pdf", "en"),
|
| 59 |
+
("ciu-elections-kit", "CIU Elections Kit",
|
| 60 |
+
"CIU Branch Elections Kit",
|
| 61 |
+
"https://www.ciu-sdi.ca/wp-content/uploads/elections/2023/EN/"
|
| 62 |
+
"elections-kit.pdf", "en"),
|
| 63 |
+
("psac-constitution", "PSAC Constitution",
|
| 64 |
+
"PSAC Constitution and Regulations (November 2024 edition)",
|
| 65 |
+
"https://psacunion.ca/sites/psac/files/"
|
| 66 |
+
"psac.constitution.november_2024_edition.eng_v002.pdf", "en"),
|
| 67 |
+
("ciu-guide-grievance", "CIU Grievance Guide",
|
| 68 |
+
"CIU Guide to Grievance Handling",
|
| 69 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 70 |
+
"guide_to_grievance_handling-e.pdf", "en"),
|
| 71 |
+
("ciu-guide-ee", "CIU EE Toolkit",
|
| 72 |
+
"CIU Employment Equity Toolkit",
|
| 73 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 74 |
+
"EMPLOYMENT-EQUITY-Tool-Kit-e.pdf", "en"),
|
| 75 |
+
("ciu-guide-transition", "CIU Transition Guide",
|
| 76 |
+
"Workers in Transition: a practical guide about gender transition",
|
| 77 |
+
"https://ciu-sdi.ca/wp-content/uploads/2012/09/"
|
| 78 |
+
"workers_in_transition_guide_-_en_-_web.pdf", "en"),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
# Governance headings: "BY-LAW 5", "REGULATION 12", "RULE 3", "ARTICLE 7",
|
| 82 |
+
# "SECTION 25", "PART B" -- at line start, optionally followed by a title.
|
| 83 |
+
_HEADING = re.compile(
|
| 84 |
+
r"^(BY-?LAW|R[EÈ]GLEMENT|REGULATION|RULE|ARTICLE|SECTION|PART|ANNEX(?:E)?"
|
| 85 |
+
r"|APPENDIX)\s+([0-9]+[0-9A-Za-z.\-]*|[A-Z])\b\s*[-–—:]?\s*(.{0,80})",
|
| 86 |
+
re.IGNORECASE)
|
| 87 |
+
_DATE = re.compile(r"\b(20\d{2}|19\d{2})\b")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _pdf_pages(path):
|
| 91 |
+
from pypdf import PdfReader
|
| 92 |
+
pages = []
|
| 93 |
+
for page in PdfReader(str(path)).pages:
|
| 94 |
+
try:
|
| 95 |
+
pages.append(page.extract_text() or "")
|
| 96 |
+
except Exception:
|
| 97 |
+
pages.append("")
|
| 98 |
+
# a line-final soft hyphen is a broken word's printed hyphen: kept as
|
| 99 |
+
# '-' so it survives norm_ws for dehyphenate (2026-09-22)
|
| 100 |
+
return [mark_soft_breaks(page) for page in pages]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _currency(pages):
|
| 104 |
+
"""Newest adoption/amendment year on the cover pages."""
|
| 105 |
+
years = _DATE.findall("\n".join(pages[:3]))
|
| 106 |
+
return max(years) if years else ""
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _doc_chunks(code, short, name, url, lang, pages):
|
| 110 |
+
text = "\n".join(pages)
|
| 111 |
+
lines = text.split("\n")
|
| 112 |
+
currency = _currency(pages)
|
| 113 |
+
marks = []
|
| 114 |
+
for i, l in enumerate(lines):
|
| 115 |
+
m = _HEADING.match(l.strip())
|
| 116 |
+
if m:
|
| 117 |
+
marks.append((i, f"{m.group(1).title()} {m.group(2)}",
|
| 118 |
+
_norm(m.group(3)) or ""))
|
| 119 |
+
segments = []
|
| 120 |
+
if len(marks) >= 6:
|
| 121 |
+
for j, (i, label, title) in enumerate(marks):
|
| 122 |
+
end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
|
| 123 |
+
body = "\n".join(p for p in (_norm(x) for x in lines[i:end]) if p)
|
| 124 |
+
if len(body) > 120:
|
| 125 |
+
segments.append((label, title, body))
|
| 126 |
+
if not segments:
|
| 127 |
+
for k, piece in enumerate(split_lines(text, 1800), start=1):
|
| 128 |
+
body = "\n".join(p for p in (_norm(x) for x in piece.split("\n")) if p)
|
| 129 |
+
if len(body) > 120:
|
| 130 |
+
segments.append((f"part {k}", "", body))
|
| 131 |
+
chunks = []
|
| 132 |
+
fr = "-fr" if lang == "fr" else ""
|
| 133 |
+
words = document_words(lines)
|
| 134 |
+
for n, (label, title, body) in enumerate(segments, start=1):
|
| 135 |
+
body = dehyphenate(body, words) # line-break hyphens (2026-09-22)
|
| 136 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 137 |
+
total = len(pieces)
|
| 138 |
+
for k, piece in enumerate(pieces, start=1):
|
| 139 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 140 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 141 |
+
chunk = {
|
| 142 |
+
"id": f"{code}{fr}-{n}{suffix}",
|
| 143 |
+
"doc_type": "union",
|
| 144 |
+
"act_code": code.upper(),
|
| 145 |
+
"act_short": short,
|
| 146 |
+
"act_name": name,
|
| 147 |
+
"section": label,
|
| 148 |
+
"marginal_note": title,
|
| 149 |
+
"part": short,
|
| 150 |
+
"division": "",
|
| 151 |
+
"heading": "",
|
| 152 |
+
"text": piece,
|
| 153 |
+
"history": "",
|
| 154 |
+
"last_amended": currency,
|
| 155 |
+
"in_force": "",
|
| 156 |
+
"status": "in force",
|
| 157 |
+
"current_to": currency,
|
| 158 |
+
"citation": f"{short}, {label}"
|
| 159 |
+
+ (f" ({title})" if title else "") + note,
|
| 160 |
+
"source_url": url,
|
| 161 |
+
}
|
| 162 |
+
if lang == "fr":
|
| 163 |
+
chunk["lang"] = "fr"
|
| 164 |
+
chunks.append(chunk)
|
| 165 |
+
return chunks
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _vssa_links():
|
| 169 |
+
req = urllib.request.Request(VSSA_PAGE, headers={"User-Agent": "Mozilla/5.0"})
|
| 170 |
+
with urllib.request.urlopen(req, timeout=60) as r:
|
| 171 |
+
html = r.read().decode("utf-8", "replace")
|
| 172 |
+
links = sorted(set(re.findall(
|
| 173 |
+
r'href="(https?://[^"]*?/uploads/vssa/([^"/]+)\.pdf)"', html)))
|
| 174 |
+
return links
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _vssa_chunks(url, stem, pages):
|
| 178 |
+
site = _norm(stem.replace("-", " "))
|
| 179 |
+
text = "\n".join(p for p in (_norm(x) for x in "\n".join(pages).split("\n")) if p)
|
| 180 |
+
text = dehyphenate(text, document_words(pages)) # line-break hyphens
|
| 181 |
+
if len(text) < 60:
|
| 182 |
+
return []
|
| 183 |
+
out = []
|
| 184 |
+
pieces = split_lines(text, 1800)
|
| 185 |
+
total = len(pieces)
|
| 186 |
+
for k, piece in enumerate(pieces, start=1):
|
| 187 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 188 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 189 |
+
out.append({
|
| 190 |
+
"id": f"vssa-{stem.lower()}{suffix}",
|
| 191 |
+
"doc_type": "union",
|
| 192 |
+
"act_code": "VSSA",
|
| 193 |
+
"act_short": "VSSA",
|
| 194 |
+
"act_name": "Variable Shift Schedule Agreements (by work site)",
|
| 195 |
+
"section": site,
|
| 196 |
+
"marginal_note": f"VSSA — {site}",
|
| 197 |
+
"part": "Variable Shift Schedule Agreements",
|
| 198 |
+
"division": "",
|
| 199 |
+
"heading": "",
|
| 200 |
+
"text": piece,
|
| 201 |
+
"history": "",
|
| 202 |
+
"last_amended": "",
|
| 203 |
+
"in_force": "",
|
| 204 |
+
"status": "in force",
|
| 205 |
+
"current_to": "",
|
| 206 |
+
"citation": f"VSSA — {site}{note}",
|
| 207 |
+
"source_url": url,
|
| 208 |
+
})
|
| 209 |
+
return out
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _identity(chunk):
|
| 213 |
+
"""The document a chunk belongs to, for preserve-on-failure: a governance
|
| 214 |
+
document is (ACT_CODE, lang, ''); a VSSA is ('VSSA', 'en', site), since
|
| 215 |
+
every site shares the VSSA act_code and is told apart by its section."""
|
| 216 |
+
code = chunk["act_code"]
|
| 217 |
+
site = chunk["section"] if code == "VSSA" else ""
|
| 218 |
+
return (code, chunk.get("lang", "en"), site)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def build(force=False, allow_shrink=False):
|
| 222 |
+
"""Rebuild ciu.json. force=True re-downloads each PDF; without it the
|
| 223 |
+
cached copy in data/raw/ciu wins and upstream revisions are invisible.
|
| 224 |
+
Returns the chunks, or False when the corpus-write guard refused.
|
| 225 |
+
|
| 226 |
+
A document or VSSA that fails to fetch or parse keeps its last-good
|
| 227 |
+
chunks (restricted to the ones that actually failed, so a VSSA the union
|
| 228 |
+
takes down stays gone once the index no longer lists it). Until
|
| 229 |
+
2026-09-21 this ingester wrote ciu.json directly: a run with the union
|
| 230 |
+
site down wrote an empty list over 635 stored chunks and exited 0."""
|
| 231 |
+
RAW.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
out = PROCESSED_DIR / "ciu.json"
|
| 233 |
+
stored = stored_chunks(out)
|
| 234 |
+
all_chunks, failed = [], set()
|
| 235 |
+
for code, short, name, url, lang in DOCS:
|
| 236 |
+
fr = "-fr" if lang == "fr" else ""
|
| 237 |
+
dest = RAW / f"{code}{fr}.pdf"
|
| 238 |
+
try:
|
| 239 |
+
fetch_cached(url, dest, pause=0.8, force=force)
|
| 240 |
+
chunks = _doc_chunks(code, short, name, url, lang,
|
| 241 |
+
_pdf_pages(dest))
|
| 242 |
+
except Exception as exc:
|
| 243 |
+
print(f" !! {short} ({lang}): {type(exc).__name__}: {exc}")
|
| 244 |
+
failed.add((code.upper(), lang, ""))
|
| 245 |
+
continue
|
| 246 |
+
print(f" {short:24} ({lang}): {len(chunks)} chunks")
|
| 247 |
+
all_chunks.extend(chunks)
|
| 248 |
+
try:
|
| 249 |
+
links = _vssa_links()
|
| 250 |
+
except Exception as exc:
|
| 251 |
+
links = []
|
| 252 |
+
print(f" !! VSSA index: {type(exc).__name__}: {exc}")
|
| 253 |
+
# No index at all says nothing about any one site: keep every stored VSSA.
|
| 254 |
+
failed.update(_identity(c) for c in stored if c.get("act_code") == "VSSA")
|
| 255 |
+
n_vssa = 0
|
| 256 |
+
for url, stem in links:
|
| 257 |
+
dest = RAW / f"vssa-{stem.lower()}.pdf"
|
| 258 |
+
try:
|
| 259 |
+
fetch_cached(url, dest, pause=0.6, force=force)
|
| 260 |
+
chunks = _vssa_chunks(url, stem, _pdf_pages(dest))
|
| 261 |
+
except Exception as exc:
|
| 262 |
+
print(f" !! VSSA {stem}: {type(exc).__name__}: {exc}")
|
| 263 |
+
failed.add(("VSSA", "en", _norm(stem.replace("-", " "))))
|
| 264 |
+
continue
|
| 265 |
+
all_chunks.extend(chunks)
|
| 266 |
+
n_vssa += 1
|
| 267 |
+
print(f" VSSA sites: {n_vssa}")
|
| 268 |
+
all_chunks, preserved = preserve_dropped(all_chunks, stored, key=_identity,
|
| 269 |
+
only=failed)
|
| 270 |
+
if preserved:
|
| 271 |
+
names = ", ".join(f"{code}{' (' + lang + ')' if lang != 'en' else ''}"
|
| 272 |
+
f"{' ' + site if site else ''}"
|
| 273 |
+
for code, lang, site in preserved)
|
| 274 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 275 |
+
f"document(s): {names}")
|
| 276 |
+
uniquify_ids(all_chunks)
|
| 277 |
+
if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
|
| 278 |
+
return False
|
| 279 |
+
print(f"ciu: {len(all_chunks)} chunks -> {out.name}")
|
| 280 |
+
return all_chunks
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def main():
|
| 284 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 285 |
+
# empty corpus succeeded, a refused write did not.
|
| 286 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 287 |
+
force="--force" in sys.argv) is not False
|
| 288 |
+
sys.exit(0 if ok else 1)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
if __name__ == "__main__":
|
| 292 |
+
main()
|
canlex/commentary.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 re
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from ._common import write_corpus
|
| 19 |
+
from .config import DATA_DIR, PROCESSED_DIR
|
| 20 |
+
|
| 21 |
+
CURATED = DATA_DIR / "curated" / "us_dispositions.json"
|
| 22 |
+
EQUIV = DATA_DIR / "curated" / "us_equivalency.json"
|
| 23 |
+
OUT = PROCESSED_DIR / "commentary.json"
|
| 24 |
+
|
| 25 |
+
ACT_CODE = "US-DISP"
|
| 26 |
+
ACT_SHORT = "US Dispositions Helper"
|
| 27 |
+
ACT_NAME = ("US criminal dispositions and the IRPA 'conviction' concept "
|
| 28 |
+
"(curated CanLex commentary)")
|
| 29 |
+
|
| 30 |
+
BANNER = ("CURATED ANALYSIS -- commentary compiled for CanLex, not a source "
|
| 31 |
+
"of law. Verify against the cited authorities before relying on it.")
|
| 32 |
+
|
| 33 |
+
# The heading over an entry's guidance references (roadmap T1.10). It used to
|
| 34 |
+
# say 'IRCC guidance (cited by reference; not reproduced here)', but the
|
| 35 |
+
# references are mostly IRB Legal Services papers, and the guidance CanLex
|
| 36 |
+
# holds is now in the corpus. canlex/server.py's _entry_brief shares it.
|
| 37 |
+
GUIDANCE_HEADING = ("Guidance cited (IRB / IRCC; where CanLex holds the text, "
|
| 38 |
+
"search it with canlex_search_legislation, doc_type "
|
| 39 |
+
"'memorandum'):")
|
| 40 |
+
|
| 41 |
+
_STATUS_LABEL = {
|
| 42 |
+
"settled": "Settled by binding authority",
|
| 43 |
+
"judicially-considered": "Judicially considered (persuasive authority)",
|
| 44 |
+
"guidance-only": "IRCC guidance only -- no judicial authority located",
|
| 45 |
+
"no-authority": "NO AUTHORITY LOCATED -- reasoned interpretation only",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Verdicts assess the COMPLETED disposition; the methodology chunk explains
|
| 49 |
+
# the rule each one derives from. 'depends' is the legacy spelling of
|
| 50 |
+
# fact-specific, kept for backwards compatibility.
|
| 51 |
+
_VERDICT_LABEL = {
|
| 52 |
+
"yes": "YES", "likely-yes": "LIKELY YES", "likely-no": "LIKELY NO",
|
| 53 |
+
"no": "NO", "fact-specific": "FACT-SPECIFIC",
|
| 54 |
+
"depends": "FACT-SPECIFIC",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _vlabel(v):
|
| 59 |
+
return _VERDICT_LABEL.get(v, str(v).upper())
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# The survey's own caution, rendered with every entry that carries state
|
| 63 |
+
# rows: most rows are secondary-source extractions and the dataset says so
|
| 64 |
+
# in its `_state_survey` key -- which nothing used to read.
|
| 65 |
+
SURVEY_NOTE = ("State rows: 51-jurisdiction survey (July 2026); verdicts applied "
|
| 66 |
+
"by rule from each row's mechanism facts. Every row shows its "
|
| 67 |
+
"provenance: a row marked 'verified <date> against <source>' was "
|
| 68 |
+
"checked against the official text it names (verification rounds "
|
| 69 |
+
"2026-07-12 to 2026-09-12); a row marked 'NOT VERIFIED' says why; "
|
| 70 |
+
"every other row is a secondary-source extraction -- confirm the "
|
| 71 |
+
"statute before relying on it to clear a record.")
|
| 72 |
+
|
| 73 |
+
# Compiler-side notes the survey left in row text ("[EXTRACTION FLAG: ...
|
| 74 |
+
# verify]", "PROFILE THIN -- VERIFY"): rendered as explicit open questions,
|
| 75 |
+
# never as analysis.
|
| 76 |
+
_FLAG = re.compile(r"\s*\[(?:EXTRACTION FLAG|Corrected|Normalized)[^\]]*\]")
|
| 77 |
+
# A sentence boundary: a period followed by whitespace and a capital -- so
|
| 78 |
+
# 'art. 42A.701' and 'Stat. Ann. § 431.073' stay whole.
|
| 79 |
+
_SENTENCE = re.compile(r"(?<=\.)\s+(?=[A-Z])")
|
| 80 |
+
_VERIFY = re.compile(r"\bVERIFY\b|\bPROFILE (?:THIN|SILENT)\b")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _split_verify(note):
|
| 84 |
+
"""(note without its compiler notes, [the notes]): whole sentences that
|
| 85 |
+
carry a VERIFY / PROFILE THIN marker are lifted out of the row text."""
|
| 86 |
+
keep, lifted = [], []
|
| 87 |
+
for sentence in _SENTENCE.split(note):
|
| 88 |
+
(lifted if _VERIFY.search(sentence) else keep).append(sentence.strip())
|
| 89 |
+
return " ".join(s for s in keep if s), lifted
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _sources(v):
|
| 93 |
+
"""A state row's 'source' (roadmap T3.5, held): one object or a list."""
|
| 94 |
+
s = v.get("source")
|
| 95 |
+
if isinstance(s, dict):
|
| 96 |
+
return [s]
|
| 97 |
+
return [x for x in (s or []) if isinstance(x, dict)]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def provenance_label(v):
|
| 101 |
+
"""The provenance tier printed with EVERY state row (roadmap T3.5): where
|
| 102 |
+
its statement of state law comes from. A verified row names the official
|
| 103 |
+
text it was checked against; a row the verification could not reach says
|
| 104 |
+
why; any other row is a secondary-source extraction."""
|
| 105 |
+
src = _sources(v)
|
| 106 |
+
if v.get("verified"):
|
| 107 |
+
if src:
|
| 108 |
+
s = src[0]
|
| 109 |
+
grade = f", {s['grade']}" if s.get("grade") else ""
|
| 110 |
+
return f"[verified {v['verified']} against {s['cite']}{grade}: {s['url']}]"
|
| 111 |
+
return f"[verified against statute text {v['verified'][:7]}]"
|
| 112 |
+
if v.get("unverifiable"):
|
| 113 |
+
read = f"; read: {src[0]['cite']}: {src[0]['url']}" if src else ""
|
| 114 |
+
return f"[NOT VERIFIED: {v['unverifiable']}{read}]"
|
| 115 |
+
return "[secondary-source extraction, unverified]"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def row_tier(v):
|
| 119 |
+
"""The same tier in a few words, for the screening memo's step 1."""
|
| 120 |
+
src = _sources(v)
|
| 121 |
+
if v.get("verified"):
|
| 122 |
+
return (f"verified {v['verified']} against {src[0]['cite']}" if src
|
| 123 |
+
else "verified against statute text")
|
| 124 |
+
if v.get("unverifiable"):
|
| 125 |
+
return f"NOT VERIFIED -- {v['unverifiable']}"
|
| 126 |
+
return "secondary-source extraction -- unverified"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def row_tag(v):
|
| 130 |
+
"""The tier as a short tag for a one-line index of rows (wave-3 verifier,
|
| 131 |
+
2026-09-15: the 'all' index listed every row with its verdict only)."""
|
| 132 |
+
if v.get("verified"):
|
| 133 |
+
return f"[verified {v['verified']}]"
|
| 134 |
+
if v.get("unverifiable"):
|
| 135 |
+
return "[NOT VERIFIED]"
|
| 136 |
+
return "[unverified]"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def row_notes(v):
|
| 140 |
+
"""The held review lines a state row carries (roadmap T3.5): a CanLex
|
| 141 |
+
characterisation of the state's law, and what decides a split row."""
|
| 142 |
+
out = []
|
| 143 |
+
if v.get("characterisation"):
|
| 144 |
+
out.append("CANLEX CHARACTERISATION (held for review): " + v["characterisation"])
|
| 145 |
+
if v.get("open_question"):
|
| 146 |
+
out.append("WHAT DECIDES IT (held for review): " + v["open_question"])
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def row_provenance(v):
|
| 151 |
+
"""CANLEX_RESULT 'row_provenance' for a state row; None without a row."""
|
| 152 |
+
if not v:
|
| 153 |
+
return None
|
| 154 |
+
s = (_sources(v) or [{}])[0]
|
| 155 |
+
return {"state": v.get("state"),
|
| 156 |
+
"tier": ("verified" if v.get("verified") else
|
| 157 |
+
"not-verified" if v.get("unverifiable") else "secondary-unverified"),
|
| 158 |
+
"verified": v.get("verified"), "verified_by": v.get("verified_by"),
|
| 159 |
+
"source_cite": s.get("cite"), "source_url": s.get("url"),
|
| 160 |
+
"unverifiable": v.get("unverifiable"),
|
| 161 |
+
"characterisation": bool(v.get("characterisation")),
|
| 162 |
+
"open_question": bool(v.get("open_question"))}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _row_lines(v, verdict_label, full=False):
|
| 166 |
+
"""The bullet for one state row with its provenance tier (every row,
|
| 167 |
+
roadmap T3.5), the held review lines, any open-question line, and --
|
| 168 |
+
full=True, the requested state in a tool answer -- each source quote."""
|
| 169 |
+
note = v["note"]
|
| 170 |
+
flags = [m.group(0).strip(" []") for m in _FLAG.finditer(note)]
|
| 171 |
+
note = _FLAG.sub("", note)
|
| 172 |
+
note, verify = _split_verify(note)
|
| 173 |
+
lines = [f"- {v['state']}{verdict_label}: {note} {provenance_label(v)}"]
|
| 174 |
+
lines += [" " + x for x in row_notes(v)]
|
| 175 |
+
if full:
|
| 176 |
+
for s in _sources(v):
|
| 177 |
+
if s.get("quote"):
|
| 178 |
+
lines.append(f" SOURCE: {s['cite']}: \"{s['quote']}\" ({s['url']}, "
|
| 179 |
+
f"retrieved {s.get('retrieved', '?')})")
|
| 180 |
+
open_q = [f for f in flags if not f.startswith("Normalized")] + verify
|
| 181 |
+
if open_q:
|
| 182 |
+
lines.append(" OPEN QUESTION (unverified extraction note): "
|
| 183 |
+
+ " | ".join(open_q))
|
| 184 |
+
return lines
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# A disposition entry may carry 'policy_caveats' (roadmap T3.6, 2026-09-15 --
|
| 188 |
+
# HELD FOR REVIEW; optional, the approved baseline has none): official guidance
|
| 189 |
+
# that pulls against the entry's bottom line, quoted verbatim (each quote pinned
|
| 190 |
+
# in the corpus by tests/test_read_next.py), with both readings -- principle 5:
|
| 191 |
+
# CanLex does not choose. First use: ENF 2 s. 3's list of scenarios in which the
|
| 192 |
+
# act branch 'would generally not be applied' against the entries whose bottom
|
| 193 |
+
# lines keep it available.
|
| 194 |
+
POLICY_CAVEAT_HEAD = "POLICY CAVEAT -- NOT SETTLED (both readings are given; neither is chosen):"
|
| 195 |
+
# Disposition roadmap T1.5 / T1.10 and the critic's Rodriguez Anzola item
|
| 196 |
+
# (2026-09-16, HELD): what a caveat bears on, keyed by its curated id -- never
|
| 197 |
+
# by its text. 'act': the act branch, whose allegation is still drafted with
|
| 198 |
+
# the caveat beside it (ENF 2 s. 3's scenarios); 'act-not-settled': the act
|
| 199 |
+
# branch after an acquittal, where no act-branch allegation, s. 44(1) skeleton
|
| 200 |
+
# or card report is drafted (ENF 6 s. 23.1 against the statute and Magtibay);
|
| 201 |
+
# 'act-if-acquitted': a pending charge, whose readings apply only if it ends
|
| 202 |
+
# in an acquittal; 'conviction': the conviction branch itself (a conviction
|
| 203 |
+
# that may be a nullity for Canadian purposes). An id not listed is 'act'.
|
| 204 |
+
CAVEAT_CLASSES = {
|
| 205 |
+
"enf-2-s3-act-branch": "act",
|
| 206 |
+
"enf-6-s23-1-acquittal": "act-not-settled",
|
| 207 |
+
"enf-6-s23-1-if-acquitted": "act-if-acquitted",
|
| 208 |
+
"rodriguez-anzola-duress-nullity": "conviction",
|
| 209 |
+
}
|
| 210 |
+
AUTHORITY_CAVEAT_HEAD = ("AUTHORITY CAVEAT -- NOT SETTLED (both readings are given; neither is "
|
| 211 |
+
"chosen):")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def caveat_class(cv):
|
| 215 |
+
"""The class CAVEAT_CLASSES gives a curated caveat item ('act' by default)."""
|
| 216 |
+
return CAVEAT_CLASSES.get((cv or {}).get("id") if isinstance(cv, dict) else None, "act")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def caveat_in_play(cv, footing):
|
| 220 |
+
"""True when a caveat bears on a step-1 footing ('conviction', 'act' or
|
| 221 |
+
'both'; None: every caveat, as the entry prints without a record)."""
|
| 222 |
+
if footing is None or footing == "both":
|
| 223 |
+
return True
|
| 224 |
+
return (caveat_class(cv) == "conviction") == (footing == "conviction")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def policy_caveat_lines(e, footing=None, skip=()):
|
| 228 |
+
"""The POLICY CAVEAT lines of an entry: the caveat text with its source and
|
| 229 |
+
chunk, then each reading ([] when the entry carries none). footing: only
|
| 230 |
+
the caveats that bear on it (caveat_in_play); skip: caveat ids the caller
|
| 231 |
+
prints another way."""
|
| 232 |
+
out = []
|
| 233 |
+
for cv in (e.get("policy_caveats") or []) if isinstance(e, dict) else []:
|
| 234 |
+
if not (isinstance(cv, dict) and isinstance(cv.get("text"), str) and cv["text"].strip()):
|
| 235 |
+
continue
|
| 236 |
+
if cv.get("id") in skip or not caveat_in_play(cv, footing):
|
| 237 |
+
continue
|
| 238 |
+
head = AUTHORITY_CAVEAT_HEAD if caveat_class(cv) == "conviction" else POLICY_CAVEAT_HEAD
|
| 239 |
+
where = "; ".join(x for x in (cv.get("source"), cv.get("chunk")) if x)
|
| 240 |
+
out.append(f"{head} {cv['text']}" + (f" [{where}]" if where else ""))
|
| 241 |
+
out += [f"- {r}" for r in cv.get("readings") or [] if isinstance(r, str) and r.strip()]
|
| 242 |
+
return out
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _entry_text(e, max_states=None):
|
| 246 |
+
"""Render one disposition entry as a readable, retrieval-friendly block.
|
| 247 |
+
|
| 248 |
+
With the 51-jurisdiction survey an entry can carry dozens of state rows;
|
| 249 |
+
max_states caps how many render inline (the corpus keeps full per-state
|
| 250 |
+
detail in separate per-state chunks so retrieval still reaches every
|
| 251 |
+
row). None renders everything -- the tool uses that for direct lookups."""
|
| 252 |
+
lines = [BANNER, ""]
|
| 253 |
+
lines.append(f"Disposition: {'; '.join(e['names'])}")
|
| 254 |
+
lines.append(f"Is the COMPLETED disposition a conviction for IRPA s. 36: "
|
| 255 |
+
f"{_vlabel(e['is_conviction'])}")
|
| 256 |
+
lines.append(f"Authority status: {_STATUS_LABEL[e['status']]}")
|
| 257 |
+
if e.get("bottom_line"):
|
| 258 |
+
lines.append("")
|
| 259 |
+
lines.append(f"BOTTOM LINE: {e['bottom_line']}")
|
| 260 |
+
caveat = policy_caveat_lines(e) # held for review; absent on the baseline
|
| 261 |
+
if caveat:
|
| 262 |
+
lines.append("")
|
| 263 |
+
lines += caveat
|
| 264 |
+
lines.append("")
|
| 265 |
+
lines.append(e["analysis"])
|
| 266 |
+
if e.get("state_variations"):
|
| 267 |
+
named = [v for v in e["state_variations"]
|
| 268 |
+
if v["state"].lower() != "general"]
|
| 269 |
+
shown = named if max_states is None else named[:max_states]
|
| 270 |
+
lines.append("")
|
| 271 |
+
if max_states is not None and len(named) > len(shown):
|
| 272 |
+
from collections import Counter
|
| 273 |
+
counts = Counter(v.get("is_conviction", "depends") for v in named)
|
| 274 |
+
lines.append(f"State-by-state coverage: {len(named)} jurisdictions "
|
| 275 |
+
f"({', '.join(f'{k}: {n}' for k, n in counts.most_common())}) "
|
| 276 |
+
f"-- full per-state detail in the per-state entries.")
|
| 277 |
+
else:
|
| 278 |
+
lines.append("State variations:")
|
| 279 |
+
for v in shown:
|
| 280 |
+
flag = (f" (conviction: {_vlabel(v['is_conviction'])})"
|
| 281 |
+
if v.get("is_conviction") else "")
|
| 282 |
+
lines += _row_lines(v, flag)
|
| 283 |
+
for v in e["state_variations"]:
|
| 284 |
+
if v["state"].lower() == "general":
|
| 285 |
+
lines.append(f"- General: {v['note']}")
|
| 286 |
+
lines.append("")
|
| 287 |
+
lines.append(SURVEY_NOTE)
|
| 288 |
+
# The Saini checklist (roadmap T2.8), only for an entry that carries the
|
| 289 |
+
# curated 'pardon_test' (held for review; the approved baseline has none).
|
| 290 |
+
from canlex.screening import saini_checklist
|
| 291 |
+
checklist = saini_checklist(e)
|
| 292 |
+
if checklist:
|
| 293 |
+
lines.append("")
|
| 294 |
+
lines += checklist
|
| 295 |
+
if e.get("authorities"):
|
| 296 |
+
# each followed by the authority registry's brief pointer (roadmap
|
| 297 |
+
# T3.2; the bare bullets when the registry is not rendered)
|
| 298 |
+
from canlex.authorities import authority_lines
|
| 299 |
+
lines.append("")
|
| 300 |
+
lines.append("Authorities:")
|
| 301 |
+
lines += authority_lines(e["authorities"], brief=True)
|
| 302 |
+
if e.get("guidance"):
|
| 303 |
+
lines.append("")
|
| 304 |
+
lines.append(GUIDANCE_HEADING)
|
| 305 |
+
for g in e["guidance"]:
|
| 306 |
+
lines.append(f"- {g['ref']}: {g['note']}")
|
| 307 |
+
if e.get("interpretation"):
|
| 308 |
+
lines.append("")
|
| 309 |
+
lines.append("INTERPRETATION (no direct authority -- this is CanLex's "
|
| 310 |
+
"reasoned view from the governing principles; treat it as "
|
| 311 |
+
"a starting point, not an answer): "
|
| 312 |
+
+ e["interpretation"])
|
| 313 |
+
return "\n".join(lines)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def build(allow_shrink=False):
|
| 317 |
+
data = json.loads(CURATED.read_text(encoding="utf-8"))
|
| 318 |
+
chunks = []
|
| 319 |
+
for e in data.get("methodology", []):
|
| 320 |
+
chunks.append({
|
| 321 |
+
"id": f"commentary-method-{e['id']}",
|
| 322 |
+
"doc_type": "commentary",
|
| 323 |
+
"act_code": ACT_CODE,
|
| 324 |
+
"act_short": ACT_SHORT,
|
| 325 |
+
"act_name": ACT_NAME,
|
| 326 |
+
"section": e["id"],
|
| 327 |
+
"marginal_note": e["title"],
|
| 328 |
+
"part": "Methodology",
|
| 329 |
+
"division": "",
|
| 330 |
+
"heading": e["title"],
|
| 331 |
+
"text": BANNER + "\n\n" + e["text"],
|
| 332 |
+
"history": "",
|
| 333 |
+
"last_amended": "",
|
| 334 |
+
"current_to": data.get("reviewed", ""),
|
| 335 |
+
"citation": f"{ACT_SHORT} — {e['title']}",
|
| 336 |
+
"source_url": "",
|
| 337 |
+
})
|
| 338 |
+
for e in data.get("dispositions", []):
|
| 339 |
+
chunks.append({
|
| 340 |
+
"id": f"commentary-disp-{e['id']}",
|
| 341 |
+
"doc_type": "commentary",
|
| 342 |
+
"act_code": ACT_CODE,
|
| 343 |
+
"act_short": ACT_SHORT,
|
| 344 |
+
"act_name": ACT_NAME,
|
| 345 |
+
"section": e["id"],
|
| 346 |
+
"marginal_note": e["names"][0],
|
| 347 |
+
"part": "US dispositions",
|
| 348 |
+
"division": "",
|
| 349 |
+
"heading": (f"Is a US {e['names'][0]} a conviction for IRPA "
|
| 350 |
+
f"s. 36? ({_vlabel(e['is_conviction'])})"),
|
| 351 |
+
"text": _entry_text(e, max_states=6),
|
| 352 |
+
"history": "",
|
| 353 |
+
"last_amended": "",
|
| 354 |
+
"current_to": data.get("reviewed", ""),
|
| 355 |
+
"citation": f"{ACT_SHORT} — {e['names'][0]}",
|
| 356 |
+
"source_url": "",
|
| 357 |
+
})
|
| 358 |
+
|
| 359 |
+
# One chunk per jurisdiction: every disposition row for that state, so a
|
| 360 |
+
# query naming a state ("Georgia first offender act", "Missouri SIS")
|
| 361 |
+
# retrieves that state's page directly.
|
| 362 |
+
by_state = {}
|
| 363 |
+
for e in data.get("dispositions", []):
|
| 364 |
+
for v in e.get("state_variations", []):
|
| 365 |
+
st = v["state"]
|
| 366 |
+
if st.lower() == "general":
|
| 367 |
+
continue
|
| 368 |
+
flag = _vlabel(v.get("is_conviction") or e["is_conviction"])
|
| 369 |
+
row = dict(v, state=e["names"][0])
|
| 370 |
+
by_state.setdefault(st, []).extend(
|
| 371 |
+
_row_lines(row, f" (conviction: {flag})"))
|
| 372 |
+
for st in sorted(by_state):
|
| 373 |
+
body = (BANNER + "\n\n"
|
| 374 |
+
+ f"US dispositions — {st}: whether each disposition type is a "
|
| 375 |
+
f"conviction for IRPA s. 36, under {st} law.\n\n"
|
| 376 |
+
+ "\n".join(by_state[st])
|
| 377 |
+
+ "\n\n" + SURVEY_NOTE
|
| 378 |
+
+ "\n\nThe act branch (IRPA s. 36(1)(c)/(2)(c)) can apply even "
|
| 379 |
+
"where a disposition is not a conviction. See the "
|
| 380 |
+
"per-disposition entries for the governing analysis and "
|
| 381 |
+
"authorities.")
|
| 382 |
+
slug = st.lower().replace(" ", "-")
|
| 383 |
+
chunks.append({
|
| 384 |
+
"id": f"commentary-state-{slug}",
|
| 385 |
+
"doc_type": "commentary",
|
| 386 |
+
"act_code": ACT_CODE,
|
| 387 |
+
"act_short": ACT_SHORT,
|
| 388 |
+
"act_name": ACT_NAME,
|
| 389 |
+
"section": f"state-{slug}",
|
| 390 |
+
"marginal_note": f"US dispositions — {st}",
|
| 391 |
+
"part": "US dispositions by state",
|
| 392 |
+
"division": "",
|
| 393 |
+
"heading": (f"{st}: criminal dispositions vs the IRPA "
|
| 394 |
+
f"'conviction' concept"),
|
| 395 |
+
"text": body,
|
| 396 |
+
"history": "",
|
| 397 |
+
"last_amended": "",
|
| 398 |
+
"current_to": data.get("reviewed", ""),
|
| 399 |
+
"citation": f"{ACT_SHORT} — {st}",
|
| 400 |
+
"source_url": "",
|
| 401 |
+
})
|
| 402 |
+
# --- equivalency pairings (step 2), same banner discipline
|
| 403 |
+
n_pairings = 0
|
| 404 |
+
if EQUIV.exists():
|
| 405 |
+
eq = json.loads(EQUIV.read_text(encoding="utf-8"))
|
| 406 |
+
for m in eq.get("methodology", []):
|
| 407 |
+
chunks.append({
|
| 408 |
+
"id": f"commentary-method-{m['id']}",
|
| 409 |
+
"doc_type": "commentary",
|
| 410 |
+
"act_code": ACT_CODE,
|
| 411 |
+
"act_short": ACT_SHORT,
|
| 412 |
+
"act_name": ACT_NAME,
|
| 413 |
+
"section": m["id"],
|
| 414 |
+
"marginal_note": m["title"],
|
| 415 |
+
"part": "Methodology",
|
| 416 |
+
"division": "",
|
| 417 |
+
"heading": m["title"],
|
| 418 |
+
"text": BANNER + "\n\n" + m["text"],
|
| 419 |
+
"history": "",
|
| 420 |
+
"last_amended": "",
|
| 421 |
+
"current_to": eq.get("reviewed", ""),
|
| 422 |
+
"citation": f"{ACT_SHORT} — {m['title']}",
|
| 423 |
+
"source_url": "",
|
| 424 |
+
})
|
| 425 |
+
for p in eq.get("pairings", []):
|
| 426 |
+
n_pairings += 1
|
| 427 |
+
lines = [BANNER, "",
|
| 428 |
+
f"US offence: {'; '.join(p['us_terms'][:5])}",
|
| 429 |
+
f"Canadian equivalent: {p['canadian_offence']}",
|
| 430 |
+
f"Maximum penalty (verified against the corpus text): "
|
| 431 |
+
f"{p['penalty']}",
|
| 432 |
+
f"Inadmissibility branch: {p['branch']}",
|
| 433 |
+
f"Authority status: {_STATUS_LABEL[p['status']]}", "",
|
| 434 |
+
p["analysis"]]
|
| 435 |
+
if p.get("caveats"):
|
| 436 |
+
lines.append("")
|
| 437 |
+
lines.append("Caveats:")
|
| 438 |
+
lines += [f"- {c}" for c in p["caveats"]]
|
| 439 |
+
if p.get("authorities"):
|
| 440 |
+
from canlex.authorities import authority_lines
|
| 441 |
+
lines.append("")
|
| 442 |
+
lines.append("Authorities:")
|
| 443 |
+
lines += authority_lines(p["authorities"], brief=True)
|
| 444 |
+
chunks.append({
|
| 445 |
+
"id": f"commentary-equiv-{p['id']}",
|
| 446 |
+
"doc_type": "commentary",
|
| 447 |
+
"act_code": ACT_CODE,
|
| 448 |
+
"act_short": ACT_SHORT,
|
| 449 |
+
"act_name": ACT_NAME,
|
| 450 |
+
"section": f"equiv-{p['id']}",
|
| 451 |
+
"marginal_note": f"Equivalency: {p['us_terms'][0]}",
|
| 452 |
+
"part": "US offence equivalency",
|
| 453 |
+
"division": "",
|
| 454 |
+
"heading": (f"What does a US {p['us_terms'][0]} conviction "
|
| 455 |
+
f"equate to in Canada?"),
|
| 456 |
+
"text": "\n".join(lines),
|
| 457 |
+
"history": "",
|
| 458 |
+
"last_amended": "",
|
| 459 |
+
"current_to": eq.get("reviewed", ""),
|
| 460 |
+
"citation": f"{ACT_SHORT} — equivalency: {p['us_terms'][0]}",
|
| 461 |
+
"source_url": "",
|
| 462 |
+
})
|
| 463 |
+
|
| 464 |
+
# A full re-render of the curated files, so a truncated or half-edited
|
| 465 |
+
# us_dispositions.json (or an equivalency file that moved) collapses the
|
| 466 |
+
# chunk count and the write would replace good analysis with the remnant.
|
| 467 |
+
# indent=1 is the stored file's format -- changing it rewrites every line.
|
| 468 |
+
if not write_corpus(OUT, chunks, indent=1, allow_shrink=allow_shrink):
|
| 469 |
+
return False
|
| 470 |
+
print(f"{len(chunks)} commentary chunks "
|
| 471 |
+
f"({len(data.get('dispositions', []))} dispositions, "
|
| 472 |
+
f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
|
| 473 |
+
f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
|
| 474 |
+
return True
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def main():
|
| 478 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
if __name__ == "__main__":
|
| 482 |
+
main()
|
canlex/conduct.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest public policy pages: employer policy instruments and a few
|
| 2 |
+
operational-policy and treaty texts (one chunk per heading).
|
| 3 |
+
|
| 4 |
+
Nearly every CBSA discipline letter and FPSLREB discipline decision in the
|
| 5 |
+
corpus is framed as a breach of a numbered section of the CBSA Code of
|
| 6 |
+
Conduct, and the Code itself operates under the Treasury Board's Values and
|
| 7 |
+
Ethics Code for the Public Sector -- yet neither text was in the corpus, so
|
| 8 |
+
a steward asking what the Code says about off-duty conduct or outside
|
| 9 |
+
employment got tribunal paraphrases of older editions. The Treasury Board
|
| 10 |
+
Directive on Terms and Conditions of Employment joins them: the FB agreement
|
| 11 |
+
defers to it for pay administration. All are single public HTML pages; each
|
| 12 |
+
is parsed into one chunk per heading.
|
| 13 |
+
|
| 14 |
+
These are employer policy instruments: binding on employees as a condition
|
| 15 |
+
of employment (the CBSA Code requires an annual attestation), grievable
|
| 16 |
+
when applied, but neither legislation nor part of the collective agreement.
|
| 17 |
+
server._format_section banners doc_type='policy' accordingly.
|
| 18 |
+
|
| 19 |
+
Each DOCS entry is one instrument: `url` (or `urls`, for a document
|
| 20 |
+
published as several pages), optional `doc_type` ('policy' by default;
|
| 21 |
+
'memorandum' for CBSA operational policy, 'legislation' for the Safe Third
|
| 22 |
+
Country Agreement), optional `lang` ('fr' twins carry a '-fr-' id infix and
|
| 23 |
+
lang='fr' like every other French chunk), and `powershell` for canada.ca
|
| 24 |
+
hosts that reject Python's TLS client.
|
| 25 |
+
|
| 26 |
+
python -m canlex.conduct [--force] [--allow-shrink]
|
| 27 |
+
"""
|
| 28 |
+
import re
|
| 29 |
+
import sys
|
| 30 |
+
|
| 31 |
+
from bs4 import BeautifulSoup
|
| 32 |
+
|
| 33 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 34 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 35 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 36 |
+
|
| 37 |
+
RAW = RAW_DIR / "policy"
|
| 38 |
+
OUT_FILE = PROCESSED_DIR / "policy.json"
|
| 39 |
+
|
| 40 |
+
_TBS = "https://www.tbs-sct.canada.ca/pol/doc-{lang}.aspx?id={id}"
|
| 41 |
+
_CBSA_DET = "https://www.cbsa-asfc.gc.ca/security-securite/detent/"
|
| 42 |
+
_STCA = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 43 |
+
"mandate/policies-operational-instructions-agreements/agreements/"
|
| 44 |
+
"safe-third-country-agreement/")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _tbs(code, short, name, id, lang="en"):
|
| 48 |
+
"""A Treasury Board policy-suite instrument, English or French."""
|
| 49 |
+
return {"code": code, "short": short, "name": name,
|
| 50 |
+
"url": _TBS.format(lang="eng" if lang == "en" else "fra", id=id),
|
| 51 |
+
"raw": f"tbs-{id}-{lang}.html", "lang": lang}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
DOCS = [
|
| 55 |
+
{"code": "CBSA-CODE",
|
| 56 |
+
"short": "CBSA Code of Conduct",
|
| 57 |
+
"name": "Canada Border Services Agency Code of Conduct",
|
| 58 |
+
"url": "https://www.cbsa-asfc.gc.ca/agency-agence/reports-rapports/"
|
| 59 |
+
"acc-resp/code-eng.html",
|
| 60 |
+
"raw": "cbsa-code-eng.html"},
|
| 61 |
+
{"code": "VE-CODE",
|
| 62 |
+
"short": "Values and Ethics Code",
|
| 63 |
+
"name": "Values and Ethics Code for the Public Sector (Treasury Board)",
|
| 64 |
+
"url": "https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=25049",
|
| 65 |
+
"raw": "values-ethics-code-eng.html"},
|
| 66 |
+
# The FB agreement defers to this Directive for pay on promotion and
|
| 67 |
+
# acting, increments, hours-of-work defaults and overpayment recovery.
|
| 68 |
+
{"code": "TB-TCE",
|
| 69 |
+
"short": "Terms and Conditions Directive",
|
| 70 |
+
"name": "Directive on Terms and Conditions of Employment (Treasury Board)",
|
| 71 |
+
"url": "https://www.tbs-sct.canada.ca/pol/doc-eng.aspx?id=15772",
|
| 72 |
+
"raw": "tb-terms-conditions-eng.html"},
|
| 73 |
+
# --- the people-management instruments the FPSLREB decisions apply ---
|
| 74 |
+
_tbs("TB-DISC", "Guidelines for Discipline",
|
| 75 |
+
"Guidelines for Discipline (Treasury Board)", 22370),
|
| 76 |
+
_tbs("TB-DTA", "Duty to Accommodate Directive",
|
| 77 |
+
"Directive on the Duty to Accommodate (Treasury Board)", 32634),
|
| 78 |
+
_tbs("TB-PERF", "Performance Management Directive",
|
| 79 |
+
"Directive on Performance Management (Treasury Board)", 27146),
|
| 80 |
+
_tbs("TB-WPHV", "Harassment and Violence Directive",
|
| 81 |
+
"Directive on the Prevention and Resolution of Workplace Harassment "
|
| 82 |
+
"and Violence (Treasury Board)", 32671),
|
| 83 |
+
_tbs("TB-LEAVE", "Leave and Special Working Arrangements Directive",
|
| 84 |
+
"Directive on Leave and Special Working Arrangements (Treasury "
|
| 85 |
+
"Board)", 15774),
|
| 86 |
+
_tbs("TB-PGS", "Policy on Government Security",
|
| 87 |
+
"Policy on Government Security (Treasury Board)", 16578),
|
| 88 |
+
_tbs("TB-SEC", "Security Screening Directive",
|
| 89 |
+
"Directive on Security Screening (Treasury Board, in effect "
|
| 90 |
+
"2025-01-06; replaces the Standard on Security Screening)", 32805),
|
| 91 |
+
# --- French twins: the same instruments as their francophone readers
|
| 92 |
+
# get them (lang='fr'; an English query never sees them) ---
|
| 93 |
+
{"code": "CBSA-CODE", "lang": "fr",
|
| 94 |
+
"short": "CBSA Code of Conduct",
|
| 95 |
+
"name": "Code de conduite de l'Agence des services frontaliers du Canada",
|
| 96 |
+
"url": "https://www.cbsa-asfc.gc.ca/agency-agence/reports-rapports/"
|
| 97 |
+
"acc-resp/code-fra.html",
|
| 98 |
+
"raw": "cbsa-code-fra.html"},
|
| 99 |
+
_tbs("VE-CODE", "Values and Ethics Code",
|
| 100 |
+
"Code de valeurs et d'éthique du secteur public", 25049, "fr"),
|
| 101 |
+
_tbs("TB-TCE", "Terms and Conditions Directive",
|
| 102 |
+
"Directive sur les conditions d'emploi", 15772, "fr"),
|
| 103 |
+
_tbs("TB-DISC", "Guidelines for Discipline",
|
| 104 |
+
"Lignes directrices concernant la discipline", 22370, "fr"),
|
| 105 |
+
_tbs("TB-DTA", "Duty to Accommodate Directive",
|
| 106 |
+
"Directive sur l'obligation de prendre des mesures d'adaptation",
|
| 107 |
+
32634, "fr"),
|
| 108 |
+
_tbs("TB-PERF", "Performance Management Directive",
|
| 109 |
+
"Directive sur la gestion du rendement", 27146, "fr"),
|
| 110 |
+
_tbs("TB-WPHV", "Harassment and Violence Directive",
|
| 111 |
+
"Directive sur la prévention et la résolution du harcèlement et de "
|
| 112 |
+
"la violence dans le lieu de travail", 32671, "fr"),
|
| 113 |
+
_tbs("TB-LEAVE", "Leave and Special Working Arrangements Directive",
|
| 114 |
+
"Directive sur les congés et les modalités de travail spéciales",
|
| 115 |
+
15774, "fr"),
|
| 116 |
+
_tbs("TB-PGS", "Policy on Government Security",
|
| 117 |
+
"Politique sur la sécurité du gouvernement", 16578, "fr"),
|
| 118 |
+
_tbs("TB-SEC", "Security Screening Directive",
|
| 119 |
+
"Directive sur le filtrage de sécurité", 32805, "fr"),
|
| 120 |
+
# --- CBSA / Public Safety detention policy: operational guidance, so
|
| 121 |
+
# doc_type 'memorandum' (persuasive, not binding) like the ENF chapters ---
|
| 122 |
+
{"code": "CBSA-NIDF", "doc_type": "memorandum",
|
| 123 |
+
"short": "CBSA Detention Framework",
|
| 124 |
+
"name": "CBSA National Immigration Detention Framework",
|
| 125 |
+
"url": _CBSA_DET + "nidf-cndi-eng.html", "raw": "cbsa-nidf-eng.html"},
|
| 126 |
+
{"code": "CBSA-NDDHM", "doc_type": "memorandum",
|
| 127 |
+
"short": "CBSA Minors Detention Directive",
|
| 128 |
+
"name": "CBSA National Directive for the Detention or Housing of Minors",
|
| 129 |
+
"url": _CBSA_DET + "nddhm-dndhm-eng.html", "raw": "cbsa-nddhm-eng.html"},
|
| 130 |
+
{"code": "CBSA-NIDS", "doc_type": "memorandum",
|
| 131 |
+
"short": "CBSA Detention Standards",
|
| 132 |
+
"name": "CBSA National Immigration Detention Standards (chapters 1-6)",
|
| 133 |
+
"urls": [_CBSA_DET + f"standards-normes/ch{n}-eng.html" for n in range(1, 7)],
|
| 134 |
+
"raw": "cbsa-nids-eng"},
|
| 135 |
+
# --- the IRCC applicant guide the screening chain points people to:
|
| 136 |
+
# eligibility, the five-year calculation with worked examples, fees,
|
| 137 |
+
# where to apply. Guidance (doc_type 'memorandum'), like ENF 14 / OP 19.
|
| 138 |
+
{"code": "IMM-5312", "doc_type": "memorandum", "powershell": True,
|
| 139 |
+
"short": "Guide 5312",
|
| 140 |
+
"name": "IRCC Guide 5312 -- Rehabilitation for Persons Who Are "
|
| 141 |
+
"Inadmissible to Canada Because of Past Criminal Activity "
|
| 142 |
+
"(application guide for form IMM 1444)",
|
| 143 |
+
"url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
|
| 144 |
+
"services/application/application-forms-guides/"
|
| 145 |
+
"guide-5312-rehabilitation-persons-inadmissible-canada-past-"
|
| 146 |
+
"criminal-activity.html",
|
| 147 |
+
"raw": "imm5312-guide-eng.html"},
|
| 148 |
+
# --- IRCC's applicant-facing pages on overcoming criminal
|
| 149 |
+
# inadmissibility (added 2026-09-12, roadmap T3.8). The rehabilitation
|
| 150 |
+
# memo names form IMM 1444: its landing page carries the form's
|
| 151 |
+
# supplementary instructions. The form PDF itself is an XFA form whose
|
| 152 |
+
# text layer is only a 'Please wait...' viewer shell, so it is not
|
| 153 |
+
# ingested. Applicant-facing summaries, not officer instructions:
|
| 154 |
+
# doc_type 'memorandum' like Guide 5312; the law is IRPR ss. 17-18.
|
| 155 |
+
{"code": "IMM-1444", "doc_type": "memorandum", "powershell": True,
|
| 156 |
+
"short": "IMM 1444 form page",
|
| 157 |
+
"name": "IRCC Application for Criminal Rehabilitation (IMM 1444) -- "
|
| 158 |
+
"form page with the form's supplementary instructions "
|
| 159 |
+
"(applicant-facing)",
|
| 160 |
+
"url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
|
| 161 |
+
"services/application/application-forms-guides/imm1444.html",
|
| 162 |
+
"raw": "imm1444-page-eng.html"},
|
| 163 |
+
{"code": "IRCC-PUB-OCC", "doc_type": "memorandum", "powershell": True,
|
| 164 |
+
"short": "IRCC: Overcome criminal convictions",
|
| 165 |
+
"name": "IRCC public page: Overcome criminal convictions "
|
| 166 |
+
"(applicant-facing)",
|
| 167 |
+
"url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
|
| 168 |
+
"services/admissibility-enforcement/"
|
| 169 |
+
"overcome-criminal-convictions.html",
|
| 170 |
+
"raw": "ircc-overcome-criminal-convictions-eng.html"},
|
| 171 |
+
{"code": "IRCC-PUB-DEEMED", "doc_type": "memorandum", "powershell": True,
|
| 172 |
+
"short": "IRCC: Deemed rehabilitation",
|
| 173 |
+
"name": "IRCC public page: Deemed rehabilitation (applicant-facing)",
|
| 174 |
+
"url": "https://www.canada.ca/en/immigration-refugees-citizenship/"
|
| 175 |
+
"services/admissibility-enforcement/"
|
| 176 |
+
"overcome-criminal-convictions/deemed-rehabilitation.html",
|
| 177 |
+
"raw": "ircc-deemed-rehabilitation-eng.html"},
|
| 178 |
+
{"code": "PS-MD-MINORS", "doc_type": "memorandum",
|
| 179 |
+
"short": "Ministerial Direction on Minors in Detention",
|
| 180 |
+
"name": "Ministerial Direction to the CBSA: Minors in Canada's "
|
| 181 |
+
"Immigration Detention System (Public Safety, November 2017)",
|
| 182 |
+
"url": "https://www.publicsafety.gc.ca/cnt/trnsprnc/ns-trnsprnc/"
|
| 183 |
+
"mnstrl-drctn-cbsa-en.aspx",
|
| 184 |
+
"raw": "ps-md-minors-eng.html"},
|
| 185 |
+
# --- the Canada-US Safe Third Country Agreement: a treaty implemented by
|
| 186 |
+
# IRPR ss. 159.1-159.7, so it renders as law, named as a treaty ---
|
| 187 |
+
{"code": "STCA", "doc_type": "legislation", "powershell": True,
|
| 188 |
+
"short": "STCA",
|
| 189 |
+
"name": "Safe Third Country Agreement (Canada-United States treaty, in "
|
| 190 |
+
"force 2004-12-29; implemented by IRPR ss. 159.1-159.7)",
|
| 191 |
+
"url": _STCA + "final-text.html", "raw": "stca-final-text-eng.html",
|
| 192 |
+
"current_to": "2004-12-29"},
|
| 193 |
+
{"code": "STCA-PROTOCOL", "doc_type": "legislation", "powershell": True,
|
| 194 |
+
"short": "STCA Additional Protocol",
|
| 195 |
+
"name": "Additional Protocol to the Safe Third Country Agreement "
|
| 196 |
+
"(in force 2023-03-25: claims between ports of entry)",
|
| 197 |
+
"url": _STCA + "additional-protocol.html",
|
| 198 |
+
"raw": "stca-additional-protocol-eng.html", "current_to": "2023-03-25"},
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
# canada.ca stamps the date two ways: a visible <time property="dateModified">
|
| 202 |
+
# on the WET template, and a <meta name="dcterms.modified" content="..."> on
|
| 203 |
+
# the application-guide pages (which have no visible stamp at all).
|
| 204 |
+
_MODIFIED = re.compile(
|
| 205 |
+
r'dateModified"[^>]*>(\d{4}-\d{2}-\d{2})|'
|
| 206 |
+
r'dcterms\.modified"[^>]*content="(\d{4}-\d{2}-\d{2})"')
|
| 207 |
+
_EFFECTIVE = re.compile(
|
| 208 |
+
r"(?:effective|in effect|takes? effect|comes? into (?:effect|force)|"
|
| 209 |
+
r"came into (?:effect|force))\D{0,40}"
|
| 210 |
+
r"((?:January|February|March|April|May|June|July|August|September|"
|
| 211 |
+
r"October|November|December)\s+\d{1,2},\s+\d{4})", re.I)
|
| 212 |
+
# Site chrome and navigation that sit inside <main> on canada.ca pages.
|
| 213 |
+
_SKIP = {"on this page", "page details", "contacts", "footnotes",
|
| 214 |
+
"language selection", "search", "menu", "you are here:",
|
| 215 |
+
"about this site", "government of canada", "date modified:",
|
| 216 |
+
"report a problem or mistake on this page", "share this page",
|
| 217 |
+
"government of canada activities and initiatives",
|
| 218 |
+
"about government", "topics menu", "search and menus",
|
| 219 |
+
"table of contents",
|
| 220 |
+
# canada.ca form pages: how to run Acrobat, not what the form asks
|
| 221 |
+
"how to download, save and open this form",
|
| 222 |
+
"open this form in acrobat reader",
|
| 223 |
+
# TBS policy-suite chrome
|
| 224 |
+
"supporting tools", "hierarchy", "archives", "more information",
|
| 225 |
+
# the same chrome in French
|
| 226 |
+
"sur cette page", "détails de la page", "table des matières",
|
| 227 |
+
"notes de bas de page", "sélection de la langue", "recherche",
|
| 228 |
+
"vous êtes ici :", "à propos de ce site", "gouvernement du canada",
|
| 229 |
+
"date de modification :", "signaler un problème ou une erreur sur "
|
| 230 |
+
"cette page", "partagez cette page", "activités et initiatives du "
|
| 231 |
+
"gouvernement du canada", "à propos du gouvernement",
|
| 232 |
+
"menu des sujets", "recherche et menus", "outils de soutien",
|
| 233 |
+
"hiérarchie", "renseignements supplémentaires",
|
| 234 |
+
"personnes-ressources", "coordonnées"}
|
| 235 |
+
_HEADINGS = ("h2", "h3", "h4")
|
| 236 |
+
_CONTENT = {"p", "blockquote", "table", "dl", "pre", "figure"}
|
| 237 |
+
_PIECE = 1800
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _own_text(li):
|
| 241 |
+
"""A list item's own text, without its nested lists (which are rendered
|
| 242 |
+
as their own bullets): the V&E Code nests '1.1 ...' items under a
|
| 243 |
+
'<strong>Respect For Democracy</strong>' item."""
|
| 244 |
+
return _norm(" ".join(
|
| 245 |
+
child.get_text(" ", strip=True) if getattr(child, "name", None) else str(child)
|
| 246 |
+
for child in li.children
|
| 247 |
+
if getattr(child, "name", None) not in ("ul", "ol")))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _block_text(heading):
|
| 251 |
+
"""Readable text from a heading up to the next heading of any level."""
|
| 252 |
+
lines = []
|
| 253 |
+
for sib in heading.find_next_siblings():
|
| 254 |
+
if sib.name in _HEADINGS or sib.name == "h1":
|
| 255 |
+
break
|
| 256 |
+
if sib.name in ("ul", "ol"):
|
| 257 |
+
for li in sib.find_all("li"):
|
| 258 |
+
item = _own_text(li)
|
| 259 |
+
if item:
|
| 260 |
+
lines.append(f"- {item}")
|
| 261 |
+
elif sib.name == "dl":
|
| 262 |
+
# Definition lists (the Directive's Part 1 is one 9 KB <dl>):
|
| 263 |
+
# one 'term: definition' line each, so the block can split.
|
| 264 |
+
term = ""
|
| 265 |
+
for el in sib.find_all(["dt", "dd"]):
|
| 266 |
+
text = _norm(el.get_text(" ", strip=True))
|
| 267 |
+
if el.name == "dt":
|
| 268 |
+
term = text
|
| 269 |
+
elif text:
|
| 270 |
+
lines.append(f"{term}: {text}" if term else text)
|
| 271 |
+
elif sib.name in _CONTENT:
|
| 272 |
+
text = _norm(sib.get_text(" ", strip=True))
|
| 273 |
+
if text:
|
| 274 |
+
lines.append(text)
|
| 275 |
+
return "\n".join(lines)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def parse(html, doc, url=None, start=0):
|
| 279 |
+
"""Chunks for one page of a document: one per h2/h3/h4 heading with body
|
| 280 |
+
text, part = the enclosing h2 (chapter). `start` continues the section
|
| 281 |
+
numbering across a multi-page document. Pure, for tests."""
|
| 282 |
+
url = url or doc.get("url") or doc["urls"][0]
|
| 283 |
+
lang = doc.get("lang", "en")
|
| 284 |
+
prefix = f"policy-fr-{doc['code'].lower()}" if lang == "fr" else \
|
| 285 |
+
f"policy-{doc['code'].lower()}"
|
| 286 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 287 |
+
main = soup.find("main") or soup
|
| 288 |
+
# canada.ca wraps sections in div/section; the TBS policy suite wraps
|
| 289 |
+
# each chapter in <details><summary><h2>..</h2></summary><div>..</div>
|
| 290 |
+
# and appendix headings in <section><header><h3>..</h3></header>.
|
| 291 |
+
# Flatten all of it so each heading and its content become siblings.
|
| 292 |
+
for tag in main.find_all(["section", "div", "article", "details",
|
| 293 |
+
"summary", "header"]):
|
| 294 |
+
tag.unwrap()
|
| 295 |
+
mod = _MODIFIED.search(html)
|
| 296 |
+
# A page without a dateModified stamp (the STCA texts) takes the date
|
| 297 |
+
# the DOCS entry pins -- for a treaty, its in-force date.
|
| 298 |
+
modified = ((mod.group(1) or mod.group(2)) if mod
|
| 299 |
+
else doc.get("current_to", ""))
|
| 300 |
+
eff = _EFFECTIVE.search(main.get_text(" ", strip=True))
|
| 301 |
+
effective = f"Effective {eff.group(1)}" if eff else ""
|
| 302 |
+
chunks, chapter, n = [], "", start
|
| 303 |
+
for h in main.find_all(list(_HEADINGS)):
|
| 304 |
+
title = re.sub(r"\s*\[\d+\]", "", _norm(h.get_text(" ", strip=True)))
|
| 305 |
+
if not title or title.lower() in _SKIP:
|
| 306 |
+
continue
|
| 307 |
+
if h.name == "h2":
|
| 308 |
+
chapter = title
|
| 309 |
+
body = _block_text(h)
|
| 310 |
+
if len(body) < 80:
|
| 311 |
+
continue
|
| 312 |
+
n += 1
|
| 313 |
+
pieces = split_lines(body, _PIECE) if len(body) > 2400 else [body]
|
| 314 |
+
total = len(pieces)
|
| 315 |
+
for k, piece in enumerate(pieces, start=1):
|
| 316 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 317 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 318 |
+
chunk = {
|
| 319 |
+
"id": f"{prefix}-{n}{suffix}",
|
| 320 |
+
"doc_type": doc.get("doc_type", "policy"),
|
| 321 |
+
"act_code": doc["code"],
|
| 322 |
+
"act_short": doc["short"],
|
| 323 |
+
"act_name": doc["name"],
|
| 324 |
+
"section": title,
|
| 325 |
+
"marginal_note": title,
|
| 326 |
+
"part": chapter if h.name != "h2" else "",
|
| 327 |
+
"division": "",
|
| 328 |
+
"heading": "",
|
| 329 |
+
"text": piece,
|
| 330 |
+
"history": effective,
|
| 331 |
+
"last_amended": modified,
|
| 332 |
+
"in_force": "",
|
| 333 |
+
"status": "in force",
|
| 334 |
+
"current_to": modified,
|
| 335 |
+
"citation": f"{doc['short']}{note}",
|
| 336 |
+
"source_url": url,
|
| 337 |
+
}
|
| 338 |
+
if lang == "fr":
|
| 339 |
+
chunk["lang"] = "fr"
|
| 340 |
+
chunks.append(chunk)
|
| 341 |
+
return chunks
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _fetch_doc(doc, force=False):
|
| 345 |
+
"""[(url, html)] for every page of the document."""
|
| 346 |
+
urls = doc.get("urls") or [doc["url"]]
|
| 347 |
+
pages = []
|
| 348 |
+
for i, url in enumerate(urls, start=1):
|
| 349 |
+
raw = doc["raw"] if len(urls) == 1 else f"{doc['raw']}-{i}.html"
|
| 350 |
+
html = fetch_cached(url, RAW / raw, pause=1.0, force=force,
|
| 351 |
+
powershell=doc.get("powershell", False))
|
| 352 |
+
pages.append((url, html.decode("utf-8", "replace")))
|
| 353 |
+
return pages
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def build(allow_shrink=False, force=False):
|
| 357 |
+
chunks, failures = [], []
|
| 358 |
+
for doc in DOCS:
|
| 359 |
+
key = (doc["code"], doc.get("lang", "en"))
|
| 360 |
+
try:
|
| 361 |
+
pages = _fetch_doc(doc, force=force)
|
| 362 |
+
except Exception as exc:
|
| 363 |
+
print(f" !! {doc['short']} ({key[1]}): fetch failed "
|
| 364 |
+
f"({type(exc).__name__}: {exc})")
|
| 365 |
+
failures.append(key)
|
| 366 |
+
continue
|
| 367 |
+
got = []
|
| 368 |
+
for url, html in pages:
|
| 369 |
+
got += parse(html, doc, url=url, start=len(got))
|
| 370 |
+
if not got:
|
| 371 |
+
# Fetched, parsed to nothing: the page template moved. Keep the
|
| 372 |
+
# stored copy rather than dropping the instrument.
|
| 373 |
+
print(f" !! {doc['short']} ({key[1]}): no content parsed; "
|
| 374 |
+
f"keeping stored copy")
|
| 375 |
+
failures.append(key)
|
| 376 |
+
continue
|
| 377 |
+
chunks += got
|
| 378 |
+
print(f" {doc['short']} ({key[1]}): {len(got)} chunks, modified "
|
| 379 |
+
f"{got[0]['current_to'] or '?'}")
|
| 380 |
+
chunks, preserved = preserve_dropped(
|
| 381 |
+
chunks, stored_chunks(OUT_FILE),
|
| 382 |
+
key=lambda c: (c["act_code"], c.get("lang", "en")),
|
| 383 |
+
only=set(failures))
|
| 384 |
+
if preserved:
|
| 385 |
+
print(f" kept the last-good copy of: "
|
| 386 |
+
f"{', '.join(f'{c} ({l})' for c, l in preserved)}")
|
| 387 |
+
uniquify_ids(chunks)
|
| 388 |
+
if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
|
| 389 |
+
return False
|
| 390 |
+
print(f"conduct: {len(chunks)} chunks -> {OUT_FILE.name}")
|
| 391 |
+
return chunks
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def main():
|
| 395 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 396 |
+
force="--force" in sys.argv) is not False
|
| 397 |
+
sys.exit(0 if ok else 1)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
if __name__ == "__main__":
|
| 401 |
+
main()
|
canlex/config.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# The Income Tax Act: added 2026-09-10 so the equivalency table's
|
| 76 |
+
# tax-evasion pairing (US 26 USC 7201 / 7206 -> ITA s. 239) rests on
|
| 77 |
+
# corpus-verified text like every other maximum. The largest instrument
|
| 78 |
+
# in the corpus (~14 MB of XML per language); its vocabulary ('resident',
|
| 79 |
+
# 'employee', 'benefit', 'duty', 'trust') overlaps the border and labour
|
| 80 |
+
# families, so it is measured question by question at every ingest.
|
| 81 |
+
"I-3.3": {
|
| 82 |
+
"short": "ITA", "name": "Income Tax Act", "kind": "act",
|
| 83 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-3.3.xml",
|
| 84 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-3.3/"},
|
| 85 |
+
"CRC-604": {
|
| 86 |
+
"short": "Import Control List", "name": "Import Control List",
|
| 87 |
+
"kind": "regulation",
|
| 88 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.,_c._604.xml",
|
| 89 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.,_c._604/"},
|
| 90 |
+
"SOR-89-202": {
|
| 91 |
+
"short": "Export Control List", "name": "Export Control List",
|
| 92 |
+
"kind": "regulation",
|
| 93 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-89-202.xml",
|
| 94 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-89-202/"},
|
| 95 |
+
"SOR-2003-323": {
|
| 96 |
+
"short": "Presentation of Persons Regs",
|
| 97 |
+
"name": "Presentation of Persons (2003) Regulations", "kind": "regulation",
|
| 98 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-323.xml",
|
| 99 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-323/"},
|
| 100 |
+
"SOR-86-1072": {
|
| 101 |
+
"short": "Duty Free Shop Regs", "name": "Duty Free Shop Regulations",
|
| 102 |
+
"kind": "regulation",
|
| 103 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1072.xml",
|
| 104 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1072/"},
|
| 105 |
+
"SOR-86-1065": {
|
| 106 |
+
"short": "Sufferance Warehouses Regs",
|
| 107 |
+
"name": "Customs Sufferance Warehouses Regulations", "kind": "regulation",
|
| 108 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1065.xml",
|
| 109 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1065/"},
|
| 110 |
+
"S-8.35": {
|
| 111 |
+
"short": "Luxury Tax Act", "name": "Select Luxury Items Tax Act", "kind": "act",
|
| 112 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-8.35.xml",
|
| 113 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-8.35/"},
|
| 114 |
+
"C-1.4": {
|
| 115 |
+
"short": "CBSA Act", "name": "Canada Border Services Agency Act", "kind": "act",
|
| 116 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-1.4.xml",
|
| 117 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-1.4/"},
|
| 118 |
+
"S-15": {
|
| 119 |
+
"short": "SIMA", "name": "Special Import Measures Act", "kind": "act",
|
| 120 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-15.xml",
|
| 121 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-15/"},
|
| 122 |
+
"E-19": {
|
| 123 |
+
"short": "EIPA", "name": "Export and Import Permits Act", "kind": "act",
|
| 124 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/E-19.xml",
|
| 125 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/e-19/"},
|
| 126 |
+
"P-19.32": {
|
| 127 |
+
"short": "Preclearance Act", "name": "Preclearance Act, 2016", "kind": "act",
|
| 128 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-19.32.xml",
|
| 129 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-19.32/"},
|
| 130 |
+
# Customs Act operational regulations -- the procedural detail behind the
|
| 131 |
+
# bare Act (reporting, accounting, exemptions, and the designated-provisions
|
| 132 |
+
# regulation that is the legal hook for every AMPS monetary penalty).
|
| 133 |
+
"SOR-86-873": {
|
| 134 |
+
"short": "Reporting of Imported Goods Regs",
|
| 135 |
+
"name": "Reporting of Imported Goods Regulations", "kind": "regulation",
|
| 136 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-873.xml",
|
| 137 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-873/"},
|
| 138 |
+
"SOR-86-1062": {
|
| 139 |
+
"short": "Accounting for Imported Goods Regs",
|
| 140 |
+
"name": "Accounting for Imported Goods and Payment of Duties Regulations",
|
| 141 |
+
"kind": "regulation",
|
| 142 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-1062.xml",
|
| 143 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-1062/"},
|
| 144 |
+
"SOR-2002-336": {
|
| 145 |
+
"short": "Designated Provisions (Customs) Regs",
|
| 146 |
+
"name": "Designated Provisions (Customs) Regulations", "kind": "regulation",
|
| 147 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-336.xml",
|
| 148 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-336/"},
|
| 149 |
+
"SOR-96-46": {
|
| 150 |
+
"short": "Customs Bonded Warehouses Regs",
|
| 151 |
+
"name": "Customs Bonded Warehouses Regulations", "kind": "regulation",
|
| 152 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-46.xml",
|
| 153 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-46/"},
|
| 154 |
+
"SOR-98-58": {
|
| 155 |
+
"short": "Returning Persons Exemption Regs",
|
| 156 |
+
"name": "Returning Persons Exemption Regulations", "kind": "regulation",
|
| 157 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-58.xml",
|
| 158 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-58/"},
|
| 159 |
+
|
| 160 |
+
# --- Criminal & financial crime ---
|
| 161 |
+
"C-46": {
|
| 162 |
+
"short": "Criminal Code", "name": "Criminal Code", "kind": "act",
|
| 163 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-46.xml",
|
| 164 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-46/"},
|
| 165 |
+
"F-11.6": {
|
| 166 |
+
"short": "Firearms Act", "name": "Firearms Act", "kind": "act",
|
| 167 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.6.xml",
|
| 168 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11.6/"},
|
| 169 |
+
# Made under Criminal Code s. 117.15: the model-by-model prohibited/
|
| 170 |
+
# restricted/non-restricted classifications (incl. the May 2020
|
| 171 |
+
# SOR/2020-96 prohibitions). Nearly all substance is in its Schedules --
|
| 172 |
+
# this entry is why the schedule parser exists.
|
| 173 |
+
"SOR-98-462": {
|
| 174 |
+
"short": "Firearms Classification Regs",
|
| 175 |
+
"name": ("Regulations Prescribing Certain Firearms and Other Weapons, "
|
| 176 |
+
"Components and Parts of Weapons, Accessories, Cartridge "
|
| 177 |
+
"Magazines, Ammunition and Projectiles as Prohibited, "
|
| 178 |
+
"Restricted or Non-Restricted"), "kind": "regulation",
|
| 179 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-462.xml",
|
| 180 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-462/"},
|
| 181 |
+
"P-24.501": {
|
| 182 |
+
"short": "PCMLTFA",
|
| 183 |
+
"name": "Proceeds of Crime (Money Laundering) and Terrorist Financing Act",
|
| 184 |
+
"kind": "act",
|
| 185 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-24.501.xml",
|
| 186 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-24.501/"},
|
| 187 |
+
"SOR-2002-412": {
|
| 188 |
+
"short": "Cross-border Currency Reporting Regs",
|
| 189 |
+
"name": "Cross-border Currency and Monetary Instruments Reporting Regulations",
|
| 190 |
+
"kind": "regulation",
|
| 191 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-412.xml",
|
| 192 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-412/"},
|
| 193 |
+
"S-6.9": {
|
| 194 |
+
"short": "SCIDA", "name": "Security of Canada Information Disclosure Act",
|
| 195 |
+
"kind": "act",
|
| 196 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.9.xml",
|
| 197 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.9/"},
|
| 198 |
+
|
| 199 |
+
# --- Controlled substances & cannabis ---
|
| 200 |
+
"C-38.8": {
|
| 201 |
+
"short": "CDSA", "name": "Controlled Drugs and Substances Act", "kind": "act",
|
| 202 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.8.xml",
|
| 203 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.8/"},
|
| 204 |
+
"SOR-97-234": {
|
| 205 |
+
"short": "CDSA Police Enforcement Regs",
|
| 206 |
+
"name": "Controlled Drugs and Substances Act (Police Enforcement) Regulations",
|
| 207 |
+
"kind": "regulation",
|
| 208 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-234.xml",
|
| 209 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-234/"},
|
| 210 |
+
"SOR-2000-217": {
|
| 211 |
+
"short": "Benzodiazepines Regs",
|
| 212 |
+
"name": "Benzodiazepines and Other Targeted Substances Regulations",
|
| 213 |
+
"kind": "regulation",
|
| 214 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2000-217.xml",
|
| 215 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2000-217/"},
|
| 216 |
+
"C.R.C.,_c._1041": {
|
| 217 |
+
"short": "Narcotic Control Regs", "name": "Narcotic Control Regulations",
|
| 218 |
+
"kind": "regulation",
|
| 219 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._1041.xml",
|
| 220 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._1041/"},
|
| 221 |
+
"SOR-2012-230": {
|
| 222 |
+
"short": "New Classes of Practitioners Regs",
|
| 223 |
+
"name": "New Classes of Practitioners Regulations", "kind": "regulation",
|
| 224 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2012-230.xml",
|
| 225 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2012-230/"},
|
| 226 |
+
"SOR-2002-359": {
|
| 227 |
+
"short": "Precursor Control Regs", "name": "Precursor Control Regulations",
|
| 228 |
+
"kind": "regulation",
|
| 229 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2002-359.xml",
|
| 230 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2002-359/"},
|
| 231 |
+
"SOR-97-229": {
|
| 232 |
+
"short": "Precursor & Substance Exemption Regs",
|
| 233 |
+
"name": "Regulations Exempting Certain Precursors and Controlled Substances "
|
| 234 |
+
"from the Application of the Controlled Drugs and Substances Act",
|
| 235 |
+
"kind": "regulation",
|
| 236 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-97-229.xml",
|
| 237 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-97-229/"},
|
| 238 |
+
"C-24.5": {
|
| 239 |
+
"short": "Cannabis Act", "name": "Cannabis Act", "kind": "act",
|
| 240 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-24.5.xml",
|
| 241 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-24.5/"},
|
| 242 |
+
"SOR-2018-144": {
|
| 243 |
+
"short": "Cannabis Regs", "name": "Cannabis Regulations", "kind": "regulation",
|
| 244 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-144.xml",
|
| 245 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-144/"},
|
| 246 |
+
|
| 247 |
+
# --- Food, health, agriculture ---
|
| 248 |
+
"F-27": {
|
| 249 |
+
"short": "Food and Drugs Act", "name": "Food and Drugs Act", "kind": "act",
|
| 250 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-27.xml",
|
| 251 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-27/"},
|
| 252 |
+
"C.R.C.,_c._870": {
|
| 253 |
+
"short": "Food and Drug Regs", "name": "Food and Drug Regulations",
|
| 254 |
+
"kind": "regulation",
|
| 255 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._870.xml",
|
| 256 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._870/"},
|
| 257 |
+
"H-3.3": {
|
| 258 |
+
"short": "Health of Animals Act", "name": "Health of Animals Act", "kind": "act",
|
| 259 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-3.3.xml",
|
| 260 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-3.3/"},
|
| 261 |
+
"C.R.C.,_c._296": {
|
| 262 |
+
"short": "Health of Animals Regs", "name": "Health of Animals Regulations",
|
| 263 |
+
"kind": "regulation",
|
| 264 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C.R.C.%2C_c._296.xml",
|
| 265 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/C.R.C.%2C_c._296/"},
|
| 266 |
+
"P-14.8": {
|
| 267 |
+
"short": "Plant Protection Act", "name": "Plant Protection Act", "kind": "act",
|
| 268 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-14.8.xml",
|
| 269 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-14.8/"},
|
| 270 |
+
"SOR-95-212": {
|
| 271 |
+
"short": "Plant Protection Regs", "name": "Plant Protection Regulations",
|
| 272 |
+
"kind": "regulation",
|
| 273 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-95-212.xml",
|
| 274 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-95-212/"},
|
| 275 |
+
"SOR-2018-108": {
|
| 276 |
+
"short": "Safe Food for Canadians Regs",
|
| 277 |
+
"name": "Safe Food for Canadians Regulations", "kind": "regulation",
|
| 278 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2018-108.xml",
|
| 279 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2018-108/"},
|
| 280 |
+
"A-8.8": {
|
| 281 |
+
"short": "AAAMPA",
|
| 282 |
+
"name": "Agriculture and Agri-Food Administrative Monetary Penalties Act",
|
| 283 |
+
"kind": "act",
|
| 284 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-8.8.xml",
|
| 285 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-8.8/"},
|
| 286 |
+
"Q-1.1": {
|
| 287 |
+
"short": "Quarantine Act", "name": "Quarantine Act", "kind": "act",
|
| 288 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Q-1.1.xml",
|
| 289 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/q-1.1/"},
|
| 290 |
+
# CITES enforcement at the border -- the basis for seizures of protected
|
| 291 |
+
# wildlife, ivory and exotic-species goods.
|
| 292 |
+
"W-8.5": {
|
| 293 |
+
"short": "WAPPRIITA",
|
| 294 |
+
"name": "Wild Animal and Plant Protection and Regulation of "
|
| 295 |
+
"International and Interprovincial Trade Act", "kind": "act",
|
| 296 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/W-8.5.xml",
|
| 297 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/w-8.5/"},
|
| 298 |
+
"SOR-96-263": {
|
| 299 |
+
"short": "WAPTR",
|
| 300 |
+
"name": "Wild Animal and Plant Trade Regulations", "kind": "regulation",
|
| 301 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-263.xml",
|
| 302 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-263/"},
|
| 303 |
+
|
| 304 |
+
# --- Labour & public service ---
|
| 305 |
+
# CHRA grounds the accommodation/discrimination case law already in the
|
| 306 |
+
# corpus (Renaud, Hydro-Québec, Elk Valley); GECA governs injury-on-duty
|
| 307 |
+
# compensation interlocking with the FB agreement's IOD leave.
|
| 308 |
+
"H-6": {
|
| 309 |
+
"short": "CHRA", "name": "Canadian Human Rights Act", "kind": "act",
|
| 310 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/H-6.xml",
|
| 311 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/h-6/"},
|
| 312 |
+
"G-5": {
|
| 313 |
+
"short": "GECA", "name": "Government Employees Compensation Act",
|
| 314 |
+
"kind": "act",
|
| 315 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/G-5.xml",
|
| 316 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/g-5/"},
|
| 317 |
+
"L-2": {
|
| 318 |
+
"short": "Canada Labour Code", "name": "Canada Labour Code", "kind": "act",
|
| 319 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/L-2.xml",
|
| 320 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/l-2/"},
|
| 321 |
+
# The whole federal harassment/violence process (notice of occurrence,
|
| 322 |
+
# designated recipient, resolution, investigation, timelines) lives in
|
| 323 |
+
# these Regulations since 2021, not in CLC Part II itself; Marentette
|
| 324 |
+
# 2024 FC 676 (in the corpus) interprets them.
|
| 325 |
+
"SOR-2020-130": {
|
| 326 |
+
"short": "WPHV Regs",
|
| 327 |
+
"name": "Work Place Harassment and Violence Prevention Regulations",
|
| 328 |
+
"kind": "regulation",
|
| 329 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2020-130.xml",
|
| 330 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2020-130/"},
|
| 331 |
+
# Part II of the Code binds the federal public administration (s. 123(2));
|
| 332 |
+
# these two Regulations give its duties their content -- first aid,
|
| 333 |
+
# lighting, sanitation, hazardous substances, confined spaces (COHSR) --
|
| 334 |
+
# and constitute the policy/work-place committees a steward sits on.
|
| 335 |
+
"SOR-86-304": {
|
| 336 |
+
"short": "COHSR",
|
| 337 |
+
"name": "Canada Occupational Health and Safety Regulations",
|
| 338 |
+
"kind": "regulation",
|
| 339 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-86-304.xml",
|
| 340 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-86-304/"},
|
| 341 |
+
"SOR-2015-164": {
|
| 342 |
+
"short": "OHS Committees Regs",
|
| 343 |
+
"name": "Policy Committees, Work Place Committees and Health and "
|
| 344 |
+
"Safety Representatives Regulations",
|
| 345 |
+
"kind": "regulation",
|
| 346 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2015-164.xml",
|
| 347 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2015-164/"},
|
| 348 |
+
"P-33.3": {
|
| 349 |
+
"short": "FPSLRA", "name": "Federal Public Sector Labour Relations Act",
|
| 350 |
+
"kind": "act",
|
| 351 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.3.xml",
|
| 352 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.3/"},
|
| 353 |
+
"P-33.35": {
|
| 354 |
+
"short": "FPSLREB Act",
|
| 355 |
+
"name": "Federal Public Sector Labour Relations and Employment Board Act",
|
| 356 |
+
"kind": "act",
|
| 357 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.35.xml",
|
| 358 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.35/"},
|
| 359 |
+
"P-33.01": {
|
| 360 |
+
"short": "PSEA", "name": "Public Service Employment Act", "kind": "act",
|
| 361 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-33.01.xml",
|
| 362 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-33.01/"},
|
| 363 |
+
"P-36": {
|
| 364 |
+
"short": "PSSA", "name": "Public Service Superannuation Act", "kind": "act",
|
| 365 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-36.xml",
|
| 366 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-36/"},
|
| 367 |
+
|
| 368 |
+
# --- Privacy ---
|
| 369 |
+
# --- Public-service labour relations, discipline and disclosure ---
|
| 370 |
+
# The FPSLRA hands grievance time limits and adjudication procedure to
|
| 371 |
+
# its Regulations; FAA s. 12 is the legal authority for discipline in a
|
| 372 |
+
# department (and s. 155 the set-off behind overpayment recovery); the
|
| 373 |
+
# PSDPA underlies the CBSA Code's disclosure chapter and the V&E Code.
|
| 374 |
+
"SOR-2005-79": {
|
| 375 |
+
"short": "FPSLR Regulations",
|
| 376 |
+
"name": "Federal Public Sector Labour Relations Regulations",
|
| 377 |
+
"kind": "regulation",
|
| 378 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-79.xml",
|
| 379 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-79/"},
|
| 380 |
+
"SOR-2005-334": {
|
| 381 |
+
"short": "PSER",
|
| 382 |
+
"name": "Public Service Employment Regulations",
|
| 383 |
+
"kind": "regulation",
|
| 384 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-334.xml",
|
| 385 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-334/"},
|
| 386 |
+
"F-11": {
|
| 387 |
+
"short": "FAA",
|
| 388 |
+
"name": "Financial Administration Act",
|
| 389 |
+
"kind": "act",
|
| 390 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/F-11.xml",
|
| 391 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/f-11/"},
|
| 392 |
+
"P-31.9": {
|
| 393 |
+
"short": "PSDPA",
|
| 394 |
+
"name": "Public Servants Disclosure Protection Act",
|
| 395 |
+
"kind": "act",
|
| 396 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-31.9.xml",
|
| 397 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-31.9/"},
|
| 398 |
+
"O-3.01": {
|
| 399 |
+
"short": "OLA",
|
| 400 |
+
"name": "Official Languages Act",
|
| 401 |
+
"kind": "act",
|
| 402 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/O-3.01.xml",
|
| 403 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/o-3.01/"},
|
| 404 |
+
"I-21": {
|
| 405 |
+
"short": "Interpretation Act",
|
| 406 |
+
"name": "Interpretation Act",
|
| 407 |
+
"kind": "act",
|
| 408 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/I-21.xml",
|
| 409 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/i-21/"},
|
| 410 |
+
# --- Criminal records and youth: the other half of IRPA s. 36(3)(b),
|
| 411 |
+
# which lifts inadmissibility only while a record suspension 'has not
|
| 412 |
+
# ceased to have effect or been revoked' under the Criminal Records Act.
|
| 413 |
+
"C-47": {
|
| 414 |
+
"short": "Criminal Records Act",
|
| 415 |
+
"name": "Criminal Records Act",
|
| 416 |
+
"kind": "act",
|
| 417 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-47.xml",
|
| 418 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-47/",
|
| 419 |
+
"skip_schedules": True}, # its offence-list schedules are keyword magnets
|
| 420 |
+
"Y-1.5": {
|
| 421 |
+
"short": "YCJA",
|
| 422 |
+
"name": "Youth Criminal Justice Act",
|
| 423 |
+
"kind": "act",
|
| 424 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/Y-1.5.xml",
|
| 425 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/y-1.5/"},
|
| 426 |
+
# --- Contraventions: IRPA s. 36(3)(e)(i) bars basing s. 36 inadmissibility
|
| 427 |
+
# on an offence 'designated as a contravention under the Contraventions
|
| 428 |
+
# Act'. The designations are the Regulations' schedules (skip_schedules
|
| 429 |
+
# stays off), so both are needed to check that caveat against corpus
|
| 430 |
+
# text. Added 2026-09-12 (roadmap T3.8).
|
| 431 |
+
"C-38.7": {
|
| 432 |
+
"short": "Contraventions Act",
|
| 433 |
+
"name": "Contraventions Act",
|
| 434 |
+
"kind": "act",
|
| 435 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/C-38.7.xml",
|
| 436 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/c-38.7/"},
|
| 437 |
+
"SOR-96-313": {
|
| 438 |
+
"short": "Contraventions Regs",
|
| 439 |
+
"name": "Contraventions Regulations",
|
| 440 |
+
"kind": "regulation",
|
| 441 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-96-313.xml",
|
| 442 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-96-313/"},
|
| 443 |
+
# --- Sanctions: IRPA s. 35(1)(c)-(e) inadmissibility is defined by
|
| 444 |
+
# reference to these Acts, and the CBSA enforces sanctioned goods.
|
| 445 |
+
"S-14.5": {
|
| 446 |
+
"short": "SEMA",
|
| 447 |
+
"name": "Special Economic Measures Act",
|
| 448 |
+
"kind": "act",
|
| 449 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-14.5.xml",
|
| 450 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-14.5/"},
|
| 451 |
+
"U-2": {
|
| 452 |
+
"short": "United Nations Act",
|
| 453 |
+
"name": "United Nations Act",
|
| 454 |
+
"kind": "act",
|
| 455 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/U-2.xml",
|
| 456 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/u-2/"},
|
| 457 |
+
"J-2.3": {
|
| 458 |
+
"short": "JVCFOA",
|
| 459 |
+
"name": "Justice for Victims of Corrupt Foreign Officials Act (Sergei Magnitsky Law)",
|
| 460 |
+
"kind": "act",
|
| 461 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/J-2.3.xml",
|
| 462 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/j-2.3/"},
|
| 463 |
+
# --- Border regulations the D-memos and Preclearance Act point at ---
|
| 464 |
+
"SOR-2019-183": {
|
| 465 |
+
"short": "Preclearance in Canada Regs",
|
| 466 |
+
"name": "Preclearance in Canada Regulations",
|
| 467 |
+
"kind": "regulation",
|
| 468 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2019-183.xml",
|
| 469 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2019-183/"},
|
| 470 |
+
"SOR-2003-219": {
|
| 471 |
+
"short": "Passenger Information Regs",
|
| 472 |
+
"name": "Passenger Information (Customs) Regulations",
|
| 473 |
+
"kind": "regulation",
|
| 474 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2003-219.xml",
|
| 475 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2003-219/"},
|
| 476 |
+
"SOR-2005-23": {
|
| 477 |
+
"short": "Reporting of Exported Goods Regs",
|
| 478 |
+
"name": "Reporting of Exported Goods Regulations",
|
| 479 |
+
"kind": "regulation",
|
| 480 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2005-23.xml",
|
| 481 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2005-23/"},
|
| 482 |
+
"SOR-98-209": {
|
| 483 |
+
"short": "Firearms Storage & Transport Regs",
|
| 484 |
+
"name": "Storage, Display, Transportation and Handling of Firearms by Individuals Regulations",
|
| 485 |
+
"kind": "regulation",
|
| 486 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-98-209.xml",
|
| 487 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-98-209/"},
|
| 488 |
+
# --- Aviation security: the airport side of an officer's work. The
|
| 489 |
+
# Preclearance Act and its Regulations point at the aerodromes listed in
|
| 490 |
+
# the CASR 2012; the Aeronautics Act is their enabling Act; the Secure Air
|
| 491 |
+
# Travel Act and Regulations are the 'no-fly' list regime. The Identity
|
| 492 |
+
# Screening Regulations (SOR/2007-82) they replaced are wholly repealed
|
| 493 |
+
# (SOR/2015-182, s. 2) and are not added. Added 2026-10-12.
|
| 494 |
+
"A-2": {
|
| 495 |
+
"short": "Aeronautics Act", "name": "Aeronautics Act", "kind": "act",
|
| 496 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/A-2.xml",
|
| 497 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/a-2/"},
|
| 498 |
+
"SOR-2011-318": {
|
| 499 |
+
"short": "CASR 2012",
|
| 500 |
+
"name": "Canadian Aviation Security Regulations, 2012",
|
| 501 |
+
"kind": "regulation",
|
| 502 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2011-318.xml",
|
| 503 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2011-318/"},
|
| 504 |
+
"S-6.7": {
|
| 505 |
+
"short": "SATA", "name": "Secure Air Travel Act", "kind": "act",
|
| 506 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/S-6.7.xml",
|
| 507 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/s-6.7/"},
|
| 508 |
+
"SOR-2015-181": {
|
| 509 |
+
"short": "Secure Air Travel Regs",
|
| 510 |
+
"name": "Secure Air Travel Regulations",
|
| 511 |
+
"kind": "regulation",
|
| 512 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/SOR-2015-181.xml",
|
| 513 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/regulations/SOR-2015-181/"},
|
| 514 |
+
"P-21": {
|
| 515 |
+
"short": "Privacy Act", "name": "Privacy Act", "kind": "act",
|
| 516 |
+
"xml_url": "https://laws-lois.justice.gc.ca/eng/XML/P-21.xml",
|
| 517 |
+
"web_base": "https://laws-lois.justice.gc.ca/eng/acts/p-21/"},
|
| 518 |
+
}
|
canlex/curated_checks.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The curated tables' statute phrases, checked against the corpus (roadmap T4.3).
|
| 2 |
+
|
| 3 |
+
The curated tables pin the statute text they rely on with `verify` rows --
|
| 4 |
+
(act, section, contains): the phrase must appear in that section as
|
| 5 |
+
ingested. This module is the one implementation of that check. It is used by
|
| 6 |
+
tests/test_curated_penalties.py and by `py -m canlex.refresh --check`
|
| 7 |
+
(refresh.check_curated_sections), and is meant for tools/predeploy.py's gate
|
| 8 |
+
(run_checks + gate). The refresh-drift helpers corpus_reader, verify_sections
|
| 9 |
+
and refresh_drift moved here from tools/predeploy.py (wave 2); until predeploy
|
| 10 |
+
and tools/compose_release.py import them from here, predeploy carries its own
|
| 11 |
+
copy, and tests/test_refresh.py checks that the two agree.
|
| 12 |
+
|
| 13 |
+
After a corpus refresh a verify row can show two signals:
|
| 14 |
+
|
| 15 |
+
- broken: the phrase is no longer in its section, or the section is gone.
|
| 16 |
+
The table quotes text the statute no longer has.
|
| 17 |
+
- amended: the section's last_amended (the corpus's own amendment date, read
|
| 18 |
+
with the section's `history` citation) is later than the date the review
|
| 19 |
+
saw. That seen date is the section's last_amended in the corpus of the
|
| 20 |
+
approved commit (data/curated/review_state.json), so `last_amended_seen`
|
| 21 |
+
advances when the maintainer approves a new baseline. It is reported per
|
| 22 |
+
verify row and never written into the curated JSON, which is held for
|
| 23 |
+
review.
|
| 24 |
+
|
| 25 |
+
When an amended section's stated maximum changed in a subsection a verify row
|
| 26 |
+
quotes, draft_history proposes a penalty_history row: the maximum before the
|
| 27 |
+
amendment, until the day before the amendment. Under Tran, an offence
|
| 28 |
+
committed before the amendment keeps the maximum then in force. No row is
|
| 29 |
+
drafted when a row the pairing already carries covers the amendment. The
|
| 30 |
+
drafts go to data/curated/drafts/ for review. Nothing here edits
|
| 31 |
+
data/curated/*.json.
|
| 32 |
+
"""
|
| 33 |
+
import datetime
|
| 34 |
+
import json
|
| 35 |
+
import re
|
| 36 |
+
import subprocess
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 40 |
+
CURATED = ROOT / "data" / "curated"
|
| 41 |
+
DRAFTS = CURATED / "drafts"
|
| 42 |
+
REVIEW_STATE = CURATED / "review_state.json"
|
| 43 |
+
EQUIVALENCY = "us_equivalency.json"
|
| 44 |
+
TRANSBORDER = "transborder.json"
|
| 45 |
+
DRAFT_GLOB = "penalty-history-draft-*.json"
|
| 46 |
+
|
| 47 |
+
PASS, WARN, FAIL = "PASS", "WARN", "FAIL" # tools/predeploy.py's statuses
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---- corpus access (working tree or a git commit) -----------------------------
|
| 51 |
+
|
| 52 |
+
def git_blob(commit, path, root=ROOT):
|
| 53 |
+
"""The bytes of `path` at `commit`, or None when git cannot show it."""
|
| 54 |
+
try:
|
| 55 |
+
out = subprocess.run(["git", "-C", str(root), "show", f"{commit}:{path}"],
|
| 56 |
+
capture_output=True, timeout=300)
|
| 57 |
+
except (OSError, subprocess.SubprocessError):
|
| 58 |
+
return None
|
| 59 |
+
return out.stdout if out.returncode == 0 else None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def lfs_content(blob, root=ROOT):
|
| 63 |
+
"""The content an LFS pointer names, read from the LOCAL LFS store (never
|
| 64 |
+
fetched), or None when it is not there. A non-pointer blob is returned
|
| 65 |
+
as is (the same rule as tools/predeploy.py lfs_object)."""
|
| 66 |
+
if blob is None or not blob.startswith(b"version https://git-lfs"):
|
| 67 |
+
return blob
|
| 68 |
+
oid = next((l.split(b":", 1)[1].strip().decode() for l in blob.splitlines()
|
| 69 |
+
if l.startswith(b"oid sha256:")), None)
|
| 70 |
+
if not oid:
|
| 71 |
+
return None
|
| 72 |
+
try:
|
| 73 |
+
out = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"],
|
| 74 |
+
capture_output=True, timeout=60)
|
| 75 |
+
except (OSError, subprocess.SubprocessError):
|
| 76 |
+
return None
|
| 77 |
+
common = Path(out.stdout.decode().strip())
|
| 78 |
+
if not common.is_absolute():
|
| 79 |
+
common = Path(root) / common
|
| 80 |
+
path = common / "lfs" / "objects" / oid[:2] / oid[2:4] / oid
|
| 81 |
+
return path.read_bytes() if path.exists() else None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _chunks(blob, root=ROOT):
|
| 85 |
+
blob = lfs_content(blob, root)
|
| 86 |
+
if blob is None:
|
| 87 |
+
return []
|
| 88 |
+
try:
|
| 89 |
+
return json.loads(blob.decode("utf-8"))
|
| 90 |
+
except ValueError:
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def squash(text):
|
| 95 |
+
return re.sub(r"\s+", " ", text or "")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class Corpus:
|
| 99 |
+
"""data/processed read per act: the working tree (commit None), a git
|
| 100 |
+
commit (`git show`, LFS pointers read from the local store), or a dict of
|
| 101 |
+
chunks (tests)."""
|
| 102 |
+
|
| 103 |
+
def __init__(self, commit=None, root=ROOT, chunks=None):
|
| 104 |
+
self.commit, self.root = commit, Path(root)
|
| 105 |
+
self._cache = dict(chunks) if chunks is not None else {}
|
| 106 |
+
self._fixed = chunks is not None
|
| 107 |
+
|
| 108 |
+
def chunks(self, act):
|
| 109 |
+
if act not in self._cache:
|
| 110 |
+
if self._fixed:
|
| 111 |
+
return []
|
| 112 |
+
path = f"data/processed/{act}.json"
|
| 113 |
+
if self.commit is None:
|
| 114 |
+
p = self.root / path
|
| 115 |
+
blob = p.read_bytes() if p.exists() else None
|
| 116 |
+
else:
|
| 117 |
+
blob = git_blob(self.commit, path, self.root)
|
| 118 |
+
self._cache[act] = _chunks(blob, self.root)
|
| 119 |
+
return self._cache[act]
|
| 120 |
+
|
| 121 |
+
def section(self, act, section):
|
| 122 |
+
return [c for c in self.chunks(act) if str(c.get("section")) == str(section)]
|
| 123 |
+
|
| 124 |
+
def raw_text(self, act, section):
|
| 125 |
+
"""The section's text with its line breaks (subsections start a line)."""
|
| 126 |
+
return "\n".join(c.get("text", "") for c in self.section(act, section))
|
| 127 |
+
|
| 128 |
+
def text(self, act, section):
|
| 129 |
+
"""Whitespace squashed: the text a verify phrase is checked against."""
|
| 130 |
+
return squash(" ".join(c.get("text", "") for c in self.section(act, section)))
|
| 131 |
+
|
| 132 |
+
__call__ = text
|
| 133 |
+
|
| 134 |
+
def last_amended(self, act, section):
|
| 135 |
+
return max((c.get("last_amended") or "" for c in self.section(act, section)),
|
| 136 |
+
default="")
|
| 137 |
+
|
| 138 |
+
def amended_by(self, act, section):
|
| 139 |
+
"""The last citation in the section's history line ('2026, c. 19, s. 23')."""
|
| 140 |
+
hist = next((c.get("history") for c in self.section(act, section)
|
| 141 |
+
if c.get("history")), "")
|
| 142 |
+
return hist.split(";")[-1].strip() if hist else ""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def corpus_reader(commit=None, cwd=ROOT):
|
| 146 |
+
"""section_text(act, section) over a commit's data/processed (None: the
|
| 147 |
+
working tree at cwd), whitespace squashed: the text a curated verify
|
| 148 |
+
phrase is checked against. Signature kept from tools/predeploy.py."""
|
| 149 |
+
return Corpus(commit, cwd).text
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---- verify rows ----------------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
def verify_rows(table, file=EQUIVALENCY):
|
| 155 |
+
"""Every verify row in a curated table, flattened: a pairing's and its
|
| 156 |
+
limbs' (us_equivalency.json) or a row's (transborder.json). Each is
|
| 157 |
+
{file, owner, limb, act, section, contains}."""
|
| 158 |
+
out = []
|
| 159 |
+
|
| 160 |
+
def add(owner, limb, v):
|
| 161 |
+
out.append({"file": file, "owner": owner, "limb": limb, "act": v["act"],
|
| 162 |
+
"section": str(v["section"]), "contains": v["contains"]})
|
| 163 |
+
|
| 164 |
+
for p in (table or {}).get("pairings") or []:
|
| 165 |
+
for v in p.get("verify") or []:
|
| 166 |
+
add(p.get("id"), None, v)
|
| 167 |
+
for l in p.get("limbs") or []:
|
| 168 |
+
if isinstance(l, dict):
|
| 169 |
+
for v in l.get("verify") or []:
|
| 170 |
+
add(p.get("id"), l.get("key"), v)
|
| 171 |
+
for r in (table or {}).get("rows") or []:
|
| 172 |
+
for v in r.get("verify") or []:
|
| 173 |
+
add(r.get("id"), None, v)
|
| 174 |
+
return out
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def phrase_in(phrase, text):
|
| 178 |
+
"""The phrase, whitespace squashed, occurs in the text (case-insensitive)."""
|
| 179 |
+
return " ".join((phrase or "").split()).lower() in (text or "").lower()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def where(row):
|
| 183 |
+
return f"{row['owner']}" + (f" limb {row['limb']}" if row.get("limb") else "")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def problems(rows, corpus):
|
| 187 |
+
"""The broken rows as messages: '<owner>: <act> s. <section> lacks '<phrase>''
|
| 188 |
+
(or 'not in corpus'). [] when every phrase is in its section."""
|
| 189 |
+
out = []
|
| 190 |
+
for r in rows:
|
| 191 |
+
text = corpus.text(r["act"], r["section"])
|
| 192 |
+
if not text:
|
| 193 |
+
out.append(f"{where(r)}: {r['act']} s. {r['section']} not in corpus")
|
| 194 |
+
elif not phrase_in(r["contains"], text):
|
| 195 |
+
out.append(f"{where(r)}: {r['act']} s. {r['section']} lacks {r['contains']!r}")
|
| 196 |
+
return out
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---- refresh drift (moved from tools/predeploy.py, wave 2 verifier) -----------
|
| 200 |
+
|
| 201 |
+
def verify_sections(pairing):
|
| 202 |
+
"""{(act, section): the maxima the pairing's verify rows (and its limbs')
|
| 203 |
+
quote for that section} -- canlex.screening.maxima_stated on each
|
| 204 |
+
phrase."""
|
| 205 |
+
from .screening import maxima_stated
|
| 206 |
+
out = {}
|
| 207 |
+
rows = list(pairing.get("verify") or []) + [
|
| 208 |
+
v for l in pairing.get("limbs") or [] if isinstance(l, dict) for v in l.get("verify") or []]
|
| 209 |
+
for v in rows:
|
| 210 |
+
out.setdefault((v["act"], v["section"]), set()).update(maxima_stated(v.get("contains")))
|
| 211 |
+
return out
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def refresh_drift(equivalency, now_text, then_text):
|
| 215 |
+
"""The curated table against a corpus refresh: (drift, changed).
|
| 216 |
+
|
| 217 |
+
drift: [(pairing id, act, section, sorted new maxima)] -- a maximum a
|
| 218 |
+
verify section states NOW that it did not state in the corpus the table
|
| 219 |
+
was approved against, and that the pairing does not quote (an amendment
|
| 220 |
+
the approved text cannot know: 2026, c. 19 raised s. 173(2)(a) to 10
|
| 221 |
+
years). A composed release shipping such a table ships stale law.
|
| 222 |
+
changed: [(pairing id, act, section)] -- every verify section whose text
|
| 223 |
+
changed at all (re-read before relying on the approved prose)."""
|
| 224 |
+
from .screening import maxima_stated
|
| 225 |
+
drift, changed = [], []
|
| 226 |
+
for p in (equivalency or {}).get("pairings") or []:
|
| 227 |
+
for (act, sec), quoted in sorted(verify_sections(p).items()):
|
| 228 |
+
now, then = now_text(act, sec), then_text(act, sec)
|
| 229 |
+
if not now or not then:
|
| 230 |
+
continue
|
| 231 |
+
if now != then:
|
| 232 |
+
changed.append((p["id"], act, sec))
|
| 233 |
+
new = maxima_stated(now) - maxima_stated(then) - quoted
|
| 234 |
+
if new:
|
| 235 |
+
drift.append((p["id"], act, sec, sorted(new)))
|
| 236 |
+
return drift, changed
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ---- amendments and the maximum they changed ------------------------------------
|
| 240 |
+
|
| 241 |
+
_SUBSECTION = re.compile(r"\n\((\d+(?:\.\d+)*)\)\s")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def subsections(raw):
|
| 245 |
+
"""{label: text} of a section's subsections as the corpus prints them (a
|
| 246 |
+
line opening '(1) ', '(2.1) '); the text before the first one, or a
|
| 247 |
+
section with none, is labelled ''. A section split over several chunks
|
| 248 |
+
is read as one text, so a chunk continuing a subsection stays in it."""
|
| 249 |
+
marks = list(_SUBSECTION.finditer(raw or ""))
|
| 250 |
+
if not marks:
|
| 251 |
+
return {"": raw or ""}
|
| 252 |
+
out = {}
|
| 253 |
+
head = raw[:marks[0].start()]
|
| 254 |
+
if head.strip():
|
| 255 |
+
out[""] = head
|
| 256 |
+
for i, m in enumerate(marks):
|
| 257 |
+
end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
|
| 258 |
+
out[m.group(1)] = out.get(m.group(1), "") + raw[m.start():end]
|
| 259 |
+
return out
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def maxima_seq(text):
|
| 263 |
+
"""The maxima a text states, in order (canlex.screening.maxima_stated on
|
| 264 |
+
each stated term), so that a maximum moving between provisions shows."""
|
| 265 |
+
from . import screening as sc
|
| 266 |
+
return [x for m in sc._STATED_MAX.finditer((text or "").lower())
|
| 267 |
+
for x in sorted(sc.maxima_stated(m.group(0)))]
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def maximum_change(phrase, then_raw, now_raw):
|
| 271 |
+
"""(changed, before, after, added, removed) for the subsections that hold
|
| 272 |
+
`phrase` then or now (every subsection when neither does).
|
| 273 |
+
|
| 274 |
+
changed: labels printed both then and now whose stated maxima differ as
|
| 275 |
+
sets, and only when the section's maxima as a whole differ (a
|
| 276 |
+
subsection split or renumbered around the same maxima is not a change).
|
| 277 |
+
before / after: the maxima those labels stated then / now.
|
| 278 |
+
added / removed: quoted labels only in the new / only in the old text,
|
| 279 |
+
a provision created or repealed, never read as a changed maximum."""
|
| 280 |
+
from collections import Counter
|
| 281 |
+
then_s, now_s = subsections(then_raw), subsections(now_raw)
|
| 282 |
+
labels = sorted({l for l, t in then_s.items() if phrase_in(phrase, squash(t))}
|
| 283 |
+
| {l for l, t in now_s.items() if phrase_in(phrase, squash(t))})
|
| 284 |
+
if not labels:
|
| 285 |
+
labels = sorted(set(then_s) | set(now_s))
|
| 286 |
+
added = [l for l in labels if l not in then_s]
|
| 287 |
+
removed = [l for l in labels if l not in now_s]
|
| 288 |
+
# The unnumbered label is the whole section in a version without
|
| 289 |
+
# subsections, but only the number and head words in one with them: it is
|
| 290 |
+
# compared only when neither version numbers its subsections.
|
| 291 |
+
numbered = bool(set(then_s) - {""}) or bool(set(now_s) - {""})
|
| 292 |
+
changed = []
|
| 293 |
+
if Counter(maxima_seq(then_raw)) != Counter(maxima_seq(now_raw)):
|
| 294 |
+
changed = [l for l in labels if l in then_s and l in now_s
|
| 295 |
+
and not (l == "" and numbered)
|
| 296 |
+
and set(maxima_seq(then_s[l])) != set(maxima_seq(now_s[l]))]
|
| 297 |
+
before = sorted({x for l in changed for x in maxima_seq(then_s[l])})
|
| 298 |
+
after = sorted({x for l in changed for x in maxima_seq(now_s[l])})
|
| 299 |
+
return changed, before, after, added, removed
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def check_rows(rows, now, then=None):
|
| 303 |
+
"""Each verify row with its report fields:
|
| 304 |
+
|
| 305 |
+
status 'broken' (phrase or section gone) > 'amended' > 'ok'
|
| 306 |
+
last_amended the section's last_amended in the corpus now
|
| 307 |
+
last_amended_seen the same in `then` (the approved corpus); '' when there
|
| 308 |
+
is no baseline or the section was not in it
|
| 309 |
+
amended last_amended is later than last_amended_seen
|
| 310 |
+
amended_by the last citation in the section's history line
|
| 311 |
+
subsections for an amended row: the quoted subsections whose stated
|
| 312 |
+
maximum changed ([] = the amendment left it alone)
|
| 313 |
+
maxima_before / maxima_after what those subsections stated then / now
|
| 314 |
+
provisions_added / provisions_removed quoted subsections that exist
|
| 315 |
+
only now / only then (created or repealed: re-read)
|
| 316 |
+
"""
|
| 317 |
+
out = []
|
| 318 |
+
for r in rows:
|
| 319 |
+
act, sec = r["act"], r["section"]
|
| 320 |
+
text = now.text(act, sec)
|
| 321 |
+
rep = dict(r, status="ok", reason="", last_amended=now.last_amended(act, sec),
|
| 322 |
+
last_amended_seen="", amended=False, amended_by="",
|
| 323 |
+
subsections=[], maxima_before=[], maxima_after=[],
|
| 324 |
+
provisions_added=[], provisions_removed=[])
|
| 325 |
+
if not text:
|
| 326 |
+
rep.update(status="broken", reason="section not in corpus")
|
| 327 |
+
elif not phrase_in(r["contains"], text):
|
| 328 |
+
rep.update(status="broken", reason="phrase not in the section")
|
| 329 |
+
if then is not None and then.section(act, sec):
|
| 330 |
+
seen = then.last_amended(act, sec)
|
| 331 |
+
rep["last_amended_seen"] = seen
|
| 332 |
+
if seen and rep["last_amended"] > seen:
|
| 333 |
+
labels, before, after, added, removed = maximum_change(
|
| 334 |
+
r["contains"], then.raw_text(act, sec), now.raw_text(act, sec))
|
| 335 |
+
rep.update(amended=True, amended_by=now.amended_by(act, sec),
|
| 336 |
+
subsections=labels, maxima_before=before, maxima_after=after,
|
| 337 |
+
provisions_added=added, provisions_removed=removed)
|
| 338 |
+
if rep["status"] == "ok":
|
| 339 |
+
rep["status"] = "amended"
|
| 340 |
+
out.append(rep)
|
| 341 |
+
return out
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _day_before(iso):
|
| 345 |
+
return (datetime.date.fromisoformat(iso) - datetime.timedelta(days=1)).isoformat()
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _years(m):
|
| 349 |
+
return "life" if m is not None and m >= 99 else f"{m} years"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def pit_url(act, section, start):
|
| 353 |
+
"""The Justice Laws point-in-time page of the version that began on
|
| 354 |
+
`start` (a candidate address, not fetched)."""
|
| 355 |
+
kind = "regulations" if act.upper().startswith(("SOR-", "C.R.C.", "CRC")) else "acts"
|
| 356 |
+
return (f"https://laws-lois.justice.gc.ca/eng/{kind}/{act}/section-{section}-"
|
| 357 |
+
f"{start.replace('-', '')}.html")
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def draft_history(report, equivalency):
|
| 361 |
+
"""DRAFT penalty_history rows for the amended rows whose quoted maximum
|
| 362 |
+
changed, and which no row the pairing carries already covers. A covering
|
| 363 |
+
row ends between the amendment the review saw and the new one; for a
|
| 364 |
+
pairing-level verify row any limb's row counts. Each report row that a
|
| 365 |
+
draft or an existing row answers gets `draft` or `covered_by`.
|
| 366 |
+
|
| 367 |
+
The proposed row is the maximum before the amendment until the day
|
| 368 |
+
before it, when the quoted subsections stated a single maximum before.
|
| 369 |
+
Otherwise max_years is None and the note says so. It is never applied
|
| 370 |
+
here.
|
| 371 |
+
|
| 372 |
+
Each draft also carries `cautions` for the reviewer: the pairing (or
|
| 373 |
+
limb) has no structured maximum, so a row cannot drive the branch by
|
| 374 |
+
itself; or the same section is quoted at another maximum in a provision
|
| 375 |
+
the amendment left alone, so a row at this level would misstate that
|
| 376 |
+
limb. `prose_names_date` says whether the curated prose already names
|
| 377 |
+
the amendment (only the structured row is missing)."""
|
| 378 |
+
from .screening import maxima_stated
|
| 379 |
+
pairings = {p.get("id"): p for p in (equivalency or {}).get("pairings") or []}
|
| 380 |
+
drafts, made = [], {}
|
| 381 |
+
for r in report:
|
| 382 |
+
if not (r.get("amended") and r.get("subsections") and r["file"] == EQUIVALENCY):
|
| 383 |
+
continue
|
| 384 |
+
p = pairings.get(r["owner"])
|
| 385 |
+
if p is None:
|
| 386 |
+
continue
|
| 387 |
+
until = _day_before(r["last_amended"])
|
| 388 |
+
covering = sorted({h.get("until") for h in p.get("penalty_history") or []
|
| 389 |
+
if isinstance(h, dict)
|
| 390 |
+
and (r["limb"] is None or h.get("limb") in (None, r["limb"]))
|
| 391 |
+
and r["last_amended_seen"] <= (h.get("until") or "") < r["last_amended"]})
|
| 392 |
+
if covering:
|
| 393 |
+
r["covered_by"] = covering
|
| 394 |
+
continue
|
| 395 |
+
key = (r["owner"], r["limb"], until)
|
| 396 |
+
if key in made:
|
| 397 |
+
r["draft"] = made[key]
|
| 398 |
+
continue
|
| 399 |
+
before, after = r["maxima_before"], r["maxima_after"]
|
| 400 |
+
old = before[0] if len(before) == 1 else None
|
| 401 |
+
limb = next((l for l in p.get("limbs") or []
|
| 402 |
+
if isinstance(l, dict) and l.get("key") == r["limb"]), None)
|
| 403 |
+
table_max = limb.get("max_years", p.get("max_years")) if limb else p.get("max_years")
|
| 404 |
+
labels = [l for l in r["subsections"] if l]
|
| 405 |
+
where = f"{r['act']} s. {r['section']}" + (
|
| 406 |
+
f" (subsection{'s' if len(labels) > 1 else ''} "
|
| 407 |
+
f"{', '.join(f'({l})' for l in labels)})" if labels else "")
|
| 408 |
+
if old is None:
|
| 409 |
+
note = (f"DRAFT: {where} as it stood before {r['last_amended']} "
|
| 410 |
+
f"({r['amended_by'] or 'amendment'}); the maximum before it is not "
|
| 411 |
+
f"determined mechanically (stated then: "
|
| 412 |
+
f"{', '.join(_years(m) for m in before) or 'none'}) -- read the "
|
| 413 |
+
f"point-in-time version and set max_years before accepting")
|
| 414 |
+
else:
|
| 415 |
+
note = (f"DRAFT: {where} before {r['last_amended']} "
|
| 416 |
+
f"({r['amended_by'] or 'amendment'}): {_years(old)} "
|
| 417 |
+
f"(now {', '.join(_years(m) for m in after) or 'none stated'}) -- "
|
| 418 |
+
f"confirm against the point-in-time version before accepting")
|
| 419 |
+
row = {"until": until, "max_years": old, "note": note}
|
| 420 |
+
if r["limb"]:
|
| 421 |
+
row["limb"] = r["limb"]
|
| 422 |
+
level = f"limb {r['limb']!r}" if r["limb"] else "pairing"
|
| 423 |
+
# The other maxima the same owner quotes for this section: a
|
| 424 |
+
# provision the amendment left alone, which a row at this level
|
| 425 |
+
# would misstate (s. 286.1(1) stays at 5 years while (2) went 10 -> 14).
|
| 426 |
+
others = sorted({m for x in report
|
| 427 |
+
if x["owner"] == r["owner"] and x["limb"] == r["limb"]
|
| 428 |
+
and (x["act"], x["section"]) == (r["act"], r["section"])
|
| 429 |
+
for m in maxima_stated(x["contains"])} - set(before) - set(after))
|
| 430 |
+
cautions = []
|
| 431 |
+
if table_max is None:
|
| 432 |
+
cautions.append(
|
| 433 |
+
f"the table gives this {level} no structured maximum (max_years null"
|
| 434 |
+
+ (f", depends_on {p.get('depends_on')!r}" if p.get("depends_on") else "")
|
| 435 |
+
+ "): a history row here cannot drive the branch on its own -- decide "
|
| 436 |
+
"whether the amended subsection needs a limb of its own")
|
| 437 |
+
if others:
|
| 438 |
+
cautions.append(
|
| 439 |
+
f"{r['act']} s. {r['section']} is also quoted at "
|
| 440 |
+
f"{', '.join(_years(m) for m in others)} in a provision the amendment left "
|
| 441 |
+
f"alone: a row on this {level} would apply to that provision too -- give "
|
| 442 |
+
f"the amended subsection{'s' if len(labels) > 1 else ''} "
|
| 443 |
+
f"{', '.join(f'({l})' for l in labels) or where} a limb of its own first")
|
| 444 |
+
prose = squash(" ".join([p.get("penalty") or "", p.get("analysis") or "",
|
| 445 |
+
p.get("branch") or ""] + list(p.get("caveats") or [])))
|
| 446 |
+
made[key] = len(drafts)
|
| 447 |
+
r["draft"] = made[key]
|
| 448 |
+
drafts.append({
|
| 449 |
+
"pairing": r["owner"], "limb": r["limb"], "act": r["act"],
|
| 450 |
+
"section": r["section"], "subsections": [l for l in r["subsections"] if l],
|
| 451 |
+
"amended": r["last_amended"], "amended_by": r["amended_by"],
|
| 452 |
+
"last_amended_seen": r["last_amended_seen"],
|
| 453 |
+
"maxima_before": before, "maxima_after": after,
|
| 454 |
+
"table_max_years": table_max,
|
| 455 |
+
"pit_url_to_read": (pit_url(r["act"], r["section"], r["last_amended_seen"])
|
| 456 |
+
if r["last_amended_seen"] else ""),
|
| 457 |
+
"prose_names_date": bool(prose) and (until in prose or r["last_amended"] in prose),
|
| 458 |
+
"cautions": cautions,
|
| 459 |
+
"status": "DRAFT - for review", "proposed_row": row})
|
| 460 |
+
return drafts
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def write_drafts(drafts, report, baseline, today, draft_dir=None):
|
| 464 |
+
"""Write the drafts, with the amended and broken report rows, to
|
| 465 |
+
<draft_dir>/penalty-history-draft-<today>.json, and return its path.
|
| 466 |
+
Nothing is written when there are no drafts. When the newest existing
|
| 467 |
+
draft file already holds the same drafts, that file's path is returned
|
| 468 |
+
instead, so a weekly check does not pile up copies."""
|
| 469 |
+
if not drafts:
|
| 470 |
+
return ""
|
| 471 |
+
draft_dir = Path(draft_dir) if draft_dir else DRAFTS
|
| 472 |
+
drafts = json.loads(json.dumps(drafts))
|
| 473 |
+
existing = sorted(draft_dir.glob(DRAFT_GLOB))
|
| 474 |
+
if existing:
|
| 475 |
+
try:
|
| 476 |
+
if json.loads(existing[-1].read_text(encoding="utf-8")).get("drafts") == drafts:
|
| 477 |
+
return str(existing[-1])
|
| 478 |
+
except ValueError:
|
| 479 |
+
pass
|
| 480 |
+
draft_dir.mkdir(parents=True, exist_ok=True)
|
| 481 |
+
path = draft_dir / f"penalty-history-draft-{today}.json"
|
| 482 |
+
payload = {
|
| 483 |
+
"generated": today,
|
| 484 |
+
"tool": "py -m canlex.refresh --check (refresh.check_curated_sections)",
|
| 485 |
+
"status": ("DRAFT research output for review. Nothing in this file is applied "
|
| 486 |
+
"to data/curated/us_equivalency.json; an accepted row is added there "
|
| 487 |
+
"separately after review."),
|
| 488 |
+
"baseline": baseline,
|
| 489 |
+
"method": ("A verify row is amended when its section's last_amended in the corpus "
|
| 490 |
+
"now is later than in the corpus of the approved commit "
|
| 491 |
+
"(last_amended_seen). A row is drafted when the maximum stated in a "
|
| 492 |
+
"subsection the row quotes changed, and no penalty_history row of the "
|
| 493 |
+
"pairing ends between the two amendment dates: the maximum before the "
|
| 494 |
+
"amendment, until the day before it."),
|
| 495 |
+
"drafts": drafts,
|
| 496 |
+
"rows": [r for r in report if r.get("amended") or r["status"] == "broken"],
|
| 497 |
+
}
|
| 498 |
+
path.write_text(json.dumps(payload, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 499 |
+
return str(path)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# ---- the approved baseline and one entry point ------------------------------------
|
| 503 |
+
|
| 504 |
+
def approved_commit(root=ROOT):
|
| 505 |
+
"""The approved commit of data/curated/review_state.json when git can read
|
| 506 |
+
it, else ''."""
|
| 507 |
+
try:
|
| 508 |
+
state = json.loads((Path(root) / "data" / "curated" / "review_state.json")
|
| 509 |
+
.read_text(encoding="utf-8"))
|
| 510 |
+
commit = state["approved"]["commit"]
|
| 511 |
+
ok = subprocess.run(["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
|
| 512 |
+
capture_output=True, timeout=60).returncode == 0
|
| 513 |
+
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
| 514 |
+
return ""
|
| 515 |
+
return commit if ok else ""
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def check_sections(equivalency, transborder=None, now=None, then=None):
|
| 519 |
+
"""check_rows over every verify row of the equivalency table and the
|
| 520 |
+
IRPR s. 19 table (optional: absent on the approved baseline)."""
|
| 521 |
+
rows = verify_rows(equivalency) + verify_rows(transborder, TRANSBORDER)
|
| 522 |
+
return check_rows(rows, now or Corpus(), then)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def run_checks(curated_dir=None, root=ROOT, baseline=None, today=None,
|
| 526 |
+
draft_dir=None, write=True):
|
| 527 |
+
"""The whole T4.3 check on a tree's curated tables and corpus:
|
| 528 |
+
{baseline, rows, drafts, draft_file, drift, error}. baseline None reads
|
| 529 |
+
the approved commit from review_state.json; '' checks broken phrases
|
| 530 |
+
only."""
|
| 531 |
+
today = today or datetime.date.today().isoformat()
|
| 532 |
+
curated_dir = Path(curated_dir) if curated_dir else Path(root) / "data" / "curated"
|
| 533 |
+
try:
|
| 534 |
+
eq = json.loads((curated_dir / EQUIVALENCY).read_text(encoding="utf-8"))
|
| 535 |
+
tb_path = curated_dir / TRANSBORDER
|
| 536 |
+
tb = json.loads(tb_path.read_text(encoding="utf-8")) if tb_path.exists() else None
|
| 537 |
+
except (OSError, ValueError) as exc:
|
| 538 |
+
return {"baseline": "", "rows": [], "drafts": [], "draft_file": "", "drift": [],
|
| 539 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 540 |
+
base = approved_commit(root) if baseline is None else baseline
|
| 541 |
+
now = Corpus(None, root)
|
| 542 |
+
then = Corpus(base, root) if base else None
|
| 543 |
+
rows = check_sections(eq, tb, now, then)
|
| 544 |
+
drafts = draft_history(rows, eq)
|
| 545 |
+
drift = [list(d) for d in refresh_drift(eq, now.text, then.text)[0]] if then else []
|
| 546 |
+
path = write_drafts(drafts, rows, base, today, draft_dir) if write else ""
|
| 547 |
+
return {"baseline": base, "rows": rows, "drafts": drafts, "draft_file": path,
|
| 548 |
+
"drift": drift, "error": ""}
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def gate(result):
|
| 552 |
+
"""(status, lines) for tools/predeploy.py: FAIL on a broken phrase (or a
|
| 553 |
+
table that cannot be read), WARN on an amended section, PASS otherwise."""
|
| 554 |
+
if result.get("error"):
|
| 555 |
+
return FAIL, [f"curated tables unreadable: {result['error']}"]
|
| 556 |
+
rows = result.get("rows") or []
|
| 557 |
+
broken = [r for r in rows if r["status"] == "broken"]
|
| 558 |
+
amended = [r for r in rows if r.get("amended")]
|
| 559 |
+
lines = [f"BROKEN {where(r)}: {r['act']} s. {r['section']} -- {r['reason']}: "
|
| 560 |
+
f"{r['contains']!r}" for r in broken]
|
| 561 |
+
for pid, act, sec, new in result.get("drift") or []:
|
| 562 |
+
lines.append(f"NOT QUOTED {pid}: {act} s. {sec} now states "
|
| 563 |
+
f"{' and '.join(_years(m) for m in new)}")
|
| 564 |
+
for d in result.get("drafts") or []:
|
| 565 |
+
lines.append(f"DRAFT history row {d['pairing']}"
|
| 566 |
+
+ (f" limb {d['limb']}" if d["limb"] else "")
|
| 567 |
+
+ f": {d['act']} s. {d['section']} until {d['proposed_row']['until']}")
|
| 568 |
+
secs = sorted({(r["act"], r["section"]) for r in amended})
|
| 569 |
+
if secs:
|
| 570 |
+
lines.append(f"amended since the approved corpus: {len(secs)} verify section(s): "
|
| 571 |
+
+ ", ".join(f"{a} s. {s}" for a, s in secs))
|
| 572 |
+
if result.get("draft_file"):
|
| 573 |
+
lines.append(f"drafts: {result['draft_file']}")
|
| 574 |
+
if broken:
|
| 575 |
+
return FAIL, lines
|
| 576 |
+
if amended or result.get("drift"):
|
| 577 |
+
return WARN, lines
|
| 578 |
+
return PASS, lines or [f"{len(rows)} verify rows: every phrase is in its section"]
|
canlex/delegation.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, force=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, force=force)
|
| 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, force=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 |
+
force=force)
|
| 470 |
+
chunks = parse_cbsa(html, src)
|
| 471 |
+
elif src["kind"] == "html-cbsa-narrative":
|
| 472 |
+
html = _fetch(src["url"], RAW / f"{src['code']}.html",
|
| 473 |
+
force=force)
|
| 474 |
+
chunks = parse_cbsa_narrative(html, src)
|
| 475 |
+
elif src["kind"] == "pdf-ircc":
|
| 476 |
+
pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf",
|
| 477 |
+
powershell=True, force=force)
|
| 478 |
+
chunks = parse_ircc(pdf, src)
|
| 479 |
+
else:
|
| 480 |
+
chunks = []
|
| 481 |
+
except Exception as exc:
|
| 482 |
+
print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
|
| 483 |
+
failures.append((src["act_code"], f"{type(exc).__name__}: {exc}"))
|
| 484 |
+
continue
|
| 485 |
+
if not chunks:
|
| 486 |
+
failures.append((src["act_code"], "no content parsed"))
|
| 487 |
+
all_chunks.extend(chunks)
|
| 488 |
+
print(f" {len(chunks)} chunks")
|
| 489 |
+
uniquify_ids(all_chunks)
|
| 490 |
+
|
| 491 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 492 |
+
stored_chunks(OUT))
|
| 493 |
+
if preserved:
|
| 494 |
+
print(f" kept the last-good copy of {len(preserved)} instrument(s): "
|
| 495 |
+
f"{', '.join(preserved)}")
|
| 496 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 497 |
+
return False
|
| 498 |
+
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
| 499 |
+
f"instrument(s) -> {OUT}")
|
| 500 |
+
return True
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def main():
|
| 504 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
|
| 505 |
+
force="--force" in sys.argv) else 1)
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
if __name__ == "__main__":
|
| 509 |
+
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,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, dehyphenate, document_words, fetch_cached,
|
| 17 |
+
norm_ws as _norm, preserve_dropped, split_lines,
|
| 18 |
+
stored_chunks, 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 |
+
text = _pdf_clean("\n".join((p.extract_text() or "") for p in reader.pages))
|
| 188 |
+
# line-break hyphens closed up (keeping the hyphen unless the memo
|
| 189 |
+
# itself uses the joined word; 2026-09-22)
|
| 190 |
+
return dehyphenate(text, document_words([text]))
|
| 191 |
+
except Exception:
|
| 192 |
+
return ""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _split(text, target=3000):
|
| 196 |
+
"""Split long PDF text into ~target-sized pieces at line boundaries."""
|
| 197 |
+
return split_lines(text, target)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def parse_pdf_memo(html, url, force=False):
|
| 201 |
+
"""Fallback for memos whose HTML page is only a stub linking to a PDF.
|
| 202 |
+
`force` re-downloads the PDF (a memo CBSA reposts as a revised PDF at
|
| 203 |
+
the same URL was invisible to --force until 2026-09-21)."""
|
| 204 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 205 |
+
main = soup.find("main")
|
| 206 |
+
if main is None:
|
| 207 |
+
return []
|
| 208 |
+
pdf_href = next((a["href"] for a in main.find_all("a", href=True)
|
| 209 |
+
if a["href"].lower().endswith(".pdf")), None)
|
| 210 |
+
if not pdf_href:
|
| 211 |
+
return []
|
| 212 |
+
pdf_url = urljoin(url, pdf_href)
|
| 213 |
+
|
| 214 |
+
match = _URL_NUMBER.search(url)
|
| 215 |
+
number = match.group(1).upper() if match else url
|
| 216 |
+
h1 = main.find("h1")
|
| 217 |
+
topic = _norm(h1.get_text(" ", strip=True)) if h1 else ""
|
| 218 |
+
topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "", topic, flags=re.I)
|
| 219 |
+
dm = main.find("time", attrs={"property": "dateModified"})
|
| 220 |
+
date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
|
| 221 |
+
|
| 222 |
+
pdf_bytes = _fetch(pdf_url, DMEMO_DIR / "pdf" / pdf_url.rsplit("/", 1)[-1],
|
| 223 |
+
force=force)
|
| 224 |
+
text = _pdf_text(pdf_bytes)
|
| 225 |
+
if not text:
|
| 226 |
+
return []
|
| 227 |
+
|
| 228 |
+
parts = _split(text)
|
| 229 |
+
chunks = []
|
| 230 |
+
for i, part in enumerate(parts, 1):
|
| 231 |
+
label = topic or number
|
| 232 |
+
if len(parts) > 1:
|
| 233 |
+
label = f"{label} (part {i})"
|
| 234 |
+
chunks.append({
|
| 235 |
+
"id": f"dmemo-{number}-pdf{i}",
|
| 236 |
+
"doc_type": "memorandum",
|
| 237 |
+
"act_code": "D-Memo",
|
| 238 |
+
"act_short": "D-Memo",
|
| 239 |
+
"act_name": "CBSA D-Memoranda",
|
| 240 |
+
"section": number,
|
| 241 |
+
"marginal_note": label,
|
| 242 |
+
"part": topic,
|
| 243 |
+
"division": "",
|
| 244 |
+
"heading": "",
|
| 245 |
+
"text": part,
|
| 246 |
+
"history": "",
|
| 247 |
+
"last_amended": date,
|
| 248 |
+
"current_to": date,
|
| 249 |
+
"citation": f"Memorandum {number}",
|
| 250 |
+
"source_url": url,
|
| 251 |
+
})
|
| 252 |
+
return chunks
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def preserve_failed(chunks, failures, stored):
|
| 256 |
+
"""Re-attach the last-good chunks of memos that could not be fetched.
|
| 257 |
+
|
| 258 |
+
Ingestion is a full rebuild, so anything that fails a fetch disappears from
|
| 259 |
+
the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
|
| 260 |
+
whose pages 404, and a transient network error looks the same -- in both
|
| 261 |
+
cases dropping guidance that is still in force is worse than serving the
|
| 262 |
+
copy we already had. Restricted to memos that actually failed, so the
|
| 263 |
+
cancelled ones (filtered out before fetching) are not resurrected.
|
| 264 |
+
Returns (chunks, preserved memo numbers).
|
| 265 |
+
"""
|
| 266 |
+
failed = set()
|
| 267 |
+
for url, _why in failures:
|
| 268 |
+
match = _URL_NUMBER.search(url)
|
| 269 |
+
if match:
|
| 270 |
+
failed.add(match.group(1).upper())
|
| 271 |
+
return preserve_dropped(chunks, stored,
|
| 272 |
+
key=lambda c: c["section"].upper(), only=failed)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def ingest(force=False, limit=None, allow_shrink=False):
|
| 276 |
+
urls = memo_urls(force=force)
|
| 277 |
+
if limit:
|
| 278 |
+
urls = urls[:limit]
|
| 279 |
+
print(f"Ingesting {len(urls)} D-Memoranda...")
|
| 280 |
+
all_chunks, failures = [], []
|
| 281 |
+
for i, url in enumerate(urls, 1):
|
| 282 |
+
try:
|
| 283 |
+
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
|
| 284 |
+
chunks = parse_memo(html, url) or parse_pdf_memo(html, url, force=force)
|
| 285 |
+
if chunks:
|
| 286 |
+
all_chunks.extend(chunks)
|
| 287 |
+
else:
|
| 288 |
+
failures.append((url, "no content parsed"))
|
| 289 |
+
except Exception as exc:
|
| 290 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 291 |
+
if i % 50 == 0:
|
| 292 |
+
print(f" {i}/{len(urls)} ...")
|
| 293 |
+
if failures:
|
| 294 |
+
print(f" {len(failures)} memos with no content / errors:")
|
| 295 |
+
for url, why in failures[:15]:
|
| 296 |
+
print(f" - {url.rsplit('/', 1)[-1]}: {why}")
|
| 297 |
+
|
| 298 |
+
if not limit:
|
| 299 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 300 |
+
stored_chunks(OUT_FILE))
|
| 301 |
+
if preserved:
|
| 302 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 303 |
+
f"memo(s): {', '.join(preserved)}")
|
| 304 |
+
# The guard applies to --limit runs too: a smoke test over five memos must
|
| 305 |
+
# not be able to write a five-memo corpus over the real one.
|
| 306 |
+
if not write_corpus(OUT_FILE, all_chunks, allow_shrink=allow_shrink):
|
| 307 |
+
return False
|
| 308 |
+
print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
|
| 309 |
+
f"-> {OUT_FILE.name}")
|
| 310 |
+
return True
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def ingest_fr(force=False, limit=None, allow_shrink=False):
|
| 314 |
+
"""The French editions (Mémorandums D), mirroring ingest().
|
| 315 |
+
|
| 316 |
+
Same pages at -fra.html, same WET markup, same parser; the chunks are
|
| 317 |
+
post-processed with lang='fr', a dmemo-fr- id prefix and the French
|
| 318 |
+
citation so language-routed retrieval (index.query_lang) serves them
|
| 319 |
+
only to French queries."""
|
| 320 |
+
try:
|
| 321 |
+
urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
|
| 322 |
+
DMEMO_DIR / "_datasource-fra.json",
|
| 323 |
+
force=force))
|
| 324 |
+
except Exception as exc:
|
| 325 |
+
print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
|
| 326 |
+
urls = []
|
| 327 |
+
if limit:
|
| 328 |
+
urls = urls[:limit]
|
| 329 |
+
print(f"Ingesting {len(urls)} Mémorandums D (français)...")
|
| 330 |
+
all_chunks, failures = [], []
|
| 331 |
+
for i, url in enumerate(urls, 1):
|
| 332 |
+
try:
|
| 333 |
+
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
|
| 334 |
+
chunks = parse_memo(html, url) or parse_pdf_memo(html, url, force=force)
|
| 335 |
+
if chunks:
|
| 336 |
+
all_chunks.extend(chunks)
|
| 337 |
+
else:
|
| 338 |
+
failures.append((url, "no content parsed"))
|
| 339 |
+
except Exception as exc:
|
| 340 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 341 |
+
if i % 50 == 0:
|
| 342 |
+
print(f" {i}/{len(urls)} ...")
|
| 343 |
+
for c in all_chunks:
|
| 344 |
+
c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
|
| 345 |
+
c["lang"] = "fr"
|
| 346 |
+
c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
|
| 347 |
+
if failures:
|
| 348 |
+
print(f" {len(failures)} mémorandums with no content / errors "
|
| 349 |
+
f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
|
| 350 |
+
if not limit:
|
| 351 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 352 |
+
stored_chunks(OUT_FILE_FR))
|
| 353 |
+
if preserved:
|
| 354 |
+
print(f" kept the last-good copy of {len(preserved)} "
|
| 355 |
+
f"unfetchable mémorandum(s)")
|
| 356 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
|
| 357 |
+
return False
|
| 358 |
+
print(f" {len(all_chunks)} section-chunks from "
|
| 359 |
+
f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
|
| 360 |
+
return True
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def main():
|
| 364 |
+
force = "--force" in sys.argv
|
| 365 |
+
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 366 |
+
if a.startswith("--limit=")), None)
|
| 367 |
+
runner = ingest_fr if "--fr" in sys.argv else ingest
|
| 368 |
+
ok = runner(force=force, limit=limit,
|
| 369 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 370 |
+
sys.exit(0 if ok else 1)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
if __name__ == "__main__":
|
| 374 |
+
main()
|
canlex/embed.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Incremental by default: embeddings.npz stores a content hash per row, and a
|
| 9 |
+
rebuild re-encodes only the chunks whose embed text changed (or is new),
|
| 10 |
+
reusing every other row. A weekly consolidation refresh that touches a few
|
| 11 |
+
hundred sections therefore takes seconds, not the ~40 minutes of a full
|
| 12 |
+
pass over ~55k windows. `--full` forces the full pass (use it after changing
|
| 13 |
+
the model, the pooling, the prefixes or embed_texts itself -- the hash
|
| 14 |
+
covers the text, not the recipe).
|
| 15 |
+
|
| 16 |
+
python -m canlex.embed [--full]
|
| 17 |
+
python -m canlex.embed --check # exit 1 unless the store is current
|
| 18 |
+
"""
|
| 19 |
+
import hashlib
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
from tokenizers import Tokenizer
|
| 26 |
+
|
| 27 |
+
from .config import PROCESSED_DIR
|
| 28 |
+
|
| 29 |
+
# The embedding model is env-configurable so an alternative (e.g. a multilingual
|
| 30 |
+
# model for French support) can be trialled without code edits; the defaults
|
| 31 |
+
# reproduce the production bge-small-en-v1.5 setup exactly. A model swap means
|
| 32 |
+
# matching three things to that model's training: the HF repo, the pooling
|
| 33 |
+
# ('cls' for BGE, 'mean' for E5/MiniLM), and the query/passage prefixes (BGE
|
| 34 |
+
# prefixes only the query; E5 prefixes both with 'query: ' / 'passage: ').
|
| 35 |
+
EMB_REPO = os.environ.get("CANLEX_EMB_REPO", "Xenova/bge-small-en-v1.5")
|
| 36 |
+
_POOLING = os.environ.get("CANLEX_EMB_POOLING", "cls").lower()
|
| 37 |
+
_QUERY_PREFIX = os.environ.get(
|
| 38 |
+
"CANLEX_EMB_QUERY_PREFIX",
|
| 39 |
+
"Represent this sentence for searching relevant passages: ")
|
| 40 |
+
_PASSAGE_PREFIX = os.environ.get("CANLEX_EMB_PASSAGE_PREFIX", "")
|
| 41 |
+
EMB_PATH = PROCESSED_DIR / "embeddings.npz"
|
| 42 |
+
_MAX_TOKENS = 512
|
| 43 |
+
_MAX_BODY = 2000 # cap embedded body text so long sections stay topically focused
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_chunks():
|
| 47 |
+
chunks = []
|
| 48 |
+
for path in sorted(PROCESSED_DIR.glob("*.json")):
|
| 49 |
+
chunks.extend(json.loads(path.read_text(encoding="utf-8")))
|
| 50 |
+
return chunks
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# A chunk longer than the embed window gets additional vectors for its tail,
|
| 54 |
+
# up to this many per chunk (an 87K-char D-memo section would otherwise need
|
| 55 |
+
# 40+; eight windows cover 16K chars, past which BM25 carries the tail).
|
| 56 |
+
_MAX_WINDOWS = 8
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def embed_text(chunk):
|
| 60 |
+
"""Compact, retrieval-focused representation of one section (head only)."""
|
| 61 |
+
# The section title is the strongest topical signal, so it is repeated to
|
| 62 |
+
# emphasise it. Title selection is doc_type-aware (see index.topical_title):
|
| 63 |
+
# a D-memo's marginal_note is a generic banner so its actual subject in
|
| 64 |
+
# 'part' is used; a case-law chunk's marginal_note is just the paragraph
|
| 65 |
+
# range so the case proposition in 'heading' is used.
|
| 66 |
+
from .index import topical_title
|
| 67 |
+
note = topical_title(chunk)
|
| 68 |
+
body = chunk["text"][:_MAX_BODY]
|
| 69 |
+
parts = [chunk["act_short"], note, note, chunk["heading"], body]
|
| 70 |
+
return " . ".join(p for p in parts if p)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def embed_texts(chunk):
|
| 74 |
+
"""One representation per embedding window of a chunk.
|
| 75 |
+
|
| 76 |
+
The first is the classic head representation; a chunk whose body exceeds
|
| 77 |
+
the embed window additionally gets tail windows (same topical-title
|
| 78 |
+
anchor, successive body slices), so a fact buried mid-memo is reachable
|
| 79 |
+
through semantic recall instead of BM25 alone. All windows share the
|
| 80 |
+
chunk's id -- the index scores a chunk by its best window."""
|
| 81 |
+
from .index import topical_title
|
| 82 |
+
texts = [embed_text(chunk)]
|
| 83 |
+
body = chunk["text"]
|
| 84 |
+
if len(body) > _MAX_BODY:
|
| 85 |
+
note = topical_title(chunk)
|
| 86 |
+
anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
|
| 87 |
+
for start in range(_MAX_BODY, len(body), _MAX_BODY):
|
| 88 |
+
if len(texts) >= _MAX_WINDOWS:
|
| 89 |
+
break
|
| 90 |
+
texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
|
| 91 |
+
return texts
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class Embedder:
|
| 95 |
+
"""Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
|
| 96 |
+
|
| 97 |
+
No API key; the model is downloaded once and cached. Produces L2-normalized
|
| 98 |
+
vectors, so a dot product between them is cosine similarity.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self):
|
| 102 |
+
# Each file from the local cache first (rerank.hub_file); the
|
| 103 |
+
# quantized model is preferred, the full one the fallback.
|
| 104 |
+
from .rerank import hub_file, make_ort_session, make_run_options
|
| 105 |
+
model_path = None
|
| 106 |
+
for name in ("onnx/model_quantized.onnx", "onnx/model.onnx"):
|
| 107 |
+
try:
|
| 108 |
+
model_path = hub_file(EMB_REPO, name)
|
| 109 |
+
break
|
| 110 |
+
except Exception:
|
| 111 |
+
continue
|
| 112 |
+
if model_path is None:
|
| 113 |
+
raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
|
| 114 |
+
tok_path = hub_file(EMB_REPO, "tokenizer.json")
|
| 115 |
+
self.session = make_ort_session(model_path)
|
| 116 |
+
self.run_options = make_run_options()
|
| 117 |
+
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 118 |
+
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 119 |
+
self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
|
| 120 |
+
|
| 121 |
+
def _run(self, texts):
|
| 122 |
+
"""Tokenize, run the encoder, CLS-pool and L2-normalize one batch."""
|
| 123 |
+
encs = self.tokenizer.encode_batch(list(texts))
|
| 124 |
+
width = max(len(e.ids) for e in encs)
|
| 125 |
+
input_ids = np.zeros((len(encs), width), dtype=np.int64)
|
| 126 |
+
attention = np.zeros((len(encs), width), dtype=np.int64)
|
| 127 |
+
type_ids = np.zeros((len(encs), width), dtype=np.int64)
|
| 128 |
+
for row, enc in enumerate(encs):
|
| 129 |
+
n = len(enc.ids)
|
| 130 |
+
input_ids[row, :n] = enc.ids
|
| 131 |
+
attention[row, :n] = enc.attention_mask
|
| 132 |
+
type_ids[row, :n] = enc.type_ids
|
| 133 |
+
feed = {"input_ids": input_ids, "attention_mask": attention}
|
| 134 |
+
if "token_type_ids" in self.input_names:
|
| 135 |
+
feed["token_type_ids"] = type_ids
|
| 136 |
+
hidden = np.asarray(self.session.run(None, feed, self.run_options)[0],
|
| 137 |
+
dtype=np.float32)
|
| 138 |
+
if hidden.ndim != 3:
|
| 139 |
+
pooled = hidden # already pooled
|
| 140 |
+
elif _POOLING == "mean": # E5 / MiniLM
|
| 141 |
+
mask = attention[:, :, None].astype(np.float32)
|
| 142 |
+
pooled = (hidden * mask).sum(1) / np.maximum(mask.sum(1), 1e-9)
|
| 143 |
+
else: # BGE: CLS token
|
| 144 |
+
pooled = hidden[:, 0, :]
|
| 145 |
+
norms = np.linalg.norm(pooled, axis=1, keepdims=True)
|
| 146 |
+
return pooled / np.maximum(norms, 1e-9)
|
| 147 |
+
|
| 148 |
+
def encode(self, texts, batch_size=32):
|
| 149 |
+
"""Return L2-normalized embeddings for passages, one row per text."""
|
| 150 |
+
texts = [_PASSAGE_PREFIX + t for t in texts]
|
| 151 |
+
if not texts:
|
| 152 |
+
return np.zeros((0, 384), dtype=np.float32)
|
| 153 |
+
rows = [self._run(texts[i:i + batch_size])
|
| 154 |
+
for i in range(0, len(texts), batch_size)]
|
| 155 |
+
return np.vstack(rows)
|
| 156 |
+
|
| 157 |
+
def encode_query(self, text):
|
| 158 |
+
"""Return the L2-normalized embedding for one search query."""
|
| 159 |
+
return self._run([_QUERY_PREFIX + text])[0]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def content_hash(texts):
|
| 163 |
+
"""Hash of a chunk's embed windows -- the identity a stored row keeps."""
|
| 164 |
+
h = hashlib.sha1()
|
| 165 |
+
for t in texts:
|
| 166 |
+
h.update(t.encode("utf-8"))
|
| 167 |
+
h.update(b"\x00")
|
| 168 |
+
return h.hexdigest()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def load_stored(path=None):
|
| 172 |
+
"""{chunk id: (hash, rows)} from an existing npz, or {} when there is none
|
| 173 |
+
or it predates per-row hashes (then nothing can be trusted for reuse)."""
|
| 174 |
+
path = path or EMB_PATH
|
| 175 |
+
if not path.exists():
|
| 176 |
+
return {}
|
| 177 |
+
with np.load(path) as data:
|
| 178 |
+
if "hashes" not in data.files:
|
| 179 |
+
return {}
|
| 180 |
+
ids = data["ids"].tolist()
|
| 181 |
+
hashes = data["hashes"].tolist()
|
| 182 |
+
vectors = data["vectors"]
|
| 183 |
+
stored = {}
|
| 184 |
+
for row, (cid, h) in enumerate(zip(ids, hashes)):
|
| 185 |
+
entry = stored.setdefault(cid, (h, []))
|
| 186 |
+
if entry[0] == h:
|
| 187 |
+
entry[1].append(vectors[row])
|
| 188 |
+
return stored
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def plan(chunks, stored):
|
| 192 |
+
"""Split chunks into (reusable, to_embed): a chunk is reusable when the
|
| 193 |
+
stored npz holds rows under its id with the same content hash. Pure, so
|
| 194 |
+
the reuse rule is testable without a model."""
|
| 195 |
+
reusable, to_embed = [], []
|
| 196 |
+
for c in chunks:
|
| 197 |
+
texts = embed_texts(c)
|
| 198 |
+
h = content_hash(texts)
|
| 199 |
+
entry = stored.get(c["id"])
|
| 200 |
+
if entry and entry[0] == h and len(entry[1]) == len(texts):
|
| 201 |
+
reusable.append((c["id"], h, entry[1]))
|
| 202 |
+
else:
|
| 203 |
+
to_embed.append((c["id"], h, texts))
|
| 204 |
+
return reusable, to_embed
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def currency(chunks=None, stored=None):
|
| 208 |
+
"""(missing, stale): chunks with no stored vector at all, and chunks
|
| 209 |
+
whose stored vectors were embedded from other text (another content
|
| 210 |
+
hash or window count) -- the two halves of what an incremental build
|
| 211 |
+
would re-embed (plan's to_embed). (0, 0) means the store is current.
|
| 212 |
+
Defaults to the processed corpus and embeddings.npz. Audit follow-up
|
| 213 |
+
2026-09-21 (Missing 15): the only signal was a stderr line at load."""
|
| 214 |
+
chunks = load_chunks() if chunks is None else chunks
|
| 215 |
+
stored = load_stored() if stored is None else stored
|
| 216 |
+
_, to_embed = plan(chunks, stored)
|
| 217 |
+
missing = sum(1 for cid, _, _ in to_embed if cid not in stored)
|
| 218 |
+
return missing, len(to_embed) - missing
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def build(full=False, encoder=None):
|
| 222 |
+
chunks = load_chunks()
|
| 223 |
+
if not chunks:
|
| 224 |
+
print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 225 |
+
return
|
| 226 |
+
stored = {} if full else load_stored()
|
| 227 |
+
reusable, to_embed = plan(chunks, stored)
|
| 228 |
+
n_windows = sum(len(t) for _, _, t in to_embed)
|
| 229 |
+
print(f"Embedding {len(to_embed)} of {len(chunks)} sections "
|
| 230 |
+
f"({n_windows} windows) with {EMB_REPO}; reusing "
|
| 231 |
+
f"{len(reusable)} unchanged ...")
|
| 232 |
+
fresh = {}
|
| 233 |
+
if to_embed:
|
| 234 |
+
encoder = encoder or Embedder()
|
| 235 |
+
texts = [t for _, _, ts in to_embed for t in ts]
|
| 236 |
+
vecs = encoder.encode(texts)
|
| 237 |
+
row = 0
|
| 238 |
+
for cid, h, ts in to_embed:
|
| 239 |
+
fresh[cid] = list(vecs[row:row + len(ts)])
|
| 240 |
+
row += len(ts)
|
| 241 |
+
by_id = {cid: (h, rows) for cid, h, rows in reusable}
|
| 242 |
+
by_id.update({cid: (h, fresh[cid]) for cid, h, _ in to_embed})
|
| 243 |
+
ids, hashes, vectors = [], [], []
|
| 244 |
+
for c in chunks: # chunk order, every window kept
|
| 245 |
+
h, rows = by_id[c["id"]]
|
| 246 |
+
for v in rows:
|
| 247 |
+
ids.append(c["id"])
|
| 248 |
+
hashes.append(h)
|
| 249 |
+
vectors.append(np.asarray(v, dtype=np.float32))
|
| 250 |
+
vectors = np.vstack(vectors) if vectors else np.zeros((0, 384), np.float32)
|
| 251 |
+
# float16 + compression: ~60% smaller on disk with no measurable
|
| 252 |
+
# retrieval cost (cosine ranks are insensitive to half-precision
|
| 253 |
+
# storage; the index upcasts to float32 at load). The npz ships in the
|
| 254 |
+
# Space repo via LFS, where every pushed version counts against the
|
| 255 |
+
# 1 GB storage cap -- size is a deploy constraint, not vanity.
|
| 256 |
+
np.savez_compressed(EMB_PATH, ids=np.array(ids), hashes=np.array(hashes),
|
| 257 |
+
vectors=vectors.astype(np.float16))
|
| 258 |
+
print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
if __name__ == "__main__":
|
| 262 |
+
if "--check" in sys.argv:
|
| 263 |
+
# Exit 1 unless every processed chunk has a current vector.
|
| 264 |
+
n_missing, n_stale = currency()
|
| 265 |
+
print(f"embeddings.npz: {n_missing} chunk(s) missing, {n_stale} stale")
|
| 266 |
+
sys.exit(1 if n_missing or n_stale else 0)
|
| 267 |
+
build(full="--full" in sys.argv)
|
canlex/enf.py
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (dehyphenate, document_words, fetch_cached,
|
| 23 |
+
mark_soft_breaks, norm_ws as _norm, powershell_status,
|
| 24 |
+
preserve_dropped, split_lines, stored_chunks, uniquify_ids,
|
| 25 |
+
write_corpus)
|
| 26 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 27 |
+
|
| 28 |
+
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 29 |
+
"resources/manuals/enf/enf{n:02d}-eng.pdf")
|
| 30 |
+
# The French editions, published beside the English ones (`--fr`). ENF 20's
|
| 31 |
+
# French current edition is enf20a-fr.pdf (verified 2026-09-09); ENF 3's
|
| 32 |
+
# standard French filename serves the same STALE 2015 edition as the English
|
| 33 |
+
# one and no current French filename was found, so it is skipped rather than
|
| 34 |
+
# ingested out of date; the ATIP chapters (2, 14) exist in English only.
|
| 35 |
+
BASE_FR = ("https://www.canada.ca/content/dam/ircc/migration/ircc/francais/"
|
| 36 |
+
"ressources/guides/enf/enf{n:02d}-fra.pdf")
|
| 37 |
+
_URL_OVERRIDES_FR = {
|
| 38 |
+
20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/francais/"
|
| 39 |
+
"ressources/guides/enf/enf20a-fr.pdf"),
|
| 40 |
+
}
|
| 41 |
+
_FR_STALE = {3}
|
| 42 |
+
RAW = RAW_DIR / "enf"
|
| 43 |
+
_CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
|
| 44 |
+
|
| 45 |
+
# Chapters whose current edition lives under a non-standard filename in the
|
| 46 |
+
# same directory (verified 2026-07-22 against the OBM Active-manuals index).
|
| 47 |
+
# ENF 3's old enf03-eng.pdf still returns 200 but is a STALE 2015-era copy;
|
| 48 |
+
# ENF 20 (Detention) was renamed enf20a-en.pdf (Aug 2024 edition).
|
| 49 |
+
_URL_OVERRIDES = {
|
| 50 |
+
3: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 51 |
+
"resources/manuals/enf/enf-3-admissibility-(en)-final.pdf"),
|
| 52 |
+
20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
| 53 |
+
"resources/manuals/enf/enf20a-en.pdf"),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Chapters IRCC withdrew from public distribution whose only public text is
|
| 57 |
+
# an ATIP-released copy hosted by a third party (meurrensonimmigration.com,
|
| 58 |
+
# verified live 2026-07-22). Ingested at the maintainer's decision
|
| 59 |
+
# (2026-07-23) with an unmistakable UNOFFICIAL banner on every chunk: the
|
| 60 |
+
# text may lag the internal current version, and nothing here is an official
|
| 61 |
+
# publication. ENF 14/OP 19 is the operational manual behind criminal
|
| 62 |
+
# rehabilitation -- directly relevant to canlex_rehabilitation.
|
| 63 |
+
UNOFFICIAL = {
|
| 64 |
+
2: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2023/06/"
|
| 65 |
+
"ENF2-3.pdf",
|
| 66 |
+
"title": "Evaluating Inadmissibility (unofficial ATIP copy)",
|
| 67 |
+
"note": "ATIP-released copy dated 2021-08-25; retired from canada.ca "
|
| 68 |
+
"in 2018 with no public successor"},
|
| 69 |
+
# ENF 14's ATIP release is a 56-page image scan with no text layer; its
|
| 70 |
+
# text comes from a Windows-OCR pass cached as enf14-ocr.txt (see the
|
| 71 |
+
# OCR fallback in build()). Regenerate the cache with pymupdf
|
| 72 |
+
# rasterization + Windows.Media.Ocr if the source PDF ever changes.
|
| 73 |
+
14: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2025/03/"
|
| 74 |
+
"Enforcement-Manual-14-%E2%80%93-Rehabilitation.pdf",
|
| 75 |
+
"title": "Criminal Rehabilitation (ENF 14/OP 19, unofficial ATIP "
|
| 76 |
+
"copy)",
|
| 77 |
+
"note": "withdrawn from public distribution ~2007; ATIP-released "
|
| 78 |
+
"scan, text recovered by OCR (last internal change "
|
| 79 |
+
"2008-04-21) -- OCR artifacts possible"},
|
| 80 |
+
}
|
| 81 |
+
# The structured status flag every chunk of those chapters carries
|
| 82 |
+
# (`source_status`). Retrieval keys its ATIP-copy penalty on this field --
|
| 83 |
+
# never on the act_name wording, which differs between the two chapters
|
| 84 |
+
# ('(unofficial ATIP copy)' vs '(ENF 14/OP 19, unofficial ATIP copy)') and
|
| 85 |
+
# so silently missed ENF 14 when a suffix test was tried (eval-diff draft
|
| 86 |
+
# 2026-09-12). canlex/index.py ATIP_STATUS must equal this string.
|
| 87 |
+
ATIP_STATUS = "unofficial-atip-copy"
|
| 88 |
+
_UNOFFICIAL_BANNER = (
|
| 89 |
+
"[UNOFFICIAL ATIP COPY — this chapter was withdrawn from public "
|
| 90 |
+
"distribution; this text comes from an access-to-information release "
|
| 91 |
+
"hosted by a third party and may lag the current internal version. "
|
| 92 |
+
"Treat as historical guidance; verify against internal sources.] ")
|
| 93 |
+
|
| 94 |
+
# Curated titles for the chapters; anything else gets a generic "ENF n".
|
| 95 |
+
# NOT display-only: index.topical_title uses a memorandum's `part` (this
|
| 96 |
+
# title) as its topical title, so it is weighted in BM25 and embedded. Every
|
| 97 |
+
# entry for an ingested chapter was checked against the chapter's own cover
|
| 98 |
+
# page on 2026-09-12 (tests/test_enf.py pins that when the PDFs are cached);
|
| 99 |
+
# before that, ENF 14 (Criminal Rehabilitation) sat under a war-crimes title
|
| 100 |
+
# and ENF 6, 17, 18, 21 and 34 carried other chapters' titles. 7, 9, 11, 13
|
| 101 |
+
# and 27 are not ingested (absent upstream) and are unverified.
|
| 102 |
+
TITLES = {
|
| 103 |
+
1: "Inadmissibility",
|
| 104 |
+
2: "Evaluating inadmissibility",
|
| 105 |
+
3: "Admissibility hearings and detention reviews",
|
| 106 |
+
4: "Port of entry examinations",
|
| 107 |
+
5: "Writing 44(1) reports",
|
| 108 |
+
6: "Review of reports under subsection A44(2)",
|
| 109 |
+
7: "Investigations and arrests",
|
| 110 |
+
8: "Deposits and guarantees",
|
| 111 |
+
9: "Judicial review",
|
| 112 |
+
10: "Removals",
|
| 113 |
+
11: "Verifying departure",
|
| 114 |
+
12: "Search, seizure, fingerprinting and photographing",
|
| 115 |
+
13: "Examining bank drafts and other negotiable instruments",
|
| 116 |
+
14: "Criminal rehabilitation",
|
| 117 |
+
15: "Obligations of transporters",
|
| 118 |
+
17: "Maritime procedures",
|
| 119 |
+
18: "Human or international rights violations",
|
| 120 |
+
19: "Appeals before the Immigration Appeal Division",
|
| 121 |
+
20: "Detention",
|
| 122 |
+
21: "Recovering missing, abducted and exploited children",
|
| 123 |
+
22: "Persons serving a sentence",
|
| 124 |
+
23: "Loss of permanent resident status",
|
| 125 |
+
24: "Ministerial interventions",
|
| 126 |
+
26: "Appeals at the Refugee Appeal Division",
|
| 127 |
+
27: "Permanent resident card",
|
| 128 |
+
28: "Ministerial opinions on danger to the public",
|
| 129 |
+
29: "Alternative means of examination programs",
|
| 130 |
+
34: "Alternatives to detention program",
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
_DATE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
|
| 134 |
+
# A manual heading line: "5. Departmental policy" / "5.1 Authority to ..."
|
| 135 |
+
_HEADING = re.compile(r"^(\d{1,2}(?:\.\d{1,2})?)\.?\s+([A-ZÀ-Ý][^\n]{2,90})$")
|
| 136 |
+
|
| 137 |
+
# --- Heading-candidate vetting --------------------------------------------
|
| 138 |
+
# _HEADING alone accepted anything shaped "<number> <Capitalised text>", so
|
| 139 |
+
# (measured on the 2026-09 corpus) 120 of ENF 2's 145 chunks sat under
|
| 140 |
+
# "s. 1" titled by the ATIP page stamp ("1 A-2023-83511-000094"), ENF 1's
|
| 141 |
+
# Saini note sat under "s. 64 (F.T.R. 311 (Fed. T.D.))" (a wrapped reporter
|
| 142 |
+
# citation), ENF 19 opened with "s. 22 August 2024" and ENF 2 had
|
| 143 |
+
# "s. 27 March 1992 until 10 October 1996." A candidate is rejected when its
|
| 144 |
+
# text is one of those shapes, and then when its number does not continue the
|
| 145 |
+
# chapter's numbering (numbering_run).
|
| 146 |
+
# No \b before the "A": the stamp is sometimes glued to the column number
|
| 147 |
+
# ("1A-2023-83511-000047").
|
| 148 |
+
_ATIP_STAMP = re.compile(r"(?<![A-Za-z])A-\d{4}-\d+-\d+\b")
|
| 149 |
+
_REPORTER = re.compile(
|
| 150 |
+
r"F\.T\.R\.|D\.L\.R\.|Imm\.\s?L\.R\.|\bN\.R\.|C\.R\.R\.|S\.C\.R\.|R\.C\.S\."
|
| 151 |
+
r"|C\.C\.C\.|Admin\.\s?L\.R\.|\(Fed\.|\bF\.C\.|\[\d{4}\]"
|
| 152 |
+
r"|\b(?:19|20)\d{2}\s+(?:FCA|FC|SCC|CAF|CF|CSC)\s+\d+")
|
| 153 |
+
_MONTHS = ("January|February|March|April|May|June|July|August|September|"
|
| 154 |
+
"October|November|December|janvier|février|mars|avril|mai|juin|"
|
| 155 |
+
"juillet|août|septembre|octobre|novembre|décembre")
|
| 156 |
+
# "August 2024", "March 1992 until ...", "May 3, 2000", "2021-08-25 ..."
|
| 157 |
+
_DATE_START = re.compile(
|
| 158 |
+
rf"^(?:(?:{_MONTHS})\s+(?:\d{{1,2}}(?:er)?,?\s+)?\d{{4}}\b"
|
| 159 |
+
r"|\d{4}-\d{2}-\d{2}\b)", re.I)
|
| 160 |
+
_LEADERS = re.compile(r"\.{4,}|…") # a table-of-contents entry
|
| 161 |
+
# A heading may legitimately end in an initialism ("... via the U.S.",
|
| 162 |
+
# "... vers les É.-U."); any other final period marks a sentence.
|
| 163 |
+
_INITIALISM_END = re.compile(r"(?<!\w)(?:[A-Za-zÀ-ÿ]\.-?){2,}$")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def heading_rejection(heading):
|
| 167 |
+
"""Why `heading` (the text after a heading number) is not a section
|
| 168 |
+
title, or None when it can be one. Pure, for tests."""
|
| 169 |
+
if _ATIP_STAMP.search(heading):
|
| 170 |
+
return "atip-stamp"
|
| 171 |
+
if _REPORTER.search(heading):
|
| 172 |
+
return "reporter-citation"
|
| 173 |
+
if _DATE_START.match(heading):
|
| 174 |
+
return "date"
|
| 175 |
+
if _LEADERS.search(heading):
|
| 176 |
+
return "toc-entry"
|
| 177 |
+
tail = heading.rstrip(" \"'”’»")
|
| 178 |
+
if tail.endswith(".") and not _INITIALISM_END.search(tail):
|
| 179 |
+
return "sentence"
|
| 180 |
+
if tail.endswith(","):
|
| 181 |
+
return "fragment" # "15 Rue Fournier," (an address list)
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# --- Headings wrapped at the PDF line end -----------------------------------
|
| 186 |
+
# A long heading wraps: "13. Carrying out a decision of the" / "Immigration
|
| 187 |
+
# Division". _HEADING sees only the first line, so (measured on the 2026-09
|
| 188 |
+
# corpus) 73 EN and 107 FR section titles ended mid-phrase and the rest of the
|
| 189 |
+
# title opened the chunk body. A title that ends where no title can end -- on
|
| 190 |
+
# a function word, an open bracket or a dash -- takes the next line when that
|
| 191 |
+
# line reads as its continuation: a short line opening on a letter, not
|
| 192 |
+
# itself a numbered heading or an appendix title, and the joined title still
|
| 193 |
+
# passes heading_rejection (so a body sentence ending in '.' is never pulled
|
| 194 |
+
# in). Only the title changes: the section number, and with it the chunk id,
|
| 195 |
+
# and the chunk text stay exactly as printed.
|
| 196 |
+
_OPEN_END = {
|
| 197 |
+
"en": frozenset("of the a an to in for with by and or under from at"
|
| 198 |
+
.split()),
|
| 199 |
+
"fr": frozenset("de la le les du des à aux dans que en et pour par sur au"
|
| 200 |
+
.split()),
|
| 201 |
+
}
|
| 202 |
+
_OPEN_TAIL = ("(", "[", "–", "—", "-")
|
| 203 |
+
_CONTINUATION_MAX = 80 # a wrapped title's second line is a short line
|
| 204 |
+
_CONTINUATION_LINES = 2 # at most this many lines are joined on
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _open_ended(heading, lang):
|
| 208 |
+
words = heading.split()
|
| 209 |
+
return bool(words) and (words[-1].lower() in _OPEN_END[lang]
|
| 210 |
+
or heading.endswith(_OPEN_TAIL))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def complete_heading(heading, following, lang="en"):
|
| 214 |
+
"""`heading` with the line(s) it wrapped onto joined back on.
|
| 215 |
+
|
| 216 |
+
`following` is the normalised, non-empty lines after the heading line
|
| 217 |
+
and before the next heading. A hyphen glued to its word ("non-",
|
| 218 |
+
"personnes-") closes up with the next line, keeping the hyphen; any
|
| 219 |
+
other join takes a space. Pure, for tests."""
|
| 220 |
+
for line in following[:_CONTINUATION_LINES]:
|
| 221 |
+
if not _open_ended(heading, lang):
|
| 222 |
+
break
|
| 223 |
+
if (len(line) > _CONTINUATION_MAX or not line[0].isalpha()
|
| 224 |
+
or _HEADING.match(line) or _APPENDIX.match(line)):
|
| 225 |
+
break
|
| 226 |
+
glued = heading.endswith("-") and not heading.endswith(" -")
|
| 227 |
+
joined = heading + ("" if glued else " ") + line
|
| 228 |
+
if heading_rejection(joined):
|
| 229 |
+
break
|
| 230 |
+
heading = joined
|
| 231 |
+
return heading
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# A word the typesetting split with a space ("Impos er, modifier ou annuler",
|
| 235 |
+
# "resolut ion"). The halves close up when the whole word is at least this
|
| 236 |
+
# many times as common in the chapter as the rarer half -- the rule
|
| 237 |
+
# caselaw._join_lines uses for the Federal Courts Reports ("a part" stays two
|
| 238 |
+
# words).
|
| 239 |
+
_WORD = re.compile(r"[^\W\d_]+")
|
| 240 |
+
_JOIN_RATIO = 3
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def chapter_words(lines):
|
| 244 |
+
"""Lower-cased word counts over a chapter's lines (for
|
| 245 |
+
repair_split_words and the line-break hyphens, _common.dehyphenate)."""
|
| 246 |
+
return document_words(lines)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def repair_split_words(heading, words):
|
| 250 |
+
"""`heading` with space-split words closed up (see _JOIN_RATIO). The
|
| 251 |
+
first half must be a bare word; the second must open lower-case, and
|
| 252 |
+
may carry trailing punctuation ("er,"). Pure, for tests."""
|
| 253 |
+
out = []
|
| 254 |
+
for tok in heading.split(" "):
|
| 255 |
+
prev = out[-1] if out else ""
|
| 256 |
+
tail = _WORD.match(tok)
|
| 257 |
+
if (prev and _WORD.fullmatch(prev) and tail and tok[0].islower()):
|
| 258 |
+
b = tail.group(0)
|
| 259 |
+
whole = words.get((prev + b).lower(), 0)
|
| 260 |
+
rarer = min(words.get(prev.lower(), 0), words.get(b.lower(), 0))
|
| 261 |
+
if whole and whole >= _JOIN_RATIO * rarer:
|
| 262 |
+
out[-1] = prev + tok
|
| 263 |
+
continue
|
| 264 |
+
out.append(tok)
|
| 265 |
+
return " ".join(out)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# How far a chapter's major number may jump between two accepted headings.
|
| 269 |
+
# Extraction can lose a heading or two; a bigger jump is a list item, a
|
| 270 |
+
# date or a page number ("27 March 1992" after s. 14).
|
| 271 |
+
_MAX_JUMP = 3
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _num_key(num):
|
| 275 |
+
major, _, minor = num.partition(".")
|
| 276 |
+
return int(major), int(minor or 0)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _step_ok(prev, nxt):
|
| 280 |
+
(p_major, p_minor), (n_major, n_minor) = prev, nxt
|
| 281 |
+
if n_major == p_major:
|
| 282 |
+
return n_minor > p_minor
|
| 283 |
+
return p_major < n_major <= p_major + _MAX_JUMP and n_minor <= 2
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def numbering_run(nums, substantive=None):
|
| 287 |
+
"""Indices of the heading numbers that form the chapter's numbering.
|
| 288 |
+
|
| 289 |
+
The best-scoring subsequence, in document order, in which each number
|
| 290 |
+
continues the previous one (same major with a larger minor, or the next
|
| 291 |
+
major -- up to _MAX_JUMP ahead -- at its start), beginning near 1.
|
| 292 |
+
Taking the best run rather than checking greedily means one stray
|
| 293 |
+
"5 Something" early on cannot lock out the real 4, 4.1, ... that follow.
|
| 294 |
+
|
| 295 |
+
`substantive[k]` says whether candidate k heads real text (more than a
|
| 296 |
+
line or two before the next candidate). A heading that does outweighs any
|
| 297 |
+
number of ones that do not, so a table of contents printed without dot
|
| 298 |
+
leaders -- ENF 14's OCR lists "1 What this chapter is about", "2 Program
|
| 299 |
+
objectives", ... as plain lines -- can never win over the body headings
|
| 300 |
+
it duplicates; among equals, more headings win. Ties keep the earliest
|
| 301 |
+
occurrence, so a heading repeated at a page break stays where it first
|
| 302 |
+
appears and the repeat reads as body text. A numbered annex that restarts
|
| 303 |
+
at 1 (ENF 19's IRB-CBSA MOU) is not a continuation either, so it stays
|
| 304 |
+
body text of the last real section instead of posing as the chapter's own
|
| 305 |
+
ss. 1-13. Pure, for tests."""
|
| 306 |
+
keys = [_num_key(n) for n in nums]
|
| 307 |
+
if substantive is None:
|
| 308 |
+
substantive = [True] * len(keys)
|
| 309 |
+
weight = [1000 if s else 1 for s in substantive]
|
| 310 |
+
best, back = [0] * len(keys), [-1] * len(keys)
|
| 311 |
+
for j, key in enumerate(keys):
|
| 312 |
+
if key[0] <= _MAX_JUMP and key[1] <= 2:
|
| 313 |
+
best[j] = weight[j]
|
| 314 |
+
for i in range(j):
|
| 315 |
+
if (best[i] and best[i] + weight[j] > best[j]
|
| 316 |
+
and _step_ok(keys[i], key)):
|
| 317 |
+
best[j], back[j] = best[i] + weight[j], i
|
| 318 |
+
if not any(best):
|
| 319 |
+
return set()
|
| 320 |
+
j = max(range(len(keys)), key=lambda x: (best[x], -x))
|
| 321 |
+
keep = set()
|
| 322 |
+
while j != -1:
|
| 323 |
+
keep.add(j)
|
| 324 |
+
j = back[j]
|
| 325 |
+
return keep
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# --- ATIP release furniture -------------------------------------------------
|
| 329 |
+
# The two UNOFFICIAL chapters are access-to-information releases: every page
|
| 330 |
+
# of ENF 2 carries an OCR'd IRCC wordmark, the "Information disclosed under
|
| 331 |
+
# the Access to Information Act" banner in both languages, the printed page
|
| 332 |
+
# number and the release stamp "1 A-2023-83511-000094"; ENF 14's OCR carries
|
| 333 |
+
# the intranet print furniture. None of it is guidance, and it used to sit in
|
| 334 |
+
# the chunk text (and, for the stamp, in the section title). It is stripped;
|
| 335 |
+
# the stamp is kept only as a page pinpoint in the citation. ATIP exemption
|
| 336 |
+
# markers ("s.16(1)(b)") are kept: they say text was withheld there.
|
| 337 |
+
_STAMP_IN_LINE = re.compile(r"(?:\b\d{1,3}\s*)?(?<![A-Za-z])A-\d{4}-\d+-\d+\b")
|
| 338 |
+
_ATIP_FURNITURE = [re.compile(p) for p in (
|
| 339 |
+
r"^I\s*[•·]\s*I$",
|
| 340 |
+
r"^lmm\S*", # OCR of the 'Immigration' wordmark
|
| 341 |
+
r"^(?:and|et)\s+C[i1]t\S*\s+Canada\b",
|
| 342 |
+
r"^Information disclosed under the Access to Information Act$",
|
| 343 |
+
r"^L[’'`]information d\S* en vertu de la loi sur\b",
|
| 344 |
+
r"^DISTRIBUTION LIMITED TO CIC AND THE CBSA$",
|
| 345 |
+
r"^Date Last Modified: \d{4}-\d{2}-\d{2}$",
|
| 346 |
+
r"^Previous\s+[I|l1]\s+Next$",
|
| 347 |
+
r"^Page\s+\S{1,3}(?:\s+Of\S*)?$",
|
| 348 |
+
r"^https?://cicintranet\.",
|
| 349 |
+
r"^UO/UO/LU 1$",
|
| 350 |
+
r"^ENF 14 1 OP 19 Criminal Rehabilitation$",
|
| 351 |
+
)]
|
| 352 |
+
_ATIP_BANNER = re.compile(
|
| 353 |
+
r"\s*Information disclosed under the Access to Information Act\s*")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def clean_atip_pages(pages):
|
| 357 |
+
"""[(line, stamp)] for an ATIP release, furniture removed.
|
| 358 |
+
|
| 359 |
+
`stamp` is the release's identifier for the page the line is printed on
|
| 360 |
+
(it sits at the page foot, so it labels the text above it), or None when
|
| 361 |
+
the page carries none (ENF 14's OCR has no stamps). Pure, for tests."""
|
| 362 |
+
rows = []
|
| 363 |
+
for page in pages:
|
| 364 |
+
kept, stamp = [], None
|
| 365 |
+
for line in page.split("\n"):
|
| 366 |
+
s = line.strip()
|
| 367 |
+
found = _ATIP_STAMP.search(s)
|
| 368 |
+
if found:
|
| 369 |
+
stamp = found.group(0)
|
| 370 |
+
s = _STAMP_IN_LINE.sub(" ", s).strip()
|
| 371 |
+
if not s:
|
| 372 |
+
# The printed page number sits on the line above.
|
| 373 |
+
while kept and not kept[-1].strip():
|
| 374 |
+
kept.pop()
|
| 375 |
+
if kept and re.fullmatch(r"\d{1,3}", kept[-1].strip()):
|
| 376 |
+
kept.pop()
|
| 377 |
+
continue
|
| 378 |
+
line = s
|
| 379 |
+
if any(p.search(s) for p in _ATIP_FURNITURE):
|
| 380 |
+
continue
|
| 381 |
+
if _ATIP_BANNER.search(line):
|
| 382 |
+
line = _ATIP_BANNER.sub(" ", line).strip()
|
| 383 |
+
if not line:
|
| 384 |
+
continue
|
| 385 |
+
kept.append(line)
|
| 386 |
+
rows.extend((line, stamp) for line in kept)
|
| 387 |
+
return rows
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _pinpoint(stamps):
|
| 391 |
+
stamps = [s for s in stamps if s]
|
| 392 |
+
if not stamps:
|
| 393 |
+
return ""
|
| 394 |
+
first, last = stamps[0], stamps[-1]
|
| 395 |
+
return first if first == last else f"{first}–{last.rsplit('-', 1)[1]}"
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# --- ENF 1's jurisprudence annex -------------------------------------------
|
| 399 |
+
# ENF 1 closes with "Appendix A Some notable inadmissibility jurisprudence":
|
| 400 |
+
# for each IRPA ground, a "Jurisprudence" list of case citations, each with
|
| 401 |
+
# its holdings. It has no numbered headings of its own, so the heading regex
|
| 402 |
+
# used to cut it at reporter volumes that happened to start a line ("55
|
| 403 |
+
# D.L.R. (4th) 321 ..."). It is segmented by case instead: one segment per
|
| 404 |
+
# case, headed by the style of cause, tagged with the ground it is listed
|
| 405 |
+
# under. The French edition is laid out the same way ("Appendice A", "c.").
|
| 406 |
+
_ANNEX_TITLE = {
|
| 407 |
+
1: re.compile(r"^(?:Appendix|Appendice)\s+A\s+(?:Some notable "
|
| 408 |
+
r"inadmissibility jurisprudence|Décisions marquantes)"),
|
| 409 |
+
}
|
| 410 |
+
_ANNEX_LABEL = {
|
| 411 |
+
"en": "Appendix A — Some notable inadmissibility jurisprudence",
|
| 412 |
+
"fr": ("Appendice A — Décisions marquantes faisant partie de la "
|
| 413 |
+
"jurisprudence relative aux interdictions de territoire"),
|
| 414 |
+
}
|
| 415 |
+
_JURIS_LINE = re.compile(r"^Jurisprudence\s*:?$")
|
| 416 |
+
# A citation line: a style of cause ("X v. Y" / "X c. Y", at most one
|
| 417 |
+
# parenthetical before the "v.", or "X, Re") followed by the report: a year
|
| 418 |
+
# in parentheses, a [year] volume, a bare volume and reporter, or a neutral
|
| 419 |
+
# citation.
|
| 420 |
+
_CASE = re.compile(
|
| 421 |
+
r"^(?P<name>[A-Z][^()\n]{0,60}?(?:\s\([^()\n]*\))?\s+(?:v|c)\.\s+[A-Z]"
|
| 422 |
+
r"[^\n]*?|[A-Z][\w'’. -]{1,40}, Re)"
|
| 423 |
+
r"(?=\s*\((?:\d{4}|[^()\n]*\d{4})\)|,?\s*\[\d{4}\]"
|
| 424 |
+
r"|,?\s+\d{1,3}\s+[A-Z]|,\s*\d{4}\s+(?:FCA|FC|SCC|CAF|CF|CSC)\b)")
|
| 425 |
+
_GROUND = re.compile(r"^[AL](\d{1,3})(?:\((\d+(?:\.\d+)?)\))?")
|
| 426 |
+
_PARTY_STOP = {"canada", "mci", "maci", "r", "re", "the", "minister",
|
| 427 |
+
"ministre", "attorney", "procureur", "solicitor",
|
| 428 |
+
"solliciteur", "secretary", "secretaire", "general", "her",
|
| 429 |
+
"majesty", "queen"}
|
| 430 |
+
# A holding starts with a bullet glyph (the PDFs use a private-use-area
|
| 431 |
+
# character); a citation-only entry has none.
|
| 432 |
+
_HOLDING = re.compile(r"^[-•▪◦‣∙-]")
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _ascii(text):
|
| 436 |
+
import unicodedata
|
| 437 |
+
return unicodedata.normalize("NFKD", text).encode("ascii",
|
| 438 |
+
"ignore").decode()
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _party_slug(name):
|
| 442 |
+
"""The id-safe party name of a style of cause: the first party word that
|
| 443 |
+
is not the Crown, the Minister or 'Re' ("Canada (MCI) v. Fenner" ->
|
| 444 |
+
'fenner', "Baroud, Re" -> 'baroud', "D’Souza v. Canada" -> 'dsouza')."""
|
| 445 |
+
for side in re.split(r"\s(?:v|c)\.\s", name, maxsplit=1):
|
| 446 |
+
side = re.sub(r"\([^)]*\)", " ", side)
|
| 447 |
+
for tok in side.replace(",", " ").split():
|
| 448 |
+
slug = re.sub(r"[^a-z]", "", _ascii(tok).lower())
|
| 449 |
+
if slug and slug not in _PARTY_STOP:
|
| 450 |
+
return slug
|
| 451 |
+
return "case"
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _ground_slug(ground):
|
| 455 |
+
m = _GROUND.match(ground or "")
|
| 456 |
+
if not m:
|
| 457 |
+
return ""
|
| 458 |
+
return m.group(1) + (f"-{m.group(2).replace('.', '-')}" if m.group(2)
|
| 459 |
+
else "")
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _annex_start(n, lines):
|
| 463 |
+
"""Index of the annex's own title line (not its table-of-contents
|
| 464 |
+
entry), or None when the chapter has no jurisprudence annex."""
|
| 465 |
+
pat = _ANNEX_TITLE.get(n)
|
| 466 |
+
if not pat:
|
| 467 |
+
return None
|
| 468 |
+
hits = [i for i, line in enumerate(lines)
|
| 469 |
+
if pat.match(_norm(line)) and not _LEADERS.search(line)]
|
| 470 |
+
return hits[-1] if hits else None
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _is_holding(raw, text):
|
| 474 |
+
"""A holding line: bullet glyph first (English edition), or indented
|
| 475 |
+
prose (the French edition's bullets do not survive extraction, but the
|
| 476 |
+
indent does). Citation continuations start flush, or with a digit or a
|
| 477 |
+
parenthesis."""
|
| 478 |
+
return bool(_HOLDING.match(text) or re.match(r"^\s+[^\W\d_]", raw))
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def annex_segments(rows):
|
| 482 |
+
"""Split annex rows [(line, pin)] into per-case segments.
|
| 483 |
+
|
| 484 |
+
Returns dicts {ground, case, absorbed, rows}: the ground is the provision
|
| 485 |
+
line the case is listed under ("A36(1)(a), (b) and (c)"), the case its
|
| 486 |
+
style of cause, rows its normalised (line, pin) pairs. The leading text
|
| 487 |
+
before the first case is one segment with case None. A citation-only
|
| 488 |
+
entry (no holding under it) is folded into the case before it under the
|
| 489 |
+
same ground rather than left as a stub -- so a ground listed as bare
|
| 490 |
+
citations (ENF 1's A38 list) becomes one segment, `absorbed` counting the
|
| 491 |
+
cases folded in. Never across grounds. Pure, for tests."""
|
| 492 |
+
items = [(_norm(raw), pin, raw) for raw, pin in rows]
|
| 493 |
+
items = [it for it in items if it[0]]
|
| 494 |
+
segs = [{"ground": "", "case": None, "rows": [], "holding": False}]
|
| 495 |
+
ground, pending = "", []
|
| 496 |
+
for k, (text, pin, raw) in enumerate(items):
|
| 497 |
+
nxt = items[k + 1][0] if k + 1 < len(items) else ""
|
| 498 |
+
if _JURIS_LINE.match(nxt) and not _JURIS_LINE.match(text):
|
| 499 |
+
ground = text # the provision heading
|
| 500 |
+
pending = [(text, pin)]
|
| 501 |
+
continue
|
| 502 |
+
if _JURIS_LINE.match(text) and pending:
|
| 503 |
+
pending.append((text, pin))
|
| 504 |
+
continue
|
| 505 |
+
prev = segs[-1]["rows"][-1][0] if segs[-1]["rows"] else ""
|
| 506 |
+
continuation = prev.count("(") > prev.count(")")
|
| 507 |
+
m = None if continuation else _CASE.match(text)
|
| 508 |
+
if m:
|
| 509 |
+
segs.append({"ground": ground, "case": _norm(m.group("name")),
|
| 510 |
+
"rows": pending + [(text, pin)], "holding": False})
|
| 511 |
+
else:
|
| 512 |
+
segs[-1]["rows"].extend(pending + [(text, pin)])
|
| 513 |
+
segs[-1]["holding"] |= _is_holding(raw, text)
|
| 514 |
+
pending = []
|
| 515 |
+
merged = []
|
| 516 |
+
for seg in segs:
|
| 517 |
+
prev = merged[-1] if merged else None
|
| 518 |
+
if (seg["case"] and not seg["holding"] and prev and prev["case"]
|
| 519 |
+
and prev["ground"] == seg["ground"]):
|
| 520 |
+
prev["rows"].extend(seg["rows"])
|
| 521 |
+
prev["absorbed"] += 1
|
| 522 |
+
continue
|
| 523 |
+
merged.append({"ground": seg["ground"], "case": seg["case"],
|
| 524 |
+
"absorbed": 0, "rows": seg["rows"]})
|
| 525 |
+
# A citation-only entry that opens its ground has nothing before it to
|
| 526 |
+
# join ("Bellido v. MCI, [2005] FC 452" heads the A40 list with no
|
| 527 |
+
# holding). Too short to stand alone, it would be dropped; it is folded
|
| 528 |
+
# into the case after it instead, which carries the same ground line.
|
| 529 |
+
out = []
|
| 530 |
+
for k, seg in enumerate(merged):
|
| 531 |
+
short = len("\n".join(t for t, _ in seg["rows"])) <= 120
|
| 532 |
+
nxt = merged[k + 1] if k + 1 < len(merged) else None
|
| 533 |
+
if (seg["case"] and short and nxt and nxt["case"]
|
| 534 |
+
and nxt["ground"] == seg["ground"]):
|
| 535 |
+
nxt["rows"][:0] = [r for r in seg["rows"]
|
| 536 |
+
if r not in nxt["rows"][:2]]
|
| 537 |
+
nxt["absorbed"] += 1 + seg["absorbed"]
|
| 538 |
+
continue
|
| 539 |
+
out.append(seg)
|
| 540 |
+
return out
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
_FRONT_LABEL = {"en": "Cover and updates to chapter",
|
| 544 |
+
"fr": "Page couverture et mises à jour du chapitre"}
|
| 545 |
+
# A contents entry that ends in a short dot run and its page number
|
| 546 |
+
# ("11. Rules applicable to appeals ... 23"): too few dots for _LEADERS.
|
| 547 |
+
_TOC_PAGE = re.compile(r"\.{2,}\s*\d{1,3}$")
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _head_key(heading):
|
| 551 |
+
"""A heading's comparable opening: case- and space-folded, trailing
|
| 552 |
+
dots and page number dropped, first 25 characters."""
|
| 553 |
+
h = re.sub(r"[\s.…]*\d{0,3}$", "", _norm(heading)).lower()
|
| 554 |
+
return re.sub(r"\s+", " ", h)[:25]
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _body_heads(lines, start, end):
|
| 558 |
+
"""{number: {heading key}} of every numbered-heading line in
|
| 559 |
+
lines[start:end] -- vetted or not, since a contents page lists what
|
| 560 |
+
the body prints."""
|
| 561 |
+
heads = {}
|
| 562 |
+
for line in lines[start:end]:
|
| 563 |
+
m = _HEADING.match(line.strip())
|
| 564 |
+
if m:
|
| 565 |
+
heads.setdefault(m.group(1), set()).add(_head_key(m.group(2)))
|
| 566 |
+
return heads
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def contents_entry(text, body_heads):
|
| 570 |
+
"""True when a numbered front-matter line is a table-of-contents entry:
|
| 571 |
+
its number and opening words repeat a heading printed in the body, or it
|
| 572 |
+
ends in dots and a page number. Any other numbered line (an update note
|
| 573 |
+
that opens with the section it changed) is not. Pure, for tests."""
|
| 574 |
+
m = _HEADING.match(text)
|
| 575 |
+
if not m:
|
| 576 |
+
return False
|
| 577 |
+
if _TOC_PAGE.search(text):
|
| 578 |
+
return True
|
| 579 |
+
key = _head_key(m.group(2))
|
| 580 |
+
for body in body_heads.get(m.group(1), ()):
|
| 581 |
+
k = min(len(key), len(body))
|
| 582 |
+
if k >= 8 and key[:k] == body[:k]:
|
| 583 |
+
return True
|
| 584 |
+
return False
|
| 585 |
+
|
| 586 |
+
# --- Trailing appendices ------------------------------------------------------
|
| 587 |
+
# Most chapters end in lettered appendices (sample letters, forms, MOUs, case
|
| 588 |
+
# lists). Many number their own items from 1, which numbering_run rightly
|
| 589 |
+
# refuses as chapter sections -- so without this their text would be cited as
|
| 590 |
+
# the chapter's last section (ENF 24's Article 1F case law as "s. 9.14").
|
| 591 |
+
# An appendix title line counts only after the last numbered section (so the
|
| 592 |
+
# cross-references and update notes that mention appendices mid-chapter are
|
| 593 |
+
# ignored), and the titles must run A, B, C ... through numbering_run's own
|
| 594 |
+
# logic, so a list of the appendices printed ahead of them (ENF 8) loses to
|
| 595 |
+
# the appendices themselves.
|
| 596 |
+
_APPENDIX = re.compile(r"^(?:Appendix|Annex|Appendice|Annexe)\s+([A-Z]|\d{1,2})"
|
| 597 |
+
r"\b\s*[:–—-]?\s*(.*)$")
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def appendix_marks(lines, start, end):
|
| 601 |
+
"""[(line index, letter, title)] of the appendix title lines in
|
| 602 |
+
lines[start:end]. Pure, for tests."""
|
| 603 |
+
cands = []
|
| 604 |
+
for i in range(start, end):
|
| 605 |
+
s = _norm(lines[i])
|
| 606 |
+
m = _APPENDIX.match(s)
|
| 607 |
+
if not m or len(s) > 120 or _LEADERS.search(s):
|
| 608 |
+
continue
|
| 609 |
+
title = m.group(2).strip()
|
| 610 |
+
# A cross-reference fragment ("Appendix C.)", "Annexe 1).") has no
|
| 611 |
+
# title after the letter. A real title may end in "(c)" or wrap
|
| 612 |
+
# mid-name ("... decision in Baker v."), so endings are not tested.
|
| 613 |
+
if not title or title[0] in ".),;":
|
| 614 |
+
continue
|
| 615 |
+
cands.append((i, m.group(1), title))
|
| 616 |
+
if not cands:
|
| 617 |
+
return []
|
| 618 |
+
nums = [str(ord(letter) - 64) if letter.isalpha() else letter
|
| 619 |
+
for _i, letter, _t in cands]
|
| 620 |
+
spans = [len(_norm(" ".join(lines[i + 1:(cands[k + 1][0]
|
| 621 |
+
if k + 1 < len(cands) else end)])))
|
| 622 |
+
for k, (i, _l, _t) in enumerate(cands)]
|
| 623 |
+
keep = numbering_run(nums, [span > 120 for span in spans])
|
| 624 |
+
return [c for k, c in enumerate(cands) if k in keep]
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
# --- chapter currency ---------------------------------------------------------
|
| 628 |
+
_LISTING = re.compile(r"^(?:\d+(?:\.\d+)*\.?\s+)?(?:Listing by date|Liste par "
|
| 629 |
+
r"date|Liste des mises à jour par date)\b", re.I)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def chapter_updated(pages, today=None):
|
| 633 |
+
"""The chapter's date: the latest ISO date on its first three pages
|
| 634 |
+
(the cover/header convention most chapters follow) or, failing that,
|
| 635 |
+
the latest date in its own 'Updates to chapter -- Listing by date'
|
| 636 |
+
block. ENF 2's ATIP copy prints no date on its cover; its listing,
|
| 637 |
+
on ATIP page A-2023-83511-000005, opens with 2021-08-25. Dates later
|
| 638 |
+
than `today` are ignored in the listing. Pure, for tests."""
|
| 639 |
+
dates = _DATE.findall("\n".join(pages[:3]))
|
| 640 |
+
if dates:
|
| 641 |
+
return max(dates)
|
| 642 |
+
from datetime import date
|
| 643 |
+
today = today or date.today().isoformat()
|
| 644 |
+
lines = "\n".join(pages[:10]).split("\n")
|
| 645 |
+
for i, line in enumerate(lines):
|
| 646 |
+
s = _norm(line)
|
| 647 |
+
if _LISTING.match(s) and not _LEADERS.search(s):
|
| 648 |
+
found = [d for d in _DATE.findall("\n".join(lines[i:i + 40]))
|
| 649 |
+
if d <= today]
|
| 650 |
+
if found:
|
| 651 |
+
return max(found)
|
| 652 |
+
return ""
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def _pdf_text(path):
|
| 656 |
+
from pypdf import PdfReader
|
| 657 |
+
reader = PdfReader(str(path))
|
| 658 |
+
pages = []
|
| 659 |
+
for page in reader.pages:
|
| 660 |
+
try:
|
| 661 |
+
pages.append(page.extract_text() or "")
|
| 662 |
+
except Exception:
|
| 663 |
+
pages.append("")
|
| 664 |
+
return pages
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def _pieces(rows):
|
| 668 |
+
"""Split normalised (line, pin) rows into chunk-sized pieces, keeping
|
| 669 |
+
each piece's pins: [(text, pinpoint)]. Same boundaries as before the
|
| 670 |
+
pins existed -- split_lines only ever cuts between lines, so walking its
|
| 671 |
+
pieces' line counts recovers each piece's rows."""
|
| 672 |
+
body = "\n".join(t for t, _ in rows)
|
| 673 |
+
texts = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 674 |
+
out, at = [], 0
|
| 675 |
+
for text in texts:
|
| 676 |
+
count = text.count("\n") + 1
|
| 677 |
+
out.append((text, _pinpoint([p for _, p in rows[at:at + count]])))
|
| 678 |
+
at += count
|
| 679 |
+
return out
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def _chunks_for(n, pages, url, lang="en", atip=False, stats=None):
|
| 683 |
+
"""Chunks for one chapter. `atip` strips an ATIP release's furniture
|
| 684 |
+
and pinpoints its page stamps; `stats`, if given, collects the rejected
|
| 685 |
+
heading candidates by reason."""
|
| 686 |
+
title = TITLES.get(n, f"ENF {n}")
|
| 687 |
+
fr = lang == "fr"
|
| 688 |
+
# a line-final soft hyphen is the printed hyphen of a broken word; kept
|
| 689 |
+
# as '-' so it survives norm_ws for the dehyphenate pass below
|
| 690 |
+
pages = [mark_soft_breaks(page) for page in pages]
|
| 691 |
+
updated = chapter_updated(pages)
|
| 692 |
+
rows = (clean_atip_pages(pages) if atip
|
| 693 |
+
else [(line, None) for page in pages for line in page.split("\n")])
|
| 694 |
+
lines = [line for line, _ in rows]
|
| 695 |
+
annex_at = _annex_start(n, lines)
|
| 696 |
+
main_end = len(lines) if annex_at is None else annex_at
|
| 697 |
+
# Segment on the manual's own numbered headings when enough are found;
|
| 698 |
+
# PDF extraction is uneven, so fall back to plain size-splitting.
|
| 699 |
+
cands = []
|
| 700 |
+
for i, line in enumerate(lines[:main_end]):
|
| 701 |
+
m = _HEADING.match(line.strip())
|
| 702 |
+
if not m:
|
| 703 |
+
continue
|
| 704 |
+
heading = _norm(m.group(2))
|
| 705 |
+
why = heading_rejection(heading)
|
| 706 |
+
if why:
|
| 707 |
+
if stats is not None:
|
| 708 |
+
stats[why] = stats.get(why, 0) + 1
|
| 709 |
+
continue
|
| 710 |
+
cands.append((i, m.group(1), heading))
|
| 711 |
+
spans = [len(_norm(" ".join(lines[i + 1:(cands[k + 1][0] if k + 1 < len(cands)
|
| 712 |
+
else main_end)])))
|
| 713 |
+
for k, (i, _num, _h) in enumerate(cands)]
|
| 714 |
+
keep = numbering_run([num for _, num, _ in cands],
|
| 715 |
+
[span > 120 for span in spans])
|
| 716 |
+
if stats is not None and len(keep) < len(cands):
|
| 717 |
+
stats["out-of-sequence"] = (stats.get("out-of-sequence", 0)
|
| 718 |
+
+ len(cands) - len(keep))
|
| 719 |
+
marks = [c for k, c in enumerate(cands) if k in keep]
|
| 720 |
+
words = chapter_words(lines)
|
| 721 |
+
|
| 722 |
+
def full_title(heading, seg):
|
| 723 |
+
# seg[0] is the heading line itself; the title may wrap onto the
|
| 724 |
+
# lines after it (complete_heading).
|
| 725 |
+
return repair_split_words(
|
| 726 |
+
complete_heading(heading, [t for t, _ in seg[1:]], lang), words)
|
| 727 |
+
|
| 728 |
+
# (id key, section, marginal note, heading field, cite label, rows)
|
| 729 |
+
segments = []
|
| 730 |
+
if len(marks) >= 6:
|
| 731 |
+
# Front matter: the cover and the 'Updates to chapter' listing, which
|
| 732 |
+
# say when and how the chapter changed. Before the heading vetting it
|
| 733 |
+
# survived by accident, under a table-of-contents line taken for a
|
| 734 |
+
# heading; now it precedes the first heading, so it is kept as its
|
| 735 |
+
# own segment, minus the contents lines: dot-leader entries, and the
|
| 736 |
+
# numbered entries a contents page prints without leaders (ENF 14's
|
| 737 |
+
# OCR lists all 36 section titles that way) -- a block of every
|
| 738 |
+
# section title would compete with the sections themselves. A
|
| 739 |
+
# numbered line that is not a contents entry stays: the update
|
| 740 |
+
# listing's own notes often open with the section they changed
|
| 741 |
+
# ("7.5 The table for search levels previously found at 7.6 has
|
| 742 |
+
# been modified", ENF 12).
|
| 743 |
+
body_heads = _body_heads(lines, marks[0][0], main_end)
|
| 744 |
+
front = [(t, p) for t, p in _norm_rows(rows[:marks[0][0]])
|
| 745 |
+
if not _LEADERS.search(t)
|
| 746 |
+
and not contents_entry(t, body_heads)]
|
| 747 |
+
if len("\n".join(t for t, _ in front)) > 120:
|
| 748 |
+
heading = _FRONT_LABEL[lang]
|
| 749 |
+
label = f"{'liminaire' if fr else 'front matter'} ({heading})"
|
| 750 |
+
segments.append(("front", f"ENF {n} front matter", heading, "",
|
| 751 |
+
label, "", front))
|
| 752 |
+
apps = appendix_marks(lines, marks[-1][0] + 1, main_end)
|
| 753 |
+
body_end = apps[0][0] if apps else main_end
|
| 754 |
+
for j, (i, num, heading) in enumerate(marks):
|
| 755 |
+
end = marks[j + 1][0] if j + 1 < len(marks) else body_end
|
| 756 |
+
seg = _norm_rows(rows[i:end])
|
| 757 |
+
if len("\n".join(t for t, _ in seg)) > 120:
|
| 758 |
+
heading = full_title(heading, seg)
|
| 759 |
+
label = (f"art. {num} ({heading})" if fr
|
| 760 |
+
else f"s. {num} ({heading})")
|
| 761 |
+
segments.append((num.replace(".", "-"), f"ENF {n} s. {num}",
|
| 762 |
+
heading, "", label, "", seg))
|
| 763 |
+
carry = []
|
| 764 |
+
for j, (i, letter, heading) in enumerate(apps):
|
| 765 |
+
end = apps[j + 1][0] if j + 1 < len(apps) else main_end
|
| 766 |
+
seg = carry + _norm_rows(rows[i:end])
|
| 767 |
+
if len("\n".join(t for t, _ in seg)) <= 120 and j + 1 < len(apps):
|
| 768 |
+
# A title-only appendix (its form is an image: ENF 20's
|
| 769 |
+
# "Annex A - Detention Oversight Form") joins the next one
|
| 770 |
+
# rather than vanishing.
|
| 771 |
+
carry = seg
|
| 772 |
+
continue
|
| 773 |
+
carry = []
|
| 774 |
+
if len("\n".join(t for t, _ in seg)) > 120:
|
| 775 |
+
heading = full_title(heading, _norm_rows(rows[i:end]))
|
| 776 |
+
label = f"{'app.' if fr else 'App.'} {letter} ({heading})"
|
| 777 |
+
segments.append((f"app-{letter.lower()}",
|
| 778 |
+
f"ENF {n} App. {letter}", heading, "",
|
| 779 |
+
label, "", seg))
|
| 780 |
+
if not segments:
|
| 781 |
+
text = "\n".join(lines[:main_end])
|
| 782 |
+
at = 0
|
| 783 |
+
for k, piece in enumerate(split_lines(text, 1800), start=1):
|
| 784 |
+
count = piece.count("\n") + 1
|
| 785 |
+
seg = _norm_rows(rows[at:at + count])
|
| 786 |
+
at += count
|
| 787 |
+
if len("\n".join(t for t, _ in seg)) > 120:
|
| 788 |
+
heading = f"{title} (part {k})"
|
| 789 |
+
label = (f"art. {k} ({heading})" if fr
|
| 790 |
+
else f"s. {k} ({heading})")
|
| 791 |
+
segments.append((str(k), f"ENF {n} s. {k}", heading, "",
|
| 792 |
+
label, "", seg))
|
| 793 |
+
if annex_at is not None:
|
| 794 |
+
segments.extend(_annex_as_segments(n, rows[annex_at:], lang))
|
| 795 |
+
out = []
|
| 796 |
+
for key, section, heading, ground, label, division, seg in segments:
|
| 797 |
+
pieces = _pieces(seg)
|
| 798 |
+
total = len(pieces)
|
| 799 |
+
for k, (piece, pin) in enumerate(pieces, start=1):
|
| 800 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 801 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 802 |
+
where = f", ATIP p. {pin}" if pin else ""
|
| 803 |
+
out.append({
|
| 804 |
+
"id": (f"enf-fr-{n}-{key}{suffix}" if fr
|
| 805 |
+
else f"enf-{n}-{key}{suffix}"),
|
| 806 |
+
"doc_type": "memorandum",
|
| 807 |
+
"act_code": f"ENF-{n}",
|
| 808 |
+
"act_short": f"ENF {n}",
|
| 809 |
+
"act_name": (f"ENF {n} — {title} (édition française)" if fr
|
| 810 |
+
else f"ENF {n} — {title}"),
|
| 811 |
+
"section": section,
|
| 812 |
+
"marginal_note": heading,
|
| 813 |
+
"part": title,
|
| 814 |
+
"division": division,
|
| 815 |
+
"heading": ground,
|
| 816 |
+
# line-break hyphens closed up inside the chunk only, after
|
| 817 |
+
# the titles, ids and pins are fixed (2026-09-22)
|
| 818 |
+
"text": dehyphenate(piece, words),
|
| 819 |
+
"history": "",
|
| 820 |
+
"last_amended": updated,
|
| 821 |
+
"current_to": updated,
|
| 822 |
+
"citation": f"ENF {n}, {label}{note}{where}",
|
| 823 |
+
"source_url": url,
|
| 824 |
+
**({"lang": "fr"} if fr else {}),
|
| 825 |
+
})
|
| 826 |
+
return out
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def _annex_as_segments(n, rows, lang):
|
| 830 |
+
"""ENF 1's jurisprudence annex as per-case segments (annex_segments)."""
|
| 831 |
+
fr = lang == "fr"
|
| 832 |
+
app = "app. A" if fr else "App. A"
|
| 833 |
+
segments = []
|
| 834 |
+
for seg in annex_segments(rows):
|
| 835 |
+
if seg["case"] is None:
|
| 836 |
+
heading, key, label = (_ANNEX_LABEL[lang].split(" — ", 1)[1],
|
| 837 |
+
"app-a", app)
|
| 838 |
+
ground = ""
|
| 839 |
+
else:
|
| 840 |
+
ground = seg["ground"]
|
| 841 |
+
gslug = _ground_slug(ground)
|
| 842 |
+
key = "-".join(p for p in ("app-a", gslug,
|
| 843 |
+
_party_slug(seg["case"])) if p)
|
| 844 |
+
heading = seg["case"]
|
| 845 |
+
k = seg["absorbed"]
|
| 846 |
+
if k:
|
| 847 |
+
heading += (f" (et {k} autre{'s' if k > 1 else ''} "
|
| 848 |
+
f"décision{'s' if k > 1 else ''})" if fr else
|
| 849 |
+
f" (and {k} more case{'s' if k > 1 else ''})")
|
| 850 |
+
sep = " : " if fr else ": "
|
| 851 |
+
label = (f"{app} ({ground}{sep}{heading})" if ground
|
| 852 |
+
else f"{app} ({heading})")
|
| 853 |
+
if len("\n".join(t for t, _ in seg["rows"])) > 120:
|
| 854 |
+
segments.append((key, f"ENF {n} App. A", heading, ground, label,
|
| 855 |
+
_ANNEX_LABEL[lang], seg["rows"]))
|
| 856 |
+
return segments
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def _norm_rows(rows):
|
| 860 |
+
"""Normalised non-empty (line, pin) rows."""
|
| 861 |
+
return [(t, p) for t, p in ((_norm(line), p) for line, p in rows) if t]
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
def _norm_block(lines):
|
| 865 |
+
return "\n".join(p for p in (_norm(l) for l in lines) if p)
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
_HTTP_404 = re.compile(r"\b404\b")
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
def is_missing_upstream(exc):
|
| 872 |
+
"""True when a fetch failed because the chapter simply is not there (404).
|
| 873 |
+
|
| 874 |
+
_CHAPTERS is a blind range(1, 41) probe, so on a healthy run most numbers
|
| 875 |
+
404 -- and a chapter IRCC RETIRES starts 404ing too. That is this module's
|
| 876 |
+
documented signal to drop a chapter, so a 404 must not be preserved
|
| 877 |
+
through: doing so would pin every withdrawn chapter in the corpus forever.
|
| 878 |
+
Every other fetch error (TLS, timeout, a dropped connection) is a real
|
| 879 |
+
failure the stored copy should survive. The two fetch paths report
|
| 880 |
+
differently -- urllib raises HTTPError with a status, the PowerShell
|
| 881 |
+
fallback raises CalledProcessError with the status only in its stderr
|
| 882 |
+
prose ("The remote server returned an error: (404) Not Found."), so that
|
| 883 |
+
side is a text match.
|
| 884 |
+
"""
|
| 885 |
+
if isinstance(exc, urllib.error.HTTPError):
|
| 886 |
+
return exc.code == 404
|
| 887 |
+
stderr = getattr(exc, "stderr", "") or ""
|
| 888 |
+
if isinstance(stderr, bytes):
|
| 889 |
+
stderr = stderr.decode("utf-8", "replace")
|
| 890 |
+
# the fetch script's own status line decides when it is there (since the
|
| 891 |
+
# verification fixes of 2026-09-22): a 5xx error page that happens to
|
| 892 |
+
# contain '404' is not a retirement; without the marker, the prose match
|
| 893 |
+
status = powershell_status(stderr)
|
| 894 |
+
if status is not None:
|
| 895 |
+
return status == 404
|
| 896 |
+
return bool(_HTTP_404.search(stderr))
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
def preserve_failed(chunks, failures, stored):
|
| 900 |
+
"""Re-attach the last-good chunks of chapters this run could not produce.
|
| 901 |
+
|
| 902 |
+
A chapter that stops fetching for any reason OTHER than a 404 -- a dropped
|
| 903 |
+
network, a TLS refusal, a PDF that suddenly has no text layer -- has not
|
| 904 |
+
been retired, it just did not arrive, and losing guidance that is still in
|
| 905 |
+
force is the worse error. So the stored copy stays and the run says so
|
| 906 |
+
loudly enough to be investigated. Chapters that 404 (retired, or never
|
| 907 |
+
existed) never enter `failures` and are not resurrected.
|
| 908 |
+
Returns (chunks, preserved act_codes).
|
| 909 |
+
"""
|
| 910 |
+
failed = {f"ENF-{n}" for n, _why in failures}
|
| 911 |
+
kept, preserved = preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 912 |
+
only=failed)
|
| 913 |
+
# A kept chapter goes back in its place, not at the end: on 2026-10-20 the
|
| 914 |
+
# kept ENF 2 and ENF 14 followed ENF 34 in enf.json. The sort is stable, so
|
| 915 |
+
# each chapter's own order is unchanged.
|
| 916 |
+
return sorted(kept, key=_chapter_number), preserved
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
def _chapter_number(chunk):
|
| 920 |
+
"""ENF chapter number of a chunk's act_code ('ENF-14' -> 14); anything else last."""
|
| 921 |
+
m = re.fullmatch(r"ENF-(\d+)", chunk.get("act_code", ""))
|
| 922 |
+
return int(m.group(1)) if m else float("inf")
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
def build(chapters=_CHAPTERS, allow_shrink=False, force=False, lang="en"):
|
| 926 |
+
# Returns the chunks written, or False if the write was refused.
|
| 927 |
+
fr = lang == "fr"
|
| 928 |
+
all_chunks, skipped, failures = [], [], []
|
| 929 |
+
for n in chapters:
|
| 930 |
+
unofficial = None if fr else UNOFFICIAL.get(n)
|
| 931 |
+
if fr and n in _FR_STALE:
|
| 932 |
+
skipped.append(n)
|
| 933 |
+
continue
|
| 934 |
+
if fr:
|
| 935 |
+
url = _URL_OVERRIDES_FR.get(n, BASE_FR.format(n=n))
|
| 936 |
+
else:
|
| 937 |
+
url = (unofficial["url"] if unofficial
|
| 938 |
+
else _URL_OVERRIDES.get(n, BASE.format(n=n)))
|
| 939 |
+
dest = RAW / (f"enf{n:02d}-fra.pdf" if fr else f"enf{n:02d}.pdf")
|
| 940 |
+
try:
|
| 941 |
+
# The ATIP host is an ordinary WordPress site; canada.ca needs
|
| 942 |
+
# the PowerShell TLS workaround.
|
| 943 |
+
fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0,
|
| 944 |
+
force=force)
|
| 945 |
+
except Exception as exc:
|
| 946 |
+
skipped.append(n)
|
| 947 |
+
if not is_missing_upstream(exc):
|
| 948 |
+
# Not a 404: the chapter may well still be published, so it
|
| 949 |
+
# counts as a failure and its stored chunks are preserved.
|
| 950 |
+
failures.append((n, f"fetch failed: {type(exc).__name__}: {exc}"))
|
| 951 |
+
if dest.exists():
|
| 952 |
+
dest.unlink() # don't cache an error page as a PDF
|
| 953 |
+
continue
|
| 954 |
+
try:
|
| 955 |
+
pages = _pdf_text(dest)
|
| 956 |
+
if sum(len(p) for p in pages) < 500:
|
| 957 |
+
# Image-scan PDF: fall back to the OCR cache (form-feed
|
| 958 |
+
# separated pages), produced offline via Windows OCR.
|
| 959 |
+
ocr = RAW / (f"enf{n:02d}-fra-ocr.txt" if fr
|
| 960 |
+
else f"enf{n:02d}-ocr.txt")
|
| 961 |
+
if ocr.exists():
|
| 962 |
+
pages = ocr.read_text(encoding="utf-8").split("\f")
|
| 963 |
+
print(f" ENF {n:2d}: using OCR cache "
|
| 964 |
+
f"({sum(len(p) for p in pages)} chars)")
|
| 965 |
+
else:
|
| 966 |
+
print(f" !! ENF {n}: no text layer and no OCR cache "
|
| 967 |
+
f"({ocr.name}); skipping")
|
| 968 |
+
failures.append((n, f"no text layer and no {ocr.name}"))
|
| 969 |
+
continue
|
| 970 |
+
rejected = {}
|
| 971 |
+
chunks = _chunks_for(n, pages, url, lang=lang,
|
| 972 |
+
atip=bool(unofficial), stats=rejected)
|
| 973 |
+
if rejected:
|
| 974 |
+
print(f" ENF {n:2d}: heading candidates rejected: "
|
| 975 |
+
+ ", ".join(f"{k} {v}" for k, v in
|
| 976 |
+
sorted(rejected.items())))
|
| 977 |
+
if not chunks:
|
| 978 |
+
# Fetched fine, parsed to nothing: the PDF's heading structure
|
| 979 |
+
# changed under us. That is the shape the D-memo loss took, so
|
| 980 |
+
# treat it as a failure and keep the stored chapter.
|
| 981 |
+
print(f" !! ENF {n}: no chunks parsed; keeping stored copy")
|
| 982 |
+
failures.append((n, "no content parsed"))
|
| 983 |
+
continue
|
| 984 |
+
except Exception as exc:
|
| 985 |
+
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
| 986 |
+
failures.append((n, f"parse failed: {type(exc).__name__}: {exc}"))
|
| 987 |
+
continue
|
| 988 |
+
if unofficial:
|
| 989 |
+
for c in chunks:
|
| 990 |
+
c["act_name"] = f"ENF {n} — {unofficial['title']}"
|
| 991 |
+
c["text"] = _UNOFFICIAL_BANNER + c["text"]
|
| 992 |
+
c["citation"] += " (unofficial ATIP copy)"
|
| 993 |
+
c["history"] = unofficial["note"]
|
| 994 |
+
c["source_status"] = ATIP_STATUS
|
| 995 |
+
print(f" ENF {n:2d}: {len(chunks)} chunks"
|
| 996 |
+
+ (" [UNOFFICIAL ATIP]" if unofficial else ""))
|
| 997 |
+
all_chunks.extend(chunks)
|
| 998 |
+
uniquify_ids(all_chunks)
|
| 999 |
+
out = PROCESSED_DIR / ("enf-fr.json" if fr else "enf.json")
|
| 1000 |
+
# Preserved chunks keep the ids they were stored with, so they are added
|
| 1001 |
+
# after uniquify_ids -- ids embed the chapter number, and a preserved
|
| 1002 |
+
# chapter is by definition absent from this run's chunks.
|
| 1003 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 1004 |
+
stored_chunks(out))
|
| 1005 |
+
if preserved:
|
| 1006 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s) "
|
| 1007 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 1008 |
+
if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
|
| 1009 |
+
return False
|
| 1010 |
+
print(f"enf: {len(all_chunks)} chunks -> {out.name} "
|
| 1011 |
+
f"(chapters absent upstream: {skipped})")
|
| 1012 |
+
return all_chunks
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def main():
|
| 1016 |
+
chunks = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 1017 |
+
force="--force" in sys.argv,
|
| 1018 |
+
lang="fr" if "--fr" in sys.argv else "en")
|
| 1019 |
+
sys.exit(1 if chunks is False else 0)
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
if __name__ == "__main__":
|
| 1023 |
+
main()
|
canlex/eval.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', 'criminality',
|
| 14 |
+
'lookup' (a section lookup in the officer's phrasing, the
|
| 15 |
+
pinned provision as gold -- 'IRPA 36(1)(a)', 'ss. 17 and 18
|
| 16 |
+
of IRPR'; 2026-09-21), ... -- metrics are reported per style
|
| 17 |
+
so a hard new slice cannot silently drag the comparable
|
| 18 |
+
legacy numbers
|
| 19 |
+
added optional YYYY-MM-DD: the date the item joined the set. Any
|
| 20 |
+
dated item is outside the frozen legacy slice.
|
| 21 |
+
holdout optional bool; holdout questions are excluded from parameter
|
| 22 |
+
sweeps (sweep.py) so knob choices cannot fit their noise --
|
| 23 |
+
eval.py reports them separately
|
| 24 |
+
must_see optional list of keys that should ALL be in view (the top
|
| 25 |
+
VIEW_K results): {act_short, marginal_note}, {act_short,
|
| 26 |
+
section}, or {act_short} alone for a case (any paragraph).
|
| 27 |
+
Guidance is keyed by marginal note, so a re-chunk that
|
| 28 |
+
renumbers its sections does not invalidate the key. A
|
| 29 |
+
must_see key is not an answer: it never scores Hit@k.
|
| 30 |
+
'no-answer' style items carry no answers: they exist to calibrate the
|
| 31 |
+
confidence signal (a good corpus-coverage score should be LOW on them),
|
| 32 |
+
reported as a separate table, never as misses.
|
| 33 |
+
|
| 34 |
+
Beyond Hit@k and MRR, each slice reports (roadmap T5.1):
|
| 35 |
+
cov@6 mean share of an item's must_see keys in the top VIEW_K results
|
| 36 |
+
(items with must_see only)
|
| 37 |
+
guid@6 share of items with a guidance result (doc_type 'memorandum':
|
| 38 |
+
ENF, PDI, IRB papers, IRCC guides) in the top VIEW_K that is an
|
| 39 |
+
answer or a must_see key (items naming guidance only)
|
| 40 |
+
Each is printed twice, 'a/b': a over the ranked view, b with the
|
| 41 |
+
official guidance the index co-surfaces for that view ('read next',
|
| 42 |
+
LegislationIndex._cosurface_guidance), which follows the results and
|
| 43 |
+
is never ranked.
|
| 44 |
+
|
| 45 |
+
py -m canlex.eval
|
| 46 |
+
"""
|
| 47 |
+
import json
|
| 48 |
+
import os
|
| 49 |
+
import sys
|
| 50 |
+
from collections import defaultdict
|
| 51 |
+
|
| 52 |
+
from .config import ROOT
|
| 53 |
+
from .index import LegislationIndex
|
| 54 |
+
|
| 55 |
+
QUESTIONS = ROOT / "data" / "eval" / "questions.json"
|
| 56 |
+
EVAL_TOP_K = 20 # search depth, so ranks past the usual 6 are still visible
|
| 57 |
+
VIEW_K = 6 # what the officer sees: cov@6 and guid@6 read this window
|
| 58 |
+
GUIDANCE_DOC_TYPES = frozenset({"memorandum"})
|
| 59 |
+
|
| 60 |
+
# The historical baseline slice: styles that existed before the 2026-07
|
| 61 |
+
# expansion. Numbers over this slice stay comparable across commits -- which
|
| 62 |
+
# only holds if the slice is frozen, so a question added later carries an
|
| 63 |
+
# `added` date (YYYY-MM-DD) and is excluded here while still reported in its
|
| 64 |
+
# style slice. Without this, eleven hard questions added on 2026-09-09
|
| 65 |
+
# moved legacy Hit@1 by 0.05 with no existing question regressing.
|
| 66 |
+
_LEGACY_STYLES = {"standard"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def is_legacy(item):
|
| 70 |
+
return item["style"] in _LEGACY_STYLES and not item.get("added")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_questions():
|
| 74 |
+
"""The question set with defaults applied; None if the file is absent."""
|
| 75 |
+
if not QUESTIONS.exists():
|
| 76 |
+
return None
|
| 77 |
+
items = json.loads(QUESTIONS.read_text(encoding="utf-8"))
|
| 78 |
+
for item in items:
|
| 79 |
+
item.setdefault("style", "standard")
|
| 80 |
+
item.setdefault("holdout", False)
|
| 81 |
+
return items
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def sweep_questions():
|
| 85 |
+
"""The slice parameter sweeps may see (sweep.py calls this): everything
|
| 86 |
+
except holdout items and the no-answer calibration set."""
|
| 87 |
+
items = load_questions() or []
|
| 88 |
+
return [i for i in items
|
| 89 |
+
if not i["holdout"] and i["style"] != "no-answer"]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _matches(result, answers):
|
| 93 |
+
"""True if a search result is one of the gold answers (act + section).
|
| 94 |
+
|
| 95 |
+
A gold answer is [act, section]; an empty section matches any chunk of that
|
| 96 |
+
act/case (used for case-law answers, whose chunks carry no section number).
|
| 97 |
+
"""
|
| 98 |
+
r_acts = {result.get("act_short", "").lower(),
|
| 99 |
+
result.get("act_code", "").lower()}
|
| 100 |
+
r_sec = result.get("section", "")
|
| 101 |
+
for act, section in answers:
|
| 102 |
+
if act.lower() in r_acts and (section == r_sec or section == ""):
|
| 103 |
+
return True
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _norm(text):
|
| 108 |
+
return " ".join((text or "").split()).lower()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def must_match(result, key):
|
| 112 |
+
"""True if a result satisfies one must_see key. The key's act_short may
|
| 113 |
+
be the act's short name or its code; a marginal_note compares case- and
|
| 114 |
+
whitespace-folded; an empty section, or no section and no marginal note,
|
| 115 |
+
matches any chunk of the act or case."""
|
| 116 |
+
if _norm(key.get("act_short")) not in {_norm(result.get("act_short")),
|
| 117 |
+
_norm(result.get("act_code"))}:
|
| 118 |
+
return False
|
| 119 |
+
if "marginal_note" in key:
|
| 120 |
+
return _norm(result.get("marginal_note")) == _norm(key["marginal_note"])
|
| 121 |
+
if "section" in key:
|
| 122 |
+
return key["section"] == "" or result.get("section", "") == key["section"]
|
| 123 |
+
return True
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def guidance_acts(chunks):
|
| 127 |
+
"""Case-folded act_short and act_code of every guidance chunk, so an item
|
| 128 |
+
can tell whether its answers or must_see keys name guidance at all."""
|
| 129 |
+
out = set()
|
| 130 |
+
for c in chunks:
|
| 131 |
+
if c.get("doc_type") in GUIDANCE_DOC_TYPES:
|
| 132 |
+
out.add(_norm(c.get("act_short")))
|
| 133 |
+
out.add(_norm(c.get("act_code")))
|
| 134 |
+
out.discard("")
|
| 135 |
+
return frozenset(out)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def score_item(item, results, read_next=(), guidance=frozenset()):
|
| 139 |
+
"""Per-question measures from one search.
|
| 140 |
+
|
| 141 |
+
rank 1-based rank of the first result matching an answer (0 = absent).
|
| 142 |
+
Co-surfaced read-next results (marked 'cosurfaced') follow the
|
| 143 |
+
ranked list and are never ranked.
|
| 144 |
+
cov6 share of the item's must_see keys in the top VIEW_K (None
|
| 145 |
+
without must_see); cov6_rn adds `read_next`, the guidance the
|
| 146 |
+
index co-surfaces for that view.
|
| 147 |
+
g6 a guidance result in the top VIEW_K that is an answer or a
|
| 148 |
+
must_see key (None when the item names no guidance -- `guidance`
|
| 149 |
+
is guidance_acts()); g6_rn adds `read_next`."""
|
| 150 |
+
ranked = [r for r in results if not r.get("cosurfaced")]
|
| 151 |
+
answers = [tuple(a) for a in item.get("answers") or []]
|
| 152 |
+
rank = next((i for i, r in enumerate(ranked, start=1)
|
| 153 |
+
if _matches(r, answers)), 0)
|
| 154 |
+
keys = item.get("must_see") or []
|
| 155 |
+
view = ranked[:VIEW_K]
|
| 156 |
+
view_rn = view + list(read_next)
|
| 157 |
+
|
| 158 |
+
def coverage(rows):
|
| 159 |
+
if not keys:
|
| 160 |
+
return None
|
| 161 |
+
return sum(1 for k in keys
|
| 162 |
+
if any(must_match(r, k) for r in rows)) / len(keys)
|
| 163 |
+
|
| 164 |
+
wants = (any(_norm(a) in guidance for a, _ in answers)
|
| 165 |
+
or any(_norm(k.get("act_short")) in guidance for k in keys))
|
| 166 |
+
|
| 167 |
+
def guided(rows):
|
| 168 |
+
if not wants:
|
| 169 |
+
return None
|
| 170 |
+
return any(r.get("doc_type") in GUIDANCE_DOC_TYPES
|
| 171 |
+
and (_matches(r, answers)
|
| 172 |
+
or any(must_match(r, k) for k in keys))
|
| 173 |
+
for r in rows)
|
| 174 |
+
|
| 175 |
+
return {"rank": rank, "cov6": coverage(view), "cov6_rn": coverage(view_rn),
|
| 176 |
+
"g6": guided(view), "g6_rn": guided(view_rn)}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _metrics(ranks):
|
| 180 |
+
n = len(ranks) or 1
|
| 181 |
+
hit = lambda k: sum(1 for r in ranks if 0 < r <= k) / n
|
| 182 |
+
mrr = sum(1.0 / r for r in ranks if r) / n
|
| 183 |
+
return hit(1), hit(3), hit(5), hit(10), mrr
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def summarize(scores):
|
| 187 |
+
"""Slice aggregates over score_item() dicts."""
|
| 188 |
+
ranks = [s["rank"] for s in scores]
|
| 189 |
+
h1, h3, h5, h10, mrr = _metrics(ranks)
|
| 190 |
+
out = {"n": len(scores), "hit1": h1, "hit3": h3, "hit5": h5,
|
| 191 |
+
"hit10": h10, "mrr": mrr}
|
| 192 |
+
for field in ("cov6", "cov6_rn", "g6", "g6_rn"):
|
| 193 |
+
vals = [float(s[field]) for s in scores if s.get(field) is not None]
|
| 194 |
+
out[field] = sum(vals) / len(vals) if vals else None
|
| 195 |
+
out[f"n_{field}"] = len(vals)
|
| 196 |
+
return out
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _report_line(label, scores):
|
| 200 |
+
m = summarize(scores)
|
| 201 |
+
line = (f" {label:24} n={m['n']:<4} Hit@1 {m['hit1']:.2f} "
|
| 202 |
+
f"Hit@3 {m['hit3']:.2f} Hit@5 {m['hit5']:.2f} "
|
| 203 |
+
f"Hit@10 {m['hit10']:.2f} MRR {m['mrr']:.2f}")
|
| 204 |
+
if m["n_cov6"]:
|
| 205 |
+
line += f" cov@6 {m['cov6']:.2f}/{m['cov6_rn']:.2f} (n={m['n_cov6']})"
|
| 206 |
+
if m["n_g6"]:
|
| 207 |
+
line += f" guid@6 {m['g6']:.2f}/{m['g6_rn']:.2f} (n={m['n_g6']})"
|
| 208 |
+
print(line)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def read_next_for(index, results, positions):
|
| 212 |
+
"""The guidance chunks the index co-surfaces for the top VIEW_K of
|
| 213 |
+
`results` (what an officer asking for VIEW_K results would get)."""
|
| 214 |
+
top = [positions[r["id"]] for r in results
|
| 215 |
+
if not r.get("cosurfaced") and r.get("id") in positions][:VIEW_K]
|
| 216 |
+
return [index.chunks[i] for i in index._cosurface_guidance(top)[len(top):]]
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def run():
|
| 220 |
+
items = load_questions()
|
| 221 |
+
if items is None:
|
| 222 |
+
print(f"No question set at {QUESTIONS}.", file=sys.stderr)
|
| 223 |
+
return
|
| 224 |
+
# Sweep mode (CANLEX_EVAL_SWEEP=1, set by sweep.py): score only the
|
| 225 |
+
# sweep-visible slice and print the legacy vertical metric block its
|
| 226 |
+
# parser expects -- holdout questions stay blind to every knob choice.
|
| 227 |
+
sweep_mode = bool(os.environ.get("CANLEX_EVAL_SWEEP"))
|
| 228 |
+
if sweep_mode:
|
| 229 |
+
items = [i for i in items
|
| 230 |
+
if not i["holdout"] and i["style"] != "no-answer"]
|
| 231 |
+
index = LegislationIndex()
|
| 232 |
+
positions = {c["id"]: i for i, c in enumerate(index.chunks)}
|
| 233 |
+
guidance = guidance_acts(index.chunks)
|
| 234 |
+
scored = [] # (item, score_item dict)
|
| 235 |
+
misses = []
|
| 236 |
+
no_answer = [] # (query, top confidence) for the calibration table
|
| 237 |
+
for item in items:
|
| 238 |
+
if item["style"] == "no-answer":
|
| 239 |
+
results = index.search(item["query"], top_k=3)
|
| 240 |
+
conf = results[0].get("confidence") if results else None
|
| 241 |
+
no_answer.append((item["query"], conf))
|
| 242 |
+
continue
|
| 243 |
+
results = index.search(item["query"], top_k=EVAL_TOP_K)
|
| 244 |
+
score = score_item(item, results,
|
| 245 |
+
read_next_for(index, results, positions), guidance)
|
| 246 |
+
scored.append((item, score))
|
| 247 |
+
rank = score["rank"]
|
| 248 |
+
if rank == 0 or rank > 5:
|
| 249 |
+
ranked = [r for r in results if not r.get("cosurfaced")]
|
| 250 |
+
misses.append((item, [tuple(a) for a in item["answers"]], rank,
|
| 251 |
+
ranked[0] if ranked else None))
|
| 252 |
+
|
| 253 |
+
all_scores = [s for _, s in scored]
|
| 254 |
+
if sweep_mode:
|
| 255 |
+
h1, h3, h5, h10, mrr = _metrics([s["rank"] for s in all_scores])
|
| 256 |
+
print(f"CanLex retrieval evaluation -- {len(all_scores)} questions "
|
| 257 |
+
f"(sweep slice)\n")
|
| 258 |
+
print(f" Hit@1: {h1:.2f}")
|
| 259 |
+
print(f" Hit@3: {h3:.2f}")
|
| 260 |
+
print(f" Hit@5: {h5:.2f}")
|
| 261 |
+
print(f" Hit@10: {h10:.2f}")
|
| 262 |
+
print(f" MRR: {mrr:.2f}")
|
| 263 |
+
return
|
| 264 |
+
by_style = defaultdict(list)
|
| 265 |
+
for it, s in scored:
|
| 266 |
+
by_style[it["style"]].append(s)
|
| 267 |
+
|
| 268 |
+
print(f"CanLex retrieval evaluation -- {len(all_scores)} scored questions")
|
| 269 |
+
source = getattr(index, "see_also_source", None)
|
| 270 |
+
print(f" (cov@6 and guid@6 print as ranked-view/with-read-next; "
|
| 271 |
+
f"read-next source: {source or 'none -- off'})\n")
|
| 272 |
+
_report_line("ALL", all_scores)
|
| 273 |
+
_report_line("legacy (comparable)", [s for it, s in scored if is_legacy(it)])
|
| 274 |
+
for style in sorted(by_style):
|
| 275 |
+
if style not in _LEGACY_STYLES:
|
| 276 |
+
_report_line(f"style: {style}", by_style[style])
|
| 277 |
+
held = [s for it, s in scored if it["holdout"]]
|
| 278 |
+
if held:
|
| 279 |
+
_report_line("holdout (sweep-blind)", held)
|
| 280 |
+
# The release gate's holdout line (with legacy). A dated slice's
|
| 281 |
+
# holdouts join the line above -- the 2026-09-15 criminality slice's
|
| 282 |
+
# ten took it from Hit@1 0.75 to 0.66 with no comparable question
|
| 283 |
+
# moving -- so the gate reads the undated holdouts, frozen like
|
| 284 |
+
# legacy and comparable across commits.
|
| 285 |
+
_report_line("holdout (undated)",
|
| 286 |
+
[s for it, s in scored if it["holdout"] and not it.get("added")])
|
| 287 |
+
_report_line("sweep-visible", [s for it, s in scored if not it["holdout"]])
|
| 288 |
+
|
| 289 |
+
if no_answer:
|
| 290 |
+
print("\nNo-answer calibration (confidence should be LOW here; the "
|
| 291 |
+
"weak-match hedge fires below 0.72):")
|
| 292 |
+
for query, conf in no_answer:
|
| 293 |
+
shown = f"{conf:.3f}" if conf is not None else "n/a"
|
| 294 |
+
flag = "" if (conf is None or conf < 0.72) else " <-- OVER-CONFIDENT"
|
| 295 |
+
print(f" {shown} {query[:70]}{flag}")
|
| 296 |
+
|
| 297 |
+
if misses:
|
| 298 |
+
print(f"\n{len(misses)} miss(es) -- gold answer ranked >5 or absent:")
|
| 299 |
+
for item, answers, rank, top in misses:
|
| 300 |
+
gold = ", ".join(f"{a} s.{s}".rstrip(" s.") for a, s in answers)
|
| 301 |
+
where = f"ranked #{rank}" if rank else f"absent (searched {EVAL_TOP_K})"
|
| 302 |
+
got = (f"{top.get('act_short', '')} s.{top.get('section', '')}".rstrip(" s.")
|
| 303 |
+
if top else "nothing")
|
| 304 |
+
print(f" [{where}] ({item['style']}) {item['query']}")
|
| 305 |
+
print(f" gold: {gold} | top result: {got}")
|
| 306 |
+
print()
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
run()
|
canlex/index.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/ingest.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest Justice Laws XML into structured, section-level JSON chunks."""
|
| 2 |
+
import contextlib
|
| 3 |
+
import re
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
import urllib.request
|
| 7 |
+
import xml.etree.ElementTree as ET
|
| 8 |
+
|
| 9 |
+
from ._common import (atomic_write_bytes, norm_ws as _norm, split_lines,
|
| 10 |
+
with_retry, write_corpus)
|
| 11 |
+
from .config import SOURCES, RAW_DIR, PROCESSED_DIR
|
| 12 |
+
|
| 13 |
+
LIMS = "{http://justice.gc.ca/lims}"
|
| 14 |
+
BLOCK_TAGS = {"Subsection", "Paragraph", "Subparagraph", "Clause", "Definition"}
|
| 15 |
+
# A French consolidation heads its divisions 'SECTION 1', 'SECTION 7.1', 'SECTION IV'
|
| 16 |
+
# where the English one says 'DIVISION'; the division field was empty on every
|
| 17 |
+
# French chunk (2026-10-12: all 571 of the CASR 2012's). Upper case and a bare
|
| 18 |
+
# label: an English heading 'Section 5 of the Act — Trafficking' (SOR/97-234) is
|
| 19 |
+
# not a division.
|
| 20 |
+
_FR_DIVISION = re.compile(r"SECTION [0-9IVXLC]+(?:\.\d+)?(?: - |$)")
|
| 21 |
+
|
| 22 |
+
# A section whose entire rendered body is a placeholder marker -- "30 [Amendment]",
|
| 23 |
+
# "162 [Amendments]", "5 [Related provision]" -- left where an enacting Act amended
|
| 24 |
+
# another Act. Content-free; dropped at ingest. ([Repealed, ...] is deliberately
|
| 25 |
+
# excluded here so repealed sections are kept and flagged instead.)
|
| 26 |
+
#
|
| 27 |
+
# The French consolidations print the same placeholders as "[Modification]",
|
| 28 |
+
# "[Modifications]" and "[Disposition connexe]" (measured 2026-09-21: 229, 125
|
| 29 |
+
# and 2 in the raw FR XML); until then the pattern was English-only and the
|
| 30 |
+
# French twins kept 314 content-free stubs as live provisions. A stub may name
|
| 31 |
+
# a RANGE of sections -- '204 et 205 [Modifications]', '211 à 214
|
| 32 |
+
# [Modifications]', '30 to 32 [Amendments]' -- and 104 of those survived the
|
| 33 |
+
# one-token rule (the audit follow-up of 2026-09-21).
|
| 34 |
+
_AMENDMENT_STUB = re.compile(
|
| 35 |
+
r"^\S+(?:\s+(?:et|à|a|to|and|-|–)\s+\S+)?\s*\[(?:Amendments?|Related provisions?"
|
| 36 |
+
r"|Modifications?|Dispositions? connexes?)\]\s*$", re.IGNORECASE)
|
| 37 |
+
|
| 38 |
+
# The semantic embedder (embed.py) only reads the first _MAX_BODY=2000 chars of
|
| 39 |
+
# a chunk, so the tail of a long section is invisible to semantic search. A
|
| 40 |
+
# section whose rendered text exceeds _SPLIT_THRESHOLD is therefore emitted as
|
| 41 |
+
# several piece-chunks, each <= _PIECE_TARGET chars (under the embed window) and
|
| 42 |
+
# split on subsection/line boundaries, so every part is embeddable. Sections at
|
| 43 |
+
# or below the threshold are emitted unchanged -- same id, same embedding --
|
| 44 |
+
# which keeps ~90% of legislation chunks (and their retrieval behaviour) intact.
|
| 45 |
+
_SPLIT_THRESHOLD = 2400
|
| 46 |
+
_PIECE_TARGET = 1800
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _split_text(text, target=_PIECE_TARGET):
|
| 50 |
+
"""Split rendered section text into <=target pieces at line boundaries,
|
| 51 |
+
folding small leading/trailing remnants into a neighbour so no piece is a
|
| 52 |
+
stub."""
|
| 53 |
+
return split_lines(text, target, fold_stubs=True)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _itertext(el):
|
| 57 |
+
return _norm("".join(el.itertext()))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _label_text(el):
|
| 61 |
+
"""A <Label>'s text without its editorial footnote marker.
|
| 62 |
+
|
| 63 |
+
Justice Laws puts `<FootnoteRef idref="...">*</FootnoteRef>` inside the
|
| 64 |
+
Label of a provision whose in-force date is footnoted (IRPA ss. 189-193,
|
| 65 |
+
s. 188(4); the YCJA, the Cannabis Act, the CBSA Act...). _itertext kept
|
| 66 |
+
the '*', so 70 chunks were stored as section '*189', cited as 's. *189',
|
| 67 |
+
linked to a section-*189.html that does not exist, and an exact
|
| 68 |
+
get_section lookup found nothing. Only the Label reads use this: a
|
| 69 |
+
FootnoteRef inside a body <Text> stays in the text as published."""
|
| 70 |
+
parts = [el.text or ""]
|
| 71 |
+
for child in el:
|
| 72 |
+
if child.tag != "FootnoteRef":
|
| 73 |
+
parts.append("".join(child.itertext()))
|
| 74 |
+
parts.append(child.tail or "")
|
| 75 |
+
return _norm("".join(parts))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# The first kilobyte of a Justice Laws consolidation names its root element;
|
| 79 |
+
# a maintenance page or an error body does not, and must not be cached.
|
| 80 |
+
_XML_ROOTS = (b"<Statute", b"<Regulation")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _xml_body(url):
|
| 84 |
+
"""One attempt at reading a consolidation's bytes."""
|
| 85 |
+
req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
|
| 86 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 87 |
+
return resp.read()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _download_xml(url, dest):
|
| 91 |
+
"""Download a consolidation to `dest` atomically (a temporary file renamed
|
| 92 |
+
into place) and only when the body is Justice Laws XML, so a truncated or
|
| 93 |
+
non-XML response can never poison the cache for every later plain run.
|
| 94 |
+
A 5xx, a dropped connection or a timeout is retried once
|
| 95 |
+
(_common.with_retry); a 404 or a non-XML body is not."""
|
| 96 |
+
data = with_retry(lambda: _xml_body(url), what=url)
|
| 97 |
+
head = data[3:1024] if data[:3] == b"\xef\xbb\xbf" else data[:1024]
|
| 98 |
+
if not any(root in head for root in _XML_ROOTS):
|
| 99 |
+
raise ValueError(f"{url}: response is not Justice Laws XML "
|
| 100 |
+
f"(no <Statute>/<Regulation> in the first KB); not cached")
|
| 101 |
+
atomic_write_bytes(dest, data)
|
| 102 |
+
time.sleep(1.0) # be polite to the Justice Laws server between downloads
|
| 103 |
+
return dest
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def fetch_xml(code, force=False):
|
| 107 |
+
src = SOURCES[code]
|
| 108 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
dest = RAW_DIR / f"{code}.xml"
|
| 110 |
+
if dest.exists() and not force:
|
| 111 |
+
return dest
|
| 112 |
+
print(f" downloading {src['xml_url']}")
|
| 113 |
+
return _download_xml(src["xml_url"], dest)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _parse_or_evict(xml_path, code):
|
| 117 |
+
"""parse_legislation, evicting the cached XML when it cannot be parsed:
|
| 118 |
+
a truncated body that passed the root check, or a cache written before
|
| 119 |
+
the check existed, would otherwise fail identically on every plain run
|
| 120 |
+
with nothing saying the cache is the cause."""
|
| 121 |
+
try:
|
| 122 |
+
return parse_legislation(xml_path, code)
|
| 123 |
+
except (ET.ParseError, ValueError) as exc:
|
| 124 |
+
with contextlib.suppress(OSError):
|
| 125 |
+
xml_path.unlink()
|
| 126 |
+
raise type(exc)(f"{exc} -- cached {xml_path.name} removed; re-run to "
|
| 127 |
+
f"download it again") from exc
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _heading_text(h):
|
| 131 |
+
"""Render a <Heading> as 'LABEL - TITLE' when both parts are present."""
|
| 132 |
+
label, title = h.find("Label"), h.find("TitleText")
|
| 133 |
+
if label is not None or title is not None:
|
| 134 |
+
parts = [_label_text(label) if label is not None else "",
|
| 135 |
+
_itertext(title) if title is not None else ""]
|
| 136 |
+
return " - ".join(p for p in parts if p)
|
| 137 |
+
return _itertext(h)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _render_block(el, is_section=False):
|
| 141 |
+
"""Recursively render a provision element into readable, structured text."""
|
| 142 |
+
label = note = ""
|
| 143 |
+
inline, blocks = [], []
|
| 144 |
+
for child in el:
|
| 145 |
+
tag = child.tag
|
| 146 |
+
if tag == "Label":
|
| 147 |
+
label = _label_text(child)
|
| 148 |
+
elif tag == "MarginalNote":
|
| 149 |
+
if not is_section: # a section's own marginal note is its title
|
| 150 |
+
note = _itertext(child)
|
| 151 |
+
elif tag == "Text":
|
| 152 |
+
inline.append(_itertext(child))
|
| 153 |
+
elif tag == "HistoricalNote":
|
| 154 |
+
continue
|
| 155 |
+
elif tag == "TableGroup":
|
| 156 |
+
# a table inside a provision (2026-10-12: CASR 2012 s. 78(2), IRPR
|
| 157 |
+
# s. 7.01, the Cannabis Regulations' s. 21): recursing into it kept
|
| 158 |
+
# only the lists nested in its cells and dropped every cell's text
|
| 159 |
+
blocks.append(_table_text(child))
|
| 160 |
+
elif tag in BLOCK_TAGS:
|
| 161 |
+
blocks.append(_render_block(child))
|
| 162 |
+
else:
|
| 163 |
+
nested = _render_block(child)
|
| 164 |
+
if nested:
|
| 165 |
+
blocks.append(nested)
|
| 166 |
+
head = " ".join(p for p in (label, f"[{note}]" if note else "", " ".join(inline)) if p)
|
| 167 |
+
if blocks:
|
| 168 |
+
return (head + "\n" if head else "") + "\n".join(b for b in blocks if b)
|
| 169 |
+
return head
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _table_text(tablegroup):
|
| 173 |
+
"""Render a CALS <TableGroup> as caption/title lines plus one
|
| 174 |
+
pipe-separated line per row. Column structure survives as ' | '
|
| 175 |
+
separators, which is enough for BM25/semantic matching and readable in a
|
| 176 |
+
result pane; no attempt is made to preserve spanned cells."""
|
| 177 |
+
out = []
|
| 178 |
+
for t in (tablegroup.find("Caption"), tablegroup.find("table/title")):
|
| 179 |
+
if t is not None:
|
| 180 |
+
txt = _itertext(t)
|
| 181 |
+
if txt:
|
| 182 |
+
out.append(txt)
|
| 183 |
+
for row in tablegroup.iter("row"):
|
| 184 |
+
cells = [_cell_text(e) for e in row.findall("entry")]
|
| 185 |
+
# an empty cell keeps its column: stripping it made the CASR 2012 Schedule 4
|
| 186 |
+
# row 'Section 14 | 5,000 |' (individual maximum only) read as a corporation
|
| 187 |
+
# amount. Only a row with no text at all is dropped.
|
| 188 |
+
if any(cells):
|
| 189 |
+
out.append(" | ".join(cells).strip())
|
| 190 |
+
return "\n".join(out)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# Elements a table cell's words are separated at (CASR 2012 s. 78(2)'s conditions
|
| 194 |
+
# are a <Provision><List><Item> tree; SOR/96-263's cells hold image captions and
|
| 195 |
+
# footnotes). Any other element is inline (Emphasis, Language, a cross-reference)
|
| 196 |
+
# and never splits a word.
|
| 197 |
+
_CELL_BREAKS = BLOCK_TAGS | {"Provision", "List", "Item", "Label", "Text", "Footnote",
|
| 198 |
+
"ImageGroup", "Caption", "AlternateText", "LineBreak"}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _cell_text(entry):
|
| 202 |
+
"""One table cell on one line, every word of it kept: the cell's text with a
|
| 203 |
+
space at each structural boundary, so a nested list reads '(a) ... (b) ...'
|
| 204 |
+
instead of '(a)the officer'. A cell holding only inline text reads exactly as
|
| 205 |
+
its itertext."""
|
| 206 |
+
parts = []
|
| 207 |
+
|
| 208 |
+
def walk(el):
|
| 209 |
+
brk = " " if el.tag in _CELL_BREAKS else ""
|
| 210 |
+
parts.append(brk + (el.text or ""))
|
| 211 |
+
for child in el:
|
| 212 |
+
walk(child)
|
| 213 |
+
parts.append(child.tail or "")
|
| 214 |
+
parts.append(brk)
|
| 215 |
+
|
| 216 |
+
walk(entry)
|
| 217 |
+
return _norm("".join(parts))
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _first_table_title(sched):
|
| 221 |
+
"""The title of the table a schedule opens with, or '' -- the CASR 2012 names
|
| 222 |
+
its aerodrome schedules only there ('Class 1 Aerodromes')."""
|
| 223 |
+
for child in sched:
|
| 224 |
+
if child.tag in ("ScheduleFormHeading", "HistoricalNote"):
|
| 225 |
+
continue
|
| 226 |
+
title = child.find("table/title") if child.tag == "TableGroup" else None
|
| 227 |
+
return _itertext(title) if title is not None else ""
|
| 228 |
+
return ""
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _sched_body(el):
|
| 232 |
+
"""Render a <Schedule>'s content elements into readable text.
|
| 233 |
+
|
| 234 |
+
Observed shapes across the corpus: List/Item trees (CDSA I-IV, IRPR
|
| 235 |
+
1.1), DocumentInternal/Group/Provision prose (CDSA V-VI, IRPA's Refugee
|
| 236 |
+
Convention), and TableGroup grids (IRPR Schedule 2 violations, the AMPS
|
| 237 |
+
designated-provisions tables). Lists, groups and provisions reuse
|
| 238 |
+
_render_block, whose Label/Text recursion matches their structure;
|
| 239 |
+
tables get the dedicated renderer above so rows keep their shape."""
|
| 240 |
+
parts = []
|
| 241 |
+
for child in el:
|
| 242 |
+
tag = child.tag
|
| 243 |
+
if tag in ("ScheduleFormHeading", "HistoricalNote"):
|
| 244 |
+
continue
|
| 245 |
+
if tag == "TableGroup":
|
| 246 |
+
parts.append(_table_text(child))
|
| 247 |
+
elif tag == "GroupHeading":
|
| 248 |
+
parts.append(_heading_text(child))
|
| 249 |
+
elif tag in ("DocumentInternal", "Group", "RegulationPiece", "BillPiece"):
|
| 250 |
+
parts.append(_sched_body(child))
|
| 251 |
+
else:
|
| 252 |
+
rendered = _render_block(child)
|
| 253 |
+
if rendered:
|
| 254 |
+
parts.append(rendered)
|
| 255 |
+
return "\n".join(p for p in parts if p)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# Schedule ids with dedicated handling: NifProvs is ingested separately as
|
| 259 |
+
# not-in-force chunks; RelatedProvs (transitional provisions of past
|
| 260 |
+
# amendments) is deliberately excluded -- see _nif_chunks.
|
| 261 |
+
_SCHED_SKIP_IDS = {"NifProvs", "RelatedProvs"}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _heading_part(heading, tag):
|
| 265 |
+
"""The full text of a ScheduleFormHeading child, or ''."""
|
| 266 |
+
el = heading.find(tag) if heading is not None else None
|
| 267 |
+
return _itertext(el) if el is not None else ""
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _schedule_chunks(root, code, src, current_to):
|
| 271 |
+
"""Chunks for an Act's substantive Schedules.
|
| 272 |
+
|
| 273 |
+
Previously every Schedule except NifProvs was silently dropped -- which
|
| 274 |
+
meant the CDSA's controlled-substance lists, the AMPS designated-
|
| 275 |
+
provisions tables and IRPA's Refugee Convention articles were absent
|
| 276 |
+
from the corpus even though the sections that depend on them were
|
| 277 |
+
ingested. A source can opt out with 'skip_schedules': True (the Customs
|
| 278 |
+
Tariff does: its schedule is the full tariff nomenclature, served
|
| 279 |
+
separately by tariff_schedule.py from the CBSA edition)."""
|
| 280 |
+
if src.get("skip_schedules"):
|
| 281 |
+
return []
|
| 282 |
+
chunks = []
|
| 283 |
+
for n, sched in enumerate(root.findall("Schedule"), start=1):
|
| 284 |
+
if sched.get("id") in _SCHED_SKIP_IDS:
|
| 285 |
+
continue
|
| 286 |
+
h = sched.find("ScheduleFormHeading")
|
| 287 |
+
label = _norm(h.findtext("Label") or "") if h is not None else ""
|
| 288 |
+
# the whole element's text: findtext stops at the first child, so
|
| 289 |
+
# '(Sections <XRefInternal>797</XRefInternal> and ...)' was stored as '(Sections'
|
| 290 |
+
title = _heading_part(h, "TitleText")
|
| 291 |
+
oref = _heading_part(h, "OriginatingRef")
|
| 292 |
+
label = label or (f"SCHEDULE ({n})" if not title else title)
|
| 293 |
+
table_title = "" if title else _first_table_title(sched)
|
| 294 |
+
repealed = sched.find("Repealed") is not None
|
| 295 |
+
body = "[Repealed]" if repealed else _sched_body(sched)
|
| 296 |
+
if not body:
|
| 297 |
+
continue
|
| 298 |
+
header = " — ".join(p for p in (title, oref) if p)
|
| 299 |
+
text = (header + "\n" if header else "") + body
|
| 300 |
+
base = {
|
| 301 |
+
"act_code": code,
|
| 302 |
+
"act_short": src["short"],
|
| 303 |
+
"act_name": src["name"],
|
| 304 |
+
"section": label.title(),
|
| 305 |
+
"marginal_note": title or table_title or label.title(),
|
| 306 |
+
"part": "SCHEDULES",
|
| 307 |
+
"division": "",
|
| 308 |
+
"heading": label.title(),
|
| 309 |
+
"history": "",
|
| 310 |
+
"last_amended": sched.get(f"{LIMS}lastAmendedDate", ""),
|
| 311 |
+
"in_force": sched.get(f"{LIMS}inforce-start-date", ""),
|
| 312 |
+
"status": "repealed" if repealed else "in force",
|
| 313 |
+
"current_to": current_to,
|
| 314 |
+
"citation": f"{src['short']}, {label.title()}",
|
| 315 |
+
"source_url": src["web_base"],
|
| 316 |
+
}
|
| 317 |
+
pieces = (_split_text(text)
|
| 318 |
+
if len(text) > _SPLIT_THRESHOLD else [text])
|
| 319 |
+
total = len(pieces)
|
| 320 |
+
for k, piece in enumerate(pieces, start=1):
|
| 321 |
+
if total == 1:
|
| 322 |
+
chunks.append({**base, "id": f"{code}-sched{n}", "text": piece})
|
| 323 |
+
else:
|
| 324 |
+
chunks.append({
|
| 325 |
+
**base, "id": f"{code}-sched{n}-p{k}", "text": piece,
|
| 326 |
+
"citation": f"{base['citation']} (part {k} of {total})"})
|
| 327 |
+
return chunks
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _history(section):
|
| 331 |
+
note = section.find("HistoricalNote")
|
| 332 |
+
if note is None:
|
| 333 |
+
return ""
|
| 334 |
+
return "; ".join(_itertext(i) for i in note.iter("HistoricalNoteSubItem"))
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def parse_legislation(xml_path, code):
|
| 338 |
+
src = SOURCES[code]
|
| 339 |
+
data = xml_path.read_bytes()
|
| 340 |
+
if data[:3] == b"\xef\xbb\xbf": # some Justice Laws XML files carry a UTF-8 BOM
|
| 341 |
+
data = data[3:]
|
| 342 |
+
root = ET.fromstring(data)
|
| 343 |
+
current_to = root.get(f"{LIMS}current-date", "")
|
| 344 |
+
body = root.find("Body")
|
| 345 |
+
if body is None:
|
| 346 |
+
# A schedule-only instrument (e.g. the Import Control List) has no
|
| 347 |
+
# <Body> at all -- its entire substance is in Schedules.
|
| 348 |
+
if not root.findall("Schedule"):
|
| 349 |
+
raise ValueError(f"{code}: no <Body> element (root <{root.tag}>)")
|
| 350 |
+
body = ()
|
| 351 |
+
|
| 352 |
+
headings, chunks = {}, []
|
| 353 |
+
for el in body:
|
| 354 |
+
if el.tag == "Heading":
|
| 355 |
+
try:
|
| 356 |
+
level = int(el.get("level", "0"))
|
| 357 |
+
except ValueError:
|
| 358 |
+
level = 0
|
| 359 |
+
headings = {k: v for k, v in headings.items() if k < level}
|
| 360 |
+
headings[level] = _heading_text(el)
|
| 361 |
+
continue
|
| 362 |
+
if el.tag != "Section":
|
| 363 |
+
continue
|
| 364 |
+
|
| 365 |
+
label_el = el.find("Label")
|
| 366 |
+
number = _label_text(label_el) if label_el is not None else ""
|
| 367 |
+
note_el = el.find("MarginalNote")
|
| 368 |
+
marginal = _itertext(note_el) if note_el is not None else ""
|
| 369 |
+
body_text = _render_block(el, is_section=True)
|
| 370 |
+
|
| 371 |
+
# Drop content-free amendment/related-provision stubs. When an Act
|
| 372 |
+
# amends OTHER Acts, the consolidation keeps a placeholder section whose
|
| 373 |
+
# whole body is just a marker -- e.g. "30 [Amendment]" -- the actual
|
| 374 |
+
# text living in the Act being amended. These carry no substance, yet
|
| 375 |
+
# were being indexed and rendered with an 'in force' currency line as if
|
| 376 |
+
# they were live provisions (117 in the CBSA Act alone). Skip them.
|
| 377 |
+
# (Repealed sections, "<n> [Repealed, ...]", are NOT dropped -- they are
|
| 378 |
+
# flagged status='repealed' and kept so a section lookup still informs.)
|
| 379 |
+
if _AMENDMENT_STUB.match(body_text):
|
| 380 |
+
continue
|
| 381 |
+
|
| 382 |
+
if number:
|
| 383 |
+
chunk_id = f"{code}-s{number}"
|
| 384 |
+
citation = f"{src['short']}, s. {number}"
|
| 385 |
+
source_url = f"{src['web_base']}section-{number}.html"
|
| 386 |
+
else:
|
| 387 |
+
# An unnumbered <Section> is a preamble or enacting recital. Keep it
|
| 388 |
+
# unless it has no text; the LIMS id keeps the chunk id unique when
|
| 389 |
+
# an Act contains more than one such block.
|
| 390 |
+
if not body_text:
|
| 391 |
+
continue
|
| 392 |
+
number = marginal or "Preamble"
|
| 393 |
+
marginal = marginal or "Preamble"
|
| 394 |
+
lims_id = el.get(f"{LIMS}id") or str(len(chunks))
|
| 395 |
+
chunk_id = f"{code}-pre-{lims_id}"
|
| 396 |
+
citation = f"{src['short']}, {number}"
|
| 397 |
+
source_url = src["web_base"]
|
| 398 |
+
|
| 399 |
+
part = next((v for _, v in sorted(headings.items())
|
| 400 |
+
if v.upper().startswith("PART")), "")
|
| 401 |
+
division = next((v for _, v in sorted(headings.items())
|
| 402 |
+
if v.upper().startswith("DIVISION") or _FR_DIVISION.match(v)), "")
|
| 403 |
+
nearest = headings[max(headings)] if headings else ""
|
| 404 |
+
|
| 405 |
+
# Per-section coming-into-force date (lims:inforce-start-date is present
|
| 406 |
+
# on every section) and repeal status. A wholly-repealed section is a
|
| 407 |
+
# <Section> whose own <Text> is a <Repealed> marker (e.g. "[Repealed,
|
| 408 |
+
# 2018, c. 27, s. 174]"); a live section with a repealed subsection has
|
| 409 |
+
# the <Repealed> nested deeper, so we key on the direct Text/Repealed to
|
| 410 |
+
# flag only sections that are themselves no longer law.
|
| 411 |
+
in_force = el.get(f"{LIMS}inforce-start-date", "")
|
| 412 |
+
status = "repealed" if el.find("Text/Repealed") is not None else "in force"
|
| 413 |
+
|
| 414 |
+
base = {
|
| 415 |
+
"act_code": code,
|
| 416 |
+
"act_short": src["short"],
|
| 417 |
+
"act_name": src["name"],
|
| 418 |
+
"section": number,
|
| 419 |
+
"marginal_note": marginal,
|
| 420 |
+
"part": part,
|
| 421 |
+
"division": division,
|
| 422 |
+
"heading": nearest,
|
| 423 |
+
"history": _history(el),
|
| 424 |
+
"last_amended": el.get(f"{LIMS}lastAmendedDate", ""),
|
| 425 |
+
"in_force": in_force,
|
| 426 |
+
"status": status,
|
| 427 |
+
"current_to": current_to,
|
| 428 |
+
"source_url": source_url,
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
# Short sections (the large majority) stay one chunk with their
|
| 432 |
+
# original id and citation -- unchanged embedding and retrieval. Long
|
| 433 |
+
# ones are split into embeddable pieces; each piece keeps the section
|
| 434 |
+
# number (so citation lookups, section-ref pinning and the eval still
|
| 435 |
+
# match) and notes its part in the citation.
|
| 436 |
+
pieces = (_split_text(body_text)
|
| 437 |
+
if len(body_text) > _SPLIT_THRESHOLD else [body_text])
|
| 438 |
+
n = len(pieces)
|
| 439 |
+
for k, piece in enumerate(pieces, start=1):
|
| 440 |
+
if n == 1:
|
| 441 |
+
chunks.append({**base, "id": chunk_id, "text": piece,
|
| 442 |
+
"citation": citation})
|
| 443 |
+
else:
|
| 444 |
+
chunks.append({**base, "id": f"{chunk_id}-p{k}", "text": piece,
|
| 445 |
+
"citation": f"{citation} (part {k} of {n})"})
|
| 446 |
+
|
| 447 |
+
chunks.extend(_schedule_chunks(root, code, src, current_to))
|
| 448 |
+
chunks.extend(_nif_chunks(root, code, src, current_to))
|
| 449 |
+
return chunks
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _nif_chunks(root, code, src, current_to):
|
| 453 |
+
"""Chunks for the Act's AMENDMENTS NOT IN FORCE schedule.
|
| 454 |
+
|
| 455 |
+
Justice Laws consolidations carry enacted-but-not-yet-in-force amendments
|
| 456 |
+
in a Schedule with id='NifProvs' (each entry a RelatedOrNotInForce node:
|
| 457 |
+
a citation heading for the amending Act plus the amendment text as regular
|
| 458 |
+
Section elements). Previously the parser dropped these silently -- the
|
| 459 |
+
corpus's biggest currency blind spot, since a Royal-Assented amendment
|
| 460 |
+
days from changing the law was invisible until consolidation. They are
|
| 461 |
+
ingested with status='not-in-force': retrieval down-weights them below
|
| 462 |
+
live law and the server banners them, but a search for their subject
|
| 463 |
+
matter now surfaces the pending law instead of nothing.
|
| 464 |
+
|
| 465 |
+
The sibling Schedule id='RelatedProvs' (transitional related provisions)
|
| 466 |
+
is deliberately NOT ingested here: those are in-force companion rules of
|
| 467 |
+
past amendments, not pending law, and would need their own treatment.
|
| 468 |
+
|
| 469 |
+
Note the Label inside an entry is the AMENDING Act's section number, so
|
| 470 |
+
the chunk's 'section' is left empty -- these must never satisfy a lookup
|
| 471 |
+
or pin for the target Act's own section numbers.
|
| 472 |
+
"""
|
| 473 |
+
nif = next((s for s in root.findall("Schedule")
|
| 474 |
+
if s.get("id") == "NifProvs"), None)
|
| 475 |
+
if nif is None:
|
| 476 |
+
return []
|
| 477 |
+
chunks = []
|
| 478 |
+
for n, entry in enumerate(nif.iter("RelatedOrNotInForce"), start=1):
|
| 479 |
+
title = entry.find("Heading/TitleText")
|
| 480 |
+
cite = _norm("".join(title.itertext())) if title is not None else ""
|
| 481 |
+
cite = cite.lstrip("—- ").strip()
|
| 482 |
+
sections = entry.findall("Section")
|
| 483 |
+
body = "\n".join(p for p in (_render_block(s, is_section=True)
|
| 484 |
+
for s in sections) if p)
|
| 485 |
+
if not body:
|
| 486 |
+
continue
|
| 487 |
+
note_el = sections[0].find("MarginalNote") if sections else None
|
| 488 |
+
marginal = _norm("".join(note_el.itertext())) if note_el is not None else ""
|
| 489 |
+
pieces = (_split_text(body)
|
| 490 |
+
if len(body) > _SPLIT_THRESHOLD else [body])
|
| 491 |
+
total = len(pieces)
|
| 492 |
+
for k, piece in enumerate(pieces, start=1):
|
| 493 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 494 |
+
part_note = "" if total == 1 else f" (part {k} of {total})"
|
| 495 |
+
chunks.append({
|
| 496 |
+
"id": f"{code}-nif-{n}{suffix}",
|
| 497 |
+
"act_code": code,
|
| 498 |
+
"act_short": src["short"],
|
| 499 |
+
"act_name": src["name"],
|
| 500 |
+
"section": "",
|
| 501 |
+
"marginal_note": marginal or f"Amendment not in force — {cite}",
|
| 502 |
+
"part": "AMENDMENTS NOT IN FORCE",
|
| 503 |
+
"division": "",
|
| 504 |
+
"heading": "Amendments not in force",
|
| 505 |
+
"text": piece,
|
| 506 |
+
"history": cite,
|
| 507 |
+
"last_amended": "",
|
| 508 |
+
"in_force": "",
|
| 509 |
+
"status": "not-in-force",
|
| 510 |
+
"current_to": current_to,
|
| 511 |
+
"citation": (f"{src['short']} — amendment not in force "
|
| 512 |
+
f"({cite}){part_note}"),
|
| 513 |
+
"source_url": src["web_base"],
|
| 514 |
+
})
|
| 515 |
+
return chunks
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def ingest(code, force=False, allow_shrink=False):
|
| 519 |
+
print(f"Ingesting {code} ({SOURCES[code]['short']})...")
|
| 520 |
+
xml_path = fetch_xml(code, force=force)
|
| 521 |
+
chunks = _parse_or_evict(xml_path, code)
|
| 522 |
+
out = PROCESSED_DIR / f"{code}.json"
|
| 523 |
+
# An Act whose XML schema shifts parses to a fraction of its sections; the
|
| 524 |
+
# guard turns that into a refusal instead of a quietly gutted statute.
|
| 525 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 526 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 527 |
+
print(f" {len(chunks)} sections -> {out.name}")
|
| 528 |
+
return chunks
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# --- French corpus (phase 3 of bilingual support) ----------------------------
|
| 532 |
+
# Justice Laws publishes every consolidation as parallel French XML in the
|
| 533 |
+
# SAME schema (English element names, French text, identical section
|
| 534 |
+
# numbering), so the whole parser -- schedules included -- works unchanged.
|
| 535 |
+
# French chunks carry lang='fr', a '-fr-' id infix, French citation style
|
| 536 |
+
# ('art.' / 'annexe') and the French web URL; retrieval filters by query
|
| 537 |
+
# language (index.py), and get_section/xref/list_acts stay English-first.
|
| 538 |
+
|
| 539 |
+
def _fr_xml_url(src):
|
| 540 |
+
return (src["xml_url"]
|
| 541 |
+
.replace("/eng/XML/", "/fra/XML/")
|
| 542 |
+
.replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
|
| 543 |
+
.replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
# The parser writes a few structural labels of its own, in English: the
|
| 547 |
+
# SCHEDULES part, the AMENDMENTS NOT IN FORCE schedule and its entries, the
|
| 548 |
+
# '(part k of n)' note on a split provision, the 'Preamble' and 'SCHEDULE (n)'
|
| 549 |
+
# fallbacks. The French twins carried them verbatim (2026-09-21 audit:
|
| 550 |
+
# 'SCHEDULES' on 845 French chunks, 'Amendments not in force' on 452, the
|
| 551 |
+
# part note on every split article); these are the French consolidations' own
|
| 552 |
+
# words. (field, English pattern, French replacement), applied in order.
|
| 553 |
+
_FR_LABELS = (
|
| 554 |
+
("part", re.compile(r"^SCHEDULES$"), "ANNEXES"),
|
| 555 |
+
("part", re.compile(r"^AMENDMENTS NOT IN FORCE$"), "MODIFICATIONS NON EN VIGUEUR"),
|
| 556 |
+
("heading", re.compile(r"^Amendments not in force$"), "Modifications non en vigueur"),
|
| 557 |
+
("marginal_note", re.compile(r"^Amendment not in force — "), "Modification non en vigueur — "),
|
| 558 |
+
("citation", re.compile(r" — amendment not in force \("), " — modification non en vigueur ("),
|
| 559 |
+
("citation", re.compile(r"\(part (\d+) of (\d+)\)"), r"(partie \1 de \2)"),
|
| 560 |
+
("citation", re.compile(r", Preamble$"), ", Préambule"),
|
| 561 |
+
("section", re.compile(r"^Preamble$"), "Préambule"),
|
| 562 |
+
("marginal_note", re.compile(r"^Preamble$"), "Préambule"),
|
| 563 |
+
("section", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
|
| 564 |
+
("heading", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
|
| 565 |
+
("marginal_note", re.compile(r"^Schedule \((\d+)\)$"), r"Annexe (\1)"),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
# The English section label a French one pairs with: a range reads '55 et
|
| 569 |
+
# 56' / '211 à 214' in French and '55 and 56' / '211 to 214' in English, a
|
| 570 |
+
# schedule 'Annexe 1' / 'Schedule 1', a form 'Formule 6' / 'Form 6' (322
|
| 571 |
+
# French labels with no English twin of the same spelling in the 2026-09-21
|
| 572 |
+
# corpus; this table pairs all but nine). Section labels are title-cased at
|
| 573 |
+
# parse, so the connectors come in both cases.
|
| 574 |
+
_SECTION_EN = (
|
| 575 |
+
(re.compile(r"^Annexes\b"), "Schedules"),
|
| 576 |
+
(re.compile(r"^Annexe\b"), "Schedule"),
|
| 577 |
+
(re.compile(r"^Formule\b"), "Form"),
|
| 578 |
+
(re.compile(r"^Préambule$"), "Preamble"),
|
| 579 |
+
(re.compile(r"\bDe La Partie\b"), "To Part"),
|
| 580 |
+
(re.compile(r" et "), " and "), (re.compile(r" Et "), " And "),
|
| 581 |
+
(re.compile(r" à "), " to "), (re.compile(r" À "), " To "),
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _fr_labels(chunk):
|
| 586 |
+
"""`chunk` (a copy) with the parser's English structural labels in French."""
|
| 587 |
+
for field, pattern, french in _FR_LABELS:
|
| 588 |
+
if chunk.get(field):
|
| 589 |
+
chunk[field] = pattern.sub(french, chunk[field])
|
| 590 |
+
return chunk
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def section_en(section):
|
| 594 |
+
"""The English twin's section label for a French one (see _SECTION_EN)."""
|
| 595 |
+
for pattern, english in _SECTION_EN:
|
| 596 |
+
section = pattern.sub(english, section)
|
| 597 |
+
return section
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def _frenchify(chunks, code):
|
| 601 |
+
"""French twins of `chunks`: a '-fr-' id, lang 'fr', the French citation
|
| 602 |
+
style and URL, the parser's structural labels in French (_FR_LABELS), and
|
| 603 |
+
`section_en`, the English twin's section label, for pairing the two."""
|
| 604 |
+
out = []
|
| 605 |
+
for c in chunks:
|
| 606 |
+
c = dict(c)
|
| 607 |
+
c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
|
| 608 |
+
c["lang"] = "fr"
|
| 609 |
+
c["citation"] = (c["citation"]
|
| 610 |
+
.replace(", s. ", ", art. ")
|
| 611 |
+
.replace(", Schedule", ", annexe"))
|
| 612 |
+
# the French site cites a regulation as DORS- and a C.R.C. chapter as ch.,
|
| 613 |
+
# as _fr_xml_url does: /fra/reglements/SOR-... and _c._ return 404, so
|
| 614 |
+
# every French regulation link was dead until 2026-10-12
|
| 615 |
+
c["source_url"] = (c["source_url"]
|
| 616 |
+
.replace("/eng/acts/", "/fra/lois/")
|
| 617 |
+
.replace("/eng/regulations/SOR-", "/fra/reglements/DORS-")
|
| 618 |
+
.replace("/eng/regulations/", "/fra/reglements/")
|
| 619 |
+
.replace("_c._", "_ch._"))
|
| 620 |
+
c = _fr_labels(c)
|
| 621 |
+
c["section_en"] = section_en(c.get("section") or "")
|
| 622 |
+
out.append(c)
|
| 623 |
+
return out
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def ingest_fr(code, force=False, allow_shrink=False):
|
| 627 |
+
src = SOURCES[code]
|
| 628 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 629 |
+
dest = RAW_DIR / f"{code}-fr.xml"
|
| 630 |
+
if not dest.exists() or force:
|
| 631 |
+
url = _fr_xml_url(src)
|
| 632 |
+
print(f" downloading {url}")
|
| 633 |
+
_download_xml(url, dest)
|
| 634 |
+
chunks = _frenchify(_parse_or_evict(dest, code), code)
|
| 635 |
+
out = PROCESSED_DIR / f"{code}-fr.json"
|
| 636 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 637 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 638 |
+
print(f" {len(chunks)} articles -> {out.name}")
|
| 639 |
+
return chunks
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def main():
|
| 643 |
+
force = "--force" in sys.argv
|
| 644 |
+
french = "--fr" in sys.argv
|
| 645 |
+
allow_shrink = "--allow-shrink" in sys.argv
|
| 646 |
+
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 647 |
+
codes = only or list(SOURCES)
|
| 648 |
+
failures = []
|
| 649 |
+
for code in codes:
|
| 650 |
+
if code not in SOURCES:
|
| 651 |
+
print(f" SKIP {code}: not a known source")
|
| 652 |
+
continue
|
| 653 |
+
try:
|
| 654 |
+
if french:
|
| 655 |
+
print(f"Ingesting {code} (français)...")
|
| 656 |
+
ingest_fr(code, force=force, allow_shrink=allow_shrink)
|
| 657 |
+
else:
|
| 658 |
+
ingest(code, force=force, allow_shrink=allow_shrink)
|
| 659 |
+
except Exception as exc:
|
| 660 |
+
failures.append((code, exc))
|
| 661 |
+
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 662 |
+
print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
|
| 663 |
+
for code, exc in failures:
|
| 664 |
+
print(f" FAILED {code}: {exc}")
|
| 665 |
+
# One collapsed Act must not pass as a clean run: refresh.py and the
|
| 666 |
+
# RUNBOOK chain this into `&& py -m canlex.embed`.
|
| 667 |
+
sys.exit(1 if failures else 0)
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
if __name__ == "__main__":
|
| 671 |
+
main()
|
canlex/irb_guidelines.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# The three not loaded in the first pass: gender-based claims (4), how
|
| 38 |
+
# an RPD hearing is prepared and run (7), and SOGIESC claims (9) --
|
| 39 |
+
# what a port-of-entry claim referred to the RPD meets next.
|
| 40 |
+
{"num": "4", "file": "guideline4",
|
| 41 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir04.aspx",
|
| 42 |
+
"title": "Gender Considerations in Proceedings Before the IRB"},
|
| 43 |
+
{"num": "7", "file": "guideline7",
|
| 44 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir07.aspx",
|
| 45 |
+
"title": "Preparation and Conduct of a Hearing in the Refugee "
|
| 46 |
+
"Protection Division"},
|
| 47 |
+
{"num": "9", "file": "guideline9",
|
| 48 |
+
"url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir09.aspx",
|
| 49 |
+
"title": "Proceedings Before the IRB Involving Sexual Orientation, "
|
| 50 |
+
"Gender Identity and Expression, and Sex Characteristics"},
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
# "Effective date: May 31, 2021" (or amended/revised) in the page prose.
|
| 54 |
+
_EFFECTIVE = re.compile(
|
| 55 |
+
r"(?:effective|amended|revised)[^.:\n]*:?\s*((?:January|February|March|"
|
| 56 |
+
r"April|May|June|July|August|September|October|November|December)\s+"
|
| 57 |
+
r"\d{1,2},\s+\d{4})", re.I)
|
| 58 |
+
_MONTHS = {m: i for i, m in enumerate(
|
| 59 |
+
("January", "February", "March", "April", "May", "June", "July",
|
| 60 |
+
"August", "September", "October", "November", "December"), start=1)}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _iso(prose):
|
| 64 |
+
m = re.match(r"(\w+)\s+(\d{1,2}),\s+(\d{4})", prose or "")
|
| 65 |
+
if not m or m.group(1) not in _MONTHS:
|
| 66 |
+
return ""
|
| 67 |
+
return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _sections(main):
|
| 71 |
+
"""Yield (label, heading, text) per numbered heading in the page body."""
|
| 72 |
+
heads = main.find_all(["h2", "h3"])
|
| 73 |
+
for i, h in enumerate(heads):
|
| 74 |
+
heading = _norm(h.get_text())
|
| 75 |
+
m = re.match(r"(\d+(?:\.\d+)*)\.?\s+(.*)", heading)
|
| 76 |
+
label = m.group(1) if m else str(i + 1)
|
| 77 |
+
title = m.group(2) if m else heading
|
| 78 |
+
parts = []
|
| 79 |
+
for sib in h.next_siblings:
|
| 80 |
+
if getattr(sib, "name", None) in ("h2", "h3"):
|
| 81 |
+
break
|
| 82 |
+
if getattr(sib, "get_text", None):
|
| 83 |
+
parts.append(sib.get_text("\n"))
|
| 84 |
+
body = "\n".join(p for p in (_norm(x) for x in
|
| 85 |
+
"\n".join(parts).split("\n")) if p)
|
| 86 |
+
if len(body) > 80:
|
| 87 |
+
yield label, title, body
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def build(allow_shrink=False, force=False):
|
| 91 |
+
chunks, failures = [], []
|
| 92 |
+
for g in GUIDELINES:
|
| 93 |
+
try:
|
| 94 |
+
html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
|
| 95 |
+
pause=1.0,
|
| 96 |
+
force=force).decode("utf-8", "replace")
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
print(f" !! Guideline {g['num']}: fetch failed "
|
| 99 |
+
f"({type(exc).__name__}: {exc})")
|
| 100 |
+
failures.append(f"IRB-G{g['num']}")
|
| 101 |
+
continue
|
| 102 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 103 |
+
main = soup.find("main") or soup
|
| 104 |
+
eff = _EFFECTIVE.search(main.get_text())
|
| 105 |
+
effective = _iso(eff.group(1)) if eff else ""
|
| 106 |
+
n = 0
|
| 107 |
+
for label, title, body in _sections(main):
|
| 108 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 109 |
+
total = len(pieces)
|
| 110 |
+
for k, piece in enumerate(pieces, start=1):
|
| 111 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 112 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 113 |
+
chunks.append({
|
| 114 |
+
"id": f"irbg-{g['num']}-{label.replace('.', '-')}{suffix}",
|
| 115 |
+
"doc_type": "memorandum",
|
| 116 |
+
"act_code": f"IRB-G{g['num']}",
|
| 117 |
+
"act_short": f"IRB Guideline {g['num']}",
|
| 118 |
+
"act_name": f"IRB Chairperson's Guideline {g['num']} — "
|
| 119 |
+
f"{g['title']}",
|
| 120 |
+
"section": f"G{g['num']} s. {label}",
|
| 121 |
+
"marginal_note": title,
|
| 122 |
+
"part": g["title"],
|
| 123 |
+
"division": "",
|
| 124 |
+
"heading": "",
|
| 125 |
+
"text": piece,
|
| 126 |
+
"history": "",
|
| 127 |
+
"last_amended": effective,
|
| 128 |
+
"current_to": effective,
|
| 129 |
+
"citation": f"Chairperson's Guideline {g['num']}, "
|
| 130 |
+
f"s. {label} ({title}){note}",
|
| 131 |
+
"source_url": g["url"],
|
| 132 |
+
})
|
| 133 |
+
n += 1
|
| 134 |
+
if not n:
|
| 135 |
+
# Fetched, parsed to nothing: the IRB page template moved.
|
| 136 |
+
print(f" !! Guideline {g['num']}: no content parsed; "
|
| 137 |
+
f"keeping stored copy")
|
| 138 |
+
failures.append(f"IRB-G{g['num']}")
|
| 139 |
+
continue
|
| 140 |
+
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 141 |
+
f"effective {effective or '?'}")
|
| 142 |
+
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 143 |
+
chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
|
| 144 |
+
key=lambda c: c["act_code"],
|
| 145 |
+
only=set(failures))
|
| 146 |
+
if preserved:
|
| 147 |
+
print(f" kept the last-good copy of {len(preserved)} guideline(s) "
|
| 148 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 149 |
+
uniquify_ids(chunks)
|
| 150 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 151 |
+
return False
|
| 152 |
+
print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
|
| 153 |
+
return chunks
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def main():
|
| 157 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 158 |
+
# empty corpus succeeded, a refused write did not.
|
| 159 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 160 |
+
force="--force" in sys.argv) is not False
|
| 161 |
+
sys.exit(0 if ok else 1)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
main()
|
canlex/irb_papers.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingest the IRB Legal Services papers the s. 36 screening chain leans on.
|
| 2 |
+
|
| 3 |
+
The equivalency pairings cite Hill, Brannson, Li, Steward, Lei, Ward, Masasi
|
| 4 |
+
and the rest through the IRB Legal Services papers -- *Removal Order
|
| 5 |
+
Appeals*, ch. 7 (Criminal Grounds for Removal), ch. 8 (Criminal
|
| 6 |
+
Equivalency) and ch. 9 (Discretionary Jurisdiction), and *Sponsorship
|
| 7 |
+
Appeals*, ch. 2 (Criminal Refusals) -- which synthesise the Federal Court
|
| 8 |
+
line on equivalency, on what counts as a conviction, on the ID's and IAD's
|
| 9 |
+
jurisdiction over the criminality grounds and on the IAD's humanitarian
|
| 10 |
+
discretion after a s. 36 removal order. Public PDFs on irb-cisr.gc.ca;
|
| 11 |
+
ingested as doc_type='memorandum' (persuasive legal commentary by the
|
| 12 |
+
Board's own counsel, not law), like the Chairperson's Guidelines.
|
| 13 |
+
|
| 14 |
+
Archived. The IRB's legal-concepts index pages (RoaAmr.aspx, SpoPar.aspx)
|
| 15 |
+
title every chapter 'ARCHIVED' and carry the Government of Canada archived-
|
| 16 |
+
content notice (kept for reference, not updated since archiving); both
|
| 17 |
+
pages were last modified 2018-07-06. Every chunk records this: status
|
| 18 |
+
'archived', `archived` (that date), `archive_index` (the index page) and a
|
| 19 |
+
`history` line the renderer prints under the text. The papers are dated
|
| 20 |
+
2008/2009, so they predate later case law and amendments.
|
| 21 |
+
|
| 22 |
+
Parsing reads PyMuPDF's font information, not bare text lines (a plain-text
|
| 23 |
+
parse titled 38 of SPO ch. 2's 39 chunks 'January 1, 2008' and three ROA
|
| 24 |
+
ch. 8 sections with sentence fragments):
|
| 25 |
+
- running heads -- series name, page number, 'Legal Services', the paper's
|
| 26 |
+
date, '<title> – Ch. N' -- are bold lines in the footer band, dropped
|
| 27 |
+
(with a text filter for the same strings on any bold line), so the date
|
| 28 |
+
is never a section title;
|
| 29 |
+
- a heading is a short bold block of body size or larger: Arial Bold for a
|
| 30 |
+
section, Times Bold for a sub-heading ('Steps in Analysis', titled
|
| 31 |
+
'<section>: <sub-heading>'); a bold ALL-CAPS line ('GENERAL PRINCIPLES')
|
| 32 |
+
is a part heading and goes in `division`;
|
| 33 |
+
- a bold candidate that reads as a fragment -- ends in ':', ',' or ';' or a
|
| 34 |
+
function word, or has a footnote number glued to a word ('Dayan,19') --
|
| 35 |
+
stays body text;
|
| 36 |
+
- footnotes (10 pt, opened by a small marker) become '[fn N] ...' paragraphs
|
| 37 |
+
after the page's text, and superscript references read '[fn N]'.
|
| 38 |
+
|
| 39 |
+
Section numbers are CanLex's (the PDFs number nothing). ROA ch. 8's were in
|
| 40 |
+
use before this parser, so its PAPERS row pins them (`pins`): a pinned
|
| 41 |
+
heading keeps its number, a heading found between two pins takes a decimal
|
| 42 |
+
(§ 1.1, § 20.2), and the old § 9 and § 12 -- page-break fragments, not
|
| 43 |
+
headings -- are folded into the sections the PDF puts them in (§ 8, § 11).
|
| 44 |
+
Unpinned papers number their sections 1, 2, 3, ...
|
| 45 |
+
|
| 46 |
+
python -m canlex.irb_papers [--force] [--allow-shrink]
|
| 47 |
+
"""
|
| 48 |
+
import re
|
| 49 |
+
import sys
|
| 50 |
+
|
| 51 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 52 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 53 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 54 |
+
|
| 55 |
+
RAW = RAW_DIR / "irb_papers"
|
| 56 |
+
_BASE = "https://www.irb-cisr.gc.ca/en/legal-policy/legal-concepts/Documents/"
|
| 57 |
+
_INDEX = "https://www.irb-cisr.gc.ca/en/legal-policy/legal-concepts/Pages/"
|
| 58 |
+
# 'Date modified' of both index pages, which title every chapter ARCHIVED.
|
| 59 |
+
ARCHIVED = "2018-07-06"
|
| 60 |
+
|
| 61 |
+
# ROA ch. 8's section numbers as first ingested (2026-09-10), in document
|
| 62 |
+
# order. The second 'Committing an Offence Outside Canada' is a real second
|
| 63 |
+
# heading (under 'Evidentiary Matters').
|
| 64 |
+
_ROA8_PINS = [
|
| 65 |
+
("Introduction", 1),
|
| 66 |
+
("Equating the Foreign Offence to a Canadian Federal Statute", 2),
|
| 67 |
+
("Determining Equivalency", 3),
|
| 68 |
+
("Determining Equivalency: Leading Federal Court Dicta", 4),
|
| 69 |
+
("Where the Foreign Law is Available", 5),
|
| 70 |
+
("Where the Foreign Law is Available: Steps in Analysis", 6),
|
| 71 |
+
("Where the Foreign Law Is Not Available", 7),
|
| 72 |
+
("Malum in se Offences", 8),
|
| 73 |
+
("Committing an Offence Outside Canada", 10),
|
| 74 |
+
("Foreign Convictions", 11),
|
| 75 |
+
("Committing an Offence Outside Canada", 13),
|
| 76 |
+
("Relevant Time for Determining Inadmissibility", 14),
|
| 77 |
+
("Section 44 Report as a Limiting Factor", 15),
|
| 78 |
+
("Discharges and Pardons", 16),
|
| 79 |
+
("Effect of a Discharge", 17),
|
| 80 |
+
("Effect of a Pardon", 18),
|
| 81 |
+
("Rehabilitation", 19),
|
| 82 |
+
("Offences Committed Outside Canada by Minors", 20),
|
| 83 |
+
("Cases", 21),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _roa(ch, title, **extra):
|
| 88 |
+
return dict({
|
| 89 |
+
"code": f"IRB-LS-ROA{ch}", "file": f"RoaAmr{ch:02d}_e.pdf",
|
| 90 |
+
"short": f"IRB Legal Services, Removal Order Appeals ch. {ch}",
|
| 91 |
+
"name": f"IRB Legal Services, Removal Order Appeals, Chapter {ch} -- "
|
| 92 |
+
f"{title} (January 1, 2009; archived by the IRB)",
|
| 93 |
+
"title": title, "label": f"ROA ch. {ch}", "dated": "2009-01-01",
|
| 94 |
+
"index": _INDEX + "RoaAmr.aspx"}, **extra)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
PAPERS = [
|
| 98 |
+
_roa(7, "Criminal Grounds for Removal"),
|
| 99 |
+
_roa(8, "Criminal Equivalency", pins=_ROA8_PINS),
|
| 100 |
+
_roa(9, "Discretionary Jurisdiction"),
|
| 101 |
+
{"code": "IRB-LS-SPO2", "file": "SpoPar02_e.pdf",
|
| 102 |
+
"short": "IRB Legal Services, Sponsorship Appeals ch. 2",
|
| 103 |
+
"name": "IRB Legal Services, Sponsorship Appeals, Chapter 2 -- "
|
| 104 |
+
"Criminal Refusals (January 1, 2008; archived by the IRB)",
|
| 105 |
+
"title": "Criminal Refusals", "label": "SPO ch. 2",
|
| 106 |
+
"dated": "2008-01-01", "index": _INDEX + "SpoPar.aspx"},
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
_MONTH_NAMES = ("January", "February", "March", "April", "May", "June",
|
| 110 |
+
"July", "August", "September", "October", "November",
|
| 111 |
+
"December")
|
| 112 |
+
_MONTHS = {m: i for i, m in enumerate(_MONTH_NAMES, start=1)}
|
| 113 |
+
_M = "|".join(_MONTH_NAMES)
|
| 114 |
+
_DATE = re.compile(rf"({_M}) (\d{{1,2}}), (\d{{4}})")
|
| 115 |
+
# The running-head pieces the papers print on every page, each usually its
|
| 116 |
+
# own line: series name, 'Legal Services', page number, the paper's date,
|
| 117 |
+
# '<title> – Ch. N'. A line made only of these pieces is a running head.
|
| 118 |
+
_PIECE = (rf"(?:Removal Order Appeals|Sponsorship Appeals|Legal Services|"
|
| 119 |
+
rf"\d{{1,3}}|(?:{_M}) \d{{1,2}}, \d{{4}}|"
|
| 120 |
+
rf"[A-Z][^\n]{{2,80}}?\s[–-]\s?Ch\.\s?\d{{1,2}})")
|
| 121 |
+
RUNNING_LINE = re.compile(rf"^{_PIECE}(?:\s+{_PIECE})*$", re.I)
|
| 122 |
+
|
| 123 |
+
_FOOTER = 90 # pt from the page bottom: the running-head band
|
| 124 |
+
_TITLE_SIZE = 14 # 'Chapter Eight' / the chapter title (16 pt)
|
| 125 |
+
_HEAD_SIZE = 11.5 # headings are 12 pt; running heads 11 pt
|
| 126 |
+
_NOTE_SIZE = 10.8 # footnote text is 10 pt; body 11-12 pt
|
| 127 |
+
_SUP_SIZE = 8.5 # superscript references 8 pt; footnote markers 6.5 pt
|
| 128 |
+
_FUNCTION_END = re.compile(
|
| 129 |
+
r"\b(?:of|the|and|or|to|in|for|a|an|with|by|on|at|from|that|as|is|are|"
|
| 130 |
+
r"under|where|which)$", re.I)
|
| 131 |
+
_GLUED_NUMBER = re.compile(r"[a-z][,;:]\d{1,3}\b") # 'Dayan,19', 'follows:1'
|
| 132 |
+
_TERMINATED = re.compile(r"[.?!:;\"”’)\]]$")
|
| 133 |
+
_FN_REF = re.compile(r"\s*\[fn \d+\]")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def is_fragment(text):
|
| 137 |
+
"""A bold line that is a sentence fragment or a running date, not a
|
| 138 |
+
heading: ends in ':', ',' or ';' or a function word, carries a footnote
|
| 139 |
+
number glued to a word, or is a bare date."""
|
| 140 |
+
t = text.strip()
|
| 141 |
+
return (t.endswith((":", ",", ";")) or bool(_FUNCTION_END.search(t))
|
| 142 |
+
or bool(_GLUED_NUMBER.search(t)) or bool(_DATE.fullmatch(t)))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _is_bold(span):
|
| 146 |
+
return "Bold" in span["font"] or bool(span.get("flags", 0) & 16)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _is_glyph(span):
|
| 150 |
+
return any(f in span["font"] for f in ("Wingdings", "Symbol"))
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def line_record(spans):
|
| 154 |
+
"""One PDF line as {text, size, bold, arial, fn, bullet}, or None when
|
| 155 |
+
blank. Superscript digits become '[fn N]' in the text; a line opened by
|
| 156 |
+
a small digit and otherwise at footnote size is a footnote (fn = N).
|
| 157 |
+
Pure (takes PyMuPDF span dicts), for tests."""
|
| 158 |
+
live = [s for s in spans if s["text"].strip()]
|
| 159 |
+
if not live:
|
| 160 |
+
return None
|
| 161 |
+
top = max(s["size"] for s in spans)
|
| 162 |
+
|
| 163 |
+
def sup(s):
|
| 164 |
+
return (s["text"].strip().isdigit() and s["size"] <= _SUP_SIZE
|
| 165 |
+
and (s["size"] < 0.8 * top or len(live) == 1))
|
| 166 |
+
|
| 167 |
+
fn, bullet, parts = None, False, []
|
| 168 |
+
rest = [s for s in live[1:]]
|
| 169 |
+
if sup(live[0]) and max((s["size"] for s in rest), default=0) <= _NOTE_SIZE:
|
| 170 |
+
fn = live[0]["text"].strip()
|
| 171 |
+
for s in spans:
|
| 172 |
+
if s is live[0] and fn is not None:
|
| 173 |
+
continue
|
| 174 |
+
text = s["text"]
|
| 175 |
+
if s is live[0] and (_is_glyph(s) or text.strip() in ("•", "▪")) \
|
| 176 |
+
and len(text.strip()) == 1:
|
| 177 |
+
bullet = True
|
| 178 |
+
continue
|
| 179 |
+
if text.strip() and sup(s):
|
| 180 |
+
parts.append(f"[fn {text.strip()}]")
|
| 181 |
+
else:
|
| 182 |
+
parts.append(text)
|
| 183 |
+
words = [s for s in live if not sup(s) and not _is_glyph(s)]
|
| 184 |
+
text = _norm("".join(parts))
|
| 185 |
+
text = re.sub(r"(\S)\[fn", r"\1 [fn", text)
|
| 186 |
+
return {"text": text,
|
| 187 |
+
"size": max((s["size"] for s in words), default=top),
|
| 188 |
+
"bold": bool(words) and all(_is_bold(s) for s in words),
|
| 189 |
+
"arial": bool(words) and "Arial" in words[0]["font"],
|
| 190 |
+
"fn": fn, "bullet": bullet}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def pdf_pages(path):
|
| 194 |
+
"""Per page, the line records in reading order, each with its block
|
| 195 |
+
number, top y and the page height."""
|
| 196 |
+
import fitz # PyMuPDF
|
| 197 |
+
pages = []
|
| 198 |
+
with fitz.open(path) as doc:
|
| 199 |
+
for page in doc:
|
| 200 |
+
lines = []
|
| 201 |
+
blocks = page.get_text("dict", sort=True)["blocks"]
|
| 202 |
+
for bno, block in enumerate(blocks):
|
| 203 |
+
for line in block.get("lines", []):
|
| 204 |
+
rec = line_record(line["spans"])
|
| 205 |
+
if rec:
|
| 206 |
+
rec.update(block=bno, y=line["bbox"][1],
|
| 207 |
+
h=page.rect.height)
|
| 208 |
+
lines.append(rec)
|
| 209 |
+
pages.append(lines)
|
| 210 |
+
return pages
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _join(texts):
|
| 214 |
+
"""Line texts into one paragraph, de-hyphenating line-end breaks."""
|
| 215 |
+
out = ""
|
| 216 |
+
for t in texts:
|
| 217 |
+
t = _norm(t)
|
| 218 |
+
if not t:
|
| 219 |
+
continue
|
| 220 |
+
if out.endswith("-") and len(out) > 1 and out[-2].isalpha() \
|
| 221 |
+
and t[:1].islower():
|
| 222 |
+
out = out[:-1] + t
|
| 223 |
+
else:
|
| 224 |
+
out = f"{out} {t}" if out else t
|
| 225 |
+
return out
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _iso(m):
|
| 229 |
+
return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def items(pages):
|
| 233 |
+
"""(items, date): the paper as a flat list of ('part', title),
|
| 234 |
+
('h', level, title), ('p', text) and ('fn', number, text), plus the
|
| 235 |
+
date its running head carries ('' if none). Pure given line records."""
|
| 236 |
+
out, date, in_cases, pending = [], "", False, ""
|
| 237 |
+
|
| 238 |
+
def last_p():
|
| 239 |
+
"""Index of the last paragraph when only footnotes follow it."""
|
| 240 |
+
for i in range(len(out) - 1, -1, -1):
|
| 241 |
+
if out[i][0] == "p":
|
| 242 |
+
return i
|
| 243 |
+
if out[i][0] != "fn":
|
| 244 |
+
return None
|
| 245 |
+
return None
|
| 246 |
+
|
| 247 |
+
def add_p(text, first_on_page):
|
| 248 |
+
i = last_p() if first_on_page else None
|
| 249 |
+
if i is not None and text[:1].islower() \
|
| 250 |
+
and not _TERMINATED.search(_FN_REF.sub("", out[i][1])):
|
| 251 |
+
out[i] = ("p", f"{out[i][1]} {text}") # continues across the page
|
| 252 |
+
else:
|
| 253 |
+
out.append(("p", text))
|
| 254 |
+
|
| 255 |
+
def flush_pending():
|
| 256 |
+
nonlocal pending
|
| 257 |
+
if pending:
|
| 258 |
+
out.append(("h", 2, pending.rstrip(" –-")))
|
| 259 |
+
pending = ""
|
| 260 |
+
|
| 261 |
+
for lines in pages:
|
| 262 |
+
blocks = []
|
| 263 |
+
for ln in lines:
|
| 264 |
+
if ln["bold"] and (ln["y"] >= ln["h"] - _FOOTER
|
| 265 |
+
or RUNNING_LINE.match(ln["text"])):
|
| 266 |
+
m = _DATE.search(ln["text"])
|
| 267 |
+
if m and not date:
|
| 268 |
+
date = _iso(m)
|
| 269 |
+
continue
|
| 270 |
+
if ln["size"] >= _TITLE_SIZE:
|
| 271 |
+
continue
|
| 272 |
+
if not ln["text"] and ln["fn"] is None:
|
| 273 |
+
continue # a glyph alone (the steps diagrams' arrows)
|
| 274 |
+
if blocks and blocks[-1][0]["block"] == ln["block"]:
|
| 275 |
+
blocks[-1].append(ln)
|
| 276 |
+
else:
|
| 277 |
+
blocks.append([ln])
|
| 278 |
+
first_on_page = True
|
| 279 |
+
for blk in blocks:
|
| 280 |
+
text = _FN_REF.sub("", _join(ln["text"] for ln in blk))
|
| 281 |
+
heading = (all(ln["bold"] and ln["fn"] is None for ln in blk)
|
| 282 |
+
and len(blk) <= 3 and len(text) <= 160
|
| 283 |
+
and text[:1].isupper()
|
| 284 |
+
and (min(ln["size"] for ln in blk) >= _HEAD_SIZE
|
| 285 |
+
or text.upper() == "CASES"))
|
| 286 |
+
# The table of cases: 'CASES' in bold (ch. 7, 8), 'Cases' in
|
| 287 |
+
# plain type (ch. 9) -- a block of that one word either way.
|
| 288 |
+
if text.upper() == "CASES" and len(blk) == 1:
|
| 289 |
+
flush_pending()
|
| 290 |
+
out.append(("h", 2, "Cases"))
|
| 291 |
+
in_cases = True
|
| 292 |
+
continue
|
| 293 |
+
if heading and text.endswith(("–", "-")):
|
| 294 |
+
pending = text # 'Rehabilitation –' / 'Burden of Proof'
|
| 295 |
+
continue
|
| 296 |
+
if heading and pending:
|
| 297 |
+
text, pending = f"{pending} {text}", ""
|
| 298 |
+
if heading and text.isupper() and len(text) > 4:
|
| 299 |
+
out.append(("part", text.title()))
|
| 300 |
+
continue
|
| 301 |
+
if heading and not is_fragment(text):
|
| 302 |
+
out.append(("h", 2 if blk[0]["arial"] else 3, text))
|
| 303 |
+
continue
|
| 304 |
+
flush_pending()
|
| 305 |
+
body = []
|
| 306 |
+
for k, ln in enumerate(blk):
|
| 307 |
+
if ln["fn"] is not None and not ln["text"] and not (
|
| 308 |
+
k + 1 < len(blk) and blk[k + 1]["size"] < _NOTE_SIZE):
|
| 309 |
+
# a lone superscript on its own line (a numbered step's
|
| 310 |
+
# reference), not a footnote opener: keep it inline
|
| 311 |
+
ref = f"[fn {ln['fn']}]"
|
| 312 |
+
if body:
|
| 313 |
+
body[-1] = f"{body[-1]} {ref}"
|
| 314 |
+
elif out and out[-1][0] == "p":
|
| 315 |
+
out[-1] = ("p", f"{out[-1][1]} {ref}")
|
| 316 |
+
continue
|
| 317 |
+
if ln["fn"] is not None:
|
| 318 |
+
if body:
|
| 319 |
+
add_p(_join(body), first_on_page)
|
| 320 |
+
first_on_page, body = False, []
|
| 321 |
+
out.append(("fn", ln["fn"], ln["text"]))
|
| 322 |
+
elif (not in_cases and not ln["bold"]
|
| 323 |
+
and ln["size"] < _NOTE_SIZE
|
| 324 |
+
and any(it[0] == "fn" for it in out)):
|
| 325 |
+
if out[-1][0] == "fn": # footnote text / continuation
|
| 326 |
+
n, t = out[-1][1], out[-1][2]
|
| 327 |
+
out[-1] = ("fn", n, _join([t, ln["text"]]))
|
| 328 |
+
else: # note continued from the last page
|
| 329 |
+
j = max(i for i, it in enumerate(out) if it[0] == "fn")
|
| 330 |
+
out.append(("fn", out[j][1], ln["text"]))
|
| 331 |
+
else:
|
| 332 |
+
if ln["bullet"] and body:
|
| 333 |
+
add_p(_join(body), first_on_page)
|
| 334 |
+
first_on_page, body = False, []
|
| 335 |
+
body.append(("- " if ln["bullet"] else "") + ln["text"])
|
| 336 |
+
if body:
|
| 337 |
+
add_p(_join(body), first_on_page)
|
| 338 |
+
first_on_page = False
|
| 339 |
+
flush_pending()
|
| 340 |
+
return out, date
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def sections(its, pins=None):
|
| 344 |
+
"""(sections, unused_pins): [{num, title, division, body}] from items().
|
| 345 |
+
A level-3 heading is titled '<section>: <sub-heading>'. Numbers follow
|
| 346 |
+
`pins` ([(title, number)] in document order) when given -- unpinned
|
| 347 |
+
headings between pins take decimals -- else run 1, 2, 3, ..."""
|
| 348 |
+
queue, has_pins = list(pins or []), bool(pins)
|
| 349 |
+
secs, parent, division = [], "", ""
|
| 350 |
+
state = {"int": 0, "sub": 0}
|
| 351 |
+
|
| 352 |
+
def number(title):
|
| 353 |
+
if queue and queue[0][0] == title:
|
| 354 |
+
state["int"], state["sub"] = queue.pop(0)[1], 0
|
| 355 |
+
return str(state["int"])
|
| 356 |
+
if has_pins:
|
| 357 |
+
state["sub"] += 1
|
| 358 |
+
return f"{state['int']}.{state['sub']}"
|
| 359 |
+
state["int"] += 1
|
| 360 |
+
return str(state["int"])
|
| 361 |
+
|
| 362 |
+
def open_section(title):
|
| 363 |
+
secs.append({"num": number(title), "title": title,
|
| 364 |
+
"division": division, "body": []})
|
| 365 |
+
|
| 366 |
+
for it in its:
|
| 367 |
+
if it[0] == "part":
|
| 368 |
+
division = it[1]
|
| 369 |
+
elif it[0] == "h":
|
| 370 |
+
title = it[2]
|
| 371 |
+
if it[1] == 2:
|
| 372 |
+
parent = title
|
| 373 |
+
elif parent:
|
| 374 |
+
title = f"{parent}: {title}"
|
| 375 |
+
open_section(title)
|
| 376 |
+
else:
|
| 377 |
+
if not secs:
|
| 378 |
+
open_section("Introduction")
|
| 379 |
+
secs[-1]["body"].append(it[1] if it[0] == "p"
|
| 380 |
+
else f"[fn {it[1]}] {it[2]}")
|
| 381 |
+
for s in secs:
|
| 382 |
+
s["body"] = "\n".join(s["body"])
|
| 383 |
+
return secs, queue
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def history(paper, dated):
|
| 387 |
+
page = paper["index"].rsplit("/", 1)[-1]
|
| 388 |
+
return (f"ARCHIVED by the IRB: its legal-concepts index ({page}, "
|
| 389 |
+
f"modified {ARCHIVED}) marks this paper archived -- kept for "
|
| 390 |
+
f"reference, not updated since. Paper dated {dated}.")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def chunks_for(paper, secs, dated):
|
| 394 |
+
"""Corpus chunks for one paper's sections. Pure, for tests."""
|
| 395 |
+
out = []
|
| 396 |
+
for s in secs:
|
| 397 |
+
body = s["body"].strip()
|
| 398 |
+
if not body:
|
| 399 |
+
continue
|
| 400 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 401 |
+
total = len(pieces)
|
| 402 |
+
for k, piece in enumerate(pieces, start=1):
|
| 403 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 404 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 405 |
+
out.append({
|
| 406 |
+
"id": f"{paper['code'].lower()}-{s['num']}{suffix}",
|
| 407 |
+
"doc_type": "memorandum",
|
| 408 |
+
"act_code": paper["code"],
|
| 409 |
+
"act_short": paper["short"],
|
| 410 |
+
"act_name": paper["name"],
|
| 411 |
+
"section": f"{paper['label']} § {s['num']}",
|
| 412 |
+
"marginal_note": s["title"],
|
| 413 |
+
"part": paper["title"],
|
| 414 |
+
"division": s["division"],
|
| 415 |
+
"heading": "",
|
| 416 |
+
"text": piece,
|
| 417 |
+
"history": history(paper, dated),
|
| 418 |
+
"last_amended": dated,
|
| 419 |
+
"current_to": dated,
|
| 420 |
+
"status": "archived",
|
| 421 |
+
"archived": ARCHIVED,
|
| 422 |
+
"archive_index": paper["index"],
|
| 423 |
+
"citation": f"{paper['short']}, § {s['num']} "
|
| 424 |
+
f"({s['title']}){note}",
|
| 425 |
+
"source_url": _BASE + paper["file"],
|
| 426 |
+
})
|
| 427 |
+
return out
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def build(allow_shrink=False, force=False):
|
| 431 |
+
chunks, failures = [], []
|
| 432 |
+
for paper in PAPERS:
|
| 433 |
+
try:
|
| 434 |
+
pdf = RAW / paper["file"]
|
| 435 |
+
fetch_cached(_BASE + paper["file"], pdf, pause=1.0, force=force)
|
| 436 |
+
its, date = items(pdf_pages(pdf))
|
| 437 |
+
except Exception as exc:
|
| 438 |
+
print(f" !! {paper['code']}: fetch/parse failed "
|
| 439 |
+
f"({type(exc).__name__}: {exc})")
|
| 440 |
+
failures.append(paper["code"])
|
| 441 |
+
continue
|
| 442 |
+
secs, unused = sections(its, paper.get("pins"))
|
| 443 |
+
if unused:
|
| 444 |
+
print(f" !! {paper['code']}: pinned headings not found "
|
| 445 |
+
f"(section numbers may have moved): "
|
| 446 |
+
f"{', '.join(t for t, _ in unused)}")
|
| 447 |
+
# The running head carries the paper's date; the PAPERS row is the
|
| 448 |
+
# fallback when the template changes.
|
| 449 |
+
dated = date or paper.get("dated", "")
|
| 450 |
+
got = chunks_for(paper, secs, dated)
|
| 451 |
+
if not got:
|
| 452 |
+
print(f" !! {paper['code']}: no content parsed; keeping stored copy")
|
| 453 |
+
failures.append(paper["code"])
|
| 454 |
+
continue
|
| 455 |
+
chunks += got
|
| 456 |
+
print(f" {paper['code']} ({paper['title']}): "
|
| 457 |
+
f"{sum(1 for s in secs if s['body'].strip())} sections, "
|
| 458 |
+
f"{len(got)} chunks, dated {dated or '?'}")
|
| 459 |
+
out = PROCESSED_DIR / "irb_papers.json"
|
| 460 |
+
chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
|
| 461 |
+
key=lambda c: c["act_code"],
|
| 462 |
+
only=set(failures))
|
| 463 |
+
if preserved:
|
| 464 |
+
print(f" kept the last-good copy of {len(preserved)} paper(s) "
|
| 465 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 466 |
+
uniquify_ids(chunks)
|
| 467 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 468 |
+
return False
|
| 469 |
+
print(f"irb_papers: {len(chunks)} chunks -> {out.name}")
|
| 470 |
+
return chunks
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def main():
|
| 474 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 475 |
+
force="--force" in sys.argv) is not False
|
| 476 |
+
sys.exit(0 if ok else 1)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
if __name__ == "__main__":
|
| 480 |
+
main()
|
canlex/loganalyze.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mine the MCP call log: what people ask CanLex, and where it falls short.
|
| 2 |
+
|
| 3 |
+
Two record shapes are read:
|
| 4 |
+
calls.jsonl -- one record per tool call (canlex/calllog.py, 2026-09-16):
|
| 5 |
+
CANLEX_LOG_DIR/calls.jsonl on a server with CANLEX_LOG_DIR
|
| 6 |
+
set, and logs/YYYY-MM-DD/<uuid>.jsonl in the private dataset
|
| 7 |
+
the Space pushes to (CANLEX_LOG_DATASET).
|
| 8 |
+
queries.jsonl -- the older search-only log (server._log_query until
|
| 9 |
+
2026-09-16), read as canlex_search_legislation calls.
|
| 10 |
+
|
| 11 |
+
py -m canlex.loganalyze # CANLEX_LOG_DIR: calls.jsonl, queries.jsonl
|
| 12 |
+
py -m canlex.loganalyze PATH [PATH ...] # files, or folders searched for *.jsonl
|
| 13 |
+
py -m canlex.loganalyze --dataset Beemer0/canlex-call-log
|
| 14 |
+
# token: CANLEX_LOG_TOKEN (this process's
|
| 15 |
+
# environment, else the Windows user
|
| 16 |
+
# environment variable of that name), else
|
| 17 |
+
# asked for with hidden input in a terminal
|
| 18 |
+
py -m canlex.loganalyze --days 7 ... # only the calls of the last 7 days (with
|
| 19 |
+
# --dataset, only those days are downloaded)
|
| 20 |
+
|
| 21 |
+
It reports calls per tool and per day, the most common question texts and
|
| 22 |
+
offence strings, no-result and weak-match searches (the only unbiased source
|
| 23 |
+
of corpus and synonym gaps, since the eval set is authored by the
|
| 24 |
+
maintainer), NOT SETTLED screening results, and errors.
|
| 25 |
+
|
| 26 |
+
Privacy: the arguments are what users typed -- offences, dates, places, and
|
| 27 |
+
sometimes personal facts about a traveller. Treat every log as sensitive: keep
|
| 28 |
+
the dataset private, never commit a log (data/logs/ is gitignored), and do not
|
| 29 |
+
paste this report anywhere public. A --dataset download goes to a temporary
|
| 30 |
+
folder that is deleted afterwards unless --out names one.
|
| 31 |
+
"""
|
| 32 |
+
import argparse
|
| 33 |
+
import datetime
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import sys
|
| 37 |
+
import tempfile
|
| 38 |
+
from collections import Counter
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
# Mirror server.HEDGE_THRESHOLD's intent without importing the server: a query
|
| 42 |
+
# whose best result scored below this is a weak match worth reviewing.
|
| 43 |
+
_WEAK = 0.72
|
| 44 |
+
|
| 45 |
+
SEARCH_TOOL = "canlex_search_legislation"
|
| 46 |
+
# CANLEX_RESULT branch codes and rehabilitation statuses the screening tools
|
| 47 |
+
# print as NOT SETTLED (mirrored, not imported, like _WEAK above). 'unknown'
|
| 48 |
+
# is the server's no-pairing-matched branch (server.py, grouped with the
|
| 49 |
+
# open branches in screening.py); 'undetermined' is rehab's 'an open fact
|
| 50 |
+
# decides a limb' status, which the memo prints as NOT SETTLED. Both were
|
| 51 |
+
# counted as settled until 2026-09-21. tests/test_calllog.py pins this list
|
| 52 |
+
# against the codes the server and engine emit.
|
| 53 |
+
OPEN_BRANCHES = ("depends", "tie", "tran-open", "limb-open", "unknown")
|
| 54 |
+
NOT_SETTLED_STATUS = "not-settled"
|
| 55 |
+
NOT_SETTLED_STATUSES = (NOT_SETTLED_STATUS, "undetermined")
|
| 56 |
+
# canlex_transborder on a composed release without the IRPR s. 19(1)(c)-(g)
|
| 57 |
+
# table: printed 'NOT ASSESSED -- the ... table is held for review', its own
|
| 58 |
+
# label, so the report counts it on its own line, not under NOT SETTLED.
|
| 59 |
+
HELD_BRANCH = "table-held"
|
| 60 |
+
OFFENCE_KEYS = ("offense", "offence")
|
| 61 |
+
TOP = 20
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _load(path):
|
| 65 |
+
rows = []
|
| 66 |
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
| 67 |
+
line = line.strip()
|
| 68 |
+
if not line:
|
| 69 |
+
continue
|
| 70 |
+
try:
|
| 71 |
+
rows.append(json.loads(line))
|
| 72 |
+
except ValueError:
|
| 73 |
+
continue
|
| 74 |
+
return rows
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def log_files(paths):
|
| 78 |
+
"""The .jsonl files named, or found (recursively) under the folders named."""
|
| 79 |
+
out = []
|
| 80 |
+
for p in map(Path, paths):
|
| 81 |
+
if p.is_dir():
|
| 82 |
+
out += sorted(f for f in p.rglob("*.jsonl") if f.is_file())
|
| 83 |
+
elif p.is_file():
|
| 84 |
+
out.append(p)
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _stamp(ts):
|
| 89 |
+
"""A record's ts as ISO 8601 text (a legacy record's is epoch seconds)."""
|
| 90 |
+
if isinstance(ts, (int, float)) and not isinstance(ts, bool):
|
| 91 |
+
return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
|
| 92 |
+
.isoformat(timespec="seconds").replace("+00:00", "Z"))
|
| 93 |
+
return str(ts or "")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _day(ts):
|
| 97 |
+
return _stamp(ts)[:10] or "?"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def normalize(row):
|
| 101 |
+
"""A call record, or None. A legacy queries.jsonl row (a 'query', no
|
| 102 |
+
'tool') becomes a canlex_search_legislation call."""
|
| 103 |
+
if not isinstance(row, dict):
|
| 104 |
+
return None
|
| 105 |
+
if isinstance(row.get("tool"), str):
|
| 106 |
+
if not isinstance(row.get("arguments"), dict):
|
| 107 |
+
row = dict(row, arguments={})
|
| 108 |
+
return row
|
| 109 |
+
if "query" not in row:
|
| 110 |
+
return None
|
| 111 |
+
return {"ts": row.get("ts"), "tool": SEARCH_TOOL,
|
| 112 |
+
"arguments": {k: row.get(k) for k in ("query", "top_k", "act", "doc_type")},
|
| 113 |
+
"ok": True, "n_results": row.get("n_results"),
|
| 114 |
+
"confidence": row.get("confidence"),
|
| 115 |
+
"top_citations": row.get("top_citations") or [], "legacy": True}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def load_calls(paths):
|
| 119 |
+
"""(calls, files read) from the files and folders named."""
|
| 120 |
+
files = log_files(paths)
|
| 121 |
+
calls = []
|
| 122 |
+
for f in files:
|
| 123 |
+
calls += [c for c in map(normalize, _load(f)) if c is not None]
|
| 124 |
+
calls.sort(key=lambda c: _stamp(c.get("ts")))
|
| 125 |
+
return calls, files
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _text(value):
|
| 129 |
+
return " ".join(str(value).split()).strip().lower() if value is not None else ""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def offences(call):
|
| 133 |
+
"""The offence strings a screening call's arguments carry."""
|
| 134 |
+
args = call.get("arguments") or {}
|
| 135 |
+
out = [args[k] for k in OFFENCE_KEYS if isinstance(args.get(k), str)]
|
| 136 |
+
entries = args.get("entries")
|
| 137 |
+
if isinstance(args.get("entries_json"), str):
|
| 138 |
+
try:
|
| 139 |
+
entries = json.loads(args["entries_json"])
|
| 140 |
+
except ValueError:
|
| 141 |
+
entries = None
|
| 142 |
+
for e in entries if isinstance(entries, list) else []:
|
| 143 |
+
if isinstance(e, dict):
|
| 144 |
+
out += [e[k] for k in OFFENCE_KEYS if isinstance(e.get(k), str)]
|
| 145 |
+
return [t for t in map(_text, out) if t]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def not_settled(call):
|
| 149 |
+
"""True when a screening result is NOT SETTLED: an open branch (a tie,
|
| 150 |
+
a fact-dependent branch, no pairing matched), or a rehabilitation status
|
| 151 |
+
that is not settled or undetermined."""
|
| 152 |
+
result = call.get("result")
|
| 153 |
+
if not isinstance(result, dict):
|
| 154 |
+
return False
|
| 155 |
+
if result.get("branch") in OPEN_BRANCHES:
|
| 156 |
+
return True
|
| 157 |
+
for key in ("rehab_status", "status"):
|
| 158 |
+
value = result.get(key)
|
| 159 |
+
values = value if isinstance(value, list) else [value]
|
| 160 |
+
if any(v in NOT_SETTLED_STATUSES for v in values):
|
| 161 |
+
return True
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def held_table(call):
|
| 166 |
+
"""True when canlex_transborder answered NOT ASSESSED because the
|
| 167 |
+
composed release ships without the s. 19(1)(c)-(g) table."""
|
| 168 |
+
result = call.get("result")
|
| 169 |
+
return isinstance(result, dict) and result.get("branch") == HELD_BRANCH
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _summary(call, width=70):
|
| 173 |
+
args = call.get("arguments") or {}
|
| 174 |
+
offs = offences(call)
|
| 175 |
+
text = (args.get("query") if isinstance(args.get("query"), str)
|
| 176 |
+
else "; ".join(offs) if offs
|
| 177 |
+
else json.dumps(args, ensure_ascii=False, sort_keys=True))
|
| 178 |
+
text = " ".join(str(text).split())
|
| 179 |
+
return text if len(text) <= width else text[:width - 3] + "..."
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def since(days, now=None):
|
| 183 |
+
"""The ISO 8601 UTC cutoff for 'the last `days` days', or None for no cutoff."""
|
| 184 |
+
if not days:
|
| 185 |
+
return None
|
| 186 |
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
| 187 |
+
return (now - datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def window_patterns(days, now=None):
|
| 191 |
+
"""snapshot_download allow_patterns for the dataset's logs/YYYY-MM-DD/ folders
|
| 192 |
+
that can hold a call from the last `days` days (every folder with no cutoff)."""
|
| 193 |
+
if not days:
|
| 194 |
+
return ["logs/*"]
|
| 195 |
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
| 196 |
+
return [f"logs/{(now - datetime.timedelta(days=n)).strftime('%Y-%m-%d')}/*"
|
| 197 |
+
for n in range(days + 1)]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def report(paths, out=None, days=None, now=None):
|
| 201 |
+
out = out or sys.stdout
|
| 202 |
+
paths = list(paths) if isinstance(paths, (list, tuple)) else [paths]
|
| 203 |
+
calls, files = load_calls(paths)
|
| 204 |
+
cutoff = since(days, now)
|
| 205 |
+
if cutoff:
|
| 206 |
+
calls = [c for c in calls if _stamp(c.get("ts")) >= cutoff]
|
| 207 |
+
|
| 208 |
+
def say(line=""):
|
| 209 |
+
print(line, file=out)
|
| 210 |
+
|
| 211 |
+
where = ", ".join(str(f) for f in files[:3]) + (f" and {len(files) - 3} more"
|
| 212 |
+
if len(files) > 3 else "")
|
| 213 |
+
if not calls:
|
| 214 |
+
say(f"No logged tool calls in {where or ', '.join(map(str, paths))}"
|
| 215 |
+
+ (f" in the last {days} day(s)." if cutoff else "."))
|
| 216 |
+
return calls
|
| 217 |
+
legacy = sum(1 for c in calls if c.get("legacy"))
|
| 218 |
+
if cutoff:
|
| 219 |
+
say(f"Window: the last {days} day(s), since {cutoff}Z.")
|
| 220 |
+
span = sorted({_day(c.get("ts")) for c in calls})
|
| 221 |
+
say(f"{len(calls)} logged tool calls, {span[0]} to {span[-1]}, from {len(files)} "
|
| 222 |
+
f"file(s): {where}" + (f" ({legacy} from the legacy search log)" if legacy else ""))
|
| 223 |
+
dropped = sum(c.get("dropped_before") or 0 for c in calls
|
| 224 |
+
if isinstance(c.get("dropped_before"), int))
|
| 225 |
+
if dropped:
|
| 226 |
+
say(f"{dropped} further call(s) were not recorded (the log's queue was full).")
|
| 227 |
+
|
| 228 |
+
per_tool = Counter(c.get("tool") for c in calls)
|
| 229 |
+
errors_by_tool = Counter(c.get("tool") for c in calls if c.get("ok") is False)
|
| 230 |
+
say("\n== Calls per tool ==")
|
| 231 |
+
for tool, n in per_tool.most_common():
|
| 232 |
+
errs = errors_by_tool.get(tool)
|
| 233 |
+
say(f" {n:>5} {tool}" + (f" ({errs} error(s))" if errs else ""))
|
| 234 |
+
|
| 235 |
+
say("\n== Calls per day ==")
|
| 236 |
+
for day, n in sorted(Counter(_day(c.get("ts")) for c in calls).items()):
|
| 237 |
+
say(f" {day} {n:>5}")
|
| 238 |
+
|
| 239 |
+
searches = [c for c in calls if c.get("tool") == SEARCH_TOOL]
|
| 240 |
+
questions = Counter(t for t in (_text((c.get("arguments") or {}).get("query"))
|
| 241 |
+
for c in searches) if t)
|
| 242 |
+
say(f"\n== Most common questions ({SEARCH_TOOL} query; {len(questions)} distinct) ==")
|
| 243 |
+
for q, n in questions.most_common(TOP):
|
| 244 |
+
say(f" {n:>3}x {q}")
|
| 245 |
+
|
| 246 |
+
offence_counts = Counter(o for c in calls for o in offences(c))
|
| 247 |
+
say(f"\n== Most common offence strings (screening tools; {len(offence_counts)} distinct) ==")
|
| 248 |
+
for o, n in offence_counts.most_common(TOP):
|
| 249 |
+
say(f" {n:>3}x {o}")
|
| 250 |
+
|
| 251 |
+
ok_searches = [c for c in searches if c.get("ok") is not False]
|
| 252 |
+
no_result = [c for c in ok_searches if c.get("n_results") == 0]
|
| 253 |
+
weak = sorted((c for c in ok_searches
|
| 254 |
+
if isinstance(c.get("confidence"), (int, float))
|
| 255 |
+
and c["confidence"] < _WEAK and c.get("n_results")),
|
| 256 |
+
key=lambda c: c["confidence"])
|
| 257 |
+
say(f"\n== No-result searches ({len(no_result)}) -- likely corpus or vocabulary gaps ==")
|
| 258 |
+
for c in no_result[:25]:
|
| 259 |
+
say(f" {_summary(c)}")
|
| 260 |
+
say(f"\n== Weak matches (confidence < {_WEAK}; {len(weak)}) -- candidate eval "
|
| 261 |
+
f"questions / synonyms ==")
|
| 262 |
+
for c in weak[:25]:
|
| 263 |
+
top = (c.get("top_citations") or ["?"])[0]
|
| 264 |
+
say(f" {c['confidence']:.3f} {_summary(c):70} -> {top}")
|
| 265 |
+
|
| 266 |
+
unsettled = [c for c in calls if not_settled(c)]
|
| 267 |
+
say(f"\n== NOT SETTLED screening results ({len(unsettled)}) ==")
|
| 268 |
+
for c in unsettled[-25:]:
|
| 269 |
+
result = c.get("result") or {}
|
| 270 |
+
what = ", ".join(f"{k}={result[k]}" for k in ("branch", "rehab_status", "status")
|
| 271 |
+
if result.get(k) is not None)
|
| 272 |
+
needs = result.get("needs") or []
|
| 273 |
+
say(f" {_day(c.get('ts'))} {c.get('tool')} {what} -- {_summary(c, 50)}")
|
| 274 |
+
if needs:
|
| 275 |
+
say(f" needs: {needs[0]}")
|
| 276 |
+
|
| 277 |
+
held = [c for c in calls if held_table(c)]
|
| 278 |
+
say(f"\n== NOT ASSESSED (held table) transborder results ({len(held)}) -- "
|
| 279 |
+
f"the s. 19(1)(c)-(g) table is held for review on the deployment ==")
|
| 280 |
+
for c in held[-25:]:
|
| 281 |
+
say(f" {_day(c.get('ts'))} {c.get('tool')} -- {_summary(c, 50)}")
|
| 282 |
+
|
| 283 |
+
errors = [c for c in calls if c.get("ok") is False]
|
| 284 |
+
say(f"\n== Errors ({len(errors)}) ==")
|
| 285 |
+
for (tool, err), n in Counter((c.get("tool"), c.get("error") or "?")
|
| 286 |
+
for c in errors).most_common():
|
| 287 |
+
say(f" {n:>5} {tool} {err}")
|
| 288 |
+
for c in errors[-10:]:
|
| 289 |
+
say(f" {_stamp(c.get('ts'))} {c.get('tool')} {c.get('error') or '?'} -- {_summary(c, 60)}")
|
| 290 |
+
return calls
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def download_dataset(repo_id, token, dest, days=None, now=None):
|
| 294 |
+
"""The dataset's logs/ folder (only the last `days` days' folders when
|
| 295 |
+
given), downloaded into `dest`; returns its path."""
|
| 296 |
+
from huggingface_hub import snapshot_download
|
| 297 |
+
return snapshot_download(repo_id=repo_id, repo_type="dataset", token=token,
|
| 298 |
+
allow_patterns=window_patterns(days, now), local_dir=str(dest))
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def user_env_token():
|
| 302 |
+
"""CANLEX_LOG_TOKEN as a Windows *user* environment variable, read from the
|
| 303 |
+
registry, or "". An unattended run (the weekly check) inherits its parent
|
| 304 |
+
app's environment, which predates a variable set later in Windows' own
|
| 305 |
+
'Edit environment variables for your account' dialog; reading the registry
|
| 306 |
+
finds it without restarting anything. Deliberately NOT huggingface_hub's
|
| 307 |
+
saved login (`hf auth login` / HF_TOKEN): that token may be broad, and the
|
| 308 |
+
log should be read only with one scoped to the log dataset."""
|
| 309 |
+
try:
|
| 310 |
+
import winreg
|
| 311 |
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
|
| 312 |
+
value, _kind = winreg.QueryValueEx(key, "CANLEX_LOG_TOKEN")
|
| 313 |
+
return str(value or "").strip()
|
| 314 |
+
except Exception:
|
| 315 |
+
return ""
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def main(argv=None):
|
| 319 |
+
ap = argparse.ArgumentParser(
|
| 320 |
+
prog="python -m canlex.loganalyze",
|
| 321 |
+
description="Report on the CanLex call log (calls.jsonl, legacy queries.jsonl, or "
|
| 322 |
+
"the private log dataset). The log may hold personal facts: keep it "
|
| 323 |
+
"private.")
|
| 324 |
+
ap.add_argument("paths", nargs="*",
|
| 325 |
+
help="log files, or folders searched for *.jsonl (default: CANLEX_LOG_DIR)")
|
| 326 |
+
ap.add_argument("--dataset", metavar="REPO",
|
| 327 |
+
help="download logs/ from this private Hugging Face dataset repo "
|
| 328 |
+
"(token from the CANLEX_LOG_TOKEN environment variable -- this "
|
| 329 |
+
"process's, else the Windows user variable -- else asked for with "
|
| 330 |
+
"hidden input in a terminal)")
|
| 331 |
+
ap.add_argument("--days", metavar="N", type=int,
|
| 332 |
+
help="report only the calls of the last N days (with --dataset, only "
|
| 333 |
+
"those days' folders are downloaded)")
|
| 334 |
+
ap.add_argument("--out", metavar="DIR",
|
| 335 |
+
help="with --dataset: keep the download in DIR (default: a temporary "
|
| 336 |
+
"folder, deleted afterwards)")
|
| 337 |
+
args = ap.parse_args(argv)
|
| 338 |
+
|
| 339 |
+
if args.dataset:
|
| 340 |
+
token = (os.environ.get("CANLEX_LOG_TOKEN") or "").strip() or user_env_token()
|
| 341 |
+
if not token and sys.stdin is not None and sys.stdin.isatty():
|
| 342 |
+
# asked for with hidden input, never typed on a command line: a
|
| 343 |
+
# shell's history file keeps what is typed there (PowerShell 5.1's
|
| 344 |
+
# PSReadLine 2.0 saves every line, in plain text)
|
| 345 |
+
import getpass
|
| 346 |
+
try:
|
| 347 |
+
token = getpass.getpass(f"Hugging Face token that can read {args.dataset} "
|
| 348 |
+
"(input hidden): ").strip()
|
| 349 |
+
except (EOFError, KeyboardInterrupt):
|
| 350 |
+
token = ""
|
| 351 |
+
if not token:
|
| 352 |
+
print("No token: run this in a terminal to be asked for a Hugging Face token "
|
| 353 |
+
f"that can read {args.dataset}, or set CANLEX_LOG_TOKEN as a Windows user "
|
| 354 |
+
"environment variable (Windows search: 'Edit environment variables for "
|
| 355 |
+
"your account'). Never type the token itself on a command line.",
|
| 356 |
+
file=sys.stderr)
|
| 357 |
+
sys.exit(2)
|
| 358 |
+
if args.out:
|
| 359 |
+
folder = download_dataset(args.dataset, token, Path(args.out), args.days)
|
| 360 |
+
report([folder] + list(args.paths), days=args.days)
|
| 361 |
+
return
|
| 362 |
+
with tempfile.TemporaryDirectory(prefix="canlex-call-log-",
|
| 363 |
+
ignore_cleanup_errors=True) as tmp:
|
| 364 |
+
folder = download_dataset(args.dataset, token, Path(tmp), args.days)
|
| 365 |
+
report([folder] + list(args.paths), days=args.days)
|
| 366 |
+
return
|
| 367 |
+
|
| 368 |
+
paths = list(args.paths)
|
| 369 |
+
if not paths:
|
| 370 |
+
log_dir = Path(os.environ.get("CANLEX_LOG_DIR", "."))
|
| 371 |
+
paths = [p for p in (log_dir / "calls.jsonl", log_dir / "queries.jsonl") if p.exists()]
|
| 372 |
+
if not log_files(paths):
|
| 373 |
+
print(f"No call log at {', '.join(map(str, paths)) or os.environ.get('CANLEX_LOG_DIR', '.')}."
|
| 374 |
+
" Set CANLEX_LOG_DIR on the server (and pass the path here), or use "
|
| 375 |
+
"--dataset.", file=sys.stderr)
|
| 376 |
+
sys.exit(1)
|
| 377 |
+
report(paths, days=args.days)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
if __name__ == "__main__":
|
| 381 |
+
main()
|
canlex/narrative.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The DRAFT s. 44(1) narrative skeleton in the ENF 5 s. 13.1 form, English
|
| 2 |
+
and French (roadmap T4.6; track A step A15, 2026-09-15 -- HELD FOR REVIEW).
|
| 3 |
+
|
| 4 |
+
ENF 5 s. 13.1 (2025-02-20) says that in applying A36(2)(b) 'it is not
|
| 5 |
+
sufficient to state that the person has been convicted of an offence', and
|
| 6 |
+
gives the narrative's form. This module renders that form from what the
|
| 7 |
+
screening chain holds, and from nothing else:
|
| 8 |
+
|
| 9 |
+
- ENF 5's frames, verbatim (EN_FRAME, FR_FRAME -- tests/test_narrative.py
|
| 10 |
+
pins every fragment to enf-5-13-1-p2 and enf-fr-5-13-1-p2/-p3);
|
| 11 |
+
- the IRPA paragraph the chain proposes, with its words verbatim (English:
|
| 12 |
+
the memo's _ALLEGATION, passed in; French: FR_ALLEGATION, pinned to
|
| 13 |
+
I-2.5-fr-s36-p1);
|
| 14 |
+
- the Canadian provision from the pairing's optional 'report_provision'
|
| 15 |
+
(curated JSON, HELD FOR REVIEW; every line verbatim in the corpus in both
|
| 16 |
+
languages), chosen by the matched limb and the maximum at commission;
|
| 17 |
+
- the maximum in the statute's own words, in each language -- ONE rule for
|
| 18 |
+
both: the words the provision's own line uses for the term ('not more than
|
| 19 |
+
10 years' / 'de dix ans'; 'for life' / 'a perpetuite').
|
| 20 |
+
|
| 21 |
+
What the tool does not hold -- the place and date of issue, the addressee,
|
| 22 |
+
the name and aliases, the city, the conviction date unless given, the
|
| 23 |
+
status evidence, the signature -- stays [CONFIRM: ...]; a value the tool
|
| 24 |
+
proposes is bracketed with what to confirm. Where the chain is not settled
|
| 25 |
+
(a tie, the limb, the offence date, the conviction question) every reading
|
| 26 |
+
gets its own allegation, each with its condition: nothing open is printed
|
| 27 |
+
as settled. A maximum at commission that differs from the current
|
| 28 |
+
provision's (Tran: a curated history row, or a provision not in force at
|
| 29 |
+
commission) prints [CONFIRM], because the former text is not in the corpus.
|
| 30 |
+
A composed release on the approved curated JSON has no report_provision: the
|
| 31 |
+
provision and maximum then stay [CONFIRM], and the block says the provisions
|
| 32 |
+
are held for review.
|
| 33 |
+
"""
|
| 34 |
+
import re
|
| 35 |
+
|
| 36 |
+
from canlex.screening import LIFE, _NUM_WORDS, limb_max
|
| 37 |
+
|
| 38 |
+
HEAD = "## DRAFT s. 44(1) narrative skeleton (ENF 5 s. 13.1): verify every bracket"
|
| 39 |
+
INTRO = (
|
| 40 |
+
"DRAFT -- not a report, and nothing in it is found for you. ENF 5 s. 13.1 (2025-02-20) "
|
| 41 |
+
"sets the elements of an A44(1) report: 'it is not sufficient to state that the person "
|
| 42 |
+
"has been convicted of an offence'. The frames below are ENF 5's words; '(drafted)' marks "
|
| 43 |
+
"CanLex's wording, for which ENF 5 gives no sample (the title, the allegation sentence, "
|
| 44 |
+
"the act-branch and two-offence narratives). Verify every bracket: [CONFIRM: ...] is a "
|
| 45 |
+
"fact CanLex does not hold, and [value -- ... CONFIRM] a value CanLex proposes from the "
|
| 46 |
+
"chain above. The maximum is the statute's own words for the maximum at commission, in "
|
| 47 |
+
"each language.")
|
| 48 |
+
MULTI = ("ENF 5 s. 13.4: 'it is generally recommended that the officer writes a separate "
|
| 49 |
+
"report for each allegation' -- one allegation per skeleton below; where a condition "
|
| 50 |
+
"is shown, the chain has not settled which applies.")
|
| 51 |
+
HELD_NOTE = ("The Canadian provisions (the pairings' 'report_provision') are HELD FOR REVIEW "
|
| 52 |
+
"and not in this deployment: the provision and the maximum stay [CONFIRM].")
|
| 53 |
+
|
| 54 |
+
# ENF 5 s. 13.1 (2025-02-20), verbatim: the English sample (enf-5-13-1-p2)
|
| 55 |
+
# and the French one (enf-fr-5-13-1-p2/-p3). The brackets the sample fills
|
| 56 |
+
# are added by the renderer.
|
| 57 |
+
EN_FRAME = {
|
| 58 |
+
"heading": "This report is based on the following information that the above-named individual:",
|
| 59 |
+
"aka": "Also known as",
|
| 60 |
+
"status_fn": ("-Is not a Canadian citizen; is not a permanent resident of Canada; is "
|
| 61 |
+
"not a registered Indian under the Indian Act;"),
|
| 62 |
+
"convicted": "…has been convicted of an offence; namely,",
|
| 63 |
+
"on_or_about": "on or about",
|
| 64 |
+
"at_or_near": "at or near",
|
| 65 |
+
"equivalence": ("This offence, if committed in Canada, would constitute an offence that "
|
| 66 |
+
"may be punishable by way of indictment under"),
|
| 67 |
+
"maximum": "and for which a maximum term of imprisonment",
|
| 68 |
+
"imposed": "may be imposed.",
|
| 69 |
+
}
|
| 70 |
+
FR_FRAME = {
|
| 71 |
+
"heading": ("Le présent rapport est fondé sur les renseignements suivants indiquant que "
|
| 72 |
+
"la personne susnommée :"),
|
| 73 |
+
"aka": "Aussi connu sous le ou les noms suivants :",
|
| 74 |
+
"status_fn": ("-n’est pas un citoyen canadien; n’est pas un résident permanent du Canada; "
|
| 75 |
+
"n’est pas un Indien inscrit en vertu de la Loi sur les Indiens;"),
|
| 76 |
+
"convicted": "… a été reconnu coupable d’une infraction; nommément",
|
| 77 |
+
"on_or_about": "le ou aux environs du",
|
| 78 |
+
"at_or_near": "à ou près de",
|
| 79 |
+
"equivalence": ("Cette infraction, commise au Canada, constituerait une infraction "
|
| 80 |
+
"punissable par mise en accusation en vertu"),
|
| 81 |
+
"maximum": "et pour laquelle une peine maximale d’emprisonnement",
|
| 82 |
+
"imposed": "peut être imposée.",
|
| 83 |
+
}
|
| 84 |
+
FRAME_SOURCE = {"en": ("enf-5-13-1-p2",), "fr": ("enf-fr-5-13-1-p2", "enf-fr-5-13-1-p3")}
|
| 85 |
+
|
| 86 |
+
# IRPA s. 36 in French, verbatim (I-2.5-fr-s36-p1): the words of each
|
| 87 |
+
# paragraph the allegation names -- the French twin of the memo's _ALLEGATION.
|
| 88 |
+
FR_ALLEGATION = {
|
| 89 |
+
"36(1)(b)": ("être déclaré coupable, à l’extérieur du Canada, d’une infraction qui, "
|
| 90 |
+
"commise au Canada, constituerait une infraction sous le régime d’une loi "
|
| 91 |
+
"fédérale punissable d’un emprisonnement maximal d’au moins dix ans",),
|
| 92 |
+
"36(1)(c)": ("commettre, à l’extérieur du Canada, une infraction qui, commise au Canada, "
|
| 93 |
+
"constituerait une infraction sous le régime d’une loi fédérale punissable "
|
| 94 |
+
"d’un emprisonnement maximal d’au moins dix ans",),
|
| 95 |
+
"36(2)(b)": ("être déclaré coupable, à l’extérieur du Canada, d’une infraction qui, "
|
| 96 |
+
"commise au Canada, constituerait une infraction sous le régime d’une loi "
|
| 97 |
+
"fédérale punissable par mise en accusation",),
|
| 98 |
+
"36(2)(b)-two": ("être déclaré coupable, à l’extérieur du Canada,",
|
| 99 |
+
"de deux infractions qui ne découlent pas des mêmes faits et qui, "
|
| 100 |
+
"commises au Canada, constitueraient des infractions sous le régime de "
|
| 101 |
+
"toute loi fédérale"),
|
| 102 |
+
"36(2)(c)": ("commettre, à l’extérieur du Canada, une infraction qui, commise au Canada, "
|
| 103 |
+
"constituerait une infraction sous le régime d’une loi fédérale punissable "
|
| 104 |
+
"par mise en accusation",),
|
| 105 |
+
}
|
| 106 |
+
FR_PARAGRAPH = {"36(1)(b)": "36(1)b)", "36(1)(c)": "36(1)c)", "36(2)(b)": "36(2)b)",
|
| 107 |
+
"36(2)(c)": "36(2)c)"}
|
| 108 |
+
IRPA_EN = "the Immigration and Refugee Protection Act"
|
| 109 |
+
IRPA_FR = "de la Loi sur l’immigration et la protection des réfugiés"
|
| 110 |
+
|
| 111 |
+
# The narratives ENF 5 gives no sample for: an act (s. 36(1)(c)/(2)(c)) and
|
| 112 |
+
# the two-offence limb of s. 36(2)(b). CanLex's wording, built on ENF 5's
|
| 113 |
+
# sentence and the statute's words; printed '(drafted)'.
|
| 114 |
+
EN_ACT = ("…has committed an act outside Canada; namely, [{act}] on or about [{date}] at or "
|
| 115 |
+
"near [{place}], that is an offence in the place where it was committed, namely "
|
| 116 |
+
"[CONFIRM: the foreign provision], and that, if committed in Canada, would "
|
| 117 |
+
"constitute an offence that may be punishable by way of indictment under [{prov}] "
|
| 118 |
+
"and for which a maximum term of imprisonment [{maxw}] may be imposed. (drafted)")
|
| 119 |
+
FR_ACT = ("… a commis, à l’extérieur du Canada, une infraction, nommément [{act}], le ou aux "
|
| 120 |
+
"environs du [{date}] à ou près de [{place}], qui constitue une infraction dans le "
|
| 121 |
+
"lieu où elle a été commise, nommément [CONFIRMER : la disposition étrangère], et "
|
| 122 |
+
"qui, commise au Canada, constituerait une infraction punissable par mise en "
|
| 123 |
+
"accusation en vertu [{prov}] et pour laquelle une peine maximale d’emprisonnement "
|
| 124 |
+
"[{maxw}] peut être imposée. (rédigé par CanLex)")
|
| 125 |
+
EN_TWO = ("…has been convicted of two offences not arising out of a single occurrence; "
|
| 126 |
+
"namely, {offences}. These offences, if committed in Canada, would constitute "
|
| 127 |
+
"offences under an Act of Parliament, namely {provisions}. (drafted)")
|
| 128 |
+
FR_TWO = ("… a été reconnu coupable de deux infractions qui ne découlent pas des mêmes faits; "
|
| 129 |
+
"nommément {offences}. Ces infractions, commises au Canada, constitueraient des "
|
| 130 |
+
"infractions sous le régime de toute loi fédérale, nommément {provisions}. "
|
| 131 |
+
"(rédigé par CanLex)")
|
| 132 |
+
|
| 133 |
+
# CanLex's labels and bracket notes, per language.
|
| 134 |
+
TEXT = {
|
| 135 |
+
"en": {
|
| 136 |
+
"block": "### English",
|
| 137 |
+
"place_date": "Place and date of issue: [CONFIRM: place of issue], [CONFIRM: date of issue]",
|
| 138 |
+
"addressee": ("To: [CONFIRM: the Minister of Public Safety or the Minister of IRCC -- "
|
| 139 |
+
"ENF 5 allows either]"),
|
| 140 |
+
"title": ("Report under subsection 44(1) of the Immigration and Refugee Protection Act "
|
| 141 |
+
"(drafted title -- not ENF 5 wording)"),
|
| 142 |
+
"name": "Name: [CONFIRM: the complete name, correctly spelled]",
|
| 143 |
+
"aka": "[CONFIRM: other names used, where applicable -- or delete the line]",
|
| 144 |
+
"signature": ("[CONFIRM: name and signature of the officer who conducted the "
|
| 145 |
+
"examination or is otherwise making the report]"),
|
| 146 |
+
"allegation": ("The above-named individual is, in my opinion, inadmissible under "
|
| 147 |
+
"paragraph [{para} -- proposed by CanLex from the chain; CONFIRM] of "
|
| 148 |
+
"{irpa}: '{text}'. (drafted)"),
|
| 149 |
+
"which": "Allegation {k} of {n}{cond}:",
|
| 150 |
+
"cond": " -- {cond}",
|
| 151 |
+
"entry": "entry #{n}, ",
|
| 152 |
+
"status_evidence": ("[CONFIRM: the evidence that the person holds none of these "
|
| 153 |
+
"statuses (ENF 5 s. 11.3)]"),
|
| 154 |
+
"status_assumed": "[CONFIRM: status not given -- CanLex assumed a foreign national]",
|
| 155 |
+
"status_pr": ("-[CONFIRM: a permanent resident's status line -- ENF 5 s. 13.1 gives "
|
| 156 |
+
"none (unsourced draft: 'Is a permanent resident of Canada; is not a "
|
| 157 |
+
"Canadian citizen; is not a registered Indian under the Indian Act;')]"),
|
| 158 |
+
"offence": ("{offence} -- the offence as recorded; CONFIRM the offence of conviction "
|
| 159 |
+
"(not the charge) against the certificate of conviction"),
|
| 160 |
+
"act": "CONFIRM: the act -- the record gives '{offence}'",
|
| 161 |
+
"date": ("CONFIRM: the date{given} -- ENF 5 s. 13.1 does not say whether it is the "
|
| 162 |
+
"conviction or the offence date; Appendix E asks for the Conviction Date"),
|
| 163 |
+
"act_date": "CONFIRM: the date of the act{given}",
|
| 164 |
+
"given_conv": "; conviction date given: {d}",
|
| 165 |
+
"given_off": "; offence date given: {d}",
|
| 166 |
+
"place": "CONFIRM: the city or town{given}",
|
| 167 |
+
"given_place": " -- the record gives {p}",
|
| 168 |
+
"proposed": "{cite} -- proposed from the curated pairing {pid} (statute line {chunk}); CONFIRM",
|
| 169 |
+
"others": "; or, only where the facts are: {alts}",
|
| 170 |
+
"alt": "{when}: {cite}",
|
| 171 |
+
"open": "CONFIRM which: {alts} -- the record's words do not say",
|
| 172 |
+
"open_alt": "{cite} ({when})",
|
| 173 |
+
"history": ("CONFIRM: the provision in force at commission -- the maximum then was "
|
| 174 |
+
"{at} (CanLex's reading, {basis}); its text is not in the corpus. Today's "
|
| 175 |
+
"provision, for reference: {cite}"),
|
| 176 |
+
"history_nif": ("CONFIRM: the provision in force at commission -- today's provision "
|
| 177 |
+
"came into force on {frm}, after the offence; the maximum at "
|
| 178 |
+
"commission on CanLex's reading: {at}; the former text is not in the "
|
| 179 |
+
"corpus. Today's provision, for reference only: {cite}"),
|
| 180 |
+
"history_higher": ("CONFIRM: the provision in force at commission -- the curated "
|
| 181 |
+
"history names it: {label}; its text is not in the corpus. Today's "
|
| 182 |
+
"provision, for reference only (not the provision at commission): "
|
| 183 |
+
"{cite}"),
|
| 184 |
+
"later": "; not listed, as not in force at commission: {alts}",
|
| 185 |
+
"later_alt": "{cite} (in force from {frm})",
|
| 186 |
+
"held": ("CONFIRM: the provision -- this deployment holds no report provision for "
|
| 187 |
+
"{pid} (HELD FOR REVIEW); the pairing names {canadian}"),
|
| 188 |
+
"inchoate": ("CONFIRM: the inchoate provision (Criminal Code ss. 463-465) with the "
|
| 189 |
+
"object offence's provision{ref}"),
|
| 190 |
+
"inchoate_ref": " -- the object offence: {cite}",
|
| 191 |
+
"none": ("CONFIRM: the provision -- no curated report provision of {pid} carries {at}; "
|
| 192 |
+
"the pairing names {canadian}"),
|
| 193 |
+
"derived": ("{cite} -- proposed from the curated pairing {pid} (statute line {chunk}); "
|
| 194 |
+
"CONFIRM, with the object offence's provision"),
|
| 195 |
+
"max_words": "{words} -- the statute's words ({chunk}); the maximum {basis}; CONFIRM",
|
| 196 |
+
"max_open": "CONFIRM: the provision decides -- {alts}",
|
| 197 |
+
"max_open_alt": "{words} ({cite})",
|
| 198 |
+
"max_history": ("CONFIRM: {at} -- the maximum at commission from the curated history; "
|
| 199 |
+
"the statute's words then are not in the corpus"),
|
| 200 |
+
"max_both": ("CONFIRM -- EACH READING, NOT SETTLED: {then} at commission (Tran read "
|
| 201 |
+
"strictly; {label}; the statute's words then are not in the corpus) | "
|
| 202 |
+
"{now} on methodology (b) (the lesser maximum in force now, under today's "
|
| 203 |
+
"provision)"),
|
| 204 |
+
"max_nif": ("CONFIRM: {at} -- CanLex's reading: the provision was not in force at "
|
| 205 |
+
"commission, and the statute's words then are not in the corpus"),
|
| 206 |
+
"max_reading": "CONFIRM: {at} -- CanLex's maximum {basis}",
|
| 207 |
+
"max_inchoate": "CONFIRM: {at} -- CanLex's reading of the inchoate rule, from the object offence",
|
| 208 |
+
"max_derived": "CONFIRM: the maximum is derived from the object offence's",
|
| 209 |
+
"max_unknown": "CONFIRM: the maximum at commission",
|
| 210 |
+
"basis_date": "at commission (the offence date given)",
|
| 211 |
+
"basis_current": ("now in force -- the offence date was not given, so the maximum at "
|
| 212 |
+
"commission is not confirmed"),
|
| 213 |
+
"basis_bound": "at commission, bounded by the {what} date given",
|
| 214 |
+
"basis_other": "at commission",
|
| 215 |
+
"two_offence": "[{offence} -- {entry}CONFIRM] on or about [{date}] at or near [{place}]",
|
| 216 |
+
"two_entry": "entry #{n}; ",
|
| 217 |
+
"two_other": "[CONFIRM: the other offence] on or about [CONFIRM: the date] at or near [CONFIRM: the place]",
|
| 218 |
+
"two_prov": "[{cite} -- proposed from the curated pairing {pid}; CONFIRM]",
|
| 219 |
+
"two_prov_confirm": "[CONFIRM: the provision for {what}]",
|
| 220 |
+
"and": " and ",
|
| 221 |
+
"life": "for life",
|
| 222 |
+
"years": "{n} years",
|
| 223 |
+
},
|
| 224 |
+
"fr": {
|
| 225 |
+
"block": "### Français",
|
| 226 |
+
"place_date": "Lieu et date : [CONFIRMER : lieu], [CONFIRMER : date]",
|
| 227 |
+
"addressee": ("Destinataire : [CONFIRMER : ministre de la Sécurité publique ou ministre "
|
| 228 |
+
"d’IRCC -- l’ENF 5 permet l’un ou l’autre]"),
|
| 229 |
+
"title": ("Rapport établi en vertu du paragraphe 44(1) de la Loi sur l’immigration et la "
|
| 230 |
+
"protection des réfugiés (titre rédigé par CanLex -- pas le libellé de l’ENF 5)"),
|
| 231 |
+
"name": "Nom : [CONFIRMER : nom complet, épelé correctement]",
|
| 232 |
+
"aka": "[CONFIRMER : autres noms utilisés, le cas échéant -- ou supprimer la ligne]",
|
| 233 |
+
"signature": ("[CONFIRMER : nom et signature de l’agent qui a mené l’examen ou qui a "
|
| 234 |
+
"rédigé le rapport]"),
|
| 235 |
+
"allegation": ("À mon avis, la personne susnommée est interdite de territoire au titre de "
|
| 236 |
+
"l’alinéa [{para} -- proposé par CanLex d’après la chaîne; CONFIRMER] "
|
| 237 |
+
"{irpa} : « {text} ». (rédigé par CanLex)"),
|
| 238 |
+
"which": "Allégation {k} sur {n}{cond} :",
|
| 239 |
+
"cond": " -- hypothèse (note de CanLex, en anglais) : {cond}",
|
| 240 |
+
"entry": "inscription no {n}, ",
|
| 241 |
+
"status_evidence": ("[CONFIRMER : la preuve que la personne n’a aucun de ces statuts "
|
| 242 |
+
"(ENF 5, section 11.3)]"),
|
| 243 |
+
"status_assumed": "[CONFIRMER : statut non fourni -- CanLex a supposé un étranger]",
|
| 244 |
+
"status_pr": ("-[CONFIRMER : ligne de statut d’un résident permanent -- l’ENF 5, section "
|
| 245 |
+
"13.1, n’en donne aucune (ébauche non sourcée : « est un résident permanent "
|
| 246 |
+
"du Canada; n’est pas un citoyen canadien; n’est pas un Indien inscrit en "
|
| 247 |
+
"vertu de la Loi sur les Indiens; »)]"),
|
| 248 |
+
"offence": ("{offence} -- infraction inscrite au dossier; CONFIRMER la déclaration de "
|
| 249 |
+
"culpabilité (et non l’accusation initiale) sur le certificat"),
|
| 250 |
+
"act": "CONFIRMER : l’acte -- le dossier indique « {offence} »",
|
| 251 |
+
"date": ("CONFIRMER : la date{given} -- l’ENF 5, section 13.1, ne précise pas s’il "
|
| 252 |
+
"s’agit de la date de la déclaration de culpabilité ou de celle de "
|
| 253 |
+
"l’infraction; l’annexe E demande la date de la déclaration de culpabilité"),
|
| 254 |
+
"act_date": "CONFIRMER : la date de l’acte{given}",
|
| 255 |
+
"given_conv": "; déclaration de culpabilité : {d}",
|
| 256 |
+
"given_off": "; infraction : {d}",
|
| 257 |
+
"place": "CONFIRMER : la ville{given}",
|
| 258 |
+
"given_place": " -- le dossier indique {p}",
|
| 259 |
+
"proposed": ("{cite} -- proposé d’après l’appariement organisé {pid} (ligne de la loi "
|
| 260 |
+
"{chunk}); CONFIRMER"),
|
| 261 |
+
"others": "; ou, seulement si les faits le montrent (note en anglais) : {alts}",
|
| 262 |
+
"alt": "{when} : {cite}",
|
| 263 |
+
"open": "CONFIRMER laquelle : {alts} -- les termes du dossier ne le disent pas",
|
| 264 |
+
"open_alt": "{cite} ({when})",
|
| 265 |
+
"history": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- la peine "
|
| 266 |
+
"maximale était alors de {at} (lecture de CanLex); son texte n’est pas dans "
|
| 267 |
+
"le corpus. La disposition actuelle, à titre indicatif : {cite}"),
|
| 268 |
+
"history_nif": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- la "
|
| 269 |
+
"disposition actuelle est entrée en vigueur le {frm}, après l’infraction; "
|
| 270 |
+
"peine maximale au moment de l’infraction selon la lecture de CanLex : "
|
| 271 |
+
"{at}; l’ancien texte n’est pas dans le corpus. La disposition actuelle, "
|
| 272 |
+
"à titre indicatif seulement : {cite}"),
|
| 273 |
+
"history_higher": ("CONFIRMER : la disposition en vigueur au moment de l’infraction -- "
|
| 274 |
+
"l’historique organisé la désigne (note en anglais) : {label}; son "
|
| 275 |
+
"texte n’est pas dans le corpus. La disposition actuelle, à titre "
|
| 276 |
+
"indicatif seulement (ce n’est pas celle en vigueur au moment de "
|
| 277 |
+
"l’infraction) : {cite}"),
|
| 278 |
+
"later": "; non retenue(s), car pas en vigueur au moment de l’infraction : {alts}",
|
| 279 |
+
"later_alt": "{cite} (en vigueur depuis le {frm})",
|
| 280 |
+
"held": ("CONFIRMER : la disposition -- ce déploiement ne contient aucune disposition de "
|
| 281 |
+
"rapport pour {pid} (EN ATTENTE D’EXAMEN)"),
|
| 282 |
+
"inchoate": ("CONFIRMER : la disposition relative à la tentative, à la complicité ou au "
|
| 283 |
+
"complot (Code criminel, art. 463 à 465), avec celle de l’infraction "
|
| 284 |
+
"visée{ref}"),
|
| 285 |
+
"inchoate_ref": " -- l’infraction visée : {cite}",
|
| 286 |
+
"none": ("CONFIRMER : la disposition -- aucune disposition de rapport organisée de "
|
| 287 |
+
"{pid} ne porte ce maximum ({at})"),
|
| 288 |
+
"derived": ("{cite} -- proposé d’après l’appariement organisé {pid} (ligne de la loi "
|
| 289 |
+
"{chunk}); CONFIRMER, avec la disposition de l’infraction visée"),
|
| 290 |
+
"max_words": ("{words} -- les mots de la loi ({chunk}); la peine maximale {basis}; "
|
| 291 |
+
"CONFIRMER"),
|
| 292 |
+
"max_open": "CONFIRMER : selon la disposition -- {alts}",
|
| 293 |
+
"max_open_alt": "{words} ({cite})",
|
| 294 |
+
"max_history": ("CONFIRMER : {at} -- la peine maximale au moment de l’infraction selon "
|
| 295 |
+
"l’historique organisé; le texte de la loi alors en vigueur n’est pas "
|
| 296 |
+
"dans le corpus"),
|
| 297 |
+
"max_both": ("CONFIRMER -- CHAQUE LECTURE, NON TRANCHÉ : {then} au moment de "
|
| 298 |
+
"l’infraction (arrêt Tran lu strictement; note en anglais : {label}; le "
|
| 299 |
+
"texte de la loi alors en vigueur n’est pas dans le corpus) | {now} selon "
|
| 300 |
+
"la méthode (b) (la peine maximale moins sévère actuellement en vigueur, "
|
| 301 |
+
"selon la disposition actuelle)"),
|
| 302 |
+
"max_nif": ("CONFIRMER : {at} -- lecture de CanLex : la disposition n’était pas en "
|
| 303 |
+
"vigueur au moment de l’infraction, et le texte de la loi alors en vigueur "
|
| 304 |
+
"n’est pas dans le corpus"),
|
| 305 |
+
"max_reading": "CONFIRMER : {at} -- la peine maximale selon CanLex, {basis}",
|
| 306 |
+
"max_inchoate": ("CONFIRMER : {at} -- lecture de CanLex de la règle applicable à la "
|
| 307 |
+
"tentative ou à la complicité, d’après l’infraction visée"),
|
| 308 |
+
"max_derived": "CONFIRMER : la peine maximale découle de celle de l’infraction visée",
|
| 309 |
+
"max_unknown": "CONFIRMER : la peine maximale au moment de l’infraction",
|
| 310 |
+
"basis_date": "au moment de l’infraction (date de l’infraction fournie)",
|
| 311 |
+
"basis_current": ("actuellement en vigueur -- la date de l’infraction n’a pas été "
|
| 312 |
+
"fournie; la peine maximale au moment de l’infraction n’est pas "
|
| 313 |
+
"confirmée"),
|
| 314 |
+
"basis_bound": "au moment de l’infraction, bornée par la date fournie ({what})",
|
| 315 |
+
"basis_other": "au moment de l’infraction",
|
| 316 |
+
"two_offence": ("[{offence} -- {entry}CONFIRMER] le ou aux environs du "
|
| 317 |
+
"[{date}] à ou près de [{place}]"),
|
| 318 |
+
"two_entry": "inscription no {n}; ",
|
| 319 |
+
"two_other": ("[CONFIRMER : l’autre infraction] le ou aux environs du [CONFIRMER : la "
|
| 320 |
+
"date] à ou près de [CONFIRMER : le lieu]"),
|
| 321 |
+
"two_prov": "[{cite} -- proposé d’après l’appariement organisé {pid}; CONFIRMER]",
|
| 322 |
+
"two_prov_confirm": "[CONFIRMER : la disposition pour {what}]",
|
| 323 |
+
"and": " et ",
|
| 324 |
+
"life": "à perpétuité",
|
| 325 |
+
"years": "{n} ans",
|
| 326 |
+
},
|
| 327 |
+
}
|
| 328 |
+
_BOUND_WHAT = {"en": {"conviction": "conviction", "completion": "sentence-completion"},
|
| 329 |
+
"fr": {"conviction": "déclaration de culpabilité",
|
| 330 |
+
"completion": "fin de la peine"}}
|
| 331 |
+
NO_SKELETON = {
|
| 332 |
+
"none": "No skeleton: no Canadian federal equivalent -- no s. 36 allegation on this matter.",
|
| 333 |
+
"pr": ("No skeleton: a permanent resident is not reached by s. 36(2), and this matter is "
|
| 334 |
+
"below the s. 36(1) threshold."),
|
| 335 |
+
"single-summary": ("No skeleton: a single summary-only equivalent grounds nothing -- "
|
| 336 |
+
"s. 36(2)(b) reaches two such offences not arising out of a single "
|
| 337 |
+
"occurrence."),
|
| 338 |
+
"not-settled": ("No skeleton yet: the Canadian equivalent is not settled ({why}) -- "
|
| 339 |
+
"settle it and re-run."),
|
| 340 |
+
"unknown": "No skeleton: no Canadian equivalent identified.",
|
| 341 |
+
"canada": ("No skeleton for entry #{n}: a conviction in Canada (s. 36(1)(a)/(2)(a)) -- ENF 5 "
|
| 342 |
+
"s. 13.1's sample is for a conviction outside Canada; draft from the "
|
| 343 |
+
"certificate of conviction."),
|
| 344 |
+
"canada_one": ("No skeleton: a conviction in Canada (s. 36(1)(a)/(2)(a)) -- ENF 5 s. 13.1's "
|
| 345 |
+
"sample is for a conviction outside Canada; draft from the certificate of "
|
| 346 |
+
"conviction."),
|
| 347 |
+
"occurrence": ("No skeleton yet for the summary-only entries: the single-occurrence "
|
| 348 |
+
"question (s. 36(2)(b)) is open -- both readings above."),
|
| 349 |
+
# the review of batch 1, round 4 (RP-04): the record's words reach content held for
|
| 350 |
+
# review and not in this deployment -- nothing is drafted from it
|
| 351 |
+
"held": ("No skeleton: the record's words reach CanLex content held for review and not in "
|
| 352 |
+
"this deployment -- no allegation is drafted until that content is approved or the "
|
| 353 |
+
"disposition is confirmed from the court record."),
|
| 354 |
+
# disposition roadmap T1.5 (HELD): the act branch after an acquittal
|
| 355 |
+
"act-not-settled": ("No skeleton for s. {para}: after an acquittal or a finding of not guilty, "
|
| 356 |
+
"whether the act branch may be used on the same conduct is NOT SETTLED -- "
|
| 357 |
+
"POLICY CAVEAT ({label}), both readings in step 1."),
|
| 358 |
+
# the review of batch 1, round 3 (MW-08): a routed programme that does not state its
|
| 359 |
+
# underlying termination (a CPL 160.50 sealing)
|
| 360 |
+
"act-not-settled-routed": ("No skeleton for s. {para}: if the underlying termination was an "
|
| 361 |
+
"acquittal ({question}), whether the act branch may be used on the same "
|
| 362 |
+
"conduct is NOT SETTLED -- POLICY CAVEAT ({label}), both readings in "
|
| 363 |
+
"step 1."),
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
# The words a provision's line uses for the term, per language (one rule).
|
| 367 |
+
_EN_TERM = re.compile(r"not (?:more than|exceeding) (?:\d+|[a-z]+) years(?: less a day)?"
|
| 368 |
+
r"|up to (?:\d+|[a-z]+) years|imprisonment for life|life imprisonment",
|
| 369 |
+
re.I)
|
| 370 |
+
_FR_TERM = re.compile(r"(?:maximale? |^\((?:[ivx]+|[a-z](?:\.\d)?)\) )de ((?:\d+|[a-zé]+) ans"
|
| 371 |
+
r"(?: moins un jour)?)|(à perpétuité)")
|
| 372 |
+
# one table for the English number words (audit follow-up 2026-09-21): the
|
| 373 |
+
# one maxima_stated reads, so the two parsers cannot drift apart
|
| 374 |
+
_EN_NUM = _NUM_WORDS
|
| 375 |
+
_FR_NUM = {"un": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7,
|
| 376 |
+
"huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13,
|
| 377 |
+
"quatorze": 14}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def term_words(line, lang="en"):
|
| 381 |
+
"""The statute's own words for the maximum term in one provision line
|
| 382 |
+
('not more than 10 years'; 'de dix ans'; life: 'for life' / 'à
|
| 383 |
+
perpétuité'), or None when the line states no term (summary conviction,
|
| 384 |
+
a maximum derived from another offence). The first term the line states
|
| 385 |
+
is the provision's general one (s. 271(a): 10 years; 14 only for a
|
| 386 |
+
complainant under 16 -- the item's 'when' says so)."""
|
| 387 |
+
if lang == "fr":
|
| 388 |
+
m = _FR_TERM.search((line or "").strip())
|
| 389 |
+
if not m:
|
| 390 |
+
return None
|
| 391 |
+
return "à perpétuité" if m.group(2) else "de " + m.group(1)
|
| 392 |
+
m = _EN_TERM.search(line or "")
|
| 393 |
+
if not m:
|
| 394 |
+
return None
|
| 395 |
+
t = m.group(0)
|
| 396 |
+
return "for life" if "life" in t.lower() else t
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def term_years(words, lang="en"):
|
| 400 |
+
"""The years a term_words() phrase states (LIFE for life), or None."""
|
| 401 |
+
if not words:
|
| 402 |
+
return None
|
| 403 |
+
if "life" in words or "perpétuité" in words:
|
| 404 |
+
return LIFE
|
| 405 |
+
num = _FR_NUM if lang == "fr" else _EN_NUM
|
| 406 |
+
m = re.search(r"(\d+|[a-zé]+) (?:years|ans)", words)
|
| 407 |
+
if not m:
|
| 408 |
+
return None
|
| 409 |
+
g = m.group(1)
|
| 410 |
+
return int(g) if g.isdigit() else num.get(g)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def fr_cite(fr):
|
| 414 |
+
"""The French citation after 'en vertu': 'de l’alinéa ...', 'du paragraphe ...'."""
|
| 415 |
+
first = (fr or "").split(" ", 1)[0]
|
| 416 |
+
return ("de l’" if first in ("alinéa", "article") else "du ") + fr
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def provisions(pairing):
|
| 420 |
+
return [i for i in (pairing or {}).get("report_provision") or [] if isinstance(i, dict)]
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def in_force_at(item, latest):
|
| 424 |
+
"""False when a report provision came into force ('in_force_from') after
|
| 425 |
+
the last day the offence can have been committed on this reading --
|
| 426 |
+
'latest', an ISO date, or None when no date bounds it (wave-4 verifier,
|
| 427 |
+
2026-09-15: a provision not in force at commission is never proposed)."""
|
| 428 |
+
f = item.get("in_force_from")
|
| 429 |
+
return not (f and latest and str(latest) < f)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def choose(pairing, reading, limb_candidates=()):
|
| 433 |
+
"""Which curated report provision(s) fit one reading of a pairing:
|
| 434 |
+
{"kind": ..., "proposed", "others", "items", "at", "now", "later",
|
| 435 |
+
"from", "higher"}. kind: 'proposed' (the provision whose line carries the
|
| 436 |
+
maximum at commission; a primary first), 'open' (candidate limbs with
|
| 437 |
+
different provisions -- the record's words do not say which), 'history'
|
| 438 |
+
(the maximum at commission differs from today's provision: a Tran history
|
| 439 |
+
row, or a provision not in force at commission -- 'from' is the date it
|
| 440 |
+
came into force, and today's provision is named for reference only,
|
| 441 |
+
never as the provision at commission), 'inchoate' (an attempt,
|
| 442 |
+
accessory, counselling or conspiracy reading), 'derived' (a provision
|
| 443 |
+
whose maximum is the object offence's), 'none' (no provision carries
|
| 444 |
+
this maximum), 'held' (the pairing has no report_provision in this
|
| 445 |
+
deployment). reading: the server's _narrative_reading -- 'latest' (the
|
| 446 |
+
last day the offence can have been committed on it) filters out the
|
| 447 |
+
provisions not yet in force ('later'); 'higher' is the curated history
|
| 448 |
+
row whose maximum at commission is HIGHER than the one the reading
|
| 449 |
+
applies (methodology (b)): the maximum then prints both readings."""
|
| 450 |
+
allp = provisions(pairing)
|
| 451 |
+
at = reading.get("max_years")
|
| 452 |
+
out = {"kind": "held", "proposed": None, "others": [], "items": [], "at": at,
|
| 453 |
+
"now": limb_max(pairing or {}, reading.get("limb")) if pairing else None,
|
| 454 |
+
"later": [], "from": reading.get("nif_from"), "higher": reading.get("higher")}
|
| 455 |
+
if not allp:
|
| 456 |
+
return out
|
| 457 |
+
items = [i for i in allp if i.get("role") != "two-plus-summary-only"]
|
| 458 |
+
limb, cands = reading.get("limb"), [c for c in limb_candidates or [] if c]
|
| 459 |
+
if limb:
|
| 460 |
+
pool = [i for i in items if not i.get("limbs") or limb in i["limbs"]]
|
| 461 |
+
elif cands:
|
| 462 |
+
pool = [i for i in items if not i.get("limbs") or set(i["limbs"]) & set(cands)]
|
| 463 |
+
else:
|
| 464 |
+
pool = items
|
| 465 |
+
latest = reading.get("latest")
|
| 466 |
+
later = [i for i in pool if not in_force_at(i, latest)]
|
| 467 |
+
pool = [i for i in pool if in_force_at(i, latest)]
|
| 468 |
+
out.update(items=pool, later=later)
|
| 469 |
+
if reading.get("inchoate"):
|
| 470 |
+
return dict(out, kind="inchoate")
|
| 471 |
+
if later and not pool:
|
| 472 |
+
# every provision of this reading came into force after the offence
|
| 473 |
+
ref = next((i for i in later if i.get("role") == "primary"), later[0])
|
| 474 |
+
return dict(out, kind="history", proposed=ref, later=[],
|
| 475 |
+
**{"from": out["from"] or min(i["in_force_from"] for i in later)})
|
| 476 |
+
groups = ({frozenset(set(i["limbs"]) & set(cands)) for i in pool if i.get("limbs")}
|
| 477 |
+
if cands and not limb else set())
|
| 478 |
+
if len(groups) > 1:
|
| 479 |
+
return dict(out, kind="open", items=[i for i in pool if i.get("limbs")])
|
| 480 |
+
derived = [i for i in pool if i.get("derived")]
|
| 481 |
+
if derived:
|
| 482 |
+
prop = next((i for i in derived if i.get("role") == "primary"), derived[0])
|
| 483 |
+
return dict(out, kind="derived", proposed=prop,
|
| 484 |
+
others=[i for i in derived if i is not prop])
|
| 485 |
+
if reading.get("not_in_force") or out["higher"] or (
|
| 486 |
+
at is not None and out["now"] is not None and at != out["now"]
|
| 487 |
+
and not reading.get("resolved")):
|
| 488 |
+
# the maximum at commission is not today's provision's (Tran)
|
| 489 |
+
same = [i for i in pool if term_years(term_words(i["line"])) == out["now"]]
|
| 490 |
+
ref = next((i for i in same if i.get("role") == "primary"), (same or pool or [None])[0])
|
| 491 |
+
return dict(out, kind="history", proposed=ref)
|
| 492 |
+
match = ([i for i in pool if term_years(term_words(i["line"])) == at]
|
| 493 |
+
if at is not None else [])
|
| 494 |
+
if match:
|
| 495 |
+
prop = next((i for i in match if i.get("role") == "primary"), match[0])
|
| 496 |
+
return dict(out, kind="proposed", proposed=prop,
|
| 497 |
+
others=[i for i in match if i is not prop])
|
| 498 |
+
return dict(out, kind="none")
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _years(mx, lang):
|
| 502 |
+
if mx is None:
|
| 503 |
+
return None
|
| 504 |
+
t = TEXT[lang]
|
| 505 |
+
if mx >= LIFE:
|
| 506 |
+
return t["life"]
|
| 507 |
+
n = int(mx) if float(mx).is_integer() else mx
|
| 508 |
+
return t["years"].format(n=n)
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _basis(a, lang):
|
| 512 |
+
t = TEXT[lang]
|
| 513 |
+
b = a.get("basis") or ""
|
| 514 |
+
if b == "offence-date":
|
| 515 |
+
return t["basis_date"]
|
| 516 |
+
if b == "current":
|
| 517 |
+
return t["basis_current"]
|
| 518 |
+
if b.startswith("inferred-from-"):
|
| 519 |
+
what = b[len("inferred-from-"):]
|
| 520 |
+
return t["basis_bound"].format(what=_BOUND_WHAT[lang].get(what, what))
|
| 521 |
+
return t["basis_other"]
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _cite(i, lang):
|
| 525 |
+
return i["en"] if lang == "en" else fr_cite(i["fr"])
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def _chunk(i, lang):
|
| 529 |
+
return i.get("chunk") if lang == "en" else i.get("chunk_fr")
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def provision_text(a, lang):
|
| 533 |
+
"""The provision bracket's contents for one allegation."""
|
| 534 |
+
t, c, p = TEXT[lang], a["choice"], a.get("pairing") or {}
|
| 535 |
+
pid, canadian = p.get("id", "?"), p.get("canadian_offence", "?")
|
| 536 |
+
k = c["kind"]
|
| 537 |
+
later = ""
|
| 538 |
+
if c.get("later"):
|
| 539 |
+
later = t["later"].format(alts=" / ".join(
|
| 540 |
+
t["later_alt"].format(cite=_cite(i, lang), frm=i["in_force_from"])
|
| 541 |
+
for i in c["later"]))
|
| 542 |
+
if k in ("proposed", "derived"):
|
| 543 |
+
i = c["proposed"]
|
| 544 |
+
s = t[k].format(cite=_cite(i, lang), pid=pid, chunk=_chunk(i, lang))
|
| 545 |
+
if c["others"]:
|
| 546 |
+
s += t["others"].format(alts=" / ".join(
|
| 547 |
+
t["alt"].format(when=o["when"], cite=_cite(o, lang)) for o in c["others"]))
|
| 548 |
+
return s + later
|
| 549 |
+
if k == "open":
|
| 550 |
+
return t["open"].format(alts=" | ".join(
|
| 551 |
+
t["open_alt"].format(cite=_cite(i, lang), when=i["when"]) for i in c["items"])) + later
|
| 552 |
+
if k == "history":
|
| 553 |
+
ref = c["proposed"]
|
| 554 |
+
cite = _cite(ref, lang) if ref else canadian
|
| 555 |
+
if c.get("higher"):
|
| 556 |
+
return t["history_higher"].format(label=c["higher"].get("limb_label") or canadian,
|
| 557 |
+
cite=cite)
|
| 558 |
+
if c.get("from"):
|
| 559 |
+
return t["history_nif"].format(frm=c["from"], at=_years(c["at"], lang) or "?",
|
| 560 |
+
cite=cite)
|
| 561 |
+
return t["history"].format(at=_years(c["at"], lang) or "?", basis=_basis(a, lang),
|
| 562 |
+
cite=cite)
|
| 563 |
+
if k == "inchoate":
|
| 564 |
+
ref = next((i for i in c["items"] if i.get("role") == "primary"),
|
| 565 |
+
(c["items"] or [None])[0])
|
| 566 |
+
return t["inchoate"].format(ref=t["inchoate_ref"].format(cite=_cite(ref, lang))
|
| 567 |
+
if ref else "")
|
| 568 |
+
if k == "none":
|
| 569 |
+
return t["none"].format(pid=pid, at=_years(c["at"], lang) or "?", canadian=canadian)
|
| 570 |
+
return t["held"].format(pid=pid, canadian=canadian)
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def maximum_text(a, lang):
|
| 574 |
+
"""The maximum bracket's contents for one allegation."""
|
| 575 |
+
t, c = TEXT[lang], a["choice"]
|
| 576 |
+
k, at = c["kind"], _years(c["at"], lang)
|
| 577 |
+
if k == "proposed":
|
| 578 |
+
i = c["proposed"]
|
| 579 |
+
words = term_words(i["line_fr"] if lang == "fr" else i["line"], lang)
|
| 580 |
+
return t["max_words"].format(words=words, chunk=_chunk(i, lang), basis=_basis(a, lang))
|
| 581 |
+
if k == "open":
|
| 582 |
+
return t["max_open"].format(alts=" | ".join(
|
| 583 |
+
t["max_open_alt"].format(words=term_words(i["line_fr"] if lang == "fr"
|
| 584 |
+
else i["line"], lang) or "?",
|
| 585 |
+
cite=_cite(i, lang)) for i in c["items"]))
|
| 586 |
+
if k == "derived":
|
| 587 |
+
return t["max_derived"]
|
| 588 |
+
if k == "history":
|
| 589 |
+
h = c.get("higher")
|
| 590 |
+
if h:
|
| 591 |
+
# methodology (b) applied the lesser maximum now; Tran read strictly
|
| 592 |
+
# gives the history row's: both, bracketed (principle 5)
|
| 593 |
+
return t["max_both"].format(then=_years(h.get("at_commission"), lang) or "?",
|
| 594 |
+
now=at or "?", label=h.get("limb_label") or "")
|
| 595 |
+
if c.get("from") and c["at"] == c["now"]:
|
| 596 |
+
return t["max_nif"].format(at=at or "?")
|
| 597 |
+
return t["max_history"].format(at=at or "?")
|
| 598 |
+
if k == "inchoate":
|
| 599 |
+
return t["max_inchoate"].format(at=at or "?")
|
| 600 |
+
if at is not None:
|
| 601 |
+
return t["max_reading"].format(at=at, basis=_basis(a, lang))
|
| 602 |
+
return t["max_unknown"]
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def _date_text(a, lang, act=False):
|
| 606 |
+
t = TEXT[lang]
|
| 607 |
+
given = ""
|
| 608 |
+
if a.get("conviction_date") and not act:
|
| 609 |
+
given += t["given_conv"].format(d=a["conviction_date"])
|
| 610 |
+
if a.get("offence_date"):
|
| 611 |
+
given += t["given_off"].format(d=a["offence_date"])
|
| 612 |
+
return t["act_date" if act else "date"].format(given=given)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _place_text(a, lang):
|
| 616 |
+
t = TEXT[lang]
|
| 617 |
+
return t["place"].format(given=t["given_place"].format(p=a["place"]) if a.get("place") else "")
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _allegation_line(a, lang):
|
| 621 |
+
t = TEXT[lang]
|
| 622 |
+
key = "36(2)(b)-two" if a["kind"] == "two" else a["paragraph"]
|
| 623 |
+
if lang == "fr":
|
| 624 |
+
text, para = " ".join(FR_ALLEGATION[key]), FR_PARAGRAPH[a["paragraph"]]
|
| 625 |
+
return t["allegation"].format(para=para, irpa=IRPA_FR, text=text)
|
| 626 |
+
return t["allegation"].format(para=a["paragraph"], irpa=IRPA_EN, text=a["en_text"])
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def _narrative(a, lang):
|
| 630 |
+
"""The narrative sentence(s) for one allegation, in ENF 5's form."""
|
| 631 |
+
t = TEXT[lang]
|
| 632 |
+
f = FR_FRAME if lang == "fr" else EN_FRAME
|
| 633 |
+
if a["kind"] == "two":
|
| 634 |
+
offs = []
|
| 635 |
+
for e in a.get("entries") or []:
|
| 636 |
+
offs.append(t["two_offence"].format(
|
| 637 |
+
offence=e["offence"],
|
| 638 |
+
entry=t["two_entry"].format(n=e["entry"]) if e.get("entry") else "",
|
| 639 |
+
date=_date_text(e, lang), place=_place_text(e, lang)))
|
| 640 |
+
while len(offs) < 2:
|
| 641 |
+
offs.append(t["two_other"])
|
| 642 |
+
provs = []
|
| 643 |
+
for e in a.get("entries") or []:
|
| 644 |
+
s = next((i for i in provisions(e.get("pairing"))
|
| 645 |
+
if i.get("role") == "two-plus-summary-only"), None)
|
| 646 |
+
provs.append(t["two_prov"].format(cite=_cite(s, lang),
|
| 647 |
+
pid=(e.get("pairing") or {}).get("id", "?"))
|
| 648 |
+
if s else t["two_prov_confirm"].format(what=e["offence"]))
|
| 649 |
+
while len(provs) < 2:
|
| 650 |
+
provs.append(t["two_prov_confirm"].format(what="the other offence" if lang == "en"
|
| 651 |
+
else "l’autre infraction"))
|
| 652 |
+
tpl = FR_TWO if lang == "fr" else EN_TWO
|
| 653 |
+
return [tpl.format(offences=t["and"].join(offs), provisions=t["and"].join(provs))]
|
| 654 |
+
prov, maxw = provision_text(a, lang), maximum_text(a, lang)
|
| 655 |
+
if a["kind"] == "act":
|
| 656 |
+
tpl = FR_ACT if lang == "fr" else EN_ACT
|
| 657 |
+
return [tpl.format(act=t["act"].format(offence=a["offence"]),
|
| 658 |
+
date=_date_text(a, lang, act=True), place=_place_text(a, lang),
|
| 659 |
+
prov=prov, maxw=maxw)]
|
| 660 |
+
return [f"{f['convicted']} [{t['offence'].format(offence=a['offence'])}] "
|
| 661 |
+
f"{f['on_or_about']} [{_date_text(a, lang)}] {f['at_or_near']} "
|
| 662 |
+
f"[{_place_text(a, lang)}]. {f['equivalence']} [{prov}] {f['maximum']} "
|
| 663 |
+
f"[{maxw}] {f['imposed']}"]
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def _status_line(person, status_given, lang):
|
| 667 |
+
t = TEXT[lang]
|
| 668 |
+
f = FR_FRAME if lang == "fr" else EN_FRAME
|
| 669 |
+
if person == "permanent-resident":
|
| 670 |
+
return t["status_pr"]
|
| 671 |
+
if not status_given:
|
| 672 |
+
return f"{t['status_assumed']} {f['status_fn']}"
|
| 673 |
+
return f"{f['status_fn']} {t['status_evidence']}"
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def render(allegations, person, status_given, language="en", notes=(), held=False):
|
| 677 |
+
"""The skeleton block's lines. allegations: from the chain (see the
|
| 678 |
+
server's _narrative_allegations); notes: 'No skeleton' lines for what
|
| 679 |
+
grounds no allegation; held: no report_provision in this deployment."""
|
| 680 |
+
langs = {"en": ("en",), "fr": ("fr",), "both": ("en", "fr")}.get(language, ("en",))
|
| 681 |
+
if not allegations:
|
| 682 |
+
return [HEAD] + list(notes)
|
| 683 |
+
out = [HEAD, INTRO]
|
| 684 |
+
if held:
|
| 685 |
+
out.append(HELD_NOTE)
|
| 686 |
+
if len(allegations) > 1:
|
| 687 |
+
out.append(MULTI)
|
| 688 |
+
out += list(notes)
|
| 689 |
+
n = len(allegations)
|
| 690 |
+
for lang in langs:
|
| 691 |
+
t = TEXT[lang]
|
| 692 |
+
f = FR_FRAME if lang == "fr" else EN_FRAME
|
| 693 |
+
out += ["", t["block"], t["place_date"], t["addressee"], t["title"], t["name"]]
|
| 694 |
+
for k, a in enumerate(allegations, 1):
|
| 695 |
+
if n > 1 or a.get("condition") or a.get("entry"):
|
| 696 |
+
cond = ((t["entry"].format(n=a["entry"]) if a.get("entry") else "")
|
| 697 |
+
+ (a.get("condition") or "")).strip().rstrip(",")
|
| 698 |
+
out.append(t["which"].format(k=k, n=n,
|
| 699 |
+
cond=t["cond"].format(cond=cond) if cond else ""))
|
| 700 |
+
out.append(_allegation_line(a, lang))
|
| 701 |
+
out.append(f["heading"])
|
| 702 |
+
out.append(f"{f['aka']} {t['aka']}")
|
| 703 |
+
out.append(_status_line(person, status_given, lang))
|
| 704 |
+
out += _narrative(a, lang)
|
| 705 |
+
out.append(t["signature"])
|
| 706 |
+
return out
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def result_field(allegations, language, held=False):
|
| 710 |
+
"""CANLEX_RESULT 'narrative': each allegation's paragraph, pairing and
|
| 711 |
+
the provision proposed (None where it stays [CONFIRM])."""
|
| 712 |
+
rows = []
|
| 713 |
+
for a in allegations:
|
| 714 |
+
c = a.get("choice") or {}
|
| 715 |
+
prop = c.get("proposed") if c.get("kind") in ("proposed", "derived") else None
|
| 716 |
+
rows.append({"paragraph": a["paragraph"], "kind": a["kind"],
|
| 717 |
+
"pairing": (a.get("pairing") or {}).get("id")
|
| 718 |
+
if a["kind"] != "two" else [(e.get("pairing") or {}).get("id")
|
| 719 |
+
for e in a.get("entries") or []],
|
| 720 |
+
"entry": a.get("entry"), "condition": a.get("condition"),
|
| 721 |
+
"provision": prop["en"] if prop else None,
|
| 722 |
+
"provision_status": c.get("kind") if a["kind"] != "two" else "two-offence",
|
| 723 |
+
# wave-4 verifier: today's provision came into force after
|
| 724 |
+
# the offence; the maximum at commission is higher than
|
| 725 |
+
# the one applied (both are bracketed)
|
| 726 |
+
"not_in_force_from": c.get("from") if c.get("kind") == "history" else None,
|
| 727 |
+
"maximum_readings": ({"at_commission": c["higher"].get("at_commission"),
|
| 728 |
+
"methodology_b": c.get("at")}
|
| 729 |
+
if c.get("kind") == "history" and c.get("higher")
|
| 730 |
+
else None)})
|
| 731 |
+
return {"language": language, "allegations": rows, "report_provision_held": bool(held)}
|
canlex/pdi.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
{"code": "pdi-pr",
|
| 71 |
+
"short": "PDI Permanent Residence",
|
| 72 |
+
"name": "PDI — Permanent residence (loss of status and residency "
|
| 73 |
+
"obligation, non-economic classes, legal references)",
|
| 74 |
+
"index": _OBM + "permanent-residence.html",
|
| 75 |
+
"scope": "/permanent-residence",
|
| 76 |
+
# the PR-card subtree is the pdi-prcard set; the economic classes
|
| 77 |
+
# (Express Entry, PNP, business) are not border work
|
| 78 |
+
"exclude": ("/permanent-residence/card", "/economic-classes")},
|
| 79 |
+
# ENF 3 s. 10.9 (2022) sends officers to "IRCC's Program Delivery
|
| 80 |
+
# Instructions (PDI) on Criminal Rehabilitation". No such page is
|
| 81 |
+
# published (checked 2026-09-12): the OBM index and the
|
| 82 |
+
# standard-requirements / temporary-residents trees link none, canada.ca
|
| 83 |
+
# search finds only the applicant pages (Guide 5312, IMM 1444, 'Deemed
|
| 84 |
+
# rehabilitation'), and .../operational-bulletins-manuals/
|
| 85 |
+
# criminal-rehabilitation.html and .../standard-requirements/
|
| 86 |
+
# criminal-rehabilitation.html return 404. The one public PDI text on
|
| 87 |
+
# rehabilitation is this fees-tree page (R309(a) / A36(3)(c), dated
|
| 88 |
+
# 2013-06-17 -- it still names the National Parole Board and ENF 2/OP 18).
|
| 89 |
+
# A single page, so no crawl scope.
|
| 90 |
+
{"code": "pdi-rehab",
|
| 91 |
+
"short": "PDI Rehabilitation",
|
| 92 |
+
"name": "PDI — Determination of rehabilitation (R309(a) and A36(3)(c))",
|
| 93 |
+
"index": _OBM + ("fees/immigration/general/"
|
| 94 |
+
"determination-rehabilitation-r309-a36-3.html"),
|
| 95 |
+
"scope": ""},
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
# canada.ca records a page's modification date in <meta name="dcterms.modified"
|
| 99 |
+
# content="YYYY-MM-DD">; the stored OBM pages carry no <time> element, so the
|
| 100 |
+
# old <time>-only pattern left every PDI chunk undated. <time> stays as the
|
| 101 |
+
# fallback for pages that do have one.
|
| 102 |
+
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
| 103 |
+
_META = re.compile(r"<meta\b[^>]*>", re.I)
|
| 104 |
+
_META_MODIFIED = re.compile(r"""name=["']dcterms\.modified["']""", re.I)
|
| 105 |
+
_META_CONTENT = re.compile(r"""content=["'](\d{4}-\d{2}-\d{2})""", re.I)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def page_modified(html, today=None):
|
| 109 |
+
"""The page's dcterms.modified date (else its <time> date), ignoring any
|
| 110 |
+
date later than `today`: the OBM translation page carries 2027-01-10,
|
| 111 |
+
which is a CMS artefact, not a date the text was current to. '' when no
|
| 112 |
+
usable date is found. Pure, for tests."""
|
| 113 |
+
from datetime import date
|
| 114 |
+
today = today or date.today().isoformat()
|
| 115 |
+
candidates = []
|
| 116 |
+
for tag in _META.findall(html):
|
| 117 |
+
if _META_MODIFIED.search(tag):
|
| 118 |
+
m = _META_CONTENT.search(tag)
|
| 119 |
+
if m:
|
| 120 |
+
candidates.append(m.group(1))
|
| 121 |
+
candidates.extend(_MODIFIED.findall(html))
|
| 122 |
+
for found in candidates:
|
| 123 |
+
if found <= today:
|
| 124 |
+
return found
|
| 125 |
+
return ""
|
| 126 |
+
_TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
|
| 127 |
+
_LINK = re.compile(r'href="([^"#?]*?\.html)"')
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _strip_tags(html):
|
| 131 |
+
html = re.sub(r"</t[dh]>", " | ", html)
|
| 132 |
+
html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
|
| 133 |
+
html = re.sub(r"<[^>]+>", " ", html)
|
| 134 |
+
return [l for l in (_norm(x).strip("| ").strip()
|
| 135 |
+
for x in html.split("\n")) if l]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _main(html):
|
| 139 |
+
m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
|
| 140 |
+
return m.group(1) if m else html
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _page_chunks(src, page_url, html, page_no):
|
| 144 |
+
content = _main(html)
|
| 145 |
+
title_m = _TITLE.search(content)
|
| 146 |
+
title = (_norm(re.sub(r"<[^>]+>", " ", title_m.group(1)))
|
| 147 |
+
if title_m else src["short"])
|
| 148 |
+
modified = page_modified(html)
|
| 149 |
+
# Split on h2 headings; each becomes a section-like unit.
|
| 150 |
+
parts = re.split(r"<h2[^>]*>", content[title_m.end():] if title_m else content)
|
| 151 |
+
chunks = []
|
| 152 |
+
for i, part in enumerate(parts):
|
| 153 |
+
if i == 0:
|
| 154 |
+
heading, body_html = title, part
|
| 155 |
+
else:
|
| 156 |
+
head_end = part.find("</h2>")
|
| 157 |
+
heading = _norm(re.sub(r"<[^>]+>", " ", part[:head_end]))
|
| 158 |
+
body_html = part[head_end:]
|
| 159 |
+
body = "\n".join(_strip_tags(body_html))
|
| 160 |
+
if len(body) < 120:
|
| 161 |
+
continue
|
| 162 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 163 |
+
total = len(pieces)
|
| 164 |
+
for k, piece in enumerate(pieces, start=1):
|
| 165 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 166 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 167 |
+
chunks.append({
|
| 168 |
+
"id": f"{src['code']}-{page_no}-{i}{suffix}",
|
| 169 |
+
"doc_type": "memorandum",
|
| 170 |
+
"act_code": src["code"].upper(),
|
| 171 |
+
"act_short": src["short"],
|
| 172 |
+
"act_name": src["name"],
|
| 173 |
+
"section": f"{title[:60]} — {heading[:60]}" if i else title[:80],
|
| 174 |
+
"marginal_note": heading,
|
| 175 |
+
"part": src["name"],
|
| 176 |
+
"division": "",
|
| 177 |
+
"heading": "",
|
| 178 |
+
"text": piece,
|
| 179 |
+
"history": "",
|
| 180 |
+
"last_amended": modified,
|
| 181 |
+
"current_to": modified,
|
| 182 |
+
"citation": f"{src['short']}: {heading}{note}",
|
| 183 |
+
"source_url": page_url,
|
| 184 |
+
})
|
| 185 |
+
return chunks
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def preserve_failed(chunks, failures, stored):
|
| 189 |
+
"""Re-attach the last-good chunks of sub-pages this run could not fetch.
|
| 190 |
+
|
| 191 |
+
Ingestion is a full rebuild, so a sub-page that 404s or drops its
|
| 192 |
+
connection silently vanishes from the corpus; serving the copy we already
|
| 193 |
+
had beats losing instructions that are still in force. Keyed on source_url,
|
| 194 |
+
the only per-page identity a PDI chunk carries that survives pages coming
|
| 195 |
+
and going -- the chunk id embeds the page's position in the index listing,
|
| 196 |
+
which shifts whenever one page drops out. Restricted to pages that actually
|
| 197 |
+
errored, so a sub-page IRCC retired on purpose is not resurrected.
|
| 198 |
+
Returns (chunks, preserved page URLs).
|
| 199 |
+
"""
|
| 200 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["source_url"],
|
| 201 |
+
only={url for url, _why in failures})
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def cache_name(src, url):
|
| 205 |
+
"""The raw-cache file name for one crawled page: the set's code plus the
|
| 206 |
+
page's path below the OBM root, so sibling pages that share a file name
|
| 207 |
+
-- the five .../non-economic-classes/<pathway>/admissibility.html pages
|
| 208 |
+
of the PR tree -- never share one cache file. Until 2026-09-21 the name
|
| 209 |
+
was the URL's last path component, and a plain (no --force) run served
|
| 210 |
+
one pathway's cached page under all five URLs. The index page keeps
|
| 211 |
+
'{code}-index.html': refresh.check_pdi reads it by that name. Pure."""
|
| 212 |
+
if url == src["index"]:
|
| 213 |
+
return f"{src['code']}-index.html"
|
| 214 |
+
rel = url.split(_OBM, 1)[1] if _OBM in url else re.sub(r"^https?://", "", url)
|
| 215 |
+
rel = re.sub(r"\.html$", "", rel)
|
| 216 |
+
return f"{src['code']}-{re.sub(r'[^A-Za-z0-9._-]+', '-', rel)}.html"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _crawl(src, failures, force=False):
|
| 220 |
+
"""Breadth-first crawl of one PDI category: index -> in-scope links ->
|
| 221 |
+
their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
|
| 222 |
+
Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
|
| 223 |
+
failures land in `failures` so preserve_failed keeps their stored
|
| 224 |
+
copies."""
|
| 225 |
+
index_html = fetch_cached(
|
| 226 |
+
src["index"], RAW / cache_name(src, src["index"]),
|
| 227 |
+
powershell=True, pause=0.8, force=force).decode("utf-8", "replace")
|
| 228 |
+
pages = [(src["index"], index_html)]
|
| 229 |
+
if not src["scope"]:
|
| 230 |
+
return pages
|
| 231 |
+
seen = {src["index"]}
|
| 232 |
+
frontier = [(src["index"], index_html)]
|
| 233 |
+
dropped = 0
|
| 234 |
+
for _depth in range(_MAX_DEPTH):
|
| 235 |
+
next_frontier = []
|
| 236 |
+
for _url, html in frontier:
|
| 237 |
+
for href in _LINK.findall(_main(html)):
|
| 238 |
+
if src["scope"] not in href:
|
| 239 |
+
continue
|
| 240 |
+
if any(x in href for x in src.get("exclude", ())):
|
| 241 |
+
continue
|
| 242 |
+
url = href if href.startswith("http") else \
|
| 243 |
+
"https://www.canada.ca" + href
|
| 244 |
+
if url in seen:
|
| 245 |
+
continue
|
| 246 |
+
seen.add(url)
|
| 247 |
+
if len(pages) >= _MAX_PAGES:
|
| 248 |
+
dropped += 1
|
| 249 |
+
continue
|
| 250 |
+
name = url.rsplit("/", 1)[1].replace(".html", "")
|
| 251 |
+
try:
|
| 252 |
+
page = fetch_cached(
|
| 253 |
+
url, RAW / cache_name(src, url),
|
| 254 |
+
powershell=True, pause=0.8,
|
| 255 |
+
force=force).decode("utf-8", "replace")
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 258 |
+
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 259 |
+
continue
|
| 260 |
+
pages.append((url, page))
|
| 261 |
+
next_frontier.append((url, page))
|
| 262 |
+
frontier = next_frontier
|
| 263 |
+
if dropped:
|
| 264 |
+
print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
|
| 265 |
+
f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
|
| 266 |
+
return pages
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def build(allow_shrink=False, force=False):
|
| 270 |
+
"""Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
|
| 271 |
+
all_chunks, failures = [], []
|
| 272 |
+
for src in SETS:
|
| 273 |
+
try:
|
| 274 |
+
pages = _crawl(src, failures, force=force)
|
| 275 |
+
except Exception as exc:
|
| 276 |
+
failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
|
| 277 |
+
print(f" !! {src['short']}: index fetch failed "
|
| 278 |
+
f"({type(exc).__name__}: {exc})")
|
| 279 |
+
continue
|
| 280 |
+
n = 0
|
| 281 |
+
for page_no, (url, html) in enumerate(pages):
|
| 282 |
+
chunks = _page_chunks(src, url, html, page_no)
|
| 283 |
+
if not chunks:
|
| 284 |
+
# Downloaded fine, parsed to nothing: the page template moved
|
| 285 |
+
# under us. That is exactly how the D-memo corpus was lost, so
|
| 286 |
+
# it counts as a failure and the stored page is kept.
|
| 287 |
+
print(f" !! {url.rsplit('/', 1)[-1]}: no content parsed; "
|
| 288 |
+
f"keeping stored copy")
|
| 289 |
+
failures.append((url, "no content parsed"))
|
| 290 |
+
continue
|
| 291 |
+
all_chunks.extend(chunks)
|
| 292 |
+
n += len(chunks)
|
| 293 |
+
print(f" {src['short']}: {len(pages)} pages, {n} chunks")
|
| 294 |
+
|
| 295 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 296 |
+
stored_chunks(OUT_FILE))
|
| 297 |
+
if preserved:
|
| 298 |
+
names = ", ".join(u.rsplit("/", 1)[-1] for u in preserved)
|
| 299 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 300 |
+
f"page(s): {names}")
|
| 301 |
+
# After preservation, not before: a preserved page brings back the ids it
|
| 302 |
+
# was written with, and this run may have handed those ids to other pages
|
| 303 |
+
# (an id embeds the page's position in the index listing).
|
| 304 |
+
uniquify_ids(all_chunks)
|
| 305 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 306 |
+
allow_shrink=allow_shrink):
|
| 307 |
+
return False
|
| 308 |
+
print(f"pdi: {len(all_chunks)} chunks -> {OUT_FILE.name}")
|
| 309 |
+
return all_chunks
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def main():
|
| 313 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 314 |
+
# empty corpus succeeded, a refused write did not.
|
| 315 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv,
|
| 316 |
+
force="--force" in sys.argv) is not False
|
| 317 |
+
sys.exit(0 if ok else 1)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
main()
|
canlex/ratelimit.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-client limits for the HTTP transport (audit follow-up 2026-09-21,
|
| 2 |
+
missing 14; verification fixes 2026-09-22, SEC-1 and SEC-2).
|
| 3 |
+
|
| 4 |
+
The public Space is unauthenticated. The request-body cap, the session idle
|
| 5 |
+
timeout and the off-loop tools bound what one request can cost, but a steady
|
| 6 |
+
stream of 500-character searches (2.5-7 s each, one at a time) still holds the
|
| 7 |
+
single search slot, and a stream of `initialize` requests opens sessions that
|
| 8 |
+
each live until the idle timeout. Two small ASGI middlewares, wrapped around
|
| 9 |
+
_Utf8Body by server.http_app, give each client a token bucket:
|
| 10 |
+
|
| 11 |
+
- SessionOpenLimit (ON by default over HTTP): a POST with no mcp-session-id
|
| 12 |
+
header is the only request that can open an MCP session; each client may
|
| 13 |
+
send SESSION_OPEN_LIMIT (60) of them per SESSION_OPEN_WINDOW (10 minutes),
|
| 14 |
+
in bursts of up to 60, and then one each 10 s. CANLEX_SESSION_OPEN_LIMIT
|
| 15 |
+
sets the number (0 turns the cap off). An officer's client opens one
|
| 16 |
+
session per connection, so it never meets the cap; one client can no
|
| 17 |
+
longer fill the session table (SEC-1: mcp 1.30.0's global cap of 10,000
|
| 18 |
+
let a single client lock every other one out with 503 for 30 minutes;
|
| 19 |
+
server.py now builds the manager with no global cap).
|
| 20 |
+
- RateLimit (OFF unless CANLEX_RATE_LIMIT_PER_MIN is set to a positive
|
| 21 |
+
integer): `per_minute` requests a minute on average and bursts of up to
|
| 22 |
+
`burst` (CANLEX_RATE_LIMIT_BURST; default a full minute's allowance, so an
|
| 23 |
+
officer's own client -- a few requests per tool call -- never meets it).
|
| 24 |
+
|
| 25 |
+
A request over either limit gets 429 with a plain-text body and Retry-After;
|
| 26 |
+
it never reaches the MCP app. GET / (the notice page and health probes) and
|
| 27 |
+
anything that is not an HTTP request (lifespan) pass through uncounted. The
|
| 28 |
+
local stdio server uses neither.
|
| 29 |
+
|
| 30 |
+
The client (client_key) is the address the last proxy saw: the rightmost
|
| 31 |
+
X-Forwarded-For entry when the header is present -- repeated header lines
|
| 32 |
+
read as one comma-separated list, in order -- else the socket peer, cut to
|
| 33 |
+
MAX_KEY_CHARS characters. This ASSUMES AN APPENDING PROXY in front: Hugging
|
| 34 |
+
Face's ingress appends the address it saw (and merges any lines the client
|
| 35 |
+
sent), so the rightmost entry is not the client's to choose. Served with no
|
| 36 |
+
such proxy (plain Docker, or a proxy that passes the header through
|
| 37 |
+
untouched), the header is whatever the client writes: it can dodge its own
|
| 38 |
+
limit by rotating it, or spend another address's bucket by naming it. At
|
| 39 |
+
most MAX_CLIENTS buckets are kept per limit; idle ones are dropped first.
|
| 40 |
+
"""
|
| 41 |
+
import math
|
| 42 |
+
import os
|
| 43 |
+
import sys
|
| 44 |
+
import threading
|
| 45 |
+
import time
|
| 46 |
+
|
| 47 |
+
ENV_PER_MIN = "CANLEX_RATE_LIMIT_PER_MIN"
|
| 48 |
+
ENV_BURST = "CANLEX_RATE_LIMIT_BURST"
|
| 49 |
+
ENV_SESSIONS = "CANLEX_SESSION_OPEN_LIMIT"
|
| 50 |
+
MAX_CLIENTS = 10000
|
| 51 |
+
MAX_KEY_CHARS = 64 # an IPv6 address with a zone is under 64 characters
|
| 52 |
+
SESSION_OPEN_LIMIT = 60 # new sessions per client per SESSION_OPEN_WINDOW
|
| 53 |
+
SESSION_OPEN_WINDOW = 600.0 # seconds
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TokenBuckets:
|
| 57 |
+
"""One token bucket per client key: `rate` tokens a second, at most
|
| 58 |
+
`burst` held. take(key) spends one token and returns 0.0, or returns the
|
| 59 |
+
seconds until one is available (nothing spent)."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, per_minute, burst=None, clock=time.monotonic,
|
| 62 |
+
max_clients=MAX_CLIENTS):
|
| 63 |
+
self.rate = per_minute / 60.0
|
| 64 |
+
self.burst = float(burst if burst else per_minute)
|
| 65 |
+
self.clock = clock
|
| 66 |
+
self.max_clients = max_clients
|
| 67 |
+
self._buckets = {} # key -> (tokens, last refill time)
|
| 68 |
+
self._lock = threading.Lock()
|
| 69 |
+
|
| 70 |
+
def __len__(self):
|
| 71 |
+
return len(self._buckets)
|
| 72 |
+
|
| 73 |
+
def take(self, key):
|
| 74 |
+
with self._lock:
|
| 75 |
+
now = self.clock()
|
| 76 |
+
tokens, last = self._buckets.get(key, (self.burst, now))
|
| 77 |
+
tokens = min(self.burst, tokens + (now - last) * self.rate)
|
| 78 |
+
if tokens >= 1.0:
|
| 79 |
+
self._buckets[key] = (tokens - 1.0, now)
|
| 80 |
+
self._prune(now)
|
| 81 |
+
return 0.0
|
| 82 |
+
self._buckets[key] = (tokens, now)
|
| 83 |
+
return (1.0 - tokens) / self.rate
|
| 84 |
+
|
| 85 |
+
def _prune(self, now):
|
| 86 |
+
"""Keep at most max_clients buckets: drop the full ones (idle long
|
| 87 |
+
enough to refill), then the least recently seen."""
|
| 88 |
+
if len(self._buckets) <= self.max_clients:
|
| 89 |
+
return
|
| 90 |
+
full = [k for k, (t, last) in self._buckets.items()
|
| 91 |
+
if t + (now - last) * self.rate >= self.burst]
|
| 92 |
+
for k in full:
|
| 93 |
+
del self._buckets[k]
|
| 94 |
+
if len(self._buckets) > self.max_clients:
|
| 95 |
+
by_age = sorted(self._buckets, key=lambda k: self._buckets[k][1])
|
| 96 |
+
for k in by_age[:len(self._buckets) - self.max_clients]:
|
| 97 |
+
del self._buckets[k]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def client_key(scope):
|
| 101 |
+
"""The address the last proxy saw: the rightmost X-Forwarded-For entry
|
| 102 |
+
(every header line joined in order, so a proxy that adds its own line is
|
| 103 |
+
read, not the client's first one), else the socket peer's host, else
|
| 104 |
+
'?'; at most MAX_KEY_CHARS characters. Trusts the header from any peer:
|
| 105 |
+
it assumes an appending proxy in front (see the module docstring)."""
|
| 106 |
+
lines = [value.decode("latin-1") for name, value in scope.get("headers") or ()
|
| 107 |
+
if name.lower() == b"x-forwarded-for"]
|
| 108 |
+
if lines:
|
| 109 |
+
last = ",".join(lines).split(",")[-1].strip()
|
| 110 |
+
if last:
|
| 111 |
+
return last[:MAX_KEY_CHARS]
|
| 112 |
+
client = scope.get("client")
|
| 113 |
+
return str(client[0])[:MAX_KEY_CHARS] if client else "?"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _exempt(scope):
|
| 117 |
+
return scope.get("type") != "http" or (scope.get("method") == "GET"
|
| 118 |
+
and scope.get("path") == "/")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _opens_session(scope):
|
| 122 |
+
"""A POST with no mcp-session-id header: the only request the session
|
| 123 |
+
manager answers by opening a session (a request naming an unknown id gets
|
| 124 |
+
404 and opens nothing)."""
|
| 125 |
+
return (scope.get("type") == "http" and scope.get("method") == "POST"
|
| 126 |
+
and not any(name.lower() == b"mcp-session-id"
|
| 127 |
+
for name, _ in scope.get("headers") or ()))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def _too_many(send, wait, text):
|
| 131 |
+
"""429 with `text` and the whole seconds to wait, as a plain-text body
|
| 132 |
+
and Retry-After."""
|
| 133 |
+
retry = max(1, math.ceil(wait))
|
| 134 |
+
body = f"{text} Retry after {retry} s.\n".encode("utf-8")
|
| 135 |
+
await send({"type": "http.response.start", "status": 429,
|
| 136 |
+
"headers": [(b"content-type", b"text/plain; charset=utf-8"),
|
| 137 |
+
(b"content-length", str(len(body)).encode("ascii")),
|
| 138 |
+
(b"retry-after", str(retry).encode("ascii"))]})
|
| 139 |
+
await send({"type": "http.response.body", "body": body})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class RateLimit:
|
| 143 |
+
"""ASGI middleware: each client's requests through a TokenBuckets."""
|
| 144 |
+
|
| 145 |
+
def __init__(self, app, per_minute, burst=None, clock=time.monotonic,
|
| 146 |
+
max_clients=MAX_CLIENTS):
|
| 147 |
+
self.app = app
|
| 148 |
+
self.per_minute = per_minute
|
| 149 |
+
self.buckets = TokenBuckets(per_minute, burst, clock, max_clients)
|
| 150 |
+
|
| 151 |
+
async def __call__(self, scope, receive, send):
|
| 152 |
+
if _exempt(scope):
|
| 153 |
+
await self.app(scope, receive, send)
|
| 154 |
+
return
|
| 155 |
+
wait = self.buckets.take(client_key(scope))
|
| 156 |
+
if not wait:
|
| 157 |
+
await self.app(scope, receive, send)
|
| 158 |
+
return
|
| 159 |
+
await _too_many(send, wait, f"Too many requests: this CanLex endpoint allows "
|
| 160 |
+
f"{self.per_minute} requests a minute per client.")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class SessionOpenLimit:
|
| 164 |
+
"""ASGI middleware: each client may send `limit` session-opening
|
| 165 |
+
requests (_opens_session) per `window` seconds, in bursts of up to
|
| 166 |
+
`limit`; every other request passes through uncounted."""
|
| 167 |
+
|
| 168 |
+
def __init__(self, app, limit=SESSION_OPEN_LIMIT, window=SESSION_OPEN_WINDOW,
|
| 169 |
+
clock=time.monotonic, max_clients=MAX_CLIENTS):
|
| 170 |
+
self.app = app
|
| 171 |
+
self.limit, self.window = limit, window
|
| 172 |
+
self.buckets = TokenBuckets(limit * 60.0 / window, limit, clock, max_clients)
|
| 173 |
+
|
| 174 |
+
async def __call__(self, scope, receive, send):
|
| 175 |
+
if not _opens_session(scope):
|
| 176 |
+
await self.app(scope, receive, send)
|
| 177 |
+
return
|
| 178 |
+
wait = self.buckets.take(client_key(scope))
|
| 179 |
+
if not wait:
|
| 180 |
+
await self.app(scope, receive, send)
|
| 181 |
+
return
|
| 182 |
+
await _too_many(send, wait, f"Too many new sessions: this CanLex endpoint lets one "
|
| 183 |
+
f"client open {self.limit} MCP sessions every "
|
| 184 |
+
f"{self.window / 60:g} minutes.")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _positive_int(environ, name):
|
| 188 |
+
raw = (environ.get(name) or "").strip()
|
| 189 |
+
if not raw:
|
| 190 |
+
return None
|
| 191 |
+
try:
|
| 192 |
+
value = int(raw)
|
| 193 |
+
except ValueError:
|
| 194 |
+
value = 0
|
| 195 |
+
if value <= 0:
|
| 196 |
+
print(f"CanLex MCP: {name}={raw!r} is not a positive integer; ignored",
|
| 197 |
+
file=sys.stderr)
|
| 198 |
+
return None
|
| 199 |
+
return value
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def from_env(app, environ=None):
|
| 203 |
+
"""`app` wrapped in RateLimit when CANLEX_RATE_LIMIT_PER_MIN is a positive
|
| 204 |
+
integer (one stderr line says so), else `app` itself."""
|
| 205 |
+
environ = os.environ if environ is None else environ
|
| 206 |
+
per_minute = _positive_int(environ, ENV_PER_MIN)
|
| 207 |
+
if per_minute is None:
|
| 208 |
+
return app
|
| 209 |
+
burst = _positive_int(environ, ENV_BURST) or per_minute
|
| 210 |
+
print(f"CanLex MCP: rate limit {per_minute} requests a minute per client "
|
| 211 |
+
f"(bursts of {burst}); GET / exempt.", file=sys.stderr)
|
| 212 |
+
return RateLimit(app, per_minute, burst)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def sessions_from_env(app, environ=None):
|
| 216 |
+
"""`app` wrapped in SessionOpenLimit -- on by default: SESSION_OPEN_LIMIT
|
| 217 |
+
new sessions per client per SESSION_OPEN_WINDOW; CANLEX_SESSION_OPEN_LIMIT
|
| 218 |
+
sets the number, and 0 turns the cap off (`app` itself). A value that is
|
| 219 |
+
not a whole number keeps the default. One stderr line says which."""
|
| 220 |
+
environ = os.environ if environ is None else environ
|
| 221 |
+
raw = (environ.get(ENV_SESSIONS) or "").strip()
|
| 222 |
+
limit = SESSION_OPEN_LIMIT
|
| 223 |
+
if raw:
|
| 224 |
+
try:
|
| 225 |
+
limit = int(raw)
|
| 226 |
+
except ValueError:
|
| 227 |
+
limit = -1
|
| 228 |
+
if limit == 0:
|
| 229 |
+
print(f"CanLex MCP: no per-client cap on new sessions ({ENV_SESSIONS}=0).",
|
| 230 |
+
file=sys.stderr)
|
| 231 |
+
return app
|
| 232 |
+
if limit < 0:
|
| 233 |
+
print(f"CanLex MCP: {ENV_SESSIONS}={raw!r} is not a whole number; the default "
|
| 234 |
+
f"{SESSION_OPEN_LIMIT} applies", file=sys.stderr)
|
| 235 |
+
limit = SESSION_OPEN_LIMIT
|
| 236 |
+
print(f"CanLex MCP: at most {limit} new sessions per client every "
|
| 237 |
+
f"{SESSION_OPEN_WINDOW / 60:g} minutes.", file=sys.stderr)
|
| 238 |
+
return SessionOpenLimit(app, limit)
|
canlex/readnext.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""'Read next': the official guidance and statute a curated entry names (roadmap T3.6).
|
| 2 |
+
|
| 3 |
+
A curated disposition entry, methodology item, equivalency pairing or transborder
|
| 4 |
+
row may carry `see_also` (HELD FOR REVIEW when added; optional -- the approved
|
| 5 |
+
baseline predates it): an ordered list of items, each a STABLE key --
|
| 6 |
+
'<act_code>|s. <section>' for a statute section, '<act_code>|<ENF section>' for a
|
| 7 |
+
numbered ENF section, '<act_code>|<marginal note>' for an IRB paper, PDI page or
|
| 8 |
+
IRCC guide section, '<act_code>|ENF 1 App. A|<case name>' for an ENF 1 App. A
|
| 9 |
+
note -- with the chunk it quotes ('chunk') and, optionally, a verbatim passage
|
| 10 |
+
of that chunk ('quote', at most 150 characters; tests/test_read_next.py pins
|
| 11 |
+
each one). This is the shape canlex/index.py's loader reads for co-surfacing
|
| 12 |
+
(curated_see_also, _see_also_spec, _see_also_match); the rule that resolves a
|
| 13 |
+
key is that module's, imported here, so the tools and the search index read a
|
| 14 |
+
key alike.
|
| 15 |
+
|
| 16 |
+
The tools print, for the entry the record matched, one line per item: the
|
| 17 |
+
corpus's own label for the chunk (act, section, marginal note), its chunk id,
|
| 18 |
+
the source's status (an unofficial ATIP copy of a withdrawn chapter is flagged;
|
| 19 |
+
guidance shows its date) and the quote. No prose of CanLex's own is printed:
|
| 20 |
+
the 'why' is the source's own words. Official sources come first; an ATIP copy
|
| 21 |
+
follows them. A key the loaded corpus cannot resolve is printed as such, never
|
| 22 |
+
dropped silently.
|
| 23 |
+
"""
|
| 24 |
+
import functools
|
| 25 |
+
import json
|
| 26 |
+
import re
|
| 27 |
+
|
| 28 |
+
from .config import PROCESSED_DIR
|
| 29 |
+
from .index import ATIP_STATUS, _see_also_match, _see_also_spec
|
| 30 |
+
|
| 31 |
+
HEADING = ("READ NEXT -- the official guidance and statute on this point, from the corpus "
|
| 32 |
+
"(chunk id in brackets):")
|
| 33 |
+
COMPACT_PREFIX = "Read next"
|
| 34 |
+
ATIP_FLAG = "UNOFFICIAL ATIP COPY of a withdrawn chapter -- historical guidance"
|
| 35 |
+
ATIP_SHORT = "unofficial ATIP copy"
|
| 36 |
+
UNRESOLVED = "not in this deployment's corpus"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def items(obj):
|
| 40 |
+
"""The see_also items of a curated object ([] when it has none)."""
|
| 41 |
+
raw = obj.get("see_also") if isinstance(obj, dict) else None
|
| 42 |
+
return [i for i in raw or [] if isinstance(i, (dict, str))]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _file_for_act(act_code):
|
| 46 |
+
"""The data/processed file holding an act_code's chunks."""
|
| 47 |
+
code = act_code or ""
|
| 48 |
+
if code.startswith("ENF-"):
|
| 49 |
+
return "enf.json"
|
| 50 |
+
if code.startswith("IRB-LS-"):
|
| 51 |
+
return "irb_papers.json"
|
| 52 |
+
if code.startswith("IRB-"):
|
| 53 |
+
return "irb_guidelines.json"
|
| 54 |
+
if code.startswith("PDI-"):
|
| 55 |
+
return "pdi.json"
|
| 56 |
+
if code.startswith(("IMM-", "IRCC-")):
|
| 57 |
+
return "policy.json"
|
| 58 |
+
return f"{code}.json"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@functools.lru_cache(maxsize=48)
|
| 62 |
+
def _file_chunks(name):
|
| 63 |
+
"""{act_code: [English chunk, ...]} of one processed file ({} when absent)."""
|
| 64 |
+
try:
|
| 65 |
+
data = json.loads((PROCESSED_DIR / name).read_text(encoding="utf-8"))
|
| 66 |
+
except (OSError, ValueError):
|
| 67 |
+
return {}
|
| 68 |
+
if isinstance(data, dict):
|
| 69 |
+
data = data.get("chunks") or []
|
| 70 |
+
out = {}
|
| 71 |
+
for c in data:
|
| 72 |
+
if isinstance(c, dict) and c.get("lang", "en") != "fr":
|
| 73 |
+
out.setdefault(c.get("act_code"), []).append(c)
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
_INDEX_MAP = [] # [(the loaded index's chunk list, {act_code: [chunk, ...]})]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _pool(act_code, index_chunks=None):
|
| 81 |
+
"""The English chunks of one act_code: from the loaded index when there is
|
| 82 |
+
one (it holds the whole corpus), else from data/processed."""
|
| 83 |
+
if index_chunks is not None:
|
| 84 |
+
# read once into a local and publish whole (audit follow-up 2026-09-21):
|
| 85 |
+
# a second screening thread never reads a map another thread replaced
|
| 86 |
+
entry = _INDEX_MAP[0] if _INDEX_MAP else None
|
| 87 |
+
if entry is None or entry[0] is not index_chunks:
|
| 88 |
+
by_act = {}
|
| 89 |
+
for c in index_chunks:
|
| 90 |
+
if c.get("lang", "en") != "fr":
|
| 91 |
+
by_act.setdefault(c.get("act_code"), []).append(c)
|
| 92 |
+
entry = (index_chunks, by_act)
|
| 93 |
+
_INDEX_MAP[:] = [entry]
|
| 94 |
+
got = entry[1].get(act_code)
|
| 95 |
+
if got:
|
| 96 |
+
return got
|
| 97 |
+
return _file_chunks(_file_for_act(act_code)).get(act_code, [])
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def resolve(item, index_chunks=None):
|
| 101 |
+
"""{key, id, chunk, quote}: the chunk an item names -- its pinned chunk when
|
| 102 |
+
that still fits the key, else the key's first English piece (the rule
|
| 103 |
+
canlex/index.py co-surfaces by); chunk None when nothing resolves. The
|
| 104 |
+
quote is kept only with the chunk it was taken from."""
|
| 105 |
+
spec, pin = _see_also_spec(item, {})
|
| 106 |
+
key = item.get("key") if isinstance(item, dict) else item
|
| 107 |
+
quote = item.get("quote") if isinstance(item, dict) else None
|
| 108 |
+
chunk = None
|
| 109 |
+
if spec:
|
| 110 |
+
pool = _pool(spec.get("act_code"), index_chunks)
|
| 111 |
+
if pin:
|
| 112 |
+
chunk = next((c for c in pool if c.get("id") == pin), None)
|
| 113 |
+
if chunk is not None and not _see_also_match(chunk, spec):
|
| 114 |
+
chunk = None
|
| 115 |
+
if chunk is None:
|
| 116 |
+
chunk = next((c for c in pool if _see_also_match(c, spec)), None)
|
| 117 |
+
return {"key": key, "id": chunk.get("id") if chunk else None, "chunk": chunk,
|
| 118 |
+
"quote": quote if chunk is not None and chunk.get("id") == pin else None}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def is_atip(chunk):
|
| 122 |
+
return bool(chunk and chunk.get("source_status") == ATIP_STATUS)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def is_guidance(chunk):
|
| 126 |
+
return bool(chunk and chunk.get("doc_type") == "memorandum")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def resolved(item_list, index_chunks=None):
|
| 130 |
+
"""The items resolved, one per chunk (the first naming it), official
|
| 131 |
+
sources first and unofficial ATIP copies after them, each group in curated
|
| 132 |
+
order; an unresolved item keeps its place in the official group."""
|
| 133 |
+
out, seen = [], set()
|
| 134 |
+
for it in item_list or ():
|
| 135 |
+
r = resolve(it, index_chunks)
|
| 136 |
+
if r["id"] is not None:
|
| 137 |
+
if r["id"] in seen:
|
| 138 |
+
continue
|
| 139 |
+
seen.add(r["id"])
|
| 140 |
+
out.append(r)
|
| 141 |
+
return [r for r in out if not is_atip(r["chunk"])] + [r for r in out if is_atip(r["chunk"])]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def label(chunk):
|
| 145 |
+
"""The corpus's own name for a chunk: 'Criminal Code s. 320.14 (Operation
|
| 146 |
+
while impaired)', 'ENF 3 s. 10.9 (Foreign pardons)', 'IRB Legal Services,
|
| 147 |
+
ROA ch. 8 § 18 (Effect of a Pardon)', 'PDI Standard Requirements (Where the
|
| 148 |
+
ruling applies)'."""
|
| 149 |
+
short = chunk.get("act_short") or ""
|
| 150 |
+
sec = chunk.get("section") or ""
|
| 151 |
+
note = chunk.get("marginal_note") or ""
|
| 152 |
+
if re.match(r"\d", sec):
|
| 153 |
+
base = f"{short} s. {sec}"
|
| 154 |
+
elif short and sec.startswith(short):
|
| 155 |
+
base = sec
|
| 156 |
+
elif short.startswith("IRB Legal Services") and sec:
|
| 157 |
+
base = f"IRB Legal Services, {sec}"
|
| 158 |
+
else:
|
| 159 |
+
base = short or sec
|
| 160 |
+
if note and note.lower() not in base.lower():
|
| 161 |
+
base += f" ({note})"
|
| 162 |
+
return base
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def flags(chunk):
|
| 166 |
+
"""The source's status: the ATIP flag, an archived paper, a guidance date."""
|
| 167 |
+
out = []
|
| 168 |
+
if is_atip(chunk):
|
| 169 |
+
out.append(ATIP_FLAG)
|
| 170 |
+
if "archived" in (chunk.get("act_name") or "").lower():
|
| 171 |
+
out.append("archived")
|
| 172 |
+
if is_guidance(chunk) and chunk.get("current_to"):
|
| 173 |
+
out.append(f"as of {chunk['current_to']}")
|
| 174 |
+
return out
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def lines(item_list, index_chunks=None, heading=HEADING):
|
| 178 |
+
"""The READ NEXT block: the heading and one line per item ([] when the
|
| 179 |
+
entry names none)."""
|
| 180 |
+
res = resolved(item_list, index_chunks)
|
| 181 |
+
if not res:
|
| 182 |
+
return []
|
| 183 |
+
out = [heading]
|
| 184 |
+
for r in res:
|
| 185 |
+
if r["chunk"] is None:
|
| 186 |
+
out.append(f"- {r['key']}: {UNRESOLVED}")
|
| 187 |
+
continue
|
| 188 |
+
fl = flags(r["chunk"])
|
| 189 |
+
text = f"- {label(r['chunk'])} [{r['id']}]" + (f" -- {'; '.join(fl)}" if fl else "")
|
| 190 |
+
if r["quote"]:
|
| 191 |
+
text += f': "{r["quote"]}"'
|
| 192 |
+
out.append(text)
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def compact_line(item_list, index_chunks=None, prefix=COMPACT_PREFIX):
|
| 197 |
+
"""One line for a memo: 'Read next: <label> [<id>]; ...' (ATIP copies
|
| 198 |
+
marked), or None when the entry names nothing."""
|
| 199 |
+
res = resolved(item_list, index_chunks)
|
| 200 |
+
if not res:
|
| 201 |
+
return None
|
| 202 |
+
parts = []
|
| 203 |
+
for r in res:
|
| 204 |
+
if r["chunk"] is None:
|
| 205 |
+
parts.append(f"{r['key']} ({UNRESOLVED})")
|
| 206 |
+
else:
|
| 207 |
+
parts.append(f"{label(r['chunk'])} [{r['id']}]"
|
| 208 |
+
+ (f" ({ATIP_SHORT})" if is_atip(r["chunk"]) else ""))
|
| 209 |
+
return f"{prefix}: " + "; ".join(parts)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def chunk_ids(item_list, index_chunks=None):
|
| 213 |
+
"""The resolved chunk ids, in printed order (CANLEX_RESULT 'read_next')."""
|
| 214 |
+
return [r["id"] for r in resolved(item_list, index_chunks) if r["id"]]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def with_fallback(primary, fallback, index_chunks=None):
|
| 218 |
+
"""A pairing's items, followed by the equivalency framework's when none of
|
| 219 |
+
the pairing's own resolves to guidance (the draft's rule: most pairings
|
| 220 |
+
name only their Canadian statute sections)."""
|
| 221 |
+
primary = list(primary or [])
|
| 222 |
+
if not primary or any(is_guidance(r["chunk"]) for r in resolved(primary, index_chunks)):
|
| 223 |
+
return primary
|
| 224 |
+
return primary + list(fallback or [])
|
canlex/refresh.py
ADDED
|
@@ -0,0 +1,1653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, saying whether the stored consolidation of each affected Act
|
| 28 |
+
already postdates it (pending_consolidation).
|
| 29 |
+
|
| 30 |
+
Two further watches, both added 2026-09-13:
|
| 31 |
+
|
| 32 |
+
- Curated verify phrases (check_curated_sections, roadmap T4.3). Every statute
|
| 33 |
+
phrase the curated tables pin is checked against the corpus now. BROKEN
|
| 34 |
+
means the phrase or section is gone. AMENDED means the section's
|
| 35 |
+
last_amended is later than in the corpus of the approved commit; when the
|
| 36 |
+
maximum a quoted subsection states changed and no penalty_history row
|
| 37 |
+
covers it, a DRAFT row goes to data/curated/drafts/. The curated JSON
|
| 38 |
+
itself is never edited here (see canlex.curated_checks).
|
| 39 |
+
- Guidance currency (check_guidance, roadmap T4.8):
|
| 40 |
+
- the official ENF chapters, by an HTTP HEAD of each PDF against the
|
| 41 |
+
last-seen marker (ENF 2 and 14, withdrawn, are skipped);
|
| 42 |
+
- each PDI category index, diffed against the index cached at ingest,
|
| 43 |
+
plus the Tran page;
|
| 44 |
+
- the IRB legal-concepts index pages, for their ARCHIVED marker.
|
| 45 |
+
IMM 1444 and Guide 5312 are read from the employer-policy check above,
|
| 46 |
+
which already stamps their page dateModified.
|
| 47 |
+
|
| 48 |
+
This is the staleness detection the corpus previously lacked: legislation was
|
| 49 |
+
refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
|
| 50 |
+
|
| 51 |
+
py -m canlex.refresh --check # human-readable; exit 1 on drift
|
| 52 |
+
py -m canlex.refresh --check --json # machine-readable for CI
|
| 53 |
+
"""
|
| 54 |
+
import datetime
|
| 55 |
+
import json
|
| 56 |
+
import os
|
| 57 |
+
import re
|
| 58 |
+
import sys
|
| 59 |
+
import tempfile
|
| 60 |
+
import time
|
| 61 |
+
import urllib.error
|
| 62 |
+
import urllib.request
|
| 63 |
+
from pathlib import Path
|
| 64 |
+
|
| 65 |
+
from bs4 import BeautifulSoup
|
| 66 |
+
|
| 67 |
+
from . import delegation, directive, dmemo
|
| 68 |
+
from ._common import BROWSER_UA, MONTHS, atomic_write_text
|
| 69 |
+
from .config import SOURCES, PROCESSED_DIR
|
| 70 |
+
|
| 71 |
+
_UA = "CanLex-refresh/0.1"
|
| 72 |
+
_HEAD_BYTES = 16384 # the root element's attributes sit at the very top
|
| 73 |
+
_CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
|
| 74 |
+
|
| 75 |
+
# Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
|
| 76 |
+
# command, description). These carry no single upstream consolidation date, so
|
| 77 |
+
# the check reports their STORED currency (newest item) and how to refresh them
|
| 78 |
+
# rather than diffing against upstream. (Delegation and the NJC directives are
|
| 79 |
+
# checked against upstream separately, by check_delegation and check_directives,
|
| 80 |
+
# because their sources DO expose a comparable signal -- the CBSA instrument
|
| 81 |
+
# index and IL3 version string, and the NJC directive index dates.)
|
| 82 |
+
_NON_XML = [
|
| 83 |
+
("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
|
| 84 |
+
("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
|
| 85 |
+
("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
|
| 86 |
+
("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
|
| 87 |
+
("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
|
| 88 |
+
"Customs Tariff Schedule ch. 98-99"),
|
| 89 |
+
("enf", "enf.json", "py -m canlex.enf", "ENF operational manuals"),
|
| 90 |
+
("amps", "amps.json", "py -m canlex.amps", "AMPS Master Penalty Document"),
|
| 91 |
+
("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
|
| 92 |
+
("commentary", "commentary.json", "py -m canlex.commentary",
|
| 93 |
+
"Curated commentary chunks"),
|
| 94 |
+
("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
|
| 95 |
+
("pdi", "pdi.json", "py -m canlex.pdi",
|
| 96 |
+
"IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
|
| 97 |
+
("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
|
| 98 |
+
"Mémorandums D (français)"),
|
| 99 |
+
("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
|
| 100 |
+
"Directives du CNM (français)"),
|
| 101 |
+
("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
|
| 102 |
+
"IRB Chairperson's Guidelines"),
|
| 103 |
+
("irb-papers", "irb_papers.json", "py -m canlex.irb_papers",
|
| 104 |
+
"IRB Legal Services papers (ROA ch. 7-9, SPO ch. 2; archived by the IRB)"),
|
| 105 |
+
("policy", "policy.json", "py -m canlex.conduct",
|
| 106 |
+
"Policy pages (employer instruments EN/FR, CBSA detention policy, STCA, "
|
| 107 |
+
"IRCC rehabilitation pages)"),
|
| 108 |
+
("agreement-fr", "agreements-fr.json", "py -m canlex.agreement --fr",
|
| 109 |
+
"Convention collective FB (français)"),
|
| 110 |
+
("enf-fr", "enf-fr.json", "py -m canlex.enf --fr",
|
| 111 |
+
"Guides ENF (édition française)"),
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _fetch_powershell(url, timeout=180):
|
| 116 |
+
"""The .NET HTTP stack, which canada.ca accepts while blocking Python's
|
| 117 |
+
client at the TLS layer (the same helper _common.fetch_cached uses, so
|
| 118 |
+
the two quote a URL the same way)."""
|
| 119 |
+
from ._common import powershell_download
|
| 120 |
+
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
|
| 121 |
+
dest = tmp.name
|
| 122 |
+
try:
|
| 123 |
+
powershell_download(url, dest, ua=BROWSER_UA, timeout=timeout)
|
| 124 |
+
with open(dest, "rb") as fh:
|
| 125 |
+
return fh.read().decode("utf-8", "replace")
|
| 126 |
+
finally:
|
| 127 |
+
try:
|
| 128 |
+
os.unlink(dest)
|
| 129 |
+
except OSError:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def check_policy():
|
| 134 |
+
"""Upstream dateModified of each employer-policy page vs the stored
|
| 135 |
+
currency -- both canada.ca-family pages stamp one, and the CBSA Code is
|
| 136 |
+
re-issued in place at the same URL (2024 edition, 2012 predecessor)."""
|
| 137 |
+
from .conduct import DOCS, _MODIFIED
|
| 138 |
+
# Keyed by (code, lang): the ten French twins share their English page's
|
| 139 |
+
# act_code, and a French page re-issued on its own date (the CBSA Code and
|
| 140 |
+
# the TBS instruments are re-issued per language) must be compared with
|
| 141 |
+
# ITS stored date, not the English one's -- until 2026-09-21 the FR row
|
| 142 |
+
# read STALE that --force could never clear, or the reverse silently.
|
| 143 |
+
stored_by_code = {}
|
| 144 |
+
path = PROCESSED_DIR / "policy.json"
|
| 145 |
+
if path.exists():
|
| 146 |
+
for c in json.loads(path.read_text(encoding="utf-8")):
|
| 147 |
+
stored_by_code.setdefault((c["act_code"], c.get("lang", "en")),
|
| 148 |
+
c.get("current_to", ""))
|
| 149 |
+
rows = []
|
| 150 |
+
for doc in DOCS:
|
| 151 |
+
stored = stored_by_code.get((doc["code"], doc.get("lang", "en")), "")
|
| 152 |
+
# Fetch the way the ingester does: a document with several pages
|
| 153 |
+
# ('urls') is stamped on its first page, and the canada.ca pages that
|
| 154 |
+
# refuse Python's HTTP client need the PowerShell path -- without
|
| 155 |
+
# this, the detention standards, the STCA texts and the rehabilitation
|
| 156 |
+
# guide could never clear, and three permanent ERROR rows teach the
|
| 157 |
+
# reader to ignore the watch.
|
| 158 |
+
url = doc.get("url") or doc["urls"][0]
|
| 159 |
+
try:
|
| 160 |
+
if doc.get("powershell"):
|
| 161 |
+
html = _fetch_powershell(url)
|
| 162 |
+
else:
|
| 163 |
+
req = urllib.request.Request(
|
| 164 |
+
url, headers={"User-Agent": BROWSER_UA})
|
| 165 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 166 |
+
html = resp.read().decode("utf-8", "replace")
|
| 167 |
+
m = _MODIFIED.search(html)
|
| 168 |
+
remote = ((m.group(1) or m.group(2)) if m else "")
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
rows.append({"short": doc["short"], "lang": doc.get("lang", "en"),
|
| 171 |
+
"stored": stored, "remote": "", "status": "error",
|
| 172 |
+
"error": f"{type(exc).__name__}: {exc}"})
|
| 173 |
+
continue
|
| 174 |
+
if not stored:
|
| 175 |
+
status = "missing-local"
|
| 176 |
+
elif not remote:
|
| 177 |
+
status = "no-date"
|
| 178 |
+
elif remote > stored:
|
| 179 |
+
status = "stale"
|
| 180 |
+
else:
|
| 181 |
+
status = "ok"
|
| 182 |
+
# lang: ten documents have a French twin under the same short label,
|
| 183 |
+
# so the summary tags the twin '(fr)' instead of printing each twice.
|
| 184 |
+
rows.append({"short": doc["short"], "lang": doc.get("lang", "en"),
|
| 185 |
+
"stored": stored, "remote": remote, "status": status, "error": ""})
|
| 186 |
+
time.sleep(0.5)
|
| 187 |
+
return rows
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---- guidance currency: ENF, PDI, IRB papers, forms (roadmap T4.8) -----------
|
| 191 |
+
# The non-XML guidance sources expose different upstream signals, so each is
|
| 192 |
+
# read the cheapest way it offers:
|
| 193 |
+
# - official ENF chapters (canada.ca PDFs): the HTTP Last-Modified of a HEAD
|
| 194 |
+
# request, against the last-seen marker -- the cached PDF's fetch time, or a
|
| 195 |
+
# date acknowledged with --ack-guidance, whichever is later. A 404 on a
|
| 196 |
+
# chapter the corpus holds means IRCC retired it. ENF 2 and 14 are skipped:
|
| 197 |
+
# withdrawn, and ingested from third-party ATIP copies (enf.UNOFFICIAL).
|
| 198 |
+
# - PDI: each category's index page only, not all ~300 pages. The in-scope
|
| 199 |
+
# links it lists are diffed against the index cached at the last ingest, and
|
| 200 |
+
# its dcterms.modified is compared; plus the Tran serious-criminality page.
|
| 201 |
+
# - IRB Legal Services papers: the ARCHIVED marker and the date modified on
|
| 202 |
+
# the legal-concepts index pages (RoaAmr.aspx, SpoPar.aspx).
|
| 203 |
+
# - IMM 1444 and Guide 5312: canlex.conduct ingests both, so check_policy
|
| 204 |
+
# already reads their page dateModified; check_guidance reports those rows
|
| 205 |
+
# and does not fetch them again.
|
| 206 |
+
_GUIDANCE_MARKER = PROCESSED_DIR.parent / "raw" / "guidance" / "last-seen.json"
|
| 207 |
+
_GUIDANCE_FORMS = ("IMM-1444", "IMM-5312")
|
| 208 |
+
_PDI_KEY_PAGES = [("pdi-standard", "PDI Tran serious-criminality page",
|
| 209 |
+
"standard-requirements/tran.html")]
|
| 210 |
+
_GUIDANCE_FLAGS = {"enf": ("check", "retired"),
|
| 211 |
+
"pdi": ("changed", "updated", "retired"),
|
| 212 |
+
"irb": ("updated", "unarchived")}
|
| 213 |
+
|
| 214 |
+
# One PowerShell process for a batch of URLs (canada.ca refuses Python's TLS
|
| 215 |
+
# client; one process instead of one per URL keeps ~50 requests to a minute
|
| 216 |
+
# or two). Passed with -EncodedCommand, so no quoting survives the Windows
|
| 217 |
+
# command line to mangle it.
|
| 218 |
+
_PS_PROBE = r"""
|
| 219 |
+
$ProgressPreference = 'SilentlyContinue'
|
| 220 |
+
$urls = @(Get-Content -LiteralPath '{urls}' -Encoding UTF8 | Where-Object {{ $_ }})
|
| 221 |
+
$rows = @()
|
| 222 |
+
$i = 0
|
| 223 |
+
foreach ($u in $urls) {{
|
| 224 |
+
$i++
|
| 225 |
+
# [string]: a Get-Content line carries PSPath/ReadCount note properties
|
| 226 |
+
# that ConvertTo-Json would serialise as an object
|
| 227 |
+
$row = [ordered]@{{url = [string]$u; status = 0; last_modified = ''; path = ''; error = ''}}
|
| 228 |
+
try {{
|
| 229 |
+
if ('{method}' -eq 'HEAD') {{
|
| 230 |
+
$r = Invoke-WebRequest -Uri $u -Method Head -UseBasicParsing -TimeoutSec 60 -UserAgent '{ua}'
|
| 231 |
+
}} else {{
|
| 232 |
+
$p = Join-Path '{out}' ("$i.html")
|
| 233 |
+
$r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 90 -UserAgent '{ua}' -OutFile $p -PassThru
|
| 234 |
+
$row.path = [string]$p
|
| 235 |
+
}}
|
| 236 |
+
$row.status = [int]$r.StatusCode
|
| 237 |
+
$row.last_modified = [string]$r.Headers['Last-Modified']
|
| 238 |
+
}} catch {{
|
| 239 |
+
if ($_.Exception.Response) {{ $row.status = [int]$_.Exception.Response.StatusCode }}
|
| 240 |
+
$row.error = [string]$_.Exception.Message
|
| 241 |
+
}}
|
| 242 |
+
$rows += [pscustomobject]$row
|
| 243 |
+
Start-Sleep -Milliseconds {pause}
|
| 244 |
+
}}
|
| 245 |
+
ConvertTo-Json -InputObject $rows -Depth 3 | Set-Content -LiteralPath '{result}' -Encoding UTF8
|
| 246 |
+
"""
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _ps_probe_script(urls_file, method, out_dir, result_file, pause_ms=300):
|
| 250 |
+
return _PS_PROBE.format(urls=urls_file, method=method, out=out_dir,
|
| 251 |
+
ua=BROWSER_UA, pause=pause_ms, result=result_file)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _powershell_probe(urls, method="HEAD", pause_ms=300, timeout=1800):
|
| 255 |
+
"""{url: {status, last_modified, html, error}} for every URL, fetched by a
|
| 256 |
+
single PowerShell process. HEAD returns headers only; GET also returns
|
| 257 |
+
the body. status is 0 when no HTTP status came back."""
|
| 258 |
+
import base64
|
| 259 |
+
import subprocess
|
| 260 |
+
urls = list(dict.fromkeys(urls))
|
| 261 |
+
if not urls:
|
| 262 |
+
return {}
|
| 263 |
+
with tempfile.TemporaryDirectory() as td:
|
| 264 |
+
td = Path(td)
|
| 265 |
+
(td / "urls.txt").write_text("\n".join(urls), encoding="utf-8")
|
| 266 |
+
script = _ps_probe_script(td / "urls.txt", method, td, td / "result.json",
|
| 267 |
+
pause_ms)
|
| 268 |
+
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
| 269 |
+
try:
|
| 270 |
+
subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
|
| 271 |
+
"-EncodedCommand", encoded],
|
| 272 |
+
check=True, capture_output=True, timeout=timeout)
|
| 273 |
+
rows = json.loads((td / "result.json").read_text(encoding="utf-8-sig"))
|
| 274 |
+
except Exception as exc:
|
| 275 |
+
err = f"{type(exc).__name__}: {exc}"
|
| 276 |
+
return {u: {"status": 0, "last_modified": "", "html": "", "error": err}
|
| 277 |
+
for u in urls}
|
| 278 |
+
out = {}
|
| 279 |
+
for r in rows if isinstance(rows, list) else [rows]:
|
| 280 |
+
path = Path(r["path"]) if r.get("path") else None
|
| 281 |
+
html = (path.read_bytes().decode("utf-8", "replace")
|
| 282 |
+
if path and path.exists() else "")
|
| 283 |
+
out[r["url"]] = {"status": int(r.get("status") or 0),
|
| 284 |
+
"last_modified": r.get("last_modified") or "",
|
| 285 |
+
"html": html, "error": r.get("error") or ""}
|
| 286 |
+
return out
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _guidance_marker(path=None):
|
| 290 |
+
try:
|
| 291 |
+
return json.loads(Path(path or _GUIDANCE_MARKER).read_text(encoding="utf-8"))
|
| 292 |
+
except (OSError, ValueError):
|
| 293 |
+
return {}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _write_guidance_marker(marker, path=None):
|
| 297 |
+
# Atomic (2026-09-22): a run killed mid-write left truncated JSON, which
|
| 298 |
+
# _guidance_marker reads as {} -- every acknowledged date silently gone
|
| 299 |
+
# and the ENF baseline re-seeded from the cached PDFs' fetch times.
|
| 300 |
+
atomic_write_text(Path(path or _GUIDANCE_MARKER),
|
| 301 |
+
json.dumps(marker, indent=1, sort_keys=True) + "\n")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _http_ts(value):
|
| 305 |
+
"""POSIX time of an HTTP date ('Mon, 17 Feb 2025 16:38:16 GMT'), else None."""
|
| 306 |
+
import email.utils
|
| 307 |
+
try:
|
| 308 |
+
return email.utils.parsedate_to_datetime(value).timestamp()
|
| 309 |
+
except (TypeError, ValueError, IndexError):
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _iso_ts(ts):
|
| 314 |
+
return (datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
|
| 315 |
+
.strftime("%Y-%m-%d %H:%M UTC") if ts is not None else "")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def enf_targets(processed_dir=None):
|
| 319 |
+
"""The official ENF chapters the corpus holds, one row per URL:
|
| 320 |
+
{chapter, lang, url, cache}. Only canada.ca editions; ENF 2 and 14 are
|
| 321 |
+
skipped (enf.UNOFFICIAL: withdrawn, known, no upstream to watch)."""
|
| 322 |
+
from . import enf
|
| 323 |
+
processed_dir = Path(processed_dir) if processed_dir else PROCESSED_DIR
|
| 324 |
+
out = []
|
| 325 |
+
for fname, lang in (("enf.json", "en"), ("enf-fr.json", "fr")):
|
| 326 |
+
path = processed_dir / fname
|
| 327 |
+
if not path.exists():
|
| 328 |
+
continue
|
| 329 |
+
seen = set()
|
| 330 |
+
for c in json.loads(path.read_text(encoding="utf-8")):
|
| 331 |
+
m = re.fullmatch(r"ENF-(\d+)", c.get("act_code", ""))
|
| 332 |
+
url = c.get("source_url", "")
|
| 333 |
+
if not m or url in seen or "canada.ca" not in url:
|
| 334 |
+
continue
|
| 335 |
+
n = int(m.group(1))
|
| 336 |
+
if n in enf.UNOFFICIAL:
|
| 337 |
+
continue
|
| 338 |
+
seen.add(url)
|
| 339 |
+
out.append({"chapter": n, "lang": lang, "url": url,
|
| 340 |
+
"cache": enf.RAW / (f"enf{n:02d}-fra.pdf" if lang == "fr"
|
| 341 |
+
else f"enf{n:02d}.pdf")})
|
| 342 |
+
return sorted(out, key=lambda t: (t["lang"], t["chapter"]))
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def enf_status(probe, seen_ts):
|
| 346 |
+
"""One ENF chapter's watch status from its HEAD probe. Pure. The values:
|
| 347 |
+
'retired' (a 404 on a chapter the corpus holds), 'error', 'no-date',
|
| 348 |
+
'baseline' (nothing seen yet), 'check' (Last-Modified later than last
|
| 349 |
+
seen), or 'ok'."""
|
| 350 |
+
if probe.get("status") == 404:
|
| 351 |
+
return "retired"
|
| 352 |
+
if probe.get("status") != 200:
|
| 353 |
+
return "error"
|
| 354 |
+
lm = _http_ts(probe.get("last_modified"))
|
| 355 |
+
if lm is None:
|
| 356 |
+
return "no-date"
|
| 357 |
+
if seen_ts is None:
|
| 358 |
+
return "baseline"
|
| 359 |
+
return "check" if lm > seen_ts + 1 else "ok"
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def check_enf(probe=None, targets=None, marker_path=None):
|
| 363 |
+
"""HEAD every official ENF chapter. The last-seen time is the later of
|
| 364 |
+
the cached PDF's mtime (re-ingesting with --force moves it) and a date
|
| 365 |
+
recorded by --ack-guidance. When neither exists, the first run records
|
| 366 |
+
the upstream date as the baseline instead of alarming."""
|
| 367 |
+
probe = probe or _powershell_probe
|
| 368 |
+
targets = enf_targets() if targets is None else targets
|
| 369 |
+
got = probe([t["url"] for t in targets], "HEAD") if targets else {}
|
| 370 |
+
marker = _guidance_marker(marker_path)
|
| 371 |
+
seeded, rows = False, []
|
| 372 |
+
for t in targets:
|
| 373 |
+
p = got.get(t["url"]) or {"status": 0, "last_modified": "", "error": "not probed"}
|
| 374 |
+
cache = Path(t["cache"]) if t.get("cache") else None
|
| 375 |
+
seen = [x for x in ((cache.stat().st_mtime if cache and cache.exists() else None),
|
| 376 |
+
_http_ts(marker.get(t["url"]))) if x is not None]
|
| 377 |
+
seen_ts = max(seen) if seen else None
|
| 378 |
+
status = enf_status(p, seen_ts)
|
| 379 |
+
if status == "baseline":
|
| 380 |
+
marker[t["url"]] = p["last_modified"]
|
| 381 |
+
seeded, status, seen_ts = True, "ok", _http_ts(p["last_modified"])
|
| 382 |
+
rows.append({"chapter": t["chapter"], "lang": t["lang"], "url": t["url"],
|
| 383 |
+
"status": status, "remote": p.get("last_modified", ""),
|
| 384 |
+
"seen": _iso_ts(seen_ts), "error": (p.get("error") or "")[:160]})
|
| 385 |
+
if seeded:
|
| 386 |
+
_write_guidance_marker(marker, marker_path)
|
| 387 |
+
return rows
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def pdi_index_links(src, html):
|
| 391 |
+
"""The in-scope links a PDI category index lists (canlex.pdi's crawl rule
|
| 392 |
+
at depth 1). An empty set for a single-page set (no scope)."""
|
| 393 |
+
from . import pdi
|
| 394 |
+
if not src.get("scope"):
|
| 395 |
+
return set()
|
| 396 |
+
out = set()
|
| 397 |
+
for href in pdi._LINK.findall(pdi._main(html or "")):
|
| 398 |
+
if src["scope"] not in href or any(x in href for x in src.get("exclude", ())):
|
| 399 |
+
continue
|
| 400 |
+
out.add(href if href.startswith("http") else "https://www.canada.ca" + href)
|
| 401 |
+
return out
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def pdi_index_diff(src, live_html, seen_html="", stored_urls=(), stored_date="",
|
| 405 |
+
today=None):
|
| 406 |
+
"""One PDI category index, live against last seen. Pure. The last-seen
|
| 407 |
+
index is the one cached at ingest; without it, the stored page URLs and
|
| 408 |
+
the index chunk's date. Returns {status, new, removed, remote, seen}, where
|
| 409 |
+
status is 'changed' (pages listed or dropped), 'updated' (dcterms.modified
|
| 410 |
+
later), 'no-date' or 'ok'. 'removed' needs the cached index: stored URLs
|
| 411 |
+
include depth-2 pages that the index never lists."""
|
| 412 |
+
from . import pdi
|
| 413 |
+
live = pdi_index_links(src, live_html)
|
| 414 |
+
if seen_html:
|
| 415 |
+
base, seen = pdi_index_links(src, seen_html), pdi.page_modified(seen_html, today)
|
| 416 |
+
else:
|
| 417 |
+
base, seen = set(stored_urls), stored_date or ""
|
| 418 |
+
new = sorted(live - base)
|
| 419 |
+
removed = sorted(base - live) if seen_html else []
|
| 420 |
+
remote = pdi.page_modified(live_html or "", today)
|
| 421 |
+
if new or removed:
|
| 422 |
+
status = "changed"
|
| 423 |
+
elif not remote:
|
| 424 |
+
status = "no-date"
|
| 425 |
+
elif seen and remote > seen:
|
| 426 |
+
status = "updated"
|
| 427 |
+
else:
|
| 428 |
+
status = "ok"
|
| 429 |
+
return {"status": status, "new": new, "removed": removed, "remote": remote,
|
| 430 |
+
"seen": seen}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _pdi_urls():
|
| 434 |
+
from . import pdi
|
| 435 |
+
return ([s["index"] for s in pdi.SETS]
|
| 436 |
+
+ [pdi._OBM + rel for _, _, rel in _PDI_KEY_PAGES])
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def check_pdi(got, today=None):
|
| 440 |
+
"""Rows for each PDI category index and each key page, from probed GETs."""
|
| 441 |
+
from . import pdi
|
| 442 |
+
stored_urls, stored_dates = {}, {}
|
| 443 |
+
if pdi.OUT_FILE.exists():
|
| 444 |
+
for c in json.loads(pdi.OUT_FILE.read_text(encoding="utf-8")):
|
| 445 |
+
stored_urls.setdefault(c.get("act_code", "").lower(), set()).add(c["source_url"])
|
| 446 |
+
stored_dates.setdefault(c["source_url"], c.get("current_to", ""))
|
| 447 |
+
rows = []
|
| 448 |
+
|
| 449 |
+
def failed(name, code, url, p):
|
| 450 |
+
rows.append({"name": name, "code": code, "url": url,
|
| 451 |
+
"status": "retired" if p.get("status") == 404 else "error",
|
| 452 |
+
"new": [], "removed": [], "remote": "", "seen": "",
|
| 453 |
+
"error": (p.get("error") or "no page")[:160]})
|
| 454 |
+
|
| 455 |
+
for s in pdi.SETS:
|
| 456 |
+
p = got.get(s["index"]) or {}
|
| 457 |
+
if p.get("status") != 200 or not p.get("html"):
|
| 458 |
+
failed(s["short"], s["code"], s["index"], p)
|
| 459 |
+
continue
|
| 460 |
+
cache = pdi.RAW / f"{s['code']}-index.html"
|
| 461 |
+
seen_html = cache.read_bytes().decode("utf-8", "replace") if cache.exists() else ""
|
| 462 |
+
d = pdi_index_diff(s, p["html"], seen_html, stored_urls.get(s["code"], ()),
|
| 463 |
+
stored_dates.get(s["index"], ""), today)
|
| 464 |
+
rows.append(dict(d, name=s["short"], code=s["code"], url=s["index"], error=""))
|
| 465 |
+
for code, name, rel in _PDI_KEY_PAGES:
|
| 466 |
+
url = pdi._OBM + rel
|
| 467 |
+
p = got.get(url) or {}
|
| 468 |
+
if p.get("status") != 200 or not p.get("html"):
|
| 469 |
+
failed(name, code, url, p)
|
| 470 |
+
continue
|
| 471 |
+
stored = stored_dates.get(url, "")
|
| 472 |
+
remote = pdi.page_modified(p["html"], today)
|
| 473 |
+
status = ("no-date" if not remote else
|
| 474 |
+
"updated" if stored and remote > stored else "ok")
|
| 475 |
+
rows.append({"name": name, "code": code, "url": url, "status": status,
|
| 476 |
+
"new": [], "removed": [], "remote": remote, "seen": stored,
|
| 477 |
+
"error": ""})
|
| 478 |
+
return rows
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
_IRB_TITLE_ARCHIVED = re.compile(
|
| 482 |
+
r'dcterms\.title"[^>]*content="\s*ARCHIVED\b|<h1[^>]*>\s*(?:<[^>]+>\s*)*ARCHIVED\b',
|
| 483 |
+
re.I)
|
| 484 |
+
_IRB_BANNER = re.compile(r'id="archived"', re.I)
|
| 485 |
+
_PAGE_MODIFIED = re.compile(
|
| 486 |
+
r'dcterms\.modified"[^>]*content="(\d{4}-\d{2}-\d{2})"|'
|
| 487 |
+
r'dateModified"[^>]*>(\d{4}-\d{2}-\d{2})<')
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def irb_archive_status(html, seen):
|
| 491 |
+
"""(status, modified, marker) for an IRB legal-concepts index page. Pure.
|
| 492 |
+
status: 'unarchived' (neither the 'ARCHIVED' title nor the archived-
|
| 493 |
+
content banner is left, so the chunks' 'archived' label may now be
|
| 494 |
+
wrong), 'updated' (date modified later than `seen`), 'no-date' or 'ok'."""
|
| 495 |
+
marker = {"title": bool(_IRB_TITLE_ARCHIVED.search(html or "")),
|
| 496 |
+
"banner": bool(_IRB_BANNER.search(html or ""))}
|
| 497 |
+
m = _PAGE_MODIFIED.search(html or "")
|
| 498 |
+
modified = (m.group(1) or m.group(2)) if m else ""
|
| 499 |
+
if not (marker["title"] or marker["banner"]):
|
| 500 |
+
status = "unarchived"
|
| 501 |
+
elif not modified:
|
| 502 |
+
status = "no-date"
|
| 503 |
+
elif seen and modified > seen:
|
| 504 |
+
status = "updated"
|
| 505 |
+
else:
|
| 506 |
+
status = "ok"
|
| 507 |
+
return status, modified, marker
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def _irb_urls():
|
| 511 |
+
from . import irb_papers
|
| 512 |
+
return sorted({p["index"] for p in irb_papers.PAPERS})
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def check_irb(got, marker_path=None):
|
| 516 |
+
"""Rows for the IRB legal-concepts index pages, from probed GETs. Last
|
| 517 |
+
seen: a date recorded by --ack-guidance, else irb_papers.ARCHIVED (the
|
| 518 |
+
'Date modified' both pages carried when the papers were ingested)."""
|
| 519 |
+
from . import irb_papers
|
| 520 |
+
marker = _guidance_marker(marker_path)
|
| 521 |
+
rows = []
|
| 522 |
+
for url in _irb_urls():
|
| 523 |
+
papers = [p["label"] for p in irb_papers.PAPERS if p["index"] == url]
|
| 524 |
+
seen = marker.get("irb:" + url) or irb_papers.ARCHIVED
|
| 525 |
+
p = got.get(url) or {}
|
| 526 |
+
page = url.rsplit("/", 1)[-1]
|
| 527 |
+
if p.get("status") != 200 or not p.get("html"):
|
| 528 |
+
rows.append({"page": page, "url": url, "papers": papers,
|
| 529 |
+
"status": "error", "modified": "", "seen": seen,
|
| 530 |
+
"marker": {}, "error": (p.get("error") or "no page")[:160]})
|
| 531 |
+
continue
|
| 532 |
+
status, modified, marker_found = irb_archive_status(p["html"], seen)
|
| 533 |
+
rows.append({"page": page, "url": url, "papers": papers, "status": status,
|
| 534 |
+
"modified": modified, "seen": seen, "marker": marker_found,
|
| 535 |
+
"error": ""})
|
| 536 |
+
return rows
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def guidance_forms(policy_rows):
|
| 540 |
+
"""The IMM 1444 and Guide 5312 rows of check_policy (their page
|
| 541 |
+
dateModified), so the guidance section names them without a second fetch."""
|
| 542 |
+
from .conduct import DOCS
|
| 543 |
+
shorts = {d["short"] for d in DOCS
|
| 544 |
+
if d["code"] in _GUIDANCE_FORMS and d.get("lang", "en") == "en"}
|
| 545 |
+
return [dict(r) for r in policy_rows or []
|
| 546 |
+
if r.get("short") in shorts and r.get("lang", "en") == "en"]
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def check_guidance(policy_rows=None, probe=None, today=None, marker_path=None,
|
| 550 |
+
enf=None):
|
| 551 |
+
"""The guidance-currency watch (roadmap T4.8): {enf, pdi, irb, forms}.
|
| 552 |
+
Two PowerShell processes run, one for the ENF HEADs and one for the PDI
|
| 553 |
+
and IRB page GETs. `enf` may pass pre-built chapter targets (tests)."""
|
| 554 |
+
probe = probe or _powershell_probe
|
| 555 |
+
today = today or datetime.date.today().isoformat()
|
| 556 |
+
enf_rows = check_enf(probe, targets=enf, marker_path=marker_path)
|
| 557 |
+
got = probe(_pdi_urls() + _irb_urls(), "GET")
|
| 558 |
+
return {"enf": enf_rows, "pdi": check_pdi(got, today),
|
| 559 |
+
"irb": check_irb(got, marker_path), "forms": guidance_forms(policy_rows)}
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def guidance_flags(guidance):
|
| 563 |
+
"""[(kind, row)] of the rows that fail the check. The forms rows are not
|
| 564 |
+
counted here: check_policy's own STALE rows already count them."""
|
| 565 |
+
return [(kind, r) for kind, statuses in _GUIDANCE_FLAGS.items()
|
| 566 |
+
for r in (guidance or {}).get(kind) or [] if r["status"] in statuses]
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def ack_guidance(probe=None, marker_path=None, enf=None):
|
| 570 |
+
"""Record the current upstream date of every ENF chapter flagged CHECK
|
| 571 |
+
and every IRB index page flagged UPDATED as seen. Run it after someone
|
| 572 |
+
has looked and found nothing to re-ingest. An UNARCHIVED page cannot be
|
| 573 |
+
acknowledged: the chunks' archived label has to change in
|
| 574 |
+
canlex.irb_papers."""
|
| 575 |
+
probe = probe or _powershell_probe
|
| 576 |
+
enf_rows = check_enf(probe, targets=enf, marker_path=marker_path)
|
| 577 |
+
irb_rows = check_irb(probe(_irb_urls(), "GET"), marker_path)
|
| 578 |
+
marker = _guidance_marker(marker_path)
|
| 579 |
+
acked = []
|
| 580 |
+
for r in enf_rows:
|
| 581 |
+
if r["status"] == "check" and r["remote"]:
|
| 582 |
+
marker[r["url"]] = r["remote"]
|
| 583 |
+
acked.append(f"ENF {r['chapter']} ({r['lang']}): {r['remote']}")
|
| 584 |
+
for r in irb_rows:
|
| 585 |
+
if r["status"] == "updated" and r["modified"]:
|
| 586 |
+
marker["irb:" + r["url"]] = r["modified"]
|
| 587 |
+
acked.append(f"IRB {r['page']}: {r['modified']}")
|
| 588 |
+
if acked:
|
| 589 |
+
_write_guidance_marker(marker, marker_path)
|
| 590 |
+
print("acknowledged: " + ("; ".join(acked) if acked else "nothing flagged"))
|
| 591 |
+
return 0
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
_CIU_DECISIONS = "https://ciu-sdi.ca/en/important-decisions/"
|
| 595 |
+
_CIU_DEC_MARKER = PROCESSED_DIR.parent / "raw" / "ciu" / "decisions-page-date.txt"
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def check_ciu_decisions():
|
| 599 |
+
"""New-case-law tripwire: the CIU curates its Important Decisions page,
|
| 600 |
+
so its dateModified moving is a cheap signal that a new decision worth
|
| 601 |
+
ingesting has issued. This is the only automated freshness signal the
|
| 602 |
+
case-law corpus has -- courts publish no 'new leading case' feed -- and
|
| 603 |
+
it complements the quarterly gap-audit ritual rather than replacing it."""
|
| 604 |
+
try:
|
| 605 |
+
req = urllib.request.Request(
|
| 606 |
+
_CIU_DECISIONS, headers={"User-Agent": BROWSER_UA})
|
| 607 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 608 |
+
html = resp.read().decode("utf-8", "replace")
|
| 609 |
+
m = re.search(r'"dateModified":"(\d{4}-\d{2}-\d{2})', html)
|
| 610 |
+
remote = m.group(1) if m else ""
|
| 611 |
+
except Exception as exc:
|
| 612 |
+
return {"stored": "", "remote": "", "status": "error",
|
| 613 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 614 |
+
if not remote:
|
| 615 |
+
return {"stored": "", "remote": "", "status": "no-date", "error": ""}
|
| 616 |
+
stored = (_CIU_DEC_MARKER.read_text(encoding="utf-8").strip()
|
| 617 |
+
if _CIU_DEC_MARKER.exists() else "")
|
| 618 |
+
if not stored:
|
| 619 |
+
# First run: record the baseline rather than alarming (atomically: a
|
| 620 |
+
# truncated marker would read as a different, older date).
|
| 621 |
+
atomic_write_text(_CIU_DEC_MARKER, remote)
|
| 622 |
+
stored = remote
|
| 623 |
+
status = "stale" if remote > stored else "ok"
|
| 624 |
+
return {"stored": stored, "remote": remote, "status": status, "error": ""}
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def check_union():
|
| 628 |
+
"""Upstream drift for the CIU/PSAC documents (incl. every site VSSA).
|
| 629 |
+
|
| 630 |
+
The union reposts amended documents at the same URLs, so the drift
|
| 631 |
+
signal is each PDF's Last-Modified header against our cached copy's
|
| 632 |
+
fetch time. New/removed VSSAs surface as fetch errors or new links on
|
| 633 |
+
the next canlex.ciu run; this check covers content changes to what we
|
| 634 |
+
already hold.
|
| 635 |
+
|
| 636 |
+
Clearing a stale row needs `canlex.ciu --force`: a plain run reuses
|
| 637 |
+
the cached PDF, so it would rebuild identical chunks and leave the
|
| 638 |
+
cache mtime -- and therefore this comparison -- unchanged."""
|
| 639 |
+
import email.utils
|
| 640 |
+
from . import ciu
|
| 641 |
+
rows = []
|
| 642 |
+
targets = [(short, lang, ciu.RAW / f"{code}{'-fr' if lang == 'fr' else ''}.pdf", url)
|
| 643 |
+
for code, short, name, url, lang in ciu.DOCS]
|
| 644 |
+
try:
|
| 645 |
+
targets += [(f"VSSA {stem}", "en",
|
| 646 |
+
ciu.RAW / f"vssa-{stem.lower()}.pdf", url)
|
| 647 |
+
for url, stem in ciu._vssa_links()]
|
| 648 |
+
except Exception as exc:
|
| 649 |
+
rows.append({"short": "VSSA index", "lang": "en", "status": "error",
|
| 650 |
+
"remote": "", "error": f"{type(exc).__name__}: {exc}"})
|
| 651 |
+
for short, lang, cache, url in targets:
|
| 652 |
+
try:
|
| 653 |
+
req = urllib.request.Request(url, method="HEAD",
|
| 654 |
+
headers={"User-Agent": BROWSER_UA})
|
| 655 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 656 |
+
lm = resp.headers.get("Last-Modified", "")
|
| 657 |
+
except Exception as exc:
|
| 658 |
+
rows.append({"short": short, "lang": lang, "status": "error",
|
| 659 |
+
"remote": "", "error": f"{type(exc).__name__}: {exc}"})
|
| 660 |
+
continue
|
| 661 |
+
if not lm:
|
| 662 |
+
rows.append({"short": short, "lang": lang, "status": "no-date",
|
| 663 |
+
"remote": "", "error": ""})
|
| 664 |
+
continue
|
| 665 |
+
remote_ts = email.utils.parsedate_to_datetime(lm).timestamp()
|
| 666 |
+
local_ts = cache.stat().st_mtime if cache.exists() else 0
|
| 667 |
+
rows.append({"short": short, "lang": lang,
|
| 668 |
+
"status": "stale" if remote_ts > local_ts else "ok",
|
| 669 |
+
"remote": lm[:25], "error": ""})
|
| 670 |
+
time.sleep(0.3)
|
| 671 |
+
return rows
|
| 672 |
+
|
| 673 |
+
# Curated datasets age: state legislatures amend deferred-adjudication
|
| 674 |
+
# statutes every session, so a reviewed date older than this many days earns
|
| 675 |
+
# a warning even though nothing upstream can be diffed automatically.
|
| 676 |
+
_CURATED_MAX_AGE_DAYS = 180
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
def check_curated(today):
|
| 680 |
+
"""Age-check the curated datasets and detect edit-without-regen drift.
|
| 681 |
+
|
| 682 |
+
Two signals: (1) each data/curated/*.json 'reviewed' date older than
|
| 683 |
+
_CURATED_MAX_AGE_DAYS -> aging (re-verify against the state sources);
|
| 684 |
+
(2) the newest commentary.json current_to differing from the newest
|
| 685 |
+
curated 'reviewed' -> the curated file was edited without rerunning
|
| 686 |
+
canlex.commentary + canlex.embed, so production serves stale chunks."""
|
| 687 |
+
from .commentary import CURATED, EQUIV
|
| 688 |
+
from .config import DATA_DIR
|
| 689 |
+
# Only the files canlex.commentary renders can put commentary.json out
|
| 690 |
+
# of date; a curated file the renderer never reads (transborder.json, the
|
| 691 |
+
# IRPR s. 19(1)(c)-(g) table read at run time) is age-checked only.
|
| 692 |
+
rendered = {CURATED.name, EQUIV.name}
|
| 693 |
+
rows = []
|
| 694 |
+
reviewed_by_file = {}
|
| 695 |
+
for path in sorted((DATA_DIR / "curated").glob("*.json")):
|
| 696 |
+
try:
|
| 697 |
+
reviewed = json.loads(
|
| 698 |
+
path.read_text(encoding="utf-8")).get("reviewed", "")
|
| 699 |
+
except Exception as exc:
|
| 700 |
+
rows.append({"file": path.name, "reviewed": "",
|
| 701 |
+
"status": "error", "error": f"{type(exc).__name__}: {exc}"})
|
| 702 |
+
continue
|
| 703 |
+
if path.name in rendered:
|
| 704 |
+
reviewed_by_file[path.name] = reviewed or ""
|
| 705 |
+
status = "ok"
|
| 706 |
+
if reviewed:
|
| 707 |
+
age = (datetime.date.fromisoformat(today)
|
| 708 |
+
- datetime.date.fromisoformat(reviewed)).days
|
| 709 |
+
if age > _CURATED_MAX_AGE_DAYS:
|
| 710 |
+
status = "aging"
|
| 711 |
+
rows.append({"file": path.name, "reviewed": reviewed,
|
| 712 |
+
"status": status, "error": ""})
|
| 713 |
+
commentary = PROCESSED_DIR / "commentary.json"
|
| 714 |
+
newest_reviewed = max(reviewed_by_file.values(), default="")
|
| 715 |
+
if newest_reviewed and commentary.exists():
|
| 716 |
+
chunks = json.loads(commentary.read_text(encoding="utf-8"))
|
| 717 |
+
# Per rendered file, by chunk id prefix (canlex.commentary stamps
|
| 718 |
+
# every chunk with its source file's 'reviewed' as current_to):
|
| 719 |
+
# us_dispositions.json renders commentary-disp-* and commentary-state-*,
|
| 720 |
+
# us_equivalency.json commentary-equiv-*; the commentary-method-* chunks
|
| 721 |
+
# come from both and are left to the newest-vs-newest row below. Until
|
| 722 |
+
# 2026-09-21 only the two maxima were compared, so editing the older
|
| 723 |
+
# file (its reviewed still below the other's) without re-rendering
|
| 724 |
+
# printed no DRIFT row while commentary.json kept the pre-edit chunks.
|
| 725 |
+
for name, prefixes in commentary_groups().items():
|
| 726 |
+
reviewed = reviewed_by_file.get(name)
|
| 727 |
+
dates = {c.get("current_to", "") for c in chunks
|
| 728 |
+
if str(c.get("id", "")).startswith(prefixes)}
|
| 729 |
+
if reviewed is None or not dates:
|
| 730 |
+
continue
|
| 731 |
+
newest = max(dates)
|
| 732 |
+
if newest != reviewed or len(dates) > 1:
|
| 733 |
+
rows.append({"file": "commentary.json",
|
| 734 |
+
"reviewed": newest, "status": "drift",
|
| 735 |
+
"error": f"{name} reviewed {reviewed} but its chunks "
|
| 736 |
+
f"say {', '.join(sorted(dates)) or '?'} -- rerun "
|
| 737 |
+
f"canlex.commentary && canlex.embed"})
|
| 738 |
+
newest_chunk = max((c.get("current_to", "") for c in chunks), default="")
|
| 739 |
+
if newest_chunk != newest_reviewed:
|
| 740 |
+
rows.append({"file": "commentary.json",
|
| 741 |
+
"reviewed": newest_chunk, "status": "drift",
|
| 742 |
+
"error": f"curated reviewed {newest_reviewed} but "
|
| 743 |
+
f"chunks say {newest_chunk or '?'} -- rerun "
|
| 744 |
+
f"canlex.commentary && canlex.embed"})
|
| 745 |
+
return rows
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def commentary_groups():
|
| 749 |
+
"""{rendered curated file name: the id prefixes of the commentary chunks
|
| 750 |
+
it renders} -- the grouping check_curated's per-file drift row relies on
|
| 751 |
+
(tests/test_refresh.py pins it against the renderer's ids)."""
|
| 752 |
+
from .commentary import CURATED, EQUIV
|
| 753 |
+
return {CURATED.name: ("commentary-disp-", "commentary-state-"),
|
| 754 |
+
EQUIV.name: ("commentary-equiv-",)}
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def check_curated_sections(today, write_drafts=True, draft_dir=None):
|
| 758 |
+
"""Every curated verify phrase against the corpus now (roadmap T4.3).
|
| 759 |
+
The rows report BROKEN (the phrase or section is gone) and AMENDED (the
|
| 760 |
+
section's last_amended is later than last_amended_seen, the date in the
|
| 761 |
+
corpus of the approved commit). A DRAFT penalty_history row goes to
|
| 762 |
+
data/curated/drafts/ when an amendment changed a quoted maximum that no
|
| 763 |
+
history row covers. The result is canlex.curated_checks.run_checks's
|
| 764 |
+
dict: {baseline, rows, drafts, draft_file, drift, error}."""
|
| 765 |
+
from . import curated_checks
|
| 766 |
+
from .config import DATA_DIR
|
| 767 |
+
return curated_checks.run_checks(curated_dir=DATA_DIR / "curated", today=today,
|
| 768 |
+
draft_dir=draft_dir, write=write_drafts)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def curated_sections_flags(result):
|
| 772 |
+
"""What fails the check: a broken phrase, a drafted (uncovered) history
|
| 773 |
+
row, a maximum the table does not quote (drift), or an unreadable table.
|
| 774 |
+
An amendment that left the quoted maximum alone is printed as RE-READ
|
| 775 |
+
and does not count."""
|
| 776 |
+
if result.get("error"):
|
| 777 |
+
return [{"error": result["error"]}]
|
| 778 |
+
return ([r for r in result.get("rows") or [] if r["status"] == "broken"]
|
| 779 |
+
+ list(result.get("drafts") or []) + list(result.get("drift") or []))
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _years(m):
|
| 783 |
+
return "life" if m is not None and m >= 99 else f"{m} years"
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
def _print_curated_sections(res):
|
| 787 |
+
from .curated_checks import where
|
| 788 |
+
rows = res.get("rows") or []
|
| 789 |
+
base = res.get("baseline") or ""
|
| 790 |
+
print(f"\nCurated verify phrases ({len(rows)} rows, us_equivalency.json + "
|
| 791 |
+
f"transborder.json; last seen = the corpus of the approved commit "
|
| 792 |
+
f"{base[:7] or '(unreadable: amendments not checked)'}):")
|
| 793 |
+
if res.get("error"):
|
| 794 |
+
print(f" ERROR {res['error'][:70]}")
|
| 795 |
+
return
|
| 796 |
+
broken = [r for r in rows if r["status"] == "broken"]
|
| 797 |
+
for r in broken:
|
| 798 |
+
print(f" BROKEN {where(r)}: {r['act']} s. {r['section']} -- {r['reason']}: "
|
| 799 |
+
f"{r['contains'][:60]!r}")
|
| 800 |
+
for pid, act, sec, new in res.get("drift") or []:
|
| 801 |
+
print(f" NOT QUOTED {pid}: {act} s. {sec} now states "
|
| 802 |
+
f"{' and '.join(_years(m) for m in new)}, which the approved corpus did "
|
| 803 |
+
f"not state and the table does not quote")
|
| 804 |
+
for d in res.get("drafts") or []:
|
| 805 |
+
pr = d["proposed_row"]
|
| 806 |
+
print(f" AMENDED {d['pairing']}{' limb ' + d['limb'] if d['limb'] else ''}: "
|
| 807 |
+
f"{d['act']} s. {d['section']} amended {d['amended']} "
|
| 808 |
+
f"({d['amended_by'] or '?'}; last seen {d['last_amended_seen']}): "
|
| 809 |
+
f"maximum {', '.join(_years(m) for m in d['maxima_before']) or 'none'} -> "
|
| 810 |
+
f"{', '.join(_years(m) for m in d['maxima_after']) or 'none'}; DRAFT "
|
| 811 |
+
f"history row until {pr['until']} at "
|
| 812 |
+
f"{_years(pr['max_years']) if pr['max_years'] is not None else '? (read the old version)'}")
|
| 813 |
+
for c in d.get("cautions") or []:
|
| 814 |
+
print(f" CAUTION {c}")
|
| 815 |
+
if d.get("prose_names_date"):
|
| 816 |
+
print(f" (the curated prose already names this amendment; only the "
|
| 817 |
+
f"structured row is missing)")
|
| 818 |
+
covered = sorted({(r["owner"], r["act"], r["section"], ", ".join(r["covered_by"]))
|
| 819 |
+
for r in rows if r.get("covered_by")})
|
| 820 |
+
for pid, act, sec, until in covered:
|
| 821 |
+
print(f" ok {pid}: {act} s. {sec} amended; the change to its maximum is "
|
| 822 |
+
f"covered by the history row until {until}")
|
| 823 |
+
answered = {(r["act"], r["section"]) for r in rows if r.get("subsections")}
|
| 824 |
+
reread = sorted({(r["act"], r["section"]) for r in rows
|
| 825 |
+
if r.get("amended") and not r.get("subsections")} - answered)
|
| 826 |
+
if reread:
|
| 827 |
+
print(f" RE-READ {len(reread)} section(s) amended since the approved corpus "
|
| 828 |
+
f"with no change to the maximum they are quoted for: "
|
| 829 |
+
+ ", ".join(f"{a} s. {s}" for a, s in reread))
|
| 830 |
+
created = sorted({(r["act"], r["section"], l) for r in rows
|
| 831 |
+
for l in r.get("provisions_added") or []})
|
| 832 |
+
repealed = sorted({(r["act"], r["section"], l) for r in rows
|
| 833 |
+
for l in r.get("provisions_removed") or []})
|
| 834 |
+
if created or repealed:
|
| 835 |
+
print(" RE-READ provisions created / repealed where a verify phrase occurs: "
|
| 836 |
+
+ ", ".join([f"{a} s. {s}({l}) new" for a, s, l in created]
|
| 837 |
+
+ [f"{a} s. {s}({l}) repealed" for a, s, l in repealed]))
|
| 838 |
+
if res.get("draft_file"):
|
| 839 |
+
print(f" -> drafts for review: {res['draft_file']}")
|
| 840 |
+
if broken or res.get("drafts") or res.get("drift"):
|
| 841 |
+
print(" -> the curated JSON is HELD FOR REVIEW: put these rows to the "
|
| 842 |
+
"maintainer; do not edit data/curated/*.json from this check")
|
| 843 |
+
else:
|
| 844 |
+
print(" every phrase is in its section; no uncovered change to a quoted maximum")
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
def _print_guidance(g):
|
| 848 |
+
print("\nGuidance currency (ENF chapters, PDI indexes, IRB papers, "
|
| 849 |
+
"rehabilitation forms):")
|
| 850 |
+
enf = g.get("enf") or []
|
| 851 |
+
for r in enf:
|
| 852 |
+
tag = f"ENF {r['chapter']}{' (fr)' if r['lang'] == 'fr' else ''}"
|
| 853 |
+
fr = " --fr" if r["lang"] == "fr" else ""
|
| 854 |
+
if r["status"] == "check":
|
| 855 |
+
print(f" CHECK {tag}: upstream Last-Modified {r['remote']} after last "
|
| 856 |
+
f"seen {r['seen']} -- run py -m canlex.enf{fr} --force && py -m "
|
| 857 |
+
f"canlex.embed (or, when nothing changed, py -m canlex.refresh "
|
| 858 |
+
f"--ack-guidance)")
|
| 859 |
+
elif r["status"] == "retired":
|
| 860 |
+
print(f" RETIRED {tag}: 404 upstream -- IRCC withdrew it; run py -m "
|
| 861 |
+
f"canlex.enf{fr} (a 404 chapter is dropped) and report what "
|
| 862 |
+
f"replaced it")
|
| 863 |
+
elif r["status"] in ("error", "no-date"):
|
| 864 |
+
print(f" {'ERROR ' if r['status'] == 'error' else '? '} {tag}: "
|
| 865 |
+
f"{r['error'][:55] or 'no Last-Modified header'}")
|
| 866 |
+
n_ok = sum(1 for r in enf if r["status"] == "ok")
|
| 867 |
+
print(f" ENF: {n_ok} of {len(enf)} official chapter editions unchanged since "
|
| 868 |
+
f"last seen (ENF 2 and 14 skipped: withdrawn, unofficial ATIP copies)")
|
| 869 |
+
pdi_rows = g.get("pdi") or []
|
| 870 |
+
for r in pdi_rows:
|
| 871 |
+
if r["status"] == "changed":
|
| 872 |
+
names = [u.rsplit("/", 1)[-1] for u in r["new"]][:4]
|
| 873 |
+
gone = [u.rsplit("/", 1)[-1] for u in r["removed"]][:4]
|
| 874 |
+
print(f" CHANGED {r['name']}: index lists {len(r['new'])} new page(s)"
|
| 875 |
+
f"{' ' + ', '.join(names) if names else ''} and dropped "
|
| 876 |
+
f"{len(r['removed'])}{' ' + ', '.join(gone) if gone else ''} -- run "
|
| 877 |
+
f"py -m canlex.pdi --force && py -m canlex.embed")
|
| 878 |
+
elif r["status"] == "updated":
|
| 879 |
+
print(f" UPDATED {r['name']}: modified {r['remote']} > last seen "
|
| 880 |
+
f"{r['seen']} -- run py -m canlex.pdi --force && py -m canlex.embed")
|
| 881 |
+
elif r["status"] == "retired":
|
| 882 |
+
print(f" RETIRED {r['name']}: 404 upstream ({r['url']})")
|
| 883 |
+
elif r["status"] in ("error", "no-date"):
|
| 884 |
+
print(f" {'ERROR ' if r['status'] == 'error' else '? '} {r['name']}: "
|
| 885 |
+
f"{r['error'][:55] or 'no dcterms.modified'}")
|
| 886 |
+
n_ok = sum(1 for r in pdi_rows if r["status"] == "ok")
|
| 887 |
+
print(f" PDI: {n_ok} of {len(pdi_rows)} index/key pages unchanged")
|
| 888 |
+
for r in g.get("irb") or []:
|
| 889 |
+
label = f"IRB {r['page']} ({', '.join(r['papers'])})"
|
| 890 |
+
if r["status"] == "unarchived":
|
| 891 |
+
print(f" UNARCHIVED {label}: the ARCHIVED marker is gone -- the chunks "
|
| 892 |
+
f"still say 'archived' (canlex.irb_papers); re-read the page and "
|
| 893 |
+
f"report it (not a refresh)")
|
| 894 |
+
elif r["status"] == "updated":
|
| 895 |
+
print(f" UPDATED {label}: date modified {r['modified']} > last seen "
|
| 896 |
+
f"{r['seen']} -- re-read; py -m canlex.irb_papers --force && py -m "
|
| 897 |
+
f"canlex.embed if a paper changed, then py -m canlex.refresh "
|
| 898 |
+
f"--ack-guidance")
|
| 899 |
+
elif r["status"] == "error":
|
| 900 |
+
print(f" ERROR {label}: {r['error'][:55]}")
|
| 901 |
+
else:
|
| 902 |
+
print(f" ok {label}: still archived (modified "
|
| 903 |
+
f"{r['modified'] or '?'})")
|
| 904 |
+
for r in g.get("forms") or []:
|
| 905 |
+
print(f" {'STALE ' if r['status'] == 'stale' else r['status'][:6]:6} "
|
| 906 |
+
f"{r['short']}: page dateModified {r.get('remote') or r.get('stored') or '?'}"
|
| 907 |
+
f" (read by the employer-policy check above)")
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def check_tariff_edition(today):
|
| 911 |
+
"""Probe whether CBSA has published a Customs Tariff edition newer than
|
| 912 |
+
the ingested one. The tariff pages live under a year-stamped path
|
| 913 |
+
(.../tariff-tarif/<year>/html/...), so probing the ingested-edition-plus-
|
| 914 |
+
one URL (and the current calendar year's) is a reliable new-edition
|
| 915 |
+
signal without parsing anything."""
|
| 916 |
+
from . import tariff_schedule
|
| 917 |
+
src = next(iter(tariff_schedule.SOURCES.values()))
|
| 918 |
+
have = int(src["edition"])
|
| 919 |
+
candidates = sorted({have + 1, int(today[:4])} - {have})
|
| 920 |
+
for year in candidates:
|
| 921 |
+
if year <= have:
|
| 922 |
+
continue
|
| 923 |
+
probe = src["url"].replace(f"/{src['edition']}/", f"/{year}/")
|
| 924 |
+
try:
|
| 925 |
+
req = urllib.request.Request(
|
| 926 |
+
probe, method="HEAD", headers={"User-Agent": BROWSER_UA})
|
| 927 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 928 |
+
if resp.status == 200:
|
| 929 |
+
return {"stored": str(have), "remote": str(year),
|
| 930 |
+
"status": "stale", "error": ""}
|
| 931 |
+
except urllib.error.HTTPError as exc:
|
| 932 |
+
if exc.code == 404:
|
| 933 |
+
continue # 404 = that edition doesn't exist yet
|
| 934 |
+
return {"stored": str(have), "remote": "", "status": "error",
|
| 935 |
+
"error": f"HTTP {exc.code} probing {probe}"}
|
| 936 |
+
except Exception as exc:
|
| 937 |
+
# DNS down, a timeout, a TLS refusal: not 'ok' -- until 2026-09-21
|
| 938 |
+
# every exception read as 'no newer edition'.
|
| 939 |
+
return {"stored": str(have), "remote": "", "status": "error",
|
| 940 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 941 |
+
return {"stored": str(have), "remote": "", "status": "ok", "error": ""}
|
| 942 |
+
|
| 943 |
+
|
| 944 |
+
def dmemo_drift(raw, stored):
|
| 945 |
+
"""(newest active LAST_UPDATED, memos newer than `stored`) from the CBSA
|
| 946 |
+
D-memo datasource JSON. Pure, for testability."""
|
| 947 |
+
rows = [r for r in json.loads(raw).get("data", [])
|
| 948 |
+
if str(r.get("STATUS", "")).strip().lower() != "cancelled"]
|
| 949 |
+
remote = max((r.get("LAST_UPDATED", "") for r in rows), default="")
|
| 950 |
+
drifted = sorted(_dmemo_number(r) for r in rows
|
| 951 |
+
if stored and r.get("LAST_UPDATED", "") > stored)
|
| 952 |
+
return remote, drifted
|
| 953 |
+
|
| 954 |
+
|
| 955 |
+
def _dmemo_number(row):
|
| 956 |
+
"""'D1-2-1' out of a datasource NUMBER cell (an anchor, or bare text)."""
|
| 957 |
+
cell = str(row.get("NUMBER", ""))
|
| 958 |
+
inner = re.search(r">([^<]+)<", cell)
|
| 959 |
+
return (inner.group(1) if inner else re.sub(r"<[^>]+>", "", cell)).strip()
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
def check_dmemo_index():
|
| 963 |
+
"""Newest per-memo 'Last updated' upstream vs stored currency.
|
| 964 |
+
|
| 965 |
+
This reads the JSON datasource behind the index table, NOT dates in the
|
| 966 |
+
index HTML: since CBSA rebuilt the page (2026-07-10) the newest date in the
|
| 967 |
+
markup is the page's own 'Date modified', which moves when the page is
|
| 968 |
+
rebuilt and no memo has changed -- that would report stale forever. The
|
| 969 |
+
datasource carries a LAST_UPDATED per memo, so drift is both true and named.
|
| 970 |
+
"""
|
| 971 |
+
stored = ""
|
| 972 |
+
path = PROCESSED_DIR / "dmemos.json"
|
| 973 |
+
if path.exists():
|
| 974 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 975 |
+
stored = max((c.get("current_to", "") for c in chunks), default="")
|
| 976 |
+
try:
|
| 977 |
+
req = urllib.request.Request(
|
| 978 |
+
dmemo.DATASOURCE_URL, headers={"User-Agent": BROWSER_UA})
|
| 979 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 980 |
+
remote, drifted = dmemo_drift(resp.read(), stored)
|
| 981 |
+
except Exception as exc:
|
| 982 |
+
return {"stored": stored, "remote": "", "drifted": [], "status": "error",
|
| 983 |
+
"error": f"{type(exc).__name__}: {exc}"}
|
| 984 |
+
status = "stale" if (remote and stored and remote > stored) else \
|
| 985 |
+
("ok" if remote else "no-date")
|
| 986 |
+
return {"stored": stored, "remote": remote, "drifted": drifted,
|
| 987 |
+
"status": status, "error": ""}
|
| 988 |
+
|
| 989 |
+
# NJC directive dates as published: the index prints prose ('June 1, 1993' --
|
| 990 |
+
# double spaces occur in the raw markup) while chunks may store either that
|
| 991 |
+
# prose or the ISO fallback directive.py derives from page text, so both sides
|
| 992 |
+
# are normalized to ISO before comparing.
|
| 993 |
+
_NJC_PROSE_DATE = re.compile(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})")
|
| 994 |
+
_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
| 995 |
+
|
| 996 |
+
# CBSA publishes its IRPA delegation as a base instrument plus incremental
|
| 997 |
+
# amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
|
| 998 |
+
# linked from the CBSA legislation index. A date on the index that we have not
|
| 999 |
+
# ingested means a new amendment to add to delegation.SOURCES.
|
| 1000 |
+
_CBSA_INDEX_URL = ("https://www.cbsa-asfc.gc.ca/agency-agence/actreg-loireg/"
|
| 1001 |
+
"legislation-eng.html")
|
| 1002 |
+
_CBSA_INSTRUMENT_RE = re.compile(r"irpa-lipr-(\d{4}-\d{2}-\d{2})-eng\.html")
|
| 1003 |
+
# The IRCC IL3 instrument is reissued with a seasonal version stamp on page 1.
|
| 1004 |
+
_IL3_VERSION_RE = re.compile(r"(?:Spring|Summer|Fall|Winter)\s+\d{4}")
|
| 1005 |
+
|
| 1006 |
+
# Bills known to be heading into force that affect the corpus. Surfaced once
|
| 1007 |
+
# their in-force date has passed, with the codes to re-ingest. (The legislation
|
| 1008 |
+
# drift check above will independently flag the affected Act once Justice Laws
|
| 1009 |
+
# consolidates the amendment; this list just names the cause.)
|
| 1010 |
+
_PENDING_BILLS = [
|
| 1011 |
+
{"bill": "C-16", "name": "Protecting Victims Act",
|
| 1012 |
+
"in_force": "2026-07-18", "affects": ["C-46"],
|
| 1013 |
+
"note": "Criminal Code: coercive control, child-protection reporting "
|
| 1014 |
+
"(coercive-control provisions come into force later)."},
|
| 1015 |
+
]
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
def _extract_current_date(raw):
|
| 1019 |
+
"""Pull the lims:current-date (YYYY-MM-DD) from the head of a Justice Laws
|
| 1020 |
+
XML document. Pure and offline, for testability. Returns '' if absent."""
|
| 1021 |
+
if raw[:3] == b"\xef\xbb\xbf":
|
| 1022 |
+
raw = raw[3:]
|
| 1023 |
+
m = _CURRENT_DATE.search(raw)
|
| 1024 |
+
return m.group(1).decode("ascii") if m else ""
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
+
def _stored_current_to(fname):
|
| 1028 |
+
"""The current_to stored in data/processed/<fname> (first chunk), '' when
|
| 1029 |
+
the file is empty, None when it does not exist."""
|
| 1030 |
+
path = PROCESSED_DIR / fname
|
| 1031 |
+
if not path.exists():
|
| 1032 |
+
return None
|
| 1033 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 1034 |
+
return chunks[0].get("current_to", "") if chunks else ""
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
def _local_current_to(code):
|
| 1038 |
+
"""The current_to stored in data/processed/<code>.json (first chunk), or ''."""
|
| 1039 |
+
return _stored_current_to(f"{code}.json")
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def check_bilingual(codes=None, reader=_stored_current_to):
|
| 1043 |
+
"""Every Justice Laws instrument must carry its French twin at the SAME
|
| 1044 |
+
consolidation date. The legislation check only reads the English file,
|
| 1045 |
+
so an instrument ingested in one language -- or refreshed in one
|
| 1046 |
+
language only -- would pass it silently. Pure given `reader`, for tests."""
|
| 1047 |
+
rows = []
|
| 1048 |
+
for code in (codes or list(SOURCES)):
|
| 1049 |
+
en = reader(f"{code}.json")
|
| 1050 |
+
fr = reader(f"{code}-fr.json")
|
| 1051 |
+
if en is None:
|
| 1052 |
+
status = "missing-en" # check_legislation reports this too
|
| 1053 |
+
elif fr is None:
|
| 1054 |
+
status = "missing-fr"
|
| 1055 |
+
elif (fr or "") != (en or ""):
|
| 1056 |
+
status = "fr-drift"
|
| 1057 |
+
else:
|
| 1058 |
+
status = "ok"
|
| 1059 |
+
rows.append({"code": code, "en": en or "", "fr": fr or "",
|
| 1060 |
+
"status": status})
|
| 1061 |
+
return rows
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
# Processed files no watch above is responsible for. Every corpus family must
|
| 1065 |
+
# be claimed by SOURCES (legislation, EN + FR), _NON_XML, or a dedicated
|
| 1066 |
+
# upstream check -- otherwise a new family can be added to the corpus and
|
| 1067 |
+
# never be looked at again. Pure, for tests.
|
| 1068 |
+
_DEDICATED = {"directives.json": "check_directives",
|
| 1069 |
+
"delegation.json": "check_delegation"}
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
def unwatched_files(files, sources=None, non_xml=None, dedicated=None):
|
| 1073 |
+
sources = list(SOURCES) if sources is None else sources
|
| 1074 |
+
non_xml = _NON_XML if non_xml is None else non_xml
|
| 1075 |
+
dedicated = _DEDICATED if dedicated is None else dedicated
|
| 1076 |
+
claimed = set(dedicated)
|
| 1077 |
+
for code in sources:
|
| 1078 |
+
claimed.add(f"{code}.json")
|
| 1079 |
+
claimed.add(f"{code}-fr.json")
|
| 1080 |
+
claimed.update(fname for _, fname, _, _ in non_xml)
|
| 1081 |
+
return sorted(f for f in files if f.endswith(".json") and f not in claimed)
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
def _remote_current_date(url):
|
| 1085 |
+
"""Fetch only the head of the XML and extract its consolidation date.
|
| 1086 |
+
Returns (date, error): date is '' on a clean fetch that lacked the
|
| 1087 |
+
attribute, error is a short string when the fetch itself failed."""
|
| 1088 |
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
| 1089 |
+
try:
|
| 1090 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 1091 |
+
head = resp.read(_HEAD_BYTES)
|
| 1092 |
+
return _extract_current_date(head), ""
|
| 1093 |
+
except Exception as exc: # network / HTTP / timeout
|
| 1094 |
+
return "", f"{type(exc).__name__}: {exc}"
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
def check_legislation():
|
| 1098 |
+
"""Compare each Justice Laws source's upstream date to the stored corpus.
|
| 1099 |
+
Returns a list of row dicts."""
|
| 1100 |
+
rows = []
|
| 1101 |
+
for code, src in SOURCES.items():
|
| 1102 |
+
local = _local_current_to(code)
|
| 1103 |
+
remote, error = _remote_current_date(src["xml_url"])
|
| 1104 |
+
if error:
|
| 1105 |
+
status = "error"
|
| 1106 |
+
elif local is None:
|
| 1107 |
+
status = "missing-local" # configured but never ingested
|
| 1108 |
+
elif remote and remote > local:
|
| 1109 |
+
status = "stale" # upstream newer than our corpus
|
| 1110 |
+
elif not remote:
|
| 1111 |
+
status = "no-date" # fetched, but no date found (unusual)
|
| 1112 |
+
else:
|
| 1113 |
+
status = "ok"
|
| 1114 |
+
rows.append({"code": code, "short": src["short"], "local": local or "",
|
| 1115 |
+
"remote": remote, "status": status, "error": error})
|
| 1116 |
+
time.sleep(0.3) # be polite to Justice Laws
|
| 1117 |
+
return rows
|
| 1118 |
+
|
| 1119 |
+
|
| 1120 |
+
def non_xml_currency():
|
| 1121 |
+
"""Stored currency (newest item) of the aggregate sources."""
|
| 1122 |
+
rows = []
|
| 1123 |
+
for label, fname, cmd, desc in _NON_XML:
|
| 1124 |
+
path = PROCESSED_DIR / fname
|
| 1125 |
+
if not path.exists():
|
| 1126 |
+
rows.append({"label": label, "desc": desc, "cmd": cmd,
|
| 1127 |
+
"newest": "", "chunks": 0, "present": False})
|
| 1128 |
+
continue
|
| 1129 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 1130 |
+
newest = max((c.get("current_to", "") for c in chunks), default="")
|
| 1131 |
+
rows.append({"label": label, "desc": desc, "cmd": cmd,
|
| 1132 |
+
"newest": newest, "chunks": len(chunks), "present": True})
|
| 1133 |
+
return rows
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
def _cbsa_ingested_dates():
|
| 1137 |
+
"""The effective dates of the CBSA dated delegation instruments we ingest."""
|
| 1138 |
+
return sorted({s["effective"] for s in delegation.SOURCES.values()
|
| 1139 |
+
if s.get("kind") == "html-cbsa" and s.get("effective")})
|
| 1140 |
+
|
| 1141 |
+
|
| 1142 |
+
def _ircc_stored_version():
|
| 1143 |
+
"""The IL3 version string recorded in the ingested corpus, or ''."""
|
| 1144 |
+
path = PROCESSED_DIR / "delegation.json"
|
| 1145 |
+
if not path.exists():
|
| 1146 |
+
return ""
|
| 1147 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 1148 |
+
versions = [c.get("current_to", "") for c in chunks
|
| 1149 |
+
if c.get("act_short") == "IRCC IL3" and c.get("current_to")]
|
| 1150 |
+
return max(versions) if versions else ""
|
| 1151 |
+
|
| 1152 |
+
|
| 1153 |
+
def check_delegation():
|
| 1154 |
+
"""Compare the delegation instruments against upstream. Returns row dicts.
|
| 1155 |
+
|
| 1156 |
+
CBSA: diff the dated instruments published on the legislation index against
|
| 1157 |
+
those we ingest -- a new date means an un-ingested amendment. IRCC: compare
|
| 1158 |
+
the IL3 version stamp published in the live PDF against the stored one."""
|
| 1159 |
+
rows = []
|
| 1160 |
+
|
| 1161 |
+
# --- CBSA: index diff (cheap; one HTML fetch) ---
|
| 1162 |
+
have = _cbsa_ingested_dates()
|
| 1163 |
+
try:
|
| 1164 |
+
req = urllib.request.Request(_CBSA_INDEX_URL,
|
| 1165 |
+
headers={"User-Agent": BROWSER_UA})
|
| 1166 |
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 1167 |
+
html = resp.read().decode("utf-8", "replace")
|
| 1168 |
+
published = sorted(set(_CBSA_INSTRUMENT_RE.findall(html)))
|
| 1169 |
+
new = sorted(set(published) - set(have))
|
| 1170 |
+
rows.append({"name": "CBSA IRPA delegation (+ amendments)",
|
| 1171 |
+
"status": "stale" if new else "ok",
|
| 1172 |
+
"have": have, "published": published, "new": new,
|
| 1173 |
+
"error": ""})
|
| 1174 |
+
except Exception as exc:
|
| 1175 |
+
rows.append({"name": "CBSA IRPA delegation (+ amendments)",
|
| 1176 |
+
"status": "error", "have": have, "published": [],
|
| 1177 |
+
"new": [], "error": f"{type(exc).__name__}: {exc}"})
|
| 1178 |
+
|
| 1179 |
+
# --- IRCC IL3: version-stamp compare (re-fetches the PDF via PowerShell) ---
|
| 1180 |
+
stored = _ircc_stored_version()
|
| 1181 |
+
try:
|
| 1182 |
+
with tempfile.TemporaryDirectory() as td:
|
| 1183 |
+
data = delegation._fetch(delegation.SOURCES["ircc"]["url"],
|
| 1184 |
+
Path(td) / "il3.pdf", powershell=True)
|
| 1185 |
+
pages = delegation._pdf_pages(data)
|
| 1186 |
+
m = _IL3_VERSION_RE.search(pages[0]) if pages else None
|
| 1187 |
+
published = m.group(0) if m else ""
|
| 1188 |
+
if not published:
|
| 1189 |
+
status = "no-version"
|
| 1190 |
+
elif published != stored:
|
| 1191 |
+
status = "stale"
|
| 1192 |
+
else:
|
| 1193 |
+
status = "ok"
|
| 1194 |
+
rows.append({"name": "IRCC IL3 instrument", "status": status,
|
| 1195 |
+
"have": [stored], "published": [published], "new": [],
|
| 1196 |
+
"error": ""})
|
| 1197 |
+
except Exception as exc:
|
| 1198 |
+
rows.append({"name": "IRCC IL3 instrument", "status": "error",
|
| 1199 |
+
"have": [stored], "published": [], "new": [],
|
| 1200 |
+
"error": f"{type(exc).__name__}: {exc}"})
|
| 1201 |
+
return rows
|
| 1202 |
+
|
| 1203 |
+
|
| 1204 |
+
def _stored_directives():
|
| 1205 |
+
"""Per-directive stored currency from data/processed/directives.json:
|
| 1206 |
+
{code: (title, current_to, source_url)}, one entry per act_code."""
|
| 1207 |
+
path = PROCESSED_DIR / "directives.json"
|
| 1208 |
+
if not path.exists():
|
| 1209 |
+
return {}
|
| 1210 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 1211 |
+
out = {}
|
| 1212 |
+
for c in chunks:
|
| 1213 |
+
code = c.get("act_code")
|
| 1214 |
+
if code and code not in out:
|
| 1215 |
+
out[code] = (c.get("act_short", ""), c.get("current_to", ""),
|
| 1216 |
+
c.get("source_url", ""))
|
| 1217 |
+
return out
|
| 1218 |
+
|
| 1219 |
+
|
| 1220 |
+
def _directive_iso(text):
|
| 1221 |
+
"""Normalize an NJC directive date ('June 1, 1993' or already ISO) to
|
| 1222 |
+
YYYY-MM-DD. Pure, for testability. Returns '' when not a recognizable date."""
|
| 1223 |
+
text = (text or "").strip()
|
| 1224 |
+
if _ISO_DATE.fullmatch(text):
|
| 1225 |
+
return text
|
| 1226 |
+
m = _NJC_PROSE_DATE.fullmatch(text)
|
| 1227 |
+
if m and m.group(1).lower() in MONTHS:
|
| 1228 |
+
return (f"{int(m.group(3)):04d}-{MONTHS[m.group(1).lower()]:02d}-"
|
| 1229 |
+
f"{int(m.group(2)):02d}")
|
| 1230 |
+
return ""
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
+
def _njc_index_dates(html):
|
| 1234 |
+
"""{code: date text} parsed from the NJC directive index, mirroring
|
| 1235 |
+
directive.directive_links: the first <a> in each top-level li of
|
| 1236 |
+
ul.directive-list is the current directive (nested lis are archived
|
| 1237 |
+
versions), and the sibling span.date its effective date ('' if absent)."""
|
| 1238 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 1239 |
+
ul = soup.find("ul", class_="directive-list")
|
| 1240 |
+
out = {}
|
| 1241 |
+
if ul is None:
|
| 1242 |
+
return out
|
| 1243 |
+
for li in ul.find_all("li", recursive=False):
|
| 1244 |
+
a = li.find("a", href=True)
|
| 1245 |
+
if not a:
|
| 1246 |
+
continue
|
| 1247 |
+
m = directive._CODE.search(a["href"])
|
| 1248 |
+
if not m:
|
| 1249 |
+
continue
|
| 1250 |
+
span = li.find("span", class_="date")
|
| 1251 |
+
out[m.group(1)] = span.get_text(strip=True) if span else ""
|
| 1252 |
+
return out
|
| 1253 |
+
|
| 1254 |
+
|
| 1255 |
+
def _njc_page_effective(url, td):
|
| 1256 |
+
"""Fresh-fetch a directive's own page and pull its 'effective <Month D,
|
| 1257 |
+
YYYY>' prose (directive._page_effective_date), following the 'Print Full
|
| 1258 |
+
Directive' link when the landing page is only a table of contents (the
|
| 1259 |
+
FSDs). Returns (iso_date, error)."""
|
| 1260 |
+
try:
|
| 1261 |
+
html = directive._fetch(url, td / "probe.html", force=True)
|
| 1262 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 1263 |
+
main = soup.find("main")
|
| 1264 |
+
date = directive._page_effective_date(main) if main else ""
|
| 1265 |
+
if not date:
|
| 1266 |
+
print_url = directive._print_link(html)
|
| 1267 |
+
if print_url:
|
| 1268 |
+
full = directive._fetch(print_url, td / "probe-full.html",
|
| 1269 |
+
force=True)
|
| 1270 |
+
fmain = BeautifulSoup(full, "html.parser").find("main")
|
| 1271 |
+
date = directive._page_effective_date(fmain) if fmain else ""
|
| 1272 |
+
return date, ""
|
| 1273 |
+
except Exception as exc:
|
| 1274 |
+
return "", f"{type(exc).__name__}: {exc}"
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def check_directives():
|
| 1278 |
+
"""Compare each ingested NJC directive's effective date against upstream.
|
| 1279 |
+
Returns row dicts.
|
| 1280 |
+
|
| 1281 |
+
The date directive.py stores in current_to comes from the NJC directive
|
| 1282 |
+
index (the span.date beside each entry), so one fresh index fetch covers
|
| 1283 |
+
nearly every directive; entries the index leaves undated (the Foreign
|
| 1284 |
+
Service Directives) fall back to the 'effective <Month D, YYYY>' prose on
|
| 1285 |
+
the directive's own page -- the same two signals ingestion uses. Probe
|
| 1286 |
+
fetches go to a temp dir, never data/raw, so a stale probe can't mask a
|
| 1287 |
+
later real refresh; directive._fetch already sleeps 0.5s after each
|
| 1288 |
+
download, so the probes stay polite without extra pauses here."""
|
| 1289 |
+
stored = _stored_directives()
|
| 1290 |
+
rows = []
|
| 1291 |
+
if not stored:
|
| 1292 |
+
return rows
|
| 1293 |
+
with tempfile.TemporaryDirectory() as td:
|
| 1294 |
+
td = Path(td)
|
| 1295 |
+
try:
|
| 1296 |
+
html = directive._fetch(directive.INDEX_URL, td / "_index.html",
|
| 1297 |
+
force=True)
|
| 1298 |
+
index_dates, index_error = _njc_index_dates(html), ""
|
| 1299 |
+
except Exception as exc:
|
| 1300 |
+
index_dates, index_error = {}, f"{type(exc).__name__}: {exc}"
|
| 1301 |
+
for code, (title, stored_date, url) in sorted(stored.items()):
|
| 1302 |
+
upstream, error = index_dates.get(code, ""), index_error
|
| 1303 |
+
if not upstream and not error:
|
| 1304 |
+
# Undated on the index: read the date off the page itself.
|
| 1305 |
+
upstream, error = _njc_page_effective(url, td)
|
| 1306 |
+
local, remote = _directive_iso(stored_date), _directive_iso(upstream)
|
| 1307 |
+
if error:
|
| 1308 |
+
status = "error"
|
| 1309 |
+
elif not remote:
|
| 1310 |
+
status = "no-date"
|
| 1311 |
+
elif remote != local:
|
| 1312 |
+
status = "stale" # NJC reissues in place: any drift counts
|
| 1313 |
+
else:
|
| 1314 |
+
status = "ok"
|
| 1315 |
+
rows.append({"code": code, "title": title, "stored": stored_date,
|
| 1316 |
+
"local": local, "remote": remote, "status": status,
|
| 1317 |
+
"error": error})
|
| 1318 |
+
return rows
|
| 1319 |
+
|
| 1320 |
+
|
| 1321 |
+
def pending_in_force(today):
|
| 1322 |
+
"""Pending bills whose in-force date has passed as of `today` (ISO str)."""
|
| 1323 |
+
return [b for b in _PENDING_BILLS if b["in_force"] <= today]
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
def pending_consolidation(bill, reader=None):
|
| 1327 |
+
"""[{code, stored, status}] for each Act `bill` affects: the consolidation
|
| 1328 |
+
date stored for it and whether that already covers the bill's in-force
|
| 1329 |
+
date -- 'consolidated' (stored current_to on or after it: a Justice Laws
|
| 1330 |
+
consolidation current to a date includes what came into force by then),
|
| 1331 |
+
'predates' (the re-ingest is still needed, once Justice Laws has
|
| 1332 |
+
consolidated it) or 'absent' (not ingested). Until 2026-09-22 the notice
|
| 1333 |
+
printed 'run: py -m canlex.ingest --force C-46' either way, for a Criminal
|
| 1334 |
+
Code already stored current to 2026-07-21 against C-16's 2026-07-18.
|
| 1335 |
+
`reader` maps a processed filename to its stored current_to
|
| 1336 |
+
(_stored_current_to); pure given it, for tests."""
|
| 1337 |
+
reader = reader or _stored_current_to
|
| 1338 |
+
rows = []
|
| 1339 |
+
for code in bill["affects"]:
|
| 1340 |
+
stored = reader(f"{code}.json") or ""
|
| 1341 |
+
status = ("absent" if not stored
|
| 1342 |
+
else "consolidated" if stored >= bill["in_force"]
|
| 1343 |
+
else "predates")
|
| 1344 |
+
rows.append({"code": code, "stored": stored, "status": status})
|
| 1345 |
+
return rows
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
+
def pending_lines(bill, rows):
|
| 1349 |
+
"""The staleness report's lines for one pending bill now in force."""
|
| 1350 |
+
lines = [f" Bill {bill['bill']} ({bill['name']}) in force {bill['in_force']} "
|
| 1351 |
+
f"-> {', '.join(bill['affects'])}",
|
| 1352 |
+
f" {bill['note']}"]
|
| 1353 |
+
for r in rows:
|
| 1354 |
+
if r["status"] == "consolidated":
|
| 1355 |
+
lines.append(f" {r['code']}: stored consolidation current to {r['stored']}, "
|
| 1356 |
+
f"on or after {bill['in_force']} -- what came into force that "
|
| 1357 |
+
f"day is in the corpus; no re-ingest needed for it")
|
| 1358 |
+
elif r["status"] == "predates":
|
| 1359 |
+
lines.append(f" {r['code']}: stored consolidation current to {r['stored']} "
|
| 1360 |
+
f"predates {bill['in_force']} -- re-ingest once Justice Laws "
|
| 1361 |
+
f"has consolidated it (the legislation rows above say STALE)")
|
| 1362 |
+
lines.append(f" run: py -m canlex.ingest --force {r['code']} && "
|
| 1363 |
+
f"py -m canlex.ingest --fr --force {r['code']}")
|
| 1364 |
+
else:
|
| 1365 |
+
lines.append(f" {r['code']}: not ingested -- run: py -m canlex.ingest "
|
| 1366 |
+
f"{r['code']}")
|
| 1367 |
+
return lines
|
| 1368 |
+
|
| 1369 |
+
|
| 1370 |
+
def run(as_json=False):
|
| 1371 |
+
today = datetime.date.today().isoformat()
|
| 1372 |
+
leg = check_legislation()
|
| 1373 |
+
deleg = check_delegation()
|
| 1374 |
+
dirs = check_directives()
|
| 1375 |
+
nonxml = non_xml_currency()
|
| 1376 |
+
curated = check_curated(today)
|
| 1377 |
+
dmemo_idx = check_dmemo_index()
|
| 1378 |
+
tariff = check_tariff_edition(today)
|
| 1379 |
+
union = check_union()
|
| 1380 |
+
ciu_dec = check_ciu_decisions()
|
| 1381 |
+
policy = check_policy()
|
| 1382 |
+
policy_stale = [r for r in policy if r["status"] in ("stale", "missing-local")]
|
| 1383 |
+
guidance = check_guidance(policy, today=today)
|
| 1384 |
+
guidance_flag = guidance_flags(guidance)
|
| 1385 |
+
sections = check_curated_sections(today)
|
| 1386 |
+
sections_flag = curated_sections_flags(sections)
|
| 1387 |
+
pending = [dict(b, consolidation=pending_consolidation(b))
|
| 1388 |
+
for b in pending_in_force(today)]
|
| 1389 |
+
twins = check_bilingual()
|
| 1390 |
+
twins_bad = [r for r in twins if r["status"] in ("missing-fr", "fr-drift")]
|
| 1391 |
+
unwatched = unwatched_files(p.name for p in PROCESSED_DIR.glob("*.json"))
|
| 1392 |
+
stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
|
| 1393 |
+
errors = [r for r in leg if r["status"] == "error"]
|
| 1394 |
+
deleg_stale = [r for r in deleg if r["status"] == "stale"]
|
| 1395 |
+
dirs_stale = [r for r in dirs if r["status"] == "stale"]
|
| 1396 |
+
curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
|
| 1397 |
+
dmemo_stale = dmemo_idx["status"] == "stale"
|
| 1398 |
+
tariff_stale = tariff["status"] == "stale"
|
| 1399 |
+
union_stale = [r for r in union if r["status"] == "stale"]
|
| 1400 |
+
ciu_dec_stale = ciu_dec["status"] == "stale"
|
| 1401 |
+
errors_other = probe_errors(deleg, dirs, curated, union, policy, guidance,
|
| 1402 |
+
dmemo_idx, tariff, ciu_dec)
|
| 1403 |
+
|
| 1404 |
+
if as_json:
|
| 1405 |
+
print(json.dumps({"checked": today, "legislation": leg,
|
| 1406 |
+
"delegation": deleg, "directives": dirs,
|
| 1407 |
+
"non_xml": nonxml, "curated": curated,
|
| 1408 |
+
"dmemo_index": dmemo_idx, "tariff": tariff,
|
| 1409 |
+
"union": union, "ciu_decisions": ciu_dec,
|
| 1410 |
+
"pending_in_force": pending,
|
| 1411 |
+
"bilingual": twins, "unwatched": unwatched,
|
| 1412 |
+
"policy": policy,
|
| 1413 |
+
"guidance": guidance,
|
| 1414 |
+
"curated_sections": sections,
|
| 1415 |
+
"stale_count": (len(stale) + len(deleg_stale)
|
| 1416 |
+
+ len(dirs_stale)
|
| 1417 |
+
+ len(curated_flag)
|
| 1418 |
+
+ len(union_stale)
|
| 1419 |
+
+ len(twins_bad) + len(unwatched)
|
| 1420 |
+
+ len(policy_stale)
|
| 1421 |
+
+ len(guidance_flag)
|
| 1422 |
+
+ len(sections_flag)
|
| 1423 |
+
+ (1 if dmemo_stale else 0)
|
| 1424 |
+
+ (1 if tariff_stale else 0)),
|
| 1425 |
+
"error_count": len(errors) + len(errors_other)}, indent=2))
|
| 1426 |
+
else:
|
| 1427 |
+
print(f"CanLex corpus staleness check — {today}\n")
|
| 1428 |
+
print(f"Justice Laws legislation ({len(leg)} instruments):")
|
| 1429 |
+
for r in sorted(leg, key=lambda x: (x["status"] != "stale", x["short"])):
|
| 1430 |
+
if r["status"] == "stale":
|
| 1431 |
+
print(f" STALE {r['short']:34} local {r['local']} "
|
| 1432 |
+
f"upstream {r['remote']}")
|
| 1433 |
+
elif r["status"] == "missing-local":
|
| 1434 |
+
print(f" ABSENT {r['short']:34} configured but not ingested "
|
| 1435 |
+
f"(upstream {r['remote']})")
|
| 1436 |
+
elif r["status"] == "error":
|
| 1437 |
+
print(f" ERROR {r['short']:34} {r['error'][:50]}")
|
| 1438 |
+
elif r["status"] == "no-date":
|
| 1439 |
+
print(f" ? {r['short']:34} no current-date found upstream")
|
| 1440 |
+
if not stale and not errors:
|
| 1441 |
+
print(f" all {len(leg)} current (local matches or exceeds upstream)")
|
| 1442 |
+
if stale:
|
| 1443 |
+
codes = " ".join(r["code"] for r in stale)
|
| 1444 |
+
print(f"\n -> {len(stale)} drifted. Refresh: "
|
| 1445 |
+
f"py -m canlex.ingest --force {codes}\n"
|
| 1446 |
+
f" and the French twins: py -m canlex.ingest --fr "
|
| 1447 |
+
f"--force {codes}\n"
|
| 1448 |
+
f" then: py -m canlex.embed && py -m canlex.eval")
|
| 1449 |
+
|
| 1450 |
+
print(f"\nFrench twins ({len(twins)} instruments):")
|
| 1451 |
+
for r in twins_bad:
|
| 1452 |
+
if r["status"] == "missing-fr":
|
| 1453 |
+
print(f" ABSENT {r['code']:34} no French edition ingested")
|
| 1454 |
+
else:
|
| 1455 |
+
print(f" DRIFT {r['code']:34} fr {r['fr'] or '?'} != en "
|
| 1456 |
+
f"{r['en'] or '?'}")
|
| 1457 |
+
if twins_bad:
|
| 1458 |
+
codes = " ".join(r["code"] for r in twins_bad)
|
| 1459 |
+
print(f" -> py -m canlex.ingest --fr --force {codes} && "
|
| 1460 |
+
f"py -m canlex.embed")
|
| 1461 |
+
else:
|
| 1462 |
+
print(f" all {len(twins)} present at the English consolidation "
|
| 1463 |
+
f"date")
|
| 1464 |
+
|
| 1465 |
+
if unwatched:
|
| 1466 |
+
print("\nUNWATCHED corpus files (no staleness watch claims them --"
|
| 1467 |
+
" add the family to refresh._NON_XML, config.SOURCES or a"
|
| 1468 |
+
" dedicated check before the next refresh):")
|
| 1469 |
+
for f in unwatched:
|
| 1470 |
+
print(f" !! {f}")
|
| 1471 |
+
|
| 1472 |
+
print("\nDelegation instruments (upstream check):")
|
| 1473 |
+
for r in deleg:
|
| 1474 |
+
if r["status"] == "stale" and r["new"]:
|
| 1475 |
+
print(f" STALE {r['name']}: new upstream {', '.join(r['new'])} "
|
| 1476 |
+
f"not ingested")
|
| 1477 |
+
elif r["status"] == "stale":
|
| 1478 |
+
print(f" STALE {r['name']}: upstream {r['published'][0] or '?'} "
|
| 1479 |
+
f"!= stored {r['have'][0] or '?'}")
|
| 1480 |
+
elif r["status"] == "error":
|
| 1481 |
+
print(f" ERROR {r['name']}: {r['error'][:50]}")
|
| 1482 |
+
elif r["status"] == "no-version":
|
| 1483 |
+
print(f" ? {r['name']}: no version stamp found upstream")
|
| 1484 |
+
elif len(r["published"]) > 1: # CBSA: a set of dated instruments
|
| 1485 |
+
print(f" ok {r['name']}: current "
|
| 1486 |
+
f"({len(r['published'])} instruments, latest "
|
| 1487 |
+
f"{max(r['published'])})")
|
| 1488 |
+
else: # IRCC: a single version stamp
|
| 1489 |
+
shown = r["published"][0] if r["published"] else "current"
|
| 1490 |
+
print(f" ok {r['name']}: current ({shown})")
|
| 1491 |
+
if deleg_stale:
|
| 1492 |
+
print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
|
| 1493 |
+
"then py -m canlex.delegation && py -m canlex.embed")
|
| 1494 |
+
|
| 1495 |
+
print(f"\nNJC directives (upstream check, {len(dirs)} ingested):")
|
| 1496 |
+
for r in dirs:
|
| 1497 |
+
if r["status"] == "stale":
|
| 1498 |
+
print(f" STALE {r['title']}: upstream {r['remote']} "
|
| 1499 |
+
f"!= stored {r['local'] or r['stored'] or '?'}")
|
| 1500 |
+
elif r["status"] == "error":
|
| 1501 |
+
print(f" ERROR {r['title']}: {r['error'][:50]}")
|
| 1502 |
+
elif r["status"] == "no-date":
|
| 1503 |
+
print(f" ? {r['title']}: no effective date found upstream")
|
| 1504 |
+
else:
|
| 1505 |
+
print(f" ok {r['title']}: current ({r['remote']})")
|
| 1506 |
+
if dirs_stale:
|
| 1507 |
+
print(" -> re-ingest: py -m canlex.directive --force && "
|
| 1508 |
+
"py -m canlex.embed")
|
| 1509 |
+
|
| 1510 |
+
print("\nOther sources (stored currency; re-run the ingester to refresh):")
|
| 1511 |
+
for r in nonxml:
|
| 1512 |
+
mark = " " if r["present"] else "!"
|
| 1513 |
+
print(f" {mark}{r['label']:14} {str(r['chunks']):>5} chunks "
|
| 1514 |
+
f"newest {r['newest'] or 'n/a':12} {r['cmd']}")
|
| 1515 |
+
|
| 1516 |
+
print("\nCurated datasets (age + regen-drift check):")
|
| 1517 |
+
for r in curated:
|
| 1518 |
+
if r["status"] == "aging":
|
| 1519 |
+
print(f" AGING {r['file']}: reviewed {r['reviewed']} — older "
|
| 1520 |
+
f"than {_CURATED_MAX_AGE_DAYS} days; re-verify the state "
|
| 1521 |
+
f"rows against their statutes and bump 'reviewed'")
|
| 1522 |
+
elif r["status"] == "drift":
|
| 1523 |
+
print(f" DRIFT {r['file']}: {r['error']}")
|
| 1524 |
+
elif r["status"] == "error":
|
| 1525 |
+
print(f" ERROR {r['file']}: {r['error'][:60]}")
|
| 1526 |
+
else:
|
| 1527 |
+
print(f" ok {r['file']}: reviewed {r['reviewed'] or '?'}")
|
| 1528 |
+
|
| 1529 |
+
_print_curated_sections(sections)
|
| 1530 |
+
|
| 1531 |
+
if dmemo_idx["status"] == "stale":
|
| 1532 |
+
drifted = dmemo_idx.get("drifted") or []
|
| 1533 |
+
named = (f" ({', '.join(drifted[:6])}"
|
| 1534 |
+
f"{', …' if len(drifted) > 6 else ''})" if drifted else "")
|
| 1535 |
+
print(f"\nD-memo index: STALE — newest upstream 'Last updated' "
|
| 1536 |
+
f"{dmemo_idx['remote']} > stored {dmemo_idx['stored']}"
|
| 1537 |
+
f"{named}; "
|
| 1538 |
+
f"run py -m canlex.dmemo --force && py -m canlex.embed")
|
| 1539 |
+
elif dmemo_idx["status"] == "error":
|
| 1540 |
+
print(f"\nD-memo index: ERROR {dmemo_idx['error'][:60]}")
|
| 1541 |
+
else:
|
| 1542 |
+
print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
|
| 1543 |
+
f"<= stored {dmemo_idx['stored'] or '?'})")
|
| 1544 |
+
|
| 1545 |
+
if tariff["status"] == "stale":
|
| 1546 |
+
print(f"\nCustoms Tariff edition: STALE — CBSA has published the "
|
| 1547 |
+
f"{tariff['remote']} edition (ingested: {tariff['stored']}); "
|
| 1548 |
+
f"bump the year in tariff_schedule.SOURCES and re-ingest")
|
| 1549 |
+
else:
|
| 1550 |
+
print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
|
| 1551 |
+
f"current)")
|
| 1552 |
+
|
| 1553 |
+
n_union_ok = sum(1 for r in union if r["status"] == "ok")
|
| 1554 |
+
print(f"\nCIU/PSAC union documents (upstream Last-Modified check, "
|
| 1555 |
+
f"{len(union)} documents):")
|
| 1556 |
+
for r in union:
|
| 1557 |
+
if r["status"] == "stale":
|
| 1558 |
+
print(f" STALE {r['short']} ({r['lang']}): upstream "
|
| 1559 |
+
f"modified {r['remote']} after our fetch — run "
|
| 1560 |
+
f"py -m canlex.ciu --force && py -m canlex.embed")
|
| 1561 |
+
elif r["status"] == "error":
|
| 1562 |
+
print(f" ERROR {r['short']}: {r['error'][:55]}")
|
| 1563 |
+
if not union_stale and not any(r["status"] == "error" for r in union):
|
| 1564 |
+
print(f" all {n_union_ok} current (upstream unchanged since fetch)")
|
| 1565 |
+
|
| 1566 |
+
print(f"\nEmployer policy pages (upstream date-modified check):")
|
| 1567 |
+
for r in policy:
|
| 1568 |
+
tag = r["short"] + (" (fr)" if r.get("lang", "en") == "fr" else "")
|
| 1569 |
+
if r["status"] == "stale":
|
| 1570 |
+
print(f" STALE {tag}: upstream modified {r['remote']} "
|
| 1571 |
+
f"> stored {r['stored']} -- run py -m canlex.conduct "
|
| 1572 |
+
f"--force && py -m canlex.embed")
|
| 1573 |
+
elif r["status"] == "missing-local":
|
| 1574 |
+
print(f" ABSENT {tag}: configured but not ingested")
|
| 1575 |
+
elif r["status"] == "error":
|
| 1576 |
+
print(f" ERROR {tag}: {r['error'][:55]}")
|
| 1577 |
+
else:
|
| 1578 |
+
print(f" ok {tag}: current "
|
| 1579 |
+
f"({r['remote'] or r['stored'] or '?'})")
|
| 1580 |
+
|
| 1581 |
+
_print_guidance(guidance)
|
| 1582 |
+
|
| 1583 |
+
if ciu_dec["status"] == "stale":
|
| 1584 |
+
print(f"\nCIU Important-Decisions page: UPDATED "
|
| 1585 |
+
f"({ciu_dec['remote']} > {ciu_dec['stored']}) — the union "
|
| 1586 |
+
f"may have flagged a new decision; review "
|
| 1587 |
+
f"{_CIU_DECISIONS} and, after ingesting anything new, "
|
| 1588 |
+
f"update {_CIU_DEC_MARKER.name} to {ciu_dec['remote']}")
|
| 1589 |
+
elif ciu_dec["status"] == "error":
|
| 1590 |
+
print(f"\nCIU Important-Decisions page: ERROR "
|
| 1591 |
+
f"{ciu_dec['error'][:55]}")
|
| 1592 |
+
else:
|
| 1593 |
+
print(f"\nCIU Important-Decisions page: ok (last modified "
|
| 1594 |
+
f"{ciu_dec['remote'] or '?'})")
|
| 1595 |
+
|
| 1596 |
+
if pending:
|
| 1597 |
+
print("\nPending bills now in force (is the stored consolidation "
|
| 1598 |
+
"on or after the in-force date?):")
|
| 1599 |
+
for b in pending:
|
| 1600 |
+
print("\n".join(pending_lines(b, b["consolidation"])))
|
| 1601 |
+
|
| 1602 |
+
if errors_other and not as_json:
|
| 1603 |
+
print(f"\n{len(errors_other)} probe error(s) in the other watches "
|
| 1604 |
+
f"(exit 1: a watch that cannot reach its source has not checked it):")
|
| 1605 |
+
for kind, what, err in errors_other[:12]:
|
| 1606 |
+
print(f" ERROR {kind}: {what}: {err[:60]}")
|
| 1607 |
+
if len(errors_other) > 12:
|
| 1608 |
+
print(f" ... and {len(errors_other) - 12} more")
|
| 1609 |
+
|
| 1610 |
+
return 1 if (stale or errors or errors_other or deleg_stale or dirs_stale
|
| 1611 |
+
or curated_flag or dmemo_stale or tariff_stale
|
| 1612 |
+
or union_stale or ciu_dec_stale or twins_bad
|
| 1613 |
+
or unwatched or policy_stale or guidance_flag
|
| 1614 |
+
or sections_flag) else 0
|
| 1615 |
+
|
| 1616 |
+
|
| 1617 |
+
def probe_errors(deleg, dirs, curated, union, policy, guidance,
|
| 1618 |
+
dmemo_idx, tariff, ciu_dec):
|
| 1619 |
+
"""[(watch, item, error)] for every row of the non-legislation watches
|
| 1620 |
+
whose status is 'error'. Until 2026-09-21 only the legislation rows'
|
| 1621 |
+
errors reached the exit status: a broken PowerShell probe printed 52
|
| 1622 |
+
ERROR rows in the guidance watch and the check passed, and
|
| 1623 |
+
check_tariff_edition read a network failure as 'ok'."""
|
| 1624 |
+
out = []
|
| 1625 |
+
for kind, rows, label in (("delegation", deleg, "name"), ("directives", dirs, "title"),
|
| 1626 |
+
("curated", curated, "file"), ("union", union, "short"),
|
| 1627 |
+
("policy", policy, "short")):
|
| 1628 |
+
out += [(kind, str(r.get(label, "?")), r.get("error", "")) for r in rows or []
|
| 1629 |
+
if r.get("status") == "error"]
|
| 1630 |
+
for kind in ("enf", "pdi", "irb"):
|
| 1631 |
+
out += [(f"guidance/{kind}", str(r.get("url") or r.get("chapter") or r.get("page") or "?"),
|
| 1632 |
+
r.get("error", "")) for r in (guidance or {}).get(kind) or []
|
| 1633 |
+
if r.get("status") == "error"]
|
| 1634 |
+
for kind, row in (("dmemo-index", dmemo_idx), ("tariff-edition", tariff),
|
| 1635 |
+
("ciu-decisions", ciu_dec)):
|
| 1636 |
+
if (row or {}).get("status") == "error":
|
| 1637 |
+
out.append((kind, "probe", row.get("error", "")))
|
| 1638 |
+
return out
|
| 1639 |
+
|
| 1640 |
+
|
| 1641 |
+
def main():
|
| 1642 |
+
args = sys.argv[1:]
|
| 1643 |
+
as_json = "--json" in args
|
| 1644 |
+
if "--ack-guidance" in args:
|
| 1645 |
+
# After someone has looked at a CHECK / UPDATED guidance row and found
|
| 1646 |
+
# nothing to re-ingest: record the upstream date as seen.
|
| 1647 |
+
sys.exit(ack_guidance())
|
| 1648 |
+
# --check is the default mode; accepted for forward-compatibility.
|
| 1649 |
+
sys.exit(run(as_json=as_json))
|
| 1650 |
+
|
| 1651 |
+
|
| 1652 |
+
if __name__ == "__main__":
|
| 1653 |
+
main()
|
canlex/rehab.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/rerank.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-encoder reranker over ONNX Runtime (local, key-free)."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import onnxruntime as ort
|
| 6 |
+
from huggingface_hub import hf_hub_download
|
| 7 |
+
from huggingface_hub.errors import LocalEntryNotFoundError
|
| 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 |
+
_MAX_QUERY_TOKENS = 256
|
| 26 |
+
# The query's token budget in a (query, document) pair.
|
| 27 |
+
# only_second truncation keeps the query whole and RAISES
|
| 28 |
+
# when the query alone overflows MAX_TOKENS (about 509
|
| 29 |
+
# tokens, 400 words of English), which took the whole
|
| 30 |
+
# search() down (code audit 2026-09-21). A query past the
|
| 31 |
+
# budget is cut to its first _MAX_QUERY_TOKENS tokens, on
|
| 32 |
+
# a token boundary, before pairing, so the document keeps
|
| 33 |
+
# at least MAX_TOKENS - _MAX_QUERY_TOKENS - 4 tokens. A
|
| 34 |
+
# query within the budget (the MCP tool's 500-character
|
| 35 |
+
# cap is about 130 tokens of English) is scored exactly
|
| 36 |
+
# as before.
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def hub_file(repo, filename):
|
| 40 |
+
"""The local path of one model file: the Hugging Face cache first, the
|
| 41 |
+
network only when the file is not cached (LocalEntryNotFoundError).
|
| 42 |
+
|
| 43 |
+
A plain hf_hub_download revalidates every file against huggingface.co
|
| 44 |
+
even when it is cached: 6-7 HEAD requests per cold start, the first tool
|
| 45 |
+
call 12.5-13.3 s online, 24-48 s with the network refused and 106 s
|
| 46 |
+
black-holed (code audit 2026-09-21, perf-07). A cached file is now read
|
| 47 |
+
with no request at all; a fresh clone still downloads once. Under
|
| 48 |
+
HF_HUB_OFFLINE=1 a missing file fails as before."""
|
| 49 |
+
try:
|
| 50 |
+
return hf_hub_download(repo, filename, local_files_only=True)
|
| 51 |
+
except LocalEntryNotFoundError:
|
| 52 |
+
return hf_hub_download(repo, filename)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ORT keeps the high-water mark of its CPU memory arena for the life of the
|
| 56 |
+
# session: the first search after load grew the process by +490 MB RSS /
|
| 57 |
+
# +1,237 MB private (two 16-document reranker batches), and it never came
|
| 58 |
+
# back. Shrinking the arena at the end of every run returns what a run
|
| 59 |
+
# allocated; outputs are bit-identical and the per-batch time is within
|
| 60 |
+
# noise (983-1160 vs 985-1079 ms; code audit 2026-09-21, perf-04b).
|
| 61 |
+
_ARENA_SHRINK = ("memory.enable_memory_arena_shrinkage", "cpu:0")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def make_run_options():
|
| 65 |
+
"""The RunOptions every session.run() of the reranker and the embedder
|
| 66 |
+
passes: shrink the CPU arena after each run (see _ARENA_SHRINK)."""
|
| 67 |
+
options = ort.RunOptions()
|
| 68 |
+
options.add_run_config_entry(*_ARENA_SHRINK)
|
| 69 |
+
return options
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def make_ort_session(model_path):
|
| 73 |
+
"""A CPU InferenceSession tuned for shared, small-vCPU hosts.
|
| 74 |
+
|
| 75 |
+
Two deviations from onnxruntime defaults: (1) spin-waiting between requests
|
| 76 |
+
is disabled -- by default ORT worker threads busy-spin after an inference
|
| 77 |
+
for a microsecond-scale latency win, which on the Space's shared 2 vCPUs
|
| 78 |
+
burns quota between queries; (2) the intra-op thread count is
|
| 79 |
+
env-configurable (CANLEX_ORT_THREADS) so concurrent sessions can be capped
|
| 80 |
+
below core count if contention is ever observed. Unset, ORT's default
|
| 81 |
+
(all cores) is kept, so single-query numerics and speed are unchanged."""
|
| 82 |
+
opts = ort.SessionOptions()
|
| 83 |
+
threads = int(os.environ.get("CANLEX_ORT_THREADS", "0"))
|
| 84 |
+
if threads:
|
| 85 |
+
opts.intra_op_num_threads = threads
|
| 86 |
+
opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
|
| 87 |
+
opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
|
| 88 |
+
return ort.InferenceSession(model_path, sess_options=opts,
|
| 89 |
+
providers=["CPUExecutionProvider"])
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class Reranker:
|
| 93 |
+
"""Cross-encoder that scores (query, section) pairs for relevance.
|
| 94 |
+
|
| 95 |
+
Loads a BGE reranker as ONNX and runs it on CPU -- no API key; the model is
|
| 96 |
+
downloaded once and cached. A cross-encoder reads the query and section
|
| 97 |
+
jointly, so it judges true relevance far better than the BM25/embedding
|
| 98 |
+
similarities used to build the candidate pool.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self):
|
| 102 |
+
model_path = hub_file(RERANK_REPO, RERANK_ONNX)
|
| 103 |
+
tok_path = hub_file(RERANK_REPO, "tokenizer.json")
|
| 104 |
+
self.session = make_ort_session(model_path)
|
| 105 |
+
self.run_options = make_run_options()
|
| 106 |
+
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 107 |
+
|
| 108 |
+
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 109 |
+
# only_second keeps the query intact and truncates the (longer) section.
|
| 110 |
+
self.tokenizer.enable_truncation(max_length=MAX_TOKENS, strategy="only_second")
|
| 111 |
+
# An untruncated twin measures the query (see cap_query): a truncated
|
| 112 |
+
# tokenizer cannot encode a lone sequence under only_second.
|
| 113 |
+
self._measure = Tokenizer.from_file(tok_path)
|
| 114 |
+
|
| 115 |
+
@staticmethod
|
| 116 |
+
def cap_query(tokenizer, query, max_tokens=_MAX_QUERY_TOKENS):
|
| 117 |
+
"""`query` cut to its first `max_tokens` tokens of `tokenizer` (an
|
| 118 |
+
untruncated tokenizer), on a token boundary of the original string;
|
| 119 |
+
the query itself, untouched, when it fits."""
|
| 120 |
+
enc = tokenizer.encode(query, add_special_tokens=False)
|
| 121 |
+
if len(enc.ids) <= max_tokens:
|
| 122 |
+
return query
|
| 123 |
+
return query[:enc.offsets[max_tokens - 1][1]]
|
| 124 |
+
|
| 125 |
+
def score(self, query, documents):
|
| 126 |
+
"""Return a relevance logit for each document paired with the query.
|
| 127 |
+
|
| 128 |
+
Higher means more relevant. The returned list is aligned with `documents`.
|
| 129 |
+
|
| 130 |
+
Each (query, document) pair is scored in an ONNX run of its own -- a
|
| 131 |
+
contract, not a tuning choice. The int8 model quantizes activations
|
| 132 |
+
per run over the whole input tensor (DynamicQuantizeLinear), so in a
|
| 133 |
+
padded batch a pair's logit moved with its batch companions: all 192
|
| 134 |
+
of a 12 x 16 sample moved, by up to 8.8. One run per pair makes a
|
| 135 |
+
score a property of the pair alone -- the same in the rerank pool,
|
| 136 |
+
the pinpoint pass and a test -- and carries no padding:
|
| 137 |
+
search(top_k=6) p50 over 40 eval questions 1,425 -> 951 ms. Adopted
|
| 138 |
+
on the eval (audit follow-up 2026-09-21, group A item 8a): legacy
|
| 139 |
+
Hit@3 .9583 -> .9688, MRR .8651 -> .8668; holdout (undated) Hit@3
|
| 140 |
+
.9250 -> .9500, MRR .8383 -> .8404; no Hit@1 moved; top-6 order
|
| 141 |
+
moves on 209 of 339 questions.
|
| 142 |
+
"""
|
| 143 |
+
if not documents:
|
| 144 |
+
return []
|
| 145 |
+
query = self.cap_query(self._measure, query)
|
| 146 |
+
encs = self.tokenizer.encode_batch(
|
| 147 |
+
[(query, doc[:_MAX_DOC_CHARS]) for doc in documents])
|
| 148 |
+
return [self._logit(enc) for enc in encs]
|
| 149 |
+
|
| 150 |
+
def _logit(self, enc):
|
| 151 |
+
"""The logit of one encoded (query, document) pair, run alone."""
|
| 152 |
+
feed = {"input_ids": np.asarray([enc.ids], dtype=np.int64),
|
| 153 |
+
"attention_mask": np.asarray([enc.attention_mask], dtype=np.int64)}
|
| 154 |
+
if "token_type_ids" in self.input_names:
|
| 155 |
+
feed["token_type_ids"] = np.asarray([enc.type_ids], dtype=np.int64)
|
| 156 |
+
logits = self.session.run(None, feed, self.run_options)[0]
|
| 157 |
+
return float(np.asarray(logits, dtype=np.float32).reshape(-1)[0])
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def main():
|
| 161 |
+
import time
|
| 162 |
+
print(f"Loading {RERANK_REPO} ({RERANK_ONNX}) ...")
|
| 163 |
+
reranker = Reranker()
|
| 164 |
+
query = "powers of arrest without warrant"
|
| 165 |
+
docs = [
|
| 166 |
+
"Arrest without warrant. A peace officer may arrest without warrant a "
|
| 167 |
+
"person who has committed a criminal offence.",
|
| 168 |
+
"Definitions. In this Act, fish means any fish and includes shellfish, "
|
| 169 |
+
"crustaceans and marine animals.",
|
| 170 |
+
"Importation. It is prohibited to import cannabis except as authorized "
|
| 171 |
+
"under this Act.",
|
| 172 |
+
]
|
| 173 |
+
start = time.perf_counter()
|
| 174 |
+
scores = reranker.score(query, docs)
|
| 175 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 176 |
+
print(f"\nQuery: {query!r}")
|
| 177 |
+
for doc, score in sorted(zip(docs, scores), key=lambda x: x[1], reverse=True):
|
| 178 |
+
print(f" {score:8.3f} {doc[:62]}")
|
| 179 |
+
print(f"\n{elapsed:.0f} ms for {len(docs)} documents")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
main()
|
canlex/reviewed_code.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/screening.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/server.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
canlex/sweep.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.012, 0.024, 0.05, 0.1], 0.024),
|
| 31 |
+
("CANLEX_REG_PENALTY", [0.002, 0.004, 0.008, 0.016], 0.004),
|
| 32 |
+
("CANLEX_BACKMATTER_PENALTY",[0.002, 0.004, 0.008, 0.016], 0.004),
|
| 33 |
+
# The hierarchy prior's two guidance tiers (roadmap T5.4): official
|
| 34 |
+
# guidance, and CanLex's own curated commentary below it. Swept together
|
| 35 |
+
# because they trade against each other on the same questions.
|
| 36 |
+
("CANLEX_MEMO_PENALTY", [0.006, 0.008, 0.010], 0.008),
|
| 37 |
+
("CANLEX_COMMENTARY_PENALTY",[0.0, 0.002, 0.004, 0.006], 0.0),
|
| 38 |
+
# IRCC's applicant-facing pages, below official guidance (T5.4; the
|
| 39 |
+
# IRCC 'Overcome criminal convictions' page outranked IRPR s. 18 on a
|
| 40 |
+
# legacy rehabilitation question by 0.0135).
|
| 41 |
+
("CANLEX_APPLICANT_PENALTY", [0.0, 0.008, 0.014, 0.02], 0.0),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
_METRIC_RE = re.compile(
|
| 46 |
+
r"Hit@1:\s*([\d.]+).*?Hit@3:\s*([\d.]+).*?Hit@5:\s*([\d.]+).*?"
|
| 47 |
+
r"Hit@10:\s*([\d.]+).*?MRR:\s*([\d.]+)", re.S)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _run_eval(env_overrides: dict[str, float]) -> dict[str, float]:
|
| 51 |
+
"""Run canlex.eval once with the given env overrides; return metrics dict."""
|
| 52 |
+
env = dict(os.environ)
|
| 53 |
+
env["CANLEX_EVAL_SWEEP"] = "1" # holdout questions stay sweep-blind
|
| 54 |
+
for k, v in env_overrides.items():
|
| 55 |
+
env[k] = f"{v}"
|
| 56 |
+
proc = subprocess.run(
|
| 57 |
+
[sys.executable, "-u", "-m", "canlex.eval"],
|
| 58 |
+
capture_output=True, text=True, env=env, cwd=ROOT,
|
| 59 |
+
)
|
| 60 |
+
if proc.returncode != 0:
|
| 61 |
+
raise RuntimeError(f"eval failed (exit {proc.returncode}):\n"
|
| 62 |
+
f"{proc.stderr[-800:]}")
|
| 63 |
+
m = _METRIC_RE.search(proc.stdout)
|
| 64 |
+
if not m:
|
| 65 |
+
raise RuntimeError(f"could not parse eval output:\n{proc.stdout[-800:]}")
|
| 66 |
+
h1, h3, h5, h10, mrr = (float(x) for x in m.groups())
|
| 67 |
+
n_misses = proc.stdout.count("miss(es)") # 0 if we end up at 100
|
| 68 |
+
return {"hit1": h1, "hit3": h3, "hit5": h5, "hit10": h10, "mrr": mrr,
|
| 69 |
+
"stdout": proc.stdout}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _score(metrics: dict[str, float]) -> tuple[float, float]:
|
| 73 |
+
"""Order by Hit@5 then MRR (both higher is better)."""
|
| 74 |
+
return (metrics["hit5"], metrics["mrr"])
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
log = LOG.open("w", encoding="utf-8")
|
| 80 |
+
log.write(f"# CanLex tuning sweep -- {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 81 |
+
print(f"# CanLex tuning sweep -- writing log to {LOG}\n")
|
| 82 |
+
|
| 83 |
+
current = {name: default for name, _values, default in KNOBS}
|
| 84 |
+
all_runs: list[dict] = []
|
| 85 |
+
|
| 86 |
+
# Baseline at current defaults.
|
| 87 |
+
print("Baseline:", current)
|
| 88 |
+
log.write(f"\nBaseline: {current}\n")
|
| 89 |
+
baseline = _run_eval(current)
|
| 90 |
+
print(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}")
|
| 91 |
+
log.write(f" Hit@5={baseline['hit5']:.3f} MRR={baseline['mrr']:.3f}\n")
|
| 92 |
+
all_runs.append({"values": dict(current), "metrics":
|
| 93 |
+
{k: baseline[k] for k in ("hit1", "hit3", "hit5", "hit10", "mrr")}})
|
| 94 |
+
best_metrics = baseline
|
| 95 |
+
|
| 96 |
+
for name, values, _default in KNOBS:
|
| 97 |
+
print(f"\nSweeping {name} in {values} (others held at {current})...")
|
| 98 |
+
log.write(f"\nSweeping {name} in {values} (others held at {current})\n")
|
| 99 |
+
local_best = (current[name], _score(best_metrics), best_metrics)
|
| 100 |
+
for v in values:
|
| 101 |
+
if v == current[name]:
|
| 102 |
+
# Re-use the already-measured baseline at this knob value.
|
| 103 |
+
metrics = best_metrics
|
| 104 |
+
else:
|
| 105 |
+
run_values = dict(current, **{name: v})
|
| 106 |
+
metrics = _run_eval(run_values)
|
| 107 |
+
row = (f" {name}={v!r:<8s} -> Hit@1={metrics['hit1']:.3f} "
|
| 108 |
+
f"Hit@3={metrics['hit3']:.3f} Hit@5={metrics['hit5']:.3f} "
|
| 109 |
+
f"Hit@10={metrics['hit10']:.3f} MRR={metrics['mrr']:.3f}")
|
| 110 |
+
print(row); log.write(row + "\n"); log.flush()
|
| 111 |
+
all_runs.append({"values": dict(current, **{name: v}),
|
| 112 |
+
"metrics": {k: metrics[k] for k in
|
| 113 |
+
("hit1", "hit3", "hit5", "hit10", "mrr")}})
|
| 114 |
+
score = _score(metrics)
|
| 115 |
+
if score > local_best[1]:
|
| 116 |
+
local_best = (v, score, metrics)
|
| 117 |
+
if local_best[0] != current[name]:
|
| 118 |
+
print(f" ! {name}: {current[name]} -> {local_best[0]} "
|
| 119 |
+
f"(Hit@5 {best_metrics['hit5']:.3f} -> {local_best[2]['hit5']:.3f})")
|
| 120 |
+
log.write(f" ! {name}: {current[name]} -> {local_best[0]}\n")
|
| 121 |
+
current[name] = local_best[0]
|
| 122 |
+
best_metrics = local_best[2]
|
| 123 |
+
|
| 124 |
+
print(f"\nBest: {current}")
|
| 125 |
+
print(f" Hit@1={best_metrics['hit1']:.3f} Hit@3={best_metrics['hit3']:.3f} "
|
| 126 |
+
f"Hit@5={best_metrics['hit5']:.3f} Hit@10={best_metrics['hit10']:.3f} "
|
| 127 |
+
f"MRR={best_metrics['mrr']:.3f}")
|
| 128 |
+
log.write(f"\nBest: {current}\n Hit@1={best_metrics['hit1']:.3f} "
|
| 129 |
+
f"Hit@5={best_metrics['hit5']:.3f} MRR={best_metrics['mrr']:.3f}\n")
|
| 130 |
+
log.close()
|
| 131 |
+
SUMMARY.write_text(json.dumps({
|
| 132 |
+
"best": current,
|
| 133 |
+
"best_metrics": {k: best_metrics[k] for k in
|
| 134 |
+
("hit1", "hit3", "hit5", "hit10", "mrr")},
|
| 135 |
+
"runs": all_runs,
|
| 136 |
+
}, indent=2), encoding="utf-8")
|
| 137 |
+
print(f"\nLog: {LOG}\nSummary: {SUMMARY}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
main()
|
canlex/synonyms.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# French for the IRB Chairperson's Guidelines, which exist in English only
|
| 54 |
+
(r"(?:directives?|lignes? directrices?) (?:de la|du) (?:cisr|président)",
|
| 55 |
+
"Chairperson's Guideline Immigration and Refugee Board"),
|
| 56 |
+
# Language of service: the Official Languages Act never says 'French'
|
| 57 |
+
# or 'English' in Part IV -- its words are 'either official language',
|
| 58 |
+
# 'communicate with and obtain available services', 'significant demand'.
|
| 59 |
+
(r"(?:served|service|serve|communicate) (?:in|en) (?:french|english|français|anglais)",
|
| 60 |
+
"communicate with and obtain available services in either official language significant demand"),
|
| 61 |
+
# Device examinations: officers say 'cell phone' / 'laptop'; Customs Act
|
| 62 |
+
# s. 99 says 'goods' and ENF 12 s. 6.15 'digital devices'. Without the
|
| 63 |
+
# bridge an arrest-context SCC case (Fearon) and two unrelated 'without
|
| 64 |
+
# warrant' marginal notes outrank the governing border authorities.
|
| 65 |
+
(r"(?:cell ?phones?|smart ?phones?|mobile (?:phones?|devices?)|laptops?"
|
| 66 |
+
r"|tablets?|digital devices?|electronic devices?)",
|
| 67 |
+
"examination of goods digital device electronic device"),
|
| 68 |
+
# Money: 'cash', 'USD', '10k' never reach PCMLTFA s. 12's own words
|
| 69 |
+
# ('currency or monetary instruments ... prescribed amount').
|
| 70 |
+
(r"(?:cash|us ?dollars|usd|10 ?k|ten thousand dollars)",
|
| 71 |
+
"currency or monetary instruments report prescribed amount"),
|
| 72 |
+
(r"flagpol(?:e|ing)", "leaving canada and reentering port of entry"),
|
| 73 |
+
# Tax evasion: 'evasion' stems to 'evas' and the Income Tax Act's own
|
| 74 |
+
# words ('evaded or attempted to evade ... payment of taxes') stem to
|
| 75 |
+
# 'evad', and s. 239 is titled only 'Other offences and punishment' -- so
|
| 76 |
+
# without the bridge "penalty for tax evasion" never reaches s. 239.
|
| 77 |
+
# 'tax' is required in the trigger, so customs-duty evasion is untouched.
|
| 78 |
+
(r"(?:tax(?:es)? evasion|evasion of (?:income )?tax(?:es)?"
|
| 79 |
+
r"|evad(?:e|ed|ing) (?:income )?tax(?:es)?|tax fraud"
|
| 80 |
+
r"|[ée]vasion fiscale|fraude fiscale)",
|
| 81 |
+
"evaded or attempted to evade payment of taxes false or deceptive "
|
| 82 |
+
"statements in a return offences and punishment"),
|
| 83 |
+
# The single most common US-screening term: bridge DUI shorthand to the
|
| 84 |
+
# Criminal Code s. 320.14 vocabulary the equivalency analysis turns on.
|
| 85 |
+
(r"(?:dwi|dui|drunk driving)",
|
| 86 |
+
"operation while impaired blood alcohol concentration conveyance"),
|
| 87 |
+
# Obstructing or assaulting an officer. The everyday phrasing matches the
|
| 88 |
+
# generic obstruction sections of the Quarantine and Excise Acts word for
|
| 89 |
+
# word ('hinder or wilfully obstruct ... duties or functions'), while the
|
| 90 |
+
# four provisions an officer actually needs each speak differently:
|
| 91 |
+
# Customs Act s. 153.1 'interfere with or molest', IRPA s. 129(1)(d)
|
| 92 |
+
# 'obstructs or impedes', Criminal Code s. 129 'resists or wilfully
|
| 93 |
+
# obstructs' and s. 270 'assaults a public officer or peace officer'.
|
| 94 |
+
# The trigger requires the conduct word to GOVERN 'officer' -- at most two
|
| 95 |
+
# words in between -- so 'what factors impede an immigration officer' and
|
| 96 |
+
# 'grab the officer's business card' are untouched. It also takes the
|
| 97 |
+
# plain words an officer writes a fact pattern in ('shoved', 'spat at'),
|
| 98 |
+
# none of which appears in any of the four provisions. Verbs that are
|
| 99 |
+
# common in ordinary legal prose ('impede', 'threaten', 'push', 'grab')
|
| 100 |
+
# are deliberately left out: they false-fire more often than they help.
|
| 101 |
+
(r"(?:obstruct|hinder|resist|assault|shov|punch|strike|struck"
|
| 102 |
+
r"|kick|spit|spat|swung|swing)\w*"
|
| 103 |
+
r"(?:\W+\w+){0,2}\W+(?:border services |peace |public |customs )?officers?",
|
| 104 |
+
"interfere with or molest an officer hindering an officer "
|
| 105 |
+
"obstructs or impedes an officer in the performance of the officer's "
|
| 106 |
+
"duties offences relating to officers "
|
| 107 |
+
"resists or wilfully obstructs a public officer or peace officer in the "
|
| 108 |
+
"execution of his duty "
|
| 109 |
+
"assaults a public officer or peace officer engaged in the execution of "
|
| 110 |
+
"his duty"),
|
| 111 |
+
# Arrest at the border. Customs Act s. 163.5 is what lends a designated
|
| 112 |
+
# officer the peace officer's arrest powers, but it never says 'arrest
|
| 113 |
+
# without warrant' -- its words are 'the powers and obligations of a peace
|
| 114 |
+
# officer under sections 495 and 497'. 'Fresh pursuit' is the officer's
|
| 115 |
+
# term for Criminal Code s. 494(1)(b)(ii)'s 'freshly pursued'.
|
| 116 |
+
(r"(?:arrest(?:s|ed|ing)? without (?:a )?warrant"
|
| 117 |
+
r"|without (?:a )?warrant arrest\w*"
|
| 118 |
+
r"|(?:found|finds|find) committing"
|
| 119 |
+
r"|fresh(?:ly)? pursu\w+|continuous pursuit)",
|
| 120 |
+
"arrest without warrant finds committing a criminal offence escaping "
|
| 121 |
+
"from and freshly pursued powers and obligations of a peace officer "
|
| 122 |
+
"under sections 495 and 497 designated officer at a customs office"),
|
| 123 |
+
(r"personal exemption",
|
| 124 |
+
"returning persons exemption duty free allowance"),
|
| 125 |
+
# Health-plan vocabulary. Drug brand and molecule names appear nowhere in
|
| 126 |
+
# the PSHCP documents: the rule that answers a Botox question is the
|
| 127 |
+
# exclusion of prescription drugs 'used for cosmetic purposes'.
|
| 128 |
+
(r"(?:botox|botulinum(?: toxin)?|cosmetic (?:drugs?|purposes?|treatments?"
|
| 129 |
+
r"|procedures?|surgery))",
|
| 130 |
+
"prescription drugs used for cosmetic purposes exclusions expenses for "
|
| 131 |
+
"services or products for cosmetic purposes only prior authorization"),
|
| 132 |
+
# Likewise the plans never name a provincial insurer: RAMQ, OHIP and the
|
| 133 |
+
# rest are all 'a provincial/territorial health insurance plan' that the
|
| 134 |
+
# PSHCP supplements, with a co-ordination rule for a Quebec resident's
|
| 135 |
+
# prescription drug claim. Only the insurers' own names trigger this:
|
| 136 |
+
# 'provincial health plan' by itself is ordinary immigration vocabulary
|
| 137 |
+
# (a permit holder's provincial coverage) and must not reach the PSHCP.
|
| 138 |
+
(r"(?:ramq|ohip|r[ée]gie de l'assurance[- ]maladie(?: du qu[ée]bec)?"
|
| 139 |
+
r"|qu[ée]bec (?:drug|prescription drug) (?:insurance|plan))",
|
| 140 |
+
"provincial/territorial health insurance plan supplementary coverage "
|
| 141 |
+
"co-ordination of benefits Quebec resident submitting a prescription "
|
| 142 |
+
"drug claim"),
|
| 143 |
+
(r"(?:nexus|trusted traveller)",
|
| 144 |
+
"presentation of persons alternative manner authorization"),
|
| 145 |
+
# Border and customs
|
| 146 |
+
(r"cbsa", "canada border services agency"),
|
| 147 |
+
(r"bsos?", "border services officer"),
|
| 148 |
+
(r"amps", "administrative monetary penalty system"),
|
| 149 |
+
# Financial-crime and labour
|
| 150 |
+
(r"fintrac", "financial transactions and reports analysis centre"),
|
| 151 |
+
(r"njc", "national joint council"),
|
| 152 |
+
# French -> English bridges. The corpus is English-only, but officers work
|
| 153 |
+
# bilingually and drafting agents emit French terms; with the tokenizer's
|
| 154 |
+
# accent folding these triggers match either accented or folded forms.
|
| 155 |
+
# High-precision statutory anchors only.
|
| 156 |
+
(r"erar", "pre-removal risk assessment application for protection"),
|
| 157 |
+
(r"asfc", "canada border services agency"),
|
| 158 |
+
(r"interdiction de territoire", "inadmissibility inadmissible"),
|
| 159 |
+
(r"grande criminalit[ée]", "serious criminality"),
|
| 160 |
+
(r"criminalit[ée]", "criminality convicted offence"),
|
| 161 |
+
(r"renvoi", "removal order"),
|
| 162 |
+
(r"saisie?s?", "seizure seized goods"),
|
| 163 |
+
# 'devises' is the everyday word for currency; the PCMLTFA's French text
|
| 164 |
+
# says 'espèces et effets' -- bridge to both statutory vocabularies.
|
| 165 |
+
(r"devises", "currency monetary instruments espèces effets"),
|
| 166 |
+
(r"r[ée]sidents? permanents?", "permanent resident"),
|
| 167 |
+
(r"[ée]trangers?", "foreign national"),
|
| 168 |
+
(r"d[ée]tention", "detention detained review"),
|
| 169 |
+
(r"r[ée]fugi[ée]s?", "refugee protection convention"),
|
| 170 |
+
(r"douanes?", "customs duties"),
|
| 171 |
+
(r"marchandises?", "goods imported"),
|
| 172 |
+
(r"d[ée]claration", "report declaration"),
|
| 173 |
+
(r"armes? [àa] feu", "firearm weapon"),
|
| 174 |
+
(r"contr[ôo]le", "examination search"),
|
| 175 |
+
(r"agents? des services frontaliers", "border services officer"),
|
| 176 |
+
(r"permis de travail", "work permit"),
|
| 177 |
+
(r"permis d'[ée]tudes", "study permit"),
|
| 178 |
+
(r"visa de r[ée]sident temporaire", "temporary resident visa"),
|
| 179 |
+
(r"r[ée]adaptation", "rehabilitation prescribed period"),
|
| 180 |
+
(r"expulsion", "deportation removal order"),
|
| 181 |
+
(r"passeurs?", "human smuggling organized entry"),
|
| 182 |
+
(r"fouille", "search of the person strip search"),
|
| 183 |
+
(r"griefs?", "grievance adjudication"),
|
| 184 |
+
(r"convention collective", "collective agreement"),
|
| 185 |
+
(r"heures suppl[ée]mentaires", "overtime hours of work"),
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
_COMPILED = [(re.compile(rf"\b{trigger}\b", re.IGNORECASE), expansion)
|
| 189 |
+
for trigger, expansion in _SYNONYMS]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def expand_query(query):
|
| 193 |
+
"""Return `query` with canonical statutory terms appended for every legal
|
| 194 |
+
abbreviation it contains; return it unchanged if it contains none."""
|
| 195 |
+
additions = [exp for pattern, exp in _COMPILED if pattern.search(query)]
|
| 196 |
+
if not additions:
|
| 197 |
+
return query
|
| 198 |
+
return f"{query} {' '.join(additions)}"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def main():
|
| 202 |
+
query = " ".join(sys.argv[1:]) or "PRRA eligibility and an H&C application"
|
| 203 |
+
print(f"query: {query}")
|
| 204 |
+
print(f"expanded: {expand_query(query)}")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
main()
|
canlex/tariff_schedule.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, force=False):
|
| 61 |
+
return fetch_cached(url, dest, force=force)
|
| 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, force=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 |
+
force=force)
|
| 254 |
+
chunks = parse_chapter(html, src)
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 257 |
+
failures.append((src["chapter"], f"{type(exc).__name__}: {exc}"))
|
| 258 |
+
continue
|
| 259 |
+
if not chunks:
|
| 260 |
+
# A chapter never parses to nothing legitimately -- an empty result
|
| 261 |
+
# means the page moved or changed shape, so treat it as a failure
|
| 262 |
+
# rather than letting it silently shrink the corpus.
|
| 263 |
+
print(f" !! {src['code']}: no chunks parsed")
|
| 264 |
+
failures.append((src["chapter"], "no chunks parsed"))
|
| 265 |
+
continue
|
| 266 |
+
all_chunks.extend(chunks)
|
| 267 |
+
print(f" {len(chunks)} chunks")
|
| 268 |
+
|
| 269 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 270 |
+
stored_chunks(OUT))
|
| 271 |
+
if preserved:
|
| 272 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s): "
|
| 273 |
+
f"{', '.join(preserved)}")
|
| 274 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 275 |
+
return False
|
| 276 |
+
print(f"\n{len(all_chunks)} tariff-schedule chunks from "
|
| 277 |
+
f"{len(SOURCES) - len(failures)} chapter(s) -> {OUT}")
|
| 278 |
+
return True
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def main():
|
| 282 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv,
|
| 283 |
+
force="--force" in sys.argv) else 1)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
if __name__ == "__main__":
|
| 287 |
+
main()
|
canlex/transborder.py
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transborder criminality: IRPA s. 36(2.1), IRPR ss. 19, 228(1)(a.01) and
|
| 2 |
+
229(1)(d.1) (roadmap T2.1, with the critic's correction).
|
| 3 |
+
|
| 4 |
+
s. 36(2)(d) is repealed (2023, c. 32, s. 53); s. 36(2.1) now reads 'A foreign
|
| 5 |
+
national is inadmissible on grounds of transborder criminality for
|
| 6 |
+
committing, on entering Canada, a prescribed offence under an Act of
|
| 7 |
+
Parliament', and IRPR s. 19(1) prescribes the offences:
|
| 8 |
+
|
| 9 |
+
(a) any indictable offence under the Criminal Code;
|
| 10 |
+
(b) any indictable offence under the Immigration and Refugee Protection Act;
|
| 11 |
+
(c)-(g) listed offences under the Firearms Act, the Customs Act (by goods),
|
| 12 |
+
the Controlled Drugs and Substances Act and the Cannabis Act.
|
| 13 |
+
|
| 14 |
+
(a) and (b) are OPEN CLASSES, not lists, so they are rules here, driven by the
|
| 15 |
+
offence's mode -- hybrid offences are deemed indictable by s. 19(2). Only
|
| 16 |
+
(c)-(g) are tabulated, and that table is curated legal content
|
| 17 |
+
(data/curated/transborder.json, HELD FOR REVIEW). A composed release ships
|
| 18 |
+
without it: (a)/(b) still work and the answer says the (c)-(g) table awaits
|
| 19 |
+
review.
|
| 20 |
+
|
| 21 |
+
Also statute-mechanical, and transcribed here because the removal path turns
|
| 22 |
+
on them: which offences the Minister's delegate may decide by a deportation
|
| 23 |
+
order (s. 228(1)(a.01)(i)-(v)) and which go to the Immigration Division
|
| 24 |
+
(s. 229(1)(d.1)); the s. 36(3)(c) rule that rehabilitation does not reach
|
| 25 |
+
s. 36(2.1); and the s. 36(3)(e)(i) contraventions carve-out, checked against
|
| 26 |
+
the Contraventions Regulations (SOR-96-313) in the corpus.
|
| 27 |
+
|
| 28 |
+
Every statutory phrase below is pinned against the corpus text by
|
| 29 |
+
tests/test_transborder.py. Every definition an officer reads is registered
|
| 30 |
+
in canlex/reviewed_code.py.
|
| 31 |
+
"""
|
| 32 |
+
import json
|
| 33 |
+
import re
|
| 34 |
+
|
| 35 |
+
TABLE_FILE = "transborder.json"
|
| 36 |
+
|
| 37 |
+
# ---- the Acts IRPR s. 19(1) names ------------------------------------------------
|
| 38 |
+
# Corpus act code -> the Act's name as s. 19(1) writes it, the paragraph, and
|
| 39 |
+
# whether the paragraph is an open class (a rule on the mode) or a list (the
|
| 40 |
+
# curated table). Any other Act: s. 19(1) prescribes no offence under it.
|
| 41 |
+
R19_ACTS = {
|
| 42 |
+
"C-46": {"name": "Criminal Code", "paragraph": "19(1)(a)", "open": True,
|
| 43 |
+
"aliases": ("criminal code", "cc", "ccc", "c-46", "code criminel")},
|
| 44 |
+
"I-2.5": {"name": "Immigration and Refugee Protection Act", "paragraph": "19(1)(b)",
|
| 45 |
+
"open": True,
|
| 46 |
+
"aliases": ("immigration and refugee protection act", "irpa", "i-2.5", "lipr")},
|
| 47 |
+
"F-11.6": {"name": "Firearms Act", "paragraph": "19(1)(c)", "open": False,
|
| 48 |
+
"aliases": ("firearms act", "f-11.6", "loi sur les armes a feu")},
|
| 49 |
+
"C-52.6": {"name": "Customs Act", "paragraph": "19(1)(d) and (e)", "open": False,
|
| 50 |
+
"aliases": ("customs act", "c-52.6", "loi sur les douanes")},
|
| 51 |
+
"C-38.8": {"name": "Controlled Drugs and Substances Act", "paragraph": "19(1)(f)",
|
| 52 |
+
"open": False,
|
| 53 |
+
"aliases": ("controlled drugs and substances act", "cdsa", "c-38.8", "lrcdas")},
|
| 54 |
+
"C-24.5": {"name": "Cannabis Act", "paragraph": "19(1)(g)", "open": False,
|
| 55 |
+
"aliases": ("cannabis act", "c-24.5", "loi sur le cannabis")},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# The goods classes IRPR s. 19(1)(d) and (e) turn on, as the Regulations
|
| 59 |
+
# define them. The keys are CanLex's; the labels quote s. 19(1).
|
| 60 |
+
GOODS = {
|
| 61 |
+
"child-pornography": "child pornography, as defined in subsection 163.1(1) of the "
|
| 62 |
+
"Criminal Code",
|
| 63 |
+
"firearm-or-weapon": "an automatic firearm, imitation firearm, non-restricted firearm, "
|
| 64 |
+
"prohibited ammunition, prohibited device, prohibited firearm, "
|
| 65 |
+
"prohibited weapon, replica firearm, restricted firearm or "
|
| 66 |
+
"restricted weapon, as those terms are defined in subsection "
|
| 67 |
+
"84(1) of the Criminal Code",
|
| 68 |
+
"controlled-substance": "a substance listed in any of Schedules I to VI of the "
|
| 69 |
+
"Controlled Drugs and Substances Act",
|
| 70 |
+
"schedule-ix-device": "a device listed in Schedule IX of that Act",
|
| 71 |
+
"cannabis": "cannabis, as defined in subsection 2(1) of the Cannabis Act",
|
| 72 |
+
"other": "goods of any other kind",
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
MODES = ("indictable", "hybrid", "summary-only")
|
| 76 |
+
|
| 77 |
+
# IRPR s. 228(1)(a.01): the transborder offences the Minister's delegate
|
| 78 |
+
# decides with a deportation order (the report 'shall not be referred to the
|
| 79 |
+
# Immigration Division'). (subparagraph, act, provisions, goods condition).
|
| 80 |
+
# For the Customs Act, '12' and '156' name the provision contravened: the
|
| 81 |
+
# offence is under s. 160(1) 'that is ... a contravention of section 12' (or
|
| 82 |
+
# 156). Every other prescribed offence is decided by the Immigration
|
| 83 |
+
# Division: s. 229(1)(d.1).
|
| 84 |
+
MD_ORDERABLE = (
|
| 85 |
+
("228(1)(a.01)(i)", "C-46", ("86", "87", "90", "91", "95", "104", "320.15(1)"), None),
|
| 86 |
+
("228(1)(a.01)(ii)", "I-2.5", ("124(1)(b)",), None),
|
| 87 |
+
("228(1)(a.01)(iii)", "F-11.6", ("110",), None),
|
| 88 |
+
("228(1)(a.01)(iv)", "C-52.6", ("159(1)",), "firearm-or-weapon"),
|
| 89 |
+
("228(1)(a.01)(v)(A)", "C-52.6", ("12",), "firearm-or-weapon"),
|
| 90 |
+
("228(1)(a.01)(v)(B)", "C-52.6", ("156",), None),
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# ---- statute text the memo quotes (pinned in tests/test_transborder.py) ------------
|
| 94 |
+
S36_2_1 = ("A foreign national is inadmissible on grounds of transborder criminality "
|
| 95 |
+
"for committing, on entering Canada, a prescribed offence under an Act of "
|
| 96 |
+
"Parliament")
|
| 97 |
+
ALLEGATION_2_1 = "committing, on entering Canada, a prescribed offence under an Act of Parliament"
|
| 98 |
+
R19_2 = ("an offence that may be prosecuted either summarily or by way of indictment is "
|
| 99 |
+
"deemed to be an offence punishable by way of indictment, even if it has been "
|
| 100 |
+
"prosecuted summarily")
|
| 101 |
+
S36_3_C = ("the matters referred to in paragraphs (1)(b) and (c) and (2)(b) and (c) do "
|
| 102 |
+
"not constitute inadmissibility")
|
| 103 |
+
S36_3_E = "inadmissibility under subsections (1) to (2.1) may not be based on an offence"
|
| 104 |
+
S36_3_E_I = "designated as a contravention under the Contraventions Act"
|
| 105 |
+
S36_3_B = "there has been a final determination of an acquittal"
|
| 106 |
+
S36_3_B_CONV = "inadmissibility under subsections (1) to (2.1) may not be based on a conviction"
|
| 107 |
+
# s. 36(3)(b) governs s. 36(2.1), but its acquittal words attach to 'a
|
| 108 |
+
# conviction ... in respect of which'; s. 36(2.1) needs no conviction. The
|
| 109 |
+
# law is unsettled here (principle 5): both readings, no pick.
|
| 110 |
+
ACQUITTAL_NOTE = (
|
| 111 |
+
"Acquittal: s. 36(3)(b) governs s. 36(2.1) too ('" + S36_3_B_CONV + "' ... "
|
| 112 |
+
"'in respect of which " + S36_3_B + "'), but its words bar a finding based on "
|
| 113 |
+
"a CONVICTION that ended in an acquittal, and s. 36(2.1) rests on "
|
| 114 |
+
"'committing', not on a conviction. Whether a final acquittal of the entry "
|
| 115 |
+
"offence ends a s. 36(2.1) allegation is NOT SETTLED here: (A) on its words "
|
| 116 |
+
"s. 36(3)(b) does not reach a ground that needs no conviction, and the facts "
|
| 117 |
+
"are still proved on reasonable grounds to believe (s. 33); (B) the acquittal "
|
| 118 |
+
"is read as ending the allegation. Confirm before relying on either reading.")
|
| 119 |
+
R228_OPENING = ("if a report in respect of a foreign national does not include any "
|
| 120 |
+
"grounds of inadmissibility other than those set out in the following "
|
| 121 |
+
"circumstances, the report shall not be referred to the Immigration "
|
| 122 |
+
"Division")
|
| 123 |
+
R228_4_A = "is under 18 years of age and not accompanied by a parent or an adult legally responsible for them"
|
| 124 |
+
R229_D1 = ("an offence other than one referred to in any of subparagraphs "
|
| 125 |
+
"228(1)(a.01)(i) to (v)")
|
| 126 |
+
CA_8_1_A = ("designating as contraventions offences created by any enactment, other "
|
| 127 |
+
"than offences for which an offender may be prosecuted only on indictment")
|
| 128 |
+
CR_S1 = ("An offence under a provision of an enactment set out in column I of the "
|
| 129 |
+
"Schedules is designated as a contravention")
|
| 130 |
+
|
| 131 |
+
# ---- the guidance the memo cites (chunk ids pinned in tests/test_transborder.py) ---
|
| 132 |
+
GUIDANCE_CURRENT = (
|
| 133 |
+
"ENF 5 (2025-02-20) Appendix F and ENF 6 (2025-02-20) Appendix A tabulate "
|
| 134 |
+
"A36(2.1) as 'FN only': the ID makes the deportation order under R229(1)(d.1), "
|
| 135 |
+
"the MD only for 'specific offences' under R228(1)(a.01) (enf-5-app-f-p2; "
|
| 136 |
+
"enf-6-app-a-p1, enf-6-app-a-p2). ENF 5 s. 7.4 lists 'Transborder criminality "
|
| 137 |
+
"[A36(2.1)]' among "
|
| 138 |
+
"the grounds proved on reasonable grounds to believe (enf-5-7-4; IRPA s. 33), "
|
| 139 |
+
"and ENF 5 s. 4 names the Minister of Public Safety as policy lead for A36(2.1) "
|
| 140 |
+
"(enf-5-4-p1).")
|
| 141 |
+
GUIDANCE_STALE = (
|
| 142 |
+
"Older guidance still describes the repealed paragraph: ENF 1 s. 3 (2013; "
|
| 143 |
+
"enf-1-3-p30) gives 'R19: Transborder Crime' for 'paragraph A36(2)(d)' with the "
|
| 144 |
+
"old list of five Acts (no Cannabis Act), and ENF 3 s. 10.8 (2022; "
|
| 145 |
+
"enf-3-10-8-p2) and the withdrawn ENF 2 (ATIP copy, enf-2-3-p12) say the "
|
| 146 |
+
"allegation 'may only be used' in a 'present tense' scenario -- the person "
|
| 147 |
+
"cannot later be found inadmissible for a past 'on entering Canada' offence "
|
| 148 |
+
"once they withdrew or left. That limit was written for former s. 36(2)(d); "
|
| 149 |
+
"whether it still states CBSA policy under s. 36(2.1) is NOT SETTLED here "
|
| 150 |
+
"(historical guidance -- confirm before relying on it).")
|
| 151 |
+
|
| 152 |
+
TABLE_HELD = (
|
| 153 |
+
"The IRPR s. 19(1)(c)-(g) table (the Firearms Act, Customs Act, Controlled "
|
| 154 |
+
"Drugs and Substances Act and Cannabis Act offences, by section and goods) is "
|
| 155 |
+
"HELD FOR REVIEW and is not in this deployment: CanLex does not say here "
|
| 156 |
+
"whether this offence is prescribed. Read IRPR s. 19(1)(c)-(g) with "
|
| 157 |
+
"canlex_get_section (act 'IRPR', section '19').")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---- helpers ------------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
def _norm(text):
|
| 163 |
+
t = (text or "").lower().replace("’", "'").replace("é", "e").replace("à", "a")
|
| 164 |
+
t = re.sub(r"[^a-z0-9.\- ]", " ", t)
|
| 165 |
+
t = re.sub(r"\bthe\b", " ", t)
|
| 166 |
+
return " ".join(t.split())
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# The spellings an Act's name carries on a record or in a client's words (the code
|
| 170 |
+
# audit of 2026-09-21, HELD): a parenthesised suffix ('(Canada)', '(CDSA)'), a
|
| 171 |
+
# chapter citation ('R.S.C. 1985, c. C-46', 'S.C. 2018, c. 16') and a trailing
|
| 172 |
+
# section ('s. 95', 'section 159(1)'). Before this any spelling the alias table did
|
| 173 |
+
# not hold exactly -- 'Criminal Code of Canada', 'Customs Act (Canada)' -- resolved
|
| 174 |
+
# to 'other' and the bottom line was a settled NOT A PRESCRIBED OFFENCE.
|
| 175 |
+
_ACT_CITE = re.compile(r"\b(?:r\.?s\.?c\.?|s\.?c\.?|l\.?r\.?c\.?|l\.?c\.?)\s*\(?\d{4}\)?"
|
| 176 |
+
r"(?:,?\s*(?:c|ch|chapter)\.?\s*[a-z0-9\-]+(?:\s*\(\s*\d+(?:st|nd|rd|th)"
|
| 177 |
+
r"\s+supp\.?\s*\))?)?", re.I)
|
| 178 |
+
# (the audit follow-up of 2026-09-21: 'subsection'/'paragraph' is no lift trigger of
|
| 179 |
+
# its own -- '..., section 95, subsection 1' read section 1 -- it rides on the section
|
| 180 |
+
# before it as 95(1))
|
| 181 |
+
_ACT_TRAILING_SECTION = re.compile(
|
| 182 |
+
r"[\s,]+(?:s(?:ection|ect\.|ec\.|s\.|\.)?|art(?:icle|\.)?)\s*"
|
| 183 |
+
r"(\d+(?:\.\d+)?(?:\s*\(\s*[0-9a-z.]+\s*\))*)"
|
| 184 |
+
# (a value is a digit run or one or two letters, never a word: the value class
|
| 185 |
+
# must not overlap the keywords, or a long tail of ', subsection 1' backtracks
|
| 186 |
+
# exponentially -- the review of the follow-up; at most four of them)
|
| 187 |
+
r"((?:[\s,]+(?:sub(?:section|s\.|-s\.)|par(?:agraph|a\.?|\.)|cl(?:ause|\.))\s*"
|
| 188 |
+
r"\(?\s*(?:\d[0-9.]*|[a-z]{1,2})\s*\)?){0,4})\s*$", re.I)
|
| 189 |
+
_ACT_SUBSECTION = re.compile(r"(?:sub(?:section|s\.|-s\.)|par(?:agraph|a\.?|\.)|cl(?:ause|\.))\s*"
|
| 190 |
+
r"\(?\s*(\d[0-9.]*|[a-z]{1,2})\s*\)?", re.I)
|
| 191 |
+
# the short codes match as whole tokens only; the names as word-bounded phrases
|
| 192 |
+
_ACT_CODES = {"cc", "ccc", "c-46", "irpa", "i-2.5", "lipr", "f-11.6", "c-52.6", "cdsa",
|
| 193 |
+
"c-38.8", "lrcdas", "c-24.5"}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def act_and_section(act):
|
| 197 |
+
"""(act code, recognised, trailing section): resolve_act's answer and the
|
| 198 |
+
section a citation form carries after the Act ('Criminal Code s. 95' ->
|
| 199 |
+
('C-46', True, '95')), or None when the words carry none."""
|
| 200 |
+
if not act or not act.strip():
|
| 201 |
+
return None, False, None
|
| 202 |
+
text = str(act)
|
| 203 |
+
section = None
|
| 204 |
+
m = _ACT_TRAILING_SECTION.search(text)
|
| 205 |
+
if m:
|
| 206 |
+
section = re.sub(r"\s+", "", m.group(1)) + "".join(
|
| 207 |
+
f"({s})" for s in _ACT_SUBSECTION.findall(m.group(2) or ""))
|
| 208 |
+
text = text[:m.start()]
|
| 209 |
+
text = re.sub(r"\([^)]*\)", " ", text) # '(Canada)', '(CDSA)', '(2nd Supp.)'
|
| 210 |
+
text = _ACT_CITE.sub(" ", text).replace("&", " and ")
|
| 211 |
+
n = _norm(text)
|
| 212 |
+
if not n:
|
| 213 |
+
return None, False, section
|
| 214 |
+
tokens = set(n.split())
|
| 215 |
+
for code, meta in R19_ACTS.items():
|
| 216 |
+
names = [_norm(meta["name"])] + [a for a in meta["aliases"] if a not in _ACT_CODES]
|
| 217 |
+
if any(re.search(r"(?<![a-z0-9])" + re.escape(name) + r"(?![a-z0-9])", n)
|
| 218 |
+
for name in names if name):
|
| 219 |
+
return code, True, section
|
| 220 |
+
for code, meta in R19_ACTS.items():
|
| 221 |
+
codes = {code.lower()} | {a for a in meta["aliases"] if a in _ACT_CODES}
|
| 222 |
+
if tokens & codes:
|
| 223 |
+
return code, True, section
|
| 224 |
+
return "other", True, section
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def resolve_act(act):
|
| 228 |
+
"""(act code, recognised) for an Act as the officer names it: a code of
|
| 229 |
+
R19_ACTS when it is one of the six Acts s. 19(1) names -- as a whole, or
|
| 230 |
+
inside a longer spelling ('Criminal Code of Canada', 'Customs Act
|
| 231 |
+
(Canada)', 'the Controlled Drugs & Substances Act (CDSA)', a chapter cite,
|
| 232 |
+
a trailing 's. N'); (None, False) when nothing was given; ('other', True)
|
| 233 |
+
for any other Act (the Excise Act, 2001, the Customs Tariff, a provincial
|
| 234 |
+
Act: genuinely not prescribed)."""
|
| 235 |
+
code, recognised, _ = act_and_section(act)
|
| 236 |
+
return code, recognised
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
_SEC = re.compile(r"^\s*(?:(?:sub)?s(?:ection|ect\.|ec\.|s\.|\.)?\s*)?"
|
| 240 |
+
r"(\d+(?:\.\d+)?)\s*((?:\(\s*[0-9a-z.]+\s*\)\s*)*)$", re.I)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def parse_section(text):
|
| 244 |
+
"""('320.15', ['1']) for '320.15(1)', ('124', ['1', 'b']) for
|
| 245 |
+
's. 124(1)(b)'; None when the text is not a section reference."""
|
| 246 |
+
if not text:
|
| 247 |
+
return None
|
| 248 |
+
m = _SEC.match(str(text).strip())
|
| 249 |
+
if not m:
|
| 250 |
+
return None
|
| 251 |
+
path = [p.strip().lower() for p in re.findall(r"\(\s*([0-9a-z.]+)\s*\)", m.group(2) or "")]
|
| 252 |
+
return m.group(1), path
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def section_relation(given, key):
|
| 256 |
+
"""'match' when the officer's section is the rule's provision or inside
|
| 257 |
+
it; 'partial' when the officer gave less than the rule needs ('320.15'
|
| 258 |
+
against '320.15(1)'); 'no' otherwise."""
|
| 259 |
+
kb = parse_section(key)
|
| 260 |
+
if not given or not kb:
|
| 261 |
+
return "no"
|
| 262 |
+
gb, gp = given
|
| 263 |
+
if gb != kb[0]:
|
| 264 |
+
return "no"
|
| 265 |
+
kp = kb[1]
|
| 266 |
+
n = min(len(gp), len(kp))
|
| 267 |
+
if gp[:n] != kp[:n]:
|
| 268 |
+
return "no"
|
| 269 |
+
return "match" if len(gp) >= len(kp) else "partial"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def section_label(sec):
|
| 273 |
+
if not sec:
|
| 274 |
+
return None
|
| 275 |
+
return sec[0] + "".join(f"({p})" for p in sec[1])
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def load_table(path=None):
|
| 279 |
+
"""The curated s. 19(1)(c)-(g) table, or None when it is absent (a
|
| 280 |
+
composed release ships without held files)."""
|
| 281 |
+
from canlex.config import DATA_DIR
|
| 282 |
+
path = path or (DATA_DIR / "curated" / TABLE_FILE)
|
| 283 |
+
try:
|
| 284 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 285 |
+
except (OSError, ValueError):
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def mode_from_pairing(p):
|
| 290 |
+
"""The Canadian offence's mode as the curated pairing records it
|
| 291 |
+
(summary_only / hybrid / a maximum), or None."""
|
| 292 |
+
if not p or p.get("equivalent") is False:
|
| 293 |
+
return None
|
| 294 |
+
if p.get("summary_only") is True:
|
| 295 |
+
return "summary-only"
|
| 296 |
+
if p.get("hybrid") is True:
|
| 297 |
+
return "hybrid"
|
| 298 |
+
if p.get("hybrid") is False and type(p.get("max_years")) is int:
|
| 299 |
+
return "indictable"
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def pairing_act(p):
|
| 304 |
+
"""The one Act a pairing's verify rows cite, or None when they cite
|
| 305 |
+
several or none."""
|
| 306 |
+
acts = {v.get("act") for v in (p or {}).get("verify") or []}
|
| 307 |
+
return acts.pop() if len(acts) == 1 else None
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ---- contraventions (IRPA s. 36(3)(e)(i)) -------------------------------------------
|
| 311 |
+
|
| 312 |
+
def _schedule_act(chunk):
|
| 313 |
+
"""The Act a Contraventions Regulations schedule is for: its title, or
|
| 314 |
+
(for a schedule the ingester titled only 'Schedule X') the first line
|
| 315 |
+
naming it."""
|
| 316 |
+
title = (chunk.get("marginal_note") or "").strip()
|
| 317 |
+
if title and not title.lower().startswith("schedule"):
|
| 318 |
+
return title
|
| 319 |
+
for line in (chunk.get("text") or "").splitlines():
|
| 320 |
+
line = line.strip()
|
| 321 |
+
if "|" in line: # a table row is never an Act's name
|
| 322 |
+
return ""
|
| 323 |
+
if line and not line.startswith("(Section") and line.lower() != "part i":
|
| 324 |
+
return line
|
| 325 |
+
return ""
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def contravention_schedules(processed_dir=None):
|
| 329 |
+
"""(the Acts the SOR-96-313 schedules are for, the corpus current_to date),
|
| 330 |
+
or (None, None) when the Regulations are not in the corpus."""
|
| 331 |
+
from canlex.config import DATA_DIR
|
| 332 |
+
path = (processed_dir or (DATA_DIR / "processed")) / "SOR-96-313.json"
|
| 333 |
+
try:
|
| 334 |
+
chunks = json.loads(path.read_text(encoding="utf-8"))
|
| 335 |
+
except (OSError, ValueError):
|
| 336 |
+
return None, None
|
| 337 |
+
acts, current, seen = [], None, set()
|
| 338 |
+
for c in chunks:
|
| 339 |
+
if c.get("lang", "en") != "en":
|
| 340 |
+
continue
|
| 341 |
+
current = current or c.get("current_to")
|
| 342 |
+
sec = str(c.get("section", ""))
|
| 343 |
+
# One Act per schedule, read from the schedule's FIRST chunk: a
|
| 344 |
+
# continuation chunk titled only 'Schedule X' starts with a table row.
|
| 345 |
+
if not sec.lower().startswith("schedule") or sec in seen:
|
| 346 |
+
continue
|
| 347 |
+
seen.add(sec)
|
| 348 |
+
a = _schedule_act(c)
|
| 349 |
+
if a and a not in acts and a.lower() != "[repealed]":
|
| 350 |
+
acts.append(a)
|
| 351 |
+
return acts, current
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def contravention_check(act_code, schedules=None):
|
| 355 |
+
"""{'status', 'text'} for the s. 36(3)(e)(i) carve-out: 'not-designated'
|
| 356 |
+
when no schedule of the Contraventions Regulations is for this Act,
|
| 357 |
+
'check-schedule' when one is, 'not-checked' when the Regulations are not
|
| 358 |
+
in the corpus or the Act is unknown."""
|
| 359 |
+
acts, current = schedules if schedules is not None else contravention_schedules()
|
| 360 |
+
head = (f"Contraventions carve-out (IRPA s. 36(3)(e)(i)): '{S36_3_E}' "
|
| 361 |
+
f"'{S36_3_E_I}'.")
|
| 362 |
+
if acts is None:
|
| 363 |
+
return {"status": "not-checked",
|
| 364 |
+
"text": head + " The Contraventions Regulations are not in this "
|
| 365 |
+
"deployment's corpus, so this was not checked -- read "
|
| 366 |
+
"SOR-96-313 before relying on the allegation."}
|
| 367 |
+
if act_code not in R19_ACTS:
|
| 368 |
+
return {"status": "not-checked",
|
| 369 |
+
"text": head + " Not checked: the Act is not one IRPR s. 19(1) names."}
|
| 370 |
+
name = R19_ACTS[act_code]["name"]
|
| 371 |
+
if any(_norm(a) == _norm(name) for a in acts):
|
| 372 |
+
return {"status": "check-schedule",
|
| 373 |
+
"text": head + f" The Contraventions Regulations (SOR-96-313, current "
|
| 374 |
+
f"to {current}) have a schedule for the {name}: read it for "
|
| 375 |
+
"this provision -- an offence designated there cannot ground "
|
| 376 |
+
"s. 36(2.1)."}
|
| 377 |
+
text = (head + f" The Contraventions Regulations (SOR-96-313, current to {current}) "
|
| 378 |
+
f"designate offences by schedule ('{CR_S1}'), and none of their "
|
| 379 |
+
f"{len(acts)} schedules is for the {name}: no offence under it is "
|
| 380 |
+
"designated, so the carve-out does not apply.")
|
| 381 |
+
if act_code == "C-46":
|
| 382 |
+
text += (" (Contraventions Act s. 8(1)(a) also excludes 'offences for which an "
|
| 383 |
+
"offender may be prosecuted only on indictment'.)")
|
| 384 |
+
if act_code == "C-24.5":
|
| 385 |
+
text += (" A Cannabis Act ticket (ss. 51-52) is that Act's own procedure, not a "
|
| 386 |
+
"designation under the Contraventions Act, so s. 36(3)(e)(i) does not "
|
| 387 |
+
"reach it on its words; how a ticketable amount is treated at the "
|
| 388 |
+
"border is NOT SETTLED here.")
|
| 389 |
+
return {"status": "not-designated", "text": text}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# ---- the assessment -------------------------------------------------------------------
|
| 393 |
+
|
| 394 |
+
def _open_class(code, mode):
|
| 395 |
+
"""(prescribed, how) for s. 19(1)(a)/(b): indictable and hybrid offences
|
| 396 |
+
are prescribed (hybrids deemed indictable by s. 19(2)); a summary-only
|
| 397 |
+
offence is not."""
|
| 398 |
+
para = R19_ACTS[code]["paragraph"]
|
| 399 |
+
name = R19_ACTS[code]["name"]
|
| 400 |
+
if mode == "indictable":
|
| 401 |
+
return True, f"IRPR s. {para}: 'any indictable offence under the {name}'"
|
| 402 |
+
if mode == "hybrid":
|
| 403 |
+
return True, (f"IRPR s. {para} with s. 19(2): '{R19_2}' -- a hybrid offence is "
|
| 404 |
+
"prescribed")
|
| 405 |
+
if mode == "summary-only":
|
| 406 |
+
return False, (f"IRPR s. {para} prescribes only indictable offences under the "
|
| 407 |
+
f"{name}; an offence that may only be prosecuted summarily is "
|
| 408 |
+
"not prescribed")
|
| 409 |
+
return None, None
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _md(code, sec, goods):
|
| 413 |
+
"""(md, subparagraph, readings): whether IRPR s. 228(1)(a.01) lets the
|
| 414 |
+
Minister's delegate make the deportation order. md is None when the
|
| 415 |
+
officer's section or goods leave it open."""
|
| 416 |
+
rules = [r for r in MD_ORDERABLE if r[1] == code]
|
| 417 |
+
if not rules:
|
| 418 |
+
return False, None, []
|
| 419 |
+
if sec is None:
|
| 420 |
+
return None, None, [f"s. {k}" + (f" with goods: {r[3]}" if r[3] else "")
|
| 421 |
+
+ f" (IRPR s. {r[0]})" for r in rules for k in r[2]]
|
| 422 |
+
partial = []
|
| 423 |
+
for sub, _, keys, cond in rules:
|
| 424 |
+
for k in keys:
|
| 425 |
+
rel = section_relation(sec, k)
|
| 426 |
+
if rel == "no":
|
| 427 |
+
continue
|
| 428 |
+
gm = ("match" if cond is None or goods == cond
|
| 429 |
+
else "partial" if goods is None else "no")
|
| 430 |
+
if rel == "match" and gm == "match":
|
| 431 |
+
return True, sub, []
|
| 432 |
+
if gm != "no":
|
| 433 |
+
partial.append(f"s. {k}" + (f" with goods: {cond}" if cond else "")
|
| 434 |
+
+ f" (IRPR s. {sub})")
|
| 435 |
+
return (None if partial else False), None, partial
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _table_rows(table, code):
|
| 439 |
+
return [r for r in (table or {}).get("rows", []) if r.get("act") == code]
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _from_table(table, code, sec, goods):
|
| 443 |
+
"""(prescribed, row, readings, needs) from the curated (c)-(g) table."""
|
| 444 |
+
rows = _table_rows(table, code)
|
| 445 |
+
if sec is None:
|
| 446 |
+
readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
|
| 447 |
+
+ f": {'prescribed' if r.get('prescribed', True) else 'NOT prescribed'}"
|
| 448 |
+
+ (f" (IRPR s. {r['paragraph']})" if r.get("paragraph") else "")
|
| 449 |
+
for r in rows]
|
| 450 |
+
return None, None, readings, [{"fact": "section", "parameter": "section",
|
| 451 |
+
"question": f"Which section of the "
|
| 452 |
+
f"{R19_ACTS[code]['name']} was contravened?"}]
|
| 453 |
+
hits = [(section_relation(sec, r["section"]), r) for r in rows]
|
| 454 |
+
exact = [r for rel, r in hits if rel == "match"]
|
| 455 |
+
partial = [r for rel, r in hits if rel == "partial"]
|
| 456 |
+
# A penalty section (Customs Act s. 160(1), CDSA s. 46) punishes the
|
| 457 |
+
# contravention of other provisions: the provision contravened decides.
|
| 458 |
+
punished = [r for r in rows if r.get("punished_under")
|
| 459 |
+
and section_relation(sec, r["punished_under"]) != "no"]
|
| 460 |
+
if punished and not exact and not partial:
|
| 461 |
+
readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
|
| 462 |
+
+ (" (prescribed" + (f", IRPR s. {r['paragraph']})" if r.get("paragraph")
|
| 463 |
+
else ")")
|
| 464 |
+
if r.get("prescribed", True) else " (NOT prescribed)")
|
| 465 |
+
for r in punished]
|
| 466 |
+
return None, None, readings, [{
|
| 467 |
+
"fact": "section", "parameter": "section",
|
| 468 |
+
"question": f"s. {section_label(sec)} of the {R19_ACTS[code]['name']} punishes "
|
| 469 |
+
"contraventions of other provisions: give the provision "
|
| 470 |
+
"contravened (for example '12' for failing to report goods, "
|
| 471 |
+
"'156', '46.3(1)')."}]
|
| 472 |
+
if exact:
|
| 473 |
+
goods_rows = [r for r in exact if r.get("goods")]
|
| 474 |
+
if goods_rows:
|
| 475 |
+
chosen = [r for r in goods_rows if r["goods"] == goods]
|
| 476 |
+
if goods is None:
|
| 477 |
+
readings = [f"goods {r['goods']}: "
|
| 478 |
+
+ ("prescribed" if r.get("prescribed", True) else "NOT prescribed")
|
| 479 |
+
+ (f" (IRPR s. {r['paragraph']})" if r.get("paragraph") else "")
|
| 480 |
+
for r in goods_rows]
|
| 481 |
+
if not any(r["goods"] == "other" for r in goods_rows):
|
| 482 |
+
readings.append("any other goods: NOT prescribed under this provision")
|
| 483 |
+
return None, None, readings, [{
|
| 484 |
+
"fact": "goods", "parameter": "goods",
|
| 485 |
+
"question": "What were the goods? IRPR s. 19(1)(d)-(e) prescribe "
|
| 486 |
+
"this offence only for the goods classes listed."}]
|
| 487 |
+
if chosen:
|
| 488 |
+
r = chosen[0]
|
| 489 |
+
return bool(r.get("prescribed", True)), r, [], []
|
| 490 |
+
return False, None, [], []
|
| 491 |
+
r = exact[0]
|
| 492 |
+
return bool(r.get("prescribed", True)), r, [], []
|
| 493 |
+
if partial:
|
| 494 |
+
readings = [f"s. {r['section']}" + (f" / goods {r['goods']}" if r.get("goods") else "")
|
| 495 |
+
+ (" (prescribed)" if r.get("prescribed", True) else " (NOT prescribed)")
|
| 496 |
+
for r in partial]
|
| 497 |
+
return None, None, readings, [{"fact": "section", "parameter": "section",
|
| 498 |
+
"question": "Give the subsection or the provision "
|
| 499 |
+
"contravened: " + "; ".join(readings)}]
|
| 500 |
+
return False, None, [], []
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def assess(act=None, section=None, mode=None, goods=None, status=None, offence=None,
|
| 504 |
+
pairings=None, table=None, match=None):
|
| 505 |
+
"""The transborder result as a dict (see server.canlex_transborder).
|
| 506 |
+
pairings: the curated equivalency pairings (for the mode of a Criminal
|
| 507 |
+
Code / IRPA offence named in words); table: the curated (c)-(g) table or
|
| 508 |
+
None; match: screening.match_pairings (injected, so this module has no
|
| 509 |
+
matcher dependency)."""
|
| 510 |
+
person = status or "foreign-national"
|
| 511 |
+
code, recognised, lifted = act_and_section(act)
|
| 512 |
+
lifted_note = None
|
| 513 |
+
if lifted and not (section and str(section).strip()):
|
| 514 |
+
# 'Criminal Code s. 95' with no section given: the section rides on the Act
|
| 515 |
+
section, lifted_note = lifted, (f"Section '{lifted}' read from the act words "
|
| 516 |
+
f"('{act}'); give it as `section` to be sure.")
|
| 517 |
+
sec = parse_section(section)
|
| 518 |
+
out = {"person": person, "act": code if code != "other" else None,
|
| 519 |
+
"act_given": act, "act_name": R19_ACTS.get(code, {}).get("name"),
|
| 520 |
+
"section": section_label(sec), "section_given": section,
|
| 521 |
+
"mode": mode, "mode_source": "given" if mode else None,
|
| 522 |
+
"goods": goods, "paragraph": None, "prescribed": None,
|
| 523 |
+
"prescribed_how": None, "readings": [], "needs": [], "pairing": None,
|
| 524 |
+
"md_orderable": None, "md_provision": None, "md_readings": [],
|
| 525 |
+
"removal": None, "removal_provision": None, "table": "present" if table else "absent",
|
| 526 |
+
"notes": []}
|
| 527 |
+
if lifted_note:
|
| 528 |
+
out["notes"].append(lifted_note)
|
| 529 |
+
if section and sec is None:
|
| 530 |
+
out["notes"].append(f"'{section}' is not read as a section number (write it as "
|
| 531 |
+
"'95', '320.15(1)' or '124(1)(b)').")
|
| 532 |
+
# A Criminal Code / IRPA offence named only in words: the curated pairing
|
| 533 |
+
# (and its verified mode flags) may name the Act and the mode.
|
| 534 |
+
if offence and pairings and match and (code in (None, "C-46", "I-2.5")):
|
| 535 |
+
scored = match(offence, pairings)
|
| 536 |
+
if scored:
|
| 537 |
+
p = scored[0][1]
|
| 538 |
+
pact = pairing_act(p)
|
| 539 |
+
if pact in ("C-46", "I-2.5") and (code is None or code == pact):
|
| 540 |
+
out["pairing"] = p["id"]
|
| 541 |
+
if code is None:
|
| 542 |
+
code, out["act"] = pact, pact
|
| 543 |
+
out["act_name"] = R19_ACTS[pact]["name"]
|
| 544 |
+
out["notes"].append(f"Act read from the curated pairing '{p['id']}' "
|
| 545 |
+
f"({p['canadian_offence']}) -- confirm it.")
|
| 546 |
+
if mode is None and mode_from_pairing(p):
|
| 547 |
+
mode = out["mode"] = mode_from_pairing(p)
|
| 548 |
+
out["mode_source"] = f"pairing:{p['id']}"
|
| 549 |
+
if code is None:
|
| 550 |
+
out["needs"].append({"fact": "act", "parameter": "act",
|
| 551 |
+
"question": "Under which Act is the offence (Criminal "
|
| 552 |
+
"Code, IRPA, Firearms Act, Customs Act, CDSA, "
|
| 553 |
+
"Cannabis Act, or another)?"})
|
| 554 |
+
elif code == "other":
|
| 555 |
+
out["prescribed"] = False
|
| 556 |
+
out["prescribed_how"] = (
|
| 557 |
+
f"IRPR s. 19(1) prescribes offences under six Acts only -- the "
|
| 558 |
+
f"Criminal Code, IRPA, the Firearms Act, the Customs Act, the CDSA and "
|
| 559 |
+
f"the Cannabis Act -- and '{act}' is not read as one of them. If it is "
|
| 560 |
+
"one of the six, re-run with its name or code.")
|
| 561 |
+
elif R19_ACTS[code]["open"]:
|
| 562 |
+
out["paragraph"] = R19_ACTS[code]["paragraph"]
|
| 563 |
+
pres, how = _open_class(code, mode)
|
| 564 |
+
out["prescribed"], out["prescribed_how"] = pres, how
|
| 565 |
+
if pres is None:
|
| 566 |
+
name = R19_ACTS[code]["name"]
|
| 567 |
+
out["readings"] = [f"indictable or hybrid (s. 19(2) deems a hybrid "
|
| 568 |
+
f"indictable): prescribed under IRPR s. {out['paragraph']}",
|
| 569 |
+
"summary-only (may ONLY be prosecuted summarily): NOT "
|
| 570 |
+
"prescribed"]
|
| 571 |
+
out["needs"].append({"fact": "mode", "parameter": "mode",
|
| 572 |
+
"question": f"How may this {name} offence be "
|
| 573 |
+
"prosecuted: indictable, hybrid or "
|
| 574 |
+
"summary-only? (read the section's "
|
| 575 |
+
"punishment clause)"})
|
| 576 |
+
elif table is None:
|
| 577 |
+
out["paragraph"] = R19_ACTS[code]["paragraph"]
|
| 578 |
+
out["notes"].append(TABLE_HELD)
|
| 579 |
+
else:
|
| 580 |
+
pres, row, readings, needs = _from_table(table, code, sec, goods)
|
| 581 |
+
out["prescribed"], out["readings"] = pres, readings
|
| 582 |
+
out["needs"] += needs
|
| 583 |
+
if row:
|
| 584 |
+
out["paragraph"] = row.get("paragraph")
|
| 585 |
+
out["prescribed_how"] = row.get("note") or (
|
| 586 |
+
f"IRPR s. {row['paragraph']}" if row.get("paragraph") else None)
|
| 587 |
+
out["row"] = row.get("id")
|
| 588 |
+
elif pres is False:
|
| 589 |
+
out["prescribed_how"] = (f"not among the offences IRPR s. "
|
| 590 |
+
f"{R19_ACTS[code]['paragraph']} prescribes under the "
|
| 591 |
+
f"{R19_ACTS[code]['name']} (as CanLex tabulates "
|
| 592 |
+
"them, held for review)")
|
| 593 |
+
# The delegate's jurisdiction (s. 228(1)(a.01)) -- a rule on the section,
|
| 594 |
+
# whether or not the (c)-(g) table is present.
|
| 595 |
+
if code in R19_ACTS and out["prescribed"] is not False:
|
| 596 |
+
md, sub, mreadings = _md(code, sec, goods)
|
| 597 |
+
out["md_orderable"], out["md_readings"] = md, mreadings
|
| 598 |
+
if sub:
|
| 599 |
+
out["md_provision"] = f"IRPR s. {sub}"
|
| 600 |
+
if md is None:
|
| 601 |
+
fact = "goods" if (sec is not None and code == "C-52.6" and goods is None) else "section"
|
| 602 |
+
if not any(n["fact"] == fact for n in out["needs"]):
|
| 603 |
+
out["needs"].append({
|
| 604 |
+
"fact": fact, "parameter": fact,
|
| 605 |
+
"question": ("What were the goods? The delegate's order turns on them: "
|
| 606 |
+
if fact == "goods" else
|
| 607 |
+
"Give the section and subsection: the Minister's delegate "
|
| 608 |
+
"makes the order only for ")
|
| 609 |
+
+ "; ".join(mreadings)})
|
| 610 |
+
if (code == "C-52.6" and sec and sec[0] == "160"
|
| 611 |
+
and not any(n["fact"] == "section" for n in out["needs"])):
|
| 612 |
+
out["needs"].append({"fact": "section", "parameter": "section",
|
| 613 |
+
"question": "Customs Act s. 160(1) punishes many "
|
| 614 |
+
"contraventions: give the provision "
|
| 615 |
+
"contravened ('12' for failing to report "
|
| 616 |
+
"goods, '156', or '159(1)' for smuggling)."})
|
| 617 |
+
if person == "permanent-resident":
|
| 618 |
+
out["branch"] = "not-reached-pr"
|
| 619 |
+
elif out["prescribed"] is True:
|
| 620 |
+
out["branch"] = "transborder-criminality"
|
| 621 |
+
elif out["prescribed"] is False:
|
| 622 |
+
out["branch"] = "not-prescribed"
|
| 623 |
+
elif code in R19_ACTS and not R19_ACTS[code]["open"] and table is None:
|
| 624 |
+
out["branch"] = "table-held"
|
| 625 |
+
else:
|
| 626 |
+
out["branch"] = "not-settled"
|
| 627 |
+
# The removal path is reported only once the offence is prescribed; while
|
| 628 |
+
# that is open the memo gives it conditionally.
|
| 629 |
+
if person == "foreign-national" and out["prescribed"] is True:
|
| 630 |
+
if out["md_orderable"] is True:
|
| 631 |
+
out["removal"], out["removal_provision"] = "md-deportation-order", out["md_provision"]
|
| 632 |
+
elif out["md_orderable"] is False:
|
| 633 |
+
out["removal"], out["removal_provision"] = "id-referral", "IRPR s. 229(1)(d.1)"
|
| 634 |
+
return out
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# ---- the memo ---------------------------------------------------------------------
|
| 638 |
+
|
| 639 |
+
def _yn(v):
|
| 640 |
+
return {True: "YES", False: "NO", None: "NOT SETTLED"}[v]
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def render(r, offence=None, trp_fee=None, family_line=None, contravention=None):
|
| 644 |
+
"""The memo lines (no banner, no CANLEX_RESULT line) for an assess()
|
| 645 |
+
result. trp_fee: the IRPR s. 298 amount read from the corpus;
|
| 646 |
+
family_line: the s. 42 family-member line; contravention: a
|
| 647 |
+
contravention_check() result."""
|
| 648 |
+
lines = []
|
| 649 |
+
person = r["person"]
|
| 650 |
+
act_txt = (f"{r['act_name']} ({r['act']})" if r.get("act_name")
|
| 651 |
+
else (r.get("act_given") or "not given"))
|
| 652 |
+
facts = [f"Offence: {offence or 'not described'}",
|
| 653 |
+
f"Act: {act_txt}; section: {r['section'] or r.get('section_given') or 'not given'}",
|
| 654 |
+
f"Mode: {r['mode'] or 'not given'}"
|
| 655 |
+
+ (f" (from the curated pairing '{r['mode_source'][8:]}' -- confirm it)"
|
| 656 |
+
if (r.get("mode_source") or "").startswith("pairing:") else ""),
|
| 657 |
+
f"Goods: {r['goods'] or 'not given'}",
|
| 658 |
+
f"Person: {person}"]
|
| 659 |
+
lines += ["\n".join(facts), ""]
|
| 660 |
+
lines += ["## Who s. 36(2.1) reaches",
|
| 661 |
+
f"IRPA s. 36(2.1): '{S36_2_1}.' It reaches a foreign national only, "
|
| 662 |
+
"for an offence committed ON ENTERING Canada; it needs no conviction "
|
| 663 |
+
"('committing'), and the facts are proved on reasonable grounds to "
|
| 664 |
+
"believe (IRPA s. 33). Former s. 36(2)(d) is repealed (2023, c. 32, "
|
| 665 |
+
"s. 53).",
|
| 666 |
+
ACQUITTAL_NOTE]
|
| 667 |
+
if person == "permanent-resident":
|
| 668 |
+
lines.append("PERMANENT RESIDENT: s. 36(2.1) does not reach a permanent "
|
| 669 |
+
"resident. A permanent resident is reached for this conduct only "
|
| 670 |
+
"under s. 36(1)(a) once convicted in Canada (a maximum of at least "
|
| 671 |
+
"10 years, or more than six months imposed) -- s. 36(2)(a) reaches "
|
| 672 |
+
"foreign nationals only. s. 37 (organized criminality) is a "
|
| 673 |
+
"separate ground.")
|
| 674 |
+
lines += ["", "## Is it a prescribed offence? (IRPR s. 19)",
|
| 675 |
+
f"Prescribed: {_yn(r['prescribed'])}"
|
| 676 |
+
+ (f" -- {r['prescribed_how']}" if r.get("prescribed_how") else "")]
|
| 677 |
+
if r["readings"]:
|
| 678 |
+
lines.append("Each reading:")
|
| 679 |
+
lines += [f"- {x}" for x in r["readings"]]
|
| 680 |
+
for n in r["notes"]:
|
| 681 |
+
lines.append(f"NOTE: {n}")
|
| 682 |
+
if r.get("row"):
|
| 683 |
+
lines.append(f"(Row '{r['row']}' of the IRPR s. 19(1)(c)-(g) table -- HELD FOR "
|
| 684 |
+
"REVIEW.)")
|
| 685 |
+
lines += ["", "## Removal path"]
|
| 686 |
+
if person == "permanent-resident":
|
| 687 |
+
lines.append("Not reached: no s. 36(2.1) report lies against a permanent resident.")
|
| 688 |
+
elif r["prescribed"] is False:
|
| 689 |
+
lines.append("None on this ground: the offence is not prescribed.")
|
| 690 |
+
else:
|
| 691 |
+
cond = "" if r["prescribed"] else "IF the offence is prescribed (not settled above): "
|
| 692 |
+
if r["md_orderable"] is True:
|
| 693 |
+
lines.append(
|
| 694 |
+
f"{cond}The Minister's delegate makes a DEPORTATION ORDER ({r['md_provision']}): "
|
| 695 |
+
"the offence is one R228(1)(a.01) lists, so the report is not referred "
|
| 696 |
+
"to the Immigration Division -- but only where the report includes no "
|
| 697 |
+
f"other ground (R228(1): '{R228_OPENING}'), and not for a person who "
|
| 698 |
+
f"'{R228_4_A}' or who cannot appreciate the proceedings and is "
|
| 699 |
+
"unaccompanied (R228(4)).")
|
| 700 |
+
elif r["md_orderable"] is False:
|
| 701 |
+
lines.append(
|
| 702 |
+
f"{cond}Referred to the Immigration Division (IRPA s. 44(2)), which makes a "
|
| 703 |
+
"DEPORTATION ORDER (IRPR s. 229(1)(d.1): transborder criminality for "
|
| 704 |
+
f"'{R229_D1}'). The delegate cannot make the order: the offence is not "
|
| 705 |
+
"one R228(1)(a.01) lists.")
|
| 706 |
+
else:
|
| 707 |
+
lines.append(f"{cond}NOT SETTLED: the delegate makes the deportation order (IRPR "
|
| 708 |
+
"s. 228(1)(a.01)) only for the offences it lists; every other "
|
| 709 |
+
"prescribed offence is referred to the Immigration Division "
|
| 710 |
+
"(IRPR s. 229(1)(d.1)). The delegate's list for this Act:")
|
| 711 |
+
lines += [f"- {x}" for x in r["md_readings"]] or ["- (none)"]
|
| 712 |
+
lines.append("At a port of entry the alternatives are allowing the person to "
|
| 713 |
+
"withdraw the application to enter and leave (IRPR s. 42 -- once a "
|
| 714 |
+
"s. 44(1) report is being prepared, only if the Minister decides not "
|
| 715 |
+
"to make a removal order or not to refer the report, s. 42(2)) or a "
|
| 716 |
+
"temporary resident permit (below).")
|
| 717 |
+
lines += ["", "## Rehabilitation",
|
| 718 |
+
"None: s. 36(3)(c) cures only 'the matters referred to in paragraphs "
|
| 719 |
+
"(1)(b) and (c) and (2)(b) and (c)' -- s. 36(2.1) is not among them, so "
|
| 720 |
+
"neither deemed nor individual rehabilitation (IRPR ss. 17-18) reaches "
|
| 721 |
+
"transborder criminality."]
|
| 722 |
+
if contravention:
|
| 723 |
+
lines += ["", "## Contraventions", contravention["text"]]
|
| 724 |
+
lines += ["", "## Next steps"]
|
| 725 |
+
if person == "foreign-national" and r["prescribed"] is not False:
|
| 726 |
+
lead = "" if r["prescribed"] else "If the offence is prescribed (above): "
|
| 727 |
+
name = (offence or "the offence").strip()
|
| 728 |
+
cite = (f" contrary to section {r['section']} of the {r['act_name']}"
|
| 729 |
+
if r.get("section") and r.get("act_name") else
|
| 730 |
+
f" under the {r['act_name']}" if r.get("act_name") else "")
|
| 731 |
+
para = (f", an offence prescribed by paragraph {r['paragraph']} of the Immigration "
|
| 732 |
+
"and Refugee Protection Regulations" if r.get("paragraph")
|
| 733 |
+
and r["prescribed"] else "")
|
| 734 |
+
deemed = (" (a hybrid offence, deemed indictable by IRPR s. 19(2))"
|
| 735 |
+
if r.get("mode") == "hybrid" and r.get("paragraph") in ("19(1)(a)", "19(1)(b)")
|
| 736 |
+
else "")
|
| 737 |
+
lines.append(f"{lead}Draft allegation paragraph (s. 36(2.1) wording; ENF 5 s. 13 "
|
| 738 |
+
"sets out the report narrative): the person is a "
|
| 739 |
+
"foreign national inadmissible under IRPA subsection 36(2.1) for "
|
| 740 |
+
f"{ALLEGATION_2_1}, namely {name}{cite}{para}{deemed}, committed on "
|
| 741 |
+
"entering Canada on <date> at <port of entry>. Record in the "
|
| 742 |
+
"narrative the facts of entry, the evidence (statutory declaration, "
|
| 743 |
+
"notebook, seizure record) and the section contravened.")
|
| 744 |
+
lines.append("Temporary resident permit (IRPA s. 24): available at the officer's "
|
| 745 |
+
"discretion where entry is justified in the circumstances, subject "
|
| 746 |
+
"to the s. 24(3.1)-(5) bars; the processing fee under IRPR s. 298 is "
|
| 747 |
+
+ (f"${trp_fee}" if trp_fee else "set out in IRPR s. 298") + ".")
|
| 748 |
+
elif person == "foreign-national":
|
| 749 |
+
lines.append("No s. 36(2.1) allegation: the offence is not prescribed. Consider "
|
| 750 |
+
"whether another ground applies (a conviction under s. 36(2)(a), "
|
| 751 |
+
"s. 41 non-compliance).")
|
| 752 |
+
if family_line and person == "foreign-national":
|
| 753 |
+
lines.append(family_line)
|
| 754 |
+
lines += ["", "## Guidance", GUIDANCE_CURRENT, GUIDANCE_STALE]
|
| 755 |
+
return lines
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def bottom_line(r):
|
| 759 |
+
person = r["person"]
|
| 760 |
+
if person == "permanent-resident":
|
| 761 |
+
return ("NOT REACHED -- s. 36(2.1) does not reach a permanent resident; "
|
| 762 |
+
"s. 36(1)(a) only once convicted.")
|
| 763 |
+
if r["branch"] == "transborder-criminality":
|
| 764 |
+
how = ("the delegate makes the deportation order (" + r["md_provision"] + ")"
|
| 765 |
+
if r["md_orderable"] is True else
|
| 766 |
+
"referral to the Immigration Division (IRPR s. 229(1)(d.1))"
|
| 767 |
+
if r["md_orderable"] is False else
|
| 768 |
+
"the delegate or the Immigration Division, by section (not settled)")
|
| 769 |
+
return (f"TRANSBORDER CRIMINALITY (IRPA s. 36(2.1); IRPR s. {r['paragraph']}) "
|
| 770 |
+
f"if committed on entering Canada -- {how}; no rehabilitation; TRP "
|
| 771 |
+
"available.")
|
| 772 |
+
if r["branch"] == "not-prescribed":
|
| 773 |
+
return "NOT A PRESCRIBED OFFENCE -- no s. 36(2.1) ground on these facts."
|
| 774 |
+
if r["branch"] == "table-held":
|
| 775 |
+
return ("NOT ASSESSED -- the IRPR s. 19(1)(c)-(g) table is held for review; read "
|
| 776 |
+
"s. 19(1) for this offence.")
|
| 777 |
+
return "NOT SETTLED -- " + "; ".join(n["question"] for n in r["needs"]) if r["needs"] \
|
| 778 |
+
else "NOT SETTLED."
|