diff --git a/.gitignore b/.gitignore index 75503a1..eb7dc2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.chatgpt/ fb_profile/ prospects.csv config.json diff --git a/app.py b/app.py index 34bdea8..b2fed17 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from flask import Flask, Response, jsonify, request, send_file, stream_with_cont import excel import extractor +import ia import scraper import trajet @@ -42,6 +43,7 @@ CONFIG_DEFAUT = { "Seriez-vous disponible pour en échanger ?\n\n" "Bien cordialement," ), + "ia_modele": ia.MODELE_DEFAUT, } app = Flask(__name__, static_folder="static") @@ -309,7 +311,7 @@ def api_config_lire(): def api_config_ecrire(): donnees = request.get_json(silent=True) or {} config = lire_config() - for cle in ("adresse_depart", "modele_message"): + for cle in ("adresse_depart", "modele_message", "ia_modele"): if cle in donnees: config[cle] = str(donnees[cle]) for cle in ("conso_l_100km", "prix_carburant", "cout_peage_km"): @@ -327,6 +329,41 @@ def api_config_ecrire(): return jsonify({"ok": True, "config": config}) +@app.get("/api/ia/status") +def api_ia_status(): + return jsonify(ia.statut_login()) + + +@app.post("/api/ia/login") +def api_ia_login(): + try: + return jsonify(ia.lancer_login()) + except Exception as e: + return jsonify({"error": "login", "message": f"Échec du démarrage de la connexion : {e}"}), 502 + + +@app.post("/api/message") +def api_message(): + donnees = request.get_json(silent=True) or {} + prospect = donnees.get("prospect") or {} + mode = donnees.get("mode") or "generer" + if not prospect.get("Nom du prospect"): + return jsonify({"error": "prospect_vide", "message": "Prospect sans nom."}), 400 + config = lire_config() + try: + message = ia.generer_message( + prospect, config.get("modele_message", ""), mode, + nom_modele=config.get("ia_modele") or ia.MODELE_DEFAUT, + ) + return jsonify({"message": message}) + except ia.IANonConnecte as e: + return jsonify({"error": "non_connecte", "message": str(e)}), 401 + except ia.IAErreur as e: + return jsonify({"error": "ia", "message": str(e)}), 502 + except Exception as e: + return jsonify({"error": "erreur", "message": f"Échec de la génération : {e}"}), 500 + + @app.get("/api/distance/") def api_distance(idx): lignes = lire_prospects() diff --git a/ia.py b/ia.py new file mode 100644 index 0000000..4259355 --- /dev/null +++ b/ia.py @@ -0,0 +1,199 @@ +"""Génération de messages par IA via l'abonnement ChatGPT (provider `chatgpt` de LiteLLM). + +L'accès se fait par le flow « Sign in with ChatGPT » (device code) : aucune clé API, c'est +l'abonnement ChatGPT de l'utilisateur qui est utilisé. Le token est stocké LOCALEMENT dans le +dossier `.chatgpt/` du projet pour rester portable (utilisable en tournée sur un portable). + +Usage strictement personnel : le backend `chatgpt.com/backend-api/codex` n'est pas une API +publique documentée et peut changer ; on s'appuie sur LiteLLM qui en absorbe les détails. +""" + +import os +import threading +from pathlib import Path + +DOSSIER = Path(__file__).parent +TOKEN_DIR = DOSSIER / ".chatgpt" + +# Doit être défini AVANT tout import de litellm pour que le token soit écrit dans le projet. +os.environ.setdefault("CHATGPT_TOKEN_DIR", str(TOKEN_DIR)) + +MODELE_DEFAUT = "chatgpt/gpt-5.4" + +# État du login device-code en cours, partagé entre la route /api/ia/login et le thread de polling. +_verrou_login = threading.Lock() +_etat_login = { + "en_cours": False, + "user_code": None, + "verification_url": None, + "erreur": None, +} + + +class IANonConnecte(Exception): + """Aucune session ChatGPT valide : il faut se connecter d'abord.""" + + +class IAErreur(Exception): + """Échec d'un appel à l'IA (réseau, rate limit, token expiré...).""" + + +def _authenticator(): + from litellm.llms.chatgpt.authenticator import Authenticator + return Authenticator() + + +def est_connecte(): + """Vrai si un token ChatGPT exploitable (access ou refresh) est stocké localement.""" + try: + data = _authenticator()._read_auth_file() + return bool(data and (data.get("access_token") or data.get("refresh_token"))) + except Exception: + return False + + +def lancer_login(): + """Démarre le flow device-code et renvoie le code + l'URL à afficher dans l'UI. + + Le polling (attente que l'utilisateur valide dans le navigateur) tourne en arrière-plan ; + l'UI suit l'avancement via `statut_login()`. + """ + if est_connecte(): + return {"deja_connecte": True} + + from litellm.llms.chatgpt.common_utils import CHATGPT_DEVICE_VERIFY_URL + + with _verrou_login: + if _etat_login["en_cours"]: + return { + "user_code": _etat_login["user_code"], + "verification_url": _etat_login["verification_url"], + } + auth = _authenticator() + device = auth._request_device_code() + auth._record_device_code_request() + _etat_login.update({ + "en_cours": True, + "user_code": device["user_code"], + "verification_url": CHATGPT_DEVICE_VERIFY_URL, + "erreur": None, + }) + + def _attendre_validation(): + try: + code = auth._poll_for_authorization_code(device) + tokens = auth._exchange_code_for_tokens(code) + auth._write_auth_file(auth._build_auth_record(tokens)) + except Exception as e: + _etat_login["erreur"] = str(e) + finally: + _etat_login["en_cours"] = False + + threading.Thread(target=_attendre_validation, daemon=True).start() + return { + "user_code": _etat_login["user_code"], + "verification_url": _etat_login["verification_url"], + } + + +def statut_login(): + """État courant pour l'UI : connecté ? login en cours ? code à afficher ? erreur ?""" + en_cours = _etat_login["en_cours"] + return { + "connecte": est_connecte(), + "en_cours": en_cours, + "user_code": _etat_login["user_code"] if en_cours else None, + "verification_url": _etat_login["verification_url"] if en_cours else None, + "erreur": _etat_login["erreur"], + } + + +def _message_modele(prospect, modele): + """Substitution {nom}/{ville}/{type} — réplique de messagePour() côté frontend.""" + ville = (prospect.get("Ville") or "").strip() + return (modele or "") \ + .replace("{nom}", prospect.get("Nom du prospect") or "") \ + .replace("{ville}", f" à {ville}" if ville else "") \ + .replace("{type}", (prospect.get("Type") or "").lower()) + + +def _infos_prospect(prospect): + champs = [ + ("Nom", prospect.get("Nom du prospect")), + ("Ville", prospect.get("Ville")), + ("Type de lieu", prospect.get("Type")), + ("Infos du lieu", prospect.get("Infos du lieu")), + ] + return "\n".join(f"- {cle} : {val}" for cle, val in champs if (val or "").strip()) + + +def _message_erreur(exc): + bas = str(exc).lower() + if "rate" in bas or "429" in bas or "limit" in bas or "quota" in bas: + return "Limite de l'abonnement ChatGPT atteinte (fenêtre de 5 h). Réessayez plus tard." + if "401" in bas or "403" in bas or "expired" in bas or "token" in bas or "unauthor" in bas: + return "Session ChatGPT expirée ou invalide. Reconnectez-vous dans les Paramètres." + return f"Échec de la génération : {exc}" + + +def generer_message(prospect, modele, mode="generer", nom_modele=None): + """Génère un message de prise de contact pour un prospect. + + mode == "generer" : l'IA rédige un message sur mesure (modèle = guide de ton/style). + mode == "peaufiner" : on substitue d'abord le modèle, puis l'IA le reformule sans en + changer le sens. + """ + nom_modele = nom_modele or MODELE_DEFAUT + if not est_connecte(): + raise IANonConnecte( + "Connectez-vous à ChatGPT dans les Paramètres avant de générer un message." + ) + + infos = _infos_prospect(prospect) or "- (aucune information détaillée disponible)" + + if mode == "peaufiner": + brouillon = _message_modele(prospect, modele) + systeme = ( + "Tu rédiges des messages de prise de contact en français pour proposer " + "l'organisation de concerts à des établissements. On te donne un brouillon : " + "reformule-le pour le rendre plus naturel, chaleureux et engageant, sans inventer " + "d'information ni changer le sens. Réponds UNIQUEMENT par le message final, sans " + "commentaire." + ) + utilisateur = f"Brouillon à améliorer :\n{brouillon}\n\nInfos sur l'établissement :\n{infos}" + else: + systeme = ( + "Tu rédiges des messages de prise de contact en français pour proposer " + "l'organisation de concerts à des établissements (bars, restaurants, salles...). " + "Le message doit être court, personnalisé, chaleureux et se terminer par une " + "question ouvrant l'échange. Réponds UNIQUEMENT par le message final, sans " + "commentaire ni objet d'e-mail." + ) + utilisateur = ( + f"Rédige un message de prise de contact pour cet établissement :\n{infos}\n\n" + f"Inspire-toi de ce modèle pour le ton et le style :\n{modele}" + ) + + # Streaming OBLIGATOIRE : le backend Codex envoie le texte par deltas et renvoie un + # `output` vide dans l'événement final, ce qui casse la voie non-streaming de LiteLLM + # (« Unknown items in responses API response: [] »). On accumule donc les morceaux. + try: + import litellm + flux = litellm.completion( + model=nom_modele, + messages=[ + {"role": "system", "content": systeme}, + {"role": "user", "content": utilisateur}, + ], + stream=True, + ) + morceaux = [] + for chunk in flux: + if chunk.choices: + delta = chunk.choices[0].delta.content + if delta: + morceaux.append(delta) + except Exception as e: + raise IAErreur(_message_erreur(e)) + + return "".join(morceaux).strip() diff --git a/requirements.txt b/requirements.txt index cc714db..9b86433 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ flask>=3.0 playwright>=1.45 +litellm>=1.83 diff --git a/static/index.html b/static/index.html index a622ca9..6e9a27b 100644 --- a/static/index.html +++ b/static/index.html @@ -96,6 +96,19 @@

Variables disponibles : {nom} (nom du lieu), {ville} (devient « à Ville », ou rien), {type}.

+
+ + +

Utilisé via votre abonnement ChatGPT (sans clé API). Ex. chatgpt/gpt-5.4, ou chatgpt/gpt-5.3-instant (plus rapide).

+
+
+ +
Vérification…
+
+ +
+ +
@@ -209,7 +222,9 @@ async function chargerConfig() { $("cfg-peage").value = config.cout_peage_km ?? ""; $("cfg-relance").value = config.delai_relance_jours ?? ""; $("cfg-message").value = config.modele_message || ""; + $("cfg-ia-modele").value = config.ia_modele || ""; afficherRelances(); + rafraichirStatutIA(); } $("cfg-enregistrer").addEventListener("click", async () => { @@ -220,6 +235,7 @@ $("cfg-enregistrer").addEventListener("click", async () => { cout_peage_km: $("cfg-peage").value, delai_relance_jours: $("cfg-relance").value, modele_message: $("cfg-message").value, + ia_modele: $("cfg-ia-modele").value.trim(), }; const rep = await fetch("/api/config", { method: "PUT", @@ -235,6 +251,46 @@ $("cfg-enregistrer").addEventListener("click", async () => { } }); +// --- Connexion ChatGPT (génération IA) --- + +let iaPolling = null; + +async function rafraichirStatutIA() { + let s; + try { s = await (await fetch("/api/ia/status")).json(); } catch { return; } + const statut = $("ia-statut"), bouton = $("ia-connexion"), code = $("ia-code"); + if (s.connecte) { + statut.textContent = "✅ Connecté à ChatGPT."; + bouton.textContent = "🔄 Se reconnecter"; + code.style.display = "none"; + } else if (s.en_cours) { + statut.textContent = "⏳ En attente de validation dans le navigateur…"; + code.style.display = "block"; + code.innerHTML = `Ouvrez ${echap(s.verification_url)} ` + + `puis saisissez le code : ${echap(s.user_code)}`; + } else { + statut.textContent = s.erreur ? `❌ ${s.erreur}` : "Non connecté à ChatGPT."; + bouton.textContent = "🔗 Se connecter à ChatGPT"; + code.style.display = "none"; + } + return s; +} + +$("ia-connexion").addEventListener("click", async () => { + $("ia-connexion").disabled = true; + try { + const r = await (await fetch("/api/ia/login", { method: "POST" })).json(); + if (r.verification_url) window.open(r.verification_url, "_blank", "noopener"); + } catch {} + $("ia-connexion").disabled = false; + await rafraichirStatutIA(); + clearInterval(iaPolling); + iaPolling = setInterval(async () => { + const s = await rafraichirStatutIA(); + if (s && !s.en_cours) clearInterval(iaPolling); // connecté, échec ou expiration + }, 3000); +}); + // Construit le message de contact à partir du modèle et du prospect. function messagePour(p) { const ville = (p["Ville"] || "").trim(); @@ -591,20 +647,65 @@ function construireDetail(detail, p) { }); actions.append(enregistrer); - // Prise de contact + // Prise de contact — zone éditable partagée (modèle statique ou message généré par IA) + const zoneIA = document.createElement("textarea"); + zoneIA.className = "ia-zone"; + zoneIA.rows = 6; + zoneIA.style.cssText = "width:100%;margin-top:8px;display:none"; + const iaInfo = document.createElement("div"); + iaInfo.className = "indice"; + + // Texte courant : message généré/édité s'il existe, sinon le modèle statique. + const texteContact = () => (zoneIA.value.trim() ? zoneIA.value : messagePour(p)); + const copier = document.createElement("button"); copier.className = "secondaire"; copier.textContent = "📋 Copier le message"; copier.addEventListener("click", async () => { try { - await navigator.clipboard.writeText(messagePour(p)); + await navigator.clipboard.writeText(texteContact()); copier.textContent = "✔ Copié"; setTimeout(() => { copier.textContent = "📋 Copier le message"; }, 1500); } catch { copier.textContent = "Échec de la copie"; } }); - actions.append(copier); + + async function genererIA(mode, bouton) { + const ancien = bouton.textContent; + bouton.disabled = true; + bouton.innerHTML = ' Génération…'; + iaInfo.textContent = ""; + try { + const rep = await fetch("/api/message", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prospect: p, mode }), + }); + const d = await rep.json(); + if (!rep.ok) { iaInfo.innerHTML = `${echap(d.message || "Échec de la génération.")}`; return; } + zoneIA.value = d.message || ""; + zoneIA.style.display = "block"; + iaInfo.textContent = "Message généré — éditable, puis « Copier le message »."; + } catch { + iaInfo.innerHTML = 'Le serveur ne répond pas.'; + } finally { + bouton.disabled = false; + bouton.textContent = ancien; + } + } + + const genererBtn = document.createElement("button"); + genererBtn.className = "secondaire"; + genererBtn.textContent = "✨ Générer (IA)"; + genererBtn.addEventListener("click", () => genererIA("generer", genererBtn)); + + const peaufinerBtn = document.createElement("button"); + peaufinerBtn.className = "secondaire"; + peaufinerBtn.textContent = "✨ Peaufiner (IA)"; + peaufinerBtn.addEventListener("click", () => genererIA("peaufiner", peaufinerBtn)); + + actions.append(copier, genererBtn, peaufinerBtn); // Trajet + carburant const trajetBtn = document.createElement("button"); @@ -637,7 +738,7 @@ function construireDetail(detail, p) { }); actions.append(trajetBtn); - detail.append(grille, actions, resu); + detail.append(grille, actions, iaInfo, zoneIA, resu); } $("recherche").addEventListener("input", afficherListe);