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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user