StrangeIB commited on
Commit
2fd90c5
·
1 Parent(s): c377a9f

Make this Space the single home: latest app + engine + CLI

Browse files

- app.py: BBH branding, experience.usc.edu download instructions, in-memory redaction
- redactors/: canonical engine (PII + grades passes, verify-before-write)
- redact_stars.py: command-line tool (single/batch)
- README: single-home model + what-to-edit-where
- remove Dockerfile + src/ template clutter (Streamlit SDK runs app.py)

Files changed (5) hide show
  1. Dockerfile +0 -20
  2. README.md +45 -13
  3. app.py +17 -7
  4. redact_stars.py +176 -0
  5. src/streamlit_app.py +0 -40
Dockerfile DELETED
@@ -1,20 +0,0 @@
1
- FROM python:3.13.5-slim
2
-
3
- WORKDIR /app
4
-
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
10
-
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
13
-
14
- RUN pip3 install -r requirements.txt
15
-
16
- EXPOSE 8501
17
-
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
-
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -11,22 +11,54 @@ license: agpl-3.0
11
 
12
  # STARS Report Redactor
13
 
14
- Student-facing web front end for the Summer Scholars / BBHCourseReg STARS
15
- redactor. Upload a USC STARS Degree Progress Report PDF; it removes personal
16
- information (name, ID, address, sport) and grades (replaced with pass/fail
17
- markers, GPA blanked) **in memory** and returns a redacted copy. The uploaded
18
- file is never written to disk or stored.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  ## Configuration
21
 
22
- Set a Space **secret** named `APP_PASSPHRASE` to the access phrase. The app
23
- refuses to run without it.
 
 
 
 
 
 
 
 
24
 
25
- ## Contents
 
 
 
 
 
26
 
27
- This Space is a deployment bundle. `app.py` + `requirements.txt` + the
28
- `redactors/` package (a copy of the canonical package in
29
- `internal-tools/redaction/redactors/` of the main repo). If the redaction logic
30
- changes there, re-copy `redactors/` here.
31
 
32
- Licensed AGPL-3.0 because it uses MuPDF/PyMuPDF.
 
 
 
 
 
 
11
 
12
  # STARS Report Redactor
13
 
14
+ Web tool for the **BUAI Builder Hub (BBH) / BBHCourseReg** project. A student
15
+ uploads their USC STARS Degree Progress Report PDF; it removes personal
16
+ information (name, ID, address, sport) and grades (each grade replaced with a
17
+ pass/fail marker, GPA figures blanked) **entirely in memory** and returns a
18
+ redacted copy. The uploaded file is never written to disk or stored.
19
+
20
+ **This repository is the single home for the redaction tool** — the web app,
21
+ the redaction engine, and the command-line tool all live here together. Pushing
22
+ to this repo automatically rebuilds and redeploys the live Space.
23
+
24
+ ## What to edit where
25
+
26
+ | File / folder | What it is | Edit it for |
27
+ |---|---|---|
28
+ | `app.py` | the Streamlit web UI | look & wording of the web app |
29
+ | `redactors/` | the redaction engine | redaction logic / behavior |
30
+ | `redact_stars.py` | the command-line tool (batch/single) | CLI features |
31
+ | `requirements.txt` | Python dependencies | adding a package |
32
+ | `README.md` | this file (the top YAML configures the Space) | docs / Space config |
33
+
34
+ Deploying is automatic: commit and `git push` to this repo, and the Space
35
+ rebuilds. There are no copies to keep in sync.
36
 
37
  ## Configuration
38
 
39
+ Set a Space **secret** named `APP_PASSPHRASE` to the access phrase (Space
40
+ Settings → Variables and secrets). The app refuses to run without it.
41
+
42
+ ## Run locally
43
+
44
+ ```bash
45
+ pip install -r requirements.txt
46
+
47
+ # web app:
48
+ streamlit run app.py # set APP_PASSPHRASE in your env first
49
 
50
+ # command line (single file, folder batch, or preview):
51
+ python redact_stars.py report.pdf # PII + grades
52
+ python redact_stars.py a_folder/ -o out/ # batch
53
+ python redact_stars.py report.pdf --redact pii # PII only
54
+ python redact_stars.py report.pdf --dry-run # show, write nothing
55
+ ```
56
 
57
+ ## Notes
 
 
 
58
 
59
+ - Redaction is not perfect anonymization: major, terms, and the course list
60
+ remain, which can be identifying in a small program. This is disclosed to
61
+ students in the app before they consent.
62
+ - Scanned/image-only PDFs have no text layer and are skipped (they can't be
63
+ redacted here); use the PDF saved from experience.usc.edu.
64
+ - Licensed AGPL-3.0 because it uses MuPDF/PyMuPDF.
app.py CHANGED
@@ -21,7 +21,7 @@ st.set_page_config(page_title="STARS Report Redactor", page_icon="🛡️",
21
 
22
  st.title("🛡️ STARS Report Redactor")
23
  st.caption("Remove your personal information and grades from a USC STARS "
24
- "Degree Progress Report before sharing it with the Summer Scholars team.")
25
 
26
  with st.expander("What this does and how your file is handled", expanded=True):
27
  st.markdown(
@@ -34,6 +34,15 @@ with st.expander("What this does and how your file is handled", expanded=True):
34
  "Please share only if you consent."
35
  )
36
 
 
 
 
 
 
 
 
 
 
37
  # --- access gate -----------------------------------------------------------
38
  if not PASSPHRASE:
39
  st.error("This app isn't configured yet (no access phrase set). "
@@ -41,7 +50,7 @@ if not PASSPHRASE:
41
  st.stop()
42
 
43
  entered = st.text_input("Access phrase", type="password",
44
- help="Provided to you by the Summer Scholars team.")
45
  if entered.strip() != PASSPHRASE:
46
  if entered:
47
  st.warning("That access phrase isn't right. Please try again.")
@@ -49,10 +58,10 @@ if entered.strip() != PASSPHRASE:
49
 
50
  # --- redaction flow --------------------------------------------------------
51
  consent = st.checkbox(
52
- "I consent to share my redacted STARS report with the Summer Scholars team.")
53
 
54
  uploaded = st.file_uploader(
55
- "Upload your STARS report (the PDF you downloaded from my.usc.edu)",
56
  type=["pdf"], accept_multiple_files=False)
57
 
58
  if uploaded is not None and not consent:
@@ -85,13 +94,14 @@ if uploaded is not None and consent:
85
 
86
  elif status == "SKIPPED":
87
  st.error("This PDF looks like a **scan** (it has no text layer), so it "
88
- "can't be redacted here. Please upload the PDF you download "
89
- "directly from my.usc.edu rather than a scanned/printed copy.")
 
90
  else:
91
  st.error("The redaction could not be fully verified, so **no file was "
92
  "produced**. Please contact the team — and do not share the "
93
  "original in the meantime.")
94
 
95
  st.divider()
96
- st.caption("Internal tool of the Summer Scholars / BBHCourseReg project. "
97
  "Processing happens in memory; no files are stored.")
 
21
 
22
  st.title("🛡️ STARS Report Redactor")
23
  st.caption("Remove your personal information and grades from a USC STARS "
24
+ "Degree Progress Report before sharing it with the BUAI Builder Hub (BBH).")
25
 
26
  with st.expander("What this does and how your file is handled", expanded=True):
27
  st.markdown(
 
34
  "Please share only if you consent."
35
  )
36
 
37
+ with st.expander("How to download your STARS report (PDF)"):
38
+ st.markdown(
39
+ "1. Go to **experience.usc.edu** and open **My Academics**.\n"
40
+ "2. Open your **STARS** (Degree Progress) report.\n"
41
+ "3. Click the **red Print button** at the top of the report.\n"
42
+ "4. In the print dialog, set the destination to **Save as PDF** and save the file.\n"
43
+ "5. Upload that saved PDF below."
44
+ )
45
+
46
  # --- access gate -----------------------------------------------------------
47
  if not PASSPHRASE:
48
  st.error("This app isn't configured yet (no access phrase set). "
 
50
  st.stop()
51
 
52
  entered = st.text_input("Access phrase", type="password",
53
+ help="Provided to you by the BBH team.")
54
  if entered.strip() != PASSPHRASE:
55
  if entered:
56
  st.warning("That access phrase isn't right. Please try again.")
 
58
 
59
  # --- redaction flow --------------------------------------------------------
60
  consent = st.checkbox(
61
+ "I consent to share my redacted STARS report with the BUAI Builder Hub (BBH).")
62
 
63
  uploaded = st.file_uploader(
64
+ "Upload your STARS report (the PDF you saved from experience.usc.edu)",
65
  type=["pdf"], accept_multiple_files=False)
66
 
67
  if uploaded is not None and not consent:
 
94
 
95
  elif status == "SKIPPED":
96
  st.error("This PDF looks like a **scan** (it has no text layer), so it "
97
+ "can't be redacted here. Please use the PDF you save via the "
98
+ "**Print Save as PDF** steps above on experience.usc.edu, "
99
+ "rather than a scanned or photographed copy.")
100
  else:
101
  st.error("The redaction could not be fully verified, so **no file was "
102
  "produced**. Please contact the team — and do not share the "
103
  "original in the meantime.")
104
 
105
  st.divider()
106
+ st.caption("Internal tool of the BUAI Builder Hub (BBH) / BBHCourseReg project. "
107
  "Processing happens in memory; no files are stored.")
redact_stars.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ redact_stars.py — Bulletproof, structure-faithful redaction for USC STARS
4
+ Degree Progress Reports (single- or double-column, any student).
5
+
6
+ One tool, two redaction passes, chosen with --redact:
7
+
8
+ --redact all (default) remove PII *and* redact grades
9
+ --redact pii remove PII only
10
+ --redact grades redact grades only
11
+
12
+ Both passes run on the same in-memory document and are gated by a single
13
+ verification step, so a file is only written once it is proven clean for the
14
+ selected passes. Output filenames encode the mode so a partial redaction can
15
+ never be mistaken for a complete one:
16
+
17
+ all -> REDACTED_<name>.pdf
18
+ pii -> REDACTED-PII_<name>.pdf
19
+ grades -> REDACTED-GRADES_<name>.pdf
20
+
21
+ PII pass: student ID, roster + diploma name, mailing address, sport/team.
22
+ Grade pass: each grade -> basis + pass/fail token (Lp/Lf/Pp/Pn); keeps
23
+ IN/IX, RG, TR, W, CR/NC, flags; neutralizes GPA/POINTS figures.
24
+
25
+ How it stays faithful & bulletproof: it TRULY removes the target glyphs
26
+ (PyMuPDF apply_redactions) and re-inserts width-preserving monospace filler, so
27
+ the fixed-width columns are preserved and the originals are gone — not covered.
28
+
29
+ Usage:
30
+ python3 redact_stars.py INPUT.pdf [-o OUT] [--redact all|pii|grades]
31
+ [--dry-run] [--tag]
32
+ python3 redact_stars.py FOLDER [-o OUT_FOLDER] [--redact ...]
33
+ """
34
+ import argparse
35
+ import os
36
+ import re
37
+ import sys
38
+
39
+ try:
40
+ import fitz # PyMuPDF
41
+ except ImportError:
42
+ sys.exit("PyMuPDF is required: pip install pymupdf --break-system-packages")
43
+
44
+ from redactors import common, pii, grades, engine
45
+
46
+ MODE_PREFIX = {
47
+ frozenset({"pii", "grades"}): "REDACTED_",
48
+ frozenset({"pii"}): "REDACTED-PII_",
49
+ frozenset({"grades"}): "REDACTED-GRADES_",
50
+ }
51
+
52
+
53
+ def _default_out(in_path, modes, out_dir=None):
54
+ base = os.path.basename(in_path)
55
+ base = re.sub(r"^DONOTSHARE[_-]*", "", base, flags=re.IGNORECASE)
56
+ base = re.sub(r"^REDACTED[A-Z-]*_", "", base, flags=re.IGNORECASE) # no stacking
57
+ total = engine.existing_modes(in_path) | set(modes) # cumulative state
58
+ d = out_dir if out_dir else os.path.dirname(in_path)
59
+ return os.path.join(d, MODE_PREFIX[frozenset(total)] + base)
60
+
61
+
62
+ def process_one(in_path, out_path, modes, tag=False):
63
+ """Redact one report for the selected passes (file in -> file out). Returns
64
+ a status dict and NEVER writes a file that fails verification."""
65
+ doc = fitz.open(in_path)
66
+ try:
67
+ res = engine.redact_doc(doc, modes, tag=tag,
68
+ existing=engine.existing_modes(in_path))
69
+ res["file"] = in_path
70
+ res["out"] = out_path
71
+ res["modes"] = modes
72
+ if res["status"] in ("REDACTED", "REVIEW"):
73
+ doc.save(out_path, garbage=4, deflate=True, clean=True)
74
+ finally:
75
+ doc.close()
76
+ return res
77
+
78
+
79
+ def _print_one(res):
80
+ print(f"\n=== {os.path.basename(res['file'])} [{'+'.join(sorted(res['modes']))}] ===")
81
+ for k, v in res.get("pii", {}).items():
82
+ print(f" {k:24s}: {v!r}")
83
+ if res.get("pii_note"):
84
+ print(f" {res['pii_note']}")
85
+ if res.get("grades"):
86
+ g = res["grades"]
87
+ print(f" grades redacted: {g['grades_replaced']} | "
88
+ f"GPA/POINTS figures: {g['gpa_replaced']} | "
89
+ f"course rows seen: {g['course_rows']}")
90
+ label = {"REDACTED": "OK (verified clean)", "REVIEW": "OK — needs human review",
91
+ "SKIPPED": "SKIPPED", "FAILED": "FAILED"}[res["status"]]
92
+ print(f" -> {label}: {res['reason'] or res['out']}")
93
+ for tok, pages in res["fragments"]:
94
+ print(f" review: name fragment {tok!r} still appears on page(s) {pages}")
95
+ for h in res.get("hard", []):
96
+ print(f" !! {h}")
97
+
98
+
99
+ def run_batch(in_dir, out_dir, modes, tag=False):
100
+ os.makedirs(out_dir, exist_ok=True)
101
+ results = []
102
+ for name in sorted(os.listdir(in_dir)):
103
+ if not name.lower().endswith(".pdf"):
104
+ continue
105
+ if name.upper().startswith("REDACTED_"): # already fully redacted -> skip
106
+ continue
107
+ src = os.path.join(in_dir, name)
108
+ try:
109
+ r = process_one(src, _default_out(src, modes, out_dir), modes, tag=tag)
110
+ except Exception as e: # unexpected -> report, keep going
111
+ r = {"file": src, "modes": modes, "status": "FAILED",
112
+ "reason": f"error: {e}", "pii": {}, "grades": {},
113
+ "fragments": [], "hard": []}
114
+ _print_one(r)
115
+ results.append(r)
116
+
117
+ order = ["REDACTED", "REVIEW", "SKIPPED", "FAILED"]
118
+ counts = {s: sum(1 for r in results if r["status"] == s) for s in order}
119
+ print("\n" + "=" * 60 + "\nBATCH SUMMARY [redact: " + "+".join(sorted(modes)) + "]")
120
+ for s in order:
121
+ print(f" {s:9s}: {counts[s]}")
122
+ print(f" outputs in: {out_dir}")
123
+ return results
124
+
125
+
126
+ def _dry_run(path, modes):
127
+ d = fitz.open(path)
128
+ if not common.has_text_layer(d):
129
+ print("No text layer (scanned image) — not viable for this tool.")
130
+ return
131
+ if "pii" in modes:
132
+ values = pii.detect(d)
133
+ print("Detected identifiers:" if values else "No identifiers detected.")
134
+ for k, v in values.items():
135
+ print(f" {k:24s}: {v!r}")
136
+ if "grades" in modes:
137
+ _, gm = grades.build_findings(d)
138
+ print(f"Grades to redact: {gm['grades_replaced']} | "
139
+ f"GPA/POINTS figures: {gm['gpa_replaced']} | "
140
+ f"course rows: {gm['course_rows']}")
141
+
142
+
143
+ def main():
144
+ ap = argparse.ArgumentParser(
145
+ description="Bulletproof redaction of USC STARS report(s): PII and/or grades.")
146
+ ap.add_argument("input", help="a STARS report PDF, or a folder of them")
147
+ ap.add_argument("-o", "--output",
148
+ help="output file (single) or output folder (batch)")
149
+ ap.add_argument("--redact", choices=["all", "pii", "grades"], default="all",
150
+ help="what to redact (default: all = PII + grades)")
151
+ ap.add_argument("--dry-run", action="store_true",
152
+ help="print what would be redacted and exit (writes nothing)")
153
+ ap.add_argument("--tag", action="store_true",
154
+ help="use [REDACTED-*] labels instead of X filler for PII")
155
+ args = ap.parse_args()
156
+
157
+ modes = {"pii", "grades"} if args.redact == "all" else {args.redact}
158
+
159
+ if os.path.isdir(args.input):
160
+ out_dir = args.output or os.path.join(args.input, "REDACTED")
161
+ run_batch(args.input, out_dir, modes, tag=args.tag)
162
+ return
163
+
164
+ if args.dry_run:
165
+ _dry_run(args.input, modes)
166
+ return
167
+
168
+ res = process_one(args.input, args.output or _default_out(args.input, modes),
169
+ modes, tag=args.tag)
170
+ _print_one(res)
171
+ if res["status"] == "FAILED":
172
+ sys.exit(1)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
src/streamlit_app.py DELETED
@@ -1,40 +0,0 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))