Beemer Claude Opus 4.7 commited on
Commit
55484bc
·
1 Parent(s): 712f1cd

Add the public-service benefit-plan member booklets (new doc_type 'benefits')

Browse files

Ingests the member booklets of the four federal benefit plans as a new
doc_type='benefits', via canlex/benefits.py:

- PSHCP -- Public Service Health Care Plan (health; active + pensioners)
- PSDCP -- Public Service Dental Care Plan (dental; active employees)
- PDSP -- Pensioners' Dental Services Plan (dental; pensioners)
- PSMIP -- Public Service Management Insurance Plan (life/AD&D/disability;
management + excluded employees)

The three Canada Life booklets are PDFs (fetched via urllib, segmented by
locating each table-of-contents heading in the body text); PSMIP is an HTML
page on canada.ca (fetched via PowerShell like agreement.py, since canada.ca
blocks urllib at the TLS layer, segmented at its h2/h3 headings). One chunk per
booklet section, sub-split at ~1800 chars with tiny head/tail remnants folded
in. 310 chunks total (PSHCP 137, PSDCP 71, PDSP 68, PSMIP 34); corpus 16,194 ->
16,504 chunks.

Booklets are guidance, not governing instruments: server.py labels each result
"_Benefit-plan member booklet ... the plan rules govern_" and the corpus
listing gains a "Benefit plans" group. They are NOT in PRIMARY_DOC_TYPES (so
_ensure_primary doesn't treat them as enacted law), but _source_key returns
None for them so a booklet's distinct sections are never collapsed by the
diversity cap (like statute sections). doc_type filter and tool docs updated to
list 'benefits'.

Why PSMIP despite the FB/BSO focus: it covers EX and excluded CBSA staff
(represented FB officers carry the separate DI plan) -- added on request for
completeness.

145-question eval (4 new benefit-plan gold questions, all pass): Hit@1 0.80 /
Hit@3 0.94 / Hit@5 0.97 / Hit@10 0.99 / MRR 0.87. The one-question Hit@5 dip vs
pre-add (D8-2-1 #5->#6) is a benefits "Out-of-Canada Benefit" section lightly
competing on a customs goods-abroad query -- acceptable for the added coverage.
9 new unit tests (TOC parse, segmentation, splitter head/tail fold, emit,
benefits source-key); 40 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

canlex/benefits.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest the member booklets of the federal public-service benefit plans.
2
+
3
+ Four plans cover federal employees (and a Border Services Officer over a
4
+ career), by population:
5
+
6
+ - PSHCP -- Public Service Health Care Plan (health; active + pensioners)
7
+ - PSDCP -- Public Service Dental Care Plan (dental; active employees)
8
+ - PDSP -- Pensioners' Dental Services Plan (dental; pensioners)
9
+ - PSMIP -- Public Service Management Insurance Plan (life / AD&D / disability
10
+ insurance; management-category and excluded employees -- so EX and
11
+ excluded CBSA staff rather than the represented FB group, who carry
12
+ the separate Disability Insurance Plan)
13
+
14
+ Each plan's *member booklet* is the plain-language guide the plan administrator
15
+ or Treasury Board publishes -- what is covered, who is eligible, how to claim,
16
+ the appeals process. The booklets are guidance: the governing terms live in the
17
+ PSHCP Directive (already ingested as doc_type='directive') and the respective
18
+ plan rules, which prevail in any discrepancy. They are tagged doc_type=
19
+ 'benefits' and labelled as such in results.
20
+
21
+ Sources: the Canada Life PDFs (welcome.canadalife.com / canadalife.com), which
22
+ -- unlike canada.ca -- serve Python's urllib fine; and the PSMIP booklet, an
23
+ HTML page on canada.ca (which blocks urllib at the TLS layer, so it is fetched
24
+ via PowerShell, as agreement.py does). A PDF booklet carries a table of
25
+ contents whose headings are located in the body to segment it; the HTML booklet
26
+ is segmented at its h2/h3 headings. Either way a retrieved chunk is a coherent
27
+ benefit topic.
28
+
29
+ py -m canlex.benefits
30
+ """
31
+ import io
32
+ import json
33
+ import re
34
+ import subprocess
35
+ import time
36
+ import urllib.request
37
+
38
+ from bs4 import BeautifulSoup
39
+ from pypdf import PdfReader
40
+
41
+ from .config import PROCESSED_DIR, RAW_DIR
42
+
43
+ RAW = RAW_DIR / "benefits"
44
+ OUT = PROCESSED_DIR / "benefits.json"
45
+
46
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
47
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
48
+
49
+ _CHUNK_CHARS = 1800 # target characters per chunk
50
+
51
+ # Each plan: its booklet, and how to read it. PDF booklets give the page
52
+ # indices of the table of contents and the first body page (everything before
53
+ # it is cover + TOC). The HTML booklet is parsed at its h2/h3 headings.
54
+ # 'edition' is the booklet's own date (for HTML it is read from the page's
55
+ # "Date modified"); bump a PDF edition and re-run when a new booklet is posted.
56
+ SOURCES = {
57
+ "PSHCP": {
58
+ "code": "PSHCP", "format": "pdf",
59
+ "name": "Public Service Health Care Plan",
60
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
61
+ "welcome-sites/pshcp/PSHCP-member-booklet.pdf"),
62
+ "label": "Member Booklet",
63
+ "toc_pages": [2, 3, 4],
64
+ "body_start": 5,
65
+ "edition": "June 2024",
66
+ },
67
+ "PSDCP": {
68
+ "code": "PSDCP", "format": "pdf",
69
+ "name": "Public Service Dental Care Plan",
70
+ "url": ("https://www.welcome.canadalife.com/content/dam/rfp/"
71
+ "welcome-sites/psdcp/psdcp-member-booklet.pdf"),
72
+ "label": "Member Booklet",
73
+ "toc_pages": [2, 3],
74
+ "body_start": 4,
75
+ "edition": "May 2026",
76
+ },
77
+ "PDSP": {
78
+ "code": "PDSP", "format": "pdf",
79
+ "name": "Pensioners' Dental Services Plan",
80
+ "url": ("https://www.canadalife.com/content/dam/rfp/welcome-sites/"
81
+ "pdsp/pdsp-member-booklet.pdf"),
82
+ "label": "Member Booklet",
83
+ "toc_pages": [2, 3],
84
+ "body_start": 4,
85
+ "edition": "November 2024",
86
+ },
87
+ "PSMIP": {
88
+ "code": "PSMIP", "format": "html",
89
+ "name": "Public Service Management Insurance Plan",
90
+ "url": ("https://www.canada.ca/en/treasury-board-secretariat/services/"
91
+ "benefit-plans/management-insurance-plan/main-plan-booklet.html"),
92
+ "label": "Main Plan Booklet",
93
+ "edition": "", # read from the page's "Date modified"
94
+ },
95
+ }
96
+
97
+ # canada.ca section labels that are navigation/footer, not plan content.
98
+ _SKIP_HEADINGS = {"on this page", "page details", "from:", "table of contents"}
99
+
100
+ # A table-of-contents line: optional dot-leaders, the heading text, then the
101
+ # printed page number. The heading must end in a letter or ')' so a bare page
102
+ # number or a dot run alone is not mistaken for a heading.
103
+ _TOC_LINE = re.compile(r"^[\s.]*(.*?[A-Za-z)])\s*\.*\s*(\d{1,3})\s*$")
104
+
105
+
106
+ def _fetch(url, dest):
107
+ if dest.exists():
108
+ return dest.read_bytes()
109
+ dest.parent.mkdir(parents=True, exist_ok=True)
110
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
111
+ with urllib.request.urlopen(req, timeout=90) as resp:
112
+ dest.write_bytes(resp.read())
113
+ return dest.read_bytes()
114
+
115
+
116
+ def _clean(text):
117
+ """Tidy extracted PDF text: turn bullets into dashes, collapse runs of
118
+ spaces, and trim excess blank lines. Apostrophes, accents and en-dashes
119
+ extract as proper Unicode, so no transliteration is needed."""
120
+ text = text.replace("•", "\n- ").replace("\xa0", " ")
121
+ text = re.sub(r"[ \t]+", " ", text)
122
+ text = re.sub(r"\n[ \t]+", "\n", text)
123
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
124
+
125
+
126
+ def _page_texts(pdf_bytes):
127
+ reader = PdfReader(io.BytesIO(pdf_bytes))
128
+ return [p.extract_text() or "" for p in reader.pages]
129
+
130
+
131
+ def parse_toc(page_texts, toc_pages):
132
+ """Return the ordered, de-duplicated list of section headings from the
133
+ table of contents."""
134
+ seen, headings = set(), []
135
+ for i in toc_pages:
136
+ if i >= len(page_texts):
137
+ continue
138
+ for line in page_texts[i].split("\n"):
139
+ line = line.strip()
140
+ if not line or "Table of Contents" in line:
141
+ continue
142
+ m = _TOC_LINE.match(line)
143
+ if not m:
144
+ continue
145
+ title = re.sub(r"\s+", " ", m.group(1)).strip(" .")
146
+ if len(title) > 2 and title.lower() not in seen:
147
+ seen.add(title.lower())
148
+ headings.append(title)
149
+ return headings
150
+
151
+
152
+ def segment(body, headings):
153
+ """Split the body text at each heading, in order, returning a list of
154
+ (heading, section_text). A heading the extractor split across lines is
155
+ matched with flexible whitespace; a heading not found in the body is
156
+ skipped (its text folds into the preceding section)."""
157
+ cuts = []
158
+ pos = 0
159
+ for h in headings:
160
+ pattern = re.compile(r"\s*".join(re.escape(w) for w in h.split()), re.I)
161
+ m = pattern.search(body, pos)
162
+ if m:
163
+ cuts.append((h, m.start()))
164
+ pos = m.end()
165
+ sections = []
166
+ for j, (h, start) in enumerate(cuts):
167
+ end = cuts[j + 1][1] if j + 1 < len(cuts) else len(body)
168
+ sections.append((h, body[start:end].strip()))
169
+ return sections
170
+
171
+
172
+ def _split(text, target=_CHUNK_CHARS):
173
+ """Split a long section into ~target-sized pieces at line boundaries,
174
+ folding a small trailing remnant back into the previous piece so a section
175
+ never ends in a stub chunk."""
176
+ if len(text) <= target:
177
+ return [text]
178
+ pieces, buf, size = [], [], 0
179
+ for line in text.split("\n"):
180
+ if size + len(line) > target and buf:
181
+ pieces.append("\n".join(buf))
182
+ buf, size = [], 0
183
+ buf.append(line)
184
+ size += len(line) + 1
185
+ if buf:
186
+ pieces.append("\n".join(buf))
187
+ # Fold a small trailing remnant back into the previous piece, and a small
188
+ # leading remnant (e.g. a heading on its own line ahead of one very long
189
+ # unbroken paragraph) into the next, so a section never yields a stub chunk.
190
+ if len(pieces) > 1 and len(pieces[-1]) < 200:
191
+ pieces[-2] = pieces[-2] + "\n" + pieces[-1]
192
+ pieces.pop()
193
+ if len(pieces) > 1 and len(pieces[0]) < 200:
194
+ pieces[1] = pieces[0] + "\n" + pieces[1]
195
+ pieces.pop(0)
196
+ return pieces
197
+
198
+
199
+ def _slug(heading, used):
200
+ """A short, unique, citable section locator from a heading."""
201
+ base = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "section"
202
+ slug, n = base, 2
203
+ while slug in used:
204
+ slug = f"{base}-{n}"
205
+ n += 1
206
+ used.add(slug)
207
+ return slug
208
+
209
+
210
+ def _emit(sections, src):
211
+ """Build CanLex chunk dicts from a list of (heading, section_text), shared
212
+ by the PDF and HTML paths. Skips pure section-divider headings and
213
+ sub-splits any section over the chunk budget."""
214
+ chunks = []
215
+ used_slugs = set()
216
+ label = src["label"]
217
+ for heading, text in sections:
218
+ # Skip pure section dividers -- a heading whose only content is the
219
+ # heading itself (its substance lives in the child sections below it).
220
+ # Compare with whitespace collapsed, since a PDF extractor often breaks
221
+ # a heading across lines ("...Plan \n(PSDCP)").
222
+ norm_text = re.sub(r"\s+", " ", text).strip()
223
+ norm_head = re.sub(r"\s+", " ", heading).strip()
224
+ remainder = (norm_text[len(norm_head):]
225
+ if norm_text.lower().startswith(norm_head.lower())
226
+ else norm_text)
227
+ if len(remainder.strip()) < 40:
228
+ continue
229
+ slug = _slug(heading, used_slugs)
230
+ pieces = _split(text)
231
+ for k, piece in enumerate(pieces, start=1):
232
+ section = slug if len(pieces) == 1 else f"{slug}-{k}"
233
+ note = heading if len(pieces) == 1 else f"{heading} (part {k})"
234
+ chunks.append({
235
+ "id": f"benefits-{src['code']}-{section}",
236
+ "doc_type": "benefits",
237
+ "act_code": src["code"],
238
+ "act_short": src["code"],
239
+ "act_name": f"{src['name']} — {label}",
240
+ "section": section,
241
+ "marginal_note": note,
242
+ "part": f"{src['name']} {label}",
243
+ "division": "",
244
+ "heading": "",
245
+ "text": piece,
246
+ "history": "",
247
+ "last_amended": "",
248
+ "current_to": src["edition"],
249
+ "citation": (f"{src['code']} {label}"
250
+ + (f" ({src['edition']})" if src["edition"] else "")
251
+ + f", “{heading}”"),
252
+ "source_url": src["url"],
253
+ })
254
+ return chunks
255
+
256
+
257
+ def parse_pdf_booklet(pdf_bytes, src):
258
+ """Segment a PDF booklet by its table-of-contents headings."""
259
+ pages = _page_texts(pdf_bytes)
260
+ headings = parse_toc(pages, src["toc_pages"])
261
+ body = _clean("\n".join(pages[src["body_start"]:]))
262
+ return _emit(segment(body, headings), src)
263
+
264
+
265
+ def _html_block_text(heading):
266
+ """Readable text from an h2/h3 up to the next heading of the same or higher
267
+ level; lists become dash items."""
268
+ lines = []
269
+ for sib in heading.find_next_siblings():
270
+ if sib.name in ("h2", "h3"):
271
+ break
272
+ if sib.name in ("ul", "ol"):
273
+ for li in sib.find_all("li", recursive=False):
274
+ item = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip()
275
+ if item:
276
+ lines.append(f"- {item}")
277
+ elif sib.name in ("p", "div", "table", "section", "h4", "h5"):
278
+ text = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)).strip()
279
+ if text:
280
+ lines.append(text)
281
+ return "\n".join(lines)
282
+
283
+
284
+ def parse_html_booklet(html, src):
285
+ """Segment the canada.ca HTML booklet at its h2/h3 headings."""
286
+ soup = BeautifulSoup(html, "html.parser")
287
+ main = soup.find("main") or soup
288
+ # Read the page's "Date modified" so the booklet's currency is recorded.
289
+ if not src["edition"]:
290
+ t = main.find("time", property="dateModified") or soup.find(
291
+ "time", property="dateModified")
292
+ if t:
293
+ src = {**src, "edition": t.get_text(strip=True)}
294
+ sections = []
295
+ for h in main.find_all(["h2", "h3"]):
296
+ title = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip()
297
+ if not title or title.lower() in _SKIP_HEADINGS:
298
+ continue
299
+ body = _html_block_text(h)
300
+ if body:
301
+ sections.append((title, f"{title}\n{body}"))
302
+ return _emit(sections, src)
303
+
304
+
305
+ def _fetch_html(url, dest):
306
+ """Fetch a canada.ca page via PowerShell -- the site blocks Python's HTTP
307
+ client at the TLS layer, but accepts PowerShell's .NET stack (as
308
+ agreement.py does)."""
309
+ if dest.exists():
310
+ return dest.read_bytes()
311
+ dest.parent.mkdir(parents=True, exist_ok=True)
312
+ command = (f"Invoke-WebRequest -Uri '{url}' -OutFile '{dest}' "
313
+ f"-UseBasicParsing -UserAgent '{_UA}'")
314
+ subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
315
+ "-Command", command],
316
+ check=True, capture_output=True, timeout=180)
317
+ time.sleep(0.5)
318
+ return dest.read_bytes()
319
+
320
+
321
+ def build():
322
+ all_chunks = []
323
+ for src in SOURCES.values():
324
+ print(f"Ingesting {src['code']} {src['label'].lower()} ...")
325
+ try:
326
+ if src["format"] == "pdf":
327
+ pdf = _fetch(src["url"], RAW / f"{src['code']}.pdf")
328
+ chunks = parse_pdf_booklet(pdf, src)
329
+ else:
330
+ html = _fetch_html(src["url"], RAW / f"{src['code']}.html"
331
+ ).decode("utf-8", "replace")
332
+ chunks = parse_html_booklet(html, src)
333
+ except Exception as exc:
334
+ print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
335
+ continue
336
+ all_chunks.extend(chunks)
337
+ print(f" {len(chunks)} chunks")
338
+ PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
339
+ OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
340
+ encoding="utf-8")
341
+ print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
342
+ f"booklet(s) -> {OUT}")
343
+
344
+
345
+ if __name__ == "__main__":
346
+ build()
canlex/index.py CHANGED
@@ -309,10 +309,18 @@ class LegislationIndex:
309
  """The parent document a chunk belongs to, for diversity capping. Returns
310
  None for primary instruments -- legislation, collective agreements and
311
  directives -- whose sections are distinct provisions and are never
312
- capped; case law is keyed by citation, memoranda by memo number."""
 
 
 
 
 
 
 
 
313
  c = self.chunks[idx]
314
  doc_type = c.get("doc_type", "legislation")
315
- if doc_type in PRIMARY_DOC_TYPES:
316
  return None
317
  if doc_type == "memorandum":
318
  return ("memorandum", c["section"]) # act_code is a shared constant
 
309
  """The parent document a chunk belongs to, for diversity capping. Returns
310
  None for primary instruments -- legislation, collective agreements and
311
  directives -- whose sections are distinct provisions and are never
312
+ capped; case law is keyed by citation, memoranda by memo number.
313
+
314
+ Benefit-plan booklets also return None: a booklet's sections are
315
+ distinct topics (eligibility, coverage, claims, appeals...) like
316
+ statute sections, so capping the whole booklet to SOURCE_CAP chunks
317
+ would starve a detailed benefits query. They are not in
318
+ PRIMARY_DOC_TYPES, though -- they are guidance, not governing
319
+ instruments, so _ensure_primary does not pull them in as if they were
320
+ enacted law."""
321
  c = self.chunks[idx]
322
  doc_type = c.get("doc_type", "legislation")
323
+ if doc_type in PRIMARY_DOC_TYPES or doc_type == "benefits":
324
  return None
325
  if doc_type == "memorandum":
326
  return ("memorandum", c["section"]) # act_code is a shared constant
canlex/server.py CHANGED
@@ -116,6 +116,13 @@ def _format_section(c: dict, related=None) -> str:
116
  "designated for functions, under IRPA and the IRPR. "
117
  "Administrative; confirm it is still the current version._")
118
  lines.append(f"(dated {c['current_to'] or 'n/a'})")
 
 
 
 
 
 
 
119
  else:
120
  meta = [f"in force; text current to {c['current_to'] or 'n/a'}"]
121
  if c["last_amended"]:
@@ -183,8 +190,9 @@ class SearchInput(BaseModel):
183
  description="Optional filter by source type: 'legislation' (Acts and "
184
  "regulations), 'memorandum' (CBSA D-Memoranda), 'agreement' (collective "
185
  "agreements), 'directive' (NJC directives), 'caselaw' (court and "
186
- "tribunal decisions), or 'delegation' (IRPA/IRPR delegation and "
187
- "designation instruments). Omit to search all.",
 
188
  )
189
 
190
 
@@ -204,7 +212,7 @@ def canlex_search_legislation(params: SearchInput) -> str:
204
  """Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
205
  and leading court decisions.
206
 
207
- The CanLex corpus has six kinds of source: 31 federal Acts and regulations
208
  (immigration, customs, criminal, drugs, food/health, labour, privacy and more);
209
  CBSA D-Memoranda (the Canada Border Services Agency's administrative guidance on
210
  how it applies customs and border law); Treasury Board collective agreements
@@ -212,8 +220,11 @@ def canlex_search_legislation(params: SearchInput) -> str:
212
  (travel, relocation, isolated posts and more); leading decisions of the
213
  courts and federal tribunals: the Supreme Court, Federal Court of Appeal and
214
  Federal Court, the Immigration and Refugee Board, and the FPSLREB and CIRB
215
- labour boards; and instruments of delegation and designation under IRPA and
216
- the IRPR (which officials the Minister has authorized to exercise which powers). Use this for ANY question about that material. It ranks results by relevance and returns
 
 
 
217
  their full text so the answer can cite the actual wording; an explicit section
218
  reference (e.g. "section 34") is always surfaced. Each result is marked with its
219
  source type.
@@ -224,8 +235,8 @@ def canlex_search_legislation(params: SearchInput) -> str:
224
  - top_k (int): How many sections to return, 1-20 (default 6).
225
  - act (Optional[str]): Restrict to one Act by short name/code, or omit for all.
226
  - doc_type (Optional[str]): 'legislation', 'memorandum', 'agreement',
227
- 'directive', 'caselaw', or 'delegation' to restrict to one source
228
- type; omit for all.
229
 
230
  Returns:
231
  str: Markdown with answering instructions followed by the matching sections.
@@ -312,6 +323,7 @@ def canlex_list_acts() -> str:
312
  directives: dict[str, dict] = {}
313
  cases: dict[str, dict] = {}
314
  delegations: dict[str, dict] = {}
 
315
  memo_numbers: set[str] = set()
316
  memo_chunks = 0
317
  memo_date = ""
@@ -343,6 +355,12 @@ def canlex_list_acts() -> str:
343
  "current_to": c["current_to"], "count": 0,
344
  })
345
  entry["count"] += 1
 
 
 
 
 
 
346
  else:
347
  entry = acts.setdefault(c["act_code"], {
348
  "short": c["act_short"], "name": c["act_name"],
@@ -379,10 +397,16 @@ def canlex_list_acts() -> str:
379
  for a in sorted(delegations.values(), key=lambda x: x["short"]):
380
  lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} items, "
381
  f"dated {a['current_to'] or 'n/a'}")
 
 
 
 
 
 
382
  lines += ["", "Search with canlex_search_legislation; filter by doc_type "
383
  "(legislation / memorandum / agreement / directive / caselaw / "
384
- "delegation). Fetch a known provision with canlex_get_section, or "
385
- "a case's citations with canlex_case."]
386
  return "\n".join(lines)
387
 
388
 
 
116
  "designated for functions, under IRPA and the IRPR. "
117
  "Administrative; confirm it is still the current version._")
118
  lines.append(f"(dated {c['current_to'] or 'n/a'})")
119
+ elif doc_type == "benefits":
120
+ lines.append("_Benefit-plan member booklet — a plain-language summary "
121
+ "published by the plan administrator (Canada Life). The "
122
+ "governing terms are in the plan's directive or rules, "
123
+ "which prevail in any discrepancy; quote the booklet for "
124
+ "what it says but flag that the plan rules control._")
125
+ lines.append(f"(booklet edition: {c['current_to'] or 'n/a'})")
126
  else:
127
  meta = [f"in force; text current to {c['current_to'] or 'n/a'}"]
128
  if c["last_amended"]:
 
190
  description="Optional filter by source type: 'legislation' (Acts and "
191
  "regulations), 'memorandum' (CBSA D-Memoranda), 'agreement' (collective "
192
  "agreements), 'directive' (NJC directives), 'caselaw' (court and "
193
+ "tribunal decisions), 'delegation' (IRPA/IRPR delegation and "
194
+ "designation instruments), or 'benefits' (public-service health and "
195
+ "dental plan member booklets). Omit to search all.",
196
  )
197
 
198
 
 
212
  """Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
213
  and leading court decisions.
214
 
215
+ The CanLex corpus has seven kinds of source: 31 federal Acts and regulations
216
  (immigration, customs, criminal, drugs, food/health, labour, privacy and more);
217
  CBSA D-Memoranda (the Canada Border Services Agency's administrative guidance on
218
  how it applies customs and border law); Treasury Board collective agreements
 
220
  (travel, relocation, isolated posts and more); leading decisions of the
221
  courts and federal tribunals: the Supreme Court, Federal Court of Appeal and
222
  Federal Court, the Immigration and Refugee Board, and the FPSLREB and CIRB
223
+ labour boards; instruments of delegation and designation under IRPA and
224
+ the IRPR (which officials the Minister has authorized to exercise which powers);
225
+ and the member booklets of the public-service benefit plans (the PSHCP health
226
+ plan and the PSDCP and PDSP dental plans). Use this for ANY question about that
227
+ material. It ranks results by relevance and returns
228
  their full text so the answer can cite the actual wording; an explicit section
229
  reference (e.g. "section 34") is always surfaced. Each result is marked with its
230
  source type.
 
235
  - top_k (int): How many sections to return, 1-20 (default 6).
236
  - act (Optional[str]): Restrict to one Act by short name/code, or omit for all.
237
  - doc_type (Optional[str]): 'legislation', 'memorandum', 'agreement',
238
+ 'directive', 'caselaw', 'delegation', or 'benefits' to restrict to
239
+ one source type; omit for all.
240
 
241
  Returns:
242
  str: Markdown with answering instructions followed by the matching sections.
 
323
  directives: dict[str, dict] = {}
324
  cases: dict[str, dict] = {}
325
  delegations: dict[str, dict] = {}
326
+ benefits: dict[str, dict] = {}
327
  memo_numbers: set[str] = set()
328
  memo_chunks = 0
329
  memo_date = ""
 
355
  "current_to": c["current_to"], "count": 0,
356
  })
357
  entry["count"] += 1
358
+ elif doc_type == "benefits":
359
+ entry = benefits.setdefault(c["act_code"], {
360
+ "short": c["act_short"], "name": c["act_name"],
361
+ "current_to": c["current_to"], "count": 0,
362
+ })
363
+ entry["count"] += 1
364
  else:
365
  entry = acts.setdefault(c["act_code"], {
366
  "short": c["act_short"], "name": c["act_name"],
 
397
  for a in sorted(delegations.values(), key=lambda x: x["short"]):
398
  lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} items, "
399
  f"dated {a['current_to'] or 'n/a'}")
400
+ if benefits:
401
+ lines += ["", "## Benefit plans"]
402
+ for a in sorted(benefits.values(), key=lambda x: x["short"]):
403
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
404
+ f"sections, booklet edition {a['current_to'] or 'n/a'}. "
405
+ f"Plain-language member guide; the plan rules govern.")
406
  lines += ["", "Search with canlex_search_legislation; filter by doc_type "
407
  "(legislation / memorandum / agreement / directive / caselaw / "
408
+ "delegation / benefits). Fetch a known provision with "
409
+ "canlex_get_section, or a case's citations with canlex_case."]
410
  return "\n".join(lines)
411
 
412
 
data/eval/questions.json CHANGED
@@ -132,7 +132,11 @@
132
  {"query": "What allowances are available to a federal employee posted at an isolated post?", "answers": [["Isolated Posts and Government Housing Directive", ""]]},
133
  {"query": "What relocation expenses are reimbursed when a federal employee must move for work?", "answers": [["NJC Relocation Directive", ""]]},
134
  {"query": "What occupational health and safety obligations does the NJC directive place on the employer?", "answers": [["Occupational Health and Safety Directive", ""]]},
135
- {"query": "What coverage does the Public Service Health Care Plan provide?", "answers": [["Public Service Health Care Plan Directive", ""]]},
 
 
 
 
136
  {"query": "What support do the Foreign Service Directives give an employee posted outside Canada?", "answers": [["Foreign Service Directives", ""]]},
137
  {"query": "Who has a right to appeal a decision to the Immigration Appeal Division?", "answers": [["IRPA", "63"]]},
138
  {"query": "What are the objectives of the Immigration and Refugee Protection Act?", "answers": [["IRPA", "3"]]},
 
132
  {"query": "What allowances are available to a federal employee posted at an isolated post?", "answers": [["Isolated Posts and Government Housing Directive", ""]]},
133
  {"query": "What relocation expenses are reimbursed when a federal employee must move for work?", "answers": [["NJC Relocation Directive", ""]]},
134
  {"query": "What occupational health and safety obligations does the NJC directive place on the employer?", "answers": [["Occupational Health and Safety Directive", ""]]},
135
+ {"query": "What coverage does the Public Service Health Care Plan provide?", "answers": [["Public Service Health Care Plan Directive", ""], ["PSHCP", ""]]},
136
+ {"query": "What does the PSHCP member booklet say about prescription drug coverage?", "answers": [["PSHCP", ""]]},
137
+ {"query": "What dental services are covered under the Public Service Dental Care Plan for employees?", "answers": [["PSDCP", ""]]},
138
+ {"query": "What dental coverage do federal pensioners get under the Pensioners' Dental Services Plan?", "answers": [["PDSP", ""]]},
139
+ {"query": "What life and disability insurance does the Public Service Management Insurance Plan provide for excluded employees?", "answers": [["PSMIP", ""]]},
140
  {"query": "What support do the Foreign Service Directives give an employee posted outside Canada?", "answers": [["Foreign Service Directives", ""]]},
141
  {"query": "Who has a right to appeal a decision to the Immigration Appeal Division?", "answers": [["IRPA", "63"]]},
142
  {"query": "What are the objectives of the Immigration and Refugee Protection Act?", "answers": [["IRPA", "3"]]},
data/processed/benefits.json ADDED
The diff for this file is too large to render. See raw diff
 
tests/test_benefits.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for benefit-plan booklet ingestion (canlex/benefits.py)."""
2
+ import unittest
3
+
4
+ from canlex import benefits
5
+
6
+
7
+ SRC = {"code": "PSHCP", "format": "pdf", "name": "Public Service Health Care Plan",
8
+ "url": "http://example/booklet.pdf", "label": "Member Booklet",
9
+ "edition": "June 2024"}
10
+
11
+
12
+ class TocParseTests(unittest.TestCase):
13
+ def test_strips_dot_leaders_and_page_numbers(self):
14
+ pages = ["cover", "",
15
+ "Table of Contents\n"
16
+ "....................Eligibility 9\n"
17
+ "Drug Benefit ........ 21\n"
18
+ "Appendix A — Rates 40\n"]
19
+ headings = benefits.parse_toc(pages, [2])
20
+ self.assertEqual(headings, ["Eligibility", "Drug Benefit",
21
+ "Appendix A — Rates"])
22
+
23
+ def test_dedupes_case_insensitively(self):
24
+ pages = ["", "", "Eligibility 9\nELIGIBILITY 9\n"]
25
+ self.assertEqual(benefits.parse_toc(pages, [2]), ["Eligibility"])
26
+
27
+
28
+ class SegmentTests(unittest.TestCase):
29
+ def test_splits_body_at_each_heading_in_order(self):
30
+ body = ("Eligibility\nYou must be an employee.\n"
31
+ "Drug Benefit\nDrugs are covered at 80%.")
32
+ secs = benefits.segment(body, ["Eligibility", "Drug Benefit"])
33
+ self.assertEqual(secs[0][0], "Eligibility")
34
+ self.assertIn("employee", secs[0][1])
35
+ self.assertEqual(secs[1][0], "Drug Benefit")
36
+ self.assertIn("80%", secs[1][1])
37
+
38
+ def test_heading_split_across_lines_is_matched(self):
39
+ # PDF extraction often breaks a heading across a line; flexible
40
+ # whitespace matching should still locate it.
41
+ body = "Welcome to the Public Service Health Care Plan \n(PSHCP) text"
42
+ secs = benefits.segment(
43
+ body, ["Welcome to the Public Service Health Care Plan (PSHCP)"])
44
+ self.assertEqual(len(secs), 1)
45
+
46
+
47
+ class SplitTests(unittest.TestCase):
48
+ def test_short_text_is_one_piece(self):
49
+ self.assertEqual(benefits._split("short"), ["short"])
50
+
51
+ def test_folds_tiny_leading_heading_into_next_piece(self):
52
+ # A heading on its own line ahead of one very long unbroken paragraph
53
+ # must not become a stub piece.
54
+ text = "Basic Life Insurance\n" + ("x" * 2000)
55
+ pieces = benefits._split(text)
56
+ self.assertTrue(all(len(p) >= 60 for p in pieces))
57
+ self.assertTrue(pieces[0].startswith("Basic Life Insurance"))
58
+
59
+
60
+ class EmitTests(unittest.TestCase):
61
+ def test_skips_heading_only_divider_sections(self):
62
+ sections = [("Introduction", "Introduction"),
63
+ ("Eligibility", "Eligibility\n" + "You qualify if employed. " * 4)]
64
+ chunks = benefits._emit(sections, SRC)
65
+ codes = {c["marginal_note"] for c in chunks}
66
+ self.assertNotIn("Introduction", codes)
67
+ self.assertIn("Eligibility", codes)
68
+
69
+ def test_chunk_carries_benefits_doc_type_and_citation(self):
70
+ sections = [("Drug Benefit", "Drug Benefit\n" + "Covered at 80%. " * 6)]
71
+ c = benefits._emit(sections, SRC)[0]
72
+ self.assertEqual(c["doc_type"], "benefits")
73
+ self.assertEqual(c["act_code"], "PSHCP")
74
+ self.assertIn("Drug Benefit", c["citation"])
75
+ self.assertIn("June 2024", c["citation"])
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
tests/test_index.py CHANGED
@@ -90,6 +90,16 @@ class SourceKeyTests(unittest.TestCase):
90
  self.assertEqual(idx._source_key(0), ("memorandum", "D1-1-1"))
91
  self.assertEqual(idx._source_key(1), ("caselaw", "2019 SCC 65"))
92
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  class DiversifyTests(unittest.TestCase):
95
  def test_caps_caselaw_per_decision(self):
 
90
  self.assertEqual(idx._source_key(0), ("memorandum", "D1-1-1"))
91
  self.assertEqual(idx._source_key(1), ("caselaw", "2019 SCC 65"))
92
 
93
+ def test_benefits_sections_are_not_capped(self):
94
+ # A benefit-plan booklet's sections are distinct topics, like statute
95
+ # sections, so the whole booklet is never collapsed to SOURCE_CAP.
96
+ idx = bare_index([
97
+ chunk(doc_type="benefits", act_code="PSHCP", section="drug-benefit"),
98
+ chunk(doc_type="benefits", act_code="PSHCP", section="exclusions"),
99
+ ])
100
+ self.assertIsNone(idx._source_key(0))
101
+ self.assertIsNone(idx._source_key(1))
102
+
103
 
104
  class DiversifyTests(unittest.TestCase):
105
  def test_caps_caselaw_per_decision(self):