1077 lines
43 KiB
Python
1077 lines
43 KiB
Python
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_scoped.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": "search_documents",
|
||
"description": "Семантический поиск по загруженным документам (RAG).",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Поисковый запрос"},
|
||
"limit": {"type": "integer", "description": "Макс. фрагментов"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"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": "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"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"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). 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": [],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"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,
|
||
user_id: int,
|
||
) -> str:
|
||
pomodoro = PomodoroService(db, user_id)
|
||
projects = ProjectService(db, user_id)
|
||
memory = MemoryService(db, user_id)
|
||
fitness = FitnessService(db, user_id)
|
||
shopping = ShoppingService(db, user_id)
|
||
reminders = RemindersService(db, user_id)
|
||
|
||
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(user_id)
|
||
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 == "search_documents":
|
||
import asyncio
|
||
|
||
from app.rag.retriever import retrieve_document_chunks
|
||
|
||
async def _run():
|
||
return await retrieve_document_chunks(
|
||
arguments.get("query", ""),
|
||
user_id=user_id,
|
||
top_k=int(arguments.get("limit") or 6),
|
||
)
|
||
|
||
result = asyncio.run(_run())
|
||
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 == "calc_body_composition":
|
||
result = fitness.calc_body_composition(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":
|
||
day = None
|
||
if arguments.get("date"):
|
||
day = date.fromisoformat(str(arguments["date"]))
|
||
result = 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"),
|
||
)
|
||
elif name == "log_steps":
|
||
day = None
|
||
if arguments.get("date"):
|
||
day = date.fromisoformat(str(arguments["date"]))
|
||
result = 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"),
|
||
)
|
||
elif name == "log_workout":
|
||
structured = await structure_workout(arguments.get("text", ""))
|
||
day = None
|
||
if arguments.get("date"):
|
||
day = date.fromisoformat(str(arguments["date"]))
|
||
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"),
|
||
active_calories=structured.get("active_calories"),
|
||
total_calories=structured.get("total_calories"),
|
||
steps=structured.get("steps"),
|
||
day=day,
|
||
days_ago=arguments.get("days_ago"),
|
||
)
|
||
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,
|
||
user_id=user_id,
|
||
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)
|