Files
Home_assistant/backend/app/tools/fitness.py
T
2026-06-16 09:19:32 +03:00

404 lines
15 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",
"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