SQL router
This commit is contained in:
141
app.py
141
app.py
@@ -17,9 +17,11 @@ from memory import (
|
|||||||
add_task,
|
add_task,
|
||||||
close_task,
|
close_task,
|
||||||
deactivate_client,
|
deactivate_client,
|
||||||
|
execute_read_query,
|
||||||
get_memory_snapshot,
|
get_memory_snapshot,
|
||||||
init_db,
|
init_db,
|
||||||
list_clients,
|
list_clients,
|
||||||
|
list_open_tasks,
|
||||||
search_memory,
|
search_memory,
|
||||||
upsert_client,
|
upsert_client,
|
||||||
upsert_project,
|
upsert_project,
|
||||||
@@ -72,9 +74,7 @@ Actions disponibles :
|
|||||||
{"action": "terminer_tache", "id": 123}
|
{"action": "terminer_tache", "id": 123}
|
||||||
{"action": "ajouter_repo", "nom": "...", "projet": "...", "chemin": "...", "url": "...", "branche": "..."}
|
{"action": "ajouter_repo", "nom": "...", "projet": "...", "chemin": "...", "url": "...", "branche": "..."}
|
||||||
{"action": "ajouter_note", "contenu": "...", "sujet": "...", "client": "...", "projet": "...", "type": "note"}
|
{"action": "ajouter_note", "contenu": "...", "sujet": "...", "client": "...", "projet": "...", "type": "note"}
|
||||||
{"action": "chercher_memoire", "requete": "..."}
|
{"action": "interroger_base", "sql": "SELECT ...", "question": "..."}
|
||||||
{"action": "lister_clients"}
|
|
||||||
{"action": "analyser_taches", "type": "client_plus_taches"}
|
|
||||||
|
|
||||||
Si une phrase contient plusieurs choses a enregistrer, utilise :
|
Si une phrase contient plusieurs choses a enregistrer, utilise :
|
||||||
{"actions": [{...}, {...}]}
|
{"actions": [{...}, {...}]}
|
||||||
@@ -110,16 +110,30 @@ Actions disponibles :
|
|||||||
- terminer_tache: {"action":"terminer_tache","id":123}
|
- terminer_tache: {"action":"terminer_tache","id":123}
|
||||||
- ajouter_repo: {"action":"ajouter_repo","nom":"...","projet":"...","chemin":"...","url":"...","branche":"..."}
|
- ajouter_repo: {"action":"ajouter_repo","nom":"...","projet":"...","chemin":"...","url":"...","branche":"..."}
|
||||||
- ajouter_note: {"action":"ajouter_note","contenu":"...","sujet":"...","client":"...","projet":"...","type":"note"}
|
- ajouter_note: {"action":"ajouter_note","contenu":"...","sujet":"...","client":"...","projet":"...","type":"note"}
|
||||||
- chercher_memoire: {"action":"chercher_memoire","requete":"..."}
|
- interroger_base: {"action":"interroger_base","sql":"SELECT ...","question":"..."}
|
||||||
- lister_clients: {"action":"lister_clients"}
|
|
||||||
- analyser_taches: {"action":"analyser_taches","type":"client_plus_taches"}
|
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 :
|
Regles :
|
||||||
- Choisis une action seulement si l'intention et les arguments sont clairs.
|
- Choisis une action seulement si l'intention et les arguments sont clairs.
|
||||||
- Si plusieurs actions sont necessaires, utilise {"actions":[...]}.
|
- Si plusieurs actions sont necessaires, utilise {"actions":[...]}.
|
||||||
- Si l'utilisateur demande une modification mais que la cible est ambigue, type="clarification".
|
- 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 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".
|
- Si aucune action ne correspond, type="answer".
|
||||||
- confidence >= 0.80 pour executer une action sans confirmation.
|
- confidence >= 0.80 pour executer une action sans confirmation.
|
||||||
- confidence entre 0.45 et 0.79 : type="clarification".
|
- 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 "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 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 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 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",
|
"ajouter_note",
|
||||||
"chercher_memoire",
|
"chercher_memoire",
|
||||||
"lister_clients",
|
"lister_clients",
|
||||||
|
"lister_taches",
|
||||||
"analyser_taches",
|
"analyser_taches",
|
||||||
|
"interroger_base",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -819,6 +836,14 @@ def executer_action(action):
|
|||||||
"memoryQuery": "",
|
"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 nom_action == "analyser_taches":
|
||||||
if action.get("type") == "client_plus_taches":
|
if action.get("type") == "client_plus_taches":
|
||||||
snapshot = get_memory_snapshot(limit=100)
|
snapshot = get_memory_snapshot(limit=100)
|
||||||
@@ -836,6 +861,17 @@ def executer_action(action):
|
|||||||
"memoryQuery": "",
|
"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 "", {}
|
return "", {}
|
||||||
|
|
||||||
|
|
||||||
@@ -889,8 +925,12 @@ def mettre_a_jour_contexte(session_id, texte, reponse, flags):
|
|||||||
contexte["last_client"] = resultat["clients"][0].get("name", "")
|
contexte["last_client"] = resultat["clients"][0].get("name", "")
|
||||||
elif flags.get("memoryQuery"):
|
elif flags.get("memoryQuery"):
|
||||||
contexte["last_client"] = flags["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"):
|
if resultat.get("projects"):
|
||||||
contexte["last_project"] = resultat["projects"][0].get("name", "")
|
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", [])
|
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)
|
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):
|
def formater_client_plus_taches(snapshot):
|
||||||
clients_avec_taches = [
|
clients_avec_taches = [
|
||||||
client_mem for client_mem in snapshot["clients"]
|
client_mem for client_mem in snapshot["clients"]
|
||||||
@@ -986,20 +1084,6 @@ def traiter_message(session_id, texte, avec_audio=False):
|
|||||||
"memoryRead": flags["memoryRead"],
|
"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)
|
decision = router_intention(session_id, texte)
|
||||||
flags = {
|
flags = {
|
||||||
"emailSent": False,
|
"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):
|
def detecter_suivi_contexte(session_id, texte):
|
||||||
contexte = session_contexts.get(session_id, {})
|
contexte = session_contexts.get(session_id, {})
|
||||||
dernier_client = contexte.get("last_client")
|
dernier_client = contexte.get("last_client")
|
||||||
|
|||||||
78
memory.py
78
memory.py
@@ -4,6 +4,22 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
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():
|
def get_memory_db():
|
||||||
@@ -85,6 +101,50 @@ def row_to_dict(row):
|
|||||||
return dict(row) if row else None
|
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):
|
def find_client(db, name):
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
@@ -229,6 +289,24 @@ def list_clients(limit=100):
|
|||||||
return [row_to_dict(row) for row in rows]
|
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):
|
def get_memory_snapshot(limit=25):
|
||||||
with connect() as db:
|
with connect() as db:
|
||||||
clients = db.execute(
|
clients = db.execute(
|
||||||
|
|||||||
Reference in New Issue
Block a user