From ed8df744f99e8d18221f08fc58a2bfed809cdeb1 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 26 Oct 2026 17:31:08 +0800 Subject: [PATCH] add readming mode --- .../oraltrainer/sample/AppConfig.kt | 1 + .../oraltrainer/sample/MainActivity.kt | 59 ++++- .../oraltrainer/sample/SampleBindings.kt | 1 + .../oraltrainer/sample/SampleScreenActions.kt | 10 + .../oraltrainer/sample/SampleScreenFactory.kt | 21 ++ .../sample/SentenceFavoriteStore.kt | 55 +++++ .../oraltrainer/sample/TrainingScreenState.kt | 1 + .../oraltrainer/sample/ui/MineScreen.kt | 80 +++++++ .../oraltrainer/sample/ui/ReadingScreen.kt | 210 ++++++++++++++++++ .../oraltrainer/sample/ui/SampleWidgets.kt | 1 + sentence_api/.env.example | 2 + sentence_api/ai_tutor.py | 63 +++++- sentence_api/config.py | 4 + sentence_api/main.py | 1 + sentence_api/tests/test_ai_tutor.py | 65 +++++- 15 files changed, 565 insertions(+), 9 deletions(-) create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceFavoriteStore.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt index 50f83cc..35ac7f7 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt @@ -3,6 +3,7 @@ package cn.learningpad.oraltrainer.sample enum class Module { COURSE, TRAIN, + READING, TEST, MINE, } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index b5c19d5..fe53f19 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -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 { + 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 { diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt index 913f099..32bdeae 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt @@ -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 && diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt index 43d681e..a67d388 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt @@ -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 + fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean } internal interface SampleScreenActions : ModuleTabActions, CourseScreenActions, TrainScreenActions, + ReadingScreenActions, TestScreenActions, MineScreenActions diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt index 7fcccd2..7619adf 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt @@ -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() diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceFavoriteStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceFavoriteStore.kt new file mode 100644 index 0000000..7d61c22 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceFavoriteStore.kt @@ -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 { + return storageKey(userKey, mediaId)?.let(::readIndexes).orEmpty() + } + + private fun readIndexes(key: String): Set { + 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) + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt index 7112a30..6e4aa68 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt @@ -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 diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt index 4647ff9..1b7b062 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt @@ -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( diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt new file mode 100644 index 0000000..7d0b6dd --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt @@ -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() + 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 -> "暂无中文翻译" + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt index 578133a..67c941a 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt @@ -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)) } diff --git a/sentence_api/.env.example b/sentence_api/.env.example index 612dc25..a11a3f6 100644 --- a/sentence_api/.env.example +++ b/sentence_api/.env.example @@ -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 diff --git a/sentence_api/ai_tutor.py b/sentence_api/ai_tutor.py index 44125cf..0b7b280 100644 --- a/sentence_api/ai_tutor.py +++ b/sentence_api/ai_tutor.py @@ -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.") diff --git a/sentence_api/config.py b/sentence_api/config.py index 73ffc65..b77fe7d 100644 --- a/sentence_api/config.py +++ b/sentence_api/config.py @@ -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(), diff --git a/sentence_api/main.py b/sentence_api/main.py index 42c476d..fa174eb 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -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) diff --git a/sentence_api/tests/test_ai_tutor.py b/sentence_api/tests/test_ai_tutor.py index dc43974..d77014b 100644 --- a/sentence_api/tests/test_ai_tutor.py +++ b/sentence_api/tests/test_ai_tutor.py @@ -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 + )