#!/usr/bin/env python3 """ Balochi Latin translation API server for Syáhag. Priority (understanding → English → target): 1) dataset.xlsx / dataset.csv — sentence search 2) words_balochi_to_english.xlsx — word senses 3) Balochi_grammar_rules.pdf — grammar understanding 4) AI (Groq) Balochi→English grounded by (1)–(3) 5) AI / Google Translate English→selected language TranslateGemma adapter is NOT used. """ from __future__ import annotations import asyncio import os import re import time import unicodedata from collections import Counter, defaultdict from heapq import nlargest from pathlib import Path import pandas as pd from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from google import genai from google.genai import types from google.genai.errors import APIError from pydantic import BaseModel, Field from pypdf import PdfReader # Understanding stack — no TranslateGemma adapter. GENAI_SUPPORT = [ "dataset", "lexicon", "grammar", ] PRIORITIES = list(GENAI_SUPPORT) DEFAULT_GROQ_MODEL = os.getenv("GROQ_MODEL", "openai/gpt-oss-120b") GROQ_SUPPORT = [ f"groq:{DEFAULT_GROQ_MODEL}", "google_translate", ] # Adapter disabled for labzank pipeline (dataset/lexicon/grammar → EN → AI). os.environ.setdefault("ENABLE_TRANSLATEGEMMA", "0") # Skip LoRA drafts above these limits (long labzank posts time out on ZeroGPU). TG_MAX_CHARS = int(os.getenv("TRANSLATEGEMMA_MAX_CHARS", "1200")) TG_MAX_WORDS = int(os.getenv("TRANSLATEGEMMA_MAX_WORDS", "180")) TG_MAX_LINES = int(os.getenv("TRANSLATEGEMMA_MAX_LINES", "6")) # Chunk Gemini only for very long essays (many sequential calls can exceed 90s). GENAI_CHUNK_CHARS = int(os.getenv("GENAI_CHUNK_CHARS", "5000")) GENAI_CHUNK_PARAS = int(os.getenv("GENAI_CHUNK_PARAS", "10")) TRANSLATEGEMMA_ADAPTER_DIR = os.getenv( "TRANSLATEGEMMA_ADAPTER", "Aynkader/translategemma-balochi-adapter", ) TRANSLATEGEMMA_HUB = os.getenv( "TRANSLATEGEMMA_HUB", "Aynkader/translategemma-balochi-adapter", ) # Optional .env support try: from dotenv import load_dotenv load_dotenv(Path(__file__).resolve().parent / ".env") except Exception: pass ROOT = Path(__file__).resolve().parent STATIC_DIR = ROOT / "static" GRAMMAR_PDF = ROOT / "Balochi_grammar_rules.pdf" GRAMMAR_CACHE = ROOT / "Balochi_grammar_rules.cache.txt" DATASET_CSV = ROOT / "Datasets" / "dataset.csv" DATASET_CSV_ROOT = ROOT / "dataset.csv" DATASET_XLSX = ROOT / "dataset.xlsx" WORDS_CSV = ROOT / "Datasets" / "words_balochi_to_english.csv" WORDS_CSV_ROOT = ROOT / "words_balochi_to_english.csv" WORDS_XLSX = ROOT / "words_balochi_to_english.xlsx" app = FastAPI(title="Balochi Latin Translation API", version="2.0") _cors_origins = [ o.strip() for o in os.getenv( "CORS_ORIGINS", "https://gwachen.com,https://www.gwachen.com,https://api.gwachen.com,https://labzank.com,https://www.labzank.com,http://127.0.0.1:8000,http://localhost:8000", ).split(",") if o.strip() ] app.add_middleware( CORSMiddleware, allow_origins=_cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) PATH_PREFIX = os.getenv("PATH_PREFIX", "").rstrip("/") if PATH_PREFIX: @app.middleware("http") async def _strip_path_prefix(request, call_next): path = request.scope.get("path", "") if path == PATH_PREFIX or path.startswith(PATH_PREFIX + "/"): new_path = path[len(PATH_PREFIX) :] or "/" request.scope["path"] = new_path raw = request.scope.get("raw_path") if isinstance(raw, (bytes, bytearray)): prefix_b = PATH_PREFIX.encode("utf-8") if raw == prefix_b or raw.startswith(prefix_b + b"/"): request.scope["raw_path"] = raw[len(prefix_b) :] or b"/" return await call_next(request) # Model configuration — match gwaschen production; override via .env GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.5-flash-lite") GEMINI_FALLBACKS = [ m.strip() for m in os.getenv( "GEMINI_FALLBACKS", "gemini-2.5-flash-lite,gemini-3.5-flash,gemini-3.6-flash,gemini-2.5-flash", ).split(",") if m.strip() ] GEMINI_RETRIES = int(os.getenv("GEMINI_RETRIES", "2")) GEMINI_RETRY_BASE_SEC = float(os.getenv("GEMINI_RETRY_BASE_SEC", "1.5")) GEMINI_THINKING_BUDGET = int(os.getenv("GEMINI_THINKING_BUDGET", "0")) MAX_FEWSHOT = int(os.getenv("MAX_FEWSHOT", "12")) MAX_LEXICON_HITS = int(os.getenv("MAX_LEXICON_HITS", "30")) CORPUS_DIRECT_SCORE = float(os.getenv("CORPUS_DIRECT_SCORE", "0.92")) GRAMMAR_CHARS = int(os.getenv("GRAMMAR_CHARS", "0")) FEWSHOT_CANDIDATE_CAP = int(os.getenv("FEWSHOT_CANDIDATE_CAP", "400")) # Lazy Gemini client initialization to prevent top-level import crashes _gemini_client: genai.Client | None = None # After free-tier 429, skip Gemini until this monotonic deadline (unix time). _gemini_quota_until: float = 0.0 def get_gemini_client() -> genai.Client | None: global _gemini_client if _gemini_client is not None: return _gemini_client api_key = os.getenv("GEMINI_API_KEY") if not api_key: print("[WARNING] GEMINI_API_KEY is not set. Gemini fallbacks will fail if invoked.") return None try: _gemini_client = genai.Client(api_key=api_key) return _gemini_client except Exception as err: print(f"[ERROR] Failed to initialize Gemini client: {err}") return None def _mark_gemini_quota(err: Exception | None = None) -> None: global _gemini_quota_until wait = _quota_retry_seconds(err) if err else 60.0 _gemini_quota_until = max(_gemini_quota_until, time.time() + wait) print(f"[gemini] quota cooldown until +{wait:.0f}s") def gemini_allowed() -> bool: return time.time() >= _gemini_quota_until BAL_FUNCTION = { "ant": "are", "ent": "is", "at": "was", "o": "and", "ke": "that", "pa": "for", "cha": "from", "man": "I", "taw": "you", "má": "we", "shomá": "you (pl)", "é": "this", "á": "that/he/she", } CORE_ORTHOGRAPHY = """ You MUST follow Balóchiay Látini Syáhag (Balochi_grammar_rules.pdf) for ALL Balochi output. Alphabet (only these): A Á B Ch D Dh E É G H I J K L M N O Ó P R S Sh T Th U W Y Z Zh - Digraphs (jotkáb): Ch, Dh, Sh, Th, Zh - Never invent letters f, q, v, x (map f→p, q→k, v→w, x→ks) - Digits: Western 0-9 only (never Arabic-Indic / Persian digit shapes) - Never output Arabic/Perso-Arabic script for Balochi Vowels (kassháb): short a e o · long á é i ó u - Long-vowel shortening before suffixes: Ráh+Band→Rahband, Sháh+Zádag→Shahzádag Grammar (mandatory for Balochi Latin): - Word order often SOV; copula ent / ant / at… - Verb endings: man→án, má→én, taw→ay, shomá→et, á sg→t/it/ent, á pl→ant - Nonpast = present/future (Man rawán = I go / I will go) - Imperative: be-/bo- affirmative, ma- prohibitive - Possessive clitics: -on/-en/-et/-o/-y/-esh; adjectives often -én (sharrén) - Gemination (dhakk): poll/porr; digraph gemination drops first h (Bachch→Bacch) Output rules: - Reply with the translation ONLY (no quotes, no commentary) - Preserve structure: one source line → one output line; blank lines stay blank - SUPPORT ORDER: (1) dataset.xlsx sentence/word search (2) words_balochi_to_english.xlsx lexicon glosses (3) Balochi_grammar_rules.pdf for understanding (4) Groq English, then Google Translate / Groq for the target language - Match dataset.xlsx Latin Syáhag style (á/é/ó, digraphs, e.g. Thám) """ GRAMMAR_MUST_FOLLOW = """ MUST FOLLOW Balochi_grammar_rules.pdf for Balochi Latin output: 1) Latin Syáhag alphabet only (A Á B Ch D Dh E É G H I J K L M N O Ó P R S Sh T Th U W Y Z Zh) 2) No Arabic/Perso-Arabic letters; no f/q/v/x; Western digits 0-9 3) Prefer SOV; use correct endings án/én/ay/et/t|it|ent/ant and copula ent/ant/at 4) Imperatives with be-/bo- or prohibitive ma- 5) Prefer wording from dataset.xlsx examples when they match meaning If a draft violates these rules, rewrite it so it obeys the PDF. """ ARABIC_SCRIPT_TARGETS = { "urdu", "arabic", "persian", "farsi", "persian (farsi)", "pashto", "sindhi", "dari", "kurdish", "sorani", } def norm(text: str) -> str: text = unicodedata.normalize("NFC", str(text)).strip() return re.sub(r"\s+", " ", text) def norm_preserve_structure(text: str) -> str: text = unicodedata.normalize("NFC", str(text)).replace("\r\n", "\n").replace("\r", "\n") lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in text.split("\n")] while lines and not lines[0]: lines.pop(0) while lines and not lines[-1]: lines.pop() return "\n".join(lines) def structure_hint(text: str, text_format: str | None = None) -> str: fmt = (text_format or "").strip().casefold() lines = text.split("\n") n = len(lines) blank = sum(1 for ln in lines if not ln.strip()) if fmt == "poetry": return ( "FORMAT: POETRY / VERSE. " f"Source has {n} lines ({blank} blank stanza breaks). " f"Your translation MUST have exactly {n} lines with the same blank-line pattern. " "Keep poem layout: one source line → one output line; blank lines stay blank between stanzas. " "Do NOT rewrite as a prose paragraph. Do NOT merge couplets into one sentence. " "Do NOT translate only the first line — translate EVERY non-blank line." ) if fmt == "story": return ( "FORMAT: STORY / PROSE. " "Translate as natural narrative paragraphs. " "Use a blank line between paragraphs where the source has paragraph breaks. " "Do NOT force a line-per-line verse layout." ) if n <= 1: return "The source is a single line or paragraph — keep it as one unit." return ( f"The source has {n} lines ({blank} blank). " f"Your translation MUST have exactly {n} lines with the same blank-line pattern. " "Translate line-by-line; do not merge lines." ) def wants_arabic_script(route: dict) -> bool: if route["key"] != "bal-any": return False label = f"{route.get('target', '')} {route.get('target_label', '')}".casefold() return any(name in label for name in ARABIC_SCRIPT_TARGETS) def arabic_script_ratio(text: str) -> float: letters = [c for c in text if c.isalpha()] if not letters: return 1.0 arabic = sum(1 for c in letters if "\u0600" <= c <= "\u06FF" or "\u0750" <= c <= "\u077F") return arabic / len(letters) def align_line_structure(source: str, translated: str) -> str: src_lines = source.split("\n") out = translated.replace("\r\n", "\n").replace("\r", "\n").strip() out_lines = out.split("\n") if len(out_lines) == len(src_lines): aligned = "\n".join(ln.strip() for ln in out_lines) return preserve_untranslated(source, aligned) non_empty_src = [ln for ln in src_lines if ln.strip()] if len(src_lines) == len(non_empty_src) and len(out_lines) == 1 and len(non_empty_src) > 1: parts = re.split(r"(?<=[.!?۔؟؛])\s+", out) parts = [p.strip() for p in parts if p.strip()] if len(parts) == len(non_empty_src): return preserve_untranslated(source, "\n".join(parts)) return preserve_untranslated(source, out) def preserve_untranslated(source: str, translated: str | None) -> str: """Keep original Balochi for lines that failed or were left empty. Aligns non-empty lines; blank lines stay blank. If the model returned fewer lines, or a line looks like a refusal / empty, copy the source line as-is. """ src = (source or "").replace("\r\n", "\n").replace("\r", "\n") dst = (translated or "").replace("\r\n", "\n").replace("\r", "\n") if not src.strip(): return (dst or "").strip() if not dst.strip(): return src src_lines = src.split("\n") dst_lines = dst.split("\n") # Fast path: same shape — only replace empty / refusal lines. if len(dst_lines) == len(src_lines): fixed: list[str] = [] for s, d in zip(src_lines, dst_lines): if not s.strip(): fixed.append("") continue d_st = d.strip() if not d_st or _looks_like_mt_refusal(d_st): fixed.append(s) else: fixed.append(d_st) return "\n".join(fixed) # Map non-empty → non-empty; rebuild with original blanks. src_ne = [ln for ln in src_lines if ln.strip()] dst_ne = [ln.strip() for ln in dst_lines if ln.strip()] mapped: list[str] = [] for i, s in enumerate(src_ne): if i < len(dst_ne): d = dst_ne[i] if not d or _looks_like_mt_refusal(d): mapped.append(s) else: mapped.append(d) else: mapped.append(s) # no translation for this line → keep original out: list[str] = [] mi = 0 for ln in src_lines: if not ln.strip(): out.append("") else: out.append(mapped[mi] if mi < len(mapped) else ln) mi += 1 return "\n".join(out) def non_empty_line_count(text: str) -> int: return sum(1 for ln in text.replace("\r\n", "\n").replace("\r", "\n").split("\n") if ln.strip()) def should_run_translategemma(text: str, text_format: str | None = None) -> bool: """TranslateGemma adapter is disabled — Groq + dataset/lexicon/grammar only.""" return False def story_paragraphs(text: str) -> list[str]: parts = re.split(r"\n\s*\n+", (text or "").strip()) return [p.strip() for p in parts if p.strip()] def should_chunk_genai(text: str, text_format: str | None) -> bool: if (text_format or "") == "poetry": return False paras = story_paragraphs(text) return len(text) >= GENAI_CHUNK_CHARS or len(paras) >= GENAI_CHUNK_PARAS def draft_covers_source(source: str, draft: str) -> bool: """Reject short one-liners when the source is a multi-line poem/story.""" draft = (draft or "").strip() if not draft: return False src_n = non_empty_line_count(source) if src_n <= 1: return True dst_n = non_empty_line_count(draft) src_words = max(1, len(source.split())) dst_words = len(draft.split()) # Need most lines, or enough words that it is clearly a full rendering. min_lines = max(2, int(src_n * 0.6 + 0.999)) if dst_n >= min_lines: return True if dst_words >= max(12, int(src_words * 0.45)): return True return False def norm_key(text: str) -> str: return norm(text).casefold() def tokenize(text: str) -> set[str]: return { t for t in re.findall( r"[A-Za-zÁÉÓŪĪáéóūīōŌZhzhChchDhShThdhshth]+", text.casefold(), ) if len(t) > 1 } def fold_accents(text: str) -> str: """á/é/ó/ū/ī/ō → a/e/o/u/i/o for false-friend key matching.""" table = str.maketrans( { "á": "a", "à": "a", "ä": "a", "é": "e", "è": "e", "ë": "e", "í": "i", "ì": "i", "ī": "i", "ó": "o", "ò": "o", "ö": "o", "ō": "o", "ú": "u", "ù": "u", "ü": "u", "ū": "u", } ) return (text or "").casefold().translate(table) # LoRA false friends: folded Balochi lemma → English strings that mean the WRONG sense. LEXICON_FALSE_FRIENDS: dict[str, list[str]] = { "hajjam": [ "pilgrim", "pilgrims", "kaaba", "ka'ba", "ka'bah", "mecca", "makkah", "hajj", "pilgrimage", ], } def lexicon_draft_conflicts(source: str, draft: str, route_key: str) -> list[str]: """If the LoRA draft contradicts clear lexicon senses, list conflicts.""" if route_key != "bal-en" or not draft: return [] draft_l = f" {draft.casefold()} " conflicts: list[str] = [] hits = lexicon_hits(source, "bal-en", limit=50) hit_by_fold = {fold_accents(tok): (tok, meaning) for tok, meaning in hits} # Also catch source tokens that map via folded false-friend keys # (lexicon stores Hajjám → key "hajjám"; false friends use "hajjam"). for tok in tokenize(source): folded = fold_accents(tok) wrongs = LEXICON_FALSE_FRIENDS.get(folded, []) if not wrongs: continue meaning = hit_by_fold.get(folded, (tok, "Barber"))[1] if folded in hit_by_fold: tok, meaning = hit_by_fold[folded] elif fold_accents("hajjám") == folded: meaning = RESOURCES["bal_lex"].get("hajjám") or "Barber" tok = "hajjám" for w in wrongs: needle = w.casefold().strip() if not needle: continue # Word-ish match so "hajj" does not require trailing space. if re.search(rf"(? bool: """True when the draft ignores clear lexicon glosses from the source (hallucination).""" if route_key != "bal-en" or not draft: return False hits = lexicon_hits(source, "bal-en", limit=20) if len(hits) < 2: return False draft_l = draft.casefold() matched = 0 for _tok, meaning in hits: head = re.split(r"[.;/]", meaning.casefold())[0].strip() words = [w for w in re.findall(r"[a-z]{4,}", head)] if any(w in draft_l for w in words): matched += 1 return matched == 0 def load_grammar_text() -> str: if GRAMMAR_PDF.exists(): reader = PdfReader(str(GRAMMAR_PDF)) parts: list[str] = [] for page in reader.pages: try: parts.append(page.extract_text() or "") except Exception: continue text = re.sub(r"\n{3,}", "\n\n", "\n".join(parts)).strip() if text: GRAMMAR_CACHE.write_text(text, encoding="utf-8") return text if GRAMMAR_CACHE.exists(): return GRAMMAR_CACHE.read_text(encoding="utf-8", errors="ignore") return "" def extract_grammar_rules(full: str, max_chars: int = 0) -> str: if not full: return "(grammar PDF unavailable)" start = 0 m = re.search(r"A\s*Á\s*B\s*Ch", full) if m: start = m.start() else: for needle in ("Balóchiay Áb", "Kassháb", "Kasshab", "Jotkáb"): i = full.find(needle) if i >= 0: start = i break body = full[start:].strip() if not body: body = full.strip() if max_chars and max_chars > 0: body = body[:max_chars] return body def violates_syahag(text: str) -> list[str]: issues: list[str] = [] if re.search(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]", text): issues.append("contains_arabic_script") if re.search(r"[fqvxFQVX]", text): issues.append("forbidden_letters_fqvx") if re.search(r"[\u0660-\u0669\u06F0-\u06F9]", text): issues.append("non_western_digits") return issues def _first_existing(*paths: Path) -> Path | None: for pth in paths: if pth.exists(): return pth return None def _read_table(*candidates: Path): path = _first_existing(*candidates) if path is None: names = ", ".join(pth.name for pth in candidates) raise FileNotFoundError(f"Missing table among: {names}") if path.suffix.lower() == ".csv": return pd.read_csv(path), path return pd.read_excel(path), path def load_resources(): dataset_df, dataset_path = _read_table(DATASET_CSV, DATASET_CSV_ROOT, DATASET_XLSX) pairs: list[dict] = [] en_to_bal: dict[str, str] = {} bal_to_en: dict[str, str] = {} for en, bal in zip(dataset_df["English_sentences"], dataset_df["Balochi_sentences"]): if pd.isna(en) or pd.isna(bal): continue en_s, bal_s = norm(en), norm(bal) if not en_s or not bal_s: continue pairs.append({"en": en_s, "bal": bal_s, "en_toks": tokenize(en_s), "bal_toks": tokenize(bal_s)}) en_to_bal.setdefault(norm_key(en_s), bal_s) bal_to_en.setdefault(norm_key(bal_s), en_s) # Lexicon (helps gwachen quality on CPU when TranslateGemma is unavailable). bal_lex: dict[str, str] = {} en_lex: dict[str, str] = {} words_path = None try: words_df, words_path = _read_table(WORDS_CSV, WORDS_CSV_ROOT, WORDS_XLSX) for bal, meaning in zip(words_df["balochi_word"], words_df["english_meaning"]): if pd.isna(bal) or pd.isna(meaning): continue bal_s, en_s = norm(bal), norm(meaning) if not bal_s or not en_s: continue bal_lex.setdefault(norm_key(bal_s), en_s) en_lex.setdefault(norm_key(en_s), bal_s) except FileNotFoundError: words_path = None grammar = load_grammar_text() grammar_excerpt = extract_grammar_rules(grammar, GRAMMAR_CHARS) bal_index: dict[str, list[int]] = defaultdict(list) en_index: dict[str, list[int]] = defaultdict(list) for i, pair in enumerate(pairs): for tok in pair["bal_toks"]: bal_index[tok].append(i) for tok in pair["en_toks"]: en_index[tok].append(i) print( f"[startup] dataset={dataset_path} words={words_path} " f"pairs={len(pairs):,} lexicon={len(bal_lex):,} " f"grammar_chars={len(grammar_excerpt):,} model={GEMINI_MODEL}" ) return { "pairs": pairs, "en_to_bal": en_to_bal, "bal_to_en": bal_to_en, "bal_lex": bal_lex, "en_lex": en_lex, "grammar_excerpt": grammar_excerpt, "bal_index": bal_index, "en_index": en_index, "dataset_path": str(dataset_path), "words_path": str(words_path) if words_path else None, } RESOURCES = load_resources() BAL_ALIASES = { "balochi", "baluchi", "bal", "bal_latn", "balochi-latin", "latin-balochi", "balochi latin", } EN_ALIASES = {"english", "en", "eng"} TARGET_LANGUAGES = [ ("english", "English"), ("urdu", "Urdu"), ("persian", "Persian (Farsi)"), ("arabic", "Arabic"), ("hindi", "Hindi"), ("pashto", "Pashto"), ("sindhi", "Sindhi"), ("brahui", "Brahui"), ("turkish", "Turkish"), ("spanish", "Spanish"), ("french", "French"), ("german", "German"), ("portuguese", "Portuguese"), ("russian", "Russian"), ("chinese", "Chinese"), ("japanese", "Japanese"), ("korean", "Korean"), ("italian", "Italian"), ("dutch", "Dutch"), ("swedish", "Swedish"), ("indonesian", "Indonesian"), ("bengali", "Bengali"), ("punjabi", "Punjabi"), ("gujarati", "Gujarati"), ("marathi", "Marathi"), ("tamil", "Tamil"), ("telugu", "Telugu"), ("malayalam", "Malayalam"), ("thai", "Thai"), ("vietnamese", "Vietnamese"), ("polish", "Polish"), ("ukrainian", "Ukrainian"), ("greek", "Greek"), ("hebrew", "Hebrew"), ("swahili", "Swahili"), ] LANG_BY_NAME = {name: code for code, name in TARGET_LANGUAGES} LANG_BY_NAME.update({code: code for code, _name in TARGET_LANGUAGES}) LANG_BY_NAME["Persian (Farsi)"] = "persian" LANG_BY_NAME["Farsi"] = "persian" def is_balochi(lang: str) -> bool: return lang.strip().casefold() in BAL_ALIASES def is_english(lang: str) -> bool: return lang.strip().casefold() in EN_ALIASES def parse_route(source_lang: str, target_lang: str) -> dict: src = source_lang.strip() tgt = target_lang.strip() if not src or not tgt: raise HTTPException(status_code=400, detail="source_lang and target_lang are required") if is_balochi(tgt): if is_english(src): return { "key": "en-bal", "source": "english", "target": "balochi", "target_label": "Balochi Latin Syáhag", "source_label": "English", } if is_balochi(src): raise HTTPException( status_code=400, detail="Source and target cannot both be Balochi. Choose another source language.", ) label = src for code, name in TARGET_LANGUAGES: if src.casefold() in {code, name.casefold()}: label = name break return { "key": "any-bal", "source": src.casefold(), "target": "balochi", "target_label": "Balochi Latin Syáhag", "source_label": label, } if is_balochi(src) and is_english(tgt): return { "key": "bal-en", "source": "balochi", "target": "english", "target_label": "English", } if is_balochi(src): label = tgt for code, name in TARGET_LANGUAGES: if tgt.casefold() in {code, name.casefold()}: label = name break return { "key": "bal-any", "source": "balochi", "target": tgt.casefold(), "target_label": label, } raise HTTPException( status_code=400, detail=( "Supported: Balochi → any language, or any language → Balochi. " f"Got source={source_lang!r} target={target_lang!r}" ), ) def exact_lookup(text: str, route_key: str) -> str | None: key = norm_key(text) if route_key == "en-bal": return RESOURCES["en_to_bal"].get(key) or RESOURCES["en_lex"].get(key) if route_key in {"bal-en", "bal-any"}: return RESOURCES["bal_to_en"].get(key) or RESOURCES["bal_lex"].get(key) return None def lexicon_hits(text: str, route_key: str, limit: int = MAX_LEXICON_HITS) -> list[tuple[str, str]]: toks = tokenize(text) if not toks: return [] lex = RESOURCES["en_lex"] if route_key == "en-bal" else RESOURCES["bal_lex"] out: list[tuple[str, str]] = [] seen: set[str] = set() for tok in toks: k = norm_key(tok) if k in seen: continue hit = lex.get(k) if hit: seen.add(k) out.append((tok, hit)) if len(out) >= limit: break return out def retrieve_fewshot(text: str, route_key: str, k: int = MAX_FEWSHOT) -> list[tuple[float, dict]]: q_toks = tokenize(text) if not q_toks: return [] index = RESOURCES["en_index"] if route_key == "en-bal" else RESOURCES["bal_index"] pairs = RESOURCES["pairs"] hit_counts: dict[int, int] = defaultdict(int) for tok in sorted(q_toks, key=lambda t: len(index.get(t, []))): postings = index.get(tok) if not postings: continue for idx in postings: hit_counts[idx] += 1 if len(hit_counts) >= FEWSHOT_CANDIDATE_CAP * 3: break if not hit_counts: return [] cand_ids = [ idx for idx, _ in nlargest( FEWSHOT_CANDIDATE_CAP, hit_counts.items(), key=lambda item: item[1], ) ] scored: list[tuple[float, dict]] = [] for idx in cand_ids: pair = pairs[idx] ref = pair["en_toks"] if route_key == "en-bal" else pair["bal_toks"] if not ref: continue inter = len(q_toks & ref) if inter == 0: continue union = len(q_toks | ref) score = inter / union src = pair["en"] if route_key == "en-bal" else pair["bal"] length_penalty = abs(len(src) - len(text)) / max(len(text), 1) score = score - 0.05 * min(length_penalty, 2.0) if score >= 0.08: scored.append((score, pair)) scored.sort(key=lambda x: x[0], reverse=True) out: list[tuple[float, dict]] = [] seen = set() for score, pair in scored: sig = (pair["en"], pair["bal"]) if sig in seen: continue seen.add(sig) out.append((score, pair)) if len(out) >= k: break return out def offline_fallback( text: str, route_key: str, fewshot: list[tuple[float, dict]], target_label: str, ) -> tuple[str, dict] | None: if route_key == "bal-any": return None # Never replace a multi-line poem/story with a single dataset sentence. if non_empty_line_count(text) > 1: return None to_bal = route_key in {"en-bal", "any-bal"} if fewshot: score, pair = fewshot[0] if score >= 0.22: tgt = pair["bal"] if to_bal else pair["en"] return tgt, { "reason": "closest_dataset_example", "score": round(score, 3), "example": {"en": pair["en"], "bal": pair["bal"]}, } if fewshot: score, pair = fewshot[0] tgt = pair["bal"] if to_bal else pair["en"] return tgt, { "reason": "weak_dataset_example", "score": round(score, 3), "example": {"en": pair["en"], "bal": pair["bal"]}, } return None def _is_retryable(err: Exception) -> bool: msg = str(err).casefold() needles = ( "high demand", "resource_exhausted", "unavailable", "try again", "rate", "quota", "429", "503", "overloaded", "temporarily", "spikes in demand", "capacity", "server error", "internal error", "deadline", "timeout", "timed out", "free_tier", ) return any(n in msg for n in needles) def _is_quota_exhausted(err: Exception) -> bool: msg = str(err).casefold() return any( n in msg for n in ( "exceeded your current quota", "free_tier", "quota exceeded", "resource_exhausted", "rate limit", ) ) def _quota_retry_seconds(err: Exception) -> float: """Parse 'Please retry in 30.29s' from Gemini quota errors.""" msg = str(err) m = re.search(r"retry in\s+([0-9]+(?:\.[0-9]+)?)\s*s", msg, flags=re.I) if m: return min(45.0, max(5.0, float(m.group(1)) + 1.0)) return 20.0 def generate_with_retries(prompt: str, *, light: bool = False) -> tuple[str, str]: if not gemini_allowed(): raise RuntimeError( f"Gemini free-tier cooldown active; retry in " f"{max(1, int(_gemini_quota_until - time.time()))}s" ) client = get_gemini_client() if client is None: raise RuntimeError("GEMINI_API_KEY is not configured on the server.") models = [GEMINI_MODEL] + [m for m in GEMINI_FALLBACKS if m != GEMINI_MODEL] last_err: Exception | None = None system = ( "You are a careful literary translator. " "Reply with the translation ONLY — no preface." if light else build_system_prompt() ) config_kwargs: dict = {"system_instruction": system, "temperature": 0.1} if (not light) and GEMINI_THINKING_BUDGET >= 0: try: config_kwargs["thinking_config"] = types.ThinkingConfig( thinking_budget=GEMINI_THINKING_BUDGET ) except Exception: pass gen_config = types.GenerateContentConfig(**config_kwargs) quota_hits = 0 for model in models: for attempt in range(1, GEMINI_RETRIES + 1): try: response = client.models.generate_content( model=model, contents=prompt, config=gen_config, ) text = (response.text or "").strip() if not text: raise RuntimeError("Empty model response") return text, model except Exception as err: last_err = err msg = str(err).casefold() bad_model = any( n in msg for n in ( "not found", "not_found", "invalid", "404", "unsupported", "no longer available", "update your code", "deprecated", ) ) # Free-tier quota: do NOT sleep 30–45s here (client already waits). # One quick retry only — cascading models burns the 20/min limit. if _is_quota_exhausted(err): quota_hits += 1 _mark_gemini_quota(err) print(f"[gemini] quota on {model} (hit={quota_hits})") if quota_hits >= 2: raise err time.sleep(1.5) continue high_demand = any( n in msg for n in ( "high demand", "spikes in demand", "overloaded", "429", "503", ) ) if high_demand: time.sleep(min(12.0, GEMINI_RETRY_BASE_SEC * (2 ** (attempt - 1)))) if attempt >= 2: break continue if attempt < GEMINI_RETRIES and _is_retryable(err): time.sleep(GEMINI_RETRY_BASE_SEC * attempt) continue if _is_retryable(err) or bad_model: break raise assert last_err is not None if _is_quota_exhausted(last_err): _mark_gemini_quota(last_err) raise last_err def build_pivot_prompt( english: str, target_label: str, *, text_format: str | None, arabic_script: bool, ) -> str: """Small EN→target prompt (avoids huge Balochi grammar system prompt).""" parts = [ f"Translate from English to {target_label}.", "Keep meaning faithful. Reply with the translation ONLY.", structure_hint(english, text_format), ] if arabic_script: parts.append( f"CRITICAL: Write the entire {target_label} translation in Arabic / " "Perso-Arabic script only. No Latin transliteration." ) else: parts.append(f"Use the standard writing system for {target_label}.") parts.append(f"English source:\n{english}\n\n{target_label} translation:") return "\n\n".join(parts) # Free EN→target when Gemini quota is dead (Google Translate + Groq + HF). GOOGLE_TL = { "english": "en", "urdu": "ur", "arabic": "ar", "persian": "fa", "farsi": "fa", "hindi": "hi", "pashto": "ps", "sindhi": "sd", "turkish": "tr", "spanish": "es", "french": "fr", "german": "de", "portuguese": "pt", "russian": "ru", "chinese": "zh-CN", "japanese": "ja", "korean": "ko", "italian": "it", "dutch": "nl", "swedish": "sv", "indonesian": "id", "bengali": "bn", "punjabi": "pa", "gujarati": "gu", "marathi": "mr", "tamil": "ta", "telugu": "te", "malayalam": "ml", "thai": "th", "vietnamese": "vi", "polish": "pl", "ukrainian": "uk", "greek": "el", "hebrew": "he", "swahili": "sw", } NLLB_TARGET = { "urdu": "urd_Arab", "arabic": "arb_Arab", "persian": "pes_Arab", "farsi": "pes_Arab", "hindi": "hin_Deva", "pashto": "pbt_Arab", "sindhi": "snd_Arab", "turkish": "tur_Latn", "spanish": "spa_Latn", "french": "fra_Latn", "german": "deu_Latn", "portuguese": "por_Latn", "russian": "rus_Cyrl", "chinese": "zho_Hans", "japanese": "jpn_Jpan", "korean": "kor_Hang", "italian": "ita_Latn", "dutch": "nld_Latn", "swedish": "swe_Latn", "indonesian": "ind_Latn", "bengali": "ben_Beng", "punjabi": "pan_Guru", "gujarati": "guj_Gujr", "marathi": "mar_Deva", "tamil": "tam_Taml", "telugu": "tel_Telu", "malayalam": "mal_Mlym", "thai": "tha_Thai", "vietnamese": "vie_Latn", "polish": "pol_Latn", "ukrainian": "ukr_Cyrl", "greek": "ell_Grek", "hebrew": "heb_Hebr", "swahili": "swh_Latn", } OPUS_EN_TARGET = { "urdu": "Helsinki-NLP/opus-mt-en-ur", "arabic": "Helsinki-NLP/opus-mt-en-ar", "hindi": "Helsinki-NLP/opus-mt-en-hi", "french": "Helsinki-NLP/opus-mt-en-fr", "german": "Helsinki-NLP/opus-mt-en-de", "spanish": "Helsinki-NLP/opus-mt-en-es", "russian": "Helsinki-NLP/opus-mt-en-ru", "chinese": "Helsinki-NLP/opus-mt-en-zh", } def _hf_token() -> str: return ( os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN") or "" ).strip() def _hf_en_to_target(english: str, target_code: str, target_label: str) -> tuple[str, str] | None: """EN→target via Hugging Face Inference (opus-mt / NLLB).""" english = (english or "").strip() if not english: return None code = (target_code or "").casefold().strip() token = _hf_token() if not token: return None try: from huggingface_hub import InferenceClient client = InferenceClient(token=token) opus = OPUS_EN_TARGET.get(code) if opus: try: out = client.translation(english, model=opus) if isinstance(out, list): out = out[0] if out else "" if isinstance(out, dict): out = out.get("translation_text") or out.get("generated_text") or "" text = str(out).strip() if text: return text, opus except Exception as err: print(f"[free_mt] opus {opus} failed: {err}") nllb_tgt = NLLB_TARGET.get(code) if nllb_tgt: try: out = client.translation( english, model="facebook/nllb-200-distilled-600M", src_lang="eng_Latn", tgt_lang=nllb_tgt, ) if isinstance(out, list): out = out[0] if out else "" if isinstance(out, dict): out = out.get("translation_text") or out.get("generated_text") or "" text = str(out).strip() if text: return text, "facebook/nllb-200-distilled-600M" except Exception as err: print(f"[free_mt] nllb failed: {err}") except Exception as err: print(f"[free_mt] hf client failed: {err}") return None def groq_configured() -> bool: return bool((os.getenv("GROQ_API_KEY") or "").strip()) def groq_model_id() -> str: return (os.getenv("GROQ_MODEL") or DEFAULT_GROQ_MODEL).strip() or DEFAULT_GROQ_MODEL def groq_support_stack() -> list[str]: return list(GROQ_SUPPORT) def mt_support_stack() -> list[str]: """Public support labels: adapter/dataset/grammar/lexicon first, then Groq/Google.""" return list(PRIORITIES) + ([f"groq:{groq_model_id()}", "google_translate"] if groq_configured() else []) def build_mt_grounding(text: str, route_key: str) -> str: """Binding senses from dataset + lexicon + grammar (no adapter).""" rk = "en-bal" if route_key in {"en-bal", "any-bal"} else "bal-en" parts: list[str] = [ "UNDERSTANDING PIPELINE (binding order):", "1) Search dataset.xlsx for matching Balochi sentences → English parallels", "2) Look up words in words_balochi_to_english.xlsx", "3) Apply Balochi_grammar_rules.pdf for morphology/syntax sense", "4) Produce faithful translation from the Balochi source using (1)–(3)", "KEEP-ORIGINAL RULE (mandatory): If a word, phrase, or whole line has no " "clear sense from dataset/lexicon/grammar and you cannot translate it " "confidently, copy the original Balochi Latin text unchanged into that " "place. Do NOT invent, skip, omit, or leave a blank for that part.", "Do NOT invent unrelated themes. Do NOT use TranslateGemma adapter drafts.", ] # Exact / near sentence hits first few = retrieve_fewshot(text, rk, k=8) if few: parts.append("BINDING dataset.xlsx sentence matches:") for score, pair in few: parts.append( f"[score={score:.2f}]\nBalochi: {pair['bal']}\nEnglish: {pair['en']}" ) # Also search each non-empty line as its own query line_hits = 0 for ln in (text or "").split("\n"): raw = ln.strip() if not raw or line_hits >= 6: continue exact = exact_lookup(raw, "bal-en" if rk == "bal-en" else "en-bal") if exact: parts.append(f"EXACT dataset/lexicon line:\nBalochi: {raw}\nEnglish: {exact}") line_hits += 1 continue sub = retrieve_fewshot(raw, rk, k=1) if sub and sub[0][0] >= 0.2: score, pair = sub[0] parts.append( f"LINE dataset match [score={score:.2f}]:\n" f"Balochi: {pair['bal']}\nEnglish: {pair['en']}" ) line_hits += 1 lex = lexicon_hits(text, rk, limit=40) if lex: parts.append("BINDING lexicon (words_balochi_to_english.xlsx):") for bal, en in lex: parts.append(f"- {bal} = {en}") grammar = (RESOURCES.get("grammar_excerpt") or "").strip() if grammar: parts.append( "BINDING grammar (Balochi_grammar_rules.pdf) — use for understanding " "the Balochi Latin source before writing English:\n" + grammar[:1800] ) return "\n".join(parts) def _looks_like_mt_refusal(text: str) -> bool: """True if the model refused instead of translating.""" t = (text or "").casefold() if len(t) > 280: return False needles = ( "cannot translate", "can't translate", "do not know how to translate", "don't know how to translate", "i'm unable to translate", "i am unable to translate", "unable to translate", "as an ai", "ترجمہ کیسے", "ترجمہ نہیں", "نہیں جانتا", ) return any(n in t for n in needles) def _looks_like_hallucinated_mt(source: str, translated: str) -> bool: """Catch generic invented poems / repeated lines that ignore the source.""" src_n = non_empty_line_count(source) dst_n = non_empty_line_count(translated) if src_n >= 2 and dst_n < max(1, int(src_n * 0.7 + 0.001)): return True lines = [ln.strip() for ln in (translated or "").split("\n") if ln.strip()] if len(lines) >= 3: top_n = Counter(lines).most_common(1)[0][1] if top_n >= 2 and top_n >= len(lines) // 2: return True # Generic English filler often produced when the model ignores Balochi. en = (translated or "").casefold() generic = ( "purpose of life", "wandering soul", "according to this account", "restless and tormented", "night spreads its darkness", ) if src_n >= 2 and any(g in en for g in generic): # Allow only if source somehow matches — these almost always mean hallucination. return True return False def _english_misses_lexicon( source: str, english: str, *, strict: bool = False ) -> bool: """True when binding lexicon glosses are absent from the English draft.""" hits = lexicon_hits(source, "bal-en", limit=24) if len(hits) < 3: return False en_fold = fold_accents(english or "").casefold() matched = 0 stop = { "the", "and", "for", "with", "from", "that", "this", "into", "onto", "over", "under", "have", "been", "were", "will", "shall", "also", } for _bal, gloss in hits: words = [ w for w in re.findall(r"[A-Za-z]{4,}", gloss or "") if w.casefold() not in stop ] if any(w.casefold() in en_fold for w in words): matched += 1 if strict: need = max(2, int(len(hits) * 0.35 + 0.999)) return matched < need # Soft: only reject total misses (used for Groq output filter). return matched == 0 def _number_source_lines(text: str) -> str: """Number non-empty lines so the model must return the same count.""" out: list[str] = [] n = 0 for ln in (text or "").split("\n"): if not ln.strip(): out.append("") continue n += 1 out.append(f"{n}. {ln.strip()}") return "\n".join(out) def _strip_line_numbers(text: str) -> str: return re.sub(r"(?m)^\s*\d+\s*[.)\-:]\s*", "", text or "") def _groq_extract_content(data: dict) -> tuple[str, str, dict]: """Return (text, finish_reason, usage) from a Groq chat completion.""" choice = (data.get("choices") or [{}])[0] msg = choice.get("message") or {} text = (msg.get("content") or "").strip().strip('"').strip("'") finish = str(choice.get("finish_reason") or "") usage = data.get("usage") or {} return text, finish, usage if isinstance(usage, dict) else {} def groq_chat_translate( source_text: str, *, source_label: str, target_code: str, target_label: str, grounding: str | None = None, route_key: str | None = None, reject_filters: bool = True, ) -> tuple[str, str] | None: """Direct Groq chat translation (Balochi or English → target), optionally grounded.""" groq_key = (os.getenv("GROQ_API_KEY") or "").strip() source_text = (source_text or "").strip() if not groq_key or not source_text: return None code = (target_code or "").casefold().strip() label = (target_label or code).strip() arabic = code in { "urdu", "arabic", "persian", "farsi", "pashto", "sindhi", "hebrew", } src_lines = non_empty_line_count(source_text) rk = route_key or ( "bal-en" if "balochi" in source_label.casefold() else ("en-bal" if code.startswith("bal") else "bal-en") ) ground = (grounding or "").strip() or build_mt_grounding(source_text, rk) try: import httpx model = groq_model_id() max_out = int(os.getenv("GROQ_MAX_COMPLETION_TOKENS", "8192")) script = ( f"Write {label} in Arabic/Perso-Arabic script only (not Latin/Roman). " if arabic else f"Use the standard writing system for {label}. " ) numbered = _number_source_lines(source_text) prompt = ( f"{ground}\n\n" f"Faithfully translate from {source_label} to {label}.\n" f"{script}\n" f"There are {src_lines} non-empty source lines. " f"Return exactly {src_lines} non-empty output lines " "(same blank-line stanza breaks). " "Use BINDING lexicon/dataset senses above. " "Translate each numbered line's meaning — do NOT invent a new poem, " "do NOT summarize, do NOT repeat lines, do NOT transliterate " "letter-by-letter. " "KEEP-ORIGINAL: if a word/phrase/line cannot be translated from the " "bindings, leave that Balochi Latin text as-is in the output. " "Never refuse. Reply with the translation ONLY " "(you may keep or drop the numbers).\n\n" f"{source_label}:\n{numbered}\n\n{label}:" ) body: dict = { "model": model, "temperature": 0.05, "max_completion_tokens": max_out, "messages": [ { "role": "system", "content": ( "You are a careful literary translator for Balochi Latin " "(Syáhag). Obey lexicon + dataset.xlsx + grammar rules. " "Translate meaning line-by-line. Never invent themes. " "If you cannot translate a word or line, keep the original " "Balochi Latin text in that place. " "Never refuse. Reply with the translation only." ), }, {"role": "user", "content": prompt}, ], } if "gpt-oss" in model.casefold(): body["reasoning_effort"] = os.getenv("GROQ_REASONING_EFFORT", "low") filters_on = reject_filters for attempt in range(3): r = httpx.post( "https://api.groq.com/openai/v1/chat/completions", headers={ "Authorization": f"Bearer {groq_key}", "Content-Type": "application/json", }, json=body, timeout=90.0, ) if r.status_code == 429: print(f"[groq] HTTP 429: {r.text[:300]}") time.sleep(2.5 * (attempt + 1)) continue if r.status_code >= 300: print(f"[groq] HTTP {r.status_code}: {r.text[:300]}") return None data = r.json() text_out, finish, usage = _groq_extract_content(data) text_out = _strip_line_numbers(text_out) if text_out and _looks_like_mt_refusal(text_out): print(f"[groq] refusal-like output: {text_out[:160]!r}") text_out = "" if filters_on and text_out and _looks_like_hallucinated_mt(source_text, text_out): print( f"[groq] hallucinated/short output rejected ({src_lines} src lines)" ) text_out = "" if ( filters_on and text_out and rk == "bal-en" and label.casefold().startswith("english") and _english_misses_lexicon(source_text, text_out) ): print("[groq] rejected: English misses binding lexicon glosses") text_out = "" if text_out: return text_out, f"groq:{model}" details = usage.get("completion_tokens_details") or {} reasoning_n = details.get("reasoning_tokens") print( f"[groq] empty/bad content finish={finish!r} " f"reasoning_tokens={reasoning_n} usage={usage}" ) if finish == "length" and attempt == 0: body["max_completion_tokens"] = min(max_out * 2, 32768) continue # Drop quality filters so labzank never hard-fails empty. if filters_on: filters_on = False continue return None except Exception as err: print(f"[groq] failed: {err}") return None def groq_translate_chunked( source_text: str, *, source_label: str, target_code: str, target_label: str, text_format: str | None = None, route_key: str | None = None, adapter_draft: str | None = None, strict_quality: bool = True, ) -> tuple[str, str] | None: """Translate with dataset/lexicon/grammar grounding; always try a best-effort fallback.""" source_text = (source_text or "").strip() if not source_text: return None rk = route_key or ( "bal-en" if "balochi" in source_label.casefold() else ("en-bal" if (target_code or "").casefold().startswith("bal") else "bal-en") ) ground = build_mt_grounding(source_text, rk) # adapter_draft ignored — TranslateGemma is disabled for this pipeline. hit = groq_chat_translate( source_text, source_label=source_label, target_code=target_code, target_label=target_label, grounding=ground, route_key=rk, reject_filters=strict_quality, ) if hit: return preserve_untranslated(source_text, hit[0]), hit[1] parts = story_paragraphs(source_text) src_lines = non_empty_line_count(source_text) should_chunk = len(parts) >= 3 or src_lines >= 8 or (text_format or "") == "poetry" if not should_chunk or len(parts) < 2: fallback = groq_chat_translate( source_text, source_label=source_label, target_code=target_code, target_label=target_label, grounding=ground, route_key=rk, reject_filters=False, ) if fallback: return preserve_untranslated(source_text, fallback[0]), fallback[1] # Nothing translated — keep original Balochi in place. return source_text, f"groq:{groq_model_id()}:keep_original" group_size = 1 if (text_format or "") == "poetry" or len(parts) >= 4 else 2 out_parts: list[str] = [] model_id = "" failed = 0 n_groups = 0 for i in range(0, len(parts), group_size): n_groups += 1 chunk = "\n\n".join(parts[i : i + group_size]) chunk_ground = build_mt_grounding(chunk, rk) piece = groq_chat_translate( chunk, source_label=source_label, target_code=target_code, target_label=target_label, grounding=chunk_ground, route_key=rk, reject_filters=strict_quality, ) if not piece: piece = groq_chat_translate( chunk, source_label=source_label, target_code=target_code, target_label=target_label, grounding=chunk_ground, route_key=rk, reject_filters=False, ) if not piece: failed += 1 out_parts.append(chunk) # keep original Balochi for this chunk continue text_part, model_id = piece out_parts.append(preserve_untranslated(chunk, text_part.strip())) time.sleep(0.2) joined = "\n\n".join(out_parts).strip() if not joined: return source_text, f"groq:{groq_model_id()}:keep_original" if failed >= n_groups: # All chunks kept original — still return original rather than inventing. return preserve_untranslated(source_text, joined), model_id or ( f"groq:{groq_model_id()}:keep_original" ) return ( preserve_untranslated(source_text, joined), model_id or f"groq:{groq_model_id()}", ) def google_translate_configured() -> bool: # Official Cloud key optional; free gtx client always available as fallback. return True def google_tl_code(target_code: str) -> str | None: code = (target_code or "").casefold().strip() if code in GOOGLE_TL: return GOOGLE_TL[code] if len(code) in {2, 3, 5, 7} and code.replace("-", "").isalpha(): return code return None def _google_translate_cloud( text: str, *, source_tl: str, target_tl: str, api_key: str ) -> str | None: import httpx r = httpx.post( "https://translation.googleapis.com/language/translate/v2", params={"key": api_key}, json={ "q": text, "source": source_tl, "target": target_tl, "format": "text", }, timeout=60.0, ) if r.status_code >= 300: print(f"[google_translate] cloud HTTP {r.status_code}: {r.text[:240]}") return None data = r.json() or {} translations = ((data.get("data") or {}).get("translations")) or [] if not translations: return None out = (translations[0].get("translatedText") or "").strip() return out or None def _google_translate_gtx( text: str, *, source_tl: str, target_tl: str ) -> str | None: """Unofficial translate.googleapis.com client=gtx (no key). Chunk long text.""" import httpx def split_parts(src: str, limit: int = 1400) -> list[str]: lines = src.split("\n") parts: list[str] = [] buf = "" for ln in lines: candidate = f"{buf}\n{ln}" if buf else ln if len(candidate) > limit and buf: parts.append(buf) buf = ln else: buf = candidate if buf: parts.append(buf) return parts or [src] out_chunks: list[str] = [] for part in split_parts(text): if not part.strip(): out_chunks.append(part) continue r = httpx.get( "https://translate.googleapis.com/translate_a/single", params={ "client": "gtx", "sl": source_tl, "tl": target_tl, "dt": "t", "q": part, }, timeout=60.0, ) if r.status_code >= 300: print(f"[google_translate] gtx HTTP {r.status_code}: {r.text[:240]}") return None data = r.json() segs: list[str] = [] try: for row in data[0] or []: if row and isinstance(row[0], str): segs.append(row[0]) except (TypeError, IndexError, KeyError) as err: print(f"[google_translate] gtx parse failed: {err}") return None piece = "".join(segs).strip() if not piece: return None out_chunks.append(piece) return "\n".join(out_chunks).strip() or None def google_translate_text( text: str, *, target_code: str, source_code: str = "english", ) -> tuple[str, str] | None: """EN (or other) → target via Google Cloud or free gtx endpoint.""" text = (text or "").strip() if not text: return None target_tl = google_tl_code(target_code) source_tl = google_tl_code(source_code) or "auto" if not target_tl: return None if source_tl == target_tl: return text, "google_translate:identity" api_key = ( os.getenv("GOOGLE_TRANSLATE_API_KEY") or os.getenv("GOOGLE_API_KEY") or "" ).strip() try: if api_key: out = _google_translate_cloud( text, source_tl=source_tl, target_tl=target_tl, api_key=api_key ) if out: return out, "google_translate:cloud" out = _google_translate_gtx(text, source_tl=source_tl, target_tl=target_tl) if out: return out, "google_translate:gtx" except Exception as err: print(f"[google_translate] failed: {err}") return None def free_en_to_target(english: str, target_code: str, target_label: str) -> tuple[str, str] | None: """EN→target: Google Translate first, then Groq, then HF opus/NLLB.""" english = (english or "").strip() if not english: return None hit = google_translate_text( english, target_code=target_code, source_code="english" ) if hit: return hit hit = groq_chat_translate( english, source_label="English", target_code=target_code, target_label=target_label, ) if hit: return hit return _hf_en_to_target(english, target_code, target_label) def build_system_prompt() -> str: return ( "PRIMARY LAW: Obey Balochi_grammar_rules.pdf for every Balochi Latin string.\n\n" + CORE_ORTHOGRAPHY + "\n\n--- Full rules from Balochi_grammar_rules.pdf (binding) ---\n" + RESOURCES["grammar_excerpt"] ) def style_fewshot_en_bal(k: int = MAX_FEWSHOT) -> list[tuple[float, dict]]: out: list[tuple[float, dict]] = [] for pair in RESOURCES["pairs"]: if len(pair["en"]) > 90 or len(pair["bal"]) > 90: continue out.append((0.45, pair)) if len(out) >= k: break return out def build_user_prompt( text: str, route: dict, fewshot: list[tuple[float, dict]], english_pivot: str | None = None, text_format: str | None = None, support_draft: str | None = None, support_draft_rejected: bool = False, lex_hits: list[tuple[str, str]] | None = None, ) -> str: route_key = route["key"] target_label = route["target_label"] to_balochi = route_key in {"en-bal", "any-bal"} if route_key in {"en-bal", "any-bal"}: src_l = route.get("source_label") or ( "English" if route_key == "en-bal" else route.get("source", "source language") ) tgt_l = "Balochi Latin Syáhag" pair_lines = [f"EN: {p['en']}\nBAL: {p['bal']}" for _, p in fewshot] if route_key == "any-bal" and not is_english(route.get("source", "")): bridge = ( f"The source is {src_l} (not Balochi). " "Understand the meaning, then write natural Balochi Latin Syáhag. " "Use English↔Balochi dataset pairs as style/sense guides. " "Apply Balochi_grammar_rules.pdf strictly (SOV, endings, orthography)." ) else: bridge = "" script_rule = ( "CRITICAL for Balochi output: Latin Syáhag ONLY (never Arabic/Perso-Arabic). " "Alphabet A Á B Ch D Dh E É G H I J K L M N O Ó P R S Sh T Th U W Y Z Zh. " "Never use f, q, v, x. Follow Balochi grammar (SOV, ent/ant, verb endings)." ) elif route_key == "bal-en": src_l, tgt_l = "Balochi Latin Syáhag", "English" pair_lines = [f"BAL: {p['bal']}\nEN: {p['en']}" for _, p in fewshot] bridge = ( "FIDELITY RULES (mandatory):\n" "- Translate ONLY the Balochi source meaning — nothing else.\n" "- Do NOT invent scenes, religions, places, or English idioms " "that are not in the source (e.g. no Hajj/Kaaba/pilgrims/'early bird' " "unless those ideas are actually present in the Balochi).\n" "- CRITICAL: Balochi 'hajjám' means BARBER (not pilgrim). " "Do not confuse it with Hajj.\n" "- Do NOT replace the text with an unrelated story.\n" "- Keep the same paragraph / line structure as the source.\n" "- Lexicon glosses are BINDING for those words (hajjám = Barber)." ) script_rule = "Write natural English in Latin script." else: src_l, tgt_l = "Balochi Latin Syáhag", target_label pair_lines = [f"BAL: {p['bal']}\nEN (sense): {p['en']}" for _, p in fewshot] bridge = ( f"The source is Balochi in Latin Syáhag script (not Arabic script). " f"Use dataset English glosses only as meaning support, then write natural {target_label}." ) if wants_arabic_script(route): script_rule = ( f"CRITICAL: Write the entire {target_label} translation in Arabic / Perso-Arabic script only. " "Do NOT use Latin transliteration (no Roman Urdu / Roman Persian)." ) else: script_rule = f"Use the standard writing system for {target_label}." priority = ( "SUPPORT STACK (mandatory order — matches genai_support):\n" "1) dataset.xlsx / dataset.csv — search words & sentences (BINDING sense)\n" "2) words_balochi_to_english.xlsx — word senses (BINDING; " "hajjám = Barber — NEVER pilgrim/Kaaba/Hajj)\n" "3) Balochi_grammar_rules.pdf — understand Balochi morphology/syntax\n" "4) Produce faithful English from the Balochi source using (1)–(3)\n" "5) Then render that English meaning into the selected target language.\n" "Do NOT use TranslateGemma / LoRA adapter drafts." ) sections = [ f"Translate from {src_l} to {tgt_l}.", "Keep meaning faithful. Reply with the translation ONLY.", priority, structure_hint(text, text_format), script_rule, ] if to_balochi: sections.append(GRAMMAR_MUST_FOLLOW) if bridge: sections.append(bridge) # support_draft ignored — TranslateGemma adapter is disabled. _ = (support_draft, support_draft_rejected) if pair_lines: sections.append( "SUPPORT 1 — parallel examples from dataset.xlsx / dataset.csv " "(BINDING sense guides):\n" + "\n\n".join(pair_lines) ) if english_pivot: sections.append( "SUPPORT 1b — exact/near dataset English sense:\n" f"{english_pivot}" ) if lex_hits: gloss = "\n".join(f"{a} → {b}" for a, b in lex_hits) sections.append( "SUPPORT 2 — BINDING lexicon glosses from words_balochi_to_english.xlsx:\n" f"{gloss}" ) if to_balochi: sections.append( "SUPPORT 3 — BINDING text from Balochi_grammar_rules.pdf " "(follow these rules exactly):\n" + RESOURCES["grammar_excerpt"][:12000] ) elif route_key in {"bal-en", "bal-any"}: sections.append( "SUPPORT 3 — Understand Balochi Latin Syáhag with Balochi_grammar_rules.pdf " "(Latin orthography, SOV, endings) before writing the translation." ) sections.append( "SUPPORT 4 — Produce the final translation using supports (1)–(3) only.\n" f"Source text:\n{text}\n\nTranslation:" ) return "\n\n".join(sections) def postprocess_balochi(text: str) -> str: text = text.strip().strip('"').strip("'") text = re.sub(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]+", "", text) repl = {"f": "p", "F": "P", "q": "k", "Q": "K", "v": "w", "V": "W", "x": "ks", "X": "Ks"} text = "".join(repl.get(ch, ch) for ch in text) return re.sub(r"\s+", " ", text).strip() class TranslationRequest(BaseModel): text: str source_lang: str target_lang: str text_format: str | None = Field( default=None, description="Optional: poetry | story — controls output layout", ) class TranslationResponse(BaseModel): original_text: str translated_text: str source_lang: str target_lang: str method: str = Field( description="translategemma | exact_dataset | corpus_near_exact | grounded_model" ) support: dict = Field(default_factory=dict) @app.get("/health") def health(): return { "ok": True, "model": GEMINI_MODEL, "fallbacks": GEMINI_FALLBACKS, "thinking_budget": GEMINI_THINKING_BUDGET, "gemini_key_set": bool(os.getenv("GEMINI_API_KEY")), "groq_key_set": groq_configured(), "groq_model": groq_model_id(), "google_translate": True, "google_translate_cloud_key_set": bool( (os.getenv("GOOGLE_TRANSLATE_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip() ), "pairs": len(RESOURCES["pairs"]), "lexicon": len(RESOURCES["bal_lex"]), "grammar_chars": len(RESOURCES["grammar_excerpt"]), "dataset_source": RESOURCES.get("dataset_path"), "words_source": RESOURCES.get("words_path"), "grammar_source": ( GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name ), "supporting_model": { "enabled": False, "ready": False, "note": "disabled — Groq uses dataset + lexicon + grammar only", }, "priority": PRIORITIES, "groq_support": groq_support_stack() if groq_configured() else [], "genai_support": GENAI_SUPPORT, "pipeline": [ "dataset.xlsx sentence/word search", "words_balochi_to_english.xlsx word senses", "Balochi_grammar_rules.pdf understanding", "groq Balochi→selected language", ], "priority_adapter": None, "translategemma_hub": None, "routes": ["balochi→any", "any→balochi", "english→balochi"], } @app.get("/languages") def languages(): return { "source_default": "balochi", "targets": [{"code": code, "name": name} for code, name in TARGET_LANGUAGES], "also_supported": ( "Balochi → any language as target_lang; " "any language as source_lang → balochi (grammar rules applied)" ), } @app.get("/") def ui(): index = STATIC_DIR / "index.html" if not index.exists(): raise HTTPException(status_code=404, detail="UI missing: static/index.html") return FileResponse(index) if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") def corpus_compose_lines(text: str, route_key: str) -> tuple[str, dict] | None: """Line-compose from dataset; unmatched lines keep the original text.""" if route_key not in {"bal-en", "en-bal"}: return None lines = text.split("\n") out: list[str] = [] matched = 0 kept_original = 0 sources: list[str] = [] for line in lines: raw = line.strip() if not raw: out.append("") continue hit = exact_lookup(raw, route_key) if hit is None: out.append(raw) # keep original Balochi / English kept_original += 1 sources.append(raw) continue matched += 1 out.append(hit) sources.append(raw) if matched == 0: return None return "\n".join(out), { "matched_lines": matched, "kept_original_lines": kept_original, "sources": sources, "note": "unmatched_lines_kept_as_original", } @app.post("/translate", response_model=TranslationResponse) async def translate_text(request: TranslationRequest): text_clean = norm_preserve_structure(request.text) if not text_clean: raise HTTPException(status_code=400, detail="text is empty") route = parse_route(request.source_lang, request.target_lang) route_key = route["key"] exact_en_or_bal = exact_lookup(text_clean, route_key) if "\n" not in text_clean else None if exact_en_or_bal is not None and route_key in {"en-bal", "bal-en"}: return TranslationResponse( original_text=text_clean, translated_text=exact_en_or_bal, source_lang=request.source_lang, target_lang=request.target_lang, method="exact_dataset", support={ "matched": text_clean, "dataset_source": RESOURCES.get("dataset_path"), "grammar_source": GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name, "priority": PRIORITIES, }, ) if route_key == "any-bal": fewshot = retrieve_fewshot(text_clean, "en-bal") or style_fewshot_en_bal() elif route_key == "bal-any": fewshot = retrieve_fewshot(text_clean, "bal-en") else: fewshot = retrieve_fewshot(text_clean, route_key) text_format = (request.text_format or "").strip().casefold() or None if text_format not in {None, "poetry", "story"}: text_format = None support_draft = None support_draft_rejected = False support_draft_meta: dict = {} # TranslateGemma draft for short en↔bal AND bal→any (English pivot for Urdu etc.). # Long labzank essays skip LoRA to avoid ZeroGPU + 90s timeouts. tg_route = ( "bal-en" if route_key == "bal-any" else (route_key if route_key in {"en-bal", "bal-en"} else None) ) if tg_route and should_run_translategemma(text_clean, text_format): try: from support_translategemma import draft_translation, translategemma_status def _tg_max_tokens(src: str) -> int: base = int(os.getenv("TRANSLATEGEMMA_MAX_NEW_TOKENS", "256")) words = max(1, len(src.split())) lines = max(1, src.count("\n") + 1) return min(1024, max(base, words * 2, lines * 32)) def _tg_draft_primary(src: str, rk: str) -> str | None: """Line-by-line LoRA first for multi-line posts; else whole-text.""" def _from_lines(s: str) -> str | None: out_lines: list[str] = [] for line in s.split("\n"): raw = line.strip() if not raw: out_lines.append("") continue part = draft_translation(raw, rk, _tg_max_tokens(raw)) if not part or not part.strip(): return None out_lines.append(part.strip().strip('"').strip("'")) draft = "\n".join(out_lines).strip() if draft and draft_covers_source(s, draft): return draft return None if "\n" in src: lined = _from_lines(src) if lined: return lined max_tokens = _tg_max_tokens(src) whole = draft_translation(src, rk, max_tokens) if whole and whole.strip() and draft_covers_source(src, whole): return whole.strip().strip('"').strip("'") if "\n" not in src and whole and whole.strip(): return whole.strip().strip('"').strip("'") return None on_zerogpu = os.getenv("SPACES_ZERO_GPU", "").strip().lower() in { "1", "true", "yes", } if on_zerogpu: support_draft = _tg_draft_primary(text_clean, tg_route) else: support_draft = await asyncio.to_thread( _tg_draft_primary, text_clean, tg_route ) support_draft_meta = translategemma_status() if support_draft: translated = support_draft if tg_route == "en-bal": translated = postprocess_balochi(translated) support_draft = translated support_draft_meta = { **support_draft_meta, "draft": support_draft, "adapter_dir": str( Path( os.getenv( "TRANSLATEGEMMA_ADAPTER", str(ROOT / "translategemma_balochi_adapter"), ) ) ), "priority_role": ( "english_pivot_for_bal_any" if route_key == "bal-any" else "draft_for_genai" ), } if not draft_covers_source(text_clean, translated): support_draft_meta = { **support_draft_meta, "rejected_short_draft": True, "draft_lines": non_empty_line_count(translated), "source_lines": non_empty_line_count(text_clean), } support_draft_rejected = True if tg_route == "bal-en": conflicts = lexicon_draft_conflicts( text_clean, translated, "bal-en" ) if conflicts: support_draft_meta = { **support_draft_meta, "rejected_lexicon_conflict": conflicts, } support_draft_rejected = True elif _looks_like_hallucinated_mt(text_clean, translated) or ( _english_misses_lexicon(text_clean, translated, strict=True) or draft_misses_lexicon_senses( text_clean, translated, "bal-en" ) ): support_draft_meta = { **support_draft_meta, "rejected_unrelated_draft": True, } support_draft_rejected = True # bal→en / en→bal: accept a clean LoRA draft WITHOUT Gemini. if ( route_key in {"bal-en", "en-bal"} and not support_draft_rejected and support_draft and draft_covers_source(text_clean, support_draft) and not _looks_like_hallucinated_mt(text_clean, support_draft) and ( route_key == "en-bal" or not _english_misses_lexicon( text_clean, support_draft, strict=True ) ) and ( route_key == "bal-en" or not violates_syahag(support_draft) ) ): return TranslationResponse( original_text=text_clean, translated_text=support_draft, source_lang=request.source_lang, target_lang=request.target_lang, method="translategemma", support={ "supporting_model": { **support_draft_meta, "priority_role": "accepted_without_genai", }, "dataset_source": RESOURCES.get("dataset_path"), "grammar_source": ( GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name ), "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, "note": "adapter_ok_dataset_lexicon_checked", "syahag_ok": ( not bool(violates_syahag(support_draft)) if route_key == "en-bal" else None ), }, ) # Do NOT soft-accept weak LoRA poems — fall through to # dataset / grounded Groq instead of shipping invented lines. except Exception as err: support_draft_meta = {"ready": False, "error": str(err)} elif route_key in {"en-bal", "bal-en", "bal-any"}: try: from support_translategemma import translategemma_status support_draft_meta = { **translategemma_status(), "skipped_long_text": True, "chars": len(text_clean), "words": len(text_clean.split()), "lines": non_empty_line_count(text_clean), "priority_role": "skipped_for_latency", } except Exception: support_draft_meta = {"skipped_long_text": True} composed = corpus_compose_lines(text_clean, route_key) if composed is not None: translated, meta = composed if route_key in {"en-bal", "any-bal"}: translated = postprocess_balochi(translated) return TranslationResponse( original_text=text_clean, translated_text=translated, source_lang=request.source_lang, target_lang=request.target_lang, method="exact_dataset_lines", support={ **meta, "dataset_source": RESOURCES.get("dataset_path"), "grammar_source": GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name, "supporting_model": support_draft_meta, "priority": PRIORITIES, }, ) if ( route_key in {"bal-en", "en-bal"} and "\n" not in text_clean and fewshot and fewshot[0][0] >= CORPUS_DIRECT_SCORE ): score, pair = fewshot[0] translated = pair["bal"] if route_key == "en-bal" else pair["en"] if route_key == "en-bal": translated = postprocess_balochi(translated) return TranslationResponse( original_text=text_clean, translated_text=translated, source_lang=request.source_lang, target_lang=request.target_lang, method="corpus_near_exact", support={ "score": round(score, 3), "example": {"en": pair["en"], "bal": pair["bal"]}, "dataset_source": RESOURCES.get("dataset_path"), "grammar_source": GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name, "supporting_model": support_draft_meta, "priority": PRIORITIES, }, ) english_pivot = exact_en_or_bal if route_key == "bal-any" else None if english_pivot is None and route_key == "bal-any" and fewshot and fewshot[0][0] >= 0.55: english_pivot = fewshot[0][1]["en"] # Prefer dataset English pivot for bal→Urdu/etc. (no adapter). if ( route_key == "bal-any" and fewshot and fewshot[0][0] >= 0.45 and draft_covers_source(text_clean, fewshot[0][1]["en"]) ): english_pivot = fewshot[0][1]["en"] lex_route = "en-bal" if route_key in {"en-bal", "any-bal"} else "bal-en" lex_hits = lexicon_hits(text_clean, lex_route) # bal→Urdu/etc.: Groq reads dataset+lexicon+grammar, then translates # Balochi → target itself (no TranslateGemma; Google only as last resort). if route_key == "bal-any" and groq_configured(): tgt_code = route.get("target", "") tgt_label = route.get("target_label", "") fmt = text_format or "poetry" free = groq_translate_chunked( text_clean, source_label="Balochi Latin Syáhag", target_code=tgt_code, target_label=tgt_label, text_format=fmt, route_key="bal-en", adapter_draft=None, strict_quality=True, ) note = "groq_grounded_balochi_to_target" pivot = None if not free: # Retry without strict filters free = groq_translate_chunked( text_clean, source_label="Balochi Latin Syáhag", target_code=tgt_code, target_label=tgt_label, text_format=fmt, route_key="bal-en", adapter_draft=None, strict_quality=False, ) if not free: # Last resort: grounded English then Google / Groq EN→target pivot = english_pivot if ( not pivot or non_empty_line_count(text_clean) > 6 or _looks_like_hallucinated_mt(text_clean, pivot or "") or _english_misses_lexicon(text_clean, pivot or "") ): en_hit = groq_translate_chunked( text_clean, source_label="Balochi Latin Syáhag", target_code="english", target_label="English", text_format=fmt, route_key="bal-en", adapter_draft=None, ) if en_hit: pivot = en_hit[0] if pivot: free = free_en_to_target(pivot, tgt_code, tgt_label) if free and str(free[1]).startswith("google_translate"): note = "fallback_google_via_grounded_english" elif free: note = "fallback_groq_via_grounded_english" if free: text, model_id = free if fmt != "story": text = align_line_structure(text_clean, text) method = ( "google_translate" if str(model_id).startswith("google_translate") else "groq_grounded" ) return TranslationResponse( original_text=text_clean, translated_text=text, source_lang=request.source_lang, target_lang=request.target_lang, method=method, support={ "model": model_id, "english_pivot": pivot, "note": note, "lexicon_hits": lex_hits[:12], "supporting_model": { "enabled": False, "ready": False, "note": "translategemma_disabled", }, "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, "groq_support": groq_support_stack(), "google_translate": str(model_id).startswith("google_translate"), "routes": ["balochi→any", "any→balochi", "english→balochi"], }, ) raise HTTPException( status_code=502, detail=( "Translation failed after dataset/lexicon/grammar/Groq " "(no TranslateGemma). Retry in a minute." ), ) # bal→English: dataset + lexicon + grammar → Groq English. No adapter / Gemini. if route_key == "bal-en" and groq_configured(): lex_hits_be = lexicon_hits(text_clean, "bal-en") # 1) Strong dataset near-match / line compose if fewshot and fewshot[0][0] >= 0.35: score, pair = fewshot[0] if draft_covers_source(text_clean, pair["en"]) or non_empty_line_count(text_clean) <= 2: return TranslationResponse( original_text=text_clean, translated_text=pair["en"], source_lang=request.source_lang, target_lang=request.target_lang, method="corpus_near_exact", support={ "score": round(score, 3), "example": {"en": pair["en"], "bal": pair["bal"]}, "lexicon_hits": lex_hits_be[:12], "note": "dataset_sentence_search", "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) composed = corpus_compose_lines(text_clean, "bal-en") if composed: text, meta = composed return TranslationResponse( original_text=text_clean, translated_text=text, source_lang=request.source_lang, target_lang=request.target_lang, method="exact_dataset_lines", support={ **meta, "lexicon_hits": lex_hits_be[:12], "note": "dataset_line_compose", "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) # 2) AI English grounded by dataset + lexicon + grammar fmt = text_format if not fmt: lines_n = non_empty_line_count(text_clean) avg = len(text_clean) / max(lines_n, 1) fmt = "poetry" if lines_n >= 4 and avg < 90 else "story" free = groq_translate_chunked( text_clean, source_label="Balochi Latin Syáhag", target_code="english", target_label="English", text_format=fmt, route_key="bal-en", adapter_draft=None, strict_quality=True, ) if not free: free = groq_translate_chunked( text_clean, source_label="Balochi Latin Syáhag", target_code="english", target_label="English", text_format=fmt, route_key="bal-en", adapter_draft=None, strict_quality=False, ) if free: text, model_id = free if fmt != "story": text = align_line_structure(text_clean, text) return TranslationResponse( original_text=text_clean, translated_text=text, source_lang=request.source_lang, target_lang=request.target_lang, method="groq_grounded", support={ "model": model_id, "note": "dataset_lexicon_grammar_then_groq_english", "lexicon_hits": lex_hits_be[:12], "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, "groq_support": groq_support_stack(), }, ) raise HTTPException( status_code=502, detail=( "Balochi→English failed after dataset/lexicon/grammar/Groq " "(TranslateGemma off; Gemini skipped). Retry in a minute." ), ) if route_key == "en-bal" and groq_configured(): free = groq_translate_chunked( text_clean, source_label="English", target_code="balochi", target_label="Balochi Latin Syáhag", text_format=text_format or "story", route_key="en-bal", adapter_draft=None, ) if free: text, model_id = free text = postprocess_balochi(text) return TranslationResponse( original_text=text_clean, translated_text=text, source_lang=request.source_lang, target_lang=request.target_lang, method="groq_grounded", support={ "model": model_id, "note": "dataset_lexicon_grammar_then_groq_balochi", "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, "groq_support": groq_support_stack(), }, ) raise HTTPException( status_code=502, detail=( "English→Balochi failed after dataset/lexicon/grammar/Groq " "(TranslateGemma off; Gemini skipped). Retry in a minute." ), ) # Short bal↔en without Groq: dataset only (no adapter / Gemini). if route_key in {"bal-en", "en-bal"} and not groq_configured(): if fewshot and fewshot[0][0] >= 0.22: score, pair = fewshot[0] translated = pair["bal"] if route_key == "en-bal" else pair["en"] if route_key == "en-bal": translated = postprocess_balochi(translated) return TranslationResponse( original_text=text_clean, translated_text=translated, source_lang=request.source_lang, target_lang=request.target_lang, method="corpus_near_exact", support={ "score": round(score, 3), "example": {"en": pair["en"], "bal": pair["bal"]}, "note": "dataset_only_no_groq_key", "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) raise HTTPException( status_code=503, detail=( "Set GROQ_API_KEY for AI English/target MT after dataset+lexicon+grammar. " "TranslateGemma adapter is disabled." ), ) # Cooldown: do not call Gemini at all while free-tier is exhausted. if not gemini_allowed(): wait = max(1, int(_gemini_quota_until - time.time())) if route_key == "bal-any" and (english_pivot or support_draft): pivot = english_pivot or support_draft or "" free = free_en_to_target( pivot, route.get("target", ""), route.get("target_label", "") ) if free: text, model_id = free if text_format != "story": text = align_line_structure(text_clean, text) return TranslationResponse( original_text=text_clean, translated_text=text, source_lang=request.source_lang, target_lang=request.target_lang, method="free_mt", support={ "model": model_id, "note": "gemini_quota_used_free_hf_or_groq", "english_pivot": pivot, "retry_after_sec": wait, "supporting_model": support_draft_meta, "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) return TranslationResponse( original_text=text_clean, translated_text=pivot, source_lang=request.source_lang, target_lang=request.target_lang, method="english_sense_quota_fallback", support={ "warning": ( f"Gemini cooldown (~{wait}s). Free HF/Groq MT unavailable; " f"showing English sense. Set GROQ_API_KEY or wait / enable billing." ), "retry_after_sec": wait, "supporting_model": support_draft_meta, "priority": PRIORITIES, }, ) raise HTTPException( status_code=429, detail=( f"Gemini free-tier cooldown — retry in {wait}s, use English, " "or enable billing on GEMINI_API_KEY / set GROQ_API_KEY." ), ) # Hard stop: with GROQ_API_KEY set, never call Gemini for labzank routes. if groq_configured() and route_key in {"bal-en", "en-bal", "bal-any", "any-bal"}: raise HTTPException( status_code=502, detail=( "Groq/Google path unavailable; Gemini skipped because " "GROQ_API_KEY is set. Retry in a minute." ), ) async def _genai_one( chunk: str, *, draft: str | None, draft_rejected: bool, fmt: str | None, pivot: str | None = None, ) -> tuple[str, str]: # Fast path: EN→target with a tiny prompt when we already have English. if route_key == "bal-any" and pivot and chunk == text_clean: try: pivot_prompt = build_pivot_prompt( pivot, route["target_label"], text_format=fmt, arabic_script=wants_arabic_script(route), ) out, model = await asyncio.to_thread( generate_with_retries, pivot_prompt, light=True ) return out.strip().strip('"').strip("'"), model except Exception as err: if _is_quota_exhausted(err) or not gemini_allowed(): free = free_en_to_target( pivot, route.get("target", ""), route.get("target_label", "") ) if free: return free raise chunk_few = retrieve_fewshot( chunk, "en-bal" if route_key == "any-bal" else ("bal-en" if route_key == "bal-any" else route_key), ) if route_key == "any-bal" and not chunk_few: chunk_few = fewshot chunk_lex = lexicon_hits(chunk, lex_route) prompt = build_user_prompt( chunk, route, chunk_few or fewshot, english_pivot=pivot if chunk == text_clean else None, text_format=fmt, support_draft=draft if chunk == text_clean else None, support_draft_rejected=draft_rejected if chunk == text_clean else False, lex_hits=chunk_lex or lex_hits, ) try: out, model = await asyncio.to_thread(generate_with_retries, prompt) except Exception as err: if ( route_key == "bal-any" and pivot and (_is_quota_exhausted(err) or not gemini_allowed()) ): free = free_en_to_target( pivot, route.get("target", ""), route.get("target_label", "") ) if free: return free raise if route_key in {"en-bal", "any-bal"}: out = postprocess_balochi(out) else: out = out.strip().strip('"').strip("'") return out, model try: used_model = GEMINI_MODEL chunked = False if should_chunk_genai(text_clean, text_format) and route_key != "bal-any": paras = story_paragraphs(text_clean) # Merge tiny paragraphs so we don't fire dozens of Gemini calls. batches: list[str] = [] buf = "" for p in paras: if not buf: buf = p elif len(buf) + len(p) + 2 <= GENAI_CHUNK_CHARS: buf = f"{buf}\n\n{p}" else: batches.append(buf) buf = p if buf: batches.append(buf) outs: list[str] = [] for batch in batches: part, used_model = await _genai_one( batch, draft=None, draft_rejected=False, fmt=text_format or "story", ) outs.append(part) translated = "\n\n".join(outs) chunked = True else: translated, used_model = await _genai_one( text_clean, draft=support_draft, draft_rejected=support_draft_rejected, fmt=text_format, pivot=english_pivot, ) if text_format != "story" and not chunked: translated = align_line_structure(text_clean, translated) if route_key in {"en-bal", "any-bal"} and not chunked: issues = violates_syahag(translated) if issues: repair_prompt = ( build_user_prompt( text_clean, route, fewshot, english_pivot=english_pivot, text_format=text_format, support_draft=support_draft, support_draft_rejected=support_draft_rejected, lex_hits=lex_hits, ) + "\n\nYour previous Balochi draft VIOLATES Balochi_grammar_rules.pdf " f"({', '.join(issues)}).\n" "Rewrite the FULL translation in correct Balochi Latin Syáhag only, " "obeying the PDF rules. Reply with the corrected translation only:\n" f"{translated}\n\nCorrected translation:" ) translated, used_model = await asyncio.to_thread( generate_with_retries, repair_prompt ) translated = postprocess_balochi(translated) if text_format != "story": translated = align_line_structure(text_clean, translated) if ( wants_arabic_script(route) and arabic_script_ratio(translated) < 0.55 and not chunked ): fix_prompt = ( build_user_prompt( text_clean, route, fewshot, english_pivot=english_pivot, text_format=text_format, support_draft=support_draft, support_draft_rejected=support_draft_rejected, lex_hits=lex_hits, ) + "\n\nYour previous draft used Latin letters. " f"Rewrite the full translation in Arabic script for {route['target_label']} only, " "preserving the same layout:\n" f"{translated}\n\nCorrected translation:" ) translated, used_model = await asyncio.to_thread(generate_with_retries, fix_prompt) translated = translated.strip().strip('"').strip("'") if text_format != "story": translated = align_line_structure(text_clean, translated) return TranslationResponse( original_text=text_clean, translated_text=translated, source_lang=request.source_lang, target_lang=request.target_lang, method="grounded_model", support={ "model": used_model, "route": route_key, "target_label": route["target_label"], "source_label": route.get("source_label"), "text_format": text_format or "auto", "english_pivot": english_pivot, "arabic_script_required": wants_arabic_script(route), "source_lines": len(text_clean.split("\n")), "output_lines": len(translated.split("\n")), "chunked": chunked, "fewshot": [{"score": round(s, 3), "en": p["en"], "bal": p["bal"]} for s, p in fewshot], "grammar_source": GRAMMAR_CACHE.name if GRAMMAR_CACHE.exists() else GRAMMAR_PDF.name, "grammar_chars": len(RESOURCES["grammar_excerpt"]), "syahag_ok": not bool(violates_syahag(translated)) if route_key in {"en-bal", "any-bal"} else None, "dataset_source": RESOURCES.get("dataset_path"), "supporting_model": support_draft_meta, "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, "priority_adapter": TRANSLATEGEMMA_ADAPTER_DIR, "translategemma_hub": TRANSLATEGEMMA_HUB, "lexicon": len(RESOURCES["bal_lex"]), }, ) except HTTPException: raise except Exception as err: # bal→en: never die on Gemini quota if we already have a LoRA draft. if ( route_key == "bal-en" and support_draft and _is_quota_exhausted(err) and not support_draft_meta.get("rejected_lexicon_conflict") ): return TranslationResponse( original_text=text_clean, translated_text=support_draft, source_lang=request.source_lang, target_lang=request.target_lang, method="translategemma", support={ "warning": "Gemini quota hit; returning adapter draft.", "gemini_error": str(getattr(err, "message", err)), "supporting_model": { **support_draft_meta, "priority_role": "lora_after_gemini_quota", }, "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) # bal→Urdu/etc.: if Gemini free quota is dead but we have English, return it. if ( route_key == "bal-any" and english_pivot and _is_quota_exhausted(err) ): return TranslationResponse( original_text=text_clean, translated_text=english_pivot, source_lang=request.source_lang, target_lang=request.target_lang, method="english_sense_quota_fallback", support={ "warning": ( f"{route['target_label']} needs Gemini; free-tier quota " "exceeded. Showing English sense from the adapter/dataset. " "Wait ~1 min or enable billing on GEMINI_API_KEY." ), "gemini_error": str(getattr(err, "message", err)), "retry_after_sec": _quota_retry_seconds(err), "route": route_key, "target_label": route["target_label"], "english_pivot": english_pivot, "supporting_model": support_draft_meta, "priority": PRIORITIES, "genai_support": GENAI_SUPPORT, }, ) offline = offline_fallback( text_clean, route_key, fewshot, route["target_label"], ) if offline is not None: translated, meta = offline if route_key in {"en-bal", "any-bal"}: translated = postprocess_balochi(translated) return TranslationResponse( original_text=text_clean, translated_text=translated, source_lang=request.source_lang, target_lang=request.target_lang, method="offline_fallback", support={ "gemini_error": str(getattr(err, "message", err)), "route": route_key, "target_label": route["target_label"], **meta, "priority": PRIORITIES, }, ) detail = getattr(err, "message", None) or str(err) if _is_quota_exhausted(err): raise HTTPException( status_code=429, detail=( f"Gemini free-tier quota exceeded. Retry in " f"{int(_quota_retry_seconds(err))}s, use English, " f"or enable billing on GEMINI_API_KEY. ({detail})" ), ) if isinstance(err, APIError): raise HTTPException(status_code=502, detail=f"Gemini API Error: {err.message}") raise HTTPException(status_code=502, detail=f"Gemini API Error: {err}") if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "8000")) uvicorn.run(app, host="0.0.0.0", port=port)