614 lines
24 KiB
Python
614 lines
24 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",
|
||
"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
|