- 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.
671 lines
32 KiB
Python
671 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Réécriture stylistique du manuscrit via l'API Google Gemini (Gemini 3.1 Pro).
|
|
|
|
Objectif : appliquer, bloc de texte par bloc de texte, le prompt de refonte
|
|
stylistique fourni par l'auteur ("la plume Perouz") à des extraits du
|
|
manuscrit, SANS jamais toucher automatiquement au fichier final. Le workflow
|
|
est volontairement en trois étapes séparées, pour permettre une validation
|
|
progressive (partie par partie) avant toute application définitive :
|
|
|
|
1. extraire -> affiche exactement le bloc qui serait envoyé à l'API,
|
|
sans appeler l'API ni rien écrire (vérification).
|
|
2. reecrire -> appelle Gemini sur ce bloc et écrit le résultat dans un
|
|
fichier de RELECTURE séparé (jamais dans le manuscrit).
|
|
3. appliquer -> une fois le résultat relu et validé par l'auteur,
|
|
remplace le bloc original par le texte réécrit dans le
|
|
fichier du manuscrit lui-même.
|
|
|
|
Les notes de bas de page (bloc de définitions en fin de fichier, après le
|
|
commentaire HTML "<!-- Notes de bas de page -->") ne sont jamais envoyées à
|
|
l'API : elles ne font pas partie du texte littéraire. Les APPELS de note
|
|
(`[^c00-1]`) à l'intérieur du bloc réécrit sont en revanche vérifiés : le
|
|
script compare la liste des appels avant/après pour s'assurer qu'aucun n'a
|
|
été supprimé, ajouté ou déplacé par erreur.
|
|
|
|
CONSISTANCE ENTRE BLOCS : la [DERNIÈRE PHRASE DU BLOC PRÉCÉDENT] envoyée à
|
|
Gemini n'est PAS mémorisée dans un fichier d'état séparé (qui pourrait se
|
|
désynchroniser du manuscrit). Elle est recalculée à chaque appel directement
|
|
depuis le texte QUI SE TROUVE RÉELLEMENT dans le fichier, juste avant la
|
|
borne --debut du bloc en cours — que ce texte ait déjà été réécrit et
|
|
appliqué, ou qu'il soit encore dans sa version originale. C'est donc
|
|
toujours la vraie phrase adjacente, dans le fichier, au moment de l'appel.
|
|
--phrase-precedente permet de la forcer manuellement si besoin (ex. premier
|
|
bloc d'un chapitre qui doit s'enchaîner avec la fin du chapitre précédent).
|
|
|
|
Identifiants lus depuis .gemini-credentials (voir ce fichier pour le format).
|
|
|
|
Usage :
|
|
# 1. Vérifier l'extrait exact avant tout appel API
|
|
python3 reecrire_gemini.py extraire \\
|
|
--file Manuscrit/00-introduction.md \\
|
|
--debut "C'est l'été 2019" \\
|
|
--fin "## Le roi qui refusa un cadeau"
|
|
|
|
# 2. Envoyer ce bloc à Gemini, écrire le résultat dans un fichier de relecture
|
|
python3 reecrire_gemini.py reecrire \\
|
|
--file Manuscrit/00-introduction.md \\
|
|
--debut "C'est l'été 2019" \\
|
|
--fin "## Le roi qui refusa un cadeau" \\
|
|
--sortie Manuscrit/.relecture-00-introduction-partie1.md
|
|
|
|
# 3. Après relecture et validation, appliquer le texte réécrit au manuscrit
|
|
python3 reecrire_gemini.py appliquer \\
|
|
--file Manuscrit/00-introduction.md \\
|
|
--debut "C'est l'été 2019" \\
|
|
--fin "## Le roi qui refusa un cadeau" \\
|
|
--texte-reecrit Manuscrit/.relecture-00-introduction-partie1.md
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
CREDENTIALS_FILE = Path(__file__).parent / ".gemini-credentials"
|
|
DEFAULT_MODEL = "gemini-3.1-pro-preview"
|
|
# Modèle utilisé pour le second appel (vérification factuelle uniquement). Une
|
|
# comparaison littérale entre deux textes ne demande pas la puissance d'un
|
|
# modèle de production : un modèle plus léger et moins coûteux fait le travail
|
|
# et libère de la capacité sur le modèle principal, plus utile là où il compte
|
|
# vraiment (la réécriture littéraire elle-même). Surchargeable par
|
|
# GEMINI_MODEL_VERIF dans .gemini-credentials.
|
|
DEFAULT_MODEL_VERIF = "gemini-3.6-flash"
|
|
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
|
|
NOTES_MARKER = "<!-- Notes de bas de page"
|
|
|
|
# Réglages d'échantillonnage. Sortis en constantes de module le 03/08/2026 pour
|
|
# que `comparer_modeles.py` puisse les importer au lieu de les recopier : une
|
|
# comparaison entre deux modèles n'a de sens qu'à réglages rigoureusement
|
|
# identiques, et deux valeurs dupliquées finissent toujours par diverger.
|
|
TEMPERATURE = 0.5
|
|
TOP_P = 0.8
|
|
|
|
# Niveau de service par défaut pour les appels API. "flex" coûte 50 % moins
|
|
# cher que le tier standard, en échange d'une latence variable (cible
|
|
# officielle : 1 à 15 minutes, capacité "sheddable" en cas de pic sur le
|
|
# trafic standard). Adopté par défaut le 03/08/2026 : nos appels sont déjà
|
|
# lancés en arrière-plan avec relecture périodique plutôt que bloquants, ce
|
|
# qui rend cette latence sans coût réel pour le déroulement du travail.
|
|
# Surchargeable via --service-tier standard, ou GEMINI_SERVICE_TIER dans
|
|
# .gemini-credentials.
|
|
DEFAULT_SERVICE_TIER = "flex"
|
|
|
|
PROMPT_TEMPLATE = """RÔLE :
|
|
Tu es le directeur littéraire d'un essai ambitieux sur l'intelligence artificielle, écrit par l'essayiste français Vincent Perouz.
|
|
|
|
TA MISSION :
|
|
Réécrire le texte brut fourni pour lui donner une "musicalité implacable", un souffle philosophique et une fluidité absolue (dans l'esprit de Yuval Noah Harari ou Günther Anders), sans aucune altération du sens ou du balisage.
|
|
|
|
LES 4 RÈGLES DE LA PLUME (LE STYLE) :
|
|
|
|
1. L'Amplitude Littéraire et le Souffle : Alterne des phrases courtes, nues et percutantes avec des phrases beaucoup plus amples, sinueuses et riches. Prends le temps de déployer la langue. Utilise un vocabulaire texturé, organique et varié. Le texte ne doit pas seulement être intellectuellement implacable, il doit être physiquement beau et agréable à lire. Laisse le lecteur respirer dans des descriptions ou des constats bien enveloppés.
|
|
|
|
2. La Gravité Implacable : Ton philosophique et habité. L'effroi naît de la mise en perspective des faits, jamais de l'emphase. INTERDICTION d'utiliser des adjectifs dramatiques ou romanesques ("terrifiant", "apocalyptique"). Reste d'une grande sobriété clinique.
|
|
|
|
3. La Soudure Logique (Anti-hachage) : Une phrase brève est réservée à une affirmation qui tient debout seule. Si un fragment dépend grammaticalement ou logiquement de la phrase précédente (cause, conséquence, explication), il doit y rester soudé (par une virgule ou un deux-points). N'isole JAMAIS un « Car » ou « Parce que » après un point.
|
|
|
|
4. Le Tuilage : Assure une transition organique entre les concepts. Utilise la rhétorique du contraste et de la gradation pour créer le vertige.
|
|
|
|
L'ÉQUILIBRE DU FOND (LA RÈGLE DE L'OS ET DE LA CHAIR) :
|
|
5. Le Droit d'Élagage (Conceptuel) : Pour alléger la charge cognitive, tu as l'autorisation de supprimer ou fusionner les listes d'exemples industriels, les énumérations ou les détails illustratifs s'ils alourdissent la démonstration. Va à l'os philosophique.
|
|
6. La Causalité Narrative (L'interdiction du faux mystère) — RÈGLE PRIORITAIRE, qui prime sur toutes les autres en cas de conflit : l'élagage ne s'applique jamais aux scènes humaines, et l'amplitude littéraire de la règle 1 ne doit JAMAIS se payer en clarté sur l'identité des acteurs. Si une anecdote ou une action est racontée, la chaîne de causalité doit être absolue et immédiate. Dès la PREMIÈRE phrase où un acteur agit, parle, paie ou commande quelque chose, son identité et son lien avec les autres acteurs de la scène doivent être donnés — jamais reportés à une phrase ou un paragraphe ultérieur, même si le texte source lui-même différait cette identification. Ne cache et ne dilue JAMAIS l'identité d'un acteur derrière une formule anonymisante ("un commanditaire", "un tiers", "un autre homme", "un virement depuis un compte personnel") pour créer un faux suspense littéraire ou pour alléger une phrase : nomme-le. En cas de doute entre une phrase plus belle mais évasive sur l'identité, et une phrase plus simple mais nominative, choisis TOUJOURS la seconde.
|
|
|
|
CONTRAINTES TECHNIQUES INVIOLABLES :
|
|
|
|
- PRÉSERVATION DES NOTES : Les balises de notes (ex: [^c00-1]) doivent IMPÉRATIVEMENT être conservées et accrochées au concept exact qu'elles sourcent.
|
|
|
|
- ZÉRO INVENTION : N'ajoute aucune idée ou chiffre ne figurant pas dans le texte source.
|
|
|
|
- Le Refus de la Sécheresse : Ne confonds pas la concision avec l'aridité. Si le texte a besoin d'une phrase de transition élégante, d'une métaphore filée subtile ou d'un adjectif précisément choisi pour donner de la couleur à un constat, utilise-les. Le but n'est pas de faire un rapport de police, mais un grand essai littéraire. Renvoie UNIQUEMENT le texte final réécrit, sans introduction ni conclusion méta ni commentaire sur ton propre travail.
|
|
|
|
CONTEXTE D'ASSEMBLAGE :
|
|
Pour assurer un tuilage parfait, je te fournis la [DERNIÈRE PHRASE DU BLOC PRÉCÉDENT]. Utilise sa cadence ou son idée pour amorcer le nouveau bloc organiquement, mais NE LA RÉPÈTE PAS.
|
|
|
|
[DERNIÈRE PHRASE DU BLOC PRÉCÉDENT] :
|
|
{phrase_precedente}
|
|
|
|
[BLOC DE TEXTE À RÉÉCRIRE] :
|
|
{bloc}
|
|
"""
|
|
|
|
FOOTNOTE_CALL_RE = re.compile(r"\[\^[^\]]+\]")
|
|
|
|
# Prompt de vérification factuelle : un second appel, séparé du prompt littéraire,
|
|
# à température quasi nulle. Sa seule fonction est de comparer littéralement les
|
|
# deux versions et de signaler les divergences de FOND (chiffres, décomptes,
|
|
# attributions, détails techniques précis, citations) — jamais de style. Ajouté
|
|
# le 03/08/2026 après deux cas réels passés inaperçus des contrôles mécaniques
|
|
# (verifier_notes/verifier_bavardage) : un décompte "13+13" fusionné en "26" sans
|
|
# le détail, et un "plan de conformité technique" disparu derrière une pénalité
|
|
# générique. Aucun de ces deux défauts ne porte sur un appel de note ni sur du
|
|
# bavardage : seule une relecture comparative peut les attraper.
|
|
FACT_CHECK_PROMPT = """Tu es un vérificateur factuel strict, sans aucune sensibilité stylistique.
|
|
|
|
On te donne un TEXTE ORIGINAL et sa VERSION RÉÉCRITE par un directeur littéraire. Ta seule tâche
|
|
est de repérer les DIVERGENCES FACTUELLES entre les deux versions.
|
|
|
|
IGNORE COMPLÈTEMENT : le style, le rythme, la longueur des phrases, le vocabulaire, l'ordre des
|
|
propositions, toute reformulation qui préserve intégralement le sens et les faits.
|
|
|
|
SIGNALE EN REVANCHE, chacune sur sa propre ligne :
|
|
- tout chiffre changé, arrondi ou supprimé (montants, dates, décomptes, pourcentages, durées) ;
|
|
- toute répartition ventilée qui disparaît (ex : « treize chefs pour X et treize pour Y » fondus
|
|
en un seul total « vingt-six chefs » sans détail des deux catégories) ;
|
|
- toute attribution nominale disparue, floutée ou rendue anonyme (une personne nommée devenue
|
|
« un commanditaire », « un tiers », etc.) ;
|
|
- toute citation directe transformée en paraphrase ou en discours indirect ;
|
|
- tout détail technique ou factuel précis remplacé par une formule vague qui en perd le sens
|
|
(ex : « un plan de conformité technique » devenu simplement « une pénalité ») ;
|
|
- toute omission d'un fait secondaire présent dans l'original ;
|
|
- toute invention d'un fait, d'un chiffre ou d'une idée absent de l'original.
|
|
|
|
FORMAT DE RÉPONSE STRICT, rien d'autre :
|
|
- S'il n'y a AUCUNE divergence factuelle : réponds uniquement "AUCUNE DIVERGENCE".
|
|
- Sinon : une liste à puces, une divergence par ligne commençant par "- ", citant le fragment
|
|
original et le fragment réécrit correspondant.
|
|
|
|
Ne commente jamais le style. Ne complimente rien. Ne rends aucun autre texte.
|
|
|
|
[TEXTE ORIGINAL] :
|
|
{original}
|
|
|
|
[VERSION RÉÉCRITE] :
|
|
{reecrit}
|
|
"""
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Identifiants
|
|
# --------------------------------------------------------------------------
|
|
|
|
def load_credentials():
|
|
if not CREDENTIALS_FILE.exists():
|
|
sys.exit(
|
|
f"Erreur : fichier introuvable {CREDENTIALS_FILE}\n"
|
|
"Crée-le avec GEMINI_API_KEY=ta_clé (voir le fichier généré à la racine du projet)."
|
|
)
|
|
creds = {}
|
|
with open(CREDENTIALS_FILE, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
creds[k.strip()] = v.strip()
|
|
api_key = creds.get("GEMINI_API_KEY", "")
|
|
if not api_key:
|
|
sys.exit(f"Erreur : GEMINI_API_KEY manquant ou vide dans {CREDENTIALS_FILE}")
|
|
model = creds.get("GEMINI_MODEL", "").strip() or DEFAULT_MODEL
|
|
model_verif = creds.get("GEMINI_MODEL_VERIF", "").strip() or DEFAULT_MODEL_VERIF
|
|
service_tier = creds.get("GEMINI_SERVICE_TIER", "").strip() or DEFAULT_SERVICE_TIER
|
|
return api_key, model, model_verif, service_tier
|
|
|
|
|
|
MOTS_MIN_CONTEXTE = 25 # seuil sous lequel on remonte chercher une phrase de plus
|
|
|
|
|
|
def extraire_dernieres_phrases(texte: str, n_min: int = 2, mots_min: int = MOTS_MIN_CONTEXTE) -> str:
|
|
"""Heuristique simple : dernières phrases non vides du texte (délimitées
|
|
par . ! ? »), jointes par un espace. On part des n_min dernières phrases
|
|
(2 par défaut, pour le rythme du tuilage), mais si la plume Perouz
|
|
produit des phrases courtes et percutantes, ces 2 phrases peuvent être
|
|
trop maigres pour donner à Gemini un vrai élan de contexte. Dans ce cas,
|
|
on remonte chercher une phrase supplémentaire jusqu'à dépasser mots_min
|
|
mots (ou jusqu'à épuisement du texte disponible)."""
|
|
texte = texte.strip()
|
|
if not texte:
|
|
return ""
|
|
morceaux = [m.strip() for m in re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ý«])", texte) if m.strip()]
|
|
if not morceaux:
|
|
return texte
|
|
|
|
n = n_min
|
|
while True:
|
|
selection = morceaux[-n:]
|
|
if sum(len(m.split()) for m in selection) >= mots_min:
|
|
break
|
|
if n >= len(morceaux):
|
|
break
|
|
n += 1
|
|
|
|
return " ".join(selection)
|
|
|
|
|
|
def phrase_precedente_reelle(texte_fichier: str, idx_debut: int) -> str:
|
|
"""Renvoie les deux dernières phrases du texte qui précède RÉELLEMENT
|
|
idx_debut dans le fichier tel qu'il est sur le disque au moment de
|
|
l'appel — que ce texte ait déjà été réécrit et appliqué ou qu'il soit
|
|
encore original. C'est la seule façon de garantir que le contexte
|
|
d'assemblage envoyé à Gemini correspond toujours à ce qui précède
|
|
vraiment le bloc en cours."""
|
|
avant = texte_fichier[:idx_debut]
|
|
# Retire les blocs LaTeX/HTML bruts (titre de chapitre, commentaires) qui
|
|
# ne sont pas de la prose et fausseraient la détection des phrases.
|
|
avant = re.sub(r"```\{=\w+\}.*?```", "", avant, flags=re.DOTALL)
|
|
avant = re.sub(r"<!--.*?-->", "", avant, flags=re.DOTALL)
|
|
avant = re.sub(r"^#{1,6}.*$", "", avant, flags=re.MULTILINE)
|
|
avant = avant.strip()
|
|
if not avant:
|
|
return ""
|
|
return extraire_dernieres_phrases(avant, n_min=2)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Extraction du bloc
|
|
# --------------------------------------------------------------------------
|
|
|
|
def lire_manuscrit(fichier: Path) -> str:
|
|
if not fichier.exists():
|
|
sys.exit(f"Erreur : fichier introuvable {fichier}")
|
|
return fichier.read_text(encoding="utf-8")
|
|
|
|
|
|
def trouver_borne(texte: str, marqueur: str, nom: str, depuis: int = 0) -> int:
|
|
idx = texte.find(marqueur, depuis)
|
|
if idx == -1:
|
|
sys.exit(
|
|
f"Erreur : marqueur {nom} introuvable dans le fichier : {marqueur!r}\n"
|
|
"Vérifie l'orthographe exacte (espaces, apostrophes typographiques, etc.)."
|
|
)
|
|
occurrences = texte.count(marqueur)
|
|
if occurrences > 1:
|
|
print(
|
|
f"Attention : le marqueur {nom} apparaît {occurrences} fois dans le fichier. "
|
|
"La première occurrence après le point de départ a été utilisée.",
|
|
file=sys.stderr,
|
|
)
|
|
return idx
|
|
|
|
|
|
def extraire_bloc(texte: str, debut: str | None, fin: str | None) -> tuple[str, int, int]:
|
|
"""Retourne (bloc, index_debut, index_fin) où [index_debut:index_fin] == bloc
|
|
exactement (bornes incluses au début, exclues à la fin)."""
|
|
limite_notes = texte.find(NOTES_MARKER)
|
|
|
|
if debut:
|
|
idx_debut = trouver_borne(texte, debut, "--debut")
|
|
else:
|
|
idx_debut = 0
|
|
|
|
if fin:
|
|
idx_fin = trouver_borne(texte, fin, "--fin", depuis=idx_debut)
|
|
elif limite_notes != -1:
|
|
idx_fin = limite_notes
|
|
else:
|
|
idx_fin = len(texte)
|
|
|
|
if idx_fin <= idx_debut:
|
|
sys.exit("Erreur : la borne de fin précède ou coïncide avec la borne de début.")
|
|
|
|
if limite_notes != -1 and idx_debut > limite_notes:
|
|
sys.exit(
|
|
"Erreur : le point de départ se situe après le bloc de notes de bas de page. "
|
|
"Les notes ne font pas partie du texte à réécrire."
|
|
)
|
|
if limite_notes != -1 and idx_fin > limite_notes:
|
|
print(
|
|
"Attention : la borne de fin dépasse le début du bloc de notes de bas de page. "
|
|
"Le bloc sera tronqué juste avant les notes.",
|
|
file=sys.stderr,
|
|
)
|
|
idx_fin = limite_notes
|
|
|
|
bloc = texte[idx_debut:idx_fin]
|
|
return bloc.rstrip("\n"), idx_debut, idx_fin
|
|
|
|
|
|
def separateur_final(texte: str, idx_debut: int, idx_fin: int) -> str:
|
|
"""Renvoie exactement les retours à la ligne qui séparaient, dans le texte
|
|
ORIGINAL, la fin du bloc de la borne de fin (prochain `##`, ou marqueur de
|
|
notes, ou fin de fichier). Sert à préserver ce séparateur lors de la
|
|
ré-injection du bloc réécrit, qui aura lui-même été dépouillé de ses
|
|
retours à la ligne finaux avant d'être recollé."""
|
|
bloc_brut = texte[idx_debut:idx_fin]
|
|
return bloc_brut[len(bloc_brut.rstrip("\n")):] or "\n\n"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Appel API Gemini
|
|
# --------------------------------------------------------------------------
|
|
|
|
def appeler_gemini(api_key: str, model: str, prompt: str, tentatives: int = 5, service_tier: str | None = None) -> str:
|
|
return appeler_gemini_temperature(api_key, model, prompt, TEMPERATURE, TOP_P, tentatives, service_tier)
|
|
|
|
|
|
def appeler_gemini_temperature(
|
|
api_key: str,
|
|
model: str,
|
|
prompt: str,
|
|
temperature: float,
|
|
top_p: float,
|
|
tentatives: int = 5,
|
|
service_tier: str | None = None,
|
|
) -> str:
|
|
"""Variante d'appeler_gemini avec température/top_p explicites, pour les
|
|
appels qui ne doivent pas partager les réglages littéraires de production
|
|
(ex. la vérification factuelle, qui veut un modèle quasi déterministe).
|
|
|
|
service_tier : "flex" (par défaut) coûte 50 % moins cher mais répond en
|
|
plusieurs minutes dans le pire cas (cible officielle 1-15 min) et peut être
|
|
évincé sans préavis en cas de pic sur le trafic standard (503). Le timeout
|
|
HTTP est donc porté à 600 s pour ce tier, contre 240 s en "standard"."""
|
|
tier = service_tier or DEFAULT_SERVICE_TIER
|
|
url = f"{API_BASE}/{model}:generateContent"
|
|
payload = {
|
|
"contents": [{"parts": [{"text": prompt}]}],
|
|
"generationConfig": {
|
|
"temperature": temperature,
|
|
"topP": top_p,
|
|
},
|
|
}
|
|
if tier and tier != "standard":
|
|
payload["service_tier"] = tier
|
|
timeout_http = 600 if tier == "flex" else 240
|
|
|
|
resp = None
|
|
derniere_erreur = None
|
|
for essai in range(1, tentatives + 1):
|
|
try:
|
|
resp = requests.post(
|
|
url,
|
|
params={"key": api_key},
|
|
json=payload,
|
|
timeout=timeout_http,
|
|
)
|
|
except requests.exceptions.RequestException as exc:
|
|
derniere_erreur = str(exc)
|
|
resp = None
|
|
|
|
# Cas retryable : erreur réseau, 429 (quota/rate-limit), 503 (surcharge
|
|
# ou éviction du tier flex — comportement attendu et documenté par
|
|
# Google pour la capacité "sheddable")
|
|
if resp is not None and resp.status_code == 200:
|
|
break
|
|
if resp is not None:
|
|
derniere_erreur = f"{resp.status_code} : {resp.text[:500]}"
|
|
retryable = resp is None or resp.status_code in (429, 500, 503, 504)
|
|
if not retryable or essai == tentatives:
|
|
break
|
|
attente = min(60, 5 * (2 ** (essai - 1)))
|
|
print(
|
|
f"Tentative {essai}/{tentatives} échouée ({derniere_erreur[:200]}) — "
|
|
f"nouvel essai dans {attente}s...",
|
|
file=sys.stderr,
|
|
)
|
|
time.sleep(attente)
|
|
|
|
if resp is None:
|
|
sys.exit(f"Erreur réseau après {tentatives} tentatives : {derniere_erreur}")
|
|
if resp.status_code != 200:
|
|
sys.exit(
|
|
f"Erreur API Gemini ({resp.status_code}) après {tentatives} tentatives : {resp.text[:2000]}\n\n"
|
|
"Vérifie GEMINI_API_KEY dans .gemini-credentials, et le nom du modèle "
|
|
f"({model!r}) — bascule éventuellement sur GEMINI_MODEL=gemini-2.5-pro "
|
|
"si gemini-3.1-pro-preview n'est pas disponible sur ta clé."
|
|
)
|
|
data = resp.json()
|
|
|
|
if "promptFeedback" in data and data["promptFeedback"].get("blockReason"):
|
|
sys.exit(f"Erreur : requête bloquée par Gemini ({data['promptFeedback']['blockReason']}).")
|
|
|
|
candidates = data.get("candidates", [])
|
|
if not candidates:
|
|
sys.exit(f"Erreur : aucune réponse renvoyée par Gemini.\n{json.dumps(data, ensure_ascii=False, indent=2)[:2000]}")
|
|
|
|
candidat = candidates[0]
|
|
finish_reason = candidat.get("finishReason", "")
|
|
parts = candidat.get("content", {}).get("parts", [])
|
|
texte = "".join(p.get("text", "") for p in parts).strip()
|
|
|
|
if not texte:
|
|
sys.exit(
|
|
f"Erreur : réponse vide de Gemini (finishReason={finish_reason!r}).\n"
|
|
f"{json.dumps(data, ensure_ascii=False, indent=2)[:2000]}"
|
|
)
|
|
if finish_reason not in ("STOP", ""):
|
|
print(f"Attention : finishReason={finish_reason!r} (réponse peut-être tronquée).", file=sys.stderr)
|
|
|
|
return texte
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Vérifications post-réécriture
|
|
# --------------------------------------------------------------------------
|
|
|
|
def verifier_notes(original: str, reecrit: str) -> list[str]:
|
|
"""Compare les appels de note [^...] avant/après. Retourne une liste
|
|
d'alertes (vide si tout est cohérent)."""
|
|
alertes = []
|
|
appels_orig = FOOTNOTE_CALL_RE.findall(original)
|
|
appels_new = FOOTNOTE_CALL_RE.findall(reecrit)
|
|
|
|
if appels_orig != appels_new:
|
|
set_orig, set_new = set(appels_orig), set(appels_new)
|
|
manquants = [a for a in appels_orig if a not in set_new]
|
|
ajoutes = [a for a in appels_new if a not in set_orig]
|
|
if manquants:
|
|
alertes.append(f"Appels de note DISPARUS après réécriture : {manquants}")
|
|
if ajoutes:
|
|
alertes.append(f"Appels de note APPARUS (non présents dans l'original) : {ajoutes}")
|
|
if not manquants and not ajoutes and appels_orig != appels_new:
|
|
alertes.append(
|
|
f"Les appels de note sont présents mais dans un ORDRE différent : "
|
|
f"avant {appels_orig} / après {appels_new}"
|
|
)
|
|
|
|
# Vérifie qu'aucune définition de note ne s'est glissée dans le bloc réécrit
|
|
if re.search(r"^\[\^[^\]]+\]:", reecrit, flags=re.MULTILINE):
|
|
alertes.append(
|
|
"Une ligne de DÉFINITION de note (`[^id]: ...`) est présente dans le texte réécrit : "
|
|
"les notes ne doivent jamais être réécrites ni déplacées."
|
|
)
|
|
|
|
return alertes
|
|
|
|
|
|
def verifier_bavardage(reecrit: str) -> list[str]:
|
|
alertes = []
|
|
debut = reecrit.strip()[:80].lower()
|
|
formules = ["voici le texte", "voici la version", "j'ai appliqué", "texte réécrit :", "voici le résultat"]
|
|
if any(f in debut for f in formules):
|
|
alertes.append(
|
|
"La réponse commence par une formule de bavardage méta (ex. « Voici le texte réécrit »). "
|
|
"À retirer manuellement avant application."
|
|
)
|
|
return alertes
|
|
|
|
|
|
def verifier_faits(api_key: str, model: str, original: str, reecrit: str, service_tier: str | None = None) -> list[str]:
|
|
"""Second appel Gemini, dédié et à température quasi nulle, qui compare
|
|
littéralement l'original et la sortie réécrite pour détecter les
|
|
divergences de fond (chiffres, décomptes ventilés, attributions,
|
|
citations, détails techniques) qu'aucun contrôle mécanique ne couvre.
|
|
Best-effort : une erreur ici ne doit jamais bloquer le flux de travail,
|
|
elle est simplement remontée comme avertissement."""
|
|
prompt = FACT_CHECK_PROMPT.format(original=original, reecrit=reecrit)
|
|
try:
|
|
reponse = appeler_gemini_temperature(
|
|
api_key, model, prompt, temperature=0.0, top_p=1.0, tentatives=3, service_tier=service_tier
|
|
)
|
|
except SystemExit as exc:
|
|
return [f"Vérification factuelle automatique impossible ({exc}). À relire manuellement contre l'archive."]
|
|
|
|
reponse = reponse.strip()
|
|
if reponse.upper().startswith("AUCUNE DIVERGENCE"):
|
|
return []
|
|
|
|
lignes = [l.strip() for l in reponse.splitlines() if l.strip().startswith("-")]
|
|
if not lignes:
|
|
# Réponse non vide mais hors format attendu : on la remonte quand même,
|
|
# mieux vaut un signal bruyant qu'un signal manqué.
|
|
lignes = [f"- {reponse}"]
|
|
return [f"[Vérification factuelle] {l.lstrip('- ').strip()}" for l in lignes]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Commandes
|
|
# --------------------------------------------------------------------------
|
|
|
|
def cmd_extraire(args):
|
|
fichier = Path(args.file)
|
|
texte = lire_manuscrit(fichier)
|
|
bloc, i0, i1 = extraire_bloc(texte, args.debut, args.fin)
|
|
|
|
phrase_prec = args.phrase_precedente or phrase_precedente_reelle(texte, i0)
|
|
|
|
print("=" * 70)
|
|
print(f"FICHIER : {fichier}")
|
|
print(f"BORNES : caractères [{i0}:{i1}] sur {len(texte)}")
|
|
print(f"MOTS : {len(bloc.split())}")
|
|
print(f"PHRASE PRÉCÉDENTE (contexte d'assemblage) : {phrase_prec or '(aucune, mémorisée vide)'}")
|
|
print("=" * 70)
|
|
print(bloc)
|
|
print("=" * 70)
|
|
print("(Aperçu seul — aucun appel API effectué.)")
|
|
|
|
|
|
def cmd_reecrire(args):
|
|
api_key, model, model_verif, service_tier = load_credentials()
|
|
if args.model:
|
|
model = args.model
|
|
if args.model_verif:
|
|
model_verif = args.model_verif
|
|
if args.service_tier:
|
|
service_tier = args.service_tier
|
|
|
|
fichier = Path(args.file)
|
|
texte = lire_manuscrit(fichier)
|
|
bloc, i0, i1 = extraire_bloc(texte, args.debut, args.fin)
|
|
|
|
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)
|
|
|
|
print(
|
|
f"Envoi à Gemini ({model}, service_tier={service_tier})... "
|
|
f"[{len(bloc.split())} mots, phrase précédente : {phrase_prec[:80]!r}]"
|
|
)
|
|
reecrit = appeler_gemini(api_key, model, prompt, service_tier=service_tier)
|
|
|
|
alertes = verifier_notes(bloc, reecrit) + verifier_bavardage(reecrit)
|
|
|
|
if not args.sans_verif_faits:
|
|
print(f"Vérification factuelle automatique (second appel, {model_verif}, service_tier={service_tier}, température 0)...")
|
|
alertes += verifier_faits(api_key, model_verif, bloc, reecrit, service_tier=service_tier)
|
|
|
|
sortie = Path(args.sortie) if args.sortie else fichier.with_suffix(f".relecture{fichier.suffix}")
|
|
sortie.parent.mkdir(parents=True, exist_ok=True)
|
|
sortie.write_text(reecrit + "\n", encoding="utf-8")
|
|
|
|
print(f"\nRésultat écrit dans : {sortie}")
|
|
print(f"Mots avant : {len(bloc.split())} / Mots après : {len(reecrit.split())}")
|
|
|
|
if alertes:
|
|
print("\n⚠️ ALERTES À VÉRIFIER AVANT APPLICATION :")
|
|
for a in alertes:
|
|
print(f" - {a}")
|
|
else:
|
|
print("\nAucune alerte automatique : appels de note identiques et dans le même ordre, pas de bavardage détecté.")
|
|
|
|
print(
|
|
"\nCe fichier N'A PAS été appliqué au manuscrit. Relis-le, puis lance :\n"
|
|
f" python3 reecrire_gemini.py appliquer --file {fichier} "
|
|
f"{f'--debut {args.debut!r} ' if args.debut else ''}"
|
|
f"{f'--fin {args.fin!r} ' if args.fin else ''}"
|
|
f"--texte-reecrit {sortie}"
|
|
)
|
|
|
|
|
|
def cmd_appliquer(args):
|
|
fichier = Path(args.file)
|
|
texte = lire_manuscrit(fichier)
|
|
bloc, i0, i1 = extraire_bloc(texte, args.debut, args.fin)
|
|
|
|
texte_reecrit_path = Path(args.texte_reecrit)
|
|
if not texte_reecrit_path.exists():
|
|
sys.exit(f"Erreur : fichier de texte réécrit introuvable : {texte_reecrit_path}")
|
|
nouveau_bloc = texte_reecrit_path.read_text(encoding="utf-8").strip("\n")
|
|
|
|
alertes = verifier_notes(bloc, nouveau_bloc) + verifier_bavardage(nouveau_bloc)
|
|
if alertes and not args.forcer:
|
|
print("⚠️ Application annulée, alertes non résolues :", file=sys.stderr)
|
|
for a in alertes:
|
|
print(f" - {a}", file=sys.stderr)
|
|
print("\nCorrige le fichier de relecture puis relance, ou utilise --forcer pour ignorer.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
sep = separateur_final(texte, i0, i1)
|
|
nouveau_texte = texte[:i0] + nouveau_bloc + sep + texte[i1:]
|
|
|
|
# Sauvegarde de sécurité avant écrasement
|
|
sauvegarde = fichier.with_suffix(fichier.suffix + ".avant-reecriture")
|
|
sauvegarde.write_text(texte, encoding="utf-8")
|
|
|
|
fichier.write_text(nouveau_texte, encoding="utf-8")
|
|
|
|
print(f"Manuscrit mis à jour : {fichier}")
|
|
print(f"Sauvegarde de l'ancienne version : {sauvegarde}")
|
|
print(f"Mots remplacés : {len(bloc.split())} -> {len(nouveau_bloc.split())}")
|
|
print(
|
|
"Le prochain bloc (--debut différent) recalculera automatiquement sa propre "
|
|
"phrase précédente depuis ce fichier mis à jour : rien à mémoriser manuellement."
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Réécriture stylistique progressive du manuscrit via l'API Gemini."
|
|
)
|
|
sub = parser.add_subparsers(dest="commande", required=True)
|
|
|
|
p_extraire = sub.add_parser("extraire", help="Affiche le bloc exact sans appeler l'API (vérification).")
|
|
p_extraire.add_argument("--file", required=True, help="Fichier du manuscrit (ex. Manuscrit/00-introduction.md)")
|
|
p_extraire.add_argument("--debut", help="Texte marquant le début du bloc (première occurrence). Omis = début du fichier.")
|
|
p_extraire.add_argument("--fin", help="Texte marquant la fin du bloc (exclu). Omis = jusqu'aux notes de bas de page / fin de fichier.")
|
|
p_extraire.add_argument("--phrase-precedente", help="Force la phrase de contexte d'assemblage (sinon recalculée depuis le texte qui précède réellement --debut dans le fichier).")
|
|
p_extraire.set_defaults(func=cmd_extraire)
|
|
|
|
p_reecrire = sub.add_parser("reecrire", help="Appelle Gemini et écrit le résultat dans un fichier de relecture séparé.")
|
|
p_reecrire.add_argument("--file", required=True)
|
|
p_reecrire.add_argument("--debut")
|
|
p_reecrire.add_argument("--fin")
|
|
p_reecrire.add_argument("--phrase-precedente", help="Force la phrase de contexte d'assemblage (sinon recalculée depuis le texte qui précède réellement --debut dans le fichier).")
|
|
p_reecrire.add_argument("--sortie", help="Fichier de relecture à écrire (défaut : <fichier>.relecture.md)")
|
|
p_reecrire.add_argument("--model", help=f"Nom du modèle Gemini (défaut : {DEFAULT_MODEL}, ou GEMINI_MODEL dans .gemini-credentials)")
|
|
p_reecrire.add_argument(
|
|
"--model-verif",
|
|
help=f"Nom du modèle Gemini pour la vérification factuelle (défaut : {DEFAULT_MODEL_VERIF}, ou GEMINI_MODEL_VERIF dans .gemini-credentials). Un modèle plus léger suffit pour cette tâche de comparaison."
|
|
)
|
|
p_reecrire.add_argument(
|
|
"--service-tier",
|
|
choices=["flex", "standard"],
|
|
help=f"Niveau de service Gemini (défaut : {DEFAULT_SERVICE_TIER}, ou GEMINI_SERVICE_TIER dans .gemini-credentials). 'flex' coûte 50%% moins cher mais répond en plusieurs minutes et peut être évincé (503) en cas de pic sur le trafic standard."
|
|
)
|
|
p_reecrire.add_argument(
|
|
"--sans-verif-faits", action="store_true",
|
|
help="Désactive le second appel de vérification factuelle (chiffres, attributions, citations)."
|
|
)
|
|
p_reecrire.set_defaults(func=cmd_reecrire)
|
|
|
|
p_appliquer = sub.add_parser("appliquer", help="Remplace le bloc dans le manuscrit par le texte réécrit validé.")
|
|
p_appliquer.add_argument("--file", required=True)
|
|
p_appliquer.add_argument("--debut")
|
|
p_appliquer.add_argument("--fin")
|
|
p_appliquer.add_argument("--texte-reecrit", required=True, help="Fichier contenant le texte réécrit validé (sortie de 'reecrire', relu par l'auteur).")
|
|
p_appliquer.add_argument("--forcer", action="store_true", help="Ignore les alertes de vérification (notes, bavardage).")
|
|
p_appliquer.set_defaults(func=cmd_appliquer)
|
|
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|