InsuranceBot / backend /uploaded_docs.py
rohitsar567's picture
feat(#77 / #52-residual-5): admin prune for persisted uploaded docs
e6eb838
Raw
History Blame
24 kB
"""Persistent uploaded-policy store (#52 β€” graded server-assignment).
WHAT THIS MODULE DOES
---------------------
When a user uploads a PDF via POST /api/upload-policy, three things must
survive an HF Space restart and become globally visible:
1. the raw PDF bytes,
2. a curated-facts-shaped JSON record (the SAME `{field:{value,
source_pdf_path, source_quote, _confidence}}` schema that
40-data/policy_facts/*.json uses, so it flows through the EXISTING
`backend.main._load_curated_facts` -> `_marketplace_catalogue` Pass-2
-> `build_scorecard` path with ZERO grading re-implementation), and
3. enough to re-index the document's chunks into the working Chroma
`policies` collection on the next boot.
PERSISTENCE MODEL
-----------------
Everything lands under `settings.UPLOADED_DOCS_DIR`:
<UPLOADED_DOCS_DIR>/
<policy_id>/
source.pdf # raw uploaded bytes
record.json # curated-facts-shaped JSON (the card)
chunks.json # [{chunk_idx,text,page_start,page_end}, ...]
meta.json # {policy_id, policy_name, insurer_slug,
# sha256, uploaded_at, session_id}
On the HF Space `settings.UPLOADED_DOCS_DIR` resolves to a directory on
the PERSISTENT `/data` disk (see backend/config.py + entrypoint.sh), so a
Space rebuild β€” which throws away the ephemeral container FS including
rag/vectors β€” does NOT lose uploaded policies. Locally (no /data) it
resolves under settings.DATA_DIR so the exact same code path works.
PRIVACY MODEL (explicit, per #52 spec)
--------------------------------------
The #52 spec says the uploaded doc is *added to THE (global) marketplace*.
So once a user uploads a policy it is intentionally a public marketplace
card and its chunks are globally retrievable (doc_type='user_upload' in
the main `policies` collection). The persistent store therefore contains
ONLY the uploaded policy document itself + data derived from it β€” never a
session profile, never another user's data. `session_id` is recorded in
meta.json purely for operational audit/abuse-tracing; it is NEVER used to
gate visibility of the card or the chunks (those are global by design) and
is NEVER written into the Chroma chunk metadata of the global collection.
The pre-existing session-scoped `user_uploads_quarantine` collection is a
separate, private, ephemeral path and is untouched by this module.
NO SILENT FAILURES
------------------
Every function here either succeeds or raises a typed exception with a
clear message. Callers (backend.main) decide whether a failure is fatal to
the request (record creation) or best-effort-logged (startup re-ingest of
ONE doc must not abort boot, but the failure is logged loudly).
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import shutil
import time
from pathlib import Path
from typing import Any, Optional
from backend.config import settings
_log = logging.getLogger(__name__)
# Chroma metadata doc_type for a persisted, globally-visible uploaded doc.
# Deliberately the SAME token the quarantine path uses so the existing
# brain_tools UPLOADED-DOC handling + retrieve.py treat it identically.
UPLOAD_DOC_TYPE = "user_upload"
# Insurer slug for uploaded docs. MUST NOT be "regulatory" (that slug is
# filtered out of the marketplace) and MUST be stable so the card always
# resolves the same insurer_meta fallback.
UPLOAD_INSURER_SLUG = "user-upload"
UPLOAD_INSURER_NAME = "User-uploaded document"
# ---------------------------------------------------------------------------
# Storage layout
# ---------------------------------------------------------------------------
def uploaded_docs_dir() -> Path:
"""The persistent root for uploaded docs. Created on first use."""
d = settings.UPLOADED_DOCS_DIR
d.mkdir(parents=True, exist_ok=True)
return d
def _doc_dir(policy_id: str) -> Path:
# policy_id is already a tight slug (see backend.main.upload_policy:
# user-upload__<sid12>__<fileslug>) but defend against path traversal.
safe = re.sub(r"[^a-zA-Z0-9_.\-]+", "-", policy_id).strip("-") or "user-upload"
return uploaded_docs_dir() / safe
def prune_persisted_upload(
policy_id: Optional[str] = None, *, prefix: Optional[str] = None
) -> dict:
"""Operator/abuse prune of persisted uploaded doc(s) (#52 residual #5,
#77). Pass an exact `policy_id` OR a `prefix` (e.g.
'user-upload__e2e-verify' to bulk-remove test/abuse cards).
HARD GUARDRAIL: only ever removes a directory that is a DIRECT CHILD of
UPLOADED_DOCS_DIR β€” it can never touch rag/corpus, 40-data, or any
curated/extracted data. A path-safety violation RAISES (must surface;
a silent no-op here would be forbidden by the no-silent-failure rule).
Returns {removed:[ids], skipped:[ids-not-present], root}.
"""
root = uploaded_docs_dir().resolve()
targets: list[str] = []
if policy_id:
targets.append(policy_id)
if prefix is not None:
pfx = re.sub(r"[^a-zA-Z0-9_.\-]+", "-", prefix).strip("-")
if not pfx:
raise RuntimeError("prune prefix is empty after sanitisation")
for d in sorted(root.glob("*")):
if d.is_dir() and d.name.startswith(pfx):
targets.append(d.name)
removed: list[str] = []
skipped: list[str] = []
for pid in dict.fromkeys(targets): # dedupe, preserve order
ddir = _doc_dir(pid).resolve()
if ddir == root or root not in ddir.parents:
raise RuntimeError(
f"refusing to prune outside uploaded-docs root: {pid!r}"
)
if not ddir.exists():
skipped.append(pid)
continue
shutil.rmtree(ddir)
removed.append(pid)
return {"removed": removed, "skipped": skipped, "root": str(root)}
# ---------------------------------------------------------------------------
# Heuristic field extraction -> curated-facts-shaped record
#
# The repo's LLM extractor (rag/extract.py) needs network + the NIM brain.
# That is correct for the corpus build but unusable inside a request (and
# untestable offline). So we derive a REAL, sourced record deterministically
# from the PDF's own text via regex over the IRDAI-standardised wording that
# every Indian health policy uses. Each field we emit carries the verbatim
# source_quote it was matched from β€” nothing is fabricated; a field is only
# emitted when its evidence is literally present in the document.
#
# This populates well above the scorecard's MIN_GRADEABLE_COMPLETENESS_PCT
# (9.0 == ~2 of 23 SCORED_FIELDS) so the card grades for real instead of
# returning the data-starved "β€”"/0 sentinel. When the document genuinely
# lacks structured terms, we DO NOT invent any β€” the card then honestly
# shows the sentinel, which is the correct behaviour.
# ---------------------------------------------------------------------------
def _ctx(text: str, m: re.Match, pad: int = 90) -> str:
"""Verbatim surrounding snippet for a regex match (the source_quote)."""
s = max(0, m.start() - pad)
e = min(len(text), m.end() + pad)
return re.sub(r"\s+", " ", text[s:e]).strip()[:300]
def _fact(value: Any, quote: str, conf: str = "medium") -> dict:
"""A curated-facts cell: {value, source_pdf_path, source_quote, _confidence}."""
return {
"value": value,
"source_pdf_path": "", # filled by the caller with the persisted PDF path
"source_quote": quote,
"_confidence": conf,
}
def extract_fields_from_text(full_text: str) -> dict[str, dict]:
"""Regex-derive scorecard-relevant fields from policy text.
Returns a {field_name: <fact cell>} dict using the SAME canonical field
names backend.scorecard.SCORED_FIELDS / ALIASES read. Only fields with
literal textual evidence are emitted. Never raises (a totally
unparseable doc just yields {}).
"""
t = full_text or ""
low = t.lower()
out: dict[str, dict] = {}
def add(field: str, value: Any, m: Optional[re.Match], conf: str = "medium"):
if value is None:
return
if field in out:
return
quote = _ctx(t, m) if m is not None else ""
out[field] = _fact(value, quote, conf)
# --- UIN (regulator identity; not a scored field but anchors the card) --
m = re.search(r"\b([A-Z]{3}[A-Z0-9]{10,22}V\d{6})\b", t)
if m:
add("uin_code", m.group(1), m, "high")
# --- Initial waiting period (days) -------------------------------------
m = re.search(
r"(\d{1,3})\s*days?[^.]{0,80}?(?:waiting period|from the (?:first )?"
r"(?:policy )?(?:commencement|inception)|shall be excluded)",
t, re.IGNORECASE,
) or re.search(
r"(?:waiting period|initial waiting)[^.]{0,60}?(\d{1,3})\s*days?",
t, re.IGNORECASE,
)
if m:
d = int(m.group(1))
if 0 < d <= 90:
add("initial_waiting_period_days", d, m, "high")
# --- Pre-existing disease waiting (months) -----------------------------
m = re.search(
r"pre[\-\s]?existing[^.]{0,120}?(\d{1,2})\s*(?:months|month)",
t, re.IGNORECASE,
) or re.search(
r"(\d{1,2})\s*months[^.]{0,80}?pre[\-\s]?existing",
t, re.IGNORECASE,
)
if m:
mo = int(m.group(1))
if 0 < mo <= 72:
add("pre_existing_disease_waiting_months", mo, m, "high")
# --- Specific-disease waiting (months) ---------------------------------
m = re.search(
r"(?:specific (?:disease|illness)|cataract|hernia)[^.]{0,120}?"
r"(\d{1,2})\s*months",
t, re.IGNORECASE,
)
if m:
mo = int(m.group(1))
if 0 < mo <= 48:
add("specific_disease_waiting_months", mo, m, "medium")
# --- Maternity waiting (months) ----------------------------------------
m = re.search(
r"maternity[^.]{0,120}?(\d{1,2})\s*months",
t, re.IGNORECASE,
)
if m:
mo = int(m.group(1))
if 0 < mo <= 48:
add("maternity_waiting_months", mo, m, "medium")
# --- Pre / post hospitalisation (days) ---------------------------------
m = re.search(r"pre[\-\s]?hospitali[sz]ation[^.]{0,60}?(\d{1,3})\s*days", t, re.IGNORECASE)
if m:
d = int(m.group(1))
if 0 < d <= 180:
add("pre_hospitalization_days", d, m, "high")
m = re.search(r"post[\-\s]?hospitali[sz]ation[^.]{0,60}?(\d{1,3})\s*days", t, re.IGNORECASE)
if m:
d = int(m.group(1))
if 0 < d <= 365:
add("post_hospitalization_days", d, m, "high")
# --- Co-payment (%) ----------------------------------------------------
m = re.search(r"co[\-\s]?pay(?:ment)?[^.]{0,80}?(\d{1,2})\s*%", t, re.IGNORECASE) \
or re.search(r"(\d{1,2})\s*%[^.]{0,40}?co[\-\s]?pay", t, re.IGNORECASE)
if m:
pct = int(m.group(1))
if 0 <= pct <= 50:
add("copayment_pct", pct, m, "medium")
# --- No-claim bonus (%) ------------------------------------------------
m = re.search(
r"(?:no[\-\s]?claim bonus|cumulative bonus|ncb)[^.]{0,80}?(\d{1,3})\s*%",
t, re.IGNORECASE,
)
if m:
pct = int(m.group(1))
if 0 < pct <= 200:
# MarketplacePolicy.no_claim_bonus_pct is Optional[int]; the
# scorecard reads it numerically either way. Emit int.
add("no_claim_bonus_pct", pct, m, "medium")
# --- Room rent capping -------------------------------------------------
m = re.search(
r"room rent[^.]{0,90}?(no (?:sub[\-\s]?limit|cap|capping|limit)|"
r"\d{1,2}\s*%\s*(?:of\s*(?:the\s*)?sum insured|of si)?|single private|"
r"twin sharing|shared accommodation)",
t, re.IGNORECASE,
)
if m:
cap = m.group(1).strip()
if re.search(r"no (sub[\-\s]?limit|cap|capping|limit)", cap, re.IGNORECASE):
cap = "No room rent cap"
add("room_rent_capping", cap, m, "medium")
# --- Network hospital count -------------------------------------------
m = re.search(
r"([\d,]{3,7})\+?\s*(?:network |empanelled |cashless )?hospitals?",
t, re.IGNORECASE,
)
if m:
try:
n = int(m.group(1).replace(",", ""))
if 50 <= n <= 50000:
add("network_hospital_count", n, m, "medium")
except ValueError:
pass
# --- Cashless supported -----------------------------------------------
if "cashless" in low:
m = re.search(r"cashless[^.]{0,80}", t, re.IGNORECASE)
add("cashless_treatment_supported", True, m, "medium")
# --- Max entry age (years) --------------------------------------------
m = re.search(
r"(?:maximum |max\.? )?entry age[^.]{0,40}?(\d{2,3})\s*years",
t, re.IGNORECASE,
) or re.search(
r"entry age[^.]{0,40}?up to\s*(\d{2,3})\s*years", t, re.IGNORECASE,
)
if m:
age = int(m.group(1))
if 30 <= age <= 100:
add("max_entry_age", age, m, "medium")
# --- AYUSH coverage ----------------------------------------------------
if re.search(r"\bayush\b", low) or "ayurved" in low:
m = re.search(r"ayush[^.]{0,90}", t, re.IGNORECASE) or re.search(
r"ayurved[^.]{0,90}", t, re.IGNORECASE)
add("ayush_coverage", {"covered": True}, m, "medium")
# --- Maternity coverage (boolean-with-detail) -------------------------
if "maternity" in low:
m = re.search(r"maternity[^.]{0,120}", t, re.IGNORECASE)
covered = not bool(re.search(
r"maternity[^.]{0,40}(not covered|excluded|no cover)", t, re.IGNORECASE))
add("maternity_coverage", {"covered": covered}, m, "medium")
# --- Ambulance / day-care / restoration (presence booleans) -----------
if "ambulance" in low:
m = re.search(r"ambulance[^.]{0,90}", t, re.IGNORECASE)
add("ambulance_cover", {"covered": True}, m, "low")
if "day care" in low or "day-care" in low or "daycare" in low:
m = re.search(r"day[\-\s]?care[^.]{0,90}", t, re.IGNORECASE)
add("day_care_treatments_count", {"covered": True, "limit_text": "Day-care procedures covered"}, m, "low")
if "restoration" in low or "refill" in low or "reinstatement" in low:
m = re.search(r"(restoration|refill|reinstatement)[^.]{0,90}", t, re.IGNORECASE)
add("restoration_benefit", {"covered": True}, m, "low")
# --- Claim settlement ratio (insurer-level; commonly stated in CIS) ----
m = re.search(
r"claim settlement ratio[^.]{0,40}?(\d{2,3}(?:\.\d{1,2})?)\s*%",
t, re.IGNORECASE,
)
if m:
try:
csr = float(m.group(1))
if 30 <= csr <= 100:
add("claim_settlement_ratio", csr, m, "medium")
except ValueError:
pass
return out
def _derive_policy_name(full_text: str, fallback: str) -> str:
"""Best-effort human policy name from the document header."""
for line in (full_text or "").splitlines():
s = line.strip()
if not s:
continue
if re.search(r"(policy|plan|insurance|mediclaim|health)", s, re.IGNORECASE) \
and 6 <= len(s) <= 90:
return re.sub(r"\s+", " ", s)
return fallback
# ---------------------------------------------------------------------------
# Persisted record (curated-facts JSON) + PDF + chunk payload
# ---------------------------------------------------------------------------
def build_record(
policy_id: str,
policy_name: str,
full_text: str,
persisted_pdf_path: str,
) -> dict:
"""Build the curated-facts-shaped JSON the marketplace Pass-2 consumes.
The returned dict is the EXACT shape `_load_curated_facts._flatten`
expects: scalar identity keys + per-field `{value, source_*}` cells.
"""
fields = extract_fields_from_text(full_text)
rel_pdf = persisted_pdf_path
for cell in fields.values():
if isinstance(cell, dict) and "source_pdf_path" in cell:
cell["source_pdf_path"] = rel_pdf
record: dict[str, Any] = {
"policy_id": policy_id,
"policy_name": policy_name or _derive_policy_name(full_text, policy_id),
"insurer_slug": UPLOAD_INSURER_SLUG,
"_uploaded_doc": True, # provenance flag (ignored by scorecard)
}
record.update(fields)
return record
def persist_upload(
*,
policy_id: str,
policy_name: str,
pdf_bytes: bytes,
full_text: str,
chunks: list[dict],
session_id: str,
) -> dict:
"""Atomically persist the PDF + JSON record + chunk payload + meta.
Returns the built record dict. Raises RuntimeError on any failure (the
caller MUST surface this β€” a "successful" upload that didn't persist is
a silent failure and is forbidden by the #52 spec).
"""
try:
ddir = _doc_dir(policy_id)
ddir.mkdir(parents=True, exist_ok=True)
pdf_path = ddir / "source.pdf"
pdf_path.write_bytes(pdf_bytes)
record = build_record(
policy_id, policy_name, full_text, persisted_pdf_path=str(pdf_path),
)
# Write to temp files then os.replace for crash-atomic visibility.
rec_tmp = ddir / "record.json.tmp"
rec_tmp.write_text(json.dumps(record, indent=2, ensure_ascii=False))
rec_tmp.replace(ddir / "record.json")
chunk_payload = [
{
"chunk_idx": c["chunk_idx"],
"text": c["text"],
"page_start": c["page_start"],
"page_end": c["page_end"],
}
for c in chunks
]
ch_tmp = ddir / "chunks.json.tmp"
ch_tmp.write_text(json.dumps(chunk_payload, ensure_ascii=False))
ch_tmp.replace(ddir / "chunks.json")
meta = {
"policy_id": policy_id,
"policy_name": record["policy_name"],
"insurer_slug": UPLOAD_INSURER_SLUG,
"sha256": hashlib.sha256(pdf_bytes).hexdigest(),
"uploaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session_id": session_id, # audit only β€” NEVER a visibility gate
"n_chunks": len(chunk_payload),
}
meta_tmp = ddir / "meta.json.tmp"
meta_tmp.write_text(json.dumps(meta, indent=2))
meta_tmp.replace(ddir / "meta.json")
_log.info(
"persisted uploaded doc %s (%d fields, %d chunks) -> %s",
policy_id, len([k for k in record if not k.startswith(("policy_", "insurer_", "_"))]),
len(chunk_payload), ddir,
)
return record
except Exception as e: # noqa: BLE001 β€” convert to a loud typed failure
raise RuntimeError(
f"persist_upload failed for {policy_id}: {type(e).__name__}: {e}"
) from e
# ---------------------------------------------------------------------------
# Read side β€” used by _load_curated_facts (cards) + startup re-ingest (chunks)
# ---------------------------------------------------------------------------
def load_persisted_records() -> dict[str, dict]:
"""{policy_id: curated-facts-shaped record} for every persisted upload.
Consumed by backend.main._load_curated_facts so each uploaded doc
surfaces as a marketplace card via the EXISTING Pass-2 + build_scorecard
path. A single corrupt record is skipped (logged) β€” it must not take
down the whole catalogue.
"""
out: dict[str, dict] = {}
root = settings.UPLOADED_DOCS_DIR
if not root.exists():
return out
for d in sorted(root.iterdir()):
if not d.is_dir():
continue
rec_path = d / "record.json"
if not rec_path.exists():
continue
try:
rec = json.loads(rec_path.read_text())
pid = rec.get("policy_id") or d.name
out[pid] = rec
except Exception as e: # noqa: BLE001
_log.warning(
"skipping corrupt uploaded record %s: %s: %s",
rec_path, type(e).__name__, e,
)
continue
return out
def iter_persisted_chunks():
"""Yield (policy_id, policy_name, [chunk dicts]) for every persisted doc.
Used by the startup re-ingest to rebuild the uploaded docs' vectors in
the working Chroma `policies` collection after a Space restart wiped the
ephemeral rag/vectors snapshot.
"""
root = settings.UPLOADED_DOCS_DIR
if not root.exists():
return
for d in sorted(root.iterdir()):
if not d.is_dir():
continue
ch_path = d / "chunks.json"
meta_path = d / "meta.json"
if not (ch_path.exists() and meta_path.exists()):
continue
try:
meta = json.loads(meta_path.read_text())
chunks = json.loads(ch_path.read_text())
except Exception as e: # noqa: BLE001
_log.warning(
"skipping unreadable persisted chunks %s: %s: %s",
ch_path, type(e).__name__, e,
)
continue
yield (
meta.get("policy_id") or d.name,
meta.get("policy_name") or d.name,
chunks,
)
async def reingest_persisted_into_policies() -> dict:
"""Re-embed every persisted uploaded doc's chunks into the working
Chroma `policies` collection (idempotent: deletes the doc's prior
chunks first, keyed by policy_id).
Globally visible by design (#52: uploaded doc is added to THE
marketplace). Returns a small summary dict. Raises only if Chroma /
embedder are completely unavailable; a single bad doc is logged and
skipped so one corrupt upload can't block boot.
"""
from rag.ingest import get_chroma_collection
from backend.providers.local_embeddings import LocalEmbeddings
docs = list(iter_persisted_chunks())
summary = {"docs": 0, "chunks": 0, "skipped": 0}
if not docs:
return summary
collection = get_chroma_collection()
embedder = LocalEmbeddings()
for policy_id, policy_name, chunks in docs:
if not chunks:
summary["skipped"] += 1
continue
try:
texts = [c["text"] for c in chunks]
vectors = await embedder.embed(texts, input_type="document")
ids = [f"{policy_id}::chunk{c['chunk_idx']}" for c in chunks]
metadatas = [
{
"policy_id": policy_id,
"insurer_slug": UPLOAD_INSURER_SLUG,
"policy_name": policy_name,
"doc_type": UPLOAD_DOC_TYPE,
"source_url": "",
"page_start": c["page_start"],
"page_end": c["page_end"],
"chunk_idx": c["chunk_idx"],
# NOTE: no session_id β€” these are GLOBAL marketplace
# chunks by design, not session-private quarantine.
}
for c in chunks
]
try:
collection.delete(where={"policy_id": policy_id})
except Exception: # noqa: BLE001 β€” first-ever ingest has nothing to delete
pass
collection.add(
ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas,
)
summary["docs"] += 1
summary["chunks"] += len(chunks)
_log.info(
"re-ingested uploaded doc %s (%d chunks) into policies",
policy_id, len(chunks),
)
except Exception as e: # noqa: BLE001 β€” one bad doc must not block boot
summary["skipped"] += 1
_log.warning(
"startup re-ingest skipped %s: %s: %s",
policy_id, type(e).__name__, e,
)
return summary