rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
86fcc31
Β·
1 Parent(s): 748ce54

feat: KI-044 PCM pre-roll via AudioWorklet + 11 folder READMEs

Browse files

KI-044 β€” Proper first-word capture in Live mode. Previous MediaRecorder
approach started recording only AFTER VAD declared speech-start, missing
the first 50-100 ms of the opening word. User report: "ensure that the
first word uttered is also captured, especially in live."

New approach (frontend/src/lib/useLiveConversation.ts):
β€’ Inline AudioWorkletProcessor (Blob-URL registered, no static asset
routing) taps the raw PCM from the mic source. Every render quantum
(128 samples) is posted to the main thread as Float32.
β€’ Main thread maintains a circular preroll buffer (~300 ms at the
AudioContext sample rate) while no utterance is in progress.
β€’ When VAD fires speech-start: snapshot the preroll into the active
utterance buffer; continue accumulating subsequent samples.
β€’ When VAD fires silence-end: encode utterance as 16-bit PCM WAV
(Sarvam Saarika native format) and post to onUtterance.
β€’ Worklet stays running via source β†’ workletNode β†’ silentSink
(zero-gain GainNode) β†’ ctx.destination so the audio graph doesn't GC
the node while keeping output silent β€” user's voice does NOT play
back through speakers.

PTT path (page.tsx::startRecording) is unaffected β€” PTT input is already
primed when the user clicks the 🎀 button.

11 folder READMEs added by a parallel agent:
β€’ backend/, backend/providers/, rag/, tools/, tools/audit/, eval/,
tests/, data/, data/profiles/, audit_results/, frontend/ (replaces
the create-next-app boilerplate).
β€’ Skipped folders that already had meaningful indices (docs/, kb/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

audit_results/README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `audit_results/` β€” Audit output + production-readiness register
2
+
3
+ Two artefact classes coexist here:
4
+
5
+ 1. **`ENTERPRISE_AUDIT.md`** β€” the **master defect log**. A living, hand-curated, severity-tagged register of every P0/P1/P2/P3 issue discovered during the readiness sprint, with evidence + fix status. Start here before any deploy decision.
6
+ 2. **`<run_id>/` subdirectories** β€” per-run output of the multi-persona audit framework (`tools/audit/`). Each is a complete, immutable snapshot of one full or partial 100-persona pass.
7
+
8
+ ## `ENTERPRISE_AUDIT.md`
9
+
10
+ | Section | What's there |
11
+ | --- | --- |
12
+ | Executive scorecard | One-line status per domain: disk stability, data pipeline, observability, accuracy, latency, profile capture, language fairness, code hygiene, test coverage, voice UX, fact-find tone, secrets. |
13
+ | Defect Register | D-001 … D-NNN. Each row: severity (P0–P3) Β· title Β· symptom Β· root cause Β· impact Β· fix status. |
14
+
15
+ Severity legend: **P0** blocks production deployment Β· **P1** blocks enterprise procurement Β· **P2** quality / hygiene Β· **P3** nice-to-have.
16
+
17
+ Status emoji: βœ… fixed Β· ⚠️ partial Β· 🟑 improving Β· πŸ”΄ open.
18
+
19
+ ## Run-directory layout
20
+
21
+ ```
22
+ audit_results/
23
+ β”œβ”€β”€ ENTERPRISE_AUDIT.md
24
+ └── <run_id>/
25
+ β”œβ”€β”€ transcripts/
26
+ β”‚ β”œβ”€β”€ P001.json
27
+ β”‚ β”œβ”€β”€ P002.json
28
+ β”‚ β”œβ”€β”€ P003.partial.json (in-flight or interrupted persona)
29
+ β”‚ └── …
30
+ β”œβ”€β”€ report.md (analyze.py rollup)
31
+ └── summary.json (machine-readable rollup)
32
+ ```
33
+
34
+ | Run-ID prefix | Meaning |
35
+ | --- | --- |
36
+ | `full_YYYYMMDD_HHMMSS` | Full 100-persona pass against the live HF Space. |
37
+ | `postfix_YYYYMMDD_HHMMSS` | Targeted re-run after shipping a specific fix, used to confirm the defect is gone. |
38
+
39
+ ## Each persona transcript
40
+
41
+ `transcripts/P###.json` captures the 30-turn flow for one persona. Per turn:
42
+
43
+ | Field | Source |
44
+ | --- | --- |
45
+ | `user_text` | `tools/audit/flows.py` |
46
+ | `reply_text` | live API |
47
+ | `intent`, `brain_used` | `backend/orchestrator.py` |
48
+ | `citations` | `backend/main.py` response |
49
+ | `profile_updates` | `backend/profile_extractor.py` |
50
+ | `faithfulness_passed`, `blocked` | `backend/faithfulness.py` |
51
+ | `latency_ms` | wall-clock around the HTTP call |
52
+
53
+ `.partial.json` files indicate the audit was interrupted (rate-limit pile-up, deploy mid-run, etc.); the runner is resumable and will pick up from the next persona on restart.
54
+
55
+ ## How runs flow into the register
56
+
57
+ 1. `python tools/audit/run_audit.py` writes per-persona transcripts.
58
+ 2. `python tools/audit/analyze.py audit_results/<run_id>/` rolls up `report.md` + `summary.json`.
59
+ 3. New defects β†’ new `D-NNN` row in `ENTERPRISE_AUDIT.md`.
60
+ 4. Fixes β†’ status flipped, run re-executed as `postfix_…`, evidence linked.
61
+
62
+ ## Related
63
+
64
+ - `tools/audit/README.md` β€” framework that produces the run dirs
65
+ - `tools/audit/personas.json`, `tools/audit/flows.json` β€” the deterministic inputs each run replays
66
+ - Root `CLAUDE.md` β€” high-level project state; defers detail to this file for change history
67
+ - [`kb/AUDIT_TRAIL.md`](../kb/AUDIT_TRAIL.md) β€” data-lineage doc that pairs with the behaviour-audit log
backend/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `backend/` β€” FastAPI orchestrator
2
+
3
+ FastAPI + Pydantic service that fronts every chat turn. The HTTP entry point is `main.py`; everything else is a typed module the orchestrator composes per turn.
4
+
5
+ ## Entry points
6
+
7
+ | File | Role |
8
+ | --- | --- |
9
+ | `main.py` | FastAPI app, all HTTP routes (`/api/chat`, `/api/profile`, `/api/policies`, admin endpoints). Wires CORS, request validation, returns the typed response that the frontend's `openapi-typescript` codegen consumes. |
10
+ | `orchestrator.py` | The brain of a turn: `classify_intent`, `pick_brain`, fact-find routing, profile-RAG injection, faithfulness gate dispatch. Pinned by `tests/test_routing_regression.py`. |
11
+ | `config.py` | Pydantic-settings β€” single source of truth for env-vars, model IDs, chunk sizes, chain budgets. |
12
+
13
+ ## Per-turn helpers
14
+
15
+ | File | Role | Related ADR |
16
+ | --- | --- | --- |
17
+ | `needs_finder.py` | 9-slot fact-find graph (`GRAPH`) + slot detection from free text. | [ADR-027](../docs/60-decisions/ADR-027-fact-find-llm-paraphraser.md) |
18
+ | `question_paraphraser.py` | LLM rewrite of the canonical slot question so each session sounds fresh; verifier rejects off-slot drift. Cached per `(session_id, slot_id)`. | ADR-027 |
19
+ | `fact_find_normalizer.py` | LLM-driven free-text β†’ slot-value coercion (e.g. "32 lakh" β†’ `3200000`). Goes through `NimChainLLM`, not a single client (KI-033). | β€” |
20
+ | `profile_extractor.py` | LLM extractor that pulls profile updates out of conversational asides ("by the way, my dad has diabetes"). Chain-pattern, never a hardcoded model. | [ADR-022](../docs/60-decisions/ADR-022-conversational-profile-updates.md) |
21
+ | `profile_store.py` | **NEW (KI-040).** Persistent named-profile JSON store under `data/profiles/`. O(1) name-keyed lookup; mirrors into `profile_rag` on every save. | β€” |
22
+ | `profile_rag.py` | Embeds the user's profile as a Chroma chunk so the brain sees it alongside policy chunks for "what's best for me?" turns. | β€” |
23
+ | `session_state.py` | In-memory session map; tracks fact-find progress + chat history per `session_id`. |
24
+ | `faithfulness.py` | 4-gate hallucination guard (retrieval floor β†’ citation integrity β†’ regex numeric grounding β†’ LLM judge). Blocks land in `logs/hallucinations.jsonl`. | β€” |
25
+ | `scorecard.py` | Pure-function 6-sub-score scorer over the 62-field extracted JSON. No LLM. | β€” |
26
+ | `translator.py` | Sarvam-M Indic ↔ English translator wrapper. | [ADR-006](../docs/60-decisions/ADR-006-sarvam-first-stack.md) |
27
+ | `translation_check.py` | Post-hoc detector for mixed-script replies; flags Hinglish leakage. | β€” |
28
+ | `persona.py` | The consultative-advisor system prompt + view-aware prompt overlays. | [ADR-008](../docs/60-decisions/ADR-008-consultative-advisor-persona.md), [ADR-021](../docs/60-decisions/ADR-021-view-aware-system-prompt.md) |
29
+ | `voice_format.py` | Strips markdown / lists / bullet glyphs so TTS sounds natural. | β€” |
30
+ | `premium_calculator.py` | Looks up `data/premiums/illustrative_premiums.json` + applies the documented scaling factors. Never claims a real quote. | [ADR-007](../docs/60-decisions/ADR-007-illustrative-pricing.md) |
31
+ | `security.py` | Request rate-limiting, input sanitisation, admin-IP allowlist. | [ADR-023](../docs/60-decisions/ADR-023-admin-panel-ip-gated.md) |
32
+ | `admin.py` | Admin-only routes (live LLM-health, usage rollups, hallucination tail). | ADR-023 |
33
+ | `llm_health.py` | Lightweight probe that pings each provider and writes `data/llm_health.json` for the admin tab. | β€” |
34
+
35
+ ## Subdirectory
36
+
37
+ `providers/` β€” concrete STT / TTS / LLM / embeddings client implementations. All LLM access goes through `NimChainLLM(chain=...)` from `providers/nvidia_nim_llm.py` β€” never instantiate a single-provider client directly. See `backend/providers/README.md`.
38
+
39
+ ## Where to read for what
40
+
41
+ - **System tour:** root `README.md`
42
+ - **Stable contracts a new contributor must know:** root `CLAUDE.md`
43
+ - **Decisions with alternatives:** `docs/60-decisions/ADR-*.md`
44
+ - **Routing invariants:** `tests/test_routing_regression.py`
45
+ - **Defect register:** `audit_results/ENTERPRISE_AUDIT.md`
backend/providers/README.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `backend/providers/` β€” STT / TTS / LLM / embedding clients
2
+
3
+ Every external model is fronted by a small typed client here. The orchestrator and helper modules **only** ever import provider symbols from this folder β€” single import surface so a provider swap is local.
4
+
5
+ ## Files
6
+
7
+ | File | Provider | Role | Notes |
8
+ | --- | --- | --- | --- |
9
+ | `base.py` | β€” | Abstract `LLM`, `STT`, `TTS`, `Embeddings` Protocols. Every concrete client conforms. | β€” |
10
+ | `nvidia_nim_llm.py` | NVIDIA NIM | Core chain runner β€” `NimChainLLM(chain=[...])` walks a fallback ladder under a wall-clock budget. Exposes `get_brain_llm()`, `get_fast_brain_llm()`, `get_judge_llm()`. Also home of `_balanced_brain_chain()` (50/50 NIM ↔ Groq rotator). | [ADR-019](../../docs/60-decisions/ADR-019-nim-single-provider-consolidation.md), [ADR-026](../../docs/60-decisions/ADR-026-provider-load-balancing.md) |
11
+ | `groq_llm.py` | Groq | Single-call Llama-3.3-70B client. Used as the 50% load-balance primary for the brain chain, never standalone. | [ADR-026](../../docs/60-decisions/ADR-026-provider-load-balancing.md) |
12
+ | `openrouter_llm.py` | OpenRouter | Multi-model fallback rung (DeepSeek-V3 etc.) for chains; rarely the primary in production. | β€” |
13
+ | `sarvam_llm.py` | Sarvam-M | Indic-aware LLM; on the judge / translator fallback chains and used by `backend/translator.py`. | [ADR-006](../../docs/60-decisions/ADR-006-sarvam-first-stack.md) |
14
+ | `sarvam_stt.py` | Sarvam Saarika v2.5 | Speech-to-text (10 Indic languages + English). | ADR-006 |
15
+ | `sarvam_tts.py` | Sarvam Bulbul v2 | Text-to-speech; returns base64 WAV the frontend mounts in the in-DOM `<audio>` element. | ADR-006 |
16
+ | `voyage_embeddings.py` | Voyage AI | Original ingest-time embedder. **Not on the hot path** β€” query-time uses Chroma vectors directly. Configured in `.env` for occasional re-ingest. | [ADR-011](../../docs/60-decisions/ADR-011-bge-local-embeddings.md) |
17
+ | `local_embeddings.py` | BGE-small-en-v1.5 (local) | The actual production embedder. 384-dim, free, no rate cap. | ADR-011 |
18
+ | `_smoke_test.py` | β€” | Stand-alone connectivity probe β€” hits every provider, prints latency + first 80 chars. Run before a long audit. | β€” |
19
+
20
+ ## Invariants
21
+
22
+ - **Never instantiate `NvidiaNimLLM(model=...)` directly.** Always go through `NimChainLLM(chain=...)` so the call survives single-pool rate limits. KI-033 migrated the last two stragglers (`profile_extractor`, `fact_find_normalizer`).
23
+ - **Brain chains preserve family diversity.** Qwen brain ↔ Mistral judge β€” failovers can't accidentally collapse to a single family and produce circular grading.
24
+ - **Per-call random for load-balance, not a shared counter.** `random.random()` is evaluated at chain-construction time; a shared `itertools.cycle` breaks under async concurrency (see ADR-026 "Why per-call random").
25
+
26
+ ## Chain budgets
27
+
28
+ | Chain | Per-link timeout (s) | Total budget (s) | Where set |
29
+ | --- | --- | --- | --- |
30
+ | Brain | 20 | 35 | `nvidia_nim_llm.py::get_brain_llm` |
31
+ | Fast brain | 12 | 22 | `nvidia_nim_llm.py::get_fast_brain_llm` |
32
+ | Judge | 30 | 75 | `nvidia_nim_llm.py::get_judge_llm` |
33
+
34
+ Per-link timeout is dynamically clipped to remaining budget.
35
+
36
+ ## Related
37
+
38
+ - [ADR-006](../../docs/60-decisions/ADR-006-sarvam-first-stack.md), [ADR-011](../../docs/60-decisions/ADR-011-bge-local-embeddings.md), [ADR-019](../../docs/60-decisions/ADR-019-nim-single-provider-consolidation.md), [ADR-026](../../docs/60-decisions/ADR-026-provider-load-balancing.md)
39
+ - `tests/test_routing_regression.py::TestProviderLoadBalancing` β€” pins the 50/50 split
40
+ - `data/llm_health.json` β€” last health-probe snapshot surfaced in the admin tab
data/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `data/` β€” Runtime + marketplace data
2
+
3
+ Three classes of file live here, intentionally side-by-side:
4
+
5
+ 1. **Runtime state** β€” written by the live server during normal operation (`profiles/`, `sessions/`, `llm_health.json`, `llm_usage.jsonl`).
6
+ 2. **Pre-computed marketplace data** β€” curated artefacts the server reads on every relevant turn (`policy_facts/`, `premiums/`, `reviews/`).
7
+ 3. **Source/lineage maps** β€” human-readable manifests of where every claim traces back to (`corpus_urls.md`, `regulatory_urls.md`, `information_source_map.md`).
8
+
9
+ The structured policy schema and PDFs themselves live under `rag/`. This folder is downstream.
10
+
11
+ ## Top-level files
12
+
13
+ | File | What it is | Owner |
14
+ | --- | --- | --- |
15
+ | `corpus_urls.md` | Discovery manifest β€” every PDF URL ingested into `rag/corpus/`. | discovery agent / `tools/check_link_rot.py` |
16
+ | `regulatory_urls.md` | IRDAI / regulatory PDF URLs. See [ADR-017](../docs/60-decisions/ADR-017-irdai-corpus-playwright-rescue.md). | discovery agent |
17
+ | `information_source_map.md` | Human-readable claim β†’ URL β†’ verdict map. Master audit doc for the Source Methodology directive. Mirror of `eval/info_source_map.json`. | `tools/info_source_map.py` |
18
+ | `llm_health.json` | Last per-provider health-probe snapshot (latency, success, last error). Powers the admin tab. | `backend/llm_health.py` |
19
+ | `llm_usage.jsonl` | Append-only per-call log: provider, model, tokens, latency, success. Aggregated in the admin tab. | `backend/main.py` |
20
+
21
+ ## Subdirectories
22
+
23
+ | Path | Class | Contents |
24
+ | --- | --- | --- |
25
+ | `profiles/` | runtime | Persistent named-profile JSON store (KI-040). One file per user, normalised-name slug. See `data/profiles/README.md`. |
26
+ | `sessions/` | runtime | Per-session conversation state JSONs. Ephemeral β€” pruned periodically. Currently includes `anonymous.json` (no-name fallback). |
27
+ | `policy_facts/` | pre-computed | **256 curated JSONs**, one per policy variant. Each field carries `{value, unit?, source_pdf_path, source_quote}` provenance. The Indian-BFSI-audit-grade machine source; `kb/policies/*.md` are the human-readable mirror. See `_curation_report.md` for the three batches that built it. |
28
+ | `policies/` | pre-computed | Subfolder per insurer with PDFs / supplementary text used for one-off lookups outside the main ingest pipeline. |
29
+ | `premiums/` | pre-computed | `illustrative_premiums.json` β€” sample starting premiums pulled from PolicyBazaar / JoinDitto / Beshak + insurer rate cards (2026-05-13). Refreshed by `tools/refresh_premiums.py`. **Illustrative only** per [ADR-007](../docs/60-decisions/ADR-007-illustrative-pricing.md). |
30
+ | `reviews/` | pre-computed | One JSON per insurer with IRDAI claim-settlement metrics, complaints/10K, aggregator sentiment, news tone. Index + leaderboard in `reviews/INDEX.md`. Source: IRDAI Annual Report 2023-24. |
31
+
32
+ ## Provenance + KPIs
33
+
34
+ | Metric | Value (2026-05-14) | Where to verify |
35
+ | --- | --- | --- |
36
+ | Curated policy variants | 256 | `data/policy_facts/` file count |
37
+ | Per-policy avg field completeness | 83.5% (Batch 1) | `data/policy_facts/_curation_report.md` |
38
+ | Information-source-map verdicts | βœ… 798 Β· ⚠️ 321 Β· ❌ 0 Β· ⏳ 1385 | `eval/info_source_map.json` |
39
+
40
+ ## Related
41
+
42
+ - [`kb/AUDIT_TRAIL.md`](../kb/AUDIT_TRAIL.md) β€” end-to-end lineage; `data/policy_facts/` is stage 8 output
43
+ - [`kb/INDEX.md`](../kb/INDEX.md) β€” policy index with completeness % per file
44
+ - [ADR-007](../docs/60-decisions/ADR-007-illustrative-pricing.md) β€” pricing is illustrative, never a real quote
45
+ - [ADR-009](../docs/60-decisions/ADR-009-19-insurer-comprehensive-schema.md) β€” 19-insurer scope + 48-field schema
data/profiles/README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `data/profiles/` β€” Named-profile JSON store
2
+
3
+ Persistent name-keyed profile store introduced by **KI-040 (2026-05-14)**. Lets a returning visitor say their name and have the bot recognise them + auto-load the stored profile, so they don't have to walk the 9-slot fact-find again.
4
+
5
+ Canonical store: `backend/profile_store.py`. ADR (deferred β€” code self-documents).
6
+
7
+ ## File layout
8
+
9
+ ```
10
+ data/profiles/
11
+ └── <normalised-name>.json (one file per user)
12
+ ```
13
+
14
+ - **Filename slug:** lowercase + alpha-only of the user's first name. `"Rohit"` and `"rohit."` both resolve to `rohit.json`.
15
+ - **Inside the file:** the original capitalised display name is preserved.
16
+
17
+ ## JSON shape
18
+
19
+ ```json
20
+ {
21
+ "display_name": "Rohit",
22
+ "first_seen": "2026-05-14T09:12:33Z",
23
+ "last_seen": "2026-05-14T22:41:08Z",
24
+ "sessions": ["sess_abc…", "sess_def…"],
25
+ "profile": {
26
+ "age": 32,
27
+ "dependents": "spouse+1_child",
28
+ "city_tier": "metro",
29
+ "...": "..."
30
+ }
31
+ }
32
+ ```
33
+
34
+ Schema: `profile` mirrors the 9-slot `GRAPH` in `backend/needs_finder.py`. Everything else is bookkeeping.
35
+
36
+ ## Two-layer sync
37
+
38
+ | Layer | Purpose | Where |
39
+ | --- | --- | --- |
40
+ | JSON (this folder) | Canonical, O(1) name-keyed lookup, deterministic, human-readable, manually editable. | `backend/profile_store.py::save_profile` |
41
+ | Chroma vector chunk | Re-embedded on every save so the brain sees the profile alongside policy chunks at retrieval time β€” powers "what's best for me?" questions. | `backend/profile_rag.py::upsert_profile_chunk` |
42
+
43
+ Both stay in sync: `save_profile()` fires the Chroma upsert in the same call. Embedding cost is once per update, not per query.
44
+
45
+ ## Why JSON, not Chroma-only
46
+
47
+ The original design considered embedding-only. The "why JSON" trade-offs:
48
+
49
+ - **Deterministic name lookup.** `Rohit` β†’ `rohit.json` is exact; vector search is approximate and can collide on common first names.
50
+ - **Human-readable.** A BFSI auditor can `cat` the file and see the full profile.
51
+ - **Manually editable.** Quick repair without a re-embed pipeline.
52
+ - **No HNSW bloat exposure.** Profile updates do not touch the policy vector store ([ADR-029](../docs/60-decisions/ADR-029-disk-storage-hardening.md)).
53
+
54
+ The Chroma chunk is purely a retrieval-time view of the canonical JSON.
55
+
56
+ ## Privacy + retention
57
+
58
+ - Profiles are local to the deployed instance. No third-party share.
59
+ - Per [ADR-010](../docs/60-decisions/ADR-010-secret-handling.md), the folder is not exposed via the HTTP API except through the user's own `session_id`.
60
+ - The folder is committed empty (placeholder) β€” actual profiles are runtime artefacts.
61
+
62
+ ## Related
63
+
64
+ - `backend/profile_store.py` β€” the canonical store implementation
65
+ - `backend/profile_extractor.py` + [ADR-022](../docs/60-decisions/ADR-022-conversational-profile-updates.md) β€” how conversational asides flow into the profile
66
+ - `backend/profile_rag.py` β€” the embedding mirror
67
+ - `backend/needs_finder.py::GRAPH` β€” the 9-slot schema the `profile` block conforms to
eval/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `eval/` β€” Gold-QA harness
2
+
3
+ Numerical accuracy + grounding eval. Walks a fixed list of curated questions through the orchestrator in-process (fast, no HTTP) and grades each reply with both a regex hard-facts checker and an LLM-judge of a different family from the brain.
4
+
5
+ `eval/` is the **objective** quality bar; `tools/audit/` is the **behavioural** stress test. Both feed the readiness register in `audit_results/ENTERPRISE_AUDIT.md`.
6
+
7
+ ## Files
8
+
9
+ | File | Role |
10
+ | --- | --- |
11
+ | `generate_gold.py` | Builds `gold_qa.json` from `data/policy_facts/`: for each curated field with a verbatim quote, emits a natural-language question + expected answer + expected citation. |
12
+ | `gold_qa.json` | 96-Q gold set. Each entry: `{policy_id, question, expected_answer, expected_regex, source_quote, source_pdf}`. |
13
+ | `run.py` | Runner. For each pair: calls `backend.orchestrator.handle_turn` with `policy_filter_ids=[pair.policy_id]` so retrieval is scoped to the policy under test. Grades each reply twice β€” regex hard-facts + Groq Llama judge (different family from the NIM brain β†’ non-circular). |
14
+ | `results.json` | Machine-readable last-run results. |
15
+ | `results.md` | Human-readable last-run report β€” per-question pass/fail, per-category rollup, hallucination breakdown. |
16
+ | `info_source_map.json` | Generated by `tools/info_source_map.py`. Claim β†’ URL β†’ verdict (βœ… 798 / ⚠️ 321 / ❌ 0 / ⏳ 1385 as of 2026-05-14). The canonical source-grounding KPI. |
17
+ | `verified_urls.json` | HEAD-check verdict on every URL in the corpus / facts. Generated by `tools/verify_urls.py`. |
18
+ | `reviews_url_verification.json` | URL-validation output for `data/reviews/<insurer>.json`. |
19
+ | `chunk_sweep_results.json`, `chunk_diagnostic.json` | Outputs of `tools/chunk_sweep.py` β€” chunk-size / overlap grid. See [ADR-018](../docs/60-decisions/ADR-018-chunk-size-sweep-deferred.md). |
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Full 96-Q eval
25
+ python -m eval.run
26
+
27
+ # Smoke
28
+ python -m eval.run --limit 30
29
+
30
+ # Scoped to one policy
31
+ python -m eval.run --policy hdfc-ergo__optima-secure
32
+
33
+ # Regenerate the gold set after curation refresh
34
+ python -m eval.generate_gold
35
+ ```
36
+
37
+ ## Grading
38
+
39
+ | Layer | What it checks | Output field |
40
+ | --- | --- | --- |
41
+ | Regex hard-facts | Expected numeric / boolean / waiting-period value appears verbatim in `reply_text`. | `regex_pass` |
42
+ | LLM judge (Groq Llama-3.3-70B) | Reply *semantically* answers the question and cites the expected source. Different family from the NIM Qwen brain β†’ non-circular. | `judge_pass` |
43
+ | Faithfulness gate | The 4-gate guard in `backend/faithfulness.py` already ran during `handle_turn`; eval records whether it blocked. | `blocked` |
44
+
45
+ ## Acceptance bar
46
+
47
+ Per `audit_results/ENTERPRISE_AUDIT.md` D-003: **β‰₯ 90% on the 96-Q gold-QA set is the P0 gate for enterprise deployment.** Current state lives in `results.md`.
48
+
49
+ ## Related
50
+
51
+ - [`backend/faithfulness.py`](../backend/faithfulness.py) β€” the 4-gate guard that produces the `blocked` verdict
52
+ - [`kb/AUDIT_TRAIL.md`](../kb/AUDIT_TRAIL.md) Β§ "Live bot replies" β€” provenance trail for any flagged reply
53
+ - `tools/info_source_map.py` β€” generator of `info_source_map.json`
54
+ - [ADR-014](../docs/60-decisions/ADR-014-groq-llama-grader.md) (superseded β€” but the Groq-judge-of-different-family principle survives)
frontend/README.md CHANGED
@@ -1,36 +1,50 @@
1
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
 
3
- ## Getting Started
4
 
5
- First, run the development server:
6
 
7
- ```bash
8
- npm run dev
9
- # or
10
- yarn dev
11
- # or
12
- pnpm dev
13
- # or
14
- bun dev
15
- ```
16
 
17
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
 
 
 
 
 
 
 
18
 
19
- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
 
21
- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
 
 
22
 
23
- ## Learn More
24
 
25
- To learn more about Next.js, take a look at the following resources:
 
 
 
 
 
 
 
26
 
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
 
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
 
 
 
 
31
 
32
- ## Deploy on Vercel
33
 
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
 
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
 
 
 
 
1
+ # `frontend/` β€” Next.js 14 (App Router) UI
2
 
3
+ The chat UI, profile builder, scorecard, and admin panel. Single-page-ish β€” almost everything lives in `src/app/page.tsx` with `src/lib/` carrying the API client, the EN ↔ ΰ€Ήΰ€Ώΰ€‚ i18n table, and the live-conversation hook.
4
 
5
+ For Claude / agent-specific rules (e.g. the "this is NOT the Next.js you know" note) see `AGENTS.md` in this folder β€” `CLAUDE.md` is an alias of it.
6
 
7
+ ## Entry points
 
 
 
 
 
 
 
 
8
 
9
+ | File | Role |
10
+ | --- | --- |
11
+ | `src/app/page.tsx` | The chat surface. Hosts the `Message` component (which mounts the in-DOM `<audio>` element for TTS), the toolbar (Live toggle + push-to-talk), the tab switcher (Chat / Profile Builder / Scorecard / Admin), and every view's render. |
12
+ | `src/app/layout.tsx` | Root layout + font wiring (Geist via `next/font`). |
13
+ | `src/app/globals.css` | Tailwind v4 entrypoint + the shadcn/ui token layer ([ADR-013](../docs/60-decisions/ADR-013-tailwind-shadcn-ui.md)). |
14
+ | `src/lib/api.ts` | Typed API client. Generated types via `openapi-typescript` from the FastAPI OpenAPI schema ([ADR-015](../docs/60-decisions/ADR-015-openapi-typescript-codegen.md)). |
15
+ | `src/lib/useLiveConversation.ts` | The continuously-open-mic VAD hook that powers Live mode + barge-in. State persists in `localStorage.insurance_live_pref` ([ADR-028](../docs/60-decisions/ADR-028-voice-ux-live-default.md)). |
16
+ | `src/lib/i18n.ts` | EN ↔ ΰ€Ήΰ€Ώΰ€‚ strings + the 13-term `GLOSSARY` mirrored to [`kb/methodology/glossary.json`](../kb/methodology/glossary.json). |
17
 
18
+ ## Voice UX invariants ([ADR-028](../docs/60-decisions/ADR-028-voice-ux-live-default.md))
19
 
20
+ - **One default voice mode + one fallback.** Live mode is the default; the toolbar pill toggles it (green = on, red = off). The 🎀 push-to-talk button suspends Live for one turn β†’ captures with VAD silence-cutoff β†’ resumes Live if the user preference is still on.
21
+ - **Hands-free mode was removed in KI-027.** Any reference to it in code or docs is stale.
22
+ - **Bot TTS plays via the in-DOM `<audio>` element inside `Message`** (autoplay-on-mount via ref'd `useEffect`). **Never use `new Audio(url).play()`** β€” those detached instances are invisible to the `document.querySelectorAll("audio").pause()` call in the barge-in handler, so they keep playing under the user's speech.
23
 
24
+ ## Tooling
25
 
26
+ | Concern | Where |
27
+ | --- | --- |
28
+ | Build / dev / lint | `package.json` scripts (`dev`, `build`, `lint`). |
29
+ | Lint config | `eslint.config.mjs`. |
30
+ | TS config | `tsconfig.json`. |
31
+ | Postcss / Tailwind | `postcss.config.mjs`. |
32
+ | Next config | `next.config.ts`. |
33
+ | Static assets | `public/`. |
34
 
35
+ ## Develop locally
 
36
 
37
+ ```bash
38
+ cd frontend
39
+ npm install # or pnpm / bun
40
+ npm run dev # localhost:3000 β€” points at the FastAPI on :8000 by default
41
+ ```
42
 
43
+ Point at a different backend with `NEXT_PUBLIC_API_BASE=...` (see `src/lib/api.ts`).
44
 
45
+ ## Related
46
 
47
+ - `AGENTS.md` (this folder) β€” required reading for AI agents touching this code
48
+ - Root `CLAUDE.md` Β§ Voice UX (ADR-028) β€” the canonical voice-UX cheat-sheet
49
+ - [ADR-005](../docs/60-decisions/ADR-005-nextjs-fastapi-frontend.md) Β· [ADR-013](../docs/60-decisions/ADR-013-tailwind-shadcn-ui.md) Β· [ADR-015](../docs/60-decisions/ADR-015-openapi-typescript-codegen.md) Β· [ADR-021](../docs/60-decisions/ADR-021-view-aware-system-prompt.md) Β· [ADR-028](../docs/60-decisions/ADR-028-voice-ux-live-default.md)
50
+ - [`backend/main.py`](../backend/main.py) β€” the API the frontend talks to
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -3,39 +3,41 @@
3
  /**
4
  * useLiveConversation β€” full-duplex voice mode with barge-in.
5
  *
6
- * Differences from the existing push-to-talk + Hands-free toggle:
 
 
 
 
 
7
  *
8
- * - Mic is OPEN continuously while live mode is on (single getUserMedia
9
- * stream, not opened/closed per turn).
10
- * - VAD (RMS on AnalyserNode + frame counters) detects when the user
11
- * starts and stops speaking, with no button press.
12
- * - When VAD detects speech start, we immediately:
13
- * * pause + clear every <audio> currently playing (kill the bot's
14
- * in-progress TTS reply mid-sentence),
15
- * * abort the in-flight /api/chat fetch via AbortController,
16
- * * start a new MediaRecorder for the user's utterance.
17
- * - When VAD detects silence for >600ms after speech, we stop the
18
- * recorder and POST the blob to the supplied `onUtterance` handler
19
- * (which the page wires to transcribe β†’ chat).
 
20
  *
21
- * The hook returns an AbortController slot the caller assigns to its
22
- * own in-flight fetches so barge-in can cancel them.
 
 
23
  */
24
 
25
  import { useCallback, useEffect, useRef, useState } from "react";
26
 
27
  export type LiveConversationOptions = {
28
- /** Called when the user finishes an utterance β€” pass it the audio blob. */
29
  onUtterance: (blob: Blob, abort: AbortController) => Promise<void>;
30
- /** Called when VAD detects speech start (so the UI can show "listening…"). */
31
  onSpeechStart?: () => void;
32
- /** Called when VAD detects speech end (so the UI can show "thinking…"). */
33
  onSpeechEnd?: () => void;
34
- /** RMS threshold above which we declare "speech". Tune in browser. */
35
  rmsThreshold?: number;
36
- /** Consecutive loud frames needed to start recording (debounce). */
37
  speechStartFrames?: number;
38
- /** Consecutive quiet frames needed to stop recording (~16 ms/frame). */
39
  silenceEndFrames?: number;
40
  };
41
 
@@ -44,24 +46,75 @@ export type LiveConversationState = {
44
  recording: boolean;
45
  micPermissionDenied: boolean;
46
  setLive: (v: boolean) => void;
47
- /** Caller-managed abort slot for in-flight fetches; VAD aborts it on speech. */
48
  inflightAbortRef: React.MutableRefObject<AbortController | null>;
49
  };
50
 
51
  const DEFAULTS = {
52
- // KI-041 (2026-05-14) β€” sensitivity bumped. Previous threshold 28 was high
53
- // enough that normal speaking volume in a moderately-quiet room didn't
54
- // trigger barge-in detection, so users reported "speaking over the bot
55
- // doesn't interrupt it". 18 catches typical conversational volume reliably
56
- // while still rejecting room hum / breathing / keyboard clatter.
57
  rmsThreshold: 18,
58
- // ~48ms of speech to fire. Previous 5 frames (~80ms) added perceptible
59
- // latency on barge-in; 3 frames keeps false-positive immunity but cuts
60
- // the response time by ~32ms.
61
- speechStartFrames: 3,
62
  silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
 
 
 
 
 
63
  };
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  export function useLiveConversation(opts: LiveConversationOptions): LiveConversationState {
66
  const [live, setLive] = useState(false);
67
  const [recording, setRecording] = useState(false);
@@ -71,11 +124,18 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
71
  const audioCtxRef = useRef<AudioContext | null>(null);
72
  const analyserRef = useRef<AnalyserNode | null>(null);
73
  const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
74
- const recorderRef = useRef<MediaRecorder | null>(null);
75
- const chunksRef = useRef<Blob[]>([]);
76
- const recordingRef = useRef(false); // sync ref for VAD loop
 
 
 
 
 
 
77
  const rafIdRef = useRef<number | null>(null);
78
  const inflightAbortRef = useRef<AbortController | null>(null);
 
79
 
80
  const onUtteranceRef = useRef(opts.onUtterance);
81
  const onSpeechStartRef = useRef(opts.onSpeechStart);
@@ -90,75 +150,80 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
90
  rmsThreshold: opts.rmsThreshold ?? DEFAULTS.rmsThreshold,
91
  speechStartFrames: opts.speechStartFrames ?? DEFAULTS.speechStartFrames,
92
  silenceEndFrames: opts.silenceEndFrames ?? DEFAULTS.silenceEndFrames,
 
 
93
  };
94
 
95
- const stopRecording = useCallback(() => {
96
- if (recorderRef.current && recorderRef.current.state !== "inactive") {
97
- try { recorderRef.current.stop(); } catch {}
98
- }
99
- }, []);
100
-
101
  const interruptBotAudio = useCallback(() => {
102
- // Pause + reset every audio element in the DOM. Bot replies use plain
103
- // <audio> elements; killing src forces the loaded buffer to drop.
104
  if (typeof document !== "undefined") {
105
  document.querySelectorAll("audio").forEach((a) => {
106
  try {
107
  a.pause();
108
- // Don't blank src β€” let the existing buffer GC but leave the
109
- // element so the chat history scroll position doesn't jump.
110
  a.currentTime = a.duration || 0;
111
  } catch {}
112
  });
113
  }
114
  }, []);
115
 
116
- const startRecording = useCallback(() => {
117
- if (!streamRef.current) return;
118
- const mime = MediaRecorder.isTypeSupported("audio/webm")
119
- ? "audio/webm"
120
- : "";
121
- const rec = mime
122
- ? new MediaRecorder(streamRef.current, { mimeType: mime })
123
- : new MediaRecorder(streamRef.current);
124
- chunksRef.current = [];
125
- rec.ondataavailable = (e) => {
126
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
127
- };
128
- rec.onstop = async () => {
129
- recordingRef.current = false;
130
- setRecording(false);
131
- if (chunksRef.current.length === 0) return;
132
- const blob = new Blob(chunksRef.current, {
133
- type: rec.mimeType || "audio/webm",
134
- });
135
- // Reject blobs that are almost certainly silence or VAD false-trips.
136
- if (blob.size < 3000) return;
137
- onSpeechEndRef.current?.();
138
- const abort = new AbortController();
139
- inflightAbortRef.current = abort;
140
- try {
141
- await onUtteranceRef.current(blob, abort);
142
- } catch (e) {
143
- const name = (e as { name?: string })?.name;
144
- if (name !== "AbortError") {
145
- // surface to console; the UI's existing error toast will fire too
146
- // eslint-disable-next-line no-console
147
- console.error("[live-mode] utterance handler failed:", e);
148
- }
149
- } finally {
150
- if (inflightAbortRef.current === abort) {
151
- inflightAbortRef.current = null;
152
- }
153
- }
154
- };
155
- recorderRef.current = rec;
156
  recordingRef.current = true;
 
157
  setRecording(true);
158
  onSpeechStartRef.current?.();
159
- rec.start();
160
  }, []);
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  // VAD loop β€” runs while `live` is true.
163
  const tickVAD = useCallback(() => {
164
  if (!analyserRef.current) return;
@@ -177,26 +242,33 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
177
  loud++;
178
  quiet = 0;
179
  if (loud === cfg.speechStartFrames && !recordingRef.current) {
180
- // Barge in: kill bot audio + cancel in-flight chat + start recording.
181
  interruptBotAudio();
182
  if (inflightAbortRef.current) {
183
  try { inflightAbortRef.current.abort(); } catch {}
184
  inflightAbortRef.current = null;
185
  }
186
- startRecording();
187
  }
188
  } else {
189
  quiet++;
190
  loud = 0;
191
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
192
- stopRecording();
193
  }
194
  }
195
 
196
  rafIdRef.current = requestAnimationFrame(loop);
197
  };
198
  rafIdRef.current = requestAnimationFrame(loop);
199
- }, [cfg.rmsThreshold, cfg.silenceEndFrames, cfg.speechStartFrames, interruptBotAudio, startRecording, stopRecording]);
 
 
 
 
 
 
 
200
 
201
  useEffect(() => {
202
  let cancelled = false;
@@ -206,7 +278,18 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
206
  cancelAnimationFrame(rafIdRef.current);
207
  rafIdRef.current = null;
208
  }
209
- stopRecording();
 
 
 
 
 
 
 
 
 
 
 
210
  if (streamRef.current) {
211
  streamRef.current.getTracks().forEach((t) => t.stop());
212
  streamRef.current = null;
@@ -242,6 +325,8 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
242
  window.AudioContext;
243
  const ctx = new AudioCtx();
244
  audioCtxRef.current = ctx;
 
 
245
  const source = ctx.createMediaStreamSource(stream);
246
  const analyser = ctx.createAnalyser();
247
  analyser.fftSize = 512;
@@ -249,6 +334,48 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
249
  source.connect(analyser);
250
  sourceRef.current = source;
251
  analyserRef.current = analyser;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  setMicPermissionDenied(false);
253
  tickVAD();
254
  } catch (e) {
@@ -263,7 +390,7 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
263
  cancelled = true;
264
  tearDown();
265
  };
266
- }, [live, stopRecording, tickVAD]);
267
 
268
  return {
269
  live,
 
3
  /**
4
  * useLiveConversation β€” full-duplex voice mode with barge-in.
5
  *
6
+ * KI-044 (2026-05-14) β€” PCM pre-roll via AudioWorklet.
7
+ * --------------------------------------------------------------------
8
+ * Previous implementation started a MediaRecorder ONLY when VAD declared
9
+ * "speech started" β€” by which point ~50-80 ms of the first word was
10
+ * already past the mic. Users reported the bot only hearing "ello" / "i
11
+ * am" rather than "hello" / "hi i am".
12
  *
13
+ * Current implementation:
14
+ * - Single getUserMedia stream + AudioContext stay open while Live is on.
15
+ * - An AudioWorkletNode taps the raw PCM from the source β€” every render
16
+ * quantum (128 samples) is posted back to the main thread as Float32.
17
+ * - The main thread keeps a circular preroll buffer (~300 ms / 4800
18
+ * samples at 16 kHz) when no utterance is in progress.
19
+ * - When VAD fires speech-start, the preroll is snapshotted into the
20
+ * active utterance buffer and subsequent samples are appended.
21
+ * - When VAD fires silence-end, we encode the full utterance (preroll +
22
+ * speech + small post-roll) as a 16-bit PCM WAV (Sarvam Saarika's
23
+ * native format) and post it to `onUtterance`.
24
+ * - VAD itself still runs off the AnalyserNode (separate path) so its
25
+ * sensitivity tuning is independent from the PCM capture rate.
26
  *
27
+ * Result: the user's first phoneme is in the blob. No more "ello".
28
+ *
29
+ * Push-to-talk path (page.tsx::startRecording) is unaffected β€” PTT
30
+ * recording starts when the user clicks, the input is already primed.
31
  */
32
 
33
  import { useCallback, useEffect, useRef, useState } from "react";
34
 
35
  export type LiveConversationOptions = {
 
36
  onUtterance: (blob: Blob, abort: AbortController) => Promise<void>;
 
37
  onSpeechStart?: () => void;
 
38
  onSpeechEnd?: () => void;
 
39
  rmsThreshold?: number;
 
40
  speechStartFrames?: number;
 
41
  silenceEndFrames?: number;
42
  };
43
 
 
46
  recording: boolean;
47
  micPermissionDenied: boolean;
48
  setLive: (v: boolean) => void;
 
49
  inflightAbortRef: React.MutableRefObject<AbortController | null>;
50
  };
51
 
52
  const DEFAULTS = {
53
+ // KI-041 β€” sensitivity bumped to catch normal conversational volume.
 
 
 
 
54
  rmsThreshold: 18,
55
+ // KI-043/044 β€” fire on first loud frame (~16 ms) so the preroll buffer
56
+ // snapshot captures as much pre-trigger audio as possible.
57
+ speechStartFrames: 1,
 
58
  silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
59
+ minUtteranceMs: 400,
60
+ // KI-044 β€” How much pre-trigger PCM we keep in the rolling buffer.
61
+ // 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
62
+ // user onset before the first detectable frame, with margin.
63
+ prerollMs: 300,
64
  };
65
 
66
+ // AudioWorklet processor source β€” inlined as a Blob URL so we don't need
67
+ // a separate static asset route. Runs on the audio thread; posts each
68
+ // 128-sample mono Float32Array back to the main thread.
69
+ const WORKLET_SOURCE = `
70
+ class PCMCaptureProcessor extends AudioWorkletProcessor {
71
+ process(inputs) {
72
+ const input = inputs[0];
73
+ if (input && input[0]) {
74
+ // Clone the buffer so it survives the transfer; the original is
75
+ // a view onto the audio thread's internal buffer.
76
+ this.port.postMessage(input[0].slice(0));
77
+ }
78
+ return true;
79
+ }
80
+ }
81
+ registerProcessor('pcm-capture', PCMCaptureProcessor);
82
+ `;
83
+
84
+ // Encode Float32 samples as a 16-bit PCM WAV file (mono). Returns a Blob
85
+ // suitable for `<input type=file>` upload to /api/transcribe.
86
+ function encodeWAV(samples: Float32Array, sampleRate: number): Blob {
87
+ const headerSize = 44;
88
+ const dataSize = samples.length * 2; // 16-bit
89
+ const buffer = new ArrayBuffer(headerSize + dataSize);
90
+ const view = new DataView(buffer);
91
+
92
+ const writeString = (offset: number, str: string) => {
93
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
94
+ };
95
+
96
+ writeString(0, "RIFF");
97
+ view.setUint32(4, 36 + dataSize, true);
98
+ writeString(8, "WAVE");
99
+ writeString(12, "fmt ");
100
+ view.setUint32(16, 16, true); // PCM chunk size
101
+ view.setUint16(20, 1, true); // PCM format
102
+ view.setUint16(22, 1, true); // mono
103
+ view.setUint32(24, sampleRate, true);
104
+ view.setUint32(28, sampleRate * 2, true); // byte rate
105
+ view.setUint16(32, 2, true); // block align
106
+ view.setUint16(34, 16, true); // bits per sample
107
+ writeString(36, "data");
108
+ view.setUint32(40, dataSize, true);
109
+
110
+ let offset = headerSize;
111
+ for (let i = 0; i < samples.length; i++, offset += 2) {
112
+ const s = Math.max(-1, Math.min(1, samples[i]));
113
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
114
+ }
115
+ return new Blob([buffer], { type: "audio/wav" });
116
+ }
117
+
118
  export function useLiveConversation(opts: LiveConversationOptions): LiveConversationState {
119
  const [live, setLive] = useState(false);
120
  const [recording, setRecording] = useState(false);
 
124
  const audioCtxRef = useRef<AudioContext | null>(null);
125
  const analyserRef = useRef<AnalyserNode | null>(null);
126
  const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
127
+ const workletRef = useRef<AudioWorkletNode | null>(null);
128
+ const workletUrlRef = useRef<string | null>(null);
129
+ const sampleRateRef = useRef<number>(48000);
130
+
131
+ // KI-044 β€” sample-level capture buffers
132
+ const prerollRef = useRef<Float32Array[]>([]);
133
+ const speechBufferRef = useRef<Float32Array[]>([]);
134
+
135
+ const recordingRef = useRef(false);
136
  const rafIdRef = useRef<number | null>(null);
137
  const inflightAbortRef = useRef<AbortController | null>(null);
138
+ const recStartTsRef = useRef<number>(0);
139
 
140
  const onUtteranceRef = useRef(opts.onUtterance);
141
  const onSpeechStartRef = useRef(opts.onSpeechStart);
 
150
  rmsThreshold: opts.rmsThreshold ?? DEFAULTS.rmsThreshold,
151
  speechStartFrames: opts.speechStartFrames ?? DEFAULTS.speechStartFrames,
152
  silenceEndFrames: opts.silenceEndFrames ?? DEFAULTS.silenceEndFrames,
153
+ minUtteranceMs: DEFAULTS.minUtteranceMs,
154
+ prerollMs: DEFAULTS.prerollMs,
155
  };
156
 
 
 
 
 
 
 
157
  const interruptBotAudio = useCallback(() => {
 
 
158
  if (typeof document !== "undefined") {
159
  document.querySelectorAll("audio").forEach((a) => {
160
  try {
161
  a.pause();
 
 
162
  a.currentTime = a.duration || 0;
163
  } catch {}
164
  });
165
  }
166
  }, []);
167
 
168
+ // KI-044 β€” open speech capture: snapshot the preroll into speechBuffer,
169
+ // flag recording, fire callbacks. The PCM keeps flowing via the worklet
170
+ // port; we just toggle where it lands.
171
+ const beginSpeechCapture = useCallback(() => {
172
+ speechBufferRef.current = [...prerollRef.current];
173
+ prerollRef.current = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  recordingRef.current = true;
175
+ recStartTsRef.current = Date.now();
176
  setRecording(true);
177
  onSpeechStartRef.current?.();
 
178
  }, []);
179
 
180
+ // KI-044 β€” close speech capture: encode WAV, run guards, fire onUtterance.
181
+ const endSpeechCapture = useCallback(async () => {
182
+ if (!recordingRef.current) return;
183
+ recordingRef.current = false;
184
+ setRecording(false);
185
+ const durationMs = Date.now() - (recStartTsRef.current || Date.now());
186
+ const chunks = speechBufferRef.current;
187
+ speechBufferRef.current = [];
188
+
189
+ if (chunks.length === 0) return;
190
+ if (durationMs < cfg.minUtteranceMs) {
191
+ // eslint-disable-next-line no-console
192
+ console.debug("[live-mode] dropped short utterance", durationMs, "ms");
193
+ return;
194
+ }
195
+
196
+ // Concatenate Float32Array chunks
197
+ let totalSamples = 0;
198
+ for (const c of chunks) totalSamples += c.length;
199
+ const merged = new Float32Array(totalSamples);
200
+ let offset = 0;
201
+ for (const c of chunks) {
202
+ merged.set(c, offset);
203
+ offset += c.length;
204
+ }
205
+
206
+ const wav = encodeWAV(merged, sampleRateRef.current);
207
+ if (wav.size < 3000) return; // floor (matches prior heuristic)
208
+
209
+ onSpeechEndRef.current?.();
210
+ const abort = new AbortController();
211
+ inflightAbortRef.current = abort;
212
+ try {
213
+ await onUtteranceRef.current(wav, abort);
214
+ } catch (e) {
215
+ const name = (e as { name?: string })?.name;
216
+ if (name !== "AbortError") {
217
+ // eslint-disable-next-line no-console
218
+ console.error("[live-mode] utterance handler failed:", e);
219
+ }
220
+ } finally {
221
+ if (inflightAbortRef.current === abort) {
222
+ inflightAbortRef.current = null;
223
+ }
224
+ }
225
+ }, [cfg.minUtteranceMs]);
226
+
227
  // VAD loop β€” runs while `live` is true.
228
  const tickVAD = useCallback(() => {
229
  if (!analyserRef.current) return;
 
242
  loud++;
243
  quiet = 0;
244
  if (loud === cfg.speechStartFrames && !recordingRef.current) {
245
+ // Barge in: kill bot audio + cancel in-flight chat + begin capture.
246
  interruptBotAudio();
247
  if (inflightAbortRef.current) {
248
  try { inflightAbortRef.current.abort(); } catch {}
249
  inflightAbortRef.current = null;
250
  }
251
+ beginSpeechCapture();
252
  }
253
  } else {
254
  quiet++;
255
  loud = 0;
256
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
257
+ void endSpeechCapture();
258
  }
259
  }
260
 
261
  rafIdRef.current = requestAnimationFrame(loop);
262
  };
263
  rafIdRef.current = requestAnimationFrame(loop);
264
+ }, [
265
+ cfg.rmsThreshold,
266
+ cfg.silenceEndFrames,
267
+ cfg.speechStartFrames,
268
+ interruptBotAudio,
269
+ beginSpeechCapture,
270
+ endSpeechCapture,
271
+ ]);
272
 
273
  useEffect(() => {
274
  let cancelled = false;
 
278
  cancelAnimationFrame(rafIdRef.current);
279
  rafIdRef.current = null;
280
  }
281
+ // Drop any pending capture without firing onUtterance
282
+ recordingRef.current = false;
283
+ speechBufferRef.current = [];
284
+ prerollRef.current = [];
285
+ if (workletRef.current) {
286
+ try { workletRef.current.disconnect(); } catch {}
287
+ workletRef.current = null;
288
+ }
289
+ if (workletUrlRef.current) {
290
+ try { URL.revokeObjectURL(workletUrlRef.current); } catch {}
291
+ workletUrlRef.current = null;
292
+ }
293
  if (streamRef.current) {
294
  streamRef.current.getTracks().forEach((t) => t.stop());
295
  streamRef.current = null;
 
325
  window.AudioContext;
326
  const ctx = new AudioCtx();
327
  audioCtxRef.current = ctx;
328
+ sampleRateRef.current = ctx.sampleRate;
329
+
330
  const source = ctx.createMediaStreamSource(stream);
331
  const analyser = ctx.createAnalyser();
332
  analyser.fftSize = 512;
 
334
  source.connect(analyser);
335
  sourceRef.current = source;
336
  analyserRef.current = analyser;
337
+
338
+ // KI-044 β€” register the inline PCM-capture worklet + tap the source.
339
+ const blob = new Blob([WORKLET_SOURCE], { type: "application/javascript" });
340
+ const url = URL.createObjectURL(blob);
341
+ workletUrlRef.current = url;
342
+ try {
343
+ await ctx.audioWorklet.addModule(url);
344
+ const node = new AudioWorkletNode(ctx, "pcm-capture");
345
+ workletRef.current = node;
346
+
347
+ const prerollSamplesCap = Math.ceil((cfg.prerollMs / 1000) * ctx.sampleRate);
348
+
349
+ node.port.onmessage = (ev: MessageEvent<Float32Array>) => {
350
+ const chunk = ev.data;
351
+ if (recordingRef.current) {
352
+ speechBufferRef.current.push(chunk);
353
+ } else {
354
+ prerollRef.current.push(chunk);
355
+ // Trim oldest chunks to keep total length under prerollSamplesCap.
356
+ let total = 0;
357
+ for (const c of prerollRef.current) total += c.length;
358
+ while (total > prerollSamplesCap && prerollRef.current.length > 1) {
359
+ total -= prerollRef.current[0].length;
360
+ prerollRef.current.shift();
361
+ }
362
+ }
363
+ };
364
+
365
+ source.connect(node);
366
+ // Worklet's process() only runs while the node is connected to a
367
+ // destination (directly or via the graph). But we DON'T want the
368
+ // user's mic playing back through speakers β€” route via a zero-gain
369
+ // GainNode so the graph stays "live" but output is silent.
370
+ const silentSink = ctx.createGain();
371
+ silentSink.gain.value = 0;
372
+ node.connect(silentSink);
373
+ silentSink.connect(ctx.destination);
374
+ } catch (e) {
375
+ // eslint-disable-next-line no-console
376
+ console.error("[live-mode] AudioWorklet setup failed; fallback to silence", e);
377
+ }
378
+
379
  setMicPermissionDenied(false);
380
  tickVAD();
381
  } catch (e) {
 
390
  cancelled = true;
391
  tearDown();
392
  };
393
+ }, [live, tickVAD, cfg.prerollMs]);
394
 
395
  return {
396
  live,
rag/README.md ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `rag/` β€” Retrieval pipeline (corpus β†’ extraction β†’ embeddings β†’ Chroma)
2
+
3
+ The end-to-end RAG pipeline: download insurer PDFs, parse + chunk, embed with local BGE-small, persist to Chroma, run structured extraction with a Pydantic schema, and serve top-k retrieval at query time.
4
+
5
+ Lineage for every artefact below is documented in [`kb/AUDIT_TRAIL.md`](../kb/AUDIT_TRAIL.md).
6
+
7
+ ## Build-time scripts
8
+
9
+ | File | Pipeline stage | What it produces |
10
+ | --- | --- | --- |
11
+ | `download_corpus.py` | 1. SOURCE β†’ 3. DOWNLOAD | `rag/corpus/<insurer>/*.pdf` + `_manifest.json`. HEAD-checks + magic-byte sniff + retry on 403/timeout. |
12
+ | `download_retry.py` | 3. DOWNLOAD | Retries the failures from the previous run. |
13
+ | `download_regulatory.py` | 3. DOWNLOAD | IRDAI / regulatory PDFs (deferred from v1; see [ADR-017](../docs/60-decisions/ADR-017-irdai-corpus-playwright-rescue.md)). |
14
+ | `ingest.py` | 4. PARSE β†’ 5. CHUNK β†’ 6. EMBED β†’ 7. INDEX | The big one. `read_pdf_pages` (pdfplumber) β†’ `chunk_pages` (800-tok / 120 overlap, sentence-aware) β†’ BGE embed β†’ `chromadb.PersistentClient.add(...)`. Carries the in-process HNSW bloat tripwire ([ADR-029](../docs/60-decisions/ADR-029-disk-storage-hardening.md)). |
15
+ | `extract.py` | 8. STRUCTURED EXTRACTION | LLM extraction over each PDF using `schema.py::HealthPolicy` (62 fields). Sarvam-M primary β†’ DeepSeek-V3 fallback. Writes `rag/extracted/<policy_id>.json` + upserts `policies.duckdb`. |
16
+ | `build_kb.py` | 9. SCORECARD β†’ KB MIRROR | Runs `backend/scorecard.py` per policy and regenerates the human-readable `kb/policies/<id>.md` tree. |
17
+ | `source_map.py` | post-build | Builds `source_map.json` β€” every chunk β†’ (PDF path, page, span) for citation rendering. |
18
+
19
+ ## Runtime modules
20
+
21
+ | File | Stage | Notes |
22
+ | --- | --- | --- |
23
+ | `retrieve.py` | 10. RETRIEVAL | Top-k Chroma query with policy-id / insurer-slug filters. In-process LRU cache (cap 256) keyed by `(query_norm, top_k, sorted policy_ids, sorted insurer_slugs)`. |
24
+ | `schema.py` | 8. STRUCTURED EXTRACTION | The 62-field `HealthPolicy` Pydantic schema β€” single source of truth for the extracted JSON shape. See `rag/SCHEMA.md` for the field-by-field doc. |
25
+
26
+ ## Persistent artefacts
27
+
28
+ | Path | Source of truth | Notes |
29
+ | --- | --- | --- |
30
+ | `rag/corpus/<insurer>/*.pdf` | insurer CDNs | 208 PDFs across 19 insurers + IRDAI. Not in git β€” hydrated at Docker build from the companion HF dataset. |
31
+ | `rag/extracted/<policy_id>.json` | `extract.py` | 203 JSONs, one per policy, conforming to `schema.HealthPolicy`. Generated; never hand-edit. |
32
+ | `rag/vectors/chroma.sqlite3` + HNSW binaries | `ingest.py` | Persistent Chroma store. Symlinked to `rag/_hf_dataset_backup/rag/vectors/` for the offline canonical copy. |
33
+ | `rag/policies.duckdb` | `extract.py` | DuckDB rollup of the 62-field JSONs; used for SQL-style filters in `backend/main.py`. |
34
+ | `rag/source_map.json` | `source_map.py` | chunk_id β†’ (pdf_path, page, span) for the citation links shown in the UI. |
35
+ | `rag/SCHEMA.md` | hand-written | Field-by-field documentation of the Pydantic schema. |
36
+
37
+ ## Subdirectories
38
+
39
+ - `corpus/` β€” raw PDFs (one folder per insurer slug). Generated by `download_corpus.py`. Per-PDF folders intentionally do not carry READMEs.
40
+ - `extracted/` β€” 62-field JSONs. Auto-generated by `extract.py`. Do not edit by hand.
41
+ - `vectors/` β€” Chroma persistent store. Treat as opaque β€” re-build with `python -m rag.ingest` if corrupted.
42
+ - `_hf_dataset_backup/rag/{corpus,extracted,vectors}/` β€” offline canonical mirror of the companion HF Dataset (`rohitsar567/insurance-bot-data`). See [ADR-020](../docs/60-decisions/ADR-020-code-data-split-hf-dataset.md).
43
+
44
+ ## Cold-rebuild
45
+
46
+ ```bash
47
+ python -m rag.download_corpus
48
+ python -m rag.download_retry
49
+ python -m rag.extract
50
+ rm -rf rag/vectors
51
+ python -m rag.ingest
52
+ python -m rag.build_kb
53
+ python -m eval.generate_gold
54
+ python -m eval.run
55
+ ```
56
+
57
+ Total cost from cold: < $2 (BGE local + ~80 LLM extractions). Wall-time: ~30-40 min on a modern laptop.
58
+
59
+ ## Related
60
+
61
+ - [ADR-004](../docs/60-decisions/ADR-004-hybrid-structured-vector.md) β€” hybrid structured + vector retrieval rationale
62
+ - [ADR-011](../docs/60-decisions/ADR-011-bge-local-embeddings.md) β€” why local BGE replaced Voyage
63
+ - [ADR-018](../docs/60-decisions/ADR-018-chunk-size-sweep-deferred.md) β€” 800/120 chunk-size baseline
64
+ - [ADR-020](../docs/60-decisions/ADR-020-code-data-split-hf-dataset.md) β€” code-vs-data repo split
65
+ - [ADR-029](../docs/60-decisions/ADR-029-disk-storage-hardening.md) β€” HNSW bloat tripwire (3-layer defence)
66
+ - [`kb/AUDIT_TRAIL.md`](../kb/AUDIT_TRAIL.md) β€” end-to-end lineage doc
tests/README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `tests/` β€” Unit + live-verification tests
2
+
3
+ Deliberately small. The bulk of behavioural quality lives in `eval/` (gold-QA accuracy) and `tools/audit/` (multi-persona stress). This folder pins the **invariants** β€” the specific bugs we have ever shipped and never want back.
4
+
5
+ ## Files
6
+
7
+ | File | Role |
8
+ | --- | --- |
9
+ | `test_routing_regression.py` | 15 `unittest` cases pinning the KI-018 / KI-023 / KI-025 fixes β€” see "Routing invariants" in the root `CLAUDE.md`. Includes `TestProviderLoadBalancing` which asserts the 50/50 NIM ↔ Groq split holds over 1000 seeded calls ([ADR-026](../docs/60-decisions/ADR-026-provider-load-balancing.md)). |
10
+ | `live_verify.py` | End-to-end production drift detector. Hits the **deployed** API with a 20-Q gold subset and asserts HTTP 200, non-empty `reply_text`, β‰₯1 citation, faithfulness pass, and Doc-01 latency budget (p95 ≀ 7000ms). Writes `tests/live_results_<ts>.md`. Cron-able for nightly. |
11
+
12
+ ## What each test pins
13
+
14
+ | KI / ADR | Assertion | Why it matters |
15
+ | --- | --- | --- |
16
+ | KI-018 (D-003) | `classify_intent("What is the waiting period for PED in Activ Assure?")` returns `"qa"` and `should_route_to_fact_find` returns `False` on empty profile. | Headline 30% gold-QA accuracy bug β€” direct QA was force-routed to fact-find. |
17
+ | KI-018 | `CONTEXT_DEPENDENT_INTENTS = {"recommendation", "comparison"}` β€” no `"qa"`. | Adding `"qa"` re-introduces the headline bug. |
18
+ | KI-023 | `FACT_FIND_TRIGGERS` uses word-boundary regex, not substring. | Stops `"hi"` firing on `"which"` / `"this"` / `"high"`. |
19
+ | ADR-026 / KI-025 | `_balanced_brain_chain(..., groq_first_probability=0.5)` lands Groq-primary between 400 and 600 of 1000 seeded calls. | Catches the shared-counter pathology where every brain call lands on one provider. |
20
+
21
+ ## Running
22
+
23
+ ```bash
24
+ # Unit (in-process, no API)
25
+ .venv/bin/python -m unittest tests.test_routing_regression -v
26
+
27
+ # Live (against deployed HF Space)
28
+ python tests/live_verify.py
29
+
30
+ # Live (against any other deploy)
31
+ TARGET_URL=http://localhost:8000 python tests/live_verify.py
32
+ ```
33
+
34
+ ## Related
35
+
36
+ - Root `CLAUDE.md` Β§ Routing invariants β€” the four lines this folder protects
37
+ - `audit_results/ENTERPRISE_AUDIT.md` D-003 β€” full incident report for KI-018
38
+ - [ADR-026](../docs/60-decisions/ADR-026-provider-load-balancing.md) β€” load-balance behaviour pinned by `TestProviderLoadBalancing`
39
+ - `eval/run.py` β€” the broader 96-Q accuracy eval (different surface, same underlying orchestrator)
tools/README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `tools/` β€” Operational scripts
2
+
3
+ Loose collection of CLI scripts: corpus operations, data uploads, probes, KB regeneration, scheduled-job runners. Nothing under `tools/` is imported by the live server β€” `backend/` and `rag/` are the runtime surface.
4
+
5
+ Scheduling for the long-running ones is wired via macOS LaunchAgents β€” see `CRON_README.md` in this folder for cadence + script paths, and [ADR-029](../docs/60-decisions/ADR-029-disk-storage-hardening.md) for the disk-safety LaunchAgents.
6
+
7
+ ## Corpus + extraction batch ops
8
+
9
+ | Script | Purpose |
10
+ | --- | --- |
11
+ | `extract_all_corpus.py`, `extract_batch_5.py`, `extract_failed.py`, `extract_pdf_range.py`, `reextract_all.py` | Batch re-extractions over `rag/corpus/`. Useful when the schema or extraction prompt changes. |
12
+ | `extract_pdf_text.py`, `extract_policy_text.py`, `extract_policy_text_batch2.py` | Raw text dumps for manual inspection / regex curation. |
13
+ | `curate_batch2.py`, `curate_remaining.py`, `clear_batch2.py` | Verbatim-quote curation passes that produced `data/policy_facts/`. See [`data/policy_facts/_curation_report.md`](../data/policy_facts/_curation_report.md). |
14
+ | `generate_policy_facts.py` | Convert extraction outputs to the `data/policy_facts/<id>.json` shape with `{value, unit, source_pdf_path, source_quote}` provenance. |
15
+ | `pydantic_validate_batch_5.py`, `validate_batch_5.py`, `validate_json.py`, `validate_schema.py` | Schema validators for the 62-field `HealthPolicy`. |
16
+ | `count_fields.py` | Per-policy completeness scorer that feeds the `kb/INDEX.md` completeness % column. |
17
+
18
+ ## Source-map + verification
19
+
20
+ | Script | Purpose |
21
+ | --- | --- |
22
+ | `info_source_map.py` | Builds `eval/info_source_map.json` + `data/information_source_map.md` β€” claim β†’ URL β†’ verdict (βœ… / ⚠️ / ❌ / ⏳). The canonical KPI for source-grounding quality. |
23
+ | `verify_urls.py` | HEAD-checks every URL in the corpus / facts; writes `eval/verified_urls.json`. |
24
+ | `verify_review_urls.py`, `verify_new_corpus.py` | Sub-verifiers for the reviews dataset and freshly-added corpus URLs. |
25
+ | `browser_verify.py` | Playwright-backed verifier for URLs that block HEAD requests. Output: `tools/browser_verified.json`. |
26
+ | `check_link_rot.py`, `check_pdf_etags.py` | LaunchAgent-driven freshness checks β€” corpus URL rot + PDF eTag drift. |
27
+ | `refresh_premiums.py` | LaunchAgent-driven refresh of `data/premiums/illustrative_premiums.json`. |
28
+
29
+ ## KB + dataset builders
30
+
31
+ | Script | Purpose |
32
+ | --- | --- |
33
+ | `build_kb_mirror.py` | Regenerates the entire `kb/policies/<id>.md` tree from `data/policy_facts/`. Idempotent. |
34
+ | `ingest_kb_summaries.py` | Ingests `kb/policies/*.md` summaries into Chroma so policy meta is retrievable. Carries the HNSW bloat tripwire. |
35
+ | `ingest_reviews.py` | Ingests `data/reviews/<insurer>.json` into Chroma. Carries the HNSW bloat tripwire. |
36
+ | `build_readme_pdf.py` | Renders the master `README.md` to PDF for offline review. |
37
+
38
+ ## HF Hub uploads (data-side mirror)
39
+
40
+ | Script | Target |
41
+ | --- | --- |
42
+ | `upload_to_hf.py` | Code-side push to the HF Space repo (`huggingface.co/spaces/rohitsar567/InsuranceBot`). |
43
+ | `upload_corpus_to_dataset.py`, `upload_extracted_to_dataset.py`, `upload_vectors_to_dataset.py`, `upload_all_to_dataset.py` | Push specific slices of `rag/` to the companion HF Dataset `rohitsar567/insurance-bot-data`. See [ADR-020](../docs/60-decisions/ADR-020-code-data-split-hf-dataset.md) and [ADR-024](../docs/60-decisions/ADR-024-triple-mirror-code-and-data.md). |
44
+ | `set_hf_secrets.py` | One-shot helper that pushes the runtime secrets into the HF Space (idempotent). |
45
+
46
+ ## Probes + diagnostics
47
+
48
+ | Script | Provider it pokes |
49
+ | --- | --- |
50
+ | `sarvam_probe.py`, `sarvam_nothink_probe.py` | Sarvam-M / Saarika / Bulbul connectivity + latency. |
51
+ | `groq_probe.py`, `groq_long_probe.py` | Groq Llama free-tier latency + sustained-rate test. |
52
+ | `openrouter_probe.py`, `or_models.py` | OpenRouter routing + model-list inspection. |
53
+ | `pdf_probe.py` | pdfplumber parse on a single PDF β€” first stop when extraction silently produces empty text. |
54
+ | `heavy_smoke_test.py` | End-to-end smoke against the live HF Space (every provider in one call). |
55
+
56
+ ## Chunk-size & retrieval sweeps
57
+
58
+ | Script | Purpose |
59
+ | --- | --- |
60
+ | `chunk_sweep.py`, `chunk_sweep_diagnostic.py` | Grid-search over chunk size / overlap. Output: `eval/chunk_sweep_results.json`. See [ADR-018](../docs/60-decisions/ADR-018-chunk-size-sweep-deferred.md). |
61
+ | `sweep_retrieval.py` | Retrieval-strategy A/B (filter vs no-filter, top-k variants). |
62
+
63
+ ## Scheduled jobs / shell wrappers
64
+
65
+ | Path | Purpose |
66
+ | --- | --- |
67
+ | `install_crons.sh`, `CRON_README.md` | Install the LaunchAgents; the README is the canonical cadence + path reference. |
68
+ | `install_git_hooks.sh`, `git-hooks/` | Pre-commit hooks (decimal grep, secret scan, schema validation). |
69
+ | `full_pipeline.sh`, `pipeline_finish_all.sh`, `post_extract_deploy.sh`, `reextract_then_deploy.sh`, `quarterly_rebuild.sh` | Multi-step orchestrations (download β†’ extract β†’ ingest β†’ push β†’ smoke). |
70
+ | `reconcile_manifest.py` | Drift check between `rag/corpus/_manifest.json` and what's actually on disk. |
71
+
72
+ ## Subdirectory
73
+
74
+ `audit/` β€” multi-persona conversational audit framework. See `tools/audit/README.md`.
75
+
76
+ ## Related
77
+
78
+ - `CRON_README.md` (this folder) β€” LaunchAgent cadence reference
79
+ - [ADR-020](../docs/60-decisions/ADR-020-code-data-split-hf-dataset.md), [ADR-024](../docs/60-decisions/ADR-024-triple-mirror-code-and-data.md), [ADR-029](../docs/60-decisions/ADR-029-disk-storage-hardening.md)
80
+ - `audit_results/ENTERPRISE_AUDIT.md` β€” defect register, including silent-LaunchAgent regressions (D-002)
tools/audit/README.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `tools/audit/` β€” Multi-persona conversational audit
2
+
3
+ End-to-end conversational stress test: walks 100 distinct personas through 30-turn flows against the live API, captures every turn's reply / latency / faithfulness verdict / blocked status, and rolls up to a defect-counting report.
4
+
5
+ This is the framework that surfaced the headline KI-018 (QA→fact-find misrouting) and KI-021 (latency p95 blow-out) defects in the readiness audit.
6
+
7
+ ## Files
8
+
9
+ | File | Role |
10
+ | --- | --- |
11
+ | `run_audit.py` | Entry point. Walks each persona Γ— 30 turns against the live `/api/chat`. Resumable (per-persona transcripts land as they finish). Concurrent (`--workers W`) but rate-aware β€” global NIM 40 req/min cap enforced via per-request sleep. Retries 5xx with exponential backoff. |
12
+ | `personas.py` | Generator: 10 archetypes Γ— 10 demographic profiles Γ— 1 deterministic style = **100 unique personas**. Stable order = stable persona IDs across runs, so diffs are regressions not shuffle noise. Run as a script to (re)generate `personas.json`. |
13
+ | `personas.json` | Materialised 100-persona list. Stable input to `run_audit.py`. |
14
+ | `flows.py` | Generator: per persona produces a 30-turn user-text sequence in 5 phases β€” opening (1) Β· fact-find answers (9) Β· free-form Qs (10) Β· edge-case probes (5) Β· adversarial + close (5). |
15
+ | `flows.json` | Materialised flows. `dict[persona_id, list[str]]` of the 30 turns each persona sends. |
16
+ | `analyze.py` | Post-run aggregator: reads `audit_results/<run_id>/transcripts/*.json`, computes per-archetype / per-language / per-style breakdowns of faithfulness, blocked rate, p95 latency. Emits `report.md` + `summary.json` into the run dir. |
17
+
18
+ ## Output layout
19
+
20
+ ```
21
+ audit_results/<run_id>/
22
+ β”œβ”€β”€ transcripts/
23
+ β”‚ β”œβ”€β”€ P001.json (complete persona)
24
+ β”‚ β”œβ”€β”€ P002.json
25
+ β”‚ β”œβ”€β”€ P003.partial.json (in-flight or interrupted)
26
+ β”‚ └── …
27
+ β”œβ”€β”€ report.md (analyze.py output β€” defect breakdown)
28
+ └── summary.json (machine-readable rollup)
29
+ ```
30
+
31
+ `<run_id>` convention is `full_YYYYMMDD_HHMMSS` for the full 100-persona pass and `postfix_YYYYMMDD_HHMMSS` for a post-fix re-run targeting a specific defect.
32
+
33
+ ## Typical run
34
+
35
+ ```bash
36
+ # Full audit against the live HF Space
37
+ python tools/audit/run_audit.py --workers 4
38
+
39
+ # Smoke (5 personas) for a config change
40
+ python tools/audit/run_audit.py --max-personas 5 --base http://localhost:8000
41
+
42
+ # Aggregate after
43
+ python tools/audit/analyze.py audit_results/full_20260514_145243/
44
+ ```
45
+
46
+ ## Watch-outs
47
+
48
+ - **HF Space rebuild is 5-8 min.** Don't start an audit until the desired image is stably deployed, or transcripts span multiple builds and become useless for A/B.
49
+ - **The 40 req/min NIM cap is global.** Bumping `--workers` past 4 will not help β€” the per-request sleep clamps dispatch rate.
50
+ - **Personas are stable by index.** If you change the `ARCHETYPES` / demographic lists, P037 is no longer the same person β€” call it out in the run notes.
51
+
52
+ ## Related
53
+
54
+ - `audit_results/ENTERPRISE_AUDIT.md` β€” defect register fed by audit output
55
+ - `audit_results/README.md` β€” output-folder layout reference
56
+ - Root `CLAUDE.md` Β§ Routing invariants β€” the KI-018 / KI-023 regressions the audit catches