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"},
)
|