Files
Home_assistant/backend/app/tools/fitness.py
T
2026-06-26 07:52:07 +03:00

614 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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