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 filesHill/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 +26 -9
- canlex/caselaw.py +30 -9
- canlex/enf.py +12 -1
- canlex/index.py +40 -2
- canlex/irb_guidelines.py +127 -0
- canlex/refresh.py +40 -3
- data/processed/caselaw.json +2 -2
- data/processed/embeddings.npz +2 -2
- data/processed/enf.json +0 -0
- data/processed/irb_guidelines.json +0 -0
RUNBOOK.md
CHANGED
|
@@ -91,17 +91,34 @@ git push mirror --all
|
|
| 91 |
|
| 92 |
## Known gaps / parked
|
| 93 |
|
| 94 |
-
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
`py -m canlex.caselaw && py -m canlex.embed`.
|
| 99 |
-
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 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/
|
| 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-
|
| 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/
|
| 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-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
|
|