This commit is contained in:
2026-06-16 09:19:32 +03:00
parent 7f1516c9c9
commit 8f3ac70b20
43 changed files with 1644 additions and 4668 deletions
+403
View File
@@ -0,0 +1,403 @@
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