""" Прямой клиент Claude Haiku 4.5 (Anthropic SDK) — альтернатива OpenClaw gateway. Отличия от `llm.ask_agent_stream`: * Сессия и история живут **локально** на клиенте (JSON в HISTORY_DIR/{agent}-{date}.json). Смена даты = автосброс. * Prompt caching через Anthropic cache_control: system prompt и старая часть истории кешируются на 5 минут → latency first-token ниже, стоимость -90% на cached-tokens. * Используется когда LLM_BACKEND=claude. Если HTTPS_PROXY задан (напр. http://192.168.31.103:8888) — httpx подхватит автоматически, Anthropic SDK пойдёт через прокси. """ import json import os import time from datetime import date from pathlib import Path try: import anthropic except ImportError: anthropic = None # SDK опциональный, активируется только при LLM_BACKEND=claude from .config import log from .text import clean_for_speech from .tts import speak, play_error_sound from . import notifier from .llm import strip_fillers # переиспользуем чистку филлеров ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5") HISTORY_DIR = Path(os.getenv("HISTORY_DIR", "data/history")) MAX_TOKENS = int(os.getenv("VOICE_MAX_TOKENS", "300")) MAX_HISTORY_MESSAGES = int(os.getenv("MAX_HISTORY", "40")) # Граница кеша — все сообщения кроме последних N идут в cache block, # что даёт prompt caching хит каждый турн. CACHE_TAIL_UNCACHED = 2 COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой ассистент Даниила (Санкт-Петербург). Стиль: - Короткие ответы: 1-2 предложения, редко 3. Это голосовой канал — многословность утомляет. - Разговорный русский, без канцелярита, без формальных оборотов («здравствуйте», «уважаемый»). - Обращение на «ты». - Не предваряй ответ фразами-заполнителями («сейчас посмотрю», «минутку», «проверяю») — сразу отвечай. - Без эмодзи, маркированных списков, код-блоков — всё будет зачитано. - Если не знаешь — скажи коротко, не оправдывайся. Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}.""" LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург). Стиль: - Тёплый, заботливый, чуть эмоциональный, но лаконичный. 1-2 предложения. - Обращение на «ты». - Без эмодзи, списков, код-блоков — это голос. - Если не знаешь — скажи коротко. Сегодня {today}.""" _client: "anthropic.Anthropic | None" = None def _get_client() -> "anthropic.Anthropic": global _client if anthropic is None: raise RuntimeError( "anthropic SDK не установлен. Запусти `pip install anthropic` " "или оставь LLM_BACKEND=openclaw." ) if not ANTHROPIC_API_KEY: raise RuntimeError("ANTHROPIC_API_KEY не задан в .env") if _client is None: _client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) return _client def _system_prompt(agent_id: str) -> str: template = LUSYA_SYSTEM_PROMPT if agent_id == "lusya" else COSMO_SYSTEM_PROMPT return template.format(today=date.today().isoformat()) def _history_path(agent_id: str) -> Path: HISTORY_DIR.mkdir(parents=True, exist_ok=True) today = date.today().isoformat() return HISTORY_DIR / f"{agent_id}-{today}.json" def load_history(agent_id: str) -> list[dict]: path = _history_path(agent_id) if not path.exists(): return [] try: return json.loads(path.read_text(encoding="utf-8")) except Exception: log.exception(f"Не смог прочитать историю {path}") return [] def save_history(agent_id: str, history: list[dict]): path = _history_path(agent_id) try: path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: log.exception(f"Не смог сохранить историю {path}") def reset_history(agent_id: str): """Удаляет историю диалога за текущий день.""" path = _history_path(agent_id) if path.exists(): path.unlink() log.info(f"История сброшена: {path}") def _build_messages(history: list[dict]) -> list[dict]: """ Готовит messages array для Claude API с prompt caching. Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша), а всё что раньше — помечается cache_control на границе. """ if len(history) <= CACHE_TAIL_UNCACHED: return [{"role": m["role"], "content": m["content"]} for m in history] cache_boundary = len(history) - CACHE_TAIL_UNCACHED messages = [] for i, msg in enumerate(history): # Граница кеша — на последнем «старом» сообщении ставим cache_control. if i == cache_boundary - 1: messages.append({ "role": msg["role"], "content": [{ "type": "text", "text": msg["content"], "cache_control": {"type": "ephemeral"}, }], }) else: messages.append({"role": msg["role"], "content": msg["content"]}) return messages def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str: """Спросить Claude Haiku 4.5 напрямую. Возвращает cleaned text (без speak — это делается снаружи).""" def _speak_if_local(t: str): if t.strip() and notifier.speak_locally(): speak(t, agent_id) try: client = _get_client() except RuntimeError as e: log.error(str(e)) msg = "Клод не настроен, попробуй OpenClaw." play_error_sound() notifier.error(msg, agent_id) _speak_if_local(msg) return msg history = load_history(agent_id) history.append({"role": "user", "content": text}) # Обрезаем слишком длинную историю if len(history) > MAX_HISTORY_MESSAGES: history = history[-MAX_HISTORY_MESSAGES:] system_blocks = [{ "type": "text", "text": _system_prompt(agent_id), "cache_control": {"type": "ephemeral"}, }] messages = _build_messages(history) 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 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}" ) except anthropic.APIConnectionError: log.exception("Anthropic API connection error") msg = "Не могу связаться с Клодом." play_error_sound() notifier.error(msg, agent_id) _speak_if_local(msg) return msg except anthropic.APITimeoutError: log.exception("Anthropic timeout") msg = "Клод не ответил вовремя." play_error_sound() notifier.error(msg, agent_id) _speak_if_local(msg) return msg except anthropic.APIStatusError as e: status = getattr(e, "status_code", "?") log.exception(f"Anthropic API status {status}") msg = "Ошибка Клода." play_error_sound() notifier.error(msg, agent_id) _speak_if_local(msg) return msg except Exception as e: log.exception(f"Неожиданная ошибка Claude: {e}") msg = "Что-то сломалось." play_error_sound() notifier.error(msg, agent_id) _speak_if_local(msg) return msg if not full_text: msg = "Не получил ответ." notifier.error(msg, agent_id) _speak_if_local(msg) return msg # Сохраняем реплику ассистента (до strip_fillers/clean — для верности истории) history.append({"role": "assistant", "content": full_text}) save_history(agent_id, history) result = clean_for_speech(strip_fillers(full_text)) _speak_if_local(result) return result