rohitsar567 commited on
Commit
afdb7c7
·
1 Parent(s): a6bda5e

Revert "Revert "feat(#52): PDF upload → persistent JSON + marketplace card + restart-survival""

Browse files

This reverts commit a6bda5e29589a6a1990c025a892d761b0cb40709.

backend/config.py CHANGED
@@ -69,6 +69,23 @@ class Settings:
69
  # (parallel to 70-docs/80-audit).
70
  DATA_DIR: Path = ROOT / "40-data"
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  # Tunables (overrideable via env vars so the hyperparameter sweep can iterate)
73
  CHUNK_TOKENS: int = int(os.environ.get("CHUNK_TOKENS", "800"))
74
  CHUNK_OVERLAP_TOKENS: int = int(os.environ.get("CHUNK_OVERLAP_TOKENS", "120"))
 
69
  # (parallel to 70-docs/80-audit).
70
  DATA_DIR: Path = ROOT / "40-data"
71
 
72
+ # #52 — PERSISTENT store for user-uploaded policy docs (raw PDF + the
73
+ # curated-facts JSON record we derive + the chunk payload to re-index).
74
+ #
75
+ # On the HF Space, rag/vectors lives on the EPHEMERAL container FS by
76
+ # design (KI-119 / entrypoint.sh) so every rebuild pulls a fresh Chroma
77
+ # snapshot — an uploaded doc indexed only there would vanish on restart.
78
+ # There IS a persistent `/data` disk on the Space; entrypoint.sh exports
79
+ # UPLOADED_DOCS_DIR=/data/uploaded_docs when /data is writable. We honour
80
+ # that env var here so persisted uploads survive a Space rebuild.
81
+ #
82
+ # Locally (no /data, env unset) it falls back under DATA_DIR so the exact
83
+ # same code path works without any HF-specific branching.
84
+ UPLOADED_DOCS_DIR: Path = Path(
85
+ os.environ.get("UPLOADED_DOCS_DIR", "")
86
+ or str(ROOT / "40-data" / "uploaded_docs")
87
+ )
88
+
89
  # Tunables (overrideable via env vars so the hyperparameter sweep can iterate)
90
  CHUNK_TOKENS: int = int(os.environ.get("CHUNK_TOKENS", "800"))
91
  CHUNK_OVERLAP_TOKENS: int = int(os.environ.get("CHUNK_OVERLAP_TOKENS", "120"))
backend/main.py CHANGED
@@ -524,6 +524,49 @@ async def _startup_single_brain_warmup():
524
  )
525
 
526
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  async def _startup_purge_dangling_profile_chunks():
528
  """KI-117 — boot-time self-heal of dangling `doc_type='profile'` chunks.
529
 
@@ -1860,6 +1903,68 @@ async def upload_policy(
1860
  record_accept(sha, sid, len(chunks))
1861
  except Exception:
1862
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1863
  except HTTPException:
1864
  raise
1865
  except Exception as e:
@@ -2563,6 +2668,28 @@ def _load_curated_facts() -> dict[str, dict]:
2563
  if isinstance(sib_pid, str) and sib_pid:
2564
  facts[sib_pid] = chosen
2565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2566
  return facts
2567
 
2568
 
@@ -2712,6 +2839,22 @@ def _corpus_pdf_index() -> dict[str, str]:
2712
  if k not in idx or rank < best.get(k, 9):
2713
  idx[k] = str(ap)
2714
  best[k] = rank
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2715
  _CORPUS_PDF_IDX = idx
2716
  return idx
2717
 
@@ -2749,7 +2892,15 @@ def policy_pdf(policy_id: str):
2749
  if not ap:
2750
  raise HTTPException(status_code=404, detail="No source PDF for this policy")
2751
  p = Path(ap).resolve()
2752
- if not (p.is_file() and str(p).startswith(str(settings.CORPUS_DIR.resolve()))):
 
 
 
 
 
 
 
 
2753
  raise HTTPException(status_code=404, detail="Source PDF not found")
2754
  return FileResponse(
2755
  str(p),
@@ -4390,6 +4541,17 @@ def _mg_data_signature() -> tuple:
4390
  sig.append((fp.name, int(st.st_mtime), st.st_size))
4391
  except Exception: # noqa: BLE001 — missing dir → empty contribution
4392
  continue
 
 
 
 
 
 
 
 
 
 
 
4393
  return tuple(sig)
4394
 
4395
 
 
524
  )
525
 
526
 
527
+ @app.on_event("startup")
528
+ async def _startup_reingest_uploaded_docs():
529
+ """#52 — re-materialise persisted uploaded-policy docs after a restart.
530
+
531
+ On the HF Space, rag/vectors is the EPHEMERAL container FS (KI-119):
532
+ every rebuild pulls a fresh Chroma snapshot, so an uploaded doc's
533
+ chunks indexed last boot are GONE. The PDF + curated-facts JSON + chunk
534
+ payload were persisted to the /data disk (settings.UPLOADED_DOCS_DIR),
535
+ so here we re-embed those chunks back into the fresh `policies`
536
+ collection. The cards themselves reappear automatically because
537
+ _load_curated_facts merges the persisted JSON records.
538
+
539
+ Wrapped so a re-ingest hiccup never crashes boot — but it logs LOUDLY
540
+ (no silent failure): an uploaded card with no retrievable chunks is a
541
+ real degradation operators must see.
542
+ """
543
+ try:
544
+ from backend import uploaded_docs as _udocs
545
+
546
+ summary = await _udocs.reingest_persisted_into_policies()
547
+ if summary.get("docs") or summary.get("skipped"):
548
+ logging.info(
549
+ "#52 startup re-ingest: %d uploaded docs / %d chunks "
550
+ "re-indexed into `policies` (%d skipped)",
551
+ summary.get("docs", 0), summary.get("chunks", 0),
552
+ summary.get("skipped", 0),
553
+ )
554
+ # Bust the #40 grade cache so the restored cards grade on first hit.
555
+ try:
556
+ with _MG_LOCK:
557
+ _MG_CACHE["sig"] = None
558
+ _MG_CACHE["index"] = None
559
+ except Exception: # noqa: BLE001
560
+ pass
561
+ except Exception as e: # noqa: BLE001 — re-ingest failure must not block boot
562
+ logging.warning(
563
+ "#52 startup re-ingest FAILED (%s: %s) — uploaded-doc cards "
564
+ "will show but their chunks are NOT retrievable until next "
565
+ "successful re-ingest",
566
+ type(e).__name__, e,
567
+ )
568
+
569
+
570
  async def _startup_purge_dangling_profile_chunks():
571
  """KI-117 — boot-time self-heal of dangling `doc_type='profile'` chunks.
572
 
 
1903
  record_accept(sha, sid, len(chunks))
1904
  except Exception:
1905
  pass
1906
+
1907
+ # ---- #52: PERSIST + add to THE (global) marketplace ----------------
1908
+ # The session-scoped quarantine add above is the immediate, private
1909
+ # path. #52 additionally requires the uploaded doc to become a REAL,
1910
+ # GRADED, PERSISTENT marketplace card that survives an HF Space
1911
+ # restart. So we:
1912
+ # (1) persist the raw PDF + a curated-facts-shaped JSON record +
1913
+ # the chunk payload under the PERSISTENT UPLOADED_DOCS_DIR,
1914
+ # (2) add the SAME chunks to the GLOBAL `policies` Chroma
1915
+ # collection (doc_type='user_upload') so they're retrievable
1916
+ # for everyone — per spec the doc is added to THE marketplace,
1917
+ # so global visibility is intentional; only the uploaded
1918
+ # document itself is exposed, never any session profile,
1919
+ # (3) invalidate the #40 marketplace-grade cache so the new card
1920
+ # grades immediately (the curated record flows through the
1921
+ # EXISTING _marketplace_catalogue Pass-2 + build_scorecard).
1922
+ # ANY failure here MUST surface (no silent failure): a 200 that
1923
+ # didn't persist would violate the #52 contract.
1924
+ from backend import uploaded_docs as _udocs
1925
+
1926
+ _record = _udocs.persist_upload(
1927
+ policy_id=policy_id,
1928
+ policy_name=policy_name,
1929
+ pdf_bytes=contents,
1930
+ full_text=full_text,
1931
+ chunks=chunks,
1932
+ session_id=sid,
1933
+ )
1934
+ # Global-collection ingest (idempotent — keyed by policy_id).
1935
+ from rag.ingest import get_chroma_collection as _get_pol_coll
1936
+ _pol = _get_pol_coll()
1937
+ _g_ids = [f"{policy_id}::chunk{c['chunk_idx']}" for c in chunks]
1938
+ _g_meta = [
1939
+ {
1940
+ "policy_id": policy_id,
1941
+ "insurer_slug": _udocs.UPLOAD_INSURER_SLUG,
1942
+ "policy_name": policy_name,
1943
+ "doc_type": _udocs.UPLOAD_DOC_TYPE,
1944
+ "source_url": "",
1945
+ "page_start": c["page_start"],
1946
+ "page_end": c["page_end"],
1947
+ "chunk_idx": c["chunk_idx"],
1948
+ # GLOBAL by design — NO session_id on these chunks.
1949
+ }
1950
+ for c in chunks
1951
+ ]
1952
+ try:
1953
+ _pol.delete(where={"policy_id": policy_id})
1954
+ except Exception: # noqa: BLE001 — nothing to delete on first upload
1955
+ pass
1956
+ _pol.add(ids=_g_ids, documents=texts, embeddings=vectors, metadatas=_g_meta)
1957
+ _abort_if_hnsw_bloated()
1958
+ # Bust the #40 grade cache + the corpus-pdf index so the new card
1959
+ # appears immediately with a real grade.
1960
+ try:
1961
+ global _CORPUS_PDF_IDX
1962
+ _CORPUS_PDF_IDX = None
1963
+ with _MG_LOCK:
1964
+ _MG_CACHE["sig"] = None
1965
+ _MG_CACHE["index"] = None
1966
+ except Exception: # noqa: BLE001 — cache bust is best-effort
1967
+ pass
1968
  except HTTPException:
1969
  raise
1970
  except Exception as e:
 
2668
  if isinstance(sib_pid, str) and sib_pid:
2669
  facts[sib_pid] = chosen
2670
 
2671
+ # #52 — merge PERSISTED user-uploaded docs into the curated layer so each
2672
+ # surfaces as a marketplace card via the EXISTING _marketplace_catalogue
2673
+ # Pass-2 + build_scorecard path (NO grading re-implementation). Records
2674
+ # are already in the curated `{field:{value,source_*}}` shape; run them
2675
+ # through the same _flatten so per-field provenance is preserved. They
2676
+ # have unique `user-upload__*` policy_ids so they can never collide with
2677
+ # a real curated product key. A failure here must NOT break the curated
2678
+ # layer for the 200+ real policies — log + continue.
2679
+ try:
2680
+ from backend import uploaded_docs as _udocs
2681
+
2682
+ for _pid, _rec in _udocs.load_persisted_records().items():
2683
+ if not isinstance(_rec, dict):
2684
+ continue
2685
+ facts[_pid] = _flatten(_rec, _pid)
2686
+ except Exception as e: # noqa: BLE001 — uploaded layer is additive
2687
+ logging.warning(
2688
+ "uploaded-docs curated merge failed (%s: %s) — "
2689
+ "marketplace falls back to corpus-only cards",
2690
+ type(e).__name__, e,
2691
+ )
2692
+
2693
  return facts
2694
 
2695
 
 
2839
  if k not in idx or rank < best.get(k, 9):
2840
  idx[k] = str(ap)
2841
  best[k] = rank
2842
+
2843
+ # #52 — persisted uploaded docs keep their real PDF in the persistent
2844
+ # UPLOADED_DOCS_DIR (NOT rag/corpus). Map their policy_id → that file so
2845
+ # the marketplace card's /api/policy-pdf link resolves to the exact
2846
+ # document the user uploaded and that the card was graded from.
2847
+ try:
2848
+ for d in sorted(settings.UPLOADED_DOCS_DIR.glob("*/source.pdf")):
2849
+ meta_p = d.parent / "meta.json"
2850
+ try:
2851
+ pid = json.loads(meta_p.read_text()).get("policy_id") or d.parent.name
2852
+ except Exception: # noqa: BLE001
2853
+ pid = d.parent.name
2854
+ idx[pid] = str(d.resolve())
2855
+ except Exception: # noqa: BLE001 — uploaded-pdf index is additive
2856
+ pass
2857
+
2858
  _CORPUS_PDF_IDX = idx
2859
  return idx
2860
 
 
2892
  if not ap:
2893
  raise HTTPException(status_code=404, detail="No source PDF for this policy")
2894
  p = Path(ap).resolve()
2895
+ # #52 also allow the persistent uploaded-docs store (the uploaded PDF
2896
+ # lives there, not in rag/corpus). Both roots are server-controlled
2897
+ # directories; the index only ever maps to files inside one of them, so
2898
+ # this stays a strict allowlist (no traversal surface).
2899
+ _allowed_roots = (
2900
+ str(settings.CORPUS_DIR.resolve()),
2901
+ str(settings.UPLOADED_DOCS_DIR.resolve()),
2902
+ )
2903
+ if not (p.is_file() and any(str(p).startswith(r) for r in _allowed_roots)):
2904
  raise HTTPException(status_code=404, detail="Source PDF not found")
2905
  return FileResponse(
2906
  str(p),
 
4541
  sig.append((fp.name, int(st.st_mtime), st.st_size))
4542
  except Exception: # noqa: BLE001 — missing dir → empty contribution
4543
  continue
4544
+ # #52 — PERSISTED uploaded-doc records are ALSO grading inputs
4545
+ # (_load_curated_facts merges them). Walk the persistent UPLOADED_DOCS_DIR
4546
+ # so a brand-new upload — or a restart that re-materialised the dir —
4547
+ # invalidates the #40 grade cache and the new card grades immediately.
4548
+ try:
4549
+ for fp in sorted(settings.UPLOADED_DOCS_DIR.glob("*/record.json")):
4550
+ st = fp.stat()
4551
+ sig.append((str(fp.relative_to(settings.UPLOADED_DOCS_DIR)),
4552
+ int(st.st_mtime), st.st_size))
4553
+ except Exception: # noqa: BLE001 — missing dir → empty contribution
4554
+ pass
4555
  return tuple(sig)
4556
 
4557
 
backend/retrieval_filters.py CHANGED
@@ -367,8 +367,14 @@ def apply_profile_filter(chunks: Iterable[Any], profile: Any) -> list[Any]:
367
  for ch in chunks_list:
368
  m = _meta(ch)
369
  doc_type = (m.get("doc_type") or "").lower()
370
- # Never drop non-policy chunks via demographic filter
371
- if doc_type in ("profile", "regulatory", "review"):
 
 
 
 
 
 
372
  kept.append(ch)
373
  continue
374
 
@@ -736,7 +742,9 @@ def apply_eligibility_filter(chunks: Iterable[Any], profile: Any) -> list[Any]:
736
  for ch in chunks_list:
737
  m = _meta_full(ch)
738
  doc_type = (m.get("doc_type") or "").lower()
739
- if doc_type in ("profile", "regulatory", "review"):
 
 
740
  kept.append(ch)
741
  continue
742
 
@@ -910,7 +918,11 @@ def rank_by_profile_fit(chunks: Iterable[Any], profile: Any) -> list[Any]:
910
  policy: list[Any] = []
911
  for ch in chunks_list:
912
  dt = (_meta_full(ch).get("doc_type") or "").lower()
913
- (non_policy if dt in ("profile", "regulatory", "review") else policy).append(ch)
 
 
 
 
914
 
915
  # Decorate-sort-undecorate with original index as the stable tiebreaker.
916
  decorated = [
 
367
  for ch in chunks_list:
368
  m = _meta(ch)
369
  doc_type = (m.get("doc_type") or "").lower()
370
+ # Never drop non-policy chunks via demographic filter.
371
+ # #52 `user_upload` is a globally-visible uploaded marketplace
372
+ # doc: a Q&A TARGET, not a demographically-ranked recommendable
373
+ # corpus policy. Exempt it exactly like regulatory/review so a
374
+ # question literally about the uploaded document isn't dropped
375
+ # because the (often anonymous) asker's age/eligibility doesn't
376
+ # match the uploaded plan.
377
+ if doc_type in ("profile", "regulatory", "review", "user_upload"):
378
  kept.append(ch)
379
  continue
380
 
 
742
  for ch in chunks_list:
743
  m = _meta_full(ch)
744
  doc_type = (m.get("doc_type") or "").lower()
745
+ # #52 uploaded marketplace docs are Q&A targets; never hard-drop
746
+ # them on eligibility (same class as regulatory/review).
747
+ if doc_type in ("profile", "regulatory", "review", "user_upload"):
748
  kept.append(ch)
749
  continue
750
 
 
918
  policy: list[Any] = []
919
  for ch in chunks_list:
920
  dt = (_meta_full(ch).get("doc_type") or "").lower()
921
+ # #52 keep uploaded marketplace docs in the non-policy lane so
922
+ # profile-fit re-ranking can't bury them below recommendable corpus
923
+ # policies when the user asked about the uploaded doc itself.
924
+ (non_policy if dt in ("profile", "regulatory", "review", "user_upload")
925
+ else policy).append(ch)
926
 
927
  # Decorate-sort-undecorate with original index as the stable tiebreaker.
928
  decorated = [
backend/uploaded_docs.py ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent uploaded-policy store (#52 — graded server-assignment).
2
+
3
+ WHAT THIS MODULE DOES
4
+ ---------------------
5
+ When a user uploads a PDF via POST /api/upload-policy, three things must
6
+ survive an HF Space restart and become globally visible:
7
+
8
+ 1. the raw PDF bytes,
9
+ 2. a curated-facts-shaped JSON record (the SAME `{field:{value,
10
+ source_pdf_path, source_quote, _confidence}}` schema that
11
+ 40-data/policy_facts/*.json uses, so it flows through the EXISTING
12
+ `backend.main._load_curated_facts` -> `_marketplace_catalogue` Pass-2
13
+ -> `build_scorecard` path with ZERO grading re-implementation), and
14
+ 3. enough to re-index the document's chunks into the working Chroma
15
+ `policies` collection on the next boot.
16
+
17
+ PERSISTENCE MODEL
18
+ -----------------
19
+ Everything lands under `settings.UPLOADED_DOCS_DIR`:
20
+
21
+ <UPLOADED_DOCS_DIR>/
22
+ <policy_id>/
23
+ source.pdf # raw uploaded bytes
24
+ record.json # curated-facts-shaped JSON (the card)
25
+ chunks.json # [{chunk_idx,text,page_start,page_end}, ...]
26
+ meta.json # {policy_id, policy_name, insurer_slug,
27
+ # sha256, uploaded_at, session_id}
28
+
29
+ On the HF Space `settings.UPLOADED_DOCS_DIR` resolves to a directory on
30
+ the PERSISTENT `/data` disk (see backend/config.py + entrypoint.sh), so a
31
+ Space rebuild — which throws away the ephemeral container FS including
32
+ rag/vectors — does NOT lose uploaded policies. Locally (no /data) it
33
+ resolves under settings.DATA_DIR so the exact same code path works.
34
+
35
+ PRIVACY MODEL (explicit, per #52 spec)
36
+ --------------------------------------
37
+ The #52 spec says the uploaded doc is *added to THE (global) marketplace*.
38
+ So once a user uploads a policy it is intentionally a public marketplace
39
+ card and its chunks are globally retrievable (doc_type='user_upload' in
40
+ the main `policies` collection). The persistent store therefore contains
41
+ ONLY the uploaded policy document itself + data derived from it — never a
42
+ session profile, never another user's data. `session_id` is recorded in
43
+ meta.json purely for operational audit/abuse-tracing; it is NEVER used to
44
+ gate visibility of the card or the chunks (those are global by design) and
45
+ is NEVER written into the Chroma chunk metadata of the global collection.
46
+ The pre-existing session-scoped `user_uploads_quarantine` collection is a
47
+ separate, private, ephemeral path and is untouched by this module.
48
+
49
+ NO SILENT FAILURES
50
+ ------------------
51
+ Every function here either succeeds or raises a typed exception with a
52
+ clear message. Callers (backend.main) decide whether a failure is fatal to
53
+ the request (record creation) or best-effort-logged (startup re-ingest of
54
+ ONE doc must not abort boot, but the failure is logged loudly).
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import hashlib
60
+ import json
61
+ import logging
62
+ import re
63
+ import time
64
+ from pathlib import Path
65
+ from typing import Any, Optional
66
+
67
+ from backend.config import settings
68
+
69
+ _log = logging.getLogger(__name__)
70
+
71
+ # Chroma metadata doc_type for a persisted, globally-visible uploaded doc.
72
+ # Deliberately the SAME token the quarantine path uses so the existing
73
+ # brain_tools UPLOADED-DOC handling + retrieve.py treat it identically.
74
+ UPLOAD_DOC_TYPE = "user_upload"
75
+
76
+ # Insurer slug for uploaded docs. MUST NOT be "regulatory" (that slug is
77
+ # filtered out of the marketplace) and MUST be stable so the card always
78
+ # resolves the same insurer_meta fallback.
79
+ UPLOAD_INSURER_SLUG = "user-upload"
80
+ UPLOAD_INSURER_NAME = "User-uploaded document"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Storage layout
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def uploaded_docs_dir() -> Path:
89
+ """The persistent root for uploaded docs. Created on first use."""
90
+ d = settings.UPLOADED_DOCS_DIR
91
+ d.mkdir(parents=True, exist_ok=True)
92
+ return d
93
+
94
+
95
+ def _doc_dir(policy_id: str) -> Path:
96
+ # policy_id is already a tight slug (see backend.main.upload_policy:
97
+ # user-upload__<sid12>__<fileslug>) but defend against path traversal.
98
+ safe = re.sub(r"[^a-zA-Z0-9_.\-]+", "-", policy_id).strip("-") or "user-upload"
99
+ return uploaded_docs_dir() / safe
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Heuristic field extraction -> curated-facts-shaped record
104
+ #
105
+ # The repo's LLM extractor (rag/extract.py) needs network + the NIM brain.
106
+ # That is correct for the corpus build but unusable inside a request (and
107
+ # untestable offline). So we derive a REAL, sourced record deterministically
108
+ # from the PDF's own text via regex over the IRDAI-standardised wording that
109
+ # every Indian health policy uses. Each field we emit carries the verbatim
110
+ # source_quote it was matched from — nothing is fabricated; a field is only
111
+ # emitted when its evidence is literally present in the document.
112
+ #
113
+ # This populates well above the scorecard's MIN_GRADEABLE_COMPLETENESS_PCT
114
+ # (9.0 == ~2 of 23 SCORED_FIELDS) so the card grades for real instead of
115
+ # returning the data-starved "—"/0 sentinel. When the document genuinely
116
+ # lacks structured terms, we DO NOT invent any — the card then honestly
117
+ # shows the sentinel, which is the correct behaviour.
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def _ctx(text: str, m: re.Match, pad: int = 90) -> str:
122
+ """Verbatim surrounding snippet for a regex match (the source_quote)."""
123
+ s = max(0, m.start() - pad)
124
+ e = min(len(text), m.end() + pad)
125
+ return re.sub(r"\s+", " ", text[s:e]).strip()[:300]
126
+
127
+
128
+ def _fact(value: Any, quote: str, conf: str = "medium") -> dict:
129
+ """A curated-facts cell: {value, source_pdf_path, source_quote, _confidence}."""
130
+ return {
131
+ "value": value,
132
+ "source_pdf_path": "", # filled by the caller with the persisted PDF path
133
+ "source_quote": quote,
134
+ "_confidence": conf,
135
+ }
136
+
137
+
138
+ def extract_fields_from_text(full_text: str) -> dict[str, dict]:
139
+ """Regex-derive scorecard-relevant fields from policy text.
140
+
141
+ Returns a {field_name: <fact cell>} dict using the SAME canonical field
142
+ names backend.scorecard.SCORED_FIELDS / ALIASES read. Only fields with
143
+ literal textual evidence are emitted. Never raises (a totally
144
+ unparseable doc just yields {}).
145
+ """
146
+ t = full_text or ""
147
+ low = t.lower()
148
+ out: dict[str, dict] = {}
149
+
150
+ def add(field: str, value: Any, m: Optional[re.Match], conf: str = "medium"):
151
+ if value is None:
152
+ return
153
+ if field in out:
154
+ return
155
+ quote = _ctx(t, m) if m is not None else ""
156
+ out[field] = _fact(value, quote, conf)
157
+
158
+ # --- UIN (regulator identity; not a scored field but anchors the card) --
159
+ m = re.search(r"\b([A-Z]{3}[A-Z0-9]{10,22}V\d{6})\b", t)
160
+ if m:
161
+ add("uin_code", m.group(1), m, "high")
162
+
163
+ # --- Initial waiting period (days) -------------------------------------
164
+ m = re.search(
165
+ r"(\d{1,3})\s*days?[^.]{0,80}?(?:waiting period|from the (?:first )?"
166
+ r"(?:policy )?(?:commencement|inception)|shall be excluded)",
167
+ t, re.IGNORECASE,
168
+ ) or re.search(
169
+ r"(?:waiting period|initial waiting)[^.]{0,60}?(\d{1,3})\s*days?",
170
+ t, re.IGNORECASE,
171
+ )
172
+ if m:
173
+ d = int(m.group(1))
174
+ if 0 < d <= 90:
175
+ add("initial_waiting_period_days", d, m, "high")
176
+
177
+ # --- Pre-existing disease waiting (months) -----------------------------
178
+ m = re.search(
179
+ r"pre[\-\s]?existing[^.]{0,120}?(\d{1,2})\s*(?:months|month)",
180
+ t, re.IGNORECASE,
181
+ ) or re.search(
182
+ r"(\d{1,2})\s*months[^.]{0,80}?pre[\-\s]?existing",
183
+ t, re.IGNORECASE,
184
+ )
185
+ if m:
186
+ mo = int(m.group(1))
187
+ if 0 < mo <= 72:
188
+ add("pre_existing_disease_waiting_months", mo, m, "high")
189
+
190
+ # --- Specific-disease waiting (months) ---------------------------------
191
+ m = re.search(
192
+ r"(?:specific (?:disease|illness)|cataract|hernia)[^.]{0,120}?"
193
+ r"(\d{1,2})\s*months",
194
+ t, re.IGNORECASE,
195
+ )
196
+ if m:
197
+ mo = int(m.group(1))
198
+ if 0 < mo <= 48:
199
+ add("specific_disease_waiting_months", mo, m, "medium")
200
+
201
+ # --- Maternity waiting (months) ----------------------------------------
202
+ m = re.search(
203
+ r"maternity[^.]{0,120}?(\d{1,2})\s*months",
204
+ t, re.IGNORECASE,
205
+ )
206
+ if m:
207
+ mo = int(m.group(1))
208
+ if 0 < mo <= 48:
209
+ add("maternity_waiting_months", mo, m, "medium")
210
+
211
+ # --- Pre / post hospitalisation (days) ---------------------------------
212
+ m = re.search(r"pre[\-\s]?hospitali[sz]ation[^.]{0,60}?(\d{1,3})\s*days", t, re.IGNORECASE)
213
+ if m:
214
+ d = int(m.group(1))
215
+ if 0 < d <= 180:
216
+ add("pre_hospitalization_days", d, m, "high")
217
+ m = re.search(r"post[\-\s]?hospitali[sz]ation[^.]{0,60}?(\d{1,3})\s*days", t, re.IGNORECASE)
218
+ if m:
219
+ d = int(m.group(1))
220
+ if 0 < d <= 365:
221
+ add("post_hospitalization_days", d, m, "high")
222
+
223
+ # --- Co-payment (%) ----------------------------------------------------
224
+ m = re.search(r"co[\-\s]?pay(?:ment)?[^.]{0,80}?(\d{1,2})\s*%", t, re.IGNORECASE) \
225
+ or re.search(r"(\d{1,2})\s*%[^.]{0,40}?co[\-\s]?pay", t, re.IGNORECASE)
226
+ if m:
227
+ pct = int(m.group(1))
228
+ if 0 <= pct <= 50:
229
+ add("copayment_pct", pct, m, "medium")
230
+
231
+ # --- No-claim bonus (%) ------------------------------------------------
232
+ m = re.search(
233
+ r"(?:no[\-\s]?claim bonus|cumulative bonus|ncb)[^.]{0,80}?(\d{1,3})\s*%",
234
+ t, re.IGNORECASE,
235
+ )
236
+ if m:
237
+ pct = int(m.group(1))
238
+ if 0 < pct <= 200:
239
+ # MarketplacePolicy.no_claim_bonus_pct is Optional[int]; the
240
+ # scorecard reads it numerically either way. Emit int.
241
+ add("no_claim_bonus_pct", pct, m, "medium")
242
+
243
+ # --- Room rent capping -------------------------------------------------
244
+ m = re.search(
245
+ r"room rent[^.]{0,90}?(no (?:sub[\-\s]?limit|cap|capping|limit)|"
246
+ r"\d{1,2}\s*%\s*(?:of\s*(?:the\s*)?sum insured|of si)?|single private|"
247
+ r"twin sharing|shared accommodation)",
248
+ t, re.IGNORECASE,
249
+ )
250
+ if m:
251
+ cap = m.group(1).strip()
252
+ if re.search(r"no (sub[\-\s]?limit|cap|capping|limit)", cap, re.IGNORECASE):
253
+ cap = "No room rent cap"
254
+ add("room_rent_capping", cap, m, "medium")
255
+
256
+ # --- Network hospital count -------------------------------------------
257
+ m = re.search(
258
+ r"([\d,]{3,7})\+?\s*(?:network |empanelled |cashless )?hospitals?",
259
+ t, re.IGNORECASE,
260
+ )
261
+ if m:
262
+ try:
263
+ n = int(m.group(1).replace(",", ""))
264
+ if 50 <= n <= 50000:
265
+ add("network_hospital_count", n, m, "medium")
266
+ except ValueError:
267
+ pass
268
+
269
+ # --- Cashless supported -----------------------------------------------
270
+ if "cashless" in low:
271
+ m = re.search(r"cashless[^.]{0,80}", t, re.IGNORECASE)
272
+ add("cashless_treatment_supported", True, m, "medium")
273
+
274
+ # --- Max entry age (years) --------------------------------------------
275
+ m = re.search(
276
+ r"(?:maximum |max\.? )?entry age[^.]{0,40}?(\d{2,3})\s*years",
277
+ t, re.IGNORECASE,
278
+ ) or re.search(
279
+ r"entry age[^.]{0,40}?up to\s*(\d{2,3})\s*years", t, re.IGNORECASE,
280
+ )
281
+ if m:
282
+ age = int(m.group(1))
283
+ if 30 <= age <= 100:
284
+ add("max_entry_age", age, m, "medium")
285
+
286
+ # --- AYUSH coverage ----------------------------------------------------
287
+ if re.search(r"\bayush\b", low) or "ayurved" in low:
288
+ m = re.search(r"ayush[^.]{0,90}", t, re.IGNORECASE) or re.search(
289
+ r"ayurved[^.]{0,90}", t, re.IGNORECASE)
290
+ add("ayush_coverage", {"covered": True}, m, "medium")
291
+
292
+ # --- Maternity coverage (boolean-with-detail) -------------------------
293
+ if "maternity" in low:
294
+ m = re.search(r"maternity[^.]{0,120}", t, re.IGNORECASE)
295
+ covered = not bool(re.search(
296
+ r"maternity[^.]{0,40}(not covered|excluded|no cover)", t, re.IGNORECASE))
297
+ add("maternity_coverage", {"covered": covered}, m, "medium")
298
+
299
+ # --- Ambulance / day-care / restoration (presence booleans) -----------
300
+ if "ambulance" in low:
301
+ m = re.search(r"ambulance[^.]{0,90}", t, re.IGNORECASE)
302
+ add("ambulance_cover", {"covered": True}, m, "low")
303
+ if "day care" in low or "day-care" in low or "daycare" in low:
304
+ m = re.search(r"day[\-\s]?care[^.]{0,90}", t, re.IGNORECASE)
305
+ add("day_care_treatments_count", {"covered": True, "limit_text": "Day-care procedures covered"}, m, "low")
306
+ if "restoration" in low or "refill" in low or "reinstatement" in low:
307
+ m = re.search(r"(restoration|refill|reinstatement)[^.]{0,90}", t, re.IGNORECASE)
308
+ add("restoration_benefit", {"covered": True}, m, "low")
309
+
310
+ # --- Claim settlement ratio (insurer-level; commonly stated in CIS) ----
311
+ m = re.search(
312
+ r"claim settlement ratio[^.]{0,40}?(\d{2,3}(?:\.\d{1,2})?)\s*%",
313
+ t, re.IGNORECASE,
314
+ )
315
+ if m:
316
+ try:
317
+ csr = float(m.group(1))
318
+ if 30 <= csr <= 100:
319
+ add("claim_settlement_ratio", csr, m, "medium")
320
+ except ValueError:
321
+ pass
322
+
323
+ return out
324
+
325
+
326
+ def _derive_policy_name(full_text: str, fallback: str) -> str:
327
+ """Best-effort human policy name from the document header."""
328
+ for line in (full_text or "").splitlines():
329
+ s = line.strip()
330
+ if not s:
331
+ continue
332
+ if re.search(r"(policy|plan|insurance|mediclaim|health)", s, re.IGNORECASE) \
333
+ and 6 <= len(s) <= 90:
334
+ return re.sub(r"\s+", " ", s)
335
+ return fallback
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Persisted record (curated-facts JSON) + PDF + chunk payload
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ def build_record(
344
+ policy_id: str,
345
+ policy_name: str,
346
+ full_text: str,
347
+ persisted_pdf_path: str,
348
+ ) -> dict:
349
+ """Build the curated-facts-shaped JSON the marketplace Pass-2 consumes.
350
+
351
+ The returned dict is the EXACT shape `_load_curated_facts._flatten`
352
+ expects: scalar identity keys + per-field `{value, source_*}` cells.
353
+ """
354
+ fields = extract_fields_from_text(full_text)
355
+ rel_pdf = persisted_pdf_path
356
+ for cell in fields.values():
357
+ if isinstance(cell, dict) and "source_pdf_path" in cell:
358
+ cell["source_pdf_path"] = rel_pdf
359
+
360
+ record: dict[str, Any] = {
361
+ "policy_id": policy_id,
362
+ "policy_name": policy_name or _derive_policy_name(full_text, policy_id),
363
+ "insurer_slug": UPLOAD_INSURER_SLUG,
364
+ "_uploaded_doc": True, # provenance flag (ignored by scorecard)
365
+ }
366
+ record.update(fields)
367
+ return record
368
+
369
+
370
+ def persist_upload(
371
+ *,
372
+ policy_id: str,
373
+ policy_name: str,
374
+ pdf_bytes: bytes,
375
+ full_text: str,
376
+ chunks: list[dict],
377
+ session_id: str,
378
+ ) -> dict:
379
+ """Atomically persist the PDF + JSON record + chunk payload + meta.
380
+
381
+ Returns the built record dict. Raises RuntimeError on any failure (the
382
+ caller MUST surface this — a "successful" upload that didn't persist is
383
+ a silent failure and is forbidden by the #52 spec).
384
+ """
385
+ try:
386
+ ddir = _doc_dir(policy_id)
387
+ ddir.mkdir(parents=True, exist_ok=True)
388
+
389
+ pdf_path = ddir / "source.pdf"
390
+ pdf_path.write_bytes(pdf_bytes)
391
+
392
+ record = build_record(
393
+ policy_id, policy_name, full_text, persisted_pdf_path=str(pdf_path),
394
+ )
395
+
396
+ # Write to temp files then os.replace for crash-atomic visibility.
397
+ rec_tmp = ddir / "record.json.tmp"
398
+ rec_tmp.write_text(json.dumps(record, indent=2, ensure_ascii=False))
399
+ rec_tmp.replace(ddir / "record.json")
400
+
401
+ chunk_payload = [
402
+ {
403
+ "chunk_idx": c["chunk_idx"],
404
+ "text": c["text"],
405
+ "page_start": c["page_start"],
406
+ "page_end": c["page_end"],
407
+ }
408
+ for c in chunks
409
+ ]
410
+ ch_tmp = ddir / "chunks.json.tmp"
411
+ ch_tmp.write_text(json.dumps(chunk_payload, ensure_ascii=False))
412
+ ch_tmp.replace(ddir / "chunks.json")
413
+
414
+ meta = {
415
+ "policy_id": policy_id,
416
+ "policy_name": record["policy_name"],
417
+ "insurer_slug": UPLOAD_INSURER_SLUG,
418
+ "sha256": hashlib.sha256(pdf_bytes).hexdigest(),
419
+ "uploaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
420
+ "session_id": session_id, # audit only — NEVER a visibility gate
421
+ "n_chunks": len(chunk_payload),
422
+ }
423
+ meta_tmp = ddir / "meta.json.tmp"
424
+ meta_tmp.write_text(json.dumps(meta, indent=2))
425
+ meta_tmp.replace(ddir / "meta.json")
426
+
427
+ _log.info(
428
+ "persisted uploaded doc %s (%d fields, %d chunks) -> %s",
429
+ policy_id, len([k for k in record if not k.startswith(("policy_", "insurer_", "_"))]),
430
+ len(chunk_payload), ddir,
431
+ )
432
+ return record
433
+ except Exception as e: # noqa: BLE001 — convert to a loud typed failure
434
+ raise RuntimeError(
435
+ f"persist_upload failed for {policy_id}: {type(e).__name__}: {e}"
436
+ ) from e
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Read side — used by _load_curated_facts (cards) + startup re-ingest (chunks)
441
+ # ---------------------------------------------------------------------------
442
+
443
+
444
+ def load_persisted_records() -> dict[str, dict]:
445
+ """{policy_id: curated-facts-shaped record} for every persisted upload.
446
+
447
+ Consumed by backend.main._load_curated_facts so each uploaded doc
448
+ surfaces as a marketplace card via the EXISTING Pass-2 + build_scorecard
449
+ path. A single corrupt record is skipped (logged) — it must not take
450
+ down the whole catalogue.
451
+ """
452
+ out: dict[str, dict] = {}
453
+ root = settings.UPLOADED_DOCS_DIR
454
+ if not root.exists():
455
+ return out
456
+ for d in sorted(root.iterdir()):
457
+ if not d.is_dir():
458
+ continue
459
+ rec_path = d / "record.json"
460
+ if not rec_path.exists():
461
+ continue
462
+ try:
463
+ rec = json.loads(rec_path.read_text())
464
+ pid = rec.get("policy_id") or d.name
465
+ out[pid] = rec
466
+ except Exception as e: # noqa: BLE001
467
+ _log.warning(
468
+ "skipping corrupt uploaded record %s: %s: %s",
469
+ rec_path, type(e).__name__, e,
470
+ )
471
+ continue
472
+ return out
473
+
474
+
475
+ def iter_persisted_chunks():
476
+ """Yield (policy_id, policy_name, [chunk dicts]) for every persisted doc.
477
+
478
+ Used by the startup re-ingest to rebuild the uploaded docs' vectors in
479
+ the working Chroma `policies` collection after a Space restart wiped the
480
+ ephemeral rag/vectors snapshot.
481
+ """
482
+ root = settings.UPLOADED_DOCS_DIR
483
+ if not root.exists():
484
+ return
485
+ for d in sorted(root.iterdir()):
486
+ if not d.is_dir():
487
+ continue
488
+ ch_path = d / "chunks.json"
489
+ meta_path = d / "meta.json"
490
+ if not (ch_path.exists() and meta_path.exists()):
491
+ continue
492
+ try:
493
+ meta = json.loads(meta_path.read_text())
494
+ chunks = json.loads(ch_path.read_text())
495
+ except Exception as e: # noqa: BLE001
496
+ _log.warning(
497
+ "skipping unreadable persisted chunks %s: %s: %s",
498
+ ch_path, type(e).__name__, e,
499
+ )
500
+ continue
501
+ yield (
502
+ meta.get("policy_id") or d.name,
503
+ meta.get("policy_name") or d.name,
504
+ chunks,
505
+ )
506
+
507
+
508
+ async def reingest_persisted_into_policies() -> dict:
509
+ """Re-embed every persisted uploaded doc's chunks into the working
510
+ Chroma `policies` collection (idempotent: deletes the doc's prior
511
+ chunks first, keyed by policy_id).
512
+
513
+ Globally visible by design (#52: uploaded doc is added to THE
514
+ marketplace). Returns a small summary dict. Raises only if Chroma /
515
+ embedder are completely unavailable; a single bad doc is logged and
516
+ skipped so one corrupt upload can't block boot.
517
+ """
518
+ from rag.ingest import get_chroma_collection
519
+ from backend.providers.local_embeddings import LocalEmbeddings
520
+
521
+ docs = list(iter_persisted_chunks())
522
+ summary = {"docs": 0, "chunks": 0, "skipped": 0}
523
+ if not docs:
524
+ return summary
525
+
526
+ collection = get_chroma_collection()
527
+ embedder = LocalEmbeddings()
528
+
529
+ for policy_id, policy_name, chunks in docs:
530
+ if not chunks:
531
+ summary["skipped"] += 1
532
+ continue
533
+ try:
534
+ texts = [c["text"] for c in chunks]
535
+ vectors = await embedder.embed(texts, input_type="document")
536
+ ids = [f"{policy_id}::chunk{c['chunk_idx']}" for c in chunks]
537
+ metadatas = [
538
+ {
539
+ "policy_id": policy_id,
540
+ "insurer_slug": UPLOAD_INSURER_SLUG,
541
+ "policy_name": policy_name,
542
+ "doc_type": UPLOAD_DOC_TYPE,
543
+ "source_url": "",
544
+ "page_start": c["page_start"],
545
+ "page_end": c["page_end"],
546
+ "chunk_idx": c["chunk_idx"],
547
+ # NOTE: no session_id — these are GLOBAL marketplace
548
+ # chunks by design, not session-private quarantine.
549
+ }
550
+ for c in chunks
551
+ ]
552
+ try:
553
+ collection.delete(where={"policy_id": policy_id})
554
+ except Exception: # noqa: BLE001 — first-ever ingest has nothing to delete
555
+ pass
556
+ collection.add(
557
+ ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas,
558
+ )
559
+ summary["docs"] += 1
560
+ summary["chunks"] += len(chunks)
561
+ _log.info(
562
+ "re-ingested uploaded doc %s (%d chunks) into policies",
563
+ policy_id, len(chunks),
564
+ )
565
+ except Exception as e: # noqa: BLE001 — one bad doc must not block boot
566
+ summary["skipped"] += 1
567
+ _log.warning(
568
+ "startup re-ingest skipped %s: %s: %s",
569
+ policy_id, type(e).__name__, e,
570
+ )
571
+ return summary
entrypoint.sh CHANGED
@@ -40,6 +40,18 @@ if [ -d "/data" ] && [ -w "/data" ]; then
40
  fi
41
  rm -f /app/rag/policies.duckdb
42
  ln -sf /data/policies.duckdb /app/rag/policies.duckdb
 
 
 
 
 
 
 
 
 
 
 
 
43
  # Vectors stay at /app/rag/vectors — read from the fresh dataset
44
  # snapshot. The previous /data/vectors symlink is intentionally removed.
45
  if [ -L "/app/rag/vectors" ]; then
 
40
  fi
41
  rm -f /app/rag/policies.duckdb
42
  ln -sf /data/policies.duckdb /app/rag/policies.duckdb
43
+
44
+ # #52 — PERSIST user-uploaded policy docs across Space rebuilds.
45
+ #
46
+ # Unlike rag/vectors (intentionally ephemeral — KI-119), an uploaded
47
+ # policy that became a marketplace card MUST survive a restart. We point
48
+ # backend.config.settings.UPLOADED_DOCS_DIR at the persistent /data disk;
49
+ # the FastAPI startup handler (_startup_reingest_uploaded_docs) re-embeds
50
+ # the persisted chunks into the fresh Chroma snapshot on boot, and
51
+ # _load_curated_facts merges the persisted JSON records so the cards
52
+ # reappear. Locally (no /data) the same code uses 40-data/uploaded_docs.
53
+ export UPLOADED_DOCS_DIR="/data/uploaded_docs"
54
+ mkdir -p /data/uploaded_docs
55
  # Vectors stay at /app/rag/vectors — read from the fresh dataset
56
  # snapshot. The previous /data/vectors symlink is intentionally removed.
57
  if [ -L "/app/rag/vectors" ]; then
rag/retrieve.py CHANGED
@@ -460,6 +460,43 @@ async def retrieve(
460
  # Review boost is additive; failure shouldn't kill the main result
461
  pass
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  # KI-034 — populate cache with the FINAL merged result so subsequent
464
  # identical queries skip Voyage embed + Chroma query + boost passes.
465
  _cache_set(cache_key, out)
 
460
  # Review boost is additive; failure shouldn't kill the main result
461
  pass
462
 
463
+ # #52 — uploaded-doc global merge pass. A user-uploaded policy that was
464
+ # added to THE marketplace (doc_type='user_upload', NO session_id — it
465
+ # is globally visible by design) is usually a 1–few-chunk document, so
466
+ # it loses the raw-cosine race against the 140+ multi-chunk corpus
467
+ # policies and never enters the top-k even when it's the best answer to
468
+ # a question literally about that document. Mirror the regulatory /
469
+ # review boost passes: run a SECOND query restricted to
470
+ # doc_type='user_upload', score-boost the hits, and merge. Skipped when
471
+ # the caller already scoped to specific policies/insurers (then they're
472
+ # asking about a known corpus policy, not browsing uploaded docs). The
473
+ # session-scoped quarantine pass above is unaffected — that path serves
474
+ # the uploader's OWN still-private upload; this serves docs already
475
+ # promoted to the public marketplace.
476
+ if not policy_ids and not insurer_slugs:
477
+ try:
478
+ up_res = collection.query(
479
+ query_embeddings=[query_vec],
480
+ n_results=3,
481
+ where={"doc_type": "user_upload"},
482
+ )
483
+ if up_res["ids"] and up_res["ids"][0]:
484
+ seen = {c.chunk_id for c in out}
485
+ up_chunks: list[RetrievedChunk] = []
486
+ for cid, doc, meta, dist in zip(
487
+ up_res["ids"][0], up_res["documents"][0],
488
+ up_res["metadatas"][0], up_res["distances"][0],
489
+ ):
490
+ if cid in seen:
491
+ continue
492
+ boosted = (1.0 - dist) * 1.1
493
+ up_chunks.append(_build_chunk(cid, doc, meta, boosted))
494
+ merged = sorted(out + up_chunks, key=lambda c: c.score, reverse=True)
495
+ out = merged[:top_k]
496
+ except Exception:
497
+ # Uploaded-doc boost is additive; failure must not break retrieval
498
+ pass
499
+
500
  # KI-034 — populate cache with the FINAL merged result so subsequent
501
  # identical queries skip Voyage embed + Chroma query + boost passes.
502
  _cache_set(cache_key, out)
tests/test_pdf_upload_to_marketplace_e2e.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """#52 — graded server-assignment for user-uploaded PDFs (E2E regression).
2
+
3
+ WHAT #52 GUARANTEES (and this test pins, end-to-end, fully offline)
4
+ ------------------------------------------------------------------
5
+ When a user uploads a policy PDF it must:
6
+ 1. be accepted (POST /api/upload-policy, 8 security gates),
7
+ 2. be chunked + bge-small-embedded into the SAME Chroma store the chat
8
+ retrieval reads (the global `policies` collection),
9
+ 3. CREATE a real curated-facts-shaped JSON record (not the data-starved
10
+ "—"/0 sentinel) under the PERSISTENT uploaded-docs store,
11
+ 4. appear in the marketplace (/api/policies/all) as a card whose grade
12
+ flows through backend.main.marketplace_grade — the #40 single source
13
+ of truth (NO re-implemented grading),
14
+ 5. be retrievable when a user asks about it,
15
+ 6. SURVIVE a restart: re-running the startup re-ingest handler must
16
+ restore its chunks AND keep the card graded.
17
+
18
+ NETWORK / LLM
19
+ -------------
20
+ The whole upload+persist+index+grade+retrieve path is intrinsically
21
+ offline: the security gates are byte/text scans, embeddings are LOCAL
22
+ bge-small (no Voyage), grading is pure-Python build_scorecard, retrieval
23
+ is Chroma cosine. So no stub is needed for steps 1-6. The final NL
24
+ synthesis (Gemini) is the ONLY networked hop and is intentionally NOT
25
+ exercised here — the repo's pattern is to assert the tool layer the brain
26
+ calls returns the answer-bearing chunk (which IS the load-bearing proof).
27
+
28
+ ISOLATION
29
+ ---------
30
+ The test points settings.UPLOADED_DOCS_DIR at a tmp dir and uses a
31
+ throwaway Chroma collection name so it never mutates the real persistent
32
+ store or the 148-policy corpus index. The scorecard-parity (#40) guard is
33
+ re-asserted in tests/test_scorecard_parity.py — this file additionally
34
+ proves the uploaded card resolves through marketplace_grade identically.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import importlib
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ import pytest
45
+
46
+ _REPO = Path(__file__).resolve().parents[1]
47
+ for _p in (str(_REPO), str(_REPO / "backend")):
48
+ if _p not in sys.path:
49
+ sys.path.insert(0, _p)
50
+
51
+
52
+ def _run(coro):
53
+ """Run a coroutine on a FRESH event loop.
54
+
55
+ Other suites in the gate close / consume the process-default asyncio
56
+ loop, so `asyncio.get_event_loop()` raises `RuntimeError: There is no
57
+ current event loop` when this test runs after them. A dedicated
58
+ new_event_loop() per call is robust regardless of prior-test state.
59
+ """
60
+ loop = asyncio.new_event_loop()
61
+ try:
62
+ asyncio.set_event_loop(loop)
63
+ return loop.run_until_complete(coro)
64
+ finally:
65
+ loop.close()
66
+ asyncio.set_event_loop(None)
67
+
68
+
69
+ # --- a structurally valid, multi-page, keyword-dense insurance PDF that
70
+ # pdfplumber actually extracts (insert_text, not insert_textbox) -------
71
+
72
+ _PARAS = [
73
+ "ZENITH SECURE HEALTH INSURANCE POLICY. Unique Identification No: "
74
+ "ZENHLIP24077V012324. This health insurance policy covers in-patient "
75
+ "hospitalisation, sum insured, premium and claim.",
76
+ "The initial waiting period is 30 days from the first policy commencement "
77
+ "date; expenses within 30 days shall be excluded except accidents. "
78
+ "Pre-existing disease (PED) and its direct complications shall be excluded "
79
+ "until the expiry of 36 months of continuous coverage.",
80
+ "Specific disease waiting period of 24 months applies to cataract and "
81
+ "hernia. Maternity benefit has a waiting period of 24 months. "
82
+ "Pre-hospitalisation cover is 60 days and post-hospitalisation cover is "
83
+ "90 days.",
84
+ "A co-payment of 20% applies to each claim for insured persons above 60 "
85
+ "years. No-claim bonus (cumulative bonus) of 50% per claim-free year is "
86
+ "granted. Room rent: no sub-limit on room rent.",
87
+ "The insurer has 12,000 network hospitals for cashless treatment across "
88
+ "India. Maximum entry age is 65 years. AYUSH treatment is covered. "
89
+ "Ambulance cover is included. Day care procedures are covered.",
90
+ "The IRDAI claim settlement ratio for the insurer is 97.2%. Cashless "
91
+ "treatment is supported at all network hospitals. Pre-existing disease, "
92
+ "exclusions, renewal and free-look terms are defined herein. This is a "
93
+ "comprehensive indemnity health insurance plan regulated by IRDAI.",
94
+ ]
95
+
96
+
97
+ def _real_policy_pdf_bytes() -> bytes:
98
+ import fitz # PyMuPDF
99
+
100
+ doc = fitz.open()
101
+ for _ in range(2): # 12 pages, dense extractable text per page
102
+ for para in _PARAS:
103
+ page = doc.new_page()
104
+ y = 72
105
+ for line in [para[i:i + 90] for i in range(0, len(para), 90)]:
106
+ page.insert_text((56, y), line, fontsize=11, fontname="helv")
107
+ y += 16
108
+ base = doc.tobytes()
109
+ doc.close()
110
+ data = base + b"\n" + (b"%" + b"X" * 118 + b"\n") * 40 + b"%%EOF\n"
111
+ assert data.startswith(b"%PDF") and 5_000 < len(data) < 25 * 1024 * 1024
112
+ return data
113
+
114
+
115
+ @pytest.fixture()
116
+ def isolated_store(tmp_path, monkeypatch):
117
+ """Point the persistent uploaded-docs dir + the working Chroma vectors
118
+ at throwaway dirs so the test never touches the real store/corpus."""
119
+ import backend.config as cfg
120
+
121
+ udir = tmp_path / "uploaded_docs"
122
+ vdir = tmp_path / "vectors"
123
+ udir.mkdir()
124
+ vdir.mkdir()
125
+ monkeypatch.setattr(cfg.settings, "UPLOADED_DOCS_DIR", udir, raising=False)
126
+ monkeypatch.setattr(cfg.settings, "VECTORS_DIR", vdir, raising=False)
127
+
128
+ # Reload the modules that captured settings paths at import time so they
129
+ # see the isolated dirs. main + brain_tools read settings live, so a
130
+ # monkeypatch is enough; rag.retrieve/ingest build the Chroma client
131
+ # path from settings.VECTORS_DIR per call, so no reload needed there.
132
+ import backend.main as M
133
+ import backend.uploaded_docs as U
134
+ import rag.retrieve as R
135
+ import rag.ingest as I
136
+
137
+ # Bust caches that may hold corpus-built state.
138
+ M._CORPUS_PDF_IDX = None
139
+ with M._MG_LOCK:
140
+ M._MG_CACHE["sig"] = None
141
+ M._MG_CACHE["index"] = None
142
+ R._RETRIEVAL_CACHE.clear()
143
+ yield {"M": M, "U": U, "R": R, "I": I, "udir": udir}
144
+
145
+
146
+ def test_upload_pdf_becomes_a_graded_persistent_marketplace_card(isolated_store):
147
+ M = isolated_store["M"]
148
+ U = isolated_store["U"]
149
+ R = isolated_store["R"]
150
+ I = isolated_store["I"]
151
+
152
+ from fastapi.testclient import TestClient
153
+
154
+ client = TestClient(M.app, raise_server_exceptions=True)
155
+ pdf = _real_policy_pdf_bytes()
156
+
157
+ # ---- STEP 1+2+3: accept + chunk/embed + create JSON record -----------
158
+ resp = client.post(
159
+ "/api/upload-policy",
160
+ files={"file": ("zenith_secure_policy.pdf", pdf, "application/pdf")},
161
+ data={"session_id": "pytest-e2e-sid-001"},
162
+ )
163
+ assert resp.status_code == 200, resp.text
164
+ body = resp.json()
165
+ pid = body["policy_id"]
166
+ assert pid.startswith("user-upload__")
167
+ assert body["chunks_added"] >= 1
168
+ assert body["pages_indexed"] >= 1
169
+
170
+ # STEP 3 — the created JSON record exists, is curated-facts-shaped, and
171
+ # carries real sourced fields (NOT empty → it WILL grade, not sentinel).
172
+ recs = U.load_persisted_records()
173
+ assert pid in recs, f"no persisted record for {pid}: {list(recs)}"
174
+ rec = recs[pid]
175
+ assert rec["insurer_slug"] == "user-upload"
176
+ sourced = [
177
+ k for k, v in rec.items()
178
+ if isinstance(v, dict) and "value" in v and v.get("value") is not None
179
+ ]
180
+ # The scorecard's sentinel fires below ~2/23 scored fields; we sourced
181
+ # well above that from this document's own text.
182
+ assert len(sourced) >= 6, f"too few sourced fields ({sourced})"
183
+ # Every emitted field must be backed by a verbatim source quote (the
184
+ # #52 'no fabrication' contract — a field only exists if its evidence
185
+ # is literally in the document).
186
+ for k in sourced:
187
+ assert rec[k].get("source_quote"), f"{k} has no source_quote"
188
+ # Persisted artefacts (PDF + chunks + meta) for restart survival.
189
+ ddir = isolated_store["udir"] / pid
190
+ for fn in ("source.pdf", "record.json", "chunks.json", "meta.json"):
191
+ assert (ddir / fn).is_file(), f"missing persisted {fn}"
192
+
193
+ # ---- STEP 4: marketplace card + graded via the #40 SSOT -------------
194
+ feed = client.get("/api/policies/all").json()
195
+ cards = [p for p in feed["policies"] if p["policy_id"] == pid]
196
+ assert len(cards) == 1, "uploaded doc not in /api/policies/all exactly once"
197
+ card = cards[0]
198
+ assert card["grade"] in ("A", "B", "C", "D", "F"), card["grade"]
199
+ assert card["grade"] != "—" and card["overall_score"] > 0, card
200
+ assert card["data_completeness_pct"] >= 9.0 # above the sentinel floor
201
+
202
+ # The card grade MUST come from backend.main.marketplace_grade (the #40
203
+ # single source of truth) and the recommendation-path signal MUST agree
204
+ # — no parallel grading implementation.
205
+ mg = M.marketplace_grade(pid)
206
+ assert mg.get("_grade") == card["grade"], (mg, card["grade"])
207
+ assert mg.get("_overall_score") == card["overall_score"]
208
+ from backend.brain_tools import _scorecard_signal
209
+ sig = _scorecard_signal(pid)
210
+ assert sig.get("_grade") == card["grade"], (sig, card["grade"])
211
+
212
+ # ---- STEP 5+6: retrievable + answer-bearing for a Q&A ---------------
213
+ async def _ask():
214
+ from backend.brain_tools import retrieve_policies
215
+
216
+ R._RETRIEVAL_CACHE.clear()
217
+ # Anonymous GLOBAL query (no session): the doc was added to THE
218
+ # marketplace, so it must surface for anyone asking about it.
219
+ r = await retrieve_policies(
220
+ query="Zenith Secure policy pre-existing disease waiting period "
221
+ "and co-payment percentage",
222
+ top_k=10,
223
+ intent="qa",
224
+ )
225
+ return r
226
+
227
+ r = _run(_ask())
228
+ up = [c for c in (r.get("chunks") or [])
229
+ if str(c.get("policy_id", "")).startswith("user-upload__")]
230
+ assert up, f"uploaded doc not retrievable for its own Q&A: {r}"
231
+ answer_text = " ".join(c["chunk_text"] for c in up)
232
+ # The retrieved chunk literally contains the answer to the question.
233
+ assert "36 months" in answer_text # PED waiting period
234
+ assert "20%" in answer_text # co-payment
235
+
236
+ # ---- RESTART PERSISTENCE: simulate a Space rebuild ------------------
237
+ # A rebuild wipes the ephemeral Chroma. Delete the uploaded doc's chunks
238
+ # from the working collection, prove they're GONE, then run the startup
239
+ # re-ingest handler and prove steps 4-6 hold again from the PERSISTED
240
+ # store alone.
241
+ coll = I.get_chroma_collection()
242
+ coll.delete(where={"policy_id": pid})
243
+ R._RETRIEVAL_CACHE.clear()
244
+ gone = coll.get(where={"policy_id": pid}, limit=5)
245
+ assert not gone.get("ids"), "chunks should be gone post simulated rebuild"
246
+
247
+ _run(M._startup_reingest_uploaded_docs())
248
+
249
+ # STEP 4 again — card still present + still graded (from persisted JSON).
250
+ feed2 = client.get("/api/policies/all").json()
251
+ cards2 = [p for p in feed2["policies"] if p["policy_id"] == pid]
252
+ assert len(cards2) == 1, "card lost after restart"
253
+ assert cards2[0]["grade"] == card["grade"], "grade changed after restart"
254
+
255
+ # STEP 5+6 again — chunks restored + retrievable from persisted payload.
256
+ async def _ask2():
257
+ from backend.brain_tools import retrieve_policies
258
+
259
+ R._RETRIEVAL_CACHE.clear()
260
+ return await retrieve_policies(
261
+ query="Zenith Secure policy co-payment percentage and "
262
+ "pre-existing disease waiting period",
263
+ top_k=10,
264
+ intent="qa",
265
+ )
266
+
267
+ r2 = _run(_ask2())
268
+ up2 = [c for c in (r2.get("chunks") or [])
269
+ if str(c.get("policy_id", "")).startswith("user-upload__")]
270
+ assert up2, f"uploaded doc NOT retrievable after restart re-ingest: {r2}"
271
+ txt2 = " ".join(c["chunk_text"] for c in up2)
272
+ assert "36 months" in txt2 and "20%" in txt2, "answer lost after restart"
273
+
274
+
275
+ def test_no_silent_failure_on_persist_error(isolated_store, monkeypatch):
276
+ """A persist failure MUST surface as an HTTP 500 (the #52 'no silent
277
+ failure' contract) — a 200 that didn't persist is forbidden."""
278
+ M = isolated_store["M"]
279
+ from fastapi.testclient import TestClient
280
+ import backend.uploaded_docs as U
281
+
282
+ def _boom(**_kw):
283
+ raise RuntimeError("persist_upload simulated disk failure")
284
+
285
+ monkeypatch.setattr(U, "persist_upload", _boom)
286
+ client = TestClient(M.app, raise_server_exceptions=False)
287
+ resp = client.post(
288
+ "/api/upload-policy",
289
+ files={"file": ("zenith_secure_policy.pdf",
290
+ _real_policy_pdf_bytes(), "application/pdf")},
291
+ data={"session_id": "pytest-e2e-sid-fail"},
292
+ )
293
+ assert resp.status_code == 500, resp.text
294
+ assert "Indexing failed" in resp.text
295
+ # The PDF must NOT be left orphaned in rag/corpus/user-upload on failure.
296
+
297
+
298
+ def test_extract_fields_emits_only_evidenced_facts():
299
+ """The heuristic extractor must NEVER fabricate: a doc with zero
300
+ structured terms yields {} (→ the honest sentinel), and every emitted
301
+ field carries its verbatim source quote."""
302
+ import backend.uploaded_docs as U
303
+
304
+ assert U.extract_fields_from_text("just some prose with no policy terms at all") == {}
305
+
306
+ fields = U.extract_fields_from_text(
307
+ "Pre-existing disease shall be excluded until the expiry of 48 "
308
+ "months. A co-payment of 15% applies. 30 days initial waiting period."
309
+ )
310
+ assert fields["pre_existing_disease_waiting_months"]["value"] == 48
311
+ assert fields["copayment_pct"]["value"] == 15
312
+ for cell in fields.values():
313
+ assert cell["source_quote"], "every fact must be source-quoted"
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(pytest.main([__file__, "-q"]))