diff --git a/.env.example b/.env.example index 6fce746..c0f2f9d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ OPENROUTER_MODEL=deepseek/deepseek-chat # deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются: # OPENROUTER_MODEL=deepseek/deepseek-v4-pro OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +# SOCKS5 для OpenRouter (чат, vision, embeddings). Пусто = напрямую. +# Docker + прокси на хосте: socks5://host.docker.internal:1080 +# OPENROUTER_SOCKS5_PROXY=socks5://127.0.0.1:1080 OPENROUTER_TOOLS_ENABLED=true # none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning. OPENROUTER_REASONING_EFFORT=none diff --git a/backend/app/config.py b/backend/app/config.py index b98dbf7..deaf117 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,8 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_model: str = "deepseek/deepseek-chat" openrouter_base_url: str = "https://openrouter.ai/api/v1" + # socks5://host:port или socks5://user:pass@host:port — для OpenRouter из docker/внутренней сети + openrouter_socks5_proxy: str = "" # Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL. memory_extract_model: str = "" # Некоторые модели (reasoning / без function calling) — выключить tools. diff --git a/backend/app/daily/metrics.py b/backend/app/daily/metrics.py index 636ac70..9dab13f 100644 --- a/backend/app/daily/metrics.py +++ b/backend/app/daily/metrics.py @@ -29,12 +29,12 @@ def plan_hit(totals: dict[str, float], targets: dict[str, float], goal: str, *, return abs(actual - target) <= target * 0.1 -def signed_balance(totals: dict[str, float], targets: dict[str, float], goal: str) -> float: +def signed_balance(totals: dict[str, float], tdee: float, goal: str) -> float: + """Energy balance vs factual TDEE (positive = deficit for lose/maintain, surplus for gain).""" actual = float(totals.get("calories", 0)) - target = float(targets.get("calories", 0)) if goal == "gain": - return actual - target - return target - actual + return actual - tdee + return tdee - actual def streak_from_flags(flags_by_day: dict[date, bool], end: date) -> int: @@ -214,7 +214,9 @@ class DayMetricsService: "steps_total": steps_total, "has_meals": has_meals, "plan_hit": plan_hit(day_totals, targets, goal, has_meals=has_meals), - "balance": signed_balance(day_totals, targets, goal) if has_meals else 0.0, + "balance": signed_balance(day_totals, daily_targets["tdee"], goal) + if has_meals + else 0.0, } cursor += timedelta(days=1) return result diff --git a/backend/app/daily/recap.py b/backend/app/daily/recap.py index 048dcf3..805c868 100644 --- a/backend/app/daily/recap.py +++ b/backend/app/daily/recap.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from datetime import date, datetime, time, timezone +from datetime import date, datetime, timezone from typing import Any from zoneinfo import ZoneInfo @@ -12,8 +12,7 @@ from sqlalchemy.orm import Session from app.character.service import CharacterService from app.chat.notice_inbox import post_notice_to_latest_chat -from app.db.models import ChatSession, DailyRecap, PomodoroSession, Reminder, SessionSummary -from app.daily.metrics import DayMetricsService +from app.db.models import DailyRecap from app.homelab.comfyui import random_rofl_pose from app.homelab.evening_summary import ( LLM_TIMEOUT_SEC, @@ -21,7 +20,6 @@ from app.homelab.evening_summary import ( generate_evening_summary_content, ) from app.homelab.image_gen import generate_image -from app.pomodoro.cycle import PHASE_WORK logger = logging.getLogger(__name__) @@ -29,12 +27,6 @@ RECAP_DONE_STATUSES = frozenset({"posted", "text_only"}) RETRY_DELAYS_MIN = (15, 30, 60) -def _day_bounds(day: date, tz: ZoneInfo) -> tuple[datetime, datetime]: - start = datetime.combine(day, time.min, tzinfo=tz) - end = datetime.combine(day, time.max, tzinfo=tz) - return start, end - - class RecapService: def __init__(self, db: Session, user_id: int): self.db = db @@ -86,54 +78,9 @@ class RecapService: } def collect_day_context(self, day: date, tz: ZoneInfo) -> dict[str, Any]: - fitness_ctx = DayMetricsService(self.db, self.user_id).day_fitness_context(day) - start_dt, end_dt = _day_bounds(day, tz) + from app.homelab.evening_summary import collect_day_context as _collect - pomodoro_rows = self.db.scalars( - select(PomodoroSession).where( - PomodoroSession.user_id == self.user_id, - PomodoroSession.phase == PHASE_WORK, - PomodoroSession.completed.is_(True), - PomodoroSession.finished_at >= start_dt, - PomodoroSession.finished_at <= end_dt, - ) - ).all() - pomodoro_tasks = [r.task_note.strip() for r in pomodoro_rows if (r.task_note or "").strip()] - - reminder_rows = self.db.scalars( - select(Reminder).where( - Reminder.user_id == self.user_id, - Reminder.completed_at.is_not(None), - Reminder.completed_at >= start_dt, - Reminder.completed_at <= end_dt, - ) - ).all() - - session_summary_text = "" - latest_session = self.db.scalar( - select(ChatSession) - .where(ChatSession.user_id == self.user_id) - .order_by(ChatSession.updated_at.desc()) - .limit(1) - ) - if latest_session: - summary_row = self.db.scalar( - select(SessionSummary).where(SessionSummary.session_id == latest_session.id) - ) - if summary_row and summary_row.summary.strip() and summary_row.updated_at: - updated = summary_row.updated_at - if updated.tzinfo is None: - updated = updated.replace(tzinfo=timezone.utc) - if updated.astimezone(tz).date() == day: - session_summary_text = summary_row.summary.strip()[:500] - - return { - **fitness_ctx, - "pomodoro_sessions": len(pomodoro_rows), - "pomodoro_tasks": pomodoro_tasks[:8], - "completed_reminders": [r.title for r in reminder_rows][:8], - "session_summary": session_summary_text, - } + return _collect(self.db, self.user_id, day, tz) def list_failed_for_retry(self, *, max_attempts: int = 3) -> list[DailyRecap]: rows = self.db.scalars( diff --git a/backend/app/fitness/gamification.py b/backend/app/fitness/gamification.py index 426738c..be3e0f8 100644 --- a/backend/app/fitness/gamification.py +++ b/backend/app/fitness/gamification.py @@ -1,4 +1,4 @@ -"""Fitness gamification: streaks, cumulative deficit, milestone medals.""" +"""Fitness gamification: streaks, cumulative energy balance vs factual TDEE, milestone medals.""" from __future__ import annotations diff --git a/backend/app/homelab/evening_summary.py b/backend/app/homelab/evening_summary.py index c9c8e69..d9c2a3c 100644 --- a/backend/app/homelab/evening_summary.py +++ b/backend/app/homelab/evening_summary.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import logging -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time from typing import Any from zoneinfo import ZoneInfo @@ -12,7 +12,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.character.service import CharacterService -from app.db.models import ChatSession, PomodoroSession, Reminder, SessionSummary +from app.db.models import ChatSession, Message, PomodoroSession, Reminder, SessionSummary from app.daily.metrics import DayMetricsService from app.homelab.comfyui import random_rofl_pose from app.homelab.image_gen import generate_image @@ -24,6 +24,10 @@ logger = logging.getLogger(__name__) DEFAULT_SCENE = "full_body, standing, smile, looking_at_viewer" LLM_TIMEOUT_SEC = 90 +CHAT_CONTEXT_ROLES = frozenset({"user", "assistant"}) +MAX_DAY_CHAT_MESSAGES = 14 +MAX_DAY_CHAT_MSG_CHARS = 500 +MAX_DAY_CHAT_EXCERPT_CHARS = 2500 def _day_bounds(day: date, tz: ZoneInfo) -> tuple[datetime, datetime]: @@ -32,6 +36,68 @@ def _day_bounds(day: date, tz: ZoneInfo) -> tuple[datetime, datetime]: return start, end +def _day_active_session_id( + db: Session, + user_id: int, + start_dt: datetime, + end_dt: datetime, +) -> int | None: + return db.scalar( + select(Message.session_id) + .join(ChatSession, ChatSession.id == Message.session_id) + .where( + ChatSession.user_id == user_id, + Message.created_at >= start_dt, + Message.created_at <= end_dt, + Message.role.in_(CHAT_CONTEXT_ROLES), + Message.content != "", + ) + .order_by(Message.created_at.desc()) + .limit(1) + ) + + +def _format_chat_excerpt(messages: list[Message]) -> str: + lines: list[str] = [] + total = 0 + for msg in messages: + content = (msg.content or "").strip() + if not content: + continue + if len(content) > MAX_DAY_CHAT_MSG_CHARS: + content = content[: MAX_DAY_CHAT_MSG_CHARS - 3] + "..." + label = "Пользователь" if msg.role == "user" else "Ассистент" + line = f"{label}: {content}" + if total + len(line) + 1 > MAX_DAY_CHAT_EXCERPT_CHARS: + break + lines.append(line) + total += len(line) + 1 + return "\n".join(lines) + + +def _day_chat_excerpt( + db: Session, + session_id: int, + start_dt: datetime, + end_dt: datetime, +) -> str: + rows = list( + db.scalars( + select(Message) + .where( + Message.session_id == session_id, + Message.created_at >= start_dt, + Message.created_at <= end_dt, + Message.role.in_(CHAT_CONTEXT_ROLES), + ) + .order_by(Message.created_at.asc()) + ).all() + ) + if not rows: + return "" + return _format_chat_excerpt(rows[-MAX_DAY_CHAT_MESSAGES:]) + + def collect_day_context(db: Session, user_id: int, day: date, tz: ZoneInfo) -> dict[str, Any]: fitness_ctx = DayMetricsService(db, user_id).day_fitness_context(day) meals_count = fitness_ctx["meals_count"] @@ -61,22 +127,15 @@ def collect_day_context(db: Session, user_id: int, day: date, tz: ZoneInfo) -> d completed_reminders = [r.title for r in reminder_rows] session_summary_text = "" - latest_session = db.scalar( - select(ChatSession) - .where(ChatSession.user_id == user_id) - .order_by(ChatSession.updated_at.desc()) - .limit(1) - ) - if latest_session: - row = db.scalar( - select(SessionSummary).where(SessionSummary.session_id == latest_session.id) + chat_excerpt = "" + active_session_id = _day_active_session_id(db, user_id, start_dt, end_dt) + if active_session_id: + chat_excerpt = _day_chat_excerpt(db, active_session_id, start_dt, end_dt) + summary_row = db.scalar( + select(SessionSummary).where(SessionSummary.session_id == active_session_id) ) - if row and row.summary.strip() and row.updated_at: - updated = row.updated_at - if updated.tzinfo is None: - updated = updated.replace(tzinfo=timezone.utc) - if updated.astimezone(tz).date() == day: - session_summary_text = row.summary.strip()[:500] + if summary_row and summary_row.summary.strip(): + session_summary_text = summary_row.summary.strip()[:500] calorie_delta = fitness_ctx["calorie_delta"] return { @@ -94,6 +153,7 @@ def collect_day_context(db: Session, user_id: int, day: date, tz: ZoneInfo) -> d "pomodoro_tasks": pomodoro_tasks[:8], "completed_reminders": completed_reminders[:8], "session_summary": session_summary_text, + "chat_excerpt": chat_excerpt, "has_fitness_data": fitness_ctx["has_fitness_data"], } @@ -114,8 +174,10 @@ def _format_context_block(ctx: dict[str, Any]) -> str: lines.append(f"Помидоро: {ctx['pomodoro_sessions']} сессий — {tasks}") if ctx["completed_reminders"]: lines.append(f"Выполненные напоминания: {', '.join(ctx['completed_reminders'])}") - if ctx["session_summary"]: - lines.append(f"Тема чата сегодня: {ctx['session_summary']}") + if ctx.get("chat_excerpt"): + lines.append(f"Последний чат за день:\n{ctx['chat_excerpt']}") + if ctx.get("session_summary"): + lines.append(f"Сводка длинного чата (ранние сообщения): {ctx['session_summary']}") return "\n".join(lines) @@ -129,6 +191,13 @@ def _fallback_summary(ctx: dict[str, Any]) -> tuple[str, str]: ) if ctx["completed_reminders"]: parts.append(f"Закрыто напоминаний: {len(ctx['completed_reminders'])}.") + if ctx.get("chat_excerpt"): + first_user = next( + (line for line in ctx["chat_excerpt"].splitlines() if line.startswith("Пользователь:")), + "", + ) + if first_user: + parts.append(first_user.replace("Пользователь:", "В чате:").strip()) if not parts: parts.append("День прошёл спокойно — мало записей, но это тоже результат.") text = " ".join(parts) @@ -174,6 +243,7 @@ async def generate_evening_summary_content( Напиши короткую вечернюю сводку (3–5 предложений) на русском от лица персонажа: - что было сделано и изучено (помидоро, напоминания, чат); - как прошёл день по фитнесу (если есть данные); +- если в чате есть важный контекст (болезнь, усталость, события, настроение) — учти его: низкая продуктивность при болезни или отдыхе это нормально, не упрекай; - мягкая поддержка или лёгкий юмор, без морализаторства. Ответь ТОЛЬКО JSON без markdown: diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 1765a25..16d0b1e 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -6,6 +6,7 @@ from typing import Any from openai import AsyncOpenAI from app.config import get_settings +from app.llm.http_client import build_openrouter_http_client logger = logging.getLogger(__name__) @@ -14,10 +15,14 @@ class LLMClient: def __init__(self) -> None: settings = get_settings() self.tools_enabled = settings.openrouter_tools_enabled - self.client = AsyncOpenAI( - api_key=settings.openrouter_api_key, - base_url=settings.openrouter_base_url, - ) + self._http_client = build_openrouter_http_client(settings.openrouter_socks5_proxy) + client_kwargs: dict[str, Any] = { + "api_key": settings.openrouter_api_key, + "base_url": settings.openrouter_base_url, + } + if self._http_client is not None: + client_kwargs["http_client"] = self._http_client + self.client = AsyncOpenAI(**client_kwargs) async def aclose(self) -> None: await self.client.close() diff --git a/backend/app/llm/http_client.py b/backend/app/llm/http_client.py new file mode 100644 index 0000000..f7a6586 --- /dev/null +++ b/backend/app/llm/http_client.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import logging + +import httpx + +logger = logging.getLogger(__name__) + + +def build_openrouter_http_client(proxy_url: str) -> httpx.AsyncClient | None: + stripped = proxy_url.strip() + if not stripped: + return None + logger.info("OpenRouter HTTP client via SOCKS5 proxy: %s", _mask_proxy_url(stripped)) + return httpx.AsyncClient( + proxy=stripped, + timeout=httpx.Timeout(120.0, connect=30.0), + ) + + +def _mask_proxy_url(url: str) -> str: + if "@" not in url: + return url + scheme, rest = url.split("://", 1) + _, hostpart = rest.rsplit("@", 1) + return f"{scheme}://***@{hostpart}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 4235edc..18ee9e1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,7 @@ sqlalchemy>=2.0.36 pydantic-settings>=2.6.0 openai>=1.55.0 python-dotenv>=1.0.1 -httpx>=0.28.0 +httpx[socks]>=0.28.0 feedparser>=6.0.11 Pillow>=11.0.0 qdrant-client>=1.12.0,<1.13.0 diff --git a/backend/tests/test_day_metrics.py b/backend/tests/test_day_metrics.py index 1a8da54..800bd92 100644 --- a/backend/tests/test_day_metrics.py +++ b/backend/tests/test_day_metrics.py @@ -5,7 +5,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from app.db.models import Base, FitnessProfile, FoodLog, User -from app.daily.metrics import DayMetricsService, plan_hit +from app.daily.metrics import DayMetricsService, plan_hit, signed_balance class DayMetricsTests(unittest.TestCase): @@ -30,6 +30,13 @@ class DayMetricsTests(unittest.TestCase): ) db.commit() + def test_signed_balance_uses_tdee_not_calorie_target(self) -> None: + tdee = 2500.0 + calorie_target = tdee - 500 + self.assertEqual(signed_balance({"calories": 2500}, tdee, "lose"), 0) + self.assertEqual(signed_balance({"calories": calorie_target}, tdee, "lose"), 500) + self.assertEqual(signed_balance({"calories": 2800}, tdee, "gain"), 300) + def test_plan_hit_lose(self) -> None: self.assertTrue( plan_hit( diff --git a/backend/tests/test_evening_summary.py b/backend/tests/test_evening_summary.py index c227ae8..ebc91fc 100644 --- a/backend/tests/test_evening_summary.py +++ b/backend/tests/test_evening_summary.py @@ -1,6 +1,14 @@ +from datetime import date, datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import Base, ChatSession, Message, User from app.homelab.evening_summary import ( _fallback_summary, + _format_context_block, _parse_llm_payload, + collect_day_context, format_evening_notice, ) @@ -46,3 +54,62 @@ def test_format_evening_notice_text_only(): notice = format_evening_notice("Только текст.") assert "Только текст." in notice assert "![day]" not in notice + + +def test_collect_day_context_includes_chat_excerpt(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine) + tz = timezone.utc + day = date(2026, 7, 3) + msg_time = datetime(2026, 7, 3, 14, 30, tzinfo=tz) + + with SessionLocal() as db: + user = User(username="u", api_token_hash="x") + db.add(user) + db.commit() + db.refresh(user) + + session = ChatSession(user_id=user.id, title="Болею") + db.add(session) + db.commit() + db.refresh(session) + + db.add_all( + [ + Message( + session_id=session.id, + role="user", + content="Сегодня болею, температура, работать не могу", + created_at=msg_time, + ), + Message( + session_id=session.id, + role="assistant", + content="Отдыхай, поправляйся", + created_at=msg_time, + ), + ] + ) + db.commit() + + ctx = collect_day_context(db, user.id, day, tz) + + assert "болею" in ctx["chat_excerpt"].lower() + block = _format_context_block(ctx) + assert "Последний чат за день" in block + assert "болею" in block.lower() + + +def test_fallback_summary_uses_chat_context(): + ctx = { + "pomodoro_sessions": 0, + "has_fitness_data": False, + "calories": 0, + "calorie_target": 2000, + "workouts_count": 0, + "completed_reminders": [], + "chat_excerpt": "Пользователь: Сегодня болею и лежу дома", + } + text, _scene = _fallback_summary(ctx) + assert "болею" in text.lower() or "В чате" in text diff --git a/backend/tests/test_llm_proxy.py b/backend/tests/test_llm_proxy.py new file mode 100644 index 0000000..6bb99c7 --- /dev/null +++ b/backend/tests/test_llm_proxy.py @@ -0,0 +1,25 @@ +import unittest + +import httpx + +from app.llm.http_client import _mask_proxy_url, build_openrouter_http_client + + +class LlmProxyTests(unittest.TestCase): + def test_build_client_without_proxy(self) -> None: + self.assertIsNone(build_openrouter_http_client("")) + self.assertIsNone(build_openrouter_http_client(" ")) + + def test_build_client_with_proxy(self) -> None: + client = build_openrouter_http_client("socks5://127.0.0.1:1080") + self.assertIsInstance(client, httpx.AsyncClient) + + def test_mask_proxy_url(self) -> None: + self.assertEqual( + _mask_proxy_url("socks5://user:secret@proxy.local:1080"), + "socks5://***@proxy.local:1080", + ) + self.assertEqual( + _mask_proxy_url("socks5://127.0.0.1:1080"), + "socks5://127.0.0.1:1080", + )