feat(claude): tool use — weather, transport, events, notes, timer

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) <noreply@anthropic.com>
This commit is contained in:
Cosmo
2026-04-23 13:33:51 +00:00
parent 356543afdb
commit 5a2d34d268
2 changed files with 322 additions and 26 deletions

View File

@@ -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