fixed a bug
This commit is contained in:
@@ -128,7 +128,7 @@ internal class CourseCatalogController(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
|
host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
|
||||||
host.onSentencePlaceholder("暂无句子边界", video.title)
|
host.onSentencePlaceholder("暂无句子边界", "")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ class MainActivity : Activity() {
|
|||||||
bindings.statusText.text =
|
bindings.statusText.text =
|
||||||
"句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
|
"句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
|
||||||
bindings.sentenceMetaText.text = "暂无句子边界"
|
bindings.sentenceMetaText.text = "暂无句子边界"
|
||||||
bindings.sentenceText.text = item.title
|
bindings.sentenceText.text = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -372,7 +372,7 @@ class MainActivity : Activity() {
|
|||||||
if (!bindings.hasPlayerChrome) return
|
if (!bindings.hasPlayerChrome) return
|
||||||
if (sentence == null) {
|
if (sentence == null) {
|
||||||
bindings.sentenceMetaText.text = "暂无句子边界"
|
bindings.sentenceMetaText.text = "暂无句子边界"
|
||||||
bindings.sentenceText.text = bindings.lessonTitleText.text
|
bindings.sentenceText.text = ""
|
||||||
bindings.explainSentenceButton?.isEnabled = false
|
bindings.explainSentenceButton?.isEnabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,10 +171,10 @@ struct FullscreenPlayerView: View {
|
|||||||
case .loading(let isLocal):
|
case .loading(let isLocal):
|
||||||
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
||||||
case .failed:
|
case .failed:
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
case .idle, .loaded:
|
case .idle, .loaded:
|
||||||
guard let sentence = controller.currentSentence else {
|
guard let sentence = controller.currentSentence else {
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
}
|
}
|
||||||
if let text = sentence.text, !text.isEmpty {
|
if let text = sentence.text, !text.isEmpty {
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -189,10 +189,10 @@ struct PlayerSectionView: View {
|
|||||||
}
|
}
|
||||||
return controller.currentItem?.title ?? "加载中"
|
return controller.currentItem?.title ?? "加载中"
|
||||||
case .failed:
|
case .failed:
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
case .idle, .loaded:
|
case .idle, .loaded:
|
||||||
guard let sentence = controller.currentSentence else {
|
guard let sentence = controller.currentSentence else {
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
}
|
}
|
||||||
if let text = sentence.text, !text.isEmpty {
|
if let text = sentence.text, !text.isEmpty {
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ 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).
|
# AI sentence explanation (OpenAI-compatible chat endpoint). A base URL ending
|
||||||
|
# in /v1 is also accepted and will automatically use /v1/chat/completions.
|
||||||
# The URL and key stay on the server; mobile clients only send the sentence text.
|
# 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_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
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
|||||||
|
|
||||||
## AI Sentence Explanation
|
## AI Sentence Explanation
|
||||||
|
|
||||||
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`:
|
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.
|
||||||
|
A base URL ending in `/v1` is also accepted and is resolved to
|
||||||
|
`/v1/chat/completions`:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
AI_API_URL=https://api.example.com/v1/chat/completions
|
AI_API_URL=https://api.example.com/v1/chat/completions
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -24,6 +25,19 @@ class AITutor:
|
|||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return bool(self.endpoint and self.model)
|
return bool(self.endpoint and self.model)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_url(self) -> str:
|
||||||
|
"""Resolve either a full chat URL or an OpenAI-compatible base URL."""
|
||||||
|
parsed = urlsplit(self.endpoint)
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
if parsed.query or path.endswith("/chat/completions"):
|
||||||
|
return self.endpoint
|
||||||
|
if path.endswith("/v1") or path.endswith("/v1/openai"):
|
||||||
|
return self.endpoint + "/chat/completions"
|
||||||
|
if not path:
|
||||||
|
return self.endpoint + "/v1/chat/completions"
|
||||||
|
return self.endpoint
|
||||||
|
|
||||||
def explain(
|
def explain(
|
||||||
self,
|
self,
|
||||||
sentence_text: str,
|
sentence_text: str,
|
||||||
@@ -51,7 +65,7 @@ class AITutor:
|
|||||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.endpoint,
|
self.request_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
@@ -67,7 +81,7 @@ class AITutor:
|
|||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"AI explanation request failed: endpoint=%s model=%s",
|
"AI explanation request failed: endpoint=%s model=%s",
|
||||||
self.endpoint,
|
self.request_url,
|
||||||
self.model,
|
self.model,
|
||||||
)
|
)
|
||||||
raise AITutorError("AI service request failed.") from exc
|
raise AITutorError("AI service request failed.") from exc
|
||||||
@@ -76,7 +90,7 @@ class AITutor:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"AI explanation returned HTTP %s: endpoint=%s model=%s body=%s",
|
"AI explanation returned HTTP %s: endpoint=%s model=%s body=%s",
|
||||||
response.status_code,
|
response.status_code,
|
||||||
self.endpoint,
|
self.request_url,
|
||||||
self.model,
|
self.model,
|
||||||
response.text[:1000],
|
response.text[:1000],
|
||||||
)
|
)
|
||||||
@@ -87,14 +101,14 @@ class AITutor:
|
|||||||
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"AI explanation returned an unexpected body: endpoint=%s body=%s",
|
"AI explanation returned an unexpected body: endpoint=%s body=%s",
|
||||||
self.endpoint,
|
self.request_url,
|
||||||
response.text[:1000],
|
response.text[:1000],
|
||||||
)
|
)
|
||||||
raise AITutorError("AI service returned an unexpected response.") from exc
|
raise AITutorError("AI service returned an unexpected response.") from exc
|
||||||
if not isinstance(explanation, str) or not explanation.strip():
|
if not isinstance(explanation, str) or not explanation.strip():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"AI explanation returned empty content: endpoint=%s body=%s",
|
"AI explanation returned empty content: endpoint=%s body=%s",
|
||||||
self.endpoint,
|
self.request_url,
|
||||||
response.text[:1000],
|
response.text[:1000],
|
||||||
)
|
)
|
||||||
raise AITutorError("AI service returned an empty explanation.")
|
raise AITutorError("AI service returned an empty explanation.")
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ def test_ai_tutor_parses_chat_completion(monkeypatch):
|
|||||||
assert timeout == 1.5
|
assert timeout == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_tutor_resolves_openai_compatible_base_url(monkeypatch):
|
||||||
|
endpoints = []
|
||||||
|
|
||||||
|
def fake_post(endpoint, **kwargs):
|
||||||
|
endpoints.append(endpoint)
|
||||||
|
return FakeResponse(body={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||||
|
tutor = AITutor("https://ai.test:8080/v1", "secret", "test-model", 1)
|
||||||
|
|
||||||
|
assert tutor.explain("Hello.") == "ok"
|
||||||
|
assert endpoints == ["https://ai.test:8080/v1/chat/completions"]
|
||||||
|
assert tutor.request_url == "https://ai.test:8080/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
def test_ai_tutor_rejects_unexpected_response(monkeypatch):
|
def test_ai_tutor_rejects_unexpected_response(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sentence_api.ai_tutor.requests.post",
|
"sentence_api.ai_tutor.requests.post",
|
||||||
|
|||||||
Reference in New Issue
Block a user