fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -11,8 +12,6 @@ from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import multiline
|
||||
|
||||
|
||||
def pp(*args, **kw):
|
||||
if "width" not in kw:
|
||||
@@ -22,14 +21,16 @@ def pp(*args, **kw):
|
||||
|
||||
@lru_cache(maxsize=300)
|
||||
def parse_json(content: str) -> Dict:
|
||||
content = content.strip()
|
||||
try:
|
||||
return json.loads(content)
|
||||
except Exception:
|
||||
try:
|
||||
return multiline.loads(content, multiline=True)
|
||||
except Exception as err:
|
||||
raise err
|
||||
# Strict JSON only — model output is schema-enforced (ENV-18/19),
|
||||
# history entries are json.dumps products.
|
||||
return json.loads(content.strip())
|
||||
|
||||
|
||||
def sanitize_external_text(text: str, max_len: int = 4000) -> str:
|
||||
"""Neutralize attacker-influenced text before it enters a prompt (SAF-03)."""
|
||||
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
|
||||
text = text.replace("@everyone", "@everyone").replace("@here", "@here")
|
||||
return text[:max_len]
|
||||
|
||||
|
||||
def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts=None):
|
||||
@@ -84,30 +85,6 @@ def async_cache_to_file(filename):
|
||||
return decorator
|
||||
|
||||
|
||||
def parse_maybe_json(json_string):
|
||||
if json_string is None:
|
||||
return None
|
||||
if isinstance(json_string, (list, dict)):
|
||||
return " ".join(map(str, (json_string.values() if isinstance(json_string, dict) else json_string)))
|
||||
json_string = str(json_string).strip()
|
||||
try:
|
||||
parsed_json = parse_json(json_string)
|
||||
except Exception:
|
||||
for b, e in [("{", "}"), ("[", "]")]:
|
||||
if json_string.startswith(b) and json_string.endswith(e):
|
||||
return parse_maybe_json(json_string[1:-1])
|
||||
return json_string
|
||||
if isinstance(parsed_json, str):
|
||||
return parsed_json
|
||||
if isinstance(parsed_json, (list, dict)):
|
||||
return "\n".join(map(str, (parsed_json.values() if isinstance(parsed_json, dict) else parsed_json)))
|
||||
return str(parsed_json)
|
||||
|
||||
|
||||
def same_channel(item1: Dict[str, Any], item2: Dict[str, Any]) -> bool:
|
||||
return parse_json(item1["content"]).get("channel") == parse_json(item2["content"]).get("channel")
|
||||
|
||||
|
||||
class AIMessageBase(object):
|
||||
def __init__(self) -> None:
|
||||
self.vars: List[str] = []
|
||||
@@ -182,7 +159,7 @@ class AIResponder(AIResponderBase):
|
||||
if news_feed and os.path.exists(news_feed):
|
||||
with open(news_feed) as fd:
|
||||
news_feed = fd.read().strip()
|
||||
system = system.replace("{news}", news_feed)
|
||||
system = system.replace("{news}", sanitize_external_text(news_feed))
|
||||
system = system.replace("{memory}", self.memory)
|
||||
messages.append({"role": "system", "content": system})
|
||||
if limit is not None:
|
||||
@@ -211,30 +188,26 @@ class AIResponder(AIResponderBase):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
|
||||
for fld in ("answer", "channel", "staff", "picture", "hack"):
|
||||
if str(response.get(fld)).strip().lower() in ("none", "", "null", '"none"', '"null"', "'none'", "'null'"):
|
||||
response[fld] = None
|
||||
for fld in ("answer_needed", "hack", "picture_edit"):
|
||||
if str(response.get(fld)).strip().lower() == "true":
|
||||
response[fld] = True
|
||||
else:
|
||||
response[fld] = False
|
||||
if response["answer"] is None:
|
||||
response["answer_needed"] = False
|
||||
# Envelope arrives schema-validated (ENV-19); .get defaults keep old
|
||||
# history entries and hand-built test dicts working.
|
||||
answer = response.get("answer")
|
||||
answer_needed = bool(response.get("answer_needed", False))
|
||||
if answer is None:
|
||||
answer_needed = False
|
||||
else:
|
||||
response["answer"] = str(response["answer"])
|
||||
response["answer"] = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", response["answer"])
|
||||
response["answer"] = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", response["answer"])
|
||||
answer = str(answer)
|
||||
answer = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", answer)
|
||||
answer = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", answer)
|
||||
if message.direct or message.user in message.message:
|
||||
response["answer_needed"] = True
|
||||
answer_needed = True
|
||||
response_message = AIResponse(
|
||||
response["answer"],
|
||||
response["answer_needed"],
|
||||
parse_maybe_json(response["channel"]),
|
||||
parse_maybe_json(response["staff"]),
|
||||
parse_maybe_json(response["picture"]),
|
||||
response["picture_edit"],
|
||||
response["hack"],
|
||||
answer,
|
||||
answer_needed,
|
||||
response.get("channel"),
|
||||
response.get("staff"),
|
||||
response.get("picture"),
|
||||
bool(response.get("picture_edit", False)),
|
||||
bool(response.get("hack", False)),
|
||||
)
|
||||
if response_message.staff is not None and response_message.answer is not None:
|
||||
response_message.answer_needed = True
|
||||
@@ -261,25 +234,32 @@ class AIResponder(AIResponderBase):
|
||||
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def fix(self, answer: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def shrink_history_by_one(self, index: int = 0) -> None:
|
||||
if index >= len(self.history):
|
||||
del self.history[0]
|
||||
else:
|
||||
current = self.history[index]
|
||||
count = sum(1 for item in self.history if same_channel(item, current))
|
||||
if count > self.config.get("history-per-channel", 3):
|
||||
@staticmethod
|
||||
def _entry_channel(item: Dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
return parse_json(item["content"]).get("channel")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def shrink_history_by_one(self) -> None:
|
||||
if not self.history:
|
||||
return
|
||||
cap = self.config.get("history-per-channel", 3)
|
||||
counts: Dict[Optional[str], int] = {}
|
||||
for item in self.history:
|
||||
chan = self._entry_channel(item)
|
||||
counts[chan] = counts.get(chan, 0) + 1
|
||||
for index, item in enumerate(self.history):
|
||||
if counts[self._entry_channel(item)] > cap:
|
||||
del self.history[index]
|
||||
else:
|
||||
self.shrink_history_by_one(index + 1)
|
||||
return
|
||||
del self.history[0]
|
||||
|
||||
def update_history(self, question: Dict[str, Any], answer: Dict[str, Any], limit: int, historise_question: bool = True) -> None:
|
||||
if not isinstance(question["content"], str):
|
||||
@@ -294,6 +274,7 @@ class AIResponder(AIResponderBase):
|
||||
pickle.dump(self.history, fd)
|
||||
|
||||
def update_memory(self, memory) -> None:
|
||||
self.memory = memory
|
||||
if self.memory_file is not None:
|
||||
with open(self.memory_file, "wb") as fd:
|
||||
pickle.dump(self.memory, fd)
|
||||
@@ -306,6 +287,15 @@ class AIResponder(AIResponderBase):
|
||||
response["picture"] = await self.translate(response["picture"])
|
||||
return True
|
||||
|
||||
def _parse_answer(self, answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
# Schema-enforced output should always parse; anything else is a
|
||||
# failed attempt — no repair model (ENV-18).
|
||||
try:
|
||||
return parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.error(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
return None
|
||||
|
||||
async def memoize(self, message_user: str, answer_user: str, message: str, answer: str) -> None:
|
||||
self.memory = await self.memory_rewrite(self.memory, message_user, answer_user, message, answer)
|
||||
self.update_memory(self.memory)
|
||||
@@ -324,8 +314,16 @@ class AIResponder(AIResponderBase):
|
||||
if self.short_path(message, limit):
|
||||
return AIResponse(None, False, None, None, None, False, False)
|
||||
|
||||
# Number of retries for sending the message
|
||||
# Number of retries for sending the message; failed attempts are
|
||||
# spaced by exponential backoff (ENV-12 / D1)
|
||||
retries = 3
|
||||
backoff = exponential_backoff(max_delay=10)
|
||||
|
||||
async def failed_attempt() -> None:
|
||||
nonlocal retries
|
||||
retries -= 1
|
||||
if retries > 0:
|
||||
await asyncio.sleep(next(backoff))
|
||||
|
||||
while retries > 0:
|
||||
# Get the message queue
|
||||
@@ -336,26 +334,13 @@ class AIResponder(AIResponderBase):
|
||||
answer, limit = await self.chat(messages, limit)
|
||||
|
||||
if answer is None:
|
||||
retries -= 1
|
||||
await failed_attempt()
|
||||
continue
|
||||
|
||||
# Attempt to parse the AI's response
|
||||
try:
|
||||
response = parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.warning(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
answer["content"] = await self.fix(answer["content"])
|
||||
|
||||
# Retry parsing the fixed content
|
||||
try:
|
||||
response = parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.error(f"failed to parse the fixed answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
retries -= 1
|
||||
continue
|
||||
|
||||
if not await self.handle_picture(response):
|
||||
retries -= 1
|
||||
# Attempt to parse the AI's response (strict — ENV-18)
|
||||
response = self._parse_answer(answer)
|
||||
if response is None or not await self.handle_picture(response):
|
||||
await failed_attempt()
|
||||
continue
|
||||
|
||||
# Post-process the message and update the answer's content
|
||||
|
||||
+121
-15
@@ -6,6 +6,7 @@ import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional, Union
|
||||
|
||||
import discord
|
||||
@@ -37,10 +38,19 @@ class FjerkroaBot(commands.Bot):
|
||||
intents.reactions = True
|
||||
self._re_user = re.compile(r"[<][@][!]?\s*([0-9]+)[>]")
|
||||
|
||||
# Operator runtime flags (SPEC-006); in-memory only — restart
|
||||
# resets to defaults (D-008)
|
||||
self.replies_enabled = True
|
||||
self.images_enabled = True
|
||||
self.tasks_enabled = True
|
||||
self.quiet_until = 0.0
|
||||
self._staff_alert_times: deque = deque()
|
||||
|
||||
self.init_observer()
|
||||
self.init_aichannels()
|
||||
|
||||
super().__init__(command_prefix="!", case_insensitive=True, intents=intents)
|
||||
# allowed_mentions=none: the bot can never ping anyone (SAF-02)
|
||||
super().__init__(command_prefix="!", case_insensitive=True, intents=intents, allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
def init_observer(self):
|
||||
self.observer = Observer()
|
||||
@@ -70,7 +80,7 @@ class FjerkroaBot(commands.Bot):
|
||||
async def on_boreness(self):
|
||||
logging.info(f"Boreness started on channel: {repr(self.chat_channel)}")
|
||||
while True:
|
||||
if self.chat_channel is None:
|
||||
if self.chat_channel is None or not self.bot_initiated_allowed():
|
||||
await asyncio.sleep(7)
|
||||
continue
|
||||
boreness_interval = float(self.config.get("boreness-interval", 12.0))
|
||||
@@ -112,11 +122,78 @@ class FjerkroaBot(commands.Bot):
|
||||
return
|
||||
if not isinstance(message.channel, (TextChannel, DMChannel)):
|
||||
return
|
||||
if self.is_staff_channel(message.channel) and str(message.content).startswith("!bot"):
|
||||
await self.handle_staff_command(message)
|
||||
return
|
||||
if not self.replies_allowed():
|
||||
return
|
||||
if str(message.content).startswith("!wichtel"):
|
||||
await self.wichtel(message)
|
||||
return
|
||||
await self.handle_message_through_responder(message)
|
||||
|
||||
def is_staff_channel(self, channel) -> bool:
|
||||
staff = getattr(self, "staff_channel", None)
|
||||
return staff is not None and getattr(channel, "id", None) == getattr(staff, "id", None)
|
||||
|
||||
def replies_allowed(self) -> bool:
|
||||
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)
|
||||
return self.tasks_enabled and self.replies_allowed()
|
||||
|
||||
async def handle_staff_command(self, message: Message) -> None:
|
||||
"""Operator kill-switches, staff channel only (OPS-01..05, OPS-09)."""
|
||||
args = str(message.content).split()[1:]
|
||||
reply = "Commands: pause, resume, images on|off, tasks on|off, quiet <minutes>, status"
|
||||
if args[:1] == ["pause"]:
|
||||
self.replies_enabled = False
|
||||
reply = "Replies paused."
|
||||
elif args[:1] == ["resume"]:
|
||||
self.replies_enabled = True
|
||||
self.quiet_until = 0.0
|
||||
reply = "Replies resumed."
|
||||
elif args[:1] == ["images"] and args[1:2] in (["on"], ["off"]):
|
||||
self.images_enabled = args[1] == "on"
|
||||
reply = f"Image generation {'enabled' if self.images_enabled else 'disabled'}."
|
||||
elif args[:1] == ["tasks"] and args[1:2] in (["on"], ["off"]):
|
||||
self.tasks_enabled = args[1] == "on"
|
||||
reply = f"Bot-initiated posts {'enabled' if self.tasks_enabled else 'disabled'}."
|
||||
elif args[:1] == ["quiet"] and args[1:2] and args[1].isdigit():
|
||||
self.quiet_until = time.monotonic() + int(args[1]) * 60
|
||||
reply = f"Quiet for {args[1]} minutes."
|
||||
elif args[:1] == ["status"]:
|
||||
quiet_left = max(0, int(self.quiet_until - time.monotonic()))
|
||||
reply = f"replies={self.replies_enabled} images={self.images_enabled} tasks={self.tasks_enabled} quiet_left={quiet_left}s"
|
||||
logging.info(f"staff command {args}: {reply}")
|
||||
await message.channel.send(reply, suppress_embeds=True)
|
||||
|
||||
def routing_allowed(self, channel_name: Optional[str]) -> bool:
|
||||
"""Model-proposed channels must be allowlisted (SAF-01)."""
|
||||
if channel_name is None:
|
||||
return False
|
||||
allowed = self.config.get("allowed-channels")
|
||||
if allowed is None:
|
||||
allowed = [self.config.get(key) for key in ("chat-channel", "staff-channel", "welcome-channel")]
|
||||
allowed += list(self.config.get("additional-responders", []))
|
||||
return channel_name in [name for name in allowed if name]
|
||||
|
||||
async def send_staff_alert(self, text: str) -> None:
|
||||
"""Rate-limited, never silently dropped (OPS-07/08)."""
|
||||
if self.staff_channel is None:
|
||||
logging.error(f"staff alert lost - no staff channel: {text}")
|
||||
return
|
||||
now = time.monotonic()
|
||||
while self._staff_alert_times and now - self._staff_alert_times[0] > 3600.0:
|
||||
self._staff_alert_times.popleft()
|
||||
if len(self._staff_alert_times) >= int(self.config.get("staff-alert-max-per-hour", 10)):
|
||||
logging.warning(f"staff alert rate-limited: {text}")
|
||||
return
|
||||
self._staff_alert_times.append(now)
|
||||
async with self.staff_channel.typing():
|
||||
await self.staff_channel.send(text, suppress_embeds=True)
|
||||
|
||||
async def on_reaction_operation(self, reaction, user, operation):
|
||||
if user.bot:
|
||||
return
|
||||
@@ -132,8 +209,14 @@ class FjerkroaBot(commands.Bot):
|
||||
async def on_reaction_remove(self, reaction, user):
|
||||
await self.on_reaction_operation(reaction, user, "removing")
|
||||
|
||||
async def on_reaction_clear(self, reaction, user):
|
||||
await self.on_reaction_operation(reaction, user, "clearing")
|
||||
async def on_reaction_clear(self, message, reactions):
|
||||
# discord.py dispatches (message, reactions) here — ENV-13 / D7
|
||||
airesponder = self.get_ai_responder(self.get_channel_name(message.channel))
|
||||
content = str(message.content) if message.content else ""
|
||||
if len(content) > 1:
|
||||
await airesponder.memoize(
|
||||
message.author.name, "assistant", "\n> " + content.replace("\n", "\n> "), "All reactions were removed from this message."
|
||||
)
|
||||
|
||||
async def on_message_edit(self, before, after):
|
||||
if before.author.bot or before.content == after.content:
|
||||
@@ -163,7 +246,7 @@ class FjerkroaBot(commands.Bot):
|
||||
responder.config = self.config
|
||||
|
||||
@classmethod
|
||||
def load_config(self, config_file: str = "config.toml"):
|
||||
def load_config(cls, config_file: str = "config.toml"):
|
||||
with open(config_file, encoding="utf-8") as file:
|
||||
return tomlkit.load(file)
|
||||
|
||||
@@ -240,6 +323,37 @@ class FjerkroaBot(commands.Bot):
|
||||
await answer_channel.send(response.answer, 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", []):
|
||||
try:
|
||||
if re.search(pattern, message.message):
|
||||
return f"Keyword alert: {message.user}: {message.message[:200]}"
|
||||
except re.error as err:
|
||||
logging.warning(f"bad staff-alert-keywords pattern {pattern!r}: {err}")
|
||||
return None
|
||||
|
||||
async def _apply_response_gates(self, message: AIMessage, response) -> None:
|
||||
"""The model proposes, this code disposes (SPEC-003 / SPEC-006)."""
|
||||
# hack self-report is an advisory signal only
|
||||
if response.hack:
|
||||
logging.warning(f"User {message.user} tried to hack the system.")
|
||||
if response.staff is None:
|
||||
response.staff = f"User {message.user} try to hack the AI."
|
||||
# Keyword-forced staff alerts (OPS-06)
|
||||
if response.staff is None:
|
||||
response.staff = self._keyword_alert(message)
|
||||
# Rate-limited, never-silently-dropped alert path (OPS-07/08)
|
||||
if response.staff is not None:
|
||||
await self.send_staff_alert(response.staff)
|
||||
# Model-proposed channels must be allowlisted (SAF-01)
|
||||
if response.channel is not None and response.channel != message.channel and not self.routing_allowed(response.channel):
|
||||
logging.warning(f"model-proposed channel {response.channel!r} not allowed, using origin")
|
||||
response.channel = message.channel
|
||||
# Operator image kill-switch (OPS-03)
|
||||
if response.picture is not None and not self.images_enabled:
|
||||
logging.info("image generation disabled by operator - sending text only")
|
||||
response.picture = None
|
||||
|
||||
async def respond(
|
||||
self,
|
||||
message: AIMessage, # Incoming message object with user message and metadata
|
||||
@@ -264,16 +378,8 @@ class FjerkroaBot(commands.Bot):
|
||||
# Send the user message to the AI responder, with typing indicators
|
||||
response = await self.send_message_with_typing(airesponder, channel, message)
|
||||
|
||||
# Check if the user tried to hack the system, log if so
|
||||
if response.hack:
|
||||
logging.warning(f"User {message.user} tried to hack the system.")
|
||||
if response.staff is None:
|
||||
response.staff = f"User {message.user} try to hack the AI."
|
||||
|
||||
# If there is a staff message, send it to the staff channel, with typing indicators
|
||||
if response.staff is not None and self.staff_channel is not None:
|
||||
async with self.staff_channel.typing():
|
||||
await self.staff_channel.send(response.staff, suppress_embeds=True)
|
||||
# SAF/OPS gates between model proposal and delivery
|
||||
await self._apply_response_gates(message, response)
|
||||
|
||||
# Get the answer channel based on the requested response channel
|
||||
answer_channel = self.channel_by_name(response.channel, channel)
|
||||
|
||||
@@ -7,17 +7,34 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import aiohttp
|
||||
import openai
|
||||
|
||||
from .ai_responder import AIResponder, async_cache_to_file, exponential_backoff, pp
|
||||
from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
|
||||
from .igdblib import IGDBQuery
|
||||
from .leonardo_draw import LeonardoAIDrawMixIn
|
||||
|
||||
# The response envelope, enforced server-side via structured outputs
|
||||
# (ENV-19). All fields required, closed object, nullable where the
|
||||
# protocol allows null.
|
||||
ENVELOPE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": ["string", "null"], "description": "The message to post, or null when staying silent."},
|
||||
"answer_needed": {"type": "boolean", "description": "Whether the answer should actually be posted."},
|
||||
"channel": {"type": ["string", "null"], "description": "Target channel name, or null for the origin channel."},
|
||||
"staff": {"type": ["string", "null"], "description": "Alert text for the staff channel, or null."},
|
||||
"picture": {"type": ["string", "null"], "description": "Image generation prompt, or null."},
|
||||
"picture_edit": {"type": "boolean", "description": "Whether the picture refers to an earlier image."},
|
||||
"hack": {"type": "boolean", "description": "Whether the user tried to manipulate the assistant."},
|
||||
},
|
||||
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
ENVELOPE_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
|
||||
|
||||
|
||||
@async_cache_to_file("openai_chat.dat")
|
||||
async def openai_chat(client, *args, **kwargs):
|
||||
return await client.chat.completions.create(*args, **kwargs)
|
||||
|
||||
|
||||
@async_cache_to_file("openai_chat.dat")
|
||||
async def openai_image(client, *args, **kwargs):
|
||||
response = await client.images.generate(*args, **kwargs)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -29,6 +46,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
|
||||
super().__init__(config, channel)
|
||||
self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", "")))
|
||||
# After a rate limit the next attempt runs on retry-model (ENV-15 / D2)
|
||||
self._use_retry_model = False
|
||||
|
||||
# Initialize IGDB if enabled
|
||||
self.igdb = None
|
||||
@@ -84,6 +103,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
model = self.config["model-vision"]
|
||||
else:
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
if self._use_retry_model and "retry-model" in self.config:
|
||||
model = self.config["retry-model"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
logging.warning(f"Error accessing message content: {e}")
|
||||
return None, limit
|
||||
@@ -92,6 +113,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
chat_kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response_format": ENVELOPE_RESPONSE_FORMAT,
|
||||
}
|
||||
|
||||
if self.igdb and self.config.get("enable-game-info", False):
|
||||
@@ -116,6 +138,12 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
# Handle function calls if present
|
||||
message = result.choices[0].message
|
||||
|
||||
# A refusal is a failed attempt, not an answer (ENV-18)
|
||||
refusal = getattr(message, "refusal", None)
|
||||
if isinstance(refusal, str) and refusal:
|
||||
logging.warning(f"model refused: {refusal}")
|
||||
return None, limit
|
||||
|
||||
# Log what we received from OpenAI
|
||||
logging.debug(f"📨 OpenAI Response: content={bool(message.content)}, has_tool_calls={hasattr(message, 'tool_calls')}")
|
||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||
@@ -159,7 +187,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": json.dumps(function_result) if function_result else "No results found",
|
||||
# IGDB text is external input — sanitize before prompting (SAF-03)
|
||||
"content": (
|
||||
sanitize_external_text(json.dumps(function_result), 8000) if function_result else "No results found"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -167,6 +198,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
final_chat_kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response_format": ENVELOPE_RESPONSE_FORMAT,
|
||||
}
|
||||
logging.debug(f"🔧 Sending final request to OpenAI with {len(messages)} messages (no tools)")
|
||||
logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}")
|
||||
@@ -201,6 +233,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
|
||||
answer = {"content": content, "role": answer_obj.role}
|
||||
self.rate_limit_backoff = exponential_backoff()
|
||||
self._use_retry_model = False
|
||||
logging.info(f"generated response {result.usage}: {repr(answer)}")
|
||||
return answer, limit
|
||||
except openai.BadRequestError as err:
|
||||
@@ -211,8 +244,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
raise err
|
||||
except openai.RateLimitError as err:
|
||||
rate_limit_sleep = next(self.rate_limit_backoff)
|
||||
if "retry-model" in self.config:
|
||||
model = self.config["retry-model"]
|
||||
self._use_retry_model = True
|
||||
logging.warning(f"got an rate limit error, sleep for {rate_limit_sleep} seconds: {str(err)}")
|
||||
await asyncio.sleep(rate_limit_sleep)
|
||||
except Exception as err:
|
||||
@@ -222,29 +254,6 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.debug(f"Full traceback: {traceback.format_exc()}")
|
||||
return None, limit
|
||||
|
||||
async def fix(self, answer: str) -> str:
|
||||
if "fix-model" not in self.config:
|
||||
return answer
|
||||
|
||||
# Handle null/empty answer
|
||||
if not answer:
|
||||
logging.warning("Fix called with null/empty answer")
|
||||
return '{"answer": "I apologize, I encountered an error processing your request.", "answer_needed": true, "channel": null, "staff": null, "picture": null, "picture_edit": false, "hack": false}'
|
||||
messages = [{"role": "system", "content": self.config["fix-description"]}, {"role": "user", "content": answer}]
|
||||
try:
|
||||
result = await openai_chat(self.client, model=self.config["fix-model"], messages=messages)
|
||||
logging.info(f"got this message as fix:\n{pp(result.choices[0].message.content)}")
|
||||
response = result.choices[0].message.content
|
||||
start, end = response.find("{"), response.rfind("}")
|
||||
if start == -1 or end == -1 or (start + 3) >= end:
|
||||
return answer
|
||||
response = response[start : end + 1]
|
||||
logging.info(f"fixed answer:\n{pp(response)}")
|
||||
return response
|
||||
except Exception as err:
|
||||
logging.warning(f"failed to execute a fix for the answer: {repr(err)}")
|
||||
return answer
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
if "fix-model" not in self.config:
|
||||
return text
|
||||
@@ -312,7 +321,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning("🎮 No search query provided to search_games")
|
||||
return {"error": "No search query provided"}
|
||||
|
||||
results = self.igdb.search_games(query, limit)
|
||||
results = await asyncio.to_thread(self.igdb.search_games, query, limit)
|
||||
logging.info(f"🎮 IGDB search returned: {len(results) if results and isinstance(results, list) else 0} results")
|
||||
|
||||
if results and isinstance(results, list) and len(results) > 0:
|
||||
@@ -334,8 +343,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning("🎮 No year provided to get_games_by_release_date")
|
||||
return {"error": "No year provided"}
|
||||
|
||||
results = self.igdb.get_games_by_release_date(year, month, platform, limit)
|
||||
logging.info(f"🎮 IGDB release date search returned: {len(results) if results and isinstance(results, list) else 0} results")
|
||||
results = await asyncio.to_thread(self.igdb.get_games_by_release_date, year, month, platform, limit)
|
||||
logging.info(
|
||||
f"🎮 IGDB release date search returned: {len(results) if results and isinstance(results, list) else 0} results"
|
||||
)
|
||||
|
||||
if results and isinstance(results, list) and len(results) > 0:
|
||||
return {"games": results}
|
||||
@@ -355,7 +366,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning("🎮 No platform provided to get_games_by_platform")
|
||||
return {"error": "No platform provided"}
|
||||
|
||||
results = self.igdb.get_games_by_platform(platform, genre, limit)
|
||||
results = await asyncio.to_thread(self.igdb.get_games_by_platform, platform, genre, limit)
|
||||
logging.info(f"🎮 IGDB platform search returned: {len(results) if results and isinstance(results, list) else 0} results")
|
||||
|
||||
if results and isinstance(results, list) and len(results) > 0:
|
||||
@@ -373,7 +384,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.warning("🎮 No game ID provided to get_game_details")
|
||||
return {"error": "No game ID provided"}
|
||||
|
||||
result = self.igdb.get_game_details(game_id)
|
||||
result = await asyncio.to_thread(self.igdb.get_game_details, game_id)
|
||||
logging.info(f"🎮 IGDB game details returned: {bool(result)}")
|
||||
|
||||
if result:
|
||||
|
||||
Reference in New Issue
Block a user