added fitness

This commit is contained in:
2026-06-10 09:12:50 +03:00
parent 0b39692300
commit d0bdd1e95c
25 changed files with 2082 additions and 7 deletions
+5
View File
@@ -19,6 +19,11 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true 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 (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user TAIGA_USERNAME=your_taiga_user
+9 -2
View File
@@ -201,12 +201,19 @@ data/ SQLite БД (создаётся автоматически)
| DELETE | `/api/v1/memory/facts/{id}` | забыть | | DELETE | `/api/v1/memory/facts/{id}` | забыть |
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата | | PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
## Фитнес-трекер
Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ,
lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`.
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
## Следующие фазы ## Следующие фазы
- Фаза 4: инструменты с обращением к внешним API - Фаза 4: инструменты с обращением к внешним API
- Фаза 5: RAG по файлам, Qdrant (если понадобится семантика) - Фаза 5: RAG по файлам
- Проактивные чаты по расписанию - Проактивные чаты по расписанию
- Фитнес-трекер - Telegram, графики веса, LLM-мотивация в напоминаниях
## Модель ## Модель
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"]) 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(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"]) api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"]) api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(webhooks.router, tags=["webhooks"])
+213
View File
@@ -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)
+2
View File
@@ -12,6 +12,8 @@ TOOLS_INSTRUCTIONS = """
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - 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, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. - Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
+52
View File
@@ -55,10 +55,27 @@ MEMORY_TOOL_NAMES = frozenset({
"update_session_summary", "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({ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status", "get_pomodoro_status",
"recall_memories", "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 = "" prefix = ""
elif tool_name in MEMORY_TOOL_NAMES: elif tool_name in MEMORY_TOOL_NAMES:
prefix = "🧠" prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
else: else:
prefix = "📋" prefix = "📋"
return f"{prefix} {data['error']}" 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"): if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**" 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 return None
+3
View File
@@ -12,6 +12,7 @@ from app.chat.notices import (
format_pomodoro_context, format_pomodoro_context,
format_tool_notice, format_tool_notice,
) )
from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.memory.context import ( from app.memory.context import (
format_identity_hint, format_identity_hint,
format_memory_context, format_memory_context,
@@ -59,10 +60,12 @@ class ChatService:
def _build_system_prompt(self, session_id: int | None = None) -> str: def _build_system_prompt(self, session_id: int | None = None) -> str:
status = PomodoroService(self.db).get_status() status = PomodoroService(self.db).get_status()
memory_snapshot = get_memory_snapshot(self.db, session_id) memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db)
return ( return (
f"{self.character.get_system_prompt()}\n\n" f"{self.character.get_system_prompt()}\n\n"
f"{format_memory_context(memory_snapshot)}\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_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}" f"{format_projects_context(projects_snapshot)}"
) )
+4
View File
@@ -36,6 +36,10 @@ class Settings(BaseSettings):
repos_dir: str = "/data/repos" 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 @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+81 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base 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): class WorkItem(Base):
__tablename__ = "work_items" __tablename__ = "work_items"
View File
+94
View File
@@ -0,0 +1,94 @@
from typing import Any
ACTIVITY_MULTIPLIERS = {
"sedentary": 1.2,
"light": 1.375,
"moderate": 1.55,
"active": 1.725,
"very_active": 1.9,
}
GOAL_CALORIE_ADJUST = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
if sex.lower() in ("m", "male", "м", "мужской"):
return base + 5
return base - 161
def tdee(
*,
sex: str,
weight_kg: float,
height_cm: float,
age: int,
activity_level: str = "moderate",
) -> float:
bmr = bmr_mifflin(sex=sex, weight_kg=weight_kg, height_cm=height_cm, age=age)
mult = ACTIVITY_MULTIPLIERS.get(activity_level, 1.55)
return bmr * mult
def bmi(weight_kg: float, height_cm: float) -> float:
if height_cm <= 0:
return 0.0
h = height_cm / 100
return weight_kg / (h * h)
def water_target_l(weight_kg: float) -> float:
return round(weight_kg * 0.033, 1)
def macro_targets(
calorie_target: float,
weight_kg: float,
goal: str = "maintain",
) -> dict[str, float]:
protein_g = round(weight_kg * (2.0 if goal == "gain" else 1.8), 0)
fat_g = round((calorie_target * 0.25) / 9, 0)
protein_cal = protein_g * 4
fat_cal = fat_g * 9
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
def one_rep_max(weight_kg: float, reps: int) -> float:
if reps <= 0:
return weight_kg
if reps == 1:
return weight_kg
return round(weight_kg * (1 + reps / 30), 1)
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
weight = float(profile.get("weight_kg") or 70)
height = float(profile.get("height_cm") or 170)
age = int(profile.get("age") or 30)
sex = str(profile.get("sex") or "male")
activity = str(profile.get("activity_level") or "moderate")
goal = str(profile.get("goal") or "maintain")
tdee_val = tdee(
sex=sex, weight_kg=weight, height_cm=height, age=age, activity_level=activity
)
calorie_target = round(tdee_val + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
macros = macro_targets(calorie_target, weight, goal)
water = water_target_l(weight)
return {
"bmr": round(bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age), 0),
"tdee": round(tdee_val, 0),
"bmi": round(bmi(weight, height), 1),
"calorie_target": calorie_target,
"protein_g": macros["protein_g"],
"fat_g": macros["fat_g"],
"carbs_g": macros["carbs_g"],
"water_l": water,
}
+55
View File
@@ -0,0 +1,55 @@
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session) -> dict[str, Any]:
return FitnessService(db).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile")
if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else:
lines.append(
f"Цели: {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return "\n".join(lines)
+114
View File
@@ -0,0 +1,114 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import ChatSession, FitnessReminder, Message
from app.fitness.service import FitnessService
KIND_LABELS = {
"water": "Вода",
"meal": "Еда",
"workout": "Тренировка",
"weigh_in": "Взвешивание",
}
def _post_fitness_notice(content: str) -> None:
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Фитнес")
db.add(session)
db.commit()
db.refresh(session)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
finally:
db.close()
def _build_notice(kind: str, summary: dict) -> str:
label = KIND_LABELS.get(kind, kind)
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
cals = totals.get("calories", 0)
cal_target = targets.get("calories", 2000)
if kind == "water":
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды."
)
if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
"Не забудь залогировать приём пищи."
)
if kind == "workout":
workouts = summary.get("workouts") or []
if workouts:
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
return f"💪 **{label}** · напоминание"
def check_reminders(db: Session) -> list[str]:
if not get_settings().fitness_reminders_enabled:
return []
now = datetime.now(timezone.utc)
service = FitnessService(db)
summary = service.get_daily_summary()
fired: list[str] = []
reminders = db.scalars(
select(FitnessReminder).where(FitnessReminder.enabled.is_(True))
).all()
for rem in reminders:
should_fire = False
if rem.interval_hours:
if rem.last_fired_at is None:
should_fire = now.hour >= rem.hour
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(hours=rem.interval_hours)
else:
if rem.kind == "weigh_in":
if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(days=7)
else:
should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if already_today:
continue
should_fire = now.hour == rem.hour and now.minute >= rem.minute
if not should_fire:
continue
notice = _build_notice(rem.kind, summary)
rem.last_fired_at = now
fired.append(notice)
if fired:
db.commit()
for notice in fired:
_post_fitness_notice(notice)
return fired
+405
View File
@@ -0,0 +1,405 @@
import json
from datetime import date, datetime, time, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session):
self.db = db
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
return None
return self._profile_to_dict(row)
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"weekly_workouts": row.weekly_workouts,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile()
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(**item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile = self.get_profile()
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
}
targets = profile or {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
}
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile is not None,
"totals": totals,
"targets": {
"calories": targets.get("calorie_target", 2000),
"protein_g": targets.get("protein_g", 140),
"fat_g": targets.get("fat_g", 65),
"carbs_g": targets.get("carbs_g", 200),
"water_ml": targets.get("water_l", 2.5) * 1000,
},
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": [self._workout_to_dict(w) for w in workouts],
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
notes: str = "",
) -> dict[str, Any]:
row = BodyMetric(
weight_kg=weight_kg,
body_fat_pct=body_fat_pct,
chest_cm=chest_cm,
waist_cm=waist_cm,
notes=notes[:1000],
)
self.db.add(row)
profile = self._get_profile_row()
if profile:
profile.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile.sex,
"age": profile.age,
"height_cm": profile.height_cm,
"weight_kg": weight_kg,
"activity_level": profile.activity_level,
"goal": profile.goal,
}
)
profile.calorie_target = targets["calorie_target"]
profile.protein_g = targets["protein_g"]
profile.fat_g = targets["fat_g"]
profile.carbs_g = targets["carbs_g"]
profile.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
return {
"ok": True,
"metric": {
"id": row.id,
"weight_kg": row.weight_kg,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
row = WorkoutLog(
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [
{
"id": r.id,
"weight_kg": r.weight_kg,
"body_fat_pct": r.body_fat_pct,
"chest_cm": r.chest_cm,
"waist_cm": r.waist_cm,
"notes": r.notes,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
}
for r in rows
]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def snapshot(self) -> dict[str, Any]:
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
"id": row.id,
"meal_type": row.meal_type,
"description": row.description,
"calories": row.calories,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"source": row.source,
"estimated": row.estimated,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
return {
"id": row.id,
"amount_ml": row.amount_ml,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
try:
exercises = json.loads(row.exercises_json or "[]")
except json.JSONDecodeError:
exercises = []
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"duration_min": row.duration_min,
"exercises": exercises,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
return {
"id": row.id,
"kind": row.kind,
"hour": row.hour,
"minute": row.minute,
"interval_hours": row.interval_hours,
"enabled": row.enabled,
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
}
+66
View File
@@ -0,0 +1,66 @@
import json
from typing import Any
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема:
{
"meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание",
"calories": 0,
"protein_g": 0,
"fat_g": 0,
"carbs_g": 0,
"estimated": true
}
Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа — float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки.
""".strip()
WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON.
Схема:
{
"title": "название",
"duration_min": null,
"notes": "",
"exercises": [
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, метрическая система.
- Если данных нет — null или пустой массив.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
+28
View File
@@ -0,0 +1,28 @@
import asyncio
import logging
from app.db.base import SessionLocal
from app.fitness.reminders import check_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
async def fitness_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Fitness watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
check_reminders(db)
finally:
db.close()
+62
View File
@@ -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"),
}
+48
View File
@@ -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]
+8 -3
View File
@@ -7,17 +7,22 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router from app.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
init_db() 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 yield
watcher_task.cancel() pomodoro_task.cancel()
fitness_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await watcher_task await pomodoro_task
with suppress(asyncio.CancelledError):
await fitness_task
def create_app() -> FastAPI: def create_app() -> FastAPI:
+223
View File
@@ -3,6 +3,10 @@ from typing import Any
from sqlalchemy.orm import Session 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.memory.service import MemoryService
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService 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", "type": "function",
"function": { "function": {
@@ -299,6 +461,7 @@ async def execute_tool(
pomodoro = PomodoroService(db) pomodoro = PomodoroService(db)
projects = ProjectService(db) projects = ProjectService(db)
memory = MemoryService(db) memory = MemoryService(db)
fitness = FitnessService(db)
try: try:
if name == "get_pomodoro_status": if name == "get_pomodoro_status":
@@ -370,6 +533,66 @@ async def execute_tool(
int(arguments["session_id"]), int(arguments["session_id"]),
arguments.get("summary", ""), 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: else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
+3
View File
@@ -3,6 +3,7 @@ import PomodoroWidget from "./components/PomodoroWidget";
import { PomodoroProvider } from "./context/PomodoroContext"; import { PomodoroProvider } from "./context/PomodoroContext";
import Character from "./pages/Character"; import Character from "./pages/Character";
import Chat from "./pages/Chat"; import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Memory from "./pages/Memory"; import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro"; import Pomodoro from "./pages/Pomodoro";
import "./App.css"; import "./App.css";
@@ -20,6 +21,7 @@ export default function App() {
<NavLink to="/pomodoro">Помидоро</NavLink> <NavLink to="/pomodoro">Помидоро</NavLink>
<NavLink to="/character">Персонаж</NavLink> <NavLink to="/character">Персонаж</NavLink>
<NavLink to="/memory">Память</NavLink> <NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<PomodoroWidget compact /> <PomodoroWidget compact />
</nav> </nav>
</header> </header>
@@ -29,6 +31,7 @@ export default function App() {
<Route path="/pomodoro" element={<Pomodoro />} /> <Route path="/pomodoro" element={<Pomodoro />} />
<Route path="/character" element={<Character />} /> <Route path="/character" element={<Character />} />
<Route path="/memory" element={<Memory />} /> <Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
</Routes> </Routes>
</main> </main>
</div> </div>
+118
View File
@@ -81,6 +81,99 @@ export interface MemoryFact {
updated_at?: string | null; 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 { export interface MemorySnapshot {
profile: UserProfile; profile: UserProfile;
facts: MemoryFact[]; facts: MemoryFact[];
@@ -245,4 +338,29 @@ export const api = {
forgetMemoryFact: (id: number) => forgetMemoryFact: (id: number) =>
request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }), request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }),
getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"),
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
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),
}),
}; };
+1
View File
@@ -16,6 +16,7 @@ function noticeLabel(content: string): string {
if (content.startsWith("📋")) return "задачи"; if (content.startsWith("📋")) return "задачи";
if (content.startsWith("🔀")) return "git"; if (content.startsWith("🔀")) return "git";
if (content.startsWith("🧠")) return "память"; if (content.startsWith("🧠")) return "память";
if (content.startsWith("💪")) return "фитнес";
return "система"; return "система";
} }
+179
View File
@@ -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;
}
+307
View File
@@ -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 (
<div className="fitness-progress">
<div className="fitness-progress-header">
<span>{label}</span>
<span>
{current.toFixed(0)}/{target.toFixed(0)} {unit}
</span>
</div>
<div className="fitness-progress-track">
<div className="fitness-progress-fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export default function Fitness() {
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
const [profile, setProfile] = useState<Partial<FitnessProfile>>({});
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 (
<div className="fitness-page">
<header className="fitness-header">
<div>
<h2>Фитнес</h2>
<p>Дневник, цели, напоминания</p>
</div>
<div className="fitness-header-actions">
<button type="button" onClick={() => load()} disabled={loading}>
{loading ? "…" : "Обновить"}
</button>
<button type="button" onClick={() => setShowRaw((v) => !v)}>
{showRaw ? "UI" : "JSON"}
</button>
</div>
</header>
{message && <div className="fitness-message">{message}</div>}
{showRaw ? (
<pre className="fitness-raw">{JSON.stringify(snapshot, null, 2)}</pre>
) : (
<>
<section className="fitness-section">
<h3>Сегодня</h3>
{totals && targets ? (
<div className="fitness-progress-grid">
<ProgressBar
label="Калории"
current={totals.calories}
target={targets.calories}
unit="ккал"
/>
<ProgressBar
label="Белок"
current={totals.protein_g}
target={targets.protein_g}
unit="г"
/>
<ProgressBar
label="Жиры"
current={totals.fat_g}
target={targets.fat_g}
unit="г"
/>
<ProgressBar
label="Углеводы"
current={totals.carbs_g}
target={targets.carbs_g}
unit="г"
/>
<ProgressBar
label="Вода"
current={totals.water_ml / 1000}
target={targets.water_ml / 1000}
unit="л"
/>
</div>
) : (
<p className="fitness-empty">Нет данных за сегодня</p>
)}
</section>
<section className="fitness-section">
<h3>Профиль и цели</h3>
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
<label>
<span>пол</span>
<select
value={profile.sex ?? "male"}
onChange={(e) => setProfile((p) => ({ ...p, sex: e.target.value }))}
>
<option value="male">male</option>
<option value="female">female</option>
</select>
</label>
<label>
<span>возраст</span>
<input
type="number"
value={profile.age ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, age: Number(e.target.value) }))
}
/>
</label>
<label>
<span>рост см</span>
<input
type="number"
value={profile.height_cm ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, height_cm: Number(e.target.value) }))
}
/>
</label>
<label>
<span>вес кг</span>
<input
type="number"
value={profile.weight_kg ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, weight_kg: Number(e.target.value) }))
}
/>
</label>
<label>
<span>активность</span>
<select
value={profile.activity_level ?? "moderate"}
onChange={(e) =>
setProfile((p) => ({ ...p, activity_level: e.target.value }))
}
>
<option value="sedentary">sedentary</option>
<option value="light">light</option>
<option value="moderate">moderate</option>
<option value="active">active</option>
<option value="very_active">very_active</option>
</select>
</label>
<label>
<span>цель</span>
<select
value={profile.goal ?? "maintain"}
onChange={(e) => setProfile((p) => ({ ...p, goal: e.target.value }))}
>
<option value="lose">lose</option>
<option value="maintain">maintain</option>
<option value="gain">gain</option>
</select>
</label>
<button type="submit">Сохранить и пересчитать TDEE</button>
</form>
{profile.computed && (
<p className="fitness-computed">
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "}
{profile.computed.bmi}
</p>
)}
</section>
<section className="fitness-section">
<h3>Логи за сегодня</h3>
<h4>Еда</h4>
<ul className="fitness-log-list">
{(today?.meals ?? []).map((m) => (
<li key={m.id}>
{m.estimated ? "≈" : ""}
{m.description} {m.calories} ккал
<button type="button" onClick={() => handleDeleteMeal(m.id)}>
×
</button>
</li>
))}
</ul>
<h4>Вода</h4>
<ul className="fitness-log-list">
{(today?.water ?? []).map((w) => (
<li key={w.id}>
+{w.amount_ml} мл
<button type="button" onClick={() => handleDeleteWater(w.id)}>
×
</button>
</li>
))}
</ul>
<h4>Тренировки</h4>
<ul className="fitness-log-list">
{(today?.workouts ?? []).map((w) => (
<li key={w.id}>{w.title}</li>
))}
</ul>
</section>
<section className="fitness-section">
<h3>История веса</h3>
<table className="fitness-table">
<thead>
<tr>
<th>Дата</th>
<th>кг</th>
</tr>
</thead>
<tbody>
{(snapshot?.body_metrics ?? []).map((m) => (
<tr key={m.id}>
<td>{m.recorded_at?.slice(0, 10)}</td>
<td>{m.weight_kg}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="fitness-section">
<h3>Напоминания</h3>
<ul className="fitness-reminders">
{(snapshot?.reminders ?? []).map((r) => (
<li key={r.id}>
<span>{r.kind}</span>
<span>
{r.interval_hours
? `каждые ${r.interval_hours}ч`
: `${String(r.hour).padStart(2, "0")}:${String(r.minute).padStart(2, "0")}`}
</span>
<button type="button" onClick={() => handleToggleReminder(r)}>
{r.enabled ? "вкл" : "выкл"}
</button>
</li>
))}
</ul>
</section>
</>
)}
</div>
);
}