add readming mode

This commit is contained in:
2026-10-26 17:31:08 +08:00
parent 9b7c7d726b
commit ed8df744f9
15 changed files with 565 additions and 9 deletions

View File

@@ -25,6 +25,8 @@ AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=45
# 后台批量翻译可使用更长时间;实际翻译超时 = max(AI_TIMEOUT_SECONDS, AI_TRANSLATION_TIMEOUT_SECONDS)
AI_TRANSLATION_TIMEOUT_SECONDS=120
# 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key

View File

@@ -21,7 +21,7 @@ _TRANSLATE_BATCH_CHARS = 2_200
_TRANSLATE_CONTEXT_BEFORE = 8
_TRANSLATE_CONTEXT_AFTER = 4
_TRANSLATE_WORKERS = 4
_TRANSLATE_TIMEOUT_SECONDS = 45.0
_TRANSLATE_TIMEOUT_SECONDS = 120.0
_TRANSLATE_SPLIT_THRESHOLD = 4
@@ -35,6 +35,7 @@ class AITutor:
api_key: str
model: str
timeout_seconds: float
translation_timeout_seconds: float = _TRANSLATE_TIMEOUT_SECONDS
@property
def available(self) -> bool:
@@ -301,13 +302,26 @@ class AITutor:
user_parts.append(
"输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}"
)
user_prompt = "\n".join(user_parts)
content = self._complete(
system_prompt,
"\n".join(user_parts),
user_prompt,
temperature=0.2,
timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS),
timeout_seconds=max(self.timeout_seconds, self.translation_timeout_seconds),
)
parsed = _parse_translation_payload(content, expected_indexes)
try:
parsed = _parse_translation_payload(content, expected_indexes)
except AITutorError:
logger.warning(
"Translation response parse failed: endpoint=%s model=%s "
"expected_indexes=%s prompt=%r raw_content=%r",
self.request_url,
self.model,
expected_indexes,
user_prompt[:2000],
content[:2000],
)
raise
source_by_index = {index: text for index, text in batch}
missing = [
index
@@ -315,6 +329,25 @@ class AITutor:
if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, ""))
]
if missing:
invalid = [
{
"index": index,
"source": source_by_index.get(index, ""),
"translation": parsed.get(index, ""),
}
for index in missing
]
logger.warning(
"Translation response failed validation: endpoint=%s model=%s "
"expected_indexes=%s parsed=%r invalid=%r prompt=%r raw_content=%r",
self.request_url,
self.model,
expected_indexes,
parsed,
invalid,
user_prompt[:2000],
content[:2000],
)
raise AITutorError("AI translation returned an unexpected response.")
return {
index: _apply_translation_terms(source_by_index.get(index, ""), parsed[index], glossary)
@@ -348,6 +381,16 @@ class AITutor:
},
timeout=timeout,
)
except requests.Timeout as exc:
logger.warning(
"AI request timed out: endpoint=%s model=%s timeout_seconds=%s",
self.request_url,
self.model,
timeout,
)
raise AITutorError(
f"AI service request timed out after {timeout:g} seconds."
) from exc
except requests.RequestException as exc:
logger.exception(
"AI request failed: endpoint=%s model=%s",
@@ -454,11 +497,13 @@ def _parse_translation_payload(content: str, expected_indexes: list[int] | int)
else:
expected = list(expected_indexes)
text = _JSON_FENCE_RE.sub("", content.strip()).strip()
parse_errors: list[str] = []
for candidate in (text, *_json_snippets(text)):
try:
parsed = _translations_from_json(json.loads(candidate), expected)
except (TypeError, ValueError):
except (TypeError, ValueError) as exc:
parsed = None
parse_errors.append(str(exc))
if parsed:
return parsed
@@ -482,6 +527,14 @@ def _parse_translation_payload(content: str, expected_indexes: list[int] | int)
}
if len(by_order) == len(expected):
return by_order
logger.warning(
"Translation payload parser exhausted all formats: expected_indexes=%s "
"json_parse_errors=%r numbered_line_keys=%s content=%r",
expected,
parse_errors,
sorted(numbered),
text[:2000],
)
raise AITutorError("AI translation returned an unexpected response.")

View File

@@ -31,6 +31,7 @@ class Settings:
ai_api_key: str
ai_model: str
ai_timeout_seconds: float
ai_translation_timeout_seconds: float
pass_score: float
volcano_oss_access_key: str
volcano_oss_secret_key: str
@@ -71,6 +72,9 @@ class Settings:
ai_api_key=os.getenv("AI_API_KEY", "").strip(),
ai_model=os.getenv("AI_MODEL", "gpt-4o-mini"),
ai_timeout_seconds=float(os.getenv("AI_TIMEOUT_SECONDS", "45")),
ai_translation_timeout_seconds=float(
os.getenv("AI_TRANSLATION_TIMEOUT_SECONDS", "120")
),
pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(),
volcano_oss_secret_key=os.getenv("VOLCANO_OSS_SECRET_KEY", "").strip(),

View File

@@ -117,6 +117,7 @@ def create_app(
api_key=service_settings.ai_api_key,
model=service_settings.ai_model,
timeout_seconds=service_settings.ai_timeout_seconds,
translation_timeout_seconds=service_settings.ai_translation_timeout_seconds,
)
oss = oss_uploader or VolcanoOSSUploader(service_settings)
processor = VideoProcessor(service_settings, video_repository, moss, oss)

View File

@@ -1,6 +1,7 @@
import pytest
import json
import logging
import re
import threading
@@ -98,7 +99,29 @@ def test_ai_tutor_translates_full_document(monkeypatch):
user_prompt = payload["messages"][1]["content"]
assert "[0] Hello." in user_prompt
assert "[1] Nice to meet you." in user_prompt
assert timeout == 45.0
assert timeout == 120.0
def test_ai_tutor_translation_timeout_can_be_configured(monkeypatch):
request_payloads = []
def fake_post(endpoint, headers, json, timeout):
request_payloads.append((json, timeout))
return _translation_response_from_prompt(json)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor(
"https://ai.test/v1",
"secret",
"test-model",
1,
translation_timeout_seconds=180,
)
translated = tutor.translate_sentences([(0, "Hello.")])
assert translated == {0: "中文0"}
assert request_payloads[0][1] == 180
def _translation_response_from_prompt(payload):
@@ -248,3 +271,43 @@ def test_ai_tutor_retries_when_translation_stays_english(monkeypatch):
assert translated == {0: "你好。"}
assert calls["count"] >= 2
def test_ai_tutor_logs_raw_response_when_translation_json_is_invalid(monkeypatch, caplog):
def fake_post(endpoint, headers, json, timeout):
return FakeResponse(
body={"choices": [{"message": {"content": "definitely not translation json"}}]}
)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
with caplog.at_level(logging.WARNING, logger="sentence_api.ai_tutor"):
with pytest.raises(AITutorError):
tutor.translate_sentences([(0, "Hello.")])
assert any(
"Translation response parse failed" in record.getMessage()
and "definitely not translation json" in record.getMessage()
for record in caplog.records
)
def test_ai_tutor_logs_invalid_translation_before_repair(monkeypatch, caplog):
def fake_post(endpoint, headers, json, timeout):
return FakeResponse(
body={"choices": [{"message": {"content": '{"0":"Still English."}'}}]}
)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
with caplog.at_level(logging.WARNING, logger="sentence_api.ai_tutor"):
with pytest.raises(AITutorError):
tutor.translate_sentences([(0, "Hello.")])
assert any(
"Translation response failed validation" in record.getMessage()
and "Still English." in record.getMessage()
for record in caplog.records
)