Files
BookIA/comparer_modeles.py
Vincent Laperrousaz e481b13161 Partie 1 : réécriture complète avec le prompt v6 (introduction, ch1-3)
- reecrire_gemini.py : nouveau prompt v6 (amplitude littéraire, refus de la
  sécheresse, règle 6 Causalité Narrative renforcée et priorisée) ; second
  appel de vérification factuelle automatique (gemini-3.6-flash, température
  0) qui compare littéralement chaque bloc réécrit à l'original et signale
  chiffres modifiés, citations dissoutes, attributions floutées, détails
  techniques perdus ; adoption du tier de service 'flex' par défaut (50 %
  moins cher, latence variable), avec repli configurable en 'standard'.
- comparer_modeles.py : mis à jour pour la nouvelle signature de
  load_credentials() (4 valeurs au lieu de 2).
- Introduction, chapitre 1, chapitre 2, chapitre 3 : réécrit- Introduction, chapitre 1, chapitre 2, chapitre 3 : réécrit- Introduction, chapitre 1, chapitre 2, chapitre 3 : réécrit- Introduction, chapitre 1, chapitre 2, chapitre 3 : réécrit- Introduction, chapitre 1, chapi'auteur intégrées
  directement sur le texte v6 de l'introduction (références sans antécédent,
  personnages non présentés, section 'Quatre mouvements' réécrite pour
  éviter l'anaphore de plan scolaire).
- 00_REECRITURE_GEMINI.md : journal de méthode mis à jour (sections 16-17)
  pour refléter le prompt v6, le second appel de vérification factuelle et
  le tier flex, jusqu'ici non documentés.
2026-08-04 13:58:55 +02:00

419 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Comparaison à l'aveugle de deux modèles sur la même tâche de réécriture.
Motif (03/08/2026) : le dispositif de réécriture stylistique du manuscrit a été
validé sur un seul modèle, Gemini 3.1 Pro (voir `00_REECRITURE_GEMINI.md`). Rien
ne dit qu'il est le meilleur pour cette plume. Ce script fait passer un MÊME bloc
de texte à deux modèles, avec le MÊME prompt et les MÊMES réglages
d'échantillonnage, et écrit les deux sorties côte à côte pour arbitrage humain.
CE QUI GARANTIT L'ÉQUITÉ DE LA COMPARAISON, et c'est tout l'intérêt du fichier :
rien n'est recopié depuis `reecrire_gemini.py`. Le prompt, les bornes de bloc, le
calcul de la phrase de tuilage, les vérifications d'appels de note et de
bavardage, la température et le top-p sont IMPORTÉS. Si le prompt de production
change, la comparaison change avec lui, sans intervention.
from reecrire_gemini import PROMPT_TEMPLATE, TEMPERATURE, TOP_P, ...
DEUX BACKENDS :
- google : API Gemini native (generateContent), clé dans .gemini-credentials
- openai : n'importe quel endpoint compatible OpenAI (/chat/completions),
donc DashScope international pour les modèles Qwen, clé dans
.qwen-credentials
GARDE-FOU AJOUTÉ ICI, ET IL MANQUAIT AU DISPOSITIF DE PRODUCTION :
la section 12.5 de `00_PLAN_NARRATIF.md` signale que les citations en bloc
(lignes commençant par « > ») ne doivent jamais partir à l'API, parce qu'une
citation attribuée à un tiers ne se réécrit pas — et que le script ne le
contrôlait pas. Ce script refuse d'envoyer un bloc qui en contient, sauf
--autoriser-citations. Le contrôle est également disponible pour la production
via `verifier_citations_bloc()`.
Usage :
# 0. Vérifier ce que la clé Qwen donne réellement accès (aucun envoi de texte)
python3 comparer_modeles.py modeles
# 1. Lancer la comparaison sur un bloc
python3 comparer_modeles.py comparer \\
--file Manuscrit/00-introduction.md \\
--fin "## Le roi qui refusa un cadeau" \\
--qwen-model qwen3-max
# 2. Comparer les deux sorties
ls Comparaison_Modeles/
"""
import argparse
import json
import re
import sys
import time
from pathlib import Path
import requests
from reecrire_gemini import (
PROMPT_TEMPLATE,
TEMPERATURE,
TOP_P,
appeler_gemini,
extraire_bloc,
lire_manuscrit,
load_credentials,
phrase_precedente_reelle,
verifier_bavardage,
verifier_notes,
)
RACINE = Path(__file__).resolve().parent
QWEN_CREDENTIALS = RACINE / ".qwen-credentials"
QWEN_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
SORTIE_DIR = RACINE / "Comparaison_Modeles"
# --------------------------------------------------------------------------
# Identifiants du backend compatible OpenAI
# --------------------------------------------------------------------------
def charger_qwen():
if not QWEN_CREDENTIALS.exists():
sys.exit(
f"Erreur : fichier introuvable {QWEN_CREDENTIALS}\n\n"
"Crée-le à la main, sans jamais faire passer la clé par une conversation :\n"
f" printf 'QWEN_API_KEY=sk-xxxxxxxx\\n' > {QWEN_CREDENTIALS}\n"
f" chmod 600 {QWEN_CREDENTIALS}\n\n"
"Il est déjà couvert par .gitignore. Clés optionnelles dans ce fichier :\n"
" QWEN_MODEL=... (modèle par défaut)\n"
" QWEN_BASE_URL=... (si l'endpoint change de région)"
)
brut = QWEN_CREDENTIALS.read_text(encoding="utf-8")
creds = {}
for ligne in brut.splitlines():
ligne = ligne.strip()
if ligne and not ligne.startswith("#") and "=" in ligne:
k, v = ligne.split("=", 1)
creds[k.strip()] = v.strip()
cle = creds.get("QWEN_API_KEY", "")
if not cle:
# Diagnostic précis plutôt qu'un « manquant ou vide » qui n'aide personne.
# Accident constaté le 03/08/2026 : le marqueur QWEN_API_KEY= avait été
# saisi AU MILIEU de la clé collée, le curseur n'étant pas en début de
# ligne. La ligne contient bien un « = », donc l'analyse ci-dessus
# produit un nom de clé absurde et aucune valeur exploitable.
detail = ""
if "QWEN_API_KEY" in brut and not any(
l.strip().startswith("QWEN_API_KEY") for l in brut.splitlines()
):
detail = (
"\n\nCAUSE PROBABLE : le marqueur `QWEN_API_KEY` est présent dans le "
"fichier mais PAS en début de ligne — il a été saisi au milieu de la "
"clé. Le fichier doit contenir une ligne, et une seule, de la forme "
"exacte :\n QWEN_API_KEY=sk-...\n"
"Réécris-le entièrement plutôt que de le rafistoler : une clé coupée "
"en deux ne se recolle pas de façon fiable."
)
elif brut.strip():
detail = (
f"\n\nLe fichier n'est pas vide ({len(brut)} octets) mais aucune ligne "
"ne commence par `QWEN_API_KEY=`."
)
sys.exit(
f"Erreur : QWEN_API_KEY introuvable dans {QWEN_CREDENTIALS}{detail}\n\n"
"Réécriture sans faire passer la clé par l'historique du shell :\n"
" read -rs QWEN && printf 'QWEN_API_KEY=%s\\n' \"$QWEN\" > "
f"{QWEN_CREDENTIALS.name} && unset QWEN\n"
"(coller la clé, puis Entrée — rien ne s'affiche à la saisie)"
)
return cle, creds.get("QWEN_MODEL", "").strip(), creds.get("QWEN_BASE_URL", "").strip() or QWEN_BASE_URL
# --------------------------------------------------------------------------
# Garde-fou citations en bloc (dette signalée en §12.5 du plan narratif)
# --------------------------------------------------------------------------
def verifier_citations_bloc(bloc: str) -> list[str]:
"""Une citation en bloc est attribuée à un tiers : elle ne se réécrit pas.
Retourne la liste des lignes fautives, vide si le bloc est sûr."""
return [
ligne.strip()
for ligne in bloc.splitlines()
if ligne.lstrip().startswith(">")
]
# --------------------------------------------------------------------------
# Appel compatible OpenAI
# --------------------------------------------------------------------------
def appeler_openai_compatible(cle, base_url, model, prompt, tentatives=4,
temperature=None, top_p=None, flux=True):
"""Appel compatible OpenAI, EN FLUX PAR DÉFAUT.
Motif (03/08/2026) : en mode bloquant, `qwen3.8-max` a dépassé deux fois le
délai de lecture de 300 s sur un bloc de mille mots, alors que le même appel
à température 0,5 avait abouti. Un modèle qui réfléchit longtemps n'envoie
rien avant d'avoir fini, si bien qu'aucun délai raisonnable ne suffit et
qu'on ne distingue pas une lenteur d'un blocage. En flux, les octets
arrivent en continu : le délai ne porte plus que sur l'intervalle entre deux
fragments, et l'avancement est visible. `--sans-flux` conserve l'ancien
comportement si un endpoint ne gère pas le streaming.
"""
url = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": TEMPERATURE if temperature is None else temperature,
"top_p": TOP_P if top_p is None else top_p,
}
if flux:
payload["stream"] = True
payload["stream_options"] = {"include_usage": True}
entetes = {"Authorization": f"Bearer {cle}", "Content-Type": "application/json"}
derniere = None
for essai in range(1, tentatives + 1):
try:
if flux:
texte, usage = _appel_flux(url, payload, entetes)
else:
texte, usage = _appel_bloquant(url, payload, entetes)
if texte:
return texte, usage
derniere = "réponse vide"
except requests.exceptions.RequestException as exc:
derniere = str(exc)
except RuntimeError as exc: # erreur HTTP non retryable
sys.exit(str(exc))
if essai == tentatives:
break
attente = min(60, 5 * (2 ** (essai - 1)))
print(f" tentative {essai}/{tentatives} échouée ({str(derniere)[:200]}) — "
f"nouvel essai dans {attente}s...", flush=True)
time.sleep(attente)
sys.exit(f"Échec après {tentatives} tentatives : {derniere}")
def _appel_bloquant(url, payload, entetes):
resp = requests.post(url, json=payload, headers=entetes, timeout=600)
if resp.status_code != 200:
if resp.status_code in (429, 500, 502, 503, 504):
raise requests.exceptions.RequestException(f"{resp.status_code} : {resp.text[:300]}")
raise RuntimeError(f"Erreur API ({resp.status_code}) : {resp.text[:1500]}")
data = resp.json()
choix = data.get("choices") or []
if not choix:
raise requests.exceptions.RequestException(f"aucune réponse : {str(data)[:400]}")
return (choix[0].get("message", {}).get("content") or "").strip(), data.get("usage", {})
def _appel_flux(url, payload, entetes):
"""Lit la réponse fragment par fragment. Le délai porte sur l'inactivité
entre deux fragments, pas sur la durée totale de génération."""
morceaux = []
usage = {}
t0 = time.time()
dernier_affichage = t0
with requests.post(url, json=payload, headers=entetes, timeout=(30, 180), stream=True) as resp:
if resp.status_code != 200:
corps = resp.text[:1500]
if resp.status_code in (429, 500, 502, 503, 504):
raise requests.exceptions.RequestException(f"{resp.status_code} : {corps[:300]}")
raise RuntimeError(f"Erreur API ({resp.status_code}) : {corps}")
for ligne in resp.iter_lines(decode_unicode=True):
if not ligne or not ligne.startswith("data:"):
continue
charge = ligne[5:].strip()
if charge == "[DONE]":
break
try:
bloc = json.loads(charge)
except json.JSONDecodeError:
continue
if bloc.get("usage"):
usage = bloc["usage"]
for ch in bloc.get("choices") or []:
delta = ch.get("delta") or {}
# `reasoning_content` est le brouillon des modèles à
# raisonnement : on ne le collecte pas, seul le texte final
# est comparable à une sortie Gemini.
if delta.get("content"):
morceaux.append(delta["content"])
if time.time() - dernier_affichage > 20:
dernier_affichage = time.time()
print(f" ... {sum(len(m) for m in morceaux)} caractères reçus "
f"après {time.time() - t0:.0f} s", flush=True)
return "".join(morceaux).strip(), usage
# --------------------------------------------------------------------------
# Commandes
# --------------------------------------------------------------------------
def cmd_modeles(args):
"""Interroge l'endpoint pour savoir ce que la clé permet réellement, plutôt
que de deviner un identifiant de modèle. N'envoie aucun texte du manuscrit."""
cle, _, base_url = charger_qwen()
url = f"{base_url.rstrip('/')}/models"
resp = requests.get(url, headers={"Authorization": f"Bearer {cle}"}, timeout=60)
if resp.status_code != 200:
sys.exit(
f"Erreur ({resp.status_code}) sur {url} :\n{resp.text[:1000]}\n\n"
"Si l'endpoint n'expose pas /models, passe directement --qwen-model "
"avec l'identifiant exact vu dans la console Model Studio."
)
noms = sorted(m.get("id", "") for m in resp.json().get("data", []))
print(f"{len(noms)} modèles accessibles sur {base_url} :\n")
for n in noms:
marque = " <-- candidat" if re.search(r"qwen3|qwen-max|qwen-plus", n) else ""
print(f" {n}{marque}")
def cmd_comparer(args):
fichier = Path(args.file)
texte = lire_manuscrit(fichier)
bloc, i0, _ = extraire_bloc(texte, args.debut, args.fin)
citations = verifier_citations_bloc(bloc)
if citations and not args.autoriser_citations:
print("⚠️ Ce bloc contient des citations en bloc, qui ne doivent pas être "
"réécrites (§12.5 du plan narratif) :", file=sys.stderr)
for c in citations:
print(f" {c[:120]}", file=sys.stderr)
sys.exit(
"\nDéplace les bornes pour exclure ces lignes, ou relance avec "
"--autoriser-citations si tu acceptes de vérifier toi-même qu'elles "
"reviennent au caractère près."
)
phrase_prec = args.phrase_precedente or phrase_precedente_reelle(texte, i0)
if not phrase_prec:
phrase_prec = "Aucune, c'est le début du chapitre."
prompt = PROMPT_TEMPLATE.format(phrase_precedente=phrase_prec, bloc=bloc)
SORTIE_DIR.mkdir(exist_ok=True)
base = fichier.stem
resultats = []
temp_qwen = TEMPERATURE if args.temperature is None else args.temperature
top_p_qwen = TOP_P if args.top_p is None else args.top_p
surcharge = (temp_qwen, top_p_qwen) != (TEMPERATURE, TOP_P)
print("=" * 74)
print(f"BLOC : {fichier} [{len(bloc.split())} mots]")
print(f"TUILAGE : {phrase_prec[:100]!r}")
if surcharge:
print(f"RÉGLAGES : Gemini temperature={TEMPERATURE} top_p={TOP_P} (production) / "
f"Qwen temperature={temp_qwen} top_p={top_p_qwen} (SURCHARGÉS)")
print(" ⚠️ les deux modèles ne tournent plus à réglages identiques : "
"cette exécution mesure l'effet du réglage sur Qwen, pas un duel équitable.")
else:
print(f"RÉGLAGES : temperature={TEMPERATURE} top_p={TOP_P} (importés de "
"reecrire_gemini.py, identiques pour les deux modèles)")
print(f"PROMPT : {len(prompt.split())} mots, importé tel quel")
print("=" * 74)
# --- référence : le bloc source, pour la comparaison à trois colonnes
src = SORTIE_DIR / f"{base}.00-source.md"
src.write_text(bloc + "\n", encoding="utf-8")
print(f"\nsource -> {src}")
if not args.sans_gemini:
cle_g, modele_g, _modele_verif_g, _tier_g = load_credentials()
if args.gemini_model:
modele_g = args.gemini_model
print(f"\n[1/2] Gemini ({modele_g})...")
t0 = time.time()
sortie_g = appeler_gemini(cle_g, modele_g, prompt)
resultats.append((f"gemini-{modele_g}", sortie_g, time.time() - t0, {}))
cle_q, modele_q, base_url = charger_qwen()
modele_q = args.qwen_model or modele_q
if not modele_q:
sys.exit("Erreur : aucun modèle Qwen indiqué. Passe --qwen-model, ou "
"renseigne QWEN_MODEL dans .qwen-credentials. Lance d'abord "
"`python3 comparer_modeles.py modeles` pour voir la liste réelle.")
print(f"\n[2/2] Qwen ({modele_q}) sur {base_url} "
f"[temperature={temp_qwen}, top_p={top_p_qwen}]...")
t0 = time.time()
sortie_q, usage = appeler_openai_compatible(
cle_q, base_url, modele_q, prompt, temperature=temp_qwen, top_p=top_p_qwen,
flux=not args.sans_flux,
)
# Le réglage entre dans le nom du fichier : sans cela, deux essais à
# températures différentes s'écrasent l'un l'autre en silence.
etiquette = f"qwen-{modele_q}"
if surcharge:
etiquette += f"-t{temp_qwen}-p{top_p_qwen}"
resultats.append((etiquette, sortie_q, time.time() - t0, usage))
print("\n" + "=" * 74)
print("RÉSULTATS")
print("=" * 74)
for nom, sortie, duree, usage in resultats:
chemin = SORTIE_DIR / f"{base}.{nom.replace('/', '_')}.md"
chemin.write_text(sortie + "\n", encoding="utf-8")
alertes = verifier_notes(bloc, sortie) + verifier_bavardage(sortie)
n_mots = len(sortie.split())
delta = n_mots - len(bloc.split())
print(f"\n{nom}")
print(f" fichier : {chemin}")
print(f" mots : {n_mots} ({delta:+d} vs source)")
print(f" durée : {duree:.1f} s")
if usage:
print(f" jetons : {usage}")
if alertes:
print(" ALERTES :")
for a in alertes:
print(f" - {a}")
else:
print(" alertes : aucune (appels de note identiques, pas de bavardage)")
print(
"\nRien n'a été écrit dans le manuscrit. Compare les fichiers, puis applique "
"le gagnant avec `reecrire_gemini.py appliquer --texte-reecrit <fichier>`.\n"
"Rappel de méthode : juger sur la lecture à voix haute et sur les six tests "
"de littéralité (section 3 terdecies), pas sur le nombre de mots."
)
def main():
p = argparse.ArgumentParser(description=__doc__.split("\n")[1],
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="commande", required=True)
p_mod = sub.add_parser("modeles", help="Liste les modèles réellement accessibles avec la clé Qwen.")
p_mod.set_defaults(func=cmd_modeles)
p_cmp = sub.add_parser("comparer", help="Envoie le même bloc aux deux modèles, mêmes réglages.")
p_cmp.add_argument("--file", required=True)
p_cmp.add_argument("--debut")
p_cmp.add_argument("--fin")
p_cmp.add_argument("--phrase-precedente")
p_cmp.add_argument("--qwen-model", help="Identifiant exact du modèle Qwen (voir la sous-commande `modeles`).")
p_cmp.add_argument("--gemini-model")
p_cmp.add_argument("--sans-gemini", action="store_true",
help="N'appelle que Qwen (utile si le quota Gemini est épuisé).")
p_cmp.add_argument("--temperature", type=float,
help=f"Surcharge la température POUR QWEN SEULEMENT (défaut {TEMPERATURE}, "
"valeur de production importée). Le réglage entre dans le nom du "
"fichier de sortie.")
p_cmp.add_argument("--top-p", type=float, dest="top_p",
help=f"Surcharge le top-p pour Qwen seulement (défaut {TOP_P}).")
p_cmp.add_argument("--sans-flux", action="store_true",
help="Appel bloquant au lieu du flux (à n'utiliser que si "
"l'endpoint ne gère pas le streaming).")
p_cmp.add_argument("--autoriser-citations", action="store_true",
help="Envoie le bloc même s'il contient des citations en bloc (déconseillé).")
p_cmp.set_defaults(func=cmd_comparer)
args = p.parse_args()
args.func(args)
if __name__ == "__main__":
main()