Route memory action

This commit is contained in:
2026-04-27 16:37:42 +02:00
parent 57313c002a
commit c5c6de0281
2 changed files with 257 additions and 11 deletions

240
app.py
View File

@@ -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")

View File

@@ -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 ?