Spaces:
Sleeping
Sleeping
| // packages/server/src/services/chatService.ts | |
| // | |
| // AI Chat Assistant — interprets natural language and executes ICC actions: | |
| // scan emails, query transactions, show stats, search, etc. | |
| // Uses the same Groq/Mistral keys as the scan pipeline. | |
| import { db } from '../db/index.js'; | |
| import { transactions, scanLogs } from '../db/schema.js'; | |
| import { eq, desc, sql, like, gte, lte, and } from 'drizzle-orm'; | |
| import { resolveScanDates, type ScanPreset } from '@icc/shared'; | |
| import { executeScan } from './scanService.js'; | |
| import { config } from '../config/env.js'; | |
| // ═══════════════════════════════════════════ | |
| // 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<ParsedIntent> { | |
| const primary = config.AI_PRIMARY_PROVIDER || 'groq'; | |
| const useMistral = primary === 'mistral' ? !!config.MISTRAL_API_KEY : !config.GROQ_API_KEY && !!config.MISTRAL_API_KEY; | |
| const apiKey = useMistral ? config.MISTRAL_API_KEY : config.GROQ_API_KEY; | |
| if (!apiKey) { | |
| return { action: 'chat', message: "Aucune clé API IA configurée. Veuillez configurer GROQ_API_KEY ou MISTRAL_API_KEY." }; | |
| } | |
| const url = useMistral | |
| ? 'https://api.mistral.ai/v1/chat/completions' | |
| : 'https://api.groq.com/openai/v1/chat/completions'; | |
| const model = useMistral ? 'mistral-small-latest' : 'llama-3.3-70b-versatile'; | |
| 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<ChatResponse> { | |
| console.log(`[Chat] User: "${userMessage}"`); | |
| 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); | |
| 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 scan in the background | |
| executeScan( | |
| { dateRange, forceRescan: intent.forceRescan || false }, | |
| userId | |
| ).then((jobId) => { | |
| console.log(`[Chat] Scan started:`, jobId); | |
| }).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.`, | |
| 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; | |
| // Always scope to the authenticated user | |
| const conditions = [eq(transactions.userId, userId)]; | |
| if (intent.branch) { | |
| conditions.push(like(transactions.branch, `%${intent.branch}%`)); | |
| } | |
| if (intent.status) { | |
| conditions.push(sql`${transactions.status} = ${intent.status}`); | |
| } | |
| const where = and(...conditions); | |
| const [rows, countResult] = await Promise.all([ | |
| db.select().from(transactions).where(where).orderBy(desc(transactions.date)).limit(limit).offset(offset), | |
| db.select({ count: sql<number>`count(*)` }).from(transactions).where(where), | |
| ]); | |
| const total = countResult[0]?.count ?? 0; | |
| if (rows.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 ${rows.length} plus récentes:`, | |
| data: { | |
| transactions: rows, | |
| pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, | |
| }, | |
| }; | |
| } catch (error: any) { | |
| return { type: 'error', message: `❌ Erreur: ${error.message}` }; | |
| } | |
| } | |
| // ─── STATISTICS ─── | |
| case 'stats': { | |
| try { | |
| const userFilter = eq(transactions.userId, userId); | |
| const [totals] = await db.select({ | |
| totalCount: sql<number>`count(*)`, | |
| totalAmount: sql<number>`COALESCE(sum(${transactions.amount}), 0)`, | |
| }).from(transactions).where(userFilter); | |
| const byBranch = await db.select({ | |
| branch: transactions.branch, | |
| count: sql<number>`count(*)`, | |
| total: sql<number>`COALESCE(sum(${transactions.amount}), 0)`, | |
| }).from(transactions).where(userFilter).groupBy(transactions.branch).orderBy(desc(sql`sum(${transactions.amount})`)).limit(10); | |
| const byStatus = await db.select({ | |
| status: transactions.status, | |
| count: sql<number>`count(*)`, | |
| total: sql<number>`COALESCE(sum(${transactions.amount}), 0)`, | |
| }).from(transactions).where(userFilter).groupBy(transactions.status); | |
| const recentScan = await db.select().from(scanLogs).where(eq(scanLogs.userId, userId)).orderBy(desc(scanLogs.id)).limit(1); | |
| const statsData = { | |
| totalTransactions: totals?.totalCount ?? 0, | |
| totalAmount: totals?.totalAmount ?? 0, | |
| byBranch, | |
| byStatus, | |
| lastScan: recentScan[0] || null, | |
| }; | |
| if ((totals?.totalCount ?? 0) === 0) { | |
| return { | |
| type: 'message', | |
| message: '📭 Aucune donnée disponible. Lance un scan pour importer des virements Interac.', | |
| }; | |
| } | |
| const topBranches = byBranch.slice(0, 5).map((b) => | |
| ` • ${b.branch}: ${b.count} virement(s) — ${Number(b.total).toFixed(2)} $` | |
| ).join('\n'); | |
| const statusSummary = byStatus.map((s) => | |
| ` • ${s.status}: ${s.count} (${Number(s.total).toFixed(2)} $)` | |
| ).join('\n'); | |
| return { | |
| type: 'stats', | |
| message: `📈 **Résumé des virements**\n\n` + | |
| `💰 **Total:** ${totals?.totalCount} virements — ${Number(totals?.totalAmount).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 = await db.select().from(scanLogs).where(eq(scanLogs.userId, userId)).orderBy(desc(scanLogs.id)).limit(10); | |
| 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.map((h) => { | |
| const date = new Date(h.startedAt).toLocaleDateString('fr-CA', { | |
| day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', | |
| }); | |
| return ` • ${date} — ${h.emailsFound} trouvés, ${h.emailsParsed} importés, ${h.errors} erreurs (${h.scanPreset})`; | |
| }).join('\n'); | |
| return { | |
| type: 'scan_history', | |
| message: `📜 **Historique des scans** (${history.length} derniers):\n\n${lines}`, | |
| data: history, | |
| }; | |
| } catch (error: any) { | |
| return { type: 'error', message: `❌ Erreur: ${error.message}` }; | |
| } | |
| } | |
| // ─── SEARCH TRANSACTIONS ─── | |
| case 'search': { | |
| try { | |
| // Always scope to the authenticated user | |
| const conditions = [eq(transactions.userId, userId)]; | |
| if (intent.query) { | |
| const q = `%${intent.query}%`; | |
| conditions.push( | |
| sql`(${transactions.sender} LIKE ${q} OR ${transactions.reference} LIKE ${q} OR ${transactions.message} LIKE ${q} OR ${transactions.branch} LIKE ${q})` | |
| ); | |
| } | |
| if (intent.branch) { | |
| conditions.push(like(transactions.branch, `%${intent.branch}%`)); | |
| } | |
| if (intent.minAmount) { | |
| conditions.push(gte(transactions.amount, intent.minAmount)); | |
| } | |
| if (intent.maxAmount) { | |
| conditions.push(lte(transactions.amount, intent.maxAmount)); | |
| } | |
| const where = and(...conditions); | |
| const results = await db.select().from(transactions).where(where).orderBy(desc(transactions.date)).limit(25); | |
| 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.", | |
| }; | |
| } | |
| } | |
| } | |