Prechádzať zdrojové kódy

Pièces jointes : une réponse découpée voit les messages déjà traités et cite le sien

Quand des mentions trop lourdes étaient réparties en plusieurs réponses, chaque
groupe relisait le contexte en excluant toutes les mentions du lot : le 2e groupe
voyait la réponse du bot au 1er, mais pas la mention à laquelle elle répondait.
En retard (contexte arrêté à la dernière mention), il ne voyait ni l'une ni l'autre.

Seules les mentions pas encore traitées (et celles refusées) sont maintenant
exclues du contexte ; en retard, les réponses déjà envoyées sont ajoutées à la fin.
Une réponse découpée cite aussi son message, dans le salon dédié comme pour
les mentions, pour qu'on sache à quoi elle répond.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 deň pred
rodič
commit
cfd4b35c61
5 zmenil súbory, kde vykonal 73 pridanie a 10 odobranie
  1. 1 1
      CHANGELOG.md
  2. 1 1
      README.md
  3. 17 5
      bot/discord_bot.py
  4. 50 1
      tests/test_mentions.py
  5. 4 2
      tests/test_request_limits.py

+ 1 - 1
CHANGELOG.md

@@ -14,7 +14,7 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une coupure réseau pendant un téléchargement ne fait plus ignorer le message.** Si une pièce jointe ne se télécharge pas (connexion coupée, ou plus de 30 secondes), Rika demande de réessayer ; une image illisible dans un message cité ou transféré est simplement ignorée. Avant, le message restait sans réponse.
 
-- **Plusieurs gros fichiers envoyés pendant que Rika écrit ne font plus échouer sa réponse.** Ils partaient tous dans un seul appel à Claude, qui pouvait dépasser ses limites. Quand leurs PDF et images sont trop lourds ensemble (plus de 18 Mo, ou plus de 100 pages et images), Rika répond maintenant en plusieurs fois, dans l'ordre.
+- **Plusieurs gros fichiers envoyés pendant que Rika écrit ne font plus échouer sa réponse.** Ils partaient tous dans un seul appel à Claude, qui pouvait dépasser ses limites. Quand leurs PDF et images sont trop lourds ensemble (plus de 18 Mo, ou plus de 100 pages et images), Rika répond maintenant en plusieurs fois, dans l'ordre, chaque réponse citant le message auquel elle répond.
 
 ---
 

+ 1 - 1
README.md

@@ -8,7 +8,7 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 
 ### Conversation
 
-- **Salon dédié** : le bot répond à tous les messages du salon choisi (`CHANNEL_ID`). Il se souvient des derniers échanges, même après un redémarrage. Les messages envoyés pendant qu'il écrit reçoivent une seule réponse groupée, comme le ferait quelqu'un qui lit plusieurs messages d'un coup ; s'ils contiennent des fichiers trop lourds ensemble, il y répond en plusieurs fois.
+- **Salon dédié** : le bot répond à tous les messages du salon choisi (`CHANNEL_ID`). Il se souvient des derniers échanges, même après un redémarrage. Les messages envoyés pendant qu'il écrit reçoivent une seule réponse groupée, comme le ferait quelqu'un qui lit plusieurs messages d'un coup ; s'ils contiennent des fichiers trop lourds ensemble, il y répond en plusieurs fois, chaque réponse citant son message.
 - **Mentions ailleurs** : dans les autres salons, il répond quand on le mentionne, en lisant les derniers messages pour comprendre la discussion (sans mémoire d'un appel à l'autre). Comme dans son salon, les mentions reçues dans un salon pendant qu'il écrit reçoivent une seule réponse groupée.
 - **Messages cités et transférés** : avec « Répondre », il lit le message cité, même ancien, et ses images. Il lit aussi les messages transférés dans son salon.
 - **Messages modifiés ou supprimés** : sa mémoire suit. Un message supprimé est oublié, un message modifié prend son nouveau texte.

+ 17 - 5
bot/discord_bot.py

@@ -487,6 +487,7 @@ class ChatBot(commands.Bot):
         """Répond aux messages en attente du salon dédié : en une fois, ou en plusieurs réponses à la suite
         si leurs pièces jointes ne tiennent pas dans une seule requête."""
         pending = self._pending.setdefault(channel.id, [])
+        split = False  # réponse découpée : chaque morceau cite son dernier message
         # Jusqu'au dernier message : une réponse différée est la seule tâche pour tous ceux de son attente.
         while True:
             async with self.history.lock(channel.id):
@@ -495,6 +496,7 @@ class ChatBot(commands.Bot):
                 pending.sort(key=lambda p: p.message.id)
                 count = fit_in_request([p.images for p in pending])
                 if count < len(pending):
+                    split = True
                     logger.info("Pièces jointes trop lourdes pour une seule réponse dans #%s : %s message(s) sur %s "
                                 "d'abord, les autres ensuite.", channel, count, len(pending))
                 batch = pending[:count]
@@ -527,7 +529,7 @@ class ChatBot(commands.Bot):
                 finally:
                     self.forget_waiting(*(p.message for p in batch))
                 await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
-            await self.send_answer(last, answer.text)
+            await self.send_answer(last, answer.text, reply=split)
 
     async def handle_mention(self, message: discord.Message) -> None:
         # Comme dans le salon dédié : les mentions arrivées pendant une réponse attendent dans la file,
@@ -561,10 +563,15 @@ class ChatBot(commands.Bot):
             if part := await self.mention_part(message):
                 answered.append(message)
                 parts.append(part)
+        # Mentions à pièce jointe refusée : leurs auteurs ont été prévenus, elles restent hors du contexte.
+        refused = {m.id for m in mentions} - {m.id for m in answered}
+        sent: list[str] = []  # en retard, réponses déjà envoyées (postées après la fin du contexte)
+        split = False  # réponse découpée : chaque morceau cite sa dernière mention
         # Liste vide : pièces jointes refusées, les auteurs ont été prévenus.
         while parts:
             count = fit_in_request([images for _, images in parts])
             if count < len(parts):
+                split = True
                 logger.info("Pièces jointes trop lourdes pour une seule réponse dans #%s : %s mention(s) sur %s "
                             "d'abord, les autres ensuite.", channel, count, len(parts))
             group, group_parts = answered[:count], parts[:count]
@@ -573,8 +580,10 @@ class ChatBot(commands.Bot):
 
             # Contexte jusqu'à maintenant, relu pour chaque réponse : elle voit celle que le bot vient d'envoyer
             # (postée après ces mentions). En retard, jusqu'à la dernière mention : la discussion qui a suivi
-            # recouvrirait celle-ci.
-            lines = await self.mention_context(channel, {m.id for m in mentions}, before=mentions[-1] if late else None)
+            # recouvrirait celle-ci ; les réponses déjà envoyées sont alors ajoutées à la fin.
+            # Seules les mentions pas encore traitées sont décrites à part : les autres sont des messages normaux.
+            pending = refused | {m.id for m in group} | {m.id for m in answered}
+            lines = await self.mention_context(channel, pending, before=mentions[-1] if late else None) + sent
             channel_name = f"#{channel.name}" if hasattr(channel, "name") else "un message privé"
             header = []
             if late:
@@ -597,8 +606,11 @@ class ChatBot(commands.Bot):
                     logger.exception("Erreur inattendue pendant le traitement d'une mention")
                     await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
                     continue
-            # En retard, la réponse cite la dernière mention : on sait à quoi elle répond.
-            await self.send_answer(last, answer.text, reply=late is not None)
+            # En retard ou découpée, la réponse cite la dernière mention : on sait à quoi elle répond.
+            await self.send_answer(last, answer.text, reply=late is not None or split)
+            if late:
+                me = last.guild.me if last.guild else self.user
+                sent.append(f"{author_name(me)} : {short_emojis(answer.text)}")
 
     async def mention_context(self, channel: discord.abc.Messageable, mention_ids: set[int],
                               before: discord.Message | None) -> list[str]:

+ 50 - 1
tests/test_mentions.py

@@ -167,7 +167,56 @@ class MentionTest(unittest.IsolatedAsyncioTestCase):
         self.assertTrue(second[0][0].endswith("et ça"))
         self.assertEqual(bot.member_notes.await_args_list, [mock.call([history[1]]), mock.call([history[2]])])
         self.assertEqual(bot.send_answer.await_args_list,
-                         [mock.call(history[1], "Coucou", reply=False), mock.call(history[2], "Coucou", reply=False)])
+                         [mock.call(history[1], "Coucou", reply=True), mock.call(history[2], "Coucou", reply=True)])
+
+    def heavy_split_setup(self, late: bool):
+        """Deux mentions trop lourdes pour une seule requête, suivies d'un message d'un membre ;
+        chaque réponse du bot est ajoutée au salon."""
+        bot = fake_bot()
+        history = []
+        channel = fake_channel(history)
+        history += [fake_mention(1, "lis ça", channel), fake_mention(2, "et ça", channel)]
+        heavy = ImageData(PDF_MEDIA_TYPE, bytes(15_000_000), name="doc.pdf")
+        bot.mention_part = mock.AsyncMock(side_effect=[("lis ça", [heavy]), ("et ça", [heavy])])
+        bot.presence.late_note.return_value = "(en retard)"
+        for message in history:
+            message.guild.me.display_name = message.guild.me.name = "Rika"
+        if late:
+            history.append(mock.Mock(id=3, clean_content="après", attachments=[], stickers=[]))
+            history[2].author.display_name = history[2].author.name = "membre3"
+        answers = iter(["Réponse un", "Réponse deux"])
+        bot.claude.ask = mock.AsyncMock(side_effect=lambda *a, **k: mock.Mock(text=next(answers)))
+
+        async def post(message, text, reply=False):
+            answer = mock.Mock(id=len(history) + 1, clean_content=text, attachments=[], stickers=[])
+            answer.author.display_name = answer.author.name = "Rika"
+            history.append(answer)
+        bot.send_answer = mock.AsyncMock(side_effect=post)
+        return bot, history[:2]
+
+    async def test_heavy_split_next_group_sees_answered_mention(self):
+        # La 2e réponse voit la 1re mention (déjà traitée) comme un message normal, et la réponse du bot.
+        bot, mentions = self.heavy_split_setup(late=False)
+        await ChatBot.answer_mention(bot, mentions)
+        first, second = (call.args[1][0][0] for call in bot.claude.ask.await_args_list)
+        self.assertNotIn("membre1 : ", first)
+        self.assertNotIn("membre2 : ", first)
+        self.assertIn("membre1 : @Rika lis ça", second)
+        self.assertNotIn("membre2 : ", second)  # la mention en cours reste décrite à part
+        self.assertIn("Rika : Réponse un", second)
+
+    async def test_late_heavy_split_next_group_sees_previous_answer(self):
+        # En retard, le contexte s'arrête à la dernière mention, mais la 2e réponse voit quand même
+        # la 1re mention et la réponse que le bot vient d'y faire.
+        bot, mentions = self.heavy_split_setup(late=True)
+        await ChatBot.answer_mention(bot, mentions, late=mock.Mock())
+        first, second = (call.args[1][0][0] for call in bot.claude.ask.await_args_list)
+        self.assertNotIn("Réponse un", first)
+        self.assertIn("membre1 : @Rika lis ça", second)
+        self.assertIn("Rika : Réponse un", second)
+        self.assertNotIn("membre2 : ", second)
+        self.assertNotIn("après", second)
+        self.assertLess(second.index("membre1 : @Rika lis ça"), second.index("Rika : Réponse un"))
 
     async def test_all_refused_no_call(self):
         bot = fake_bot()

+ 4 - 2
tests/test_request_limits.py

@@ -93,6 +93,8 @@ class MainChannelTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(asked_texts(bot), [[text(1)], [text(2)]])
         self.assertEqual(bot.history.add_exchange.await_count, 2)
         self.assertEqual([call.args[0].id for call in bot.send_answer.await_args_list], [1, 2])
+        # Réponse découpée : chacune cite son message, pour qu'on sache à quoi elle répond.
+        self.assertEqual([call.kwargs["reply"] for call in bot.send_answer.await_args_list], [True, True])
         self.assertEqual(bot._pending[CHANNEL_ID], [])
 
     async def test_light_messages_grouped(self):
@@ -101,7 +103,7 @@ class MainChannelTest(unittest.IsolatedAsyncioTestCase):
         bot._pending[CHANNEL_ID] = [pending(2, channel, [pdf(5)]), pending(1, channel, [pdf(5)])]
         await ChatBot.answer_main_channel(bot, channel)
         self.assertEqual(asked_texts(bot), [[text(1), text(2)]])
-        bot.send_answer.assert_awaited_once()
+        bot.send_answer.assert_awaited_once_with(mock.ANY, mock.ANY, reply=False)
 
     async def test_page_limit(self):
         # 3 PDF de 40 pages : les deux premiers ensemble (80 pages), le troisième ensuite.
@@ -119,7 +121,7 @@ class MainChannelTest(unittest.IsolatedAsyncioTestCase):
         bot.claude.ask.side_effect = [ClaudeError("Erreur."), mock.Mock(text="Réponse", web_used=False)]
         await ChatBot.answer_main_channel(bot, channel)
         bot.safe_reply.assert_awaited_once_with(first.message, "Erreur.")
-        bot.send_answer.assert_awaited_once_with(second.message, "Réponse")
+        bot.send_answer.assert_awaited_once_with(second.message, "Réponse", reply=True)
         bot.history.add_exchange.assert_awaited_once()
         self.assertEqual(bot.forget_waiting.call_args_list, [mock.call(first.message), mock.call(second.message)])