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}") @app.post("/api/translate", response_model=TranslationResponse) 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 @app.get("/api/acoustic/models") 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() @app.post("/api/acoustic/transcribe") 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" @app.post("/api/acoustic/tts") 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, ) @app.get("/") 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)