added fitness

This commit is contained in:
2026-06-10 09:12:50 +03:00
parent 0b39692300
commit d0bdd1e95c
25 changed files with 2082 additions and 7 deletions
View File
+94
View File
@@ -0,0 +1,94 @@
from typing import Any
ACTIVITY_MULTIPLIERS = {
"sedentary": 1.2,
"light": 1.375,
"moderate": 1.55,
"active": 1.725,
"very_active": 1.9,
}
GOAL_CALORIE_ADJUST = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
if sex.lower() in ("m", "male", "м", "мужской"):
return base + 5
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 bmi(weight_kg: float, height_cm: float) -> float:
if height_cm <= 0:
return 0.0
h = height_cm / 100
return weight_kg / (h * h)
def water_target_l(weight_kg: float) -> float:
return round(weight_kg * 0.033, 1)
def macro_targets(
calorie_target: float,
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_cal = protein_g * 4
fat_cal = fat_g * 9
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
def one_rep_max(weight_kg: float, reps: int) -> float:
if reps <= 0:
return weight_kg
if reps == 1:
return weight_kg
return round(weight_kg * (1 + reps / 30), 1)
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
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")
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)
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),
"calorie_target": calorie_target,
"protein_g": macros["protein_g"],
"fat_g": macros["fat_g"],
"carbs_g": macros["carbs_g"],
"water_l": water,
}
+55
View File
@@ -0,0 +1,55 @@
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session) -> dict[str, Any]:
return FitnessService(db).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile")
if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else:
lines.append(
f"Цели: {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return "\n".join(lines)
+114
View File
@@ -0,0 +1,114 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import ChatSession, FitnessReminder, Message
from app.fitness.service import FitnessService
KIND_LABELS = {
"water": "Вода",
"meal": "Еда",
"workout": "Тренировка",
"weigh_in": "Взвешивание",
}
def _post_fitness_notice(content: str) -> None:
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Фитнес")
db.add(session)
db.commit()
db.refresh(session)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
finally:
db.close()
def _build_notice(kind: str, summary: dict) -> str:
label = KIND_LABELS.get(kind, kind)
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
cals = totals.get("calories", 0)
cal_target = targets.get("calories", 2000)
if kind == "water":
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды."
)
if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
"Не забудь залогировать приём пищи."
)
if kind == "workout":
workouts = summary.get("workouts") or []
if workouts:
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
return f"💪 **{label}** · напоминание"
def check_reminders(db: Session) -> list[str]:
if not get_settings().fitness_reminders_enabled:
return []
now = datetime.now(timezone.utc)
service = FitnessService(db)
summary = service.get_daily_summary()
fired: list[str] = []
reminders = db.scalars(
select(FitnessReminder).where(FitnessReminder.enabled.is_(True))
).all()
for rem in reminders:
should_fire = False
if rem.interval_hours:
if rem.last_fired_at is None:
should_fire = now.hour >= rem.hour
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(hours=rem.interval_hours)
else:
if rem.kind == "weigh_in":
if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(days=7)
else:
should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if already_today:
continue
should_fire = now.hour == rem.hour and now.minute >= rem.minute
if not should_fire:
continue
notice = _build_notice(rem.kind, summary)
rem.last_fired_at = now
fired.append(notice)
if fired:
db.commit()
for notice in fired:
_post_fitness_notice(notice)
return fired
+405
View File
@@ -0,0 +1,405 @@
import json
from datetime import date, datetime, time, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session):
self.db = db
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
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,
}
)
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,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile()
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
):
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,
}
)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(**item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile = self.get_profile()
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
}
targets = profile or {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
}
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile is not None,
"totals": totals,
"targets": {
"calories": targets.get("calorie_target", 2000),
"protein_g": targets.get("protein_g", 140),
"fat_g": targets.get("fat_g", 65),
"carbs_g": targets.get("carbs_g", 200),
"water_ml": targets.get("water_l", 2.5) * 1000,
},
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": [self._workout_to_dict(w) for w in workouts],
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
notes: str = "",
) -> dict[str, Any]:
row = BodyMetric(
weight_kg=weight_kg,
body_fat_pct=body_fat_pct,
chest_cm=chest_cm,
waist_cm=waist_cm,
notes=notes[:1000],
)
self.db.add(row)
profile = self._get_profile_row()
if profile:
profile.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile.sex,
"age": profile.age,
"height_cm": profile.height_cm,
"weight_kg": weight_kg,
"activity_level": profile.activity_level,
"goal": profile.goal,
}
)
profile.calorie_target = targets["calorie_target"]
profile.protein_g = targets["protein_g"]
profile.fat_g = targets["fat_g"]
profile.carbs_g = targets["carbs_g"]
profile.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
return {
"ok": True,
"metric": {
"id": row.id,
"weight_kg": row.weight_kg,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
row = WorkoutLog(
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [
{
"id": r.id,
"weight_kg": r.weight_kg,
"body_fat_pct": r.body_fat_pct,
"chest_cm": r.chest_cm,
"waist_cm": r.waist_cm,
"notes": r.notes,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
}
for r in rows
]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def snapshot(self) -> dict[str, Any]:
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
"id": row.id,
"meal_type": row.meal_type,
"description": row.description,
"calories": row.calories,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"source": row.source,
"estimated": row.estimated,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
return {
"id": row.id,
"amount_ml": row.amount_ml,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
try:
exercises = json.loads(row.exercises_json or "[]")
except json.JSONDecodeError:
exercises = []
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"duration_min": row.duration_min,
"exercises": exercises,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
return {
"id": row.id,
"kind": row.kind,
"hour": row.hour,
"minute": row.minute,
"interval_hours": row.interval_hours,
"enabled": row.enabled,
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
}
+66
View File
@@ -0,0 +1,66 @@
import json
from typing import Any
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема:
{
"meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание",
"calories": 0,
"protein_g": 0,
"fat_g": 0,
"carbs_g": 0,
"estimated": true
}
Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа — float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки.
""".strip()
WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON.
Схема:
{
"title": "название",
"duration_min": null,
"notes": "",
"exercises": [
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, метрическая система.
- Если данных нет — null или пустой массив.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
+28
View File
@@ -0,0 +1,28 @@
import asyncio
import logging
from app.db.base import SessionLocal
from app.fitness.reminders import check_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
async def fitness_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Fitness watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
check_reminders(db)
finally:
db.close()