404 lines
15 KiB
Python
404 lines
15 KiB
Python
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
|