Beemer Claude Fable 5 commited on
Commit
782370b
·
1 Parent(s): 774f62e

Autonomous round: equivalency trilogy, ENF 20 + fresh ENF 3, IRB Guidelines 2/3/8, parser fixes, typo bridge, new watches

Browse files

Hill/Brannson/Li captured from CanLII and ingested (the FCA authorities the
s.36 equivalency suite applies); ENF 20 Detention + current ENF 3 edition
via filename overrides (old enf03-eng.pdf serves a stale copy); IRB
Chairperson's Guidelines 2/3/8 (227 chunks); Kinhnicki 1->37 chunks
(div-body fallback) and a footnote-sequence guard that fixed Li; trigram
typo bridge (prefix-guarded, unknown tokens only); refresh.py tariff-edition
probe. 25,551 vectors. Eval: legacy 0.79/0.93/0.99/1.00 MRR 0.87 (Hit@5/10
above the pre-growth baseline); typo slice 0.25->0.50; french Hit@3
0.40->0.80.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

RUNBOOK.md CHANGED
@@ -91,17 +91,34 @@ git push mirror --all
91
 
92
  ## Known gaps / parked
93
 
94
- - Hill, Brannson and Li (the FCA equivalency trilogy) have curated
95
- `CANLII_CASES` entries but need their page text captured manually from
96
- CanLII (DataDome blocks scripts): open the URL each stub prints, save the
97
- page text to the named `data/raw/caselaw/*.txt` file, re-run
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  `py -m canlex.caselaw && py -m canlex.embed`.
99
- - ENF chapters 2, 7, 9, 11, 13, 14, 16, 20 (detention), 25, 27 are not at
100
- the legacy PDF URL pattern; the current-policy PDI HTML tree is the
101
- eventual replacement source.
102
- - Dag (FCA) and Kinhnicki (FPSLREB) parse to 2/1 chunks (paragraph-detector
103
- fallthrough on those cached pages) known, low-impact.
 
104
  - French support is phase 1 only (accent-folded tokens + FR→EN synonym
105
  bridges; the `french` eval slice tracks it). Phase 2 = multilingual
106
  embedder via the `CANLEX_EMB_REPO` env hooks; phase 3 = parallel French
107
  corpus.
 
 
 
 
91
 
92
  ## Known gaps / parked
93
 
94
+ - ENF coverage (researched and URL-verified 2026-07-22): ENF 20 Detention
95
+ and the current ENF 3 edition live under non-standard filenames — handled
96
+ by `_URL_OVERRIDES` in canlex/enf.py (the old enf03-eng.pdf still serves a
97
+ STALE 2015-era copy; never remove the override). ENF 7 was never publicly
98
+ released; ENF 13/16/25 never existed; ENF 9 (Judicial Reviews) and ENF 11
99
+ (Verifying Departure) are retired with no successor (Wayback copies exist
100
+ but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
101
+ replaced by the PDI section at
102
+ operational-bulletins-manuals/permanent-residence/card.html (~10 HTML
103
+ sub-pages) — a future small scraper. ENF 2 (Evaluating Inadmissibility)
104
+ and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
105
+ ATIP-released copies hosted by a private law firm — NOT ingested pending
106
+ the maintainer's call on unofficial sources; the Tran PDI page
107
+ (standard-requirements/tran.html) partially covers ENF 2's ground.
108
+ - CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
109
+ 2026-07-22): open the URL a missing-file stub prints in a real browser,
110
+ copy the page text to the named `data/raw/caselaw/*.txt`, re-run
111
  `py -m canlex.caselaw && py -m canlex.embed`.
112
+ - Dag (FCA) parses to 2 chunks because it genuinely is a 9-paragraph bench
113
+ judgment correct, not a bug.
114
+ - A weekly citator sweep over the ~110 corpus decisions (negative-treatment
115
+ detection) was considered and deliberately skipped: ~6 minutes of
116
+ rate-limited CanLII API per run for marginal signal. Run `canlex_case` on
117
+ a decision before relying on it instead.
118
  - French support is phase 1 only (accent-folded tokens + FR→EN synonym
119
  bridges; the `french` eval slice tracks it). Phase 2 = multilingual
120
  embedder via the `CANLEX_EMB_REPO` env hooks; phase 3 = parallel French
121
  corpus.
122
+ - Typo robustness: unknown query tokens get a trigram-Dice fuzzy bridge
123
+ (0.6 floor, shared 2-char prefix, 0.7 score discount) in
124
+ `index._fuzzy_term`; the `typo` eval slice tracks it.
canlex/caselaw.py CHANGED
@@ -441,23 +441,23 @@ CANLII_CASES = [
441
  # the raw text arrives via the manual browser-capture workflow and
442
  # ingestion skips gracefully until each file exists.
443
  {"file": "canlii-1981-brannson.txt",
444
- "canlii_path": "en/ca/fca/doc/1980/1980canlii2735/1980canlii2735.html",
445
  "short": "Brannson",
446
  "name": "Brannson v Canada (Minister of Employment and Immigration)",
447
  "cite": "[1981] 2 FC 141 (CA)",
448
- "report": "",
449
- "date": "1980-11-14",
450
  "court_name": "Federal Court of Appeal",
451
  "topic": "Foreign-conviction equivalency: compare the essential elements "
452
  "of the foreign and Canadian offences; the foreign offence's "
453
  "wording and the evidence may both be examined (first of the "
454
  "equivalency trilogy)"},
455
  {"file": "canlii-1987-hill.txt",
456
- "canlii_path": "en/ca/fca/doc/1987/1987canlii5086/1987canlii5086.html",
457
  "short": "Hill",
458
  "name": "Hill v Canada (Minister of Employment and Immigration)",
459
  "cite": "(1987), 73 NR 315 (FCA)",
460
- "report": "",
461
  "date": "1987-01-29",
462
  "court_name": "Federal Court of Appeal",
463
  "topic": "The governing three-branch equivalency test: (1) compare the "
@@ -469,8 +469,8 @@ CANLII_CASES = [
469
  "short": "Li",
470
  "name": "Li v Canada (Minister of Citizenship and Immigration)",
471
  "cite": "[1997] 1 FC 235 (CA)",
472
- "report": "",
473
- "date": "1996-10-11",
474
  "court_name": "Federal Court of Appeal",
475
  "topic": "Equivalency compares the essential elements of the offences, "
476
  "not procedural or evidentiary rules; defences available under "
@@ -589,6 +589,15 @@ def _paragraphs(soup):
589
  if "MsoFootnoteText" not in (p.get("class") or [])]
590
  texts = [p.get_text() for p in blocks]
591
 
 
 
 
 
 
 
 
 
 
592
  # Drop the post-reasons apparatus (appended legislation, solicitors list);
593
  # it is not part of the reasons and would otherwise swell the last paragraph.
594
  for i, raw in enumerate(texts):
@@ -704,10 +713,13 @@ def _text_paragraphs(text):
704
  openers[i] = n
705
  expected = n + 1
706
 
707
- if len(openers) < 5:
708
  paras = [(str(j), _norm(ln)) for j, ln in enumerate(lines, start=1)]
709
  return False, [(n, t) for n, t in paras if len(t) > 1]
710
 
 
 
 
711
  paras, num, buf = [], None, []
712
  for i, ln in enumerate(lines):
713
  if i in openers:
@@ -719,7 +731,16 @@ def _text_paragraphs(text):
719
  buf.append(ln)
720
  if num is not None:
721
  paras.append((str(num), _norm(" ".join(buf))))
722
- return True, [(n, t) for n, t in paras if t]
 
 
 
 
 
 
 
 
 
723
 
724
 
725
  def _canlii_chunks(case, text):
 
441
  # the raw text arrives via the manual browser-capture workflow and
442
  # ingestion skips gracefully until each file exists.
443
  {"file": "canlii-1981-brannson.txt",
444
+ "canlii_path": "en/ca/fca/doc/1980/1980canlii4197/1980canlii4197.html",
445
  "short": "Brannson",
446
  "name": "Brannson v Canada (Minister of Employment and Immigration)",
447
  "cite": "[1981] 2 FC 141 (CA)",
448
+ "report": "1980 CanLII 4197",
449
+ "date": "1980-06-05",
450
  "court_name": "Federal Court of Appeal",
451
  "topic": "Foreign-conviction equivalency: compare the essential elements "
452
  "of the foreign and Canadian offences; the foreign offence's "
453
  "wording and the evidence may both be examined (first of the "
454
  "equivalency trilogy)"},
455
  {"file": "canlii-1987-hill.txt",
456
+ "canlii_path": "en/ca/fca/doc/1987/1987canlii9881/1987canlii9881.html",
457
  "short": "Hill",
458
  "name": "Hill v Canada (Minister of Employment and Immigration)",
459
  "cite": "(1987), 73 NR 315 (FCA)",
460
+ "report": "1987 CanLII 9881; 1 Imm LR (2d) 1",
461
  "date": "1987-01-29",
462
  "court_name": "Federal Court of Appeal",
463
  "topic": "The governing three-branch equivalency test: (1) compare the "
 
469
  "short": "Li",
470
  "name": "Li v Canada (Minister of Citizenship and Immigration)",
471
  "cite": "[1997] 1 FC 235 (CA)",
472
+ "report": "1996 CanLII 4086",
473
+ "date": "1996-08-07",
474
  "court_name": "Federal Court of Appeal",
475
  "topic": "Equivalency compares the essential elements of the offences, "
476
  "not procedural or evidentiary rules; defences available under "
 
589
  if "MsoFootnoteText" not in (p.get("class") or [])]
590
  texts = [p.get_text() for p in blocks]
591
 
592
+ # Some pre-2009 board pages (e.g. PSSRB 2003) carry the reasons in bare
593
+ # <div> blocks with no <p> markup at all, so the <p> strategy collects
594
+ # only header furniture. When it captured almost none of the body's
595
+ # text, hand the whole body to the plain-text parser instead -- the same
596
+ # one the CanLII paste captures use, with its own numbering detection
597
+ # and apparatus cutoff.
598
+ if sum(len(t) for t in texts) < 0.2 * len(body.get_text()):
599
+ return _text_paragraphs(body.get_text("\n"))
600
+
601
  # Drop the post-reasons apparatus (appended legislation, solicitors list);
602
  # it is not part of the reasons and would otherwise swell the last paragraph.
603
  for i, raw in enumerate(texts):
 
713
  openers[i] = n
714
  expected = n + 1
715
 
716
+ def _unnumbered():
717
  paras = [(str(j), _norm(ln)) for j, ln in enumerate(lines, start=1)]
718
  return False, [(n, t) for n, t in paras if len(t) > 1]
719
 
720
+ if len(openers) < 5:
721
+ return _unnumbered()
722
+
723
  paras, num, buf = [], None, []
724
  for i, ln in enumerate(lines):
725
  if i in openers:
 
731
  buf.append(ln)
732
  if num is not None:
733
  paras.append((str(num), _norm(" ".join(buf))))
734
+ paras = [(n, t) for n, t in paras if t]
735
+
736
+ # A pre-2001 report-style decision has unnumbered prose reasons but a
737
+ # trailing footnote block whose own [1]..[N] sequence the detector will
738
+ # happily latch onto (Li: 26 footnotes, 4% of the text). If the
739
+ # "numbered paragraphs" cover only a sliver of the document, the real
740
+ # reasons are the unnumbered prose -- fall back to it.
741
+ if sum(len(t) for _, t in paras) < 0.2 * len(text):
742
+ return _unnumbered()
743
+ return True, paras
744
 
745
 
746
  def _canlii_chunks(case, text):
canlex/enf.py CHANGED
@@ -26,6 +26,17 @@ BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
26
  RAW = RAW_DIR / "enf"
27
  _CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
28
 
 
 
 
 
 
 
 
 
 
 
 
29
  # Curated titles for the core chapters; anything else gets a parsed or
30
  # generic title. Only used for display -- retrieval works off the text.
31
  TITLES = {
@@ -130,7 +141,7 @@ def _norm_block(lines):
130
  def build(chapters=_CHAPTERS):
131
  all_chunks, skipped = [], []
132
  for n in chapters:
133
- url = BASE.format(n=n)
134
  dest = RAW / f"enf{n:02d}.pdf"
135
  try:
136
  fetch_cached(url, dest, powershell=True, pause=1.0)
 
26
  RAW = RAW_DIR / "enf"
27
  _CHAPTERS = range(1, 41) # probe; gaps and retired chapters just skip
28
 
29
+ # Chapters whose current edition lives under a non-standard filename in the
30
+ # same directory (verified 2026-07-22 against the OBM Active-manuals index).
31
+ # ENF 3's old enf03-eng.pdf still returns 200 but is a STALE 2015-era copy;
32
+ # ENF 20 (Detention) was renamed enf20a-en.pdf (Aug 2024 edition).
33
+ _URL_OVERRIDES = {
34
+ 3: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
35
+ "resources/manuals/enf/enf-3-admissibility-(en)-final.pdf"),
36
+ 20: ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
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
  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)
canlex/index.py CHANGED
@@ -214,6 +214,7 @@ class LegislationIndex:
214
  if not self.chunks:
215
  raise RuntimeError(
216
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
 
217
  self._build_bm25()
218
  self._build_note_tokens()
219
  self._build_xref()
@@ -332,6 +333,36 @@ class LegislationIndex:
332
  print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
333
  f"using hybrid fusion order.", file=sys.stderr)
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  def _bm25_scores(self, query, allowed=None):
336
  """allowed: optional boolean list by chunk index. Filtered searches
337
  pass it so recall competes only within scope -- filtering after
@@ -340,14 +371,21 @@ class LegislationIndex:
340
  scores = defaultdict(float)
341
  for term in set(tokenize(query)):
342
  idf = self.idf.get(term)
 
343
  if idf is None:
344
- continue
 
 
 
 
 
 
345
  for idx, tf in self.postings[term]:
346
  if allowed is not None and not allowed[idx]:
347
  continue
348
  dl = self.doc_len[idx]
349
  denom = tf + K1 * (1 - B + B * dl / self.avgdl)
350
- scores[idx] += idf * tf * (K1 + 1) / denom
351
  return scores
352
 
353
  def _semantic_ranking(self, query, allowed=None):
 
214
  if not self.chunks:
215
  raise RuntimeError(
216
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
217
+ self._tri_index = None # lazy; see _fuzzy_term
218
  self._build_bm25()
219
  self._build_note_tokens()
220
  self._build_xref()
 
333
  print(f"CanLex index: reranker disabled ({type(exc).__name__}: {exc}); "
334
  f"using hybrid fusion order.", file=sys.stderr)
335
 
336
+ def _fuzzy_term(self, term):
337
+ """The closest vocabulary term for a token the corpus has never seen
338
+ -- a typo bridge ('detension' -> 'detention'), scored by character-
339
+ trigram Dice similarity with a strict 0.6 floor and a length guard.
340
+ Only ever consulted for UNKNOWN tokens, so correctly-spelled queries
341
+ are untouched. The trigram index is built lazily on first miss."""
342
+ if len(term) < 4:
343
+ return None
344
+ if self._tri_index is None:
345
+ tri = defaultdict(set)
346
+ for vocab_term in self.idf:
347
+ for i in range(len(vocab_term) - 2):
348
+ tri[vocab_term[i:i + 3]].add(vocab_term)
349
+ self._tri_index = tri
350
+ tris = {term[i:i + 3] for i in range(len(term) - 2)}
351
+ counts = Counter()
352
+ for t in tris:
353
+ counts.update(self._tri_index.get(t, ()))
354
+ best, best_score = None, 0.6
355
+ for cand, n in counts.most_common(50):
356
+ # Typos rarely corrupt a word's opening letters; requiring a
357
+ # shared 2-char prefix kills drift like 'detens' -> 'pretens'
358
+ # while keeping 'seisur' -> 'seizur'.
359
+ if abs(len(cand) - len(term)) > 2 or cand[:2] != term[:2]:
360
+ continue
361
+ score = 2 * n / (len(tris) + len(cand) - 2)
362
+ if score > best_score:
363
+ best, best_score = cand, score
364
+ return best
365
+
366
  def _bm25_scores(self, query, allowed=None):
367
  """allowed: optional boolean list by chunk index. Filtered searches
368
  pass it so recall competes only within scope -- filtering after
 
371
  scores = defaultdict(float)
372
  for term in set(tokenize(query)):
373
  idf = self.idf.get(term)
374
+ weight = 1.0
375
  if idf is None:
376
+ # Unknown token: try the typo bridge at a discount, so a
377
+ # misspelled query still reaches the provision while a real
378
+ # match always outranks a fuzzy one.
379
+ alt = self._fuzzy_term(term)
380
+ if alt is None:
381
+ continue
382
+ term, idf, weight = alt, self.idf[alt], 0.7
383
  for idx, tf in self.postings[term]:
384
  if allowed is not None and not allowed[idx]:
385
  continue
386
  dl = self.doc_len[idx]
387
  denom = tf + K1 * (1 - B + B * dl / self.avgdl)
388
+ scores[idx] += weight * idf * tf * (K1 + 1) / denom
389
  return scores
390
 
391
  def _semantic_ranking(self, query, allowed=None):
canlex/irb_guidelines.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 json
16
+ 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"
24
+
25
+ GUIDELINES = [
26
+ {"num": "2", "file": "guideline2",
27
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir02.aspx",
28
+ "title": "Detention"},
29
+ {"num": "3", "file": "guideline3",
30
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir03.aspx",
31
+ "title": "Child Refugee Claimants: Procedural and Evidentiary Issues"},
32
+ {"num": "8", "file": "guideline8",
33
+ "url": "https://irb.gc.ca/en/legal-policy/policies/Pages/GuideDir08.aspx",
34
+ "title": "Accessibility to IRB Proceedings — Procedural Accommodations "
35
+ "and Substantive Considerations"},
36
+ ]
37
+
38
+ # "Effective date: May 31, 2021" (or amended/revised) in the page prose.
39
+ _EFFECTIVE = re.compile(
40
+ r"(?:effective|amended|revised)[^.:\n]*:?\s*((?:January|February|March|"
41
+ r"April|May|June|July|August|September|October|November|December)\s+"
42
+ r"\d{1,2},\s+\d{4})", re.I)
43
+ _MONTHS = {m: i for i, m in enumerate(
44
+ ("January", "February", "March", "April", "May", "June", "July",
45
+ "August", "September", "October", "November", "December"), start=1)}
46
+
47
+
48
+ def _iso(prose):
49
+ m = re.match(r"(\w+)\s+(\d{1,2}),\s+(\d{4})", prose or "")
50
+ if not m or m.group(1) not in _MONTHS:
51
+ return ""
52
+ return f"{m.group(3)}-{_MONTHS[m.group(1)]:02d}-{int(m.group(2)):02d}"
53
+
54
+
55
+ def _sections(main):
56
+ """Yield (label, heading, text) per numbered heading in the page body."""
57
+ heads = main.find_all(["h2", "h3"])
58
+ for i, h in enumerate(heads):
59
+ heading = _norm(h.get_text())
60
+ m = re.match(r"(\d+(?:\.\d+)*)\.?\s+(.*)", heading)
61
+ label = m.group(1) if m else str(i + 1)
62
+ title = m.group(2) if m else heading
63
+ parts = []
64
+ for sib in h.next_siblings:
65
+ if getattr(sib, "name", None) in ("h2", "h3"):
66
+ break
67
+ if getattr(sib, "get_text", None):
68
+ parts.append(sib.get_text("\n"))
69
+ body = "\n".join(p for p in (_norm(x) for x in
70
+ "\n".join(parts).split("\n")) if p)
71
+ if len(body) > 80:
72
+ yield label, title, body
73
+
74
+
75
+ def build():
76
+ chunks = []
77
+ for g in GUIDELINES:
78
+ try:
79
+ html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
80
+ pause=1.0).decode("utf-8", "replace")
81
+ except Exception as exc:
82
+ print(f" !! Guideline {g['num']}: fetch failed "
83
+ f"({type(exc).__name__}: {exc})")
84
+ continue
85
+ soup = BeautifulSoup(html, "html.parser")
86
+ main = soup.find("main") or soup
87
+ eff = _EFFECTIVE.search(main.get_text())
88
+ effective = _iso(eff.group(1)) if eff else ""
89
+ n = 0
90
+ for label, title, body in _sections(main):
91
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
92
+ total = len(pieces)
93
+ for k, piece in enumerate(pieces, start=1):
94
+ suffix = "" if total == 1 else f"-p{k}"
95
+ note = "" if total == 1 else f" (part {k} of {total})"
96
+ chunks.append({
97
+ "id": f"irbg-{g['num']}-{label.replace('.', '-')}{suffix}",
98
+ "doc_type": "memorandum",
99
+ "act_code": f"IRB-G{g['num']}",
100
+ "act_short": f"IRB Guideline {g['num']}",
101
+ "act_name": f"IRB Chairperson's Guideline {g['num']} — "
102
+ f"{g['title']}",
103
+ "section": f"G{g['num']} s. {label}",
104
+ "marginal_note": title,
105
+ "part": g["title"],
106
+ "division": "",
107
+ "heading": "",
108
+ "text": piece,
109
+ "history": "",
110
+ "last_amended": effective,
111
+ "current_to": effective,
112
+ "citation": f"Chairperson's Guideline {g['num']}, "
113
+ f"s. {label} ({title}){note}",
114
+ "source_url": g["url"],
115
+ })
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")
122
+ print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
123
+ return chunks
124
+
125
+
126
+ if __name__ == "__main__":
127
+ build()
canlex/refresh.py CHANGED
@@ -122,6 +122,32 @@ def check_curated(today):
122
  _DMEMO_INDEX = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def check_dmemo_index():
126
  """Newest 'Date modified' on the CBSA D-memo index vs stored currency.
127
  A newer date upstream means at least one memo changed since ingestion --
@@ -442,6 +468,7 @@ def run(as_json=False):
442
  nonxml = non_xml_currency()
443
  curated = check_curated(today)
444
  dmemo_idx = check_dmemo_index()
 
445
  pending = pending_in_force(today)
446
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
447
  errors = [r for r in leg if r["status"] == "error"]
@@ -449,17 +476,19 @@ def run(as_json=False):
449
  dirs_stale = [r for r in dirs if r["status"] == "stale"]
450
  curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
451
  dmemo_stale = dmemo_idx["status"] == "stale"
 
452
 
453
  if as_json:
454
  print(json.dumps({"checked": today, "legislation": leg,
455
  "delegation": deleg, "directives": dirs,
456
  "non_xml": nonxml, "curated": curated,
457
- "dmemo_index": dmemo_idx,
458
  "pending_in_force": pending,
459
  "stale_count": (len(stale) + len(deleg_stale)
460
  + len(dirs_stale)
461
  + len(curated_flag)
462
- + (1 if dmemo_stale else 0)),
 
463
  "error_count": len(errors)}, indent=2))
464
  else:
465
  print(f"CanLex corpus staleness check — {today}\n")
@@ -550,6 +579,14 @@ def run(as_json=False):
550
  print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
551
  f"<= stored {dmemo_idx['stored'] or '?'})")
552
 
 
 
 
 
 
 
 
 
553
  if pending:
554
  print("\nPending bills now in force (re-ingest the affected Acts):")
555
  for b in pending:
@@ -559,7 +596,7 @@ def run(as_json=False):
559
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
560
 
561
  return 1 if (stale or errors or deleg_stale or dirs_stale
562
- or curated_flag or dmemo_stale) else 0
563
 
564
 
565
  def main():
 
122
  _DMEMO_INDEX = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
123
 
124
 
125
+ def check_tariff_edition(today):
126
+ """Probe whether CBSA has published a Customs Tariff edition newer than
127
+ the ingested one. The tariff pages live under a year-stamped path
128
+ (.../tariff-tarif/<year>/html/...), so probing the ingested-edition-plus-
129
+ one URL (and the current calendar year's) is a reliable new-edition
130
+ signal without parsing anything."""
131
+ from . import tariff_schedule
132
+ src = next(iter(tariff_schedule.SOURCES.values()))
133
+ have = int(src["edition"])
134
+ candidates = sorted({have + 1, int(today[:4])} - {have})
135
+ for year in candidates:
136
+ if year <= have:
137
+ continue
138
+ probe = src["url"].replace(f"/{src['edition']}/", f"/{year}/")
139
+ try:
140
+ req = urllib.request.Request(
141
+ probe, method="HEAD", headers={"User-Agent": BROWSER_UA})
142
+ with urllib.request.urlopen(req, timeout=30) as resp:
143
+ if resp.status == 200:
144
+ return {"stored": str(have), "remote": str(year),
145
+ "status": "stale", "error": ""}
146
+ except Exception:
147
+ continue # 404 = that edition doesn't exist yet
148
+ return {"stored": str(have), "remote": "", "status": "ok", "error": ""}
149
+
150
+
151
  def check_dmemo_index():
152
  """Newest 'Date modified' on the CBSA D-memo index vs stored currency.
153
  A newer date upstream means at least one memo changed since ingestion --
 
468
  nonxml = non_xml_currency()
469
  curated = check_curated(today)
470
  dmemo_idx = check_dmemo_index()
471
+ tariff = check_tariff_edition(today)
472
  pending = pending_in_force(today)
473
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
474
  errors = [r for r in leg if r["status"] == "error"]
 
476
  dirs_stale = [r for r in dirs if r["status"] == "stale"]
477
  curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
478
  dmemo_stale = dmemo_idx["status"] == "stale"
479
+ tariff_stale = tariff["status"] == "stale"
480
 
481
  if as_json:
482
  print(json.dumps({"checked": today, "legislation": leg,
483
  "delegation": deleg, "directives": dirs,
484
  "non_xml": nonxml, "curated": curated,
485
+ "dmemo_index": dmemo_idx, "tariff": tariff,
486
  "pending_in_force": pending,
487
  "stale_count": (len(stale) + len(deleg_stale)
488
  + len(dirs_stale)
489
  + len(curated_flag)
490
+ + (1 if dmemo_stale else 0)
491
+ + (1 if tariff_stale else 0)),
492
  "error_count": len(errors)}, indent=2))
493
  else:
494
  print(f"CanLex corpus staleness check — {today}\n")
 
579
  print(f"\nD-memo index: ok (upstream {dmemo_idx['remote'] or '?'} "
580
  f"<= stored {dmemo_idx['stored'] or '?'})")
581
 
582
+ if tariff["status"] == "stale":
583
+ print(f"\nCustoms Tariff edition: STALE — CBSA has published the "
584
+ f"{tariff['remote']} edition (ingested: {tariff['stored']}); "
585
+ f"bump the year in tariff_schedule.SOURCES and re-ingest")
586
+ else:
587
+ print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
588
+ f"current)")
589
+
590
  if pending:
591
  print("\nPending bills now in force (re-ingest the affected Acts):")
592
  for b in pending:
 
596
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
597
 
598
  return 1 if (stale or errors or deleg_stale or dirs_stale
599
+ or curated_flag or dmemo_stale or tariff_stale) else 0
600
 
601
 
602
  def main():
data/processed/caselaw.json CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f64b399c2d461d8016af792160c868187afa2da17dce23cfb81196eeb6ed8954
3
- size 11504314
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d0a415257b35e02c821e4d8ea225f8c6c73a72acfef7bb2f5a9de99f7a7d0455
3
+ size 11745635
data/processed/embeddings.npz CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f294b58f4c0f1f1dec6b6c103b38cda132f3aa5e855361a9ad70cb20fdddd12f
3
- size 44410370
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b827022dc504fa7bc636c50d3206fe90d38e5996420fe33950fd00ba193f16a7
3
+ size 45276878
data/processed/enf.json CHANGED
The diff for this file is too large to render. See raw diff
 
data/processed/irb_guidelines.json ADDED
The diff for this file is too large to render. See raw diff