LLM fixes: - _try_repair_json() — ripara JSON malformato (apici, virgole trailing) - _strip_json_from_text() — rimuove residui JSON dalla narration - parse_tool_call() — rimuove troncamento 200 caratteri dal fallback DM fixes: - Fallback euristico quando params vuoti (dm.py) - Estrazione narrazione da dm_override/narrate params.params.text Prompt (prompts.py): - Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...) - Aggiunto vocabolario fantasy obbligatorio Server (server.py): - dm_cycle in ThreadPoolExecutor per non bloccare WS ping - WebSocketDisconnect gestiti - Rimosso import inesistente db_schema Frontend (index.html): - Nascosti tool name dalla chat - Personaggio -> PG, connessioni aree con nomi Infrastruttura: - Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go - pyproject.toml, Dockerfile, docker-compose.yml, start.sh - .env auto-load (start.sh) - Funnel Lv0 (funnel.py) — creazione PG livello 0 Docs: architecture, database, setup, game mechanics, codebase ref
125 lines
4.1 KiB
Python
125 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import json
|
|
import re
|
|
|
|
from src.engine.providers import LLMProvider, create_provider
|
|
|
|
|
|
_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None)
|
|
|
|
|
|
def get_provider() -> LLMProvider:
|
|
provider = _provider_var.get()
|
|
if provider is None:
|
|
from src.engine.config import load_from_env
|
|
provider = create_provider(load_from_env())
|
|
_provider_var.set(provider)
|
|
return provider
|
|
|
|
|
|
def set_provider(provider: LLMProvider) -> None:
|
|
_provider_var.set(provider)
|
|
|
|
|
|
def ask(prompt: str, system: str = "") -> str:
|
|
return get_provider().ask(prompt, system=system)
|
|
|
|
|
|
def _try_repair_json(text: str) -> str | None:
|
|
"""Tenta di riparare errori JSON comuni prodotti dagli LLM."""
|
|
m = re.search(r'(\{[\s\S]*\})', text)
|
|
if not m:
|
|
return None
|
|
blob = m.group(1)
|
|
|
|
# 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)
|
|
# Fix 2: qualsiasi altra chiave con apici singoli
|
|
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)
|
|
try:
|
|
json.loads(blob)
|
|
return blob
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _extract_json(text: str) -> str | None:
|
|
for pat in [
|
|
r'```json\s*([\s\S]*?)```',
|
|
r'```\s*([\s\S]*?)```',
|
|
r'(\{[\s\S]*\})',
|
|
]:
|
|
m = re.search(pat, text)
|
|
if m:
|
|
candidate = m.group(1).strip()
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except json.JSONDecodeError:
|
|
# tenta riparazione
|
|
repaired = _try_repair_json(candidate)
|
|
if repaired:
|
|
return repaired
|
|
continue
|
|
# try repair on full text too
|
|
return _try_repair_json(text)
|
|
|
|
|
|
def _strip_json_from_text(text: str) -> str:
|
|
"""Rimuove qualsiasi contenuto simile a JSON dal testo narrativo."""
|
|
# toglie blocchi {...} completi di tool call
|
|
cleaned = re.sub(r'\{"tool":[\s\S]*?"narration":\s*"[^"]*"\s*\}', '', text)
|
|
# toglie blocchi JSON parziali o incompleti
|
|
cleaned = re.sub(r'\{"tool":[\s\S]*?\}', '', cleaned)
|
|
cleaned = re.sub(r'\{\s*"[a-zA-Z_]+"\s*[:=][^}]*\}', '', cleaned)
|
|
# pulisce spaziature multiple
|
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
|
# toglie virgolette pendenti all'inizio/fine
|
|
cleaned = cleaned.strip('"\' ,.')
|
|
return cleaned
|
|
|
|
|
|
def parse_tool_call(text: str) -> dict:
|
|
text = text.strip()
|
|
blob = _extract_json(text)
|
|
if blob:
|
|
try:
|
|
data = json.loads(blob)
|
|
if "tool" in data:
|
|
return data
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Fallback: estrae tool name via regex, ma PULISCE la narration dal JSON
|
|
tool_match = re.search(r'\b(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
|
|
narration = _strip_json_from_text(text) if text else ""
|
|
if tool_match:
|
|
return {"tool": tool_match.group(1), "params": {}, "narration": narration}
|
|
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": narration or text[:500]}}, "narration": narration}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_actions = [
|
|
"Tiro un d20 per vedere se sento rumori",
|
|
"Attacco l'orco con la mia ascia!",
|
|
"Esploro il corridoio buio",
|
|
]
|
|
from src.engine.prompts import SYSTEM_PROMPT as _DM_PROMPT
|
|
for a in test_actions:
|
|
print(f"\n>>> Giocatore: {a}")
|
|
try:
|
|
resp = ask(a, system=_DM_PROMPT)
|
|
print(f"LLM: {resp[:300]}")
|
|
parsed = parse_tool_call(resp)
|
|
print(f"Parser: {json.dumps(parsed, indent=2)}")
|
|
except Exception as e:
|
|
print(f"ERRORE: {e}")
|