Open-Source AI Фитнес-Тренер: 27 MCP-инструментов, 3 провайдера и граф упражнений. chatgpt.. chatgpt. Claude.. chatgpt. Claude. fastapi.. chatgpt. Claude. fastapi. fitness.. chatgpt. Claude. fastapi. fitness. llm.. chatgpt. Claude. fastapi. fitness. llm. nextjs.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source. PostgreSQL.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source. PostgreSQL. pwa.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source. PostgreSQL. pwa. python.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source. PostgreSQL. pwa. python. rag.. chatgpt. Claude. fastapi. fitness. llm. nextjs. nginx. Open source. PostgreSQL. pwa. python. rag. SQL.

Дисклеймер: это open source, в нем могут быть недостатки, заходите, предлагайте идеи, исправления. Публикую тут в ознакомительных и образовательных целях. Выпилил этот кусок в open source из части личного проекта, о котором писал тут. Весь код писал полностью 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 Лицензия: 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

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

Groq

api.groq.com

Llama 3.3 70B за 0.5 сек

Together AI

api.together.xyz

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

DeepSeek

api.deepseek.com

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

OpenRouter

openrouter.ai

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

Fireworks

api.fireworks.ai

Быстрый 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 млн токенов, автокомпактом и построил память на 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.

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

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

Ссылки

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

Автор: men10577

Источник

Rambler's Top100