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>
This commit is contained in:
11 files changed
+2350
-1417
No files matched your search
+188
@@ -0,0 +1,188 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user