Files
Home_assistant/backend/app/tools/registry.py
T
2026-06-16 08:04:15 +03:00

1106 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (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"],
},
},
},
{
"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": [],
},
},
},
{
"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",
"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
}
result = fitness.set_profile(updates)
elif name == "calc_fitness_targets":
from app.fitness.calculators import compute_daily_targets
steps = int(arguments.get("steps") or 0)
result = compute_daily_targets(arguments, steps_total=steps, workouts=[])
elif name == "calc_body_composition":
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"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
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 = 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)
result = {
"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 "",
}
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)