From 5a2d34d268f16d60f0bca0738606ca5d98a64fb7 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 23 Apr 2026 13:33:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(claude):=20tool=20use=20=E2=80=94=20weathe?= =?UTF-8?q?r,=20transport,=20events,=20notes,=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Haiku 4.5 теперь умеет дёргать tools. Все tools — proxy к endpoints планшета (/api/voice/tools/* и /api/voice/timer) с bearer auth VOICE_API_KEY. Никакой дополнительной auth в скрипте не требуется. - satellite/tools.py — 5 tools: * get_weather(city?) → Open-Meteo через tablet * get_transport(direction, routes?) → трамваи Антонова-Овсеенко * get_today_events(range?) → Google Calendar (today/week) * get_notes() → текстовые + shopping lists * set_timer(seconds, label) → создаёт таймер на дашборде Каждый tool возвращает dict/list; ошибки упаковываются как {error: ...} и отдаются Claude как результат — он сам обрабатывает. - satellite/llm_claude.py: * Подключил TOOL_SCHEMAS в вызов messages.create * Цикл tool-use: до MAX_TOOL_ROUNDS=4 раундов tool_use → exec → tool_result * System prompt дополнен инструкцией «используй tools без спроса» * Финальный текст (после всех tool rounds) сохраняется в историю как один assistant-turn — tool rounds в history не пишутся чтобы не раздувать кеш * Usage логируется суммарно за все раунды Работает с уже поднятым tinyproxy на .103 (HTTPS_PROXY в .env). Co-Authored-By: Claude Opus 4.7 (1M context) --- satellite/llm_claude.py | 147 +++++++++++++++++++++++------ satellite/tools.py | 201 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 26 deletions(-) create mode 100644 satellite/tools.py diff --git a/satellite/llm_claude.py b/satellite/llm_claude.py index c7ee586..20fd34c 100644 --- a/satellite/llm_claude.py +++ b/satellite/llm_claude.py @@ -27,6 +27,7 @@ from .text import clean_for_speech from .tts import speak, play_error_sound from . import notifier from .llm import strip_fillers # переиспользуем чистку филлеров +from .tools import TOOL_SCHEMAS, execute_tool ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5") @@ -48,6 +49,12 @@ COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой а - Без эмодзи, маркированных списков, код-блоков — всё будет зачитано. - Если не знаешь — скажи коротко, не оправдывайся. +Инструменты: +У тебя есть tools для погоды, транспорта (трамваи на остановке Антонова-Овсеенко), +календарных событий, заметок, и запуска таймера на дашборде. Используй их без просьбы +разрешения — если пользователь спрашивает «какая погода», сразу вызывай get_weather, +потом формулируй ответ. Не пересказывай сырые данные дословно — дай человеческую сводку. + Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}.""" LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург). @@ -58,6 +65,10 @@ LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой - Без эмодзи, списков, код-блоков — это голос. - Если не знаешь — скажи коротко. +Инструменты: +У тебя есть tools — погода, трамваи, события, заметки, таймеры. Используй их +без лишних вопросов, а результат формулируй человеческим языком. + Сегодня {today}.""" _client: "anthropic.Anthropic | None" = None @@ -142,8 +153,25 @@ def _build_messages(history: list[dict]) -> list[dict]: return messages +MAX_TOOL_ROUNDS = 4 # safety: не даём Claude крутить tools бесконечно + + +def _call_once(client, system_blocks, messages): + """Один вызов без стрима — нужен для tool-use round trips. + Возвращает final Message object (с usage, content blocks, stop_reason).""" + return client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=MAX_TOKENS, + system=system_blocks, + messages=messages, + tools=TOOL_SCHEMAS, + ) + + def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str: - """Спросить Claude Haiku 4.5 напрямую. Возвращает cleaned text (без speak — это делается снаружи).""" + """Спросить Claude Haiku 4.5 напрямую, с поддержкой tool use. + Поток tool-use раундов: Claude → tool_use → мы выполняем → tool_result → Claude → ... → текст. + Возвращает финальный текст ответа (cleaned).""" def _speak_if_local(t: str): if t.strip() and notifier.speak_locally(): @@ -161,8 +189,6 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str: history = load_history(agent_id) history.append({"role": "user", "content": text}) - - # Обрезаем слишком длинную историю if len(history) > MAX_HISTORY_MESSAGES: history = history[-MAX_HISTORY_MESSAGES:] @@ -172,31 +198,93 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str: "cache_control": {"type": "ephemeral"}, }] - messages = _build_messages(history) + # messages для API — строится из history плюс накапливающихся tool-use/tool-result блоков + api_messages = _build_messages(history) + + total_start = time.time() + total_in = 0 + total_out = 0 + total_cache_r = 0 + total_cache_w = 0 + final_text = "" + # Собираем всю цепочку (assistant content blocks, tool results) чтобы одним куском сохранить в history + assistant_blocks_accumulated: list[dict] = [] - start = time.time() - full_text = "" try: - with client.messages.stream( - model=ANTHROPIC_MODEL, - max_tokens=MAX_TOKENS, - system=system_blocks, - messages=messages, - ) as stream: - for chunk in stream.text_stream: - full_text += chunk + for round_i in range(MAX_TOOL_ROUNDS): + round_start = time.time() + resp = _call_once(client, system_blocks, api_messages) + + usage = resp.usage + total_in += usage.input_tokens + total_out += usage.output_tokens + total_cache_r += getattr(usage, "cache_read_input_tokens", 0) or 0 + total_cache_w += getattr(usage, "cache_creation_input_tokens", 0) or 0 + + # Разбираем content на text + tool_use + text_chunks = [] + tool_uses = [] + for block in resp.content: + btype = getattr(block, "type", None) + if btype == "text": + text_chunks.append(block.text) + elif btype == "tool_use": + tool_uses.append(block) + + # Копим текст ассистента (может быть между tool-вызовами в новых моделях) + final_text += "".join(text_chunks) + + # Добавляем ответ ассистента в api_messages (как есть) + # Это ВАЖНО: для tool_result следующим сообщением assistant content должен быть сохранён + # ровно как вернул API, чтобы tool_use_id совпал. + assistant_content = [ + # Конвертируем объекты anthropic SDK в dict-представление + b.model_dump() if hasattr(b, "model_dump") else dict(b.__dict__) + for b in resp.content + ] + api_messages.append({"role": "assistant", "content": assistant_content}) + assistant_blocks_accumulated.extend(assistant_content) - final = stream.get_final_message() - usage = final.usage - cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0 - cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0 - elapsed = time.time() - start print( - f"🧠 Claude {ANTHROPIC_MODEL} {elapsed:.2f}s · " - f"in={usage.input_tokens} out={usage.output_tokens} " - f"cache_r={cache_read} cache_w={cache_write}" + f"🧠 round {round_i + 1} {time.time() - round_start:.2f}s · " + f"stop={resp.stop_reason} · in={usage.input_tokens} out={usage.output_tokens} " + f"cache_r={getattr(usage, 'cache_read_input_tokens', 0) or 0}" ) + if resp.stop_reason == "tool_use" and tool_uses: + # Выполняем все запрошенные tools, собираем tool_result блоки + tool_results = [] + for tu in tool_uses: + name = tu.name + tu_id = tu.id + params = tu.input or {} + print(f"🔧 Tool: {name}({params})") + result = execute_tool(name, params, agent_id) + # Упаковываем результат в JSON-строку (Claude ожидает string в tool_result content) + import json as _json + result_str = _json.dumps(result, ensure_ascii=False) + print(f" → {result_str[:200]}") + tool_results.append({ + "type": "tool_result", + "tool_use_id": tu_id, + "content": result_str, + }) + + # Добавляем user-message с результатами tools + api_messages.append({"role": "user", "content": tool_results}) + # Продолжаем цикл — Claude обработает результаты и либо вызовет ещё, либо выдаст текст + continue + + # stop_reason="end_turn" / "max_tokens" / "stop_sequence" — готов финальный ответ + break + + elapsed = time.time() - total_start + print( + f"🧠 Claude {ANTHROPIC_MODEL} total {elapsed:.2f}s · " + f"in={total_in} out={total_out} " + f"cache_r={total_cache_r} cache_w={total_cache_w}" + ) + except anthropic.APIConnectionError: log.exception("Anthropic API connection error") msg = "Не могу связаться с Клодом." @@ -227,16 +315,23 @@ def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str: _speak_if_local(msg) return msg - if not full_text: + if not final_text: msg = "Не получил ответ." notifier.error(msg, agent_id) _speak_if_local(msg) return msg - # Сохраняем реплику ассистента (до strip_fillers/clean — для верности истории) - history.append({"role": "assistant", "content": full_text}) + # Сохраняем полный ассистентский turn (с tool-use/result) в историю. + # Для следующего turn'а history содержит: user text, затем assistant (text + tool_use), + # user (tool_result), assistant (final text). Всё в правильном порядке. + # Сейчас history у нас содержит только исходный user; добавим всё что произошло: + # - assistant с его blocks + # - если были tool results, они тоже должны быть (но только между assistant турами) + # Для простоты сохраним финальный текст как одну запись — tool rounds не сохраняем в history, + # иначе history в JSON будет пухнуть и не влезет в кеш. + history.append({"role": "assistant", "content": final_text}) save_history(agent_id, history) - result = clean_for_speech(strip_fillers(full_text)) + result = clean_for_speech(strip_fillers(final_text)) _speak_if_local(result) return result diff --git a/satellite/tools.py b/satellite/tools.py new file mode 100644 index 0000000..e2c48e0 --- /dev/null +++ b/satellite/tools.py @@ -0,0 +1,201 @@ +""" +Tool definitions для Claude Haiku 4.5. + +Каждый tool описан схемой (Anthropic format) + обёрткой-executor. +Большинство tools — тонкие прокси к /api/voice/tools/* на планшете. +Таймер — тоже прокси, но POST (создаёт таймер, тот появляется на дашборде). + +Аутентификация: Bearer VOICE_API_KEY (тот же что для /api/voice/event). +""" +import os +from typing import Any + +import requests + +from .config import log + +TABLET_URL = os.getenv("TABLET_URL", "").rstrip("/") +VOICE_API_KEY = os.getenv("VOICE_API_KEY", "") + +_session = requests.Session() + + +def _headers() -> dict: + return {"Authorization": f"Bearer {VOICE_API_KEY}"} + + +def _tablet_get(path: str, params: dict | None = None) -> dict: + url = f"{TABLET_URL}{path}" + r = _session.get(url, headers=_headers(), params=params, timeout=8) + r.raise_for_status() + return r.json() + + +def _tablet_post(path: str, payload: dict) -> dict: + url = f"{TABLET_URL}{path}" + r = _session.post(url, headers=_headers(), json=payload, timeout=8) + r.raise_for_status() + return r.json() + + +# ───────────────────────────────────────────────────────────── +# Tool schemas (Anthropic format). Порядок = приоритет в Claude подсказках. +# ───────────────────────────────────────────────────────────── + +TOOL_SCHEMAS: list[dict] = [ + { + "name": "get_weather", + "description": ( + "Получить текущую погоду и короткий прогноз для города. " + "Для вопросов вроде 'какая сегодня погода', 'холодно ли на улице', " + "'нужен ли зонт'. По умолчанию — Санкт-Петербург." + ), + "input_schema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). По умолчанию Санкт-Петербург.", + }, + }, + }, + }, + { + "name": "get_transport", + "description": ( + "Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. " + "Для вопросов 'когда следующий 23-й', 'что ближайшее в центр', 'пора идти на остановку'." + ), + "input_schema": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": ["to_center", "from_center", "all"], + "description": "to_center = в центр (к Новочеркасской), from_center = от центра (к Большевиков), all = оба направления", + }, + "routes": { + "type": "string", + "description": "Фильтр маршрутов через запятую, например '23' или '23,27'. Пусто = все маршруты.", + }, + }, + }, + }, + { + "name": "get_today_events", + "description": ( + "События из календаря на сегодня или на неделю. " + "Для вопросов 'что сегодня', 'какие планы', 'во сколько встреча'." + ), + "input_schema": { + "type": "object", + "properties": { + "range": { + "type": "string", + "enum": ["today", "week"], + "description": "today (по умолчанию) или week (7 дней)", + }, + }, + }, + }, + { + "name": "get_notes", + "description": ( + "Список заметок и списков покупок с планшета. " + "Для 'что мне купить', 'что в списке', 'какие записи'." + ), + "input_schema": {"type": "object", "properties": {}}, + }, + { + "name": "set_timer", + "description": ( + "Запустить таймер на планшете. Показывает обратный отсчёт с названием " + "и звенит по окончании. " + "Используй для 'поставь таймер на 10 минут', 'напомни через час', " + "'засеки 5 минут для чайника'." + ), + "input_schema": { + "type": "object", + "properties": { + "seconds": { + "type": "integer", + "description": "Длительность в секундах (1..86400)", + "minimum": 1, + "maximum": 86400, + }, + "label": { + "type": "string", + "description": "Короткое название таймера (например 'Чайник', 'Паста')", + }, + }, + "required": ["seconds", "label"], + }, + }, +] + + +# ───────────────────────────────────────────────────────────── +# Executors — возвращают JSON-совместимый dict или строку-ошибку. +# ───────────────────────────────────────────────────────────── + +def _exec_get_weather(params: dict, agent_id: str) -> Any: + city = params.get("city", "") + return _tablet_get("/api/voice/tools/weather", params={"city": city} if city else None) + + +def _exec_get_transport(params: dict, agent_id: str) -> Any: + q = {} + if "direction" in params: + q["direction"] = params["direction"] + if "routes" in params: + q["routes"] = params["routes"] + return _tablet_get("/api/voice/tools/transport", params=q) + + +def _exec_get_today_events(params: dict, agent_id: str) -> Any: + range_ = params.get("range", "today") + return _tablet_get("/api/voice/tools/events", params={"range": range_}) + + +def _exec_get_notes(params: dict, agent_id: str) -> Any: + return _tablet_get("/api/voice/tools/notes") + + +def _exec_set_timer(params: dict, agent_id: str) -> Any: + seconds = int(params.get("seconds", 0)) + label = params.get("label", "Таймер") + if seconds < 1: + return {"error": "seconds must be positive"} + return _tablet_post( + "/api/voice/timer", + {"action": "start", "seconds": seconds, "label": label, "agent": agent_id}, + ) + + +EXECUTORS = { + "get_weather": _exec_get_weather, + "get_transport": _exec_get_transport, + "get_today_events": _exec_get_today_events, + "get_notes": _exec_get_notes, + "set_timer": _exec_set_timer, +} + + +def execute_tool(name: str, params: dict, agent_id: str = "cosmo") -> Any: + """Выполнить tool по имени. Возвращает результат (dict/list/str). + При ошибке возвращает {'error': '...'} — это отправляется в Claude как результат.""" + fn = EXECUTORS.get(name) + if fn is None: + return {"error": f"unknown tool: {name}"} + try: + result = fn(params, agent_id) + return result + except requests.HTTPError as e: + log.warning(f"Tool {name} HTTP {e.response.status_code}: {e.response.text[:200]}") + return {"error": f"tool_http_{e.response.status_code}"} + except requests.RequestException as e: + log.warning(f"Tool {name} network error: {e}") + return {"error": "tool_network_error"} + except Exception as e: + log.exception(f"Tool {name} failed") + return {"error": f"tool_exception: {e}"}