Heaven K
fix: use Mistral free-tier models only (mistral-small-latest, nemo, 7b, 8x7b)
2f22b9e
// 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.",
};
}
}
}