Skip to content

Commit 9822cb3

Browse files
committed
Fix deletion while iterating, deduplicate messages by ID, add reply chain to context
1 parent b9d50f1 commit 9822cb3

4 files changed

Lines changed: 67 additions & 24 deletions

File tree

arabot/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "9.2.1"
1+
__version__ = "9.2.2"

arabot/core/patches.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def _rsearch(self, target: RSearchTarget) -> str | None:
5858

5959
case self.RSearchTarget.IMAGE_URL:
6060
if attachment := disnake.utils.find(
61-
lambda a: a.content_type.startswith("image") and a.height, msg.attachments
61+
lambda a: a.content_type and a.content_type.startswith("image") and a.height, msg.attachments
6262
):
6363
result = attachment.url
6464
elif embed := disnake.utils.find(lambda e: e.image.url, msg.embeds):
@@ -70,7 +70,7 @@ async def _rsearch(self, target: RSearchTarget) -> str | None:
7070

7171
case self.RSearchTarget.AUDIO_VIDEO_URL:
7272
if attachment := disnake.utils.find(
73-
lambda a: a.content_type.startswith(("audio", "video")), msg.attachments
73+
lambda a: a.content_type and a.content_type.startswith(("audio", "video")), msg.attachments
7474
):
7575
result = attachment.url
7676
elif re.fullmatch(r"https?://(-\.)?([^\s/?\.#]+\.?)+(/\S*)?", self.argument_only):
@@ -241,7 +241,7 @@ async def connect_play_disconnect(
241241

242242

243243
async def get_or_fetch_reference_message(self: disnake.Message) -> disnake.Message | False | None:
244-
if not (ref := self.reference):
244+
if not (ref := self.reference) or not ref.message_id:
245245
return False
246246
with suppress(disnake.HTTPException):
247247
return ref.cached_message or await self.channel.fetch_message(ref.message_id)

arabot/plugins/ai.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from time import time
66
from typing import Literal, NotRequired, TypedDict
77

8+
import disnake
89
from disnake.ext.commands import command
910
from yarl import URL
1011

@@ -78,20 +79,21 @@ def __init__(self, ara: Ara):
7879
"Authorization": f"Bearer {Config.nvidia_api_key}",
7980
"Accept": "application/json",
8081
}
81-
self.context = defaultdict[int, list[NimPrompt]](list)
82+
self.context = defaultdict[int, list[tuple[int, NimPrompt]]](list)
8283

8384
instructions = Path("resources/llm-instructions.md").read_text(encoding="utf-8")
8485
self.instructions = NimPrompt(role="system", content=instructions)
8586

8687
@command(brief="Prompt LLM with text, replies and images", help=HELP_TEXT, usage="<prompt and/or media>")
8788
async def ai(self, ctx: Context):
88-
prompt = self.ctx_to_prompt(ctx)
89-
if not prompt:
89+
ctx.message.content = ctx.argument_only.strip()
90+
nim_prompt = self.msg_to_prompt(ctx.message)
91+
if not nim_prompt:
9092
await ctx.send_help(ctx.command)
9193
return
9294

93-
history = list(filter(None, map(self.prune_expired_media, self.context[ctx.channel.id][-18:])))
94-
messages = [self.instructions, *history, prompt]
95+
history, reply_chain = await self.get_clean_history(ctx)
96+
messages = [self.instructions, *history, *(p for _, p in reply_chain), nim_prompt]
9597

9698
payload = {
9799
"messages": messages,
@@ -112,28 +114,68 @@ async def ai(self, ctx: Context):
112114
logging.debug("AI payload: %r\nAI response: %r", payload, data)
113115

114116
answer: str = data["choices"][0]["message"]["content"]
115-
116117
ai_response = NimPrompt(role="assistant", content=answer)
117-
self.context[ctx.channel.id] = [self.instructions, *history[-17:], prompt, ai_response]
118118

119119
if len(answer) > (maxlen := 1997):
120120
answer = ".".join(answer[:maxlen].rsplit(".", maxsplit=2)[:-1]) + "..."
121121

122-
await ctx.reply(answer, mention_author=True)
122+
reply_msg = await ctx.reply(answer, mention_author=True)
123123

124-
@staticmethod
125-
def ctx_to_prompt(ctx: Context) -> NimPrompt | None:
126-
items: list[NimInput] = []
124+
memory = self.context[ctx.channel.id]
125+
memory.extend(reply_chain) # TODO: Don't append existing messages
126+
memory.append((ctx.message.id, nim_prompt))
127+
memory.append((reply_msg.id, ai_response))
128+
129+
self.context[ctx.channel.id] = memory[-18:]
130+
131+
log = "\n".join(
132+
f"{i}: {m['content'] if isinstance(m['content'], str) else m['content'][0]['text']}"
133+
for i, m in self.context[ctx.channel.id]
134+
)
135+
logging.info(f"\n{log}\n")
136+
137+
async def get_clean_history(self, ctx: Context) -> tuple[list[NimPrompt], list[tuple[int, NimPrompt]]]:
138+
raw_history = self.context[ctx.channel.id][-18:]
139+
history: list[NimPrompt] = []
140+
history_ids = set[int]()
141+
142+
for msg_id, prompt in raw_history:
143+
if pruned := self.prune_expired_media(prompt):
144+
history.append(pruned)
145+
history_ids.add(msg_id)
146+
147+
reply_chain: list[tuple[int, NimPrompt]] = []
148+
current_msg = ctx.message
127149

128-
if prompt := ctx.argument_only.strip():
129-
item = NimInputText(
130-
type=NimInputType.TEXT,
131-
text=f"[{ctx.author.id}|{ctx.author.global_name or ctx.author.name}]:{prompt}",
132-
)
150+
for _ in range(3):
151+
if not (ref := current_msg.reference) or not (ref_msg_id := ref.message_id) or ref in history_ids:
152+
break
153+
154+
try:
155+
ref_msg = ref.cached_message or await ctx.channel.fetch_message(ref_msg_id)
156+
except disnake.HTTPException:
157+
break
158+
159+
if ref_prompt := self.msg_to_prompt(ref_msg):
160+
reply_chain.insert(0, (ref_msg_id, ref_prompt))
161+
history_ids.add(ref_msg_id)
162+
163+
current_msg = ref_msg
164+
165+
return history, reply_chain
166+
167+
def msg_to_prompt(self, msg: disnake.Message) -> NimPrompt | None:
168+
if msg.author == self.ara.user:
169+
return NimPrompt(role="assistant", content=msg.content) if msg.content else None
170+
171+
items: list[NimInput] = []
172+
if msg.content:
173+
author_name = msg.author.global_name or msg.author.name
174+
item = NimInputText(type=NimInputType.TEXT, text=f"[{msg.author.id}|{author_name}]:{msg.content}")
133175
items.append(item)
134176

135-
for att in ctx.message.attachments:
136-
if att.content_type.startswith("image/"):
177+
for att in msg.attachments:
178+
if att.content_type and att.content_type.startswith("image/"):
137179
item = NimInputImageUrl(type=NimInputType.IMAGE_URL, image_url=NimInputUrl(url=att.url))
138180
items.append(item)
139181

@@ -144,7 +186,8 @@ def prune_expired_media(item: NimPrompt) -> NimPrompt | None:
144186
if isinstance(item["content"], str):
145187
return item
146188

147-
for idx, input_item in enumerate(item["content"]):
189+
for idx in range(len(item["content"]) - 1, -1, -1):
190+
input_item = item["content"][idx]
148191
match input_item["type"]:
149192
case NimInputType.AUDIO_URL:
150193
url = input_item["audio_url"]["url"]

arabot/plugins/games.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ async def connect4(self, ctx: Context) -> None:
224224

225225
@commands.Cog.listener()
226226
async def on_reaction_add(self, reaction: disnake.Reaction, user: disnake.abc.User) -> None:
227-
if user.id == self.ara.user.id:
227+
if user == self.ara.user:
228228
return
229229
if reaction.message.id in self.waiting_games:
230230
message, player1, p1_token = self.waiting_games[reaction.message.id]

0 commit comments

Comments
 (0)