Spaces:
Sleeping
feat(upload-extract): Tier-1 Gemini stability + new ADR-044 + README rewrites
Browse filesUPLOADED_DOCS Gemini retry hardening (extract_one_for_upload):
- Gemini retry count 1 β 3 with jittered exp backoffs (2s/4s/8s Β±25%),
mirroring ADR-042's _TRANSIENT_RETRY_BACKOFFS_STICKY pattern that
handles Gemini's 429/5xx tail in single_brain.
- NIM moved to FINAL fallback (after Gemini exhausts retries).
- Raw LLM responses persisted to
UPLOADED_DOCS_DIR/<pid>/llm_raw_<n>_<label>.txt on every attempt
(success or failure) β gives the operator visibility into WHY a
Gemini call failed (was it 429, truncation mid-emission, markdown
fence around the JSON, schema mismatch?). Previously a black box.
DOCS:
- New 70-docs/60-decisions/ADR-044-uploaded-pdf-parity.md β the
architectural record for the entire upload-PDF parity effort
(LLM extraction + insurer detection + status endpoint + sequence
lock + backfill). Includes verification fingerprints from the
live e8ccfa0 audit.
- README Β§2.1 user-journey diagram extended with the new upload
sub-flow (UP β UPIDX β UPCARD β UPCHOICE) and supporting prose.
- README Β§4.4 "Uploaded-PDF field extraction is deterministic-
heuristic, not LLM" REWRITTEN β now describes the two-pass
heuristic-floor + LLM-augment pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 70-docs/60-decisions/ADR-044-uploaded-pdf-parity.md +118 -0
- README.md +38 -6
- backend/uploaded_docs.py +43 -24
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-044 β Uploaded-PDF parity with catalogued 148 + locked upload sequence
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-05-27
|
| 4 |
+
**Status:** Accepted
|
| 5 |
+
**Related:** Extends ADR-022 (conversational profile updates), ADR-040 (Gemini primary), ADR-043 (no cross-session recall). Does not supersede any.
|
| 6 |
+
**Commits:** `4bb66dd` (initial parity), `245310d` (asyncio import hotfix), `88e54a8` (backfill), `24349be` (PTT voice gate), `9ec9eae` (Header import hotfix), `7ef3ca3` β `fd30ceb` β `52b0b5d` β `835f745` β `e8ccfa0` (LLM-extraction iteration), `dfaa4d6` β `3dcbe9b` (sequence lock).
|
| 7 |
+
|
| 8 |
+
## Context
|
| 9 |
+
|
| 10 |
+
Pre-this-ADR the upload pipeline produced a card that the user (rightly) said *"lacks all data as required in parity with the existing policy pdf cards"*:
|
| 11 |
+
|
| 12 |
+
- Card displayed only ~13β48% of decision-critical fields (vs the 74% median completeness on the catalogued 148).
|
| 13 |
+
- `insurer_slug` was hardcoded to `"user-upload"`, so the Claim Experience sub-score got no real IRDAI claim-ratio data β the inline card showed *"Reviews: reputation data being compiled"* indefinitely (placeholder text with no backend that ever populates it).
|
| 14 |
+
- The chat sequence around an upload was broken: ack message + choice prompt fired in parallel BEFORE the card was ready, and voice auto-fire during the long indexing window dropped unprompted "please upload" or analysis messages into chat.
|
| 15 |
+
- Old uploads (uploaded before the new pipeline was wired) never got upgraded.
|
| 16 |
+
|
| 17 |
+
User directive (2026-05-27): *"Whatever is done for the 148 policies pre-populated, exact same level of usability needs to be there for user-uploaded PDFs. No less."* And the chat sequence: *"the card should not be presented until the card summary and the detailed LLM extraction and full field population is done. And until the card is presented, no further questions should be asked."*
|
| 18 |
+
|
| 19 |
+
## Decision
|
| 20 |
+
|
| 21 |
+
Three coordinated changes, all in service of catalogued-grade parity for live uploads.
|
| 22 |
+
|
| 23 |
+
### D1. LLM-assisted extraction pipeline for live uploads
|
| 24 |
+
|
| 25 |
+
Identical extractor as the catalogued 148:
|
| 26 |
+
|
| 27 |
+
| Step | Same as catalogued 148 |
|
| 28 |
+
|---|---|
|
| 29 |
+
| Chunking | β
`rag/ingest.py`, ~500-token overlap, BGE-small-en-v1.5 384-d embeddings |
|
| 30 |
+
| Embedding into Chroma | β
Per-session `user_uploads_quarantine` + global `policies` collection |
|
| 31 |
+
| LLM extraction | β
Same `get_brain_llm()` (Gemini 2.5-flash primary, NIM chain fallback), same `EXTRACT_SYSTEM` prompt, same `HealthPolicy` Pydantic schema |
|
| 32 |
+
| Structured JSON output | β
Same `rag/extracted/<policy_id>.json` path |
|
| 33 |
+
| Marketplace inclusion | β
Same `_marketplace_catalogue` Pass-1 + Pass-2 pipeline; uploads become first-class cards (`UPLOADED_DOCS_DIR/<pid>/`) |
|
| 34 |
+
| Scorecard | β
Same `/api/policies/{id}/scorecard` endpoint, no `user-upload__` branches |
|
| 35 |
+
| Premium estimate | β
Same `/api/premium/estimate` endpoint |
|
| 36 |
+
| RAG chat answers | β
Same `retrieve_policies` tool, same faithfulness guard |
|
| 37 |
+
| Card UI | β
Same `PolicyScorecardWidget` component, no source-aware branches |
|
| 38 |
+
| Source-PDF link | β
Persisted PDF at `UPLOADED_DOCS_DIR/<pid>/source.pdf`, served via the same per-policy PDF endpoint |
|
| 39 |
+
|
| 40 |
+
The only difference from the catalogued 148 is that catalogued PDFs went through **multiple extraction passes + human review + curated overlay** (`40-data/policy_facts/<insurer>__<product>.json`). Live uploads run **one extraction pass with no human review**, but with two safety nets:
|
| 41 |
+
|
| 42 |
+
- **Heuristic baseline floor** β `build_record()` in `backend/uploaded_docs.py` synchronously regex-extracts ~30β50% of fields during the upload HTTP call. This is the floor; on LLM extraction failure the card still has real data. Same shape as the curated layer (`{value, source_pdf_path, source_quote, _confidence}`).
|
| 43 |
+
- **LLM-into-heuristic merge** β when `extract_one_for_upload` completes, the LLM output is merged INTO `record.json` (LLM wins per-field where non-empty, heuristic stays where LLM was silent). This is the same "extracted + curated overlay" model the catalogued 148 use.
|
| 44 |
+
|
| 45 |
+
### D2. Insurer detection from PDF text
|
| 46 |
+
|
| 47 |
+
`backend/uploaded_docs.py::detect_insurer_slug()` scans the first ~6 000 chars of the PDF text against the 21 insurer name patterns we have reviews data for (acko, aditya-birla, bajaj-allianz, care-health, cholamandalam, go-digit, hdfc-ergo, icici-lombard, iffco-tokio, indusind-general, manipalcigna, national-insurance, new-india, niva-bupa, oriental-insurance, reliance-general, royal-sundaram, sbi-general, star-health, tata-aig). On a hit, `insurer_slug` flips off the generic `"user-upload"` to the real slug. The Claim Experience sub-score then reads the corresponding `40-data/reviews/<slug>.json` β same path as a catalogued card. Fail-closed: no match β stays `"user-upload"` (the generic insurer reputation isn't fabricated, the sub-score just falls back to a neutral score).
|
| 48 |
+
|
| 49 |
+
Live verification: Sarvah Param.pdf (a ManipalCigna product) β detected as `manipalcigna` β Claim Experience score = 83/100 with verbatim signals `["cashless supported", "99.0% CSR (IRDAI 2023-24)"]`.
|
| 50 |
+
|
| 51 |
+
### D3. New upload status endpoint + frontend polling
|
| 52 |
+
|
| 53 |
+
- **New `_UPLOAD_EXTRACTION_STATUS` in-memory dict** in `backend/uploaded_docs.py` tracks per-upload state: `pending β running β complete | failed`.
|
| 54 |
+
- **`extract_one_for_upload` writes status** at every phase + final `completeness_pct` and `overall_grade`.
|
| 55 |
+
- **New `GET /api/upload/extraction-status/{policy_id}`** exposes the live dict. Returns `status="unknown"` for unrecognised policy_ids so the client can stop polling.
|
| 56 |
+
- **Frontend `handleFile` polls** every 3 s for up to 120 s. Card-bearing assistant message + choice prompt fire only after the poll sees `complete` or `failed`/timeout.
|
| 57 |
+
|
| 58 |
+
### D4. Locked chat sequence
|
| 59 |
+
|
| 60 |
+
Per user directive (verbatim): *"until the card summary and the detailed LLM extraction and full field population is done, the card should not be presented. And until the card is presented, no further questions should be asked."* Implemented:
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
1. User picks file (PDF input)
|
| 64 |
+
2. setUploadStatus(t("upload.indexing")) β amber banner above composer
|
| 65 |
+
setExtractionInFlight(true) β gates EVERY send-path
|
| 66 |
+
3. POST /api/upload-policy β ~10-60s
|
| 67 |
+
4. pushAssistant(ack_reading) β "Got it β reading X, ~30-60s"
|
| 68 |
+
[NO card yet, NO choice prompt yet]
|
| 69 |
+
5. Poll /api/upload/extraction-status every 3s, up to 120s
|
| 70 |
+
[During this entire wait window, EVERYTHING is gated:
|
| 71 |
+
- Send button: disabled={busy || !input.trim() || extractionInFlight}
|
| 72 |
+
- Textarea: disabled={busy || extractionInFlight}
|
| 73 |
+
- PDF button: disabled={busy || extractionInFlight}
|
| 74 |
+
- send() function: early-return on extractionInFlight (last-line guard)
|
| 75 |
+
- voiceSubmitRef: guarded
|
| 76 |
+
- PTT/Sarvam path: guarded
|
| 77 |
+
]
|
| 78 |
+
6. On status="complete":
|
| 79 |
+
pushAssistant(card_ready_message, { citations: [{policy_id, ...}] })
|
| 80 |
+
[card renders inline below this bubble]
|
| 81 |
+
pushAssistant(choice_prompt)
|
| 82 |
+
On status="failed" / timeout:
|
| 83 |
+
pushAssistant(extraction_failed_message)
|
| 84 |
+
pushAssistant(choice_prompt)
|
| 85 |
+
7. setExtractionInFlight(false) β controls re-enable
|
| 86 |
+
8. User picks "finish profile" / "dive into PDF" β normal chat continues
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
The choice prompt **never fires before the card lands**. This is verified by the in-code ordering β both branches of step 6 push the choice prompt AFTER the prior message.
|
| 90 |
+
|
| 91 |
+
### D5. Backfill for legacy uploads
|
| 92 |
+
|
| 93 |
+
`backend/uploaded_docs.py::backfill_extractions(force=False)` iterates `UPLOADED_DOCS_DIR`, skips any policy_id that already has `rag/extracted/<pid>.json` (unless `force=True`), and runs `extract_one_for_upload` for the rest. Fired as a fire-and-forget `asyncio.create_task` from the `@app.on_event("startup")` hook so every container boot upgrades any old upload that was persisted before this pipeline was wired. Also exposed as `POST /api/admin/upload/reextract?force=<bool>` for on-demand operator triggering.
|
| 94 |
+
|
| 95 |
+
## Consequences
|
| 96 |
+
|
| 97 |
+
- **Live upload cards reach catalogued-grade depth** by construction: same LLM, same schema, same scorecard endpoint. Verified live: Sarvah Param.pdf upload β grade C, score 65/100, 6 sub-scores all populated with verbatim signals, real IRDAI claim data via manipalcigna insurer detection.
|
| 98 |
+
- **Heuristic baseline is a hard floor** β even if Gemini 5xxs / parses fail, the card still produces a real grade C off the 47.8% heuristic completeness.
|
| 99 |
+
- **No race conditions in the chat flow** β Send button + every voice path are gated by `extractionInFlight`, and the `send()` function has a last-line guard so even a programmatic future caller can't bypass.
|
| 100 |
+
- **Backward-compatible for old uploads** β backfill upgrades anything persisted under `UPLOADED_DOCS_DIR/` before this pipeline was wired.
|
| 101 |
+
- **Operator visibility** β `/api/upload/extraction-status/{pid}` shows live state to the frontend AND can be polled by the operator for debugging.
|
| 102 |
+
|
| 103 |
+
## Caveats / open follow-ups
|
| 104 |
+
|
| 105 |
+
- **Single LLM pass per upload** β catalogued 148 had multiple extraction passes + human review. A non-standard PDF that the heuristic also can't read well will produce a card at the heuristic floor (40β50% completeness) without a grade lift from the LLM. Multi-pass extraction is a known follow-up (task #109).
|
| 106 |
+
- **Gemini intermittency** β live observation shows Gemini occasionally returning unparseable output for the same prompt that succeeded earlier. NIM fallback fires automatically on Gemini failure. Cause not fully traced; tracked for follow-up.
|
| 107 |
+
- **Cross-session retrieval** β every upload is written to both the per-session quarantine AND the global `policies` Chroma collection (so it can become a marketplace card visible to other users). The global chunks have no `session_id` metadata, so `retrieve_policies` queries can pull chunks from other users' uploads. For the "analyse THIS upload" intent this can produce cross-policy confusion. Mitigated today by the per-session quarantine boost in retrieval; full per-session scoping is a tracked follow-up.
|
| 108 |
+
|
| 109 |
+
## Verification
|
| 110 |
+
|
| 111 |
+
- Live audit on commit `e8ccfa0` (https://rohitsar567-insurancebot.hf.space):
|
| 112 |
+
- Fresh upload of Sarvah Param.pdf via `/api/upload-policy`
|
| 113 |
+
- Extraction status: pending β running β complete in ~12 s
|
| 114 |
+
- Scorecard endpoint: `grade=C, score=65, completeness=52.2%, insurer_slug=manipalcigna`
|
| 115 |
+
- Six sub-scores all populated with verbatim signals
|
| 116 |
+
- Claim Experience score 83 with `["cashless supported", "99.0% CSR (IRDAI 2023-24)"]` β real IRDAI claim data, sourced from `40-data/reviews/manipalcigna.json` via the detected insurer slug
|
| 117 |
+
- Heuristic baseline floor verified: when Gemini fails on a fresh upload, the card still serves `grade=C, score=64, completeness=47.8%` via the heuristic record
|
| 118 |
+
- Sequence lock verified live: choice prompt always appears AFTER card_ready_message OR extraction_failed_message, never before
|
|
@@ -133,11 +133,15 @@ flowchart TD
|
|
| 133 |
EXPLORE -->|"Compare"| CMP["βοΈ Compare up to 4 plans side by side Β· full scorecard per plan"]
|
| 134 |
EXPLORE -->|"Browse"| MKT["π Browse the full indexed marketplace"]
|
| 135 |
EXPLORE -->|"Ask"| QA["π¬ Ask follow-up questions β answered only from the actual documents"]
|
| 136 |
-
EXPLORE -->|"My own policy"| UP["π Upload your own policy PDF
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
CMP --> PREM
|
| 138 |
MKT --> PREM
|
| 139 |
QA --> PREM
|
| 140 |
-
UP --> PREM
|
| 141 |
PREM["πΈ A live premium estimate that updates as you change your profile"] --> DONE["β
Decide with confidence β no lead capture, no commission bias"]
|
| 142 |
VOICE["ποΈ Optional the whole way: speak instead of type β it speaks the answers back"] -.-> TELL
|
| 143 |
VOICE -.-> QA
|
|
@@ -160,6 +164,15 @@ memory; closing the tab forgets you (privacy-by-design, see ADR-043).
|
|
| 160 |
- **Branches from the shortlist.** Compare side by side, browse the full
|
| 161 |
marketplace, ask follow-up questions, or upload your *own* policy PDF
|
| 162 |
and ask about your document (kept private to your session).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
- **Live premium.** Updates as you change the profile.
|
| 164 |
- **Decision.** No lead capture and no commission bias β the path ends at
|
| 165 |
*decide*, not at a sales handoff.
|
|
@@ -915,10 +928,29 @@ These are real and stated up front rather than buried:
|
|
| 915 |
curated/extracted baseline. Treat uploads as session-scoped. An
|
| 916 |
operator/abuse prune endpoint exists (`POST /api/admin/uploaded-docs/
|
| 917 |
prune`, password-gated) to remove a persisted upload by id or prefix.
|
| 918 |
-
- **Uploaded-PDF field extraction is
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
- **Live (BETA) voice mode** uses the browser's in-built speech
|
| 923 |
recognition and is labelled unstable; **push-to-talk** is the reliable
|
| 924 |
path (warm-armed mic + pre-roll so the first word is never clipped, and
|
|
|
|
| 133 |
EXPLORE -->|"Compare"| CMP["βοΈ Compare up to 4 plans side by side Β· full scorecard per plan"]
|
| 134 |
EXPLORE -->|"Browse"| MKT["π Browse the full indexed marketplace"]
|
| 135 |
EXPLORE -->|"Ask"| QA["π¬ Ask follow-up questions β answered only from the actual documents"]
|
| 136 |
+
EXPLORE -->|"My own policy"| UP["π Upload your own policy PDF"]
|
| 137 |
+
UP --> UPIDX["β³ Quick ack β 'Reading it through, ~30β60 s'<br/>(everything in chat is gated while the analysis runs)"]
|
| 138 |
+
UPIDX --> UPCARD["π Inline scorecard card with FULL data:<br/>grade letter Β· 6 sub-scores Β· verbatim signals Β· insurer reputation"]
|
| 139 |
+
UPCARD --> UPCHOICE{"How would you like to proceed?"}
|
| 140 |
+
UPCHOICE -->|"Finish profile"| TELL
|
| 141 |
+
UPCHOICE -->|"Dive into the PDF"| QA
|
| 142 |
CMP --> PREM
|
| 143 |
MKT --> PREM
|
| 144 |
QA --> PREM
|
|
|
|
| 145 |
PREM["πΈ A live premium estimate that updates as you change your profile"] --> DONE["β
Decide with confidence β no lead capture, no commission bias"]
|
| 146 |
VOICE["ποΈ Optional the whole way: speak instead of type β it speaks the answers back"] -.-> TELL
|
| 147 |
VOICE -.-> QA
|
|
|
|
| 164 |
- **Branches from the shortlist.** Compare side by side, browse the full
|
| 165 |
marketplace, ask follow-up questions, or upload your *own* policy PDF
|
| 166 |
and ask about your document (kept private to your session).
|
| 167 |
+
- **Upload-PDF flow is a staged sequence** (ADR-044, 2026-05-27):
|
| 168 |
+
upload β bot says *"reading it through, ~30β60 s"* β all chat input is
|
| 169 |
+
gated during the wait (Send button, textarea, voice paths all blocked
|
| 170 |
+
so nothing can interrupt the staging) β bot pushes the inline
|
| 171 |
+
scorecard card with FULL extracted data once the LLM pass lands β bot
|
| 172 |
+
then asks whether you'd like to finish your profile or dive into the
|
| 173 |
+
PDF. The card is the same shape as any catalogued policy card β six
|
| 174 |
+
sub-scores, verbatim signals, real claim-settlement data when the
|
| 175 |
+
insurer is recognised.
|
| 176 |
- **Live premium.** Updates as you change the profile.
|
| 177 |
- **Decision.** No lead capture and no commission bias β the path ends at
|
| 178 |
*decide*, not at a sales handoff.
|
|
|
|
| 928 |
curated/extracted baseline. Treat uploads as session-scoped. An
|
| 929 |
operator/abuse prune endpoint exists (`POST /api/admin/uploaded-docs/
|
| 930 |
prune`, password-gated) to remove a persisted upload by id or prefix.
|
| 931 |
+
- **Uploaded-PDF field extraction is LLM-assisted, with a deterministic
|
| 932 |
+
heuristic floor (ADR-044, 2026-05-27).** Every upload runs through two
|
| 933 |
+
passes:
|
| 934 |
+
- **Heuristic baseline** β regex + keyword extraction over the PDF text,
|
| 935 |
+
runs synchronously inside the upload HTTP call (sub-second), populates
|
| 936 |
+
common fields like waiting periods and room-rent rule. Yields
|
| 937 |
+
~30β50 % data_completeness.
|
| 938 |
+
- **LLM-assisted extraction** β fires as a background asyncio task
|
| 939 |
+
after the upload returns. Same `get_brain_llm()` chain the catalogued
|
| 940 |
+
148 use offline (Gemini 2.5-flash primary, NVIDIA NIM fallback);
|
| 941 |
+
same `EXTRACT_SYSTEM` prompt; same `HealthPolicy` Pydantic schema;
|
| 942 |
+
output written to `rag/extracted/<policy_id>.json` and merged INTO
|
| 943 |
+
the persisted `record.json` (LLM values override where present,
|
| 944 |
+
heuristic stays where the LLM was silent). ~10β60 s.
|
| 945 |
+
The frontend polls `GET /api/upload/extraction-status/<policy_id>`
|
| 946 |
+
during the wait and renders the inline scorecard card ONLY after the
|
| 947 |
+
LLM pass either completes or hits its 120 s timeout. The card is
|
| 948 |
+
catalogued-grade β same `PolicyScorecardWidget`, same six sub-scores,
|
| 949 |
+
same insurer reputation data (because `detect_insurer_slug` matches
|
| 950 |
+
the PDF's legal name against the 21 known insurer slugs we have
|
| 951 |
+
reviews data for and flips `insurer_slug` off the generic `user-upload`
|
| 952 |
+
on a hit). On any LLM-pass failure the heuristic floor still produces a
|
| 953 |
+
real grade, never a fabricated one or a data-starved sentinel.
|
| 954 |
- **Live (BETA) voice mode** uses the browser's in-built speech
|
| 955 |
recognition and is labelled unstable; **push-to-talk** is the reliable
|
| 956 |
path (warm-armed mic + pre-roll so the first word is never clipped, and
|
|
@@ -853,38 +853,51 @@ async def extract_one_for_upload(
|
|
| 853 |
ChatMessage(role="user", content=prompt),
|
| 854 |
]
|
| 855 |
|
| 856 |
-
# Gemini-
|
| 857 |
-
#
|
| 858 |
-
#
|
| 859 |
-
#
|
| 860 |
-
#
|
| 861 |
-
#
|
| 862 |
-
#
|
| 863 |
-
#
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
raw = ""
|
| 868 |
policy: Optional[HealthPolicy] = None
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
try:
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
chat_kwargs = {
|
| 875 |
"messages": messages,
|
| 876 |
"temperature": 0.0,
|
| 877 |
"max_tokens": 8192,
|
| 878 |
}
|
| 879 |
-
# NOTE β we DON'T set response_format here. The
|
| 880 |
-
# EXTRACT_SYSTEM prompt already mandates JSON-only output
|
| 881 |
-
# ("Single object. No whitespace beyond what's needed.").
|
| 882 |
-
# Setting Gemini's responseMimeType=application/json was
|
| 883 |
-
# silently producing payloads that json_from_llm_text /
|
| 884 |
-
# HealthPolicy schema validation rejected. Letting the
|
| 885 |
-
# model emit JSON natively (and using json_from_llm_text's
|
| 886 |
-
# tolerant front-strip of fences + <think> blocks) is the
|
| 887 |
-
# path the catalogued 148 used successfully.
|
| 888 |
res = await asyncio.wait_for(
|
| 889 |
llm.chat(**chat_kwargs),
|
| 890 |
timeout=attempt_timeout,
|
|
@@ -894,6 +907,12 @@ async def extract_one_for_upload(
|
|
| 894 |
"[upload-extract] %s returned %d chars; parsing JSONβ¦",
|
| 895 |
label, len(raw or ""),
|
| 896 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
data = json_from_llm_text(raw)
|
| 898 |
# Force-fill identity fields (REQUIRED by the schema, the
|
| 899 |
# LLM frequently emits null for these because they're not
|
|
|
|
| 853 |
ChatMessage(role="user", content=prompt),
|
| 854 |
]
|
| 855 |
|
| 856 |
+
# Tier-1 Gemini-stability hardening (ADR-044, 2026-05-27):
|
| 857 |
+
# 1. Bumped retry count from 1 β 3 on the Gemini primary path
|
| 858 |
+
# with jittered exp backoffs (2s/4s/8s Β±25%). Mirrors the
|
| 859 |
+
# _TRANSIENT_RETRY_BACKOFFS_STICKY pattern from single_brain
|
| 860 |
+
# (ADR-042) that proved effective for Gemini's 429/5xx tail.
|
| 861 |
+
# 2. NIM is the FINAL fallback after Gemini exhausts retries.
|
| 862 |
+
# 3. Raw LLM responses are captured to disk on each failed
|
| 863 |
+
# attempt β UPLOADED_DOCS_DIR/<pid>/llm_raw_<n>.txt β so the
|
| 864 |
+
# operator can SEE why a Gemini call failed (was it a 429,
|
| 865 |
+
# truncated mid-emission, returned with markdown fences,
|
| 866 |
+
# etc.). Previously the failure was a black box.
|
| 867 |
+
import random as _random
|
| 868 |
+
_GEMINI_BACKOFFS = (2.0, 4.0, 8.0)
|
| 869 |
+
_GEMINI_JITTER = 0.25
|
| 870 |
+
def _jit(b: float) -> float:
|
| 871 |
+
return b * _random.uniform(1 - _GEMINI_JITTER, 1 + _GEMINI_JITTER)
|
| 872 |
+
|
| 873 |
+
llm_gemini = GoogleGeminiLLM(timeout=180.0)
|
| 874 |
+
llm_nim = get_brain_llm()
|
| 875 |
raw = ""
|
| 876 |
policy: Optional[HealthPolicy] = None
|
| 877 |
+
attempts: list[tuple[object, str]] = [
|
| 878 |
+
(llm_gemini, "gemini-2.5-flash#1"),
|
| 879 |
+
(llm_gemini, "gemini-2.5-flash#2"),
|
| 880 |
+
(llm_gemini, "gemini-2.5-flash#3"),
|
| 881 |
+
(llm_nim, "nim-fallback"),
|
| 882 |
+
]
|
| 883 |
+
for attempt, (llm, label) in enumerate(attempts):
|
| 884 |
try:
|
| 885 |
+
# Backoff BEFORE every attempt after the first Gemini try.
|
| 886 |
+
# First attempt: no wait. Subsequent Gemini attempts: jittered
|
| 887 |
+
# exp. NIM fallback: no extra backoff (Gemini already gave up).
|
| 888 |
+
if 1 <= attempt <= len(_GEMINI_BACKOFFS):
|
| 889 |
+
_bo = _jit(_GEMINI_BACKOFFS[attempt - 1])
|
| 890 |
+
_log.info(
|
| 891 |
+
"[upload-extract] sleeping %.1fs before %s retry",
|
| 892 |
+
_bo, label,
|
| 893 |
+
)
|
| 894 |
+
await asyncio.sleep(_bo)
|
| 895 |
+
attempt_timeout = 180 if label.startswith("gemini") else 120
|
| 896 |
chat_kwargs = {
|
| 897 |
"messages": messages,
|
| 898 |
"temperature": 0.0,
|
| 899 |
"max_tokens": 8192,
|
| 900 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
res = await asyncio.wait_for(
|
| 902 |
llm.chat(**chat_kwargs),
|
| 903 |
timeout=attempt_timeout,
|
|
|
|
| 907 |
"[upload-extract] %s returned %d chars; parsing JSONβ¦",
|
| 908 |
label, len(raw or ""),
|
| 909 |
)
|
| 910 |
+
# Persist raw response for ops visibility (always β both
|
| 911 |
+
# successful + failed parses get a copy).
|
| 912 |
+
try:
|
| 913 |
+
(_doc_dir(policy_id) / f"llm_raw_{attempt + 1}_{label.replace('#','-')}.txt").write_text(raw or "")
|
| 914 |
+
except Exception:
|
| 915 |
+
pass
|
| 916 |
data = json_from_llm_text(raw)
|
| 917 |
# Force-fill identity fields (REQUIRED by the schema, the
|
| 918 |
# LLM frequently emits null for these because they're not
|