fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches
This commit is contained in:
@@ -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