Files
dcc-rpg/src/engine/llm.py
T
truetype eacc946868 fix: JSON repair, WS thread pool, switch to DeepSeek V4 Flash, funnel Lv0
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
2026-06-28 08:18:24 +02:00

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}")