rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
11cf4b3
·
1 Parent(s): 8a87526

fix(fact-find): KI-090 — lenient FF-block parser; accept JSON tail without <FF> tags

Browse files

Post-KI-088 live probe at commit 14ee008 showed brain success climbed
from 20% to 30% as the NIM concurrency semaphore fixed queue saturation
— BUT 70% of turns now fell to `fallback:no_trailer` at 4-10s latency
(not 41s). The brain IS responding successfully; the parser was
rejecting good replies because the LLM dropped the literal
`<FF>...</FF>` tags and emitted a bare JSON tail instead.

KI-090 makes `_parse_ff_block` try three strategies in order:
1. Strict `<FF>{...}</FF>` (the contract — still preferred)
2. Fenced `\`\`\`json {...} \`\`\`` (common LLM habit)
3. Bare `{...}` at the end of the reply

Each candidate must parse as a dict AND contain at least one of the
contract keys (`captured` / `slot_driving` / `complete`) to count.
Otherwise stray inline JSON in the prose would be falsely accepted.

`_strip_ff_block` mirrors the new strategies in reverse so prose-only
reply never leaks the tail JSON to the user.

7/7 parse test cases pass: strict, fenced, bare-tail, strict-with-extra
all extract correctly; no-JSON, wrong-keys, malformed all return None.
7/7 strip cases verify the user-facing prose never contains the JSON
metadata, even for malformed inputs.

tests/test_routing_regression.py → 15 passed.
tests/test_credits_election.py → 12 passed.

Expected production impact: most of the 70% `fallback:no_trailer` turns
should now parse as `fact_find_brain::continue` — brain success rate
climbs from 30% to expected 85-95%. The bot will start delivering
natural-LLM conversation on most turns, with canonical fallback
reserved for genuinely unparseable replies (rare).

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

Files changed (1) hide show
  1. backend/fact_find_brain.py +68 -19
backend/fact_find_brain.py CHANGED
@@ -147,38 +147,87 @@ Bot reply: Most policies in India have a 24-36 month waiting period for pre-exis
147
  # ----------------------------------------------------------------------------
148
 
149
  _FF_BLOCK_RE = re.compile(r"<FF>\s*(\{.*?\})\s*</FF>", re.DOTALL)
 
 
 
 
 
 
150
 
151
 
152
  def _parse_ff_block(text: str) -> Optional[dict]:
153
- """Extract + parse the <FF>...</FF> JSON trailer from an LLM reply.
154
-
155
- Returns the parsed dict on success, None when the trailer is missing,
156
- malformed, or fails JSON parse. The caller should treat None as
157
- `ambiguous=True` and fall through to the canonical fallback.
 
 
 
 
 
 
 
 
 
 
 
 
158
  """
159
  if not text:
160
  return None
 
161
  m = _FF_BLOCK_RE.search(text)
162
- if not m:
163
- return None
164
- raw_json = m.group(1).strip()
165
- try:
166
- data = json.loads(raw_json)
167
- except json.JSONDecodeError:
168
- return None
169
- if not isinstance(data, dict):
170
- return None
171
- return data
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  def _strip_ff_block(text: str) -> str:
175
- """Remove the <FF>...</FF> trailer (and any trailing whitespace) so the
176
- user-facing reply doesn't leak the schema tag."""
 
 
 
 
 
177
  if not text:
178
  return text
179
  cleaned = _FF_BLOCK_RE.sub("", text).strip()
180
- # Remove any orphaned partial tags too — defensive against LLMs that emit
181
- # an opening <FF> without a close, or a malformed inner block.
 
 
 
 
 
 
 
 
 
 
 
 
182
  cleaned = re.sub(r"<FF>.*$", "", cleaned, flags=re.DOTALL).strip()
183
  cleaned = re.sub(r"</FF>", "", cleaned).strip()
184
  return cleaned
 
147
  # ----------------------------------------------------------------------------
148
 
149
  _FF_BLOCK_RE = re.compile(r"<FF>\s*(\{.*?\})\s*</FF>", re.DOTALL)
150
+ # KI-090 (2026-05-15) — lenient fallback for FF parsing. Many LLMs drop
151
+ # the literal <FF>...</FF> tags and emit a bare JSON tail (or inline
152
+ # fenced JSON). When the strict tag match fails, try these regexes in
153
+ # order so we accept whatever the brain actually produces.
154
+ _FF_FENCED_RE = re.compile(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", re.IGNORECASE)
155
+ _FF_TAIL_JSON_RE = re.compile(r"(\{[\s\S]*\})\s*\Z")
156
 
157
 
158
  def _parse_ff_block(text: str) -> Optional[dict]:
159
+ """Extract + parse the structured trailer dict from an LLM reply.
160
+
161
+ Returns the parsed dict on success, None when no parseable JSON object
162
+ can be located. Caller treats None as `ambiguous=True` and falls
163
+ through to the canonical fallback.
164
+
165
+ KI-090 (2026-05-15) — lenient parsing. The original strict
166
+ `<FF>{...}</FF>` regex was correct per the system-prompt contract, but
167
+ real LLMs (Qwen, Nemotron under load, Groq Llama-3.3 at times) drop
168
+ the literal tags and emit only the JSON. Pre-KI-090 those replies
169
+ fell to `fallback:no_trailer` even though the brain had produced a
170
+ perfectly valid structured tail. Now we try:
171
+ 1. Strict `<FF>{...}</FF>` (the contract — still preferred).
172
+ 2. ```` ```json {...} ``` ```` fenced (a common LLM habit).
173
+ 3. Bare `{...}` at the very end of the reply.
174
+ Any candidate that parses as a JSON dict with at least one of the
175
+ expected keys (`captured` / `slot_driving` / `complete`) wins.
176
  """
177
  if not text:
178
  return None
179
+ candidates: list[str] = []
180
  m = _FF_BLOCK_RE.search(text)
181
+ if m:
182
+ candidates.append(m.group(1).strip())
183
+ for fenced in _FF_FENCED_RE.finditer(text):
184
+ candidates.append(fenced.group(1).strip())
185
+ tail = _FF_TAIL_JSON_RE.search(text.rstrip())
186
+ if tail:
187
+ candidates.append(tail.group(1).strip())
188
+ seen: set[str] = set()
189
+ for raw in candidates:
190
+ if raw in seen:
191
+ continue
192
+ seen.add(raw)
193
+ try:
194
+ data = json.loads(raw)
195
+ except json.JSONDecodeError:
196
+ continue
197
+ if not isinstance(data, dict):
198
+ continue
199
+ # Require at least one of the contract keys so we don't accept
200
+ # a stray JSON object from the prose (e.g. a quoted example).
201
+ if any(k in data for k in ("captured", "slot_driving", "complete")):
202
+ return data
203
+ return None
204
 
205
 
206
  def _strip_ff_block(text: str) -> str:
207
+ """Remove the FF trailer (tagged, fenced, or bare-JSON tail) so the
208
+ user-facing reply doesn't leak the schema tag.
209
+
210
+ KI-090 — mirrors the lenient `_parse_ff_block` strategies in reverse:
211
+ strip strict `<FF>...</FF>`, then ```` ```json ... ``` ````, then any
212
+ bare-JSON tail that contains a contract key.
213
+ """
214
  if not text:
215
  return text
216
  cleaned = _FF_BLOCK_RE.sub("", text).strip()
217
+ cleaned = _FF_FENCED_RE.sub("", cleaned).strip()
218
+ # Strip a bare-JSON tail ONLY if it contains a contract key — otherwise
219
+ # we might delete prose that happens to end with a JSON-ish bracket.
220
+ tail = _FF_TAIL_JSON_RE.search(cleaned)
221
+ if tail:
222
+ try:
223
+ tail_json = json.loads(tail.group(1))
224
+ if isinstance(tail_json, dict) and any(
225
+ k in tail_json for k in ("captured", "slot_driving", "complete")
226
+ ):
227
+ cleaned = cleaned[: tail.start()].rstrip()
228
+ except json.JSONDecodeError:
229
+ pass
230
+ # Defensive: any orphan partial tags
231
  cleaned = re.sub(r"<FF>.*$", "", cleaned, flags=re.DOTALL).strip()
232
  cleaned = re.sub(r"</FF>", "", cleaned).strip()
233
  return cleaned