Files
MULTI-IA-CODAGE/main.py
T
xavier fd064dde43
Build and Push Docker Image / build-and-push (push) Successful in 37s
Correction main.py
2026-10-06 01:20:24 +02:00

628 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fasthtml.common import *
import os
import requests
import json
import uuid
import asyncio
import time
import re
from datetime import datetime
from dotenv import load_dotenv
from auth import validate_password_strength, hash_password, verify_password, generate_totp_secret, verify_totp
load_dotenv()
# --- THEME & SCRIPTS ---
theme_hdrs = [
Script(src="https://cdn.tailwindcss.com"),
Script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"),
Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/tokyo-night-dark.min.css"),
Style("""
body { background-color: #0f172a; color: #f8fafc; font-family: system-ui, sans-serif; margin: 0;}
.sidebar { background-color: #1e293b; border-right: 1px solid #333; height: 100vh; padding: 20px; display: flex; flex-direction: column;}
.main-content { padding: 20px; height: 100vh; display: flex; flex-direction: column; position: relative;}
.chat-container { flex-grow: 1; overflow-y: auto; margin-bottom: 20px; border: 1px solid #333; padding: 15px; border-radius: 8px; background: #151e2e; }
.input-row { display: flex; gap: 10px; align-items: flex-end; width: 100%; background: #1e293b; padding: 10px; border-radius: 10px; border: 1px solid #333;}
.input-box { flex-grow: 1; padding: 12px; border-radius: 5px; background: #2a2a35; border: 1px solid #444; color: white; outline: none; }
.file-upload-btn { background: #2a2a35; border: 1px solid #444; color: #94a3b8; padding: 12px; border-radius: 5px; cursor: pointer; font-size:14px; display: flex; align-items:center; justify-content:center;}
.model-selector { padding: 8px; border-radius: 5px; background: #1e293b; border: 1px solid #444; color: #00e5ff; width: 100%; font-size: 12px; outline:none; }
.send-btn { padding: 12px 24px; border-radius: 5px; background: #00e5ff; color: #0f172a; font-weight: bold; cursor: pointer; border: none; }
.msg-user { color: #00e5ff; font-size: 12px; font-weight: bold; margin-bottom: 2px; margin-top: 15px;}
.msg-ia { color: #a855f7; font-size: 12px; font-weight: bold; margin-bottom: 2px; margin-top: 15px;}
.bubble-user { background: #1e293b; padding: 10px; border-radius: 5px; display: inline-block; color: white; border: 1px solid #333;}
.bubble-ia { background: #1e293b; padding: 15px; border-radius: 8px; display: block; color: #e2e8f0; border: 1px solid #333;}
.bubble-ia pre { background: #111827; padding: 15px; border-radius: 8px; margin-top: 10px; position: relative; border: 1px solid #444;}
.htmx-indicator { display: none; position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%); background: rgba(15, 23, 42, 0.9); border: 1px solid #00e5ff; padding: 15px 30px; border-radius: 50px; color: #00e5ff; font-weight: bold; z-index: 50;}
.htmx-request .htmx-indicator { display: block; }
.history-btn { background: #2a2a35; color: #e2e8f0; border: 1px solid #444; padding: 8px; border-radius: 5px; cursor: pointer; text-align: left; }
.history-btn:hover { background: #38bdf8; color: #0f172a; }
.export-btn { display: block; background: #0f172a; border: 1px solid #00e5ff; color: #00e5ff; font-weight: bold; padding: 10px; border-radius: 5px; text-align: center; cursor: pointer; transition: 0.2s; width: 100%;}
.export-btn:hover { background: #00e5ff; color: #0f172a; }
"""),
Script("""
function filterModels() {
let filter = document.querySelector('input[name="filter_type"]:checked').value;
document.querySelectorAll('.model-selector option').forEach(opt => {
if(opt.value === "") return;
let isFree = opt.getAttribute('data-free') === 'true';
if(filter === 'free' && !isFree) opt.style.display = 'none';
else if(filter === 'paid' && isFree) opt.style.display = 'none';
else opt.style.display = 'block';
});
}
function validateMultiIA() {
let wCount = 0;
for(let i=1; i<=5; i++) { if(document.getElementById('worker'+i).value !== "") wCount++; }
if(wCount === 0) {
alert("Erreur : Vous devez sélectionner au moins une IA de Travail.");
return false;
}
if(wCount > 1) {
if(document.getElementById('writer').value === "" || document.getElementById('synthesizer').value === "") {
alert("Erreur : Puisque vous avez sélectionné plusieurs travailleurs, le Rédacteur et le Concaténeur sont obligatoires.");
return false;
}
}
return true;
}
function updateFileCount(input) {
let label = document.getElementById('file-label-text');
if(input.files && input.files.length > 0) { label.innerText = `📎 ${input.files.length} fichier(s)`; label.style.color = '#00e5ff'; }
else { label.innerText = '📎 Fichiers Joints'; label.style.color = '#94a3b8'; }
}
htmx.onLoad(function(content) {
content.querySelectorAll('.bubble-ia:not(.rendered)').forEach(function(el) {
el.innerHTML = marked.parse(el.textContent);
el.classList.add('rendered');
el.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); });
let chat = document.getElementById('chat-history'); chat.scrollTop = chat.scrollHeight;
});
});
""")
]
app, rt = fast_app(secret_key=os.getenv("SESSION_SECRET", "super-secret-key-fallback"), hdrs=theme_hdrs)
# --- AUTHENTIFICATION ---
@rt('/login')
def get():
return Title("Connexion - AETHAS38"), Body(
Div(
H2("AETHAS 38 - Accès Sécurisé", style="color:#00e5ff; margin-bottom:20px; text-align:center;"),
Form(
Input(type="email", name="email", placeholder="E-mail", cls="input-box mb-3 w-full", required=True),
Input(type="password", name="password", placeholder="Mot de passe", cls="input-box mb-3 w-full", required=True),
Input(type="text", name="totp_code", placeholder="Code 2FA (6 chiffres)", cls="input-box mb-4 w-full", required=True),
Button("Se connecter", type="submit", cls="send-btn w-full"),
hx_post="/auth/login", hx_target="#auth-response"
),
Div(id="auth-response", style="color:red; margin-top:10px; text-align:center;"),
cls="sidebar", style="margin: auto; width: 400px; height: auto; border-radius: 10px; margin-top: 15vh;"
), style="background-color: #0f172a; height: 100vh; display: flex;"
)
@rt('/auth/login')
def post(email: str, password: str, totp_code: str, session):
admin_email = os.getenv("ADMIN_EMAIL")
admin_hash = os.getenv("ADMIN_PASSWORD_HASH")
totp_secret = os.getenv("ADMIN_2FA_SECRET")
if not admin_email or not admin_hash or not totp_secret:
return Div("Configuration système incomplète (.env).", style="color:red; font-weight:bold;")
if email == admin_email and verify_password(admin_hash, password) and verify_totp(totp_secret, totp_code):
session["authenticated"] = True
session["role"] = "admin"
return Script("window.location.href = '/';")
else:
return Div("Identifiants ou code 2FA incorrects.", style="color:#ef4444; font-weight:bold;")
@rt('/logout')
def get(session):
session.clear()
return RedirectResponse('/login')
# --- SYSTEM & LOGS ---
@rt('/check_updates')
def get(session):
if not session.get("authenticated"): return Span("")
try:
res = requests.get("https://gitea.aethas38.duckdns.org/api/v1/repos/xavier/MULTI-IA-CODAGE/commits?limit=1", timeout=3)
if res.status_code == 200:
remote_commit = res.json()[0].get("sha", "")[:7]
return Span(f"🟢 Gitea Connecté (Dernier commit : {remote_commit})", style="color:#10b981;")
return Span("🟠 Gitea injoignable", style="color:#fbbf24;")
except Exception as e:
return Span("⚪ Statut réseau inconnu", style="color:#94a3b8;")
os.makedirs("sessions", exist_ok=True)
os.makedirs("logs", exist_ok=True)
APP_LAUNCH_TIME = datetime.now().strftime("%Y%m%d_%H%M%S")
LOG_FILE = f"logs/AETHAS38_Run_{APP_LAUNCH_TIME}.log"
def log_event(session_id, category, action):
sid_short = session_id[:8] if session_id and session_id != "system" else "SYSTEM"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{timestamp}] [Session:{sid_short}] [{category}] {action}\n")
# --- CATALOGUE MANAGER ---
CATALOG_FILE = "catalog.json"
MODELS_DATA = []
def load_catalog():
global MODELS_DATA
if os.path.exists(CATALOG_FILE):
with open(CATALOG_FILE, "r", encoding="utf-8") as f:
try: MODELS_DATA = json.load(f)
except: MODELS_DATA = []
if not MODELS_DATA:
# Fallback de sécurité si aucun catalogue n'a été importé
MODELS_DATA = [
{"id": "groq|llama3-8b-8192", "name": "Groq - Llama 3 (8B)", "is_free": True, "provider": "Général / Texte", "description": "Modèle par défaut."},
{"id": "gemini|gemini-1.5-flash", "name": "Google - Gemini 1.5 Flash", "is_free": True, "provider": "Vision", "description": "Modèle par défaut."}
]
load_catalog()
def save_catalog(data):
global MODELS_DATA
MODELS_DATA = data
with open(CATALOG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
def get_budget():
mgmt_key = os.getenv("OPENROUTER_MANAGEMENT_KEY")
if not mgmt_key: return "Clé manquante"
try:
data = requests.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {mgmt_key}"}, timeout=5).json().get("data", {})
return f"{data.get('total_credits', 0) - data.get('total_usage', 0):.4f} $"
except: return "Erreur réseau"
# --- PERSISTENCE HISTORIQUE ---
HISTORY_FILE = "history.json"
def load_index():
if not os.path.exists(HISTORY_FILE): return {}
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
try: return json.load(f)
except: return {}
def save_index(idx):
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(idx, f, indent=4, ensure_ascii=False)
def load_session_data(session_id):
path = f"sessions/{session_id}.json"
if not os.path.exists(path): return []
with open(path, "r", encoding="utf-8") as f:
try: return json.load(f)
except: return []
def save_session_data(session_id, messages):
with open(f"sessions/{session_id}.json", "w", encoding="utf-8") as f:
json.dump(messages, f, indent=4, ensure_ascii=False)
def create_new_session():
session_id = str(uuid.uuid4())
idx = load_index()
idx[session_id] = {"title": f"Projet du {datetime.now().strftime('%d/%m %H:%M')}", "pinned": False}
save_index(idx)
save_session_data(session_id, [])
return session_id
def render_history_list():
idx = load_index()
sorted_sessions = sorted(idx.items(), key=lambda x: (not x[1].get('pinned', False), x[1]['title']), reverse=False)
items = [Button("➕ Nouveau Projet", cls="export-btn mb-3", style="background:#10b981; color:#0f172a;", hx_get="/session/new", hx_target="#chat-history")]
for sid, data in sorted_sessions:
pin_icon, pin_color = ("📍", "#10b981") if data.get('pinned') else ("📌", "#94a3b8")
item = Div(
Div(data['title'], cls="truncate flex-grow cursor-pointer text-sm hover:text-cyan-400", hx_get=f"/session/load/{sid}", hx_target="#chat-history"),
Div(
Button("✏", cls="text-xs mx-1 hover:text-white", onclick=f"let name = prompt('Nouveau nom:'); if(name) {{ htmx.ajax('POST', '/history/rename/{sid}', {{values: {{title: name}}, target: '#history-list-container'}}); }}"),
Button(pin_icon, style=f"color:{pin_color};", cls="text-xs mx-1 hover:text-white", hx_post=f"/history/pin/{sid}", hx_target="#history-list-container"),
Button("🗑️", cls="text-xs hover:text-red-500", onclick=f"if(confirm('Supprimer ce projet ?')) {{ htmx.ajax('POST', '/history/delete/{sid}', {{target: '#history-list-container'}}); }}"),
cls="flex-shrink-0"
),
cls="flex justify-between items-center w-full mb-2 bg-[#2a2a35] p-2 rounded border border-[#444]"
)
items.append(item)
return Div(*items, id="history-list-container", hx_swap_oob="true")
# --- UI COMPONENTS ---
def render_model_dropdown(field_id, placeholder):
groups = {}
for m in MODELS_DATA:
prov = m.get('provider', 'Général')
if prov not in groups: groups[prov] = []
groups[prov].append(m)
opts = [Option("--- Laisser vide ---", value="")]
for prov in sorted(groups.keys()):
grp_opts = []
for m in sorted(groups[prov], key=lambda x: x['name']):
is_free = str(m.get('is_free', False)).lower()
label = f"{m['name']} ({'Gratuit' if is_free=='true' else 'Payant'})"
grp_opts.append(Option(label, value=m['id'], **{"data-free": is_free}))
opts.append(Optgroup(label=prov)(*grp_opts))
return Div(
P(placeholder, style="color:#94a3b8; font-size:10px; font-weight:bold; margin-bottom:2px;"),
Select(*opts, name=field_id, id=field_id, cls="model-selector", onchange="filterModels()"),
style="margin-bottom: 8px;"
)
def parse_txt_catalog(content: str):
""" Logique VBA traduite en Python pour le fallback du fichier TXT """
lines = content.split('\n')
models = []
current = {}
ext_date = "Inconnue"
ext_time = ""
for line in lines:
line_trim = line.strip()
if "Généré le" in line_trim:
match = re.search(r"Généré le (\d{2}/\d{2}/\d{4}) à (\d{2}:\d{2}:\d{2})", line_trim)
if match:
ext_date = match.group(1)
ext_time = match.group(2)
if line_trim.startswith("Nom : "): current['name'] = line_trim.replace("Nom : ", "").strip()
elif line_trim.startswith("ID : "): current['id'] = line_trim.replace("ID : ", "").strip()
elif line_trim.startswith("Tarif : "): current['is_free'] = ("Gratuit" in line_trim)
elif line_trim.startswith("Spécialité : "): current['provider'] = line_trim.replace("Spécialité : ", "").strip()
elif line_trim.startswith("Description : "): current['description'] = line_trim.replace("Description : ", "").strip()
elif line_trim.startswith("---"):
if 'id' in current and 'name' in current: models.append(current)
current = {}
return models, ext_date, ext_time
# --- API DISTANTE ---
def ask_llm(session_id, full_id, msg):
parts = full_id.split("|", 1)
provider = parts[0]
actual_model = parts[1] if len(parts) > 1 else full_id
log_event(session_id, "API_REQ", f"Provider: {provider} | Modèle: {actual_model}")
try:
if provider == "openrouter":
res = requests.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}"}, json={"model": actual_model, "messages": [{"role": "user", "content": msg}]})
if res.status_code == 200: return res.json()["choices"][0]["message"]["content"]
return f"⚠️️ Erreur API ({provider}) : {res.text}"
elif provider == "groq":
res = requests.post("https://api.groq.com/openai/v1/chat/completions", headers={"Authorization": f"Bearer {os.getenv('GROQ_API_KEY')}"}, json={"model": actual_model, "messages": [{"role": "user", "content": msg}]})
if res.status_code == 200: return res.json()["choices"][0]["message"]["content"]
return f"⚠️ Erreur API ({provider}) : {res.text}"
elif provider == "gemini":
res = requests.post(f"https://generativelanguage.googleapis.com/v1beta/models/{actual_model}:generateContent?key={os.getenv('GEMINI_API_KEY')}", json={"contents": [{"parts": [{"text": msg}]}]})
if res.status_code == 200: return res.json()["candidates"][0]["content"]["parts"][0]["text"]
return f"⚠️ Erreur API ({provider}) : {res.text}"
return "⚠️ Fournisseur API non implémenté ou ID invalide."
except Exception as e: return f"⚠️ Erreur de connexion : {str(e)}"
async def async_ask_llm(session_id, full_id, msg):
return await asyncio.to_thread(ask_llm, session_id, full_id, msg)
# --- ROUTES & VIEWS ---
SESSION_PROGRESS = {}
@rt('/status/{sid}')
def get_status(sid: str):
return SESSION_PROGRESS.get(sid, "⚙️ Traitement en cours...")
@rt('/')
def get(session):
if not session.get("authenticated"): return RedirectResponse('/login')
session_id = create_new_session()
return Title("AETHAS38 Orchestrateur"), Body(
Div(
# --- SIDEBAR GAUCHE ---
Div(
H2("AETHAS 38", style="color:#00e5ff; font-weight:bold; font-size:24px;"),
P("Orchestrateur Distant", style="color:#a855f7; margin-bottom: 10px; font-size:12px;"),
Div("⏳ Vérification GitHub...", hx_get="/check_updates", hx_trigger="load", style="font-size:10px; margin-bottom:15px;"),
Div(
H3("Importer Nouveaux Modèles", style="color:#94a3b8; font-size:12px; font-weight:bold; margin-bottom:5px;"),
Form(
Input(type="file", name="catalog_file", accept=".txt,.xlsm,.xlsx", required=True, id="cat-upload", style="display:none;", onchange="document.getElementById('cat-label').innerText = '📎 Fichier Sélectionné';"),
Label(Span("📎 Sélectionner (.txt ou .xlsm)", id="cat-label"), _for="cat-upload", cls="file-upload-btn mb-2"),
Button("Importer le Catalogue", type="submit", cls="export-btn", style="background:#a855f7; color:#fff; border:none;"),
hx_post="/import_catalog", hx_target="#catalog-status", enctype="multipart/form-data"
),
Div(id="catalog-status", style="font-size:10px; color:#10b981; margin-top:5px; text-align:center;"),
style="background:#151e2e; padding:10px; border-radius:5px; border:1px solid #333; margin-bottom:15px;"
),
Div(H3("Historique", style="color:#94a3b8; font-size:12px; font-weight:bold; margin-bottom:10px;"), render_history_list(), style="flex-grow:1; overflow:hidden;"),
Button("📥 Exporter Modèles Actuels (.txt)", type="button", cls="export-btn", onclick="window.location.href='/export_models'"),
A("Déconnexion", href="/logout", cls="export-btn w-full mt-2", style="color:#ef4444; border-color:#ef4444; margin-bottom:10px;"),
Div(P("BUDGET", style="color:#94a3b8; font-size:10px; font-weight:bold;"), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;")),
cls="sidebar"
),
# --- ZONE PRINCIPALE ---
Div(
Div(id="chat-history", cls="chat-container"),
Div(Span("⚙️ Préparation du Pipeline...", id="loading-text"), id="loading-tracker", cls="htmx-indicator"),
Form(
Input(type="hidden", name="session_id", value=session_id, id="current-session-id"),
Div(
P("Filtre d'affichage :", style="color:#00e5ff; font-weight:bold; font-size:12px; margin-right:15px;"),
Label(Input(type="radio", name="filter_type", value="all", checked=True, onchange="filterModels()"), " Tous"),
Label(Input(type="radio", name="filter_type", value="free", onchange="filterModels()"), " Gratuits Uniquement"),
Label(Input(type="radio", name="filter_type", value="paid", onchange="filterModels()"), " Payants Uniquement"),
cls="flex items-center text-sm text-white gap-4 mb-4 bg-[#1e293b] p-2 rounded border border-[#333] w-fit"
),
Div(
Div(render_model_dropdown("writer", "1. Rédacteur de Prompt"), render_model_dropdown("synthesizer", "7. Concaténeur Final"), style="border-right: 1px solid #444; padding-right:15px;"),
Div(
render_model_dropdown("worker1", "2. IA Travailleur 1 (Obligatoire)"),
render_model_dropdown("worker2", "3. IA Travailleur 2"),
render_model_dropdown("worker3", "4. IA Travailleur 3"),
style="padding: 0 15px;"
),
Div(
render_model_dropdown("worker4", "5. IA Travailleur 4"),
render_model_dropdown("worker5", "6. IA Travailleur 5"),
style="padding-left: 15px;"
),
cls="grid grid-cols-3 gap-4 mb-4 bg-[#151e2e] p-4 rounded border border-[#333]"
),
Div(
Input(type="file", name="fichiers", id="file-upload", multiple=True, style="display:none;", onchange="updateFileCount(this)"),
Label(Span("📎 Fichiers Joints", id="file-label-text"), _for="file-upload", cls="file-upload-btn w-40"),
Input(type="text", name="msg", placeholder="Détaillez le travail à effectuer...", cls="input-box", required=True),
Button("Lancer le Pipeline", type="submit", cls="send-btn"),
cls="input-row"
),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#loading-tracker", enctype="multipart/form-data",
onsubmit="return validateMultiIA();",
**{"hx-on:htmx:afterRequest": "if(event.detail.successful) { this.reset(); document.getElementById('file-label-text').innerText='📎 Fichiers Joints'; filterModels(); }"}
),
cls="main-content"
),
style="display: grid; grid-template-columns: 280px 1fr;"
),
Script("""
let statusInterval;
document.addEventListener('htmx:beforeRequest', function(evt) {
if(evt.detail.target.id === 'chat-history') {
let sid = document.getElementById('current-session-id').value;
document.getElementById('loading-text').innerText = "⚙️ Envoi des données...";
statusInterval = setInterval(async () => {
try { let res = await fetch('/status/' + sid); if(res.ok) document.getElementById('loading-text').innerText = await res.text(); } catch(e) {}
}, 800);
}
});
document.addEventListener('htmx:afterRequest', function(evt) { if(evt.detail.target.id === 'chat-history') clearInterval(statusInterval); });
filterModels();
""")
)
# --- PIPELINE DE TRAITEMENT ---
@rt('/chat')
async def post(msg: str, session_id: str, session, writer: str = "", worker1: str = "", worker2: str = "", worker3: str = "", worker4: str = "", worker5: str = "", synthesizer: str = "", fichiers: list[UploadFile] = None):
if not session.get("authenticated"): return RedirectResponse('/login')
t_start = time.time()
files_context, noms_fichiers = "", []
if fichiers:
for f in fichiers:
if f.filename:
noms_fichiers.append(f.filename)
try: files_context += f"\n--- {f.filename} ---\n{(await f.read()).decode('utf-8')}\n"
except: files_context += f"\n--- {f.filename} (Binaire ignoré) ---\n"
base_prompt = f"Fichiers fournis :\n{files_context}\n\nConsigne de l'utilisateur : {msg}" if files_context else msg
workers = [w for w in [worker1, worker2, worker3, worker4, worker5] if w]
t_w0 = time.time()
if writer:
SESSION_PROGRESS[session_id] = "🧠 Étape 1 : Le Rédacteur prépare l'invite..."
prompt_to_work = ask_llm(session_id, writer, "Tu es un Ingénieur Prompt Senior. Reformule et optimise techniquement cette demande pour des LLM spécialisés. Retourne uniquement l'invite optimisée sans salutations :\n\n" + base_prompt)
else:
prompt_to_work = base_prompt
t_w1 = time.time()
SESSION_PROGRESS[session_id] = f"⚡ Étape 2 : Travail en cours ({len(workers)} IA en parallèle)..."
tasks = [async_ask_llm(session_id, w, prompt_to_work) for w in workers]
results = await asyncio.gather(*tasks)
t_w2 = time.time()
if synthesizer and len(workers) > 0:
SESSION_PROGRESS[session_id] = "🏗️ Étape 3 : Synthèse et assemblage final..."
synth_input = "Tu es l'Architecte Final. Voici la même tâche effectuée par plusieurs intelligences artificielles :\n\n"
for i, res in enumerate(results): synth_input += f"--- Proposition IA {i+1} ---\n{res}\n\n"
synth_input += "Analyse ces propositions, garde le meilleur code, corrige les erreurs potentielles et génère la solution finale absolue et complète en français."
final_response = ask_llm(session_id, synthesizer, synth_input)
else:
final_response = results[0]
t_w3 = time.time()
SESSION_PROGRESS[session_id] = "✅ Pipeline Terminé"
rapport = (
f"\n\n---\n⏱️ **Rapport de Performance (Pipeline Distant)**\n"
f"- **Rédacteur** : {t_w1 - t_w0:.2f} s\n"
f"- **Travailleurs (Parallèle)** : {t_w2 - t_w1:.2f} s\n"
f"- **Concaténeur** : {t_w3 - t_w2:.2f} s\n"
f"- **Temps Total du Cycle** : {t_w3 - t_start:.2f} s"
)
info_lbl = f"<br><span style='color:#38bdf8; font-size:10px;'>⚙️ {len(workers)} Travailleur(s) | 📎 {len(noms_fichiers)} fichier(s)</span>"
session_data = load_session_data(session_id)
session_data.append({"user": msg, "ia": final_response + rapport, "info": info_lbl})
save_session_data(session_id, session_data)
return Div(P("Vous", cls="msg-user"), Div(NotStr(f"{msg}{info_lbl}"), cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(final_response + rapport, cls="bubble-ia")), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;", hx_swap_oob="true")
# --- IMPORT / EXPORT CATALOGUE ---
@rt('/export_models')
def get(session):
if not session.get("authenticated"): return RedirectResponse('/login')
log_event("system", "SYSTEM", "Demande d'exportation du catalogue de modèles.")
now = datetime.now()
txt_content = f"=== CATALOGUE COMPLET DES MODÈLES (Généré le {now.strftime('%d/%m/%Y à %H:%M:%S')}) ===\n\n"
google_banned = False
for m in MODELS_DATA:
name = m.get('name', 'Inconnu')
m_id = m.get('id', 'Inconnu')
usage = "Général / Texte"
arch_mod = str(m.get('architecture', {}).get('modality', '')).lower()
m_id_lower = m_id.lower()
if "vision" in arch_mod or "vision" in m_id_lower or "vl" in m_id_lower or "image" in arch_mod:
usage = "Vision (Analyse d'images & Multimodal)"
elif "coder" in m_id_lower or "code" in m_id_lower or "math" in m_id_lower:
usage = "Codage & Mathématiques"
elif "video" in m_id_lower:
usage = "Vidéo"
elif "rp" in m_id_lower or "uncensored" in m_id_lower or "roleplay" in m_id_lower:
usage = "Non-censuré / Roleplay"
is_free = m.get("is_free")
if is_free is None:
pricing = m.get('pricing', {})
p_prompt = float(pricing.get('prompt', -1))
p_comp = float(pricing.get('completion', -1))
cout = "Gratuit" if p_prompt == 0.0 and p_comp == 0.0 else f"Payant (In: {p_prompt}, Out: {p_comp})"
else:
cout = "Gratuit" if is_free else "Payant"
desc_courte = str(m.get('description', '')).replace('\n', ' ')[:150]
desc_fr = desc_courte
if "openrouter" in m_id and desc_courte:
if not google_banned:
try:
url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=fr&dt=t&q=" + requests.utils.quote(desc_courte)
trans_res = requests.get(url, timeout=1.5)
if trans_res.status_code == 200:
desc_fr = "".join([x[0] for x in trans_res.json()[0]])
else:
google_banned = True
desc_fr += " [Traduction interrompue: Sécurité anti-spam]"
except:
google_banned = True
desc_fr += " [Traduction interrompue: Délai d'attente]"
else:
desc_fr = desc_courte
if not desc_fr: desc_fr = "Aucune description fournie."
txt_content += (
f"Nom : {name}\n"
f"ID : {m_id}\n"
f"Tarif : {cout}\n"
f"Spécialité : {usage}\n"
f"Description : {desc_fr}\n"
f"{'-'*60}\n"
)
filename = f"modeles_ia_{datetime.now().strftime('%Y%m%d_%H%M')}.txt"
return Response(txt_content, media_type="text/plain", headers={"Content-Disposition": f"attachment; filename={filename}"})
@rt('/import_catalog')
async def post(catalog_file: UploadFile, session):
if not session.get("authenticated"): return RedirectResponse('/login')
filename = catalog_file.filename.lower()
new_models = []
ext_date, ext_time = "Inconnue", ""
if filename.endswith(".txt"):
content = (await catalog_file.read()).decode('utf-8').replace('\r\n', '\n')
new_models, ext_date, ext_time = parse_txt_catalog(content)
elif filename.endswith((".xlsx", ".xlsm")):
try:
import openpyxl
from io import BytesIO
content = await catalog_file.read()
wb = openpyxl.load_workbook(BytesIO(content), data_only=True)
ws = wb.active
val_b2 = ws["B2"].value
val_c2 = ws["C2"].value
if val_b2 and hasattr(val_b2, 'strftime'): ext_date = val_b2.strftime('%d/%m/%Y')
else: ext_date = str(val_b2) if val_b2 else "Inconnue"
if val_c2 and hasattr(val_c2, 'strftime'): ext_time = val_c2.strftime('%H:%M:%S')
else: ext_time = str(val_c2) if val_c2 else ""
for row in ws.iter_rows(min_row=5, values_only=True):
if row[0] and row[1]:
new_models.append({
'name': str(row[0]).strip(),
'id': str(row[1]).strip(),
'is_free': ("Gratuit" in str(row[2])) if row[2] else False,
'provider': str(row[3]).strip() if row[3] else "Général",
'description': str(row[4]).strip() if row[4] else ""
})
except ImportError:
return Div("❌ Erreur : 'openpyxl' n'est pas installé sur le serveur. Veuillez uploader le fichier .txt.", style="color:red;")
except Exception as e:
return Div(f"❌ Erreur Excel : {str(e)}", style="color:red;")
else:
return Div("❌ Format invalide (.txt ou .xlsm attendu)", style="color:red;")
if new_models:
save_catalog(new_models)
date_str = f"Extrait le {ext_date} à {ext_time}." if ext_date != "Inconnue" else ""
return Div(f"✅ Catalogue mis à jour ({len(new_models)} modèles). {date_str}", Script("setTimeout(()=>window.location.reload(), 2000);"), style="color:#10b981;")
else:
return Div("❌ Aucun modèle valide trouvé.", style="color:red;")
# --- HISTORY SYSTEM ---
@rt('/session/new')
def get(session):
if not session.get("authenticated"): return RedirectResponse('/login')
sid = create_new_session()
return Div(), Input(type="hidden", name="session_id", value=sid, id="current-session-id", hx_swap_oob="true"), render_history_list()
@rt('/session/load/{sid}')
def get(sid: str, session):
if not session.get("authenticated"): return RedirectResponse('/login')
messages = load_session_data(sid)
bubbles = [Div(P("Vous", cls="msg-user"), Div(NotStr(f"{msg['user']}{msg.get('info', '')}"), cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(msg['ia'], cls="bubble-ia")) for msg in messages]
return tuple(bubbles) + (Input(type="hidden", name="session_id", value=sid, id="current-session-id", hx_swap_oob="true"),)
@rt('/history/pin/{sid}')
def post_pin(sid: str, session):
if not session.get("authenticated"): return RedirectResponse('/login')
idx = load_index()
if sid in idx:
idx[sid]['pinned'] = not idx[sid].get('pinned', False)
save_index(idx)
return render_history_list()
@rt('/history/rename/{sid}')
def post_rename(sid: str, title: str, session):
if not session.get("authenticated"): return RedirectResponse('/login')
idx = load_index()
if sid in idx:
idx[sid]['title'] = title
save_index(idx)
return render_history_list()
@rt('/history/delete/{sid}')
def post_delete(sid: str, session):
if not session.get("authenticated"): return RedirectResponse('/login')
idx = load_index()
if sid in idx:
del idx[sid]
save_index(idx)
if os.path.exists(f"sessions/{sid}.json"): os.remove(f"sessions/{sid}.json")
return render_history_list()
if __name__ == '__main__':
import uvicorn
# Démarre le serveur Uvicorn en mode production, accroché à Docker de manière stable
uvicorn.run(app, host="0.0.0.0", port=5001)