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 files

canlex_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 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
- url = _URL_OVERRIDES.get(n, BASE.format(n=n))
 
 
145
  dest = RAW / f"enf{n:02d}.pdf"
146
  try:
147
- fetch_cached(url, dest, powershell=True, pause=1.0)
 
 
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
- print(f" ENF {n:2d}: {len(chunks)} chunks")
 
 
 
 
 
 
 
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 "