раздел 05 · шаг 5/7

Шаг 5. AI-фича через Claude API

Ключевой шаг проекта. При создании задачи бэк зовёт Anthropic Claude Haiku 4.5, который определяет категорию по заголовку. "купить молоко" -> personal, "доделать отчёт" -> work, "почитать про SQLAlchemy" -> learning.

Что делаем

  • Устанавливаем anthropic SDK
  • Создаём сервис категоризации с prompt caching на system-промпте
  • Подключаем сервис в POST /api/tasks
  • Делаем graceful degradation: если API упал - категория other, ошибку логируем
  • Тестируем руками

Где взять API-ключ

  1. Идёте на console.anthropic.com
  2. Sign up / Sign in
  3. Settings -> API Keys -> Create Key
  4. Копируете ключ (sk-ant-...) - больше не покажут

Кладёте ключ в .env в корне проекта:

ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxx

Убедитесь, что .env в .gitignore (мы его добавили на шаге 1).

Промпт для Claude

Добавь AI-категоризацию задач в backend/.

Требования:
- Установи anthropic SDK: добавь в requirements.txt anthropic>=0.40.0 и установи
- Создай backend/app/services/categorizer.py с функцией:
    async def categorize(title: str) -> str
- Используй модель claude-haiku-4-5 через AsyncAnthropic клиент
- API-ключ берётся из env: ANTHROPIC_API_KEY (через python-dotenv или os.getenv)
- System prompt должен быть кешируемым (cache_control: ephemeral, type: "ephemeral")
- В system prompt чётко перечисли категории и критерии:
    work - рабочие задачи, встречи, отчёты, проекты, дедлайны
    personal - быт, покупки, семья, дом, личные дела
    learning - учёба, чтение, курсы, изучение технологий
    health - спорт, врачи, лекарства, питание, сон
    other - всё остальное, что не подпадает явно
- User-message: только title задачи
- Ответ Claude - одно слово из списка категорий
- Парсинг: если ответ не входит в список - вернуть "other"
- Обработка ошибок: anthropic.APIError, asyncio.TimeoutError, любая другая -
  логируем через logging.warning, возвращаем "other", НЕ падаем
- Timeout 10 секунд через anthropic клиент

В POST /api/tasks:
- После создания записи в БД вызывай categorize(task.title)
- Сохраняй результат в task.category, делай commit
- Возвращай в ответе уже с категорией

Тесты:
- В tests/conftest.py добавь фикстуру monkeypatch для categorize,
  чтобы по умолчанию в тестах не звался реальный API
- Один интеграционный тест с моком: categorize мокается чтобы вернуть "work",
  POST задачи возвращает category: "work"

Не забудь обновить .env.example: добавь ANTHROPIC_API_KEY=sk-ant-...

Что Claude создаст

Файл backend/app/services/categorizer.py будет примерно таким:

import logging
import os
from anthropic import AsyncAnthropic, APIError

logger = logging.getLogger(__name__)

ALLOWED_CATEGORIES = {"work", "personal", "learning", "health", "other"}

SYSTEM_PROMPT = """You are a task categorizer. Given a task title, respond with exactly one word from this list:

- work: job tasks, meetings, reports, projects, deadlines
- personal: errands, shopping, family, home, personal affairs
- learning: studying, reading, courses, exploring technology
- health: sports, doctors, medication, nutrition, sleep
- other: anything that doesn't clearly fit above

Respond with ONLY the category name, no explanation, no punctuation."""

_client: AsyncAnthropic | None = None


def get_client() -> AsyncAnthropic:
    global _client
    if _client is None:
        _client = AsyncAnthropic(
            api_key=os.environ["ANTHROPIC_API_KEY"],
            timeout=10.0,
        )
    return _client


async def categorize(title: str) -> str:
    try:
        client = get_client()
        message = await client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=20,
            system=[
                {
                    "type": "text",
                    "text": SYSTEM_PROMPT,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            messages=[{"role": "user", "content": title}],
        )
        text = message.content[0].text.strip().lower()
        if text in ALLOWED_CATEGORIES:
            return text
        logger.warning("Unknown category from Claude: %s", text)
        return "other"
    except APIError as e:
        logger.warning("Anthropic API error: %s", e)
        return "other"
    except Exception as e:
        logger.exception("Unexpected categorize error: %s", e)
        return "other"

В роуте POST /api/tasks появится:

from app.services.categorizer import categorize

@router.post("", response_model=TaskRead, status_code=201)
async def create_task(
    payload: TaskCreate,
    session: AsyncSession = Depends(get_session),
):
    task = Task(title=payload.title)
    session.add(task)
    await session.flush()

    task.category = await categorize(task.title)

    await session.commit()
    await session.refresh(task)
    return task

Установка зависимостей

source venv/bin/activate
cd backend
pip install -r requirements.txt

Загрузка .env

Чтобы FastAPI видел ANTHROPIC_API_KEY, нужно либо использовать python-dotenv, либо запускать uvicorn с подгрузкой:

# из корня проекта, чтобы .env подхватился
set -a && source .env && set +a
cd backend
uvicorn app.main:app --reload

Или поставьте python-dotenv и загружайте в app/main.py:

from dotenv import load_dotenv
load_dotenv()

Тест

curl -X POST http://localhost:8000/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"купить молоко"}'

Ответ:

{
  "id": 1,
  "title": "купить молоко",
  "category": "personal",
  "done": false,
  "created_at": "2026-05-27T10:30:00"
}

Попробуйте разные:

  • "доделать квартальный отчёт" -> work
  • "почитать главу про async в Python" -> learning
  • "записаться к стоматологу" -> health
  • "asdfgh" -> other (Claude поймёт что мусор)

Prompt caching: что важно

System prompt пометили cache_control: ephemeral. Это значит, что после первого вызова Anthropic кеширует промпт на 5 минут и следующие запросы дешевле в разы (для Haiku - в 10 раз дешевле на закешированных токенах).

В ответе SDK можно посмотреть usage:

print(message.usage.cache_read_input_tokens)      # из кеша
print(message.usage.cache_creation_input_tokens)  # положили в кеш

Для эффективности кеша блок должен быть >= 1024 токенов. Наш SYSTEM_PROMPT короче, поэтому реальная экономия включится если вы расширите его примерами (few-shot examples) - они и нужны для качества, и для длины блока.

Проверка тестов

cd backend
pytest -v

Все существующие тесты должны проходить, потому что в conftest.py мы замокали categorize. Реальный API в тестах не вызывается.

Точка сохранения

cd ..
git add .
git commit -m "step 5: AI categorization via Anthropic Claude Haiku 4.5"

Полезные ссылки