Spaces:
Sleeping
Sleeping
Commit ·
afdb7c7
1
Parent(s): a6bda5e
Revert "Revert "feat(#52): PDF upload → persistent JSON + marketplace card + restart-survival""
Browse filesThis reverts commit a6bda5e29589a6a1990c025a892d761b0cb40709.
- backend/config.py +17 -0
- backend/main.py +163 -1
- backend/retrieval_filters.py +16 -4
- backend/uploaded_docs.py +571 -0
- entrypoint.sh +12 -0
- rag/retrieve.py +37 -0
- tests/test_pdf_upload_to_marketplace_e2e.py +317 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]))
|