Selaa lähdekoodia

feat: tool calling — détection d'intention naturelle

L'utilisateur peut maintenant parler naturellement sans slash commands :
- 'quel temps fait-il ?' → météo réelle
- 'les news du jour' → actualités
- 'cherche X sur le web' → recherche web
- 'état du serveur' → monitoring
- 'mon agenda' → Google Calendar
- 'mes mails' → Gmail
- 'rappelle-moi dans 30m de ...' → rappel créé
Brin 1 kuukausi sitten
vanhempi
sitoutus
324ef966a4
1 muutettua tiedostoa jossa 133 lisäystä ja 5 poistoa
  1. 133 5
      bot.py

+ 133 - 5
bot.py

@@ -265,6 +265,129 @@ async def detect_agent(message: str) -> str:
         pass
     return "konan"
 
+# ─── Détection d'intention (tool calling) ─────────────────────────────────────
+
+import re as _re
+
+async def detect_intent(text: str) -> dict:
+    prompt = (
+        "Analyse ce message en français et réponds UNIQUEMENT avec un objet JSON sur une seule ligne.\n\n"
+        "Intents possibles :\n"
+        '{"intent":"meteo","city":"NomVille"}         — météo (city=null si non précisé)\n'
+        '{"intent":"news","category":"tech"}          — actualités (tech/sport/politique/general)\n'
+        '{"intent":"recherche","query":"..."}         — recherche web\n'
+        '{"intent":"monitoring"}                      — état serveur CPU/RAM/disque\n'
+        '{"intent":"calendrier"}                      — agenda Google Calendar\n'
+        '{"intent":"mails"}                           — mails Gmail non lus\n'
+        '{"intent":"rappel","time":"30m","message":"texte"} — créer un rappel\n'
+        '{"intent":"conversation"}                    — tout le reste\n\n'
+        f'Message : "{text}"\n\nJSON :'
+    )
+    try:
+        async with httpx.AsyncClient(timeout=15) as client:
+            resp = await client.post(OLLAMA_URL, json={
+                "model": "qwen2.5:3b",
+                "messages": [{"role": "user", "content": prompt}],
+                "stream": False,
+                "options": {"num_predict": 60, "temperature": 0}
+            })
+            raw = resp.json()["message"]["content"].strip()
+            match = _re.search(r'\{[^}]+\}', raw)
+            if match:
+                return json.loads(match.group())
+    except Exception:
+        pass
+    return {"intent": "conversation"}
+
+async def execute_intent(intent_data: dict, user_id: int, update, context) -> bool:
+    """Exécute l'intention détectée. Retourne True si traitée, False pour conversation."""
+    intent = intent_data.get("intent", "conversation")
+
+    if intent == "meteo":
+        city = intent_data.get("city") or DEFAULT_CITY
+        result = await get_weather(city)
+        await update.message.reply_text(result)
+        return True
+
+    elif intent == "news":
+        category = intent_data.get("category", "tech")
+        if category not in NEWS_FEEDS:
+            category = "tech"
+        result = get_news(category)
+        await update.message.reply_text(result, parse_mode="Markdown")
+        return True
+
+    elif intent == "recherche":
+        query = intent_data.get("query", "")
+        if not query:
+            return False
+        result = web_search(query)
+        await update.message.reply_text(result, parse_mode="Markdown")
+        return True
+
+    elif intent == "monitoring":
+        status = get_server_status()
+        await update.message.reply_text(status, parse_mode="Markdown")
+        return True
+
+    elif intent == "calendrier":
+        events = get_calendar_events()
+        if events is None:
+            await update.message.reply_text("Google non connecté. Lance /google_connect")
+        elif not events:
+            await update.message.reply_text("Aucun événement à venir.")
+        else:
+            msg = "📅 *Prochains événements :*\n\n"
+            for event in events:
+                start = event["start"].get("dateTime", event["start"].get("date", ""))
+                if "T" in start:
+                    dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
+                    start_str = dt.strftime("%d/%m %H:%M")
+                else:
+                    start_str = start
+                msg += f"• *{event.get('summary', 'Sans titre')}*\n  📅 {start_str}\n\n"
+            await update.message.reply_text(msg, parse_mode="Markdown")
+        return True
+
+    elif intent == "mails":
+        emails = get_gmail_messages()
+        if emails is None:
+            await update.message.reply_text("Google non connecté. Lance /google_connect")
+        elif not emails:
+            await update.message.reply_text("Aucun mail non lu.")
+        else:
+            msg = "📬 *Mails non lus :*\n\n"
+            for e in emails:
+                msg += f"• *{e['subject']}*\n  De : {e['from'][:50]}\n\n"
+            await update.message.reply_text(msg, parse_mode="Markdown")
+        return True
+
+    elif intent == "rappel":
+        time_str = intent_data.get("time", "")
+        message = intent_data.get("message", "")
+        if not time_str or not message:
+            await update.message.reply_text("Précise l'heure et le message du rappel.")
+            return True
+        target_time = parse_time(time_str)
+        if not target_time:
+            await update.message.reply_text(f"Format de temps non reconnu : {time_str}")
+            return True
+        reminder_id = f"{user_id}_{int(target_time.timestamp())}"
+        bot = context.application.bot
+        scheduler.add_job(
+            send_reminder, "date",
+            run_date=target_time,
+            args=[bot, user_id, message, reminder_id],
+            id=reminder_id, replace_existing=True
+        )
+        reminders = load_reminders()
+        reminders.append({"id": reminder_id, "user_id": user_id, "message": message, "time": target_time.isoformat()})
+        save_reminders(reminders)
+        await update.message.reply_text(f"⏰ Rappel créé : \"{message}\" à {target_time.strftime('%H:%M le %d/%m')}")
+        return True
+
+    return False
+
 # ─── Ollama ───────────────────────────────────────────────────────────────────
 
 async def ask_agent(user_id, message, agent="konan"):
@@ -977,6 +1100,9 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
             return
 
     await update.message.reply_text("⏳")
+    intent_data = await detect_intent(text)
+    if await execute_intent(intent_data, user_id, update, context):
+        return
     agent = await detect_agent(text)
     emoji = AGENT_EMOJIS.get(agent, "🤖")
     await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
@@ -994,11 +1120,13 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
     try:
         text = await transcribe_voice(tmp_path)
         await update.message.reply_text(f"« {text} »")
-        agent = await detect_agent(text)
-        emoji = AGENT_EMOJIS.get(agent, "🤖")
-        await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
-        reply, _ = await ask_agent(user_id, text, agent)
-        await update.message.reply_text(reply)
+        intent_data = await detect_intent(text)
+        if not await execute_intent(intent_data, user_id, update, context):
+            agent = await detect_agent(text)
+            emoji = AGENT_EMOJIS.get(agent, "🤖")
+            await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
+            reply, _ = await ask_agent(user_id, text, agent)
+            await update.message.reply_text(reply)
         audio_path = text_to_audio(reply)
         with open(audio_path, "rb") as audio_file:
             await update.message.reply_voice(audio_file)