| """ |
| Gradio UI for the Textract + Comprehend Medical PDF redaction workflow. |
| |
| Phase 1 (Review): Upload a PDF β runs Textract + Comprehend Medical β |
| returns a highlighted review PDF and an editable redactions JSON. |
| |
| Phase 2 (Redact): Optionally edit the JSON to mark false positives as |
| excluded, then apply permanent redactions and download the final PDF. |
| """ |
|
|
| import importlib.util |
| import json |
| import logging |
| import os |
| import queue |
| import tempfile |
| import threading |
| import time |
| import uuid |
| import webbrowser |
| from pathlib import Path |
|
|
| import boto3 |
| import fitz |
| import gradio as gr |
|
|
| |
| _spec = importlib.util.spec_from_file_location( |
| "redaction", |
| Path(__file__).parent / "comprehend-medical-redaction-text.py", |
| ) |
| _mod = importlib.util.module_from_spec(_spec) |
| _spec.loader.exec_module(_mod) |
|
|
| get_textract_blocks = _mod.get_textract_blocks |
| build_page_data = _mod.build_page_data |
| collect_redactions = _mod.collect_redactions |
| generate_review_pdf = _mod.generate_review_pdf |
| apply_final_redactions = _mod.apply_final_redactions |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [USAGE] %(message)s", |
| datefmt="%Y-%m-%dT%H:%M:%SZ", |
| ) |
| _log = logging.getLogger("usage") |
|
|
|
|
| def _event(name: str, session: str = "", **kwargs): |
| """Log a structured usage event. No PII, filenames, or AWS identifiers.""" |
| parts = [f"event={name}"] |
| if session: |
| parts.append(f"session={session}") |
| parts.extend(f"{k}={v}" for k, v in kwargs.items() if v is not None) |
| _log.info(" ".join(parts)) |
|
|
|
|
| |
| REGION = "us-east-1" |
| POLL_INTERVAL = 15 |
| DEFAULT_MAX_PAGES = 20 |
|
|
| |
| |
| |
| SHOW_AUTH = os.environ.get("SHOW_AUTH", "true").lower() != "false" |
|
|
|
|
| def _parse_s3_url(s3_url: str) -> tuple[str, str]: |
| """Parse 's3://bucket/prefix/' into ('bucket', 'prefix'). Prefix may be empty.""" |
| s3_url = s3_url.strip().rstrip("/") |
| if not s3_url.startswith("s3://"): |
| raise ValueError("Expected format: s3://bucket-name/optional-prefix/") |
| path = s3_url[5:] |
| bucket, _, prefix = path.partition("/") |
| if not bucket: |
| raise ValueError("Bucket name cannot be empty") |
| return bucket, prefix |
|
|
|
|
| |
|
|
|
|
| def _make_boto3_session(creds: dict) -> boto3.Session: |
| """Build a boto3 Session from stored credentials state.""" |
| if creds.get("type") == "static": |
| return boto3.Session( |
| aws_access_key_id=creds["key_id"], |
| aws_secret_access_key=creds["secret_key"], |
| aws_session_token=creds.get("session_token") or None, |
| region_name=creds.get("region") or REGION, |
| ) |
| if creds.get("type") == "profile": |
| return boto3.Session( |
| profile_name=creds.get("profile") or None, |
| region_name=creds.get("region") or REGION, |
| ) |
| return boto3.Session(region_name=creds.get("region") or REGION) |
|
|
|
|
| def _make_clients(creds: dict): |
| session = _make_boto3_session(creds) |
| return ( |
| session.client("s3"), |
| session.client("textract"), |
| session.client("comprehendmedical"), |
| ) |
|
|
|
|
| def _caller_identity(creds: dict) -> str: |
| """Return the caller ARN string, or raise on failure.""" |
| session = _make_boto3_session(creds) |
| sts = session.client("sts", region_name=creds.get("region") or REGION) |
| return sts.get_caller_identity()["Arn"] |
|
|
|
|
| |
|
|
|
|
| def check_connection(creds: dict): |
| try: |
| arn = _caller_identity(creds) |
| return f"**Status:** Connected as `{arn}`", creds |
| except Exception as exc: |
| return f"**Status:** Not connected β {exc}", creds |
|
|
|
|
| def apply_static_creds(key_id: str, secret: str, token: str, region: str, creds: dict, session_id: str = ""): |
| if not key_id.strip() or not secret.strip(): |
| return ( |
| "Access Key ID and Secret Access Key are required.", |
| creds, |
| "**Status:** Not connected", |
| ) |
| new_creds = { |
| "type": "static", |
| "key_id": key_id.strip(), |
| "secret_key": secret.strip(), |
| "session_token": token.strip() or None, |
| "region": region.strip() or REGION, |
| } |
| try: |
| arn = _caller_identity(new_creds) |
| _event("logged_in", session_id, method="static") |
| msg = f"**Status:** Connected as `{arn}`" |
| return msg, new_creds, msg |
| except Exception as exc: |
| return f"Invalid credentials: {exc}", creds, "**Status:** Not connected" |
|
|
|
|
| _AUTH_LINK_EMPTY = "" |
| _AUTH_LINK_TMPL = """ |
| <div style="margin:8px 0;padding:12px 16px;background:#eff6ff;border-radius:6px; |
| border-left:4px solid #2563eb;font-family:sans-serif;"> |
| <strong>Authorization required</strong><br><br> |
| <a href="{url}" target="_blank" |
| style="display:inline-block;padding:8px 16px;background:#2563eb;color:#fff; |
| border-radius:4px;text-decoration:none;font-weight:600;"> |
| Authorize in AWS → |
| </a> |
| <br><br> |
| <small style="color:#555;"> |
| If the button above doesn't work, copy this URL into your browser:<br> |
| <code style="word-break:break-all;">{url}</code> |
| </small> |
| </div> |
| """ |
|
|
|
|
| def start_sso_device_flow( |
| start_url: str, |
| sso_region: str, |
| account_id: str, |
| role_name: str, |
| region: str, |
| creds: dict, |
| session_id: str = "", |
| ): |
| """ |
| Generator implementing the OAuth 2.0 device authorization grant against |
| AWS IAM Identity Center β no AWS CLI required. |
| |
| Yields: (log_text, auth_link_html, creds_state, auth_status_markdown) |
| """ |
| start_url = start_url.strip() |
| sso_region = sso_region.strip() |
| account_id = account_id.strip() |
| role_name = role_name.strip() |
| region = region.strip() or REGION |
|
|
| logs: list[str] = [] |
|
|
| def emit(*lines): |
| logs.extend(lines) |
| return "\n".join(logs) |
|
|
| missing = [ |
| f |
| for f, v in [ |
| ("SSO Start URL", start_url), |
| ("Identity Center Region", sso_region), |
| ("Account ID", account_id), |
| ("Role Name", role_name), |
| ] |
| if not v |
| ] |
| if missing: |
| yield ( |
| emit(f"Please fill in: {', '.join(missing)}"), |
| _AUTH_LINK_EMPTY, |
| creds, |
| "**Status:** Not connected", |
| ) |
| return |
|
|
| try: |
| oidc = boto3.client("sso-oidc", region_name=sso_region) |
|
|
| |
| client_reg = oidc.register_client( |
| clientName="document-redaction-agent", |
| clientType="public", |
| ) |
|
|
| |
| device_auth = oidc.start_device_authorization( |
| clientId=client_reg["clientId"], |
| clientSecret=client_reg["clientSecret"], |
| startUrl=start_url, |
| ) |
| except Exception as exc: |
| yield ( |
| emit(f"ERROR starting device authorization: {exc}"), |
| _AUTH_LINK_EMPTY, |
| creds, |
| "**Status:** Error", |
| ) |
| return |
|
|
| verification_url = device_auth["verificationUriComplete"] |
| device_code = device_auth["deviceCode"] |
| interval = max(device_auth.get("interval", 5), 5) |
| expires_in = device_auth.get("expiresIn", 600) |
| deadline = time.time() + expires_in |
|
|
| |
| webbrowser.open(verification_url) |
|
|
| link_html = _AUTH_LINK_TMPL.format(url=verification_url) |
| yield ( |
| emit("Waiting for you to authorize in the browser ..."), |
| link_html, |
| creds, |
| "**Status:** Waiting for authorization ...", |
| ) |
|
|
| |
| while time.time() < deadline: |
| time.sleep(interval) |
| try: |
| token = oidc.create_token( |
| clientId=client_reg["clientId"], |
| clientSecret=client_reg["clientSecret"], |
| grantType="urn:ietf:params:oauth:grant-type:device_code", |
| deviceCode=device_code, |
| ) |
| except oidc.exceptions.AuthorizationPendingException: |
| remaining = int(deadline - time.time()) |
| yield ( |
| emit(f" Still waiting ... ({remaining}s remaining)"), |
| link_html, |
| creds, |
| "**Status:** Waiting for authorization ...", |
| ) |
| continue |
| except oidc.exceptions.SlowDownException: |
| interval += 5 |
| continue |
| except Exception as exc: |
| yield ( |
| emit(f"ERROR during token poll: {exc}"), |
| link_html, |
| creds, |
| "**Status:** Error", |
| ) |
| return |
|
|
| |
| sso = boto3.client("sso", region_name=sso_region) |
| access_token = token["accessToken"] |
| try: |
| role_creds = sso.get_role_credentials( |
| accountId=account_id, |
| roleName=role_name, |
| accessToken=access_token, |
| )["roleCredentials"] |
| except Exception as exc: |
| |
| |
| yield ( |
| emit( |
| f"ERROR fetching role credentials: {exc}", |
| "", |
| "Checking what your user has access to ...", |
| ), |
| link_html, |
| creds, |
| "**Status:** Error", |
| ) |
| try: |
| accounts = sso.list_accounts(accessToken=access_token).get( |
| "accountList", [] |
| ) |
| if not accounts: |
| emit(" (no accounts found for this Identity Center user)") |
| else: |
| emit("", "Your Identity Center user has access to:") |
| for acct in accounts: |
| acct_id = acct["accountId"] |
| acct_name = acct.get("accountName", acct_id) |
| emit(f" Account: {acct_name} (ID: {acct_id})") |
| try: |
| roles = sso.list_account_roles( |
| accessToken=access_token, accountId=acct_id |
| ).get("roleList", []) |
| for r in roles: |
| emit(f" Permission Set: {r['roleName']}") |
| except Exception: |
| emit(" (could not list roles for this account)") |
| emit( |
| "", |
| "Update the Account ID and Permission Set fields above and try again.", |
| ) |
| except Exception as list_exc: |
| emit(f" Could not list accessible accounts: {list_exc}") |
| yield ( |
| "\n".join(logs), |
| link_html, |
| creds, |
| "**Status:** Error β see log for available accounts", |
| ) |
| return |
|
|
| new_creds = { |
| "type": "static", |
| "key_id": role_creds["accessKeyId"], |
| "secret_key": role_creds["secretAccessKey"], |
| "session_token": role_creds["sessionToken"], |
| "region": region, |
| } |
| try: |
| arn = _caller_identity(new_creds) |
| except Exception as exc: |
| yield ( |
| emit(f"Credentials retrieved but identity check failed: {exc}"), |
| link_html, |
| creds, |
| "**Status:** Error", |
| ) |
| return |
|
|
| _event("logged_in", session_id, method="sso") |
| status = f"**Status:** Connected as `{arn}`" |
| yield emit(f"Connected as {arn}"), _AUTH_LINK_EMPTY, new_creds, status |
| return |
|
|
| yield ( |
| emit("Authorization timed out. Click the button again to restart."), |
| _AUTH_LINK_EMPTY, |
| creds, |
| "**Status:** Timed out", |
| ) |
|
|
|
|
| |
|
|
|
|
| def _make_work_dir() -> Path: |
| return Path(tempfile.mkdtemp(prefix="redact_")) |
|
|
|
|
| def _stream_worker(fn, log_q: queue.Queue): |
| try: |
| fn() |
| log_q.put(("done", None)) |
| except Exception as exc: |
| log_q.put(("error", str(exc))) |
|
|
|
|
| def _drain_queue(log_q: queue.Queue, logs: list, timeout=120): |
| while True: |
| try: |
| kind, data = log_q.get(timeout=timeout) |
| except queue.Empty: |
| logs.append("(timed out waiting for response)") |
| yield True, "\n".join(logs) |
| return |
| if kind == "log": |
| logs.append(data) |
| yield False, "\n".join(logs) |
| elif kind == "done": |
| yield True, "\n".join(logs) |
| return |
| elif kind == "error": |
| logs.append(f"\nERROR: {data}") |
| yield True, "\n".join(logs) |
| return |
|
|
|
|
| |
|
|
|
|
| def run_review(pdf_upload, creds: dict, s3_url: str, max_pages: int, session_id: str = ""): |
| if pdf_upload is None: |
| yield "Please upload a PDF first.", None, "", "", "" |
| return |
|
|
| try: |
| s3_bucket, s3_prefix = _parse_s3_url(s3_url) |
| except ValueError as exc: |
| yield f"Invalid S3 URL in Settings: {exc}", None, "", "", "" |
| return |
|
|
| max_pages = int(max_pages) if max_pages and int(max_pages) > 0 else None |
| _event("review_started", session_id) |
| unique_key = "/".join( |
| filter(None, [s3_prefix, uuid.uuid4().hex[:8], Path(pdf_upload).name]) |
| ) |
|
|
| work_dir = _make_work_dir() |
| pdf_path = Path(pdf_upload) |
| local_pdf = work_dir / pdf_path.name |
| local_pdf.write_bytes(pdf_path.read_bytes()) |
|
|
| textract_cache = work_dir / "_textract_cache.json" |
| review_pdf = work_dir / f"{local_pdf.stem}-review.pdf" |
| redactions_json = work_dir / "redactions.json" |
| if max_pages: |
| with fitz.open(str(local_pdf)) as _doc: |
| actual_pages = min(max_pages, len(_doc)) |
| source_pdf = work_dir / f"_subset_{actual_pages}pages.pdf" |
| else: |
| source_pdf = local_pdf |
|
|
| log_q: queue.Queue = queue.Queue() |
| logs: list = [] |
| result: dict = {} |
|
|
| def log(msg): |
| log_q.put(("log", msg)) |
|
|
| def worker(): |
| s3, textract, cm_client = _make_clients(creds) |
| blocks = get_textract_blocks( |
| s3, |
| textract, |
| local_pdf, |
| s3_bucket, |
| unique_key, |
| textract_cache, |
| max_pages, |
| POLL_INTERVAL, |
| log=log, |
| ) |
| page_data = build_page_data(blocks) |
| log(f" {len(page_data)} pages indexed.") |
| redactions = collect_redactions(page_data, cm_client, log=log) |
| redactions_json.write_text( |
| json.dumps({"redactions": redactions}, indent=2), encoding="utf-8" |
| ) |
| log(f" Saved: {redactions_json}") |
| generate_review_pdf(redactions, source_pdf, review_pdf, log=log) |
| result["review_pdf"] = str(review_pdf) |
| result["redactions_text"] = redactions_json.read_text(encoding="utf-8") |
| result["work_dir"] = str(work_dir) |
| result["source_pdf"] = str(source_pdf) |
|
|
| thread = threading.Thread(target=_stream_worker, args=(worker, log_q)) |
| thread.start() |
|
|
| for _done, log_text in _drain_queue(log_q, logs): |
| yield ( |
| log_text, |
| result.get("review_pdf"), |
| result.get("redactions_text", ""), |
| result.get("work_dir", ""), |
| result.get("source_pdf", ""), |
| ) |
|
|
| thread.join() |
|
|
|
|
| |
|
|
|
|
| def mark_adjusted(text: str, has_adjusted: bool, session_id: str = ""): |
| """Log once per session when the user first edits the redactions JSON.""" |
| if not has_adjusted and text.strip(): |
| _event("redactions_adjusted", session_id) |
| return True |
| return has_adjusted |
|
|
|
|
| |
|
|
|
|
| def run_redact(redactions_text: str, work_dir_str: str, source_pdf_str: str, session_id: str = ""): |
| if not work_dir_str: |
| yield "Run the Review phase first.", None |
| return |
| if not redactions_text.strip(): |
| yield "Redactions JSON is empty.", None |
| return |
| try: |
| json.loads(redactions_text) |
| except json.JSONDecodeError as exc: |
| yield f"Invalid JSON: {exc}", None |
| return |
|
|
| work_dir = Path(work_dir_str) |
| source_pdf = Path(source_pdf_str) |
| redactions_json = work_dir / "redactions.json" |
| redactions_json.write_text(redactions_text, encoding="utf-8") |
| output_pdf = work_dir / f"{source_pdf.stem}-redacted.pdf" |
|
|
| log_q: queue.Queue = queue.Queue() |
| logs: list = [] |
| result: dict = {} |
|
|
| def log(msg): |
| log_q.put(("log", msg)) |
|
|
| def worker(): |
| t0 = time.time() |
| apply_final_redactions(redactions_json, source_pdf, output_pdf, log=log) |
| result["output_pdf"] = str(output_pdf) |
| _event("redact_completed", session_id, duration_s=int(time.time() - t0)) |
|
|
| thread = threading.Thread(target=_stream_worker, args=(worker, log_q)) |
| thread.start() |
|
|
| for _done, log_text in _drain_queue(log_q, logs): |
| yield log_text, result.get("output_pdf") |
|
|
| thread.join() |
|
|
|
|
| |
|
|
| with gr.Blocks(title="Document Redaction Agent") as demo: |
| gr.HTML("<h1 style='text-align:center;'> PDF Redaction Agent </h1>") |
| gr.Markdown( |
| "<center>This agent utilizes Textract and AWS Comprehend to extract content from PDFs and " |
| "then apply redactions. We use a two phase approach outlined below. Please note " |
| "that comprehend is not available in all regions.</center>" |
| ) |
| gr.Markdown( |
| "**Begin:** Start by logging into your AWS account via Identity Center or Static Credentials." |
| " Make sure that the user has access to S3, Textract and Comprehend and is in a region where all services are supported. \n" |
| "**Phase 1:** Upload a PDF to detect PHI and generate a highlighted review PDF. \n" |
| "**Phase 2:** Edit the redactions JSON to exclude false positives, then apply " |
| "permanent redactions." |
| ) |
|
|
| |
| creds_state = gr.State({}) |
| work_dir_state = gr.State("") |
| source_pdf_state = gr.State("") |
| session_id_state = gr.State(lambda: uuid.uuid4().hex[:8]) |
| has_adjusted_state = gr.State(False) |
|
|
| |
| with gr.Accordion("AWS Authentication", open=True, visible=SHOW_AUTH): |
| auth_status = gr.Markdown("**Status:** Unknown β click Check Connection") |
| check_btn = gr.Button("Check Connection", size="sm") |
|
|
| with gr.Tab("SSO / Identity Center Login"): |
| gr.Markdown( |
| "Fill in your IAM Identity Center details and click **Login with AWS SSO**. " |
| "A browser window will open for you to authorize β no AWS CLI required. " |
| "Find these values in the AWS Console under **IAM Identity Center β Settings**." |
| ) |
| with gr.Row(): |
| sso_start_url_in = gr.Textbox( |
| label="SSO Start URL", |
| placeholder="https://my-portal.awsapps.com/start", |
| scale=3, |
| ) |
| sso_idc_region_in = gr.Textbox( |
| label="Identity Center Region", |
| placeholder="us-east-1", |
| scale=1, |
| ) |
| with gr.Row(): |
| sso_account_id_in = gr.Textbox( |
| label="AWS Account ID", |
| placeholder="123456789012", |
| scale=1, |
| ) |
| sso_role_name_in = gr.Textbox( |
| label="Permission Set / Role Name", |
| placeholder="AdministratorAccess", |
| scale=2, |
| ) |
| sso_region_in = gr.Textbox( |
| label="Working Region (for S3 / Textract / Comprehend)", |
| value=REGION, |
| ) |
| sso_btn = gr.Button("Login with AWS SSO", variant="primary") |
| sso_auth_link = gr.HTML(_AUTH_LINK_EMPTY) |
| sso_log = gr.Textbox( |
| label="Login Progress", |
| lines=6, |
| interactive=False, |
| ) |
|
|
| with gr.Tab("Static Credentials"): |
| gr.Markdown( |
| "Enter AWS credentials directly. Temporary credentials (from `aws sts " |
| "assume-role` or the SSO credential file) require the Session Token." |
| ) |
| with gr.Row(): |
| key_id_in = gr.Textbox(label="Access Key ID", scale=2) |
| region_in = gr.Textbox(label="Region", value=REGION, scale=1) |
| secret_in = gr.Textbox(label="Secret Access Key", type="password") |
| token_in = gr.Textbox(label="Session Token (optional)") |
| static_btn = gr.Button("Apply Credentials", variant="primary") |
| static_msg = gr.Markdown("") |
|
|
| |
| with gr.Accordion("Settings", open=True): |
| s3_url_in = gr.Textbox( |
| label="S3 Staging Location", |
| placeholder="s3://your-bucket-name/textract-input/", |
| info=( |
| "The S3 folder where the PDF will be uploaded for Textract to process. " |
| "Must be in the same AWS account and region as your credentials. " |
| "Format: s3://bucket-name/optional-prefix/" |
| ), |
| ) |
| max_pages_in = gr.Number( |
| label="Max Pages (0 = entire document)", |
| value=DEFAULT_MAX_PAGES, |
| minimum=0, |
| precision=0, |
| info="Limit processing to the first N pages. Useful for large documents or cost control.", |
| ) |
|
|
| |
| with gr.Tab("Phase 1 β Review"): |
| pdf_input = gr.File(label="Upload PDF", file_types=[".pdf"], type="filepath") |
| review_btn = gr.Button("Run Review", variant="primary") |
| review_log = gr.Textbox( |
| label="Progress", lines=10, interactive=False, show_copy_button=True |
| ) |
| review_pdf_out = gr.File(label="Review PDF (download)", interactive=False) |
| redactions_editor = gr.Textbox( |
| label='Redactions JSON β set "exclude": true on false positives', |
| lines=20, |
| interactive=True, |
| ) |
|
|
| |
| with gr.Tab("Phase 2 β Redact"): |
| gr.Markdown( |
| "Edit the **Redactions JSON** in Phase 1 to mark false positives " |
| '(`"exclude": true`), then click **Apply Redactions**.' |
| ) |
| redact_btn = gr.Button("Apply Redactions", variant="primary") |
| redact_log = gr.Textbox( |
| label="Progress", lines=8, interactive=False, show_copy_button=True |
| ) |
| redacted_pdf_out = gr.File(label="Redacted PDF (download)", interactive=False) |
|
|
| |
| check_btn.click( |
| fn=check_connection, |
| inputs=[creds_state], |
| outputs=[auth_status, creds_state], |
| ) |
|
|
| sso_btn.click( |
| fn=start_sso_device_flow, |
| inputs=[ |
| sso_start_url_in, |
| sso_idc_region_in, |
| sso_account_id_in, |
| sso_role_name_in, |
| sso_region_in, |
| creds_state, |
| session_id_state, |
| ], |
| outputs=[sso_log, sso_auth_link, creds_state, auth_status], |
| ) |
|
|
| static_btn.click( |
| fn=apply_static_creds, |
| inputs=[key_id_in, secret_in, token_in, region_in, creds_state, session_id_state], |
| outputs=[static_msg, creds_state, auth_status], |
| ) |
|
|
| review_btn.click( |
| fn=run_review, |
| inputs=[pdf_input, creds_state, s3_url_in, max_pages_in, session_id_state], |
| outputs=[ |
| review_log, |
| review_pdf_out, |
| redactions_editor, |
| work_dir_state, |
| source_pdf_state, |
| ], |
| ) |
|
|
| redactions_editor.change( |
| fn=mark_adjusted, |
| inputs=[redactions_editor, has_adjusted_state, session_id_state], |
| outputs=[has_adjusted_state], |
| ) |
|
|
| redact_btn.click( |
| fn=run_redact, |
| inputs=[redactions_editor, work_dir_state, source_pdf_state, session_id_state], |
| outputs=[redact_log, redacted_pdf_out], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.queue().launch( |
| server_name="0.0.0.0", |
| server_port=int(os.environ.get("PORT", 7860)), |
| ) |
|
|