diff --git a/.env.example b/.env.example index e90608e..3419ae3 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ GMAIL_PASSWORD=your_gmail_app_password APP_SECRET=change_me_to_a_long_random_string TTS_PROVIDER=browser CONTACTS_FILE=/root/openbot/data/contacts.json +MEMORY_DB=/root/openbot/data/memory.sqlite diff --git a/DEPLOY_NOTES.md b/DEPLOY_NOTES.md index c2987ea..62f2fb3 100644 --- a/DEPLOY_NOTES.md +++ b/DEPLOY_NOTES.md @@ -28,7 +28,8 @@ Derniers changements locaux a deployer: - Voix navigateur adoucie avec preference pour voix feminine francaise. - Ajout de contacts email par conversation. - Carnet de contacts prod dans `/root/openbot/data/contacts.json`. -- Cache PWA: opensquared-assistant-v6. +- Memoire professionnelle SQLite dans `/root/openbot/data/memory.sqlite`. +- Cache PWA: opensquared-assistant-v7. ``` ## VPS @@ -182,6 +183,8 @@ Correctif applique: - La version v6 privilegie une voix feminine francaise si disponible sur l'appareil. - Les contacts peuvent etre ajoutes en disant par exemple: `Ajoute Sophie avec l'adresse sophie@example.com`. - Le service systemd definit `CONTACTS_FILE=/root/openbot/data/contacts.json`. +- La version v7 ajoute une memoire professionnelle SQLite pour clients, projets, taches, notes et depots Git. +- Le service systemd definit `MEMORY_DB=/root/openbot/data/memory.sqlite`. Si le bouton ne reagit pas apres un deploiement: @@ -191,3 +194,71 @@ Android: fermer l'app, rouvrir Chrome, ou desinstaller/reinstaller la PWA ``` Si ca persiste, ouvrir F12 -> Console et lire l'erreur JS. + +## Fin de session - 2026-04-27 + +Etat fonctionnel vise: + +- L'app ne depend plus d'une session Putty ouverte. +- `systemd` gere le processus `opensquared-assistant`. +- Gunicorn lance `app:app` depuis `/root/openbot`. +- Le bouton `Demarrer` active une ecoute mains libres. +- Une phrase est transmise automatiquement apres environ 2 secondes de silence. +- La reponse est lue avec une voix navigateur francaise, adoucie et preferee feminine si disponible. +- Les contacts email peuvent etre ajoutes par conversation. +- Les clients, projets, taches, notes et depots Git associes peuvent etre memorises en SQLite. + +Commits a avoir sur le VPS: + +```text +f876b3a Add hands-free PWA mode and systemd service +94e14a3 Add soft French voice and contact saving +``` + +Checklist VPS: + +```bash +cd /root/openbot +git pull +cp deploy/opensquared-assistant.service /etc/systemd/system/opensquared-assistant.service +systemctl daemon-reload +systemctl enable --now opensquared-assistant +install -m 755 deploy/openbot /usr/local/bin/openbot +openbot restart +openbot status +``` + +Commandes utiles: + +```bash +openbot start +openbot stop +openbot restart +openbot logs +systemctl status opensquared-assistant +journalctl -u opensquared-assistant -f +``` + +Variables importantes: + +```text +TTS_PROVIDER=browser +CONTACTS_FILE=/root/openbot/data/contacts.json +MEMORY_DB=/root/openbot/data/memory.sqlite +``` + +Exemples vocaux: + +```text +Ajoute Sophie avec l'adresse sophie@example.com +Envoie un email a Sophie +SAFTCO est un nouveau client, note quelque part que je dois rappeler le directeur +Associe le repo /root/openbot/repos/saftco au projet Portail SAFTCO +Qu'est-ce que tu sais sur SAFTCO ? +``` + +Attention cache mobile: + +- `index.html` charge `/app.js?v=7`. +- Service worker: `opensquared-assistant-v7`. +- Si Android garde l'ancien JS, fermer/rouvrir Chrome ou reinstaller la PWA. diff --git a/README.md b/README.md index d6a6d64..2b0159b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ POC de PWA mobile pour parler a l'assistant depuis un telephone. - Lecture audio automatique de la reponse avec une voix francaise douce cote navigateur. - Envoi d'emails Gmail avec carnet de contacts `contacts.json`. - Ajout de contacts par conversation, par exemple : `Ajoute Sophie avec l'adresse sophie@example.com`. +- Memoire professionnelle SQLite pour clients, projets, taches, notes et depots Git associes. ## Configuration locale @@ -27,6 +28,7 @@ GMAIL_PASSWORD=... APP_SECRET=... TTS_PROVIDER=browser CONTACTS_FILE=/root/openbot/data/contacts.json +MEMORY_DB=/root/openbot/data/memory.sqlite ``` Installer : @@ -141,6 +143,25 @@ Envoie un email a Sophie L'assistant remplacera `Sophie` par son adresse email. +## Memoire professionnelle + +Les informations professionnelles sont stockees dans une base SQLite. Sur le VPS, le service utilise : + +```text +/root/openbot/data/memory.sqlite +``` + +Exemples de phrases : + +```text +SAFTCO est un nouveau client, note quelque part que je dois rappeler le directeur. +Note que le projet Portail SAFTCO est associe au client SAFTCO. +Associe le repo /root/openbot/repos/saftco au projet Portail SAFTCO. +Qu'est-ce que tu sais sur SAFTCO ? +``` + +La memoire peut contenir des clients, projets, taches, notes et depots Git associes a un projet. + ## Notes - Groq Orpheus TTS doit etre accepte dans la console Groq pour fonctionner : diff --git a/app.py b/app.py index 3606295..f406d05 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ import base64 import json import os +import re import smtplib import tempfile from email.mime.multipart import MIMEMultipart @@ -10,6 +11,15 @@ 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, + init_db, + search_memory, + upsert_client, + upsert_project, +) load_dotenv() @@ -29,6 +39,7 @@ 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 = {} @@ -46,6 +57,21 @@ Quand l'utilisateur veut ajouter, memoriser ou modifier un contact email, collec Quand tu as les deux informations, reponds uniquement avec ce JSON : {"action": "ajouter_contact", "nom": "...", "email": "..."} +Tu disposes aussi d'une memoire professionnelle persistante pour les clients, projets, taches, notes et depots Git. +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": "ajouter_projet", "nom": "...", "client": "...", "statut": "...", "resume": "...", "prochaine_action": "..."} +{"action": "ajouter_tache", "titre": "...", "client": "...", "projet": "...", "details": "...", "echeance": "..."} +{"action": "ajouter_repo", "nom": "...", "projet": "...", "chemin": "...", "url": "...", "branche": "..."} +{"action": "ajouter_note", "contenu": "...", "sujet": "...", "client": "...", "projet": "...", "type": "note"} +{"action": "chercher_memoire", "requete": "..."} + +Si une phrase contient plusieurs choses a enregistrer, utilise : +{"actions": [{...}, {...}]} + +Quand l'utilisateur demande ce que tu sais d'un client, d'un projet, d'une tache ou d'un depot, utilise chercher_memoire. + 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.""" @@ -87,11 +113,23 @@ def extraire_action(reponse): data, _ = decodeur.raw_decode(reponse[index:]) except json.JSONDecodeError: continue - if isinstance(data, dict) and data.get("action") in {"envoyer_email", "ajouter_contact"}: + 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", + "ajouter_projet", + "ajouter_tache", + "ajouter_repo", + "ajouter_note", + "chercher_memoire", +} + + def ajouter_contact(nom, email): contacts = charger_contacts() nom_normalise = " ".join(nom.strip().lower().split()) @@ -137,6 +175,78 @@ def demander_au_llm(session_id, texte): return contenu +def detecter_action_directe(texte): + texte_nettoye = texte.strip() + texte_min = texte_nettoye.lower() + + 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 re.search(r"\b(liste|affiche|montre)\b.*\bclients?\b", texte_min): + return {"action": "chercher_memoire", "requete": ""} + + 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 transcrire_audio(fichier): suffix = Path(fichier.filename or "voice.webm").suffix or ".webm" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: @@ -186,20 +296,145 @@ def generer_audio(texte): pass -def traiter_message(session_id, texte, avec_audio=False): - reponse = demander_au_llm(session_id, texte) - action = extraire_action(reponse) - email_sent = False - contact_saved = False +def executer_action(action): + nom_action = action.get("action") - if action and action.get("action") == "envoyer_email": + if nom_action == "envoyer_email": destinataire = envoyer_email(action["destinataire"], action["sujet"], action["corps"]) - reponse = f"Email envoye a {destinataire}." - email_sent = True - elif action and action.get("action") == "ajouter_contact": + return f"Email envoye a {destinataire}.", {"emailSent": True} + + if nom_action == "ajouter_contact": nom, email = ajouter_contact(action["nom"], action["email"]) - reponse = f"Contact ajoute : {nom.title()}." - contact_saved = True + 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 == "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 == "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} + + 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, + } + for action in actions: + if not isinstance(action, dict): + continue + reponse, action_flags = executer_action(action) + if reponse: + reponses.append(reponse) + for cle in flags: + flags[cle] = flags[cle] or bool(action_flags.get(cle)) + + return " ".join(reponses), flags + + +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 traiter_message(session_id, texte, avec_audio=False): + action_directe = detecter_action_directe(texte) + reponse = json.dumps(action_directe, ensure_ascii=False) if action_directe else demander_au_llm(session_id, texte) + action = extraire_action(reponse) + flags = { + "emailSent": False, + "contactSaved": False, + "memorySaved": False, + "memoryRead": False, + } + + if action: + reponse, flags = executer_actions(action) audio_url = None if avec_audio and TTS_PROVIDER == "groq": @@ -208,8 +443,10 @@ def traiter_message(session_id, texte, avec_audio=False): return { "reply": reponse, "audioUrl": audio_url, - "emailSent": email_sent, - "contactSaved": contact_saved, + "emailSent": flags["emailSent"], + "contactSaved": flags["contactSaved"], + "memorySaved": flags["memorySaved"], + "memoryRead": flags["memoryRead"], } diff --git a/deploy/opensquared-assistant.service b/deploy/opensquared-assistant.service index 30342c2..86a3a3d 100644 --- a/deploy/opensquared-assistant.service +++ b/deploy/opensquared-assistant.service @@ -6,6 +6,7 @@ After=network.target WorkingDirectory=/root/openbot EnvironmentFile=/root/openbot/.env Environment=CONTACTS_FILE=/root/openbot/data/contacts.json +Environment=MEMORY_DB=/root/openbot/data/memory.sqlite ExecStart=/root/openbot/.venv/bin/gunicorn -w 2 -b 0.0.0.0:8080 app:app Restart=always RestartSec=3 diff --git a/memory.py b/memory.py new file mode 100644 index 0000000..3d284eb --- /dev/null +++ b/memory.py @@ -0,0 +1,398 @@ +import os +import sqlite3 +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent + + +def get_memory_db(): + return Path(os.getenv("MEMORY_DB", ROOT / "data" / "memory.sqlite")) + + +def connect(): + memory_db = get_memory_db() + memory_db.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(memory_db) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection + + +def init_db(): + with connect() as db: + db.executescript( + """ + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + status TEXT DEFAULT 'actif', + notes TEXT DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + status TEXT DEFAULT 'actif', + summary TEXT DEFAULT '', + next_action TEXT DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + title TEXT NOT NULL, + details TEXT DEFAULT '', + due_at TEXT DEFAULT '', + done INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS repositories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + name TEXT NOT NULL, + local_path TEXT DEFAULT '', + remote_url TEXT DEFAULT '', + main_branch TEXT DEFAULT '', + last_indexed_at TEXT DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(name, local_path, remote_url) + ); + + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + kind TEXT NOT NULL DEFAULT 'note', + subject TEXT DEFAULT '', + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + + +def row_to_dict(row): + return dict(row) if row else None + + +def find_client(db, name): + if not name: + return None + return db.execute( + "SELECT * FROM clients WHERE name = ? COLLATE NOCASE", + (clean_text(name),), + ).fetchone() + + +def find_project(db, name): + if not name: + return None + return db.execute( + "SELECT * FROM projects WHERE name = ? COLLATE NOCASE", + (clean_text(name),), + ).fetchone() + + +def upsert_client(name, notes="", status="actif"): + name = clean_text(name) + notes = clean_text(notes) + status = clean_text(status) or "actif" + if not name: + raise ValueError("Nom de client manquant") + + with connect() as db: + existing = find_client(db, name) + if existing: + merged_notes = merge_notes(existing["notes"], notes) + db.execute( + """ + UPDATE clients + SET status = ?, notes = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (status, merged_notes, existing["id"]), + ) + client_id = existing["id"] + else: + cursor = db.execute( + "INSERT INTO clients (name, status, notes) VALUES (?, ?, ?)", + (name, status, notes), + ) + client_id = cursor.lastrowid + return row_to_dict(db.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()) + + +def upsert_project(name, client_name="", status="actif", summary="", next_action=""): + name = clean_text(name) + if not name: + raise ValueError("Nom de projet manquant") + + client_id = None + with connect() as db: + if client_name: + client = find_client(db, client_name) + if client is None: + cursor = db.execute( + "INSERT INTO clients (name, notes) VALUES (?, ?)", + (clean_text(client_name), "Ajoute automatiquement depuis un projet."), + ) + client_id = cursor.lastrowid + else: + client_id = client["id"] + + existing = find_project(db, name) + if existing: + db.execute( + """ + UPDATE projects + SET client_id = COALESCE(?, client_id), + status = COALESCE(NULLIF(?, ''), status), + summary = COALESCE(NULLIF(?, ''), summary), + next_action = COALESCE(NULLIF(?, ''), next_action), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (client_id, clean_text(status), clean_text(summary), clean_text(next_action), existing["id"]), + ) + project_id = existing["id"] + else: + cursor = db.execute( + """ + INSERT INTO projects (client_id, name, status, summary, next_action) + VALUES (?, ?, ?, ?, ?) + """, + (client_id, name, clean_text(status) or "actif", clean_text(summary), clean_text(next_action)), + ) + project_id = cursor.lastrowid + return row_to_dict(db.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()) + + +def add_task(title, client_name="", project_name="", details="", due_at=""): + title = clean_text(title) + if not title: + raise ValueError("Titre de tache manquant") + + with connect() as db: + client_id = ensure_client_id(db, client_name) + project_id = ensure_project_id(db, project_name, client_id) + cursor = db.execute( + """ + INSERT INTO tasks (client_id, project_id, title, details, due_at) + VALUES (?, ?, ?, ?, ?) + """, + (client_id, project_id, title, clean_text(details), clean_text(due_at)), + ) + return row_to_dict(db.execute("SELECT * FROM tasks WHERE id = ?", (cursor.lastrowid,)).fetchone()) + + +def add_repository(name, project_name="", local_path="", remote_url="", main_branch=""): + name = clean_text(name) or derive_repo_name(local_path, remote_url) + if not name: + raise ValueError("Nom de depot manquant") + + with connect() as db: + project_id = ensure_project_id(db, project_name, None) + db.execute( + """ + INSERT INTO repositories (project_id, name, local_path, remote_url, main_branch) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(name, local_path, remote_url) DO UPDATE SET + project_id = COALESCE(excluded.project_id, repositories.project_id), + main_branch = COALESCE(NULLIF(excluded.main_branch, ''), repositories.main_branch), + updated_at = CURRENT_TIMESTAMP + """, + (project_id, name, clean_text(local_path), clean_text(remote_url), clean_text(main_branch)), + ) + return row_to_dict( + db.execute( + """ + SELECT * FROM repositories + WHERE name = ? AND local_path = ? AND remote_url = ? + """, + (name, clean_text(local_path), clean_text(remote_url)), + ).fetchone() + ) + + +def add_memory(content, kind="note", subject="", client_name="", project_name=""): + content = clean_text(content) + if not content: + raise ValueError("Memoire vide") + + with connect() as db: + client_id = ensure_client_id(db, client_name) + project_id = ensure_project_id(db, project_name, client_id) + cursor = db.execute( + """ + INSERT INTO memories (client_id, project_id, kind, subject, content) + VALUES (?, ?, ?, ?, ?) + """, + (client_id, project_id, clean_text(kind) or "note", clean_text(subject), content), + ) + return row_to_dict(db.execute("SELECT * FROM memories WHERE id = ?", (cursor.lastrowid,)).fetchone()) + + +def search_memory(query="", limit=12): + query = clean_text(query) + like = f"%{query}%" + with connect() as db: + if query: + clients = db.execute( + "SELECT * FROM clients WHERE name LIKE ? OR notes LIKE ? ORDER BY updated_at DESC LIMIT ?", + (like, like, limit), + ).fetchall() + projects = db.execute( + """ + SELECT p.*, c.name AS client_name + FROM projects p + LEFT JOIN clients c ON c.id = p.client_id + WHERE p.name LIKE ? OR p.summary LIKE ? OR p.next_action LIKE ? OR c.name LIKE ? + ORDER BY p.updated_at DESC LIMIT ? + """, + (like, like, like, like, limit), + ).fetchall() + tasks = 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 (t.title LIKE ? OR t.details LIKE ? OR c.name LIKE ? OR p.name LIKE ?) + ORDER BY t.created_at DESC LIMIT ? + """, + (like, like, like, like, limit), + ).fetchall() + repos = db.execute( + """ + SELECT r.*, p.name AS project_name + FROM repositories r + LEFT JOIN projects p ON p.id = r.project_id + WHERE r.name LIKE ? OR r.local_path LIKE ? OR r.remote_url LIKE ? OR p.name LIKE ? + ORDER BY r.updated_at DESC LIMIT ? + """, + (like, like, like, like, limit), + ).fetchall() + notes = db.execute( + """ + SELECT m.*, c.name AS client_name, p.name AS project_name + FROM memories m + LEFT JOIN clients c ON c.id = m.client_id + LEFT JOIN projects p ON p.id = m.project_id + WHERE m.subject LIKE ? OR m.content LIKE ? OR c.name LIKE ? OR p.name LIKE ? + ORDER BY m.created_at DESC LIMIT ? + """, + (like, like, like, like, limit), + ).fetchall() + else: + clients = db.execute("SELECT * FROM clients ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall() + projects = db.execute( + """ + SELECT p.*, c.name AS client_name + FROM projects p + LEFT JOIN clients c ON c.id = p.client_id + ORDER BY p.updated_at DESC LIMIT ? + """, + (limit,), + ).fetchall() + tasks = 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 + ORDER BY t.created_at DESC LIMIT ? + """, + (limit,), + ).fetchall() + repos = db.execute( + """ + SELECT r.*, p.name AS project_name + FROM repositories r + LEFT JOIN projects p ON p.id = r.project_id + ORDER BY r.updated_at DESC LIMIT ? + """, + (limit,), + ).fetchall() + notes = db.execute( + """ + SELECT m.*, c.name AS client_name, p.name AS project_name + FROM memories m + LEFT JOIN clients c ON c.id = m.client_id + LEFT JOIN projects p ON p.id = m.project_id + ORDER BY m.created_at DESC LIMIT ? + """, + (limit,), + ).fetchall() + + return { + "clients": [row_to_dict(row) for row in clients], + "projects": [row_to_dict(row) for row in projects], + "tasks": [row_to_dict(row) for row in tasks], + "repositories": [row_to_dict(row) for row in repos], + "memories": [row_to_dict(row) for row in notes], + } + + +def ensure_client_id(db, client_name): + client_name = clean_text(client_name) + if not client_name: + return None + client = find_client(db, client_name) + if client: + return client["id"] + cursor = db.execute("INSERT INTO clients (name) VALUES (?)", (client_name,)) + return cursor.lastrowid + + +def ensure_project_id(db, project_name, client_id): + project_name = clean_text(project_name) + if not project_name: + return None + project = find_project(db, project_name) + if project: + if client_id and not project["client_id"]: + db.execute( + "UPDATE projects SET client_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (client_id, project["id"]), + ) + return project["id"] + cursor = db.execute( + "INSERT INTO projects (client_id, name) VALUES (?, ?)", + (client_id, project_name), + ) + return cursor.lastrowid + + +def merge_notes(existing, new): + existing = clean_text(existing) + new = clean_text(new) + if not new: + return existing + if not existing: + return new + if new.lower() in existing.lower(): + return existing + return f"{existing}\n{new}" + + +def clean_text(value): + return " ".join(str(value or "").strip().split()) + + +def derive_repo_name(local_path, remote_url): + source = clean_text(local_path) or clean_text(remote_url) + if not source: + return "" + name = source.rstrip("/\\").split("/")[-1].split("\\")[-1] + return name.removesuffix(".git") diff --git a/static/app.js b/static/app.js index 9cbbdf7..b993a96 100644 --- a/static/app.js +++ b/static/app.js @@ -323,8 +323,14 @@ async function sendVoiceBlob(blob) { async function handleAssistantResponse(data) { const reply = data.reply || ""; addBubble(reply, "assistant"); - const status = data.emailSent ? "Email envoye" : data.contactSaved ? "Contact ajoute" : "Pret"; - setStatus(status, data.emailSent || data.contactSaved ? "live" : "idle"); + const status = data.emailSent + ? "Email envoye" + : data.contactSaved + ? "Contact ajoute" + : data.memorySaved + ? "Memoire mise a jour" + : "Pret"; + setStatus(status, data.emailSent || data.contactSaved || data.memorySaved ? "live" : "idle"); if (!audioToggle.checked || !reply) return; diff --git a/static/index.html b/static/index.html index 1075acc..bbfc04f 100644 --- a/static/index.html +++ b/static/index.html @@ -50,6 +50,6 @@ - + diff --git a/static/sw.js b/static/sw.js index 20d98b0..111a1f6 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,8 +1,8 @@ -const CACHE_NAME = "opensquared-assistant-v6"; +const CACHE_NAME = "opensquared-assistant-v7"; const ASSETS = [ "/", "/styles.css", - "/app.js?v=6", + "/app.js?v=7", "/manifest.webmanifest", "/icons/icon-v2.svg" ];