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", "get_body_metrics", "compare_fitness_days", "set_fitness_profile", "calc_fitness_targets", "calc_body_composition", "log_meal", "log_water", "log_weight", "log_steps", "log_workout", "update_fitness_log", "delete_fitness_log", "merge_fitness_logs", "lookup_food", "lookup_exercise", "set_fitness_reminder", "get_body_metrics", }) 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_body_metrics", "description": ( "История антропометрии: вес, % жира, обхваты, WHR, FFMI по датам. " "«Как менялся вес», «динамика жира», «прошлые замеры», «что было месяц назад»." ), "parameters": { "type": "object", "properties": { "limit": { "type": "integer", "description": "Сколько последних записей, по умолчанию 20, макс. 50", }, }, "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": "compare_fitness_days", "description": ( "Сравнить несколько дней бок о бок: ккал, БЖУ, вода, шаги, кол-во приёмов пищи. " "«Сравни понедельник и вторник», «калории вчера vs сегодня»." ), "parameters": { "type": "object", "properties": { "dates": { "type": "array", "items": {"type": "string"}, "description": "Даты YYYY-MM-DD, минимум 2", }, }, "required": ["dates"], }, }, }, { "type": "function", "function": { "name": "update_fitness_log", "description": ( "Исправить уже записанную запись: еда (ккал/БЖУ/клетчатка), вода (мл), шаги. " "Нужен log_id из get_fitness_summary." ), "parameters": { "type": "object", "properties": { "kind": {"type": "string", "description": "meal|water|steps"}, "log_id": {"type": "integer"}, "description": {"type": "string"}, "calories": {"type": "number"}, "protein_g": {"type": "number"}, "fat_g": {"type": "number"}, "carbs_g": {"type": "number"}, "fiber_g": {"type": "number"}, "estimated": {"type": "boolean"}, "amount_ml": {"type": "integer"}, "steps": {"type": "integer"}, "active_calories": {"type": "number"}, "notes": {"type": "string"}, }, "required": ["kind", "log_id"], }, }, }, { "type": "function", "function": { "name": "delete_fitness_log", "description": ( "Удалить запись фитнеса. last=true — последняя за день (откат дубликата). " "Или log_id из get_fitness_summary." ), "parameters": { "type": "object", "properties": { "kind": {"type": "string", "description": "meal|water|steps|workout"}, "log_id": {"type": "integer"}, "last": {"type": "boolean", "description": "Удалить последнюю запись за день"}, "date": {"type": "string"}, "days_ago": {"type": "integer"}, }, "required": ["kind"], }, }, }, { "type": "function", "function": { "name": "merge_fitness_logs", "description": ( "Объединить две записи одного типа за день в одну (суммирует ккал/БЖУ/мл/шаги). " "keep_id остаётся, merge_id удаляется." ), "parameters": { "type": "object", "properties": { "kind": {"type": "string", "description": "meal|water|steps"}, "keep_id": {"type": "integer"}, "merge_id": {"type": "integer"}, }, "required": ["kind", "keep_id", "merge_id"], }, }, }, { "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 для прошлых дней). " "replace=true — финальное значение за день (заменяет предыдущие записи, не суммирует). " "Для итога с часов/Mi Fitness используй replace=true." ), "parameters": { "type": "object", "properties": { "steps": {"type": "integer"}, "active_calories": {"type": "number"}, "notes": {"type": "string"}, "date": {"type": "string"}, "days_ago": {"type": "integer"}, "replace": { "type": "boolean", "description": "true = заменить все записи шагов за этот день", }, }, "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"], }, }, }, ] def _resolve_day(arguments: dict[str, Any]) -> date | None: if arguments.get("date"): return date.fromisoformat(str(arguments["date"])) if arguments.get("days_ago") is not None: return datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"])) return None 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_body_metrics": limit = max(1, min(int(arguments.get("limit") or 20), 50)) metrics = fitness.list_body_metrics(limit=limit) return {"metrics": metrics, "count": len(metrics)} 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 == "compare_fitness_days": raw_dates = arguments.get("dates") or [] parsed = [date.fromisoformat(str(d)) for d in raw_dates if d] if len(parsed) < 2: return {"error": "Need at least two dates (YYYY-MM-DD)"} return fitness.compare_days(parsed) 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), fiber_g=float(structured.get("fiber_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": return fitness.log_steps( int(arguments.get("steps") or 0), active_calories=arguments.get("active_calories"), notes=arguments.get("notes", ""), day=_resolve_day(arguments), days_ago=arguments.get("days_ago"), replace=bool(arguments.get("replace")), source="llm", ) if name == "update_fitness_log": kind = str(arguments.get("kind", "")).lower().strip() log_id = int(arguments["log_id"]) if kind in ("meal", "meals", "food"): updates = { k: arguments[k] for k in ( "description", "meal_type", "calories", "protein_g", "fat_g", "carbs_g", "fiber_g", "estimated", ) if k in arguments and arguments[k] is not None } result = fitness.update_food_log(log_id, updates) elif kind == "water": updates = {k: arguments[k] for k in ("amount_ml",) if k in arguments and arguments[k] is not None} result = fitness.update_water_log(log_id, updates) elif kind in ("step", "steps"): updates = { k: arguments[k] for k in ("steps", "active_calories", "notes") if k in arguments } result = fitness.update_step_log(log_id, updates) else: return {"error": f"Unsupported kind: {kind}"} if not result: return {"error": "Not found"} return result if name == "delete_fitness_log": kind = str(arguments.get("kind", "")).lower().strip() if arguments.get("last"): result = fitness.delete_last_log(kind, day=_resolve_day(arguments)) if not result: return {"error": "No records found"} return result log_id = arguments.get("log_id") if log_id is None: return {"error": "Provide log_id or last=true"} log_id = int(log_id) deleted = False if kind in ("meal", "meals", "food"): deleted = fitness.delete_food_log(log_id) elif kind == "water": deleted = fitness.delete_water_log(log_id) elif kind in ("step", "steps"): deleted = fitness.delete_step_log(log_id) elif kind in ("workout", "workouts"): deleted = fitness.delete_workout_log(log_id) else: return {"error": f"Unsupported kind: {kind}"} if not deleted: return {"error": "Not found"} return {"ok": True, "kind": kind, "log_id": log_id} if name == "merge_fitness_logs": kind = str(arguments.get("kind", "")).lower().strip() keep_id = int(arguments["keep_id"]) merge_id = int(arguments["merge_id"]) if kind in ("meal", "meals", "food"): result = fitness.merge_food_logs(keep_id, merge_id) elif kind == "water": result = fitness.merge_water_logs(keep_id, merge_id) elif kind in ("step", "steps"): result = fitness.merge_step_logs(keep_id, merge_id) else: return {"error": f"Unsupported kind: {kind}"} if not result: return {"error": "Not found"} return result 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