IA Assistant version initiale

This commit is contained in:
ocroguennec committed 2026-09-05 10:54:50 +02:00
1 parent 3e8508d48b
commit 662003fdd3
29 files changed
+4253 -10

No files matched your search

+150
View File
@@ -2727,4 +2727,154 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_op
}
}
// ── Migration : Assistant IA embarqué (04/09/26) ──────────────────────────
// Chat conversationnel branché sur les mêmes outils lecture seule que le
// serveur MCP externe (mcp-server/tools.js, réutilisé tel quel — jamais
// modifié — via un pont MCP in-process, voir backend/src/ai/mcpBridge.js).
// Contrôle d'accès à 3 niveaux : ai_config.actif (global), users.ai_enabled
// (par utilisateur, décidé par l'admin), ai_providers.actif (par
// fournisseur). Deux adaptateurs prévus dès le départ (type 'anthropic'
// natif et 'openai_compatible', ce dernier pouvant pointer vers OpenAI,
// Mistral, un LLM local, ou OpenRouter via base_url) — seul l'adaptateur
// Anthropic est implémenté dans cette première passe. Voir
// backend/src/routes/ai.js et project_ia_embarquee.md (mémoire projet) pour
// le détail de l'architecture.
{
const usersColsAi = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!usersColsAi.includes('ai_enabled')) {
db.exec('ALTER TABLE users ADD COLUMN ai_enabled INTEGER NOT NULL DEFAULT 0');
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('anthropic','openai_compatible')),
base_url TEXT,
modele TEXT NOT NULL,
api_key_chiffree TEXT NOT NULL,
actif INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_providers_actif ON ai_providers(actif)');
db.exec(`
CREATE TABLE IF NOT EXISTS ai_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
actif INTEGER NOT NULL DEFAULT 0,
provider_defaut_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
quota_quotidien INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)`);
db.exec(`
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
provider_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
modele TEXT,
tokens_entree INTEGER,
tokens_sortie INTEGER,
outils_appeles TEXT,
erreur TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_user ON ai_usage_log(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_date ON ai_usage_log(created_at)');
// Clés API internes auto-provisionnées pour l'assistant IA (une par couple
// user/investisseur, jamais affichées dans Mon compte → Clés API) : lui
// donnent accès à /api/v1 exactement comme un client MCP externe, en
// réutilisant l'infrastructure api_keys existante sans la dupliquer — voir
// backend/src/ai/internalApiKey.js.
{
const apiKeysCols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!apiKeysCols.includes('internal')) {
db.exec('ALTER TABLE api_keys ADD COLUMN internal INTEGER NOT NULL DEFAULT 0');
}
}
// Conversations de l'assistant IA — plusieurs fils nommés par couple
// user/investisseur (même granularité de scope que le reste de l'appli, cf.
// requireInvestisseur), consultables et supprimables individuellement
// (page dédiée /assistant-ia + widget flottant, qui partagent la même
// conversation via son id). titre : dérivé du premier message utilisateur
// à la création (voir routes/ai.js), jamais renommé automatiquement ensuite.
db.exec(`
CREATE TABLE IF NOT EXISTS ai_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
titre TEXT NOT NULL DEFAULT 'Nouvelle conversation',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_conversations_scope ON ai_conversations(user_id, investisseur_id, updated_at DESC)');
// Messages de l'assistant IA — rattachés à une conversation (ai_conversations).
// outils_appeles : JSON (tableau de noms d'outil) pour les messages assistant
// uniquement, sinon NULL. Voir backend/src/ai/conversationHistory.js.
// user_id/investisseur_id restent présents sur chaque message (redondants
// avec ai_conversations mais pratiques pour des requêtes directes futures,
// et c'est le schéma d'origine avant l'introduction du multi-conversations).
db.exec(`
CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant')),
content TEXT NOT NULL,
outils_appeles TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration : ajoute conversation_id si la table ai_messages existait déjà
// (créée avant l'introduction du multi-conversations, 04/09/26).
{
const aiMsgCols = db.prepare('PRAGMA table_info(ai_messages)').all().map(c => c.name);
if (!aiMsgCols.includes('conversation_id')) {
db.exec('ALTER TABLE ai_messages ADD COLUMN conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages(conversation_id, id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_thread ON ai_messages(user_id, investisseur_id, created_at)');
// Backfill : les messages enregistrés avant l'introduction du
// multi-conversations n'ont pas de conversation_id — on les regroupe dans
// une conversation unique par couple user/investisseur pour ne rien perdre
// de l'historique existant. Idempotent (ne trouve plus rien à faire une
// fois exécuté, puisque conversation_id n'est alors plus jamais NULL).
{
const orphanScopes = db.prepare(`
SELECT DISTINCT user_id, investisseur_id FROM ai_messages WHERE conversation_id IS NULL
`).all();
for (const { user_id, investisseur_id } of orphanScopes) {
const firstUserMsg = db.prepare(`
SELECT content FROM ai_messages
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL AND role = 'user'
ORDER BY id ASC LIMIT 1
`).get(user_id, investisseur_id);
const rawTitre = firstUserMsg ? firstUserMsg.content.replace(/\s+/g, ' ').trim() : 'Conversation';
const titre = rawTitre.length > 60 ? rawTitre.slice(0, 57) + '…' : (rawTitre || 'Conversation');
const { lastInsertRowid: convId } = db.prepare(`
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
`).run(user_id, investisseur_id, titre);
db.prepare(`
UPDATE ai_messages SET conversation_id = ?
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL
`).run(convId, user_id, investisseur_id);
}
}
export default db;