diff --git a/app.py b/app.py index de75154..1d332bf 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ from memory import ( add_repository, add_task, close_task, + deactivate_client, get_memory_snapshot, init_db, list_clients, @@ -65,6 +66,7 @@ Tu disposes aussi d'une memoire professionnelle persistante pour les clients, pr Quand l'utilisateur te demande de noter, memoriser, ajouter ou associer une information professionnelle, reponds uniquement avec un JSON. Actions disponibles : {"action": "ajouter_client", "nom": "...", "notes": "...", "statut": "actif"} +{"action": "supprimer_client", "nom": "..."} {"action": "ajouter_projet", "nom": "...", "client": "...", "statut": "...", "resume": "...", "prochaine_action": "..."} {"action": "ajouter_tache", "titre": "...", "client": "...", "projet": "...", "details": "...", "echeance": "..."} {"action": "terminer_tache", "id": 123} @@ -84,6 +86,50 @@ Pour tout le reste, reponds en francais naturel, avec des phrases courtes. N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement. En mode voiture, sois bref : une ou deux phrases maximum.""" +ROUTER_PROMPT = """Tu es le routeur d'intention de l'assistant personnel de Laurent. +Ton role est de comparer le message utilisateur avec la liste des actions disponibles et le contexte fourni. + +Reponds uniquement avec un JSON valide, sans markdown. + +Format obligatoire : +{ + "type": "action" | "answer" | "clarification", + "confidence": 0.0, + "action": null | {"action": "..."} | {"actions": [{"action": "..."}]}, + "reply": "", + "question": "" +} + +Actions disponibles : +- envoyer_email: {"action":"envoyer_email","destinataire":"...","sujet":"...","corps":"..."} +- ajouter_contact: {"action":"ajouter_contact","nom":"...","email":"..."} +- ajouter_client: {"action":"ajouter_client","nom":"...","notes":"...","statut":"actif"} +- supprimer_client: {"action":"supprimer_client","nom":"..."} +- ajouter_projet: {"action":"ajouter_projet","nom":"...","client":"...","statut":"...","resume":"...","prochaine_action":"..."} +- ajouter_tache: {"action":"ajouter_tache","titre":"...","client":"...","projet":"...","details":"...","echeance":"..."} +- terminer_tache: {"action":"terminer_tache","id":123} +- ajouter_repo: {"action":"ajouter_repo","nom":"...","projet":"...","chemin":"...","url":"...","branche":"..."} +- ajouter_note: {"action":"ajouter_note","contenu":"...","sujet":"...","client":"...","projet":"...","type":"note"} +- chercher_memoire: {"action":"chercher_memoire","requete":"..."} +- lister_clients: {"action":"lister_clients"} +- analyser_taches: {"action":"analyser_taches","type":"client_plus_taches"} + +Regles : +- Choisis une action seulement si l'intention et les arguments sont clairs. +- Si plusieurs actions sont necessaires, utilise {"actions":[...]}. +- Si l'utilisateur demande une modification mais que la cible est ambigue, type="clarification". +- Si l'utilisateur pose une question generale et que le contexte suffit, type="answer" et reponds directement. +- Si une action de lecture convient mieux qu'une reponse libre, type="action". +- Si aucune action ne correspond, type="answer". +- confidence >= 0.80 pour executer une action sans confirmation. +- confidence entre 0.45 et 0.79 : type="clarification". +- N'invente pas d'information absente du contexte. Si la demande porte sur une categorie inconnue, demande une precision. +- Pour "nouveaux clients", ne suppose pas que tous les clients sont nouveaux. Demande si Laurent veut les derniers ajoutes, tous les clients, ou une notion precise de nouveau. +- Pour supprimer/terminer une tache, utilise l'id si le contexte de conversation contient les dernieres taches affichees. +- Pour retirer/enlever/supprimer un client de la liste, utilise supprimer_client. +- Pour "quel client a le plus de taches", utilise analyser_taches. +""" + def charger_contacts(): try: @@ -186,6 +232,7 @@ ACTIONS = { "envoyer_email", "ajouter_contact", "ajouter_client", + "supprimer_client", "ajouter_projet", "ajouter_tache", "terminer_tache", @@ -242,6 +289,99 @@ def demander_au_llm(session_id, texte): return contenu +def router_intention(session_id, texte): + historique = histories.get(session_id, [])[-12:] + messages = [ + {"role": "system", "content": ROUTER_PROMPT}, + { + "role": "system", + "content": ( + "Contexte disponible pour router la demande :\n" + + construire_contexte_routeur(session_id) + ), + }, + ] + historique + [{"role": "user", "content": texte}] + + reponse = client.chat.completions.create( + model=CHAT_MODEL, + messages=messages, + temperature=0.0, + max_tokens=700, + ) + contenu = reponse.choices[0].message.content or "" + data = extraire_json_routeur(contenu) + if not data: + return { + "type": "answer", + "confidence": 0.0, + "action": None, + "reply": contenu.strip() or "Je n'ai pas compris. Tu peux reformuler ?", + "question": "", + } + return normaliser_decision_routeur(data) + + +def construire_contexte_routeur(session_id=None): + contacts = charger_contacts() + contacts_connus = "\n".join(f"- {nom.title()} : {email}" for nom, email in contacts.items()) + return ( + "Contacts email connus :\n" + + (contacts_connus or "- aucun") + + "\n\nMemoire professionnelle actuelle :\n" + + construire_contexte_memoire(session_id) + ) + + +def extraire_json_routeur(contenu): + decodeur = json.JSONDecoder() + for index, caractere in enumerate(contenu): + if caractere != "{": + continue + try: + data, _ = decodeur.raw_decode(contenu[index:]) + except json.JSONDecodeError: + continue + if isinstance(data, dict): + return data + return None + + +def normaliser_decision_routeur(data): + type_decision = data.get("type") or "answer" + if type_decision not in {"action", "answer", "clarification"}: + type_decision = "answer" + + try: + confidence = float(data.get("confidence", 0)) + except (TypeError, ValueError): + confidence = 0.0 + confidence = max(0.0, min(1.0, confidence)) + + action = data.get("action") + if isinstance(action, dict) and not action_valide(action): + action = None + + if type_decision == "action" and (confidence < 0.80 or not action): + type_decision = "clarification" + + return { + "type": type_decision, + "confidence": confidence, + "action": action, + "reply": str(data.get("reply") or "").strip(), + "question": str(data.get("question") or "").strip(), + } + + +def action_valide(action): + if action.get("action") in ACTIONS: + return True + actions = action.get("actions") + if isinstance(actions, list) and actions: + return all(isinstance(item, dict) and item.get("action") in ACTIONS for item in actions) + return False + + def detecter_action_directe(texte, session_id=None): texte_nettoye = texte.strip() texte_min = texte_nettoye.lower() @@ -260,6 +400,10 @@ def detecter_action_directe(texte, session_id=None): ] } + suppression_client = detecter_suppression_client(texte_nettoye) + if suppression_client: + return suppression_client + analyse_taches = detecter_analyse_taches(texte_nettoye) if analyse_taches: return analyse_taches @@ -369,6 +513,26 @@ def detecter_tache_client(texte): } +def detecter_suppression_client(texte): + if not re.search(r"\b(enleve|enlève|supprime|retire|efface|desactive|désactive)\b", texte, flags=re.IGNORECASE): + return None + if re.search(r"\btaches?\b|\bprojets?\b|\brepos?\b|\bdepots?\b|\bdépôts?\b", texte, flags=re.IGNORECASE): + return None + + match = re.search( + r"\b(?:enleve|enlève|supprime|retire|efface|desactive|désactive)\s+(?:le\s+client\s+|client\s+)?(.+?)(?:\s+(?:de|dans)\s+(?:la\s+)?liste.*)?$", + texte, + flags=re.IGNORECASE, + ) + if not match: + return None + + nom = nettoyer_nom_client(match.group(1)) + if not nom: + return None + return {"action": "supprimer_client", "nom": nom} + + def detecter_recherche_taches(texte): if re.search( r"\b(pour\s+quel\s+client|quel\s+client|plus\s+de\s+taches?|plus\s+de\s+tâches?|combien|classement|top)\b", @@ -496,6 +660,12 @@ def separer_noms_clients(texte): def nettoyer_nom_client(nom): nom = nom.strip(" -\t\r.,;:") + nom = re.sub( + r"\s+\b(je|j'ai|j ai|stp|svp|s'il te plait|s'il te plaît|merci)\b.*$", + "", + nom, + flags=re.IGNORECASE, + ).strip(" -\t\r.,;:") if not nom or len(nom) < 2: return "" if re.search(r"\b(liste|clients?|voici|note|notes|noter|quelque|part)\b", nom, flags=re.IGNORECASE): @@ -580,6 +750,13 @@ def executer_action(action): ) return f"C'est note : {client_mem['name']} est dans tes clients.", {"memorySaved": True} + if nom_action == "supprimer_client": + client_mem = deactivate_client(action.get("nom", "")) + nom = action.get("nom", "").strip() + if not client_mem: + return f"Je n'ai pas trouve le client {nom} dans ta liste.", {"memoryRead": True} + return f"C'est fait : {client_mem['name']} n'apparaitra plus dans tes clients actifs.", {"memorySaved": True} + if nom_action == "ajouter_projet": projet = upsert_project( action.get("nom", ""), @@ -695,11 +872,7 @@ def executer_actions(action_data): def mettre_a_jour_contexte(session_id, texte, reponse, flags): - historique = histories.setdefault(session_id, []) - if not historique or historique[-1].get("content") != texte: - historique.append({"role": "user", "content": texte}) - historique.append({"role": "assistant", "content": reponse}) - del historique[:-20] + ajouter_echange_historique(session_id, texte, reponse) resultat = flags.get("memoryResult") if not resultat: @@ -717,6 +890,14 @@ def mettre_a_jour_contexte(session_id, texte, reponse, flags): contexte["tasks"] = resultat.get("tasks", []) +def ajouter_echange_historique(session_id, texte, reponse): + historique = histories.setdefault(session_id, []) + if not historique or historique[-1].get("content") != texte: + historique.append({"role": "user", "content": texte}) + historique.append({"role": "assistant", "content": reponse}) + del historique[:-20] + + def formater_memoire(resultat): lignes = [] for client_mem in resultat["clients"][:5]: @@ -787,9 +968,21 @@ def formater_client_plus_taches(snapshot): def traiter_message(session_id, texte, avec_audio=False): - action_directe = detecter_action_directe(texte, session_id) - reponse = json.dumps(action_directe, ensure_ascii=False) if action_directe else demander_au_llm(session_id, texte) - action = extraire_action(reponse) + action_confirmee = recuperer_action_confirmee(session_id, texte) + if action_confirmee: + reponse, flags = executer_actions(action_confirmee) + mettre_a_jour_contexte(session_id, texte, reponse, flags) + audio_url = generer_audio(reponse) if avec_audio and TTS_PROVIDER == "groq" else None + return { + "reply": reponse, + "audioUrl": audio_url, + "emailSent": flags["emailSent"], + "contactSaved": flags["contactSaved"], + "memorySaved": flags["memorySaved"], + "memoryRead": flags["memoryRead"], + } + + decision = router_intention(session_id, texte) flags = { "emailSent": False, "contactSaved": False, @@ -799,9 +992,20 @@ def traiter_message(session_id, texte, avec_audio=False): "memoryQuery": "", } - if action: - reponse, flags = executer_actions(action) + if decision["type"] == "action": + reponse, flags = executer_actions(decision["action"]) mettre_a_jour_contexte(session_id, texte, reponse, flags) + elif decision["type"] == "clarification": + reponse = decision["question"] or decision["reply"] or "Tu peux préciser ce que tu veux que je fasse ?" + if decision.get("action"): + session_contexts.setdefault(session_id, {})["pending_action"] = decision["action"] + ajouter_echange_historique(session_id, texte, reponse) + else: + reponse = decision["reply"] + if not reponse: + reponse = demander_au_llm(session_id, texte) + else: + ajouter_echange_historique(session_id, texte, reponse) audio_url = None if avec_audio and TTS_PROVIDER == "groq": @@ -817,6 +1021,22 @@ def traiter_message(session_id, texte, avec_audio=False): } +def recuperer_action_confirmee(session_id, texte): + contexte = session_contexts.get(session_id, {}) + pending_action = contexte.get("pending_action") + if not pending_action: + return None + + texte_min = texte.strip().lower() + if re.fullmatch(r"(oui|yes|ok|d'accord|vas-y|confirme|c'est ca|c'est ça|exact|tout a fait|tout à fait)[\s.!?]*", texte_min): + contexte.pop("pending_action", None) + return pending_action + if re.fullmatch(r"(non|no|annule|stop|laisse tomber|pas ca|pas ça)[\s.!?]*", texte_min): + contexte.pop("pending_action", None) + return None + return None + + @app.get("/") def index(): return send_from_directory(STATIC_DIR, "index.html") diff --git a/memory.py b/memory.py index 61ff53c..d16cc87 100644 --- a/memory.py +++ b/memory.py @@ -132,6 +132,26 @@ def upsert_client(name, notes="", status="actif"): return row_to_dict(db.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()) +def deactivate_client(name): + name = clean_text(name) + if not name: + raise ValueError("Nom de client manquant") + + with connect() as db: + row = find_client(db, name) + if not row: + return None + db.execute( + """ + UPDATE clients + SET status = 'inactif', updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (row["id"],), + ) + return row_to_dict(row) + + def upsert_project(name, client_name="", status="actif", summary="", next_action=""): name = clean_text(name) if not name: @@ -198,7 +218,12 @@ def add_task(title, client_name="", project_name="", details="", due_at=""): def list_clients(limit=100): with connect() as db: rows = db.execute( - "SELECT * FROM clients ORDER BY name COLLATE NOCASE LIMIT ?", + """ + SELECT * FROM clients + WHERE status != 'inactif' + ORDER BY name COLLATE NOCASE + LIMIT ? + """, (limit,), ).fetchall() return [row_to_dict(row) for row in rows] @@ -212,6 +237,7 @@ def get_memory_snapshot(limit=25): COUNT(t.id) AS open_task_count FROM clients c LEFT JOIN tasks t ON t.client_id = c.id AND t.done = 0 + WHERE c.status != 'inactif' GROUP BY c.id ORDER BY c.name COLLATE NOCASE LIMIT ?