commit f60d9628e0cf016d46b726b3af7c947be5014e60 Author: Alexandre Gut Date: Tue Mar 31 13:10:46 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f12947 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# ── Node.js / React ─────────────────────────────────────────────────────────── +node_modules/ +dist/ +build/ +.next/ +.nuxt/ +.vite/ +*.tsbuildinfo + +# ── Python ──────────────────────────────────────────────────────────────────── +__pycache__/ +*.py[cod] +venv/ +.venv/ +env/ + +# ── Secrets ──────────────────────────────────────────────────────────────────── +.env +.env.* +.env.local +.env.production +secret.env +*.secret +credentials.json + +# ── Logs ────────────────────────────────────────────────────────────────────── +*.log +logs/ +npm-debug.log* +yarn-debug.log* + +# ── Modèles IA (trop lourds) ────────────────────────────────────────────────── +*.gguf +*.bin +*.safetensors +models/ + +# ── Cache ───────────────────────────────────────────────────────────────────── +.cache/ +.parcel-cache/ +.pytest_cache/ +.coverage + +# ── IDE ─────────────────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db diff --git a/hsbg_ai/backend/__init__.py b/hsbg_ai/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/ai/__init__.py b/hsbg_ai/backend/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/ai/engine/__init__.py b/hsbg_ai/backend/ai/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/ai/engine/decision_engine.py b/hsbg_ai/backend/ai/engine/decision_engine.py new file mode 100644 index 0000000..665873c --- /dev/null +++ b/hsbg_ai/backend/ai/engine/decision_engine.py @@ -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() diff --git a/hsbg_ai/backend/ai/engine/heuristics.py b/hsbg_ai/backend/ai/engine/heuristics.py new file mode 100644 index 0000000..8a68232 --- /dev/null +++ b/hsbg_ai/backend/ai/engine/heuristics.py @@ -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 diff --git a/hsbg_ai/backend/ai/engine/llm_advisor.py b/hsbg_ai/backend/ai/engine/llm_advisor.py new file mode 100644 index 0000000..fbae429 --- /dev/null +++ b/hsbg_ai/backend/ai/engine/llm_advisor.py @@ -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() diff --git a/hsbg_ai/backend/ai/engine/state_analyzer.py b/hsbg_ai/backend/ai/engine/state_analyzer.py new file mode 100644 index 0000000..1854cf5 --- /dev/null +++ b/hsbg_ai/backend/ai/engine/state_analyzer.py @@ -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")), + ) diff --git a/hsbg_ai/backend/ai/learning/__init__.py b/hsbg_ai/backend/ai/learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/ai/learning/feedback_processor.py b/hsbg_ai/backend/ai/learning/feedback_processor.py new file mode 100644 index 0000000..c5eb41e --- /dev/null +++ b/hsbg_ai/backend/ai/learning/feedback_processor.py @@ -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, + } diff --git a/hsbg_ai/backend/api/__init__.py b/hsbg_ai/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/api/middleware/__init__.py b/hsbg_ai/backend/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/api/routes/__init__.py b/hsbg_ai/backend/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/api/routes/advice.py b/hsbg_ai/backend/api/routes/advice.py new file mode 100644 index 0000000..414b380 --- /dev/null +++ b/hsbg_ai/backend/api/routes/advice.py @@ -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, + } diff --git a/hsbg_ai/backend/api/routes/database_routes.py b/hsbg_ai/backend/api/routes/database_routes.py new file mode 100644 index 0000000..b24eda4 --- /dev/null +++ b/hsbg_ai/backend/api/routes/database_routes.py @@ -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() + ] diff --git a/hsbg_ai/backend/api/routes/game.py b/hsbg_ai/backend/api/routes/game.py new file mode 100644 index 0000000..82c57f5 --- /dev/null +++ b/hsbg_ai/backend/api/routes/game.py @@ -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 + ] diff --git a/hsbg_ai/backend/api/routes/learning.py b/hsbg_ai/backend/api/routes/learning.py new file mode 100644 index 0000000..2d94a3b --- /dev/null +++ b/hsbg_ai/backend/api/routes/learning.py @@ -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"} diff --git a/hsbg_ai/backend/api/routes/settings_routes.py b/hsbg_ai/backend/api/routes/settings_routes.py new file mode 100644 index 0000000..83b1555 --- /dev/null +++ b/hsbg_ai/backend/api/routes/settings_routes.py @@ -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, + } diff --git a/hsbg_ai/backend/api/routes/websocket_routes.py b/hsbg_ai/backend/api/routes/websocket_routes.py new file mode 100644 index 0000000..7537bf4 --- /dev/null +++ b/hsbg_ai/backend/api/routes/websocket_routes.py @@ -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) diff --git a/hsbg_ai/backend/config/__init__.py b/hsbg_ai/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/config/settings.py b/hsbg_ai/backend/config/settings.py new file mode 100644 index 0000000..52474c3 --- /dev/null +++ b/hsbg_ai/backend/config/settings.py @@ -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() diff --git a/hsbg_ai/backend/database/__init__.py b/hsbg_ai/backend/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/database/db.py b/hsbg_ai/backend/database/db.py new file mode 100644 index 0000000..f3aa12a --- /dev/null +++ b/hsbg_ai/backend/database/db.py @@ -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() diff --git a/hsbg_ai/backend/database/migrations/__init__.py b/hsbg_ai/backend/database/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/database/models.py b/hsbg_ai/backend/database/models.py new file mode 100644 index 0000000..063e108 --- /dev/null +++ b/hsbg_ai/backend/database/models.py @@ -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) diff --git a/hsbg_ai/backend/database/seeds/__init__.py b/hsbg_ai/backend/database/seeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/database/seeds/seed_data.py b/hsbg_ai/backend/database/seeds/seed_data.py new file mode 100644 index 0000000..c4d0f55 --- /dev/null +++ b/hsbg_ai/backend/database/seeds/seed_data.py @@ -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()) diff --git a/hsbg_ai/backend/main.py b/hsbg_ai/backend/main.py new file mode 100644 index 0000000..b51798e --- /dev/null +++ b/hsbg_ai/backend/main.py @@ -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, + ) diff --git a/hsbg_ai/backend/services/__init__.py b/hsbg_ai/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/services/ai_service.py b/hsbg_ai/backend/services/ai_service.py new file mode 100644 index 0000000..3ba8dc0 --- /dev/null +++ b/hsbg_ai/backend/services/ai_service.py @@ -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") diff --git a/hsbg_ai/backend/services/vision_service.py b/hsbg_ai/backend/services/vision_service.py new file mode 100644 index 0000000..19e9ed1 --- /dev/null +++ b/hsbg_ai/backend/services/vision_service.py @@ -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() diff --git a/hsbg_ai/backend/utils/__init__.py b/hsbg_ai/backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/vision/__init__.py b/hsbg_ai/backend/vision/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hsbg_ai/backend/vision/screenshot_manager.py b/hsbg_ai/backend/vision/screenshot_manager.py new file mode 100644 index 0000000..369c2d4 --- /dev/null +++ b/hsbg_ai/backend/vision/screenshot_manager.py @@ -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() diff --git a/hsbg_ai/frontend/index.html b/hsbg_ai/frontend/index.html new file mode 100644 index 0000000..d867154 --- /dev/null +++ b/hsbg_ai/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + HSBG AI Assistant + + +
+ + + diff --git a/hsbg_ai/frontend/package.json b/hsbg_ai/frontend/package.json new file mode 100644 index 0000000..fb614f0 --- /dev/null +++ b/hsbg_ai/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "hsbg-ai-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "axios": "^1.7.7", + "lucide-react": "^0.447.0", + "recharts": "^2.12.7", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "vite": "^5.4.8" + } +} diff --git a/hsbg_ai/frontend/postcss.config.js b/hsbg_ai/frontend/postcss.config.js new file mode 100644 index 0000000..82731cb --- /dev/null +++ b/hsbg_ai/frontend/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } } diff --git a/hsbg_ai/frontend/src/App.jsx b/hsbg_ai/frontend/src/App.jsx new file mode 100644 index 0000000..aaba33c --- /dev/null +++ b/hsbg_ai/frontend/src/App.jsx @@ -0,0 +1,56 @@ +import { Routes, Route, NavLink } from 'react-router-dom' +import { Activity, Sword, Brain, Database, Settings } from 'lucide-react' +import DashboardPage from './pages/DashboardPage' +import GamePage from './pages/GamePage' +import LearningPage from './pages/LearningPage' +import DatabasePage from './pages/DatabasePage' +import SettingsPage from './pages/SettingsPage' + +const NAV = [ + { to: '/', icon: Activity, label: 'Dashboard' }, + { to: '/game', icon: Sword, label: 'Partie' }, + { to: '/learning', icon: Brain, label: 'Apprentissage' }, + { to: '/database', icon: Database, label: 'Base HSBG' }, + { to: '/settings', icon: Settings, label: 'Paramètres' }, +] + +export default function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/hsbg_ai/frontend/src/index.css b/hsbg_ai/frontend/src/index.css new file mode 100644 index 0000000..7f83a63 --- /dev/null +++ b/hsbg_ai/frontend/src/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-950 text-gray-100; + background: radial-gradient(ellipse at top, #1c1010 0%, #080808 100%); + min-height: 100vh; +} + +.card-glow { box-shadow: 0 0 20px rgba(245,179,10,0.3); } +.confidence-bar { @apply h-1.5 rounded-full transition-all; } diff --git a/hsbg_ai/frontend/src/main.jsx b/hsbg_ai/frontend/src/main.jsx new file mode 100644 index 0000000..40bea91 --- /dev/null +++ b/hsbg_ai/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +) diff --git a/hsbg_ai/frontend/src/pages/DashboardPage.jsx b/hsbg_ai/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..216b013 --- /dev/null +++ b/hsbg_ai/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' +import { Activity, Brain, Zap, Target, CheckCircle, XCircle } from 'lucide-react' + +function StatCard({ icon: Icon, label, value, sub, color }) { + return ( +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ) +} + +export default function DashboardPage() { + const [health, setHealth] = useState(null) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + axios.get('/health').then(r => setHealth(r.data)).catch(() => {}), + axios.get('/api/learning/stats').then(r => setStats(r.data)).catch(() => {}), + ]).finally(() => setLoading(false)) + }, []) + + return ( +
+
+

HSBG AI Assistant

+

Intelligence artificielle locale pour Hearthstone Battlegrounds

+
+ + {/* Status serveur */} +
+ {health ? ( + + + Serveur actif · v{health.version} · Modèle: {health.llm_model} + + ) : ( + + + Serveur hors ligne + + )} +
+ + {/* Stats */} + {!loading && stats && ( +
+ + + + +
+ )} + + {/* Guide rapide */} +
+
+

🚀 Démarrage rapide

+
    + {[ + ['Partie', 'Démarrer une session et saisir l\'état du jeu'], + ['Conseil', 'L\'IA génère des recommandations en temps réel'], + ['Feedback', 'Évaluer les conseils pour entraîner l\'IA'], + ['Base HSBG', 'Consulter et enrichir les données de cartes'], + ['Paramètres', 'Configurer le LLM et la vision'], + ].map(([page, desc], i) => ( +
  1. + {i + 1}. + {page} — {desc} +
  2. + ))} +
+
+ +
+

🧠 Configuration LLM

+
+
+

# Installer Ollama

+

curl -fsSL https://ollama.ai/install.sh | sh

+
+
+

# Télécharger un modèle

+

ollama pull llama3.2

+
+

+ Sans LLM, l'IA fonctionne en mode heuristique (rapide, pas de raisonnement naturel). +

+
+
+
+
+ ) +} diff --git a/hsbg_ai/frontend/src/pages/DatabasePage.jsx b/hsbg_ai/frontend/src/pages/DatabasePage.jsx new file mode 100644 index 0000000..116dec8 --- /dev/null +++ b/hsbg_ai/frontend/src/pages/DatabasePage.jsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' +import { Database, Shield, Sword, Search } from 'lucide-react' + +const TIER_COLORS = { + '1': 'bg-gray-600', '2': 'bg-green-800', '3': 'bg-blue-800', + '4': 'bg-purple-800', '5': 'bg-orange-800', '6': 'bg-red-800', +} + +export default function DatabasePage() { + const [tab, setTab] = useState('heroes') + const [data, setData] = useState([]) + const [search, setSearch] = useState('') + const [tier, setTier] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + setLoading(true) + const params = new URLSearchParams() + if (search) params.append('search', search) + if (tier && tab === 'minions') params.append('tier', tier) + const url = `/api/database/${tab === 'heroes' ? 'heroes' : 'minions'}?${params}` + axios.get(url).then(r => setData(r.data)).catch(() => setData([])).finally(() => setLoading(false)) + }, [tab, search, tier]) + + return ( +
+
+ +
+

Base de données HSBG

+

Héros, serviteurs et sorts du patch {import.meta.env?.VITE_PATCH || 'actuel'}

+
+
+ +
+ {[{ id: 'heroes', icon: Shield, label: 'Héros' }, { id: 'minions', icon: Sword, label: 'Serviteurs' }].map(({ id, icon: Icon, label }) => ( + + ))} +
+ +
+
+ + setSearch(e.target.value)} + placeholder={`Rechercher ${tab === 'heroes' ? 'un héros' : 'un serviteur'}...`} + className="w-full bg-gray-900 border border-gray-700 rounded-xl pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-600" /> +
+ {tab === 'minions' && ( + + )} +
+ + {loading ? ( +

Chargement...

+ ) : tab === 'heroes' ? ( +
+ {data.map(h => ( +
+
+

{h.name}

+
+ {h.tier_rating} + /10 +
+
+

{h.hero_power}

+ {h.description &&

{h.description}

} +
+ {(h.strengths || []).map(s => ( + {s} + ))} +
+
+ {(h.weaknesses || []).map(w => ( + {w} + ))} +
+
+ ))} +
+ ) : ( +
+
+ + + + {['Nom', 'Tier', 'Race', 'ATT', 'HP', 'Capacités', 'Description'].map(h => ( + + ))} + + + + {data.map(m => ( + + + + + + + + + + ))} + +
{h}
{m.name} + + T{m.tier} + + + {Array.isArray(m.race) ? m.race.join(', ') : m.race} + {m.attack}{m.health} +
+ {m.has_divine && Divine} + {m.has_taunt && Taunt} + {m.has_windfury && Windfury} + {m.has_poisonous && Venin} + {m.has_reborn && Reborn} + {m.battlecry && Cri} + {m.deathrattle && Mort} + {m.passive && Passif} +
+
+ {m.battlecry || m.deathrattle || m.passive || '—'} +
+ {data.length === 0 && ( +

Aucun résultat — vérifiez la base de données

+ )} +
+
+ )} +
+ ) +} diff --git a/hsbg_ai/frontend/src/pages/GamePage.jsx b/hsbg_ai/frontend/src/pages/GamePage.jsx new file mode 100644 index 0000000..c8ae38c --- /dev/null +++ b/hsbg_ai/frontend/src/pages/GamePage.jsx @@ -0,0 +1,406 @@ +import { useState, useCallback } from 'react' +import axios from 'axios' +import { Play, Square, RefreshCw, Monitor, Swords, Plus, X, ThumbsUp, ThumbsDown, Minus } from 'lucide-react' + +const ACTION_MAP = { + buy: { label: 'Acheter', color: 'text-green-400' }, + sell: { label: 'Vendre', color: 'text-red-400' }, + freeze: { label: 'Geler', color: 'text-blue-400' }, + upgrade: { label: 'Monter tier', color: 'text-yellow-400' }, + reposition: { label: 'Repositionner', color: 'text-purple-400' }, + hero_power: { label: 'Pouvoir héros', color: 'text-orange-400' }, + wait: { label: 'Attendre', color: 'text-gray-400' }, +} + +function ConfBar({ value }) { + const pct = Math.round((value || 0) * 100) + const col = pct >= 70 ? 'bg-green-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-red-500' + return ( +
+
+ Confiance{pct}% +
+
+
+
+
+ ) +} + +function MinionChip({ minion, onRemove }) { + return ( +
+ {onRemove && ( + + )} +

{minion.name || '?'}

+

{minion.attack || 0}/{minion.health || 0}

+ {minion.race && ( +

+ {Array.isArray(minion.race) ? minion.race.join(', ') : minion.race} +

+ )} +
+ ) +} + +function AddMinionButton({ onClick }) { + return ( + + ) +} + +function FeedbackBar({ decisionId, onDone }) { + const [sent, setSent] = useState(false) + const [better, setBetter] = useState('') + const [comment, setComment] = useState('') + + const send = async (rating) => { + try { + await axios.post('/api/learning/feedback', { + decision_id: decisionId, rating, + better_action: better ? { action: better } : null, + comment: comment || null, + }) + setSent(true) + onDone && onDone() + } catch { alert('Erreur envoi feedback') } + } + + if (sent) return ( +
+ ✅ Feedback envoyé — merci! +
+ ) + + return ( +
+

Ce conseil était-il bon?

+
+ + + +
+ setBetter(e.target.value)} + placeholder="Meilleure action? (buy/sell/freeze/upgrade/wait...)" + className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-1.5 text-xs text-white mb-2 focus:outline-none focus:border-yellow-600" /> + setComment(e.target.value)} + placeholder="Commentaire (optionnel)" + className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:border-yellow-600" /> +
+ ) +} + +export default function GamePage() { + const [session, setSession] = useState(null) + const [advice, setAdvice] = useState(null) + const [loading, setLoading] = useState(false) + const [screenshot, setScreenshot] = useState(null) + const [state, setState] = useState({ + turn: 1, tavern_tier: 1, gold: 3, hero_hp: 40, + board_minions: [], tavern_minions: [], + freeze: false, can_upgrade: true, upgrade_cost: 5, + current_placement: 5, player_count: 8, phase: 'recruit', + }) + + const startSession = async () => { + try { + const r = await axios.post('/api/game/start') + setSession(r.data) + setAdvice(null) + } catch { alert('Erreur démarrage session') } + } + + const endSession = async () => { + const place = parseInt(prompt('Place finale (1-8)?', '4') || '4') + if (!place) return + try { + await axios.post(`/api/game/${session.session_id}/end?final_place=${place}`) + setSession(null); setAdvice(null) + } catch { alert('Erreur fin de session') } + } + + const getAdvice = useCallback(async () => { + setLoading(true) + try { + const r = await axios.post('/api/advice/', { ...state, session_id: session?.session_id }) + setAdvice(r.data) + } catch { alert('Service IA non disponible — vérifiez que le backend tourne sur :8000') } + finally { setLoading(false) } + }, [state, session]) + + const captureScreen = async () => { + setLoading(true) + try { + const r = await axios.get('/api/advice/from-screen') + setAdvice(r.data.advice) + if (r.data.screenshot) setScreenshot(r.data.screenshot) + if (r.data.extracted_state?.gold) setState(s => ({ ...s, ...r.data.extracted_state })) + } catch { alert('Capture non disponible — activez la vision dans les paramètres') } + finally { setLoading(false) } + } + + const addMinion = (field) => { + const name = prompt('Nom du serviteur?') + if (!name) return + const attack = parseInt(prompt('Attaque?', '2') || '2') + const health = parseInt(prompt('Points de vie?', '2') || '2') + const race = prompt('Race? (mech/beast/murloc/demon/dragon/none)', 'none') || 'none' + setState(s => ({ + ...s, + [field]: [...s[field], { name, attack, health, race: [race] }] + })) + } + + const removeMinion = (field, idx) => { + setState(s => ({ ...s, [field]: s[field].filter((_, i) => i !== idx) })) + } + + const setField = (key, val) => setState(s => ({ ...s, [key]: val })) + + const mainAction = advice?.main_decision + const actionInfo = ACTION_MAP[mainAction?.action] || { label: mainAction?.action, color: 'text-white' } + + return ( +
+ {/* Header */} +
+
+

Partie en cours

+

+ {session ? `Session #${session.session_id}` : 'Aucune session active'} +

+
+
+ {!session ? ( + + ) : ( + + )} +
+
+ +
+ {/* Colonne gauche: état du jeu */} +
+ {/* Stats numériques */} +
+

État du tour

+
+ {[ + ['Tour', 'turn', 1, 50], + ['Tier', 'tavern_tier', 1, 6], + ['Or (g)', 'gold', 0, 20], + ['HP Héros', 'hero_hp', 1, 40], + ['Position', 'current_placement', 1, 8], + ['Coût up.', 'upgrade_cost', 0, 10], + ].map(([label, key, min, max]) => ( +
+ + setField(key, parseInt(e.target.value) || 0)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-white text-center focus:outline-none focus:border-yellow-600" + /> +
+ ))} +
+
+ {[['freeze', '❄️ Gel actif'], ['can_upgrade', '⬆️ Peut monter']].map(([key, label]) => ( + + ))} +
+
+ + {/* Board */} +
+
+

+ Board ({state.board_minions.length}/7) +

+
+
+ {state.board_minions.map((m, i) => ( + removeMinion('board_minions', i)} /> + ))} + {state.board_minions.length < 7 && ( + addMinion('board_minions')} /> + )} +
+
+ + {/* Taverne */} +
+
+

+ Taverne ({state.tavern_minions.length}) +

+
+
+ {state.tavern_minions.map((m, i) => ( + removeMinion('tavern_minions', i)} /> + ))} + {state.tavern_minions.length < 7 && ( + addMinion('tavern_minions')} /> + )} +
+
+ + {/* Actions */} +
+ + +
+
+ + {/* Colonne droite: conseils */} +
+ {advice ? ( + <> + {/* Conseil principal */} +
+
+ Conseil principal + {advice.model_used} +
+

+ {actionInfo.label} +

+ {mainAction?.target && ( +

+ → {mainAction.target?.name || JSON.stringify(mainAction.target)} +

+ )} + +

{mainAction?.reasoning}

+ {mainAction?.warnings?.length > 0 && ( +
+ {mainAction.warnings.map((w, i) => ( +

⚠️ {w}

+ ))} +
+ )} + {mainAction?.synergies?.length > 0 && ( +
+ {mainAction.synergies.map(s => ( + + {s} + + ))} +
+ )} +
+ + {/* Analyse du board */} + {advice.board_analysis && ( +
+

Analyse

+

{advice.board_analysis}

+
+ )} + + {/* Stratégie LLM */} + {advice.strategy_long_term && ( +
+

Stratégie

+

{advice.strategy_long_term}

+
+ )} + + {/* Menaces */} + {advice.threat_assessment && ( +
+

Menaces

+

{advice.threat_assessment}

+
+ )} + + {/* Alternatives */} + {advice.secondary_decisions?.length > 0 && ( +
+

Alternatives

+
+ {advice.secondary_decisions.slice(0, 4).map((d, i) => { + const info = ACTION_MAP[d.action] || { label: d.action, color: 'text-gray-400' } + return ( +
+ {info.label} +
+ {d.reasoning?.slice(0, 40)}... + {Math.round(d.confidence * 100)}% +
+
+ ) + })} +
+
+ )} + + {/* Feedback */} + {advice.decision_id && ( + + )} + + {/* Meta */} +

{advice.processing_ms}ms de traitement

+ + ) : ( +
+ +

Prêt à analyser

+

Renseignez l'état du jeu puis cliquez sur "Obtenir conseil"

+
+ )} +
+
+ + {/* Screenshot preview */} + {screenshot && ( +
+
+

Capture d'écran

+ +
+ capture +
+ )} +
+ ) +} diff --git a/hsbg_ai/frontend/src/pages/LearningPage.jsx b/hsbg_ai/frontend/src/pages/LearningPage.jsx new file mode 100644 index 0000000..5386fbb --- /dev/null +++ b/hsbg_ai/frontend/src/pages/LearningPage.jsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' +import { Brain, ThumbsUp, ThumbsDown, Minus, RefreshCw, BarChart2 } from 'lucide-react' + +const RATING_ICONS = { + 1: , + 0: , + '-1': , +} +const ACTION_COLORS = { + buy: 'text-green-400', sell: 'text-red-400', freeze: 'text-blue-400', + upgrade: 'text-yellow-400', reposition: 'text-purple-400', wait: 'text-gray-400', +} + +export default function LearningPage() { + const [stats, setStats] = useState(null) + const [decisions, setDecisions] = useState([]) + const [loading, setLoading] = useState(true) + + const load = async () => { + setLoading(true) + try { + const [s, d] = await Promise.all([ + axios.get('/api/learning/stats'), + axios.get('/api/learning/decisions?limit=30'), + ]) + setStats(s.data) + setDecisions(d.data) + } catch {} + finally { setLoading(false) } + } + + useEffect(() => { load() }, []) + + const sendFeedback = async (id, rating) => { + try { + await axios.post('/api/learning/feedback', { decision_id: id, rating }) + load() + } catch { alert('Erreur envoi feedback') } + } + + return ( +
+
+
+ +
+

Mode Apprentissage

+

Évaluez les décisions IA pour améliorer le modèle

+
+
+ +
+ + {/* Stats */} + {stats && ( +
+ {[ + ['Feedbacks total', stats.total, 'text-white'], + [`Bons (${stats.good_rate}%)`, stats.good, 'text-green-400'], + ['Mauvais', stats.bad, 'text-red-400'], + ['Entraînements', stats.trained, 'text-blue-400'], + ].map(([label, val, color]) => ( +
+

{val}

+

{label}

+
+ ))} +
+ )} + + {/* Barre de progression good rate */} + {stats && stats.total > 0 && ( +
+
+ Qualité des conseils + {stats.good_rate}% de réussite +
+
+
+
+ {stats.buffer_pending > 0 && ( +

⚡ {stats.buffer_pending} feedbacks en attente d'export

+ )} +
+ )} + + {/* Historique */} +
+
+

Historique des décisions

+

Cliquez 👍/👎 pour noter les conseils non évalués

+
+ + {loading ? ( +

Chargement...

+ ) : decisions.length === 0 ? ( +
+ +

Aucune décision enregistrée

+

Lancez une partie et demandez des conseils!

+
+ ) : ( +
+ {decisions.map(d => { + const action = d.recommendation?.main_decision?.action + return ( +
+
+
+ + {action?.toUpperCase() || '?'} + + Tour {d.turn} + {d.phase} + {d.model_used} + {d.processing_ms}ms +
+
+ {d.outcome_rating !== null && d.outcome_rating !== undefined + ? RATING_ICONS[String(d.outcome_rating)] + : ( + <> + + + + + ) + } +
+
+

{d.reasoning}

+
+
+
+
+ {Math.round(d.confidence * 100)}% +
+ {d.user_feedback && ( +

💬 {d.user_feedback}

+ )} +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/hsbg_ai/frontend/src/pages/SettingsPage.jsx b/hsbg_ai/frontend/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..ef46fb3 --- /dev/null +++ b/hsbg_ai/frontend/src/pages/SettingsPage.jsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' +import { Settings, Cpu, Eye, Brain, AlertCircle } from 'lucide-react' + +function Section({ title, icon: Icon, children }) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ) +} + +function Row({ label, value, mono = false, highlight = false }) { + return ( +
+ {label} + + {String(value)} + +
+ ) +} + +export default function SettingsPage() { + const [cfg, setCfg] = useState(null) + + useEffect(() => { + axios.get('/api/settings/').then(r => setCfg(r.data)).catch(() => {}) + }, []) + + if (!cfg) return
Chargement...
+ + return ( +
+
+ +
+

Paramètres

+

Configuration de l'IA et des services

+
+
+ +
+ +

+ Pour modifier ces paramètres, éditez .env puis redémarrez le backend avec bash start_backend.sh +

+
+ +
+
+ + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+ + {/* Guides d'installation */} +
+
+

🧠 Installer Ollama (LLM local)

+
+ {[ + ['# 1. Installer Ollama', ''], + ['curl -fsSL https://ollama.ai/install.sh | sh', 'text-yellow-300'], + ['# 2. Télécharger llama3.2 (4.7GB)', ''], + ['ollama pull llama3.2', 'text-yellow-300'], + ['# 3. Ou un modèle plus léger', ''], + ['ollama pull mistral:7b', 'text-yellow-300'], + ].map(([cmd, color], i) => ( +

{cmd}

+ ))} +
+
+ +
+

👁️ Activer la vision OCR

+
+

# Installer Tesseract OCR

+

sudo apt-get install tesseract-ocr tesseract-ocr-fra

+

# Puis dans .env:

+

VISION_ENABLED=true

+

TESSERACT_PATH=/usr/bin/tesseract

+
+
+
+
+ ) +} diff --git a/hsbg_ai/frontend/tailwind.config.js b/hsbg_ai/frontend/tailwind.config.js new file mode 100644 index 0000000..abe21aa --- /dev/null +++ b/hsbg_ai/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +export default { + content: ["./index.html","./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + hsbg: { gold:'#F5B30A', dark:'#1A0A00', card:'#2A1500', board:'#0D1F0D' } + } + } + }, + plugins: [] +} diff --git a/hsbg_ai/frontend/vite.config.js b/hsbg_ai/frontend/vite.config.js new file mode 100644 index 0000000..ed52c47 --- /dev/null +++ b/hsbg_ai/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { target: 'http://localhost:8000', changeOrigin: true }, + '/ws': { target: 'ws://localhost:8000', ws: true } + } + }, + build: { outDir: 'dist' } +}) diff --git a/hsbg_ai/requirements.txt b/hsbg_ai/requirements.txt new file mode 100644 index 0000000..3f94b8c --- /dev/null +++ b/hsbg_ai/requirements.txt @@ -0,0 +1,30 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 +websockets==13.0 +sqlalchemy==2.0.35 +alembic==1.13.3 +aiosqlite==0.20.0 +langchain==0.3.0 +langchain-community==0.3.0 +ollama==0.3.3 +sentence-transformers==3.1.1 +numpy==1.26.4 +scikit-learn==1.5.2 +Pillow==10.4.0 +pytesseract==0.3.13 +opencv-python-headless==4.10.0.84 +mss==9.0.2 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-dotenv==1.0.1 +httpx==0.27.2 +aiofiles==24.1.0 +structlog==24.4.0 +rich==13.8.1 +click==8.1.7 +pandas==2.2.3 +pytest==8.3.3 +pytest-asyncio==0.24.0 +black==24.8.0 +ruff==0.6.8 diff --git a/hsbg_ai/seed_db.sh b/hsbg_ai/seed_db.sh new file mode 100644 index 0000000..97c0959 --- /dev/null +++ b/hsbg_ai/seed_db.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Recharge les données HSBG dans la base +cd "$(dirname "$0")" +source .venv/bin/activate +echo "🌱 Rechargement de la base de données HSBG..." +python -m backend.database.seeds.seed_data diff --git a/hsbg_ai/start_backend.sh b/hsbg_ai/start_backend.sh new file mode 100644 index 0000000..0972496 --- /dev/null +++ b/hsbg_ai/start_backend.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Lance le backend HSBG AI +cd "$(dirname "$0")" +source .venv/bin/activate + +echo "╔══════════════════════════════════════╗" +echo "║ HSBG AI - Backend (FastAPI) ║" +echo "║ http://localhost:8000 ║" +echo "║ API Docs: /docs ║" +echo "╚══════════════════════════════════════╝" +echo "" + +python -m uvicorn backend.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --reload \ + --log-level info diff --git a/hsbg_ai/start_frontend.sh b/hsbg_ai/start_frontend.sh new file mode 100644 index 0000000..acbd3ec --- /dev/null +++ b/hsbg_ai/start_frontend.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Lance le frontend HSBG AI (React + Vite) +cd "$(dirname "$0")/frontend" + +echo "╔══════════════════════════════════════╗" +echo "║ HSBG AI - Frontend (React) ║" +echo "║ http://localhost:3000 ║" +echo "╚══════════════════════════════════════╝" +echo "" + +npm run dev diff --git a/init_project.sh b/init_project.sh new file mode 100644 index 0000000..071b019 --- /dev/null +++ b/init_project.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# ============================================================================= +# HSBG AI ASSISTANT - Script d'initialisation +# Compatible: WSL (Ubuntu), Linux +# Usage: bash init_project.sh +# ============================================================================= +set -e + +# Couleurs +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_ok() { echo -e "${GREEN}[ OK ]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERR ]${NC} $1"; } +log_step() { echo -e "\n${BOLD}${CYAN}━━━ $1 ━━━${NC}"; } + +# ============================================================================= +echo -e "${BOLD}${CYAN}" +echo " ╔═══════════════════════════════════════════════════════╗" +echo " ║ HSBG AI ASSISTANT - Initialisation ║" +echo " ║ Hearthstone Battlegrounds - Intelligence IA ║" +echo " ╚═══════════════════════════════════════════════════════╝" +echo -e "${NC}" +# ============================================================================= + +PROJECT_DIR="$(pwd)/hsbg_ai" +VENV_DIR="${PROJECT_DIR}/.venv" + +# 1. Prérequis +log_step "VÉRIFICATION DES PRÉREQUIS" +command -v python3 >/dev/null 2>&1 || { log_error "Python3 requis (3.10+)"; exit 1; } +log_ok "Python $(python3 --version | grep -oP '\d+\.\d+') trouvé" +command -v pip3 >/dev/null 2>&1 || { log_error "pip3 requis"; exit 1; } +log_ok "pip3 trouvé" + +HAS_NODE=0 +if command -v node >/dev/null 2>&1; then + HAS_NODE=1 + log_ok "Node.js $(node --version) trouvé" +else + log_warn "Node.js non trouvé - frontend sera lancé en mode dev séparé" +fi + +# 2. Vérifier que le dossier projet existe (créé par les fichiers précédents) +log_step "VÉRIFICATION DU PROJET" +if [ ! -d "${PROJECT_DIR}" ]; then + log_error "Dossier ${PROJECT_DIR} introuvable. Assurez-vous que tous les fichiers sont extraits." + exit 1 +fi +log_ok "Projet trouvé: ${PROJECT_DIR}" + +# 3. Virtualenv Python +log_step "ENVIRONNEMENT PYTHON" +log_info "Création du virtualenv..." +python3 -m venv "${VENV_DIR}" +source "${VENV_DIR}/bin/activate" +pip install --upgrade pip setuptools wheel -q +log_ok "Virtualenv créé" + +log_info "Installation des dépendances Python (5-10 minutes)..." +pip install -r "${PROJECT_DIR}/requirements.txt" -q --no-warn-script-location +log_ok "Dépendances Python installées" + +# 4. Dépendances frontend +if [ "${HAS_NODE}" = "1" ]; then + log_step "FRONTEND NODE.JS" + cd "${PROJECT_DIR}/frontend" + npm install -q + log_ok "Dépendances npm installées" + cd - >/dev/null +fi + +# 5. Initialisation de la base de données +log_step "BASE DE DONNÉES" +cd "${PROJECT_DIR}" +source "${VENV_DIR}/bin/activate" +python -m backend.database.seeds.seed_data +log_ok "Base de données HSBG initialisée" +cd - >/dev/null + +# 6. Git +if command -v git >/dev/null 2>&1; then + log_step "INITIALISATION GIT" + cd "${PROJECT_DIR}" + git init -q + cat > .gitignore << 'GITEOF' +.venv/ +__pycache__/ +*.pyc +*.pyo +.env +data/hsbg_ai.db +data/screenshots/ +data/learning/feedback/ +data/learning/sessions/ +logs/*.log +frontend/node_modules/ +frontend/dist/ +*.egg-info/ +.DS_Store +Thumbs.db +GITEOF + git add . + git commit -m "feat: init HSBG AI project v1.0.0" -q + log_ok "Dépôt git initialisé" + cd - >/dev/null +fi + +# ============================================================================= +# RÉSUMÉ FINAL +# ============================================================================= +echo "" +echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}${GREEN}║ ✅ INSTALLATION TERMINÉE AVEC SUCCÈS! ║${NC}" +echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BOLD}📁 Projet:${NC} ${PROJECT_DIR}" +echo "" +echo -e "${BOLD}🚀 DÉMARRAGE (2 terminaux):${NC}" +echo -e " ${CYAN}Terminal 1 (Backend):${NC}" +echo -e " cd hsbg_ai && bash start_backend.sh" +echo "" +echo -e " ${CYAN}Terminal 2 (Frontend):${NC}" +echo -e " cd hsbg_ai && bash start_frontend.sh" +echo "" +echo -e "${BOLD}🌐 Accès:${NC}" +echo -e " Interface web: ${YELLOW}http://localhost:3000${NC}" +echo -e " API + Docs: ${YELLOW}http://localhost:8000/docs${NC}" +echo -e " Health check: ${YELLOW}http://localhost:8000/health${NC}" +echo "" +echo -e "${BOLD}🧠 LLM Local (optionnel, recommandé):${NC}" +echo -e " ${CYAN}1.${NC} curl -fsSL https://ollama.ai/install.sh | sh" +echo -e " ${CYAN}2.${NC} ollama pull llama3.2" +echo "" +echo -e "${BOLD}👁️ OCR Vision (optionnel):${NC}" +echo -e " sudo apt-get install tesseract-ocr tesseract-ocr-fra" +echo ""