adam-hassen commited on
Commit
ac348e2
·
1 Parent(s): ef5913c

fix: nouvelles modifications ajout de chatbot

Browse files
Interface Graphique/.env ADDED
@@ -0,0 +1 @@
 
 
1
+ GROQ_API_KEY=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
Interface Graphique/app.py CHANGED
@@ -1,14 +1,28 @@
 
 
 
 
 
1
  import dash
2
  from dash import dcc, html, Input, Output, State
3
  import yfinance as yf
4
  import pandas as pd
5
- from flask import jsonify, request as flask_request
6
  import requests
7
  from bs4 import BeautifulSoup
8
  from services.database import init_db
9
  import logging
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
11
 
 
 
 
 
 
 
 
 
 
12
  # Supprimer les logs trop bavards
13
  logging.getLogger('werkzeug').setLevel(logging.ERROR)
14
 
@@ -27,6 +41,9 @@ app = dash.Dash(
27
 
28
  app.title = "ENSIM - Predictions Boursieres"
29
 
 
 
 
30
  # === TICKERS ===
31
  TICKERS = {
32
  "BTC-USD": "BTC/USD",
@@ -91,7 +108,101 @@ app.layout = html.Div([
91
  html.Div(id="navbar-container"),
92
 
93
  # === PAGE CONTENT ===
94
- dash.page_container
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  ])
96
 
97
  # === LISTE DES PAGES PROTÉGÉES ===
@@ -129,7 +240,7 @@ def update_layout(pathname, session):
129
  ])
130
 
131
  if session and session.get("is_admin"):
132
- print(f"👑 Lien admin ajouté pour {session.get('email')}")
133
  nav_links.append(dcc.Link("Admin", href="/admin", className=nav_cls("/admin")))
134
 
135
  nav_links.append(html.Button("Déconnexion", id="logout-btn", className="nav-link"))
@@ -317,6 +428,334 @@ def api_prices():
317
  return jsonify(results)
318
 
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  # === LANCEMENT ===
321
  if __name__ == "__main__":
322
  import os
 
1
+ import os
2
+ # Doit être défini AVANT tout import de tensorflow/keras/protobuf
3
+ os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
4
+ os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3')
5
+
6
  import dash
7
  from dash import dcc, html, Input, Output, State
8
  import yfinance as yf
9
  import pandas as pd
10
+ from flask import jsonify, request as flask_request, Response, stream_with_context
11
  import requests
12
  from bs4 import BeautifulSoup
13
  from services.database import init_db
14
  import logging
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
16
 
17
+ # Charger les variables d'environnement depuis .env (chemin absolu)
18
+ import os as _os
19
+ try:
20
+ from dotenv import load_dotenv
21
+ _env_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), ".env")
22
+ load_dotenv(dotenv_path=_env_path, override=True)
23
+ except ImportError:
24
+ pass # python-dotenv non installé, les env vars système seront utilisées
25
+
26
  # Supprimer les logs trop bavards
27
  logging.getLogger('werkzeug').setLevel(logging.ERROR)
28
 
 
41
 
42
  app.title = "ENSIM - Predictions Boursieres"
43
 
44
+ # Désactiver le cache navigateur pour les assets (force rechargement chatbot.js à chaque fois)
45
+ app.server.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
46
+
47
  # === TICKERS ===
48
  TICKERS = {
49
  "BTC-USD": "BTC/USD",
 
108
  html.Div(id="navbar-container"),
109
 
110
  # === PAGE CONTENT ===
111
+ dash.page_container,
112
+
113
+ # === CHATBOT WIDGET ===
114
+ html.Button(
115
+ html.I(className="fa-solid fa-robot"),
116
+ id="chatbot-toggle",
117
+ title="Assistant IA"
118
+ ),
119
+ html.Div(
120
+ id="chatbot-panel",
121
+ className="chatbot-hidden",
122
+ children=[
123
+
124
+ # ── Header ──────────────────────────────────────────
125
+ html.Div(id="chatbot-header", children=[
126
+ html.Div(className="chatbot-header-left", children=[
127
+ html.Div(className="chatbot-avatar", children=[
128
+ html.I(className="fa-solid fa-robot")
129
+ ]),
130
+ html.Div(className="chatbot-header-info", children=[
131
+ html.Span("Conseiller IA", className="chatbot-header-name"),
132
+ html.Div(className="chatbot-header-status", children=[
133
+ html.Span(className="chatbot-status-dot"),
134
+ html.Span("En ligne"),
135
+ ]),
136
+ ]),
137
+ ]),
138
+ html.Div(className="chatbot-header-actions", children=[
139
+ html.Button(
140
+ html.I(className="fa-solid fa-clock-rotate-left"),
141
+ id="chatbot-history-btn",
142
+ title="Historique des conversations"
143
+ ),
144
+ html.Button(
145
+ html.I(className="fa-solid fa-pen-to-square"),
146
+ id="chatbot-new-btn",
147
+ title="Nouvelle conversation"
148
+ ),
149
+ html.Button(
150
+ html.I(className="fa-solid fa-xmark"),
151
+ id="chatbot-close",
152
+ title="Fermer"
153
+ ),
154
+ ]),
155
+ ]),
156
+
157
+ # ── Vue Chat (par défaut) ────────────────────────────
158
+ html.Div(id="chatbot-chat-view", children=[
159
+ html.Div(id="chatbot-messages", children=[
160
+ html.Div(
161
+ id="chatbot-welcome",
162
+ className="chatbot-msg chatbot-msg-bot",
163
+ children=[
164
+ html.Div(className="chatbot-bubble", children=[
165
+ html.Strong("Bonjour !"),
166
+ html.Br(),
167
+ "Je suis votre assistant IA. Posez-moi vos questions sur la plateforme, les modèles de prédiction ou les actions disponibles."
168
+ ])
169
+ ]
170
+ )
171
+ ]),
172
+ html.Div(id="chatbot-input-row", children=[
173
+ html.Button(
174
+ html.I(className="fa-solid fa-microphone"),
175
+ id="chatbot-mic",
176
+ title="Parler"
177
+ ),
178
+ html.Textarea(
179
+ id="chatbot-input",
180
+ placeholder="Posez votre question ou parlez…",
181
+ rows=1
182
+ ),
183
+ html.Button(
184
+ html.I(className="fa-solid fa-paper-plane"),
185
+ id="chatbot-send",
186
+ title="Envoyer"
187
+ ),
188
+ ]),
189
+ ]),
190
+
191
+ # ── Vue Historique (cachée par défaut) ───────────────
192
+ html.Div(id="chatbot-history-view", className="cb-view-hidden", children=[
193
+ html.Div(id="chatbot-history-header", children=[
194
+ html.Span("Conversations", className="cb-hist-title"),
195
+ html.Button(
196
+ [html.I(className="fa-solid fa-plus"), " Nouvelle"],
197
+ id="chatbot-new-btn2",
198
+ className="cb-new-btn"
199
+ ),
200
+ ]),
201
+ html.Div(id="chatbot-history-list"),
202
+ ]),
203
+
204
+ ]
205
+ ),
206
  ])
207
 
208
  # === LISTE DES PAGES PROTÉGÉES ===
 
240
  ])
241
 
242
  if session and session.get("is_admin"):
243
+ print(f"[ADMIN] Lien admin ajouté pour {session.get('email')}")
244
  nav_links.append(dcc.Link("Admin", href="/admin", className=nav_cls("/admin")))
245
 
246
  nav_links.append(html.Button("Déconnexion", id="logout-btn", className="nav-link"))
 
428
  return jsonify(results)
429
 
430
 
431
+ # === API TRANSCRIPTION VOCALE (Groq Whisper) ===
432
+ @app.server.route('/api/transcribe', methods=['POST'])
433
+ def api_transcribe():
434
+ from groq import Groq
435
+
436
+ audio_file = flask_request.files.get('audio')
437
+ if not audio_file:
438
+ return jsonify({'error': 'Aucun fichier audio'}), 400
439
+
440
+ api_key = os.environ.get("GROQ_API_KEY")
441
+ if not api_key:
442
+ return jsonify({'error': 'Clé API non configurée'}), 500
443
+
444
+ try:
445
+ client = Groq(api_key=api_key)
446
+ audio_bytes = audio_file.read()
447
+ transcription = client.audio.transcriptions.create(
448
+ file=("audio.webm", audio_bytes),
449
+ model="whisper-large-v3-turbo",
450
+ language="fr",
451
+ response_format="text",
452
+ )
453
+ return jsonify({'text': str(transcription).strip()})
454
+ except Exception as e:
455
+ return jsonify({'error': str(e)}), 500
456
+
457
+
458
+ # === API INVESTISSEMENT CHATBOT ===
459
+ @app.server.route('/api/chat-invest', methods=['POST'])
460
+ def api_chat_invest():
461
+ from services.database import get_connection
462
+
463
+ data = flask_request.get_json(force=True, silent=True) or {}
464
+ email = data.get('email', '').strip()
465
+ symbol = data.get('symbol', '').strip().upper()
466
+ model = data.get('model', 'sentiment').strip().lower()
467
+ action = data.get('action', 'ACHETER').strip().upper()
468
+ amount = float(data.get('amount', 0) or 0)
469
+
470
+ print(f"[chat-invest] REÇU → email={email!r} symbol={symbol!r} model={model!r} action={action!r} amount={amount}")
471
+
472
+ ALLOWED_SYMBOLS = {'AAPL','MSFT','TSLA','NVDA','GOOGL','AMZN','META','BTC-USD'}
473
+ ALLOWED_MODELS = {'sentiment','lstm','transformer'}
474
+
475
+ if not email or symbol not in ALLOWED_SYMBOLS or model not in ALLOWED_MODELS or amount <= 0:
476
+ print(f"[chat-invest] VALIDATION ÉCHOUÉE — email={email!r} symbol={symbol!r} model={model!r} amount={amount}")
477
+ return jsonify({'error': 'Données invalides', 'debug': {'email': email, 'symbol': symbol, 'model': model, 'amount': amount}}), 400
478
+
479
+ try:
480
+ # Prix actuel
481
+ h = yf.Ticker(symbol).history(period='2d', interval='1d')
482
+ price = float(h['Close'].iloc[-1]) if not h.empty else 0.0
483
+
484
+ # Mapper action → directions compatibles avec Mon Suivi
485
+ pred_dir = 'up' if action == 'ACHETER' else 'down'
486
+ actual_dir = 'up' if action == 'ACHETER' else 'down'
487
+
488
+ conn = get_connection()
489
+ conn.execute("""
490
+ INSERT INTO user_trades
491
+ (user_email, symbol, entry_price, quantity,
492
+ prediction_direction, actual_direction, pnl, pnl_percentage,
493
+ model_type, status)
494
+ VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open')
495
+ """, (email, symbol, price, amount, pred_dir, actual_dir, model))
496
+ conn.commit()
497
+ conn.close()
498
+
499
+ print(f"[chat-invest] Trade sauvegardé: {email} | {symbol} | {model} | {action} | {amount}€ à {price}$")
500
+
501
+ return jsonify({
502
+ 'success': True,
503
+ 'symbol': symbol,
504
+ 'price': round(price, 2),
505
+ 'amount': amount,
506
+ 'action': action,
507
+ 'model': model,
508
+ })
509
+ except Exception as e:
510
+ print(f"[chat-invest] Erreur: {e}")
511
+ return jsonify({'error': str(e)}), 500
512
+
513
+
514
+ # === API COMPARAISON MODÈLES PAR ACTION ===
515
+ @app.server.route('/api/model-compare', methods=['GET'])
516
+ def api_model_compare():
517
+ from services.database import get_connection
518
+
519
+ email = flask_request.args.get('email', '').strip()
520
+ symbol = flask_request.args.get('symbol', '').strip().upper()
521
+
522
+ if not email or not symbol:
523
+ return jsonify({'error': 'Missing params'}), 400
524
+
525
+ MODEL_LABELS = {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'}
526
+
527
+ try:
528
+ conn = get_connection()
529
+ # Résumé par modèle
530
+ rows = conn.execute("""
531
+ SELECT model_type,
532
+ COUNT(*) as trades,
533
+ SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
534
+ COALESCE(SUM(pnl), 0) as total_pnl,
535
+ COALESCE(AVG(pnl_percentage), 0) as avg_pct
536
+ FROM user_trades
537
+ WHERE user_email = ? AND symbol = ? AND status = 'closed'
538
+ GROUP BY model_type
539
+ """, (email, symbol)).fetchall()
540
+
541
+ models = {}
542
+ for model_type, trades, wins, total_pnl, avg_pct in rows:
543
+ # Derniers trades de ce modèle
544
+ recent = conn.execute("""
545
+ SELECT entry_date, prediction_direction, quantity, pnl, pnl_percentage
546
+ FROM user_trades
547
+ WHERE user_email = ? AND symbol = ? AND model_type = ? AND status = 'closed'
548
+ ORDER BY entry_date DESC LIMIT 5
549
+ """, (email, symbol, model_type)).fetchall()
550
+
551
+ models[model_type] = {
552
+ 'label': MODEL_LABELS.get(model_type, model_type),
553
+ 'trades': trades,
554
+ 'wins': wins,
555
+ 'losses': trades - wins,
556
+ 'win_rate': round(wins / trades * 100, 1) if trades > 0 else 0.0,
557
+ 'total_pnl': round(total_pnl, 2),
558
+ 'avg_pct': round(avg_pct, 2),
559
+ 'recent': [
560
+ {
561
+ 'date': str(r[0])[:10],
562
+ 'signal': r[1],
563
+ 'amount': r[2] or 0,
564
+ 'pnl': round(r[3] or 0, 2),
565
+ 'pnl_pct': round(r[4] or 0, 2),
566
+ }
567
+ for r in recent
568
+ ],
569
+ }
570
+ conn.close()
571
+
572
+ best_model = None
573
+ if models:
574
+ best_model = max(models.items(), key=lambda x: x[1]['total_pnl'])[0]
575
+
576
+ return jsonify({'symbol': symbol, 'models': models, 'best_model': best_model})
577
+ except Exception as e:
578
+ print(f"[model-compare] Erreur: {e}")
579
+ return jsonify({'error': str(e)}), 500
580
+
581
+
582
+ # === API COMPARAISON MODÈLES — SIMULATION BACKTESTS ===
583
+ @app.server.route('/api/backtest-compare', methods=['GET'])
584
+ def api_backtest_compare():
585
+ symbol = flask_request.args.get('symbol', '').strip().upper()
586
+ if not symbol:
587
+ return jsonify({'error': 'Missing symbol'}), 400
588
+
589
+ START = 500.0
590
+ DAYS = 180
591
+
592
+ def _run_lstm():
593
+ try:
594
+ from pages.mon_suivi import _run_backtest_lstm
595
+ return 'lstm', _run_backtest_lstm(symbol, start_amount=START, days=DAYS)
596
+ except Exception as e:
597
+ print(f"[backtest-compare] lstm {symbol}: {e}")
598
+ return 'lstm', None
599
+
600
+ def _run_transformer():
601
+ try:
602
+ from services.transformer_service import predict_backtest as _bt
603
+ return 'transformer', _bt(symbol, start_amount=START, days=DAYS)
604
+ except Exception as e:
605
+ print(f"[backtest-compare] transformer {symbol}: {e}")
606
+ return 'transformer', None
607
+
608
+ def _run_sentiment():
609
+ try:
610
+ from pages.mon_suivi import _run_backtest
611
+ return 'sentiment', _run_backtest(symbol, start_amount=START, days=DAYS)
612
+ except Exception as e:
613
+ print(f"[backtest-compare] sentiment {symbol}: {e}")
614
+ return 'sentiment', None
615
+
616
+ def _ds(arr, n=30):
617
+ if not arr or len(arr) <= n:
618
+ return [round(v, 2) for v in arr]
619
+ step = len(arr) / n
620
+ pts = [arr[int(i * step)] for i in range(n)] + [arr[-1]]
621
+ return [round(v, 2) for v in pts]
622
+
623
+ results = {}
624
+ with ThreadPoolExecutor(max_workers=3) as ex:
625
+ futures = [ex.submit(_run_lstm), ex.submit(_run_transformer), ex.submit(_run_sentiment)]
626
+ for f in as_completed(futures):
627
+ key, bt = f.result()
628
+ if bt:
629
+ results[key] = {
630
+ 'label': {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'}[key],
631
+ 'start': START,
632
+ 'final': bt['final_value'],
633
+ 'return_pct': bt['total_return'],
634
+ 'bh_final': round(bt['buy_hold'][-1], 2) if bt.get('buy_hold') else START,
635
+ 'bh_return': bt['buy_hold_return'],
636
+ 'n_trades': bt['n_trades'],
637
+ 'wins': bt['wins'],
638
+ 'losses': bt['losses'],
639
+ 'win_rate': bt['win_rate'],
640
+ 'start_date': bt['start_date'],
641
+ 'series': _ds(bt.get('portfolio', [])),
642
+ 'bh_series': _ds(bt.get('buy_hold', [])),
643
+ }
644
+
645
+ if not results:
646
+ return jsonify({'error': f'Données insuffisantes pour {symbol}'}), 404
647
+
648
+ best = max(results.items(), key=lambda x: x[1]['return_pct'])[0]
649
+ return jsonify({'symbol': symbol, 'models': results, 'best_model': best, 'start': START})
650
+
651
+
652
+ # === API DÉTECTION INVESTISSEMENT (post-streaming, fiable) ===
653
+ @app.server.route('/api/chat-detect-invest', methods=['POST'])
654
+ def api_chat_detect_invest():
655
+ """
656
+ Après le streaming, vérifie si la conversation contient un investissement à enregistrer.
657
+ Retourne {"invest": {...}} ou {"invest": null}.
658
+ Utilise un prompt dédié à l'extraction JSON — beaucoup plus fiable que le marqueur inline.
659
+ """
660
+ import json as _json
661
+ from groq import Groq
662
+
663
+ data = flask_request.get_json(force=True, silent=True) or {}
664
+ messages = data.get('messages', [])[-6:] # derniers messages seulement
665
+
666
+ api_key = os.environ.get("GROQ_API_KEY")
667
+ if not api_key:
668
+ return jsonify({'invest': None})
669
+
670
+ EXTRACT_PROMPT = """Tu es un extracteur JSON. Analyse le dernier message de l'utilisateur et extrait les données d'un investissement à enregistrer.
671
+
672
+ MAPPINGS OBLIGATOIRES — noms d'entreprises → symboles boursiers :
673
+ Apple / AAPL → "AAPL"
674
+ Microsoft / MSFT → "MSFT"
675
+ Tesla / TSLA → "TSLA"
676
+ Nvidia / NVDA → "NVDA"
677
+ Google / Alphabet / GOOGL → "GOOGL"
678
+ Amazon / AMZN → "AMZN"
679
+ Meta / Facebook / META → "META"
680
+ Bitcoin / BTC / crypto → "BTC-USD"
681
+
682
+ MAPPINGS MODÈLES :
683
+ LSTM / LST / lstm / réseau de neurones / prix → "lstm"
684
+ Transformer / transformeur / hybride → "transformer"
685
+ Sentiment / actualités / actualites / news → "sentiment"
686
+
687
+ Si l'utilisateur dit qu'il a investi / mis de l'argent / suivi un conseil / acheté / vendu, extrais :
688
+ {"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}
689
+
690
+ RÈGLES :
691
+ - action = "ACHETER" si l'utilisateur a acheté ou suivi un conseil haussier, "VENDRE" sinon
692
+ - amount = le montant en euros (nombre seul, ex: 500)
693
+ - Réponds UNIQUEMENT avec le JSON brut, rien d'autre
694
+ - Si une info manque ou si ce n'est PAS une intention d'enregistrement : réponds null"""
695
+
696
+ try:
697
+ client = Groq(api_key=api_key)
698
+ resp = client.chat.completions.create(
699
+ model="llama-3.3-70b-versatile",
700
+ messages=[{"role": "system", "content": EXTRACT_PROMPT}] + messages,
701
+ max_tokens=80,
702
+ stream=False,
703
+ temperature=0,
704
+ )
705
+ raw = (resp.choices[0].message.content or "").strip()
706
+ print(f"[detect-invest] raw='{raw}'")
707
+ if raw == "null" or not raw.startswith("{"):
708
+ return jsonify({"invest": None})
709
+ invest = _json.loads(raw)
710
+ # Validation
711
+ ALLOWED_SYMBOLS = {"AAPL","MSFT","TSLA","NVDA","GOOGL","AMZN","META","BTC-USD"}
712
+ ALLOWED_MODELS = {"sentiment","lstm","transformer"}
713
+ if (invest.get("symbol") in ALLOWED_SYMBOLS
714
+ and invest.get("model","").lower() in ALLOWED_MODELS
715
+ and invest.get("action") in {"ACHETER","VENDRE"}
716
+ and float(invest.get("amount", 0) or 0) > 0):
717
+ invest["model"] = invest["model"].lower()
718
+ return jsonify({"invest": invest})
719
+ return jsonify({"invest": None})
720
+ except Exception as e:
721
+ print(f"[detect-invest] erreur: {e}")
722
+ return jsonify({"invest": None})
723
+
724
+
725
+ # === API CHATBOT (SSE streaming) ===
726
+ @app.server.route('/api/chat', methods=['POST'])
727
+ def api_chat():
728
+ from services.chat_service import stream_chat
729
+ import json
730
+
731
+ data = flask_request.get_json(force=True, silent=True) or {}
732
+ messages = data.get('messages', [])
733
+
734
+ # Validation basique
735
+ if not isinstance(messages, list):
736
+ return jsonify({'error': 'Invalid payload'}), 400
737
+
738
+ def generate():
739
+ try:
740
+ for chunk in stream_chat(messages):
741
+ payload = json.dumps({'text': chunk}, ensure_ascii=False)
742
+ yield f"data: {payload}\n\n"
743
+ except Exception as e:
744
+ payload = json.dumps({'error': str(e)}, ensure_ascii=False)
745
+ yield f"data: {payload}\n\n"
746
+ finally:
747
+ yield "data: [DONE]\n\n"
748
+
749
+ return Response(
750
+ stream_with_context(generate()),
751
+ content_type='text/event-stream',
752
+ headers={
753
+ 'Cache-Control': 'no-cache',
754
+ 'X-Accel-Buffering': 'no',
755
+ }
756
+ )
757
+
758
+
759
  # === LANCEMENT ===
760
  if __name__ == "__main__":
761
  import os
Interface Graphique/assets/chatbot.css ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ================================================================
2
+ CHATBOT.CSS — Widget assistant IA — Design premium + draggable
3
+ ================================================================ */
4
+
5
+ /* ── Bouton flottant draggable ───────────────────────────────── */
6
+ #chatbot-toggle {
7
+ position: fixed;
8
+ bottom: 28px;
9
+ right: 28px;
10
+ z-index: 9100;
11
+ width: 60px;
12
+ height: 60px;
13
+ border-radius: 50%;
14
+ border: none;
15
+ cursor: grab;
16
+ background: linear-gradient(135deg, #00f0ff 0%, #0055ff 100%);
17
+ box-shadow:
18
+ 0 4px 20px rgba(0, 200, 255, 0.5),
19
+ 0 0 0 0 rgba(0, 200, 255, 0.35),
20
+ inset 0 1px 1px rgba(255,255,255,0.25);
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ transition: box-shadow 0.3s, transform 0.2s;
25
+ animation: cbPulse 3.5s ease-in-out infinite;
26
+ user-select: none;
27
+ touch-action: none;
28
+ }
29
+ #chatbot-toggle:active { cursor: grabbing; }
30
+ #chatbot-toggle:hover {
31
+ box-shadow: 0 6px 30px rgba(0,200,255,0.65), inset 0 1px 1px rgba(255,255,255,0.2);
32
+ transform: scale(1.08);
33
+ }
34
+ #chatbot-toggle.cb-dragging {
35
+ animation: none;
36
+ transform: scale(1.12);
37
+ box-shadow: 0 8px 36px rgba(0,200,255,0.7);
38
+ cursor: grabbing;
39
+ }
40
+ #chatbot-toggle i {
41
+ font-size: 1.5rem;
42
+ color: #010214;
43
+ transition: transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
44
+ pointer-events: none;
45
+ }
46
+ #chatbot-toggle.chatbot-toggle-active i { transform: rotate(20deg) scale(0.9); }
47
+
48
+ @keyframes cbPulse {
49
+ 0%,100% { box-shadow: 0 4px 20px rgba(0,200,255,.5), 0 0 0 0 rgba(0,200,255,.35), inset 0 1px 1px rgba(255,255,255,.25); }
50
+ 50% { box-shadow: 0 4px 20px rgba(0,200,255,.5), 0 0 0 14px rgba(0,200,255,0), inset 0 1px 1px rgba(255,255,255,.25); }
51
+ }
52
+
53
+ /* ── Panel ───────────────────────────────────────────────────── */
54
+ #chatbot-panel {
55
+ position: fixed;
56
+ z-index: 9099;
57
+ width: 380px;
58
+ max-height: 560px;
59
+ border-radius: 22px;
60
+ background: linear-gradient(160deg, rgba(6,10,35,0.97) 0%, rgba(3,6,22,0.98) 100%);
61
+ border: 1px solid rgba(0,210,255,0.15);
62
+ box-shadow:
63
+ 0 20px 60px rgba(0,0,0,0.7),
64
+ 0 0 0 1px rgba(0,180,255,0.05) inset,
65
+ 0 1px 0 rgba(0,220,255,0.12) inset;
66
+ backdrop-filter: blur(24px);
67
+ -webkit-backdrop-filter: blur(24px);
68
+ display: flex;
69
+ flex-direction: column;
70
+ overflow: hidden;
71
+ /* Position par défaut (sera écrasée par le JS) */
72
+ bottom: 102px;
73
+ right: 28px;
74
+ }
75
+ #chatbot-panel.chatbot-hidden { display: none; }
76
+ #chatbot-panel.chatbot-open { animation: cbSlideIn 0.3s cubic-bezier(0.22,1,0.36,1) both; }
77
+ @keyframes cbSlideIn {
78
+ from { opacity:0; transform: scale(0.88) translateY(18px); }
79
+ to { opacity:1; transform: scale(1) translateY(0); }
80
+ }
81
+
82
+ /* ── Header ──────────────────────────────────────────────────── */
83
+ #chatbot-header {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ padding: 15px 18px 13px;
88
+ background: linear-gradient(135deg, rgba(0,180,255,0.1) 0%, rgba(0,80,255,0.07) 100%);
89
+ border-bottom: 1px solid rgba(0,200,255,0.1);
90
+ flex-shrink: 0;
91
+ position: relative;
92
+ cursor: grab;
93
+ user-select: none;
94
+ }
95
+ #chatbot-header:active { cursor: grabbing; }
96
+ #chatbot-header::after {
97
+ content: '';
98
+ position: absolute;
99
+ bottom: 0; left: 18px; right: 18px;
100
+ height: 1px;
101
+ background: linear-gradient(90deg, transparent, rgba(0,220,255,0.25), transparent);
102
+ }
103
+ /* Poignée de drag — 6 points centrés en haut du header */
104
+ #chatbot-header::before {
105
+ content: '⋮⋮';
106
+ position: absolute;
107
+ top: 5px; left: 50%; transform: translateX(-50%);
108
+ font-size: 0.65rem; letter-spacing: 3px;
109
+ color: rgba(0,200,255,0.25);
110
+ pointer-events: none;
111
+ line-height: 1;
112
+ }
113
+ .chatbot-header-left { display: flex; align-items: center; gap: 11px; }
114
+ .chatbot-avatar {
115
+ width: 38px; height: 38px;
116
+ border-radius: 50%;
117
+ background: linear-gradient(135deg, #00d4ff, #0050ff);
118
+ display: flex; align-items: center; justify-content: center;
119
+ font-size: 1rem; color: #fff;
120
+ box-shadow: 0 2px 12px rgba(0,180,255,0.4);
121
+ flex-shrink: 0;
122
+ position: relative;
123
+ }
124
+ .chatbot-avatar::after {
125
+ content: '';
126
+ position: absolute;
127
+ inset: -2px;
128
+ border-radius: 50%;
129
+ background: linear-gradient(135deg, rgba(0,220,255,0.5), transparent);
130
+ z-index: -1;
131
+ }
132
+ .chatbot-header-info { display: flex; flex-direction: column; gap: 3px; }
133
+ .chatbot-header-name {
134
+ font-size: 0.9rem; font-weight: 700;
135
+ color: #eaf6ff; letter-spacing: 0.025em;
136
+ }
137
+ .chatbot-header-status {
138
+ font-size: 0.7rem; color: #3ef5a0;
139
+ display: flex; align-items: center; gap: 5px;
140
+ }
141
+ .chatbot-status-dot {
142
+ width: 6px; height: 6px; border-radius: 50%;
143
+ background: #3ef5a0;
144
+ box-shadow: 0 0 6px rgba(62,245,160,0.8);
145
+ animation: cbStatusBlink 2.5s ease-in-out infinite;
146
+ flex-shrink: 0;
147
+ }
148
+ @keyframes cbStatusBlink {
149
+ 0%,100% { opacity:1; }
150
+ 50% { opacity:0.5; }
151
+ }
152
+ .chatbot-header-actions {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ }
157
+ #chatbot-close,
158
+ #chatbot-clear {
159
+ background: rgba(255,255,255,0.05);
160
+ border: 1px solid rgba(255,255,255,0.07);
161
+ color: rgba(184,223,240,0.45);
162
+ font-size: 0.82rem; cursor: pointer;
163
+ width: 30px; height: 30px;
164
+ border-radius: 8px;
165
+ display: flex; align-items: center; justify-content: center;
166
+ transition: all 0.2s;
167
+ flex-shrink: 0;
168
+ }
169
+ #chatbot-close:hover {
170
+ background: rgba(0,200,255,0.1);
171
+ border-color: rgba(0,200,255,0.2);
172
+ color: #00f0ff;
173
+ transform: rotate(90deg);
174
+ }
175
+ #chatbot-clear:hover {
176
+ background: rgba(255,80,80,0.1);
177
+ border-color: rgba(255,80,80,0.2);
178
+ color: #ff6060;
179
+ }
180
+
181
+ /* Badge utilisateur connecté */
182
+ .chatbot-user-badge {
183
+ font-size: 0.68rem;
184
+ color: rgba(0,220,255,0.55);
185
+ background: rgba(0,200,255,0.07);
186
+ border: 1px solid rgba(0,200,255,0.1);
187
+ border-radius: 20px;
188
+ padding: 1px 8px;
189
+ margin-top: 2px;
190
+ letter-spacing: 0.02em;
191
+ max-width: 160px;
192
+ overflow: hidden;
193
+ text-overflow: ellipsis;
194
+ white-space: nowrap;
195
+ }
196
+
197
+ /* ── Zone messages ───────────────────────────────────────────── */
198
+ #chatbot-messages {
199
+ flex: 1;
200
+ overflow-y: auto;
201
+ padding: 16px 14px 10px;
202
+ display: flex;
203
+ flex-direction: column;
204
+ gap: 10px;
205
+ scrollbar-width: thin;
206
+ scrollbar-color: rgba(0,200,255,0.15) transparent;
207
+ }
208
+ #chatbot-messages::-webkit-scrollbar { width: 3px; }
209
+ #chatbot-messages::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 3px; }
210
+
211
+ /* ── Bulles ──────────────────────────────────────────────────── */
212
+ .chatbot-msg {
213
+ display: flex;
214
+ max-width: 86%;
215
+ animation: cbMsgIn 0.22s cubic-bezier(0.22,1,0.36,1) both;
216
+ }
217
+ @keyframes cbMsgIn {
218
+ from { opacity:0; transform: translateY(10px) scale(0.96); }
219
+ to { opacity:1; transform: translateY(0) scale(1); }
220
+ }
221
+ .chatbot-msg-user { align-self: flex-end; flex-direction: row-reverse; }
222
+ .chatbot-msg-bot { align-self: flex-start; }
223
+
224
+ .chatbot-bubble {
225
+ padding: 10px 15px;
226
+ border-radius: 18px;
227
+ font-size: 0.84rem;
228
+ line-height: 1.6;
229
+ white-space: pre-wrap;
230
+ word-break: break-word;
231
+ position: relative;
232
+ }
233
+ .chatbot-msg-user .chatbot-bubble {
234
+ background: linear-gradient(135deg, #00b8e6 0%, #0044cc 100%);
235
+ color: #fff;
236
+ border-bottom-right-radius: 5px;
237
+ box-shadow: 0 3px 14px rgba(0,140,220,0.3);
238
+ }
239
+ .chatbot-msg-bot .chatbot-bubble {
240
+ background: rgba(255,255,255,0.04);
241
+ border: 1px solid rgba(0,200,255,0.1);
242
+ color: #cde8ff;
243
+ border-bottom-left-radius: 5px;
244
+ box-shadow: 0 2px 10px rgba(0,0,0,0.2);
245
+ }
246
+
247
+ /* Message de bienvenue */
248
+ #chatbot-welcome { align-self: flex-start; max-width: 98%; }
249
+ #chatbot-welcome .chatbot-bubble {
250
+ background: linear-gradient(135deg, rgba(0,180,255,0.08), rgba(0,60,200,0.06));
251
+ border: 1px solid rgba(0,200,255,0.13);
252
+ color: #a8d8f0;
253
+ font-size: 0.81rem;
254
+ }
255
+ #chatbot-welcome .chatbot-bubble strong { color: #00e5ff; }
256
+
257
+ /* ── Typing dots ─────────────────────────────────────────────── */
258
+ .chatbot-typing-bubble {
259
+ display: flex; align-items: center; gap: 5px;
260
+ padding: 12px 16px; min-width: 58px;
261
+ }
262
+ .chatbot-dot {
263
+ width: 7px; height: 7px; border-radius: 50%;
264
+ background: rgba(0,220,255,0.55);
265
+ animation: cbDot 1.4s ease-in-out infinite;
266
+ }
267
+ .chatbot-dot:nth-child(1) { animation-delay: 0s; }
268
+ .chatbot-dot:nth-child(2) { animation-delay: 0.2s; }
269
+ .chatbot-dot:nth-child(3) { animation-delay: 0.4s; }
270
+ @keyframes cbDot {
271
+ 0%,60%,100% { transform: translateY(0); opacity:0.45; }
272
+ 30% { transform: translateY(-7px); opacity:1; }
273
+ }
274
+
275
+ /* ── Input row ───────────────────────────────────────────────── */
276
+ #chatbot-input-row {
277
+ display: flex; align-items: flex-end; gap: 8px;
278
+ padding: 12px 14px 14px;
279
+ border-top: 1px solid rgba(0,200,255,0.07);
280
+ flex-shrink: 0;
281
+ background: rgba(0,0,0,0.25);
282
+ position: relative;
283
+ }
284
+ #chatbot-input-row::before {
285
+ content: '';
286
+ position: absolute;
287
+ top: 0; left: 14px; right: 14px; height: 1px;
288
+ background: linear-gradient(90deg, transparent, rgba(0,200,255,0.15), transparent);
289
+ }
290
+ #chatbot-input {
291
+ flex: 1;
292
+ background: rgba(255,255,255,0.05);
293
+ border: 1px solid rgba(0,200,255,0.13);
294
+ border-radius: 14px;
295
+ color: #dff0ff;
296
+ font-family: inherit;
297
+ font-size: 0.83rem;
298
+ padding: 10px 14px;
299
+ resize: none;
300
+ outline: none;
301
+ min-height: 40px;
302
+ max-height: 110px;
303
+ overflow-y: auto;
304
+ transition: border-color 0.25s, box-shadow 0.25s, background 0.25s;
305
+ line-height: 1.5;
306
+ scrollbar-width: thin;
307
+ }
308
+ #chatbot-input::placeholder { color: rgba(160,210,240,0.3); }
309
+ #chatbot-input:focus {
310
+ border-color: rgba(0,200,255,0.38);
311
+ background: rgba(255,255,255,0.07);
312
+ box-shadow: 0 0 0 3px rgba(0,200,255,0.07);
313
+ }
314
+ #chatbot-send {
315
+ width: 40px; height: 40px;
316
+ border-radius: 12px; border: none;
317
+ background: linear-gradient(135deg, #00d4ff, #0050ff);
318
+ color: #010214;
319
+ font-size: 0.88rem; cursor: pointer;
320
+ display: flex; align-items: center; justify-content: center;
321
+ flex-shrink: 0;
322
+ transition: transform 0.15s, box-shadow 0.2s;
323
+ box-shadow: 0 3px 12px rgba(0,160,255,0.35);
324
+ }
325
+ #chatbot-send:hover { transform: scale(1.08); box-shadow: 0 4px 18px rgba(0,160,255,0.55); }
326
+ #chatbot-send:active { transform: scale(0.93); }
327
+
328
+ /* ── Boutons header supplémentaires ─────────────────────────── */
329
+ #chatbot-history-btn,
330
+ #chatbot-new-btn {
331
+ background: rgba(255,255,255,0.05);
332
+ border: 1px solid rgba(255,255,255,0.07);
333
+ color: rgba(184,223,240,0.45);
334
+ font-size: 0.82rem; cursor: pointer;
335
+ width: 30px; height: 30px;
336
+ border-radius: 8px;
337
+ display: flex; align-items: center; justify-content: center;
338
+ transition: all 0.2s;
339
+ flex-shrink: 0;
340
+ }
341
+ #chatbot-history-btn:hover {
342
+ background: rgba(0,200,255,0.1);
343
+ border-color: rgba(0,200,255,0.2);
344
+ color: #00f0ff;
345
+ }
346
+ #chatbot-history-btn.cb-btn-active {
347
+ background: rgba(0,200,255,0.15);
348
+ border-color: rgba(0,200,255,0.3);
349
+ color: #00f0ff;
350
+ }
351
+ #chatbot-new-btn:hover {
352
+ background: rgba(60,255,160,0.1);
353
+ border-color: rgba(60,255,160,0.2);
354
+ color: #3ef5a0;
355
+ }
356
+
357
+ /* ── Vues toggle ─────────────────────────────────────────────── */
358
+ #chatbot-chat-view {
359
+ display: flex;
360
+ flex-direction: column;
361
+ flex: 1;
362
+ overflow: hidden;
363
+ min-height: 0;
364
+ }
365
+ #chatbot-history-view {
366
+ display: flex;
367
+ flex-direction: column;
368
+ flex: 1;
369
+ overflow: hidden;
370
+ min-height: 0;
371
+ }
372
+ .cb-view-hidden { display: none !important; }
373
+
374
+ /* ── Header vue historique ───────────────────────────────────── */
375
+ #chatbot-history-header {
376
+ display: flex;
377
+ align-items: center;
378
+ justify-content: space-between;
379
+ padding: 14px 16px 10px;
380
+ border-bottom: 1px solid rgba(0,200,255,0.08);
381
+ flex-shrink: 0;
382
+ }
383
+ .cb-hist-title {
384
+ font-size: 0.85rem;
385
+ font-weight: 700;
386
+ color: #eaf6ff;
387
+ letter-spacing: 0.02em;
388
+ }
389
+ .cb-new-btn {
390
+ display: flex; align-items: center; gap: 6px;
391
+ background: linear-gradient(135deg, rgba(0,200,255,0.12), rgba(0,80,255,0.1));
392
+ border: 1px solid rgba(0,200,255,0.2);
393
+ color: #00e5ff;
394
+ font-size: 0.75rem; font-weight: 600;
395
+ padding: 5px 11px;
396
+ border-radius: 8px;
397
+ cursor: pointer;
398
+ transition: all 0.2s;
399
+ }
400
+ .cb-new-btn:hover {
401
+ background: linear-gradient(135deg, rgba(0,200,255,0.2), rgba(0,80,255,0.15));
402
+ box-shadow: 0 2px 10px rgba(0,180,255,0.2);
403
+ }
404
+
405
+ /* ── Liste des conversations ─────────────────────────────────── */
406
+ #chatbot-history-list {
407
+ flex: 1;
408
+ overflow-y: auto;
409
+ padding: 8px 10px 12px;
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: 4px;
413
+ scrollbar-width: thin;
414
+ scrollbar-color: rgba(0,200,255,0.15) transparent;
415
+ }
416
+ #chatbot-history-list::-webkit-scrollbar { width: 3px; }
417
+ #chatbot-history-list::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 3px; }
418
+
419
+ .cb-hist-section { margin-bottom: 6px; }
420
+
421
+ .cb-hist-section-label {
422
+ font-size: 0.68rem;
423
+ font-weight: 600;
424
+ color: rgba(0,200,255,0.4);
425
+ text-transform: uppercase;
426
+ letter-spacing: 0.08em;
427
+ padding: 6px 6px 4px;
428
+ }
429
+
430
+ .cb-hist-item {
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 8px;
434
+ padding: 9px 10px;
435
+ border-radius: 10px;
436
+ cursor: pointer;
437
+ border: 1px solid transparent;
438
+ transition: all 0.18s;
439
+ position: relative;
440
+ }
441
+ .cb-hist-item:hover {
442
+ background: rgba(0,180,255,0.07);
443
+ border-color: rgba(0,200,255,0.1);
444
+ }
445
+ .cb-hist-item-active {
446
+ background: rgba(0,180,255,0.1);
447
+ border-color: rgba(0,200,255,0.18);
448
+ }
449
+ .cb-hist-item-active::before {
450
+ content: '';
451
+ position: absolute;
452
+ left: 0; top: 20%; bottom: 20%;
453
+ width: 3px;
454
+ background: #00e5ff;
455
+ border-radius: 0 3px 3px 0;
456
+ }
457
+ .cb-hist-item-body {
458
+ flex: 1;
459
+ min-width: 0;
460
+ }
461
+ .cb-hist-item-title {
462
+ font-size: 0.8rem;
463
+ font-weight: 600;
464
+ color: #d4eeff;
465
+ white-space: nowrap;
466
+ overflow: hidden;
467
+ text-overflow: ellipsis;
468
+ margin-bottom: 2px;
469
+ }
470
+ .cb-hist-item-preview {
471
+ font-size: 0.71rem;
472
+ color: rgba(160,210,240,0.45);
473
+ white-space: nowrap;
474
+ overflow: hidden;
475
+ text-overflow: ellipsis;
476
+ }
477
+ .cb-hist-delete {
478
+ background: none;
479
+ border: none;
480
+ color: rgba(184,223,240,0.2);
481
+ font-size: 0.72rem;
482
+ cursor: pointer;
483
+ padding: 4px 5px;
484
+ border-radius: 6px;
485
+ opacity: 0;
486
+ transition: all 0.15s;
487
+ flex-shrink: 0;
488
+ }
489
+ .cb-hist-item:hover .cb-hist-delete { opacity: 1; }
490
+ .cb-hist-delete:hover {
491
+ background: rgba(255,80,80,0.12);
492
+ color: #ff6060;
493
+ }
494
+
495
+ /* ── Empty state ─────────────────────────────────────────────── */
496
+ .cb-hist-empty {
497
+ display: flex;
498
+ flex-direction: column;
499
+ align-items: center;
500
+ justify-content: center;
501
+ gap: 10px;
502
+ padding: 40px 20px;
503
+ color: rgba(160,210,240,0.3);
504
+ text-align: center;
505
+ }
506
+ .cb-hist-empty i { font-size: 2rem; }
507
+ .cb-hist-empty p { font-size: 0.8rem; margin: 0; }
508
+
509
+ /* ── État verrouillé (non connecté) ─────────────────────────── */
510
+ .cb-locked-wrap {
511
+ display: flex;
512
+ flex-direction: column;
513
+ align-items: center;
514
+ justify-content: center;
515
+ text-align: center;
516
+ padding: 30px 24px 24px;
517
+ gap: 12px;
518
+ height: 100%;
519
+ min-height: 240px;
520
+ }
521
+ .cb-locked-icon {
522
+ width: 64px; height: 64px;
523
+ border-radius: 50%;
524
+ background: linear-gradient(135deg, rgba(0,200,255,0.08), rgba(0,60,200,0.06));
525
+ border: 1px solid rgba(0,200,255,0.15);
526
+ display: flex; align-items: center; justify-content: center;
527
+ font-size: 1.6rem;
528
+ color: rgba(0,200,255,0.5);
529
+ margin-bottom: 4px;
530
+ }
531
+ .cb-locked-title {
532
+ font-size: 0.95rem;
533
+ font-weight: 700;
534
+ color: #eaf6ff;
535
+ }
536
+ .cb-locked-desc {
537
+ font-size: 0.78rem;
538
+ color: rgba(160,210,240,0.5);
539
+ line-height: 1.6;
540
+ max-width: 260px;
541
+ }
542
+ .cb-locked-btn {
543
+ display: inline-flex;
544
+ align-items: center;
545
+ gap: 7px;
546
+ margin-top: 6px;
547
+ padding: 9px 22px;
548
+ border-radius: 12px;
549
+ background: linear-gradient(135deg, #00c4e0, #0050cc);
550
+ color: #fff;
551
+ font-size: 0.82rem;
552
+ font-weight: 600;
553
+ text-decoration: none;
554
+ transition: opacity 0.2s, transform 0.15s;
555
+ box-shadow: 0 3px 14px rgba(0,160,255,0.3);
556
+ }
557
+ .cb-locked-btn:hover { opacity: 0.88; transform: translateY(-1px); }
558
+
559
+ /* Input désactivé */
560
+ #chatbot-input:disabled {
561
+ opacity: 0.35;
562
+ cursor: not-allowed;
563
+ }
564
+ #chatbot-send:disabled {
565
+ opacity: 0.25;
566
+ cursor: not-allowed;
567
+ pointer-events: none;
568
+ }
569
+ #chatbot-mic:disabled {
570
+ opacity: 0.25;
571
+ cursor: not-allowed;
572
+ /* PAS de pointer-events:none → le clic doit toujours arriver à toggleVoice() */
573
+ }
574
+
575
+ /* ── Bouton micro ────────────────────────────────────────────── */
576
+ #chatbot-mic {
577
+ width: 36px; height: 36px;
578
+ border-radius: 10px; border: none;
579
+ background: rgba(255,255,255,0.05);
580
+ border: 1px solid rgba(0,200,255,0.13);
581
+ color: rgba(160,210,240,0.5);
582
+ font-size: 0.85rem; cursor: pointer;
583
+ display: flex; align-items: center; justify-content: center;
584
+ flex-shrink: 0;
585
+ transition: all 0.2s;
586
+ }
587
+ #chatbot-mic:hover {
588
+ background: rgba(0,200,255,0.08);
589
+ border-color: rgba(0,200,255,0.25);
590
+ color: #00e5ff;
591
+ }
592
+ #chatbot-mic.cb-mic-active {
593
+ background: rgba(255,60,60,0.15);
594
+ border-color: rgba(255,80,80,0.4);
595
+ color: #ff5555;
596
+ animation: cbMicPulse 1s ease-in-out infinite;
597
+ }
598
+ @keyframes cbMicPulse {
599
+ 0%,100% { box-shadow: 0 0 0 0 rgba(255,80,80,0.4); }
600
+ 50% { box-shadow: 0 0 0 6px rgba(255,80,80,0); }
601
+ }
602
+
603
+ /* ── Carte confirmation investissement ───────────────────────── */
604
+ .cb-invest-card {
605
+ margin: 8px 0 8px 10px;
606
+ border-radius: 14px;
607
+ border: 1.5px solid rgba(0,230,128,0.35);
608
+ background: linear-gradient(135deg, rgba(0,30,60,0.97), rgba(0,20,45,0.99));
609
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,200,120,0.08) inset;
610
+ overflow: hidden;
611
+ animation: cbMsgIn 0.25s ease both;
612
+ max-width: 320px;
613
+ }
614
+ .cb-invest-card-pulse {
615
+ animation: cbMsgIn 0.25s ease both, cbInvestGlow 2s ease-in-out 0.3s 3;
616
+ }
617
+ @keyframes cbInvestGlow {
618
+ 0%,100% { border-color: rgba(0,230,128,0.35); box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
619
+ 50% { border-color: rgba(0,230,128,0.8); box-shadow: 0 4px 32px rgba(0,200,100,0.3); }
620
+ }
621
+ .cb-invest-note {
622
+ margin: 0 12px 6px;
623
+ font-size: 0.7rem;
624
+ color: rgba(0,230,150,0.7);
625
+ text-align: center;
626
+ }
627
+ .cb-invest-header {
628
+ display: flex; align-items: center; gap: 8px;
629
+ padding: 8px 12px 6px;
630
+ background: linear-gradient(135deg, rgba(0,180,255,0.1), rgba(0,60,200,0.07));
631
+ border-bottom: 1px solid rgba(0,200,255,0.08);
632
+ font-size: 0.76rem; font-weight: 700;
633
+ color: #00e5ff; letter-spacing: 0.02em;
634
+ }
635
+ .cb-invest-details {
636
+ padding: 7px 12px;
637
+ display: flex; flex-direction: column; gap: 3px;
638
+ }
639
+ .cb-invest-row {
640
+ display: flex; justify-content: space-between; align-items: center;
641
+ font-size: 0.76rem;
642
+ }
643
+ .cb-invest-row span { color: rgba(160,210,240,0.5); }
644
+ .cb-invest-row strong { color: #d4eeff; font-weight: 600; }
645
+ .cb-invest-row .cb-buy { color: #3ef5a0; }
646
+ .cb-invest-row .cb-sell { color: #ff6060; }
647
+ .cb-invest-actions {
648
+ display: flex; gap: 8px;
649
+ padding: 0 12px 10px;
650
+ }
651
+ .cb-invest-confirm {
652
+ flex: 1;
653
+ padding: 7px 10px;
654
+ border-radius: 9px; border: none;
655
+ background: linear-gradient(135deg, #00d084, #00a868);
656
+ color: #fff; font-size: 0.78rem; font-weight: 700;
657
+ cursor: pointer; transition: opacity 0.2s, transform 0.15s;
658
+ letter-spacing: 0.01em;
659
+ }
660
+ .cb-invest-confirm:hover { opacity: 0.88; transform: translateY(-1px); }
661
+ .cb-invest-confirm:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
662
+ .cb-invest-cancel {
663
+ padding: 7px 12px;
664
+ border-radius: 9px;
665
+ border: 1px solid rgba(255,255,255,0.08);
666
+ background: rgba(255,255,255,0.04);
667
+ color: rgba(160,210,240,0.5);
668
+ font-size: 0.75rem; cursor: pointer;
669
+ transition: all 0.2s;
670
+ }
671
+ .cb-invest-cancel:hover {
672
+ background: rgba(255,80,80,0.08);
673
+ border-color: rgba(255,80,80,0.2);
674
+ color: #ff7070;
675
+ }
676
+ .cb-invest-success, .cb-invest-error {
677
+ padding: 12px 14px;
678
+ font-size: 0.78rem; line-height: 1.6;
679
+ }
680
+ .cb-invest-success { color: #3ef5a0; }
681
+ .cb-invest-error { color: #ff6060; }
682
+ .cb-invest-link {
683
+ display: inline-block;
684
+ margin-top: 6px;
685
+ color: #00e5ff;
686
+ text-decoration: none;
687
+ font-weight: 600;
688
+ font-size: 0.76rem;
689
+ }
690
+ .cb-invest-link:hover { text-decoration: underline; }
691
+
692
+ /* ── Modal comparaison modèles — fenêtre déplaçable ─────────── */
693
+ #cb-compare-modal {
694
+ position: fixed;
695
+ z-index: 99999;
696
+ width: 440px;
697
+ max-height: 90vh;
698
+ background: linear-gradient(160deg, rgba(4,10,34,0.99), rgba(2,6,20,1));
699
+ border: 1px solid rgba(0,200,255,0.18);
700
+ border-radius: 18px;
701
+ box-shadow: 0 24px 80px rgba(0,0,0,0.75), 0 0 0 1px rgba(0,180,255,0.08) inset;
702
+ display: flex;
703
+ flex-direction: column;
704
+ overflow: hidden;
705
+ animation: cbModalIn 0.22s cubic-bezier(0.22,1,0.36,1) both;
706
+ }
707
+ @keyframes cbModalIn {
708
+ from { opacity: 0; transform: translateY(14px) scale(0.96); }
709
+ to { opacity: 1; transform: translateY(0) scale(1); }
710
+ }
711
+ .cb-modal-header {
712
+ display: flex; align-items: center; justify-content: space-between;
713
+ padding: 11px 14px 10px;
714
+ background: rgba(0,180,255,0.08);
715
+ border-bottom: 1px solid rgba(0,200,255,0.13);
716
+ flex-shrink: 0;
717
+ user-select: none;
718
+ }
719
+ .cb-modal-title {
720
+ display: flex; align-items: center; gap: 8px;
721
+ font-size: 0.77rem; font-weight: 700; color: #00d4ff;
722
+ letter-spacing: 0.02em;
723
+ }
724
+ .cb-modal-drag-hint {
725
+ font-size: 0.72rem;
726
+ color: rgba(120,180,220,0.35);
727
+ pointer-events: none;
728
+ }
729
+ .cb-modal-close {
730
+ background: rgba(255,255,255,0.05);
731
+ border: 1px solid rgba(255,255,255,0.08);
732
+ cursor: pointer;
733
+ color: rgba(160,210,240,0.6);
734
+ font-size: 0.78rem;
735
+ padding: 4px 8px; border-radius: 6px;
736
+ transition: all 0.15s;
737
+ }
738
+ .cb-modal-close:hover { background: rgba(255,80,80,0.15); color: #ff6060; border-color: rgba(255,80,80,0.3); }
739
+ .cb-modal-body {
740
+ flex: 1;
741
+ overflow-y: auto;
742
+ padding: 12px 14px 14px;
743
+ scrollbar-width: thin;
744
+ scrollbar-color: rgba(0,180,255,0.15) transparent;
745
+ }
746
+ .cb-modal-body::-webkit-scrollbar { width: 3px; }
747
+ .cb-modal-body::-webkit-scrollbar-thumb { background: rgba(0,180,255,0.2); border-radius: 2px; }
748
+ .cb-modal-subtitle {
749
+ font-size: 0.66rem;
750
+ color: rgba(120,180,220,0.4);
751
+ text-align: center;
752
+ margin-bottom: 12px;
753
+ }
754
+ .cb-modal-loading {
755
+ text-align: center;
756
+ padding: 50px 10px;
757
+ font-size: 0.82rem;
758
+ color: rgba(160,210,240,0.55);
759
+ line-height: 2;
760
+ }
761
+ .cb-modal-loading i { color: #00d4ff; font-size: 2rem; display: block; margin-bottom: 14px; }
762
+ .cb-modal-loading small { color: rgba(120,180,220,0.3); font-size: 0.69rem; }
763
+ .cb-modal-error {
764
+ text-align: center;
765
+ padding: 30px 10px;
766
+ font-size: 0.75rem;
767
+ color: rgba(255,150,150,0.65);
768
+ line-height: 1.7;
769
+ }
770
+ .cb-modal-model {
771
+ border-radius: 12px;
772
+ border: 1px solid rgba(255,255,255,0.06);
773
+ background: rgba(255,255,255,0.025);
774
+ padding: 11px 13px;
775
+ margin-bottom: 10px;
776
+ }
777
+ .cb-modal-model-best {
778
+ border-color: rgba(0,230,128,0.28);
779
+ background: rgba(0,230,128,0.035);
780
+ box-shadow: 0 0 0 1px rgba(0,230,128,0.08) inset;
781
+ }
782
+ .cb-modal-model-head {
783
+ display: flex; align-items: center; justify-content: space-between;
784
+ margin-bottom: 7px;
785
+ }
786
+ .cb-modal-explain {
787
+ font-size: 0.72rem;
788
+ color: rgba(160,210,240,0.65);
789
+ line-height: 1.65;
790
+ margin-bottom: 8px;
791
+ padding: 7px 10px;
792
+ background: rgba(0,0,0,0.2);
793
+ border-radius: 7px;
794
+ border-left: 2px solid rgba(0,200,255,0.2);
795
+ }
796
+ .cb-modal-explain strong { font-weight: 700; }
797
+ .cb-modal-view-btn {
798
+ font-size: 0.62rem; font-weight: 600;
799
+ color: rgba(0,200,255,0.7);
800
+ background: rgba(0,200,255,0.08);
801
+ border: 1px solid rgba(0,200,255,0.2);
802
+ border-radius: 5px;
803
+ padding: 2px 7px;
804
+ text-decoration: none;
805
+ transition: all 0.15s;
806
+ white-space: nowrap;
807
+ }
808
+ .cb-modal-view-btn:hover { background: rgba(0,200,255,0.18); color: #00d4ff; }
809
+ .cb-modal-footer {
810
+ text-align: center;
811
+ font-size: 0.71rem;
812
+ color: rgba(160,210,240,0.38);
813
+ padding: 8px 0 2px;
814
+ border-top: 1px solid rgba(255,255,255,0.05);
815
+ margin-top: 4px;
816
+ }
817
+ .cb-modal-footer strong { color: #00d4ff; }
818
+
819
+ /* ── Shared compare styles ────────────────────────────────────── */
820
+ .cb-cmp-name {
821
+ font-size: 0.78rem; font-weight: 700; color: #d4eeff;
822
+ display: flex; align-items: center; gap: 6px;
823
+ }
824
+ .cb-cmp-name i { color: #00d4ff; font-size: 0.74rem; }
825
+ .cb-cmp-badge {
826
+ font-size: 0.61rem; font-weight: 700; letter-spacing: 0.05em;
827
+ background: rgba(0,230,128,0.18); color: #3ef5a0;
828
+ border: 1px solid rgba(0,230,128,0.35);
829
+ padding: 1px 7px; border-radius: 20px;
830
+ }
831
+ .cb-cmp-sim-summary {
832
+ font-size: 0.70rem; color: rgba(160,210,240,0.55);
833
+ margin-bottom: 4px; line-height: 1.5;
834
+ }
835
+ .cb-cmp-sim-summary strong { font-weight: 700; }
836
+ .cb-cmp-spark {
837
+ margin: 3px 0 6px;
838
+ border-radius: 6px;
839
+ background: rgba(0,0,0,0.35);
840
+ padding: 3px 4px 2px;
841
+ overflow: hidden;
842
+ }
843
+ .cb-cmp-spark-legend {
844
+ display: flex; gap: 12px;
845
+ font-size: 0.59rem; color: rgba(120,180,220,0.45);
846
+ margin-top: 2px; padding-left: 2px;
847
+ }
848
+ .cb-cmp-spark-ai { color: #00d4ff; }
849
+ .cb-cmp-spark-bh { color: rgba(255,255,255,0.28); }
850
+ .cb-cmp-grid {
851
+ display: grid;
852
+ grid-template-columns: 1fr 1fr;
853
+ gap: 5px;
854
+ margin-top: 5px;
855
+ }
856
+ .cb-cmp-cell {
857
+ background: rgba(255,255,255,0.03);
858
+ border: 1px solid rgba(255,255,255,0.05);
859
+ border-radius: 7px;
860
+ padding: 5px 7px;
861
+ display: flex; flex-direction: column; gap: 2px;
862
+ }
863
+ .cb-cmp-lbl {
864
+ font-size: 0.60rem; color: rgba(120,180,220,0.45);
865
+ text-transform: uppercase; letter-spacing: 0.04em;
866
+ }
867
+ .cb-cmp-val { font-size: 0.72rem; font-weight: 600; color: #c8e4f8; }
868
+ .cb-cmp-pos { color: #3ef5a0; }
869
+ .cb-cmp-neg { color: #ff6060; }
870
+
871
+ /* ── Badge "action en attente" sur le bouton toggle ──────────── */
872
+ .cb-pending-badge {
873
+ position: absolute;
874
+ top: -4px;
875
+ right: -4px;
876
+ width: 18px;
877
+ height: 18px;
878
+ border-radius: 50%;
879
+ background: #ff4040;
880
+ color: #fff;
881
+ font-size: 0.65rem;
882
+ font-weight: 700;
883
+ display: flex;
884
+ align-items: center;
885
+ justify-content: center;
886
+ animation: cbPendingPulse 1s ease-in-out infinite;
887
+ pointer-events: none;
888
+ }
889
+ @keyframes cbPendingPulse {
890
+ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255,64,64,0.5); }
891
+ 50% { transform: scale(1.15); box-shadow: 0 0 0 6px rgba(255,64,64,0); }
892
+ }
893
+
894
+ /* ── Loader détection investissement ────────────────────────── */
895
+ .cb-detect-loader {
896
+ display: flex; align-items: center;
897
+ margin: 4px 0 4px 10px;
898
+ padding: 6px 12px;
899
+ border-radius: 10px;
900
+ background: rgba(0,200,255,0.06);
901
+ border: 1px solid rgba(0,200,255,0.12);
902
+ width: fit-content;
903
+ font-size: 0.72rem;
904
+ color: rgba(160,210,240,0.6);
905
+ animation: cbMsgIn 0.2s ease both;
906
+ }
907
+ .cb-detect-loader .chatbot-dot {
908
+ width: 5px; height: 5px;
909
+ border-radius: 50%;
910
+ background: rgba(0,200,255,0.5);
911
+ margin: 0 2px;
912
+ animation: cbTypingDot 1.2s infinite;
913
+ }
914
+ .cb-detect-loader .chatbot-dot:nth-child(2) { animation-delay: 0.2s; }
915
+ .cb-detect-loader .chatbot-dot:nth-child(3) { animation-delay: 0.4s; }
916
+
917
+ /* ── Mobile ──────────────────────────────────────────────────── */
918
+ @media (max-width: 480px) {
919
+ #chatbot-panel { width: calc(100vw - 24px); right: 12px !important; left: 12px !important; }
920
+ #chatbot-toggle { right: 16px !important; bottom: 20px !important; }
921
+ }
Interface Graphique/assets/chatbot.js ADDED
@@ -0,0 +1,1245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ================================================================
2
+ CHATBOT.JS — v6 — Extraction avec contexte historique
3
+ ================================================================ */
4
+ console.log("[Chatbot] v6 chargé — extraction avec contexte historique");
5
+
6
+ (function () {
7
+ "use strict";
8
+
9
+ // ── État global ──────────────────────────────────────────────
10
+ let _history = []; // messages de la conversation active
11
+ let _activeId = null; // id de la conversation active
12
+ let _isStreaming = false;
13
+ let _bound = false;
14
+ let _historyOpen = false;
15
+ let _initialized = false;
16
+
17
+ const POS_KEY = "chatbot_btn_pos";
18
+ const PANEL_POS_KEY = "chatbot_panel_pos";
19
+ const MAX_MSG = 60; // messages max par conversation
20
+ const MAX_CONVS = 30; // conversations max à conserver
21
+
22
+ // ── Clés localStorage ────────────────────────────────────────
23
+
24
+ function _getUserEmail() {
25
+ try {
26
+ const raw = sessionStorage.getItem("session-store");
27
+ if (!raw) return null;
28
+ const d = JSON.parse(raw);
29
+ return d && d.email ? d.email : null;
30
+ } catch (_) { return null; }
31
+ }
32
+
33
+ function _storeKey() {
34
+ const email = _getUserEmail();
35
+ const hash = email ? btoa(email).replace(/[^a-zA-Z0-9]/g, "") : "guest";
36
+ return "chatbot_convs_" + hash;
37
+ }
38
+
39
+ // ── Persistance conversations ────────────────────────────────
40
+
41
+ function _loadStore() {
42
+ try {
43
+ const raw = localStorage.getItem(_storeKey());
44
+ return raw ? JSON.parse(raw) : { activeId: null, list: [] };
45
+ } catch (_) { return { activeId: null, list: [] }; }
46
+ }
47
+
48
+ function _saveStore(store) {
49
+ try { localStorage.setItem(_storeKey(), JSON.stringify(store)); } catch (_) {}
50
+ }
51
+
52
+ // Génère un titre à partir du 1er message utilisateur
53
+ function _autoTitle(text) {
54
+ if (!text) return "Nouvelle conversation";
55
+ const t = text.trim().replace(/\s+/g, " ");
56
+ return t.length > 45 ? t.slice(0, 42) + "…" : t;
57
+ }
58
+
59
+ // Formate la date de façon relative
60
+ function _relDate(isoStr) {
61
+ try {
62
+ const d = new Date(isoStr);
63
+ const now = new Date();
64
+ const diff = Math.floor((now - d) / 86400000);
65
+ if (diff === 0) return "Aujourd'hui";
66
+ if (diff === 1) return "Hier";
67
+ if (diff < 7) return "Il y a " + diff + " jours";
68
+ if (diff < 30) return "Il y a " + Math.floor(diff / 7) + " sem.";
69
+ return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
70
+ } catch (_) { return ""; }
71
+ }
72
+
73
+ // ── Gestion des conversations ─────────────────────────────────
74
+
75
+ function _newConversation() {
76
+ _history = [];
77
+ _activeId = "conv_" + Date.now();
78
+
79
+ // Réinitialiser la zone messages
80
+ const container = $("#chatbot-messages");
81
+ if (container) {
82
+ container.innerHTML = "";
83
+ const wrap = document.createElement("div");
84
+ wrap.id = "chatbot-welcome";
85
+ wrap.className = "chatbot-msg chatbot-msg-bot";
86
+ wrap.innerHTML = `<div class="chatbot-bubble">
87
+ <strong> Bonjour !</strong><br>
88
+ Je suis votre assistant IA. Posez-moi vos questions sur la plateforme, les modèles de prédiction ou les actions disponibles.
89
+ </div>`;
90
+ container.appendChild(wrap);
91
+ }
92
+
93
+ // Passer à la vue chat
94
+ if (_historyOpen) _toggleHistoryView(false);
95
+
96
+ setTimeout(() => { const i = $("#chatbot-input"); if (i) { i.value = ""; i.focus(); } }, 100);
97
+ }
98
+
99
+ function _saveCurrentConversation() {
100
+ if (!_activeId || _history.length === 0) return;
101
+ const store = _loadStore();
102
+ const idx = store.list.findIndex(c => c.id === _activeId);
103
+ const now = new Date().toISOString();
104
+
105
+ const title = _autoTitle(
106
+ (_history.find(m => m.role === "user") || {}).content || ""
107
+ );
108
+
109
+ if (idx >= 0) {
110
+ store.list[idx].messages = _history.slice(-MAX_MSG);
111
+ store.list[idx].updated = now;
112
+ store.list[idx].title = title;
113
+ } else {
114
+ store.list.unshift({
115
+ id: _activeId,
116
+ title: title,
117
+ created: now,
118
+ updated: now,
119
+ messages: _history.slice(-MAX_MSG),
120
+ });
121
+ // Limiter le nombre de conversations
122
+ if (store.list.length > MAX_CONVS) {
123
+ store.list = store.list.slice(0, MAX_CONVS);
124
+ }
125
+ }
126
+ store.activeId = _activeId;
127
+ _saveStore(store);
128
+ }
129
+
130
+ function _loadConversation(id) {
131
+ const store = _loadStore();
132
+ const conv = store.list.find(c => c.id === id);
133
+ if (!conv) return;
134
+
135
+ _activeId = id;
136
+ _history = conv.messages || [];
137
+
138
+ // Rerendre les messages
139
+ const container = $("#chatbot-messages");
140
+ if (container) {
141
+ container.innerHTML = "";
142
+ _history.forEach(function (msg) {
143
+ const role = msg.role === "assistant" ? "bot" : msg.role;
144
+ const wrap = document.createElement("div");
145
+ wrap.className = "chatbot-msg chatbot-msg-" + role;
146
+ const bubble = document.createElement("div");
147
+ bubble.className = "chatbot-bubble";
148
+ bubble.textContent = msg.content;
149
+ wrap.appendChild(bubble);
150
+ container.appendChild(wrap);
151
+ });
152
+ }
153
+ scrollBottom();
154
+
155
+ // Fermer l'historique, aller sur la vue chat
156
+ _toggleHistoryView(false);
157
+ setTimeout(() => { const i = $("#chatbot-input"); if (i) i.focus(); }, 200);
158
+ }
159
+
160
+ function _deleteConversation(id, e) {
161
+ e.stopPropagation();
162
+ const store = _loadStore();
163
+ store.list = store.list.filter(c => c.id !== id);
164
+ if (store.activeId === id) store.activeId = null;
165
+ _saveStore(store);
166
+
167
+ // Si on vient de supprimer la conv active → nouvelle conv
168
+ if (_activeId === id) _newConversation();
169
+
170
+ // Rafraîchir la liste
171
+ _renderHistoryList();
172
+ }
173
+
174
+ // ── Vue historique ────────────────────────────────────────────
175
+
176
+ function _toggleHistoryView(forceState) {
177
+ _historyOpen = (forceState !== undefined) ? forceState : !_historyOpen;
178
+
179
+ const chatView = $("#chatbot-chat-view");
180
+ const histView = $("#chatbot-history-view");
181
+ const btn = $("#chatbot-history-btn");
182
+ if (!chatView || !histView) return;
183
+
184
+ if (_historyOpen) {
185
+ chatView.classList.add("cb-view-hidden");
186
+ histView.classList.remove("cb-view-hidden");
187
+ btn && btn.classList.add("cb-btn-active");
188
+ _renderHistoryList();
189
+ } else {
190
+ histView.classList.add("cb-view-hidden");
191
+ chatView.classList.remove("cb-view-hidden");
192
+ btn && btn.classList.remove("cb-btn-active");
193
+ scrollBottom();
194
+ }
195
+ }
196
+
197
+ function _renderHistoryList() {
198
+ const list = $("#chatbot-history-list");
199
+ if (!list) return;
200
+
201
+ const store = _loadStore();
202
+ list.innerHTML = "";
203
+
204
+ if (store.list.length === 0) {
205
+ list.innerHTML = `<div class="cb-hist-empty">
206
+ <i class="fa-solid fa-comments"></i>
207
+ <p>Aucune conversation sauvegardée</p>
208
+ </div>`;
209
+ return;
210
+ }
211
+
212
+ // Grouper par date (Aujourd'hui / Hier / Cette semaine / Plus ancien)
213
+ const groups = {};
214
+ store.list.forEach(function (conv) {
215
+ const label = _relDate(conv.updated);
216
+ if (!groups[label]) groups[label] = [];
217
+ groups[label].push(conv);
218
+ });
219
+
220
+ Object.keys(groups).forEach(function (label) {
221
+ const section = document.createElement("div");
222
+ section.className = "cb-hist-section";
223
+
224
+ const heading = document.createElement("div");
225
+ heading.className = "cb-hist-section-label";
226
+ heading.textContent = label;
227
+ section.appendChild(heading);
228
+
229
+ groups[label].forEach(function (conv) {
230
+ const item = document.createElement("div");
231
+ item.className = "cb-hist-item" + (conv.id === _activeId ? " cb-hist-item-active" : "");
232
+
233
+ const preview = (conv.messages.slice().reverse().find(m => m.role === "assistant") || {}).content || "";
234
+ const previewShort = preview.slice(0, 55) + (preview.length > 55 ? "…" : "");
235
+
236
+ item.innerHTML = `
237
+ <div class="cb-hist-item-body">
238
+ <div class="cb-hist-item-title">${_esc(conv.title)}</div>
239
+ <div class="cb-hist-item-preview">${_esc(previewShort)}</div>
240
+ </div>
241
+ <button class="cb-hist-delete" title="Supprimer">
242
+ <i class="fa-solid fa-trash-can"></i>
243
+ </button>
244
+ `;
245
+
246
+ item.querySelector(".cb-hist-item-body").addEventListener("click", function () {
247
+ _loadConversation(conv.id);
248
+ });
249
+ item.querySelector(".cb-hist-delete").addEventListener("click", function (e) {
250
+ _deleteConversation(conv.id, e);
251
+ });
252
+
253
+ section.appendChild(item);
254
+ });
255
+
256
+ list.appendChild(section);
257
+ });
258
+ }
259
+
260
+ function _esc(str) {
261
+ return (str || "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
262
+ }
263
+
264
+ // ── Helpers DOM ──────────────────────────────────────────────
265
+
266
+ function $(sel) { return document.querySelector(sel); }
267
+
268
+ function scrollBottom() {
269
+ const msgs = $("#chatbot-messages");
270
+ if (msgs) msgs.scrollTop = msgs.scrollHeight;
271
+ }
272
+
273
+ function addMessage(role, text, id) {
274
+ const msgs = $("#chatbot-messages");
275
+ if (!msgs) return null;
276
+ const wrap = document.createElement("div");
277
+ wrap.className = "chatbot-msg chatbot-msg-" + role;
278
+ if (id) wrap.id = id;
279
+ const bubble = document.createElement("div");
280
+ bubble.className = "chatbot-bubble";
281
+ bubble.textContent = text;
282
+ wrap.appendChild(bubble);
283
+ msgs.appendChild(wrap);
284
+ scrollBottom();
285
+ return bubble;
286
+ }
287
+
288
+ function showTyping() {
289
+ const msgs = $("#chatbot-messages");
290
+ if (!msgs) return;
291
+ const wrap = document.createElement("div");
292
+ wrap.className = "chatbot-msg chatbot-msg-bot";
293
+ wrap.id = "chatbot-typing";
294
+ wrap.innerHTML = `<div class="chatbot-bubble chatbot-typing-bubble">
295
+ <span class="chatbot-dot"></span><span class="chatbot-dot"></span><span class="chatbot-dot"></span>
296
+ </div>`;
297
+ msgs.appendChild(wrap);
298
+ scrollBottom();
299
+ }
300
+
301
+ function removeTyping() {
302
+ const t = $("#chatbot-typing");
303
+ if (t) t.remove();
304
+ }
305
+
306
+ // ── Voix : MediaRecorder + Groq Whisper ─────────────────────
307
+ // Fiable sur HTTP local — pas de dépendance Chrome Speech API
308
+
309
+ let _isListening = false;
310
+ let _mediaRecorder = null;
311
+ let _audioChunks = [];
312
+
313
+ function _setMicState(active) {
314
+ _isListening = active;
315
+ const btn = $("#chatbot-mic");
316
+ if (!btn) return;
317
+ if (active) {
318
+ btn.classList.add("cb-mic-active");
319
+ btn.title = "Arrêter";
320
+ } else {
321
+ btn.classList.remove("cb-mic-active");
322
+ btn.title = "Parler";
323
+ }
324
+ }
325
+
326
+ function toggleVoice() {
327
+ const input = $("#chatbot-input");
328
+
329
+ if (!_getUserEmail()) {
330
+ if (input) input.placeholder = "Connectez-vous pour utiliser l'assistant…";
331
+ return;
332
+ }
333
+
334
+ // Arrêter l'enregistrement
335
+ if (_isListening && _mediaRecorder) {
336
+ _mediaRecorder.stop();
337
+ return;
338
+ }
339
+
340
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
341
+ addMessage("bot", "Accès au microphone non disponible sur ce navigateur.");
342
+ return;
343
+ }
344
+
345
+ navigator.mediaDevices.getUserMedia({ audio: true })
346
+ .then(function (stream) {
347
+ _audioChunks = [];
348
+
349
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
350
+ ? 'audio/webm;codecs=opus'
351
+ : MediaRecorder.isTypeSupported('audio/webm')
352
+ ? 'audio/webm'
353
+ : 'audio/ogg;codecs=opus';
354
+
355
+ _mediaRecorder = new MediaRecorder(stream, { mimeType });
356
+
357
+ _mediaRecorder.ondataavailable = function (e) {
358
+ if (e.data && e.data.size > 0) _audioChunks.push(e.data);
359
+ };
360
+
361
+ _mediaRecorder.onstop = async function () {
362
+ stream.getTracks().forEach(t => t.stop());
363
+ _setMicState(false);
364
+ if (_audioChunks.length === 0) return;
365
+
366
+ const blob = new Blob(_audioChunks, { type: mimeType });
367
+ _audioChunks = [];
368
+ if (input) input.placeholder = "Transcription en cours…";
369
+
370
+ try {
371
+ const formData = new FormData();
372
+ formData.append('audio', blob, 'audio.webm');
373
+ const resp = await fetch('/api/transcribe', { method: 'POST', body: formData });
374
+ const data = await resp.json();
375
+
376
+ if (data.text && data.text.trim()) {
377
+ if (input) {
378
+ input.value = data.text.trim();
379
+ input.placeholder = "Posez votre question ou parlez…";
380
+ }
381
+ setTimeout(sendMessage, 300);
382
+ } else {
383
+ if (input) input.placeholder = "Posez votre question ou parlez…";
384
+ addMessage("bot", data.error ? data.error : "Aucune parole détectée, réessayez.");
385
+ }
386
+ } catch (err) {
387
+ if (input) input.placeholder = "Posez votre question ou parlez…";
388
+ addMessage("bot", "Erreur transcription : " + err.message);
389
+ }
390
+ };
391
+
392
+ _setMicState(true);
393
+ if (input) input.placeholder = "Parlez… re-cliquez pour arrêter";
394
+ _mediaRecorder.start();
395
+ })
396
+ .catch(function (err) {
397
+ _setMicState(false);
398
+ if (err.name === 'NotAllowedError') {
399
+ addMessage("bot", "Permission micro refusée.\nAutorisez le micro dans les paramètres du navigateur.");
400
+ } else {
401
+ addMessage("bot", "Micro inaccessible : " + err.message);
402
+ }
403
+ });
404
+ }
405
+
406
+ // ── Comparaison modèles ──────────────────────────────────────
407
+
408
+ // Intention : "quel modèle pour Apple ?" / "compare les modèles sur TSLA" etc.
409
+ // Regex sans caractères spéciaux pour éviter les problèmes d'encodage
410
+ const _COMPARE_RE = /quel\s+mod.le|meilleur\s+mod.le|compare\s+.{0,15}mod.le|mod.le\s+.{0,20}conseill|recommand.{0,10}mod.le|quel\s+mod.l|si\s+j.avais\s+suivi|quel\s+algo|comparer\s+les\s+mod/i;
411
+
412
+ function _extractCompareSymbol(text, history) {
413
+ const lo = ' ' + text.toLowerCase() + ' ';
414
+ const ctxLo = lo + ' ' + (history || []).slice(-8).map(m => (m.content || '').toLowerCase()).join(' ') + ' ';
415
+ const sym = _findSymbol(lo) || _findSymbol(ctxLo);
416
+ console.log("[Chatbot] _extractCompareSymbol:", sym, "| lo:", lo.slice(0,60));
417
+ return sym;
418
+ }
419
+
420
+ function _makeSpark(series, bhSeries, W, H) {
421
+ W = W || 380; H = H || 80;
422
+ if (!series || series.length < 2) return '';
423
+ const all = [...series, ...(bhSeries || [])];
424
+ const mn = Math.min(...all), mx = Math.max(...all);
425
+ const rng = mx - mn || 1;
426
+ const PAD = { t: 6, b: 6, l: 4, r: 4 };
427
+ const iW = W - PAD.l - PAD.r, iH = H - PAD.t - PAD.b;
428
+ const vy = (v) => (PAD.t + (mx - v) / rng * iH).toFixed(1);
429
+ const vx = (i, len) => (PAD.l + i / (len - 1) * iW).toFixed(1);
430
+ const pts = (arr) => arr.map((v, i) => `${vx(i, arr.length)},${vy(v)}`).join(' ');
431
+ const bhLine = bhSeries && bhSeries.length > 1
432
+ ? `<polyline points="${pts(bhSeries)}" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.2" stroke-dasharray="4,3"/>`
433
+ : '';
434
+ // Coloring: green if final > start, red otherwise
435
+ const finalV = series[series.length - 1];
436
+ const startV = series[0];
437
+ const lineCol = finalV >= startV ? '#3ef5a0' : '#ff6060';
438
+ // Dashed line at start value (reference)
439
+ const refY = vy(startV);
440
+ const refLine = `<line x1="${PAD.l}" y1="${refY}" x2="${W - PAD.r}" y2="${refY}" stroke="rgba(255,255,255,0.1)" stroke-width="1" stroke-dasharray="2,4"/>`;
441
+ return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" style="width:100%;height:${H}px;display:block">
442
+ ${refLine}
443
+ ${bhLine}
444
+ <polyline points="${pts(series)}" fill="none" stroke="${lineCol}" stroke-width="2"/>
445
+ </svg>`;
446
+ }
447
+
448
+ function _makeDraggable(el, handle) {
449
+ let sx = 0, sy = 0, sl = 0, st = 0, drag = false;
450
+ handle.style.cursor = 'grab';
451
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
452
+ const onDown = (cx, cy) => {
453
+ drag = true;
454
+ const r = el.getBoundingClientRect();
455
+ sx = cx; sy = cy; sl = r.left; st = r.top;
456
+ handle.style.cursor = 'grabbing';
457
+ el.style.transition = 'none';
458
+ };
459
+ const onMove = (cx, cy) => {
460
+ if (!drag) return;
461
+ const nl = clamp(sl + cx - sx, 0, window.innerWidth - el.offsetWidth);
462
+ const nt = clamp(st + cy - sy, 0, window.innerHeight - el.offsetHeight);
463
+ el.style.left = nl + 'px'; el.style.top = nt + 'px';
464
+ el.style.right = 'auto'; el.style.bottom = 'auto';
465
+ };
466
+ const onUp = () => { drag = false; handle.style.cursor = 'grab'; };
467
+ handle.addEventListener('mousedown', e => {
468
+ if (e.target.closest('.cb-modal-close')) return;
469
+ e.preventDefault(); onDown(e.clientX, e.clientY);
470
+ });
471
+ document.addEventListener('mousemove', e => onMove(e.clientX, e.clientY));
472
+ document.addEventListener('mouseup', onUp);
473
+ handle.addEventListener('touchstart', e => {
474
+ const t = e.touches[0]; onDown(t.clientX, t.clientY);
475
+ }, { passive: true });
476
+ handle.addEventListener('touchmove', e => {
477
+ const t = e.touches[0]; onMove(t.clientX, t.clientY);
478
+ }, { passive: true });
479
+ handle.addEventListener('touchend', onUp, { passive: true });
480
+ }
481
+
482
+ async function _loadAndShowCompare(symbol, bubble) {
483
+ if (!symbol) return;
484
+
485
+ console.log("[Chatbot] Ouverture modal comparaison pour:", symbol);
486
+ const modal = _openCompareModal(symbol);
487
+ if (!modal) { console.warn("[Chatbot] panel introuvable pour modal"); return; }
488
+
489
+ try {
490
+ const resp = await fetch(`/api/backtest-compare?symbol=${encodeURIComponent(symbol)}`);
491
+ const data = await resp.json();
492
+ const body = document.getElementById("cb-modal-body");
493
+ if (!body) return;
494
+ if (data.error) {
495
+ body.innerHTML = `<div class="cb-modal-error">Donnees insuffisantes pour ${symbol}.<br><small>Ce ticker n'est peut-etre pas supporte par nos modeles.</small></div>`;
496
+ } else {
497
+ _populateCompareModal(body, symbol, data);
498
+ // Ajouter un message chat avec les vrais résultats (corrige la reco générique du LLM)
499
+ _addBacktestSummaryToChat(symbol, data);
500
+ }
501
+ } catch (e) {
502
+ const body = document.getElementById("cb-modal-body");
503
+ if (body) body.innerHTML = `<div class="cb-modal-error">Erreur de connexion au serveur.</div>`;
504
+ console.warn("[Chatbot] backtest-compare fetch error:", e);
505
+ }
506
+ }
507
+
508
+ function _addBacktestSummaryToChat(symbol, data) {
509
+ const models = data.models || {};
510
+ const best = data.best_model;
511
+ const start = data.start || 500;
512
+ const keys = Object.keys(models);
513
+ if (keys.length === 0) return;
514
+
515
+ const lines = keys.map(k => {
516
+ const m = models[k];
517
+ const gain = m.final - start;
518
+ const sign = gain >= 0 ? '+' : '';
519
+ const isBest = k === best;
520
+ return `• ${m.label} : ${sign}${gain.toFixed(2)}€ (${sign}${m.return_pct}%) sur 500€${isBest ? ' ← meilleur' : ''}`;
521
+ }).join('\n');
522
+
523
+ const bestM = models[best] || {};
524
+ const bestGain = (bestM.final || start) - start;
525
+ const bestSign = bestGain >= 0 ? '+' : '';
526
+ const bestLabel = bestM.label || best;
527
+
528
+ 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}.`;
529
+
530
+ addMessage("bot", msg);
531
+ _history.push({ role: "assistant", content: msg });
532
+ _saveCurrentConversation();
533
+ setTimeout(() => { const m = $("#chatbot-messages"); if (m) m.scrollTop = m.scrollHeight; }, 100);
534
+ }
535
+
536
+ function _openCompareModal(symbol) {
537
+ const old = document.getElementById("cb-compare-modal");
538
+ if (old) old.remove();
539
+
540
+ const modal = document.createElement("div");
541
+ modal.id = "cb-compare-modal";
542
+
543
+ // Position initiale : à gauche du chatbot panel
544
+ const panel = document.getElementById("chatbot-panel");
545
+ if (panel) {
546
+ const r = panel.getBoundingClientRect();
547
+ const mw = 440;
548
+ let left = r.left - mw - 14;
549
+ if (left < 10) left = Math.min(r.right + 14, window.innerWidth - mw - 10);
550
+ const top = Math.max(10, r.top - 10);
551
+ modal.style.cssText = `left:${left}px;top:${top}px;`;
552
+ }
553
+
554
+ modal.innerHTML = `
555
+ <div class="cb-modal-header" id="cb-modal-drag-handle">
556
+ <div class="cb-modal-title">
557
+ <i class="fas fa-chart-line"></i>
558
+ <span>Si j'avais suivi les conseils — ${symbol}</span>
559
+ </div>
560
+ <div style="display:flex;align-items:center;gap:6px">
561
+ <span class="cb-modal-drag-hint"><i class="fas fa-arrows-alt"></i></span>
562
+ <button class="cb-modal-close" id="cb-modal-close-btn"><i class="fas fa-times"></i></button>
563
+ </div>
564
+ </div>
565
+ <div class="cb-modal-body" id="cb-modal-body">
566
+ <div class="cb-modal-loading">
567
+ <i class="fas fa-spinner fa-spin"></i>
568
+ Simulation en cours...<br>
569
+ <small>Les 3 modeles sont calcules en parallele (~15s)</small>
570
+ </div>
571
+ </div>`;
572
+
573
+ document.body.appendChild(modal);
574
+ document.getElementById("cb-modal-close-btn").addEventListener("click", () => modal.remove());
575
+ _makeDraggable(modal, document.getElementById("cb-modal-drag-handle"));
576
+ return modal;
577
+ }
578
+
579
+ function _populateCompareModal(body, symbol, data) {
580
+ const MODEL_ICON = { lstm: 'fas fa-brain', transformer: 'fas fa-atom', sentiment: 'fas fa-newspaper' };
581
+ const models = data.models || {};
582
+ const best = data.best_model;
583
+ const start = data.start || 500;
584
+ const keys = Object.keys(models);
585
+ if (keys.length === 0) {
586
+ body.innerHTML = `<div class="cb-modal-error">Aucun modele n'a pu simuler ${symbol}.</div>`;
587
+ return;
588
+ }
589
+ const rows = keys.map(k => {
590
+ const m = models[k];
591
+ const isBest = k === best;
592
+ const gain = m.final - start;
593
+ const gainEur = (gain >= 0 ? '+' : '') + gain.toFixed(2);
594
+ const gainCls = gain >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
595
+ const retSign = m.return_pct >= 0 ? '+' : '';
596
+ const bhSign = m.bh_return >= 0 ? '+' : '';
597
+ const vsPassif = m.final - m.bh_final;
598
+ const vsEur = (vsPassif >= 0 ? '+' : '') + vsPassif.toFixed(2);
599
+ const vsCls = vsPassif >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
600
+ const vsText = vsPassif >= 0
601
+ ? `<span class="${vsCls}">${vsEur}€ de plus</span> qu'un simple achat sans IA`
602
+ : `<span class="${vsCls}">${vsEur}€ de moins</span> qu'un simple achat sans IA`;
603
+ const spark = _makeSpark(m.series, m.bh_series, 400, 80);
604
+ return `
605
+ <div class="cb-modal-model${isBest ? ' cb-modal-model-best' : ''}">
606
+ <div class="cb-modal-model-head">
607
+ <span class="cb-cmp-name"><i class="${MODEL_ICON[k] || 'fas fa-robot'}"></i> ${m.label}</span>
608
+ <div style="display:flex;align-items:center;gap:7px">
609
+ ${isBest ? '<span class="cb-cmp-badge">Recommande</span>' : ''}
610
+ <a href="/mon-suivi?mode=${k}&ticker=${symbol}" class="cb-modal-view-btn" title="Voir dans Mon Suivi">
611
+ <i class="fas fa-external-link-alt"></i> Mon Suivi
612
+ </a>
613
+ </div>
614
+ </div>
615
+ <div class="cb-modal-explain">
616
+ Si vous aviez investi <strong>${start}€</strong> sur <strong>${symbol}</strong>
617
+ depuis le <strong>${m.start_date}</strong> en suivant le modele <strong>${m.label}</strong>,
618
+ vous auriez maintenant
619
+ <strong class="${gainCls}">${m.final.toFixed(2)}€</strong> — soit
620
+ <strong class="${gainCls}">${gainEur}€ (${retSign}${m.return_pct}%)</strong>.
621
+ C'est ${vsText} (qui aurait donne <strong>${m.bh_final.toFixed(2)}€</strong>).
622
+ </div>
623
+ ${spark ? `<div class="cb-cmp-spark">${spark}<div class="cb-cmp-spark-legend"><span class="cb-cmp-spark-ai">— Modele IA</span><span class="cb-cmp-spark-bh">- - Sans IA (buy&amp;hold)</span></div></div>` : ''}
624
+ <div class="cb-cmp-grid">
625
+ <div class="cb-cmp-cell">
626
+ <span class="cb-cmp-lbl">Valeur finale</span>
627
+ <span class="cb-cmp-val ${gainCls}">${m.final.toFixed(2)}€</span>
628
+ </div>
629
+ <div class="cb-cmp-cell">
630
+ <span class="cb-cmp-lbl">Taux reussite</span>
631
+ <span class="cb-cmp-val">${m.win_rate}%</span>
632
+ </div>
633
+ <div class="cb-cmp-cell">
634
+ <span class="cb-cmp-lbl">Sans IA (buy&amp;hold)</span>
635
+ <span class="cb-cmp-val">${m.bh_final.toFixed(2)}€ (${bhSign}${m.bh_return}%)</span>
636
+ </div>
637
+ </div>
638
+ </div>`;
639
+ }).join('');
640
+ const bestLabel = best ? (models[best]?.label || best) : '—';
641
+ body.innerHTML = `
642
+ <div class="cb-modal-subtitle">Simulation sur 180 jours · investissement de reference : ${start}€</div>
643
+ ${rows}
644
+ <div class="cb-modal-footer">Recommandation : <strong>${bestLabel}</strong> — meilleur rendement sur la periode</div>`;
645
+ }
646
+
647
+ // _showCompareCard gardé pour compat (ne fait rien, modal gere tout)
648
+ function _showCompareCard() {}
649
+
650
+ // ── Détection marqueur investissement ───────────────────────
651
+
652
+ const INVEST_RE = /##INVEST##\s*(\{[\s\S]*?\})\s*##/;
653
+
654
+ function _parseInvestMarker(text) {
655
+ const m = text.match(INVEST_RE);
656
+ if (!m) return null;
657
+ try { return JSON.parse(m[1]); } catch(_) { return null; }
658
+ }
659
+
660
+ function _stripInvestMarker(text) {
661
+ return text.replace(INVEST_RE, "").trim();
662
+ }
663
+
664
+ function _showInvestConfirm(bubble, data) {
665
+ console.log("[Chatbot] _showInvestConfirm appelé:", data);
666
+ const MODEL_LABELS = { sentiment: "Actualités", lstm: "LSTM", transformer: "Transformer" };
667
+ const card = document.createElement("div");
668
+ card.className = "cb-invest-card cb-invest-card-pulse";
669
+ card.innerHTML = `
670
+ <div class="cb-invest-header">
671
+ <i class="fa-solid fa-briefcase"></i>
672
+ <span>Confirmez l'enregistrement</span>
673
+ </div>
674
+ <div class="cb-invest-details">
675
+ <div class="cb-invest-row"><span>Action</span><strong>${data.symbol}</strong></div>
676
+ <div class="cb-invest-row"><span>Modèle</span><strong>${MODEL_LABELS[data.model] || data.model}</strong></div>
677
+ <div class="cb-invest-row"><span>Sens</span><strong class="${data.action==='ACHETER'?'cb-buy':'cb-sell'}">${data.action}</strong></div>
678
+ <div class="cb-invest-row"><span>Montant</span><strong>${data.amount} €</strong></div>
679
+ </div>
680
+ <p class="cb-invest-note">Cliquez sur le bouton ci-dessous pour enregistrer dans Mon Suivi</p>
681
+ <div class="cb-invest-actions">
682
+ <button class="cb-invest-confirm">Enregistrer dans Mon Suivi</button>
683
+ <button class="cb-invest-cancel">Annuler</button>
684
+ </div>
685
+ `;
686
+
687
+ // Insérer après la bulle bot (avec fallback sur le container)
688
+ const wrap = bubble ? bubble.closest(".chatbot-msg") : null;
689
+ if (wrap && wrap.parentNode) {
690
+ wrap.parentNode.insertBefore(card, wrap.nextSibling);
691
+ } else {
692
+ const msgs = $("#chatbot-messages");
693
+ if (msgs) msgs.appendChild(card);
694
+ }
695
+
696
+ // Forcer l'ouverture du panel si fermé — ouvrir directement sans réinitialiser
697
+ const panel = $("#chatbot-panel");
698
+ if (panel && !panel.classList.contains("chatbot-open")) {
699
+ _positionPanel();
700
+ panel.classList.remove("chatbot-hidden");
701
+ panel.classList.add("chatbot-open");
702
+ const tb = $("#chatbot-toggle");
703
+ if (tb) tb.classList.add("chatbot-toggle-active");
704
+ }
705
+
706
+ // Badge rouge sur le bouton toggle pour signaler une action en attente
707
+ const toggleBtn = $("#chatbot-toggle");
708
+ if (toggleBtn && !toggleBtn.querySelector(".cb-pending-badge")) {
709
+ const badge = document.createElement("span");
710
+ badge.className = "cb-pending-badge";
711
+ badge.textContent = "!";
712
+ toggleBtn.appendChild(badge);
713
+ }
714
+
715
+ // Scroll précis : amener le bas de la carte dans la zone visible du panel
716
+ function _scrollCardIntoPanel() {
717
+ const msgs = $("#chatbot-messages");
718
+ if (!msgs) return;
719
+ const msgsRect = msgs.getBoundingClientRect();
720
+ const cardRect = card.getBoundingClientRect();
721
+ const overflow = cardRect.bottom - msgsRect.bottom;
722
+ if (overflow > 0) {
723
+ msgs.scrollTop += overflow + 16;
724
+ } else {
725
+ // Fallback si la carte est déjà dans la zone
726
+ msgs.scrollTop = msgs.scrollHeight;
727
+ }
728
+ }
729
+ setTimeout(_scrollCardIntoPanel, 100);
730
+ setTimeout(_scrollCardIntoPanel, 400);
731
+ setTimeout(_scrollCardIntoPanel, 800);
732
+
733
+ // Supprimer le badge quand l'utilisateur interagit avec la carte
734
+ card.addEventListener("click", function() {
735
+ const b = toggleBtn && toggleBtn.querySelector(".cb-pending-badge");
736
+ if (b) b.remove();
737
+ }, { once: true });
738
+
739
+ card.querySelector(".cb-invest-confirm").addEventListener("click", async function () {
740
+ this.disabled = true;
741
+ this.textContent = "Enregistrement…";
742
+ const email = _getUserEmail() || "";
743
+ console.log("[Chatbot] Enregistrer cliqué — email:", email, "data:", data);
744
+ try {
745
+ const resp = await fetch("/api/chat-invest", {
746
+ method: "POST",
747
+ headers: { "Content-Type": "application/json" },
748
+ body: JSON.stringify({ ...data, email }),
749
+ });
750
+ const result = await resp.json();
751
+ console.log("[Chatbot] /api/chat-invest réponse:", resp.status, result);
752
+ const MODEL_TABS = { sentiment: "Actualités", lstm: "LSTM (prix)", transformer: "Transformer (hybride)" };
753
+ const tabLabel = MODEL_TABS[data.model] || data.model;
754
+ if (result.success) {
755
+ card.innerHTML = `<div class="cb-invest-success">
756
+ Investissement enregistré<br>
757
+ <small>${data.symbol} · ${data.amount} € au prix de $${result.price}</small><br>
758
+ <small style="color:rgba(0,230,150,0.6)">Retrouvez-le dans <strong>Mon Suivi</strong> › onglet <strong>${tabLabel}</strong></small>
759
+ <br><a href="/mon-suivi?mode=${data.model}" class="cb-invest-link">Voir dans Mon Suivi</a>
760
+ </div>`;
761
+ // Injecter dans l'historique pour que le LLM régénère le marqueur la prochaine fois
762
+ _history.push({
763
+ role: "user",
764
+ content: `[Système] Investissement ${data.symbol} (${data.amount}€, ${data.model}) enregistré avec succès via le marqueur ##INVEST##. Pour tout prochain investissement, vous devez obligatoirement générer un nouveau marqueur ##INVEST## — sans marqueur, rien n'est enregistré.`
765
+ });
766
+ _saveCurrentConversation();
767
+ } else {
768
+ card.innerHTML = `<div class="cb-invest-error" style="font-size:0.85rem;padding:14px">
769
+ Enregistrement échoué<br>
770
+ <strong style="color:#ff8080">${result.error || 'données invalides'}</strong><br>
771
+ <small>Vérifiez que vous êtes connecté et réessayez.</small>
772
+ <br><button onclick="this.closest('.cb-invest-card').remove()" style="margin-top:8px;padding:4px 12px;border-radius:6px;border:1px solid rgba(255,100,100,0.3);background:none;color:#ff8080;cursor:pointer;font-size:0.75rem">Fermer</button>
773
+ </div>`;
774
+ }
775
+ } catch(err) {
776
+ console.error("[Chatbot] Erreur fetch invest:", err);
777
+ card.innerHTML = `<div class="cb-invest-error" style="font-size:0.85rem;padding:14px">
778
+ Impossible de contacter le serveur.<br>
779
+ <small>Vérifiez votre connexion et réessayez.</small>
780
+ </div>`;
781
+ }
782
+ scrollBottom();
783
+ });
784
+
785
+ card.querySelector(".cb-invest-cancel").addEventListener("click", function () {
786
+ card.remove();
787
+ });
788
+ }
789
+
790
+ // ── Extraction investissement (100% client, sans API) ────────
791
+
792
+ const _SYMBOL_MAP = {
793
+ 'apple':'AAPL', 'aapl':'AAPL',
794
+ 'microsoft':'MSFT', 'msft':'MSFT',
795
+ 'tesla':'TSLA', 'tsla':'TSLA',
796
+ 'nvidia':'NVDA', 'nvda':'NVDA',
797
+ 'google':'GOOGL', 'alphabet':'GOOGL', 'googl':'GOOGL',
798
+ 'amazon':'AMZN', 'amzn':'AMZN',
799
+ 'meta':'META', 'facebook':'META',
800
+ 'bitcoin':'BTC-USD', 'btc':'BTC-USD', 'crypto':'BTC-USD',
801
+ };
802
+ const _MODEL_LIST = [
803
+ ['lstm','lstm'],['ltsm','lstm'],['bilstm','lstm'],['réseau de neurones','lstm'],
804
+ ['transformer','transformer'],['transformeur','transformer'],['hybride','transformer'],
805
+ ['sentiment','sentiment'],['actualit','sentiment'],['news','sentiment'],
806
+ ];
807
+
808
+ // Regex d'intention : .? entre j et ai couvre TOUTES les variantes d'apostrophe
809
+ const _INTENT_RE = /j.?ai\s*(investi|mis\b|achet|vendu|suivi|fait\b)|enregistr|vas-y/i;
810
+
811
+ // Cherche un symbole ou un modèle dans une chaîne basse
812
+ function _findSymbol(lo) {
813
+ for (const [k, v] of Object.entries(_SYMBOL_MAP)) {
814
+ if (lo.includes(k)) return v;
815
+ }
816
+ return null;
817
+ }
818
+ function _findModel(lo) {
819
+ for (const [k, v] of _MODEL_LIST) {
820
+ if (lo.includes(k)) return v;
821
+ }
822
+ return null;
823
+ }
824
+
825
+ // history = tableau de messages {role, content} — utilisé comme contexte si le
826
+ // symbole/modèle n'est pas dans le message actuel
827
+ function _extractInvestment(text, history) {
828
+ console.log("[Chatbot] _extractInvestment appelé sur:", text.slice(0,80));
829
+ if (!_INTENT_RE.test(text)) {
830
+ console.log("[Chatbot] aucune intention d'investissement détectée");
831
+ return null;
832
+ }
833
+
834
+ const lo = ' ' + text.toLowerCase() + ' ';
835
+
836
+ // Contexte: tous les messages récents (pour trouver le symbole si non répété)
837
+ const ctxLo = lo + ' ' + (history || []).slice(-10)
838
+ .map(m => (m.content || '').toLowerCase()).join(' ') + ' ';
839
+
840
+ // Symbol: d'abord dans le message actuel, puis dans le contexte
841
+ const symbol = _findSymbol(lo) || _findSymbol(ctxLo);
842
+
843
+ // Model: idem
844
+ const model = _findModel(lo) || _findModel(ctxLo);
845
+
846
+ // Amount : cherche un nombre + €/euro dans le message actuel
847
+ let amount = null;
848
+ const m1 = text.match(/(\d[\d\s]*(?:[.,]\d{1,2})?)\s*(?:€|euros?)/i);
849
+ if (m1) amount = parseFloat(m1[1].replace(/\s/g,'').replace(',','.'));
850
+ if (!amount) {
851
+ const m2 = text.match(/(?:€|euros?)\s*(\d[\d\s]*(?:[.,]\d{1,2})?)/i);
852
+ if (m2) amount = parseFloat(m2[1].replace(/\s/g,'').replace(',','.'));
853
+ }
854
+ if (!amount) {
855
+ // fallback : plus grand nombre dans le texte
856
+ const nums = text.match(/\b(\d{2,7}(?:[.,]\d{1,2})?)\b/g);
857
+ if (nums) amount = Math.max(...nums.map(n => parseFloat(n.replace(',','.'))));
858
+ }
859
+
860
+ const action = /vend|short|baissier|sold/i.test(text) ? 'VENDRE' : 'ACHETER';
861
+
862
+ console.log("[Chatbot] extraction →", {symbol, model, amount, action});
863
+ if (symbol && amount > 0) {
864
+ return { symbol, model: model || 'lstm', action, amount };
865
+ }
866
+ console.log("[Chatbot] extraction échouée — symbol:", symbol, "amount:", amount);
867
+ return null;
868
+ }
869
+
870
+ // ── Envoi du message ─────────────────────────────────────────
871
+
872
+ async function sendMessage() {
873
+ if (_isStreaming) return;
874
+ const input = $("#chatbot-input");
875
+ if (!input) return;
876
+ const text = input.value.trim();
877
+ if (!text) return;
878
+
879
+ console.log("[Chatbot] sendMessage appelé:", text.slice(0, 60));
880
+
881
+ const welcome = $("#chatbot-welcome");
882
+ if (welcome) welcome.remove();
883
+
884
+ input.value = "";
885
+ _isStreaming = true;
886
+ addMessage("user", text);
887
+ _history.push({ role: "user", content: text });
888
+ showTyping();
889
+
890
+ // Extraction AVANT le fetch — message actuel + historique pour le contexte
891
+ const pendingInvest = _extractInvestment(text, _history);
892
+ const isCompareQuery = _COMPARE_RE.test(text);
893
+ const compareSymbol = isCompareQuery ? _extractCompareSymbol(text, _history) : null;
894
+ console.log("[Chatbot] pendingInvest:", pendingInvest, "| compareSymbol:", compareSymbol);
895
+
896
+ try {
897
+ const response = await fetch("/api/chat", {
898
+ method: "POST",
899
+ headers: { "Content-Type": "application/json" },
900
+ body: JSON.stringify({ messages: _history }),
901
+ });
902
+ if (!response.ok) throw new Error("Erreur serveur " + response.status);
903
+
904
+ removeTyping();
905
+ const botBubble = addMessage("bot", "");
906
+ if (!botBubble) return;
907
+
908
+ const reader = response.body.getReader();
909
+ const decoder = new TextDecoder();
910
+ let fullText = "";
911
+
912
+ while (true) {
913
+ const { done, value } = await reader.read();
914
+ if (done) break;
915
+ for (const line of decoder.decode(value, { stream: true }).split("\n")) {
916
+ if (!line.startsWith("data: ")) continue;
917
+ const chunk = line.slice(6).trim();
918
+ if (chunk === "[DONE]") break;
919
+ try {
920
+ const p = JSON.parse(chunk);
921
+ if (p.text) { fullText += p.text; botBubble.textContent = fullText; scrollBottom(); }
922
+ if (p.error) { botBubble.textContent = p.error; }
923
+ } catch (_) {}
924
+ }
925
+ }
926
+
927
+ // Sauvegarder la réponse bot dans l'historique
928
+ const savedText = fullText || botBubble.textContent || "";
929
+ _history.push({ role: "assistant", content: savedText });
930
+ if (_history.length > 40) _history = _history.slice(-40);
931
+ _saveCurrentConversation();
932
+
933
+ // Toujours stripper le marqueur ##INVEST## du texte affiché (quel que soit l'intent)
934
+ botBubble.textContent = _stripInvestMarker(savedText);
935
+
936
+ // 1. Marqueur inline du LLM (prioritaire)
937
+ const markerData = _parseInvestMarker(savedText);
938
+ const hadInvestIntent = _INTENT_RE.test(text);
939
+ if (markerData && markerData.amount > 0) {
940
+ if (hadInvestIntent) {
941
+ console.log("[Chatbot] Marqueur LLM détecté:", markerData);
942
+ _showInvestConfirm(botBubble, markerData);
943
+ } else {
944
+ console.log("[Chatbot] Marqueur ignoré — pas d'intention dans le message utilisateur");
945
+ }
946
+ } else if (markerData && markerData.amount <= 0) {
947
+ console.log("[Chatbot] Marqueur ignoré — montant nul ou manquant");
948
+ } else if (pendingInvest) {
949
+ // 2. Extraction client sur le message utilisateur (toujours fiable)
950
+ console.log("[Chatbot] Affichage carte confirmation (client):", pendingInvest);
951
+ _showInvestConfirm(botBubble, pendingInvest);
952
+ } else {
953
+ // 3. Détection d'un faux-positif : le bot prétend avoir enregistré sans marqueur
954
+ const falseSave = /enregistr|sauvegard|c.est fait|c.est not|confirm/i.test(savedText);
955
+ if (falseSave && pendingInvest === null && /investi|achet|vendu|mis\b|suivi/i.test(text)) {
956
+ 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 ?");
957
+ }
958
+ }
959
+
960
+ // 4. Comparaison des modèles — afficher la carte après la réponse LLM
961
+ if (compareSymbol) {
962
+ _loadAndShowCompare(compareSymbol, botBubble);
963
+ }
964
+
965
+ } catch (err) {
966
+ removeTyping();
967
+ addMessage("bot", "Impossible de contacter l'assistant.");
968
+ console.error("[Chatbot]", err);
969
+ } finally {
970
+ _isStreaming = false;
971
+ }
972
+ }
973
+
974
+ // ── Toggle panel ─────────────────────────────────────────────
975
+
976
+ function togglePanel() {
977
+ const panel = $("#chatbot-panel");
978
+ const btn = $("#chatbot-toggle");
979
+ if (!panel || !btn) return;
980
+
981
+ const isOpen = panel.classList.contains("chatbot-open");
982
+ if (isOpen) {
983
+ panel.classList.remove("chatbot-open");
984
+ panel.classList.add("chatbot-hidden");
985
+ btn.classList.remove("chatbot-toggle-active");
986
+ } else {
987
+ _positionPanel();
988
+ panel.classList.remove("chatbot-hidden");
989
+ panel.classList.add("chatbot-open");
990
+ btn.classList.add("chatbot-toggle-active");
991
+
992
+ // Re-init à chaque ouverture (re-vérifie la connexion)
993
+ if (!_initialized) {
994
+ _initialized = true;
995
+ }
996
+ _initSession();
997
+
998
+ scrollBottom();
999
+ setTimeout(_initPanelDrag, 50);
1000
+ setTimeout(() => { const i = $("#chatbot-input"); if (i) i.focus(); }, 320);
1001
+ }
1002
+ }
1003
+
1004
+ // Charge la dernière conversation ou crée une nouvelle
1005
+ function _initSession() {
1006
+ // ── Vérification connexion ──────────────────────────────
1007
+ if (!_getUserEmail()) {
1008
+ _showLockedState();
1009
+ return;
1010
+ }
1011
+
1012
+ _updateUserBadge();
1013
+ const store = _loadStore();
1014
+
1015
+ if (store.activeId) {
1016
+ const conv = store.list.find(c => c.id === store.activeId);
1017
+ if (conv && conv.messages.length > 0) {
1018
+ _activeId = conv.id;
1019
+ _history = conv.messages;
1020
+ const container = $("#chatbot-messages");
1021
+ if (container) {
1022
+ const w = container.querySelector("#chatbot-welcome");
1023
+ if (w) w.remove();
1024
+ _history.forEach(function (msg) {
1025
+ const role = msg.role === "assistant" ? "bot" : msg.role;
1026
+ const wrap = document.createElement("div");
1027
+ wrap.className = "chatbot-msg chatbot-msg-" + role;
1028
+ const bubble = document.createElement("div");
1029
+ bubble.className = "chatbot-bubble";
1030
+ bubble.textContent = msg.content;
1031
+ wrap.appendChild(bubble);
1032
+ container.appendChild(wrap);
1033
+ });
1034
+ }
1035
+ scrollBottom();
1036
+ return;
1037
+ }
1038
+ }
1039
+ _activeId = "conv_" + Date.now();
1040
+ }
1041
+
1042
+ function _showLockedState() {
1043
+ // Remplacer les messages par un message de verrouillage
1044
+ const container = $("#chatbot-messages");
1045
+ if (container) {
1046
+ container.innerHTML = `
1047
+ <div class="cb-locked-wrap">
1048
+ <div class="cb-locked-icon"><i class="fa-solid fa-lock"></i></div>
1049
+ <div class="cb-locked-title">Connexion requise</div>
1050
+ <div class="cb-locked-desc">
1051
+ Le Conseiller IA est réservé aux membres connectés.
1052
+ Connectez-vous pour accéder à l'assistant.
1053
+ </div>
1054
+ <a href="/login" class="cb-locked-btn">
1055
+ <i class="fa-solid fa-right-to-bracket"></i> Se connecter
1056
+ </a>
1057
+ </div>
1058
+ `;
1059
+ }
1060
+ // Désactiver l'input et les boutons
1061
+ const input = $("#chatbot-input");
1062
+ const send = $("#chatbot-send");
1063
+ const mic = $("#chatbot-mic");
1064
+ const newBtn = $("#chatbot-new-btn");
1065
+ const histBtn = $("#chatbot-history-btn");
1066
+ if (input) { input.disabled = true; input.placeholder = "Connectez-vous pour utiliser l'assistant…"; }
1067
+ if (send) send.disabled = true;
1068
+ if (mic) mic.disabled = true;
1069
+ if (newBtn) newBtn.disabled = true;
1070
+ if (histBtn) histBtn.disabled = true;
1071
+ }
1072
+
1073
+ function _updateUserBadge() {
1074
+ const email = _getUserEmail();
1075
+ const info = $(".chatbot-header-info");
1076
+ if (!info || !email) return;
1077
+ const old = info.querySelector(".chatbot-user-badge");
1078
+ if (old) old.remove();
1079
+ const badge = document.createElement("div");
1080
+ badge.className = "chatbot-user-badge";
1081
+ badge.textContent = email.split("@")[0];
1082
+ info.appendChild(badge);
1083
+ }
1084
+
1085
+ // ── Drag panel ───────────────────────────────────────────────
1086
+
1087
+ function _positionPanel() {
1088
+ const panel = $("#chatbot-panel");
1089
+ const btn = $("#chatbot-toggle");
1090
+ if (!panel || !btn) return;
1091
+ const saved = _loadPanelPos();
1092
+ if (saved) { _applyPanelPos(saved.left, saved.top); return; }
1093
+ const r = btn.getBoundingClientRect();
1094
+ const panelW = 380, panelH = 580, m = 12;
1095
+ const vw = window.innerWidth, vh = window.innerHeight;
1096
+ let left = r.left + r.width / 2 - panelW / 2;
1097
+ let top = r.top - panelH - m;
1098
+ if (top < m) top = r.bottom + m;
1099
+ if (left + panelW > vw - m) left = vw - panelW - m;
1100
+ if (left < m) left = m;
1101
+ if (top + panelH > vh - m) top = vh - panelH - m;
1102
+ _applyPanelPos(left, top);
1103
+ }
1104
+
1105
+ function _applyPanelPos(l, t) {
1106
+ const p = $("#chatbot-panel");
1107
+ if (!p) return;
1108
+ p.style.left = l+"px"; p.style.top = t+"px"; p.style.right = "auto"; p.style.bottom = "auto";
1109
+ }
1110
+ function _savePanelPos(l, t) { try { localStorage.setItem(PANEL_POS_KEY, JSON.stringify({left:l,top:t})); } catch(_){} }
1111
+ function _loadPanelPos() {
1112
+ try {
1113
+ const raw = localStorage.getItem(PANEL_POS_KEY);
1114
+ if (!raw) return null;
1115
+ const p = JSON.parse(raw); const vw=window.innerWidth,vh=window.innerHeight;
1116
+ if (p.left<0||p.top<0||p.left>vw-100||p.top>vh-100) return null; return p;
1117
+ } catch(_){ return null; }
1118
+ }
1119
+
1120
+ function _initPanelDrag() {
1121
+ const panel = $("#chatbot-panel"), header = $("#chatbot-header");
1122
+ if (!panel||!header||header._panelDragBound) return;
1123
+ header._panelDragBound = true;
1124
+ let sx,sy,sl,st,drag=false;
1125
+ function onStart(e) {
1126
+ if (e.target.closest("#chatbot-close")||e.target.closest("#chatbot-history-btn")||
1127
+ e.target.closest("#chatbot-new-btn")) return;
1128
+ const pt=e.touches?e.touches[0]:e; sx=pt.clientX; sy=pt.clientY;
1129
+ const r=panel.getBoundingClientRect(); sl=r.left; st=r.top; drag=false;
1130
+ document.addEventListener("mousemove",onMove); document.addEventListener("mouseup",onEnd);
1131
+ document.addEventListener("touchmove",onMove,{passive:false}); document.addEventListener("touchend",onEnd);
1132
+ }
1133
+ function onMove(e) {
1134
+ const pt=e.touches?e.touches[0]:e; const dx=pt.clientX-sx,dy=pt.clientY-sy;
1135
+ if (!drag&&Math.hypot(dx,dy)>4){drag=true;panel.style.transition="none";panel.style.opacity="0.92";}
1136
+ if (!drag) return; if (e.cancelable) e.preventDefault();
1137
+ const vw=window.innerWidth,vh=window.innerHeight,pw=panel.offsetWidth,ph=panel.offsetHeight,m=8;
1138
+ _applyPanelPos(Math.max(m,Math.min(sl+dx,vw-pw-m)),Math.max(m,Math.min(st+dy,vh-ph-m)));
1139
+ }
1140
+ function onEnd() {
1141
+ document.removeEventListener("mousemove",onMove); document.removeEventListener("mouseup",onEnd);
1142
+ document.removeEventListener("touchmove",onMove); document.removeEventListener("touchend",onEnd);
1143
+ if (drag){drag=false;panel.style.transition="";panel.style.opacity="";
1144
+ const r=panel.getBoundingClientRect(); _savePanelPos(r.left,r.top);}
1145
+ }
1146
+ header.addEventListener("mousedown",onStart); header.addEventListener("touchstart",onStart,{passive:true});
1147
+ }
1148
+
1149
+ // ── Drag bouton ──────────────────────────────────────────────
1150
+
1151
+ function _initDrag() {
1152
+ const btn = $("#chatbot-toggle");
1153
+ if (!btn||btn._dragBound) return; btn._dragBound=true;
1154
+ let sx,sy,sl,st,dragging=false;
1155
+ const saved=_loadPos();
1156
+ if (saved){_applyBtnPos(saved.left,saved.top);}
1157
+ else{_applyBtnPos(window.innerWidth-88,window.innerHeight-88);}
1158
+ function onStart(e){
1159
+ const pt=e.touches?e.touches[0]:e; sx=pt.clientX;sy=pt.clientY;
1160
+ const r=btn.getBoundingClientRect(); sl=r.left;st=r.top; dragging=false;
1161
+ document.addEventListener("mousemove",onMove); document.addEventListener("mouseup",onEnd);
1162
+ document.addEventListener("touchmove",onMove,{passive:false}); document.addEventListener("touchend",onEnd);
1163
+ }
1164
+ function onMove(e){
1165
+ const pt=e.touches?e.touches[0]:e; const dx=pt.clientX-sx,dy=pt.clientY-sy;
1166
+ if (!dragging&&Math.hypot(dx,dy)>5){dragging=true;btn.classList.add("cb-dragging");}
1167
+ if (!dragging) return; if(e.cancelable)e.preventDefault();
1168
+ const vw=window.innerWidth,vh=window.innerHeight,sz=btn.offsetWidth,m=8;
1169
+ _applyBtnPos(Math.max(m,Math.min(sl+dx,vw-sz-m)),Math.max(m,Math.min(st+dy,vh-sz-m)));
1170
+ }
1171
+ function onEnd(){
1172
+ document.removeEventListener("mousemove",onMove); document.removeEventListener("mouseup",onEnd);
1173
+ document.removeEventListener("touchmove",onMove); document.removeEventListener("touchend",onEnd);
1174
+ if(dragging){btn.classList.remove("cb-dragging");_snapToEdge();dragging=false;}
1175
+ else{togglePanel();}
1176
+ }
1177
+ btn.addEventListener("mousedown",onStart); btn.addEventListener("touchstart",onStart,{passive:true});
1178
+ }
1179
+
1180
+ function _snapToEdge(){
1181
+ const btn=$("#chatbot-toggle"); if(!btn) return;
1182
+ const r=btn.getBoundingClientRect(),vw=window.innerWidth,vh=window.innerHeight,sz=r.width,m=12;
1183
+ const left=(r.left+sz/2<vw/2)?m:vw-sz-m, top=Math.max(m,Math.min(r.top,vh-sz-m));
1184
+ btn.style.transition="left 0.35s cubic-bezier(0.22,1,0.36,1),top 0.2s ease";
1185
+ _applyBtnPos(left,top); setTimeout(()=>{btn.style.transition="";},400); _savePos(left,top);
1186
+ const panel=$("#chatbot-panel");
1187
+ if(panel&&panel.classList.contains("chatbot-open")) setTimeout(_positionPanel,50);
1188
+ }
1189
+ function _applyBtnPos(l,t){const btn=$("#chatbot-toggle");if(!btn)return;btn.style.left=l+"px";btn.style.top=t+"px";btn.style.right="auto";btn.style.bottom="auto";}
1190
+ function _savePos(l,t){try{localStorage.setItem(POS_KEY,JSON.stringify({left:l,top:t}));}catch(_){}}
1191
+ function _loadPos(){
1192
+ try{const raw=localStorage.getItem(POS_KEY);if(!raw)return null;const p=JSON.parse(raw);const vw=window.innerWidth,vh=window.innerHeight;
1193
+ if(p.left<0||p.top<0||p.left>vw-40||p.top>vh-40)return null;return p;}catch(_){return null;}
1194
+ }
1195
+
1196
+ // ── Event delegation ─────────────────────────────────────────
1197
+
1198
+ function attachGlobalListeners() {
1199
+ // Utilise une propriété sur document pour éviter les doublons
1200
+ // si le script est chargé deux fois (Dash debug/hot-reload)
1201
+ if (document._chatbotBound) return;
1202
+ document._chatbotBound = true;
1203
+ _bound = true;
1204
+
1205
+ document.addEventListener("click", function (e) {
1206
+ if (e.target.closest("#chatbot-close")) { togglePanel(); return; }
1207
+ if (e.target.closest("#chatbot-send")) { sendMessage(); return; }
1208
+ if (e.target.closest("#chatbot-mic")) { toggleVoice(); return; }
1209
+ if (e.target.closest("#chatbot-history-btn")) { _toggleHistoryView(); return; }
1210
+ if (e.target.closest("#chatbot-new-btn") ||
1211
+ e.target.closest("#chatbot-new-btn2")) { _newConversation(); return; }
1212
+ });
1213
+
1214
+ document.addEventListener("keydown", function (e) {
1215
+ if (e.target&&e.target.id==="chatbot-input"&&e.key==="Enter"&&!e.shiftKey) {
1216
+ e.preventDefault(); sendMessage();
1217
+ }
1218
+ });
1219
+
1220
+ document.addEventListener("mousedown", function (e) {
1221
+ const panel=$("#chatbot-panel"),btn=$("#chatbot-toggle");
1222
+ if (!panel||panel.classList.contains("chatbot-hidden")) return;
1223
+ if (!panel.contains(e.target)&&btn&&!btn.contains(e.target)) togglePanel();
1224
+ });
1225
+ }
1226
+
1227
+ // ── Init ─────────────────────────────────────────────────────
1228
+
1229
+ function init() { attachGlobalListeners(); _initDrag(); }
1230
+
1231
+ if (document.readyState==="loading") { document.addEventListener("DOMContentLoaded",init); }
1232
+ else { init(); }
1233
+
1234
+ const _obs = new MutationObserver(function(){const btn=$("#chatbot-toggle");if(btn&&!btn._dragBound)_initDrag();});
1235
+ _obs.observe(document.body,{childList:true,subtree:true});
1236
+
1237
+ window.addEventListener("resize",function(){
1238
+ const btn=$("#chatbot-toggle"); if(!btn)return;
1239
+ const r=btn.getBoundingClientRect(),vw=window.innerWidth,vh=window.innerHeight,sz=btn.offsetWidth,m=12;
1240
+ _applyBtnPos(Math.max(m,Math.min(r.left,vw-sz-m)),Math.max(m,Math.min(r.top,vh-sz-m)));
1241
+ const panel=$("#chatbot-panel");
1242
+ if(panel&&panel.classList.contains("chatbot-open"))_positionPanel();
1243
+ });
1244
+
1245
+ })();
Interface Graphique/pages/admin/testimonials.py CHANGED
@@ -36,8 +36,8 @@ def refresh_pending(n):
36
  html.Span(f"Gain: +{gain}€" if gain else ""),
37
  ]),
38
  html.Div(className="testimonial-actions", children=[
39
- html.Button("Approuver", id=f"approve-{t_id}", className="approve-btn"),
40
- html.Button("Rejeter", id=f"reject-{t_id}", className="reject-btn"),
41
  ]),
42
  ])
43
  cards.append(card)
 
36
  html.Span(f"Gain: +{gain}€" if gain else ""),
37
  ]),
38
  html.Div(className="testimonial-actions", children=[
39
+ html.Button("Approuver", id=f"approve-{t_id}", className="approve-btn"),
40
+ html.Button("Rejeter", id=f"reject-{t_id}", className="reject-btn"),
41
  ]),
42
  ])
43
  cards.append(card)
Interface Graphique/pages/analyse_page.py CHANGED
@@ -531,7 +531,6 @@ def update_news_feed(data, filter_ticker, filter_sentiment):
531
  items = []
532
  for _, row in df.iterrows():
533
  sentiment = row.get('sentiment', 'neutral')
534
- score = row.get('score_sentiment', 0)
535
  ticker = row.get('symbol', '?')
536
  title = str(row.get('titre', ''))[:200]
537
  source = str(row.get('source', 'Unknown'))
@@ -571,8 +570,6 @@ def update_news_feed(data, filter_ticker, filter_sentiment):
571
  html.Div(title, className="analyse-news-title"),
572
  html.Div(className="analyse-news-source-row", children=[
573
  html.Span(source, className="analyse-news-source"),
574
- html.Span(" · "),
575
- html.Span(f"Score IA: {score:+.3f}", className="analyse-news-score"),
576
  ])
577
  ]),
578
  html.Div(className="analyse-news-badge-col", children=[
 
531
  items = []
532
  for _, row in df.iterrows():
533
  sentiment = row.get('sentiment', 'neutral')
 
534
  ticker = row.get('symbol', '?')
535
  title = str(row.get('titre', ''))[:200]
536
  source = str(row.get('source', 'Unknown'))
 
570
  html.Div(title, className="analyse-news-title"),
571
  html.Div(className="analyse-news-source-row", children=[
572
  html.Span(source, className="analyse-news-source"),
 
 
573
  ])
574
  ]),
575
  html.Div(className="analyse-news-badge-col", children=[
Interface Graphique/pages/face_login.py CHANGED
@@ -19,7 +19,7 @@ layout = html.Div(className="auth-page login-page", children=[
19
 
20
  # Message explicatif
21
  html.Div(style={"text-align": "center", "margin-bottom": "20px", "color": "var(--accent-2)"},
22
- children="📸 Regarde la caméra pour te connecter automatiquement"),
23
 
24
  # Zone caméra
25
  html.Div(className="camera-container", children=[
@@ -30,13 +30,13 @@ layout = html.Div(className="auth-page login-page", children=[
30
 
31
  ),
32
  html.Div(id="face-login-status", className="camera-status no-face",
33
- children="Initialisation..."),
34
  html.Div(className="camera-overlay") # Guide ovale
35
  ]),
36
 
37
  # Bouton d'annulation
38
  html.Button(
39
- "⬅️ RETOUR",
40
  id="face-login-back-btn",
41
  className="auth-btn",
42
  style={"margin-top": "20px", "background": "linear-gradient(135deg, #8be9ff, #00f0ff)"}
@@ -65,7 +65,7 @@ def get_all_users_with_faces():
65
  cursor.execute("SELECT email, face_image FROM users WHERE face_image IS NOT NULL AND face_image != ''")
66
  results = cursor.fetchall()
67
  conn.close()
68
- print(f"📊 {len(results)} utilisateurs avec image faciale trouvés")
69
  for email, img in results:
70
  print(f" - {email}: {len(img) if img else 0} caractères")
71
  return results
@@ -76,15 +76,15 @@ clientside_callback(
76
  """
77
  function(n_intervals) {
78
  if (window.videoStream) {
79
- return "📸 Caméra déjà active";
80
  }
81
-
82
  var videoElement = document.getElementById('face-login-camera');
83
  if (videoElement && window.startCamera) {
84
  window.startCamera('face-login-camera');
85
- return "📸 Caméra activée - Recherche de visage...";
86
  }
87
- return "Initialisation...";
88
  }
89
  """,
90
  Output("face-login-status", "children"),
@@ -123,14 +123,14 @@ def process_face_login(face_data):
123
  return no_update, no_update, no_update, no_update, no_update, no_update
124
 
125
  try:
126
- print(f"🔍 Image capturée reçue ({len(face_data)} caractères)")
127
 
128
  # Récupérer tous les utilisateurs avec images
129
  users = get_all_users_with_faces()
130
 
131
  if len(users) == 0:
132
  return (no_update, no_update, no_update,
133
- "Aucun visage enregistré", "camera-status no-face",
134
  no_update)
135
 
136
  # Chercher le meilleur match
@@ -144,7 +144,7 @@ def process_face_login(face_data):
144
  # Comparer les images
145
  match, score = compare_faces(face_data, stored_image, threshold=SIMILARITY_THRESHOLD)
146
 
147
- print(f"📊 Similarité avec {email}: {score:.2%}")
148
 
149
  if score > best_score:
150
  best_score = score
@@ -152,28 +152,28 @@ def process_face_login(face_data):
152
 
153
  if match:
154
  best_match = email
155
- print(f" MATCH TROUVÉ!")
156
 
157
  if best_match or best_score > SIMILARITY_THRESHOLD:
158
  email_to_use = best_match if best_match else best_email
159
- print(f"🎉 Connexion réussie pour {email_to_use} (score: {best_score:.2%})")
160
 
161
  return ({"email": email_to_use},
162
- f"Bienvenue {email_to_use.split('@')[0]} !", "auth-message success",
163
- f"Visage reconnu ({best_score:.0%})", "camera-status face-detected",
164
  "/")
165
  else:
166
- print(f"Aucun match trouvé (meilleur score: {best_score:.2%})")
167
  return (no_update, no_update, no_update,
168
- f"Visage non reconnu ({best_score:.0%})", "camera-status no-face",
169
  no_update)
170
 
171
  except Exception as e:
172
- print(f"Erreur analyse faciale: {e}")
173
  import traceback
174
  traceback.print_exc()
175
- return (no_update, f"Erreur: {str(e)}", "auth-message error",
176
- "Erreur de scan", "camera-status no-face",
177
  no_update)
178
 
179
  # Callback pour le bouton retour
 
19
 
20
  # Message explicatif
21
  html.Div(style={"text-align": "center", "margin-bottom": "20px", "color": "var(--accent-2)"},
22
+ children="Regarde la caméra pour te connecter automatiquement"),
23
 
24
  # Zone caméra
25
  html.Div(className="camera-container", children=[
 
30
 
31
  ),
32
  html.Div(id="face-login-status", className="camera-status no-face",
33
+ children="Initialisation..."),
34
  html.Div(className="camera-overlay") # Guide ovale
35
  ]),
36
 
37
  # Bouton d'annulation
38
  html.Button(
39
+ "RETOUR",
40
  id="face-login-back-btn",
41
  className="auth-btn",
42
  style={"margin-top": "20px", "background": "linear-gradient(135deg, #8be9ff, #00f0ff)"}
 
65
  cursor.execute("SELECT email, face_image FROM users WHERE face_image IS NOT NULL AND face_image != ''")
66
  results = cursor.fetchall()
67
  conn.close()
68
+ print(f"{len(results)} utilisateurs avec image faciale trouvés")
69
  for email, img in results:
70
  print(f" - {email}: {len(img) if img else 0} caractères")
71
  return results
 
76
  """
77
  function(n_intervals) {
78
  if (window.videoStream) {
79
+ return "Caméra déjà active";
80
  }
81
+
82
  var videoElement = document.getElementById('face-login-camera');
83
  if (videoElement && window.startCamera) {
84
  window.startCamera('face-login-camera');
85
+ return "Caméra activée - Recherche de visage...";
86
  }
87
+ return "Initialisation...";
88
  }
89
  """,
90
  Output("face-login-status", "children"),
 
123
  return no_update, no_update, no_update, no_update, no_update, no_update
124
 
125
  try:
126
+ print(f"Image capturée reçue ({len(face_data)} caractères)")
127
 
128
  # Récupérer tous les utilisateurs avec images
129
  users = get_all_users_with_faces()
130
 
131
  if len(users) == 0:
132
  return (no_update, no_update, no_update,
133
+ "Aucun visage enregistré", "camera-status no-face",
134
  no_update)
135
 
136
  # Chercher le meilleur match
 
144
  # Comparer les images
145
  match, score = compare_faces(face_data, stored_image, threshold=SIMILARITY_THRESHOLD)
146
 
147
+ print(f"Similarité avec {email}: {score:.2%}")
148
 
149
  if score > best_score:
150
  best_score = score
 
152
 
153
  if match:
154
  best_match = email
155
+ print(f" MATCH TROUVÉ!")
156
 
157
  if best_match or best_score > SIMILARITY_THRESHOLD:
158
  email_to_use = best_match if best_match else best_email
159
+ print(f"Connexion réussie pour {email_to_use} (score: {best_score:.2%})")
160
 
161
  return ({"email": email_to_use},
162
+ f"Bienvenue {email_to_use.split('@')[0]} !", "auth-message success",
163
+ f"Visage reconnu ({best_score:.0%})", "camera-status face-detected",
164
  "/")
165
  else:
166
+ print(f"Aucun match trouvé (meilleur score: {best_score:.2%})")
167
  return (no_update, no_update, no_update,
168
+ f"Visage non reconnu ({best_score:.0%})", "camera-status no-face",
169
  no_update)
170
 
171
  except Exception as e:
172
+ print(f"Erreur analyse faciale: {e}")
173
  import traceback
174
  traceback.print_exc()
175
+ return (no_update, f"Erreur: {str(e)}", "auth-message error",
176
+ "Erreur de scan", "camera-status no-face",
177
  no_update)
178
 
179
  # Callback pour le bouton retour
Interface Graphique/pages/home.py CHANGED
@@ -247,9 +247,9 @@ def _transformer_card(ticker, company, pred, target_href="/mon-suivi"):
247
  dir_str = f"{dir_prob*100:.1f}%" if dir_prob is not None else "N/A"
248
  direction = "monter" if rec == 'ACHETER' else "baisser"
249
  tip_body = (
250
- f"Notre Transformer hybride combine {60} jours d'historique de prix et les dernières "
251
  f"actualités financières sur {company}. "
252
- f"Il prédit que le cours va {direction} de {abs(return_pct):.3f}% avec une probabilité directionnelle de {dir_str}."
253
  ) if return_pct is not None else f"Données insuffisantes pour {company} — le modèle Transformer n'a pas pu produire de signal."
254
 
255
  return html.Div(className=f"home-pred-card {_REC_CARD.get(rec, 'home-pred-watch')}", children=[
@@ -282,7 +282,7 @@ def _transformer_card(ticker, company, pred, target_href="/mon-suivi"):
282
  html.P(tip_body, className="home-pred-tooltip-body"),
283
  html.Div(className="home-pred-tooltip-footer", children=[
284
  html.I(className="fas fa-atom"),
285
- html.Span(f" Prob. directionnelle : {dir_str} · {ret_str}"),
286
  ]),
287
  ]),
288
  ]),
 
247
  dir_str = f"{dir_prob*100:.1f}%" if dir_prob is not None else "N/A"
248
  direction = "monter" if rec == 'ACHETER' else "baisser"
249
  tip_body = (
250
+ f"Notre Transformer hybride combine 60 jours d'historique de prix et les dernières "
251
  f"actualités financières sur {company}. "
252
+ f"Il prédit que le cours va {direction} de {abs(return_pct):.3f}% dans les prochains jours."
253
  ) if return_pct is not None else f"Données insuffisantes pour {company} — le modèle Transformer n'a pas pu produire de signal."
254
 
255
  return html.Div(className=f"home-pred-card {_REC_CARD.get(rec, 'home-pred-watch')}", children=[
 
282
  html.P(tip_body, className="home-pred-tooltip-body"),
283
  html.Div(className="home-pred-tooltip-footer", children=[
284
  html.I(className="fas fa-atom"),
285
+ html.Span(f" Rendement prédit : {ret_str} · Transformer hybride"),
286
  ]),
287
  ]),
288
  ]),
Interface Graphique/pages/login.py CHANGED
@@ -189,7 +189,7 @@ def login(n_clicks, email, password, remember):
189
  return no_update, no_update, no_update, no_update, no_update
190
 
191
  if not email or not password:
192
- return no_update, "Email et mot de passe requis", "auth-message error", no_update, no_update
193
 
194
  user = verify_user(email, password)
195
 
@@ -200,9 +200,9 @@ def login(n_clicks, email, password, remember):
200
  if (remember and "remember" in remember)
201
  else {}
202
  )
203
- return session_data, "Connexion réussie !", "auth-message success", "/", remember_data
204
 
205
- return no_update, "Email ou mot de passe incorrect", "auth-message error", no_update, no_update
206
 
207
  # ── Pré-remplir l'email si redirigé depuis le signup ──
208
  @callback(
 
189
  return no_update, no_update, no_update, no_update, no_update
190
 
191
  if not email or not password:
192
+ return no_update, "Email et mot de passe requis", "auth-message error", no_update, no_update
193
 
194
  user = verify_user(email, password)
195
 
 
200
  if (remember and "remember" in remember)
201
  else {}
202
  )
203
+ return session_data, "Connexion réussie !", "auth-message success", "/", remember_data
204
 
205
+ return no_update, "Email ou mot de passe incorrect", "auth-message error", no_update, no_update
206
 
207
  # ── Pré-remplir l'email si redirigé depuis le signup ──
208
  @callback(
Interface Graphique/pages/mon_suivi.py CHANGED
@@ -868,6 +868,7 @@ layout = html.Div(className="suivi-page", children=[
868
  dcc.Store(id="suivi-modal-action", data="ACHAT"),
869
  dcc.Store(id="suivi-refresh", data=0),
870
  dcc.Store(id="suivi-backtest-ticker-store", data="AAPL"),
 
871
  dcc.Download(id="suivi-report-download"),
872
  dcc.Interval(id="suivi-init", interval=300, n_intervals=0, max_intervals=1),
873
  dcc.Interval(id="suivi-auto", interval=5 * 60 * 1000, n_intervals=0),
@@ -1163,7 +1164,7 @@ def toggle_suivi_mode(_s, _l, _t):
1163
  Output("suivi-mode", "data", allow_duplicate=True),
1164
  Output("suivi-section01-sub", "children", allow_duplicate=True),
1165
  Input("url", "search"),
1166
- prevent_initial_call=True,
1167
  )
1168
  def sync_mode_from_url(search):
1169
  active = "home-mode-btn home-mode-active"
@@ -1177,14 +1178,52 @@ def sync_mode_from_url(search):
1177
  return no_update, no_update, no_update, no_update, no_update
1178
 
1179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
  @callback(
1181
  Output("suivi-pred-store", "data"),
1182
  Input("suivi-init", "n_intervals"),
1183
  Input("suivi-auto", "n_intervals"),
1184
  Input("suivi-mode", "data"),
 
1185
  )
1186
- def load_pred_data(_init, _auto, mode):
1187
  from concurrent.futures import ThreadPoolExecutor, as_completed as _as_completed
 
 
 
 
 
1188
  mode = mode or "sentiment"
1189
 
1190
  if mode == "lstm":
@@ -1305,9 +1344,9 @@ def render_predictions(pred_data, session):
1305
  'NEUTRE': "Pas encore — attendez",
1306
  }
1307
  _tip_body = {
1308
- 'HAUSSIER': "Notre IA a analysé les dernières actualités et les signaux sont positifs. Les investisseurs réagissent bien — c'est un bon moment pour investir.",
1309
- 'BAISSIER': "Les actualités récentes sont mauvaises pour cette entreprise. Notre IA a repéré des signaux négatifs — le cours risque de baisser. Mieux vaut attendre.",
1310
- 'NEUTRE': "Les signaux sont encore flous. Notre IA n'est pas assez sûre pour te donner un conseil clair. Reviens dans quelques heures.",
1311
  }
1312
 
1313
  # Itérer sur COMPANIES (ordre fixe) plutôt que pred_data
@@ -1400,7 +1439,7 @@ def render_predictions(pred_data, session):
1400
  html.Div(className="suivi-pred-tooltip-footer", children=[
1401
  html.I(className=date_icon),
1402
  html.Span(
1403
- f" Prob. directionnelle : {data.get('dir_prob', 0)*100:.0f}%" if is_transformer
1404
  else (f" Rendement prédit : {return_pct:+.3f}%" if is_lstm and return_pct is not None
1405
  else f" Valable jusqu'au {data.get('signal_valid_to', '—')}")
1406
  ),
@@ -1500,7 +1539,7 @@ def _action_hint(action, signal):
1500
  else:
1501
  label = "Vous vendez — vous misez sur une baisse du prix"
1502
  icon_cls = "fas fa-circle-check suivi-hint-ok" if follows else "fas fa-triangle-exclamation suivi-hint-warn"
1503
- suffix = " (suit le conseil IA)" if follows else " (à l'opposé du conseil IA)"
1504
  return html.Span([html.I(className=icon_cls), f" {label}{suffix}"], className="suivi-action-hint-inner")
1505
 
1506
 
@@ -2128,7 +2167,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
2128
  + f"<br><b>Résultat : {'+' if pnl >= 0 else ''}{pnl:,.2f} € ({pnl_pct:+.2f}%)</b>"
2129
  )
2130
 
2131
- label = f"{'Gagné' if pnl >= 0 else 'Perdu'} {pnl:+.0f}€"
2132
  color = '#00ff87' if pnl >= 0 else '#ff4d6d'
2133
 
2134
  # Ligne verticale au moment de l'investissement
@@ -2168,7 +2207,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
2168
  fig.add_trace(go.Scatter(
2169
  x=win_x, y=win_y,
2170
  mode='markers',
2171
- name='Vous avez gagné',
2172
  marker=dict(color='#00ff87', size=14, symbol='triangle-up',
2173
  line=dict(color='#010214', width=1.5)),
2174
  hovertemplate='%{customdata}<extra></extra>',
@@ -2179,7 +2218,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
2179
  fig.add_trace(go.Scatter(
2180
  x=loss_x, y=loss_y,
2181
  mode='markers',
2182
- name='Vous avez perdu',
2183
  marker=dict(color='#ff4d6d', size=14, symbol='triangle-down',
2184
  line=dict(color='#010214', width=1.5)),
2185
  hovertemplate='%{customdata}<extra></extra>',
 
868
  dcc.Store(id="suivi-modal-action", data="ACHAT"),
869
  dcc.Store(id="suivi-refresh", data=0),
870
  dcc.Store(id="suivi-backtest-ticker-store", data="AAPL"),
871
+ dcc.Store(id="suivi-url-ticker"),
872
  dcc.Download(id="suivi-report-download"),
873
  dcc.Interval(id="suivi-init", interval=300, n_intervals=0, max_intervals=1),
874
  dcc.Interval(id="suivi-auto", interval=5 * 60 * 1000, n_intervals=0),
 
1164
  Output("suivi-mode", "data", allow_duplicate=True),
1165
  Output("suivi-section01-sub", "children", allow_duplicate=True),
1166
  Input("url", "search"),
1167
+ prevent_initial_call="initial_duplicate",
1168
  )
1169
  def sync_mode_from_url(search):
1170
  active = "home-mode-btn home-mode-active"
 
1178
  return no_update, no_update, no_update, no_update, no_update
1179
 
1180
 
1181
+ @callback(
1182
+ Output("suivi-url-ticker", "data"),
1183
+ Input("url", "search"),
1184
+ prevent_initial_call=False,
1185
+ )
1186
+ def read_ticker_from_url(search):
1187
+ if not search:
1188
+ return None
1189
+ try:
1190
+ from urllib.parse import parse_qs
1191
+ params = parse_qs(search.lstrip('?'))
1192
+ t = params.get('ticker', [None])[0]
1193
+ return t.upper() if t else None
1194
+ except Exception:
1195
+ return None
1196
+
1197
+
1198
+ @callback(
1199
+ Output("suivi-backtest-bg", "className", allow_duplicate=True),
1200
+ Output("suivi-backtest-ticker-store", "data", allow_duplicate=True),
1201
+ Input("suivi-pred-store", "data"),
1202
+ State("suivi-url-ticker", "data"),
1203
+ prevent_initial_call=True,
1204
+ )
1205
+ def auto_open_backtest_from_url(pred_data, url_ticker):
1206
+ if not url_ticker or not pred_data:
1207
+ return no_update, no_update
1208
+ if url_ticker not in pred_data:
1209
+ return no_update, no_update
1210
+ return "suivi-modal-visible", url_ticker
1211
+
1212
+
1213
  @callback(
1214
  Output("suivi-pred-store", "data"),
1215
  Input("suivi-init", "n_intervals"),
1216
  Input("suivi-auto", "n_intervals"),
1217
  Input("suivi-mode", "data"),
1218
+ State("url", "search"),
1219
  )
1220
+ def load_pred_data(_init, _auto, mode, url_search):
1221
  from concurrent.futures import ThreadPoolExecutor, as_completed as _as_completed
1222
+ # L'URL est prioritaire sur le store (évite le flash "sentiment" au chargement)
1223
+ if url_search:
1224
+ if "mode=lstm" in url_search: mode = "lstm"
1225
+ elif "mode=transformer" in url_search: mode = "transformer"
1226
+ elif "mode=sentiment" in url_search: mode = "sentiment"
1227
  mode = mode or "sentiment"
1228
 
1229
  if mode == "lstm":
 
1344
  'NEUTRE': "Pas encore — attendez",
1345
  }
1346
  _tip_body = {
1347
+ 'HAUSSIER': "Notre IA a analysé l'historique de prix et les actualités récentes. Les signaux sont positifs — c'est un bon moment pour investir.",
1348
+ 'BAISSIER': "Notre IA a analysé l'historique de prix et les actualités récentes. Les signaux sont négatifs — le cours risque de baisser. Mieux vaut attendre.",
1349
+ 'NEUTRE': "Les signaux sont encore flous. Notre IA n'est pas assez sûre pour donner un conseil clair. Reviens dans quelques heures.",
1350
  }
1351
 
1352
  # Itérer sur COMPANIES (ordre fixe) plutôt que pred_data
 
1439
  html.Div(className="suivi-pred-tooltip-footer", children=[
1440
  html.I(className=date_icon),
1441
  html.Span(
1442
+ f" Rendement prédit : {return_pct:+.3f}%" if is_transformer and return_pct is not None
1443
  else (f" Rendement prédit : {return_pct:+.3f}%" if is_lstm and return_pct is not None
1444
  else f" Valable jusqu'au {data.get('signal_valid_to', '—')}")
1445
  ),
 
1539
  else:
1540
  label = "Vous vendez — vous misez sur une baisse du prix"
1541
  icon_cls = "fas fa-circle-check suivi-hint-ok" if follows else "fas fa-triangle-exclamation suivi-hint-warn"
1542
+ suffix = " (suit le conseil IA)" if follows else " (à l'opposé du conseil IA)"
1543
  return html.Span([html.I(className=icon_cls), f" {label}{suffix}"], className="suivi-action-hint-inner")
1544
 
1545
 
 
2167
  + f"<br><b>Résultat : {'+' if pnl >= 0 else ''}{pnl:,.2f} € ({pnl_pct:+.2f}%)</b>"
2168
  )
2169
 
2170
+ label = f"{'Gagné' if pnl >= 0 else 'Perdu'} {pnl:+.0f}€"
2171
  color = '#00ff87' if pnl >= 0 else '#ff4d6d'
2172
 
2173
  # Ligne verticale au moment de l'investissement
 
2207
  fig.add_trace(go.Scatter(
2208
  x=win_x, y=win_y,
2209
  mode='markers',
2210
+ name='Vous avez gagné',
2211
  marker=dict(color='#00ff87', size=14, symbol='triangle-up',
2212
  line=dict(color='#010214', width=1.5)),
2213
  hovertemplate='%{customdata}<extra></extra>',
 
2218
  fig.add_trace(go.Scatter(
2219
  x=loss_x, y=loss_y,
2220
  mode='markers',
2221
+ name='Vous avez perdu',
2222
  marker=dict(color='#ff4d6d', size=14, symbol='triangle-down',
2223
  line=dict(color='#010214', width=1.5)),
2224
  hovertemplate='%{customdata}<extra></extra>',
Interface Graphique/pages/profil.py CHANGED
@@ -325,7 +325,7 @@ def load_profil(_init, pathname, session):
325
  _sec_item(
326
  "fas fa-camera" if face else "fas fa-camera-slash",
327
  "Connexion faciale",
328
- "Activée" if face else "Non configurée",
329
  color="#00ff87" if face else None,
330
  ),
331
  _sec_item("fas fa-lock", "Mot de passe", "••••••••", note="Modifiable depuis la page de connexion"),
 
325
  _sec_item(
326
  "fas fa-camera" if face else "fas fa-camera-slash",
327
  "Connexion faciale",
328
+ "Activée" if face else "Non configurée",
329
  color="#00ff87" if face else None,
330
  ),
331
  _sec_item("fas fa-lock", "Mot de passe", "••••••••", note="Modifiable depuis la page de connexion"),
Interface Graphique/pages/signup.py CHANGED
@@ -117,7 +117,7 @@ layout = html.Div(className="auth-page", style={"paddingTop": "100px"}, children
117
  html.Strong("Reconnaissance faciale"),
118
  html.Span("Recommandé", className="signup-badge-rec"),
119
  ]),
120
- html.P("Connexion instantanée sans mot de passe, 100 % en local",
121
  className="signup-face-card-desc"),
122
  ]),
123
  html.Label(className="signup-face-toggle-wrap", children=[
@@ -431,11 +431,11 @@ def process_face_image(face_data):
431
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
432
  faces = face_cascade.detectMultiScale(gray, 1.1, 4)
433
  if len(faces) > 0:
434
- return face_data, "Visage détecté !", "camera-status face-detected"
435
- return no_update, "Aucun visage détecté", "camera-status no-face"
436
  except Exception as e:
437
  print(f"Erreur: {e}")
438
- return no_update, "Erreur", "camera-status no-face"
439
  return no_update, no_update, no_update
440
 
441
 
 
117
  html.Strong("Reconnaissance faciale"),
118
  html.Span("Recommandé", className="signup-badge-rec"),
119
  ]),
120
+ html.P("Connexion instantanée sans mot de passe",
121
  className="signup-face-card-desc"),
122
  ]),
123
  html.Label(className="signup-face-toggle-wrap", children=[
 
431
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
432
  faces = face_cascade.detectMultiScale(gray, 1.1, 4)
433
  if len(faces) > 0:
434
+ return face_data, "Visage détecté !", "camera-status face-detected"
435
+ return no_update, "Aucun visage détecté", "camera-status no-face"
436
  except Exception as e:
437
  print(f"Erreur: {e}")
438
+ return no_update, "Erreur", "camera-status no-face"
439
  return no_update, no_update, no_update
440
 
441
 
Interface Graphique/services/chat_service.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service Chatbot — Assistant IA utilisant Groq (LLaMA 3.3 70B).
3
+ Gratuit, ultra-rapide, streaming SSE.
4
+ """
5
+
6
+ import os
7
+
8
+ SYSTEM_PROMPT = """Tu es l'assistant virtuel de StockPredict AI, une plateforme de prédictions boursières intelligente.
9
+ Tu aides les utilisateurs à comprendre et utiliser la plateforme. Réponds toujours dans la langue de l'utilisateur (français ou anglais).
10
+ Sois clair, simple et bienveillant — l'utilisateur n'est pas forcément expert en finance ou en IA.
11
+
12
+ ## CE QUE FAIT LA PLATEFORME
13
+
14
+ StockPredict AI prédit la direction des prix de 8 actions : Apple (AAPL), Microsoft (MSFT), Tesla (TSLA), NVIDIA (NVDA), Google (GOOGL), Amazon (AMZN), Meta (META), et Bitcoin (BTC-USD).
15
+
16
+ ## LES 3 MODÈLES DE PRÉDICTION
17
+
18
+ ### 1. Modèle Actualités (Sentiment)
19
+ - Analyse les derniers articles de presse sur chaque action
20
+ - Détecte si le ton des actualités est positif ou négatif
21
+ - Donne un signal : HAUSSIER (prix devrait monter), BAISSIER (prix devrait baisser), ou NEUTRE
22
+ - Idéal pour comprendre l'ambiance du marché
23
+
24
+ ### 2. Modèle LSTM (Intelligence Artificielle sur les prix)
25
+ - Un réseau de neurones BiLSTM analysé 30 jours d'historique de prix
26
+ - Calcule 14 indicateurs techniques (RSI, tendances, momentum, volatilité...)
27
+ - Prédit la direction probable du prochain jour
28
+ - Donne une probabilité directionnelle
29
+
30
+ ### 3. Modèle Transformer Hybride
31
+ - Le plus avancé : combine les prix ET les actualités
32
+ - Utilise une architecture Transformer (comme les LLMs) sur 60 jours
33
+ - Analyse 44 indicateurs (prix + sentiment des news)
34
+ - Donne une probabilité directionnelle + une amplitude estimée
35
+
36
+ ## COMMENT LIRE LES SIGNAUX
37
+
38
+ - HAUSSIER : l'IA pense que le prix va monter. Action suggérée : ACHETER
39
+ - BAISSIER : l'IA pense que le prix va baisser. Action suggérée : VENDRE (ou ne rien faire)
40
+ - NEUTRE : signal incertain, mieux vaut attendre
41
+
42
+ IMPORTANT : Ce sont des prédictions, pas des garanties. Les marchés sont imprévisibles.
43
+
44
+ ## LES PAGES DE L'APPLICATION
45
+
46
+ ### Accueil (/)
47
+ - Voir les prédictions en temps réel pour les 8 actions
48
+ - Choisir le modèle (boutons en haut : Actualités / LSTM / Transformer)
49
+ - Cliquer "Calculer mon gain" pour aller dans Mon Suivi avec le même modèle
50
+
51
+ ### Mon Suivi (/mon-suivi)
52
+ - Voir toutes les prédictions détaillées par modèle
53
+ - Simuler un investissement (entrer un montant, choisir ACHAT ou VENTE)
54
+ - "Juste voir le résultat" : simulation sans sauvegarder
55
+ - "J'ai fait cet investissement" : enregistre dans votre historique
56
+ - "Voir si j'avais suivi les conseils" : backtest sur 6 mois
57
+
58
+ ### Analyse (/analysis)
59
+ - Dashboard complet des actualités et du sentiment par action
60
+ - Graphiques d'évolution du sentiment dans le temps
61
+ - Liste des derniers articles analysés
62
+
63
+ ### Marchés (/actions_page)
64
+ - Graphiques de cours en temps réel (chandeliers)
65
+ - Historique des prix des 8 actions
66
+
67
+ ### Mon Profil (/profil)
68
+ - Modifier vos informations personnelles
69
+ - Activer/désactiver la reconnaissance faciale
70
+ - Voir vos statistiques
71
+
72
+ ## S'INSCRIRE / SE CONNECTER
73
+
74
+ - Inscription : aller sur /signup, remplir le formulaire (prénom, nom, email, mot de passe)
75
+ - Le mot de passe doit avoir min. 8 caractères, une majuscule et un chiffre
76
+ - Connexion faciale optionnelle : connexion instantanée en se positionnant face à la caméra
77
+
78
+ ## QUESTIONS FRÉQUENTES
79
+
80
+ Q: Comment gagner de l'argent avec la plateforme ?
81
+ R: Simulez d'abord avec "Juste voir le résultat" pour comprendre. Si le modèle dit HAUSSIER et vous achetez, vous gagnez si le prix monte. Si BAISSIER et vous vendez (short), vous gagnez si le prix baisse. Commencez toujours par simuler.
82
+
83
+ Q: Quel modèle est le meilleur ?
84
+ R: Le Transformer Hybride est le plus sophistiqué car il combine prix et actualités. Mais chaque modèle a ses points forts selon le contexte de marché.
85
+
86
+ Q: Mes données sont-elles sécurisées ?
87
+ R: Oui, les mots de passe sont chiffrés (bcrypt), les données biométriques restent locales et ne sont jamais partagées.
88
+
89
+ Q: Qu'est-ce que le backtest ?
90
+ R: C'est une simulation : "si j'avais investi 500€ il y a 6 mois en suivant les conseils de l'IA, j'aurais gagné/perdu X€". Cela permet d'évaluer la fiabilité du modèle sur des données passées réelles.
91
+
92
+ Réponds de façon concise et claire. Si tu ne sais pas quelque chose sur la plateforme, dis-le honnêtement.
93
+
94
+ ## ENREGISTREMENT D'INVESTISSEMENT (RÈGLE ABSOLUE — NE JAMAIS ENFREINDRE)
95
+
96
+ TU N'AS AUCUN ACCÈS À LA BASE DE DONNÉES. Tu ne peux PAS enregistrer quoi que ce soit toi-même.
97
+ Le seul et unique mécanisme d'enregistrement est le marqueur ##INVEST##. Sans ce marqueur, RIEN n'est sauvegardé.
98
+
99
+ DONC : ne dis JAMAIS "j'ai enregistré", "c'est sauvegardé", "c'est fait", "votre investissement est confirmé" — car ce serait un MENSONGE. L'utilisateur verrait un message de confirmation mais rien ne serait enregistré.
100
+
101
+ Quand l'utilisateur indique avoir fait un investissement (exemples : "j'ai suivi le LSTM et mis 500€ sur AAPL", "enregistre mon investissement", "j'ai acheté TSLA avec le transformer pour 300€", "vas-y enregistre") :
102
+
103
+ 1. Répondre en UNE SEULE phrase courte : "Voici le récapitulatif de votre investissement :"
104
+ 2. Terminer IMMÉDIATEMENT par le marqueur sur sa propre ligne — RIEN après :
105
+ ##INVEST##{"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}##
106
+
107
+ RÈGLE D'OR : Si tu as toutes les infos → génère le marqueur. Si une info manque → demande-la. C'est la seule alternative. Il n'existe pas de troisième option.
108
+
109
+ Règles du marqueur :
110
+ - symbol : l'un de AAPL, MSFT, TSLA, NVDA, GOOGL, AMZN, META, BTC-USD
111
+ - model : l'un de sentiment, lstm, transformer (en minuscules)
112
+ - action : ACHETER ou VENDRE
113
+ - amount : nombre (euros investis, ex: 500)
114
+
115
+ EXEMPLE CORRECT — utilisateur : "j'ai suivi le lstm sur AAPL avec 500€" :
116
+ "Voici le récapitulatif de votre investissement :
117
+ ##INVEST##{"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}##"
118
+
119
+ EXEMPLES INTERDITS (provoquent un bug grave) :
120
+ "Je vais enregistrer votre investissement." ← INTERDIT
121
+ "Votre investissement a été enregistré !" ← INTERDIT
122
+ "C'est fait, j'ai sauvegardé." ← INTERDIT
123
+ "Parfait, c'est noté !" ← INTERDIT sans marqueur
124
+ """
125
+
126
+
127
+ def stream_chat(messages: list):
128
+ """
129
+ Génère une réponse en streaming via Groq (LLaMA 3.3 70B).
130
+ Yields: chunks de texte
131
+ """
132
+ from groq import Groq
133
+
134
+ api_key = os.environ.get("GROQ_API_KEY")
135
+ if not api_key:
136
+ yield "Clé API non configurée. Ajoutez GROQ_API_KEY dans vos variables d'environnement."
137
+ return
138
+
139
+ client = Groq(api_key=api_key)
140
+
141
+ # Injection du system prompt en tête de liste
142
+ full_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages
143
+
144
+ try:
145
+ stream = client.chat.completions.create(
146
+ model="llama-3.3-70b-versatile",
147
+ messages=full_messages,
148
+ max_tokens=1024,
149
+ stream=True,
150
+ )
151
+ for chunk in stream:
152
+ delta = chunk.choices[0].delta
153
+ if delta.content:
154
+ yield delta.content
155
+ except Exception as e:
156
+ yield f"Désolé, une erreur est survenue : {str(e)}"
Interface Graphique/services/database.py CHANGED
@@ -1,7 +1,13 @@
1
  import sqlite3
2
  import bcrypt
 
3
 
4
- DB_NAME = "users.db"
 
 
 
 
 
5
 
6
  def get_connection():
7
  return sqlite3.connect(DB_NAME)
@@ -16,7 +22,7 @@ def init_db():
16
  table_exists = cursor.fetchone()
17
 
18
  if not table_exists:
19
- print("🆕 Création de la table users...")
20
  cursor.execute("""
21
  CREATE TABLE users (
22
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -31,7 +37,7 @@ def init_db():
31
  )
32
  """)
33
  conn.commit()
34
- print("Table users créée avec succès")
35
 
36
  # Créer un admin par défaut
37
  create_default_admin(conn)
@@ -41,51 +47,51 @@ def init_db():
41
  columns = [col[1] for col in cursor.fetchall()]
42
 
43
  if 'is_admin' not in columns:
44
- print("🔄 Migration: ajout de la colonne is_admin...")
45
  cursor.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
46
  conn.commit()
47
- print("Migration is_admin réussie")
48
-
49
  if 'face_image' not in columns:
50
- print("🔄 Migration: ajout de la colonne face_image...")
51
  cursor.execute("ALTER TABLE users ADD COLUMN face_image TEXT")
52
  conn.commit()
53
- print("Migration face_image réussie")
54
-
55
  if 'created_at' not in columns:
56
- print("🔄 Migration: ajout de la colonne created_at...")
57
  cursor.execute("ALTER TABLE users ADD COLUMN created_at DATETIME")
58
  conn.commit()
59
  cursor.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
60
  conn.commit()
61
- print("Migration created_at réussie")
62
 
63
  if 'prenom' not in columns:
64
  cursor.execute("ALTER TABLE users ADD COLUMN prenom TEXT")
65
  conn.commit()
66
- print("Migration prenom réussie")
67
 
68
  if 'nom' not in columns:
69
  cursor.execute("ALTER TABLE users ADD COLUMN nom TEXT")
70
  conn.commit()
71
- print("Migration nom réussie")
72
 
73
  if 'telephone' not in columns:
74
  cursor.execute("ALTER TABLE users ADD COLUMN telephone TEXT")
75
  conn.commit()
76
- print("Migration telephone réussie")
77
 
78
  if 'public_stats' not in columns:
79
  cursor.execute("ALTER TABLE users ADD COLUMN public_stats INTEGER DEFAULT 0")
80
  conn.commit()
81
- print("Migration public_stats réussie")
82
 
83
  if 'is_online' not in columns:
84
  cursor.execute("ALTER TABLE users ADD COLUMN is_online INTEGER DEFAULT 0")
85
  conn.commit()
86
- print("Migration is_online réussie")
87
 
88
- print("📦 Table users déjà existante, conservation des données")
89
  ensure_admin_exists(conn)
90
 
91
  # === TABLE USER_TRADES ===
@@ -108,7 +114,7 @@ def init_db():
108
  FOREIGN KEY (user_email) REFERENCES users(email)
109
  )
110
  """)
111
- print("Table user_trades vérifiée/créée")
112
 
113
  # Migration: model_type column (for existing databases)
114
  cursor.execute("PRAGMA table_info(user_trades)")
@@ -116,7 +122,7 @@ def init_db():
116
  if 'model_type' not in trade_columns:
117
  cursor.execute("ALTER TABLE user_trades ADD COLUMN model_type TEXT DEFAULT 'sentiment'")
118
  conn.commit()
119
- print("Migration model_type réussie")
120
 
121
  # === TABLE PREDICTIONS_LOG ===
122
  cursor.execute("""
@@ -134,7 +140,7 @@ def init_db():
134
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
135
  )
136
  """)
137
- print("Table predictions_log vérifiée/créée")
138
 
139
  # === TABLE TESTIMONIALS ===
140
  cursor.execute("""
@@ -153,11 +159,11 @@ def init_db():
153
  FOREIGN KEY (user_email) REFERENCES users(email)
154
  )
155
  """)
156
- print("Table testimonials vérifiée/créée")
157
 
158
  conn.commit()
159
  conn.close()
160
- print("🎯 Toutes les tables sont initialisées avec succès")
161
 
162
  def create_default_admin(conn):
163
  """Crée un admin par défaut"""
@@ -174,12 +180,12 @@ def create_default_admin(conn):
174
  (default_email, hashed, 1)
175
  )
176
  conn.commit()
177
- print("👑 Admin par défaut créé: admin@ensim.com / Admin123!")
178
- print("⚠️ CHANGE CE MOT DE PASSE DÈS QUE POSSIBLE !")
179
  except sqlite3.IntegrityError:
180
- print("ℹ️ Admin par défaut déjà existant")
181
  except Exception as e:
182
- print(f"Erreur création admin: {e}")
183
 
184
  def ensure_admin_exists(conn):
185
  """S'assure qu'il y a au moins un admin"""
@@ -188,7 +194,7 @@ def ensure_admin_exists(conn):
188
  count = cursor.fetchone()[0]
189
 
190
  if count == 0:
191
- print("⚠️ Aucun admin trouvé, création d'un admin par défaut...")
192
  create_default_admin(conn)
193
  else:
194
- print(f"👥 {count} administrateur(s) trouvé(s)")
 
1
  import sqlite3
2
  import bcrypt
3
+ import os
4
 
5
+ # Chemin absolu basé sur __file__ : remonter 3 niveaux depuis services/database.py
6
+ # → toujours <racine_projet>/users.db, peu importe le répertoire de lancement
7
+ _HERE = os.path.dirname(os.path.abspath(__file__)) # .../Interface Graphique/services
8
+ _APP_DIR = os.path.dirname(_HERE) # .../Interface Graphique
9
+ _ROOT_DIR = os.path.dirname(_APP_DIR) # .../Projet4A_PredictionsBoursieres-main
10
+ DB_NAME = os.path.join(_ROOT_DIR, "users.db")
11
 
12
  def get_connection():
13
  return sqlite3.connect(DB_NAME)
 
22
  table_exists = cursor.fetchone()
23
 
24
  if not table_exists:
25
+ print("Création de la table users...")
26
  cursor.execute("""
27
  CREATE TABLE users (
28
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
37
  )
38
  """)
39
  conn.commit()
40
+ print("Table users créée avec succès")
41
 
42
  # Créer un admin par défaut
43
  create_default_admin(conn)
 
47
  columns = [col[1] for col in cursor.fetchall()]
48
 
49
  if 'is_admin' not in columns:
50
+ print("Migration: ajout de la colonne is_admin...")
51
  cursor.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
52
  conn.commit()
53
+ print("Migration is_admin réussie")
54
+
55
  if 'face_image' not in columns:
56
+ print("Migration: ajout de la colonne face_image...")
57
  cursor.execute("ALTER TABLE users ADD COLUMN face_image TEXT")
58
  conn.commit()
59
+ print("Migration face_image réussie")
60
+
61
  if 'created_at' not in columns:
62
+ print("Migration: ajout de la colonne created_at...")
63
  cursor.execute("ALTER TABLE users ADD COLUMN created_at DATETIME")
64
  conn.commit()
65
  cursor.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
66
  conn.commit()
67
+ print("Migration created_at réussie")
68
 
69
  if 'prenom' not in columns:
70
  cursor.execute("ALTER TABLE users ADD COLUMN prenom TEXT")
71
  conn.commit()
72
+ print("Migration prenom réussie")
73
 
74
  if 'nom' not in columns:
75
  cursor.execute("ALTER TABLE users ADD COLUMN nom TEXT")
76
  conn.commit()
77
+ print("Migration nom réussie")
78
 
79
  if 'telephone' not in columns:
80
  cursor.execute("ALTER TABLE users ADD COLUMN telephone TEXT")
81
  conn.commit()
82
+ print("Migration telephone réussie")
83
 
84
  if 'public_stats' not in columns:
85
  cursor.execute("ALTER TABLE users ADD COLUMN public_stats INTEGER DEFAULT 0")
86
  conn.commit()
87
+ print("Migration public_stats réussie")
88
 
89
  if 'is_online' not in columns:
90
  cursor.execute("ALTER TABLE users ADD COLUMN is_online INTEGER DEFAULT 0")
91
  conn.commit()
92
+ print("Migration is_online réussie")
93
 
94
+ print("Table users déjà existante, conservation des données")
95
  ensure_admin_exists(conn)
96
 
97
  # === TABLE USER_TRADES ===
 
114
  FOREIGN KEY (user_email) REFERENCES users(email)
115
  )
116
  """)
117
+ print("Table user_trades vérifiée/créée")
118
 
119
  # Migration: model_type column (for existing databases)
120
  cursor.execute("PRAGMA table_info(user_trades)")
 
122
  if 'model_type' not in trade_columns:
123
  cursor.execute("ALTER TABLE user_trades ADD COLUMN model_type TEXT DEFAULT 'sentiment'")
124
  conn.commit()
125
+ print("Migration model_type réussie")
126
 
127
  # === TABLE PREDICTIONS_LOG ===
128
  cursor.execute("""
 
140
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
141
  )
142
  """)
143
+ print("Table predictions_log vérifiée/créée")
144
 
145
  # === TABLE TESTIMONIALS ===
146
  cursor.execute("""
 
159
  FOREIGN KEY (user_email) REFERENCES users(email)
160
  )
161
  """)
162
+ print("Table testimonials vérifiée/créée")
163
 
164
  conn.commit()
165
  conn.close()
166
+ print("Toutes les tables sont initialisées avec succès")
167
 
168
  def create_default_admin(conn):
169
  """Crée un admin par défaut"""
 
180
  (default_email, hashed, 1)
181
  )
182
  conn.commit()
183
+ print("Admin par défaut créé: admin@ensim.com / Admin123!")
184
+ print("CHANGE CE MOT DE PASSE DÈS QUE POSSIBLE !")
185
  except sqlite3.IntegrityError:
186
+ print("Admin par défaut déjà existant")
187
  except Exception as e:
188
+ print(f"Erreur création admin: {e}")
189
 
190
  def ensure_admin_exists(conn):
191
  """S'assure qu'il y a au moins un admin"""
 
194
  count = cursor.fetchone()[0]
195
 
196
  if count == 0:
197
+ print("Aucun admin trouvé, création d'un admin par défaut...")
198
  create_default_admin(conn)
199
  else:
200
+ print(f"{count} administrateur(s) trouvé(s)")
Interface Graphique/services/face_auth.py CHANGED
@@ -21,13 +21,13 @@ try:
21
  test_window = cv2.namedWindow("test", cv2.WINDOW_NORMAL)
22
  cv2.destroyWindow("test")
23
  GUI_AVAILABLE = True
24
- print("OpenCV disponible (mode graphique)")
25
  except:
26
  GUI_AVAILABLE = False
27
- print("⚠️ Interface graphique OpenCV non disponible (mode console)")
28
-
29
  except ImportError as e:
30
- print(f"⚠️ OpenCV non disponible: {e}")
31
  print(" Pour l'activer: pip install opencv-python")
32
  OPENCV_AVAILABLE = False
33
  GUI_AVAILABLE = False
@@ -42,7 +42,7 @@ FACE_DATA_DIR = Path("face_data")
42
  try:
43
  FACE_DATA_DIR.mkdir(exist_ok=True)
44
  except:
45
- print(f"⚠️ Impossible de créer le dossier {FACE_DATA_DIR}")
46
 
47
  def get_face_hash_from_image(frame):
48
  """
@@ -65,7 +65,7 @@ def get_face_hash_from_image(frame):
65
 
66
  return face_hash
67
  except Exception as e:
68
- print(f"Erreur création hash: {e}")
69
  return None
70
 
71
  def detect_face(frame):
@@ -94,33 +94,33 @@ def detect_face(frame):
94
 
95
  return face_roi
96
  except Exception as e:
97
- print(f"Erreur détection visage: {e}")
98
  return None
99
 
100
  """
101
  Capture un visage depuis la webcam (version avec fallback console)
102
  """
103
  if not OPENCV_AVAILABLE:
104
- print("OpenCV non disponible")
105
  return None
106
-
107
  try:
108
  # Initialiser la webcam
109
  video_capture = cv2.VideoCapture(0)
110
  if not video_capture.isOpened():
111
- print("Impossible d'ouvrir la webcam")
112
  return None
113
 
114
  print("\n" + "="*50)
115
- print("🔵 CAPTURE FACIALE - ENSIM")
116
  print("="*50)
117
-
118
  if GUI_AVAILABLE:
119
- print("📸 Fenêtre de capture va s'ouvrir...")
120
  print(" [ESPACE] pour capturer")
121
  print(" [Q] pour annuler")
122
  else:
123
- print("📸 Mode console - regarde la caméra")
124
  print(" Capture automatique dans 3 secondes...")
125
  time.sleep(1)
126
  print(" 3...")
@@ -163,55 +163,55 @@ def detect_face(frame):
163
  (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
164
 
165
  if face_roi is None:
166
- cv2.putText(display_frame, "⚠️ AUCUN VISAGE DETECTE",
167
  (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
168
-
169
  cv2.imshow('Capture Visage - ENSIM', display_frame)
170
  key = cv2.waitKey(1) & 0xFF
171
-
172
  if key == ord(' '):
173
  if face_roi is not None:
174
  face_hash = get_face_hash_from_image(face_roi)
175
  face_image = face_roi
176
  captured = True
177
- print("\n✅ Visage capturé avec succès!")
178
  else:
179
- print("\n❌ Aucun visage détecté, réessaie")
180
-
181
  elif key == ord('q'):
182
- print("\n❌ Capture annulée")
183
  break
184
 
185
  # Mode console (sans GUI)
186
  else:
187
  console_attempts += 1
188
- print(f"\r📸 Tentative {console_attempts}/{max_console_attempts}... ", end="", flush=True)
189
-
190
  if face_roi is not None:
191
  face_hash = get_face_hash_from_image(face_roi)
192
  face_image = face_roi
193
  captured = True
194
- print("\n✅ Visage capturé avec succès!")
195
  elif console_attempts >= max_console_attempts:
196
- print("\n❌ Échec capture - aucun visage détecté")
197
  break
198
  else:
199
  time.sleep(0.5) # Pause entre les tentatives
200
-
201
  # Nettoyer
202
  video_capture.release()
203
  if GUI_AVAILABLE:
204
  cv2.destroyAllWindows()
205
-
206
  if face_hash:
207
- print("Données faciales enregistrées")
208
  return {"hash": face_hash, "image": face_image}
209
-
210
- print("Aucune donnée faciale capturée")
211
  return None
212
-
213
  except Exception as e:
214
- print(f"\n❌ Erreur capture: {e}")
215
  return None
216
  def capture_face_from_webcam():
217
  """
 
21
  test_window = cv2.namedWindow("test", cv2.WINDOW_NORMAL)
22
  cv2.destroyWindow("test")
23
  GUI_AVAILABLE = True
24
+ print("OpenCV disponible (mode graphique)")
25
  except:
26
  GUI_AVAILABLE = False
27
+ print("Interface graphique OpenCV non disponible (mode console)")
28
+
29
  except ImportError as e:
30
+ print(f"OpenCV non disponible: {e}")
31
  print(" Pour l'activer: pip install opencv-python")
32
  OPENCV_AVAILABLE = False
33
  GUI_AVAILABLE = False
 
42
  try:
43
  FACE_DATA_DIR.mkdir(exist_ok=True)
44
  except:
45
+ print(f"Impossible de créer le dossier {FACE_DATA_DIR}")
46
 
47
  def get_face_hash_from_image(frame):
48
  """
 
65
 
66
  return face_hash
67
  except Exception as e:
68
+ print(f"Erreur création hash: {e}")
69
  return None
70
 
71
  def detect_face(frame):
 
94
 
95
  return face_roi
96
  except Exception as e:
97
+ print(f"Erreur détection visage: {e}")
98
  return None
99
 
100
  """
101
  Capture un visage depuis la webcam (version avec fallback console)
102
  """
103
  if not OPENCV_AVAILABLE:
104
+ print("OpenCV non disponible")
105
  return None
106
+
107
  try:
108
  # Initialiser la webcam
109
  video_capture = cv2.VideoCapture(0)
110
  if not video_capture.isOpened():
111
+ print("Impossible d'ouvrir la webcam")
112
  return None
113
 
114
  print("\n" + "="*50)
115
+ print("CAPTURE FACIALE - ENSIM")
116
  print("="*50)
117
+
118
  if GUI_AVAILABLE:
119
+ print("Fenêtre de capture va s'ouvrir...")
120
  print(" [ESPACE] pour capturer")
121
  print(" [Q] pour annuler")
122
  else:
123
+ print("Mode console - regarde la caméra")
124
  print(" Capture automatique dans 3 secondes...")
125
  time.sleep(1)
126
  print(" 3...")
 
163
  (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
164
 
165
  if face_roi is None:
166
+ cv2.putText(display_frame, "AUCUN VISAGE DETECTE",
167
  (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
168
+
169
  cv2.imshow('Capture Visage - ENSIM', display_frame)
170
  key = cv2.waitKey(1) & 0xFF
171
+
172
  if key == ord(' '):
173
  if face_roi is not None:
174
  face_hash = get_face_hash_from_image(face_roi)
175
  face_image = face_roi
176
  captured = True
177
+ print("\nVisage capturé avec succès!")
178
  else:
179
+ print("\nAucun visage détecté, réessaie")
180
+
181
  elif key == ord('q'):
182
+ print("\nCapture annulée")
183
  break
184
 
185
  # Mode console (sans GUI)
186
  else:
187
  console_attempts += 1
188
+ print(f"\rTentative {console_attempts}/{max_console_attempts}... ", end="", flush=True)
189
+
190
  if face_roi is not None:
191
  face_hash = get_face_hash_from_image(face_roi)
192
  face_image = face_roi
193
  captured = True
194
+ print("\nVisage capturé avec succès!")
195
  elif console_attempts >= max_console_attempts:
196
+ print("\nÉchec capture - aucun visage détecté")
197
  break
198
  else:
199
  time.sleep(0.5) # Pause entre les tentatives
200
+
201
  # Nettoyer
202
  video_capture.release()
203
  if GUI_AVAILABLE:
204
  cv2.destroyAllWindows()
205
+
206
  if face_hash:
207
+ print("Données faciales enregistrées")
208
  return {"hash": face_hash, "image": face_image}
209
+
210
+ print("Aucune donnée faciale capturée")
211
  return None
212
+
213
  except Exception as e:
214
+ print(f"\nErreur capture: {e}")
215
  return None
216
  def capture_face_from_webcam():
217
  """
Interface Graphique/users.db ADDED
Binary file (28.7 kB). View file
 
Modele_AI_prévisions_boursières (1).ipynb DELETED
The diff for this file is too large to render. See raw diff
 
PIPELINE_SCHEMA.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Demande de schéma — Pipeline de prédictions boursières
2
+
3
+ ## Contexte pour le designer
4
+
5
+ Génère un **schéma de pipeline clair et professionnel** (style diagramme de flux / architecture ML) pour un projet de prédictions boursières qui implémente **3 modes de prédiction distincts**.
6
+
7
+ ---
8
+
9
+ ## Clarification de l'architecture (réponse au prof)
10
+
11
+ ### Question du prof : Que signifie « Transformer hybride » ?
12
+
13
+ **Réponse précise :**
14
+ Le mot « hybride » qualifie les **sources de données**, pas les modèles.
15
+
16
+ | Mode | Sources de données | Modèle | Nature du "hybride" |
17
+ |------|-------------------|--------|---------------------|
18
+ | Actualités | Articles de presse uniquement | Analyse de sentiment (règles) | ✗ Non hybride |
19
+ | LSTM | Historique des prix uniquement | BiLSTM + Embedding symbole | ✗ Non hybride |
20
+ | Transformer | **Prix + Actualités (combinés)** | Transformer Encoder | ✓ Hybride (2 sources) |
21
+
22
+ ---
23
+
24
+ ## Les 3 pipelines complets
25
+
26
+ ---
27
+
28
+ ### MODE 1 — Analyse des Actualités (Sentiment)
29
+
30
+ ```
31
+ SOURCE PRÉTRAITEMENT MODÈLE SORTIE
32
+ ───── ───────────── ────── ──────
33
+
34
+ Articles Score de Agrégation Signal
35
+ de presse ──► sentiment ──► temporelle ──► HAUSSIER
36
+ (CSV GitHub) [-1, +1] (moyenne BAISSIER
37
+ par article glissante) NEUTRE
38
+ + seuils
39
+ ```
40
+
41
+ **Détails techniques :**
42
+ - Source : fichier CSV hébergé sur GitHub (mis à jour automatiquement)
43
+ - Scoring : score de sentiment par article (colonne `score_sentiment`)
44
+ - Agrégation : moyenne des scores sur une fenêtre temporelle par ticker
45
+ - Signal : HAUSSIER si score > seuil+, BAISSIER si score < seuil-, NEUTRE sinon
46
+ - Sortie : signal directionnel + score de confiance
47
+
48
+ ---
49
+
50
+ ### MODE 2 — LSTM (Historique des prix)
51
+
52
+ ```
53
+ SOURCE FEATURE ENGINEERING NORMALISATION MODÈLE SORTIE
54
+ ───── ─────────────────── ───────────── ────── ──────
55
+
56
+ Prix OHLCV 14 indicateurs RobustScaler BiLSTM Probabilité
57
+ (yfinance) ──► techniques ──► clip [-5, 5] ──► (30 × 14) ──► directionnelle
58
+ 90 jours ret1, ret3, ret5 (sauvegardé) + Embedding → ACHETER
59
+ trend_s, trend_l symbole (8 dim) VENDRE
60
+ mom3, mom10 ↓
61
+ vol10, vol_ratio Dense → Sigmoid
62
+ rsi14, rsi7
63
+ atr, bb, volr
64
+ ```
65
+
66
+ **Détails techniques :**
67
+ - Fenêtre temporelle : 30 jours (SEQ_LEN = 30)
68
+ - Features : 14 indicateurs techniques calculés sur l'historique OHLCV
69
+ - Normalisation : RobustScaler (résistant aux outliers) + clipping ±5
70
+ - Architecture : Bidirectional LSTM (64 unités) → LSTM (32) → Dense → Sigmoid
71
+ - Embedding symbole : 8 dimensions (pour AAPL, TSLA, MSFT, GOOGL, NVDA, AMZN, META, BTC-USD)
72
+ - Sortie : probabilité ∈ [0,1] → signal + rendement estimé (prob × volatilité récente)
73
+
74
+ ---
75
+
76
+ ### MODE 3 — Transformer Hybride (Prix + Actualités)
77
+
78
+ ```
79
+ SOURCE 1 FEATURE ENG. FUSION NORMALISATION
80
+ ───────── ──────────── ────── ─────────────
81
+ Prix OHLCV ──► 16 features ──► ──► StandardScaler
82
+ (yfinance) techniques 44 features (feature_scaler)
83
+ combinées
84
+ SOURCE 2 FEATURE ENG. ↓
85
+ ───────── ────────────
86
+ Articles ──► 28 features ──►
87
+ de presse sentiment-dérivées
88
+ (GitHub CSV) (lags, moyennes,
89
+ EMA, sommes)
90
+
91
+ MODÈLE SORTIE
92
+ ────── ──────
93
+ Transformer ──► Dir. prob. (sigmoid)
94
+ Encoder + Amplitude (tanh)
95
+ (60 × 44) ↓
96
+ d_model=64 log-rendement
97
+ nhead=4 = (2p-1) × |amp| × max_lr
98
+ 2 couches ↓
99
+ ACHETER / VENDRE
100
+ + prix prédit demain
101
+ ```
102
+
103
+ **Détails techniques :**
104
+ - Fenêtre temporelle : 60 jours (window = 60)
105
+ - Features prix (16) : OHLCV, close_lag1, MA5, volatilité, RSI, MACD, BB, returns, log-return, ratio SMA, range HL
106
+ - Features sentiment (28) : score_sentiment, articles_count, has_news, lags [1,2,3,5], moyennes [3,5,7,14j], sommes, EMA [5,10], signaux forts
107
+ - Total : 44 features
108
+ - Architecture : Linear(44→64) → Positional Encoding → TransformerEncoder × 2 → LayerNorm → tête direction (sigmoid) + tête amplitude (tanh)
109
+ - Sortie finale : log-rendement signé → prix prédit + signal directionnel
110
+
111
+ ---
112
+
113
+ ## Schéma global comparatif à générer
114
+
115
+ ```
116
+ ┌─────────────────────────────────────────────────────────────────────────────┐
117
+ │ APPLICATION DE PRÉDICTIONS BOURSIÈRES │
118
+ │ (8 tickers : US + Crypto) │
119
+ └───────────────────────────┬─────────────────────────────────────────────────┘
120
+
121
+ ┌───────────────────┼──────────────────────┐
122
+ ▼ ▼ ▼
123
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
124
+ │ MODE 1 │ │ MODE 2 │ │ MODE 3 │
125
+ │ Actualités │ │ LSTM │ │ Transformer Hybride│
126
+ └──────┬──────┘ └──────┬───────┘ └──────────┬──────────┘
127
+ │ │ │
128
+ ▼ ▼ ▼
129
+ Articles de Prix OHLCV Prix OHLCV
130
+ presse (CSV) 90 jours + Articles
131
+ │ │ (2 sources)
132
+ ▼ ▼ ▼
133
+ Sentiment 14 features 44 features
134
+ scoring techniques (16 prix +
135
+ (30 jours) 28 sentiment)
136
+ (60 jours)
137
+ │ │ │
138
+ ▼ ▼ ▼
139
+ Agrégation RobustScaler StandardScaler
140
+ + seuils + clip ±5
141
+ │ │ │
142
+ ▼ ▼ ▼
143
+ Règles de BiLSTM (30×14) Transformer
144
+ décision + Embedding Encoder (60×44)
145
+ (seuils) symbole d_model=64, 2L
146
+ │ │ │
147
+ ▼ ▼ ▼
148
+ Score Probabilité Dir.prob + Amplitude
149
+ sentiment [0,1] → log-rendement
150
+ │ │ │
151
+ └──────────────────┴────────────────────────┘
152
+
153
+
154
+ ┌─────────────────────────┐
155
+ │ SIGNAL FINAL │
156
+ │ HAUSSIER / BAISSIER / │
157
+ │ NEUTRE │
158
+ │ + Prix prédit demain │
159
+ │ + Rendement estimé % │
160
+ └─────────────────────────┘
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Instructions pour le schéma visuel
166
+
167
+ Génère un **diagramme de pipeline vertical** avec :
168
+
169
+ 1. **3 colonnes** (une par mode), chacune de couleur distincte :
170
+ - Mode 1 (Actualités) : violet/mauve
171
+ - Mode 2 (LSTM) : bleu/cyan
172
+ - Mode 3 (Transformer) : vert/teal
173
+
174
+ 2. **5 rangées** (couches du pipeline) :
175
+ - Sources de données
176
+ - Feature engineering
177
+ - Normalisation
178
+ - Modèle
179
+ - Sortie
180
+
181
+ 3. **Flèches de flux** entre chaque étape
182
+
183
+ 4. **Zone de convergence** en bas des 3 colonnes vers un bloc "Sortie unifiée"
184
+
185
+ 5. Style : professionnel, dark background, adapté rapport académique
186
+
187
+ 6. Mentionner dans chaque colonne : nombre de features, taille de fenêtre temporelle, architecture du modèle
188
+
189
+ ---
190
+
191
+ ## Ce qui différencie les 3 modes (réponse directe au prof)
192
+
193
+ | Critère | Mode 1 | Mode 2 | Mode 3 |
194
+ |---------|--------|--------|--------|
195
+ | **Sources** | Actualités uniquement | Prix uniquement | **Prix + Actualités** |
196
+ | **Modèle** | Règles (pas de NN) | BiLSTM | Transformer Encoder |
197
+ | **Features** | Sentiment [-1,+1] | 14 indicateurs techniques | 44 features (hybrides) |
198
+ | **Fenêtre** | Variable (articles) | 30 jours | 60 jours |
199
+ | **Sortie brute** | Score sentiment | Probabilité sigmoid | Prob. + Amplitude |
200
+ | **"Hybride" ?** | ✗ | ✗ | ✓ (données hybrides) |
201
+
202
+ **Conclusion :** « Transformer hybride » = architecture Transformer appliquée à une **entrée hybride** (fusion de données de prix et de sentiment d'actualités). Le mot « hybride » qualifie les données d'entrée, non l'architecture du modèle.
requirements.txt CHANGED
@@ -13,4 +13,6 @@ beautifulsoup4
13
  scikit-learn
14
  joblib
15
  tensorflow
16
- torch
 
 
 
13
  scikit-learn
14
  joblib
15
  tensorflow
16
+ torch
17
+ groq
18
+ python-dotenv
users.db CHANGED
Binary files a/users.db and b/users.db differ