diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2cb00a2..c9df105 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -246,3 +246,40 @@ jobs: echo "✗ Пайплайн завершился с ошибкой" echo " Проверьте логи выше для деталей" fi + + notify-telegram-on-success: + name: Notify Telegram on success + if: always() && needs.test.result == 'success' && (needs.train-and-publish.result == 'success' || needs.train-and-publish.result == 'skipped') + needs: [test, train-and-publish] + runs-on: ubuntu-latest + steps: + - name: Send Telegram notification (silent) + uses: appleboy/telegram-action@v1.0.1 + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + format: markdown + disable_notification: true + message: | + *Пайплайн прошёл успешно* + Репо: `${{ github.repository }}` + Ветка: `${{ github.ref_name }}` + [Открыть run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + notify-telegram-on-failure: + name: Notify Telegram on failure + if: always() && (needs.test.result == 'failure' || needs.train-and-publish.result == 'failure') + needs: [test, train-and-publish] + runs-on: ubuntu-latest + steps: + - name: Send Telegram notification + uses: appleboy/telegram-action@v1.0.1 + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + format: markdown + message: | + *Пайплайн упал* + Репо: `${{ github.repository }}` + Ветка: `${{ github.ref_name }}` + [Открыть run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) diff --git a/.gitignore b/.gitignore index 369f553..54e628f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ Thumbs.db # Hugging Face cache (для Docker) cache/ +.cache/ # Environment variables .env \ No newline at end of file diff --git a/README.md b/README.md index 05d72ba..8322bc6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ Мини-сервис для классификации голосовых команд (SetFit). Обучает модель на малом датасете и классифицирует текстовые команды. Создан для использования в проекте навыка для Sber Salute. ---- - ## Стек технологий | Категория | Технологии | @@ -21,8 +19,6 @@ | Инфраструктура | Docker | | Разработка | pytest, ruff, httpx | ---- - ## Оглавление | Раздел | Содержание | @@ -37,8 +33,6 @@ | [CI/CD](#cicd) | Пайплайн и ссылка на настройку | | [Лицензия](#лицензия) | MIT | ---- - ## Быстрый старт 1. Создайте `.env` с `HF_TOKEN` и `HF_REPO_ID` ([токен](https://huggingface.co/settings/tokens), [модель](https://huggingface.co/google/embeddinggemma-300M) — принять условия). @@ -52,8 +46,6 @@ Остановка: `docker-compose down`. ---- - ## Установка и запуск ### Требование: Hugging Face @@ -86,8 +78,6 @@ python -m commands_classifier.cli serve Опции: `--host`, `--port`, `--config`. БД создаётся при первом запуске, данные из `data/` или CSV из `config.yaml`. ---- - ## Использование ### CLI @@ -103,19 +93,20 @@ python -m commands_classifier.client examples list python -m commands_classifier.client examples add --text "команда" --command "label" python -m commands_classifier.client examples delete --id 1 python -m commands_classifier.client health +python -m commands_classifier.client metrics +python -m commands_classifier.client reset python -m commands_classifier.client load-from-hf [--repo-id "username/model-name"] python -m commands_classifier.client load-from-hf-status +python -m commands_classifier.client command-feedback # репорт «исправить команду» из RDS-2P-Salute ``` По умолчанию клиент подключается к `http://localhost:20001` (флаг `--url` для другого адреса). ### Python -- **API-клиент:** `CVCApiClient(base_url)` — методы `predict`, `predict_batch`, `embed`, `train`, `get_training_status`, `get_examples`, `add_example`, `delete_example`. +- **API-клиент:** `CVCApiClient(base_url)` — методы `predict`, `predict_batch`, `embed`, `train`, `get_training_status`, `get_examples`, `add_example`, `delete_example`, `health`, `metrics`, `reset`, `load_from_hf`, `get_load_from_hf_status`, `get_command_feedback`. - **Библиотека (без сервера):** `CommandsClassifier()` + `load_dataset(path)` → `train(texts, labels)`, `predict(text)`, `save(path)`, `load(path)`. ---- - ## Конфигурация и API ### config.yaml @@ -137,6 +128,10 @@ database: path: "db/training_data.db" csv_migration_path: "data" +# Опционально: URL репорта «исправить команду» из RDS-2P-Salute (по умолчанию: rds-2p-salute-app:8000) +# command_feedback: +# url: "http://rds-2p-salute-app:8000/v1/admin/command-feedback" + training: iterations: 20 epochs: 1 @@ -144,25 +139,27 @@ training: learning_rate: 2e-5 ``` -### Эндпоинты +### Эндпоинты (API v1) + +Все ручки версионированы префиксом `/v1`. | Метод | Путь | Описание | |-------|------|----------| -| POST | /embed | Эмбеддинги (TEI) | -| GET | /health | Проверка работоспособности | -| GET | /metrics | Счётчики примеров и статус обучения | -| POST | /predict | Классификация одного текста | -| POST | /predict/batch | Batch классификация | -| POST | /train | Запуск обучения (фоновый) | -| GET | /train/status | Статус обучения | -| GET, POST, DELETE | /examples, /examples/{id} | Обучающие примеры | -| POST | /load_from_hf | Загрузка модели с Hugging Face | -| GET | /load_from_hf/status | Статус загрузки | +| POST | /v1/embed | Эмбеддинги (TEI) | +| GET | /v1/health | Проверка работоспособности | +| GET | /v1/metrics | Счётчики примеров и статус обучения | +| POST | /v1/predict | Классификация одного текста | +| POST | /v1/predict/batch | Batch классификация | +| POST | /v1/train | Запуск обучения (фоновый) | +| GET | /v1/train/status | Статус обучения | +| GET, POST, DELETE | /v1/examples, /v1/examples/{id} | Обучающие примеры | +| POST | /v1/reset | Сброс обучения (удаление модели, пометка примеров как необученных) | +| POST | /v1/load_from_hf | Загрузка модели с Hugging Face | +| GET | /v1/load_from_hf/status | Статус загрузки | +| GET | /v1/command-feedback | Репорт «исправить команду» из RDS-2P-Salute (прокси) | Интерактивная документация: **http://localhost:20001/docs**. Устройство (CPU/CUDA/ROCm) определяется при запуске автоматически. ---- - ## Данные ### Формат датасета @@ -177,8 +174,6 @@ training: `--iterations`, `--epochs`, `--batch-size`, `--learning-rate`. Значения по умолчанию — в `config.yaml` (секция `training`). ---- - ## Разработка ### Тесты и линт @@ -207,23 +202,18 @@ CVC/ └── docs/ # cicd_setup.md и др. ``` ---- - ## CI/CD Пайплайн [.github/workflows/deploy.yml](.github/workflows/deploy.yml): - При каждом push — **тесты** (линт + pytest в Docker). - Job **Train and Publish** — при метке `[retrain]` в сообщении коммита или при ручном запуске (Actions → Run workflow). Секреты: `HF_TOKEN`, `HF_REPO_ID`. +- **Уведомления в Telegram** при успешной и неуспешной сборке (опционально: секреты `TELEGRAM_TOKEN`, `TELEGRAM_TO`). Подробнее: [docs/telegram_notifications.md](docs/telegram_notifications.md). Подробная настройка (self-hosted runner, GPU, секреты): [docs/cicd_setup.md](docs/cicd_setup.md). ---- - ## Лицензия MIT ---- - *Проект создан с использованием нейросетей.* diff --git a/commands_classifier/api/routes/__init__.py b/commands_classifier/api/routes/__init__.py index d02dfd5..ed6884e 100644 --- a/commands_classifier/api/routes/__init__.py +++ b/commands_classifier/api/routes/__init__.py @@ -1,5 +1,6 @@ """Routes для API сервера CVC.""" +from commands_classifier.api.routes.command_feedback import router as command_feedback_router from commands_classifier.api.routes.examples import router as examples_router from commands_classifier.api.routes.health import router as health_router from commands_classifier.api.routes.load_from_hf import router as load_from_hf_router @@ -12,4 +13,5 @@ "examples_router", "load_from_hf_router", "health_router", + "command_feedback_router", ] diff --git a/commands_classifier/api/routes/command_feedback.py b/commands_classifier/api/routes/command_feedback.py new file mode 100644 index 0000000..187a6ae --- /dev/null +++ b/commands_classifier/api/routes/command_feedback.py @@ -0,0 +1,75 @@ +"""Эндпоинт для загрузки репорта «исправить команду» из RDS-2P-Salute.""" + +import logging +from typing import Any, List + +import requests +from fastapi import APIRouter, HTTPException + +from commands_classifier.api.state import get_config + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["command-feedback"]) + +# URL по умолчанию (внутри Docker-сети robot-services-network) +DEFAULT_COMMAND_FEEDBACK_URL = "http://rds-2p-salute-app:8000/v1/admin/command-feedback" +REQUEST_TIMEOUT = 30 + + +@router.get("/v1/command-feedback") +def get_command_feedback() -> List[dict]: + """ + Загружает записи обратной связи по командам из приложения RDS-2P-Salute. + + Эндпоинт доступен только из локальной/внутренней сети на стороне RDS; + при запросе с публичного IP RDS вернёт 403. CVC проксирует ответ как есть. + """ + config = get_config() + url = ( + config.get("command_feedback", {}).get("url") + or config.get("rds", {}).get("command_feedback_url") + or DEFAULT_COMMAND_FEEDBACK_URL + ).strip() + + if not url: + raise HTTPException( + status_code=503, + detail="command_feedback.url не задан в конфигурации", + ) + + try: + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + data: Any = resp.json() + if not isinstance(data, list): + raise HTTPException( + status_code=502, + detail="Ответ RDS не является массивом", + ) + return data + except requests.exceptions.Timeout: + logger.warning("Timeout при запросе command-feedback: %s", url) + raise HTTPException( + status_code=504, + detail="Таймаут при обращении к сервису RDS", + ) + except requests.exceptions.ConnectionError as e: + logger.warning("Ошибка соединения с RDS: %s", e) + raise HTTPException( + status_code=503, + detail="Сервис RDS недоступен (проверьте сеть и имя хоста)", + ) + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 403: + raise HTTPException( + status_code=502, + detail="RDS вернул 403 Forbidden (доступ только из внутренней сети)", + ) + raise HTTPException( + status_code=502, + detail=f"Ошибка RDS: {e.response.status_code if e.response else str(e)}", + ) + except ValueError as e: + logger.warning("Невалидный JSON от RDS: %s", e) + raise HTTPException(status_code=502, detail="Невалидный JSON в ответе RDS") diff --git a/commands_classifier/api/routes/examples.py b/commands_classifier/api/routes/examples.py index 2cda780..0e24fce 100644 --- a/commands_classifier/api/routes/examples.py +++ b/commands_classifier/api/routes/examples.py @@ -36,7 +36,7 @@ class ExampleResponse(BaseModel): command: str -@router.get("/examples", response_model=List[ExampleResponse]) +@router.get("/v1/examples", response_model=List[ExampleResponse]) async def get_examples(): """ Получает все примеры из базы данных. @@ -50,7 +50,7 @@ async def get_examples(): return [ExampleResponse(id=ex[0], text=ex[1], command=ex[2]) for ex in examples] -@router.post("/examples", response_model=ExampleResponse, status_code=201) +@router.post("/v1/examples", response_model=ExampleResponse, status_code=201) async def add_example(request: ExampleRequest): """ Добавляет новый пример в базу данных. @@ -79,7 +79,7 @@ async def add_example(request: ExampleRequest): raise HTTPException(status_code=500, detail=f"Ошибка при добавлении примера: {str(e)}") -@router.delete("/examples/{example_id}") +@router.delete("/v1/examples/{example_id}") async def delete_example(example_id: int): """ Удаляет пример по ID. @@ -102,7 +102,7 @@ async def delete_example(example_id: int): return {"message": f"Пример {example_id} успешно удален"} -@router.get("/examples/{example_id}", response_model=ExampleResponse) +@router.get("/v1/examples/{example_id}", response_model=ExampleResponse) async def get_example(example_id: int): """ Получает пример по ID. diff --git a/commands_classifier/api/routes/health.py b/commands_classifier/api/routes/health.py index 7838939..4dd3bf2 100644 --- a/commands_classifier/api/routes/health.py +++ b/commands_classifier/api/routes/health.py @@ -8,7 +8,7 @@ router = APIRouter(tags=["health"]) -@router.get("/health") +@router.get("/v1/health") async def health(response: Response): """ Проверка работоспособности сервера (TEI совместимый). @@ -37,7 +37,7 @@ async def health(response: Response): } -@router.get("/metrics") +@router.get("/v1/metrics") async def metrics(): """ Метрики сервера (TEI совместимый). diff --git a/commands_classifier/api/routes/load_from_hf.py b/commands_classifier/api/routes/load_from_hf.py index 24d1330..1d0a60e 100644 --- a/commands_classifier/api/routes/load_from_hf.py +++ b/commands_classifier/api/routes/load_from_hf.py @@ -158,7 +158,7 @@ def _run_load_from_hf_task(repo_id: str, local_dir: str, load_id: str): _load_from_hf_status["completed_at"] = datetime.now().isoformat() -@router.post("/load_from_hf", response_model=LoadFromHfResponse) +@router.post("/v1/load_from_hf", response_model=LoadFromHfResponse) async def load_from_hf(request: LoadFromHfRequest): """ Загружает модель с Hugging Face Hub. @@ -239,7 +239,7 @@ async def load_from_hf(request: LoadFromHfRequest): return LoadFromHfResponse(message="Загрузка модели запущена в фоновом режиме", load_id=load_id) -@router.get("/load_from_hf/status", response_model=LoadFromHfStatusResponse) +@router.get("/v1/load_from_hf/status", response_model=LoadFromHfStatusResponse) async def get_load_from_hf_status(): """ Возвращает статус загрузки модели. diff --git a/commands_classifier/api/routes/predict.py b/commands_classifier/api/routes/predict.py index b5b2ed9..c9ceea5 100644 --- a/commands_classifier/api/routes/predict.py +++ b/commands_classifier/api/routes/predict.py @@ -75,7 +75,7 @@ class PredictBatchResponse(BaseModel): confidences: Optional[List[float]] = None -@router.post("/embed", response_model=EmbedResponse) +@router.post("/v1/embed", response_model=EmbedResponse) async def embed(request: EmbedRequest): """ Получает эмбеддинги для текстов (TEI совместимый эндпоинт). @@ -106,7 +106,7 @@ async def embed(request: EmbedRequest): return EmbedResponse(embeddings=embeddings) -@router.post("/predict", response_model=PredictResponse) +@router.post("/v1/predict", response_model=PredictResponse) async def predict(request: PredictRequest): """ Классифицирует один текст в команду. @@ -136,7 +136,7 @@ async def predict(request: PredictRequest): raise HTTPException(status_code=500, detail=f"Ошибка при предсказании: {str(e)}") -@router.post("/predict/batch", response_model=PredictBatchResponse) +@router.post("/v1/predict/batch", response_model=PredictBatchResponse) async def predict_batch(request: PredictBatchRequest): """ Классифицирует список текстов в команды. diff --git a/commands_classifier/api/routes/training.py b/commands_classifier/api/routes/training.py index b42fb2d..1e96613 100644 --- a/commands_classifier/api/routes/training.py +++ b/commands_classifier/api/routes/training.py @@ -38,7 +38,7 @@ class ResetResponse(BaseModel): model_deleted: bool -@router.post("/train", response_model=TrainResponse) +@router.post("/v1/train", response_model=TrainResponse) async def train(request: TrainRequest): """ Запускает обучение модели в фоновом режиме. @@ -89,7 +89,7 @@ async def train(request: TrainRequest): raise HTTPException(status_code=500, detail=f"Ошибка при запуске обучения: {str(e)}") -@router.get("/train/status") +@router.get("/v1/train/status") async def get_training_status(): """ Возвращает статус текущего обучения. @@ -105,7 +105,7 @@ async def get_training_status(): return training_manager.get_status() -@router.post("/reset", response_model=ResetResponse) +@router.post("/v1/reset", response_model=ResetResponse) async def reset_training(): """ Полностью сбрасывает обучение: diff --git a/commands_classifier/api/server.py b/commands_classifier/api/server.py index 773169f..6e163a1 100644 --- a/commands_classifier/api/server.py +++ b/commands_classifier/api/server.py @@ -10,6 +10,7 @@ from commands_classifier import db from commands_classifier.api.routes import ( + command_feedback_router, examples_router, health_router, load_from_hf_router, @@ -133,9 +134,10 @@ async def lifespan(app: FastAPI): title="CVC API", description="API для классификации голосовых команд", lifespan=lifespan ) -# Подключаем роутеры +# Подключаем роутеры (версионирование указано в самих роутах) app.include_router(predict_router) app.include_router(training_router) app.include_router(examples_router) app.include_router(load_from_hf_router) app.include_router(health_router) +app.include_router(command_feedback_router) diff --git a/commands_classifier/client.py b/commands_classifier/client.py index f664e1c..02ea419 100644 --- a/commands_classifier/client.py +++ b/commands_classifier/client.py @@ -16,7 +16,7 @@ def __init__(self, base_url: str = "http://localhost:20001", use_proxy: bool = F Инициализирует клиент. Args: - base_url: Базовый URL API сервера (по умолчанию: http://localhost:20001) + base_url: Базовый URL сервера (по умолчанию: http://localhost:20001) use_proxy: Использовать ли системный прокси (по умолчанию: False) """ self.base_url = base_url.rstrip("/") @@ -40,7 +40,7 @@ def predict(self, text: str, return_confidence: bool = False) -> dict: Результат классификации """ response = self.session.post( - f"{self.base_url}/predict", json={"text": text, "return_confidence": return_confidence} + f"{self.base_url}/v1/predict", json={"text": text, "return_confidence": return_confidence} ) response.raise_for_status() return response.json() @@ -57,7 +57,7 @@ def predict_batch(self, texts: list, return_confidence: bool = False) -> dict: Результаты классификации """ response = self.session.post( - f"{self.base_url}/predict/batch", + f"{self.base_url}/v1/predict/batch", json={"texts": texts, "return_confidence": return_confidence}, ) response.raise_for_status() @@ -73,7 +73,7 @@ def embed(self, texts: list) -> dict: Returns: Эмбеддинги """ - response = self.session.post(f"{self.base_url}/embed", json={"inputs": texts}) + response = self.session.post(f"{self.base_url}/v1/embed", json={"inputs": texts}) response.raise_for_status() return response.json() @@ -107,19 +107,19 @@ def train( if learning_rate is not None: payload["learning_rate"] = learning_rate - response = self.session.post(f"{self.base_url}/train", json=payload) + response = self.session.post(f"{self.base_url}/v1/train", json=payload) response.raise_for_status() return response.json() def get_training_status(self) -> dict: """Получает статус обучения.""" - response = self.session.get(f"{self.base_url}/train/status") + response = self.session.get(f"{self.base_url}/v1/train/status") response.raise_for_status() return response.json() def get_examples(self) -> list: """Получает все примеры.""" - response = self.session.get(f"{self.base_url}/examples") + response = self.session.get(f"{self.base_url}/v1/examples") response.raise_for_status() return response.json() @@ -135,7 +135,7 @@ def add_example(self, text: str, command: str) -> dict: Созданный пример """ response = self.session.post( - f"{self.base_url}/examples", json={"text": text, "command": command} + f"{self.base_url}/v1/examples", json={"text": text, "command": command} ) response.raise_for_status() return response.json() @@ -150,7 +150,7 @@ def delete_example(self, example_id: int) -> dict: Returns: Результат удаления """ - response = self.session.delete(f"{self.base_url}/examples/{example_id}") + response = self.session.delete(f"{self.base_url}/v1/examples/{example_id}") response.raise_for_status() return response.json() @@ -164,19 +164,19 @@ def get_example(self, example_id: int) -> dict: Returns: Пример """ - response = self.session.get(f"{self.base_url}/examples/{example_id}") + response = self.session.get(f"{self.base_url}/v1/examples/{example_id}") response.raise_for_status() return response.json() def health(self) -> dict: """Проверяет работоспособность сервера.""" - response = self.session.get(f"{self.base_url}/health") + response = self.session.get(f"{self.base_url}/v1/health") response.raise_for_status() return response.json() def metrics(self) -> dict: """Получает метрики сервера.""" - response = self.session.get(f"{self.base_url}/metrics") + response = self.session.get(f"{self.base_url}/v1/metrics") response.raise_for_status() return response.json() @@ -189,7 +189,7 @@ def reset(self) -> dict: Returns: Результат сброса (reset_examples, model_deleted) """ - response = self.session.post(f"{self.base_url}/reset") + response = self.session.post(f"{self.base_url}/v1/reset") response.raise_for_status() return response.json() @@ -211,7 +211,7 @@ def load_from_hf(self, repo_id: Optional[str] = None, local_dir: Optional[str] = if local_dir: payload["local_dir"] = local_dir - response = self.session.post(f"{self.base_url}/load_from_hf", json=payload) + response = self.session.post(f"{self.base_url}/v1/load_from_hf", json=payload) response.raise_for_status() return response.json() @@ -222,7 +222,18 @@ def get_load_from_hf_status(self) -> dict: Returns: Статус загрузки (load_id, status, progress, local_path, error) """ - response = self.session.get(f"{self.base_url}/load_from_hf/status") + response = self.session.get(f"{self.base_url}/v1/load_from_hf/status") + response.raise_for_status() + return response.json() + + def get_command_feedback(self) -> list: + """ + Загружает репорт «исправить команду» из сервиса RDS-2P-Salute через CVC. + + Returns: + Список записей обратной связи (user_utterance, classified_function, created_at, ...) + """ + response = self.session.get(f"{self.base_url}/v1/command-feedback") response.raise_for_status() return response.json() @@ -374,7 +385,7 @@ def main(): "--url", type=str, default="http://localhost:20001", - help="URL API сервера (по умолчанию: http://localhost:20001)", + help="URL сервера (по умолчанию: http://localhost:20001)", ) subparsers = parser.add_subparsers(dest="command", help="Команды") @@ -445,6 +456,12 @@ def main(): # Команда load-from-hf-status subparsers.add_parser("load-from-hf-status", help="Проверить статус загрузки модели") + # Команда command-feedback — выгрузка некорректных выражений из RDS-2P-Salute + subparsers.add_parser( + "command-feedback", + help="Загрузить репорт «исправить команду» из RDS-2P-Salute (некорректные выражения)", + ) + args = parser.parse_args() if not args.command: @@ -571,6 +588,24 @@ def main(): except Exception as e: print(f"Ошибка: {e}", file=sys.stderr) sys.exit(1) + elif args.command == "command-feedback": + try: + client = CVCApiClient(args.url) + items = client.get_command_feedback() + print(json.dumps(items, indent=2, ensure_ascii=False)) + except requests.exceptions.HTTPError as e: + if e.response is not None: + try: + detail = e.response.json().get("detail", e.response.text) + except Exception: + detail = e.response.text + print(f"Ошибка: {detail}", file=sys.stderr) + else: + print(f"Ошибка: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Ошибка: {e}", file=sys.stderr) + sys.exit(1) if __name__ == "__main__": diff --git a/config.yaml b/config.yaml index d6800d4..dbaec57 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,11 @@ database: # Если указана директория, загружаются все CSV файлы из неё csv_migration_path: "data" +# Репорты «исправить команду» из RDS-2P-Salute (опционально) +# По умолчанию: http://rds-2p-salute-app:8000/v1/admin/command-feedback +# command_feedback: +# url: "http://rds-2p-salute-app:8000/v1/admin/command-feedback" + # Параметры обучения по умолчанию # Можно переопределить через HTTP API /train endpoint training: diff --git a/data/commands_extended.csv b/data/commands_extended.csv index c159553..aa4ba86 100644 --- a/data/commands_extended.csv +++ b/data/commands_extended.csv @@ -383,6 +383,69 @@ text,command попроси лечь,lie_down вели лечь,lie_down прикажи лечь,lie_down +положи робота,lie_down +положи панду,lie_down +положите робота,lie_down +положите панду,lie_down +положить робота,lie_down +положить панду,lie_down +положь робота,lie_down +положь панду,lie_down +положишь робота,lie_down +положишь панду,lie_down +положил бы робота,lie_down +положила бы панду,lie_down +положили бы робота,lie_down +надо положить робота,lie_down +нужно положить робота,lie_down +пора положить робота,lie_down +надо положить панду,lie_down +нужно положить панду,lie_down +пора положить панду,lie_down +можно положить робота,lie_down +можно положить панду,lie_down +давай положи робота,lie_down +давай положи панду,lie_down +уложить робота,lie_down +уложить панду,lie_down +уложи робота,lie_down +уложи панду,lie_down +уложите робота,lie_down +уложите панду,lie_down +уложишь робота,lie_down +уложил бы робота,lie_down +уложила бы панду,lie_down +надо уложить робота,lie_down +нужно уложить панду,lie_down +пора уложить робота,lie_down +давай уложи робота,lie_down +опустить робота,lie_down +опустить панду,lie_down +опусти робота,lie_down +опусти панду,lie_down +опустите робота,lie_down +опустите панду,lie_down +опустишь робота,lie_down +опустил бы робота,lie_down +надо опустить робота,lie_down +нужно опустить панду,lie_down +давай опусти робота,lie_down +скажи роботу положить робота,lie_down +скажи панде положить панду,lie_down +робот положи робота,lie_down +панда положи панду,lie_down +положи робота робот,lie_down +положи панду панда,lie_down +пожалуйста положи робота,lie_down +пожалуйста положи панду,lie_down +положи-ка робота,lie_down +уложи-ка робота,lie_down +ну-ка положи робота,lie_down +а ну положи робота,lie_down +эй положи робота,lie_down +быстро положи робота,lie_down +робот пожалуйста положи робота,lie_down +панда пожалуйста положи панду,lie_down кувыркнуться,rotate кувыркнись,rotate кувыркаться,rotate diff --git a/data/commands_special_commands.csv b/data/commands_special_commands.csv index 5397b01..7828fc1 100644 --- a/data/commands_special_commands.csv +++ b/data/commands_special_commands.csv @@ -140,3 +140,53 @@ text,command отвяжи панду панда,unbind пожалуйста отвяжи робота,unbind пожалуйста отвяжи панду,unbind +исправить команду,report_command +исправь команду,report_command +исправьте команду,report_command +исправлять команду,report_command +исправляй команду,report_command +исправляйте команду,report_command +исправишь команду,report_command +исправил бы команду,report_command +исправила бы команду,report_command +исправили бы команду,report_command +надо исправить команду,report_command +нужно исправить команду,report_command +пора исправить команду,report_command +можно исправить команду,report_command +давай исправь команду,report_command +поправить команду,report_command +поправь команду,report_command +поправьте команду,report_command +поправлять команду,report_command +поправляй команду,report_command +поправляйте команду,report_command +поправишь команду,report_command +поправил бы команду,report_command +надо поправить команду,report_command +нужно поправить команду,report_command +давай поправь команду,report_command +пожаловаться на команду,report_command +пожаловаться на неправильную команду,report_command +пожалуюсь на команду,report_command +пожаловался бы на команду,report_command +сообщить об ошибке в команде,report_command +сообщи об ошибке в команде,report_command +сообщите об ошибке в команде,report_command +сообщить об ошибке команды,report_command +сообщи об ошибке команды,report_command +ошибка в команде,report_command +неправильная команда,report_command +команда неправильная,report_command +команда работает неправильно,report_command +команда не та,report_command +не та команда,report_command +изменить команду,report_command +измени команду,report_command +измените команду,report_command +изменять команду,report_command +надо изменить команду,report_command +нужно изменить команду,report_command +давай измени команду,report_command +пожалуйста исправь команду,report_command +пожалуйста поправь команду,report_command diff --git a/data/unknown_base.csv b/data/unknown_base.csv index e533994..385705e 100644 --- a/data/unknown_base.csv +++ b/data/unknown_base.csv @@ -475,4 +475,6 @@ text,command олежи робота,unknown запусти робота,unknown робот запустись,unknown -опусти руку,unknown \ No newline at end of file +опусти руку,unknown +включи колонку,unknown +выключи колонку,unknown \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 32893ff..95dbf64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: networks: - robot-services-network healthcheck: - test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:20001/health', timeout=5).raise_for_status()"] + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:20001/v1/health', timeout=5).raise_for_status()"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/cicd_setup.md b/docs/cicd_setup.md index 45d5a78..20cf8c5 100644 --- a/docs/cicd_setup.md +++ b/docs/cicd_setup.md @@ -62,6 +62,12 @@ cat ~/.cvc_ssh_keys/deploy_key.pub Для переопределения параметров обучения из config.yaml ``` +4. **TELEGRAM_TOKEN**, **TELEGRAM_TO** (опционально) + ``` + Для уведомлений в Telegram при падении пайплайна. + Подробная настройка: см. docs/telegram_notifications.md + ``` + ## Шаг 4: Настройка self-hosted runner на GPU-машине Если вы используете GitHub Actions с self-hosted runner: @@ -216,9 +222,11 @@ cat ~/.cvc_ssh_keys/deploy_key.pub Скрипты уже созданы в директории `ci/`: - `ci/generate_ssh_key.sh` - генерация SSH-ключа (опционально, если нужен SSH для других задач) -- `ci/train_via_api.py` - запуск обучения модели через API +- `ci/train_via_api.py` - запуск обучения модели (напрямую, без вызова API) - `ci/upload_to_hf.py` - загрузка модели на Hugging Face Hub +**API:** все эндпоинты CVC версионированы префиксом `/v1` (например, `/v1/health`, `/v1/train`). Полный список — в [README](../README.md#эндпоинты-api-v1). + ## Шаг 6: Проверка workflow Workflow файл находится в `.github/workflows/deploy.yml`. Он автоматически: diff --git a/tests/api/test_examples.py b/tests/api/test_examples.py index b58b7eb..7e9020e 100644 --- a/tests/api/test_examples.py +++ b/tests/api/test_examples.py @@ -2,16 +2,16 @@ def test_get_examples_empty(client): - """GET /examples при пустой БД возвращает [].""" - response = client.get("/examples") + """GET /v1/examples при пустой БД возвращает [].""" + response = client.get("/v1/examples") assert response.status_code == 200 assert response.json() == [] def test_add_example_returns_201(client): - """POST /examples создаёт пример и возвращает 201 с id, text, command.""" + """POST /v1/examples создаёт пример и возвращает 201 с id, text, command.""" response = client.post( - "/examples", + "/v1/examples", json={"text": "лягись", "command": "lie_down"}, ) assert response.status_code == 201 @@ -22,9 +22,9 @@ def test_add_example_returns_201(client): def test_get_examples_after_add(client): - """GET /examples после добавления возвращает список с одним примером.""" - client.post("/examples", json={"text": "отмена", "command": "dismiss"}) - response = client.get("/examples") + """GET /v1/examples после добавления возвращает список с одним примером.""" + client.post("/v1/examples", json={"text": "отмена", "command": "dismiss"}) + response = client.get("/v1/examples") assert response.status_code == 200 examples = response.json() assert len(examples) == 1 @@ -33,32 +33,32 @@ def test_get_examples_after_add(client): def test_get_example_by_id(client): - """GET /examples/{id} возвращает пример по ID.""" - add_resp = client.post("/examples", json={"text": "неизвестно", "command": "unknown"}) + """GET /v1/examples/{id} возвращает пример по ID.""" + add_resp = client.post("/v1/examples", json={"text": "неизвестно", "command": "unknown"}) example_id = add_resp.json()["id"] - response = client.get(f"/examples/{example_id}") + response = client.get(f"/v1/examples/{example_id}") assert response.status_code == 200 assert response.json()["id"] == example_id assert response.json()["text"] == "неизвестно" def test_get_example_by_id_not_found(client): - """GET /examples/999 возвращает 404.""" - response = client.get("/examples/999") + """GET /v1/examples/999 возвращает 404.""" + response = client.get("/v1/examples/999") assert response.status_code == 404 def test_delete_example(client): - """DELETE /examples/{id} удаляет пример и возвращает 200.""" - add_resp = client.post("/examples", json={"text": "x", "command": "y"}) + """DELETE /v1/examples/{id} удаляет пример и возвращает 200.""" + add_resp = client.post("/v1/examples", json={"text": "x", "command": "y"}) example_id = add_resp.json()["id"] - response = client.delete(f"/examples/{example_id}") + response = client.delete(f"/v1/examples/{example_id}") assert response.status_code == 200 - get_resp = client.get(f"/examples/{example_id}") + get_resp = client.get(f"/v1/examples/{example_id}") assert get_resp.status_code == 404 def test_add_example_validation_empty_text(client): - """POST /examples с пустым text возвращает 422.""" - response = client.post("/examples", json={"text": "", "command": "cmd"}) + """POST /v1/examples с пустым text возвращает 422.""" + response = client.post("/v1/examples", json={"text": "", "command": "cmd"}) assert response.status_code == 422 diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 116749d..1ac69bb 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -1,9 +1,9 @@ -"""API-тесты для эндпоинтов /health и /metrics.""" +"""API-тесты для эндпоинтов /v1/health и /v1/metrics.""" def test_health_returns_200(client): - """GET /health возвращает 200 и структуру status, model_loaded, training_active.""" - response = client.get("/health") + """GET /v1/health возвращает 200 и структуру status, model_loaded, training_active.""" + response = client.get("/v1/health") assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" @@ -13,14 +13,14 @@ def test_health_returns_200(client): def test_health_model_not_loaded(client): """Без загруженной модели model_loaded = False.""" - response = client.get("/health") + response = client.get("/v1/health") assert response.status_code == 200 assert response.json()["model_loaded"] is False def test_metrics_returns_200(client): - """GET /metrics возвращает 200 и счётчики примеров.""" - response = client.get("/metrics") + """GET /v1/metrics возвращает 200 и счётчики примеров.""" + response = client.get("/v1/metrics") assert response.status_code == 200 data = response.json() assert "total_examples" in data @@ -32,6 +32,6 @@ def test_metrics_returns_200(client): def test_metrics_empty_db(client): """При пустой БД total_examples = 0.""" - response = client.get("/metrics") + response = client.get("/v1/metrics") assert response.status_code == 200 assert response.json()["total_examples"] == 0 diff --git a/tests/api/test_predict.py b/tests/api/test_predict.py index e3427eb..8775c79 100644 --- a/tests/api/test_predict.py +++ b/tests/api/test_predict.py @@ -2,8 +2,8 @@ def test_predict_without_model_returns_503(client): - """POST /predict без загруженной модели возвращает 503.""" - response = client.post("/predict", json={"text": "лягись"}) + """POST /v1/predict без загруженной модели возвращает 503.""" + response = client.post("/v1/predict", json={"text": "лягись"}) assert response.status_code == 503 assert ( "модель" in response.json().get("detail", "").lower() @@ -12,8 +12,8 @@ def test_predict_without_model_returns_503(client): def test_predict_with_mock_classifier_returns_200(client_with_mock_classifier): - """POST /predict с мок-классификатором возвращает 200 и command.""" - response = client_with_mock_classifier.post("/predict", json={"text": "лягись"}) + """POST /v1/predict с мок-классификатором возвращает 200 и command.""" + response = client_with_mock_classifier.post("/v1/predict", json={"text": "лягись"}) assert response.status_code == 200 data = response.json() assert "command" in data @@ -21,9 +21,9 @@ def test_predict_with_mock_classifier_returns_200(client_with_mock_classifier): def test_predict_with_confidence(client_with_mock_classifier): - """POST /predict с return_confidence=True возвращает confidence.""" + """POST /v1/predict с return_confidence=True возвращает confidence.""" response = client_with_mock_classifier.post( - "/predict", + "/v1/predict", json={"text": "лягись", "return_confidence": True}, ) assert response.status_code == 200 @@ -34,15 +34,15 @@ def test_predict_with_confidence(client_with_mock_classifier): def test_predict_batch_without_model_returns_503(client): - """POST /predict/batch без модели возвращает 503.""" - response = client.post("/predict/batch", json={"texts": ["a", "b"]}) + """POST /v1/predict/batch без модели возвращает 503.""" + response = client.post("/v1/predict/batch", json={"texts": ["a", "b"]}) assert response.status_code == 503 def test_predict_batch_with_mock_classifier(client_with_mock_classifier): - """POST /predict/batch с моком возвращает список commands.""" + """POST /v1/predict/batch с моком возвращает список commands.""" response = client_with_mock_classifier.post( - "/predict/batch", + "/v1/predict/batch", json={"texts": ["лягись", "отмена"]}, ) assert response.status_code == 200 @@ -52,7 +52,7 @@ def test_predict_batch_with_mock_classifier(client_with_mock_classifier): def test_embed_returns_200(client): """POST /embed без модели создаёт временный классификатор и возвращает эмбеддинги (или 200).""" - response = client.post("/embed", json={"inputs": ["hello"]}) + response = client.post("/v1/embed", json={"inputs": ["hello"]}) # Может быть 200 (если эндпоинт создаёт временную модель по config) или 503/500 при отсутствии модели assert response.status_code in (200, 503, 500) if response.status_code == 200: @@ -62,8 +62,8 @@ def test_embed_returns_200(client): def test_embed_with_mock_classifier(client_with_mock_classifier): - """POST /embed с мок-классификатором возвращает эмбеддинги.""" - response = client_with_mock_classifier.post("/embed", json={"inputs": ["hello", "world"]}) + """POST /v1/embed с мок-классификатором возвращает эмбеддинги.""" + response = client_with_mock_classifier.post("/v1/embed", json={"inputs": ["hello", "world"]}) assert response.status_code == 200 data = response.json() assert "embeddings" in data @@ -71,6 +71,6 @@ def test_embed_with_mock_classifier(client_with_mock_classifier): def test_predict_validation_empty_text(client_with_mock_classifier): - """POST /predict с пустым text возвращает 422.""" - response = client_with_mock_classifier.post("/predict", json={"text": ""}) + """POST /v1/predict с пустым text возвращает 422.""" + response = client_with_mock_classifier.post("/v1/predict", json={"text": ""}) assert response.status_code == 422 diff --git a/tests/api/test_training.py b/tests/api/test_training.py index c102741..8863552 100644 --- a/tests/api/test_training.py +++ b/tests/api/test_training.py @@ -1,9 +1,9 @@ -"""API-тесты для эндпоинтов /train, /train/status, /reset.""" +"""API-тесты для эндпоинтов /v1/train, /v1/train/status, /v1/reset.""" def test_train_returns_200(client): - """POST /train с моком TrainingManager возвращает 200 и training_id.""" - response = client.post("/train", json={}) + """POST /v1/train с моком TrainingManager возвращает 200 и training_id.""" + response = client.post("/v1/train", json={}) assert response.status_code == 200 data = response.json() assert "training_id" in data @@ -12,9 +12,9 @@ def test_train_returns_200(client): def test_train_with_params(client): - """POST /train с параметрами принимает их.""" + """POST /v1/train с параметрами принимает их.""" response = client.post( - "/train", + "/v1/train", json={ "num_iterations": 5, "num_epochs": 1, @@ -28,8 +28,8 @@ def test_train_with_params(client): def test_train_status_returns_200(client): - """GET /train/status возвращает 200 и статус.""" - response = client.get("/train/status") + """GET /v1/train/status возвращает 200 и статус.""" + response = client.get("/v1/train/status") assert response.status_code == 200 data = response.json() assert "status" in data @@ -37,8 +37,8 @@ def test_train_status_returns_200(client): def test_reset_returns_200(client): - """POST /reset возвращает 200 и reset_examples, model_deleted.""" - response = client.post("/reset") + """POST /v1/reset возвращает 200 и reset_examples, model_deleted.""" + response = client.post("/v1/reset") assert response.status_code == 200 data = response.json() assert "message" in data diff --git a/tests/conftest.py b/tests/conftest.py index b588b46..2b3c198 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from commands_classifier import db from commands_classifier.api.routes import ( + command_feedback_router, examples_router, health_router, load_from_hf_router, @@ -119,6 +120,7 @@ def app(temp_db_path, temp_model_dir): test_app.include_router(examples_router) test_app.include_router(load_from_hf_router) test_app.include_router(health_router) + test_app.include_router(command_feedback_router) return test_app @@ -133,6 +135,7 @@ def app_with_mock_classifier(temp_db_path, temp_model_dir): test_app.include_router(examples_router) test_app.include_router(load_from_hf_router) test_app.include_router(health_router) + test_app.include_router(command_feedback_router) return test_app diff --git a/tests/integration/test_e2e_docker.py b/tests/integration/test_e2e_docker.py index f7be31e..95f9a02 100644 --- a/tests/integration/test_e2e_docker.py +++ b/tests/integration/test_e2e_docker.py @@ -11,6 +11,7 @@ from commands_classifier import db from commands_classifier.api.routes import ( + command_feedback_router, examples_router, health_router, load_from_hf_router, @@ -85,6 +86,7 @@ def e2e_app(temp_db_path, temp_model_dir): app.include_router(examples_router) app.include_router(load_from_hf_router) app.include_router(health_router) + app.include_router(command_feedback_router) return app @@ -98,8 +100,8 @@ def e2e_client(e2e_app, temp_db_path, temp_model_dir): def test_e2e_health(e2e_client): - """GET /health возвращает 200 и структуру.""" - response = e2e_client.get("/health") + """GET /v1/health возвращает 200 и структуру.""" + response = e2e_client.get("/v1/health") assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" @@ -107,8 +109,8 @@ def test_e2e_health(e2e_client): def test_e2e_predict_without_model_fails(e2e_client): - """POST /predict без обученной модели возвращает ошибку (503).""" - response = e2e_client.post("/predict", json={"text": "лягись"}) + """POST /v1/predict без обученной модели возвращает ошибку (503).""" + response = e2e_client.post("/v1/predict", json={"text": "лягись"}) assert response.status_code == 503 @@ -118,7 +120,7 @@ def test_e2e_train_then_predict_then_reset_then_predict_fails(e2e_client): """ # Запуск обучения (минимальные итерации для скорости) train_resp = e2e_client.post( - "/train", + "/v1/train", json={"num_iterations": 2, "num_epochs": 1, "batch_size": 32}, ) assert train_resp.status_code == 200 @@ -130,7 +132,7 @@ def test_e2e_train_then_predict_then_reset_then_predict_fails(e2e_client): step = 5 elapsed = 0 while elapsed < max_wait: - status_resp = e2e_client.get("/train/status") + status_resp = e2e_client.get("/v1/train/status") assert status_resp.status_code == 200 status_data = status_resp.json() if status_data.get("status") == "completed": @@ -144,7 +146,7 @@ def test_e2e_train_then_predict_then_reset_then_predict_fails(e2e_client): # Predict по трём строкам из датасета (по одной на класс) for text in SAMPLE_TEXTS_BY_CLASS: - pred_resp = e2e_client.post("/predict", json={"text": text}) + pred_resp = e2e_client.post("/v1/predict", json={"text": text}) assert pred_resp.status_code == 200, f"predict для '{text}' вернул {pred_resp.status_code}" data = pred_resp.json() assert "command" in data @@ -153,10 +155,10 @@ def test_e2e_train_then_predict_then_reset_then_predict_fails(e2e_client): ) # Сброс модели - reset_resp = e2e_client.post("/reset") + reset_resp = e2e_client.post("/v1/reset") assert reset_resp.status_code == 200 assert "reset_examples" in reset_resp.json() # После reset predict снова должен вернуть ошибку (модель выгружена) - predict_after_reset = e2e_client.post("/predict", json={"text": "лягись"}) + predict_after_reset = e2e_client.post("/v1/predict", json={"text": "лягись"}) assert predict_after_reset.status_code == 503