smart tdee

This commit is contained in:
2026-06-16 04:38:23 +00:00
parent f2e98942ff
commit a3f01cd850
56 changed files with 2519 additions and 591 deletions
+7
View File
@@ -17,6 +17,12 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning. # none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none OPENROUTER_REASONING_EFFORT=none
# Vision (скриншоты Mi Fitness и др.)
OPENROUTER_VISION_MODEL=google/gemini-2.5-flash-lite
VISION_MAX_EDGE_PX=1280
VISION_JPEG_QUALITY=85
VISION_DEBUG_ENABLED=true
VISION_MAX_IMAGES=8
# JSON-экстракция памяти отдельной моделью (если основная капризничает): # JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat # MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
@@ -64,6 +70,7 @@ WEATHER_LAT=59.9343
WEATHER_LON=30.3351 WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300 WEATHER_CACHE_SEC=300
WEATHER_FORECAST_DAYS=7
# Если локальный OpenMeteo отдаёт только temperature_2m — подставить публичный API # Если локальный OpenMeteo отдаёт только temperature_2m — подставить публичный API
OPENMETEO_FALLBACK_URL=https://api.open-meteo.com OPENMETEO_FALLBACK_URL=https://api.open-meteo.com
OPENMETEO_FALLBACK_ON_PARTIAL=true OPENMETEO_FALLBACK_ON_PARTIAL=true
+2
View File
@@ -37,6 +37,8 @@ docker compose up --build
- Web UI: http://localhost:${FRONTEND_PORT:-3080} - Web UI: http://localhost:${FRONTEND_PORT:-3080}
- Healthcheck: http://localhost:8080/api/v1/health - Healthcheck: http://localhost:8080/api/v1/health
**Prod за nginx:** при загрузке скриншотов возможна ошибка `413 Request Entity Too Large` — дефолтный лимит nginx 1 MB. На **host nginx** (Ubuntu перед docker) добавьте `client_max_body_size 64m;` в `server { }` и в `location /api/`. Пример: [`deploy/nginx-host-assistant.conf.example`](deploy/nginx-host-assistant.conf.example). После правки: `sudo nginx -t && sudo systemctl reload nginx`. Контейнер frontend тоже поднимает лимит в `frontend/nginx.conf` — пересоберите образ.
Порты в `.env`: Порты в `.env`:
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
+102 -8
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,6 +12,7 @@ from app.api.schemas import (
SessionDetailOut, SessionDetailOut,
SessionOut, SessionOut,
) )
from app.auth.deps import get_current_user
from app.chat.generation import ( from app.chat.generation import (
GenerationBusyError, GenerationBusyError,
get_active_handle, get_active_handle,
@@ -19,12 +21,18 @@ from app.chat.generation import (
subscribe_generation, subscribe_generation,
) )
from app.chat.service import ChatService from app.chat.service import ChatService
from app.auth.deps import get_current_user from app.config import get_settings
from app.db.base import get_db from app.db.base import get_db
from app.db.models import User from app.db.models import User
from app.vision import VisionService, format_user_messages, vision_debug_payloads
from app.vision.analyze import VisionUnavailableError
from app.vision.preprocess import prepare_image
from app.vision.storage import format_upload_images_markdown, save_upload
router = APIRouter() router = APIRouter()
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/sessions", response_model=SessionOut) @router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut: def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
@@ -108,11 +116,95 @@ def delete_session(session_id: int, db: Session = Depends(get_db), user: User =
return {"ok": True} return {"ok": True}
def _collect_form_uploads(form) -> list:
uploads: list = []
seen_ids: set[int] = set()
def _append(item) -> None:
if item is None or not hasattr(item, "read"):
return
item_id = id(item)
if item_id in seen_ids:
return
seen_ids.add(item_id)
uploads.append(item)
if hasattr(form, "getlist"):
for item in form.getlist("images"):
_append(item)
single = form.get("image")
_append(single)
return uploads
async def _analyze_upload(raw: bytes, *, caption: str, user_id: int):
prepared = prepare_image(raw)
filename = save_upload(prepared, user_id=user_id)
result = await VisionService().analyze_prepared(prepared, user_hint=caption)
return result, filename
async def _parse_message_request(
request: Request,
*,
user_id: int,
) -> tuple[str, dict | None]:
content_type = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" not in content_type:
try:
body = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
payload = MessageCreate.model_validate(body)
return payload.content, None
form = await request.form()
caption = str(form.get("content") or "").strip()
uploads = _collect_form_uploads(form)
if not uploads:
raise HTTPException(status_code=400, detail="Field 'images' or 'image' is required for multipart upload")
max_images = max(1, int(get_settings().vision_max_images))
if len(uploads) > max_images:
raise HTTPException(
status_code=400,
detail=f"Too many images (max {max_images})",
)
raw_images: list[bytes] = []
for upload in uploads:
raw = await upload.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty image file")
mime = getattr(upload, "content_type", None) or "application/octet-stream"
if mime not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail=f"Unsupported image type: {mime}")
raw_images.append(raw)
try:
analyzed = await asyncio.gather(
*(_analyze_upload(raw, caption=caption, user_id=user_id) for raw in raw_images)
)
except VisionUnavailableError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
results = [item[0] for item in analyzed]
filenames = [item[1] for item in analyzed]
debug = vision_debug_payloads(results)
vision_text = format_user_messages(caption, results)
images_md = format_upload_images_markdown(user_id, filenames)
user_text = f"{images_md}\n\n{vision_text}" if images_md else vision_text
if not user_text.strip():
raise HTTPException(status_code=400, detail="Could not build message from image")
return user_text, debug
@router.post("/sessions/{session_id}/messages") @router.post("/sessions/{session_id}/messages")
async def send_message( async def send_message(
session_id: int, session_id: int,
payload: MessageCreate, request: Request,
db: Session = Depends(get_db), user: User = Depends(get_current_user), db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StreamingResponse: ) -> StreamingResponse:
service = ChatService(db, user.id) service = ChatService(db, user.id)
if not service.get_session(session_id): if not service.get_session(session_id):
@@ -121,16 +213,19 @@ async def send_message(
if is_generation_active(session_id): if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress") raise HTTPException(status_code=409, detail="Generation already in progress")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД. user_text, vision_debug = await _parse_message_request(request, user_id=user.id)
service.save_user_message(session_id, payload.content)
service.save_user_message(session_id, user_text)
try: try:
handle = await start_generation(session_id, user.id, payload.content) handle = await start_generation(session_id, user.id, user_text)
except GenerationBusyError: except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream(): async def event_stream():
try: try:
if vision_debug:
yield ChatService._sse("vision", vision_debug)
async for chunk in subscribe_generation(handle): async for chunk in subscribe_generation(handle):
yield chunk yield chunk
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -155,4 +250,3 @@ def context_preview(
) -> dict: ) -> dict:
service = ChatService(db, user.id) service = ChatService(db, user.id)
return service.context_preview(session_id, query=query) return service.context_preview(session_id, query=query)
+3 -4
View File
@@ -21,12 +21,9 @@ class ProfileUpdate(BaseModel):
age: int | None = None age: int | None = None
height_cm: float | None = None height_cm: float | None = None
weight_kg: float | None = None weight_kg: float | None = None
activity_level: str | None = None
goal: str | None = None goal: str | None = None
target_weight_kg: float | None = None target_weight_kg: float | None = None
weekly_workouts: int | None = None neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
baseline_steps: int | None = None
baseline_workout_kcal: float | None = None
class MealCreate(BaseModel): class MealCreate(BaseModel):
@@ -254,6 +251,8 @@ async def create_workout(
active_calories=structured.get("active_calories"), active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"), total_calories=structured.get("total_calories"),
steps=structured.get("steps"), steps=structured.get("steps"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
day=day, day=day,
days_ago=payload.days_ago, days_ago=payload.days_ago,
logged_at=payload.logged_at, logged_at=payload.logged_at,
+4 -2
View File
@@ -48,7 +48,9 @@ def homelab_status() -> dict:
@router.get("/weather") @router.get("/weather")
def weather_dashboard( def weather_dashboard(
hours_ahead: int = 12, hours_ahead: int = 12,
days_ahead: int = 7,
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
) -> dict: ) -> dict:
hours = max(1, min(int(hours_ahead), 48)) hours = max(1, min(int(hours_ahead), 168))
return build_weather_dashboard(hours_ahead=hours) days = max(1, min(int(days_ahead), 16))
return build_weather_dashboard(hours_ahead=hours, days_ahead=days)
+22 -1
View File
@@ -1,9 +1,11 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.auth.deps import get_current_user
from app.config import get_settings from app.config import get_settings
from app.db.models import User
router = APIRouter(prefix="/media", tags=["media"]) router = APIRouter(prefix="/media", tags=["media"])
@@ -19,3 +21,22 @@ def get_generated_image(filename: str) -> FileResponse:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/png") return FileResponse(path, media_type="image/png")
@router.get("/uploads/{user_id}/{filename}")
def get_upload_image(
user_id: int,
filename: str,
user: User = Depends(get_current_user),
) -> FileResponse:
if user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
settings = get_settings()
path = Path(settings.uploads_dir) / str(user_id) / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/jpeg")
+1
View File
@@ -15,6 +15,7 @@ router = APIRouter()
class SettingsPatch(BaseModel): class SettingsPatch(BaseModel):
openrouter_model: str | None = None openrouter_model: str | None = None
memory_extract_model: str | None = None memory_extract_model: str | None = None
openrouter_vision_model: str | None = None
openrouter_reasoning_effort: str | None = None openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50) rag_top_k: int | None = Field(default=None, ge=1, le=50)
+4 -1
View File
@@ -14,7 +14,10 @@ def _extract_token(request: Request) -> str | None:
if token: if token:
return token return token
header = request.headers.get("X-API-Token", "").strip() header = request.headers.get("X-API-Token", "").strip()
return header or None if header:
return header
query = request.query_params.get("token", "").strip()
return query or None
def get_current_user( def get_current_user(
+4
View File
@@ -14,6 +14,10 @@ TOOLS_INSTRUCTIONS = """
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout, - Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history. - «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
- Скриншоты и фото: vision-модель уже разобрала каждую картинку ДО твоего ответа. В сообщении один или несколько блоков [Скриншот] / [Скриншот N/M] — это содержимое изображений; отвечай так, будто ты их видишь.
- НЕ говори, что у тебя нет глаз / ты не видишь картинку / нужен Gemini, OpenRouter или curl — распознавание уже выполнено.
- fitness_workout / fitness_steps + fitness_hints: log_workout, log_steps и т.д.; при confidence=low уточни детали.
- document_type=other: опиши и прокомментируй по блоку [Скриншот], без советов про настройку vision API.
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder. calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), 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. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
+16 -2
View File
@@ -20,7 +20,7 @@ from app.chat.notices import (
) )
from app.fitness.context import format_fitness_context, get_fitness_snapshot from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.homelab.context import format_datetime_context from app.homelab.context import format_datetime_context
from app.homelab.openmeteo import format_weather_snapshot from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
from app.memory.context import ( from app.memory.context import (
format_identity_hint, format_identity_hint,
format_memory_context, format_memory_context,
@@ -34,6 +34,7 @@ from app.db.models import ChatSession, Message
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool from app.tools.registry import TOOL_DEFINITIONS, execute_tool
from app.vision.analyze import format_vision_turn_hint
MAX_TOOL_ROUNDS = 5 MAX_TOOL_ROUNDS = 5
MAX_HISTORY_MESSAGES = 40 MAX_HISTORY_MESSAGES = 40
@@ -45,6 +46,11 @@ _DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"), "shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"), "reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"), "projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
"weather": (
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
"weather", "rain", "forecast", "umbrella", "outside",
),
} }
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -186,7 +192,12 @@ class ChatService:
self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context), self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context),
self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context), self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context),
self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context), self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context),
format_weather_snapshot(), self._optional_domain(
"weather",
user_query,
lambda: OpenMeteoClient().fetch_forecast(hours_ahead=6, days_ahead=7),
lambda snap: format_weather_snapshot(snap, include_daily=True),
),
format_pomodoro_context(status), format_pomodoro_context(status),
self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context), self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context),
] ]
@@ -201,6 +212,9 @@ class ChatService:
identity_hint = format_identity_hint(memory_snapshot, last_user) identity_hint = format_identity_hint(memory_snapshot, last_user)
if identity_hint: if identity_hint:
system_prompt += f"\n\n{identity_hint}" system_prompt += f"\n\n{identity_hint}"
vision_hint = format_vision_turn_hint(last_user)
if vision_hint:
system_prompt += f"\n\n{vision_hint}"
if len(all_chat) > MAX_HISTORY_MESSAGES: if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += ( system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} " f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
+23
View File
@@ -1,8 +1,19 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
DEPRECATED_VISION_MODELS: dict[str, str] = {
"google/gemini-2.0-flash-lite-001": "google/gemini-2.5-flash-lite",
"google/gemini-2.0-flash-lite": "google/gemini-2.5-flash-lite",
}
def resolve_vision_model(model: str) -> str:
stripped = model.strip()
return DEPRECATED_VISION_MODELS.get(stripped, stripped)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -23,6 +34,17 @@ class Settings(BaseSettings):
openrouter_tools_enabled: bool = True openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking. # DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none" openrouter_reasoning_effort: str = "none"
openrouter_vision_model: str = "google/gemini-2.5-flash-lite"
vision_max_edge_px: int = 1280
vision_jpeg_quality: int = 85
vision_debug_enabled: bool = True
vision_max_images: int = 8
uploads_dir: str = "./data/uploads"
@field_validator("openrouter_vision_model")
@classmethod
def migrate_vision_model(cls, value: str) -> str:
return resolve_vision_model(value)
database_url: str = "sqlite:///./data/assistant.db" database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
@@ -63,6 +85,7 @@ class Settings(BaseSettings):
weather_lon: float = 30.3351 weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург" weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300 weather_cache_sec: int = 300
weather_forecast_days: int = 7
openmeteo_fallback_url: str = "https://api.open-meteo.com" openmeteo_fallback_url: str = "https://api.open-meteo.com"
openmeteo_fallback_on_partial: bool = True openmeteo_fallback_on_partial: bool = True
+130 -1
View File
@@ -1,6 +1,20 @@
from sqlalchemy import inspect, text import logging
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.db.base import engine from app.db.base import engine
from app.db.models import FitnessProfile
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
logger = logging.getLogger(__name__)
TDEE_V2_BACKFILL = "fitness_tdee_v2_backfill"
MACROS_GKG_BACKFILL = "fitness_macros_gkg_v1"
def _table_exists(table: str) -> bool:
return table in inspect(engine).get_table_names()
def _add_column_if_missing(table: str, column: str, ddl: str) -> None: def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
@@ -14,6 +28,113 @@ def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
conn.execute(text(ddl)) conn.execute(text(ddl))
def _ensure_schema_migrations_table() -> None:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
"name TEXT PRIMARY KEY, "
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
def _migration_applied(name: str) -> bool:
_ensure_schema_migrations_table()
with engine.begin() as conn:
row = conn.execute(
text("SELECT 1 FROM _schema_migrations WHERE name = :name"),
{"name": name},
).fetchone()
return row is not None
def _mark_migration_applied(name: str) -> None:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO _schema_migrations (name) VALUES (:name)"),
{"name": name},
)
def _profile_targets(row: FitnessProfile) -> dict[str, float]:
neat = row.neat_base_kcal if row.neat_base_kcal is not None else DEFAULT_NEAT_KCAL
return compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": neat,
}
)
def backfill_tdee_targets(*, force: bool = False) -> int:
"""Recalculate stored calorie/macro targets for all profiles (PAL → BMR+NEAT)."""
if not _table_exists("fitness_profiles"):
return 0
_ensure_schema_migrations_table()
if not force and _migration_applied(TDEE_V2_BACKFILL):
return 0
with engine.begin() as conn:
conn.execute(
text(
"UPDATE fitness_profiles "
"SET neat_base_kcal = :neat "
"WHERE neat_base_kcal IS NULL"
),
{"neat": DEFAULT_NEAT_KCAL},
)
updated = 0
with Session(engine) as db:
rows = db.scalars(select(FitnessProfile)).all()
for row in rows:
if row.neat_base_kcal is None:
row.neat_base_kcal = DEFAULT_NEAT_KCAL
targets = _profile_targets(row)
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"]
updated += 1
db.commit()
if not force or not _migration_applied(TDEE_V2_BACKFILL):
_mark_migration_applied(TDEE_V2_BACKFILL)
logger.info("TDEE v2 backfill: recalculated %s fitness profile(s)", updated)
return updated
def backfill_macros_gkg(*, force: bool = False) -> int:
"""Recalculate stored BJU from weight (protein/fat g/kg, carbs = remainder)."""
if not _table_exists("fitness_profiles"):
return 0
_ensure_schema_migrations_table()
if not force and _migration_applied(MACROS_GKG_BACKFILL):
return 0
updated = 0
with Session(engine) as db:
rows = db.scalars(select(FitnessProfile)).all()
for row in rows:
macros = macro_targets(row.calorie_target, row.weight_kg, row.goal)
row.protein_g = macros["protein_g"]
row.fat_g = macros["fat_g"]
row.carbs_g = macros["carbs_g"]
updated += 1
db.commit()
_mark_migration_applied(MACROS_GKG_BACKFILL)
logger.info("Macros g/kg backfill: updated %s fitness profile(s)", updated)
return updated
def run_fitness_migrations() -> None: def run_fitness_migrations() -> None:
inspector = inspect(engine) inspector = inspect(engine)
@@ -28,6 +149,11 @@ def run_fitness_migrations() -> None:
"baseline_workout_kcal", "baseline_workout_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT", "ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
) )
_add_column_if_missing(
"fitness_profiles",
"neat_base_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN neat_base_kcal FLOAT DEFAULT 200.0",
)
if "workout_logs" in inspector.get_table_names(): if "workout_logs" in inspector.get_table_names():
_add_column_if_missing( _add_column_if_missing(
@@ -92,3 +218,6 @@ def run_fitness_migrations() -> None:
"ffmi", "ffmi",
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT", "ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
) )
backfill_tdee_targets()
backfill_macros_gkg()
+1
View File
@@ -179,6 +179,7 @@ class FitnessProfile(Base):
activity_level: Mapped[str] = mapped_column(String(32), default="moderate") activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain") goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True) target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
neat_base_kcal: Mapped[float] = mapped_column(Float, default=200.0)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3) weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True) baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True) baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
+50 -125
View File
@@ -1,143 +1,68 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any from typing import Any
BASELINE_STEPS_BY_LEVEL: dict[str, int] = { DEFAULT_MET = 5.0
"sedentary": 5000,
"light": 7000,
"moderate": 9000,
"active": 11000,
"very_active": 13000,
}
WORKOUT_KCAL_PER_SESSION = 200 MET_BY_KEYWORD: list[tuple[str, float]] = [
KCAL_PER_STEP_PER_KG = 0.0005 ("триатлон", 10.0),
FALLBACK_KCAL_PER_MIN = 6 ("марафон", 9.8),
("бег", 9.8),
("running", 9.8),
("run", 9.0),
("плаван", 8.0),
("swim", 8.0),
("велосипед", 7.5),
("cycling", 7.5),
("вел", 7.5),
("hiit", 8.0),
("кроссфит", 8.0),
("силов", 6.0),
("strength", 6.0),
("зал", 5.5),
("gym", 5.5),
("йога", 3.0),
("yoga", 3.0),
("ходьб", 3.5),
("walk", 3.5),
("прогул", 3.5),
]
@dataclass def infer_met(workout: dict[str, Any]) -> float | None:
class ActivityBonus: explicit = workout.get("met")
steps: int if explicit is not None:
steps_baseline: int return float(explicit)
steps_bonus_kcal: float
workout_active_kcal: float
workout_baseline_kcal: float
workout_bonus_kcal: float
total_bonus_kcal: float
scale_factor: float
def to_dict(self) -> dict[str, Any]: activity_type = str(workout.get("activity_type") or "").lower()
return asdict(self) title = str(workout.get("title") or "").lower()
notes = str(workout.get("notes") or "").lower()
haystack = f"{activity_type} {title} {notes}"
for keyword, met in MET_BY_KEYWORD:
if keyword in haystack:
return met
return None
def baseline_steps(profile: dict[str, Any]) -> int: def estimate_workout_active_kcal(workout: dict[str, Any], *, weight_kg: float) -> float:
override = profile.get("baseline_steps")
if override is not None:
return int(override)
level = str(profile.get("activity_level") or "moderate")
return BASELINE_STEPS_BY_LEVEL.get(level, 9000)
def baseline_workout_kcal_day(profile: dict[str, Any]) -> float:
override = profile.get("baseline_workout_kcal")
if override is not None:
return float(override)
weekly = int(profile.get("weekly_workouts") or 3)
return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1)
def estimate_workout_active_kcal(workout: dict[str, Any]) -> float:
active = workout.get("active_calories") active = workout.get("active_calories")
if active is not None: if active is not None:
return float(active) return round(float(active), 1)
duration = workout.get("duration_min") duration = workout.get("duration_min")
if duration: if not duration:
return float(duration) * FALLBACK_KCAL_PER_MIN
return 0.0 return 0.0
met = infer_met(workout)
if met is None:
return 0.0
def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float: hours = float(duration) / 60.0
extra_steps = max(0, steps - baseline_steps) return round(met * weight_kg * hours, 1)
return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1)
def compute_activity_bonus( def workouts_kcal_total(workouts: list[dict[str, Any]], *, weight_kg: float) -> float:
profile: dict[str, Any], if not workouts:
*, return 0.0
steps_total: int, return round(sum(estimate_workout_active_kcal(w, weight_kg=weight_kg) for w in workouts), 1)
workouts: list[dict[str, Any]],
) -> ActivityBonus:
weight_kg = float(profile.get("weight_kg") or 70)
steps_base = baseline_steps(profile)
workout_base = baseline_workout_kcal_day(profile)
s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg)
workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1)
w_bonus = max(0.0, round(workout_active - workout_base, 1))
total_bonus = round(s_bonus + w_bonus, 1)
base_cal = float(profile.get("calorie_target") or 2000)
scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4)
return ActivityBonus(
steps=steps_total,
steps_baseline=steps_base,
steps_bonus_kcal=s_bonus,
workout_active_kcal=workout_active,
workout_baseline_kcal=workout_base,
workout_bonus_kcal=w_bonus,
total_bonus_kcal=total_bonus,
scale_factor=scale_factor,
)
def _targets_dict(
*,
calories: float,
protein_g: float,
fat_g: float,
carbs_g: float,
water_ml: float,
) -> dict[str, float]:
return {
"calories": round(calories),
"protein_g": round(protein_g),
"fat_g": round(fat_g),
"carbs_g": round(carbs_g),
"water_ml": round(water_ml),
}
def build_base_targets(profile: dict[str, Any]) -> dict[str, float]:
water_l = float(profile.get("water_l") or 2.5)
return _targets_dict(
calories=float(profile.get("calorie_target") or 2000),
protein_g=float(profile.get("protein_g") or 140),
fat_g=float(profile.get("fat_g") or 65),
carbs_g=float(profile.get("carbs_g") or 200),
water_ml=water_l * 1000,
)
def scale_targets(
base_targets: dict[str, float],
bonus_kcal: float,
) -> tuple[dict[str, float], dict[str, float]]:
"""Return (effective_targets, targets_base). Water is not scaled."""
targets_base = dict(base_targets)
base_cal = float(base_targets["calories"])
if bonus_kcal <= 0 or base_cal <= 0:
return dict(base_targets), targets_base
scale = (base_cal + bonus_kcal) / base_cal
effective = _targets_dict(
calories=base_cal + bonus_kcal,
protein_g=float(base_targets["protein_g"]) * scale,
fat_g=float(base_targets["fat_g"]) * scale,
carbs_g=float(base_targets["carbs_g"]) * scale,
water_ml=float(base_targets["water_ml"]),
)
return effective, targets_base
+104 -30
View File
@@ -1,12 +1,12 @@
from typing import Any from typing import Any
ACTIVITY_MULTIPLIERS = { from app.fitness.activity_budget import workouts_kcal_total
"sedentary": 1.2,
"light": 1.375, DEFAULT_NEAT_KCAL = 200.0
"moderate": 1.55, NEAT_KCAL_MIN = 200.0
"active": 1.725, NEAT_KCAL_MAX = 300.0
"very_active": 1.9, KCAL_PER_STEP_REF = 0.04 / 86 # ~0.04 kcal/step at 86 kg
} WATER_ML_PER_KG = 33 # middle of 3035 ml/kg range
GOAL_CALORIE_ADJUST = { GOAL_CALORIE_ADJUST = {
"lose": -500, "lose": -500,
@@ -14,6 +14,13 @@ GOAL_CALORIE_ADJUST = {
"gain": 300, "gain": 300,
} }
PROTEIN_G_PER_KG = {
"lose": 2.2,
"maintain": 1.8,
"gain": 1.8,
}
FAT_G_PER_KG = 1.0
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float: def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age base = 10 * weight_kg + 6.25 * height_cm - 5 * age
@@ -22,17 +29,17 @@ def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> fl
return base - 161 return base - 161
def tdee( def neat_base_kcal(profile: dict[str, Any]) -> float:
*, raw = profile.get("neat_base_kcal")
sex: str, if raw is not None:
weight_kg: float, return max(NEAT_KCAL_MIN, min(NEAT_KCAL_MAX, float(raw)))
height_cm: float, return DEFAULT_NEAT_KCAL
age: int,
activity_level: str = "moderate",
) -> float: def steps_kcal(*, steps: int, weight_kg: float) -> float:
bmr = bmr_mifflin(sex=sex, weight_kg=weight_kg, height_cm=height_cm, age=age) if steps <= 0:
mult = ACTIVITY_MULTIPLIERS.get(activity_level, 1.55) return 0.0
return bmr * mult return round(steps * weight_kg * KCAL_PER_STEP_REF, 1)
def bmi(weight_kg: float, height_cm: float) -> float: def bmi(weight_kg: float, height_cm: float) -> float:
@@ -43,7 +50,7 @@ def bmi(weight_kg: float, height_cm: float) -> float:
def water_target_l(weight_kg: float) -> float: def water_target_l(weight_kg: float) -> float:
return round(weight_kg * 0.033, 1) return round(weight_kg * WATER_ML_PER_KG / 1000, 1)
def macro_targets( def macro_targets(
@@ -51,8 +58,8 @@ def macro_targets(
weight_kg: float, weight_kg: float,
goal: str = "maintain", goal: str = "maintain",
) -> dict[str, float]: ) -> dict[str, float]:
protein_g = round(weight_kg * (2.0 if goal == "gain" else 1.8), 0) protein_g = round(weight_kg * PROTEIN_G_PER_KG.get(goal, 1.8), 0)
fat_g = round((calorie_target * 0.25) / 9, 0) fat_g = round(weight_kg * FAT_G_PER_KG, 0)
protein_cal = protein_g * 4 protein_cal = protein_g * 4
fat_cal = fat_g * 9 fat_cal = fat_g * 9
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0)) carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
@@ -67,28 +74,95 @@ def one_rep_max(weight_kg: float, reps: int) -> float:
return round(weight_kg * (1 + reps / 30), 1) return round(weight_kg * (1 + reps / 30), 1)
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]: def _profile_fields(profile: dict[str, Any]) -> tuple[float, float, int, str, str]:
weight = float(profile.get("weight_kg") or 70) weight = float(profile.get("weight_kg") or 70)
height = float(profile.get("height_cm") or 170) height = float(profile.get("height_cm") or 170)
age = int(profile.get("age") or 30) age = int(profile.get("age") or 30)
sex = str(profile.get("sex") or "male") sex = str(profile.get("sex") or "male")
activity = str(profile.get("activity_level") or "moderate")
goal = str(profile.get("goal") or "maintain") goal = str(profile.get("goal") or "maintain")
return weight, height, age, sex, goal
tdee_val = tdee(
sex=sex, weight_kg=weight, height_cm=height, age=age, activity_level=activity def compute_tdee(
) profile: dict[str, Any],
calorie_target = round(tdee_val + GOAL_CALORIE_ADJUST.get(goal, 0), 0) *,
steps_total: int = 0,
workouts: list[dict[str, Any]] | None = None,
) -> dict[str, float]:
weight, height, age, sex, _ = _profile_fields(profile)
bmr = bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age)
neat = neat_base_kcal(profile)
s_kcal = steps_kcal(steps=steps_total, weight_kg=weight)
w_kcal = workouts_kcal_total(workouts or [], weight_kg=weight)
tdee_val = bmr + neat + s_kcal + w_kcal
return {
"bmr": round(bmr, 0),
"neat_kcal": round(neat, 0),
"steps_kcal": s_kcal,
"workout_kcal": w_kcal,
"tdee": round(tdee_val, 0),
}
def compute_daily_targets(
profile: dict[str, Any],
*,
steps_total: int = 0,
workouts: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
weight, height, age, sex, goal = _profile_fields(profile)
breakdown = compute_tdee(profile, steps_total=steps_total, workouts=workouts)
calorie_target = round(breakdown["tdee"] + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
macros = macro_targets(calorie_target, weight, goal) macros = macro_targets(calorie_target, weight, goal)
water = water_target_l(weight) water = water_target_l(weight)
return { return {
"bmr": round(bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age), 0), **breakdown,
"tdee": round(tdee_val, 0),
"bmi": round(bmi(weight, height), 1),
"calorie_target": calorie_target, "calorie_target": calorie_target,
"protein_g": macros["protein_g"], "protein_g": macros["protein_g"],
"fat_g": macros["fat_g"], "fat_g": macros["fat_g"],
"carbs_g": macros["carbs_g"], "carbs_g": macros["carbs_g"],
"water_l": water, "water_l": water,
"bmi": round(bmi(weight, height), 1),
"steps": steps_total,
}
def targets_to_api(daily: dict[str, Any]) -> dict[str, float]:
return {
"calories": daily["calorie_target"],
"protein_g": daily["protein_g"],
"fat_g": daily["fat_g"],
"carbs_g": daily["carbs_g"],
"water_ml": round(daily["water_l"] * 1000),
}
def tdee_breakdown_to_api(daily: dict[str, Any]) -> dict[str, Any]:
return {
"bmr": daily["bmr"],
"neat_kcal": daily["neat_kcal"],
"steps_kcal": daily["steps_kcal"],
"workout_kcal": daily["workout_kcal"],
"tdee": daily["tdee"],
"calorie_target": daily["calorie_target"],
"steps": daily.get("steps", 0),
}
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
"""Rest-day targets (BMR + NEAT, no steps/workouts) for profile storage."""
daily = compute_daily_targets(profile, steps_total=0, workouts=[])
return {
"bmr": daily["bmr"],
"tdee": daily["tdee"],
"bmi": daily["bmi"],
"neat_kcal": daily["neat_kcal"],
"steps_kcal": 0,
"workout_kcal": 0,
"calorie_target": daily["calorie_target"],
"protein_g": daily["protein_g"],
"fat_g": daily["fat_g"],
"carbs_g": daily["carbs_g"],
"water_l": daily["water_l"],
} }
+22 -10
View File
@@ -16,11 +16,16 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
if not profile: if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.") lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else: else:
computed = profile.get("computed") or {}
lines.append( lines.append(
f"Цели (база): {profile.get('calorie_target')} ккал, " f"Цели (база, без шагов/тренировок): {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, " f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л" f"вода {profile.get('water_l')} л"
) )
lines.append(
f"BMR {computed.get('bmr', '?')} + NEAT {computed.get('neat_kcal', 200)} = "
f"TDEE база {computed.get('tdee', '?')} ккал"
)
if profile.get("goal"): if profile.get("goal"):
lines.append( lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, " f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
@@ -30,19 +35,23 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
today = snapshot.get("today") or {} today = snapshot.get("today") or {}
totals = today.get("totals") or {} totals = today.get("totals") or {}
targets = today.get("targets") or {} targets = today.get("targets") or {}
targets_base = today.get("targets_base") or {} breakdown = today.get("tdee_breakdown") or {}
activity = today.get("activity") or {}
steps_total = today.get("steps_total") or 0 steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000 water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000 water_target = targets.get("water_ml", 2500) / 1000
if profile and (activity.get("total_bonus_kcal") or steps_total): if breakdown:
lines.append( lines.append(
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), " f"TDEE за день: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал" f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + "
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
f"цель {breakdown.get('calorie_target')} ккал"
)
elif steps_total == 0:
lines.append(
"Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. "
"log_steps / log_workout для точной дневной цели."
) )
base_cal = targets_base.get("calories", profile.get("calorie_target"))
lines.append(f"Эффективная цель ккал: {base_cal}{targets.get('calories', base_cal)}")
lines.append("") lines.append("")
lines.append( lines.append(
@@ -61,7 +70,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
if stats.get("count"): if stats.get("count"):
lines.append( lines.append(
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} " f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)" f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)"
) )
latest = (snapshot.get("body_metrics") or [None])[0] latest = (snapshot.get("body_metrics") or [None])[0]
@@ -89,6 +98,9 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), " "Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, " "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить." "TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
"Еда — оценка LLM (≈)."
) )
return chr(10).join(lines) return chr(10).join(lines)
+59 -56
View File
@@ -14,13 +14,14 @@ from app.db.models import (
WaterLog, WaterLog,
WorkoutLog, WorkoutLog,
) )
from app.fitness.activity_budget import ( from app.fitness.activity_budget import estimate_workout_active_kcal
build_base_targets, from app.fitness.calculators import (
compute_activity_bonus, compute_daily_targets,
estimate_workout_active_kcal, compute_targets,
scale_targets, one_rep_max,
targets_to_api,
tdee_breakdown_to_api,
) )
from app.fitness.calculators import compute_targets, one_rep_max
from app.fitness.body_composition import compute_body_composition from app.fitness.body_composition import compute_body_composition
DEFAULT_REMINDERS = [ DEFAULT_REMINDERS = [
@@ -45,28 +46,26 @@ class FitnessService:
return None return None
return self._profile_to_dict(row) return self._profile_to_dict(row)
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: def _profile_params(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets( return {
{ "sex": row.sex,
"sex": row.sex, "age": row.age,
"age": row.age, "height_cm": row.height_cm,
"height_cm": row.height_cm, "weight_kg": row.weight_kg,
"weight_kg": row.weight_kg, "goal": row.goal,
"activity_level": row.activity_level, "neat_base_kcal": row.neat_base_kcal,
"goal": row.goal, }
}
) def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(self._profile_params(row))
return { return {
"sex": row.sex, "sex": row.sex,
"age": row.age, "age": row.age,
"height_cm": row.height_cm, "height_cm": row.height_cm,
"weight_kg": row.weight_kg, "weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal, "goal": row.goal,
"target_weight_kg": row.target_weight_kg, "target_weight_kg": row.target_weight_kg,
"weekly_workouts": row.weekly_workouts, "neat_base_kcal": row.neat_base_kcal,
"baseline_steps": row.baseline_steps,
"baseline_workout_kcal": row.baseline_workout_kcal,
"calorie_target": row.calorie_target, "calorie_target": row.calorie_target,
"protein_g": row.protein_g, "protein_g": row.protein_g,
"fat_g": row.fat_g, "fat_g": row.fat_g,
@@ -85,23 +84,13 @@ class FitnessService:
self.db.flush() self.db.flush()
for key in ( for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level", "sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "weekly_workouts", "goal", "target_weight_kg", "neat_base_kcal",
"baseline_steps", "baseline_workout_kcal",
): ):
if key in updates and updates[key] is not None: if key in updates and updates[key] is not None:
setattr(row, key, updates[key]) setattr(row, key, updates[key])
targets = compute_targets( targets = compute_targets(self._profile_params(row))
{
"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.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"] row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"] row.fat_g = targets["fat_g"]
@@ -193,14 +182,12 @@ class FitnessService:
if profile: if profile:
return profile return profile
return { return {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
"weight_kg": 70, "weight_kg": 70,
"activity_level": "moderate", "height_cm": 170,
"weekly_workouts": 3, "age": 30,
"sex": "male",
"goal": "maintain",
"neat_base_kcal": 200,
} }
@@ -248,24 +235,19 @@ class FitnessService:
"steps": steps_total, "steps": steps_total,
} }
base_targets = build_base_targets(profile) daily = compute_daily_targets(
activity = compute_activity_bonus(
profile, profile,
steps_total=steps_total, steps_total=steps_total,
workouts=workouts, workouts=workouts,
) )
effective_targets, targets_base = scale_targets( targets = targets_to_api(daily)
base_targets,
activity.total_bonus_kcal,
)
return { return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(), "date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile_row is not None, "profile_configured": profile_row is not None,
"totals": totals, "totals": totals,
"targets": effective_targets, "targets": targets,
"targets_base": targets_base, "tdee_breakdown": tdee_breakdown_to_api(daily),
"activity": activity.to_dict(),
"meals": [self._food_to_dict(f) for f in foods], "meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters], "water": [self._water_to_dict(w) for w in waters],
"workouts": workouts, "workouts": workouts,
@@ -393,8 +375,8 @@ class FitnessService:
"age": profile_row.age, "age": profile_row.age,
"height_cm": profile_row.height_cm, "height_cm": profile_row.height_cm,
"weight_kg": weight_kg, "weight_kg": weight_kg,
"activity_level": profile_row.activity_level,
"goal": profile_row.goal, "goal": profile_row.goal,
"neat_base_kcal": profile_row.neat_base_kcal,
} }
) )
profile_row.calorie_target = targets["calorie_target"] profile_row.calorie_target = targets["calorie_target"]
@@ -428,10 +410,27 @@ class FitnessService:
active_calories: float | None = None, active_calories: float | None = None,
total_calories: float | None = None, total_calories: float | None = None,
steps: int | None = None, steps: int | None = None,
activity_type: str | None = None,
met: float | None = None,
logged_at: datetime | str | None = None, logged_at: datetime | str | None = None,
day: date | None = None, day: date | None = None,
days_ago: int | None = None, days_ago: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
profile = self.get_profile() or {}
weight_kg = float(profile.get("weight_kg") or 70)
if active_calories is None and duration_min and met is not None:
active_calories = round(met * weight_kg * (float(duration_min) / 60.0), 1)
elif active_calories is None and duration_min:
draft = {
"title": title,
"notes": notes,
"activity_type": activity_type,
"met": met,
"duration_min": duration_min,
}
active_calories = estimate_workout_active_kcal(draft, weight_kg=weight_kg) or None
row = WorkoutLog( row = WorkoutLog(
user_id=self.user_id, user_id=self.user_id,
title=title[:255], title=title[:255],
@@ -471,12 +470,16 @@ class FitnessService:
).all() ).all()
profile = self.get_profile() or {} profile = self.get_profile() or {}
weekly_target = int(profile.get("weekly_workouts") or 3) weight_kg = float(profile.get("weight_kg") or 70)
weekly_target = 3
count = len(rows) count = len(rows)
duration_min = sum(r.duration_min or 0 for r in rows) duration_min = sum(r.duration_min or 0 for r in rows)
active_kcal = round( active_kcal = round(
sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows), sum(
estimate_workout_active_kcal(self._workout_to_dict(r), weight_kg=weight_kg)
for r in rows
),
1, 1,
) )
@@ -583,7 +586,7 @@ class FitnessService:
*, *,
days: int = 7, days: int = 7,
end_day: date | None = None, end_day: date | None = None,
include_targets_base: bool = True, include_tdee_breakdown: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
days = max(1, min(days, 90)) days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date() end = end_day or datetime.now(timezone.utc).date()
@@ -603,8 +606,8 @@ class FitnessService:
"meal_count": len(full["meals"]), "meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]), "workout_count": len(full["workouts"]),
} }
if include_targets_base: if include_tdee_breakdown:
item["targets_base"] = full.get("targets_base") item["tdee_breakdown"] = full.get("tdee_breakdown")
summaries.append(item) summaries.append(item)
return { return {
+7 -1
View File
@@ -28,8 +28,10 @@ WORKOUT_PROMPT = """
Формат: Формат:
{ {
"title": "название", "title": "название",
"activity_type": "ходьба|бег|силовая|велосипед|плавание|йога|hiit|другое",
"duration_min": null, "duration_min": null,
"active_calories": null, "active_calories": null,
"met": null,
"total_calories": null, "total_calories": null,
"steps": null, "steps": null,
"notes": "", "notes": "",
@@ -39,7 +41,11 @@ WORKOUT_PROMPT = """
} }
Правила: Правила:
- weight_kg в кг, округляй разумно. - weight_kg в кг, округляй разумно.
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null. - active_calories — только если явно указаны в тексте, иначе null.
- duration_min — длительность в минутах, если можно оценить из текста.
- met — MET по Compendium of Physical Activities, если ккал не указаны (ходьба ~3.5, бег ~9.8, силовая ~6, велосипед ~7.5, плавание ~8, йога ~3, hiit ~8).
- activity_type — тип активности для расчёта MET.
- total_calories / steps — если упомянуты в тексте, иначе null.
- Если данных нет — null или пустой массив. - Если данных нет — null или пустой массив.
""".strip() """.strip()
+9 -5
View File
@@ -7,7 +7,7 @@ from app.homelab.rss import RssClient
def build_morning_digest(db: Session, *, include_news: bool = True) -> str: def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
del db # timezone resolved via weather client / profile in future extensions del db # timezone resolved via weather client / profile in future extensions
weather_client = OpenMeteoClient() weather_client = OpenMeteoClient()
weather = weather_client.fetch_current_and_hourly(hours_ahead=12) weather = weather_client.fetch_forecast(hours_ahead=12, days_ahead=7)
lines = ["🌤 **Утренний дайджест**", ""] lines = ["🌤 **Утренний дайджест**", ""]
@@ -18,7 +18,10 @@ def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, " f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, "
f"ветер {cur.get('wind_speed_kmh')} км/ч." f"ветер {cur.get('wind_speed_kmh')} км/ч."
) )
lines.append(weather_client.rain_summary(hours_ahead=12)) lines.append(weather_client.rain_summary(hours_ahead=12, daily=weather.get("daily")))
daily = weather_client.daily_summary(days_ahead=7)
if daily:
lines.append(f"**На неделю**: {daily}")
else: else:
lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).") lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).")
@@ -41,12 +44,13 @@ def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
return "\n".join(lines) return "\n".join(lines)
def build_weather_briefing(hours_ahead: int = 12, include_news: bool = False) -> dict: def build_weather_briefing(hours_ahead: int = 12, days_ahead: int = 7, include_news: bool = False) -> dict:
client = OpenMeteoClient() client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead) weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
result = { result = {
"weather": weather, "weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "", "rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
"context": format_weather_snapshot(weather), "context": format_weather_snapshot(weather),
} }
if include_news: if include_news:
+248 -59
View File
@@ -29,12 +29,19 @@ WEATHER_CODES: dict[int, str] = {
99: "гроза с градом", 99: "гроза с градом",
} }
WEATHER_QUERY_KEYWORDS = (
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
"weather", "rain", "forecast", "umbrella", "outside",
)
_cache: dict[str, Any] = { _cache: dict[str, Any] = {
"data": None, "data": None,
"fetched_at": 0.0, "fetched_at": 0.0,
"expires_at": 0.0, "expires_at": 0.0,
"source": "local", "source": "local",
"local_coverage": {"current": [], "hourly": []}, "local_coverage": {"current": [], "hourly": [], "daily": []},
"merged_fields": [],
} }
CURRENT_FIELDS = ( CURRENT_FIELDS = (
@@ -51,6 +58,14 @@ HOURLY_FIELDS = (
"precipitation", "precipitation",
"weather_code", "weather_code",
) )
DAILY_FIELDS = (
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"precipitation_sum",
"precipitation_probability_max",
"wind_speed_10m_max",
)
RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025" RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025"
RECOMMENDED_SYNC_VARIABLES = ( RECOMMENDED_SYNC_VARIABLES = (
@@ -58,11 +73,20 @@ RECOMMENDED_SYNC_VARIABLES = (
"precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m" "precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m"
) )
SYNC_HINT = ( SYNC_HINT = (
"Контейнер open-meteo-sync, скорее всего, качает только temperature_2m. " "Локальный open-meteo-sync отдаёт неполные данные. "
f"Задай SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} и " f"SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} "
f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). " f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). "
"Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api" "Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api"
) )
PRECIP_PROB_HINT = (
"Для вероятности дождя добавь ncep_gefs025 в SYNC_DOMAINS "
"и precipitation_probability в SYNC_VARIABLES."
)
def weather_query_relevant(query: str) -> bool:
q = (query or "").lower()
return any(kw in q for kw in WEATHER_QUERY_KEYWORDS)
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]: def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
@@ -70,6 +94,11 @@ def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
return values if isinstance(values, list) else [] return values if isinstance(values, list) else []
def _daily_series(daily: dict[str, Any], key: str) -> list[Any]:
values = daily.get(key)
return values if isinstance(values, list) else []
def _hourly_start_index(times: list[str], anchor_time: str | None) -> int: def _hourly_start_index(times: list[str], anchor_time: str | None) -> int:
if not times: if not times:
return 0 return 0
@@ -85,18 +114,21 @@ def _hourly_start_index(times: list[str], anchor_time: str | None) -> int:
def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]: def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]:
"""Какие поля реально пришли от OpenMeteo (не null)."""
current = raw.get("current") or {} current = raw.get("current") or {}
hourly = raw.get("hourly") or {} hourly = raw.get("hourly") or {}
current_present = [ daily = raw.get("daily") or {}
key for key in CURRENT_FIELDS if current.get(key) is not None current_present = [key for key in CURRENT_FIELDS if current.get(key) is not None]
]
hourly_present = [] hourly_present = []
for key in HOURLY_FIELDS: for key in HOURLY_FIELDS:
series = _hourly_series(hourly, key) series = _hourly_series(hourly, key)
if any(v is not None for v in series): if any(v is not None for v in series):
hourly_present.append(key) hourly_present.append(key)
return {"current": current_present, "hourly": hourly_present} daily_present = []
for key in DAILY_FIELDS:
series = _daily_series(daily, key)
if any(v is not None for v in series):
daily_present.append(key)
return {"current": current_present, "hourly": hourly_present, "daily": daily_present}
def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool: def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool:
@@ -106,11 +138,27 @@ def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool:
return False return False
if len(current) < 3: if len(current) < 3:
return False return False
if "precipitation_probability" not in hourly and "weather_code" not in hourly: if "weather_code" not in hourly and "temperature_2m" not in hourly:
return False return False
return True return True
def _local_needs_sync_hint(local_coverage: dict[str, list[str]]) -> bool:
current = set(local_coverage.get("current") or [])
hourly = set(local_coverage.get("hourly") or [])
if "temperature_2m" not in current:
return True
if "weather_code" not in current:
return True
if "temperature_2m" not in hourly:
return True
return False
def _missing_precip_probability(coverage: dict[str, list[str]]) -> bool:
return "precipitation_probability" not in set(coverage.get("hourly") or [])
def _fmt_num(value: Any, *, suffix: str = "") -> str: def _fmt_num(value: Any, *, suffix: str = "") -> str:
if value is None: if value is None:
return "" return ""
@@ -121,6 +169,42 @@ def _fmt_num(value: Any, *, suffix: str = "") -> str:
return f"{text}{suffix}" if suffix else text return f"{text}{suffix}" if suffix else text
def _conditions(code: Any) -> str:
if code is None:
return "неизвестно"
return WEATHER_CODES.get(int(code), "неизвестно")
def _format_day_label(date_str: str, index: int) -> str:
if index == 0:
return "Сегодня"
if index == 1:
return "Завтра"
if not date_str:
return f"День {index + 1}"
parts = date_str.split("-")
if len(parts) == 3:
return f"{parts[2]}.{parts[1]}"
return date_str
def _merge_hourly_field(target: dict[str, Any], source: dict[str, Any], field: str) -> bool:
hourly_t = target.setdefault("hourly", {})
hourly_s = source.get("hourly") or {}
src = hourly_s.get(field)
if not isinstance(src, list) or not any(v is not None for v in src):
return False
dst = hourly_t.get(field)
if isinstance(dst, list) and len(dst) == len(src):
hourly_t[field] = [
dst[i] if dst[i] is not None else src[i]
for i in range(len(src))
]
else:
hourly_t[field] = src
return True
class OpenMeteoClient: class OpenMeteoClient:
def __init__(self) -> None: def __init__(self) -> None:
settings = get_settings() settings = get_settings()
@@ -131,6 +215,7 @@ class OpenMeteoClient:
self.lon = settings.weather_lon self.lon = settings.weather_lon
self.location_name = settings.weather_location_name self.location_name = settings.weather_location_name
self.cache_ttl = settings.weather_cache_sec self.cache_ttl = settings.weather_cache_sec
self.forecast_days = max(2, int(settings.weather_forecast_days or 7))
def _request_params(self) -> dict[str, Any]: def _request_params(self) -> dict[str, Any]:
return { return {
@@ -138,8 +223,9 @@ class OpenMeteoClient:
"longitude": self.lon, "longitude": self.lon,
"current": ",".join(CURRENT_FIELDS), "current": ",".join(CURRENT_FIELDS),
"hourly": ",".join(HOURLY_FIELDS), "hourly": ",".join(HOURLY_FIELDS),
"daily": ",".join(DAILY_FIELDS),
"timezone": "auto", "timezone": "auto",
"forecast_days": 2, "forecast_days": self.forecast_days,
} }
def _fetch_from_url(self, base_url: str) -> dict[str, Any]: def _fetch_from_url(self, base_url: str) -> dict[str, Any]:
@@ -157,18 +243,26 @@ class OpenMeteoClient:
local_coverage = _field_coverage(local_raw) local_coverage = _field_coverage(local_raw)
source = "local" source = "local"
raw = local_raw raw = local_raw
merged_fields: list[str] = []
if ( need_fallback = (
self.fallback_on_partial self.fallback_on_partial
and self.fallback_url and self.fallback_url
and self.fallback_url.rstrip("/") != self.base_url and self.fallback_url.rstrip("/") != self.base_url
and not _coverage_sufficient(local_coverage) )
):
if need_fallback:
try: try:
fallback_raw = self._fetch_from_url(self.fallback_url) fallback_raw = self._fetch_from_url(self.fallback_url)
if _coverage_sufficient(_field_coverage(fallback_raw)): fallback_coverage = _field_coverage(fallback_raw)
if not _coverage_sufficient(local_coverage) and _coverage_sufficient(fallback_coverage):
raw = fallback_raw raw = fallback_raw
source = "fallback" source = "fallback"
elif _missing_precip_probability(local_coverage) and not _missing_precip_probability(fallback_coverage):
if _merge_hourly_field(raw, fallback_raw, "precipitation_probability"):
merged_fields.append("precipitation_probability")
source = "merged"
except Exception: except Exception:
pass pass
@@ -177,6 +271,7 @@ class OpenMeteoClient:
_cache["expires_at"] = now + self.cache_ttl _cache["expires_at"] = now + self.cache_ttl
_cache["source"] = source _cache["source"] = source
_cache["local_coverage"] = local_coverage _cache["local_coverage"] = local_coverage
_cache["merged_fields"] = merged_fields
return raw return raw
def cache_status(self) -> dict[str, Any]: def cache_status(self) -> dict[str, Any]:
@@ -194,43 +289,78 @@ class OpenMeteoClient:
"ttl_sec": self.cache_ttl, "ttl_sec": self.cache_ttl,
"expires_in_sec": expires_in_sec, "expires_in_sec": expires_in_sec,
"source": _cache.get("source") or "local", "source": _cache.get("source") or "local",
"merged_fields": list(_cache.get("merged_fields") or []),
} }
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]: def _build_hourly_slice(self, raw: dict[str, Any], hours_ahead: int) -> list[dict[str, Any]]:
current = raw.get("current") or {}
hourly = raw.get("hourly") or {}
times = hourly.get("time") or []
start = _hourly_start_index(times, current.get("time"))
end = min(start + hours_ahead, len(times))
rows: list[dict[str, Any]] = []
for i in range(start, end):
code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None
temp_series = _hourly_series(hourly, "temperature_2m")
precip_series = _hourly_series(hourly, "precipitation")
prob_series = _hourly_series(hourly, "precipitation_probability")
rows.append({
"time": times[i],
"temperature_c": temp_series[i] if i < len(temp_series) else None,
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
"weather_code": code,
"conditions": _conditions(code),
})
return rows
def _build_daily_slice(self, raw: dict[str, Any], days_ahead: int) -> list[dict[str, Any]]:
daily = raw.get("daily") or {}
times = daily.get("time") or []
limit = min(days_ahead, len(times))
rows: list[dict[str, Any]] = []
for i in range(limit):
code = _daily_series(daily, "weather_code")[i] if i < len(_daily_series(daily, "weather_code")) else None
rows.append({
"date": times[i],
"label": _format_day_label(times[i], i),
"temperature_max_c": _daily_series(daily, "temperature_2m_max")[i] if i < len(_daily_series(daily, "temperature_2m_max")) else None,
"temperature_min_c": _daily_series(daily, "temperature_2m_min")[i] if i < len(_daily_series(daily, "temperature_2m_min")) else None,
"precipitation_sum_mm": _daily_series(daily, "precipitation_sum")[i] if i < len(_daily_series(daily, "precipitation_sum")) else None,
"precipitation_probability_max": _daily_series(daily, "precipitation_probability_max")[i] if i < len(_daily_series(daily, "precipitation_probability_max")) else None,
"wind_speed_max_kmh": _daily_series(daily, "wind_speed_10m_max")[i] if i < len(_daily_series(daily, "wind_speed_10m_max")) else None,
"weather_code": code,
"conditions": _conditions(code),
})
return rows
def fetch_forecast(self, hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
hours_ahead = max(1, min(int(hours_ahead), 168))
days_ahead = max(1, min(int(days_ahead), self.forecast_days))
try: try:
raw = self._fetch_raw() raw = self._fetch_raw()
except Exception as exc: except Exception as exc:
return {"ok": False, "error": str(exc), "location": self.location_name} return {"ok": False, "error": str(exc), "location": self.location_name}
current = raw.get("current") or {} current = raw.get("current") or {}
hourly = raw.get("hourly") or {}
times = hourly.get("time") or []
start = _hourly_start_index(times, current.get("time"))
end = min(start + hours_ahead, len(times))
hourly_slice = []
for i in range(start, end):
code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None
temp_series = _hourly_series(hourly, "temperature_2m")
precip_series = _hourly_series(hourly, "precipitation")
prob_series = _hourly_series(hourly, "precipitation_probability")
hourly_slice.append({
"time": times[i],
"temperature_c": temp_series[i] if i < len(temp_series) else None,
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
"weather_code": code,
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
})
code = current.get("weather_code") code = current.get("weather_code")
coverage = _field_coverage(raw) coverage = _field_coverage(raw)
local_coverage = _cache.get("local_coverage") or coverage
sync_hint = ""
if _local_needs_sync_hint(local_coverage):
sync_hint = SYNC_HINT
elif _missing_precip_probability(local_coverage):
sync_hint = PRECIP_PROB_HINT
return { return {
"ok": True, "ok": True,
"location": self.location_name, "location": self.location_name,
"data_source": _cache.get("source") or "local", "data_source": _cache.get("source") or "local",
"local_field_coverage": _cache.get("local_coverage") or coverage, "merged_fields": list(_cache.get("merged_fields") or []),
"local_field_coverage": local_coverage,
"field_coverage": coverage, "field_coverage": coverage,
"sync_hint": SYNC_HINT if not _coverage_sufficient(_cache.get("local_coverage") or coverage) else "", "sync_hint": sync_hint,
"current": { "current": {
"time": current.get("time"), "time": current.get("time"),
"temperature_c": current.get("temperature_2m"), "temperature_c": current.get("temperature_2m"),
@@ -239,13 +369,17 @@ class OpenMeteoClient:
"precipitation_mm": current.get("precipitation"), "precipitation_mm": current.get("precipitation"),
"wind_speed_kmh": current.get("wind_speed_10m"), "wind_speed_kmh": current.get("wind_speed_10m"),
"weather_code": code, "weather_code": code,
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно", "conditions": _conditions(code),
}, },
"hourly": hourly_slice, "hourly": self._build_hourly_slice(raw, hours_ahead),
"daily": self._build_daily_slice(raw, days_ahead),
} }
def rain_summary(self, hours_ahead: int = 12) -> str: def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
data = self.fetch_current_and_hourly(hours_ahead=hours_ahead) return self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=min(7, self.forecast_days))
def rain_summary(self, hours_ahead: int = 12, daily: list[dict[str, Any]] | None = None) -> str:
data = self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=2)
if not data.get("ok"): if not data.get("ok"):
return f"Погода недоступна: {data.get('error', 'ошибка')}" return f"Погода недоступна: {data.get('error', 'ошибка')}"
@@ -255,16 +389,49 @@ class OpenMeteoClient:
precip = hour.get("precipitation_mm") or 0 precip = hour.get("precipitation_mm") or 0
if (prob is not None and prob >= 40) or precip > 0: if (prob is not None and prob >= 40) or precip > 0:
time_str = (hour.get("time") or "")[11:16] time_str = (hour.get("time") or "")[11:16]
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)") prob_text = f"{prob}%" if prob is not None else ""
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
lines: list[str] = []
if rainy_hours: if rainy_hours:
return "Ожидаются осадки: " + ", ".join(rainy_hours[:6]) lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
return "Существенных осадков в ближайшие часы не ожидается." else:
lines.append("Существенных осадков в ближайшие часы не ожидается.")
days = daily if daily is not None else data.get("daily") or []
if len(days) > 1:
tomorrow = days[1]
tmax = tomorrow.get("temperature_max_c")
tmin = tomorrow.get("temperature_min_c")
prob = tomorrow.get("precipitation_probability_max")
precip = tomorrow.get("precipitation_sum_mm") or 0
cond = tomorrow.get("conditions") or "неизвестно"
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
precip_part = f", {precip} мм" if precip > 0 else ""
lines.append(
f"Завтра: {_fmt_num(tmin)}{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}{precip_part}."
)
return " ".join(lines)
def daily_summary(self, days_ahead: int = 7) -> str:
data = self.fetch_forecast(hours_ahead=1, days_ahead=days_ahead)
if not data.get("ok"):
return ""
parts = []
for day in data.get("daily") or []:
label = day.get("label") or day.get("date")
tmax = day.get("temperature_max_c")
tmin = day.get("temperature_min_c")
cond = day.get("conditions") or "неизвестно"
prob = day.get("precipitation_probability_max")
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
parts.append(f"{label}: {_fmt_num(tmin)}{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}")
return "; ".join(parts)
def format_weather_snapshot(data: dict[str, Any] | None = None) -> str: def format_weather_snapshot(data: dict[str, Any] | None = None, *, include_daily: bool = True) -> str:
client = OpenMeteoClient() client = OpenMeteoClient()
snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6) snapshot = data if data is not None else client.fetch_forecast(hours_ahead=6, days_ahead=3)
lines = ["[Погода]"] lines = ["[Погода]"]
if not snapshot.get("ok"): if not snapshot.get("ok"):
@@ -281,30 +448,50 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}" f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}"
f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}." f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}."
) )
hourly = snapshot.get("hourly") or []
rainy_hours = [] rainy_hours = []
for hour in hourly: for hour in snapshot.get("hourly") or []:
prob = hour.get("precipitation_probability") prob = hour.get("precipitation_probability")
precip = hour.get("precipitation_mm") or 0 precip = hour.get("precipitation_mm") or 0
if (prob is not None and prob >= 40) or precip > 0: if (prob is not None and prob >= 40) or precip > 0:
time_str = (hour.get("time") or "")[11:16] time_str = (hour.get("time") or "")[11:16]
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)") prob_text = f"{prob}%" if prob is not None else ""
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
if rainy_hours: if rainy_hours:
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6])) lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
else: else:
lines.append("Существенных осадков в ближайшие часы не ожидается.") lines.append("Существенных осадков в ближайшие часы не ожидается.")
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
if include_daily:
days = snapshot.get("daily") or []
if len(days) > 1:
tomorrow = days[1]
lines.append(
f"Завтра: {_fmt_num(tomorrow.get('temperature_min_c'))}"
f"{_fmt_num(tomorrow.get('temperature_max_c'), suffix='°C')}, "
f"{tomorrow.get('conditions') or 'неизвестно'}."
)
if len(days) > 2:
week_bits = []
for day in days[2:7]:
week_bits.append(
f"{day.get('label')}: {_fmt_num(day.get('temperature_min_c'))}"
f"{_fmt_num(day.get('temperature_max_c'), suffix='°C')}"
)
if week_bits:
lines.append("Далее: " + "; ".join(week_bits) + ".")
lines.append("Подробнее — get_weather (hours_ahead, days_ahead).")
return "\n".join(lines) return "\n".join(lines)
def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]: def build_weather_dashboard(hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
"""Полный снимок для UI: данные OpenMeteo + контекст ассистента."""
client = OpenMeteoClient() client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead) weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
settings = get_settings()
return { return {
"weather": weather, "weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "", "rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
"assistant_context": format_weather_snapshot(weather), "assistant_context": format_weather_snapshot(weather),
"cache": client.cache_status(), "cache": client.cache_status(),
"config": { "config": {
@@ -313,24 +500,26 @@ def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]:
"longitude": client.lon, "longitude": client.lon,
"openmeteo_base_url": client.base_url, "openmeteo_base_url": client.base_url,
"cache_ttl_sec": client.cache_ttl, "cache_ttl_sec": client.cache_ttl,
"forecast_days": 2, "forecast_days": client.forecast_days,
"timezone": "auto", "timezone": "auto",
}, },
"available_fields": { "available_fields": {
"current": list(CURRENT_FIELDS), "current": list(CURRENT_FIELDS),
"hourly": list(HOURLY_FIELDS), "hourly": list(HOURLY_FIELDS),
"daily": list(DAILY_FIELDS),
}, },
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, "field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, "local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local", "data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
"merged_fields": weather.get("merged_fields", []) if weather.get("ok") else [],
"sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT, "sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT,
"recommended_sync": { "recommended_sync": {
"domains": RECOMMENDED_SYNC_DOMAINS, "domains": RECOMMENDED_SYNC_DOMAINS,
"variables": RECOMMENDED_SYNC_VARIABLES, "variables": RECOMMENDED_SYNC_VARIABLES,
}, },
"assistant_tools": { "assistant_tools": {
"get_weather": "Текущая погода и почасовой прогнос (hours_ahead до 48)", "get_weather": "Сейчас + почасово (hours_ahead до 168) + по дням (days_ahead до 16)",
"get_morning_briefing": "Погода + заголовки RSS-новостей", "get_morning_briefing": "Погода + заголовки RSS-новостей",
}, },
"system_prompt": "Краткий блок [Погода] в system prompt каждого сообщения (6 ч почасово).", "system_prompt": "Блок [Погода] в system prompt — только если запрос про погоду/одежду/прогноз.",
} }
+51
View File
@@ -34,6 +34,16 @@ class LLMClient:
finally: finally:
db.close() db.close()
def _vision_model_runtime(self) -> str:
from app.db.base import SessionLocal
from app.settings.service import SettingsService
db = SessionLocal()
try:
return str(SettingsService(db).get_effective("openrouter_vision_model"))
finally:
db.close()
@property @property
def model(self) -> str: def model(self) -> str:
return self._runtime()[0] return self._runtime()[0]
@@ -46,6 +56,10 @@ class LLMClient:
def reasoning_effort(self) -> str: def reasoning_effort(self) -> str:
return self._runtime()[2] return self._runtime()[2]
@property
def vision_model(self) -> str:
return self._vision_model_runtime()
def _reasoning_extra_body(self) -> dict[str, Any] | None: def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort: if not self.reasoning_effort:
return None return None
@@ -272,6 +286,43 @@ class LLMClient:
return result return result
async def complete_vision(
self,
messages: list[dict[str, Any]],
*,
temperature: float = 0.1,
model: str | None = None,
) -> dict[str, Any]:
use_model = model or self.vision_model
kwargs: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": temperature,
"extra_body": {"reasoning": {"effort": "none", "exclude": True}},
}
response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
usage_dict: dict[str, Any] = {}
if usage is not None:
usage_dict = {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
}
logger.info(
"LLM vision usage: prompt=%s completion=%s total=%s model=%s",
usage_dict.get("prompt_tokens"),
usage_dict.get("completion_tokens"),
usage_dict.get("total_tokens"),
use_model,
)
message = response.choices[0].message
return {
"content": message.content or "",
"model": use_model,
"usage": usage_dict,
}
@staticmethod @staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]: def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments: if not arguments:
+5 -1
View File
@@ -7,12 +7,13 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings, resolve_vision_model
from app.db.models import AssistantState from app.db.models import AssistantState
SETTING_KEYS = ( SETTING_KEYS = (
"openrouter_model", "openrouter_model",
"memory_extract_model", "memory_extract_model",
"openrouter_vision_model",
"openrouter_reasoning_effort", "openrouter_reasoning_effort",
"rag_enabled", "rag_enabled",
"rag_top_k", "rag_top_k",
@@ -48,6 +49,7 @@ class SettingsService:
mapping = { mapping = {
"openrouter_model": defaults.openrouter_model, "openrouter_model": defaults.openrouter_model,
"memory_extract_model": defaults.memory_extract_model or defaults.openrouter_model, "memory_extract_model": defaults.memory_extract_model or defaults.openrouter_model,
"openrouter_vision_model": defaults.openrouter_vision_model,
"openrouter_reasoning_effort": defaults.openrouter_reasoning_effort, "openrouter_reasoning_effort": defaults.openrouter_reasoning_effort,
"rag_enabled": defaults.rag_enabled, "rag_enabled": defaults.rag_enabled,
"rag_top_k": defaults.rag_top_k, "rag_top_k": defaults.rag_top_k,
@@ -65,6 +67,8 @@ class SettingsService:
return max(1, min(50, int(raw))) return max(1, min(50, int(raw)))
except ValueError: except ValueError:
return self._default_for(key) return self._default_for(key)
if key == "openrouter_vision_model":
return resolve_vision_model(raw.strip())
return raw return raw
def snapshot(self) -> dict[str, Any]: def snapshot(self) -> dict[str, Any]:
+28 -17
View File
@@ -336,7 +336,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "set_fitness_profile", "name": "set_fitness_profile",
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.", "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (TDEE = BMR + NEAT).",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -344,13 +344,12 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"age": {"type": "integer"}, "age": {"type": "integer"},
"height_cm": {"type": "number"}, "height_cm": {"type": "number"},
"weight_kg": {"type": "number"}, "weight_kg": {"type": "number"},
"activity_level": {
"type": "string",
"description": "sedentary/light/moderate/active/very_active",
},
"goal": {"type": "string", "description": "lose/maintain/gain"}, "goal": {"type": "string", "description": "lose/maintain/gain"},
"target_weight_kg": {"type": "number"}, "target_weight_kg": {"type": "number"},
"weekly_workouts": {"type": "integer"}, "neat_base_kcal": {
"type": "number",
"description": "NEAT-база 200–300 ккал, по умолчанию 200",
},
}, },
"required": [], "required": [],
}, },
@@ -360,7 +359,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "calc_fitness_targets", "name": "calc_fitness_targets",
"description": "Калькулятор BMR/TDEE/макросов без сохранения.", "description": "Калькулятор BMR/TDEE/макросов без сохранения (rest-day: BMR + NEAT).",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -368,8 +367,9 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"age": {"type": "integer"}, "age": {"type": "integer"},
"height_cm": {"type": "number"}, "height_cm": {"type": "number"},
"weight_kg": {"type": "number"}, "weight_kg": {"type": "number"},
"activity_level": {"type": "string"},
"goal": {"type": "string"}, "goal": {"type": "string"},
"neat_base_kcal": {"type": "number"},
"steps": {"type": "integer", "description": "Шаги за день для расчёта TDEE"},
}, },
"required": ["weight_kg", "height_cm", "age"], "required": ["weight_kg", "height_cm", "age"],
}, },
@@ -539,15 +539,19 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"function": { "function": {
"name": "get_weather", "name": "get_weather",
"description": ( "description": (
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». " "ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь», «завтра», «на неделю». "
"Текущая погода и прогноз по часам." "Текущая погода, почасовой и дневной прогноз."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"hours_ahead": { "hours_ahead": {
"type": "integer", "type": "integer",
"description": "Сколько часов прогноза (по умолчанию 12)", "description": "Сколько часов почасового прогноза (по умолчанию 12, до 168)",
},
"days_ahead": {
"type": "integer",
"description": "Сколько дней дневного прогноза (по умолчанию 7, до 16)",
}, },
}, },
"required": [], "required": [],
@@ -917,14 +921,17 @@ async def execute_tool(
updates = { updates = {
k: arguments[k] k: arguments[k]
for k in ( for k in (
"sex", "age", "height_cm", "weight_kg", "activity_level", "sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "weekly_workouts", "goal", "target_weight_kg", "neat_base_kcal",
) )
if k in arguments and arguments[k] is not None if k in arguments and arguments[k] is not None
} }
result = fitness.set_profile(updates) result = fitness.set_profile(updates)
elif name == "calc_fitness_targets": elif name == "calc_fitness_targets":
result = fitness.calc_targets(arguments) from app.fitness.calculators import compute_daily_targets
steps = int(arguments.get("steps") or 0)
result = compute_daily_targets(arguments, steps_total=steps, workouts=[])
elif name == "calc_body_composition": elif name == "calc_body_composition":
result = fitness.calc_body_composition(arguments) result = fitness.calc_body_composition(arguments)
elif name == "log_meal": elif name == "log_meal":
@@ -980,6 +987,8 @@ async def execute_tool(
active_calories=structured.get("active_calories"), active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"), total_calories=structured.get("total_calories"),
steps=structured.get("steps"), steps=structured.get("steps"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
day=day, day=day,
days_ago=arguments.get("days_ago"), days_ago=arguments.get("days_ago"),
) )
@@ -1002,12 +1011,14 @@ async def execute_tool(
interval_hours=arguments.get("interval_hours"), interval_hours=arguments.get("interval_hours"),
) )
elif name == "get_weather": elif name == "get_weather":
hours = int(arguments.get("hours_ahead") or 12) hours = max(1, min(int(arguments.get("hours_ahead") or 12), 168))
days = max(1, min(int(arguments.get("days_ahead") or 7), 16))
client = OpenMeteoClient() client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours) weather = client.fetch_forecast(hours_ahead=hours, days_ahead=days)
result = { result = {
"weather": weather, "weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "", "rain_summary": client.rain_summary(hours_ahead=hours, daily=weather.get("daily")) if weather.get("ok") else "",
"daily_summary": client.daily_summary(days_ahead=days) if weather.get("ok") else "",
} }
elif name == "get_morning_briefing": elif name == "get_morning_briefing":
include_news = arguments.get("include_news", True) include_news = arguments.get("include_news", True)
+10
View File
@@ -0,0 +1,10 @@
from app.vision.analyze import VisionResult, VisionService, format_user_message, format_user_messages, vision_debug_payload, vision_debug_payloads
__all__ = [
"VisionResult",
"VisionService",
"format_user_message",
"format_user_messages",
"vision_debug_payload",
"vision_debug_payloads",
]
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from openai import APIStatusError
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
from app.vision.preprocess import PreparedImage, prepare_image
from app.vision.prompts import VISION_SYSTEM_PROMPT
logger = logging.getLogger(__name__)
class VisionUnavailableError(Exception):
"""Vision LLM endpoint missing or unreachable on OpenRouter."""
def __init__(self, model: str, detail: str) -> None:
self.model = model
super().__init__(detail)
@dataclass
class VisionResult:
parsed: dict[str, Any] = field(default_factory=dict)
raw_content: str = ""
model: str = ""
usage: dict[str, Any] = field(default_factory=dict)
image_meta: dict[str, Any] = field(default_factory=dict)
parse_error: str | None = None
class VisionService:
def __init__(self) -> None:
self.llm = LLMClient()
async def analyze(self, image_bytes: bytes, *, user_hint: str = "") -> VisionResult:
prepared = prepare_image(image_bytes)
return await self.analyze_prepared(prepared, user_hint=user_hint)
async def analyze_prepared(self, prepared: PreparedImage, *, user_hint: str = "") -> VisionResult:
b64 = base64.standard_b64encode(prepared.jpeg_bytes).decode("ascii")
hint = f"\n\nПодсказка пользователя: {user_hint.strip()}" if user_hint.strip() else ""
messages: list[dict[str, Any]] = [
{"role": "system", "content": VISION_SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": f"Извлеки данные со скриншота.{hint}"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
},
],
},
]
model = self.llm.vision_model
try:
response = await self.llm.complete_vision(messages)
except APIStatusError as exc:
if exc.status_code == 404:
raise VisionUnavailableError(
model,
f"Vision-модель «{model}» недоступна на OpenRouter. "
"Укажите другую в Settings (например google/gemini-2.5-flash-lite).",
) from exc
raise
raw = (response.get("content") or "").strip()
parsed: dict[str, Any] = {}
parse_error: str | None = None
try:
parsed = json.loads(strip_markdown_json(raw))
if not isinstance(parsed, dict):
parse_error = "Vision response is not a JSON object"
parsed = {}
except json.JSONDecodeError as exc:
parse_error = str(exc)
parsed = {"description": raw[:2000], "document_type": "other", "raw_fallback": True}
return VisionResult(
parsed=parsed,
raw_content=raw,
model=str(response.get("model") or self.llm.vision_model),
usage=dict(response.get("usage") or {}),
image_meta=prepared.to_meta(),
parse_error=parse_error,
)
def _format_screenshot_block(
result: VisionResult,
*,
index: int | None = None,
total: int | None = None,
) -> str:
parsed = result.parsed or {}
doc_type = parsed.get("document_type") or "other"
confidence = parsed.get("confidence") or "unknown"
if index is not None and total is not None and total > 1:
header = f"[Скриншот {index}/{total}: {doc_type}, confidence={confidence}]"
else:
header = f"[Скриншот: {doc_type}, confidence={confidence}]"
lines = [header]
if parsed.get("description"):
lines.append(f"Описание: {parsed['description']}")
extracted = parsed.get("extracted_text") or []
if extracted:
lines.append("Текст с экрана:")
lines.extend(f"- {line}" for line in extracted if str(line).strip())
tables = parsed.get("tables") or []
if tables:
lines.append("Таблицы:")
for table in tables:
title = table.get("title") if isinstance(table, dict) else None
if title:
lines.append(f" [{title}]")
rows = table.get("rows") if isinstance(table, dict) else None
if isinstance(rows, list):
for row in rows:
if isinstance(row, list):
lines.append(" | " + " | ".join(str(cell) for cell in row))
hints = parsed.get("fitness_hints")
if hints:
lines.append(f"Подсказки для фитнеса: {json.dumps(hints, ensure_ascii=False)}")
if result.parse_error:
lines.append(f"(parse_error: {result.parse_error})")
return "\n".join(lines)
def format_user_message(caption: str, result: VisionResult) -> str:
return format_user_messages(caption, [result])
def format_user_messages(caption: str, results: list[VisionResult]) -> str:
if not results:
return caption.strip()
total = len(results)
blocks = [
_format_screenshot_block(result, index=index, total=total)
for index, result in enumerate(results, start=1)
]
text = "\n\n".join(blocks)
if caption.strip():
text = f"{text}\n\nПодпись: {caption.strip()}"
return text
VISION_TURN_HINT = (
"[Скриншоты в этом сообщении]: vision уже извлекла данные с каждой картинки в блоки [Скриншот] ниже. "
"Отвечай по Описанию и извлечённому тексту как по увиденному. "
"Не утверждай, что не видишь изображения, и не предлагай настроить vision API."
)
def format_vision_turn_hint(user_text: str) -> str:
if "[Скриншот" not in (user_text or ""):
return ""
return VISION_TURN_HINT
def vision_debug_payload(result: VisionResult) -> dict[str, Any]:
from app.config import get_settings
payload: dict[str, Any] = {
"model": result.model,
"parsed": result.parsed,
"image_meta": result.image_meta,
"usage": result.usage,
"parse_error": result.parse_error,
}
if get_settings().vision_debug_enabled:
payload["raw_content"] = result.raw_content
return payload
def vision_debug_payloads(results: list[VisionResult]) -> dict[str, Any] | None:
if not results:
return None
items = [vision_debug_payload(result) for result in results]
if len(items) == 1:
return items[0]
models = {str(item.get("model") or "") for item in items}
payload: dict[str, Any] = {
"count": len(items),
"images": items,
"model": next(iter(models)) if len(models) == 1 else sorted(models),
}
return payload
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import io
from dataclasses import dataclass
from PIL import Image, ImageOps
from app.config import get_settings
@dataclass
class PreparedImage:
jpeg_bytes: bytes
width: int
height: int
original_bytes: int
compressed_bytes: int
mime: str = "image/jpeg"
def to_meta(self) -> dict[str, int | str]:
return {
"mime": self.mime,
"width": self.width,
"height": self.height,
"original_bytes": self.original_bytes,
"compressed_bytes": self.compressed_bytes,
}
def prepare_image(raw_bytes: bytes) -> PreparedImage:
settings = get_settings()
max_edge = max(256, int(settings.vision_max_edge_px))
quality = max(40, min(95, int(settings.vision_jpeg_quality)))
with Image.open(io.BytesIO(raw_bytes)) as img:
img = ImageOps.exif_transpose(img)
img = img.convert("RGB")
width, height = img.size
if max(width, height) > max_edge:
img.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
width, height = img.size
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=quality, optimize=True)
jpeg_bytes = buffer.getvalue()
return PreparedImage(
jpeg_bytes=jpeg_bytes,
width=width,
height=height,
original_bytes=len(raw_bytes),
compressed_bytes=len(jpeg_bytes),
)
+30
View File
@@ -0,0 +1,30 @@
VISION_SYSTEM_PROMPT = """
Ты OCR-ассистент для скриншотов приложений здоровья и фитнеса (Mi Fitness, Xiaomi, Zepp Life и аналоги).
Извлеки ВСЕ видимые тексты, числа и таблицы. Приоритет измеримые данные: длительность, калории, пульс, шаги, дистанция, дата, название активности.
Ответ ТОЛЬКО JSON без markdown и комментариев.
Схема:
{
"description": "краткое описание экрана",
"document_type": "fitness_workout|fitness_steps|fitness_summary|other",
"extracted_text": ["строка1"],
"tables": [{"title": "заголовок или null", "rows": [["ячейка1", "ячейка2"]]}],
"fitness_hints": {
"title": null,
"activity_type": null,
"duration_min": null,
"active_calories": null,
"total_calories": null,
"steps": null,
"avg_heart_rate": null,
"date": null
},
"confidence": "high|medium|low",
"notes": ""
}
Правила:
- extracted_text все значимые строки с экрана по порядку сверху вниз.
- tables любые табличные блоки (заголовок + строки).
- fitness_hints только если данные явно видны; иначе null.
- duration_min целые минуты; steps целое число; калории и пульс числа.
- confidence=low если текст размыт или часть обрезана.
""".strip()
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from pathlib import Path
from app.config import get_settings
from app.vision.preprocess import PreparedImage
def save_upload(prepared: PreparedImage, *, user_id: int) -> str:
settings = get_settings()
user_dir = Path(settings.uploads_dir) / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}.jpg"
path = user_dir / name
path.write_bytes(prepared.jpeg_bytes)
return name
def upload_media_path(user_id: int, filename: str) -> str:
return f"/api/v1/media/uploads/{user_id}/{filename}"
def format_upload_images_markdown(user_id: int, filenames: list[str]) -> str:
if not filenames:
return ""
total = len(filenames)
lines: list[str] = []
for index, name in enumerate(filenames, start=1):
alt = f"скриншот {index}/{total}" if total > 1 else "скриншот"
lines.append(f"![{alt}]({upload_media_path(user_id, name)})")
return "\n".join(lines)
+7 -1
View File
@@ -22,7 +22,8 @@
- В ответах пользователю не используй эмодзи - В ответах пользователю не используй эмодзи
Погода и дайджест: Погода и дайджест:
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода] - Вопросы о погоде, дожде, «что на улице», «завтра», «на неделю» — get_weather (hours_ahead, days_ahead)
- Блок [Погода] в контексте появляется только при релевантном запросе
- Утренний брифинг — get_morning_briefing - Утренний брифинг — get_morning_briefing
Списки покупок: Списки покупок:
@@ -33,3 +34,8 @@
- «Нарисуй себя» → generate_image draw_self=true; «в полный рост» → scene_description="full_body, standing" - «Нарисуй себя» → generate_image draw_self=true; «в полный рост» → scene_description="full_body, standing"
- Другая сцена → scene_description (booru-теги или короткий запрос); draw_self=true если персонаж из карточки - Другая сцена → scene_description (booru-теги или короткий запрос); draw_self=true если персонаж из карточки
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги - Внешность персонажа задаётся в настройках карточки, не выдумывай теги
Скриншоты:
- Пользователь может прикрепить фото; vision-модель уже разобрала его до твоего ответа
- Результат — блок [Скриншот: ...] в сообщении: Описание, текст с экрана, fitness_hints
- Отвечай по этому блоку как по увиденному; не говори, что не видишь картинку, и не предлагай настроить Gemini/OpenRouter
+1
View File
@@ -8,4 +8,5 @@ python-dotenv>=1.0.1
aiosqlite>=0.20.0 aiosqlite>=0.20.0
httpx>=0.28.0 httpx>=0.28.0
feedparser>=6.0.11 feedparser>=6.0.11
Pillow>=11.0.0
qdrant-client>=1.12.0,<1.13.0 qdrant-client>=1.12.0,<1.13.0
-84
View File
@@ -1,84 +0,0 @@
import unittest
from app.fitness.activity_budget import (
compute_activity_bonus,
scale_targets,
steps_bonus_kcal,
)
PROFILE = {
"weight_kg": 70,
"activity_level": "moderate",
"weekly_workouts": 3,
"calorie_target": 2000,
"protein_g": 126,
"fat_g": 56,
"carbs_g": 250,
"water_l": 2.5,
}
class ActivityBudgetTests(unittest.TestCase):
def test_no_bonus_at_baseline(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=9000,
workouts=[{"active_calories": 85}],
)
self.assertEqual(bonus.steps_bonus_kcal, 0.0)
self.assertEqual(bonus.workout_bonus_kcal, 0.0)
self.assertEqual(bonus.total_bonus_kcal, 0.0)
self.assertEqual(bonus.scale_factor, 1.0)
def test_steps_and_workout_bonus(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=21800,
workouts=[{"active_calories": 155}],
)
self.assertGreater(bonus.steps_bonus_kcal, 0)
self.assertGreater(bonus.workout_bonus_kcal, 0)
self.assertEqual(
bonus.total_bonus_kcal,
round(bonus.steps_bonus_kcal + bonus.workout_bonus_kcal, 1),
)
self.assertGreater(bonus.scale_factor, 1.0)
def test_steps_bonus_formula(self) -> None:
kcal = steps_bonus_kcal(steps=21800, baseline_steps=9000, weight_kg=70)
self.assertEqual(kcal, round(12800 * 70 * 0.0005, 1))
def test_proportional_macro_scale(self) -> None:
base = {
"calories": 2000,
"protein_g": 100,
"fat_g": 50,
"carbs_g": 200,
"water_ml": 2500,
}
effective, targets_base = scale_targets(base, 500)
self.assertEqual(effective["calories"], 2500)
self.assertEqual(targets_base, base)
self.assertEqual(effective["water_ml"], 2500)
ratio = effective["calories"] / base["calories"]
self.assertAlmostEqual(effective["protein_g"] / base["protein_g"], ratio, places=1)
self.assertAlmostEqual(effective["fat_g"] / base["fat_g"], ratio, places=1)
self.assertAlmostEqual(effective["carbs_g"] / base["carbs_g"], ratio, places=1)
def test_floor_at_base_when_no_activity(self) -> None:
effective, _ = scale_targets(
{
"calories": 2045,
"protein_g": 156,
"fat_g": 57,
"carbs_g": 227,
"water_ml": 2900,
},
0,
)
self.assertEqual(effective["calories"], 2045)
if __name__ == "__main__":
unittest.main()
+58 -14
View File
@@ -1,13 +1,17 @@
from unittest.mock import patch from unittest.mock import patch
from app.homelab.openmeteo import ( from app.homelab.openmeteo import (
PRECIP_PROB_HINT,
RECOMMENDED_SYNC_DOMAINS, RECOMMENDED_SYNC_DOMAINS,
RECOMMENDED_SYNC_VARIABLES, RECOMMENDED_SYNC_VARIABLES,
SYNC_HINT, SYNC_HINT,
_coverage_sufficient, _coverage_sufficient,
_field_coverage, _field_coverage,
_hourly_start_index, _hourly_start_index,
_local_needs_sync_hint,
build_weather_dashboard, build_weather_dashboard,
format_weather_snapshot,
weather_query_relevant,
) )
@@ -21,49 +25,89 @@ def test_coverage_sufficient():
assert _coverage_sufficient( assert _coverage_sufficient(
{ {
"current": ["temperature_2m", "weather_code", "wind_speed_10m"], "current": ["temperature_2m", "weather_code", "wind_speed_10m"],
"hourly": ["temperature_2m", "precipitation_probability", "weather_code"], "hourly": ["temperature_2m", "weather_code"],
} }
) is True ) is True
def test_field_coverage_partial(): def test_field_coverage_partial():
raw = { raw = {
"current": {"time": "2026-06-14T18:15", "temperature_2m": 20.6}, "current": {"time": "2026-06-14T18:15", "temperature_2m": 20.6, "weather_code": 2},
"hourly": { "hourly": {
"time": ["2026-06-14T18:00", "2026-06-14T19:00"], "time": ["2026-06-14T18:00", "2026-06-14T19:00"],
"temperature_2m": [20.0, 19.5], "temperature_2m": [20.0, 19.5],
"precipitation": [0.0, 0.0], "precipitation": [0.0, 0.0],
"weather_code": [2, 3],
},
"daily": {
"time": ["2026-06-14", "2026-06-15"],
"temperature_2m_max": [21.0, 18.0],
"temperature_2m_min": [12.0, 10.0],
"weather_code": [2, 3],
}, },
} }
coverage = _field_coverage(raw) coverage = _field_coverage(raw)
assert coverage["current"] == ["temperature_2m"] assert "temperature_2m" in coverage["current"]
assert "temperature_2m" in coverage["hourly"] assert "weather_code" in coverage["hourly"]
assert "precipitation" in coverage["hourly"] assert "temperature_2m_max" in coverage["daily"]
assert "weather_code" not in coverage["hourly"]
def test_build_weather_dashboard_includes_sync_hint(): def test_local_needs_sync_hint():
assert _local_needs_sync_hint({"current": ["temperature_2m"], "hourly": ["temperature_2m"]}) is True
assert _local_needs_sync_hint(
{"current": ["temperature_2m", "weather_code"], "hourly": ["temperature_2m", "weather_code"]}
) is False
def test_weather_query_relevant():
assert weather_query_relevant("какая погода завтра")
assert not weather_query_relevant("напиши код на python")
def test_format_weather_snapshot_includes_tomorrow():
snap = {
"ok": True,
"location": "СПб",
"current": {"temperature_c": 20, "conditions": "ясно"},
"hourly": [],
"daily": [
{"label": "Сегодня", "temperature_min_c": 10, "temperature_max_c": 20, "conditions": "ясно"},
{"label": "Завтра", "temperature_min_c": 12, "temperature_max_c": 18, "conditions": "дождь"},
],
}
text = format_weather_snapshot(snap)
assert "Завтра:" in text
assert "None" not in text
def test_build_weather_dashboard_includes_daily():
fake_weather = { fake_weather = {
"ok": True, "ok": True,
"location": "Test", "location": "Test",
"data_source": "local", "data_source": "local",
"local_field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]}, "local_field_coverage": {"current": ["temperature_2m", "weather_code"], "hourly": ["temperature_2m"], "daily": []},
"field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]}, "field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"], "daily": []},
"sync_hint": SYNC_HINT, "sync_hint": PRECIP_PROB_HINT,
"current": {"temperature_c": 10, "conditions": "неизвестно"}, "merged_fields": [],
"current": {"temperature_c": 10, "conditions": "ясно"},
"hourly": [], "hourly": [],
"daily": [{"label": "Завтра", "temperature_min_c": 5, "temperature_max_c": 12, "conditions": "дождь"}],
} }
with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls: with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls:
client = mock_cls.return_value client = mock_cls.return_value
client.fetch_current_and_hourly.return_value = fake_weather client.fetch_forecast.return_value = fake_weather
client.rain_summary.return_value = "ok" client.rain_summary.return_value = "ok"
client.daily_summary.return_value = "Завтра: 512°C"
client.cache_status.return_value = {"source": "local", "has_data": True, "cached": True, "ttl_sec": 300} client.cache_status.return_value = {"source": "local", "has_data": True, "cached": True, "ttl_sec": 300}
client.location_name = "Test" client.location_name = "Test"
client.lat = 1.0 client.lat = 1.0
client.lon = 2.0 client.lon = 2.0
client.base_url = "http://local" client.base_url = "http://local"
client.cache_ttl = 300 client.cache_ttl = 300
result = build_weather_dashboard() client.forecast_days = 7
assert result["sync_hint"] result = build_weather_dashboard(days_ahead=7)
assert result["daily_summary"] == "Завтра: 512°C"
assert result["recommended_sync"]["domains"] == RECOMMENDED_SYNC_DOMAINS assert result["recommended_sync"]["domains"] == RECOMMENDED_SYNC_DOMAINS
assert result["recommended_sync"]["variables"] == RECOMMENDED_SYNC_VARIABLES assert result["recommended_sync"]["variables"] == RECOMMENDED_SYNC_VARIABLES
assert SYNC_HINT # constant exists
+116
View File
@@ -0,0 +1,116 @@
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.db.base import Base
from app.db.models import FitnessProfile, User
from app.fitness.calculators import compute_targets
class TdeeBackfillTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
with self.engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
"name TEXT PRIMARY KEY, "
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
def _insert_legacy_profile(self) -> None:
Session = sessionmaker(bind=self.engine)
with Session() as db:
user = User(username="tester", api_token_hash="x")
db.add(user)
db.flush()
db.add(
FitnessProfile(
user_id=user.id,
sex="male",
age=30,
height_cm=180,
weight_kg=86,
goal="maintain",
activity_level="active",
calorie_target=3200,
protein_g=155,
fat_g=89,
carbs_g=400,
water_l=3.0,
neat_base_kcal=None,
)
)
db.commit()
def test_backfill_recalculates_stored_targets(self) -> None:
from app.db import migrate_fitness
self._insert_legacy_profile()
with patch.object(migrate_fitness, "engine", self.engine):
updated = migrate_fitness.backfill_tdee_targets(force=True)
self.assertEqual(updated, 1)
Session = sessionmaker(bind=self.engine)
with Session() as db:
row = db.query(FitnessProfile).one()
expected = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": 200,
}
)
self.assertEqual(row.neat_base_kcal, 200.0)
self.assertEqual(row.calorie_target, expected["calorie_target"])
self.assertEqual(row.protein_g, expected["protein_g"])
self.assertLess(row.calorie_target, 3200)
def test_backfill_runs_once(self) -> None:
from app.db import migrate_fitness
self._insert_legacy_profile()
with patch.object(migrate_fitness, "engine", self.engine):
first = migrate_fitness.backfill_tdee_targets(force=True)
second = migrate_fitness.backfill_tdee_targets()
self.assertEqual(first, 1)
self.assertEqual(second, 0)
def test_macros_backfill_updates_bju_only(self) -> None:
from app.db import migrate_fitness
from app.fitness.calculators import macro_targets
self._insert_legacy_profile()
with patch.object(migrate_fitness, "engine", self.engine):
migrate_fitness.backfill_tdee_targets(force=True)
Session = sessionmaker(bind=self.engine)
with Session() as db:
row = db.query(FitnessProfile).one()
calorie_before = row.calorie_target
row.protein_g = 999
db.commit()
updated = migrate_fitness.backfill_macros_gkg(force=True)
self.assertEqual(updated, 1)
with Session() as db:
row = db.query(FitnessProfile).one()
expected = macro_targets(calorie_before, row.weight_kg, row.goal)
self.assertEqual(row.calorie_target, calorie_before)
self.assertEqual(row.protein_g, expected["protein_g"])
self.assertEqual(row.fat_g, expected["fat_g"])
self.assertEqual(row.carbs_g, expected["carbs_g"])
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
import unittest
from app.fitness.activity_budget import estimate_workout_active_kcal, infer_met, workouts_kcal_total
from app.fitness.calculators import (
DEFAULT_NEAT_KCAL,
bmr_mifflin,
compute_daily_targets,
compute_targets,
compute_tdee,
macro_targets,
steps_kcal,
water_target_l,
)
PROFILE = {
"sex": "male",
"age": 30,
"height_cm": 180,
"weight_kg": 86,
"goal": "maintain",
"neat_base_kcal": 200,
}
class TdeeComponentsTests(unittest.TestCase):
def test_rest_day_tdee_is_bmr_plus_neat(self) -> None:
breakdown = compute_tdee(PROFILE, steps_total=0, workouts=[])
bmr = bmr_mifflin(sex="male", weight_kg=86, height_cm=180, age=30)
self.assertEqual(breakdown["bmr"], round(bmr, 0))
self.assertEqual(breakdown["neat_kcal"], DEFAULT_NEAT_KCAL)
self.assertEqual(breakdown["steps_kcal"], 0.0)
self.assertEqual(breakdown["workout_kcal"], 0.0)
self.assertEqual(breakdown["tdee"], round(bmr + DEFAULT_NEAT_KCAL, 0))
def test_steps_kcal_at_reference_weight(self) -> None:
kcal = steps_kcal(steps=10000, weight_kg=86)
self.assertAlmostEqual(kcal, 400.0, delta=1.0)
def test_daily_targets_include_activity(self) -> None:
daily = compute_daily_targets(
PROFILE,
steps_total=8000,
workouts=[{"active_calories": 450}],
)
self.assertGreater(daily["steps_kcal"], 0)
self.assertEqual(daily["workout_kcal"], 450.0)
self.assertEqual(
daily["tdee"],
daily["bmr"] + daily["neat_kcal"] + daily["steps_kcal"] + daily["workout_kcal"],
)
self.assertEqual(daily["calorie_target"], daily["tdee"])
def test_compute_targets_rest_day(self) -> None:
targets = compute_targets(PROFILE)
self.assertEqual(targets["steps_kcal"], 0)
self.assertEqual(targets["workout_kcal"], 0)
self.assertEqual(targets["calorie_target"], targets["tdee"])
def test_water_target(self) -> None:
self.assertEqual(water_target_l(70), 2.3)
def test_workout_active_calories_priority(self) -> None:
kcal = estimate_workout_active_kcal(
{"active_calories": 300, "duration_min": 60, "met": 9.8},
weight_kg=86,
)
self.assertEqual(kcal, 300.0)
def test_workout_met_fallback(self) -> None:
kcal = estimate_workout_active_kcal(
{"title": "бег", "duration_min": 60},
weight_kg=86,
)
self.assertAlmostEqual(kcal, 9.8 * 86, delta=1.0)
def test_workout_no_data_returns_zero(self) -> None:
self.assertEqual(estimate_workout_active_kcal({}, weight_kg=70), 0.0)
def test_infer_met_from_title(self) -> None:
self.assertEqual(infer_met({"title": "пробежал триатлон"}), 10.0)
def test_workouts_kcal_total(self) -> None:
total = workouts_kcal_total(
[
{"active_calories": 100},
{"title": "ходьба", "duration_min": 30},
],
weight_kg=86,
)
self.assertGreater(total, 100)
class MacroTargetsTests(unittest.TestCase):
def test_lose_macros_from_weight(self) -> None:
macros = macro_targets(2363, 86, "lose")
self.assertEqual(macros["protein_g"], 189)
self.assertEqual(macros["fat_g"], 86)
self.assertEqual(macros["carbs_g"], 208)
def test_maintain_macros_from_weight(self) -> None:
macros = macro_targets(2000, 86, "maintain")
self.assertEqual(macros["protein_g"], 155)
self.assertEqual(macros["fat_g"], 86)
self.assertEqual(macros["carbs_g"], 152)
def test_active_day_increases_carbs_only(self) -> None:
rest = compute_daily_targets(PROFILE, steps_total=0, workouts=[])
active = compute_daily_targets(
PROFILE,
steps_total=8000,
workouts=[{"active_calories": 450}],
)
self.assertEqual(rest["protein_g"], active["protein_g"])
self.assertEqual(rest["fat_g"], active["fat_g"])
self.assertGreater(active["calorie_target"], rest["calorie_target"])
self.assertGreater(active["carbs_g"], rest["carbs_g"])
def test_low_calorie_target_floors_carbs_at_zero(self) -> None:
macros = macro_targets(1000, 86, "lose")
self.assertEqual(macros["protein_g"], 189)
self.assertEqual(macros["fat_g"], 86)
self.assertEqual(macros["carbs_g"], 0)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import json
import unittest
from unittest.mock import AsyncMock, patch
from app.vision.analyze import VisionService, format_user_message, format_vision_turn_hint
from app.vision.preprocess import PreparedImage
class VisionAnalyzeTests(unittest.TestCase):
def test_format_user_message_with_fitness_hints(self) -> None:
from app.vision.analyze import VisionResult
result = VisionResult(
parsed={
"description": "Экран тренировки бег",
"document_type": "fitness_workout",
"extracted_text": ["45 мин", "420 ккал"],
"tables": [{"title": "Пульс", "rows": [["средний", "152"]]}],
"fitness_hints": {"duration_min": 45, "active_calories": 420},
"confidence": "high",
},
raw_content="{}",
model="test-model",
)
text = format_user_message("запиши тренировку", result)
self.assertIn("[Скриншот: fitness_workout", text)
self.assertIn("420 ккал", text)
self.assertIn("Подпись: запиши тренировку", text)
def test_run_async_analyze(self) -> None:
import asyncio
prepared = PreparedImage(
jpeg_bytes=b"fakejpeg",
width=100,
height=100,
original_bytes=200,
compressed_bytes=100,
)
payload = {
"description": "Шаги за день",
"document_type": "fitness_steps",
"extracted_text": ["8432 шага"],
"tables": [],
"fitness_hints": {"steps": 8432},
"confidence": "high",
"notes": "",
}
async def _run() -> None:
service = VisionService()
with patch.object(
service.llm,
"complete_vision",
new=AsyncMock(return_value={"content": json.dumps(payload), "model": "vision-test", "usage": {}}),
):
result = await service.analyze_prepared(prepared, user_hint="шаги")
self.assertEqual(result.parsed["document_type"], "fitness_steps")
self.assertEqual(result.parsed["fitness_hints"]["steps"], 8432)
self.assertEqual(result.model, "vision-test")
asyncio.run(_run())
def test_format_vision_turn_hint(self) -> None:
self.assertEqual(format_vision_turn_hint("привет"), "")
self.assertIn("не видишь", format_vision_turn_hint("[Скриншот: other, confidence=high]\nОписание: test"))
self.assertIn("не видишь", format_vision_turn_hint("[Скриншот 2/3: other, confidence=high]\nОписание: test"))
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
import unittest
from app.vision.analyze import VisionResult, format_user_message, format_user_messages, vision_debug_payloads
class FormatUserMessageTests(unittest.TestCase):
def test_parse_error_in_message(self) -> None:
result = VisionResult(parsed={}, raw_content="not json", parse_error="bad json")
text = format_user_message("", result)
self.assertIn("parse_error", text)
def test_multiple_screenshots_numbered(self) -> None:
results = [
VisionResult(
parsed={"document_type": "fitness_steps", "confidence": "high", "description": "Шаги"},
raw_content="{}",
),
VisionResult(
parsed={"document_type": "other", "confidence": "medium", "description": "Еда"},
raw_content="{}",
),
]
text = format_user_messages("сравни", results)
self.assertIn("[Скриншот 1/2: fitness_steps", text)
self.assertIn("[Скриншот 2/2: other", text)
self.assertIn("Подпись: сравни", text)
self.assertEqual(text.count("Подпись:"), 1)
def test_vision_debug_payloads_multi(self) -> None:
results = [
VisionResult(parsed={"document_type": "other"}, raw_content="{}", model="m1"),
VisionResult(parsed={"document_type": "other"}, raw_content="{}", model="m1"),
]
payload = vision_debug_payloads(results)
assert payload is not None
self.assertEqual(payload["count"], 2)
self.assertEqual(len(payload["images"]), 2)
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
import io
import unittest
from PIL import Image
from app.vision.preprocess import prepare_image
class VisionPreprocessTests(unittest.TestCase):
def _make_png(self, width: int, height: int) -> bytes:
buffer = io.BytesIO()
Image.new("RGB", (width, height), color=(120, 80, 200)).save(buffer, format="PNG")
return buffer.getvalue()
def test_resize_large_image(self) -> None:
raw = self._make_png(2400, 1600)
prepared = prepare_image(raw)
self.assertLessEqual(max(prepared.width, prepared.height), 1280)
self.assertLess(prepared.compressed_bytes, prepared.original_bytes)
def test_small_image_keeps_dimensions(self) -> None:
raw = self._make_png(640, 480)
prepared = prepare_image(raw)
self.assertEqual(prepared.width, 640)
self.assertEqual(prepared.height, 480)
self.assertEqual(prepared.mime, "image/jpeg")
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -0,0 +1,19 @@
import unittest
from app.vision.storage import format_upload_images_markdown, upload_media_path
class UploadMarkdownTests(unittest.TestCase):
def test_single_image(self) -> None:
md = format_upload_images_markdown(5, ["abc.jpg"])
self.assertIn(upload_media_path(5, "abc.jpg"), md)
self.assertIn("![скриншот]", md)
def test_multiple_images(self) -> None:
md = format_upload_images_markdown(3, ["a.jpg", "b.jpg"])
self.assertIn("скриншот 1/2", md)
self.assertIn("скриншот 2/2", md)
if __name__ == "__main__":
unittest.main()
+22 -4
View File
@@ -7,6 +7,11 @@ def test_build_weather_dashboard_structure():
fake_weather = { fake_weather = {
"ok": True, "ok": True,
"location": "Test City", "location": "Test City",
"data_source": "local",
"local_field_coverage": {"current": ["temperature_2m"], "hourly": [], "daily": []},
"field_coverage": {"current": ["temperature_2m"], "hourly": [], "daily": []},
"sync_hint": "",
"merged_fields": [],
"current": { "current": {
"time": "2026-06-13T12:00", "time": "2026-06-13T12:00",
"temperature_c": 18.5, "temperature_c": 18.5,
@@ -27,12 +32,22 @@ def test_build_weather_dashboard_structure():
"conditions": "переменная облачность", "conditions": "переменная облачность",
} }
], ],
"daily": [
{
"date": "2026-06-14",
"label": "Завтра",
"temperature_max_c": 20.0,
"temperature_min_c": 12.0,
"conditions": "дождь",
}
],
} }
with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls: with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls:
client = mock_cls.return_value client = mock_cls.return_value
client.fetch_current_and_hourly.return_value = fake_weather client.fetch_forecast.return_value = fake_weather
client.rain_summary.return_value = "Существенных осадков в ближайшие часы не ожидается." client.rain_summary.return_value = "Существенных осадков в ближайшие часы не ожидается."
client.daily_summary.return_value = "Завтра: 1220°C"
client.cache_status.return_value = { client.cache_status.return_value = {
"has_data": True, "has_data": True,
"cached": True, "cached": True,
@@ -40,18 +55,21 @@ def test_build_weather_dashboard_structure():
"age_sec": 10, "age_sec": 10,
"ttl_sec": 300, "ttl_sec": 300,
"expires_in_sec": 290, "expires_in_sec": 290,
"source": "local",
"merged_fields": [],
} }
client.location_name = "Test City" client.location_name = "Test City"
client.lat = 59.9 client.lat = 59.9
client.lon = 30.3 client.lon = 30.3
client.base_url = "http://openmeteo.test" client.base_url = "http://openmeteo.test"
client.cache_ttl = 300 client.cache_ttl = 300
client.forecast_days = 7
result = build_weather_dashboard(hours_ahead=6) result = build_weather_dashboard(hours_ahead=6, days_ahead=7)
assert result["weather"]["ok"] is True assert result["weather"]["ok"] is True
assert "[Погода]" in result["assistant_context"] assert "[Погода]" in result["assistant_context"]
assert "None" not in result["assistant_context"] assert "None" not in result["assistant_context"]
assert "temperature_2m" in result["available_fields"]["current"] assert "daily" in result["available_fields"]
assert "get_weather" in result["assistant_tools"] assert result["daily_summary"]
assert result["config"]["location"] == "Test City" assert result["config"]["location"] == "Test City"
+3
View File
@@ -0,0 +1,3 @@
[0615/195857.423:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Не удается найти указанный файл. (0x2)
[0615/195858.504:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Не удается найти указанный файл. (0x2)
[0615/195858.527:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Не удается найти указанный файл. (0x2)
+41
View File
@@ -0,0 +1,41 @@
# Пример для host nginx (Ubuntu) перед docker-compose.
# Ошибка «413 Request Entity Too Large» при загрузке скриншотов — нет client_max_body_size.
#
# Скопируйте фрагмент в server { } для assistant.your-domain.ru, затем:
# sudo nginx -t && sudo systemctl reload nginx
#
# Порты из .env: FRONTEND_PORT (3080), BACKEND_PORT (8202).
server {
listen 443 ssl http2;
server_name assistant.grigowashere.ru;
# До 8 скриншотов за раз (VISION_MAX_IMAGES), с запасом на JPEG до preprocess
client_max_body_size 64m;
# ssl_certificate ...;
# ssl_certificate_key ...;
location /api/ {
proxy_pass http://127.0.0.1:8202;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 64m;
}
location / {
proxy_pass http://127.0.0.1:3080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 64m;
}
}
+4
View File
@@ -4,6 +4,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Скриншоты (до VISION_MAX_IMAGES штук) иначе 413 от nginx в контейнере
client_max_body_size 128m;
location /api/ { location /api/ {
proxy_pass http://backend:8080; proxy_pass http://backend:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -16,6 +19,7 @@ server {
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
proxy_send_timeout 300s; proxy_send_timeout 300s;
proxy_read_timeout 300s; proxy_read_timeout 300s;
client_max_body_size 128m;
} }
location / { location / {
+76 -21
View File
@@ -63,6 +63,17 @@ export interface ChatStreamChunk {
data: Record<string, unknown>; data: Record<string, unknown>;
} }
export interface VisionDebugPayload {
model?: string | string[];
count?: number;
parsed?: Record<string, unknown>;
raw_content?: string;
image_meta?: Record<string, unknown>;
usage?: Record<string, unknown>;
parse_error?: string | null;
images?: VisionDebugPayload[];
}
async function* readChatSse(response: Response): AsyncGenerator<ChatStreamChunk> { async function* readChatSse(response: Response): AsyncGenerator<ChatStreamChunk> {
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
const detail = await response.text().catch(() => ""); const detail = await response.text().catch(() => "");
@@ -167,21 +178,36 @@ export interface WeatherHourly {
conditions?: string; conditions?: string;
} }
export interface WeatherDaily {
date?: string;
label?: string;
temperature_max_c?: number | null;
temperature_min_c?: number | null;
precipitation_sum_mm?: number | null;
precipitation_probability_max?: number | null;
wind_speed_max_kmh?: number | null;
weather_code?: number | null;
conditions?: string;
}
export interface WeatherSnapshot { export interface WeatherSnapshot {
ok: boolean; ok: boolean;
location?: string; location?: string;
error?: string; error?: string;
field_coverage?: { current: string[]; hourly: string[] }; field_coverage?: { current: string[]; hourly: string[]; daily: string[] };
local_field_coverage?: { current: string[]; hourly: string[] }; local_field_coverage?: { current: string[]; hourly: string[]; daily: string[] };
data_source?: string; data_source?: string;
merged_fields?: string[];
sync_hint?: string; sync_hint?: string;
current?: WeatherCurrent; current?: WeatherCurrent;
hourly?: WeatherHourly[]; hourly?: WeatherHourly[];
daily?: WeatherDaily[];
} }
export interface WeatherDashboard { export interface WeatherDashboard {
weather: WeatherSnapshot; weather: WeatherSnapshot;
rain_summary: string; rain_summary: string;
daily_summary: string;
assistant_context: string; assistant_context: string;
cache: { cache: {
has_data: boolean; has_data: boolean;
@@ -191,6 +217,7 @@ export interface WeatherDashboard {
ttl_sec: number; ttl_sec: number;
expires_in_sec: number | null; expires_in_sec: number | null;
source?: string; source?: string;
merged_fields?: string[];
}; };
config: { config: {
location: string; location: string;
@@ -204,10 +231,12 @@ export interface WeatherDashboard {
available_fields: { available_fields: {
current: string[]; current: string[];
hourly: string[]; hourly: string[];
daily: string[];
}; };
field_coverage: { current: string[]; hourly: string[] }; field_coverage: { current: string[]; hourly: string[]; daily: string[] };
local_field_coverage: { current: string[]; hourly: string[] }; local_field_coverage: { current: string[]; hourly: string[]; daily: string[] };
data_source: string; data_source: string;
merged_fields: string[];
sync_hint: string; sync_hint: string;
recommended_sync: { domains: string; variables: string }; recommended_sync: { domains: string; variables: string };
assistant_tools: Record<string, string>; assistant_tools: Record<string, string>;
@@ -254,15 +283,14 @@ export interface MemoryFact {
} }
export interface FitnessActivityBonus { export interface FitnessTdeeBreakdown {
bmr: number;
neat_kcal: number;
steps_kcal: number;
workout_kcal: number;
tdee: number;
calorie_target: number;
steps: number; steps: number;
steps_baseline: number;
steps_bonus_kcal: number;
workout_active_kcal: number;
workout_baseline_kcal: number;
workout_bonus_kcal: number;
total_bonus_kcal: number;
scale_factor: number;
} }
export interface FitnessTargets { export interface FitnessTargets {
@@ -297,6 +325,9 @@ export interface FitnessComputed {
bmr: number; bmr: number;
tdee: number; tdee: number;
bmi: number; bmi: number;
neat_kcal?: number;
steps_kcal?: number;
workout_kcal?: number;
} }
export interface FitnessProfile { export interface FitnessProfile {
@@ -304,12 +335,9 @@ export interface FitnessProfile {
age?: number; age?: number;
height_cm?: number; height_cm?: number;
weight_kg?: number; weight_kg?: number;
activity_level?: string;
goal?: string; goal?: string;
target_weight_kg?: number | null; target_weight_kg?: number | null;
weekly_workouts?: number; neat_base_kcal?: number;
baseline_steps?: number | null;
baseline_workout_kcal?: number | null;
calorie_target?: number; calorie_target?: number;
protein_g?: number; protein_g?: number;
fat_g?: number; fat_g?: number;
@@ -359,8 +387,7 @@ export interface FitnessDailySummary {
steps?: number; steps?: number;
}; };
targets: FitnessTargets; targets: FitnessTargets;
targets_base?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown;
activity?: FitnessActivityBonus;
steps?: StepLogItem[]; steps?: StepLogItem[];
steps_total?: number; steps_total?: number;
meals: FoodLogItem[]; meals: FoodLogItem[];
@@ -407,7 +434,7 @@ export interface FitnessDayOverview {
has_data: boolean; has_data: boolean;
totals: FitnessDailySummary["totals"]; totals: FitnessDailySummary["totals"];
targets: FitnessDailySummary["targets"]; targets: FitnessDailySummary["targets"];
targets_base?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown;
meal_count: number; meal_count: number;
workout_count: number; workout_count: number;
} }
@@ -579,6 +606,31 @@ export const api = {
yield* readChatSse(response); yield* readChatSse(response);
}, },
sendMessageWithImage: async function* (sessionId: number, content: string, file: File) {
yield* api.sendMessageWithImages(sessionId, content, [file]);
},
sendMessageWithImages: async function* (sessionId: number, content: string, files: File[]) {
const form = new FormData();
form.append("content", content);
for (const file of files) {
form.append("images", file);
}
const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, {
method: "POST",
headers: authHeaders(),
body: form,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail || `Ошибка отправки (${response.status})`);
}
yield* readChatSse(response);
},
streamGeneration: async function* (sessionId: number) { streamGeneration: async function* (sessionId: number) {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/v1/chat/sessions/${sessionId}/generation/stream`, `${API_BASE}/api/v1/chat/sessions/${sessionId}/generation/stream`,
@@ -634,8 +686,10 @@ export const api = {
{ method: "POST" } { method: "POST" }
), ),
weatherDashboard: (hoursAhead = 12) => weatherDashboard: (hoursAhead = 12, daysAhead = 7) =>
request<WeatherDashboard>(`/api/v1/homelab/weather?hours_ahead=${hoursAhead}`), request<WeatherDashboard>(
`/api/v1/homelab/weather?hours_ahead=${hoursAhead}&days_ahead=${daysAhead}`,
),
getCharacter: () => request<CharacterCardV2>("/api/v1/character"), getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
@@ -914,6 +968,7 @@ export interface RemindersCalendar {
export interface AssistantSettings { export interface AssistantSettings {
openrouter_model: string; openrouter_model: string;
memory_extract_model: string; memory_extract_model: string;
openrouter_vision_model: string;
openrouter_reasoning_effort: string; openrouter_reasoning_effort: string;
rag_enabled: boolean; rag_enabled: boolean;
rag_top_k: number; rag_top_k: number;
+37 -11
View File
@@ -1,33 +1,56 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { ChatMessage } from "../api/client"; import { ChatMessage, getAuthToken } from "../api/client";
const API_BASE = import.meta.env.VITE_API_URL ?? ""; const API_BASE = import.meta.env.VITE_API_URL ?? "";
function resolveMediaUrl(src: string | undefined): string | undefined { function resolveMediaUrl(src: string | undefined): string | undefined {
if (!src) return src; if (!src) return src;
if (/^https?:\/\//i.test(src) || src.startsWith("data:")) { if (/^https?:\/\//i.test(src) || src.startsWith("data:") || src.startsWith("blob:")) {
return src; return src;
} }
if (src.startsWith("/")) {
return `${API_BASE}${src}`; let path = src;
if (path.startsWith("/")) {
path = `${API_BASE}${path}`;
} }
return src;
if (path.includes("/media/uploads/")) {
const token = getAuthToken();
if (token) {
const url = new URL(path, window.location.origin);
url.searchParams.set("token", token);
return url.toString();
}
}
return path;
} }
function createMarkdownComponents(onContentResize?: () => void): Components { function createMarkdownComponents(onContentResize?: () => void): Components {
return { return {
img: ({ src, alt }) => ( img: ({ src, alt }) => {
const resolved = resolveMediaUrl(src);
if (!resolved) return null;
return (
<a
className="message-image-link"
href={resolved}
target="_blank"
rel="noopener noreferrer"
>
<img <img
src={resolveMediaUrl(src)} src={resolved}
alt={alt ?? ""} alt={alt ?? ""}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={onContentResize} onLoad={onContentResize}
onError={onContentResize} onError={onContentResize}
/> />
), </a>
);
},
}; };
} }
@@ -57,7 +80,10 @@ function messageClassName(role: string): string {
return role; return role;
} }
function usesMarkdown(role: string): boolean { function usesMarkdown(role: string, content: string): boolean {
if (role === "user") {
return /!\[[^\]]*\]\([^)]+\)/.test(content);
}
return role === "assistant" || role === "notice" || role === "character"; return role === "assistant" || role === "notice" || role === "character";
} }
@@ -74,10 +100,10 @@ function MessageBubbleInner({ msg, onContentResize }: MessageBubbleProps) {
const markdown = useMemo( const markdown = useMemo(
() => () =>
usesMarkdown(msg.role) ? ( usesMarkdown(msg.role, msg.content) ? (
<ReactMarkdown components={markdownComponents}>{msg.content}</ReactMarkdown> <ReactMarkdown components={markdownComponents}>{msg.content}</ReactMarkdown>
) : ( ) : (
msg.content <span className="message-plain-text">{msg.content}</span>
), ),
[msg.role, msg.content, markdownComponents], [msg.role, msg.content, markdownComponents],
); );
+39
View File
@@ -201,6 +201,45 @@
line-height: 1.45; line-height: 1.45;
} }
.weather-widget-tomorrow .weather-widget-note {
font-weight: 600;
color: #dce3ee;
}
.weather-widget-tabs {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0.5rem 0 0.75rem;
flex-wrap: wrap;
}
.weather-widget-tabs button {
padding: 0.35rem 0.65rem;
border: 1px solid #3a4254;
border-radius: 6px;
background: #1b2130;
color: #a8b0bd;
cursor: pointer;
font-size: 0.78rem;
}
.weather-widget-tabs button.active {
background: #2b3445;
color: #fff;
border-color: #4f7cff;
}
.weather-widget-hours {
margin-left: auto;
padding: 0.3rem 0.45rem;
border: 1px solid #3a4254;
border-radius: 6px;
background: #1b2130;
color: #c5ccd6;
font-size: 0.78rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.weather-widget-panel { .weather-widget-panel {
position: fixed; position: fixed;
+97 -58
View File
@@ -29,6 +29,13 @@ function cacheLabel(cache: WeatherDashboard["cache"]): string {
return "только что загружено"; return "только что загружено";
} }
function sourceLabel(data: WeatherDashboard | null): string {
if (!data) return "";
if (data.data_source === "fallback") return " · api.open-meteo.com";
if (data.data_source === "merged") return " · local + fallback";
return "";
}
interface WeatherWidgetProps { interface WeatherWidgetProps {
compact?: boolean; compact?: boolean;
} }
@@ -38,12 +45,14 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hoursAhead, setHoursAhead] = useState(12);
const [view, setView] = useState<"hourly" | "daily">("hourly");
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const dash = await api.weatherDashboard(12); const dash = await api.weatherDashboard(hoursAhead, 7);
setData(dash); setData(dash);
setError(dash.weather.ok ? null : dash.weather.error ?? "OpenMeteo недоступен"); setError(dash.weather.ok ? null : dash.weather.error ?? "OpenMeteo недоступен");
} catch (err) { } catch (err) {
@@ -51,7 +60,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [hoursAhead]);
useEffect(() => { useEffect(() => {
load().catch(() => undefined); load().catch(() => undefined);
@@ -78,6 +87,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
}, [open]); }, [open]);
const cur = data?.weather.current; const cur = data?.weather.current;
const tomorrow = data?.weather.daily?.[1];
const compactLabel = const compactLabel =
cur?.temperature_c != null cur?.temperature_c != null
? `${Math.round(cur.temperature_c)}° · ${cur.conditions ?? "—"}` ? `${Math.round(cur.temperature_c)}° · ${cur.conditions ?? "—"}`
@@ -111,7 +121,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
<strong>OpenMeteo</strong> <strong>OpenMeteo</strong>
<span className="weather-widget-sub"> <span className="weather-widget-sub">
{data?.config.location ?? "—"} · {cacheLabel(data?.cache ?? { has_data: false, cached: false, fetched_at: null, age_sec: null, ttl_sec: 300, expires_in_sec: null })} {data?.config.location ?? "—"} · {cacheLabel(data?.cache ?? { has_data: false, cached: false, fetched_at: null, age_sec: null, ttl_sec: 300, expires_in_sec: null })}
{data?.data_source === "fallback" && " · данные с api.open-meteo.com"} {sourceLabel(data)}
</span> </span>
</div> </div>
<button type="button" className="weather-widget-refresh" onClick={() => load()} disabled={loading}> <button type="button" className="weather-widget-refresh" onClick={() => load()} disabled={loading}>
@@ -121,42 +131,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
{error && !data?.weather.ok && <p className="weather-widget-error">{error}</p>} {error && !data?.weather.ok && <p className="weather-widget-error">{error}</p>}
{data?.sync_hint && ( {data?.sync_hint && <p className="weather-widget-warn">{data.sync_hint}</p>}
<p className="weather-widget-warn">
{data.sync_hint}
{data.recommended_sync && (
<>
<br />
<code>SYNC_DOMAINS={data.recommended_sync.domains}</code>
<br />
<code>SYNC_VARIABLES={data.recommended_sync.variables}</code>
</>
)}
</p>
)}
{data?.field_coverage &&
data.data_source !== "fallback" &&
(data.field_coverage.current.length < data.available_fields.current.length ||
data.field_coverage.hourly.length < data.available_fields.hourly.length) && (
<p className="weather-widget-warn">
OpenMeteo вернул не все поля. Пришло: current {" "}
{data.field_coverage.current.join(", ") || "ничего"}; hourly {" "}
{data.field_coverage.hourly.join(", ") || "ничего"}. Проверь sync на{" "}
{data.config.openmeteo_base_url}.
</p>
)}
{data?.local_field_coverage &&
data.data_source === "fallback" &&
(data.local_field_coverage.current.length < data.available_fields.current.length ||
data.local_field_coverage.hourly.length < data.available_fields.hourly.length) && (
<p className="weather-widget-warn">
Локальный OpenMeteo ({data.config.openmeteo_base_url}) отдаёт только: current {" "}
{data.local_field_coverage.current.join(", ") || "ничего"}; hourly {" "}
{data.local_field_coverage.hourly.join(", ") || "ничего"}. Показаны данные fallback.
</p>
)}
{data?.weather.ok && cur && ( {data?.weather.ok && cur && (
<section className="weather-widget-section"> <section className="weather-widget-section">
@@ -194,16 +169,56 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
</section> </section>
)} )}
{tomorrow && (
<section className="weather-widget-section weather-widget-tomorrow">
<h4>Завтра</h4>
<p className="weather-widget-note">
{tomorrow.label}: {tomorrow.temperature_min_c ?? "—"}{tomorrow.temperature_max_c ?? "—"}°C,{" "}
{tomorrow.conditions}
{tomorrow.precipitation_probability_max != null && tomorrow.precipitation_probability_max >= 30
? `, дождь до ${tomorrow.precipitation_probability_max}%`
: ""}
</p>
</section>
)}
{data?.rain_summary && ( {data?.rain_summary && (
<section className="weather-widget-section"> <section className="weather-widget-section">
<h4>Осадки (12 ч)</h4> <h4>Осадки</h4>
<p className="weather-widget-note">{data.rain_summary}</p> <p className="weather-widget-note">{data.rain_summary}</p>
</section> </section>
)} )}
{(data?.weather.hourly?.length ?? 0) > 0 && ( <div className="weather-widget-tabs">
<button
type="button"
className={view === "hourly" ? "active" : ""}
onClick={() => setView("hourly")}
>
По часам
</button>
<button
type="button"
className={view === "daily" ? "active" : ""}
onClick={() => setView("daily")}
>
7 дней
</button>
{view === "hourly" && (
<select
className="weather-widget-hours"
value={hoursAhead}
onChange={(e) => setHoursAhead(Number(e.target.value))}
>
<option value={12}>12 ч</option>
<option value={24}>24 ч</option>
<option value={48}>48 ч</option>
</select>
)}
</div>
{view === "hourly" && (data?.weather.hourly?.length ?? 0) > 0 && (
<section className="weather-widget-section"> <section className="weather-widget-section">
<h4>По часам</h4>
<div className="weather-widget-table-wrap"> <div className="weather-widget-table-wrap">
<table className="weather-widget-table"> <table className="weather-widget-table">
<thead> <thead>
@@ -231,6 +246,40 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
</section> </section>
)} )}
{view === "daily" && (data?.weather.daily?.length ?? 0) > 0 && (
<section className="weather-widget-section">
{data?.daily_summary && <p className="weather-widget-note">{data.daily_summary}</p>}
<div className="weather-widget-table-wrap">
<table className="weather-widget-table">
<thead>
<tr>
<th>День</th>
<th>°C</th>
<th>Осадки</th>
<th>Дождь</th>
<th>Ветер</th>
<th>Условия</th>
</tr>
</thead>
<tbody>
{data!.weather.daily!.map((row) => (
<tr key={row.date}>
<td>{row.label}</td>
<td>
{row.temperature_min_c ?? "—"}{row.temperature_max_c ?? "—"}
</td>
<td>{row.precipitation_sum_mm ?? 0} мм</td>
<td>{row.precipitation_probability_max ?? "—"}%</td>
<td>{row.wind_speed_max_kmh ?? "—"}</td>
<td>{row.conditions}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{data?.assistant_context && ( {data?.assistant_context && (
<section className="weather-widget-section"> <section className="weather-widget-section">
<h4>Контекст ассистента</h4> <h4>Контекст ассистента</h4>
@@ -254,25 +303,15 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
<dd>{data.config.openmeteo_base_url}/v1/forecast</dd> <dd>{data.config.openmeteo_base_url}/v1/forecast</dd>
</div> </div>
<div> <div>
<dt>TTL кэша</dt> <dt>Прогноз</dt>
<dd>{data.config.cache_ttl_sec} с</dd> <dd>{data.config.forecast_days} дней</dd>
</div> </div>
{data.merged_fields.length > 0 && (
<div> <div>
<dt>Current fields</dt> <dt>Доп. с fallback</dt>
<dd> <dd>{data.merged_fields.join(", ")}</dd>
запрошено: {data.available_fields.current.join(", ")}
<br />
получено: {data.field_coverage.current.join(", ") || "—"}
</dd>
</div>
<div>
<dt>Hourly fields</dt>
<dd>
запрошено: {data.available_fields.hourly.join(", ")}
<br />
получено: {data.field_coverage.hourly.join(", ") || "—"}
</dd>
</div> </div>
)}
</dl> </dl>
<ul className="weather-widget-tools"> <ul className="weather-widget-tools">
{Object.entries(data.assistant_tools).map(([name, desc]) => ( {Object.entries(data.assistant_tools).map(([name, desc]) => (
+128 -4
View File
@@ -204,7 +204,8 @@
.chat-input { .chat-input {
display: flex; display: flex;
gap: 0.75rem; flex-direction: column;
gap: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
@@ -212,6 +213,120 @@
background: #0f1115; background: #0f1115;
} }
.chat-input-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.chat-input-dragover {
outline: 2px dashed #4f7cff;
outline-offset: -2px;
background: #141a28;
}
.chat-file-input {
display: none;
}
.chat-attach-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
background: #2a3142;
color: inherit;
border: 1px solid #3a4558;
border-radius: 8px;
padding: 0 0.75rem;
cursor: pointer;
}
.chat-image-previews {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.chat-image-previews .chat-image-clear-all {
align-self: center;
background: transparent;
color: #9aa5b5;
border: 1px solid #3a4558;
border-radius: 8px;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.85rem;
}
.chat-image-previews .chat-image-clear-all:hover {
color: #e8edf4;
}
.chat-vision-debug-item {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #3a4558;
}
.chat-vision-debug-item:first-of-type {
margin-top: 0.5rem;
padding-top: 0;
border-top: none;
}
.chat-image-preview {
position: relative;
display: inline-block;
max-width: 160px;
}
.chat-image-preview img {
display: block;
max-width: 160px;
max-height: 120px;
border-radius: 8px;
border: 1px solid #3a4558;
}
.chat-image-preview button {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.65);
color: white;
border: none;
border-radius: 999px;
width: 24px;
height: 24px;
cursor: pointer;
}
.chat-vision-debug {
margin: 0 1rem 0.5rem;
padding: 0.75rem;
border: 1px solid #3a4558;
border-radius: 8px;
background: #12151c;
font-size: 0.85rem;
}
.chat-vision-debug pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0.5rem 0 0;
max-height: 240px;
overflow: auto;
}
.chat-vision-error {
color: #ff8a80;
margin: 0.25rem 0;
}
.chat-input textarea { .chat-input textarea {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -222,16 +337,25 @@
color: inherit; color: inherit;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 16px; font-size: 16px;
line-height: 1.35;
} }
.chat-input button { .chat-input-row button[type="submit"] {
align-self: flex-end;
flex-shrink: 0; flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 6.5rem;
background: #4f7cff; background: #4f7cff;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 0.65rem 1rem; padding: 0 1rem;
}
.chat-input-row button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
.chat-input button:disabled { .chat-input button:disabled {
+23
View File
@@ -8,6 +8,29 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.message-user .message-content,
.message-plain-text {
white-space: pre-wrap;
word-break: break-word;
}
.message-user .message-content img,
.message-image-link img {
max-width: min(100%, 280px);
width: auto;
max-height: 220px;
margin: 0 0 0.5rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
object-fit: cover;
cursor: zoom-in;
}
.message-image-link {
display: inline-block;
line-height: 0;
}
.message-content img { .message-content img {
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
+162 -9
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { DragEvent, FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { api, ChatMessage, ChatSession, ChatStreamChunk } from "../api/client"; import { api, ChatMessage, ChatSession, ChatStreamChunk } from "../api/client";
import MessageList, { MessageListHandle } from "../components/MessageList"; import MessageList, { MessageListHandle } from "../components/MessageList";
import PomodoroWidget from "../components/PomodoroWidget"; import PomodoroWidget from "../components/PomodoroWidget";
@@ -12,11 +12,31 @@ const INITIAL_MESSAGE_LIMIT = 30;
const LOAD_OLDER_LIMIT = 30; const LOAD_OLDER_LIMIT = 30;
const SYNC_TAIL_LIMIT = 15; const SYNC_TAIL_LIMIT = 15;
const GENERATION_POLL_MS = 2000; const GENERATION_POLL_MS = 2000;
const MAX_PENDING_IMAGES = 8;
type PendingImageItem = {
file: File;
previewUrl: string;
};
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function buildImageMarkdown(items: PendingImageItem[]): string {
if (!items.length) return "";
return items
.map((item, index) => {
const label = items.length > 1 ? `скриншот ${index + 1}/${items.length}` : "скриншот";
return `![${label}](${item.previewUrl})`;
})
.join("\n");
}
function buildUserMessagePreview(items: PendingImageItem[], text: string): string {
return [buildImageMarkdown(items), text.trim()].filter(Boolean).join("\n\n");
}
function shouldShowMessage(msg: ChatMessage): boolean { function shouldShowMessage(msg: ChatMessage): boolean {
if (msg.role === "tool") return false; if (msg.role === "tool") return false;
if (msg.role === "assistant" && msg.tool_calls_json) return false; if (msg.role === "assistant" && msg.tool_calls_json) return false;
@@ -36,7 +56,10 @@ export default function Chat() {
"thinking" | "preparing" | "generating" | "tools" "thinking" | "preparing" | "generating" | "tools"
>("thinking"); >("thinking");
const [chatError, setChatError] = useState<string | null>(null); const [chatError, setChatError] = useState<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingImageItem[]>([]);
const [inputDragOver, setInputDragOver] = useState(false);
const tempMessageId = useRef(0); const tempMessageId = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesRef = useRef<HTMLDivElement>(null); const messagesRef = useRef<HTMLDivElement>(null);
const messageListRef = useRef<MessageListHandle>(null); const messageListRef = useRef<MessageListHandle>(null);
const bottomAnchorRef = useRef<HTMLDivElement>(null); const bottomAnchorRef = useRef<HTMLDivElement>(null);
@@ -133,6 +156,9 @@ export default function Chat() {
const processStreamChunk = useCallback( const processStreamChunk = useCallback(
(chunk: ChatStreamChunk, assistantTextRef: { current: string }) => { (chunk: ChatStreamChunk, assistantTextRef: { current: string }) => {
if (chunk.event === "vision") {
setPendingPhase("preparing");
}
if (chunk.event === "status") { if (chunk.event === "status") {
const phase = chunk.data.phase; const phase = chunk.data.phase;
if (phase === "preparing") { if (phase === "preparing") {
@@ -368,6 +394,17 @@ export default function Chat() {
usePomodoroNotify(handlePomodoroNotify); usePomodoroNotify(handlePomodoroNotify);
useEffect(() => {
return () => {
setPendingImages((prev) => {
for (const item of prev) {
URL.revokeObjectURL(item.previewUrl);
}
return prev;
});
};
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -422,30 +459,103 @@ export default function Chat() {
} }
}; };
const clearPendingImages = useCallback(() => {
setPendingImages((prev) => {
for (const item of prev) {
URL.revokeObjectURL(item.previewUrl);
}
return [];
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const removePendingImage = useCallback((index: number) => {
setPendingImages((prev) => {
const next = [...prev];
const [removed] = next.splice(index, 1);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
}
return next;
});
}, []);
const handleImagePick = (fileList: FileList | null) => {
if (!fileList?.length) return;
const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
if (!picked.length) return;
setPendingImages((prev) => {
const room = MAX_PENDING_IMAGES - prev.length;
if (room <= 0) return prev;
const nextItems = picked.slice(0, room).map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
}));
return [...prev, ...nextItems];
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleInputDragOver = (event: DragEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading) return;
setInputDragOver(true);
};
const handleInputDragLeave = (event: DragEvent<HTMLFormElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setInputDragOver(false);
};
const handleInputDrop = (event: DragEvent<HTMLFormElement>) => {
event.preventDefault();
setInputDragOver(false);
if (loading) return;
handleImagePick(event.dataTransfer.files);
};
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!input.trim() || !activeId || loading) return; if (!activeId || loading) return;
const text = input.trim(); const text = input.trim();
const submittingImages = [...pendingImages];
if (!text && submittingImages.length === 0) return;
setInput(""); setInput("");
dismissKeyboard(); dismissKeyboard();
stickToBottomRef.current = true; stickToBottomRef.current = true;
setLoading(true); setLoading(true);
resetStreaming(); resetStreaming();
setPendingPhase("thinking"); setPendingPhase(submittingImages.length > 0 ? "preparing" : "thinking");
setChatError(null); setChatError(null);
const displayContent = buildUserMessagePreview(submittingImages, text);
const tempUser: ChatMessage = { const tempUser: ChatMessage = {
id: nextTempId(), id: nextTempId(),
role: "user", role: "user",
content: text, content: displayContent,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
applyMessages((prev) => [...prev, tempUser], { scrollToBottom: true }); applyMessages((prev) => [...prev, tempUser], { scrollToBottom: true });
const imageFiles = submittingImages.map((item) => item.file);
setPendingImages([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
try { try {
const assistantTextRef = { current: "" }; const assistantTextRef = { current: "" };
for await (const chunk of api.sendMessage(activeId, text)) { const stream = imageFiles.length
? api.sendMessageWithImages(activeId, text, imageFiles)
: api.sendMessage(activeId, text);
for await (const chunk of stream) {
processStreamChunk(chunk, assistantTextRef); processStreamChunk(chunk, assistantTextRef);
if (chunk.event === "done") { if (chunk.event === "done") {
flushStreaming(); flushStreaming();
@@ -478,6 +588,9 @@ export default function Chat() {
await syncRecentMessages(activeId); await syncRecentMessages(activeId);
} }
} finally { } finally {
for (const item of submittingImages) {
URL.revokeObjectURL(item.previewUrl);
}
setLoading(false); setLoading(false);
if (pendingHistoryReload.current && activeId) { if (pendingHistoryReload.current && activeId) {
pendingHistoryReload.current = false; pendingHistoryReload.current = false;
@@ -584,12 +697,51 @@ export default function Chat() {
/> />
</div> </div>
<form className="chat-input" onSubmit={handleSubmit}> <form
className={`chat-input${inputDragOver ? " chat-input-dragover" : ""}`}
onSubmit={handleSubmit}
onDragOver={handleInputDragOver}
onDragLeave={handleInputDragLeave}
onDrop={handleInputDrop}
>
{pendingImages.length ? (
<div className="chat-image-previews">
{pendingImages.map((item, index) => (
<div key={`${item.file.name}-${index}`} className="chat-image-preview">
<img src={item.previewUrl} alt={`Превью ${index + 1}`} />
<button type="button" onClick={() => removePendingImage(index)} aria-label="Убрать">
×
</button>
</div>
))}
<button type="button" className="chat-image-clear-all" onClick={clearPendingImages}>
Очистить все
</button>
</div>
) : null}
<div className="chat-input-row">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="chat-file-input"
onChange={(e) => handleImagePick(e.target.files)}
/>
<button
type="button"
className="chat-attach-btn"
title="Прикрепить скриншоты"
onClick={() => fileInputRef.current?.click()}
disabled={loading || pendingImages.length >= MAX_PENDING_IMAGES}
>
📎
</button>
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..." placeholder="Напишите сообщение или прикрепите скриншоты…"
rows={2} rows={2}
enterKeyHint="send" enterKeyHint="send"
autoComplete="off" autoComplete="off"
@@ -600,9 +752,10 @@ export default function Chat() {
} }
}} }}
/> />
<button type="submit" disabled={loading || !input.trim()}> <button type="submit" disabled={loading || (!input.trim() && pendingImages.length === 0)}>
{loading ? "..." : "Отправить"} {loading ? "..." : "Отправить"}
</button> </button>
</div>
</form> </form>
</> </>
)} )}
+23 -33
View File
@@ -38,20 +38,14 @@ function ProgressBar({
label, label,
current, current,
target, target,
baseTarget,
unit, unit,
}: { }: {
label: string; label: string;
current: number; current: number;
target: number; target: number;
baseTarget?: number;
unit: string; unit: string;
}) { }) {
const base = baseTarget && baseTarget > 0 ? baseTarget : target;
const bonus = Math.max(0, target - base);
const scaleMax = Math.max(target, current, 1); const scaleMax = Math.max(target, current, 1);
const basePct = (base / scaleMax) * 100;
const bonusPct = (bonus / scaleMax) * 100;
const fillPct = (Math.min(current, scaleMax) / scaleMax) * 100; const fillPct = (Math.min(current, scaleMax) / scaleMax) * 100;
const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0; const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0;
return ( return (
@@ -60,12 +54,9 @@ function ProgressBar({
<span>{label}</span> <span>{label}</span>
<span> <span>
{current.toFixed(0)}/{target.toFixed(0)} {unit} {current.toFixed(0)}/{target.toFixed(0)} {unit}
{bonus > 0 ? ` (+${bonus.toFixed(0)} бонус)` : ""}
</span> </span>
</div> </div>
<div className="fitness-progress-track fitness-progress-track-v2"> <div className="fitness-progress-track fitness-progress-track-v2">
<div className="fitness-progress-base" style={{ width: `${basePct}%` }} />
<div className="fitness-progress-bonus" style={{ width: `${bonusPct}%` }} />
<div className="fitness-progress-fill" style={{ width: `${fillPct}%` }} /> <div className="fitness-progress-fill" style={{ width: `${fillPct}%` }} />
{overflowPct > 0 ? ( {overflowPct > 0 ? (
<div className="fitness-progress-overflow" style={{ width: `${overflowPct}%` }} /> <div className="fitness-progress-overflow" style={{ width: `${overflowPct}%` }} />
@@ -177,8 +168,7 @@ export default function Fitness() {
const totals = daySummary?.totals; const totals = daySummary?.totals;
const targets = daySummary?.targets; const targets = daySummary?.targets;
const targetsBase = daySummary?.targets_base; const tdeeBreakdown = daySummary?.tdee_breakdown;
const activity = daySummary?.activity;
const workoutStats = snapshot?.workout_stats; const workoutStats = snapshot?.workout_stats;
const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0]; const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0];
const isToday = selectedDate === todayIso(); const isToday = selectedDate === todayIso();
@@ -255,15 +245,19 @@ export default function Fitness() {
{totals && targets ? ( {totals && targets ? (
<div className="fitness-day-panel"> <div className="fitness-day-panel">
{activity ? ( {tdeeBreakdown ? (
<div className="fitness-activity-block"> <div className="fitness-activity-block">
<p> <p>
Шаги: {daySummary?.steps_total ?? activity.steps} / база {activity.steps_baseline} TDEE: BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "}
{tdeeBreakdown.steps_kcal} ({daySummary?.steps_total ?? tdeeBreakdown.steps}) + тренировки{" "}
{tdeeBreakdown.workout_kcal} = {tdeeBreakdown.tdee} ккал
</p> </p>
<p> <p>Цель ккал: {tdeeBreakdown.calorie_target}</p>
Бонус активности: +{activity.total_bonus_kcal} ккал (шаги +{activity.steps_bonus_kcal}, {!daySummary?.steps_total && !daySummary?.workouts?.length ? (
тренировки +{activity.workout_bonus_kcal}) <p className="fitness-hint">
Шаги и тренировки не внесены TDEE = BMR + NEAT. Внесите данные через чат для точной цели.
</p> </p>
) : null}
</div> </div>
) : null} ) : null}
@@ -282,28 +276,24 @@ export default function Fitness() {
label="Калории" label="Калории"
current={totals.calories} current={totals.calories}
target={targets.calories} target={targets.calories}
baseTarget={targetsBase?.calories}
unit="ккал" unit="ккал"
/> />
<ProgressBar <ProgressBar
label="Белок" label="Белок"
current={totals.protein_g} current={totals.protein_g}
target={targets.protein_g} target={targets.protein_g}
baseTarget={targetsBase?.protein_g}
unit="г" unit="г"
/> />
<ProgressBar <ProgressBar
label="Жиры" label="Жиры"
current={totals.fat_g} current={totals.fat_g}
target={targets.fat_g} target={targets.fat_g}
baseTarget={targetsBase?.fat_g}
unit="г" unit="г"
/> />
<ProgressBar <ProgressBar
label="Углеводы" label="Углеводы"
current={totals.carbs_g} current={totals.carbs_g}
target={targets.carbs_g} target={targets.carbs_g}
baseTarget={targetsBase?.carbs_g}
unit="г" unit="г"
/> />
<ProgressBar <ProgressBar
@@ -375,19 +365,16 @@ export default function Fitness() {
/> />
</label> </label>
<label> <label>
<span>активность</span> <span>NEAT ккал</span>
<select <input
value={profile.activity_level ?? "moderate"} type="number"
min={200}
max={300}
value={profile.neat_base_kcal ?? 200}
onChange={(e) => onChange={(e) =>
setProfile((p) => ({ ...p, activity_level: e.target.value })) setProfile((p) => ({ ...p, neat_base_kcal: Number(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>
<label> <label>
<span>цель</span> <span>цель</span>
@@ -404,10 +391,13 @@ export default function Fitness() {
</form> </form>
{profile.computed && ( {profile.computed && (
<p className="fitness-computed"> <p className="fitness-computed">
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "} BMR {profile.computed.bmr} + NEAT {profile.computed.neat_kcal ?? 200} = TDEE база{" "}
{profile.computed.bmi} {profile.computed.tdee} · BMI {profile.computed.bmi}
</p> </p>
)} )}
<p className="fitness-hint">
Дневная цель пересчитывается от фактических шагов и тренировок (TDEE ± дефицит/профицит).
</p>
<div className="fitness-body-calc"> <div className="fitness-body-calc">
<h4>Navy-калькулятор (без сохранения)</h4> <h4>Navy-калькулятор (без сохранения)</h4>
+10
View File
@@ -39,6 +39,7 @@ export default function Settings() {
const updated = await api.patchSettings({ const updated = await api.patchSettings({
openrouter_model: settings.openrouter_model, openrouter_model: settings.openrouter_model,
memory_extract_model: settings.memory_extract_model, memory_extract_model: settings.memory_extract_model,
openrouter_vision_model: settings.openrouter_vision_model,
openrouter_reasoning_effort: settings.openrouter_reasoning_effort, openrouter_reasoning_effort: settings.openrouter_reasoning_effort,
rag_enabled: settings.rag_enabled, rag_enabled: settings.rag_enabled,
rag_top_k: settings.rag_top_k, rag_top_k: settings.rag_top_k,
@@ -109,6 +110,15 @@ export default function Settings() {
onChange={(e) => setSettings({ ...settings, memory_extract_model: e.target.value })} onChange={(e) => setSettings({ ...settings, memory_extract_model: e.target.value })}
/> />
</label> </label>
<label>
Vision-модель (скриншоты)
<input
value={settings.openrouter_vision_model}
onChange={(e) =>
setSettings({ ...settings, openrouter_vision_model: e.target.value })
}
/>
</label>
<label> <label>
Reasoning effort Reasoning effort
<select <select
+7
View File
File diff suppressed because one or more lines are too long
+20
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

+26
View File
@@ -130,6 +130,32 @@ class HaClient:
): ):
yield chunk yield chunk
async def send_message_with_image_stream(
self,
session_id: int,
content: str,
image_bytes: bytes,
*,
filename: str = "photo.jpg",
content_type: str = "image/jpeg",
) -> AsyncIterator[SseChunk]:
url = f"{self.base_url}/chat/sessions/{session_id}/messages"
files = {"image": (filename, image_bytes, content_type)}
data = {"content": content}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
url,
headers=self._headers({"Accept": "text/event-stream"}),
files=files,
data=data,
) as response:
if response.status_code >= 400:
body = await response.aread()
raise HaApiError(body.decode("utf-8", errors="replace") or f"HTTP {response.status_code}", response.status_code)
async for chunk in iter_sse(response):
yield chunk
async def stream_generation(self, session_id: int) -> AsyncIterator[SseChunk]: async def stream_generation(self, session_id: int) -> AsyncIterator[SseChunk]:
async for chunk in self._stream("GET", f"/chat/sessions/{session_id}/generation/stream"): async for chunk in self._stream("GET", f"/chat/sessions/{session_id}/generation/stream"):
yield chunk yield chunk
+77
View File
@@ -60,6 +60,32 @@ async def _run_chat_stream(
HaClient(settings.ha_api_base_url, linked.api_token), HaClient(settings.ha_api_base_url, linked.api_token),
ha_api_base=settings.ha_api_base_url, ha_api_base=settings.ha_api_base_url,
) )
elif chunk.event == "vision":
data = chunk.data if isinstance(chunk.data, dict) else {}
images = data.get("images")
if isinstance(images, list) and images:
for index, item in enumerate(images, start=1):
if not isinstance(item, dict):
continue
parsed = item.get("parsed")
model = item.get("model")
preview = ""
if isinstance(parsed, dict):
preview = str(parsed.get("description") or "")[:400]
lines = [f"Vision {index}/{len(images)} ({model or '?'}):", preview or "(нет описания)"]
if isinstance(parsed, dict) and parsed.get("fitness_hints"):
lines.append(f"fitness_hints: {parsed.get('fitness_hints')}")
await message.answer("\n".join(lines)[:4000])
else:
parsed = data.get("parsed")
model = data.get("model")
preview = ""
if isinstance(parsed, dict):
preview = str(parsed.get("description") or "")[:400]
lines = [f"Vision ({model or '?'}):", preview or "(нет описания)"]
if isinstance(parsed, dict) and parsed.get("fitness_hints"):
lines.append(f"fitness_hints: {parsed.get('fitness_hints')}")
await message.answer("\n".join(lines)[:4000])
elif chunk.event == "error": elif chunk.event == "error":
err = str(chunk.data.get("message") or "Ошибка генерации") err = str(chunk.data.get("message") or "Ошибка генерации")
await message.answer(err) await message.answer(err)
@@ -134,3 +160,54 @@ async def handle_chat_message(message: Message, settings: Settings, storage: Sto
except Exception as exc: except Exception as exc:
logger.exception("Chat stream failed for telegram_id=%s", message.from_user.id) logger.exception("Chat stream failed for telegram_id=%s", message.from_user.id)
await message.answer(f"Ошибка при получении ответа: {exc}") await message.answer(f"Ошибка при получении ответа: {exc}")
@router.message(F.photo, IsLinked())
async def handle_chat_photo(message: Message, settings: Settings, storage: Storage) -> None:
if not is_allowed(message, settings):
return
if not message.from_user or not message.photo:
return
linked = await storage.get_user(message.from_user.id)
if not linked:
return
lock = _user_lock(message.from_user.id)
if lock.locked():
await message.answer("Подожди, предыдущий ответ ещё генерируется.")
return
async with lock:
client = HaClient(settings.ha_api_base_url, linked.api_token)
caption = (message.caption or "").strip()
photo = message.photo[-1]
try:
file = await message.bot.get_file(photo.file_id)
if not file.file_path:
await message.answer("Не удалось получить файл фото.")
return
downloaded = await message.bot.download_file(file.file_path)
image_bytes = downloaded.read() if hasattr(downloaded, "read") else bytes(downloaded)
status = await client.generation_status(linked.session_id)
if status.get("active"):
stream = client.stream_generation(linked.session_id)
else:
stream = client.send_message_with_image_stream(
linked.session_id,
caption,
image_bytes,
filename="telegram.jpg",
)
except Exception as exc:
logger.exception("Failed to start chat photo for telegram_id=%s", message.from_user.id)
await message.answer(f"Ошибка связи с Home Assistant: {exc}")
return
try:
await _run_chat_stream(message, settings, storage, linked, _iter_stream(stream))
except Exception as exc:
logger.exception("Chat photo stream failed for telegram_id=%s", message.from_user.id)
await message.answer(f"Ошибка при получении ответа: {exc}")