llm_translate.py 11.6 KB
"""
LLM-based translation helper using Qwen chat model.

This module provides a thin wrapper around DashScope's `qwen-flash` model
for high-quality, prompt-controlled translation, independent of the main
`Translator` (machine translation) pipeline.

Usage example:

    from query.llm_translate import llm_translate

    result = llm_translate(
        text="我看到这个视频后没有笑",
        target_lang="en",
        source_lang="zh",
        source_lang_label="中文",
        target_lang_label="英文",
    )
"""

from __future__ import annotations

import logging
import os
import time
from typing import Dict, Optional

from openai import OpenAI

from config.env_config import DASHSCOPE_API_KEY
from config.services_config import get_translation_config

logger = logging.getLogger(__name__)


# 华北2(北京):https://dashscope.aliyuncs.com/compatible-mode/v1
# 新加坡:https://dashscope-intl.aliyuncs.com/compatible-mode/v1
# 美国(弗吉尼亚):https://dashscope-us.aliyuncs.com/compatible-mode/v1
#
# 默认保持与现有翻译/索引脚本相同的美国地域,可通过环境变量覆盖:
#   DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
DEFAULT_QWEN_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
QWEN_MODEL_NAME = "qwen-flash"


# 由调用方提供的语言标签/代码填充,占位符说明:
# - source_lang: 源语言的人类可读名称(按目标语言本地化,例如 "中文", "English")
# - target_lang: 目标语言的人类可读名称
# - src_lang_code: 源语言代码,例如 "zh"
# - tgt_lang_code: 目标语言代码,例如 "en"
TRANSLATION_PROMPTS: Dict[str, str] = {
    "zh": """你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译员。你的目标是在遵循 {target_lang} 的语法、词汇和文化习惯的前提下,准确传达原始 {source_lang} 文本的含义和细微差别。请只输出 {target_lang} 的翻译内容,不要包含任何额外的解释或评论。请将以下 {source_lang} 文本翻译成 {target_lang}:

{text}""",
    "en": """You are a professional {source_lang} ({src_lang_code}) to {target_lang} ({tgt_lang_code}) translator. Your goal is to accurately convey the meaning and nuances of the original {source_lang} text while adhering to {target_lang} grammar, vocabulary, and cultural sensitivities. Produce only the {target_lang} translation, without any additional explanations or commentary. Please translate the following {source_lang} text into {target_lang}:

{text}""",
    "ru": """Вы профессиональный переводчик с {source_lang} ({src_lang_code}) на {target_lang} ({tgt_lang_code}). Ваша задача — точно передать смысл и нюансы исходного текста на {source_lang}, соблюдая грамматику, лексику и культурные особенности {target_lang}. Выводите только перевод на {target_lang}, без каких-либо дополнительных объяснений или комментариев. Пожалуйста, переведите следующий текст с {source_lang} на {target_lang}:

{text}""",
    "ar": """أنت مترجم محترف من {source_lang} ({src_lang_code}) إلى {target_lang} ({tgt_lang_code}). هدفك هو نقل المعنى والدلالات الدقيقة للنص الأصلي بلغة {source_lang} بدقة، مع الالتزام بقواعد اللغة والمفردات والحساسيات الثقافية الخاصة بلغة {target_lang}. قم بإنتاج الترجمة إلى {target_lang} فقط دون أي شروحات أو تعليقات إضافية. يرجى ترجمة النص التالي من {source_lang} إلى {target_lang}:

{text}""",
    "ja": """あなたは {source_lang}({src_lang_code})から {target_lang}({tgt_lang_code})へのプロの翻訳者です。{target_lang} の文法、語彙、文化的配慮に従いながら、元の {source_lang} テキストの意味やニュアンスを正確に伝えることが目的です。追加の説明やコメントは一切含めず、{target_lang} の翻訳のみを出力してください。次の {source_lang} テキストを {target_lang} に翻訳してください:

{text}""",
    "es": """Eres un traductor profesional de {source_lang} ({src_lang_code}) a {target_lang} ({tgt_lang_code}). Tu objetivo es transmitir con precisión el significado y los matices del texto original en {source_lang}, respetando la gramática, el vocabulario y las sensibilidades culturales de {target_lang}. Produce únicamente la traducción en {target_lang}, sin explicaciones ni comentarios adicionales. Por favor, traduce el siguiente texto de {source_lang} a {target_lang}:

{text}""",
    "de": """Du bist ein professioneller Übersetzer von {source_lang} ({src_lang_code}) nach {target_lang} ({tgt_lang_code}). Dein Ziel ist es, die Bedeutung und Nuancen des ursprünglichen {source_lang}-Textes genau zu vermitteln und dabei die Grammatik, den Wortschatz und die kulturellen Besonderheiten von {target_lang} zu berücksichtigen. Gib ausschließlich die Übersetzung in {target_lang} aus, ohne zusätzliche Erklärungen oder Kommentare. Bitte übersetze den folgenden {source_lang}-Text in {target_lang}:

{text}""",
    "fr": """Vous êtes un traducteur professionnel de {source_lang} ({src_lang_code}) vers {target_lang} ({tgt_lang_code}). Votre objectif est de transmettre fidèlement le sens et les nuances du texte original en {source_lang}, tout en respectant la grammaire, le vocabulaire et les sensibilités culturelles de {target_lang}. Produisez uniquement la traduction en {target_lang}, sans explications ni commentaires supplémentaires. Veuillez traduire le texte suivant de {source_lang} vers {target_lang} :

{text}""",
    "it": """Sei un traduttore professionista da {source_lang} ({src_lang_code}) a {target_lang} ({tgt_lang_code}). Il tuo obiettivo è trasmettere con precisione il significato e le sfumature del testo originale in {source_lang}, rispettando la grammatica, il vocabolario e le sensibilità culturali di {target_lang}. Produci solo la traduzione in {target_lang}, senza spiegazioni o commenti aggiuntivi. Per favore traduci il seguente testo da {source_lang} a {target_lang}:

{text}""",
    "pt": """Você é um tradutor profissional de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Seu objetivo é transmitir com precisão o significado e as nuances do texto original em {source_lang}, respeitando a gramática, o vocabulário e as sensibilidades culturais de {target_lang}. Produza apenas a tradução em {target_lang}, sem quaisquer explicações ou comentários adicionais. Por favor, traduza o seguinte texto de {source_lang} para {target_lang}:

{text}""",
}


def _get_qwen_client(base_url: Optional[str] = None) -> Optional[OpenAI]:
    """
    Lazily construct an OpenAI-compatible client for DashScope.

    Uses DASHSCOPE_API_KEY and base_url (provider config / env) to configure endpoint.
    """
    api_key = DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY")
    if not api_key:
        logger.warning("DASHSCOPE_API_KEY not set; llm-based translation will be disabled")
        return None

    # 优先使用显式传入的 base_url,其次环境变量,最后默认地域。
    base_url = (
        (base_url or "").strip()
        or os.getenv("DASHSCOPE_BASE_URL")
        or DEFAULT_QWEN_BASE_URL
    )

    try:
        client = OpenAI(api_key=api_key, base_url=base_url)
        return client
    except Exception as exc:
        logger.error("Failed to initialize DashScope OpenAI client: %s", exc, exc_info=True)
        return None


def _build_prompt(
    text: str,
    target_lang: str,
    source_lang_label: str,
    target_lang_label: str,
    src_lang_code: str,
    tgt_lang_code: str,
) -> str:
    """
    Build translation prompt for given target language, defaulting to English template.
    """
    key = (target_lang or "").lower()
    template = TRANSLATION_PROMPTS.get(key) or TRANSLATION_PROMPTS["en"]
    return template.format(
        source_lang=source_lang_label,
        target_lang=target_lang_label,
        src_lang_code=src_lang_code,
        tgt_lang_code=tgt_lang_code,
        text=text,
    )


def llm_translate(
    text: str,
    target_lang: str,
    *,
    source_lang: Optional[str] = None,
    source_lang_label: Optional[str] = None,
    target_lang_label: Optional[str] = None,
    timeout_sec: Optional[float] = None,
) -> Optional[str]:
    """
    Translate text with Qwen chat model using rich prompts.

    - 根据目标语言选择提示词,如果没匹配到则退回英文模板。
    - 不对 text 做语言检测或缓存,调用方自行控制。

    Args:
        text: 原始文本
        target_lang: 目标语言代码(如 "zh", "en")
        source_lang: 源语言代码(可选,不影响提示词选择,仅用于日志)
        source_lang_label: 源语言展示名称,用于 prompt(默认使用 source_lang)
        target_lang_label: 目标语言展示名称,用于 prompt(默认使用 target_lang)
        timeout_sec: 请求超时时间(秒,可选;若未配置则从 config 读取或采用默认)

    Returns:
        翻译后的文本;如失败则返回 None。
    """
    if not text or not str(text).strip():
        return text

    cfg = get_translation_config()
    provider_cfg = cfg.providers.get("llm", {}) if isinstance(cfg.providers, dict) else {}

    model_name = provider_cfg.get("model") or QWEN_MODEL_NAME
    req_timeout = float(provider_cfg.get("timeout_sec") or timeout_sec or 30.0)
    base_url = (provider_cfg.get("base_url") or "").strip() or None

    client = _get_qwen_client(base_url=base_url)
    if not client:
        # 无法调用云端,直接回退
        logger.warning(
            "[llm_translate] Client init failed; returning original text. "
            "text=%r target_lang=%s source_lang=%s",
            text[:80],
            target_lang,
            source_lang or "auto",
        )
        return text

    tgt = (target_lang or "").lower() or "en"
    src = (source_lang or "auto").lower()
    src_label = source_lang_label or src
    tgt_label = target_lang_label or tgt

    prompt = _build_prompt(
        text=text,
        target_lang=tgt,
        source_lang_label=src_label,
        target_lang_label=tgt_label,
        src_lang_code=src,
        tgt_lang_code=tgt,
    )

    start = time.time()
    try:
        completion = client.chat.completions.create(
            model=model_name,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                }
            ],
            timeout=req_timeout,
        )
        content = (completion.choices[0].message.content or "").strip()
        duration_ms = (time.time() - start) * 1000
        logger.info(
            "[llm_translate] Success | model=%s src=%s tgt=%s latency=%.1fms text=%r -> %r",
            model_name,
            src,
            tgt,
            duration_ms,
            text[:80],
            content[:80],
        )
        return content or text
    except Exception as exc:
        duration_ms = (time.time() - start) * 1000
        logger.warning(
            "[llm_translate] Failed | model=%s src=%s tgt=%s latency=%.1fms error=%s",
            model_name,
            src,
            tgt,
            duration_ms,
            exc,
            exc_info=True,
        )
        # 安全回退:出错时返回原文,避免中断上游流程
        return text


__all__ = [
    "TRANSLATION_PROMPTS",
    "llm_translate",
]