Spaces:
Running
feat: KI-044 PCM pre-roll via AudioWorklet + 11 folder READMEs
Browse filesKI-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 +67 -0
- backend/README.md +45 -0
- backend/providers/README.md +40 -0
- data/README.md +45 -0
- data/profiles/README.md +67 -0
- eval/README.md +54 -0
- frontend/README.md +37 -23
- frontend/src/lib/useLiveConversation.ts +218 -91
- rag/README.md +66 -0
- tests/README.md +39 -0
- tools/README.md +80 -0
- tools/audit/README.md +56 -0
|
@@ -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
|
|
@@ -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`
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -1,36 +1,50 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
npm run dev
|
| 9 |
-
# or
|
| 10 |
-
yarn dev
|
| 11 |
-
# or
|
| 12 |
-
pnpm dev
|
| 13 |
-
# or
|
| 14 |
-
bun dev
|
| 15 |
-
```
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
##
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -3,39 +3,41 @@
|
|
| 3 |
/**
|
| 4 |
* useLiveConversation β full-duplex voice mode with barge-in.
|
| 5 |
*
|
| 6 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
*
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
* -
|
| 11 |
-
*
|
| 12 |
-
* -
|
| 13 |
-
*
|
| 14 |
-
*
|
| 15 |
-
*
|
| 16 |
-
*
|
| 17 |
-
*
|
| 18 |
-
*
|
| 19 |
-
*
|
|
|
|
| 20 |
*
|
| 21 |
-
*
|
| 22 |
-
*
|
|
|
|
|
|
|
| 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
|
| 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 |
-
//
|
| 59 |
-
//
|
| 60 |
-
|
| 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
|
| 75 |
-
const
|
| 76 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 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 +
|
| 181 |
interruptBotAudio();
|
| 182 |
if (inflightAbortRef.current) {
|
| 183 |
try { inflightAbortRef.current.abort(); } catch {}
|
| 184 |
inflightAbortRef.current = null;
|
| 185 |
}
|
| 186 |
-
|
| 187 |
}
|
| 188 |
} else {
|
| 189 |
quiet++;
|
| 190 |
loud = 0;
|
| 191 |
if (quiet === cfg.silenceEndFrames && recordingRef.current) {
|
| 192 |
-
|
| 193 |
}
|
| 194 |
}
|
| 195 |
|
| 196 |
rafIdRef.current = requestAnimationFrame(loop);
|
| 197 |
};
|
| 198 |
rafIdRef.current = requestAnimationFrame(loop);
|
| 199 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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,
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|