Spaces:
Running
Running
| import os | |
| import pandas as pd | |
| from datetime import datetime | |
| import tempfile | |
| from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, Form | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from openai import AsyncOpenAI | |
| from dotenv import load_dotenv | |
| from src.dialect_rules import ( | |
| hausa_variety_instruction, | |
| nigerian_variety_instruction, | |
| nigerian_variety_retry_prompt, | |
| nigerian_variety_retry_reason, | |
| ) | |
| load_dotenv() | |
| app = FastAPI(title="PurePolyglot Hybrid Backend", version="1.0.0") | |
| # Enable CORS for the Vite SPA | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Attempt Qwen first, fallback to Groq | |
| QWEN_API_KEY = os.getenv("QWEN_API_KEY") | |
| QWEN_BASE_URL = os.getenv("QWEN_BASE_URL", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1") | |
| QWEN_MODEL_NAME = os.getenv("QWEN_MODEL_NAME", "qwen3-coder-80b-instruct") | |
| GROQ_API_KEY = os.getenv("GROQ_API_KEY") | |
| GROQ_MODEL_NAME = os.getenv("GROQ_MODEL_NAME", "llama-3.3-70b-versatile") | |
| DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") | |
| DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com") | |
| DEEPSEEK_MODEL_NAME = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") | |
| GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai/") | |
| GEMINI_MODEL_NAME = os.getenv("GEMINI_MODEL_NAME", "gemini-2.5-flash") | |
| OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") | |
| OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") | |
| OPENROUTER_FREE_MODEL_NAME = os.getenv("OPENROUTER_FREE_MODEL_NAME", "openrouter/free") | |
| OPENROUTER_NEMOTRON_MODEL_NAME = os.getenv("OPENROUTER_NEMOTRON_MODEL_NAME", "nvidia/nemotron-3-nano-30b-a3b:free") | |
| OPENROUTER_GPT_OSS_MODEL_NAME = os.getenv("OPENROUTER_GPT_OSS_MODEL_NAME", "openai/gpt-oss-20b:free") | |
| OPENROUTER_LFM_MODEL_NAME = os.getenv("OPENROUTER_LFM_MODEL_NAME", "liquid/lfm-2.5-1.2b-instruct:free") | |
| AI_ROUTES = {} | |
| if QWEN_API_KEY and QWEN_API_KEY != "your-api-key-here": | |
| AI_ROUTES["qwen"] = (AsyncOpenAI(api_key=QWEN_API_KEY, base_url=QWEN_BASE_URL), QWEN_MODEL_NAME, "Qwen Hybrid Node") | |
| if GROQ_API_KEY: | |
| AI_ROUTES["llama"] = (AsyncOpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1"), GROQ_MODEL_NAME, "Groq Llama Hybrid Node") | |
| if DEEPSEEK_API_KEY: | |
| AI_ROUTES["deepseek"] = (AsyncOpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL), DEEPSEEK_MODEL_NAME, "DeepSeek Hybrid Node") | |
| if GEMINI_API_KEY: | |
| AI_ROUTES["gemini"] = (AsyncOpenAI(api_key=GEMINI_API_KEY, base_url=GEMINI_BASE_URL), GEMINI_MODEL_NAME, "Gemini Hybrid Node") | |
| if OPENROUTER_API_KEY: | |
| openrouter_client = AsyncOpenAI(api_key=OPENROUTER_API_KEY, base_url=OPENROUTER_BASE_URL) | |
| AI_ROUTES["openrouter-free"] = (openrouter_client, OPENROUTER_FREE_MODEL_NAME, "OpenRouter Free Node") | |
| AI_ROUTES["nemotron"] = (openrouter_client, OPENROUTER_NEMOTRON_MODEL_NAME, "OpenRouter Nemotron Free Node") | |
| AI_ROUTES["gpt-oss"] = (openrouter_client, OPENROUTER_GPT_OSS_MODEL_NAME, "OpenRouter GPT-OSS Free Node") | |
| AI_ROUTES["lfm"] = (openrouter_client, OPENROUTER_LFM_MODEL_NAME, "OpenRouter LFM Free Node") | |
| if "qwen" in AI_ROUTES: | |
| client, MODEL_NAME, NODE_TYPE = AI_ROUTES["qwen"] | |
| elif "llama" in AI_ROUTES: | |
| client, MODEL_NAME, NODE_TYPE = AI_ROUTES["llama"] | |
| elif "deepseek" in AI_ROUTES: | |
| client, MODEL_NAME, NODE_TYPE = AI_ROUTES["deepseek"] | |
| elif "gemini" in AI_ROUTES: | |
| client, MODEL_NAME, NODE_TYPE = AI_ROUTES["gemini"] | |
| elif "openrouter-free" in AI_ROUTES: | |
| client, MODEL_NAME, NODE_TYPE = AI_ROUTES["openrouter-free"] | |
| else: | |
| client = None | |
| MODEL_NAME = None | |
| NODE_TYPE = "Offline" | |
| def resolve_ai_route(ai_model: str, source_label: str, target_label: str, text: str): | |
| choice = (ai_model or "auto").strip().lower() | |
| if choice == "auto": | |
| hint = f"{source_label} {target_label} {text}".lower() | |
| if any(token in hint for token in [ | |
| "korean", "hangul", "chinese", "mandarin", "cantonese", "arabic", | |
| "japanese", "thai", "vietnamese", "code-switch", "multilingual" | |
| ]): | |
| choice = "qwen" | |
| elif any(token in hint for token in [ | |
| "reason", "explain", "ambiguity", "semantic", "pragmatic", "cultural", "review", "oracle" | |
| ]): | |
| choice = "nemotron" | |
| else: | |
| choice = "llama" | |
| ordered = [choice, "qwen", "llama", "nemotron", "gpt-oss", "lfm", "openrouter-free", "deepseek", "gemini"] | |
| for route_name in ordered: | |
| if route_name in AI_ROUTES: | |
| return AI_ROUTES[route_name] | |
| return client, MODEL_NAME, NODE_TYPE | |
| class TranslationRequest(BaseModel): | |
| text: str | |
| source_language: str = "Unknown" | |
| source_dialect: str = "Standard" | |
| target_language: str | |
| target_dialect: str | |
| user_key: str = "Polyglot Player" | |
| ai_model: str = "auto" | |
| class TranslationResponse(BaseModel): | |
| original_text: str | |
| translated_text: str | |
| target_dialect: str | |
| node: str | |
| def log_to_pending_queue(request: TranslationRequest, translation: str, node_type: str = None): | |
| """Background task to log translations to pending_approvals.csv""" | |
| pending_file = "/app/pending_approvals.csv" if os.path.exists("/app") else "pending_approvals.csv" | |
| new_entry = { | |
| "User": request.user_key, | |
| "Data_Origin": "Game: Polyglot Chat", | |
| "Utterance": request.text, | |
| "Dialect": request.target_dialect, | |
| "Clarification": translation, | |
| "Clarification_Source": f"{node_type or NODE_TYPE} / {request.ai_model}", | |
| "Tone": "Neutral / Conversational", | |
| "Context": f"Translated from {request.source_language} ({request.source_dialect})", | |
| "Pragmatic_Analysis": "", | |
| "Audio": "", | |
| "Timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| "Chain_ID": "", | |
| "Approvers": "", | |
| "Language": request.target_language | |
| } | |
| try: | |
| if os.path.exists(pending_file): | |
| df = pd.read_csv(pending_file) | |
| else: | |
| df = pd.DataFrame(columns=new_entry.keys()) | |
| df = pd.concat([df, pd.DataFrame([new_entry])], ignore_index=True) | |
| df.to_csv(pending_file, index=False) | |
| print(f"✅ Logged to {pending_file} for peer review.") | |
| # Sync to Hugging Face PureChain_Dataset if HF_TOKEN is present | |
| from huggingface_hub import HfApi | |
| hf_token = os.environ.get("HF_TOKEN") | |
| if hf_token: | |
| api = HfApi(token=hf_token) | |
| api.upload_file( | |
| path_or_fileobj=pending_file, | |
| path_in_repo="pending_approvals.csv", | |
| repo_id="toecm/PureChain_Dataset", | |
| repo_type="dataset", | |
| commit_message="🔄 Auto-sync: Polyglot Chat text translation added to pending approvals queue" | |
| ) | |
| print("☁️ Synced Polyglot Chat text translation entry to HF PureChain_Dataset.") | |
| except Exception as e: | |
| print(f"Failed to save translation to pending queue: {e}") | |
| async def translate_text(request: TranslationRequest, background_tasks: BackgroundTasks): | |
| source_label = f"{request.source_language} ({request.source_dialect})" | |
| target_label = f"{request.target_language} ({request.target_dialect})" | |
| route_client, route_model, route_node = resolve_ai_route(request.ai_model, source_label, target_label, request.text) | |
| if not route_client: | |
| raise HTTPException(status_code=500, detail="No LLM API key configured for Qwen, Llama/Groq, OpenRouter, DeepSeek, or Gemini.") | |
| variety_instruction = "\n".join(filter(None, [ | |
| nigerian_variety_instruction(source_label, target_label), | |
| hausa_variety_instruction(source_label, target_label), | |
| ])) | |
| system_prompt = ( | |
| f"You are an expert polyglot interpreter specializing in deep cultural and linguistic dialects.\n" | |
| f"Translate the following text from {source_label} " | |
| f"into {target_label}.\n" | |
| f"Output ONLY the raw translated string. Do not include quotes, explanations, or thinking traces.\n" | |
| f"Use the target language's native writing system. Korean, Jeju, and Satoori outputs must use Hangul only, not romanization and not Chinese or Japanese characters. " | |
| f"Arabic outputs must use Arabic script. Igbo outputs must keep proper Igbo letters and tone/dot marks such as ị, ụ, ọ, ṅ, ẹ, á, and à where natural.\n" | |
| f"{variety_instruction}" | |
| ) | |
| try: | |
| response = await route_client.chat.completions.create( | |
| model=route_model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": request.text} | |
| ], | |
| temperature=0.3, | |
| max_tokens=256 | |
| ) | |
| translated_text = response.choices[0].message.content.strip() | |
| boundary_reason = nigerian_variety_retry_reason(translated_text, target_label) | |
| if boundary_reason: | |
| retry_prompt = system_prompt + "\n" + nigerian_variety_retry_prompt( | |
| request.text, source_label, target_label, translated_text, boundary_reason | |
| ) | |
| retry_response = await route_client.chat.completions.create( | |
| model=route_model, | |
| messages=[ | |
| {"role": "system", "content": retry_prompt}, | |
| {"role": "user", "content": request.text} | |
| ], | |
| temperature=0.2, | |
| max_tokens=256 | |
| ) | |
| retry_text = retry_response.choices[0].message.content.strip() | |
| if retry_text and not nigerian_variety_retry_reason(retry_text, target_label): | |
| translated_text = retry_text | |
| # Log to CSV in the background | |
| background_tasks.add_task(log_to_pending_queue, request, translated_text, route_node) | |
| return TranslationResponse( | |
| original_text=request.text, | |
| translated_text=translated_text, | |
| target_dialect=f"{request.target_language} ({request.target_dialect})", | |
| node=route_node | |
| ) | |
| except Exception as e: | |
| print(f"Error calling {route_node} API: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def _get_shared_acoustic_agent(): | |
| try: | |
| import main | |
| return getattr(main, "ACOUSTIC_AGENT", None) | |
| except Exception as e: | |
| print(f"Acoustic agent lookup failed: {e}") | |
| return None | |
| async def acoustic_models(): | |
| agent = _get_shared_acoustic_agent() | |
| if not agent or not hasattr(agent, "models"): | |
| return { | |
| "ok": False, | |
| "service": "pure-acoustic-agent", | |
| "error": "Acoustic agent unavailable. Run through backend/app.py or the Gradio app bootstrap.", | |
| } | |
| return agent.models() | |
| async def acoustic_transcribe( | |
| audio: UploadFile = File(...), | |
| language: str = Form(""), | |
| dialect: str = Form(""), | |
| speech_model: str = Form("auto"), | |
| audio_sanitation: str = Form("on"), | |
| ): | |
| agent = _get_shared_acoustic_agent() | |
| if not agent or not hasattr(agent, "transcribe"): | |
| raise HTTPException(status_code=503, detail="Acoustic agent unavailable.") | |
| suffix = os.path.splitext(audio.filename or "")[1] or ".webm" | |
| temp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: | |
| temp_path = tmp.name | |
| tmp.write(await audio.read()) | |
| return agent.transcribe( | |
| temp_path, | |
| language=language, | |
| dialect=dialect, | |
| speech_model=speech_model, | |
| audio_sanitation=audio_sanitation, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| if temp_path and os.path.exists(temp_path): | |
| try: | |
| os.remove(temp_path) | |
| except Exception: | |
| pass | |
| class AcousticTTSRequest(BaseModel): | |
| text: str = "" | |
| language: str = "" | |
| dialect: str = "" | |
| voice: str = "browser-native" | |
| async def acoustic_tts(request: AcousticTTSRequest): | |
| agent = _get_shared_acoustic_agent() | |
| if not agent or not hasattr(agent, "tts"): | |
| raise HTTPException(status_code=503, detail="Acoustic agent unavailable.") | |
| return agent.tts( | |
| request.text, | |
| language=request.language, | |
| dialect=request.dialect, | |
| voice=request.voice, | |
| ) | |
| async def root(): | |
| return {"message": f"PurePolyglot Hybrid Backend Online ({NODE_TYPE})"} | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("api:app", host="0.0.0.0", port=8000, reload=True) | |