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
+1
View File
@@ -23,3 +23,4 @@ last_updates.json
.pytest_cache/
*.py,v
*.msg
news_feed.py
+30 -40
View File
@@ -1,4 +1,7 @@
# Pre-commit hooks configuration for Fjerkroa Bot
#
# Formatter/linter/type-checker run from the uv-managed project env so
# hook versions == pyproject dev-dependency versions (no pin drift).
repos:
# Built-in hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -13,49 +16,36 @@ repos:
- id: check-case-conflict
- id: check-merge-conflict
- id: debug-statements
- id: requirements-txt-fixer
# Black code formatter
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
language_version: python3
args: [--line-length=140]
# isort import sorter
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
args: [--profile=black, --line-length=140]
# Flake8 linter
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
args: [--max-line-length=140]
# Bandit security scanner - disabled due to expected pickle/random usage
# - repo: https://github.com/pycqa/bandit
# rev: 1.7.5
# hooks:
# - id: bandit
# args: [-r, fjerkroa_bot]
# exclude: tests/
# MyPy type checker
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.3.0
hooks:
- id: mypy
additional_dependencies: [types-toml, types-requests, types-setuptools]
args: [--config-file=pyproject.toml, --ignore-missing-imports]
# Local hooks using Makefile
# Project-env tools (single version source: pyproject.toml)
- repo: local
hooks:
- id: black
name: black
entry: uv run black
language: system
types: [python]
- id: isort
name: isort
entry: uv run isort
language: system
types: [python]
- id: flake8
name: flake8
entry: uv run flake8
language: system
types: [python]
- id: mypy
name: mypy
entry: uv run mypy fjerkroa_bot tests
language: system
pass_filenames: false
- id: trace
name: Spec coverage (trace)
entry: uv run python tools/trace.py
language: system
pass_filenames: false
always_run: true
- id: tests
name: Run tests
entry: make test-fast
+11
View File
@@ -16,3 +16,14 @@ system = "You are a smart AI assistant with access to real-time video game infor
igdb-client-id = "YOUR_IGDB_CLIENT_ID"
igdb-access-token = "YOUR_IGDB_ACCESS_TOKEN"
enable-game-info = true
# --- operator / safety (SPEC-003, SPEC-006) ---
# Model may route answers only to allowlisted channels; default = the
# channels named in this config (chat/staff/welcome/additional-responders).
# allowed-channels = ["chat", "staff"]
# Regexes that force a staff alert regardless of the model's judgement:
# staff-alert-keywords = ["(?i)hjelp|help|emergency"]
# Staff-alert rate limit per rolling hour (excess alerts are logged):
# staff-alert-max-per-hour = 10
# Staff commands (staff channel only): !bot pause | resume | images on|off
# | tasks on|off | quiet <minutes> | status
+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
# 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[fld] = False
if response["answer"] is None:
response["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:
+33
View File
@@ -0,0 +1,33 @@
# SPEC-003 — Safety, privacy + abuse hardening
FDB-005 lands the injection-defense subset (the old `hack`
self-report was the only defense — S4). Consequential actions are
gated *outside* the model: the model proposes, deterministic code
disposes. Quotas, budget caps, memory policies and GDPR controls
follow with FDB-014 (SAF-10+, reserved).
### SAF-01 — Model-proposed channel routing is allowlisted (coverage: test)
A model-proposed answer channel is honored only when it is in the
allowed set: config `allowed-channels` when set, otherwise the
channels already named in config (`chat-channel`, `staff-channel`,
`welcome-channel`, `additional-responders`). Anything else falls back
to the origin channel and is logged. Prompt injection must not be
able to redirect the bot into arbitrary channels.
### SAF-02 — Outbound messages cannot ping (coverage: test)
The bot is constructed with `allowed_mentions = none`: no user, role
or @everyone/@here pings in any outbound message, regardless of what
the model emits.
### SAF-03 — External text is sanitized before prompting (coverage: test)
Text from external sources injected into prompts or tool results —
news-feed content and IGDB results — passes `sanitize_external_text`:
control characters stripped, `@everyone`/`@here` neutralized with a
zero-width space, length capped (default 4000 chars). RSS headlines
and game descriptions are attacker-influenced input.
The `hack` envelope field remains as an advisory signal (logged,
staff-notified) but is no longer the defense.
+58
View File
@@ -0,0 +1,58 @@
# SPEC-006 — Staff / operator controls
Staff operate the bot from the staff channel without SSH. Runtime
flags live in memory — a restart resets to config defaults (D-008).
The staff-alert path is a tested contract: alerts are the
business-critical feature on the restaurant deployment.
### OPS-01 — Staff commands only work in the staff channel (coverage: test)
`!bot …` commands are honored only when sent in the configured staff
channel. In any other channel the text is treated as a normal
message.
### OPS-02 — Pause and resume (coverage: test)
`!bot pause` stops all public replying (messages are ignored, no
model calls); `!bot resume` restores it and clears quiet mode. Staff
commands keep working while paused.
### OPS-03 — Image kill-switch (coverage: test)
`!bot images off` drops the picture part of any response before
generation (answer text still goes out); `!bot images on` restores.
### OPS-04 — Quiet mode with auto-resume (coverage: test)
`!bot quiet <minutes>` silences public replies for N minutes, then
the bot resumes by itself. Friday-service panic button that cannot be
forgotten.
### OPS-05 — Status report (coverage: test)
`!bot status` answers in the staff channel with the current flags
(replies / images / tasks / remaining quiet time).
### OPS-06 — Keyword-forced staff alerts (coverage: test)
When a user message matches any configured `staff-alert-keywords`
regex and the model set no staff note, a staff alert is forced with
user + message excerpt. Alerting must not depend solely on the
model's judgement.
### OPS-07 — Staff alerts are rate-limited (coverage: test)
At most `staff-alert-max-per-hour` (default 10) alerts reach the
staff channel per rolling hour; excess alerts are logged. An
injection or a glitch must not be able to flood staff.
### OPS-08 — Lost staff alerts are logged (coverage: test)
When a staff alert cannot be delivered (staff channel unresolved),
the alert text is written to the error log — never silently dropped.
### OPS-09 — Self-tasking kill-switch (coverage: test)
`!bot tasks off` disables bot-initiated posting (today: the boreness
loop; later: the FDB-011 scheduler); `!bot tasks on` restores.
Bot-initiated posts also respect pause/quiet.
+5 -29
View File
@@ -6,7 +6,7 @@ import toml
from discord import Message, TextChannel, User
from fjerkroa_bot import FjerkroaBot
from fjerkroa_bot.ai_responder import AIMessage, AIResponse, parse_maybe_json
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
class TestBotBase(unittest.IsolatedAsyncioTestCase):
@@ -26,9 +26,10 @@ class TestBotBase(unittest.IsolatedAsyncioTestCase):
"additional-responders": [],
}
self.history_data = []
with patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data), patch.object(
FjerkroaBot, "user", new_callable=PropertyMock
) as mock_user:
with (
patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data),
patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user,
):
mock_user.return_value = MagicMock(spec=User)
mock_user.return_value.id = 12
self.bot = FjerkroaBot("config.toml")
@@ -56,31 +57,6 @@ class TestFunctionality(TestBotBase):
result = FjerkroaBot.load_config("config.toml")
self.assertEqual(result, self.config_data)
def test_json_strings(self) -> None:
json_string = '{"key1": "value1", "key2": "value2"}'
expected_output = "value1\nvalue2"
self.assertEqual(parse_maybe_json(json_string), expected_output)
non_json_string = "This is not a JSON string."
self.assertEqual(parse_maybe_json(non_json_string), non_json_string)
json_array = '["value1", "value2", "value3"]'
expected_output = "value1\nvalue2\nvalue3"
self.assertEqual(parse_maybe_json(json_array), expected_output)
json_string = '"value1"'
expected_output = "value1"
self.assertEqual(parse_maybe_json(json_string), expected_output)
json_struct = '{"This is a string."}'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = '["This is a string."]'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "{This is a string.}"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "[This is a string.]"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
async def test_message_lings(self) -> None:
request = AIMessage(
"Lala",
+3 -9
View File
@@ -26,15 +26,9 @@ class TestOpenAIResponderSimple(unittest.IsolatedAsyncioTestCase):
responder = OpenAIResponder(config)
self.assertIsNotNone(responder.client)
async def test_fix_no_fix_model(self):
"""Test fix when no fix-model is configured."""
config_no_fix = {"openai-key": "test", "model": "gpt-4"}
responder = OpenAIResponder(config_no_fix)
original_answer = '{"answer": "test"}'
result = await responder.fix(original_answer)
self.assertEqual(result, original_answer)
def test_no_repair_path_exists(self):
"""ENV-18: the repair path is gone — no fix() on the responder."""
self.assertFalse(hasattr(self.responder, "fix"))
async def test_translate_no_fix_model(self):
"""Test translate when no fix-model is configured."""
+132
View File
@@ -0,0 +1,132 @@
"""Unit coverage for the FDB-004 defect-fix requirements (ENV-12..17)."""
import threading
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import openai
from discord import Message, TextChannel
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from fjerkroa_bot.openai_responder import OpenAIResponder, openai_chat
from .test_bdd_envelope import FakeModelResponder, envelope
from .test_main import TestBotBase
def make_entry(channel: str, text: str = "x"):
import json
return {"role": "user", "content": json.dumps({"message": text, "channel": channel})}
class TestSendBackoff(unittest.IsolatedAsyncioTestCase):
async def test_send_sleeps_between_retries(self):
"""ENV-12: failed attempts are separated by exponential-backoff sleeps (D1)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertGreaterEqual(sleep.await_count, 2)
for call in sleep.await_args_list:
self.assertGreater(call.args[0], 0)
class TestReactionClear(TestBotBase):
async def test_on_reaction_clear_discord_signature(self):
"""ENV-13: on_reaction_clear(message, reactions) memoizes the clearing (D7)."""
message = MagicMock(spec=Message)
message.content = "Some message text"
message.author.name = "alice"
message.channel = MagicMock(spec=TextChannel)
self.bot.airesponder.memoize = AsyncMock()
await self.bot.on_reaction_clear(message, [Mock()])
self.bot.airesponder.memoize.assert_awaited_once()
class TestUpdateMemory(unittest.TestCase):
def test_update_memory_uses_argument(self):
"""ENV-14: update_memory persists the passed value, not stale state (D12)."""
responder = AIResponder({"system": "s", "history-limit": 5}, "chat")
responder.update_memory("NEW MEMORY")
self.assertEqual(responder.memory, "NEW MEMORY")
class TestRetryModel(unittest.IsolatedAsyncioTestCase):
async def test_retry_model_used_after_rate_limit(self):
"""ENV-15: the attempt after a rate limit uses retry-model, then switches back (D2)."""
config = {
"openai-token": "test",
"model": "main-model",
"retry-model": "fallback-model",
"system": "s",
"history-limit": 5,
}
responder = OpenAIResponder(config, "chat")
rate_limit = openai.RateLimitError(
"rate limited", response=httpx.Response(429, request=httpx.Request("POST", "http://test")), body=None
)
def ok_result():
message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None)
return Mock(choices=[Mock(message=message)], usage="usage")
with (
patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock,
patch("asyncio.sleep", new_callable=AsyncMock),
):
chat_mock.side_effect = [rate_limit, ok_result(), ok_result()]
messages = [{"role": "user", "content": "hi"}]
first, _ = await responder.chat(list(messages), 10)
self.assertIsNone(first)
second, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(second)
third, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(third)
self.assertEqual(chat_mock.await_args_list[0].kwargs["model"], "main-model")
self.assertEqual(chat_mock.await_args_list[1].kwargs["model"], "fallback-model")
self.assertEqual(chat_mock.await_args_list[2].kwargs["model"], "main-model")
class TestNoDiskCache(unittest.IsolatedAsyncioTestCase):
async def test_chat_hits_client_every_time(self):
"""ENV-16: identical chat calls reach the client every time — no pickle cache (D3)."""
client = Mock()
client.chat.completions.create = AsyncMock(return_value="RESPONSE")
kwargs = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
await openai_chat(client, **kwargs)
await openai_chat(client, **kwargs)
self.assertEqual(client.chat.completions.create.await_count, 2)
class TestIgdbOffEventLoop(unittest.IsolatedAsyncioTestCase):
async def test_igdb_search_runs_in_worker_thread(self):
"""ENV-17: IGDB lookups run in a worker thread, results unchanged (D5)."""
responder = OpenAIResponder({"openai-token": "test", "model": "m", "system": "s", "history-limit": 5}, "chat")
responder.igdb = Mock()
calling_thread = {}
def capture_search(query, limit):
calling_thread["thread"] = threading.current_thread()
return [{"name": "Zelda"}]
responder.igdb.search_games = capture_search
result = await responder._execute_igdb_function("search_games", {"query": "zelda"})
self.assertEqual(result, {"games": [{"name": "Zelda"}]})
self.assertIsNot(calling_thread["thread"], threading.main_thread())
class TestShrinkTolerance(unittest.TestCase):
def test_shrink_tolerates_non_json_entries(self):
"""ENV-11: non-JSON history entries do not crash shrinking (D10 rewrite)."""
responder = AIResponder({"system": "s", "history-limit": 4}, "chat")
responder.history = [
{"role": "user", "content": "plain, not json"},
make_entry("a", "a0"),
make_entry("a", "a1"),
make_entry("a", "a2"),
make_entry("a", "a3"),
]
responder.shrink_history_by_one()
self.assertEqual(len(responder.history), 4)
+135
View File
@@ -0,0 +1,135 @@
"""Unit coverage for SPEC-006 operator controls (OPS-01..09)."""
import time
from unittest.mock import AsyncMock, MagicMock
from discord import Message, TextChannel, User
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
from .test_main import TestBotBase
class OpsBase(TestBotBase):
def staff_msg(self, content: str) -> Message:
message = MagicMock(spec=Message)
message.content = content
message.author = MagicMock(spec=User)
message.author.bot = False
message.author.id = 999
message.channel = self.bot.staff_channel
return message
def public_msg(self, content: str) -> Message:
message = self.create_message(content)
message.content = content
return message
class TestStaffCommandAuth(OpsBase):
async def test_commands_ignored_outside_staff_channel(self):
"""OPS-01: !bot commands in a public channel do not flip flags."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.public_msg("!bot pause"))
self.assertTrue(self.bot.replies_enabled)
self.bot.handle_message_through_responder.assert_awaited_once()
async def test_commands_honored_in_staff_channel(self):
"""OPS-01: !bot commands in the staff channel are executed."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.replies_enabled)
class TestPauseResume(OpsBase):
async def test_pause_blocks_public_replies(self):
"""OPS-02: paused bot ignores public messages; resume restores replying."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.staff_msg("!bot pause"))
await self.bot.on_message(self.public_msg("hello?"))
self.bot.handle_message_through_responder.assert_not_awaited()
await self.bot.on_message(self.staff_msg("!bot resume"))
self.assertTrue(self.bot.replies_enabled)
await self.bot.on_message(self.public_msg("hello again"))
self.bot.handle_message_through_responder.assert_awaited_once()
class TestImageKillSwitch(OpsBase):
async def test_images_off_strips_picture(self):
"""OPS-03: with images off the picture request is dropped, answer still sent."""
await self.bot.on_message(self.staff_msg("!bot images off"))
self.assertFalse(self.bot.images_enabled)
response = AIResponse("here is your cat", True, "chat", None, "a cat", False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
origin = MagicMock(spec=TextChannel)
await self.bot.respond(AIMessage("alice", "draw a cat", "chat"), origin)
sent = self.bot.send_answer_with_typing.await_args.args[0]
self.assertIsNone(sent.picture)
self.assertEqual(sent.answer, "here is your cat")
class TestQuietMode(OpsBase):
async def test_quiet_pauses_then_auto_resumes(self):
"""OPS-04: !bot quiet N silences replies for N minutes, then auto-resumes."""
await self.bot.on_message(self.staff_msg("!bot quiet 10"))
self.assertFalse(self.bot.replies_allowed())
self.bot.quiet_until = time.monotonic() - 1
self.assertTrue(self.bot.replies_allowed())
class TestStatus(OpsBase):
async def test_status_reports_flags(self):
"""OPS-05: !bot status answers in the staff channel with the flag state."""
await self.bot.on_message(self.staff_msg("!bot status"))
self.bot.staff_channel.send.assert_awaited()
text = self.bot.staff_channel.send.await_args.args[0]
self.assertIn("replies", text)
self.assertIn("images", text)
self.assertIn("tasks", text)
class TestKeywordAlerts(OpsBase):
async def test_keyword_forces_staff_alert(self):
"""OPS-06: staff-alert-keywords match forces an alert when the model set none."""
self.bot.config["staff-alert-keywords"] = ["(?i)hjelp|help"]
response = AIResponse("ok", True, "chat", None, None, False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
await self.bot.respond(AIMessage("guest", "HELP at table 4", "chat"), MagicMock(spec=TextChannel))
self.bot.staff_channel.send.assert_awaited_once()
self.assertIn("guest", self.bot.staff_channel.send.await_args.args[0])
class TestAlertRateLimit(OpsBase):
async def test_alerts_rate_limited(self):
"""OPS-07: staff alerts above the hourly cap are logged, not sent."""
self.bot.config["staff-alert-max-per-hour"] = 2
await self.bot.send_staff_alert("one")
await self.bot.send_staff_alert("two")
await self.bot.send_staff_alert("three")
self.assertEqual(self.bot.staff_channel.send.await_count, 2)
class TestAlertFallback(OpsBase):
async def test_lost_alert_is_logged_not_raised(self):
"""OPS-08: no staff channel -> alert goes to the error log, no crash."""
self.bot.staff_channel = None
with self.assertLogs(level="ERROR") as logs:
await self.bot.send_staff_alert("nobody hears this")
self.assertTrue(any("nobody hears this" in line for line in logs.output))
class TestTasksKillSwitch(OpsBase):
async def test_tasks_off_blocks_bot_initiated(self):
"""OPS-09: !bot tasks off disables bot-initiated posting; on restores."""
self.assertTrue(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks off"))
self.assertFalse(self.bot.tasks_enabled)
self.assertFalse(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks on"))
self.assertTrue(self.bot.bot_initiated_allowed())
async def test_pause_also_blocks_bot_initiated(self):
"""OPS-09: bot-initiated posts respect pause/quiet."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.bot_initiated_allowed())
+64
View File
@@ -0,0 +1,64 @@
"""Unit coverage for SPEC-003 injection gates (SAF-01..03)."""
import tempfile
import unittest
from fjerkroa_bot.ai_responder import AIMessage, AIResponder, sanitize_external_text
from .test_main import TestBotBase
class TestChannelRoutingGate(TestBotBase):
async def test_default_allowed_set_from_config_names(self):
"""SAF-01: default allowed set = channels named in config; everything else refused."""
self.bot.config = dict(self.bot.config)
self.bot.config.update(
{"chat-channel": "general", "staff-channel": "staff", "welcome-channel": "welcome", "additional-responders": ["games"]}
)
self.assertTrue(self.bot.routing_allowed("general"))
self.assertTrue(self.bot.routing_allowed("staff"))
self.assertTrue(self.bot.routing_allowed("games"))
self.assertFalse(self.bot.routing_allowed("random-channel"))
self.assertFalse(self.bot.routing_allowed(None))
async def test_explicit_allowlist_wins(self):
"""SAF-01: configured allowed-channels replaces the default set."""
self.bot.config = dict(self.bot.config)
self.bot.config.update({"chat-channel": "general", "allowed-channels": ["announcements"]})
self.assertTrue(self.bot.routing_allowed("announcements"))
self.assertFalse(self.bot.routing_allowed("general"))
class TestAllowedMentions(TestBotBase):
async def test_outbound_pings_disabled(self):
"""SAF-02: the bot is constructed with allowed_mentions = none."""
mentions = self.bot.allowed_mentions
self.assertIsNotNone(mentions)
self.assertFalse(mentions.everyone)
self.assertFalse(mentions.users)
self.assertFalse(mentions.roles)
class TestSanitizeExternalText(unittest.TestCase):
def test_sanitizer_strips_and_caps(self):
"""SAF-03: control chars stripped, @everyone/@here neutralized, length capped."""
dirty = "hei\x00\x1b[31m @everyone @here " + "x" * 5000
clean = sanitize_external_text(dirty, max_len=4000)
self.assertNotIn("\x00", clean)
self.assertNotIn("\x1b", clean)
self.assertNotIn("@everyone", clean)
self.assertNotIn("@here", clean)
self.assertLessEqual(len(clean), 4000)
self.assertIn("hei", clean)
def test_news_content_sanitized_into_prompt(self):
"""SAF-03: news file content passes the sanitizer before prompt injection."""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fd:
fd.write("Breaking: @everyone \x00 click here")
news_path = fd.name
config = {"system": "News: {news}", "history-limit": 5, "news": news_path}
responder = AIResponder(config, "chat")
system = responder.message(AIMessage("alice", "hei"))[0]["content"]
self.assertNotIn("@everyone", system)
self.assertNotIn("\x00", system)
self.assertIn("Breaking:", system)
+60
View File
@@ -0,0 +1,60 @@
"""Unit coverage for FDB-005 structured outputs (ENV-18, ENV-19)."""
import unittest
from unittest.mock import AsyncMock, Mock, patch
from fjerkroa_bot.ai_responder import AIMessage
from fjerkroa_bot.openai_responder import ENVELOPE_RESPONSE_FORMAT, OpenAIResponder
from .test_bdd_envelope import FakeModelResponder, envelope
RESPONDER_CONFIG = {"openai-token": "test", "model": "main-model", "system": "s", "history-limit": 5}
def ok_result(content=None):
message = Mock(content=content or envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None, refusal=None)
return Mock(choices=[Mock(message=message)], usage="usage")
class TestNoRepairPath(unittest.IsolatedAsyncioTestCase):
async def test_malformed_output_is_failed_attempt(self):
"""ENV-18: malformed output is a failed attempt with backoff — no repair path (replaces ENV-06)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
responder.scripted = ["definitely not json", "definitely not json", "definitely not json"]
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertEqual(responder.chat_calls, 3)
self.assertGreaterEqual(sleep.await_count, 2)
self.assertFalse(hasattr(responder, "fix"))
async def test_refusal_is_failed_attempt(self):
"""ENV-18: a model refusal yields no answer from chat()."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
refusal_message = Mock(content=None, refusal="I cannot help with that.", tool_calls=None, role="assistant")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = Mock(choices=[Mock(message=refusal_message)], usage="usage")
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNone(answer)
class TestEnvelopeSchema(unittest.IsolatedAsyncioTestCase):
def test_schema_shape_pinned(self):
"""ENV-19: strict envelope schema — exact fields, all required, closed object."""
json_schema = ENVELOPE_RESPONSE_FORMAT["json_schema"]
schema = json_schema["schema"]
expected = {"answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"}
self.assertEqual(set(schema["properties"]), expected)
self.assertEqual(set(schema["required"]), expected)
self.assertFalse(schema["additionalProperties"])
self.assertTrue(json_schema["strict"])
self.assertEqual(json_schema["name"], "envelope")
async def test_chat_carries_response_format(self):
"""ENV-19: chat calls pass the pinned response_format to the API."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = ok_result()
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNotNone(answer)
self.assertEqual(chat_mock.await_args.kwargs["response_format"], ENVELOPE_RESPONSE_FORMAT)