add explain module
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` 改为
|
||||
|
||||
@@ -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
75
sentence_api/ai_tutor.py
Normal 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()
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
57
sentence_api/tests/test_ai_tutor.py
Normal file
57
sentence_api/tests/test_ai_tutor.py
Normal 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.")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user