Typo repair v2, bilingual guidance corpus, full PDI trees
Browse filesMisspelled words are now repaired before retrieval (trigram candidates,
Damerau-Levenshtein <= 2, prefix + frequency guards; ultra-rare stems get
the fix appended, never substituted) and the repaired text feeds the
embedder and reranker -- typo slice 0.00 -> 0.75 Hit@1. The D-memos (356
memos, 1,995 chunks) and all 11 NJC directives (1,135 chunks) gained their
French editions via --fr modes, lang-routed like the legislation; French
Hit@3 back to 0.80. pdi.py now crawls five border-relevant PDI category
trees breadth-first (242 pages, 2,068 chunks; caps logged, wipe-guards
kept). Weekly watch rows for all new files. 46,027 chunks / 51,756
vectors; legacy 0.74/0.94/0.98/0.99 MRR 0.84 (Hit@1 softened by guidance
competition, top-3 intact); holdout 0.77/1.00/1.00.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RUNBOOK.md +60 -14
- canlex/directive.py +66 -3
- canlex/dmemo.py +61 -5
- canlex/index.py +176 -18
- canlex/pdi.py +83 -19
- canlex/refresh.py +6 -1
- data/processed/directives-fr.json +0 -0
- data/processed/dmemos-fr.json +0 -0
- data/processed/embeddings.npz +2 -2
- data/processed/pdi.json +0 -0
- tests/test_sprint.py +139 -0
|
@@ -9,15 +9,16 @@ Order matters — chunks feed embeddings feed the eval:
|
|
| 9 |
```
|
| 10 |
py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
|
| 11 |
py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
|
| 12 |
-
py -m canlex.pdi #
|
|
|
|
| 13 |
py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
|
| 14 |
-
py -m canlex.dmemo
|
| 15 |
py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
|
| 16 |
py -m canlex.amps # AMPS Master Penalty Document
|
| 17 |
py -m canlex.charter # Charter / Constitution HTML
|
| 18 |
py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
|
| 19 |
py -m canlex.delegation # CBSA/IRCC delegation instruments
|
| 20 |
-
py -m canlex.directive
|
| 21 |
py -m canlex.agreement # FB collective agreement
|
| 22 |
py -m canlex.benefits # benefit-plan booklets
|
| 23 |
py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
|
|
@@ -150,12 +151,19 @@ git push mirror --all
|
|
| 150 |
(Verifying Departure) are retired with no successor (Wayback copies exist
|
| 151 |
but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
|
| 152 |
replaced by the PDI section at
|
| 153 |
-
operational-bulletins-manuals/permanent-residence/card.html
|
| 154 |
-
|
| 155 |
and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
|
| 156 |
ATIP-released copies hosted by a private law firm — NOT ingested pending
|
| 157 |
-
the maintainer's call on unofficial sources; the Tran PDI page
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
- CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
|
| 160 |
2026-07-22): open the URL a missing-file stub prints in a real browser,
|
| 161 |
copy the page text to the named `data/raw/caselaw/*.txt`, re-run
|
|
@@ -175,10 +183,48 @@ git push mirror --all
|
|
| 175 |
regressed every slice and broke confidence calibration; don't retry
|
| 176 |
without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
|
| 177 |
published bilingually) is the remaining bilingual frontier.
|
| 178 |
-
- ENF 2
|
| 179 |
-
UNOFFICIAL on every chunk). ENF 14
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
-
|
| 183 |
-
|
| 184 |
-
`index._fuzzy_term`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
```
|
| 10 |
py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
|
| 11 |
py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
|
| 12 |
+
py -m canlex.pdi # IRCC PDI trees (TR / refugee / standard /
|
| 13 |
+
# identity / PR-card; capped BFS, drops logged)
|
| 14 |
py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
|
| 15 |
+
py -m canlex.dmemo [--fr] # CBSA D-Memoranda (EN; --fr = Mémorandums D)
|
| 16 |
py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
|
| 17 |
py -m canlex.amps # AMPS Master Penalty Document
|
| 18 |
py -m canlex.charter # Charter / Constitution HTML
|
| 19 |
py -m canlex.caselaw # Lexum/Decisia courts + cached CanLII texts
|
| 20 |
py -m canlex.delegation # CBSA/IRCC delegation instruments
|
| 21 |
+
py -m canlex.directive [--fr] # NJC directives (EN; --fr = éditions françaises)
|
| 22 |
py -m canlex.agreement # FB collective agreement
|
| 23 |
py -m canlex.benefits # benefit-plan booklets
|
| 24 |
py -m canlex.tariff_schedule # Customs Tariff ch. 98/99 (CBSA edition)
|
|
|
|
| 151 |
(Verifying Departure) are retired with no successor (Wayback copies exist
|
| 152 |
but retired guidance is deliberately not ingested). ENF 27 (PR Card) was
|
| 153 |
replaced by the PDI section at
|
| 154 |
+
operational-bulletins-manuals/permanent-residence/card.html — ingested by
|
| 155 |
+
`canlex.pdi` (the `pdi-prcard` set). ENF 2 (Evaluating Inadmissibility)
|
| 156 |
and ENF 14/OP 19 (Criminal Rehabilitation) exist publicly only as
|
| 157 |
ATIP-released copies hosted by a private law firm — NOT ingested pending
|
| 158 |
+
the maintainer's call on unofficial sources; the Tran PDI page now comes
|
| 159 |
+
in with the whole `standard-requirements` tree and partially covers
|
| 160 |
+
ENF 2's ground.
|
| 161 |
+
- PDI scope: `canlex.pdi` crawls six category trees breadth-first
|
| 162 |
+
(`_MAX_DEPTH` 2, `_MAX_PAGES` 160 per tree). Deliberately out of scope:
|
| 163 |
+
citizenship, fees, office service delivery, and the year-by-year
|
| 164 |
+
operational-bulletin archives (mostly expired instructions). If a tree
|
| 165 |
+
ever hits the page cap the run PRINTS how many in-scope pages it dropped
|
| 166 |
+
— raise `_MAX_PAGES` rather than letting the silence read as coverage.
|
| 167 |
- CanLII capture workflow (used for the Hill/Brannson/Li trilogy,
|
| 168 |
2026-07-22): open the URL a missing-file stub prints in a real browser,
|
| 169 |
copy the page text to the named `data/raw/caselaw/*.txt`, re-run
|
|
|
|
| 183 |
regressed every slice and broke confidence calibration; don't retry
|
| 184 |
without re-calibrating HEDGE_THRESHOLD. French guidance (D-memos are
|
| 185 |
published bilingually) is the remaining bilingual frontier.
|
| 186 |
+
- ENF 2 and ENF 14/OP 19 are both ingested from their ATIP releases
|
| 187 |
+
(authorized 2026-07-23, bannered UNOFFICIAL on every chunk). ENF 14's
|
| 188 |
+
image-scan PDF was recovered by OCR (pymupdf rasterization + Windows OCR;
|
| 189 |
+
cached at data/raw/enf/enf14-ocr.txt — enf.py auto-falls-back to
|
| 190 |
+
`*-ocr.txt` when a PDF extracts under 500 chars).
|
| 191 |
+
- Typo robustness, two layers: unknown STEMS get the trigram bridge in
|
| 192 |
+
`index._fuzzy_term` (BM25 recall only), and misspelled WORDS are repaired
|
| 193 |
+
by `index._correct_query` (trigram candidates, Damerau-Levenshtein <= 2,
|
| 194 |
+
shared 2-char prefix, frequency floor 5 so OCR debris can't win) -- the
|
| 195 |
+
repaired text feeds the semantic embedder and the reranker. Ultra-rare
|
| 196 |
+
stems (df <= 2) get the correction APPENDED, never substituted, so rare
|
| 197 |
+
legitimate terms keep their recall. The `typo` eval slice tracks it.
|
| 198 |
+
- Bilingual guidance: the D-memos and NJC directives now carry their French
|
| 199 |
+
editions (`--fr` modes; dmemos-fr.json / directives-fr.json) alongside
|
| 200 |
+
the French legislation, all lang-routed. Remaining English-only: case
|
| 201 |
+
law, ENF/AMPS/PDI, benefits booklets, and the curated commentary.
|
| 202 |
+
|
| 203 |
+
## Two retrieval invariants worth not re-breaking (2026-07-29)
|
| 204 |
+
|
| 205 |
+
Ingesting the French guidance and the wider PDI trees exposed both of these.
|
| 206 |
+
Each was measured question-by-question against the previous corpus, not
|
| 207 |
+
guessed at — the harness is a per-question rank dump over two corpus
|
| 208 |
+
variants, which is how a 5-question shuffle gets attributed instead of
|
| 209 |
+
argued about.
|
| 210 |
+
|
| 211 |
+
- **BM25 collection statistics are language-scoped.** `_build_bm25` keeps
|
| 212 |
+
`idf`/`avgdl` (whole corpus) AND `idf_en`/`avgdl_en` (English scope);
|
| 213 |
+
every English query scores with the English pair. An English query can
|
| 214 |
+
never retrieve the French twins, but a single corpus-wide idf let those
|
| 215 |
+
unreachable chunks set the rarity of every shared token (numbers, act
|
| 216 |
+
codes, folded cognates) and drag the average length: adding the French
|
| 217 |
+
D-memos and directives alone moved legacy Hit@1 0.767 → 0.750 without a
|
| 218 |
+
single French chunk being retrievable. French queries still use the
|
| 219 |
+
whole-corpus pair — they search both languages.
|
| 220 |
+
- **The diversity cap keys memoranda on the parent DOCUMENT.** `_source_key`
|
| 221 |
+
used `section` for every memorandum, which is the document identity only
|
| 222 |
+
for the flat families (`_FLAT_MEMO_CODES`: one act_code across ~360
|
| 223 |
+
D-memos / ~1,000 AMPS contraventions). ENF chapters, PDI pages and IRB
|
| 224 |
+
guidelines carry a per-document act_code and use `section` to subdivide
|
| 225 |
+
it, so each ENF section and each PDI page was its own "source" and
|
| 226 |
+
neither RECALL_CAP nor SOURCE_CAP ever bound: the ~150-page PDI refugee
|
| 227 |
+
tree filled the candidate pool and pushed IRPA s. 112 out of the top 20
|
| 228 |
+
of "what is a pre-removal risk assessment" entirely. If a new corpus
|
| 229 |
+
family lands, check which field carries its document identity before
|
| 230 |
+
trusting the caps.
|
|
@@ -151,11 +151,14 @@ def parse_directive(html, url, title, date):
|
|
| 151 |
|
| 152 |
|
| 153 |
def _print_link(html):
|
| 154 |
-
"""Find a 'Print Full Directive' / 'Print all FSDs' link on a TOC page
|
|
|
|
| 155 |
soup = BeautifulSoup(html, "html.parser")
|
| 156 |
for a in soup.find_all("a", href=True):
|
| 157 |
text = _norm(a.get_text(" ", strip=True)).lower()
|
| 158 |
-
if "print full directive" in text or "print all fsd" in text
|
|
|
|
|
|
|
| 159 |
return a["href"] if a["href"].startswith("http") else BASE + a["href"]
|
| 160 |
return None
|
| 161 |
|
|
@@ -222,11 +225,71 @@ def ingest(force=False, limit=None, allow_shrink=False):
|
|
| 222 |
return True
|
| 223 |
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
def main():
|
| 226 |
force = "--force" in sys.argv
|
| 227 |
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 228 |
if a.startswith("--limit=")), None)
|
| 229 |
-
|
|
|
|
| 230 |
allow_shrink="--allow-shrink" in sys.argv)
|
| 231 |
sys.exit(0 if ok else 1)
|
| 232 |
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
def _print_link(html):
|
| 154 |
+
"""Find a 'Print Full Directive' / 'Print all FSDs' link on a TOC page
|
| 155 |
+
(or its French label on the /fr editions)."""
|
| 156 |
soup = BeautifulSoup(html, "html.parser")
|
| 157 |
for a in soup.find_all("a", href=True):
|
| 158 |
text = _norm(a.get_text(" ", strip=True)).lower()
|
| 159 |
+
if ("print full directive" in text or "print all fsd" in text
|
| 160 |
+
or "imprimer la directive" in text
|
| 161 |
+
or "imprimer toutes" in text or "imprimez toutes" in text):
|
| 162 |
return a["href"] if a["href"].startswith("http") else BASE + a["href"]
|
| 163 |
return None
|
| 164 |
|
|
|
|
| 225 |
return True
|
| 226 |
|
| 227 |
|
| 228 |
+
OUT_FILE_FR = OUT_FILE.with_name("directives-fr.json")
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def ingest_fr(force=False, limit=None, allow_shrink=False):
|
| 232 |
+
"""The French editions of the NJC directives, mirroring ingest().
|
| 233 |
+
|
| 234 |
+
The NJC publishes each directive at .../directive/<code>/fr with the
|
| 235 |
+
same markup as /en, so the parser is reused as-is. The EN index supplies
|
| 236 |
+
the directive list and identity (act_short stays the English title so
|
| 237 |
+
act filters behave one way regardless of language); chunks get
|
| 238 |
+
lang='fr' and a -fr id infix, and language-routed retrieval serves them
|
| 239 |
+
only to French queries."""
|
| 240 |
+
directives = directive_links(force=force)
|
| 241 |
+
if limit:
|
| 242 |
+
directives = directives[:limit]
|
| 243 |
+
print(f"Ingesting {len(directives)} directives du CNM (français)...")
|
| 244 |
+
all_chunks, failures = [], []
|
| 245 |
+
for url, title, date in directives:
|
| 246 |
+
code_match = _CODE.search(url)
|
| 247 |
+
code = code_match.group(1) if code_match else "x"
|
| 248 |
+
fr_url = re.sub(r"/en$", "/fr", url)
|
| 249 |
+
try:
|
| 250 |
+
html = _fetch(fr_url, DIRECTIVE_DIR / f"{code}-fr.html",
|
| 251 |
+
force=force)
|
| 252 |
+
chunks = parse_directive(html, fr_url, title, date)
|
| 253 |
+
if not chunks:
|
| 254 |
+
print_url = _print_link(html)
|
| 255 |
+
if print_url:
|
| 256 |
+
full = _fetch(print_url,
|
| 257 |
+
DIRECTIVE_DIR / f"{code}-fr-full.html",
|
| 258 |
+
force=force)
|
| 259 |
+
chunks = parse_directive(full, fr_url, title, date)
|
| 260 |
+
if chunks:
|
| 261 |
+
all_chunks.extend(chunks)
|
| 262 |
+
print(f" {title} (fr): {len(chunks)} chunks")
|
| 263 |
+
else:
|
| 264 |
+
failures.append((code, title, "no content parsed"))
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
failures.append((code, title, f"{type(exc).__name__}: {exc}"))
|
| 267 |
+
for c in all_chunks:
|
| 268 |
+
c["id"] = c["id"].replace("directive-", "directive-fr-", 1)
|
| 269 |
+
c["lang"] = "fr"
|
| 270 |
+
if not limit:
|
| 271 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 272 |
+
stored_chunks(OUT_FILE_FR))
|
| 273 |
+
if preserved:
|
| 274 |
+
print(f" kept the last-good copy of {len(preserved)} "
|
| 275 |
+
f"unparseable directive(s)")
|
| 276 |
+
for _code, title, why in failures:
|
| 277 |
+
print(f" FAILED {title} (fr): {why}")
|
| 278 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, indent=2,
|
| 279 |
+
allow_shrink=allow_shrink):
|
| 280 |
+
return False
|
| 281 |
+
print(f"\n{len(all_chunks)} chunks from "
|
| 282 |
+
f"{len(directives) - len(failures)} directives -> "
|
| 283 |
+
f"{OUT_FILE_FR.name}")
|
| 284 |
+
return True
|
| 285 |
+
|
| 286 |
+
|
| 287 |
def main():
|
| 288 |
force = "--force" in sys.argv
|
| 289 |
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 290 |
if a.startswith("--limit=")), None)
|
| 291 |
+
runner = ingest_fr if "--fr" in sys.argv else ingest
|
| 292 |
+
ok = runner(force=force, limit=limit,
|
| 293 |
allow_shrink="--allow-shrink" in sys.argv)
|
| 294 |
sys.exit(0 if ok else 1)
|
| 295 |
|
|
@@ -32,10 +32,15 @@ DMEMO_DIR = RAW_DIR / "dmemos"
|
|
| 32 |
OUT_FILE = PROCESSED_DIR / "dmemos.json"
|
| 33 |
|
| 34 |
# <h2> headings that are page boilerplate rather than memo content.
|
| 35 |
-
_SKIP_HEADINGS = {"contact us", "related links"
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
_DS_HREF = re.compile(r'href="([^"]+)"')
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def _fetch(url, dest, force=False):
|
|
@@ -46,7 +51,7 @@ def datasource_urls(raw):
|
|
| 46 |
"""Active D-memo URLs from the index's JSON datasource. Pure, for tests."""
|
| 47 |
urls, seen = [], set()
|
| 48 |
for row in json.loads(raw).get("data", []):
|
| 49 |
-
if _norm(row.get("STATUS", "")).lower()
|
| 50 |
continue
|
| 51 |
match = _DS_HREF.search(row.get("NUMBER", ""))
|
| 52 |
if not match:
|
|
@@ -299,11 +304,62 @@ def ingest(force=False, limit=None, allow_shrink=False):
|
|
| 299 |
return True
|
| 300 |
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
def main():
|
| 303 |
force = "--force" in sys.argv
|
| 304 |
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 305 |
if a.startswith("--limit=")), None)
|
| 306 |
-
|
|
|
|
| 307 |
allow_shrink="--allow-shrink" in sys.argv)
|
| 308 |
sys.exit(0 if ok else 1)
|
| 309 |
|
|
|
|
| 32 |
OUT_FILE = PROCESSED_DIR / "dmemos.json"
|
| 33 |
|
| 34 |
# <h2> headings that are page boilerplate rather than memo content.
|
| 35 |
+
_SKIP_HEADINGS = {"contact us", "related links",
|
| 36 |
+
"contactez-nous", "liens connexes"}
|
| 37 |
+
# Bilingual: the French editions live at the same paths with -fra.html, so
|
| 38 |
+
# one pattern serves both ingest passes.
|
| 39 |
+
_MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-(?:eng|fra)\.html")
|
| 40 |
+
_URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-(?:eng|fra)\.html")
|
| 41 |
_DS_HREF = re.compile(r'href="([^"]+)"')
|
| 42 |
+
DATASOURCE_URL_FR = DATASOURCE_URL.replace("-eng.json", "-fra.json")
|
| 43 |
+
OUT_FILE_FR = PROCESSED_DIR / "dmemos-fr.json"
|
| 44 |
|
| 45 |
|
| 46 |
def _fetch(url, dest, force=False):
|
|
|
|
| 51 |
"""Active D-memo URLs from the index's JSON datasource. Pure, for tests."""
|
| 52 |
urls, seen = [], set()
|
| 53 |
for row in json.loads(raw).get("data", []):
|
| 54 |
+
if _norm(row.get("STATUS", "")).lower() in ("cancelled", "annulé"):
|
| 55 |
continue
|
| 56 |
match = _DS_HREF.search(row.get("NUMBER", ""))
|
| 57 |
if not match:
|
|
|
|
| 304 |
return True
|
| 305 |
|
| 306 |
|
| 307 |
+
def ingest_fr(force=False, limit=None, allow_shrink=False):
|
| 308 |
+
"""The French editions (Mémorandums D), mirroring ingest().
|
| 309 |
+
|
| 310 |
+
Same pages at -fra.html, same WET markup, same parser; the chunks are
|
| 311 |
+
post-processed with lang='fr', a dmemo-fr- id prefix and the French
|
| 312 |
+
citation so language-routed retrieval (index.query_lang) serves them
|
| 313 |
+
only to French queries."""
|
| 314 |
+
try:
|
| 315 |
+
urls = datasource_urls(_fetch(DATASOURCE_URL_FR,
|
| 316 |
+
DMEMO_DIR / "_datasource-fra.json",
|
| 317 |
+
force=force))
|
| 318 |
+
except Exception as exc:
|
| 319 |
+
print(f" FR datasource unavailable ({type(exc).__name__}: {exc})")
|
| 320 |
+
urls = []
|
| 321 |
+
if limit:
|
| 322 |
+
urls = urls[:limit]
|
| 323 |
+
print(f"Ingesting {len(urls)} Mémorandums D (français)...")
|
| 324 |
+
all_chunks, failures = [], []
|
| 325 |
+
for i, url in enumerate(urls, 1):
|
| 326 |
+
try:
|
| 327 |
+
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
|
| 328 |
+
chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
|
| 329 |
+
if chunks:
|
| 330 |
+
all_chunks.extend(chunks)
|
| 331 |
+
else:
|
| 332 |
+
failures.append((url, "no content parsed"))
|
| 333 |
+
except Exception as exc:
|
| 334 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 335 |
+
if i % 50 == 0:
|
| 336 |
+
print(f" {i}/{len(urls)} ...")
|
| 337 |
+
for c in all_chunks:
|
| 338 |
+
c["id"] = c["id"].replace("dmemo-", "dmemo-fr-", 1)
|
| 339 |
+
c["lang"] = "fr"
|
| 340 |
+
c["citation"] = c["citation"].replace("Memorandum", "Mémorandum", 1)
|
| 341 |
+
if failures:
|
| 342 |
+
print(f" {len(failures)} mémorandums with no content / errors "
|
| 343 |
+
f"(first: {failures[0][0].rsplit('/', 1)[-1]})")
|
| 344 |
+
if not limit:
|
| 345 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 346 |
+
stored_chunks(OUT_FILE_FR))
|
| 347 |
+
if preserved:
|
| 348 |
+
print(f" kept the last-good copy of {len(preserved)} "
|
| 349 |
+
f"unfetchable mémorandum(s)")
|
| 350 |
+
if not write_corpus(OUT_FILE_FR, all_chunks, allow_shrink=allow_shrink):
|
| 351 |
+
return False
|
| 352 |
+
print(f" {len(all_chunks)} section-chunks from "
|
| 353 |
+
f"{len(urls) - len(failures)} mémorandums -> {OUT_FILE_FR.name}")
|
| 354 |
+
return True
|
| 355 |
+
|
| 356 |
+
|
| 357 |
def main():
|
| 358 |
force = "--force" in sys.argv
|
| 359 |
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 360 |
if a.startswith("--limit=")), None)
|
| 361 |
+
runner = ingest_fr if "--fr" in sys.argv else ingest
|
| 362 |
+
ok = runner(force=force, limit=limit,
|
| 363 |
allow_shrink="--allow-shrink" in sys.argv)
|
| 364 |
sys.exit(0 if ok else 1)
|
| 365 |
|
|
@@ -71,6 +71,10 @@ APPENDIX_CAP = 3 # max referenced appendices co-surfaced into a result set
|
|
| 71 |
# items are distinct provisions, so (like legislation) they are never collapsed
|
| 72 |
# under the diversity cap.
|
| 73 |
PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
_TOKEN = re.compile(r"[a-z0-9]+")
|
| 76 |
# A chunk id ending '-p<k>' marks one embeddable piece of a long section that
|
|
@@ -237,6 +241,8 @@ class LegislationIndex:
|
|
| 237 |
raise RuntimeError(
|
| 238 |
f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 239 |
self._tri_index = None # lazy; see _fuzzy_term
|
|
|
|
|
|
|
| 240 |
# Cached recall mask for the common case (English query, no
|
| 241 |
# filters): everything but the French twins.
|
| 242 |
self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
|
|
@@ -248,9 +254,22 @@ class LegislationIndex:
|
|
| 248 |
self._load_reranker()
|
| 249 |
|
| 250 |
def _build_bm25(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
self.doc_len = []
|
| 252 |
self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
|
| 253 |
df = defaultdict(int)
|
|
|
|
|
|
|
| 254 |
for idx, c in enumerate(self.chunks):
|
| 255 |
# The topical title is repeated to weight it above body text;
|
| 256 |
# the Act name, code and section are indexed too, so an Act's own
|
|
@@ -265,12 +284,24 @@ class LegislationIndex:
|
|
| 265 |
c["section"], c["text"]))
|
| 266 |
counts = Counter(tokenize(blob))
|
| 267 |
self.doc_len.append(sum(counts.values()))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
for term, tf in counts.items():
|
| 269 |
self.postings[term].append((idx, tf))
|
| 270 |
df[term] += 1
|
|
|
|
|
|
|
| 271 |
n = len(self.chunks)
|
| 272 |
self.avgdl = sum(self.doc_len) / n
|
| 273 |
self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
def _build_note_tokens(self):
|
| 276 |
"""Pre-tokenise each chunk's topical title (see topical_title) for the
|
|
@@ -390,28 +421,128 @@ class LegislationIndex:
|
|
| 390 |
best, best_score = cand, score
|
| 391 |
return best
|
| 392 |
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
"""allowed: optional boolean list by chunk index. Filtered searches
|
| 395 |
pass it so recall competes only within scope -- filtering after
|
| 396 |
recall meant a doc_type='caselaw' search drew from whatever case law
|
| 397 |
-
happened to survive the open-corpus top-N competition.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
scores = defaultdict(float)
|
| 399 |
for term in set(tokenize(query)):
|
| 400 |
-
idf =
|
| 401 |
weight = 1.0
|
| 402 |
if idf is None:
|
| 403 |
# Unknown token: try the typo bridge at a discount, so a
|
| 404 |
# misspelled query still reaches the provision while a real
|
| 405 |
# match always outranks a fuzzy one.
|
| 406 |
alt = self._fuzzy_term(term)
|
| 407 |
-
if alt is None:
|
| 408 |
continue
|
| 409 |
-
term, idf, weight = alt,
|
| 410 |
for idx, tf in self.postings[term]:
|
| 411 |
if allowed is not None and not allowed[idx]:
|
| 412 |
continue
|
| 413 |
dl = self.doc_len[idx]
|
| 414 |
-
denom = tf + K1 * (1 - B + B * dl /
|
| 415 |
scores[idx] += weight * idf * tf * (K1 + 1) / denom
|
| 416 |
return scores
|
| 417 |
|
|
@@ -472,7 +603,23 @@ class LegislationIndex:
|
|
| 472 |
if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
|
| 473 |
return None
|
| 474 |
if doc_type == "memorandum":
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
return (doc_type, c["act_code"]) # one decision, keyed by citation
|
| 477 |
|
| 478 |
def _capped_top(self, ordered):
|
|
@@ -654,9 +801,14 @@ class LegislationIndex:
|
|
| 654 |
def search(self, query, top_k=6, act=None, doc_type=None, court=None,
|
| 655 |
decided_after=None):
|
| 656 |
"""Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
|
| 657 |
-
#
|
| 658 |
-
#
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
qlang = query_lang(query)
|
| 661 |
confidence = None
|
| 662 |
fused = defaultdict(float)
|
|
@@ -673,7 +825,8 @@ class LegislationIndex:
|
|
| 673 |
mask = self._en_mask
|
| 674 |
else:
|
| 675 |
mask = None
|
| 676 |
-
bm25 = self._bm25_scores(expanded, allowed=mask
|
|
|
|
| 677 |
for rank, idx in enumerate(self._capped_top(
|
| 678 |
sorted(bm25, key=bm25.get, reverse=True))):
|
| 679 |
fused[idx] += 1.0 / (RRF_K + rank)
|
|
@@ -756,13 +909,18 @@ class LegislationIndex:
|
|
| 756 |
pool = candidates[:RERANK_POOL]
|
| 757 |
docs = [self._rerank_doc(i) for i in pool]
|
| 758 |
ce_orig = self.reranker.score(query, docs)
|
| 759 |
-
#
|
| 760 |
-
#
|
| 761 |
-
#
|
| 762 |
-
#
|
| 763 |
-
#
|
| 764 |
-
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
ce_exp = self.reranker.score(expanded, docs)
|
| 767 |
ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
|
| 768 |
ce = dict(zip(pool, ce_orig))
|
|
|
|
| 71 |
# items are distinct provisions, so (like legislation) they are never collapsed
|
| 72 |
# under the diversity cap.
|
| 73 |
PRIMARY_DOC_TYPES = frozenset({"legislation", "agreement", "directive", "delegation"})
|
| 74 |
+
# Memorandum families whose act_code is ONE shared constant over many
|
| 75 |
+
# independent items, so the diversity cap keys on `section` instead (see
|
| 76 |
+
# _source_key): a D-memo number, an AMPS contravention code.
|
| 77 |
+
_FLAT_MEMO_CODES = frozenset({"D-Memo", "AMPS"})
|
| 78 |
|
| 79 |
_TOKEN = re.compile(r"[a-z0-9]+")
|
| 80 |
# A chunk id ending '-p<k>' marks one embeddable piece of a long section that
|
|
|
|
| 241 |
raise RuntimeError(
|
| 242 |
f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 243 |
self._tri_index = None # lazy; see _fuzzy_term
|
| 244 |
+
self._word_vocab = None # lazy; see _correct_word (typo repair)
|
| 245 |
+
self._word_tri = None
|
| 246 |
# Cached recall mask for the common case (English query, no
|
| 247 |
# filters): everything but the French twins.
|
| 248 |
self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
|
|
|
|
| 254 |
self._load_reranker()
|
| 255 |
|
| 256 |
def _build_bm25(self):
|
| 257 |
+
"""BM25 postings plus TWO sets of collection statistics: one over the
|
| 258 |
+
whole corpus, one over the English scope alone.
|
| 259 |
+
|
| 260 |
+
An English query never retrieves the French twins (see _en_mask), yet
|
| 261 |
+
a single corpus-wide idf/avgdl let those unreachable chunks set the
|
| 262 |
+
rarity of every shared token (numbers, act codes, proper nouns, folded
|
| 263 |
+
cognates) and drag the average document length. Ingesting the French
|
| 264 |
+
D-memos and NJC directives moved legacy Hit@1 0.767 -> 0.750 that way,
|
| 265 |
+
without a single French chunk ever being retrievable -- the statistics
|
| 266 |
+
must be scoped to what the query can actually reach.
|
| 267 |
+
"""
|
| 268 |
self.doc_len = []
|
| 269 |
self.postings = defaultdict(list) # term -> [(doc_idx, term_frequency), ...]
|
| 270 |
df = defaultdict(int)
|
| 271 |
+
df_en = defaultdict(int)
|
| 272 |
+
en_docs = en_total_len = 0
|
| 273 |
for idx, c in enumerate(self.chunks):
|
| 274 |
# The topical title is repeated to weight it above body text;
|
| 275 |
# the Act name, code and section are indexed too, so an Act's own
|
|
|
|
| 284 |
c["section"], c["text"]))
|
| 285 |
counts = Counter(tokenize(blob))
|
| 286 |
self.doc_len.append(sum(counts.values()))
|
| 287 |
+
is_en = c.get("lang", "en") != "fr"
|
| 288 |
+
if is_en:
|
| 289 |
+
en_docs += 1
|
| 290 |
+
en_total_len += self.doc_len[-1]
|
| 291 |
for term, tf in counts.items():
|
| 292 |
self.postings[term].append((idx, tf))
|
| 293 |
df[term] += 1
|
| 294 |
+
if is_en:
|
| 295 |
+
df_en[term] += 1
|
| 296 |
n = len(self.chunks)
|
| 297 |
self.avgdl = sum(self.doc_len) / n
|
| 298 |
self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
|
| 299 |
+
# The English-scoped twins. A term the English corpus never uses is
|
| 300 |
+
# absent from idf_en rather than zero-weighted: it can only match
|
| 301 |
+
# chunks the mask already excludes.
|
| 302 |
+
self.avgdl_en = en_total_len / (en_docs or 1)
|
| 303 |
+
self.idf_en = {t: math.log(1 + (en_docs - d + 0.5) / (d + 0.5))
|
| 304 |
+
for t, d in df_en.items()}
|
| 305 |
|
| 306 |
def _build_note_tokens(self):
|
| 307 |
"""Pre-tokenise each chunk's topical title (see topical_title) for the
|
|
|
|
| 421 |
best, best_score = cand, score
|
| 422 |
return best
|
| 423 |
|
| 424 |
+
@staticmethod
|
| 425 |
+
def _edit_distance(a, b, cap=3):
|
| 426 |
+
"""Damerau-Levenshtein distance, early-exited past `cap` -- typos
|
| 427 |
+
are substitutions, insertions, deletions and transpositions, and a
|
| 428 |
+
transposition ('undeclraed') must cost 1, not 2."""
|
| 429 |
+
if abs(len(a) - len(b)) > cap:
|
| 430 |
+
return cap + 1
|
| 431 |
+
prev2, prev = None, list(range(len(b) + 1))
|
| 432 |
+
for i, ca in enumerate(a, start=1):
|
| 433 |
+
cur = [i]
|
| 434 |
+
best_row = i
|
| 435 |
+
for j, cb in enumerate(b, start=1):
|
| 436 |
+
cost = 0 if ca == cb else 1
|
| 437 |
+
d = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
| 438 |
+
if (prev2 is not None and i > 1 and j > 1
|
| 439 |
+
and ca == b[j - 2] and a[i - 2] == cb):
|
| 440 |
+
d = min(d, prev2[j - 2] + 1)
|
| 441 |
+
cur.append(d)
|
| 442 |
+
best_row = min(best_row, d)
|
| 443 |
+
if best_row > cap:
|
| 444 |
+
return cap + 1
|
| 445 |
+
prev2, prev = prev, cur
|
| 446 |
+
return prev[-1]
|
| 447 |
+
|
| 448 |
+
def _correct_word(self, word):
|
| 449 |
+
"""Nearest corpus WORD (not stem) for a misspelling, or None.
|
| 450 |
+
|
| 451 |
+
Trigram index generates candidates; Damerau-Levenshtein picks the
|
| 452 |
+
winner (distance <= 2, shared 2-char prefix, frequency floor of 5
|
| 453 |
+
so OCR debris in the corpus can never be a 'correction'). The
|
| 454 |
+
result is real text: it feeds the semantic embedder and the
|
| 455 |
+
cross-encoder, which score words, not stems."""
|
| 456 |
+
if self._word_vocab is None:
|
| 457 |
+
words = Counter()
|
| 458 |
+
for c in self.chunks:
|
| 459 |
+
words.update(re.findall(
|
| 460 |
+
r"[a-z]{4,}", _fold_accents(c["text"].lower())))
|
| 461 |
+
tri = defaultdict(set)
|
| 462 |
+
for w, n in words.items():
|
| 463 |
+
if n >= 5: # junk never enters the index
|
| 464 |
+
for i in range(len(w) - 2):
|
| 465 |
+
tri[w[i:i + 3]].add(w)
|
| 466 |
+
self._word_vocab, self._word_tri = words, tri
|
| 467 |
+
tris = {word[i:i + 3] for i in range(len(word) - 2)}
|
| 468 |
+
candidates = set()
|
| 469 |
+
for t in tris:
|
| 470 |
+
candidates |= self._word_tri.get(t, set())
|
| 471 |
+
best, best_key = None, (3, 0) # (distance, -frequency)
|
| 472 |
+
for cand in candidates:
|
| 473 |
+
if abs(len(cand) - len(word)) > 2 or cand[:2] != word[:2]:
|
| 474 |
+
continue
|
| 475 |
+
dist = self._edit_distance(word, cand, cap=2)
|
| 476 |
+
if dist > 2:
|
| 477 |
+
continue
|
| 478 |
+
key = (dist, -self._word_vocab[cand])
|
| 479 |
+
if key < best_key:
|
| 480 |
+
best, best_key = cand, key
|
| 481 |
+
return best
|
| 482 |
+
|
| 483 |
+
def _correct_query(self, query):
|
| 484 |
+
"""The query with misspelled words replaced by their nearest corpus
|
| 485 |
+
words; the query itself when nothing needed fixing. A word is only
|
| 486 |
+
'misspelled' when its stem is absent from the whole BM25 vocabulary,
|
| 487 |
+
so correctly-spelled queries pass through untouched by
|
| 488 |
+
construction."""
|
| 489 |
+
out, changed = [], False
|
| 490 |
+
for tok in query.split():
|
| 491 |
+
word = _fold_accents(tok.lower()).strip(".,;:!?()'\"")
|
| 492 |
+
if len(word) >= 4 and word.isalpha():
|
| 493 |
+
stem = _stem(word)
|
| 494 |
+
df = len(self.postings.get(stem, ()))
|
| 495 |
+
if df == 0:
|
| 496 |
+
# Unknown everywhere: replace outright.
|
| 497 |
+
fix = self._correct_word(word)
|
| 498 |
+
if fix and fix != word:
|
| 499 |
+
out.append(fix)
|
| 500 |
+
changed = True
|
| 501 |
+
continue
|
| 502 |
+
elif df <= 2:
|
| 503 |
+
# Nearly unknown -- often a typo that happens to collide
|
| 504 |
+
# with OCR debris in the corpus. APPEND a strong
|
| 505 |
+
# correction rather than replacing, so a legitimately
|
| 506 |
+
# rare term (a chemical in a CDSA schedule, a case name)
|
| 507 |
+
# never loses its own recall.
|
| 508 |
+
fix = self._correct_word(word)
|
| 509 |
+
if (fix and fix != word
|
| 510 |
+
and self._word_vocab[fix] >= 100):
|
| 511 |
+
out.append(f"{tok} {fix}")
|
| 512 |
+
changed = True
|
| 513 |
+
continue
|
| 514 |
+
out.append(tok)
|
| 515 |
+
return " ".join(out) if changed else query
|
| 516 |
+
|
| 517 |
+
def _bm25_scores(self, query, allowed=None, en_only=False):
|
| 518 |
"""allowed: optional boolean list by chunk index. Filtered searches
|
| 519 |
pass it so recall competes only within scope -- filtering after
|
| 520 |
recall meant a doc_type='caselaw' search drew from whatever case law
|
| 521 |
+
happened to survive the open-corpus top-N competition.
|
| 522 |
+
|
| 523 |
+
en_only: score with the English-scoped collection statistics (see
|
| 524 |
+
_build_bm25). Set for every English query, whose mask already bars
|
| 525 |
+
the French twins from the results they would otherwise be weighting.
|
| 526 |
+
"""
|
| 527 |
+
idf_map = self.idf_en if en_only else self.idf
|
| 528 |
+
avgdl = self.avgdl_en if en_only else self.avgdl
|
| 529 |
scores = defaultdict(float)
|
| 530 |
for term in set(tokenize(query)):
|
| 531 |
+
idf = idf_map.get(term)
|
| 532 |
weight = 1.0
|
| 533 |
if idf is None:
|
| 534 |
# Unknown token: try the typo bridge at a discount, so a
|
| 535 |
# misspelled query still reaches the provision while a real
|
| 536 |
# match always outranks a fuzzy one.
|
| 537 |
alt = self._fuzzy_term(term)
|
| 538 |
+
if alt is None or alt not in idf_map:
|
| 539 |
continue
|
| 540 |
+
term, idf, weight = alt, idf_map[alt], 0.7
|
| 541 |
for idx, tf in self.postings[term]:
|
| 542 |
if allowed is not None and not allowed[idx]:
|
| 543 |
continue
|
| 544 |
dl = self.doc_len[idx]
|
| 545 |
+
denom = tf + K1 * (1 - B + B * dl / avgdl)
|
| 546 |
scores[idx] += weight * idf * tf * (K1 + 1) / denom
|
| 547 |
return scores
|
| 548 |
|
|
|
|
| 603 |
if doc_type in PRIMARY_DOC_TYPES or doc_type in ("benefits", "commentary"):
|
| 604 |
return None
|
| 605 |
if doc_type == "memorandum":
|
| 606 |
+
# The key must be the parent DOCUMENT, and which field carries that
|
| 607 |
+
# identity differs across the memorandum families. ENF chapters,
|
| 608 |
+
# PDI pages and the IRB guidelines each have their own act_code and
|
| 609 |
+
# use `section` to subdivide it, so act_code is the document.
|
| 610 |
+
# _FLAT_MEMO_CODES are the two families where one act_code covers
|
| 611 |
+
# many independent items -- ~360 D-memoranda and ~1,000 AMPS
|
| 612 |
+
# contraventions -- and `section` (memo number / contravention
|
| 613 |
+
# code) is the unit a reader would cite.
|
| 614 |
+
#
|
| 615 |
+
# Keying every memorandum on `section` (as this did) gave each ENF
|
| 616 |
+
# section and each PDI page its own source key, so neither cap ever
|
| 617 |
+
# bound at the document level: the ~150-page PDI refugee tree could
|
| 618 |
+
# fill the whole candidate pool and push IRPA s. 112 out of a PRRA
|
| 619 |
+
# query entirely.
|
| 620 |
+
code = c["act_code"]
|
| 621 |
+
return ("memorandum",
|
| 622 |
+
c["section"] if code in _FLAT_MEMO_CODES else code)
|
| 623 |
return (doc_type, c["act_code"]) # one decision, keyed by citation
|
| 624 |
|
| 625 |
def _capped_top(self, ordered):
|
|
|
|
| 801 |
def search(self, query, top_k=6, act=None, doc_type=None, court=None,
|
| 802 |
decided_after=None):
|
| 803 |
"""Hybrid candidate fusion (BM25 + semantic), then cross-encoder rerank."""
|
| 804 |
+
# Repair misspellings first (no-op for correctly-spelled queries),
|
| 805 |
+
# then expand legal abbreviations (PRRA, H&C, ...) into statutory
|
| 806 |
+
# wording for the recall stages. The corrected text is what feeds
|
| 807 |
+
# the semantic embedder and the cross-encoder -- the stem-level
|
| 808 |
+
# fuzzy bridge in _bm25_scores only ever helped BM25, which is why
|
| 809 |
+
# typo queries used to reach the pool but not the top ranks.
|
| 810 |
+
corrected = self._correct_query(query)
|
| 811 |
+
expanded = expand_query(corrected)
|
| 812 |
qlang = query_lang(query)
|
| 813 |
confidence = None
|
| 814 |
fused = defaultdict(float)
|
|
|
|
| 825 |
mask = self._en_mask
|
| 826 |
else:
|
| 827 |
mask = None
|
| 828 |
+
bm25 = self._bm25_scores(expanded, allowed=mask,
|
| 829 |
+
en_only=(qlang == "en"))
|
| 830 |
for rank, idx in enumerate(self._capped_top(
|
| 831 |
sorted(bm25, key=bm25.get, reverse=True))):
|
| 832 |
fused[idx] += 1.0 / (RRF_K + rank)
|
|
|
|
| 909 |
pool = candidates[:RERANK_POOL]
|
| 910 |
docs = [self._rerank_doc(i) for i in pool]
|
| 911 |
ce_orig = self.reranker.score(query, docs)
|
| 912 |
+
# A repaired query is scored alongside the original and each
|
| 913 |
+
# candidate keeps its better score -- the cross-encoder cannot
|
| 914 |
+
# ground 'detension' against text saying 'detention'. Applies
|
| 915 |
+
# only when a correction fired, so legacy queries never take
|
| 916 |
+
# the second pass.
|
| 917 |
+
if corrected != query:
|
| 918 |
+
ce_fix = self.reranker.score(corrected, docs)
|
| 919 |
+
ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_fix)]
|
| 920 |
+
# Optional (CANLEX_RERANK_DUAL=1): likewise for the
|
| 921 |
+
# synonym-expanded form. Off by default: the 2026-07 eval could
|
| 922 |
+
# not attribute its effect cleanly on the grown corpus.
|
| 923 |
+
if expanded != corrected and os.environ.get("CANLEX_RERANK_DUAL"):
|
| 924 |
ce_exp = self.reranker.score(expanded, docs)
|
| 925 |
ce_orig = [max(a, b) for a, b in zip(ce_orig, ce_exp)]
|
| 926 |
ce = dict(zip(pool, ce_orig))
|
|
@@ -21,19 +21,52 @@ OUT_FILE = PROCESSED_DIR / "pdi.json"
|
|
| 21 |
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 22 |
"publications-manuals/operational-bulletins-manuals/")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
SETS = [
|
| 25 |
{"code": "pdi-prcard",
|
| 26 |
"short": "PDI PR Card",
|
| 27 |
"name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
|
| 28 |
"index": _OBM + "permanent-residence/card.html",
|
| 29 |
-
# sub-pages live under these path fragments relative to the index
|
| 30 |
"scope": "/permanent-residence/card"},
|
| 31 |
-
{"code": "pdi-
|
| 32 |
-
"short": "PDI
|
| 33 |
-
"name": "PDI —
|
| 34 |
-
|
| 35 |
-
"
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
]
|
| 38 |
|
| 39 |
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
|
@@ -116,17 +149,25 @@ def preserve_failed(chunks, failures, stored):
|
|
| 116 |
only={url for url, _why in failures})
|
| 117 |
|
| 118 |
|
| 119 |
-
def
|
| 120 |
-
"""
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
if src["scope"] not in href:
|
| 131 |
continue
|
| 132 |
url = href if href.startswith("http") else \
|
|
@@ -134,15 +175,38 @@ def build(allow_shrink=False):
|
|
| 134 |
if url in seen:
|
| 135 |
continue
|
| 136 |
seen.add(url)
|
|
|
|
|
|
|
|
|
|
| 137 |
name = url.rsplit("/", 1)[1].replace(".html", "")
|
| 138 |
try:
|
| 139 |
page = fetch_cached(
|
| 140 |
url, RAW / f"{src['code']}-{name}.html",
|
| 141 |
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 142 |
-
pages.append((url, page))
|
| 143 |
except Exception as exc:
|
| 144 |
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 145 |
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
n = 0
|
| 147 |
for page_no, (url, html) in enumerate(pages):
|
| 148 |
chunks = _page_chunks(src, url, html, page_no)
|
|
|
|
| 21 |
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 22 |
"publications-manuals/operational-bulletins-manuals/")
|
| 23 |
|
| 24 |
+
# Each set is one PDI category tree, crawled breadth-first from its index to
|
| 25 |
+
# _MAX_DEPTH, capped at _MAX_PAGES pages (dropped pages are LOGGED, never
|
| 26 |
+
# silently skipped). Scope fragments keep the crawl inside the category. The
|
| 27 |
+
# selection is the border-relevant slice of the OBM tree: temporary
|
| 28 |
+
# residents (visitors/eTA/TRV/TRPs), refugee protection (port-of-entry
|
| 29 |
+
# claims), the cross-cutting standard requirements (includes the Tran
|
| 30 |
+
# serious-criminality page), officer designation/delegation, biometrics and
|
| 31 |
+
# identity, and the PR-card section (ENF 27's successor). Deliberately out:
|
| 32 |
+
# citizenship, fees, office service-delivery, and the year-by-year
|
| 33 |
+
# operational-bulletin archives (mostly expired instructions).
|
| 34 |
+
_MAX_DEPTH = 2
|
| 35 |
+
_MAX_PAGES = 160 # 60 capped out: the Temporary Residents tree alone has
|
| 36 |
+
# ~150 in-scope pages (2026-07 crawl)
|
| 37 |
+
|
| 38 |
SETS = [
|
| 39 |
{"code": "pdi-prcard",
|
| 40 |
"short": "PDI PR Card",
|
| 41 |
"name": "PDI — Permanent resident card and PR status (successor to ENF 27)",
|
| 42 |
"index": _OBM + "permanent-residence/card.html",
|
|
|
|
| 43 |
"scope": "/permanent-residence/card"},
|
| 44 |
+
{"code": "pdi-tr",
|
| 45 |
+
"short": "PDI Temporary Residents",
|
| 46 |
+
"name": "PDI — Temporary Resident Program (visitors, eTA, TRV, permits)",
|
| 47 |
+
"index": _OBM + "temporary-residents.html",
|
| 48 |
+
"scope": "/temporary-residents"},
|
| 49 |
+
{"code": "pdi-refugee",
|
| 50 |
+
"short": "PDI Refugee Protection",
|
| 51 |
+
"name": "PDI — Refugee Protection (claims, eligibility, port of entry)",
|
| 52 |
+
"index": _OBM + "refugee-protection.html",
|
| 53 |
+
"scope": "/refugee-protection"},
|
| 54 |
+
{"code": "pdi-standard",
|
| 55 |
+
"short": "PDI Standard Requirements",
|
| 56 |
+
"name": "PDI — Standard requirements (incl. Tran serious-criminality "
|
| 57 |
+
"assessment)",
|
| 58 |
+
"index": _OBM + "standard-requirements.html",
|
| 59 |
+
"scope": "/standard-requirements"},
|
| 60 |
+
{"code": "pdi-delegation",
|
| 61 |
+
"short": "PDI Designation-Delegation",
|
| 62 |
+
"name": "PDI — Designation of officers and delegation of authority",
|
| 63 |
+
"index": _OBM + "legislation.html",
|
| 64 |
+
"scope": "operational-bulletins-manuals/legislation"},
|
| 65 |
+
{"code": "pdi-identity",
|
| 66 |
+
"short": "PDI Biometrics-Identity",
|
| 67 |
+
"name": "PDI — Biometrics and identity management",
|
| 68 |
+
"index": _OBM + "identity-management.html",
|
| 69 |
+
"scope": "/identity-management"},
|
| 70 |
]
|
| 71 |
|
| 72 |
_MODIFIED = re.compile(r'<time[^>]*>(\d{4}-\d{2}-\d{2})</time>')
|
|
|
|
| 149 |
only={url for url, _why in failures})
|
| 150 |
|
| 151 |
|
| 152 |
+
def _crawl(src, failures):
|
| 153 |
+
"""Breadth-first crawl of one PDI category: index -> in-scope links ->
|
| 154 |
+
their in-scope links, to _MAX_DEPTH, capped at _MAX_PAGES pages.
|
| 155 |
+
Returns [(url, html)]; pages dropped by the cap are LOGGED, and fetch
|
| 156 |
+
failures land in `failures` so preserve_failed keeps their stored
|
| 157 |
+
copies."""
|
| 158 |
+
index_html = fetch_cached(
|
| 159 |
+
src["index"], RAW / f"{src['code']}-index.html",
|
| 160 |
+
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 161 |
+
pages = [(src["index"], index_html)]
|
| 162 |
+
if not src["scope"]:
|
| 163 |
+
return pages
|
| 164 |
+
seen = {src["index"]}
|
| 165 |
+
frontier = [(src["index"], index_html)]
|
| 166 |
+
dropped = 0
|
| 167 |
+
for _depth in range(_MAX_DEPTH):
|
| 168 |
+
next_frontier = []
|
| 169 |
+
for _url, html in frontier:
|
| 170 |
+
for href in _LINK.findall(_main(html)):
|
| 171 |
if src["scope"] not in href:
|
| 172 |
continue
|
| 173 |
url = href if href.startswith("http") else \
|
|
|
|
| 175 |
if url in seen:
|
| 176 |
continue
|
| 177 |
seen.add(url)
|
| 178 |
+
if len(pages) >= _MAX_PAGES:
|
| 179 |
+
dropped += 1
|
| 180 |
+
continue
|
| 181 |
name = url.rsplit("/", 1)[1].replace(".html", "")
|
| 182 |
try:
|
| 183 |
page = fetch_cached(
|
| 184 |
url, RAW / f"{src['code']}-{name}.html",
|
| 185 |
powershell=True, pause=0.8).decode("utf-8", "replace")
|
|
|
|
| 186 |
except Exception as exc:
|
| 187 |
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 188 |
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 189 |
+
continue
|
| 190 |
+
pages.append((url, page))
|
| 191 |
+
next_frontier.append((url, page))
|
| 192 |
+
frontier = next_frontier
|
| 193 |
+
if dropped:
|
| 194 |
+
print(f" !! {src['short']}: page cap {_MAX_PAGES} hit -- {dropped} "
|
| 195 |
+
f"in-scope pages NOT ingested (raise _MAX_PAGES to cover them)")
|
| 196 |
+
return pages
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def build(allow_shrink=False):
|
| 200 |
+
"""Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
|
| 201 |
+
all_chunks, failures = [], []
|
| 202 |
+
for src in SETS:
|
| 203 |
+
try:
|
| 204 |
+
pages = _crawl(src, failures)
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
failures.append((src["index"], f"{type(exc).__name__}: {exc}"))
|
| 207 |
+
print(f" !! {src['short']}: index fetch failed "
|
| 208 |
+
f"({type(exc).__name__}: {exc})")
|
| 209 |
+
continue
|
| 210 |
n = 0
|
| 211 |
for page_no, (url, html) in enumerate(pages):
|
| 212 |
chunks = _page_chunks(src, url, html, page_no)
|
|
@@ -71,7 +71,12 @@ _NON_XML = [
|
|
| 71 |
("commentary", "commentary.json", "py -m canlex.commentary",
|
| 72 |
"Curated commentary chunks"),
|
| 73 |
("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
|
| 74 |
-
("pdi", "pdi.json", "py -m canlex.pdi",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
|
| 76 |
"IRB Chairperson's Guidelines"),
|
| 77 |
]
|
|
|
|
| 71 |
("commentary", "commentary.json", "py -m canlex.commentary",
|
| 72 |
"Curated commentary chunks"),
|
| 73 |
("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
|
| 74 |
+
("pdi", "pdi.json", "py -m canlex.pdi",
|
| 75 |
+
"IRCC PDI trees (TR/refugee/standard/identity/PR-card)"),
|
| 76 |
+
("memorandum-fr", "dmemos-fr.json", "py -m canlex.dmemo --fr",
|
| 77 |
+
"Mémorandums D (français)"),
|
| 78 |
+
("directive-fr", "directives-fr.json", "py -m canlex.directive --fr",
|
| 79 |
+
"Directives du CNM (français)"),
|
| 80 |
("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
|
| 81 |
"IRB Chairperson's Guidelines"),
|
| 82 |
]
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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:2fa0640d280572773d6e35b23454c4de8e589a5d658a11a79e76175cce4faab3
|
| 3 |
+
size 91712138
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -142,6 +142,145 @@ class MultiVectorTests(unittest.TestCase):
|
|
| 142 |
self.assertTrue(all(t.startswith("A . ") for t in texts[1:]))
|
| 143 |
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
class RefreshCuratedTests(unittest.TestCase):
|
| 146 |
def test_check_curated_runs_offline(self):
|
| 147 |
from canlex.refresh import check_curated
|
|
|
|
| 142 |
self.assertTrue(all(t.startswith("A . ") for t in texts[1:]))
|
| 143 |
|
| 144 |
|
| 145 |
+
class TypoCorrectionTests(unittest.TestCase):
|
| 146 |
+
def test_edit_distance(self):
|
| 147 |
+
from canlex.index import LegislationIndex as L
|
| 148 |
+
self.assertEqual(L._edit_distance("detension", "detention"), 1)
|
| 149 |
+
self.assertEqual(L._edit_distance("undeclraed", "undeclared"), 1)
|
| 150 |
+
self.assertEqual(L._edit_distance("serius", "serious"), 1)
|
| 151 |
+
self.assertEqual(L._edit_distance("same", "same"), 0)
|
| 152 |
+
self.assertGreater(L._edit_distance("apple", "orange"), 2)
|
| 153 |
+
|
| 154 |
+
def test_edit_distance_transposition_costs_one(self):
|
| 155 |
+
from canlex.index import LegislationIndex as L
|
| 156 |
+
self.assertEqual(L._edit_distance("sieze", "seize"), 1)
|
| 157 |
+
|
| 158 |
+
@staticmethod
|
| 159 |
+
def _index(text, postings=None):
|
| 160 |
+
"""A bare index carrying just what the correction layer reads."""
|
| 161 |
+
from canlex.index import LegislationIndex
|
| 162 |
+
idx = LegislationIndex.__new__(LegislationIndex)
|
| 163 |
+
idx.chunks = [{"text": text}]
|
| 164 |
+
idx.postings = postings or {}
|
| 165 |
+
idx._word_vocab = idx._word_tri = None
|
| 166 |
+
return idx
|
| 167 |
+
|
| 168 |
+
def test_corrects_to_the_nearest_corpus_word(self):
|
| 169 |
+
idx = self._index("detention review " * 6)
|
| 170 |
+
self.assertEqual(idx._correct_word("detension"), "detention")
|
| 171 |
+
|
| 172 |
+
def test_rare_corpus_word_is_never_a_correction(self):
|
| 173 |
+
# Frequency floor of 5: OCR debris must not win a correction.
|
| 174 |
+
idx = self._index("detentlon " * 4 + "detention " * 9)
|
| 175 |
+
self.assertEqual(idx._correct_word("detension"), "detention")
|
| 176 |
+
|
| 177 |
+
def test_known_query_passes_through_untouched(self):
|
| 178 |
+
query = "detention review timelines"
|
| 179 |
+
idx = self._index("detention review " * 6,
|
| 180 |
+
postings={"detent": [0], "review": [0],
|
| 181 |
+
"timelin": [0]})
|
| 182 |
+
self.assertIs(idx._correct_query(query), query)
|
| 183 |
+
|
| 184 |
+
def test_unknown_word_is_replaced(self):
|
| 185 |
+
idx = self._index("detention review " * 6, postings={"review": [0]})
|
| 186 |
+
self.assertEqual(idx._correct_query("detension review"),
|
| 187 |
+
"detention review")
|
| 188 |
+
|
| 189 |
+
def test_nearly_unknown_word_is_appended_not_replaced(self):
|
| 190 |
+
# df <= 2 -- a rare legitimate term keeps its own recall, so the
|
| 191 |
+
# correction is added beside it rather than swapped in.
|
| 192 |
+
idx = self._index("seizure " * 120 + "seizuer ",
|
| 193 |
+
postings={"seizuer": [0]})
|
| 194 |
+
self.assertEqual(idx._correct_query("seizuer"), "seizuer seizure")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class LanguageScopedBM25Tests(unittest.TestCase):
|
| 198 |
+
"""The French twins must not weight English retrieval (see _build_bm25)."""
|
| 199 |
+
|
| 200 |
+
@staticmethod
|
| 201 |
+
def _index(chunks):
|
| 202 |
+
from canlex.index import LegislationIndex
|
| 203 |
+
idx = LegislationIndex.__new__(LegislationIndex)
|
| 204 |
+
idx.chunks = chunks
|
| 205 |
+
idx._tri_index = None
|
| 206 |
+
idx._build_bm25()
|
| 207 |
+
return idx
|
| 208 |
+
|
| 209 |
+
@staticmethod
|
| 210 |
+
def _chunk(text, lang="en"):
|
| 211 |
+
return {"doc_type": "legislation", "act_code": "I-2.5", "act_name": "A",
|
| 212 |
+
"act_short": "A", "section": "1", "marginal_note": "",
|
| 213 |
+
"heading": "", "part": "", "division": "", "lang": lang,
|
| 214 |
+
"text": text}
|
| 215 |
+
|
| 216 |
+
def test_french_chunks_do_not_move_english_idf(self):
|
| 217 |
+
en = [self._chunk("detention review"), self._chunk("seizure")]
|
| 218 |
+
idx_en = self._index(list(en))
|
| 219 |
+
idx_mixed = self._index(en + [self._chunk("detention", lang="fr")] * 8)
|
| 220 |
+
self.assertAlmostEqual(idx_en.idf["detain"], idx_mixed.idf_en["detain"])
|
| 221 |
+
# ... while the whole-corpus statistic, which French queries use, does
|
| 222 |
+
# see them.
|
| 223 |
+
self.assertNotAlmostEqual(idx_en.idf["detain"], idx_mixed.idf["detain"])
|
| 224 |
+
|
| 225 |
+
def test_french_chunks_do_not_move_english_avgdl(self):
|
| 226 |
+
en = [self._chunk("detention review of a permanent resident")]
|
| 227 |
+
idx_en = self._index(list(en))
|
| 228 |
+
idx_mixed = self._index(en + [self._chunk("a b c d e f g h", lang="fr")])
|
| 229 |
+
self.assertAlmostEqual(idx_en.avgdl, idx_mixed.avgdl_en)
|
| 230 |
+
self.assertNotAlmostEqual(idx_en.avgdl, idx_mixed.avgdl)
|
| 231 |
+
|
| 232 |
+
def test_french_only_term_scores_nothing_in_english_scope(self):
|
| 233 |
+
idx = self._index([self._chunk("detention review"),
|
| 234 |
+
self._chunk("annulé", lang="fr")])
|
| 235 |
+
self.assertEqual(idx._bm25_scores("annulé", en_only=True), {})
|
| 236 |
+
self.assertTrue(idx._bm25_scores("annulé"))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class MemorandumSourceKeyTests(unittest.TestCase):
|
| 240 |
+
"""The diversity cap must key each memorandum on its parent document."""
|
| 241 |
+
|
| 242 |
+
@staticmethod
|
| 243 |
+
def _keys(chunks):
|
| 244 |
+
from canlex.index import LegislationIndex
|
| 245 |
+
idx = LegislationIndex.__new__(LegislationIndex)
|
| 246 |
+
idx.chunks = [dict(c, doc_type="memorandum") for c in chunks]
|
| 247 |
+
return [idx._source_key(i) for i in range(len(idx.chunks))]
|
| 248 |
+
|
| 249 |
+
def test_enf_sections_share_their_chapter(self):
|
| 250 |
+
keys = self._keys([{"id": "enf-10-21", "act_code": "ENF-10",
|
| 251 |
+
"section": "ENF 10 s. 21"},
|
| 252 |
+
{"id": "enf-10-6", "act_code": "ENF-10",
|
| 253 |
+
"section": "ENF 10 s. 6"},
|
| 254 |
+
{"id": "enf-19-11", "act_code": "ENF-19",
|
| 255 |
+
"section": "ENF 19 s. 11"}])
|
| 256 |
+
self.assertEqual(keys[0], keys[1])
|
| 257 |
+
self.assertNotEqual(keys[0], keys[2])
|
| 258 |
+
|
| 259 |
+
def test_pdi_pages_share_their_tree(self):
|
| 260 |
+
keys = self._keys([{"id": "pdi-refugee-31-2", "act_code": "PDI-REFUGEE",
|
| 261 |
+
"section": "PRRA: Intake"},
|
| 262 |
+
{"id": "pdi-refugee-34-0", "act_code": "PDI-REFUGEE",
|
| 263 |
+
"section": "PRRA: Applicant"}])
|
| 264 |
+
self.assertEqual(keys[0], keys[1])
|
| 265 |
+
|
| 266 |
+
def test_flat_families_key_on_the_item(self):
|
| 267 |
+
# One act_code covers every D-memo and every AMPS contravention, so
|
| 268 |
+
# there the cap must bind per memo / per contravention instead.
|
| 269 |
+
keys = self._keys([{"id": "dmemo-D19-9-2-1", "act_code": "D-Memo",
|
| 270 |
+
"section": "D19-9-2"},
|
| 271 |
+
{"id": "dmemo-D19-9-2-2", "act_code": "D-Memo",
|
| 272 |
+
"section": "D19-9-2"},
|
| 273 |
+
{"id": "dmemo-D2-3-1-1", "act_code": "D-Memo",
|
| 274 |
+
"section": "D2-3-1"},
|
| 275 |
+
{"id": "amps-C001", "act_code": "AMPS",
|
| 276 |
+
"section": "C001"},
|
| 277 |
+
{"id": "amps-C004", "act_code": "AMPS",
|
| 278 |
+
"section": "C004"}])
|
| 279 |
+
self.assertEqual(keys[0], keys[1])
|
| 280 |
+
self.assertNotEqual(keys[1], keys[2])
|
| 281 |
+
self.assertNotEqual(keys[3], keys[4])
|
| 282 |
+
|
| 283 |
+
|
| 284 |
class RefreshCuratedTests(unittest.TestCase):
|
| 285 |
def test_check_curated_runs_offline(self):
|
| 286 |
from canlex.refresh import check_curated
|