Add the public-service benefit-plan member booklets (new doc_type 'benefits')
Browse filesIngests 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 +346 -0
- canlex/index.py +10 -2
- canlex/server.py +33 -9
- data/eval/questions.json +5 -1
- data/processed/benefits.json +0 -0
- tests/test_benefits.py +79 -0
- tests/test_index.py +10 -0
|
@@ -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()
|
|
@@ -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
|
|
@@ -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),
|
| 187 |
-
"designation instruments)
|
|
|
|
| 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
|
| 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;
|
| 216 |
-
the IRPR (which officials the Minister has authorized to exercise which powers)
|
|
|
|
|
|
|
|
|
|
| 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 '
|
| 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
|
| 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 |
|
|
@@ -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"]]},
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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()
|
|
@@ -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):
|