Spaces:
Running
Running
| import os | |
| import pandas as pd | |
| from datetime import datetime | |
| import tempfile | |
| import threading | |
| import uuid | |
| from fastapi import FastAPI, HTTPException, UploadFile, File, Form | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| 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 | |
| class PolyglotReviewSubmission(BaseModel): | |
| interaction_id: str = Field(min_length=8, max_length=128) | |
| supersedes_interaction_id: str = Field(default="", max_length=128) | |
| app_source: str = Field(default="PurePolyglot", min_length=2, max_length=64) | |
| user_key: str = Field(default="Polyglot Player", max_length=256) | |
| source_text: str = Field(min_length=1, max_length=10000) | |
| source_input_mode: str = Field(default="text", max_length=32) | |
| machine_transcript_initial: str = Field(default="", max_length=10000) | |
| user_transcript_final: str = Field(default="", max_length=10000) | |
| machine_translation_initial: str = Field(min_length=1, max_length=10000) | |
| user_translation_final: str = Field(min_length=1, max_length=10000) | |
| source_language: str = Field(default="Unknown", max_length=128) | |
| source_dialect: str = Field(default="Standard", max_length=256) | |
| target_language: str = Field(default="Unknown", max_length=128) | |
| target_dialect: str = Field(default="Standard", max_length=256) | |
| asr_model: str = Field(default="", max_length=128) | |
| audio_sanitation: bool = False | |
| ai_model: str = Field(default="auto", max_length=128) | |
| translation_route: str = Field(default="frontend-reviewed", max_length=128) | |
| consent_confirmed: bool = False | |
| consent_version: str = Field(default="polyglot-reviewed-submit-v1", max_length=128) | |
| _PENDING_QUEUE_LOCK = threading.Lock() | |
| def _pending_queue_path(): | |
| configured = os.environ.get("PENDING_APPROVALS_FILE", "").strip() | |
| if configured: | |
| return configured | |
| return "/app/pending_approvals.csv" if os.path.exists("/app") else "pending_approvals.csv" | |
| def _translation_edit_distance(initial_text: str, final_text: str): | |
| initial = str(initial_text or "").casefold().split() | |
| final = str(final_text or "").casefold().split() | |
| if not initial and not final: | |
| return 0.0 | |
| previous = list(range(len(final) + 1)) | |
| for row_index, initial_token in enumerate(initial, start=1): | |
| current = [row_index] | |
| for column_index, final_token in enumerate(final, start=1): | |
| substitution_cost = 0 if initial_token == final_token else 1 | |
| current.append( | |
| min( | |
| current[-1] + 1, | |
| previous[column_index] + 1, | |
| previous[column_index - 1] + substitution_cost, | |
| ) | |
| ) | |
| previous = current | |
| return round(previous[-1] / max(len(initial), len(final), 1), 4) | |
| def _sync_pending_queue_to_hub(pending_file: str, queue_id: str): | |
| hf_token = os.environ.get("HF_TOKEN") | |
| if not hf_token: | |
| return False | |
| from huggingface_hub import HfApi | |
| 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=f"Reviewed Polyglot Chat submission {queue_id}", | |
| ) | |
| return True | |
| def _append_polyglot_review(request: PolyglotReviewSubmission): | |
| pending_file = _pending_queue_path() | |
| submitted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| queue_id = f"polyglot-{uuid.uuid4()}" | |
| final_source_text = (request.user_transcript_final or request.source_text).strip() | |
| new_entry = { | |
| # Legacy reviewer fields remain populated for existing admin tools. | |
| "User": request.user_key, | |
| "Data_Origin": "Game: Polyglot Chat", | |
| "Utterance": final_source_text, | |
| "Dialect": request.target_dialect.strip(), | |
| "Clarification": request.user_translation_final.strip(), | |
| "Clarification_Source": f"User-reviewed / {request.ai_model}", | |
| "Tone": "Neutral / Conversational", | |
| "Context": f"Translated from {request.source_language} ({request.source_dialect})", | |
| "Pragmatic_Analysis": "", | |
| "Audio": "", | |
| "Timestamp": submitted_at, | |
| "Chain_ID": "", | |
| "Approvers": "", | |
| "Language": request.target_language.strip(), | |
| # Reviewed-translation audit and fine-tuning fields. | |
| "Queue_ID": queue_id, | |
| "Interaction_ID": request.interaction_id.strip(), | |
| "Supersedes_Interaction_ID": request.supersedes_interaction_id.strip(), | |
| "App_Source": request.app_source.strip(), | |
| "Submission_Status": "Pending Review", | |
| "Consent_Confirmed": "true", | |
| "Consent_Version": request.consent_version.strip(), | |
| "Source_Language": request.source_language.strip(), | |
| "Source_Dialect": request.source_dialect.strip(), | |
| "Source_Input_Mode": request.source_input_mode.strip().lower() or "text", | |
| "Machine_Transcript_Initial": request.machine_transcript_initial.strip(), | |
| "User_Transcript_Final": final_source_text, | |
| "Transcript_Edit_Distance": _translation_edit_distance( | |
| request.machine_transcript_initial, | |
| final_source_text, | |
| ) if request.machine_transcript_initial.strip() else 0.0, | |
| "ASR_Model": request.asr_model.strip(), | |
| "Audio_Sanitation": str(request.audio_sanitation).lower(), | |
| "Audio_Retained": "false", | |
| "Target_Language": request.target_language.strip(), | |
| "Target_Dialect": request.target_dialect.strip(), | |
| "Machine_Translation_Initial": request.machine_translation_initial.strip(), | |
| "User_Translation_Final": request.user_translation_final.strip(), | |
| "Translation_Edit_Distance": _translation_edit_distance( | |
| request.machine_translation_initial, | |
| request.user_translation_final, | |
| ), | |
| "AI_Model": request.ai_model.strip(), | |
| "Translation_Route": request.translation_route.strip(), | |
| "Review_Submitted_At": submitted_at, | |
| } | |
| with _PENDING_QUEUE_LOCK: | |
| if os.path.exists(pending_file): | |
| df = pd.read_csv(pending_file, dtype=str).fillna("") | |
| else: | |
| parent = os.path.dirname(os.path.abspath(pending_file)) | |
| os.makedirs(parent, exist_ok=True) | |
| df = pd.DataFrame() | |
| if "Interaction_ID" in df.columns: | |
| duplicate = df[df["Interaction_ID"].astype(str) == request.interaction_id.strip()] | |
| if not duplicate.empty: | |
| existing = duplicate.iloc[0] | |
| existing_final = str( | |
| existing.get("User_Translation_Final", "") | |
| or existing.get("Clarification", "") | |
| ).strip() | |
| if existing_final != request.user_translation_final.strip(): | |
| raise HTTPException( | |
| status_code=409, | |
| detail="This interaction ID already belongs to a different reviewed translation.", | |
| ) | |
| existing_queue_id = str(existing.get("Queue_ID", "")) | |
| synced_to_hub = _sync_pending_queue_to_hub( | |
| pending_file, | |
| existing_queue_id or request.interaction_id.strip(), | |
| ) | |
| return { | |
| "queued": True, | |
| "duplicate": True, | |
| "queue_id": existing_queue_id, | |
| "status": str(existing.get("Submission_Status", "Pending Review")), | |
| "synced_to_hub": synced_to_hub, | |
| } | |
| for column in new_entry: | |
| if column not in df.columns: | |
| df[column] = "" | |
| row = {column: new_entry.get(column, "") for column in df.columns} | |
| df.loc[len(df)] = row | |
| temp_file = f"{pending_file}.tmp" | |
| df.to_csv(temp_file, index=False) | |
| os.replace(temp_file, pending_file) | |
| synced_to_hub = _sync_pending_queue_to_hub(pending_file, queue_id) | |
| return { | |
| "queued": True, | |
| "duplicate": False, | |
| "queue_id": queue_id, | |
| "status": "Pending Review", | |
| "synced_to_hub": synced_to_hub, | |
| } | |
| def submit_polyglot_review(request: PolyglotReviewSubmission): | |
| if not request.consent_confirmed: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Explicit consent is required before a translation can enter pending review.", | |
| ) | |
| try: | |
| return _append_polyglot_review(request) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| print(f"Failed to submit reviewed Polyglot Chat entry: {exc}") | |
| raise HTTPException(status_code=503, detail="Pending review submission failed.") from exc | |
| async def translate_text(request: TranslationRequest): | |
| 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 | |
| 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 sys | |
| main_module = sys.modules.get("main") or sys.modules.get("__main__") | |
| return getattr(main_module, "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) | |