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 { enum class Module {
COURSE, COURSE,
TRAIN, TRAIN,
READING,
TEST, TEST,
MINE, MINE,
} }

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import android.widget.LinearLayout
import cn.learningpad.oraltrainer.sample.ui.CourseScreen import cn.learningpad.oraltrainer.sample.ui.CourseScreen
import cn.learningpad.oraltrainer.sample.ui.MineScreen import cn.learningpad.oraltrainer.sample.ui.MineScreen
import cn.learningpad.oraltrainer.sample.ui.PlayerChrome 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.SampleWidgets
import cn.learningpad.oraltrainer.sample.ui.TestScreen import cn.learningpad.oraltrainer.sample.ui.TestScreen
import cn.learningpad.oraltrainer.sample.ui.TrainScreen import cn.learningpad.oraltrainer.sample.ui.TrainScreen
@@ -36,6 +37,13 @@ internal class SampleScreenFactory(
playerChrome = playerChrome, playerChrome = playerChrome,
) )
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions) 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) private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
fun createContentView(): View { fun createContentView(): View {
@@ -59,11 +67,24 @@ internal class SampleScreenFactory(
Module.COURSE -> addView(courseScreen.createSection()) Module.COURSE -> addView(courseScreen.createSection())
Module.MINE -> addView(mineScreen.createSection()) Module.MINE -> addView(mineScreen.createSection())
Module.TRAIN -> trainScreen.attachPortraitSections(this) Module.TRAIN -> trainScreen.attachPortraitSections(this)
Module.READING -> addView(readingScreen.createSection())
Module.TEST -> testScreen.attachSections(this) 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() { fun renderCatalog() {
if (screenState.activeModule == Module.COURSE) { if (screenState.activeModule == Module.COURSE) {
courseScreen.renderCatalog() 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 currentCourse: TrainingVideoSummary? = null
var continuousPlaybackEnabled = false var continuousPlaybackEnabled = false
var chineseSubtitlesEnabled = false var chineseSubtitlesEnabled = false
var readingTranslationVisible = true
var chineseTranslationLoading = false var chineseTranslationLoading = false
var chineseTranslationError = "" var chineseTranslationError = ""
var testSubtitleVisible = false 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.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.UserApi import cn.learningpad.oraltrainer.sdk.UserApi
internal class MineScreen( internal class MineScreen(
@@ -33,6 +34,9 @@ internal class MineScreen(
bindings.myContentList = LinearLayout(activity).apply { bindings.myContentList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
} }
bindings.favoriteSentenceList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
return ScrollView(activity).apply { return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(activity).apply { addView(LinearLayout(activity).apply {
@@ -52,6 +56,13 @@ internal class MineScreen(
44.dp, 44.dp,
).withMargins(0, 12.dp, 0, 20.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 { addView(TextView(activity).apply {
text = "我的测试结果" text = "我的测试结果"
textSize = 18f textSize = 18f
@@ -64,6 +75,7 @@ internal class MineScreen(
} }
fun refreshContent(userApi: UserApi, token: String) { fun refreshContent(userApi: UserApi, token: String) {
renderFavoriteSentences()
val list = bindings.myContentList ?: return val list = bindings.myContentList ?: return
list.removeAllViews() list.removeAllViews()
val course = screenState.currentCourse 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) { private fun deleteResult(userApi: UserApi, token: String, attemptId: String) {
userApi.deleteResult(token, attemptId) { result -> userApi.deleteResult(token, attemptId) { result ->
result.fold( 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) setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("课程", Module.COURSE, actions)) addView(moduleTab("课程", Module.COURSE, actions))
addView(moduleTab("训练", Module.TRAIN, actions)) addView(moduleTab("训练", Module.TRAIN, actions))
addView(moduleTab("阅读", Module.READING, actions))
addView(moduleTab("测试", Module.TEST, actions)) addView(moduleTab("测试", Module.TEST, actions))
addView(moduleTab("我的", Module.MINE, 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_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=45 AI_TIMEOUT_SECONDS=45
# 后台批量翻译可使用更长时间;实际翻译超时 = max(AI_TIMEOUT_SECONDS, AI_TRANSLATION_TIMEOUT_SECONDS)
AI_TRANSLATION_TIMEOUT_SECONDS=120
# 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。 # 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key 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_BEFORE = 8
_TRANSLATE_CONTEXT_AFTER = 4 _TRANSLATE_CONTEXT_AFTER = 4
_TRANSLATE_WORKERS = 4 _TRANSLATE_WORKERS = 4
_TRANSLATE_TIMEOUT_SECONDS = 45.0 _TRANSLATE_TIMEOUT_SECONDS = 120.0
_TRANSLATE_SPLIT_THRESHOLD = 4 _TRANSLATE_SPLIT_THRESHOLD = 4
@@ -35,6 +35,7 @@ class AITutor:
api_key: str api_key: str
model: str model: str
timeout_seconds: float timeout_seconds: float
translation_timeout_seconds: float = _TRANSLATE_TIMEOUT_SECONDS
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -301,13 +302,26 @@ class AITutor:
user_parts.append( user_parts.append(
"输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}" "输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}"
) )
user_prompt = "\n".join(user_parts)
content = self._complete( content = self._complete(
system_prompt, system_prompt,
"\n".join(user_parts), user_prompt,
temperature=0.2, 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) 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} source_by_index = {index: text for index, text in batch}
missing = [ missing = [
index index
@@ -315,6 +329,25 @@ class AITutor:
if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, "")) if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, ""))
] ]
if missing: 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.") raise AITutorError("AI translation returned an unexpected response.")
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)
@@ -348,6 +381,16 @@ class AITutor:
}, },
timeout=timeout, 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: except requests.RequestException as exc:
logger.exception( logger.exception(
"AI request failed: endpoint=%s model=%s", "AI request failed: endpoint=%s model=%s",
@@ -454,11 +497,13 @@ def _parse_translation_payload(content: str, expected_indexes: list[int] | int)
else: else:
expected = list(expected_indexes) expected = list(expected_indexes)
text = _JSON_FENCE_RE.sub("", content.strip()).strip() text = _JSON_FENCE_RE.sub("", content.strip()).strip()
parse_errors: list[str] = []
for candidate in (text, *_json_snippets(text)): for candidate in (text, *_json_snippets(text)):
try: try:
parsed = _translations_from_json(json.loads(candidate), expected) parsed = _translations_from_json(json.loads(candidate), expected)
except (TypeError, ValueError): except (TypeError, ValueError) as exc:
parsed = None parsed = None
parse_errors.append(str(exc))
if parsed: if parsed:
return parsed return parsed
@@ -482,6 +527,14 @@ def _parse_translation_payload(content: str, expected_indexes: list[int] | int)
} }
if len(by_order) == len(expected): if len(by_order) == len(expected):
return by_order 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.") raise AITutorError("AI translation returned an unexpected response.")

View File

@@ -31,6 +31,7 @@ class Settings:
ai_api_key: str ai_api_key: str
ai_model: str ai_model: str
ai_timeout_seconds: float ai_timeout_seconds: float
ai_translation_timeout_seconds: float
pass_score: float pass_score: float
volcano_oss_access_key: str volcano_oss_access_key: str
volcano_oss_secret_key: str volcano_oss_secret_key: str
@@ -71,6 +72,9 @@ class Settings:
ai_api_key=os.getenv("AI_API_KEY", "").strip(), ai_api_key=os.getenv("AI_API_KEY", "").strip(),
ai_model=os.getenv("AI_MODEL", "gpt-4o-mini"), ai_model=os.getenv("AI_MODEL", "gpt-4o-mini"),
ai_timeout_seconds=float(os.getenv("AI_TIMEOUT_SECONDS", "45")), 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")), pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(), volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(),
volcano_oss_secret_key=os.getenv("VOLCANO_OSS_SECRET_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, api_key=service_settings.ai_api_key,
model=service_settings.ai_model, model=service_settings.ai_model,
timeout_seconds=service_settings.ai_timeout_seconds, timeout_seconds=service_settings.ai_timeout_seconds,
translation_timeout_seconds=service_settings.ai_translation_timeout_seconds,
) )
oss = oss_uploader or VolcanoOSSUploader(service_settings) oss = oss_uploader or VolcanoOSSUploader(service_settings)
processor = VideoProcessor(service_settings, video_repository, moss, oss) processor = VideoProcessor(service_settings, video_repository, moss, oss)

View File

@@ -1,6 +1,7 @@
import pytest import pytest
import json import json
import logging
import re import re
import threading import threading
@@ -98,7 +99,29 @@ def test_ai_tutor_translates_full_document(monkeypatch):
user_prompt = payload["messages"][1]["content"] user_prompt = payload["messages"][1]["content"]
assert "[0] Hello." in user_prompt assert "[0] Hello." in user_prompt
assert "[1] Nice to meet you." 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): def _translation_response_from_prompt(payload):
@@ -248,3 +271,43 @@ def test_ai_tutor_retries_when_translation_stays_english(monkeypatch):
assert translated == {0: "你好。"} assert translated == {0: "你好。"}
assert calls["count"] >= 2 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
)