- 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.
419 lines
18 KiB
Python
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()
|