refactor: split bot.py into runtime/dispatch/worker modules; add retry scheduler, route TTL, ack tracking, follow-up screener, structured event log, and test suite
This commit is contained in:
24 files changed
+3531
-434
No files matched your search
+94
-10
@@ -5,12 +5,23 @@ 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,
|
||||
)
|
||||
@@ -38,6 +49,7 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
|
||||
resp = _get_client().messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=256,
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
@@ -45,7 +57,8 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
|
||||
}
|
||||
],
|
||||
)
|
||||
shortened: str = resp.content[0].text.strip()
|
||||
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
|
||||
@@ -61,7 +74,7 @@ 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():
|
||||
if isinstance(block, TextBlock) and block.text.strip():
|
||||
text = block.text.strip()
|
||||
return text
|
||||
|
||||
@@ -94,8 +107,10 @@ def call_claude(
|
||||
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"}]
|
||||
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):
|
||||
@@ -103,6 +118,7 @@ def call_claude(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=4096,
|
||||
temperature=1.0,
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
system=prompt,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
@@ -119,14 +135,14 @@ def call_claude(
|
||||
|
||||
log.info("(web search round %d)...", round_num + 1)
|
||||
messages.append({"role": "assistant", "content": resp.content})
|
||||
tool_results: list[dict[str, str]] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for block in resp.content:
|
||||
if hasattr(block, "id") and block.type == "server_tool_use":
|
||||
if isinstance(block, ServerToolUseBlock):
|
||||
tool_results.append(
|
||||
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
|
||||
)
|
||||
if tool_results:
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
messages.append({"role": "user", "content": cast("Any", tool_results)})
|
||||
else:
|
||||
break
|
||||
|
||||
@@ -155,6 +171,7 @@ def rate_limit_message() -> str:
|
||||
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) "
|
||||
@@ -163,9 +180,11 @@ def rate_limit_message() -> str:
|
||||
),
|
||||
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
|
||||
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."
|
||||
@@ -186,3 +205,68 @@ def extract_trigger_query(text: str) -> str | None:
|
||||
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")
|
||||
Reference in new issue
Block a user