notes memory

This commit is contained in:
2026-04-27 15:42:17 +02:00
parent 94e14a3674
commit 410f1acd5d
9 changed files with 755 additions and 20 deletions

View File

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

View File

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

View File

@@ -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
View File

@@ -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"],
}

View File

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

View File

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

View File

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

View File

@@ -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"
];