refactor
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(homelab.router, tags=["homelab"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||
api_router.include_router(character.router, tags=["character"])
|
||||
api_router.include_router(projects.router, tags=["projects"])
|
||||
api_router.include_router(memory.router, tags=["memory"])
|
||||
api_router.include_router(fitness.router, tags=["fitness"])
|
||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
|
||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||
api_router.include_router(media.router, tags=["media"])
|
||||
@@ -1,70 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
|
||||
from app.chat.service import ChatService
|
||||
from app.db.base import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=SessionOut)
|
||||
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
|
||||
service = ChatService(db)
|
||||
return service.create_session(title=payload.title)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionOut])
|
||||
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
|
||||
service = ChatService(db)
|
||||
return service.list_sessions()
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
|
||||
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
|
||||
service = ChatService(db)
|
||||
session = service.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
||||
service = ChatService(db)
|
||||
if not service.delete_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/messages")
|
||||
async def send_message(
|
||||
session_id: int,
|
||||
payload: MessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
service = ChatService(db)
|
||||
if not service.get_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
|
||||
service.save_user_message(session_id, payload.content)
|
||||
|
||||
async def event_stream():
|
||||
async for chunk in service.stream_response(
|
||||
session_id,
|
||||
payload.content,
|
||||
user_message_saved=True,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
+11
-51
@@ -3,6 +3,13 @@ from typing import Any
|
||||
|
||||
from app.db.models import PomodoroSession
|
||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
||||
from app.tools.fitness import TOOL_NAMES as FITNESS_TOOL_NAMES
|
||||
from app.tools.homelab import TOOL_NAMES as HOMELAB_TOOL_NAMES
|
||||
from app.tools.memory import TOOL_NAMES as MEMORY_TOOL_NAMES
|
||||
from app.tools.pomodoro import TOOL_NAMES as POMODORO_TOOL_NAMES
|
||||
from app.tools.projects import TOOL_NAMES as PROJECT_TOOL_NAMES
|
||||
from app.tools.reminders import TOOL_NAMES as REMINDER_TOOL_NAMES
|
||||
from app.tools.shopping import TOOL_NAMES as SHOPPING_TOOL_NAMES
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "Работа",
|
||||
@@ -48,57 +55,6 @@ def format_phase_completed_notice(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
POMODORO_TOOL_NAMES = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
"reset_pomodoro_cycle",
|
||||
"get_pomodoro_history",
|
||||
})
|
||||
|
||||
MEMORY_TOOL_NAMES = frozenset({
|
||||
"remember_fact",
|
||||
"recall_memories",
|
||||
"forget_memory",
|
||||
"update_profile",
|
||||
"update_session_summary",
|
||||
})
|
||||
|
||||
FITNESS_TOOL_NAMES = frozenset({
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"set_fitness_profile",
|
||||
"calc_fitness_targets",
|
||||
"calc_body_composition",
|
||||
"log_meal",
|
||||
"log_water",
|
||||
"log_weight",
|
||||
"log_workout",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"set_fitness_reminder",
|
||||
})
|
||||
|
||||
# Не засорять чат служебными ответами
|
||||
REMINDER_TOOL_NAMES = frozenset({
|
||||
"list_reminders",
|
||||
"create_reminder",
|
||||
"update_reminder",
|
||||
"delete_reminder",
|
||||
"complete_reminder",
|
||||
})
|
||||
|
||||
SHOPPING_TOOL_NAMES = frozenset({
|
||||
"list_shopping_lists",
|
||||
"create_shopping_list",
|
||||
"add_shopping_items",
|
||||
"check_shopping_item",
|
||||
"remove_shopping_item",
|
||||
"delete_shopping_list",
|
||||
})
|
||||
|
||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||
"get_pomodoro_status",
|
||||
@@ -156,6 +112,10 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
prefix = "🛒"
|
||||
elif tool_name in REMINDER_TOOL_NAMES:
|
||||
prefix = "📅"
|
||||
elif tool_name in PROJECT_TOOL_NAMES:
|
||||
prefix = "📋"
|
||||
elif tool_name in HOMELAB_TOOL_NAMES:
|
||||
prefix = "🏠"
|
||||
else:
|
||||
prefix = "📋"
|
||||
return f"{prefix} {data['error']}"
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import SessionLocal
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
|
||||
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
|
||||
from app.chat.notices import (
|
||||
POMODORO_TOOL_NAMES,
|
||||
format_pomodoro_context,
|
||||
format_tool_notice,
|
||||
)
|
||||
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||
from app.homelab.context import format_datetime_context
|
||||
from app.homelab.openmeteo import format_weather_snapshot
|
||||
from app.memory.context import (
|
||||
format_identity_hint,
|
||||
format_memory_context,
|
||||
get_memory_snapshot,
|
||||
)
|
||||
from app.memory.extract import extract_after_turn
|
||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||
from app.reminders.context import format_reminders_context, get_reminders_snapshot
|
||||
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||
from app.db.models import ChatSession, Message
|
||||
from app.llm.client import LLMClient
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
MAX_HISTORY_MESSAGES = 40
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
service = ChatService(db)
|
||||
session = service.get_session(session_id)
|
||||
if not session:
|
||||
return []
|
||||
return service._build_messages(session)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _extract_memory_background(
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
await extract_after_turn(db, session_id, user_text, assistant_text)
|
||||
except Exception as exc:
|
||||
logger.warning("Background memory extraction failed: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class ChatService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService()
|
||||
|
||||
def list_sessions(self) -> list[ChatSession]:
|
||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_session(self, session_id: int) -> ChatSession | None:
|
||||
return self.db.get(ChatSession, session_id)
|
||||
|
||||
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
||||
session = ChatSession(title=title)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: int) -> bool:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
self.db.delete(session)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
||||
status = PomodoroService(self.db).get_status()
|
||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||
shopping_snapshot = get_shopping_snapshot(self.db)
|
||||
reminders_snapshot = get_reminders_snapshot(self.db)
|
||||
projects_snapshot = get_projects_snapshot(self.db)
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
f"{format_datetime_context(self.db)}\n\n"
|
||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||
f"{format_shopping_context(shopping_snapshot)}\n\n"
|
||||
f"{format_reminders_context(reminders_snapshot)}\n\n"
|
||||
f"{format_weather_snapshot()}\n\n"
|
||||
f"{format_pomodoro_context(status)}\n\n"
|
||||
f"{format_projects_context(projects_snapshot)}"
|
||||
)
|
||||
|
||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||
system_prompt = self._build_system_prompt(session.id)
|
||||
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||
if last_user:
|
||||
memory_snapshot = get_memory_snapshot(self.db, session.id)
|
||||
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
||||
if identity_hint:
|
||||
system_prompt += f"\n\n{identity_hint}"
|
||||
if len(all_chat) > MAX_HISTORY_MESSAGES:
|
||||
system_prompt += (
|
||||
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
|
||||
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
|
||||
)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
|
||||
|
||||
for msg in chat_messages:
|
||||
content = msg.content or None
|
||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||
if msg.tool_calls_json:
|
||||
entry["tool_calls"] = json.loads(msg.tool_calls_json)
|
||||
if not content:
|
||||
entry["content"] = None
|
||||
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
|
||||
if reasoning_data:
|
||||
LLMClient.attach_reasoning_to_message(
|
||||
entry,
|
||||
reasoning=reasoning_data.get("reasoning", ""),
|
||||
reasoning_details=reasoning_data.get("reasoning_details"),
|
||||
)
|
||||
if msg.role == "tool" and msg.tool_call_id:
|
||||
entry["tool_call_id"] = msg.tool_call_id
|
||||
messages.append(entry)
|
||||
messages = sanitize_openai_messages(messages)
|
||||
messages = strip_historical_reasoning(messages)
|
||||
return messages
|
||||
|
||||
def _save_message(
|
||||
self,
|
||||
session_id: int,
|
||||
role: str,
|
||||
content: str = "",
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
reasoning_json: str | None = None,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=content,
|
||||
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
|
||||
reasoning_json=reasoning_json,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
self.db.add(message)
|
||||
session = self.get_session(session_id)
|
||||
if session and role == "user" and session.title == "Новый чат" and content:
|
||||
session.title = content[:60] + ("..." if len(content) > 60 else "")
|
||||
self.db.commit()
|
||||
self.db.refresh(message)
|
||||
return message
|
||||
|
||||
def save_user_message(self, session_id: int, user_text: str) -> None:
|
||||
self._save_message(session_id, "user", user_text)
|
||||
|
||||
async def _fallback_complete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
session_id: int,
|
||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""Нестриминговый запасной путь, если stream вернул пустоту."""
|
||||
logger.info("chat session=%s fallback complete", session_id)
|
||||
result: dict[str, Any] = {"content": "", "tool_calls": []}
|
||||
for with_tools in (True, False):
|
||||
result = await self.llm.complete(
|
||||
messages,
|
||||
tools=TOOL_DEFINITIONS if with_tools else None,
|
||||
temperature=0.5,
|
||||
visible_reply=True,
|
||||
)
|
||||
if (result.get("content") or "").strip() or result.get("tool_calls"):
|
||||
break
|
||||
|
||||
tool_calls = result.get("tool_calls") or []
|
||||
content = (result.get("content") or "").strip()
|
||||
notices: list[str] = []
|
||||
pomodoro_events: list[dict[str, Any]] = []
|
||||
|
||||
if tool_calls:
|
||||
assistant_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": content or None,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
messages.append(assistant_msg)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"assistant",
|
||||
content,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
for tool_call in tool_calls:
|
||||
fn = tool_call["function"]
|
||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||
tool_result = await execute_tool(
|
||||
self.db, fn["name"], args, session_id=session_id
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": tool_result,
|
||||
}
|
||||
)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"tool",
|
||||
tool_result,
|
||||
tool_call_id=tool_call["id"],
|
||||
)
|
||||
notice = format_tool_notice(fn["name"], tool_result)
|
||||
if notice:
|
||||
self._save_message(session_id, "notice", notice)
|
||||
notices.append(notice)
|
||||
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||
pomodoro_events.append(
|
||||
{"name": fn["name"], "result": json.loads(tool_result)}
|
||||
)
|
||||
|
||||
followup = await self.llm.complete(
|
||||
messages,
|
||||
tools=None,
|
||||
temperature=0.4,
|
||||
visible_reply=True,
|
||||
)
|
||||
return (followup.get("content") or "").strip(), notices, pomodoro_events
|
||||
|
||||
return content, notices, pomodoro_events
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
*,
|
||||
user_message_saved: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
yield self._sse("error", {"message": "Session not found"})
|
||||
return
|
||||
|
||||
if not user_message_saved:
|
||||
self._save_message(session_id, "user", user_text)
|
||||
yield self._sse("status", {"phase": "preparing"})
|
||||
t0 = time.monotonic()
|
||||
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
|
||||
prepare_sec = time.monotonic() - t0
|
||||
if not messages:
|
||||
yield self._sse("error", {"message": "Session not found"})
|
||||
return
|
||||
yield self._sse("status", {"phase": "generating"})
|
||||
streamed_reply_parts: list[str] = []
|
||||
all_tool_notices: list[str] = []
|
||||
tools_executed = 0
|
||||
tool_round = 0
|
||||
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
tool_round += 1
|
||||
t_round = time.monotonic()
|
||||
content_parts: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
reasoning = ""
|
||||
reasoning_details: list[Any] | None = None
|
||||
finish_reason = ""
|
||||
|
||||
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
|
||||
stream_live = tools_executed > 0
|
||||
|
||||
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
|
||||
if event["type"] == "content":
|
||||
content_parts.append(event["content"])
|
||||
if stream_live:
|
||||
yield self._sse("token", {"content": event["content"]})
|
||||
elif event["type"] == "reasoning":
|
||||
reasoning = event.get("reasoning", "") or reasoning
|
||||
if event.get("reasoning_details"):
|
||||
reasoning_details = event["reasoning_details"]
|
||||
elif event["type"] == "error":
|
||||
logger.warning(
|
||||
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
|
||||
session_id,
|
||||
tool_round,
|
||||
prepare_sec,
|
||||
event.get("content"),
|
||||
)
|
||||
yield self._sse("error", {"message": event.get("content", "LLM error")})
|
||||
return
|
||||
elif event["type"] == "tool_calls":
|
||||
tool_calls = event["tool_calls"]
|
||||
elif event["type"] == "done":
|
||||
finish_reason = event.get("finish_reason", "")
|
||||
|
||||
logger.info(
|
||||
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
|
||||
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
|
||||
session_id,
|
||||
tool_round,
|
||||
prepare_sec,
|
||||
time.monotonic() - t_round,
|
||||
len("".join(content_parts)),
|
||||
len(tool_calls),
|
||||
finish_reason,
|
||||
len(reasoning),
|
||||
)
|
||||
|
||||
if tool_calls:
|
||||
round_text = "".join(content_parts)
|
||||
if round_text.strip():
|
||||
streamed_reply_parts.append(round_text)
|
||||
|
||||
assistant_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": round_text or None,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
LLMClient.attach_reasoning_to_message(
|
||||
assistant_msg,
|
||||
reasoning=reasoning,
|
||||
reasoning_details=reasoning_details,
|
||||
)
|
||||
reasoning_json = LLMClient.serialize_reasoning(
|
||||
reasoning=reasoning,
|
||||
reasoning_details=reasoning_details,
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
self._save_message(
|
||||
session_id,
|
||||
"assistant",
|
||||
round_text,
|
||||
tool_calls=tool_calls,
|
||||
reasoning_json=reasoning_json,
|
||||
)
|
||||
|
||||
round_notices: list[str] = []
|
||||
for tool_call in tool_calls:
|
||||
fn = tool_call["function"]
|
||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||
result = await execute_tool(
|
||||
self.db, fn["name"], args, session_id=session_id
|
||||
)
|
||||
tools_executed += 1
|
||||
tool_message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": result,
|
||||
}
|
||||
messages.append(tool_message)
|
||||
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
||||
|
||||
notice = format_tool_notice(fn["name"], result)
|
||||
if notice:
|
||||
self._save_message(session_id, "notice", notice)
|
||||
round_notices.append(notice)
|
||||
all_tool_notices.append(notice)
|
||||
|
||||
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||
yield self._sse(
|
||||
"pomodoro",
|
||||
{"name": fn["name"], "result": json.loads(result)},
|
||||
)
|
||||
|
||||
yield self._sse("status", {"phase": "tools"})
|
||||
for notice in round_notices:
|
||||
yield self._sse("notice", {"content": notice})
|
||||
|
||||
continue
|
||||
|
||||
if content_parts and not stream_live:
|
||||
for part in content_parts:
|
||||
yield self._sse("token", {"content": part})
|
||||
|
||||
final_content = "".join(content_parts).strip()
|
||||
if not final_content and streamed_reply_parts and tools_executed == 0:
|
||||
final_content = "".join(streamed_reply_parts).strip()
|
||||
if not final_content and reasoning:
|
||||
final_content = reasoning.strip()
|
||||
if not final_content and tools_executed:
|
||||
retry = await self.llm.complete(
|
||||
messages,
|
||||
tools=None,
|
||||
temperature=0.4,
|
||||
visible_reply=True,
|
||||
)
|
||||
final_content = (retry.get("content") or "").strip()
|
||||
if final_content:
|
||||
yield self._sse("token", {"content": final_content})
|
||||
# Notices уже в чате как role=notice — не дублируем в assistant.
|
||||
if not final_content:
|
||||
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
|
||||
messages, session_id
|
||||
)
|
||||
if final_content:
|
||||
yield self._sse("token", {"content": final_content})
|
||||
for notice in fb_notices:
|
||||
yield self._sse("notice", {"content": notice})
|
||||
for event in fb_pomodoro:
|
||||
yield self._sse("pomodoro", event)
|
||||
|
||||
if not final_content:
|
||||
logger.warning(
|
||||
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
|
||||
session_id,
|
||||
tools_executed,
|
||||
tool_round,
|
||||
finish_reason,
|
||||
)
|
||||
yield self._sse(
|
||||
"error",
|
||||
{
|
||||
"message": (
|
||||
"Модель не вернула ответ (finish_reason="
|
||||
f"{finish_reason or 'unknown'}). "
|
||||
"Попробуй новый чат или проверь OPENROUTER_MODEL."
|
||||
),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
self._save_message(session_id, "assistant", final_content)
|
||||
|
||||
logger.info(
|
||||
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
|
||||
session_id,
|
||||
tools_executed,
|
||||
len(final_content),
|
||||
time.monotonic() - t0,
|
||||
)
|
||||
yield self._sse("done", {})
|
||||
if get_settings().memory_auto_extract:
|
||||
asyncio.create_task(
|
||||
_extract_memory_background(session_id, user_text, final_content)
|
||||
)
|
||||
return
|
||||
|
||||
yield self._sse("error", {"message": "Too many tool call rounds"})
|
||||
|
||||
@staticmethod
|
||||
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
@@ -66,11 +66,11 @@ class Settings(BaseSettings):
|
||||
taiga_base_url: str = "http://host.docker.internal:9000"
|
||||
taiga_username: str = ""
|
||||
taiga_password: str = ""
|
||||
taiga_public_url: str = "https://taiga.grigowashere.ru"
|
||||
taiga_public_url: str = "https://taiga.example.com"
|
||||
|
||||
gitea_base_url: str = "http://host.docker.internal:3000"
|
||||
gitea_token: str = ""
|
||||
gitea_public_url: str = "https://git.grigowashere.ru"
|
||||
gitea_public_url: str = "https://git.example.com"
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
repos_dir: str = "/data/repos"
|
||||
@@ -80,7 +80,7 @@ class Settings(BaseSettings):
|
||||
fitness_reminders_enabled: bool = True
|
||||
reminders_enabled: bool = True
|
||||
|
||||
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||
openmeteo_base_url: str = "http://host.docker.internal:8085"
|
||||
weather_lat: float = 59.9343
|
||||
weather_lon: float = 30.3351
|
||||
weather_location_name: str = "Санкт-Петербург"
|
||||
@@ -100,7 +100,7 @@ class Settings(BaseSettings):
|
||||
morning_digest_hour: int = 8
|
||||
morning_digest_minute: int = 0
|
||||
|
||||
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||
comfyui_base_url: str = "http://host.docker.internal:8188"
|
||||
comfyui_enabled: bool = True
|
||||
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||
comfyui_checkpoint: str = ""
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", "../.env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_model: str = "deepseek/deepseek-chat"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
|
||||
memory_extract_model: str = ""
|
||||
# Некоторые модели (reasoning / без function calling) — выключить tools.
|
||||
openrouter_tools_enabled: bool = True
|
||||
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
|
||||
openrouter_reasoning_effort: str = "none"
|
||||
|
||||
database_url: str = "sqlite:///./data/assistant.db"
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
|
||||
system_prompt_path: str = "./prompts/assistant.md"
|
||||
memory_auto_extract: bool = True
|
||||
|
||||
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
||||
taiga_base_url: str = "http://host.docker.internal:9000"
|
||||
taiga_username: str = ""
|
||||
taiga_password: str = ""
|
||||
taiga_public_url: str = "https://taiga.grigowashere.ru"
|
||||
|
||||
gitea_base_url: str = "http://host.docker.internal:3000"
|
||||
gitea_token: str = ""
|
||||
gitea_public_url: str = "https://git.grigowashere.ru"
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
repos_dir: str = "/data/repos"
|
||||
|
||||
wger_base_url: str = "https://wger.de/api/v2"
|
||||
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||
fitness_reminders_enabled: bool = True
|
||||
reminders_enabled: bool = True
|
||||
|
||||
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||
weather_lat: float = 59.9343
|
||||
weather_lon: float = 30.3351
|
||||
weather_location_name: str = "Санкт-Петербург"
|
||||
weather_cache_sec: int = 300
|
||||
|
||||
news_rss_urls: str = (
|
||||
"https://habr.com/ru/rss/all/all/,"
|
||||
"https://www.reddit.com/r/programming/.rss"
|
||||
)
|
||||
news_cache_sec: int = 1800
|
||||
news_max_items: int = 7
|
||||
|
||||
morning_digest_enabled: bool = True
|
||||
morning_digest_hour: int = 8
|
||||
morning_digest_minute: int = 0
|
||||
|
||||
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||
comfyui_enabled: bool = True
|
||||
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||
comfyui_checkpoint: str = ""
|
||||
comfyui_unet: str = "anima-preview3-base.safetensors"
|
||||
comfyui_clip: str = "qwen_3_06b_base.safetensors"
|
||||
comfyui_vae: str = "qwen_image_vae.safetensors"
|
||||
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
|
||||
comfyui_style_lora_weight: float = 0.7
|
||||
comfyui_steps: int = 30
|
||||
comfyui_cfg: float = 4.0
|
||||
comfyui_sampler: str = "er_sde"
|
||||
comfyui_scheduler: str = "simple"
|
||||
comfyui_width: int = 1024
|
||||
comfyui_height: int = 720
|
||||
comfyui_negative_prompt: str = (
|
||||
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||
)
|
||||
comfyui_poll_interval_sec: float = 2.0
|
||||
comfyui_timeout_sec: float = 180.0
|
||||
comfyui_rofl_enabled: bool = True
|
||||
comfyui_rofl_max_per_day: int = 1
|
||||
comfyui_rofl_probability: float = 0.15
|
||||
comfyui_rofl_min_interval_hours: int = 12
|
||||
generated_media_dir: str = "./data/generated"
|
||||
|
||||
netdata_base_url: str = "http://host.docker.internal:19999"
|
||||
netdata_public_url: str = ""
|
||||
netdata_alerts_enabled: bool = True
|
||||
netdata_poll_interval_sec: int = 120
|
||||
|
||||
rp_chat_base_url: str = "http://host.docker.internal:8201"
|
||||
rp_chat_enabled: bool = True
|
||||
rp_chat_timeout_sec: float = 300.0
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def taiga_configured(self) -> bool:
|
||||
return bool(self.taiga_username and self.taiga_password)
|
||||
|
||||
@property
|
||||
def gitea_configured(self) -> bool:
|
||||
return bool(self.gitea_token)
|
||||
|
||||
@property
|
||||
def news_rss_urls_list(self) -> list[str]:
|
||||
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
|
||||
|
||||
def load_system_prompt(self) -> str:
|
||||
path = Path(self.system_prompt_path)
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return "Ты домашний ИИ-ассистент. Общайся на русском."
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
|
||||
def dialect_name(engine: Engine) -> str:
|
||||
return engine.dialect.name
|
||||
|
||||
|
||||
def is_sqlite(engine: Engine) -> bool:
|
||||
return dialect_name(engine) == "sqlite"
|
||||
|
||||
|
||||
def is_postgresql(engine: Engine) -> bool:
|
||||
return dialect_name(engine) == "postgresql"
|
||||
|
||||
|
||||
def bool_literal(engine: Engine, value: bool = False) -> str:
|
||||
if is_sqlite(engine):
|
||||
return "1" if value else "0"
|
||||
return "true" if value else "false"
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.db.base import engine
|
||||
from app.db.dialect import bool_literal
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
@@ -17,7 +18,7 @@ def run_migrations() -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE pomodoro_sessions "
|
||||
"ADD COLUMN completion_notified BOOLEAN DEFAULT 0"
|
||||
f"ADD COLUMN completion_notified BOOLEAN DEFAULT {bool_literal(engine, False)}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import engine
|
||||
from app.db.models import FitnessProfile
|
||||
from app.db.models import FitnessProfile, StepLog
|
||||
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -173,19 +173,7 @@ def run_fitness_migrations() -> None:
|
||||
)
|
||||
|
||||
if "step_logs" not in inspector.get_table_names():
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE step_logs ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"steps INTEGER DEFAULT 0, "
|
||||
"active_calories FLOAT, "
|
||||
"source VARCHAR(32) DEFAULT 'manual', "
|
||||
"notes TEXT DEFAULT ''"
|
||||
")"
|
||||
)
|
||||
)
|
||||
StepLog.__table__.create(engine, checkfirst=True)
|
||||
|
||||
if "body_metrics" in inspector.get_table_names():
|
||||
_add_column_if_missing(
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.auth.tokens import hash_token
|
||||
from app.character.card import DEFAULT_CARD, normalize_card
|
||||
from app.config import get_settings
|
||||
from app.db.base import engine
|
||||
from app.db.models import CharacterCard, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,39 +56,11 @@ def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||
|
||||
|
||||
def _ensure_users_table() -> None:
|
||||
if _table_exists("users"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE users ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"username VARCHAR(64) NOT NULL UNIQUE, "
|
||||
"display_name VARCHAR(255) DEFAULT '', "
|
||||
"api_token_hash VARCHAR(64) NOT NULL, "
|
||||
"is_active BOOLEAN DEFAULT 1, "
|
||||
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
|
||||
User.__table__.create(engine, checkfirst=True)
|
||||
|
||||
|
||||
def _ensure_character_cards_table() -> None:
|
||||
if _table_exists("character_cards"):
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE character_cards ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
|
||||
"card_json TEXT DEFAULT '{}', "
|
||||
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
|
||||
CharacterCard.__table__.create(engine, checkfirst=True)
|
||||
|
||||
|
||||
def _add_user_id_columns() -> None:
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(32))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PomodoroCycle(Base):
|
||||
__tablename__ = "pomodoro_cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TaigaProject(Base):
|
||||
__tablename__ = "taiga_projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectBinding(Base):
|
||||
__tablename__ = "project_bindings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class MemoryFact(Base):
|
||||
__tablename__ = "memory_facts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||
session_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SessionSummary(Base):
|
||||
__tablename__ = "session_summaries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class FitnessProfile(Base):
|
||||
__tablename__ = "fitness_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class BodyMetric(Base):
|
||||
__tablename__ = "body_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
weight_kg: Mapped[float] = mapped_column(Float)
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class FoodLog(Base):
|
||||
__tablename__ = "food_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class WaterLog(Base):
|
||||
__tablename__ = "water_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class WorkoutLog(Base):
|
||||
__tablename__ = "workout_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
|
||||
|
||||
class FitnessReminder(Base):
|
||||
__tablename__ = "fitness_reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ShoppingList(Base):
|
||||
__tablename__ = "shopping_lists"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListItem(Base):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
list_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
text: Mapped[str] = mapped_column(String(500))
|
||||
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class AssistantState(Base):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -18,7 +18,7 @@ class RssClient:
|
||||
self.max_items = settings.news_max_items
|
||||
|
||||
def _fetch_feed(self, url: str) -> list[dict[str, str]]:
|
||||
headers = {"User-Agent": "HomeAIAssistant/1.0 (+https://assistant.grigowashere.ru)"}
|
||||
headers = {"User-Agent": "HomeAIAssistant/1.0"}
|
||||
with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.model = settings.openrouter_model
|
||||
self.tools_enabled = settings.openrouter_tools_enabled
|
||||
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=settings.openrouter_api_key,
|
||||
base_url=settings.openrouter_base_url,
|
||||
)
|
||||
|
||||
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
||||
if not self.reasoning_effort:
|
||||
return None
|
||||
if self.reasoning_effort == "none":
|
||||
return {"reasoning": {"effort": "none", "exclude": True}}
|
||||
return {"reasoning": {"effort": self.reasoning_effort}}
|
||||
|
||||
@staticmethod
|
||||
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
|
||||
parts: list[str] = []
|
||||
for attr in ("reasoning", "reasoning_content"):
|
||||
value = getattr(delta, attr, None)
|
||||
if value:
|
||||
parts.append(str(value))
|
||||
|
||||
details: list[Any] = []
|
||||
raw_details = getattr(delta, "reasoning_details", None)
|
||||
if raw_details:
|
||||
if isinstance(raw_details, list):
|
||||
details.extend(raw_details)
|
||||
else:
|
||||
details.append(raw_details)
|
||||
|
||||
return "".join(parts), details
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
|
||||
if not details:
|
||||
return None
|
||||
items = details if isinstance(details, list) else [details]
|
||||
normalized: list[Any] = []
|
||||
for item in items:
|
||||
if hasattr(item, "model_dump"):
|
||||
normalized.append(item.model_dump())
|
||||
elif isinstance(item, dict):
|
||||
normalized.append(item)
|
||||
else:
|
||||
normalized.append(item)
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def attach_reasoning_to_message(
|
||||
message: dict[str, Any],
|
||||
*,
|
||||
reasoning: str = "",
|
||||
reasoning_details: list[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if reasoning:
|
||||
message["reasoning"] = reasoning
|
||||
message["reasoning_content"] = reasoning
|
||||
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
|
||||
if normalized:
|
||||
message["reasoning_details"] = normalized
|
||||
return message
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
model: str | None = None,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
use_tools = bool(tools) and self.tools_enabled
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
if use_tools:
|
||||
kwargs["tools"] = tools
|
||||
extra_body = self._reasoning_extra_body()
|
||||
if extra_body:
|
||||
kwargs["extra_body"] = extra_body
|
||||
|
||||
try:
|
||||
stream = await self.client.chat.completions.create(**kwargs)
|
||||
except Exception as exc:
|
||||
logger.exception("LLM stream failed: %s", exc)
|
||||
yield {"type": "error", "content": str(exc)}
|
||||
yield {"type": "done", "finish_reason": "error"}
|
||||
return
|
||||
|
||||
tool_calls: dict[int, dict[str, Any]] = {}
|
||||
reasoning_parts: list[str] = []
|
||||
reasoning_details: list[Any] = []
|
||||
|
||||
try:
|
||||
async for chunk in stream:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
if delta.content:
|
||||
yield {"type": "content", "content": delta.content}
|
||||
|
||||
reasoning_text, details = self._delta_reasoning(delta)
|
||||
if reasoning_text:
|
||||
reasoning_parts.append(reasoning_text)
|
||||
if details:
|
||||
reasoning_details.extend(details)
|
||||
|
||||
if delta.tool_calls:
|
||||
for tool_call in delta.tool_calls:
|
||||
idx = tool_call.index
|
||||
if idx not in tool_calls:
|
||||
tool_calls[idx] = {
|
||||
"id": tool_call.id or "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""},
|
||||
}
|
||||
if tool_call.id:
|
||||
tool_calls[idx]["id"] = tool_call.id
|
||||
if tool_call.function:
|
||||
if tool_call.function.name:
|
||||
tool_calls[idx]["function"]["name"] = tool_call.function.name
|
||||
if tool_call.function.arguments:
|
||||
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
||||
|
||||
if choice.finish_reason:
|
||||
reasoning = "".join(reasoning_parts)
|
||||
normalized_details = self._normalize_reasoning_details(reasoning_details)
|
||||
if reasoning or normalized_details:
|
||||
yield {
|
||||
"type": "reasoning",
|
||||
"reasoning": reasoning,
|
||||
"reasoning_details": normalized_details,
|
||||
}
|
||||
if tool_calls:
|
||||
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
|
||||
logger.info(
|
||||
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
|
||||
"content_in_stream=%d reasoning_len=%d",
|
||||
model or self.model,
|
||||
choice.finish_reason,
|
||||
len(tool_calls),
|
||||
len(reasoning_parts),
|
||||
len(reasoning),
|
||||
)
|
||||
yield {"type": "done", "finish_reason": choice.finish_reason}
|
||||
except Exception as exc:
|
||||
logger.exception("LLM stream read failed: %s", exc)
|
||||
yield {"type": "error", "content": str(exc)}
|
||||
yield {"type": "done", "finish_reason": "error"}
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
temperature: float = 0.7,
|
||||
model: str | None = None,
|
||||
for_extraction: bool = False,
|
||||
visible_reply: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
use_tools = bool(tools) and self.tools_enabled and not for_extraction
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if use_tools:
|
||||
kwargs["tools"] = tools
|
||||
if for_extraction:
|
||||
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
|
||||
else:
|
||||
extra_body = self._reasoning_extra_body()
|
||||
if extra_body:
|
||||
kwargs["extra_body"] = extra_body
|
||||
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
message = response.choices[0].message
|
||||
|
||||
content = message.content or ""
|
||||
reasoning = ""
|
||||
for attr in ("reasoning", "reasoning_content"):
|
||||
value = getattr(message, attr, None)
|
||||
if value:
|
||||
reasoning = str(value)
|
||||
break
|
||||
|
||||
if not content and reasoning and not visible_reply:
|
||||
content = reasoning
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"content": content,
|
||||
"tool_calls": [],
|
||||
"reasoning": reasoning,
|
||||
"reasoning_details": getattr(message, "reasoning_details", None),
|
||||
}
|
||||
|
||||
if message.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in message.tool_calls
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
|
||||
if not arguments:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def serialize_reasoning(
|
||||
*,
|
||||
reasoning: str = "",
|
||||
reasoning_details: list[Any] | None = None,
|
||||
) -> str | None:
|
||||
payload: dict[str, Any] = {}
|
||||
if reasoning:
|
||||
payload["reasoning"] = reasoning
|
||||
payload["reasoning_content"] = reasoning
|
||||
if reasoning_details:
|
||||
payload["reasoning_details"] = reasoning_details
|
||||
if not payload:
|
||||
return None
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {"reasoning": raw}
|
||||
if isinstance(data, str):
|
||||
return {"reasoning": data, "reasoning_content": data}
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return {}
|
||||
@@ -1,54 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import api_router
|
||||
from app.config import get_settings
|
||||
from app.db.base import init_db
|
||||
from app.fitness.watcher import fitness_watcher_loop
|
||||
from app.homelab.watcher import homelab_watcher_loop
|
||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||
from app.reminders.watcher import reminders_watcher_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
init_db()
|
||||
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||
reminders_task = asyncio.create_task(reminders_watcher_loop())
|
||||
yield
|
||||
pomodoro_task.cancel()
|
||||
fitness_task.cancel()
|
||||
homelab_task.cancel()
|
||||
reminders_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await pomodoro_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await fitness_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await homelab_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await reminders_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -1,83 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.memory.service import MemoryService
|
||||
|
||||
from app.memory.parse import is_identity_question
|
||||
|
||||
MAX_FACTS_IN_CONTEXT = 25
|
||||
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
|
||||
|
||||
|
||||
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]:
|
||||
return MemoryService(db).snapshot(session_id)
|
||||
|
||||
|
||||
def format_memory_context(snapshot: dict[str, Any]) -> str:
|
||||
lines = ["[Память и профиль — долгосрочный контекст]"]
|
||||
|
||||
profile = snapshot.get("profile") or {}
|
||||
profile_lines = []
|
||||
for key in PROFILE_KEYS:
|
||||
value = (profile.get(key) or "").strip()
|
||||
if value:
|
||||
profile_lines.append(f"- {key}: {value}")
|
||||
if profile_lines:
|
||||
lines.append("Профиль пользователя:")
|
||||
lines.extend(profile_lines)
|
||||
else:
|
||||
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
|
||||
|
||||
summary = (snapshot.get("session_summary") or "").strip()
|
||||
if summary:
|
||||
lines.append("")
|
||||
lines.append("Сводка текущего чата (ранние сообщения):")
|
||||
lines.append(summary)
|
||||
|
||||
facts = snapshot.get("facts") or []
|
||||
if facts:
|
||||
lines.append("")
|
||||
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
|
||||
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
|
||||
lines.append(
|
||||
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
||||
)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("Запомненные факты: пока нет.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Правила памяти: "
|
||||
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
|
||||
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
|
||||
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
|
||||
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
|
||||
"«забудь #N» → forget_memory. "
|
||||
"Длинный чат — update_session_summary."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
|
||||
if not is_identity_question(user_text):
|
||||
return ""
|
||||
|
||||
profile = snapshot.get("profile") or {}
|
||||
facts = snapshot.get("facts") or []
|
||||
lines = [
|
||||
"[Вопрос об идентичности пользователя]",
|
||||
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
|
||||
]
|
||||
name = (profile.get("name") or "").strip()
|
||||
age = (profile.get("age") or "").strip()
|
||||
if name:
|
||||
lines.append(f"Имя: {name}")
|
||||
if age:
|
||||
lines.append(f"Возраст: {age} лет")
|
||||
for fact in facts:
|
||||
lines.append(f"Факт: {fact.get('content')}")
|
||||
if not name and not age and not facts:
|
||||
lines.append("Данных нет — скажи, что не помнишь.")
|
||||
return "\n".join(lines)
|
||||
@@ -1,228 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import MemoryFact, SessionSummary, UserProfile
|
||||
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
|
||||
|
||||
DEFAULT_PROFILE: dict[str, Any] = {
|
||||
"name": "",
|
||||
"age": "",
|
||||
"timezone": "",
|
||||
"language": "ru",
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
|
||||
class MemoryService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_profile(self) -> dict[str, Any]:
|
||||
row = self.db.scalar(select(UserProfile).limit(1))
|
||||
if not row:
|
||||
return dict(DEFAULT_PROFILE)
|
||||
try:
|
||||
data = json.loads(row.data_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
merged = dict(DEFAULT_PROFILE)
|
||||
merged.update(data)
|
||||
return merged
|
||||
|
||||
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
row = self.db.scalar(select(UserProfile).limit(1))
|
||||
if not row:
|
||||
row = UserProfile(data_json="{}")
|
||||
self.db.add(row)
|
||||
self.db.flush()
|
||||
|
||||
current = self.get_profile()
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
else:
|
||||
current[key] = value
|
||||
|
||||
row.data_json = json.dumps(current, ensure_ascii=False)
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
return {"ok": True, "profile": current}
|
||||
|
||||
def _find_similar_fact(self, text: str) -> MemoryFact | None:
|
||||
for fact in self.db.scalars(
|
||||
select(MemoryFact).where(MemoryFact.active.is_(True))
|
||||
):
|
||||
if texts_are_similar(fact.content, text):
|
||||
return fact
|
||||
return None
|
||||
|
||||
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
|
||||
parsed = parse_identity(text)
|
||||
if not parsed:
|
||||
return None
|
||||
return self.update_profile(parsed)
|
||||
|
||||
def remember_fact(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
category: str = "fact",
|
||||
source: str = "user",
|
||||
session_id: int | None = None,
|
||||
importance: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
text = content.strip()
|
||||
if not text:
|
||||
raise ValueError("Пустой факт")
|
||||
|
||||
profile_sync = self._sync_identity_to_profile(text)
|
||||
|
||||
existing = self._find_similar_fact(text)
|
||||
if existing:
|
||||
if len(text) > len(existing.content):
|
||||
existing.content = text[:2000]
|
||||
existing.category = category or existing.category
|
||||
existing.importance = max(existing.importance, min(5, max(1, importance)))
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
if session_id:
|
||||
existing.session_id = session_id
|
||||
self.db.commit()
|
||||
result = {
|
||||
"ok": True,
|
||||
"action": "updated",
|
||||
"memory_id": existing.id,
|
||||
"content": existing.content,
|
||||
"category": existing.category,
|
||||
}
|
||||
if profile_sync:
|
||||
result["profile"] = profile_sync.get("profile")
|
||||
return result
|
||||
|
||||
fact = MemoryFact(
|
||||
category=(category or "fact")[:64],
|
||||
content=text[:2000],
|
||||
source=source[:32],
|
||||
session_id=session_id,
|
||||
importance=min(5, max(1, importance)),
|
||||
)
|
||||
self.db.add(fact)
|
||||
self.db.commit()
|
||||
self.db.refresh(fact)
|
||||
result = {
|
||||
"ok": True,
|
||||
"action": "created",
|
||||
"memory_id": fact.id,
|
||||
"content": fact.content,
|
||||
"category": fact.category,
|
||||
}
|
||||
if profile_sync:
|
||||
result["profile"] = profile_sync.get("profile")
|
||||
return result
|
||||
|
||||
def recall_memories(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
category: str | None = None,
|
||||
limit: int = 20,
|
||||
active_only: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(MemoryFact).order_by(
|
||||
MemoryFact.importance.desc(),
|
||||
MemoryFact.updated_at.desc(),
|
||||
)
|
||||
if active_only:
|
||||
stmt = stmt.where(MemoryFact.active.is_(True))
|
||||
if category:
|
||||
stmt = stmt.where(MemoryFact.category == category)
|
||||
facts = self.db.scalars(stmt.limit(100)).all()
|
||||
if query:
|
||||
qnorm = normalize_text(query)
|
||||
facts = [
|
||||
f
|
||||
for f in facts
|
||||
if qnorm in normalize_text(f.content)
|
||||
or qnorm in normalize_text(f.category)
|
||||
]
|
||||
facts = facts[: min(limit, 50)]
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"category": f.category,
|
||||
"content": f.content,
|
||||
"importance": f.importance,
|
||||
"source": f.source,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in facts
|
||||
]
|
||||
|
||||
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
||||
fact = self.db.get(MemoryFact, memory_id)
|
||||
if not fact:
|
||||
raise ValueError(f"Память #{memory_id} не найдена")
|
||||
fact.active = False
|
||||
fact.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
||||
|
||||
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(MemoryFact)
|
||||
.where(MemoryFact.active.is_(True))
|
||||
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
||||
return self.db.scalar(
|
||||
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||
)
|
||||
|
||||
def update_session_summary(
|
||||
self,
|
||||
session_id: int,
|
||||
summary: str,
|
||||
*,
|
||||
message_count: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
text = summary.strip()
|
||||
if not text:
|
||||
raise ValueError("Пустая сводка")
|
||||
|
||||
row = self.get_session_summary(session_id)
|
||||
if not row:
|
||||
row = SessionSummary(session_id=session_id)
|
||||
self.db.add(row)
|
||||
|
||||
row.summary = text[:4000]
|
||||
row.message_count = message_count
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||
|
||||
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
|
||||
facts = self.get_active_facts()
|
||||
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||
return {
|
||||
"profile": self.get_profile(),
|
||||
"facts": [
|
||||
{
|
||||
"id": f.id,
|
||||
"category": f.category,
|
||||
"content": f.content,
|
||||
"importance": f.importance,
|
||||
"source": f.source,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in facts
|
||||
],
|
||||
"session_summary": summary_row.summary if summary_row else "",
|
||||
"total_facts": len(facts),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
NOT_HANDLED: Any = object()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
db: Session
|
||||
user_id: int
|
||||
session_id: int | None
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Any
|
||||
|
||||
from app.rag.retriever import retrieve_document_chunks
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({"search_documents"})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_documents",
|
||||
"description": "Семантический поиск по загруженным документам (RAG).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Поисковый запрос"},
|
||||
"limit": {"type": "integer", "description": "Макс. фрагментов"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
if name == "search_documents":
|
||||
return await retrieve_document_chunks(
|
||||
arguments.get("query", ""),
|
||||
user_id=ctx.user_id,
|
||||
top_k=int(arguments.get("limit") or 6),
|
||||
)
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,403 @@
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.fitness.service import FitnessService
|
||||
from app.fitness.structuring import structure_meal, structure_workout
|
||||
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||
from app.integrations.wger import WgerClient
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"get_fitness_summary",
|
||||
"get_fitness_history",
|
||||
"set_fitness_profile",
|
||||
"calc_fitness_targets",
|
||||
"calc_body_composition",
|
||||
"log_meal",
|
||||
"log_water",
|
||||
"log_weight",
|
||||
"log_steps",
|
||||
"log_workout",
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"set_fitness_reminder",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fitness_summary",
|
||||
"description": (
|
||||
"Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. "
|
||||
"Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "Дата YYYY-MM-DD"},
|
||||
"days_ago": {
|
||||
"type": "integer",
|
||||
"description": "0 сегодня, 1 вчера, 2 позавчера…",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fitness_history",
|
||||
"description": (
|
||||
"Краткая история за несколько дней (ккал, вода, тренировки по дням). "
|
||||
"«На прошлой неделе», «за 7 дней»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"},
|
||||
"end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_fitness_profile",
|
||||
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (TDEE = BMR + NEAT).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sex": {"type": "string", "description": "male/female"},
|
||||
"age": {"type": "integer"},
|
||||
"height_cm": {"type": "number"},
|
||||
"weight_kg": {"type": "number"},
|
||||
"goal": {"type": "string", "description": "lose/maintain/gain"},
|
||||
"target_weight_kg": {"type": "number"},
|
||||
"neat_base_kcal": {
|
||||
"type": "number",
|
||||
"description": "NEAT-база 200–300 ккал, по умолчанию 200",
|
||||
},
|
||||
"activity_level": {
|
||||
"type": "string",
|
||||
"description": "sedentary/moderate/active/very_active — fallback для TDEE план",
|
||||
},
|
||||
"weekly_workouts": {
|
||||
"type": "integer",
|
||||
"description": "Тренировок в неделю для fallback TDEE план",
|
||||
},
|
||||
"baseline_steps": {
|
||||
"type": "integer",
|
||||
"description": "Ожидаемые шаги/день (fallback TDEE план)",
|
||||
},
|
||||
"baseline_workout_kcal": {
|
||||
"type": "number",
|
||||
"description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calc_fitness_targets",
|
||||
"description": "Калькулятор BMR/TDEE/макросов без сохранения (rest-day: BMR + NEAT).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sex": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
"height_cm": {"type": "number"},
|
||||
"weight_kg": {"type": "number"},
|
||||
"goal": {"type": "string"},
|
||||
"neat_base_kcal": {"type": "number"},
|
||||
"steps": {"type": "integer", "description": "Шаги за день для расчёта TDEE"},
|
||||
},
|
||||
"required": ["weight_kg", "height_cm", "age"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calc_body_composition",
|
||||
"description": (
|
||||
"Navy-калькулятор % жира, WHR, LBM, FFMI без сохранения. "
|
||||
"Пол/рост/вес из профиля, если не указаны."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sex": {"type": "string"},
|
||||
"height_cm": {"type": "number"},
|
||||
"weight_kg": {"type": "number"},
|
||||
"neck_cm": {"type": "number"},
|
||||
"waist_cm": {"type": "number"},
|
||||
"hip_cm": {"type": "number"},
|
||||
"body_fat_pct": {"type": "number"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_meal",
|
||||
"description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Что съел"},
|
||||
"meal_type": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_water",
|
||||
"description": "Записать воду в мл.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"amount_ml": {"type": "integer"},
|
||||
},
|
||||
"required": ["amount_ml"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_weight",
|
||||
"description": (
|
||||
"Записать антропометрию: вес и обхваты (см). "
|
||||
"При neck+waist(+hip для женщин) автоматически считается Navy % жира."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"weight_kg": {"type": "number"},
|
||||
"body_fat_pct": {"type": "number"},
|
||||
"neck_cm": {"type": "number"},
|
||||
"waist_cm": {"type": "number"},
|
||||
"hip_cm": {"type": "number"},
|
||||
"chest_cm": {"type": "number"},
|
||||
"notes": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"days_ago": {"type": "integer"},
|
||||
},
|
||||
"required": ["weight_kg"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_steps",
|
||||
"description": "Записать шаги (можно задним числом: date или days_ago).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {"type": "integer"},
|
||||
"active_calories": {"type": "number"},
|
||||
"notes": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"days_ago": {"type": "integer"},
|
||||
},
|
||||
"required": ["steps"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_workout",
|
||||
"description": "Записать тренировку из текста (date/days_ago для прошлых дней).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"days_ago": {"type": "integer"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_food",
|
||||
"description": "Поиск продукта в Open Food Facts (ккал на 100г).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_exercise",
|
||||
"description": "Поиск упражнения в базе wger.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_fitness_reminder",
|
||||
"description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"hour": {"type": "integer"},
|
||||
"minute": {"type": "integer"},
|
||||
"interval_hours": {"type": "integer"},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
fitness = FitnessService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "get_fitness_summary":
|
||||
day: date | None = None
|
||||
if arguments.get("date"):
|
||||
day = date.fromisoformat(str(arguments["date"]))
|
||||
elif arguments.get("days_ago") is not None:
|
||||
day = datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"]))
|
||||
return fitness.get_daily_summary(day)
|
||||
if name == "get_fitness_history":
|
||||
end_day = None
|
||||
if arguments.get("end_date"):
|
||||
end_day = date.fromisoformat(str(arguments["end_date"]))
|
||||
return fitness.get_history(
|
||||
days=int(arguments.get("days") or 7),
|
||||
end_day=end_day,
|
||||
)
|
||||
if name == "set_fitness_profile":
|
||||
updates = {
|
||||
k: arguments[k]
|
||||
for k in (
|
||||
"sex", "age", "height_cm", "weight_kg",
|
||||
"goal", "target_weight_kg", "neat_base_kcal",
|
||||
"activity_level", "weekly_workouts",
|
||||
"baseline_steps", "baseline_workout_kcal",
|
||||
)
|
||||
if k in arguments and arguments[k] is not None
|
||||
}
|
||||
return fitness.set_profile(updates)
|
||||
if name == "calc_fitness_targets":
|
||||
from app.fitness.calculators import compute_daily_targets
|
||||
|
||||
steps = int(arguments.get("steps") or 0)
|
||||
return compute_daily_targets(arguments, steps_total=steps, workouts=[])
|
||||
if name == "calc_body_composition":
|
||||
return fitness.calc_body_composition(arguments)
|
||||
if name == "log_meal":
|
||||
structured = await structure_meal(arguments.get("text", ""))
|
||||
return fitness.log_meal(
|
||||
description=structured.get("description") or arguments.get("text", ""),
|
||||
meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack",
|
||||
calories=float(structured.get("calories") or 0),
|
||||
protein_g=float(structured.get("protein_g") or 0),
|
||||
fat_g=float(structured.get("fat_g") or 0),
|
||||
carbs_g=float(structured.get("carbs_g") or 0),
|
||||
source="llm",
|
||||
estimated=True,
|
||||
)
|
||||
if name == "log_water":
|
||||
return fitness.log_water(int(arguments.get("amount_ml", 250)))
|
||||
if name == "log_weight":
|
||||
day = None
|
||||
if arguments.get("date"):
|
||||
day = date.fromisoformat(str(arguments["date"]))
|
||||
return fitness.log_weight(
|
||||
float(arguments["weight_kg"]),
|
||||
body_fat_pct=arguments.get("body_fat_pct"),
|
||||
chest_cm=arguments.get("chest_cm"),
|
||||
waist_cm=arguments.get("waist_cm"),
|
||||
neck_cm=arguments.get("neck_cm"),
|
||||
hip_cm=arguments.get("hip_cm"),
|
||||
notes=arguments.get("notes", ""),
|
||||
day=day,
|
||||
days_ago=arguments.get("days_ago"),
|
||||
)
|
||||
if name == "log_steps":
|
||||
day = None
|
||||
if arguments.get("date"):
|
||||
day = date.fromisoformat(str(arguments["date"]))
|
||||
return fitness.log_steps(
|
||||
int(arguments.get("steps") or 0),
|
||||
active_calories=arguments.get("active_calories"),
|
||||
notes=arguments.get("notes", ""),
|
||||
day=day,
|
||||
days_ago=arguments.get("days_ago"),
|
||||
)
|
||||
if name == "log_workout":
|
||||
structured = await structure_workout(arguments.get("text", ""))
|
||||
day = None
|
||||
if arguments.get("date"):
|
||||
day = date.fromisoformat(str(arguments["date"]))
|
||||
return fitness.log_workout(
|
||||
title=structured.get("title") or "Тренировка",
|
||||
notes=structured.get("notes") or arguments.get("text", ""),
|
||||
duration_min=structured.get("duration_min"),
|
||||
exercises=structured.get("exercises"),
|
||||
active_calories=structured.get("active_calories"),
|
||||
total_calories=structured.get("total_calories"),
|
||||
steps=structured.get("steps"),
|
||||
activity_type=structured.get("activity_type"),
|
||||
met=structured.get("met"),
|
||||
day=day,
|
||||
days_ago=arguments.get("days_ago"),
|
||||
)
|
||||
if name == "lookup_food":
|
||||
return OpenFoodFactsClient().search(
|
||||
arguments.get("query", ""),
|
||||
limit=arguments.get("limit", 5),
|
||||
)
|
||||
if name == "lookup_exercise":
|
||||
return WgerClient().search_exercises(
|
||||
arguments.get("query", ""),
|
||||
limit=arguments.get("limit", 8),
|
||||
)
|
||||
if name == "set_fitness_reminder":
|
||||
return fitness.set_reminder(
|
||||
arguments.get("kind", "water"),
|
||||
enabled=arguments.get("enabled"),
|
||||
hour=arguments.get("hour"),
|
||||
minute=arguments.get("minute"),
|
||||
interval_hours=arguments.get("interval_hours"),
|
||||
)
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,116 @@
|
||||
from typing import Any
|
||||
|
||||
from app.homelab.digest import build_weather_briefing
|
||||
from app.homelab.image_gen import generate_image as run_generate_image
|
||||
from app.homelab.openmeteo import OpenMeteoClient
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"get_weather",
|
||||
"get_morning_briefing",
|
||||
"generate_image",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": (
|
||||
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь», «завтра», «на неделю». "
|
||||
"Текущая погода, почасовой и дневной прогноз."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hours_ahead": {
|
||||
"type": "integer",
|
||||
"description": "Сколько часов почасового прогноза (по умолчанию 12, до 168)",
|
||||
},
|
||||
"days_ahead": {
|
||||
"type": "integer",
|
||||
"description": "Сколько дней дневного прогноза (по умолчанию 7, до 16)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_morning_briefing",
|
||||
"description": "Утренний брифинг: погода и заголовки новостей.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_news": {
|
||||
"type": "boolean",
|
||||
"description": "Включить новости (по умолчанию true)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"description": (
|
||||
"Аниме-картинка (Anima). draw_self=true — персонаж из карточки; "
|
||||
"scene_description — поза/кадр/одежда (booru-теги на англ. или короткий запрос: "
|
||||
"full body, sitting, apron). Можно оба параметра: draw_self + scene_description. "
|
||||
"Внешность только из appearance_tags карточки."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draw_self": {
|
||||
"type": "boolean",
|
||||
"description": "Нарисовать персонажа из карточки",
|
||||
},
|
||||
"scene_description": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Поза, кадр, одежда, обстановка — booru-теги или запрос "
|
||||
"(full_body, standing, apron, blush). С draw_self=true — уточняет сцену."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
if name == "get_weather":
|
||||
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()
|
||||
weather = client.fetch_forecast(hours_ahead=hours, days_ahead=days)
|
||||
return {
|
||||
"weather": weather,
|
||||
"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 "",
|
||||
}
|
||||
if name == "get_morning_briefing":
|
||||
include_news = arguments.get("include_news", True)
|
||||
return build_weather_briefing(
|
||||
hours_ahead=12,
|
||||
include_news=bool(include_news),
|
||||
)
|
||||
if name == "generate_image":
|
||||
return await run_generate_image(
|
||||
ctx.db,
|
||||
user_id=ctx.user_id,
|
||||
session_id=ctx.session_id,
|
||||
draw_self=bool(arguments.get("draw_self")),
|
||||
scene_description=arguments.get("scene_description", ""),
|
||||
)
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,146 @@
|
||||
from typing import Any
|
||||
|
||||
from app.memory.service import MemoryService
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"remember_fact",
|
||||
"recall_memories",
|
||||
"forget_memory",
|
||||
"update_profile",
|
||||
"update_session_summary",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remember_fact",
|
||||
"description": (
|
||||
"Сохранить факт в долгосрочную память. "
|
||||
"Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Что запомнить"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "preference, person, habit, project, fact",
|
||||
},
|
||||
"importance": {"type": "integer", "description": "1-5, по умолчанию 3"},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall_memories",
|
||||
"description": (
|
||||
"Поиск в долгосрочной памяти. "
|
||||
"Когда спрашивают «что ты помнишь», «что я говорил про X»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Подстрока для поиска"},
|
||||
"category": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "forget_memory",
|
||||
"description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["memory_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_profile",
|
||||
"description": (
|
||||
"Обновить профиль пользователя: name, timezone, language, notes. "
|
||||
"Передавай только изменившиеся поля."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "string", "description": "Возраст пользователя"},
|
||||
"timezone": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_session_summary",
|
||||
"description": (
|
||||
"Сохранить краткую сводку темы текущего чата "
|
||||
"(когда диалог длинный или пользователь просит «сожми контекст»)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "description": "2-5 предложений о теме чата"},
|
||||
"session_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["summary", "session_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
memory = MemoryService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "remember_fact":
|
||||
return memory.remember_fact(
|
||||
arguments.get("content", ""),
|
||||
category=arguments.get("category", "fact"),
|
||||
importance=arguments.get("importance", 3),
|
||||
session_id=ctx.session_id,
|
||||
source="tool",
|
||||
)
|
||||
if name == "recall_memories":
|
||||
return memory.recall_memories(
|
||||
query=arguments.get("query"),
|
||||
category=arguments.get("category"),
|
||||
limit=arguments.get("limit", 20),
|
||||
)
|
||||
if name == "forget_memory":
|
||||
return memory.forget_memory(int(arguments["memory_id"]))
|
||||
if name == "update_profile":
|
||||
updates = {
|
||||
k: arguments[k]
|
||||
for k in ("name", "age", "timezone", "language", "notes")
|
||||
if k in arguments and arguments[k] is not None
|
||||
}
|
||||
return memory.update_profile(updates)
|
||||
if name == "update_session_summary":
|
||||
return memory.update_session_summary(
|
||||
int(arguments["session_id"]),
|
||||
arguments.get("summary", ""),
|
||||
)
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,157 @@
|
||||
from typing import Any
|
||||
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
"reset_pomodoro_cycle",
|
||||
"get_pomodoro_history",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_status",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_pomodoro",
|
||||
"description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты работы"},
|
||||
"task_note": {"type": "string", "description": "Над чем работаем"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_short_break",
|
||||
"description": "Запустить короткий перерыв между работами.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_long_break",
|
||||
"description": "Запустить длинный перерыв после завершения цикла работ.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_pomodoro",
|
||||
"description": "Остановить текущую фазу таймера.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {"type": "string", "description": "Отчёт о сделанном"},
|
||||
"completed": {
|
||||
"type": "boolean",
|
||||
"description": "True если фаза полностью завершена",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skip_pomodoro_phase",
|
||||
"description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "reset_pomodoro_cycle",
|
||||
"description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clear_task": {
|
||||
"type": "boolean",
|
||||
"description": "Также очистить текущую задачу",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_history",
|
||||
"description": "История помидоро-сессий (таймер), не Taiga-задачи.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Сколько сессий вернуть"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
pomodoro = PomodoroService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "get_pomodoro_status":
|
||||
return pomodoro.get_status()
|
||||
if name == "start_pomodoro":
|
||||
return pomodoro.start_work(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
task_note=arguments.get("task_note", ""),
|
||||
)
|
||||
if name == "start_short_break":
|
||||
return pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
|
||||
if name == "start_long_break":
|
||||
return pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
|
||||
if name == "stop_pomodoro":
|
||||
return pomodoro.stop(
|
||||
result=arguments.get("result", ""),
|
||||
completed=arguments.get("completed", False),
|
||||
)
|
||||
if name == "skip_pomodoro_phase":
|
||||
return pomodoro.skip_phase()
|
||||
if name == "reset_pomodoro_cycle":
|
||||
return pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
|
||||
if name == "get_pomodoro_history":
|
||||
return pomodoro.history(limit=arguments.get("limit", 10))
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import Any
|
||||
|
||||
from app.projects.service import ProjectService
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"sync_taiga_projects",
|
||||
"list_taiga_projects",
|
||||
"list_taiga_tasks",
|
||||
"create_work_item",
|
||||
"list_work_items",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "sync_taiga_projects",
|
||||
"description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_taiga_projects",
|
||||
"description": "Список проектов Taiga с привязкой Gitea.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_taiga_tasks",
|
||||
"description": (
|
||||
"ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». "
|
||||
"Живые user stories и tasks из Taiga API. НЕ путать с list_work_items."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_slug": {
|
||||
"type": "string",
|
||||
"description": "Slug проекта, например home_assistant. Пусто = все проекты.",
|
||||
},
|
||||
"limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_work_item",
|
||||
"description": (
|
||||
"Создать фичу/баг из вольного текста: структурировать через LLM, "
|
||||
"создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Полное описание от пользователя"},
|
||||
"project_slug": {
|
||||
"type": "string",
|
||||
"description": "Slug проекта Taiga, если известен",
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_work_items",
|
||||
"description": (
|
||||
"Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). "
|
||||
"НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "description": "open или closed"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
projects = ProjectService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "sync_taiga_projects":
|
||||
from app.projects.context import invalidate_projects_snapshot_cache
|
||||
|
||||
result = projects.sync_taiga_projects()
|
||||
invalidate_projects_snapshot_cache(ctx.user_id)
|
||||
return result
|
||||
if name == "list_taiga_projects":
|
||||
return projects.list_projects()
|
||||
if name == "list_taiga_tasks":
|
||||
return projects.list_taiga_open_tasks(
|
||||
project_slug=arguments.get("project_slug"),
|
||||
limit=arguments.get("limit", 20),
|
||||
)
|
||||
if name == "create_work_item":
|
||||
return await projects.create_work_item(
|
||||
arguments.get("text", ""),
|
||||
project_slug=arguments.get("project_slug"),
|
||||
)
|
||||
if name == "list_work_items":
|
||||
return projects.list_work_items(
|
||||
limit=arguments.get("limit", 20),
|
||||
status=arguments.get("status"),
|
||||
)
|
||||
return NOT_HANDLED
|
||||
+22
-1084
File diff suppressed because it is too large
Load Diff
@@ -1,961 +0,0 @@
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.fitness.service import FitnessService
|
||||
from app.fitness.structuring import structure_meal, structure_workout
|
||||
from app.homelab.digest import build_weather_briefing
|
||||
from app.homelab.image_gen import generate_image as run_generate_image
|
||||
from app.homelab.openmeteo import OpenMeteoClient
|
||||
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||
from app.integrations.wger import WgerClient
|
||||
from app.memory.service import MemoryService
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.projects.service import ProjectService
|
||||
from app.reminders.service import RemindersService
|
||||
from app.shopping.service import ShoppingService
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_status",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_pomodoro",
|
||||
"description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты работы"},
|
||||
"task_note": {"type": "string", "description": "Над чем работаем"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_short_break",
|
||||
"description": "Запустить короткий перерыв между работами.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_long_break",
|
||||
"description": "Запустить длинный перерыв после завершения цикла работ.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_pomodoro",
|
||||
"description": "Остановить текущую фазу таймера.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {"type": "string", "description": "Отчёт о сделанном"},
|
||||
"completed": {
|
||||
"type": "boolean",
|
||||
"description": "True если фаза полностью завершена",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skip_pomodoro_phase",
|
||||
"description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "reset_pomodoro_cycle",
|
||||
"description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clear_task": {
|
||||
"type": "boolean",
|
||||
"description": "Также очистить текущую задачу",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_history",
|
||||
"description": "История помидоро-сессий (таймер), не Taiga-задачи.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Сколько сессий вернуть"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "sync_taiga_projects",
|
||||
"description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_taiga_projects",
|
||||
"description": "Список проектов Taiga с привязкой Gitea.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_taiga_tasks",
|
||||
"description": (
|
||||
"ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». "
|
||||
"Живые user stories и tasks из Taiga API. НЕ путать с list_work_items."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_slug": {
|
||||
"type": "string",
|
||||
"description": "Slug проекта, например home_assistant. Пусто = все проекты.",
|
||||
},
|
||||
"limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_work_item",
|
||||
"description": (
|
||||
"Создать фичу/баг из вольного текста: структурировать через LLM, "
|
||||
"создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Полное описание от пользователя"},
|
||||
"project_slug": {
|
||||
"type": "string",
|
||||
"description": "Slug проекта Taiga, если известен",
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remember_fact",
|
||||
"description": (
|
||||
"Сохранить факт в долгосрочную память. "
|
||||
"Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Что запомнить"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "preference, person, habit, project, fact",
|
||||
},
|
||||
"importance": {"type": "integer", "description": "1-5, по умолчанию 3"},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall_memories",
|
||||
"description": (
|
||||
"Поиск в долгосрочной памяти. "
|
||||
"Когда спрашивают «что ты помнишь», «что я говорил про X»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Подстрока для поиска"},
|
||||
"category": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "forget_memory",
|
||||
"description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["memory_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_profile",
|
||||
"description": (
|
||||
"Обновить профиль пользователя: name, timezone, language, notes. "
|
||||
"Передавай только изменившиеся поля."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "string", "description": "Возраст пользователя"},
|
||||
"timezone": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_session_summary",
|
||||
"description": (
|
||||
"Сохранить краткую сводку темы текущего чата "
|
||||
"(когда диалог длинный или пользователь просит «сожми контекст»)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "description": "2-5 предложений о теме чата"},
|
||||
"session_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["summary", "session_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fitness_summary",
|
||||
"description": (
|
||||
"Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. "
|
||||
"Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "Дата YYYY-MM-DD"},
|
||||
"days_ago": {
|
||||
"type": "integer",
|
||||
"description": "0 сегодня, 1 вчера, 2 позавчера…",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fitness_history",
|
||||
"description": (
|
||||
"Краткая история за несколько дней (ккал, вода, тренировки по дням). "
|
||||
"«На прошлой неделе», «за 7 дней»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"},
|
||||
"end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_fitness_profile",
|
||||
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sex": {"type": "string", "description": "male/female"},
|
||||
"age": {"type": "integer"},
|
||||
"height_cm": {"type": "number"},
|
||||
"weight_kg": {"type": "number"},
|
||||
"activity_level": {
|
||||
"type": "string",
|
||||
"description": "sedentary/light/moderate/active/very_active",
|
||||
},
|
||||
"goal": {"type": "string", "description": "lose/maintain/gain"},
|
||||
"target_weight_kg": {"type": "number"},
|
||||
"weekly_workouts": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calc_fitness_targets",
|
||||
"description": "Калькулятор BMR/TDEE/макросов без сохранения.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sex": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
"height_cm": {"type": "number"},
|
||||
"weight_kg": {"type": "number"},
|
||||
"activity_level": {"type": "string"},
|
||||
"goal": {"type": "string"},
|
||||
},
|
||||
"required": ["weight_kg", "height_cm", "age"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_meal",
|
||||
"description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Что съел"},
|
||||
"meal_type": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_water",
|
||||
"description": "Записать воду в мл.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"amount_ml": {"type": "integer"},
|
||||
},
|
||||
"required": ["amount_ml"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_weight",
|
||||
"description": "Записать вес в кг.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"weight_kg": {"type": "number"},
|
||||
"body_fat_pct": {"type": "number"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["weight_kg"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_workout",
|
||||
"description": "Записать тренировку из текста.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_food",
|
||||
"description": "Поиск продукта в Open Food Facts (ккал на 100г).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_exercise",
|
||||
"description": "Поиск упражнения в базе wger.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_fitness_reminder",
|
||||
"description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"hour": {"type": "integer"},
|
||||
"minute": {"type": "integer"},
|
||||
"interval_hours": {"type": "integer"},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": (
|
||||
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». "
|
||||
"Текущая погода и прогноз по часам."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hours_ahead": {
|
||||
"type": "integer",
|
||||
"description": "Сколько часов прогноза (по умолчанию 12)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_morning_briefing",
|
||||
"description": "Утренний брифинг: погода и заголовки новостей.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_news": {
|
||||
"type": "boolean",
|
||||
"description": "Включить новости (по умолчанию true)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"description": (
|
||||
"Аниме-картинка (Anima через RP-чат). "
|
||||
"«Нарисуй себя» / портрет персонажа → draw_self=true. "
|
||||
"Другая сцена → scene_description на английском (booru-теги). "
|
||||
"Внешность берётся из карточки персонажа. Только по запросу или когда уместно."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draw_self": {
|
||||
"type": "boolean",
|
||||
"description": "Нарисовать персонажа из карточки в контексте текущего чата",
|
||||
},
|
||||
"scene_description": {
|
||||
"type": "string",
|
||||
"description": "Описание сцены на английском (booru-теги), если не draw_self",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_shopping_lists",
|
||||
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_shopping_list",
|
||||
"description": "Создать новый список покупок.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_shopping_items",
|
||||
"description": "Добавить товары в список. Список создаётся, если не существует.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list_name": {"type": "string", "description": "Название списка"},
|
||||
"list_id": {"type": "integer"},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
"quantity": {"type": "number"},
|
||||
"unit": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["items"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "check_shopping_item",
|
||||
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "integer"},
|
||||
"checked": {"type": "boolean"},
|
||||
},
|
||||
"required": ["item_id", "checked"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remove_shopping_item",
|
||||
"description": "Удалить позицию из списка по item_id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"item_id": {"type": "integer"}},
|
||||
"required": ["item_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_shopping_list",
|
||||
"description": "Удалить весь список покупок.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"list_id": {"type": "integer"}},
|
||||
"required": ["list_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_reminders",
|
||||
"description": "Список активных напоминаний. «Что напомнил», «мои напоминания».",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_reminder",
|
||||
"description": (
|
||||
"Создать напоминание. due_at — ISO datetime в часовом поясе пользователя "
|
||||
"(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "О чём напомнить"},
|
||||
"due_at": {"type": "string", "description": "ISO datetime"},
|
||||
"notes": {"type": "string"},
|
||||
"all_day": {"type": "boolean"},
|
||||
"recurrence": {
|
||||
"type": "string",
|
||||
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
|
||||
"description": "Повтор (yearly — день рождения, Новый год)",
|
||||
},
|
||||
},
|
||||
"required": ["title", "due_at"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_reminder",
|
||||
"description": "Изменить напоминание по id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reminder_id": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"due_at": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
"all_day": {"type": "boolean"},
|
||||
"recurrence": {
|
||||
"type": "string",
|
||||
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_reminder",
|
||||
"description": "Удалить напоминание по id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"reminder_id": {"type": "integer"}},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "complete_reminder",
|
||||
"description": "Отметить напоминание выполненным (снять с календаря).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"reminder_id": {"type": "integer"}},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_work_items",
|
||||
"description": (
|
||||
"Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). "
|
||||
"НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "description": "open или closed"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute_tool(
|
||||
db: Session,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
session_id: int | None = None,
|
||||
) -> str:
|
||||
pomodoro = PomodoroService(db)
|
||||
projects = ProjectService(db)
|
||||
memory = MemoryService(db)
|
||||
fitness = FitnessService(db)
|
||||
shopping = ShoppingService(db)
|
||||
reminders = RemindersService(db)
|
||||
|
||||
try:
|
||||
if name == "get_pomodoro_status":
|
||||
result = pomodoro.get_status()
|
||||
elif name == "start_pomodoro":
|
||||
result = pomodoro.start_work(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
task_note=arguments.get("task_note", ""),
|
||||
)
|
||||
elif name == "start_short_break":
|
||||
result = pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
|
||||
elif name == "start_long_break":
|
||||
result = pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
|
||||
elif name == "stop_pomodoro":
|
||||
result = pomodoro.stop(
|
||||
result=arguments.get("result", ""),
|
||||
completed=arguments.get("completed", False),
|
||||
)
|
||||
elif name == "skip_pomodoro_phase":
|
||||
result = pomodoro.skip_phase()
|
||||
elif name == "reset_pomodoro_cycle":
|
||||
result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
|
||||
elif name == "get_pomodoro_history":
|
||||
result = pomodoro.history(limit=arguments.get("limit", 10))
|
||||
elif name == "sync_taiga_projects":
|
||||
from app.projects.context import invalidate_projects_snapshot_cache
|
||||
|
||||
result = projects.sync_taiga_projects()
|
||||
invalidate_projects_snapshot_cache()
|
||||
elif name == "list_taiga_projects":
|
||||
result = projects.list_projects()
|
||||
elif name == "list_taiga_tasks":
|
||||
result = projects.list_taiga_open_tasks(
|
||||
project_slug=arguments.get("project_slug"),
|
||||
limit=arguments.get("limit", 20),
|
||||
)
|
||||
elif name == "create_work_item":
|
||||
result = await projects.create_work_item(
|
||||
arguments.get("text", ""),
|
||||
project_slug=arguments.get("project_slug"),
|
||||
)
|
||||
elif name == "list_work_items":
|
||||
result = projects.list_work_items(
|
||||
limit=arguments.get("limit", 20),
|
||||
status=arguments.get("status"),
|
||||
)
|
||||
elif name == "remember_fact":
|
||||
result = memory.remember_fact(
|
||||
arguments.get("content", ""),
|
||||
category=arguments.get("category", "fact"),
|
||||
importance=arguments.get("importance", 3),
|
||||
session_id=session_id,
|
||||
source="tool",
|
||||
)
|
||||
elif name == "recall_memories":
|
||||
result = memory.recall_memories(
|
||||
query=arguments.get("query"),
|
||||
category=arguments.get("category"),
|
||||
limit=arguments.get("limit", 20),
|
||||
)
|
||||
elif name == "forget_memory":
|
||||
result = memory.forget_memory(int(arguments["memory_id"]))
|
||||
elif name == "update_profile":
|
||||
updates = {
|
||||
k: arguments[k]
|
||||
for k in ("name", "age", "timezone", "language", "notes")
|
||||
if k in arguments and arguments[k] is not None
|
||||
}
|
||||
result = memory.update_profile(updates)
|
||||
elif name == "update_session_summary":
|
||||
result = memory.update_session_summary(
|
||||
int(arguments["session_id"]),
|
||||
arguments.get("summary", ""),
|
||||
)
|
||||
elif name == "get_fitness_summary":
|
||||
day: date | None = None
|
||||
if arguments.get("date"):
|
||||
day = date.fromisoformat(str(arguments["date"]))
|
||||
elif arguments.get("days_ago") is not None:
|
||||
day = datetime.now(timezone.utc).date() - timedelta(
|
||||
days=int(arguments["days_ago"])
|
||||
)
|
||||
result = fitness.get_daily_summary(day)
|
||||
elif name == "get_fitness_history":
|
||||
end_day = None
|
||||
if arguments.get("end_date"):
|
||||
end_day = date.fromisoformat(str(arguments["end_date"]))
|
||||
result = fitness.get_history(
|
||||
days=int(arguments.get("days") or 7),
|
||||
end_day=end_day,
|
||||
)
|
||||
elif name == "set_fitness_profile":
|
||||
updates = {
|
||||
k: arguments[k]
|
||||
for k in (
|
||||
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||
"goal", "target_weight_kg", "weekly_workouts",
|
||||
)
|
||||
if k in arguments and arguments[k] is not None
|
||||
}
|
||||
result = fitness.set_profile(updates)
|
||||
elif name == "calc_fitness_targets":
|
||||
result = fitness.calc_targets(arguments)
|
||||
elif name == "log_meal":
|
||||
structured = await structure_meal(arguments.get("text", ""))
|
||||
result = fitness.log_meal(
|
||||
description=structured.get("description") or arguments.get("text", ""),
|
||||
meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack",
|
||||
calories=float(structured.get("calories") or 0),
|
||||
protein_g=float(structured.get("protein_g") or 0),
|
||||
fat_g=float(structured.get("fat_g") or 0),
|
||||
carbs_g=float(structured.get("carbs_g") or 0),
|
||||
source="llm",
|
||||
estimated=True,
|
||||
)
|
||||
elif name == "log_water":
|
||||
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
|
||||
elif name == "log_weight":
|
||||
result = fitness.log_weight(
|
||||
float(arguments["weight_kg"]),
|
||||
body_fat_pct=arguments.get("body_fat_pct"),
|
||||
notes=arguments.get("notes", ""),
|
||||
)
|
||||
elif name == "log_workout":
|
||||
structured = await structure_workout(arguments.get("text", ""))
|
||||
result = fitness.log_workout(
|
||||
title=structured.get("title") or "Тренировка",
|
||||
notes=structured.get("notes") or arguments.get("text", ""),
|
||||
duration_min=structured.get("duration_min"),
|
||||
exercises=structured.get("exercises"),
|
||||
)
|
||||
elif name == "lookup_food":
|
||||
result = OpenFoodFactsClient().search(
|
||||
arguments.get("query", ""),
|
||||
limit=arguments.get("limit", 5),
|
||||
)
|
||||
elif name == "lookup_exercise":
|
||||
result = WgerClient().search_exercises(
|
||||
arguments.get("query", ""),
|
||||
limit=arguments.get("limit", 8),
|
||||
)
|
||||
elif name == "set_fitness_reminder":
|
||||
result = fitness.set_reminder(
|
||||
arguments.get("kind", "water"),
|
||||
enabled=arguments.get("enabled"),
|
||||
hour=arguments.get("hour"),
|
||||
minute=arguments.get("minute"),
|
||||
interval_hours=arguments.get("interval_hours"),
|
||||
)
|
||||
elif name == "get_weather":
|
||||
hours = int(arguments.get("hours_ahead") or 12)
|
||||
client = OpenMeteoClient()
|
||||
weather = client.fetch_current_and_hourly(hours_ahead=hours)
|
||||
result = {
|
||||
"weather": weather,
|
||||
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "",
|
||||
}
|
||||
elif name == "get_morning_briefing":
|
||||
include_news = arguments.get("include_news", True)
|
||||
result = build_weather_briefing(
|
||||
hours_ahead=12,
|
||||
include_news=bool(include_news),
|
||||
)
|
||||
elif name == "generate_image":
|
||||
result = await run_generate_image(
|
||||
db,
|
||||
session_id=session_id,
|
||||
draw_self=bool(arguments.get("draw_self")),
|
||||
scene_description=arguments.get("scene_description", ""),
|
||||
)
|
||||
elif name == "list_shopping_lists":
|
||||
result = shopping.list_lists(include_items=True)
|
||||
elif name == "create_shopping_list":
|
||||
result = shopping.create_list(arguments.get("name", ""))
|
||||
elif name == "add_shopping_items":
|
||||
result = shopping.add_items(
|
||||
arguments.get("items") or [],
|
||||
list_id=arguments.get("list_id"),
|
||||
list_name=arguments.get("list_name"),
|
||||
)
|
||||
elif name == "check_shopping_item":
|
||||
result = shopping.set_item_checked(
|
||||
int(arguments["item_id"]),
|
||||
bool(arguments.get("checked", True)),
|
||||
)
|
||||
elif name == "remove_shopping_item":
|
||||
result = shopping.remove_item(int(arguments["item_id"]))
|
||||
elif name == "delete_shopping_list":
|
||||
result = shopping.delete_list(int(arguments["list_id"]))
|
||||
elif name == "list_reminders":
|
||||
result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20))
|
||||
elif name == "create_reminder":
|
||||
result = reminders.create(
|
||||
title=arguments.get("title", ""),
|
||||
due_at=arguments.get("due_at", ""),
|
||||
notes=arguments.get("notes", ""),
|
||||
all_day=bool(arguments.get("all_day", False)),
|
||||
recurrence=arguments.get("recurrence", "none"),
|
||||
)
|
||||
elif name == "update_reminder":
|
||||
result = reminders.update(
|
||||
int(arguments["reminder_id"]),
|
||||
title=arguments.get("title"),
|
||||
due_at=arguments.get("due_at"),
|
||||
notes=arguments.get("notes"),
|
||||
all_day=arguments.get("all_day"),
|
||||
recurrence=arguments.get("recurrence"),
|
||||
enabled=arguments.get("enabled"),
|
||||
)
|
||||
elif name == "delete_reminder":
|
||||
result = reminders.delete(int(arguments["reminder_id"]))
|
||||
elif name == "complete_reminder":
|
||||
result = reminders.complete(int(arguments["reminder_id"]))
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except ValueError as exc:
|
||||
return json.dumps({"error": str(exc)}, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, ensure_ascii=False)
|
||||
@@ -0,0 +1,134 @@
|
||||
from typing import Any
|
||||
|
||||
from app.reminders_scoped.service import RemindersService
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"list_reminders",
|
||||
"create_reminder",
|
||||
"update_reminder",
|
||||
"delete_reminder",
|
||||
"complete_reminder",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_reminders",
|
||||
"description": "Список активных напоминаний. «Что напомнил», «мои напоминания».",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_reminder",
|
||||
"description": (
|
||||
"Создать напоминание. due_at — ISO datetime в часовом поясе пользователя "
|
||||
"(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "О чём напомнить"},
|
||||
"due_at": {"type": "string", "description": "ISO datetime"},
|
||||
"notes": {"type": "string"},
|
||||
"all_day": {"type": "boolean"},
|
||||
"recurrence": {
|
||||
"type": "string",
|
||||
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
|
||||
"description": "Повтор (yearly — день рождения, Новый год)",
|
||||
},
|
||||
},
|
||||
"required": ["title", "due_at"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_reminder",
|
||||
"description": "Изменить напоминание по id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reminder_id": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"due_at": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
"all_day": {"type": "boolean"},
|
||||
"recurrence": {
|
||||
"type": "string",
|
||||
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_reminder",
|
||||
"description": "Удалить напоминание по id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"reminder_id": {"type": "integer"}},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "complete_reminder",
|
||||
"description": "Отметить напоминание выполненным (снять с календаря).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"reminder_id": {"type": "integer"}},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
reminders = RemindersService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "list_reminders":
|
||||
return reminders.list_upcoming(limit=int(arguments.get("limit") or 20))
|
||||
if name == "create_reminder":
|
||||
return reminders.create(
|
||||
title=arguments.get("title", ""),
|
||||
due_at=arguments.get("due_at", ""),
|
||||
notes=arguments.get("notes", ""),
|
||||
all_day=bool(arguments.get("all_day", False)),
|
||||
recurrence=arguments.get("recurrence", "none"),
|
||||
)
|
||||
if name == "update_reminder":
|
||||
return reminders.update(
|
||||
int(arguments["reminder_id"]),
|
||||
title=arguments.get("title"),
|
||||
due_at=arguments.get("due_at"),
|
||||
notes=arguments.get("notes"),
|
||||
all_day=arguments.get("all_day"),
|
||||
recurrence=arguments.get("recurrence"),
|
||||
enabled=arguments.get("enabled"),
|
||||
)
|
||||
if name == "delete_reminder":
|
||||
return reminders.delete(int(arguments["reminder_id"]))
|
||||
if name == "complete_reminder":
|
||||
return reminders.complete(int(arguments["reminder_id"]))
|
||||
return NOT_HANDLED
|
||||
@@ -0,0 +1,132 @@
|
||||
from typing import Any
|
||||
|
||||
from app.shopping.service import ShoppingService
|
||||
from app.tools._dispatch import NOT_HANDLED, ToolContext
|
||||
|
||||
TOOL_NAMES = frozenset({
|
||||
"list_shopping_lists",
|
||||
"create_shopping_list",
|
||||
"add_shopping_items",
|
||||
"check_shopping_item",
|
||||
"remove_shopping_item",
|
||||
"delete_shopping_list",
|
||||
})
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_shopping_lists",
|
||||
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_shopping_list",
|
||||
"description": "Создать новый список покупок.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_shopping_items",
|
||||
"description": "Добавить товары в список. Список создаётся, если не существует.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list_name": {"type": "string", "description": "Название списка"},
|
||||
"list_id": {"type": "integer"},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
"quantity": {"type": "number"},
|
||||
"unit": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["items"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "check_shopping_item",
|
||||
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "integer"},
|
||||
"checked": {"type": "boolean"},
|
||||
},
|
||||
"required": ["item_id", "checked"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remove_shopping_item",
|
||||
"description": "Удалить позицию из списка по item_id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"item_id": {"type": "integer"}},
|
||||
"required": ["item_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_shopping_list",
|
||||
"description": "Удалить весь список покупок.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"list_id": {"type": "integer"}},
|
||||
"required": ["list_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any:
|
||||
if name not in TOOL_NAMES:
|
||||
return NOT_HANDLED
|
||||
|
||||
shopping = ShoppingService(ctx.db, ctx.user_id)
|
||||
|
||||
if name == "list_shopping_lists":
|
||||
return shopping.list_lists(include_items=True)
|
||||
if name == "create_shopping_list":
|
||||
return shopping.create_list(arguments.get("name", ""))
|
||||
if name == "add_shopping_items":
|
||||
return shopping.add_items(
|
||||
arguments.get("items") or [],
|
||||
list_id=arguments.get("list_id"),
|
||||
list_name=arguments.get("list_name"),
|
||||
)
|
||||
if name == "check_shopping_item":
|
||||
return shopping.set_item_checked(
|
||||
int(arguments["item_id"]),
|
||||
bool(arguments.get("checked", True)),
|
||||
)
|
||||
if name == "remove_shopping_item":
|
||||
return shopping.remove_item(int(arguments["item_id"]))
|
||||
if name == "delete_shopping_list":
|
||||
return shopping.delete_list(int(arguments["list_id"]))
|
||||
return NOT_HANDLED
|
||||
Reference in New Issue
Block a user