|
|
""" |
|
|
API routes for crossword puzzle generator. |
|
|
Matches the existing JavaScript API for frontend compatibility. |
|
|
""" |
|
|
|
|
|
import logging |
|
|
from typing import List, Dict, Any, Optional |
|
|
from datetime import datetime |
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Depends |
|
|
from pydantic import BaseModel, Field |
|
|
|
|
|
from ..services.crossword_generator_wrapper import CrosswordGenerator |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
router = APIRouter() |
|
|
|
|
|
|
|
|
class GeneratePuzzleRequest(BaseModel): |
|
|
topics: List[str] = Field(..., description="List of topics for the puzzle") |
|
|
difficulty: str = Field(default="medium", description="Difficulty level: easy, medium, hard") |
|
|
customSentence: Optional[str] = Field(default=None, description="Optional custom sentence to influence word selection") |
|
|
multiTheme: bool = Field(default=True, description="Whether to use multi-theme processing or single-theme blending") |
|
|
wordCount: Optional[int] = Field(default=10, description="Number of words to include in the crossword (8-15)") |
|
|
similarityTemperature: Optional[float] = Field(default=None, description="Override similarity temperature for word selection randomness (0.1-2.0)") |
|
|
difficultyWeight: Optional[float] = Field(default=None, description="Override difficulty weight for similarity vs frequency balance (0.0-1.0)") |
|
|
|
|
|
class WordInfo(BaseModel): |
|
|
word: str |
|
|
clue: str |
|
|
similarity: Optional[float] = None |
|
|
source: Optional[str] = None |
|
|
|
|
|
class ClueInfo(BaseModel): |
|
|
number: int |
|
|
word: str |
|
|
text: str |
|
|
direction: str |
|
|
position: Dict[str, int] |
|
|
|
|
|
class PuzzleMetadata(BaseModel): |
|
|
topics: List[str] |
|
|
difficulty: str |
|
|
wordCount: int |
|
|
size: int |
|
|
aiGenerated: bool |
|
|
|
|
|
class PuzzleResponse(BaseModel): |
|
|
grid: List[List[str]] |
|
|
clues: List[ClueInfo] |
|
|
metadata: PuzzleMetadata |
|
|
debug: Optional[Dict[str, Any]] = None |
|
|
|
|
|
class TopicInfo(BaseModel): |
|
|
id: str |
|
|
name: str |
|
|
|
|
|
|
|
|
generator = None |
|
|
|
|
|
def get_crossword_generator(request: Request) -> CrosswordGenerator: |
|
|
"""Dependency to get the crossword generator with thematic service.""" |
|
|
global generator |
|
|
if generator is None: |
|
|
thematic_service = getattr(request.app.state, 'thematic_service', None) |
|
|
generator = CrosswordGenerator(thematic_service) |
|
|
return generator |
|
|
|
|
|
@router.get("/topics", response_model=List[TopicInfo]) |
|
|
async def get_topics(): |
|
|
"""Get available topics for puzzle generation.""" |
|
|
|
|
|
topics = [ |
|
|
{"id": "animals", "name": "Animals"}, |
|
|
{"id": "geography", "name": "Geography"}, |
|
|
{"id": "science", "name": "Science"}, |
|
|
{"id": "technology", "name": "Technology"}, |
|
|
{"id": "sports", "name": "Sports"}, |
|
|
{"id": "history", "name": "History"}, |
|
|
{"id": "food", "name": "Food"}, |
|
|
{"id": "entertainment", "name": "Entertainment"}, |
|
|
{"id": "nature", "name": "Nature"}, |
|
|
{"id": "transportation", "name": "Transportation"}, |
|
|
{"id": "art", "name": "Art"}, |
|
|
{"id": "medicine", "name": "Medicine"}, |
|
|
{"id": "philosophy", "name": "Philosophy"}, |
|
|
{"id": "music", "name": "Music"}, |
|
|
{"id": "books", "name": "Books"}, |
|
|
{"id": "movies", "name": "Movies"}, |
|
|
{"id": "appliances", "name": "Appliances"}, |
|
|
{"id": "culture", "name": "Culture"}, |
|
|
{"id": "cuisine", "name": "Cuisine"}, |
|
|
{"id": "languages", "name": "Languages"}, |
|
|
{"id": "relations", "name": "Relations"}, |
|
|
{"id": "space", "name": "Space"}, |
|
|
{"id": "universe", "name": "Universe"}, |
|
|
{"id": "meditation", "name": "Meditation"} |
|
|
] |
|
|
return topics |
|
|
|
|
|
@router.post("/generate", response_model=PuzzleResponse) |
|
|
async def generate_puzzle( |
|
|
request: GeneratePuzzleRequest, |
|
|
crossword_gen: CrosswordGenerator = Depends(get_crossword_generator) |
|
|
): |
|
|
""" |
|
|
Generate a crossword puzzle with AI thematic word generation. |
|
|
|
|
|
This endpoint matches the JavaScript API exactly for frontend compatibility. |
|
|
""" |
|
|
try: |
|
|
sentence_info = f", custom sentence: '{request.customSentence}'" if request.customSentence else "" |
|
|
theme_mode = "multi-theme" if request.multiTheme else "single-theme" |
|
|
logger.info(f"🎯 Generating puzzle for topics: {request.topics}, difficulty: {request.difficulty}{sentence_info}, mode: {theme_mode}") |
|
|
|
|
|
|
|
|
if not request.topics and not (request.customSentence and request.customSentence.strip()): |
|
|
raise HTTPException(status_code=400, detail="At least one topic or a custom sentence is required") |
|
|
|
|
|
valid_difficulties = ["easy", "medium", "hard"] |
|
|
if request.difficulty not in valid_difficulties: |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail=f"Invalid difficulty. Must be one of: {valid_difficulties}" |
|
|
) |
|
|
|
|
|
|
|
|
if request.wordCount and (request.wordCount < 8 or request.wordCount > 15): |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail="Word count must be between 8 and 15" |
|
|
) |
|
|
|
|
|
|
|
|
advanced_params = {} |
|
|
if request.similarityTemperature is not None: |
|
|
advanced_params['similarity_temperature'] = request.similarityTemperature |
|
|
if request.difficultyWeight is not None: |
|
|
advanced_params['difficulty_weight'] = request.difficultyWeight |
|
|
|
|
|
puzzle_data = await crossword_gen.generate_puzzle( |
|
|
topics=request.topics, |
|
|
difficulty=request.difficulty, |
|
|
custom_sentence=request.customSentence, |
|
|
multi_theme=request.multiTheme, |
|
|
requested_words=request.wordCount, |
|
|
advanced_params=advanced_params |
|
|
) |
|
|
|
|
|
if not puzzle_data: |
|
|
raise HTTPException(status_code=500, detail="Failed to generate puzzle") |
|
|
|
|
|
logger.info(f"✅ Generated puzzle with {puzzle_data['metadata']['wordCount']} words") |
|
|
return puzzle_data |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Error generating puzzle: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
@router.post("/words") |
|
|
async def generate_words( |
|
|
request: GeneratePuzzleRequest, |
|
|
crossword_gen: CrosswordGenerator = Depends(get_crossword_generator) |
|
|
): |
|
|
""" |
|
|
Generate words for given topics (debug endpoint). |
|
|
|
|
|
This endpoint allows testing word generation without full puzzle creation. |
|
|
""" |
|
|
try: |
|
|
words = await crossword_gen.generate_words_for_topics( |
|
|
topics=request.topics, |
|
|
difficulty=request.difficulty |
|
|
) |
|
|
|
|
|
return { |
|
|
"topics": request.topics, |
|
|
"difficulty": request.difficulty, |
|
|
"wordCount": len(words), |
|
|
"words": words |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ Error generating words: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
@router.get("/health") |
|
|
async def api_health(request: Request): |
|
|
"""API health check with cache status.""" |
|
|
thematic_service = getattr(request.app.state, 'thematic_service', None) |
|
|
|
|
|
health_info = { |
|
|
"status": "healthy", |
|
|
"timestamp": datetime.utcnow().isoformat(), |
|
|
"backend": "python", |
|
|
"version": "2.0.0", |
|
|
"thematic_service": { |
|
|
"available": thematic_service is not None, |
|
|
"initialized": thematic_service.is_initialized if thematic_service else False |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if thematic_service: |
|
|
try: |
|
|
cache_status = thematic_service.get_cache_status() |
|
|
health_info["cache"] = cache_status |
|
|
except Exception as e: |
|
|
health_info["cache"] = {"error": str(e)} |
|
|
|
|
|
return health_info |
|
|
|
|
|
@router.get("/health/cache") |
|
|
async def cache_health(request: Request): |
|
|
"""Detailed cache health check and status.""" |
|
|
thematic_service = getattr(request.app.state, 'thematic_service', None) |
|
|
|
|
|
if not thematic_service: |
|
|
return {"error": "Thematic service not available"} |
|
|
|
|
|
try: |
|
|
cache_status = thematic_service.get_cache_status() |
|
|
|
|
|
|
|
|
import os |
|
|
cache_dir = cache_status['cache_directory'] |
|
|
|
|
|
diagnostics = { |
|
|
"cache_status": cache_status, |
|
|
"diagnostics": { |
|
|
"cache_dir_exists": os.path.exists(cache_dir), |
|
|
"cache_dir_readable": os.access(cache_dir, os.R_OK) if os.path.exists(cache_dir) else False, |
|
|
"cache_dir_writable": os.access(cache_dir, os.W_OK) if os.path.exists(cache_dir) else False, |
|
|
"service_initialized": thematic_service.is_initialized, |
|
|
"vocab_size_limit": thematic_service.vocab_size_limit, |
|
|
"model_name": thematic_service.model_name |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if os.path.exists(cache_dir): |
|
|
try: |
|
|
cache_files = [] |
|
|
for file in os.listdir(cache_dir): |
|
|
file_path = os.path.join(cache_dir, file) |
|
|
if os.path.isfile(file_path): |
|
|
stat = os.stat(file_path) |
|
|
cache_files.append({ |
|
|
"name": file, |
|
|
"size_bytes": stat.st_size, |
|
|
"size_mb": round(stat.st_size / (1024 * 1024), 2), |
|
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat() |
|
|
}) |
|
|
diagnostics["cache_files"] = cache_files |
|
|
except Exception as e: |
|
|
diagnostics["cache_files_error"] = str(e) |
|
|
|
|
|
return diagnostics |
|
|
|
|
|
except Exception as e: |
|
|
return {"error": f"Failed to get cache status: {e}"} |
|
|
|
|
|
@router.post("/health/cache/reinitialize") |
|
|
async def reinitialize_cache(request: Request): |
|
|
"""Force re-initialization of the thematic service and cache creation.""" |
|
|
thematic_service = getattr(request.app.state, 'thematic_service', None) |
|
|
|
|
|
if not thematic_service: |
|
|
return {"error": "Thematic service not available"} |
|
|
|
|
|
try: |
|
|
|
|
|
thematic_service.is_initialized = False |
|
|
|
|
|
|
|
|
await thematic_service.initialize_async() |
|
|
|
|
|
|
|
|
cache_status = thematic_service.get_cache_status() |
|
|
|
|
|
return { |
|
|
"message": "Cache re-initialization completed", |
|
|
"cache_status": cache_status, |
|
|
"timestamp": datetime.utcnow().isoformat() |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
import traceback |
|
|
return { |
|
|
"error": f"Failed to reinitialize cache: {e}", |
|
|
"traceback": traceback.format_exc(), |
|
|
"timestamp": datetime.utcnow().isoformat() |
|
|
} |
|
|
|
|
|
@router.get("/debug/thematic-search") |
|
|
async def debug_thematic_search( |
|
|
topic: str, |
|
|
difficulty: str = "medium", |
|
|
max_words: int = 10, |
|
|
request: Request = None |
|
|
): |
|
|
""" |
|
|
Debug endpoint to test thematic word generation directly. |
|
|
""" |
|
|
try: |
|
|
thematic_service = getattr(request.app.state, 'thematic_service', None) |
|
|
if not thematic_service or not thematic_service.is_initialized: |
|
|
raise HTTPException(status_code=503, detail="Thematic service not available") |
|
|
|
|
|
result = await thematic_service.find_words_for_crossword([topic], difficulty, max_words) |
|
|
words = result["words"] |
|
|
|
|
|
response = { |
|
|
"topic": topic, |
|
|
"difficulty": difficulty, |
|
|
"max_words": max_words, |
|
|
"found_words": len(words), |
|
|
"words": words |
|
|
} |
|
|
|
|
|
|
|
|
if "debug" in result: |
|
|
response["debug"] = result["debug"] |
|
|
|
|
|
return response |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"❌ Thematic search debug failed: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |