Split 1424-line monolith into orac/ package with clean separation: - constants.py — bot identity, radio config, triggers, limits - crypto.py — Ed25519 identity, channel/peer crypto, ECDH - meshcore.py — packet parsing/building, routing, dedup, node registry - ai.py — Claude API integration, query extraction - state.py — JSON persistence, history tracking - bot.py — main loop, packet dispatch, connection management Removed duplicated donglora library code (cobs_frame, read_frame, encode_radio_config, encode_command, decode_response) — now uses donglora.send/recv/connect directly. Added MeshCore Public channel (fixed PSK 8b3387e9...cd72) and expanded built-in hashtag channels: #nodeops, #repeater-ops, #test, #weather, #protest, #general, #bot, #net, #emcomm. Replaced ANSI print() with stdlib logging (color formatter for TTY). Fixed dependency (donglora-python -> donglora). Strict ruff linting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
6.3 KiB
Python
189 lines
6.3 KiB
Python
"""Claude API integration: query extraction, response shortening."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
import time
|
|
|
|
import anthropic
|
|
|
|
from orac.constants import (
|
|
AT_MENTIONS,
|
|
MAX_RESPONSE_CHARS,
|
|
SYSTEM_PROMPT,
|
|
TRIGGERS,
|
|
)
|
|
|
|
log = logging.getLogger("orac")
|
|
|
|
# ── Anthropic client singleton ───────────────────────────────────
|
|
|
|
_client: anthropic.Anthropic | None = None
|
|
|
|
|
|
def _get_client() -> anthropic.Anthropic:
|
|
global _client
|
|
if _client is None:
|
|
_client = anthropic.Anthropic()
|
|
return _client
|
|
|
|
|
|
# ── Response shortening ──────────────────────────────────────────
|
|
|
|
|
|
def _shorten_with_claude(text: str, max_chars: int) -> str:
|
|
"""Use Claude to shorten text to fit within max_chars."""
|
|
try:
|
|
resp = _get_client().messages.create(
|
|
model="claude-sonnet-4-6",
|
|
max_tokens=256,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": f"Shorten this to {max_chars} characters: {text}",
|
|
}
|
|
],
|
|
)
|
|
shortened: str = resp.content[0].text.strip()
|
|
except Exception as e:
|
|
log.error("Shorten API error: %s", e)
|
|
shortened = text
|
|
encoded = shortened.encode("utf-8")
|
|
if len(encoded) > max_chars:
|
|
while len(shortened.encode("utf-8")) > max_chars - 1:
|
|
shortened = shortened[:-1]
|
|
shortened += "\u2026"
|
|
return shortened
|
|
|
|
|
|
def _extract_text(resp: anthropic.types.Message) -> str:
|
|
"""Extract the last non-empty text block from a Claude response."""
|
|
text = ""
|
|
for block in resp.content:
|
|
if block.type == "text" and block.text.strip():
|
|
text = block.text.strip()
|
|
return text
|
|
|
|
|
|
# ── Main query function ─────────────────────────────────────────
|
|
|
|
|
|
def call_claude(
|
|
query: str,
|
|
sender: str,
|
|
history: list[str],
|
|
max_chars: int,
|
|
context_label: str,
|
|
) -> str | None:
|
|
"""Send a query to Claude with conversation context. Returns response text or None."""
|
|
try:
|
|
nonce = random.randint(1000, 9999)
|
|
now = time.strftime("%Y-%m-%d %H:%M %Z")
|
|
prompt = (
|
|
SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}"
|
|
)
|
|
|
|
if history:
|
|
context = "\n".join(history)
|
|
user_content = (
|
|
f"[seed:{nonce}]\n"
|
|
f"Recent messages in {context_label}:\n{context}\n\n"
|
|
f"{sender} says: {query}"
|
|
)
|
|
else:
|
|
user_content = f"[seed:{nonce}] {sender} says: {query}"
|
|
|
|
messages: list[dict[str, object]] = [{"role": "user", "content": user_content}]
|
|
tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}]
|
|
|
|
text = ""
|
|
for round_num in range(3):
|
|
resp = _get_client().messages.create(
|
|
model="claude-sonnet-4-6",
|
|
max_tokens=4096,
|
|
temperature=1.0,
|
|
system=prompt,
|
|
tools=tools,
|
|
messages=messages,
|
|
)
|
|
|
|
text = _extract_text(resp)
|
|
if text:
|
|
break
|
|
|
|
if resp.stop_reason != "tool_use":
|
|
block_types = [b.type for b in resp.content]
|
|
log.warning("No text, stop=%s, blocks=%s", resp.stop_reason, block_types)
|
|
break
|
|
|
|
log.info("(web search round %d)...", round_num + 1)
|
|
messages.append({"role": "assistant", "content": resp.content})
|
|
tool_results: list[dict[str, str]] = []
|
|
for block in resp.content:
|
|
if hasattr(block, "id") and block.type == "server_tool_use":
|
|
tool_results.append(
|
|
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
|
|
)
|
|
if tool_results:
|
|
messages.append({"role": "user", "content": tool_results})
|
|
else:
|
|
break
|
|
|
|
if not text:
|
|
log.warning("No text after tool loop")
|
|
return None
|
|
except Exception as e:
|
|
log.error("Claude API error: %s", e)
|
|
return None
|
|
|
|
if len(text.encode("utf-8")) > max_chars:
|
|
log.warning("Response too long (%dB), shortening...", len(text.encode("utf-8")))
|
|
text = _shorten_with_claude(text, max_chars)
|
|
|
|
return text
|
|
|
|
|
|
# ── Rate-limit message ───────────────────────────────────────────
|
|
|
|
|
|
def rate_limit_message() -> str:
|
|
"""Generate a witty rate-limit message via Claude."""
|
|
try:
|
|
nonce = random.randint(1000, 9999)
|
|
resp = _get_client().messages.create(
|
|
model="claude-sonnet-4-6",
|
|
max_tokens=100,
|
|
temperature=1.0,
|
|
system=(
|
|
f"You are Orac, a sardonic AI on a radio mesh network. "
|
|
f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) "
|
|
f"telling someone to slow down and try again in a few seconds. "
|
|
f"Be witty, varied, and in-character. No quotes around the message."
|
|
),
|
|
messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}],
|
|
)
|
|
text: str = resp.content[0].text.strip().strip("\"'")
|
|
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
|
|
return text
|
|
except Exception:
|
|
pass
|
|
return "Patience. Try again in a moment."
|
|
|
|
|
|
# ── Trigger detection ────────────────────────────────────────────
|
|
|
|
|
|
def extract_trigger_query(text: str) -> str | None:
|
|
"""Check for !triggers and @mentions. Returns query text or None."""
|
|
lower = text.lower()
|
|
for trigger in (*TRIGGERS, *AT_MENTIONS):
|
|
idx = lower.find(trigger)
|
|
if idx != -1:
|
|
before = text[:idx]
|
|
after = text[idx + len(trigger) :]
|
|
query = (before + " " + after).strip()
|
|
if query:
|
|
return query
|
|
return None
|