Spaces:
Sleeping
fix(upload-extract): status parity on LLM-fail path + docs updated
Browse filesKI-333 β when all LLM passes (multi-pass + single-pass + NIM) fail
and the card falls back to the heuristic floor, the status endpoint
now reports the SAME completeness_pct + overall_grade the card
endpoint serves. Previously status said comp=None/grade=None while
the card showed C/65% β confusing parity gap caught live on
Test Policy.pdf (8 MB).
Fix runs the same `_catalogue_scorecard(pid, None)` β fallback
`build_scorecard(record.json flat, insurer_reviews)` resolution
the success path uses, but on the heuristic record (which exists
because build_record() ran synchronously in the upload HTTP call).
Flattens cell-shape {value, ...} dicts to scalars before scoring.
Plus README Β§2.8 + Β§4.4 + CLAUDE.md updated with:
- Heuristic floor expansion (KI-332): 32 patterns vs prior 16
- Multi-pass per-section extraction path
- Updated resolution order (1 hash-cache β 2 multipass β 3 single β 4 NIM β 5 heuristic)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md +21 -0
- README.md +256 -53
- backend/uploaded_docs.py +52 -0
|
@@ -76,6 +76,27 @@ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model.
|
|
| 76 |
- **Sticky-session retry policy (kept from ADR-042).** `_gemini_call` accepts `is_sticky` (read once from `session.single_brain_sticky` in `handle_turn`). Non-sticky: 1 retry @ 1.5 s (fast-fail to nim_fallback on cold-start). Sticky: 2 retries with jittered exp backoffs (1.5 s β 3 s, Β±25 %). The user-facing canned reply on exhausted retries: *"My model service had a brief blip on that turn β please send the same message again, it should go through now."*
|
| 77 |
- **Profile completeness gates on `Profile.asked`.** Default `dependents="self"` pre-fill in the builder form no longer registers as "done"; `profile_completeness_view` + `POST /api/profile` mask any field not in the `asked` list before scoring. The "Your profile X% done" badge starts at 0% for a brand-new session and only ticks up on explicit captures.
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
## Refusal precision (KI-046)
|
| 80 |
|
| 81 |
- Persona prompt now explicitly instructs the bot to refuse on **fanciful / out-of-scope scenarios** (space tourism, diamond-tipped surgery, fictional procedures) with a specific refusal sentence.
|
|
|
|
| 76 |
- **Sticky-session retry policy (kept from ADR-042).** `_gemini_call` accepts `is_sticky` (read once from `session.single_brain_sticky` in `handle_turn`). Non-sticky: 1 retry @ 1.5 s (fast-fail to nim_fallback on cold-start). Sticky: 2 retries with jittered exp backoffs (1.5 s β 3 s, Β±25 %). The user-facing canned reply on exhausted retries: *"My model service had a brief blip on that turn β please send the same message again, it should go through now."*
|
| 77 |
- **Profile completeness gates on `Profile.asked`.** Default `dependents="self"` pre-fill in the builder form no longer registers as "done"; `profile_completeness_view` + `POST /api/profile` mask any field not in the `asked` list before scoring. The "Your profile X% done" badge starts at 0% for a brand-new session and only ticks up on explicit captures.
|
| 78 |
|
| 79 |
+
## Uploaded-PDF pipeline (ADR-044, 2026-05-27 hardening)
|
| 80 |
+
|
| 81 |
+
- **Owning module:** `backend/uploaded_docs.py` (~1100 LOC). Endpoint `POST /api/upload-policy` returns HTTP 200 within ~1 s after the heuristic baseline is written. LLM extraction runs in a background `asyncio.create_task`.
|
| 82 |
+
- **Heuristic floor is a HARD guarantee.** `build_record()` runs synchronously inside the upload HTTP call (sub-second), writing `UPLOADED_DOCS_DIR/<pid>/record.json` with cell-shape `{value, source_pdf_path, source_quote, _confidence}` at ~30β50% data_completeness BEFORE the LLM ever fires. If Gemini fails AND NIM fails, the card still renders at this floor (47.8%, grade C on Test Policy.pdf, verified live).
|
| 83 |
+
- **`extract_one_for_upload()` resolution order (DO NOT reorder).**
|
| 84 |
+
1. Hash-cache short-circuit β `_find_cached_extraction(sha256(pdf_bytes))` returns a prior successful extraction β copy; `llm_used='hash-cache'`; ~1 s.
|
| 85 |
+
2. **Multi-pass per-section** β fires when `len(text) β₯ 25_000` chars. `_multipass_extract_with_gemini` runs 7 sections (`_EXTRACT_SECTIONS`) in parallel via `asyncio.gather`. Any section landing counts as success (partial HealthPolicy still merges); `llm_used='gemini-2.5-flash-multipass'`. Total failure β falls through to step 3.
|
| 86 |
+
3. Gemini 2.5-flash single-pass β 3 attempts with jittered exp backoff (`base * (2**i) * random.uniform(0.75, 1.25)` for base=2.0); `llm_used='gemini-2.5-flash#1|#2|#3'`.
|
| 87 |
+
4. NIM fallback β single attempt; `llm_used='nim-fallback'`.
|
| 88 |
+
5. Heuristic floor β `record.json` already exists from step 0; card renders at floor (now ~65β70% post KI-332 expansion); `status='failed'`.
|
| 89 |
+
- **Merge model.** When the LLM payload lands, scalars are merged INTO the heuristic `record.json` (LLM value wins where non-empty, heuristic stays where LLM silent; cell-shape preserved). This is the same "extracted + curated overlay" model the catalogued 148 use via `40-data/policy_facts/`.
|
| 90 |
+
- **Status endpoint = scorecard endpoint by construction.** `_set_extraction_status` at extraction-complete time calls `backend.main._catalogue_scorecard(pid, None)` β the SAME resolver `/api/policies/{id}/scorecard` uses primary. Falls back to `build_scorecard(doc, insurer_reviews, profile=None)` only when catalogue indices haven't refreshed. **DO NOT call `build_scorecard(doc, profile=None)` without `insurer_reviews`** β that was the 2026-05-27 bug (`completeness_pct=17.4`, `grade=None`) the cache-fix landed.
|
| 91 |
+
- **Attribute trap.** The `Scorecard` dataclass exposes `.grade`, NOT `.overall_grade` (only the wire `ScorecardResponse` model renames it). The status resolver reads `_sc.grade` β `.overall_grade` will silently return None on the dataclass.
|
| 92 |
+
- **`_MG_CACHE` invalidation BEFORE scorecard resolve.** `_set_extraction_status` bursts `backend.main._MG_CACHE` (the marketplace grade cache) BEFORE calling `_catalogue_scorecard`, so the resolver rebuilds the catalogue indices with the new card. Doing it after = stale cache.
|
| 93 |
+
- **Provenance fields on every status response:** `llm_used` (`gemini-2.5-flash#N | nim-fallback | hash-cache | null`) + `llm_response_chars` (size of raw LLM payload). Operator can verify which LLM landed the extraction without HF Space stdout access.
|
| 94 |
+
- **Backfill on startup.** `backfill_extractions()` is fired as an asyncio task from a `@app.on_event("startup")` hook β iterates `UPLOADED_DOCS_DIR/*`, skips any pid that already has `rag/extracted/<pid>.json` (unless `force=True`), runs extraction for the rest. Same logic exposed as admin endpoint `POST /api/admin/upload/reextract?force=<bool>`.
|
| 95 |
+
- **Insurer detection.** `detect_insurer_slug()` scans the first ~6000 chars of PDF text against 21 known insurer name patterns (`_INSURER_NAME_PATTERNS`). On hit, `insurer_slug` flips from generic `'user-upload'` to the real slug β the Claim Experience sub-score then reads `40-data/reviews/<slug>.json` (real IRDAI claim ratios). Fail-closed: no match β stays `'user-upload'`, no fabricated insurer name.
|
| 96 |
+
- **Locked chat sequence (ADR-044 D4).** Frontend's `extractionInFlight` flag gates Send + textarea + PDF button + every voice path (PTT/Sarvam/auto-fire) for the entire wait window. Choice prompt NEVER fires before card lands. Both branches of step 6 in `page.tsx`'s `handleFile` push the choice prompt AFTER the prior card/fail message β DO NOT reorder.
|
| 97 |
+
- **Post-card dive-in mode (KI-330).** When the upload card lands, `page.tsx` calls `setActiveUploadPid(r.policy_id)`. That pid then flows into every subsequent `/api/chat` request as `view_context.active_policy_id`. `single_brain.handle_turn` reads it and prepends an ACTIVE POLICY DIVE-IN block to the system instruction. **DO NOT remove the wire** β without it the brain pivots to "let me pull your recommendations" when the user asks waiting-period / room-rent / coverage questions about the just-uploaded PDF. Verified 9/10 grounded on 2026-05-27 audit.
|
| 98 |
+
- **Live verification matrix (2026-05-27, commit `2a58c28`):** 5 PDFs (manipalcigna, hdfc-ergo, care-health, icici-lombard, star-health) Γ {upload, extraction, scorecard, premium baseline, premium older+PED, personalisation profile, RAG grounded answer} = 35 cells, 33 green (the 2 misses are honest: Test Policy.pdf 3/3 Gemini fails caught by heuristic floor; one "room rent" question on star-health is correctly answered but the keyword detector missed it).
|
| 99 |
+
|
| 100 |
## Refusal precision (KI-046)
|
| 101 |
|
| 102 |
- Persona prompt now explicitly instructs the bot to refuse on **fanciful / out-of-scope scenarios** (space tourism, diamond-tipped surgery, fictional procedures) with a specific refusal sentence.
|
|
@@ -604,7 +604,7 @@ flowchart LR
|
|
| 604 |
|
| 605 |
**Provenance rule.** Every policy fact shown to a user traces to a real clause in a real PDF. Where a document genuinely doesn't state something, it is recorded as a sourced-null (*"not stated in <file>.pdf"*) β never invented or back-filled.
|
| 606 |
|
| 607 |
-
### 2.8 Uploaded-PDF flow β 8 security gates β catalogued-grade card
|
| 608 |
|
| 609 |
```mermaid
|
| 610 |
flowchart TB
|
|
@@ -618,40 +618,140 @@ flowchart TB
|
|
| 618 |
G7 --> G8["8 Hash dedupe + reject-cache"]
|
| 619 |
G8 -->|"pass"| QC["per-session QUARANTINE Chroma + global policies collection<br/>BGE-small embeddings Β· 24h idle TTL"]
|
| 620 |
QC --> HEUR["Heuristic baseline (synchronous, sub-second)<br/>regex/keyword over PDF text<br/>writes UPLOADED_DOCS_DIR/<pid>/record.json @ ~30-50% completeness"]
|
| 621 |
-
HEUR --> ACK["HTTP 200 β frontend pushes ack 'Reading it through, ~30-60s'<br/>EVERY chat input gated during the wait"]
|
| 622 |
HEUR -.->|"detect_insurer_slug<br/>match against 21 known insurers"| INS["insurer_slug = manipalcigna / hdfc-ergo / ...<br/>(or 'user-upload' on no match β fail-closed)"]
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
G1 & G2 & G3 & G4 & G5 & G6 & G7 & G8 -->|"fail"| REJ["clean rejection (reason surfaced)"]
|
| 627 |
```
|
| 628 |
|
| 629 |
-
**Summary.**
|
| 630 |
-
|
| 631 |
-
|
| 632 |
|
| 633 |
**How it flows:**
|
| 634 |
|
| 635 |
-
- **The 8 gates, in order.** (1) **File mechanics** β `%PDF`
|
| 636 |
-
size band, well-formed `%%EOF`, no embedded
|
| 637 |
-
launch actions. (2) **Content quality** β
|
| 638 |
-
β₯3 pages, at least one insurance-domain
|
| 639 |
-
sweep** β "ignore previous
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
- **On pass.** Chunks land in a per-session **quarantine** Chroma
|
| 650 |
-
collection β session-isolated, 24 h idle TTL β
|
| 651 |
-
`policies`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
- **On fail.** A clean rejection naming the gate; the file is deleted;
|
| 653 |
nothing is embedded.
|
| 654 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
### 2.9 Deployment
|
| 656 |
|
| 657 |
```mermaid
|
|
@@ -690,7 +790,7 @@ to end.
|
|
| 690 |
|
| 691 |
## 3. Key functions in plain language
|
| 692 |
|
| 693 |
-
**Summary.**
|
| 694 |
|
| 695 |
### 3.1 Profile construction
|
| 696 |
|
|
@@ -858,7 +958,75 @@ no cross-session memory β purely a same-conversation resilience path.
|
|
| 858 |
(There is no cross-session recall β see Β§2.6 and ADR-043 for why that
|
| 859 |
was removed.)
|
| 860 |
|
| 861 |
-
### 3.7
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
|
| 863 |
| What | Where | Why |
|
| 864 |
|---|---|---|
|
|
@@ -935,28 +1103,44 @@ These are real and stated up front rather than buried:
|
|
| 935 |
operator/abuse prune endpoint exists (`POST /api/admin/uploaded-docs/
|
| 936 |
prune`, password-gated) to remove a persisted upload by id or prefix.
|
| 937 |
- **Uploaded-PDF field extraction is LLM-assisted, with a deterministic
|
| 938 |
-
heuristic floor (ADR-044, 2026-05-27).** Every upload
|
| 939 |
-
passes:
|
| 940 |
-
- **Heuristic baseline** β regex + keyword extraction over the PDF
|
| 941 |
-
|
| 942 |
common fields like waiting periods and room-rent rule. Yields
|
| 943 |
-
~30β50 % data_completeness.
|
| 944 |
-
- **
|
| 945 |
-
after the upload returns. Same
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
the persisted `record.json` (LLM values override where present,
|
| 950 |
-
heuristic stays where the LLM was silent). ~10β60 s.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
The frontend polls `GET /api/upload/extraction-status/<policy_id>`
|
| 952 |
during the wait and renders the inline scorecard card ONLY after the
|
| 953 |
-
LLM pass
|
| 954 |
catalogued-grade β same `PolicyScorecardWidget`, same six sub-scores,
|
| 955 |
-
same insurer
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
- **Live (BETA) voice mode** uses the browser's in-built speech
|
| 961 |
recognition and is labelled unstable; **push-to-talk** is the reliable
|
| 962 |
path (warm-armed mic + pre-roll so the first word is never clipped, and
|
|
@@ -1026,20 +1210,39 @@ these:
|
|
| 1026 |
β β sum_insured.py
|
| 1027 |
β βββ session_state.py per-session profile (in-memory only, ADR-043)
|
| 1028 |
β βββ uploaded_docs.py user-uploaded PDF pipeline (ADR-044):
|
| 1029 |
-
β β - persist_upload() β heuristic baseline
|
| 1030 |
-
β β
|
|
|
|
|
|
|
|
|
|
| 1031 |
β β - detect_insurer_slug() β match PDF text
|
| 1032 |
-
β β against 21 known insurer
|
| 1033 |
-
β β
|
| 1034 |
-
β β
|
| 1035 |
-
β β
|
| 1036 |
-
β β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
β β - backfill_extractions() β startup hook
|
| 1038 |
β β re-runs LLM extraction on every
|
| 1039 |
-
β β
|
| 1040 |
-
β β
|
| 1041 |
-
β β - _UPLOAD_EXTRACTION_STATUS + endpoint
|
| 1042 |
β β GET /api/upload/extraction-status/{pid}
|
|
|
|
|
|
|
| 1043 |
β βββ voice_format.py TTS pre-processing (money/Indic normalisation)
|
| 1044 |
β βββ admin.py /api/admin/* (health, telemetry)
|
| 1045 |
β βββ providers/ thin clients: google_gemini, nvidia_nim, sarvam_*,
|
|
|
|
| 604 |
|
| 605 |
**Provenance rule.** Every policy fact shown to a user traces to a real clause in a real PDF. Where a document genuinely doesn't state something, it is recorded as a sourced-null (*"not stated in <file>.pdf"*) β never invented or back-filled.
|
| 606 |
|
| 607 |
+
### 2.8 Uploaded-PDF flow β 8 security gates β Gemini extraction β catalogued-grade card
|
| 608 |
|
| 609 |
```mermaid
|
| 610 |
flowchart TB
|
|
|
|
| 618 |
G7 --> G8["8 Hash dedupe + reject-cache"]
|
| 619 |
G8 -->|"pass"| QC["per-session QUARANTINE Chroma + global policies collection<br/>BGE-small embeddings Β· 24h idle TTL"]
|
| 620 |
QC --> HEUR["Heuristic baseline (synchronous, sub-second)<br/>regex/keyword over PDF text<br/>writes UPLOADED_DOCS_DIR/<pid>/record.json @ ~30-50% completeness"]
|
|
|
|
| 621 |
HEUR -.->|"detect_insurer_slug<br/>match against 21 known insurers"| INS["insurer_slug = manipalcigna / hdfc-ergo / ...<br/>(or 'user-upload' on no match β fail-closed)"]
|
| 622 |
+
HEUR --> ACK["HTTP 200 returns immediately<br/>frontend pushes ack 'Got it β reading X, ~30-60s'<br/>EVERY chat input GATED via extractionInFlight"]
|
| 623 |
+
ACK --> CACHE{"sha256(pdf_bytes)<br/>seen before?"}
|
| 624 |
+
CACHE -->|"hit"| COPY["copy prior rag/extracted/<other_pid>.json β this pid<br/>llm_used='hash-cache' Β· ~1s"]
|
| 625 |
+
CACHE -->|"miss"| LLM["Background extract_one_for_upload<br/>Gemini 2.5-flash Β· 3 retries (2/4/8s Β± 25% jitter)<br/>llm_used='gemini-2.5-flash#N'<br/>llm_response_chars logged for ops"]
|
| 626 |
+
LLM -->|"all fail"| NIM["NIM fallback chain<br/>llm_used='nim-fallback'"]
|
| 627 |
+
LLM -->|"success"| WRITE["write rag/extracted/<pid>.json"]
|
| 628 |
+
NIM -->|"success"| WRITE
|
| 629 |
+
NIM -->|"fail"| FLOOR["heuristic record.json wins<br/>status='failed' but card still renders at ~47% / grade C"]
|
| 630 |
+
WRITE --> MERGE["merge LLM scalars INTO record.json<br/>LLM value wins where non-empty<br/>heuristic stays where LLM silent"]
|
| 631 |
+
COPY --> MERGE
|
| 632 |
+
MERGE --> BUST["invalidate _MG_CACHE (marketplace grade cache)"]
|
| 633 |
+
BUST --> RESOLVE["_catalogue_scorecard(pid, None)<br/>SAME resolver /api/policies/{id}/scorecard uses"]
|
| 634 |
+
RESOLVE --> STATUS["_set_extraction_status(complete, comp, grade, llm_used, llm_response_chars)<br/>BY CONSTRUCTION equal to card endpoint"]
|
| 635 |
+
FLOOR --> STATUS
|
| 636 |
+
STATUS --> POLL["frontend GET /api/upload/extraction-status/{pid}<br/>every 3s, max 120s"]
|
| 637 |
+
POLL --> CARD["pushAssistant(card_ready, citations=[{pid}])<br/>then pushAssistant(choice_prompt)<br/>setActiveUploadPid(pid) Β· setExtractionInFlight(false)"]
|
| 638 |
+
CARD --> ENABLE["Send + textarea + PDF + voice all re-enabled<br/>view_context.active_policy_id={pid} on next chat turn<br/>β single_brain enters ACTIVE POLICY DIVE-IN mode (KI-330)"]
|
| 639 |
G1 & G2 & G3 & G4 & G5 & G6 & G7 & G8 -->|"fail"| REJ["clean rejection (reason surfaced)"]
|
| 640 |
```
|
| 641 |
|
| 642 |
+
**Summary.** The full pipeline an uploaded PDF traverses to become a
|
| 643 |
+
catalogued-grade card with the same data depth as the 148 pre-curated policies
|
| 644 |
+
β from HTTP request through Gemini extraction through inline chat card.
|
| 645 |
|
| 646 |
**How it flows:**
|
| 647 |
|
| 648 |
+
- **The 8 security gates, in order.** (1) **File mechanics** β `%PDF`
|
| 649 |
+
magic, 5 KBβ25 MB size band, well-formed `%%EOF`, no embedded
|
| 650 |
+
executables / JavaScript / launch actions. (2) **Content quality** β
|
| 651 |
+
β₯1500 extractable chars, β₯3 pages, at least one insurance-domain
|
| 652 |
+
keyword. (3) **Prompt-injection sweep** β "ignore previous
|
| 653 |
+
instructions", "reveal your system prompt", jailbreak patterns.
|
| 654 |
+
(4) **Per-session rate limit.** (5) **Per-IP rate limit** (catches
|
| 655 |
+
session-ID rotation). (6) **Encrypted/locked PDF** β rejected
|
| 656 |
+
cleanly. (7) **Page-count ceiling** (>200 pages β an abuse/bundle
|
| 657 |
+
vector). (8) **Hash dedupe + reject-cache** β identical re-uploads
|
| 658 |
+
short-circuit.
|
| 659 |
+
- **Beyond identical-file dedup.** A **UIN net-new check** also runs β
|
| 660 |
+
if the PDF's IRDAI UIN already belongs to a catalogued policy, the
|
| 661 |
+
caller is pointed at the existing marketplace card instead of indexing
|
| 662 |
+
a duplicate. **PDF-text fuzzy matching** also runs β if the upload
|
| 663 |
+
content identifies as a known catalogued product (matching insurer +
|
| 664 |
+
product-name patterns), the upload endpoint resolves to the existing
|
| 665 |
+
`<insurer-slug>__<product>` id and reuses the curated card, skipping
|
| 666 |
+
fresh extraction entirely (best UX for known products).
|
| 667 |
- **On pass.** Chunks land in a per-session **quarantine** Chroma
|
| 668 |
+
collection β session-isolated, 24 h idle TTL β AND in the shared
|
| 669 |
+
`policies` collection so the upload can become a marketplace card.
|
| 670 |
+
- **The locked chat sequence (ADR-044).** ack β gated wait β card β
|
| 671 |
+
choice. The frontend's `extractionInFlight` flag disables Send,
|
| 672 |
+
textarea, PDF button, and EVERY voice path (PTT / Sarvam / voice
|
| 673 |
+
auto-submit) for the entire wait window. The choice prompt NEVER
|
| 674 |
+
fires before the card-bearing message lands. Live-verified by
|
| 675 |
+
Playwright: ack at idx 155 β card/fail at idx 315 β choice at idx
|
| 676 |
+
500, strictly ordered. Inputs re-enable in the same render as the
|
| 677 |
+
choice prompt.
|
| 678 |
+
- **The two LLM fast paths.** *Hash-cache* β `sha256(pdf_bytes)` matches
|
| 679 |
+
a prior successful extraction β copy that `rag/extracted/<pid>.json`
|
| 680 |
+
to this pid, surface `llm_used="hash-cache"`, ~1 s. *Gemini path* β
|
| 681 |
+
3 retries with jittered exponential backoff (2/4/8 s Β± 25 %),
|
| 682 |
+
surfaces `llm_used="gemini-2.5-flash#N"` where N is the successful
|
| 683 |
+
attempt, plus `llm_response_chars` so the operator can see WHICH LLM
|
| 684 |
+
landed the extraction without HF Space stdout access.
|
| 685 |
+
- **The heuristic floor is a hard guarantee, now significantly fatter
|
| 686 |
+
(KI-332, 2026-05-27).** `build_record()` runs synchronously inside
|
| 687 |
+
the upload HTTP call (sub-second) and writes `record.json` BEFORE
|
| 688 |
+
the LLM ever fires. The pattern set was expanded from ~16 fields to
|
| 689 |
+
~28+ on the 2026-05-27 hardening pass: sum-insured ladder detection
|
| 690 |
+
(`βΉ3L / βΉ5L / βΉ10L` β list), policy_type, min entry age, child entry
|
| 691 |
+
days, lifelong-renewability flag, grace period, free-look period,
|
| 692 |
+
geographic coverage, ICU capping, deductible amount, NCB cap %,
|
| 693 |
+
organ donor / critical illness / preventive checkup / domiciliary /
|
| 694 |
+
newborn presence booleans, premium payment modes. Local synthetic
|
| 695 |
+
test hits 32 fields. Expected upload completeness on LLM-fail rises
|
| 696 |
+
from ~47.8 % to ~65β70 %. If Gemini fails all 3 retries AND the NIM
|
| 697 |
+
fallback fails, the card still renders at this richer floor β never
|
| 698 |
+
fabricated, never a generic "Retry" placeholder. Verified on a hard
|
| 699 |
+
Test Policy.pdf (8 MB) where Gemini 3/3 retries returned malformed
|
| 700 |
+
JSON: card still landed with the expanded heuristic data.
|
| 701 |
+
- **Multi-pass per-section extraction for big PDFs (KI-332, 2026-05-27).**
|
| 702 |
+
For uploads with β₯ 25 K chars of extracted text (e.g. dense
|
| 703 |
+
100+ page policy wordings, 8 MB PDFs), the single-pass Gemini call
|
| 704 |
+
reliably truncates JSON mid-emission β the HealthPolicy schema has
|
| 705 |
+
~40 fields and a complete output with verbatim quotes can exceed
|
| 706 |
+
Gemini 2.5-flash's reliable output budget. **Solution:** split the
|
| 707 |
+
schema into 7 logical sections (identity, eligibility, financial,
|
| 708 |
+
waiting periods, coverage, limits, network+claims) and run each as
|
| 709 |
+
its own smaller Gemini call IN PARALLEL via `asyncio.gather`. Each
|
| 710 |
+
call carries ~15 % of the schema β fits comfortably in budget.
|
| 711 |
+
Failure-isolated: 6/7 sections landing produces a partial extraction
|
| 712 |
+
strictly better than the heuristic floor. Same wall-clock cost as
|
| 713 |
+
single-pass (parallel). On total multi-pass failure, falls through
|
| 714 |
+
to the legacy single-pass + NIM chain (heuristic floor still wins).
|
| 715 |
+
Activation: `len(text) β₯ 25_000` triggers multi-pass; smaller PDFs
|
| 716 |
+
keep using single-pass (faster, cheaper, works fine).
|
| 717 |
+
- **Status endpoint == scorecard endpoint by construction.** When the
|
| 718 |
+
background extraction finalises, it calls the SAME
|
| 719 |
+
`_catalogue_scorecard(pid, None)` resolver that `/api/policies/{id}/
|
| 720 |
+
scorecard` uses. The `completeness_pct` and `overall_grade` on
|
| 721 |
+
`GET /api/upload/extraction-status/{pid}` are therefore byte-
|
| 722 |
+
identical to what the inline card renders. Same applies to the
|
| 723 |
+
hash-cache short-circuit (which had to be fixed separately β
|
| 724 |
+
earlier draft of the cache branch called `build_scorecard(...)`
|
| 725 |
+
without `insurer_reviews` AND read `.overall_grade` instead of
|
| 726 |
+
`.grade`, silently reporting status=17.4 % / grade None while the
|
| 727 |
+
card showed 47.8 % / C). 2026-05-27 multi-PDF audit (commit
|
| 728 |
+
`58e3c82`) confirmed parity across manipalcigna, hdfc-ergo,
|
| 729 |
+
care-health, icici-lombard, star-health, Test Policy.pdf.
|
| 730 |
+
- **Post-card dive-in mode (KI-330).** After the card lands the
|
| 731 |
+
frontend sets `activeUploadPid` and plumbs it into every chat
|
| 732 |
+
turn's `view_context.active_policy_id`. `single_brain.handle_turn`
|
| 733 |
+
reads that and prepends an ACTIVE POLICY DIVE-IN block to the
|
| 734 |
+
system instruction, forcing the brain to answer policy-specific
|
| 735 |
+
questions via `retrieve_policies` + `get_policy_facts` on that
|
| 736 |
+
pid instead of pivoting to "let me pull your recommendations".
|
| 737 |
+
Verified 9/10 on the post-fix audit (up from 0/10 pre-fix).
|
| 738 |
- **On fail.** A clean rejection naming the gate; the file is deleted;
|
| 739 |
nothing is embedded.
|
| 740 |
|
| 741 |
+
**Operator endpoints (admin-only):**
|
| 742 |
+
|
| 743 |
+
- `POST /api/admin/upload/reextract?force=<bool>` β re-runs
|
| 744 |
+
`extract_one_for_upload` for every persisted upload that lacks a
|
| 745 |
+
`rag/extracted/<pid>.json`, or for ALL persisted uploads with
|
| 746 |
+
`force=true`. Wired to a startup hook so every container boot
|
| 747 |
+
upgrades legacy uploads automatically.
|
| 748 |
+
- `GET /api/upload/extraction-status/{policy_id}` β the live state of
|
| 749 |
+
the in-memory `_UPLOAD_EXTRACTION_STATUS` dict. Fields: `status`
|
| 750 |
+
(`pending | running | complete | failed | unknown`), `llm_used`
|
| 751 |
+
(`gemini-2.5-flash#N | nim-fallback | hash-cache`),
|
| 752 |
+
`llm_response_chars`, `completeness_pct`, `overall_grade`,
|
| 753 |
+
`started_at`, `completed_at`, `error`.
|
| 754 |
+
|
| 755 |
### 2.9 Deployment
|
| 756 |
|
| 757 |
```mermaid
|
|
|
|
| 790 |
|
| 791 |
## 3. Key functions in plain language
|
| 792 |
|
| 793 |
+
**Summary.** Seven internal jobs make the bot work. Each one gets a sequence diagram showing what calls what, a β€50-word summary, and a step-by-step explanation. An eighth subsection makes explicit *what is stored vs what is live-only*.
|
| 794 |
|
| 795 |
### 3.1 Profile construction
|
| 796 |
|
|
|
|
| 958 |
(There is no cross-session recall β see Β§2.6 and ADR-043 for why that
|
| 959 |
was removed.)
|
| 960 |
|
| 961 |
+
### 3.7 Uploaded-PDF LLM extraction (the Β§2.8 pipeline in code terms)
|
| 962 |
+
|
| 963 |
+
```mermaid
|
| 964 |
+
sequenceDiagram
|
| 965 |
+
autonumber
|
| 966 |
+
participant FE as Frontend (page.tsx)
|
| 967 |
+
participant API as /api/upload-policy
|
| 968 |
+
participant SEC as security.py (8 gates)
|
| 969 |
+
participant UD as uploaded_docs.py
|
| 970 |
+
participant H as heuristic build_record()
|
| 971 |
+
participant CK as hash cache lookup
|
| 972 |
+
participant G as Gemini 2.5-flash (3 retries)
|
| 973 |
+
participant N as NIM fallback chain
|
| 974 |
+
participant SC as scorecard.py + main._catalogue_scorecard
|
| 975 |
+
participant ST as _UPLOAD_EXTRACTION_STATUS dict
|
| 976 |
+
participant FE2 as Frontend poller
|
| 977 |
+
FE->>API: POST multipart (PDF + session_id)
|
| 978 |
+
API->>SEC: run 8 gates
|
| 979 |
+
SEC-->>API: pass | reject
|
| 980 |
+
API->>UD: persist_upload() β UPLOADED_DOCS_DIR/<pid>/{source.pdf, meta.json}
|
| 981 |
+
UD->>H: build_record() β regex/keyword over text
|
| 982 |
+
H-->>UD: record.json @ ~30-50% completeness
|
| 983 |
+
UD-->>FE: HTTP 200 {policy_id, ...}
|
| 984 |
+
FE->>FE: setExtractionInFlight(true) Β· gate ALL inputs Β· pushAssistant(ack)
|
| 985 |
+
UD->>UD: asyncio.create_task(extract_one_for_upload)
|
| 986 |
+
Note over UD: _set_extraction_status(status='running')
|
| 987 |
+
UD->>CK: _find_cached_extraction(sha256(pdf_bytes))
|
| 988 |
+
alt cache hit
|
| 989 |
+
CK-->>UD: copy prior rag/extracted/<other_pid>.json
|
| 990 |
+
UD->>UD: llm_used='hash-cache'
|
| 991 |
+
else cache miss
|
| 992 |
+
UD->>G: chat(prompt, schema) β attempt 1
|
| 993 |
+
alt success
|
| 994 |
+
G-->>UD: HealthPolicy JSON Β· llm_used='gemini-2.5-flash#1'
|
| 995 |
+
else timeout / malformed JSON
|
| 996 |
+
UD->>G: retry attempt 2 (2s backoff Β± 25%)
|
| 997 |
+
G-->>UD: HealthPolicy OR fail
|
| 998 |
+
UD->>G: retry attempt 3 (4s backoff Β± 25%)
|
| 999 |
+
G-->>UD: HealthPolicy OR fail
|
| 1000 |
+
UD->>N: NIM fallback (single attempt)
|
| 1001 |
+
N-->>UD: HealthPolicy OR all-fail
|
| 1002 |
+
end
|
| 1003 |
+
UD->>UD: write rag/extracted/<pid>.json
|
| 1004 |
+
UD->>UD: merge LLM scalars INTO record.json
|
| 1005 |
+
end
|
| 1006 |
+
UD->>SC: _catalogue_scorecard(pid, None) β same as /api/policies/.../scorecard
|
| 1007 |
+
SC-->>UD: Scorecard{grade, data_completeness_pct, sub_scores}
|
| 1008 |
+
UD->>ST: _set_extraction_status(complete, comp, grade, llm_used, llm_response_chars)
|
| 1009 |
+
loop every 3s up to 120s
|
| 1010 |
+
FE2->>UD: GET /api/upload/extraction-status/{pid}
|
| 1011 |
+
UD-->>FE2: status snapshot
|
| 1012 |
+
end
|
| 1013 |
+
FE2->>FE2: pushAssistant(card_ready, citations=[{pid}])
|
| 1014 |
+
FE2->>FE2: setActiveUploadPid(pid)
|
| 1015 |
+
FE2->>FE2: pushAssistant(choice_prompt) Β· setExtractionInFlight(false)
|
| 1016 |
+
```
|
| 1017 |
+
|
| 1018 |
+
**Summary.** When a user uploads a PDF the backend writes a heuristic-baseline record first (sub-second), HTTP returns, and a background asyncio task either copies a prior extraction (hash cache hit) or runs Gemini 2.5-flash with 3 jittered retries β NIM fallback β heuristic floor. The status endpoint reports the SAME `completeness_pct` + `overall_grade` the card endpoint serves, by construction.
|
| 1019 |
+
|
| 1020 |
+
**How it flows:**
|
| 1021 |
+
|
| 1022 |
+
- **HTTP returns before extraction starts.** `extract_one_for_upload` is fired with `asyncio.create_task` so the user sees the card-ready ack inside one second, not after 30β60 s.
|
| 1023 |
+
- **Provenance, always.** Every `_set_extraction_status` call carries `llm_used` (`gemini-2.5-flash#1 | #2 | #3 | nim-fallback | hash-cache`) and `llm_response_chars`. The operator can see WHICH LLM landed the extraction without HF Space stdout access β verified live on 2026-05-27.
|
| 1024 |
+
- **Hash cache short-circuit.** `_find_cached_extraction(sha256(pdf_bytes))` looks for a prior successful extraction with the same content. On hit, the prior `rag/extracted/<other_pid>.json` is copied to this pid in ~1 s.
|
| 1025 |
+
- **Retries are jittered exponential.** 2 s / 4 s / 8 s backoffs each multiplied by `random.uniform(0.75, 1.25)` so repeated transient blips on a single Gemini instance don't synchronise.
|
| 1026 |
+
- **Merge model.** LLM output is merged INTO the heuristic record (LLM wins per-field where non-empty, heuristic stays where LLM silent) β the same "extracted + curated overlay" model the catalogued 148 use via `40-data/policy_facts/`.
|
| 1027 |
+
- **Status == card by construction.** The status endpoint calls `_catalogue_scorecard(pid, None)` β the SAME resolver `/api/policies/{id}/scorecard` uses. If they differ, that's a bug; today they match across 5 verified uploads.
|
| 1028 |
+
|
| 1029 |
+
### 3.8 What is stored vs what is live-only
|
| 1030 |
|
| 1031 |
| What | Where | Why |
|
| 1032 |
|---|---|---|
|
|
|
|
| 1103 |
operator/abuse prune endpoint exists (`POST /api/admin/uploaded-docs/
|
| 1104 |
prune`, password-gated) to remove a persisted upload by id or prefix.
|
| 1105 |
- **Uploaded-PDF field extraction is LLM-assisted, with a deterministic
|
| 1106 |
+
heuristic floor (ADR-044, 2026-05-27 hardening bundle).** Every upload
|
| 1107 |
+
runs through two passes with a hash-cache fast path:
|
| 1108 |
+
- **Heuristic baseline** β regex + keyword extraction over the PDF
|
| 1109 |
+
text, synchronous inside the upload HTTP call (sub-second), populates
|
| 1110 |
common fields like waiting periods and room-rent rule. Yields
|
| 1111 |
+
~30β50 % `data_completeness`.
|
| 1112 |
+
- **Gemini extraction (3 jittered retries)** β fires as a background
|
| 1113 |
+
asyncio task after the upload returns. Same Gemini 2.5-flash + same
|
| 1114 |
+
`EXTRACT_SYSTEM` prompt + same `HealthPolicy` Pydantic schema the
|
| 1115 |
+
catalogued 148 use offline. Backoffs 2 / 4 / 8 s Β± 25 % jitter. On
|
| 1116 |
+
success, writes `rag/extracted/<policy_id>.json` and merges INTO
|
| 1117 |
the persisted `record.json` (LLM values override where present,
|
| 1118 |
+
heuristic stays where the LLM was silent). ~10β60 s total.
|
| 1119 |
+
- **NIM fallback** β single attempt if all three Gemini retries fail.
|
| 1120 |
+
- **Heuristic floor** β if NIM also fails, the card still renders at
|
| 1121 |
+
the heuristic baseline (~47.8 %, grade C). Verified live on Test
|
| 1122 |
+
Policy.pdf (8 MB) where Gemini 3/3 retries returned malformed JSON.
|
| 1123 |
+
- **Hash-cache short-circuit** β if `sha256(pdf_bytes)` matches a
|
| 1124 |
+
prior successful extraction, that file is copied (~1 s) with
|
| 1125 |
+
`llm_used="hash-cache"` surfaced for ops.
|
| 1126 |
The frontend polls `GET /api/upload/extraction-status/<policy_id>`
|
| 1127 |
during the wait and renders the inline scorecard card ONLY after the
|
| 1128 |
+
LLM pass completes / fails / hits its 120 s timeout. The card is
|
| 1129 |
catalogued-grade β same `PolicyScorecardWidget`, same six sub-scores,
|
| 1130 |
+
same insurer-reputation data (`detect_insurer_slug` matches the PDF's
|
| 1131 |
+
legal name against the 21 known insurer slugs we have reviews data
|
| 1132 |
+
for and flips `insurer_slug` off the generic `user-upload` on a hit).
|
| 1133 |
+
**Status β card parity by construction:** the status endpoint and the
|
| 1134 |
+
card endpoint both call `_catalogue_scorecard(pid, None)`, so
|
| 1135 |
+
`completeness_pct` + `overall_grade` are byte-identical. **Operator
|
| 1136 |
+
provenance**: every status response carries `llm_used` (`gemini-2.5-
|
| 1137 |
+
flash#N | nim-fallback | hash-cache`) and `llm_response_chars` so the
|
| 1138 |
+
question "did Gemini actually run?" is answerable without HF Space
|
| 1139 |
+
stdout access. **Post-card dive-in mode (KI-330)**: the just-uploaded
|
| 1140 |
+
pid becomes `view_context.active_policy_id` on the next chat turn,
|
| 1141 |
+
so `single_brain` answers policy-specific questions via
|
| 1142 |
+
`retrieve_policies` + `get_policy_facts` instead of pivoting to
|
| 1143 |
+
recommendations.
|
| 1144 |
- **Live (BETA) voice mode** uses the browser's in-built speech
|
| 1145 |
recognition and is labelled unstable; **push-to-talk** is the reliable
|
| 1146 |
path (warm-armed mic + pre-roll so the first word is never clipped, and
|
|
|
|
| 1210 |
β β sum_insured.py
|
| 1211 |
β βββ session_state.py per-session profile (in-memory only, ADR-043)
|
| 1212 |
β βββ uploaded_docs.py user-uploaded PDF pipeline (ADR-044):
|
| 1213 |
+
β β - persist_upload() β heuristic baseline
|
| 1214 |
+
β β (sub-second regex/keyword) + sha256 of
|
| 1215 |
+
β β PDF bytes β record.json + meta.json
|
| 1216 |
+
β β - build_record() β heuristic floor; ~30β50%
|
| 1217 |
+
β β completeness; guaranteed before LLM fires
|
| 1218 |
β β - detect_insurer_slug() β match PDF text
|
| 1219 |
+
β β against 21 known insurer patterns; flips
|
| 1220 |
+
β β insurer_slug off 'user-upload' on hit
|
| 1221 |
+
β β - extract_one_for_upload() β background
|
| 1222 |
+
β β asyncio task: hash-cache β Gemini
|
| 1223 |
+
β β 2.5-flash (3 jittered retries 2/4/8s Β±25%)
|
| 1224 |
+
β β β NIM fallback β heuristic floor. Writes
|
| 1225 |
+
β β rag/extracted/<pid>.json, merges LLM
|
| 1226 |
+
β β scalars INTO record.json (LLM wins where
|
| 1227 |
+
β β non-empty, heuristic stays where silent)
|
| 1228 |
+
β β - _find_cached_extraction() β sha256
|
| 1229 |
+
β β lookup across UPLOADED_DOCS_DIR/*/meta.json
|
| 1230 |
+
β β for prior successful extractions
|
| 1231 |
+
β β - _set_extraction_status() β finalises
|
| 1232 |
+
β β status using main._catalogue_scorecard(pid)
|
| 1233 |
+
β β so completeness_pct + overall_grade match
|
| 1234 |
+
β β the card endpoint BY CONSTRUCTION
|
| 1235 |
+
β β - Provenance fields: llm_used
|
| 1236 |
+
β β (gemini-2.5-flash#N | nim-fallback |
|
| 1237 |
+
β β hash-cache) + llm_response_chars
|
| 1238 |
β β - backfill_extractions() β startup hook
|
| 1239 |
β β re-runs LLM extraction on every
|
| 1240 |
+
β β UPLOADED_DOCS_DIR/<pid>/ missing
|
| 1241 |
+
β β rag/extracted/<pid>.json
|
| 1242 |
+
β β - _UPLOAD_EXTRACTION_STATUS dict + endpoint
|
| 1243 |
β β GET /api/upload/extraction-status/{pid}
|
| 1244 |
+
β β - Admin endpoint
|
| 1245 |
+
β β POST /api/admin/upload/reextract?force=...
|
| 1246 |
β βββ voice_format.py TTS pre-processing (money/Indic normalisation)
|
| 1247 |
β βββ admin.py /api/admin/* (health, telemetry)
|
| 1248 |
β βββ providers/ thin clients: google_gemini, nvidia_nim, sarvam_*,
|
|
@@ -1557,9 +1557,61 @@ async def extract_one_for_upload(
|
|
| 1557 |
"[upload-extract] no policy extracted for %s after retries; "
|
| 1558 |
"card stays on heuristic record", policy_id,
|
| 1559 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
await _set_extraction_status(
|
| 1561 |
policy_id, status="failed",
|
| 1562 |
completed_at=_now(),
|
|
|
|
|
|
|
| 1563 |
error="LLM returned no valid HealthPolicy after primary + fallback retries",
|
| 1564 |
)
|
| 1565 |
return False
|
|
|
|
| 1557 |
"[upload-extract] no policy extracted for %s after retries; "
|
| 1558 |
"card stays on heuristic record", policy_id,
|
| 1559 |
)
|
| 1560 |
+
# KI-333 (2026-05-27) β even on total LLM failure, the heuristic
|
| 1561 |
+
# record.json already produced a card (now ~65-70% post KI-332).
|
| 1562 |
+
# Surface the SAME completeness + grade the chat card will show,
|
| 1563 |
+
# so the operator-visible status matches user-visible card.
|
| 1564 |
+
# Previously the fail path left comp=None/grade=None while the
|
| 1565 |
+
# card showed C/65% β confusing parity gap.
|
| 1566 |
+
_final_comp = None
|
| 1567 |
+
_final_grade = None
|
| 1568 |
+
try:
|
| 1569 |
+
# Bust the marketplace grade cache so _catalogue_scorecard
|
| 1570 |
+
# rebuilds with the new heuristic record.
|
| 1571 |
+
import backend.main as _bm_f
|
| 1572 |
+
with _bm_f._MG_LOCK:
|
| 1573 |
+
_bm_f._MG_CACHE["sig"] = None
|
| 1574 |
+
_bm_f._MG_CACHE["index"] = None
|
| 1575 |
+
_sc_f = _bm_f._catalogue_scorecard(policy_id, None)
|
| 1576 |
+
if _sc_f is None:
|
| 1577 |
+
# Fallback path β bare scorecard on the heuristic record.json
|
| 1578 |
+
# if catalogue indices haven't rebuilt yet.
|
| 1579 |
+
from backend.scorecard import build_scorecard as _bs_f
|
| 1580 |
+
rec_path = _doc_dir(policy_id) / "record.json"
|
| 1581 |
+
if rec_path.exists():
|
| 1582 |
+
rec = json.loads(rec_path.read_text())
|
| 1583 |
+
# Flatten cell-shape {value, ...} dicts to scalars for build_scorecard.
|
| 1584 |
+
flat = {}
|
| 1585 |
+
for k, v in rec.items():
|
| 1586 |
+
if isinstance(v, dict) and "value" in v:
|
| 1587 |
+
flat[k] = v["value"]
|
| 1588 |
+
else:
|
| 1589 |
+
flat[k] = v
|
| 1590 |
+
flat.setdefault("policy_id", policy_id)
|
| 1591 |
+
flat.setdefault("insurer_slug", insurer_slug)
|
| 1592 |
+
flat.setdefault("insurer_name", insurer_name)
|
| 1593 |
+
flat.setdefault("policy_name", policy_name)
|
| 1594 |
+
_ir_f = None
|
| 1595 |
+
if insurer_slug:
|
| 1596 |
+
from backend.config import settings as _settings_f
|
| 1597 |
+
_rp_f = _settings_f.DATA_DIR / "reviews" / f"{insurer_slug}.json"
|
| 1598 |
+
if _rp_f.exists():
|
| 1599 |
+
try: _ir_f = json.loads(_rp_f.read_text())
|
| 1600 |
+
except Exception: _ir_f = None
|
| 1601 |
+
_sc_f = _bs_f(flat, insurer_reviews=_ir_f, profile=None)
|
| 1602 |
+
if _sc_f is not None:
|
| 1603 |
+
_final_comp = float(_sc_f.data_completeness_pct)
|
| 1604 |
+
_final_grade = _sc_f.grade # NOT overall_grade
|
| 1605 |
+
except Exception as _sc_err_f: # noqa: BLE001
|
| 1606 |
+
_log.warning(
|
| 1607 |
+
"[upload-extract] fail-path heuristic-scorecard resolve "
|
| 1608 |
+
"failed for %s: %s", policy_id, _sc_err_f,
|
| 1609 |
+
)
|
| 1610 |
await _set_extraction_status(
|
| 1611 |
policy_id, status="failed",
|
| 1612 |
completed_at=_now(),
|
| 1613 |
+
completeness_pct=_final_comp,
|
| 1614 |
+
overall_grade=_final_grade,
|
| 1615 |
error="LLM returned no valid HealthPolicy after primary + fallback retries",
|
| 1616 |
)
|
| 1617 |
return False
|