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

@@ -3,6 +3,7 @@ package cn.learningpad.oraltrainer.sample
enum class Module {
COURSE,
TRAIN,
READING,
TEST,
MINE,
}

View File

@@ -41,6 +41,7 @@ class MainActivity : Activity() {
private lateinit var testSession: TestSessionController
private lateinit var explanationOverlay: SentenceExplanationOverlay
private lateinit var translationApi: SubtitleTranslationApi
private lateinit var sentenceFavoriteStore: SentenceFavoriteStore
private var translationRequest: CancellableTranslationRequest? = null
private var progressBarDragging = false
@@ -80,6 +81,7 @@ class MainActivity : Activity() {
translationApi = SubtitleTranslationApi(this, SERVER_BASE_URL) {
authSessionStore.session?.token.orEmpty()
}
sentenceFavoriteStore = SentenceFavoriteStore(this)
screenFactory = SampleScreenFactory(
activity = this,
@@ -264,7 +266,17 @@ class MainActivity : Activity() {
}
private fun refreshCurrentUi() {
if (screenState.activeModule != Module.TRAIN && screenState.activeModule != Module.TEST) {
if (screenState.activeModule != Module.TRAIN &&
screenState.activeModule != Module.TEST &&
screenState.activeModule != Module.READING
) {
screenFactory.setCatalogStatus(screenState.catalogStatus)
return
}
if (screenState.activeModule == Module.READING) {
screenState.currentSentenceCount = controller.currentTrainingItem()?.sentences?.size ?: 0
screenFactory.refreshReadingSentences()
ensureChineseTranslations()
screenFactory.setCatalogStatus(screenState.catalogStatus)
return
}
@@ -284,11 +296,14 @@ class MainActivity : Activity() {
}
override fun onMediaChanged(item: TrainingMediaItem?) {
screenState.currentSentenceCount = item?.sentences?.size ?: 0
if (bindings.hasPlayerChrome) {
bindings.lessonTitleText.text = item?.title ?: "未选择课程"
screenState.currentSentenceCount = item?.sentences?.size ?: 0
}
ensureChineseTranslations()
if (screenState.activeModule == Module.READING) {
screenFactory.refreshReadingSentences()
}
testSession.ensureHistoryForCurrentCourse()
if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi()
@@ -398,10 +413,19 @@ class MainActivity : Activity() {
screenState.courseBoundaries[current.id] = cached.copy(sentences = updated)
}
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
}
private fun favoriteUserKey(): String =
SentenceFavoriteStore.userKey(authSessionStore.session?.token.orEmpty())
private fun ensureChineseTranslations() {
if (screenState.activeModule != Module.TRAIN || !screenState.chineseSubtitlesEnabled) {
val translationEnabled = when (screenState.activeModule) {
Module.TRAIN -> screenState.chineseSubtitlesEnabled
Module.READING -> screenState.readingTranslationVisible
else -> false
}
if (!translationEnabled) {
return
}
val item = controller.currentTrainingItem() ?: return
@@ -411,12 +435,14 @@ class MainActivity : Activity() {
screenState.chineseTranslationLoading = false
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
return
}
val generation = screenState.beginTranslationRequest()
screenState.chineseTranslationLoading = true
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
translationRequest?.cancel()
val expectedIndexes = sentences.mapNotNull { sentence ->
sentence.index.takeIf { !sentence.text.isNullOrBlank() }
@@ -505,6 +531,27 @@ class MainActivity : Activity() {
}
}
override fun onReadingTranslationVisible(visible: Boolean) {
screenState.readingTranslationVisible = visible
if (!visible) {
translationRequest?.cancel()
screenState.chineseTranslationLoading = false
} else {
ensureChineseTranslations()
}
screenFactory.refreshReadingTranslations()
}
override fun isSentenceFavorite(sentence: SentenceBoundary): Boolean {
val item = controller.currentTrainingItem() ?: return false
return sentence.index in sentenceFavoriteStore.indexes(favoriteUserKey(), item.id)
}
override fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean {
val item = controller.currentTrainingItem() ?: return false
return sentenceFavoriteStore.toggle(favoriteUserKey(), item.id, sentence.index)
}
override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return
@@ -558,6 +605,12 @@ class MainActivity : Activity() {
override fun nickname(): String =
authSessionStore.session?.user?.nickname.orEmpty()
override fun favoriteSentences(): List<SentenceBoundary> {
val item = controller.currentTrainingItem() ?: return emptyList()
val indexes = sentenceFavoriteStore.indexes(favoriteUserKey(), item.id)
return item.sentences.filter { it.index in indexes }
}
}
private fun catalogHost() = object : CourseCatalogController.Host {

View File

@@ -31,6 +31,7 @@ internal class SampleBindings {
var dubbingStatusText: TextView? = null
var explainSentenceButton: ImageButton? = null
var myContentList: LinearLayout? = null
var favoriteSentenceList: LinearLayout? = null
val hasPlayerChrome: Boolean
get() = ::lessonTitleText.isInitialized &&

View File

@@ -1,5 +1,6 @@
package cn.learningpad.oraltrainer.sample
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
internal interface ModuleTabActions {
@@ -24,6 +25,12 @@ internal interface TrainScreenActions :
fun onChineseSubtitlesChanged(enabled: Boolean)
}
internal interface ReadingScreenActions {
fun onReadingTranslationVisible(visible: Boolean)
fun isSentenceFavorite(sentence: SentenceBoundary): Boolean
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
}
internal interface TestScreenActions :
SentenceExplainActions {
fun onSeekProgressPreview(progress: Int)
@@ -42,11 +49,14 @@ internal interface TestScreenActions :
internal interface MineScreenActions {
fun onLogout()
fun nickname(): String
fun favoriteSentences(): List<SentenceBoundary>
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
}
internal interface SampleScreenActions :
ModuleTabActions,
CourseScreenActions,
TrainScreenActions,
ReadingScreenActions,
TestScreenActions,
MineScreenActions

View File

@@ -7,6 +7,7 @@ import android.widget.LinearLayout
import cn.learningpad.oraltrainer.sample.ui.CourseScreen
import cn.learningpad.oraltrainer.sample.ui.MineScreen
import cn.learningpad.oraltrainer.sample.ui.PlayerChrome
import cn.learningpad.oraltrainer.sample.ui.ReadingScreen
import cn.learningpad.oraltrainer.sample.ui.SampleWidgets
import cn.learningpad.oraltrainer.sample.ui.TestScreen
import cn.learningpad.oraltrainer.sample.ui.TrainScreen
@@ -36,6 +37,13 @@ internal class SampleScreenFactory(
playerChrome = playerChrome,
)
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions)
private val readingScreen = ReadingScreen(
activity = activity,
screenState = screenState,
controller = controller,
widgets = widgets,
actions = actions,
)
private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
fun createContentView(): View {
@@ -59,11 +67,24 @@ internal class SampleScreenFactory(
Module.COURSE -> addView(courseScreen.createSection())
Module.MINE -> addView(mineScreen.createSection())
Module.TRAIN -> trainScreen.attachPortraitSections(this)
Module.READING -> addView(readingScreen.createSection())
Module.TEST -> testScreen.attachSections(this)
}
}
}
fun refreshReadingSentences() {
if (screenState.activeModule == Module.READING) {
readingScreen.renderSentences()
}
}
fun refreshReadingTranslations() {
if (screenState.activeModule == Module.READING) {
readingScreen.refreshTranslations()
}
}
fun renderCatalog() {
if (screenState.activeModule == Module.COURSE) {
courseScreen.renderCatalog()

View File

@@ -0,0 +1,55 @@
package cn.learningpad.oraltrainer.sample
import android.content.Context
import java.security.MessageDigest
/** Persists reading-mode favorites locally, scoped by signed-in user and video. */
internal class SentenceFavoriteStore(context: Context) {
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
fun toggle(userKey: String, mediaId: String, sentenceIndex: Int): Boolean {
val key = storageKey(userKey, mediaId) ?: return false
val favorites = readIndexes(key)
val isFavorite = sentenceIndex in favorites
val updated = if (isFavorite) {
favorites - sentenceIndex
} else {
favorites + sentenceIndex
}
preferences.edit()
.putString(key, updated.sorted().joinToString(SEPARATOR))
.apply()
return !isFavorite
}
fun indexes(userKey: String, mediaId: String): Set<Int> {
return storageKey(userKey, mediaId)?.let(::readIndexes).orEmpty()
}
private fun readIndexes(key: String): Set<Int> {
return preferences.getString(key, null).orEmpty()
.split(SEPARATOR)
.mapNotNull { it.trim().toIntOrNull() }
.toSet()
}
private fun storageKey(userKey: String, mediaId: String): String? {
val normalizedUser = userKey.trim()
val normalizedMedia = mediaId.trim()
if (normalizedUser.isEmpty() || normalizedMedia.isEmpty()) return null
return "$KEY_PREFIX$normalizedUser:$normalizedMedia"
}
companion object {
private const val PREFERENCES_NAME = "sentence_reading_favorites"
private const val KEY_PREFIX = "indexes"
private const val SEPARATOR = ","
fun userKey(token: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(token.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }.take(24)
}
}
}

View File

@@ -15,6 +15,7 @@ internal class TrainingScreenState {
var currentCourse: TrainingVideoSummary? = null
var continuousPlaybackEnabled = false
var chineseSubtitlesEnabled = false
var readingTranslationVisible = true
var chineseTranslationLoading = false
var chineseTranslationError = ""
var testSubtitleVisible = false

View File

@@ -16,6 +16,7 @@ import cn.learningpad.oraltrainer.sample.SampleBindings
import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.UserApi
internal class MineScreen(
@@ -33,6 +34,9 @@ internal class MineScreen(
bindings.myContentList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
bindings.favoriteSentenceList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(activity).apply {
@@ -52,6 +56,13 @@ internal class MineScreen(
44.dp,
).withMargins(0, 12.dp, 0, 20.dp)
})
addView(TextView(activity).apply {
text = "本视频收藏句子"
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(bindings.favoriteSentenceList)
addView(TextView(activity).apply {
text = "我的测试结果"
textSize = 18f
@@ -64,6 +75,7 @@ internal class MineScreen(
}
fun refreshContent(userApi: UserApi, token: String) {
renderFavoriteSentences()
val list = bindings.myContentList ?: return
list.removeAllViews()
val course = screenState.currentCourse
@@ -125,6 +137,74 @@ internal class MineScreen(
}
}
private fun renderFavoriteSentences() {
val list = bindings.favoriteSentenceList ?: return
list.removeAllViews()
if (screenState.currentCourse == null) {
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
return
}
val favorites = actions.favoriteSentences()
if (favorites.isEmpty()) {
list.addView(widgets.infoCard("当前课程还没有收藏句子"))
return
}
favorites.forEach { sentence ->
list.addView(favoriteCard(sentence) {
if (!actions.toggleSentenceFavorite(sentence)) {
renderFavoriteSentences()
}
})
}
}
private fun favoriteCard(
sentence: SentenceBoundary,
onRemove: () -> Unit,
): View {
val foreign = sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"
val translation = sentence.zhText?.takeIf { it.isNotBlank() }
val cardText = buildString {
append("第 ${sentence.index + 1} 句")
append("\n$foreign")
translation?.let { append("\n$it") }
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.selected, 8f)
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 8.dp)
addView(TextView(activity).apply {
text = cardText
setTextColor(SampleTheme.textDark)
textSize = 13f
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Button(activity).apply {
text = "移除"
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(10.dp, 0, 10.dp, 0)
background = rounded(SampleTheme.danger, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(8.dp, 0, 0, 0)
setOnClickListener { onRemove() }
})
}
}
private fun deleteResult(userApi: UserApi, token: String, attemptId: String) {
userApi.deleteResult(token, attemptId) { result ->
result.fold(

View File

@@ -0,0 +1,210 @@
package cn.learningpad.oraltrainer.sample.ui
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import cn.learningpad.oraltrainer.sample.ReadingScreenActions
import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
/** Scrollable full-text reading view for the selected course. */
internal class ReadingScreen(
private val activity: Activity,
private val screenState: TrainingScreenState,
private val controller: OralTrainerController,
private val widgets: SampleWidgets,
private val actions: ReadingScreenActions,
) : UiStyled {
override val uiContext get() = activity
private data class SentenceRow(
val translation: TextView,
val favoriteButton: Button,
)
private val sentenceRows = mutableMapOf<Int, SentenceRow>()
private var sentenceList: LinearLayout? = null
private var titleText: TextView? = null
fun createSection(): View {
val content = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
}
val list = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
sentenceList = list
val title = TextView(activity).apply {
setTextColor(Color.WHITE)
textSize = 24f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 0, 0, 6.dp)
}
titleText = title
content.addView(title)
content.addView(createTranslationToggle())
content.addView(list)
renderSentences()
return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
addView(content)
}
}
fun renderSentences() {
val list = sentenceList ?: return
val item = controller.currentTrainingItem()
titleText?.text = screenState.currentCourse?.title.orEmpty()
.ifBlank { item?.title ?: "阅读模式" }
sentenceRows.clear()
list.removeAllViews()
if (item == null || item.sentences.isEmpty()) {
list.addView(widgets.infoCard("当前课程还没有可阅读的句子文本"))
return
}
val currentTitle = titleText?.text?.toString().orEmpty()
titleText?.text = "$currentTitle · ${item.sentences.size} 句"
item.sentences.forEach { sentence ->
list.addView(sentenceRow(sentence))
}
}
fun refreshTranslations() {
val item = controller.currentTrainingItem() ?: return
item.sentences.forEach { sentence ->
val row = sentenceRows[sentence.index] ?: return@forEach
row.translation.visibility = if (screenState.readingTranslationVisible) {
View.VISIBLE
} else {
View.GONE
}
row.translation.text = translationText(sentence)
}
}
fun refreshFavorites() {
val item = controller.currentTrainingItem() ?: return
item.sentences.forEach { sentence ->
sentenceRows[sentence.index]?.favoriteButton?.apply {
val isFavorite = actions.isSentenceFavorite(sentence)
setText(if (isFavorite) "已收藏" else "收藏")
background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
}
private fun createTranslationToggle(): View =
LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 4.dp, 0, 10.dp)
addView(TextView(activity).apply {
text = "中文翻译"
setTextColor(Color.WHITE)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Switch(activity).apply {
isChecked = screenState.readingTranslationVisible
buttonTintList = android.content.res.ColorStateList.valueOf(SampleTheme.accent)
setOnCheckedChangeListener { _, checked ->
actions.onReadingTranslationVisible(checked)
}
})
}
private fun sentenceRow(sentence: SentenceBoundary): View {
val translation = TextView(activity).apply {
text = translationText(sentence)
setTextColor(SampleTheme.textSubtle)
textSize = 14f
setPadding(0, 6.dp, 0, 0)
visibility = if (screenState.readingTranslationVisible) View.VISIBLE else View.GONE
}
val favoriteButton = Button(activity).apply {
setText("收藏")
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(12.dp, 0, 12.dp, 0)
background = rounded(SampleTheme.button, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(10.dp, 0, 0, 0)
setOnClickListener {
val isFavorite = actions.toggleSentenceFavorite(sentence)
setText(if (isFavorite) "已收藏" else "收藏")
background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
val isFavorite = actions.isSentenceFavorite(sentence)
favoriteButton.setText(if (isFavorite) "已收藏" else "收藏")
favoriteButton.background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
val textColumn = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(activity).apply {
text = "${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
setTextColor(Color.WHITE)
textSize = 16f
})
addView(translation)
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 10.dp)
addView(textColumn)
addView(favoriteButton)
}
}
private fun translationText(sentence: SentenceBoundary): String = when {
!sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty()
screenState.chineseTranslationLoading -> "正在翻译中文…"
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
else -> "暂无中文翻译"
}
}

View File

@@ -30,6 +30,7 @@ internal class SampleWidgets(
setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("课程", Module.COURSE, actions))
addView(moduleTab("训练", Module.TRAIN, actions))
addView(moduleTab("阅读", Module.READING, actions))
addView(moduleTab("测试", Module.TEST, actions))
addView(moduleTab("我的", Module.MINE, actions))
}

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),
)
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
)