rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
2ec48b7
Β·
1 Parent(s): d92f07a

fix(upload-extract): status parity on LLM-fail path + docs updated

Browse files

KI-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>

Files changed (3) hide show
  1. CLAUDE.md +21 -0
  2. README.md +256 -53
  3. backend/uploaded_docs.py +52 -0
CLAUDE.md CHANGED
@@ -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.
README.md CHANGED
@@ -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 &lt;file&gt;.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/&lt;pid&gt;/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
- ACK --> LLM["Background asyncio task: extract_one_for_upload<br/>Gemini 2.5-flash (3 retries, exp backoff) β†’ NIM fallback<br/>writes rag/extracted/&lt;pid&gt;.json"]
624
- LLM --> MERGE["Merge LLM output INTO heuristic record.json<br/>LLM wins per-field where non-empty, heuristic stays where LLM silent"]
625
- MERGE --> CARD["Card-ready: card renders inline in chat<br/>same shape as the 148 catalogued (grade, 6 sub-scores, signals, reviews)"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  G1 & G2 & G3 & G4 & G5 & G6 & G7 & G8 -->|"fail"| REJ["clean rejection (reason surfaced)"]
627
  ```
628
 
629
- **Summary.** Every check a user-uploaded PDF passes
630
- before its content is allowed to touch the vector store, and where rejected
631
- files go.
632
 
633
  **How it flows:**
634
 
635
- - **The 8 gates, in order.** (1) **File mechanics** β€” `%PDF` magic, 5 KB–25 MB
636
- size band, well-formed `%%EOF`, no embedded executables / JavaScript /
637
- launch actions. (2) **Content quality** β€” β‰₯1500 extractable chars,
638
- β‰₯3 pages, at least one insurance-domain keyword. (3) **Prompt-injection
639
- sweep** β€” "ignore previous instructions", "reveal your system prompt",
640
- jailbreak patterns. (4) **Per-session rate limit.** (5) **Per-IP rate
641
- limit** (catches session-ID rotation). (6) **Encrypted/locked PDF** β€”
642
- rejected cleanly. (7) **Page-count ceiling** (>200 pages β€” an
643
- abuse/bundle vector). (8) **Hash dedupe + reject-cache** β€” identical
644
- re-uploads short-circuit.
645
- - **Beyond identical-file dedup.** A **UIN net-new check** also runs β€” if
646
- the PDF's IRDAI UIN already belongs to a catalogued policy, the caller
647
- is pointed at the existing marketplace card instead of indexing a
648
- duplicate.
 
 
 
 
 
649
  - **On pass.** Chunks land in a per-session **quarantine** Chroma
650
- collection β€” session-isolated, 24 h idle TTL β€” *never* the shared
651
- `policies` corpus.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.** Six 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. A seventh subsection makes explicit *what is stored vs what is live-only*.
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 What is stored vs what is live-only
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 runs through two
939
- passes:
940
- - **Heuristic baseline** β€” regex + keyword extraction over the PDF text,
941
- runs synchronously inside the upload HTTP call (sub-second), populates
942
  common fields like waiting periods and room-rent rule. Yields
943
- ~30–50 % data_completeness.
944
- - **LLM-assisted extraction** β€” fires as a background asyncio task
945
- after the upload returns. Same `get_brain_llm()` chain the catalogued
946
- 148 use offline (Gemini 2.5-flash primary, NVIDIA NIM fallback);
947
- same `EXTRACT_SYSTEM` prompt; same `HealthPolicy` Pydantic schema;
948
- output written to `rag/extracted/<policy_id>.json` and merged INTO
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 either completes or hits its 120 s timeout. The card is
954
  catalogued-grade β€” same `PolicyScorecardWidget`, same six sub-scores,
955
- same insurer reputation data (because `detect_insurer_slug` matches
956
- the PDF's legal name against the 21 known insurer slugs we have
957
- reviews data for and flips `insurer_slug` off the generic `user-upload`
958
- on a hit). On any LLM-pass failure the heuristic floor still produces a
959
- real grade, never a fabricated one or a data-starved sentinel.
 
 
 
 
 
 
 
 
 
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
- β”‚ β”‚ sha256 of PDF bytes
 
 
 
1031
  β”‚ β”‚ - detect_insurer_slug() β€” match PDF text
1032
- β”‚ β”‚ against 21 known insurer name patterns
1033
- β”‚ β”‚ - extract_one_for_upload() β€” Gemini-primary
1034
- β”‚ β”‚ (3 retries, exp backoff) β†’ NIM fallback,
1035
- β”‚ β”‚ content-hash cache, raw-response logging,
1036
- β”‚ β”‚ LLM-into-heuristic merge
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1037
  β”‚ β”‚ - backfill_extractions() β€” startup hook
1038
  β”‚ β”‚ re-runs LLM extraction on every
1039
- β”‚ β”‚ existing UPLOADED_DOCS_DIR/<pid>/ that
1040
- β”‚ β”‚ doesn't yet have rag/extracted/<pid>.json
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 &lt;file&gt;.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/&lt;pid&gt;/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/&lt;other_pid&gt;.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/&lt;pid&gt;.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/&lcub;id&rcub;/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/&lcub;pid&rcub;<br/>every 3s, max 120s"]
637
+ POLL --> CARD["pushAssistant(card_ready, citations=[&lcub;pid&rcub;])<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=&lcub;pid&rcub; 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/&lt;pid&gt;/{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/&lt;other_pid&gt;.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/&lt;pid&gt;.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_*,
backend/uploaded_docs.py CHANGED
@@ -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