Initial commit
This commit is contained in:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -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
|
||||||
0
hsbg_ai/backend/__init__.py
Normal file
0
hsbg_ai/backend/__init__.py
Normal file
0
hsbg_ai/backend/ai/__init__.py
Normal file
0
hsbg_ai/backend/ai/__init__.py
Normal file
0
hsbg_ai/backend/ai/engine/__init__.py
Normal file
0
hsbg_ai/backend/ai/engine/__init__.py
Normal file
196
hsbg_ai/backend/ai/engine/decision_engine.py
Normal file
196
hsbg_ai/backend/ai/engine/decision_engine.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Moteur de décision IA principal - Architecture hybride.
|
||||||
|
Flux: StateAnalyzer → HeuristicEngine + LLMAdvisor → Fusion → FullAdvice
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameState:
|
||||||
|
"""État complet d'un tour HSBG."""
|
||||||
|
turn: int = 0
|
||||||
|
tavern_tier: int = 1
|
||||||
|
gold: int = 3
|
||||||
|
hero_id: str = ""
|
||||||
|
hero_hp: int = 40
|
||||||
|
tavern_minions: list = field(default_factory=list)
|
||||||
|
board_minions: list = field(default_factory=list)
|
||||||
|
hand_minions: list = field(default_factory=list)
|
||||||
|
freeze: bool = False
|
||||||
|
can_upgrade: bool = True
|
||||||
|
upgrade_cost: int = 5
|
||||||
|
available_spells: list = field(default_factory=list)
|
||||||
|
opponent_boards: list = field(default_factory=list)
|
||||||
|
current_placement: int = 5
|
||||||
|
player_count: int = 8
|
||||||
|
phase: str = "recruit"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
"""Une décision IA avec justification."""
|
||||||
|
action: str # buy|sell|freeze|upgrade|reposition|hero_power|wait
|
||||||
|
target: dict = None # Carte ou minion ciblé
|
||||||
|
priority: int = 5 # 1-10
|
||||||
|
confidence: float = 0.5 # 0.0-1.0
|
||||||
|
reasoning: str = "" # Explication textuelle
|
||||||
|
alternatives: list = field(default_factory=list)
|
||||||
|
synergies_highlighted: list = field(default_factory=list)
|
||||||
|
warnings: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FullAdvice:
|
||||||
|
"""Conseil complet pour un tour entier."""
|
||||||
|
main_decision: Decision
|
||||||
|
secondary_decisions: list = field(default_factory=list)
|
||||||
|
board_analysis: str = ""
|
||||||
|
strategy_long_term: str = ""
|
||||||
|
threat_assessment: str = ""
|
||||||
|
processing_ms: int = 0
|
||||||
|
model_used: str = "heuristic"
|
||||||
|
confidence_overall: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionEngine:
|
||||||
|
"""
|
||||||
|
Moteur de décision hybride HSBG.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
1. StateAnalyzer → Parse l'état brut en GameState typé
|
||||||
|
2. HeuristicEngine → Règles métier rapides et déterministes
|
||||||
|
3. LLMAdvisor → Raisonnement LLM pour enrichir (si disponible)
|
||||||
|
4. Fusion → Combine les deux avec pondération par confiance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self._initialized = False
|
||||||
|
# Import lazy pour éviter les imports circulaires
|
||||||
|
from backend.ai.engine.heuristics import HeuristicEngine
|
||||||
|
from backend.ai.engine.llm_advisor import LLMAdvisor
|
||||||
|
from backend.ai.engine.state_analyzer import StateAnalyzer
|
||||||
|
self.heuristic = HeuristicEngine()
|
||||||
|
self.llm = LLMAdvisor(settings)
|
||||||
|
self.analyzer = StateAnalyzer()
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialise les composants asynchrones (LLM)."""
|
||||||
|
await self.llm.initialize()
|
||||||
|
self._initialized = True
|
||||||
|
log.info("decision_engine.ready", model=self.settings.llm_model)
|
||||||
|
|
||||||
|
async def get_advice(self, raw_state: dict) -> FullAdvice:
|
||||||
|
"""
|
||||||
|
Point d'entrée principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_state: Dict brut (depuis API ou OCR)
|
||||||
|
Returns:
|
||||||
|
FullAdvice avec conseil principal + alternatives
|
||||||
|
"""
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
# 1. Parser l'état brut
|
||||||
|
state = await self.analyzer.parse(raw_state)
|
||||||
|
|
||||||
|
# 2. Heuristiques (toujours disponibles, < 5ms)
|
||||||
|
heuristic_decisions = self.heuristic.evaluate(state)
|
||||||
|
|
||||||
|
# 3. LLM (si disponible, ~500-2000ms)
|
||||||
|
llm_advice = None
|
||||||
|
if self._initialized:
|
||||||
|
try:
|
||||||
|
llm_advice = await self.llm.advise(state, heuristic_decisions)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("llm.failed_gracefully", error=str(e))
|
||||||
|
|
||||||
|
# 4. Fusion avec pondération
|
||||||
|
final = self._fuse(state, heuristic_decisions, llm_advice)
|
||||||
|
final.processing_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"advice.generated",
|
||||||
|
action=final.main_decision.action,
|
||||||
|
confidence=round(final.main_decision.confidence, 2),
|
||||||
|
ms=final.processing_ms,
|
||||||
|
model=final.model_used,
|
||||||
|
)
|
||||||
|
return final
|
||||||
|
|
||||||
|
def _fuse(self, state: GameState, heuristics: list, llm: "FullAdvice | None") -> FullAdvice:
|
||||||
|
"""
|
||||||
|
Fusionne heuristiques + LLM.
|
||||||
|
Règle: LLM prioritaire si confiance > 0.7, sinon heuristiques.
|
||||||
|
"""
|
||||||
|
if not heuristics:
|
||||||
|
return FullAdvice(
|
||||||
|
main_decision=Decision(action="wait", reasoning="Aucune action identifiée"),
|
||||||
|
model_used="fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
# LLM haute confiance → priorité totale
|
||||||
|
if llm and llm.confidence_overall > 0.7:
|
||||||
|
llm.model_used = f"{self.settings.llm_model}+heuristic"
|
||||||
|
return llm
|
||||||
|
|
||||||
|
# Heuristiques comme base
|
||||||
|
main = max(heuristics, key=lambda d: d.priority * d.confidence)
|
||||||
|
secondary = sorted(
|
||||||
|
[d for d in heuristics if d != main],
|
||||||
|
key=lambda d: d.priority,
|
||||||
|
reverse=True,
|
||||||
|
)[:3]
|
||||||
|
|
||||||
|
result = FullAdvice(
|
||||||
|
main_decision=main,
|
||||||
|
secondary_decisions=secondary,
|
||||||
|
board_analysis=self._analyze_board(state),
|
||||||
|
model_used="heuristic",
|
||||||
|
confidence_overall=main.confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrichissement LLM partiel (stratégie + menaces même si confiance faible)
|
||||||
|
if llm:
|
||||||
|
result.strategy_long_term = llm.strategy_long_term
|
||||||
|
result.threat_assessment = llm.threat_assessment
|
||||||
|
result.model_used = f"heuristic+{self.settings.llm_model}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _analyze_board(self, state: GameState) -> str:
|
||||||
|
"""Génère une analyse textuelle rapide du board."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if len(state.board_minions) == 7:
|
||||||
|
parts.append("⚠️ Board plein — vendre avant d'acheter")
|
||||||
|
|
||||||
|
if state.gold >= 10 and state.tavern_tier < 6:
|
||||||
|
parts.append(f"💰 Or abondant — envisager tier {state.tavern_tier + 1}")
|
||||||
|
|
||||||
|
if state.current_placement > 5 and state.turn > 8:
|
||||||
|
parts.append("🚨 Position critique — trouver synergie forte rapidement")
|
||||||
|
|
||||||
|
# Détecter synergie dominante
|
||||||
|
from collections import Counter
|
||||||
|
races = []
|
||||||
|
for m in state.board_minions:
|
||||||
|
r = m.get("race", [])
|
||||||
|
races.extend(r if isinstance(r, list) else [r])
|
||||||
|
if races:
|
||||||
|
top_race, top_count = Counter(races).most_common(1)[0]
|
||||||
|
if top_count >= 3:
|
||||||
|
parts.append(f"✨ Synergie {top_race} détectée ({top_count}/7)")
|
||||||
|
|
||||||
|
if state.freeze:
|
||||||
|
parts.append("❄️ Taverne gelée — les cartes sont réservées")
|
||||||
|
|
||||||
|
return " | ".join(parts) if parts else "Board standard — continuer normalement"
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
"""Arrête proprement les composants."""
|
||||||
|
await self.llm.shutdown()
|
||||||
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal file
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Moteur heuristique HSBG.
|
||||||
|
Règles métier codées en dur — rapides, déterministes, toujours disponibles.
|
||||||
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
from backend.ai.engine.decision_engine import GameState, Decision
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class HeuristicEngine:
|
||||||
|
"""
|
||||||
|
Évalue l'état de jeu avec des règles métier HSBG.
|
||||||
|
Chaque règle retourne une Decision ou None.
|
||||||
|
Triées par priorité × confiance décroissante.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def evaluate(self, state: GameState) -> list[Decision]:
|
||||||
|
"""Évalue toutes les règles et retourne les décisions candidates."""
|
||||||
|
rules = [
|
||||||
|
self._rule_triple, # Priorité max: triple = version dorée
|
||||||
|
self._rule_upgrade, # Montée de tier
|
||||||
|
self._rule_freeze, # Geler bonnes cartes
|
||||||
|
self._rule_buy_synergy, # Acheter dans la synergie
|
||||||
|
self._rule_sell, # Vendre les faibles
|
||||||
|
self._rule_economy, # Gestion de l'or
|
||||||
|
self._rule_reposition, # Positionnement board
|
||||||
|
]
|
||||||
|
decisions = []
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
result = rule(state)
|
||||||
|
if result:
|
||||||
|
decisions.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("heuristic.error", rule=rule.__name__, error=str(e))
|
||||||
|
|
||||||
|
return sorted(decisions, key=lambda d: d.priority * d.confidence, reverse=True)
|
||||||
|
|
||||||
|
# ─── Règles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rule_triple(self, state: GameState) -> Decision | None:
|
||||||
|
"""Détecter et compléter les triples — priorité maximale."""
|
||||||
|
all_minions = state.board_minions + state.hand_minions
|
||||||
|
counts = Counter(m.get("name") for m in all_minions if m.get("name"))
|
||||||
|
|
||||||
|
for name, count in counts.items():
|
||||||
|
if count >= 2:
|
||||||
|
# Chercher le 3ème en taverne
|
||||||
|
for m in state.tavern_minions:
|
||||||
|
if m.get("name") == name:
|
||||||
|
can_afford = m.get("cost", 3) <= state.gold
|
||||||
|
return Decision(
|
||||||
|
action="buy",
|
||||||
|
target=m,
|
||||||
|
priority=9,
|
||||||
|
confidence=0.90,
|
||||||
|
reasoning=f"🏆 TRIPLE en vue! Acheter {name} → version dorée!",
|
||||||
|
warnings=[] if can_afford else [
|
||||||
|
f"Manque {m.get('cost',3) - state.gold}g — vendre un serviteur si nécessaire"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rule_upgrade(self, state: GameState) -> Decision | None:
|
||||||
|
"""Monter de tier au bon moment."""
|
||||||
|
if not state.can_upgrade or state.gold < state.upgrade_cost:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Montée d'urgence (HP bas + mauvaise position)
|
||||||
|
if state.current_placement >= 6 and state.hero_hp < 20 and state.upgrade_cost <= 4:
|
||||||
|
return Decision(
|
||||||
|
action="upgrade",
|
||||||
|
priority=8,
|
||||||
|
confidence=0.80,
|
||||||
|
reasoning=f"🚨 Urgence: HP={state.hero_hp}, position={state.current_placement} → tier {state.tavern_tier + 1}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendrier optimal de montée
|
||||||
|
optimal = {1: 3, 2: 5, 3: 7, 4: 10, 5: 14}
|
||||||
|
target_turn = optimal.get(state.tavern_tier, 99)
|
||||||
|
if state.turn >= target_turn:
|
||||||
|
return Decision(
|
||||||
|
action="upgrade",
|
||||||
|
priority=6,
|
||||||
|
confidence=0.65,
|
||||||
|
reasoning=f"📈 Tour {state.turn}: montée optimale vers tier {state.tavern_tier + 1} ({state.upgrade_cost}g)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Montée accélérée si beaucoup d'or en retard
|
||||||
|
if state.gold >= state.upgrade_cost + 4 and state.turn >= target_turn - 2:
|
||||||
|
return Decision(
|
||||||
|
action="upgrade",
|
||||||
|
priority=5,
|
||||||
|
confidence=0.55,
|
||||||
|
reasoning=f"💰 Or abondant ({state.gold}g) — montée anticipée tier {state.tavern_tier + 1}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rule_freeze(self, state: GameState) -> Decision | None:
|
||||||
|
"""Geler si de bonnes cartes ne sont pas achetables ce tour."""
|
||||||
|
if state.freeze:
|
||||||
|
return None # Déjà gelé
|
||||||
|
|
||||||
|
strong = [m for m in state.tavern_minions if self._is_strong(m, state)]
|
||||||
|
if len(strong) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
affordable = [m for m in strong if m.get("cost", 3) <= state.gold]
|
||||||
|
unaffordable = [m for m in strong if m.get("cost", 3) > state.gold]
|
||||||
|
|
||||||
|
if unaffordable:
|
||||||
|
return Decision(
|
||||||
|
action="freeze",
|
||||||
|
priority=7,
|
||||||
|
confidence=0.68,
|
||||||
|
reasoning=f"❄️ Geler: {len(strong)} bonne(s) carte(s), {len(unaffordable)} non achetable(s) ce tour",
|
||||||
|
synergies_highlighted=[m.get("name", "") for m in strong],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rule_buy_synergy(self, state: GameState) -> Decision | None:
|
||||||
|
"""Acheter une carte qui renforce la synergie de race principale."""
|
||||||
|
if state.gold < 3 or not state.tavern_minions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculer la race dominante sur le board
|
||||||
|
races = []
|
||||||
|
for m in state.board_minions:
|
||||||
|
r = m.get("race", [])
|
||||||
|
races.extend(r if isinstance(r, list) else [r])
|
||||||
|
|
||||||
|
if not races:
|
||||||
|
return None
|
||||||
|
|
||||||
|
race_counts = Counter(races)
|
||||||
|
top_race, top_count = race_counts.most_common(1)[0]
|
||||||
|
|
||||||
|
if top_count < 2 or top_race in ("none", "", "all"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Chercher en taverne une carte de cette race
|
||||||
|
synergy_targets = []
|
||||||
|
for m in state.tavern_minions:
|
||||||
|
m_races = m.get("race", [])
|
||||||
|
if isinstance(m_races, str):
|
||||||
|
m_races = [m_races]
|
||||||
|
if top_race in m_races and m.get("cost", 3) <= state.gold:
|
||||||
|
synergy_targets.append(m)
|
||||||
|
|
||||||
|
if synergy_targets:
|
||||||
|
# Préférer les cartes avec des effets
|
||||||
|
best = max(synergy_targets, key=lambda m: (
|
||||||
|
int(m.get("has_divine", 0)) * 3 +
|
||||||
|
int(bool(m.get("battlecry"))) * 2 +
|
||||||
|
int(bool(m.get("deathrattle"))) * 2 +
|
||||||
|
m.get("attack", 0) + m.get("health", 0)
|
||||||
|
))
|
||||||
|
return Decision(
|
||||||
|
action="buy",
|
||||||
|
target=best,
|
||||||
|
priority=7,
|
||||||
|
confidence=0.72,
|
||||||
|
reasoning=f"🔗 Renforcer synergie {top_race} ({top_count} sur board): acheter {best.get('name', '?')}",
|
||||||
|
synergies_highlighted=[top_race],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rule_sell(self, state: GameState) -> Decision | None:
|
||||||
|
"""Vendre les serviteurs trop faibles pour libérer de la place."""
|
||||||
|
if len(state.board_minions) < 6:
|
||||||
|
return None
|
||||||
|
|
||||||
|
weak = [m for m in state.board_minions if self._is_weak(m, state)]
|
||||||
|
if not weak:
|
||||||
|
return None
|
||||||
|
|
||||||
|
worst = min(weak, key=lambda m: m.get("attack", 0) + m.get("health", 0))
|
||||||
|
return Decision(
|
||||||
|
action="sell",
|
||||||
|
target=worst,
|
||||||
|
priority=5,
|
||||||
|
confidence=0.62,
|
||||||
|
reasoning=f"🗑️ Vendre {worst.get('name', '?')} ({worst.get('attack',0)}/{worst.get('health',0)}) — trop faible en tier {state.tavern_tier}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rule_economy(self, state: GameState) -> Decision | None:
|
||||||
|
"""Gérer prudemment l'or en début de partie."""
|
||||||
|
if state.gold <= 2 and state.turn < 4:
|
||||||
|
return Decision(
|
||||||
|
action="wait",
|
||||||
|
priority=3,
|
||||||
|
confidence=0.50,
|
||||||
|
reasoning=f"💸 Or limité ({state.gold}g) en tour {state.turn} — économiser pour la suite",
|
||||||
|
warnings=["Éviter les gels coûteux en early game"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rule_reposition(self, state: GameState) -> Decision | None:
|
||||||
|
"""Suggérer un repositionnement si des cartes clés sont mal placées."""
|
||||||
|
if len(state.board_minions) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
has_taunt = any(m.get("has_taunt") for m in state.board_minions)
|
||||||
|
has_divine = any(m.get("has_divine") for m in state.board_minions)
|
||||||
|
has_cleave = any(m.get("on_attack") and "adjacent" in m.get("on_attack","").lower()
|
||||||
|
for m in state.board_minions)
|
||||||
|
|
||||||
|
if has_taunt or has_divine or has_cleave:
|
||||||
|
tips = []
|
||||||
|
if has_taunt:
|
||||||
|
tips.append("Taunt à gauche (absorbe les attaques)")
|
||||||
|
if has_divine:
|
||||||
|
tips.append("Divine Shield au centre ou protégé")
|
||||||
|
if has_cleave:
|
||||||
|
tips.append("Cleave en position 1 ou 3")
|
||||||
|
return Decision(
|
||||||
|
action="reposition",
|
||||||
|
priority=4,
|
||||||
|
confidence=0.58,
|
||||||
|
reasoning=f"🗺️ Optimiser le board: {' | '.join(tips)}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_strong(self, minion: dict, state: GameState) -> bool:
|
||||||
|
"""Un serviteur est fort si ses stats dépassent le seuil du tier actuel."""
|
||||||
|
stat_thresholds = {1: 4, 2: 7, 3: 10, 4: 14, 5: 18, 6: 22}
|
||||||
|
min_stats = stat_thresholds.get(state.tavern_tier, 8)
|
||||||
|
stats = minion.get("attack", 0) + minion.get("health", 0)
|
||||||
|
return (
|
||||||
|
stats >= min_stats
|
||||||
|
or minion.get("has_divine", False)
|
||||||
|
or minion.get("has_taunt", False)
|
||||||
|
or bool(minion.get("battlecry", ""))
|
||||||
|
or bool(minion.get("deathrattle", ""))
|
||||||
|
or bool(minion.get("passive", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_weak(self, minion: dict, state: GameState) -> bool:
|
||||||
|
"""Un serviteur tier 1-2 sans capacité est faible en mid/late game."""
|
||||||
|
if state.turn < 8:
|
||||||
|
return False
|
||||||
|
tier = int(minion.get("tier", "1"))
|
||||||
|
if tier > 2:
|
||||||
|
return False
|
||||||
|
stats = minion.get("attack", 0) + minion.get("health", 0)
|
||||||
|
has_ability = (
|
||||||
|
minion.get("has_divine") or minion.get("has_taunt") or
|
||||||
|
minion.get("battlecry") or minion.get("deathrattle") or minion.get("passive")
|
||||||
|
)
|
||||||
|
return stats < 6 and not has_ability
|
||||||
134
hsbg_ai/backend/ai/engine/llm_advisor.py
Normal file
134
hsbg_ai/backend/ai/engine/llm_advisor.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Conseiller LLM local via Ollama — enrichit les décisions heuristiques."""
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from backend.ai.engine.decision_engine import GameState, Decision, FullAdvice
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """Tu es un expert Hearthstone Battlegrounds rang Légende.
|
||||||
|
Analyse l'état de jeu et donne un conseil tactique optimal.
|
||||||
|
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant/après, sans markdown:
|
||||||
|
{"main_action":"buy|sell|freeze|upgrade|reposition|hero_power|wait","target_card":null,"priority":7,"confidence":0.8,"reasoning":"explication courte","strategy":"stratégie long terme 1 phrase","threats":"principale menace adversaire","warnings":[]}"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMAdvisor:
|
||||||
|
"""Interface avec Ollama pour les conseils LLM."""
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.base_url = settings.llm_base_url
|
||||||
|
self.model = settings.llm_model
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Teste la disponibilité d'Ollama et du modèle."""
|
||||||
|
self._client = httpx.AsyncClient(timeout=10)
|
||||||
|
try:
|
||||||
|
r = await self._client.get(f"{self.base_url}/api/tags")
|
||||||
|
if r.status_code == 200:
|
||||||
|
models = r.json().get("models", [])
|
||||||
|
model_names = [m.get("name", "") for m in models]
|
||||||
|
self._available = any(self.model in name for name in model_names)
|
||||||
|
if self._available:
|
||||||
|
log.info("llm.ready", model=self.model)
|
||||||
|
else:
|
||||||
|
log.warning("llm.model_not_found", model=self.model,
|
||||||
|
available=model_names,
|
||||||
|
hint=f"Exécutez: ollama pull {self.model}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("llm.ollama_unreachable", url=self.base_url, error=str(e),
|
||||||
|
hint="Installez Ollama: curl -fsSL https://ollama.ai/install.sh | sh")
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
async def advise(self, state: GameState, heuristics: list[Decision]) -> FullAdvice | None:
|
||||||
|
"""Demande un conseil au LLM local."""
|
||||||
|
if not self._available or not self._client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = self._build_prompt(state, heuristics)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self._client.post(
|
||||||
|
f"{self.base_url}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"system": SYSTEM_PROMPT,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": self.settings.llm_temperature,
|
||||||
|
"num_predict": self.settings.llm_max_tokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeout=self.settings.llm_timeout,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
raw = r.json().get("response", "")
|
||||||
|
return self._parse(raw)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("llm.request_failed", error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_prompt(self, state: GameState, decisions: list[Decision]) -> str:
|
||||||
|
"""Construit le prompt depuis l'état de jeu."""
|
||||||
|
board = ", ".join(
|
||||||
|
f"{m.get('name','?')}({m.get('attack',0)}/{m.get('health',0)})"
|
||||||
|
+ (" [DIVINE]" if m.get("has_divine") else "")
|
||||||
|
+ (" [TAUNT]" if m.get("has_taunt") else "")
|
||||||
|
for m in state.board_minions
|
||||||
|
) or "vide"
|
||||||
|
|
||||||
|
tavern = ", ".join(
|
||||||
|
f"{m.get('name','?')}[{m.get('cost',3)}g T{m.get('tier',1)}]"
|
||||||
|
for m in state.tavern_minions
|
||||||
|
) or "vide"
|
||||||
|
|
||||||
|
top_h = decisions[0].reasoning if decisions else "aucune heuristique"
|
||||||
|
|
||||||
|
return f"""=== ÉTAT DU JEU ===
|
||||||
|
Tour: {state.turn} | Tier taverne: {state.tavern_tier} | Or: {state.gold}g
|
||||||
|
Héros: {state.hero_id} | HP: {state.hero_hp} | Position: {state.current_placement}/{state.player_count}
|
||||||
|
Board ({len(state.board_minions)}/7): {board}
|
||||||
|
Taverne: {tavern}
|
||||||
|
Gel: {'OUI' if state.freeze else 'NON'} | Upgrade possible: {'OUI' if state.can_upgrade else 'NON'} ({state.upgrade_cost}g)
|
||||||
|
Phase: {state.phase}
|
||||||
|
|
||||||
|
=== MEILLEURE HEURISTIQUE ===
|
||||||
|
{top_h}
|
||||||
|
|
||||||
|
Analyse et donne ta recommandation JSON."""
|
||||||
|
|
||||||
|
def _parse(self, raw: str) -> FullAdvice | None:
|
||||||
|
"""Parse la réponse JSON du LLM."""
|
||||||
|
try:
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}") + 1
|
||||||
|
if start == -1 or end == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = json.loads(raw[start:end])
|
||||||
|
|
||||||
|
main = Decision(
|
||||||
|
action=data.get("main_action", "wait"),
|
||||||
|
priority=min(10, max(1, int(data.get("priority", 5)))),
|
||||||
|
confidence=min(1.0, max(0.0, float(data.get("confidence", 0.5)))),
|
||||||
|
reasoning=data.get("reasoning", ""),
|
||||||
|
warnings=data.get("warnings", []) or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
return FullAdvice(
|
||||||
|
main_decision=main,
|
||||||
|
strategy_long_term=data.get("strategy", ""),
|
||||||
|
threat_assessment=data.get("threats", ""),
|
||||||
|
confidence_overall=main.confidence,
|
||||||
|
model_used=self.model,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||||
|
log.warning("llm.parse_failed", error=str(e), raw_preview=raw[:300])
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
26
hsbg_ai/backend/ai/engine/state_analyzer.py
Normal file
26
hsbg_ai/backend/ai/engine/state_analyzer.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Parse et normalise l'état de jeu brut en GameState typé."""
|
||||||
|
from backend.ai.engine.decision_engine import GameState
|
||||||
|
|
||||||
|
|
||||||
|
class StateAnalyzer:
|
||||||
|
"""Convertit les données brutes (API, OCR, manuel) en GameState."""
|
||||||
|
|
||||||
|
async def parse(self, raw: dict) -> GameState:
|
||||||
|
return GameState(
|
||||||
|
turn=int(raw.get("turn", 0)),
|
||||||
|
tavern_tier=int(raw.get("tavern_tier", 1)),
|
||||||
|
gold=int(raw.get("gold", 3)),
|
||||||
|
hero_id=str(raw.get("hero_id", "")),
|
||||||
|
hero_hp=int(raw.get("hero_hp", 40)),
|
||||||
|
tavern_minions=list(raw.get("tavern_minions", [])),
|
||||||
|
board_minions=list(raw.get("board_minions", [])),
|
||||||
|
hand_minions=list(raw.get("hand_minions", [])),
|
||||||
|
freeze=bool(raw.get("freeze", False)),
|
||||||
|
can_upgrade=bool(raw.get("can_upgrade", True)),
|
||||||
|
upgrade_cost=int(raw.get("upgrade_cost", 5)),
|
||||||
|
available_spells=list(raw.get("available_spells", [])),
|
||||||
|
opponent_boards=list(raw.get("opponent_boards", [])),
|
||||||
|
current_placement=int(raw.get("current_placement", 5)),
|
||||||
|
player_count=int(raw.get("player_count", 8)),
|
||||||
|
phase=str(raw.get("phase", "recruit")),
|
||||||
|
)
|
||||||
0
hsbg_ai/backend/ai/learning/__init__.py
Normal file
0
hsbg_ai/backend/ai/learning/__init__.py
Normal file
117
hsbg_ai/backend/ai/learning/feedback_processor.py
Normal file
117
hsbg_ai/backend/ai/learning/feedback_processor.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Système d'apprentissage par feedback utilisateur."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from backend.database.models import AIDecision, LearningFeedback
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackProcessor:
|
||||||
|
"""
|
||||||
|
Traite les retours utilisateur pour améliorer l'IA.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Utilisateur évalue une décision (bon/mauvais/neutre)
|
||||||
|
2. Le feedback est persisté en DB
|
||||||
|
3. Un buffer accumule les feedbacks
|
||||||
|
4. Quand le buffer est plein → export JSON pour entraînement futur
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self._buffer: list[dict] = []
|
||||||
|
self._trained_count = 0
|
||||||
|
|
||||||
|
async def record_feedback(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
decision_id: int,
|
||||||
|
rating: str,
|
||||||
|
better_action: dict | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
) -> LearningFeedback:
|
||||||
|
"""Enregistre un feedback et met à jour la décision associée."""
|
||||||
|
decision = await db.get(AIDecision, decision_id)
|
||||||
|
if not decision:
|
||||||
|
raise ValueError(f"Décision {decision_id} introuvable")
|
||||||
|
|
||||||
|
# Créer le feedback
|
||||||
|
fb = LearningFeedback(
|
||||||
|
decision_id=decision_id,
|
||||||
|
rating=rating,
|
||||||
|
better_action=better_action,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
db.add(fb)
|
||||||
|
|
||||||
|
# Mettre à jour la décision avec le résultat
|
||||||
|
decision.outcome_rating = {"good": 1, "neutral": 0, "bad": -1}.get(rating, 0)
|
||||||
|
decision.user_feedback = comment
|
||||||
|
if better_action:
|
||||||
|
decision.better_decision = better_action
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Buffer pour entraînement
|
||||||
|
self._buffer.append({
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"game_state": decision.game_state,
|
||||||
|
"recommendation": decision.recommendation,
|
||||||
|
"rating": rating,
|
||||||
|
"better_action": better_action,
|
||||||
|
"ts": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("feedback.recorded", id=decision_id, rating=rating,
|
||||||
|
buffer=len(self._buffer))
|
||||||
|
|
||||||
|
# Auto-flush si buffer plein
|
||||||
|
if (self.settings.learning_auto_save
|
||||||
|
and len(self._buffer) >= self.settings.learning_batch_size):
|
||||||
|
await self._flush_buffer()
|
||||||
|
|
||||||
|
return fb
|
||||||
|
|
||||||
|
async def _flush_buffer(self):
|
||||||
|
"""Exporte le buffer en JSON pour entraînement."""
|
||||||
|
if not self._buffer:
|
||||||
|
return
|
||||||
|
os.makedirs("data/learning/feedback", exist_ok=True)
|
||||||
|
fname = f"data/learning/feedback/batch_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
try:
|
||||||
|
import aiofiles
|
||||||
|
async with aiofiles.open(fname, "w") as f:
|
||||||
|
await f.write(json.dumps(self._buffer, indent=2, ensure_ascii=False))
|
||||||
|
self._trained_count += len(self._buffer)
|
||||||
|
log.info("feedback.batch_saved", count=len(self._buffer), file=fname)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("feedback.flush_failed", error=str(e))
|
||||||
|
finally:
|
||||||
|
self._buffer.clear()
|
||||||
|
|
||||||
|
async def force_flush(self):
|
||||||
|
"""Flush manuel du buffer."""
|
||||||
|
await self._flush_buffer()
|
||||||
|
|
||||||
|
async def get_stats(self, db: AsyncSession) -> dict:
|
||||||
|
"""Statistiques globales du système d'apprentissage."""
|
||||||
|
result = await db.execute(select(LearningFeedback))
|
||||||
|
feedbacks = result.scalars().all()
|
||||||
|
total = len(feedbacks)
|
||||||
|
good = sum(1 for f in feedbacks if f.rating == "good")
|
||||||
|
bad = sum(1 for f in feedbacks if f.rating == "bad")
|
||||||
|
neutral = total - good - bad
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"good": good,
|
||||||
|
"bad": bad,
|
||||||
|
"neutral": neutral,
|
||||||
|
"good_rate": round(good / total * 100, 1) if total > 0 else 0.0,
|
||||||
|
"trained": self._trained_count,
|
||||||
|
"buffer_pending": len(self._buffer),
|
||||||
|
"learning_enabled": self.settings.learning_enabled,
|
||||||
|
}
|
||||||
0
hsbg_ai/backend/api/__init__.py
Normal file
0
hsbg_ai/backend/api/__init__.py
Normal file
0
hsbg_ai/backend/api/middleware/__init__.py
Normal file
0
hsbg_ai/backend/api/middleware/__init__.py
Normal file
0
hsbg_ai/backend/api/routes/__init__.py
Normal file
0
hsbg_ai/backend/api/routes/__init__.py
Normal file
115
hsbg_ai/backend/api/routes/advice.py
Normal file
115
hsbg_ai/backend/api/routes/advice.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Routes API — Conseils IA."""
|
||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from backend.database.db import get_db
|
||||||
|
from backend.database.models import AIDecision
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AdviceRequest(BaseModel):
|
||||||
|
session_id: int | None = None
|
||||||
|
turn: int = 0
|
||||||
|
tavern_tier: int = 1
|
||||||
|
gold: int = 3
|
||||||
|
hero_id: str = ""
|
||||||
|
hero_hp: int = 40
|
||||||
|
board_minions: list = []
|
||||||
|
tavern_minions: list = []
|
||||||
|
hand_minions: list = []
|
||||||
|
freeze: bool = False
|
||||||
|
can_upgrade: bool = True
|
||||||
|
upgrade_cost: int = 5
|
||||||
|
available_spells: list = []
|
||||||
|
current_placement: int = 5
|
||||||
|
player_count: int = 8
|
||||||
|
phase: str = "recruit"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_advice(advice) -> dict:
|
||||||
|
"""Sérialise un objet FullAdvice en dict JSON-serializable."""
|
||||||
|
return {
|
||||||
|
"main_decision": {
|
||||||
|
"action": advice.main_decision.action,
|
||||||
|
"target": advice.main_decision.target,
|
||||||
|
"priority": advice.main_decision.priority,
|
||||||
|
"confidence": advice.main_decision.confidence,
|
||||||
|
"reasoning": advice.main_decision.reasoning,
|
||||||
|
"synergies": advice.main_decision.synergies_highlighted,
|
||||||
|
"warnings": advice.main_decision.warnings,
|
||||||
|
},
|
||||||
|
"secondary_decisions": [
|
||||||
|
{
|
||||||
|
"action": d.action,
|
||||||
|
"target": d.target,
|
||||||
|
"priority": d.priority,
|
||||||
|
"confidence": d.confidence,
|
||||||
|
"reasoning": d.reasoning,
|
||||||
|
}
|
||||||
|
for d in advice.secondary_decisions
|
||||||
|
],
|
||||||
|
"board_analysis": advice.board_analysis,
|
||||||
|
"strategy_long_term": advice.strategy_long_term,
|
||||||
|
"threat_assessment": advice.threat_assessment,
|
||||||
|
"processing_ms": advice.processing_ms,
|
||||||
|
"model_used": advice.model_used,
|
||||||
|
"confidence_overall": advice.confidence_overall,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def get_advice(
|
||||||
|
req: AdviceRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Génère un conseil IA pour l'état de jeu fourni."""
|
||||||
|
ai = request.app.state.ai_service
|
||||||
|
if not ai:
|
||||||
|
raise HTTPException(503, "Service IA non initialisé")
|
||||||
|
|
||||||
|
state = req.model_dump()
|
||||||
|
advice = await ai.get_advice(state)
|
||||||
|
data = serialize_advice(advice)
|
||||||
|
|
||||||
|
# Persister si session active
|
||||||
|
if req.session_id:
|
||||||
|
dec = AIDecision(
|
||||||
|
session_id=req.session_id,
|
||||||
|
turn=req.turn,
|
||||||
|
phase=req.phase,
|
||||||
|
game_state=state,
|
||||||
|
recommendation=data,
|
||||||
|
reasoning=advice.main_decision.reasoning,
|
||||||
|
confidence=advice.confidence_overall,
|
||||||
|
model_used=advice.model_used,
|
||||||
|
processing_ms=advice.processing_ms,
|
||||||
|
)
|
||||||
|
db.add(dec)
|
||||||
|
await db.flush()
|
||||||
|
data["decision_id"] = dec.id
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/from-screen")
|
||||||
|
async def advice_from_screen(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Génère un conseil depuis la capture d'écran en cours."""
|
||||||
|
vis = request.app.state.vision_service
|
||||||
|
ai = request.app.state.ai_service
|
||||||
|
|
||||||
|
if not vis:
|
||||||
|
raise HTTPException(503, "Service vision non disponible")
|
||||||
|
|
||||||
|
# Obtenir l'état actuel (ou déclencher une capture)
|
||||||
|
state = vis.get_current_state()
|
||||||
|
if not state:
|
||||||
|
state = await vis.capture_now()
|
||||||
|
|
||||||
|
advice = await ai.get_advice(state)
|
||||||
|
return {
|
||||||
|
"advice": serialize_advice(advice),
|
||||||
|
"screenshot": vis.get_screenshot_b64(),
|
||||||
|
"extracted_state": state,
|
||||||
|
}
|
||||||
146
hsbg_ai/backend/api/routes/database_routes.py
Normal file
146
hsbg_ai/backend/api/routes/database_routes.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Routes API — Base de données HSBG (héros, serviteurs, sorts)."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from backend.database.db import get_db
|
||||||
|
from backend.database.models import Hero, Minion, Spell
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Héros ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/heroes")
|
||||||
|
async def list_heroes(search: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||||
|
q = select(Hero).where(Hero.is_active == True)
|
||||||
|
if search:
|
||||||
|
q = q.where(Hero.name.ilike(f"%{search}%"))
|
||||||
|
r = await db.execute(q)
|
||||||
|
return [
|
||||||
|
{"id": h.id, "card_id": h.card_id, "name": h.name, "hero_power": h.hero_power,
|
||||||
|
"description": h.description, "strengths": h.strengths, "weaknesses": h.weaknesses,
|
||||||
|
"synergies": h.synergies, "tier_rating": h.tier_rating, "patch_added": h.patch_added}
|
||||||
|
for h in r.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class HeroIn(BaseModel):
|
||||||
|
card_id: str
|
||||||
|
name: str
|
||||||
|
hero_power: str = ""
|
||||||
|
description: str = ""
|
||||||
|
strengths: list = []
|
||||||
|
weaknesses: list = []
|
||||||
|
synergies: list = []
|
||||||
|
tier_rating: float = 5.0
|
||||||
|
patch_added: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heroes", status_code=201)
|
||||||
|
async def create_hero(data: HeroIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
hero = Hero(**data.model_dump())
|
||||||
|
db.add(hero)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": hero.id, "name": hero.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/heroes/{hero_id}")
|
||||||
|
async def update_hero(hero_id: int, data: HeroIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
hero = await db.get(Hero, hero_id)
|
||||||
|
if not hero:
|
||||||
|
raise HTTPException(404, "Héros introuvable")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(hero, k, v)
|
||||||
|
return {"id": hero.id, "name": hero.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/heroes/{hero_id}")
|
||||||
|
async def delete_hero(hero_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
hero = await db.get(Hero, hero_id)
|
||||||
|
if not hero:
|
||||||
|
raise HTTPException(404, "Héros introuvable")
|
||||||
|
hero.is_active = False
|
||||||
|
return {"status": "deactivated"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Serviteurs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/minions")
|
||||||
|
async def list_minions(
|
||||||
|
tier: int | None = None,
|
||||||
|
race: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = select(Minion).where(Minion.is_active == True)
|
||||||
|
if tier:
|
||||||
|
q = q.where(Minion.tier == str(tier))
|
||||||
|
if search:
|
||||||
|
q = q.where(Minion.name.ilike(f"%{search}%"))
|
||||||
|
r = await db.execute(q)
|
||||||
|
minions = r.scalars().all()
|
||||||
|
if race:
|
||||||
|
minions = [m for m in minions if race in (m.race or [])]
|
||||||
|
return [
|
||||||
|
{"id": m.id, "card_id": m.card_id, "name": m.name, "tier": m.tier,
|
||||||
|
"race": m.race, "attack": m.attack, "health": m.health,
|
||||||
|
"has_divine": m.has_divine, "has_taunt": m.has_taunt,
|
||||||
|
"has_windfury": m.has_windfury, "has_poisonous": m.has_poisonous,
|
||||||
|
"has_reborn": m.has_reborn, "battlecry": m.battlecry,
|
||||||
|
"deathrattle": m.deathrattle, "passive": m.passive,
|
||||||
|
"synergies": m.synergies, "keywords": m.keywords, "patch_added": m.patch_added}
|
||||||
|
for m in minions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MinionIn(BaseModel):
|
||||||
|
card_id: str
|
||||||
|
name: str
|
||||||
|
tier: str = "1"
|
||||||
|
race: list = []
|
||||||
|
attack: int = 0
|
||||||
|
health: int = 0
|
||||||
|
tavern_cost: int = 3
|
||||||
|
has_divine: bool = False
|
||||||
|
has_taunt: bool = False
|
||||||
|
has_windfury: bool = False
|
||||||
|
has_poisonous: bool = False
|
||||||
|
has_reborn: bool = False
|
||||||
|
battlecry: str = ""
|
||||||
|
deathrattle: str = ""
|
||||||
|
on_attack: str = ""
|
||||||
|
passive: str = ""
|
||||||
|
synergies: list = []
|
||||||
|
keywords: list = []
|
||||||
|
patch_added: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/minions", status_code=201)
|
||||||
|
async def create_minion(data: MinionIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
minion = Minion(**data.model_dump())
|
||||||
|
db.add(minion)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": minion.id, "name": minion.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/minions/{minion_id}")
|
||||||
|
async def update_minion(minion_id: int, data: MinionIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
minion = await db.get(Minion, minion_id)
|
||||||
|
if not minion:
|
||||||
|
raise HTTPException(404, "Serviteur introuvable")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(minion, k, v)
|
||||||
|
return {"id": minion.id, "name": minion.name}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Sorts ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/spells")
|
||||||
|
async def list_spells(db: AsyncSession = Depends(get_db)):
|
||||||
|
r = await db.execute(select(Spell).where(Spell.is_active == True))
|
||||||
|
return [
|
||||||
|
{"id": s.id, "card_id": s.card_id, "name": s.name, "tier": s.tier,
|
||||||
|
"cost": s.cost, "effect": s.effect, "target": s.target}
|
||||||
|
for s in r.scalars().all()
|
||||||
|
]
|
||||||
70
hsbg_ai/backend/api/routes/game.py
Normal file
70
hsbg_ai/backend/api/routes/game.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Routes API — Gestion des sessions de jeu."""
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from backend.database.db import get_db
|
||||||
|
from backend.database.models import GameSession
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_game(hero_id: str = "", player_count: int = 8,
|
||||||
|
db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Démarre une nouvelle session de jeu."""
|
||||||
|
s = GameSession(is_active=True, session_meta={"player_count": player_count, "hero_id": hero_id})
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
return {"session_id": s.id, "started_at": s.started_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/end")
|
||||||
|
async def end_game(session_id: int, final_place: int = 4,
|
||||||
|
db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Termine une session avec la place finale."""
|
||||||
|
s = await db.get(GameSession, session_id)
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Session introuvable")
|
||||||
|
s.is_active = False
|
||||||
|
s.ended_at = datetime.utcnow()
|
||||||
|
s.final_place = final_place
|
||||||
|
return {"status": "ended", "session_id": session_id, "final_place": final_place}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active")
|
||||||
|
async def get_active(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Retourne la session active ou {'active': false}."""
|
||||||
|
r = await db.execute(select(GameSession).where(GameSession.is_active == True))
|
||||||
|
s = r.scalar_one_or_none()
|
||||||
|
if not s:
|
||||||
|
return {"active": False}
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"session_id": s.id,
|
||||||
|
"started_at": s.started_at.isoformat(),
|
||||||
|
"total_turns": s.total_turns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(limit: int = 10, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Historique des parties terminées."""
|
||||||
|
from sqlalchemy import desc
|
||||||
|
r = await db.execute(
|
||||||
|
select(GameSession)
|
||||||
|
.where(GameSession.is_active == False)
|
||||||
|
.order_by(desc(GameSession.ended_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
sessions = r.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"started_at": s.started_at.isoformat(),
|
||||||
|
"ended_at": s.ended_at.isoformat() if s.ended_at else None,
|
||||||
|
"final_place": s.final_place,
|
||||||
|
"total_turns": s.total_turns,
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
92
hsbg_ai/backend/api/routes/learning.py
Normal file
92
hsbg_ai/backend/api/routes/learning.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Routes API — Mode Apprentissage."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from typing import Literal
|
||||||
|
from backend.database.db import get_db
|
||||||
|
from backend.database.models import AIDecision, LearningFeedback
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
_processor = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_processor():
|
||||||
|
global _processor
|
||||||
|
if not _processor:
|
||||||
|
from backend.config.settings import get_settings
|
||||||
|
from backend.ai.learning.feedback_processor import FeedbackProcessor
|
||||||
|
_processor = FeedbackProcessor(get_settings())
|
||||||
|
return _processor
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackIn(BaseModel):
|
||||||
|
decision_id: int
|
||||||
|
rating: Literal["good", "bad", "neutral"]
|
||||||
|
better_action: dict | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback")
|
||||||
|
async def submit_feedback(req: FeedbackIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Soumet un retour utilisateur sur une décision IA."""
|
||||||
|
proc = _get_processor()
|
||||||
|
try:
|
||||||
|
fb = await proc.record_feedback(
|
||||||
|
db,
|
||||||
|
decision_id=req.decision_id,
|
||||||
|
rating=req.rating,
|
||||||
|
better_action=req.better_action,
|
||||||
|
comment=req.comment,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"feedback_id": fb.id,
|
||||||
|
"rating": fb.rating,
|
||||||
|
"message": "Feedback enregistré — merci pour votre contribution!",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Statistiques globales du système d'apprentissage."""
|
||||||
|
return await _get_processor().get_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/decisions")
|
||||||
|
async def get_decisions(
|
||||||
|
session_id: int | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Historique des décisions IA avec leurs feedbacks."""
|
||||||
|
q = select(AIDecision).order_by(desc(AIDecision.created_at)).limit(limit)
|
||||||
|
if session_id:
|
||||||
|
q = q.where(AIDecision.session_id == session_id)
|
||||||
|
result = await db.execute(q)
|
||||||
|
decisions = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": d.id,
|
||||||
|
"session_id": d.session_id,
|
||||||
|
"turn": d.turn,
|
||||||
|
"phase": d.phase,
|
||||||
|
"recommendation": d.recommendation,
|
||||||
|
"reasoning": d.reasoning,
|
||||||
|
"confidence": d.confidence,
|
||||||
|
"outcome_rating": d.outcome_rating,
|
||||||
|
"user_feedback": d.user_feedback,
|
||||||
|
"model_used": d.model_used,
|
||||||
|
"processing_ms": d.processing_ms,
|
||||||
|
"created_at": d.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for d in decisions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flush")
|
||||||
|
async def flush_buffer():
|
||||||
|
"""Force l'export du buffer d'apprentissage."""
|
||||||
|
await _get_processor().force_flush()
|
||||||
|
return {"status": "flushed"}
|
||||||
25
hsbg_ai/backend/api/routes/settings_routes.py
Normal file
25
hsbg_ai/backend/api/routes/settings_routes.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Routes API — Paramètres."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from backend.config.settings import get_settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_config():
|
||||||
|
"""Retourne la configuration active (lecture seule)."""
|
||||||
|
s = get_settings()
|
||||||
|
return {
|
||||||
|
"llm_provider": s.llm_provider,
|
||||||
|
"llm_model": s.llm_model,
|
||||||
|
"llm_base_url": s.llm_base_url,
|
||||||
|
"llm_temperature": s.llm_temperature,
|
||||||
|
"llm_max_tokens": s.llm_max_tokens,
|
||||||
|
"vision_enabled": s.vision_enabled,
|
||||||
|
"screenshot_interval": s.screenshot_interval,
|
||||||
|
"learning_enabled": s.learning_enabled,
|
||||||
|
"learning_rate": s.learning_rate,
|
||||||
|
"learning_batch_size": s.learning_batch_size,
|
||||||
|
"debug": s.debug,
|
||||||
|
"current_patch": s.current_patch,
|
||||||
|
}
|
||||||
76
hsbg_ai/backend/api/routes/websocket_routes.py
Normal file
76
hsbg_ai/backend/api/routes/websocket_routes.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Routes WebSocket — Mises à jour en temps réel."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Request
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
_clients: list[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/game")
|
||||||
|
async def ws_game(websocket: WebSocket, request: Request):
|
||||||
|
"""WebSocket principal pour conseils en temps réel."""
|
||||||
|
await websocket.accept()
|
||||||
|
_clients.append(websocket)
|
||||||
|
log.info("ws.client_connected", total=len(_clients))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
|
||||||
|
if msg_type == "state_update":
|
||||||
|
# L'état du jeu a changé → calculer un conseil
|
||||||
|
ai = request.app.state.ai_service
|
||||||
|
if ai:
|
||||||
|
advice = await ai.get_advice(msg.get("state", {}))
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "advice",
|
||||||
|
"data": {
|
||||||
|
"action": advice.main_decision.action,
|
||||||
|
"reasoning": advice.main_decision.reasoning,
|
||||||
|
"confidence": advice.main_decision.confidence,
|
||||||
|
"warnings": advice.main_decision.warnings,
|
||||||
|
"board_analysis": advice.board_analysis,
|
||||||
|
"model": advice.model_used,
|
||||||
|
"ms": advice.processing_ms,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
elif msg_type == "ping":
|
||||||
|
await websocket.send_json({"type": "pong", "ts": asyncio.get_event_loop().time()})
|
||||||
|
|
||||||
|
elif msg_type == "screenshot_request":
|
||||||
|
# Demande de capture d'écran
|
||||||
|
vis = request.app.state.vision_service
|
||||||
|
if vis:
|
||||||
|
state = await vis.capture_now()
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "screenshot_result",
|
||||||
|
"state": state,
|
||||||
|
"screenshot": vis.get_screenshot_b64(),
|
||||||
|
})
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
_clients.remove(websocket)
|
||||||
|
log.info("ws.client_disconnected", total=len(_clients))
|
||||||
|
except Exception as e:
|
||||||
|
log.error("ws.error", error=str(e))
|
||||||
|
if websocket in _clients:
|
||||||
|
_clients.remove(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(message: dict):
|
||||||
|
"""Diffuse un message à tous les clients connectés."""
|
||||||
|
disconnected = []
|
||||||
|
for client in _clients:
|
||||||
|
try:
|
||||||
|
await client.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
disconnected.append(client)
|
||||||
|
for c in disconnected:
|
||||||
|
_clients.remove(c)
|
||||||
0
hsbg_ai/backend/config/__init__.py
Normal file
0
hsbg_ai/backend/config/__init__.py
Normal file
67
hsbg_ai/backend/config/settings.py
Normal file
67
hsbg_ai/backend/config/settings.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Configuration centralisée via Pydantic Settings."""
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import field_validator
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Serveur
|
||||||
|
backend_host: str = "127.0.0.1"
|
||||||
|
backend_port: int = 8000
|
||||||
|
debug: bool = True
|
||||||
|
secret_key: str = "changeme"
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
database_url: str = "sqlite:///./data/hsbg_ai.db"
|
||||||
|
|
||||||
|
# LLM
|
||||||
|
llm_provider: str = "ollama"
|
||||||
|
llm_base_url: str = "http://localhost:11434"
|
||||||
|
llm_model: str = "llama3.2"
|
||||||
|
llm_fallback_model: str = "mistral"
|
||||||
|
llm_timeout: int = 30
|
||||||
|
llm_max_tokens: int = 2048
|
||||||
|
llm_temperature: float = 0.1
|
||||||
|
|
||||||
|
# Vision
|
||||||
|
vision_enabled: bool = True
|
||||||
|
screenshot_interval: float = 2.0
|
||||||
|
tesseract_path: str = "/usr/bin/tesseract"
|
||||||
|
|
||||||
|
# Apprentissage
|
||||||
|
learning_enabled: bool = True
|
||||||
|
learning_rate: float = 0.001
|
||||||
|
learning_batch_size: int = 32
|
||||||
|
learning_auto_save: bool = True
|
||||||
|
learning_save_interval: int = 300
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend_host: str = "127.0.0.1"
|
||||||
|
frontend_port: int = 3000
|
||||||
|
cors_origins: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_file: str = "./logs/hsbg_ai.log"
|
||||||
|
|
||||||
|
# Data
|
||||||
|
hsbg_data_path: str = "./data/hsbg"
|
||||||
|
current_patch: str = "30.2"
|
||||||
|
|
||||||
|
@field_validator("cors_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [x.strip() for x in v.split(",")]
|
||||||
|
return v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
0
hsbg_ai/backend/database/__init__.py
Normal file
0
hsbg_ai/backend/database/__init__.py
Normal file
33
hsbg_ai/backend/database/db.py
Normal file
33
hsbg_ai/backend/database/db.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Connexion et sessions de base de données async."""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from backend.config.settings import get_settings
|
||||||
|
from backend.database.models import Base
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Convertir l'URL SQLite en version async
|
||||||
|
DB_URL = settings.database_url
|
||||||
|
if DB_URL.startswith("sqlite:///"):
|
||||||
|
DB_URL = DB_URL.replace("sqlite:///", "sqlite+aiosqlite:///")
|
||||||
|
|
||||||
|
engine = create_async_engine(DB_URL, echo=settings.debug, pool_pre_ping=True)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Crée toutes les tables si elles n'existent pas."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
"""Dépendance FastAPI - fournit une session DB."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
0
hsbg_ai/backend/database/migrations/__init__.py
Normal file
0
hsbg_ai/backend/database/migrations/__init__.py
Normal file
128
hsbg_ai/backend/database/models.py
Normal file
128
hsbg_ai/backend/database/models.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Modèles SQLAlchemy - Base de données HSBG AI."""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Float, Boolean, Text,
|
||||||
|
DateTime, JSON, ForeignKey
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Hero(Base):
|
||||||
|
__tablename__ = "heroes"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
card_id = Column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
name = Column(String(128), nullable=False)
|
||||||
|
hero_power = Column(Text, default="")
|
||||||
|
hp_cost = Column(Integer, default=0)
|
||||||
|
hp_cooldown = Column(Integer, default=0)
|
||||||
|
description = Column(Text, default="")
|
||||||
|
strengths = Column(JSON, default=list)
|
||||||
|
weaknesses = Column(JSON, default=list)
|
||||||
|
synergies = Column(JSON, default=list)
|
||||||
|
tier_rating = Column(Float, default=5.0)
|
||||||
|
patch_added = Column(String(16), default="")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Minion(Base):
|
||||||
|
__tablename__ = "minions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
card_id = Column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
name = Column(String(128), nullable=False)
|
||||||
|
tier = Column(String(2), nullable=False, default="1")
|
||||||
|
race = Column(JSON, default=list)
|
||||||
|
attack = Column(Integer, default=0)
|
||||||
|
health = Column(Integer, default=0)
|
||||||
|
tavern_cost = Column(Integer, default=3)
|
||||||
|
is_golden = Column(Boolean, default=False)
|
||||||
|
has_divine = Column(Boolean, default=False)
|
||||||
|
has_taunt = Column(Boolean, default=False)
|
||||||
|
has_windfury = Column(Boolean, default=False)
|
||||||
|
has_poisonous = Column(Boolean, default=False)
|
||||||
|
has_reborn = Column(Boolean, default=False)
|
||||||
|
battlecry = Column(Text, default="")
|
||||||
|
deathrattle = Column(Text, default="")
|
||||||
|
on_attack = Column(Text, default="")
|
||||||
|
passive = Column(Text, default="")
|
||||||
|
synergies = Column(JSON, default=list)
|
||||||
|
keywords = Column(JSON, default=list)
|
||||||
|
patch_added = Column(String(16), default="")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Spell(Base):
|
||||||
|
__tablename__ = "spells"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
card_id = Column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
name = Column(String(128), nullable=False)
|
||||||
|
tier = Column(String(2), default="1")
|
||||||
|
cost = Column(Integer, default=0)
|
||||||
|
effect = Column(Text, default="")
|
||||||
|
target = Column(String(64), default="minion")
|
||||||
|
keywords = Column(JSON, default=list)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class GameSession(Base):
|
||||||
|
__tablename__ = "game_sessions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
started_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
ended_at = Column(DateTime, nullable=True)
|
||||||
|
hero_id = Column(Integer, ForeignKey("heroes.id"), nullable=True)
|
||||||
|
final_place = Column(Integer, nullable=True)
|
||||||
|
total_turns = Column(Integer, default=0)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
session_meta = Column(JSON, default=dict)
|
||||||
|
hero = relationship("Hero")
|
||||||
|
decisions = relationship("AIDecision", back_populates="session")
|
||||||
|
|
||||||
|
|
||||||
|
class AIDecision(Base):
|
||||||
|
__tablename__ = "ai_decisions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False)
|
||||||
|
turn = Column(Integer, default=0)
|
||||||
|
phase = Column(String(32), default="recruit")
|
||||||
|
game_state = Column(JSON, default=dict)
|
||||||
|
recommendation = Column(JSON, default=dict)
|
||||||
|
reasoning = Column(Text, default="")
|
||||||
|
confidence = Column(Float, default=0.5)
|
||||||
|
was_followed = Column(Boolean, nullable=True)
|
||||||
|
outcome_rating = Column(Integer, nullable=True)
|
||||||
|
user_feedback = Column(Text, nullable=True)
|
||||||
|
better_decision = Column(JSON, nullable=True)
|
||||||
|
model_used = Column(String(64), default="heuristic")
|
||||||
|
processing_ms = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
session = relationship("GameSession", back_populates="decisions")
|
||||||
|
|
||||||
|
|
||||||
|
class LearningFeedback(Base):
|
||||||
|
__tablename__ = "learning_feedback"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
decision_id = Column(Integer, ForeignKey("ai_decisions.id"), nullable=False)
|
||||||
|
rating = Column(String(8), default="neutral")
|
||||||
|
better_action = Column(JSON, nullable=True)
|
||||||
|
comment = Column(Text, nullable=True)
|
||||||
|
trained = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
decision = relationship("AIDecision")
|
||||||
|
|
||||||
|
|
||||||
|
class Patch(Base):
|
||||||
|
__tablename__ = "patches"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
version = Column(String(16), unique=True, nullable=False)
|
||||||
|
release_date = Column(DateTime)
|
||||||
|
changes = Column(JSON, default=dict)
|
||||||
|
notes = Column(Text, default="")
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
0
hsbg_ai/backend/database/seeds/__init__.py
Normal file
0
hsbg_ai/backend/database/seeds/__init__.py
Normal file
225
hsbg_ai/backend/database/seeds/seed_data.py
Normal file
225
hsbg_ai/backend/database/seeds/seed_data.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Peuplement initial de la base de données HSBG.
|
||||||
|
Usage: python -m backend.database.seeds.seed_data
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ajouter le répertoire racine au path
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
||||||
|
|
||||||
|
from backend.database.db import AsyncSessionLocal, init_db
|
||||||
|
from backend.database.models import Hero, Minion, Spell
|
||||||
|
|
||||||
|
# ─── Données Héros ────────────────────────────────────────────────────────────
|
||||||
|
HEROES = [
|
||||||
|
{
|
||||||
|
"card_id": "HERO_ragnaros", "name": "Ragnaros l'Illuminé", "tier_rating": 7.0,
|
||||||
|
"hero_power": "Niveau de lancement: Donne +3 ATT à un serviteur ami aléatoire.",
|
||||||
|
"hp_cost": 2, "hp_cooldown": 0,
|
||||||
|
"description": "Excellent en mid/late game agressif. Meilleur avec des boards denses.",
|
||||||
|
"strengths": ["combat", "aggressive_boards", "mid_game"],
|
||||||
|
"weaknesses": ["divine_shield_heavy", "defensive_boards"],
|
||||||
|
"synergies": ["beast", "demon", "all"], "patch_added": "15.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_millificent", "name": "Millificent Manastorm", "tier_rating": 6.5,
|
||||||
|
"hero_power": "Passif: La taverne propose toujours au moins un Mécanisme.",
|
||||||
|
"hp_cost": 0, "hp_cooldown": 0,
|
||||||
|
"description": "Setup méca rapide et consistant. Idéal pour les synergies méca.",
|
||||||
|
"strengths": ["mech_setup", "consistency", "divine_shield_generation"],
|
||||||
|
"weaknesses": ["slow_early", "no_mechs_in_rotation"],
|
||||||
|
"synergies": ["mech"], "patch_added": "15.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_finley", "name": "Sire Finley de Mrglton", "tier_rating": 7.5,
|
||||||
|
"hero_power": "Avance vos serviteurs de la taverne. Coût: 2.",
|
||||||
|
"hp_cost": 2, "hp_cooldown": 0,
|
||||||
|
"description": "Très polyvalent, s'adapte à toutes les compositions. Top pick en général.",
|
||||||
|
"strengths": ["flexible", "tempo", "any_comp"],
|
||||||
|
"weaknesses": ["gold_hungry"],
|
||||||
|
"synergies": ["all"], "patch_added": "15.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_mukla", "name": "Roi Mukla", "tier_rating": 4.0,
|
||||||
|
"hero_power": "Passif: Gagnez 1 or de plus par tour (mais donne des Bananes aux adversaires).",
|
||||||
|
"hp_cost": 0, "hp_cooldown": 0,
|
||||||
|
"description": "Avantage économique constant mais renforce les adversaires.",
|
||||||
|
"strengths": ["economy", "fast_tier", "late_game_scaling"],
|
||||||
|
"weaknesses": ["banana_enemies", "weak_early_game"],
|
||||||
|
"synergies": ["economy", "late_game"], "patch_added": "16.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_deathwing", "name": "Deathwing", "tier_rating": 5.5,
|
||||||
|
"hero_power": "Passif: Tous vos serviteurs gagnent +3 ATT.",
|
||||||
|
"hp_cost": 0, "hp_cooldown": 0,
|
||||||
|
"description": "Buff d'attaque passif. Excellent pour les compositions agressives early.",
|
||||||
|
"strengths": ["early_aggression", "tempo", "all_minions"],
|
||||||
|
"weaknesses": ["late_game_scaling", "no_defensive_option"],
|
||||||
|
"synergies": ["all"], "patch_added": "15.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_jaraxxus", "name": "Lord Jaraxxus", "tier_rating": 6.0,
|
||||||
|
"hero_power": "Octroie +1/+1 à un Démon ami aléatoire. Coût: 1.",
|
||||||
|
"hp_cost": 1, "hp_cooldown": 0,
|
||||||
|
"description": "Scale très bien avec une composition démon.",
|
||||||
|
"strengths": ["demon_comp", "consistent_scaling"],
|
||||||
|
"weaknesses": ["requires_demons", "slow_if_no_demons"],
|
||||||
|
"synergies": ["demon"], "patch_added": "15.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": "HERO_patchwork", "name": "Patchwork", "tier_rating": 6.0,
|
||||||
|
"hero_power": "Passif: Votre premier serviteur vendu chaque tour donne +1/+1 à un serviteur ami aléatoire.",
|
||||||
|
"hp_cost": 0, "hp_cooldown": 0,
|
||||||
|
"description": "Excellent pour les compositions avec beaucoup de ventes.",
|
||||||
|
"strengths": ["sell_synergy", "flexible", "economy"],
|
||||||
|
"weaknesses": ["requires_selling_strategy"],
|
||||||
|
"synergies": ["all"], "patch_added": "17.0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── Données Serviteurs ───────────────────────────────────────────────────────
|
||||||
|
MINIONS = [
|
||||||
|
# ── Tier 1 ──
|
||||||
|
{"card_id": "BGS_t1_01", "name": "Gardien de la Sécurité", "tier": "1",
|
||||||
|
"race": ["mech"], "attack": 2, "health": 3, "tavern_cost": 3,
|
||||||
|
"has_divine": True, "deathrattle": "Donne +3 ATT à un méca ami aléatoire.",
|
||||||
|
"synergies": ["mech"], "keywords": ["divine_shield", "deathrattle"], "patch_added": "16.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t1_02", "name": "Hyène Affamée", "tier": "1",
|
||||||
|
"race": ["beast"], "attack": 1, "health": 1, "tavern_cost": 3,
|
||||||
|
"passive": "Quand un Murloc ami meurt: gagne +2 ATT.",
|
||||||
|
"synergies": ["beast", "murloc"], "keywords": ["passive"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t1_03", "name": "Murloc Tidecaller", "tier": "1",
|
||||||
|
"race": ["murloc"], "attack": 1, "health": 2, "tavern_cost": 3,
|
||||||
|
"passive": "Gagne +1 ATT chaque fois qu'un Murloc est invoqué.",
|
||||||
|
"synergies": ["murloc"], "keywords": ["passive"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t1_04", "name": "Drone Déchiqueté", "tier": "1",
|
||||||
|
"race": ["mech"], "attack": 1, "health": 1, "tavern_cost": 3,
|
||||||
|
"deathrattle": "Invoque deux 1/1 Drones.",
|
||||||
|
"synergies": ["mech", "token"], "keywords": ["deathrattle"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
# ── Tier 2 ──
|
||||||
|
{"card_id": "BGS_t2_01", "name": "Dragueur de Taverne", "tier": "2",
|
||||||
|
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 3,
|
||||||
|
"battlecry": "Donne +2/+2 à un serviteur ami aléatoire.",
|
||||||
|
"synergies": [], "keywords": ["battlecry"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t2_02", "name": "Attaquant du Fouet", "tier": "2",
|
||||||
|
"race": ["murloc"], "attack": 2, "health": 1, "tavern_cost": 3,
|
||||||
|
"has_poisonous": True, "battlecry": "Donne Venimeux à un Murloc ami aléatoire.",
|
||||||
|
"synergies": ["murloc"], "keywords": ["poisonous", "battlecry"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t2_03", "name": "Paladin Défenseur", "tier": "2",
|
||||||
|
"race": ["none"], "attack": 2, "health": 2, "tavern_cost": 3,
|
||||||
|
"has_divine": True, "has_taunt": True,
|
||||||
|
"synergies": [], "keywords": ["divine_shield", "taunt"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
# ── Tier 3 ──
|
||||||
|
{"card_id": "BGS_t3_01", "name": "Infernal de Sang", "tier": "3",
|
||||||
|
"race": ["demon"], "attack": 3, "health": 4, "tavern_cost": 4,
|
||||||
|
"has_taunt": True, "deathrattle": "Invoque un 3/3 Infernal.",
|
||||||
|
"synergies": ["demon"], "keywords": ["taunt", "deathrattle"], "patch_added": "15.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t3_02", "name": "Vif-Argent Libre", "tier": "3",
|
||||||
|
"race": ["mech"], "attack": 3, "health": 4, "tavern_cost": 4,
|
||||||
|
"has_divine": True,
|
||||||
|
"passive": "Quand un méca ami gagne un Bouclier Divin: gagne +2 ATT.",
|
||||||
|
"synergies": ["mech"], "keywords": ["divine_shield", "passive"], "patch_added": "16.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t3_03", "name": "Infesteur de Crève-Mort", "tier": "3",
|
||||||
|
"race": ["undead"], "attack": 3, "health": 3, "tavern_cost": 4,
|
||||||
|
"deathrattle": "Infeste un serviteur ami aléatoire avec +3/+3.",
|
||||||
|
"synergies": ["undead"], "keywords": ["deathrattle"], "patch_added": "22.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t3_04", "name": "Défenseur de Sombrebourg", "tier": "3",
|
||||||
|
"race": ["none"], "attack": 2, "health": 5, "tavern_cost": 4,
|
||||||
|
"has_taunt": True, "passive": "Chaque fois qu'un serviteur ami gagne du Bouclier Divin: gagne +2/+2.",
|
||||||
|
"synergies": ["divine_shield"], "keywords": ["taunt", "passive"], "patch_added": "16.0"},
|
||||||
|
|
||||||
|
# ── Tier 4 ──
|
||||||
|
{"card_id": "BGS_t4_01", "name": "Brann Bronzebeard", "tier": "4",
|
||||||
|
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 5,
|
||||||
|
"passive": "Vos cris de guerre se déclenchent deux fois.",
|
||||||
|
"synergies": ["battlecry"], "keywords": ["passive"], "patch_added": "17.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t4_02", "name": "Chevauche-Tempête", "tier": "4",
|
||||||
|
"race": ["dragon"], "attack": 5, "health": 4, "tavern_cost": 5,
|
||||||
|
"battlecry": "Donne +3 ATT à vos dragons amis.",
|
||||||
|
"synergies": ["dragon"], "keywords": ["battlecry"], "patch_added": "17.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t4_03", "name": "Paladin Gardien", "tier": "4",
|
||||||
|
"race": ["none"], "attack": 3, "health": 3, "tavern_cost": 5,
|
||||||
|
"has_divine": True, "has_taunt": True,
|
||||||
|
"passive": "Au début de votre tour: donne Bouclier Divin à un serviteur ami aléatoire.",
|
||||||
|
"synergies": ["divine_shield"], "keywords": ["divine_shield", "taunt", "passive"], "patch_added": "17.0"},
|
||||||
|
|
||||||
|
# ── Tier 5 ──
|
||||||
|
{"card_id": "BGS_t5_01", "name": "Kangaro Boxeur", "tier": "5",
|
||||||
|
"race": ["beast"], "attack": 5, "health": 5, "tavern_cost": 6,
|
||||||
|
"battlecry": "Gagne +1 ATT par serviteur ami sur le board.",
|
||||||
|
"synergies": ["beast"], "keywords": ["battlecry"], "patch_added": "18.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t5_02", "name": "Murozond", "tier": "5",
|
||||||
|
"race": ["dragon"], "attack": 5, "health": 5, "tavern_cost": 6,
|
||||||
|
"battlecry": "Gagne les capacités de vos autres types de races sur le board.",
|
||||||
|
"synergies": ["dragon", "all"], "keywords": ["battlecry"], "patch_added": "19.0"},
|
||||||
|
|
||||||
|
# ── Tier 6 ──
|
||||||
|
{"card_id": "BGS_t6_01", "name": "Amalgame de l'Égout", "tier": "6",
|
||||||
|
"race": ["all"], "attack": 6, "health": 4, "tavern_cost": 7,
|
||||||
|
"passive": "Hérite des capacités de toutes les races de vos serviteurs amis.",
|
||||||
|
"synergies": ["all"], "keywords": ["passive"], "patch_added": "17.0"},
|
||||||
|
|
||||||
|
{"card_id": "BGS_t6_02", "name": "Zapp Brannigan", "tier": "6",
|
||||||
|
"race": ["mech"], "attack": 7, "health": 10, "tavern_cost": 7,
|
||||||
|
"passive": "Attaque toujours le serviteur ennemi avec l'ATT la plus faible.",
|
||||||
|
"synergies": ["mech"], "keywords": ["passive"], "patch_added": "15.0"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── Données Sorts ────────────────────────────────────────────────────────────
|
||||||
|
SPELLS = [
|
||||||
|
{"card_id": "SP_01", "name": "Soif de Sang", "tier": "1", "cost": 0,
|
||||||
|
"effect": "Donne +3/+1 à un serviteur ami.", "target": "minion", "keywords": ["buff"]},
|
||||||
|
{"card_id": "SP_02", "name": "Bière Frelatée", "tier": "1", "cost": 0,
|
||||||
|
"effect": "Donne +1/+1 à tous vos serviteurs.", "target": "board", "keywords": ["aoe_buff"]},
|
||||||
|
{"card_id": "SP_03", "name": "Armure Réactive", "tier": "2", "cost": 1,
|
||||||
|
"effect": "Votre héros gagne +4 armure.", "target": "hero", "keywords": ["armor"]},
|
||||||
|
{"card_id": "SP_04", "name": "Maître en Cris de Guerre", "tier": "3", "cost": 2,
|
||||||
|
"effect": "Déclenche le cri de guerre d'un serviteur ami.", "target": "minion", "keywords": ["battlecry"]},
|
||||||
|
{"card_id": "SP_05", "name": "Huile de Pierre Sacrée", "tier": "4", "cost": 2,
|
||||||
|
"effect": "Donne Bouclier Divin à tous vos serviteurs.", "target": "board", "keywords": ["divine_shield"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
print("\n🌱 Peuplement de la base de données HSBG...")
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Héros
|
||||||
|
for h in HEROES:
|
||||||
|
db.add(Hero(**h))
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✅ {len(HEROES)} héros ajoutés")
|
||||||
|
|
||||||
|
# Serviteurs
|
||||||
|
for m in MINIONS:
|
||||||
|
db.add(Minion(**m))
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✅ {len(MINIONS)} serviteurs ajoutés")
|
||||||
|
|
||||||
|
# Sorts
|
||||||
|
for s in SPELLS:
|
||||||
|
db.add(Spell(**s))
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✅ {len(SPELLS)} sorts ajoutés")
|
||||||
|
|
||||||
|
print("\n✅ Base de données HSBG prête!\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
109
hsbg_ai/backend/main.py
Normal file
109
hsbg_ai/backend/main.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""HSBG AI Assistant — Application FastAPI principale."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from backend.api.routes.advice import router as advice_router
|
||||||
|
from backend.api.routes.database_routes import router as db_router
|
||||||
|
from backend.api.routes.game import router as game_router
|
||||||
|
from backend.api.routes.learning import router as learning_router
|
||||||
|
from backend.api.routes.settings_routes import router as settings_router
|
||||||
|
from backend.api.routes.websocket_routes import router as ws_router
|
||||||
|
from backend.config.settings import get_settings
|
||||||
|
from backend.database.db import init_db
|
||||||
|
from backend.services.ai_service import AIService
|
||||||
|
from backend.services.vision_service import VisionService
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
cfg = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Cycle de vie: démarrage → running → arrêt."""
|
||||||
|
log.info("hsbg_ai.starting", version="1.0.0")
|
||||||
|
|
||||||
|
# Initialiser la base de données
|
||||||
|
await init_db()
|
||||||
|
log.info("database.ready")
|
||||||
|
|
||||||
|
# Initialiser le service IA
|
||||||
|
ai = AIService(cfg)
|
||||||
|
await ai.initialize()
|
||||||
|
app.state.ai_service = ai
|
||||||
|
|
||||||
|
# Initialiser le service Vision
|
||||||
|
vis = VisionService(cfg)
|
||||||
|
if cfg.vision_enabled:
|
||||||
|
await vis.start()
|
||||||
|
app.state.vision_service = vis
|
||||||
|
|
||||||
|
log.info("hsbg_ai.ready", port=cfg.backend_port, llm=cfg.llm_model)
|
||||||
|
yield # Application en cours d'exécution
|
||||||
|
|
||||||
|
# Arrêt propre
|
||||||
|
log.info("hsbg_ai.stopping")
|
||||||
|
await vis.stop()
|
||||||
|
await ai.shutdown()
|
||||||
|
log.info("hsbg_ai.stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="HSBG AI Assistant",
|
||||||
|
description="IA d'assistance temps réel pour Hearthstone Battlegrounds",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS pour le frontend React
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=cfg.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routes API REST
|
||||||
|
app.include_router(game_router, prefix="/api/game", tags=["Partie"])
|
||||||
|
app.include_router(advice_router, prefix="/api/advice", tags=["Conseils IA"])
|
||||||
|
app.include_router(learning_router, prefix="/api/learning", tags=["Apprentissage"])
|
||||||
|
app.include_router(db_router, prefix="/api/database", tags=["Base HSBG"])
|
||||||
|
app.include_router(settings_router, prefix="/api/settings", tags=["Paramètres"])
|
||||||
|
app.include_router(ws_router, prefix="/ws", tags=["WebSocket"])
|
||||||
|
|
||||||
|
# Servir le frontend buildé si disponible
|
||||||
|
frontend_dist = Path("frontend/dist")
|
||||||
|
if frontend_dist.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="static")
|
||||||
|
|
||||||
|
@app.get("/health", tags=["Système"])
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"llm_model": cfg.llm_model,
|
||||||
|
"patch": cfg.current_patch,
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.main:app",
|
||||||
|
host=cfg.backend_host,
|
||||||
|
port=cfg.backend_port,
|
||||||
|
reload=cfg.debug,
|
||||||
|
log_config=None,
|
||||||
|
)
|
||||||
0
hsbg_ai/backend/services/__init__.py
Normal file
0
hsbg_ai/backend/services/__init__.py
Normal file
24
hsbg_ai/backend/services/ai_service.py
Normal file
24
hsbg_ai/backend/services/ai_service.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Service IA — façade pour le moteur de décision."""
|
||||||
|
from backend.ai.engine.decision_engine import DecisionEngine, FullAdvice
|
||||||
|
from backend.ai.learning.feedback_processor import FeedbackProcessor
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class AIService:
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.engine = DecisionEngine(settings)
|
||||||
|
self.feedback = FeedbackProcessor(settings)
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
await self.engine.initialize()
|
||||||
|
log.info("ai_service.ready")
|
||||||
|
|
||||||
|
async def get_advice(self, state: dict) -> FullAdvice:
|
||||||
|
return await self.engine.get_advice(state)
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
await self.engine.shutdown()
|
||||||
|
log.info("ai_service.stopped")
|
||||||
26
hsbg_ai/backend/services/vision_service.py
Normal file
26
hsbg_ai/backend/services/vision_service.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Service Vision — façade pour le gestionnaire de captures d'écran."""
|
||||||
|
from backend.vision.screenshot_manager import ScreenshotManager
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class VisionService:
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self._mgr = ScreenshotManager(settings)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
await self._mgr.start()
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
await self._mgr.stop()
|
||||||
|
|
||||||
|
def get_current_state(self) -> dict:
|
||||||
|
return self._mgr.get_state()
|
||||||
|
|
||||||
|
def get_screenshot_b64(self) -> str | None:
|
||||||
|
return self._mgr.get_b64()
|
||||||
|
|
||||||
|
async def capture_now(self) -> dict:
|
||||||
|
return await self._mgr.capture_now()
|
||||||
0
hsbg_ai/backend/utils/__init__.py
Normal file
0
hsbg_ai/backend/utils/__init__.py
Normal file
0
hsbg_ai/backend/vision/__init__.py
Normal file
0
hsbg_ai/backend/vision/__init__.py
Normal file
195
hsbg_ai/backend/vision/screenshot_manager.py
Normal file
195
hsbg_ai/backend/vision/screenshot_manager.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Capture d'écran continue + extraction OCR de l'état HSBG."""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mss
|
||||||
|
import mss.tools
|
||||||
|
MSS_OK = True
|
||||||
|
except ImportError:
|
||||||
|
MSS_OK = False
|
||||||
|
log.warning("vision.mss_missing", install="pip install mss")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
PIL_OK = True
|
||||||
|
except ImportError:
|
||||||
|
PIL_OK = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pytesseract
|
||||||
|
OCR_OK = True
|
||||||
|
except ImportError:
|
||||||
|
OCR_OK = False
|
||||||
|
log.warning("vision.tesseract_missing",
|
||||||
|
install="sudo apt-get install tesseract-ocr && pip install pytesseract")
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotManager:
|
||||||
|
"""
|
||||||
|
Capture et analyse les frames du jeu HSBG.
|
||||||
|
|
||||||
|
Zones de capture (coordonnées relatives 0.0-1.0):
|
||||||
|
- gold: haut gauche (or disponible)
|
||||||
|
- tier: haut droite (niveau de taverne)
|
||||||
|
- hero_hp: centre haut (HP du héros)
|
||||||
|
- board: centre (board du joueur)
|
||||||
|
- tavern: bas (serviteurs en taverne)
|
||||||
|
"""
|
||||||
|
|
||||||
|
ZONES = {
|
||||||
|
"gold": (0.02, 0.02, 0.12, 0.10),
|
||||||
|
"tier": (0.85, 0.02, 0.98, 0.14),
|
||||||
|
"hero_hp": (0.44, 0.01, 0.56, 0.09),
|
||||||
|
"board": (0.10, 0.38, 0.90, 0.72),
|
||||||
|
"tavern": (0.02, 0.72, 0.98, 0.98),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self._running = False
|
||||||
|
self._latest_bytes: bytes | None = None
|
||||||
|
self._latest_state: dict = {}
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
if OCR_OK and settings.tesseract_path:
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = settings.tesseract_path
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Démarre la boucle de capture en arrière-plan."""
|
||||||
|
if not MSS_OK:
|
||||||
|
log.warning("vision.skipped", reason="mss non installé")
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
log.info("vision.capture_started", interval=self.settings.screenshot_interval)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Arrête proprement la boucle de capture."""
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
log.info("vision.capture_stopped")
|
||||||
|
|
||||||
|
async def _loop(self):
|
||||||
|
"""Boucle principale de capture."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._tick()
|
||||||
|
await asyncio.sleep(self.settings.screenshot_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("vision.loop_error", error=str(e))
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
async def _tick(self):
|
||||||
|
"""Une itération: capture + analyse."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
data = await loop.run_in_executor(None, self._grab)
|
||||||
|
if data:
|
||||||
|
self._latest_bytes = data
|
||||||
|
state = await loop.run_in_executor(None, self._extract, data)
|
||||||
|
if state:
|
||||||
|
self._latest_state = state
|
||||||
|
|
||||||
|
def _grab(self) -> bytes | None:
|
||||||
|
"""Prend une capture d'écran (exécuté dans un thread)."""
|
||||||
|
if not MSS_OK:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with mss.mss() as sct:
|
||||||
|
monitor = sct.monitors[1] # Écran principal
|
||||||
|
frame = sct.grab(monitor)
|
||||||
|
return mss.tools.to_png(frame.rgb, frame.size)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("screenshot.grab_failed", error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract(self, data: bytes) -> dict:
|
||||||
|
"""Extrait les valeurs numériques via OCR (exécuté dans un thread)."""
|
||||||
|
if not PIL_OK or not OCR_OK:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
for zone_name, coords in self.ZONES.items():
|
||||||
|
crop = self._crop(img, coords)
|
||||||
|
if crop:
|
||||||
|
text = self._ocr(crop)
|
||||||
|
if text:
|
||||||
|
state[f"raw_{zone_name}"] = text
|
||||||
|
|
||||||
|
# Parser les valeurs numériques
|
||||||
|
state["gold"] = self._parse_num(state.get("raw_gold", ""))
|
||||||
|
state["tavern_tier"] = self._parse_num(state.get("raw_tier", ""))
|
||||||
|
state["hero_hp"] = self._parse_num(state.get("raw_hero_hp", ""))
|
||||||
|
|
||||||
|
return state
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("vision.extract_failed", error=str(e))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _crop(self, img, coords: tuple):
|
||||||
|
"""Découpe une zone de l'image."""
|
||||||
|
if not PIL_OK:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
w, h = img.size
|
||||||
|
x1, y1, x2, y2 = coords
|
||||||
|
return img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h)))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ocr(self, img) -> str:
|
||||||
|
"""OCR optimisé pour HSBG (chiffres + lettres)."""
|
||||||
|
if not OCR_OK or not PIL_OK:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
# Pipeline de prétraitement pour améliorer l'OCR
|
||||||
|
enhanced = ImageEnhance.Contrast(img).enhance(2.5)
|
||||||
|
enhanced = enhanced.convert("L") # Grayscale
|
||||||
|
enhanced = enhanced.filter(ImageFilter.SHARPEN)
|
||||||
|
# Agrandir pour meilleure précision
|
||||||
|
w, h = enhanced.size
|
||||||
|
enhanced = enhanced.resize((w * 2, h * 2), Image.LANCZOS)
|
||||||
|
|
||||||
|
text = pytesseract.image_to_string(
|
||||||
|
enhanced,
|
||||||
|
config="--psm 7 --oem 3 -c tessedit_char_whitelist=0123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ",
|
||||||
|
)
|
||||||
|
return text.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _parse_num(self, text: str) -> int:
|
||||||
|
"""Extrait un nombre d'un texte OCR."""
|
||||||
|
nums = re.findall(r"\d+", text or "")
|
||||||
|
return int(nums[0]) if nums else 0
|
||||||
|
|
||||||
|
# ─── API publique ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_b64(self) -> str | None:
|
||||||
|
"""Dernière capture en base64 (pour affichage frontend)."""
|
||||||
|
if self._latest_bytes:
|
||||||
|
return base64.b64encode(self._latest_bytes).decode()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""Dernier état extrait."""
|
||||||
|
return self._latest_state.copy()
|
||||||
|
|
||||||
|
async def capture_now(self) -> dict:
|
||||||
|
"""Déclenche une capture manuelle et retourne l'état."""
|
||||||
|
await self._tick()
|
||||||
|
return self.get_state()
|
||||||
12
hsbg_ai/frontend/index.html
Normal file
12
hsbg_ai/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HSBG AI Assistant</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
hsbg_ai/frontend/package.json
Normal file
26
hsbg_ai/frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
hsbg_ai/frontend/postcss.config.js
Normal file
1
hsbg_ai/frontend/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
56
hsbg_ai/frontend/src/App.jsx
Normal file
56
hsbg_ai/frontend/src/App.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<aside className="w-14 md:w-52 bg-gray-900/95 border-r border-yellow-900/20 flex flex-col pt-4 shrink-0">
|
||||||
|
<div className="px-3 mb-6 hidden md:block">
|
||||||
|
<p className="text-yellow-400 font-bold text-lg tracking-wide">HSBG AI</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Assistant Battlegrounds</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-1 px-2 flex-1">
|
||||||
|
{NAV.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to} to={to} end={to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-all
|
||||||
|
${isActive
|
||||||
|
? 'bg-yellow-900/40 text-yellow-400 font-medium'
|
||||||
|
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/80'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={17} className="shrink-0" />
|
||||||
|
<span className="hidden md:inline">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="px-3 py-3 border-t border-gray-800 hidden md:block">
|
||||||
|
<p className="text-gray-600 text-xs">v1.0.0 · Local AI</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/game" element={<GamePage />} />
|
||||||
|
<Route path="/learning" element={<LearningPage />} />
|
||||||
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
hsbg_ai/frontend/src/index.css
Normal file
12
hsbg_ai/frontend/src/index.css
Normal file
@@ -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; }
|
||||||
13
hsbg_ai/frontend/src/main.jsx
Normal file
13
hsbg_ai/frontend/src/main.jsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
105
hsbg_ai/frontend/src/pages/DashboardPage.jsx
Normal file
105
hsbg_ai/frontend/src/pages/DashboardPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-4 hover:border-gray-700 transition-colors">
|
||||||
|
<div className={`p-2.5 rounded-xl ${color}`}><Icon size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-xs mb-0.5">{label}</p>
|
||||||
|
<p className="text-white font-bold text-xl leading-none">{value}</p>
|
||||||
|
{sub && <p className="text-gray-500 text-xs mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-yellow-400 mb-1">HSBG AI Assistant</h1>
|
||||||
|
<p className="text-gray-400">Intelligence artificielle locale pour Hearthstone Battlegrounds</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status serveur */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
{health ? (
|
||||||
|
<span className="flex items-center gap-2 bg-green-900/30 border border-green-700/50 rounded-full px-4 py-1.5 text-green-400 text-sm">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
Serveur actif · v{health.version} · Modèle: {health.llm_model}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 bg-red-900/30 border border-red-700/50 rounded-full px-4 py-1.5 text-red-400 text-sm">
|
||||||
|
<XCircle size={14} />
|
||||||
|
Serveur hors ligne
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{!loading && stats && (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard icon={Brain} label="Feedbacks total" value={stats.total}
|
||||||
|
color="bg-purple-900/50 text-purple-400" />
|
||||||
|
<StatCard icon={Activity} label="Taux de réussite" value={`${stats.good_rate}%`}
|
||||||
|
sub={`${stats.good} bons / ${stats.bad} mauvais`} color="bg-green-900/50 text-green-400" />
|
||||||
|
<StatCard icon={Zap} label="Entraînements" value={stats.trained}
|
||||||
|
color="bg-blue-900/50 text-blue-400" />
|
||||||
|
<StatCard icon={Target} label="En attente" value={stats.buffer_pending}
|
||||||
|
sub="dans le buffer" color="bg-yellow-900/50 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Guide rapide */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-4">🚀 Démarrage rapide</h3>
|
||||||
|
<ol className="space-y-2.5 text-sm text-gray-300">
|
||||||
|
{[
|
||||||
|
['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) => (
|
||||||
|
<li key={i} className="flex gap-2">
|
||||||
|
<span className="text-yellow-400 font-bold shrink-0">{i + 1}.</span>
|
||||||
|
<span><strong className="text-yellow-300">{page}</strong> — {desc}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-4">🧠 Configuration LLM</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-gray-300 font-mono text-xs mb-1"># Installer Ollama</p>
|
||||||
|
<p className="text-yellow-300 font-mono text-xs">curl -fsSL https://ollama.ai/install.sh | sh</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-gray-300 font-mono text-xs mb-1"># Télécharger un modèle</p>
|
||||||
|
<p className="text-yellow-300 font-mono text-xs">ollama pull llama3.2</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-xs">
|
||||||
|
Sans LLM, l'IA fonctionne en mode heuristique (rapide, pas de raisonnement naturel).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
hsbg_ai/frontend/src/pages/DatabasePage.jsx
Normal file
142
hsbg_ai/frontend/src/pages/DatabasePage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Database size={24} className="text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Base de données HSBG</h2>
|
||||||
|
<p className="text-gray-400 text-sm">Héros, serviteurs et sorts du patch {import.meta.env?.VITE_PATCH || 'actuel'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{[{ id: 'heroes', icon: Shield, label: 'Héros' }, { id: 'minions', icon: Sword, label: 'Serviteurs' }].map(({ id, icon: Icon, label }) => (
|
||||||
|
<button key={id} onClick={() => { setTab(id); setSearch(''); setTier('') }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors
|
||||||
|
${tab === id ? 'bg-blue-700 text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'}`}>
|
||||||
|
<Icon size={15} />{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-5">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input value={search} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
{tab === 'minions' && (
|
||||||
|
<select value={tier} onChange={e => setTier(e.target.value)}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-600">
|
||||||
|
<option value="">Tous tiers</option>
|
||||||
|
{[1,2,3,4,5,6].map(t => <option key={t} value={t}>Tier {t}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-400 text-center py-10">Chargement...</p>
|
||||||
|
) : tab === 'heroes' ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{data.map(h => (
|
||||||
|
<div key={h.id} className="bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-yellow-800/40 transition-colors">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<h3 className="font-bold text-yellow-400">{h.name}</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-yellow-500 text-sm font-bold">{h.tier_rating}</span>
|
||||||
|
<span className="text-gray-600 text-xs">/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-xs mb-2 leading-relaxed">{h.hero_power}</p>
|
||||||
|
{h.description && <p className="text-gray-500 text-xs mb-3">{h.description}</p>}
|
||||||
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
|
{(h.strengths || []).map(s => (
|
||||||
|
<span key={s} className="text-xs bg-green-900/40 text-green-400 border border-green-800/30 px-1.5 py-0.5 rounded">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(h.weaknesses || []).map(w => (
|
||||||
|
<span key={w} className="text-xs bg-red-900/30 text-red-400 border border-red-800/30 px-1.5 py-0.5 rounded">{w}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-800 text-xs text-gray-400 font-medium">
|
||||||
|
{['Nom', 'Tier', 'Race', 'ATT', 'HP', 'Capacités', 'Description'].map(h => (
|
||||||
|
<th key={h} className="px-3 py-2.5 text-left whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{data.map(m => (
|
||||||
|
<tr key={m.id} className="hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="px-3 py-2.5 font-semibold text-white whitespace-nowrap">{m.name}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-medium text-white ${TIER_COLORS[m.tier] || 'bg-gray-600'}`}>
|
||||||
|
T{m.tier}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-blue-400 text-xs whitespace-nowrap">
|
||||||
|
{Array.isArray(m.race) ? m.race.join(', ') : m.race}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-green-400 font-bold text-center">{m.attack}</td>
|
||||||
|
<td className="px-3 py-2.5 text-red-400 font-bold text-center">{m.health}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.has_divine && <span className="text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-800/30 px-1.5 py-0.5 rounded">Divine</span>}
|
||||||
|
{m.has_taunt && <span className="text-xs bg-gray-700 text-gray-300 px-1.5 py-0.5 rounded">Taunt</span>}
|
||||||
|
{m.has_windfury && <span className="text-xs bg-purple-900/40 text-purple-300 px-1.5 py-0.5 rounded">Windfury</span>}
|
||||||
|
{m.has_poisonous && <span className="text-xs bg-green-900/40 text-green-300 px-1.5 py-0.5 rounded">Venin</span>}
|
||||||
|
{m.has_reborn && <span className="text-xs bg-blue-900/40 text-blue-300 px-1.5 py-0.5 rounded">Reborn</span>}
|
||||||
|
{m.battlecry && <span className="text-xs bg-purple-900/30 text-purple-400 px-1.5 py-0.5 rounded">Cri</span>}
|
||||||
|
{m.deathrattle && <span className="text-xs bg-red-900/30 text-red-400 px-1.5 py-0.5 rounded">Mort</span>}
|
||||||
|
{m.passive && <span className="text-xs bg-gray-700 text-gray-400 px-1.5 py-0.5 rounded">Passif</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-gray-400 text-xs max-w-[200px]">
|
||||||
|
{m.battlecry || m.deathrattle || m.passive || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{data.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-center py-8">Aucun résultat — vérifiez la base de données</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
406
hsbg_ai/frontend/src/pages/GamePage.jsx
Normal file
406
hsbg_ai/frontend/src/pages/GamePage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||||
|
<span>Confiance</span><span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${col} transition-all duration-500`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MinionChip({ minion, onRemove }) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-gray-800 border border-gray-600 rounded-lg p-2 text-xs group">
|
||||||
|
{onRemove && (
|
||||||
|
<button onClick={onRemove}
|
||||||
|
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="font-semibold text-yellow-300 truncate max-w-[90px]">{minion.name || '?'}</p>
|
||||||
|
<p className="text-gray-300">{minion.attack || 0}/{minion.health || 0}</p>
|
||||||
|
{minion.race && (
|
||||||
|
<p className="text-blue-400 text-[10px]">
|
||||||
|
{Array.isArray(minion.race) ? minion.race.join(', ') : minion.race}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddMinionButton({ onClick }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick}
|
||||||
|
className="border border-dashed border-gray-600 rounded-lg p-2 text-xs text-gray-500 hover:border-yellow-600 hover:text-yellow-500 transition-colors flex flex-col items-center justify-center min-h-[64px]">
|
||||||
|
<Plus size={14} className="mb-1" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-green-900/20 border border-green-700/40 rounded-xl p-3 text-green-400 text-sm text-center">
|
||||||
|
✅ Feedback envoyé — merci!
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-3">Ce conseil était-il bon?</p>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button onClick={() => send('good')} className="flex-1 flex items-center justify-center gap-1.5 bg-green-700 hover:bg-green-600 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<ThumbsUp size={13} /> Bon
|
||||||
|
</button>
|
||||||
|
<button onClick={() => send('neutral')} className="flex-1 flex items-center justify-center gap-1.5 bg-gray-700 hover:bg-gray-600 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<Minus size={13} /> Neutre
|
||||||
|
</button>
|
||||||
|
<button onClick={() => send('bad')} className="flex-1 flex items-center justify-center gap-1.5 bg-red-700 hover:bg-red-600 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<ThumbsDown size={13} /> Mauvais
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input value={better} onChange={e => 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" />
|
||||||
|
<input value={comment} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-4 lg:p-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-yellow-400">Partie en cours</h2>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
{session ? `Session #${session.session_id}` : 'Aucune session active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!session ? (
|
||||||
|
<button onClick={startSession}
|
||||||
|
className="flex items-center gap-2 bg-green-600 hover:bg-green-500 px-4 py-2 rounded-xl text-sm font-semibold transition-colors">
|
||||||
|
<Play size={15} /> Démarrer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={endSession}
|
||||||
|
className="flex items-center gap-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold transition-colors">
|
||||||
|
<Square size={15} /> Terminer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
|
||||||
|
{/* Colonne gauche: état du jeu */}
|
||||||
|
<div className="xl:col-span-2 space-y-4">
|
||||||
|
{/* Stats numériques */}
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-300 mb-4">État du tour</h3>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-xs text-gray-500 block mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
type="number" min={min} max={max}
|
||||||
|
value={state[key]}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 mt-3 pt-3 border-t border-gray-800">
|
||||||
|
{[['freeze', '❄️ Gel actif'], ['can_upgrade', '⬆️ Peut monter']].map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={state[key]}
|
||||||
|
onChange={e => setField(key, e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-yellow-500" />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board */}
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-300">
|
||||||
|
Board <span className="text-gray-500">({state.board_minions.length}/7)</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
|
||||||
|
{state.board_minions.map((m, i) => (
|
||||||
|
<MinionChip key={i} minion={m} onRemove={() => removeMinion('board_minions', i)} />
|
||||||
|
))}
|
||||||
|
{state.board_minions.length < 7 && (
|
||||||
|
<AddMinionButton onClick={() => addMinion('board_minions')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taverne */}
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-300">
|
||||||
|
Taverne <span className="text-gray-500">({state.tavern_minions.length})</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
|
||||||
|
{state.tavern_minions.map((m, i) => (
|
||||||
|
<MinionChip key={i} minion={m} onRemove={() => removeMinion('tavern_minions', i)} />
|
||||||
|
))}
|
||||||
|
{state.tavern_minions.length < 7 && (
|
||||||
|
<AddMinionButton onClick={() => addMinion('tavern_minions')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={getAdvice} disabled={loading}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-yellow-600 hover:bg-yellow-500 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-3 rounded-xl font-semibold transition-colors">
|
||||||
|
{loading
|
||||||
|
? <><RefreshCw size={16} className="animate-spin" /> Analyse...</>
|
||||||
|
: <><Swords size={16} /> Obtenir conseil</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button onClick={captureScreen} disabled={loading}
|
||||||
|
title="Capturer l'écran automatiquement"
|
||||||
|
className="flex items-center gap-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 px-4 py-3 rounded-xl text-sm font-medium transition-colors">
|
||||||
|
<Monitor size={16} /> Capturer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colonne droite: conseils */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{advice ? (
|
||||||
|
<>
|
||||||
|
{/* Conseil principal */}
|
||||||
|
<div className="bg-gray-900 border border-yellow-800/40 rounded-xl p-5 card-glow">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-xs text-gray-400 uppercase tracking-wider">Conseil principal</span>
|
||||||
|
<span className="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded">{advice.model_used}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold mt-2 ${actionInfo.color}`}>
|
||||||
|
{actionInfo.label}
|
||||||
|
</p>
|
||||||
|
{mainAction?.target && (
|
||||||
|
<p className="text-yellow-300 text-sm mt-1">
|
||||||
|
→ {mainAction.target?.name || JSON.stringify(mainAction.target)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ConfBar value={mainAction?.confidence} />
|
||||||
|
<p className="text-gray-200 text-sm mt-3 leading-relaxed">{mainAction?.reasoning}</p>
|
||||||
|
{mainAction?.warnings?.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{mainAction.warnings.map((w, i) => (
|
||||||
|
<p key={i} className="text-yellow-400 text-xs">⚠️ {w}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mainAction?.synergies?.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{mainAction.synergies.map(s => (
|
||||||
|
<span key={s} className="text-xs bg-blue-900/40 text-blue-400 border border-blue-800/30 px-2 py-0.5 rounded">
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analyse du board */}
|
||||||
|
{advice.board_analysis && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2">Analyse</p>
|
||||||
|
<p className="text-gray-200 text-sm leading-relaxed">{advice.board_analysis}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stratégie LLM */}
|
||||||
|
{advice.strategy_long_term && (
|
||||||
|
<div className="bg-gray-900 border border-blue-800/30 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-blue-400 uppercase tracking-wider mb-2">Stratégie</p>
|
||||||
|
<p className="text-gray-200 text-sm">{advice.strategy_long_term}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menaces */}
|
||||||
|
{advice.threat_assessment && (
|
||||||
|
<div className="bg-gray-900 border border-red-900/30 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-red-400 uppercase tracking-wider mb-2">Menaces</p>
|
||||||
|
<p className="text-gray-200 text-sm">{advice.threat_assessment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alternatives */}
|
||||||
|
{advice.secondary_decisions?.length > 0 && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2">Alternatives</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{advice.secondary_decisions.slice(0, 4).map((d, i) => {
|
||||||
|
const info = ACTION_MAP[d.action] || { label: d.action, color: 'text-gray-400' }
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center justify-between py-1.5 border-b border-gray-800 last:border-0">
|
||||||
|
<span className={`text-sm font-medium ${info.color}`}>{info.label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-500 text-xs truncate max-w-[120px]">{d.reasoning?.slice(0, 40)}...</span>
|
||||||
|
<span className="text-gray-600 text-xs">{Math.round(d.confidence * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{advice.decision_id && (
|
||||||
|
<FeedbackBar decisionId={advice.decision_id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<p className="text-gray-700 text-xs text-center">{advice.processing_ms}ms de traitement</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-8 text-center">
|
||||||
|
<Swords size={40} className="mx-auto mb-4 text-gray-700" />
|
||||||
|
<p className="text-gray-400 font-medium mb-2">Prêt à analyser</p>
|
||||||
|
<p className="text-gray-600 text-sm">Renseignez l'état du jeu puis cliquez sur "Obtenir conseil"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshot preview */}
|
||||||
|
{screenshot && (
|
||||||
|
<div className="mt-4 bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<p className="text-sm text-gray-400">Capture d'écran</p>
|
||||||
|
<button onClick={() => setScreenshot(null)} className="text-gray-600 hover:text-gray-400">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<img src={`data:image/png;base64,${screenshot}`} className="max-h-48 rounded-lg" alt="capture" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
hsbg_ai/frontend/src/pages/LearningPage.jsx
Normal file
163
hsbg_ai/frontend/src/pages/LearningPage.jsx
Normal file
@@ -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: <ThumbsUp size={13} className="text-green-400" />,
|
||||||
|
0: <Minus size={13} className="text-gray-400" />,
|
||||||
|
'-1': <ThumbsDown size={13} className="text-red-400" />,
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Brain size={24} className="text-purple-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Mode Apprentissage</h2>
|
||||||
|
<p className="text-gray-400 text-sm">Évaluez les décisions IA pour améliorer le modèle</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={load} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm transition-colors">
|
||||||
|
<RefreshCw size={14} /> Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl p-4 text-center">
|
||||||
|
<p className={`text-2xl font-bold ${color}`}>{val}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-1">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Barre de progression good rate */}
|
||||||
|
{stats && stats.total > 0 && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-300 flex items-center gap-1.5"><BarChart2 size={14} /> Qualité des conseils</span>
|
||||||
|
<span className="text-gray-400">{stats.good_rate}% de réussite</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gradient-to-r from-green-600 to-green-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${stats.good_rate}%` }} />
|
||||||
|
</div>
|
||||||
|
{stats.buffer_pending > 0 && (
|
||||||
|
<p className="text-yellow-400 text-xs mt-2">⚡ {stats.buffer_pending} feedbacks en attente d'export</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Historique */}
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-800">
|
||||||
|
<h3 className="font-semibold text-white">Historique des décisions</h3>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Cliquez 👍/👎 pour noter les conseils non évalués</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-400 text-center py-8">Chargement...</p>
|
||||||
|
) : decisions.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Brain size={32} className="mx-auto mb-3 text-gray-700" />
|
||||||
|
<p className="text-gray-500">Aucune décision enregistrée</p>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">Lancez une partie et demandez des conseils!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{decisions.map(d => {
|
||||||
|
const action = d.recommendation?.main_decision?.action
|
||||||
|
return (
|
||||||
|
<div key={d.id} className="px-4 py-3 hover:bg-gray-800/30 transition-colors">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`font-semibold text-sm ${ACTION_COLORS[action] || 'text-white'}`}>
|
||||||
|
{action?.toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 text-xs">Tour {d.turn}</span>
|
||||||
|
<span className="text-gray-700 text-xs">{d.phase}</span>
|
||||||
|
<span className="text-gray-700 text-xs bg-gray-800 px-1.5 py-0.5 rounded">{d.model_used}</span>
|
||||||
|
<span className="text-gray-700 text-xs">{d.processing_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||||
|
{d.outcome_rating !== null && d.outcome_rating !== undefined
|
||||||
|
? RATING_ICONS[String(d.outcome_rating)]
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<button onClick={() => sendFeedback(d.id, 'good')}
|
||||||
|
className="p-1 hover:text-green-400 text-gray-600 transition-colors" title="Bon conseil">
|
||||||
|
<ThumbsUp size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => sendFeedback(d.id, 'neutral')}
|
||||||
|
className="p-1 hover:text-gray-300 text-gray-600 transition-colors" title="Neutre">
|
||||||
|
<Minus size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => sendFeedback(d.id, 'bad')}
|
||||||
|
className="p-1 hover:text-red-400 text-gray-600 transition-colors" title="Mauvais conseil">
|
||||||
|
<ThumbsDown size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-xs leading-relaxed">{d.reasoning}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<div className="flex-1 h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-blue-600 rounded-full"
|
||||||
|
style={{ width: `${Math.round(d.confidence * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600 text-xs">{Math.round(d.confidence * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
{d.user_feedback && (
|
||||||
|
<p className="text-gray-500 text-xs mt-1 italic">💬 {d.user_feedback}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
hsbg_ai/frontend/src/pages/SettingsPage.jsx
Normal file
114
hsbg_ai/frontend/src/pages/SettingsPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4 pb-3 border-b border-gray-800">
|
||||||
|
<Icon size={16} className="text-gray-400" />
|
||||||
|
<h3 className="font-semibold text-white">{title}</h3>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, mono = false, highlight = false }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-gray-800 last:border-0">
|
||||||
|
<span className="text-gray-400 text-sm">{label}</span>
|
||||||
|
<span className={`text-sm font-medium max-w-[60%] text-right truncate
|
||||||
|
${mono ? 'font-mono text-yellow-300' : highlight ? 'text-green-400' : 'text-white'}`}>
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [cfg, setCfg] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get('/api/settings/').then(r => setCfg(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-6 text-gray-400">Chargement...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Settings size={24} className="text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Paramètres</h2>
|
||||||
|
<p className="text-gray-400 text-sm">Configuration de l'IA et des services</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-xl p-4 mb-6 flex gap-3">
|
||||||
|
<AlertCircle size={16} className="text-yellow-400 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-yellow-300 text-sm">
|
||||||
|
Pour modifier ces paramètres, éditez <code className="bg-gray-800 px-1 rounded">.env</code> puis redémarrez le backend avec <code className="bg-gray-800 px-1 rounded">bash start_backend.sh</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section title="LLM Local (Ollama)" icon={Cpu}>
|
||||||
|
<Row label="Fournisseur" value={cfg.llm_provider} mono />
|
||||||
|
<Row label="Modèle actif" value={cfg.llm_model} mono />
|
||||||
|
<Row label="URL Ollama" value={cfg.llm_base_url} mono />
|
||||||
|
<Row label="Température" value={cfg.llm_temperature} />
|
||||||
|
<Row label="Tokens max" value={cfg.llm_max_tokens} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Vision & OCR" icon={Eye}>
|
||||||
|
<Row label="Activée" value={cfg.vision_enabled ? '✅ Oui' : '❌ Non'}
|
||||||
|
highlight={cfg.vision_enabled} />
|
||||||
|
<Row label="Intervalle de capture" value={`${cfg.screenshot_interval}s`} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Apprentissage" icon={Brain}>
|
||||||
|
<Row label="Activé" value={cfg.learning_enabled ? '✅ Oui' : '❌ Non'}
|
||||||
|
highlight={cfg.learning_enabled} />
|
||||||
|
<Row label="Taux d'apprentissage" value={cfg.learning_rate} />
|
||||||
|
<Row label="Taille du batch" value={cfg.learning_batch_size} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Système" icon={Settings}>
|
||||||
|
<Row label="Patch HSBG" value={cfg.current_patch} />
|
||||||
|
<Row label="Mode debug" value={cfg.debug ? 'Oui' : 'Non'} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guides d'installation */}
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="bg-blue-900/20 border border-blue-800/40 rounded-xl p-5">
|
||||||
|
<h4 className="font-semibold text-blue-300 mb-3">🧠 Installer Ollama (LLM local)</h4>
|
||||||
|
<div className="space-y-2 font-mono text-xs">
|
||||||
|
{[
|
||||||
|
['# 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) => (
|
||||||
|
<p key={i} className={`${color || 'text-gray-500'}`}>{cmd}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-green-900/20 border border-green-800/40 rounded-xl p-5">
|
||||||
|
<h4 className="font-semibold text-green-300 mb-3">👁️ Activer la vision OCR</h4>
|
||||||
|
<div className="space-y-2 font-mono text-xs">
|
||||||
|
<p className="text-gray-500"># Installer Tesseract OCR</p>
|
||||||
|
<p className="text-yellow-300">sudo apt-get install tesseract-ocr tesseract-ocr-fra</p>
|
||||||
|
<p className="text-gray-500"># Puis dans .env:</p>
|
||||||
|
<p className="text-yellow-300">VISION_ENABLED=true</p>
|
||||||
|
<p className="text-yellow-300">TESSERACT_PATH=/usr/bin/tesseract</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
hsbg_ai/frontend/tailwind.config.js
Normal file
11
hsbg_ai/frontend/tailwind.config.js
Normal file
@@ -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: []
|
||||||
|
}
|
||||||
12
hsbg_ai/frontend/vite.config.js
Normal file
12
hsbg_ai/frontend/vite.config.js
Normal file
@@ -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' }
|
||||||
|
})
|
||||||
30
hsbg_ai/requirements.txt
Normal file
30
hsbg_ai/requirements.txt
Normal file
@@ -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
|
||||||
6
hsbg_ai/seed_db.sh
Normal file
6
hsbg_ai/seed_db.sh
Normal file
@@ -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
|
||||||
17
hsbg_ai/start_backend.sh
Normal file
17
hsbg_ai/start_backend.sh
Normal file
@@ -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
|
||||||
11
hsbg_ai/start_frontend.sh
Normal file
11
hsbg_ai/start_frontend.sh
Normal file
@@ -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
|
||||||
139
init_project.sh
Normal file
139
init_project.sh
Normal file
@@ -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 ""
|
||||||
Reference in New Issue
Block a user