from datetime import date, datetime, timedelta, timezone from typing import Any from app.fitness.service import FitnessService from app.fitness.structuring import structure_meal, structure_workout from app.integrations.openfoodfacts import OpenFoodFactsClient from app.integrations.wger import WgerClient from app.tools._dispatch import NOT_HANDLED, ToolContext TOOL_NAMES = frozenset({ "get_fitness_summary", "get_fitness_history", "set_fitness_profile", "calc_fitness_targets", "calc_body_composition", "log_meal", "log_water", "log_weight", "log_steps", "log_workout", "lookup_food", "lookup_exercise", "set_fitness_reminder", }) TOOL_DEFINITIONS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "get_fitness_summary", "description": ( "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. " "Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)." ), "parameters": { "type": "object", "properties": { "date": {"type": "string", "description": "Дата YYYY-MM-DD"}, "days_ago": { "type": "integer", "description": "0 сегодня, 1 вчера, 2 позавчера…", }, }, "required": [], }, }, }, { "type": "function", "function": { "name": "get_fitness_history", "description": ( "Краткая история за несколько дней (ккал, вода, тренировки по дням). " "«На прошлой неделе», «за 7 дней»." ), "parameters": { "type": "object", "properties": { "days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"}, "end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"}, }, "required": [], }, }, }, { "type": "function", "function": { "name": "set_fitness_profile", "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (TDEE = BMR + NEAT).", "parameters": { "type": "object", "properties": { "sex": {"type": "string", "description": "male/female"}, "age": {"type": "integer"}, "height_cm": {"type": "number"}, "weight_kg": {"type": "number"}, "goal": {"type": "string", "description": "lose/maintain/gain"}, "target_weight_kg": {"type": "number"}, "neat_base_kcal": { "type": "number", "description": "NEAT-база 200–300 ккал, по умолчанию 200", }, "activity_level": { "type": "string", "description": "sedentary/moderate/active/very_active — fallback для TDEE план", }, "weekly_workouts": { "type": "integer", "description": "Тренировок в неделю для fallback TDEE план", }, "baseline_steps": { "type": "integer", "description": "Ожидаемые шаги/день (fallback TDEE план)", }, "baseline_workout_kcal": { "type": "number", "description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)", }, }, "required": [], }, }, }, { "type": "function", "function": { "name": "calc_fitness_targets", "description": "Калькулятор BMR/TDEE/макросов без сохранения (rest-day: BMR + NEAT).", "parameters": { "type": "object", "properties": { "sex": {"type": "string"}, "age": {"type": "integer"}, "height_cm": {"type": "number"}, "weight_kg": {"type": "number"}, "goal": {"type": "string"}, "neat_base_kcal": {"type": "number"}, "steps": {"type": "integer", "description": "Шаги за день для расчёта TDEE"}, }, "required": ["weight_kg", "height_cm", "age"], }, }, }, { "type": "function", "function": { "name": "calc_body_composition", "description": ( "Navy-калькулятор % жира, WHR, LBM, FFMI без сохранения. " "Пол/рост/вес из профиля, если не указаны." ), "parameters": { "type": "object", "properties": { "sex": {"type": "string"}, "height_cm": {"type": "number"}, "weight_kg": {"type": "number"}, "neck_cm": {"type": "number"}, "waist_cm": {"type": "number"}, "hip_cm": {"type": "number"}, "body_fat_pct": {"type": "number"}, }, "required": [], }, }, }, { "type": "function", "function": { "name": "log_meal", "description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "Что съел"}, "meal_type": {"type": "string"}, }, "required": ["text"], }, }, }, { "type": "function", "function": { "name": "log_water", "description": "Записать воду в мл.", "parameters": { "type": "object", "properties": { "amount_ml": {"type": "integer"}, }, "required": ["amount_ml"], }, }, }, { "type": "function", "function": { "name": "log_weight", "description": ( "Записать антропометрию: вес и обхваты (см). " "При neck+waist(+hip для женщин) автоматически считается Navy % жира." ), "parameters": { "type": "object", "properties": { "weight_kg": {"type": "number"}, "body_fat_pct": {"type": "number"}, "neck_cm": {"type": "number"}, "waist_cm": {"type": "number"}, "hip_cm": {"type": "number"}, "chest_cm": {"type": "number"}, "notes": {"type": "string"}, "date": {"type": "string"}, "days_ago": {"type": "integer"}, }, "required": ["weight_kg"], }, }, }, { "type": "function", "function": { "name": "log_steps", "description": "Записать шаги (можно задним числом: date или days_ago).", "parameters": { "type": "object", "properties": { "steps": {"type": "integer"}, "active_calories": {"type": "number"}, "notes": {"type": "string"}, "date": {"type": "string"}, "days_ago": {"type": "integer"}, }, "required": ["steps"], }, }, }, { "type": "function", "function": { "name": "log_workout", "description": "Записать тренировку из текста (date/days_ago для прошлых дней).", "parameters": { "type": "object", "properties": { "text": {"type": "string"}, "date": {"type": "string"}, "days_ago": {"type": "integer"}, }, "required": ["text"], }, }, }, { "type": "function", "function": { "name": "lookup_food", "description": "Поиск продукта в Open Food Facts (ккал на 100г).", "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "lookup_exercise", "description": "Поиск упражнения в базе wger.", "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "set_fitness_reminder", "description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.", "parameters": { "type": "object", "properties": { "kind": {"type": "string"}, "enabled": {"type": "boolean"}, "hour": {"type": "integer"}, "minute": {"type": "integer"}, "interval_hours": {"type": "integer"}, }, "required": ["kind"], }, }, }, ] async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: if name not in TOOL_NAMES: return NOT_HANDLED fitness = FitnessService(ctx.db, ctx.user_id) if name == "get_fitness_summary": day: date | None = None if arguments.get("date"): day = date.fromisoformat(str(arguments["date"])) elif arguments.get("days_ago") is not None: day = datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"])) return fitness.get_daily_summary(day) if name == "get_fitness_history": end_day = None if arguments.get("end_date"): end_day = date.fromisoformat(str(arguments["end_date"])) return fitness.get_history( days=int(arguments.get("days") or 7), end_day=end_day, ) if name == "set_fitness_profile": updates = { k: arguments[k] for k in ( "sex", "age", "height_cm", "weight_kg", "goal", "target_weight_kg", "neat_base_kcal", "activity_level", "weekly_workouts", "baseline_steps", "baseline_workout_kcal", ) if k in arguments and arguments[k] is not None } return fitness.set_profile(updates) if name == "calc_fitness_targets": from app.fitness.calculators import compute_daily_targets steps = int(arguments.get("steps") or 0) return compute_daily_targets(arguments, steps_total=steps, workouts=[]) if name == "calc_body_composition": return fitness.calc_body_composition(arguments) if name == "log_meal": structured = await structure_meal(arguments.get("text", "")) return fitness.log_meal( description=structured.get("description") or arguments.get("text", ""), meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack", calories=float(structured.get("calories") or 0), protein_g=float(structured.get("protein_g") or 0), fat_g=float(structured.get("fat_g") or 0), carbs_g=float(structured.get("carbs_g") or 0), source="llm", estimated=True, ) if name == "log_water": return fitness.log_water(int(arguments.get("amount_ml", 250))) if name == "log_weight": day = None if arguments.get("date"): day = date.fromisoformat(str(arguments["date"])) return fitness.log_weight( float(arguments["weight_kg"]), body_fat_pct=arguments.get("body_fat_pct"), chest_cm=arguments.get("chest_cm"), waist_cm=arguments.get("waist_cm"), neck_cm=arguments.get("neck_cm"), hip_cm=arguments.get("hip_cm"), notes=arguments.get("notes", ""), day=day, days_ago=arguments.get("days_ago"), ) if name == "log_steps": day = None if arguments.get("date"): day = date.fromisoformat(str(arguments["date"])) return fitness.log_steps( int(arguments.get("steps") or 0), active_calories=arguments.get("active_calories"), notes=arguments.get("notes", ""), day=day, days_ago=arguments.get("days_ago"), ) if name == "log_workout": structured = await structure_workout(arguments.get("text", "")) day = None if arguments.get("date"): day = date.fromisoformat(str(arguments["date"])) return fitness.log_workout( title=structured.get("title") or "Тренировка", notes=structured.get("notes") or arguments.get("text", ""), duration_min=structured.get("duration_min"), exercises=structured.get("exercises"), active_calories=structured.get("active_calories"), total_calories=structured.get("total_calories"), steps=structured.get("steps"), activity_type=structured.get("activity_type"), met=structured.get("met"), day=day, days_ago=arguments.get("days_ago"), ) if name == "lookup_food": return OpenFoodFactsClient().search( arguments.get("query", ""), limit=arguments.get("limit", 5), ) if name == "lookup_exercise": return WgerClient().search_exercises( arguments.get("query", ""), limit=arguments.get("limit", 8), ) if name == "set_fitness_reminder": return fitness.set_reminder( arguments.get("kind", "water"), enabled=arguments.get("enabled"), hour=arguments.get("hour"), minute=arguments.get("minute"), interval_hours=arguments.get("interval_hours"), ) return NOT_HANDLED