/
pa1ch
/
FitAssistant
Обзор
Документация
Войти
/
pa1ch
/
FitAssistant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app/services/agent.py
751 строка
36 KB
Pavel Chugaev
feat: AI-генерация — метка снаряда, промпт против дублей, честная ошибка вместо тихого фолбэка
16 июл 2026, 16:21
16 июл 2026, 16:21
72a9039
Код
Авторство
О чём код?
"""Agentic tool-calling assistant (PydanticAI). Replaces the old single-shot intent router (`intent.py`): instead of asking the model to emit one JSON intent, we expose the app's actions as *tools* and let the model decide which to call, in what order, and with what arguments — including a ``search_exercises`` tool so it can resolve free-form exercise names instead of having the whole catalog stuffed into the prompt. The model is built from a priority chain (``ai_models.yaml``, or ``settings.ai_model`` as a fallback): each entry is ``"provider:model"``, and if more than one survives they are wrapped in a PydanticAI ``FallbackModel`` — when one model is unavailable (rate limit, 5xx, retired) the next is tried automatically. Links whose provider key is unset in ``.env`` are skipped. This module is import-safe without an API key — the real model is only built when the agent actually runs. Tests inject ``TestModel``/``FunctionModel`` instead. """ from __future__ import annotations import logging from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import TYPE_CHECKING import yaml from pydantic import BaseModel, Field from pydantic_ai import Agent, RunContext from app.config import settings from app.services.athlete_context import build_athlete_context from app.services.dialog_memory import load_dialog, save_dialog from app.services.exercise import get_all_exercises from app.services.exercise import search_exercises as search_exercises_service from app.services.measurement import get_measurements from app.services.measurement import save_measurement as save_measurement_service from app.services.progress import get_exercise_progress, get_workout_stats from app.services.workout import ( DraftExercise, DraftSet, get_open_draft, get_workouts_with_details, save_draft, ) if TYPE_CHECKING: from datetime import date from pydantic_ai.models import Model from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User from app.models.workout import Workout @dataclass class AgentDeps: """Per-run context injected into every tool (DI via PydanticAI ``deps``).""" user: User session: AsyncSession today: date # user-local today (accounting for timezone) # Filled in by ``run_turn`` before the model runs: a text rendering of the # athlete's current draft, so the model can merge new speech into it. draft_summary: str | None = None # Who the athlete is (sex/age/height/weight/level/goal + their own note # about injuries etc.) — built by services/athlete_context.py in run_turn. athlete_context: str | None = None # Set by ``save_workout`` when it writes the draft, so ``run_turn`` knows the # turn produced a workout (vs. a plain answer like stats). saved: bool = False # Set by ``generate_workout``: an extra line (e.g. estimated duration) shown # under the rendered draft. plan_note: str | None = None INSTRUCTIONS = """\ Ты — голосовой ассистент-тренер фитнес-приложения. Пользователь голосом записывает \ тренировки и замеры тела, задаёт вопросы о прогрессе. Тренировку он часто надиктовывает \ ПО ЧАСТЯМ (несколькими сообщениями подряд), а ты ведёшь один черновик тренировки на сегодня. Правила: - Отвечай на русском, коротко и по делу. - Ты тренер, а не универсальный чат-бот: на темы вне тренировок, питания, замеров и \ здоровья спортсмена отвечай одной вежливой фразой и возвращай разговор к тренировкам. - Ты помнишь только последние несколько реплик. Короткие ответы пользователя («да», \ «а приседа?») трактуй в контексте предыдущих сообщений диалога. - Для любого действия вызывай подходящий инструмент — не выдумывай данные. - Замеры тела (вес, обхваты груди/талии/бёдер/бицепса/бедра) записывай через \ save_measurement — это НЕ тренировка и НЕ черновик. «Вес 85, талия 80» — это замеры. \ Если сторона не указана («бицепс 40») — записывай одно значение в левую и правую. \ Если непонятно, вес тела это или вес в упражнении (например, просто «запиши 80») — переспроси. - Чтобы добавить упражнение: сначала найди его через search_exercises и возьми id и точное \ название из результата. Результаты отсортированы по похожести и могут быть приблизительными — \ распознавание речи иногда искажает слова. Если очевиден один подходящий вариант — бери его. \ Если результат пустой — скажи, что не нашёл такое упражнение, попроси повторить; не придумывай \ его и не сохраняй. Если близких вариантов несколько и неясно, какой имелся в виду — переспроси. - Ниже показан текущий черновик (если он есть). Объедини его с новым сообщением и вызови \ save_workout, передав ВЕСЬ состав тренировки: все упражнения из черновика ПЛЮС новые или \ правки из нового сообщения. Никогда не теряй уже добавленные упражнения — кроме случая, когда \ пользователь явно просит убрать или заменить их. У упражнений из черновика уже есть id — \ используй их, не ищи заново. - "3 по 10 с весом 80" означает 3 подхода по 10 повторений с весом 80 кг. - Вес — это ДОБАВОЧНЫЙ вес сверх собственного. Если упражнение без отягощения \ (подтягивания, отжимания, брусья и т.п.) — не указывай weight_kg (оставь пустым), \ просто запиши повторения. Вес ставь только когда пользователь явно назвал отягощение \ ("с весом 10", "плюс 10 кг", "пояс 20"). - Резинку, хват, "до отказа" и прочие пометки клади в поле notes упражнения \ (например, notes="с резинкой"). Не превращай их в вес. - notes — одно на всё упражнение, не на отдельный подход. Если пометки у подходов \ разные, кратко сверни их в один notes (например, notes="1-2 с резинкой, 3 чисто"). - Числа (веса, повторы) бери строго из слов пользователя. - save_workout вызывай ТОЛЬКО когда пользователь сообщает об упражнениях/подходах или правит \ их. На вопросы (статистика, история) отвечай инструментами и текстом, НЕ трогая черновик. - «Собери/сгенерируй/составь тренировку на …» — это generate_workout: передай названные \ группы мышц (или пресет: Фулбади, Верх тела, Низ тела, Push, Pull) и длительность в минутах, \ если названа. Упражнения подбирает система — сам их не выбирай. Если инструмент ответил, что \ черновик уже есть, — спроси пользователя, заменить ли его, и при согласии повтори вызов с \ replace_draft=true. - Вопросы о прогрессе и истории: возьми данные инструментами (get_workout_history, \ get_measurement_history, get_exercise_progress_history, get_stats) и ответь сам — кратко, \ как тренер: тренды, регулярность, 1–2 конкретных совета. Не выдумывай цифры, которых нет в \ данных. Графики есть в приложении на вкладке «Прогресс» — можешь напомнить об этом. - Если данных не хватает или они неоднозначны — переспроси, а не угадывай и не сохраняй. """ class SetInput(BaseModel): """Один подход в упражнении.""" weight_kg: float | None = Field( default=None, description="Добавочный вес в кг сверх собственного. Пусто (null) — без отягощения.", ) reps: int = Field(description="Число повторений в подходе.") class ExerciseInput(BaseModel): """Одно упражнение тренировки с его подходами.""" exercise_id: int = Field(description="id упражнения из результата search_exercises.") name: str = Field( description="Название упражнения из результата search_exercises (для показа)." ) sets: list[SetInput] = Field(description="Подходы по порядку.") notes: str | None = Field( default=None, description='Пометка к упражнению, например "с резинкой", "узким хватом". Пусто — нет.', ) # Agent is created without a model so the module imports without an API key. # The real model is supplied per-run; tests pass their own. Mutating tool # (save_workout) writes straight to the athlete's DB draft — finalisation # ("Готово") happens outside the agent, as a plain service call. agent = Agent( deps_type=AgentDeps, output_type=str, instructions=INSTRUCTIONS, ) @agent.instructions def _athlete_context_instruction(ctx: RunContext[AgentDeps]) -> str: """Tell the model who it is coaching, so answers account for the athlete's level, goal and stated limitations (injuries etc.).""" if not ctx.deps.athlete_context: return "" return ( "О спортсмене: " + ctx.deps.athlete_context + " Учитывай это в ответах и " "рекомендациях; при явном противоречии с ограничениями спортсмена (травмы, " "болезни) — предупреди." ) @agent.instructions def _current_draft_instruction(ctx: RunContext[AgentDeps]) -> str: """Inject the athlete's current draft so the model merges new speech into it.""" if not ctx.deps.draft_summary: return "Сейчас черновик пуст — это начало новой тренировки." return ( "Текущий черновик тренировки (объедини с новым сообщением, " "ничего из него не теряй):\n" + ctx.deps.draft_summary ) @agent.tool async def get_stats(ctx: RunContext[AgentDeps]) -> dict: """Сводная статистика тренировок пользователя: всего, за месяц, за 30 дней, всего подходов, среднее в неделю и самое частое упражнение.""" stats = await get_workout_stats(ctx.deps.session, ctx.deps.user.id) return { "total_workouts": stats.total_workouts, "workouts_this_month": stats.workouts_this_month, "workouts_last_30_days": stats.workouts_last_30_days, "total_sets": stats.total_sets, "avg_workouts_per_week": stats.avg_workouts_per_week, "most_frequent_exercise": stats.most_frequent_exercise, } @agent.tool async def get_workout_history(ctx: RunContext[AgentDeps], limit: int = 10) -> list[dict]: """Последние завершённые тренировки пользователя: дата, упражнения, подходы. Используй для вопросов об истории («что я делал в прошлый раз?») и как данные для анализа прогресса. weight_kg = null означает упражнение без отягощения. Args: limit: сколько последних тренировок вернуть (по умолчанию 10, максимум 30). """ workouts = await get_workouts_with_details( ctx.deps.session, ctx.deps.user.id, limit=min(limit, 30) ) return [ { "date": str(w.date), "exercises": [ { "name": we.exercise.name, **({"notes": we.notes} if we.notes else {}), "sets": [ { "weight_kg": float(s.weight_kg) if s.weight_kg is not None else None, "reps": s.reps, } for s in we.sets ], } for we in w.exercises ], } for w in workouts ] @agent.tool async def get_measurement_history(ctx: RunContext[AgentDeps], limit: int = 10) -> list[dict]: """Последние замеры тела пользователя (вес в кг, обхваты в см), новые первыми. Args: limit: сколько последних замеров вернуть (по умолчанию 10, максимум 30). """ measurements = await get_measurements(ctx.deps.session, ctx.deps.user.id, limit=min(limit, 30)) out = [] for m in measurements: row: dict = {"date": str(m.date)} for field in ( "weight_kg", "chest_cm", "waist_cm", "hips_cm", "biceps_left_cm", "biceps_right_cm", "thigh_left_cm", "thigh_right_cm", ): value = getattr(m, field) if value is not None: row[field] = float(value) out.append(row) return out @agent.tool async def get_exercise_progress_history( ctx: RunContext[AgentDeps], exercise_id: int, limit: int = 12 ) -> list[dict]: """Прогресс по одному упражнению по тренировкам: лучший вес, расчётный одноповторный максимум (est_1rm), повторения. Старые первыми. exercise_id бери из search_exercises. Для упражнений без отягощения max_weight и est_1rm равны 0 — смотри на повторения (max_reps). Args: exercise_id: id упражнения из search_exercises. limit: сколько последних точек вернуть (по умолчанию 12). """ points = await get_exercise_progress(ctx.deps.session, ctx.deps.user.id, exercise_id) return [ { "date": str(p.date), "max_weight": p.max_weight, "est_1rm": p.est_1rm, "max_reps": p.max_reps, "total_sets": p.total_sets, } for p in points[-limit:] ] @agent.tool async def search_exercises(ctx: RunContext[AgentDeps], query: str) -> list[dict]: """Найти упражнения по части названия (регистронезависимо). Args: query: фрагмент названия, например "жим" или "присед". Returns: Список найденных упражнений: id, name, muscle_group. Пустой список — ничего не найдено. """ found = await search_exercises_service(ctx.deps.session, query, user_id=ctx.deps.user.id) return [{"id": e.id, "name": e.name, "muscle_group": e.muscle_group} for e in found] _MEASUREMENT_LABELS = { "weight_kg": ("вес", "кг"), "chest_cm": ("грудь", "см"), "waist_cm": ("талия", "см"), "hips_cm": ("бёдра", "см"), "biceps_left_cm": ("бицепс левый", "см"), "biceps_right_cm": ("бицепс правый", "см"), "thigh_left_cm": ("бедро левое", "см"), "thigh_right_cm": ("бедро правое", "см"), } @agent.tool async def save_measurement( ctx: RunContext[AgentDeps], weight_kg: float | None = None, chest_cm: float | None = None, waist_cm: float | None = None, hips_cm: float | None = None, biceps_left_cm: float | None = None, biceps_right_cm: float | None = None, thigh_left_cm: float | None = None, thigh_right_cm: float | None = None, ) -> str: """Записать замеры тела за сегодня (вес и/или обхваты, в кг и см). Передавай только те параметры, которые пользователь назвал, — остальные не трогаются. Повторный вызов в тот же день дополняет/исправляет уже записанное. Это НЕ для веса в упражнениях — для тренировок есть save_workout. """ values = { k: v for k, v in { "weight_kg": weight_kg, "chest_cm": chest_cm, "waist_cm": waist_cm, "hips_cm": hips_cm, "biceps_left_cm": biceps_left_cm, "biceps_right_cm": biceps_right_cm, "thigh_left_cm": thigh_left_cm, "thigh_right_cm": thigh_right_cm, }.items() if v is not None } if not values: return "Не передано ни одного значения — нечего записывать." _, is_overwrite = await save_measurement_service( ctx.deps.session, ctx.deps.user.id, ctx.deps.today, **values ) await ctx.deps.session.commit() parts = [ f"{_MEASUREMENT_LABELS[k][0]} {v:g} {_MEASUREMENT_LABELS[k][1]}" for k, v in values.items() ] verb = "Обновил замеры за сегодня" if is_overwrite else "Записал замеры" return f"{verb}: {', '.join(parts)}." @agent.tool async def save_workout(ctx: RunContext[AgentDeps], exercises: list[ExerciseInput]) -> str: """Сохранить ВЕСЬ состав сегодняшнего черновика тренировки. Перезаписывает черновик целиком: передавай все упражнения (из черновика плюс новые/правки), а не только последнее. Черновик не финализируется — это сделает пользователь кнопкой «Готово». Каждый exercise_id — из search_exercises или из показанного черновика. Args: exercises: полный список упражнений тренировки, каждое со своими подходами. """ valid_ids = await get_all_exercises(ctx.deps.session, user_id=ctx.deps.user.id) to_save = [e for e in exercises if e.exercise_id in valid_ids] if not to_save: return "Не удалось сохранить: упражнения не найдены в справочнике." draft_exercises = [ DraftExercise( exercise_id=e.exercise_id, sets=[DraftSet(reps=s.reps, weight_kg=s.weight_kg) for s in e.sets], notes=e.notes, ) for e in to_save ] await save_draft(ctx.deps.session, ctx.deps.user.id, ctx.deps.today, draft_exercises) await ctx.deps.session.commit() ctx.deps.saved = True names = ", ".join(valid_ids[e.exercise_id] for e in to_save) return f"Черновик обновлён ({len(to_save)} упр.): {names}." @agent.tool async def generate_workout( ctx: RunContext[AgentDeps], muscle_groups: list[str], duration_minutes: int | None = None, replace_draft: bool = False, ) -> str: """Сгенерировать тренировку по группам мышц и записать её в черновик. Упражнения, подходы и порядок подбирает система с учётом уровня и предпочтений спортсмена — не выбирай их сам. Args: muscle_groups: группы мышц (например ["Грудь", "Трицепс"]) или один пресет: "Фулбади", "Верх тела", "Низ тела", "Push", "Pull". duration_minutes: желаемая длительность в минутах, если названа. replace_draft: True — заменить существующий непустой черновик (только после явного согласия пользователя). """ # Import here, not at module top: workout_plan_agent imports build_model # from this module, so a top-level import would be circular. from app.services.muscle_regions import WORKOUT_PRESETS, get_muscle_regions from app.services.workout_plan import WorkoutPlanError from app.services.workout_plan_agent import AiUnavailableError, generate_plan draft = await get_open_draft(ctx.deps.session, ctx.deps.user.id, ctx.deps.today) if draft is not None and draft.exercises and not replace_draft: return ( "У пользователя уже есть непустой черновик тренировки на сегодня. " "Спроси, заменить ли его сгенерированной тренировкой; при согласии " "вызови generate_workout ещё раз с replace_draft=true." ) known_regions = await get_muscle_regions(ctx.deps.session, ctx.deps.user.id) presets = {name.casefold(): regions for name, regions in WORKOUT_PRESETS.items()} regions_by_fold = {r.casefold(): r for r in known_regions} resolved: list[str] = [] unknown: list[str] = [] for item in muscle_groups: key = item.strip().casefold() if key in presets: resolved.extend(r for r in presets[key] if r not in resolved) elif key in regions_by_fold: if regions_by_fold[key] not in resolved: resolved.append(regions_by_fold[key]) else: unknown.append(item) if unknown or not resolved: options = ", ".join([*known_regions, *WORKOUT_PRESETS]) bad = f"Не знаю групп: {', '.join(unknown)}. " if unknown else "" return f"{bad}Доступные группы и пресеты: {options}. Уточни у пользователя." try: plan = await generate_plan( ctx.deps.session, user_id=ctx.deps.user.id, muscle_groups=resolved, experience_level=ctx.deps.user.experience_level, duration_minutes=duration_minutes, ) except WorkoutPlanError as e: return str(e) except AiUnavailableError as e: return f"Тренировка НЕ сгенерирована: {e} Сообщи об этом пользователю." # Same placeholder sets as the web form: the athlete edits reps/weight later. draft_exercises = [ DraftExercise( exercise_id=pe.exercise.id, sets=[DraftSet(reps=10, weight_kg=None) for _ in range(pe.sets)], ) for group in plan.groups for pe in group.exercises ] if not draft_exercises: return "Не удалось собрать план: нет подходящих упражнений." await save_draft(ctx.deps.session, ctx.deps.user.id, ctx.deps.today, draft_exercises) await ctx.deps.session.commit() ctx.deps.saved = True ctx.deps.plan_note = ( f"⏱ Примерная длительность: {plan.estimated_duration_minutes} мин. " "Повторения и веса — ориентир, поправь под себя." ) names = ", ".join(pe.exercise.name for g in plan.groups for pe in g.exercises) return ( f"Сгенерировал и записал в черновик ({len(draft_exercises)} упр.): {names}. " f"Примерная длительность {plan.estimated_duration_minutes} мин." ) logger = logging.getLogger(__name__) # OpenAI-совместимые провайдеры: у всех один протокол, отличаются только адресом # API и переменной с ключом — достаточно OpenAIChatModel с нужным base_url. _OPENAI_COMPATIBLE: dict[str, tuple[str, str]] = { # провайдер: (base_url, имя поля с ключом в settings) "deepseek": ("https://api.deepseek.com", "deepseek_api_key"), "zhipu": ("https://open.bigmodel.cn/api/paas/v4", "zhipu_api_key"), "qwen": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen_api_key"), "moonshot": ("https://api.moonshot.cn/v1", "moonshot_api_key"), "openrouter": ("https://openrouter.ai/api/v1", "openrouter_api_key"), } # Провайдеры с собственным протоколом (не OpenAI-совместимые) → поле с ключом. _NATIVE_KEY_FIELDS: dict[str, str] = { "anthropic": "anthropic_api_key", "groq": "groq_api_key", "openai": "openai_api_key", "google": "google_api_key", } def _key_field_for(provider_name: str) -> str | None: """Имя поля settings с ключом провайдера (или None, если провайдер неизвестен).""" if provider_name in _NATIVE_KEY_FIELDS: return _NATIVE_KEY_FIELDS[provider_name] if provider_name in _OPENAI_COMPATIBLE: return _OPENAI_COMPATIBLE[provider_name][1] return None def _parse_chain_file(path: Path) -> list[str]: """Прочитать ai_models.yaml → список строк "провайдер:модель" по приоритету.""" raw = yaml.safe_load(path.read_text(encoding="utf-8")) if raw is None: return [] if not isinstance(raw, list): raise RuntimeError( f"{path}: ожидался список строк 'провайдер:модель', а не {type(raw).__name__}." ) entries: list[str] = [] for item in raw: if not isinstance(item, str) or not item.strip(): raise RuntimeError( f"{path}: некорректная запись {item!r} — ожидалась строка 'провайдер:модель'." ) entries.append(item.strip()) return entries def _load_chain() -> list[str]: """Цепочка моделей: из ai_models.yaml, иначе из settings.ai_model (через запятую).""" file_setting = settings.ai_models_file.strip() if file_setting: path = Path(file_setting) if path.is_file(): entries = _parse_chain_file(path) if entries: return entries return [s.strip() for s in settings.ai_model.split(",") if s.strip()] def _build_single(spec: str) -> Model | None: """Собрать одну модель из "провайдер:модель". Возвращает None, если у провайдера не задан ключ (звено пропускается). Бросает RuntimeError при неверном формате или неизвестном провайдере. """ provider_name, _, model_name = spec.partition(":") if not model_name: raise RuntimeError(f'Модель должна быть в формате "провайдер:модель", получено: {spec!r}') key_field = _key_field_for(provider_name) if key_field is None: supported = ", ".join(sorted({*_NATIVE_KEY_FIELDS, *_OPENAI_COMPATIBLE})) raise RuntimeError( f"Неизвестный AI-провайдер {provider_name!r} в {spec!r}. Поддерживаются: {supported}." ) api_key = getattr(settings, key_field) if not api_key: logger.warning("Пропускаю модель %s: не задан %s в .env", spec, key_field.upper()) return None from app.services.http import proxied_async_client if provider_name == "anthropic": from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.providers.anthropic import AnthropicProvider return AnthropicModel( model_name, provider=AnthropicProvider(api_key=api_key, http_client=proxied_async_client()), ) if provider_name == "groq": from pydantic_ai.models.groq import GroqModel from pydantic_ai.providers.groq import GroqProvider return GroqModel( model_name, provider=GroqProvider(api_key=api_key, http_client=proxied_async_client()), ) if provider_name == "google": from pydantic_ai.models.google import GoogleModel from pydantic_ai.providers.google import GoogleProvider # google-genai ходит через переданный httpx-клиент (в т.ч. наш прокси). return GoogleModel( model_name, provider=GoogleProvider(api_key=api_key, http_client=proxied_async_client()), ) # openai и все OpenAI-совместимые (deepseek/zhipu/qwen/moonshot/openrouter) from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider base_url = _OPENAI_COMPATIBLE[provider_name][0] if provider_name in _OPENAI_COMPATIBLE else None if base_url: provider = OpenAIProvider( base_url=base_url, api_key=api_key, http_client=proxied_async_client() ) else: provider = OpenAIProvider(api_key=api_key, http_client=proxied_async_client()) return OpenAIChatModel(model_name, provider=provider) @lru_cache(maxsize=8) def _assemble_chain(chain: tuple[str, ...]) -> Model: """Собрать (и закэшировать) модель/цепочку по списку "провайдер:модель". Кэш по составу цепочки: httpx-клиенты переиспользуются между ходами, а не создаются заново на каждый запрос. Правка ai_models.yaml меняет состав цепочки → новый ключ кэша (после перезапуска процесса). """ models: list[Model] = [] skipped: list[str] = [] for spec in chain: built = _build_single(spec) if built is None: field = _key_field_for(spec.partition(":")[0]) or "" skipped.append(f"{spec} → {field.upper()}") else: models.append(built) if not models: raise RuntimeError( "Ни одна модель из цепочки AI недоступна — не заданы ключи в .env. " "Пропущены: " + "; ".join(skipped) + "." ) if len(models) == 1: return models[0] from pydantic_ai.models.fallback import FallbackModel return FallbackModel(*models) def build_model() -> Model: """Построить модель (или цепочку с fallback) из ai_models.yaml / settings.ai_model. Raises RuntimeError (на русском, как и остальной AI-слой), если AI выключен, цепочка пуста или ни одной модели с ключом не осталось. """ if not settings.ai_enabled: raise RuntimeError("AI-функции отключены (AI_ENABLED=false).") chain = _load_chain() if not chain: raise RuntimeError( f"Не задана ни одна модель: заполни {settings.ai_models_file} или AI_MODEL в .env." ) return _assemble_chain(tuple(chain)) # --- Turn orchestration --- # # A "turn" feeds one user message (voice/text) to the agent and yields: # * WorkoutDraft — the message changed the draft; show it + "Готово"/"Отмена". # * AgentReply — a plain answer (e.g. stats, or "не нашёл упражнение"); the # draft, if any, is untouched. # # State lives in the DB draft, not in a serialized history, so a turn is # self-contained and survives a bot restart. Finalising/discarding the draft are # plain service calls (see the bot handler), not agent turns. @dataclass class AgentReply: """The agent answered in plain text; the draft (if any) is untouched.""" text: str @dataclass class WorkoutDraft: """This turn changed the draft: show it and offer "Готово"/"Отмена".""" text: str # rendered draft (the exercises assembled so far) exercise_count: int = 0 AgentOutcome = AgentReply | WorkoutDraft def _render_draft(workout: Workout) -> tuple[str, int]: """Human-readable draft for the chat, plus how many exercises it holds.""" lines: list[str] = [] for we in workout.exercises: set_strs = [ f"{float(s.weight_kg):g}×{s.reps}" if s.weight_kg else f"{s.reps} повт." for s in we.sets ] suffix = f" <i>({we.notes})</i>" if we.notes else "" lines.append(f"• <b>{we.exercise.name}</b>: {', '.join(set_strs) or '—'}{suffix}") body = "\n".join(lines) if lines else "тренировка" return f"📋 Черновик тренировки:\n{body}", len(workout.exercises) def _draft_summary_for_agent(workout: Workout) -> str | None: """Compact draft rendering fed back to the model (includes exercise ids).""" lines: list[str] = [] for we in workout.exercises: set_strs = [ f"{float(s.weight_kg):g}x{s.reps}" if s.weight_kg else f"{s.reps}" for s in we.sets ] note = f", notes={we.notes}" if we.notes else "" lines.append(f"- [id={we.exercise_id}] {we.exercise.name}: {', '.join(set_strs)}{note}") return "\n".join(lines) if lines else None async def run_turn(text: str, deps: AgentDeps, *, model: Model | None = None) -> AgentOutcome: """Feed one user message to the agent, merging it into the athlete's draft. The last few turns (short TTL, see ``dialog_memory``) are replayed as message history so follow-ups work; the draft itself still lives in the DB and is re-injected fresh each turn. """ draft = await get_open_draft(deps.session, deps.user.id, deps.today) deps.draft_summary = _draft_summary_for_agent(draft) if draft else None if deps.athlete_context is None: deps.athlete_context = await build_athlete_context(deps.session, deps.user) deps.saved = False deps.plan_note = None history = await load_dialog(deps.session, deps.user.id) result = await agent.run(text, deps=deps, model=model or build_model(), message_history=history) await save_dialog(deps.session, deps.user.id, list(result.all_messages())) await deps.session.commit() if deps.saved: draft = await get_open_draft(deps.session, deps.user.id, deps.today) if draft is not None: rendered, count = _render_draft(draft) if deps.plan_note: rendered += f"\n\n{deps.plan_note}" return WorkoutDraft(text=rendered, exercise_count=count) return AgentReply(text=result.output or "")