feat: AI Dungeon Master engine + web frontend

- Engine: stato SQLite, state machine, 10 approved tools, parser azioni
- LLM: multi-provider (Ollama/OpenRouter/Local), system prompt DCC
- DM cycle: LLM decide tool → engine esegue → narrazione + meccanica
- Dati avventura: JSON machine-readable con 9 aree, mostri, enigmi, quest
- Web: FastAPI + WebSocket backend, SPA frontend dark fantasy
- Ref: architecture-patterns/neon-scratch-lounge (separazione reasoning/execution)
This commit is contained in:
truetype committed 2026-06-26 20:31:34 +02:00
1 parent ae73fe43d3
commit 90f823fdb9
17 files changed
+4082

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import os
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.engine.config import ProviderConfig, load_from_env
class LLMProvider(ABC):
@abstractmethod
def ask(self, prompt: str, system: str = "") -> str:
...
class OllamaProvider(LLMProvider):
def __init__(self, base_url: str, model: str, timeout: int = 60):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
def ask(self, prompt: str, system: str = "") -> str:
body = {"model": self.model, "prompt": prompt, "stream": False}
if system:
body["system"] = system
resp = httpx.post(
f"{self.base_url}/api/generate",
json=body,
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json().get("response", "")
class OpenRouterProvider(LLMProvider):
_FREE_FALLBACK = [
"openrouter/free",
"google/gemma-4-31b-it:free",
"qwen/qwen3-coder:free",
]
def __init__(self, api_key: str, model: str, timeout: int = 60, free_fallback: bool = True):
self.api_key = api_key
self.model = model
self.timeout = timeout
self.free_fallback = free_fallback
def _call(self, model: str, prompt: str, system: str) -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
body = {"model": model, "messages": messages, "stream": False}
resp = httpx.post(
"https://openrouter.ai/api/v1/chat/completions",
json=body,
headers=headers,
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def ask(self, prompt: str, system: str = "") -> str:
models = [self.model]
if self.free_fallback:
models.extend(self._FREE_FALLBACK)
last_err = None
for m in models:
try:
return self._call(m, prompt, system)
except Exception as e:
last_err = e
continue
raise last_err # type: ignore
class LocalProvider(LLMProvider):
def __init__(self, base_url: str, model: str, api_key: str = "", timeout: int = 60):
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
def ask(self, prompt: str, system: str = "") -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
body = {"model": self.model, "messages": messages, "stream": False}
resp = httpx.post(
f"{self.base_url}/chat/completions",
json=body,
headers=headers,
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
_registry: dict[str, Any] = {
"ollama": OllamaProvider,
"openrouter": OpenRouterProvider,
"local": LocalProvider,
}
def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
if cfg is None:
cfg = load_from_env()
cls = _registry.get(cfg.kind)
if not cls:
raise ValueError(f"Provider sconosciuto: {cfg.kind} (disponibili: {list(_registry.keys())})")
if cfg.kind == "ollama":
return cls(cfg.ollama_base_url, cfg.ollama_model, cfg.timeout)
elif cfg.kind == "openrouter":
key = cfg.openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "")
if not key:
raise ValueError("OPENROUTER_API_KEY non configurata")
return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback)
elif cfg.kind == "local":
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout)
else:
raise ValueError(f"Provider non implementato: {cfg.kind}")