Initial commit

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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