Initial commit
This commit is contained in:
0
hsbg_ai/backend/__init__.py
Normal file
0
hsbg_ai/backend/__init__.py
Normal file
0
hsbg_ai/backend/ai/__init__.py
Normal file
0
hsbg_ai/backend/ai/__init__.py
Normal file
0
hsbg_ai/backend/ai/engine/__init__.py
Normal file
0
hsbg_ai/backend/ai/engine/__init__.py
Normal file
196
hsbg_ai/backend/ai/engine/decision_engine.py
Normal file
196
hsbg_ai/backend/ai/engine/decision_engine.py
Normal 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()
|
||||
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal file
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal 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
|
||||
134
hsbg_ai/backend/ai/engine/llm_advisor.py
Normal file
134
hsbg_ai/backend/ai/engine/llm_advisor.py
Normal 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()
|
||||
26
hsbg_ai/backend/ai/engine/state_analyzer.py
Normal file
26
hsbg_ai/backend/ai/engine/state_analyzer.py
Normal 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")),
|
||||
)
|
||||
0
hsbg_ai/backend/ai/learning/__init__.py
Normal file
0
hsbg_ai/backend/ai/learning/__init__.py
Normal file
117
hsbg_ai/backend/ai/learning/feedback_processor.py
Normal file
117
hsbg_ai/backend/ai/learning/feedback_processor.py
Normal 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,
|
||||
}
|
||||
0
hsbg_ai/backend/api/__init__.py
Normal file
0
hsbg_ai/backend/api/__init__.py
Normal file
0
hsbg_ai/backend/api/middleware/__init__.py
Normal file
0
hsbg_ai/backend/api/middleware/__init__.py
Normal file
0
hsbg_ai/backend/api/routes/__init__.py
Normal file
0
hsbg_ai/backend/api/routes/__init__.py
Normal file
115
hsbg_ai/backend/api/routes/advice.py
Normal file
115
hsbg_ai/backend/api/routes/advice.py
Normal 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,
|
||||
}
|
||||
146
hsbg_ai/backend/api/routes/database_routes.py
Normal file
146
hsbg_ai/backend/api/routes/database_routes.py
Normal 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()
|
||||
]
|
||||
70
hsbg_ai/backend/api/routes/game.py
Normal file
70
hsbg_ai/backend/api/routes/game.py
Normal 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
|
||||
]
|
||||
92
hsbg_ai/backend/api/routes/learning.py
Normal file
92
hsbg_ai/backend/api/routes/learning.py
Normal 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"}
|
||||
25
hsbg_ai/backend/api/routes/settings_routes.py
Normal file
25
hsbg_ai/backend/api/routes/settings_routes.py
Normal 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,
|
||||
}
|
||||
76
hsbg_ai/backend/api/routes/websocket_routes.py
Normal file
76
hsbg_ai/backend/api/routes/websocket_routes.py
Normal 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)
|
||||
0
hsbg_ai/backend/config/__init__.py
Normal file
0
hsbg_ai/backend/config/__init__.py
Normal file
67
hsbg_ai/backend/config/settings.py
Normal file
67
hsbg_ai/backend/config/settings.py
Normal 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()
|
||||
0
hsbg_ai/backend/database/__init__.py
Normal file
0
hsbg_ai/backend/database/__init__.py
Normal file
33
hsbg_ai/backend/database/db.py
Normal file
33
hsbg_ai/backend/database/db.py
Normal 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()
|
||||
0
hsbg_ai/backend/database/migrations/__init__.py
Normal file
0
hsbg_ai/backend/database/migrations/__init__.py
Normal file
128
hsbg_ai/backend/database/models.py
Normal file
128
hsbg_ai/backend/database/models.py
Normal 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)
|
||||
0
hsbg_ai/backend/database/seeds/__init__.py
Normal file
0
hsbg_ai/backend/database/seeds/__init__.py
Normal file
225
hsbg_ai/backend/database/seeds/seed_data.py
Normal file
225
hsbg_ai/backend/database/seeds/seed_data.py
Normal 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
109
hsbg_ai/backend/main.py
Normal 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,
|
||||
)
|
||||
0
hsbg_ai/backend/services/__init__.py
Normal file
0
hsbg_ai/backend/services/__init__.py
Normal file
24
hsbg_ai/backend/services/ai_service.py
Normal file
24
hsbg_ai/backend/services/ai_service.py
Normal 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")
|
||||
26
hsbg_ai/backend/services/vision_service.py
Normal file
26
hsbg_ai/backend/services/vision_service.py
Normal 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()
|
||||
0
hsbg_ai/backend/utils/__init__.py
Normal file
0
hsbg_ai/backend/utils/__init__.py
Normal file
0
hsbg_ai/backend/vision/__init__.py
Normal file
0
hsbg_ai/backend/vision/__init__.py
Normal file
195
hsbg_ai/backend/vision/screenshot_manager.py
Normal file
195
hsbg_ai/backend/vision/screenshot_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user