Spaces:
Sleeping
Sleeping
chore(cleanup): purge stale narrative/tombstones/dead code β codebase reads as the current standard
23b8fad | # Known Issues + Quality Sprint Log | |
| > β οΈ **Historical defect log β not the present-state map.** Entries below | |
| > are dated incidents; many reference subsystems (`orchestrator`, | |
| > `faithfulness.py` judge, `fact_find_brain` / `sales_brain`, | |
| > `fact_find_normalizer`, the 3-tier Gemini/NIM/OpenRouter chain, | |
| > sticky-primary election) that were **subsequently removed** by the | |
| > single-LLM-with-tools rewrite (one Gemini 2.5-flash call per turn with | |
| > `save_profile_field` / `retrieve_policies` / `mark_recommendation`, | |
| > structured+vector retrieval, small `nim_fallback`). The log is retained | |
| > as the quality trajectory of record; KI/ADR cross-references stay valid as | |
| > history. Present-state authority: [`README.md`](../../README.md) Β§4. | |
| Living document. Every defect we find β whether via code review, eval audit, | |
| or production observation β lands here with severity, root cause, and the | |
| plan to fix it. Closed issues stay in the log with a `**FIXED in <sha>**` | |
| annotation so reviewers can audit the project's quality trajectory. | |
| ## Severity scale | |
| - **P0 / Critical** β User-visible incorrect behavior, BFSI compliance risk, | |
| or data loss. Block any release. | |
| - **P1 / High** β Silent degradation; user gets a worse experience but it | |
| doesn't visibly break. Ship a fix in the next sprint. | |
| - **P2 / Medium** β Edge case; cosmetic; non-critical path. Backlog. | |
| - **P3 / Low** β Code smell or minor inefficiency. | |
| --- | |
| ## Open issues | |
| ### KI-001 β Gate 4 (LLM judge) fails OPEN on judge error | |
| **Severity:** P0 | |
| **Source:** `backend/faithfulness.py:253-255` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| When the judge LLM call fails (network, rate limit, JSON parse error, NIM | |
| 408/503), Gate 4 currently returns `supported=True, ["judge_error_failopen"]`. | |
| The reply ships through without grading. | |
| In BFSI this is the *unsafe* default β an unsupported claim that should | |
| have been blocked by Gate 4 leaks through to the user because the judge | |
| hiccupped. The audit log preserves `judge_error_failopen` but the user | |
| never sees the gate failed. | |
| **Fix plan:** Add `FAITHFULNESS_FAIL_CLOSED` env var (default `true` in | |
| production, `false` in dev / smoke tests). When fail-closed, return | |
| `supported=False, ["judge_unavailable_failclosed"]` so the orchestrator's | |
| cross-check retry path or final refusal fires instead. | |
| --- | |
| ### KI-002 β Session-state disk flush silently swallows errors | |
| **Severity:** P1 | |
| **Source:** `backend/session_state.py:67-68` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| `SessionState._flush()` writes the profile JSON to disk via a tmp+replace | |
| pattern. On disk-full, EACCES, or JSON encode error, the bare `except | |
| Exception: pass` drops the failure with zero observability. The user's | |
| profile is silently lost on the next Space restart. They redo fact-find. | |
| **Fix plan:** Add `logging.warning("session flush failed for %s: %s", | |
| session_id, e)` β keep the no-crash behaviour but surface failure rate to | |
| the HF Space logs so we can detect when it starts happening. | |
| --- | |
| ### KI-003 β Session-state disk load silently returns None on schema drift | |
| **Severity:** P1 | |
| **Source:** `backend/session_state.py:114-115` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| When the on-disk session JSON has a schema mismatch (Profile dataclass | |
| field renamed, type changed), `_load_from_disk` catches the exception and | |
| returns `None`. The user gets a fresh blank profile and has to redo | |
| fact-find. No log, no metric. | |
| **Fix plan:** Log the exception with `session_id` so we know schema drift | |
| is happening. Also: tighten the existing valid-field filter (line 105-106) | |
| to additionally type-check values so a stringified int doesn't pass through. | |
| --- | |
| ### KI-004 β Indic translator failure β original Indic text flows into English brain silently | |
| **Severity:** P1 | |
| **Source:** `backend/orchestrator.py:148-149` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| When Sarvam-M translator fails on an Indic query, the orchestrator falls | |
| through with the original Indic text, sending it to the English-trained | |
| DeepSeek/NIM brain. The brain handles it imperfectly. The user gets a | |
| degraded reply with no indication that the translator failed. | |
| **Fix plan:** Log the failure (`logging.warning("Indic translator failed | |
| for session %s, lang=%s: %s", session_id, language, e)`). Optionally | |
| return a soft refusal in Indic ("Sorry, I'm having trouble with the | |
| translation right now β could you ask in English?") instead of silently | |
| mis-routing. | |
| --- | |
| ### KI-005 β Profile-RAG chunk upsert failure silently swallowed | |
| **Severity:** P1 | |
| **Source:** `backend/orchestrator.py:285-287` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| After the conversational profile-update extractor lands a new field, the | |
| orchestrator re-upserts the profile chunk into Chroma so retrieval reflects | |
| the latest state. If that Chroma write fails (lock, disk, schema), the | |
| exception is swallowed. Subsequent turns retrieve the *stale* profile. | |
| The user thinks the bot incorporates their new fact ("I just got | |
| diabetes"); it actually doesn't. | |
| **Fix plan:** Log the failure + record in `TurnResult.profile_updates` | |
| that the upsert hit a problem so the frontend can show a small warning | |
| or retry. | |
| --- | |
| ### KI-006 β Conversational profile extraction failure silently swallowed | |
| **Severity:** P2 | |
| **Source:** `backend/orchestrator.py:288-289` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| If `extract_profile_updates()` itself raises (rare; NIM unavailable), the | |
| mid-chat profile-update feature is silently disabled for that turn. User | |
| won't know why their "I just turned 40" didn't take. | |
| **Fix plan:** Log + add to `TurnResult.profile_updates_meta` so the | |
| frontend could surface "we missed an update β try mentioning it again". | |
| --- | |
| ### KI-007 β Indic cascade total failure β English reply with zero log | |
| **Severity:** P2 | |
| **Source:** `backend/orchestrator.py:425-426` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| When all three Indic drift gates fail (or `translate_to_indic` itself | |
| raises), we fall back to English. The user asked in Hinglish but gets | |
| English. No log of which gate failed. | |
| **Fix plan:** Add structured logging of which gate caused the fall-back | |
| (`anchor` / `llmjudge` / `cosine`) so we can tune thresholds against real | |
| production drift data. | |
| --- | |
| ### KI-008 β TTS preprocess can swallow blocking content | |
| **Severity:** P3 | |
| **Source:** `backend/main.py:258-272` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| `tts_preprocess()` is called inside a `try: β¦ except Exception as e: log | |
| + return text only` block. If the preprocessor strips the acronym expansion | |
| incorrectly, the TTS voice would butcher PED / SI / IRDAI etc. No fall-back | |
| to a hard-coded acronym dict β we just log + skip. | |
| **Fix plan:** Add a regression test for `tts_preprocess()` covering the 20 | |
| most common BFSI acronyms. | |
| --- | |
| ### KI-009 β Live-mode VAD: no calibration on entry | |
| **Severity:** P2 | |
| **Source:** `frontend/src/lib/useLiveConversation.ts` β `rmsThreshold: 28` | |
| **Discovered:** Code-review sweep 2026-05-14 | |
| The RMS threshold is a constant. Quiet speakers, far-mic users, and noisy | |
| backgrounds all hit one fixed bar. Some users won't trigger VAD; others | |
| will trigger it on background noise. | |
| **Fix plan:** Calibrate the threshold by sampling 1 second of ambient | |
| audio on Live-mode entry. Set threshold to `mean(ambient) + 2 * sigma`. | |
| --- | |
| ### KI-010 β Audit runner: output unbuffered required `PYTHONUNBUFFERED=1` | |
| **Severity:** P3 | |
| **Source:** `tools/audit/run_audit.py` | |
| **Discovered:** Self-test 2026-05-14 | |
| Initial run had zero progress prints in the captured log because Python's | |
| default stdout buffering held lines until process exit. Fixed in the same | |
| session: all `print()` calls now use `flush=True` and we add per-5-turn | |
| progress prints. Document for future tooling. | |
| **Status:** FIXED in commit during audit framework rollout. | |
| --- | |
| ## Closed issues (this session) | |
| - **Issue 1: Full-duplex voice barge-in** β shipped in `d31e132`. | |
| - **Issue 2 + 4: Garbage profile recording + sidebar sync** β shipped in `9a1b321`. | |
| - **Issue 3: Sarvam STT 400 (webmβwav)** β shipped in `a777198`. | |
| - **Bug A: Cold-start "Load failed"** β shipped in `f81328f`. | |
| - **Bug B: Citation chip insurer prefix** β shipped in `f81328f`. | |
| - **Bug C: "Try again" intent handling** β shipped in `f81328f`. | |
| --- | |
| ## Quality-sprint cadence | |
| Every batch of fixes ships as one commit referencing the KI numbers it | |
| closes. The audit run (`80-audit/<run_id>/report.md`) is the | |
| empirical signal for whether a fix is actually working in production. | |
| The standing ratio target: **for every 1 user-facing bug a reviewer | |
| catches, we should close 5 internal issues from this log before the next | |
| review.** | |
| ### KI-011 β Fact-find re-ask infinite loop under load β **FIXED in `171f2a4`** | |
| **Severity:** P0 | |
| **Source:** `backend/orchestrator.py` fact-find branch + `backend/fact_find_normalizer.py` LLM-only path | |
| **Discovered:** First persona of the 100-persona audit (P002, verbose style) | |
| When NIM rate-limited the Llama-3.3-70B normalizer under audit | |
| concurrency, the LLM call raised, the orchestrator marked the answer | |
| ambiguous, kept `awaiting_question_id` set, and re-asked the same | |
| question on the next turn. User moved on with answers to OTHER | |
| questions. Normalizer rejected them. Bot re-asked again. Infinite loop. | |
| **Fix:** Keyword fast-path in `fact_find_normalizer.py` (hand-curated | |
| substring matchers for 9 metros, 15 tier1 cities, dependents | |
| combinations, income/budget bands, primary goals, common health | |
| conditions) bypasses the LLM for ~80% of answers. Re-ask cap in | |
| `orchestrator.py` gives up after 2 failed normalizations on the same | |
| question and marks it asked. Production audit on persona P002 | |
| post-fix: 30/30 turns completed in 85s with 0 refusals (vs. infinite | |
| loop before). | |
| ### KI-012 β Bot stuck in fact_find_complete readback loop β **FIXED in next commit** | |
| **Severity:** P0 | |
| **Source:** `backend/orchestrator.py` fact-find-complete branch | |
| **Discovered:** Reviewing audit transcript of P002 post-KI-011-fix | |
| After fact-find completes, the orchestrator only calls | |
| `session.set_awaiting(None)` β it does NOT flip | |
| `session.free_form_session = True`. On every subsequent turn, the | |
| classifier still routes through the fact-find branch (because | |
| `free_form_session` is false), `next_question()` returns None (all | |
| fields captured), and the code path emits the readback summary AGAIN | |
| instead of going to retrieval + brain. | |
| **Effect:** P002 used 19 of 30 turns repeating the same readback | |
| "Got it β here's what I've understood: β¦" instead of answering the | |
| user's real policy questions. Every persona that completes fact-find | |
| hits this. | |
| **Fix:** When fact-find completes, set | |
| `session.free_form_session = True` and flush to disk. Subsequent turns | |
| skip the fact-find branch entirely and go through retrieval + brain | |
| as intended. | |
| ### KI-013 β Bot recommends policies on vague openers without fact-find β **FIXED in next commit** | |
| **Severity:** P0 | |
| **Source:** `backend/orchestrator.py` intent β fact_find gating | |
| **Discovered:** Real user testing 2026-05-14 | |
| User gave a vague opening ("I want a health policy") and bot | |
| immediately retrieved + pitched "Care Senior" β a senior-citizen-only | |
| policy. User is not a senior. The intent classifier routed the message | |
| to "recommendation" / "qa", not to fact_find. The orchestrator then | |
| went straight to retrieval + brain β bot recommended whatever scored | |
| highest, regardless of user demographics. | |
| **Fix:** Force fact-find whenever profile is empty (no age, no | |
| dependents, no income_band). Regardless of what the intent classifier | |
| says. Bot will now always start with "First, your age?" before any | |
| recommendation. | |
| ### KI-014 β Vague dependents term "family" auto-mapped to self+spouse+kids β **FIXED in next commit** | |
| **Severity:** P1 | |
| **Source:** `backend/fact_find_normalizer.py` keyword fast-path | |
| **Discovered:** Real user testing 2026-05-14 | |
| User said "family" as their dependents answer. Bot assumed | |
| "self+spouse+kids". User had intended their joint family (parents + | |
| siblings). All subsequent recommendations were wrong. | |
| **Fix:** Add VAGUE_TERMS list (`family`, `everyone`, `joint family`, | |
| etc.) that explicitly returns None from the keyword fast-path, | |
| forcing either the LLM normalizer (which is more nuanced) or a re-ask | |
| clarifier. Phrases like "family β me and my wife" still parse | |
| correctly because the disambiguating words come through. | |
| ### KI-015 β Age in readback summary doesn't match user's stated age | |
| **Severity:** P1 | |
| **Source:** Possibly `backend/needs_finder.py::record_answer` for age, | |
| or LLM readback hallucination | |
| **Discovered:** Real user testing 2026-05-14 | |
| User said "31" but bot's readback summary said "30". Possible causes: | |
| (a) User's earlier answer contained "30" that the int parser caught | |
| first; (b) The bot is using the LLM to generate the readback and the | |
| LLM is hallucinating numeric values. | |
| **Fix plan:** Add a CONFIRMATION step before the bot transitions to | |
| free-form recommendations. After fact-find readback, the bot should | |
| ask "Does this all look right? Reply 'yes' or correct anything that's | |
| off." Then proceed only if user confirms. Also: log the raw fact-find | |
| inputs vs the captured profile so we can debug mismatches. | |
| ### KI-016 β NIM has promoted Qwen3-next-80B over DeepSeek-V4-Flash | |
| **Severity:** P2 | |
| **Source:** Live audit log: `brain=nim-chain::qwen3-next-80b-a3b-instruct::v4-flash::qa` | |
| **Discovered:** Audit run 2026-05-14 | |
| The NIM chain now tries Qwen3-next-80B BEFORE DeepSeek-V4-Flash for | |
| `qa` intents. NIM-side catalog change (not ours). Latency per turn | |
| ~10s β slower than V4-Flash's ~3s. Worth investigating whether | |
| Qwen3-next is empirically a better fit than V4-Flash for our use | |
| case (Indian health-insurance grounded Q&A), or whether we should | |
| explicitly demote it via the admin panel's chain reorder. | |
| **Fix plan:** Run eval/run.py on the gold set with each model | |
| isolated as primary, compare factual/citation/refusal scores. If | |
| V4-Flash wins, reorder via /api/admin/chain. | |
| ### KI-017 β Reviews underrepresented in vector store β **FIXED in `next commit`** | |
| **Severity:** P2 β user-facing (sparse review retrieval) | |
| **Source:** `tools/ingest_reviews.py` produced 1 chunk per insurer (~500 chars) | |
| **Discovered:** Architecture audit 2026-05-14 | |
| Pre-fix state: 10 review chunks in Chroma vs ~116 KB of structured | |
| review data in `data/reviews/*.json` (10 insurers, each with claim | |
| metrics, aggregator ratings, Reddit sentiment, YouTube coverage, | |
| news, aggregate score). A user asking "what do customers say about | |
| Star Health?" retrieved only ONE generic paragraph per insurer, | |
| losing the nuance of metrics-vs-sentiment-vs-news. | |
| **Fix:** Refactored `review_to_paragraph()` β `review_to_chunks()` | |
| that yields 4-6 semantically distinct chunks per insurer: | |
| 1. CLAIM METRICS (IRDAI primary-source numbers) | |
| 2. AGGREGATOR RATINGS (Policybazaar, InsuranceDekho, MouthShut, Trustpilot) | |
| 3. REDDIT/QUORA SENTIMENT (notable themes + sample post URLs) | |
| 4. YOUTUBE COVERAGE (creator reviews + sentiment) | |
| 5. RECENT NEWS (verified press coverage, one line per item) | |
| 6. OVERALL TRUST SCORE (aggregate + letter grade + computation notes) | |
| Each chunk gets a `review_facet` metadata field so retrieval can | |
| filter or boost by facet when intent is clear. Live count: | |
| 60 chunks total (was 10), all 10 insurers Γ 6 facets. | |
| Verified locally; pushed to HF Dataset; live HF Space picks up | |
| on next rebuild. | |
| ### KI-018 β `rag/policies.duckdb` stub is dead code (132 bytes, never populated) | |
| **Severity:** P3 | |
| **Source:** `rag/policies.duckdb` is in the repo but empty (132-byte sqlite header only) | |
| **Discovered:** Data architecture audit 2026-05-14 | |
| ADR-004 designed a hybrid structured (DuckDB) + vector (Chroma) split. The | |
| DuckDB half was never populated; the marketplace UI ended up reading | |
| `data/policy_facts/*.json` directly (one file per policy) at request time | |
| via the `/api/policies/all` endpoint. The 132-byte file in the repo is | |
| visually misleading β looks like a real store, isn't. | |
| **Fix plan (two options):** | |
| - (A) Populate it from `data/policy_facts/*.json` so the marketplace can | |
| do SQL filters (sum-insured β₯ X AND room-rent-cap = no AND restoration | |
| = unlimited). Worth ~30 min. | |
| - (B) Remove the file + the `import duckdb` + the `policies.duckdb` ADR | |
| reference + add a note in ADR-025 explaining why structured filtering | |
| reads JSON directly. Cleaner architecturally. | |
| **Recommendation:** (A) β once activated, the marketplace tab gets | |
| proper SQL filtering that's faster than the current N-JSON-load pattern. | |
| ### KI-167 β Scripted `<FF>` trailer + canonical_fallback removed in favour of LLM-driven `sales_brain` β **FIXED** (ADR-039) | |
| **Severity:** P0 | |
| **Source:** `backend/fact_find_brain.py` + `_canonical_fallback` + `_pick_opener` family in `backend/orchestrator.py` | |
| **Discovered:** 2026-05-15 β user report *"zero natural LLM chat β it always defaults to the script."* | |
| Pre-fix, every fact-find turn ran through ADR-030's one-call brain with a `<FF>...</FF>` trailer convention; on parse failure or contract violation the orchestrator fell to `_canonical_fallback`, which emitted the scripted `Question.prompt_en` of the next unfilled slot prefixed with `"Got that β {slot}."`. Real LLMs (Qwen, Nemotron, Mistral) under load regularly dropped the trailer tags (KI-090 lenient parser bought partial recovery; KI-155 / KI-156 / KI-158 / KI-161 showed it was structural) and silently violated the contract, so the scripted prefix became the dominant user-facing path. | |
| **Fix:** Ripped out `backend/fact_find_brain.py` (441 LOC) + `_canonical_fallback` + `_pick_opener` + `_NEUTRAL_OPENERS` + `_FAMILY_OPENERS` + `_contains_self_introduction`. Replaced with `backend/sales_brain.py` β one LLM call per turn against `FAST_BRAIN_CHAIN` using native provider JSON mode (`response_mime_type=application/json` on Gemini, `response_format={"type":"json_object"}` on NIM). New deterministic post-processor `backend/sales_brain_normalizer.py` validates and normalises the captured fields. KI-091 / KI-094 None-guards retained. See [ADR-039](../60-decisions/ADR-039-llm-driven-sales-brain.md). | |
| --- | |
| ### KI-168 β Hybrid voice capture (Web Speech + MediaRecorder + Sarvam) β **FIXED** | |
| **Severity:** P1 | |
| **Source:** `frontend/src/lib/useStreamingVoice.ts` | |
| **Discovered:** 2026-05-15 | |
| Pre-fix voice UX waited for the full Sarvam STT round-trip before showing the user their transcribed text β felt sluggish on the chat surface. New hybrid: Web Speech API streams interim transcripts into the chat input live, while `MediaRecorder` captures the authoritative audio blob in parallel; on browser silence-detect the blob is POSTed to `/api/transcribe` (Sarvam Saarika STT) for the authoritative transcript. Auto-submit falls back to the Web Speech transcript only if Sarvam errors. KI-173 heartbeat + KI-174 `visibilitychange` / `focus` revival hooks keep the mic alive across tab and app switches. | |
| --- | |
| ### KI-171 β Skip faithfulness judge on `fact_find` + `recommendation` intents β **FIXED** | |
| **Severity:** P1 | |
| **Source:** `backend/orchestrator.py` | |
| **Discovered:** 2026-05-15 | |
| Faithfulness Gate 4 (LLM judge) was being run on every turn including fact-find and recommendation, where there's no retrieved-chunks context to grade the reply against β burning judge calls and occasionally blocking valid replies. Now gated behind `intent β {qa, comparison}` only. Recommendation replies still go through Gates 1-3 (retrieval floor, citation integrity, regex numeric grounding). | |
| --- | |
| ### KI-175 / KI-176 β NIM chain reorder + OpenRouter re-added as cross-provider diversity β **FIXED** | |
| **Severity:** P2 | |
| **Source:** Chain definitions in `backend/providers/nvidia_nim_llm.py` | |
| **Discovered:** 2026-05-15 | |
| KI-175 demoted NIM Nemotron 49B from primary to last resort across all three chains β Mistral Large 3 675B and Qwen 80B consistently outperform on the live audit. KI-176 re-added OpenRouter as a cross-provider diversity tier using OR's `models: [...]` array param for server-side fallback within the OR free pool. The NIM-only lock from [ADR-038](../60-decisions/ADR-038-nim-only-chains.md) was relaxed in scope (the `<FF>` trailer convention that motivated it no longer exists post-KI-167); only OR `:free` candidates with verified native JSON mode are included (Nemotron-3-Super 120B, Qwen 80B `:free`). | |
| --- | |
| ### KI-178 / KI-179 β Google Gemini Flash added as Tier 0 primary β **FIXED** (ADR-040) | |
| **Severity:** P2 | |
| **Source:** `backend/providers/google_gemini_llm.py` (new); chain definitions in `nvidia_nim_llm.py` | |
| **Discovered:** 2026-05-15 | |
| Operator obtained a Google AI Studio API key. KI-178 audited which OpenRouter free-tier models support `response_format` (only Nemotron-3-Super 120B + Qwen 80B + Gemma-4 31B). KI-179 added a new `LLMProvider` against Google AI Studio's REST API (`gemini-2.0-flash` + `gemini-2.5-flash`, native JSON mode via `response_mime_type=application/json`, 1500 req/day free quota). Promoted to Tier 0 primary on Brain Fast (Gemini 2.0 Flash) + Brain Main (Gemini 2.5 Flash). NIM stays as Tier 1 fallback with Mistral 675B as the strongest non-Gemini candidate; OpenRouter sits as Tier 2 diversity. Judge stays on NIM Mistral 675B (different family from Gemini β preserves brain β judge family-diversity invariant). See [ADR-040](../60-decisions/ADR-040-google-gemini-primary.md). | |
| --- | |
| ### KI-019 β `kb/policies/*.md` not embedded (224 human-readable summaries left out of retrieval) | |
| **Severity:** P3 | |
| **Source:** `kb/policies/<policy_id>.md` files exist (224 markdown summaries) but the ingest pipeline only reads from `rag/extracted/*.json`. The natural-language summary text is never embedded. | |
| **Discovered:** Data architecture audit 2026-05-14 | |
| Each policy has a hand-written or auto-generated `kb/policies/<id>.md` | |
| that summarizes the policy in 1-2 pages of natural English (e.g. | |
| "Care Supreme covers in-patient hospitalization across India with a | |
| 36-month PED waitingβ¦"). These would be EXCELLENT retrieval targets | |
| for "what does X cover?" style questions because they're already | |
| phrased for human reading. Currently retrieval matches against raw | |
| PDF-extracted prose which is denser + less semantic. | |
| **Fix plan:** Add a kb-markdown ingestion path in `rag/ingest.py` that | |
| walks `kb/policies/*.md`, splits at H2 boundaries (each H2 β one | |
| schema-field discussion), embeds each section, and writes with | |
| `doc_type="summary"` so retrieval can prefer summary chunks when the | |
| intent is high-level Q&A. | |
| Expected impact: better grounding for natural-language "tell me about X" | |
| questions; cleaner citations (one summary chunk vs. 3-4 raw chunks). | |