rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
171f2a4
Β·
1 Parent(s): 3dab53c

fix(fact-find): keyword fast-path + re-ask cap (KI-011 critical)

Browse files

CRITICAL bug discovered via the 100-persona audit on the first persona:
under audit concurrency, NIM rate-limit causes the Llama-3.3-70B normalizer
LLM call to fail. Normalizer returns None. Orchestrator KEEPS
awaiting_question_id set. Bot re-asks the SAME question. User answers
something β€” but it's an answer to the NEXT question we never moved on
to. Normalizer fails again. Bot re-asks again. Infinite loop.

Concretely on persona P002 (verbose style): turn 7 user said "so
basically, bangalore, let me know what you think" β†’ location normalizer
LLM call hit NIM rate limit β†’ returned None β†’ reask_clarify. Then turns
8-20+ all received reask_clarify response to "Which city?" while the
persona moved on with answers to dependents/income/etc. Bot stayed stuck
asking about city forever.

Fix layered in two places:

[A] backend/fact_find_normalizer.py β€” KEYWORD FAST PATH
New `_keyword_normalize()` function tries deterministic substring
matches BEFORE the LLM. Hand-curated patterns cover ~80% of common
answers:
- dependents: spouse/wife/kids/parents combinations β†’ enum
- income_band: "around 8 lakh", "more than 25" β†’ enum
- primary_goal: "first policy", "compare", "tax planning" β†’ enum
- location: 9 metros + 15 tier1 + 14 tier2 cities by name β†’ tier
- budget: numeric "k" suffixes β†’ band
- health_conditions: keyword extraction from "diabetes, BP" β†’ list
LLM is fallback only for nuanced phrasing. Zero NIM calls for common
answers means audit concurrency can't break the fact-find flow.

[B] backend/orchestrator.py β€” RE-ASK CAP
Session now tracks _reask_counts[question_id]. After 2 failed
normalizations on the SAME question, the orchestrator gives up:
marks question as asked, clears awaiting_question_id, lets
next_question() move on. Better to have an incomplete profile than
an infinite re-ask trap.

[C] docs/40-evaluation/known-issues.md β€” 10-issue Quality Sprint Log
Documents 10 P0/P1/P2 issues found in the same code-review sweep
(faithfulness fail-open, session disk swallows, translator silent
fall-throughs, etc.) with fix plans. KI-011 is the one this commit
closes; others are tracked for future sprints.

Unit-tested: 14/14 keyword-path cases pass including the exact P002
verbose-style input "so basically, bangalore, let me know what you think".

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

backend/fact_find_normalizer.py CHANGED
@@ -112,7 +112,6 @@ async def normalize_answer(question_id: str, raw_text: str) -> Any:
112
 
113
  schema = _FIELD_SCHEMA.get(question_id)
114
  if schema is None:
115
- # Unknown question id β€” defensive pass-through
116
  return raw_text.strip() or None
117
 
118
  # Fast paths β€” no LLM needed for plain integers / cover-amount parsing.
@@ -121,10 +120,122 @@ async def normalize_answer(question_id: str, raw_text: str) -> Any:
121
  if question_id == "existing_cover":
122
  return _parse_existing_cover(raw_text)
123
 
 
 
 
 
 
 
 
 
 
 
124
  # Enum + list fields β€” let the LLM map natural language to canonical value.
125
  return await _llm_normalize(question_id, raw_text, schema)
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # ----------------------------------------------------------------------------
129
  # Fast-path parsers (no LLM)
130
  # ----------------------------------------------------------------------------
 
112
 
113
  schema = _FIELD_SCHEMA.get(question_id)
114
  if schema is None:
 
115
  return raw_text.strip() or None
116
 
117
  # Fast paths β€” no LLM needed for plain integers / cover-amount parsing.
 
120
  if question_id == "existing_cover":
121
  return _parse_existing_cover(raw_text)
122
 
123
+ # KEYWORD FAST PATH β€” robust to NIM rate-limit and to LLM hiccups.
124
+ # Try matching common patterns BEFORE the LLM call. Catches ~80% of
125
+ # answers without consuming a NIM request and is deterministic under
126
+ # load. The LLM is the fall-back for nuanced/edge phrasings.
127
+ kw = _keyword_normalize(question_id, raw_text)
128
+ if kw is not None:
129
+ validated = _validate(kw, schema)
130
+ if validated is not None:
131
+ return validated
132
+
133
  # Enum + list fields β€” let the LLM map natural language to canonical value.
134
  return await _llm_normalize(question_id, raw_text, schema)
135
 
136
 
137
+ # ----------------------------------------------------------------------------
138
+ # Keyword fast-path β€” hand-curated common phrasing β†’ schema value.
139
+ # Order matters within each field: more-specific patterns first.
140
+ # Case-insensitive substring matches on a normalized version of the text.
141
+ # ----------------------------------------------------------------------------
142
+
143
+ def _keyword_normalize(question_id: str, raw_text: str) -> Any:
144
+ s = raw_text.lower()
145
+
146
+ if question_id == "dependents":
147
+ if any(k in s for k in ["spouse", "wife", "husband"]) and "kid" in s and "parent" in s:
148
+ return "self+spouse+kids+parents"
149
+ if any(k in s for k in ["spouse", "wife", "husband"]) and "kid" in s:
150
+ return "self+spouse+kids"
151
+ if any(k in s for k in ["spouse", "wife", "husband"]) and "parent" in s:
152
+ return "self+spouse+kids+parents"
153
+ if any(k in s for k in ["spouse", "wife", "husband"]):
154
+ return "self+spouse"
155
+ if "parent" in s and "no" not in s.split():
156
+ return "self+parents"
157
+ if "just me" in s or "only me" in s or "myself" in s or "only self" in s or s.strip() in {"me", "self"}:
158
+ return "self"
159
+
160
+ elif question_id == "income_band":
161
+ import re as _re
162
+ if _re.search(r"(more than|above|over|>=?|>)\s*25", s) or "25l+" in s or "25 lakh+" in s:
163
+ return "25L+"
164
+ m = _re.search(r"(\d+(?:\.\d+)?)\s*(?:l|lakh|lac)", s)
165
+ if m:
166
+ val = float(m.group(1))
167
+ if val >= 25: return "25L+"
168
+ if val >= 10: return "10L-25L"
169
+ if val >= 5: return "5L-10L"
170
+ return "under_5L"
171
+ if "10-25" in s or "10l-25l" in s: return "10L-25L"
172
+ if "5-10" in s or "5l-10l" in s: return "5L-10L"
173
+ if "under 5" in s or "<5" in s or "below 5" in s: return "under_5L"
174
+
175
+ elif question_id == "primary_goal":
176
+ if any(k in s for k in ["first policy", "first one", "first time", "first buy", "new policy", "buying my first"]):
177
+ return "first_buy"
178
+ if any(k in s for k in ["upgrade", "upgrading", "better cover", "more cover", "increase cover"]):
179
+ return "upgrade"
180
+ if any(k in s for k in ["compare", "comparison", " vs ", " vs.", "versus"]):
181
+ return "compare_specific"
182
+ if any(k in s for k in [" tax ", "80d", "deduction", "tax planning"]):
183
+ return "tax_planning"
184
+
185
+ elif question_id == "location":
186
+ metro = ["bangalore", "bengaluru", "mumbai", "delhi", "new delhi", "chennai", "kolkata", "hyderabad", "pune"]
187
+ tier1 = ["ahmedabad", "jaipur", "lucknow", "kanpur", "nagpur", "indore", "thane", "bhopal", "visakhapatnam", "patna", "vadodara", "ghaziabad", "ludhiana", "agra", "nashik"]
188
+ tier2 = ["surat", "kochi", "trivandrum", "thiruvananthapuram", "coimbatore", "vijayawada", "madurai", "rajkot", "ranchi", "amritsar", "allahabad", "prayagraj", "jodhpur", "raipur"]
189
+ for c in metro:
190
+ if c in s: return "metro"
191
+ for c in tier1:
192
+ if c in s: return "tier1"
193
+ for c in tier2:
194
+ if c in s: return "tier2"
195
+ if "metro" in s: return "metro"
196
+ if "tier 1" in s or "tier1" in s: return "tier1"
197
+ if "tier 2" in s or "tier2" in s: return "tier2"
198
+ if "tier 3" in s or "tier3" in s or "village" in s or "small town" in s: return "tier3"
199
+
200
+ elif question_id == "budget":
201
+ import re as _re
202
+ if "60k+" in s or ">60k" in s or "more than 60" in s or "above 60" in s:
203
+ return "60k+"
204
+ if "30-60" in s or "30k_60k" in s or "30k-60k" in s:
205
+ return "30k_60k"
206
+ if "15-30" in s or "15k_30k" in s or "15k-30k" in s:
207
+ return "15k_30k"
208
+ if "under 15" in s or "<15" in s or "below 15" in s or "under_15" in s:
209
+ return "under_15k"
210
+ m = _re.search(r"(\d+)\s*k", s)
211
+ if m:
212
+ v = int(m.group(1))
213
+ if v >= 60: return "60k+"
214
+ if v >= 30: return "30k_60k"
215
+ if v >= 15: return "15k_30k"
216
+ return "under_15k"
217
+
218
+ elif question_id == "health_conditions":
219
+ if any(p in s for p in ["none", "no condition", "nothing", "no pre-exist", "no health", "no chronic"]):
220
+ return []
221
+ canonical = []
222
+ cond_keywords = {
223
+ "diabetes": ["diabetes", "diabetic", "sugar"],
224
+ "hypertension": ["hypertension", " bp ", "blood pressure", "high bp"],
225
+ "thyroid": ["thyroid", "hypothyroid", "hyperthyroid"],
226
+ "asthma": ["asthma"],
227
+ "heart": ["heart problem", "heart disease", "cardiac"],
228
+ "cancer": ["cancer", "tumor"],
229
+ }
230
+ for cond, kws in cond_keywords.items():
231
+ if any(k in s for k in kws):
232
+ canonical.append(cond)
233
+ if canonical:
234
+ return canonical
235
+
236
+ return None
237
+
238
+
239
  # ----------------------------------------------------------------------------
240
  # Fast-path parsers (no LLM)
241
  # ----------------------------------------------------------------------------
backend/orchestrator.py CHANGED
@@ -163,9 +163,14 @@ async def handle_turn(
163
  if treat_as_fact_find:
164
  # If we were awaiting an answer, normalize + record it before picking next Q.
165
  # Uses backend/fact_find_normalizer.py to map free-text β†’ schema enums.
166
- # If the input is a non-answer (STT failure / empty / gibberish), or the
167
- # LLM can't confidently map it, we DON'T clear awaiting_question_id so
168
- # the bot re-asks the same question rather than silently moving on.
 
 
 
 
 
169
  ambiguous_or_failed = False
170
  if session.awaiting_question_id:
171
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
@@ -187,11 +192,27 @@ async def handle_turn(
187
  if qid not in session.profile.asked:
188
  session.profile.asked.append(qid)
189
  session.set_awaiting(None)
 
 
 
190
  else:
191
  ambiguous_or_failed = True
192
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  # If the answer didn't normalize, pick the SAME question again (re-ask
194
- # with a gentle clarifier) instead of moving on with garbage.
 
195
  if ambiguous_or_failed and session.awaiting_question_id:
196
  q = next((qq for qq in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if qq.id == session.awaiting_question_id), None)
197
  else:
 
163
  if treat_as_fact_find:
164
  # If we were awaiting an answer, normalize + record it before picking next Q.
165
  # Uses backend/fact_find_normalizer.py to map free-text β†’ schema enums.
166
+ #
167
+ # Two safety nets:
168
+ # (1) Keyword fast-path inside normalize_answer() handles ~80% of
169
+ # answers without needing the NIM LLM (no rate-limit risk).
170
+ # (2) Re-ask cap (`_reask_count` on the session) β€” after 2 consecutive
171
+ # failures on the same question we GIVE UP, skip that question,
172
+ # and proceed to the next. Better to have an incomplete profile
173
+ # than an infinite reask loop.
174
  ambiguous_or_failed = False
175
  if session.awaiting_question_id:
176
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
 
192
  if qid not in session.profile.asked:
193
  session.profile.asked.append(qid)
194
  session.set_awaiting(None)
195
+ # Reset re-ask counter on success
196
+ if hasattr(session, "_reask_counts"):
197
+ session._reask_counts.pop(qid, None)
198
  else:
199
  ambiguous_or_failed = True
200
 
201
+ # ---- Re-ask cap (safety against infinite loops) ----
202
+ if ambiguous_or_failed:
203
+ if not hasattr(session, "_reask_counts"):
204
+ session._reask_counts = {}
205
+ session._reask_counts[qid] = session._reask_counts.get(qid, 0) + 1
206
+ if session._reask_counts[qid] >= 2:
207
+ # Give up on this question; mark it asked so next_question moves on.
208
+ if qid not in session.profile.asked:
209
+ session.profile.asked.append(qid)
210
+ session.set_awaiting(None)
211
+ ambiguous_or_failed = False # no longer a reask situation
212
+
213
  # If the answer didn't normalize, pick the SAME question again (re-ask
214
+ # with a gentle clarifier) instead of moving on with garbage β€” UNLESS
215
+ # the cap above just kicked in, in which case we move on.
216
  if ambiguous_or_failed and session.awaiting_question_id:
217
  q = next((qq for qq in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if qq.id == session.awaiting_question_id), None)
218
  else:
docs/40-evaluation/known-issues.md ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Known Issues + Quality Sprint Log
2
+
3
+ Living document. Every defect we find β€” whether via code review, eval audit,
4
+ or production observation β€” lands here with severity, root cause, and the
5
+ plan to fix it. Closed issues stay in the log with a `**FIXED in <sha>**`
6
+ annotation so reviewers can audit the project's quality trajectory.
7
+
8
+ ## Severity scale
9
+
10
+ - **P0 / Critical** β€” User-visible incorrect behavior, BFSI compliance risk,
11
+ or data loss. Block any release.
12
+ - **P1 / High** β€” Silent degradation; user gets a worse experience but it
13
+ doesn't visibly break. Ship a fix in the next sprint.
14
+ - **P2 / Medium** β€” Edge case; cosmetic; non-critical path. Backlog.
15
+ - **P3 / Low** β€” Code smell or minor inefficiency.
16
+
17
+ ---
18
+
19
+ ## Open issues
20
+
21
+ ### KI-001 β€” Gate 4 (LLM judge) fails OPEN on judge error
22
+
23
+ **Severity:** P0
24
+ **Source:** `backend/faithfulness.py:253-255`
25
+ **Discovered:** Code-review sweep 2026-05-14
26
+
27
+ When the judge LLM call fails (network, rate limit, JSON parse error, NIM
28
+ 408/503), Gate 4 currently returns `supported=True, ["judge_error_failopen"]`.
29
+ The reply ships through without grading.
30
+
31
+ In BFSI this is the *unsafe* default β€” an unsupported claim that should
32
+ have been blocked by Gate 4 leaks through to the user because the judge
33
+ hiccupped. The audit log preserves `judge_error_failopen` but the user
34
+ never sees the gate failed.
35
+
36
+ **Fix plan:** Add `FAITHFULNESS_FAIL_CLOSED` env var (default `true` in
37
+ production, `false` in dev / smoke tests). When fail-closed, return
38
+ `supported=False, ["judge_unavailable_failclosed"]` so the orchestrator's
39
+ cross-check retry path or final refusal fires instead.
40
+
41
+ ---
42
+
43
+ ### KI-002 β€” Session-state disk flush silently swallows errors
44
+
45
+ **Severity:** P1
46
+ **Source:** `backend/session_state.py:67-68`
47
+ **Discovered:** Code-review sweep 2026-05-14
48
+
49
+ `SessionState._flush()` writes the profile JSON to disk via a tmp+replace
50
+ pattern. On disk-full, EACCES, or JSON encode error, the bare `except
51
+ Exception: pass` drops the failure with zero observability. The user's
52
+ profile is silently lost on the next Space restart. They redo fact-find.
53
+
54
+ **Fix plan:** Add `logging.warning("session flush failed for %s: %s",
55
+ session_id, e)` β€” keep the no-crash behaviour but surface failure rate to
56
+ the HF Space logs so we can detect when it starts happening.
57
+
58
+ ---
59
+
60
+ ### KI-003 β€” Session-state disk load silently returns None on schema drift
61
+
62
+ **Severity:** P1
63
+ **Source:** `backend/session_state.py:114-115`
64
+ **Discovered:** Code-review sweep 2026-05-14
65
+
66
+ When the on-disk session JSON has a schema mismatch (Profile dataclass
67
+ field renamed, type changed), `_load_from_disk` catches the exception and
68
+ returns `None`. The user gets a fresh blank profile and has to redo
69
+ fact-find. No log, no metric.
70
+
71
+ **Fix plan:** Log the exception with `session_id` so we know schema drift
72
+ is happening. Also: tighten the existing valid-field filter (line 105-106)
73
+ to additionally type-check values so a stringified int doesn't pass through.
74
+
75
+ ---
76
+
77
+ ### KI-004 β€” Indic translator failure β†’ original Indic text flows into English brain silently
78
+
79
+ **Severity:** P1
80
+ **Source:** `backend/orchestrator.py:148-149`
81
+ **Discovered:** Code-review sweep 2026-05-14
82
+
83
+ When Sarvam-M translator fails on an Indic query, the orchestrator falls
84
+ through with the original Indic text, sending it to the English-trained
85
+ DeepSeek/NIM brain. The brain handles it imperfectly. The user gets a
86
+ degraded reply with no indication that the translator failed.
87
+
88
+ **Fix plan:** Log the failure (`logging.warning("Indic translator failed
89
+ for session %s, lang=%s: %s", session_id, language, e)`). Optionally
90
+ return a soft refusal in Indic ("Sorry, I'm having trouble with the
91
+ translation right now β€” could you ask in English?") instead of silently
92
+ mis-routing.
93
+
94
+ ---
95
+
96
+ ### KI-005 β€” Profile-RAG chunk upsert failure silently swallowed
97
+
98
+ **Severity:** P1
99
+ **Source:** `backend/orchestrator.py:285-287`
100
+ **Discovered:** Code-review sweep 2026-05-14
101
+
102
+ After the conversational profile-update extractor lands a new field, the
103
+ orchestrator re-upserts the profile chunk into Chroma so retrieval reflects
104
+ the latest state. If that Chroma write fails (lock, disk, schema), the
105
+ exception is swallowed. Subsequent turns retrieve the *stale* profile.
106
+ The user thinks the bot incorporates their new fact ("I just got
107
+ diabetes"); it actually doesn't.
108
+
109
+ **Fix plan:** Log the failure + record in `TurnResult.profile_updates`
110
+ that the upsert hit a problem so the frontend can show a small warning
111
+ or retry.
112
+
113
+ ---
114
+
115
+ ### KI-006 β€” Conversational profile extraction failure silently swallowed
116
+
117
+ **Severity:** P2
118
+ **Source:** `backend/orchestrator.py:288-289`
119
+ **Discovered:** Code-review sweep 2026-05-14
120
+
121
+ If `extract_profile_updates()` itself raises (rare; NIM unavailable), the
122
+ mid-chat profile-update feature is silently disabled for that turn. User
123
+ won't know why their "I just turned 40" didn't take.
124
+
125
+ **Fix plan:** Log + add to `TurnResult.profile_updates_meta` so the
126
+ frontend could surface "we missed an update β€” try mentioning it again".
127
+
128
+ ---
129
+
130
+ ### KI-007 β€” Indic cascade total failure β†’ English reply with zero log
131
+
132
+ **Severity:** P2
133
+ **Source:** `backend/orchestrator.py:425-426`
134
+ **Discovered:** Code-review sweep 2026-05-14
135
+
136
+ When all three Indic drift gates fail (or `translate_to_indic` itself
137
+ raises), we fall back to English. The user asked in Hinglish but gets
138
+ English. No log of which gate failed.
139
+
140
+ **Fix plan:** Add structured logging of which gate caused the fall-back
141
+ (`anchor` / `llmjudge` / `cosine`) so we can tune thresholds against real
142
+ production drift data.
143
+
144
+ ---
145
+
146
+ ### KI-008 β€” TTS preprocess can swallow blocking content
147
+
148
+ **Severity:** P3
149
+ **Source:** `backend/main.py:258-272`
150
+ **Discovered:** Code-review sweep 2026-05-14
151
+
152
+ `tts_preprocess()` is called inside a `try: … except Exception as e: log
153
+ + return text only` block. If the preprocessor strips the acronym expansion
154
+ incorrectly, the TTS voice would butcher PED / SI / IRDAI etc. No fall-back
155
+ to a hard-coded acronym dict β€” we just log + skip.
156
+
157
+ **Fix plan:** Add a regression test for `tts_preprocess()` covering the 20
158
+ most common BFSI acronyms.
159
+
160
+ ---
161
+
162
+ ### KI-009 β€” Live-mode VAD: no calibration on entry
163
+
164
+ **Severity:** P2
165
+ **Source:** `frontend/src/lib/useLiveConversation.ts` β€” `rmsThreshold: 28`
166
+ **Discovered:** Code-review sweep 2026-05-14
167
+
168
+ The RMS threshold is a constant. Quiet speakers, far-mic users, and noisy
169
+ backgrounds all hit one fixed bar. Some users won't trigger VAD; others
170
+ will trigger it on background noise.
171
+
172
+ **Fix plan:** Calibrate the threshold by sampling 1 second of ambient
173
+ audio on Live-mode entry. Set threshold to `mean(ambient) + 2 * sigma`.
174
+
175
+ ---
176
+
177
+ ### KI-010 β€” Audit runner: output unbuffered required `PYTHONUNBUFFERED=1`
178
+
179
+ **Severity:** P3
180
+ **Source:** `tools/audit/run_audit.py`
181
+ **Discovered:** Self-test 2026-05-14
182
+
183
+ Initial run had zero progress prints in the captured log because Python's
184
+ default stdout buffering held lines until process exit. Fixed in the same
185
+ session: all `print()` calls now use `flush=True` and we add per-5-turn
186
+ progress prints. Document for future tooling.
187
+
188
+ **Status:** FIXED in commit during audit framework rollout.
189
+
190
+ ---
191
+
192
+ ## Closed issues (this session)
193
+
194
+ - **Issue 1: Full-duplex voice barge-in** β€” shipped in `d31e132`.
195
+ - **Issue 2 + 4: Garbage profile recording + sidebar sync** β€” shipped in `9a1b321`.
196
+ - **Issue 3: Sarvam STT 400 (webm→wav)** — shipped in `a777198`.
197
+ - **Bug A: Cold-start "Load failed"** β€” shipped in `f81328f`.
198
+ - **Bug B: Citation chip insurer prefix** β€” shipped in `f81328f`.
199
+ - **Bug C: "Try again" intent handling** β€” shipped in `f81328f`.
200
+
201
+ ---
202
+
203
+ ## Quality-sprint cadence
204
+
205
+ Every batch of fixes ships as one commit referencing the KI numbers it
206
+ closes. The audit run (`audit_results/<run_id>/report.md`) is the
207
+ empirical signal for whether a fix is actually working in production.
208
+
209
+ The standing ratio target: **for every 1 user-facing bug a reviewer
210
+ catches, we should close 5 internal issues from this log before the next
211
+ review.**