spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32r"""Traçabilité du glossaire (annexe A).341. Extrait de tous les chapitres rédigés (`manuscript/chapters/chNN.tex`, sans `\stubchapter`) les termes5 marqués `\motcle{…}`, `\terme{fr}{en}`, les titres d'environnements `definition` et les sigles définis6 entre parenthèses ; les normalise (macros retirées, initiale en minuscule sauf sigle ou nom propre) et7 écrit `verification/glossaire-termes.csv` (terme, type, chapitres d'occurrence, nombre d'occurrences8 dans la prose de tous les chapitres).92. Lit `manuscript/appendices/glossaire.tex` et écrit le bilan `verification/chapters/appA.md`10 (nombre d'entrées, termes sans équivalent anglais établi, chapitres couverts par les renvois,11 termes extraits absents du glossaire).1213Usage : python3 tools/glossaire_extraction.py (depuis la racine du dépôt ; aucune dépendance externe)14"""15from __future__ import annotations16import csv, datetime, json, pathlib, re, unicodedata17from collections import defaultdict1819ROOT = pathlib.Path(__file__).resolve().parents[1]20CHAP = ROOT / "manuscript/chapters"21GLOS = ROOT / "manuscript/appendices/glossaire.tex"22OUT_CSV = ROOT / "verification/glossaire-termes.csv"23OUT_MD = ROOT / "verification/chapters/appA.md"24TITRES = json.load(open(CHAP / "TITRES.json", encoding="utf-8"))2526RE_MOTCLE = re.compile(r"\\motcle\{((?:[^{}]|\{[^{}]*\})*)\}")27RE_TERME = re.compile(r"\\terme\{((?:[^{}]|\{[^{}]*\})*)\}\{((?:[^{}]|\{[^{}]*\})*)\}")28RE_DEF = re.compile(r"\\begin\{definition\}\{((?:[^{}]|\{[^{}]*\})*)\}")29# sigle défini : « revenu net d'exploitation (RNE, \emph{net operating income}) » ou « (RNE) » ; 2 à 6 capitales30RE_SIGLE = re.compile(r"\(([A-ZÉ][A-ZÉ&/]{1,6})(?:[,;)]|\s*\\emph)")31SIGLES_EXCLUS = {"II", "III", "IV", "VI", "VII", "VIII", "IX", "XI", "XII", "XX", "HC", "OLS", "ISO", "PDF", "CSV", "URL",32 "USD", "CAD", "API", "SQL", "ID", "QC", "ON", "BC", "AB", "MB", "SK", "NB", "NS", "PE", "NL", "YT", "NT", "NU",33 "US", "UK", "EU", "SE", "NE", "SO", "NO", "ES", "EN", "FR", "MA", "AR", "ARMA", "ADF", "KPSS", "AIC", "BIC",34 "MSE", "RMSE", "MAE", "MAPE", "COD", "PRD", "PRB", "IQR", "TM", "RLRQ", "LRC", "LQ"}35PROPRES = ("Québec", "Montréal", "Gatineau", "Ottawa", "Canada", "Laval", "Monte", "Bayes", "Gauss", "Markov", "Moran", "Geary",36 "Getis", "Pareto", "Nash", "Ricardo", "Alonso", "Muth", "Mills", "Rosen", "Lancaster", "Hotelling", "Duan", "Halvorsen",37 "Kennedy", "Case", "Shiller", "Bailey", "Tobit", "Heckman", "Hausman", "Durbin", "Watson", "Shapley", "LASSO", "SHAP",38 "Ellwood", "Akerson", "Inwood", "Hoskold", "Gordon", "Fisher", "Laspeyres", "Paasche", "Törnqvist", "Marshall", "Swift",39 "RSMeans", "Excel", "Python", "R", "Code", "Loi", "Règlement", "Tribunal", "Ordre", "Régie", "Société", "Statistique",40 "Banque", "Teranet", "Centris", "JLR", "OEAQ", "SCHL", "TAL", "IVS", "CUSPAP", "USPAP", "IAAO", "RICS", "AIC", "BSIF",41 "SIG", "ArcGIS", "QGIS", "PostGIS", "OpenStreetMap", "Geocoder", "Lidar", "LiDAR")4243def strip_tex(s: str) -> str:44 s = re.sub(r"\\(emph|textit|textbf|texttt|text|mbox)\{([^{}]*)\}", r"\2", s)45 s = re.sub(r"\\(?:cref|Cref|ref|label|index|parencite|textcite|cite)\{[^{}]*\}", "", s)46 s = re.sub(r"\\[a-zA-Z]+\*?(\[[^\]]*\])?", "", s)47 s = s.replace("~", " ").replace("{", "").replace("}", "").replace("$", "")48 s = re.sub(r"\s+", " ", s).strip(" .;:")49 return s5051def normalise(s: str) -> str:52 s = strip_tex(s)53 if not s: return s54 first = s.split()[0]55 if first.isupper() and len(first) <= 6: # sigle56 return s57 w = re.split(r"[^\wÀ-ÿ]", first)[0]58 if w in PROPRES or w.rstrip("s") in PROPRES: # nom propre59 return s60 return s[0].lower() + s[1:]6162def sort_key(s: str) -> str:63 s = unicodedata.normalize("NFD", s)64 s = "".join(c for c in s if unicodedata.category(c) != "Mn")65 return s.lower().replace("’", "'").replace("œ", "oe")6667def chapters() -> dict[str, str]:68 res = {}69 for f in sorted(CHAP.glob("ch[0-9][0-9].tex")):70 txt = f.read_text(encoding="utf-8")71 if "\\stubchapter" in txt: continue72 res[f.stem[2:]] = txt73 return res7475def main() -> None:76 chs = chapters()77 plain = {n: strip_tex(re.sub(r"%.*", "", t)) for n, t in chs.items()}78 occ = defaultdict(lambda: {"type": set(), "chapitres": set()})79 for n, txt in chs.items():80 txt = re.sub(r"(?m)%.*$", "", txt)81 for m in RE_MOTCLE.finditer(txt):82 t = normalise(m.group(1))83 if t: occ[t]["type"].add("motcle"); occ[t]["chapitres"].add(n)84 for m in RE_TERME.finditer(txt):85 t = normalise(m.group(1))86 if t: occ[t]["type"].add("terme"); occ[t]["chapitres"].add(n); occ[t].setdefault("en", strip_tex(m.group(2)))87 for m in RE_DEF.finditer(txt):88 t = normalise(m.group(1))89 if t: occ[t]["type"].add("definition"); occ[t]["chapitres"].add(n)90 for m in RE_SIGLE.finditer(txt):91 s = m.group(1)92 if s in SIGLES_EXCLUS or len(s) < 2: continue93 occ[s]["type"].add("sigle"); occ[s]["chapitres"].add(n)94 rows = []95 for t, info in occ.items():96 pat = re.compile(r"(?<![\wÀ-ÿ])" + re.escape(t) + r"(?![\wÀ-ÿ])", re.I)97 counts = {n: len(pat.findall(p)) for n, p in plain.items()}98 total = sum(counts.values())99 where = sorted(set(info["chapitres"]) | {n for n, c in counts.items() if c})100 rows.append({"terme": t, "type": "+".join(sorted(info["type"])), "equivalent_en": info.get("en", ""),101 "chapitres_definition": " ".join(sorted(info["chapitres"])), "chapitres_occurrence": " ".join(where),102 "occurrences": total})103 rows.sort(key=lambda r: sort_key(r["terme"]))104 OUT_CSV.parent.mkdir(parents=True, exist_ok=True)105 with open(OUT_CSV, "w", newline="", encoding="utf-8") as f:106 w = csv.DictWriter(f, fieldnames=list(rows[0].keys())); w.writeheader(); w.writerows(rows)107 print(f"{len(rows)} termes extraits de {len(chs)} chapitres -> {OUT_CSV.relative_to(ROOT)}")108109 # ---- bilan du glossaire rédigé ------------------------------------------------------------110 if not GLOS.exists() or "\\stubchapter" in GLOS.read_text(encoding="utf-8"):111 print("glossaire.tex : stub, pas de bilan"); return112 g = GLOS.read_text(encoding="utf-8")113 entries = re.findall(r"\\gloentree\{((?:[^{}]|\{[^{}]*\})*)\}\{((?:[^{}]|\{[^{}]*\})*)\}\{((?:[^{}]|\{[^{}]*\})*)\}\{((?:[^{}]|\{[^{}]*\})*)\}", g)114 sans_en = [strip_tex(e[0]) for e in entries if strip_tex(e[1]) in ("", "—", "---", "--")]115 refs = defaultdict(int)116 for e in entries:117 for m in re.finditer(r"ch:(\d\d)", e[3]): refs[m.group(1)] += 1118 non_couverts = sorted(set(f"{int(k):02d}" for k in TITRES) - set(refs))119 labels = [sort_key(strip_tex(e[0])) for e in entries]120 desordre = [entries[i][0] for i in range(1, len(labels)) if labels[i] < labels[i - 1]]121 gl_plain = sort_key(strip_tex(g))122 absents = [r["terme"] for r in rows if r["type"] != "sigle" and sort_key(r["terme"]) not in gl_plain]123 absents_sigles = [r["terme"] for r in rows if r["type"] == "sigle" and not re.search(r"(?<![A-Z])" + re.escape(r["terme"]) + r"(?![A-Z])", strip_tex(g))]124 mots = len(re.findall(r"[\wÀ-ÿ'’-]+", strip_tex(g)))125 today = datetime.date.today().isoformat()126 lines = [f"# Annexe A — Glossaire français–anglais — bilan de rédaction ({today})", "",127 "## Livrable",128 f"- `manuscript/appendices/glossaire.tex` : **{len(entries)} entrées** (`\\gloentree`) en `longtable` à trois colonnes "129 f"(terme français, équivalent anglais, définition et renvoi), triées par ordre alphabétique français sans égard aux accents ; "130 f"≈ {mots} mots. Note liminaire sur l'usage des sigles français/anglais (RNE/NOI, RCD/DSCR, RPV/LTV, TRI/IRR, VAN/NPV, "131 "RBE/EGI, RBP/PGI, PHMU/HBU, MRB/GIM, TGA/overall cap rate). Aucune citation bibliographique : les définitions renvoient aux chapitres.",132 f"- `tools/glossaire_extraction.py` → `verification/glossaire-termes.csv` : {len(rows)} termes extraits "133 f"({sum(1 for r in rows if 'motcle' in r['type'])} `\\motcle`, {sum(1 for r in rows if 'terme' in r['type'])} `\\terme`, "134 f"{sum(1 for r in rows if 'definition' in r['type'])} titres de `definition`, {sum(1 for r in rows if r['type']=='sigle')} sigles) "135 f"dans {len(chs)} chapitres rédigés (stubs exclus : {', '.join(sorted(set(f'{int(k):02d}' for k in TITRES) - set(chs))) or 'aucun'}).", "",136 "## Équivalents anglais non établis (marqués « — »)",137 *( [f"- {t}" for t in sans_en] or ["- aucun"] ), "",138 "## Chapitres couverts par les renvois",139 f"- {len(refs)} chapitres sur 48 reçoivent au moins un renvoi `\\cref{{ch:NN}}` ; renvois par chapitre : " +140 ", ".join(f"ch. {k} ({v})" for k, v in sorted(refs.items())) + ".",141 f"- Chapitres sans renvoi : {', '.join('ch. ' + c for c in non_couverts) or 'aucun'}.", "",142 "## Contrôles automatiques",143 f"- Ordre alphabétique (clé sans accents) : {'respecté' if not desordre else 'ANOMALIES : ' + '; '.join(desordre)}.",144 f"- Termes extraits (`\\motcle`/`\\terme`/`definition`) dont le libellé exact n'apparaît pas dans le glossaire : {len(absents)} "145 "(souvent couverts sous un libellé voisin ; liste dans `verification/glossaire-termes.csv`, colonne `type`) :",146 " " + "; ".join(absents) if absents else " aucun",147 f"- Sigles extraits absents du glossaire : {', '.join(absents_sigles) or 'aucun'}.", ""]148 OUT_MD.write_text("\n".join(lines), encoding="utf-8")149 print(f"bilan -> {OUT_MD.relative_to(ROOT)} ({len(entries)} entrées, {len(sans_en)} sans équivalent EN, {len(refs)} chapitres renvoyés)")150151if __name__ == "__main__":152 main()153