Parcourir la source

Merge branch 'codex' of penta/satoko-chatbot into master

OK
penta il y a 1 jour
Parent
commit
7dbbf213f1
3 fichiers modifiés avec 105 ajouts et 4 suppressions
  1. 26 3
      chatbot.py
  2. 57 1
      test_voice_stt.py
  3. 22 0
      voice_stt.py

+ 26 - 3
chatbot.py

@@ -51,7 +51,7 @@ if WEB_SEARCH_RESULT_COUNT < 1:
 # Initialiser le client OpenAI asynchrone ici
 openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
 
-BOT_VERSION = "3.2.0"
+BOT_VERSION = "3.3.1"
 
 # Vérifier la version de Discord
 if not sys.version_info[:2] >= (3, 15):
@@ -417,7 +417,7 @@ async def call_openai_api(user_text, user_name, voice=False):
     # Assembler les messages avec le prompt de personnalité en premier
     messages = [
         {"role": "system", "content": PERSONALITY_PROMPT}
-    ] + conversation_history + [message_to_send]
+    ] + (conversation_history[-5:] if voice else conversation_history) + [message_to_send]
 
     # Le contexte web reste limité à cet appel, sans remplir l'historique.
     web_results = await search_web(user_text)
@@ -445,15 +445,19 @@ async def call_openai_api(user_text, user_name, voice=False):
             "Il peut contenir des erreurs de reconnaissance, notamment sur les noms propres. "
             "Interprète les petites erreurs à l'aide du contexte sans inventer les propos. "
             "Si une ambiguïté change le sens, demande une précision. "
-            "Réponds naturellement à l'ensemble des interventions, en distinguant les personnes."
+            "Réponds naturellement à l'ensemble des interventions, en distinguant les personnes. "
+            "Réponds en une à trois phrases courtes, sauf demande explicite de détails."
         )
 
+    voice_options = {"extra_body": {"options": {"think": False}}} if voice else {}
+
     try:
         response = await openai_client.chat.completions.create(
             model=MODEL,
             stream=False,
             messages=messages,
             temperature=TEMPERATURE,
+            **voice_options,
             **search_options
         )
 
@@ -480,6 +484,7 @@ async def call_openai_api(user_text, user_name, voice=False):
             # Permettre de préciser la recherche, puis imposer une réponse à la limite.
             response = await openai_client.chat.completions.create(
                 model=MODEL, stream=False, messages=messages, temperature=TEMPERATURE,
+                **voice_options,
                 **(search_options if search_count < 3 and _ < 2 else {})
             )
 
@@ -544,6 +549,21 @@ async def on_ready():
 
 voice_command_lock = asyncio.Lock()
 
+async def handle_status(message):
+    voice = message.guild.voice_client if message.guild else None
+    sink = getattr(voice, 'sink', None) if voice else None
+    lines = [f"Satoko {BOT_VERSION} — modèle : {MODEL}",
+             f"Vocal : {'connecté' if voice and voice.is_connected() else 'déconnecté'}"]
+    if sink:
+        def duration(value):
+            return f"{value:.1f} s" if value is not None else "pas encore mesuré"
+        lines += [f"Réponse en cours : {'oui' if sink.responding else 'non'}",
+                  f"Audio en attente STT : {sink.queue.qsize()} — transcriptions en attente : {len(sink.pending)}",
+                  f"Dernier STT réussi : {duration(sink.last_stt_seconds)}",
+                  f"Dernier traitement de réponse : {duration(sink.last_reply_seconds)}"]
+    await message.channel.send("\n".join(lines), allowed_mentions=discord.AllowedMentions.none())
+
+
 async def handle_voice_command(message):
     if not VOICE_ENABLED:
         await message.channel.send("Le vocal est désactivé (VOICE_ENABLED=false).")
@@ -639,6 +659,9 @@ async def on_message(message):
         return
 
     user_text = message.content.strip()
+    if user_text.lower() == '!status':
+        await handle_status(message)
+        return
     if user_text.lower() in ('!join', '!quit'):
         await handle_voice_command(message)
         return

+ 57 - 1
test_voice_stt.py

@@ -114,10 +114,22 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
                 self.sink.add_pcm(other, self.pcm, time.monotonic())
                 self.assertIn(2, self.sink.buffers)
             release.set()
-            await asyncio.wait_for(second.wait(), 2)
+            await asyncio.wait_for(second.wait(), 4)
             self.assertEqual(batches[1], [(other, 'Minecraft ?'), (self.user, 'Avec des mods')])
             self.assertFalse(self.sink.pending)
 
+    async def test_reply_waits_for_global_silence(self):
+        with self.sink.lock:
+            self.sink.last_voice = time.monotonic()
+        self.sink.pending.append((self.user, 'Bonjour'))
+        self.sink.pending_event.set()
+        await asyncio.sleep(0.2)
+        self.reply.assert_not_awaited()
+        with self.sink.lock:
+            self.sink.last_voice = time.monotonic() - 2.1
+        await asyncio.sleep(0.2)
+        self.reply.assert_awaited_once_with([(self.user, 'Bonjour')])
+
     async def test_close_discards_pending_audio(self):
         self.sink.queue.put_nowait((self.user, self.pcm))
         await self.sink.close()
@@ -158,6 +170,50 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
 
 
 class CommandTests(unittest.IsolatedAsyncioTestCase):
+    async def test_voice_limits_history_and_disables_thinking_only_for_voice(self):
+        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
+        function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
+                        and n.name == 'call_openai_api')
+        response = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(
+            tool_calls=None, content='Bonjour'))])
+        create = AsyncMock(return_value=response)
+        history = [{'role': 'user', 'content': str(i)} for i in range(10)]
+        namespace = dict(transform_emote=lambda text, output: text, PERSONALITY_PROMPT='Personnalité',
+                         conversation_history=history, search_web=AsyncMock(return_value=None),
+                         WEB_SEARCH_ENABLED=True, MODEL='test', TEMPERATURE=1, logger=Mock(),
+                         openai_client=SimpleNamespace(chat=SimpleNamespace(
+                             completions=SimpleNamespace(create=create))),
+                         add_to_conversation_history=AsyncMock())
+        exec(compile(ast.Module(body=[function], type_ignores=[]), '<api>', 'exec'), namespace)
+        await namespace['call_openai_api']('Bonjour', 'Alice', voice=True)
+        args = create.call_args.kwargs
+        self.assertEqual(args['messages'][1:-1], history[-5:])
+        self.assertEqual(args['extra_body']['options']['think'], False)
+        self.assertIn('une à trois phrases', args['messages'][0]['content'])
+        self.assertIn('search_web', args['messages'][0]['content'])
+        await namespace['call_openai_api']('Bonjour', 'Alice')
+        args = create.call_args.kwargs
+        self.assertEqual(args['messages'][1:-1], history)
+        self.assertNotIn('extra_body', args)
+
+    async def test_status_connected_and_disconnected(self):
+        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
+        function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
+                        and n.name == 'handle_status')
+        namespace = dict(BOT_VERSION='test', MODEL='gemma', discord=discord)
+        exec(compile(ast.Module(body=[function], type_ignores=[]), '<status>', 'exec'), namespace)
+        message = SimpleNamespace(guild=None, channel=SimpleNamespace(send=AsyncMock()))
+        await namespace['handle_status'](message)
+        self.assertIn('déconnecté', message.channel.send.call_args.args[0])
+        sink = SimpleNamespace(responding=True, queue=asyncio.Queue(), pending=[1, 2],
+                               last_stt_seconds=0.5, last_reply_seconds=None)
+        message.guild = SimpleNamespace(voice_client=SimpleNamespace(sink=sink, is_connected=lambda: True))
+        await namespace['handle_status'](message)
+        text = message.channel.send.call_args.args[0]
+        self.assertIn('transcriptions en attente : 2', text)
+        self.assertIn('0.5 s', text)
+        self.assertIn('pas encore mesuré', text)
+
     async def test_join_replies_in_command_channel_and_quit_closes_sink(self):
         # Charger uniquement la fonction : aucun token ni connexion Discord.
         tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))

+ 22 - 0
voice_stt.py

@@ -29,6 +29,10 @@ class STTSink(voice_recv.AudioSink):
         self.buffers = {}
         self.decoders = {}
         self.closed = False
+        self.responding = False
+        self.last_stt_seconds = None
+        self.last_reply_seconds = None
+        self.last_voice = 0.0
         self.last_error = 0
         self.stats_lock = threading.Lock()
         self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
@@ -132,6 +136,7 @@ class STTSink(voice_recv.AudioSink):
         buffer[1].extend(pcm[:available])
         if audible:
             buffer[2] = now
+            self.last_voice = now
 
     async def flush_phrases(self):
         while True:
@@ -181,6 +186,7 @@ class STTSink(voice_recv.AudioSink):
                     )
                     response.raise_for_status()
                     text = response.json()['text'].strip()
+                    self.last_stt_seconds = time.monotonic() - started
                     logger.info("Retour STT : HTTP=%d durée=%.2fs caractères=%d",
                                 response.status_code, time.monotonic() - started, len(text))
                     if not text:
@@ -207,16 +213,32 @@ class STTSink(voice_recv.AudioSink):
     async def respond_batches(self):
         while not self.closed:
             await self.pending_event.wait()
+            # L'affichage STT continue ; seule la réponse attend le silence global.
+            while not self.closed:
+                await self.queue.join()
+                with self.lock:
+                    quiet = time.monotonic() - self.last_voice > 2.0
+                    buffered = bool(self.buffers)
+                if quiet and not buffered and self.queue.empty():
+                    break
+                await asyncio.sleep(0.1)
+            if self.closed:
+                return
             self.pending_event.clear()
             batch, self.pending = self.pending, []
             if not batch:
                 continue
+            started = time.monotonic()
+            self.responding = True
             try:
                 await self.on_phrase(batch)
             except asyncio.CancelledError:
                 raise
             except Exception as exc:
                 logger.warning("Réponse au lot vocal impossible : %s", type(exc).__name__)
+            finally:
+                self.last_reply_seconds = time.monotonic() - started
+                self.responding = False
 
     @voice_recv.AudioSink.listener()
     def on_voice_member_disconnect(self, member, ssrc):