Deploy Bot commited on
Commit
77d2609
·
0 Parent(s):

Deploy backend to HF Spaces

Browse files
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set up a new user named "user" with user ID 1000
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy requirements file to the working directory
11
+ COPY --chown=user ./requirements.txt requirements.txt
12
+
13
+ # Install the Python dependencies
14
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
15
+
16
+ # Copy the application code to the working directory
17
+ COPY --chown=user . .
18
+
19
+ # Expose port 7860 (Hugging Face Spaces default)
20
+ EXPOSE 7860
21
+
22
+ # Command to run the application
23
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
Dockerfile.hf ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set up a new user named "user" with user ID 1000
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy requirements file to the working directory
11
+ COPY --chown=user ./requirements.txt requirements.txt
12
+
13
+ # Install the Python dependencies
14
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
15
+
16
+ # Copy the application code to the working directory
17
+ COPY --chown=user . .
18
+
19
+ # Expose port 7860 (Hugging Face Spaces default)
20
+ EXPOSE 7860
21
+
22
+ # Command to run the application
23
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tibetan TTS Backend
3
+ emoji: 🏔️
4
+ colorFrom: blue
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Tibetan Learning Platform - Backend API
13
+
14
+ This is the FastAPI backend for the Tibetan Learning Platform, capable of Text-to-Speech (MMS-TTS), Translation, and Learning modules.
15
+
16
+ ## Features
17
+ - **TTS**: Meta MMS bindings for Tibetan
18
+ - **Translation**: Dictionary and Fallback API
19
+ - **Learning**: SRS Flashcard backend
20
+
21
+ ## Deployment
22
+ Running on Docker with User 1000 at Port 7860.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Tibetan Learning Platform Backend
app/main.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tibetan Learning Platform - FastAPI Backend
3
+ Main application entry point
4
+ """
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from contextlib import asynccontextmanager
9
+
10
+ from app.routers import tts, translate, learn
11
+
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(app: FastAPI):
15
+ """Application lifespan events"""
16
+ # Startup: Load TTS model
17
+ from app.services.tts_service import tts_service
18
+ print("🔄 Loading TTS model...")
19
+ await tts_service.load_model()
20
+ print("✅ TTS model loaded successfully!")
21
+
22
+ yield
23
+
24
+ # Shutdown: Cleanup
25
+ print("👋 Shutting down...")
26
+
27
+
28
+ app = FastAPI(
29
+ title="Tibetan Learning Platform API",
30
+ description="API for Tibetan TTS, Translation, and Learning features",
31
+ version="1.0.0",
32
+ lifespan=lifespan
33
+ )
34
+
35
+ # CORS middleware for frontend
36
+ app.add_middleware(
37
+ CORSMiddleware,
38
+ allow_origins=[
39
+ "http://localhost:3000",
40
+ "http://127.0.0.1:3000",
41
+ "*", # Allow all origins for HF Spaces deployment
42
+ ],
43
+ allow_credentials=True,
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+ # Include routers
49
+ app.include_router(tts.router, prefix="/api/tts", tags=["TTS"])
50
+ app.include_router(translate.router, prefix="/api/translate", tags=["Translation"])
51
+ app.include_router(learn.router, prefix="/api/learn", tags=["Learning"])
52
+
53
+
54
+ @app.get("/")
55
+ async def root():
56
+ """Health check endpoint"""
57
+ return {
58
+ "status": "ok",
59
+ "message": "Tibetan Learning Platform API",
60
+ "version": "1.0.0"
61
+ }
62
+
63
+
64
+ @app.get("/api/health")
65
+ async def health_check():
66
+ """Detailed health check"""
67
+ from app.services.tts_service import tts_service
68
+ return {
69
+ "status": "healthy",
70
+ "tts_model_loaded": tts_service.is_loaded(),
71
+ "device": tts_service.get_device()
72
+ }
73
+
74
+
75
+ if __name__ == "__main__":
76
+ import uvicorn
77
+ uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Data Models
app/models/schemas.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for API request/response models
3
+ """
4
+
5
+ from pydantic import BaseModel, Field
6
+ from typing import Optional, List, Literal
7
+
8
+
9
+ # ===================
10
+ # TTS Schemas
11
+ # ===================
12
+
13
+ class TTSRequest(BaseModel):
14
+ """Request model for TTS generation"""
15
+ text: str = Field(..., min_length=1, max_length=1000, description="Tibetan text to convert to speech")
16
+ speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speech speed (0.5x to 2.0x)")
17
+
18
+
19
+ class TTSResponse(BaseModel):
20
+ """Response model for TTS metadata"""
21
+ success: bool
22
+ message: str
23
+ sample_rate: Optional[int] = None
24
+ duration_seconds: Optional[float] = None
25
+
26
+
27
+ # ===================
28
+ # Translation Schemas
29
+ # ===================
30
+
31
+ LanguageCode = Literal["bo", "zh", "en"]
32
+
33
+ class TranslateRequest(BaseModel):
34
+ """Request model for translation"""
35
+ text: str = Field(..., min_length=1, max_length=5000, description="Text to translate")
36
+ source: LanguageCode = Field(..., description="Source language code (bo=Tibetan, zh=Chinese, en=English)")
37
+ target: LanguageCode = Field(..., description="Target language code")
38
+
39
+
40
+ class TranslateResponse(BaseModel):
41
+ """Response model for translation"""
42
+ success: bool
43
+ translation: Optional[str] = None
44
+ pronunciation: Optional[str] = None # Romanization for Tibetan
45
+ source_language: str
46
+ target_language: str
47
+ error: Optional[str] = None
48
+
49
+
50
+ # ===================
51
+ # Learning Schemas
52
+ # ===================
53
+
54
+ class VocabularyItem(BaseModel):
55
+ """Single vocabulary item"""
56
+ id: str
57
+ tibetan: str
58
+ chinese: str
59
+ english: str
60
+ pronunciation: str # Wylie or phonetic
61
+ category: str
62
+ example_sentence: Optional[str] = None
63
+
64
+
65
+ class VocabularyCategory(BaseModel):
66
+ """Vocabulary category with items"""
67
+ category_id: str
68
+ category_name_zh: str
69
+ category_name_en: str
70
+ items: List[VocabularyItem]
71
+
72
+
73
+ class AlphabetLetter(BaseModel):
74
+ """Single Tibetan alphabet letter"""
75
+ id: str
76
+ letter: str # The Tibetan letter
77
+ unicode: str # Unicode codepoint
78
+ pronunciation: str # IPA or phonetic
79
+ description_zh: str
80
+ description_en: str
81
+ example_word: Optional[str] = None
82
+ example_meaning: Optional[str] = None
83
+
84
+
85
+ class AlphabetResponse(BaseModel):
86
+ """Response model for alphabet data"""
87
+ consonants: List[AlphabetLetter]
88
+ vowels: List[AlphabetLetter]
89
+
90
+
91
+ class FlashcardProgress(BaseModel):
92
+ """User's flashcard progress (for SRS)"""
93
+ vocabulary_id: str
94
+ ease_factor: float = 2.5 # SM-2 algorithm
95
+ interval_days: int = 1
96
+ repetitions: int = 0
97
+ next_review: Optional[str] = None # ISO datetime
98
+
99
+
100
+ class FlashcardReview(BaseModel):
101
+ """Request model for flashcard review"""
102
+ vocabulary_id: str
103
+ quality: int = Field(..., ge=0, le=5, description="Review quality (0=complete blackout, 5=perfect)")
app/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # API Routers
app/routers/learn.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Learning API Router - Vocabulary, Alphabet, Flashcards
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException, Query
6
+ from typing import Optional
7
+ from app.models.schemas import FlashcardReview, FlashcardProgress
8
+ from app.services.learning_service import learning_service
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/vocabulary")
15
+ async def get_vocabulary(
16
+ category: Optional[str] = Query(None, description="Filter by category")
17
+ ):
18
+ """
19
+ Get vocabulary items
20
+
21
+ - **category**: Optional category filter (greetings, numbers, colors, daily)
22
+ """
23
+ try:
24
+ return learning_service.get_vocabulary_by_category(category)
25
+ except Exception as e:
26
+ raise HTTPException(status_code=500, detail=str(e))
27
+
28
+
29
+ @router.get("/vocabulary/categories")
30
+ async def get_vocabulary_categories():
31
+ """
32
+ Get list of vocabulary categories
33
+ """
34
+ categories = learning_service.get_vocabulary_categories()
35
+ return {"categories": categories}
36
+
37
+
38
+ @router.get("/alphabet")
39
+ async def get_alphabet():
40
+ """
41
+ Get Tibetan alphabet data (consonants and vowels)
42
+ """
43
+ try:
44
+ return learning_service.get_alphabet()
45
+ except Exception as e:
46
+ raise HTTPException(status_code=500, detail=str(e))
47
+
48
+
49
+ @router.get("/flashcards")
50
+ async def get_flashcards(
51
+ category: Optional[str] = Query(None, description="Filter by category"),
52
+ limit: int = Query(10, ge=1, le=50, description="Number of flashcards to return")
53
+ ):
54
+ """
55
+ Get flashcards for practice session
56
+
57
+ - **category**: Optional category filter
58
+ - **limit**: Maximum number of cards (1-50)
59
+ """
60
+ try:
61
+ cards = learning_service.get_flashcards(category, limit)
62
+ return {
63
+ "flashcards": cards,
64
+ "count": len(cards)
65
+ }
66
+ except Exception as e:
67
+ raise HTTPException(status_code=500, detail=str(e))
68
+
69
+
70
+ @router.post("/flashcards/review", response_model=FlashcardProgress)
71
+ async def review_flashcard(review: FlashcardReview):
72
+ """
73
+ Submit a flashcard review and update SRS progress
74
+
75
+ - **vocabulary_id**: ID of the vocabulary item
76
+ - **quality**: Review quality (0=complete blackout, 5=perfect)
77
+
78
+ Uses SM-2 spaced repetition algorithm
79
+ """
80
+ try:
81
+ progress = learning_service.update_progress(
82
+ vocabulary_id=review.vocabulary_id,
83
+ quality=review.quality
84
+ )
85
+ return FlashcardProgress(
86
+ vocabulary_id=review.vocabulary_id,
87
+ ease_factor=progress["ease_factor"],
88
+ interval_days=progress["interval_days"],
89
+ repetitions=progress["repetitions"],
90
+ next_review=progress["next_review"]
91
+ )
92
+ except Exception as e:
93
+ raise HTTPException(status_code=500, detail=str(e))
94
+
95
+
96
+ @router.get("/stats")
97
+ async def get_learning_stats():
98
+ """
99
+ Get learning statistics
100
+ """
101
+ vocab_data = learning_service.get_vocabulary_by_category()
102
+ categories = vocab_data.get("categories", [])
103
+
104
+ total_items = sum(len(cat.get("items", [])) for cat in categories)
105
+
106
+ return {
107
+ "total_vocabulary": total_items,
108
+ "categories_count": len(categories),
109
+ "alphabet": {
110
+ "consonants": 30,
111
+ "vowels": 4
112
+ }
113
+ }
app/routers/translate.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Translation API Router
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from app.models.schemas import TranslateRequest, TranslateResponse
7
+ from app.services.translate_service import translate_service
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/", response_model=TranslateResponse)
14
+ async def translate_text(request: TranslateRequest):
15
+ """
16
+ Translate text between Tibetan, Chinese, and English
17
+
18
+ - **text**: Text to translate
19
+ - **source**: Source language code (bo=Tibetan, zh=Chinese, en=English)
20
+ - **target**: Target language code
21
+ """
22
+ try:
23
+ result = await translate_service.translate(
24
+ text=request.text,
25
+ source=request.source,
26
+ target=request.target
27
+ )
28
+
29
+ return TranslateResponse(
30
+ success=result.get("success", False),
31
+ translation=result.get("translation"),
32
+ pronunciation=result.get("pronunciation"),
33
+ source_language=result.get("source_language", request.source),
34
+ target_language=result.get("target_language", request.target),
35
+ error=result.get("error")
36
+ )
37
+
38
+ except Exception as e:
39
+ raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
40
+
41
+
42
+ @router.get("/languages")
43
+ async def get_supported_languages():
44
+ """
45
+ Get list of supported languages
46
+ """
47
+ return {
48
+ "languages": [
49
+ {"code": "bo", "name_zh": "藏语", "name_en": "Tibetan", "name_native": "བོད་སྐད།"},
50
+ {"code": "zh", "name_zh": "中文", "name_en": "Chinese", "name_native": "中文"},
51
+ {"code": "en", "name_zh": "英语", "name_en": "English", "name_native": "English"}
52
+ ],
53
+ "pairs": [
54
+ {"source": "bo", "target": "zh"},
55
+ {"source": "bo", "target": "en"},
56
+ {"source": "zh", "target": "bo"},
57
+ {"source": "zh", "target": "en"},
58
+ {"source": "en", "target": "bo"},
59
+ {"source": "en", "target": "zh"}
60
+ ]
61
+ }
app/routers/tts.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTS API Router - Text-to-Speech endpoints
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import StreamingResponse
7
+ from app.models.schemas import TTSRequest, TTSResponse
8
+ from app.services.tts_service import tts_service
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/generate", response_class=StreamingResponse)
15
+ async def generate_speech(request: TTSRequest):
16
+ """
17
+ Generate speech audio from Tibetan text
18
+
19
+ - **text**: Tibetan text to convert (1-1000 characters)
20
+ - **speed**: Speech speed multiplier (0.5x to 2.0x)
21
+
22
+ Returns: WAV audio file
23
+ """
24
+ try:
25
+ # Generate audio
26
+ audio, sample_rate = await tts_service.generate_audio(
27
+ text=request.text,
28
+ speed=request.speed
29
+ )
30
+
31
+ # Convert to WAV bytes
32
+ audio_bytes = tts_service.audio_to_wav_bytes(audio, sample_rate)
33
+
34
+ # Return as streaming response
35
+ return StreamingResponse(
36
+ audio_bytes,
37
+ media_type="audio/wav",
38
+ headers={
39
+ "Content-Disposition": "attachment; filename=tibetan_tts.wav",
40
+ "X-Audio-Duration": str(tts_service.get_audio_duration(audio, sample_rate)),
41
+ "X-Sample-Rate": str(sample_rate)
42
+ }
43
+ )
44
+
45
+ except ValueError as e:
46
+ raise HTTPException(status_code=400, detail=str(e))
47
+ except RuntimeError as e:
48
+ raise HTTPException(status_code=500, detail=str(e))
49
+ except Exception as e:
50
+ raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
51
+
52
+
53
+ @router.post("/metadata", response_model=TTSResponse)
54
+ async def get_tts_metadata(request: TTSRequest):
55
+ """
56
+ Get metadata about TTS generation without generating audio
57
+ Useful for estimating duration or validating input
58
+ """
59
+ try:
60
+ # Validate text
61
+ text = tts_service.preprocess_text(request.text)
62
+
63
+ return TTSResponse(
64
+ success=True,
65
+ message=f"Text validated: {len(text)} characters",
66
+ sample_rate=tts_service.sample_rate,
67
+ duration_seconds=None # Would need to generate to get actual duration
68
+ )
69
+
70
+ except ValueError as e:
71
+ return TTSResponse(
72
+ success=False,
73
+ message=str(e),
74
+ sample_rate=None,
75
+ duration_seconds=None
76
+ )
77
+
78
+
79
+ @router.get("/status")
80
+ async def get_tts_status():
81
+ """
82
+ Get TTS service status
83
+ """
84
+ return {
85
+ "model_loaded": tts_service.is_loaded(),
86
+ "model_name": tts_service.model_name,
87
+ "device": tts_service.get_device(),
88
+ "sample_rate": tts_service.sample_rate,
89
+ "max_text_length": 1000,
90
+ "speed_range": {"min": 0.5, "max": 2.0, "default": 1.0}
91
+ }
app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services
app/services/learning_service.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Learning Service - Vocabulary, Alphabet, and SRS (Spaced Repetition System)
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional
8
+ from datetime import datetime, timedelta
9
+
10
+
11
+ class LearningService:
12
+ """Service for vocabulary and learning features"""
13
+
14
+ def __init__(self):
15
+ self.data_dir = Path(__file__).parent.parent / "data"
16
+ self._vocabulary: Dict[str, Any] = {}
17
+ self._alphabet: Dict[str, Any] = {}
18
+ self._progress: Dict[str, Any] = {} # In-memory progress (for now)
19
+ self._load_data()
20
+
21
+ def _load_data(self):
22
+ """Load vocabulary and alphabet data"""
23
+ try:
24
+ vocab_path = self.data_dir / "vocabulary.json"
25
+ if vocab_path.exists():
26
+ with open(vocab_path, "r", encoding="utf-8") as f:
27
+ self._vocabulary = json.load(f)
28
+ else:
29
+ self._vocabulary = self._get_default_vocabulary()
30
+
31
+ alphabet_path = self.data_dir / "alphabet.json"
32
+ if alphabet_path.exists():
33
+ with open(alphabet_path, "r", encoding="utf-8") as f:
34
+ self._alphabet = json.load(f)
35
+ else:
36
+ self._alphabet = self._get_default_alphabet()
37
+
38
+ except Exception as e:
39
+ print(f"Error loading data: {e}")
40
+ self._vocabulary = self._get_default_vocabulary()
41
+ self._alphabet = self._get_default_alphabet()
42
+
43
+ def get_vocabulary_categories(self) -> List[str]:
44
+ """Get list of vocabulary categories"""
45
+ return list(self._vocabulary.get("categories", {}).keys())
46
+
47
+ def get_vocabulary_by_category(self, category: Optional[str] = None) -> Dict[str, Any]:
48
+ """Get vocabulary items, optionally filtered by category"""
49
+ categories = self._vocabulary.get("categories", {})
50
+
51
+ if category and category in categories:
52
+ return {
53
+ "category_id": category,
54
+ "category_name_zh": categories[category].get("name_zh", category),
55
+ "category_name_en": categories[category].get("name_en", category),
56
+ "items": categories[category].get("items", [])
57
+ }
58
+
59
+ # Return all categories
60
+ result = []
61
+ for cat_id, cat_data in categories.items():
62
+ result.append({
63
+ "category_id": cat_id,
64
+ "category_name_zh": cat_data.get("name_zh", cat_id),
65
+ "category_name_en": cat_data.get("name_en", cat_id),
66
+ "items": cat_data.get("items", [])
67
+ })
68
+ return {"categories": result}
69
+
70
+ def get_alphabet(self) -> Dict[str, Any]:
71
+ """Get Tibetan alphabet data"""
72
+ return self._alphabet
73
+
74
+ def get_flashcards(self, category: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
75
+ """Get flashcards for practice"""
76
+ all_items = []
77
+ categories = self._vocabulary.get("categories", {})
78
+
79
+ if category and category in categories:
80
+ all_items = categories[category].get("items", [])
81
+ else:
82
+ for cat_data in categories.values():
83
+ all_items.extend(cat_data.get("items", []))
84
+
85
+ # Apply SRS ordering (items due for review first)
86
+ # For now, just return items in order
87
+ return all_items[:limit]
88
+
89
+ def update_progress(self, vocabulary_id: str, quality: int) -> Dict[str, Any]:
90
+ """
91
+ Update learning progress using SM-2 algorithm
92
+
93
+ quality: 0-5 (0=complete blackout, 5=perfect response)
94
+ """
95
+ progress = self._progress.get(vocabulary_id, {
96
+ "ease_factor": 2.5,
97
+ "interval_days": 1,
98
+ "repetitions": 0,
99
+ "next_review": None
100
+ })
101
+
102
+ # SM-2 Algorithm
103
+ if quality >= 3:
104
+ # Correct response
105
+ if progress["repetitions"] == 0:
106
+ progress["interval_days"] = 1
107
+ elif progress["repetitions"] == 1:
108
+ progress["interval_days"] = 6
109
+ else:
110
+ progress["interval_days"] = round(
111
+ progress["interval_days"] * progress["ease_factor"]
112
+ )
113
+ progress["repetitions"] += 1
114
+ else:
115
+ # Incorrect response
116
+ progress["repetitions"] = 0
117
+ progress["interval_days"] = 1
118
+
119
+ # Update ease factor
120
+ progress["ease_factor"] = max(
121
+ 1.3,
122
+ progress["ease_factor"] + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
123
+ )
124
+
125
+ # Calculate next review date
126
+ next_review = datetime.now() + timedelta(days=progress["interval_days"])
127
+ progress["next_review"] = next_review.isoformat()
128
+
129
+ self._progress[vocabulary_id] = progress
130
+ return progress
131
+
132
+ def _get_default_vocabulary(self) -> Dict[str, Any]:
133
+ """Default vocabulary data"""
134
+ return {
135
+ "categories": {
136
+ "greetings": {
137
+ "name_zh": "问候语",
138
+ "name_en": "Greetings",
139
+ "items": [
140
+ {
141
+ "id": "greet_001",
142
+ "tibetan": "བཀྲ་ཤིས་བདེ་ལེགས།",
143
+ "chinese": "扎西德勒(吉祥如意/你好)",
144
+ "english": "Tashi Delek (Hello/Blessings)",
145
+ "pronunciation": "tashi delek",
146
+ "category": "greetings"
147
+ },
148
+ {
149
+ "id": "greet_002",
150
+ "tibetan": "ཐུགས་རྗེ་ཆེ།",
151
+ "chinese": "谢谢",
152
+ "english": "Thank you",
153
+ "pronunciation": "thuk je che",
154
+ "category": "greetings"
155
+ },
156
+ {
157
+ "id": "greet_003",
158
+ "tibetan": "མཇལ་བར་དགའ་པོ་བྱུང་།",
159
+ "chinese": "很高兴见到你",
160
+ "english": "Nice to meet you",
161
+ "pronunciation": "jal war ga po chung",
162
+ "category": "greetings"
163
+ },
164
+ {
165
+ "id": "greet_004",
166
+ "tibetan": "བདེ་མོ།",
167
+ "chinese": "再见",
168
+ "english": "Goodbye",
169
+ "pronunciation": "de mo",
170
+ "category": "greetings"
171
+ }
172
+ ]
173
+ },
174
+ "numbers": {
175
+ "name_zh": "数字",
176
+ "name_en": "Numbers",
177
+ "items": [
178
+ {"id": "num_001", "tibetan": "གཅིག", "chinese": "一", "english": "One", "pronunciation": "chik", "category": "numbers"},
179
+ {"id": "num_002", "tibetan": "གཉིས", "chinese": "二", "english": "Two", "pronunciation": "nyi", "category": "numbers"},
180
+ {"id": "num_003", "tibetan": "གསུམ", "chinese": "三", "english": "Three", "pronunciation": "sum", "category": "numbers"},
181
+ {"id": "num_004", "tibetan": "བཞི", "chinese": "四", "english": "Four", "pronunciation": "shi", "category": "numbers"},
182
+ {"id": "num_005", "tibetan": "ལྔ", "chinese": "五", "english": "Five", "pronunciation": "nga", "category": "numbers"},
183
+ {"id": "num_006", "tibetan": "དྲུག", "chinese": "六", "english": "Six", "pronunciation": "druk", "category": "numbers"},
184
+ {"id": "num_007", "tibetan": "བདུན", "chinese": "七", "english": "Seven", "pronunciation": "dün", "category": "numbers"},
185
+ {"id": "num_008", "tibetan": "བརྒྱད", "chinese": "八", "english": "Eight", "pronunciation": "gyé", "category": "numbers"},
186
+ {"id": "num_009", "tibetan": "དགུ", "chinese": "九", "english": "Nine", "pronunciation": "gu", "category": "numbers"},
187
+ {"id": "num_010", "tibetan": "བཅུ", "chinese": "十", "english": "Ten", "pronunciation": "chu", "category": "numbers"}
188
+ ]
189
+ },
190
+ "colors": {
191
+ "name_zh": "颜色",
192
+ "name_en": "Colors",
193
+ "items": [
194
+ {"id": "color_001", "tibetan": "དཀར་པོ", "chinese": "白色", "english": "White", "pronunciation": "kar po", "category": "colors"},
195
+ {"id": "color_002", "tibetan": "ནག་པོ", "chinese": "黑色", "english": "Black", "pronunciation": "nak po", "category": "colors"},
196
+ {"id": "color_003", "tibetan": "དམར་པོ", "chinese": "红色", "english": "Red", "pronunciation": "mar po", "category": "colors"},
197
+ {"id": "color_004", "tibetan": "སྔོན་པོ", "chinese": "蓝色", "english": "Blue", "pronunciation": "ngön po", "category": "colors"},
198
+ {"id": "color_005", "tibetan": "སེར་པོ", "chinese": "黄色", "english": "Yellow", "pronunciation": "ser po", "category": "colors"},
199
+ {"id": "color_006", "tibetan": "ལྗང་ཁུ", "chinese": "绿色", "english": "Green", "pronunciation": "jang khu", "category": "colors"}
200
+ ]
201
+ },
202
+ "daily": {
203
+ "name_zh": "日常用语",
204
+ "name_en": "Daily Phrases",
205
+ "items": [
206
+ {"id": "daily_001", "tibetan": "ང", "chinese": "我", "english": "I/Me", "pronunciation": "nga", "category": "daily"},
207
+ {"id": "daily_002", "tibetan": "ཁྱོད", "chinese": "你", "english": "You", "pronunciation": "khyö", "category": "daily"},
208
+ {"id": "daily_003", "tibetan": "ཁོ", "chinese": "他", "english": "He", "pronunciation": "kho", "category": "daily"},
209
+ {"id": "daily_004", "tibetan": "མོ", "chinese": "她", "english": "She", "pronunciation": "mo", "category": "daily"},
210
+ {"id": "daily_005", "tibetan": "ཡིན།", "chinese": "是", "english": "Yes/Is", "pronunciation": "yin", "category": "daily"},
211
+ {"id": "daily_006", "tibetan": "མིན།", "chinese": "不是", "english": "No/Is not", "pronunciation": "min", "category": "daily"},
212
+ {"id": "daily_007", "tibetan": "དགོས།", "chinese": "需要", "english": "Need", "pronunciation": "gö", "category": "daily"},
213
+ {"id": "daily_008", "tibetan": "ཆུ", "chinese": "水", "english": "Water", "pronunciation": "chu", "category": "daily"}
214
+ ]
215
+ }
216
+ }
217
+ }
218
+
219
+ def _get_default_alphabet(self) -> Dict[str, Any]:
220
+ """Default Tibetan alphabet data"""
221
+ return {
222
+ "consonants": [
223
+ {"id": "con_01", "letter": "ཀ", "unicode": "U+0F40", "pronunciation": "ka", "description_zh": "清辅音,类似汉语拼音'k'", "description_en": "Voiceless velar stop, like 'k' in 'kite'"},
224
+ {"id": "con_02", "letter": "ཁ", "unicode": "U+0F41", "pronunciation": "kha", "description_zh": "送气音,类似'k'但带强气流", "description_en": "Aspirated 'k', like 'k' in 'king' with more breath"},
225
+ {"id": "con_03", "letter": "ག", "unicode": "U+0F42", "pronunciation": "ga", "description_zh": "浊辅音,类似汉语拼音'g'", "description_en": "Voiced velar stop, like 'g' in 'go'"},
226
+ {"id": "con_04", "letter": "ང", "unicode": "U+0F44", "pronunciation": "nga", "description_zh": "鼻音,类似汉语拼音'ng'", "description_en": "Velar nasal, like 'ng' in 'sing'"},
227
+ {"id": "con_05", "letter": "ཅ", "unicode": "U+0F45", "pronunciation": "ca", "description_zh": "塞擦音,类似汉语拼音'j'", "description_en": "Voiceless palatal affricate, like 'ch' in 'church'"},
228
+ {"id": "con_06", "letter": "ཆ", "unicode": "U+0F46", "pronunciation": "cha", "description_zh": "送气塞擦音", "description_en": "Aspirated 'ch'"},
229
+ {"id": "con_07", "letter": "ཇ", "unicode": "U+0F47", "pronunciation": "ja", "description_zh": "浊塞擦音,类似汉语拼音'zh'", "description_en": "Voiced palatal affricate, like 'j' in 'judge'"},
230
+ {"id": "con_08", "letter": "ཉ", "unicode": "U+0F49", "pronunciation": "nya", "description_zh": "鼻音,类似西班牙语'ñ'", "description_en": "Palatal nasal, like 'ny' in 'canyon'"},
231
+ {"id": "con_09", "letter": "ཏ", "unicode": "U+0F4F", "pronunciation": "ta", "description_zh": "清塞音,类似汉语拼音't'", "description_en": "Voiceless dental stop, like 't' in 'top'"},
232
+ {"id": "con_10", "letter": "ཐ", "unicode": "U+0F50", "pronunciation": "tha", "description_zh": "送气塞音", "description_en": "Aspirated 't'"},
233
+ {"id": "con_11", "letter": "ད", "unicode": "U+0F51", "pronunciation": "da", "description_zh": "浊塞音,类似汉语拼音'd'", "description_en": "Voiced dental stop, like 'd' in 'dog'"},
234
+ {"id": "con_12", "letter": "ན", "unicode": "U+0F53", "pronunciation": "na", "description_zh": "鼻音,类似汉语拼音'n'", "description_en": "Dental nasal, like 'n' in 'no'"},
235
+ {"id": "con_13", "letter": "པ", "unicode": "U+0F54", "pronunciation": "pa", "description_zh": "清塞音,类似汉语拼音'p'", "description_en": "Voiceless bilabial stop, like 'p' in 'pot'"},
236
+ {"id": "con_14", "letter": "ཕ", "unicode": "U+0F55", "pronunciation": "pha", "description_zh": "送气塞音", "description_en": "Aspirated 'p'"},
237
+ {"id": "con_15", "letter": "བ", "unicode": "U+0F56", "pronunciation": "ba", "description_zh": "浊塞音,类似汉语拼音'b'", "description_en": "Voiced bilabial stop, like 'b' in 'boy'"},
238
+ {"id": "con_16", "letter": "མ", "unicode": "U+0F58", "pronunciation": "ma", "description_zh": "鼻音,类似汉语拼音'm'", "description_en": "Bilabial nasal, like 'm' in 'mom'"},
239
+ {"id": "con_17", "letter": "ཙ", "unicode": "U+0F59", "pronunciation": "tsa", "description_zh": "塞擦音,类似汉语拼音'c'", "description_en": "Voiceless alveolar affricate, like 'ts' in 'cats'"},
240
+ {"id": "con_18", "letter": "ཚ", "unicode": "U+0F5A", "pronunciation": "tsha", "description_zh": "送气塞擦音", "description_en": "Aspirated 'ts'"},
241
+ {"id": "con_19", "letter": "ཛ", "unicode": "U+0F5B", "pronunciation": "dza", "description_zh": "浊塞擦音,类似汉语拼音'z'", "description_en": "Voiced alveolar affricate, like 'dz' in 'adze'"},
242
+ {"id": "con_20", "letter": "ཝ", "unicode": "U+0F5D", "pronunciation": "wa", "description_zh": "半元音,类似汉语拼音'w'", "description_en": "Labial approximant, like 'w' in 'water'"},
243
+ {"id": "con_21", "letter": "ཞ", "unicode": "U+0F5E", "pronunciation": "zha", "description_zh": "浊擦音,类似汉语拼音'r'", "description_en": "Voiced retroflex fricative, like 'zh' in 'measure'"},
244
+ {"id": "con_22", "letter": "ཟ", "unicode": "U+0F5F", "pronunciation": "za", "description_zh": "浊擦音,类似汉语拼音'z'", "description_en": "Voiced alveolar fricative, like 'z' in 'zoo'"},
245
+ {"id": "con_23", "letter": "འ", "unicode": "U+0F60", "pronunciation": "'a", "description_zh": "声门塞音,表示元音开始", "description_en": "Glottal stop, used as a vowel carrier"},
246
+ {"id": "con_24", "letter": "ཡ", "unicode": "U+0F61", "pronunciation": "ya", "description_zh": "半元音,类似汉语拼音'y'", "description_en": "Palatal approximant, like 'y' in 'yes'"},
247
+ {"id": "con_25", "letter": "ར", "unicode": "U+0F62", "pronunciation": "ra", "description_zh": "颤音,类似西班牙语'r'", "description_en": "Alveolar trill, like rolled 'r'"},
248
+ {"id": "con_26", "letter": "ལ", "unicode": "U+0F63", "pronunciation": "la", "description_zh": "边音,类似汉语拼音'l'", "description_en": "Alveolar lateral, like 'l' in 'love'"},
249
+ {"id": "con_27", "letter": "ཤ", "unicode": "U+0F64", "pronunciation": "sha", "description_zh": "清擦音,类似汉语拼音'sh'", "description_en": "Voiceless palatal fricative, like 'sh' in 'ship'"},
250
+ {"id": "con_28", "letter": "ས", "unicode": "U+0F66", "pronunciation": "sa", "description_zh": "清擦音,类似汉语拼音's'", "description_en": "Voiceless alveolar fricative, like 's' in 'sun'"},
251
+ {"id": "con_29", "letter": "ཧ", "unicode": "U+0F67", "pronunciation": "ha", "description_zh": "清擦音,类似汉语拼音'h'", "description_en": "Voiceless glottal fricative, like 'h' in 'hat'"},
252
+ {"id": "con_30", "letter": "ཨ", "unicode": "U+0F68", "pronunciation": "a", "description_zh": "元音字母", "description_en": "Vowel letter 'a'"}
253
+ ],
254
+ "vowels": [
255
+ {"id": "vow_01", "letter": "ི", "unicode": "U+0F72", "pronunciation": "i", "description_zh": "元音符号 i,加在辅音上方", "description_en": "Vowel sign 'i', placed above the consonant"},
256
+ {"id": "vow_02", "letter": "ུ", "unicode": "U+0F74", "pronunciation": "u", "description_zh": "元音符号 u,加在辅音下方", "description_en": "Vowel sign 'u', placed below the consonant"},
257
+ {"id": "vow_03", "letter": "ེ", "unicode": "U+0F7A", "pronunciation": "e", "description_zh": "元音符号 e,加在辅音上方", "description_en": "Vowel sign 'e', placed above the consonant"},
258
+ {"id": "vow_04", "letter": "ོ", "unicode": "U+0F7C", "pronunciation": "o", "description_zh": "元音符号 o,加在辅音上方", "description_en": "Vowel sign 'o', placed above the consonant"}
259
+ ]
260
+ }
261
+
262
+
263
+ # Singleton instance
264
+ learning_service = LearningService()
app/services/translate_service.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Translation Service for Tibetan ↔ Chinese ↔ English
3
+ Uses Google Translate with improved parsing and local dictionary
4
+ """
5
+
6
+ import httpx
7
+ from typing import Optional, Dict, Any, List
8
+ import re
9
+
10
+ class TranslationService:
11
+ """Translation service supporting multiple backends"""
12
+
13
+ # Language code mapping
14
+ LANGUAGE_NAMES = {
15
+ "bo": {"zh": "藏语", "en": "Tibetan"},
16
+ "zh": {"zh": "中文", "en": "Chinese"},
17
+ "en": {"zh": "英语", "en": "English"}
18
+ }
19
+
20
+ # Extended dictionary for common phrases
21
+ DICTIONARY = {
22
+ # Tibetan -> Chinese
23
+ ("bo", "zh"): {
24
+ "བཀྲ་ཤིས་བདེ་ལེགས།": "扎西德勒(吉祥如意/你好)",
25
+ "ཐུགས་རྗེ་ཆེ།": "谢谢",
26
+ "བདེ་མོ།": "再见",
27
+ "མཇལ་བར་དགའ་པོ་བྱུང་།": "很高兴见到你",
28
+ "ཡིན།": "是",
29
+ "མིན།": "不是",
30
+ "གཅིག": "一",
31
+ "གཉིས": "二",
32
+ "གསུམ": "三",
33
+ "བཞི": "四",
34
+ "ལྔ": "五",
35
+ "དྲུག": "六",
36
+ "བདུན": "七",
37
+ "བརྒྱད": "八",
38
+ "དགུ": "九",
39
+ "བཅུ": "十",
40
+ "དཀར་པོ": "白色",
41
+ "ནག་པོ": "黑色",
42
+ "དམར་པོ": "红色",
43
+ "སྔོན་པོ": "蓝色",
44
+ "སེར་པོ": "黄色",
45
+ "ལྗང་ཁུ": "绿色",
46
+ "ང": "我",
47
+ "ཁྱོད": "你",
48
+ "ཁོ": "他",
49
+ "མོ": "她",
50
+ "ཆུ": "水",
51
+ "དགོས།": "需要",
52
+ },
53
+ # Tibetan -> English
54
+ ("bo", "en"): {
55
+ "བཀྲ་ཤིས་བདེ་ལེགས།": "Tashi Delek (Hello/Blessings)",
56
+ "ཐུགས་རྗེ་ཆེ།": "Thank you",
57
+ "བདེ་མོ།": "Goodbye",
58
+ "མཇལ་བར་དགའ་པོ་བྱུང་།": "Nice to meet you",
59
+ "ཡིན།": "Yes / Is",
60
+ "མིན།": "No / Is not",
61
+ "གཅིག": "One",
62
+ "གཉིས": "Two",
63
+ "གསུམ": "Three",
64
+ "བཞི": "Four",
65
+ "ལྔ": "Five",
66
+ "དཀར་པོ": "White",
67
+ "ནག་པོ": "Black",
68
+ "དམར་པོ": "Red",
69
+ "སྔོན་པོ": "Blue",
70
+ "ང": "I / Me",
71
+ "ཁྱོད": "You",
72
+ "ཆུ": "Water",
73
+ },
74
+ # Chinese -> Tibetan
75
+ ("zh", "bo"): {
76
+ "你好": "བཀྲ་ཤིས་བདེ་ལེགས།",
77
+ "谢谢": "ཐུགས་རྗེ་ཆེ།",
78
+ "再见": "བདེ་མོ།",
79
+ "很高兴见到你": "མཇལ་བར་དགའ་པོ་བྱུང་།",
80
+ "是": "ཡིན།",
81
+ "不是": "མིན།",
82
+ "一": "གཅིག",
83
+ "二": "གཉིས",
84
+ "三": "གསུམ",
85
+ "四": "བཞི",
86
+ "五": "ལྔ",
87
+ "六": "དྲུག",
88
+ "七": "བདུན",
89
+ "八": "བརྒྱད",
90
+ "九": "དགུ",
91
+ "十": "བཅུ",
92
+ "白色": "དཀར་པོ",
93
+ "黑色": "ནག་པོ",
94
+ "红色": "དམར་པོ",
95
+ "蓝色": "སྔོན་པོ",
96
+ "黄色": "སེར་པོ",
97
+ "绿色": "ལྗང་ཁུ",
98
+ "我": "ང",
99
+ "你": "ཁྱོད",
100
+ "他": "ཁོ",
101
+ "她": "མོ",
102
+ "水": "ཆུ",
103
+ "需要": "དགོས།",
104
+ },
105
+ # English -> Tibetan
106
+ ("en", "bo"): {
107
+ "hello": "བཀྲ་ཤིས་བདེ་ལེགས།",
108
+ "thank you": "ཐུགས་རྗེ་ཆེ།",
109
+ "thanks": "ཐུགས་རྗེ་ཆེ།",
110
+ "goodbye": "བདེ་མོ།",
111
+ "bye": "བདེ་མོ།",
112
+ "yes": "ཡིན།",
113
+ "no": "མིན།",
114
+ "one": "གཅིག",
115
+ "two": "གཉིས",
116
+ "three": "གསུམ",
117
+ "four": "བཞི",
118
+ "five": "ལྔ",
119
+ "white": "དཀར་པོ",
120
+ "black": "ནག་པོ",
121
+ "red": "དམར་པོ",
122
+ "blue": "སྔོན་པོ",
123
+ "yellow": "སེར་པོ",
124
+ "green": "ལྗང་ཁུ",
125
+ "i": "ང",
126
+ "you": "ཁྱོད",
127
+ "water": "ཆུ",
128
+ },
129
+ }
130
+
131
+ # Pronunciation mappings for Tibetan words
132
+ PRONUNCIATION = {
133
+ "བཀྲ་ཤིས་བདེ་ལེགས།": "tashi delek",
134
+ "ཐུགས་རྗེ་ཆེ།": "thuk je che",
135
+ "བདེ་མོ།": "de mo",
136
+ "མཇལ་བར་དགའ་པོ་བྱུང་།": "jal war ga po chung",
137
+ "ཡིན།": "yin",
138
+ "མིན།": "min",
139
+ "གཅིག": "chik",
140
+ "གཉིས": "nyi",
141
+ "གསུམ": "sum",
142
+ "བཞི": "shi",
143
+ "ལྔ": "nga",
144
+ "དྲུག": "druk",
145
+ "བདུན": "dün",
146
+ "བརྒྱད": "gyé",
147
+ "དགུ": "gu",
148
+ "བཅུ": "chu",
149
+ "དཀར་པོ": "kar po",
150
+ "ནག་པོ": "nak po",
151
+ "དམར་པོ": "mar po",
152
+ "སྔོན་པོ": "ngön po",
153
+ "སེར་པོ": "ser po",
154
+ "ལྗང་ཁུ": "jang khu",
155
+ "ང": "nga",
156
+ "ཁྱོད": "khyö",
157
+ "ཁོ": "kho",
158
+ "མོ": "mo",
159
+ "ཆུ": "chu",
160
+ "དགོས།": "gö",
161
+ }
162
+
163
+ def __init__(self):
164
+ self.client = httpx.AsyncClient(timeout=30.0)
165
+
166
+ async def translate(
167
+ self,
168
+ text: str,
169
+ source: str,
170
+ target: str
171
+ ) -> Dict[str, Any]:
172
+ """
173
+ Translate text between languages
174
+ """
175
+ text = text.strip()
176
+
177
+ if not text:
178
+ return {
179
+ "success": False,
180
+ "translation": None,
181
+ "pronunciation": None,
182
+ "source_language": self.LANGUAGE_NAMES.get(source, {}).get("en", source),
183
+ "target_language": self.LANGUAGE_NAMES.get(target, {}).get("en", target),
184
+ "error": "Empty text"
185
+ }
186
+
187
+ if source == target:
188
+ return {
189
+ "success": True,
190
+ "translation": text,
191
+ "pronunciation": self.PRONUNCIATION.get(text) if source == "bo" else None,
192
+ "source_language": self.LANGUAGE_NAMES[source]["en"],
193
+ "target_language": self.LANGUAGE_NAMES[target]["en"]
194
+ }
195
+
196
+ # Try dictionary first
197
+ result = self._translate_dictionary(text, source, target)
198
+ if result.get("success"):
199
+ return result
200
+
201
+ # Try Google Translate as fallback
202
+ result = await self._translate_google(text, source, target)
203
+ if result and result.get("success"):
204
+ return result
205
+
206
+ # Return error if nothing works
207
+ return {
208
+ "success": False,
209
+ "translation": None,
210
+ "pronunciation": None,
211
+ "source_language": self.LANGUAGE_NAMES.get(source, {}).get("en", source),
212
+ "target_language": self.LANGUAGE_NAMES.get(target, {}).get("en", target),
213
+ "error": f"无法翻译此文本。"
214
+ }
215
+
216
+ def _translate_dictionary(
217
+ self,
218
+ text: str,
219
+ source: str,
220
+ target: str
221
+ ) -> Dict[str, Any]:
222
+ """Dictionary-based translation"""
223
+ key = (source, target)
224
+ lookup = self.DICTIONARY.get(key, {})
225
+
226
+ # Normalize text: strip punctuation like '?' or '!' for lookup
227
+ clean_text = text.strip().rstrip("?.!!。")
228
+ lookup_text_lower = clean_text.lower()
229
+
230
+ translation = lookup.get(lookup_text_lower) or lookup.get(clean_text) or lookup.get(text.strip())
231
+
232
+ if translation:
233
+ pronunciation = None
234
+ if target == "bo":
235
+ pronunciation = self.PRONUNCIATION.get(translation)
236
+ elif source == "bo":
237
+ pronunciation = self.PRONUNCIATION.get(text.strip())
238
+
239
+ return {
240
+ "success": True,
241
+ "translation": translation,
242
+ "pronunciation": pronunciation,
243
+ "source_language": self.LANGUAGE_NAMES[source]["en"],
244
+ "target_language": self.LANGUAGE_NAMES[target]["en"]
245
+ }
246
+
247
+ return {"success": False}
248
+
249
+ async def _translate_google(
250
+ self,
251
+ text: str,
252
+ source: str,
253
+ target: str
254
+ ) -> Optional[Dict[str, Any]]:
255
+ """Translate using Google Translate API"""
256
+ try:
257
+ # Map language codes
258
+ lang_map = {"bo": "bo", "zh": "zh-CN", "en": "en"}
259
+ src = lang_map.get(source, source)
260
+ tgt = lang_map.get(target, target)
261
+
262
+ url = "https://translate.googleapis.com/translate_a/single"
263
+ params = [
264
+ ("client", "gtx"),
265
+ ("sl", src),
266
+ ("tl", tgt),
267
+ ("dt", "t"),
268
+ ("dt", "rm"),
269
+ ("q", text)
270
+ ]
271
+
272
+ response = await self.client.get(url, params=params)
273
+
274
+ if response.status_code == 200:
275
+ data = response.json()
276
+
277
+ # Parse response
278
+ translation = ""
279
+ pronunciation = None
280
+
281
+ if data and isinstance(data, list) and len(data) > 0:
282
+ # Extract translation
283
+ if data[0] and isinstance(data[0], list):
284
+ for part in data[0]:
285
+ if isinstance(part, list) and len(part) > 0 and part[0]:
286
+ translation += str(part[0])
287
+
288
+ # Pronunciation extraction
289
+ # IMPORTANT: For target=bo (Tibetan), Google often returns corrupt or Source-language pronunciation (like Pinyin)
290
+ # We MUST DISABLE pronunciation for Tibetan target unless we are sure it's valid.
291
+
292
+ if target != "bo":
293
+ # Only try to extract pronunciation if target is NOT Tibetan
294
+ # Because for Tibetan target, Google returns garbage or Pinyin
295
+ for item in data[0]:
296
+ if isinstance(item, list) and len(item) > 2 and isinstance(item[2], str) and item[2]:
297
+ pronunciation = item[2]
298
+ break
299
+
300
+ # If we accidentally got pinyin for Tibetan, clear it
301
+ # Pinyin usually has tone marks, Tibetan romanization usually doesn't (or looks different)
302
+ # But safest is just to never trust Google's pronunciation for Tibetan target
303
+ if target == "bo":
304
+ pronunciation = None
305
+
306
+ if translation:
307
+ return {
308
+ "success": True,
309
+ "translation": translation,
310
+ "pronunciation": pronunciation,
311
+ "source_language": self.LANGUAGE_NAMES[source]["en"],
312
+ "target_language": self.LANGUAGE_NAMES[target]["en"]
313
+ }
314
+
315
+ return None
316
+
317
+ except Exception as e:
318
+ print(f"Google Translate error: {e}")
319
+ return None
320
+
321
+ async def close(self):
322
+ """Close the HTTP client"""
323
+ await self.client.aclose()
324
+
325
+
326
+ # Singleton instance
327
+ translate_service = TranslationService()
app/services/tts_service.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tibetan Text-to-Speech Service
3
+ Migrated from original Streamlit app, using Meta's MMS (facebook/mms-tts-bod) model
4
+ """
5
+
6
+ import torch
7
+ import numpy as np
8
+ import scipy.signal
9
+ import scipy.io.wavfile
10
+ from io import BytesIO
11
+ from typing import Tuple, Optional
12
+ import asyncio
13
+ from functools import lru_cache
14
+ import warnings
15
+
16
+ warnings.filterwarnings('ignore')
17
+
18
+
19
+ class TibetanTTSService:
20
+ """Backend service for Tibetan Text-to-Speech using Meta's MMS model"""
21
+
22
+ def __init__(self, model_name: str = "facebook/mms-tts-bod"):
23
+ """Initialize the TTS service"""
24
+ self.model_name = model_name
25
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
26
+ self.model = None
27
+ self.tokenizer = None
28
+ self.sample_rate = 16000 # Default sample rate for MMS-TTS
29
+ self._loaded = False
30
+
31
+ async def load_model(self) -> bool:
32
+ """Load the model and tokenizer (async wrapper)"""
33
+ # Run in executor to avoid blocking
34
+ loop = asyncio.get_event_loop()
35
+ return await loop.run_in_executor(None, self._load_model_sync)
36
+
37
+ def _load_model_sync(self) -> bool:
38
+ """Synchronous model loading"""
39
+ try:
40
+ from transformers import VitsModel, AutoTokenizer
41
+
42
+ print(f"Loading model {self.model_name} on {self.device}...")
43
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
44
+ self.model = VitsModel.from_pretrained(self.model_name)
45
+ self.model.to(self.device)
46
+ self.model.eval()
47
+ self._loaded = True
48
+ print(f"✅ Model loaded successfully on {self.device}")
49
+ return True
50
+ except Exception as e:
51
+ print(f"❌ Error loading model: {str(e)}")
52
+ return False
53
+
54
+ def is_loaded(self) -> bool:
55
+ """Check if model is loaded"""
56
+ return self._loaded
57
+
58
+ def get_device(self) -> str:
59
+ """Get current device"""
60
+ return self.device
61
+
62
+ def preprocess_text(self, text: str) -> str:
63
+ """Normalize and prepare text for the model"""
64
+ # Basic preprocessing: strip whitespace
65
+ text = text.strip()
66
+
67
+ # Check if text is empty
68
+ if not text:
69
+ raise ValueError("Input text cannot be empty")
70
+
71
+ # Check text length (MMS models typically have a max length)
72
+ if len(text) > 1000:
73
+ raise ValueError("Input text is too long. Please limit to 1000 characters.")
74
+
75
+ return text
76
+
77
+ def adjust_speed(self, audio: np.ndarray, original_rate: int, speed_factor: float) -> Tuple[np.ndarray, int]:
78
+ """
79
+ Adjust audio speed without changing pitch using resampling
80
+
81
+ Args:
82
+ audio: numpy array of audio samples
83
+ original_rate: original sample rate
84
+ speed_factor: speed multiplier (0.5 = slower, 2.0 = faster)
85
+
86
+ Returns:
87
+ Modified audio array and sample rate
88
+ """
89
+ if speed_factor == 1.0:
90
+ return audio, original_rate
91
+
92
+ # Calculate the number of samples in the output
93
+ num_samples = int(len(audio) / speed_factor)
94
+
95
+ # Use scipy's resample for high-quality speed adjustment
96
+ resampled_audio = scipy.signal.resample(audio, num_samples)
97
+
98
+ return resampled_audio, original_rate
99
+
100
+ async def generate_audio(self, text: str, speed: float = 1.0) -> Tuple[np.ndarray, int]:
101
+ """
102
+ Generate audio from text with optional speed adjustment (async)
103
+
104
+ Args:
105
+ text: Input Tibetan text
106
+ speed: Speed factor (0.5 to 2.0)
107
+
108
+ Returns:
109
+ tuple: (audio_array, sample_rate)
110
+ """
111
+ # Run in executor to avoid blocking
112
+ loop = asyncio.get_event_loop()
113
+ return await loop.run_in_executor(None, self._generate_audio_sync, text, speed)
114
+
115
+ def _generate_audio_sync(self, text: str, speed: float = 1.0) -> Tuple[np.ndarray, int]:
116
+ """Synchronous audio generation"""
117
+ if not self._loaded:
118
+ raise RuntimeError("TTS model not loaded. Call load_model() first.")
119
+
120
+ try:
121
+ # Preprocess text
122
+ text = self.preprocess_text(text)
123
+
124
+ # Tokenize input
125
+ inputs = self.tokenizer(text, return_tensors="pt")
126
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
127
+
128
+ # Generate audio
129
+ with torch.no_grad():
130
+ output = self.model(**inputs)
131
+ audio = output.waveform
132
+
133
+ # Convert to numpy array
134
+ audio_np = audio.squeeze().cpu().numpy()
135
+
136
+ # Normalize audio to prevent clipping
137
+ audio_np = audio_np / np.max(np.abs(audio_np)) * 0.95
138
+
139
+ # Apply speed adjustment
140
+ if speed != 1.0:
141
+ audio_np, sample_rate = self.adjust_speed(audio_np, self.sample_rate, speed)
142
+ else:
143
+ sample_rate = self.sample_rate
144
+
145
+ return audio_np, sample_rate
146
+
147
+ except Exception as e:
148
+ raise RuntimeError(f"Error generating audio: {str(e)}")
149
+
150
+ def audio_to_wav_bytes(self, audio: np.ndarray, sample_rate: int) -> BytesIO:
151
+ """Convert audio array to WAV bytes for response"""
152
+ byte_io = BytesIO()
153
+ # Convert to 16-bit PCM
154
+ audio_int16 = (audio * 32767).astype(np.int16)
155
+ scipy.io.wavfile.write(byte_io, sample_rate, audio_int16)
156
+ byte_io.seek(0)
157
+ return byte_io
158
+
159
+ def get_audio_duration(self, audio: np.ndarray, sample_rate: int) -> float:
160
+ """Get audio duration in seconds"""
161
+ return len(audio) / sample_rate
162
+
163
+
164
+ # Singleton instance
165
+ tts_service = TibetanTTSService()
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI Backend Dependencies
2
+ fastapi>=0.104.0
3
+ uvicorn[standard]>=0.24.0
4
+ python-multipart>=0.0.6
5
+
6
+ # TTS Dependencies (from original app)
7
+ torch>=2.0.0
8
+ transformers>=4.30.0
9
+ scipy>=1.10.0
10
+ numpy>=1.24.0
11
+ soundfile>=0.12.1
12
+
13
+ # Translation & HTTP
14
+ httpx>=0.25.0
15
+ aiohttp>=3.9.0
16
+
17
+ # Database
18
+ sqlalchemy>=2.0.0
19
+ aiosqlite>=0.19.0
20
+
21
+ # Utilities
22
+ pydantic>=2.5.0
23
+ python-dotenv>=1.0.0