Compare commits

..

4 Commits

35 changed files with 4243 additions and 389 deletions
+10
View File
@@ -8,9 +8,19 @@ build/
history/
.config.yaml
.db
db/
.env
openai_chat.dat
openai_chat.dat.*
start.sh
env.sh
ggg.toml
kroa.toml
last_updates.json
.coverage
.venv/
.mypy_cache/
.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
+45
View File
@@ -0,0 +1,45 @@
# DECISIONS.md — ADR log
Decisions inside the set architecture. D-NNN, never renumbered.
- **D-001** — SDD+BDD+TDD adopted (2026-07-13). SPEC-system port:
numbered requirements in `specs/`, coverage classes
feature/test/manual, `tools/trace.py` enforcement in `make check`.
Process is binding — see SPEC-000.
- **D-002** — BDD runs at the responder seam, not against live
services. `FakeModelResponder` scripts model output; Discord-event
behavior is unit-tested with mocked discord.py objects. Rationale:
Discord ToS forbids test-account automation and LLM output is
nondeterministic — a live-BDD lane would be flaky by construction.
- **D-003** — Packaging = uv + pyproject only (2026-07-13). setup.py,
requirements.txt and pytest.ini removed; single source of truth,
locked via uv.lock. Python `>=3.11` floor keeps the kitchen host
(py3.11) deployable until FDB-016 lands.
- **D-004** — openai SDK pinned `<2` (1.109.x). The v2 SDK migration
happens together with the FDB-005 envelope rewrite (structured
outputs / Responses API) — one breaking change, one test cycle,
instead of two.
- **D-005** — `--strict-markers` stays on; requirement tags from
`.feature` files are registered as pytest markers dynamically in
`tests/conftest.py`.
- **D-006** — FDB-005 stays on chat.completions + structured outputs;
the Responses API migration is deferred to FDB-007, where history
handling gets redesigned anyway — one conversation-state reshape
instead of two.
- **D-007** — openai SDK bumped to 2.x together with the envelope
rewrite (supersedes the D-004 pin). `multiline` dependency dropped —
strict schema output made relaxed-JSON parsing dead code.
- **D-008** — Operator runtime flags (pause/images/tasks/quiet) are
in-memory only; a restart resets to config defaults. Persistence
arrives with the FDB-011 task store if staff practice demands it.
- **D-009** — `translate()` still keys off `fix-model` although the
repair path is gone; the whole translate-before-draw step dies in
FDB-009 (gpt-image-2 is multilingual). Not worth a config rename
for one phase.
- **D-010** — Persistence uses stdlib `sqlite3` via
`asyncio.to_thread`, not aiosqlite: no new dependency, and a
connection-per-operation with WAL is plenty at this message volume.
- **D-011** — `save_history` replaces the channel's rows wholesale
per message instead of appending: histories are capped at
`history-limit` (~200-350 rows) and trims must be reflected;
correctness over micro-optimization.
+28 -46
View File
@@ -1,6 +1,6 @@
# Fjerkroa Bot Development Makefile
# Fjerkroa Bot Development Makefile (uv-managed)
.PHONY: help install install-dev clean test test-cov lint format type-check security-check all-checks pre-commit run build
.PHONY: help install install-dev clean test test-cov test-fast lint format format-check type-check security-check audit trace check all-checks pre-commit run run-dev build ci
# Default target
help: ## Show this help message
@@ -9,12 +9,12 @@ help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
# Installation targets
install: ## Install production dependencies
pip3.11 install -r requirements.txt
install: ## Sync production dependencies
uv sync --no-dev
install-dev: install ## Install development dependencies and pre-commit hooks
pip3.11 install -e .
pre-commit install
install-dev: ## Sync all dependencies and install pre-commit hooks
uv sync
uv run pre-commit install
# Cleaning targets
clean: ## Clean up temporary files and caches
@@ -28,72 +28,54 @@ clean: ## Clean up temporary files and caches
# Testing targets
test: ## Run tests
python3.11 -m pytest -v
uv run pytest -v
test-cov: ## Run tests with coverage report
python3.11 -m pytest --cov=fjerkroa_bot --cov-report=html --cov-report=term-missing
uv run pytest --cov=fjerkroa_bot --cov-report=html --cov-report=term-missing
test-fast: ## Run tests without slow tests
python3.11 -m pytest -v -m "not slow"
uv run pytest -v -m "not slow"
# Code quality targets
lint: ## Run linter (flake8)
python3.11 -m flake8 fjerkroa_bot tests
uv run flake8 fjerkroa_bot tests
format: ## Format code with black and isort
python3.11 -m black fjerkroa_bot tests
python3.11 -m isort fjerkroa_bot tests
uv run black fjerkroa_bot tests
uv run isort fjerkroa_bot tests
format-check: ## Check if code is properly formatted
python3.11 -m black --check fjerkroa_bot tests
python3.11 -m isort --check-only fjerkroa_bot tests
uv run black --check fjerkroa_bot tests
uv run isort --check-only fjerkroa_bot tests
type-check: ## Run type checker (mypy)
python3.11 -m mypy fjerkroa_bot tests
uv run mypy fjerkroa_bot tests
security-check: ## Run security scanner (bandit)
python3.11 -m bandit -r fjerkroa_bot --configfile pyproject.toml
uv run bandit -r fjerkroa_bot --configfile pyproject.toml
audit: ## Audit locked dependencies for known CVEs
uv export --no-dev --no-emit-project --format requirements-txt | uv run pip-audit -r /dev/stdin --disable-pip
trace: ## Verify spec requirement coverage (SDD/BDD/TDD)
uv run python tools/trace.py
# Combined targets
all-checks: lint format-check type-check security-check test ## Run all code quality checks and tests
check: lint format-check type-check security-check trace test ## The gate: all quality checks + spec trace + tests
all-checks: check audit ## check + dependency audit
pre-commit: format lint type-check security-check test ## Run all pre-commit checks (format, then check)
# Development targets
run: ## Run the bot (requires config.toml)
python3.11 -m fjerkroa_bot
uv run python -m fjerkroa_bot
run-dev: ## Run the bot in development mode with auto-reload
python3.11 -m watchdog.watchmedo auto-restart --patterns="*.py" --recursive -- python3.11 -m fjerkroa_bot
uv run watchmedo auto-restart --patterns="*.py" --recursive -- python -m fjerkroa_bot
# Build targets
build: clean ## Build distribution packages
python3.11 setup.py sdist bdist_wheel
uv build
# CI targets
ci: install-dev all-checks ## Full CI pipeline (install deps and run all checks)
# Docker targets (if needed in future)
docker-build: ## Build Docker image
docker build -t fjerkroa-bot .
docker-run: ## Run bot in Docker container
docker run -d --name fjerkroa-bot fjerkroa-bot
# Utility targets
deps-update: ## Update dependencies (requires pip-tools)
python3.11 -m piptools compile requirements.in --upgrade
requirements-lock: ## Generate locked requirements
pip3.11 freeze > requirements-lock.txt
check-deps: ## Check for outdated dependencies
pip3.11 list --outdated
# Documentation targets (if needed)
docs: ## Generate documentation (placeholder)
@echo "Documentation generation not implemented yet"
# Database/migration targets (if needed)
migrate: ## Run database migrations (placeholder)
@echo "No migrations needed for this project"
+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
+61
View File
@@ -0,0 +1,61 @@
Feature: Response envelope handling
The responder parses the model's JSON envelope and decides what the
bot says, where, and whether staff is alerted. (SPEC-001)
Background:
Given a responder with history limit 10
@ENV-01
Scenario: Model answer reaches the user
Given the model answers with answer "Hei! Velkommen." and answer_needed "true"
When user "alice" sends "Hei bot" in channel "chat"
Then the response answer contains "Hei! Velkommen."
And the response is marked as needed
@ENV-02
Scenario: Suppressed answer stays silent
Given the model answers with answer "irrelevant musing" and answer_needed "false"
When user "alice" sends "talking to bob" in channel "chat"
Then the response is not marked as needed
@ENV-03
Scenario: Staff note forces delivery
Given the model answers with answer "Et oyeblikk!" and staff note "Guest at table 4 needs a waiter"
When user "guest" sends "Can somebody help us?" in channel "chat"
Then the response staff note is "Guest at table 4 needs a waiter"
And the response is marked as needed
@ENV-04
Scenario: Direct messages are always answered
Given the model answers with answer "Svar." and answer_needed "false"
When user "alice" sends "hei" directly to the bot
Then the response is marked as needed
@ENV-05
Scenario: Short-path rules skip the model
Given a short-path rule for channels "spam.*" and users "bob.*"
When user "bobby" sends "noise noise" in channel "spam-corner"
Then the model was not called
And the response is empty
And the history contains the message from "bobby"
@ENV-07
Scenario: History is trimmed to the limit
Given a responder with history limit 4
And 6 prior history entries in channel "chat"
And the model answers with answer "ok" and answer_needed "true"
When user "alice" sends "hei" in channel "chat"
Then the history length is at most 4
@ENV-08
Scenario: Markdown links are unwrapped
Given the model answers with answer "Se [menyen](https://fjerkroa.example/meny) her" and answer_needed "true"
When user "alice" sends "meny?" in channel "chat"
Then the response answer contains "https://fjerkroa.example/meny"
And the response answer does not contain "[menyen]"
@ENV-09
Scenario: Missing channel falls back to the message channel
Given the model answers with answer "ok" and no channel
When user "alice" sends "hei" in channel "kitchen-talk"
Then the response channel is "kitchen-talk"
+94 -106
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import os
@@ -11,7 +12,7 @@ from pathlib import Path
from pprint import pformat
from typing import Any, Dict, List, Optional, Tuple, Union
import multiline
from .persistence import PersistentStore
def pp(*args, **kw):
@@ -22,14 +23,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 +87,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] = []
@@ -161,17 +140,16 @@ class AIResponder(AIResponderBase):
self.history: List[Dict[str, Any]] = []
self.memory: str = "I am an assistant."
self.rate_limit_backoff = exponential_backoff()
self.history_file: Optional[Path] = None
self.memory_file: Optional[Path] = None
self.store: Optional[PersistentStore] = None
if "history-directory" in self.config:
self.history_file = Path(self.config["history-directory"]).expanduser() / f"{self.channel}.dat"
if self.history_file.exists():
with open(self.history_file, "rb") as fd:
self.history = pickle.load(fd)
self.memory_file = Path(self.config["history-directory"]).expanduser() / f"{self.channel}.memory"
if self.memory_file.exists():
with open(self.memory_file, "rb") as fd:
self.memory = pickle.load(fd)
directory = Path(self.config["history-directory"]).expanduser()
self.store = PersistentStore(directory / "bot.db")
# Legacy pickles import once, then live on as *.migrated (PER-03)
self.store.migrate_pickles(self.channel, directory / f"{self.channel}.dat", directory / f"{self.channel}.memory")
self.history = self.store.load_history(self.channel)
stored_memory = self.store.load_memory(self.channel)
if stored_memory is not None:
self.memory = stored_memory
logging.info(f"memmory:\n{self.memory}")
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
@@ -182,7 +160,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 +189,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
@@ -252,34 +226,38 @@ class AIResponder(AIResponderBase):
self.history.append({"role": "user", "content": str(message)})
while len(self.history) > limit:
self.shrink_history_by_one()
if self.history_file is not None:
with open(self.history_file, "wb") as fd:
pickle.dump(self.history, fd)
return True
return False
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):
@@ -289,14 +267,17 @@ class AIResponder(AIResponderBase):
self.history.append(answer)
while len(self.history) > limit:
self.shrink_history_by_one()
if self.history_file is not None:
with open(self.history_file, "wb") as fd:
pickle.dump(self.history, fd)
def update_memory(self, memory) -> None:
if self.memory_file is not None:
with open(self.memory_file, "wb") as fd:
pickle.dump(self.memory, fd)
self.memory = memory
async def _persist_history(self) -> None:
if self.store is not None:
await asyncio.to_thread(self.store.save_history, self.channel, list(self.history))
async def _persist_memory(self) -> None:
if self.store is not None:
await asyncio.to_thread(self.store.save_memory, self.channel, self.memory)
async def handle_picture(self, response: Dict) -> bool:
if not isinstance(response.get("picture"), (type(None), str)):
@@ -306,9 +287,19 @@ 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)
await self._persist_memory()
async def memoize_reaction(self, message_user: str, reaction_user: str, operation: str, reaction: str, message: str) -> None:
quoted_message = message.replace("\n", "\n> ")
@@ -322,10 +313,19 @@ class AIResponder(AIResponderBase):
# Check if a short path applies, return an empty AIResponse if it does
if self.short_path(message, limit):
await self._persist_history()
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,34 +336,22 @@ 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
answer_message = await self.post_process(message, response)
answer["content"] = str(answer_message)
# Update message history
# Update message history; persistence runs off the loop (PER-05)
self.update_history(messages[-1], answer, limit, message.historise_question)
await self._persist_history()
logging.info(f"got this answer:\n{str(answer_message)}")
# Update memory
+142 -23
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:
@@ -153,17 +236,30 @@ class FjerkroaBot(commands.Bot):
)
def on_config_file_modified(self, event):
if event.src_path == self.config_file:
new_config = self.load_config(self.config_file)
if repr(new_config) != repr(self.config):
logging.info(f"config file {self.config_file} changed, reloading.")
self.config = new_config
self.airesponder.config = self.config
for responder in self.aichannels.values():
responder.config = self.config
# Runs on the watchdog observer thread — the swap itself is
# scheduled onto the event loop so no request reads a
# half-swapped config (CFG-04 / D9)
if event.src_path != self.config_file:
return
new_config = self.load_config(self.config_file)
if repr(new_config) == repr(self.config):
return
logging.info(f"config file {self.config_file} changed, reloading.")
def apply() -> None:
self.config = new_config
self.airesponder.config = new_config
for responder in self.aichannels.values():
responder.config = new_config
try:
self.loop.call_soon_threadsafe(apply)
except (RuntimeError, AttributeError):
# event loop not running yet (startup) — no concurrent readers
apply()
@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 +336,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 +391,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:
+87
View File
@@ -0,0 +1,87 @@
"""SQLite persistence for history + memory (SPEC-009).
One database per deployment, stdlib sqlite3 only (D-010). Callers run
the sync methods in a worker thread (`asyncio.to_thread`) so the
event loop never blocks on disk (PER-05 / D6).
"""
import logging
import os
import pickle
import sqlite3
from contextlib import closing
from pathlib import Path
from typing import Any, Dict, List, Optional
SCHEMA_VERSION = 1
class PersistentStore:
def __init__(self, db_path: Path) -> None:
self.db_path = Path(db_path)
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self) -> None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as conn, conn:
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
conn.execute(
"CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, channel TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL)"
)
conn.execute("CREATE INDEX IF NOT EXISTS history_channel ON history (channel)")
conn.execute("CREATE TABLE IF NOT EXISTS memory (channel TEXT PRIMARY KEY, content TEXT NOT NULL)")
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
os.chmod(self.db_path, 0o600) # conversation data (PER-04)
def load_history(self, channel: str) -> List[Dict[str, Any]]:
with closing(self._connect()) as conn:
rows = conn.execute("SELECT role, content FROM history WHERE channel = ? ORDER BY id", (channel,)).fetchall()
return [{"role": role, "content": content} for role, content in rows]
def save_history(self, channel: str, history: List[Dict[str, Any]]) -> None:
# Full replace per save: histories are small (<= history-limit)
# and trims must be reflected (D-011)
with closing(self._connect()) as conn, conn:
conn.execute("DELETE FROM history WHERE channel = ?", (channel,))
conn.executemany(
"INSERT INTO history (channel, role, content) VALUES (?, ?, ?)",
[(channel, str(entry.get("role", "user")), str(entry.get("content", ""))) for entry in history],
)
def load_memory(self, channel: str) -> Optional[str]:
with closing(self._connect()) as conn:
row = conn.execute("SELECT content FROM memory WHERE channel = ?", (channel,)).fetchone()
return row[0] if row else None
def save_memory(self, channel: str, content: str) -> None:
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO memory (channel, content) VALUES (?, ?) ON CONFLICT(channel) DO UPDATE SET content = excluded.content",
(channel, content),
)
def migrate_pickles(self, channel: str, history_file: Path, memory_file: Path) -> None:
"""Import legacy pickles once; rename them *.migrated (PER-03)."""
if self.load_history(channel) or self.load_memory(channel) is not None:
return
if history_file.exists():
try:
with open(history_file, "rb") as fd:
self.save_history(channel, pickle.load(fd))
history_file.rename(history_file.with_name(history_file.name + ".migrated"))
logging.info(f"migrated legacy history pickle for {channel}")
except Exception as err:
logging.error(f"failed to migrate history pickle {history_file}: {err!r}")
if memory_file.exists():
try:
with open(memory_file, "rb") as fd:
self.save_memory(channel, str(pickle.load(fd)))
memory_file.rename(memory_file.with_name(memory_file.name + ".migrated"))
logging.info(f"migrated legacy memory pickle for {channel}")
except Exception as err:
logging.error(f"failed to migrate memory pickle {memory_file}: {err!r}")
+11
View File
@@ -0,0 +1,11 @@
# Manual verification log
Rows for requirements with `coverage: manual` (see SPEC-000). Newest
first. A manual requirement is only "covered" when it has a row here
with date + result.
| ID | Date | Result |
| --- | --- | --- |
_No manual-coverage requirements declared yet — DEP-NN arrives with
FDB-017._
+44 -47
View File
@@ -1,6 +1,46 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "fjerkroa-bot"
version = "2.0"
description = "Discord bot with OpenAI responder for Fjærkroa and GGG"
authors = [{ name = "Oleksandr Kozachuk", email = "ddeus.lp@mailnull.com" }]
requires-python = ">=3.11"
dependencies = [
"discord.py>=2.5,<3",
"openai>=2.45", # 2.x since the FDB-005 envelope rewrite (D-007)
"aiohttp>=3.12",
"tomlkit>=0.13",
"watchdog>=6",
"requests>=2.32",
]
[project.scripts]
fjerkroa_bot = "fjerkroa_bot:main"
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=1",
"pytest-bdd>=8",
"pytest-cov>=6",
"respx>=0.22",
"toml>=0.10",
"mypy>=1.16",
"flake8>=7",
"black>=25",
"isort>=6",
"bandit[toml]>=1.8",
"pre-commit>=4",
"pip-audit>=2.9",
"types-requests",
"types-toml",
]
[tool.setuptools]
packages = ["fjerkroa_bot"]
[tool.mypy]
files = ["fjerkroa_bot", "tests"]
@@ -22,7 +62,6 @@ show_error_codes = true
[[tool.mypy.overrides]]
module = [
"discord.*",
"multiline.*",
"aiohttp.*",
"openai.*",
"tomlkit.*",
@@ -31,50 +70,9 @@ module = [
]
ignore_missing_imports = true
[tool.flake8]
max-line-length = 140
max-complexity = 10
ignore = [
"E203",
"E266",
"E501",
"W503",
"E306",
]
exclude = [
".git",
".mypy_cache",
".pytest_cache",
"__pycache__",
"build",
"dist",
"venv",
]
[tool.poetry]
name = "fjerkroa_bot"
version = "2.0"
description = ""
authors = ["Oleksandr Kozachuk <ddeus.lp@mailnull.com>"]
[tool.poetry.dependencies]
python = "^3.8"
"discord.py" = "*"
openai = "*"
aiohttp = "*"
mypy = "*"
flake8 = "*"
pre-commit = "*"
pytest = "*"
setuptools = "*"
wheel = "*"
watchdog = "*"
tomlkit = "*"
multiline = "*"
[tool.black]
line-length = 140
target-version = ['py38']
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
@@ -108,7 +106,7 @@ skips = ["B101", "B601", "B301", "B311", "B403", "B113"] # Skip pickle, random,
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --strict-markers --strict-config"
addopts = "-ra -q --strict-markers --strict-config -W ignore::DeprecationWarning"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
@@ -123,7 +121,6 @@ source = ["fjerkroa_bot"]
omit = [
"*/tests/*",
"*/test_*",
"setup.py",
]
[tool.coverage.report]
-2
View File
@@ -1,2 +0,0 @@
[pytest]
addopts = -W ignore::DeprecationWarning
-17
View File
@@ -1,17 +0,0 @@
aiohttp
bandit[toml]
black
discord.py
flake8
isort
multiline
mypy
openai
pre-commit
pytest
pytest-asyncio
pytest-cov
setuptools
tomlkit
watchdog
wheel
-17
View File
@@ -1,17 +0,0 @@
from setuptools import find_packages, setup
setup(
name="fjerkroa-bot",
version="2.0",
packages=find_packages(),
entry_points={"console_scripts": ["fjerkroa_bot = fjerkroa_bot:main"]},
test_suite="tests",
install_requires=["discord.py", "openai"],
author="Oleksandr Kozachuk",
author_email="ddeus.lp@mailnull.com",
description="A simple Discord bot that uses OpenAI's GPT to chat with users",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
url="https://github.com/ok2/fjerkroa-bot",
classifiers=["Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3"],
)
+69
View File
@@ -0,0 +1,69 @@
# SPEC-000 — Development process (binding)
This repo is developed spec-driven + behaviour-driven + test-driven.
## The three loops
1. **Spec-driven**: every behavior exists first as a numbered
requirement (`<AREA>-NN`) in `specs/SPEC-NNN-*.md`. No code without
a requirement. Requirements are never deleted — a dead requirement
is marked `(withdrawn: <successor or reason>)` in its title line
and keeps its ID forever.
2. **Behaviour-driven**: every *user-visible* requirement gets at
least one Gherkin scenario tagged `@<ID>` in `features/*.feature`,
executed by pytest-bdd.
3. **Test-driven**: implementation starts at a red test. Unit tests
cover the non-visible requirements (arithmetic, invariants,
parsing).
Change flow: spec change → feature/test red → implementation green →
refactor.
## Requirement format
```
### ENV-01 — Model answer reaches the user (coverage: feature)
Normative statement first. Rationale after.
```
Coverage classes:
- `feature` — needs an `@<ID>` tag in some `.feature` file.
- `test` — the ID must appear in a test file (docstring or comment
of the covering test).
- `manual` — needs a row in `manual-verification.md` with date and
result before the increment demo.
## Enforcement
`tools/trace.py` (stdlib-only) runs in `make check` (and `make
trace`). It fails the build when a declared requirement lacks its
coverage class artifact, when a feature tag references an undeclared
ID, or when an ID is declared twice. Code fences in spec files are
ignored by trace (the example above does not declare ENV-01).
## Test substrate
BDD scenarios run against the **responder seam**: a
`FakeModelResponder` subclass with scripted model output — no live
Discord, no live OpenAI (see DECISIONS.md D-002). Discord-event
behavior is covered by unit tests with mocked discord.py objects.
## ADRs
Decisions inside the set architecture go to `DECISIONS.md` as
`D-NNN — title — one-paragraph rationale`. The stack itself is not
re-litigated there.
## Spec map
- SPEC-000 process (this file, no IDs)
- SPEC-001 responder + envelope — ENV-NN
- SPEC-002 memory — MEM-NN (lands with FDB-007)
- SPEC-003 safety/privacy/abuse — SAF-NN (lands with FDB-014)
- SPEC-004 images — IMG-NN (lands with FDB-009/010)
- SPEC-005 self-tasking — TSK-NN (lands with FDB-011)
- SPEC-006 staff/operator controls — OPS-NN (lands with FDB-005)
- SPEC-007 deploy/hosts — DEP-NN (lands with FDB-017, mostly manual)
- SPEC-008 config — CFG-NN
+133
View File
@@ -0,0 +1,133 @@
# SPEC-001 — Responder + response envelope
Characterization of the current (v2) responder contract in
`fjerkroa_bot/ai_responder.py`. The model answers with a JSON
envelope: `answer`, `answer_needed`, `channel`, `staff`, `picture`,
`picture_edit`, `hack`. FDB-005 will replace the transport of this
contract (structured outputs); the *behavioral* requirements below
survive that change unless marked otherwise.
### ENV-01 — Model answer reaches the user (coverage: feature)
When the model envelope carries a non-empty `answer` and
`answer_needed` is true, `AIResponder.send()` returns an `AIResponse`
with that answer text and `answer_needed == True`. This is the core
loop: user talks, bot answers.
### ENV-02 — Suppressed answer stays silent (coverage: feature)
When the model envelope sets `answer_needed` to false (and the
message is not direct, mentions nobody, and no staff note is set),
the returned `AIResponse` has `answer_needed == False`. The bot may
observe without butting in.
### ENV-03 — Staff note forces delivery (coverage: feature)
When the envelope carries a non-null `staff` text and a non-null
`answer`, the returned response preserves the staff text and has
`answer_needed == True`. Staff alerts must never be silently
dropped.
### ENV-04 — Direct messages are always answered (coverage: feature)
When the incoming `AIMessage` is marked `direct` and the model
returns a non-null answer, `answer_needed` is forced to `True`
regardless of the model's own `answer_needed`. A user addressing the
bot directly gets a reply.
### ENV-05 — Short-path rules skip the model (coverage: feature)
When a configured `short-path` `[channel-regex, user-regex]` pair
matches the message, `send()` appends the message to history, trims
history to the limit, persists it, and returns an empty response
(answer `None`, `answer_needed False`) **without calling the model**.
Cheap archival of noisy channels.
### ENV-06 — Malformed model output is repaired (coverage: withdrawn — successor ENV-18)
Withdrawn 2026-07-13 with FDB-005: strict structured outputs make the
repair model obsolete. Malformed output now counts as a failed
attempt — see ENV-18.
### ENV-07 — History is trimmed to the limit (coverage: feature)
After a completed exchange, `len(history) <= history-limit` holds.
Trimming happens both before the model call and after appending the
new question/answer pair.
### ENV-08 — Markdown links are unwrapped (coverage: feature)
In the final answer text, `[label](url)` becomes `url` and
`@[label](url)` becomes `label`. Discord renders raw URLs; markdown
link syntax from the model reads as noise.
### ENV-09 — Missing channel falls back to the message channel (coverage: feature)
When the envelope `channel` is null/none/empty, the response channel
is the channel the message came from.
### ENV-10 — System prompt template substitution (coverage: test)
`message()` substitutes `{date}` (YYYY-MM-DD), `{time}`, `{memory}`
(current memory string) in the system prompt; `{news}` is replaced
with the news file content when the configured file exists and stays
literal when it does not.
### ENV-11 — Per-channel history shrink prefers busy channels (coverage: test)
`shrink_history_by_one()` removes the oldest entry whose channel has
more than `history-per-channel` (default 3) entries; when no channel
exceeds the cap, the oldest entry overall is removed.
### ENV-12 — Exhausted retries raise, attempts are spaced (coverage: test)
When the model returns no usable answer three times in a row,
`send()` raises `RuntimeError`. Consecutive attempts are separated by
an exponential-backoff sleep — failures never hammer the API
back-to-back (D1).
### ENV-13 — Reaction-clear events are recorded (coverage: test)
`on_reaction_clear(message, reactions)` — the discord.py signature —
records the clearing in the channel's memory. (D7: the previous
handler declared `(reaction, user)` and crashed on dispatch.)
### ENV-14 — update_memory persists its argument (coverage: test)
`update_memory(memory)` sets the responder's memory to the passed
value and persists that value. (D12: the argument was ignored.)
### ENV-15 — retry-model is used after a rate limit (coverage: test)
After a rate-limited attempt, the next `chat()` attempt uses the
configured `retry-model` instead of `model`; a successful attempt
switches back. (D2: the fallback was assigned to a local and never
took effect.)
### ENV-16 — Model calls are never served from a disk cache (coverage: test)
`openai_chat`/`openai_image` call the client every time. The pickle
response cache (`openai_chat.dat`) is a test-era artifact and must
not exist in the production path (D3/D4).
### ENV-17 — IGDB tool execution runs in a worker thread (coverage: test)
`_execute_igdb_function` executes the synchronous IGDB library off
the event loop (worker thread), returning identical results. The
event loop keeps serving Discord events during lookups (D5).
### ENV-18 — Malformed or refused output is a failed attempt (coverage: test)
Model output is requested as schema-validated JSON (strict structured
outputs). Output that still fails to parse, or a model refusal,
counts as a failed attempt (backoff + retry per ENV-12) — there is no
repair model, no `fix()` path, no relaxed-JSON fallback. Replaces
ENV-06.
### ENV-19 — The envelope schema is pinned (coverage: test)
Every chat call carries `response_format` = strict JSON schema named
`envelope` with exactly the fields `answer`, `answer_needed`,
`channel`, `staff`, `picture`, `picture_edit`, `hack` — all required,
`additionalProperties: false`, nullable where the protocol allows
null. Tool-followup calls carry the same format.
+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.
+32
View File
@@ -0,0 +1,32 @@
# SPEC-008 — Configuration
TOML config per deployment (`kroa.toml`, `ggg.toml` — both untracked;
`config.toml` in the repo is the placeholder sample). Loaded by
`FjerkroaBot.load_config`, hot-reloaded by a watchdog observer
(defect D9 — reload race — is tracked in FDB-004 and will refine
these requirements).
### CFG-01 — TOML config loads into a plain dict (coverage: test)
`FjerkroaBot.load_config(path)` parses the TOML file and returns its
top-level table as a dict; responders read raw keys from it.
### CFG-02 — Per-channel system prompt override (coverage: test)
A responder bound to channel `X` uses `config["X"]` as its system
prompt when that key exists, else `config["system"]`. One deployment
can speak differently per channel.
### CFG-03 — Missing news file leaves the placeholder untouched (coverage: test)
When `news` points to a non-existent file, the `{news}` placeholder
stays literal in the system prompt (no crash, no empty substitution).
### CFG-04 — Config hot-reload applies on the event loop (coverage: test)
The watchdog observer thread never mutates live config references
itself: a detected change is loaded, then the swap of `bot.config`
and all responder `.config` references is scheduled onto the event
loop (`call_soon_threadsafe`), so no request ever reads a
half-swapped config (D9). Before the loop runs (startup), the swap
applies directly — there are no concurrent readers yet.
+38
View File
@@ -0,0 +1,38 @@
# SPEC-009 — Persistence
One SQLite database per deployment (`<history-directory>/bot.db`),
replacing the per-channel pickle files (`<channel>.dat`,
`<channel>.memory`). Stdlib `sqlite3` via worker threads — no new
dependency (D-010). Without `history-directory` in config the bot
runs memory-only, as before.
### PER-01 — History survives restarts (coverage: test)
History entries written through the store are returned, in order and
per channel, by a fresh store instance on the same database file.
### PER-02 — Memory survives restarts (coverage: test)
The per-channel memory string written through the store is returned
by a fresh store instance on the same database file.
### PER-03 — Existing pickles migrate exactly once (coverage: test)
On responder start, when the database holds no rows for the channel
and legacy pickle files exist, their content is imported and the
pickle files are renamed to `*.migrated` (kept for rollback). A
second start does not re-import. Users keep their history through
the cutover (review consensus: migration is a decision, not an
accident).
### PER-04 — Database hygiene (coverage: test)
The database runs in WAL journal mode, carries `PRAGMA user_version`
= schema version (currently 1) for future migrations/rollback
policy, and the file is chmod 0600 (it stores conversation data).
### PER-05 — Writes run off the event loop (coverage: test)
History and memory persistence happen in a worker thread
(`asyncio.to_thread`) — a slow disk cannot stall Discord event
handling (D6).
+15
View File
@@ -0,0 +1,15 @@
import re
from pathlib import Path
FEATURES_DIR = Path(__file__).resolve().parent.parent / "features"
TAG_RE = re.compile(r"@([A-Z]{2,8}-\d{2,3})\b")
def pytest_configure(config):
# pytest-bdd turns @ENV-01 tags into markers; register them so
# --strict-markers stays enabled (D-005).
ids = set()
for feature in FEATURES_DIR.glob("**/*.feature"):
ids |= set(TAG_RE.findall(feature.read_text(encoding="utf-8")))
for req_id in sorted(ids):
config.addinivalue_line("markers", f"{req_id}: spec requirement tag")
+1 -13
View File
@@ -1,6 +1,3 @@
import os
import pickle
import tempfile
import unittest
from unittest.mock import Mock, patch
@@ -147,7 +144,6 @@ You always try to say something positive about the current day and the Fjærkroa
def test_update_history(self) -> None:
updater = self.bot.airesponder
updater.history = []
updater.history_file = None
question = {"content": '{"channel": "test_channel", "message": "What is the meaning of life?"}'}
answer = {"content": '{"channel": "test_channel", "message": "42"}'}
@@ -179,15 +175,7 @@ You always try to say something positive about the current day and the Fjærkroa
next_answer2 = {"content": '{"channel": "other_channel", "message": "Tripple Z"}'}
updater.update_history(next_question2, next_answer2, 4)
self.assertEqual(updater.history, [new_answer, next_answer, next_question2, next_answer2])
# Test case 5: Check history file save using mock
with unittest.mock.patch("builtins.open", unittest.mock.mock_open()) as mock_file:
_, temp_path = tempfile.mkstemp()
os.remove(temp_path)
self.bot.airesponder.history_file = temp_path
updater.update_history(question, answer, 2)
mock_file.assert_called_with(temp_path, "wb")
mock_file().write.assert_called_with(pickle.dumps([question, answer]))
# File persistence moved to the SQLite store — covered by PER-01 (SPEC-009)
if __name__ == "__mait__":
+147
View File
@@ -0,0 +1,147 @@
"""BDD steps for features/envelope.feature (SPEC-001, ENV-01..09).
Scenarios drive AIResponder.send() through a FakeModelResponder with
scripted model output — no live OpenAI, no live Discord (D-002).
"""
import asyncio
import json
from typing import Any, Dict, List, Optional, Tuple
from pytest_bdd import given, parsers, scenarios, then, when
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
scenarios("../features/envelope.feature")
def envelope(answer=None, answer_needed=False, channel="chat", staff=None, picture=None, picture_edit=False, hack=False) -> str:
return json.dumps(
{
"answer": answer,
"answer_needed": answer_needed,
"channel": channel,
"staff": staff,
"picture": picture,
"picture_edit": picture_edit,
"hack": hack,
}
)
class FakeModelResponder(AIResponder):
"""AIResponder with the model calls scripted away (D-002)."""
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.scripted: List[str] = []
self.chat_calls = 0
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
self.chat_calls += 1
if not self.scripted:
return None, limit
return {"role": "assistant", "content": self.scripted.pop(0)}, limit
async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str:
return memory
async def translate(self, text: str, language: str = "english") -> str:
return text
@given(parsers.parse("a responder with history limit {limit:d}"), target_fixture="responder")
def responder(limit):
config = {"system": "You are a test bot. {memory}", "history-limit": limit}
return FakeModelResponder(config, "chat")
@given(parsers.parse('the model answers with answer "{answer}" and answer_needed "{needed}"'))
def script_answer(responder, answer, needed):
responder.scripted.append(envelope(answer=answer, answer_needed=needed == "true"))
@given(parsers.parse('the model answers with answer "{answer}" and staff note "{staff}"'))
def script_staff(responder, answer, staff):
responder.scripted.append(envelope(answer=answer, answer_needed=False, staff=staff))
@given(parsers.parse('the model answers with answer "{answer}" and channel "{channel}"'))
def script_channel(responder, answer, channel):
responder.scripted.append(envelope(answer=answer, answer_needed=True, channel=channel))
@given(parsers.parse('the model answers with answer "{answer}" and no channel'))
def script_channel_none(responder, answer):
responder.scripted.append(envelope(answer=answer, answer_needed=True, channel=None))
@given(parsers.parse('a short-path rule for channels "{chan_re}" and users "{user_re}"'))
def short_path_rule(responder, chan_re, user_re):
responder.config["short-path"] = [[chan_re, user_re]]
@given(parsers.parse('{count:d} prior history entries in channel "{channel}"'))
def prior_history(responder, count, channel):
for i in range(count):
responder.history.append({"role": "user", "content": json.dumps({"message": f"old {i}", "channel": channel})})
@when(parsers.parse('user "{user}" sends "{text}" in channel "{channel}"'), target_fixture="response")
def send_message(responder, user, text, channel):
return asyncio.run(responder.send(AIMessage(user, text, channel)))
@when(parsers.parse('user "{user}" sends "{text}" directly to the bot'), target_fixture="response")
def send_direct(responder, user, text):
return asyncio.run(responder.send(AIMessage(user, text, "chat", direct=True)))
@then(parsers.parse('the response answer contains "{text}"'))
def answer_contains(response, text):
assert response.answer is not None and text in response.answer
@then(parsers.parse('the response answer does not contain "{text}"'))
def answer_not_contains(response, text):
assert response.answer is not None and text not in response.answer
@then("the response is marked as needed")
def is_needed(response):
assert response.answer_needed is True
@then("the response is not marked as needed")
def not_needed(response):
assert response.answer_needed is False
@then(parsers.parse('the response staff note is "{text}"'))
def staff_note_is(response, text):
assert response.staff == text
@then(parsers.parse('the response channel is "{channel}"'))
def channel_is(response, channel):
assert response.channel == channel
@then("the model was not called")
def model_not_called(responder):
assert responder.chat_calls == 0
@then("the response is empty")
def response_empty(response):
assert response.answer is None and response.answer_needed is False
@then(parsers.parse('the history contains the message from "{user}"'))
def history_has_user(responder, user):
assert any(f'"user": "{user}"' in item["content"] for item in responder.history)
@then(parsers.parse("the history length is at most {limit:d}"))
def history_at_most(responder, limit):
assert len(responder.history) <= limit
+7 -35
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",
@@ -131,17 +107,13 @@ class TestFunctionality(TestBotBase):
' "channel": "some_channel", "direct": false, "historise_question": true}',
)
@patch("builtins.open", new_callable=mock_open)
def test_update_history_with_file(self, mock_file):
def test_update_history_trims_to_limit(self):
self.bot.airesponder.update_history({"content": '{"q": "What\'s your name?"}'}, {"content": '{"a": "AI"}'}, 10)
self.assertEqual(len(self.bot.airesponder.history), 2)
self.bot.airesponder.update_history({"content": '{"q1": "Q1"}'}, {"content": '{"a1": "A1"}'}, 2)
self.bot.airesponder.update_history({"content": '{"q2": "Q2"}'}, {"content": '{"a2": "A2"}'}, 2)
self.assertEqual(len(self.bot.airesponder.history), 2)
self.bot.airesponder.history_file = "mock_file.pkl"
self.bot.airesponder.update_history({"content": '{"q": "What\'s your favorite color?"}'}, {"content": '{"a": "Blue"}'}, 10)
mock_file.assert_called_once_with("mock_file.pkl", "wb")
mock_file().write.assert_called_once()
# File persistence moved to the SQLite store — covered by PER-01 (SPEC-009)
if __name__ == "__mait__":
+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."""
+43
View File
@@ -0,0 +1,43 @@
"""Unit coverage for SPEC-008 (CFG-01..03)."""
import unittest
from unittest.mock import mock_open, patch
import toml
from fjerkroa_bot import FjerkroaBot
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
class TestConfigLoad(unittest.TestCase):
def test_load_config_parses_toml(self):
"""CFG-01: load_config parses the TOML file into a plain dict."""
data = {"system": "prompt", "history-limit": 7, "additional-responders": []}
with patch("builtins.open", mock_open(read_data=toml.dumps(data))):
result = FjerkroaBot.load_config("config.toml")
self.assertEqual(result, data)
class TestPerChannelPrompt(unittest.TestCase):
def test_channel_override_wins(self):
"""CFG-02: responder bound to a channel uses config[channel] over config['system']."""
config = {"system": "Default prompt", "kitchen": "Kitchen prompt", "history-limit": 5}
responder = AIResponder(config, "kitchen")
system = responder.message(AIMessage("alice", "hei", "kitchen"))[0]["content"]
self.assertTrue(system.startswith("Kitchen prompt"))
def test_fallback_to_system(self):
"""CFG-02: without a channel key the shared system prompt is used."""
config = {"system": "Default prompt", "history-limit": 5}
responder = AIResponder(config, "kitchen")
system = responder.message(AIMessage("alice", "hei", "kitchen"))[0]["content"]
self.assertTrue(system.startswith("Default prompt"))
class TestNewsFileMissing(unittest.TestCase):
def test_missing_news_file_keeps_placeholder(self):
"""CFG-03: nonexistent news file -> {news} placeholder stays literal, no crash."""
config = {"system": "N: {news}", "history-limit": 5, "news": "/nonexistent/news.txt"}
responder = AIResponder(config, "chat")
system = responder.message(AIMessage("alice", "hei"))[0]["content"]
self.assertIn("{news}", system)
+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)
+56
View File
@@ -0,0 +1,56 @@
"""Unit coverage for SPEC-001 test-class requirements (ENV-10..12)."""
import json
import time
import unittest
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from .test_bdd_envelope import FakeModelResponder
def entry(channel: str, text: str = "x"):
return {"role": "user", "content": json.dumps({"message": text, "channel": channel})}
class TestSystemPromptTemplate(unittest.TestCase):
def test_template_substitution(self):
"""ENV-10: {date}/{memory} substituted; missing news file leaves {news} literal."""
config = {"system": "Date {date} memory {memory} news {news}", "history-limit": 5, "news": "/nonexistent/news.txt"}
responder = AIResponder(config, "chat")
responder.memory = "MEMSTR"
messages = responder.message(AIMessage("alice", "hei"))
system = messages[0]["content"]
self.assertIn(time.strftime("%Y-%m-%d"), system)
self.assertIn("MEMSTR", system)
self.assertIn("{news}", system) # left literal — file does not exist
class TestHistoryShrink(unittest.TestCase):
def test_shrink_prefers_busy_channel(self):
"""ENV-11: entry from the channel exceeding history-per-channel is removed first."""
config = {"system": "s", "history-limit": 4}
responder = AIResponder(config, "chat")
responder.history = [entry("a", "a0"), entry("a", "a1"), entry("a", "a2"), entry("a", "a3"), entry("b", "b0")]
responder.shrink_history_by_one()
self.assertEqual(len(responder.history), 4)
self.assertNotIn("a0", responder.history[0]["content"]) # oldest busy-channel entry gone
def test_shrink_falls_back_to_oldest(self):
"""ENV-11: when no channel exceeds the cap, the oldest entry overall is removed."""
config = {"system": "s", "history-limit": 4}
responder = AIResponder(config, "chat")
responder.history = [entry("a", "a0"), entry("b", "b0"), entry("c", "c0")]
responder.shrink_history_by_one()
self.assertEqual(len(responder.history), 2)
self.assertNotIn("a0", responder.history[0]["content"])
class TestRetriesExhausted(unittest.IsolatedAsyncioTestCase):
async def test_send_raises_after_three_failures(self):
"""ENV-12: three model failures -> RuntimeError (revision pending in FDB-004/D1)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
# scripted list empty -> chat() returns None every attempt
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertEqual(responder.chat_calls, 3)
+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())
+143
View File
@@ -0,0 +1,143 @@
"""Unit coverage for SPEC-009 persistence (PER-01..05) + CFG-04 (D9)."""
import pickle
import sqlite3
import stat
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from fjerkroa_bot.persistence import PersistentStore
from .test_bdd_envelope import FakeModelResponder, envelope
from .test_main import TestBotBase
class StoreBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = Path(self.tmp.name) / "bot.db"
def tearDown(self):
self.tmp.cleanup()
class TestHistoryRoundtrip(StoreBase):
def test_history_survives_restart(self):
"""PER-01: history rows come back per channel, in order, from a fresh store."""
store = PersistentStore(self.db_path)
entries = [{"role": "user", "content": "one"}, {"role": "assistant", "content": "two"}]
store.save_history("chat", entries)
store.save_history("other", [{"role": "user", "content": "elsewhere"}])
reloaded = PersistentStore(self.db_path)
self.assertEqual(reloaded.load_history("chat"), entries)
self.assertEqual(reloaded.load_history("other"), [{"role": "user", "content": "elsewhere"}])
def test_save_replaces_previous_state(self):
"""PER-01: save_history reflects trims — replaced, not appended."""
store = PersistentStore(self.db_path)
store.save_history("chat", [{"role": "user", "content": "a"}, {"role": "user", "content": "b"}])
store.save_history("chat", [{"role": "user", "content": "b"}])
self.assertEqual(store.load_history("chat"), [{"role": "user", "content": "b"}])
class TestMemoryRoundtrip(StoreBase):
def test_memory_survives_restart(self):
"""PER-02: per-channel memory string persists across store instances."""
store = PersistentStore(self.db_path)
store.save_memory("chat", "remember this")
self.assertEqual(PersistentStore(self.db_path).load_memory("chat"), "remember this")
self.assertIsNone(PersistentStore(self.db_path).load_memory("unknown"))
class TestPickleMigration(StoreBase):
def test_pickles_migrate_once(self):
"""PER-03: legacy pickles import once, files renamed *.migrated, no re-import."""
history_file = Path(self.tmp.name) / "chat.dat"
memory_file = Path(self.tmp.name) / "chat.memory"
with open(history_file, "wb") as fd:
pickle.dump([{"role": "user", "content": "old times"}], fd)
with open(memory_file, "wb") as fd:
pickle.dump("old memory", fd)
config = {"system": "s", "history-limit": 5, "history-directory": self.tmp.name}
responder = AIResponder(config, "chat")
self.assertEqual(responder.history, [{"role": "user", "content": "old times"}])
self.assertEqual(responder.memory, "old memory")
self.assertFalse(history_file.exists())
self.assertFalse(memory_file.exists())
self.assertTrue(history_file.with_suffix(".dat.migrated").exists())
# second start reads from the DB, does not re-import
responder2 = AIResponder(config, "chat")
self.assertEqual(responder2.history, [{"role": "user", "content": "old times"}])
class TestDatabaseHygiene(StoreBase):
def test_wal_version_and_permissions(self):
"""PER-04: WAL mode, user_version = 1, file mode 0600."""
PersistentStore(self.db_path)
conn = sqlite3.connect(self.db_path)
try:
self.assertEqual(conn.execute("PRAGMA journal_mode").fetchone()[0], "wal")
self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], 1)
finally:
conn.close()
mode = stat.S_IMODE(self.db_path.stat().st_mode)
self.assertEqual(mode, 0o600)
class TestWritesOffEventLoop(unittest.IsolatedAsyncioTestCase):
async def test_persistence_runs_in_worker_thread(self):
"""PER-05: history writes happen off the event loop (D6)."""
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
config = {"system": "s", "history-limit": 5, "history-directory": tmp.name}
responder = FakeModelResponder(config, "chat")
writing_threads = set()
original_save = responder.store.save_history
def capture_save(channel, history):
writing_threads.add(threading.current_thread())
return original_save(channel, history)
responder.store.save_history = capture_save
responder.scripted.append(envelope(answer="hei", answer_needed=True))
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertTrue(writing_threads)
self.assertNotIn(threading.main_thread(), writing_threads)
# and the data actually landed
self.assertTrue(PersistentStore(Path(tmp.name) / "bot.db").load_history("chat"))
class TestConfigReloadRace(TestBotBase):
async def test_reload_swaps_on_event_loop(self):
"""CFG-04: watchdog thread schedules the config swap via call_soon_threadsafe (D9)."""
new_config = dict(self.config_data)
new_config["history-limit"] = 99
self.bot.load_config = lambda path: new_config
self.bot.loop = MagicMock()
event = MagicMock()
event.src_path = self.bot.config_file
self.bot.on_config_file_modified(event)
self.bot.loop.call_soon_threadsafe.assert_called_once()
apply_fn = self.bot.loop.call_soon_threadsafe.call_args.args[0]
apply_fn()
self.assertEqual(self.bot.config["history-limit"], 99)
self.assertEqual(self.bot.airesponder.config["history-limit"], 99)
async def test_reload_applies_directly_without_loop(self):
"""CFG-04: before the loop runs, the swap applies synchronously."""
new_config = dict(self.config_data)
new_config["history-limit"] = 42
self.bot.load_config = lambda path: new_config
loop = MagicMock()
loop.call_soon_threadsafe.side_effect = RuntimeError("no running loop")
self.bot.loop = loop
event = MagicMock()
event.src_path = self.bot.config_file
self.bot.on_config_file_modified(event)
self.assertEqual(self.bot.config["history-limit"], 42)
+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)
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""trace.py — enforce spec requirement coverage (SPEC-000).
Stdlib only. Collects declared requirement IDs from specs/SPEC-*.md
headers (code fences stripped), @ID tags from features/*.feature, ID
mentions from tests/**/*.py, and rows from manual-verification.md.
Fails when a declared requirement lacks its coverage artifact, when a
feature tag or manual row references an undeclared ID, or when an ID
is declared twice.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ID_PATTERN = r"[A-Z]{2,8}-\d{2,3}"
HEADER_RE = re.compile(rf"^###\s+({ID_PATTERN})\s+[—–-]+\s+.*\(coverage:\s*(feature|test|manual|withdrawn[^)]*)\)", re.M)
FENCE_RE = re.compile(r"^```.*?^```", re.M | re.S)
TAG_RE = re.compile(rf"@({ID_PATTERN})\b")
MANUAL_ROW_RE = re.compile(rf"^\|\s*({ID_PATTERN})\s*\|", re.M)
def collect():
declared = {}
errors = []
for spec in sorted((ROOT / "specs").glob("SPEC-*.md")):
text = FENCE_RE.sub("", spec.read_text(encoding="utf-8"))
for req_id, coverage in HEADER_RE.findall(text):
if req_id in declared:
errors.append(f"{req_id}: declared twice")
declared[req_id] = coverage
feature_tags = set()
features_dir = ROOT / "features"
if features_dir.exists():
for feature in features_dir.glob("**/*.feature"):
feature_tags |= set(TAG_RE.findall(feature.read_text(encoding="utf-8")))
test_ids = set()
for test_file in (ROOT / "tests").glob("**/*.py"):
test_ids |= set(re.findall(ID_PATTERN, test_file.read_text(encoding="utf-8")))
manual_ids = set()
manual_file = ROOT / "manual-verification.md"
if manual_file.exists():
manual_ids = set(MANUAL_ROW_RE.findall(manual_file.read_text(encoding="utf-8")))
return declared, feature_tags, test_ids, manual_ids, errors
def coverage_errors(declared, feature_tags, test_ids, manual_ids):
errors = []
for req_id, coverage in sorted(declared.items()):
if "withdrawn" in coverage:
continue
if coverage == "feature" and req_id not in feature_tags:
errors.append(f"{req_id}: coverage 'feature' but no @{req_id} tag under features/")
elif coverage == "test" and req_id not in test_ids and req_id not in feature_tags:
errors.append(f"{req_id}: coverage 'test' but not mentioned under tests/ or features/")
elif coverage == "manual" and req_id not in manual_ids:
errors.append(f"{req_id}: coverage 'manual' but no row in manual-verification.md")
errors += [f"@{tag}: tagged under features/ but not declared in specs/" for tag in sorted(feature_tags - set(declared))]
errors += [f"{mid}: manual-verification.md row without spec declaration" for mid in sorted(manual_ids - set(declared))]
return errors
def main() -> int:
declared, feature_tags, test_ids, manual_ids, errors = collect()
errors += coverage_errors(declared, feature_tags, test_ids, manual_ids)
if errors:
print("trace: FAIL")
for error in errors:
print(f" - {error}")
return 1
by_class = {c: sum(1 for v in declared.values() if v == c) for c in ("feature", "test", "manual")}
print(
f"trace: OK — {len(declared)} requirements ({by_class['feature']} feature / {by_class['test']} test / {by_class['manual']} manual)"
)
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+2380
View File
File diff suppressed because it is too large Load Diff