Initial commit
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user