File size: 50,385 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
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
"""Moonley Phase-1 fallback serving backend (Thor, bound to the tailnet).

  GET /                       -> v2 frontend (results + judgment views)
  GET /api/search_stream      -> reviewer-IMPROVED retrieval, STREAMED stepwise (SSE):
                                 emits live step events (search -> rerank -> paralegal
                                 review -> drop -> bounded re-query -> good-law) then
                                 streams the grounded answer token-by-token.
  GET /api/search             -> same pipeline, non-streamed (fallback). {answer, results[], steps}
  GET /api/judgment?id=       -> full judgment: metadata + verbatim issue/held + citator +
                                 dark good-law (provenance) + reassembled text
  GET /api/ask_judgment?id=&q= -> grounded Q&A over a single judgment

Retrieval: dense (BGE bf16) + cross-encoder rerank. Answer / paralegal-review / ask:
The answer model uses the key in the local service environment. The local Qwen model is reserved
for the citator Tier-2 batch (whole-doc treatment), NOT serving. Good-law: DARK
(overruled/doubted/per_incuriam/unknown).
Run:  uvicorn serve:app --host 0.0.0.0 --port 8000
"""
import json, os, re
from collections import defaultdict, Counter
import numpy as np, torch, requests
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
from sentence_transformers import SentenceTransformer, CrossEncoder

HERE = os.path.dirname(os.path.abspath(__file__))
DEV = "cuda" if torch.cuda.is_available() else "cpu"
CAND, BGE_Q = 40, "Represent this sentence for searching relevant passages: "

# ============================ PILOT LOGGING ============================
# Pilot stage (George, 2026-06): capture as much as we can to improve the product for lawyers.
# Privacy gating is intentionally OFF — the ONLY hard rule is that secrets (the DeepSeek API key /
# Clerk bearer token) must NEVER land in a log. Two append-only JSONL streams on Thor, joined by req_id:
#   usage-YYYY-MM-DD.jsonl     one line per user-facing request (who ran what, RAW query, judgments opened)
#   internal-YYYY-MM-DD.jsonl  per-DeepSeek-call FULL prompt+response + latency/tokens, plus surfaced errors
# Logging is fire-and-forget on a daemon thread: it never blocks the SSE stream and can never crash a request.
import threading, queue, uuid, time, contextvars
from contextlib import contextmanager
from datetime import datetime, timezone

LOG_DIR   = os.environ.get("MOONLEY_LOG_DIR") or os.environ.get("THEMIS_LOG_DIR") or os.path.join(os.path.expanduser("~"), "moonley", "logs")
LOG_FULL  = os.environ.get("THEMIS_LOG_PROMPTS", "1") != "0"   # full DeepSeek prompt+response — ON by default for the pilot
REQ_ID    = contextvars.ContextVar("req_id", default="")
FN_LABEL  = contextvars.ContextVar("fn_label", default="")
USAGE_CTX = contextvars.ContextVar("usage_ctx", default=None)  # {claimed_user, client_ip, user_agent} from the gate

_LOG_Q = queue.Queue(maxsize=20000); _LOG_DROPPED = [0]
_SECRET_KEY = re.compile(r"^(authorization|deepseek_api_key|api[_-]?key|clerk_secret_key|x-api-key)$", re.I)
_SECRET_VAL = re.compile(r"Bearer\s+\S+|sk-[A-Za-z0-9]{8,}")
_LOGCTRL    = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")

def _scrub(d):                                  # defense-in-depth: secrets never reach disk even via an echoed error
    key = globals().get("DS_KEY") or ""
    out = {}
    for k, v in d.items():
        if _SECRET_KEY.match(k): continue        # never log a secret-named field
        if isinstance(v, str):
            v = _SECRET_VAL.sub("[REDACTED]", v)
            if key: v = v.replace(key, "[REDACTED]")
        out[k] = v
    return out

def _log_writer():
    while True:
        try:
            stream, obj = _LOG_Q.get()
            os.makedirs(LOG_DIR, mode=0o700, exist_ok=True)
            day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
            fd = os.open(os.path.join(LOG_DIR, f"{stream}-{day}.jsonl"), os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
            with os.fdopen(fd, "a", encoding="utf-8") as f:
                f.write(json.dumps(obj, ensure_ascii=False) + "\n")
        except Exception:
            pass
threading.Thread(target=_log_writer, daemon=True).start()

def log_event(stream_name, **fields):           # fire-and-forget; never raises into request code
    try:
        obj = {"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "req_id": REQ_ID.get()}
        obj.update(fields)
        _LOG_Q.put_nowait((stream_name, _scrub(obj)))
    except queue.Full:
        _LOG_DROPPED[0] += 1
    except Exception:
        pass

def _clip(s, n=20000):
    s = "" if s is None else str(s)
    s = _LOGCTRL.sub(" ", s)
    return s if len(s) <= n else s[:n] + "…"

@contextmanager
def fn(label):                                  # tags every DeepSeek call made within the block (no signature changes)
    tok = FN_LABEL.set(label)
    try: yield
    finally: FN_LABEL.reset(tok)

def _bind_ctx(it, ctx):
    """Iterate a streaming generator inside a FIXED context so REQ_ID/FN_LABEL set in the endpoint
    persist across yields — and into llm() called mid-stream. (Starlette would otherwise run each
    next() in a fresh context, losing the request id on the DeepSeek-call logs.)"""
    while True:
        try: yield ctx.run(next, it)
        except StopIteration: return
# ======================================================================

# --- DeepSeek (serving LLM) — key from .env, never logged ---
def _load_env(path):
    if os.path.exists(path):
        for ln in open(path):
            ln = ln.strip()
            if ln and not ln.startswith("#") and "=" in ln:
                k, v = ln.split("=", 1)
                os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
_load_env(os.path.join(HERE, ".env"))
from clerk_auth import (  # noqa: E402 - the local .env must be loaded first
    PUBLIC_PATHS,
    authenticate_clerk_request,
    cors_origins,
    frontend_auth_config,
)
DS_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
DS_URL = "https://api.deepseek.com/chat/completions"
DS_HDR = {"Authorization": f"Bearer {DS_KEY}", "Content-Type": "application/json"}
DS_MODEL = "deepseek-chat"

def _ds_meta(label, t0, status, msgs, out, streamed):
    rec = {"lvl": "INFO", "stage": label, "fn": label, "ds_model": DS_MODEL, "ds_status": status,
           "ds_latency_ms": int((time.time() - t0) * 1000), "streamed": streamed,
           "prompt_chars": sum(len(m.get("content", "")) for m in msgs), "completion_chars": len(out)}
    if LOG_FULL:                                       # full prompt + response (pilot: max data)
        rec["prompt"] = [{"role": m.get("role"), "content": _clip(m.get("content"))} for m in msgs]
        rec["completion"] = _clip(out)
    return rec

def llm(msgs, max_new=256):
    t0 = time.time(); label = FN_LABEL.get() or "llm"
    try:
        r = requests.post(DS_URL, headers=DS_HDR, timeout=120,
                          json={"model": DS_MODEL, "messages": msgs, "max_tokens": max_new, "temperature": 0})
        r.raise_for_status()
        j = r.json(); out = j["choices"][0]["message"]["content"].strip(); u = j.get("usage") or {}
        rec = _ds_meta(label, t0, r.status_code, msgs, out, False)
        rec["prompt_tokens"] = u.get("prompt_tokens"); rec["completion_tokens"] = u.get("completion_tokens")
        rec["finish_reason"] = (j["choices"][0] or {}).get("finish_reason")
        log_event("internal", **rec)
        return out
    except Exception as e:
        log_event("internal", lvl="ERROR", stage=label, fn=label, ds_latency_ms=int((time.time() - t0) * 1000),
                  exc_type=type(e).__name__, exc_msg=_clip(str(e), 300), timed_out=isinstance(e, requests.exceptions.Timeout))
        raise

def llm_stream(msgs, max_new=256):
    t0 = time.time(); label = FN_LABEL.get() or "llm"; acc = []; status = None
    try:
        with requests.post(DS_URL, headers=DS_HDR, timeout=120, stream=True,
                           json={"model": DS_MODEL, "messages": msgs, "max_tokens": max_new,
                                 "temperature": 0, "stream": True}) as r:
            status = r.status_code; r.raise_for_status()
            for raw in r.iter_lines():
                if not raw:
                    continue
                ln = raw.decode("utf-8", "ignore")
                if not ln.startswith("data: "):
                    continue
                payload = ln[6:]
                if payload == "[DONE]":
                    break
                try:
                    delta = json.loads(payload)["choices"][0]["delta"].get("content")
                except Exception:
                    delta = None
                if delta:
                    acc.append(delta); yield delta
        log_event("internal", **_ds_meta(label, t0, status, msgs, "".join(acc), True))
    except Exception as e:
        log_event("internal", lvl="ERROR", stage=label, fn=label, ds_latency_ms=int((time.time() - t0) * 1000),
                  exc_type=type(e).__name__, exc_msg=_clip(str(e), 300), timed_out=isinstance(e, requests.exceptions.Timeout))
        raise

print("loading index...", flush=True)
chunks = [json.loads(l) for l in open("escr_chunks.jsonl")]
texts = [c["text"] for c in chunks]; chunk_doc = [c["doc_id"] for c in chunks]
M = np.load("escr_vectors.npy")
meta = {}; goodlaw = {}
for l in open("escr_meta.jsonl"): m = json.loads(l); meta[m["doc_id"]] = m
for l in open("good_law.jsonl"): g = json.loads(l); goodlaw[g["doc_id"]] = g
doc_chunks = defaultdict(list)
for i, d in enumerate(chunk_doc): doc_chunks[d].append(i)
nc2doc = {m.get("neutral_citation"): d for d, m in meta.items() if m.get("neutral_citation")}
NDOCS = len(meta)
# doc_id -> (year, path) for constructing the open-registry PDF URL (path lives in corpus_full, not meta)
pdfmap = {}
if os.path.exists("escr_pdfmap.jsonl"):
    for l in open("escr_pdfmap.jsonl"):
        try: r = json.loads(l); pdfmap[r["doc_id"]] = (str(r.get("year") or ""), r["path"])
        except Exception: pass
print(f"pdfmap: {len(pdfmap)} judgments have a source PDF", flush=True)

# --- source PDF cache: pull the authoritative SCR PDF from the open registry, keep a small LRU on
#     disk, serve it from OUR origin (so it embeds inline — no cross-origin iframe blocking) ---
PDF_BASE  = "https://indian-supreme-court-judgments.s3.ap-south-1.amazonaws.com"
PDF_CACHE = os.environ.get("THEMIS_PDF_CACHE") or os.path.join(HERE, "pdf_cache")
PDF_CACHE_MAX = int(os.environ.get("THEMIS_PDF_CACHE_MAX", "20"))
_PDF_LOCK = threading.Lock()
os.makedirs(PDF_CACHE, exist_ok=True)

def _pdf_evict():                                            # keep at most PDF_CACHE_MAX files (oldest-used go first)
    files = [os.path.join(PDF_CACHE, f) for f in os.listdir(PDF_CACHE) if f.endswith(".pdf")]
    if len(files) <= PDF_CACHE_MAX: return
    files.sort(key=lambda p: os.path.getmtime(p))
    for p in files[:len(files) - PDF_CACHE_MAX]:
        try: os.remove(p)
        except Exception: pass

def fetch_pdf(d):
    """Local cached path to doc d's source PDF; pull from the open registry on a miss.
    Returns (path, 'hit'|'miss') on success, or (None, reason)."""
    yp = pdfmap.get(d)
    if not yp: return None, "no_pdf"
    year, path = yp
    local = os.path.join(PDF_CACHE, path + "_EN.pdf")
    if os.path.exists(local):
        try: os.utime(local, None)                          # touch = mark recently used (LRU)
        except Exception: pass
        return local, "hit"
    url = f"{PDF_BASE}/data/pdf/year={year}/english/{path}_EN.pdf"
    try:
        r = requests.get(url, timeout=30)
        if r.status_code != 200 or r.content[:4] != b"%PDF":
            return None, f"upstream_{r.status_code}"
    except Exception:
        return None, "fetch_error"
    with _PDF_LOCK:
        tmp = local + ".tmp"
        with open(tmp, "wb") as f: f.write(r.content)
        os.replace(tmp, local)
        _pdf_evict()
    return local, "miss"

# --- Stage-3: citation-graph tools (edges.jsonl) + BM25 keyword index ---
from rank_bm25 import BM25Okapi
out_edges = defaultdict(list); in_edges = defaultdict(list); edge_meta = {}
cite_indeg = defaultdict(int)                       # CITE-edge in-degree only — the RELIABLE salience/display count
for _l in open("edges.jsonl"):
    _e = json.loads(_l); _f, _t = _e["from"], _e["target"]
    out_edges[_f].append(_t); in_edges[_t].append(_f)
    edge_meta[(_f, _t)] = {"treatment": _e.get("treatment"), "method": _e.get("method")}
    if _e.get("method") == "cite": cite_indeg[_t] += 1
print(f"graph: {len(edge_meta)} edges", flush=True)
_tok = lambda s: re.findall(r"[a-z0-9]+", s.lower())
bm25 = BM25Okapi([_tok(t) for t in texts])
print("BM25 index built", flush=True)
import difflib
name_vocab = set()                                 # case-name token vocabulary for fuzzy lookup (typo tolerance)
for _m in meta.values():
    for _w in re.findall(r"[a-z]+", (_m.get("case_name") or "").lower()):
        if len(_w) >= 4: name_vocab.add(_w)

# --- citation resolver (neutral + equivalent) — only RESOLVABLE cites become inline links ---
def norm_cite(c): return re.sub(r"\s+", " ", (c or "").replace(".", "")).strip().upper()  # dots stripped: S.C.R.==SCR
cite_resolver = {}
for _d, _m in meta.items():
    for _k in [_m.get("neutral_citation")] + (_m.get("equivalent_citations") or []):
        if _k: cite_resolver.setdefault(norm_cite(_k), _d)
CITE_RE = re.compile(r"\[\d{4}\]\s*\d+\s*S\.?C\.?R\.?\s*\d+|\(\d{4}\)\s*\d+\s*SCC\s*\d+|\d{4}\s+INSC\s+\d+|AIR\s+\d{4}\s+SC\s+\d+")

# --- deterministic reporter-chrome cleaner (DELETE-ONLY; raw is preserved for audit) ---
_CTRL     = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")                  # PDF/OCR control chars (e.g. \x08 wedged into running headers)
_MARGIN   = re.compile(r"(?m)^[ \t]*[A-H][ \t]*$")                       # reporter gutter letters A–H on their own line
_RUNHDR   = re.compile(r"\s*\d{1,4}\s+(?:\[\d{4}\]\s*\d+\s*S\.?C\.?R\.?[^A-Za-z]*)?Digital Supreme Court Reports\s*")
_CITELINE = re.compile(r"(?im)^[ \t]*(?:\[\d{4}\]\s*\d+\s*S\.?C\.?R\.?\s*\d+|\(\d{4}\)\s*\d+\s*SCC\s*\d+|\d{4}\s+INSC\s+\d+|AIR\s+\d{4}\s+SC\s+\d+)[ \t.:]*$")
_CSTART   = re.compile(r"(?im)^[ \t]*(?:\d{1,3}\.\s|Issue\s+for\s+Consideration|Head\s*notes?\b|IN THE SUPREME COURT|The appellants?\b|This appeal\b|These appeals\b|Leave granted\b|Heard\b)")
_DEHYPH   = re.compile(r"([A-Za-z])-\n[ \t]*([a-z])")                    # join words split by a line-break hyphen (judg-\nment)
_GUTTER   = re.compile(r" ([A-H]) ?\n")                                  # SCR right-margin gutter letter wedged at a line end ("…the D\n")

def clean_headnote(s):
    if not s: return s
    s = _CTRL.sub("", s)                # strip control chars so the running-header regex can match
    s = _RUNHDR.sub(" ", s)
    s = re.sub(r"\bHead\s*notes?\s*†?", "", s, flags=re.I)   # drop the "Headnotes†" marker
    s = s.replace("†", "")
    s = re.split(r"\*\s*Author\b", s)[0]                      # cut the reporter author/citation footer ("*Author [2024] 12 S.C.R. 1437 …")
    s = re.sub(r"^[\s:–—-]+", "", s)                          # leading ":" / dashes
    return re.sub(r"[ \t]{2,}", " ", s).strip()

def clean_judgment(raw):
    if not raw: return ""
    t = _CTRL.sub("", raw)              # strip PDF/OCR control chars first
    t = _MARGIN.sub("", t)              # drop margin letters
    t = _RUNHDR.sub(" ", t)             # kill the "130 [2024] 6 S.C.R. Digital Supreme Court Reports" triad anywhere
    t = _CITELINE.sub("", t)            # drop page-top citation-only echo lines
    # Prefer starting the opinion at its first numbered paragraph — eSCR formats these as a bare "1.\n"/"2.\n"
    # line — which drops the duplicated reporter headnote (already shown verbatim in the ISSUE/HELD cards; the
    # full unedited text remains one click away via "View raw text"). Fall back to the caption-preamble slice.
    mo = re.search(r"(?m)^[ \t]*1\.[ \t]*$", t) or re.search(r"(?m)^[ \t]*2\.[ \t]*$", t)
    if mo and mo.start() > 200:
        t = t[mo.start():]
    else:
        m = _CSTART.search(t[:2000])    # slice the duplicated reporter caption preamble, only if an anchor is found early
        if m: t = t[m.start():]
    t = _DEHYPH.sub(r"\1\2", t)         # repair hyphen-split words across line breaks
    t = _GUTTER.sub("\n", t)            # drop SCR right-margin gutter letters (A–H) wedged at line ends
    t = re.sub(r"[ \t]{2,}", " ", t)
    t = re.sub(r"\n{3,}", "\n\n", t)
    return t.strip()

def doc_links(d, text):
    out = {}
    for c in CITE_RE.findall(text):
        rid = cite_resolver.get(norm_cite(c))
        if rid and rid != d and c not in out: out[c] = rid
    return [{"cite": k, "id": v} for k, v in out.items()]

def passage_snippet(raw, n=300):
    """Search results show retrieval CHUNKS, which start mid-sentence. Clean reporter chrome
    and snap the start to a sentence/word boundary so the snippet reads cleanly."""
    t = re.sub(r"\s+", " ", clean_headnote(raw or "")).strip()
    if not t: return ""
    m = re.search(r"[.?!]\s+([A-Z])", t[:90])          # prefer a sentence start near the front
    if m: t = t[m.start(1):]
    elif t[0].islower():                                # else drop a leading partial word
        sp = t.find(" ")
        if 0 <= sp <= 30: t = "…" + t[sp + 1:]
    if len(t) > n:                                      # truncate on a word boundary
        cut = t.rfind(" ", 0, n)
        t = (t[:cut] if cut > 0 else t[:n]).rstrip(" ,;:–-") + "…"
    return t

def resolve_cited(cases_cited, self_id):
    """Cases THIS judgment relies on (from metadata), each resolved to a corpus doc where the
    parallel citation matches (cross-reporter via the ' : '-joined citation string)."""
    out = []
    for c in (cases_cited or []):
        cites = c.get("citations") or []
        rid = None
        for cstr in cites:
            for part in re.split(r"\s*[:;]\s*", cstr):
                rid = cite_resolver.get(norm_cite(part))
                if rid and rid != self_id: break
                rid = None
            if rid: break
        if not rid and c.get("name"):                      # citation didn't resolve (e.g. cited only by SCC / SCC OnLine,
            hits = name_search(c["name"], 1)               # which our resolver doesn't index) — fall back to a fuzzy NAME
            if hits and hits[0] != self_id: rid = hits[0]  # match (needs a distinctive token, so generics won't mislink)
        out.append({"name": c.get("name"), "citation": (cites[0] if cites else ""),
                    "treatment": c.get("treatment"), "id": rid})
    return out
st = SentenceTransformer("BAAI/bge-small-en-v1.5", device=DEV,
                         model_kwargs={"torch_dtype": torch.bfloat16 if DEV == "cuda" else torch.float32})  # bf16 only helps on GPU; CPU box loads fp32
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device=DEV)
print(f"READY — {NDOCS} judgments, DeepSeek serving={'yes' if DS_KEY else 'NO KEY'}", flush=True)

def card(d, s, ci):
    m = meta.get(d, {}); gl = goodlaw.get(d, {})
    return {"doc_id": d, "case_name": m.get("case_name"), "neutral_citation": m.get("neutral_citation"),
            "equivalent_citations": m.get("equivalent_citations"), "court": m.get("court"), "date": m.get("date"),
            "bench_strength": m.get("bench_strength"), "disposition": m.get("disposition"),
            "good_law_status": gl.get("good_law_status", "unknown"), "good_law_prov": gl.get("provenance"),
            "cited_by": cite_indeg.get(d, 0), "rr": round(s, 2), "passage": passage_snippet(texts[ci]),
            "chunk": re.sub(r"\s+", " ", clean_headnote(texts[ci]))[:1600]}

def dense(q, n=CAND):
    qv = st.encode(BGE_Q + q, normalize_embeddings=True, convert_to_numpy=True).astype(np.float32)
    sim = M @ qv
    cand = np.argpartition(-sim, n)[:n]
    return [int(ci) for ci in cand[np.argsort(-sim[cand])]]

def rerank(q, cand, topk=12):
    rr = ce.predict([(q, texts[ci]) for ci in cand]); best = {}
    for ci, s in zip(cand, rr):
        d = chunk_doc[ci]
        if d not in best or s > best[d][0]: best[d] = (float(s), ci)
    return [card(d, s, ci) for d, (s, ci) in sorted(best.items(), key=lambda x: x[1][0], reverse=True)[:topk]]

def bm25_top(q, n=CAND):
    s = bm25.get_scores(_tok(q))
    return [int(i) for i in np.argsort(-s)[:n] if s[i] > 0]

def candidates(q, n=CAND):
    """Hybrid candidate pool: dense (semantic) + BM25 (exact terms / section nums / names), RRF-fused."""
    dc, bc = dense(q, n), bm25_top(q, n)
    sc = defaultdict(float)
    for r, ci in enumerate(dc): sc[ci] += 1.0 / (60 + r + 1)
    for r, ci in enumerate(bc): sc[ci] += 1.0 / (60 + r + 1)
    return [ci for ci, _ in sorted(sc.items(), key=lambda x: -x[1])][:max(n, 48)]

def retrieve(q, topk=12):
    return rerank(q, candidates(q), topk)

# --- citation-graph tools (Stage 3) ---
def cited_by_docs(d):   # who cites d (inbound), de-duped
    return list(dict.fromkeys(in_edges.get(d, [])))
def cites_docs(d):      # what d cites (outbound)
    return list(dict.fromkeys(out_edges.get(d, [])))

def card_for_doc(q, d):
    """Build a card for a doc by reranking its own chunks against q (real relevance score for added cases)."""
    cis = doc_chunks.get(d, [])
    if not cis: return id_card(d)
    rr = ce.predict([(q, texts[ci]) for ci in cis[:6]])
    bi = int(np.argmax(rr))
    c = card(d, float(rr[bi]), cis[bi]); c["relevance"] = "partial"
    return c

def verify(q, cases):
    """Fresh paralegal reviewer (DeepSeek) — only the passages, nothing else."""
    listing = "\n".join(f"[{i}] {c['case_name']}: {c['passage'][:280]}" for i, c in enumerate(cases))
    msg = [{"role": "system", "content": 'You are a paralegal screening search results. Judge whether each case is relevant to the legal query. Output ONLY a JSON array like [{"i":0,"v":"relevant"}] where v is relevant, partial, or not.'},
           {"role": "user", "content": f"Query: {q}\n\nCases:\n{listing}\n\nJSON:"}]
    try:
        with fn("verify"): t = llm(msg, 400)
        j = json.loads(t[t.find("["):t.rfind("]") + 1]); vm = {d["i"]: d["v"] for d in j}
        for i, c in enumerate(cases): c["relevance"] = vm.get(i, "partial")
    except Exception as e:
        log_event("internal", lvl="WARN", stage="verify", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300))
        for c in cases: c["relevance"] = "partial"
    return cases

# --- exact identity lookup (case name / citation) — a name or cite is not a legal QUESTION,
#     so dense-passage search + the relevance reviewer miss it; route it to metadata instead. ---
_NAME_STOP = {"v", "vs", "of", "and", "the", "ors", "anr", "etc", "state", "union", "govt", "government", "in", "re"}
_CITE_ANY = re.compile(r"\[\d{4}\]\s*\d+\s*S\.?C\.?R\.?\s*\d+|\(\d{4}\)\s*\d+\s*SCC\s*\d+|\d{4}\s+INSC\s+\d+|AIR\s+\d{4}\s+SC\s+\d+", re.I)

def name_search(q, k=6):
    raw = [t for t in re.findall(r"[a-z]+", q.lower()) if t not in _NAME_STOP and len(t) > 1]
    if not raw: return []
    qtok = set()                                            # fuzzy-expand each token vs the name vocab (visaka -> vishaka)
    for t in raw:
        if t in name_vocab or len(t) <= 3: qtok.add(t)
        else: qtok.update(difflib.get_close_matches(t, name_vocab, n=3, cutoff=0.82) or [t])
    scored = []
    for d, m in meta.items():
        ntok = set(re.findall(r"[a-z]+", (m.get("case_name") or "").lower()))
        ov = qtok & ntok
        if len(ov) >= 2 or (len(ov) == 1 and any(len(t) >= 7 for t in ov)):
            scored.append((len(ov), cite_indeg.get(d, 0), d))   # SALIENCE tiebreak: the landmark (more-cited) wins over a namesake
    scored.sort(reverse=True)
    return [d for _, _, d in scored[:k]]

def identity_hits(q):
    ql = q.strip()
    m = _CITE_ANY.search(ql)
    if m:
        rid = cite_resolver.get(norm_cite(m.group(0))) or nc2doc.get(m.group(0))
        if rid: return [rid], "citation"
    if re.search(r"\bv[s.]?\b|\bversus\b", ql, re.I) and len(ql) <= 90:
        hits = name_search(ql)
        if hits: return hits, "case name"
    return [], None

def id_card(d):
    cis = doc_chunks.get(d)
    c = card(d, 9.9, cis[0]) if cis else {"doc_id": d, "rr": 9.9}
    m = meta.get(d, {}); gl = goodlaw.get(d, {})
    c.update({"case_name": m.get("case_name"), "neutral_citation": m.get("neutral_citation"),
              "equivalent_citations": m.get("equivalent_citations"), "court": m.get("court"), "date": m.get("date"),
              "bench_strength": m.get("bench_strength"), "disposition": m.get("disposition"),
              "good_law_status": gl.get("good_law_status", "unknown"), "good_law_prov": gl.get("provenance"),
              "cited_by": cite_indeg.get(d, 0), "relevance": "relevant",
              "passage": passage_snippet(m.get("held") or m.get("issue") or c.get("passage") or "")})
    return c

def improve(q):
    ids, kind = identity_hits(q)
    if ids:
        cases = [id_card(d) for d in ids][:8]
        if kind == "case name":
            seen = {c["doc_id"] for c in cases}
            for c in rerank(q, candidates(q), 6):
                if c["doc_id"] not in seen and len(cases) < 8:
                    c["relevance"] = "partial"; cases.append(c); seen.add(c["doc_id"])
        return cases, {"identity": kind, "retrieved": len(cases), "dropped": 0, "requeried": False}
    steps = {"retrieved": 0, "dropped": 0, "requeried": False}
    cases = verify(q, retrieve(q, 12)); steps["retrieved"] = len(cases)
    kept = [c for c in cases if c["relevance"] in ("relevant", "partial")]
    steps["dropped"] = len(cases) - len(kept)
    if len(kept) < 4:                                            # one bounded re-query on weak recall
        steps["requeried"] = True
        try:
            with fn("requery"): rw = llm([{"role": "user", "content": f'Rewrite this as a precise legal-register search query (one line, no preamble): "{q}"'}], 60).strip().strip('"')
        except Exception as e:
            log_event("internal", lvl="WARN", stage="requery", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300)); rw = q
        seen = {c["doc_id"] for c in kept}
        kept += [c for c in verify(q, retrieve(rw, 8)) if c["relevance"] in ("relevant", "partial") and c["doc_id"] not in seen]
    kept.sort(key=lambda c: (c["relevance"] != "relevant", -c["rr"]))
    return kept[:8], steps

# --- RENDER-FROM-LEDGER grounding gate (anti-hallucination, all-paths) ---
# The synthesiser must back each claim with a VERBATIM quote copied from the case's loaded text;
# we drop any claim whose quote is not a true substring of that case's chunk, or whose [n] is out
# of range. The user-facing answer is rendered ONLY from the surviving (verified) claims — so a
# hallucinated holding or a fabricated/un-loaded case has no surface to appear on.
def _ground_msgs(q, cases):
    ctx = "\n\n".join(f"[{i+1}] {c['case_name']} ({c.get('neutral_citation') or ''}):\n{c.get('chunk') or c.get('passage')}" for i, c in enumerate(cases[:5]))
    sysmsg = ('You are summarising SEARCH RESULTS for a lawyer. Using ONLY the supplied case texts, output a JSON array of 2-4 items '
              'that SUMMARISE what the retrieved cases hold on the issue — a neutral digest of the line of authority to help a lawyer scan the results. '
              'This is a SUMMARY OF THE CASES, NOT legal advice, NOT a recommendation, NOT guidance to a client — never say what the lawyer or client "should" do. '
              'Each item: {"claim": one plain sentence stating what that case holds/establishes, "n": the [n] of the case, "quote": a SHORT span (6-20 words) copied EXACTLY, character-for-character, from case [n]\'s supplied text}. '
              'The quote MUST be a verbatim substring of case [n]. Never paraphrase the quote, never invent. If the cases do not address the issue, output [].')
    return [{"role": "system", "content": sysmsg},
            {"role": "user", "content": f"Query: {q}\n\nCases:\n{ctx}\n\nJSON array:"}]

def _norm(s): return re.sub(r"\s+", " ", (s or "")).strip().lower()

def verify_claims(arr, cases):
    """THE gate (pure, unit-testable): keep a claim only if its [n] is in range AND its quote is a
    verbatim substring of case [n]'s loaded text. A fabricated/un-loaded case or invented quote drops."""
    texts_norm = [_norm(c.get("chunk") or c.get("passage")) for c in cases[:5]]
    verified, dropped = [], []                                        # dropped: [{claim, reason}] for transparency
    for it in (arr if isinstance(arr, list) else []):
        n = (it or {}).get("n"); claim = ((it or {}).get("claim") or "").strip(); quote = ((it or {}).get("quote") or "").strip()
        if not claim or not isinstance(n, int) or isinstance(n, bool) or not (1 <= n <= len(texts_norm)):
            if claim: dropped.append({"claim": claim[:240], "reason": "no valid case reference"})
            continue                                                  # bool-n guard: isinstance(True,int) is True
        nq = _norm(quote)
        if nq and len(nq.split()) >= 4 and nq in texts_norm[n - 1]:   # the gate: a substantive (>=4-word) verbatim substring
            verified.append({"claim": claim, "n": n, "quote": quote})
        else:
            dropped.append({"claim": claim[:240], "reason": "could not be traced to a verbatim passage in the cited case"})
    return verified, dropped

def grounded_answer(q, cases):
    """Returns {text, claims:[{claim,n,quote}], dropped:int}. text is rendered only from verified claims."""
    if not cases:
        return {"text": "No relevant judgments found for this query.", "claims": [], "dropped": 0}
    try:
        with fn("ground"): raw = llm(_ground_msgs(q, cases), 700)
        arr = json.loads(raw[raw.find("["):raw.rfind("]") + 1])
    except Exception as e:
        log_event("internal", lvl="WARN", stage="ground", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300))
        return {"text": "No grounded synthesis could be verified — see the cases below.", "claims": [], "dropped": 0}
    verified, dropped = verify_claims(arr, cases)
    if not verified:
        return {"text": "No grounded synthesis could be verified against the retrieved cases — review the cases below directly.", "claims": [], "dropped": len(dropped), "dropped_items": dropped}
    # Render the VERIFIED words, not just the unchecked paraphrase: show each claim with the verbatim
    # quote it rests on, so the user reads what is actually grounded (deep claim↔quote entailment is Stage-2).
    text = "  ".join(f'{v["claim"]} — "…{v["quote"]}…" [{v["n"]}]' for v in verified)
    return {"text": text, "claims": verified, "dropped": len(dropped), "dropped_items": dropped}

def answer(q, cases):                       # non-stream callers (/api/search)
    return grounded_answer(q, cases)["text"]

def answer_events(q, cases, stats=None):    # SSE: the verified grounded answer + provenance
    yield sse({"t": "step", "k": "answer", "s": "run", "label": "Summarising the cases"})
    ga = grounded_answer(q, cases)
    buf = ""
    for w in ga["text"].split(" "):
        buf += w + " "
        if len(buf) >= 14:
            yield sse({"t": "answer_delta", "text": buf}); buf = ""
    if buf: yield sse({"t": "answer_delta", "text": buf})
    if ga["claims"]:
        yield sse({"t": "claims", "claims": ga["claims"]})
    if ga.get("dropped_items"):                     # transparency: what we REFUSED to assert (couldn't ground)
        yield sse({"t": "dropped_claims", "items": ga["dropped_items"]})
    n = len(ga["claims"])
    if stats is not None: stats["n_verified"] = n; stats["n_dropped"] = ga["dropped"]
    lab = (f"Summary grounded in {n} verbatim holding{'s' if n != 1 else ''}" + (f" · set aside {ga['dropped']} the cases didn't support" if ga["dropped"] else "")) if n else "Couldn't ground a summary — review the cases below"
    yield sse({"t": "step", "k": "answer", "s": "done", "label": lab})

def doc_text(d):
    cs = [texts[i] for i in doc_chunks.get(d, [])]
    return ("".join(c[:1200] for c in cs[:-1]) + cs[-1]) if cs else ""

app = FastAPI(title="Moonley API", description="Grounded Indian legal research API")

# --- Clerk access gate for public hosting ---
@app.middleware("http")
async def _clerk_gate(request, call_next):
    rid = uuid.uuid4().hex[:16]; REQ_ID.set(rid)
    rejection = None
    if request.method != "OPTIONS" and request.url.path not in PUBLIC_PATHS:
        rejection = authenticate_clerk_request(request)
    cu = getattr(request.state, "clerk_user_id", "")
    ip = request.client.host if request.client else ""
    ua = request.headers.get("user-agent", "")
    request.state.req_id = rid; request.state.claimed_user = cu
    request.state.client_ip = ip; request.state.user_agent = ua
    USAGE_CTX.set({"claimed_user": cu, "client_ip": ip, "user_agent": ua})
    if rejection is not None:
        log_event("usage", endpoint=request.url.path, claimed_user=cu, client_ip=ip, user_agent=ua,
                  http_status=rejection.status_code, outcome="denied")
        return rejection
    return await call_next(request)

from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(CORSMiddleware, allow_origins=cors_origins(), allow_methods=["*"],
                   allow_headers=["*"], expose_headers=["*"])

@app.get("/api/v2/auth/config")
def auth_config():
    return frontend_auth_config()

def sse(o): return "data: " + json.dumps(o, ensure_ascii=False) + "\n\n"

@app.get("/api/search_stream")
def search_stream(q: str, request: Request):
    rid = getattr(request.state, "req_id", ""); REQ_ID.set(rid)
    uc = USAGE_CTX.get() or {"claimed_user": getattr(request.state, "claimed_user", ""),
                             "client_ip": getattr(request.state, "client_ip", ""),
                             "user_agent": getattr(request.state, "user_agent", "")}
    t0 = time.time()
    def gen():
        route = "doctrinal"; final = []; thin = False; outcome = "ok"; stats = {}
        try:
            yield sse({"t": "meta", "req_id": rid})
            ids, kind = identity_hits(q)
            if ids:                                            # exact case-name / citation lookup
                route = "identity"
                yield sse({"t": "step", "k": "identity", "s": "done", "label": f"Matched {len(ids)} judgment{'s' if len(ids) != 1 else ''} by {kind}"})
                cases = [id_card(d) for d in ids][:8]
                if kind == "case name":
                    seen = {c["doc_id"] for c in cases}
                    for c in rerank(q, candidates(q), 6):
                        if c["doc_id"] not in seen and len(cases) < 8:
                            c["relevance"] = "partial"; cases.append(c); seen.add(c["doc_id"])
                final = cases
                yield sse({"t": "step", "k": "goodlaw", "s": "done", "label": "Checked which results are still good law"})
                yield sse({"t": "results", "results": cases})
                for ev in answer_events(q, cases, stats): yield ev
                yield sse({"t": "done"})
                return
            yield sse({"t": "step", "k": "search", "s": "run", "label": f"Searching all {NDOCS:,} reportable Supreme Court judgments"})
            cand = candidates(q)
            yield sse({"t": "step", "k": "search", "s": "done", "label": f"Searched all {NDOCS:,} judgments by meaning and keywords"})

            yield sse({"t": "step", "k": "rerank", "s": "run", "label": "Ranking the closest matches to your issue"})
            cases = rerank(q, cand, 12)
            yield sse({"t": "step", "k": "rerank", "s": "done", "label": f"Shortlisted the {len(cases)} closest judgments"})

            yield sse({"t": "step", "k": "review", "s": "run", "label": "Reviewing each result for relevance to your issue"})
            cases = verify(q, cases)
            kept = [c for c in cases if c["relevance"] in ("relevant", "partial")]
            dropped = len(cases) - len(kept)
            yield sse({"t": "step", "k": "review", "s": "done", "label": f"Reviewed {len(cases)} — kept {len(kept)} on-point, set aside {dropped}"})

            if len(kept) < 4:
                thin = True
                yield sse({"t": "step", "k": "requery", "s": "run", "label": "Few on-point results — rephrasing the search once"})
                try:
                    with fn("requery"): rw = llm([{"role": "user", "content": f'Rewrite this as a precise legal-register search query (one line, no preamble): "{q}"'}], 60).strip().strip('"')
                except Exception as e:
                    log_event("internal", lvl="WARN", stage="requery", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300)); rw = q
                seen = {c["doc_id"] for c in kept}
                extra = [c for c in verify(q, rerank(rw, candidates(rw), 8)) if c["relevance"] in ("relevant", "partial") and c["doc_id"] not in seen]
                kept += extra
                yield sse({"t": "step", "k": "requery", "s": "done", "label": f'Rephrased the search — found {len(extra)} more'})

            kept.sort(key=lambda c: (c["relevance"] != "relevant", -c["rr"]))
            kept = kept[:8]; final = kept
            yield sse({"t": "step", "k": "goodlaw", "s": "done", "label": "Checked which results are still good law"})
            yield sse({"t": "results", "results": kept})
            for ev in answer_events(q, kept, stats): yield ev
            yield sse({"t": "done"})
        except Exception as e:
            outcome = "error"
            log_event("internal", lvl="ERROR", stage="search_stream", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300))
            yield sse({"t": "error", "message": str(e)[:200]})
            yield sse({"t": "done"})
        finally:
            log_event("usage", **uc, endpoint="search_stream", mode="fast", route=route, http_status=200,
                      q=_clip(q, 2000), n_results=len(final), top_doc_ids=[c.get("doc_id") for c in final[:5]],
                      n_claims_verified=stats.get("n_verified", 0), n_claims_dropped=stats.get("n_dropped", 0),
                      thin=thin, latency_ms=int((time.time() - t0) * 1000), outcome=(outcome if final or outcome == "error" else "empty"))
    ctx = contextvars.copy_context()
    return StreamingResponse(_bind_ctx(gen(), ctx), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"})

def _plan(q):
    """DeepSeek controller: decompose the issue + name the LEADING authorities a lawyer expects.
    Names are only candidates — each is grounded via name_search; a hallucinated name simply fails to resolve."""
    try:
        with fn("plan"):
            t = llm([{"role": "system", "content": 'Indian Supreme Court legal-research planner. For the query output JSON {"sub_issues":[1-3 short issue phrases],"authorities":[up to 5 LEADING / LANDMARK SC case names a lawyer would expect on this exact issue — case names only, no citations]}. Name only genuinely well-known authorities; every name is verified against our corpus, so do not pad. [] if unsure.'},
                     {"role": "user", "content": q}], 320)
        j = json.loads(t[t.find("{"):t.rfind("}") + 1])
        return (j.get("sub_issues") or [])[:3], (j.get("authorities") or [])[:5]
    except Exception as e:
        log_event("internal", lvl="WARN", stage="plan", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300))
        return [], []

@app.get("/api/deep_search_stream")
def deep_search_stream(q: str, request: Request):
    rid = getattr(request.state, "req_id", ""); REQ_ID.set(rid)
    uc = USAGE_CTX.get() or {"claimed_user": getattr(request.state, "claimed_user", ""),
                             "client_ip": getattr(request.state, "client_ip", ""),
                             "user_agent": getattr(request.state, "user_agent", "")}
    t0 = time.time()
    def gen():
        route = "deep"; final = []; outcome = "ok"; stats = {}
        try:
            yield sse({"t": "meta", "req_id": rid})
            ids, kind = identity_hits(q)                          # a bare name/cite lookup never needs the deep loop
            if ids:
                route = "identity"
                cases = [id_card(d) for d in ids][:8]; final = cases
                yield sse({"t": "step", "k": "identity", "s": "done", "label": f"Matched {len(ids)} by {kind}"})
                yield sse({"t": "results", "results": cases})
                for ev in answer_events(q, cases, stats): yield ev
                yield sse({"t": "done"}); return

            yield sse({"t": "step", "k": "plan", "s": "run", "label": "Identifying the leading authorities a lawyer would expect"})
            subs, auths = _plan(q)
            yield sse({"t": "step", "k": "plan", "s": "done", "label": ("Checking for the leading authorities on this issue: " + ", ".join(auths[:5])) if auths else f"Broke the issue into {len(subs)} sub-issue(s)"})

            yield sse({"t": "step", "k": "seed", "s": "run", "label": f"Searching all {NDOCS:,} reportable Supreme Court judgments"})
            cases = verify(q, retrieve(q, 12))
            kept = [c for c in cases if c["relevance"] in ("relevant", "partial")]
            seen = {c["doc_id"] for c in kept}
            yield sse({"t": "step", "k": "seed", "s": "done", "label": f"{len(kept)} on-point results from the search"})

            yield sse({"t": "step", "k": "expand", "s": "run", "label": "Bringing in the leading authorities and the cases they rely on"})
            add, auth_docs = [], set()
            auth_hits = {}                                        # authority NAME -> resolved corpus doc_ids (to close the loop)
            for nm in auths:                                      # ground each proposed authority (name -> corpus doc)
                docs = name_search(nm, 2); auth_hits[nm] = docs
                for d in docs:
                    if d not in seen and d not in add: add.append(d); auth_docs.add(d)
            nbr = Counter()                                       # cases the seed results commonly rely on (shared citations)
            for c in kept[:6]:
                for t in cites_docs(c["doc_id"]):
                    if t not in seen: nbr[t] += 1
            for t, _ in nbr.most_common(6):
                if t not in add: add.append(t)
            new_cards = verify(q, [card_for_doc(q, d) for d in add[:14]])
            for c in new_cards: c["authority"] = c["doc_id"] in auth_docs   # a PLAN-named leading authority
            added = [c for c in new_cards if c["relevance"] in ("relevant", "partial")
                     and c["good_law_status"] not in ("overruled", "partly_overruled", "per_incuriam")]
            kept += added
            yield sse({"t": "step", "k": "expand", "s": "done", "label": f"Added {len(added)} more after review (leading authorities + frequently-cited cases)"})

            # rank: a reviewer-confirmed leading authority earns a top slot (authority buys a seat AFTER vetting);
            # then relevant seed results by rerank; then partials. (Pure rerank would bury old foundational cases.)
            def _sk(c): return (not (c.get("authority") and c["relevance"] == "relevant"), c["relevance"] != "relevant", -c.get("rr", 0))
            uniq, s2 = [], set()
            for c in sorted(kept, key=_sk):
                if c["doc_id"] not in s2: s2.add(c["doc_id"]); uniq.append(c)
            kept = uniq[:10]; final = kept
            # close the authority loop: tell the lawyer which expected landmarks actually made it into the results
            if auths:
                kept_ids = {c["doc_id"] for c in kept}
                got = [nm for nm in auths if any(dd in kept_ids for dd in auth_hits.get(nm, []))]
                miss = [nm for nm in auths if nm not in got]
                lab = (("Leading authorities now in your results: " + ", ".join(got)) if got else "None of the expected landmark authorities were on point here") \
                      + (" · not on point here: " + ", ".join(miss) if miss else "")
                yield sse({"t": "step", "k": "authcheck", "s": "done", "label": lab})
            yield sse({"t": "step", "k": "goodlaw", "s": "done", "label": "Checked which results are still good law"})
            yield sse({"t": "results", "results": kept})
            for ev in answer_events(q, kept, stats): yield ev
            yield sse({"t": "done"})
        except Exception as e:
            outcome = "error"
            log_event("internal", lvl="ERROR", stage="deep_search_stream", exc_type=type(e).__name__, exc_msg=_clip(str(e), 300))
            yield sse({"t": "error", "message": str(e)[:200]}); yield sse({"t": "done"})
        finally:
            log_event("usage", **uc, endpoint="deep_search_stream", mode="deep", route=route, http_status=200,
                      q=_clip(q, 2000), n_results=len(final), top_doc_ids=[c.get("doc_id") for c in final[:5]],
                      n_claims_verified=stats.get("n_verified", 0), n_claims_dropped=stats.get("n_dropped", 0),
                      latency_ms=int((time.time() - t0) * 1000), outcome=(outcome if final or outcome == "error" else "empty"))
    ctx = contextvars.copy_context()
    return StreamingResponse(_bind_ctx(gen(), ctx), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"})

@app.get("/api/search")
def search(q: str, request: Request):
    REQ_ID.set(getattr(request.state, "req_id", ""))
    uc = USAGE_CTX.get() or {"claimed_user": getattr(request.state, "claimed_user", ""),
                             "client_ip": getattr(request.state, "client_ip", ""),
                             "user_agent": getattr(request.state, "user_agent", "")}
    t0 = time.time()
    try:
        cases, steps = improve(q)
        log_event("usage", **uc, endpoint="search", mode="fallback", q=_clip(q, 2000), http_status=200,
                  n_results=len(cases), top_doc_ids=[c.get("doc_id") for c in cases[:5]],
                  latency_ms=int((time.time() - t0) * 1000), outcome=("ok" if cases else "empty"))
        return JSONResponse({"query": q, "answer": answer(q, cases), "results": cases, "steps": steps})
    except Exception as e:
        log_event("usage", **uc, endpoint="search", mode="fallback", q=_clip(q, 2000), http_status=500,
                  latency_ms=int((time.time() - t0) * 1000), outcome="error")
        return JSONResponse({"query": q, "answer": "", "results": [], "error": str(e)[:200]}, status_code=500)

@app.get("/api/judgment")
def judgment(id: str, request: Request):
    REQ_ID.set(getattr(request.state, "req_id", ""))
    uc = USAGE_CTX.get() or {"claimed_user": getattr(request.state, "claimed_user", ""),
                             "client_ip": getattr(request.state, "client_ip", ""),
                             "user_agent": getattr(request.state, "user_agent", "")}
    d = id if id in meta else nc2doc.get(id)
    if not d:
        log_event("usage", **uc, endpoint="judgment", doc_id=_clip(id, 200), http_status=404, outcome="not_found")
        return JSONResponse({"error": "not found"}, status_code=404)
    m = meta.get(d, {}); gl = goodlaw.get(d, {})
    log_event("usage", **uc, endpoint="judgment", doc_id=d, neutral_citation=m.get("neutral_citation"),
              case_name=_clip(m.get("case_name"), 300), http_status=200, outcome="ok")
    raw = doc_text(d); clean = clean_judgment(raw)[:80000]
    return JSONResponse({"doc_id": d, "case_name": m.get("case_name"), "neutral_citation": m.get("neutral_citation"),
        "equivalent_citations": m.get("equivalent_citations"), "court": m.get("court"), "date": m.get("date"),
        "bench": m.get("bench"), "author_judge": m.get("author_judge"), "bench_strength": m.get("bench_strength"),
        "case_number": m.get("case_number"), "disposition": m.get("disposition"), "acts": m.get("acts"),
        "issue": clean_headnote(m.get("issue")), "held": clean_headnote(m.get("held")),
        "good_law_status": gl.get("good_law_status", "unknown"),
        "good_law_prov": gl.get("provenance"), "as_of": gl.get("as_of"), "cited_by": cite_indeg.get(d, 0),
        "treatment_breakdown": gl.get("treatment_breakdown", {}), "corpus_n": NDOCS,
        "cnr": m.get("cnr"), "year": m.get("year"), "cited_cases": resolve_cited(m.get("cases_cited"), d),
        "has_pdf": d in pdfmap,
        "text": clean, "text_raw": raw[:80000], "links": doc_links(d, clean)})

@app.get("/api/pdf")
def pdf(id: str, request: Request, dl: int = 0):
    REQ_ID.set(getattr(request.state, "req_id", ""))
    uc = USAGE_CTX.get() or {"claimed_user": getattr(request.state, "claimed_user", ""),
                             "client_ip": getattr(request.state, "client_ip", ""),
                             "user_agent": getattr(request.state, "user_agent", "")}
    t0 = time.time()
    d = id if id in meta else nc2doc.get(id)
    if not d:
        log_event("usage", **uc, endpoint="pdf", doc_id=_clip(id, 200), http_status=404, outcome="not_found")
        return JSONResponse({"error": "not found"}, status_code=404)
    local, status = fetch_pdf(d)
    if not local:
        log_event("usage", **uc, endpoint="pdf", doc_id=d, http_status=502, outcome=status,
                  latency_ms=int((time.time() - t0) * 1000))
        return JSONResponse({"error": "pdf unavailable", "reason": status}, status_code=502)
    log_event("usage", **uc, endpoint="pdf", doc_id=d, http_status=200, outcome="ok", cache=status,
              mode=("download" if dl else "inline"), latency_ms=int((time.time() - t0) * 1000))
    fname = (d.replace(" ", "_") + ".pdf") if dl else None
    disp = f'attachment; filename="{fname}"' if dl else "inline"
    return FileResponse(local, media_type="application/pdf",
                        headers={"Content-Disposition": disp, "Cache-Control": "private, max-age=3600"})

@app.get("/")
def home():
    return JSONResponse(
        {"service": "Moonley API", "status": "ok", "ui": "https://moonley-pilot.vercel.app"},
        headers={"Cache-Control": "no-store"},
    )