Fix hanging test and establish comprehensive development environment

- Fix infinite retry loop in ai_responder.py that caused test_fix1 to hang
- Add missing picture_edit parameter to all AIResponse constructor calls
- Set up complete development toolchain with Black, isort, Bandit, and MyPy
- Create comprehensive Makefile for development workflows
- Add pre-commit hooks with formatting, linting, security, and type checking
- Update test mocking to provide contextual responses for different scenarios
- Configure all tools for 140 character line length and strict type checking
- Add DEVELOPMENT.md with setup instructions and workflow documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OK
2025-08-08 19:07:14 +02:00
parent fb39aef577
commit fbec05dfe9
16 changed files with 916 additions and 327 deletions
+87 -83
View File
@@ -1,21 +1,22 @@
import os
import json
import random
import multiline
import logging
import time
import re
import os
import pickle
from pathlib import Path
from io import BytesIO
from pprint import pformat
import random
import re
import time
from functools import lru_cache, wraps
from typing import Optional, List, Dict, Any, Tuple, Union
from io import BytesIO
from pathlib import Path
from pprint import pformat
from typing import Any, Dict, List, Optional, Tuple, Union
import multiline
def pp(*args, **kw):
if 'width' not in kw:
kw['width'] = 300
if "width" not in kw:
kw["width"] = 300
return pformat(*args, **kw)
@@ -45,7 +46,7 @@ def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts
"""
attempt = 0
while True:
sleep = min(max_delay, factor * base ** attempt)
sleep = min(max_delay, factor * base**attempt)
jitter_amount = jitter * sleep
sleep += random.uniform(-jitter_amount, jitter_amount)
yield sleep
@@ -59,7 +60,7 @@ def async_cache_to_file(filename):
cache = None
if cache_file.exists():
try:
with cache_file.open('rb') as fd:
with cache_file.open("rb") as fd:
cache = pickle.load(fd)
except Exception:
cache = {}
@@ -74,10 +75,12 @@ def async_cache_to_file(filename):
return cache[key]
result = await func(*args, **kwargs)
cache[key] = result
with cache_file.open('wb') as fd:
with cache_file.open("wb") as fd:
pickle.dump(cache, fd)
return result
return wrapper
return decorator
@@ -85,24 +88,24 @@ 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)))
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 [('{', '}'), ('[', ']')]:
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 "\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')
return parse_json(item1["content"]).get("channel") == parse_json(item2["content"]).get("channel")
class AIMessageBase(object):
@@ -121,64 +124,66 @@ class AIMessage(AIMessageBase):
self.channel = channel
self.direct = direct
self.historise_question = historise_question
self.vars = ['user', 'message', 'channel', 'direct']
self.vars = ["user", "message", "channel", "direct", "historise_question"]
class AIResponse(AIMessageBase):
def __init__(self,
answer: Optional[str],
answer_needed: bool,
channel: Optional[str],
staff: Optional[str],
picture: Optional[str],
hack: bool
) -> None:
def __init__(
self,
answer: Optional[str],
answer_needed: bool,
channel: Optional[str],
staff: Optional[str],
picture: Optional[str],
picture_edit: bool,
hack: bool,
) -> None:
self.answer = answer
self.answer_needed = answer_needed
self.channel = channel
self.staff = staff
self.picture = picture
self.picture_edit = picture_edit
self.hack = hack
self.vars = ['answer', 'answer_needed', 'channel', 'staff', 'picture', 'hack']
self.vars = ["answer", "answer_needed", "channel", "staff", "picture", "hack"]
class AIResponderBase(object):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__()
self.config = config
self.channel = channel if channel is not None else 'system'
self.channel = channel if channel is not None else "system"
class AIResponder(AIResponderBase):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.history: List[Dict[str, Any]] = []
self.memory: str = 'I am an assistant.'
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
if 'history-directory' in self.config:
self.history_file = Path(self.config['history-directory']).expanduser() / f'{self.channel}.dat'
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:
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'
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:
with open(self.memory_file, "rb") as fd:
self.memory = pickle.load(fd)
logging.info(f'memmory:\n{self.memory}')
logging.info(f"memmory:\n{self.memory}")
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
messages = []
system = self.config.get(self.channel, self.config['system'])
system = system.replace('{date}', time.strftime('%Y-%m-%d'))\
.replace('{time}', time.strftime('%H:%M:%S'))
news_feed = self.config.get('news')
system = self.config.get(self.channel, self.config["system"])
system = system.replace("{date}", time.strftime("%Y-%m-%d")).replace("{time}", time.strftime("%H:%M:%S"))
news_feed = self.config.get("news")
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('{memory}', self.memory)
system = system.replace("{news}", news_feed)
system = system.replace("{memory}", self.memory)
messages.append({"role": "system", "content": system})
if limit is not None:
while len(self.history) > limit:
@@ -195,7 +200,7 @@ class AIResponder(AIResponderBase):
return messages
async def draw(self, description: str) -> BytesIO:
if self.config.get('leonardo-token') is not None:
if self.config.get("leonardo-token") is not None:
return await self.draw_leonardo(description)
return await self.draw_openai(description)
@@ -206,29 +211,31 @@ 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'"):
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'):
if str(response.get(fld)).strip().lower() == 'true':
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
if response["answer"] is None:
response["answer_needed"] = False
else:
response['answer'] = str(response['answer'])
response['answer'] = re.sub(r'@\[([^\]]*)\]\([^\)]*\)', r'\1', response['answer'])
response['answer'] = re.sub(r'\[[^\]]*\]\(([^\)]*)\)', r'\1', response['answer'])
response["answer"] = str(response["answer"])
response["answer"] = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", response["answer"])
response["answer"] = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", response["answer"])
if message.direct or message.user in message.message:
response['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['hack'])
response["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"],
)
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:
@@ -236,9 +243,9 @@ class AIResponder(AIResponderBase):
return response_message
def short_path(self, message: AIMessage, limit: int) -> bool:
if message.direct or 'short-path' not in self.config:
if message.direct or "short-path" not in self.config:
return False
for chan_re, user_re in self.config['short-path']:
for chan_re, user_re in self.config["short-path"]:
chan_ma = re.match(chan_re, message.channel)
user_ma = re.match(user_re, message.user)
if chan_ma and user_ma:
@@ -246,7 +253,7 @@ class AIResponder(AIResponderBase):
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:
with open(self.history_file, "wb") as fd:
pickle.dump(self.history, fd)
return True
return False
@@ -269,30 +276,26 @@ class AIResponder(AIResponderBase):
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):
if count > self.config.get("history-per-channel", 3):
del self.history[index]
else:
self.shrink_history_by_one(index + 1)
def update_history(self,
question: Dict[str, Any],
answer: Dict[str, Any],
limit: int,
historise_question: bool = True) -> None:
if type(question['content']) != str:
question['content'] = question['content'][0]['text']
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):
question["content"] = question["content"][0]["text"]
if historise_question:
self.history.append(question)
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:
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:
with open(self.memory_file, "wb") as fd:
pickle.dump(self.memory, fd)
async def handle_picture(self, response: Dict) -> bool:
@@ -308,10 +311,10 @@ class AIResponder(AIResponderBase):
self.update_memory(self.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> ')
await self.memoize(message_user, 'assistant',
f'\n> {quoted_message}',
f'User {reaction_user} has {operation} this raction: {reaction}')
quoted_message = message.replace("\n", "\n> ")
await self.memoize(
message_user, "assistant", f"\n> {quoted_message}", f"User {reaction_user} has {operation} this raction: {reaction}"
)
async def send(self, message: AIMessage) -> AIResponse:
# Get the history limit from the configuration
@@ -319,7 +322,7 @@ class AIResponder(AIResponderBase):
# Check if a short path applies, return an empty AIResponse if it does
if self.short_path(message, limit):
return AIResponse(None, False, None, None, None, False)
return AIResponse(None, False, None, None, None, False, False)
# Number of retries for sending the message
retries = 3
@@ -333,18 +336,19 @@ class AIResponder(AIResponderBase):
answer, limit = await self.chat(messages, limit)
if answer is None:
retries -= 1
continue
# Attempt to parse the AI's response
try:
response = parse_json(answer['content'])
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'])
answer["content"] = await self.fix(answer["content"])
# Retry parsing the fixed content
try:
response = parse_json(answer['content'])
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
@@ -356,7 +360,7 @@ class AIResponder(AIResponderBase):
# Post-process the message and update the answer's content
answer_message = await self.post_process(message, response)
answer['content'] = str(answer_message)
answer["content"] = str(answer_message)
# Update message history
self.update_history(messages[-1], answer, limit, message.historise_question)
@@ -364,7 +368,7 @@ class AIResponder(AIResponderBase):
# Update memory
if answer_message.answer is not None:
await self.memoize(message.user, 'assistant', message.message, answer_message.answer)
await self.memoize(message.user, "assistant", message.message, answer_message.answer)
# Return the updated answer message
return answer_message