135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
|
|
"""Conseiller LLM local via Ollama — enrichit les décisions heuristiques."""
|
||
|
|
import json
|
||
|
|
import httpx
|
||
|
|
import structlog
|
||
|
|
from backend.ai.engine.decision_engine import GameState, Decision, FullAdvice
|
||
|
|
|
||
|
|
log = structlog.get_logger()
|
||
|
|
|
||
|
|
SYSTEM_PROMPT = """Tu es un expert Hearthstone Battlegrounds rang Légende.
|
||
|
|
Analyse l'état de jeu et donne un conseil tactique optimal.
|
||
|
|
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant/après, sans markdown:
|
||
|
|
{"main_action":"buy|sell|freeze|upgrade|reposition|hero_power|wait","target_card":null,"priority":7,"confidence":0.8,"reasoning":"explication courte","strategy":"stratégie long terme 1 phrase","threats":"principale menace adversaire","warnings":[]}"""
|
||
|
|
|
||
|
|
|
||
|
|
class LLMAdvisor:
|
||
|
|
"""Interface avec Ollama pour les conseils LLM."""
|
||
|
|
|
||
|
|
def __init__(self, settings):
|
||
|
|
self.settings = settings
|
||
|
|
self.base_url = settings.llm_base_url
|
||
|
|
self.model = settings.llm_model
|
||
|
|
self._client: httpx.AsyncClient | None = None
|
||
|
|
self._available = False
|
||
|
|
|
||
|
|
async def initialize(self):
|
||
|
|
"""Teste la disponibilité d'Ollama et du modèle."""
|
||
|
|
self._client = httpx.AsyncClient(timeout=10)
|
||
|
|
try:
|
||
|
|
r = await self._client.get(f"{self.base_url}/api/tags")
|
||
|
|
if r.status_code == 200:
|
||
|
|
models = r.json().get("models", [])
|
||
|
|
model_names = [m.get("name", "") for m in models]
|
||
|
|
self._available = any(self.model in name for name in model_names)
|
||
|
|
if self._available:
|
||
|
|
log.info("llm.ready", model=self.model)
|
||
|
|
else:
|
||
|
|
log.warning("llm.model_not_found", model=self.model,
|
||
|
|
available=model_names,
|
||
|
|
hint=f"Exécutez: ollama pull {self.model}")
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("llm.ollama_unreachable", url=self.base_url, error=str(e),
|
||
|
|
hint="Installez Ollama: curl -fsSL https://ollama.ai/install.sh | sh")
|
||
|
|
self._available = False
|
||
|
|
|
||
|
|
async def advise(self, state: GameState, heuristics: list[Decision]) -> FullAdvice | None:
|
||
|
|
"""Demande un conseil au LLM local."""
|
||
|
|
if not self._available or not self._client:
|
||
|
|
return None
|
||
|
|
|
||
|
|
prompt = self._build_prompt(state, heuristics)
|
||
|
|
|
||
|
|
try:
|
||
|
|
r = await self._client.post(
|
||
|
|
f"{self.base_url}/api/generate",
|
||
|
|
json={
|
||
|
|
"model": self.model,
|
||
|
|
"prompt": prompt,
|
||
|
|
"system": SYSTEM_PROMPT,
|
||
|
|
"stream": False,
|
||
|
|
"options": {
|
||
|
|
"temperature": self.settings.llm_temperature,
|
||
|
|
"num_predict": self.settings.llm_max_tokens,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
timeout=self.settings.llm_timeout,
|
||
|
|
)
|
||
|
|
if r.status_code == 200:
|
||
|
|
raw = r.json().get("response", "")
|
||
|
|
return self._parse(raw)
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("llm.request_failed", error=str(e))
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _build_prompt(self, state: GameState, decisions: list[Decision]) -> str:
|
||
|
|
"""Construit le prompt depuis l'état de jeu."""
|
||
|
|
board = ", ".join(
|
||
|
|
f"{m.get('name','?')}({m.get('attack',0)}/{m.get('health',0)})"
|
||
|
|
+ (" [DIVINE]" if m.get("has_divine") else "")
|
||
|
|
+ (" [TAUNT]" if m.get("has_taunt") else "")
|
||
|
|
for m in state.board_minions
|
||
|
|
) or "vide"
|
||
|
|
|
||
|
|
tavern = ", ".join(
|
||
|
|
f"{m.get('name','?')}[{m.get('cost',3)}g T{m.get('tier',1)}]"
|
||
|
|
for m in state.tavern_minions
|
||
|
|
) or "vide"
|
||
|
|
|
||
|
|
top_h = decisions[0].reasoning if decisions else "aucune heuristique"
|
||
|
|
|
||
|
|
return f"""=== ÉTAT DU JEU ===
|
||
|
|
Tour: {state.turn} | Tier taverne: {state.tavern_tier} | Or: {state.gold}g
|
||
|
|
Héros: {state.hero_id} | HP: {state.hero_hp} | Position: {state.current_placement}/{state.player_count}
|
||
|
|
Board ({len(state.board_minions)}/7): {board}
|
||
|
|
Taverne: {tavern}
|
||
|
|
Gel: {'OUI' if state.freeze else 'NON'} | Upgrade possible: {'OUI' if state.can_upgrade else 'NON'} ({state.upgrade_cost}g)
|
||
|
|
Phase: {state.phase}
|
||
|
|
|
||
|
|
=== MEILLEURE HEURISTIQUE ===
|
||
|
|
{top_h}
|
||
|
|
|
||
|
|
Analyse et donne ta recommandation JSON."""
|
||
|
|
|
||
|
|
def _parse(self, raw: str) -> FullAdvice | None:
|
||
|
|
"""Parse la réponse JSON du LLM."""
|
||
|
|
try:
|
||
|
|
start = raw.find("{")
|
||
|
|
end = raw.rfind("}") + 1
|
||
|
|
if start == -1 or end == 0:
|
||
|
|
return None
|
||
|
|
|
||
|
|
data = json.loads(raw[start:end])
|
||
|
|
|
||
|
|
main = Decision(
|
||
|
|
action=data.get("main_action", "wait"),
|
||
|
|
priority=min(10, max(1, int(data.get("priority", 5)))),
|
||
|
|
confidence=min(1.0, max(0.0, float(data.get("confidence", 0.5)))),
|
||
|
|
reasoning=data.get("reasoning", ""),
|
||
|
|
warnings=data.get("warnings", []) or [],
|
||
|
|
)
|
||
|
|
|
||
|
|
return FullAdvice(
|
||
|
|
main_decision=main,
|
||
|
|
strategy_long_term=data.get("strategy", ""),
|
||
|
|
threat_assessment=data.get("threats", ""),
|
||
|
|
confidence_overall=main.confidence,
|
||
|
|
model_used=self.model,
|
||
|
|
)
|
||
|
|
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||
|
|
log.warning("llm.parse_failed", error=str(e), raw_preview=raw[:300])
|
||
|
|
return None
|
||
|
|
|
||
|
|
async def shutdown(self):
|
||
|
|
if self._client:
|
||
|
|
await self._client.aclose()
|