Beemer Claude Fable 5 commited on
Commit
eae149d
·
1 Parent(s): 6ce7899

CIU/PSAC union corpus: by-laws suite, PSAC Constitution, VSSAs, guides, 15 decisions; weekly watch extended

Browse files

New doc_type 'union' (bannered: bargaining-agent documents, not employer
policy): CIU National By-Laws (EN+FR), CIU Regulations, Branch Rules, Local
Modifications, Branch Elections Kit, PSAC Constitution (Nov 2024), the
grievance/EE/transition guides, and all 25 published site VSSAs -- 635
chunks via canlex.ciu. The union's Important Decisions list ingested in
full: 12 FPSLREB/PSLRB decisions (Bartlett scheduling through Guay-Bassett)
plus Jane Doe 2018 FCA 183, Marentette 2024 FC 676 and Fraser 2020 SCC 28
(124 decisions total). refresh.py check_union() diffs upstream
Last-Modified for all 35 union documents; PDI and IRB-guideline currency
rows added; legislation drift advice now includes the French twins; the
weekly SKILL.md rewritten to match (and its wrong error-semantics line
fixed). 40,994 chunks / 44,302 vectors. Gate: legacy 0.77/0.94/0.98/0.99
MRR 0.85; union slice 1.00 Hit@5; holdout 1.00 Hit@3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

README.md CHANGED
@@ -39,6 +39,12 @@ The server also publishes an MCP prompt (`us_record_screening`, the guided
39
  three-step screening flow) and resources (the corpus inventory and the
40
  conviction/equivalency methodology texts) that any MCP client can use.
41
 
 
 
 
 
 
 
42
  Retrieval runs fully locally inside the container and needs no API key. The
43
  optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
44
  variable. A weekly scheduled staleness check watches upstream sources for
 
39
  three-step screening flow) and resources (the corpus inventory and the
40
  conviction/equivalency methodology texts) that any MCP client can use.
41
 
42
+ The labour side now carries the full union stack alongside the FB collective
43
+ agreement: the CIU National By-Laws (EN/FR), CIU Regulations, Branch Rules
44
+ and local modifications, the Branch Elections Kit, the PSAC Constitution,
45
+ the union's member guides, and every published site VSSA — all bannered as
46
+ bargaining-agent documents, distinct from employer policy.
47
+
48
  Retrieval runs fully locally inside the container and needs no API key. The
49
  optional case citator reads a CanLII key from the `CANLII_API_KEY` environment
50
  variable. A weekly scheduled staleness check watches upstream sources for
RUNBOOK.md CHANGED
@@ -10,6 +10,7 @@ Order matters — chunks feed embeddings feed the eval:
10
  py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
11
  py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
12
  py -m canlex.pdi # scoped IRCC PDI pages (PR card / Tran)
 
13
  py -m canlex.dmemo # CBSA D-Memoranda
14
  py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
15
  py -m canlex.amps # AMPS Master Penalty Document
 
10
  py -m canlex.ingest [--force] [CODES] # Justice Laws XML -> per-Act JSON (incl. Schedules)
11
  py -m canlex.ingest --fr # parallel French corpus (same schema; SOR->DORS URLs)
12
  py -m canlex.pdi # scoped IRCC PDI pages (PR card / Tran)
13
+ py -m canlex.ciu # CIU/PSAC union documents + site VSSAs
14
  py -m canlex.dmemo # CBSA D-Memoranda
15
  py -m canlex.enf # ENF operational manuals (canada.ca PDFs)
16
  py -m canlex.amps # AMPS Master Penalty Document
canlex/caselaw.py CHANGED
@@ -70,6 +70,10 @@ CASES = [
70
  {"court": "scc", "id": 20081, "short": "Mason",
71
  "topic": "Inadmissibility under IRPA s. 34(1)(e) for acts of violence "
72
  "endangering safety in Canada; reasonableness review"},
 
 
 
 
73
  {"court": "scc", "id": 16146, "short": "Igloo Vikski",
74
  "topic": "Customs tariff classification methodology; how the General "
75
  "Rules for the Interpretation of the Harmonized System apply "
@@ -205,6 +209,14 @@ CASES = [
205
  "reliability of the evidence before committal"},
206
 
207
  # --- Federal Court of Appeal ---
 
 
 
 
 
 
 
 
208
  {"court": "fca", "id": 33278, "short": "Cha",
209
  "topic": "Scope of s. 44(1) inadmissibility reports and Minister's "
210
  "delegate removal orders at the port of entry; officers and "
@@ -331,6 +343,40 @@ CASES = [
331
  "where only part is shown to be of legitimate origin"},
332
 
333
  # --- Federal Public Sector Labour Relations and Employment Board ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  {"court": "fpslreb", "id": 520990, "short": "Menzies",
335
  "topic": "Progressive discipline of a CBSA border services officer; the "
336
  "lock-step approach to discipline rejected"},
 
70
  {"court": "scc", "id": 20081, "short": "Mason",
71
  "topic": "Inadmissibility under IRPA s. 34(1)(e) for acts of violence "
72
  "endangering safety in Canada; reasonableness review"},
73
+ {"court": "scc", "id": 18510, "short": "Fraser",
74
+ "topic": "Adverse-effect discrimination under Charter s. 15(1); the "
75
+ "RCMP job-sharing pension rules' disproportionate impact on "
76
+ "women; substantive equality methodology"},
77
  {"court": "scc", "id": 16146, "short": "Igloo Vikski",
78
  "topic": "Customs tariff classification methodology; how the General "
79
  "Rules for the Interpretation of the Harmonized System apply "
 
209
  "reliability of the evidence before committal"},
210
 
211
  # --- Federal Court of Appeal ---
212
+ {"court": "fca", "id": 345651, "short": "Jane Doe",
213
+ "topic": "Judicial review of 2017 PSLREB 55; compensation for a "
214
+ "victim of workplace sexual assault in the federal public "
215
+ "service; remedial authority of the Board"},
216
+ {"court": "fc", "id": 525537, "short": "Marentette",
217
+ "topic": "Workplace violence and harassment investigations under the "
218
+ "Canada Labour Code; procedural fairness owed to the "
219
+ "complainant in the investigation process"},
220
  {"court": "fca", "id": 33278, "short": "Cha",
221
  "topic": "Scope of s. 44(1) inadmissibility reports and Minister's "
222
  "delegate removal orders at the port of entry; officers and "
 
343
  "where only part is shown to be of legitimate origin"},
344
 
345
  # --- Federal Public Sector Labour Relations and Employment Board ---
346
+ # The CIU's published "Important Decisions" list (ciu-sdi.ca), ingested
347
+ # 2026-07-23; topics adapted from the union's own subject lines.
348
+ {"court": "fpslreb", "id": 358837, "short": "Bartlett",
349
+ "topic": "Hours of work and variable shift scheduling under the FB "
350
+ "collective agreement (Border Services officers)"},
351
+ {"court": "fpslreb", "id": 359582, "short": "Legros",
352
+ "topic": "Age discrimination grievance against the CBSA"},
353
+ {"court": "fpslreb", "id": 359605, "short": "Bétournay",
354
+ "topic": "Breach of trust, disguised disciplinary action and "
355
+ "termination (CRA)"},
356
+ {"court": "fpslreb", "id": 359635, "short": "Frezza",
357
+ "topic": "Bad faith and disguised discipline in the federal public "
358
+ "service (DND)"},
359
+ {"court": "fpslreb", "id": 360491, "short": "PSAC v TB (CBSA) 2018-66",
360
+ "topic": "Policy grievance: interpretation of the FB collective "
361
+ "agreement (PSAC v Treasury Board / CBSA)"},
362
+ {"court": "fpslreb", "id": 361700, "short": "PSAC v TB (CBSA) 2018-82",
363
+ "topic": "Policy grievance: interpretation of the FB collective "
364
+ "agreement (PSAC v Treasury Board / CBSA)"},
365
+ {"court": "fpslreb", "id": 361701, "short": "Michaud",
366
+ "topic": "Suspension grievance in the federal public service (CRA)"},
367
+ {"court": "fpslreb", "id": 407654, "short": "Hyslop",
368
+ "topic": "Suspension grievance of a CBSA employee"},
369
+ {"court": "fpslreb", "id": 407686, "short": "Girard",
370
+ "topic": "Suspension and the effective date of termination (CRA)"},
371
+ {"court": "fpslreb", "id": 420718, "short": "Edwards",
372
+ "topic": "Sick leave, personal-needs leave and disability "
373
+ "discrimination under the FB collective agreement (CBSA)"},
374
+ {"court": "fpslreb", "id": 420308, "short": "Touchette",
375
+ "topic": "Proportionality of discipline; suspension of a CBSA "
376
+ "employee reduced"},
377
+ {"court": "fpslreb", "id": 521306, "short": "Guay-Bassett",
378
+ "topic": "Scheduling, designated paid holidays and sick leave under "
379
+ "the FB collective agreement (CBSA)"},
380
  {"court": "fpslreb", "id": 520990, "short": "Menzies",
381
  "topic": "Progressive discipline of a CBSA border services officer; the "
382
  "lock-step approach to discipline rejected"},
canlex/ciu.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest CIU and PSAC union documents (doc_type='union').
2
+
3
+ The Customs and Immigration Union is the PSAC component representing the FB
4
+ bargaining unit; its governance instruments and published reference material
5
+ are the union-side counterpart to the FB collective agreement already in the
6
+ corpus. Ingested here, all as public documents from ciu-sdi.ca / psacunion.ca:
7
+
8
+ - the CIU National By-Laws (EN + FR), CIU Regulations, Branch Rules and the
9
+ Local Modifications to Branch Rules -- the union's internal law;
10
+ - the PSAC Constitution and Regulations (the parent instrument the by-laws
11
+ operate under: dues, discipline and appeals bottom out there);
12
+ - the Branch Elections Kit and the members' guides (grievance handling,
13
+ employment equity, workers in transition);
14
+ - every site-specific Variable Shift Schedule Agreement (VSSA) the union
15
+ publishes -- the locally negotiated schedule grids for ports of entry.
16
+
17
+ None of this is employer policy or part of the collective agreement itself;
18
+ _format_section banners doc_type='union' accordingly.
19
+
20
+ python -m canlex.ciu
21
+ """
22
+ import json
23
+ import re
24
+ import urllib.request
25
+
26
+ from ._common import fetch_cached, norm_ws as _norm, split_lines, uniquify_ids
27
+ from .config import PROCESSED_DIR, RAW_DIR
28
+
29
+ RAW = RAW_DIR / "ciu"
30
+ VSSA_PAGE = "https://ciu-sdi.ca/en/vssa/"
31
+ _DOCS_BASE = "https://www.ciu-sdi.ca/wp-content/uploads/documents/"
32
+
33
+ # (code, act_short, act_name, url, lang)
34
+ DOCS = [
35
+ ("ciu-bylaws", "CIU By-Laws",
36
+ "CIU National By-Laws (adopted by CIU National Conventions)",
37
+ _DOCS_BASE + "bylaws_en.pdf", "en"),
38
+ ("ciu-bylaws", "CIU By-Laws",
39
+ "Règlements nationaux du SDI (adoptés par les congrès)",
40
+ _DOCS_BASE + "bylaws_fr.pdf", "fr"),
41
+ ("ciu-regs", "CIU Regulations",
42
+ "CIU Regulations (adopted by the CIU National Board of Directors)",
43
+ _DOCS_BASE + "ciu-regs-en.pdf", "en"),
44
+ ("ciu-branch-rules", "CIU Branch Rules",
45
+ "CIU Branch Rules (adopted by the CIU National Board of Directors)",
46
+ _DOCS_BASE + "branch-rules-en.pdf", "en"),
47
+ ("ciu-local-mods", "CIU Local Modifications",
48
+ "Local Modifications to CIU Branch Rules",
49
+ _DOCS_BASE + "local_mod_branch_rules_en.pdf", "en"),
50
+ ("ciu-elections-kit", "CIU Elections Kit",
51
+ "CIU Branch Elections Kit",
52
+ "https://www.ciu-sdi.ca/wp-content/uploads/elections/2023/EN/"
53
+ "elections-kit.pdf", "en"),
54
+ ("psac-constitution", "PSAC Constitution",
55
+ "PSAC Constitution and Regulations (November 2024 edition)",
56
+ "https://psacunion.ca/sites/psac/files/"
57
+ "psac.constitution.november_2024_edition.eng_v002.pdf", "en"),
58
+ ("ciu-guide-grievance", "CIU Grievance Guide",
59
+ "CIU Guide to Grievance Handling",
60
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
61
+ "guide_to_grievance_handling-e.pdf", "en"),
62
+ ("ciu-guide-ee", "CIU EE Toolkit",
63
+ "CIU Employment Equity Toolkit",
64
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
65
+ "EMPLOYMENT-EQUITY-Tool-Kit-e.pdf", "en"),
66
+ ("ciu-guide-transition", "CIU Transition Guide",
67
+ "Workers in Transition: a practical guide about gender transition",
68
+ "https://ciu-sdi.ca/wp-content/uploads/2012/09/"
69
+ "workers_in_transition_guide_-_en_-_web.pdf", "en"),
70
+ ]
71
+
72
+ # Governance headings: "BY-LAW 5", "REGULATION 12", "RULE 3", "ARTICLE 7",
73
+ # "SECTION 25", "PART B" -- at line start, optionally followed by a title.
74
+ _HEADING = re.compile(
75
+ r"^(BY-?LAW|R[EÈ]GLEMENT|REGULATION|RULE|ARTICLE|SECTION|PART|ANNEX(?:E)?"
76
+ r"|APPENDIX)\s+([0-9]+[0-9A-Za-z.\-]*|[A-Z])\b\s*[-–—:]?\s*(.{0,80})",
77
+ re.IGNORECASE)
78
+ _DATE = re.compile(r"\b(20\d{2}|19\d{2})\b")
79
+
80
+
81
+ def _pdf_pages(path):
82
+ from pypdf import PdfReader
83
+ pages = []
84
+ for page in PdfReader(str(path)).pages:
85
+ try:
86
+ pages.append(page.extract_text() or "")
87
+ except Exception:
88
+ pages.append("")
89
+ return pages
90
+
91
+
92
+ def _currency(pages):
93
+ """Newest adoption/amendment year on the cover pages."""
94
+ years = _DATE.findall("\n".join(pages[:3]))
95
+ return max(years) if years else ""
96
+
97
+
98
+ def _doc_chunks(code, short, name, url, lang, pages):
99
+ text = "\n".join(pages)
100
+ lines = text.split("\n")
101
+ currency = _currency(pages)
102
+ marks = []
103
+ for i, l in enumerate(lines):
104
+ m = _HEADING.match(l.strip())
105
+ if m:
106
+ marks.append((i, f"{m.group(1).title()} {m.group(2)}",
107
+ _norm(m.group(3)) or ""))
108
+ segments = []
109
+ if len(marks) >= 6:
110
+ for j, (i, label, title) in enumerate(marks):
111
+ end = marks[j + 1][0] if j + 1 < len(marks) else len(lines)
112
+ body = "\n".join(p for p in (_norm(x) for x in lines[i:end]) if p)
113
+ if len(body) > 120:
114
+ segments.append((label, title, body))
115
+ if not segments:
116
+ for k, piece in enumerate(split_lines(text, 1800), start=1):
117
+ body = "\n".join(p for p in (_norm(x) for x in piece.split("\n")) if p)
118
+ if len(body) > 120:
119
+ segments.append((f"part {k}", "", body))
120
+ chunks = []
121
+ fr = "-fr" if lang == "fr" else ""
122
+ for n, (label, title, body) in enumerate(segments, start=1):
123
+ pieces = split_lines(body, 1800) if len(body) > 2400 else [body]
124
+ total = len(pieces)
125
+ for k, piece in enumerate(pieces, start=1):
126
+ suffix = "" if total == 1 else f"-p{k}"
127
+ note = "" if total == 1 else f" (part {k} of {total})"
128
+ chunk = {
129
+ "id": f"{code}{fr}-{n}{suffix}",
130
+ "doc_type": "union",
131
+ "act_code": code.upper(),
132
+ "act_short": short,
133
+ "act_name": name,
134
+ "section": label,
135
+ "marginal_note": title,
136
+ "part": short,
137
+ "division": "",
138
+ "heading": "",
139
+ "text": piece,
140
+ "history": "",
141
+ "last_amended": currency,
142
+ "in_force": "",
143
+ "status": "in force",
144
+ "current_to": currency,
145
+ "citation": f"{short}, {label}"
146
+ + (f" ({title})" if title else "") + note,
147
+ "source_url": url,
148
+ }
149
+ if lang == "fr":
150
+ chunk["lang"] = "fr"
151
+ chunks.append(chunk)
152
+ return chunks
153
+
154
+
155
+ def _vssa_links():
156
+ req = urllib.request.Request(VSSA_PAGE, headers={"User-Agent": "Mozilla/5.0"})
157
+ with urllib.request.urlopen(req, timeout=60) as r:
158
+ html = r.read().decode("utf-8", "replace")
159
+ links = sorted(set(re.findall(
160
+ r'href="(https?://[^"]*?/uploads/vssa/([^"/]+)\.pdf)"', html)))
161
+ return links
162
+
163
+
164
+ def _vssa_chunks(url, stem, pages):
165
+ site = _norm(stem.replace("-", " "))
166
+ text = "\n".join(p for p in (_norm(x) for x in "\n".join(pages).split("\n")) if p)
167
+ if len(text) < 60:
168
+ return []
169
+ out = []
170
+ pieces = split_lines(text, 1800)
171
+ total = len(pieces)
172
+ for k, piece in enumerate(pieces, start=1):
173
+ suffix = "" if total == 1 else f"-p{k}"
174
+ note = "" if total == 1 else f" (part {k} of {total})"
175
+ out.append({
176
+ "id": f"vssa-{stem.lower()}{suffix}",
177
+ "doc_type": "union",
178
+ "act_code": "VSSA",
179
+ "act_short": "VSSA",
180
+ "act_name": "Variable Shift Schedule Agreements (by work site)",
181
+ "section": site,
182
+ "marginal_note": f"VSSA — {site}",
183
+ "part": "Variable Shift Schedule Agreements",
184
+ "division": "",
185
+ "heading": "",
186
+ "text": piece,
187
+ "history": "",
188
+ "last_amended": "",
189
+ "in_force": "",
190
+ "status": "in force",
191
+ "current_to": "",
192
+ "citation": f"VSSA — {site}{note}",
193
+ "source_url": url,
194
+ })
195
+ return out
196
+
197
+
198
+ def build():
199
+ RAW.mkdir(parents=True, exist_ok=True)
200
+ all_chunks = []
201
+ for code, short, name, url, lang in DOCS:
202
+ fr = "-fr" if lang == "fr" else ""
203
+ dest = RAW / f"{code}{fr}.pdf"
204
+ try:
205
+ fetch_cached(url, dest, pause=0.8)
206
+ chunks = _doc_chunks(code, short, name, url, lang,
207
+ _pdf_pages(dest))
208
+ except Exception as exc:
209
+ print(f" !! {short} ({lang}): {type(exc).__name__}: {exc}")
210
+ continue
211
+ print(f" {short:24} ({lang}): {len(chunks)} chunks")
212
+ all_chunks.extend(chunks)
213
+ try:
214
+ links = _vssa_links()
215
+ except Exception as exc:
216
+ links = []
217
+ print(f" !! VSSA index: {type(exc).__name__}: {exc}")
218
+ n_vssa = 0
219
+ for url, stem in links:
220
+ dest = RAW / f"vssa-{stem.lower()}.pdf"
221
+ try:
222
+ fetch_cached(url, dest, pause=0.6)
223
+ chunks = _vssa_chunks(url, stem, _pdf_pages(dest))
224
+ except Exception as exc:
225
+ print(f" !! VSSA {stem}: {type(exc).__name__}: {exc}")
226
+ continue
227
+ all_chunks.extend(chunks)
228
+ n_vssa += 1
229
+ print(f" VSSA sites: {n_vssa}")
230
+ uniquify_ids(all_chunks)
231
+ out = PROCESSED_DIR / "ciu.json"
232
+ out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
233
+ encoding="utf-8")
234
+ print(f"ciu: {len(all_chunks)} chunks -> {out.name}")
235
+ return all_chunks
236
+
237
+
238
+ if __name__ == "__main__":
239
+ build()
canlex/refresh.py CHANGED
@@ -70,8 +70,55 @@ _NON_XML = [
70
  ("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
71
  ("commentary", "commentary.json", "py -m canlex.commentary",
72
  "Curated commentary chunks"),
 
 
 
 
73
  ]
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  # Curated datasets age: state legislatures amend deferred-adjudication
76
  # statutes every session, so a reviewed date older than this many days earns
77
  # a warning even though nothing upstream can be diffed automatically.
@@ -488,6 +535,7 @@ def run(as_json=False):
488
  curated = check_curated(today)
489
  dmemo_idx = check_dmemo_index()
490
  tariff = check_tariff_edition(today)
 
491
  pending = pending_in_force(today)
492
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
493
  errors = [r for r in leg if r["status"] == "error"]
@@ -496,16 +544,19 @@ def run(as_json=False):
496
  curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
497
  dmemo_stale = dmemo_idx["status"] == "stale"
498
  tariff_stale = tariff["status"] == "stale"
 
499
 
500
  if as_json:
501
  print(json.dumps({"checked": today, "legislation": leg,
502
  "delegation": deleg, "directives": dirs,
503
  "non_xml": nonxml, "curated": curated,
504
  "dmemo_index": dmemo_idx, "tariff": tariff,
 
505
  "pending_in_force": pending,
506
  "stale_count": (len(stale) + len(deleg_stale)
507
  + len(dirs_stale)
508
  + len(curated_flag)
 
509
  + (1 if dmemo_stale else 0)
510
  + (1 if tariff_stale else 0)),
511
  "error_count": len(errors)}, indent=2))
@@ -529,6 +580,8 @@ def run(as_json=False):
529
  codes = " ".join(r["code"] for r in stale)
530
  print(f"\n -> {len(stale)} drifted. Refresh: "
531
  f"py -m canlex.ingest --force {codes}\n"
 
 
532
  f" then: py -m canlex.embed && py -m canlex.eval")
533
 
534
  print("\nDelegation instruments (upstream check):")
@@ -610,6 +663,19 @@ def run(as_json=False):
610
  print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
611
  f"current)")
612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  if pending:
614
  print("\nPending bills now in force (re-ingest the affected Acts):")
615
  for b in pending:
@@ -619,7 +685,8 @@ def run(as_json=False):
619
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
620
 
621
  return 1 if (stale or errors or deleg_stale or dirs_stale
622
- or curated_flag or dmemo_stale or tariff_stale) else 0
 
623
 
624
 
625
  def main():
 
70
  ("charter", "charter.json", "py -m canlex.charter", "Charter / Constitution"),
71
  ("commentary", "commentary.json", "py -m canlex.commentary",
72
  "Curated commentary chunks"),
73
+ ("union", "ciu.json", "py -m canlex.ciu", "CIU/PSAC union documents"),
74
+ ("pdi", "pdi.json", "py -m canlex.pdi", "IRCC PDI pages (PR card / Tran)"),
75
+ ("irb-guidelines", "irb_guidelines.json", "py -m canlex.irb_guidelines",
76
+ "IRB Chairperson's Guidelines"),
77
  ]
78
 
79
+
80
+ def check_union():
81
+ """Upstream drift for the CIU/PSAC documents (incl. every site VSSA).
82
+
83
+ The union reposts amended documents at the same URLs, so the drift
84
+ signal is each PDF's Last-Modified header against our cached copy's
85
+ fetch time. New/removed VSSAs surface as fetch errors or new links on
86
+ the next canlex.ciu run; this check covers content changes to what we
87
+ already hold."""
88
+ import email.utils
89
+ from . import ciu
90
+ rows = []
91
+ targets = [(short, lang, ciu.RAW / f"{code}{'-fr' if lang == 'fr' else ''}.pdf", url)
92
+ for code, short, name, url, lang in ciu.DOCS]
93
+ try:
94
+ targets += [(f"VSSA {stem}", "en",
95
+ ciu.RAW / f"vssa-{stem.lower()}.pdf", url)
96
+ for url, stem in ciu._vssa_links()]
97
+ except Exception as exc:
98
+ rows.append({"short": "VSSA index", "lang": "en", "status": "error",
99
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
100
+ for short, lang, cache, url in targets:
101
+ try:
102
+ req = urllib.request.Request(url, method="HEAD",
103
+ headers={"User-Agent": BROWSER_UA})
104
+ with urllib.request.urlopen(req, timeout=30) as resp:
105
+ lm = resp.headers.get("Last-Modified", "")
106
+ except Exception as exc:
107
+ rows.append({"short": short, "lang": lang, "status": "error",
108
+ "remote": "", "error": f"{type(exc).__name__}: {exc}"})
109
+ continue
110
+ if not lm:
111
+ rows.append({"short": short, "lang": lang, "status": "no-date",
112
+ "remote": "", "error": ""})
113
+ continue
114
+ remote_ts = email.utils.parsedate_to_datetime(lm).timestamp()
115
+ local_ts = cache.stat().st_mtime if cache.exists() else 0
116
+ rows.append({"short": short, "lang": lang,
117
+ "status": "stale" if remote_ts > local_ts else "ok",
118
+ "remote": lm[:25], "error": ""})
119
+ time.sleep(0.3)
120
+ return rows
121
+
122
  # Curated datasets age: state legislatures amend deferred-adjudication
123
  # statutes every session, so a reviewed date older than this many days earns
124
  # a warning even though nothing upstream can be diffed automatically.
 
535
  curated = check_curated(today)
536
  dmemo_idx = check_dmemo_index()
537
  tariff = check_tariff_edition(today)
538
+ union = check_union()
539
  pending = pending_in_force(today)
540
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
541
  errors = [r for r in leg if r["status"] == "error"]
 
544
  curated_flag = [r for r in curated if r["status"] in ("aging", "drift")]
545
  dmemo_stale = dmemo_idx["status"] == "stale"
546
  tariff_stale = tariff["status"] == "stale"
547
+ union_stale = [r for r in union if r["status"] == "stale"]
548
 
549
  if as_json:
550
  print(json.dumps({"checked": today, "legislation": leg,
551
  "delegation": deleg, "directives": dirs,
552
  "non_xml": nonxml, "curated": curated,
553
  "dmemo_index": dmemo_idx, "tariff": tariff,
554
+ "union": union,
555
  "pending_in_force": pending,
556
  "stale_count": (len(stale) + len(deleg_stale)
557
  + len(dirs_stale)
558
  + len(curated_flag)
559
+ + len(union_stale)
560
  + (1 if dmemo_stale else 0)
561
  + (1 if tariff_stale else 0)),
562
  "error_count": len(errors)}, indent=2))
 
580
  codes = " ".join(r["code"] for r in stale)
581
  print(f"\n -> {len(stale)} drifted. Refresh: "
582
  f"py -m canlex.ingest --force {codes}\n"
583
+ f" and the French twins: py -m canlex.ingest --fr "
584
+ f"--force {codes}\n"
585
  f" then: py -m canlex.embed && py -m canlex.eval")
586
 
587
  print("\nDelegation instruments (upstream check):")
 
663
  print(f"\nCustoms Tariff edition: ok ({tariff['stored']} is "
664
  f"current)")
665
 
666
+ n_union_ok = sum(1 for r in union if r["status"] == "ok")
667
+ print(f"\nCIU/PSAC union documents (upstream Last-Modified check, "
668
+ f"{len(union)} documents):")
669
+ for r in union:
670
+ if r["status"] == "stale":
671
+ print(f" STALE {r['short']} ({r['lang']}): upstream "
672
+ f"modified {r['remote']} after our fetch — run "
673
+ f"py -m canlex.ciu && py -m canlex.embed")
674
+ elif r["status"] == "error":
675
+ print(f" ERROR {r['short']}: {r['error'][:55]}")
676
+ if not union_stale and not any(r["status"] == "error" for r in union):
677
+ print(f" all {n_union_ok} current (upstream unchanged since fetch)")
678
+
679
  if pending:
680
  print("\nPending bills now in force (re-ingest the affected Acts):")
681
  for b in pending:
 
685
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
686
 
687
  return 1 if (stale or errors or deleg_stale or dirs_stale
688
+ or curated_flag or dmemo_stale or tariff_stale
689
+ or union_stale) else 0
690
 
691
 
692
  def main():
canlex/server.py CHANGED
@@ -196,6 +196,20 @@ def _format_section(c: dict, related=None) -> str:
196
  age = _guidance_age_note(c["current_to"])
197
  if age:
198
  lines.append(age)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  elif doc_type == "commentary":
200
  lines.append("_**CURATED ANALYSIS — CanLex commentary, not a source "
201
  "of law.** A curated synthesis of the cited authorities; "
@@ -267,7 +281,7 @@ def _format_section(c: dict, related=None) -> str:
267
  # as an enum rather than free text (regex 'pattern' constraints are dropped by
268
  # several function-calling translators, notably Gemini's).
269
  DocType = Literal["legislation", "memorandum", "agreement", "directive",
270
- "caselaw", "delegation", "benefits", "commentary"]
271
 
272
  # Longest alternative first, and 's'/'ss' only at a word boundary or before
273
  # a dotted number -- so 'section 20.1' loses the whole word (not its first
@@ -322,7 +336,7 @@ def canlex_search_legislation(
322
  """Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
323
  and leading court decisions.
324
 
325
- The CanLex corpus has eight kinds of source: federal Acts and regulations
326
  with their Schedules (immigration, customs, criminal, firearms, drugs,
327
  food/health, labour, privacy and more); CBSA D-Memoranda (administrative
328
  guidance on how the CBSA applies customs and border law); Treasury Board
@@ -331,8 +345,10 @@ def canlex_search_legislation(
331
  leading decisions of the courts and federal tribunals (Supreme Court,
332
  Federal Court of Appeal and Federal Court, the IRB, and the FPSLREB and
333
  CIRB labour boards); instruments of delegation and designation under IRPA
334
- and the IRPR; member booklets of the public-service benefit plans; and
335
- CanLex's curated commentary (US-disposition and equivalency analysis).
 
 
336
  Use this for ANY question about that material. It ranks results by
337
  relevance and returns their full text so the answer can cite the actual
338
  wording; an explicit section reference (e.g. "section 34") is always
@@ -725,6 +741,7 @@ def canlex_list_acts() -> str:
725
  delegations: dict[str, dict] = {}
726
  benefits: dict[str, dict] = {}
727
  commentary: dict[str, dict] = {}
 
728
  memo_numbers: set[str] = set()
729
  memo_chunks = 0
730
  memo_date = ""
@@ -776,6 +793,12 @@ def canlex_list_acts() -> str:
776
  "current_to": c["current_to"], "count": 0,
777
  })
778
  entry["count"] += 1
 
 
 
 
 
 
779
  else:
780
  entry = acts.setdefault(c["act_code"], {
781
  "short": c["act_short"], "name": c["act_name"],
@@ -822,6 +845,12 @@ def canlex_list_acts() -> str:
822
  lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
823
  f"sections, booklet edition {a['current_to'] or 'n/a'}. "
824
  f"Plain-language member guide; the plan rules govern.")
 
 
 
 
 
 
825
  if commentary:
826
  lines += ["", "## Curated commentary (not law)"]
827
  for a in sorted(commentary.values(), key=lambda x: x["short"]):
 
196
  age = _guidance_age_note(c["current_to"])
197
  if age:
198
  lines.append(age)
199
+ elif doc_type == "union":
200
+ lines.append("_Union document (CIU/PSAC) — internal governance "
201
+ "rules, member guides or locally negotiated scheduling "
202
+ "instruments published by the bargaining agent. They "
203
+ "bind the union and its members under the union's own "
204
+ "rules; they are NOT employer policy and NOT part of "
205
+ "the collective agreement itself (a VSSA operates "
206
+ "under the FB agreement's variable-shift provisions). "
207
+ "The union site may lag renegotiations — verify "
208
+ "currency with the Branch._")
209
+ lines.append(f"(document currency: {_stated(c['current_to'])})")
210
+ age = _guidance_age_note(c["current_to"])
211
+ if age:
212
+ lines.append(age)
213
  elif doc_type == "commentary":
214
  lines.append("_**CURATED ANALYSIS — CanLex commentary, not a source "
215
  "of law.** A curated synthesis of the cited authorities; "
 
281
  # as an enum rather than free text (regex 'pattern' constraints are dropped by
282
  # several function-calling translators, notably Gemini's).
283
  DocType = Literal["legislation", "memorandum", "agreement", "directive",
284
+ "caselaw", "delegation", "benefits", "commentary", "union"]
285
 
286
  # Longest alternative first, and 's'/'ss' only at a word boundary or before
287
  # a dotted number -- so 'section 20.1' loses the whole word (not its first
 
336
  """Search Canadian federal law, CBSA D-Memoranda, agreements, NJC directives,
337
  and leading court decisions.
338
 
339
+ The CanLex corpus has nine kinds of source: federal Acts and regulations
340
  with their Schedules (immigration, customs, criminal, firearms, drugs,
341
  food/health, labour, privacy and more); CBSA D-Memoranda (administrative
342
  guidance on how the CBSA applies customs and border law); Treasury Board
 
345
  leading decisions of the courts and federal tribunals (Supreme Court,
346
  Federal Court of Appeal and Federal Court, the IRB, and the FPSLREB and
347
  CIRB labour boards); instruments of delegation and designation under IRPA
348
+ and the IRPR; member booklets of the public-service benefit plans;
349
+ CanLex's curated commentary (US-disposition and equivalency analysis);
350
+ and union documents (the CIU by-laws/regulations/branch rules, the PSAC
351
+ Constitution, member guides, and every published site VSSA).
352
  Use this for ANY question about that material. It ranks results by
353
  relevance and returns their full text so the answer can cite the actual
354
  wording; an explicit section reference (e.g. "section 34") is always
 
741
  delegations: dict[str, dict] = {}
742
  benefits: dict[str, dict] = {}
743
  commentary: dict[str, dict] = {}
744
+ union_docs: dict[str, dict] = {}
745
  memo_numbers: set[str] = set()
746
  memo_chunks = 0
747
  memo_date = ""
 
793
  "current_to": c["current_to"], "count": 0,
794
  })
795
  entry["count"] += 1
796
+ elif doc_type == "union":
797
+ entry = union_docs.setdefault(c["act_code"], {
798
+ "short": c["act_short"], "name": c["act_name"],
799
+ "current_to": c["current_to"], "count": 0,
800
+ })
801
+ entry["count"] += 1
802
  else:
803
  entry = acts.setdefault(c["act_code"], {
804
  "short": c["act_short"], "name": c["act_name"],
 
845
  lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
846
  f"sections, booklet edition {a['current_to'] or 'n/a'}. "
847
  f"Plain-language member guide; the plan rules govern.")
848
+ if union_docs:
849
+ lines += ["", "## Union documents (CIU/PSAC)"]
850
+ for a in sorted(union_docs.values(), key=lambda x: x["short"]):
851
+ lines.append(f"- **{a['short']}** — {a['name']}: {a['count']} "
852
+ f"sections, currency {a['current_to'] or 'n/a'}. "
853
+ f"Bargaining-agent document; not employer policy.")
854
  if commentary:
855
  lines += ["", "## Curated commentary (not law)"]
856
  for a in sorted(commentary.values(), key=lambda x: x["short"]):
data/eval/questions.json CHANGED
@@ -1990,5 +1990,53 @@
1990
  "query": "What notice must an Ontario landlord give to evict a tenant?",
1991
  "answers": [],
1992
  "style": "no-answer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1993
  }
1994
  ]
 
1990
  "query": "What notice must an Ontario landlord give to evict a tenant?",
1991
  "answers": [],
1992
  "style": "no-answer"
1993
+ },
1994
+ {
1995
+ "query": "What do the CIU by-laws say about how branch elections are conducted?",
1996
+ "answers": [
1997
+ [
1998
+ "CIU By-Laws",
1999
+ ""
2000
+ ],
2001
+ [
2002
+ "CIU Elections Kit",
2003
+ ""
2004
+ ],
2005
+ [
2006
+ "CIU Branch Rules",
2007
+ ""
2008
+ ]
2009
+ ],
2010
+ "style": "union"
2011
+ },
2012
+ {
2013
+ "query": "What is the variable shift schedule agreement for Windsor Bridge?",
2014
+ "answers": [
2015
+ [
2016
+ "VSSA",
2017
+ ""
2018
+ ]
2019
+ ],
2020
+ "style": "union"
2021
+ },
2022
+ {
2023
+ "query": "How does the PSAC constitution deal with membership discipline and expulsion?",
2024
+ "answers": [
2025
+ [
2026
+ "PSAC Constitution",
2027
+ ""
2028
+ ]
2029
+ ],
2030
+ "style": "union"
2031
+ },
2032
+ {
2033
+ "query": "What are the timelines for presenting a grievance according to the union's grievance guide?",
2034
+ "answers": [
2035
+ [
2036
+ "CIU Grievance Guide",
2037
+ ""
2038
+ ]
2039
+ ],
2040
+ "style": "union"
2041
  }
2042
  ]
data/processed/caselaw.json CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:d0a415257b35e02c821e4d8ea225f8c6c73a72acfef7bb2f5a9de99f7a7d0455
3
- size 11745635
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:94e937a9a6a36a17662c7fb63bc1cec7397a28ff910e0c6d5889c2b2ac8deed8
3
+ size 13150503
data/processed/ciu.json ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/embeddings.npz CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:bbde594e9da686b30a3cf252204432194849aac8e6e7cbdf6fbc9f8d5c7cef5f
3
- size 76219542
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6380fda9c44fafe766c7cf83a1b40f8a7ef8a689533069d8181b3a28a79c06f
3
+ size 78503650
webapp/app.py CHANGED
@@ -124,7 +124,7 @@ TOOL_DECLARATIONS = [
124
  "type": "string",
125
  "enum": ["legislation", "memorandum", "agreement",
126
  "directive", "caselaw", "delegation",
127
- "benefits", "commentary"],
128
  "description": (
129
  "Optional. Restrict to one source type: 'legislation', "
130
  "'memorandum' (CBSA D-Memoranda), 'agreement' "
 
124
  "type": "string",
125
  "enum": ["legislation", "memorandum", "agreement",
126
  "directive", "caselaw", "delegation",
127
+ "benefits", "commentary", "union"],
128
  "description": (
129
  "Optional. Restrict to one source type: 'legislation', "
130
  "'memorandum' (CBSA D-Memoranda), 'agreement' "