diff --git a/src/engine/llm.py b/src/engine/llm.py index 394619e..5a7ec16 100644 --- a/src/engine/llm.py +++ b/src/engine/llm.py @@ -1,14 +1,43 @@ from __future__ import annotations import contextvars +import hashlib import json import re +import time from src.engine.providers import LLMProvider, create_provider _provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None) +# Cache risposte LLM: {cache_key: (timestamp, response)} +_cache: dict[str, tuple[float, str]] = {} +_CACHE_TTL = 300 # 5 minuti + + +def _cache_key(prompt: str, system: str) -> str: + return hashlib.sha256(f"{system}||{prompt}".encode()).hexdigest() + + +def _cache_get(prompt: str, system: str) -> str | None: + key = _cache_key(prompt, system) + entry = _cache.get(key) + if entry and time.monotonic() - entry[0] < _CACHE_TTL: + return entry[1] + if entry: + del _cache[key] + return None + + +def _cache_set(prompt: str, system: str, response: str) -> None: + key = _cache_key(prompt, system) + _cache[key] = (time.monotonic(), response) + # Mantieni cache sotto 100 entry + if len(_cache) > 100: + oldest = min(_cache.keys(), key=lambda k: _cache[k][0]) + del _cache[oldest] + def get_provider() -> LLMProvider: provider = _provider_var.get() @@ -24,7 +53,12 @@ def set_provider(provider: LLMProvider) -> None: def ask(prompt: str, system: str = "") -> str: - return get_provider().ask(prompt, system=system) + cached = _cache_get(prompt, system) + if cached is not None: + return cached + response = get_provider().ask(prompt, system=system) + _cache_set(prompt, system, response) + return response def _try_repair_json(text: str) -> str | None: diff --git a/src/web/server.py b/src/web/server.py index 714d4e2..a785ae7 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -4,6 +4,7 @@ import json import asyncio import logging import os +import time from pathlib import Path from concurrent.futures import ThreadPoolExecutor @@ -140,6 +141,19 @@ def add_character( _executor = ThreadPoolExecutor(max_workers=4) +# Rate limiting: almeno 1.5s tra azioni dello stesso client +_last_action: dict[str, float] = {} +_RATE_LIMIT = 1.5 + + +def _check_rate(client_id: str) -> str | None: + now = time.monotonic() + last = _last_action.get(client_id, 0) + if now - last < _RATE_LIMIT: + return f"Troppo veloce! Aspetta {_RATE_LIMIT - (now - last):.1f}s" + _last_action[client_id] = now + return None + @app.websocket("/ws/{campaign_id}") async def game_ws(ws: WebSocket, campaign_id: int): @@ -171,6 +185,13 @@ async def game_ws(ws: WebSocket, campaign_id: int): if not player_action: continue + # Rate limiting + if ws.client: + err = _check_rate(str(ws.client)) + if err: + await ws.send_json({"type": "error", "message": err}) + continue + try: # dm_cycle è bloccante (chiamata HTTP al LLM) — # la eseguiamo in un thread worker per non bloccare