STARSRedacter / redactors /grades.py
StrangeIB's picture
Upload 8 files
c377a9f verified
Raw
History Blame Contribute Delete
4.37 kB
"""Grade redaction pass (basis + pass/fail only).
Replaces each actual course grade with a token that preserves ONLY the grading
basis and the university-level pass/fail outcome, hiding the precise grade:
letter A-..D- (earns undergrad credit) -> "Lp" (letter, passed)
letter F -> "Lf" (letter, failed)
P (Pass, P/NP basis) -> "Pp" (P/NP, passed)
NP (No Pass, P/NP basis) -> "Pn" (P/NP, not passed)
Deliberately KEPT untouched (status / basis, not a performance value):
CR NC (credit / no-credit basis), IN IX (incomplete / expired),
MG NS (missing / not submitted), RG (in progress), TR (transfer),
W (withdrawn), and all ">" course flags.
Also neutralizes the printed GPA and POINTS summary figures (the only numbers
in the report written with THREE decimals, e.g. "3.107", "174.000"), so the
grades cannot be reverse-engineered from the summaries. Unit tallies use two
decimals ("93.00 UNITS") and are left intact — the degree verifier needs them.
Grades are located structurally: in a course row the grade is the word
immediately following the units field (a 1-decimal number like "4.0"), which
cleanly separates it from the suffix letters (L/X/G) that precede the units.
The legend / grade-definition text has no units field, so it is never touched.
"""
import re
from collections import defaultdict
import fitz
UNITS_RE = re.compile(r"^\d+\.\d$") # course units: one decimal (4.0, 3.3)
DEC3_RE = re.compile(r"^\d+\.\d{3}$") # GPA / POINTS: three decimals
# grade -> replacement token (only these are replaced)
REPLACE = {g: "Lp" for g in
("A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-")}
REPLACE["F"] = "Lf"
REPLACE["P"] = "Pp"
REPLACE["NP"] = "Pn"
RAW_GRADES = set(REPLACE) # tokens that must NOT survive after a units field
def _lines(page):
"""Group a page's words into (block, line) rows, each sorted left->right."""
groups = defaultdict(list)
for w in page.get_text("words"): # (x0,y0,x1,y1, word, block, line, n)
groups[(w[5], w[6])].append(w)
for ws in groups.values():
ws.sort(key=lambda w: w[0])
return groups.values()
def build_findings(doc):
findings = []
n_grades = n_gpa = n_rows = 0
for pno, page in enumerate(doc):
for ws in _lines(page):
toks = [w[4] for w in ws]
for i, t in enumerate(toks):
if UNITS_RE.match(t):
n_rows += 1
if i + 1 < len(ws) and toks[i + 1] in REPLACE:
gw = ws[i + 1]
orig = toks[i + 1]
repl = REPLACE[orig]
rect = fitz.Rect(gw[:4])
pitch = rect.width / max(len(orig), 1)
box = fitz.Rect(rect.x0, rect.y0,
rect.x0 + pitch * len(repl), rect.y1)
findings.append({"page": pno, "rect": rect,
"box": box, "text": repl})
n_grades += 1
# GPA / POINTS summary numbers (three-decimal) -> X.XXX
for w in page.get_text("words"):
if DEC3_RE.match(w[4]):
rect = fitz.Rect(w[:4])
findings.append({"page": pno, "rect": rect, "box": rect,
"text": re.sub(r"\d", "X", w[4])})
n_gpa += 1
meta = {"grades_replaced": n_grades, "gpa_replaced": n_gpa,
"course_rows": n_rows}
return findings, meta
def verify(doc, meta):
"""HARD: any raw letter grade / P / NP still sits after a units field, or a
three-decimal GPA/POINTS number survived. No soft warnings."""
hard = []
for pno, page in enumerate(doc):
for ws in _lines(page):
toks = [w[4] for w in ws]
for i, t in enumerate(toks):
if UNITS_RE.match(t) and i + 1 < len(toks) and toks[i + 1] in RAW_GRADES:
hard.append(f"raw grade {toks[i + 1]!r} survived after units "
f"on page {pno + 1}")
for w in page.get_text("words"):
if DEC3_RE.match(w[4]):
hard.append(f"GPA/POINTS value {w[4]!r} survived on page {pno + 1}")
return hard, []