fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 12:56:32 +02:00
parent f1578cbd99
commit 6e5abf3d2c
14 changed files with 770 additions and 214 deletions
+72 -87
View File
@@ -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
View File
@@ -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)
+45 -34
View File
@@ -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: