Files
BookIA/verifier-tuilage.py
Vincent Laperrousaz fb0d6145f8 Partie 1 : reconstruction dialectique et réécriture Gemini (intro, ch1-4)
- Nouvelle règle de tension dialectique (section 2), avec encadré du
  03/08 rappelant que les seuils sont des instruments de diagnostic,
  pas des lois.
- Reconstruction dialectique de l'introduction et des chapitres 1 à 3
  (arcs argumentatifs, coutures, invariants restaurés).
- Réécriture stylistique par l'API Gemini (bloc par bloc) des
  chapitres 1 à 3, suivie des contrôles de littéralité et de rythme.
- Motif du tuyau installé une seconde fois au chapitre 4 ; six
  arbitrages du plan narratif tranchés et consignés.
- Suppression de la préface (jamais rédigée) ; le fichier devient
  table-des-matieres.md, seul porteur du sommaire.
- Archivage des quatre ouvertures de partie en prose, non compilées
  et redondantes avec l'annonce du plan dans l'introduction.
- Essai de comparaison Gemini/Qwen consigné dans 00_REECRITURE_GEMINI.md
  section 10 (sorties jetables supprimées, outil conservé).
- Audit de cohérence complet de la Partie 1 : invariants, renvois,
  appareil de notes, registre des sources, motifs, contrôles
  automatisés.

Fichiers de méthode versionnés pour la première fois :
00_PLAN_NARRATIF.md, 00_PROMPTS_RECHERCHE.md, 00_REECRITURE_GEMINI.md.
2026-08-03 18:00:14 +02:00

502 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
verifier-tuilage.py — mesure du TISSU CONJONCTIF (section 3 duodecies, règles B, C, D).
Troisième instrument du manuscrit, et il comble le trou nommé en propres termes
dans 00-COORDINATION.md : « le script n'a aucun indicateur de tissu conjonctif ».
· `detecteur-ia.py` mesure le RYTHME (longueurs, couperets, tirets)
· `verifier-litteralite.py` mesure la FORMULATION (survalorisation, gabarits)
· `verifier-tuilage.py` mesure L'ARTICULATION (ce qui relie une phrase à la suivante)
Le défaut visé est celui relevé par l'auteur le 30/07/2026 : « une suite de
phrases sans lien fort qui guide le lecteur ». Il est invisible aux deux autres
scripts, qui délivrent un verdict « conforme » sur un texte dont chaque phrase
est posée à plat à côté de la précédente.
QUATRE MESURES
1. PHRASES ORPHELINES — une phrase est accrochée à celle qui la précède par au
moins un de ces trois liens : une reprise lexicale (un mot plein commun),
une entrée anaphorique (« Ce silence… », « Elle… », « Cette prudence… »),
ou une articulation explicite autorisée par la règle B (`parce que`,
`puisque`, `alors que`, deux-points…). Aucun des trois : la phrase est
orpheline, et c'est au lecteur de deviner le rapport.
2. TUILAGE STRICT — forme exigeante du même contrôle, celle de la consigne de
l'auteur : le concept qui CLÔT une phrase doit amorcer la suivante. On ne
regarde donc que la queue de la phrase N (six mots pleins) et la tête de la
phrase N+1 (cinq mots pleins). Un texte peut n'avoir aucune orpheline et un
taux de tuilage médiocre : les liens y sont réels mais lointains, et le
lecteur avance sans être porté.
3. PHRASES PLATES — phrases de douze mots ou plus sans aucune subordination :
ni relative, ni conjonction, ni deux-points, ni point-virgule. Deux
propositions coordonnées par « et » restent plates. C'est la trace directe
du défaut décrit en section 3 duodecies : les tirets et les antithèses
supprimés ont été soldés par des points, et la subordination logique n'est
jamais revenue. La règle B l'autorise explicitement à l'intérieur d'une
phrase ; seules les charnières scolaires en TÊTE sont proscrites, et
celles-là sont comptées par `detecteur-ia.py`.
4. COUTURES SÈCHES (règle D, le gond) — passage d'un paragraphe au suivant sans
aucun lien lexical ni anaphorique. Le contrôle s'arrête aux frontières de
section : une couture sèche après un `##` est légitime.
Le rapport est découpé en BLOCS DE 500 À 800 MOTS, parce que c'est l'unité de
réécriture (voir .kiro/skills/reecrire-un-bloc/SKILL.md). Les blocs sont ensuite
classés du pire au meilleur : c'est la file de travail.
CE QUE CE SCRIPT NE VOIT PAS
— si le panneau indicateur (règle C) dit la bonne chose : il extrait la
première phrase de chaque paragraphe (`--fil`), la lecture reste à faire ;
— la qualité d'une reprise : « Cette chose » accroche formellement et
n'informe personne ;
— la musicalité, qui se juge à voix haute. Le CV local par bloc en donne un
indice, pas un verdict.
Usage :
python3 verifier-tuilage.py # tout le manuscrit
python3 verifier-tuilage.py Manuscrit/00-introduction.md # une section
python3 verifier-tuilage.py --detail Manuscrit/00-introduction.md # + les phrases fautives
python3 verifier-tuilage.py --fil Manuscrit/00-introduction.md # contrôle règle C
python3 verifier-tuilage.py --bloc 700 Manuscrit/01-*.md # autre taille de bloc
"""
import glob
import os
import re
import statistics
import sys
import unicodedata
from importlib.machinery import SourceFileLoader
_ICI = os.path.dirname(os.path.abspath(__file__))
_D = SourceFileLoader("_d", os.path.join(_ICI, "detecteur-ia.py")).load_module()
nettoyer = _D.nettoyer
paragraphes = _D.paragraphes
phrases = _D.phrases
mots = _D.mots
MANUSCRIT = _D.MANUSCRIT
# --- Mots vides : ils ne comptent jamais comme reprise lexicale --------------
# Verbes supports et adverbes de haute fréquence inclus : deux phrases qui
# partagent « faire » ou « déjà » ne sont pas tuilées pour autant.
VIDES = set("""
a ai ait aient as au aux aucun aucune aura auraient aurait auront aussi autant
autre autres avaient avais avait avant avec avoir avons ayant beaucoup bien
c car ce ceci cela celle celles celui cependant ces cet cette ceux chacun chaque
chez comme comment d dans de dedans dehors deja depuis des deux devait devant
devenir devient devrait doit doivent donc dont du duquel elle elles en encore
enfin entre es est et etaient etais etait etant ete etes etre eu eux fait faire
fais faisait fait faites fut furent grand grande ici il ils j jamais je jusqu
jusque l la laquelle le lequel les lesquels leur leurs lors lorsque lui m ma
mais me meme memes mes moi moins mon n ne ni non nos notre nous nul on ont ou
oui par parce pas peu peut peuvent plus plusieurs plutot pour pourquoi pourtant
pouvait pouvoir premier premiere pres pris puis puisque qu quand que quel quelle
quelles quelque quels qui quoi rien s sa sans se sera serait seront ses seul
seule si sien soit son sont sous souvent soi sur t ta tant te tel telle telles
tels tes toi ton toujours tous tout toute toutes tres trop tu un une va vais
vers veut voici voila voir vont vos votre vous y etait avait sera aurait
alors ainsi donc alors chose choses fois cas point points facon maniere
""".split())
# --- Reprises anaphoriques : la phrase désigne ce qui précède -----------------
# Elles sont cherchées dans les SIX PREMIERS MOTS, pas au seul premier : « Metz y
# lit que… » et « Le chiffre leur a paru petit » reprennent tous deux la phrase
# d'avant, sans commencer par le mot qui le fait.
ANAPHORIQUES = set("""
ce cet cette ces c celui celle ceux celles cela ca il elle ils elles on lui
leur leurs son sa ses y dont tel telle tels telles pareil pareille
chacun chacune aucun aucune rien voila meme memes
""".split())
FENETRE_ANAPHORE = 6
# --- Articulations autorisées par la règle B (subordination interne) ---------
ARTICULATION = re.compile(
r"\b(?:parce qu|puisqu?|car|au motif qu|alors qu|tandis qu|si bien qu|"
r"c'est-à-dire|de sorte qu|à mesure qu|dès qu|lorsqu?|quand|sauf qu|"
r"tant qu|au point qu|pour qu|afin qu|bien qu|quoiqu?|même si|comme si|"
r"sans qu|jusqu'à ce qu|d'autant qu|faute d|si\b.{0,40}\balors)"
r"|\b(?:qui|que|qu'|dont|où|lequel|laquelle|auquel|duquel|lesquels|"
r"desquels|auxquels)\b"
r"|[:;]",
re.I)
ARTICULATION_TETE = re.compile(
r"^(?:parce qu|puisqu|car|alors qu|tandis qu|si bien qu|c'est-à-dire|"
r"de sorte qu|à mesure qu|dès qu|lorsqu|quand|tant qu|pour qu|afin qu|"
r"bien qu|quoiqu|même si|comme si|sans qu|jusqu'à ce qu|d'autant qu|"
r"faute d|reste|restait|restent)",
re.I)
# --- Repères mesurés (calibrage : voir 00-COORDINATION.md, section 3 quaterdecies)
SEUILS = {
"pct_orphelines": 12.0, # PLAFOND : part de phrases sans aucun lien amont
"pct_tuilage": 62.0, # PLANCHER : queue de phrase reprise en tête de la suivante
"pct_plates": 30.0, # PLAFOND : phrases longues sans subordination
"pct_coutures": 18.0, # PLAFOND : passages de paragraphe sans gond
}
BLOC_DEFAUT = 650
BLOC_MIN, BLOC_MAX = 500, 800
# --- Outillage lexical -------------------------------------------------------
def sans_accent(mot):
return "".join(c for c in unicodedata.normalize("NFD", mot)
if unicodedata.category(c) != "Mn")
def pleins(phrase):
"""Racines approchées des mots pleins d'une phrase, dans l'ordre."""
sortie = []
for m in mots(phrase):
base = sans_accent(m.lower())
if base in VIDES or len(base) < 3:
continue
sortie.append(base[:5] if len(base) > 5 else base)
return sortie
def corps_seul(brut):
return brut.split("<!-- Notes de bas de page")[0]
def anaphorique(phrase):
"""Une reprise pronominale ou démonstrative dans les six premiers mots."""
for m in mots(phrase)[:FENETRE_ANAPHORE]:
for part in re.split(r"[']", sans_accent(m.lower())):
if part in ANAPHORIQUES:
return True
return False
def articulee(phrase):
return bool(ARTICULATION.search(phrase))
def plate(phrase, longueur):
"""Phrase ample posée à plat : aucune subordination, aucune ponctuation forte."""
return longueur >= 12 and not articulee(phrase)
# --- Découpage en sections puis en paragraphes --------------------------------
def sections(texte):
"""Découpe la prose aux titres : une couture sèche après un `##` est licite."""
blocs, courant = [], []
for ligne in texte.split("\n"):
if ligne.lstrip().startswith("#"):
if courant:
blocs.append("\n".join(courant))
courant = []
continue
courant.append(ligne)
if courant:
blocs.append("\n".join(courant))
return [paragraphes(b) for b in blocs if paragraphes(b)]
def ligne_de(lignes, extrait):
cle = re.sub(r"\s+", " ", extrait).strip()[:28]
return next((i + 1 for i, l in enumerate(lignes) if cle and cle in l), 0)
# --- Analyse -----------------------------------------------------------------
def analyser(chemin, taille_bloc=BLOC_DEFAUT):
with open(chemin, encoding="utf-8") as f:
brut = f.read()
lignes = brut.split("\n")
texte = nettoyer(corps_seul(brut))
orphelines, plates_l, coutures = [], [], []
tuilees = liaisons = 0 # tuilage strict : numérateur / dénominateur
n_phrases = n_amples = 0
n_coutures = 0
blocs = []
bloc = None
def ouvrir():
return {"mots": 0, "phrases": 0, "orphelines": 0, "amples": 0,
"plates": 0, "tuilees": 0, "liaisons": 0, "coutures": 0,
"seches": 0, "longueurs": [], "ligne": 0, "para": 0}
for paras in sections(texte):
if bloc is not None: # un titre ferme toujours le bloc courant
blocs.append(bloc)
bloc = None
fin_precedente = None # queue du paragraphe précédent
for para in paras:
ph = [p for p in phrases(para) if mots(p)]
if not ph:
continue
if bloc is None:
bloc = ouvrir()
bloc["ligne"] = ligne_de(lignes, para)
bloc["para"] += 1
# --- couture avec le paragraphe précédent (règle D) ---
if fin_precedente is not None:
n_coutures += 1
bloc["coutures"] += 1
amont = set(fin_precedente)
if not (amont & set(pleins(ph[0]))) and not anaphorique(ph[0]):
bloc["seches"] += 1
coutures.append((ligne_de(lignes, para), ph[0]))
precedente = None
for p in ph:
longueur = len(mots(p))
n_phrases += 1
bloc["phrases"] += 1
bloc["mots"] += longueur
bloc["longueurs"].append(longueur)
if longueur >= 12:
n_amples += 1
bloc["amples"] += 1
if plate(p, longueur):
bloc["plates"] += 1
plates_l.append((ligne_de(lignes, para), p))
if precedente is not None:
liaisons += 1
bloc["liaisons"] += 1
amont = pleins(precedente)
aval = pleins(p)
ana = anaphorique(p)
art = bool(ARTICULATION_TETE.match(p.lstrip(\"' ")))
lexical = bool(set(amont) & set(aval))
if not (lexical or ana or art):
bloc["orphelines"] += 1
orphelines.append((ligne_de(lignes, para), p))
# tuilage strict : la queue amorce la tête
if ana or (set(amont[-6:]) & set(aval[:5])):
tuilees += 1
bloc["tuilees"] += 1
precedente = p
fin_precedente = pleins(" ".join(ph[-2:]))
if bloc["mots"] >= taille_bloc:
blocs.append(bloc)
bloc = None
if bloc is not None:
blocs.append(bloc)
if not n_phrases:
return None
# fusion d'un dernier bloc résiduel trop court avec son voisin
if len(blocs) > 1 and blocs[-1]["mots"] < BLOC_MIN // 2:
queue = blocs.pop()
for cle in ("mots", "phrases", "orphelines", "amples", "plates",
"tuilees", "liaisons", "coutures", "seches", "para"):
blocs[-1][cle] += queue[cle]
blocs[-1]["longueurs"].extend(queue["longueurs"])
for b in blocs:
b["pct_orphelines"] = 100 * b["orphelines"] / b["liaisons"] if b["liaisons"] else 0
b["pct_tuilage"] = 100 * b["tuilees"] / b["liaisons"] if b["liaisons"] else 0
b["pct_plates"] = 100 * b["plates"] / b["amples"] if b["amples"] else 0
b["pct_seches"] = 100 * b["seches"] / b["coutures"] if b["coutures"] else 0
moy = statistics.mean(b["longueurs"]) if b["longueurs"] else 0
ec = statistics.pstdev(b["longueurs"]) if len(b["longueurs"]) > 1 else 0
b["cv"] = ec / moy if moy else 0
total_plates = sum(b["plates"] for b in blocs)
total_orph = len(orphelines)
total_seches = sum(b["seches"] for b in blocs)
return {
"fichier": os.path.basename(chemin),
"phrases": n_phrases,
"liaisons": liaisons,
"orphelines": orphelines,
"pct_orphelines": 100 * total_orph / liaisons if liaisons else 0,
"tuilees": tuilees,
"pct_tuilage": 100 * tuilees / liaisons if liaisons else 0,
"amples": n_amples,
"plates": plates_l,
"pct_plates": 100 * total_plates / n_amples if n_amples else 0,
"coutures": n_coutures,
"seches": coutures,
"pct_seches": 100 * total_seches / n_coutures if n_coutures else 0,
"blocs": blocs,
"texte": texte,
"lignes": lignes,
}
def verdict(r):
a = []
if r["pct_orphelines"] > SEUILS["pct_orphelines"]:
a.append("phrases juxtaposées sans lien amont")
if r["pct_tuilage"] < SEUILS["pct_tuilage"]:
a.append("tuilage insuffisant (la queue de phrase n'amorce pas la suivante)")
if r["pct_plates"] > SEUILS["pct_plates"]:
a.append("phrases amples posées à plat (subordination absente)")
if r["pct_seches"] > SEUILS["pct_coutures"]:
a.append("coutures de paragraphe sans gond")
return a
# --- Restitution -------------------------------------------------------------
def afficher(r, detail=False):
print("=" * 78)
print(r["fichier"])
print("=" * 78)
print(" Phrases analysées / liaisons internes : %d / %d"
% (r["phrases"], r["liaisons"]))
print(" PHRASES ORPHELINES : %-4d (%.1f %%) (plafond %.0f %%)"
% (len(r["orphelines"]), r["pct_orphelines"], SEUILS["pct_orphelines"]))
print(" TUILAGE STRICT : %-4d (%.1f %%) (plancher %.0f %%)"
% (r["tuilees"], r["pct_tuilage"], SEUILS["pct_tuilage"]))
print(" PHRASES PLATES (≥12 mots, sans subordination) : %-4d (%.1f %% des amples)"
" (plafond %.0f %%)"
% (len(r["plates"]), r["pct_plates"], SEUILS["pct_plates"]))
print(" COUTURES SÈCHES : %-4d sur %d passages (%.1f %%) (plafond %.0f %%)"
% (len(r["seches"]), r["coutures"], r["pct_seches"], SEUILS["pct_coutures"]))
print("\n Blocs de rédaction (%d à %d mots) — la file de travail :"
% (BLOC_MIN, BLOC_MAX))
print(" %-4s%-7s%-7s%-8s%-9s%-9s%-8s%s"
% ("n°", "ligne", "mots", "orph.", "tuilage", "plates", "CV", "état"))
for i, b in enumerate(r["blocs"], 1):
alertes = []
if b["pct_orphelines"] > SEUILS["pct_orphelines"]:
alertes.append("orph")
if b["pct_tuilage"] < SEUILS["pct_tuilage"]:
alertes.append("tuil")
if b["pct_plates"] > SEUILS["pct_plates"]:
alertes.append("plat")
if b["pct_seches"] > SEUILS["pct_coutures"]:
alertes.append("gond")
print(" %-4d%-7d%-7d%-8s%-9s%-9s%-8.2f%s"
% (i, b["ligne"], b["mots"],
"%.0f %%" % b["pct_orphelines"],
"%.0f %%" % b["pct_tuilage"],
"%.0f %%" % b["pct_plates"],
b["cv"],
" ".join(alertes) if alertes else "ok"))
pires = sorted(r["blocs"], key=lambda b: (b["pct_tuilage"] - b["pct_orphelines"]
- b["pct_plates"] / 2))[:5]
faibles = [b for b in pires if b["pct_tuilage"] < SEUILS["pct_tuilage"]
or b["pct_orphelines"] > SEUILS["pct_orphelines"]
or b["pct_plates"] > SEUILS["pct_plates"]]
if faibles:
print("\n À reprendre en priorité : "
+ ", ".join("bloc %d (l.%d)" % (r["blocs"].index(b) + 1, b["ligne"])
for b in faibles))
if detail:
if r["orphelines"]:
print("\n — Phrases orphelines (aucune reprise, aucune anaphore,"
" aucune articulation) :")
for ligne, p in r["orphelines"]:
print(" l.%-5d %s" % (ligne, re.sub(r"\s+", " ", p)[:150]))
if r["seches"]:
print("\n — Coutures sèches (règle D : poser un gond) :")
for ligne, p in r["seches"]:
print(" l.%-5d %s" % (ligne, re.sub(r"\s+", " ", p)[:150]))
if r["plates"]:
print("\n — Phrases amples sans subordination (règle B) :")
for ligne, p in r["plates"][:40]:
print(" l.%-5d %s" % (ligne, re.sub(r"\s+", " ", p)[:150]))
a = verdict(r)
print("\n → VERDICT : " + ("; ".join(a) if a else
"articulation conforme aux règles B, C et D"))
return not a
def afficher_fil(r):
"""Contrôle de la règle C : les premières phrases de paragraphe, à la file."""
print("=" * 78)
print("%s — LE FIL (premières phrases de paragraphe)" % r["fichier"])
print("=" * 78)
print(" Lues à la suite, elles doivent raconter le mouvement de la section.")
print(" Un trou dans ce récit est un panneau indicateur manquant.\n")
for paras in sections(r["texte"]):
for para in paras:
ph = phrases(para)
if not ph:
continue
print(" %s" % re.sub(r"\s+", " ", ph[0])[:150])
print()
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
detail = "--detail" in sys.argv
fil = "--fil" in sys.argv
taille = BLOC_DEFAUT
for a in sys.argv[1:]:
if a.startswith("--bloc"):
reste = a.split("=")[-1]
if reste.isdigit():
taille = int(reste)
if "--bloc" in sys.argv:
i = sys.argv.index("--bloc")
if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit():
taille = int(sys.argv[i + 1])
args = [a for a in args if a != sys.argv[i + 1]]
if args:
cibles = []
for a in args:
cibles.extend(sorted(glob.glob(a)) or [a])
else:
cibles = [f for f in MANUSCRIT
if os.path.exists(f) and os.path.getsize(f) > 1500]
tous_ok, resultats = True, []
for c in cibles:
if not os.path.exists(c):
print("[introuvable] %s" % c)
continue
r = analyser(c, taille)
if not r:
continue
resultats.append(r)
if fil:
afficher_fil(r)
else:
tous_ok &= afficher(r, detail)
if len(resultats) > 1 and not fil:
print("\n" + "=" * 78)
print("SYNTHÈSE")
print("=" * 78)
print("%-40s%9s%9s%9s%9s" % ("Fichier", "%orph", "%tuil", "%plates", "%sèches"))
for r in resultats:
print("%-40s%8.1f%%%8.1f%%%8.1f%%%8.1f%%"
% (r["fichier"], r["pct_orphelines"], r["pct_tuilage"],
r["pct_plates"], r["pct_seches"]))
print("\nRepères : orphelines ≤ %.0f %%, tuilage ≥ %.0f %%,"
" plates ≤ %.0f %%, coutures sèches ≤ %.0f %%"
% (SEUILS["pct_orphelines"], SEUILS["pct_tuilage"],
SEUILS["pct_plates"], SEUILS["pct_coutures"]))
sys.exit(0 if tous_ok else 1)
if __name__ == "__main__":
main()