added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
}
|
||||
|
||||
WORKOUT_KCAL_PER_SESSION = 200
|
||||
KCAL_PER_STEP_PER_KG = 0.0005
|
||||
FALLBACK_KCAL_PER_MIN = 6
|
||||
|
||||
|
||||
@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 to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
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:
|
||||
active = workout.get("active_calories")
|
||||
if active is not None:
|
||||
return float(active)
|
||||
duration = workout.get("duration_min")
|
||||
if duration:
|
||||
return float(duration) * FALLBACK_KCAL_PER_MIN
|
||||
return 0.0
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _is_female(sex: str) -> bool:
|
||||
return sex.lower() in ("f", "female", "ж", "женский", "woman")
|
||||
|
||||
|
||||
def _cm_to_inches(cm: float) -> float:
|
||||
return cm / 2.54
|
||||
|
||||
|
||||
def _clamp_bf(value: float) -> float:
|
||||
return round(max(3.0, min(50.0, value)), 1)
|
||||
|
||||
|
||||
def navy_body_fat_pct(
|
||||
*,
|
||||
sex: str,
|
||||
height_cm: float,
|
||||
neck_cm: float,
|
||||
waist_cm: float,
|
||||
hip_cm: float | None = None,
|
||||
) -> float | None:
|
||||
if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0:
|
||||
return None
|
||||
|
||||
height_in = _cm_to_inches(height_cm)
|
||||
neck_in = _cm_to_inches(neck_cm)
|
||||
waist_in = _cm_to_inches(waist_cm)
|
||||
|
||||
if _is_female(sex):
|
||||
if hip_cm is None or hip_cm <= 0:
|
||||
return None
|
||||
hip_in = _cm_to_inches(hip_cm)
|
||||
sum_in = waist_in + hip_in - neck_in
|
||||
if sum_in <= 0:
|
||||
return None
|
||||
denom = (
|
||||
1.29579
|
||||
- 0.35004 * math.log10(sum_in)
|
||||
+ 0.22100 * math.log10(height_in)
|
||||
)
|
||||
else:
|
||||
diff_in = waist_in - neck_in
|
||||
if diff_in <= 0:
|
||||
return None
|
||||
denom = (
|
||||
1.0324
|
||||
- 0.19077 * math.log10(diff_in)
|
||||
+ 0.15456 * math.log10(height_in)
|
||||
)
|
||||
|
||||
if denom <= 0:
|
||||
return None
|
||||
|
||||
return _clamp_bf(495.0 / denom - 450.0)
|
||||
|
||||
|
||||
def whr(waist_cm: float, hip_cm: float) -> float | None:
|
||||
if waist_cm <= 0 or hip_cm <= 0:
|
||||
return None
|
||||
return round(waist_cm / hip_cm, 2)
|
||||
|
||||
|
||||
def lean_body_mass(weight_kg: float, body_fat_pct: float) -> float:
|
||||
return round(weight_kg * (1.0 - body_fat_pct / 100.0), 1)
|
||||
|
||||
|
||||
def ffmi(weight_kg: float, height_cm: float, body_fat_pct: float) -> float | None:
|
||||
if height_cm <= 0:
|
||||
return None
|
||||
height_m = height_cm / 100.0
|
||||
lbm = weight_kg * (1.0 - body_fat_pct / 100.0)
|
||||
raw = lbm / (height_m * height_m)
|
||||
normalized = raw + 6.1 * (1.8 - height_m)
|
||||
return round(normalized, 1)
|
||||
|
||||
|
||||
def compute_body_composition(
|
||||
*,
|
||||
sex: str,
|
||||
height_cm: float,
|
||||
weight_kg: float,
|
||||
neck_cm: float | None = None,
|
||||
waist_cm: float | None = None,
|
||||
hip_cm: float | None = None,
|
||||
body_fat_pct: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
result: dict[str, Any] = {
|
||||
"body_fat_pct": None,
|
||||
"body_fat_method": None,
|
||||
"whr": None,
|
||||
"lbm_kg": None,
|
||||
"ffmi": None,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
bf = body_fat_pct
|
||||
method: str | None = "manual" if bf is not None else None
|
||||
|
||||
if bf is None and neck_cm and waist_cm:
|
||||
navy_bf = navy_body_fat_pct(
|
||||
sex=sex,
|
||||
height_cm=height_cm,
|
||||
neck_cm=neck_cm,
|
||||
waist_cm=waist_cm,
|
||||
hip_cm=hip_cm,
|
||||
)
|
||||
if navy_bf is not None:
|
||||
bf = navy_bf
|
||||
method = "navy"
|
||||
elif _is_female(sex) and not hip_cm:
|
||||
warnings.append("Для Navy у женщин нужен обхват бёдер (hip_cm).")
|
||||
elif neck_cm and waist_cm and waist_cm <= neck_cm:
|
||||
warnings.append("Обхват талии должен быть больше шеи для Navy.")
|
||||
|
||||
if bf is not None:
|
||||
result["body_fat_pct"] = round(float(bf), 1)
|
||||
result["body_fat_method"] = method
|
||||
result["lbm_kg"] = lean_body_mass(weight_kg, float(bf))
|
||||
result["ffmi"] = ffmi(weight_kg, height_cm, float(bf))
|
||||
|
||||
if waist_cm and hip_cm:
|
||||
result["whr"] = whr(waist_cm, hip_cm)
|
||||
|
||||
return result
|
||||
@@ -1,55 +1,94 @@
|
||||
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 (date/days_ago), get_fitness_history, "
|
||||
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
|
||||
"Еда — оценка LLM (≈), пользователь может уточнить."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.fitness.service import FitnessService
|
||||
|
||||
|
||||
def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]:
|
||||
return FitnessService(db, user_id).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 {}
|
||||
targets_base = today.get("targets_base") or {}
|
||||
activity = today.get("activity") 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):
|
||||
lines.append(
|
||||
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
|
||||
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
|
||||
)
|
||||
base_cal = targets_base.get("calories", profile.get("calorie_target"))
|
||||
lines.append(f"Эффективная цель ккал: {base_cal} → {targets.get('calories', base_cal)}")
|
||||
|
||||
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)}")
|
||||
|
||||
stats = snapshot.get("workout_stats") or {}
|
||||
if stats.get("count"):
|
||||
lines.append(
|
||||
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
|
||||
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)"
|
||||
)
|
||||
|
||||
latest = (snapshot.get("body_metrics") or [None])[0]
|
||||
if latest:
|
||||
lines.append("")
|
||||
lines.append("Антропометрия (последняя):")
|
||||
parts = [f"{latest.get('weight_kg')} кг"]
|
||||
if latest.get("body_fat_pct") is not None:
|
||||
method = latest.get("body_fat_method") or "?"
|
||||
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
|
||||
if latest.get("neck_cm"):
|
||||
parts.append(f"шея {latest.get('neck_cm')}")
|
||||
if latest.get("waist_cm"):
|
||||
parts.append(f"талия {latest.get('waist_cm')}")
|
||||
if latest.get("hip_cm"):
|
||||
parts.append(f"бёдра {latest.get('hip_cm')}")
|
||||
if latest.get("whr"):
|
||||
parts.append(f"WHR {latest.get('whr')}")
|
||||
if latest.get("ffmi"):
|
||||
parts.append(f"FFMI {latest.get('ffmi')}")
|
||||
lines.append(" · ".join(parts))
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Правила: 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 (≈), пользователь может уточнить."
|
||||
)
|
||||
return chr(10).join(lines)
|
||||
|
||||
+111
-114
@@ -1,114 +1,111 @@
|
||||
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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chat.notice_inbox import post_notice_to_latest_chat
|
||||
from app.config import get_settings
|
||||
from app.db.models import FitnessReminder, User
|
||||
from app.fitness.service import FitnessService
|
||||
|
||||
KIND_LABELS = {
|
||||
"water": "Вода",
|
||||
"meal": "Еда",
|
||||
"workout": "Тренировка",
|
||||
"weigh_in": "Взвешивание",
|
||||
}
|
||||
|
||||
|
||||
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_user_reminders(db: Session, user_id: int) -> list[str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
service = FitnessService(db, user_id)
|
||||
summary = service.get_daily_summary()
|
||||
fired: list[str] = []
|
||||
|
||||
reminders = db.scalars(
|
||||
select(FitnessReminder).where(
|
||||
FitnessReminder.user_id == user_id,
|
||||
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:
|
||||
for notice in fired:
|
||||
post_notice_to_latest_chat(notice, user_id)
|
||||
|
||||
return fired
|
||||
|
||||
|
||||
def check_reminders(db: Session) -> list[str]:
|
||||
if not get_settings().fitness_reminders_enabled:
|
||||
return []
|
||||
|
||||
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||
all_fired: list[str] = []
|
||||
for user in users:
|
||||
all_fired.extend(_check_user_reminders(db, user.id))
|
||||
|
||||
if all_fired:
|
||||
db.commit()
|
||||
|
||||
return all_fired
|
||||
|
||||
+690
-441
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
import json
|
||||
from datetime import date, datetime, time, timedelta, 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 get_history(
|
||||
self,
|
||||
*,
|
||||
days: int = 7,
|
||||
end_day: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
days = max(1, min(days, 90))
|
||||
end = end_day or datetime.now(timezone.utc).date()
|
||||
start = end - timedelta(days=days - 1)
|
||||
summaries: list[dict[str, Any]] = []
|
||||
|
||||
for offset in range(days):
|
||||
d = start + timedelta(days=offset)
|
||||
full = self.get_daily_summary(d)
|
||||
totals = full["totals"]
|
||||
has_data = bool(full["meals"] or full["water"] or full["workouts"])
|
||||
summaries.append(
|
||||
{
|
||||
"date": full["date"],
|
||||
"has_data": has_data,
|
||||
"totals": totals,
|
||||
"targets": full["targets"],
|
||||
"meal_count": len(full["meals"]),
|
||||
"workout_count": len(full["workouts"]),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"start_date": start.isoformat(),
|
||||
"end_date": end.isoformat(),
|
||||
"days": days,
|
||||
"summaries": summaries,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
return {
|
||||
"profile": self.get_profile(),
|
||||
"today": self.get_daily_summary(today),
|
||||
"history": self.get_history(days=7, end_day=today),
|
||||
"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,
|
||||
}
|
||||
@@ -1,66 +1,96 @@
|
||||
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)
|
||||
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,
|
||||
"active_calories": null,
|
||||
"total_calories": null,
|
||||
"steps": null,
|
||||
"notes": "",
|
||||
"exercises": [
|
||||
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
|
||||
]
|
||||
}
|
||||
Правила:
|
||||
- weight_kg в кг, округляй разумно.
|
||||
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null.
|
||||
- Если данных нет — null или пустой массив.
|
||||
""".strip()
|
||||
|
||||
STEPS_PROMPT = """
|
||||
Преобразуй запись о шагах в JSON. Только JSON.
|
||||
Формат:
|
||||
{
|
||||
"steps": 0,
|
||||
"active_calories": null,
|
||||
"notes": ""
|
||||
}
|
||||
Правила:
|
||||
- steps — целое число шагов за день.
|
||||
- active_calories — только если явно указаны.
|
||||
""".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)
|
||||
|
||||
|
||||
async def structure_steps(raw_text: str) -> dict[str, Any]:
|
||||
llm = LLMClient()
|
||||
result = await llm.complete(
|
||||
[
|
||||
{"role": "system", "content": STEPS_PROMPT},
|
||||
{"role": "user", "content": raw_text},
|
||||
],
|
||||
temperature=0.2,
|
||||
)
|
||||
raw = strip_markdown_json(result.get("content") or "")
|
||||
return json.loads(raw)
|
||||
|
||||
Reference in New Issue
Block a user