Dynamic passive mode

This commit is contained in:
John Burwell 2025-10-03 16:24:10 +00:00
parent 79d901a9b5
commit 7ba19523ab
3 changed files with 503 additions and 108 deletions

View File

@ -27,6 +27,13 @@ The Chat plugin supports the following configuration parameters:
- **`system_prompt`**: The system prompt to guide the assistant's behavior. Default: `You are a helpful assistant.`. - **`system_prompt`**: The system prompt to guide the assistant's behavior. Default: `You are a helpful assistant.`.
- **`scrollback_lines`**: The number of recent lines from the channel to include as context. Default: `10`. - **`scrollback_lines`**: The number of recent lines from the channel to include as context. Default: `10`.
- **`join_string`**: The string used to join multi-line responses into a single line. Default: ` / `. - **`join_string`**: The string used to join multi-line responses into a single line. Default: ` / `.
- **`passive_mode`**: Controls passive participation. Options: `off`, `mention`, `smart`. Default: `off`.
- **`passive_probability`**: When `passive_mode` is `smart`, probability (0-1) that the bot considers replying when heuristics match. Default: `0.35`.
- **`passive_max_replies`**: Maximum passive replies per thread (`-1` disables the cap). Default: `3`.
- **`passive_engagement_timeout`**: Seconds before an active passive thread expires if the bot stays quiet. Default: `180`.
- **`passive_cooldown`**: Cooldown in seconds after ending a passive thread before starting a new one. Default: `120`.
- **`passive_trigger_words`**: Space-separated keywords that increase the chance of a passive response in `smart` mode. Default: *(empty)*.
- **`passive_prompt_addendum`**: Text appended to the system prompt while passive mode is active, shaping the bot's etiquette.
### Example Configuration ### Example Configuration
@ -45,6 +52,22 @@ To adjust the maximum tokens:
/msg BotName config plugins.Chat.max_tokens 512 /msg BotName config plugins.Chat.max_tokens 512
``` ```
### Passive Mode
Enable light-weight participation by switching `passive_mode` to `mention` so the bot automatically answers when called by name:
```
/msg BotName config plugins.Chat.passive_mode mention
```
For a looser "hang out" presence, activate `smart` mode and adjust the heuristics:
```
/msg BotName config plugins.Chat.passive_mode smart
/msg BotName config plugins.Chat.passive_probability 0.25
/msg BotName config plugins.Chat.passive_trigger_words help thoughts idea
```
In smart mode the bot watches channel flow, but only jumps in when it is confident it can help or close an active thread. Direct `.chat`/`@Bot chat` commands still work exactly as before.
## Usage ## Usage
Once configured, you can use the `chat` command to interact with the bot. For example: Once configured, you can use the `chat` command to interact with the bot. For example:

View File

@ -121,6 +121,70 @@ conf.registerGlobalValue(
) )
) )
# Passive interaction settings
conf.registerGlobalValue(
Chat,
"passive_mode",
registry.String(
"off",
_("""Controls passive participation. Options: off, mention, smart."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_probability",
registry.Float(
0.35,
_("""When passive_mode is 'smart', probability (0-1) that the bot considers chiming in on eligible lines."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_max_replies",
registry.Integer(
3,
_("""Maximum number of passive replies per thread (-1 for no cap)."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_engagement_timeout",
registry.Integer(
180,
_("""Seconds after which an ongoing thread expires if the bot stays silent."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_cooldown",
registry.Integer(
120,
_("""Seconds to stay quiet after ending a passive thread before starting a new one."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_trigger_words",
registry.SpaceSeparatedListOfStrings(
[],
_("""Extra keywords that make the bot more likely to respond in smart mode."""),
)
)
conf.registerGlobalValue(
Chat,
"passive_prompt_addendum",
registry.String(
"Stay aware of the channel like a quiet regular. Only jump in when someone addresses $bot_name, when you are already in a thread, or when you are very confident a brief reply will help. Let conversations end naturally if no one follows up.",
_("""Additional guidance appended to the system prompt when passive mode is enabled."""),
)
)
# Logging level for the plugin # Logging level for the plugin
conf.registerGlobalValue( conf.registerGlobalValue(
Chat, Chat,

524
plugin.py
View File

@ -28,14 +28,20 @@
### ###
import enum
import json import json
import re
import requests
import supybot
from supybot import callbacks, conf, ircutils
from supybot.commands import *
import logging import logging
import os import os
import random
import re
import time
from dataclasses import dataclass
from typing import Dict, Optional
import requests
import supybot
from supybot import callbacks, conf, ircmsgs, ircutils, schedule
from supybot.commands import *
try: try:
from supybot.i18n import PluginInternationalization from supybot.i18n import PluginInternationalization
@ -69,6 +75,49 @@ def truncate_messages(messages, max_tokens):
return truncated return truncated
class EngagementState(enum.Enum):
IDLE = "idle"
INVITED = "invited"
ENGAGED = "engaged"
COOLING = "cooling"
@dataclass
class ChannelSession:
state: EngagementState = EngagementState.IDLE
thread_owner: Optional[str] = None
last_invitation: float = 0.0
last_spoken: float = 0.0
last_user_msg: float = 0.0
replies_in_thread: int = 0
cooling_until: float = 0.0
def reset(self):
self.state = EngagementState.IDLE
self.thread_owner = None
self.last_invitation = 0.0
self.last_spoken = 0.0
self.last_user_msg = 0.0
self.replies_in_thread = 0
self.cooling_until = 0.0
CLASSIFIER_SYSTEM_PROMPT = (
"You evaluate whether an IRC bot should reply."
" Respond with exactly one word: reply or skip."
" Choose reply only if the bot's participation would be helpful,"
" expected, or keeps an active thread alive."
)
def is_poetry_block(lines):
if 2 < len(lines) <= 4:
avg_len = sum(len(l) for l in lines) / len(lines)
if avg_len < 20 and all(not l.endswith((".", "?", "!")) for l in lines):
return True
return False
class Chat(callbacks.Plugin): class Chat(callbacks.Plugin):
"""Sends message to ChatGPT and replies with the response """Sends message to ChatGPT and replies with the response
""" """
@ -79,13 +128,7 @@ class Chat(callbacks.Plugin):
log_level = self.registryValue('log_level').upper() log_level = self.registryValue('log_level').upper()
self.log.setLevel(getattr(logging, log_level, logging.INFO)) self.log.setLevel(getattr(logging, log_level, logging.INFO))
self.log.info("Chat plugin initialized with log level: %s", log_level) self.log.info("Chat plugin initialized with log level: %s", log_level)
self.sessions: Dict[str, ChannelSession] = {}
def is_poetry_block(lines):
if 2 < len(lines) <= 4:
avg_len = sum(len(l) for l in lines) / len(lines)
if avg_len < 20 and all(not l.endswith(('.', '?', '!')) for l in lines):
return True
return False
def _send_line(self, irc, target, line): def _send_line(self, irc, target, line):
if line.startswith("/me "): if line.startswith("/me "):
@ -100,7 +143,7 @@ class Chat(callbacks.Plugin):
schedule.addEvent(lambda l=line: self._send_line(irc, target, l), schedule.addEvent(lambda l=line: self._send_line(irc, target, l),
now + delay) now + delay)
def handle_response(self, irc, msg, response): def handle_response(self, irc, msg, response, session: Optional[ChannelSession] = None):
target = msg.args[0] target = msg.args[0]
lines = [l.strip() for l in response.splitlines() if l.strip()] lines = [l.strip() for l in response.splitlines() if l.strip()]
if not lines: if not lines:
@ -114,6 +157,344 @@ class Chat(callbacks.Plugin):
else: else:
self._burst(irc, target, lines) self._burst(irc, target, lines)
if session is not None:
now = time.time()
session.last_spoken = now
session.replies_in_thread += 1
if session.thread_owner is None:
session.thread_owner = msg.nick
if session.state != EngagementState.ENGAGED:
session.state = EngagementState.ENGAGED
def _invocation_string(self, irc):
return f"{conf.supybot.reply.whenAddressedBy.chars()}{self.name().lower()} "
def _is_command_invocation(self, irc, text):
stripped = text.strip()
lowered = stripped.lower()
plugin_name = self.name().lower()
nick_lower = irc.nick.lower()
prefix_chars = conf.supybot.reply.whenAddressedBy.chars()
for char in prefix_chars:
token = f"{char}{plugin_name} "
if lowered.startswith(token):
return True
if lowered.startswith(f"{plugin_name} "):
return True
address_patterns = (
f"{nick_lower}: {plugin_name} ",
f"{nick_lower}, {plugin_name} ",
f"@{nick_lower} {plugin_name} ",
)
for pattern in address_patterns:
if lowered.startswith(pattern):
return True
return False
def _session_key(self, irc, channel):
return f"{irc.network}:{channel.lower()}"
def _get_session(self, irc, channel):
key = self._session_key(irc, channel)
if key not in self.sessions:
self.sessions[key] = ChannelSession()
return self.sessions[key]
def _resolve_system_prompt(self, irc, channel):
default_prompt = "You are a helpful assistant."
prompt_file = self.registryValue("system_prompt_file")
if prompt_file and not os.path.isabs(prompt_file):
prompt_file = os.path.join(os.path.dirname(__file__), prompt_file)
try:
if prompt_file:
with open(prompt_file, "r") as f:
system_prompt = f.read()
else:
raise FileNotFoundError("No prompt file specified.")
except Exception as e:
self.log.error(f"Could not read prompt file: {e}")
system_prompt = self.registryValue("system_prompt") or default_prompt
system_prompt = system_prompt.replace("$bot_name", irc.nick).replace("$channel_name", channel)
passive_mode = self.registryValue("passive_mode")
passive_addendum = (self.registryValue("passive_prompt_addendum") or "").strip()
if passive_mode != "off" and passive_addendum:
passive_addendum = passive_addendum.replace("$bot_name", irc.nick).replace("$channel_name", channel)
system_prompt = f"{system_prompt.strip()}\n\n{passive_addendum}"
return system_prompt
def _collect_events(self, irc, channel, invocation_string, exclude_msg=None):
history_limit = self.registryValue("scrollback_lines")
events = []
for message in irc.state.history[-history_limit:]:
if message.command != 'PRIVMSG' or message.args[0] != channel:
continue
if exclude_msg is not None and message is exclude_msg:
continue
nick = message.nick or ""
cleaned = self.filter_prefix(message.args[1], invocation_string)
role = 'assistant' if ircutils.strEqual(nick, irc.nick) else 'user'
events.append({
"role": role,
"nick": nick,
"content": cleaned,
})
return events
def _events_to_messages(self, events):
rendered = []
for event in events:
if event["role"] == "assistant":
content = event["content"]
else:
speaker = event["nick"] or "user"
content = f"{speaker}: {event['content']}"
rendered.append({"role": event["role"], "content": content})
return rendered
def _build_messages(self, irc, msg, user_content, include_current=False):
invocation_string = self._invocation_string(irc)
events = self._collect_events(
irc,
msg.args[0],
invocation_string,
exclude_msg=None if include_current else msg,
)
system_prompt = self._resolve_system_prompt(irc, msg.args[0])
messages = [{"role": "system", "content": system_prompt}] + self._events_to_messages(events)
if include_current:
content = self.filter_prefix(user_content, invocation_string)
messages.append({"role": "user", "content": f"{msg.nick}: {content}"})
else:
messages.append({"role": "user", "content": user_content})
messages = truncate_messages(messages, 8192)
return messages, events
def _post_chat_completion(self, messages, max_tokens=None, temperature=None, model=None, timeout=10):
payload = {
"model": model or self.registryValue("model"),
"messages": messages,
}
if max_tokens is not None:
payload["max_tokens"] = max_tokens
if temperature is not None:
payload["temperature"] = temperature
api_key = self.registryValue('api_key')
if not api_key:
raise ValueError("API key not configured")
res = requests.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
},
json=payload,
timeout=timeout
)
res.raise_for_status()
data = res.json()
if "error" in data:
raise RuntimeError(data["error"].get("message", "Unknown error"))
return data['choices'][0]['message']['content'].strip()
def _reset_if_stale(self, session, now):
engagement_timeout = self.registryValue("passive_engagement_timeout")
if session.state == EngagementState.ENGAGED and session.last_spoken:
if now - session.last_spoken > engagement_timeout:
session.state = EngagementState.COOLING
session.cooling_until = now + self.registryValue("passive_cooldown")
session.thread_owner = None
session.replies_in_thread = 0
if session.state == EngagementState.INVITED and session.last_invitation:
if now - session.last_invitation > engagement_timeout:
session.state = EngagementState.COOLING
session.cooling_until = now + self.registryValue("passive_cooldown")
session.thread_owner = None
session.replies_in_thread = 0
if session.state == EngagementState.COOLING and session.cooling_until:
if now >= session.cooling_until:
session.reset()
def _is_direct_mention(self, irc, text):
lowered = text.lower()
nick_lower = irc.nick.lower()
if lowered.startswith(f"{nick_lower}:") or lowered.startswith(f"{nick_lower},"):
return True
tokens = re.findall(r"[@]?[\w'-]+", lowered)
for token in tokens:
if token.lstrip('@') == nick_lower:
return True
return False
def _looks_like_question(self, text):
stripped = text.strip()
if stripped.endswith('?'):
return True
lowered = stripped.lower()
question_words = {"who", "what", "when", "where", "why", "how", "should", "could", "would"}
if any(lowered.startswith(word + " ") for word in question_words):
return True
return False
def _contains_trigger_word(self, text):
triggers = [w.lower() for w in self.registryValue("passive_trigger_words")]
if not triggers:
return False
words = set(re.findall(r"[\w']+", text.lower()))
return any(word in words for word in triggers)
def _classify_passive_trigger(self, irc, msg, events):
history_lines = []
for event in events[-6:]:
speaker = event["nick"] or irc.nick
if event["role"] == "assistant":
speaker = irc.nick
history_lines.append(f"{speaker}: {event['content']}")
prompt = (
"Recent IRC conversation:\n" + "\n".join(history_lines[-8:]) +
f"\n\nShould {irc.nick} reply? Respond with one word: reply or skip."
)
messages = [
{"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
]
try:
outcome = self._post_chat_completion(messages, max_tokens=4, temperature=0)
except Exception as e:
self.log.debug(f"Classifier fallback due to error: {e}")
return False
decision = outcome.strip().split()[0].lower()
return decision == "reply"
def _should_respond_passively(self, irc, msg, session, events):
passive_mode = self.registryValue("passive_mode")
if passive_mode == "off":
return False
text = msg.args[1]
now = time.time()
mention = self._is_direct_mention(irc, text)
if mention:
if session.state == EngagementState.COOLING:
session.reset()
session.thread_owner = msg.nick
session.last_invitation = now
session.state = EngagementState.INVITED
session.replies_in_thread = 0
return True
if session.state == EngagementState.COOLING:
return False
if session.state in (EngagementState.INVITED, EngagementState.ENGAGED) and session.thread_owner:
if ircutils.strEqual(msg.nick, session.thread_owner):
return True
if passive_mode == "mention":
return False
if session.state != EngagementState.IDLE:
return False
candidate = self._looks_like_question(text) or self._contains_trigger_word(text)
if not candidate:
return False
try:
probability = float(self.registryValue("passive_probability"))
except (TypeError, ValueError):
probability = 0.0
if probability <= 0:
return False
roll = random.random()
if roll > probability:
self.log.debug(f"Passive skip due to probability gate ({roll:.2f} > {probability:.2f})")
return False
if not self._classify_passive_trigger(irc, msg, events):
self.log.debug("Passive classifier opted to skip reply")
return False
session.thread_owner = msg.nick
session.last_invitation = now
session.state = EngagementState.INVITED
session.replies_in_thread = 0
return True
def doPrivmsg(self, irc, msg):
parent_do_privmsg = getattr(super(Chat, self), 'doPrivmsg', None)
if parent_do_privmsg:
parent_do_privmsg(irc, msg)
passive_mode = self.registryValue("passive_mode")
if passive_mode == "off":
return
target = msg.args[0]
if not ircutils.isChannel(target):
return
if ircutils.strEqual(msg.nick, irc.nick):
return
text = msg.args[1]
if self._is_command_invocation(irc, text):
return
if not self.registryValue('api_key'):
return
invocation_string = self._invocation_string(irc)
session = self._get_session(irc, target)
now = time.time()
session.last_user_msg = now
self._reset_if_stale(session, now)
messages, events = self._build_messages(irc, msg, text, include_current=True)
if not self._should_respond_passively(irc, msg, session, events):
return
max_replies = self.registryValue("passive_max_replies")
if max_replies >= 0 and session.replies_in_thread >= max_replies:
session.state = EngagementState.COOLING
session.cooling_until = now + self.registryValue("passive_cooldown")
session.thread_owner = None
session.replies_in_thread = 0
return
try:
response = self._post_chat_completion(messages, max_tokens=self.registryValue("max_tokens"))
except (ValueError, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e:
self.log.debug(f"Passive reply aborted due to recoverable error: {e}")
return
except (requests.exceptions.RequestException, RuntimeError) as e:
self.log.debug(f"Passive reply aborted due to API error: {e}")
return
self.handle_response(irc, msg, response, session=session)
def filter_prefix(self, msg, prefix): def filter_prefix(self, msg, prefix):
if msg.startswith(prefix): if msg.startswith(prefix):
return msg[len(prefix):] return msg[len(prefix):]
@ -131,115 +512,42 @@ class Chat(callbacks.Plugin):
@bot chat What is the capital of France? @bot chat What is the capital of France?
""" """
# Construct the invocation string to identify bot commands
invocation_string = f"{conf.supybot.reply.whenAddressedBy.chars()}{self.name().lower()} "
self.log.debug(f"Invocation string: {invocation_string} | User: {msg.nick} | Channel: {msg.args[0]}")
# Retrieve model and token settings from the plugin's configuration
model = self.registryValue("model")
max_tokens = self.registryValue("max_tokens") max_tokens = self.registryValue("max_tokens")
session = self._get_session(irc, msg.args[0])
now = time.time()
session.last_user_msg = now
session.last_invitation = now
session.thread_owner = msg.nick
if session.state == EngagementState.COOLING:
session.reset()
session.state = EngagementState.INVITED
session.replies_in_thread = 0
# Determine the system prompt to use messages, _ = self._build_messages(irc, msg, string, include_current=False)
default_prompt = "You are a helpful assistant."
prompt_file = self.registryValue("system_prompt_file")
# Ensure the path to the prompt file is absolute
if prompt_file and not os.path.isabs(prompt_file):
prompt_file = os.path.join(os.path.dirname(__file__), prompt_file)
try:
if prompt_file:
with open(prompt_file, "r") as f:
system_prompt = f.read()
else:
raise FileNotFoundError("No prompt file specified.")
except Exception as e:
self.log.error(f"Could not read prompt file: {e}")
system_prompt = self.registryValue("system_prompt") or default_prompt
# Replace dynamic placeholders in the system prompt with actual values
system_prompt = system_prompt.replace("$bot_name", irc.nick).replace("$channel_name", msg.args[0])
# Retrieve the last few lines of the chat scrollback to provide context
history = irc.state.history[-self.registryValue("scrollback_lines"):]
self.log.debug(f"Raw history: {history}")
# Filter the scrollback to include only PRIVMSGs in the current channel
filtered_messages = [
(message.nick, self.filter_prefix(message.args[1], f"{invocation_string}"))
for message in history
if message.command == 'PRIVMSG' and message.args[0] == msg.args[0]
][:-1]
if not filtered_messages:
# Log a warning if no relevant messages are found in the scrollback
self.log.warning(f"No messages found in scrollback for channel {msg.args[0]}")
# Format the conversation history for the API request
conversation_history = [
{
"role": "assistant" if nick == "" else "user",
"content": re.sub(r'^.+?:\\s', '', msg) if nick == "" else f"{nick}: {msg}"
}
for nick, msg in filtered_messages
]
# Combine the system prompt and the conversation history
messages = [{"role": "system", "content": system_prompt}] + conversation_history + [{"role": "user", "content": msg.args[1]}]
# Truncate messages to ensure the total token count does not exceed the model's limit
messages = truncate_messages(messages, 8192)
self.log.debug(f"API Request: {json.dumps(messages)}") self.log.debug(f"API Request: {json.dumps(messages)}")
try: try:
# Send the request to the OpenAI API response = self._post_chat_completion(messages, max_tokens=max_tokens)
res = requests.post( except ValueError as e:
"https://api.openai.com/v1/chat/completions", self.log.error(f"Configuration error: {e}")
headers={ irc.reply("The API key is not configured. Please set it before using this command.")
"Content-Type": "application/json", return
"Authorization": f"Bearer {self.registryValue('api_key')}"
},
json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
},
timeout=10 # Set a timeout for the request
)
res.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
res = res.json()
self.log.debug(f"API Response: {json.dumps(res)}")
if "error" in res:
# Log and reply with the error message if the API returns an error
error_message = res["error"].get("message", "Unknown error")
self.log.error(f"API error: {error_message} | User input: {msg.args[1]} | Channel: {msg.args[0]}")
irc.reply(f"API error: {error_message}")
return
# Extract and format the response from the API
response = res['choices'][0]['message']['content'].strip()
self.handle_response(irc, msg, response)
# Log the successful processing of the request
self.log.info(f"Successfully processed request for user {msg.nick} in channel {msg.args[0]}")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
# Handle and log timeout errors
self.log.error("Request timed out.") self.log.error("Request timed out.")
irc.reply("The request to the API timed out. Please try again later.") irc.reply("The request to the API timed out. Please try again later.")
return
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
# Handle and log HTTP errors
self.log.error(f"HTTP error: {e}") self.log.error(f"HTTP error: {e}")
irc.reply("An HTTP error occurred while contacting the API.") irc.reply("An HTTP error occurred while contacting the API.")
return
except requests.exceptions.RequestException as e: except (requests.exceptions.RequestException, RuntimeError) as e:
# Handle and log other request exceptions
self.log.error(f"Request exception: {e}") self.log.error(f"Request exception: {e}")
irc.reply("An error occurred while contacting the API.") irc.reply("An error occurred while contacting the API.")
return
self.handle_response(irc, msg, response, session=session)
self.log.info(f"Successfully processed request for user {msg.nick} in channel {msg.args[0]}")
chat = wrap(chat, ['text']) chat = wrap(chat, ['text'])
Class = Chat Class = Chat