Repository navigation
Expand file tree
/
Copy pathbuild_fts5.py
More file actions
2965 lines (2636 loc) · 128 KB
/
Copy pathbuild_fts5.py
File metadata and controls
2965 lines (2636 loc) · 128 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
"""
Build/update SQLite FTS5 search database from scraped decisions.
Reads from:
- output/decisions/*.jsonl (from run_scraper.py — full Decision objects)
- output/data/daily/*.parquet (from pipeline.py — Parquet shards)
Produces:
- output/decisions.db (SQLite with FTS5 full-text search)
The DB schema matches what mcp_server.py expects.
Usage:
python3 build_fts5.py # default: ./output
python3 build_fts5.py --output /opt/caselaw/repo/output
python3 build_fts5.py --output ./output --db ~/.swiss-caselaw/decisions.db
python3 build_fts5.py --watch 60 # rebuild every 60 seconds
"""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import logging
import os
import re
import sqlite3
import time
from collections import defaultdict
from datetime import date, datetime, timezone
from pathlib import Path
from db_schema import COVERAGE_SCHEMA_SQL, INSERT_COLUMNS, INSERT_OR_IGNORE_SQL, SCHEMA_SQL
from models import make_canonical_key
logger = logging.getLogger("build_fts5")
# Pre-swap safety gate: a freshly-built temp DB must retain at least this
# fraction of the live DB's row count before it can atomically replace it.
# Guards against an empty / partial / corrupt build (the 2026-05 ENOSPC +
# WAL-corruption incidents produced near-empty .tmp builds) silently
# swapping over a healthy 990k-row production corpus. The corpus only grows,
# so a >5% drop is always a bug; the post-swap zero-row check is too late —
# workers serve the swapped inode immediately.
SWAP_MIN_RETAIN_FRACTION = 0.95
# Per-court pre-swap gate. The GLOBAL gate above is blind to a per-court
# collapse: the SG alphabetical dedup-collision class (es_sg_publikationen
# winning INSERT OR IGNORE over the direct sg_* shards — see
# memory/sg_anomaly_root_cause_2026_04_30.md) once dropped 89-92% of
# individual chambers while netting only ~-181 rows corpus-wide, invisible to
# a 5% global floor (5% of ~990k ≈ 49.5k rows). Enforce a per-court retain
# fraction, but only on courts large enough that a big proportional drop is
# unambiguously a bug (the sub-50-row micro-courts like zh_mietgericht=1 would
# false-trip a bare percentage). Calibration 2026-06-16: across the last 10
# published snapshots the largest legitimate per-court drop on a court ≥500
# rows was -0.0% (bger -20), so 0.80 has zero false-trip history; and an
# aborted swap fails SAFE (workers keep serving the last-good DB). Coverage
# verified 2026-06-16: the SG-collision chambers (sg_publikationen 643,
# sg_kantonsgericht 1,077, sg_verwaltungsrekurskommission 1,173) all sit ≥500,
# so the floor catches the motivating incident; the 45 sub-500 micro-courts
# (~4,917 rows, 0.5% of corpus) are left to the global gate by design.
PER_COURT_MIN_RETAIN_FRACTION = 0.80
PER_COURT_MIN_SIZE = 500
# Courts the per-court gate does not guard. zh_gerichte is not a court: it is
# the bucket the entscheidsuche copies of Zürich rulings fall into when no
# court is known. Each of its rows is a copy the direct gerichte-zh.ch scrape
# has, or will have, under the court that decided, so the bucket is meant to
# drain (1,438 rows on 2026-10-02; the backfill of that day takes it below
# 80% in one night). OCL_SWAP_GATE_EXEMPT adds courts for a run
# (comma-separated) without switching the gate off for everything else.
PER_COURT_GATE_EXEMPT = frozenset({"zh_gerichte"})
def _per_court_gate_exempt() -> frozenset:
"""PER_COURT_GATE_EXEMPT (permanent) union OCL_SWAP_GATE_EXEMPT (one run,
comma-separated, case-insensitive)."""
extra = os.environ.get("OCL_SWAP_GATE_EXEMPT", "")
return PER_COURT_GATE_EXEMPT | {c.strip().lower() for c in extra.split(",") if c.strip()}
def _date_inversion_guard_inline(decision_date, publication_date):
"""Gross date-inversion guard: a court cannot publish a decision MORE THAN
A MONTH before it rules. When publication_date is >31 days before
decision_date, the publication_date is a mislabel or date-parse error —
NULL it (decision_date, the header ruling date, is the trusted/mandatory
field; publication_date is optional). Only the GROSS band (>31 days) is
corrected — the 0-3 day band may be a dispatch/Versanddatum and is left
intact. Idempotent + self-healing on every full rebuild. See
quality/checks/dates.py::check_publication_before_decision."""
if not decision_date or not publication_date:
return publication_date
try:
d = date.fromisoformat(str(decision_date)[:10])
p = date.fromisoformat(str(publication_date)[:10])
except ValueError:
return publication_date
if (p - d).days < -31:
return None
return publication_date
def _check_swap_row_gate(new_count: int, old_count: int,
fraction: float = SWAP_MIN_RETAIN_FRACTION) -> None:
"""Raise RuntimeError if swapping a temp DB with ``new_count`` rows over a
live DB with ``old_count`` rows would shrink the corpus below ``fraction``
of its current size. No-op when there is no readable live DB yet
(old_count <= 0). Set OCL_SKIP_SWAP_GATE=1 to force an intentional large
shrink (e.g. a deliberate mass purge)."""
if old_count <= 0:
return
if os.environ.get("OCL_SKIP_SWAP_GATE") == "1":
logger.warning(
"pre-swap row gate OVERRIDDEN via OCL_SKIP_SWAP_GATE "
"(new=%d, live=%d)", new_count, old_count)
return
if new_count < old_count * fraction:
raise RuntimeError(
f"pre-swap gate: refusing to swap — new build has {new_count:,} "
f"rows = {new_count / old_count:.1%} of live {old_count:,} "
f"(< {fraction:.0%}). Live DB left untouched; temp DB kept for "
f"inspection. Set OCL_SKIP_SWAP_GATE=1 to force an intentional "
f"shrink."
)
def _norm_docket(docket) -> str:
return re.sub(r"[^0-9A-Z]", "", str(docket or "").upper())
def _fold_dockets(court, docket, docket2) -> set:
"""The docket keys a row can be folded under: its normalised docket_number and
the key _cross_court_dedup groups by (decision number for
DECISION_NUMBER_COURTS, TG without the "Nr." noise)."""
keys = {_norm_docket(docket)}
key = _norm_docket(_dedup_docket({"court": court, "docket_number": docket,
"docket_number_2": docket2}))
keys.add(key)
if str(court or "").startswith("tg_"):
keys.add(re.sub(r"NR(?=\d)", "", key))
keys.discard("")
return keys
def _rows_answered_by_alias(new_db: Path, live_db: Path, court: str) -> int:
"""Live rows of ``court`` that the new build no longer holds under their own
id but still answers: their id is a ``decision_id_aliases.previous_id`` in the
new build, pointing at a row it holds under the same docket. That is what a
cross-court fold (_record_folded_id: two copies of one ruling, the fuller one
kept) and a re-key (previous_decision_id) leave behind. A row lost the way the
gate was built for (2026-04 SG collision: INSERT OR IGNORE dropped it) has no
alias and is not counted. Read-only on both files; 0 on any error."""
try:
new = sqlite3.connect(f"file:{new_db}?mode=ro&immutable=1", uri=True)
except sqlite3.Error:
return 0
try:
live = sqlite3.connect(f"file:{live_db}?mode=ro&immutable=1", uri=True)
except sqlite3.Error:
new.close()
return 0
answered = 0
try:
for did, docket, docket2 in live.execute(
"SELECT decision_id, docket_number, docket_number_2 FROM decisions WHERE court = ?",
(court,)):
if new.execute("SELECT 1 FROM decisions WHERE decision_id = ?", (did,)).fetchone():
continue
row = new.execute(
"SELECT d.court, d.docket_number, d.docket_number_2 FROM decision_id_aliases a "
"JOIN decisions d ON d.decision_id = a.decision_id WHERE a.previous_id = ?",
(did,)).fetchone()
if row and _fold_dockets(court, docket, docket2) & _fold_dockets(*row):
answered += 1
except sqlite3.Error as e:
logger.warning("per-court gate: alias check for %s failed (%s); counting none", court, e)
return 0
finally:
new.close()
live.close()
return answered
def _check_swap_per_court_gate(
new_by_court: dict, live_by_court: dict,
fraction: float = PER_COURT_MIN_RETAIN_FRACTION,
min_live_rows: int = PER_COURT_MIN_SIZE,
answered_by_alias=None) -> None:
"""Raise RuntimeError if ANY court with >= ``min_live_rows`` live rows would
shrink below ``fraction`` of its live count in the new build. Catches a
per-court collapse (the SG dedup-collision class) that the global
_check_swap_row_gate cannot see. No-op when there is no readable live DB yet
(``live_by_court`` empty), mirroring the global gate's old_count<=0
short-circuit. Honours the SAME OCL_SKIP_SWAP_GATE=1 escape hatch — a
deliberate court retirement/rename trips this by design and is the intended
operator-override case. New courts (present in new_by_court, absent from
live_by_court) are correctly ignored: the loop iterates over live_by_court.
``answered_by_alias(court) -> int``, asked only for a court below the floor,
counts its live rows the new build still answers through an alias under the
same docket (see _rows_answered_by_alias). Those are folded or re-keyed, not
lost: 2026-10-09 the St. Gallen twin dates let the cross-court dedup fold
1,152 short sg_gerichte copies into their full-text twins, and the gate
refused the swap (3,141 -> 1,989) although every one still resolved.
Targeted override (2026-09-11): OCL_SWAP_GATE_EXEMPT is a comma-separated,
case-insensitive list of court codes whose shrink is intentional for this
run (a court retirement, e.g. the entscheidsuche CH_VB feed being dropped).
Only those courts are skipped; every other court and the global row gate
stay armed. Prefer this over OCL_SKIP_SWAP_GATE=1, which disarms BOTH gates
for the whole run and would hide an unrelated collapse on the same night."""
if not live_by_court:
return
if os.environ.get("OCL_SKIP_SWAP_GATE") == "1":
logger.warning(
"pre-swap per-court gate OVERRIDDEN via OCL_SKIP_SWAP_GATE")
return
exempt = _per_court_gate_exempt()
env_exempt = exempt - PER_COURT_GATE_EXEMPT
if env_exempt:
logger.warning(
"pre-swap per-court gate: exempting %s for this run via "
"OCL_SWAP_GATE_EXEMPT", ",".join(sorted(env_exempt)))
for code in sorted(env_exempt - set(live_by_court)):
logger.warning(
"pre-swap per-court gate: exempt court %r is not in the live "
"DB — check the spelling, or remove the leftover exemption "
"from .env.publish if its retirement already swapped", code)
for court, live_n in sorted(live_by_court.items()):
if live_n < min_live_rows:
continue
new_n = new_by_court.get(court, 0)
if court in exempt:
if new_n < live_n * fraction:
logger.warning("pre-swap per-court gate: %s %d → %d rows, exempt",
court, live_n, new_n)
continue
if new_n < live_n * fraction:
folded = answered_by_alias(court) if answered_by_alias else 0
if folded and new_n + folded >= live_n * fraction:
logger.warning(
"pre-swap per-court gate: %s %d → %d rows, %d more answered through "
"an alias under the same docket (folded or re-keyed, not lost); "
"passes at %.1f%%", court, live_n, new_n, folded,
100 * (new_n + folded) / live_n)
continue
raise RuntimeError(
f"pre-swap per-court gate: refusing to swap — court "
f"{court!r} collapsed {live_n:,} → {new_n:,} rows "
f"(+{folded:,} answered through an alias) "
f"({(new_n / live_n) if live_n else 0:.1%} of live, "
f"< {fraction:.0%}). Live DB left untouched; temp DB kept for "
f"inspection. For an intentional per-court shrink (e.g. a court "
f"retirement) set OCL_SWAP_GATE_EXEMPT={court} for one run; "
f"OCL_SKIP_SWAP_GATE=1 is the last resort (it disarms every gate)."
)
# ── Per-phase timing instrumentation ──────────────────────────
#
# Step 2 of publish.py is a 4–10 h black-box. Without per-phase
# timings we can't tell whether the variance comes from FTS5 optimize,
# hash computation, or one of the 13 normalisation passes. The context
# manager below logs:
#
# Phase: Deduplicating decisions...
# (existing inner logger.info lines from the phase body)
# -> Deduplicating decisions done in 27m 13s
#
# and ``_log_phase_summary()`` at end-of-build prints a sorted
# breakdown like:
#
# === build_fts5 phase summary ===
# 45m 02s ( 51.8%) FTS5 optimize
# 27m 13s ( 31.3%) Deduplicating decisions
# ...
#
# State is module-scoped (ok for a one-shot rebuild script). On a
# crash, the surviving timings still print via the finally clause in
# the context manager.
_PHASE_TIMINGS: list[tuple[str, float]] = []
def _fmt_dur(seconds: float) -> str:
"""Human-friendly duration: '12.3s' / '4m 27s' / '1h 14m'."""
if seconds < 60:
return f"{seconds:.1f}s"
m, s = divmod(int(seconds), 60)
if m < 60:
return f"{m}m {s:02d}s"
h, m = divmod(m, 60)
return f"{h}h {m:02d}m"
def _early_stats_push_enabled() -> bool:
"""True only when the caller asked for the early dashboard push (publish.py Step 2 does)."""
return os.environ.get("OCL_EARLY_STATS_PUSH", "0").strip().lower() in {"1", "true", "yes"}
def _spawn_early_stats_push(swapped_db: Path) -> None:
"""Fire-and-forget: regenerate docs/stats.json + git push so the
public dashboard reflects the freshly-swapped FTS5 DB within ~5 min
of the atomic swap, instead of waiting for the post-swap
integrity check (60 GB DB → ~3 h under ionice idle) and the rest
of the slow tier (graph rebuild, materialien, parquet, HF upload)
to complete.
The dashboard is a static GitHub Pages site that reads
docs/stats.json from the main branch. As soon as we push, GitHub
Pages picks up the change in ~30–90 s.
Failure is non-fatal: the publish.py final Step 5/6 always runs
at the end with full graph aggregations and will overwrite this
early commit's stats.json.
"""
import subprocess
import sys
repo_dir = Path(__file__).resolve().parent
stats_script = repo_dir / "generate_stats.py"
stats_out = repo_dir / "docs" / "stats.json"
log_path = "/var/log/early_stats_push.log"
# Bash: regen → stage → commit (--allow-empty so a no-op stats run
# doesn't fail) → push. Each step gates the next via &&. Output
# goes to a dedicated log so it doesn't interleave with publish.log.
bash_cmd = (
f"set -e; "
f"echo '[' $(date -u +%FT%TZ) '] early-stats-push: starting'; "
f"{sys.executable} {stats_script} "
f" --db {swapped_db} "
f" --output {stats_out} "
f" --no-interesting-stats; "
f"cd {repo_dir}; "
f"git pull --rebase origin main 2>&1 | tail -3 || true; "
f"git add {stats_out}; "
f"git diff --cached --quiet && {{ echo 'no stats.json change'; exit 0; }}; "
f"git commit -m 'chore: stats.json refresh (post-swap, pre-finalization)'; "
f"git push origin main; "
f"echo '[' $(date -u +%FT%TZ) '] early-stats-push: done'"
)
try:
log_fd = open(log_path, "ab")
except OSError:
# Fall back to /tmp if /var/log isn't writable (e.g. dev machine)
log_fd = open("/tmp/early_stats_push.log", "ab")
subprocess.Popen(
["bash", "-c", bash_cmd],
stdout=log_fd, stderr=subprocess.STDOUT,
start_new_session=True, # detach from build_fts5's process group
)
logger.info(
" Spawned early-stats-push (fire-and-forget, log: %s) — "
"dashboard will reflect new DB in ~5 min", log_path
)
@contextlib.contextmanager
def _phase_timer(name: str):
"""Log start, append timing, log end. Use as `with _phase_timer("X"):`.
Existing `logger.info("X...")` calls inside phase bodies stay; the
context manager prefixes its own "Phase:" / "->" lines around them
so the trace remains readable both live and post-mortem.
"""
t0 = time.monotonic()
logger.info(f"Phase: {name}...")
try:
yield
finally:
dt = time.monotonic() - t0
_PHASE_TIMINGS.append((name, dt))
logger.info(f" -> {name} done in {_fmt_dur(dt)}")
def _log_phase_summary() -> None:
"""Sorted summary of every recorded phase. Cheap (<1ms)."""
if not _PHASE_TIMINGS:
return
total = sum(d for _, d in _PHASE_TIMINGS)
if total < 1:
return
logger.info("=== build_fts5 phase summary ===")
for name, dt in sorted(_PHASE_TIMINGS, key=lambda x: -x[1]):
pct = 100 * dt / total
logger.info(f" {_fmt_dur(dt):>10} ({pct:>5.1f}%) {name}")
logger.info(f" {_fmt_dur(total):>10} (sum of phases)")
# ── Text cleaning ────────────────────────────────────────────
# Bounded and newline-anchored — 2026-08-25. The previous form was
# `<[^>]+>`, and `[^>]` matches newlines: in OCR'd text a single stray "<"
# swallowed everything up to the next ">", across thousands of characters
# and many lines, and replaced it with one space. Measured against the
# source shards before the fix:
# bge_historical_14_I_192 12,159 -> 2,317 chars served (80.9% lost)
# bge_historical_11_I_5 13,081 -> 6,585 (49.7% lost)
# bge_historical_15_I_79 13,004 -> 8,645 (33.5% lost)
# Each of those files contained 1-6 stray "<" and NO real HTML at all; the
# "<" are scanner misreads — "1<r" is OCR for "1er". In force since
# f3a087f7 (2026-02-22), and _clean_text is applied to full_text, regeste
# AND title, so headnotes were truncated too.
#
# `[^>\n]` stops a run at the first newline; `{1,200}` bounds it to a
# plausible tag length. Both narrow what is deleted, so this can only ever
# preserve MORE text than before — it cannot lose anything that survives
# today. Real tags (<b>, <span class="x">, </p>) are still stripped.
_HTML_TAG_RE = re.compile(r"<[^>\n]{1,200}>")
_MULTI_SPACE_RE = re.compile(r"[ \t]+")
_HTML_ENTITIES = {
" ": " ",
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
"'": "'",
}
def _fix_mojibake(text: str) -> str:
"""Fix double-encoded UTF-8 (UTF-8 bytes decoded as Latin-1).
Common pattern: 'ä' (U+00E4) stored as 'ä' (C3 A4 decoded as Latin-1).
"""
try:
# If the text contains typical mojibake sequences, try to fix
fixed = text.encode("latin-1").decode("utf-8")
# Sanity check: the fixed version should be shorter or equal
if len(fixed) <= len(text):
return fixed
except (UnicodeDecodeError, UnicodeEncodeError):
pass
return text
# C0 control chars except tab/newline/CR. An embedded NUL (\x00) in Omnis/
# Findinfo portal text (ti/ne/ge/so) makes SQLite's length() stop at the NUL, so
# the structure builder's `WHERE length(full_text) >= 500` filter silently drops
# the row (and the VPS's older sqlite3 truncates the read outright) — only the
# ~500-char header survived, zeroing decision_structure Erwaegungen extraction
# and costing search recall. Proven 2026-06-29 on ti_gerichte_34.2024.28
# (NUL at offset 498; length()=495 vs the JSONL shard's 77,544 chars).
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def _clean_text(text: str | None) -> str | None:
"""Strip control chars + HTML tags, fix entities/mojibake, normalize whitespace."""
if not text:
return text
# Remove control chars first so a NUL can't truncate the body downstream.
text = _CONTROL_CHARS_RE.sub("", text)
# Strip HTML tags
text = _HTML_TAG_RE.sub(" ", text)
# Replace HTML entities
for entity, replacement in _HTML_ENTITIES.items():
if entity in text:
text = text.replace(entity, replacement)
# Fix mojibake (only if likely — check for common mojibake markers)
if "\xc3" in text:
text = _fix_mojibake(text)
# Normalize whitespace (collapse runs of spaces/tabs, preserve newlines)
text = _MULTI_SPACE_RE.sub(" ", text)
return text.strip()
# ── BGer regeste extraction ──────────────────────────────────
_REGESTE_START_RE = re.compile(
r"(?:^|\n)\s*Regeste\b[:\s]*\n",
re.IGNORECASE,
)
_REGESTE_END_MARKERS = [
re.compile(r"^\s*Sachverhalt\b", re.IGNORECASE | re.MULTILINE),
re.compile(r"^\s*Faits\b", re.IGNORECASE | re.MULTILINE),
re.compile(r"^\s*Fatti\b", re.IGNORECASE | re.MULTILINE),
re.compile(r"^\s*Urteilskopf\b", re.IGNORECASE | re.MULTILINE),
re.compile(r"^\s*A\.\s", re.MULTILINE),
]
def _extract_regeste_from_text(full_text: str) -> str | None:
"""Try to extract regeste from BGE/BGer full_text.
BGE decisions typically contain:
Regeste
<regeste text in DE>
Regeste (or Regesto)
<regeste in FR/IT>
Sachverhalt / Faits / Fatti / A.
"""
m = _REGESTE_START_RE.search(full_text)
if not m:
return None
start = m.end()
# Find the end: next major section header
end = len(full_text)
for pat in _REGESTE_END_MARKERS:
em = pat.search(full_text, start)
if em and em.start() < end:
end = em.start()
regeste = full_text[start:end].strip()
# Skip if too short or too long (probably a false match)
if len(regeste) < 20 or len(regeste) > 5000:
return None
return regeste
# ── Dedup + post-processing ──────────────────────────────────
_DEDUP_HTML_RE = re.compile(r"<[^>]+>")
_DEDUP_NONALNUM_RE = re.compile(r"[^a-z0-9]+")
def _norm_for_dedup(full_text) -> str:
"""Normalized content signature for the date-agnostic dedup pass.
Strips HTML tags, lowercases, drops all non-alphanumerics, caps length. Two
imports of the SAME decision (html vs plaintext rendering, es vs direct,
or a truncated copy) collapse to the same string or a prefix of it; two
GENUINELY DISTINCT decisions on the same docket (Zwischenentscheid vs
Endentscheid) diverge early. Used to decide whether a same-court+docket,
different-date row is a true duplicate (delete) or a distinct decision (keep).
"""
t = _DEDUP_HTML_RE.sub(" ", (full_text or "")[:8000])
return _DEDUP_NONALNUM_RE.sub("", t.lower())[:4000]
def _build_docket_aliases(conn: sqlite3.Connection) -> int:
"""Populate decision_docket_aliases from consolidated federal captions (#41).
For every court='bger' decision, extract the joined (secondary) dockets from
its caption and map each to the lead decision_id, so a lookup by any joined
docket resolves to the consolidated decision. Skips any alias that is already
a primary docket_number: such a docket resolves directly, and mapping it to
the consolidation would be ambiguous (covers revision references and
reciprocal consolidations). Fully rebuilt each run (a few thousand rows);
additive and non-fatal — never fails the build.
"""
import docket_aliases
conn.execute("DELETE FROM decision_docket_aliases")
# Every primary docket key; an alias colliding with one is skipped so a real
# lead decision always wins.
primary: set[str] = set()
for (dn,) in conn.execute(
"SELECT docket_number FROM decisions "
"WHERE docket_number IS NOT NULL AND docket_number != ''"
):
k = docket_aliases.normalize_docket_key(dn)
if k:
primary.add(k)
rows: list[tuple] = []
for did, dn, head in conn.execute(
"SELECT decision_id, docket_number, substr(full_text, 1, 2500) "
"FROM decisions WHERE court = 'bger'"
):
for alias in docket_aliases.extract_joined_dockets(head, dn):
if alias in primary:
continue
rows.append(("bger", alias, alias, did, "bger_caption_v1"))
if rows:
conn.executemany(
"INSERT OR IGNORE INTO decision_docket_aliases "
"(court, alias_docket, alias_docket_norm, canonical_decision_id, "
" extraction_method) VALUES (?, ?, ?, ?, ?)",
rows,
)
conn.commit()
return len(rows)
def _dedup_decisions(conn: sqlite3.Connection) -> int:
"""Remove duplicate decisions sharing the same canonical_key.
The canonical_key aggressively normalizes court + docket + date so that
formatting variants (dots vs underscores, case, etc.) collapse together.
Falls back to exact (court, docket_number, decision_date) if canonical_key
is not yet populated.
Strategy: keep the row with the most total content (full_text + regeste).
Before deleting losers, merge their regeste into the survivor if the
survivor's regeste is empty.
This avoids the prior bug (2026-04-25 audit) where the rule "non-empty
regeste wins" caused metadata-only federation stubs (~280 chars text +
short regeste) to win over rich direct PDF scrapes (~9,000 chars text,
no regeste). For Tribuna-based scrapers (gr/be/fr/zg/sz) the direct
scrape never extracts regeste from PDF, so the regeste-precedence rule
threw away ~9,300 GR full-PDF rows in favor of metadata stubs.
Returns number of rows deleted.
"""
# Check if canonical_key column exists and is populated
has_canonical = False
try:
row = conn.execute(
"SELECT COUNT(*) FROM decisions WHERE canonical_key IS NOT NULL AND canonical_key != ''"
).fetchone()
has_canonical = row[0] > 0
except sqlite3.OperationalError:
pass
if has_canonical:
# Exclude keys with empty docket part (format: court|DOCKET|date)
dup_sql = """
SELECT canonical_key, COUNT(*) as cnt
FROM decisions
WHERE canonical_key IS NOT NULL AND canonical_key != ''
AND canonical_key NOT LIKE '%||%'
GROUP BY canonical_key
HAVING cnt > 1
"""
groups = conn.execute(dup_sql).fetchall()
# Do NOT early-return when there are no exact-key dups — the
# date-agnostic Pass 2 below must still run independently (P0.1, 2026-07-13).
deleted = 0
for (canonical_key, cnt) in groups:
rows = conn.execute(
"""
SELECT decision_id, COALESCE(regeste, ''), LENGTH(COALESCE(full_text, ''))
FROM decisions
WHERE canonical_key = ?
ORDER BY
LENGTH(COALESCE(full_text, '')) + LENGTH(COALESCE(regeste, '')) DESC
""",
(canonical_key,),
).fetchall()
survivor_id, survivor_regeste, _ = rows[0]
# Backfill regeste from a loser if survivor has none.
if not survivor_regeste:
for _, loser_regeste, _ in rows[1:]:
if loser_regeste:
conn.execute(
"UPDATE decisions SET regeste = ?, content_hash = NULL WHERE decision_id = ?",
(loser_regeste, survivor_id),
)
break
for row in rows[1:]:
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (row[0],))
deleted += 1
else:
# Fallback: exact match on (court, docket_number, decision_date)
dup_sql = """
SELECT court, docket_number, decision_date, COUNT(*) as cnt
FROM decisions
WHERE docket_number IS NOT NULL AND LENGTH(TRIM(docket_number)) > 0
GROUP BY court, docket_number, decision_date
HAVING cnt > 1
"""
groups = conn.execute(dup_sql).fetchall()
# Do NOT early-return when there are no exact-key dups — the
# date-agnostic Pass 2 below must still run independently (P0.1, 2026-07-13).
deleted = 0
for court, docket, date, cnt in groups:
rows = conn.execute(
"""
SELECT decision_id, COALESCE(regeste, ''), LENGTH(COALESCE(full_text, ''))
FROM decisions
WHERE court = ? AND docket_number = ? AND decision_date IS ?
ORDER BY
LENGTH(COALESCE(full_text, '')) + LENGTH(COALESCE(regeste, '')) DESC
""",
(court, docket, date),
).fetchall()
survivor_id, survivor_regeste, _ = rows[0]
if not survivor_regeste:
for _, loser_regeste, _ in rows[1:]:
if loser_regeste:
conn.execute(
"UPDATE decisions SET regeste = ?, content_hash = NULL WHERE decision_id = ?",
(loser_regeste, survivor_id),
)
break
for row in rows[1:]:
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (row[0],))
deleted += 1
# ── Pass 2: date-agnostic dedup — CONTENT-AWARE (2026-07-13, P0.1 fix) ──
# Same court+docket with different dates is EITHER (a) one decision imported
# twice with different date fields (es publication_date vs direct
# decision_date; html vs plaintext rendering) — a true duplicate — OR (b)
# GENUINELY DISTINCT decisions on the same docket decided on different dates
# (Zwischenentscheid + Endentscheid, costs orders, remands). The old pass
# grouped by court|docket ignoring the date and deleted all but the longest,
# silently destroying every (b) case — a mission-critical completeness loss
# (confirmed: e.g. bvger B-93/2007 kept the June interim and deleted the
# December final; ~56k deletions/build, a large fraction genuinely distinct).
# Now a member is deleted ONLY if its normalized, HTML-stripped content is
# identical to, or a truncated prefix of, the survivor's (provably the same
# decision). Distinct decisions on the same docket are kept.
#
# OCL_DATE_AGNOSTIC_DEDUP: "content_aware" (default) | "off" (skip the pass) |
# "legacy" (old destructive behaviour — emergency rollback only).
_da_mode = os.environ.get("OCL_DATE_AGNOSTIC_DEDUP", "content_aware").lower()
deleted2 = 0
if _da_mode != "off":
all_rows = conn.execute(
"SELECT decision_id, canonical_key, LENGTH(COALESCE(full_text, '')), "
"LENGTH(COALESCE(regeste, '')) FROM decisions "
"WHERE canonical_key IS NOT NULL AND canonical_key <> ''"
).fetchall()
groups2 = defaultdict(list)
for did, ckey, tlen, rlen in all_rows:
parts = ckey.split("|")
if len(parts) == 3 and parts[1]:
groups2[f"{parts[0]}|{parts[1]}"].append((did, tlen, rlen))
multi = {k: v for k, v in groups2.items() if len(v) > 1}
if _da_mode == "legacy":
for entries in multi.values():
entries.sort(key=lambda x: -(x[1] + x[2]))
for did, _, _ in entries[1:]:
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (did,))
deleted2 += 1
else: # content_aware (default)
# Normalized content for members of multi-member groups only (fetch a
# bounded prefix, not full blobs, to keep IO/memory low).
member_ids = [did for v in multi.values() for (did, _, _) in v]
norm = {}
for i in range(0, len(member_ids), 800):
chunk = member_ids[i:i + 800]
ph = ",".join("?" * len(chunk))
for did, ft in conn.execute(
f"SELECT decision_id, substr(full_text, 1, 8000) "
f"FROM decisions WHERE decision_id IN ({ph})", chunk,
):
norm[did] = _norm_for_dedup(ft)
kept_distinct = 0
for entries in multi.values():
entries.sort(key=lambda x: -(x[1] + x[2]))
sn = norm.get(entries[0][0], "")
for did, _, _ in entries[1:]:
mn = norm.get(did, "")
short, longn = (mn, sn) if len(mn) <= len(sn) else (sn, mn)
# true duplicate iff the shorter normalized content is a
# substantive prefix of the longer (identical / truncated /
# re-rendered); otherwise it is a distinct decision — KEEP it.
if len(short) >= 200 and longn.startswith(short):
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (did,))
deleted2 += 1
else:
kept_distinct += 1
logger.info(
f" Pass 2 (content-aware): kept {kept_distinct} distinct "
f"same-docket decisions that the legacy pass would have deleted"
)
if deleted2:
logger.info(f" Pass 2 (date-agnostic): removed {deleted2} duplicates")
deleted += deleted2
conn.commit()
return deleted
# Courts whose decisions may overlap across different court codes.
# Entscheidsuche often maps to a generic code (e.g. zh_gerichte) while
# direct scrapers use specific codes (zh_obergericht). Grouping allows
# cross-court dedup within each set.
_COURT_OVERLAP_GROUPS: list[set[str]] = [
# ZH: entscheidsuche → zh_gerichte, direct scraper → sub-courts
{"zh_gerichte", "zh_obergericht", "zh_kassationsgericht", "zh_handelsgericht",
"zh_bezirksgericht_zuerich", "zh_bezirksgericht_winterthur",
"zh_bezirksgericht_horgen", "zh_bezirksgericht_dietikon",
"zh_bezirksgericht_buelach", "zh_bezirksgericht_dielsdorf",
"zh_bezirksgericht_uster", "zh_bezirksgericht_pfaeffikon",
"zh_bezirksgericht_hinwil", "zh_bezirksgericht_meilen",
"zh_bezirksgericht_affoltern", "zh_mietgericht", "zh_arbeitsgericht"},
# SG: entscheidsuche → sg_gerichte, direct scraper (sg_publikationen) → sub-courts
{"sg_gerichte", "sg_publikationen", "sg_versicherungsgericht",
"sg_verwaltungsgericht", "sg_verwaltungsrekurskommission",
"sg_kantonsgericht", "sg_handelsgericht"},
# AG: entscheidsuche → ag_gerichte, direct scraper → sub-courts
{"ag_gerichte", "ag_obergericht", "ag_versicherungsgericht",
"ag_handelsgericht", "ag_spezialverwaltungsgericht",
"ag_strafgericht", "ag_zivilgericht", "ag_verwaltungsgericht",
"ag_anwaltskommission", "ag_aufsichtskommission", "ag_regierungsrat",
"ag_departement_bks", "ag_departement_bvu", "ag_departement_gs",
"ag_departement_vi", "ag_justizgericht", "ag_justizleitung", "ag_weitere"},
# SH: entscheidsuche generic vs specific
{"sh_gerichte", "sh_obergericht"},
# TG: entscheidsuche → tg_obergericht, direct scraper → tg_gerichte
{"tg_gerichte", "tg_obergericht"},
# VD: historical findinfo/omni vs current scraper
{"vd_findinfo", "vd_gerichte", "vd_omni"},
# BS: entscheidsuche → bs_gerichte, direct scraper → sub-courts
{"bs_gerichte", "bs_appellationsgericht", "bs_sozialversicherungsgericht",
"bs_zivilgericht"},
# BE: steuerrekurs overlaps with verwaltungsgericht
{"be_steuerrekurs", "be_verwaltungsgericht"},
]
# Collection buckets inside an overlap group: a code that names no court.
# When such a row and a row filed under the deciding court are the same
# decision, the attributed row is the one to serve. "Longest wins" alone let
# the federation copy win on a few characters of extraction difference: 125
# Zürich rulings were served as "ZH Gerichte" with their direct row deleted
# (measured 2026-10-02 on the production shards; the direct row had a median
# 98% of the copy's content). A copy that is substantially fuller still wins,
# so a truncated direct row never displaces a complete one.
_GENERIC_BUCKET_COURTS = frozenset({"zh_gerichte"})
ATTRIBUTED_ROW_MIN_CONTENT = 0.9
# Build a lookup: court_code → frozenset of group members
_COURT_TO_GROUP: dict[str, frozenset[str]] = {}
for _group in _COURT_OVERLAP_GROUPS:
_frozen = frozenset(_group)
for _code in _group:
_COURT_TO_GROUP[_code] = _frozen
def _record_folded_id(conn: sqlite3.Connection, dropped_id: str, kept_id: str) -> None:
"""Keep the id of a deleted twin resolving to the copy that was kept
(decision_id_aliases, the table re-keyed rows use). A ruling held as both
sg_gerichte_VZ.2004.35 and sg_publikationen_VZ.2004.35 is served once; a
link, attest ledger or dataset row naming the other id must still find it.
Never fails the build (a fixture without the table just skips it)."""
try:
conn.execute(
"INSERT OR IGNORE INTO decision_id_aliases (previous_id, decision_id, source) "
"VALUES (?, ?, 'cross_court_dedup')",
(dropped_id, kept_id),
)
except sqlite3.OperationalError as e:
logger.debug("decision_id_aliases not recorded for %s: %s", dropped_id, e)
def _cross_court_dedup(conn: sqlite3.Connection) -> int:
"""Remove duplicates where the same docket exists under overlapping court codes.
Only matches within explicit court overlap groups (not all courts in a canton).
Keeps the version with the longest full_text.
"""
# Load decisions from courts that belong to overlap groups
overlap_courts = set()
for group in _COURT_OVERLAP_GROUPS:
overlap_courts.update(group)
if not overlap_courts:
return 0
placeholders = ",".join("?" * len(overlap_courts))
rows = conn.execute(
f"SELECT decision_id, court, docket_number, decision_date, "
f"LENGTH(COALESCE(full_text, '')), LENGTH(COALESCE(regeste, '')), "
f"docket_number_2 "
f"FROM decisions "
f"WHERE court IN ({placeholders}) "
f"AND docket_number IS NOT NULL AND LENGTH(TRIM(docket_number)) > 0",
list(overlap_courts),
).fetchall()
# Group by (overlap_group_id, normalized_docket)
groups: dict[str, list[tuple[str, int, int, str]]] = defaultdict(list)
for did, court, docket, date, tlen, rlen, docket2 in rows:
group = _COURT_TO_GROUP.get(court)
if not group:
continue
# BS: several decisions share a case number; the decision number
# (docket_number_2) is the identity, on the direct and the es rows alike.
docket = _dedup_docket({"court": court, "docket_number": docket, "docket_number_2": docket2})
docket_norm = re.sub(r"[^A-Z0-9]", "", (docket or "").upper())
if "tg_gerichte" in group:
docket_norm = re.sub(r"NR(?=\d)", "", docket_norm) # TG "Nr." noise
# Include date to avoid false matches across years
date_compact = (date or "").replace("-", "")[:8]
key = f"{id(group)}|{docket_norm}|{date_compact}"
groups[key].append((did, tlen, rlen, court))
deleted = 0
for entries in groups.values():
if len(entries) < 2:
continue
# Keep version with the most total content (full_text + regeste)
entries.sort(key=lambda x: -(x[1] + x[2]))
if entries[0][3] in _GENERIC_BUCKET_COURTS:
floor = (entries[0][1] + entries[0][2]) * ATTRIBUTED_ROW_MIN_CONTENT
for i, e in enumerate(entries[1:], start=1):
if e[3] not in _GENERIC_BUCKET_COURTS and e[1] + e[2] >= floor:
entries.insert(0, entries.pop(i))
break
for did, _, _, _ in entries[1:]:
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (did,))
_record_folded_id(conn, did, entries[0][0])
deleted += 1
deleted += _generic_bucket_twin_dedup(conn)
if deleted:
conn.commit()
return deleted
# Share of 60-character windows of one text found in the other, both ways, at
# or above which two rows of one docket are the same ruling.
BUCKET_TWIN_MIN_OVERLAP = 0.9
_TWIN_WINDOW = 60
def _same_ruling_text(a: str, b: str) -> bool:
"""Identical after normalisation, or near-identical both ways (two
extractions of one PDF differ by hyphenation and page furniture)."""
if not a or not b:
return False
if a == b:
return True
def share(x: str, y: str) -> float:
wins = [x[i:i + _TWIN_WINDOW] for i in range(0, max(len(x) - _TWIN_WINDOW, 1), _TWIN_WINDOW // 2)]
return sum(1 for w in wins if w in y) / len(wins)
return share(a, b) >= BUCKET_TWIN_MIN_OVERLAP and share(b, a) >= BUCKET_TWIN_MIN_OVERLAP
def _generic_bucket_twin_dedup(conn: sqlite3.Connection) -> int:
"""Delete a generic-bucket row whose text the corpus also holds under the
deciding court and the same docket, whatever the date says.
The date-keyed pass above cannot see these: the federation copy carries
another date than the court's own entry (measured 2026-10-02 on the
production Zürich shards: of 1,180 zh_gerichte rows left after that pass,
1,027 had text identical to a direct row of the same docket and 117
near-identical — every one of them a ruling served twice, once without a
court). The text decides, so a different ruling of the same docket stays.
"""
if not _GENERIC_BUCKET_COURTS:
return 0
marks = ",".join("?" * len(_GENERIC_BUCKET_COURTS))
bucket = conn.execute(
f"SELECT decision_id, court, docket_number, full_text FROM decisions "
f"WHERE court IN ({marks}) AND docket_number IS NOT NULL "
f"AND LENGTH(TRIM(docket_number)) > 0",
list(_GENERIC_BUCKET_COURTS),
).fetchall()
deleted = 0
for did, court, docket, text in bucket:
peers = [c for c in _COURT_TO_GROUP.get(court, ()) if c not in _GENERIC_BUCKET_COURTS]
if not peers:
continue
norm = _norm_for_dedup(text)
if not norm:
continue
pmarks = ",".join("?" * len(peers))
twins = conn.execute(
f"SELECT full_text FROM decisions WHERE docket_number = ? AND court IN ({pmarks})",
[docket, *peers],
).fetchall()
if any(_same_ruling_text(norm, _norm_for_dedup(t[0])) for t in twins):
conn.execute("DELETE FROM decisions WHERE decision_id = ?", (did,))
deleted += 1
if deleted:
logger.info(f" Generic-bucket twins (same docket, same text, other date): removed {deleted}")
return deleted
def _normalize_dockets(conn: sqlite3.Connection) -> int:
"""König audit 2026-04-30 + QC follow-up 2026-05-01: normalise whitespace
in docket_number.
Two passes (idempotent, run on every nightly):
1. Trim leading/trailing whitespace. Found ~21,000 rows in 2026-04-30,
concentrated in zh_verwaltungsgericht (11,359), ch_vb (6,721),
vd_findinfo (2,705), edoeb (45), sh_obergericht (10), and several
AG chambers. Whitespace breaks exact-match queries
(' AEG.2018.00004' won't match 'AEG.2018.00004').
2. Replace internal newlines (LF / CR) with a single space. The QC
gate surfaced 5,441 rows in 2026-05-01 audit where the scraper
grabbed two adjacent table cells and joined them with a newline,
e.g. 'A 2024 015\\nUrteil vom...'. Internal newlines also break
URL routing (/entscheid/{id}) and exact-match queries.
"""
cur = conn.execute(
"UPDATE decisions SET docket_number = trim(docket_number) "
"WHERE docket_number != trim(docket_number)"
)
fixed = cur.rowcount
# Pass 2: collapse internal newlines (and tabs) to single space, then
# collapse multiple spaces to one.
cur2 = conn.execute(
"UPDATE decisions SET docket_number = "
"trim(replace(replace(replace(docket_number, char(10), ' '), "
" char(13), ' '), "
" char(9), ' ')) "
"WHERE docket_number LIKE '%' || char(10) || '%' "
" OR docket_number LIKE '%' || char(13) || '%' "
" OR docket_number LIKE '%' || char(9) || '%'"
)
fixed += cur2.rowcount