"""Translate product copy to Persian via an OpenAI-compatible Chat Completions API.""" from __future__ import annotations import json import os import re import ssl import urllib.error import urllib.request from pathlib import Path from typing import Any def _ssl_contexts() -> list[ssl.SSLContext]: contexts: list[ssl.SSLContext] = [] try: import certifi # type: ignore contexts.append(ssl.create_default_context(cafile=certifi.where())) except Exception: pass try: contexts.append(ssl.create_default_context()) except Exception: pass contexts.append(ssl._create_unverified_context()) return contexts ROOT = Path(__file__).resolve().parent DATA_DIR = ROOT / "data" SETTINGS_PATH = DATA_DIR / ".ai-settings.json" DEFAULT_BASE_URL = "https://api.openai.com/v1" DEFAULT_MODEL = "gpt-4o-mini" ARABIC_SCRIPT = re.compile(r"[\u0600-\u06FF]") SYSTEM_PROMPT = ( "You are a professional Persian (Farsi) copywriter for a luxury cosmetics retailer in Iran. " "Translate the given product text into natural, polished Persian. " "Keep brand names, shade codes, SPF, numbers, percentages, and product line names in Latin script when that is normal for cosmetics. " "Do not add marketing fluff that is not in the source. " "Return only the translated text, with no quotes or commentary." ) CATEGORY_SYSTEM_PROMPT = ( "You translate cosmetics ecommerce category names into short, natural Persian (Farsi) labels " "for an Iranian KIKO Milano storefront. " "Return only the Persian category title — no quotes, no explanation. " "Keep well-known brand tokens like KIKO in Latin if needed. " "Prefer concise menu labels (1–4 words)." ) def _mask_key(key: str) -> str: if not key: return "" return key[:3] + "…" + key[-4:] if len(key) > 8 else "••••" def _default_settings() -> dict: return { "apiKey": os.environ.get("OPENAI_API_KEY", "").strip(), "baseUrl": os.environ.get("OPENAI_BASE_URL", DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL, "model": os.environ.get("OPENAI_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL, "serpApiKey": os.environ.get("SERPAPI_API_KEY", os.environ.get("SERP_API_KEY", "")).strip(), "categoryApiKey": os.environ.get("CATEGORY_TRANSLATE_API_KEY", "").strip(), } def load_settings() -> dict: data = _default_settings() if SETTINGS_PATH.is_file(): try: raw = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) if isinstance(raw, dict): for key in ("apiKey", "baseUrl", "model", "serpApiKey", "categoryApiKey"): if isinstance(raw.get(key), str) and raw[key].strip(): data[key] = raw[key].strip() except (OSError, json.JSONDecodeError): pass data["baseUrl"] = (data.get("baseUrl") or DEFAULT_BASE_URL).rstrip("/") # Guard: SerpAPI dashboard/search hosts are not Chat Completions base URLs. if "serpapi.com" in data["baseUrl"].lower(): data["baseUrl"] = DEFAULT_BASE_URL data["model"] = data.get("model") or DEFAULT_MODEL data["serpApiKey"] = data.get("serpApiKey") or "" data["categoryApiKey"] = data.get("categoryApiKey") or "" return data def save_settings( *, api_key: str | None = None, base_url: str | None = None, model: str | None = None, serp_api_key: str | None = None, category_api_key: str | None = None, ) -> dict: current = load_settings() if api_key is not None: current["apiKey"] = api_key.strip() if base_url is not None: current["baseUrl"] = (base_url.strip() or DEFAULT_BASE_URL).rstrip("/") if model is not None: current["model"] = model.strip() or DEFAULT_MODEL if serp_api_key is not None: current["serpApiKey"] = serp_api_key.strip() if category_api_key is not None: current["categoryApiKey"] = category_api_key.strip() DATA_DIR.mkdir(parents=True, exist_ok=True) SETTINGS_PATH.write_text( json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) try: os.chmod(SETTINGS_PATH, 0o600) except OSError: pass return public_settings(current) def public_settings(settings: dict | None = None) -> dict: data = settings or load_settings() key = data.get("apiKey") or "" serp = data.get("serpApiKey") or "" cat_key = data.get("categoryApiKey") or "" return { "hasKey": bool(key), "keyHint": _mask_key(key), "hasSerpKey": bool(serp), "serpKeyHint": _mask_key(serp), "hasCategoryKey": bool(cat_key), "categoryKeyHint": _mask_key(cat_key), "baseUrl": data.get("baseUrl") or DEFAULT_BASE_URL, "model": data.get("model") or DEFAULT_MODEL, } def looks_persian(text: str) -> bool: letters = [c for c in text if c.isalpha()] if len(letters) < 8: # Short labels: any Arabic-script char counts return bool(ARABIC_SCRIPT.search(text or "")) persian = sum(1 for c in letters if ARABIC_SCRIPT.match(c)) return (persian / len(letters)) >= 0.35 def needs_translation(text: str, *, overwrite: bool = False) -> bool: value = (text or "").strip() if not value: return False if overwrite: return not looks_persian(value) return not looks_persian(value) def chat_translate( text: str, *, settings: dict | None = None, timeout: int = 90, system_prompt: str | None = None, user_prefix: str | None = None, api_key_override: str | None = None, ) -> str: data = settings or load_settings() api_key = (api_key_override or data.get("apiKey") or "").strip() if not api_key: raise ValueError("AI API key is not configured in Settings") base = (data.get("baseUrl") or DEFAULT_BASE_URL).rstrip("/") model = data.get("model") or DEFAULT_MODEL url = f"{base}/chat/completions" payload = { "model": model, "temperature": 0.2, "messages": [ {"role": "system", "content": system_prompt or SYSTEM_PROMPT}, { "role": "user", "content": f"{user_prefix or 'Translate this cosmetics product text to Persian:'}\n\n{text}", }, ], } # SerpAPI keys / dashboard URLs cannot call Chat Completions. if "serpapi.com" in base.lower(): raise ValueError( "OpenAI base URL is set to SerpAPI. Use https://api.openai.com/v1 for translation." ) if api_key == (data.get("serpApiKey") or "").strip() and api_key: raise ValueError( "The OpenAI key field currently holds your SerpAPI key. " "Paste a real OpenAI (or compatible) key for translation." ) body = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url, data=body, method="POST", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "kikomiko-admin/1.0", }, ) raw = "" last_err: Exception | None = None for ctx in _ssl_contexts(): try: with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: raw = resp.read().decode("utf-8", "ignore") last_err = None break except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "ignore")[:400] raise ValueError(f"AI API HTTP {exc.code}: {detail or exc.reason}") from exc except urllib.error.URLError as exc: last_err = exc continue if last_err is not None: raise ValueError(f"AI API unreachable: {getattr(last_err, 'reason', last_err)}") from last_err try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError("AI API returned invalid JSON") from exc try: content = parsed["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: raise ValueError("AI API response missing translation content") from exc out = str(content or "").strip() if out.startswith("```"): out = re.sub(r"^```(?:\w+)?\s*", "", out) out = re.sub(r"\s*```$", "", out) if (out.startswith('"') and out.endswith('"')) or ( out.startswith("«") and out.endswith("»") ): out = out[1:-1].strip() if not out: raise ValueError("AI API returned an empty translation") return out def collect_jobs(product: dict, *, overwrite: bool = False) -> list[tuple[str, str]]: """Return list of (path, text) that still need Persian translation.""" jobs: list[tuple[str, str]] = [] desc = str(product.get("description") or "").strip() if needs_translation(desc, overwrite=overwrite): jobs.append(("description", desc)) for key in ("descriptionIntro", "tagline", "seoDescription", "seoTitle"): value = str(product.get(key) or "").strip() if needs_translation(value, overwrite=overwrite): jobs.append((key, value)) variants = product.get("colorVariants") or product.get("color_variants") or [] if isinstance(variants, list): for i, variant in enumerate(variants): if not isinstance(variant, dict): continue value = str(variant.get("description") or "").strip() if needs_translation(value, overwrite=overwrite): jobs.append((f"colorVariants.{i}.description", value)) return jobs def apply_translation(product: dict, path: str, translated: str) -> None: if path == "description": if not (product.get("sourceDescription") or "").strip(): product["sourceDescription"] = product.get("description") or "" product["description"] = translated return if path.startswith("colorVariants."): parts = path.split(".") idx = int(parts[1]) variants = product.get("colorVariants") or [] if isinstance(variants, list) and 0 <= idx < len(variants): variants[idx]["description"] = translated product["colorVariants"] = variants return product[path] = translated def translate_product( product: dict, *, overwrite: bool = False, settings: dict | None = None, ) -> dict[str, Any]: """Translate pending English fields on one product. Mutates and returns stats.""" jobs = collect_jobs(product, overwrite=overwrite) changed: list[str] = [] errors: list[str] = [] for path, text in jobs: try: translated = chat_translate(text, settings=settings) apply_translation(product, path, translated) changed.append(path) except Exception as exc: # noqa: BLE001 — surface per-field failures errors.append(f"{path}: {exc}") break # stop this product on first API failure (often key/quota) return { "id": product.get("id"), "slug": product.get("slug"), "changed": changed, "errors": errors, "pending": len(jobs), } def category_needs_fa(category: dict, *, overwrite: bool = False) -> bool: """True when nameFa is missing or still English/non-Persian.""" name = str(category.get("name") or category.get("slug") or "").strip() name_fa = str(category.get("nameFa") or category.get("name_fa") or "").strip() if not name: return False if not name_fa: return True if overwrite: return not looks_persian(name_fa) return not looks_persian(name_fa) def resolve_category_api_key(settings: dict | None = None) -> str: """OpenAI-compatible key for categories; never reuse a SerpAPI key by mistake.""" data = settings or load_settings() cat = (data.get("categoryApiKey") or "").strip() main = (data.get("apiKey") or "").strip() serp = (data.get("serpApiKey") or "").strip() if cat and cat != serp: return cat if main and main != serp: return main return "" def local_category_fa(name: str, slug: str = "") -> str: """Dictionary / seed Persian label without calling an external API.""" try: import server as admin_server # lazy: avoids import cycle at module load for candidate in ( admin_server.seed_name_fa_by_slug().get(slug or "", ""), admin_server.persian_category_label(name), admin_server.persian_category_label((slug or "").replace("-", " ")), admin_server.persian_category_label((slug or "").replace("-", " / ")), ): text = str(candidate or "").strip() if text and looks_persian(text): return text except Exception: pass return "" def translate_category_name( category: dict, *, overwrite: bool = False, settings: dict | None = None, ) -> dict[str, Any]: """Translate one category English name → Persian nameFa. Mutates category. Prefers local seed/KIKO maps (works offline); falls back to AI when needed. """ data = settings or load_settings() if not category_needs_fa(category, overwrite=overwrite): return { "slug": category.get("slug"), "changed": False, "nameFa": category.get("nameFa") or category.get("name_fa") or "", "source": "existing", "errors": [], } source = str(category.get("name") or category.get("slug") or "").strip() slug = str(category.get("slug") or "").strip() local = local_category_fa(source, slug) if local: category["nameFa"] = local category["name_fa"] = local return { "slug": slug, "changed": True, "from": source, "nameFa": local, "source": "local", "errors": [], } api_key = resolve_category_api_key(data) if not api_key: raise ValueError( "No Persian mapping for this category and no OpenAI key is set. " "Save a real OpenAI API key (not the SerpAPI key) in Settings, or add the label to seed_categories." ) translated = chat_translate( source, settings=data, system_prompt=CATEGORY_SYSTEM_PROMPT, user_prefix="Translate this cosmetics category name to a short Persian menu label:", api_key_override=api_key, ) if not looks_persian(translated): raise ValueError(f"AI returned non-Persian category label: {translated!r}") category["nameFa"] = translated category["name_fa"] = translated return { "slug": slug, "changed": True, "from": source, "nameFa": translated, "source": "ai", "errors": [], }