Dynamic passive mode
This commit is contained in:
parent
79d901a9b5
commit
7ba19523ab
23
README.md
23
README.md
@ -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:
|
||||||
|
|||||||
64
config.py
64
config.py
@ -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
524
plugin.py
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user