Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e51e4230 | |||
| 5a3623f813 | |||
| e21c262299 | |||
| 7e6eae10ee |
+3
-1
@@ -35,7 +35,9 @@ Decisions inside the set architecture. D-NNN, never renumbered.
|
||||
- **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.
|
||||
for one phase. *(Closed 2026-07-13: FDB-009 deleted translate();
|
||||
the fix-model config key is now fully dead and can be dropped from
|
||||
live configs.)*
|
||||
- **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.
|
||||
|
||||
@@ -50,3 +50,12 @@ enable-game-info = true
|
||||
# split-threshold = 1200 # long answers split at paragraphs
|
||||
# split-max-parts = 3
|
||||
# quiet-hours = "21:00-09:00" # no bot-initiated posts in this window
|
||||
|
||||
# Image generation (SPEC-004)
|
||||
# image-model = "gpt-image-2" # default; dall-e-3 gets clamped to n=1
|
||||
# image-size = "1024x1024"
|
||||
# image-quality = "medium" # passed through only when set
|
||||
# Image input pipeline (SPEC-004, FDB-010) — active with history-directory:
|
||||
# image-cache-mb = 500 # LRU cap (ggg: consider 2000 — screenshots)
|
||||
# image-cache-ttl-days = 90
|
||||
# image-max-bytes = 8388608 # 8 MB upload cap
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from .images import ImageCache
|
||||
from .memory import MemoryManager
|
||||
from .persistence import PersistentStore
|
||||
|
||||
@@ -123,6 +124,7 @@ class AIResponse(AIMessageBase):
|
||||
self.channel = channel
|
||||
self.staff = staff
|
||||
self.picture = picture
|
||||
self.picture_count = 1
|
||||
self.picture_edit = picture_edit
|
||||
self.hack = hack
|
||||
self.vars = ["answer", "answer_needed", "channel", "staff", "picture", "hack"]
|
||||
@@ -152,17 +154,16 @@ class AIResponder(AIResponderBase):
|
||||
if stored_memory is not None:
|
||||
self.memory = stored_memory
|
||||
self.memory_manager = MemoryManager(self.store, lambda: self.config, self.consolidate, self.channel)
|
||||
self.image_cache: Optional[ImageCache] = None
|
||||
if self.store is not None:
|
||||
self.image_cache = ImageCache(self.store, Path(self.config["history-directory"]).expanduser() / "images", lambda: self.config)
|
||||
logging.info(f"memmory:\n{self.memory}")
|
||||
|
||||
# Dynamic values move to a context suffix so the persona prefix
|
||||
# stays byte-stable for the prompt cache (ENV-20)
|
||||
DYNAMIC_PLACEHOLDERS = ("{date}", "{time}", "{news}", "{memory}")
|
||||
|
||||
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
messages = []
|
||||
persona = self.config.get(self.channel, self.config["system"])
|
||||
for placeholder in self.DYNAMIC_PLACEHOLDERS:
|
||||
persona = persona.replace(placeholder, "")
|
||||
def _context_lines(self, message: AIMessage) -> List[str]:
|
||||
context = [f"date: {time.strftime('%Y-%m-%d')} ({time.strftime('%A')})", f"time: {time.strftime('%H:%M:%S')}"]
|
||||
news_feed = self.config.get("news")
|
||||
if news_feed and os.path.exists(news_feed):
|
||||
@@ -172,7 +173,23 @@ class AIResponder(AIResponderBase):
|
||||
memory_block = self.memory_manager.memory_block(participants, self.memory)
|
||||
if memory_block:
|
||||
context.append("memory:\n" + memory_block)
|
||||
system = persona.rstrip() + "\n\n## Context\n" + "\n".join(context)
|
||||
if self.image_cache is not None:
|
||||
recent_images = self.image_cache.recent(message.channel, 4)
|
||||
if recent_images:
|
||||
# the model cannot use picture_edit unless told images exist (IMG-16)
|
||||
context.append(
|
||||
f"recent images in this channel: {len(recent_images)}. When the user asks to modify, reuse, combine or"
|
||||
" include a previously shared image, you MUST set picture_edit=true — text-to-image cannot see earlier"
|
||||
" images; only picture_edit passes them to the image model."
|
||||
)
|
||||
return context
|
||||
|
||||
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
messages = []
|
||||
persona = self.config.get(self.channel, self.config["system"])
|
||||
for placeholder in self.DYNAMIC_PLACEHOLDERS:
|
||||
persona = persona.replace(placeholder, "")
|
||||
system = persona.rstrip() + "\n\n## Context\n" + "\n".join(self._context_lines(message))
|
||||
messages.append({"role": "system", "content": system})
|
||||
if limit is not None:
|
||||
while len(self.history) > limit:
|
||||
@@ -188,15 +205,15 @@ class AIResponder(AIResponderBase):
|
||||
messages.append({"role": "user", "content": content})
|
||||
return messages
|
||||
|
||||
async def draw(self, description: str) -> BytesIO:
|
||||
async def draw(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
if self.config.get("leonardo-token") is not None:
|
||||
return await self.draw_leonardo(description)
|
||||
return await self.draw_openai(description)
|
||||
return [await self.draw_leonardo(description)] # single image only, behind config
|
||||
return await self.draw_openai(description, count)
|
||||
|
||||
async def draw_leonardo(self, description: str) -> BytesIO:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def draw_openai(self, description: str) -> BytesIO:
|
||||
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
|
||||
@@ -221,6 +238,10 @@ class AIResponder(AIResponderBase):
|
||||
bool(response.get("picture_edit", False)),
|
||||
bool(response.get("hack", False)),
|
||||
)
|
||||
try:
|
||||
response_message.picture_count = max(1, min(int(response.get("picture_count") or 1), 4)) # IMG-02
|
||||
except (TypeError, ValueError):
|
||||
response_message.picture_count = 1
|
||||
if response_message.staff is not None and response_message.answer is not None:
|
||||
response_message.answer_needed = True
|
||||
if response_message.channel is None:
|
||||
@@ -250,9 +271,6 @@ class AIResponder(AIResponderBase):
|
||||
"""Cheap reply/factual/emoji pre-pass (BEH-01); None = fail open."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def _entry_channel(item: Dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
@@ -299,11 +317,10 @@ class AIResponder(AIResponderBase):
|
||||
await asyncio.to_thread(self.store.save_history, self.channel, list(self.history))
|
||||
|
||||
async def handle_picture(self, response: Dict) -> bool:
|
||||
# Prompt goes to the image API verbatim — no translate step (IMG-05)
|
||||
if not isinstance(response.get("picture"), (type(None), str)):
|
||||
logging.warning(f"picture key is wrong in response: {pp(response)}")
|
||||
return False
|
||||
if response.get("picture") is not None:
|
||||
response["picture"] = await self.translate(response["picture"])
|
||||
return True
|
||||
|
||||
def _parse_answer(self, answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -197,6 +197,8 @@ class FjerkroaBot(commands.Bot):
|
||||
removed += self.airesponder.store.delete_history_of_user(user)
|
||||
# facts + observations + episode traces (MEM-09)
|
||||
removed += self.airesponder.store.purge_user_memory(user)
|
||||
if self.airesponder.image_cache is not None:
|
||||
removed += self.airesponder.image_cache.purge_user(user) # IMG-14
|
||||
logging.info(f"forgetme: removed {removed} entries for {user}")
|
||||
await message.channel.send(
|
||||
f"Removed your messages, facts and memory traces ({removed} entries).",
|
||||
@@ -337,6 +339,8 @@ class FjerkroaBot(commands.Bot):
|
||||
|
||||
async def on_message_delete(self, message):
|
||||
airesponder = self.get_ai_responder(self.get_channel_name(message.channel))
|
||||
if airesponder.image_cache is not None:
|
||||
airesponder.image_cache.purge_message(str(message.id)) # IMG-14
|
||||
await airesponder.observe_event(message.author.name, "delete", f"deleted: {message.content}")
|
||||
|
||||
def on_config_file_modified(self, event):
|
||||
@@ -397,27 +401,46 @@ class FjerkroaBot(commands.Bot):
|
||||
def get_ai_responder(self, channel_name):
|
||||
return self.aichannels[channel_name] if channel_name in self.aichannels else self.airesponder
|
||||
|
||||
async def _ingest_attachments(self, message, channel_name: str, airesponder) -> list:
|
||||
"""Cache-first attachment handling; CDN URLs never travel further (IMG-10/11)."""
|
||||
urls = []
|
||||
for attachment in message.attachments:
|
||||
if airesponder.image_cache is None:
|
||||
urls.append(attachment.url)
|
||||
continue
|
||||
sha = await airesponder.image_cache.ingest_url(attachment.url, channel_name, message.author.name, str(message.id))
|
||||
if sha is not None:
|
||||
recent = airesponder.image_cache.recent(channel_name, 8)
|
||||
ext = next((row["ext"] for row in recent if row["sha256"] == sha), "png")
|
||||
data_url = airesponder.image_cache.data_url(sha, ext)
|
||||
if data_url:
|
||||
urls.append(data_url)
|
||||
return urls
|
||||
|
||||
async def handle_message_through_responder(self, message):
|
||||
"""Handle a message through the AI responder"""
|
||||
message_content = str(message.content).strip()
|
||||
if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str):
|
||||
reference_content = str(message.reference.resolved.content).replace("\n", "> \n")
|
||||
message_content = f"> {reference_content}\n\n{message_content}"
|
||||
channel_name = self.get_channel_name(message.channel)
|
||||
airesponder = self.get_ai_responder(channel_name)
|
||||
attachment_urls = []
|
||||
if message.attachments:
|
||||
attachment_urls = await self._ingest_attachments(message, channel_name, airesponder)
|
||||
if len(message_content) < 1:
|
||||
# image-only posts: cached + observed, no reply (IMG-17)
|
||||
if attachment_urls:
|
||||
await airesponder.observe_event(message.author.name, "image", f"posted {len(attachment_urls)} image(s)")
|
||||
return
|
||||
message_content = self._resolve_mentions(message_content)
|
||||
channel_name = self.get_channel_name(message.channel)
|
||||
msg = AIMessage(
|
||||
message.author.name, message_content, channel_name, self.user in message.mentions or isinstance(message.channel, DMChannel)
|
||||
)
|
||||
if message.attachments:
|
||||
for attachment in message.attachments:
|
||||
if not msg.urls:
|
||||
msg.urls = []
|
||||
msg.urls.append(attachment.url)
|
||||
if attachment_urls:
|
||||
msg.urls = attachment_urls
|
||||
|
||||
# Reply/ignore classifier gate — direct messages bypass (BEH-01/02/03/07)
|
||||
airesponder = self.get_ai_responder(channel_name)
|
||||
handled, factual = await self._classifier_gate(message, msg, airesponder, channel_name)
|
||||
if handled:
|
||||
return
|
||||
@@ -462,7 +485,20 @@ class FjerkroaBot(commands.Bot):
|
||||
"""Send the answer paced, split and with images on the last part (BEH-04/05/06)"""
|
||||
files = None
|
||||
if response.picture is not None:
|
||||
files = [discord.File(fp=await airesponder.draw(response.picture), filename="image.png")]
|
||||
count = getattr(response, "picture_count", 1)
|
||||
channel_name = self.get_channel_name(answer_channel)
|
||||
buffers = None
|
||||
if getattr(response, "picture_edit", False) and airesponder.image_cache is not None:
|
||||
sources = airesponder.image_cache.recent_paths(channel_name, 4)
|
||||
if sources:
|
||||
buffers = await airesponder.edit_openai(response.picture, sources, count)
|
||||
if buffers is None:
|
||||
# empty cache or no edit request: plain generation (IMG-13 fallback)
|
||||
buffers = await airesponder.draw(response.picture, count)
|
||||
if airesponder.image_cache is not None:
|
||||
for buffer in buffers:
|
||||
airesponder.image_cache.ingest_bytes(buffer.getvalue(), channel_name, "assistant", None) # IMG-15
|
||||
files = [discord.File(fp=buffer, filename=f"image-{index}.png") for index, buffer in enumerate(buffers)]
|
||||
parts = split_answer(response.answer, int(self.config.get("split-threshold", 1200)), int(self.config.get("split-max-parts", 3)))
|
||||
pace = float(self.config.get("typing-chars-per-second", 0) or 0)
|
||||
max_delay = float(self.config.get("typing-max-seconds", 8))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Content-hash image cache (SPEC-004, FDB-010).
|
||||
|
||||
Attachments are downloaded once, sniffed, stored under their sha256
|
||||
and served to vision as data: URLs — Discord's expiring CDN links
|
||||
never travel further (IMG-10/11). LRU + TTL keep the cache bounded
|
||||
(IMG-12); deletions and !forgetme propagate here (IMG-14).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .persistence import PersistentStore
|
||||
|
||||
DEFAULT_CACHE_MB = 500
|
||||
DEFAULT_TTL_DAYS = 90
|
||||
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
||||
DOWNLOAD_TIMEOUT_S = 20
|
||||
|
||||
MAGIC = [
|
||||
(b"\x89PNG", "png"),
|
||||
(b"\xff\xd8\xff", "jpg"),
|
||||
(b"GIF87a", "gif"),
|
||||
(b"GIF89a", "gif"),
|
||||
]
|
||||
|
||||
|
||||
def sniff_ext(data: bytes) -> Optional[str]:
|
||||
"""Extension from magic bytes only — names and headers lie (IMG-10)."""
|
||||
for magic, ext in MAGIC:
|
||||
if data.startswith(magic):
|
||||
return ext
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "webp"
|
||||
return None
|
||||
|
||||
|
||||
class ImageCache:
|
||||
def __init__(self, store: PersistentStore, root: Path, config_getter: Callable[[], Dict[str, Any]]) -> None:
|
||||
self.store = store
|
||||
self.root = Path(root)
|
||||
self._config = config_getter
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, sha256: str, ext: str) -> Path:
|
||||
return self.root / f"{sha256}.{ext}"
|
||||
|
||||
def ingest_bytes(self, data: bytes, channel: str, user: str, message_id: Optional[str]) -> Optional[str]:
|
||||
ext = sniff_ext(data)
|
||||
if ext is None:
|
||||
logging.warning(f"image cache: rejected non-image bytes from {user} (IMG-10)")
|
||||
return None
|
||||
if len(data) > int(self._config().get("image-max-bytes", DEFAULT_MAX_BYTES)):
|
||||
logging.warning(f"image cache: rejected oversized upload from {user} ({len(data)} bytes)")
|
||||
return None
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
path = self._path(sha256, ext)
|
||||
if not path.exists():
|
||||
path.write_bytes(data)
|
||||
self.store.image_add(sha256, channel, user, message_id, ext, len(data))
|
||||
self.evict()
|
||||
return sha256
|
||||
|
||||
async def ingest_url(self, url: str, channel: str, user: str, message_id: Optional[str]) -> Optional[str]:
|
||||
try:
|
||||
data = await self._download(url)
|
||||
except Exception as err:
|
||||
logging.warning(f"image cache: download failed for {user}: {repr(err)}")
|
||||
return None
|
||||
return self.ingest_bytes(data, channel, user, message_id)
|
||||
|
||||
async def _download(self, url: str) -> bytes:
|
||||
limit = int(self._config().get("image-max-bytes", DEFAULT_MAX_BYTES))
|
||||
timeout = aiohttp.ClientTimeout(total=DOWNLOAD_TIMEOUT_S)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.content.read(limit + 1)
|
||||
|
||||
def data_url(self, sha256: str, ext: str) -> Optional[str]:
|
||||
path = self._path(sha256, ext)
|
||||
if not path.exists():
|
||||
return None
|
||||
mime = "jpeg" if ext == "jpg" else ext
|
||||
return f"data:image/{mime};base64," + base64.b64encode(path.read_bytes()).decode()
|
||||
|
||||
def recent(self, channel: str, count: int) -> List[Dict[str, Any]]:
|
||||
return self.store.images_recent(channel, count)
|
||||
|
||||
def recent_paths(self, channel: str, count: int) -> List[Path]:
|
||||
paths = [self._path(row["sha256"], row["ext"]) for row in self.recent(channel, count)]
|
||||
return [path for path in paths if path.exists()]
|
||||
|
||||
def _remove(self, sha256: str, ext: str) -> None:
|
||||
self._path(sha256, ext).unlink(missing_ok=True)
|
||||
self.store.images_delete(sha256)
|
||||
|
||||
def evict(self) -> None:
|
||||
"""TTL first, then LRU down to the byte cap (IMG-12)."""
|
||||
config = self._config()
|
||||
for row in self.store.images_expired(int(config.get("image-cache-ttl-days", DEFAULT_TTL_DAYS))):
|
||||
self._remove(row["sha256"], row["ext"])
|
||||
cap = int(config.get("image-cache-mb", DEFAULT_CACHE_MB)) * 1024 * 1024
|
||||
while self.store.images_total_bytes() > cap:
|
||||
victims = self.store.images_oldest(1)
|
||||
if not victims:
|
||||
break
|
||||
self._remove(victims[0]["sha256"], victims[0]["ext"])
|
||||
|
||||
def purge_user(self, user: str) -> int:
|
||||
rows = self.store.images_for_user(user)
|
||||
for row in rows:
|
||||
self._remove(row["sha256"], row["ext"])
|
||||
return len(rows)
|
||||
|
||||
def purge_message(self, message_id: str) -> int:
|
||||
rows = self.store.images_for_message(message_id)
|
||||
for row in rows:
|
||||
self._remove(row["sha256"], row["ext"])
|
||||
return len(rows)
|
||||
@@ -1,14 +1,14 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
import openai
|
||||
|
||||
from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
|
||||
from .ai_responder import AIResponder, exponential_backoff, sanitize_external_text
|
||||
from .igdblib import IGDBQuery
|
||||
from .leonardo_draw import LeonardoAIDrawMixIn
|
||||
from .quota import QuotaLedger
|
||||
@@ -24,10 +24,11 @@ ENVELOPE_SCHEMA = {
|
||||
"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_count": {"type": "integer", "description": "How many images to generate (1-4), 1 unless more were asked for."},
|
||||
"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"],
|
||||
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_count", "picture_edit", "hack"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
ENVELOPE_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
|
||||
@@ -92,10 +93,11 @@ async def openai_chat(client, *args, **kwargs):
|
||||
|
||||
|
||||
async def openai_image(client, *args, **kwargs):
|
||||
response = await client.images.generate(*args, **kwargs)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(response.data[0].url) as image:
|
||||
return BytesIO(await image.read())
|
||||
return await client.images.generate(*args, **kwargs)
|
||||
|
||||
|
||||
async def openai_image_edit(client, *args, **kwargs):
|
||||
return await client.images.edit(*args, **kwargs)
|
||||
|
||||
|
||||
class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
@@ -126,15 +128,26 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
else:
|
||||
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
|
||||
|
||||
async def draw_openai(self, description: str) -> BytesIO:
|
||||
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
if not self.ledger.budget_ok():
|
||||
raise RuntimeError("daily budget exhausted - refusing image call")
|
||||
model = self.config.get("image-model", "gpt-image-2")
|
||||
kwargs: Dict[str, Any] = {"model": model, "prompt": description, "size": self.config.get("image-size", "1024x1024")}
|
||||
if "image-quality" in self.config:
|
||||
kwargs["quality"] = self.config["image-quality"]
|
||||
if model.startswith("gpt-image"):
|
||||
kwargs["n"] = max(1, min(int(count), 4))
|
||||
else:
|
||||
# legacy models: single image, base64 must be requested (IMG-04)
|
||||
kwargs["n"] = 1
|
||||
kwargs["response_format"] = "b64_json"
|
||||
for _ in range(3):
|
||||
try:
|
||||
response = await openai_image(self.client, prompt=description, n=1, size="1024x1024", model="dall-e-3")
|
||||
self.ledger.add_images(1)
|
||||
logging.info(f"Drawed a picture with DALL-E on this description: {repr(description)}")
|
||||
return response
|
||||
response = await openai_image(self.client, **kwargs)
|
||||
buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data]
|
||||
self.ledger.add_images(len(buffers))
|
||||
logging.info(f"generated {len(buffers)} image(s) on {model} for: {repr(description)}")
|
||||
return buffers
|
||||
except Exception as err:
|
||||
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
|
||||
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
|
||||
@@ -209,6 +222,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
if igdb_functions and isinstance(igdb_functions, list):
|
||||
chat_kwargs["tools"] = [{"type": "function", "function": func} for func in igdb_functions]
|
||||
chat_kwargs["tool_choice"] = "auto"
|
||||
# gpt-5.6 rejects tools + reasoning on chat/completions (ENV-21)
|
||||
chat_kwargs["reasoning_effort"] = self.config.get("reasoning-effort", "none")
|
||||
logging.info(f"🎮 IGDB functions available to AI: {[f['name'] for f in igdb_functions]}")
|
||||
logging.debug(f" Full chat_kwargs with tools: {list(chat_kwargs.keys())}")
|
||||
except (TypeError, AttributeError) as e:
|
||||
@@ -343,26 +358,28 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.debug(f"Full traceback: {traceback.format_exc()}")
|
||||
return None, limit
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
if "fix-model" not in self.config:
|
||||
return text
|
||||
message = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"You are an professional translator to {language} language,"
|
||||
f" you translate everything you get directly to {language}"
|
||||
f" if it is not already in {language}, otherwise you just copy it.",
|
||||
},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
async def edit_openai(self, description: str, paths: List[Any], count: int = 1) -> List[BytesIO]:
|
||||
"""Edit/remix from cached inputs, ≤4 files (IMG-13)."""
|
||||
if not self.ledger.budget_ok():
|
||||
raise RuntimeError("daily budget exhausted - refusing image edit")
|
||||
model = self.config.get("image-model", "gpt-image-2")
|
||||
handles = [open(path, "rb") for path in paths[:4]]
|
||||
try:
|
||||
result = await openai_chat(self.client, model=self.config["fix-model"], messages=message)
|
||||
response = result.choices[0].message.content
|
||||
logging.info(f"got this translated message:\n{pp(response)}")
|
||||
return response
|
||||
except Exception as err:
|
||||
logging.warning(f"failed to translate the text: {repr(err)}")
|
||||
return text
|
||||
response = await openai_image_edit(
|
||||
self.client,
|
||||
model=model,
|
||||
image=handles if len(handles) > 1 else handles[0],
|
||||
prompt=description,
|
||||
n=max(1, min(int(count), 4)),
|
||||
size=self.config.get("image-size", "1024x1024"),
|
||||
)
|
||||
finally:
|
||||
for handle in handles:
|
||||
handle.close()
|
||||
buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data]
|
||||
self.ledger.add_images(len(buffers))
|
||||
logging.info(f"edited {len(buffers)} image(s) on {model} from {len(handles)} input(s)")
|
||||
return buffers
|
||||
|
||||
async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03)."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
|
||||
class PersistentStore:
|
||||
@@ -60,6 +60,12 @@ class PersistentStore:
|
||||
)
|
||||
# Legacy single-string memories carry over as one episode each (MEM-08)
|
||||
conn.execute("INSERT INTO episodes (channel, summary) SELECT channel, content FROM memory")
|
||||
if version < 4:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS images (id INTEGER PRIMARY KEY, sha256 TEXT UNIQUE NOT NULL, channel TEXT NOT NULL,"
|
||||
" user TEXT NOT NULL, message_id TEXT, ext TEXT NOT NULL, bytes INTEGER NOT NULL,"
|
||||
" created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||
)
|
||||
if version < SCHEMA_VERSION:
|
||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
os.chmod(self.db_path, 0o600) # conversation data (PER-04)
|
||||
@@ -189,6 +195,53 @@ class PersistentStore:
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
# --- image cache index (SPEC-004, FDB-010) ---
|
||||
|
||||
def image_add(self, sha256: str, channel: str, user: str, message_id: Optional[str], ext: str, nbytes: int) -> None:
|
||||
with closing(self._connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO images (sha256, channel, user, message_id, ext, bytes) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sha256, channel, user, message_id, ext, nbytes),
|
||||
)
|
||||
|
||||
def images_recent(self, channel: str, count: int) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT sha256, user, ext FROM images WHERE channel = ? ORDER BY id DESC LIMIT ?", (channel, count)
|
||||
).fetchall()
|
||||
return [{"sha256": row[0], "user": row[1], "ext": row[2]} for row in rows]
|
||||
|
||||
def images_total_bytes(self) -> int:
|
||||
with closing(self._connect()) as conn:
|
||||
row = conn.execute("SELECT COALESCE(SUM(bytes), 0) FROM images").fetchone()
|
||||
return int(row[0])
|
||||
|
||||
def images_oldest(self, count: int) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute("SELECT sha256, ext, bytes FROM images ORDER BY id LIMIT ?", (count,)).fetchall()
|
||||
return [{"sha256": row[0], "ext": row[1], "bytes": row[2]} for row in rows]
|
||||
|
||||
def images_expired(self, ttl_days: int) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT sha256, ext FROM images WHERE created_at < datetime('now', ?)", (f"-{int(ttl_days)} days",)
|
||||
).fetchall()
|
||||
return [{"sha256": row[0], "ext": row[1]} for row in rows]
|
||||
|
||||
def images_delete(self, sha256: str) -> None:
|
||||
with closing(self._connect()) as conn, conn:
|
||||
conn.execute("DELETE FROM images WHERE sha256 = ?", (sha256,))
|
||||
|
||||
def images_for_user(self, user: str) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute("SELECT sha256, ext FROM images WHERE user = ?", (user,)).fetchall()
|
||||
return [{"sha256": row[0], "ext": row[1]} for row in rows]
|
||||
|
||||
def images_for_message(self, message_id: str) -> List[Dict[str, Any]]:
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute("SELECT sha256, ext FROM images WHERE message_id = ?", (message_id,)).fetchall()
|
||||
return [{"sha256": row[0], "ext": row[1]} for row in rows]
|
||||
|
||||
def purge_user_memory(self, user: str) -> int:
|
||||
"""Facts, observations and episode traces of one user (MEM-09)."""
|
||||
removed = 0
|
||||
|
||||
@@ -8,7 +8,7 @@ with date + result.
|
||||
| --- | --- | --- |
|
||||
| DEP-01 | 2026-07-13 | Verified with the v3.0.0 ggg deploy: tag-only refusal + untracked config/state survived. fjerkroa redeploy after the service window (tree already identical to 3d22894). |
|
||||
| DEP-02 | 2026-07-13 | Service map exercised: luma restart via script (v3.0.0); kroa mapping code-reviewed, exercised on its next deploy. |
|
||||
| DEP-03 | 2026-07-13 | ggg had no pre-existing bot.db (pickle era) — nothing to back up; backup branch code-reviewed, exercised on the next deploy of either host. |
|
||||
| DEP-03 | 2026-07-13 | Exercised with the v3.1.0 ggg deploy: bot.db.pre-v3.1.0 confirmed on the host. (v3.0.0 note: no pre-existing db in the pickle era.) |
|
||||
| DEP-04 | 2026-07-13 | Smoke gate exercised on ggg: RUNNING + fresh login line. |
|
||||
| DEP-05 | 2026-07-13 | Live-verified: kroa deploy attempt ~15h Oslo refused without DEPLOY_FORCE=1. |
|
||||
| DEP-06 | 2026-07-13 | Rollback documented (older tag + db backup restore); live drill pending — next release. |
|
||||
|
||||
@@ -75,6 +75,14 @@ in a context suffix, not inline — see ENV-20; legacy `{date}`,
|
||||
`{time}`, `{news}`, `{memory}` placeholders in operator templates are
|
||||
stripped.
|
||||
|
||||
### ENV-21 — Tool calls disable reasoning effort (coverage: test)
|
||||
|
||||
When function tools are attached to a chat call, the call carries
|
||||
`reasoning_effort` (config `reasoning-effort`, default `"none"`) —
|
||||
gpt-5.6 models reject tools + reasoning on chat/completions with a
|
||||
400 otherwise (found live on ggg 2026-07-13: IGDB tools made Luma
|
||||
mute after the Luna cutover). Tool-less calls stay untouched.
|
||||
|
||||
### ENV-20 — Persona prefix is byte-stable (coverage: test)
|
||||
|
||||
`message()` renders the system message as: static persona text
|
||||
@@ -140,6 +148,7 @@ ENV-06.
|
||||
|
||||
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,
|
||||
`channel`, `staff`, `picture`, `picture_count` (since FDB-009,
|
||||
IMG-02), `picture_edit`, `hack` — all required,
|
||||
`additionalProperties: false`, nullable where the protocol allows
|
||||
null. Tool-followup calls carry the same format.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPEC-004 — Image generation
|
||||
|
||||
Generation on `image-model` (default `gpt-image-2`), base64 end to
|
||||
end — no URL downloads, no expiring CDN links in the generation path.
|
||||
Optional knobs: `image-size` (default 1024x1024), `image-quality`
|
||||
(passed through only when set). The Leonardo path stays behind
|
||||
`leonardo-token` until parity is confirmed, then dies. The input
|
||||
pipeline (attachment cache, vision, edit/remix) is FDB-010 / IMG-10+.
|
||||
|
||||
### IMG-01 — Images arrive as base64 buffers (coverage: test)
|
||||
|
||||
`draw_openai(description, count)` requests `count` images and returns
|
||||
a list of decoded image buffers straight from the API response; every
|
||||
generated image is metered in the ledger (SAF-05).
|
||||
|
||||
### IMG-02 — The envelope carries picture_count (coverage: test)
|
||||
|
||||
The envelope gains `picture_count` (integer). `post_process` clamps
|
||||
it to 1..4 and defaults to 1 when absent (legacy history entries,
|
||||
old-model output). ENV-19's field list is revised accordingly.
|
||||
|
||||
### IMG-03 — Multiple images, one message (coverage: test)
|
||||
|
||||
`picture_count` images are attached as multiple files to a single
|
||||
Discord send (the last part when the answer is split, per BEH-06).
|
||||
|
||||
### IMG-04 — Legacy image models degrade safely (coverage: test)
|
||||
|
||||
When `image-model` is not a `gpt-image-*` model (e.g. `dall-e-3`),
|
||||
the count is clamped to 1 and `response_format="b64_json"` is
|
||||
requested explicitly (gpt-image models return base64 natively and
|
||||
reject the parameter).
|
||||
|
||||
### IMG-05 — Picture prompts go to the API untouched (coverage: test)
|
||||
|
||||
The translate-before-draw step is deleted: the model's picture prompt
|
||||
reaches the image API verbatim (current image models handle
|
||||
Norwegian/German natively). The `translate()` method and its
|
||||
`fix-model` dependency are gone (closes D-009).
|
||||
|
||||
## Input pipeline (FDB-010)
|
||||
|
||||
Attachments live in a content-hash cache
|
||||
(`<history-directory>/images/<sha256>.<ext>`, index in the store,
|
||||
schema v4). Active only with a store; without one the legacy CDN-URL
|
||||
path remains.
|
||||
|
||||
### IMG-10 — Attachments are ingested at message time (coverage: test)
|
||||
|
||||
Every image attachment is downloaded immediately (timeout, size cap
|
||||
`image-max-bytes` default 8 MB) and stored under its content hash.
|
||||
Only sniffed png/jpeg/gif/webp bytes are accepted — extension and
|
||||
declared MIME are ignored (attacker-controlled). Rejected content is
|
||||
dropped and logged (D11 root fix + cache-abuse hardening).
|
||||
|
||||
### IMG-11 — Vision reads from the cache, never CDN URLs (coverage: test)
|
||||
|
||||
Vision parts are `data:` URLs built from cached bytes. Discord's
|
||||
signed, expiring CDN URLs never reach the model or the history.
|
||||
|
||||
### IMG-12 — The cache is capped and aged (coverage: test)
|
||||
|
||||
`image-cache-mb` (default 500) LRU-evicts oldest-first;
|
||||
`image-cache-ttl-days` (default 90) ages entries out. Eviction always
|
||||
removes file and index row together.
|
||||
|
||||
### IMG-13 — picture_edit edits the newest channel images (coverage: test)
|
||||
|
||||
`picture_edit=true` calls `images.edit` with up to the 4 newest
|
||||
cached images of the answer channel as inputs (API max is 16; 4 keeps
|
||||
prompts sane). An empty cache falls back to plain generation — the
|
||||
flag alone must never fail a reply.
|
||||
|
||||
### IMG-14 — Deletion propagates to the cache (coverage: test)
|
||||
|
||||
Deleting a Discord message purges its cached images; `!forgetme`
|
||||
purges all of the user's images — files and rows (extends
|
||||
SAF-08/MEM-09).
|
||||
|
||||
### IMG-15 — Generated images join the cache (coverage: test)
|
||||
|
||||
Bot-generated images are ingested like uploads (user `assistant`), so
|
||||
"make a variant of that" remix chains work on the bot's own output.
|
||||
|
||||
### IMG-17 — Image-only messages are cached (coverage: test)
|
||||
|
||||
A message consisting only of attachments (no text) is ingested into
|
||||
the cache and recorded as an observation, even though no reply is
|
||||
produced — the image must be available for later `picture_edit` and
|
||||
vision follow-ups. (Previously the empty-text early-return dropped
|
||||
such posts entirely.)
|
||||
|
||||
### IMG-16 — The prompt announces editable images (coverage: test)
|
||||
|
||||
When the answer channel has cached images, the context suffix states
|
||||
how many and that `picture_edit=true` edits the newest — the model
|
||||
cannot use a capability it does not know about.
|
||||
@@ -102,33 +102,6 @@ You always try to say something positive about the current day and the Fjærkroa
|
||||
# Skip this test due to Mock iteration issues - functionality works in practice
|
||||
self.skipTest("Mock iteration issue - test works in real usage")
|
||||
|
||||
async def test_translate1(self) -> None:
|
||||
self.bot.airesponder.config["fix-model"] = "gpt-4o-mini"
|
||||
|
||||
# Mock translation responses
|
||||
def translation_side_effect(*args, **kwargs):
|
||||
mock_resp = Mock()
|
||||
mock_resp.choices = [Mock()]
|
||||
mock_resp.choices[0].message = Mock()
|
||||
|
||||
# Check the input text to return appropriate translation
|
||||
user_content = kwargs["messages"][1]["content"]
|
||||
if user_content == "Das ist ein komischer Text.":
|
||||
mock_resp.choices[0].message.content = "This is a strange text."
|
||||
elif user_content == "This is a strange text.":
|
||||
mock_resp.choices[0].message.content = "Dies ist ein seltsamer Text."
|
||||
else:
|
||||
mock_resp.choices[0].message.content = user_content
|
||||
|
||||
return mock_resp
|
||||
|
||||
self.mock_openai_chat.side_effect = translation_side_effect
|
||||
|
||||
response = await self.bot.airesponder.translate("Das ist ein komischer Text.")
|
||||
self.assertEqual(response, "This is a strange text.")
|
||||
response = await self.bot.airesponder.translate("This is a strange text.", language="german")
|
||||
self.assertEqual(response, "Dies ist ein seltsamer Text.")
|
||||
|
||||
async def test_fix1(self) -> None:
|
||||
# Skip this test due to Mock iteration issues - functionality works in practice
|
||||
self.skipTest("Mock iteration issue - test works in real usage")
|
||||
|
||||
@@ -49,9 +49,6 @@ class FakeModelResponder(AIResponder):
|
||||
async def classify(self, message, history_tail):
|
||||
return getattr(self, "scripted_classification", None)
|
||||
|
||||
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):
|
||||
|
||||
@@ -30,16 +30,6 @@ class TestOpenAIResponderSimple(unittest.IsolatedAsyncioTestCase):
|
||||
"""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."""
|
||||
config_no_fix = {"openai-key": "test", "model": "gpt-4"}
|
||||
responder = OpenAIResponder(config_no_fix)
|
||||
|
||||
original_text = "Hello world"
|
||||
result = await responder.translate(original_text)
|
||||
|
||||
self.assertEqual(result, original_text)
|
||||
|
||||
async def test_consolidate_no_memory_model(self):
|
||||
"""MEM-10: without memory-model, consolidation is a no-op returning None."""
|
||||
config_no_memory = {"openai-key": "test", "model": "gpt-4"}
|
||||
|
||||
@@ -121,7 +121,7 @@ class TestSplitSends(OpsBase):
|
||||
self.bot.config["split-threshold"] = 50
|
||||
answer = "Første del.\n\nAndre del som også er ganske lang her."
|
||||
response = AIResponse(answer, True, "chat", None, "a cat", False, False)
|
||||
self.bot.airesponder.draw = AsyncMock(return_value=__import__("io").BytesIO(b"png"))
|
||||
self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(b"png")])
|
||||
channel = MagicMock(spec=TextChannel)
|
||||
channel.send = AsyncMock()
|
||||
await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit coverage for SPEC-004 image generation (IMG-01..05)."""
|
||||
|
||||
import base64
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
from discord import TextChannel
|
||||
|
||||
from fjerkroa_bot.ai_responder import AIMessage, AIResponder, AIResponse
|
||||
from fjerkroa_bot.openai_responder import OpenAIResponder
|
||||
|
||||
from .test_bdd_envelope import FakeModelResponder, envelope
|
||||
from .test_spec_ops import OpsBase
|
||||
|
||||
RESPONDER_CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}
|
||||
|
||||
|
||||
def image_api_result(count):
|
||||
return Mock(data=[Mock(b64_json=base64.b64encode(f"png{i}".encode()).decode()) for i in range(count)])
|
||||
|
||||
|
||||
class TestBase64Generation(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_draw_returns_decoded_buffers_and_meters(self):
|
||||
"""IMG-01: count images decoded from b64_json, each metered in the ledger."""
|
||||
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
|
||||
with patch("fjerkroa_bot.openai_responder.openai_image", new_callable=AsyncMock) as image_mock:
|
||||
image_mock.return_value = image_api_result(2)
|
||||
buffers = await responder.draw_openai("en katt på brygga", 2)
|
||||
self.assertEqual([buf.read() for buf in buffers], [b"png0", b"png1"])
|
||||
self.assertEqual(responder.ledger.images_today(), 2)
|
||||
self.assertEqual(image_mock.await_args.kwargs["n"], 2)
|
||||
self.assertEqual(image_mock.await_args.kwargs["model"], "gpt-image-2")
|
||||
self.assertNotIn("response_format", image_mock.await_args.kwargs)
|
||||
|
||||
async def test_legacy_model_clamped_single_b64(self):
|
||||
"""IMG-04: dall-e-3 -> n=1 and explicit response_format=b64_json."""
|
||||
config = dict(RESPONDER_CONFIG, **{"image-model": "dall-e-3"})
|
||||
responder = OpenAIResponder(config, "chat")
|
||||
with patch("fjerkroa_bot.openai_responder.openai_image", new_callable=AsyncMock) as image_mock:
|
||||
image_mock.return_value = image_api_result(1)
|
||||
buffers = await responder.draw_openai("a cat", 3)
|
||||
self.assertEqual(len(buffers), 1)
|
||||
self.assertEqual(image_mock.await_args.kwargs["n"], 1)
|
||||
self.assertEqual(image_mock.await_args.kwargs["response_format"], "b64_json")
|
||||
|
||||
|
||||
class TestPictureCountEnvelope(unittest.IsolatedAsyncioTestCase):
|
||||
async def clamp(self, raw):
|
||||
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
|
||||
payload = {"answer": "ok", "answer_needed": True, "channel": "chat", "picture": "katt"}
|
||||
if raw is not None:
|
||||
payload["picture_count"] = raw
|
||||
return await responder.post_process(AIMessage("alice", "tegn", "chat"), payload)
|
||||
|
||||
async def test_clamped_and_defaulted(self):
|
||||
"""IMG-02: picture_count clamps to 1..4, defaults to 1 when absent."""
|
||||
self.assertEqual((await self.clamp(3)).picture_count, 3)
|
||||
self.assertEqual((await self.clamp(9)).picture_count, 4)
|
||||
self.assertEqual((await self.clamp(0)).picture_count, 1)
|
||||
self.assertEqual((await self.clamp(None)).picture_count, 1)
|
||||
|
||||
|
||||
class TestMultiImageSend(OpsBase):
|
||||
async def test_files_attached_to_single_send(self):
|
||||
"""IMG-03: picture_count images ride as multiple files on one send."""
|
||||
response = AIResponse("her er kattene", True, "chat", None, "to katter", False, False)
|
||||
response.picture_count = 2
|
||||
import io
|
||||
|
||||
self.bot.airesponder.draw = AsyncMock(return_value=[io.BytesIO(b"a"), io.BytesIO(b"b")])
|
||||
channel = MagicMock(spec=TextChannel)
|
||||
channel.send = AsyncMock()
|
||||
await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True)
|
||||
self.bot.airesponder.draw.assert_awaited_once_with("to katter", 2)
|
||||
files = channel.send.await_args.kwargs["files"]
|
||||
self.assertEqual(len(files), 2)
|
||||
|
||||
|
||||
class TestNoTranslateStep(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_translate_is_gone_prompt_untouched(self):
|
||||
"""IMG-05: no translate() anywhere; the picture prompt survives verbatim."""
|
||||
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
|
||||
self.assertFalse(hasattr(responder, "translate"))
|
||||
self.assertFalse(hasattr(AIResponder, "translate"))
|
||||
responder.scripted.append(envelope(answer="ok", answer_needed=True, picture="en rød katt på brygga"))
|
||||
result = await responder.send(AIMessage("alice", "tegn en katt", "chat"))
|
||||
self.assertEqual(result.picture, "en rød katt på brygga")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Unit coverage for SPEC-004 input pipeline (IMG-10..16)."""
|
||||
|
||||
import base64
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
|
||||
from fjerkroa_bot.images import ImageCache, sniff_ext
|
||||
from fjerkroa_bot.openai_responder import OpenAIResponder
|
||||
from fjerkroa_bot.persistence import PersistentStore
|
||||
|
||||
from .test_bdd_envelope import FakeModelResponder
|
||||
from .test_spec_ops import OpsBase
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 64
|
||||
|
||||
|
||||
def make_cache(tmp, config=None):
|
||||
store = PersistentStore(Path(tmp) / "bot.db")
|
||||
cache = ImageCache(store, Path(tmp) / "images", lambda: config or {})
|
||||
return store, cache
|
||||
|
||||
|
||||
class TestIngest(unittest.TestCase):
|
||||
def test_sniffed_types_only(self):
|
||||
"""IMG-10: magic bytes decide; garbage and foreign types are rejected."""
|
||||
self.assertEqual(sniff_ext(PNG), "png")
|
||||
self.assertEqual(sniff_ext(b"\xff\xd8\xff\xe0rest"), "jpg")
|
||||
self.assertIsNone(sniff_ext(b"MZ\x90\x00 definitely-an-exe"))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp)
|
||||
self.assertIsNone(cache.ingest_bytes(b"not an image", "chat", "alice", "1"))
|
||||
sha = cache.ingest_bytes(PNG, "chat", "alice", "1")
|
||||
self.assertIsNotNone(sha)
|
||||
self.assertTrue((Path(tmp) / "images" / f"{sha}.png").exists())
|
||||
self.assertEqual(store.images_recent("chat", 5)[0]["sha256"], sha)
|
||||
|
||||
def test_size_cap(self):
|
||||
"""IMG-10: oversized uploads are dropped."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_, cache = make_cache(tmp, {"image-max-bytes": 32})
|
||||
self.assertIsNone(cache.ingest_bytes(PNG, "chat", "alice", "1"))
|
||||
|
||||
|
||||
class TestVisionDataUrls(OpsBase):
|
||||
async def test_attachment_becomes_data_url(self):
|
||||
"""IMG-11: the model sees a data: URL, never the CDN link."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_, cache = make_cache(tmp)
|
||||
self.bot.airesponder.image_cache = cache
|
||||
self.bot.respond = AsyncMock()
|
||||
message = self.public_msg("look at this")
|
||||
attachment = Mock()
|
||||
attachment.url = "https://cdn.discordapp.com/attachments/1/2/cat.png?ex=deadbeef"
|
||||
message.attachments = [attachment]
|
||||
message.id = 42
|
||||
with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG):
|
||||
await self.bot.on_message(message)
|
||||
sent_msg = self.bot.respond.await_args.args[0]
|
||||
self.assertTrue(sent_msg.urls[0].startswith("data:image/png;base64,"))
|
||||
self.assertNotIn("cdn.discordapp.com", sent_msg.urls[0])
|
||||
|
||||
|
||||
class TestEviction(unittest.TestCase):
|
||||
def test_lru_cap(self):
|
||||
"""IMG-12: byte cap evicts oldest first, file + row together."""
|
||||
big = b"\x89PNG\r\n\x1a\n" + b"a" * (700 * 1024)
|
||||
big2 = b"\x89PNG\r\n\x1a\n" + b"b" * (700 * 1024)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp, {"image-cache-mb": 1})
|
||||
first = cache.ingest_bytes(big, "chat", "alice", "1")
|
||||
second = cache.ingest_bytes(big2, "chat", "alice", "2")
|
||||
shas = [row["sha256"] for row in store.images_recent("chat", 5)]
|
||||
self.assertNotIn(first, shas)
|
||||
self.assertIn(second, shas)
|
||||
self.assertFalse((Path(tmp) / "images" / f"{first}.png").exists())
|
||||
|
||||
def test_ttl(self):
|
||||
"""IMG-12: entries past image-cache-ttl-days age out."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp, {"image-cache-ttl-days": 30})
|
||||
sha = cache.ingest_bytes(PNG, "chat", "alice", "1")
|
||||
with sqlite3.connect(store.db_path) as conn:
|
||||
conn.execute("UPDATE images SET created_at = datetime('now', '-60 days') WHERE sha256 = ?", (sha,))
|
||||
cache.evict()
|
||||
self.assertEqual(store.images_recent("chat", 5), [])
|
||||
self.assertFalse((Path(tmp) / "images" / f"{sha}.png").exists())
|
||||
|
||||
|
||||
class TestEditPath(OpsBase):
|
||||
async def prepare(self, with_images):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
_, cache = make_cache(self.tmp.name)
|
||||
self.bot.airesponder.image_cache = cache
|
||||
if with_images:
|
||||
cache.ingest_bytes(PNG, "chat", "alice", "1")
|
||||
self.bot.airesponder.edit_openai = AsyncMock(return_value=[__import__("io").BytesIO(PNG)])
|
||||
self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)])
|
||||
response = AIResponse("her", True, "chat", None, "als wikinger", True, False)
|
||||
channel = MagicMock()
|
||||
channel.name = "chat"
|
||||
channel.send = AsyncMock()
|
||||
channel.typing = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()))
|
||||
await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True)
|
||||
|
||||
async def test_edit_uses_cached_sources(self):
|
||||
"""IMG-13: picture_edit + cached images -> images.edit path."""
|
||||
await self.prepare(with_images=True)
|
||||
self.bot.airesponder.edit_openai.assert_awaited_once()
|
||||
self.bot.airesponder.draw.assert_not_awaited()
|
||||
|
||||
async def test_empty_cache_falls_back_to_generate(self):
|
||||
"""IMG-13: empty cache -> plain generation, the flag never fails a reply."""
|
||||
await self.prepare(with_images=False)
|
||||
self.bot.airesponder.edit_openai.assert_not_awaited()
|
||||
self.bot.airesponder.draw.assert_awaited_once()
|
||||
|
||||
|
||||
class TestPurges(OpsBase):
|
||||
async def test_message_delete_and_forgetme_purge_images(self):
|
||||
"""IMG-14: message deletion and !forgetme remove files + rows."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp)
|
||||
self.bot.airesponder.image_cache = cache
|
||||
cache.ingest_bytes(PNG, "chat", "alice", "99")
|
||||
deleted = MagicMock()
|
||||
deleted.id = 99
|
||||
deleted.content = "pic"
|
||||
deleted.author.name = "alice"
|
||||
deleted.channel = MagicMock()
|
||||
await self.bot.on_message_delete(deleted)
|
||||
self.assertEqual(store.images_recent("chat", 5), [])
|
||||
cache.ingest_bytes(b"\x89PNG\r\n\x1a\n" + b"z" * 32, "chat", "alice", "100")
|
||||
message = self.public_msg("!forgetme")
|
||||
message.author.name = "alice"
|
||||
await self.bot.on_message(message)
|
||||
self.assertEqual(store.images_recent("chat", 5), [])
|
||||
|
||||
|
||||
class TestGeneratedImagesCached(OpsBase):
|
||||
async def test_bot_output_joins_cache(self):
|
||||
"""IMG-15: generated images are ingested as user 'assistant'."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp)
|
||||
self.bot.airesponder.image_cache = cache
|
||||
self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)])
|
||||
response = AIResponse("her", True, "chat", None, "en katt", False, False)
|
||||
channel = MagicMock()
|
||||
channel.name = "chat"
|
||||
channel.send = AsyncMock()
|
||||
await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True)
|
||||
rows = store.images_recent("chat", 5)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["user"], "assistant")
|
||||
|
||||
|
||||
class TestImageOnlyMessages(OpsBase):
|
||||
async def test_image_only_post_cached_no_reply(self):
|
||||
"""IMG-17: attachment without text -> cached + observed, no reply."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store, cache = make_cache(tmp)
|
||||
self.bot.airesponder.image_cache = cache
|
||||
self.bot.airesponder.observe_event = AsyncMock()
|
||||
self.bot.respond = AsyncMock()
|
||||
message = self.public_msg("")
|
||||
message.content = ""
|
||||
message.channel.name = "chat"
|
||||
attachment = Mock()
|
||||
attachment.url = "https://cdn.discordapp.com/attachments/1/2/silent.png"
|
||||
message.attachments = [attachment]
|
||||
message.id = 77
|
||||
with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG):
|
||||
await self.bot.on_message(message)
|
||||
self.assertEqual(len(store.images_recent("chat", 5)), 1)
|
||||
self.bot.airesponder.observe_event.assert_awaited_once()
|
||||
self.bot.respond.assert_not_awaited()
|
||||
|
||||
|
||||
class TestContextAnnouncesImages(unittest.IsolatedAsyncioTestCase):
|
||||
def test_suffix_mentions_picture_edit(self):
|
||||
"""IMG-16: cached channel images are announced in the context suffix."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = {"system": "s", "history-limit": 5, "history-directory": tmp}
|
||||
responder = FakeModelResponder(config, "chat")
|
||||
responder.image_cache.ingest_bytes(PNG, "chat", "alice", "1")
|
||||
system = responder.message(AIMessage("alice", "hei", "chat"))[0]["content"]
|
||||
self.assertIn("picture_edit", system)
|
||||
self.assertIn("recent images in this channel: 1", system)
|
||||
|
||||
|
||||
class TestEditOpenai(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_edit_call_shape_and_metering(self):
|
||||
"""IMG-13: images.edit gets the file handles, n clamped, ledger counts."""
|
||||
responder = OpenAIResponder({"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}, "chat")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
paths = []
|
||||
for index in range(2):
|
||||
path = Path(tmp) / f"in{index}.png"
|
||||
path.write_bytes(PNG)
|
||||
paths.append(path)
|
||||
api_result = Mock(data=[Mock(b64_json=base64.b64encode(b"out").decode())])
|
||||
with patch("fjerkroa_bot.openai_responder.openai_image_edit", new_callable=AsyncMock) as edit_mock:
|
||||
edit_mock.return_value = api_result
|
||||
buffers = await responder.edit_openai("wikinger", paths, 9)
|
||||
self.assertEqual(buffers[0].read(), b"out")
|
||||
self.assertEqual(edit_mock.await_args.kwargs["n"], 4)
|
||||
self.assertEqual(len(edit_mock.await_args.kwargs["image"]), 2)
|
||||
self.assertEqual(responder.ledger.images_today(), 1)
|
||||
@@ -43,7 +43,7 @@ class TestEnvelopeSchema(unittest.IsolatedAsyncioTestCase):
|
||||
"""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"}
|
||||
expected = {"answer", "answer_needed", "channel", "staff", "picture", "picture_count", "picture_edit", "hack"}
|
||||
self.assertEqual(set(schema["properties"]), expected)
|
||||
self.assertEqual(set(schema["required"]), expected)
|
||||
self.assertFalse(schema["additionalProperties"])
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Unit coverage for ENV-21 (tools + reasoning_effort, found live on ggg)."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fjerkroa_bot.openai_responder import OpenAIResponder
|
||||
|
||||
from .test_bdd_envelope import envelope
|
||||
|
||||
|
||||
def ok_result():
|
||||
message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None, refusal=None)
|
||||
return Mock(choices=[Mock(message=message)], usage="usage")
|
||||
|
||||
|
||||
class TestToolsReasoningEffort(unittest.IsolatedAsyncioTestCase):
|
||||
async def chat_kwargs(self, with_tools):
|
||||
config = {"openai-token": "t", "model": "gpt-5.6-luna", "system": "s", "history-limit": 5, "enable-game-info": with_tools}
|
||||
responder = OpenAIResponder(config, "chat")
|
||||
if with_tools:
|
||||
responder.igdb = Mock()
|
||||
responder.igdb.get_openai_functions = Mock(return_value=[{"name": "search_games", "parameters": {}}])
|
||||
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
|
||||
chat_mock.return_value = ok_result()
|
||||
await responder.chat([{"role": "user", "content": "hi"}], 10)
|
||||
return chat_mock.await_args.kwargs
|
||||
|
||||
async def test_tools_carry_reasoning_effort_none(self):
|
||||
"""ENV-21: tools attached -> reasoning_effort 'none' rides along."""
|
||||
kwargs = await self.chat_kwargs(with_tools=True)
|
||||
self.assertIn("tools", kwargs)
|
||||
self.assertEqual(kwargs["reasoning_effort"], "none")
|
||||
|
||||
async def test_toolless_calls_untouched(self):
|
||||
"""ENV-21: without tools no reasoning_effort is sent."""
|
||||
kwargs = await self.chat_kwargs(with_tools=False)
|
||||
self.assertNotIn("tools", kwargs)
|
||||
self.assertNotIn("reasoning_effort", kwargs)
|
||||
Reference in New Issue
Block a user