init
This commit is contained in:
362
api.py
362
api.py
@@ -5,12 +5,19 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from pathlib import Path
|
||||
import re
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
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
|
||||
GameCore,
|
||||
get_registry,
|
||||
reload_games,
|
||||
get_active_game_types,
|
||||
get_active_type_ids,
|
||||
get_game_by_id,
|
||||
id_to_type,
|
||||
type_to_id,
|
||||
ModelConfig,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +25,7 @@ from src import (
|
||||
app = FastAPI(
|
||||
title="Game Generator API",
|
||||
description="API tạo game giáo dục từ văn bản",
|
||||
version="2.0.0"
|
||||
version="2.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -31,31 +38,43 @@ app.add_middleware(
|
||||
|
||||
# ============== 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)")
|
||||
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)")
|
||||
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")
|
||||
max_items: Optional[int] = Field(default=100)
|
||||
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")
|
||||
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
|
||||
input_chars: int = 0 # Character count sent to LLM
|
||||
output_chars: int = 0 # Character count received from LLM
|
||||
|
||||
|
||||
class GameScoreInfo(BaseModel):
|
||||
@@ -66,12 +85,14 @@ class GameScoreInfo(BaseModel):
|
||||
|
||||
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
|
||||
@@ -92,7 +113,7 @@ class GenerateResponse(BaseModel):
|
||||
|
||||
class GameInfo(BaseModel):
|
||||
type_id: int
|
||||
game_type: str # Keep for reference
|
||||
game_type: str
|
||||
display_name: str
|
||||
description: str
|
||||
active: bool
|
||||
@@ -127,7 +148,7 @@ _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(
|
||||
@@ -135,69 +156,77 @@ def get_core(config_override: Optional[LLMConfigRequest] = None) -> GameCore:
|
||||
model_name=config_override.model_name,
|
||||
api_key=config_override.api_key,
|
||||
temperature=config_override.temperature,
|
||||
base_url=config_override.base_url
|
||||
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)]
|
||||
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(
|
||||
|
||||
result = await core.run_multi_async(
|
||||
text=request.text,
|
||||
enabled_games=games,
|
||||
max_items=request.max_items or 3,
|
||||
min_score=request.min_score,
|
||||
max_items=request.max_items or 100,
|
||||
validate=request.run_validator,
|
||||
debug=request.debug
|
||||
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", "")
|
||||
))
|
||||
|
||||
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:
|
||||
if tid >= 0: # 0=quiz, 1=sequence are valid
|
||||
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
|
||||
|
||||
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,
|
||||
@@ -206,25 +235,120 @@ async def generate_games(request: GenerateRequest):
|
||||
results=results_by_id,
|
||||
llm=result.get("llm"),
|
||||
token_usage=result.get("token_usage"),
|
||||
errors=result.get("errors", [])
|
||||
errors=result.get("errors", []),
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
games=[],
|
||||
game_scores=[],
|
||||
results={},
|
||||
errors=[str(e)]
|
||||
success=False, games=[], game_scores=[], results={}, errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
# ============== FAST GENERATE (1 API call - OPTIMIZED) ==============
|
||||
|
||||
|
||||
class FastGenerateRequest(BaseModel):
|
||||
text: str = Field(description="Input text", min_length=10)
|
||||
enabled_game_ids: Optional[List[int]] = Field(
|
||||
default=None, description="Limit type_ids"
|
||||
)
|
||||
max_items: int = Field(default=100, description="Max items per game")
|
||||
min_score: int = Field(default=50, description="Min score 0-100 to include game")
|
||||
run_validator: bool = Field(default=True)
|
||||
debug: bool = Field(default=False)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None)
|
||||
|
||||
|
||||
@app.post("/generate/fast", response_model=GenerateResponse)
|
||||
async def generate_fast(request: FastGenerateRequest):
|
||||
"""
|
||||
🚀 OPTIMIZED: 1 API call để analyze + generate TẤT CẢ games phù hợp.
|
||||
|
||||
So với /generate (2+ calls):
|
||||
- Chỉ 1 API call
|
||||
- Tiết kiệm quota/tokens
|
||||
- Nhanh hơn
|
||||
|
||||
So với /generate/single:
|
||||
- Trả về NHIỀU games (không chỉ 1)
|
||||
"""
|
||||
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 = await core.run_fast_async(
|
||||
text=request.text,
|
||||
enabled_games=games,
|
||||
max_items=request.max_items,
|
||||
min_score=request.min_score,
|
||||
validate=request.run_validator,
|
||||
debug=request.debug,
|
||||
)
|
||||
|
||||
# Convert to response format (same as /generate)
|
||||
game_ids = [type_to_id(g) for g in result.get("games", [])]
|
||||
|
||||
game_scores = [
|
||||
GameScoreInfo(
|
||||
type_id=type_to_id(s.get("type", "")),
|
||||
score=s.get("score", 0),
|
||||
reason=s.get("reason", ""),
|
||||
)
|
||||
for s in result.get("game_scores", [])
|
||||
]
|
||||
|
||||
results_by_id = {}
|
||||
for game_type, data in result.get("results", {}).items():
|
||||
tid = type_to_id(game_type)
|
||||
if tid >= 0: # 0=quiz, 1=sequence are valid
|
||||
results_by_id[tid] = data
|
||||
|
||||
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,
|
||||
api_calls=1, # Always 1 for fast
|
||||
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")
|
||||
enabled_game_ids: Optional[List[int]] = Field(
|
||||
default=None, description="Limit type_ids to choose from"
|
||||
)
|
||||
max_items: int = Field(default=100, description="Max items to generate")
|
||||
run_validator: bool = Field(default=True)
|
||||
debug: bool = Field(default=False)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None)
|
||||
@@ -244,32 +368,34 @@ class SingleGenerateResponse(BaseModel):
|
||||
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)]
|
||||
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
|
||||
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,
|
||||
@@ -277,21 +403,19 @@ async def generate_single_game(request: SingleGenerateRequest):
|
||||
items=result.get("items", []),
|
||||
token_usage=result.get("token_usage"),
|
||||
llm=result.get("llm"),
|
||||
errors=result.get("errors", [])
|
||||
errors=result.get("errors", []),
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return SingleGenerateResponse(
|
||||
success=False,
|
||||
errors=[str(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")
|
||||
max_items: int = Field(default=100, description="Max items to generate")
|
||||
run_validator: bool = Field(default=True)
|
||||
debug: bool = Field(default=False)
|
||||
llm_config: Optional[LLMConfigRequest] = Field(default=None)
|
||||
@@ -299,6 +423,7 @@ class DirectGenerateRequest(BaseModel):
|
||||
|
||||
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
|
||||
@@ -322,28 +447,28 @@ async def generate_direct(type_id: int, request: DirectGenerateRequest):
|
||||
return DirectGenerateResponse(
|
||||
success=False,
|
||||
games=[type_id],
|
||||
errors=[f"Game with type_id={type_id} not found"]
|
||||
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
|
||||
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
|
||||
metadata=data.get("metadata") if isinstance(data, dict) else None,
|
||||
)
|
||||
|
||||
|
||||
return DirectGenerateResponse(
|
||||
success=result.get("success", False),
|
||||
games=[type_id],
|
||||
@@ -352,15 +477,11 @@ async def generate_direct(type_id: int, request: DirectGenerateRequest):
|
||||
format_error=format_error,
|
||||
token_usage=result.get("token_usage"),
|
||||
llm=result.get("llm"),
|
||||
errors=result.get("errors", [])
|
||||
errors=result.get("errors", []),
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return DirectGenerateResponse(
|
||||
success=False,
|
||||
games=[type_id],
|
||||
errors=[str(e)]
|
||||
)
|
||||
return DirectGenerateResponse(success=False, games=[type_id], errors=[str(e)])
|
||||
|
||||
|
||||
@app.get("/games", response_model=GamesListResponse)
|
||||
@@ -368,29 +489,29 @@ 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,
|
||||
))
|
||||
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
|
||||
total=len(games_list), active_count=active_count, games=games_list
|
||||
)
|
||||
|
||||
|
||||
@@ -409,28 +530,28 @@ async def deactivate_game(game_type: str):
|
||||
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)
|
||||
|
||||
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
|
||||
active=active,
|
||||
)
|
||||
|
||||
|
||||
@@ -438,16 +559,16 @@ def _set_game_active(game_type: str, active: bool) -> ActionResponse:
|
||||
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
|
||||
base_url=_current_config.base_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -455,50 +576,43 @@ async def get_llm_config():
|
||||
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
|
||||
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}"
|
||||
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)}"
|
||||
)
|
||||
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()}"
|
||||
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()
|
||||
}
|
||||
return {"status": "healthy", "active_games": get_active_game_types()}
|
||||
|
||||
|
||||
# ============== STARTUP ==============
|
||||
@@ -510,4 +624,8 @@ async def startup():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=2088)
|
||||
|
||||
port = os.getenv("PORT")
|
||||
if not port:
|
||||
raise ValueError("Missing required environment variable: PORT")
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(port))
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
"""
|
||||
games/match.py - Match Game - Match sentences with images
|
||||
games/match.py - Match Game - Match words/phrases with images
|
||||
type_id = 3
|
||||
|
||||
Input: Danh sách từ hoặc cụm từ
|
||||
Output: Mỗi item gồm từ/cụm từ và mô tả hình ảnh tương ứng
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from typing import List, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
|
||||
|
||||
# ============== SCHEMA ==============
|
||||
class MatchItem(BaseModel):
|
||||
word: str = Field(description="The sentence to be matched (EXACT copy from source)")
|
||||
match_with: str = Field(description="Short keyword for reference")
|
||||
original_quote: str = Field(description="EXACT quote from source text")
|
||||
image_description: str = Field(default="", description="Detailed visual description for image generation/search")
|
||||
image_is_complex: bool = Field(default=False, description="True if image needs precise quantities, humans, or multiple detailed objects")
|
||||
"""Schema cho 1 item của Match game"""
|
||||
|
||||
word: str = Field(
|
||||
description="The word or phrase to be matched (EXACT copy from source, cleaned of numbering)"
|
||||
)
|
||||
original_quote: str = Field(
|
||||
description="EXACT quote from source text before any cleaning"
|
||||
)
|
||||
image_description: str = Field(
|
||||
description="Detailed visual description for image generation in ENGLISH. Must be specific and visual."
|
||||
)
|
||||
image_keywords: List[str] = Field(
|
||||
default=[], description="2-3 English keywords for image search"
|
||||
)
|
||||
image_is_complex: bool = Field(
|
||||
default=False,
|
||||
description="True if image needs precise quantities, humans, or multiple detailed objects",
|
||||
)
|
||||
|
||||
|
||||
class MatchMetadata(BaseModel):
|
||||
"""Metadata đánh giá nội dung"""
|
||||
|
||||
title: str = Field(description="Title from source or short descriptive title")
|
||||
description: str = Field(description="One sentence summary of the content")
|
||||
grade: int = Field(
|
||||
description="Estimated grade level 1-5 (1=easy/young, 5=advanced)"
|
||||
)
|
||||
type: Literal["match"] = Field(default="match", description="Game type")
|
||||
difficulty: int = Field(description="Difficulty 1-5 for that grade")
|
||||
|
||||
|
||||
class MatchOutput(BaseModel):
|
||||
"""Output wrapper for match items"""
|
||||
items: List[MatchItem] = Field(description="List of match items generated from source text")
|
||||
|
||||
items: List[MatchItem] = Field(
|
||||
description="List of match items generated from source text"
|
||||
)
|
||||
metadata: MatchMetadata = Field(description="Metadata about the content")
|
||||
|
||||
|
||||
# Output parser
|
||||
@@ -26,56 +60,110 @@ output_parser = PydanticOutputParser(pydantic_object=MatchOutput)
|
||||
|
||||
# ============== CONFIG ==============
|
||||
GAME_CONFIG = {
|
||||
# === REQUIRED ===
|
||||
"game_type": "match",
|
||||
"type_id": 3,
|
||||
"display_name": "Match with Image",
|
||||
"description": "Match sentences with images",
|
||||
|
||||
"active": True,
|
||||
|
||||
"min_items": 2,
|
||||
"max_items": 10,
|
||||
"description": "Match words or phrases with their corresponding images",
|
||||
"schema": MatchItem,
|
||||
"output_schema": MatchOutput,
|
||||
"output_parser": output_parser,
|
||||
|
||||
"system_prompt": """Extract sentences and create image descriptions for matching game.
|
||||
The game will show images and players must match them with the correct sentences.
|
||||
|
||||
YOUR TASK:
|
||||
1. Extract meaningful sentences from the source text
|
||||
2. Create a DETAILED image_description that clearly represents the sentence
|
||||
3. The image should be distinct enough to match with its sentence
|
||||
|
||||
CRITICAL RULES:
|
||||
1. KEEP THE ORIGINAL LANGUAGE - Do NOT translate the source text
|
||||
2. original_quote MUST be an EXACT copy from source text
|
||||
3. image_description must be DETAILED and SPECIFIC to the sentence content
|
||||
4. Each image should be visually distinguishable from others""",
|
||||
# === OPTIONAL ===
|
||||
"active": True,
|
||||
"max_items": 10,
|
||||
# Input validation rules
|
||||
"input_format_rules": [
|
||||
"Text MUST be a list of words or phrases separated by commas, semicolons, or newlines",
|
||||
"NOT suitable for long sentences or paragraphs",
|
||||
"Each item should be a concrete noun/concept that can be visualized",
|
||||
],
|
||||
# Analyzer rules - khi nào nên chọn game này
|
||||
"analyzer_rules": [
|
||||
"Text is a list of words or short phrases",
|
||||
"Words represent concrete objects/concepts that can be visualized",
|
||||
"Examples: 'apple, banana, orange' or 'cat; dog; bird'",
|
||||
"NOT suitable for abstract concepts or long sentences",
|
||||
],
|
||||
# Generation rules - cách tạo nội dung
|
||||
"generation_rules": [
|
||||
"KEEP ORIGINAL LANGUAGE for 'word' field - Do NOT translate",
|
||||
"original_quote = EXACT copy from source before cleaning",
|
||||
"Clean numbering like '1.', 'a)', '•' from word field",
|
||||
"Each word/phrase should represent a visualizable concept",
|
||||
# Image rules
|
||||
"image_description: MUST be DETAILED visual description in ENGLISH",
|
||||
"image_description: Describe colors, shapes, actions, context",
|
||||
"image_keywords: 2-3 English keywords for search",
|
||||
"image_is_complex: TRUE for humans, precise counts, complex scenes",
|
||||
"NEVER leave image_description empty!",
|
||||
# Quality rules
|
||||
"Each image should be visually DISTINCT from others",
|
||||
"Avoid generic descriptions - be specific",
|
||||
],
|
||||
"examples": [], # Defined below
|
||||
}
|
||||
|
||||
|
||||
# ============== EXAMPLES ==============
|
||||
EXAMPLES = [
|
||||
{
|
||||
"input": "The Sun is a star. The Moon orbits Earth.",
|
||||
"input": "apple; banana;",
|
||||
"output": {
|
||||
"items": [
|
||||
{
|
||||
"word": "The Sun is a star.",
|
||||
"match_with": "sun",
|
||||
"original_quote": "The Sun is a star.",
|
||||
"image_description": "A bright glowing yellow sun with solar flares",
|
||||
"image_is_complex": False
|
||||
"word": "apple",
|
||||
"original_quote": "apple",
|
||||
"image_description": "A shiny red apple with a green leaf on top",
|
||||
"image_keywords": ["apple", "fruit", "red"],
|
||||
"image_is_complex": False,
|
||||
},
|
||||
{
|
||||
"word": "The Moon orbits Earth.",
|
||||
"match_with": "moon",
|
||||
"original_quote": "The Moon orbits Earth.",
|
||||
"image_description": "A grey moon circling around the blue Earth planet",
|
||||
"image_is_complex": False
|
||||
}
|
||||
]
|
||||
"word": "banana",
|
||||
"original_quote": "banana",
|
||||
"image_description": "A curved yellow banana",
|
||||
"image_keywords": ["banana", "fruit", "yellow"],
|
||||
"image_is_complex": False,
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"title": "Fruits",
|
||||
"description": "Common fruits vocabulary",
|
||||
"grade": 1,
|
||||
"type": "match",
|
||||
"difficulty": 1,
|
||||
},
|
||||
},
|
||||
"why_suitable": "Has distinct concepts that can be visualized and matched"
|
||||
}
|
||||
"why_suitable": "Simple words representing concrete objects that can be visualized",
|
||||
},
|
||||
{
|
||||
"input": "1. elephant\n2. giraffe\n",
|
||||
"output": {
|
||||
"items": [
|
||||
{
|
||||
"word": "elephant",
|
||||
"original_quote": "1. elephant",
|
||||
"image_description": "A large grey elephant with big ears and long trunk",
|
||||
"image_keywords": ["elephant", "animal", "africa"],
|
||||
"image_is_complex": False,
|
||||
},
|
||||
{
|
||||
"word": "giraffe",
|
||||
"original_quote": "2. giraffe",
|
||||
"image_description": "A tall giraffe with brown spots and long neck",
|
||||
"image_keywords": ["giraffe", "tall", "spots"],
|
||||
"image_is_complex": False,
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"title": "African Animals",
|
||||
"description": "Safari animals vocabulary",
|
||||
"grade": 2,
|
||||
"type": "match",
|
||||
"difficulty": 1,
|
||||
},
|
||||
},
|
||||
"why_suitable": "Numbered list of animals - numbering will be cleaned",
|
||||
},
|
||||
]
|
||||
|
||||
GAME_CONFIG["examples"] = EXAMPLES
|
||||
|
||||
806
src/core.py
806
src/core.py
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
game_registry.py - Tự động load games từ thư mục games/
|
||||
|
||||
Hệ thống sẽ:
|
||||
1. Scan thư mục games/
|
||||
1. Scan thư mục games/
|
||||
2. Load mọi file .py (trừ _template.py và __init__.py)
|
||||
3. Chỉ load games có active: True
|
||||
4. Đăng ký tự động vào registry
|
||||
@@ -10,6 +10,7 @@ Hệ thống sẽ:
|
||||
THÊM GAME MỚI = TẠO FILE TRONG games/
|
||||
BẬT/TẮT GAME = SỬA active: True/False trong file game
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
@@ -20,75 +21,78 @@ class GameRegistry:
|
||||
"""
|
||||
Registry tự động load games từ thư mục games/
|
||||
Chỉ load games có active: True
|
||||
|
||||
|
||||
Supports lookup by:
|
||||
- game_type (string): "quiz", "sequence"
|
||||
- type_id (int): 1, 2
|
||||
"""
|
||||
|
||||
_instance: Optional["GameRegistry"] = None
|
||||
_all_games: Dict[str, GameType] = {} # Keyed by game_type
|
||||
_id_map: Dict[int, str] = {} # type_id -> game_type
|
||||
_loaded: bool = False
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._all_games = {}
|
||||
cls._instance._id_map = {}
|
||||
return cls._instance
|
||||
|
||||
|
||||
def __init__(self):
|
||||
if not self._loaded:
|
||||
self._load_all_games()
|
||||
self._loaded = True
|
||||
|
||||
|
||||
def _load_all_games(self):
|
||||
"""Scan và load tất cả game definitions từ games/"""
|
||||
games_dir = Path(__file__).parent / "games"
|
||||
|
||||
|
||||
if not games_dir.exists():
|
||||
print(f"⚠️ Games directory not found: {games_dir}")
|
||||
return
|
||||
|
||||
|
||||
for file_path in games_dir.glob("*.py"):
|
||||
# Skip __init__.py và _template.py và base.py
|
||||
if file_path.name.startswith("_") or file_path.name == "base.py":
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
game_def = self._load_game_from_file(file_path)
|
||||
if game_def:
|
||||
self._all_games[game_def.game_type] = game_def
|
||||
if game_def.type_id > 0:
|
||||
if game_def.type_id >= 0: # 0=quiz, 1=sequence are valid
|
||||
self._id_map[game_def.type_id] = game_def.game_type
|
||||
status = "✅" if game_def.active else "⏸️"
|
||||
print(f"{status} Loaded: {game_def.game_type} (id={game_def.type_id}, active={game_def.active})")
|
||||
print(
|
||||
f"{status} Loaded: {game_def.game_type} (id={game_def.type_id}, active={game_def.active})"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading {file_path.name}: {e}")
|
||||
|
||||
|
||||
def _load_game_from_file(self, file_path: Path) -> Optional[GameType]:
|
||||
"""Load 1 game definition từ file"""
|
||||
module_name = f"games.{file_path.stem}"
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
config = getattr(module, "GAME_CONFIG", None)
|
||||
examples = getattr(module, "EXAMPLES", [])
|
||||
|
||||
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
|
||||
# Inject examples if not in config
|
||||
if examples and "examples" not in config:
|
||||
config["examples"] = examples
|
||||
|
||||
|
||||
return create_game_type(config)
|
||||
|
||||
|
||||
def reload(self):
|
||||
"""Reload tất cả games"""
|
||||
self._all_games.clear()
|
||||
@@ -96,55 +100,57 @@ class GameRegistry:
|
||||
self._loaded = False
|
||||
self._load_all_games()
|
||||
self._loaded = True
|
||||
|
||||
|
||||
# ============== PUBLIC API ==============
|
||||
|
||||
|
||||
def get_game(self, game_type: str) -> Optional[GameType]:
|
||||
"""Lấy game by game_type (chỉ active)"""
|
||||
game = self._all_games.get(game_type)
|
||||
return game if game and game.active else None
|
||||
|
||||
|
||||
def get_game_by_id(self, type_id: int) -> Optional[GameType]:
|
||||
"""Lấy game by type_id (chỉ active)"""
|
||||
game_type = self._id_map.get(type_id)
|
||||
if game_type:
|
||||
return self.get_game(game_type)
|
||||
return None
|
||||
|
||||
|
||||
def get_game_type_by_id(self, type_id: int) -> Optional[str]:
|
||||
"""Convert type_id -> game_type"""
|
||||
return self._id_map.get(type_id)
|
||||
|
||||
|
||||
def get_id_by_game_type(self, game_type: str) -> int:
|
||||
"""Convert game_type -> type_id"""
|
||||
"""Convert game_type -> type_id. Returns -1 if not found."""
|
||||
game = self._all_games.get(game_type)
|
||||
return game.type_id if game else 0
|
||||
|
||||
return game.type_id if game else -1 # -1 = not found
|
||||
|
||||
def get_all_games(self) -> Dict[str, GameType]:
|
||||
"""Lấy tất cả games ACTIVE"""
|
||||
return {k: v for k, v in self._all_games.items() if v.active}
|
||||
|
||||
|
||||
def get_all_games_including_inactive(self) -> Dict[str, GameType]:
|
||||
"""Lấy tất cả games (kể cả inactive)"""
|
||||
return self._all_games.copy()
|
||||
|
||||
|
||||
def get_game_types(self) -> List[str]:
|
||||
"""Lấy danh sách game types ACTIVE"""
|
||||
return [k for k, v in self._all_games.items() if v.active]
|
||||
|
||||
|
||||
def get_type_ids(self) -> List[int]:
|
||||
"""Lấy danh sách type_ids ACTIVE"""
|
||||
return [v.type_id for v in self._all_games.values() if v.active and v.type_id > 0]
|
||||
|
||||
return [
|
||||
v.type_id for v in self._all_games.values() if v.active and v.type_id > 0
|
||||
]
|
||||
|
||||
def get_analyzer_context(self) -> str:
|
||||
"""Tạo context cho Analyzer (chỉ từ active games)"""
|
||||
context_parts = []
|
||||
|
||||
|
||||
for game_type, game in self._all_games.items():
|
||||
if not game.active:
|
||||
continue
|
||||
|
||||
hints = game.analyzer_rules # New field name
|
||||
|
||||
hints = game.analyzer_rules # New field name
|
||||
if hints:
|
||||
hints_text = "\n - ".join(hints)
|
||||
context_parts.append(
|
||||
@@ -152,9 +158,9 @@ class GameRegistry:
|
||||
f" Description: {game.description}\n"
|
||||
f" Suitable when:\n - {hints_text}"
|
||||
)
|
||||
|
||||
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
|
||||
def is_active(self, game_type: str) -> bool:
|
||||
"""Kiểm tra game có active không"""
|
||||
game = self._all_games.get(game_type)
|
||||
|
||||
@@ -4,88 +4,180 @@ games/_template.py - TEMPLATE CHO GAME MỚI
|
||||
THÊM GAME MỚI CHỈ CẦN:
|
||||
1. Copy file này
|
||||
2. Rename thành <game_type>.py (ví dụ: matching.py)
|
||||
3. Sửa nội dung bên trong
|
||||
3. Sửa nội dung bên trong theo hướng dẫn
|
||||
4. DONE! Hệ thống tự động nhận diện.
|
||||
|
||||
Không cần sửa bất kỳ file nào khác!
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from typing import List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
|
||||
|
||||
# ============== 1. SCHEMA ==============
|
||||
# ============== 1. ITEM SCHEMA ==============
|
||||
# Định nghĩa structure của 1 item trong game
|
||||
# BẮT BUỘC phải có: original_quote và explanation
|
||||
# BẮT BUỘC phải có: original_quote
|
||||
|
||||
class YourGameItem(BaseModel):
|
||||
"""Schema cho 1 item của game"""
|
||||
|
||||
# Các trường BẮT BUỘC (để chống hallucination)
|
||||
# === TRƯỜNG BẮT BUỘC ===
|
||||
original_quote: str = Field(
|
||||
description="Trích dẫn NGUYÊN VĂN từ văn bản gốc"
|
||||
description="EXACT quote from source text - dùng để verify không hallucinate"
|
||||
)
|
||||
explanation: str = Field(description="Giải thích")
|
||||
|
||||
# Thêm các trường riêng của game ở đây
|
||||
# === TRƯỜNG RIÊNG CỦA GAME ===
|
||||
# Thêm các trường cần thiết cho game của bạn
|
||||
# Ví dụ:
|
||||
# question: str = Field(description="Câu hỏi")
|
||||
# answer: str = Field(description="Đáp án")
|
||||
question: str = Field(description="The question")
|
||||
answer: str = Field(description="The correct answer")
|
||||
|
||||
# === TRƯỜNG HÌNH ẢNH (Khuyến nghị) ===
|
||||
image_description: str = Field(default="", description="Visual description in English")
|
||||
image_keywords: List[str] = Field(default=[], description="2-3 English keywords for image search")
|
||||
image_is_complex: bool = Field(default=False, description="True if needs precise quantities/humans/complex scene")
|
||||
|
||||
|
||||
# ============== 2. CONFIG ==============
|
||||
# Cấu hình cho game
|
||||
# ============== 2. METADATA SCHEMA ==============
|
||||
# Metadata mô tả nội dung được generate
|
||||
|
||||
class YourGameMetadata(BaseModel):
|
||||
"""Metadata đánh giá nội dung"""
|
||||
title: str = Field(description="Title from source or short descriptive title")
|
||||
description: str = Field(description="One sentence summary")
|
||||
grade: int = Field(description="Grade level 1-5 (1=easy, 5=advanced)")
|
||||
type: Literal["your_game"] = Field(default="your_game", description="Game type - MUST match game_type below")
|
||||
difficulty: int = Field(description="Difficulty 1-5 for that grade")
|
||||
|
||||
|
||||
# ============== 3. OUTPUT SCHEMA ==============
|
||||
# Wrapper chứa danh sách items và metadata
|
||||
|
||||
class YourGameOutput(BaseModel):
|
||||
"""Output wrapper - BẮT BUỘC phải có"""
|
||||
items: List[YourGameItem] = Field(description="List of game items")
|
||||
metadata: YourGameMetadata = Field(description="Metadata about the content")
|
||||
|
||||
|
||||
# Output parser - tự động từ output schema
|
||||
output_parser = PydanticOutputParser(pydantic_object=YourGameOutput)
|
||||
|
||||
|
||||
# ============== 4. CONFIG ==============
|
||||
# Cấu hình cho game - ĐÂY LÀ PHẦN QUAN TRỌNG NHẤT
|
||||
|
||||
GAME_CONFIG = {
|
||||
# Key duy nhất cho game (dùng trong API)
|
||||
"game_type": "your_game",
|
||||
# === REQUIRED FIELDS ===
|
||||
|
||||
# Key duy nhất cho game (dùng trong API) - PHẢI unique
|
||||
"game_type": "your_game",
|
||||
|
||||
# ID số nguyên unique - PHẢI khác các game khác
|
||||
# Quiz=1, Sequence=2, ... tiếp tục từ 3
|
||||
"type_id": 99, # TODO: Đổi thành số unique
|
||||
|
||||
# Tên hiển thị
|
||||
"display_name": "Tên Game",
|
||||
"display_name": "Your Game Name",
|
||||
|
||||
# Mô tả ngắn
|
||||
"description": "Mô tả game của bạn",
|
||||
"description": "Description of your game",
|
||||
|
||||
# Số lượng items
|
||||
"max_items": 5,
|
||||
|
||||
# Trỏ đến schema class
|
||||
# Schema classes - BẮT BUỘC
|
||||
"schema": YourGameItem,
|
||||
"output_schema": YourGameOutput,
|
||||
"output_parser": output_parser,
|
||||
|
||||
# Prompt cho LLM
|
||||
"system_prompt": """Bạn là chuyên gia tạo [tên game].
|
||||
|
||||
NHIỆM VỤ: [Mô tả nhiệm vụ]
|
||||
|
||||
QUY TẮC:
|
||||
1. original_quote PHẢI là trích dẫn NGUYÊN VĂN
|
||||
2. [Quy tắc khác]
|
||||
3. [Quy tắc khác]""",
|
||||
# === OPTIONAL FIELDS (có default) ===
|
||||
|
||||
# Game có active không
|
||||
"active": True,
|
||||
|
||||
# Số lượng items tối đa
|
||||
"max_items": 10,
|
||||
|
||||
# Rules validate input trước khi generate (Direct Mode)
|
||||
"input_format_rules": [
|
||||
"Text should contain ... suitable for this game.",
|
||||
"Text MUST have ...",
|
||||
],
|
||||
|
||||
# Rules cho Analyzer nhận diện game phù hợp
|
||||
"analyzer_rules": [
|
||||
"Text MUST contain ...",
|
||||
"NOT suitable if text is ...",
|
||||
],
|
||||
|
||||
# Rules cho Generator tạo nội dung
|
||||
"generation_rules": [
|
||||
"KEEP ORIGINAL LANGUAGE - Do NOT translate",
|
||||
"original_quote = EXACT quote from source text",
|
||||
"ALL content must come from source only - do NOT invent",
|
||||
|
||||
# Thêm rules riêng cho game của bạn
|
||||
"Your specific rule 1",
|
||||
"Your specific rule 2",
|
||||
|
||||
# Visual fields
|
||||
"image_description: MUST be visual description in ENGLISH",
|
||||
"image_keywords: MUST provide 2-3 English keywords",
|
||||
"NEVER leave image fields empty!",
|
||||
],
|
||||
|
||||
# Examples - giúp LLM học format
|
||||
"examples": [] # Sẽ định nghĩa bên dưới
|
||||
}
|
||||
|
||||
|
||||
# ============== 3. EXAMPLES ==============
|
||||
# Ví dụ input/output để:
|
||||
# - Analyzer học khi nào nên suggest game này
|
||||
# - Generator dùng làm few-shot
|
||||
# ============== 5. EXAMPLES ==============
|
||||
# Ví dụ input/output để LLM học pattern
|
||||
|
||||
EXAMPLES = [
|
||||
{
|
||||
# Input text mẫu
|
||||
"input": "Văn bản mẫu ở đây...",
|
||||
"input": "Sample text for your game...",
|
||||
|
||||
# Output mong đợi
|
||||
# Output mong đợi - PHẢI match schema
|
||||
"output": {
|
||||
"items": [
|
||||
{
|
||||
"original_quote": "Trích dẫn từ văn bản",
|
||||
"explanation": "Giải thích",
|
||||
# Các trường khác của schema...
|
||||
"original_quote": "Exact quote from input",
|
||||
"question": "Sample question?",
|
||||
"answer": "Sample answer",
|
||||
"image_description": "Visual description",
|
||||
"image_keywords": ["keyword1", "keyword2"],
|
||||
"image_is_complex": False
|
||||
}
|
||||
]
|
||||
],
|
||||
"metadata": {
|
||||
"title": "Sample Title",
|
||||
"description": "Sample description",
|
||||
"grade": 2,
|
||||
"type": "your_game",
|
||||
"difficulty": 2
|
||||
}
|
||||
},
|
||||
|
||||
# Analyzer học từ trường này
|
||||
"why_suitable": "Giải thích tại sao văn bản này phù hợp với game này"
|
||||
# Giải thích tại sao phù hợp - Analyzer học từ đây
|
||||
"why_suitable": "Explain why this input is suitable for this game"
|
||||
},
|
||||
# Thêm 1-2 examples nữa...
|
||||
# Thêm 1-2 examples nữa để LLM học tốt hơn...
|
||||
]
|
||||
|
||||
# Gán examples vào config
|
||||
GAME_CONFIG["examples"] = EXAMPLES
|
||||
|
||||
|
||||
# ============== 6. POST PROCESS (Optional) ==============
|
||||
# Function xử lý output sau khi LLM generate
|
||||
|
||||
def post_process_your_game(items: List[dict]) -> List[dict]:
|
||||
"""Clean up hoặc transform items sau khi generate"""
|
||||
for item in items:
|
||||
# Ví dụ: clean up text
|
||||
if item.get("answer"):
|
||||
item["answer"] = item["answer"].strip()
|
||||
return items
|
||||
|
||||
|
||||
# Đăng ký handler (optional)
|
||||
# GAME_CONFIG["post_process_handler"] = post_process_your_game
|
||||
|
||||
@@ -1,139 +1,172 @@
|
||||
"""
|
||||
games/quiz.py - Quiz Game - Multiple choice questions
|
||||
games/quiz.py - Optimized for LLM Performance while keeping System Integrity
|
||||
"""
|
||||
from typing import List, Literal
|
||||
import re
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
import re
|
||||
|
||||
|
||||
# ============== SCHEMA ==============
|
||||
# ==========================================
|
||||
# 1. OPTIMIZED SCHEMA (Thông minh hơn)
|
||||
# ==========================================
|
||||
class QuizItem(BaseModel):
|
||||
question: str = Field(description="The question based on source content")
|
||||
answers: str = Field(description="The correct answer")
|
||||
options: List[str] = Field(description="List of options including correct answer")
|
||||
original_quote: str = Field(description="EXACT quote from source text")
|
||||
image_description: str = Field(default="", description="Visual description for the question")
|
||||
image_keywords: List[str] = Field(default=[], description="Keywords for image search")
|
||||
image_is_complex: bool = Field(default=False, description="True if image needs precise quantities, humans, or multiple detailed objects")
|
||||
# LLM chỉ cần tập trung sinh ra raw data, việc clean để code lo
|
||||
question: str = Field(description="Question text. Use ____ for blanks.")
|
||||
# Request field có thể để default, logic xử lý sau
|
||||
request: str = Field(
|
||||
default="Choose the correct answer", description="Instruction type"
|
||||
)
|
||||
answer: str = Field(description="Correct answer text")
|
||||
options: List[str] = Field(description="List of options")
|
||||
original_quote: str = Field(description="Exact source sentence")
|
||||
|
||||
# Gom nhóm image fields để prompt gọn hơn
|
||||
image_description: str = Field(
|
||||
default="", description="Visual description (if needed)"
|
||||
)
|
||||
image_keywords: List[str] = Field(default=[])
|
||||
image_is_complex: bool = Field(default=False)
|
||||
|
||||
@field_validator("answer", "options", mode="before")
|
||||
@classmethod
|
||||
def clean_prefixes(cls, v):
|
||||
"""Tự động xóa A., B., (1)... ngay khi nhận dữ liệu từ LLM"""
|
||||
|
||||
def clean_str(text):
|
||||
# Regex xóa (A), 1., Q: ở đầu và (1) ở cuối
|
||||
text = re.sub(
|
||||
r"^(\([A-Za-z0-9]\)|[A-Za-z0-9]\.|Q\d*:)\s*",
|
||||
"",
|
||||
str(text),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r"\s*\([A-Za-z0-9]\)$", "", text)
|
||||
return text.strip()
|
||||
|
||||
if isinstance(v, list):
|
||||
return [clean_str(item) for item in v]
|
||||
return clean_str(v)
|
||||
|
||||
|
||||
class QuizMetadata(BaseModel):
|
||||
"""Metadata đánh giá nội dung"""
|
||||
title: str = Field(
|
||||
description="Title for this content. Prefer title from source document if available and suitable, otherwise create a short descriptive title."
|
||||
)
|
||||
description: str = Field(
|
||||
description="Short description summarizing the content/topic of the quiz."
|
||||
)
|
||||
grade: int = Field(
|
||||
description="Estimated grade level 1-5 (1=easy/young, 5=advanced/older). Judge by vocabulary, concepts, required knowledge."
|
||||
)
|
||||
type: Literal["quiz"] = Field(default="quiz", description="Game type (always 'quiz')")
|
||||
difficulty: int = Field(
|
||||
description="Difficulty 1-5 for that grade (1=very easy, 5=very hard). Judge by question complexity, number of options, abstract concepts."
|
||||
)
|
||||
title: str = Field(description="Short content title")
|
||||
description: str = Field(description="Summary")
|
||||
grade: int = Field(description="Level 1-5")
|
||||
type: Literal["quiz"] = "quiz"
|
||||
difficulty: int = Field(description="Level 1-5")
|
||||
|
||||
|
||||
class QuizOutput(BaseModel):
|
||||
"""Output wrapper for quiz items"""
|
||||
items: List[QuizItem] = Field(description="List of quiz items generated from source text")
|
||||
metadata: QuizMetadata = Field(description="Metadata about the quiz content")
|
||||
items: List[QuizItem]
|
||||
metadata: QuizMetadata
|
||||
|
||||
|
||||
# Output parser
|
||||
output_parser = PydanticOutputParser(pydantic_object=QuizOutput)
|
||||
|
||||
# ==========================================
|
||||
# 2. COMPACT CONFIG (Giữ đủ key, giảm nội dung)
|
||||
# ==========================================
|
||||
|
||||
# ============== CONFIG ==============
|
||||
# ============== CONFIG ==============
|
||||
GAME_CONFIG = {
|
||||
# --- SYSTEM FIELDS (Giữ nguyên không đổi) ---
|
||||
"game_type": "quiz",
|
||||
"display_name": "Quiz",
|
||||
"description": "Multiple choice questions",
|
||||
"type_id": 1,
|
||||
|
||||
"type_id": 0,
|
||||
"active": True,
|
||||
|
||||
"max_items": 10,
|
||||
"schema": QuizItem,
|
||||
"output_schema": QuizOutput,
|
||||
"output_parser": output_parser,
|
||||
|
||||
# --- USER UI HINTS (Rút gọn văn bản hiển thị) ---
|
||||
"input_format_rules": [
|
||||
"Text should contain facts or questions suitable for a quiz.",
|
||||
"Prefer extracting existing multiple choice questions if available.",
|
||||
"Text MUST contain questions with multiple choice options",
|
||||
"Text must contain specific facts or Q&A content.",
|
||||
"Suitable for multiple choice extraction.",
|
||||
],
|
||||
|
||||
# 1. Recognition Rules (for Analyzer)
|
||||
# --- PRE-CHECK LOGIC (Rút gọn) ---
|
||||
"analyzer_rules": [
|
||||
"Text MUST contain questions with multiple choice options",
|
||||
"NOT suitable if text is just a list of words with no questions",
|
||||
"Contains questions with options OR factual statements.",
|
||||
"Not just a list of unconnected words.",
|
||||
],
|
||||
|
||||
# 2. Rules tạo nội dung (cho Generator)
|
||||
"generation_rules": [
|
||||
"KEEP ORIGINAL LANGUAGE - Do NOT translate",
|
||||
"original_quote = EXACT quote from source text (full question block)",
|
||||
"ALL content must come from source only - do NOT invent",
|
||||
"REMOVE unnecessary numbering: 'Question 1:', '(1)', '(2)', 'A.', 'B.' from question/options/answers",
|
||||
"STRICTLY CLEAN OUTPUT for 'answers': MUST contain ONLY the text content of the correct option.",
|
||||
"FORBIDDEN in 'answers': Prefixes like '(1)', '(2)', 'A.', 'B.', '1.' - REMOVE THEM.",
|
||||
"IMPORTANT: The 'answers' field MUST EXACTLY MATCH one of the 'options' values text-wise.",
|
||||
|
||||
# VISUAL FIELD COMPULSORY
|
||||
"image_description: MUST be a visual description relevant to the question in ENGLISH.",
|
||||
"image_keywords: MUST provide 2-3 English keywords for search.",
|
||||
"image_is_complex: FALSE for simple/static objects, TRUE for quantities/humans/complex scenes",
|
||||
"NEVER leave image fields empty!",
|
||||
# --- LLM INSTRUCTIONS ---
|
||||
"generation_rules": [
|
||||
"MODE: STRICT EXTRACTION & LOCALITY PRIORITIZED.",
|
||||
"1. MANDATORY OPTIONS & LOCALITY: Only create a quiz item if 2-4 options are EXPLICITLY present and located immediately after/below the question. SKIP if options are shared in a 'Word Box' or 'Word Bank' tại đầu/cuối trang.",
|
||||
"2. ANSWER PRIORITY: Use the provided key if available. If the marker is empty, solve it yourself using grammar rules. Do not redefine existing keys.",
|
||||
"3. ZERO FABRICATION: Do NOT invent distractors. Only extract what is explicitly present.",
|
||||
"4. LOGICAL AMBIGUITY: If a question is grammatically correct with multiple options but lacks context, SKIP IT.",
|
||||
"5. SEMANTIC OPTION EXTRACTION: Extract ONLY the meaningful word/phrase. Strip away ALL labels like (1), (A), or OCR noise.",
|
||||
"6. SMART FILL-IN-THE-BLANK: If the question is a 'Fill in the blank' type, you MUST analyze the sentence structure and place the '____' at the grammatically correct position (e.g., 'Blood ____ oozing'). DO NOT blindly put it at the end. If the sentence is already a complete question (not a blank type), do not add '____'.",
|
||||
"7. METADATA: Fill metadata accurately based on content. Do not leave empty."
|
||||
],
|
||||
# --- EXAMPLES (Chỉ giữ 1 cái tốt nhất để làm mẫu format) ---
|
||||
"examples": [
|
||||
{
|
||||
"input": "The giraffe has a long neck. Options: neck, leg, tail.",
|
||||
"output": {
|
||||
"items": [
|
||||
{
|
||||
"question": "The giraffe has a long ____.",
|
||||
"request": "Fill in the blank",
|
||||
"answer": "neck",
|
||||
"options": ["neck", "leg", "tail"],
|
||||
"original_quote": "The giraffe has a long neck.",
|
||||
"image_description": "A giraffe",
|
||||
"image_keywords": ["giraffe"],
|
||||
"image_is_complex": False,
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"title": "Animals",
|
||||
"description": "Giraffe anatomy",
|
||||
"grade": 2,
|
||||
"type": "quiz",
|
||||
"difficulty": 1,
|
||||
},
|
||||
},
|
||||
"why_suitable": "Valid extraction: Text has Fact + Options.",
|
||||
}
|
||||
],
|
||||
|
||||
"examples": EXAMPLES if 'EXAMPLES' in globals() else []
|
||||
}
|
||||
|
||||
|
||||
def clean_prefix(text: str) -> str:
|
||||
"""Remove prefixes like (1), (A), 1., A. from text"""
|
||||
if not text: return text
|
||||
# Regex: Start with ( (number/letter) ) OR number/letter dot. Followed by spaces.
|
||||
return re.sub(r'^(\(\d+\)|\([A-Za-z]\)|\d+\.|[A-Za-z]\.)\s*', '', text).strip()
|
||||
# # ==========================================
|
||||
# # 3. HANDLER (Logic hậu xử lý gọn nhẹ)
|
||||
# # ==========================================
|
||||
# def post_process_quiz(items: List[dict]) -> List[dict]:
|
||||
# valid_items = []
|
||||
# for item in items:
|
||||
# options = item.get("options", [])
|
||||
# answer = item.get("answer", "")
|
||||
|
||||
# if len(options) < 2:
|
||||
# continue
|
||||
|
||||
# # Nếu có answer từ input, thì so khớp để làm sạch
|
||||
# if answer:
|
||||
# matched_option = next(
|
||||
# (opt for opt in options if opt.lower() == answer.lower()), None
|
||||
# )
|
||||
# if matched_option:
|
||||
# item["answer"] = matched_option
|
||||
# # Nếu có answer mà không khớp option nào thì mới cân nhắc loại (hoặc để AI tự đoán lại)
|
||||
|
||||
# # Nếu answer rỗng (do ngoặc trống), ta vẫn giữ câu này lại
|
||||
# # (với điều kiện LLM đã được dặn là phải tự điền vào trường answer)
|
||||
# if not item.get("answer"):
|
||||
# # Bạn có thể chọn loại bỏ hoặc tin tưởng vào đáp án LLM tự suy luận
|
||||
# pass
|
||||
|
||||
# item["request"] = (
|
||||
# "Fill in the blank"
|
||||
# if "____" in item.get("question", "")
|
||||
# else "Choose the correct answer"
|
||||
# )
|
||||
# valid_items.append(item)
|
||||
# return valid_items
|
||||
|
||||
|
||||
def post_process_quiz(items: List[dict]) -> List[dict]:
|
||||
"""Clean up answers and options prefixes"""
|
||||
for item in items:
|
||||
# Clean answers
|
||||
if item.get("answers"):
|
||||
item["answers"] = clean_prefix(item["answers"])
|
||||
|
||||
# Clean options
|
||||
if item.get("options") and isinstance(item["options"], list):
|
||||
item["options"] = [clean_prefix(opt) for opt in item["options"]]
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# Register handler
|
||||
GAME_CONFIG["post_process_handler"] = post_process_quiz
|
||||
|
||||
|
||||
# ============== EXAMPLES ==============
|
||||
EXAMPLES = [
|
||||
{
|
||||
"input": "The Sun is a star at the center of the Solar System.",
|
||||
"output": {
|
||||
"items": [{
|
||||
"question": "Where is the Sun located?",
|
||||
"answers": "At the center of the Solar System",
|
||||
"options": ["At the center of the Solar System", "At the edge of the Solar System", "Near the Moon", "Outside the universe"],
|
||||
"original_quote": "The Sun is a star at the center of the Solar System.",
|
||||
"image_description": "The sun in the middle of planets",
|
||||
"image_keywords": ["sun", "planets"],
|
||||
"image_is_complex": False
|
||||
}]
|
||||
},
|
||||
"why_suitable": "Has clear facts"
|
||||
}
|
||||
]
|
||||
# # Đăng ký handler
|
||||
# GAME_CONFIG["post_process_handler"] = post_process_quiz
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
games/sequence.py - Arrange Sequence Game (Sentences OR Words)
|
||||
type_id = 2
|
||||
type_id = 1
|
||||
LLM tự quyết định dựa vào ngữ nghĩa:
|
||||
- "good morning", "apple", "happy" → WORD
|
||||
- "Hi, I'm Lisa", "The sun rises" → SENTENCE
|
||||
@@ -38,7 +38,7 @@ class SequenceMetadata(BaseModel):
|
||||
description="LLM decides: 'word' for words/phrases, 'sentence' for complete sentences"
|
||||
)
|
||||
difficulty: int = Field(
|
||||
description="Difficulty 1-5 for that grade."
|
||||
description="Difficulty 1-3 for that grade."
|
||||
)
|
||||
|
||||
|
||||
@@ -52,59 +52,7 @@ class SequenceOutput(BaseModel):
|
||||
output_parser = PydanticOutputParser(pydantic_object=SequenceOutput)
|
||||
|
||||
|
||||
# ============== CONFIG ==============
|
||||
# ============== CONFIG ==============
|
||||
GAME_CONFIG = {
|
||||
"game_type": "sequence",
|
||||
"display_name": "Arrange Sequence",
|
||||
"description": "Arrange sentences or words in order",
|
||||
"type_id": 2,
|
||||
|
||||
"active": True,
|
||||
|
||||
"max_items": 10,
|
||||
"schema": SequenceItem,
|
||||
"output_schema": SequenceOutput,
|
||||
"output_parser": output_parser,
|
||||
|
||||
"input_format_rules": [
|
||||
"Text MUST be a list of items (words, phrases, sentences) to be ordered.",
|
||||
"Do NOT generate sequence from multiple choice questions (A/B/C/D).",
|
||||
"Do NOT generate sequence if the text is a quiz or test format.",
|
||||
],
|
||||
|
||||
# 1. Recognition Rules (for Analyzer)
|
||||
"analyzer_rules": [
|
||||
"Text is a list of words, phrases, or sentences suitable for ordering",
|
||||
"Items are separated by commas, semicolons, or newlines",
|
||||
"Example: 'apple, banana, orange' or 'Sentence 1; Sentence 2'",
|
||||
"NO questions required - just a list of items",
|
||||
"Text is NOT a long essay or complex dialogue",
|
||||
],
|
||||
|
||||
# 2. Rules tạo nội dung (cho Generator)
|
||||
"generation_rules": [
|
||||
"KEEP ORIGINAL LANGUAGE - Do NOT translate",
|
||||
"Analyze text semantically to extract meaningful items",
|
||||
"For each item, decide type: WORD/PHRASE or SENTENCE",
|
||||
"- If item is a WORD/PHRASE (label, noun, greeting) -> Fill 'word' field",
|
||||
"- If item is a COMPLETE SENTENCE (subject+verb) -> Fill 'sentence' field",
|
||||
"NEVER fill both fields for the same item",
|
||||
"Set metadata.sub_type = 'word' or 'sentence' (all items should match sub_type)",
|
||||
"Clean up OCR noise, numbering (e.g. '1. Apple' -> 'Apple')",
|
||||
|
||||
# CONSISTENCY RULES
|
||||
"CRITICAL: All extracted items MUST be of the SAME type.",
|
||||
"Choose ONE type for the whole list: either ALL 'word' OR ALL 'sentence'.",
|
||||
"If input has mixed types, pick the MAJORITY type and ignore the others.",
|
||||
|
||||
# VISUAL FIELD COMPULSORY
|
||||
"image_description: MUST be a visual description of the item in ENGLISH. Example: 'A red apple', 'Two people shaking hands'",
|
||||
"image_keywords: MUST provide 2-3 English keywords for search. Example: ['apple', 'fruit', 'red']",
|
||||
],
|
||||
|
||||
"examples": EXAMPLES if 'EXAMPLES' in globals() else []
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ============== EXAMPLES ==============
|
||||
@@ -171,3 +119,59 @@ EXAMPLES = [
|
||||
"why": "These are PHRASES/GREETINGS, not complete sentences → use 'word' field"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
# ============== CONFIG ==============
|
||||
# ============== CONFIG ==============
|
||||
GAME_CONFIG = {
|
||||
"game_type": "sequence",
|
||||
"display_name": "Arrange Sequence",
|
||||
"description": "Arrange sentences or words in order",
|
||||
"type_id": 1,
|
||||
|
||||
"active": True,
|
||||
|
||||
"max_items": 10,
|
||||
"schema": SequenceItem,
|
||||
"output_schema": SequenceOutput,
|
||||
"output_parser": output_parser,
|
||||
|
||||
"input_format_rules": [
|
||||
"Text MUST be a list of items (words, phrases, sentences) to be ordered.",
|
||||
"Do NOT generate sequence from multiple choice questions (A/B/C/D).",
|
||||
"Do NOT generate sequence if the text is a quiz or test format.",
|
||||
],
|
||||
|
||||
# 1. Recognition Rules (for Analyzer)
|
||||
"analyzer_rules": [
|
||||
"Text is a list of words, phrases, or sentences suitable for ordering",
|
||||
"Items are separated by commas, semicolons, or newlines",
|
||||
"Example: 'apple, banana, orange' or 'Sentence 1; Sentence 2'",
|
||||
"NO questions required - just a list of items",
|
||||
"Text is NOT a long essay or complex dialogue",
|
||||
],
|
||||
|
||||
# 2. Rules tạo nội dung (cho Generator)
|
||||
"generation_rules": [
|
||||
"KEEP ORIGINAL LANGUAGE - Do NOT translate",
|
||||
"Analyze text semantically to extract meaningful items",
|
||||
"For each item, decide type: WORD/PHRASE or SENTENCE",
|
||||
"- If item is a WORD/PHRASE (label, noun, greeting) -> Fill 'word' field",
|
||||
"- If item is a COMPLETE SENTENCE (subject+verb) -> Fill 'sentence' field",
|
||||
"NEVER fill both fields for the same item",
|
||||
"Set metadata.sub_type = 'word' or 'sentence' (all items should match sub_type)",
|
||||
"Clean up OCR noise, numbering (e.g. '1. Apple' -> 'Apple')",
|
||||
|
||||
# CONSISTENCY RULES
|
||||
"CRITICAL: All extracted items MUST be of the SAME type.",
|
||||
"Choose ONE type for the whole list: either ALL 'word' OR ALL 'sentence'.",
|
||||
"If input has mixed types, pick the MAJORITY type and ignore the others.",
|
||||
|
||||
# VISUAL FIELD COMPULSORY
|
||||
"image_description: MUST be a visual description of the item in ENGLISH. Example: 'A red apple', 'Two people shaking hands'",
|
||||
"image_keywords: MUST provide 2-3 English keywords for search. Example: ['apple', 'fruit', 'red']",
|
||||
],
|
||||
|
||||
"examples": EXAMPLES if 'EXAMPLES' in globals() else []
|
||||
}
|
||||
@@ -74,7 +74,7 @@ DEFAULT_CONFIGS = {
|
||||
"openai": ModelConfig(
|
||||
provider="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
temperature=0.1
|
||||
temperature=0.1,
|
||||
),
|
||||
"openai_light": ModelConfig(
|
||||
provider="openai",
|
||||
@@ -117,13 +117,19 @@ def get_llm(config: ModelConfig) -> BaseChatModel:
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
api_key = config.api_key or os.getenv("GOOGLE_API_KEY")
|
||||
print("Using GOOGLE_API_KEY:", api_key)
|
||||
if not api_key:
|
||||
raise ValueError("GOOGLE_API_KEY required for Gemini. Set via env or config.api_key")
|
||||
|
||||
return ChatGoogleGenerativeAI(
|
||||
model=config.model_name,
|
||||
temperature=config.temperature,
|
||||
google_api_key=api_key
|
||||
google_api_key=api_key,
|
||||
version="v1",
|
||||
additional_headers={
|
||||
"User-Agent": "PostmanRuntime/7.43.0",
|
||||
"Accept": "*/*"
|
||||
}
|
||||
)
|
||||
|
||||
elif provider == "openai":
|
||||
@@ -136,7 +142,8 @@ def get_llm(config: ModelConfig) -> BaseChatModel:
|
||||
return ChatOpenAI(
|
||||
model=config.model_name,
|
||||
temperature=config.temperature,
|
||||
api_key=api_key
|
||||
api_key=api_key,
|
||||
base_url=config.base_url or None
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user