diff --git a/.env.example b/.env.example index bb5a4ee..babc413 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 SYSTEM_PROMPT_PATH=./prompts/assistant.md MEMORY_AUTO_EXTRACT=true +# Fitness (wger + Open Food Facts — public HTTPS, no proxy) +WGER_BASE_URL=https://wger.de/api/v2 +OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org +FITNESS_REMINDERS_ENABLED=true + # Taiga (on host :9000, nginx → taiga.grigowashere.ru) TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_USERNAME=your_taiga_user diff --git a/README.md b/README.md index bd3201a..2b4d14a 100644 --- a/README.md +++ b/README.md @@ -201,12 +201,19 @@ data/ SQLite БД (создаётся автоматически) | DELETE | `/api/v1/memory/facts/{id}` | забыть | | PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата | +## Фитнес-трекер + +Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ, +lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`. + +Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3». + ## Следующие фазы - Фаза 4: инструменты с обращением к внешним API -- Фаза 5: RAG по файлам, Qdrant (если понадобится семантика) +- Фаза 5: RAG по файлам - Проактивные чаты по расписанию -- Фитнес-трекер +- Telegram, графики веса, LLM-мотивация в напоминаниях ## Модель diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 335372c..645121f 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import character, chat, health, memory, pomodoro, projects, webhooks +from app.api.routes import character, chat, fitness, health, memory, pomodoro, projects, webhooks api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router, tags=["health"]) @@ -9,4 +9,5 @@ api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"] api_router.include_router(character.router, tags=["character"]) api_router.include_router(projects.router, tags=["projects"]) api_router.include_router(memory.router, tags=["memory"]) +api_router.include_router(fitness.router, tags=["fitness"]) api_router.include_router(webhooks.router, tags=["webhooks"]) diff --git a/backend/app/api/routes/fitness.py b/backend/app/api/routes/fitness.py new file mode 100644 index 0000000..d6e44bd --- /dev/null +++ b/backend/app/api/routes/fitness.py @@ -0,0 +1,213 @@ +from datetime import date +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.db.base import get_db +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 + +router = APIRouter() + + +class ProfileUpdate(BaseModel): + sex: str | None = None + age: int | None = None + height_cm: float | None = None + weight_kg: float | None = None + activity_level: str | None = None + goal: str | None = None + target_weight_kg: float | None = None + weekly_workouts: int | None = None + + +class MealCreate(BaseModel): + text: str = Field(min_length=1) + meal_type: str | None = None + + +class WaterCreate(BaseModel): + amount_ml: int = Field(gt=0) + + +class WeightCreate(BaseModel): + weight_kg: float = Field(gt=0) + body_fat_pct: float | None = None + chest_cm: float | None = None + waist_cm: float | None = None + notes: str = "" + + +class WorkoutCreate(BaseModel): + text: str = Field(min_length=1) + + +class ReminderUpdate(BaseModel): + enabled: bool | None = None + hour: int | None = Field(default=None, ge=0, le=23) + minute: int | None = Field(default=None, ge=0, le=59) + interval_hours: int | None = Field(default=None, ge=1, le=12) + + +@router.get("/fitness") +def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: + return FitnessService(db).snapshot() + + +@router.get("/fitness/summary") +def get_summary( + day: str | None = None, + db: Session = Depends(get_db), +) -> dict[str, Any]: + d = date.fromisoformat(day) if day else None + return FitnessService(db).get_daily_summary(d) + + +@router.get("/fitness/profile") +def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: + profile = FitnessService(db).get_profile() + return profile or {"configured": False} + + +@router.put("/fitness/profile") +def update_profile( + payload: ProfileUpdate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return FitnessService(db).set_profile(payload.model_dump(exclude_none=True)) + + +@router.post("/fitness/profile/calc") +def calc_targets( + payload: ProfileUpdate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + params = payload.model_dump(exclude_none=True) + if not params: + raise HTTPException(status_code=400, detail="No parameters") + return FitnessService(db).calc_targets(params) + + +@router.post("/fitness/meals") +async def create_meal( + payload: MealCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + service = FitnessService(db) + try: + structured = await structure_meal(payload.text) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return service.log_meal( + description=structured.get("description") or payload.text, + meal_type=payload.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=bool(structured.get("estimated", True)), + ) + + +@router.post("/fitness/water") +def create_water( + payload: WaterCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return FitnessService(db).log_water(payload.amount_ml) + + +@router.post("/fitness/weight") +def create_weight( + payload: WeightCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return FitnessService(db).log_weight( + payload.weight_kg, + body_fat_pct=payload.body_fat_pct, + chest_cm=payload.chest_cm, + waist_cm=payload.waist_cm, + notes=payload.notes, + ) + + +@router.post("/fitness/workouts") +async def create_workout( + payload: WorkoutCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + service = FitnessService(db) + try: + structured = await structure_workout(payload.text) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return service.log_workout( + title=structured.get("title") or "Тренировка", + notes=structured.get("notes") or payload.text, + duration_min=structured.get("duration_min"), + exercises=structured.get("exercises"), + ) + + +@router.get("/fitness/body-metrics") +def list_metrics( + limit: int = 30, + db: Session = Depends(get_db), +) -> list[dict[str, Any]]: + return FitnessService(db).list_body_metrics(limit=limit) + + +@router.delete("/fitness/meals/{log_id}") +def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: + if not FitnessService(db).delete_food_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.delete("/fitness/water/{log_id}") +def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: + if not FitnessService(db).delete_water_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.delete("/fitness/workouts/{log_id}") +def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: + if not FitnessService(db).delete_workout_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.get("/fitness/reminders") +def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]: + return FitnessService(db).list_reminders() + + +@router.put("/fitness/reminders/{kind}") +def update_reminder( + kind: str, + payload: ReminderUpdate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return FitnessService(db).set_reminder( + kind, + enabled=payload.enabled, + hour=payload.hour, + minute=payload.minute, + interval_hours=payload.interval_hours, + ) + + +@router.get("/fitness/lookup/food") +def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]: + return OpenFoodFactsClient().search(q, limit=limit) + + +@router.get("/fitness/lookup/exercise") +def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]: + return WgerClient().search_exercises(q, limit=limit) diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 684b66e..be3a9a1 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -12,6 +12,8 @@ TOOLS_INSTRUCTIONS = """ - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. +- Фитнес: get_fitness_summary, set_fitness_profile, log_meal, log_water, log_weight, log_workout, + calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder. - Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. - Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 570dd4c..81e5f9a 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -55,10 +55,27 @@ MEMORY_TOOL_NAMES = frozenset({ "update_session_summary", }) +FITNESS_TOOL_NAMES = frozenset({ + "get_fitness_summary", + "set_fitness_profile", + "calc_fitness_targets", + "log_meal", + "log_water", + "log_weight", + "log_workout", + "lookup_food", + "lookup_exercise", + "set_fitness_reminder", +}) + # Не засорять чат служебными ответами TOOLS_SKIP_CHAT_NOTICE = frozenset({ "get_pomodoro_status", "recall_memories", + "get_fitness_summary", + "lookup_food", + "lookup_exercise", + "calc_fitness_targets", }) @@ -76,6 +93,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: prefix = "⏱" elif tool_name in MEMORY_TOOL_NAMES: prefix = "🧠" + elif tool_name in FITNESS_TOOL_NAMES: + prefix = "💪" else: prefix = "📋" return f"{prefix} {data['error']}" @@ -138,6 +157,39 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: if tool_name == "update_session_summary" and data.get("ok"): return "🧠 **Сводка чата сохранена**" + if tool_name == "log_meal" and data.get("ok"): + meal = data.get("meal", {}) + est = "≈" if meal.get("estimated") else "" + return ( + f"💪 **Приём пищи** · {meal.get('description')} · " + f"{est}{meal.get('calories', 0):.0f} ккал " + f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})" + ) + + if tool_name == "log_water" and data.get("ok"): + w = data.get("water", {}) + return f"💪 **Вода** +{w.get('amount_ml')} мл" + + if tool_name == "log_weight" and data.get("ok"): + m = data.get("metric", {}) + return f"💪 **Вес** {m.get('weight_kg')} кг" + + if tool_name == "log_workout" and data.get("ok"): + wo = data.get("workout", {}) + return f"💪 **Тренировка** · {wo.get('title')}" + + if tool_name == "set_fitness_profile" and data.get("ok"): + p = data.get("profile", {}) + return ( + f"💪 **Профиль** · {p.get('calorie_target')} ккал, " + f"вода {p.get('water_l')} л" + ) + + if tool_name == "set_fitness_reminder" and data.get("ok"): + r = data.get("reminder", {}) + state = "вкл" if r.get("enabled") else "выкл" + return f"💪 **Напоминание {r.get('kind')}** · {state}" + return None diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index b3a0914..d618d43 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -12,6 +12,7 @@ from app.chat.notices import ( format_pomodoro_context, format_tool_notice, ) +from app.fitness.context import format_fitness_context, get_fitness_snapshot from app.memory.context import ( format_identity_hint, format_memory_context, @@ -59,10 +60,12 @@ class ChatService: def _build_system_prompt(self, session_id: int | None = None) -> str: status = PomodoroService(self.db).get_status() memory_snapshot = get_memory_snapshot(self.db, session_id) + fitness_snapshot = get_fitness_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db) return ( f"{self.character.get_system_prompt()}\n\n" f"{format_memory_context(memory_snapshot)}\n\n" + f"{format_fitness_context(fitness_snapshot)}\n\n" f"{format_pomodoro_context(status)}\n\n" f"{format_projects_context(projects_snapshot)}" ) diff --git a/backend/app/config.py b/backend/app/config.py index 52a3734..e7fe4f0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -36,6 +36,10 @@ class Settings(BaseSettings): repos_dir: str = "/data/repos" + wger_base_url: str = "https://wger.de/api/v2" + openfoodfacts_base_url: str = "https://world.openfoodfacts.org" + fitness_reminders_enabled: bool = True + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 7a488d9..61e17bc 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -135,6 +135,86 @@ class SessionSummary(Base): ) +class FitnessProfile(Base): + __tablename__ = "fitness_profiles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + sex: Mapped[str] = mapped_column(String(16), default="male") + age: Mapped[int] = mapped_column(Integer, default=30) + height_cm: Mapped[float] = mapped_column(Float, default=170.0) + weight_kg: Mapped[float] = mapped_column(Float, default=70.0) + activity_level: Mapped[str] = mapped_column(String(32), default="moderate") + goal: Mapped[str] = mapped_column(String(32), default="maintain") + target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True) + weekly_workouts: Mapped[int] = mapped_column(Integer, default=3) + calorie_target: Mapped[float] = mapped_column(Float, default=2000.0) + protein_g: Mapped[float] = mapped_column(Float, default=140.0) + fat_g: Mapped[float] = mapped_column(Float, default=65.0) + carbs_g: Mapped[float] = mapped_column(Float, default=200.0) + water_l: Mapped[float] = mapped_column(Float, default=2.5) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class BodyMetric(Base): + __tablename__ = "body_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + weight_kg: Mapped[float] = mapped_column(Float) + body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True) + chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + notes: Mapped[str] = mapped_column(Text, default="") + + +class FoodLog(Base): + __tablename__ = "food_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + meal_type: Mapped[str] = mapped_column(String(32), default="snack") + description: Mapped[str] = mapped_column(Text, default="") + calories: Mapped[float] = mapped_column(Float, default=0) + protein_g: Mapped[float] = mapped_column(Float, default=0) + fat_g: Mapped[float] = mapped_column(Float, default=0) + carbs_g: Mapped[float] = mapped_column(Float, default=0) + source: Mapped[str] = mapped_column(String(32), default="llm") + estimated: Mapped[bool] = mapped_column(Boolean, default=True) + + +class WaterLog(Base): + __tablename__ = "water_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + amount_ml: Mapped[int] = mapped_column(Integer) + + +class WorkoutLog(Base): + __tablename__ = "workout_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + title: Mapped[str] = mapped_column(String(255), default="Тренировка") + notes: Mapped[str] = mapped_column(Text, default="") + duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True) + exercises_json: Mapped[str] = mapped_column(Text, default="[]") + + +class FitnessReminder(Base): + __tablename__ = "fitness_reminders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + kind: Mapped[str] = mapped_column(String(32)) + hour: Mapped[int] = mapped_column(Integer, default=12) + minute: Mapped[int] = mapped_column(Integer, default=0) + interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class WorkItem(Base): __tablename__ = "work_items" diff --git a/backend/app/fitness/__init__.py b/backend/app/fitness/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/fitness/calculators.py b/backend/app/fitness/calculators.py new file mode 100644 index 0000000..dd3efcc --- /dev/null +++ b/backend/app/fitness/calculators.py @@ -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, + } diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py new file mode 100644 index 0000000..47ace06 --- /dev/null +++ b/backend/app/fitness/context.py @@ -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) diff --git a/backend/app/fitness/reminders.py b/backend/app/fitness/reminders.py new file mode 100644 index 0000000..3820cad --- /dev/null +++ b/backend/app/fitness/reminders.py @@ -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 diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py new file mode 100644 index 0000000..50f5b20 --- /dev/null +++ b/backend/app/fitness/service.py @@ -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, + } diff --git a/backend/app/fitness/structuring.py b/backend/app/fitness/structuring.py new file mode 100644 index 0000000..67b7761 --- /dev/null +++ b/backend/app/fitness/structuring.py @@ -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) diff --git a/backend/app/fitness/watcher.py b/backend/app/fitness/watcher.py new file mode 100644 index 0000000..cd41222 --- /dev/null +++ b/backend/app/fitness/watcher.py @@ -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() diff --git a/backend/app/integrations/openfoodfacts.py b/backend/app/integrations/openfoodfacts.py new file mode 100644 index 0000000..da44984 --- /dev/null +++ b/backend/app/integrations/openfoodfacts.py @@ -0,0 +1,62 @@ +from typing import Any + +import httpx + +from app.config import get_settings + + +class OpenFoodFactsClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = settings.openfoodfacts_base_url.rstrip("/") + + def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]: + with httpx.Client(timeout=20.0) as client: + response = client.get( + f"{self.base_url}/cgi/search.pl", + params={ + "search_terms": query, + "search_simple": 1, + "action": "process", + "json": 1, + "page_size": limit, + "lc": "ru", + }, + ) + response.raise_for_status() + products = response.json().get("products") or [] + + out: list[dict[str, Any]] = [] + for p in products[:limit]: + nutriments = p.get("nutriments") or {} + out.append( + { + "name": p.get("product_name") or p.get("product_name_ru") or query, + "brand": p.get("brands", ""), + "barcode": p.get("code"), + "calories_per_100g": nutriments.get("energy-kcal_100g"), + "protein_g_per_100g": nutriments.get("proteins_100g"), + "fat_g_per_100g": nutriments.get("fat_100g"), + "carbs_g_per_100g": nutriments.get("carbohydrates_100g"), + } + ) + return out + + def get_by_barcode(self, barcode: str) -> dict[str, Any] | None: + with httpx.Client(timeout=20.0) as client: + response = client.get(f"{self.base_url}/api/v2/product/{barcode}.json") + if response.status_code == 404: + return None + response.raise_for_status() + product = response.json().get("product") + if not product: + return None + nutriments = product.get("nutriments") or {} + return { + "name": product.get("product_name") or product.get("product_name_ru"), + "barcode": barcode, + "calories_per_100g": nutriments.get("energy-kcal_100g"), + "protein_g_per_100g": nutriments.get("proteins_100g"), + "fat_g_per_100g": nutriments.get("fat_100g"), + "carbs_g_per_100g": nutriments.get("carbohydrates_100g"), + } diff --git a/backend/app/integrations/wger.py b/backend/app/integrations/wger.py new file mode 100644 index 0000000..b96e494 --- /dev/null +++ b/backend/app/integrations/wger.py @@ -0,0 +1,48 @@ +from typing import Any + +import httpx + +from app.config import get_settings + + +class WgerClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = settings.wger_base_url.rstrip("/") + + def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]: + with httpx.Client(timeout=20.0) as client: + response = client.get( + f"{self.base_url}/exercise/search/", + params={"term": query, "language": "ru"}, + ) + response.raise_for_status() + data = response.json() + sug = data.get("suggestions", data) if isinstance(data, dict) else [] + if isinstance(sug, dict): + results = sug.get("results", []) + elif isinstance(sug, list): + results = sug + else: + results = [] + + out: list[dict[str, Any]] = [] + for item in results[:limit]: + if isinstance(item, dict): + name = item.get("value") or item.get("name") or str(item) + out.append({"name": name, "data": item}) + elif isinstance(item, str): + out.append({"name": item}) + if out: + return out + + response2 = client.get( + f"{self.base_url}/exerciseinfo/", + params={"language": 2, "limit": limit}, + ) + response2.raise_for_status() + for item in (response2.json().get("results") or [])[:limit]: + name = item.get("name") or f"#{item.get('id')}" + if query.lower() in name.lower(): + out.append({"id": item.get("id"), "name": name, "category": item.get("category")}) + return out[:limit] diff --git a/backend/app/main.py b/backend/app/main.py index 21ac9ba..54ff56a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,17 +7,22 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.routes import api_router from app.config import get_settings from app.db.base import init_db +from app.fitness.watcher import fitness_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop @asynccontextmanager async def lifespan(_: FastAPI): init_db() - watcher_task = asyncio.create_task(pomodoro_watcher_loop()) + pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) + fitness_task = asyncio.create_task(fitness_watcher_loop()) yield - watcher_task.cancel() + pomodoro_task.cancel() + fitness_task.cancel() with suppress(asyncio.CancelledError): - await watcher_task + await pomodoro_task + with suppress(asyncio.CancelledError): + await fitness_task def create_app() -> FastAPI: diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 65e69fe..5f01fab 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -3,6 +3,10 @@ from typing import Any from sqlalchemy.orm import Session +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.memory.service import MemoryService from app.pomodoro.service import PomodoroService from app.projects.service import ProjectService @@ -268,6 +272,164 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "get_fitness_summary", + "description": "Сводка фитнеса за сегодня: ккал, БЖУ, вода, тренировки.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "set_fitness_profile", + "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.", + "parameters": { + "type": "object", + "properties": { + "sex": {"type": "string", "description": "male/female"}, + "age": {"type": "integer"}, + "height_cm": {"type": "number"}, + "weight_kg": {"type": "number"}, + "activity_level": { + "type": "string", + "description": "sedentary/light/moderate/active/very_active", + }, + "goal": {"type": "string", "description": "lose/maintain/gain"}, + "target_weight_kg": {"type": "number"}, + "weekly_workouts": {"type": "integer"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "calc_fitness_targets", + "description": "Калькулятор BMR/TDEE/макросов без сохранения.", + "parameters": { + "type": "object", + "properties": { + "sex": {"type": "string"}, + "age": {"type": "integer"}, + "height_cm": {"type": "number"}, + "weight_kg": {"type": "number"}, + "activity_level": {"type": "string"}, + "goal": {"type": "string"}, + }, + "required": ["weight_kg", "height_cm", "age"], + }, + }, + }, + { + "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": "Записать вес в кг.", + "parameters": { + "type": "object", + "properties": { + "weight_kg": {"type": "number"}, + "body_fat_pct": {"type": "number"}, + "notes": {"type": "string"}, + }, + "required": ["weight_kg"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_workout", + "description": "Записать тренировку из текста.", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + "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"], + }, + }, + }, { "type": "function", "function": { @@ -299,6 +461,7 @@ async def execute_tool( pomodoro = PomodoroService(db) projects = ProjectService(db) memory = MemoryService(db) + fitness = FitnessService(db) try: if name == "get_pomodoro_status": @@ -370,6 +533,66 @@ async def execute_tool( int(arguments["session_id"]), arguments.get("summary", ""), ) + elif name == "get_fitness_summary": + result = fitness.get_daily_summary() + elif name == "set_fitness_profile": + updates = { + k: arguments[k] + for k in ( + "sex", "age", "height_cm", "weight_kg", "activity_level", + "goal", "target_weight_kg", "weekly_workouts", + ) + if k in arguments and arguments[k] is not None + } + result = fitness.set_profile(updates) + elif name == "calc_fitness_targets": + result = fitness.calc_targets(arguments) + elif name == "log_meal": + structured = await structure_meal(arguments.get("text", "")) + result = 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, + ) + elif name == "log_water": + result = fitness.log_water(int(arguments.get("amount_ml", 250))) + elif name == "log_weight": + result = fitness.log_weight( + float(arguments["weight_kg"]), + body_fat_pct=arguments.get("body_fat_pct"), + notes=arguments.get("notes", ""), + ) + elif name == "log_workout": + structured = await structure_workout(arguments.get("text", "")) + result = 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"), + ) + elif name == "lookup_food": + result = OpenFoodFactsClient().search( + arguments.get("query", ""), + limit=arguments.get("limit", 5), + ) + elif name == "lookup_exercise": + result = WgerClient().search_exercises( + arguments.get("query", ""), + limit=arguments.get("limit", 8), + ) + elif name == "set_fitness_reminder": + result = 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"), + ) else: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52016f4..ffc5943 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import PomodoroWidget from "./components/PomodoroWidget"; import { PomodoroProvider } from "./context/PomodoroContext"; import Character from "./pages/Character"; import Chat from "./pages/Chat"; +import Fitness from "./pages/Fitness"; import Memory from "./pages/Memory"; import Pomodoro from "./pages/Pomodoro"; import "./App.css"; @@ -20,6 +21,7 @@ export default function App() { Помидоро Персонаж Память + Фитнес @@ -29,6 +31,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 29415f8..94b4872 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,6 +81,99 @@ export interface MemoryFact { updated_at?: string | null; } +export interface FitnessComputed { + bmr: number; + tdee: number; + bmi: number; +} + +export interface FitnessProfile { + sex?: string; + age?: number; + height_cm?: number; + weight_kg?: number; + activity_level?: string; + goal?: string; + target_weight_kg?: number | null; + weekly_workouts?: number; + calorie_target?: number; + protein_g?: number; + fat_g?: number; + carbs_g?: number; + water_l?: number; + computed?: FitnessComputed; +} + +export interface FoodLogItem { + id: number; + meal_type: string; + description: string; + calories: number; + protein_g: number; + fat_g: number; + carbs_g: number; + estimated: boolean; + logged_at?: string; +} + +export interface WaterLogItem { + id: number; + amount_ml: number; + logged_at?: string; +} + +export interface WorkoutLogItem { + id: number; + title: string; + notes?: string; + duration_min?: number | null; + exercises?: unknown[]; + logged_at?: string; +} + +export interface FitnessDailySummary { + date: string; + totals: { + calories: number; + protein_g: number; + fat_g: number; + carbs_g: number; + water_ml: number; + }; + targets: { + calories: number; + protein_g: number; + fat_g: number; + carbs_g: number; + water_ml: number; + }; + meals: FoodLogItem[]; + water: WaterLogItem[]; + workouts: WorkoutLogItem[]; +} + +export interface BodyMetric { + id: number; + weight_kg: number; + recorded_at?: string; +} + +export interface FitnessReminder { + id: number; + kind: string; + hour: number; + minute: number; + interval_hours?: number | null; + enabled: boolean; +} + +export interface FitnessSnapshot { + profile: FitnessProfile | null; + today: FitnessDailySummary; + body_metrics: BodyMetric[]; + reminders: FitnessReminder[]; +} + export interface MemorySnapshot { profile: UserProfile; facts: MemoryFact[]; @@ -245,4 +338,29 @@ export const api = { forgetMemoryFact: (id: number) => request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }), + + getFitnessSnapshot: () => request("/api/v1/fitness"), + + updateFitnessProfile: (updates: Partial) => + request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }), + + deleteFitnessMeal: (id: number) => + request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }), + + deleteFitnessWater: (id: number) => + request<{ ok: boolean }>(`/api/v1/fitness/water/${id}`, { method: "DELETE" }), + + updateFitnessReminder: ( + kind: string, + updates: { enabled?: boolean; hour?: number; minute?: number; interval_hours?: number } + ) => + request<{ ok: boolean }>(`/api/v1/fitness/reminders/${kind}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }), }; diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 2ad5b8b..d3a99a1 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -16,6 +16,7 @@ function noticeLabel(content: string): string { if (content.startsWith("📋")) return "задачи"; if (content.startsWith("🔀")) return "git"; if (content.startsWith("🧠")) return "память"; + if (content.startsWith("💪")) return "фитнес"; return "система"; } diff --git a/frontend/src/pages/Fitness.css b/frontend/src/pages/Fitness.css new file mode 100644 index 0000000..9023d4d --- /dev/null +++ b/frontend/src/pages/Fitness.css @@ -0,0 +1,179 @@ +.fitness-page { + max-width: 900px; + margin: 0 auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.fitness-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 1rem; +} + +.fitness-header h2 { + margin: 0 0 0.25rem; +} + +.fitness-header p { + margin: 0; + color: #8b95a8; + font-size: 0.9rem; +} + +.fitness-header-actions { + display: flex; + gap: 0.5rem; +} + +.fitness-message { + padding: 0.6rem 0.9rem; + background: #1a2433; + border: 1px solid #2a3f5a; + border-radius: 8px; +} + +.fitness-section { + background: #151922; + border: 1px solid #2a2f3a; + border-radius: 10px; + padding: 1rem 1.25rem; +} + +.fitness-section h3 { + margin: 0 0 0.75rem; +} + +.fitness-section h4 { + margin: 0.75rem 0 0.35rem; + font-size: 0.85rem; + color: #8b95a8; +} + +.fitness-progress-grid { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.fitness-progress-header { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + margin-bottom: 0.2rem; +} + +.fitness-progress-track { + height: 8px; + background: #0f1218; + border-radius: 4px; + overflow: hidden; +} + +.fitness-progress-fill { + height: 100%; + background: #3d7a5a; + border-radius: 4px; +} + +.fitness-profile-form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.fitness-profile-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: #8b95a8; +} + +.fitness-profile-form input, +.fitness-profile-form select { + padding: 0.45rem 0.6rem; + border-radius: 6px; + border: 1px solid #2a2f3a; + background: #0f1218; + color: #e8ecf1; +} + +.fitness-profile-form button { + grid-column: 1 / -1; + justify-self: start; +} + +.fitness-computed { + margin: 0.75rem 0 0; + font-size: 0.9rem; + color: #8b95a8; +} + +.fitness-log-list { + list-style: none; + margin: 0; + padding: 0; +} + +.fitness-log-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + border-bottom: 1px solid #1e2430; + font-size: 0.9rem; +} + +.fitness-log-list button { + padding: 0 0.4rem; + font-size: 1rem; + line-height: 1; +} + +.fitness-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.fitness-table th, +.fitness-table td { + text-align: left; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid #1e2430; +} + +.fitness-reminders { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.fitness-reminders li { + display: flex; + gap: 1rem; + align-items: center; +} + +.fitness-empty { + color: #8b95a8; + font-style: italic; +} + +.fitness-raw { + background: #0f1218; + border: 1px solid #2a2f3a; + border-radius: 10px; + padding: 1rem; + overflow: auto; + font-size: 0.82rem; + max-height: 70vh; +} diff --git a/frontend/src/pages/Fitness.tsx b/frontend/src/pages/Fitness.tsx new file mode 100644 index 0000000..c4ea5d8 --- /dev/null +++ b/frontend/src/pages/Fitness.tsx @@ -0,0 +1,307 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { + api, + FitnessProfile, + FitnessReminder, + FitnessSnapshot, +} from "../api/client"; +import "./Fitness.css"; + +function ProgressBar({ label, current, target, unit }: { + label: string; + current: number; + target: number; + unit: string; +}) { + const pct = target > 0 ? Math.min(100, (current / target) * 100) : 0; + return ( +
+
+ {label} + + {current.toFixed(0)}/{target.toFixed(0)} {unit} + +
+
+
+
+
+ ); +} + +export default function Fitness() { + const [snapshot, setSnapshot] = useState(null); + const [profile, setProfile] = useState>({}); + const [message, setMessage] = useState(""); + const [showRaw, setShowRaw] = useState(false); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await api.getFitnessSnapshot(); + setSnapshot(data); + if (data.profile) setProfile(data.profile); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load().catch(console.error); + }, [load]); + + const handleProfileSave = async (e: FormEvent) => { + e.preventDefault(); + try { + await api.updateFitnessProfile(profile); + setMessage("Профиль сохранён"); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleToggleReminder = async (rem: FitnessReminder) => { + try { + await api.updateFitnessReminder(rem.kind, { enabled: !rem.enabled }); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleDeleteMeal = async (id: number) => { + await api.deleteFitnessMeal(id); + await load(); + }; + + const handleDeleteWater = async (id: number) => { + await api.deleteFitnessWater(id); + await load(); + }; + + const today = snapshot?.today; + const totals = today?.totals; + const targets = today?.targets; + + return ( +
+
+
+

Фитнес

+

Дневник, цели, напоминания

+
+
+ + +
+
+ + {message &&
{message}
} + + {showRaw ? ( +
{JSON.stringify(snapshot, null, 2)}
+ ) : ( + <> +
+

Сегодня

+ {totals && targets ? ( +
+ + + + + +
+ ) : ( +

Нет данных за сегодня

+ )} +
+ +
+

Профиль и цели

+
+ + + + + + + +
+ {profile.computed && ( +

+ BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "} + {profile.computed.bmi} +

+ )} +
+ +
+

Логи за сегодня

+

Еда

+
    + {(today?.meals ?? []).map((m) => ( +
  • + {m.estimated ? "≈" : ""} + {m.description} — {m.calories} ккал + +
  • + ))} +
+

Вода

+
    + {(today?.water ?? []).map((w) => ( +
  • + +{w.amount_ml} мл + +
  • + ))} +
+

Тренировки

+
    + {(today?.workouts ?? []).map((w) => ( +
  • {w.title}
  • + ))} +
+
+ +
+

История веса

+ + + + + + + + + {(snapshot?.body_metrics ?? []).map((m) => ( + + + + + ))} + +
Датакг
{m.recorded_at?.slice(0, 10)}{m.weight_kg}
+
+ +
+

Напоминания

+
    + {(snapshot?.reminders ?? []).map((r) => ( +
  • + {r.kind} + + {r.interval_hours + ? `каждые ${r.interval_hours}ч` + : `${String(r.hour).padStart(2, "0")}:${String(r.minute).padStart(2, "0")}`} + + +
  • + ))} +
+
+ + )} +
+ ); +}