Files
ai-bot/orac/ai.py
T
swaitsandClaude Opus 4.6 9e6f171c5d feat!: modularize into orac/ package, add Public channel, deduplicate
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>
2026-04-08 13:34:18 -06:00

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