Spaces:
Sleeping
fix(fact-find): KI-090 — lenient FF-block parser; accept JSON tail without <FF> tags
Browse filesPost-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>
- backend/fact_find_brain.py +68 -19
|
@@ -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
|
| 154 |
-
|
| 155 |
-
Returns the parsed dict on success, None when
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
"""
|
| 159 |
if not text:
|
| 160 |
return None
|
|
|
|
| 161 |
m = _FF_BLOCK_RE.search(text)
|
| 162 |
-
if
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
def _strip_ff_block(text: str) -> str:
|
| 175 |
-
"""Remove 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 |
-
|
| 181 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|