rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
88e54a8
Β·
1 Parent(s): 245310d

feat(upload): backfill LLM extraction for old uploads + admin re-extract endpoint

Browse files

Old uploads (persisted before ADR-044's LLM-extraction pipeline was
wired, OR persisted between dfaa4d6 and 245310d when the pipeline
was crashing on the missing asyncio import) never had a
rag/extracted/<policy_id>.json written. Their cards therefore stayed
at the ~13-17% heuristic-only completeness baseline forever β€” looked
identical pre- and post- fix because the new pipeline never touched
them.

CHANGES
─────────────────────────────────────────────────────────────────────
backend/uploaded_docs.py
- New backfill_extractions(force=False) β€” iterates every persisted
upload (UPLOADED_DOCS_DIR), skips ones that already have a
rag/extracted/<id>.json (unless force=True), calls
extract_one_for_upload sequentially. Returns
{processed, skipped, failed, policies}.

backend/main.py
- New @app .on_event("startup") _startup_upload_extraction_backfill β€”
fires backfill as a fire-and-forget asyncio task on every
container boot. Old uploads upgrade automatically to the new
pipeline's data depth.
- New POST /api/admin/upload/reextract β€” admin-gated trigger for
on-demand backfill, with `?force=true` to re-extract everything.

VERIFY
─────────────────────────────────────────────────────────────────────
- py_compile + import both clean
- Live audit on next deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. backend/main.py +34 -0
  2. backend/uploaded_docs.py +55 -0
backend/main.py CHANGED
@@ -555,6 +555,20 @@ async def _startup_quarantine_ttl_purge():
555
  asyncio.create_task(_quarantine_purge_loop())
556
 
557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  @app.on_event("startup")
559
  async def _startup_single_brain_warmup():
560
  """Pre-warm the Gemini single-brain connection so the FIRST /api/chat turn
@@ -2067,6 +2081,26 @@ class ExtractionStatusResponse(BaseModel):
2067
  error: Optional[str] = None
2068
 
2069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2070
  @app.get(
2071
  "/api/upload/extraction-status/{policy_id}",
2072
  response_model=ExtractionStatusResponse,
 
555
  asyncio.create_task(_quarantine_purge_loop())
556
 
557
 
558
+ @app.on_event("startup")
559
+ async def _startup_upload_extraction_backfill():
560
+ """ADR-044 (2026-05-27) β€” on every container boot, run LLM-assisted
561
+ extraction on any persisted upload that doesn't yet have a
562
+ `rag/extracted/<policy_id>.json` file. This upgrades old uploads
563
+ (persisted before the LLM-extraction pipeline was wired, OR before
564
+ a fix to the pipeline was deployed) to the same data depth a fresh
565
+ upload now gets. Idempotent: extracts that already exist are skipped.
566
+ Fire-and-forget so it doesn't delay app readiness.
567
+ """
568
+ from backend import uploaded_docs as _udocs
569
+ asyncio.create_task(_udocs.backfill_extractions(force=False))
570
+
571
+
572
  @app.on_event("startup")
573
  async def _startup_single_brain_warmup():
574
  """Pre-warm the Gemini single-brain connection so the FIRST /api/chat turn
 
2081
  error: Optional[str] = None
2082
 
2083
 
2084
+ @app.post("/api/admin/upload/reextract")
2085
+ async def admin_reextract_uploads(
2086
+ request: Request,
2087
+ x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
2088
+ force: bool = False,
2089
+ ):
2090
+ """Run LLM-assisted extraction on every persisted upload that doesn't
2091
+ yet have a `rag/extracted/<id>.json` (or `force=true` to re-extract all).
2092
+ Admin-gated; fires synchronously so the response carries the summary.
2093
+
2094
+ Use when an upload was persisted before the LLM-extraction pipeline was
2095
+ wired and needs to be upgraded without re-uploading.
2096
+ """
2097
+ from backend.admin import _check_admin
2098
+ _check_admin(request, x_admin_password)
2099
+ from backend import uploaded_docs as _udocs
2100
+ summary = await _udocs.backfill_extractions(force=force)
2101
+ return summary
2102
+
2103
+
2104
  @app.get(
2105
  "/api/upload/extraction-status/{policy_id}",
2106
  response_model=ExtractionStatusResponse,
backend/uploaded_docs.py CHANGED
@@ -957,8 +957,63 @@ async def extract_one_for_upload(
957
  )
958
  except Exception:
959
  pass
 
960
  _log.warning(
961
  "[upload-extract] unexpected failure for %s: %s: %s",
962
  policy_id, type(e).__name__, str(e)[:400],
963
  )
964
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
957
  )
958
  except Exception:
959
  pass
960
+ # fall through to the existing _log.warning that follows
961
  _log.warning(
962
  "[upload-extract] unexpected failure for %s: %s: %s",
963
  policy_id, type(e).__name__, str(e)[:400],
964
  )
965
  return False
966
+
967
+
968
+ async def backfill_extractions(*, force: bool = False) -> dict:
969
+ """Run LLM-assisted extraction for every persisted upload that doesn't
970
+ yet have a corresponding `rag/extracted/<policy_id>.json` (or force=True
971
+ to re-extract every upload). Fires sequentially so we don't fan-out the
972
+ LLM chain. Returns a {processed, skipped, failed} summary.
973
+
974
+ Designed to be called once at server startup (to upgrade old uploads
975
+ that were persisted before the LLM-extraction pipeline was wired) AND
976
+ as the backing for POST /api/admin/upload/reextract.
977
+ """
978
+ from backend.config import settings as _settings
979
+ summary: dict = {"processed": 0, "skipped": 0, "failed": 0, "policies": []}
980
+ records = load_persisted_records()
981
+ for policy_id, record in records.items():
982
+ try:
983
+ out_json = _settings.EXTRACTED_DIR / f"{policy_id}.json"
984
+ if out_json.exists() and not force:
985
+ summary["skipped"] += 1
986
+ continue
987
+ pdf_path = _doc_dir(policy_id) / "source.pdf"
988
+ if not pdf_path.exists():
989
+ _log.warning(
990
+ "[backfill] missing source.pdf for %s β€” skipping", policy_id,
991
+ )
992
+ summary["skipped"] += 1
993
+ continue
994
+ policy_name = record.get("policy_name") or policy_id
995
+ insurer_slug = record.get("insurer_slug") or UPLOAD_INSURER_SLUG
996
+ insurer_name = record.get("insurer_name") or detected_insurer_name(insurer_slug)
997
+ ok = await extract_one_for_upload(
998
+ policy_id=policy_id,
999
+ pdf_path=pdf_path,
1000
+ policy_name=policy_name,
1001
+ insurer_slug=insurer_slug,
1002
+ insurer_name=insurer_name,
1003
+ )
1004
+ if ok:
1005
+ summary["processed"] += 1
1006
+ summary["policies"].append(policy_id)
1007
+ else:
1008
+ summary["failed"] += 1
1009
+ except Exception as e: # noqa: BLE001
1010
+ _log.warning(
1011
+ "[backfill] failed for %s: %s: %s",
1012
+ policy_id, type(e).__name__, str(e)[:200],
1013
+ )
1014
+ summary["failed"] += 1
1015
+ _log.info(
1016
+ "[backfill] done: processed=%d skipped=%d failed=%d",
1017
+ summary["processed"], summary["skipped"], summary["failed"],
1018
+ )
1019
+ return summary