fff
This commit is contained in:
@@ -62,6 +62,7 @@ POST /api/v1/admin/videos/{sha256}/process
|
|||||||
POST /api/v1/admin/videos/{sha256}/repair-gaps
|
POST /api/v1/admin/videos/{sha256}/repair-gaps
|
||||||
GET /api/v1/admin/videos/{sha256}/subtitle-translation
|
GET /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||||
POST /api/v1/admin/videos/{sha256}/subtitle-translation
|
POST /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||||
|
POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair
|
||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
|
||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import requests
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
|
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
|
||||||
|
_CJK_RE = re.compile("[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
||||||
_NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$")
|
_NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$")
|
||||||
_TRANSLATE_BATCH_SENTENCES = 12
|
_TRANSLATE_BATCH_SENTENCES = 12
|
||||||
_TRANSLATE_BATCH_CHARS = 2_200
|
_TRANSLATE_BATCH_CHARS = 2_200
|
||||||
@@ -95,10 +96,13 @@ class AITutor:
|
|||||||
raise AITutorError("No sentence text was provided for translation.")
|
raise AITutorError("No sentence text was provided for translation.")
|
||||||
|
|
||||||
glossary = _normalize_translation_terms(terms)
|
glossary = _normalize_translation_terms(terms)
|
||||||
|
source_by_index = {index: text for index, text in ordered}
|
||||||
translations = {
|
translations = {
|
||||||
index: text.strip()
|
index: text.strip()
|
||||||
for index, text in (existing or {}).items()
|
for index, text in (existing or {}).items()
|
||||||
if isinstance(text, str) and text.strip()
|
if isinstance(text, str)
|
||||||
|
and text.strip()
|
||||||
|
and not translation_needs_repair(source_by_index.get(index, ""), text)
|
||||||
}
|
}
|
||||||
pending = [(index, text) for index, text in ordered if index not in translations]
|
pending = [(index, text) for index, text in ordered if index not in translations]
|
||||||
if not pending:
|
if not pending:
|
||||||
@@ -304,10 +308,14 @@ class AITutor:
|
|||||||
timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS),
|
timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS),
|
||||||
)
|
)
|
||||||
parsed = _parse_translation_payload(content, expected_indexes)
|
parsed = _parse_translation_payload(content, expected_indexes)
|
||||||
missing = [index for index in expected_indexes if not parsed.get(index, "").strip()]
|
source_by_index = {index: text for index, text in batch}
|
||||||
|
missing = [
|
||||||
|
index
|
||||||
|
for index in expected_indexes
|
||||||
|
if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, ""))
|
||||||
|
]
|
||||||
if missing:
|
if missing:
|
||||||
raise AITutorError("AI translation returned an unexpected response.")
|
raise AITutorError("AI translation returned an unexpected response.")
|
||||||
source_by_index = {index: text for index, text in batch}
|
|
||||||
return {
|
return {
|
||||||
index: _apply_translation_terms(source_by_index.get(index, ""), parsed[index], glossary)
|
index: _apply_translation_terms(source_by_index.get(index, ""), parsed[index], glossary)
|
||||||
for index in expected_indexes
|
for index in expected_indexes
|
||||||
@@ -377,6 +385,19 @@ class AITutor:
|
|||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def translation_needs_repair(source: str | None, zh_text: str | None) -> bool:
|
||||||
|
"""Return True when a sentence still needs a Chinese translation."""
|
||||||
|
source_text = (source or "").strip()
|
||||||
|
if not source_text:
|
||||||
|
return False
|
||||||
|
zh = (zh_text or "").strip()
|
||||||
|
if not zh:
|
||||||
|
return True
|
||||||
|
if _CJK_RE.search(zh):
|
||||||
|
return False
|
||||||
|
return _CJK_RE.search(source_text) is None
|
||||||
|
|
||||||
|
|
||||||
def _translation_batches(
|
def _translation_batches(
|
||||||
pending: list[tuple[int, str]],
|
pending: list[tuple[int, str]],
|
||||||
all_sentences: list[tuple[int, str]],
|
all_sentences: list[tuple[int, str]],
|
||||||
|
|||||||
@@ -753,6 +753,23 @@ def create_app(
|
|||||||
except AITutorError as exc:
|
except AITutorError as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@application.post(
|
||||||
|
"/api/v1/admin/videos/{video_hash}/subtitle-translation/repair",
|
||||||
|
response_model=AdminSubtitleTranslationResponse,
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
def repair_subtitle_translation(
|
||||||
|
video_hash: str = SHA256_PATH,
|
||||||
|
) -> AdminSubtitleTranslationResponse:
|
||||||
|
try:
|
||||||
|
return application.state.subtitle_translations.repair_missing(video_hash)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except AITutorError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
@application.put(
|
@application.put(
|
||||||
"/api/v1/admin/videos/{video_hash}/title",
|
"/api/v1/admin/videos/{video_hash}/title",
|
||||||
response_model=VideoSummary,
|
response_model=VideoSummary,
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ class SubtitleRetranslateRequest(BaseModel):
|
|||||||
|
|
||||||
class AdminSubtitleTranslationResponse(SubtitleTranslationResponse):
|
class AdminSubtitleTranslationResponse(SubtitleTranslationResponse):
|
||||||
terms: List[TranslationTerm] = Field(default_factory=list)
|
terms: List[TranslationTerm] = Field(default_factory=list)
|
||||||
|
missing_count: int = Field(default=0, ge=0)
|
||||||
|
attempted_count: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class SentenceBoundaryAdjust(BaseModel):
|
class SentenceBoundaryAdjust(BaseModel):
|
||||||
|
|||||||
@@ -1011,11 +1011,28 @@ class VideoRepository:
|
|||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def clear_sentence_translations(self, video_hash: str) -> None:
|
def clear_sentence_translations(
|
||||||
|
self,
|
||||||
|
video_hash: str,
|
||||||
|
indexes: Optional[Sequence[int]] = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_hash = normalize_video_hash(video_hash)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
if indexes is None:
|
||||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
connection.execute(
|
||||||
(normalize_video_hash(video_hash),),
|
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
||||||
|
(normalized_hash,),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
unique_indexes = list(dict.fromkeys(indexes))
|
||||||
|
if not unique_indexes:
|
||||||
|
return
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
UPDATE sentences SET zh_text = NULL
|
||||||
|
WHERE video_hash = ? AND sentence_index = ?
|
||||||
|
""",
|
||||||
|
[(normalized_hash, index) for index in unique_indexes],
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_sentence_translations(
|
def save_sentence_translations(
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); }
|
|||||||
.dialog-heading span { font-size: 12px; color: #68747d; }
|
.dialog-heading span { font-size: 12px; color: #68747d; }
|
||||||
.dialog-heading-actions { display: flex; align-items: center; gap: 8px; }
|
.dialog-heading-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; }
|
.gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; }
|
||||||
|
.missing-zh-row .zh-text { color: #a45d08; }
|
||||||
.sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; }
|
.sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; }
|
||||||
.sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; }
|
.sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; }
|
||||||
.sentence-scroll select { min-width: 90px; }
|
.sentence-scroll select { min-width: 90px; }
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
<textarea id="retranslate-terms" placeholder="Harimoto=哈瑞么托"></textarea>
|
<textarea id="retranslate-terms" placeholder="Harimoto=哈瑞么托"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="retranslate-actions">
|
<div class="retranslate-actions">
|
||||||
|
<button id="repair-translations" type="button">只补译缺失</button>
|
||||||
<button class="primary" type="submit">开始重新翻译</button>
|
<button class="primary" type="submit">开始重新翻译</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dialog-heading-actions">
|
<div class="dialog-heading-actions">
|
||||||
<button id="repair-gaps" type="button">修复空白</button>
|
<button id="repair-gaps" type="button">修复空白</button>
|
||||||
|
<button id="repair-missing-zh" type="button">补译缺失</button>
|
||||||
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
|
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +139,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=admin-repair-gaps-v1" defer></script>
|
<script src="/static/admin.js?v=admin-repair-zh-v1" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const GAP_REPAIR_MIN_MS = 8000;
|
const GAP_REPAIR_MIN_MS = 8000;
|
||||||
|
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
|
adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
|
||||||
@@ -27,11 +28,13 @@ const elements = {
|
|||||||
sentenceRows: document.querySelector("#sentence-rows"),
|
sentenceRows: document.querySelector("#sentence-rows"),
|
||||||
closeDialog: document.querySelector("#close-dialog"),
|
closeDialog: document.querySelector("#close-dialog"),
|
||||||
repairGaps: document.querySelector("#repair-gaps"),
|
repairGaps: document.querySelector("#repair-gaps"),
|
||||||
|
repairMissingZh: document.querySelector("#repair-missing-zh"),
|
||||||
retranslateDialog: document.querySelector("#retranslate-dialog"),
|
retranslateDialog: document.querySelector("#retranslate-dialog"),
|
||||||
retranslateTitle: document.querySelector("#retranslate-title"),
|
retranslateTitle: document.querySelector("#retranslate-title"),
|
||||||
retranslateMeta: document.querySelector("#retranslate-meta"),
|
retranslateMeta: document.querySelector("#retranslate-meta"),
|
||||||
retranslateForm: document.querySelector("#retranslate-form"),
|
retranslateForm: document.querySelector("#retranslate-form"),
|
||||||
retranslateTerms: document.querySelector("#retranslate-terms"),
|
retranslateTerms: document.querySelector("#retranslate-terms"),
|
||||||
|
repairTranslations: document.querySelector("#repair-translations"),
|
||||||
closeRetranslateDialog: document.querySelector("#close-retranslate-dialog"),
|
closeRetranslateDialog: document.querySelector("#close-retranslate-dialog"),
|
||||||
userRows: document.querySelector("#user-rows"),
|
userRows: document.querySelector("#user-rows"),
|
||||||
userCount: document.querySelector("#user-count"),
|
userCount: document.querySelector("#user-count"),
|
||||||
@@ -52,7 +55,13 @@ elements.closeDialog.addEventListener("click", () => elements.dialog.close());
|
|||||||
elements.repairGaps.addEventListener("click", () => {
|
elements.repairGaps.addEventListener("click", () => {
|
||||||
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
|
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
|
||||||
});
|
});
|
||||||
|
elements.repairMissingZh.addEventListener("click", () => {
|
||||||
|
if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true });
|
||||||
|
});
|
||||||
elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
|
elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
|
||||||
|
elements.repairTranslations.addEventListener("click", () => {
|
||||||
|
if (state.retranslateVideo) repairMissingTranslations(state.retranslateVideo, { closeRetranslate: true });
|
||||||
|
});
|
||||||
elements.retranslateForm.addEventListener("submit", submitRetranslate);
|
elements.retranslateForm.addEventListener("submit", submitRetranslate);
|
||||||
elements.uploadForm.addEventListener("submit", uploadVideo);
|
elements.uploadForm.addEventListener("submit", uploadVideo);
|
||||||
elements.videoFile.addEventListener("change", () => {
|
elements.videoFile.addEventListener("change", () => {
|
||||||
@@ -295,9 +304,12 @@ async function openSentences(video) {
|
|||||||
const sentences = detail.boundaries?.sentences || [];
|
const sentences = detail.boundaries?.sentences || [];
|
||||||
const durationMs = detail.video.duration_ms || 0;
|
const durationMs = detail.video.duration_ms || 0;
|
||||||
const gaps = collectSentenceGaps(sentences, durationMs);
|
const gaps = collectSentenceGaps(sentences, durationMs);
|
||||||
|
const missingZh = sentences.filter(translationNeedsRepair).length;
|
||||||
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
|
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
|
||||||
elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel} / ${detail.video.video_hash}`;
|
const zhLabel = missingZh ? ` / ${missingZh} 句未译` : "";
|
||||||
|
elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`;
|
||||||
elements.repairGaps.disabled = gaps.length === 0;
|
elements.repairGaps.disabled = gaps.length === 0;
|
||||||
|
elements.repairMissingZh.disabled = missingZh === 0;
|
||||||
elements.sentenceRows.replaceChildren();
|
elements.sentenceRows.replaceChildren();
|
||||||
let gapIndex = 0;
|
let gapIndex = 0;
|
||||||
for (const sentence of sentences) {
|
for (const sentence of sentences) {
|
||||||
@@ -345,8 +357,18 @@ function gapRow(gap) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function translationNeedsRepair(sentence) {
|
||||||
|
const source = (sentence.text || "").trim();
|
||||||
|
const zh = (sentence.zh_text || "").trim();
|
||||||
|
if (!source) return false;
|
||||||
|
if (!zh) return true;
|
||||||
|
if (CJK_RE.test(zh)) return false;
|
||||||
|
return !CJK_RE.test(source);
|
||||||
|
}
|
||||||
|
|
||||||
function sentenceRow(video, sentence, totalCount) {
|
function sentenceRow(video, sentence, totalCount) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
|
if (translationNeedsRepair(sentence)) row.className = "missing-zh-row";
|
||||||
const textArea = document.createElement("textarea");
|
const textArea = document.createElement("textarea");
|
||||||
textArea.value = sentence.text || "";
|
textArea.value = sentence.text || "";
|
||||||
const language = document.createElement("select");
|
const language = document.createElement("select");
|
||||||
@@ -360,7 +382,9 @@ function sentenceRow(video, sentence, totalCount) {
|
|||||||
const textContainer = document.createElement("td");
|
const textContainer = document.createElement("td");
|
||||||
const zhNote = document.createElement("div");
|
const zhNote = document.createElement("div");
|
||||||
zhNote.className = "zh-text";
|
zhNote.className = "zh-text";
|
||||||
zhNote.textContent = sentence.zh_text || "暂无中文翻译";
|
zhNote.textContent = translationNeedsRepair(sentence)
|
||||||
|
? (sentence.zh_text ? `仍是英文:${sentence.zh_text}` : "暂无中文翻译")
|
||||||
|
: sentence.zh_text;
|
||||||
textContainer.append(textArea, zhNote);
|
textContainer.append(textArea, zhNote);
|
||||||
const languageContainer = document.createElement("td");
|
const languageContainer = document.createElement("td");
|
||||||
languageContainer.append(language);
|
languageContainer.append(language);
|
||||||
@@ -479,8 +503,12 @@ async function openRetranslate(video) {
|
|||||||
try {
|
try {
|
||||||
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`);
|
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`);
|
||||||
elements.retranslateTerms.value = formatTranslationTerms(payload.terms || []);
|
elements.retranslateTerms.value = formatTranslationTerms(payload.terms || []);
|
||||||
const status = payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成");
|
const missing = payload.missing_count || 0;
|
||||||
|
const status = missing
|
||||||
|
? `${missing} 句未译`
|
||||||
|
: (payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成"));
|
||||||
elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`;
|
elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`;
|
||||||
|
elements.repairTranslations.disabled = missing === 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.serviceState.textContent = `加载翻译信息失败: ${error.message}`;
|
elements.serviceState.textContent = `加载翻译信息失败: ${error.message}`;
|
||||||
}
|
}
|
||||||
@@ -540,6 +568,39 @@ async function submitRetranslate(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function repairMissingTranslations(video, options = {}) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`将只补译“${video.title}”里缺失或仍是英文的中文,已有中文会保留。继续?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
const buttons = [elements.repairMissingZh, elements.repairTranslations].filter(Boolean);
|
||||||
|
buttons.forEach((button) => { button.disabled = true; });
|
||||||
|
elements.serviceState.textContent = "正在补译缺失的中文…";
|
||||||
|
try {
|
||||||
|
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation/repair`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const attempted = payload.attempted_count || 0;
|
||||||
|
if (!attempted) {
|
||||||
|
elements.serviceState.textContent = "没有需要补译的句子";
|
||||||
|
} else if (payload.complete) {
|
||||||
|
elements.serviceState.textContent = `补译完成:处理了 ${attempted} 句`;
|
||||||
|
} else {
|
||||||
|
const remaining = payload.missing_count ? `,还剩 ${payload.missing_count} 句` : "";
|
||||||
|
elements.serviceState.textContent = `已开始补译 ${attempted} 句${remaining},请稍后刷新`;
|
||||||
|
}
|
||||||
|
if (options.closeRetranslate) elements.retranslateDialog.close();
|
||||||
|
await loadVideos();
|
||||||
|
if (options.reloadSentences) await openSentences(video);
|
||||||
|
} catch (error) {
|
||||||
|
elements.serviceState.textContent = `补译失败: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
if (!options.reloadSentences) {
|
||||||
|
buttons.forEach((button) => { button.disabled = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reprocess(videoHash) {
|
async function reprocess(videoHash) {
|
||||||
try {
|
try {
|
||||||
await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" });
|
await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" });
|
||||||
|
|||||||
@@ -212,3 +212,39 @@ def test_parse_translation_payload_accepts_fenced_json_and_numbered_lines():
|
|||||||
12: "甲",
|
12: "甲",
|
||||||
13: "乙",
|
13: "乙",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_retranslates_english_existing_cache(monkeypatch):
|
||||||
|
def fake_post(endpoint, headers, 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)
|
||||||
|
translated = tutor.translate_sentences(
|
||||||
|
[(0, "Hello."), (1, "Thanks.")],
|
||||||
|
existing={0: "Hello.", 1: "谢谢。"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert translated[0] == "中文0"
|
||||||
|
assert translated[1] == "谢谢。"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_retries_when_translation_stays_english(monkeypatch):
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def fake_post(endpoint, headers, json, timeout):
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 1:
|
||||||
|
return FakeResponse(
|
||||||
|
body={"choices": [{"message": {"content": '{"0":"Hello."}'}}]}
|
||||||
|
)
|
||||||
|
return FakeResponse(
|
||||||
|
body={"choices": [{"message": {"content": '{"0":"你好。"}'}}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
|
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
|
||||||
|
translated = tutor.translate_sentences([(0, "Hello.")])
|
||||||
|
|
||||||
|
assert translated == {0: "你好。"}
|
||||||
|
assert calls["count"] >= 2
|
||||||
|
|||||||
@@ -1165,3 +1165,91 @@ def test_admin_gap_repair_requires_transcriber(tmp_path):
|
|||||||
|
|
||||||
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_repair_missing_and_english_translations(tmp_path, monkeypatch):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
repository = client.app.state.video_repository
|
||||||
|
_ready_video(repository)
|
||||||
|
repository.save_sentence_translations(
|
||||||
|
VIDEO_HASH,
|
||||||
|
{0: "你好。", 1: "Nice to meet you."},
|
||||||
|
)
|
||||||
|
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
def translate_sentences(sentences, **kwargs):
|
||||||
|
requested.append([index for index, _text in sentences])
|
||||||
|
existing = dict(kwargs.get("existing") or {})
|
||||||
|
result = {}
|
||||||
|
for index, _text in sentences:
|
||||||
|
current = existing.get(index, "")
|
||||||
|
if current and any("\u4e00" <= char <= "\u9fff" for char in current):
|
||||||
|
result[index] = current
|
||||||
|
else:
|
||||||
|
result[index] = f"补译{index}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
class StubAITutor:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
stub = StubAITutor()
|
||||||
|
stub.translate_sentences = translate_sentences
|
||||||
|
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||||
|
client.app.state.subtitle_translations.ai_tutor = stub
|
||||||
|
client.app.state.subtitle_translations.post_wait_seconds = 0.2
|
||||||
|
|
||||||
|
snapshot = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation")
|
||||||
|
assert snapshot.status_code == 200
|
||||||
|
assert snapshot.json()["missing_count"] == 1
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation/repair"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("complete"):
|
||||||
|
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
|
||||||
|
payload = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation").json()
|
||||||
|
payload["attempted_count"] = response.json()["attempted_count"]
|
||||||
|
payload["complete"] = finished.json()["complete"]
|
||||||
|
|
||||||
|
assert payload["attempted_count"] == 1
|
||||||
|
assert payload["complete"] is True
|
||||||
|
assert payload["missing_count"] == 0
|
||||||
|
document = repository.get_document(VIDEO_HASH)
|
||||||
|
assert [sentence.zh_text for sentence in document.sentences] == ["你好。", "补译1"]
|
||||||
|
assert requested
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_translation_repair_keeps_complete_chinese(tmp_path, monkeypatch):
|
||||||
|
client = make_client(tmp_path)
|
||||||
|
repository = client.app.state.video_repository
|
||||||
|
_ready_video(repository)
|
||||||
|
repository.save_sentence_translations(
|
||||||
|
VIDEO_HASH,
|
||||||
|
{0: "你好。", 1: "见到你很高兴。"},
|
||||||
|
)
|
||||||
|
called = {"count": 0}
|
||||||
|
|
||||||
|
def translate_sentences(sentences, **kwargs):
|
||||||
|
called["count"] += 1
|
||||||
|
return {index: f"新{index}" for index, _text in sentences}
|
||||||
|
|
||||||
|
class StubAITutor:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
stub = StubAITutor()
|
||||||
|
stub.translate_sentences = translate_sentences
|
||||||
|
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||||
|
client.app.state.subtitle_translations.ai_tutor = stub
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation/repair"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["attempted_count"] == 0
|
||||||
|
assert response.json()["missing_count"] == 0
|
||||||
|
assert called["count"] == 0
|
||||||
|
document = repository.get_document(VIDEO_HASH)
|
||||||
|
assert [sentence.zh_text for sentence in document.sentences] == ["你好。", "见到你很高兴。"]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .ai_tutor import AITutor, AITutorError
|
from .ai_tutor import AITutor, AITutorError, translation_needs_repair
|
||||||
from .models import (
|
from .models import (
|
||||||
AdminSubtitleTranslationResponse,
|
AdminSubtitleTranslationResponse,
|
||||||
SentenceBoundaryDocument,
|
SentenceBoundaryDocument,
|
||||||
@@ -133,6 +133,23 @@ class SubtitleTranslationService:
|
|||||||
video_hash,
|
video_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def repair_missing(self, video_hash: str) -> AdminSubtitleTranslationResponse:
|
||||||
|
"""Re-translate missing or still-English sentences, keeping good Chinese."""
|
||||||
|
if self.repository.get_video(video_hash) is None:
|
||||||
|
raise KeyError(video_hash)
|
||||||
|
document = self.repository.get_document(video_hash)
|
||||||
|
if document is None:
|
||||||
|
raise KeyError(video_hash)
|
||||||
|
missing = missing_translation_indexes(document)
|
||||||
|
if missing:
|
||||||
|
self.invalidate(video_hash)
|
||||||
|
self.repository.clear_sentence_translations(video_hash, missing)
|
||||||
|
snapshot = self.ensure(video_hash)
|
||||||
|
else:
|
||||||
|
snapshot = self.snapshot(document)
|
||||||
|
result = self._with_terms(snapshot)
|
||||||
|
return result.model_copy(update={"attempted_count": len(missing)})
|
||||||
|
|
||||||
def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None:
|
def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None:
|
||||||
current = self.current(video_hash)
|
current = self.current(video_hash)
|
||||||
if current is None:
|
if current is None:
|
||||||
@@ -157,8 +174,12 @@ class SubtitleTranslationService:
|
|||||||
TranslationTerm(source=source, zh=zh)
|
TranslationTerm(source=source, zh=zh)
|
||||||
for source, zh in self.repository.get_translation_glossary(snapshot.video_hash)
|
for source, zh in self.repository.get_translation_glossary(snapshot.video_hash)
|
||||||
]
|
]
|
||||||
|
document = self.repository.get_document(snapshot.video_hash)
|
||||||
|
missing_count = 0 if document is None else len(missing_translation_indexes(document))
|
||||||
payload = snapshot.model_dump()
|
payload = snapshot.model_dump()
|
||||||
payload["terms"] = terms
|
payload["terms"] = terms
|
||||||
|
payload["missing_count"] = missing_count
|
||||||
|
payload["attempted_count"] = 0
|
||||||
return AdminSubtitleTranslationResponse(**payload)
|
return AdminSubtitleTranslationResponse(**payload)
|
||||||
|
|
||||||
def ensure_from_local(self, document: SentenceBoundaryDocument) -> SubtitleTranslationResponse:
|
def ensure_from_local(self, document: SentenceBoundaryDocument) -> SubtitleTranslationResponse:
|
||||||
@@ -275,3 +296,11 @@ class SubtitleTranslationService:
|
|||||||
with job.lock:
|
with job.lock:
|
||||||
job.running = False
|
job.running = False
|
||||||
job.done.set()
|
job.done.set()
|
||||||
|
|
||||||
|
|
||||||
|
def missing_translation_indexes(document: SentenceBoundaryDocument) -> list[int]:
|
||||||
|
return [
|
||||||
|
sentence.index
|
||||||
|
for sentence in document.sentences
|
||||||
|
if translation_needs_repair(sentence.text, sentence.zh_text)
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user