human behavior: classifier gate, pacing, splitting, quiet hours, stable prompt prefix
This commit is contained in:
@@ -154,17 +154,25 @@ class AIResponder(AIResponderBase):
|
||||
self.memory_manager = MemoryManager(self.store, lambda: self.config, self.consolidate, self.channel)
|
||||
logging.info(f"memmory:\n{self.memory}")
|
||||
|
||||
# Dynamic values move to a context suffix so the persona prefix
|
||||
# stays byte-stable for the prompt cache (ENV-20)
|
||||
DYNAMIC_PLACEHOLDERS = ("{date}", "{time}", "{news}", "{memory}")
|
||||
|
||||
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
messages = []
|
||||
system = self.config.get(self.channel, self.config["system"])
|
||||
system = system.replace("{date}", time.strftime("%Y-%m-%d")).replace("{time}", time.strftime("%H:%M:%S"))
|
||||
persona = self.config.get(self.channel, self.config["system"])
|
||||
for placeholder in self.DYNAMIC_PLACEHOLDERS:
|
||||
persona = persona.replace(placeholder, "")
|
||||
context = [f"date: {time.strftime('%Y-%m-%d')} ({time.strftime('%A')})", f"time: {time.strftime('%H:%M:%S')}"]
|
||||
news_feed = self.config.get("news")
|
||||
if news_feed and os.path.exists(news_feed):
|
||||
with open(news_feed) as fd:
|
||||
news_feed = fd.read().strip()
|
||||
system = system.replace("{news}", sanitize_external_text(news_feed))
|
||||
context.append("news:\n" + sanitize_external_text(fd.read().strip()))
|
||||
participants = [message.user] + [entry_user for entry_user in self._history_users(20)]
|
||||
system = system.replace("{memory}", self.memory_manager.memory_block(participants, self.memory))
|
||||
memory_block = self.memory_manager.memory_block(participants, self.memory)
|
||||
if memory_block:
|
||||
context.append("memory:\n" + memory_block)
|
||||
system = persona.rstrip() + "\n\n## Context\n" + "\n".join(context)
|
||||
messages.append({"role": "system", "content": system})
|
||||
if limit is not None:
|
||||
while len(self.history) > limit:
|
||||
@@ -238,6 +246,10 @@ class AIResponder(AIResponderBase):
|
||||
async def consolidate(self, observations: List[Dict[str, Any]], known_facts: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def classify(self, message: AIMessage, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Cheap reply/factual/emoji pre-pass (BEH-01); None = fail open."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
+101
-22
@@ -24,6 +24,45 @@ DEFAULT_PRIVACY_NOTICE = (
|
||||
"Type !forgetme to remove your messages from my history. Questions: ask the staff."
|
||||
)
|
||||
|
||||
DISCORD_HARD_LIMIT = 1900 # margin under the 2000-char API limit
|
||||
|
||||
|
||||
def quiet_hours_active(spec: Optional[str], now_hhmm: str) -> bool:
|
||||
"""BEH-08: 'HH:MM-HH:MM' window, may wrap midnight; garbage = inactive."""
|
||||
if not spec or "-" not in str(spec):
|
||||
return False
|
||||
start, _, end = str(spec).partition("-")
|
||||
start, end = start.strip(), end.strip()
|
||||
if not (len(start) == 5 and len(end) == 5 and start[2] == ":" and end[2] == ":"):
|
||||
return False
|
||||
if start <= end:
|
||||
return start <= now_hhmm < end
|
||||
return now_hhmm >= start or now_hhmm < end
|
||||
|
||||
|
||||
def split_answer(text: str, threshold: int, max_parts: int) -> list:
|
||||
"""BEH-06: split at paragraph boundaries, hard-cap under the Discord limit."""
|
||||
if text is None:
|
||||
return [""]
|
||||
parts = [text]
|
||||
if len(text) > max(threshold, 1) and max_parts > 1:
|
||||
parts = []
|
||||
for paragraph in text.split("\n\n"):
|
||||
if parts and len(parts[-1]) + len(paragraph) + 2 <= threshold:
|
||||
parts[-1] = parts[-1] + "\n\n" + paragraph
|
||||
else:
|
||||
parts.append(paragraph)
|
||||
while len(parts) > max_parts:
|
||||
tail = parts.pop()
|
||||
parts[-1] = parts[-1] + "\n\n" + tail
|
||||
hard: list = []
|
||||
for part in parts:
|
||||
while len(part) > DISCORD_HARD_LIMIT:
|
||||
hard.append(part[:DISCORD_HARD_LIMIT])
|
||||
part = part[DISCORD_HARD_LIMIT:]
|
||||
hard.append(part)
|
||||
return hard
|
||||
|
||||
|
||||
class ConfigFileHandler(FileSystemEventHandler):
|
||||
def __init__(self, on_modified):
|
||||
@@ -172,12 +211,14 @@ class FjerkroaBot(commands.Bot):
|
||||
return self.replies_enabled and time.monotonic() >= self.quiet_until
|
||||
|
||||
def bot_initiated_allowed(self) -> bool:
|
||||
# Gate for boreness today, the FDB-011 scheduler later (OPS-09)
|
||||
# Gate for boreness today, the FDB-011 scheduler later (OPS-09, BEH-08)
|
||||
if quiet_hours_active(self.config.get("quiet-hours"), time.strftime("%H:%M")):
|
||||
return False
|
||||
return self.tasks_enabled and self.replies_allowed()
|
||||
|
||||
def _memory_command(self, args) -> Optional[str]:
|
||||
"""Staff memory review/edit (MEM-07)."""
|
||||
if args[:1] not in (["memory"], ["forget-fact"], ["pin"], ["unpin"]):
|
||||
if args[:1] not in (["memory"], ["forget-fact"], ["pin"], ["unpin"], ["pins"]):
|
||||
return None
|
||||
store = self.airesponder.store
|
||||
if store is None:
|
||||
@@ -193,6 +234,9 @@ class FjerkroaBot(commands.Bot):
|
||||
return f"Pinned for {args[1]}."
|
||||
if args[:1] == ["unpin"] and args[1:2] and args[1].isdigit():
|
||||
return f"Removed {store.delete_pinned(int(args[1]))} pin(s)."
|
||||
if args[:1] == ["pins"]:
|
||||
pins = store.pinned_all()
|
||||
return "\n".join(f"{pin['id']} [{pin['channel'] or 'global'}]: {pin['fact']}" for pin in pins) or "No pins."
|
||||
return None
|
||||
|
||||
async def handle_staff_command(self, message: Message) -> None:
|
||||
@@ -361,14 +405,7 @@ class FjerkroaBot(commands.Bot):
|
||||
message_content = f"> {reference_content}\n\n{message_content}"
|
||||
if len(message_content) < 1:
|
||||
return
|
||||
for ma_user in self._re_user.finditer(message_content):
|
||||
uid = int(ma_user.group(1))
|
||||
for guild in self.guilds:
|
||||
user = guild.get_member(uid)
|
||||
if user is not None:
|
||||
break
|
||||
if user is not None:
|
||||
message_content = re.sub(f"[<][@][!]? *{uid} *[>]", f"@{user.name}", message_content)
|
||||
message_content = self._resolve_mentions(message_content)
|
||||
channel_name = self.get_channel_name(message.channel)
|
||||
msg = AIMessage(
|
||||
message.author.name, message_content, channel_name, self.user in message.mentions or isinstance(message.channel, DMChannel)
|
||||
@@ -378,23 +415,64 @@ class FjerkroaBot(commands.Bot):
|
||||
if not msg.urls:
|
||||
msg.urls = []
|
||||
msg.urls.append(attachment.url)
|
||||
await self.respond(msg, message.channel)
|
||||
|
||||
# Reply/ignore classifier gate — direct messages bypass (BEH-01/02/03/07)
|
||||
airesponder = self.get_ai_responder(channel_name)
|
||||
handled, factual = await self._classifier_gate(message, msg, airesponder, channel_name)
|
||||
if handled:
|
||||
return
|
||||
await self.respond(msg, message.channel, factual=factual)
|
||||
|
||||
def _resolve_mentions(self, message_content: str) -> str:
|
||||
for ma_user in self._re_user.finditer(message_content):
|
||||
uid = int(ma_user.group(1))
|
||||
user = None
|
||||
for guild in self.guilds:
|
||||
user = guild.get_member(uid)
|
||||
if user is not None:
|
||||
break
|
||||
if user is not None:
|
||||
message_content = re.sub(f"[<][@][!]? *{uid} *[>]", f"@{user.name}", message_content)
|
||||
return message_content
|
||||
|
||||
async def _classifier_gate(self, message, msg: AIMessage, airesponder, channel_name: str):
|
||||
"""(handled, factual): handled=True = reply suppressed, maybe emoji (BEH-01/07)."""
|
||||
if "classifier-model" not in self.config or msg.direct:
|
||||
return False, False
|
||||
verdict = await airesponder.classify(msg, airesponder.history[-6:])
|
||||
if verdict is None:
|
||||
return False, False # fail open (BEH-03)
|
||||
if not verdict.get("reply", True):
|
||||
emoji = verdict.get("emoji")
|
||||
if emoji:
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except Exception as err:
|
||||
logging.debug(f"reaction failed: {repr(err)}")
|
||||
self.log_message_action("classifier-skip", msg, channel_name)
|
||||
return True, False
|
||||
return False, bool(verdict.get("factual", False))
|
||||
|
||||
async def send_message_with_typing(self, airesponder, channel, message):
|
||||
"""Send the user message to the AI responder with typing animation in discord"""
|
||||
async with channel.typing():
|
||||
return await airesponder.send(message)
|
||||
|
||||
async def send_answer_with_typing(self, response, answer_channel, airesponder):
|
||||
"""Send an answer from AI to discord channel with typing animation"""
|
||||
async with answer_channel.typing():
|
||||
if response.picture is not None:
|
||||
# Generate the image with the AI and send it with the answer
|
||||
images = [discord.File(fp=await airesponder.draw(response.picture), filename="image.png")]
|
||||
await answer_channel.send(response.answer, files=images, suppress_embeds=True)
|
||||
else:
|
||||
await answer_channel.send(response.answer, suppress_embeds=True)
|
||||
self.last_activity_time = time.monotonic()
|
||||
async def send_answer_with_typing(self, response, answer_channel, airesponder, factual: bool = False):
|
||||
"""Send the answer paced, split and with images on the last part (BEH-04/05/06)"""
|
||||
files = None
|
||||
if response.picture is not None:
|
||||
files = [discord.File(fp=await airesponder.draw(response.picture), filename="image.png")]
|
||||
parts = split_answer(response.answer, int(self.config.get("split-threshold", 1200)), int(self.config.get("split-max-parts", 3)))
|
||||
pace = float(self.config.get("typing-chars-per-second", 0) or 0)
|
||||
max_delay = float(self.config.get("typing-max-seconds", 8))
|
||||
for index, part in enumerate(parts):
|
||||
async with answer_channel.typing():
|
||||
if pace > 0 and not factual:
|
||||
await asyncio.sleep(min(len(part) / pace, max_delay))
|
||||
last = index == len(parts) - 1
|
||||
await answer_channel.send(part, files=files if last else None, suppress_embeds=True)
|
||||
self.last_activity_time = time.monotonic()
|
||||
|
||||
def _keyword_alert(self, message: AIMessage) -> Optional[str]:
|
||||
for pattern in self.config.get("staff-alert-keywords", []):
|
||||
@@ -438,6 +516,7 @@ class FjerkroaBot(commands.Bot):
|
||||
self,
|
||||
message: AIMessage, # Incoming message object with user message and metadata
|
||||
channel: Union[TextChannel, DMChannel], # Channel (Text or Direct Message) the message is coming from
|
||||
factual: bool = False, # classifier verdict: skip the artificial typing delay (BEH-05)
|
||||
) -> None:
|
||||
"""Handle a message from a user with an AI responder"""
|
||||
|
||||
@@ -480,7 +559,7 @@ class FjerkroaBot(commands.Bot):
|
||||
return
|
||||
|
||||
# Send the AI's answer to the specified answer channel, with typing indicators
|
||||
await self.send_answer_with_typing(response, answer_channel, airesponder)
|
||||
await self.send_answer_with_typing(response, answer_channel, airesponder, factual=factual)
|
||||
|
||||
async def close(self):
|
||||
self.observer.stop()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
@@ -56,6 +57,29 @@ CONSOLIDATION_RESPONSE_FORMAT = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "consolidation", "strict": True, "schema": CONSOLIDATION_SCHEMA},
|
||||
}
|
||||
# Reply/ignore + factual pre-pass (SPEC-010 BEH-01): one cheap call
|
||||
CLASSIFIER_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reply": {"type": "boolean", "description": "Should the assistant answer this message?"},
|
||||
"factual": {"type": "boolean", "description": "Does the user want concrete information (hours, prices, availability)?"},
|
||||
"emoji": {"type": ["string", "null"], "description": "Optional single emoji reaction when not replying, else null."},
|
||||
},
|
||||
"required": ["reply", "factual", "emoji"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
CLASSIFIER_RESPONSE_FORMAT = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "reply_verdict", "strict": True, "schema": CLASSIFIER_SCHEMA},
|
||||
}
|
||||
CLASSIFIER_SYSTEM = (
|
||||
"You watch a group chat that has an assistant bot. Decide whether the assistant should answer the LAST message:"
|
||||
" reply=true when it addresses the assistant, asks something the assistant can help with, or continues a conversation"
|
||||
" with the assistant; reply=false for human-to-human chatter the assistant should not butt into."
|
||||
" factual=true when the user wants concrete information (opening hours, prices, availability, addresses)."
|
||||
" When reply=false you may suggest one fitting emoji reaction, else null."
|
||||
)
|
||||
|
||||
CONSOLIDATION_SYSTEM = (
|
||||
"You maintain the long-term memory of a Discord assistant. From the observation log, extract NEW durable facts that users stated"
|
||||
" about THEMSELVES only (never record what one user claims about another user), and write one short episode summary of the"
|
||||
@@ -115,6 +139,16 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
|
||||
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
|
||||
|
||||
@staticmethod
|
||||
def _last_author(messages: List[Dict[str, Any]]) -> Optional[str]:
|
||||
try:
|
||||
content = messages[-1]["content"]
|
||||
if not isinstance(content, str):
|
||||
content = content[0]["text"]
|
||||
return str(json.loads(content).get("user")) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _record_usage(self, result: Any) -> None:
|
||||
usage = getattr(result, "usage", None)
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", None)
|
||||
@@ -164,6 +198,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
"messages": messages,
|
||||
"response_format": ENVELOPE_RESPONSE_FORMAT,
|
||||
}
|
||||
author = self._last_author(messages)
|
||||
if author:
|
||||
# hashed, never the raw Discord name (SAF-10)
|
||||
chat_kwargs["safety_identifier"] = "discord-" + hashlib.sha256(author.encode()).hexdigest()[:16]
|
||||
|
||||
if self.igdb and self.config.get("enable-game-info", False):
|
||||
try:
|
||||
@@ -326,6 +364,25 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning(f"failed to translate the text: {repr(err)}")
|
||||
return text
|
||||
|
||||
async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03)."""
|
||||
if "classifier-model" not in self.config or not self.ledger.budget_ok():
|
||||
return None
|
||||
tail = "\n".join(str(entry.get("content", ""))[:300] for entry in history_tail[-6:])
|
||||
messages = [
|
||||
{"role": "system", "content": CLASSIFIER_SYSTEM},
|
||||
{"role": "user", "content": f"Recent chat:\n{tail}\n\nLAST message:\n{str(message)}"},
|
||||
]
|
||||
try:
|
||||
result = await openai_chat(
|
||||
self.client, model=self.config["classifier-model"], messages=messages, response_format=CLASSIFIER_RESPONSE_FORMAT
|
||||
)
|
||||
self._record_usage(result)
|
||||
return json.loads(result.choices[0].message.content)
|
||||
except Exception as err:
|
||||
logging.warning(f"classifier failed - failing open: {repr(err)}")
|
||||
return None
|
||||
|
||||
async def consolidate(self, observations: List[Dict[str, Any]], known_facts: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Batched memory consolidation on memory-model (MEM-02)."""
|
||||
if "memory-model" not in self.config or not self.ledger.budget_ok():
|
||||
|
||||
@@ -163,6 +163,11 @@ class PersistentStore:
|
||||
).fetchall()
|
||||
return [{"id": row[0], "channel": row[1], "fact": row[2]} for row in rows]
|
||||
|
||||
def pinned_all(self) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute("SELECT id, channel, fact FROM pinned_facts ORDER BY id").fetchall()
|
||||
return [{"id": row[0], "channel": row[1], "fact": row[2]} for row in rows]
|
||||
|
||||
def delete_pinned(self, pin_id: int) -> int:
|
||||
with closing(self._connect()) as conn, conn:
|
||||
return conn.execute("DELETE FROM pinned_facts WHERE id = ?", (pin_id,)).rowcount
|
||||
|
||||
Reference in New Issue
Block a user