/* ================================================================
CHATBOT.JS — v6 — Extraction avec contexte historique
================================================================ */
console.log("[Chatbot] v6 chargé — extraction avec contexte historique");
(function () {
"use strict";
// ── État global ──────────────────────────────────────────────
let _history = []; // messages de la conversation active
let _activeId = null; // id de la conversation active
let _isStreaming = false;
let _bound = false;
let _historyOpen = false;
let _initialized = false;
const POS_KEY = "chatbot_btn_pos";
const PANEL_POS_KEY = "chatbot_panel_pos";
const MAX_MSG = 60; // messages max par conversation
const MAX_CONVS = 30; // conversations max à conserver
// ── Clés localStorage ────────────────────────────────────────
function _getUserEmail() {
try {
const raw = sessionStorage.getItem("session-store");
if (!raw) return null;
const d = JSON.parse(raw);
return d && d.email ? d.email : null;
} catch (_) { return null; }
}
function _storeKey() {
const email = _getUserEmail();
const hash = email ? btoa(email).replace(/[^a-zA-Z0-9]/g, "") : "guest";
return "chatbot_convs_" + hash;
}
// ── Persistance conversations ────────────────────────────────
function _loadStore() {
try {
const raw = localStorage.getItem(_storeKey());
return raw ? JSON.parse(raw) : { activeId: null, list: [] };
} catch (_) { return { activeId: null, list: [] }; }
}
function _saveStore(store) {
try { localStorage.setItem(_storeKey(), JSON.stringify(store)); } catch (_) {}
}
// Génère un titre à partir du 1er message utilisateur
function _autoTitle(text) {
if (!text) return "Nouvelle conversation";
const t = text.trim().replace(/\s+/g, " ");
return t.length > 45 ? t.slice(0, 42) + "…" : t;
}
// Formate la date de façon relative
function _relDate(isoStr) {
try {
const d = new Date(isoStr);
const now = new Date();
const diff = Math.floor((now - d) / 86400000);
if (diff === 0) return "Aujourd'hui";
if (diff === 1) return "Hier";
if (diff < 7) return "Il y a " + diff + " jours";
if (diff < 30) return "Il y a " + Math.floor(diff / 7) + " sem.";
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
} catch (_) { return ""; }
}
// ── Gestion des conversations ─────────────────────────────────
function _newConversation() {
_history = [];
_activeId = "conv_" + Date.now();
// Réinitialiser la zone messages
const container = $("#chatbot-messages");
if (container) {
container.innerHTML = "";
const wrap = document.createElement("div");
wrap.id = "chatbot-welcome";
wrap.className = "chatbot-msg chatbot-msg-bot";
wrap.innerHTML = `
Donnees insuffisantes pour ${symbol}.
Ce ticker n'est peut-etre pas supporte par nos modeles.
`;
} else {
_populateCompareModal(body, symbol, data);
// Ajouter un message chat avec les vrais résultats (corrige la reco générique du LLM)
_addBacktestSummaryToChat(symbol, data);
}
} catch (e) {
const body = document.getElementById("cb-modal-body");
if (body) body.innerHTML = `Erreur de connexion au serveur.
`;
console.warn("[Chatbot] backtest-compare fetch error:", e);
}
}
function _addBacktestSummaryToChat(symbol, data) {
const models = data.models || {};
const best = data.best_model;
const start = data.start || 500;
const keys = Object.keys(models);
if (keys.length === 0) return;
const lines = keys.map(k => {
const m = models[k];
const gain = m.final - start;
const sign = gain >= 0 ? '+' : '';
const isBest = k === best;
return `• ${m.label} : ${sign}${gain.toFixed(2)}€ (${sign}${m.return_pct}%) sur 500€${isBest ? ' ← meilleur' : ''}`;
}).join('\n');
const bestM = models[best] || {};
const bestGain = (bestM.final || start) - start;
const bestSign = bestGain >= 0 ? '+' : '';
const bestLabel = bestM.label || best;
const msg = `Voici les resultats reels de la simulation sur les 180 derniers jours pour ${symbol} :\n${lines}\n\nSur la base des donnees historiques, le modele le plus performant pour ${symbol} est **${bestLabel}** (${bestSign}${bestGain.toFixed(2)}€ sur 500€ investis). C'est celui que je recommande pour ${symbol}.`;
addMessage("bot", msg);
_history.push({ role: "assistant", content: msg });
_saveCurrentConversation();
setTimeout(() => { const m = $("#chatbot-messages"); if (m) m.scrollTop = m.scrollHeight; }, 100);
}
function _openCompareModal(symbol) {
const old = document.getElementById("cb-compare-modal");
if (old) old.remove();
const modal = document.createElement("div");
modal.id = "cb-compare-modal";
// Position initiale : à gauche du chatbot panel
const panel = document.getElementById("chatbot-panel");
if (panel) {
const r = panel.getBoundingClientRect();
const mw = 440;
let left = r.left - mw - 14;
if (left < 10) left = Math.min(r.right + 14, window.innerWidth - mw - 10);
const top = Math.max(10, r.top - 10);
modal.style.cssText = `left:${left}px;top:${top}px;`;
}
modal.innerHTML = `
Simulation en cours...
Les 3 modeles sont calcules en parallele (~15s)
`;
document.body.appendChild(modal);
document.getElementById("cb-modal-close-btn").addEventListener("click", () => modal.remove());
_makeDraggable(modal, document.getElementById("cb-modal-drag-handle"));
return modal;
}
function _populateCompareModal(body, symbol, data) {
const MODEL_ICON = { lstm: 'fas fa-brain', transformer: 'fas fa-atom', sentiment: 'fas fa-newspaper' };
const models = data.models || {};
const best = data.best_model;
const start = data.start || 500;
const keys = Object.keys(models);
if (keys.length === 0) {
body.innerHTML = `Aucun modele n'a pu simuler ${symbol}.
`;
return;
}
const rows = keys.map(k => {
const m = models[k];
const isBest = k === best;
const gain = m.final - start;
const gainEur = (gain >= 0 ? '+' : '') + gain.toFixed(2);
const gainCls = gain >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
const retSign = m.return_pct >= 0 ? '+' : '';
const bhSign = m.bh_return >= 0 ? '+' : '';
const vsPassif = m.final - m.bh_final;
const vsEur = (vsPassif >= 0 ? '+' : '') + vsPassif.toFixed(2);
const vsCls = vsPassif >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
const vsText = vsPassif >= 0
? `
Si vous aviez investi ${start}€ sur ${symbol}
depuis le ${m.start_date} en suivant le modele ${m.label},
vous auriez maintenant
${m.final.toFixed(2)}€ — soit
${gainEur}€ (${retSign}${m.return_pct}%).
C'est ${vsText} (qui aurait donne ${m.bh_final.toFixed(2)}€).
${spark ? `
${spark}
— Modele IA- - Sans IA (buy&hold)
` : ''}
Valeur finale
${m.final.toFixed(2)}€
Taux reussite
${m.win_rate}%
Sans IA (buy&hold)
${m.bh_final.toFixed(2)}€ (${bhSign}${m.bh_return}%)
`;
}).join('');
const bestLabel = best ? (models[best]?.label || best) : '—';
body.innerHTML = `
Impossible de contacter le serveur.
Vérifiez votre connexion et réessayez.
`;
}
scrollBottom();
});
card.querySelector(".cb-invest-cancel").addEventListener("click", function () {
card.remove();
});
}
// ── Extraction investissement (100% client, sans API) ────────
const _SYMBOL_MAP = {
'apple':'AAPL', 'aapl':'AAPL',
'microsoft':'MSFT', 'msft':'MSFT',
'tesla':'TSLA', 'tsla':'TSLA',
'nvidia':'NVDA', 'nvda':'NVDA',
'google':'GOOGL', 'alphabet':'GOOGL', 'googl':'GOOGL',
'amazon':'AMZN', 'amzn':'AMZN',
'meta':'META', 'facebook':'META',
'bitcoin':'BTC-USD', 'btc':'BTC-USD', 'crypto':'BTC-USD',
};
const _MODEL_LIST = [
['lstm','lstm'],['ltsm','lstm'],['bilstm','lstm'],['réseau de neurones','lstm'],
['transformer','transformer'],['transformeur','transformer'],['hybride','transformer'],
['sentiment','sentiment'],['actualit','sentiment'],['news','sentiment'],
];
// Regex d'intention : .? entre j et ai couvre TOUTES les variantes d'apostrophe
const _INTENT_RE = /j.?ai\s*(investi|mis\b|achet|vendu|suivi|fait\b)|enregistr|vas-y/i;
// Cherche un symbole ou un modèle dans une chaîne basse
function _findSymbol(lo) {
for (const [k, v] of Object.entries(_SYMBOL_MAP)) {
if (lo.includes(k)) return v;
}
return null;
}
function _findModel(lo) {
for (const [k, v] of _MODEL_LIST) {
if (lo.includes(k)) return v;
}
return null;
}
// history = tableau de messages {role, content} — utilisé comme contexte si le
// symbole/modèle n'est pas dans le message actuel
function _extractInvestment(text, history) {
console.log("[Chatbot] _extractInvestment appelé sur:", text.slice(0,80));
if (!_INTENT_RE.test(text)) {
console.log("[Chatbot] aucune intention d'investissement détectée");
return null;
}
const lo = ' ' + text.toLowerCase() + ' ';
// Contexte: tous les messages récents (pour trouver le symbole si non répété)
const ctxLo = lo + ' ' + (history || []).slice(-10)
.map(m => (m.content || '').toLowerCase()).join(' ') + ' ';
// Symbol: d'abord dans le message actuel, puis dans le contexte
const symbol = _findSymbol(lo) || _findSymbol(ctxLo);
// Model: idem
const model = _findModel(lo) || _findModel(ctxLo);
// Amount : cherche un nombre + €/euro dans le message actuel
let amount = null;
const m1 = text.match(/(\d[\d\s]*(?:[.,]\d{1,2})?)\s*(?:€|euros?)/i);
if (m1) amount = parseFloat(m1[1].replace(/\s/g,'').replace(',','.'));
if (!amount) {
const m2 = text.match(/(?:€|euros?)\s*(\d[\d\s]*(?:[.,]\d{1,2})?)/i);
if (m2) amount = parseFloat(m2[1].replace(/\s/g,'').replace(',','.'));
}
if (!amount) {
// fallback : plus grand nombre dans le texte
const nums = text.match(/\b(\d{2,7}(?:[.,]\d{1,2})?)\b/g);
if (nums) amount = Math.max(...nums.map(n => parseFloat(n.replace(',','.'))));
}
const action = /vend|short|baissier|sold/i.test(text) ? 'VENDRE' : 'ACHETER';
console.log("[Chatbot] extraction →", {symbol, model, amount, action});
if (symbol && amount > 0) {
return { symbol, model: model || 'lstm', action, amount };
}
console.log("[Chatbot] extraction échouée — symbol:", symbol, "amount:", amount);
return null;
}
// ── Envoi du message ─────────────────────────────────────────
async function sendMessage() {
if (_isStreaming) return;
const input = $("#chatbot-input");
if (!input) return;
const text = input.value.trim();
if (!text) return;
console.log("[Chatbot] sendMessage appelé:", text.slice(0, 60));
const welcome = $("#chatbot-welcome");
if (welcome) welcome.remove();
input.value = "";
_isStreaming = true;
addMessage("user", text);
_history.push({ role: "user", content: text });
showTyping();
// Extraction AVANT le fetch — message actuel + historique pour le contexte
const pendingInvest = _extractInvestment(text, _history);
const isCompareQuery = _COMPARE_RE.test(text);
const compareSymbol = isCompareQuery ? _extractCompareSymbol(text, _history) : null;
console.log("[Chatbot] pendingInvest:", pendingInvest, "| compareSymbol:", compareSymbol);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: _history }),
});
if (!response.ok) throw new Error("Erreur serveur " + response.status);
removeTyping();
const botBubble = addMessage("bot", "");
if (!botBubble) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value, { stream: true }).split("\n")) {
if (!line.startsWith("data: ")) continue;
const chunk = line.slice(6).trim();
if (chunk === "[DONE]") break;
try {
const p = JSON.parse(chunk);
if (p.text) { fullText += p.text; botBubble.textContent = fullText; scrollBottom(); }
if (p.error) { botBubble.textContent = p.error; }
} catch (_) {}
}
}
// Sauvegarder la réponse bot dans l'historique
const savedText = fullText || botBubble.textContent || "";
_history.push({ role: "assistant", content: savedText });
if (_history.length > 40) _history = _history.slice(-40);
_saveCurrentConversation();
// Toujours stripper le marqueur ##INVEST## du texte affiché (quel que soit l'intent)
botBubble.textContent = _stripInvestMarker(savedText);
// 1. Marqueur inline du LLM (prioritaire)
const markerData = _parseInvestMarker(savedText);
const hadInvestIntent = _INTENT_RE.test(text);
if (markerData && markerData.amount > 0) {
if (hadInvestIntent) {
console.log("[Chatbot] Marqueur LLM détecté:", markerData);
_showInvestConfirm(botBubble, markerData);
} else {
console.log("[Chatbot] Marqueur ignoré — pas d'intention dans le message utilisateur");
}
} else if (markerData && markerData.amount <= 0) {
console.log("[Chatbot] Marqueur ignoré — montant nul ou manquant");
} else if (pendingInvest) {
// 2. Extraction client sur le message utilisateur (toujours fiable)
console.log("[Chatbot] Affichage carte confirmation (client):", pendingInvest);
_showInvestConfirm(botBubble, pendingInvest);
} else {
// 3. Détection d'un faux-positif : le bot prétend avoir enregistré sans marqueur
const falseSave = /enregistr|sauvegard|c.est fait|c.est not|confirm/i.test(savedText);
if (falseSave && pendingInvest === null && /investi|achet|vendu|mis\b|suivi/i.test(text)) {
addMessage("bot", "Pour enregistrer votre investissement, j'ai besoin des détails complets : quelle action, quel modèle (LSTM / Transformer / Actualités) et quel montant ?");
}
}
// 4. Comparaison des modèles — afficher la carte après la réponse LLM
if (compareSymbol) {
_loadAndShowCompare(compareSymbol, botBubble);
}
} catch (err) {
removeTyping();
addMessage("bot", "Impossible de contacter l'assistant.");
console.error("[Chatbot]", err);
} finally {
_isStreaming = false;
}
}
// ── Toggle panel ─────────────────────────────────────────────
function togglePanel() {
const panel = $("#chatbot-panel");
const btn = $("#chatbot-toggle");
if (!panel || !btn) return;
const isOpen = panel.classList.contains("chatbot-open");
if (isOpen) {
panel.classList.remove("chatbot-open");
panel.classList.add("chatbot-hidden");
btn.classList.remove("chatbot-toggle-active");
} else {
_positionPanel();
panel.classList.remove("chatbot-hidden");
panel.classList.add("chatbot-open");
btn.classList.add("chatbot-toggle-active");
// Re-init à chaque ouverture (re-vérifie la connexion)
if (!_initialized) {
_initialized = true;
}
_initSession();
scrollBottom();
setTimeout(_initPanelDrag, 50);
setTimeout(() => { const i = $("#chatbot-input"); if (i) i.focus(); }, 320);
}
}
// Charge la dernière conversation ou crée une nouvelle
function _initSession() {
// ── Vérification connexion ──────────────────────────────
if (!_getUserEmail()) {
_showLockedState();
return;
}
_updateUserBadge();
const store = _loadStore();
if (store.activeId) {
const conv = store.list.find(c => c.id === store.activeId);
if (conv && conv.messages.length > 0) {
_activeId = conv.id;
_history = conv.messages;
const container = $("#chatbot-messages");
if (container) {
const w = container.querySelector("#chatbot-welcome");
if (w) w.remove();
_history.forEach(function (msg) {
const role = msg.role === "assistant" ? "bot" : msg.role;
const wrap = document.createElement("div");
wrap.className = "chatbot-msg chatbot-msg-" + role;
const bubble = document.createElement("div");
bubble.className = "chatbot-bubble";
bubble.textContent = msg.content;
wrap.appendChild(bubble);
container.appendChild(wrap);
});
}
scrollBottom();
return;
}
}
_activeId = "conv_" + Date.now();
}
function _showLockedState() {
// Remplacer les messages par un message de verrouillage
const container = $("#chatbot-messages");
if (container) {
container.innerHTML = `