Tighten the system/user prompts so the shortener returns ONLY the rewritten message — no "Here's a version at N characters:" preamble, no quotes, no labels. Previously such preamble was being transmitted on-air and consumed the byte budget, causing the actual content to truncate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
9.9 KiB
Python
283 lines
9.9 KiB
Python
"""Claude API integration: query extraction, response shortening."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
import time
|
|
from typing import Any, cast
|
|
|
|
import anthropic
|
|
from anthropic.types import (
|
|
MessageParam,
|
|
ServerToolUseBlock,
|
|
TextBlock,
|
|
ToolUnionParam,
|
|
)
|
|
|
|
from orac.constants import (
|
|
AT_MENTIONS,
|
|
BOT_NAME,
|
|
CLAUDE_TIMEOUT_S,
|
|
MAX_RESPONSE_CHARS,
|
|
SCREENER_MODEL,
|
|
SCREENER_TIMEOUT_S,
|
|
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,
|
|
timeout=CLAUDE_TIMEOUT_S,
|
|
system=(
|
|
"You rewrite a message to fit within a strict character budget. "
|
|
"Output ONLY the rewritten message — no preamble, no quotes, no "
|
|
"explanation, no commentary, no labels, no character count. "
|
|
"Preserve the original meaning, tone, and voice. Plain text only."
|
|
),
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Rewrite the following message so it is at most "
|
|
f"{max_chars} characters. Reply with the rewritten "
|
|
f"message only.\n\n{text}"
|
|
),
|
|
}
|
|
],
|
|
)
|
|
first = resp.content[0] if resp.content else None
|
|
shortened = first.text.strip() if isinstance(first, TextBlock) else text
|
|
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 isinstance(block, TextBlock) 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[MessageParam] = [{"role": "user", "content": user_content}]
|
|
tools: list[ToolUnionParam] = [
|
|
cast("ToolUnionParam", {"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,
|
|
timeout=CLAUDE_TIMEOUT_S,
|
|
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, Any]] = []
|
|
for block in resp.content:
|
|
if isinstance(block, ServerToolUseBlock):
|
|
tool_results.append(
|
|
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
|
|
)
|
|
if tool_results:
|
|
messages.append({"role": "user", "content": cast("Any", 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,
|
|
timeout=CLAUDE_TIMEOUT_S,
|
|
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."}],
|
|
)
|
|
first = resp.content[0] if resp.content else None
|
|
if isinstance(first, TextBlock):
|
|
text = first.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
|
|
|
|
|
|
# ── Channel follow-up screener ─────────────────────────────────
|
|
|
|
_SCREENER_SYSTEM_PROMPT = f"""\
|
|
You are a one-shot classifier. Your job is to decide whether the LATEST message
|
|
in a group chat is addressed (implicitly) to an AI assistant named {BOT_NAME}.
|
|
|
|
Reply with exactly one token: YES or NO. No punctuation, no explanation.
|
|
|
|
Say YES only when ALL of the following hold:
|
|
- {BOT_NAME} has been part of the recent conversation (see history).
|
|
- The latest message is plausibly a direct follow-up to {BOT_NAME}'s most
|
|
recent message: answering a question {BOT_NAME} asked, providing info
|
|
{BOT_NAME} requested, pushing back on {BOT_NAME}'s claim, or continuing
|
|
a thread {BOT_NAME} was participating in.
|
|
- No other human in the chat is the more obvious addressee.
|
|
|
|
Say NO if:
|
|
- The message is between two other humans, about an unrelated topic.
|
|
- The message could go either way. When uncertain, NO. People hate bot noise.
|
|
- The message just mentions or references {BOT_NAME} third-person without
|
|
asking anything ("someone could ask {BOT_NAME}").
|
|
- {BOT_NAME} wasn't in the recent conversation at all.
|
|
|
|
Err strongly toward NO. It's much better to miss a follow-up than to barge into
|
|
a conversation where nobody invited you."""
|
|
|
|
|
|
def screen_channel_message(
|
|
channel_name: str,
|
|
sender: str,
|
|
text: str,
|
|
history: list[str],
|
|
) -> bool:
|
|
"""Classify whether *text* (in *channel_name*) is a follow-up for Orac.
|
|
|
|
Uses a cheap Haiku call. Returns True for YES, False for NO/error.
|
|
"""
|
|
try:
|
|
history_block = "\n".join(history[-10:]) if history else "(no prior messages)"
|
|
user_content = (
|
|
f"Channel: {channel_name}\n\n"
|
|
f"Recent history (oldest first):\n{history_block}\n\n"
|
|
f"Latest message:\n{sender}: {text}\n\n"
|
|
f"Verdict (YES or NO):"
|
|
)
|
|
resp = _get_client().messages.create(
|
|
model=SCREENER_MODEL,
|
|
max_tokens=4,
|
|
temperature=0.0,
|
|
timeout=SCREENER_TIMEOUT_S,
|
|
system=_SCREENER_SYSTEM_PROMPT,
|
|
messages=[{"role": "user", "content": user_content}],
|
|
)
|
|
except Exception as e:
|
|
log.warning("Screener API error: %s", e)
|
|
return False
|
|
|
|
first = resp.content[0] if resp.content else None
|
|
if not isinstance(first, TextBlock):
|
|
return False
|
|
verdict = first.text.strip().upper()
|
|
# Accept exact "YES" or a response starting with YES; anything else is NO.
|
|
return verdict.startswith("YES")
|