- BrainTools - https://www.braintools.ru -

Open-Source AI Фитнес-Тренер: 27 MCP-инструментов, 3 провайдера и граф упражнений

Дисклеймер: это open source, в нем могут быть недостатки, заходите, предлагайте идеи, исправления. Публикую тут в ознакомительных и образовательных целях. Выпилил этот кусок в open source из части личного проекта, о котором писал тут [1]. Весь код писал полностью Claude Code на Opus 4.5 с thinking режимом.

Выделили из production-проекта и открыли в open-source PWA-приложение для персонального фитнес-коучинга с AI. Пользователь общается с тренером через чат, а тот создаёт программы тренировок, отслеживает прогресс, предлагает альтернативные упражнения.

В статье:

  • Multi-provider AI (Claude, GPT, Ollama) – переключается одной переменной

  • 27 MCP-инструментов для управления тренировками

  • Knowledge Graph упражнений (NetworkX / Neo4j)

  • RAG-память с pgvector для долгосрочного контекста

  • PWA с offline-режимом

GitHub: https://github.com/gmen1057/fitness-coach [2] Лицензия: MIT

Open-Source AI Фитнес-Тренер: 27 MCP-инструментов, 3 провайдера и граф упражнений - 1

Почему не взять готовое?

Просто так захотелось. Использовать 12+ агентов с Claude Code CLI и сделать что-то для себя. По токенам не скажу, все в рамках подписки за 200$.

Нужно было:

  • AI-тренер, который помнит историю тренировок

  • Возможность модифицировать программу через естественный язык

  • Приватность (для параноиков) – опция запускать AI локально (Ollama)

  • Граф упражнений – “болит колено, чем заменить приседания?”

Архитектура

Общая схема

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Frontend  │────>│   Backend   │────>│  PostgreSQL │
│  (Next.js)  │ SSE │  (FastAPI)  │     │  + pgvector │
└─────────────┘     └──────┬──────┘     └─────────────┘
                           │
              ┌────────────┼────────────┐
              v            v            v
        ┌──────────┐ ┌──────���───┐ ┌──────────┐
        │ Anthropic│ │  OpenAI  │ │  Ollama  │
        │  Claude  │ │   GPT    │ │  (Local) │
        └──────────┘ └──────────┘ └──────────┘

Создание плана через чат

Пользователь: "Составь мне план для набора массы, 4 дня в неделю"
                                    │
                                    v
┌─────────────────────────────────────────────────────────────────┐
│                     AI AGENT (Claude/GPT/Ollama)                │
│                                                                 │
│  1. Анализирует запрос                                          │
│  2. Выбирает инструмент: create_full_plan                       │
│  3. Генерирует параметры:                                       │
│     {name: "Mass Building", goal: "hypertrophy",                │
│      weeks: 8, days_per_week: 4}                                │
└─────────────────────────────────────────────────────────────────┘
                                    │
                                    v
┌─────────────────────────────────────────────────────────────────┐
│                     MCP TOOL: create_full_plan                  │
│                                                                 │
│  - Создаёт план в PostgreSQL                                    │
│  - Генерирует 8 недель × 4 дня = 32 тренировки                  │
│  - Каждая тренировка: 5-8 упражнений из Knowledge Graph         │
│  - Возвращает структуру плана AI                                │
└─────────────────────────────────────────────────────────────────┘
                                    │
                                    v
┌─────────────────────────────────────────────────────────────────┐
│                     AI RESPONSE                                 │
│                                                                 │
│  "Создал план 'Mass Building' на 8 недель:                      │
│   - Понедельник: Грудь + Трицепс (8 упражнений)                 │
│   - Вторник: Спина + Бицепс (7 упражнений)                      │
│   - Четверг: Плечи + Пресс (6 упражнений)                       │
│   - Пятница: Ноги (8 упражнений)                                │
│                                                                 │
│   Хочешь посмотреть детали первой недели?"                      │
└─────────────────────────────────────────────────────────────────┘

Multi-Provider AI

Почему три провайдера?

Больше было лень

Провайдер

Лучше для

Стоимость

Приватность

Claude Sonnet 4.5

Качество, reasoning

$3-15/1M токенов

Облако

GPT-4o

Совместимость

$2.50-10/1M токенов

Облако

Ollama (llama3.3)

Приватность, offline

Бесплатно

100% локально

Возможно для кого-то будет открытием

OpenAI API – это стандарт. Один клиент:

┌─────────────────────────────────────────────────────────────────┐
│                    OpenAI-совместимый клиент                    │
└─────────────────────────────────────────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────��
        │                     │                     │
        v                     v                     v
┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│    OpenAI     │   │   Облачные    │   │   Локальные   │
│  GPT-4o/4.1   │   │   провайдеры  │   │   серверы     │
└───────────────┘   └───────────────┘   └───────────────┘
                           │                     │
              ┌────────────┼────────────┐        │
              v            v            v        v
         ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐
         │ Groq   │  │Together│  │ Open   │  │ vLLM   │
         │(быстро)│  │   AI   │  │ Router │  │LMStudio│
         └────────┘  └────────┘  └────────┘  └────────┘
              │            │           │
              v            v           v
         ┌────────┐  ┌────────┐  ┌────────┐
         │DeepSeek│  │Fireworks│ │100+ моделей│
         │Mistral │  │Perplexity│ │через один│
         └────────┘  └────────┘  │  endpoint │
                                 └────────┘

Что это значит на практике:

Провайдер

base_url

Зачем

OpenAI

api.openai.com [3]

Оригинал, GPT-4o/4.1

Groq

api.groq.com [4]

Llama 3.3 70B за 0.5 сек

Together AI

api.together.xyz [5]

Дешёвые open-source модели

DeepSeek

api.deepseek.com [6]

DeepSeek-V3, дёшево и качественно

OpenRouter

openrouter.ai [7]

100+ моделей, один API key

Fireworks

api.fireworks.ai [8]

Быстрый inference

vLLM

localhost:8000

Свой сервер с любой моделью

LM Studio

localhost:1234

Desktop app, GPU inference

Переключение провайдера

Меняем две переменные – base_url и api_key:

# Вариант 1: Anthropic (рекомендуется)
ANTHROPIC_API_KEY=sk-ant-api03-xxx

# Вариант 2: OpenAI
OPENAI_API_KEY=sk-proj-xxx

# Вариант 3: Локально
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3.3

Реализация абстракции

from typing import Protocol, AsyncIterator

class AIProvider(Protocol):
    """Протокол для AI-провайдеров"""
    
    async def chat_stream(
        self, 
        messages: list[dict], 
        tools: list[dict]
    ) -> AsyncIterator[StreamEvent]:
        """Стриминговый ответ с поддержкой tool calls"""
        ...

class AnthropicProvider:
    def __init__(self):
        self.client = Anthropic(api_key=settings.ANTHROPIC_API_KEY)
        self.model = settings.ANTHROPIC_MODEL or "claude-sonnet-4-5"
    
    async def chat_stream(self, messages, tools):
        async with self.client.messages.stream(
            model=self.model,
            messages=messages,
            tools=tools,
            max_tokens=4096
        ) as stream:
            async for event in stream:
                yield self._convert_event(event)

class OllamaProvider:
    def __init__(self):
        self.base_url = settings.OLLAMA_BASE_URL
        self.model = settings.OLLAMA_MODEL or "llama3.3"
    
    async def chat_stream(self, messages, tools):
        # Ollama не поддерживает native tool calling
        # Эмулируем через structured prompting
        enhanced_prompt = self._inject_tools_into_prompt(messages, tools)
        async for chunk in self._stream_completion(enhanced_prompt):
            yield self._parse_tool_calls(chunk)

Multi-Provider Fallback

Если основной провайдер недоступен – автоматический fallback:

class AIRouter:
    def __init__(self):
        self.providers = [
            AnthropicProvider(),
            OpenAIProvider(),
            OllamaProvider()
        ]
    
    async def chat_stream(self, messages, tools):
        for provider in self.providers:
            try:
                async for event in provider.chat_stream(messages, tools):
                    yield event
                return
            except (APIError, ConnectionError) as e:
                logger.warning(f"{provider.__class__.__name__} failed: {e}")
                continue
        
        raise AllProvidersFailedError("No AI providers available")

27 MCP-инструментов

Зачем?

Первая версия имела 6 инструментов. Проблема: AI делал 6-10 последовательных вызовов для создания одной программы:

P.S. На своем личном я использую Claude Agent SDK, там работает немного иначе

v1.0 (6 инструментов):
create_plan → create_week → create_day → create_exercise → create_exercise → ...
Итого: 20-30 tool calls для одной программы
Время: 15-25 секунд

Добавили batch-инструменты:

v2.0 (27 инструментов):
create_full_plan (создаёт всё за 1 вызов)
Итого: 1-3 tool calls
Время: 3-5 секунд

Категории инструментов

# 1. БАЗОВЫЕ (7 штук) - чтение данных
tools_basic = [
    "get_workout_plans",      # Список планов пользователя
    "get_plan_details",       # Детали конкретного плана
    "get_current_workout",    # Сегодняшняя тренировка
    "get_workout_stats",      # Статистика (streaks, completion rate)
    "get_week_details",       # Детали недели
    "get_day_exercises",      # Упражнения дня
    "get_workout_history",    # История выполненных тренировок
]

# 2. CRUD (8 штук) - создание и редактирование
tools_crud = [
    "create_workout_plan",    # Создать пустой план
    "edit_workout_plan",      # Изменить название/цель
    "create_week",            # Добавить неделю
    "create_day",             # Добавить день
    "add_exercise",           # Добавить упражнение
    "edit_exercise",          # Изменить упражнение
    "delete_exercise",        # Удалить упражнение
    "reorder_exercises",      # Изменить порядок
]

# 3. BATCH (2 штуки) - массовые операции
tools_batch = [
    "create_full_plan",       # Создать полный план за 1 вызов
    "create_full_week",       # Создать полную неделю за 1 вызов
]

# 4. GRAPH (4 штуки) - работа с графом упражнений
tools_graph = [
    "get_exercise_alternatives",  # "Чем заменить жим лёжа?"
    "get_exercise_progressions",  # "Как усложнить отжимания?"
    "get_exercises_for_muscle",   # "Упражнения на бицепс"
    "get_exercise_info",          # Детали упражнения
]

# 5. RAG (2 штуки) - долгосрочная память
tools_rag = [
    "search_workout_memory",  # "Что я делал в прошлом месяце?"
    "store_training_insight", # Сохранить инсайт
]

# 6. STATUS (3 штуки) - логирование
tools_status = [
    "complete_workout_day",   # Отметить день выполненным
    "skip_workout_day",       # Пропустить с причиной
    "add_exercise_note",      # Добавить заметку
]

# 7. PROGRAMS (1 штука)
tools_programs = [
    "get_training_programs",  # Библиотека готовых программ
]

Параллельное выполнение

Когда AI вызывает несколько инструментов – выполняем параллельно:

async def execute_tool_calls(tool_calls: list[ToolCall]) -> list[ToolResult]:
    """Параллельное выполнение инструментов через asyncio.gather"""
    
    tasks = [execute_single_tool(tc) for tc in tool_calls]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    return [
        ToolResult(
            tool_use_id=tc.id,
            content=str(r) if not isinstance(r, Exception) else f"Error: {r}"
        )
        for tc, r in zip(tool_calls, results)
    ]

Это важно для операций типа “покажи мою статистику и текущую тренировку”:

Последовательно: get_stats (200ms) + get_current_workout (150ms) = 350ms
Параллельно: max(200ms, 150ms) = 200ms

Knowledge Graph упражнений

Почему граф?

Простая база упражнений не отвечает на вопросы:

  • “Чем заменить приседания, если болит колено?”

  • “Как усложнить отжимания, когда стало легко?”

  • “Какие упражнения нагружают нижнюю часть груди?”

Граф хранит связи между упражнениями:

┌─────────────┐     TARGETS      ┌─────────────┐
│   Bench     │─────────────────>│   Chest     │
│   Press     │                  │   (Muscle)  │
└─────────────┘                  └─────────────┘
       │                                ^
       │ ALTERNATIVE                    │
       v                                │ TARGETS
┌─────────────┐                  ┌─────────────┐
│  Dumbbell   │──────────────────│   Triceps   │
│   Press     │                  │   (Muscle)  │
└─────────────┘                  └─────────────┘
       │
       │ PROGRESSION_TO
       v
┌─────────────┐
│  Incline    │
│   Press     │
└─────────────┘

Реализация: NetworkX или Neo4j

# Development: In-memory граф (NetworkX)
class InMemoryExerciseGraph:
    def __init__(self):
        self.graph = nx.DiGraph()
        self._load_exercises()
    
    def get_alternatives(self, exercise_id: str) -> list[Exercise]:
        """Найти альтернативные упражнения"""
        alternatives = []
        
        for neighbor in self.graph.neighbors(exercise_id):
            edge = self.graph.edges[exercise_id, neighbor]
            if edge.get("relation") == "ALTERNATIVE":
                alternatives.append(self._get_exercise(neighbor))
        
        return alternatives
    
    def get_progressions(self, exercise_id: str) -> list[Exercise]:
        """Найти прогрессии (усложнения)"""
        return [
            self._get_exercise(n)
            for n in self.graph.neighbors(exercise_id)
            if self.graph.edges[exercise_id, n].get("relation") == "PROGRESSION_TO"
        ]
    
    def get_exercises_for_muscle(self, muscle: str) -> list[Exercise]:
        """Найти все упражнения для мышцы"""
        return [
            self._get_exercise(n)
            for n in self.graph.predecessors(muscle)
            if self.graph.edges[n, muscle].get("relation") == "TARGETS"
        ]

# Production: Neo4j для персистентности и масштабирования
class Neo4jExerciseGraph:
    def __init__(self):
        self.driver = neo4j.GraphDatabase.driver(
            settings.NEO4J_URI,
            auth=(settings.NEO4J_USER, settings.NEO4J_PASSWORD)
        )
    
    def get_alternatives(self, exercise_id: str) -> list[Exercise]:
        query = """
        MATCH (e:Exercise {id: $exercise_id})-[:ALTERNATIVE]->(alt:Exercise)
        RETURN alt
        """
        with self.driver.session() as session:
            result = session.run(query, exercise_id=exercise_id)
            return [Exercise(**record["alt"]) for record in result]

Пример использования через чат

Пользователь: "Болит плечо, чем заменить жим штанги стоя?"

AI внутренне вызывает:
  get_exercise_alternatives(exercise="overhead_press")

Граф возвращает:
  [
    {"name": "Landmine Press", "reason": "меньше нагрузка на плечевой сустав"},
    {"name": "Arnold Press", "reason": "контролируемое движение"},
    {"name": "Cable Lateral Raise", "reason": "изоляция без компрессии"}
  ]

AI отвечает:
  "При боли в плече могу предложить замены:
   1. Landmine Press - меньше нагружает плечевой сустав
   2. Arnold Press - более контролируемая амплитуда
   3. Cable Lateral Raise - изоляция без осевой нагрузки
   
   Какой вариант добавить в программу?"

RAG-память с pgvector

P.S. На своем личном я использую Claude Agent SDK, там работает немного иначе. Я сделал непрерывную сессию с контекстом 1 млн токенов, автокомпактом и построил память [9] на Zep.

Проблема контекста

LLM имеют ограниченное контекстное окно. Нельзя загрузить всю историю тренировок в каждый запрос.

Решение: Semantic Search

┌─────────────────────────────────────────────────────────────────┐
│                     USER MESSAGE                                │
│  "Покажи тренировки, где я делал становую тягу с большим весо��" │
└─────────────────────────────────────────────────────────────────┘
                                │
                                v
┌─────────────────────────────────────────────────────────────────┐
│                     EMBEDDING                                   │
│  text-embedding-3-small → [0.023, -0.156, 0.089, ...]          │
└─────────────────────────────────────────────────────────────────┘
                                │
                                v
┌─────────────────────────────────────────────────────────────────┐
│                  PGVECTOR SEARCH                                │
│  SELECT * FROM workout_memories                                 │
│  ORDER BY embedding <=> $query_embedding                        │
│  LIMIT 5                                                        │
└─────────────────────────────────────────────────────────────────┘
                                │
                                v
┌─────────────────────────────────────────────────────────────────┐
│                     RESULTS                                     │
│  1. "15 янв: становая 140кг × 5 (PR!)"                         │
│  2. "8 янв: становая 130кг × 8"                                 │
│  3. "2 янв: становая 125кг × 10"                                │
└─────────────────────────────────────────────────────────────────┘

Реализация

# Модель для хранения памяти
class WorkoutMemory(Base):
    __tablename__ = "workout_memories"
    
    id = Column(UUID, primary_key=True)
    user_id = Column(UUID, ForeignKey("users.id"))
    content = Column(Text)  # "15 янв: становая 140кг × 5"
    embedding = Column(Vector(1536))  # pgvector
    created_at = Column(DateTime)
    
    __table_args__ = (
        Index('ix_memory_embedding', embedding, postgresql_using='ivfflat'),
    )

# Сервис RAG
class RAGService:
    def __init__(self):
        self.embedding_client = OpenAI()  # или Ollama
    
    async def search(self, query: str, user_id: str, limit: int = 5):
        # 1. Получаем embedding запроса
        embedding = await self._get_embedding(query)
        
        # 2. Ищем похожие записи
        result = await self.db.execute(
            select(WorkoutMemory)
            .where(WorkoutMemory.user_id == user_id)
            .order_by(WorkoutMemory.embedding.cosine_distance(embedding))
            .limit(limit)
        )
        
        return result.scalars().all()
    
    async def store(self, content: str, user_id: str):
        embedding = await self._get_embedding(content)
        
        memory = WorkoutMemory(
            user_id=user_id,
            content=content,
            embedding=embedding
        )
        
        self.db.add(memory)
        await self.db.commit()

Бесплатные embeddings через Ollama

Не хотите платить OpenAI за embeddings? Ollama поддерживает локальные модели:

# Установка
ollama pull nomic-embed-text

# Конфигурация
FITNESS_EMBEDDING_PROVIDER=ollama
FITNESS_OLLAMA_BASE_URL=http://localhost:11434
class OllamaEmbeddingProvider:
    async def get_embedding(self, text: str) -> list[float]:
        response = await self.client.post(
            f"{self.base_url}/api/embeddings",
            json={"model": "nomic-embed-text", "prompt": text}
        )
        return response.json()["embedding"]

Plan Navigator

Проблема

AI нужен контекст текущего плана, но загружать всю структуру (8 недель × 4 дня × 7 упражнений) – это тысячи токенов.

Решение

Plan Navigator генерирует компактный индекс (300-500 символов):

class PlanNavigator:
    def build_context(self, plan_id: str) -> str:
        plan = await self.get_plan(plan_id)
        current = await self.get_current_position(plan_id)
        
        return f"""
ПЛАН: {plan.name} ({plan.goal})
ПРОГРЕСС: Неделя {current.week}/{plan.total_weeks}, День {current.day}/{current.days_in_week}
СТАТУС: {current.completed_days} выполнено, {current.skipped_days} пропущено
STREAK: {current.streak} дней подряд
СЕГОДНЯ: {current.today_workout.name if current.today_workout else "Отдых"}

Последние 3 тренировки:
- {current.recent[0].date}: {current.recent[0].name} ({'done' if current.recent[0].completed else 'skip'})
- {current.recent[1].date}: {current.recent[1].name} ({'done' if current.recent[1].completed else 'skip'})
- {current.recent[2].date}: {current.recent[2].name} ({'done' if current.recent[2].completed else 'skip'})
"""

Этот контекст добавляется к каждому запросу AI, давая ему понимание текущего состояния без загрузки всей базы.

SSE Streaming

Почему SSE, а не WebSocket?

Критерий

WebSocket

SSE

Направление

Bidirectional

Server → Client only

Сложность

Выше

Ниже

Reconnection

Ручной

Автоматический

HTTP/2

Отдельное соединение

Мультиплексирование

Для AI чата

Зачем?

Нормально

Для AI-чата нам нужен только поток от сервера к клиенту. SSE проще и надёжнее.

Формат событий

# Backend: FastAPI SSE endpoint
@router.post("/chat")
async def chat_stream(request: ChatRequest):
    async def event_generator():
        async for event in ai_service.chat_stream(request.message):
            match event.type:
                case "text":
                    yield f"event: textndata: {json.dumps({'content': event.content})}nn"
                
                case "thinking":
                    # Extended Thinking (Claude Agent SDK v2)
                    yield f"event: thinkingndata: {json.dumps({'thought': event.content})}nn"
                
                case "tool_start":
                    yield f"event: tool_startndata: {json.dumps({'tool': event.tool, 'input': event.input})}nn"
                
                case "tool_result":
                    yield f"event: tool_resultndata: {json.dumps({'tool': event.tool, 'result': event.result})}nn"
                
                case "done":
                    yield f"event: donendata: {json.dumps({'status': 'completed'})}nn"
                
                case "error":
                    yield f"event: errorndata: {json.dumps({'error': str(event.error)})}nn"
    
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream"
    )
// Frontend: обработка SSE
const eventSource = new EventSource('/api/fitness/chat');

eventSource.addEventListener('text', (e) => {
  const data = JSON.parse(e.data);
  appendToMessage(data.content);
});

eventSource.addEventListener('tool_start', (e) => {
  const data = JSON.parse(e.data);
  showToolIndicator(data.tool, 'loading');
});

eventSource.addEventListener('tool_result', (e) => {
  const data = JSON.parse(e.data);
  showToolIndicator(data.tool, 'success');
});

eventSource.addEventListener('thinking', (e) => {
  // Показываем процесс размышления (Extended Thinking)
  const data = JSON.parse(e.data);
  showThinkingBubble(data.thought);
});

PWA с Material You (потому что у меня Pixel)

Дизайн-система

Использовали Material 3 / Material You:

  • Большие радиусы скругления (28px для карточек)

  • Мягкие тени с цветовым оттенком

  • Градиентные фоны

  • Минимум 44px для tap targets (мобильная доступность)

Offline Support

// Service Worker стратегии
const CACHE_NAME = 'fitness-coach-v1';

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // Статические ресурсы: Cache First
  if (url.pathname.match(/.(js|css|png|svg)$/)) {
    event.respondWith(cacheFirst(event.request));
    return;
  }
  
  // API данные: Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
    return;
  }
  
  // Chat: Skip cache (SSE streaming)
  if (url.pathname.includes('/chat')) {
    event.respondWith(fetch(event.request));
    return;
  }
});

Offline Action Queue

Когда пользователь offline, действия складываются в очередь:

// stores/workout.ts (Zustand)
interface WorkoutState {
  offlineQueue: OfflineAction[];
  isOffline: boolean;
  
  completeDay: () => Promise<boolean>;
  syncOfflineQueue: () => Promise<void>;
}

const useWorkoutStore = create<WorkoutState>()(
  persist(
    (set, get) => ({
      offlineQueue: [],
      isOffline: !navigator.onLine,
      
      completeDay: async () => {
        const action = { type: 'COMPLETE_DAY', payload: {...}, timestamp: Date.now() };
        
        if (get().isOffline) {
          // Offline: добавляем в очередь
          set(state => ({ 
            offlineQueue: [...state.offlineQueue, action] 
          }));
          return true;
        }
        
        // Online: выполняем сразу
        try {
          await api.completeDay(action.payload);
          return true;
        } catch {
          set(state => ({ offlineQueue: [...state.offlineQueue, action] }));
          return false;
        }
      },
      
      syncOfflineQueue: async () => {
        const queue = get().offlineQueue;
        
        for (const action of queue) {
          await api.executeAction(action);
        }
        
        set({ offlineQueue: [] });
      }
    }),
    { name: 'workout-store' }
  )
);

Деплой одной командой

git clone https://github.com/gmen1057/fitness-coach.git
cd fitness-coach/docker

# Настройка
cp .env.example .env
nano .env  # Добавить API ключ (Anthropic/OpenAI/Ollama)

# Запуск
docker compose up -d

http://localhost:8000 [10].

Docker Compose

services:
  postgres:
    image: pgvector/pgvector:pg16
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: fitness_coach

  backend:
    build: ../backend
    depends_on:
      - postgres
    environment:
      DATABASE_URL: postgresql+asyncpg://...
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
    ports:
      - "8000:8000"

  frontend:
    build: ../frontend
    depends_on:
      - backend
    ports:
      - "3000:3000"

Расходы на API (не знаю зачем вам это, но все спрашивают)

Claude Sonnet 4.5

Метрика

Значение

Input

$3 / 1M токенов

Output

$15 / 1M токенов

Средний запрос

~800 input + 400 output токенов

Стоимость запроса

~$0.008

100 запросов/день

~$24/месяц

OpenAI GPT-4o-mini

Метрика

Значение

Input

$0.15 / 1M токенов

Output

$0.60 / 1M токенов

Средний запрос

~800 input + 400 output токенов

Стоимость запроса

~$0.0004

100 запросов/день

~$1.2/месяц

Ollama (локально)

Метрика

Значение

Стоимость API

$0

Требования

8GB+ RAM, GPU опционально

Качество

70-80% от Claude

Эволюция проекта

v1.0: 6 инструментов

Проблема: 20-30 tool calls на создание плана
Время: 15-25 секунд
UX: Плохой

v2.0: 27 инструментов + batch

Решение: create_full_plan, create_full_week
Tool calls: 1-3
Время: 3-5 секунд
UX: Хороший

v3.0: + Knowledge Graph

Добавлено: Альтернативы, прогрессии, поиск по мышцам
Новые возможности: "чем заменить?", "как усложнить?"

v4.0: + RAG память

Добавлено: pgvector, долгосрочный контекст
Новые возможности: "что я делал в прошлом месяце?"

Что еще

1. Единый mega-промпт

  • Идея: Загрузить всю информацию в system prompt

  • Проблема: 10K+ токенов, медленно, дорого

  • Решение: Plan Navigator (300-500 токенов)

2. Langchain

  • Идея: Использовать готовый framework

  • Проблема: Избыточная абстракция, сложный дебаг

  • Решение: Прямые вызовы Anthropic SDK

3. Один провайдер

  • Иде��: Только Claude, без fallback

  • Проблема: При сбое API – полный downtime

  • Решение: Multi-provider с автоматическим переключением

4. WebSocket для чата

  • Идея: Real-time bidirectional

  • Проблема: Сложнее SSE, reconnection headache

  • Решение: SSE с автоматическим reconnection

Что работает

  1. Batch-инструменты – один вызов вместо десятков

  2. Граф упражнений – семантические связи лучше SQL joins

  3. SSE – проще WebSocket для однонаправленного потока

  4. Protocol-based providers – легко добавить нового провайдера

  5. pgvector – векторный поиск без отдельной базы

Чего избегать

  1. Mega-промпты – дорого и медленно

  2. Жёсткая привязка к провайдеру – API падают

  3. Синхронные tool calls – asyncio.gather спасает

  4. Хранение всей истории в контексте – RAG лучше

Roadmap

Сделано:

  • [x] Multi-provider AI

  • [x] 27 MCP-инструментов

  • [x] Knowledge Graph

  • [x] RAG-память

  • [x] PWA с offline

  • [x] Docker Compose

Нет:

мне лень

  • [ ] Nutrition tracking

  • [ ] Видео упражнений

  • [ ] Интеграция с фитнес-трекерами

  • [ ] Мобильное приложение (React Native)

Итог

  • 3 AI-провайдера на выбор (облако или локально)

  • 27 инструментов для управления тренировками

  • Граф упражнений для умных рекомендаций

  • RAG-память для долгосрочного контекста

  • PWA с offline-режимом

GitHub: https://github.com/gmen1057/fitness-coach [2]

Лицензия: MIT – форкайте, модифицируйте, коммерциализируйте.

Ссылки

Проект разработан совместно с Claude Code CLI. Код написан AI, архитектурные решения – человек.

Автор: men10577

Источник [16]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/24915

URLs in this post:

[1] тут: https://habr.com/ru/articles/987872/

[2] https://github.com/gmen1057/fitness-coach: https://github.com/gmen1057/fitness-coach

[3] api.openai.com: http://api.openai.com

[4] api.groq.com: http://api.groq.com

[5] api.together.xyz: http://api.together.xyz

[6] api.deepseek.com: http://api.deepseek.com

[7] openrouter.ai: http://openrouter.ai

[8] api.fireworks.ai: http://api.fireworks.ai

[9] память: http://www.braintools.ru/article/4140

[10] http://localhost:8000: http://localhost:8000

[11] Anthropic Claude API: https://docs.anthropic.com/

[12] OpenAI API: https://platform.openai.com/docs

[13] Ollama: https://ollama.ai/

[14] pgvector: https://github.com/pgvector/pgvector

[15] NetworkX: https://networkx.org/

[16] Источник: https://habr.com/ru/articles/990694/?utm_source=habrahabr&utm_medium=rss&utm_campaign=990694

www.BrainTools.ru

Rambler's Top100