add readming mode
This commit is contained in:
@@ -3,6 +3,7 @@ package cn.learningpad.oraltrainer.sample
|
|||||||
enum class Module {
|
enum class Module {
|
||||||
COURSE,
|
COURSE,
|
||||||
TRAIN,
|
TRAIN,
|
||||||
|
READING,
|
||||||
TEST,
|
TEST,
|
||||||
MINE,
|
MINE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 -> "暂无中文翻译"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
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}
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user