notes memory
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
21
README.md
21
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 :
|
||||
|
||||
265
app.py
265
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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
398
memory.py
Normal file
398
memory.py
Normal file
@@ -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")
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -50,6 +50,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js?v=6" type="module"></script>
|
||||
<script src="/app.js?v=7" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user