// packages/server/src/services/chatService.ts // // AI Chat Assistant that interprets natural language commands // and executes ICC actions: scan emails, query transactions, show stats, etc. // // Uses Groq or Mistral (same keys as the scan pipeline) for intent parsing. import { resolveScanDates, type ScanPreset } from '../../shared/types/scan'; import { resolveBranch } from '../../shared/constants/branches'; import database from '../db/database'; import { executeScan } from './scanService'; // ═══════════════════════════════════════════ // SYSTEM PROMPT — defines the assistant's capabilities // ═══════════════════════════════════════════ const SYSTEM_PROMPT = `Tu es l'assistant IA de l'ICC Interac Manager. Tu aides l'utilisateur à gérer ses virements Interac. Tu peux exécuter ces ACTIONS en répondant UNIQUEMENT avec un bloc JSON (pas de texte avant/après): 1. **Scanner des courriels** — Chercher des virements Interac dans Gmail {"action": "scan", "preset": "today|last7days|custom", "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD", "forceRescan": false} 2. **Afficher les transactions** — Lister les virements importés {"action": "list_transactions", "page": 1, "limit": 25, "branch": "optional filter", "status": "optional filter"} 3. **Statistiques** — Résumé des données {"action": "stats"} 4. **Historique des scans** — Voir les scans précédents {"action": "scan_history"} 5. **Chercher des transactions** — Par expéditeur, montant, ou succursale {"action": "search", "query": "search terms", "branch": "optional", "minAmount": null, "maxAmount": null} 6. **Réponse conversationnelle** — Pour les questions générales {"action": "chat", "message": "Ta réponse ici"} RÈGLES: - Aujourd'hui c'est ${new Date().toISOString().split('T')[0]}. - Si l'utilisateur dit "scanne depuis janvier 2024" → preset=custom, startDate=2024-01-01, endDate=aujourd'hui. - Si l'utilisateur dit "scanne aujourd'hui" → preset=today. - Si l'utilisateur dit "scanne les 7 derniers jours" → preset=last7days. - Si l'utilisateur dit "montre les transactions" → action=list_transactions. - Si l'utilisateur dit "combien de virements" → action=stats. - Pour les dates en français: "janvier"=01, "février"=02, "mars"=03, "avril"=04, "mai"=05, "juin"=06, "juillet"=07, "août"=08, "septembre"=09, "octobre"=10, "novembre"=11, "décembre"=12. - Si une date de fin n'est pas précisée, utilise aujourd'hui. - Si une date de début n'est pas précisée pour un scan, demande-la via action=chat. - Réponds TOUJOURS en français. - Retourne UNIQUEMENT du JSON valide, pas de markdown, pas de backticks.`; // ═══════════════════════════════════════════ // INTENT PARSER — calls AI to interpret user message // ═══════════════════════════════════════════ interface ParsedIntent { action: string; [key: string]: any; } async function parseIntent(userMessage: string): Promise { const apiKey = process.env.GROQ_API_KEY || process.env.MISTRAL_API_KEY; if (!apiKey) { return { action: 'chat', message: "Aucune clé API IA configurée. Veuillez configurer GROQ_API_KEY ou MISTRAL_API_KEY." }; } const isGroq = !!process.env.GROQ_API_KEY; const url = isGroq ? 'https://api.groq.com/openai/v1/chat/completions' : 'https://api.mistral.ai/v1/chat/completions'; const model = isGroq ? 'llama-3.3-70b-versatile' : 'mistral-small-latest'; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: userMessage }, ], temperature: 0.1, max_tokens: 300, response_format: { type: 'json_object' }, }), }); if (!response.ok) { const errText = await response.text(); console.error('[Chat] AI error:', errText); return { action: 'chat', message: "Désolé, je n'ai pas pu comprendre ta demande. Réessaie." }; } const data = await response.json(); const raw = data.choices[0]?.message?.content || ''; const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); return JSON.parse(cleaned); } catch (error: any) { console.error('[Chat] Parse error:', error.message); return { action: 'chat', message: "Erreur de parsing. Réessaie avec une commande plus simple." }; } } // ═══════════════════════════════════════════ // ACTION EXECUTOR — runs the parsed intent // ═══════════════════════════════════════════ export interface ChatResponse { type: 'message' | 'scan_started' | 'transactions' | 'stats' | 'scan_history' | 'search_results' | 'error'; message: string; data?: any; } export async function handleChatMessage( userMessage: string, userId: string ): Promise { console.log(`[Chat] User: "${userMessage}"`); // Parse intent via AI const intent = await parseIntent(userMessage); console.log(`[Chat] Intent:`, JSON.stringify(intent)); switch (intent.action) { // ─── SCAN EMAILS ─── case 'scan': { try { const preset = (intent.preset || 'today') as ScanPreset; const startDate = intent.startDate; const endDate = intent.endDate || new Date().toISOString().split('T')[0]; const dateRange = resolveScanDates(preset, startDate, endDate); // Format dates for display const fmtDate = (iso: string) => { const d = new Date(iso); return d.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }); }; const rangeDisplay = preset === 'today' ? "aujourd'hui" : preset === 'last7days' ? 'les 7 derniers jours' : `du ${fmtDate(dateRange.startDate)} au ${fmtDate(dateRange.endDate)}`; // Start the scan in the background executeScan( { dateRange, forceRescan: intent.forceRescan || false }, userId ).then((summary) => { console.log(`[Chat] Scan completed:`, summary); }).catch((err) => { console.error(`[Chat] Scan failed:`, err.message); }); return { type: 'scan_started', message: `🔍 Scan lancé pour ${rangeDisplay}. Les résultats apparaîtront en temps réel dans le tableau ci-dessous.`, data: { dateRange, preset }, }; } catch (error: any) { return { type: 'error', message: `❌ Erreur lors du démarrage du scan: ${error.message}`, }; } } // ─── LIST TRANSACTIONS ─── case 'list_transactions': { try { const page = intent.page || 1; const limit = Math.min(intent.limit || 25, 50); const offset = (page - 1) * limit; let transactions = database.getTransactions(limit, offset); const total = database.getTransactionCount(); // Apply optional filters if (intent.branch) { transactions = transactions.filter((t: any) => t.branch?.toLowerCase().includes(intent.branch.toLowerCase()) ); } if (intent.status) { transactions = transactions.filter((t: any) => t.status === intent.status); } if (transactions.length === 0) { return { type: 'message', message: '📭 Aucune transaction trouvée. Lance un scan pour importer des virements.', }; } return { type: 'transactions', message: `📊 ${total} transaction(s) au total. Voici les ${transactions.length} plus récentes:`, data: { transactions, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }, }; } catch (error: any) { return { type: 'error', message: `❌ Erreur: ${error.message}` }; } } // ─── STATISTICS ─── case 'stats': { try { const db = database.raw; const totalAmount = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM transactions').get() as any; const totalCount = db.prepare('SELECT COUNT(*) as count FROM transactions').get() as any; const byBranch = db.prepare( 'SELECT branch, COUNT(*) as count, SUM(amount) as total FROM transactions GROUP BY branch ORDER BY total DESC LIMIT 10' ).all() as any[]; const byStatus = db.prepare( 'SELECT status, COUNT(*) as count, SUM(amount) as total FROM transactions GROUP BY status' ).all() as any[]; const recentScan = db.prepare( 'SELECT * FROM scan_logs ORDER BY started_at DESC LIMIT 1' ).get() as any; const statsData = { totalTransactions: totalCount.count, totalAmount: totalAmount.total, byBranch, byStatus, lastScan: recentScan, }; if (totalCount.count === 0) { return { type: 'message', message: '📭 Aucune donnée disponible. Lance un scan pour importer des virements Interac.', }; } // Build a nice summary message const topBranches = byBranch.slice(0, 5).map((b: any) => ` • ${b.branch}: ${b.count} virement(s) — ${Number(b.total).toFixed(2)} $` ).join('\n'); const statusSummary = byStatus.map((s: any) => ` • ${s.status}: ${s.count} (${Number(s.total).toFixed(2)} $)` ).join('\n'); return { type: 'stats', message: `📈 **Résumé des virements**\n\n` + `💰 **Total:** ${totalCount.count} virements — ${Number(totalAmount.total).toFixed(2)} $ CAD\n\n` + `🏢 **Top succursales:**\n${topBranches}\n\n` + `📋 **Par statut:**\n${statusSummary}`, data: statsData, }; } catch (error: any) { return { type: 'error', message: `❌ Erreur: ${error.message}` }; } } // ─── SCAN HISTORY ─── case 'scan_history': { try { const history = database.getScanHistory() as any[]; if (history.length === 0) { return { type: 'message', message: "📭 Aucun scan précédent. Dis-moi de scanner et je m'en occupe!", }; } const lines = history.slice(0, 10).map((h: any) => { const date = new Date(h.started_at).toLocaleDateString('fr-CA', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', }); return ` • ${date} — ${h.emails_found} trouvés, ${h.emails_parsed} importés, ${h.errors} erreurs (${h.scan_preset})`; }).join('\n'); return { type: 'scan_history', message: `📜 **Historique des scans** (${history.length} derniers):\n\n${lines}`, data: history.slice(0, 10), }; } catch (error: any) { return { type: 'error', message: `❌ Erreur: ${error.message}` }; } } // ─── SEARCH TRANSACTIONS ─── case 'search': { try { const db = database.raw; let sql = 'SELECT * FROM transactions WHERE 1=1'; const params: any[] = []; if (intent.query) { sql += ' AND (sender LIKE ? OR reference LIKE ? OR message LIKE ? OR branch LIKE ?)'; const q = `%${intent.query}%`; params.push(q, q, q, q); } if (intent.branch) { sql += ' AND branch LIKE ?'; params.push(`%${intent.branch}%`); } if (intent.minAmount) { sql += ' AND amount >= ?'; params.push(intent.minAmount); } if (intent.maxAmount) { sql += ' AND amount <= ?'; params.push(intent.maxAmount); } sql += ' ORDER BY date DESC LIMIT 25'; const results = db.prepare(sql).all(...params) as any[]; if (results.length === 0) { return { type: 'message', message: `🔍 Aucun résultat pour "${intent.query || ''}". Essaie un terme différent.`, }; } return { type: 'search_results', message: `🔍 ${results.length} résultat(s) trouvé(s):`, data: { transactions: results, query: intent.query }, }; } catch (error: any) { return { type: 'error', message: `❌ Erreur: ${error.message}` }; } } // ─── CONVERSATIONAL RESPONSE ─── case 'chat': default: { return { type: 'message', message: intent.message || "Je suis prêt à t'aider! Tu peux me demander de scanner des courriels, afficher les transactions, ou consulter les statistiques.", }; } } } export const chatService = { handleChatMessage }; export default chatService;