import base64 import json import os import re import smtplib import tempfile from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from pathlib import Path from dotenv import load_dotenv from flask import Flask, jsonify, request, send_from_directory from groq import Groq from memory import ( add_memory, add_repository, add_task, close_task, deactivate_client, execute_read_query, get_memory_snapshot, init_db, list_clients, list_open_tasks, search_memory, upsert_client, upsert_project, ) load_dotenv() ROOT = Path(__file__).resolve().parent STATIC_DIR = ROOT / "static" CONTACTS_FILE = Path(os.getenv("CONTACTS_FILE", ROOT / "contacts.json")) GROQ_API_KEY = os.getenv("GROQ_API_KEY", "") GMAIL_EMAIL = os.getenv("GMAIL_EMAIL", "") GMAIL_PASSWORD = os.getenv("GMAIL_PASSWORD", "") CHAT_MODEL = "llama-3.3-70b-versatile" STT_MODEL = "whisper-large-v3-turbo" TTS_MODEL = "canopylabs/orpheus-v1-english" TTS_VOICE = "troy" TTS_PROVIDER = os.getenv("TTS_PROVIDER", "browser").lower() app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="") app.secret_key = os.getenv("APP_SECRET", "dev-secret-change-me") init_db() client = Groq(api_key=GROQ_API_KEY) histories = {} session_contexts = {} SYSTEM_PROMPT = """Tu es l'assistant personnel vocal de Laurent. Tu peux discuter normalement en francais et aider Laurent avec sa memoire professionnelle. Le routage des actions, des lectures SQL et des confirmations est gere avant toi. Si tu recois une question generale, reponds naturellement avec le contexte fourni. Si une information manque pour repondre correctement, demande une precision. 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"} - interroger_base: {"action":"interroger_base","sql":"SELECT ...","question":"..."} Schema SQLite disponible pour interroger_base : - clients(id, name, status, notes, created_at, updated_at) - projects(id, client_id, name, status, summary, next_action, created_at, updated_at) - tasks(id, client_id, project_id, title, details, due_at, done, created_at, updated_at) - repositories(id, project_id, name, local_path, remote_url, main_branch, last_indexed_at, created_at, updated_at) - memories(id, client_id, project_id, kind, subject, content, created_at) Relations : - projects.client_id -> clients.id - tasks.client_id -> clients.id - tasks.project_id -> projects.id - repositories.project_id -> projects.id - memories.client_id -> clients.id - memories.project_id -> projects.id 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. - Pour toute question de lecture ou d'analyse sur la memoire professionnelle, prefere interroger_base avec un SELECT. - Pour interroger_base, SQL doit etre une seule requete SELECT, sans point-virgule, sans PRAGMA, sans ecriture, avec LIMIT si le resultat peut etre long. - 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 interroger_base avec COUNT, GROUP BY, ORDER BY. - Pour "quelles sont mes taches", "mes taches a faire", "taches pour mes clients", utilise interroger_base sur tasks avec clients/projects. - Le dernier message est prioritaire, mais tu dois interpreter les pronoms et references implicites avec toute la conversation fournie. - Si l'utilisateur dit "lui", "le", "la", "ca", "ça", "ce client", "ce projet" ou "cette tache", retrouve l'objet vise dans l'historique recent avant de choisir l'action. - Si tu ne peux pas identifier clairement l'objet vise, type="clarification". - Les questions de suivi courtes doivent etre resolues avec l'historique recent avant de choisir interroger_base, une action directe ou une clarification. """ def charger_contacts(): try: return json.loads(CONTACTS_FILE.read_text(encoding="utf-8")) except FileNotFoundError: return {"laurent": "barontil@hotmail.com"} def sauvegarder_contacts(contacts): CONTACTS_FILE.parent.mkdir(parents=True, exist_ok=True) CONTACTS_FILE.write_text( json.dumps(contacts, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def construire_system_prompt(session_id=None): contacts = charger_contacts() lignes = [f"- {nom.title()} : {email}" for nom, email in contacts.items()] return ( SYSTEM_PROMPT + "\n\nContacts connus :\n" + "\n".join(lignes) + "\n\nMemoire professionnelle actuelle :\n" + construire_contexte_memoire(session_id) ) def construire_contexte_memoire(session_id=None): snapshot = get_memory_snapshot() contexte = session_contexts.get(session_id or "", {}) lignes = [] clients_mem = snapshot["clients"] if clients_mem: lignes.append("Clients connus et taches ouvertes :") for client_mem in clients_mem: lignes.append(f"- {client_mem['name']} : {client_mem['open_task_count']} tache(s) ouverte(s)") else: lignes.append("Aucun client connu.") if snapshot["tasks"]: lignes.append("Taches ouvertes recentes :") for tache in snapshot["tasks"][:15]: sujet = tache.get("client_name") or tache.get("project_name") or "general" lignes.append(f"- #{tache['id']} {sujet} : {tache['title']}") if snapshot["projects"]: lignes.append("Projets recents :") for projet in snapshot["projects"][:10]: client_nom = projet.get("client_name") or "sans client" statut = projet.get("status") or "actif" lignes.append(f"- {projet['name']} ({client_nom}) : {statut}") if snapshot["repositories"]: lignes.append("Depots Git associes :") for repo in snapshot["repositories"][:10]: cible = repo.get("project_name") or "sans projet" chemin = repo.get("local_path") or repo.get("remote_url") or "chemin non renseigne" lignes.append(f"- {repo['name']} -> {cible} : {chemin}") if contexte: lignes.append("Contexte de la conversation en cours :") if contexte.get("last_client"): lignes.append(f"- dernier client mentionne : {contexte['last_client']}") if contexte.get("last_project"): lignes.append(f"- dernier projet mentionne : {contexte['last_project']}") if contexte.get("tasks"): taches_affichees = [] for tache in contexte["tasks"][:5]: titre = tache.get("title") or tache.get("titre") or tache.get("task_title") or tache.get("tache") identifiant = tache.get("id") if titre and identifiant is not None: taches_affichees.append(f"#{identifiant} {titre}") elif titre: taches_affichees.append(str(titre)) if taches_affichees: lignes.append(f"- dernieres taches affichees : {', '.join(taches_affichees)}") lignes.append( "Utilise ce contexte pour repondre aux questions globales. " "Pour modifier la memoire, reponds avec une action JSON." ) return "\n".join(lignes) def resoudre_contact(destinataire): contacts = charger_contacts() valeur = destinataire.strip() return contacts.get(valeur.lower(), valeur) def extraire_action(reponse): decodeur = json.JSONDecoder() for index, caractere in enumerate(reponse): if caractere != "{": continue try: data, _ = decodeur.raw_decode(reponse[index:]) except json.JSONDecodeError: continue if isinstance(data, dict) and (data.get("action") in ACTIONS or isinstance(data.get("actions"), list)): return data return None ACTIONS = { "envoyer_email", "ajouter_contact", "ajouter_client", "supprimer_client", "ajouter_projet", "ajouter_tache", "terminer_tache", "ajouter_repo", "ajouter_note", "chercher_memoire", "lister_clients", "lister_taches", "analyser_taches", "interroger_base", } def ajouter_contact(nom, email): contacts = charger_contacts() nom_normalise = " ".join(nom.strip().lower().split()) email_normalise = email.strip() if not nom_normalise or "@" not in email_normalise: raise ValueError("Contact incomplet") contacts[nom_normalise] = email_normalise sauvegarder_contacts(contacts) return nom_normalise, email_normalise def envoyer_email(destinataire, sujet, corps): destinataire = resoudre_contact(destinataire) msg = MIMEMultipart() msg["From"] = GMAIL_EMAIL msg["To"] = destinataire msg["Subject"] = sujet msg.attach(MIMEText(corps, "plain", "utf-8")) with smtplib.SMTP("smtp.gmail.com", 587) as serveur: serveur.starttls() serveur.login(GMAIL_EMAIL, GMAIL_PASSWORD) serveur.sendmail(GMAIL_EMAIL, destinataire, msg.as_string()) return destinataire def demander_au_llm(session_id, texte): historique = histories.setdefault(session_id, []) historique.append({"role": "user", "content": texte}) del historique[:-20] reponse = client.chat.completions.create( model=CHAT_MODEL, messages=[{"role": "system", "content": construire_system_prompt(session_id)}] + historique, temperature=0.45, max_tokens=900, ) contenu = reponse.choices[0].message.content historique.append({"role": "assistant", "content": contenu}) return contenu def router_intention(session_id, texte): historique = histories.get(session_id, [])[-20:] 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() clients_a_ajouter = extraire_liste_clients(texte_nettoye) if clients_a_ajouter: return { "actions": [ { "action": "ajouter_client", "nom": nom, "notes": "Ajoute depuis une liste fournie par Laurent.", "statut": "actif", } for nom in clients_a_ajouter ] } 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 tache_client = detecter_tache_client(texte_nettoye) if tache_client: return tache_client recherche_taches = detecter_recherche_taches(texte_nettoye) if recherche_taches: return recherche_taches suppression_tache = detecter_suppression_tache(texte_nettoye, session_id) if suppression_tache: return suppression_tache recherche_match = re.search( r"(?:qu(?:'| )?est[- ]?ce que tu sais sur|que sais[- ]?tu sur|cherche dans la memoire|memoire sur)\s+(.+)", texte_nettoye, flags=re.IGNORECASE, ) if recherche_match: return { "action": "chercher_memoire", "requete": recherche_match.group(1).strip(" ?.,;:"), } if detecter_liste_clients_demandee(texte_min): return {"action": "lister_clients"} if re.search(r"\b(liste|affiche|montre)\b.*\b(projets?|taches?|repos?|depots?)\b", texte_min): return {"action": "chercher_memoire", "requete": ""} repo_match = re.search( r"(?:associe|ajoute|note)\s+(?:le\s+)?repo\s+(\S+)(?:\s+au\s+projet\s+(.+))?", texte_nettoye, flags=re.IGNORECASE, ) if repo_match: chemin_ou_url = repo_match.group(1).strip(" .,;:") projet = (repo_match.group(2) or "").strip(" .,;:") return { "action": "ajouter_repo", "chemin": chemin_ou_url if not chemin_ou_url.startswith(("http://", "https://", "git@")) else "", "url": chemin_ou_url if chemin_ou_url.startswith(("http://", "https://", "git@")) else "", "projet": projet, } client_match = re.search( r"\b([A-Z0-9][A-Z0-9&.\- ]{2,})\s+est\s+(?:un|une)\s+nouveau\s+client\b", texte_nettoye, flags=re.IGNORECASE, ) if not client_match: return None nom_client = client_match.group(1).strip(" .,;:") actions = [ { "action": "ajouter_client", "nom": nom_client, "notes": texte_nettoye, "statut": "actif", } ] rappel_match = re.search( r"(?:je\s+dois\s+|il\s+faut\s+|note\s+(?:quelque\s+part\s+)?que\s+je\s+dois\s+)(rappeler\s+.+)", texte_nettoye, flags=re.IGNORECASE, ) if rappel_match: actions.append( { "action": "ajouter_tache", "titre": rappel_match.group(1).strip(" .,;:"), "client": nom_client, "details": texte_nettoye, } ) return {"actions": actions} def detecter_tache_client(texte): if not re.search(r"\b(note|notes|noter|tache|doit|dois|a faire|à faire)\b", texte, flags=re.IGNORECASE): return None match = re.search( r"\bpour\s+(?:mon\s+client\s+|le\s+client\s+)?([A-Z0-9][A-Z0-9&.\- ]{1,40}?)[,\s]+(?:on\s+)?(?:doit|dois|devra|faut)\s+(.+?)(?:\s+tu\s+peux\s+noter.*|\s+note.*|\s+notes.*|\s*$)", texte, flags=re.IGNORECASE, ) if not match: return None client_nom = nettoyer_nom_client(match.group(1)) titre = nettoyer_titre_tache(match.group(2)) if not client_nom or not titre: return None return { "action": "ajouter_tache", "titre": titre, "client": client_nom, "details": 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", texte, flags=re.IGNORECASE, ): return None match = re.search( r"\b(?:que\s+dois[- ]?je\s+faire|quoi\s+faire|taches?|a faire|à faire)\b.*\b(?:pour|sur)\s+(?:mon\s+client\s+|le\s+client\s+)?([A-Z0-9][A-Z0-9&.\- ]{1,40})", texte, flags=re.IGNORECASE, ) if not match: return None client_nom = nettoyer_nom_client(match.group(1)) if not client_nom: return None return {"action": "chercher_memoire", "requete": client_nom} def detecter_analyse_taches(texte): if not re.search(r"\bclients?\b", texte, flags=re.IGNORECASE): return None if not re.search(r"\b(taches?|tâches?|a faire|à faire|reste)\b", texte, flags=re.IGNORECASE): return None if re.search( r"\b(plus\s+de|le\s+plus|maximum|max|prioritaire|charge|chargé|chargee|chargée)\b", texte, flags=re.IGNORECASE, ): return {"action": "analyser_taches", "type": "client_plus_taches"} return None def detecter_suppression_tache(texte, session_id): if not re.search(r"\b(enleve|enlève|supprime|retire|efface|termine|marque.*faite)\b", texte, flags=re.IGNORECASE): return None if not re.search(r"\btaches?\b", texte, flags=re.IGNORECASE): return None contexte = session_contexts.get(session_id or "", {}) taches = contexte.get("tasks") or [] if not taches: return None client_match = re.search( r"\b(?:pour|sur)\s+(?:mon\s+client\s+|le\s+client\s+)?([A-Z0-9][A-Z0-9&.\- ]{1,40})", texte, flags=re.IGNORECASE, ) client_nom = nettoyer_nom_client(client_match.group(1)) if client_match else contexte.get("last_client", "") candidates = [ tache for tache in taches if not client_nom or (tache.get("client_name") or "").lower() == client_nom.lower() ] if not candidates: candidates = taches if re.search(r"\b(derniere|dernière|dernier|derniere affichee|dernière affichée)\b", texte, flags=re.IGNORECASE): task_id = candidates[-1].get("id") else: task_id = candidates[0].get("id") if len(candidates) == 1 else None if not task_id: return None return {"action": "terminer_tache", "id": task_id} def detecter_liste_clients_demandee(texte_min): if not re.search(r"\bclients?\b", texte_min): return False return bool( re.search(r"\b(liste|affiche|montre|donne)\b.*\bclients?\b", texte_min) or re.search(r"\bclients?\b.*\b(liste|affiche|montre|donne)\b", texte_min) or re.search(r"\b(quels?|qui)\s+sont\b.*\bclients?\b", texte_min) or re.search(r"\btous\s+mes\s+clients?\b", texte_min) or re.search(r"\bmes\s+clients?\b", texte_min) ) def extraire_liste_clients(texte): if not re.search(r"\bclients?\b", texte, flags=re.IGNORECASE): return [] if not re.search(r"\b(voici|liste|note|notes|noter|memorise|ajoute|ajoutes)\b", texte, flags=re.IGNORECASE): return [] lignes = [ligne.strip(" -\t\r.,;") for ligne in texte.splitlines()] candidats = [] apres_entete = False for ligne in lignes: if not ligne: continue ligne_min = ligne.lower() if "client" in ligne_min: apres_entete = True apres_deux_points = ligne.split(":", 1)[1].strip() if ":" in ligne else "" if apres_deux_points: candidats.extend(separer_noms_clients(apres_deux_points)) continue if not apres_entete: continue if re.search(r"\b(note|notes|noter|quelque part|merci|stp|s'il te plait)\b", ligne_min): continue candidats.extend(separer_noms_clients(ligne)) noms = [] deja_vus = set() for candidat in candidats: nom = nettoyer_nom_client(candidat) if not nom: continue cle = nom.lower() if cle not in deja_vus: noms.append(nom) deja_vus.add(cle) return noms def separer_noms_clients(texte): morceaux = re.split(r"[,;/]|\s+\bet\b\s+", texte) return [morceau.strip() for morceau in morceaux if morceau.strip()] 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): return "" if len(nom.split()) > 4: return "" return nom def nettoyer_titre_tache(titre): titre = titre.strip(" -\t\r.,;:?") titre = re.sub(r"^de\s+", "", titre, flags=re.IGNORECASE) titre = re.sub(r"\s+", " ", titre) return titre[:1].upper() + titre[1:] if titre else "" def transcrire_audio(fichier): suffix = Path(fichier.filename or "voice.webm").suffix or ".webm" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: fichier.save(tmp) chemin = tmp.name try: with open(chemin, "rb") as audio: transcription = client.audio.transcriptions.create( model=STT_MODEL, file=audio, language="fr", response_format="text", ) return transcription if isinstance(transcription, str) else transcription.text finally: try: os.remove(chemin) except OSError: pass def generer_audio(texte): texte = texte.strip() if len(texte) > 900: texte = texte[:897] + "..." reponse_audio = client.audio.speech.create( model=TTS_MODEL, voice=TTS_VOICE, input=texte, response_format="wav", speed=1.0, ) with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: chemin = tmp.name try: reponse_audio.write_to_file(chemin) audio = Path(chemin).read_bytes() return "data:audio/wav;base64," + base64.b64encode(audio).decode("ascii") finally: try: os.remove(chemin) except OSError: pass def executer_action(action): nom_action = action.get("action") if nom_action == "envoyer_email": destinataire = envoyer_email(action["destinataire"], action["sujet"], action["corps"]) return f"Email envoye a {destinataire}.", {"emailSent": True} if nom_action == "ajouter_contact": nom, email = ajouter_contact(action["nom"], action["email"]) return f"Contact ajoute : {nom.title()}.", {"contactSaved": True} if nom_action == "ajouter_client": client_mem = upsert_client( action.get("nom", ""), notes=action.get("notes", ""), status=action.get("statut", "actif"), ) 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", ""), client_name=action.get("client", ""), status=action.get("statut", "actif"), summary=action.get("resume", ""), next_action=action.get("prochaine_action", ""), ) return f"C'est note pour le projet {projet['name']}.", {"memorySaved": True} if nom_action == "ajouter_tache": tache = add_task( action.get("titre", ""), client_name=action.get("client", ""), project_name=action.get("projet", ""), details=action.get("details", ""), due_at=action.get("echeance", ""), ) return f"Tache ajoutee : {tache['title']}.", {"memorySaved": True} if nom_action == "terminer_tache": tache = close_task(action.get("id")) if not tache: return "Je n'ai pas retrouve cette tache ouverte.", {"memoryRead": True} contexte = tache.get("client_name") or tache.get("project_name") or "memoire professionnelle" return f"C'est fait : j'ai retire la tache {tache['title']} pour {contexte}.", {"memorySaved": True} if nom_action == "ajouter_repo": repo = add_repository( action.get("nom", ""), project_name=action.get("projet", ""), local_path=action.get("chemin", ""), remote_url=action.get("url", ""), main_branch=action.get("branche", ""), ) return f"Depot associe : {repo['name']}.", {"memorySaved": True} if nom_action == "ajouter_note": note = add_memory( action.get("contenu", ""), kind=action.get("type", "note"), subject=action.get("sujet", ""), client_name=action.get("client", ""), project_name=action.get("projet", ""), ) sujet = note["subject"] or "memoire professionnelle" return f"C'est note dans {sujet}.", {"memorySaved": True} if nom_action == "chercher_memoire": resultat = search_memory(action.get("requete", "")) return formater_memoire(resultat), {"memoryRead": True, "memoryResult": resultat, "memoryQuery": action.get("requete", "")} if nom_action == "lister_clients": clients_mem = list_clients() return formater_liste_clients(clients_mem), { "memoryRead": True, "memoryResult": {"clients": clients_mem, "projects": [], "tasks": [], "repositories": [], "memories": []}, "memoryQuery": "", } if nom_action == "lister_taches": taches = list_open_tasks() return formater_liste_taches(taches), { "memoryRead": True, "memoryResult": {"clients": [], "projects": [], "tasks": taches, "repositories": [], "memories": []}, "memoryQuery": "", } if nom_action == "analyser_taches": if action.get("type") == "client_plus_taches": snapshot = get_memory_snapshot(limit=100) reponse, client_nom = formater_client_plus_taches(snapshot) resultat_client = search_memory(client_nom) if client_nom else None return reponse, { "memoryRead": True, "memoryResult": resultat_client or { "clients": snapshot["clients"], "projects": snapshot["projects"], "tasks": snapshot["tasks"], "repositories": snapshot["repositories"], "memories": [], }, "memoryQuery": "", } if nom_action == "interroger_base": sql = action.get("sql", "") question = action.get("question") or action.get("requete") or "" rows = execute_read_query(sql) reponse = resumer_resultat_sql(question, sql, rows) return reponse, { "memoryRead": True, "memoryResult": {"clients": [], "projects": [], "tasks": rows, "repositories": [], "memories": []}, "memoryQuery": question, } return "", {} def executer_actions(action_data): actions = action_data.get("actions") if not isinstance(actions, list): actions = [action_data] reponses = [] flags = { "emailSent": False, "contactSaved": False, "memorySaved": False, "memoryRead": False, "memoryResult": None, "memoryQuery": "", } actions_executees = [] for action in actions: if not isinstance(action, dict): continue reponse, action_flags = executer_action(action) actions_executees.append(action) if reponse: reponses.append(reponse) for cle in ("emailSent", "contactSaved", "memorySaved", "memoryRead"): flags[cle] = flags[cle] or bool(action_flags.get(cle)) if action_flags.get("memoryResult"): flags["memoryResult"] = action_flags["memoryResult"] flags["memoryQuery"] = action_flags.get("memoryQuery", "") if actions_executees and all(action.get("action") == "ajouter_client" for action in actions_executees): noms = [action.get("nom", "").strip() for action in actions_executees if action.get("nom", "").strip()] if len(noms) > 1: return f"C'est note : {len(noms)} clients ajoutes ({', '.join(noms)}).", flags return " ".join(reponses), flags def mettre_a_jour_contexte(session_id, texte, reponse, flags): ajouter_echange_historique(session_id, texte, reponse) resultat = flags.get("memoryResult") if not resultat: return contexte = session_contexts.setdefault(session_id, {}) if flags.get("memoryQuery"): contexte["last_query"] = flags["memoryQuery"] if resultat.get("clients"): contexte["last_client"] = resultat["clients"][0].get("name", "") elif flags.get("memoryQuery"): contexte["last_client"] = flags["memoryQuery"] elif resultat.get("tasks") and resultat["tasks"][0].get("client_name"): contexte["last_client"] = resultat["tasks"][0].get("client_name", "") if resultat.get("projects"): contexte["last_project"] = resultat["projects"][0].get("name", "") elif resultat.get("tasks") and resultat["tasks"][0].get("project_name"): contexte["last_project"] = resultat["tasks"][0].get("project_name", "") 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]: detail = f"Client : {client_mem['name']}" if client_mem.get("notes"): detail += f" - {client_mem['notes']}" lignes.append(detail) for projet in resultat["projects"][:5]: detail = f"Projet : {projet['name']}" if projet.get("client_name"): detail += f" ({projet['client_name']})" if projet.get("status"): detail += f" - statut : {projet['status']}" if projet.get("next_action"): detail += f" - prochaine action : {projet['next_action']}" lignes.append(detail) for tache in resultat["tasks"][:5]: contexte = tache.get("client_name") or tache.get("project_name") or "general" lignes.append(f"Tache : {tache['title']} ({contexte})") for repo in resultat["repositories"][:5]: chemin = repo.get("local_path") or repo.get("remote_url") or "chemin non renseigne" lignes.append(f"Depot : {repo['name']} - {chemin}") for note in resultat["memories"][:5]: sujet = note.get("subject") or note.get("client_name") or note.get("project_name") or "note" lignes.append(f"Note : {sujet} - {note['content']}") if not lignes: return "Je n'ai rien trouve dans la memoire professionnelle." return "Voici ce que j'ai trouve :\n" + "\n".join(f"- {ligne}" for ligne in lignes[:10]) def formater_liste_clients(clients_mem): if not clients_mem: return "Je n'ai encore aucun client dans la memoire professionnelle." noms = [client_mem["name"] for client_mem in clients_mem] return "Voici tes clients :\n" + "\n".join(f"- {nom}" for nom in noms) def formater_liste_taches(taches): if not taches: return "Tu n'as aucune tache ouverte dans la memoire professionnelle." groupes = {} for tache in taches: cle = tache.get("client_name") or tache.get("project_name") or "General" groupes.setdefault(cle, []).append(tache) lignes = ["Voici tes taches ouvertes :"] for sujet, items in groupes.items(): lignes.append(f"- {sujet} :") for tache in items: lignes.append(f" - #{tache['id']} {tache['title']}") return "\n".join(lignes) def resumer_resultat_sql(question, sql, rows): if not rows: return "Je n'ai rien trouve dans la memoire professionnelle." if not GROQ_API_KEY: return formater_lignes_sql(rows) payload = json.dumps(rows[:50], ensure_ascii=False, indent=2) messages = [ { "role": "system", "content": ( "Tu transformes un resultat SQL en reponse courte en francais pour Laurent. " "Ne mentionne pas SQL. Ne rajoute rien qui n'est pas dans les lignes." ), }, { "role": "user", "content": ( f"Question utilisateur: {question or 'question non fournie'}\n" f"Requete executee: {sql}\n" f"Resultats JSON:\n{payload}" ), }, ] reponse = client.chat.completions.create( model=CHAT_MODEL, messages=messages, temperature=0.2, max_tokens=500, ) return (reponse.choices[0].message.content or "").strip() or formater_lignes_sql(rows) def formater_lignes_sql(rows): lignes = ["Voici ce que j'ai trouve :"] for row in rows[:20]: valeurs = [f"{cle}: {valeur}" for cle, valeur in row.items()] lignes.append("- " + ", ".join(valeurs)) return "\n".join(lignes) def formater_client_plus_taches(snapshot): clients_avec_taches = [ client_mem for client_mem in snapshot["clients"] if int(client_mem.get("open_task_count") or 0) > 0 ] if not clients_avec_taches: return "Tu n'as aucune tache ouverte dans la memoire professionnelle.", "" max_count = max(int(client_mem["open_task_count"]) for client_mem in clients_avec_taches) premiers = [ client_mem["name"] for client_mem in clients_avec_taches if int(client_mem["open_task_count"]) == max_count ] lignes = [ f"{client_mem['name']} : {client_mem['open_task_count']} tache(s)" for client_mem in sorted( clients_avec_taches, key=lambda item: (-int(item["open_task_count"]), item["name"].lower()), ) ] if len(premiers) == 1: intro = f"Le client avec le plus de taches ouvertes est {premiers[0]} avec {max_count} tache(s)." else: intro = f"Les clients avec le plus de taches ouvertes sont {', '.join(premiers)} avec {max_count} tache(s) chacun." return intro + "\n" + "\n".join(f"- {ligne}" for ligne in lignes), premiers[0] def traiter_message(session_id, texte, avec_audio=False): 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, "memorySaved": False, "memoryRead": False, "memoryResult": None, "memoryQuery": "", } 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) ajouter_echange_historique(session_id, texte, reponse) audio_url = None if avec_audio and TTS_PROVIDER == "groq": audio_url = generer_audio(reponse) return { "reply": reponse, "audioUrl": audio_url, "emailSent": flags["emailSent"], "contactSaved": flags["contactSaved"], "memorySaved": flags["memorySaved"], "memoryRead": flags["memoryRead"], } def detecter_lecture_simple(texte): texte_min = texte.strip().lower() if re.search(r"\b(taches?|tâches?)\b", texte_min) and re.search(r"\b(quelles?|liste|affiche|montre|mes|a faire|à faire|ouvertes?)\b", texte_min): if not re.search(r"\b(ajoute|note|notes|supprime|enleve|enlève|retire|termine|marque)\b", texte_min): return {"action": "lister_taches"} if re.search(r"\bclients?\b", texte_min) and re.search(r"\b(qui|quels?|liste|affiche|montre|mes|tous)\b", texte_min): if not re.search(r"\b(ajoute|note|notes|supprime|enleve|enlève|retire|desactive|désactive|nouveaux?)\b", texte_min): return {"action": "lister_clients"} return None def detecter_suivi_contexte(session_id, texte): contexte = session_contexts.get(session_id, {}) dernier_client = contexte.get("last_client") if not dernier_client: return None texte_min = texte.strip().lower() if re.search(r"\b(lui|ce client|pour lui)\b", texte_min) and re.search(r"\b(que dois|quoi faire|a faire|à faire|tache|tâche)\b", texte_min): return {"action": "chercher_memoire", "requete": dernier_client} if re.search(r"\b(c'est quoi|quelle est|quelles sont|montre|detail|détail)\b", texte_min) and re.search(r"\b(tache|tâche|taches|tâches)\b", texte_min): return {"action": "chercher_memoire", "requete": dernier_client} return None 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") @app.get("/health") def health(): return jsonify({"ok": True}) @app.post("/api/chat") def chat(): data = request.get_json(force=True) texte = (data.get("text") or "").strip() session_id = data.get("sessionId") or "default" avec_audio = bool(data.get("withAudio")) if not texte: return jsonify({"error": "Message vide"}), 400 try: return jsonify(traiter_message(session_id, texte, avec_audio)) except Exception as e: print(f"Erreur /api/chat : {e}", flush=True) return jsonify({"error": str(e)}), 500 @app.post("/api/voice") def voice(): fichier = request.files.get("audio") session_id = request.form.get("sessionId") or "default" avec_audio = request.form.get("withAudio", "false").lower() == "true" if not fichier: return jsonify({"error": "Audio manquant"}), 400 try: texte = transcrire_audio(fichier).strip() resultat = traiter_message(session_id, texte, avec_audio=avec_audio) resultat["transcript"] = texte return jsonify(resultat) except Exception as e: print(f"Erreur /api/voice : {e}", flush=True) return jsonify({"error": str(e)}), 500 @app.post("/api/reset") def reset(): data = request.get_json(silent=True) or {} session_id = data.get("sessionId") or "default" histories.pop(session_id, None) session_contexts.pop(session_id, None) return jsonify({"ok": True}) if __name__ == "__main__": app.run( host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=os.getenv("FLASK_DEBUG", "0") == "1", )