check point
This commit is contained in:
513
api.py
Normal file
513
api.py
Normal file
@@ -0,0 +1,513 @@
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from src import (
|
||||
GameCore, get_registry, reload_games,
|
||||
get_active_game_types, get_active_type_ids,
|
||||
get_game_by_id, id_to_type, type_to_id,
|
||||
ModelConfig
|
||||
)
|
||||
|
||||
|
||||
# ============== APP ==============
|
||||
app = FastAPI(
|
||||
title="Game Generator API",
|
||||
description="API tạo game giáo dục từ văn bản",
|
||||
version="2.0.0"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============== REQUEST/RESPONSE MODELS ==============
|
||||
|
||||
class LLMConfigRequest(BaseModel):
|
||||
provider: str = Field(default="gemini", description="ollama, gemini, openai")
|
||||
model_name: str = Field(default="gemini-2.0-flash-lite")
|
||||
api_key: Optional[str] = Field(default=None, description="API key (None = lấy từ env)")
|
||||
temperature: float = Field(default=0.1)
|
||||
base_url: Optional[str] = Field(default=None, description="Base URL cho Ollama")
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
text: str = Field(description="Input text", min_length=10)
|
||||
enabled_game_ids: Optional[List[int]] = Field(default=None, description="List of type_ids (1=quiz, 2=sequence_sentence, 3=sequence_word)")
|
||||
run_analyzer: bool = Field(default=True)
|
||||
run_validator: bool = Field(default=True)
|
||||
max_items: Optional[int] = Field(default=3)
|
||||
min_score: int = Field(default=50, description="Minimum score (0-100) for analyzer to include a game")
|
||||
debug: bool = Field(default=False, description="Print prompts to server log")
|
||||
|
||||
# LLM config (optional - override global)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None, description="Override LLM config")
|
||||
|
||||
|
||||
class TokenUsageResponse(BaseModel):
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class GameScoreInfo(BaseModel):
|
||||
type_id: int
|
||||
score: int
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class GameResultData(BaseModel):
|
||||
"""Structure thống nhất cho mỗi game result"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class CommonMetadataResponse(BaseModel):
|
||||
"""Metadata chung cho toàn bộ kết quả generate"""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
grade: int = 0
|
||||
difficulty: int = 0
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
success: bool
|
||||
games: List[int] # type_ids
|
||||
game_scores: List[GameScoreInfo] = []
|
||||
metadata: Optional[CommonMetadataResponse] = None # Metadata chung từ analyzer
|
||||
results: Dict[int, GameResultData] # keyed by type_id, value is {items, metadata}
|
||||
llm: Optional[str] = None
|
||||
api_calls: Optional[int] = None
|
||||
token_usage: Optional[TokenUsageResponse] = None
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
class GameInfo(BaseModel):
|
||||
type_id: int
|
||||
game_type: str # Keep for reference
|
||||
display_name: str
|
||||
description: str
|
||||
active: bool
|
||||
max_items: int
|
||||
|
||||
|
||||
class GamesListResponse(BaseModel):
|
||||
total: int
|
||||
active_count: int
|
||||
games: List[GameInfo]
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
game_type: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class LLMConfigResponse(BaseModel):
|
||||
provider: str
|
||||
model_name: str
|
||||
temperature: float
|
||||
base_url: Optional[str] = None
|
||||
|
||||
|
||||
# ============== GLOBAL ==============
|
||||
_core: Optional[GameCore] = None
|
||||
_current_config: Optional[ModelConfig] = None
|
||||
|
||||
|
||||
def get_core(config_override: Optional[LLMConfigRequest] = None) -> GameCore:
|
||||
"""Get or create GameCore with optional config override"""
|
||||
global _core, _current_config
|
||||
|
||||
if config_override:
|
||||
# Create new core with override config
|
||||
config = ModelConfig(
|
||||
provider=config_override.provider,
|
||||
model_name=config_override.model_name,
|
||||
api_key=config_override.api_key,
|
||||
temperature=config_override.temperature,
|
||||
base_url=config_override.base_url
|
||||
)
|
||||
return GameCore(llm_config=config)
|
||||
|
||||
if _core is None:
|
||||
# Default: tự detect từ env
|
||||
_core = GameCore()
|
||||
_current_config = _core.llm_config
|
||||
|
||||
return _core
|
||||
|
||||
|
||||
# ============== ENDPOINTS ==============
|
||||
|
||||
@app.post("/generate", response_model=GenerateResponse)
|
||||
async def generate_games(request: GenerateRequest):
|
||||
"""Generate games from text with scoring"""
|
||||
try:
|
||||
core = get_core(request.llm_config)
|
||||
|
||||
# Convert type_ids to game_types
|
||||
if request.enabled_game_ids:
|
||||
games = [id_to_type(tid) for tid in request.enabled_game_ids if id_to_type(tid)]
|
||||
else:
|
||||
games = get_active_game_types()
|
||||
|
||||
result = core.run_multi(
|
||||
text=request.text,
|
||||
enabled_games=games,
|
||||
max_items=request.max_items or 3,
|
||||
min_score=request.min_score,
|
||||
validate=request.run_validator,
|
||||
debug=request.debug
|
||||
)
|
||||
|
||||
# Convert game_types to type_ids in response
|
||||
game_ids = [type_to_id(g) for g in result.get("games", [])]
|
||||
|
||||
# Convert game_scores
|
||||
game_scores = []
|
||||
for s in result.get("game_scores", []):
|
||||
game_scores.append(GameScoreInfo(
|
||||
type_id=type_to_id(s.get("type", "")),
|
||||
score=s.get("score", 0),
|
||||
reason=s.get("reason", "")
|
||||
))
|
||||
|
||||
# Convert results keys to type_ids
|
||||
results_by_id = {}
|
||||
for game_type, items in result.get("results", {}).items():
|
||||
tid = type_to_id(game_type)
|
||||
if tid > 0:
|
||||
results_by_id[tid] = items
|
||||
|
||||
# Get common metadata from analyzer
|
||||
core_meta = result.get("metadata", {})
|
||||
common_metadata = CommonMetadataResponse(
|
||||
title=core_meta.get("title", ""),
|
||||
description=core_meta.get("description", ""),
|
||||
grade=core_meta.get("grade", 0),
|
||||
difficulty=core_meta.get("difficulty", 0)
|
||||
) if core_meta else None
|
||||
|
||||
return GenerateResponse(
|
||||
success=result.get("success", False),
|
||||
games=game_ids,
|
||||
game_scores=game_scores,
|
||||
metadata=common_metadata,
|
||||
results=results_by_id,
|
||||
llm=result.get("llm"),
|
||||
token_usage=result.get("token_usage"),
|
||||
errors=result.get("errors", [])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
games=[],
|
||||
game_scores=[],
|
||||
results={},
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
# ============== SINGLE BEST (1 PROMPT) ==============
|
||||
|
||||
class SingleGenerateRequest(BaseModel):
|
||||
text: str = Field(description="Input text", min_length=10)
|
||||
enabled_game_ids: Optional[List[int]] = Field(default=None, description="Limit type_ids to choose from")
|
||||
max_items: int = Field(default=3, description="Max items to generate")
|
||||
run_validator: bool = Field(default=True)
|
||||
debug: bool = Field(default=False)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None)
|
||||
|
||||
|
||||
class SingleGenerateResponse(BaseModel):
|
||||
success: bool
|
||||
type_id: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
items: List[Dict[str, Any]] = []
|
||||
token_usage: Optional[TokenUsageResponse] = None
|
||||
llm: Optional[str] = None
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
@app.post("/generate/single", response_model=SingleGenerateResponse)
|
||||
async def generate_single_game(request: SingleGenerateRequest):
|
||||
"""
|
||||
Generate 1 game phù hợp nhất trong 1 prompt duy nhất.
|
||||
|
||||
- Analyze text để chọn game type tốt nhất
|
||||
- Generate items cho game đó
|
||||
- Tất cả trong 1 API call
|
||||
"""
|
||||
try:
|
||||
core = get_core(request.llm_config)
|
||||
|
||||
# Convert type_ids to game_types
|
||||
if request.enabled_game_ids:
|
||||
games = [id_to_type(tid) for tid in request.enabled_game_ids if id_to_type(tid)]
|
||||
else:
|
||||
games = None
|
||||
|
||||
result = core.run_single(
|
||||
text=request.text,
|
||||
enabled_games=games,
|
||||
max_items=request.max_items,
|
||||
debug=request.debug,
|
||||
validate=request.run_validator
|
||||
)
|
||||
|
||||
# Convert game_type to type_id
|
||||
game_type = result.get("game_type")
|
||||
tid = type_to_id(game_type) if game_type else None
|
||||
|
||||
return SingleGenerateResponse(
|
||||
success=result.get("success", False),
|
||||
type_id=tid,
|
||||
reason=result.get("reason"),
|
||||
items=result.get("items", []),
|
||||
token_usage=result.get("token_usage"),
|
||||
llm=result.get("llm"),
|
||||
errors=result.get("errors", [])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SingleGenerateResponse(
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
# ============== DIRECT GENERATE (1 game cụ thể, không analyze) ==============
|
||||
|
||||
class DirectGenerateRequest(BaseModel):
|
||||
text: str = Field(description="Input text", min_length=10)
|
||||
max_items: int = Field(default=3, description="Max items to generate")
|
||||
run_validator: bool = Field(default=True)
|
||||
debug: bool = Field(default=False)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None)
|
||||
|
||||
|
||||
class DirectGenerateResponse(BaseModel):
|
||||
"""Response thống nhất, giống GenerateResponse nhưng cho 1 game"""
|
||||
success: bool
|
||||
games: List[int] = [] # Single type_id in list
|
||||
results: Dict[int, GameResultData] = {} # Same structure as GenerateResponse
|
||||
is_format_error: bool = False
|
||||
format_error: Optional[str] = None
|
||||
token_usage: Optional[TokenUsageResponse] = None
|
||||
llm: Optional[str] = None
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
@app.post("/generate/{type_id}", response_model=DirectGenerateResponse)
|
||||
async def generate_direct(type_id: int, request: DirectGenerateRequest):
|
||||
"""
|
||||
Generate 1 game cụ thể, KHÔNG analyze.
|
||||
Response format giống với /generate nhưng chỉ có 1 game.
|
||||
"""
|
||||
try:
|
||||
# Get game by type_id
|
||||
game_type = id_to_type(type_id)
|
||||
if not game_type:
|
||||
return DirectGenerateResponse(
|
||||
success=False,
|
||||
games=[type_id],
|
||||
errors=[f"Game with type_id={type_id} not found"]
|
||||
)
|
||||
|
||||
core = get_core(request.llm_config)
|
||||
|
||||
result = core.generate(
|
||||
game_type=game_type,
|
||||
text=request.text,
|
||||
max_items=request.max_items,
|
||||
validate=request.run_validator,
|
||||
debug=request.debug
|
||||
)
|
||||
|
||||
format_error = result.get("format_error")
|
||||
data = result.get("data") or {}
|
||||
|
||||
# Build results với structure thống nhất
|
||||
game_result = GameResultData(
|
||||
items=data.get("items", []) if isinstance(data, dict) else [],
|
||||
metadata=data.get("metadata") if isinstance(data, dict) else None
|
||||
)
|
||||
|
||||
return DirectGenerateResponse(
|
||||
success=result.get("success", False),
|
||||
games=[type_id],
|
||||
results={type_id: game_result},
|
||||
is_format_error=format_error is not None,
|
||||
format_error=format_error,
|
||||
token_usage=result.get("token_usage"),
|
||||
llm=result.get("llm"),
|
||||
errors=result.get("errors", [])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DirectGenerateResponse(
|
||||
success=False,
|
||||
games=[type_id],
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/games", response_model=GamesListResponse)
|
||||
async def list_games():
|
||||
"""Lấy danh sách games"""
|
||||
registry = get_registry()
|
||||
all_games = registry.get_all_games_including_inactive()
|
||||
|
||||
games_list = []
|
||||
active_count = 0
|
||||
|
||||
for game_type, game in all_games.items():
|
||||
games_list.append(GameInfo(
|
||||
type_id=game.type_id,
|
||||
game_type=game.game_type,
|
||||
display_name=game.display_name,
|
||||
description=game.description,
|
||||
active=game.active,
|
||||
max_items=game.max_items,
|
||||
))
|
||||
if game.active:
|
||||
active_count += 1
|
||||
|
||||
# Sort by type_id
|
||||
games_list.sort(key=lambda g: g.type_id)
|
||||
|
||||
return GamesListResponse(
|
||||
total=len(games_list),
|
||||
active_count=active_count,
|
||||
games=games_list
|
||||
)
|
||||
|
||||
|
||||
@app.post("/games/{game_type}/activate", response_model=ActionResponse)
|
||||
async def activate_game(game_type: str):
|
||||
"""Bật game"""
|
||||
return _set_game_active(game_type, True)
|
||||
|
||||
|
||||
@app.post("/games/{game_type}/deactivate", response_model=ActionResponse)
|
||||
async def deactivate_game(game_type: str):
|
||||
"""Tắt game"""
|
||||
return _set_game_active(game_type, False)
|
||||
|
||||
|
||||
def _set_game_active(game_type: str, active: bool) -> ActionResponse:
|
||||
games_dir = Path(__file__).parent / "src" / "games"
|
||||
game_file = games_dir / f"{game_type}.py"
|
||||
|
||||
if not game_file.exists():
|
||||
raise HTTPException(404, f"Game '{game_type}' not found")
|
||||
|
||||
content = game_file.read_text(encoding="utf-8")
|
||||
pattern = r'("active"\s*:\s*)(True|False)'
|
||||
new_value = "True" if active else "False"
|
||||
|
||||
if not re.search(pattern, content):
|
||||
raise HTTPException(400, f"Cannot find 'active' field in {game_type}.py")
|
||||
|
||||
new_content = re.sub(pattern, f'\\1{new_value}', content)
|
||||
game_file.write_text(new_content, encoding="utf-8")
|
||||
|
||||
reload_games()
|
||||
|
||||
action = "activated" if active else "deactivated"
|
||||
return ActionResponse(
|
||||
success=True,
|
||||
message=f"Game '{game_type}' has been {action}",
|
||||
game_type=game_type,
|
||||
active=active
|
||||
)
|
||||
|
||||
|
||||
@app.get("/llm", response_model=LLMConfigResponse)
|
||||
async def get_llm_config():
|
||||
"""Xem LLM config hiện tại"""
|
||||
global _current_config
|
||||
|
||||
if _current_config is None:
|
||||
core = get_core()
|
||||
_current_config = core.llm_config
|
||||
|
||||
return LLMConfigResponse(
|
||||
provider=_current_config.provider,
|
||||
model_name=_current_config.model_name,
|
||||
temperature=_current_config.temperature,
|
||||
base_url=_current_config.base_url
|
||||
)
|
||||
|
||||
|
||||
@app.post("/llm", response_model=ActionResponse)
|
||||
async def set_llm_config(config: LLMConfigRequest):
|
||||
"""Đổi LLM config global"""
|
||||
global _core, _current_config
|
||||
|
||||
new_config = ModelConfig(
|
||||
provider=config.provider,
|
||||
model_name=config.model_name,
|
||||
api_key=config.api_key,
|
||||
temperature=config.temperature,
|
||||
base_url=config.base_url
|
||||
)
|
||||
|
||||
try:
|
||||
_core = GameCore(llm_config=new_config)
|
||||
_current_config = new_config
|
||||
|
||||
return ActionResponse(
|
||||
success=True,
|
||||
message=f"LLM changed to {config.provider}/{config.model_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
return ActionResponse(
|
||||
success=False,
|
||||
message=f"Failed to change LLM: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/reload", response_model=ActionResponse)
|
||||
async def reload_all_games():
|
||||
"""Reload games"""
|
||||
global _core
|
||||
|
||||
reload_games()
|
||||
_core = None
|
||||
|
||||
return ActionResponse(
|
||||
success=True,
|
||||
message=f"Reloaded. Active games: {get_active_game_types()}"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"active_games": get_active_game_types()
|
||||
}
|
||||
|
||||
|
||||
# ============== STARTUP ==============
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
print("🚀 Game Generator API started")
|
||||
print(f"📋 Active games: {get_active_game_types()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=2088)
|
||||
Reference in New Issue
Block a user