From 31de8b0d844b1480d2176eba92bc2c281137933d Mon Sep 17 00:00:00 2001 From: vuongps38770 <166083538+vuongps38770@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:06:29 +0700 Subject: [PATCH] check point --- .env | 0 API.md | 186 +++++++ Dockerfile | 16 + README.md | 100 ++++ api.py | 513 ++++++++++++++++++ backup_source/match.py | 81 +++ backup_source/memory_card.py | 61 +++ backup_source/sequence_sentence.py | 127 +++++ backup_source/sequence_word.py | 134 +++++ postman_collection.json | 265 +++++++++ requirements.txt | 20 + src/__init__.py | 44 ++ src/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 782 bytes src/__pycache__/core.cpython-310.pyc | Bin 0 -> 13388 bytes src/__pycache__/game_registry.cpython-310.pyc | Bin 0 -> 7955 bytes src/__pycache__/llm_config.cpython-310.pyc | Bin 0 -> 4104 bytes src/__pycache__/validator.cpython-310.pyc | Bin 0 -> 5254 bytes src/core.py | 513 ++++++++++++++++++ src/game_registry.py | 220 ++++++++ src/games/__init__.py | 9 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 377 bytes src/games/__pycache__/base.cpython-310.pyc | Bin 0 -> 2878 bytes src/games/__pycache__/match.cpython-310.pyc | Bin 0 -> 2674 bytes .../__pycache__/memory_card.cpython-310.pyc | Bin 0 -> 2067 bytes src/games/__pycache__/quiz.cpython-310.pyc | Bin 0 -> 4107 bytes .../__pycache__/sentence.cpython-310.pyc | Bin 0 -> 1288 bytes .../__pycache__/sequence.cpython-310.pyc | Bin 0 -> 5440 bytes src/games/_template.py | 91 ++++ src/games/base.py | 85 +++ src/games/quiz.py | 139 +++++ src/games/sequence.py | 173 ++++++ src/llm_config.py | 191 +++++++ src/logger.py | 37 ++ src/validator.py | 204 +++++++ 34 files changed, 3209 insertions(+) create mode 100644 .env create mode 100644 API.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api.py create mode 100644 backup_source/match.py create mode 100644 backup_source/memory_card.py create mode 100644 backup_source/sequence_sentence.py create mode 100644 backup_source/sequence_word.py create mode 100644 postman_collection.json create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-310.pyc create mode 100644 src/__pycache__/core.cpython-310.pyc create mode 100644 src/__pycache__/game_registry.cpython-310.pyc create mode 100644 src/__pycache__/llm_config.cpython-310.pyc create mode 100644 src/__pycache__/validator.cpython-310.pyc create mode 100644 src/core.py create mode 100644 src/game_registry.py create mode 100644 src/games/__init__.py create mode 100644 src/games/__pycache__/__init__.cpython-310.pyc create mode 100644 src/games/__pycache__/base.cpython-310.pyc create mode 100644 src/games/__pycache__/match.cpython-310.pyc create mode 100644 src/games/__pycache__/memory_card.cpython-310.pyc create mode 100644 src/games/__pycache__/quiz.cpython-310.pyc create mode 100644 src/games/__pycache__/sentence.cpython-310.pyc create mode 100644 src/games/__pycache__/sequence.cpython-310.pyc create mode 100644 src/games/_template.py create mode 100644 src/games/base.py create mode 100644 src/games/quiz.py create mode 100644 src/games/sequence.py create mode 100644 src/llm_config.py create mode 100644 src/logger.py create mode 100644 src/validator.py diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/API.md b/API.md new file mode 100644 index 0000000..893f6fa --- /dev/null +++ b/API.md @@ -0,0 +1,186 @@ +# Game Generator API + +## Game Types + +| type_id | game_type | Mô tả | +|---------|-----------|-------| +| 1 | quiz | Multiple choice questions | +| 2 | sequence_sentence | Sắp xếp câu | +| 3 | sequence_word | Sắp xếp từ | + +--- + +## 1. POST /generate + +Analyze + Generate nhiều games. + +**Request:** +```json +{ + "text": "Mặt Trời là ngôi sao...", + "enabled_game_ids": [1, 2], // optional, default: all + "max_items": 3, // optional, default: 3 + "min_score": 30, // optional, default: 30 + "run_validator": true // optional, default: true +} +``` + +**Response:** +```json +{ + "success": true, + "games": [1, 2], + "game_scores": [ + {"type_id": 1, "score": 85, "reason": "..."} + ], + "results": { + "1": [{"question": "...", "answers": "...", ...}], + "2": [{"sentence": "...", ...}] + }, + "token_usage": {"prompt_tokens": 100, "completion_tokens": 50}, + "llm": "gemini/gemini-2.0-flash-lite" +} +``` + +--- + +## 2. POST /generate/single + +1 API call = Analyze + Generate 1 game tốt nhất. + +**Request:** +```json +{ + "text": "Python là ngôn ngữ...", + "enabled_game_ids": [1, 2, 3], // optional + "max_items": 3 // optional +} +``` + +**Response:** +```json +{ + "success": true, + "type_id": 1, + "reason": "Text has clear facts", + "items": [{"question": "...", ...}], + "token_usage": {...}, + "llm": "..." +} +``` + +--- + +## 3. POST /generate/{type_id} + +Generate trực tiếp 1 game (không analyze). + +### Quiz (type_id = 1) + +**Input format:** +``` +Question: Thủ đô Việt Nam? +A. Hà Nội +B. TP HCM +C. Đà Nẵng +D. Huế +Correct: A +``` + +**Request:** +```json +{ + "text": "Question: ...\\nA. ...\\nB. ...\\nCorrect: A", + "max_items": 5 +} +``` + +### Sequence Sentence (type_id = 2) + +**Input format:** +``` +sentence1; sentence2; sentence3 +``` + +**Request:** +```json +{ + "text": "Mặt trời mọc; Chim hót; Người thức dậy", + "max_items": 10 +} +``` + +### Sequence Word (type_id = 3) + +**Input format:** +``` +word1; word2; word3 +``` + +**Request:** +```json +{ + "text": "Apple; Banana; Orange; Grape", + "max_items": 10 +} +``` + +**Response (all direct):** +```json +{ + "success": true, + "type_id": 1, + "items": [...], + "token_usage": {...}, + "llm": "..." +} +``` + +--- + +## 4. GET /games + +**Response:** +```json +{ + "total": 3, + "active_count": 3, + "games": [ + {"type_id": 1, "game_type": "quiz", "display_name": "Quiz", "active": true}, + {"type_id": 2, "game_type": "sequence_sentence", ...}, + {"type_id": 3, "game_type": "sequence_word", ...} + ] +} +``` + +--- + +## 5. POST /llm + +**Request:** +```json +{ + "provider": "gemini", + "model_name": "gemini-2.0-flash-lite", + "temperature": 0.1 +} +``` + +Ollama: +```json +{ + "provider": "ollama", + "model_name": "qwen2.5:14b", + "base_url": "http://localhost:11434" +} +``` + +--- + +## 6. Other Endpoints + +- `GET /llm` - Xem LLM config hiện tại +- `POST /reload` - Reload game definitions +- `GET /health` - Health check +- `POST /games/{game_type}/activate` - Bật game +- `POST /games/{game_type}/deactivate` - Tắt game diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94beb07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY . . + +# Expose port +EXPOSE 2009 + +# Start command +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "2009"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ba5a19 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# GAME GENERATOR + +## 🚀 TỐI ƯU API CALLS + +| Trước | Sau | +|-------|-----| +| Analyzer: 1 call | Analyzer: 1 call | +| Generate: N calls (1 per game) | Generate: **1 call** (tất cả games) | +| Validator: N calls | Validator: **0 call** (Python) | +| **Tổng: N+1 calls** | **Tổng: 1-2 calls** | + +--- + +## 📁 Cấu trúc + +``` +gen_using_graph/ +├── api.py # FastAPI server +├── requirements.txt +│ +└── src/ + ├── core.py # Core engine (tối ưu API calls) + ├── game_registry.py # Auto-load games + ├── validator.py # Hallucination check (không dùng API) + │ + └── games/ # Game definitions + ├── _template.py # Template + ├── quiz.py # Quiz game + └── fill_blank.py # Fill-blank game +``` + +--- + +## 🔌 API ENDPOINTS + +### Generate games +```bash +POST /generate +{ + "text": "Văn bản...", + "enabled_games": ["quiz", "fill_blank"], + "run_analyzer": true, + "run_validator": true, + "max_items": 3 +} + +# Response includes: +# "api_calls": 2 <-- Số lần gọi LLM +``` + +### Xem games +```bash +GET /games +``` + +### Bật/Tắt game +```bash +POST /games/quiz/activate +POST /games/quiz/deactivate +``` + +### Reload +```bash +POST /reload +``` + +--- + +## 🎮 THÊM GAME MỚI + +1. Copy `src/games/_template.py` → `src/games/new_game.py` +2. Sửa nội dung +3. Gọi `POST /reload` + +--- + +## ✅ BẬT/TẮT GAME + +```python +# Trong file game +GAME_CONFIG = { + "active": True, # Bật + "active": False, # Tắt +} +``` + +Hoặc qua API: +```bash +curl -X POST http://localhost:8000/games/quiz/deactivate +``` + +--- + +## 🚀 Chạy + +```bash +pip install -r requirements.txt +export GOOGLE_API_KEY=your_key +uvicorn api:app --port 8000 +``` diff --git a/api.py b/api.py new file mode 100644 index 0000000..e4fddca --- /dev/null +++ b/api.py @@ -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) diff --git a/backup_source/match.py b/backup_source/match.py new file mode 100644 index 0000000..54241ea --- /dev/null +++ b/backup_source/match.py @@ -0,0 +1,81 @@ +""" +games/match.py - Match Game - Match sentences with images +""" +from typing import List +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") + + +class MatchOutput(BaseModel): + """Output wrapper for match items""" + items: List[MatchItem] = Field(description="List of match items generated from source text") + + +# Output parser +output_parser = PydanticOutputParser(pydantic_object=MatchOutput) + + +# ============== CONFIG ============== +GAME_CONFIG = { + "game_type": "match", + "display_name": "Match with Image", + "description": "Match sentences with images", + + "active": True, + + "min_items": 2, + "max_items": 10, + "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""", +} + + +# ============== EXAMPLES ============== +EXAMPLES = [ + { + "input": "The Sun is a star. The Moon orbits Earth.", + "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": "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 + } + ] + }, + "why_suitable": "Has distinct concepts that can be visualized and matched" + } +] diff --git a/backup_source/memory_card.py b/backup_source/memory_card.py new file mode 100644 index 0000000..1d2d6f1 --- /dev/null +++ b/backup_source/memory_card.py @@ -0,0 +1,61 @@ +""" +games/memory_card.py - Memory Card Game - Flip cards to find pairs +""" +from typing import List +from pydantic import BaseModel, Field +from langchain_core.output_parsers import PydanticOutputParser + + +# ============== SCHEMA ============== +class MemoryCardItem(BaseModel): + name: str = Field(description="Card content/label") + pair_id: str = Field(description="ID to match pairs (same pair_id = matching cards)") + original_quote: str = Field(description="EXACT quote from source text") + image_description: str = Field(default="", description="Visual description for the card") + image_is_complex: bool = Field(default=False, description="True if image needs precise quantities, humans, or multiple detailed objects") + + +class MemoryCardOutput(BaseModel): + """Output wrapper for memory card items""" + items: List[MemoryCardItem] = Field(description="List of memory card items generated from source text") + + +# Output parser +output_parser = PydanticOutputParser(pydantic_object=MemoryCardOutput) + + +# ============== CONFIG ============== +GAME_CONFIG = { + "game_type": "memory_card", + "display_name": "Memory Card", + "description": "Flip cards to find pairs", + + "active": False, # Disabled + + "min_items": 4, + "max_items": 10, + "schema": MemoryCardItem, + "output_schema": MemoryCardOutput, + "output_parser": output_parser, + + "system_prompt": """Create memory card pairs. +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. ALL content must come from the source text only""", +} + + +# ============== EXAMPLES ============== +EXAMPLES = [ + { + "input": "The Sun is a star.", + "output": { + "items": [ + {"name": "The Sun", "pair_id": "p1", "original_quote": "The Sun is a star.", "image_description": "A bright sun", "image_is_complex": False}, + {"name": "a star", "pair_id": "p1", "original_quote": "The Sun is a star.", "image_description": "A glowing star", "image_is_complex": False} + ] + }, + "why_suitable": "Has concept pairs" + } +] diff --git a/backup_source/sequence_sentence.py b/backup_source/sequence_sentence.py new file mode 100644 index 0000000..5fb4a02 --- /dev/null +++ b/backup_source/sequence_sentence.py @@ -0,0 +1,127 @@ +""" +games/sequence_sentence.py - Arrange Sentences Game +type_id = 2 +""" +from typing import List +from pydantic import BaseModel, Field +from langchain_core.output_parsers import PydanticOutputParser + + +# ============== SCHEMA ============== +class SentenceItem(BaseModel): + sentence: str = Field(description="Full sentence to arrange (EXACT from source)") + original_quote: str = Field(description="EXACT quote from source text") + image_description: str = Field(default="", description="Visual description of the content") + 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") + + +class SentenceMetadata(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." + ) + grade: int = Field( + description="Estimated grade level 1-5 (1=easy/young, 5=advanced/older). Judge by vocabulary, concepts." + ) + type: str = Field(default="sequence_sentence", description="Game type") + difficulty: int = Field( + description="Difficulty 1-5 for that grade (1=very easy, 5=very hard). Judge by sentence complexity, vocabulary." + ) + + +class SentenceOutput(BaseModel): + """Output wrapper for sentence items""" + items: List[SentenceItem] = Field(description="List of sentence items generated from source text") + metadata: SentenceMetadata = Field(description="Metadata about the content") + + +# Output parser +output_parser = PydanticOutputParser(pydantic_object=SentenceOutput) + + +# ============== CONFIG ============== +GAME_CONFIG = { + "game_type": "sequence_sentence", + "display_name": "Arrange Sentences", + "description": "Arrange sentences in order", + "type_id": 2, + + "active": True, + + "max_items": 10, + "schema": SentenceItem, + "output_schema": SentenceOutput, + "output_parser": output_parser, + + # Dùng cho analyze + generate (không có format rules) + "system_prompt": """Extract sentences from source text. + +RULES: +1. KEEP THE ORIGINAL LANGUAGE - Do NOT translate +2. sentence = EXACT copy from source text +3. original_quote = same as sentence value +4. image_description = ALWAYS provide a short visual description (NEVER empty) +5. image_is_complex = FALSE for simple/static objects, TRUE for quantities/humans/complex scenes""", + + # Dùng cho generate trực tiếp (CÓ format rules) + "direct_prompt": """Extract sentences from source text. + +EXPECTED INPUT: List of sentences (separated by semicolon, newline, or similar) + +STEP 1 - VALIDATE INPUT: +Analyze if input looks like a list of sentences suitable for "arrange sentences" game. +- Should contain multiple complete sentences +- Should NOT be a quiz, single word list, or Q&A format + +If input is clearly NOT suitable (e.g. it's a quiz, single words only, or wrong format), return: +{{"items": [], "format_error": "Input không phù hợp cho game sắp xếp câu"}} + +STEP 2 - EXTRACT (if valid): +RULES: +1. KEEP THE ORIGINAL LANGUAGE - Do NOT translate +2. Extract ALL sentences from source +3. sentence = EXACT sentence from source (trim whitespace) +4. original_quote = same as sentence value +5. image_description = ALWAYS provide a short visual description (NEVER leave empty) +6. image_is_complex: + - FALSE: simple objects, static things, no specific quantities (e.g. "a sun", "a tree") + - TRUE: needs exact quantities, humans/people, or complex details (e.g. "3 birds", "a boy reading")""", +} + + +# ============== EXAMPLES ============== +EXAMPLES = [ + { + "input": "The Sun is a star; The Moon orbits Earth; Mars is red", + "output": { + "items": [ + { + "sentence": "The Sun is a star", + "original_quote": "The Sun is a star", + "image_description": "A bright glowing sun", + "image_keywords": ["sun", "star"], + "image_is_complex": False + }, + { + "sentence": "The Moon orbits Earth", + "original_quote": "The Moon orbits Earth", + "image_description": "Moon circling around Earth", + "image_keywords": ["moon", "earth", "orbit"], + "image_is_complex": False + }, + { + "sentence": "Mars is red", + "original_quote": "Mars is red", + "image_description": "Red planet Mars", + "image_keywords": ["mars", "red", "planet"], + "image_is_complex": False + } + ] + }, + "why_suitable": "Source has sentences separated by semicolons" + } +] diff --git a/backup_source/sequence_word.py b/backup_source/sequence_word.py new file mode 100644 index 0000000..f6e6a38 --- /dev/null +++ b/backup_source/sequence_word.py @@ -0,0 +1,134 @@ +""" +games/sequence_word.py - Arrange Words Game +type_id = 3 +""" +from typing import List +from pydantic import BaseModel, Field +from langchain_core.output_parsers import PydanticOutputParser + + +# ============== SCHEMA ============== +class WordItem(BaseModel): + word: str = Field(description="Word or phrase to arrange (EXACT from source)") + original_quote: str = Field(description="EXACT quote from source text") + image_description: str = Field(default="", description="Visual description of the content") + 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") + + +class WordMetadata(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." + ) + grade: int = Field( + description="Estimated grade level 1-5 (1=easy/young, 5=advanced/older). Judge by vocabulary complexity." + ) + type: str = Field(default="sequence_word", description="Game type") + difficulty: int = Field( + description="Difficulty 1-5 for that grade (1=very easy, 5=very hard). Judge by word complexity, number of items." + ) + + +class WordOutput(BaseModel): + """Output wrapper for word items""" + items: List[WordItem] = Field(description="List of word items generated from source text") + metadata: WordMetadata = Field(description="Metadata about the content") + + +# Output parser +output_parser = PydanticOutputParser(pydantic_object=WordOutput) + + +# ============== CONFIG ============== +GAME_CONFIG = { + "game_type": "sequence_word", + "display_name": "Arrange Words", + "description": "Arrange words or phrases in order", + "type_id": 3, + + "active": True, + + "max_items": 10, + "schema": WordItem, + "output_schema": WordOutput, + "output_parser": output_parser, + + # Dùng cho analyze + generate (không có format rules) + "system_prompt": """Extract words or phrases from source text. + +RULES: +1. KEEP THE ORIGINAL LANGUAGE - Do NOT translate +2. word = EXACT copy from source text +3. original_quote = same as word value +4. image_description = ALWAYS provide a short visual description (NEVER empty) +5. image_is_complex = FALSE for simple/static objects, TRUE for quantities/humans/complex scenes""", + + # Dùng cho generate trực tiếp (CÓ format rules) + "direct_prompt": """Extract words or phrases from source text. + +EXPECTED INPUT: List of words/phrases (separated by semicolon, comma, newline, or similar) + +STEP 1 - VALIDATE INPUT: +Analyze if input looks like a list of words/phrases suitable for "arrange words" game. +- Should contain multiple short words or phrases +- Should NOT be a paragraph, essay, or Q&A format + +If input is clearly NOT suitable (e.g. it's a quiz, a long paragraph, or wrong format), return: +{{"items": [], "format_error": "Input không phù hợp cho game sắp xếp từ"}} + +STEP 2 - EXTRACT (if valid): +RULES: +1. KEEP THE ORIGINAL LANGUAGE - Do NOT translate +2. Extract ALL words/phrases from source +3. word = EXACT word/phrase from source (trim whitespace) +4. original_quote = same as word value +5. image_description = ALWAYS provide a short visual description (NEVER leave empty) +6. image_is_complex: + - FALSE: simple objects, static things, no specific quantities (e.g. "an apple", "a book") + - TRUE: needs exact quantities, humans/people, or complex details (e.g. "5 oranges", "a woman cooking")""", +} + + +# ============== EXAMPLES ============== +EXAMPLES = [ + { + "input": "Apple; Banana; Orange; Grape", + "output": { + "items": [ + { + "word": "Apple", + "original_quote": "Apple", + "image_description": "A red apple", + "image_keywords": ["apple"], + "image_is_complex": False + }, + { + "word": "Banana", + "original_quote": "Banana", + "image_description": "A yellow banana", + "image_keywords": ["banana"], + "image_is_complex": False + }, + { + "word": "Orange", + "original_quote": "Orange", + "image_description": "An orange fruit", + "image_keywords": ["orange"], + "image_is_complex": False + }, + { + "word": "Grape", + "original_quote": "Grape", + "image_description": "Purple grapes", + "image_keywords": ["grape"], + "image_is_complex": False + } + ] + }, + "why_suitable": "Source has words separated by semicolons" + } +] diff --git a/postman_collection.json b/postman_collection.json new file mode 100644 index 0000000..31a028b --- /dev/null +++ b/postman_collection.json @@ -0,0 +1,265 @@ +{ + "info": { + "name": "Game Generator API", + "description": "API tạo game giáo dục từ văn bản", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "📊 Generate Multi", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Mặt Trời là ngôi sao ở trung tâm của Hệ Mặt Trời.\",\n \"enabled_game_ids\": [1, 2],\n \"max_items\": 3\n}" + }, + "url": { + "raw": "http://localhost:8000/generate", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "generate" + ] + }, + "description": "Analyze + Generate nhiều games\n\nREQUEST:\n• text (required)\n• enabled_game_ids: [1,2,3] (optional)\n• max_items: 3 (optional)\n• min_score: 30 (optional)\n• run_validator: true (optional)\n\nRESPONSE:\n• games: [1, 2]\n• results: {1: [...], 2: [...]}" + } + }, + { + "name": "🎯 Generate Single Best", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Python là ngôn ngữ lập trình phổ biến.\",\n \"max_items\": 3\n}" + }, + "url": { + "raw": "http://localhost:8000/generate/single", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "generate", + "single" + ] + }, + "description": "1 API call = Analyze + Generate 1 game tốt nhất\n\nRESPONSE:\n• type_id: 1\n• reason: \"...\"\n• items: [...]" + } + }, + { + "name": "🎮 Direct Quiz (type_id=1)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Question: Thủ đô Việt Nam?\\nA. Hà Nội\\nB. TP HCM\\nC. Đà Nẵng\\nD. Huế\\nCorrect: A\",\n \"max_items\": 5\n}" + }, + "url": { + "raw": "http://localhost:8000/generate/1", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "generate", + "1" + ] + }, + "description": "Generate Quiz trực tiếp\n\nINPUT FORMAT:\nQuestion: ...\nA. ...\nB. ...\nC. ...\nD. ...\nCorrect: A" + } + }, + { + "name": "🎮 Direct Sentence (type_id=2)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Mặt trời mọc; Chim hót; Người thức dậy\",\n \"max_items\": 10\n}" + }, + "url": { + "raw": "http://localhost:8000/generate/2", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "generate", + "2" + ] + }, + "description": "Generate Arrange Sentences trực tiếp\n\nINPUT FORMAT:\nsentence1; sentence2; sentence3" + } + }, + { + "name": "🎮 Direct Word (type_id=3)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Apple; Banana; Orange; Grape\",\n \"max_items\": 10\n}" + }, + "url": { + "raw": "http://localhost:8000/generate/3", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "generate", + "3" + ] + }, + "description": "Generate Arrange Words trực tiếp\n\nINPUT FORMAT:\nword1; word2; word3" + } + }, + { + "name": "📋 List Games", + "request": { + "method": "GET", + "url": { + "raw": "http://localhost:8000/games", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "games" + ] + }, + "description": "Danh sách games\n\nRESPONSE:\n[\n {type_id: 1, game_type: \"quiz\", ...},\n {type_id: 2, ...},\n {type_id: 3, ...}\n]" + } + }, + { + "name": "⚙️ Get LLM", + "request": { + "method": "GET", + "url": { + "raw": "http://localhost:8000/llm", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "llm" + ] + } + } + }, + { + "name": "⚙️ Set LLM - Gemini", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"provider\": \"gemini\",\n \"model_name\": \"gemini-2.0-flash-lite\"\n}" + }, + "url": { + "raw": "http://localhost:8000/llm", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "llm" + ] + } + } + }, + { + "name": "⚙️ Set LLM - Ollama", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"provider\": \"ollama\",\n \"model_name\": \"qwen2.5:14b\",\n \"base_url\": \"http://localhost:11434\"\n}" + }, + "url": { + "raw": "http://localhost:8000/llm", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "llm" + ] + } + } + }, + { + "name": "🔄 Reload Games", + "request": { + "method": "POST", + "url": { + "raw": "http://localhost:8000/reload", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "reload" + ] + } + } + }, + { + "name": "❤️ Health", + "request": { + "method": "GET", + "url": { + "raw": "http://localhost:8000/health", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "health" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2d32f14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Game Generator - Dependencies + +# LangChain Core +langchain>=0.1.0 +langchain-core>=0.1.0 + +# LLM Providers +langchain-google-genai>=1.0.0 +langchain-openai>=0.0.5 +langchain-ollama>=0.1.0 + +# Pydantic +pydantic>=2.0.0 + +# API Server +fastapi>=0.100.0 +uvicorn>=0.23.0 + +# Utilities +python-dotenv>=1.0.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..8f811b0 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,44 @@ +""" +src - Game Generator Core Package +""" +from src.core import GameCore +from src.game_registry import ( + GameRegistry, + get_registry, + reload_games, + get_active_game_types, + get_active_type_ids, + get_game_by_id, + get_game, + id_to_type, + type_to_id +) +from src.llm_config import ModelConfig, get_llm, get_default_config, create_config +from src.validator import QuoteValidator, quick_validate + + +__all__ = [ + # Core + "GameCore", + + # Registry + "GameRegistry", + "get_registry", + "reload_games", + "get_active_game_types", + "get_active_type_ids", + "get_game_by_id", + "get_game", + "id_to_type", + "type_to_id", + + # LLM Config + "ModelConfig", + "get_llm", + "get_default_config", + "create_config", + + # Validator + "QuoteValidator", + "quick_validate", +] diff --git a/src/__pycache__/__init__.cpython-310.pyc b/src/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..227e8be466d222dc87cdc41ac3c892d127470e77 GIT binary patch literal 782 zcmb7>&uiN-6vySni59csYXAC6F!!8r(gWgx~N$-a#ij0KyUA&cUNXQQ~95^N^H2?S46z=;NRMHx6G(Ilsh+?@XOQVBOmz-kGsM~LYynG;|LM88fi6Vy4 zulpeQ^xpQFXA<<<))z&($M+SCbYBv#V^q8Yrk*ge|-!k#)r}m<^3xWDA4Cnp0R)P!lal| zlzt<2g~=j*J%96}Zrr}^=U8`Q!>DC#-HJ^=qZ9V54)4{J%8hiCo^JO(eW?xKDe)=_ T4|LdznB>E!(c|en%7?!J3vtsw literal 0 HcmV?d00001 diff --git a/src/__pycache__/core.cpython-310.pyc b/src/__pycache__/core.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..15eb764516d4e0bb59f8f433a4b9a4b8bd91cf39 GIT binary patch literal 13388 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 Ln(|Q5iwFM&q}dkT literal 0 HcmV?d00001 diff --git a/src/__pycache__/validator.cpython-310.pyc b/src/__pycache__/validator.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..843197ff1b2615f5326e1dcf8f7ac9d271433861 GIT binary patch literal 5254 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 Dict[str, int]: + return {"prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, "total_tokens": self.total_tokens} + + +class GameCore: + """ + Simple Game Generator. + + Usage: + core = GameCore() + + # 1. Generate nhiều games (analyze first) + result = core.run_multi(text) + + # 2. Generate 1 game tốt nhất (1 API call) + result = core.run_single(text) + + # 3. Generate 1 game cụ thể + result = core.generate("quiz", text) + """ + + def __init__(self, llm_config: Optional[Union[ModelConfig, Dict, str]] = None): + self.llm_config = self._parse_config(llm_config) + self.llm = get_llm(self.llm_config) + self.validator = QuoteValidator() + self.registry = get_registry() + print(f"🤖 LLM: {self.llm_config.provider}/{self.llm_config.model_name}") + + def _parse_config(self, config) -> ModelConfig: + if config is None: + if os.getenv("GOOGLE_API_KEY"): + return get_default_config("gemini") + elif os.getenv("OPENAI_API_KEY"): + return get_default_config("openai") + return get_default_config("ollama") + + if isinstance(config, ModelConfig): + return config + if isinstance(config, str): + return get_default_config(config) + if isinstance(config, dict): + return ModelConfig(**config) + raise ValueError(f"Invalid config: {type(config)}") + + # ============== 1. RUN MULTI (Analyze + Generate nhiều games) ============== + + def run_multi( + self, + text: str, + enabled_games: Optional[List[str]] = None, + max_items: int = 3, + min_score: int = 20, + validate: bool = True, + debug: bool = False + ) -> Dict[str, Any]: + """ + Analyze text + Generate nhiều games phù hợp. + + Returns: {success, games, results, errors, token_usage, llm} + """ + tracker = TokenUsage() + errors = [] + + # 1. Analyze (also returns metadata) + available = enabled_games or self.registry.get_game_types() + logger.info(f"Analyzing text for multi-gen. Available games: {available}") + games, scores, metadata, err = self._analyze(text, available, min_score, tracker, debug) + errors.extend(err) + + if not games: + logger.warning("Analyzer found no suitable games matches.") + return self._result(False, [], {}, errors, tracker, metadata=metadata) + + logger.info(f"Analyzer selected: {games}") + + # 2. Generate + results, err = self._generate_multi(games, text, max_items, tracker, debug) + errors.extend(err) + + # 3. Validate + if validate: + results = self._validate(results, text) + + # Check if any game has items + has_items = any(data.get("items", []) for data in results.values() if isinstance(data, dict)) + return self._result(has_items, games, results, errors, tracker, scores, metadata) + + # ============== 2. RUN SINGLE (1 API call: Analyze + Generate 1 game) ============== + + def run_single( + self, + text: str, + enabled_games: Optional[List[str]] = None, + max_items: int = 3, + validate: bool = True, + debug: bool = False + ) -> Dict[str, Any]: + """ + 1 API call: Analyze + Generate game tốt nhất. + + Returns: {success, game_type, reason, items, errors, token_usage, llm} + """ + tracker = TokenUsage() + available = enabled_games or self.registry.get_game_types() + logger.info(f"Starting run_single for available games: {available}") + + # Build games info + games_info = [] + for gt in available: + game = get_game(gt) + if game: + example = json.dumps(game.examples[0].get('output', {}), ensure_ascii=False, indent=2) if game.examples else "{}" + games_info.append(f"### {gt}\n{game.description}\nExample output:\n{example}") + + prompt = ChatPromptTemplate.from_messages([ + ("system", """You are an educational game generator. +1. ANALYZE text and CHOOSE the BEST game type +2. GENERATE items for that game + +RULES: +- KEEP original language +- original_quote = EXACT copy from source +- ALL content from source only"""), + ("human", """GAMES: +{games_info} + +TEXT: +{text} + +Choose BEST game from: {types} +Generate max {max_items} items. + +Return JSON: +{{"game_type": "chosen", "reason": "why", "items": [...]}}""") + ]) + + content = {"games_info": "\n\n".join(games_info), "text": text[:2000], "types": ", ".join(available), "max_items": max_items} + + if debug: + print(f"\n{'='*50}\n🎯 RUN SINGLE\n{'='*50}") + + try: + resp = (prompt | self.llm).invoke(content) + tracker.add(self._get_usage(resp)) + + data = self._parse_json(resp.content) + game_type = data.get("game_type") + items = self._post_process(data.get("items", []), game_type) + + if validate and items: + items = [i for i in items if self.validator.validate_quote(i.get("original_quote", ""), text).is_valid] + + return { + "success": len(items) > 0, + "game_type": game_type, + "reason": data.get("reason", ""), + "items": items, + "errors": [], + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } + except Exception as e: + return {"success": False, "game_type": None, "items": [], "errors": [str(e)], "token_usage": tracker.to_dict(), "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}"} + + # ============== 3. GENERATE (1 game cụ thể, không analyze) ============== + + def generate( + self, + game_type: str, + text: str, + max_items: int = 3, + validate: bool = True, + debug: bool = False + ) -> Dict[str, Any]: + """Generate 1 game cụ thể""" + tracker = TokenUsage() + logger.info(f"Generating single game content: {game_type}") + + game = get_game(game_type) + + if not game: + return {"success": False, "game_type": game_type, "items": [], "errors": [f"Game not found: {game_type}"], "token_usage": {}, "llm": ""} + + # Build Format Rules Section + format_rules_section = "" + if game.input_format_rules: + rules_str = "\n".join(f"- {r}" for r in game.input_format_rules) + format_rules_section = f""" +CRITICAL: FIRST, VALIDATE THE INPUT TEXT. +Format Rules: +{rules_str} + +If the text is completely UNSUITABLE for this game type, you MUST output strictly this JSON and nothing else: +{{{{ "format_error": "Input text incompatible with game requirements." }}}} +""" + + prompt = ChatPromptTemplate.from_messages([ + ("system", f"""{game.generated_system_prompt} +{format_rules_section}"""), + ("human", """TEXT TO PROCESS: +{text} + +Generate content in JSON format: +{format_instructions}""") + ]) + + if debug: + print(f"\n{'='*50}\n🎮 GENERATE: {game_type}\n{'='*50}") + + try: + resp = (prompt | self.llm).invoke({ + "text": text, + "format_instructions": game.format_instructions + }) + tracker.add(self._get_usage(resp)) + + # 1. Parse as raw JSON first to check for format_error + raw_data = None + try: + raw_data = self._parse_json(resp.content) + except: + pass + + # 2. Check if it's a format_error immediately + if raw_data and raw_data.get("format_error"): + return { + "success": False, + "game_type": game_type, + "data": None, + "format_error": raw_data["format_error"], + "errors": [raw_data["format_error"]], + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } + + parsed_data = raw_data + + # 3. Try output_parser for structured validation if present + if game.output_parser: + try: + parsed = game.output_parser.parse(resp.content) + parsed_data = parsed.model_dump() + except Exception as pe: + if debug: print(f"⚠️ output_parser failed: {pe}") + # Keep raw_data if parser fails but we have JSON + + + # Check format error + if parsed_data and parsed_data.get("format_error"): + return { + "success": False, + "game_type": game_type, + "data": None, + "format_error": parsed_data["format_error"], + "errors": [parsed_data["format_error"]], + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } + + # Post-process + items = parsed_data.get("items", []) if parsed_data else [] + items = self._post_process(items, game_type) + + if validate and items: + items = [i for i in items if self.validator.validate_quote(i.get("original_quote", ""), text).is_valid] + + if not items: + return { + "success": False, + "game_type": game_type, + "data": None, + "format_error": "No items extracted", + "errors": [], + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } + + if parsed_data: + parsed_data["items"] = items + + return { + "success": True, + "game_type": game_type, + "data": parsed_data, + "errors": [], + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } + except Exception as e: + return {"success": False, "game_type": game_type, "data": None, "errors": [str(e)], "token_usage": tracker.to_dict(), "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}"} + + # ============== PRIVATE METHODS ============== + + def _analyze(self, text: str, available: List[str], min_score: int, tracker: TokenUsage, debug: bool) -> tuple: + """Analyze text để suggest games - với retry""" + # Lấy context từ game configs + context = get_analyzer_context() + + prompt = ChatPromptTemplate.from_messages([ + ("system", """You are a game type analyzer. Score each game 0-100 based on how well the text matches the game requirements. + +GAME REQUIREMENTS: +{context} + +SCORING: +- 70-100: Text matches game requirements well +- 40-69: Partial match +- 0-39: Does not match requirements + +IMPORTANT: You MUST use the exact game type name (e.g. 'quiz', 'sequence') in the "type" field. + +Return valid JSON with scores AND metadata about the content: +{{ + "scores": [ + {{ + "type": "NAME_OF_GAME_TYPE", + "score": 80, + "reason": "..." + }} + ], + "metadata": {{ + "title": "Title from source or create short title", + "description": "One sentence summary", + "grade": 1-5, + "difficulty": 1-5 + }} +}}"""), + ("human", """TEXT TO ANALYZE: +{text} + +Analyze for games: {types} +Return JSON:""") + ]) + + max_retries = 2 + for attempt in range(max_retries): + try: + resp = (prompt | self.llm).invoke({ + "context": context, + "text": text[:800], + "types": ", ".join(available) + }) + tracker.add(self._get_usage(resp)) + + if debug: + print(f"📝 Analyzer raw: {resp.content[:300]}") + + # Parse JSON với fallback + content = resp.content.strip() + if not content: + if debug: + print(f"⚠️ Empty response, retry {attempt + 1}") + continue + + data = self._parse_json(content) + scores = [s for s in data.get("scores", []) if s.get("type") in available and s.get("score", 0) >= min_score] + scores.sort(key=lambda x: x.get("score", 0), reverse=True) + + # Extract metadata from response + metadata = data.get("metadata", {}) + + if debug: + print(f"🔍 Scores: {scores}") + print(f"📋 Metadata: {metadata}") + + return [s["type"] for s in scores], scores, metadata, [] + + except Exception as e: + if debug: + print(f"⚠️ Analyze attempt {attempt + 1} failed: {e}") + if attempt == max_retries - 1: + # Final fallback: return all games với low score + return available, [], {}, [f"Analyze error: {e}"] + + return available, [], {}, ["Analyze failed after retries"] + + def _generate_multi(self, games: List[str], text: str, max_items: int, tracker: TokenUsage, debug: bool) -> tuple: + """Generate nhiều games""" + if len(games) == 1: + result = self.generate(games[0], text, max_items, validate=False, debug=debug) + tracker.add(result.get("token_usage", {})) + # Fix: generate returns {data: {items: [...]}} not {items: [...]} + data = result.get("data") or {} + items = data.get("items", []) if isinstance(data, dict) else [] + return {games[0]: {"items": items, "metadata": data.get("metadata")}}, result.get("errors", []) + + # Multi-game: Build schema info for each game + games_schema = [] + for gt in games: + game = get_game(gt) + if game: + games_schema.append(f"""### {gt.upper()} +{game.generated_system_prompt} + +REQUIRED OUTPUT FORMAT: +{game.format_instructions}""") + + prompt = ChatPromptTemplate.from_messages([ + ("system", """You are a multi-game content generator. +Generate items for EACH game type following their EXACT schema. +IMPORTANT: Include ALL required fields for each item (image_description, image_keywords, etc.) +RULES: Keep original language, use exact quotes from text."""), + ("human", """GAMES AND THEIR SCHEMAS: +{schemas} + +SOURCE TEXT: +{text} + +Generate items for: {types} +Return valid JSON: {{{format}}}""") + ]) + + fmt = ", ".join([f'"{gt}": {{"items": [...], "metadata": {{...}}}}' for gt in games]) + + try: + resp = (prompt | self.llm).invoke({ + "schemas": "\n\n".join(games_schema), + "text": text, + "types": ", ".join(games), + "format": fmt + }) + tracker.add(self._get_usage(resp)) + + data = self._parse_json(resp.content) + results = {} + errors = [] + for gt in games: + game_data = data.get(gt, {}) if isinstance(data.get(gt), dict) else {} + items = game_data.get("items", []) + items = self._post_process(items, gt) + # Thống nhất structure: {items: [...], metadata: {...}} + results[gt] = {"items": items, "metadata": game_data.get("metadata")} + if not items: + errors.append(f"No items for {gt}") + + return results, errors + except Exception as e: + return {gt: {"items": [], "metadata": None} for gt in games}, [f"Generate error: {e}"] + + def _validate(self, results: Dict[str, dict], text: str) -> Dict[str, dict]: + """Validate items trong results""" + validated = {} + for gt, data in results.items(): + items = data.get("items", []) if isinstance(data, dict) else [] + valid_items = [i for i in items if self.validator.validate_quote(i.get("original_quote", ""), text).is_valid] + validated[gt] = {"items": valid_items, "metadata": data.get("metadata") if isinstance(data, dict) else None} + return validated + + def _post_process(self, items: List, game_type: str) -> List[Dict]: + ms = int(time.time() * 1000) + result = [] + for i, item in enumerate(items): + d = item if isinstance(item, dict) else (item.model_dump() if hasattr(item, 'model_dump') else {}) + d["id"] = f"{game_type[:2].upper()}-{ms}-{i}" + d["game_type"] = game_type + result.append(d) + return result + + def _parse_json(self, content: str) -> Dict: + if "```" in content: + content = content.split("```")[1].replace("json", "").strip() + return json.loads(content) + + def _get_usage(self, resp) -> Dict: + if hasattr(resp, 'response_metadata'): + meta = resp.response_metadata + return meta.get('usage', meta.get('usage_metadata', meta.get('token_usage', {}))) + return getattr(resp, 'usage_metadata', {}) + + def _result(self, success: bool, games: List, results: Dict, errors: List, tracker: TokenUsage, scores: List = None, metadata: Dict = None) -> Dict: + return { + "success": success, + "games": games, + "game_scores": scores or [], + "metadata": metadata or {}, + "results": results, + "errors": errors, + "token_usage": tracker.to_dict(), + "llm": f"{self.llm_config.provider}/{self.llm_config.model_name}" + } diff --git a/src/game_registry.py b/src/game_registry.py new file mode 100644 index 0000000..b175b7b --- /dev/null +++ b/src/game_registry.py @@ -0,0 +1,220 @@ +""" +game_registry.py - Tự động load games từ thư mục games/ + +Hệ thống sẽ: +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 + +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 +from src.games.base import GameType, create_game_type + + +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: + 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})") + 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() + self._id_map.clear() + 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""" + game = self._all_games.get(game_type) + return game.type_id if game else 0 + + 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] + + 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 + if hints: + hints_text = "\n - ".join(hints) + context_parts.append( + f"**{game.display_name}** (id={game.type_id}):\n" + 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) + return game.active if game else False + + +# ============== GLOBAL FUNCTIONS ============== +_registry: Optional[GameRegistry] = None + + +def get_registry() -> GameRegistry: + global _registry + if _registry is None: + _registry = GameRegistry() + return _registry + + +def reload_games(): + """Reload tất cả games (gọi sau khi thêm/sửa game)""" + get_registry().reload() + + +def get_game(game_type: str) -> Optional[GameType]: + return get_registry().get_game(game_type) + + +def get_active_game_types() -> List[str]: + return get_registry().get_game_types() + + +def get_analyzer_context() -> str: + return get_registry().get_analyzer_context() + + +def list_all_games() -> None: + """In danh sách tất cả games và trạng thái""" + registry = get_registry() + print("\n📋 DANH SÁCH GAMES:") + print("-" * 50) + for game_type, game in registry.get_all_games_including_inactive().items(): + status = "✅ ACTIVE" if game.active else "⏸️ INACTIVE" + print(f" [{game.type_id}] {game.display_name} ({game_type}): {status}") + print("-" * 50) + + +def get_game_by_id(type_id: int) -> Optional[GameType]: + """Lấy game by type_id""" + return get_registry().get_game_by_id(type_id) + + +def get_active_type_ids() -> List[int]: + """Lấy danh sách type_ids active""" + return get_registry().get_type_ids() + + +def id_to_type(type_id: int) -> Optional[str]: + """Convert type_id -> game_type""" + return get_registry().get_game_type_by_id(type_id) + + +def type_to_id(game_type: str) -> int: + """Convert game_type -> type_id""" + return get_registry().get_id_by_game_type(game_type) diff --git a/src/games/__init__.py b/src/games/__init__.py new file mode 100644 index 0000000..4366bd7 --- /dev/null +++ b/src/games/__init__.py @@ -0,0 +1,9 @@ +""" +games/ - Game type definitions + +Mỗi game là 1 file với GAME_CONFIG dict. +Thêm game mới = thêm file mới. +""" +from .base import GameType, create_game_type + +__all__ = ["GameType", "create_game_type"] diff --git a/src/games/__pycache__/__init__.cpython-310.pyc b/src/games/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33ee7b4d7ef10b0c5db324b2ad9de4a0268512e5 GIT binary patch literal 377 zcmYjM%}T>S5Z-LkAA*HG!d!c3)KfuJ6n}z3>%pEvU|6%;xR9Tc-3oaU550KRSLm^C z5%5qv`3j!gjUGC%-|WnMGv7=(82H54gx+BaM4P^qoEybvm{fvk zfmR0X4wb#7at+Kzou-G#%LK9Uo|xLJ*#gTl#CKBlY=ecL$@NK`=sLE;RIEh7uwrSP nilUuAW=FBAlK(QIJryxn)=mF3FZn7H=XO8UHQ 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 literal 0 HcmV?d00001 diff --git a/src/games/__pycache__/memory_card.cpython-310.pyc b/src/games/__pycache__/memory_card.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3630a7eaed892554dc50ea47cd2133bff356917f GIT binary patch literal 2067 zcmZ`(&2Jk;6yII1*Xy<8I3cuYX~i5sswHq$z7ADEHHqU|I8M|!2w9O&!cz*rqfc@fI z*6&(be;T0Nhrjq41hJ+Tv56Dgi8FOfFxK(^AZtsOK8Y6Iw2$I^U}xCSqWc9)mEVr)F;f4dN-2es=A1@MP`HXNlVntUfaZ$?r|^aY64;UDF@=*Q zW-%owJO7qOQmA%$0uy1BB@pp=+-WL53^g`ksDdy|GO~#Ex*0+uI4(V07n|4hB`ZR5 zwHbyuO*4r#v?2_-?lrDkinp}b)RFFamc`F3ennff`0LaCyJwMHo*5pW&1f1f1Rx#G zIL_y1f=4UQs|@EJe5(%-O|#gx^>4@CwTm8aS-NmUZRc&~h;M{vKKa#OwXZl-{U0ry1liEbs7U7*)o2vVdROQ^tQ zzVI4cPF2kg<4&EoLFHRoY->j?3`rINum<>R+U{tfq2N1O{D*3}x~grvAoS;=M9j6X z+r|bxy_v6iIxM*`ZLEP@G|JS?2q|{|Z+IL$*SC4DOHur`$0;QCO3r4&bc4Nz!^v>3 zH$o3jNBxt#!JRJps^33GlLvit^l&&Bj=?wTjR&W_K_6z-eulO;pxc)ol}HUR5oRl!6dwlaqw{$^+qGKnhVeuP$wY%s(UXTgR(SUb^=w` z>aSq$YZ=T{ZaO)ha<_pr>dT?G&I$NC4RhGdLo+B1gnkAOwRCI|rA17Oufsi*2VlT9 zhJniemq=Xqnc=6p8RK*o%`rd{Wt?_b*_Y`GUe$qyxtgH-eZ6O6&^zphdq?Ag;XwIN z#KU7C%bQrO{Cnk4`oe|}#b+Q~t66E=O}p*3{lIgojtxKk=(kyWe>l62)3RH(^SATD K@!UWC7ykei3sAfO literal 0 HcmV?d00001 diff --git a/src/games/__pycache__/quiz.cpython-310.pyc b/src/games/__pycache__/quiz.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f504339c0d8da5d7800b140def60008876a44506 GIT binary patch literal 4107 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 literal 0 HcmV?d00001 diff --git a/src/games/__pycache__/sentence.cpython-310.pyc b/src/games/__pycache__/sentence.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63f8f8ebdf4d47adfc2c91929c2929a9630bd3d6 GIT binary patch literal 1288 zcma)4&1)n@6tDiAp2;Me#C64kSSN7=v#=LI!jk<+#=vHFA=yRSk)qO7^ptj2w_Vj6 z=jaCrf?!S}<`BIsA|j%v%yIHp)NSyTKVUCkagMoK@_*x0=vQ%*%Ihwwv;;Tg@YhN&>0 zCzS5FrhS_#zEv=unP5Zkj3xHK<>i612EV!uf}_|0%VyW}&D=DkROw$918(L1szhH06YG zl~khCqL60cbn7EN-YY7m29P%*L;`$5Oox!XV0C8gE`c;OJBxTqSLO;KRLVkAn@JH; z*@;(kR=6O3Le-MBt@vy0?%TsekB3ktB^6DF8I>tXMpVdQ$|b3l06(p0IU1@e88*|g zr_ZNT0f~?$jp~BvpvbXb*SYF6kt&4U+J-ICTBQ z_QcMo79e(JM?VyJcX}xB@&5Dqui%{#9XC|K&o08cz1U68>sgQAs?*ZfA<_` z(?gt0f2?r|ZzXupfB4??s{wv={MldyTHUrVi5{2SbeT|PM#oL>&D`Y~g*4IPF*og8 zNFrd^3QYRRnHj2N#B*xq)mTA{lvR9^+&k0X zu^!d4qhB|iV33dn%EqzT0sf`0Q$Q_|#oKv%&YB@Nf5-+xDME&;9|okCh+* literal 0 HcmV?d00001 diff --git a/src/games/__pycache__/sequence.cpython-310.pyc b/src/games/__pycache__/sequence.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4383cbc1c494a6c40470f85b4ffc5150ac7f4005 GIT binary patch literal 5440 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 N|D703{UiI;{{U_!bmss7 literal 0 HcmV?d00001 diff --git a/src/games/_template.py b/src/games/_template.py new file mode 100644 index 0000000..444d81e --- /dev/null +++ b/src/games/_template.py @@ -0,0 +1,91 @@ +""" +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 .py (ví dụ: matching.py) +3. Sửa nội dung bên trong +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 pydantic import BaseModel, Field + + +# ============== 1. SCHEMA ============== +# Định nghĩa structure của 1 item trong game +# BẮT BUỘC phải có: original_quote và explanation + +class YourGameItem(BaseModel): + """Schema cho 1 item của game""" + + # Các trường BẮT BUỘC (để chống hallucination) + original_quote: str = Field( + description="Trích dẫn NGUYÊN VĂN từ văn bản gốc" + ) + explanation: str = Field(description="Giải thích") + + # Thêm các trường riêng của game ở đây + # Ví dụ: + # question: str = Field(description="Câu hỏi") + # answer: str = Field(description="Đáp án") + + +# ============== 2. CONFIG ============== +# Cấu hình cho game + +GAME_CONFIG = { + # Key duy nhất cho game (dùng trong API) + "game_type": "your_game", + + # Tên hiển thị + "display_name": "Tên Game", + + # Mô tả ngắn + "description": "Mô tả game của bạn", + + # Số lượng items + "max_items": 5, + + # Trỏ đến schema class + "schema": YourGameItem, + + # 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]""", +} + + +# ============== 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 + +EXAMPLES = [ + { + # Input text mẫu + "input": "Văn bản mẫu ở đây...", + + # Output mong đợi + "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... + } + ] + }, + + # 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" + }, + # Thêm 1-2 examples nữa... +] diff --git a/src/games/base.py b/src/games/base.py new file mode 100644 index 0000000..868aa3e --- /dev/null +++ b/src/games/base.py @@ -0,0 +1,85 @@ +""" +games/base.py - Base Game Type Definition +""" +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional, Type +from pydantic import BaseModel +from langchain_core.output_parsers import PydanticOutputParser + + +@dataclass +class GameType: + """ + Định nghĩa cấu trúc chuẩn cho một Game. + Mọi game phải tuân thủ cấu trúc này để Core có thể xử lý tự động. + """ + # --- REQUIRED FIELDS (No default value) --- + type_id: int + game_type: str # e.g. "quiz" + display_name: str # e.g. "Multiple Choice Quiz" + description: str # e.g. "Create questions from text" + + schema: Type[BaseModel] # Schema cho 1 item (e.g. QuizItem) + output_schema: Type[BaseModel] # Schema cho output (e.g. QuizOutput) + + generation_rules: List[str] # Rules để tạo nội dung + analyzer_rules: List[str] # Rules để analyzer nhận diện + + # --- OPTIONAL FIELDS (Has default value) --- + input_format_rules: List[str] = field(default_factory=list) # Rules validate input format (Direct Mode) + active: bool = True + max_items: int = 10 + examples: List[Dict[str, Any]] = field(default_factory=list) + output_parser: Optional[PydanticOutputParser] = None + + def __post_init__(self): + if self.output_parser is None and self.output_schema: + self.output_parser = PydanticOutputParser(pydantic_object=self.output_schema) + + @property + def format_instructions(self) -> str: + """Lấy hướng dẫn format JSON từ parser""" + if self.output_parser: + return self.output_parser.get_format_instructions() + return "" + + @property + def generated_system_prompt(self) -> str: + """Tự động tạo System Prompt từ rules và description""" + rules_txt = "\n".join([f"- {r}" for r in self.generation_rules]) + return f"""Game: {self.display_name} +Description: {self.description} + +GENERATION RULES: +{rules_txt} + +Always ensure output follows the JSON schema exactly.""" + + +def create_game_type(config: Dict[str, Any]) -> GameType: + """Factory method to create GameType from Config Dict""" + # Backward compatibility mapping + gen_rules = config.get("generation_rules", []) + if not gen_rules and "system_prompt" in config: + # Nếu chưa có rules tách biệt, dùng tạm system_prompt cũ làm 1 rule + gen_rules = [config["system_prompt"]] + + # Map analyzer_hints -> analyzer_rules + ana_rules = config.get("analyzer_rules", []) or config.get("analyzer_hints", []) + + return GameType( + type_id=config.get("type_id", 0), + game_type=config["game_type"], + display_name=config["display_name"], + description=config["description"], + input_format_rules=config.get("input_format_rules", []), + active=config.get("active", True), + max_items=config.get("max_items", 10), + schema=config["schema"], + output_schema=config["output_schema"], + generation_rules=gen_rules, + analyzer_rules=ana_rules, + examples=config.get("examples", []), + output_parser=config.get("output_parser") + ) + diff --git a/src/games/quiz.py b/src/games/quiz.py new file mode 100644 index 0000000..c4ed989 --- /dev/null +++ b/src/games/quiz.py @@ -0,0 +1,139 @@ +""" +games/quiz.py - Quiz Game - Multiple choice questions +""" +from typing import List, Literal +import re +from pydantic import BaseModel, Field +from langchain_core.output_parsers import PydanticOutputParser + + +# ============== SCHEMA ============== +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") + + + +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." + ) + + +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") + + +# Output parser +output_parser = PydanticOutputParser(pydantic_object=QuizOutput) + + +# ============== CONFIG ============== +# ============== CONFIG ============== +GAME_CONFIG = { + "game_type": "quiz", + "display_name": "Quiz", + "description": "Multiple choice questions", + "type_id": 1, + + "active": True, + + "max_items": 10, + "schema": QuizItem, + "output_schema": QuizOutput, + "output_parser": output_parser, + + "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", + ], + + # 1. Recognition Rules (for Analyzer) + "analyzer_rules": [ + "Text MUST contain questions with multiple choice options", + "NOT suitable if text is just a list of words with no questions", + ], + + # 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!", + ], + + "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() + + +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" + } +] diff --git a/src/games/sequence.py b/src/games/sequence.py new file mode 100644 index 0000000..f93526a --- /dev/null +++ b/src/games/sequence.py @@ -0,0 +1,173 @@ +""" +games/sequence.py - Arrange Sequence Game (Sentences OR Words) +type_id = 2 +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 +Output trả về đúng trường: word hoặc sentence +""" +from typing import List, Literal, Optional +from pydantic import BaseModel, Field +from langchain_core.output_parsers import PydanticOutputParser + + +# ============== SCHEMA ============== +class SequenceItem(BaseModel): + """Item - LLM điền word HOẶC sentence, không điền cả 2""" + word: Optional[str] = Field(default=None, description="Fill this if item is a WORD/PHRASE (not complete sentence)") + sentence: Optional[str] = Field(default=None, description="Fill this if item is a COMPLETE SENTENCE") + original_quote: str = Field(description="EXACT quote from source text") + image_description: str = Field(default="", description="Visual description of the content") + 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 complex details") + + +class SequenceMetadata(BaseModel): + """Metadata đánh giá nội dung""" + title: str = Field( + description="Title for this content. Prefer title from source document if available." + ) + description: str = Field( + description="Short description summarizing the content/topic." + ) + grade: int = Field( + description="Estimated grade level 1-5 (1=easy/young, 5=advanced/older)." + ) + type: Literal["sequence"] = Field(default="sequence", description="Game type") + sub_type: Literal["sentence", "word"] = Field( + description="LLM decides: 'word' for words/phrases, 'sentence' for complete sentences" + ) + difficulty: int = Field( + description="Difficulty 1-5 for that grade." + ) + + +class SequenceOutput(BaseModel): + """Output wrapper for sequence items""" + items: List[SequenceItem] = Field(description="List of sequence items") + metadata: SequenceMetadata = Field(description="Metadata about the content") + + +# Output parser +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 ============== +EXAMPLES = [ + { + "input": "apple; banana;\norange; grape;\ncat; dog;", + "output": { + "items": [ + {"word": "apple", "sentence": None, "original_quote": "apple", "image_description": "A red apple", "image_keywords": ["apple"], "image_is_complex": False}, + {"word": "banana", "sentence": None, "original_quote": "banana", "image_description": "A yellow banana", "image_keywords": ["banana"], "image_is_complex": False}, + {"word": "orange", "sentence": None, "original_quote": "orange", "image_description": "An orange fruit", "image_keywords": ["orange"], "image_is_complex": False}, + {"word": "grape", "sentence": None, "original_quote": "grape", "image_description": "Purple grapes", "image_keywords": ["grape"], "image_is_complex": False}, + {"word": "cat", "sentence": None, "original_quote": "cat", "image_description": "A cat", "image_keywords": ["cat"], "image_is_complex": False}, + {"word": "dog", "sentence": None, "original_quote": "dog", "image_description": "A dog", "image_keywords": ["dog"], "image_is_complex": False} + ], + "metadata": { + "title": "Animals and Fruits", + "description": "Common animals and fruits", + "grade": 1, + "type": "sequence", + "sub_type": "word", + "difficulty": 1 + } + }, + "why": "Items are single words → use 'word' field" + }, + { + "input": "Hi, I'm Lisa; Nice to meet you; How are you?", + "output": { + "items": [ + {"word": None, "sentence": "Hi, I'm Lisa", "original_quote": "Hi, I'm Lisa", "image_description": "A girl introducing herself", "image_keywords": ["girl", "greeting"], "image_is_complex": True}, + {"word": None, "sentence": "Nice to meet you", "original_quote": "Nice to meet you", "image_description": "Two people shaking hands", "image_keywords": ["handshake", "greeting"], "image_is_complex": True}, + {"word": None, "sentence": "How are you?", "original_quote": "How are you?", "image_description": "Person asking a question", "image_keywords": ["question", "greeting"], "image_is_complex": True} + ], + "metadata": { + "title": "English Greetings", + "description": "Common greeting sentences", + "grade": 2, + "type": "sequence", + "sub_type": "sentence", + "difficulty": 2 + } + }, + "why": "Items are complete sentences → use 'sentence' field" + }, + { + "input": "good morning; good afternoon; good evening; good night", + "output": { + "items": [ + {"word": "good morning", "sentence": None, "original_quote": "good morning", "image_description": "Morning sunrise", "image_keywords": ["morning", "sun"], "image_is_complex": False}, + {"word": "good afternoon", "sentence": None, "original_quote": "good afternoon", "image_description": "Afternoon sun", "image_keywords": ["afternoon"], "image_is_complex": False}, + {"word": "good evening", "sentence": None, "original_quote": "good evening", "image_description": "Evening sunset", "image_keywords": ["evening", "sunset"], "image_is_complex": False}, + {"word": "good night", "sentence": None, "original_quote": "good night", "image_description": "Night sky with moon", "image_keywords": ["night", "moon"], "image_is_complex": False} + ], + "metadata": { + "title": "Time Greetings", + "description": "Greetings for different times of day", + "grade": 1, + "type": "sequence", + "sub_type": "word", + "difficulty": 1 + } + }, + "why": "These are PHRASES/GREETINGS, not complete sentences → use 'word' field" + } +] diff --git a/src/llm_config.py b/src/llm_config.py new file mode 100644 index 0000000..c45b313 --- /dev/null +++ b/src/llm_config.py @@ -0,0 +1,191 @@ +""" +llm_config.py - Cấu hình LLM linh hoạt + +Hỗ trợ: +- Ollama (local) +- Google Gemini +- OpenAI + +Sử dụng: + from llm_config import ModelConfig, get_llm + + config = ModelConfig(provider="ollama", model_name="qwen2.5:14b") + llm = get_llm(config) +""" +import os +from typing import Optional +from pydantic import BaseModel, Field +from langchain_core.language_models.chat_models import BaseChatModel + + +class ModelConfig(BaseModel): + """Cấu hình cho LLM""" + provider: str = Field( + default="gemini", + description="Provider: ollama, gemini, openai" + ) + model_name: str = Field( + default="gemini-2.0-flash-lite", + description="Tên model" + ) + api_key: Optional[str] = Field( + default=None, + description="API key (nếu None, lấy từ env)" + ) + temperature: float = Field( + default=0.1, + description="Độ sáng tạo (0.0 - 1.0)" + ) + base_url: Optional[str] = Field( + default=None, + description="Base URL cho Ollama" + ) + + class Config: + # Cho phép tạo từ dict + extra = "allow" + + +# ============== DEFAULT CONFIGS ============== + +DEFAULT_CONFIGS = { + "ollama": ModelConfig( + provider="ollama", + model_name="qwen2.5:14b", + temperature=0.1, + base_url=None # Sẽ lấy từ OLLAMA_BASE_URL env + ), + "ollama_light": ModelConfig( + provider="ollama", + model_name="qwen2.5:7b", + temperature=0.0, + base_url=None # Sẽ lấy từ OLLAMA_BASE_URL env + ), + "gemini": ModelConfig( + provider="gemini", + model_name="gemini-2.0-flash-lite", + temperature=0.1 + ), + "gemini_light": ModelConfig( + provider="gemini", + model_name="gemini-2.0-flash-lite", + temperature=0.0 + ), + "openai": ModelConfig( + provider="openai", + model_name="gpt-4o-mini", + temperature=0.1 + ), + "openai_light": ModelConfig( + provider="openai", + model_name="gpt-4o-mini", + temperature=0.0 + ), +} + + +def get_default_config(name: str = "gemini") -> ModelConfig: + """Lấy config mặc định theo tên""" + return DEFAULT_CONFIGS.get(name, DEFAULT_CONFIGS["gemini"]) + + +# ============== LLM FACTORY ============== + +def get_llm(config: ModelConfig) -> BaseChatModel: + """ + Factory function tạo LLM instance + + Args: + config: ModelConfig object + + Returns: + BaseChatModel instance + """ + provider = config.provider.lower() + + if provider == "ollama": + from langchain_ollama import ChatOllama + + base_url = config.base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + return ChatOllama( + model=config.model_name, + temperature=config.temperature, + base_url=base_url + ) + + elif provider == "gemini": + from langchain_google_genai import ChatGoogleGenerativeAI + + api_key = config.api_key or os.getenv("GOOGLE_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 + ) + + elif provider == "openai": + from langchain_openai import ChatOpenAI + + api_key = config.api_key or os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY required for OpenAI. Set via env or config.api_key") + + return ChatOpenAI( + model=config.model_name, + temperature=config.temperature, + api_key=api_key + ) + + else: + raise ValueError(f"Provider '{provider}' không được hỗ trợ. Chọn: ollama, gemini, openai") + + +def get_completion_model(config: ModelConfig): + """ + Tạo completion model (non-chat) nếu cần + Hiện tại chỉ Ollama có completion model riêng + """ + if config.provider.lower() == "ollama": + from langchain_ollama.llms import OllamaLLM + + base_url = config.base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + return OllamaLLM( + model=config.model_name, + temperature=config.temperature, + base_url=base_url + ) + + # Các provider khác dùng Chat interface + return get_llm(config) + + +# ============== HELPER ============== + +def create_config( + provider: str = "gemini", + model_name: Optional[str] = None, + api_key: Optional[str] = None, + temperature: float = 0.1, + base_url: Optional[str] = None +) -> ModelConfig: + """ + Helper function tạo ModelConfig + + Nếu không chỉ định model_name, sẽ dùng default cho provider đó + """ + default_models = { + "ollama": "qwen2.5:14b", + "gemini": "gemini-2.0-flash-lite", + "openai": "gpt-4o-mini" + } + + return ModelConfig( + provider=provider, + model_name=model_name or default_models.get(provider, "gemini-2.0-flash-lite"), + api_key=api_key, + temperature=temperature, + base_url=base_url + ) diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..7510ef6 --- /dev/null +++ b/src/logger.py @@ -0,0 +1,37 @@ +import logging +import sys +import os +from logging.handlers import RotatingFileHandler + +def setup_logger(name: str = "sena_gen"): + logger = logging.getLogger(name) + + if logger.handlers: + return logger + + logger.setLevel(logging.INFO) + + # Format + formatter = logging.Formatter( + '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] - %(message)s' + ) + + # Console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # File handler (Optional - based on env) + log_file = os.getenv("LOG_FILE", "logs/gen_game.log") + if log_file: + os.makedirs(os.path.dirname(log_file), exist_ok=True) + file_handler = RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8' + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + +# Singleton logger +logger = setup_logger() diff --git a/src/validator.py b/src/validator.py new file mode 100644 index 0000000..05ec56b --- /dev/null +++ b/src/validator.py @@ -0,0 +1,204 @@ +""" +validator.py - Hallucination Guardrail +Kiểm tra original_quote có thực sự nằm trong văn bản gốc không (Python-based, 0 API calls) +""" +import re +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass +from difflib import SequenceMatcher +import unicodedata + + +@dataclass +class ValidationResult: + """Kết quả validate một item""" + item_index: int + is_valid: bool + original_quote: str + match_found: bool + match_score: float + error_message: Optional[str] = None + + +@dataclass +class ValidatedGameOutput: + """Kết quả sau khi validate một game""" + game_type: str + valid_items: List[Dict[str, Any]] + invalid_items: List[Dict[str, Any]] + validation_results: List[ValidationResult] + + @property + def all_valid(self) -> bool: + return len(self.invalid_items) == 0 + + @property + def validity_rate(self) -> float: + total = len(self.valid_items) + len(self.invalid_items) + return len(self.valid_items) / total if total > 0 else 0.0 + + +class QuoteValidator: + """ + Validator kiểm tra original_quote có thực sự nằm trong văn bản gốc + Sử dụng nhiều chiến lược matching: exact, fuzzy, substring + + KHÔNG GỌI API - hoàn toàn Python-based + """ + + def __init__( + self, + fuzzy_threshold: float = 0.85, + min_quote_length: int = 10, + normalize_whitespace: bool = True + ): + self.fuzzy_threshold = fuzzy_threshold + self.min_quote_length = min_quote_length + self.normalize_whitespace = normalize_whitespace + + def _normalize_text(self, text: str) -> str: + """Chuẩn hóa text để so sánh""" + if not text: + return "" + + text = unicodedata.normalize('NFC', text) + text = text.lower() + + if self.normalize_whitespace: + text = re.sub(r'\s+', ' ', text).strip() + + return text + + def _exact_match(self, quote: str, source: str) -> bool: + """Kiểm tra quote có nằm chính xác trong source không""" + return self._normalize_text(quote) in self._normalize_text(source) + + def _fuzzy_match(self, quote: str, source: str) -> float: + """Tìm đoạn giống nhất trong source và trả về similarity score""" + norm_quote = self._normalize_text(quote) + norm_source = self._normalize_text(source) + + if not norm_quote or not norm_source: + return 0.0 + + if len(norm_quote) > len(norm_source): + return 0.0 + + best_score = 0.0 + quote_len = len(norm_quote) + + window_sizes = [ + quote_len, + int(quote_len * 0.9), + int(quote_len * 1.1), + ] + + for window_size in window_sizes: + if window_size <= 0 or window_size > len(norm_source): + continue + + for i in range(len(norm_source) - window_size + 1): + window = norm_source[i:i + window_size] + score = SequenceMatcher(None, norm_quote, window).ratio() + best_score = max(best_score, score) + + if best_score >= self.fuzzy_threshold: + return best_score + + return best_score + + def validate_quote( + self, + original_quote: str, + source_text: str, + item_index: int = 0 + ) -> ValidationResult: + """Validate một original_quote against source_text""" + + if not original_quote: + return ValidationResult( + item_index=item_index, + is_valid=False, + original_quote=original_quote or "", + match_found=False, + match_score=0.0, + error_message="original_quote is empty" + ) + + if len(original_quote) < self.min_quote_length: + return ValidationResult( + item_index=item_index, + is_valid=False, + original_quote=original_quote, + match_found=False, + match_score=0.0, + error_message=f"quote too short (min: {self.min_quote_length})" + ) + + # Strategy 1: Exact match + if self._exact_match(original_quote, source_text): + return ValidationResult( + item_index=item_index, + is_valid=True, + original_quote=original_quote, + match_found=True, + match_score=1.0, + error_message=None + ) + + # Strategy 2: Fuzzy match + fuzzy_score = self._fuzzy_match(original_quote, source_text) + if fuzzy_score >= self.fuzzy_threshold: + return ValidationResult( + item_index=item_index, + is_valid=True, + original_quote=original_quote, + match_found=True, + match_score=fuzzy_score, + error_message=None + ) + + return ValidationResult( + item_index=item_index, + is_valid=False, + original_quote=original_quote, + match_found=False, + match_score=fuzzy_score, + error_message=f"Quote not found. Score: {fuzzy_score:.2f}" + ) + + def validate_game_output( + self, + game_type: str, + items: List[Dict[str, Any]], + source_text: str + ) -> ValidatedGameOutput: + """Validate tất cả items trong một game output""" + valid_items = [] + invalid_items = [] + validation_results = [] + + for i, item in enumerate(items): + original_quote = item.get("original_quote", "") + result = self.validate_quote(original_quote, source_text, i) + validation_results.append(result) + + if result.is_valid: + valid_items.append(item) + else: + item["_validation_error"] = result.error_message + invalid_items.append(item) + + return ValidatedGameOutput( + game_type=game_type, + valid_items=valid_items, + invalid_items=invalid_items, + validation_results=validation_results + ) + + +def quick_validate(original_quote: str, source_text: str, threshold: float = 0.85) -> bool: + """Hàm tiện ích validate nhanh""" + validator = QuoteValidator(fuzzy_threshold=threshold) + result = validator.validate_quote(original_quote, source_text) + return result.is_valid