Harden command parsing (review fix)
interpret() now extracts the FIRST balanced [...] array and tolerates code fences / trailing prose, instead of a greedy [.*] that could swallow trailing bracketed text and fail to parse. Falls back gracefully to a spoken apology. Added regression tests for trailing brackets, fenced objects, and garbage. 44 tests passing; edge cases (angle 0, offset 0, negative moves, unknown stock) verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
892a376669
commit
7b5c58902c
|
|
@ -73,17 +73,47 @@ User said: "{utterance}"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_calls(raw: str) -> list[dict] | None:
|
||||||
|
"""Pull a JSON array of calls out of a model response, tolerating code
|
||||||
|
fences and trailing prose. Tries the whole string, then the FIRST balanced
|
||||||
|
[...] (not greedy-to-last-bracket, which would swallow trailing text)."""
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw)
|
||||||
|
raw = re.sub(r"\n?```$", "", raw).strip()
|
||||||
|
|
||||||
|
candidates = [raw]
|
||||||
|
start = raw.find("[")
|
||||||
|
if start != -1:
|
||||||
|
depth = 0
|
||||||
|
for i in range(start, len(raw)):
|
||||||
|
if raw[i] == "[":
|
||||||
|
depth += 1
|
||||||
|
elif raw[i] == "]":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
candidates.append(raw[start:i + 1])
|
||||||
|
break
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
value = json.loads(candidate)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return [value]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def interpret(utterance: str, schemas: str) -> list[dict]:
|
def interpret(utterance: str, schemas: str) -> list[dict]:
|
||||||
prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance)
|
prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance)
|
||||||
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
||||||
match = re.search(r"\[.*\]", raw, re.DOTALL) # tolerate stray text/fences
|
calls = _extract_calls(raw)
|
||||||
if not match:
|
if calls is None:
|
||||||
return [{"tool": "say", "args": {"text": "Sorry, I didn't catch a command."}}]
|
return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that command."}}]
|
||||||
try:
|
return calls
|
||||||
calls = json.loads(match.group(0))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that."}}]
|
|
||||||
return calls if isinstance(calls, list) else [calls]
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch(calls: list[dict], verbose: bool = True) -> list[str]:
|
def dispatch(calls: list[dict], verbose: bool = True) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -83,3 +83,18 @@ def test_interpret_handles_garbage(monkeypatch):
|
||||||
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
|
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
|
||||||
calls = driver.interpret("blah", schemas="[]")
|
calls = driver.interpret("blah", schemas="[]")
|
||||||
assert calls[0]["tool"] == "say"
|
assert calls[0]["tool"] == "say"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_calls_ignores_trailing_brackets():
|
||||||
|
"""A greedy [.*] would swallow the trailing '[note]' and fail to parse."""
|
||||||
|
raw = '[{"tool": "wood-undo", "args": {}}]\n\nLet me know [if that helps].'
|
||||||
|
assert driver._extract_calls(raw) == [{"tool": "wood-undo", "args": {}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_calls_strips_fences_and_handles_object():
|
||||||
|
assert driver._extract_calls('```json\n{"tool": "wood-clear", "args": {}}\n```') == \
|
||||||
|
[{"tool": "wood-clear", "args": {}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_calls_returns_none_on_garbage():
|
||||||
|
assert driver._extract_calls("no json here") is None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue