add explain module

This commit is contained in:
2026-09-06 07:38:56 +08:00
parent 7d843e8ff1
commit 701148e00e
20 changed files with 775 additions and 8 deletions

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.3" versionName = "0.1.4"
} }
compileOptions { compileOptions {

View File

@@ -42,6 +42,7 @@ class MainActivity : Activity() {
private lateinit var screenFactory: SampleScreenFactory private lateinit var screenFactory: SampleScreenFactory
private lateinit var catalogController: CourseCatalogController private lateinit var catalogController: CourseCatalogController
private lateinit var testSession: TestSessionController private lateinit var testSession: TestSessionController
private lateinit var explanationOverlay: SentenceExplanationOverlay
private var progressBarDragging = false private var progressBarDragging = false
@@ -66,6 +67,12 @@ class MainActivity : Activity() {
sdk = sdkComponents.sdk sdk = sdkComponents.sdk
controller = sdkComponents.controller controller = sdkComponents.controller
authCoordinator = AuthCoordinator(this, sdk.userApi, ::saveAuthSession) authCoordinator = AuthCoordinator(this, sdk.userApi, ::saveAuthSession)
explanationOverlay = SentenceExplanationOverlay(
this,
SentenceExplanationApi(this, SERVER_BASE_URL) {
authSessionStore.session?.token.orEmpty()
},
)
screenFactory = SampleScreenFactory( screenFactory = SampleScreenFactory(
activity = this, activity = this,
@@ -217,6 +224,7 @@ class MainActivity : Activity() {
authCoordinator.reset() authCoordinator.reset()
testSession.release() testSession.release()
controller.release() controller.release()
explanationOverlay.release()
super.onDestroy() super.onDestroy()
} }
@@ -365,6 +373,7 @@ class MainActivity : Activity() {
if (sentence == null) { if (sentence == null) {
bindings.sentenceMetaText.text = "暂无句子边界" bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = bindings.lessonTitleText.text bindings.sentenceText.text = bindings.lessonTitleText.text
bindings.explainSentenceButton?.isEnabled = false
return return
} }
val countText = if (screenState.currentSentenceCount > 0) { val countText = if (screenState.currentSentenceCount > 0) {
@@ -375,6 +384,7 @@ class MainActivity : Activity() {
bindings.sentenceMetaText.text = bindings.sentenceMetaText.text =
"第 ${sentence.index + 1}$countText 句 ${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}" "第 ${sentence.index + 1}$countText 句 ${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}"
bindings.sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子" bindings.sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
bindings.explainSentenceButton?.isEnabled = !sentence.text.isNullOrBlank()
if (screenState.activeModule == Module.TEST) { if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi() testSession.refreshTestUi()
} }
@@ -417,6 +427,16 @@ class MainActivity : Activity() {
} }
} }
override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return
controller.pause()
explanationOverlay.show(
sentence,
controller.currentTrainingItem()?.title.orEmpty(),
)
}
override fun onTestSubtitleChanged(visible: Boolean) { override fun onTestSubtitleChanged(visible: Boolean) {
testSession.onTestSubtitleChanged(visible) testSession.onTestSubtitleChanged(visible)
} }

View File

@@ -1,6 +1,7 @@
package cn.learningpad.oraltrainer.sample package cn.learningpad.oraltrainer.sample
import android.widget.Button import android.widget.Button
import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
@@ -26,6 +27,7 @@ internal class SampleBindings {
lateinit var mergeDubButton: Button lateinit var mergeDubButton: Button
lateinit var shareDubButton: Button lateinit var shareDubButton: Button
var dubbingStatusText: TextView? = null var dubbingStatusText: TextView? = null
var explainSentenceButton: ImageButton? = null
var myContentList: LinearLayout? = null var myContentList: LinearLayout? = null
val hasPlayerChrome: Boolean val hasPlayerChrome: Boolean

View File

@@ -10,7 +10,12 @@ internal interface CourseScreenActions {
fun onSelectCourse(video: TrainingVideoSummary) fun onSelectCourse(video: TrainingVideoSummary)
} }
internal interface TrainScreenActions { internal interface SentenceExplainActions {
fun onExplainCurrentSentence()
}
internal interface TrainScreenActions :
SentenceExplainActions {
fun onImportLocalVideo() fun onImportLocalVideo()
fun onRefreshCatalog() fun onRefreshCatalog()
fun onSeekProgressPreview(progress: Int) fun onSeekProgressPreview(progress: Int)
@@ -20,7 +25,8 @@ internal interface TrainScreenActions {
fun onTrainCatalogVideoClick(video: TrainingVideoSummary) fun onTrainCatalogVideoClick(video: TrainingVideoSummary)
} }
internal interface TestScreenActions { internal interface TestScreenActions :
SentenceExplainActions {
fun onSeekProgressPreview(progress: Int) fun onSeekProgressPreview(progress: Int)
fun onSeekStart() fun onSeekStart()
fun onSeekStop(progress: Int) fun onSeekStop(progress: Int)

View File

@@ -0,0 +1,113 @@
package cn.learningpad.oraltrainer.sample
import android.content.Context
import android.os.Handler
import android.os.Looper
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
data class SentenceExplanationResult(
val explanation: String,
)
interface SentenceExplanationCallback {
fun onSuccess(result: SentenceExplanationResult)
fun onError(error: Throwable)
}
class CancellableExplanationRequest internal constructor(
private val future: Future<*>,
) {
fun cancel() {
future.cancel(true)
}
}
/** Calls the app server; AI credentials never live in the mobile app. */
internal class SentenceExplanationApi(
context: Context,
private val baseUrl: String,
private val tokenProvider: () -> String,
) {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newCachedThreadPool()
fun explain(
sentenceText: String,
courseTitle: String,
sentenceIndex: Int?,
language: String?,
callback: SentenceExplanationCallback,
): CancellableExplanationRequest {
val future = executor.submit {
try {
val result = requestBlocking(
sentenceText,
courseTitle,
sentenceIndex,
language,
)
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(result) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableExplanationRequest(future)
}
fun release() {
executor.shutdownNow()
}
private fun requestBlocking(
sentenceText: String,
courseTitle: String,
sentenceIndex: Int?,
language: String?,
): SentenceExplanationResult {
val endpoint = baseUrl.trimEnd('/') + "/api/v1/sentences/explain"
val body = JSONObject().apply {
put("sentence_text", sentenceText)
put("course_title", courseTitle)
sentenceIndex?.let { put("sentence_index", it) }
language?.takeIf { it.isNotBlank() }?.let { put("language", it) }
}
val connection = URL(endpoint).openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.doOutput = true
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.4 (Android)")
tokenProvider().takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
connection.outputStream.use { output ->
output.write(body.toString().toByteArray(Charsets.UTF_8))
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw IOException("AI explain API returned HTTP $statusCode: $response")
}
val explanation = JSONObject(response).getString("explanation")
return SentenceExplanationResult(explanation)
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,150 @@
package cn.learningpad.oraltrainer.sample
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
/** Modal explanation layer shown after the user taps the subtitle AI button. */
internal class SentenceExplanationOverlay(
private val activity: Activity,
private val api: SentenceExplanationApi,
) {
private var currentOverlay: View? = null
private var currentRequest: CancellableExplanationRequest? = null
private var requestGeneration = 0
fun show(sentence: SentenceBoundary, courseTitle: String) {
close()
val generation = ++requestGeneration
val root = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(Color.argb(160, 0, 0, 0))
isClickable = true
isFocusable = true
}
val card = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
background = rounded(Color.WHITE, 16f, SampleTheme.lightBorder)
setPadding(20.dp, 20.dp, 20.dp, 18.dp)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
Gravity.CENTER,
).apply {
setMargins(24.dp, 48.dp, 24.dp, 48.dp)
}
}
val title = TextView(activity).apply {
text = "句子讲解"
setTextColor(SampleTheme.textDark)
textSize = 19f
typeface = Typeface.DEFAULT_BOLD
}
val subtitle = TextView(activity).apply {
text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle
setTextColor(SampleTheme.accentDeep)
textSize = 14f
setPadding(0, 8.dp, 0, 12.dp)
maxLines = 3
}
val message = TextView(activity).apply {
text = "AI 正在讲解这句话…"
setTextColor(SampleTheme.textSubtle)
textSize = 15f
setLineSpacing(3.dp.toFloat(), 1.1f)
}
val scroll = ScrollView(activity).apply {
addView(message)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
}
val close = TextView(activity).apply {
text = "关闭"
setTextColor(Color.WHITE)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
gravity = Gravity.CENTER
background = rounded(SampleTheme.button, 10f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
44.dp,
).apply { topMargin = 16.dp }
setOnClickListener { close() }
}
card.addView(title)
card.addView(subtitle)
card.addView(scroll)
card.addView(close)
root.addView(card)
addToWindow(root)
currentOverlay = root
currentRequest = api.explain(
sentence.text.orEmpty(),
courseTitle,
sentence.index,
sentence.language,
object : SentenceExplanationCallback {
override fun onSuccess(result: SentenceExplanationResult) {
if (generation == requestGeneration) message.text = result.explanation
}
override fun onError(error: Throwable) {
if (generation == requestGeneration) {
message.text = "讲解失败:${error.message ?: "请稍后重试"}"
message.setTextColor(SampleTheme.danger)
}
}
},
)
}
fun close() {
currentRequest?.cancel()
currentRequest = null
currentOverlay?.let { overlay ->
(overlay.parent as? ViewGroup)?.removeView(overlay)
}
currentOverlay = null
}
fun release() {
close()
api.release()
}
private fun addToWindow(view: View) {
(activity.window?.decorView as? ViewGroup)?.addView(view)
}
private val Int.dp: Int
get() = (this * activity.resources.displayMetrics.density).toInt()
private val Float.dp: Int
get() = (this * activity.resources.displayMetrics.density).toInt()
private fun rounded(
color: Int,
radiusDp: Float,
strokeColor: Int? = null,
): GradientDrawable = GradientDrawable().apply {
setColor(color)
cornerRadius = radiusDp.dp.toFloat()
strokeColor?.let { setStroke(1.dp, it) }
}
}

View File

@@ -162,7 +162,23 @@ internal class PlayerChrome(
addView(createContinuousPlaybackRow(translucent)) addView(createContinuousPlaybackRow(translucent))
} }
addView(bindings.sentenceMetaText) addView(bindings.sentenceMetaText)
addView(bindings.sentenceText) addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(
bindings.sentenceText,
LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f,
),
)
bindings.explainSentenceButton = createExplainButton(translucent)
addView(
bindings.explainSentenceButton,
LinearLayout.LayoutParams(32.dp, 32.dp).apply { marginStart = 10.dp },
)
})
addView(bindings.progressBar) addView(bindings.progressBar)
addView(bindings.statusText) addView(bindings.statusText)
} }
@@ -211,6 +227,23 @@ internal class PlayerChrome(
} }
} }
private fun createExplainButton(translucent: Boolean): ImageButton =
ImageButton(activity).apply {
setImageResource(R.drawable.ic_ai_explain)
imageTintList = ColorStateList.valueOf(
if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep,
)
background = rounded(
if (translucent) SampleTheme.overlay else SampleTheme.selected,
16.dp.toFloat(),
)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(6.dp, 6.dp, 6.dp, 6.dp)
contentDescription = "AI 讲解这句话"
isEnabled = false
setOnClickListener { seekActions.onExplainCurrentSentence() }
}
private fun createLandscapeSentencePanel(): View = private fun createLandscapeSentencePanel(): View =
createSentenceContent(translucent = true).apply { createSentenceContent(translucent = true).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M24,4a13,13 0 0 0 -7.2,23.8c0.7,0.5 1.2,1.3 1.2,2.2v1.5h12v-1.5c0,-0.9 0.5,-1.7 1.2,-2.2A13,13 0 0 0 24,4zM18,36h12v3h-12zM20,42h8v2h-8z"/>
<path
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M38,10l1.8,4.2L44,16l-4.2,1.8L38,22l-1.8,-4.2L32,16l4.2,-1.8z"/>
</vector>

View File

@@ -37,3 +37,46 @@ enum SentenceBoundaryAPI {
return result.value return result.value
} }
} }
struct SentenceExplanationResponse: Decodable {
let explanation: String
private enum CodingKeys: String, CodingKey {
case explanation
}
}
extension SentenceBoundaryAPI {
static func explain(
_ sentence: SentenceBoundary,
courseTitle: String
) async throws -> String {
var body: [String: Any] = [
"sentence_text": sentence.text ?? "",
"course_title": courseTitle,
"sentence_index": sentence.index,
]
if let language = sentence.language {
body["language"] = language
}
var headers: [String: String] = ["Content-Type": "application/json"]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/sentences/explain",
method: "POST",
timeout: AppConfig.readTimeout,
headers: headers,
body: try JSONSerialization.data(withJSONObject: body)
)
guard (200...299).contains(result.statusCode) else {
let detail = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, detail)
}
return try JSONDecoder()
.decode(SentenceExplanationResponse.self, from: result.body)
.explanation
}
}

View File

@@ -6,6 +6,7 @@ struct FullscreenPlayerView: View {
let onExit: () -> Void let onExit: () -> Void
@State private var subtitleVisible = true @State private var subtitleVisible = true
@State private var explanationSentence: SentenceBoundary?
var body: some View { var body: some View {
ZStack { ZStack {
@@ -38,10 +39,34 @@ struct FullscreenPlayerView: View {
Text(sentenceMetaText) Text(sentenceMetaText)
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundColor(Theme.textOverlay) .foregroundColor(Theme.textOverlay)
Text(sentenceTextText) HStack(alignment: .center, spacing: 8) {
Text(sentenceTextText)
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(2) .lineLimit(2)
Spacer()
Button {
guard let sentence = controller.currentSentence,
!(sentence.text ?? "").isEmpty else {
return
}
controller.pause()
explanationSentence = sentence
} label: {
Image(systemName: "lightbulb.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundColor(
controller.currentSentence?.text?.isEmpty == false
? Theme.accentLight
: Theme.textSubtle
)
.frame(width: 30, height: 30)
.background(Theme.overlay)
.clipShape(Circle())
.accessibilityLabel("AI 讲解这句话")
}
.buttonStyle(.plain)
}
progressRow progressRow
Text(statusText) Text(statusText)
.font(.system(size: 13)) .font(.system(size: 13))
@@ -70,6 +95,12 @@ struct FullscreenPlayerView: View {
} }
.padding(16) .padding(16)
} }
.sheet(item: $explanationSentence) { sentence in
SentenceExplanationSheet(
courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence
)
}
} }
@State private var progressDragging = false @State private var progressDragging = false

View File

@@ -10,6 +10,7 @@ struct PlayerSectionView: View {
@State private var progressDragging = false @State private var progressDragging = false
@State private var dragPositionMs: Int64 = 0 @State private var dragPositionMs: Int64 = 0
@State private var explanationSentence: SentenceBoundary?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -19,6 +20,12 @@ struct PlayerSectionView: View {
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.sheet(item: $explanationSentence) { sentence in
SentenceExplanationSheet(
courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence
)
}
} }
private var playerSurface: some View { private var playerSurface: some View {
@@ -85,10 +92,33 @@ struct PlayerSectionView: View {
Text(sentenceMetaText) Text(sentenceMetaText)
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundColor(Theme.textSubtle) .foregroundColor(Theme.textSubtle)
Text(sentenceTextText) HStack(alignment: .center, spacing: 8) {
Text(sentenceTextText)
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(2) .lineLimit(2)
Spacer()
Button {
guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else {
return
}
controller.pause()
explanationSentence = sentence
} label: {
Image(systemName: "lightbulb.circle.fill")
.font(.system(size: 23, weight: .semibold))
.foregroundColor(
controller.currentSentence?.text?.isEmpty == false
? Theme.accentLight
: Theme.textSubtle
)
.frame(width: 32, height: 32)
.background(Theme.overlay)
.clipShape(Circle())
.accessibilityLabel("AI 讲解这句话")
}
.buttonStyle(.plain)
}
progressRow progressRow
Text(statusText) Text(statusText)
.font(.system(size: 13)) .font(.system(size: 13))
@@ -195,3 +225,53 @@ struct PlayerSectionView: View {
controller.setPlaybackSpeed(next) controller.setPlaybackSpeed(next)
} }
} }
struct SentenceExplanationSheet: View {
let courseTitle: String
let sentence: SentenceBoundary
@State private var explanation = ""
@State private var errorMessage: String?
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(sentence.text ?? "")
.font(.system(size: 18, weight: .bold))
.foregroundColor(Theme.textDark)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 15))
.foregroundColor(Theme.danger)
} else if explanation.isEmpty {
Text("AI 正在讲解这句话…")
.font(.system(size: 15))
.foregroundColor(Theme.textSubtle)
} else {
Text(explanation)
.font(.system(size: 16))
.foregroundColor(Theme.textDark)
.lineSpacing(5)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
.background(Color.white.ignoresSafeArea())
.navigationTitle("句子讲解")
.navigationBarTitleDisplayMode(.inline)
}
.task {
guard explanation.isEmpty, errorMessage == nil else { return }
do {
explanation = try await SentenceBoundaryAPI.explain(
sentence,
courseTitle: courseTitle
)
} catch {
errorMessage = "讲解失败:\(error.localizedDescription)"
}
}
}
}

View File

@@ -17,6 +17,13 @@ MOSS_MAX_NEW_TOKENS=65536
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整 # 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
MOSS_END_PADDING_MS=300 MOSS_END_PADDING_MS=300
# AI sentence explanation (OpenAI-compatible chat completions endpoint).
# The URL and key stay on the server; mobile clients only send the sentence text.
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
# 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。 # 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key
VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key

View File

@@ -185,6 +185,9 @@ openssl rand -hex 32
```dotenv ```dotenv
PUBLIC_BASE_URL=https://videoservice.d1kt.cn PUBLIC_BASE_URL=https://videoservice.d1kt.cn
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini
``` ```
若使用 Whisper 方案(第 2A 节),把 `MOSS_TRANSCRIBE_URL` 改为 若使用 Whisper 方案(第 2A 节),把 `MOSS_TRANSCRIBE_URL` 改为

View File

@@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides:
- video catalog and HTTP Range playback; - video catalog and HTTP Range playback;
- sentence-boundary lookup compatible with the existing Android SDK; - sentence-boundary lookup compatible with the existing Android SDK;
- student recording upload and `asr-fluency-v1` assessment; - student recording upload and `asr-fluency-v1` assessment;
- AI sentence explanation for signed-in mobile users;
- the legacy JSON boundary index as a read-only fallback. - the legacy JSON boundary index as a read-only fallback.
## Local Run ## Local Run
@@ -50,6 +51,7 @@ GET /api/v1/videos
GET /api/v1/videos/{sha256} GET /api/v1/videos/{sha256}
GET /api/v1/videos/{sha256}/content GET /api/v1/videos/{sha256}/content
GET /api/v1/videos/{sha256}/sentence-boundaries GET /api/v1/videos/{sha256}/sentence-boundaries
POST /api/v1/sentences/explain
POST /api/v1/videos/{sha256}/sentences/{index}/assessments POST /api/v1/videos/{sha256}/sentences/{index}/assessments
POST /api/v1/admin/videos POST /api/v1/admin/videos
@@ -64,6 +66,21 @@ DELETE /api/v1/admin/videos/{sha256}
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured. requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
## AI Sentence Explanation
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`:
```dotenv
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
```
The endpoint requires a signed-in user and accepts only the sentence and course
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
to the mobile clients.
## Assessment ## Assessment
```bash ```bash

75
sentence_api/ai_tutor.py Normal file
View File

@@ -0,0 +1,75 @@
"""AI-backed sentence explanations for the mobile clients."""
from dataclasses import dataclass
import requests
class AITutorError(RuntimeError):
"""Raised when the configured AI service cannot answer a request."""
@dataclass(frozen=True)
class AITutor:
endpoint: str
api_key: str
model: str
timeout_seconds: float
@property
def available(self) -> bool:
return bool(self.endpoint and self.model)
def explain(
self,
sentence_text: str,
*,
course_title: str = "",
sentence_index: int | None = None,
language: str = "",
) -> str:
if not self.available:
raise AITutorError("AI explanation is not configured.")
context = course_title.strip() or "口语训练课程"
position = f"第 {sentence_index + 1} 句" if sentence_index is not None else "当前句子"
language_note = f"句子语言:{language.strip()}。" if language.strip() else ""
system_prompt = (
"你是一位耐心的外语口语老师。请用简体中文讲解用户提供的影视/课程句子。"
"先给出自然的中文翻译,再解释关键词、常用搭配或语法要点,并简要说明这句话适合怎样跟读。"
"回答使用简明清晰的分段短文,总长度不超过 300 字,不要编造视频里没有的信息。"
)
user_prompt = (
f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}"
)
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
response = requests.post(
self.endpoint,
headers=headers,
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"stream": False,
},
timeout=self.timeout_seconds,
)
except requests.RequestException as exc:
raise AITutorError("AI service request failed.") from exc
if response.status_code < 200 or response.status_code >= 300:
raise AITutorError(f"AI service returned HTTP {response.status_code}.")
try:
body = response.json()
explanation = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise AITutorError("AI service returned an unexpected response.") from exc
if not isinstance(explanation, str) or not explanation.strip():
raise AITutorError("AI service returned an empty explanation.")
return explanation.strip()

View File

@@ -26,6 +26,10 @@ class Settings:
moss_timeout_seconds: float moss_timeout_seconds: float
moss_max_new_tokens: int moss_max_new_tokens: int
moss_end_padding_ms: int moss_end_padding_ms: int
ai_api_url: str
ai_api_key: str
ai_model: str
ai_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
@@ -61,6 +65,10 @@ class Settings:
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")), moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")), moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")), moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
ai_api_url=os.getenv("AI_API_URL", "").strip().rstrip("/"),
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")),
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

@@ -27,6 +27,7 @@ from fastapi.responses import FileResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect from starlette.requests import ClientDisconnect
from .ai_tutor import AITutor, AITutorError
from .assessment import AssessmentService from .assessment import AssessmentService
from .auth import AuthenticatedUser from .auth import AuthenticatedUser
@@ -40,6 +41,8 @@ from .models import (
SentenceBoundaryAdjust, SentenceBoundaryAdjust,
SentenceBoundary, SentenceBoundary,
SentenceBoundaryDocument, SentenceBoundaryDocument,
SentenceExplanationRequest,
SentenceExplanationResponse,
SentenceTextUpdate, SentenceTextUpdate,
UserDubShareSummary, UserDubShareSummary,
SsoExchangeRequest, SsoExchangeRequest,
@@ -93,6 +96,12 @@ def create_app(
timeout_seconds=service_settings.moss_timeout_seconds, timeout_seconds=service_settings.moss_timeout_seconds,
max_new_tokens=service_settings.moss_max_new_tokens, max_new_tokens=service_settings.moss_max_new_tokens,
) )
ai_tutor = AITutor(
endpoint=service_settings.ai_api_url,
api_key=service_settings.ai_api_key,
model=service_settings.ai_model,
timeout_seconds=service_settings.ai_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)
assessment_service = AssessmentService( assessment_service = AssessmentService(
@@ -110,6 +119,7 @@ def create_app(
application.state.boundary_store = legacy_store application.state.boundary_store = legacy_store
application.state.video_repository = video_repository application.state.video_repository = video_repository
application.state.transcriber = moss application.state.transcriber = moss
application.state.ai_tutor = ai_tutor
application.state.oss_uploader = oss application.state.oss_uploader = oss
application.state.video_processor = processor application.state.video_processor = processor
application.state.assessment_service = assessment_service application.state.assessment_service = assessment_service
@@ -161,6 +171,7 @@ def create_app(
"video_count": len(videos) + legacy_store.count(), "video_count": len(videos) + legacy_store.count(),
"managed_video_count": len(videos), "managed_video_count": len(videos),
"moss_configured": moss.available, "moss_configured": moss.available,
"ai_explanation_configured": application.state.ai_tutor.available,
"volcano_oss_configured": oss.enabled, "volcano_oss_configured": oss.enabled,
"scoring_version": "asr-fluency-v1", "scoring_version": "asr-fluency-v1",
} }
@@ -298,6 +309,26 @@ def create_app(
raise HTTPException(status_code=404, detail="Course is not available.") raise HTTPException(status_code=404, detail="Course is not available.")
return Response(status_code=204) return Response(status_code=204)
@application.post(
"/api/v1/sentences/explain",
response_model=SentenceExplanationResponse,
)
def explain_sentence(
payload: SentenceExplanationRequest,
user: AuthenticatedUser = Depends(current_user),
) -> SentenceExplanationResponse:
del user
try:
explanation = application.state.ai_tutor.explain(
payload.sentence_text,
course_title=payload.course_title,
sentence_index=payload.sentence_index,
language=payload.language or "",
)
except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return SentenceExplanationResponse(explanation=explanation)
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204) @application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
def unenroll_course( def unenroll_course(
video_hash: str = SHA256_PATH, video_hash: str = SHA256_PATH,

View File

@@ -92,6 +92,19 @@ class SentenceTextUpdate(BaseModel):
language: Optional[str] = Field(default=None, max_length=32) language: Optional[str] = Field(default=None, max_length=32)
class SentenceExplanationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
sentence_text: str = Field(min_length=1, max_length=4000)
course_title: str = Field(default="", max_length=200)
sentence_index: Optional[int] = Field(default=None, ge=0)
language: Optional[str] = Field(default=None, max_length=32)
class SentenceExplanationResponse(BaseModel):
explanation: str = Field(min_length=1, max_length=6000)
class SentenceBoundaryAdjust(BaseModel): class SentenceBoundaryAdjust(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -0,0 +1,57 @@
import pytest
from sentence_api.ai_tutor import AITutor, AITutorError
class FakeResponse:
def __init__(self, status_code=200, body=None):
self.status_code = status_code
self._body = body or {}
def json(self):
if self._body is None:
raise ValueError("invalid JSON")
return self._body
def test_ai_tutor_parses_chat_completion(monkeypatch):
requests_payloads = []
def fake_post(endpoint, headers, json, timeout):
requests_payloads.append((endpoint, headers, json, timeout))
return FakeResponse(
body={
"choices": [
{"message": {"content": " 这句话的意思是…… "}}
]
}
)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/chat", "secret", "test-model", 1.5)
explanation = tutor.explain(
"The early bird catches the worm.",
course_title="English idioms",
sentence_index=1,
language="en",
)
endpoint, headers, payload, timeout = requests_payloads[0]
assert explanation == "这句话的意思是……"
assert endpoint == "https://ai.test/chat"
assert headers["Authorization"] == "Bearer secret"
assert payload["model"] == "test-model"
assert "The early bird catches the worm." in payload["messages"][1]["content"]
assert timeout == 1.5
def test_ai_tutor_rejects_unexpected_response(monkeypatch):
monkeypatch.setattr(
"sentence_api.ai_tutor.requests.post",
lambda *args, **kwargs: FakeResponse(body={"choices": []}),
)
tutor = AITutor("https://ai.test/chat", "secret", "test-model", 1)
with pytest.raises(AITutorError):
tutor.explain("Hello.")

View File

@@ -80,6 +80,70 @@ def test_invalid_hash_is_rejected(tmp_path):
assert response.status_code == 422 assert response.status_code == 422
def test_sentence_explanation_requires_login(tmp_path):
index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8")
settings = replace(
Settings.from_env(),
data_dir=tmp_path / "data",
legacy_boundaries_file=index_path,
)
settings.ensure_directories()
client = TestClient(
create_app(
BoundaryStore(index_path),
settings=settings,
repository=VideoRepository(settings.database_path),
)
)
response = client.post(
"/api/v1/sentences/explain",
json={"sentence_text": "Hello."},
)
assert response.status_code == 401
def test_sentence_explanation_uses_server_side_ai_client(tmp_path, monkeypatch):
client = make_client(tmp_path)
requested = []
def explain(text, **kwargs):
requested.append((text, kwargs))
return "这句话表示打招呼。"
class StubAITutor:
pass
stub = StubAITutor()
stub.explain = explain
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
response = client.post(
"/api/v1/sentences/explain",
json={
"sentence_text": "Hello.",
"course_title": "Greetings",
"sentence_index": 0,
"language": "en",
},
)
assert response.status_code == 200
assert response.json() == {"explanation": "这句话表示打招呼。"}
assert requested == [
(
"Hello.",
{
"course_title": "Greetings",
"sentence_index": 0,
"language": "en",
},
)
]
def test_oss_video_url_is_used_for_catalog_and_content(tmp_path): def test_oss_video_url_is_used_for_catalog_and_content(tmp_path):
index_path = tmp_path / "boundaries.json" index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8") index_path.write_text("{}", encoding="utf-8")