Beemer Claude Fable 5 commited on
Commit
5247238
·
1 Parent(s): d24e96c

refresh.py: upstream staleness check for the NJC directives

Browse files

Directives graduate from stored-date reporting to upstream-checked, like the
delegation instruments. The effective date directive.py stores comes from the
NJC index page's span.date, not the individual directive pages (9 of 11 carry
no date; d6's page prose says 2009 against a true 2021), so check_directives()
probes one fresh index fetch, with a page-prose fallback only for the
index-undated Foreign Service Directives. Probe fetches go to a temp dir --
never data/raw. Staleness affects the exit code; fetch errors do not. Five
offline tests.

First live run caught real drift: the FSDs at stored 2026-04-01 vs upstream
2026-06-01 (re-ingested in the caselaw commit).

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

Files changed (2) hide show
  1. canlex/refresh.py +165 -15
  2. tests/test_refresh.py +30 -0
canlex/refresh.py CHANGED
@@ -14,11 +14,17 @@ It also checks the two delegation instruments against upstream (check_delegation
14
  the CBSA IRPA delegation -- by diffing the dated 'irpa-lipr-YYYY-MM-DD' instruments
15
  the CBSA legislation index publishes against those delegation.SOURCES ingests, so
16
  a newly-published amendment is flagged -- and the IRCC IL3 instrument, by comparing
17
- the version stamp in the live PDF against the stored one. Finally it prints the
18
- stored currency of the remaining non-XML sources (D-memos, case law, agreements,
19
- directives, benefits, tariff schedule), which expose no comparable upstream
20
- signal, with the command to refresh each; and flags any tracked pending bill
21
- whose coming-into-force date has passed.
 
 
 
 
 
 
22
 
23
  This is the staleness detection the corpus previously lacked: legislation was
24
  refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
@@ -35,8 +41,10 @@ import time
35
  import urllib.request
36
  from pathlib import Path
37
 
38
- from . import delegation
39
- from ._common import BROWSER_UA
 
 
40
  from .config import SOURCES, PROCESSED_DIR
41
 
42
  _UA = "CanLex-refresh/0.1"
@@ -46,19 +54,26 @@ _CURRENT_DATE = re.compile(rb'current-date="(\d{4}-\d{2}-\d{2})"')
46
  # Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
47
  # command, description). These carry no single upstream consolidation date, so
48
  # the check reports their STORED currency (newest item) and how to refresh them
49
- # rather than diffing against upstream. (Delegation is checked against upstream
50
- # separately, by check_delegation, because its sources DO expose a comparable
51
- # signal -- the CBSA instrument index and the IL3 version string.)
 
52
  _NON_XML = [
53
  ("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
54
  ("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
55
  ("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
56
- ("directive", "directives.json", "py -m canlex.directive", "NJC directives"),
57
  ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
58
  ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
59
  "Customs Tariff Schedule ch. 98-99"),
60
  ]
61
 
 
 
 
 
 
 
 
62
  # CBSA publishes its IRPA delegation as a base instrument plus incremental
63
  # amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
64
  # linked from the CBSA legislation index. A date on the index that we have not
@@ -219,6 +234,123 @@ def check_delegation():
219
  return rows
220
 
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  def pending_in_force(today):
223
  """Pending bills whose in-force date has passed as of `today` (ISO str)."""
224
  return [b for b in _PENDING_BILLS if b["in_force"] <= today]
@@ -228,17 +360,20 @@ def run(as_json=False):
228
  today = datetime.date.today().isoformat()
229
  leg = check_legislation()
230
  deleg = check_delegation()
 
231
  nonxml = non_xml_currency()
232
  pending = pending_in_force(today)
233
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
234
  errors = [r for r in leg if r["status"] == "error"]
235
  deleg_stale = [r for r in deleg if r["status"] == "stale"]
 
236
 
237
  if as_json:
238
  print(json.dumps({"checked": today, "legislation": leg,
239
- "delegation": deleg, "non_xml": nonxml,
240
- "pending_in_force": pending,
241
- "stale_count": len(stale) + len(deleg_stale),
 
242
  "error_count": len(errors)}, indent=2))
243
  else:
244
  print(f"CanLex corpus staleness check — {today}\n")
@@ -285,6 +420,21 @@ def run(as_json=False):
285
  print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
286
  "then py -m canlex.delegation && py -m canlex.embed")
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  print("\nOther sources (stored currency; re-run the ingester to refresh):")
289
  for r in nonxml:
290
  mark = " " if r["present"] else "!"
@@ -299,7 +449,7 @@ def run(as_json=False):
299
  print(f" {b['note']}")
300
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
301
 
302
- return 1 if (stale or errors or deleg_stale) else 0
303
 
304
 
305
  def main():
 
14
  the CBSA IRPA delegation -- by diffing the dated 'irpa-lipr-YYYY-MM-DD' instruments
15
  the CBSA legislation index publishes against those delegation.SOURCES ingests, so
16
  a newly-published amendment is flagged -- and the IRCC IL3 instrument, by comparing
17
+ the version stamp in the live PDF against the stored one. The NJC directives get
18
+ the same treatment (check_directives): the effective date each chunk stores in
19
+ current_to comes from the NJC directive index (the span.date beside each entry),
20
+ so one fresh index fetch re-reads it for nearly every directive, with the
21
+ 'effective <Month D, YYYY>' prose on the directive's own page as fallback for
22
+ entries the index leaves undated (the Foreign Service Directives) -- exactly the
23
+ two signals ingestion uses. Finally it prints the stored currency of the
24
+ remaining non-XML sources (D-memos, case law, agreements, benefits, tariff
25
+ schedule), which expose no comparable upstream signal, with the command to
26
+ refresh each; and flags any tracked pending bill whose coming-into-force date
27
+ has passed.
28
 
29
  This is the staleness detection the corpus previously lacked: legislation was
30
  refreshed by hand, so e.g. Bill C-12 only landed when someone thought to look.
 
41
  import urllib.request
42
  from pathlib import Path
43
 
44
+ from bs4 import BeautifulSoup
45
+
46
+ from . import delegation, directive
47
+ from ._common import BROWSER_UA, MONTHS
48
  from .config import SOURCES, PROCESSED_DIR
49
 
50
  _UA = "CanLex-refresh/0.1"
 
54
  # Aggregate (non-Justice-Laws) sources: (label, processed filename, refresh
55
  # command, description). These carry no single upstream consolidation date, so
56
  # the check reports their STORED currency (newest item) and how to refresh them
57
+ # rather than diffing against upstream. (Delegation and the NJC directives are
58
+ # checked against upstream separately, by check_delegation and check_directives,
59
+ # because their sources DO expose a comparable signal -- the CBSA instrument
60
+ # index and IL3 version string, and the NJC directive index dates.)
61
  _NON_XML = [
62
  ("memorandum", "dmemos.json", "py -m canlex.dmemo", "CBSA D-Memoranda"),
63
  ("caselaw", "caselaw.json", "py -m canlex.caselaw", "Court & tribunal decisions"),
64
  ("agreement", "agreements.json", "py -m canlex.agreement", "FB collective agreement"),
 
65
  ("benefits", "benefits.json", "py -m canlex.benefits", "Benefit-plan booklets"),
66
  ("tariff", "tariff_schedule.json", "py -m canlex.tariff_schedule",
67
  "Customs Tariff Schedule ch. 98-99"),
68
  ]
69
 
70
+ # NJC directive dates as published: the index prints prose ('June 1, 1993' --
71
+ # double spaces occur in the raw markup) while chunks may store either that
72
+ # prose or the ISO fallback directive.py derives from page text, so both sides
73
+ # are normalized to ISO before comparing.
74
+ _NJC_PROSE_DATE = re.compile(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})")
75
+ _ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
76
+
77
  # CBSA publishes its IRPA delegation as a base instrument plus incremental
78
  # amendments (no consolidation), each a dated 'irpa-lipr-YYYY-MM-DD-eng.html'
79
  # linked from the CBSA legislation index. A date on the index that we have not
 
234
  return rows
235
 
236
 
237
+ def _stored_directives():
238
+ """Per-directive stored currency from data/processed/directives.json:
239
+ {code: (title, current_to, source_url)}, one entry per act_code."""
240
+ path = PROCESSED_DIR / "directives.json"
241
+ if not path.exists():
242
+ return {}
243
+ chunks = json.loads(path.read_text(encoding="utf-8"))
244
+ out = {}
245
+ for c in chunks:
246
+ code = c.get("act_code")
247
+ if code and code not in out:
248
+ out[code] = (c.get("act_short", ""), c.get("current_to", ""),
249
+ c.get("source_url", ""))
250
+ return out
251
+
252
+
253
+ def _directive_iso(text):
254
+ """Normalize an NJC directive date ('June 1, 1993' or already ISO) to
255
+ YYYY-MM-DD. Pure, for testability. Returns '' when not a recognizable date."""
256
+ text = (text or "").strip()
257
+ if _ISO_DATE.fullmatch(text):
258
+ return text
259
+ m = _NJC_PROSE_DATE.fullmatch(text)
260
+ if m and m.group(1).lower() in MONTHS:
261
+ return (f"{int(m.group(3)):04d}-{MONTHS[m.group(1).lower()]:02d}-"
262
+ f"{int(m.group(2)):02d}")
263
+ return ""
264
+
265
+
266
+ def _njc_index_dates(html):
267
+ """{code: date text} parsed from the NJC directive index, mirroring
268
+ directive.directive_links: the first <a> in each top-level li of
269
+ ul.directive-list is the current directive (nested lis are archived
270
+ versions), and the sibling span.date its effective date ('' if absent)."""
271
+ soup = BeautifulSoup(html, "html.parser")
272
+ ul = soup.find("ul", class_="directive-list")
273
+ out = {}
274
+ if ul is None:
275
+ return out
276
+ for li in ul.find_all("li", recursive=False):
277
+ a = li.find("a", href=True)
278
+ if not a:
279
+ continue
280
+ m = directive._CODE.search(a["href"])
281
+ if not m:
282
+ continue
283
+ span = li.find("span", class_="date")
284
+ out[m.group(1)] = span.get_text(strip=True) if span else ""
285
+ return out
286
+
287
+
288
+ def _njc_page_effective(url, td):
289
+ """Fresh-fetch a directive's own page and pull its 'effective <Month D,
290
+ YYYY>' prose (directive._page_effective_date), following the 'Print Full
291
+ Directive' link when the landing page is only a table of contents (the
292
+ FSDs). Returns (iso_date, error)."""
293
+ try:
294
+ html = directive._fetch(url, td / "probe.html", force=True)
295
+ soup = BeautifulSoup(html, "html.parser")
296
+ main = soup.find("main")
297
+ date = directive._page_effective_date(main) if main else ""
298
+ if not date:
299
+ print_url = directive._print_link(html)
300
+ if print_url:
301
+ full = directive._fetch(print_url, td / "probe-full.html",
302
+ force=True)
303
+ fmain = BeautifulSoup(full, "html.parser").find("main")
304
+ date = directive._page_effective_date(fmain) if fmain else ""
305
+ return date, ""
306
+ except Exception as exc:
307
+ return "", f"{type(exc).__name__}: {exc}"
308
+
309
+
310
+ def check_directives():
311
+ """Compare each ingested NJC directive's effective date against upstream.
312
+ Returns row dicts.
313
+
314
+ The date directive.py stores in current_to comes from the NJC directive
315
+ index (the span.date beside each entry), so one fresh index fetch covers
316
+ nearly every directive; entries the index leaves undated (the Foreign
317
+ Service Directives) fall back to the 'effective <Month D, YYYY>' prose on
318
+ the directive's own page -- the same two signals ingestion uses. Probe
319
+ fetches go to a temp dir, never data/raw, so a stale probe can't mask a
320
+ later real refresh; directive._fetch already sleeps 0.5s after each
321
+ download, so the probes stay polite without extra pauses here."""
322
+ stored = _stored_directives()
323
+ rows = []
324
+ if not stored:
325
+ return rows
326
+ with tempfile.TemporaryDirectory() as td:
327
+ td = Path(td)
328
+ try:
329
+ html = directive._fetch(directive.INDEX_URL, td / "_index.html",
330
+ force=True)
331
+ index_dates, index_error = _njc_index_dates(html), ""
332
+ except Exception as exc:
333
+ index_dates, index_error = {}, f"{type(exc).__name__}: {exc}"
334
+ for code, (title, stored_date, url) in sorted(stored.items()):
335
+ upstream, error = index_dates.get(code, ""), index_error
336
+ if not upstream and not error:
337
+ # Undated on the index: read the date off the page itself.
338
+ upstream, error = _njc_page_effective(url, td)
339
+ local, remote = _directive_iso(stored_date), _directive_iso(upstream)
340
+ if error:
341
+ status = "error"
342
+ elif not remote:
343
+ status = "no-date"
344
+ elif remote != local:
345
+ status = "stale" # NJC reissues in place: any drift counts
346
+ else:
347
+ status = "ok"
348
+ rows.append({"code": code, "title": title, "stored": stored_date,
349
+ "local": local, "remote": remote, "status": status,
350
+ "error": error})
351
+ return rows
352
+
353
+
354
  def pending_in_force(today):
355
  """Pending bills whose in-force date has passed as of `today` (ISO str)."""
356
  return [b for b in _PENDING_BILLS if b["in_force"] <= today]
 
360
  today = datetime.date.today().isoformat()
361
  leg = check_legislation()
362
  deleg = check_delegation()
363
+ dirs = check_directives()
364
  nonxml = non_xml_currency()
365
  pending = pending_in_force(today)
366
  stale = [r for r in leg if r["status"] in ("stale", "missing-local")]
367
  errors = [r for r in leg if r["status"] == "error"]
368
  deleg_stale = [r for r in deleg if r["status"] == "stale"]
369
+ dirs_stale = [r for r in dirs if r["status"] == "stale"]
370
 
371
  if as_json:
372
  print(json.dumps({"checked": today, "legislation": leg,
373
+ "delegation": deleg, "directives": dirs,
374
+ "non_xml": nonxml, "pending_in_force": pending,
375
+ "stale_count": (len(stale) + len(deleg_stale)
376
+ + len(dirs_stale)),
377
  "error_count": len(errors)}, indent=2))
378
  else:
379
  print(f"CanLex corpus staleness check — {today}\n")
 
420
  print(" -> re-ingest: add any new CBSA dates to delegation.SOURCES, "
421
  "then py -m canlex.delegation && py -m canlex.embed")
422
 
423
+ print(f"\nNJC directives (upstream check, {len(dirs)} ingested):")
424
+ for r in dirs:
425
+ if r["status"] == "stale":
426
+ print(f" STALE {r['title']}: upstream {r['remote']} "
427
+ f"!= stored {r['local'] or r['stored'] or '?'}")
428
+ elif r["status"] == "error":
429
+ print(f" ERROR {r['title']}: {r['error'][:50]}")
430
+ elif r["status"] == "no-date":
431
+ print(f" ? {r['title']}: no effective date found upstream")
432
+ else:
433
+ print(f" ok {r['title']}: current ({r['remote']})")
434
+ if dirs_stale:
435
+ print(" -> re-ingest: py -m canlex.directive --force && "
436
+ "py -m canlex.embed")
437
+
438
  print("\nOther sources (stored currency; re-run the ingester to refresh):")
439
  for r in nonxml:
440
  mark = " " if r["present"] else "!"
 
449
  print(f" {b['note']}")
450
  print(f" run: py -m canlex.ingest --force {' '.join(b['affects'])}")
451
 
452
+ return 1 if (stale or errors or deleg_stale or dirs_stale) else 0
453
 
454
 
455
  def main():
tests/test_refresh.py CHANGED
@@ -54,6 +54,36 @@ class CbsaInstrumentRegexTests(unittest.TestCase):
54
  self.assertEqual(m.group(0), "Fall 2025")
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  class DriftSemanticsTests(unittest.TestCase):
58
  def test_iso_date_string_compare_detects_newer_upstream(self):
59
  # The check uses plain string comparison on ISO dates; verify ordering.
 
54
  self.assertEqual(m.group(0), "Fall 2025")
55
 
56
 
57
+ class DirectiveCheckTests(unittest.TestCase):
58
+ def test_directive_iso_normalizes_index_prose(self):
59
+ # The live index prints double spaces before single-digit days.
60
+ self.assertEqual(refresh._directive_iso("June 1, 1993"), "1993-06-01")
61
+ self.assertEqual(refresh._directive_iso("March 28, 2026"), "2026-03-28")
62
+
63
+ def test_directive_iso_passes_iso_through(self):
64
+ # FSD chunks store the ISO fallback directive.py derives from page text.
65
+ self.assertEqual(refresh._directive_iso("2026-04-01"), "2026-04-01")
66
+
67
+ def test_directive_iso_rejects_junk(self):
68
+ self.assertEqual(refresh._directive_iso("not a date"), "")
69
+ self.assertEqual(refresh._directive_iso("Smarch 1, 2026"), "")
70
+ self.assertEqual(refresh._directive_iso(""), "")
71
+
72
+ def test_index_dates_take_current_entries_not_archived(self):
73
+ html = ('<ul class="directive-list">'
74
+ '<li><a href="/directive/d2/en"><strong>Commuting</strong></a> '
75
+ '<span class="date">October 1, 2020</span>'
76
+ '<ul><li><a href="/directive/d2/v22/en"><strong>'
77
+ 'Archived version: June 1, 2010</strong></a></li></ul></li>'
78
+ '<li><a href="/directive/fsd-dse/en"><strong>FSD</strong></a>'
79
+ '</li></ul>')
80
+ self.assertEqual(refresh._njc_index_dates(html),
81
+ {"d2": "October 1, 2020", "fsd-dse": ""})
82
+
83
+ def test_index_dates_empty_when_list_missing(self):
84
+ self.assertEqual(refresh._njc_index_dates("<html><body/></html>"), {})
85
+
86
+
87
  class DriftSemanticsTests(unittest.TestCase):
88
  def test_iso_date_string_compare_detects_newer_upstream(self):
89
  # The check uses plain string comparison on ISO dates; verify ordering.