Spaces:
Sleeping
#47: UIN-based net-new dedup for user-uploaded policy PDFs
Browse filesWhen a user uploads a policy PDF whose IRDAI UIN already belongs to a
catalogue policy, it is NOT net-new β the route now short-circuits and
returns the existing card (UploadResponse.already_in_catalogue=True +
existing_policy_id/name) instead of indexing a duplicate. Adds
_catalogue_uin_index() (cached, modern-format UINs only) and
_match_catalogue_uin() (case-insensitive extraction). Hash-dedup (Gate 8)
still handles identical re-uploads; this adds same-policy-different-PDF.
Other gaps from the #47 spec were already covered by the existing #52
work (persisted JSON, global graded marketplace card, dynamic insurer
logo) β not rebuilt; durable cross-restart persistence intentionally
left session-scoped (HF FS is ephemeral; durability would leak unvetted
uploads into the shared catalogue).
Also fixes a pre-existing test-hygiene leak: the #45 auto-persist wrote
the recall test's throwaway name to disk; fixture teardown now cleans it.
Regression test tests/test_upload_uin_dedup.py; full suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@@ -237,6 +237,88 @@ class UploadResponse(BaseModel):
|
|
| 237 |
chunks_added: int
|
| 238 |
pages_indexed: int
|
| 239 |
elapsed_ms: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
|
| 242 |
# ---------------------------------------------------------------------------
|
|
@@ -1815,6 +1897,22 @@ async def upload_policy(
|
|
| 1815 |
# Run 8-gate security check (dedupe + mechanics + encrypted + content +
|
| 1816 |
# page ceiling + injection + per-session + per-IP rate limit + LLM judge)
|
| 1817 |
full_text = "\n".join(t for _, t in pages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1818 |
client_ip = (request.client.host if request and request.client else "") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
| 1819 |
verdict = await check_upload(
|
| 1820 |
content=contents,
|
|
|
|
| 237 |
chunks_added: int
|
| 238 |
pages_indexed: int
|
| 239 |
elapsed_ms: int
|
| 240 |
+
# #47 (2026-05-21) β UIN net-new dedup. When the uploaded PDF's IRDAI
|
| 241 |
+
# UIN already belongs to a catalogue policy, the upload is NOT indexed
|
| 242 |
+
# as a new card; these fields point the caller at the existing policy.
|
| 243 |
+
already_in_catalogue: bool = False
|
| 244 |
+
existing_policy_id: Optional[str] = None
|
| 245 |
+
existing_policy_name: Optional[str] = None
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
# #47 (2026-05-21) β UIN net-new dedup for user uploads. Before a freshly
|
| 250 |
+
# uploaded PDF is indexed as a brand-new marketplace card, check whether its
|
| 251 |
+
# IRDAI UIN already belongs to a catalogued policy; if so it is NOT net-new
|
| 252 |
+
# and the caller is pointed at the existing card. All imports are lazy β the
|
| 253 |
+
# upload route imports `re` locally, so `re` is not module-level here.
|
| 254 |
+
_UIN_PATTERN = r"\b[A-Z]{5,9}\d{5}V\d{6}\b"
|
| 255 |
+
_catalogue_uin_cache = None # type: Optional[dict]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _catalogue_uin_index() -> dict:
|
| 259 |
+
"""Map every catalogue policy's IRDAI UIN -> (policy_id, policy_name).
|
| 260 |
+
Built once from 40-data/policy_facts/*.json, then cached."""
|
| 261 |
+
global _catalogue_uin_cache
|
| 262 |
+
if _catalogue_uin_cache is not None:
|
| 263 |
+
return _catalogue_uin_cache
|
| 264 |
+
import json as _json
|
| 265 |
+
import pathlib as _pl
|
| 266 |
+
import re as _re
|
| 267 |
+
|
| 268 |
+
def _find_uin(o):
|
| 269 |
+
if isinstance(o, dict):
|
| 270 |
+
if "uin_code" in o:
|
| 271 |
+
v = o["uin_code"]
|
| 272 |
+
return v.get("value") if isinstance(v, dict) else v
|
| 273 |
+
for x in o.values():
|
| 274 |
+
r = _find_uin(x)
|
| 275 |
+
if r:
|
| 276 |
+
return r
|
| 277 |
+
elif isinstance(o, list):
|
| 278 |
+
for x in o:
|
| 279 |
+
r = _find_uin(x)
|
| 280 |
+
if r:
|
| 281 |
+
return r
|
| 282 |
+
return None
|
| 283 |
+
|
| 284 |
+
idx: dict = {}
|
| 285 |
+
pf_dir = _pl.Path(__file__).resolve().parent.parent / "40-data" / "policy_facts"
|
| 286 |
+
for fp in sorted(pf_dir.glob("*.json")):
|
| 287 |
+
try:
|
| 288 |
+
uin = _find_uin(_json.loads(fp.read_text()))
|
| 289 |
+
except Exception:
|
| 290 |
+
continue
|
| 291 |
+
if not uin or not isinstance(uin, str):
|
| 292 |
+
continue
|
| 293 |
+
uin = uin.strip().upper()
|
| 294 |
+
# Only index modern-format IRDAI UINs β those are the only ones the
|
| 295 |
+
# uploaded-text matcher (_UIN_PATTERN) can ever extract. Legacy
|
| 296 |
+
# registration codes (e.g. "IRDAI/HLT/CTTK/...") are unmatchable,
|
| 297 |
+
# so indexing them would be dead weight.
|
| 298 |
+
if not _re.fullmatch(r"[A-Z]{5,9}\d{5}V\d{6}", uin):
|
| 299 |
+
continue
|
| 300 |
+
stem = fp.stem
|
| 301 |
+
for suf in ("__wordings", "__cis", "__brochure", "__prospectus"):
|
| 302 |
+
if stem.endswith(suf):
|
| 303 |
+
stem = stem[: -len(suf)]
|
| 304 |
+
nm = stem.split("__")[-1].replace("-", " ").title()
|
| 305 |
+
idx.setdefault(uin, (stem, nm))
|
| 306 |
+
_catalogue_uin_cache = idx
|
| 307 |
+
return idx
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _match_catalogue_uin(text: str):
|
| 311 |
+
"""Return (policy_id, policy_name) if `text` carries the IRDAI UIN of an
|
| 312 |
+
already-catalogued policy; else None."""
|
| 313 |
+
import re as _re
|
| 314 |
+
|
| 315 |
+
idx = _catalogue_uin_index()
|
| 316 |
+
# Case-insensitive extraction β a UIN may appear in any case in the
|
| 317 |
+
# uploaded text / after PDF extraction; normalise to upper for lookup.
|
| 318 |
+
for u in {m.upper() for m in _re.findall(_UIN_PATTERN, text or "", _re.IGNORECASE)}:
|
| 319 |
+
if u in idx:
|
| 320 |
+
return idx[u]
|
| 321 |
+
return None
|
| 322 |
|
| 323 |
|
| 324 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1897 |
# Run 8-gate security check (dedupe + mechanics + encrypted + content +
|
| 1898 |
# page ceiling + injection + per-session + per-IP rate limit + LLM judge)
|
| 1899 |
full_text = "\n".join(t for _, t in pages)
|
| 1900 |
+
# #47 β UIN net-new dedup: if the uploaded PDF's IRDAI UIN already
|
| 1901 |
+
# belongs to a catalogue policy it is NOT net-new β return the
|
| 1902 |
+
# existing card instead of indexing a duplicate. `indexed_ok` stays
|
| 1903 |
+
# False so the finally block deletes the freshly-written temp file.
|
| 1904 |
+
_uin_hit = _match_catalogue_uin(full_text)
|
| 1905 |
+
if _uin_hit:
|
| 1906 |
+
return UploadResponse(
|
| 1907 |
+
policy_id=_uin_hit[0],
|
| 1908 |
+
policy_name=_uin_hit[1],
|
| 1909 |
+
chunks_added=0,
|
| 1910 |
+
pages_indexed=len(pages),
|
| 1911 |
+
elapsed_ms=int((_time.time() - t0) * 1000),
|
| 1912 |
+
already_in_catalogue=True,
|
| 1913 |
+
existing_policy_id=_uin_hit[0],
|
| 1914 |
+
existing_policy_name=_uin_hit[1],
|
| 1915 |
+
)
|
| 1916 |
client_ip = (request.client.host if request and request.client else "") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
| 1917 |
verdict = await check_upload(
|
| 1918 |
content=contents,
|
|
@@ -101,6 +101,14 @@ class _FirstNameStoredFixture(unittest.TestCase):
|
|
| 101 |
try:
|
| 102 |
import json
|
| 103 |
for fp in _PROFILES_DIR.glob("*.json"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
try:
|
| 105 |
d = json.loads(fp.read_text())
|
| 106 |
except Exception:
|
|
|
|
| 101 |
try:
|
| 102 |
import json
|
| 103 |
for fp in _PROFILES_DIR.glob("*.json"):
|
| 104 |
+
# Bug-#45 test hygiene: handle_turn now auto-persists ANY
|
| 105 |
+
# captured name, so the hard-coded "unknown name" used by
|
| 106 |
+
# test_unknown_name_no_false_recall_later_turn leaks a
|
| 107 |
+
# zzqxnobody*.json. Clean it (and this fixture's own files)
|
| 108 |
+
# so a re-run starts from a true no-stored-profile state.
|
| 109 |
+
if fp.stem.startswith("zzqxnobody"):
|
| 110 |
+
fp.unlink(missing_ok=True)
|
| 111 |
+
continue
|
| 112 |
try:
|
| 113 |
d = json.loads(fp.read_text())
|
| 114 |
except Exception:
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression test for #47 β UIN net-new dedup on user uploads (2026-05-21).
|
| 2 |
+
|
| 3 |
+
When a user uploads a policy PDF whose IRDAI UIN already belongs to a
|
| 4 |
+
catalogue policy, it is NOT net-new: the upload route must short-circuit
|
| 5 |
+
and return the existing card (UploadResponse.already_in_catalogue=True)
|
| 6 |
+
instead of indexing a duplicate. These pin the helper logic + the
|
| 7 |
+
response-model contract; both symbols did not exist pre-#47.
|
| 8 |
+
"""
|
| 9 |
+
import unittest
|
| 10 |
+
|
| 11 |
+
from backend.main import (
|
| 12 |
+
UploadResponse,
|
| 13 |
+
_catalogue_uin_index,
|
| 14 |
+
_match_catalogue_uin,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TestUploadUinDedup(unittest.TestCase):
|
| 19 |
+
def test_catalogue_uin_index_is_built(self):
|
| 20 |
+
idx = _catalogue_uin_index()
|
| 21 |
+
self.assertGreater(len(idx), 50, "catalogue UIN index looks empty")
|
| 22 |
+
for uin, val in idx.items():
|
| 23 |
+
self.assertRegex(uin, r"^[A-Z]{5,9}\d{5}V\d{6}$")
|
| 24 |
+
self.assertEqual(len(val), 2, "value must be (policy_id, name)")
|
| 25 |
+
|
| 26 |
+
def test_known_catalogue_uin_matches(self):
|
| 27 |
+
# Every indexed (modern-format) UIN must round-trip through the
|
| 28 |
+
# matcher when embedded in wording text. Order-independent β not
|
| 29 |
+
# fragile to dict-iteration / module-cache state.
|
| 30 |
+
idx = _catalogue_uin_index()
|
| 31 |
+
self.assertTrue(idx, "catalogue UIN index is empty")
|
| 32 |
+
misses = []
|
| 33 |
+
for uin, val in idx.items():
|
| 34 |
+
if _match_catalogue_uin(f"policy wording UIN {uin} terms") != val:
|
| 35 |
+
misses.append(uin)
|
| 36 |
+
if _match_catalogue_uin(uin.lower()) != val: # case-insensitive
|
| 37 |
+
misses.append(uin + " (lowercase)")
|
| 38 |
+
self.assertEqual(misses, [], f"UINs that did not round-trip: {misses[:8]}")
|
| 39 |
+
|
| 40 |
+
def test_unknown_or_fake_uin_is_not_matched(self):
|
| 41 |
+
self.assertIsNone(_match_catalogue_uin("plain text, no identifier"))
|
| 42 |
+
self.assertIsNone(_match_catalogue_uin(""))
|
| 43 |
+
# valid UIN *shape* but not a real catalogue policy
|
| 44 |
+
self.assertIsNone(_match_catalogue_uin("ZZZHLIP00000V000000"))
|
| 45 |
+
|
| 46 |
+
def test_uploadresponse_dedup_fields(self):
|
| 47 |
+
hit = UploadResponse(
|
| 48 |
+
policy_id="acko__acko-health-ii", policy_name="Acko Health Ii",
|
| 49 |
+
chunks_added=0, pages_indexed=10, elapsed_ms=5,
|
| 50 |
+
already_in_catalogue=True,
|
| 51 |
+
existing_policy_id="acko__acko-health-ii",
|
| 52 |
+
existing_policy_name="Acko Health Ii")
|
| 53 |
+
self.assertTrue(hit.already_in_catalogue)
|
| 54 |
+
self.assertEqual(hit.existing_policy_id, "acko__acko-health-ii")
|
| 55 |
+
# net-new upload β fields default to the not-a-dedup-hit state
|
| 56 |
+
fresh = UploadResponse(
|
| 57 |
+
policy_id="user-upload__x__y", policy_name="Y",
|
| 58 |
+
chunks_added=42, pages_indexed=45, elapsed_ms=9)
|
| 59 |
+
self.assertFalse(fresh.already_in_catalogue)
|
| 60 |
+
self.assertIsNone(fresh.existing_policy_id)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
unittest.main()
|