smart tdee
This commit is contained in:
@@ -1,143 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
BASELINE_STEPS_BY_LEVEL: dict[str, int] = {
|
||||
"sedentary": 5000,
|
||||
"light": 7000,
|
||||
"moderate": 9000,
|
||||
"active": 11000,
|
||||
"very_active": 13000,
|
||||
}
|
||||
DEFAULT_MET = 5.0
|
||||
|
||||
WORKOUT_KCAL_PER_SESSION = 200
|
||||
KCAL_PER_STEP_PER_KG = 0.0005
|
||||
FALLBACK_KCAL_PER_MIN = 6
|
||||
MET_BY_KEYWORD: list[tuple[str, float]] = [
|
||||
("триатлон", 10.0),
|
||||
("марафон", 9.8),
|
||||
("бег", 9.8),
|
||||
("running", 9.8),
|
||||
("run", 9.0),
|
||||
("плаван", 8.0),
|
||||
("swim", 8.0),
|
||||
("велосипед", 7.5),
|
||||
("cycling", 7.5),
|
||||
("вел", 7.5),
|
||||
("hiit", 8.0),
|
||||
("кроссфит", 8.0),
|
||||
("силов", 6.0),
|
||||
("strength", 6.0),
|
||||
("зал", 5.5),
|
||||
("gym", 5.5),
|
||||
("йога", 3.0),
|
||||
("yoga", 3.0),
|
||||
("ходьб", 3.5),
|
||||
("walk", 3.5),
|
||||
("прогул", 3.5),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityBonus:
|
||||
steps: int
|
||||
steps_baseline: int
|
||||
steps_bonus_kcal: float
|
||||
workout_active_kcal: float
|
||||
workout_baseline_kcal: float
|
||||
workout_bonus_kcal: float
|
||||
total_bonus_kcal: float
|
||||
scale_factor: float
|
||||
def infer_met(workout: dict[str, Any]) -> float | None:
|
||||
explicit = workout.get("met")
|
||||
if explicit is not None:
|
||||
return float(explicit)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
activity_type = str(workout.get("activity_type") or "").lower()
|
||||
title = str(workout.get("title") or "").lower()
|
||||
notes = str(workout.get("notes") or "").lower()
|
||||
haystack = f"{activity_type} {title} {notes}"
|
||||
|
||||
for keyword, met in MET_BY_KEYWORD:
|
||||
if keyword in haystack:
|
||||
return met
|
||||
return None
|
||||
|
||||
|
||||
def baseline_steps(profile: dict[str, Any]) -> int:
|
||||
override = profile.get("baseline_steps")
|
||||
if override is not None:
|
||||
return int(override)
|
||||
level = str(profile.get("activity_level") or "moderate")
|
||||
return BASELINE_STEPS_BY_LEVEL.get(level, 9000)
|
||||
|
||||
|
||||
def baseline_workout_kcal_day(profile: dict[str, Any]) -> float:
|
||||
override = profile.get("baseline_workout_kcal")
|
||||
if override is not None:
|
||||
return float(override)
|
||||
weekly = int(profile.get("weekly_workouts") or 3)
|
||||
return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1)
|
||||
|
||||
|
||||
def estimate_workout_active_kcal(workout: dict[str, Any]) -> float:
|
||||
def estimate_workout_active_kcal(workout: dict[str, Any], *, weight_kg: float) -> float:
|
||||
active = workout.get("active_calories")
|
||||
if active is not None:
|
||||
return float(active)
|
||||
return round(float(active), 1)
|
||||
|
||||
duration = workout.get("duration_min")
|
||||
if duration:
|
||||
return float(duration) * FALLBACK_KCAL_PER_MIN
|
||||
return 0.0
|
||||
if not duration:
|
||||
return 0.0
|
||||
|
||||
met = infer_met(workout)
|
||||
if met is None:
|
||||
return 0.0
|
||||
|
||||
hours = float(duration) / 60.0
|
||||
return round(met * weight_kg * hours, 1)
|
||||
|
||||
|
||||
def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float:
|
||||
extra_steps = max(0, steps - baseline_steps)
|
||||
return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1)
|
||||
|
||||
|
||||
def compute_activity_bonus(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
steps_total: int,
|
||||
workouts: list[dict[str, Any]],
|
||||
) -> ActivityBonus:
|
||||
weight_kg = float(profile.get("weight_kg") or 70)
|
||||
steps_base = baseline_steps(profile)
|
||||
workout_base = baseline_workout_kcal_day(profile)
|
||||
|
||||
s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg)
|
||||
workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1)
|
||||
w_bonus = max(0.0, round(workout_active - workout_base, 1))
|
||||
total_bonus = round(s_bonus + w_bonus, 1)
|
||||
|
||||
base_cal = float(profile.get("calorie_target") or 2000)
|
||||
scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4)
|
||||
|
||||
return ActivityBonus(
|
||||
steps=steps_total,
|
||||
steps_baseline=steps_base,
|
||||
steps_bonus_kcal=s_bonus,
|
||||
workout_active_kcal=workout_active,
|
||||
workout_baseline_kcal=workout_base,
|
||||
workout_bonus_kcal=w_bonus,
|
||||
total_bonus_kcal=total_bonus,
|
||||
scale_factor=scale_factor,
|
||||
)
|
||||
|
||||
|
||||
def _targets_dict(
|
||||
*,
|
||||
calories: float,
|
||||
protein_g: float,
|
||||
fat_g: float,
|
||||
carbs_g: float,
|
||||
water_ml: float,
|
||||
) -> dict[str, float]:
|
||||
return {
|
||||
"calories": round(calories),
|
||||
"protein_g": round(protein_g),
|
||||
"fat_g": round(fat_g),
|
||||
"carbs_g": round(carbs_g),
|
||||
"water_ml": round(water_ml),
|
||||
}
|
||||
|
||||
|
||||
def build_base_targets(profile: dict[str, Any]) -> dict[str, float]:
|
||||
water_l = float(profile.get("water_l") or 2.5)
|
||||
return _targets_dict(
|
||||
calories=float(profile.get("calorie_target") or 2000),
|
||||
protein_g=float(profile.get("protein_g") or 140),
|
||||
fat_g=float(profile.get("fat_g") or 65),
|
||||
carbs_g=float(profile.get("carbs_g") or 200),
|
||||
water_ml=water_l * 1000,
|
||||
)
|
||||
|
||||
|
||||
def scale_targets(
|
||||
base_targets: dict[str, float],
|
||||
bonus_kcal: float,
|
||||
) -> tuple[dict[str, float], dict[str, float]]:
|
||||
"""Return (effective_targets, targets_base). Water is not scaled."""
|
||||
targets_base = dict(base_targets)
|
||||
base_cal = float(base_targets["calories"])
|
||||
|
||||
if bonus_kcal <= 0 or base_cal <= 0:
|
||||
return dict(base_targets), targets_base
|
||||
|
||||
scale = (base_cal + bonus_kcal) / base_cal
|
||||
effective = _targets_dict(
|
||||
calories=base_cal + bonus_kcal,
|
||||
protein_g=float(base_targets["protein_g"]) * scale,
|
||||
fat_g=float(base_targets["fat_g"]) * scale,
|
||||
carbs_g=float(base_targets["carbs_g"]) * scale,
|
||||
water_ml=float(base_targets["water_ml"]),
|
||||
)
|
||||
return effective, targets_base
|
||||
|
||||
def workouts_kcal_total(workouts: list[dict[str, Any]], *, weight_kg: float) -> float:
|
||||
if not workouts:
|
||||
return 0.0
|
||||
return round(sum(estimate_workout_active_kcal(w, weight_kg=weight_kg) for w in workouts), 1)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from typing import Any
|
||||
|
||||
ACTIVITY_MULTIPLIERS = {
|
||||
"sedentary": 1.2,
|
||||
"light": 1.375,
|
||||
"moderate": 1.55,
|
||||
"active": 1.725,
|
||||
"very_active": 1.9,
|
||||
}
|
||||
from app.fitness.activity_budget import workouts_kcal_total
|
||||
|
||||
DEFAULT_NEAT_KCAL = 200.0
|
||||
NEAT_KCAL_MIN = 200.0
|
||||
NEAT_KCAL_MAX = 300.0
|
||||
KCAL_PER_STEP_REF = 0.04 / 86 # ~0.04 kcal/step at 86 kg
|
||||
WATER_ML_PER_KG = 33 # middle of 30–35 ml/kg range
|
||||
|
||||
GOAL_CALORIE_ADJUST = {
|
||||
"lose": -500,
|
||||
@@ -14,6 +14,13 @@ GOAL_CALORIE_ADJUST = {
|
||||
"gain": 300,
|
||||
}
|
||||
|
||||
PROTEIN_G_PER_KG = {
|
||||
"lose": 2.2,
|
||||
"maintain": 1.8,
|
||||
"gain": 1.8,
|
||||
}
|
||||
FAT_G_PER_KG = 1.0
|
||||
|
||||
|
||||
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
|
||||
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
|
||||
@@ -22,17 +29,17 @@ def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> fl
|
||||
return base - 161
|
||||
|
||||
|
||||
def tdee(
|
||||
*,
|
||||
sex: str,
|
||||
weight_kg: float,
|
||||
height_cm: float,
|
||||
age: int,
|
||||
activity_level: str = "moderate",
|
||||
) -> float:
|
||||
bmr = bmr_mifflin(sex=sex, weight_kg=weight_kg, height_cm=height_cm, age=age)
|
||||
mult = ACTIVITY_MULTIPLIERS.get(activity_level, 1.55)
|
||||
return bmr * mult
|
||||
def neat_base_kcal(profile: dict[str, Any]) -> float:
|
||||
raw = profile.get("neat_base_kcal")
|
||||
if raw is not None:
|
||||
return max(NEAT_KCAL_MIN, min(NEAT_KCAL_MAX, float(raw)))
|
||||
return DEFAULT_NEAT_KCAL
|
||||
|
||||
|
||||
def steps_kcal(*, steps: int, weight_kg: float) -> float:
|
||||
if steps <= 0:
|
||||
return 0.0
|
||||
return round(steps * weight_kg * KCAL_PER_STEP_REF, 1)
|
||||
|
||||
|
||||
def bmi(weight_kg: float, height_cm: float) -> float:
|
||||
@@ -43,7 +50,7 @@ def bmi(weight_kg: float, height_cm: float) -> float:
|
||||
|
||||
|
||||
def water_target_l(weight_kg: float) -> float:
|
||||
return round(weight_kg * 0.033, 1)
|
||||
return round(weight_kg * WATER_ML_PER_KG / 1000, 1)
|
||||
|
||||
|
||||
def macro_targets(
|
||||
@@ -51,8 +58,8 @@ def macro_targets(
|
||||
weight_kg: float,
|
||||
goal: str = "maintain",
|
||||
) -> dict[str, float]:
|
||||
protein_g = round(weight_kg * (2.0 if goal == "gain" else 1.8), 0)
|
||||
fat_g = round((calorie_target * 0.25) / 9, 0)
|
||||
protein_g = round(weight_kg * PROTEIN_G_PER_KG.get(goal, 1.8), 0)
|
||||
fat_g = round(weight_kg * FAT_G_PER_KG, 0)
|
||||
protein_cal = protein_g * 4
|
||||
fat_cal = fat_g * 9
|
||||
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
|
||||
@@ -67,28 +74,95 @@ def one_rep_max(weight_kg: float, reps: int) -> float:
|
||||
return round(weight_kg * (1 + reps / 30), 1)
|
||||
|
||||
|
||||
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
def _profile_fields(profile: dict[str, Any]) -> tuple[float, float, int, str, str]:
|
||||
weight = float(profile.get("weight_kg") or 70)
|
||||
height = float(profile.get("height_cm") or 170)
|
||||
age = int(profile.get("age") or 30)
|
||||
sex = str(profile.get("sex") or "male")
|
||||
activity = str(profile.get("activity_level") or "moderate")
|
||||
goal = str(profile.get("goal") or "maintain")
|
||||
return weight, height, age, sex, goal
|
||||
|
||||
tdee_val = tdee(
|
||||
sex=sex, weight_kg=weight, height_cm=height, age=age, activity_level=activity
|
||||
)
|
||||
calorie_target = round(tdee_val + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
|
||||
|
||||
def compute_tdee(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
steps_total: int = 0,
|
||||
workouts: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, float]:
|
||||
weight, height, age, sex, _ = _profile_fields(profile)
|
||||
bmr = bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age)
|
||||
neat = neat_base_kcal(profile)
|
||||
s_kcal = steps_kcal(steps=steps_total, weight_kg=weight)
|
||||
w_kcal = workouts_kcal_total(workouts or [], weight_kg=weight)
|
||||
tdee_val = bmr + neat + s_kcal + w_kcal
|
||||
return {
|
||||
"bmr": round(bmr, 0),
|
||||
"neat_kcal": round(neat, 0),
|
||||
"steps_kcal": s_kcal,
|
||||
"workout_kcal": w_kcal,
|
||||
"tdee": round(tdee_val, 0),
|
||||
}
|
||||
|
||||
|
||||
def compute_daily_targets(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
steps_total: int = 0,
|
||||
workouts: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
weight, height, age, sex, goal = _profile_fields(profile)
|
||||
breakdown = compute_tdee(profile, steps_total=steps_total, workouts=workouts)
|
||||
calorie_target = round(breakdown["tdee"] + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
|
||||
macros = macro_targets(calorie_target, weight, goal)
|
||||
water = water_target_l(weight)
|
||||
|
||||
return {
|
||||
"bmr": round(bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age), 0),
|
||||
"tdee": round(tdee_val, 0),
|
||||
"bmi": round(bmi(weight, height), 1),
|
||||
**breakdown,
|
||||
"calorie_target": calorie_target,
|
||||
"protein_g": macros["protein_g"],
|
||||
"fat_g": macros["fat_g"],
|
||||
"carbs_g": macros["carbs_g"],
|
||||
"water_l": water,
|
||||
"bmi": round(bmi(weight, height), 1),
|
||||
"steps": steps_total,
|
||||
}
|
||||
|
||||
|
||||
def targets_to_api(daily: dict[str, Any]) -> dict[str, float]:
|
||||
return {
|
||||
"calories": daily["calorie_target"],
|
||||
"protein_g": daily["protein_g"],
|
||||
"fat_g": daily["fat_g"],
|
||||
"carbs_g": daily["carbs_g"],
|
||||
"water_ml": round(daily["water_l"] * 1000),
|
||||
}
|
||||
|
||||
|
||||
def tdee_breakdown_to_api(daily: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"bmr": daily["bmr"],
|
||||
"neat_kcal": daily["neat_kcal"],
|
||||
"steps_kcal": daily["steps_kcal"],
|
||||
"workout_kcal": daily["workout_kcal"],
|
||||
"tdee": daily["tdee"],
|
||||
"calorie_target": daily["calorie_target"],
|
||||
"steps": daily.get("steps", 0),
|
||||
}
|
||||
|
||||
|
||||
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Rest-day targets (BMR + NEAT, no steps/workouts) for profile storage."""
|
||||
daily = compute_daily_targets(profile, steps_total=0, workouts=[])
|
||||
return {
|
||||
"bmr": daily["bmr"],
|
||||
"tdee": daily["tdee"],
|
||||
"bmi": daily["bmi"],
|
||||
"neat_kcal": daily["neat_kcal"],
|
||||
"steps_kcal": 0,
|
||||
"workout_kcal": 0,
|
||||
"calorie_target": daily["calorie_target"],
|
||||
"protein_g": daily["protein_g"],
|
||||
"fat_g": daily["fat_g"],
|
||||
"carbs_g": daily["carbs_g"],
|
||||
"water_l": daily["water_l"],
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||
if not profile:
|
||||
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
|
||||
else:
|
||||
computed = profile.get("computed") or {}
|
||||
lines.append(
|
||||
f"Цели (база): {profile.get('calorie_target')} ккал, "
|
||||
f"Цели (база, без шагов/тренировок): {profile.get('calorie_target')} ккал, "
|
||||
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
|
||||
f"вода {profile.get('water_l')} л"
|
||||
)
|
||||
lines.append(
|
||||
f"BMR {computed.get('bmr', '?')} + NEAT {computed.get('neat_kcal', 200)} = "
|
||||
f"TDEE база {computed.get('tdee', '?')} ккал"
|
||||
)
|
||||
if profile.get("goal"):
|
||||
lines.append(
|
||||
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
|
||||
@@ -30,19 +35,23 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||
today = snapshot.get("today") or {}
|
||||
totals = today.get("totals") or {}
|
||||
targets = today.get("targets") or {}
|
||||
targets_base = today.get("targets_base") or {}
|
||||
activity = today.get("activity") or {}
|
||||
breakdown = today.get("tdee_breakdown") or {}
|
||||
steps_total = today.get("steps_total") or 0
|
||||
water_l = totals.get("water_ml", 0) / 1000
|
||||
water_target = targets.get("water_ml", 2500) / 1000
|
||||
|
||||
if profile and (activity.get("total_bonus_kcal") or steps_total):
|
||||
if breakdown:
|
||||
lines.append(
|
||||
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
|
||||
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
|
||||
f"TDEE за день: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
|
||||
f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + "
|
||||
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
|
||||
f"цель {breakdown.get('calorie_target')} ккал"
|
||||
)
|
||||
elif steps_total == 0:
|
||||
lines.append(
|
||||
"Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. "
|
||||
"log_steps / log_workout для точной дневной цели."
|
||||
)
|
||||
base_cal = targets_base.get("calories", profile.get("calorie_target"))
|
||||
lines.append(f"Эффективная цель ккал: {base_cal} → {targets.get('calories', base_cal)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
@@ -61,7 +70,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||
if stats.get("count"):
|
||||
lines.append(
|
||||
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
|
||||
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)"
|
||||
f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)"
|
||||
)
|
||||
|
||||
latest = (snapshot.get("body_metrics") or [None])[0]
|
||||
@@ -89,6 +98,9 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
|
||||
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
|
||||
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
|
||||
"Еда — оценка LLM (≈), пользователь может уточнить."
|
||||
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
|
||||
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
|
||||
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
|
||||
"Еда — оценка LLM (≈)."
|
||||
)
|
||||
return chr(10).join(lines)
|
||||
|
||||
@@ -14,13 +14,14 @@ from app.db.models import (
|
||||
WaterLog,
|
||||
WorkoutLog,
|
||||
)
|
||||
from app.fitness.activity_budget import (
|
||||
build_base_targets,
|
||||
compute_activity_bonus,
|
||||
estimate_workout_active_kcal,
|
||||
scale_targets,
|
||||
from app.fitness.activity_budget import estimate_workout_active_kcal
|
||||
from app.fitness.calculators import (
|
||||
compute_daily_targets,
|
||||
compute_targets,
|
||||
one_rep_max,
|
||||
targets_to_api,
|
||||
tdee_breakdown_to_api,
|
||||
)
|
||||
from app.fitness.calculators import compute_targets, one_rep_max
|
||||
from app.fitness.body_composition import compute_body_composition
|
||||
|
||||
DEFAULT_REMINDERS = [
|
||||
@@ -45,28 +46,26 @@ class FitnessService:
|
||||
return None
|
||||
return self._profile_to_dict(row)
|
||||
|
||||
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||
targets = compute_targets(
|
||||
{
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"activity_level": row.activity_level,
|
||||
"goal": row.goal,
|
||||
}
|
||||
)
|
||||
def _profile_params(self, row: FitnessProfile) -> dict[str, Any]:
|
||||
return {
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"goal": row.goal,
|
||||
"neat_base_kcal": row.neat_base_kcal,
|
||||
}
|
||||
|
||||
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||
targets = compute_targets(self._profile_params(row))
|
||||
return {
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"activity_level": row.activity_level,
|
||||
"goal": row.goal,
|
||||
"target_weight_kg": row.target_weight_kg,
|
||||
"weekly_workouts": row.weekly_workouts,
|
||||
"baseline_steps": row.baseline_steps,
|
||||
"baseline_workout_kcal": row.baseline_workout_kcal,
|
||||
"neat_base_kcal": row.neat_base_kcal,
|
||||
"calorie_target": row.calorie_target,
|
||||
"protein_g": row.protein_g,
|
||||
"fat_g": row.fat_g,
|
||||
@@ -85,23 +84,13 @@ class FitnessService:
|
||||
self.db.flush()
|
||||
|
||||
for key in (
|
||||
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||
"goal", "target_weight_kg", "weekly_workouts",
|
||||
"baseline_steps", "baseline_workout_kcal",
|
||||
"sex", "age", "height_cm", "weight_kg",
|
||||
"goal", "target_weight_kg", "neat_base_kcal",
|
||||
):
|
||||
if key in updates and updates[key] is not None:
|
||||
setattr(row, key, updates[key])
|
||||
|
||||
targets = compute_targets(
|
||||
{
|
||||
"sex": row.sex,
|
||||
"age": row.age,
|
||||
"height_cm": row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"activity_level": row.activity_level,
|
||||
"goal": row.goal,
|
||||
}
|
||||
)
|
||||
targets = compute_targets(self._profile_params(row))
|
||||
row.calorie_target = targets["calorie_target"]
|
||||
row.protein_g = targets["protein_g"]
|
||||
row.fat_g = targets["fat_g"]
|
||||
@@ -193,14 +182,12 @@ class FitnessService:
|
||||
if profile:
|
||||
return profile
|
||||
return {
|
||||
"calorie_target": 2000,
|
||||
"protein_g": 140,
|
||||
"fat_g": 65,
|
||||
"carbs_g": 200,
|
||||
"water_l": 2.5,
|
||||
"weight_kg": 70,
|
||||
"activity_level": "moderate",
|
||||
"weekly_workouts": 3,
|
||||
"height_cm": 170,
|
||||
"age": 30,
|
||||
"sex": "male",
|
||||
"goal": "maintain",
|
||||
"neat_base_kcal": 200,
|
||||
}
|
||||
|
||||
|
||||
@@ -248,24 +235,19 @@ class FitnessService:
|
||||
"steps": steps_total,
|
||||
}
|
||||
|
||||
base_targets = build_base_targets(profile)
|
||||
activity = compute_activity_bonus(
|
||||
daily = compute_daily_targets(
|
||||
profile,
|
||||
steps_total=steps_total,
|
||||
workouts=workouts,
|
||||
)
|
||||
effective_targets, targets_base = scale_targets(
|
||||
base_targets,
|
||||
activity.total_bonus_kcal,
|
||||
)
|
||||
targets = targets_to_api(daily)
|
||||
|
||||
return {
|
||||
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
||||
"profile_configured": profile_row is not None,
|
||||
"totals": totals,
|
||||
"targets": effective_targets,
|
||||
"targets_base": targets_base,
|
||||
"activity": activity.to_dict(),
|
||||
"targets": targets,
|
||||
"tdee_breakdown": tdee_breakdown_to_api(daily),
|
||||
"meals": [self._food_to_dict(f) for f in foods],
|
||||
"water": [self._water_to_dict(w) for w in waters],
|
||||
"workouts": workouts,
|
||||
@@ -393,8 +375,8 @@ class FitnessService:
|
||||
"age": profile_row.age,
|
||||
"height_cm": profile_row.height_cm,
|
||||
"weight_kg": weight_kg,
|
||||
"activity_level": profile_row.activity_level,
|
||||
"goal": profile_row.goal,
|
||||
"neat_base_kcal": profile_row.neat_base_kcal,
|
||||
}
|
||||
)
|
||||
profile_row.calorie_target = targets["calorie_target"]
|
||||
@@ -428,10 +410,27 @@ class FitnessService:
|
||||
active_calories: float | None = None,
|
||||
total_calories: float | None = None,
|
||||
steps: int | None = None,
|
||||
activity_type: str | None = None,
|
||||
met: float | None = None,
|
||||
logged_at: datetime | str | None = None,
|
||||
day: date | None = None,
|
||||
days_ago: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = self.get_profile() or {}
|
||||
weight_kg = float(profile.get("weight_kg") or 70)
|
||||
|
||||
if active_calories is None and duration_min and met is not None:
|
||||
active_calories = round(met * weight_kg * (float(duration_min) / 60.0), 1)
|
||||
elif active_calories is None and duration_min:
|
||||
draft = {
|
||||
"title": title,
|
||||
"notes": notes,
|
||||
"activity_type": activity_type,
|
||||
"met": met,
|
||||
"duration_min": duration_min,
|
||||
}
|
||||
active_calories = estimate_workout_active_kcal(draft, weight_kg=weight_kg) or None
|
||||
|
||||
row = WorkoutLog(
|
||||
user_id=self.user_id,
|
||||
title=title[:255],
|
||||
@@ -471,12 +470,16 @@ class FitnessService:
|
||||
).all()
|
||||
|
||||
profile = self.get_profile() or {}
|
||||
weekly_target = int(profile.get("weekly_workouts") or 3)
|
||||
weight_kg = float(profile.get("weight_kg") or 70)
|
||||
weekly_target = 3
|
||||
|
||||
count = len(rows)
|
||||
duration_min = sum(r.duration_min or 0 for r in rows)
|
||||
active_kcal = round(
|
||||
sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows),
|
||||
sum(
|
||||
estimate_workout_active_kcal(self._workout_to_dict(r), weight_kg=weight_kg)
|
||||
for r in rows
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
@@ -583,7 +586,7 @@ class FitnessService:
|
||||
*,
|
||||
days: int = 7,
|
||||
end_day: date | None = None,
|
||||
include_targets_base: bool = True,
|
||||
include_tdee_breakdown: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
days = max(1, min(days, 90))
|
||||
end = end_day or datetime.now(timezone.utc).date()
|
||||
@@ -603,8 +606,8 @@ class FitnessService:
|
||||
"meal_count": len(full["meals"]),
|
||||
"workout_count": len(full["workouts"]),
|
||||
}
|
||||
if include_targets_base:
|
||||
item["targets_base"] = full.get("targets_base")
|
||||
if include_tdee_breakdown:
|
||||
item["tdee_breakdown"] = full.get("tdee_breakdown")
|
||||
summaries.append(item)
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,8 +28,10 @@ WORKOUT_PROMPT = """
|
||||
Формат:
|
||||
{
|
||||
"title": "название",
|
||||
"activity_type": "ходьба|бег|силовая|велосипед|плавание|йога|hiit|другое",
|
||||
"duration_min": null,
|
||||
"active_calories": null,
|
||||
"met": null,
|
||||
"total_calories": null,
|
||||
"steps": null,
|
||||
"notes": "",
|
||||
@@ -39,7 +41,11 @@ WORKOUT_PROMPT = """
|
||||
}
|
||||
Правила:
|
||||
- weight_kg в кг, округляй разумно.
|
||||
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null.
|
||||
- active_calories — только если явно указаны в тексте, иначе null.
|
||||
- duration_min — длительность в минутах, если можно оценить из текста.
|
||||
- met — MET по Compendium of Physical Activities, если ккал не указаны (ходьба ~3.5, бег ~9.8, силовая ~6, велосипед ~7.5, плавание ~8, йога ~3, hiit ~8).
|
||||
- activity_type — тип активности для расчёта MET.
|
||||
- total_calories / steps — если упомянуты в тексте, иначе null.
|
||||
- Если данных нет — null или пустой массив.
|
||||
""".strip()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user