Test_AI_writing / devtools /fmt_audit.py
unknown
Claude Opus 5.5
Journal Formatter: footnotes, web references, books and reports, author marks, cleaner LaTeX
2eb3609
Raw History Blame Contribute Delete
12.6 kB
"""Tester for the Journal Formatter: formats every test manuscript (devtools/fmt_corpus.py) into several journal
styles and checks each Word / LaTeX output against the source and the journal's rules.
python devtools/fmt_audit.py [--presets ieee_journal,elsevier] [--docs numeric,messy] [--latex]
Prints one line per problem; ends with a summary. --latex also compiles every LaTeX project (texlive.net, test content
only) and reports errors, undefined citations / references and overfull lines."""
import argparse
import io
import re
import subprocess
import sys
import tempfile
import zipfile
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import os
os.environ.setdefault("SKIP_MODEL_LOAD", "1")
sys.stdout.reconfigure(encoding="utf-8")
import docx # noqa: E402
import fmt_corpus as C # noqa: E402
from formatmatch.engine import FormatSource, build_spec, format_manuscript # noqa: E402
PRESETS = ["ieee_journal", "elsevier", "elsevier_harvard", "springer_journal", "mdpi", "emerald", "plos", "wiley"]
PAGE = {"A4": (21.0, 29.7), "Letter": (21.59, 27.94), "letter": (21.59, 27.94)}
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def norm(t):
t = re.sub(r"\[[\d,\s–\-]+\]|\([A-Z][^()]*?\d{4}[a-z]?\)", " ", t or "") # citations (they are rewritten)
t = t.replace("Fig.", "Figure").replace("–", "-").replace("—", "-")
return re.findall(r"[a-z0-9]+", t.lower())
def source_paragraphs(kind):
"""The body text the output must still contain: abstract, every paragraph / list item, captions, footnote."""
out = [C.ABSTRACT, C.TABLE_CAPTION, C.FIG_CAPTION, "The authors thank the Centre for Energy Storage"]
for k, t in C.BODY:
if k in ("p", "ul", "ol"):
out.append(re.sub(r"\{(c|f|t):[\d,]+\}|\{fn\}", " ", t))
return out
class Audit:
def __init__(self, label):
self.label, self.problems = label, []
def bad(self, what):
self.problems.append(what)
print(f" ✗ [{self.label}] {what}", flush=True)
def check_docx(a, data, spec, kind):
d = docx.Document(io.BytesIO(data))
z = zipfile.ZipFile(io.BytesIO(data))
xml = z.read("word/document.xml").decode("utf-8")
paras = [p.text for p in d.paragraphs]
text = "\n".join(paras + [c.text for t in d.tables for r in t.rows for c in r.cells])
words = Counter(norm(text))
# 1. nothing lost
for src in source_paragraphs(kind):
w = norm(src)
missing = [x for x in set(w) if words[x] == 0 and len(x) > 2]
if len(missing) > max(1, len(set(w)) // 12):
a.bad(f"text lost from “{src[:60]}…”: missing {missing[:6]}")
foot = z.read("word/footnotes.xml").decode("utf-8") if "word/footnotes.xml" in z.namelist() else ""
if "differential scanning calorimetry" not in foot:
a.bad("footnote lost")
if xml.count("<m:oMath") < C.FACTS["equations"]:
a.bad("equation lost")
if len(d.inline_shapes) < C.FACTS["figures"]:
a.bad("figure lost")
if len(d.tables) < C.FACTS["tables"]:
a.bad("table lost")
for row in C.TABLE:
for cell in row:
if not any(cell == c.text.strip() for t in d.tables for r in t.rows for c in r.cells):
a.bad(f"table cell lost: {cell!r}")
break
# 2. references: all there, each with its year, first author and DOI / URL
ref_head = next((i for i, p in enumerate(d.paragraphs) if p.text.strip().lower().rstrip(".:") in
("references", "reference", "bibliography", "literature cited")), None)
refs = [p.text for p in d.paragraphs[ref_head + 1:] if p.text.strip()] if ref_head is not None else []
if len(refs) != C.FACTS["refs"]:
a.bad(f"{len(refs)} references in the output, {C.FACTS['refs']} in the manuscript")
for key, num, ay in C.REFS:
surname = re.split(r"[ ,]", key)[0]
year = re.search(r"\d{4}", key).group()
hit = [r for r in refs if surname in r and year in r]
if not hit:
a.bad(f"reference {key} not found (author + year)")
continue
for m in re.findall(r"10\.\d{4,}/[^\s,;]+|https?://\S+", ay):
core = m.rstrip(".").replace("https://doi.org/", "")
if core not in hit[0]:
a.bad(f"reference {key}: DOI/URL lost ({core[:40]})")
if re.search(r"\bAvailable\b(?!:| (?:at|from|online):)", hit[0], re.I):
a.bad(f"reference {key}: dangling 'Available' ({hit[0][-60:]})")
# 3. citations: right mode, every one resolves, every reference cited
body = "\n".join(paras[:ref_head] if ref_head else paras)
mode = spec["citations"]["mode"]
numeric_cites = re.findall(r"\[(\d+(?:\s*[,–-]\s*\d+)*)\]", body)
ay_cites = re.findall(r"\(([A-Z][A-Za-z\- ]+(?: et al\.)?(?:,| and| &)[^()]*?\d{4}[a-z]?)\)", body)
if mode in ("numeric", "superscript"):
if ay_cites:
a.bad(f"author-year citations left in a numeric journal: {ay_cites[:2]}")
cited = set()
for c in numeric_cites:
for part in re.split(r"\s*,\s*", c):
ab = re.split(r"\s*[–-]\s*", part)
lo, hi = int(ab[0]), int(ab[-1])
cited.update(range(lo, hi + 1))
sup = [r for p in d.paragraphs[:ref_head or len(d.paragraphs)] for r in p.runs
if r.font.superscript and re.fullmatch(r"[\d,–\-]+", r.text.strip() or "x")]
if mode == "superscript" and not sup:
a.bad("superscript citations expected, none found")
if mode == "numeric":
if not numeric_cites:
a.bad("no numeric citations in the text")
if cited and (max(cited) > len(refs) or min(cited) < 1):
a.bad(f"citation number out of range: {sorted(cited)} for {len(refs)} references")
if cited and len(cited) < len(refs):
a.bad(f"references never cited: {sorted(set(range(1, len(refs) + 1)) - cited)}")
order = []
for c in numeric_cites: # every number of a group, in reading order
for part in re.split(r"\s*,\s*", c):
ab = re.split(r"\s*[–-]\s*", part)
order += list(range(int(ab[0]), int(ab[-1]) + 1))
firsts = [x for i, x in enumerate(order) if x not in order[:i]]
if firsts != sorted(firsts) and spec["references"]["style"] in ("ieee", "vancouver", "numeric", "ama"):
a.bad(f"numbers not in order of first citation: {firsts}")
else:
if numeric_cites:
a.bad(f"numeric citations left in an author-year journal: {numeric_cites[:3]}")
if not ay_cites:
a.bad("no author-year citations in the text")
for c in ay_cites:
for one in re.split(r";\s*", c):
sur = re.match(r"([A-Z][A-Za-z\-]+)", one.strip())
yr = re.search(r"\d{4}", one)
if sur and yr and not any(sur.group(1) in r and yr.group() in r for r in refs):
a.bad(f"citation ({one.strip()}) has no reference")
# 4. the journal's rules
sec = d.sections[0]
want = PAGE.get(spec["page"]["size"])
if want and (abs(sec.page_width.cm - want[0]) > 0.3 or abs(sec.page_height.cm - want[1]) > 0.3):
a.bad(f"page {sec.page_width.cm:.1f}×{sec.page_height.cm:.1f} cm, journal wants {spec['page']['size']}")
cols = re.findall(r'<w:cols [^>]*w:num="(\d+)"', xml)
if int(spec["page"].get("columns", 1)) > 1 and str(spec["page"]["columns"]) not in cols:
a.bad(f"{spec['page']['columns']} columns expected, found {cols or ['1']}")
fam = spec["font"]["family"]
styles = z.read("word/styles.xml").decode("utf-8")
if fam not in styles and fam not in xml:
a.bad(f"body font {fam} not used")
kw = next((p for p in paras if "liquid cooling" in p.lower() and "electric vehicle" in p.lower()
and "lithium-ion battery" in p.lower() and len(p) < 220), "")
if not kw:
a.bad("keywords line lost")
elif spec["keywords"]["separator"].strip() and spec["keywords"]["separator"].strip() not in kw:
a.bad(f"keywords separator {spec['keywords']['separator']!r} not used: {kw[:80]}")
fig_lab = spec["captions"]["figure_label"]
caps = [p for p in paras if re.match(r"(Figure|Fig\.?)\s*1\b", p.strip(), re.I)]
if not any(c.strip().lower().startswith(fig_lab.lower()) for c in caps):
a.bad(f"figure caption does not use “{fig_lab}”: {caps[:1]}")
# 5. debris
for p in paras:
if re.search(r"\{[cft]:|\{fn\}|\?\?|\s{2,}\S", p) and not p.startswith("\t"):
a.bad(f"stray characters: {p[:80]!r}")
break
empties = sum(1 for p in d.paragraphs if not p.text.strip() and not p._p.xpath(".//w:drawing|.//m:oMath"))
if empties > 4:
a.bad(f"{empties} empty paragraphs")
def check_latex(a, data, spec, compile_it):
z = zipfile.ZipFile(io.BytesIO(data))
tex = z.read("main.tex").decode("utf-8")
if "differential scanning calorimetry" not in tex:
a.bad("LaTeX: footnote lost")
if "\\footnote" not in tex:
a.bad("LaTeX: no \\footnote")
for m in re.findall(r"10\.\d{4,}/[^\s,;}]+|https?://[^\s}]+", tex):
pass
if "batteryuniversity.com" not in tex:
a.bad("LaTeX: URL of the web reference lost")
if re.search(r"\{c:|\{fn\}|\?\?", tex):
a.bad("LaTeX: stray characters")
if not compile_it:
return
files = [("main.tex", tex)] + [(n, z.read(n).decode("utf-8")) for n in z.namelist()
if n.endswith((".bib", ".cls", ".sty", ".bst"))]
tex2 = "\\PassOptionsToPackage{demo}{graphicx}\n" + tex # images are not sent: grey boxes instead
files[0] = ("document.tex", tex2) # (the service compiles document.tex)
with tempfile.TemporaryDirectory() as td:
args = ["curl", "-s", "-L", "--max-time", "200", "-o", str(Path(td) / "out")]
for n, body in files:
p = Path(td) / n.replace("/", "_")
p.write_text(body, encoding="utf-8", newline="\n")
args += ["-F", f"filecontents[]=<{p}", "-F", f"filename[]={n}"]
args += ["-F", "engine=pdflatex", "-F", "return=log", "https://texlive.net/cgi-bin/latexcgi"]
subprocess.run(args, capture_output=True, timeout=260)
log = (Path(td) / "out").read_bytes().decode("utf-8", "replace") if (Path(td) / "out").exists() else ""
if not log:
a.bad("LaTeX: compile service did not answer")
return
errs = re.findall(r"^! .*", log, re.M)
if errs:
a.bad(f"LaTeX: {len(errs)} error(s): {errs[:2]}")
if re.search(r"Citation .* undefined", log):
a.bad("LaTeX: undefined citations")
if re.search(r"Reference .* undefined", log):
a.bad("LaTeX: undefined references")
over = [float(x) for x in re.findall(r"Overfull \\hbox \(([\d.]+)pt", log)]
if [x for x in over if x > 5]:
a.bad(f"LaTeX: {len([x for x in over if x > 5])} line(s) run into the margin (up to {max(over):.0f}pt)")
if "Output written" not in log and not errs:
a.bad("LaTeX: no PDF produced")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--presets", default=",".join(PRESETS))
ap.add_argument("--docs", default="numeric,authoryear,messy")
ap.add_argument("--latex", action="store_true")
a_ = ap.parse_args()
total, failed = 0, Counter()
for kind in a_.docs.split(","):
data = io.BytesIO()
C.build(kind).save(data)
for preset in a_.presets.split(","):
spec = build_spec(FormatSource(preset=preset))
label = f"{kind} → {preset}"
print(f"• {label}", flush=True)
a = Audit(label)
try:
r = format_manuscript(f"{kind}.docx", data.getvalue(), spec)
except Exception as err:
a.bad(f"FORMATTER CRASHED: {type(err).__name__}: {err}")
failed[label] += 1
continue
check_docx(a, r.docx, spec, kind)
check_latex(a, r.latex_zip, spec, a_.latex)
total += 1
failed[label] += len(a.problems)
bad = {k: v for k, v in failed.items() if v}
print(f"\n{total} outputs checked, {sum(bad.values())} problem(s) in {len(bad)} of them")
if __name__ == "__main__":
main()