-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathpublish.py
More file actions
2231 lines (1986 loc) · 97 KB
/
Copy pathpublish.py
File metadata and controls
2231 lines (1986 loc) · 97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
publish.py — Daily publishing pipeline for Swiss Case Law
==========================================================
Orchestration script for VPS cron job. Runs the full pipeline:
1. Ingest new entscheidsuche.ch downloads (if entscheidsuche_ingest.py exists)
2. Build/update FTS5 database
2d. Quality enrichment (titles, regeste, dates, hashes, dedup)
2b. Quality report (optional)
2c. Build reference graph (citations + statutes, ~78 min)
3. Export database/JSONL → Parquet
4. Upload Parquet + dataset card to HuggingFace
5. Generate stats.json
6. Git commit + push docs/stats.json
Most steps are wrapped in try/except — failures are logged. Critical steps
(FTS5, Parquet) will skip subsequent guarded steps (HF upload, git push) to
avoid publishing an incomplete dataset.
Cron:
15 3 * * * cd /opt/caselaw/repo && python3 publish.py >> logs/publish.log 2>&1
Usage:
python3 publish.py # run full pipeline
python3 publish.py --step 3 # run only step 3 (export)
python3 publish.py --dry-run # log what would happen
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import signal
import urllib.request
import logging
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
LOCK_FILE_PATH = "/tmp/opencaselaw-publish.lock"
CHECKPOINT_PATH = Path("/tmp/opencaselaw-publish-checkpoint.json")
NTFY_TOPIC = "opencaselaw-publish" # https://ntfy.sh/opencaselaw-publish
def _notify(title: str, message: str, *, priority: str = "default"):
"""Send push notification via ntfy.sh (best-effort, never fails the pipeline)."""
try:
req = urllib.request.Request(
f"https://ntfy.sh/{NTFY_TOPIC}",
data=message.encode(),
headers={"Title": title, "Priority": priority},
)
urllib.request.urlopen(req, timeout=5)
except Exception:
pass # notification failure must never break the pipeline
def _append_run_record(record: dict) -> None:
"""Append one JSON line to state/publish_runs.jsonl.
The pipeline's only durable structured record. Until 2026-08-19 a run
left behind 109 overwritten bytes on success and NOTHING on failure
(the failure branch exited before any marker) — per-step timings were
computed, logged as text and lost, so a 13h41m → 17h07m build creep
and a gate timeout were invisible until they hurt. Append-only, one
line per step and one summary per run, written on success AND failure.
Telemetry must never break the pipeline: any error here is swallowed.
"""
try:
state_dir = REPO_DIR / "state"
state_dir.mkdir(exist_ok=True)
with open(state_dir / "publish_runs.jsonl", "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception:
pass
def _save_checkpoint(step_num, results: dict):
"""Save completed step so pipeline can resume after crash."""
CHECKPOINT_PATH.write_text(json.dumps({
"last_completed_step": str(step_num),
"results": {str(k): v for k, v in results.items()},
"timestamp": datetime.now(timezone.utc).isoformat(),
}))
def _load_checkpoint() -> dict | None:
"""Load checkpoint from prior crashed run (if any).
TTL is 4h (was 12h). The daily timer fires every ~24h, so a 12h TTL
leaves a wide window where yesterday's checkpoint can be re-used by
today's timer-triggered run if even one step failed (defeating the
refresh purpose of the daily publish). 4h is short enough to never
bleed into the next daily run, yet long enough for a manual resume
after a crash within the same publish window.
"""
if CHECKPOINT_PATH.exists():
try:
data = json.loads(CHECKPOINT_PATH.read_text())
age_hours = (datetime.now(timezone.utc) - datetime.fromisoformat(data["timestamp"])).total_seconds() / 3600
if age_hours < 4:
return data
except Exception:
pass
return None
def _clear_checkpoint():
"""Remove checkpoint after successful completion."""
CHECKPOINT_PATH.unlink(missing_ok=True)
logger = logging.getLogger("publish")
REPO_DIR = Path(__file__).parent.resolve()
OUTPUT_DIR = REPO_DIR / "output"
DATASET_DIR = OUTPUT_DIR / "dataset"
DOCS_DIR = REPO_DIR / "docs"
DB_PATH = OUTPUT_DIR / "decisions.db"
HF_REPO_ID = "voilaj/swiss-caselaw"
# Build_fts5 writes decisions.db.tmp (~63 GB) plus a .tmp-wal that peaks
# around 10 GB, then atomically swaps. We keep a 7 GB safety margin so the
# concurrent decision_structure sidecar build (step 2g) doesn't squeeze the
# volume during the brief window before the .tmp is replaced.
DATA_VOLUME = "/mnt/HC_Volume_104655575"
BUILD_DISK_REQUIRED_GB = 80
def run_cmd(
cmd: list[str],
description: str,
dry_run: bool = False,
timeout: int = 3600,
stall_timeout: int | None = 5400,
on_line=None,
outcome_sink: dict | None = None,
) -> bool:
"""Run a command, return True on success.
outcome_sink, when given, receives {"timed_out": bool, "stalled": bool,
"returncode": int|None} so a caller can distinguish "the command was
killed at its wall-clock cap" from "the command ran and said no". The
QC gate needs that distinction: a timeout says nothing about corpus
quality, but until 2026-08-22 it was indistinguishable from a CRITICAL
verdict and cascade-skipped the HF upload and both git pushes (08-18,
08-21 — both timeouts, zero regressions).
Streams stdout/stderr line-by-line to the logger instead of buffering
the full output in memory (avoids OOM on long-running steps like
build_fts5 or graph build that can produce hundreds of MB of output).
on_line: optional callback invoked once per stdout line BEFORE it
reaches the logger. Used by Step 2 to release the publish lock the
instant build_fts5 prints its OCL_SWAP_DONE sentinel, without
waiting for the rest of the integrity-check tail. Callback
exceptions are caught and logged so a faulty hook can't crash the
publish.
Two independent kill-switches:
- ``timeout``: hard wall-clock cap (default 3600 s). The wall-clock
bound has to accommodate the longest legitimate step (Step 2c
reference graph at 10800 s).
- ``stall_timeout``: kill the process if no output line is received
for this many seconds (default 5400 s = 90 min). Catches the
"process is alive but wedged" class — silent OOM, deadlocked DB,
infinite loop. Bumped from the 30 min initial value on
2026-05-02 after that watchdog killed a healthy build mid-dedup
(build_fts5 dedup is silent for ~45 min by design; optimize is
silent for ~45 min; both are legitimate). 90 min covers the
longest legitimate silent phase with 2× margin. Set to None to
disable.
"""
logger.info(f" $ {' '.join(cmd)}")
if dry_run:
logger.info(" [dry-run] skipped")
return True
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # merge stderr into stdout to avoid pipe deadlock
text=True,
cwd=str(REPO_DIR),
# start_new_session=True makes the child a process-group leader
# (its PID == its PGID) so we can kill the WHOLE group via
# os.killpg, which catches grandchildren build_fts5 may spawn.
# 2026-05-07 incident: Step 2 wall-clock fired at 03:20:05 but
# build_fts5 kept running, swap happened 3h 20m later — the old
# proc.kill() only signalled the immediate child, missed the
# ionice→nice→python chain or a child SQLite worker still in D.
start_new_session=True,
)
# Watchdog timers: kill the process either on wall-clock timeout
# OR on output-stall timeout. We can't rely on proc.wait(timeout=)
# because the for-loop over proc.stdout blocks until EOF
# (i.e. process exit).
timed_out = threading.Event()
stalled = threading.Event()
last_output_at = [time.time()]
def _kill_pg(reason: str) -> None:
"""Kill the entire process group: SIGTERM, 5s grace, then SIGKILL.
Belt-and-braces against children that survive a single SIGKILL
to the leader (the 2026-05-07 incident).
"""
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, OSError):
# Process already dead.
return
try:
os.killpg(pgid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
# Give it 5 seconds for atomic-swap / file-handle close to finish.
for _ in range(50):
if proc.poll() is not None:
return
time.sleep(0.1)
try:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
# SIGKILL queued — give kernel up to 30s to deliver
# (D-state syscalls may delay kill arrival).
for _ in range(300):
if proc.poll() is not None:
return
time.sleep(0.1)
logger.warning(
f" process group {pgid} still alive 30s after SIGKILL "
f"({reason}); subprocess may be wedged in D state"
)
def _kill_on_timeout():
timed_out.set()
_kill_pg("wall-clock timeout")
def _kill_on_stall():
while proc.poll() is None and not timed_out.is_set():
idle = time.time() - last_output_at[0]
if stall_timeout is not None and idle > stall_timeout:
stalled.set()
_kill_pg("stall watchdog")
return
time.sleep(min(60, max(5, (stall_timeout or 60) // 4)))
wall_timer = threading.Timer(timeout, _kill_on_timeout)
wall_timer.start()
stall_thread = None
if stall_timeout is not None:
stall_thread = threading.Thread(target=_kill_on_stall, daemon=True)
stall_thread.start()
try:
assert proc.stdout is not None
for line in proc.stdout:
last_output_at[0] = time.time()
line = line.rstrip("\n")
if line:
if on_line is not None:
try:
on_line(line)
except Exception as _hook_err:
logger.warning(
f" on_line callback raised "
f"{type(_hook_err).__name__}: {_hook_err}"
)
logger.info(f" | {line}")
proc.wait()
finally:
wall_timer.cancel()
if outcome_sink is not None:
outcome_sink["timed_out"] = timed_out.is_set()
outcome_sink["stalled"] = stalled.is_set()
outcome_sink["returncode"] = proc.returncode
if timed_out.is_set():
logger.error(f" timed out after {timeout}s (wall-clock)")
return False
if stalled.is_set():
logger.error(
f" stalled — no output for {stall_timeout}s; killed by watchdog"
)
return False
if proc.returncode != 0:
logger.error(f" exit code {proc.returncode}")
return False
return True
except Exception as e:
logger.error(f" failed: {e}")
if outcome_sink is not None:
outcome_sink.setdefault("timed_out", False)
outcome_sink.setdefault("stalled", False)
outcome_sink.setdefault("returncode", None)
return False
def step_1_ingest(dry_run: bool = False) -> bool:
"""Step 1: Ingest new entscheidsuche.ch downloads."""
logger.info("Step 1: Ingest entscheidsuche downloads")
ingest_script = REPO_DIR / "entscheidsuche_ingest.py"
if not ingest_script.exists():
# Try scrapers directory
ingest_script = REPO_DIR / "scrapers" / "entscheidsuche_ingest.py"
if not ingest_script.exists():
logger.info(" No ingest script found, skipping")
return True
return run_cmd(
[sys.executable, str(ingest_script)],
"Ingest entscheidsuche downloads",
dry_run,
)
def _cleanup_stale_build_artifacts() -> None:
"""Remove .tmp/.tmp-wal/.tmp-shm leftover from a crashed prior build.
Only removes files older than 6 h to avoid clobbering an in-flight build
invoked outside publish.py (the lockfile already prevents concurrent
publish runs, so this is defence-in-depth for manual build_fts5 invocations).
"""
candidates = [
f"{DATA_VOLUME}/output/decisions.db.tmp",
f"{DATA_VOLUME}/output/decisions.db.tmp-wal",
f"{DATA_VOLUME}/output/decisions.db.tmp-shm",
# `.quick` is a SQLite quickcheck snapshot left behind by a
# crashed build_fts5 PRAGMA quick_check pass. It can reach 60 GB
# and burned the 2026-05-02 nightly when the disk filled up.
f"{DATA_VOLUME}/output/decisions.db.quick",
f"{DATA_VOLUME}/output/.reference_graph.db.tmp-journal",
f"{DATA_VOLUME}/output/.decisions.db.tmp-journal",
# decision_structure rebuild artefacts. The full rebuild writes
# to a sibling .tmp file that can grow to ~45 GB. A crash mid-
# build leaves the .tmp orphaned and burns the next nightly
# at pre-flight. Once decision_structure.db lives on /mnt
# (post-2026-05-02 symlink) the .tmp lands there too; covering
# both legacy /opt and current /mnt paths is defence-in-depth.
f"{DATA_VOLUME}/output/decision_structure.db.tmp",
f"{DATA_VOLUME}/output/decision_structure.db.tmp-journal",
"/opt/caselaw/repo/output/decision_structure.db.tmp",
"/opt/caselaw/repo/output/decision_structure.db.tmp-journal",
"/opt/caselaw/repo/output/decision_structure.db.partial-2026-04-29",
]
now = time.time()
for path in candidates:
try:
st = os.stat(path)
except FileNotFoundError:
continue
age_h = (now - st.st_mtime) / 3600
if age_h < 6:
logger.warning(
f" Stale-build cleanup: skipping {path} "
f"(age {age_h:.1f}h < 6h, possibly active)"
)
continue
try:
os.unlink(path)
logger.warning(
f" Stale-build cleanup: removed {path} "
f"({st.st_size / 1e9:.1f} GB freed, age {age_h:.1f}h)"
)
except OSError as e:
logger.error(f" Stale-build cleanup failed for {path}: {e}")
def _preflight_disk_check() -> bool:
"""Verify /mnt has enough room for build_fts5's transient .tmp + .tmp-wal."""
import shutil
if not Path(DATA_VOLUME).exists():
logger.warning(f" Pre-flight: {DATA_VOLUME} not present, skipping check")
return True
free_gb = shutil.disk_usage(DATA_VOLUME).free / 1e9
if free_gb < BUILD_DISK_REQUIRED_GB:
logger.error(
f"PRE-FLIGHT FAILED: {DATA_VOLUME} has {free_gb:.1f} GB free, "
f"build needs ~{BUILD_DISK_REQUIRED_GB} GB transient. "
f"Top consumers in {DATA_VOLUME}/output:"
)
out = Path(f"{DATA_VOLUME}/output")
if out.exists():
top = sorted(
((p, p.stat().st_size) for p in out.iterdir() if p.is_file()),
key=lambda x: -x[1],
)[:10]
for p, sz in top:
logger.error(f" {sz / 1e9:>6.1f} GB {p.name}")
return False
logger.info(
f" Pre-flight: {DATA_VOLUME} has {free_gb:.1f} GB free "
f"(need >= {BUILD_DISK_REQUIRED_GB})"
)
return True
def _parse_worker_ports(systemctl_list_units_output: str) -> list:
"""Extract worker ports from `systemctl list-units mcp-server@*.service`
(--plain --no-legend) output. Pure + unit-tested."""
ports = []
for line in systemctl_list_units_output.splitlines():
parts = line.split()
if parts and parts[0].startswith("mcp-server@") and parts[0].endswith(".service"):
ports.append(parts[0][len("mcp-server@"):-len(".service")])
return sorted(set(ports))
def _recycle_mcp_workers(dry_run: bool = False) -> None:
"""Roll-restart the mcp-server@ SSE workers to release handles to the
just-swapped (now-deleted) decisions.db inode.
After the Step 2 atomic swap, each serving worker keeps pooled SQLite
connections open on the OLD decisions.db inode, pinning that ~70 GB file
(unlinked, not yet freed) until the process recycles. With the data volume
near capacity this starved the post-build aux tier: on 2026-07-08,
reference_graph + decision_structure both hit 'database or disk is full'
because ~130 GB of orphaned inodes were pinned by 106 worker handles. A
rolling restart (one worker at a time, gated on /health) releases them with
zero serving downtime.
Non-fatal: logs and continues on any error so a recycle hiccup never fails
the build. No-op under dry-run or when the units are absent (dev box).
"""
if dry_run:
logger.info(" [dry-run] would roll-restart mcp-server@ workers post-swap")
return
try:
out = subprocess.run(
["systemctl", "list-units", "mcp-server@*.service",
"--state=active", "--no-legend", "--plain", "--no-pager"],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.SubprocessError):
return # no systemctl (dev box) — nothing to recycle
ports = _parse_worker_ports(out.stdout)
if not ports:
logger.info(" post-swap recycle: no active mcp-server@ workers; skipping")
return
logger.info(
f" post-swap recycle: rolling restart of {len(ports)} workers "
f"({', '.join(ports)}) to free the old decisions.db inode"
)
for port in ports:
try:
subprocess.run(
["systemctl", "restart", f"mcp-server@{port}.service"],
check=False, timeout=60,
)
except subprocess.SubprocessError as e:
logger.warning(f" worker {port}: restart error {e}; continuing")
continue
healthy = False
for _ in range(15):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/health", timeout=3,
) as r:
if r.status == 200:
healthy = True
break
except Exception: # noqa: BLE001 - keep polling until the deadline
pass
time.sleep(1)
logger.info(f" worker {port}: {'ok' if healthy else 'TIMEOUT (continuing)'}")
time.sleep(2)
def step_2_build_fts5(
dry_run: bool = False,
full_rebuild: bool = False,
on_line=None,
) -> bool:
"""Step 2: Build/update FTS5 search database.
Always uses full rebuild: builds to .db.tmp then atomic os.replace().
This avoids DB locks with live MCP workers (immutable=1 connections).
on_line: optional per-stdout-line callback. The publish driver
passes a hook that releases the publish lock when build_fts5
prints its ``OCL_SWAP_DONE`` sentinel — so the lock isn't held
through the post-swap integrity_check tail (1–3 h on the 60 GB
DB) and quick_publish can fold fresh BGer poller scrapes into
the live DB during that window.
"""
script = REPO_DIR / "build_fts5.py"
if not script.exists():
logger.error(" build_fts5.py not found")
return False
logger.info("Step 2: Full FTS5 rebuild (low I/O priority, zero-downtime swap)")
# Pre-flight: clean stale .tmp from a prior crashed build, then check we
# have room for the new build. Fail fast (skip the 90-min crash cycle).
if not dry_run:
_cleanup_stale_build_artifacts()
if not _preflight_disk_check():
return False
# Use ionice/nice to prevent I/O starvation of live MCP workers.
# Best-effort class (-c2), not idle (-c3): under serving load the idle class
# got fully starved, stalling build_fts5 optimize past the nightly cap.
# Proven 2026-06-23: optimize 35min (best-effort) vs >4h (idle). See memory
# incident_2026_06_23_build_starvation_salvage.
cmd = ["ionice", "-c2", "nice", "-n", "10",
sys.executable, str(script), "--output", str(OUTPUT_DIR),
"--full-rebuild"]
# Wall-clock cap. History:
# 18000s (5h) — too tight; hit 2026-05-04 03:30 mid-optimize.
# 25200s (7h) — too tight; hit 2026-05-07 03:20 after 1.46M
# wayback_queue + heavy König cleanup pushed total to 10h 20m.
# 43200s (12h) — current. Today's worst case (10h 20m) + 1h 40m
# cushion. The 16h unit-level TimeoutStartSec is the outer cap.
# Note: with the 2026-05-07 process-group kill fix, even if this
# cap fires, the entire build_fts5 tree dies within ~6s — no more
# silent overrun + cascade-skip + post-mortem-class incidents.
timeout = 43200 # 12h hard cap; legitimate completion ranges 4–10h
# Stall watchdog: the FTS5 'optimize' phase + post-swap
# PRAGMA integrity_check both emit NO stdout for ~1–3 h each.
# History:
# 5400s (1.5h) — too tight; killed mid-optimize 2026-05-04.
# 10800s (3h) — bumped 2026-05-04; tripped today (2026-05-11)
# because integrity_check on the post-swap 60 GB DB
# ran exactly 3h under disk contention. The 13:41
# watchdog kill cascade-skipped Steps 4/6 even
# though the swap had succeeded at 10:40.
# 14400s (4h) — current. Empirical post-swap integrity_check
# ranges 2h 55m – 3h 20m on this hardware. 4h
# gives a ~40-min cushion. The new
# OCL_SWAP_DONE handshake (commit b4ba734) means
# quick_publish can run DURING this window, which
# adds disk contention and is the reason the
# previous cap got hit.
return run_cmd(cmd, "Build FTS5 database", dry_run,
timeout=timeout, stall_timeout=14400,
on_line=on_line)
def step_2b_quality_report(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2b: Generate quality report and check gates."""
logger.info("Step 2b: Quality report")
script = REPO_DIR / "quality_report.py"
if not script.exists():
logger.info(" quality_report.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" Database not found, skipping quality report")
return True
return run_cmd(
[sys.executable, str(script),
"--db", str(DB_PATH),
"--output", str(OUTPUT_DIR / "quality_report.json"),
"--gate"],
"Quality report",
dry_run,
timeout=7200,
)
def step_2c_build_reference_graph(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2c: Build reference graph (citations + statutes)."""
logger.info("Step 2c: Build reference graph")
# Use the INCREMENTAL builder in forced-full mode rather than
# build_reference_graph.py directly. Same inputs (both read
# decisions.db), same full rebuild — but this one also writes the
# `meta` and `processed_decisions` state tables.
#
# Without that state a subsequent --in-place incremental run finds no
# diff base (_select_diff_base -> "no_state") and bootstraps the whole
# graph from scratch, ~3h22m measured on production, instead of
# applying a delta in ~50min. That is what blocks the weekday-
# incremental cutover. The step comment below has said "Real fix:
# build_reference_graph_incremental.py" since 2026-06-03; this is it.
script = REPO_DIR / "search_stack" / "build_reference_graph_incremental.py"
if not script.exists():
logger.info(" build_reference_graph_incremental.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping reference graph")
return True
graph_db = OUTPUT_DIR / "reference_graph.db"
return run_cmd(
[sys.executable, str(script),
"--decisions-db", str(DB_PATH),
"--graph-db", str(graph_db),
"--force-full",
"--in-place"],
"Build reference graph",
dry_run,
# Bumped 7200→10800 (2026-05-01), then 10800→18000 (2026-06-03 STOPGAP):
# the full builder is ~78min solo but ran >3h and hit the 10800s cap on
# the 06-02 nightly under 4-way post-build I/O contention (see
# PARALLEL_MAX_WORKERS, now 2). 5h leaves margin until 2c moves to the
# incremental builder. Real fix: build_reference_graph_incremental.py.
timeout=18000,
)
def step_2d_enrich_quality(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2d: Enrich data quality (titles, regeste, dates, hashes, dedup)."""
logger.info("Step 2d: Quality enrichment")
script = REPO_DIR / "scripts" / "enrich_quality.py"
if not script.exists():
logger.info(" enrich_quality.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping enrichment")
return True
cmd = [
sys.executable, str(script),
"--db", str(DB_PATH),
"--output", str(OUTPUT_DIR),
]
if dry_run:
cmd.append("--dry-run")
return run_cmd(cmd, "Quality enrichment", dry_run, timeout=7200)
def step_2e_build_anwaltsrecht_tags(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2e: Build Anwaltsrecht tags DB from SAV PDFs."""
logger.info("Step 2e: Build Anwaltsrecht tags")
script = REPO_DIR / "search_stack" / "build_anwaltsrecht_tags.py"
if not script.exists():
logger.info(" build_anwaltsrecht_tags.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping Anwaltsrecht tags")
return True
tags_db = OUTPUT_DIR / "anwaltsrecht_tags.db"
return run_cmd(
[sys.executable, str(script),
"--fts5-db", str(DB_PATH),
"--output", str(tags_db)],
"Build Anwaltsrecht tags",
dry_run,
timeout=600,
)
def step_2g_build_decision_structure(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2g: Rebuild decision_structure.db sidecar (Sachverhalt / Erwägungen-Paragraphs / Dispositiv / Regeste).
Federal + cantonal + regulatory courts. Reads every JSONL shard
in OUTPUT_DIR/decisions/ that has the canonical
`<court>.jsonl` / `es_<court>.jsonl` shape, skipping backups
(.bak*, .broken) and tmp files (tmp*). Writes sidecar SQLite
with atomic swap. Used by get_decision_structure / get_erwaegung /
get_regeste MCP tools and to enrich get_case_brief responses.
Auto-glob means new shards (e.g. when a canton's first scraper
lands) are picked up automatically without a publish.py edit.
"""
logger.info("Step 2g: Build decision_structure sidecar")
script = REPO_DIR / "search_stack" / "extract_decision_structure.py"
if not script.exists():
logger.info(" extract_decision_structure.py not found, skipping")
return True
decisions_dir = OUTPUT_DIR / "decisions"
if not decisions_dir.exists():
logger.warning(f" {decisions_dir} not found, skipping")
return True
# Glob every shard, exclude backups / tmp / broken files.
candidates = sorted(decisions_dir.glob("*.jsonl"))
skip_patterns = (".bak", ".broken", ".tmp", ".old")
shard_names = []
for path in candidates:
name = path.name
if any(p in name for p in skip_patterns):
continue
# tmp* files (no extension match) — handled by name prefix check
stem = path.stem # filename without trailing .jsonl
if stem.startswith("tmp") and stem[3:4].isalnum():
continue
shard_names.append(stem)
if not shard_names:
logger.warning(" no shards found, skipping")
return True
logger.info(f" building from {len(shard_names)} shards")
shards_arg = ",".join(shard_names)
return run_cmd(
[sys.executable, str(script), "--build",
"--shards", shards_arg,
"--decisions-dir", str(decisions_dir),
"--output", str(OUTPUT_DIR / "decision_structure.db")],
f"Build decision_structure sidecar ({len(shard_names)} shards)",
dry_run,
timeout=14400, # 2026-06-03 STOPGAP: full build now runs >2h (outgrew
# the "~1h" estimate) and hit the old 7200s cap on the 06-02 nightly
# under 4-way I/O contention. Real fix: extract_decision_structure_incremental.
# The new FTS5 'rebuild' + 'optimize' phase added in commit
# b8e4cf3 (find_relevant_erwaegung infra) emits no stdout while
# SQLite rewrites the index over ~970K paragraph rows. On the
# 2026-05-04 publish that silent phase ran past the default
# 5400s stall watchdog and got killed mid-rebuild — leaving the
# decision_structure.db sidecar (and therefore the FTS5 index
# find_relevant_erwaegung depends on) un-built. Bumped to 9000s
# (2.5h) — wide enough for the silent finalisation window
# observed in production, narrow enough to still catch a
# genuinely-wedged process within a few hours.
stall_timeout=9000,
)
def step_3_export_parquet(dry_run: bool = False) -> bool:
"""Step 3: Export SQLite/JSONL corpus to Parquet."""
logger.info("Step 3: Export Parquet")
script = REPO_DIR / "export_parquet.py"
if not script.exists():
logger.error(" export_parquet.py not found")
return False
cmd = [sys.executable, str(script),
"--input", str(OUTPUT_DIR / "decisions"),
"--output", str(DATASET_DIR)]
# The erwaegungen-paragraphs artifact is 4.8 GB (P1.4) — weekly cadence
# only (Sunday, aligned with the full-snapshot rhythm); the lean
# structure.parquet + graph exports ride every run.
if datetime.now(timezone.utc).weekday() == 6:
cmd.append("--structure-paragraphs")
return run_cmd(cmd, "Export Parquet", dry_run)
def step_4_upload_hf(dry_run: bool = False) -> bool:
"""Step 4: Upload Parquet + dataset card to HuggingFace."""
logger.info("Step 4: Upload to HuggingFace")
if dry_run:
logger.info(" [dry-run] would upload to HuggingFace")
return True
try:
from huggingface_hub import HfApi
except ImportError:
logger.error(" huggingface_hub not installed. Run: pip install huggingface_hub")
return False
if not DATASET_DIR.exists():
logger.error(f" Dataset directory not found: {DATASET_DIR}")
return False
parquet_files = list(DATASET_DIR.glob("*.parquet"))
if not parquet_files:
logger.error(" No Parquet files to upload")
return False
# Defence in depth: DATASET_DIR is never cleaned, so a stale parquet from
# before a court joined EXCLUDED_COURTS would still be globbed and pushed.
# FAIL the step rather than skip the file — silently uploading non-CC0
# material under a CC0 licence tag must not look like a successful publish.
from export_parquet import EXCLUDED_COURTS
blocked = sorted(p.stem for p in parquet_files if p.stem in EXCLUDED_COURTS)
if blocked:
logger.error(
" Refusing to upload non-CC0 court(s) to %s: %s. "
"Delete the stale file(s) from %s and re-run.",
HF_REPO_ID, ", ".join(blocked), DATASET_DIR,
)
return False
try:
api = HfApi()
# Upload dataset card
card_path = REPO_DIR / "dataset_card.md"
if card_path.exists():
api.upload_file(
path_or_fileobj=str(card_path),
path_in_repo="README.md",
repo_id=HF_REPO_ID,
repo_type="dataset",
)
logger.info(" Uploaded dataset card")
# Upload Parquet files to data/ directory (batch upload).
# graph/ is EXCLUDED here: its tables have different schemas and a
# nested parquet under data/ would break the HF load_dataset config —
# it gets its own repo path below (P2.4).
logger.info(f" Uploading {len(parquet_files)} Parquet files to data/...")
api.upload_folder(
folder_path=str(DATASET_DIR),
path_in_repo="data",
repo_id=HF_REPO_ID,
repo_type="dataset",
allow_patterns="*.parquet",
ignore_patterns=["graph/*", "structure/*"],
delete_patterns="*.parquet", # prune remote parquet not in local folder
)
logger.info(f" Uploaded {len(parquet_files)} files to {HF_REPO_ID}")
# Aux exports, each to its OWN repo path (a foreign-schema parquet
# under data/ would break the load_dataset config): graph/ = 8.65M
# resolved citation edges + 11.86M statute refs (P2.4); structure/ =
# section metadata + erwaegungen paragraph segmentation (P1.4).
# Own try/except per dir: an aux hiccup must not fail the main
# dataset upload that already succeeded.
for aux in ("graph", "structure"):
aux_dir = DATASET_DIR / aux
if aux_dir.exists() and list(aux_dir.glob("*.parquet")):
try:
api.upload_folder(
folder_path=str(aux_dir),
path_in_repo=aux,
repo_id=HF_REPO_ID,
repo_type="dataset",
allow_patterns="*.parquet",
delete_patterns="*.parquet",
)
logger.info(f" Uploaded aux parquet to {aux}/")
except Exception as e:
logger.error(f" {aux}/ upload failed (main dataset upload unaffected): {e}")
return True
except Exception as e:
logger.error(f" HuggingFace upload failed: {e}")
return False
def step_2f_build_materialien(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2f: Rebuild materialien.db (Botschaft refs + digests + debates)."""
logger.info("Step 2f: Build materialien.db")
script = REPO_DIR / "search_stack" / "build_materialien_db.py"
if not script.exists():
logger.info(" build_materialien_db.py not found, skipping")
return True
materialien_dir = REPO_DIR / "data" / "materialien"
if not materialien_dir.exists():
logger.info(" data/materialien/ not found, skipping")
return True
return run_cmd(
[sys.executable, "-m", "search_stack.build_materialien_db",
"--input-dir", str(materialien_dir)],
"Build materialien.db",
dry_run,
timeout=600,
)
def step_2h_build_legal_scholarship(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2h: Rebuild legal_scholarship.db (OA Swiss legal publications).
Runs WEEKLY on Sunday by default — academic publications + commentaries
don't change at caselaw cadence, and once university IRs + e-periodica
are activated the OAI-PMH walks will harvest tens of thousands of
records (multi-hour). Sunday-gating keeps the nightly publish lean
while still keeping the corpus fresh.
Override with OCL_PUBLISH_SCHOLARSHIP_WEEKDAY (0=Mon … 6=Sun;
-1 = any day, for ad-hoc catch-up runs).
Steps when the gate is open:
1. Harvest all active OA scholarship sources via OAI-PMH
2. Build the unified FTS5 DB (atomic swap) re-exporting commentaries
"""
logger.info("Step 2h: Build legal_scholarship.db")
try:
target_weekday = int(
os.environ.get("OCL_PUBLISH_SCHOLARSHIP_WEEKDAY", "6")
)
except ValueError:
target_weekday = 6
if target_weekday >= 0:
today = datetime.now(timezone.utc).weekday()
if today != target_weekday:
logger.info(
" weekday=%d ≠ target=%d; skipping (scholarship rebuilds weekly on Sunday)",
today, target_weekday,
)
return True
builder = REPO_DIR / "search_stack" / "build_legal_scholarship.py"
if not builder.exists():
logger.info(" build_legal_scholarship.py not found, skipping")
return True
harvest_ok = run_cmd(
[sys.executable, "-m", "scrapers.scholarship.harvest_all"],
"Harvest OA legal scholarship sources",
dry_run,
timeout=3600,
)
if not harvest_ok:
logger.warning(" scholarship harvest failed; building from existing JSONL only")
return run_cmd(
[sys.executable, "-m", "search_stack.build_legal_scholarship"],
"Build legal_scholarship.db",
dry_run,
timeout=900,
)
def _ensure_representation_manifest(dry_run: bool = False) -> None:
"""Rebuild the cross-identifier representation manifest against the freshly
swapped decisions.db so generate_stats can emit a generation-matched
unique-decision count.
Read-only w.r.t. serving: it writes ONLY output/representation_manifest.db (a
sidecar nothing serves from yet) and never touches decisions.db. Fully
failure-tolerant: on any failure generate_stats omits the unique block (it
treats an absent or generation-mismatched sidecar gracefully). ~10 min on the
full corpus; runs here because step 5a is post-swap (final DB, stable inode)."""
script = REPO_DIR / "scripts" / "build_representation_manifest.py"
if not script.exists():
logger.warning(" build_representation_manifest.py not found; skipping dual-count")
return
ok = run_cmd(
[sys.executable, str(script)],
"Rebuild representation manifest (cross-identifier dual-count)",
dry_run,
timeout=1800, # 30 min hard cap (build is ~10 min)
stall_timeout=None, # one long silent scan phase; the wall-clock cap suffices
)
if not ok:
logger.warning(" representation manifest rebuild failed (non-fatal); "
"stats.json will omit or mark-stale the unique count")
def step_5_generate_stats(dry_run: bool = False) -> bool:
"""Step 5: Generate stats.json from database."""
logger.info("Step 5: Generate stats.json")
# Build the dual-count sidecar first (non-fatal) so stats.json carries a
# generation-matched unique-decision estimate alongside the record count.
_ensure_representation_manifest(dry_run)
script = REPO_DIR / "generate_stats.py"
if not script.exists():
logger.error(" generate_stats.py not found")
return False
return run_cmd(
[sys.executable, str(script),
"--db", str(DB_PATH),
"--output", str(DOCS_DIR / "stats.json"),
# interesting_stats is heavy (full scans on decisions.db +
# reference_graph.db). The early-tier run skips it; Step 5e
# below recomputes JUST the interesting_stats block AFTER
# Step 2c rebuilds reference_graph, so the dashboard isn't
# showing fresh corpus counts paired with last-week's graph
# numbers (caught in 2026-05-16 code review).
"--no-interesting-stats"],
"Generate stats",
dry_run,
)
def step_5e_interesting_stats(dry_run: bool = False) -> bool:
"""Step 5e: Recompute stats.json with FRESH reference_graph counts
AFTER Step 2c rebuilds reference_graph.db.
The early Step 5a runs BEFORE reference_graph rebuild and writes
docs/stats.json with the previous build's citation/statute edges
in the *corpus* block (collect_corpus_stats reads reference_graph.db).
The dashboard reads ``stats.corpus.citation_edges`` and
``stats.corpus.statute_edges`` for the "Graph & doctrine" card —
so until this step runs, those numbers can lag a full nightly.
A FULL re-run (no --interesting-stats-only / --no-interesting-stats
flags) recomputes BOTH the corpus block (with fresh graph counts)
AND the interesting_stats block (top-cited / most-cited statute /
graph_size). The 2026-05-16-evening fix using --interesting-stats-only
refreshed the wrong block — caught in code review same day.
Non-fatal: on failure the dashboard keeps whatever Step 5a wrote