From aca36c78c1783800d22d1279e33e2c72f626a787 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sun, 6 Sep 2026 08:32:04 +0800 Subject: [PATCH] fixed a bug --- .../sample/CourseCatalogController.kt | 2 +- .../oraltrainer/sample/MainActivity.kt | 4 ++-- .../Views/FullscreenPlayerView.swift | 4 ++-- ios/OralTrainer/Views/PlayerSectionView.swift | 4 ++-- sentence_api/.env.example | 3 ++- sentence_api/README.md | 4 +++- sentence_api/ai_tutor.py | 24 +++++++++++++++---- sentence_api/tests/test_ai_tutor.py | 15 ++++++++++++ 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt index 8cc262c..093ddef 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt @@ -128,7 +128,7 @@ internal class CourseCatalogController( return } host.onStatus("句子边界加载失败:${error.message.orEmpty()}") - host.onSentencePlaceholder("暂无句子边界", video.title) + host.onSentencePlaceholder("暂无句子边界", "") } }) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index c462834..12642f5 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -214,7 +214,7 @@ class MainActivity : Activity() { bindings.statusText.text = "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}" bindings.sentenceMetaText.text = "暂无句子边界" - bindings.sentenceText.text = item.title + bindings.sentenceText.text = "" } } }) @@ -372,7 +372,7 @@ class MainActivity : Activity() { if (!bindings.hasPlayerChrome) return if (sentence == null) { bindings.sentenceMetaText.text = "暂无句子边界" - bindings.sentenceText.text = bindings.lessonTitleText.text + bindings.sentenceText.text = "" bindings.explainSentenceButton?.isEnabled = false return } diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift index 8f52569..805073e 100644 --- a/ios/OralTrainer/Views/FullscreenPlayerView.swift +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -171,10 +171,10 @@ struct FullscreenPlayerView: View { case .loading(let isLocal): return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中") case .failed: - return controller.currentItem?.title ?? "未选择课程" + return "" case .idle, .loaded: guard let sentence = controller.currentSentence else { - return controller.currentItem?.title ?? "未选择课程" + return "" } if let text = sentence.text, !text.isEmpty { return text diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index 68a899b..862aed6 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -189,10 +189,10 @@ struct PlayerSectionView: View { } return controller.currentItem?.title ?? "加载中" case .failed: - return controller.currentItem?.title ?? "未选择课程" + return "" case .idle, .loaded: guard let sentence = controller.currentSentence else { - return controller.currentItem?.title ?? "未选择课程" + return "" } if let text = sentence.text, !text.isEmpty { return text diff --git a/sentence_api/.env.example b/sentence_api/.env.example index 8c7bb89..bec45eb 100644 --- a/sentence_api/.env.example +++ b/sentence_api/.env.example @@ -17,7 +17,8 @@ MOSS_MAX_NEW_TOKENS=65536 # 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整 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. AI_API_URL=https://api.example.com/v1/chat/completions AI_API_KEY=replace-with-your-ai-api-key diff --git a/sentence_api/README.md b/sentence_api/README.md index 2dbe940..8abc544 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -68,7 +68,9 @@ 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`: +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 AI_API_URL=https://api.example.com/v1/chat/completions diff --git a/sentence_api/ai_tutor.py b/sentence_api/ai_tutor.py index 4730f6e..29d50a7 100644 --- a/sentence_api/ai_tutor.py +++ b/sentence_api/ai_tutor.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging +from urllib.parse import urlsplit import requests @@ -24,6 +25,19 @@ class AITutor: def available(self) -> bool: 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( self, sentence_text: str, @@ -51,7 +65,7 @@ class AITutor: headers["Authorization"] = f"Bearer {self.api_key}" try: response = requests.post( - self.endpoint, + self.request_url, headers=headers, json={ "model": self.model, @@ -67,7 +81,7 @@ class AITutor: except requests.RequestException as exc: logger.exception( "AI explanation request failed: endpoint=%s model=%s", - self.endpoint, + self.request_url, self.model, ) raise AITutorError("AI service request failed.") from exc @@ -76,7 +90,7 @@ class AITutor: logger.warning( "AI explanation returned HTTP %s: endpoint=%s model=%s body=%s", response.status_code, - self.endpoint, + self.request_url, self.model, response.text[:1000], ) @@ -87,14 +101,14 @@ class AITutor: except (KeyError, IndexError, TypeError, ValueError) as exc: logger.warning( "AI explanation returned an unexpected body: endpoint=%s body=%s", - self.endpoint, + self.request_url, response.text[:1000], ) raise AITutorError("AI service returned an unexpected response.") from exc if not isinstance(explanation, str) or not explanation.strip(): logger.warning( "AI explanation returned empty content: endpoint=%s body=%s", - self.endpoint, + self.request_url, response.text[:1000], ) raise AITutorError("AI service returned an empty explanation.") diff --git a/sentence_api/tests/test_ai_tutor.py b/sentence_api/tests/test_ai_tutor.py index 4edb04c..de778b0 100644 --- a/sentence_api/tests/test_ai_tutor.py +++ b/sentence_api/tests/test_ai_tutor.py @@ -47,6 +47,21 @@ def test_ai_tutor_parses_chat_completion(monkeypatch): 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): monkeypatch.setattr( "sentence_api.ai_tutor.requests.post",