Vérification de la totalité des notes du chapitre 5 et resserrage de l'appareil critique, en quatre passes. Vingt-deux écarts de fond corrigés, dont : - le format WET de Common Crawl conserve l'URL, contrairement à ce qu'affirmait le texte ; la perte de provenance porte sur les métadonnées de l'en-tête HTML, routées vers le WAT - un taux de duplication de 90 % sans origine, remplacé par la mesure publiée (14 à 52 %, CopyCat, SIGIR 2021) - une citation en bloc de Ben Zhao dont deux phrases étaient fabriquées - l'exemple canonique de Nightshade, inventé et inversé (les voitures deviennent des vaches, non le contraire) - l'exception britannique de fouille existe (art. 29A CDPA 1988), mais elle est limitée aux usages non commerciaux - la chronologie de la négociation sur les journaux ChatGPT était inversée : les 20 millions sont une offre d'OpenAI, non une concession arrachée - Weinberg présenté comme allié des créateurs alors qu'il défend l'usage loyal Retraits pour insuffisance de source : citation de Bill Gross et paraphrase d'Aaron Rubin (Digiday inaccessible), 15 000 images Getty (étude ni nommée ni datée), taxe de 6 % du syndicat britannique, qualification d'« abus » par OpenAI (agrégateurs seuls), reculs de trafic par titre, ratios de volume entre formats WARC, WAT et WET. Appareil critique : 55 notes ramenées à 33, soit une note tous les 380 mots au lieu de 221, en alignement sur les chapitres 1 à 4. Fusion par bloc démonstratif, citations verbatim reportées au registre. Correction d'un appel dupliqué que Pandoc aurait imprimé deux fois. Quatre notes reposent désormais sur une pièce judiciaire primaire lue dans son texte original : approbation définitive du règlement Anthropic (3:24-cv-05417, pièce 680), mémoire d'objections d'OpenAI sur les journaux de conversations (1:25-md-03143, pièce 935), plainte consolidée (pièce 183), et ordonnance Riganian citant Andersen (4:25-cv-00824, pièce 117). Plus aucune note ne porte de réserve d'accès. Contrôles : compter-mots, verifier-litteralite et detecteur-ia passés, 206 pages sur 20/20 sections.
468 lines
22 KiB
Python
468 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
detecteur-ia.py — Diagnostic de « signature IA » sur les fichiers du manuscrit.
|
||
|
||
Ne juge pas le fond : mesure trois symptômes stylométriques qui font tomber un
|
||
texte dans les détecteurs automatiques (GPTZero, Originality, Compilatio IA...).
|
||
|
||
1. BURSTINESS (asymétrie rythmique)
|
||
Un texte humain alterne brutalement les longueurs de phrase. Un texte de LLM
|
||
converge vers une longueur moyenne stable. On mesure l'écart-type des
|
||
longueurs de phrase, la part de phrases courtes (<= 5 mots), et surtout les
|
||
« plateaux » : suites de phrases de longueur quasi identique (+/- 2 mots).
|
||
|
||
ATTENTION — la part de phrases courtes est un plancher À DEUX FACES, et
|
||
c'est l'erreur qui a coûté le plus cher au manuscrit (constat du 30/07/2026,
|
||
sur l'Introduction). Une phrase brève LOGÉE DANS un paragraphe ample donne
|
||
du rythme ; la même phrase ISOLÉE EN PARAGRAPHE d'une ligne donne du
|
||
hachage. Les deux satisfont le seuil « >= 12 % », et pourtant la seconde
|
||
détruit la lecture au lieu de la porter. Le script distingue donc désormais
|
||
les phrases courtes INTERNES (celles qui comptent, plancher 11 %) des
|
||
phrases courtes ISOLÉES, et il pose un PLAFOND sur les paragraphes-couperets
|
||
(<= 10 % des paragraphes). Voir 00-COORDINATION.md, section 3 duodecies.
|
||
|
||
2. PERPLEXITÉ LEXICALE (tics de langage probables)
|
||
Liste noire de formules et métaphores sur-représentées dans la prose des
|
||
modèles de langue, plus les charnières logiques scolaires en tête de phrase.
|
||
|
||
3. CHARNIÈRES ARTIFICIELLES
|
||
Paragraphes ouverts par un connecteur logique ; paragraphes isolés d'une
|
||
seule phrase (indicateur inverse, à maximiser).
|
||
|
||
4. MARQUEURS DE FABRICATION
|
||
Les signatures les plus lourdes ne sont pas lexicales mais syntaxiques :
|
||
densité de tirets cadratins (l'incise en tiret est LE tic d'écriture des
|
||
modèles), antithèses « ce n'est pas X, c'est Y », tricolons (A, B et C),
|
||
et régularité de la longueur des paragraphes.
|
||
|
||
Les fichiers du manuscrit vivent dans Manuscrit/ (isolé des notes de
|
||
recherche, du registre des sources et des rapports d'audit — voir
|
||
00-COORDINATION.md, section 11).
|
||
|
||
Usage :
|
||
python3 detecteur-ia.py # tous les fichiers du manuscrit
|
||
python3 detecteur-ia.py Manuscrit/01-chap01-*.md # un ou plusieurs fichiers
|
||
python3 detecteur-ia.py --verbose Manuscrit/00-introduction.md # + n° de ligne de chaque tic
|
||
"""
|
||
|
||
import glob
|
||
import os
|
||
import re
|
||
import statistics
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# --- Fichiers du manuscrit (même périmètre que compter-mots.py) ---------------
|
||
|
||
MANUSCRIT_DIR = Path(__file__).resolve().parent / "Manuscrit"
|
||
|
||
# Périmètre mis à jour le 03/08/2026. Cette liste contenait encore
|
||
# "preface.md" et les quatre ouvertures de partie "00b-/03b-/06b-/09b-", que
|
||
# compiler-livre.sh n'a jamais compilées : le filtre exists() de la ligne 433
|
||
# les écartait en silence, si bien que l'écart n'a pas été vu. La préface est
|
||
# supprimée (son fichier est devenu Manuscrit/table-des-matieres.md, qui ne
|
||
# porte aucune prose et n'a donc rien à faire ici) et les quatre ouvertures de
|
||
# partie sont archivées ; les séparateurs "partieN-*.md" réellement compilés ne
|
||
# contiennent qu'un bloc LaTeX, aucune phrase à mesurer.
|
||
MANUSCRIT = [
|
||
str(MANUSCRIT_DIR / "00-introduction.md"),
|
||
str(MANUSCRIT_DIR / "01-chap01-mort-de-la-verite.md"),
|
||
str(MANUSCRIT_DIR / "02-chap02-neuvieme-nom.md"),
|
||
str(MANUSCRIT_DIR / "03-chap03-anesthesie-cognitive.md"),
|
||
str(MANUSCRIT_DIR / "04-chap04-plan-social-invisible.md"),
|
||
str(MANUSCRIT_DIR / "05-chap05-holdup-sur-la-creation.md"),
|
||
str(MANUSCRIT_DIR / "06-chap06-nouveaux-maitres-du-monde.md"),
|
||
str(MANUSCRIT_DIR / "07-chap07-guerre-automatisee.md"),
|
||
str(MANUSCRIT_DIR / "08-chap08-mur-de-lagi.md"),
|
||
str(MANUSCRIT_DIR / "09-chap09-probleme-de-lalignement.md"),
|
||
str(MANUSCRIT_DIR / "10-chap10-loi-contre-la-machine.md"),
|
||
str(MANUSCRIT_DIR / "11-chap11-filtre-ethique.md"),
|
||
str(MANUSCRIT_DIR / "12-chap12-cultiver-lirremplacable.md"),
|
||
str(MANUSCRIT_DIR / "13-conclusion.md"),
|
||
str(MANUSCRIT_DIR / "4e-de-couverture.md"),
|
||
]
|
||
|
||
# --- Règle 2 : lexique interdit (tics de LLM) --------------------------------
|
||
# Chaque entrée : (libellé, expression régulière)
|
||
|
||
TICS_LEXICAUX = [
|
||
("tisser une toile / tapisserie", r"\btiss(?:e|er|ant|é|ée)\b[^.]{0,30}\b(?:toile|tapisserie|lien|liens|fil|réseau)\b"),
|
||
("naviguer dans un paysage", r"\bnavigu(?:e|er|ant)\b[^.]{0,25}\b(?:paysage|univers|monde|complexité|méandres)\b"),
|
||
("paysage (métaphorique)", r"\b(?:paysage|écosystème)\s+(?:numérique|technologique|médiatique|informationnel|économique)\b"),
|
||
("en fin de compte", r"\ben fin de compte\b"),
|
||
("il est crucial / essentiel", r"\bil (?:est|devient|serait) (?:crucial|essentiel|impératif|primordial|vital|fondamental|important|nécessaire)\b"),
|
||
("il convient de", r"\bil convient de\b"),
|
||
("il est important de noter", r"\bil (?:est|convient de) (?:important|noter)\b"),
|
||
("force est de constater", r"\bforce est de constater\b"),
|
||
("une symphonie / une danse / un ballet", r"\b(?:une (?:symphonie|danse|valse)|un (?:ballet|orchestre))\b"),
|
||
("repousser les limites / frontières", r"\brepouss(?:e|er|ant|é)\b[^.]{0,20}\b(?:limites|frontières)\b"),
|
||
("à l'ère du numérique", r"\bà l'ère (?:du numérique|de l'information|numérique)\b"),
|
||
("dans un monde en constante évolution", r"\bdans un monde (?:en constante évolution|de plus en plus)\b"),
|
||
("plonger au cœur de", r"\bplong(?:e|er|eons|ez)\b[^.]{0,15}\b(?:au c(?:œ|oe)ur|dans l'univers)\b"),
|
||
("véritable révolution / bouleversement", r"\bvéritable (?:révolution|bouleversement|séisme|raz-de-marée)\b"),
|
||
("à double tranchant", r"\b(?:épée|arme|couteau) à double tranchant\b"),
|
||
("pierre angulaire / clé de voûte", r"\b(?:pierre angulaire|clé de voûte)\b"),
|
||
("terrain fertile / propice", r"\bterreau fertile\b|\bterrain (?:fertile|propice)\b"),
|
||
("ouvrir la voie à", r"\bouvr(?:e|ir|ant)\b[^.]{0,10}\bla voie à\b"),
|
||
("jouer un rôle (crucial/clé)", r"\bjou(?:e|er|ant)\b[^.]{0,15}\brôle (?:crucial|clé|central|majeur|déterminant)\b"),
|
||
("il ne s'agit pas seulement de", r"\bil ne s'agit pas (?:seulement|simplement|uniquement) de\b"),
|
||
("loin d'être anodin", r"\bloin d'être (?:anodin|anecdotique|neutre)\b"),
|
||
("en somme / en définitive", r"\ben (?:somme|définitive)\b"),
|
||
("un défi de taille", r"\bun défi (?:de taille|majeur|colossal)\b"),
|
||
("au cœur de la tourmente", r"\bau c(?:œ|oe)ur de la (?:tourmente|tempête)\b"),
|
||
("changement de paradigme", r"\bchangement de paradigme\b"),
|
||
("nouvelle ère", r"\bune nouvelle ère\b"),
|
||
("miroir de nos sociétés", r"\bmiroir de (?:nos|la) (?:sociétés?|époque)\b"),
|
||
("boîte noire (usé)", r"\bbo(?:î|i)te noire\b"),
|
||
("prise de conscience collective", r"\bprise de conscience collective\b"),
|
||
]
|
||
|
||
# --- Règle 3 : charnières scolaires en tête de phrase ou de paragraphe -------
|
||
|
||
CHARNIERES = [
|
||
"En effet", "Par conséquent", "Néanmoins", "De plus", "En outre",
|
||
"Toutefois", "Cependant", "Ainsi", "Dès lors", "Par ailleurs",
|
||
"En revanche", "D'une part", "D'autre part", "Or,", "Aussi,",
|
||
"En conclusion", "Pour conclure", "En résumé", "Premièrement",
|
||
"Deuxièmement", "Troisièmement", "Enfin,", "Certes", "Autrement dit",
|
||
"En d'autres termes", "De ce fait", "C'est pourquoi", "Il en résulte",
|
||
]
|
||
|
||
CHARNIERE_RE = re.compile(
|
||
r"^(?:\*\*|_|\*)?(" + "|".join(re.escape(c.rstrip(",")) for c in CHARNIERES) + r")\b[,\s]",
|
||
)
|
||
|
||
# --- Nettoyage Markdown ------------------------------------------------------
|
||
|
||
|
||
def nettoyer(texte):
|
||
"""Retire tout ce qui n'est pas de la prose lue (notes, titres, HTML, LaTeX)."""
|
||
# bloc de définitions des notes de bas de page (tout ce qui suit le commentaire)
|
||
texte = re.split(r"<!--\s*Notes de bas de page\s*-->", texte)[0]
|
||
texte = re.sub(r"^\[\^[^\]]+\]:.*(?:\n(?:[ \t]+.*|))*", "", texte, flags=re.M)
|
||
texte = re.sub(r"\[\^[^\]]+\]", "", texte) # appels de notes
|
||
texte = re.sub(r"<!--.*?-->", "", texte, flags=re.S) # commentaires HTML
|
||
# blocs bruts délimités par des clôtures ```…``` (titres en LaTeX de
|
||
# l'introduction, de la conclusion et des pages de partie) : ce n'est pas
|
||
# de la prose, et les lignes de clôture comptaient à tort comme des
|
||
# paragraphes très courts, ce qui faussait le décompte des couperets.
|
||
texte = re.sub(r"(?ms)^```.*?^```[ \t]*$", "", texte)
|
||
texte = re.sub(r"^\s*\\\w+.*$", "", texte, flags=re.M) # commandes LaTeX
|
||
texte = re.sub(r"^---+$", "", texte, flags=re.M)
|
||
return texte
|
||
|
||
|
||
def paragraphes(texte):
|
||
"""Paragraphes de prose : titres et lignes de métadonnées exclus."""
|
||
blocs = []
|
||
for bloc in re.split(r"\n\s*\n", texte):
|
||
bloc = bloc.strip()
|
||
if not bloc or bloc.startswith("#"):
|
||
continue
|
||
if re.match(r"^(?:\||:|\d+\.\s|[-*+]\s)", bloc): # tableaux et listes
|
||
continue
|
||
blocs.append(bloc)
|
||
return blocs
|
||
|
||
|
||
ABREVIATIONS = r"(?<!\bM)(?<!\bMM)(?<!\bMme)(?<!\bDr)(?<!\bSt)(?<!\betc)(?<!\bcf)(?<!\bp\.\sex)(?<!\bJ\.-C)"
|
||
FIN_PHRASE = re.compile(ABREVIATIONS + r"[.!?…]+[\s»\"]+(?=[«\"A-ZÀÂÉÈÊÎÔÙÜÇ0-9])")
|
||
|
||
|
||
def phrases(bloc):
|
||
"""Découpe grossière mais stable en phrases."""
|
||
bloc = re.sub(r"^>\s?", "", bloc, flags=re.M) # citations en bloc
|
||
bloc = re.sub(r"\s+", " ", bloc).strip()
|
||
bloc = re.sub(r"(\d)\.\s(?=\d)", r"\1DOTNUM ", bloc) # 1. 2 -> pas une fin
|
||
morceaux = [p.strip() for p in FIN_PHRASE.split(bloc) if p.strip()]
|
||
return [m.replace("DOTNUM", ".") for m in morceaux]
|
||
|
||
|
||
def mots(phrase):
|
||
return re.findall(r"[0-9]+(?:[.,][0-9]+)*|[A-Za-zÀ-ÖØ-öø-ÿ]+(?:['’-][A-Za-zÀ-ÖØ-öø-ÿ]+)*", phrase)
|
||
|
||
|
||
# --- Analyse -----------------------------------------------------------------
|
||
|
||
|
||
def analyser(chemin, verbose=False):
|
||
with open(chemin, encoding="utf-8") as f:
|
||
brut = f.read()
|
||
|
||
lignes = brut.split("\n")
|
||
texte = nettoyer(brut)
|
||
blocs = paragraphes(texte)
|
||
|
||
longueurs = []
|
||
plateaux = [] # (longueur, nb de phrases consécutives quasi identiques)
|
||
serie, ref = 1, None
|
||
para_mono = 0 # paragraphes sans aucune phrase courte
|
||
para_une_phrase = 0
|
||
couperets = 0 # paragraphes-couperets : <= 12 mots en tout
|
||
courtes_iso = 0 # phrases <= 5 mots seules dans leur paragraphe
|
||
courtes_int = 0 # phrases <= 5 mots logées dans un paragraphe ample
|
||
nb_para = 0 # paragraphes de prose réellement mesurés
|
||
|
||
for bloc in blocs:
|
||
ph = phrases(bloc)
|
||
if not ph:
|
||
continue
|
||
lp = [len(mots(p)) for p in ph]
|
||
lp = [n for n in lp if n > 0]
|
||
if not lp:
|
||
continue
|
||
nb_para += 1
|
||
if len(lp) == 1:
|
||
para_une_phrase += 1
|
||
if sum(lp) <= 12:
|
||
couperets += 1
|
||
if len(lp) >= 3 and min(lp) > 8:
|
||
para_mono += 1
|
||
for n in lp:
|
||
if n <= 5:
|
||
if len(lp) == 1:
|
||
courtes_iso += 1
|
||
else:
|
||
courtes_int += 1
|
||
longueurs.extend(lp)
|
||
ref, serie = None, 1
|
||
for n in lp:
|
||
if ref is not None and abs(n - ref) <= 2:
|
||
serie += 1
|
||
else:
|
||
if serie >= 3 and ref is not None:
|
||
plateaux.append((ref, serie))
|
||
serie = 1
|
||
ref = n
|
||
if serie >= 3 and ref is not None:
|
||
plateaux.append((ref, serie))
|
||
|
||
if not longueurs:
|
||
return None
|
||
|
||
# --- Règle 4 : marqueurs de fabrication ---------------------------------
|
||
total_mots = sum(longueurs)
|
||
par_mille = (1000.0 / total_mots) if total_mots else 0
|
||
|
||
tirets = len(re.findall(r"—", texte))
|
||
pv = len(re.findall(r";", texte))
|
||
antitheses = []
|
||
for bloc in blocs:
|
||
for p in phrases(bloc):
|
||
if re.search(r"\bn(?:e |')(?:est|sont|s'agi\w+|était|étaient|vient|viennent|tient|tiennent)\s+pas\b", p, re.I) \
|
||
and re.search(r"(?:,\s*c'est|\bc'est\b|\bil est\b|\bmais\b|\bil l'est\b)", p, re.I):
|
||
antitheses.append(re.sub(r"\s+", " ", p)[:90])
|
||
tricolons = 0
|
||
for bloc in blocs:
|
||
for p in phrases(bloc):
|
||
if re.match(r"^[^,;:]{3,60},[^,;:]{3,60},[^,;:]{3,60}\set\s", p):
|
||
tricolons += 1
|
||
|
||
long_para = [len(mots(b)) for b in blocs]
|
||
long_para = [n for n in long_para if n > 0]
|
||
cv_para = (statistics.pstdev(long_para) / statistics.mean(long_para)) if len(long_para) > 1 else 0
|
||
|
||
moyenne = statistics.mean(longueurs)
|
||
ecart = statistics.pstdev(longueurs) if len(longueurs) > 1 else 0.0
|
||
courtes = sum(1 for n in longueurs if n <= 5)
|
||
longues = sum(1 for n in longueurs if n >= 30)
|
||
|
||
# tics lexicaux
|
||
tics = []
|
||
for libelle, motif in TICS_LEXICAUX:
|
||
for m in re.finditer(motif, texte, flags=re.I):
|
||
extrait = re.sub(r"\s+", " ", m.group(0))
|
||
ligne = next((i + 1 for i, l in enumerate(lignes) if extrait[:25] in l), 0)
|
||
tics.append((libelle, extrait, ligne))
|
||
|
||
# charnières en tête de paragraphe et de phrase
|
||
char_para, char_phrase = [], []
|
||
for bloc in blocs:
|
||
premiere = bloc.lstrip("*_> ")
|
||
m = CHARNIERE_RE.match(premiere)
|
||
if m:
|
||
ligne = next((i + 1 for i, l in enumerate(lignes) if premiere[:30] in l), 0)
|
||
char_para.append((m.group(1), ligne))
|
||
for p in phrases(bloc)[1:]:
|
||
m2 = CHARNIERE_RE.match(p.lstrip("*_ "))
|
||
if m2:
|
||
ligne = next((i + 1 for i, l in enumerate(lignes) if p[:30] in l), 0)
|
||
char_phrase.append((m2.group(1), ligne))
|
||
|
||
return {
|
||
"fichier": os.path.basename(chemin),
|
||
"phrases": len(longueurs),
|
||
"moyenne": moyenne,
|
||
"ecart": ecart,
|
||
"cv": ecart / moyenne if moyenne else 0,
|
||
"courtes": courtes,
|
||
"pct_courtes": 100 * courtes / len(longueurs),
|
||
"courtes_iso": courtes_iso,
|
||
"courtes_int": courtes_int,
|
||
"pct_courtes_int": 100 * courtes_int / len(longueurs),
|
||
"couperets": couperets,
|
||
"pct_couperets": 100 * couperets / nb_para if nb_para else 0,
|
||
"longues": longues,
|
||
"plateaux": plateaux,
|
||
"para": len(blocs),
|
||
"para_mesures": nb_para,
|
||
"para_mono": para_mono,
|
||
"para_une_phrase": para_une_phrase,
|
||
"tics": tics,
|
||
"char_para": char_para,
|
||
"char_phrase": char_phrase,
|
||
"mots": total_mots,
|
||
"tirets": tirets,
|
||
"tirets_1000": tirets * par_mille,
|
||
"pv_1000": pv * par_mille,
|
||
"antitheses": antitheses,
|
||
"tricolons": tricolons,
|
||
"cv_para": cv_para,
|
||
"verbose": verbose,
|
||
}
|
||
|
||
|
||
# --- Restitution -------------------------------------------------------------
|
||
|
||
SEUILS = {
|
||
"cv": 0.55, # coefficient de variation des longueurs de phrase
|
||
"pct_courtes": 12.0, # part de phrases de 5 mots ou moins (toutes) — INFORMATIF, ne conditionne plus le verdict
|
||
"pct_courtes_int": None, # PLANCHER RETIRÉ LE 04/08/2026 PAR ARBITRAGE DE L'AUTEUR (voir ci-dessous)
|
||
"pct_couperets": 10.0, # PLAFOND de paragraphes-couperets (<= 12 mots) — MAINTENU, garde-fou anti-hachage
|
||
"tirets_1000": 4.0, # tirets cadratins pour 1 000 mots
|
||
"antitheses": 6, # « ce n'est pas X, c'est Y » par section
|
||
"cv_para": 0.45, # variation de la longueur des paragraphes
|
||
}
|
||
|
||
|
||
def verdict(r):
|
||
alertes = []
|
||
if r["cv"] < SEUILS["cv"]:
|
||
alertes.append("rythme trop régulier")
|
||
# PLANCHER DE PHRASES BRÈVES LOGÉES : RETIRÉ LE 04/08/2026, ARBITRAGE DE L'AUTEUR.
|
||
# Motif : la réécriture v6 par le dispositif Gemini produit une prose qui tient son
|
||
# rythme par la subordination et par la période plutôt que par la phrase-scansion,
|
||
# et cette prose est voulue. Le déficit mesuré (3 à 8 % contre un plancher de 11 %)
|
||
# n'est donc pas un défaut à corriger mais une caractéristique assumée de la v6.
|
||
# Application directe de l'encadré en tête de la section 3 de 00-COORDINATION.md :
|
||
# quand la mesure et le texte se contredisent, c'est le texte qui a raison, et
|
||
# l'écart se note au lieu de se corriger.
|
||
# LA MESURE RESTE AFFICHÉE, à titre informatif, pour suivre la dérive d'une passe à
|
||
# l'autre. Elle ne produit plus d'alerte. NE PAS LA RÉTABLIR SANS ARBITRAGE.
|
||
# Le garde-fou anti-hachage, lui, est conservé : c'est le plafond de couperets.
|
||
# Symétrique, et c'est le garde-fou anti-hachage (voir section 3 duodecies) :
|
||
# une accumulation de paragraphes d'une ligne casse le fil de lecture.
|
||
if r["para_mesures"] >= 20 and r["pct_couperets"] > SEUILS["pct_couperets"]:
|
||
alertes.append("hachage : trop de paragraphes-couperets")
|
||
if len(r["plateaux"]) > max(3, r["para"] // 12):
|
||
alertes.append("plateaux de longueur")
|
||
if r["tics"]:
|
||
alertes.append("lexique de LLM")
|
||
if len(r["char_para"]) > 2:
|
||
alertes.append("charnières en tête de paragraphe")
|
||
# Les densités pour 1 000 mots n'ont pas de sens sur un texte très court
|
||
# (4e de couverture, page de partie) : un seul tiret y pèse 3 points.
|
||
if r["mots"] >= 800 and r["tirets_1000"] > SEUILS["tirets_1000"]:
|
||
alertes.append("surdose de tirets cadratins")
|
||
if len(r["antitheses"]) > SEUILS["antitheses"]:
|
||
alertes.append("antithèses « pas X, c'est Y » en série")
|
||
if r["cv_para"] < SEUILS["cv_para"]:
|
||
alertes.append("paragraphes trop réguliers")
|
||
return alertes
|
||
|
||
|
||
def afficher(r):
|
||
print(f"\n{'=' * 78}\n{r['fichier']}\n{'=' * 78}")
|
||
print(f" Phrases analysées : {r['phrases']} ({r['para']} paragraphes)")
|
||
print(f" Longueur moyenne : {r['moyenne']:.1f} mots")
|
||
print(f" Écart-type / variation : {r['ecart']:.1f} → CV = {r['cv']:.2f}"
|
||
f" (cible ≥ {SEUILS['cv']:.2f})")
|
||
print(f" Phrases ≤ 5 mots : {r['courtes']} ({r['pct_courtes']:.1f} %)"
|
||
f" (informatif, ancienne cible ≥ {SEUILS['pct_courtes']:.0f} %)")
|
||
print(f" · dont logées dans un paragraphe ample : {r['courtes_int']}"
|
||
f" ({r['pct_courtes_int']:.1f} %) (INFORMATIF — plancher retiré le 04/08/2026)")
|
||
print(f" · dont seules sur leur ligne : {r['courtes_iso']}"
|
||
f" (ne comptent pas comme rythme)")
|
||
print(f" Phrases ≥ 30 mots : {r['longues']}")
|
||
print(f" PARAGRAPHES-COUPERETS (≤ 12 mots) : {r['couperets']}"
|
||
f" ({r['pct_couperets']:.1f} %) (PLAFOND {SEUILS['pct_couperets']:.0f} %,"
|
||
f" repère chapitres 1-4 : 4,5 à 7,6 %)")
|
||
print(f" Paragraphes d'une seule phrase : {r['para_une_phrase']}")
|
||
print(f" Paragraphes sans respiration courte (≥3 phrases, toutes > 8 mots) : {r['para_mono']}")
|
||
|
||
if r["plateaux"]:
|
||
print(f" Plateaux rythmiques (≥3 phrases de longueur ±2) : {len(r['plateaux'])}")
|
||
if r["verbose"]:
|
||
for longueur, n in r["plateaux"][:20]:
|
||
print(f" · {n} phrases autour de {longueur} mots")
|
||
|
||
if r["tics"]:
|
||
print(f" TICS LEXICAUX : {len(r['tics'])}")
|
||
for libelle, extrait, ligne in r["tics"]:
|
||
print(f" · l.{ligne:<5} [{libelle}] « {extrait} »")
|
||
else:
|
||
print(" Tics lexicaux : aucun")
|
||
|
||
if r["char_para"]:
|
||
print(f" CHARNIÈRES EN TÊTE DE PARAGRAPHE : {len(r['char_para'])}")
|
||
for mot, ligne in r["char_para"]:
|
||
print(f" · l.{ligne:<5} « {mot} »")
|
||
if r["char_phrase"]:
|
||
print(f" Charnières en tête de phrase (dans le corps) : {len(r['char_phrase'])}")
|
||
if r["verbose"]:
|
||
for mot, ligne in r["char_phrase"]:
|
||
print(f" · l.{ligne:<5} « {mot} »")
|
||
|
||
court = " [texte trop court : densité non significative]" if r["mots"] < 800 else ""
|
||
print(f" Tirets cadratins : {r['tirets']} ({r['tirets_1000']:.1f} / 1 000 mots"
|
||
f" — plafond {SEUILS['tirets_1000']:.0f}){court}")
|
||
print(f" Points-virgules : {r['pv_1000']:.1f} / 1 000 mots")
|
||
print(f" Antithèses « pas X, c'est Y » : {len(r['antitheses'])}"
|
||
f" (plafond {SEUILS['antitheses']})")
|
||
if r["verbose"]:
|
||
for a in r["antitheses"]:
|
||
print(f" · {a}")
|
||
print(f" Tricolons (A, B et C) : {r['tricolons']}")
|
||
print(f" Variation longueur des paragraphes : CV = {r['cv_para']:.2f}"
|
||
f" (cible ≥ {SEUILS['cv_para']:.2f})")
|
||
|
||
a = verdict(r)
|
||
print(f" → VERDICT : {'; '.join(a) if a else 'conforme aux quatre règles de brouillage'}")
|
||
|
||
|
||
def main():
|
||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||
verbose = "--verbose" in sys.argv or "-v" in sys.argv
|
||
|
||
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]
|
||
|
||
resultats = []
|
||
for c in cibles:
|
||
if not os.path.exists(c):
|
||
print(f"[introuvable] {c}")
|
||
continue
|
||
r = analyser(c, verbose)
|
||
if r:
|
||
resultats.append(r)
|
||
afficher(r)
|
||
|
||
if len(resultats) > 1:
|
||
print(f"\n{'=' * 78}\nSYNTHÈSE\n{'=' * 78}")
|
||
print(f"{'Fichier':<38}{'CV':>6}{'%≤5m':>7}{'%int':>7}{'%coup':>7}"
|
||
f"{'Tics':>6}{'Char':>6}{'—/1000':>9}{'Antith':>8}{'CVpara':>8}")
|
||
for r in resultats:
|
||
print(f"{r['fichier']:<38}{r['cv']:>6.2f}{r['pct_courtes']:>6.1f}%"
|
||
f"{r['pct_courtes_int']:>6.1f}%{r['pct_couperets']:>6.1f}%"
|
||
f"{len(r['tics']):>6}{len(r['char_para']):>6}"
|
||
f"{r['tirets_1000']:>9.1f}{len(r['antitheses']):>8}{r['cv_para']:>8.2f}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|