fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -11,8 +12,6 @@ from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import multiline
|
||||
|
||||
|
||||
def pp(*args, **kw):
|
||||
if "width" not in kw:
|
||||
@@ -22,14 +21,16 @@ def pp(*args, **kw):
|
||||
|
||||
@lru_cache(maxsize=300)
|
||||
def parse_json(content: str) -> Dict:
|
||||
content = content.strip()
|
||||
try:
|
||||
return json.loads(content)
|
||||
except Exception:
|
||||
try:
|
||||
return multiline.loads(content, multiline=True)
|
||||
except Exception as err:
|
||||
raise err
|
||||
# Strict JSON only — model output is schema-enforced (ENV-18/19),
|
||||
# history entries are json.dumps products.
|
||||
return json.loads(content.strip())
|
||||
|
||||
|
||||
def sanitize_external_text(text: str, max_len: int = 4000) -> str:
|
||||
"""Neutralize attacker-influenced text before it enters a prompt (SAF-03)."""
|
||||
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
|
||||
text = text.replace("@everyone", "@everyone").replace("@here", "@here")
|
||||
return text[:max_len]
|
||||
|
||||
|
||||
def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts=None):
|
||||
@@ -84,30 +85,6 @@ def async_cache_to_file(filename):
|
||||
return decorator
|
||||
|
||||
|
||||
def parse_maybe_json(json_string):
|
||||
if json_string is None:
|
||||
return None
|
||||
if isinstance(json_string, (list, dict)):
|
||||
return " ".join(map(str, (json_string.values() if isinstance(json_string, dict) else json_string)))
|
||||
json_string = str(json_string).strip()
|
||||
try:
|
||||
parsed_json = parse_json(json_string)
|
||||
except Exception:
|
||||
for b, e in [("{", "}"), ("[", "]")]:
|
||||
if json_string.startswith(b) and json_string.endswith(e):
|
||||
return parse_maybe_json(json_string[1:-1])
|
||||
return json_string
|
||||
if isinstance(parsed_json, str):
|
||||
return parsed_json
|
||||
if isinstance(parsed_json, (list, dict)):
|
||||
return "\n".join(map(str, (parsed_json.values() if isinstance(parsed_json, dict) else parsed_json)))
|
||||
return str(parsed_json)
|
||||
|
||||
|
||||
def same_channel(item1: Dict[str, Any], item2: Dict[str, Any]) -> bool:
|
||||
return parse_json(item1["content"]).get("channel") == parse_json(item2["content"]).get("channel")
|
||||
|
||||
|
||||
class AIMessageBase(object):
|
||||
def __init__(self) -> None:
|
||||
self.vars: List[str] = []
|
||||
@@ -182,7 +159,7 @@ class AIResponder(AIResponderBase):
|
||||
if news_feed and os.path.exists(news_feed):
|
||||
with open(news_feed) as fd:
|
||||
news_feed = fd.read().strip()
|
||||
system = system.replace("{news}", news_feed)
|
||||
system = system.replace("{news}", sanitize_external_text(news_feed))
|
||||
system = system.replace("{memory}", self.memory)
|
||||
messages.append({"role": "system", "content": system})
|
||||
if limit is not None:
|
||||
@@ -211,30 +188,26 @@ class AIResponder(AIResponderBase):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
|
||||
for fld in ("answer", "channel", "staff", "picture", "hack"):
|
||||
if str(response.get(fld)).strip().lower() in ("none", "", "null", '"none"', '"null"', "'none'", "'null'"):
|
||||
response[fld] = None
|
||||
for fld in ("answer_needed", "hack", "picture_edit"):
|
||||
if str(response.get(fld)).strip().lower() == "true":
|
||||
response[fld] = True
|
||||
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
|
||||
@@ -261,25 +234,32 @@ class AIResponder(AIResponderBase):
|
||||
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def fix(self, answer: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def shrink_history_by_one(self, index: int = 0) -> None:
|
||||
if index >= len(self.history):
|
||||
del self.history[0]
|
||||
else:
|
||||
current = self.history[index]
|
||||
count = sum(1 for item in self.history if same_channel(item, current))
|
||||
if count > self.config.get("history-per-channel", 3):
|
||||
@staticmethod
|
||||
def _entry_channel(item: Dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
return parse_json(item["content"]).get("channel")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def shrink_history_by_one(self) -> None:
|
||||
if not self.history:
|
||||
return
|
||||
cap = self.config.get("history-per-channel", 3)
|
||||
counts: Dict[Optional[str], int] = {}
|
||||
for item in self.history:
|
||||
chan = self._entry_channel(item)
|
||||
counts[chan] = counts.get(chan, 0) + 1
|
||||
for index, item in enumerate(self.history):
|
||||
if counts[self._entry_channel(item)] > cap:
|
||||
del self.history[index]
|
||||
else:
|
||||
self.shrink_history_by_one(index + 1)
|
||||
return
|
||||
del self.history[0]
|
||||
|
||||
def update_history(self, question: Dict[str, Any], answer: Dict[str, Any], limit: int, historise_question: bool = True) -> None:
|
||||
if not isinstance(question["content"], str):
|
||||
@@ -294,6 +274,7 @@ class AIResponder(AIResponderBase):
|
||||
pickle.dump(self.history, fd)
|
||||
|
||||
def update_memory(self, memory) -> None:
|
||||
self.memory = memory
|
||||
if self.memory_file is not None:
|
||||
with open(self.memory_file, "wb") as fd:
|
||||
pickle.dump(self.memory, fd)
|
||||
@@ -306,6 +287,15 @@ class AIResponder(AIResponderBase):
|
||||
response["picture"] = await self.translate(response["picture"])
|
||||
return True
|
||||
|
||||
def _parse_answer(self, answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
# Schema-enforced output should always parse; anything else is a
|
||||
# failed attempt — no repair model (ENV-18).
|
||||
try:
|
||||
return parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.error(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
return None
|
||||
|
||||
async def memoize(self, message_user: str, answer_user: str, message: str, answer: str) -> None:
|
||||
self.memory = await self.memory_rewrite(self.memory, message_user, answer_user, message, answer)
|
||||
self.update_memory(self.memory)
|
||||
@@ -324,8 +314,16 @@ class AIResponder(AIResponderBase):
|
||||
if self.short_path(message, limit):
|
||||
return AIResponse(None, False, None, None, None, False, False)
|
||||
|
||||
# Number of retries for sending the message
|
||||
# Number of retries for sending the message; failed attempts are
|
||||
# spaced by exponential backoff (ENV-12 / D1)
|
||||
retries = 3
|
||||
backoff = exponential_backoff(max_delay=10)
|
||||
|
||||
async def failed_attempt() -> None:
|
||||
nonlocal retries
|
||||
retries -= 1
|
||||
if retries > 0:
|
||||
await asyncio.sleep(next(backoff))
|
||||
|
||||
while retries > 0:
|
||||
# Get the message queue
|
||||
@@ -336,26 +334,13 @@ class AIResponder(AIResponderBase):
|
||||
answer, limit = await self.chat(messages, limit)
|
||||
|
||||
if answer is None:
|
||||
retries -= 1
|
||||
await failed_attempt()
|
||||
continue
|
||||
|
||||
# Attempt to parse the AI's response
|
||||
try:
|
||||
response = parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.warning(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
answer["content"] = await self.fix(answer["content"])
|
||||
|
||||
# Retry parsing the fixed content
|
||||
try:
|
||||
response = parse_json(answer["content"])
|
||||
except Exception as err:
|
||||
logging.error(f"failed to parse the fixed answer: {pp(err)}\n{repr(answer['content'])}")
|
||||
retries -= 1
|
||||
continue
|
||||
|
||||
if not await self.handle_picture(response):
|
||||
retries -= 1
|
||||
# Attempt to parse the AI's response (strict — ENV-18)
|
||||
response = self._parse_answer(answer)
|
||||
if response is None or not await self.handle_picture(response):
|
||||
await failed_attempt()
|
||||
continue
|
||||
|
||||
# Post-process the message and update the answer's content
|
||||
|
||||
Reference in New Issue
Block a user