-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcite_check.py
More file actions
129 lines (106 loc) · 5.08 KB
/
Copy pathcite_check.py
File metadata and controls
129 lines (106 loc) · 5.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/usr/bin/env python3
"""Phase 7 gate — every in-text citation must name a paper in rows.json.
A review's reference list is built from rows.json, so an in-text citation that
names no row is a reference the reader cannot follow, and an author-year that
matches TWO rows is a citation the reader cannot resolve. Both are invisible to
the renderer, which simply prints whatever prose it is given.
python3 tools/cite_check.py --rows rows.json --content content.json
Reads the `abstract` and every `sections[].paragraphs[]` of the content JSON,
parses APA author-date citations in both parenthetical and narrative form, and
checks each against author-year keys derived from the canonical `apa` strings.
Exit 1 on an UNRESOLVED citation (a hard gate: the reference list cannot back it).
AMBIGUOUS citations are reported as warnings, because the fix is editorial —
APA-7 8.19 says to name enough subsequent authors to tell the two apart
("(Kral, Davis, et al., 2022)"), which this tool cannot write for you.
Note on year suffixes: APA-7's other disambiguator is 2025a/2025b. references.py
accepts those, and so does this tool, but adding them means editing rows.json's
canonical `apa` strings; the extra-author form usually costs less.
"""
import argparse
import re
import sys
import common
PHASE = "7" # pipeline phase, read by tools/gen_docs.py for the tool index
# "Family, I. N." — the unit the APA formatter emits. Family may be a compound
# ('Lambon Ralph') or carry a particle ('de Heer', 'van den Heuvel').
SURNAME = re.compile(
r"([A-ZÀ-Ý][\wÀ-ÿ'’\-]*(?:\s(?:[a-zà-ÿ]+\s)*[A-ZÀ-Ý][\wÀ-ÿ'’\-]*)?)"
r",\s+(?:[A-ZÀ-Ý]\.(?:\s*[-A-ZÀ-Ý]\.)*)")
# Fold accents and curly apostrophes so 'Millière' matches 'Milliere'.
norm = common.fold
def keys_for(apa):
"""Every in-text form that should legitimately resolve to this reference."""
p = common.parse_apa(apa)
if not p:
return []
fams, yr = SURNAME.findall(p["authors"]), f"{p['year']}{p['suffix']}"
if not fams:
return []
if len(fams) == 1:
return [f"{fams[0]}, {yr}"]
if len(fams) == 2:
return [f"{fams[0]} & {fams[1]}, {yr}", f"{fams[0]} et al., {yr}"]
# 3+ authors: the plain form, plus APA-7 8.19's extra-author disambiguation
return [f"{fams[0]} et al., {yr}",
f"{fams[0]}, {fams[1]}, et al., {yr}",
f"{fams[0]}, {fams[1]}, {fams[2]}, et al., {yr}"]
def citations_in(text):
"""Both APA forms: parenthetical '(Farb et al., 2007)' and narrative
'Farb et al. (2007)' / 'Farb and Segal (2007)'. ('Farb and colleagues
(2007)' is not an APA form and is not parsed.)"""
found = set()
for grp in re.findall(r"\(([^()]*\d{4}[a-z]?[^()]*)\)", text):
for part in grp.split(";"):
part = part.strip().rstrip(",")
# trim a locator: "(Smith, 2020, p. 4)" -> "Smith, 2020"
part = re.sub(r",\s*(?:p{1,2}\.|para\.|Ch\.)\s*[\d\-–]+$", "", part)
if re.search(r",\s*\d{4}[a-z]?$", part):
found.add(part)
for m in re.finditer(
r"([A-ZÀ-Ý][\wÀ-ÿ'’\-]+(?:\s(?:&|and)\s[A-ZÀ-Ý][\wÀ-ÿ'’\-]+|"
r"\s+et\s+al\.)?)\s+\((\d{4}[a-z]?)\)", text):
who = m.group(1).replace(" and ", " & ")
found.add(f"{who}, {m.group(2)}")
return found
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rows", required=True)
ap.add_argument("--content", required=True)
ap.add_argument("--key", default=None, help="row key field (default: ref, else label)")
ap.add_argument("--quiet", action="store_true", help="only report problems")
args = ap.parse_args()
rows = common.load_json(args.rows)
content = common.load_json(args.content)
keyf = common.key_field(rows, args.key)
index = {}
for r in rows:
for k in keys_for(r.get("apa", "")):
index.setdefault(norm(k), []).append(r.get(keyf, "?"))
text = content.get("abstract", "")
for s in content.get("sections", []):
text += "\n" + "\n".join(s.get("paragraphs", []))
cites = citations_in(text)
unresolved, ambiguous, ok = [], [], []
for c in sorted(cites):
hits = sorted(set(index.get(norm(c), [])))
if not hits:
unresolved.append(c)
elif len(hits) > 1:
ambiguous.append((c, hits))
else:
ok.append((c, hits[0]))
print(f"{len(cites)} distinct in-text citations | {len(ok)} resolve | "
f"{len(ambiguous)} ambiguous | {len(unresolved)} unresolved")
for c, hits in ambiguous:
print(f" ⚠ ambiguous: ({c}) matches {hits} — APA-7 8.19: name more authors")
for ref in hits:
row = next((r for r in rows if r.get(keyf) == ref), {})
print(f" {ref}: {(row.get('title') or row.get('apa', ''))[:88]}")
for c in unresolved:
print(f" ✗ unresolved: ({c}) names no reference in {args.rows}")
if not args.quiet and not unresolved and not ambiguous:
print("✓ every in-text citation resolves to exactly one reference")
if unresolved:
sys.exit(1)
if __name__ == "__main__":
main()