Beemer Claude Fable 5 commited on
Commit ·
023c3b2
1
Parent(s): ebba99b
Three new tools (whats_new/citing/toc), ENF 2 ATIP ingestion, PDI scraper, duplicate-id fix
Browse filescanlex_whats_new (amendments since a date), canlex_citing (reverse xrefs),
canlex_toc (Act structure); webapp declares all ten. ENF 2 ingested from
its ATIP release with an UNOFFICIAL banner on every chunk (ENF 14 blocked:
image-scan PDF, needs OCR). pdi.py ingests the PR-card PDI section (ENF 27
successor) and the Tran page. uniquify_ids fixes 161 duplicate chunk ids
(enf TOC/body collisions + 6 pre-existing in delegation) that silently
orphaned 394 chunks from their embeddings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- canlex/_common.py +18 -0
- canlex/delegation.py +2 -1
- canlex/enf.py +42 -4
- canlex/irb_guidelines.py +2 -1
- canlex/pdi.py +141 -0
- canlex/server.py +177 -0
- webapp/app.py +59 -0
canlex/_common.py
CHANGED
|
@@ -92,3 +92,21 @@ def split_lines(text, target=1800, fold_stubs=False):
|
|
| 92 |
pieces[1] = pieces[0] + "\n" + pieces[1]
|
| 93 |
pieces.pop(0)
|
| 94 |
return pieces
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
pieces[1] = pieces[0] + "\n" + pieces[1]
|
| 93 |
pieces.pop(0)
|
| 94 |
return pieces
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def uniquify_ids(chunks):
|
| 98 |
+
"""Rename duplicate chunk ids in place (second occurrence -> id-b, -c...).
|
| 99 |
+
|
| 100 |
+
Duplicate ids silently orphan chunks downstream: the embeddings loader
|
| 101 |
+
keys rows by id, so one twin gets vectors and the other degrades to
|
| 102 |
+
BM25-only with nothing but a stderr note. Generators whose source
|
| 103 |
+
numbering can repeat (a manual's TOC line and body heading both reading
|
| 104 |
+
'5.1'; delegation items re-using an instrument number) run their output
|
| 105 |
+
through this before writing."""
|
| 106 |
+
seen = {}
|
| 107 |
+
for c in chunks:
|
| 108 |
+
n = seen.get(c["id"], 0)
|
| 109 |
+
seen[c["id"]] = n + 1
|
| 110 |
+
if n:
|
| 111 |
+
c["id"] = f"{c['id']}-{chr(ord('a') + n)}"
|
| 112 |
+
return chunks
|
canlex/delegation.py
CHANGED
|
@@ -19,7 +19,7 @@ import re
|
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
from pypdf import PdfReader
|
| 21 |
|
| 22 |
-
from ._common import fetch_cached, norm_ws as _norm
|
| 23 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 24 |
|
| 25 |
RAW = RAW_DIR / "delegation"
|
|
@@ -465,6 +465,7 @@ def build():
|
|
| 465 |
all_chunks.extend(chunks)
|
| 466 |
print(f" {len(chunks)} chunks")
|
| 467 |
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 468 |
OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
|
| 469 |
encoding="utf-8")
|
| 470 |
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
|
|
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
from pypdf import PdfReader
|
| 21 |
|
| 22 |
+
from ._common import fetch_cached, norm_ws as _norm, uniquify_ids
|
| 23 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 24 |
|
| 25 |
RAW = RAW_DIR / "delegation"
|
|
|
|
| 465 |
all_chunks.extend(chunks)
|
| 466 |
print(f" {len(chunks)} chunks")
|
| 467 |
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 468 |
+
uniquify_ids(all_chunks)
|
| 469 |
OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
|
| 470 |
encoding="utf-8")
|
| 471 |
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
canlex/enf.py
CHANGED
|
@@ -18,7 +18,7 @@ the D-memoranda.
|
|
| 18 |
import json
|
| 19 |
import re
|
| 20 |
|
| 21 |
-
from ._common import fetch_cached, norm_ws as _norm, split_lines
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
|
@@ -37,6 +37,32 @@ _URL_OVERRIDES = {
|
|
| 37 |
"resources/manuals/enf/enf20a-en.pdf"),
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# Curated titles for the core chapters; anything else gets a parsed or
|
| 41 |
# generic title. Only used for display -- retrieval works off the text.
|
| 42 |
TITLES = {
|
|
@@ -141,10 +167,14 @@ def _norm_block(lines):
|
|
| 141 |
def build(chapters=_CHAPTERS):
|
| 142 |
all_chunks, skipped = [], []
|
| 143 |
for n in chapters:
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
dest = RAW / f"enf{n:02d}.pdf"
|
| 146 |
try:
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
except Exception:
|
| 149 |
skipped.append(n)
|
| 150 |
if dest.exists():
|
|
@@ -155,8 +185,16 @@ def build(chapters=_CHAPTERS):
|
|
| 155 |
except Exception as exc:
|
| 156 |
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
| 157 |
continue
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
all_chunks.extend(chunks)
|
|
|
|
| 160 |
out = PROCESSED_DIR / "enf.json"
|
| 161 |
out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
|
| 162 |
encoding="utf-8")
|
|
|
|
| 18 |
import json
|
| 19 |
import re
|
| 20 |
|
| 21 |
+
from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
|
|
|
| 37 |
"resources/manuals/enf/enf20a-en.pdf"),
|
| 38 |
}
|
| 39 |
|
| 40 |
+
# Chapters IRCC withdrew from public distribution whose only public text is
|
| 41 |
+
# an ATIP-released copy hosted by a third party (meurrensonimmigration.com,
|
| 42 |
+
# verified live 2026-07-22). Ingested at the maintainer's decision
|
| 43 |
+
# (2026-07-23) with an unmistakable UNOFFICIAL banner on every chunk: the
|
| 44 |
+
# text may lag the internal current version, and nothing here is an official
|
| 45 |
+
# publication. ENF 14/OP 19 is the operational manual behind criminal
|
| 46 |
+
# rehabilitation -- directly relevant to canlex_rehabilitation.
|
| 47 |
+
UNOFFICIAL = {
|
| 48 |
+
2: {"url": "https://meurrensonimmigration.com/wp-content/uploads/2023/06/"
|
| 49 |
+
"ENF2-3.pdf",
|
| 50 |
+
"title": "Evaluating Inadmissibility (unofficial ATIP copy)",
|
| 51 |
+
"note": "ATIP-released copy dated 2021-08-25; retired from canada.ca "
|
| 52 |
+
"in 2018 with no public successor"},
|
| 53 |
+
# ENF 14/OP 19 (Criminal Rehabilitation) is NOT here despite the
|
| 54 |
+
# maintainer authorizing it: the ATIP release at meurrensonimmigration
|
| 55 |
+
# .com/wp-content/uploads/2025/03/Enforcement-Manual-14-...pdf is a
|
| 56 |
+
# 56-page image scan with no text layer -- pypdf extracts zero
|
| 57 |
+
# characters. Ingesting it needs an OCR pass (tesseract or the Windows
|
| 58 |
+
# OCR API); parked until that toolchain exists.
|
| 59 |
+
}
|
| 60 |
+
_UNOFFICIAL_BANNER = (
|
| 61 |
+
"[UNOFFICIAL ATIP COPY — this chapter was withdrawn from public "
|
| 62 |
+
"distribution; this text comes from an access-to-information release "
|
| 63 |
+
"hosted by a third party and may lag the current internal version. "
|
| 64 |
+
"Treat as historical guidance; verify against internal sources.] ")
|
| 65 |
+
|
| 66 |
# Curated titles for the core chapters; anything else gets a parsed or
|
| 67 |
# generic title. Only used for display -- retrieval works off the text.
|
| 68 |
TITLES = {
|
|
|
|
| 167 |
def build(chapters=_CHAPTERS):
|
| 168 |
all_chunks, skipped = [], []
|
| 169 |
for n in chapters:
|
| 170 |
+
unofficial = UNOFFICIAL.get(n)
|
| 171 |
+
url = (unofficial["url"] if unofficial
|
| 172 |
+
else _URL_OVERRIDES.get(n, BASE.format(n=n)))
|
| 173 |
dest = RAW / f"enf{n:02d}.pdf"
|
| 174 |
try:
|
| 175 |
+
# The ATIP host is an ordinary WordPress site; canada.ca needs
|
| 176 |
+
# the PowerShell TLS workaround.
|
| 177 |
+
fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0)
|
| 178 |
except Exception:
|
| 179 |
skipped.append(n)
|
| 180 |
if dest.exists():
|
|
|
|
| 185 |
except Exception as exc:
|
| 186 |
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
| 187 |
continue
|
| 188 |
+
if unofficial:
|
| 189 |
+
for c in chunks:
|
| 190 |
+
c["act_name"] = f"ENF {n} — {unofficial['title']}"
|
| 191 |
+
c["text"] = _UNOFFICIAL_BANNER + c["text"]
|
| 192 |
+
c["citation"] += " (unofficial ATIP copy)"
|
| 193 |
+
c["history"] = unofficial["note"]
|
| 194 |
+
print(f" ENF {n:2d}: {len(chunks)} chunks"
|
| 195 |
+
+ (" [UNOFFICIAL ATIP]" if unofficial else ""))
|
| 196 |
all_chunks.extend(chunks)
|
| 197 |
+
uniquify_ids(all_chunks)
|
| 198 |
out = PROCESSED_DIR / "enf.json"
|
| 199 |
out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
|
| 200 |
encoding="utf-8")
|
canlex/irb_guidelines.py
CHANGED
|
@@ -17,7 +17,7 @@ import re
|
|
| 17 |
|
| 18 |
from bs4 import BeautifulSoup
|
| 19 |
|
| 20 |
-
from ._common import fetch_cached, norm_ws as _norm, split_lines
|
| 21 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 22 |
|
| 23 |
RAW = RAW_DIR / "irb_guidelines"
|
|
@@ -116,6 +116,7 @@ def build():
|
|
| 116 |
n += 1
|
| 117 |
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 118 |
f"effective {effective or '?'}")
|
|
|
|
| 119 |
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 120 |
out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
|
| 121 |
encoding="utf-8")
|
|
|
|
| 17 |
|
| 18 |
from bs4 import BeautifulSoup
|
| 19 |
|
| 20 |
+
from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
|
| 21 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 22 |
|
| 23 |
RAW = RAW_DIR / "irb_guidelines"
|
|
|
|
| 116 |
n += 1
|
| 117 |
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 118 |
f"effective {effective or '?'}")
|
| 119 |
+
uniquify_ids(chunks)
|
| 120 |
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 121 |
out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
|
| 122 |
encoding="utf-8")
|
canlex/pdi.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 11 |
+
"""
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
|
| 16 |
+
from .config import PROCESSED_DIR, RAW_DIR
|
| 17 |
+
|
| 18 |
+
RAW = RAW_DIR / "pdi"
|
| 19 |
+
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 20 |
+
"publications-manuals/operational-bulletins-manuals/")
|
| 21 |
+
|
| 22 |
+
SETS = [
|
| 23 |
+
{"code": "pdi-prcard",
|
| 24 |
+
"short": "PDI PR Card",
|
| 25 |
+
"name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
|
| 26 |
+
"index": _OBM + "permanent-residence/card.html",
|
| 27 |
+
# sub-pages live under these path fragments relative to the index
|
| 28 |
+
"scope": "/permanent-residence/card"},
|
| 29 |
+
{"code": "pdi-tran",
|
| 30 |
+
"short": "PDI Tran",
|
| 31 |
+
"name": "PDI — Assessing inadmissibility for serious criminality "
|
| 32 |
+
"following Tran v. Canada",
|
| 33 |
+
"index": _OBM + "standard-requirements/tran.html",
|
| 34 |
+
"scope": None}, # single page
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
| 38 |
+
_TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
|
| 39 |
+
_LINK = re.compile(r'href="([^"#?]*?\.html)"')
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _strip_tags(html):
|
| 43 |
+
html = re.sub(r"</t[dh]>", " | ", html)
|
| 44 |
+
html = re.sub(r"</(?:p|li|tr|h\d|caption)>", "\n", html)
|
| 45 |
+
html = re.sub(r"<[^>]+>", " ", html)
|
| 46 |
+
return [l for l in (_norm(x).strip("| ").strip()
|
| 47 |
+
for x in html.split("\n")) if l]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _main(html):
|
| 51 |
+
m = re.search(r"<main[^>]*>(.*?)</main>", html, re.S)
|
| 52 |
+
return m.group(1) if m else html
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _page_chunks(src, page_url, html, page_no):
|
| 56 |
+
content = _main(html)
|
| 57 |
+
title_m = _TITLE.search(content)
|
| 58 |
+
title = (_norm(re.sub(r"<[^>]+>", " ", title_m.group(1)))
|
| 59 |
+
if title_m else src["short"])
|
| 60 |
+
mod = _MODIFIED.search(html)
|
| 61 |
+
modified = mod.group(1) if mod else ""
|
| 62 |
+
# Split on h2 headings; each becomes a section-like unit.
|
| 63 |
+
parts = re.split(r"<h2[^>]*>", content[title_m.end():] if title_m else content)
|
| 64 |
+
chunks = []
|
| 65 |
+
for i, part in enumerate(parts):
|
| 66 |
+
if i == 0:
|
| 67 |
+
heading, body_html = title, part
|
| 68 |
+
else:
|
| 69 |
+
head_end = part.find("</h2>")
|
| 70 |
+
heading = _norm(re.sub(r"<[^>]+>", " ", part[:head_end]))
|
| 71 |
+
body_html = part[head_end:]
|
| 72 |
+
body = "\n".join(_strip_tags(body_html))
|
| 73 |
+
if len(body) < 120:
|
| 74 |
+
continue
|
| 75 |
+
pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
|
| 76 |
+
total = len(pieces)
|
| 77 |
+
for k, piece in enumerate(pieces, start=1):
|
| 78 |
+
suffix = "" if total == 1 else f"-p{k}"
|
| 79 |
+
note = "" if total == 1 else f" (part {k} of {total})"
|
| 80 |
+
chunks.append({
|
| 81 |
+
"id": f"{src['code']}-{page_no}-{i}{suffix}",
|
| 82 |
+
"doc_type": "memorandum",
|
| 83 |
+
"act_code": src["code"].upper(),
|
| 84 |
+
"act_short": src["short"],
|
| 85 |
+
"act_name": src["name"],
|
| 86 |
+
"section": f"{title[:60]} — {heading[:60]}" if i else title[:80],
|
| 87 |
+
"marginal_note": heading,
|
| 88 |
+
"part": src["name"],
|
| 89 |
+
"division": "",
|
| 90 |
+
"heading": "",
|
| 91 |
+
"text": piece,
|
| 92 |
+
"history": "",
|
| 93 |
+
"last_amended": modified,
|
| 94 |
+
"current_to": modified,
|
| 95 |
+
"citation": f"{src['short']}: {heading}{note}",
|
| 96 |
+
"source_url": page_url,
|
| 97 |
+
})
|
| 98 |
+
return chunks
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build():
|
| 102 |
+
all_chunks = []
|
| 103 |
+
for src in SETS:
|
| 104 |
+
index_html = fetch_cached(
|
| 105 |
+
src["index"], RAW / f"{src['code']}-index.html",
|
| 106 |
+
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 107 |
+
pages = [(src["index"], index_html)]
|
| 108 |
+
if src["scope"]:
|
| 109 |
+
seen = {src["index"]}
|
| 110 |
+
for href in _LINK.findall(_main(index_html)):
|
| 111 |
+
if src["scope"] not in href:
|
| 112 |
+
continue
|
| 113 |
+
url = href if href.startswith("http") else \
|
| 114 |
+
"https://www.canada.ca" + href
|
| 115 |
+
if url in seen:
|
| 116 |
+
continue
|
| 117 |
+
seen.add(url)
|
| 118 |
+
name = url.rsplit("/", 1)[1].replace(".html", "")
|
| 119 |
+
try:
|
| 120 |
+
page = fetch_cached(
|
| 121 |
+
url, RAW / f"{src['code']}-{name}.html",
|
| 122 |
+
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 123 |
+
pages.append((url, page))
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 126 |
+
n = 0
|
| 127 |
+
for page_no, (url, html) in enumerate(pages):
|
| 128 |
+
chunks = _page_chunks(src, url, html, page_no)
|
| 129 |
+
all_chunks.extend(chunks)
|
| 130 |
+
n += len(chunks)
|
| 131 |
+
print(f" {src['short']}: {len(pages)} pages, {n} chunks")
|
| 132 |
+
uniquify_ids(all_chunks)
|
| 133 |
+
out = PROCESSED_DIR / "pdi.json"
|
| 134 |
+
out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
|
| 135 |
+
encoding="utf-8")
|
| 136 |
+
print(f"pdi: {len(all_chunks)} chunks -> {out.name}")
|
| 137 |
+
return all_chunks
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
build()
|
canlex/server.py
CHANGED
|
@@ -718,7 +718,15 @@ def canlex_list_acts() -> str:
|
|
| 718 |
memo_numbers: set[str] = set()
|
| 719 |
memo_chunks = 0
|
| 720 |
memo_date = ""
|
|
|
|
|
|
|
| 721 |
for c in index.chunks:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
doc_type = c.get("doc_type", "legislation")
|
| 723 |
if doc_type == "memorandum":
|
| 724 |
memo_numbers.add(c["section"])
|
|
@@ -765,6 +773,10 @@ def canlex_list_acts() -> str:
|
|
| 765 |
})
|
| 766 |
entry["count"] += 1
|
| 767 |
lines = ["# CanLex corpus", "", "## Enacted law"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
for a in sorted(acts.values(), key=lambda x: x["short"]):
|
| 769 |
lines.append(f"- **{a['short']}** — {a['name']} ({a['code']}): "
|
| 770 |
f"{a['count']} sections, current to {a['current_to'] or 'n/a'}")
|
|
@@ -814,6 +826,171 @@ def canlex_list_acts() -> str:
|
|
| 814 |
return "\n".join(lines)
|
| 815 |
|
| 816 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
_CITATOR = None
|
| 818 |
|
| 819 |
|
|
|
|
| 718 |
memo_numbers: set[str] = set()
|
| 719 |
memo_chunks = 0
|
| 720 |
memo_date = ""
|
| 721 |
+
fr_chunks = 0
|
| 722 |
+
fr_acts: set[str] = set()
|
| 723 |
for c in index.chunks:
|
| 724 |
+
if c.get("lang", "en") == "fr":
|
| 725 |
+
# French twins share their English instrument's identity; count
|
| 726 |
+
# them once as coverage rather than doubling every act's rows.
|
| 727 |
+
fr_chunks += 1
|
| 728 |
+
fr_acts.add(c["act_code"])
|
| 729 |
+
continue
|
| 730 |
doc_type = c.get("doc_type", "legislation")
|
| 731 |
if doc_type == "memorandum":
|
| 732 |
memo_numbers.add(c["section"])
|
|
|
|
| 773 |
})
|
| 774 |
entry["count"] += 1
|
| 775 |
lines = ["# CanLex corpus", "", "## Enacted law"]
|
| 776 |
+
if fr_chunks:
|
| 777 |
+
lines.insert(1, f"_Bilingual: {len(fr_acts)} instruments also carry "
|
| 778 |
+
f"their parallel French text ({fr_chunks} articles); "
|
| 779 |
+
f"a French-language query searches both._")
|
| 780 |
for a in sorted(acts.values(), key=lambda x: x["short"]):
|
| 781 |
lines.append(f"- **{a['short']}** — {a['name']} ({a['code']}): "
|
| 782 |
f"{a['count']} sections, current to {a['current_to'] or 'n/a'}")
|
|
|
|
| 826 |
return "\n".join(lines)
|
| 827 |
|
| 828 |
|
| 829 |
+
@mcp.tool(name="canlex_whats_new",
|
| 830 |
+
annotations={"title": "What Changed Recently", **_READONLY})
|
| 831 |
+
def canlex_whats_new(
|
| 832 |
+
since: Annotated[str, Field(
|
| 833 |
+
description="ISO date (YYYY-MM-DD): report amendments and additions "
|
| 834 |
+
"on or after this date.", pattern=r"^\d{4}-\d{2}-\d{2}$")],
|
| 835 |
+
) -> str:
|
| 836 |
+
"""List what has changed in the corpus's law since a date: provisions
|
| 837 |
+
amended or newly in force on or after it, plus each guidance source's
|
| 838 |
+
newest modification date.
|
| 839 |
+
|
| 840 |
+
Use for questions like "has IRPA changed since March?" or "what did the
|
| 841 |
+
last refresh pick up?". Changes reflect the ingested consolidations --
|
| 842 |
+
an amendment newer than the corpus's 'current to' dates is invisible
|
| 843 |
+
here; say so when the date asked about is very recent.
|
| 844 |
+
|
| 845 |
+
Returns:
|
| 846 |
+
str: Markdown -- amended/new provisions grouped by Act, then
|
| 847 |
+
guidance sources' newest dates.
|
| 848 |
+
"""
|
| 849 |
+
index = _index()
|
| 850 |
+
amended: dict[str, list] = {}
|
| 851 |
+
guidance_newest: dict[str, str] = {}
|
| 852 |
+
for c in index.chunks:
|
| 853 |
+
if c.get("lang", "en") == "fr":
|
| 854 |
+
continue
|
| 855 |
+
if c.get("doc_type", "legislation") == "legislation":
|
| 856 |
+
stamp = max(c.get("last_amended") or "", c.get("in_force") or "")
|
| 857 |
+
if stamp >= since and c.get("section"):
|
| 858 |
+
amended.setdefault(c["act_short"], []).append(
|
| 859 |
+
(c["section"], c.get("marginal_note", ""), stamp))
|
| 860 |
+
else:
|
| 861 |
+
key = c.get("act_short") or c.get("doc_type")
|
| 862 |
+
cur = c.get("current_to") or ""
|
| 863 |
+
if cur > guidance_newest.get(key, ""):
|
| 864 |
+
guidance_newest[key] = cur
|
| 865 |
+
lines = [f"# Changes since {since}", ""]
|
| 866 |
+
if amended:
|
| 867 |
+
lines.append("## Provisions amended or newly in force")
|
| 868 |
+
for act in sorted(amended):
|
| 869 |
+
secs = sorted({(s, n, d) for s, n, d in amended[act]},
|
| 870 |
+
key=lambda t: t[2], reverse=True)
|
| 871 |
+
lines.append(f"**{act}** ({len(secs)} provisions):")
|
| 872 |
+
for s, n, d in secs[:25]:
|
| 873 |
+
lines.append(f"- s. {s} ({n}) — {d}")
|
| 874 |
+
if len(secs) > 25:
|
| 875 |
+
lines.append(f"- ...and {len(secs) - 25} more.")
|
| 876 |
+
else:
|
| 877 |
+
lines.append("No ingested provision shows an amendment or "
|
| 878 |
+
"coming-into-force date on or after that date.")
|
| 879 |
+
recent_guidance = {k: v for k, v in guidance_newest.items() if v >= since}
|
| 880 |
+
if recent_guidance:
|
| 881 |
+
lines += ["", "## Guidance sources modified since then"]
|
| 882 |
+
for k, v in sorted(recent_guidance.items(), key=lambda kv: -ord(kv[1][0])
|
| 883 |
+
if kv[1] else 0):
|
| 884 |
+
lines.append(f"- {k}: {v}")
|
| 885 |
+
lines += ["", "_Reflects the ingested consolidations only; verify "
|
| 886 |
+
"anything newer than the corpus's 'current to' dates upstream._"]
|
| 887 |
+
return "\n".join(lines)
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
@mcp.tool(name="canlex_citing",
|
| 891 |
+
annotations={"title": "What Cites This Provision", **_READONLY})
|
| 892 |
+
def canlex_citing(
|
| 893 |
+
act: Annotated[str, Field(
|
| 894 |
+
description="Act short name or code, e.g. 'IRPA'.",
|
| 895 |
+
min_length=1, max_length=60)],
|
| 896 |
+
section: Annotated[str, Field(
|
| 897 |
+
description="Section number, e.g. '34' (prefixes accepted).",
|
| 898 |
+
min_length=1, max_length=40)],
|
| 899 |
+
) -> str:
|
| 900 |
+
"""Reverse cross-reference lookup: everything in the corpus that cites a
|
| 901 |
+
given provision -- related provisions in the same Act, regulations made
|
| 902 |
+
under it, and the CBSA D-memoranda/guidance citing the section --
|
| 903 |
+
without fetching the section's own text first.
|
| 904 |
+
|
| 905 |
+
Returns:
|
| 906 |
+
str: Markdown lists of citing/related material, or a not-found
|
| 907 |
+
message.
|
| 908 |
+
"""
|
| 909 |
+
index = _index()
|
| 910 |
+
result = index.get_section(act, section)
|
| 911 |
+
if result is None and section != _normalize_section(section):
|
| 912 |
+
result = index.get_section(act, _normalize_section(section))
|
| 913 |
+
if result is None:
|
| 914 |
+
return (f"No section '{section}' in '{act}'. Use canlex_list_acts "
|
| 915 |
+
f"for the loaded instruments.")
|
| 916 |
+
related = index.related(result) or {}
|
| 917 |
+
lines = [f"# Material citing {result['citation']}", ""]
|
| 918 |
+
memos = related.get("memoranda")
|
| 919 |
+
if memos:
|
| 920 |
+
lines.append("**Guidance citing this section** (persuasive, not "
|
| 921 |
+
"binding): " + ", ".join(memos))
|
| 922 |
+
provisions = related.get("provisions")
|
| 923 |
+
if provisions:
|
| 924 |
+
lines.append("**Related provisions in this Act:** "
|
| 925 |
+
+ "; ".join(f"s. {s} ({n})" if n else f"s. {s}"
|
| 926 |
+
for s, n in provisions))
|
| 927 |
+
regs = related.get("regulations")
|
| 928 |
+
if regs:
|
| 929 |
+
lines.append("**Regulations made under this Act:** "
|
| 930 |
+
+ "; ".join(f"{n} ({s})" for s, n in regs))
|
| 931 |
+
enabling = related.get("enabling_act")
|
| 932 |
+
if enabling:
|
| 933 |
+
lines.append(f"**Made under:** {enabling[1]} ({enabling[0]})")
|
| 934 |
+
if len(lines) == 2:
|
| 935 |
+
lines.append("Nothing in the corpus cross-references this section.")
|
| 936 |
+
lines += ["", "For case law interpreting it, search with "
|
| 937 |
+
"canlex_search_legislation (doc_type 'caselaw')."]
|
| 938 |
+
return "\n".join(lines)
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
@mcp.tool(name="canlex_toc",
|
| 942 |
+
annotations={"title": "Act Table of Contents", **_READONLY})
|
| 943 |
+
def canlex_toc(
|
| 944 |
+
act: Annotated[str, Field(
|
| 945 |
+
description="Act short name or code, e.g. 'IRPA' or 'Customs Act'.",
|
| 946 |
+
min_length=1, max_length=60)],
|
| 947 |
+
) -> str:
|
| 948 |
+
"""Browse an Act's structure: its Parts/Divisions and the sections under
|
| 949 |
+
each, with their marginal notes. Use when you know the Act but not the
|
| 950 |
+
section number, or to survey what an Act covers.
|
| 951 |
+
|
| 952 |
+
Returns:
|
| 953 |
+
str: Markdown outline. Very large Acts (e.g. the Criminal Code)
|
| 954 |
+
return Parts with section ranges rather than every section.
|
| 955 |
+
"""
|
| 956 |
+
index = _index()
|
| 957 |
+
a = act.strip().lower()
|
| 958 |
+
secs = [c for c in index.chunks
|
| 959 |
+
if c.get("doc_type", "legislation") == "legislation"
|
| 960 |
+
and c.get("lang", "en") == "en"
|
| 961 |
+
and a in (c["act_short"].lower(), c["act_code"].lower(),
|
| 962 |
+
c.get("act_name", "").lower())
|
| 963 |
+
and c.get("section") and not c["id"].endswith(tuple(
|
| 964 |
+
f"-p{k}" for k in range(2, 40)))]
|
| 965 |
+
if not secs:
|
| 966 |
+
known = sorted({c["act_short"] for c in index.chunks
|
| 967 |
+
if c.get("doc_type", "legislation") == "legislation"})
|
| 968 |
+
return (f"No legislation matches '{act}'. Loaded: "
|
| 969 |
+
+ ", ".join(known))
|
| 970 |
+
by_part: dict[str, list] = {}
|
| 971 |
+
seen = set()
|
| 972 |
+
for c in secs:
|
| 973 |
+
if c["section"] in seen:
|
| 974 |
+
continue
|
| 975 |
+
seen.add(c["section"])
|
| 976 |
+
by_part.setdefault(c.get("part") or "(no Part)", []).append(
|
| 977 |
+
(c["section"], c.get("marginal_note", "")))
|
| 978 |
+
name = secs[0]["act_name"]
|
| 979 |
+
total = sum(len(v) for v in by_part.values())
|
| 980 |
+
lines = [f"# {name} — contents ({total} sections)", ""]
|
| 981 |
+
detailed = total <= 150
|
| 982 |
+
for part, entries in by_part.items():
|
| 983 |
+
lines.append(f"## {part}" if part != "(no Part)" else "## —")
|
| 984 |
+
if detailed:
|
| 985 |
+
for s, n in entries:
|
| 986 |
+
lines.append(f"- s. {s}" + (f" — {n}" if n else ""))
|
| 987 |
+
else:
|
| 988 |
+
first, last = entries[0][0], entries[-1][0]
|
| 989 |
+
lines.append(f"- ss. {first}–{last} ({len(entries)} sections)")
|
| 990 |
+
lines += ["", "Fetch any section with canlex_get_section."]
|
| 991 |
+
return "\n".join(lines)
|
| 992 |
+
|
| 993 |
+
|
| 994 |
_CITATOR = None
|
| 995 |
|
| 996 |
|
webapp/app.py
CHANGED
|
@@ -161,6 +161,58 @@ TOOL_DECLARATIONS = [
|
|
| 161 |
"required": ["act", "section"],
|
| 162 |
},
|
| 163 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
{
|
| 165 |
"name": "canlex_list_acts",
|
| 166 |
"description": (
|
|
@@ -546,6 +598,13 @@ def _summarize_call(name: str, args: dict) -> str:
|
|
| 546 |
return f"Looking up case {args.get('case_url', '?')}"
|
| 547 |
if name == "canlex_list_acts":
|
| 548 |
return "Listing the CanLex corpus"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
if name == "canlex_us_disposition":
|
| 550 |
state = f" ({args['state']})" if args.get("state") else ""
|
| 551 |
return (f"Checking US disposition "
|
|
|
|
| 161 |
"required": ["act", "section"],
|
| 162 |
},
|
| 163 |
},
|
| 164 |
+
{
|
| 165 |
+
"name": "canlex_whats_new",
|
| 166 |
+
"description": (
|
| 167 |
+
"List provisions amended or newly in force since a date, plus "
|
| 168 |
+
"guidance sources modified since then. Use for 'has X changed "
|
| 169 |
+
"since ...?' questions."
|
| 170 |
+
),
|
| 171 |
+
"parameters": {
|
| 172 |
+
"type": "object",
|
| 173 |
+
"properties": {
|
| 174 |
+
"since": {
|
| 175 |
+
"type": "string",
|
| 176 |
+
"description": "ISO date YYYY-MM-DD.",
|
| 177 |
+
},
|
| 178 |
+
},
|
| 179 |
+
"required": ["since"],
|
| 180 |
+
},
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"name": "canlex_citing",
|
| 184 |
+
"description": (
|
| 185 |
+
"Reverse cross-reference lookup: the guidance, related "
|
| 186 |
+
"provisions and regulations that cite a given section, without "
|
| 187 |
+
"fetching its text."
|
| 188 |
+
),
|
| 189 |
+
"parameters": {
|
| 190 |
+
"type": "object",
|
| 191 |
+
"properties": {
|
| 192 |
+
"act": {"type": "string",
|
| 193 |
+
"description": "Act short name or code."},
|
| 194 |
+
"section": {"type": "string",
|
| 195 |
+
"description": "Section number, e.g. '34'."},
|
| 196 |
+
},
|
| 197 |
+
"required": ["act", "section"],
|
| 198 |
+
},
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"name": "canlex_toc",
|
| 202 |
+
"description": (
|
| 203 |
+
"Browse an Act's table of contents (Parts and sections with "
|
| 204 |
+
"marginal notes). Use when the Act is known but the section "
|
| 205 |
+
"number is not."
|
| 206 |
+
),
|
| 207 |
+
"parameters": {
|
| 208 |
+
"type": "object",
|
| 209 |
+
"properties": {
|
| 210 |
+
"act": {"type": "string",
|
| 211 |
+
"description": "Act short name or code."},
|
| 212 |
+
},
|
| 213 |
+
"required": ["act"],
|
| 214 |
+
},
|
| 215 |
+
},
|
| 216 |
{
|
| 217 |
"name": "canlex_list_acts",
|
| 218 |
"description": (
|
|
|
|
| 598 |
return f"Looking up case {args.get('case_url', '?')}"
|
| 599 |
if name == "canlex_list_acts":
|
| 600 |
return "Listing the CanLex corpus"
|
| 601 |
+
if name == "canlex_whats_new":
|
| 602 |
+
return f"Checking changes since {args.get('since', '?')}"
|
| 603 |
+
if name == "canlex_citing":
|
| 604 |
+
return (f"Finding material citing {args.get('act', '?')} "
|
| 605 |
+
f"s. {args.get('section', '?')}")
|
| 606 |
+
if name == "canlex_toc":
|
| 607 |
+
return f"Browsing the contents of {args.get('act', '?')}"
|
| 608 |
if name == "canlex_us_disposition":
|
| 609 |
state = f" ({args['state']})" if args.get("state") else ""
|
| 610 |
return (f"Checking US disposition "
|