diff --git a/backend/agent_translation/agent.py b/backend/agent_translation/agent.py
index cfe6476..af00ade 100644
--- a/backend/agent_translation/agent.py
+++ b/backend/agent_translation/agent.py
@@ -1,23 +1,26 @@
"""Translation agent - handles article translation via LLM."""
+from __future__ import annotations
+
import asyncio
import re
from llm_providers import (
+ ChatCompletion,
ChatMessage,
ChatOptions,
LLMProvider,
LLMProviderError,
get_provider,
)
+from llm_providers.base import TokenUsage
def chunk_by_headings(text: str, max_chars: int = 4000) -> list[str]:
- """按标题分段,每段不超过 max_chars 字符"""
- # 按标题分割(支持 Markdown 标题)
- sections = re.split(r'(?=^#{1,3}\s)', text, flags=re.MULTILINE)
+ """Split large documents into heading-aware chunks."""
+ sections = re.split(r"(?=^#{1,3}\s)", text, flags=re.MULTILINE)
- chunks = []
+ chunks: list[str] = []
current_chunk = ""
for section in sections:
@@ -25,19 +28,17 @@ def chunk_by_headings(text: str, max_chars: int = 4000) -> list[str]:
if not section:
continue
- # 如果当前 chunk 加上新 section 超过限制,先保存当前 chunk
if current_chunk and len(current_chunk) + len(section) > max_chars:
chunks.append(current_chunk.strip())
current_chunk = section
else:
- current_chunk = current_chunk + "\n\n" + section if current_chunk else section
+ current_chunk = f"{current_chunk}\n\n{section}" if current_chunk else section
if current_chunk.strip():
chunks.append(current_chunk.strip())
- # 如果没有标题或分段太长,按段落分
if not chunks or (len(chunks) == 1 and len(chunks[0]) > max_chars):
- paragraphs = text.split('\n\n')
+ paragraphs = text.split("\n\n")
chunks = []
current_chunk = ""
for para in paragraphs:
@@ -48,7 +49,7 @@ def chunk_by_headings(text: str, max_chars: int = 4000) -> list[str]:
chunks.append(current_chunk.strip())
current_chunk = para
else:
- current_chunk = current_chunk + "\n\n" + para if current_chunk else para
+ current_chunk = f"{current_chunk}\n\n{para}" if current_chunk else para
if current_chunk.strip():
chunks.append(current_chunk.strip())
@@ -69,8 +70,7 @@ class TranslationAgent:
"translation. Translate the provided article content into the requested "
"target language.\n"
"- If the target language is English, translate every Chinese sentence into English.\n"
- "- If the target language is Chinese (中文), "
- "translate every English sentence into Chinese.\n"
+ "- If the target language is Chinese, translate every English sentence into Chinese.\n"
"- Text already written in the target language must be left exactly as-is.\n"
"- Never echo the source text back untranslated, and never answer in the "
"source language when a translation is requested.\n\n"
@@ -81,7 +81,7 @@ class TranslationAgent:
"- Tone and style of the original\n"
"- Markdown formatting (bold, italic, headers, lists, etc.)\n\n"
"Important rules:\n"
- "- Replace image links () with [图片] placeholder\n"
+ "- Replace image links () with [image] placeholder\n"
"- Keep text links [text](url) as [text] only, remove the URL\n"
"- Remove HTML tags like
,
,
etc.\n"
"- Clean up excessive whitespace and blank lines\n"
@@ -89,14 +89,13 @@ class TranslationAgent:
"Respond with ONLY the translated text in clean Markdown format."
)
- def __init__(self, provider: LLMProvider | None = None):
- """
- Initialize the translation agent.
-
- Args:
- provider: LLMProvider instance. If None, uses default provider from registry.
- """
- self.provider = provider or get_provider()
+ def __init__(self, provider: LLMProvider | None = None, use_mock: bool = False):
+ if provider is not None:
+ self.provider = provider
+ elif use_mock:
+ self.provider = MockTranslationProvider()
+ else:
+ self.provider = get_provider()
async def translate_chunk(
self,
@@ -105,20 +104,7 @@ async def translate_chunk(
temperature: float = 0.3,
max_retries: int = 3,
) -> dict:
- """翻译单个片段,失败时按指数退避重试。
-
- Args:
- content: 待翻译片段
- target_lang: 目标语言
- temperature: LLM temperature
- max_retries: 最大尝试次数(含首次)
-
- Returns:
- dict with text + token usage.
-
- Raises:
- LLMProviderError: 重试耗尽后仍失败时抛出,由调用方决定如何降级。
- """
+ """Translate a single chunk with retry-on-provider-error behavior."""
messages = [
ChatMessage(role="system", content=self.SYSTEM_PROMPT),
ChatMessage(
@@ -142,53 +128,58 @@ async def translate_chunk(
last_error = exc
if attempt == max_retries - 1:
break
- # 指数退避:0.5s, 1s, 2s ...,缓解限流/瞬时网络错误
await asyncio.sleep(0.5 * (2**attempt))
raise last_error if last_error else LLMProviderError("Translation failed")
- async def translate_bilingual(
+ async def translate_whole_article(
self,
content: str,
target_lang: str,
temperature: float = 0.3,
) -> dict:
- """
- 双语对照翻译:英文一段,中文一段,交替显示。
+ """Translate the whole article in one request, summary-style."""
+ messages = [
+ ChatMessage(role="system", content=self.SYSTEM_PROMPT),
+ ChatMessage(
+ role="user",
+ content=f"Please translate to {target_lang}:\n\n{content}",
+ ),
+ ]
+ options = ChatOptions(temperature=temperature)
- Args:
- content: Article text to translate
- target_lang: Target language (e.g., "Chinese")
- temperature: LLM temperature
+ completion = await self.provider.chat(messages, options=options)
+ return {
+ "translated_text": completion.content,
+ "provider": self.provider.name,
+ "model": self.provider.model,
+ "usage": {
+ "prompt_tokens": completion.usage.prompt_tokens,
+ "completion_tokens": completion.usage.completion_tokens,
+ },
+ }
- Returns:
- Dictionary with translated_text (bilingual format) and usage
+ async def translate_bilingual(
+ self,
+ content: str,
+ target_lang: str,
+ temperature: float = 0.3,
+ ) -> dict:
"""
- # 按段落分段
- paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()]
+ Translate into alternating original/translation blocks.
- # 合并短段落,避免太碎片化
- merged_paragraphs = []
- current = ""
- for para in paragraphs:
- if len(current) + len(para) < 500:
- current = current + "\n\n" + para if current else para
- else:
- if current:
- merged_paragraphs.append(current)
- current = para
- if current:
- merged_paragraphs.append(current)
+ Each markdown paragraph is translated independently so the rendered
+ bilingual view stays aligned block by block.
+ """
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", content) if p.strip()]
total_prompt_tokens = 0
total_completion_tokens = 0
- bilingual_parts = []
+ bilingual_parts: list[str] = []
failed_chunks = 0
- for para in merged_paragraphs:
- # 检查是否是标题
- if para.startswith('#'):
- # 标题直接翻译,不保留原文;失败则退化为原标题
+ for para in paragraphs:
+ if para.startswith("#"):
try:
result = await self.translate_chunk(para, target_lang, temperature)
bilingual_parts.append(result["text"])
@@ -197,32 +188,29 @@ async def translate_bilingual(
except LLMProviderError:
failed_chunks += 1
bilingual_parts.append(para)
- else:
- # 普通段落:先原文,再翻译。单段失败不影响整篇,保留原文占位。
- original = f'
{para}
'
- try:
- result = await self.translate_chunk(para, target_lang, temperature)
- translated = result["text"]
- total_prompt_tokens += result["prompt_tokens"]
- total_completion_tokens += result["completion_tokens"]
- except LLMProviderError:
- failed_chunks += 1
- translated = para
- translation = f'
{translated}
'
- bilingual_parts.append(original)
- bilingual_parts.append("")
- bilingual_parts.append(translation)
- bilingual_parts.append("")
-
- # 全部片段都失败时,视为整体失败,交由 service 标记 failure
- if merged_paragraphs and failed_chunks == len(merged_paragraphs):
- raise LLMProviderError("All translation chunks failed")
+ continue
- # 合并结果
- translated_text = "\n\n".join(bilingual_parts)
+ original = f'
{para}
'
+ try:
+ result = await self.translate_chunk(para, target_lang, temperature)
+ translated = result["text"]
+ total_prompt_tokens += result["prompt_tokens"]
+ total_completion_tokens += result["completion_tokens"]
+ except LLMProviderError:
+ failed_chunks += 1
+ translated = para
+
+ translation = f'
{translated}
'
+ bilingual_parts.append(original)
+ bilingual_parts.append("")
+ bilingual_parts.append(translation)
+ bilingual_parts.append("")
+
+ if paragraphs and failed_chunks == len(paragraphs):
+ raise LLMProviderError("All translation chunks failed")
return {
- "translated_text": translated_text,
+ "translated_text": "\n\n".join(bilingual_parts),
"provider": self.provider.name,
"model": self.provider.model,
"usage": {
@@ -241,34 +229,18 @@ async def translate(
"""
Translate content to target language.
Automatically chunks long articles by headings.
-
- Args:
- content: Article text to translate (cleaned markdown/plain text)
- target_lang: Target language (e.g., "English", "Chinese", "Spanish")
- temperature: LLM temperature (0.0-1.0), lower = more consistent
- bilingual: If True, return bilingual format (original + translation)
-
- Returns:
- Dictionary with:
- - translated_text: The translated content
- - provider: Provider name
- - model: Model name
- - usage: Token usage (prompt_tokens, completion_tokens)
"""
- # 如果是双语对照模式
if bilingual:
return await self.translate_bilingual(content, target_lang, temperature)
- # 分段
chunks = chunk_by_headings(content, max_chars=4000)
total_prompt_tokens = 0
total_completion_tokens = 0
- translated_chunks = []
+ translated_chunks: list[str] = []
failed_chunks = 0
for chunk in chunks:
- # 单段失败不影响整篇:保留原文占位,继续翻译其余片段
try:
result = await self.translate_chunk(chunk, target_lang, temperature)
translated_chunks.append(result["text"])
@@ -278,15 +250,11 @@ async def translate(
failed_chunks += 1
translated_chunks.append(chunk)
- # 全部片段都失败时,视为整体失败,交由 service 标记 failure
if chunks and failed_chunks == len(chunks):
raise LLMProviderError("All translation chunks failed")
- # 合并翻译结果
- translated_text = "\n\n".join(translated_chunks)
-
return {
- "translated_text": translated_text,
+ "translated_text": "\n\n".join(translated_chunks),
"provider": self.provider.name,
"model": self.provider.model,
"usage": {
@@ -294,3 +262,70 @@ async def translate(
"completion_tokens": total_completion_tokens,
},
}
+
+
+class MockTranslationProvider:
+ """Local mock provider used when no real translation provider is configured."""
+
+ @property
+ def name(self) -> str:
+ return "mock"
+
+ @property
+ def model(self) -> str:
+ return "mock-translation"
+
+ async def chat(
+ self,
+ messages: list[ChatMessage],
+ *,
+ stream: bool = False,
+ options: ChatOptions | None = None,
+ ) -> ChatCompletion:
+ user_prompt = next(
+ (
+ message.content
+ for message in reversed(messages)
+ if message.role == "user"
+ ),
+ "",
+ )
+ match = re.match(r"Please translate to\s+(.+?):\s*\n\n([\s\S]*)", user_prompt)
+ if match:
+ target_lang = match.group(1).strip()
+ source_text = match.group(2).strip()
+ else:
+ target_lang = "translated"
+ source_text = user_prompt.strip()
+
+ translated = _mock_translate_text(source_text, target_lang)
+ return ChatCompletion(
+ content=translated,
+ model=self.model,
+ usage=TokenUsage(
+ prompt_tokens=max(len(source_text) // 4, 1),
+ completion_tokens=max(len(translated) // 4, 1),
+ ),
+ )
+
+
+def _mock_translate_text(source_text: str, target_lang: str) -> str:
+ cleaned = source_text.strip()
+ if not cleaned:
+ return ""
+
+ if cleaned.startswith("#"):
+ heading_marks, _, heading_text = cleaned.partition(" ")
+ translated_heading = _mock_translate_sentence(heading_text or cleaned, target_lang)
+ return f"{heading_marks} {translated_heading}".strip()
+
+ return _mock_translate_sentence(cleaned, target_lang)
+
+
+def _mock_translate_sentence(text: str, target_lang: str) -> str:
+ language = target_lang.strip().lower()
+ if language == "english":
+ return f"[Mock English] {text}"
+ if language == "chinese":
+ return f"[Mock Chinese] {text}"
+ return f"[Mock {target_lang}] {text}"
diff --git a/backend/agent_translation/http/__init__.py b/backend/agent_translation/http/__init__.py
index 8ad8b69..7445a5c 100644
--- a/backend/agent_translation/http/__init__.py
+++ b/backend/agent_translation/http/__init__.py
@@ -1,5 +1 @@
-"""HTTP layer for translation agent."""
-
-from .router import router
-
-__all__ = ["router"]
+"""HTTP package for translation agent routes."""
diff --git a/backend/agent_translation/service.py b/backend/agent_translation/service.py
index 712112f..3d882f4 100644
--- a/backend/agent_translation/service.py
+++ b/backend/agent_translation/service.py
@@ -1,22 +1,32 @@
-"""Application service for article translation.
-
-Responsible for:
-1. Loading article content from database
-2. Invoking translation agent with LLM provider
-3. Handling errors and recording results
-4. Persisting translations to database
-"""
+from __future__ import annotations
+import logging
+import os
import re
from dataclasses import dataclass
from datetime import UTC
+from pathlib import Path
+
+from pydantic import SecretStr
+from agent_summary.llm_client import LLMClient, LLMClientError
from app.schemas.agent import TranslationRequest, TranslationResult
from db import get_article, get_article_content, record_usage, save_agent_result
from llm_providers import LLMProviderError, ProviderNotFoundError, get_provider
+from llm_providers.base import (
+ ChatCompletion,
+ ChatMessage,
+ ChatOptions,
+ LLMProvider,
+ TokenUsage,
+)
+from llm_providers.config import ProviderConfig, ProviderKind
+from llm_providers.openai_compatible import OpenAICompatibleProvider
from .agent import TranslationAgent
+logger = logging.getLogger(__name__)
+
class TranslationServiceError(Exception):
"""Base class for translation request failures."""
@@ -32,176 +42,116 @@ class ArticleContentUnavailableError(TranslationServiceError):
@dataclass(slots=True)
class TranslationService:
- """
- Application service for article translation.
+ """Application service for loading article content and persisting translation results."""
- Orchestrates:
- - Data loading from database
- - LLM provider selection
- - Translation execution
- - Result persistence
- - Usage tracking
- """
+ agent_factory: type[TranslationAgent] = TranslationAgent
async def generate(self, request: TranslationRequest) -> TranslationResult:
- """
- Generate translation for an article.
-
- Workflow:
- 1. Load article metadata
- 2. Resolve content (prefer cleaned over raw)
- 3. Get LLM provider
- 4. Execute translation
- 5. Handle errors gracefully
- 6. Save result and usage to database
-
- Args:
- request: TranslationRequest with entry_id, target_lang, provider, model
-
- Returns:
- TranslationResult with translated_html and status.
- Always succeeds - status indicates success/failure
-
- Raises:
- ArticleNotFoundError: If article doesn't exist
- ArticleContentUnavailableError: If article has no content
- """
- # 1. Load article from database
article = get_article(request.entry_id)
if article is None:
raise ArticleNotFoundError(request.entry_id)
- # 2. Resolve content - prefer cleaned markdown/text over raw HTML
content = self._resolve_content(request.entry_id, article.reader_html)
if not content:
raise ArticleContentUnavailableError(request.entry_id)
- # 3. Execute translation (with error handling)
- result = await self._execute_translation(
- entry_id=request.entry_id,
- target_lang=request.target_lang,
- content=content,
- provider_name=request.provider,
- model_name=request.model,
- )
-
- # 4. Save result to database
- save_agent_result(result)
-
- return result
-
- async def _execute_translation(
- self,
- entry_id: str,
- target_lang: str,
- content: str,
- provider_name: str | None,
- model_name: str | None,
- ) -> TranslationResult:
- """
- Execute translation with error handling.
-
- Returns SUCCESS or FAILURE status (never raises exceptions).
- Failures are recorded in the result for frontend handling.
-
- Args:
- entry_id: Article ID
- target_lang: Target language
- content: Article content to translate
- provider_name: Optional provider name (uses default if None)
- model_name: Optional model name (uses provider's default if None)
-
- Returns:
- TranslationResult with status SUCCESS or FAILURE
- """
try:
- # Get LLM provider (uses default if name not specified)
- provider = get_provider(name=provider_name)
-
- # Build agent with selected provider
- agent = TranslationAgent(provider=provider)
-
- # Execute translation (bilingual mode: original + translation)
- agent_result = await agent.translate(
- content=content,
- target_lang=target_lang,
- temperature=0.3, # Lower temperature for consistency
- bilingual=True, # 双语对照模式
- )
-
- # Record token usage
- from datetime import datetime
- today = datetime.now(UTC).strftime("%Y-%m-%d")
- record_usage(
- day=today,
- provider=agent_result["provider"],
- model=agent_result["model"],
- agent="translation",
- prompt_tokens=agent_result["usage"]["prompt_tokens"],
- completion_tokens=agent_result["usage"]["completion_tokens"],
- )
-
- # Return success result
- return TranslationResult(
- entry_id=entry_id,
- target_lang=target_lang,
- translation_html=agent_result["translated_text"],
+ if (
+ self.agent_factory is TranslationAgent
+ and not request.provider
+ and not request.model
+ and not _use_mock_llm()
+ ):
+ result = await self._translate_via_legacy_client(content, request.target_lang)
+ else:
+ agent = self._build_agent(request)
+ result = await agent.translate(
+ content=content,
+ target_lang=request.target_lang,
+ temperature=0.3,
+ bilingual=True,
+ )
+
+ translation = TranslationResult(
+ entry_id=request.entry_id,
+ target_lang=request.target_lang,
+ translation_html=result["translated_text"],
status="success",
- provider=agent_result["provider"],
- model=agent_result["model"],
+ provider=result["provider"],
+ model=result["model"],
)
-
- except ProviderNotFoundError:
- # No provider configured
- return TranslationResult(
- entry_id=entry_id,
- target_lang=target_lang,
+ save_agent_result(translation)
+
+ usage = result.get("usage", {})
+ if usage:
+ from datetime import datetime
+
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
+ record_usage(
+ day=today,
+ provider=result["provider"],
+ model=result["model"],
+ agent="translation",
+ prompt_tokens=usage.get("prompt_tokens", 0),
+ completion_tokens=usage.get("completion_tokens", 0),
+ )
+
+ return translation
+ except ProviderNotFoundError as exc:
+ translation = TranslationResult(
+ entry_id=request.entry_id,
+ target_lang=request.target_lang,
translation_html="",
status="failure",
- provider=provider_name or "unknown",
- model=model_name or "unknown",
+ provider=request.provider or "unknown",
+ model=request.model or "unknown",
)
-
- except LLMProviderError:
- # LLM API error (auth, network, rate limit, etc.)
- # Don't propagate - record as failure
- return TranslationResult(
- entry_id=entry_id,
- target_lang=target_lang,
+ save_agent_result(translation)
+ logger.warning("Translation provider not found for %s: %s", request.entry_id, exc)
+ return translation
+ except LLMProviderError as exc:
+ translation = TranslationResult(
+ entry_id=request.entry_id,
+ target_lang=request.target_lang,
translation_html="",
status="failure",
- provider=provider_name or "unknown",
- model=model_name or "unknown",
+ provider=request.provider or "unknown",
+ model=request.model or "unknown",
)
-
- except Exception:
- # Unexpected error - record as failure
- return TranslationResult(
- entry_id=entry_id,
- target_lang=target_lang,
+ save_agent_result(translation)
+ logger.warning("Translation provider error for %s: %s", request.entry_id, exc)
+ return translation
+ except Exception as exc:
+ translation = TranslationResult(
+ entry_id=request.entry_id,
+ target_lang=request.target_lang,
translation_html="",
status="failure",
- provider=provider_name or "unknown",
- model=model_name or "unknown",
+ provider=request.provider or "unknown",
+ model=request.model or "unknown",
)
+ save_agent_result(translation)
+ logger.exception("Unexpected translation failure for %s: %s", request.entry_id, exc)
+ return translation
+
+ def _build_agent(self, request: TranslationRequest) -> TranslationAgent:
+ if (
+ self.agent_factory is not TranslationAgent
+ and not request.provider
+ and not request.model
+ ):
+ return _instantiate_agent(self.agent_factory)
+
+ provider_name = (request.provider or "").strip().lower()
+ if provider_name == "mock" or _use_mock_llm():
+ return _instantiate_agent(self.agent_factory, use_mock=True)
+
+ provider = _resolve_provider(request)
+ if request.model:
+ provider = ModelOverrideProvider(provider, request.model)
+ return _instantiate_agent(self.agent_factory, provider=provider)
def _resolve_content(self, entry_id: str, reader_html: str) -> str:
- """
- Resolve article content, preferring cleaned content over raw HTML.
-
- Content resolution priority:
- 1. Cleaned markdown (highest quality)
- 2. Plain text (fallback)
- 3. Stripped raw HTML (last resort)
-
- Args:
- entry_id: Article ID for database lookup
- reader_html: Raw HTML from feed (fallback)
-
- Returns:
- Best available content string, or empty string if none available
- """
- # Try to get cleaned content from database
stored_content = get_article_content(entry_id)
if stored_content is not None:
if stored_content.cleaned_markdown.strip():
@@ -209,28 +159,204 @@ def _resolve_content(self, entry_id: str, reader_html: str) -> str:
if stored_content.plain_text.strip():
return stored_content.plain_text.strip()
- # Fallback: strip raw HTML
if reader_html.strip():
- return self._strip_html(reader_html)
-
+ return _strip_html(reader_html)
return ""
- @staticmethod
- def _strip_html(html: str) -> str:
- """
- Simple HTML tag removal.
-
- Removes HTML tags and normalizes whitespace.
- Used as fallback when cleaned content unavailable.
-
- Args:
- html: Raw HTML string
-
- Returns:
- Plain text with normalized whitespace
- """
- # Remove HTML tags
- text = re.sub(r"<[^>]+>", " ", html)
- # Normalize whitespace
- text = re.sub(r"\s+", " ", text)
- return text.strip()
+ async def _translate_via_legacy_client(self, content: str, target_lang: str) -> dict:
+ legacy_config = _load_translation_legacy_config()
+ client = LLMClient(
+ api_key=legacy_config["api_key"],
+ base_url=legacy_config["base_url"],
+ model=legacy_config["model"],
+ )
+ prompt = (
+ "You are a professional translator specializing in Chinese <-> English translation. "
+ "Translate the provided article content into the requested target language.\n"
+ "- If the target language is English, translate every Chinese sentence into English.\n"
+ "- If the target language is Chinese, translate every English sentence into Chinese.\n"
+ "- Text already written in the target language must be left exactly as-is.\n"
+ "- Never echo the source text back untranslated, and never answer in the "
+ "source language when a translation is requested.\n\n"
+ "Preserve meaning, nuance, structure, formatting, technical terms, and tone. "
+ "Remove raw HTML tags and output clean readable Markdown only.\n\n"
+ f"Please translate to {target_lang}:\n\n{content}"
+ )
+ try:
+ response = await client.chat_with_usage(prompt)
+ except LLMClientError as exc:
+ raise LLMProviderError(exc.detail) from exc
+
+ return {
+ "translated_text": response.text,
+ "provider": "legacy-env",
+ "model": client.model,
+ "usage": {
+ "prompt_tokens": response.prompt_tokens,
+ "completion_tokens": response.completion_tokens,
+ },
+ }
+
+
+class ModelOverrideProvider:
+ """Wrap an LLMProvider while allowing the request to override the model name."""
+
+ def __init__(self, provider: LLMProvider, model: str):
+ self._provider = provider
+ self._model = model
+
+ @property
+ def name(self) -> str:
+ return self._provider.name
+
+ @property
+ def model(self) -> str:
+ return self._model
+
+ async def chat(self, messages, *, stream=False, options=None):
+ return await self._provider.chat(messages, stream=stream, options=options)
+
+
+class SummaryClientProvider:
+ """Adapter that makes agent_summary's legacy LLMClient usable as an LLMProvider."""
+
+ def __init__(self, request: TranslationRequest):
+ self._client = LLMClient(model=request.model or None)
+ self._provider_name = "legacy-env"
+
+ @property
+ def name(self) -> str:
+ return self._provider_name
+
+ @property
+ def model(self) -> str:
+ return self._client.model
+
+ async def chat(
+ self,
+ messages: list[ChatMessage],
+ *,
+ stream: bool = False,
+ options: ChatOptions | None = None,
+ ) -> ChatCompletion:
+ if stream:
+ raise LLMProviderError("Streaming is not supported by SummaryClientProvider")
+
+ prompt = _messages_to_prompt(messages, options=options)
+ try:
+ response = await self._client.chat_with_usage(prompt)
+ except LLMClientError as exc:
+ raise LLMProviderError(exc.detail) from exc
+
+ return ChatCompletion(
+ content=response.text,
+ model=self._client.model,
+ usage=TokenUsage(
+ prompt_tokens=response.prompt_tokens,
+ completion_tokens=response.completion_tokens,
+ ),
+ )
+
+
+def _strip_html(value: str) -> str:
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", value)).strip()
+
+
+def _use_mock_llm() -> bool:
+ return False
+
+
+def _resolve_provider(request: TranslationRequest) -> LLMProvider:
+ provider_name = request.provider if request.provider else None
+ if not provider_name:
+ return SummaryClientProvider(request)
+
+ try:
+ return get_provider(provider_name)
+ except ProviderNotFoundError:
+ return _provider_from_legacy_env(request)
+
+
+def _provider_from_legacy_env(request: TranslationRequest) -> LLMProvider:
+ legacy_config = _load_translation_legacy_config()
+ api_key = legacy_config["api_key"]
+ if not api_key:
+ raise ProviderNotFoundError("No providers configured")
+
+ base_url = legacy_config["base_url"]
+ model = (request.model or legacy_config["model"] or "ecnu-max").strip()
+ config = ProviderConfig(
+ name=request.provider or "legacy-env",
+ kind=ProviderKind.OPENAI_COMPATIBLE,
+ model=model,
+ base_url=base_url,
+ api_key=SecretStr(api_key),
+ )
+ return OpenAICompatibleProvider(config)
+
+
+def _messages_to_prompt(
+ messages: list[ChatMessage],
+ *,
+ options: ChatOptions | None = None,
+) -> str:
+ prompt_parts: list[str] = []
+ for message in messages:
+ if message.role == "system":
+ prompt_parts.append(message.content.strip())
+ continue
+ label = "User" if message.role == "user" else "Assistant"
+ prompt_parts.append(f"{label}:\n{message.content.strip()}")
+
+ if options is not None and options.temperature is not None:
+ prompt_parts.append(f"Temperature: {options.temperature}")
+
+ return "\n\n".join(part for part in prompt_parts if part)
+
+
+def _candidate_env_paths() -> list[Path]:
+ home = Path.home()
+ return [
+ Path(__file__).parent / ".env",
+ Path.cwd() / ".env",
+ home / ".mercury" / ".env",
+ home / ".mercury" / "agent_translation.env",
+ ]
+
+
+def _load_legacy_env() -> None:
+ for env_path in _candidate_env_paths():
+ if not env_path.exists():
+ continue
+ with open(env_path, encoding="utf-8") as handle:
+ for line in handle:
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, value = line.split("=", 1)
+ os.environ.setdefault(key.strip(), value.strip())
+
+
+def _load_translation_legacy_config() -> dict[str, str]:
+ env_values: dict[str, str] = {}
+ for env_path in _candidate_env_paths():
+ if not env_path.exists():
+ continue
+ with open(env_path, encoding="utf-8") as handle:
+ for line in handle:
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, value = line.split("=", 1)
+ env_values.setdefault(key.strip(), value.strip())
+
+ return {
+ "api_key": env_values.get("LLM_API_KEY", "").strip(),
+ "base_url": env_values.get("LLM_BASE_URL", "https://chat.ecnu.edu.cn/open/api/v1").strip(),
+ "model": env_values.get("LLM_MODEL", "ecnu-max").strip(),
+ }
+
+
+def _instantiate_agent(agent_factory, **kwargs):
+ try:
+ return agent_factory(**kwargs)
+ except TypeError:
+ return agent_factory()
diff --git a/backend/agent_translation/tests/test_agent_resilience.py b/backend/agent_translation/tests/test_agent_resilience.py
index 8c0a88d..80183b8 100644
--- a/backend/agent_translation/tests/test_agent_resilience.py
+++ b/backend/agent_translation/tests/test_agent_resilience.py
@@ -96,3 +96,23 @@ async def test_single_chunk_failure_does_not_abort_when_others_succeed() -> None
# The article still returns; the failed chunk falls back to its original text.
assert "translated" in result["translated_text"]
assert result["provider"] == "fake"
+
+
+@pytest.mark.asyncio
+async def test_bilingual_translation_keeps_one_pair_per_paragraph() -> None:
+ provider = FakeProvider(["第一段译文", "第二段译文"])
+ agent = TranslationAgent(provider=provider)
+
+ result = await agent.translate(
+ "First paragraph.\n\nSecond paragraph.",
+ target_lang="Chinese",
+ bilingual=True,
+ )
+
+ assert provider.calls == 2
+ assert result["translated_text"].count('class="bilingual-original"') == 2
+ assert result["translated_text"].count('class="bilingual-translation"') == 2
+ assert '
First paragraph.
' in result["translated_text"]
+ assert '
第一段译文
' in result["translated_text"]
+ assert '
Second paragraph.
' in result["translated_text"]
+ assert '
第二段译文
' in result["translated_text"]
diff --git a/backend/tests/test_translation_http_contract.py b/backend/tests/test_translation_http_contract.py
new file mode 100644
index 0000000..0ab724f
--- /dev/null
+++ b/backend/tests/test_translation_http_contract.py
@@ -0,0 +1,204 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.config import settings
+from app.main import app
+from app.schemas.entry import Entry
+from app.schemas.feed import Feed
+from db import get_article, init_db, save_article, save_article_content, save_feed
+
+
+@pytest.fixture
+def api_db(tmp_path, monkeypatch) -> Iterator[str]:
+ db_path = tmp_path / "mercury-translation-http.db"
+ monkeypatch.setattr(settings, "db_path", db_path)
+ init_db(db_path)
+
+ save_feed(
+ Feed(
+ id="feed-1",
+ title="Mercury Blog",
+ site_url="https://example.com",
+ feed_url="https://example.com/feed.xml",
+ unread_count=0,
+ status="idle",
+ ),
+ db_path,
+ )
+ save_article(_article("article-1", reader_html="
Hello Mercury
"), db_path)
+ save_article_content(
+ article_id="article-1",
+ raw_html="
Hello Mercury
",
+ cleaned_html="
Hello Mercury
",
+ cleaned_markdown="Hello Mercury",
+ plain_text="Hello Mercury",
+ db_path=db_path,
+ )
+
+ yield str(db_path)
+
+
+@pytest.fixture
+def api_client(api_db: str) -> Iterator[TestClient]:
+ with TestClient(app) as client:
+ yield client
+
+
+def test_translate_article_uses_mock_fallback_and_persists_result(
+ api_client: TestClient,
+ monkeypatch,
+) -> None:
+ import agent_translation.service as translation_service_module
+ from agent_translation.http import router as translation_router
+ from agent_translation.service import TranslationService
+
+ monkeypatch.setattr(translation_service_module, "_use_mock_llm", lambda: True)
+ monkeypatch.setattr(
+ translation_router,
+ "get_translation_service",
+ lambda: TranslationService(),
+ )
+
+ response = api_client.post(
+ "/agents/translation",
+ json={"entry_id": "article-1", "target_lang": "Chinese"},
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["status"] == "success"
+ assert payload["provider"] == "mock"
+ assert '
Hello Mercury
' in payload["translation_html"]
+ assert (
+ '
[Mock Chinese] Hello Mercury
'
+ in payload["translation_html"]
+ )
+
+ saved = get_article("article-1")
+ assert saved is not None
+ assert payload["translation_html"] == saved.translation_html
+
+
+def test_translate_article_returns_404_for_missing_entry(api_client: TestClient) -> None:
+ response = api_client.post(
+ "/agents/translation",
+ json={"entry_id": "missing", "target_lang": "Chinese"},
+ )
+
+ assert response.status_code == 404
+ assert response.json() == {"detail": "Entry not found"}
+
+
+def test_translate_article_returns_409_when_no_content_is_available(api_client: TestClient) -> None:
+ save_article(_article("article-empty", reader_html=""), settings.db_path)
+
+ response = api_client.post(
+ "/agents/translation",
+ json={"entry_id": "article-empty", "target_lang": "Chinese"},
+ )
+
+ assert response.status_code == 409
+ assert response.json() == {"detail": "Entry has no content to translate"}
+
+
+def test_translate_article_returns_failure_payload_for_internal_errors(
+ api_client: TestClient, monkeypatch
+) -> None:
+ from agent_translation.http import router as translation_router
+ from agent_translation.service import TranslationService
+
+ class FailingAgent:
+ async def translate(
+ self,
+ content: str,
+ target_lang: str,
+ temperature: float = 0.3,
+ bilingual: bool = False,
+ ) -> dict:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(
+ translation_router,
+ "get_translation_service",
+ lambda: TranslationService(agent_factory=FailingAgent),
+ )
+
+ response = api_client.post(
+ "/agents/translation",
+ json={"entry_id": "article-1", "target_lang": "Chinese"},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "entry_id": "article-1",
+ "target_lang": "Chinese",
+ "translation_html": "",
+ "status": "failure",
+ "provider": "unknown",
+ "model": "unknown",
+ }
+
+
+def test_translate_article_falls_back_to_legacy_env_provider_when_registry_is_empty(
+ api_client: TestClient, monkeypatch
+) -> None:
+ from agent_translation.http import router as translation_router
+ from agent_translation.service import TranslationService
+
+ async def fake_translate_via_legacy_client(self, content: str, target_lang: str) -> dict:
+ assert content == "Hello Mercury"
+ assert target_lang == "Chinese"
+ return {
+ "translated_text": "Translated from legacy env",
+ "provider": "legacy-env",
+ "model": "legacy-model",
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1},
+ }
+
+ monkeypatch.setattr(
+ TranslationService,
+ "_translate_via_legacy_client",
+ fake_translate_via_legacy_client,
+ )
+ monkeypatch.setattr(translation_router, "get_translation_service", lambda: TranslationService())
+
+ response = api_client.post(
+ "/agents/translation",
+ json={"entry_id": "article-1", "target_lang": "Chinese"},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "entry_id": "article-1",
+ "target_lang": "Chinese",
+ "translation_html": "Translated from legacy env",
+ "status": "success",
+ "provider": "legacy-env",
+ "model": "legacy-model",
+ }
+
+
+def _article(article_id: str, reader_html: str) -> Entry:
+ return Entry(
+ id=article_id,
+ feed_id="feed-1",
+ title=f"{article_id} title",
+ summary="Short summary",
+ author="Mercury Team",
+ url=f"https://example.com/{article_id}",
+ published_at="2026-05-25T08:00:00Z",
+ is_read=False,
+ is_starred=False,
+ tag_ids=[],
+ reader_html=reader_html,
+ web_preview="",
+ related_entry_ids=[],
+ note="",
+ summary_text="",
+ translation_html=None,
+ translation_status="idle",
+ )
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 348f3b8..81ec5c9 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -1105,14 +1105,23 @@ function ReaderDetail(props: {
import("./services/api").then(({ translateArticle }) => {
translateArticle(entry.id, detectTargetLang(entry.readerHtml))
.then((result) => {
- props.onUpdateEntry(entry.id, (current) => ({
- ...current,
- translationHtml: result.translation_html,
- translationStatus: result.status === "success" ? "success" : "failure"
- }));
+ if (result.status === "success" && result.translation_html.trim()) {
+ props.onUpdateEntry(entry.id, (current) => ({
+ ...current,
+ translationHtml: result.translation_html,
+ translationStatus: "success"
+ }));
+ return;
+ }
+
+ props.onUpdateEntry(entry.id, (current) => ({ ...current, translationStatus: "failure" }));
+ props.onNotice(t("translationFailed"));
+ setTranslationMode("original");
})
- .catch(() => {
+ .catch((error) => {
props.onUpdateEntry(entry.id, (current) => ({ ...current, translationStatus: "failure" }));
+ props.onNotice(getApiErrorMessage(error));
+ setTranslationMode("original");
});
});
return;
@@ -2540,10 +2549,10 @@ function capitalize(value: string): string {
// article read in an English UI still gets a Chinese translation rather than
// a no-op "English -> English" round trip.
function detectTargetLang(content: string): string {
- const chineseChars = (content.match(/[一-鿿]/g) ?? []).length;
+ const chineseChars = Array.from(content.matchAll(/\p{Script=Han}/gu)).length;
const asciiLetters = (content.match(/[A-Za-z]/g) ?? []).length;
// Treat the text as Chinese when CJK characters clearly dominate.
- return chineseChars > asciiLetters ? "English" : "中文";
+ return chineseChars > asciiLetters ? "English" : "Chinese";
}
function emptyProviderDraft(): ProviderDraft {