This commit is contained in:
kandrusyak
2026-07-25 15:28:28 +03:00
parent 6667e0d253
commit 0163208484
4 changed files with 90 additions and 19 deletions

View File

@@ -94,6 +94,10 @@ YANDEX_STT_LANGUAGE=ru-RU
VOICE_MAX_DURATION_SECONDS=120 VOICE_MAX_DURATION_SECONDS=120
``` ```
`YANDEX_CLOUD_FOLDER` — идентификатор каталога Yandex Cloud (например, значение
вида `b1g...`), а не имя модели. Имя и версия модели указываются отдельно в
`YANDEX_CLOUD_MODEL`, например `qwen3.6-35b-a3b/latest`.
Значения моделей можно посмотреть командой `/models`, а пользовательский выбор Значения моделей можно посмотреть командой `/models`, а пользовательский выбор
сохранить командой `/model имя-модели`. Выбор хранится отдельно для локального и сохранить командой `/model имя-модели`. Выбор хранится отдельно для локального и
облачного режимов. Короткое имя облачной модели автоматически преобразуется в URI облачного режимов. Короткое имя облачной модели автоматически преобразуется в URI
@@ -130,6 +134,9 @@ docker run -d \
kandrusyak-bot:local kandrusyak-bot:local
``` ```
Ollama должна быть доступна контейнеру по адресу из `OLLAMA_BASE_URL`; адрес Ollama должна быть доступна контейнеру по адресу из `OLLAMA_BASE_URL`; адрес
`localhost` внутри контейнера указывает на сам контейнер, а не на Linux-хост. `localhost` внутри контейнера указывает на сам контейнер, а не на Linux-хост.

View File

@@ -115,7 +115,7 @@ def get_default_ollama_model() -> str:
def get_yandex_cloud_folder() -> str: def get_yandex_cloud_folder() -> str:
load_env_file() load_env_file()
folder = os.getenv(YANDEX_CLOUD_FOLDER_ENV_NAME, "").strip() folder = os.getenv(YANDEX_CLOUD_FOLDER_ENV_NAME, "").strip()
if not folder: if not folder or folder == "":
raise RuntimeError( raise RuntimeError(
f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file." f"Set {YANDEX_CLOUD_FOLDER_ENV_NAME} in environment or .env file."
) )

View File

@@ -9,6 +9,15 @@ class YandexAIError(AIClientError):
pass pass
def describe_yandex_error(exc: Exception) -> str:
response = getattr(exc, "response", None)
status_code = getattr(response, "status_code", None)
response_text = getattr(response, "text", None)
if status_code and isinstance(response_text, str) and response_text.strip():
return f"HTTP {status_code}: {response_text.strip()[:800]}"
return str(exc)
def create_async_sdk(folder_id: str) -> Any: def create_async_sdk(folder_id: str) -> Any:
try: try:
from yandex_ai_studio_sdk import AsyncAIStudio from yandex_ai_studio_sdk import AsyncAIStudio
@@ -42,6 +51,14 @@ class YandexAIClient:
def normalize_model(self, model: str) -> str: def normalize_model(self, model: str) -> str:
normalized = model.strip() normalized = model.strip()
if normalized.startswith("gpt://"): if normalized.startswith("gpt://"):
model_path = normalized.removeprefix("gpt://").strip("/")
parts = model_path.split("/")
if (
not normalized.startswith(f"gpt://{self.folder_id}/")
and len(parts) == 2
and parts[-1] in {"latest", "rc"}
):
return f"gpt://{self.folder_id}/{model_path}"
return normalized return normalized
return f"gpt://{self.folder_id}/{normalized.lstrip('/')}" return f"gpt://{self.folder_id}/{normalized.lstrip('/')}"
@@ -49,7 +66,9 @@ class YandexAIClient:
try: try:
models = await self._sdk.chat.completions.list() models = await self._sdk.chat.completions.list()
except Exception as exc: except Exception as exc:
raise YandexAIError(f"Не удалось получить список моделей: {exc}") from exc raise YandexAIError(
f"Не удалось получить список моделей: {describe_yandex_error(exc)}"
) from exc
model_names = { model_names = {
str(uri) str(uri)
@@ -67,19 +86,23 @@ class YandexAIClient:
sdk_messages = [ sdk_messages = [
{ {
"role": message.get("role", "user"), "role": message.get("role", "user"),
"text": message.get("content", ""), "content": message.get("content", ""),
} }
for message in messages for message in messages
] ]
try: try:
completion = self._sdk.models.completions(self.normalize_model(model)) completion = self._sdk.chat.completions(self.normalize_model(model))
if json_mode: if json_mode:
completion = completion.configure(response_format="json") completion = completion.configure(response_format="json")
result = await completion.run(sdk_messages, timeout=180) result = await completion.run(sdk_messages, timeout=180)
content = getattr(result, "text", None)
if content is None:
content = getattr(result[0], "text", None) content = getattr(result[0], "text", None)
except Exception as exc: except Exception as exc:
raise YandexAIError(f"Запрос к модели завершился ошибкой: {exc}") from exc raise YandexAIError(
f"Запрос к модели завершился ошибкой: {describe_yandex_error(exc)}"
) from exc
if isinstance(content, str) and content.strip(): if isinstance(content, str) and content.strip():
return content.strip() return content.strip()

View File

@@ -3,7 +3,11 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from assistant_bot.yandex_ai import YandexAIClient, YandexSpeechRecognizer from assistant_bot.yandex_ai import (
YandexAIClient,
YandexSpeechRecognizer,
describe_yandex_error,
)
class FakeCompletion: class FakeCompletion:
@@ -22,21 +26,37 @@ class FakeCompletion:
return [SimpleNamespace(text=" готово ")] return [SimpleNamespace(text=" готово ")]
class FakeChatCompletions:
def __init__(self, completion, list_models) -> None:
self.completion = completion
self.list = list_models
self.requested_models = []
def __call__(self, model):
self.requested_models.append(model)
return self.completion
class YandexAIClientTests(unittest.IsolatedAsyncioTestCase): class YandexAIClientTests(unittest.IsolatedAsyncioTestCase):
def test_http_error_includes_safe_response_body(self) -> None:
error = RuntimeError("forbidden")
error.response = SimpleNamespace(
status_code=403,
text='{"error":{"message":"folder mismatch"}}',
)
self.assertEqual(
describe_yandex_error(error),
'HTTP 403: {"error":{"message":"folder mismatch"}}',
)
async def test_chat_uses_sdk_messages_and_json_mode(self) -> None: async def test_chat_uses_sdk_messages_and_json_mode(self) -> None:
completion = FakeCompletion() completion = FakeCompletion()
requested_models = [] completions = FakeChatCompletions(completion, self._list_models)
def completions(model):
requested_models.append(model)
return completion
sdk = SimpleNamespace( sdk = SimpleNamespace(
models=SimpleNamespace(completions=completions),
chat=SimpleNamespace( chat=SimpleNamespace(
completions=SimpleNamespace( completions=completions,
list=self._list_models,
)
), ),
) )
client = YandexAIClient(folder_id="folder-id", sdk=sdk) client = YandexAIClient(folder_id="folder-id", sdk=sdk)
@@ -51,17 +71,38 @@ class YandexAIClientTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(answer, "готово") self.assertEqual(answer, "готово")
self.assertEqual(requested_models, ["gpt://folder-id/yandexgpt"]) self.assertEqual(
completions.requested_models,
["gpt://folder-id/yandexgpt"],
)
self.assertEqual(completion.configuration, {"response_format": "json"}) self.assertEqual(completion.configuration, {"response_format": "json"})
self.assertEqual( self.assertEqual(
completion.messages, completion.messages,
[ [
{"role": "system", "text": "Отвечай кратко"}, {"role": "system", "content": "Отвечай кратко"},
{"role": "user", "text": "Привет"}, {"role": "user", "content": "Привет"},
], ],
) )
self.assertEqual(completion.timeout, 180) self.assertEqual(completion.timeout, 180)
async def test_chat_repairs_legacy_uri_without_folder_id(self) -> None:
completion = FakeCompletion()
completions = FakeChatCompletions(completion, self._list_models)
sdk = SimpleNamespace(
chat=SimpleNamespace(completions=completions),
)
client = YandexAIClient(folder_id="folder-id", sdk=sdk)
await client.chat(
"gpt://qwen3.6-35b-a3b/latest",
[{"role": "user", "content": "Привет"}],
)
self.assertEqual(
completions.requested_models,
["gpt://folder-id/qwen3.6-35b-a3b/latest"],
)
async def test_list_models_returns_sorted_uris(self) -> None: async def test_list_models_returns_sorted_uris(self) -> None:
sdk = SimpleNamespace( sdk = SimpleNamespace(
chat=SimpleNamespace( chat=SimpleNamespace(