diff --git a/app.py b/app.py index 6e58775..9e23314 100644 --- a/app.py +++ b/app.py @@ -17,9 +17,11 @@ from memory import ( 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, @@ -72,9 +74,7 @@ Actions disponibles : {"action": "terminer_tache", "id": 123} {"action": "ajouter_repo", "nom": "...", "projet": "...", "chemin": "...", "url": "...", "branche": "..."} {"action": "ajouter_note", "contenu": "...", "sujet": "...", "client": "...", "projet": "...", "type": "note"} -{"action": "chercher_memoire", "requete": "..."} -{"action": "lister_clients"} -{"action": "analyser_taches", "type": "client_plus_taches"} +{"action": "interroger_base", "sql": "SELECT ...", "question": "..."} Si une phrase contient plusieurs choses a enregistrer, utilise : {"actions": [{...}, {...}]} @@ -110,16 +110,30 @@ Actions disponibles : - 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"} +- 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. -- Si une action de lecture convient mieux qu'une reponse libre, type="action". +- 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". @@ -127,9 +141,10 @@ Regles : - 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. +- 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. - Si l'utilisateur dit "lui", "ce client", "ce projet" ou "cette tache", utilise le contexte de conversation en cours. -- Si l'utilisateur demande "que dois-je faire pour lui ?", "c'est quoi comme tache ?" ou "quelle est la tache ?", utilise chercher_memoire avec le dernier client/projet connu. +- Si l'utilisateur demande "que dois-je faire pour lui ?", "c'est quoi comme tache ?" ou "quelle est la tache ?", utilise interroger_base avec le dernier client/projet connu. """ @@ -242,7 +257,9 @@ ACTIONS = { "ajouter_note", "chercher_memoire", "lister_clients", + "lister_taches", "analyser_taches", + "interroger_base", } @@ -819,6 +836,14 @@ def executer_action(action): "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) @@ -836,6 +861,17 @@ def executer_action(action): "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 "", {} @@ -889,8 +925,12 @@ def mettre_a_jour_contexte(session_id, texte, reponse, flags): 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", []) @@ -944,6 +984,64 @@ def formater_liste_clients(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"] @@ -986,20 +1084,6 @@ def traiter_message(session_id, texte, avec_audio=False): "memoryRead": flags["memoryRead"], } - action_suivi = detecter_suivi_contexte(session_id, texte) - if action_suivi: - reponse, flags = executer_actions(action_suivi) - 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, @@ -1039,6 +1123,17 @@ def traiter_message(session_id, texte, avec_audio=False): } +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") diff --git a/memory.py b/memory.py index d16cc87..7c78166 100644 --- a/memory.py +++ b/memory.py @@ -4,6 +4,22 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent +ALLOWED_READ_TABLES = {"clients", "projects", "tasks", "repositories", "memories"} +FORBIDDEN_SQL_WORDS = { + "insert", + "update", + "delete", + "drop", + "alter", + "create", + "replace", + "truncate", + "attach", + "detach", + "pragma", + "vacuum", + "reindex", +} def get_memory_db(): @@ -85,6 +101,50 @@ def row_to_dict(row): return dict(row) if row else None +def execute_read_query(sql, limit=100): + sql = normalize_read_sql(sql) + limited_sql = f"SELECT * FROM ({sql}) AS readonly_query LIMIT ?" + + with connect() as db: + db.set_authorizer(readonly_authorizer) + rows = db.execute(limited_sql, (limit,)).fetchall() + return [row_to_dict(row) for row in rows] + + +def normalize_read_sql(sql): + sql = clean_text(sql).rstrip(";") + sql_lower = sql.lower() + if not sql_lower.startswith("select "): + raise ValueError("Seules les requetes SELECT sont autorisees") + if ";" in sql: + raise ValueError("Une seule requete SELECT est autorisee") + if "--" in sql or "/*" in sql or "*/" in sql: + raise ValueError("Les commentaires SQL ne sont pas autorises") + for word in FORBIDDEN_SQL_WORDS: + if re_word_match(sql_lower, word): + raise ValueError(f"Mot SQL interdit: {word}") + return sql + + +def re_word_match(text, word): + import re + + return re.search(rf"\b{re.escape(word)}\b", text) is not None + + +def readonly_authorizer(action, arg1, arg2, db_name, trigger_name): + if action == sqlite3.SQLITE_SELECT: + return sqlite3.SQLITE_OK + if action == sqlite3.SQLITE_READ: + table_name = arg1 or "" + if table_name in ALLOWED_READ_TABLES: + return sqlite3.SQLITE_OK + return sqlite3.SQLITE_DENY + if action == sqlite3.SQLITE_FUNCTION: + return sqlite3.SQLITE_OK + return sqlite3.SQLITE_DENY + + def find_client(db, name): if not name: return None @@ -229,6 +289,24 @@ def list_clients(limit=100): return [row_to_dict(row) for row in rows] +def list_open_tasks(limit=100): + with connect() as db: + rows = db.execute( + """ + SELECT t.*, c.name AS client_name, p.name AS project_name + FROM tasks t + LEFT JOIN clients c ON c.id = t.client_id + LEFT JOIN projects p ON p.id = t.project_id + WHERE t.done = 0 + AND (c.id IS NULL OR c.status != 'inactif') + ORDER BY COALESCE(c.name, p.name, 'zzzz') COLLATE NOCASE, t.created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [row_to_dict(row) for row in rows] + + def get_memory_snapshot(limit=25): with connect() as db: clients = db.execute(