# Architettura: Neon Scratch Lounge (On-Premise) ## Pattern: Separazione Reasoning/Execution L'architettura separa nettamente ciò che fa il **LLM** (ragionamento, narrazione) da ciò che fa il **motore Python** (meccaniche deterministiche, persistenza). ``` ┌─────────────────────────────────────────────────┐ │ CLIENT (Web UI) │ │ Chat testuale + pannello stato laterale │ └──────────────────┬──────────────────────────────┘ │ WebSocket / HTTP REST ┌──────────────────▼──────────────────────────────┐ │ LLM ORCHESTRATOR (src/engine/dm.py) │ │ - Mantiene contesto sessione │ │ - Invia prompt: regole + stato + azione │ │ - Riceve: narrazione + tool calls │ │ - Fallback: heuristic parser su errore LLM │ └──────────────────┬──────────────────────────────┘ │ ┌──────────────────▼──────────────────────────────┐ │ TOOL REGISTRY (src/engine/tools.py) │ │ @tool decorator → registro centralizzato │ │ 10 Approved Tools + dm-override │ └──────────────────┬──────────────────────────────┘ │ ┌──────────────────▼──────────────────────────────┐ │ STATO PERSISTENTE (SQLite) │ │ src/engine/db.py — 8 tabelle, CRUD completo │ │ WAL mode, thread-local connections │ └─────────────────────────────────────────────────┘ ``` ## Flusso del Turno (dm_cycle) ``` dm_cycle(campaign_id, player_action) │ ├─ 1. GameContext.load(campaign_id) │ Carica da DB: campagna, PG, log, quest, aree │ → dataclass GameContext │ ├─ 2. _llm_decision(ctx, action) │ build_prompt(ctx, action) → prompt enorme │ llm_ask(prompt, system=SYSTEM_PROMPT) │ parse_tool_call(raw_llm_response) → {tool, params, narration} │ SE LLM fallisce → fallback_parse(action, chars) │ ├─ 3. tools.run(tool_name, **params) │ TOOL_REGISTRY[tool_name](**params) │ → ToolResult dict │ ├─ 4. Persistenza turno │ db.advance_turn(campaign_id) │ db.log_turn(campaign_id, turn, action, result) │ └─ 5. Response dict {turn, action, tool, params, narration, mechanics, full_output} ``` ## Multi-Provider LLM ``` ProviderConfig (env vars) │ ├─ kind = "ollama" → OllamaProvider │ POST {base_url}/api/generate │ model: gemma2:9b │ ├─ kind = "openrouter" → OpenRouterProvider │ POST openrouter.ai/api/v1/chat/completions │ Fallback automatico a modelli free │ └─ kind = "local" → LocalProvider POST {base_url}/chat/completions (OpenAI-compatible) Per inference server locali (vLLM, TGI, llama.cpp) ``` ## Prompt Engineering ### System Prompt (SYSTEM_PROMPT) - Stile narrazione: sword & sorcery, italiano, 2-3 frasi - Regole base: CD, CA, tiri salvezza, mighty deed - Tool disponibili: JSON con parametri + esempi - Formato output obbligatorio: JSON `{tool, params, narration}` ### Context Builder (build_prompt) - `format_context(ctx)`: campagna, PG, cronologia, quest, aree - `format_area_context(area_id)`: descrizione area, atmosfera, features, mostri presenti - Knowledge base completa via DB (non file statici) ## Heuristic Fallback Quando il LLM non produce un tool valido: 1. `parse_tool_call()` fallisce → regex JSON + keyword extraction 2. Se ancora fallisce → `heuristic_fallback.parse_action()` 3. Keyword matching: "attacco" → combat, "muovo" → movement, "d20" → roll_dice 4. Fallback finale → dm_override (narrazione pura) ## Frontend ### WebSocket Protocol - `state`: stato completo campagna (all'entrata + dopo ogni azione) - `response`: risultato turno (narrazione + tool + meccaniche) - `action`: azione giocatore (input → WebSocket → dm_cycle) - `ping/pong`: keepalive - `error`: messaggio errore ### REST API - `GET /api/campaigns` — lista campagne - `GET /api/campaign/{id}` — stato completo - `POST /api/campaign` — crea nuova campagna - `POST /api/campaign/{id}/character` — aggiunge PG - `GET /` — frontend - `GET /health` — health check ## Key Design Decisions ### Perché SQLite invece di DynamoDB - Zero dipendenze, on-premise - WAL mode per concorrenza thread-safe - Thread-local connections via `threading.local()` ### Perché @tool decorator invece di enum - Aggiungere nuovo tool = scrivere funzione + decorator - Nessun'altra modifica richiesta - Auto-registrazione in TOOL_REGISTRY ### Perché heuristic fallback - Ollama su CPU è lento e talvolta non rispetta il formato JSON - Il fallback garantisce che l'azione vada sempre a buon fine - Keyword parser copre ~80% dei casi d'uso base