Beemer Claude Fable 5 commited on
Commit ·
ebba99b
1
Parent(s): 8878510
Bilingual corpus (phase 3) + multi-vector embeddings + language-routed retrieval
Browse filesParallel French legislation for all 59 instruments (same-schema fra XML;
SOR->DORS URL mapping; lang='fr' chunks, art./annexe citations). English
queries never see French chunks; French queries (accent/stopword detection)
search both languages. Long chunks now embed as up to 8 windows scored by
best window -- D-memo tails reachable semantically. Reranker window 1000->
2000 (eval-held, Hit@5 +0.01). devises->espèces bridge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- canlex/embed.py +37 -5
- canlex/index.py +74 -25
- canlex/ingest.py +55 -1
- canlex/rerank.py +5 -1
- canlex/synonyms.py +3 -0
canlex/embed.py
CHANGED
|
@@ -39,8 +39,14 @@ def load_chunks():
|
|
| 39 |
return chunks
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def embed_text(chunk):
|
| 43 |
-
"""Compact, retrieval-focused representation of one section."""
|
| 44 |
# The section title is the strongest topical signal, so it is repeated to
|
| 45 |
# emphasise it. Title selection is doc_type-aware (see index.topical_title):
|
| 46 |
# a D-memo's marginal_note is a generic banner so its actual subject in
|
|
@@ -53,6 +59,27 @@ def embed_text(chunk):
|
|
| 53 |
return " . ".join(p for p in parts if p)
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
class Embedder:
|
| 57 |
"""Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
|
| 58 |
|
|
@@ -122,10 +149,15 @@ def build():
|
|
| 122 |
if not chunks:
|
| 123 |
print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 124 |
return
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
|
| 130 |
|
| 131 |
|
|
|
|
| 39 |
return chunks
|
| 40 |
|
| 41 |
|
| 42 |
+
# A chunk longer than the embed window gets additional vectors for its tail,
|
| 43 |
+
# up to this many per chunk (an 87K-char D-memo section would otherwise need
|
| 44 |
+
# 40+; eight windows cover 16K chars, past which BM25 carries the tail).
|
| 45 |
+
_MAX_WINDOWS = 8
|
| 46 |
+
|
| 47 |
+
|
| 48 |
def embed_text(chunk):
|
| 49 |
+
"""Compact, retrieval-focused representation of one section (head only)."""
|
| 50 |
# The section title is the strongest topical signal, so it is repeated to
|
| 51 |
# emphasise it. Title selection is doc_type-aware (see index.topical_title):
|
| 52 |
# a D-memo's marginal_note is a generic banner so its actual subject in
|
|
|
|
| 59 |
return " . ".join(p for p in parts if p)
|
| 60 |
|
| 61 |
|
| 62 |
+
def embed_texts(chunk):
|
| 63 |
+
"""One representation per embedding window of a chunk.
|
| 64 |
+
|
| 65 |
+
The first is the classic head representation; a chunk whose body exceeds
|
| 66 |
+
the embed window additionally gets tail windows (same topical-title
|
| 67 |
+
anchor, successive body slices), so a fact buried mid-memo is reachable
|
| 68 |
+
through semantic recall instead of BM25 alone. All windows share the
|
| 69 |
+
chunk's id -- the index scores a chunk by its best window."""
|
| 70 |
+
from .index import topical_title
|
| 71 |
+
texts = [embed_text(chunk)]
|
| 72 |
+
body = chunk["text"]
|
| 73 |
+
if len(body) > _MAX_BODY:
|
| 74 |
+
note = topical_title(chunk)
|
| 75 |
+
anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
|
| 76 |
+
for start in range(_MAX_BODY, len(body), _MAX_BODY):
|
| 77 |
+
if len(texts) >= _MAX_WINDOWS:
|
| 78 |
+
break
|
| 79 |
+
texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
|
| 80 |
+
return texts
|
| 81 |
+
|
| 82 |
+
|
| 83 |
class Embedder:
|
| 84 |
"""Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
|
| 85 |
|
|
|
|
| 149 |
if not chunks:
|
| 150 |
print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
|
| 151 |
return
|
| 152 |
+
texts, ids = [], []
|
| 153 |
+
for c in chunks:
|
| 154 |
+
for t in embed_texts(c):
|
| 155 |
+
texts.append(t)
|
| 156 |
+
ids.append(c["id"])
|
| 157 |
+
print(f"Embedding {len(chunks)} sections as {len(texts)} windows "
|
| 158 |
+
f"with {EMB_REPO} ...")
|
| 159 |
+
vectors = Embedder().encode(texts)
|
| 160 |
+
np.savez(EMB_PATH, ids=np.array(ids), vectors=vectors)
|
| 161 |
print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
|
| 162 |
|
| 163 |
|
canlex/index.py
CHANGED
|
@@ -135,6 +135,28 @@ def _stem(word):
|
|
| 135 |
return stemmed
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _fold_accents(text):
|
| 139 |
"""ASCII-fold accented characters so a French or mixed query tokenizes
|
| 140 |
usefully instead of shattering: the [a-z0-9] token pattern used to split
|
|
@@ -215,6 +237,9 @@ class LegislationIndex:
|
|
| 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()
|
|
@@ -299,20 +324,22 @@ class LegislationIndex:
|
|
| 299 |
try:
|
| 300 |
import numpy as np
|
| 301 |
from .embed import Embedder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
with np.load(emb_path) as data:
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
for c in self.chunks:
|
| 308 |
-
vec = id_to_vec.get(c["id"])
|
| 309 |
-
if vec is None:
|
| 310 |
-
missing += 1
|
| 311 |
-
rows.append(np.zeros(dim, dtype=np.float32))
|
| 312 |
-
else:
|
| 313 |
-
rows.append(vec)
|
| 314 |
self._np = np
|
| 315 |
-
self.vectors =
|
|
|
|
|
|
|
|
|
|
| 316 |
self.embedder = Embedder()
|
| 317 |
self.semantic = True
|
| 318 |
if missing:
|
|
@@ -389,17 +416,21 @@ class LegislationIndex:
|
|
| 389 |
return scores
|
| 390 |
|
| 391 |
def _semantic_ranking(self, query, allowed=None):
|
|
|
|
| 392 |
qv = self.embedder.encode_query(query)
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
if allowed is not None:
|
| 395 |
# Mask out-of-scope chunks so a filtered search ranks (and reports
|
| 396 |
# confidence) within its own scope, not corpus-wide.
|
| 397 |
-
sims =
|
| 398 |
-
self._np.asarray(allowed, dtype=bool), sims, -1.0)
|
| 399 |
# Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
|
| 400 |
# surplus chunks of any one deep source, and the extra depth is what
|
| 401 |
# lets other sources backfill the freed CANDIDATES slots.
|
| 402 |
-
order =
|
| 403 |
# The top cosine similarity doubles as a corpus-coverage signal: a query
|
| 404 |
# the corpus cannot answer has no passage close to it.
|
| 405 |
return [int(i) for i in order], float(sims.max())
|
|
@@ -591,11 +622,14 @@ class LegislationIndex:
|
|
| 591 |
return {pos: (label, " ".join(snippet[:240].split()))
|
| 592 |
for pos, (score, label, snippet) in best.items()}
|
| 593 |
|
| 594 |
-
def _filter_ok(self, c, act, doc_type):
|
| 595 |
"""One predicate for both recall masking and the late result filter.
|
| 596 |
The act filter matches short name, code, or full name -- an agent
|
| 597 |
passing 'Immigration and Refugee Protection Act' should not get
|
| 598 |
-
silence.
|
|
|
|
|
|
|
|
|
|
| 599 |
if act:
|
| 600 |
a = act.lower()
|
| 601 |
if a not in (c["act_short"].lower(), c["act_code"].lower(),
|
|
@@ -610,13 +644,21 @@ class LegislationIndex:
|
|
| 610 |
# Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
|
| 611 |
# the recall stages; the reranker sees the original AND expanded forms.
|
| 612 |
expanded = expand_query(query)
|
|
|
|
| 613 |
confidence = None
|
| 614 |
fused = defaultdict(float)
|
| 615 |
# A filtered search competes only within its scope: the mask reaches
|
| 616 |
# both recall stages, so results come from the best of the whole
|
| 617 |
-
# filtered corpus rather than whatever survived open recall.
|
| 618 |
-
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
bm25 = self._bm25_scores(expanded, allowed=mask)
|
| 621 |
for rank, idx in enumerate(self._capped_top(
|
| 622 |
sorted(bm25, key=bm25.get, reverse=True))):
|
|
@@ -684,7 +726,7 @@ class LegislationIndex:
|
|
| 684 |
fused[idx] -= BACKMATTER_PENALTY
|
| 685 |
|
| 686 |
candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
|
| 687 |
-
if self._filter_ok(self.chunks[i], act, doc_type)]
|
| 688 |
if not candidates:
|
| 689 |
return []
|
| 690 |
scores = {i: fused[i] for i in candidates}
|
|
@@ -748,14 +790,19 @@ class LegislationIndex:
|
|
| 748 |
results.append(result)
|
| 749 |
return results
|
| 750 |
|
| 751 |
-
def get_section(self, act, section):
|
|
|
|
|
|
|
| 752 |
act = act.lower()
|
|
|
|
| 753 |
for c in self.chunks:
|
| 754 |
if c["section"] == section and act in (
|
| 755 |
c["act_short"].lower(), c["act_code"].lower(),
|
| 756 |
c.get("act_name", "").lower()):
|
| 757 |
-
|
| 758 |
-
|
|
|
|
|
|
|
| 759 |
|
| 760 |
def _build_xref(self):
|
| 761 |
"""Index legislation by (act, section); find each Act's definitions
|
|
@@ -767,6 +814,8 @@ class LegislationIndex:
|
|
| 767 |
for c in self.chunks:
|
| 768 |
if c.get("doc_type", "legislation") != "legislation":
|
| 769 |
continue
|
|
|
|
|
|
|
| 770 |
self._by_section[(c["act_code"], c["section"])] = c
|
| 771 |
if c["act_code"] not in self._defs_section and (
|
| 772 |
c["marginal_note"].strip().lower() in (
|
|
|
|
| 135 |
return stemmed
|
| 136 |
|
| 137 |
|
| 138 |
+
# Query-language detection for the bilingual corpus: accented characters or
|
| 139 |
+
# two-plus French function words mark a French query. French queries may draw
|
| 140 |
+
# on BOTH languages (guidance and case law exist only in English); English
|
| 141 |
+
# queries never surface French chunks (the English corpus is complete, and a
|
| 142 |
+
# French twin would only duplicate results).
|
| 143 |
+
_FR_HINT = re.compile(r"[àâçéèêëîïôùûüÿœæ]", re.IGNORECASE)
|
| 144 |
+
# ASCII-only entries: tokens are matched pre-folding with [a-zA-Z']+, and any
|
| 145 |
+
# accented query already short-circuits through _FR_HINT above.
|
| 146 |
+
_FR_WORDS = frozenset(
|
| 147 |
+
"le la les de des une du et est dans pour que qui aux au sur par ne pas "
|
| 148 |
+
"peut doit selon lors quelle quel cette ces son ses leur d'un d'une "
|
| 149 |
+
"interdiction fouille saisie renvoi territoire".split())
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def query_lang(query):
|
| 153 |
+
"""'fr' when the query reads as French, else 'en'."""
|
| 154 |
+
if _FR_HINT.search(query):
|
| 155 |
+
return "fr"
|
| 156 |
+
tokens = re.findall(r"[a-zA-Z']+", query.lower())
|
| 157 |
+
return "fr" if sum(1 for t in tokens if t in _FR_WORDS) >= 2 else "en"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
def _fold_accents(text):
|
| 161 |
"""ASCII-fold accented characters so a French or mixed query tokenizes
|
| 162 |
usefully instead of shattering: the [a-z0-9] token pattern used to split
|
|
|
|
| 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]
|
| 243 |
self._build_bm25()
|
| 244 |
self._build_note_tokens()
|
| 245 |
self._build_xref()
|
|
|
|
| 324 |
try:
|
| 325 |
import numpy as np
|
| 326 |
from .embed import Embedder
|
| 327 |
+
# Multi-vector layout: rows share a chunk id when the chunk was
|
| 328 |
+
# embedded as several windows (long D-memo sections etc.); a
|
| 329 |
+
# chunk's semantic score is the max over its windows. Rows whose
|
| 330 |
+
# id matches no loaded chunk are dropped; chunks with no rows
|
| 331 |
+
# count as missing (BM25-only for them).
|
| 332 |
+
chunk_ordinal = {c["id"]: i for i, c in enumerate(self.chunks)}
|
| 333 |
with np.load(emb_path) as data:
|
| 334 |
+
all_ids = data["ids"].tolist()
|
| 335 |
+
all_vecs = data["vectors"]
|
| 336 |
+
keep = [(chunk_ordinal[i], r) for r, i in enumerate(all_ids)
|
| 337 |
+
if i in chunk_ordinal]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
self._np = np
|
| 339 |
+
self.vectors = all_vecs[[r for _, r in keep]]
|
| 340 |
+
self._row_chunk = np.array([o for o, _ in keep], dtype=np.int64)
|
| 341 |
+
covered = set(self._row_chunk.tolist())
|
| 342 |
+
missing = len(self.chunks) - len(covered)
|
| 343 |
self.embedder = Embedder()
|
| 344 |
self.semantic = True
|
| 345 |
if missing:
|
|
|
|
| 416 |
return scores
|
| 417 |
|
| 418 |
def _semantic_ranking(self, query, allowed=None):
|
| 419 |
+
np = self._np
|
| 420 |
qv = self.embedder.encode_query(query)
|
| 421 |
+
row_sims = self.vectors @ qv
|
| 422 |
+
# Aggregate windows to chunks by max: a long section scores as its
|
| 423 |
+
# best-matching window, so a fact buried mid-memo is reachable.
|
| 424 |
+
sims = np.full(len(self.chunks), -1.0, dtype=np.float32)
|
| 425 |
+
np.maximum.at(sims, self._row_chunk, row_sims)
|
| 426 |
if allowed is not None:
|
| 427 |
# Mask out-of-scope chunks so a filtered search ranks (and reports
|
| 428 |
# confidence) within its own scope, not corpus-wide.
|
| 429 |
+
sims = np.where(np.asarray(allowed, dtype=bool), sims, -1.0)
|
|
|
|
| 430 |
# Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
|
| 431 |
# surplus chunks of any one deep source, and the extra depth is what
|
| 432 |
# lets other sources backfill the freed CANDIDATES slots.
|
| 433 |
+
order = np.argsort(sims)[::-1][:CANDIDATES * 4]
|
| 434 |
# The top cosine similarity doubles as a corpus-coverage signal: a query
|
| 435 |
# the corpus cannot answer has no passage close to it.
|
| 436 |
return [int(i) for i in order], float(sims.max())
|
|
|
|
| 622 |
return {pos: (label, " ".join(snippet[:240].split()))
|
| 623 |
for pos, (score, label, snippet) in best.items()}
|
| 624 |
|
| 625 |
+
def _filter_ok(self, c, act, doc_type, qlang="en"):
|
| 626 |
"""One predicate for both recall masking and the late result filter.
|
| 627 |
The act filter matches short name, code, or full name -- an agent
|
| 628 |
passing 'Immigration and Refugee Protection Act' should not get
|
| 629 |
+
silence. English queries never see French chunks; French queries see
|
| 630 |
+
both languages (guidance/case law exist only in English)."""
|
| 631 |
+
if qlang == "en" and c.get("lang", "en") == "fr":
|
| 632 |
+
return False
|
| 633 |
if act:
|
| 634 |
a = act.lower()
|
| 635 |
if a not in (c["act_short"].lower(), c["act_code"].lower(),
|
|
|
|
| 644 |
# Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
|
| 645 |
# the recall stages; the reranker sees the original AND expanded forms.
|
| 646 |
expanded = expand_query(query)
|
| 647 |
+
qlang = query_lang(query)
|
| 648 |
confidence = None
|
| 649 |
fused = defaultdict(float)
|
| 650 |
# A filtered search competes only within its scope: the mask reaches
|
| 651 |
# both recall stages, so results come from the best of the whole
|
| 652 |
+
# filtered corpus rather than whatever survived open recall. An
|
| 653 |
+
# unfiltered English query uses the cached English-only mask; a
|
| 654 |
+
# French query searches both languages unmasked.
|
| 655 |
+
if act or doc_type:
|
| 656 |
+
mask = [self._filter_ok(c, act, doc_type, qlang)
|
| 657 |
+
for c in self.chunks]
|
| 658 |
+
elif qlang == "en":
|
| 659 |
+
mask = self._en_mask
|
| 660 |
+
else:
|
| 661 |
+
mask = None
|
| 662 |
bm25 = self._bm25_scores(expanded, allowed=mask)
|
| 663 |
for rank, idx in enumerate(self._capped_top(
|
| 664 |
sorted(bm25, key=bm25.get, reverse=True))):
|
|
|
|
| 726 |
fused[idx] -= BACKMATTER_PENALTY
|
| 727 |
|
| 728 |
candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
|
| 729 |
+
if self._filter_ok(self.chunks[i], act, doc_type, qlang)]
|
| 730 |
if not candidates:
|
| 731 |
return []
|
| 732 |
scores = {i: fused[i] for i in candidates}
|
|
|
|
| 790 |
results.append(result)
|
| 791 |
return results
|
| 792 |
|
| 793 |
+
def get_section(self, act, section, lang="en"):
|
| 794 |
+
"""The section chunk in the requested language, falling back to the
|
| 795 |
+
other language rather than returning nothing."""
|
| 796 |
act = act.lower()
|
| 797 |
+
fallback = None
|
| 798 |
for c in self.chunks:
|
| 799 |
if c["section"] == section and act in (
|
| 800 |
c["act_short"].lower(), c["act_code"].lower(),
|
| 801 |
c.get("act_name", "").lower()):
|
| 802 |
+
if c.get("lang", "en") == lang:
|
| 803 |
+
return c
|
| 804 |
+
fallback = fallback or c
|
| 805 |
+
return fallback
|
| 806 |
|
| 807 |
def _build_xref(self):
|
| 808 |
"""Index legislation by (act, section); find each Act's definitions
|
|
|
|
| 814 |
for c in self.chunks:
|
| 815 |
if c.get("doc_type", "legislation") != "legislation":
|
| 816 |
continue
|
| 817 |
+
if c.get("lang", "en") == "fr":
|
| 818 |
+
continue # xref keys are per (act, section); English canonical
|
| 819 |
self._by_section[(c["act_code"], c["section"])] = c
|
| 820 |
if c["act_code"] not in self._defs_section and (
|
| 821 |
c["marginal_note"].strip().lower() in (
|
canlex/ingest.py
CHANGED
|
@@ -399,8 +399,58 @@ def ingest(code, force=False):
|
|
| 399 |
return chunks
|
| 400 |
|
| 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
def main():
|
| 403 |
force = "--force" in sys.argv
|
|
|
|
| 404 |
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 405 |
codes = only or list(SOURCES)
|
| 406 |
failures = []
|
|
@@ -409,7 +459,11 @@ def main():
|
|
| 409 |
print(f" SKIP {code}: not a known source")
|
| 410 |
continue
|
| 411 |
try:
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
except Exception as exc:
|
| 414 |
failures.append((code, exc))
|
| 415 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
|
|
|
| 399 |
return chunks
|
| 400 |
|
| 401 |
|
| 402 |
+
# --- French corpus (phase 3 of bilingual support) ----------------------------
|
| 403 |
+
# Justice Laws publishes every consolidation as parallel French XML in the
|
| 404 |
+
# SAME schema (English element names, French text, identical section
|
| 405 |
+
# numbering), so the whole parser -- schedules included -- works unchanged.
|
| 406 |
+
# French chunks carry lang='fr', a '-fr-' id infix, French citation style
|
| 407 |
+
# ('art.' / 'annexe') and the French web URL; retrieval filters by query
|
| 408 |
+
# language (index.py), and get_section/xref/list_acts stay English-first.
|
| 409 |
+
|
| 410 |
+
def _fr_xml_url(src):
|
| 411 |
+
return (src["xml_url"]
|
| 412 |
+
.replace("/eng/XML/", "/fra/XML/")
|
| 413 |
+
.replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
|
| 414 |
+
.replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _frenchify(chunks, code):
|
| 418 |
+
out = []
|
| 419 |
+
for c in chunks:
|
| 420 |
+
c = dict(c)
|
| 421 |
+
c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
|
| 422 |
+
c["lang"] = "fr"
|
| 423 |
+
c["citation"] = (c["citation"]
|
| 424 |
+
.replace(", s. ", ", art. ")
|
| 425 |
+
.replace(", Schedule", ", annexe"))
|
| 426 |
+
c["source_url"] = (c["source_url"]
|
| 427 |
+
.replace("/eng/acts/", "/fra/lois/")
|
| 428 |
+
.replace("/eng/regulations/", "/fra/reglements/"))
|
| 429 |
+
out.append(c)
|
| 430 |
+
return out
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def ingest_fr(code, force=False):
|
| 434 |
+
src = SOURCES[code]
|
| 435 |
+
dest = RAW_DIR / f"{code}-fr.xml"
|
| 436 |
+
if not dest.exists() or force:
|
| 437 |
+
url = _fr_xml_url(src)
|
| 438 |
+
print(f" downloading {url}")
|
| 439 |
+
req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
|
| 440 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 441 |
+
dest.write_bytes(resp.read())
|
| 442 |
+
time.sleep(1.0)
|
| 443 |
+
chunks = _frenchify(parse_legislation(dest, code), code)
|
| 444 |
+
out = PROCESSED_DIR / f"{code}-fr.json"
|
| 445 |
+
out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
|
| 446 |
+
encoding="utf-8")
|
| 447 |
+
print(f" {len(chunks)} articles -> {out.name}")
|
| 448 |
+
return chunks
|
| 449 |
+
|
| 450 |
+
|
| 451 |
def main():
|
| 452 |
force = "--force" in sys.argv
|
| 453 |
+
french = "--fr" in sys.argv
|
| 454 |
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 455 |
codes = only or list(SOURCES)
|
| 456 |
failures = []
|
|
|
|
| 459 |
print(f" SKIP {code}: not a known source")
|
| 460 |
continue
|
| 461 |
try:
|
| 462 |
+
if french:
|
| 463 |
+
print(f"Ingesting {code} (français)...")
|
| 464 |
+
ingest_fr(code, force=force)
|
| 465 |
+
else:
|
| 466 |
+
ingest(code, force=force)
|
| 467 |
except Exception as exc:
|
| 468 |
failures.append((code, exc))
|
| 469 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
canlex/rerank.py
CHANGED
|
@@ -10,7 +10,11 @@ from tokenizers import Tokenizer
|
|
| 10 |
RERANK_REPO = "Xenova/bge-reranker-base"
|
| 11 |
RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
|
| 12 |
MAX_TOKENS = 512
|
| 13 |
-
_MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# cap doc text before tokenizing. Cross-encoder cost is
|
| 15 |
# ~linear in tokens per doc, so halving this ~halves the
|
| 16 |
# dominant query cost; env-sweepable, eval-gated (a
|
|
|
|
| 10 |
RERANK_REPO = "Xenova/bge-reranker-base"
|
| 11 |
RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
|
| 12 |
MAX_TOKENS = 512
|
| 13 |
+
_MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "2000"))
|
| 14 |
+
# 1000 -> 2000 adopted 2026-07-23: the doubled reading
|
| 15 |
+
# window held every 203-Q eval metric and lifted Hit@5
|
| 16 |
+
# +0.01; the cross-encoder now judges a full-size piece
|
| 17 |
+
# rather than 60% of one.
|
| 18 |
# cap doc text before tokenizing. Cross-encoder cost is
|
| 19 |
# ~linear in tokens per doc, so halving this ~halves the
|
| 20 |
# dominant query cost; env-sweepable, eval-gated (a
|
canlex/synonyms.py
CHANGED
|
@@ -77,6 +77,9 @@ _SYNONYMS = [
|
|
| 77 |
(r"criminalit[ée]", "criminality convicted offence"),
|
| 78 |
(r"renvoi", "removal order"),
|
| 79 |
(r"saisie?s?", "seizure seized goods"),
|
|
|
|
|
|
|
|
|
|
| 80 |
(r"r[ée]sidents? permanents?", "permanent resident"),
|
| 81 |
(r"[ée]trangers?", "foreign national"),
|
| 82 |
(r"d[ée]tention", "detention detained review"),
|
|
|
|
| 77 |
(r"criminalit[ée]", "criminality convicted offence"),
|
| 78 |
(r"renvoi", "removal order"),
|
| 79 |
(r"saisie?s?", "seizure seized goods"),
|
| 80 |
+
# 'devises' is the everyday word for currency; the PCMLTFA's French text
|
| 81 |
+
# says 'espèces et effets' -- bridge to both statutory vocabularies.
|
| 82 |
+
(r"devises", "currency monetary instruments espèces effets"),
|
| 83 |
(r"r[ée]sidents? permanents?", "permanent resident"),
|
| 84 |
(r"[ée]trangers?", "foreign national"),
|
| 85 |
(r"d[ée]tention", "detention detained review"),
|