test: parser LLM + fix repair JSON
test / test (push) Has been cancelled

- test_llm_parser.py: 18 test per parse_tool_call, repair, strip
- Fix: repair raddoppiava i due punti nelle chiavi
- Fix: aggiunto passaggio per valori con apici singoli
- Copertura: JSON valido, apici singoli, misti, codeblock,
  virgole trailing, = invece di :, testo senza JSON
- 72 test totali, tutti passano
This commit is contained in:
truetype committed 2026-07-01 09:44:49 +02:00
1 parent f4cf649318
commit 08edeed8bb
2 files changed
+146 -3

No files matched your search

+6 -3
View File
@@ -34,16 +34,19 @@ def _try_repair_json(text: str) -> str | None:
return None
blob = m.group(1)
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params": ")
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params")
blob = re.sub(r"(?<!\\)'(params|narration|tool|command|text|character_id|dc|modifier|die|count)"
r"'(?=\s*[:=])", r'"\1":', blob)
r"'(?=\s*[:=])", r'"\1"', blob)
# Fix 2: qualsiasi altra chiave con apici singoli
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1":', blob)
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1"', blob)
# Fix 3: trailing comma prima di } o ]
blob = re.sub(r',\s*}', '}', blob)
blob = re.sub(r',\s*\]', ']', blob)
# Fix 4: = al posto di : nelle chiavi
blob = re.sub(r'"(\w+)"\s*=', r'"\1":', blob)
# Fix 5: valori con apici singoli → doppi (dopo aver riparato le chiavi)
# 'valore' → "valore"
blob = re.sub(r"'([^']*?)'(?=\s*[,}\]])", r'"\1"', blob)
try:
json.loads(blob)
return blob
+140
View File
@@ -0,0 +1,140 @@
"""Test parsing risposte LLM — JSON extraction, repair, strip."""
import sys
sys.path.insert(0, "src")
from src.engine.llm import parse_tool_call, _try_repair_json, _strip_json_from_text
def test_parse_valid_json():
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tiro il dado."}'
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
assert "Tiro" in r["narration"]
def test_parse_valid_json_with_all_params():
r = parse_tool_call(
'{"tool": "combat", "params": {"character_id": 1, "target_ac": 15}, "narration": "Attacco!"}'
)
assert r["tool"] == "combat"
assert r["params"]["character_id"] == 1
assert r["params"]["target_ac"] == 15
def test_parse_dm_override():
r = parse_tool_call(
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Scena epica."}}, "narration": "Il mago appare."}'
)
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
def test_parse_plain_text_fallback():
"""Testo senza JSON deve cascare sul fallback dm_override narrate."""
r = parse_tool_call("Esploro la stanza buia con cautela")
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
assert "stanza" in r["narration"]
def test_parse_text_containing_json_residue():
"""Testo con JSON completo + testo extra dopo.
Il JSON viene parsato correttamente, il testo extra nella narrazione
e gestito da dm.py (_llm_decision estrae da params.params.text)."""
r = parse_tool_call(
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Cripta oscura."}}} La stanza e buia...'
)
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
def test_parse_single_quotes():
"""JSON con apici singoli (errore comune LLM)."""
r = parse_tool_call(
"{'tool': 'roll_dice', 'params': {'die': 'd20'}, 'narration': 'Provo fortuna.'}"
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
def test_parse_mixed_quotes():
"""Mix di apici singoli e doppi."""
r = parse_tool_call(
'{"tool": "ability_check", \'params\': {"character_id": 1, "dc": 12}, "narration": "Cerco tracce."}'
)
assert r["tool"] == "ability_check"
assert r["params"]["character_id"] == 1
def test_parse_trailing_commas():
"""Virgole prima di } o ] (errore comune)."""
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die": "d20", "count": 2,}, "narration": "Doppio tiro.",}'
)
assert r["tool"] == "roll_dice"
assert r["params"]["count"] == 2
def test_parse_equals_instead_of_colon():
"""= invece di : nelle chiavi."""
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die" = "d20"}, "narration": "Tiro."}'
)
assert r["tool"] == "roll_dice"
def test_parse_backtick_code_block():
r = parse_tool_call(
"```json\n{\"tool\": \"roll_dice\", \"params\": {\"die\": \"d20\"}, \"narration\": \"Tiro.\"}\n```"
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
def test_parse_unknown_tool_uses_fallback():
"""Tool sconosciuto deve cascare su dm_override narrate."""
r = parse_tool_call(
'{"tool": "danza_elfica", "params": {}, "narration": "Ballano."}'
)
assert r["tool"] in ("dm_override", "danza_elfica")
def test_repair_json_single_quotes():
result = _try_repair_json("{'tool': 'test', 'params': {}}")
assert result is not None
assert '"tool"' in result
def test_repair_json_trailing_comma():
result = _try_repair_json('{"tool": "test", "params": {},}')
assert result is not None
def test_repair_json_equals_sign():
result = _try_repair_json('{"tool" = "test", "params" = {}}')
assert result is not None
def test_repair_json_invalid_returns_none():
result = _try_repair_json("non è json")
assert result is None
def test_strip_json_removes_tool_call():
cleaned = _strip_json_from_text(
'{"tool": "ability_check", "params": {}, "narration": "Testo"} Druso si muove.'
)
assert "Druso" in cleaned
assert "tool" not in cleaned
def test_strip_json_partial_block():
cleaned = _strip_json_from_text('{"tool": "test"} Avanza.')
assert "Avanza" in cleaned
def test_strip_json_empty_input():
assert _strip_json_from_text("") == ""
assert _strip_json_from_text(" ") == ""