Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: ML Pipeline - Train and Publish

on:
push:
branches: [ main, master, dev ] # Триггер на push в main/master/dev (новый датасет)
branches: [ main, dev ] # Триггер на push в main/master/dev (новый датасет)
workflow_dispatch: # Позволяет запускать вручную через GitHub UI
inputs:
run_training:
Expand Down Expand Up @@ -69,7 +69,7 @@ jobs:
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm cvc-dev pytest tests/ -v --tb=short --cov=commands_classifier --cov-report=term-missing
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm cvc-dev pytest tests/ -v --tb=short --cov=app --cov-report=term-missing

# Запускается только при push с меткой [retrain] в сообщении коммита или при ручном запуске (с опцией run_training)
train-and-publish:
Expand Down Expand Up @@ -262,7 +262,7 @@ jobs:
disable_notification: true
message: |
*Пайплайн прошёл успешно*
Репо: `${{ github.repository }}`
Репозиторий: `${{ github.repository }}`
Ветка: `${{ github.ref_name }}`
[Открыть run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

Expand All @@ -280,6 +280,6 @@ jobs:
format: markdown
message: |
*Пайплайн упал*
Репо: `${{ github.repository }}`
Репозиторий: `${{ github.repository }}`
Ветка: `${{ github.ref_name }}`
[Открыть run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
69 changes: 69 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Публикация образа в GHCR после успешного прохождения основного пайплайна на main

name: Publish Docker image

on:
workflow_run:
workflows: ["ML Pipeline - Train and Publish"]
types: [completed]
branches: [main]

permissions:
contents: read
packages: write

jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
name: Build and push to GHCR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set image name
run: echo "IMAGE_BASE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/cvc-api" >> $GITHUB_ENV

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_BASE }}:main
${{ env.IMAGE_BASE }}:${{ github.event.workflow_run.head_sha }}
push: true

notify-telegram-on-publish:
name: Notify Telegram on image published
if: always() && needs.publish.result == 'success'
needs: [publish]
runs-on: ubuntu-latest
steps:
- name: Send Telegram notification (silent)
uses: appleboy/telegram-action@v1.0.1
continue-on-error: true
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
disable_notification: true
message: |
*Образ CVC успешно опубликован в GHCR*
Репозиторий: `${{ github.repository }}`
Ветка: `${{ github.event.workflow_run.head_branch }}`
Образ: `ghcr.io/${{ github.repository_owner }}/cvc-api:main`
[Открыть run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ RUN pip install --root-user-action=ignore --upgrade pip setuptools wheel
COPY requirements-docker.txt .
RUN pip install --root-user-action=ignore -r requirements-docker.txt

COPY commands_classifier/ ./commands_classifier/
COPY app/ ./app/
COPY config.yaml .
COPY pytest.ini .
COPY data/ ./data/
Expand All @@ -24,4 +24,4 @@ RUN mkdir -p models checkpoints

EXPOSE 20001

CMD ["python", "-m", "commands_classifier.cli", "serve", "--host", "0.0.0.0", "--port", "20001"]
CMD ["python", "-m", "app.main", "--host", "0.0.0.0", "--port", "20001"]
59 changes: 39 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# CVC - Classification of Voice Commands

[![ML Pipeline](https://github.com/ShiWarai/CVC/actions/workflows/deploy.yml/badge.svg)](https://github.com/ShiWarai/CVC/actions/workflows/deploy.yml)
[![ML Pipeline](https://github.com/ShiWarai/CVC/actions/workflows/deploy.yml/badge.svg?branch=main)](https://github.com/ShiWarai/CVC/actions/workflows/deploy.yml)
[![License: MIT](https://img.shields.io/github/license/ShiWarai/CVC)](https://opensource.org/licenses/MIT)
![Python Version](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue)
![Docker Ready](https://img.shields.io/badge/docker-ready-blue?logo=docker)
[![CVC-Panda on Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97%20CVC--Panda-Model-yellow)](https://huggingface.co/ShiWarai/CVC-Panda)

Мини-сервис для классификации голосовых команд (SetFit). Обучает модель на малом датасете и классифицирует текстовые команды. Создан для использования в проекте навыка для Sber Salute.
Мини-сервис для классификации голосовых команд (SetFit). Обучает модель на малом датасете и классифицирует текстовые команды. Создан для использования в проекте навыка для команд роботу-собаке.

## Стек технологий

Expand All @@ -29,7 +29,7 @@
| [Использование](#использование) | CLI, Python-клиент, библиотека |
| [Конфигурация и API](#конфигурация-и-api) | config.yaml, эндпоинты |
| [Данные](#данные) | Формат датасета, параметры обучения |
| [Разработка](#разработка) | Тесты, линт, структура проекта |
| [Разработка](#разработка) | Тесты, линт, архитектура, структура проекта |
| [CI/CD](#cicd) | Пайплайн и ссылка на настройку |
| [Лицензия](#лицензия) | MIT |

Expand Down Expand Up @@ -73,10 +73,10 @@ HF_REPO_ID=your-username/model-name
### Локальный запуск

```bash
python -m commands_classifier.cli serve
python -m app.main
```

Опции: `--host`, `--port`, `--config`. БД создаётся при первом запуске, данные из `data/` или CSV из `config.yaml`.
Опции: `--host`, `--port`, `--config`, `--reload`. БД создаётся при первом запуске, данные из `data/` или CSV из `config.yaml`.

## Использование

Expand All @@ -85,19 +85,19 @@ python -m commands_classifier.cli serve
После запуска сервера (Docker или локально):

```bash
python -m commands_classifier.client predict --text "равняйся" [--show-confidence]
python -m commands_classifier.client predict --file commands.txt
python -m commands_classifier.client train [--batch-size 32 --iterations 30]
python -m commands_classifier.client train-status
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
python -m app.client predict --text "равняйся" [--show-confidence]
python -m app.client predict --file commands.txt
python -m app.client train [--batch-size 32 --iterations 30]
python -m app.client train-status
python -m app.client examples list
python -m app.client examples add --text "команда" --command "label"
python -m app.client examples delete --id 1
python -m app.client health
python -m app.client metrics
python -m app.client reset
python -m app.client load-from-hf [--repo-id "username/model-name"]
python -m app.client load-from-hf-status
python -m app.client command-feedback # репорт «исправить команду» из RDS-2P-Salute
```

По умолчанию клиент подключается к `http://localhost:20001` (флаг `--url` для другого адреса).
Expand Down Expand Up @@ -185,16 +185,35 @@ docker compose -f docker-compose.yml build cvc-api
docker compose -f docker-compose.yml -f docker-compose.dev.yml build cvc-dev

docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm cvc-dev ruff check .
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm cvc-dev pytest tests/ -v --tb=short --cov=commands_classifier --cov-report=term-missing
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm cvc-dev pytest tests/ -v --tb=short --cov=app --cov-report=term-missing
```

### Архитектура

Проект построен по принципам чистой архитектуры (слои не зависят от деталей доставки и инфраструктуры).

| Слой | Назначение |
|------|------------|
| **domain** | Сущности (`Example`, `PredictionResult`, `TrainingStatus`), порты (`IClassifier`, `IExampleRepository`), утилиты (`text_utils`). Без внешних зависимостей. |
| **application** | Сценарии (use cases): предсказание (`PredictUseCase`), работа с примерами (`ExamplesUseCase`). Получают зависимости через конструктор. |
| **adapters** | Реализации портов: **persistence** — SQLite-репозиторий примеров; **ml** — SetFit-классификатор и retry для HF; **data_loading** — загрузка датасета из CSV/JSON. |
| **api** | FastAPI-приложение, роуты, глобальное состояние (state). В `init_app()` собираются use cases и адаптеры (composition root). |

Точка входа сервера: `main.py` → `app.api.server`; CLI к API: `client.py`.

### Структура проекта

```
CVC/
├── config.yaml
├── requirements-docker.txt | requirements-cuda.txt | requirements-rocm.txt
├── commands_classifier/ # Код: model, dataset, db, cli, client, api/
├── app/ # Точка входа: python -m app.main
│ ├── main.py # Запуск сервера
│ ├── domain/ # Сущности, порты, text_utils
│ ├── application/ # Use cases
│ ├── adapters/ # persistence (SQLite), ml (SetFit), data_loading
│ ├── api/ # FastAPI, роуты, state
│ └── client.py # HTTP-клиент и библиотека
├── data/ # CSV/JSON для миграции
├── models/ # Сохранённые модели
├── db/ # SQLite (training_data.db)
Expand Down
File renamed without changes.
38 changes: 38 additions & 0 deletions app/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Адаптеры: реализация портов domain (persistence, ml, загрузка данных)."""

from app.adapters.data_loading import load_dataset
from app.adapters.ml import CommandsClassifier, retry_hf
from app.adapters.persistence import (
SqliteExampleRepository,
add_example,
check_connection,
count_examples,
delete_example,
get_all_examples,
get_example_by_id,
get_examples_for_training,
get_trained_examples_by_labels,
get_training_stats,
init_db,
mark_examples_as_trained,
reset_training_status,
)

__all__ = [
"SqliteExampleRepository",
"init_db",
"add_example",
"get_all_examples",
"get_example_by_id",
"delete_example",
"count_examples",
"get_examples_for_training",
"get_trained_examples_by_labels",
"mark_examples_as_trained",
"get_training_stats",
"reset_training_status",
"check_connection",
"CommandsClassifier",
"retry_hf",
"load_dataset",
]
5 changes: 5 additions & 0 deletions app/adapters/data_loading/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Загрузка датасетов с диска (CSV/JSON)."""

from app.adapters.data_loading.dataset import load_dataset

__all__ = ["load_dataset"]
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Утилиты для загрузки и подготовки датасетов."""
"""Загрузка датасета из CSV или JSON файла."""

import json
from pathlib import Path
Expand All @@ -21,30 +21,22 @@ def load_dataset(dataset_path: str) -> Tuple[List[str], List[str]]:
ValueError: Если формат файла не поддерживается
"""
path = Path(dataset_path)

if not path.exists():
raise FileNotFoundError(f"Файл датасета не найден: {dataset_path}")

if path.suffix.lower() == ".csv":
df = pd.read_csv(dataset_path)

# Проверяем наличие нужных колонок
if "text" not in df.columns or "command" not in df.columns:
raise ValueError(
"CSV файл должен содержать колонки 'text' и 'command'. "
f"Найдены колонки: {list(df.columns)}"
)

texts = df["text"].astype(str).tolist()
labels = df["command"].astype(str).tolist()

elif path.suffix.lower() == ".json":
with open(dataset_path, "r", encoding="utf-8") as f:
data = json.load(f)

# Поддерживаем два формата JSON:
# 1. Список объектов: [{"text": "...", "command": "..."}, ...]
# 2. Объект с ключами: {"texts": [...], "commands": [...]}
if isinstance(data, list):
texts = [item["text"] for item in data]
labels = [item["command"] for item in data]
Expand Down
6 changes: 6 additions & 0 deletions app/adapters/ml/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""ML-адаптер: SetFit-классификатор и retry для Hugging Face."""

from app.adapters.ml.hf_retry import retry_hf
from app.adapters.ml.setfit_classifier import CommandsClassifier

__all__ = ["retry_hf", "CommandsClassifier"]
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

T = TypeVar("T")

# Задержки в секундах: 1, 2, 4
DEFAULT_BACKOFF = (1.0, 2.0, 4.0)


Expand All @@ -20,7 +19,7 @@ def retry_hf(
Args:
fn: Безаргументный callable (например, lambda: from_pretrained(...)).
max_retries: Максимальное число попыток (включая первую).
backoff: Кортеж задержек в секундах между попытками (длина должна быть >= max_retries - 1).
backoff: Кортеж задержек в секундах между попытками.

Returns:
Результат вызова fn().
Expand Down
Loading
Loading