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

@@ -17,6 +17,13 @@ MOSS_MAX_NEW_TOKENS=65536
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
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 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key
VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key

View File

@@ -185,6 +185,9 @@ openssl rand -hex 32
```dotenv
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
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` 改为

View File

@@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides:
- video catalog and HTTP Range playback;
- sentence-boundary lookup compatible with the existing Android SDK;
- 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.
## Local Run
@@ -50,6 +51,7 @@ GET /api/v1/videos
GET /api/v1/videos/{sha256}
GET /api/v1/videos/{sha256}/content
GET /api/v1/videos/{sha256}/sentence-boundaries
POST /api/v1/sentences/explain
POST /api/v1/videos/{sha256}/sentences/{index}/assessments
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
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
```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_max_new_tokens: int
moss_end_padding_ms: int
ai_api_url: str
ai_api_key: str
ai_model: str
ai_timeout_seconds: float
pass_score: float
volcano_oss_access_key: str
volcano_oss_secret_key: str
@@ -61,6 +65,10 @@ class Settings:
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
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")),
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_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 starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from .ai_tutor import AITutor, AITutorError
from .assessment import AssessmentService
from .auth import AuthenticatedUser
@@ -40,6 +41,8 @@ from .models import (
SentenceBoundaryAdjust,
SentenceBoundary,
SentenceBoundaryDocument,
SentenceExplanationRequest,
SentenceExplanationResponse,
SentenceTextUpdate,
UserDubShareSummary,
SsoExchangeRequest,
@@ -93,6 +96,12 @@ def create_app(
timeout_seconds=service_settings.moss_timeout_seconds,
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)
processor = VideoProcessor(service_settings, video_repository, moss, oss)
assessment_service = AssessmentService(
@@ -110,6 +119,7 @@ def create_app(
application.state.boundary_store = legacy_store
application.state.video_repository = video_repository
application.state.transcriber = moss
application.state.ai_tutor = ai_tutor
application.state.oss_uploader = oss
application.state.video_processor = processor
application.state.assessment_service = assessment_service
@@ -161,6 +171,7 @@ def create_app(
"video_count": len(videos) + legacy_store.count(),
"managed_video_count": len(videos),
"moss_configured": moss.available,
"ai_explanation_configured": application.state.ai_tutor.available,
"volcano_oss_configured": oss.enabled,
"scoring_version": "asr-fluency-v1",
}
@@ -298,6 +309,26 @@ def create_app(
raise HTTPException(status_code=404, detail="Course is not available.")
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)
def unenroll_course(
video_hash: str = SHA256_PATH,

View File

@@ -92,6 +92,19 @@ class SentenceTextUpdate(BaseModel):
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):
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
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):
index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8")