Initial commit

This commit is contained in:
2026-03-31 13:10:46 +02:00
commit f60d9628e0
52 changed files with 3383 additions and 0 deletions

View File

View File

View File

View File

@@ -0,0 +1,196 @@
"""
Moteur de décision IA principal - Architecture hybride.
Flux: StateAnalyzer → HeuristicEngine + LLMAdvisor → Fusion → FullAdvice
"""
import time
from dataclasses import dataclass, field
import structlog
log = structlog.get_logger()
@dataclass
class GameState:
"""État complet d'un tour HSBG."""
turn: int = 0
tavern_tier: int = 1
gold: int = 3
hero_id: str = ""
hero_hp: int = 40
tavern_minions: list = field(default_factory=list)
board_minions: list = field(default_factory=list)
hand_minions: list = field(default_factory=list)
freeze: bool = False
can_upgrade: bool = True
upgrade_cost: int = 5
available_spells: list = field(default_factory=list)
opponent_boards: list = field(default_factory=list)
current_placement: int = 5
player_count: int = 8
phase: str = "recruit"
@dataclass
class Decision:
"""Une décision IA avec justification."""
action: str # buy|sell|freeze|upgrade|reposition|hero_power|wait
target: dict = None # Carte ou minion ciblé
priority: int = 5 # 1-10
confidence: float = 0.5 # 0.0-1.0
reasoning: str = "" # Explication textuelle
alternatives: list = field(default_factory=list)
synergies_highlighted: list = field(default_factory=list)
warnings: list = field(default_factory=list)
@dataclass
class FullAdvice:
"""Conseil complet pour un tour entier."""
main_decision: Decision
secondary_decisions: list = field(default_factory=list)
board_analysis: str = ""
strategy_long_term: str = ""
threat_assessment: str = ""
processing_ms: int = 0
model_used: str = "heuristic"
confidence_overall: float = 0.5
class DecisionEngine:
"""
Moteur de décision hybride HSBG.
Architecture:
1. StateAnalyzer → Parse l'état brut en GameState typé
2. HeuristicEngine → Règles métier rapides et déterministes
3. LLMAdvisor → Raisonnement LLM pour enrichir (si disponible)
4. Fusion → Combine les deux avec pondération par confiance
"""
def __init__(self, settings):
self.settings = settings
self._initialized = False
# Import lazy pour éviter les imports circulaires
from backend.ai.engine.heuristics import HeuristicEngine
from backend.ai.engine.llm_advisor import LLMAdvisor
from backend.ai.engine.state_analyzer import StateAnalyzer
self.heuristic = HeuristicEngine()
self.llm = LLMAdvisor(settings)
self.analyzer = StateAnalyzer()
async def initialize(self):
"""Initialise les composants asynchrones (LLM)."""
await self.llm.initialize()
self._initialized = True
log.info("decision_engine.ready", model=self.settings.llm_model)
async def get_advice(self, raw_state: dict) -> FullAdvice:
"""
Point d'entrée principal.
Args:
raw_state: Dict brut (depuis API ou OCR)
Returns:
FullAdvice avec conseil principal + alternatives
"""
start = time.perf_counter()
# 1. Parser l'état brut
state = await self.analyzer.parse(raw_state)
# 2. Heuristiques (toujours disponibles, < 5ms)
heuristic_decisions = self.heuristic.evaluate(state)
# 3. LLM (si disponible, ~500-2000ms)
llm_advice = None
if self._initialized:
try:
llm_advice = await self.llm.advise(state, heuristic_decisions)
except Exception as e:
log.warning("llm.failed_gracefully", error=str(e))
# 4. Fusion avec pondération
final = self._fuse(state, heuristic_decisions, llm_advice)
final.processing_ms = int((time.perf_counter() - start) * 1000)
log.info(
"advice.generated",
action=final.main_decision.action,
confidence=round(final.main_decision.confidence, 2),
ms=final.processing_ms,
model=final.model_used,
)
return final
def _fuse(self, state: GameState, heuristics: list, llm: "FullAdvice | None") -> FullAdvice:
"""
Fusionne heuristiques + LLM.
Règle: LLM prioritaire si confiance > 0.7, sinon heuristiques.
"""
if not heuristics:
return FullAdvice(
main_decision=Decision(action="wait", reasoning="Aucune action identifiée"),
model_used="fallback",
)
# LLM haute confiance → priorité totale
if llm and llm.confidence_overall > 0.7:
llm.model_used = f"{self.settings.llm_model}+heuristic"
return llm
# Heuristiques comme base
main = max(heuristics, key=lambda d: d.priority * d.confidence)
secondary = sorted(
[d for d in heuristics if d != main],
key=lambda d: d.priority,
reverse=True,
)[:3]
result = FullAdvice(
main_decision=main,
secondary_decisions=secondary,
board_analysis=self._analyze_board(state),
model_used="heuristic",
confidence_overall=main.confidence,
)
# Enrichissement LLM partiel (stratégie + menaces même si confiance faible)
if llm:
result.strategy_long_term = llm.strategy_long_term
result.threat_assessment = llm.threat_assessment
result.model_used = f"heuristic+{self.settings.llm_model}"
return result
def _analyze_board(self, state: GameState) -> str:
"""Génère une analyse textuelle rapide du board."""
parts = []
if len(state.board_minions) == 7:
parts.append("⚠️ Board plein — vendre avant d'acheter")
if state.gold >= 10 and state.tavern_tier < 6:
parts.append(f"💰 Or abondant — envisager tier {state.tavern_tier + 1}")
if state.current_placement > 5 and state.turn > 8:
parts.append("🚨 Position critique — trouver synergie forte rapidement")
# Détecter synergie dominante
from collections import Counter
races = []
for m in state.board_minions:
r = m.get("race", [])
races.extend(r if isinstance(r, list) else [r])
if races:
top_race, top_count = Counter(races).most_common(1)[0]
if top_count >= 3:
parts.append(f"✨ Synergie {top_race} détectée ({top_count}/7)")
if state.freeze:
parts.append("❄️ Taverne gelée — les cartes sont réservées")
return " | ".join(parts) if parts else "Board standard — continuer normalement"
async def shutdown(self):
"""Arrête proprement les composants."""
await self.llm.shutdown()

View File

@@ -0,0 +1,254 @@
"""
Moteur heuristique HSBG.
Règles métier codées en dur — rapides, déterministes, toujours disponibles.
"""
from collections import Counter
from backend.ai.engine.decision_engine import GameState, Decision
import structlog
log = structlog.get_logger()
class HeuristicEngine:
"""
Évalue l'état de jeu avec des règles métier HSBG.
Chaque règle retourne une Decision ou None.
Triées par priorité × confiance décroissante.
"""
def evaluate(self, state: GameState) -> list[Decision]:
"""Évalue toutes les règles et retourne les décisions candidates."""
rules = [
self._rule_triple, # Priorité max: triple = version dorée
self._rule_upgrade, # Montée de tier
self._rule_freeze, # Geler bonnes cartes
self._rule_buy_synergy, # Acheter dans la synergie
self._rule_sell, # Vendre les faibles
self._rule_economy, # Gestion de l'or
self._rule_reposition, # Positionnement board
]
decisions = []
for rule in rules:
try:
result = rule(state)
if result:
decisions.append(result)
except Exception as e:
log.warning("heuristic.error", rule=rule.__name__, error=str(e))
return sorted(decisions, key=lambda d: d.priority * d.confidence, reverse=True)
# ─── Règles ───────────────────────────────────────────────────────────────
def _rule_triple(self, state: GameState) -> Decision | None:
"""Détecter et compléter les triples — priorité maximale."""
all_minions = state.board_minions + state.hand_minions
counts = Counter(m.get("name") for m in all_minions if m.get("name"))
for name, count in counts.items():
if count >= 2:
# Chercher le 3ème en taverne
for m in state.tavern_minions:
if m.get("name") == name:
can_afford = m.get("cost", 3) <= state.gold
return Decision(
action="buy",
target=m,
priority=9,
confidence=0.90,
reasoning=f"🏆 TRIPLE en vue! Acheter {name} → version dorée!",
warnings=[] if can_afford else [
f"Manque {m.get('cost',3) - state.gold}g — vendre un serviteur si nécessaire"
],
)
return None
def _rule_upgrade(self, state: GameState) -> Decision | None:
"""Monter de tier au bon moment."""
if not state.can_upgrade or state.gold < state.upgrade_cost:
return None
# Montée d'urgence (HP bas + mauvaise position)
if state.current_placement >= 6 and state.hero_hp < 20 and state.upgrade_cost <= 4:
return Decision(
action="upgrade",
priority=8,
confidence=0.80,
reasoning=f"🚨 Urgence: HP={state.hero_hp}, position={state.current_placement} → tier {state.tavern_tier + 1}",
)
# Calendrier optimal de montée
optimal = {1: 3, 2: 5, 3: 7, 4: 10, 5: 14}
target_turn = optimal.get(state.tavern_tier, 99)
if state.turn >= target_turn:
return Decision(
action="upgrade",
priority=6,
confidence=0.65,
reasoning=f"📈 Tour {state.turn}: montée optimale vers tier {state.tavern_tier + 1} ({state.upgrade_cost}g)",
)
# Montée accélérée si beaucoup d'or en retard
if state.gold >= state.upgrade_cost + 4 and state.turn >= target_turn - 2:
return Decision(
action="upgrade",
priority=5,
confidence=0.55,
reasoning=f"💰 Or abondant ({state.gold}g) — montée anticipée tier {state.tavern_tier + 1}",
)
return None
def _rule_freeze(self, state: GameState) -> Decision | None:
"""Geler si de bonnes cartes ne sont pas achetables ce tour."""
if state.freeze:
return None # Déjà gelé
strong = [m for m in state.tavern_minions if self._is_strong(m, state)]
if len(strong) < 2:
return None
affordable = [m for m in strong if m.get("cost", 3) <= state.gold]
unaffordable = [m for m in strong if m.get("cost", 3) > state.gold]
if unaffordable:
return Decision(
action="freeze",
priority=7,
confidence=0.68,
reasoning=f"❄️ Geler: {len(strong)} bonne(s) carte(s), {len(unaffordable)} non achetable(s) ce tour",
synergies_highlighted=[m.get("name", "") for m in strong],
)
return None
def _rule_buy_synergy(self, state: GameState) -> Decision | None:
"""Acheter une carte qui renforce la synergie de race principale."""
if state.gold < 3 or not state.tavern_minions:
return None
# Calculer la race dominante sur le board
races = []
for m in state.board_minions:
r = m.get("race", [])
races.extend(r if isinstance(r, list) else [r])
if not races:
return None
race_counts = Counter(races)
top_race, top_count = race_counts.most_common(1)[0]
if top_count < 2 or top_race in ("none", "", "all"):
return None
# Chercher en taverne une carte de cette race
synergy_targets = []
for m in state.tavern_minions:
m_races = m.get("race", [])
if isinstance(m_races, str):
m_races = [m_races]
if top_race in m_races and m.get("cost", 3) <= state.gold:
synergy_targets.append(m)
if synergy_targets:
# Préférer les cartes avec des effets
best = max(synergy_targets, key=lambda m: (
int(m.get("has_divine", 0)) * 3 +
int(bool(m.get("battlecry"))) * 2 +
int(bool(m.get("deathrattle"))) * 2 +
m.get("attack", 0) + m.get("health", 0)
))
return Decision(
action="buy",
target=best,
priority=7,
confidence=0.72,
reasoning=f"🔗 Renforcer synergie {top_race} ({top_count} sur board): acheter {best.get('name', '?')}",
synergies_highlighted=[top_race],
)
return None
def _rule_sell(self, state: GameState) -> Decision | None:
"""Vendre les serviteurs trop faibles pour libérer de la place."""
if len(state.board_minions) < 6:
return None
weak = [m for m in state.board_minions if self._is_weak(m, state)]
if not weak:
return None
worst = min(weak, key=lambda m: m.get("attack", 0) + m.get("health", 0))
return Decision(
action="sell",
target=worst,
priority=5,
confidence=0.62,
reasoning=f"🗑️ Vendre {worst.get('name', '?')} ({worst.get('attack',0)}/{worst.get('health',0)}) — trop faible en tier {state.tavern_tier}",
)
def _rule_economy(self, state: GameState) -> Decision | None:
"""Gérer prudemment l'or en début de partie."""
if state.gold <= 2 and state.turn < 4:
return Decision(
action="wait",
priority=3,
confidence=0.50,
reasoning=f"💸 Or limité ({state.gold}g) en tour {state.turn} — économiser pour la suite",
warnings=["Éviter les gels coûteux en early game"],
)
return None
def _rule_reposition(self, state: GameState) -> Decision | None:
"""Suggérer un repositionnement si des cartes clés sont mal placées."""
if len(state.board_minions) < 3:
return None
has_taunt = any(m.get("has_taunt") for m in state.board_minions)
has_divine = any(m.get("has_divine") for m in state.board_minions)
has_cleave = any(m.get("on_attack") and "adjacent" in m.get("on_attack","").lower()
for m in state.board_minions)
if has_taunt or has_divine or has_cleave:
tips = []
if has_taunt:
tips.append("Taunt à gauche (absorbe les attaques)")
if has_divine:
tips.append("Divine Shield au centre ou protégé")
if has_cleave:
tips.append("Cleave en position 1 ou 3")
return Decision(
action="reposition",
priority=4,
confidence=0.58,
reasoning=f"🗺️ Optimiser le board: {' | '.join(tips)}",
)
return None
# ─── Helpers ──────────────────────────────────────────────────────────────
def _is_strong(self, minion: dict, state: GameState) -> bool:
"""Un serviteur est fort si ses stats dépassent le seuil du tier actuel."""
stat_thresholds = {1: 4, 2: 7, 3: 10, 4: 14, 5: 18, 6: 22}
min_stats = stat_thresholds.get(state.tavern_tier, 8)
stats = minion.get("attack", 0) + minion.get("health", 0)
return (
stats >= min_stats
or minion.get("has_divine", False)
or minion.get("has_taunt", False)
or bool(minion.get("battlecry", ""))
or bool(minion.get("deathrattle", ""))
or bool(minion.get("passive", ""))
)
def _is_weak(self, minion: dict, state: GameState) -> bool:
"""Un serviteur tier 1-2 sans capacité est faible en mid/late game."""
if state.turn < 8:
return False
tier = int(minion.get("tier", "1"))
if tier > 2:
return False
stats = minion.get("attack", 0) + minion.get("health", 0)
has_ability = (
minion.get("has_divine") or minion.get("has_taunt") or
minion.get("battlecry") or minion.get("deathrattle") or minion.get("passive")
)
return stats < 6 and not has_ability

View File

@@ -0,0 +1,134 @@
"""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()

View File

@@ -0,0 +1,26 @@
"""Parse et normalise l'état de jeu brut en GameState typé."""
from backend.ai.engine.decision_engine import GameState
class StateAnalyzer:
"""Convertit les données brutes (API, OCR, manuel) en GameState."""
async def parse(self, raw: dict) -> GameState:
return GameState(
turn=int(raw.get("turn", 0)),
tavern_tier=int(raw.get("tavern_tier", 1)),
gold=int(raw.get("gold", 3)),
hero_id=str(raw.get("hero_id", "")),
hero_hp=int(raw.get("hero_hp", 40)),
tavern_minions=list(raw.get("tavern_minions", [])),
board_minions=list(raw.get("board_minions", [])),
hand_minions=list(raw.get("hand_minions", [])),
freeze=bool(raw.get("freeze", False)),
can_upgrade=bool(raw.get("can_upgrade", True)),
upgrade_cost=int(raw.get("upgrade_cost", 5)),
available_spells=list(raw.get("available_spells", [])),
opponent_boards=list(raw.get("opponent_boards", [])),
current_placement=int(raw.get("current_placement", 5)),
player_count=int(raw.get("player_count", 8)),
phase=str(raw.get("phase", "recruit")),
)

View File

View File

@@ -0,0 +1,117 @@
"""Système d'apprentissage par feedback utilisateur."""
import json
import os
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.database.models import AIDecision, LearningFeedback
import structlog
log = structlog.get_logger()
class FeedbackProcessor:
"""
Traite les retours utilisateur pour améliorer l'IA.
Workflow:
1. Utilisateur évalue une décision (bon/mauvais/neutre)
2. Le feedback est persisté en DB
3. Un buffer accumule les feedbacks
4. Quand le buffer est plein → export JSON pour entraînement futur
"""
def __init__(self, settings):
self.settings = settings
self._buffer: list[dict] = []
self._trained_count = 0
async def record_feedback(
self,
db: AsyncSession,
decision_id: int,
rating: str,
better_action: dict | None = None,
comment: str | None = None,
) -> LearningFeedback:
"""Enregistre un feedback et met à jour la décision associée."""
decision = await db.get(AIDecision, decision_id)
if not decision:
raise ValueError(f"Décision {decision_id} introuvable")
# Créer le feedback
fb = LearningFeedback(
decision_id=decision_id,
rating=rating,
better_action=better_action,
comment=comment,
)
db.add(fb)
# Mettre à jour la décision avec le résultat
decision.outcome_rating = {"good": 1, "neutral": 0, "bad": -1}.get(rating, 0)
decision.user_feedback = comment
if better_action:
decision.better_decision = better_action
await db.flush()
# Buffer pour entraînement
self._buffer.append({
"decision_id": decision_id,
"game_state": decision.game_state,
"recommendation": decision.recommendation,
"rating": rating,
"better_action": better_action,
"ts": datetime.utcnow().isoformat(),
})
log.info("feedback.recorded", id=decision_id, rating=rating,
buffer=len(self._buffer))
# Auto-flush si buffer plein
if (self.settings.learning_auto_save
and len(self._buffer) >= self.settings.learning_batch_size):
await self._flush_buffer()
return fb
async def _flush_buffer(self):
"""Exporte le buffer en JSON pour entraînement."""
if not self._buffer:
return
os.makedirs("data/learning/feedback", exist_ok=True)
fname = f"data/learning/feedback/batch_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
try:
import aiofiles
async with aiofiles.open(fname, "w") as f:
await f.write(json.dumps(self._buffer, indent=2, ensure_ascii=False))
self._trained_count += len(self._buffer)
log.info("feedback.batch_saved", count=len(self._buffer), file=fname)
except Exception as e:
log.error("feedback.flush_failed", error=str(e))
finally:
self._buffer.clear()
async def force_flush(self):
"""Flush manuel du buffer."""
await self._flush_buffer()
async def get_stats(self, db: AsyncSession) -> dict:
"""Statistiques globales du système d'apprentissage."""
result = await db.execute(select(LearningFeedback))
feedbacks = result.scalars().all()
total = len(feedbacks)
good = sum(1 for f in feedbacks if f.rating == "good")
bad = sum(1 for f in feedbacks if f.rating == "bad")
neutral = total - good - bad
return {
"total": total,
"good": good,
"bad": bad,
"neutral": neutral,
"good_rate": round(good / total * 100, 1) if total > 0 else 0.0,
"trained": self._trained_count,
"buffer_pending": len(self._buffer),
"learning_enabled": self.settings.learning_enabled,
}

View File

View File

View File

@@ -0,0 +1,115 @@
"""Routes API — Conseils IA."""
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database.db import get_db
from backend.database.models import AIDecision
router = APIRouter()
class AdviceRequest(BaseModel):
session_id: int | None = None
turn: int = 0
tavern_tier: int = 1
gold: int = 3
hero_id: str = ""
hero_hp: int = 40
board_minions: list = []
tavern_minions: list = []
hand_minions: list = []
freeze: bool = False
can_upgrade: bool = True
upgrade_cost: int = 5
available_spells: list = []
current_placement: int = 5
player_count: int = 8
phase: str = "recruit"
def serialize_advice(advice) -> dict:
"""Sérialise un objet FullAdvice en dict JSON-serializable."""
return {
"main_decision": {
"action": advice.main_decision.action,
"target": advice.main_decision.target,
"priority": advice.main_decision.priority,
"confidence": advice.main_decision.confidence,
"reasoning": advice.main_decision.reasoning,
"synergies": advice.main_decision.synergies_highlighted,
"warnings": advice.main_decision.warnings,
},
"secondary_decisions": [
{
"action": d.action,
"target": d.target,
"priority": d.priority,
"confidence": d.confidence,
"reasoning": d.reasoning,
}
for d in advice.secondary_decisions
],
"board_analysis": advice.board_analysis,
"strategy_long_term": advice.strategy_long_term,
"threat_assessment": advice.threat_assessment,
"processing_ms": advice.processing_ms,
"model_used": advice.model_used,
"confidence_overall": advice.confidence_overall,
}
@router.post("/")
async def get_advice(
req: AdviceRequest,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Génère un conseil IA pour l'état de jeu fourni."""
ai = request.app.state.ai_service
if not ai:
raise HTTPException(503, "Service IA non initialisé")
state = req.model_dump()
advice = await ai.get_advice(state)
data = serialize_advice(advice)
# Persister si session active
if req.session_id:
dec = AIDecision(
session_id=req.session_id,
turn=req.turn,
phase=req.phase,
game_state=state,
recommendation=data,
reasoning=advice.main_decision.reasoning,
confidence=advice.confidence_overall,
model_used=advice.model_used,
processing_ms=advice.processing_ms,
)
db.add(dec)
await db.flush()
data["decision_id"] = dec.id
return data
@router.get("/from-screen")
async def advice_from_screen(request: Request, db: AsyncSession = Depends(get_db)):
"""Génère un conseil depuis la capture d'écran en cours."""
vis = request.app.state.vision_service
ai = request.app.state.ai_service
if not vis:
raise HTTPException(503, "Service vision non disponible")
# Obtenir l'état actuel (ou déclencher une capture)
state = vis.get_current_state()
if not state:
state = await vis.capture_now()
advice = await ai.get_advice(state)
return {
"advice": serialize_advice(advice),
"screenshot": vis.get_screenshot_b64(),
"extracted_state": state,
}

View File

@@ -0,0 +1,146 @@
"""Routes API — Base de données HSBG (héros, serviteurs, sorts)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from backend.database.db import get_db
from backend.database.models import Hero, Minion, Spell
router = APIRouter()
# ─── Héros ────────────────────────────────────────────────────────────────────
@router.get("/heroes")
async def list_heroes(search: str | None = None, db: AsyncSession = Depends(get_db)):
q = select(Hero).where(Hero.is_active == True)
if search:
q = q.where(Hero.name.ilike(f"%{search}%"))
r = await db.execute(q)
return [
{"id": h.id, "card_id": h.card_id, "name": h.name, "hero_power": h.hero_power,
"description": h.description, "strengths": h.strengths, "weaknesses": h.weaknesses,
"synergies": h.synergies, "tier_rating": h.tier_rating, "patch_added": h.patch_added}
for h in r.scalars().all()
]
class HeroIn(BaseModel):
card_id: str
name: str
hero_power: str = ""
description: str = ""
strengths: list = []
weaknesses: list = []
synergies: list = []
tier_rating: float = 5.0
patch_added: str = ""
@router.post("/heroes", status_code=201)
async def create_hero(data: HeroIn, db: AsyncSession = Depends(get_db)):
hero = Hero(**data.model_dump())
db.add(hero)
await db.flush()
return {"id": hero.id, "name": hero.name}
@router.put("/heroes/{hero_id}")
async def update_hero(hero_id: int, data: HeroIn, db: AsyncSession = Depends(get_db)):
hero = await db.get(Hero, hero_id)
if not hero:
raise HTTPException(404, "Héros introuvable")
for k, v in data.model_dump().items():
setattr(hero, k, v)
return {"id": hero.id, "name": hero.name}
@router.delete("/heroes/{hero_id}")
async def delete_hero(hero_id: int, db: AsyncSession = Depends(get_db)):
hero = await db.get(Hero, hero_id)
if not hero:
raise HTTPException(404, "Héros introuvable")
hero.is_active = False
return {"status": "deactivated"}
# ─── Serviteurs ───────────────────────────────────────────────────────────────
@router.get("/minions")
async def list_minions(
tier: int | None = None,
race: str | None = None,
search: str | None = None,
db: AsyncSession = Depends(get_db),
):
q = select(Minion).where(Minion.is_active == True)
if tier:
q = q.where(Minion.tier == str(tier))
if search:
q = q.where(Minion.name.ilike(f"%{search}%"))
r = await db.execute(q)
minions = r.scalars().all()
if race:
minions = [m for m in minions if race in (m.race or [])]
return [
{"id": m.id, "card_id": m.card_id, "name": m.name, "tier": m.tier,
"race": m.race, "attack": m.attack, "health": m.health,
"has_divine": m.has_divine, "has_taunt": m.has_taunt,
"has_windfury": m.has_windfury, "has_poisonous": m.has_poisonous,
"has_reborn": m.has_reborn, "battlecry": m.battlecry,
"deathrattle": m.deathrattle, "passive": m.passive,
"synergies": m.synergies, "keywords": m.keywords, "patch_added": m.patch_added}
for m in minions
]
class MinionIn(BaseModel):
card_id: str
name: str
tier: str = "1"
race: list = []
attack: int = 0
health: int = 0
tavern_cost: int = 3
has_divine: bool = False
has_taunt: bool = False
has_windfury: bool = False
has_poisonous: bool = False
has_reborn: bool = False
battlecry: str = ""
deathrattle: str = ""
on_attack: str = ""
passive: str = ""
synergies: list = []
keywords: list = []
patch_added: str = ""
@router.post("/minions", status_code=201)
async def create_minion(data: MinionIn, db: AsyncSession = Depends(get_db)):
minion = Minion(**data.model_dump())
db.add(minion)
await db.flush()
return {"id": minion.id, "name": minion.name}
@router.put("/minions/{minion_id}")
async def update_minion(minion_id: int, data: MinionIn, db: AsyncSession = Depends(get_db)):
minion = await db.get(Minion, minion_id)
if not minion:
raise HTTPException(404, "Serviteur introuvable")
for k, v in data.model_dump().items():
setattr(minion, k, v)
return {"id": minion.id, "name": minion.name}
# ─── Sorts ────────────────────────────────────────────────────────────────────
@router.get("/spells")
async def list_spells(db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Spell).where(Spell.is_active == True))
return [
{"id": s.id, "card_id": s.card_id, "name": s.name, "tier": s.tier,
"cost": s.cost, "effect": s.effect, "target": s.target}
for s in r.scalars().all()
]

View File

@@ -0,0 +1,70 @@
"""Routes API — Gestion des sessions de jeu."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.database.db import get_db
from backend.database.models import GameSession
router = APIRouter()
@router.post("/start")
async def start_game(hero_id: str = "", player_count: int = 8,
db: AsyncSession = Depends(get_db)):
"""Démarre une nouvelle session de jeu."""
s = GameSession(is_active=True, session_meta={"player_count": player_count, "hero_id": hero_id})
db.add(s)
await db.flush()
return {"session_id": s.id, "started_at": s.started_at.isoformat()}
@router.post("/{session_id}/end")
async def end_game(session_id: int, final_place: int = 4,
db: AsyncSession = Depends(get_db)):
"""Termine une session avec la place finale."""
s = await db.get(GameSession, session_id)
if not s:
raise HTTPException(404, "Session introuvable")
s.is_active = False
s.ended_at = datetime.utcnow()
s.final_place = final_place
return {"status": "ended", "session_id": session_id, "final_place": final_place}
@router.get("/active")
async def get_active(db: AsyncSession = Depends(get_db)):
"""Retourne la session active ou {'active': false}."""
r = await db.execute(select(GameSession).where(GameSession.is_active == True))
s = r.scalar_one_or_none()
if not s:
return {"active": False}
return {
"active": True,
"session_id": s.id,
"started_at": s.started_at.isoformat(),
"total_turns": s.total_turns,
}
@router.get("/history")
async def get_history(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""Historique des parties terminées."""
from sqlalchemy import desc
r = await db.execute(
select(GameSession)
.where(GameSession.is_active == False)
.order_by(desc(GameSession.ended_at))
.limit(limit)
)
sessions = r.scalars().all()
return [
{
"id": s.id,
"started_at": s.started_at.isoformat(),
"ended_at": s.ended_at.isoformat() if s.ended_at else None,
"final_place": s.final_place,
"total_turns": s.total_turns,
}
for s in sessions
]

View File

@@ -0,0 +1,92 @@
"""Routes API — Mode Apprentissage."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import Literal
from backend.database.db import get_db
from backend.database.models import AIDecision, LearningFeedback
router = APIRouter()
_processor = None
def _get_processor():
global _processor
if not _processor:
from backend.config.settings import get_settings
from backend.ai.learning.feedback_processor import FeedbackProcessor
_processor = FeedbackProcessor(get_settings())
return _processor
class FeedbackIn(BaseModel):
decision_id: int
rating: Literal["good", "bad", "neutral"]
better_action: dict | None = None
comment: str | None = None
@router.post("/feedback")
async def submit_feedback(req: FeedbackIn, db: AsyncSession = Depends(get_db)):
"""Soumet un retour utilisateur sur une décision IA."""
proc = _get_processor()
try:
fb = await proc.record_feedback(
db,
decision_id=req.decision_id,
rating=req.rating,
better_action=req.better_action,
comment=req.comment,
)
return {
"feedback_id": fb.id,
"rating": fb.rating,
"message": "Feedback enregistré — merci pour votre contribution!",
}
except ValueError as e:
raise HTTPException(404, str(e))
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db)):
"""Statistiques globales du système d'apprentissage."""
return await _get_processor().get_stats(db)
@router.get("/decisions")
async def get_decisions(
session_id: int | None = None,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""Historique des décisions IA avec leurs feedbacks."""
q = select(AIDecision).order_by(desc(AIDecision.created_at)).limit(limit)
if session_id:
q = q.where(AIDecision.session_id == session_id)
result = await db.execute(q)
decisions = result.scalars().all()
return [
{
"id": d.id,
"session_id": d.session_id,
"turn": d.turn,
"phase": d.phase,
"recommendation": d.recommendation,
"reasoning": d.reasoning,
"confidence": d.confidence,
"outcome_rating": d.outcome_rating,
"user_feedback": d.user_feedback,
"model_used": d.model_used,
"processing_ms": d.processing_ms,
"created_at": d.created_at.isoformat(),
}
for d in decisions
]
@router.post("/flush")
async def flush_buffer():
"""Force l'export du buffer d'apprentissage."""
await _get_processor().force_flush()
return {"status": "flushed"}

View File

@@ -0,0 +1,25 @@
"""Routes API — Paramètres."""
from fastapi import APIRouter
from backend.config.settings import get_settings
router = APIRouter()
@router.get("/")
async def get_config():
"""Retourne la configuration active (lecture seule)."""
s = get_settings()
return {
"llm_provider": s.llm_provider,
"llm_model": s.llm_model,
"llm_base_url": s.llm_base_url,
"llm_temperature": s.llm_temperature,
"llm_max_tokens": s.llm_max_tokens,
"vision_enabled": s.vision_enabled,
"screenshot_interval": s.screenshot_interval,
"learning_enabled": s.learning_enabled,
"learning_rate": s.learning_rate,
"learning_batch_size": s.learning_batch_size,
"debug": s.debug,
"current_patch": s.current_patch,
}

View File

@@ -0,0 +1,76 @@
"""Routes WebSocket — Mises à jour en temps réel."""
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Request
import structlog
router = APIRouter()
log = structlog.get_logger()
_clients: list[WebSocket] = []
@router.websocket("/game")
async def ws_game(websocket: WebSocket, request: Request):
"""WebSocket principal pour conseils en temps réel."""
await websocket.accept()
_clients.append(websocket)
log.info("ws.client_connected", total=len(_clients))
try:
while True:
raw = await websocket.receive_text()
msg = json.loads(raw)
msg_type = msg.get("type")
if msg_type == "state_update":
# L'état du jeu a changé → calculer un conseil
ai = request.app.state.ai_service
if ai:
advice = await ai.get_advice(msg.get("state", {}))
await websocket.send_json({
"type": "advice",
"data": {
"action": advice.main_decision.action,
"reasoning": advice.main_decision.reasoning,
"confidence": advice.main_decision.confidence,
"warnings": advice.main_decision.warnings,
"board_analysis": advice.board_analysis,
"model": advice.model_used,
"ms": advice.processing_ms,
}
})
elif msg_type == "ping":
await websocket.send_json({"type": "pong", "ts": asyncio.get_event_loop().time()})
elif msg_type == "screenshot_request":
# Demande de capture d'écran
vis = request.app.state.vision_service
if vis:
state = await vis.capture_now()
await websocket.send_json({
"type": "screenshot_result",
"state": state,
"screenshot": vis.get_screenshot_b64(),
})
except WebSocketDisconnect:
_clients.remove(websocket)
log.info("ws.client_disconnected", total=len(_clients))
except Exception as e:
log.error("ws.error", error=str(e))
if websocket in _clients:
_clients.remove(websocket)
async def broadcast(message: dict):
"""Diffuse un message à tous les clients connectés."""
disconnected = []
for client in _clients:
try:
await client.send_json(message)
except Exception:
disconnected.append(client)
for c in disconnected:
_clients.remove(c)

View File

View File

@@ -0,0 +1,67 @@
"""Configuration centralisée via Pydantic Settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings
from pydantic import field_validator
from typing import List
class Settings(BaseSettings):
# Serveur
backend_host: str = "127.0.0.1"
backend_port: int = 8000
debug: bool = True
secret_key: str = "changeme"
# Base de données
database_url: str = "sqlite:///./data/hsbg_ai.db"
# LLM
llm_provider: str = "ollama"
llm_base_url: str = "http://localhost:11434"
llm_model: str = "llama3.2"
llm_fallback_model: str = "mistral"
llm_timeout: int = 30
llm_max_tokens: int = 2048
llm_temperature: float = 0.1
# Vision
vision_enabled: bool = True
screenshot_interval: float = 2.0
tesseract_path: str = "/usr/bin/tesseract"
# Apprentissage
learning_enabled: bool = True
learning_rate: float = 0.001
learning_batch_size: int = 32
learning_auto_save: bool = True
learning_save_interval: int = 300
# Frontend
frontend_host: str = "127.0.0.1"
frontend_port: int = 3000
cors_origins: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# Logs
log_level: str = "INFO"
log_file: str = "./logs/hsbg_ai.log"
# Data
hsbg_data_path: str = "./data/hsbg"
current_patch: str = "30.2"
@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors(cls, v):
if isinstance(v, str):
return [x.strip() for x in v.split(",")]
return v
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

View File

@@ -0,0 +1,33 @@
"""Connexion et sessions de base de données async."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from backend.config.settings import get_settings
from backend.database.models import Base
settings = get_settings()
# Convertir l'URL SQLite en version async
DB_URL = settings.database_url
if DB_URL.startswith("sqlite:///"):
DB_URL = DB_URL.replace("sqlite:///", "sqlite+aiosqlite:///")
engine = create_async_engine(DB_URL, echo=settings.debug, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def init_db():
"""Crée toutes les tables si elles n'existent pas."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_db():
"""Dépendance FastAPI - fournit une session DB."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -0,0 +1,128 @@
"""Modèles SQLAlchemy - Base de données HSBG AI."""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Float, Boolean, Text,
DateTime, JSON, ForeignKey
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class Hero(Base):
__tablename__ = "heroes"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
hero_power = Column(Text, default="")
hp_cost = Column(Integer, default=0)
hp_cooldown = Column(Integer, default=0)
description = Column(Text, default="")
strengths = Column(JSON, default=list)
weaknesses = Column(JSON, default=list)
synergies = Column(JSON, default=list)
tier_rating = Column(Float, default=5.0)
patch_added = Column(String(16), default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Minion(Base):
__tablename__ = "minions"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
tier = Column(String(2), nullable=False, default="1")
race = Column(JSON, default=list)
attack = Column(Integer, default=0)
health = Column(Integer, default=0)
tavern_cost = Column(Integer, default=3)
is_golden = Column(Boolean, default=False)
has_divine = Column(Boolean, default=False)
has_taunt = Column(Boolean, default=False)
has_windfury = Column(Boolean, default=False)
has_poisonous = Column(Boolean, default=False)
has_reborn = Column(Boolean, default=False)
battlecry = Column(Text, default="")
deathrattle = Column(Text, default="")
on_attack = Column(Text, default="")
passive = Column(Text, default="")
synergies = Column(JSON, default=list)
keywords = Column(JSON, default=list)
patch_added = Column(String(16), default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Spell(Base):
__tablename__ = "spells"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
tier = Column(String(2), default="1")
cost = Column(Integer, default=0)
effect = Column(Text, default="")
target = Column(String(64), default="minion")
keywords = Column(JSON, default=list)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
class GameSession(Base):
__tablename__ = "game_sessions"
id = Column(Integer, primary_key=True, index=True)
started_at = Column(DateTime, default=datetime.utcnow)
ended_at = Column(DateTime, nullable=True)
hero_id = Column(Integer, ForeignKey("heroes.id"), nullable=True)
final_place = Column(Integer, nullable=True)
total_turns = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
session_meta = Column(JSON, default=dict)
hero = relationship("Hero")
decisions = relationship("AIDecision", back_populates="session")
class AIDecision(Base):
__tablename__ = "ai_decisions"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False)
turn = Column(Integer, default=0)
phase = Column(String(32), default="recruit")
game_state = Column(JSON, default=dict)
recommendation = Column(JSON, default=dict)
reasoning = Column(Text, default="")
confidence = Column(Float, default=0.5)
was_followed = Column(Boolean, nullable=True)
outcome_rating = Column(Integer, nullable=True)
user_feedback = Column(Text, nullable=True)
better_decision = Column(JSON, nullable=True)
model_used = Column(String(64), default="heuristic")
processing_ms = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
session = relationship("GameSession", back_populates="decisions")
class LearningFeedback(Base):
__tablename__ = "learning_feedback"
id = Column(Integer, primary_key=True, index=True)
decision_id = Column(Integer, ForeignKey("ai_decisions.id"), nullable=False)
rating = Column(String(8), default="neutral")
better_action = Column(JSON, nullable=True)
comment = Column(Text, nullable=True)
trained = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
decision = relationship("AIDecision")
class Patch(Base):
__tablename__ = "patches"
id = Column(Integer, primary_key=True, index=True)
version = Column(String(16), unique=True, nullable=False)
release_date = Column(DateTime)
changes = Column(JSON, default=dict)
notes = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,225 @@
"""
Peuplement initial de la base de données HSBG.
Usage: python -m backend.database.seeds.seed_data
"""
import asyncio
import sys
import os
# Ajouter le répertoire racine au path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from backend.database.db import AsyncSessionLocal, init_db
from backend.database.models import Hero, Minion, Spell
# ─── Données Héros ────────────────────────────────────────────────────────────
HEROES = [
{
"card_id": "HERO_ragnaros", "name": "Ragnaros l'Illuminé", "tier_rating": 7.0,
"hero_power": "Niveau de lancement: Donne +3 ATT à un serviteur ami aléatoire.",
"hp_cost": 2, "hp_cooldown": 0,
"description": "Excellent en mid/late game agressif. Meilleur avec des boards denses.",
"strengths": ["combat", "aggressive_boards", "mid_game"],
"weaknesses": ["divine_shield_heavy", "defensive_boards"],
"synergies": ["beast", "demon", "all"], "patch_added": "15.0",
},
{
"card_id": "HERO_millificent", "name": "Millificent Manastorm", "tier_rating": 6.5,
"hero_power": "Passif: La taverne propose toujours au moins un Mécanisme.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Setup méca rapide et consistant. Idéal pour les synergies méca.",
"strengths": ["mech_setup", "consistency", "divine_shield_generation"],
"weaknesses": ["slow_early", "no_mechs_in_rotation"],
"synergies": ["mech"], "patch_added": "15.0",
},
{
"card_id": "HERO_finley", "name": "Sire Finley de Mrglton", "tier_rating": 7.5,
"hero_power": "Avance vos serviteurs de la taverne. Coût: 2.",
"hp_cost": 2, "hp_cooldown": 0,
"description": "Très polyvalent, s'adapte à toutes les compositions. Top pick en général.",
"strengths": ["flexible", "tempo", "any_comp"],
"weaknesses": ["gold_hungry"],
"synergies": ["all"], "patch_added": "15.0",
},
{
"card_id": "HERO_mukla", "name": "Roi Mukla", "tier_rating": 4.0,
"hero_power": "Passif: Gagnez 1 or de plus par tour (mais donne des Bananes aux adversaires).",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Avantage économique constant mais renforce les adversaires.",
"strengths": ["economy", "fast_tier", "late_game_scaling"],
"weaknesses": ["banana_enemies", "weak_early_game"],
"synergies": ["economy", "late_game"], "patch_added": "16.0",
},
{
"card_id": "HERO_deathwing", "name": "Deathwing", "tier_rating": 5.5,
"hero_power": "Passif: Tous vos serviteurs gagnent +3 ATT.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Buff d'attaque passif. Excellent pour les compositions agressives early.",
"strengths": ["early_aggression", "tempo", "all_minions"],
"weaknesses": ["late_game_scaling", "no_defensive_option"],
"synergies": ["all"], "patch_added": "15.0",
},
{
"card_id": "HERO_jaraxxus", "name": "Lord Jaraxxus", "tier_rating": 6.0,
"hero_power": "Octroie +1/+1 à un Démon ami aléatoire. Coût: 1.",
"hp_cost": 1, "hp_cooldown": 0,
"description": "Scale très bien avec une composition démon.",
"strengths": ["demon_comp", "consistent_scaling"],
"weaknesses": ["requires_demons", "slow_if_no_demons"],
"synergies": ["demon"], "patch_added": "15.0",
},
{
"card_id": "HERO_patchwork", "name": "Patchwork", "tier_rating": 6.0,
"hero_power": "Passif: Votre premier serviteur vendu chaque tour donne +1/+1 à un serviteur ami aléatoire.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Excellent pour les compositions avec beaucoup de ventes.",
"strengths": ["sell_synergy", "flexible", "economy"],
"weaknesses": ["requires_selling_strategy"],
"synergies": ["all"], "patch_added": "17.0",
},
]
# ─── Données Serviteurs ───────────────────────────────────────────────────────
MINIONS = [
# ── Tier 1 ──
{"card_id": "BGS_t1_01", "name": "Gardien de la Sécurité", "tier": "1",
"race": ["mech"], "attack": 2, "health": 3, "tavern_cost": 3,
"has_divine": True, "deathrattle": "Donne +3 ATT à un méca ami aléatoire.",
"synergies": ["mech"], "keywords": ["divine_shield", "deathrattle"], "patch_added": "16.0"},
{"card_id": "BGS_t1_02", "name": "Hyène Affamée", "tier": "1",
"race": ["beast"], "attack": 1, "health": 1, "tavern_cost": 3,
"passive": "Quand un Murloc ami meurt: gagne +2 ATT.",
"synergies": ["beast", "murloc"], "keywords": ["passive"], "patch_added": "15.0"},
{"card_id": "BGS_t1_03", "name": "Murloc Tidecaller", "tier": "1",
"race": ["murloc"], "attack": 1, "health": 2, "tavern_cost": 3,
"passive": "Gagne +1 ATT chaque fois qu'un Murloc est invoqué.",
"synergies": ["murloc"], "keywords": ["passive"], "patch_added": "15.0"},
{"card_id": "BGS_t1_04", "name": "Drone Déchiqueté", "tier": "1",
"race": ["mech"], "attack": 1, "health": 1, "tavern_cost": 3,
"deathrattle": "Invoque deux 1/1 Drones.",
"synergies": ["mech", "token"], "keywords": ["deathrattle"], "patch_added": "15.0"},
# ── Tier 2 ──
{"card_id": "BGS_t2_01", "name": "Dragueur de Taverne", "tier": "2",
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 3,
"battlecry": "Donne +2/+2 à un serviteur ami aléatoire.",
"synergies": [], "keywords": ["battlecry"], "patch_added": "15.0"},
{"card_id": "BGS_t2_02", "name": "Attaquant du Fouet", "tier": "2",
"race": ["murloc"], "attack": 2, "health": 1, "tavern_cost": 3,
"has_poisonous": True, "battlecry": "Donne Venimeux à un Murloc ami aléatoire.",
"synergies": ["murloc"], "keywords": ["poisonous", "battlecry"], "patch_added": "15.0"},
{"card_id": "BGS_t2_03", "name": "Paladin Défenseur", "tier": "2",
"race": ["none"], "attack": 2, "health": 2, "tavern_cost": 3,
"has_divine": True, "has_taunt": True,
"synergies": [], "keywords": ["divine_shield", "taunt"], "patch_added": "15.0"},
# ── Tier 3 ──
{"card_id": "BGS_t3_01", "name": "Infernal de Sang", "tier": "3",
"race": ["demon"], "attack": 3, "health": 4, "tavern_cost": 4,
"has_taunt": True, "deathrattle": "Invoque un 3/3 Infernal.",
"synergies": ["demon"], "keywords": ["taunt", "deathrattle"], "patch_added": "15.0"},
{"card_id": "BGS_t3_02", "name": "Vif-Argent Libre", "tier": "3",
"race": ["mech"], "attack": 3, "health": 4, "tavern_cost": 4,
"has_divine": True,
"passive": "Quand un méca ami gagne un Bouclier Divin: gagne +2 ATT.",
"synergies": ["mech"], "keywords": ["divine_shield", "passive"], "patch_added": "16.0"},
{"card_id": "BGS_t3_03", "name": "Infesteur de Crève-Mort", "tier": "3",
"race": ["undead"], "attack": 3, "health": 3, "tavern_cost": 4,
"deathrattle": "Infeste un serviteur ami aléatoire avec +3/+3.",
"synergies": ["undead"], "keywords": ["deathrattle"], "patch_added": "22.0"},
{"card_id": "BGS_t3_04", "name": "Défenseur de Sombrebourg", "tier": "3",
"race": ["none"], "attack": 2, "health": 5, "tavern_cost": 4,
"has_taunt": True, "passive": "Chaque fois qu'un serviteur ami gagne du Bouclier Divin: gagne +2/+2.",
"synergies": ["divine_shield"], "keywords": ["taunt", "passive"], "patch_added": "16.0"},
# ── Tier 4 ──
{"card_id": "BGS_t4_01", "name": "Brann Bronzebeard", "tier": "4",
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 5,
"passive": "Vos cris de guerre se déclenchent deux fois.",
"synergies": ["battlecry"], "keywords": ["passive"], "patch_added": "17.0"},
{"card_id": "BGS_t4_02", "name": "Chevauche-Tempête", "tier": "4",
"race": ["dragon"], "attack": 5, "health": 4, "tavern_cost": 5,
"battlecry": "Donne +3 ATT à vos dragons amis.",
"synergies": ["dragon"], "keywords": ["battlecry"], "patch_added": "17.0"},
{"card_id": "BGS_t4_03", "name": "Paladin Gardien", "tier": "4",
"race": ["none"], "attack": 3, "health": 3, "tavern_cost": 5,
"has_divine": True, "has_taunt": True,
"passive": "Au début de votre tour: donne Bouclier Divin à un serviteur ami aléatoire.",
"synergies": ["divine_shield"], "keywords": ["divine_shield", "taunt", "passive"], "patch_added": "17.0"},
# ── Tier 5 ──
{"card_id": "BGS_t5_01", "name": "Kangaro Boxeur", "tier": "5",
"race": ["beast"], "attack": 5, "health": 5, "tavern_cost": 6,
"battlecry": "Gagne +1 ATT par serviteur ami sur le board.",
"synergies": ["beast"], "keywords": ["battlecry"], "patch_added": "18.0"},
{"card_id": "BGS_t5_02", "name": "Murozond", "tier": "5",
"race": ["dragon"], "attack": 5, "health": 5, "tavern_cost": 6,
"battlecry": "Gagne les capacités de vos autres types de races sur le board.",
"synergies": ["dragon", "all"], "keywords": ["battlecry"], "patch_added": "19.0"},
# ── Tier 6 ──
{"card_id": "BGS_t6_01", "name": "Amalgame de l'Égout", "tier": "6",
"race": ["all"], "attack": 6, "health": 4, "tavern_cost": 7,
"passive": "Hérite des capacités de toutes les races de vos serviteurs amis.",
"synergies": ["all"], "keywords": ["passive"], "patch_added": "17.0"},
{"card_id": "BGS_t6_02", "name": "Zapp Brannigan", "tier": "6",
"race": ["mech"], "attack": 7, "health": 10, "tavern_cost": 7,
"passive": "Attaque toujours le serviteur ennemi avec l'ATT la plus faible.",
"synergies": ["mech"], "keywords": ["passive"], "patch_added": "15.0"},
]
# ─── Données Sorts ────────────────────────────────────────────────────────────
SPELLS = [
{"card_id": "SP_01", "name": "Soif de Sang", "tier": "1", "cost": 0,
"effect": "Donne +3/+1 à un serviteur ami.", "target": "minion", "keywords": ["buff"]},
{"card_id": "SP_02", "name": "Bière Frelatée", "tier": "1", "cost": 0,
"effect": "Donne +1/+1 à tous vos serviteurs.", "target": "board", "keywords": ["aoe_buff"]},
{"card_id": "SP_03", "name": "Armure Réactive", "tier": "2", "cost": 1,
"effect": "Votre héros gagne +4 armure.", "target": "hero", "keywords": ["armor"]},
{"card_id": "SP_04", "name": "Maître en Cris de Guerre", "tier": "3", "cost": 2,
"effect": "Déclenche le cri de guerre d'un serviteur ami.", "target": "minion", "keywords": ["battlecry"]},
{"card_id": "SP_05", "name": "Huile de Pierre Sacrée", "tier": "4", "cost": 2,
"effect": "Donne Bouclier Divin à tous vos serviteurs.", "target": "board", "keywords": ["divine_shield"]},
]
async def seed():
print("\n🌱 Peuplement de la base de données HSBG...")
await init_db()
async with AsyncSessionLocal() as db:
# Héros
for h in HEROES:
db.add(Hero(**h))
await db.commit()
print(f"{len(HEROES)} héros ajoutés")
# Serviteurs
for m in MINIONS:
db.add(Minion(**m))
await db.commit()
print(f"{len(MINIONS)} serviteurs ajoutés")
# Sorts
for s in SPELLS:
db.add(Spell(**s))
await db.commit()
print(f"{len(SPELLS)} sorts ajoutés")
print("\n✅ Base de données HSBG prête!\n")
if __name__ == "__main__":
asyncio.run(seed())

109
hsbg_ai/backend/main.py Normal file
View File

@@ -0,0 +1,109 @@
"""HSBG AI Assistant — Application FastAPI principale."""
from contextlib import asynccontextmanager
from pathlib import Path
import structlog
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.api.routes.advice import router as advice_router
from backend.api.routes.database_routes import router as db_router
from backend.api.routes.game import router as game_router
from backend.api.routes.learning import router as learning_router
from backend.api.routes.settings_routes import router as settings_router
from backend.api.routes.websocket_routes import router as ws_router
from backend.config.settings import get_settings
from backend.database.db import init_db
from backend.services.ai_service import AIService
from backend.services.vision_service import VisionService
log = structlog.get_logger()
cfg = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Cycle de vie: démarrage → running → arrêt."""
log.info("hsbg_ai.starting", version="1.0.0")
# Initialiser la base de données
await init_db()
log.info("database.ready")
# Initialiser le service IA
ai = AIService(cfg)
await ai.initialize()
app.state.ai_service = ai
# Initialiser le service Vision
vis = VisionService(cfg)
if cfg.vision_enabled:
await vis.start()
app.state.vision_service = vis
log.info("hsbg_ai.ready", port=cfg.backend_port, llm=cfg.llm_model)
yield # Application en cours d'exécution
# Arrêt propre
log.info("hsbg_ai.stopping")
await vis.stop()
await ai.shutdown()
log.info("hsbg_ai.stopped")
def create_app() -> FastAPI:
app = FastAPI(
title="HSBG AI Assistant",
description="IA d'assistance temps réel pour Hearthstone Battlegrounds",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS pour le frontend React
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes API REST
app.include_router(game_router, prefix="/api/game", tags=["Partie"])
app.include_router(advice_router, prefix="/api/advice", tags=["Conseils IA"])
app.include_router(learning_router, prefix="/api/learning", tags=["Apprentissage"])
app.include_router(db_router, prefix="/api/database", tags=["Base HSBG"])
app.include_router(settings_router, prefix="/api/settings", tags=["Paramètres"])
app.include_router(ws_router, prefix="/ws", tags=["WebSocket"])
# Servir le frontend buildé si disponible
frontend_dist = Path("frontend/dist")
if frontend_dist.exists():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="static")
@app.get("/health", tags=["Système"])
async def health():
return {
"status": "ok",
"version": "1.0.0",
"llm_model": cfg.llm_model,
"patch": cfg.current_patch,
}
return app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"backend.main:app",
host=cfg.backend_host,
port=cfg.backend_port,
reload=cfg.debug,
log_config=None,
)

View File

View File

@@ -0,0 +1,24 @@
"""Service IA — façade pour le moteur de décision."""
from backend.ai.engine.decision_engine import DecisionEngine, FullAdvice
from backend.ai.learning.feedback_processor import FeedbackProcessor
import structlog
log = structlog.get_logger()
class AIService:
def __init__(self, settings):
self.settings = settings
self.engine = DecisionEngine(settings)
self.feedback = FeedbackProcessor(settings)
async def initialize(self):
await self.engine.initialize()
log.info("ai_service.ready")
async def get_advice(self, state: dict) -> FullAdvice:
return await self.engine.get_advice(state)
async def shutdown(self):
await self.engine.shutdown()
log.info("ai_service.stopped")

View File

@@ -0,0 +1,26 @@
"""Service Vision — façade pour le gestionnaire de captures d'écran."""
from backend.vision.screenshot_manager import ScreenshotManager
import structlog
log = structlog.get_logger()
class VisionService:
def __init__(self, settings):
self.settings = settings
self._mgr = ScreenshotManager(settings)
async def start(self):
await self._mgr.start()
async def stop(self):
await self._mgr.stop()
def get_current_state(self) -> dict:
return self._mgr.get_state()
def get_screenshot_b64(self) -> str | None:
return self._mgr.get_b64()
async def capture_now(self) -> dict:
return await self._mgr.capture_now()

View File

View File

View File

@@ -0,0 +1,195 @@
"""Capture d'écran continue + extraction OCR de l'état HSBG."""
import asyncio
import base64
import io
import re
import structlog
log = structlog.get_logger()
try:
import mss
import mss.tools
MSS_OK = True
except ImportError:
MSS_OK = False
log.warning("vision.mss_missing", install="pip install mss")
try:
from PIL import Image, ImageEnhance, ImageFilter
PIL_OK = True
except ImportError:
PIL_OK = False
try:
import pytesseract
OCR_OK = True
except ImportError:
OCR_OK = False
log.warning("vision.tesseract_missing",
install="sudo apt-get install tesseract-ocr && pip install pytesseract")
class ScreenshotManager:
"""
Capture et analyse les frames du jeu HSBG.
Zones de capture (coordonnées relatives 0.0-1.0):
- gold: haut gauche (or disponible)
- tier: haut droite (niveau de taverne)
- hero_hp: centre haut (HP du héros)
- board: centre (board du joueur)
- tavern: bas (serviteurs en taverne)
"""
ZONES = {
"gold": (0.02, 0.02, 0.12, 0.10),
"tier": (0.85, 0.02, 0.98, 0.14),
"hero_hp": (0.44, 0.01, 0.56, 0.09),
"board": (0.10, 0.38, 0.90, 0.72),
"tavern": (0.02, 0.72, 0.98, 0.98),
}
def __init__(self, settings):
self.settings = settings
self._running = False
self._latest_bytes: bytes | None = None
self._latest_state: dict = {}
self._task: asyncio.Task | None = None
if OCR_OK and settings.tesseract_path:
pytesseract.pytesseract.tesseract_cmd = settings.tesseract_path
async def start(self):
"""Démarre la boucle de capture en arrière-plan."""
if not MSS_OK:
log.warning("vision.skipped", reason="mss non installé")
return
self._running = True
self._task = asyncio.create_task(self._loop())
log.info("vision.capture_started", interval=self.settings.screenshot_interval)
async def stop(self):
"""Arrête proprement la boucle de capture."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
log.info("vision.capture_stopped")
async def _loop(self):
"""Boucle principale de capture."""
while self._running:
try:
await self._tick()
await asyncio.sleep(self.settings.screenshot_interval)
except asyncio.CancelledError:
break
except Exception as e:
log.warning("vision.loop_error", error=str(e))
await asyncio.sleep(2)
async def _tick(self):
"""Une itération: capture + analyse."""
loop = asyncio.get_event_loop()
data = await loop.run_in_executor(None, self._grab)
if data:
self._latest_bytes = data
state = await loop.run_in_executor(None, self._extract, data)
if state:
self._latest_state = state
def _grab(self) -> bytes | None:
"""Prend une capture d'écran (exécuté dans un thread)."""
if not MSS_OK:
return None
try:
with mss.mss() as sct:
monitor = sct.monitors[1] # Écran principal
frame = sct.grab(monitor)
return mss.tools.to_png(frame.rgb, frame.size)
except Exception as e:
log.warning("screenshot.grab_failed", error=str(e))
return None
def _extract(self, data: bytes) -> dict:
"""Extrait les valeurs numériques via OCR (exécuté dans un thread)."""
if not PIL_OK or not OCR_OK:
return {}
try:
img = Image.open(io.BytesIO(data))
state = {}
for zone_name, coords in self.ZONES.items():
crop = self._crop(img, coords)
if crop:
text = self._ocr(crop)
if text:
state[f"raw_{zone_name}"] = text
# Parser les valeurs numériques
state["gold"] = self._parse_num(state.get("raw_gold", ""))
state["tavern_tier"] = self._parse_num(state.get("raw_tier", ""))
state["hero_hp"] = self._parse_num(state.get("raw_hero_hp", ""))
return state
except Exception as e:
log.warning("vision.extract_failed", error=str(e))
return {}
def _crop(self, img, coords: tuple):
"""Découpe une zone de l'image."""
if not PIL_OK:
return None
try:
w, h = img.size
x1, y1, x2, y2 = coords
return img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h)))
except Exception:
return None
def _ocr(self, img) -> str:
"""OCR optimisé pour HSBG (chiffres + lettres)."""
if not OCR_OK or not PIL_OK:
return ""
try:
# Pipeline de prétraitement pour améliorer l'OCR
enhanced = ImageEnhance.Contrast(img).enhance(2.5)
enhanced = enhanced.convert("L") # Grayscale
enhanced = enhanced.filter(ImageFilter.SHARPEN)
# Agrandir pour meilleure précision
w, h = enhanced.size
enhanced = enhanced.resize((w * 2, h * 2), Image.LANCZOS)
text = pytesseract.image_to_string(
enhanced,
config="--psm 7 --oem 3 -c tessedit_char_whitelist=0123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ",
)
return text.strip()
except Exception:
return ""
def _parse_num(self, text: str) -> int:
"""Extrait un nombre d'un texte OCR."""
nums = re.findall(r"\d+", text or "")
return int(nums[0]) if nums else 0
# ─── API publique ─────────────────────────────────────────────────────────
def get_b64(self) -> str | None:
"""Dernière capture en base64 (pour affichage frontend)."""
if self._latest_bytes:
return base64.b64encode(self._latest_bytes).decode()
return None
def get_state(self) -> dict:
"""Dernier état extrait."""
return self._latest_state.copy()
async def capture_now(self) -> dict:
"""Déclenche une capture manuelle et retourne l'état."""
await self._tick()
return self.get_state()