fixed a bug

This commit is contained in:
2026-09-06 08:32:04 +08:00
parent 0baff0b447
commit aca36c78c1
8 changed files with 46 additions and 14 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.")

View File

@@ -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",