Spaces:
Sleeping
feat(upload): backfill LLM extraction for old uploads + admin re-extract endpoint
Browse filesOld 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>
- backend/main.py +34 -0
- backend/uploaded_docs.py +55 -0
|
@@ -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,
|
|
@@ -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
|