File size: 6,897 Bytes
1d9bd9b | 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 | #!/usr/bin/env python3
"""Backfill synthetic headnotes for the 1970s-80s crater (~5,900 docs).
Reporter headnote coverage collapses to 1.6% (1970s) / 3.8% (1980s) — exactly the
golden era of constitutional doctrine. Downstream, the skim gate and judge ground on
`held`-or-first-pages, so crater docs present a cover page and silently exit the
pipeline. This writes an LLM-drafted issue/held in reporter register from each
judgment's own text, marked synthetic:true (the UI must disclose it).
Selection: held_len < 200 AND decision_year in [1965, 1995] (from corpus_ledger.jsonl).
Model: deepseek-v4-flash (non-thinking pinned), temperature 0. ~$6-7 for the full set. Requires DEEPSEEK_API_KEY in phase1/scripts/.env
or the environment.
Run: python phase1/scripts/backfill_headnotes.py [data_dir] [--dry-run N] [--limit N]
Out: <data_dir>/synthetic_headnotes.jsonl {doc_id, issue, held, synthetic: true}
(appends; already-done doc_ids are skipped -> resumable)
After: re-run build_held_vectors.py to fold synthetic helds into the HELD arm.
"""
import json, os, re, sys, time
data_dir = next((a for a in sys.argv[1:] if not a.startswith("--")), None) \
or os.environ.get("THEMIS_DATA", "phase1/data/thor_artifacts")
DRY = 0
if "--dry-run" in sys.argv:
i = sys.argv.index("--dry-run"); DRY = int(sys.argv[i + 1]) if len(sys.argv) > i + 1 else 3
LIMIT = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else None
MODEL = os.environ.get("THEMIS_LLM_MODEL", "deepseek-v4-flash") # deepseek-chat alias dies 2026-07-24
SYS = ("You are a Supreme Court of India law reporter writing an eSCR-style headnote from the "
"judgment text supplied. Output STRICT JSON: {\"issue\": \"...\", \"held\": \"...\"}. "
"'issue' = the question(s) of law before the Court, 1-3 sentences. 'held' = what the Court "
"decided and its reasoning, 150-400 words, neutral reporter register, past tense "
"(\"Held: ...\"), naming doctrines and provisions precisely. Use ONLY the supplied text; "
"if it is insufficient, output {\"issue\": \"\", \"held\": \"\"}.")
def select_docs():
ledger = {}
for line in open(os.path.join(data_dir, "corpus_ledger.jsonl"), encoding="utf-8"):
r = json.loads(line); ledger[r["doc_id"]] = r
picks = [d for d, r in ledger.items()
if (r.get("held_len") or 0) < 200
and r.get("decision_year") and 1965 <= r["decision_year"] <= 1995
and r.get("text_health") == "ok"]
return picks
def doc_text(doc_ids):
"""Front ~10 pages; for LONG judgments also the tail — the operative holding of a
200-page judgment (e.g. Bachan Singh's 'rarest of rare' at para 209) lives at the
END and a front-only summary misses the doctrine the case is famous for."""
want = set(doc_ids); front = {d: [] for d in doc_ids}; tail = {d: [] for d in doc_ids}
nch = {d: 0 for d in doc_ids}
pat = re.compile(r'"doc_id":\s*"([^"]+)"')
for line in open(os.path.join(data_dir, "escr_chunks.jsonl"), encoding="utf-8"):
d = pat.search(line[:120]).group(1)
if d in want:
t = json.loads(line)["text"]; nch[d] += 1
if len(front[d]) < 10: front[d].append(t)
tail[d].append(t)
if len(tail[d]) > 5: tail[d].pop(0) # rolling last-5 window
out = {}
for d in doc_ids:
if nch[d] > 50: # long doc: head + tail pack
out[d] = (" ".join(front[d])[:10000] + "\n[... middle omitted ...]\n"
+ " ".join(tail[d])[:5500])
else:
out[d] = " ".join(front[d] + [c for c in tail[d] if c not in front[d]])[:16000]
return out
def main():
picks = select_docs()
outp = os.path.join(data_dir, "synthetic_headnotes.jsonl")
done = set()
if os.path.exists(outp):
for line in open(outp, encoding="utf-8"):
done.add(json.loads(line)["doc_id"])
todo = [d for d in picks if d not in done]
if LIMIT: todo = todo[:LIMIT]
print(f"[backfill] crater docs: {len(picks)} | done: {len(done)} | todo: {len(todo)}", flush=True)
if DRY:
texts = doc_text(todo[:DRY])
for d in todo[:DRY]:
print(f"\n--- DRY {d} --- prompt head:\n{texts.get(d,'')[:500]}", flush=True)
print(f"\n[backfill] dry-run only ({DRY} docs shown); no API calls made.", flush=True)
return
key = os.environ.get("DEEPSEEK_API_KEY", "")
if not key:
env = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.exists(env):
for l in open(env):
if l.startswith("DEEPSEEK_API_KEY="): key = l.split("=", 1)[1].strip()
if not key:
sys.exit("[backfill] DEEPSEEK_API_KEY missing (env or phase1/scripts/.env) — aborting.")
import threading
from concurrent.futures import ThreadPoolExecutor
import requests
WORKERS = int(os.environ.get("THEMIS_BACKFILL_WORKERS", "8"))
lock = threading.Lock()
done_n = [0, 0] # ok, skip
def one(d, t):
try:
r = requests.post("https://api.deepseek.com/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={"model": MODEL, "temperature": 0,
"thinking": {"type": "disabled"},
"response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": SYS},
{"role": "user", "content": t}]},
timeout=180)
j = json.loads(r.json()["choices"][0]["message"]["content"])
if len(j.get("held") or "") > 100:
row = json.dumps({"doc_id": d, "issue": j.get("issue", ""),
"held": j["held"], "synthetic": True, "model": MODEL},
ensure_ascii=False)
with lock:
f.write(row + "\n"); f.flush(); done_n[0] += 1
return
except Exception as e:
print(f"[backfill] {d}: {e}", flush=True); time.sleep(2)
with lock:
done_n[1] += 1
B = 200
t0 = time.time()
with open(outp, "a", encoding="utf-8") as f:
for s in range(0, len(todo), B):
batch = todo[s:s + B]
texts = doc_text(batch) # one chunks-file pass per 200 docs
jobs = [(d, texts[d]) for d in batch if len(texts.get(d, "")) >= 1500]
with ThreadPoolExecutor(WORKERS) as ex:
list(ex.map(lambda a: one(*a), jobs))
el = time.time() - t0
print(f"[backfill] {min(s+B,len(todo))}/{len(todo)} ok={done_n[0]} skip={done_n[1]} "
f"({done_n[0]/el*3600:.0f}/h)", flush=True)
if __name__ == "__main__":
main()
|