commit b8bb31842a0d3945a9d036d24921c6f1e8885c41 Author: William Bruno Date: Thu Jun 11 01:40:32 2026 -0400 First Commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f9ca00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements file and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code +COPY . . + +# Expose the port the app runs on +EXPOSE 5000 + +# Command to run the application using Gunicorn (production ready) +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..54d9f22 --- /dev/null +++ b/app.py @@ -0,0 +1,334 @@ +import sys +import gc +import re +import json +import io +import logging +from pathlib import Path +from datetime import datetime +import pdfplumber +from pypdf import PdfReader, PdfWriter +from pypdf.constants import UserAccessPermissions +from pypdf.generic import NameObject, BooleanObject +from typing import TypedDict, Literal +from flask import Flask, request, send_file, abort +from werkzeug.utils import secure_filename + +# --- Logging Configuration --- +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + handlers=[logging.StreamHandler(sys.stdout)] +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Target template name inside the container working directory +TEMPLATE_NAME = "pftr_template.pdf" + +InterpretationType = Literal[ + "normal", + "pvd", + "cw_neuro_disorder", + "ild_pn", + "asthma_cb", + "emphysema" +] + + +class ParsedPdfData(TypedDict): + patient_name: str + date_of_service: str + date_of_birth: str + smoker: str + highlighted_box: InterpretationType + + +def process_pft_pdf(input_bytes: bytes) -> io.BytesIO: + text_mapping: dict[str, InterpretationType] = { + "normal": "normal", + "pv disorders": "pvd", + "pulmonary vascular": "pvd", + "cw and nm disorders": "cw_neuro_disorder", + "chest wall": "cw_neuro_disorder", + "neuromuscular": "cw_neuro_disorder", + "ild pneumonitis": "ild_pn", + "interstitial lung disorders": "ild_pn", + "pneumonitis": "ild_pn", + "asthma cb": "asthma_cb", + "asthma": "asthma_cb", + "chronic bronchitis": "asthma_cb", + "emphysema": "emphysema", + } + + record: ParsedPdfData = { + "patient_name": "", + "date_of_service": "", + "date_of_birth": "", + "smoker": "", + "highlighted_box": "normal" + } + + # Use a detached context for pdfplumber to read the file metadata + with pdfplumber.open(io.BytesIO(input_bytes)) as pdf: + if len(pdf.pages) < 2: + logger.error( + "Expected a 2-page PFT report, found %s page(s).", len(pdf.pages)) + raise ValueError( + f"Expected a 2-page PFT report, found {len(pdf.pages)} page(s).") + + page1 = pdf.pages[0] + page2 = pdf.pages[1] + + # --- 1. FULL TEXT METADATA EXTRACTION (Page 1) --- + full_text_p1 = page1.extract_text() + + if full_text_p1: + lines = [line.strip() + for line in full_text_p1.split("\n") if line.strip()] + for line in lines: + if "ID: #" in line: + record["patient_name"] = line.split("ID:")[0].strip() + dob_match = re.search(r"\((\d{1,2}/\d{1,2}/\d{4})\)", line) + if dob_match: + record["date_of_birth"] = dob_match.group(1) + break + + dos_match = re.search( + r"Test Date:\s*(\d{1,2}/\d{1,2}/\d{4})", full_text_p1) + if dos_match: + record["date_of_service"] = dos_match.group(1) + + smoker_match = re.search( + r"Smoker\s*:?\s*(Yes|No|Former)", full_text_p1, re.IGNORECASE) + if smoker_match: + record["smoker"] = smoker_match.group(1).capitalize() + + # --- 2. INTERPRETATION DIAGRAM PROCESSING (Page 2) --- + text_p2 = page2.extract_text() + detected_box: InterpretationType = "normal" + box_found = False + + if text_p2 and "System Summary Interpretation" in text_p2: + summary_text = text_p2.split( + "System Summary Interpretation")[-1].strip().lower() + for phrase, token in text_mapping.items(): + if phrase in summary_text: + detected_box = token + box_found = True + break + + if not box_found: + target_boxes = [ + {"token": "normal", "x0": 300, "x1": 360, "top": 530, "bottom": 570}, + {"token": "pvd", "x0": 350, "x1": 420, "top": 580, "bottom": 630}, + {"token": "cw_neuro_disorder", "x0": 420, + "x1": 490, "top": 580, "bottom": 630}, + {"token": "ild_pn", "x0": 490, "x1": 560, "top": 580, "bottom": 630}, + {"token": "asthma_cb", "x0": 560, + "x1": 630, "top": 580, "bottom": 630}, + {"token": "emphysema", "x0": 630, + "x1": 710, "top": 580, "bottom": 630} + ] + + shaded_elements = [r for r in page2.rects if r.get( + "non_stroke_color") is not None] + for shape in shaded_elements: + for box in target_boxes: + if (box["x0"] <= shape["x0"] <= box["x1"]) and (box["top"] <= shape["top"] <= box["bottom"]): + detected_box = box["token"] # type: ignore + box_found = True + break + + record["highlighted_box"] = detected_box + + logger.info("PDF parsed, data found: %s", + json.dumps(record, ensure_ascii=False)) + + # --- 3. WRITING TO TEMPLATE --- + template_file_path = Path(TEMPLATE_NAME) + if not template_file_path.is_file(): + logger.error("Template file does not exist: %s", TEMPLATE_NAME) + raise FileNotFoundError( + f"Template file '{TEMPLATE_NAME}' missing inside container workspace.") + + writer = PdfWriter() + + template_reader = PdfReader(TEMPLATE_NAME) + writer.append(template_reader) + + patient_data = {"patient_name": "", + "date_of_service": "", "date_of_birth": ""} + check_boxes = { + "results_cw_neuro_disorder": "/Off", "results_ild_pn": "/Off", "results_asthma_cb": "/Off", + "results_emphysema": "/Off", "refer_pulmo": "/Off", "refer_smoking": "/Off", + "results_pvd": "/Off", "results_normal": "/Off" + } + + formatted_dos = datetime.strptime(record.get( + "date_of_service"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_service") else "" + formatted_dob = datetime.strptime(record.get( + "date_of_birth"), "%m/%d/%Y").strftime("%m/%d/%Y") if record.get("date_of_birth") else "" + + patient_data |= { + "patient_name": record.get("patient_name").title() if record.get("patient_name") else "", + "date_of_service": formatted_dos, + "date_of_birth": formatted_dob, + } + + check_boxes |= { + "refer_smoking": "/Yes" if record.get("smoker") == "Yes" else "/Off"} + + match record["highlighted_box"]: + case "normal": + check_boxes |= {"results_normal": "/Yes"} + case "pvd": + check_boxes |= {"results_pvd": "/Yes", "refer_pulmo": "/Yes"} + case "cw_neuro_disorder": + check_boxes |= { + "results_cw_neuro_disorder": "/Yes", "refer_pulmo": "/Yes"} + case "ild_pn": + check_boxes |= {"results_ild_pn": "/Yes", "refer_pulmo": "/Yes"} + case "asthma_cb": + check_boxes |= {"results_asthma_cb": "/Yes", "refer_pulmo": "/Yes"} + case "emphysema": + check_boxes |= {"results_emphysema": "/Yes", "refer_pulmo": "/Yes"} + + form_data = patient_data | check_boxes + + writer.get_fields() + + writer.update_page_form_field_values( + writer.pages[0], + form_data, + auto_regenerate=False, + flatten=True + ) + + for page in writer.pages: + if "/Annots" in page: + for annot in page["/Annots"]: + annot_obj = annot.get_object() + if annot_obj.get("/t") in check_boxes: + field_name = annot_obj["/T"] + target_value = check_boxes[field_name] + annot_obj.update({ + NameObject("/V"): NameObject(target_value), + NameObject("/AS"): NameObject(target_value) + }) + + try: + writer.set_need_appearances_writer() + except AttributeError: + if "/AcroForm" in writer._root_object: + writer._root_object["/AcroForm"].update({ + NameObject("/NeedAppearances"): BooleanObject(True) + }) + + # Strip widget annotations + writer.remove_annotations(subtypes="/Widget") + + # Append raw input pages directly onto the end of our current working context + raw_results_reader = PdfReader(io.BytesIO(input_bytes)) + writer.append(raw_results_reader) + + # Secure document permissions + permissions = UserAccessPermissions.PRINT | UserAccessPermissions.EXTRACT + owner_pw = "heather" + + writer.encrypt( + user_password="", + owner_password=owner_pw, + permissions_flag=permissions + ) + + # Output final joined stream directly to Flask + output_stream = io.BytesIO() + writer.write(output_stream) + output_stream.seek(0) + + logger.info("PDTR successfully prepended to PFT result PDF.") + return output_stream + + +@app.route('/process-pdf', methods=['POST']) +def upload_file(): + if 'file' not in request.files: + logger.info("No file part in request") + return abort(400, "No file part in the request") + + file = request.files['file'] + if file.filename == '': + logger.info("No file selector") + return abort(400, "No file selected") + + if file and file.filename.endswith('.pdf'): + original_filename = secure_filename(file.filename) + logger.info("Received PDF: %s", original_filename) + + try: + # Read input file directly into memory bytes + raw_input_bytes = file.read() + + # Execute logic sequentially + processed_file_stream = process_pft_pdf(raw_input_bytes) + + logger.info("Returning processed PDF file: %s", original_filename) + return send_file( + processed_file_stream, + mimetype='application/pdf', + as_attachment=True, + download_name=original_filename + ) + + except Exception as e: + logger.exception("Error during execution processing pipeline") + return abort(500, f"Error processing file: {str(e)}") + return abort(400, "Invalid file format. Only PDF files are accepted.") + + +@app.route('/', methods=['GET']) +def index(): + # Simple, clean HTML page with an upload form for manual testing + return """ + + + + + + PFT Result PDF Processing Service + + + +

PFT Report Processor

+

This automated API service parses PFT reports and appends formatted data templates.

+
+

Manual Test Upload

+
+ + + +
+
+ + + """ + + +@app.teardown_request +def spi_clear_memory(exception=None): + """Runs automatically after every single request.""" + # Force collection of unreferenced objects + gc.collect() + + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=5000) diff --git a/code.gs b/code.gs new file mode 100644 index 0000000..b591788 --- /dev/null +++ b/code.gs @@ -0,0 +1,144 @@ +function processSpecificFolder() { + const DATED_FOLDER_ID = ""; + const API_URL = "https://pft2pftr.williambr.uno/process-pdf"; + + const datedFolder = DriveApp.getFolderById(DATED_FOLDER_ID); + processPdfFilesInFolder(datedFolder, API_URL); +} + +function processNewPdfs() { + // 1. Configuration + const ACTIVE_PROVIDERS_FOLDER_ID = "12Ppr39dN-mdzRKMxBhABgFkWmfMX_f7O"; + const API_URL = "https://pft2pftr.williambr.uno/process-pdf"; + + // Set cutoff date to May 1, 2026 + const CUTOFF_DATE = new Date(2026, 5, 1); // Note: Month is 0-indexed (4 = May) + + const activeProvidersFolder = DriveApp.getFolderById(ACTIVE_PROVIDERS_FOLDER_ID); + const providerFolders = activeProvidersFolder.getFolders(); + + // 2. Loop through Provider Folders (e.g., NP jeong, Dr Santos) + while (providerFolders.hasNext()) { + const providerFolder = providerFolders.next(); + Logger.log("Scanning provider folder: " + providerFolder.getName()); + + const dateFolders = providerFolder.getFolders(); + + // 3. Loop through Date Folders (e.g., 05.21.2026) + while (dateFolders.hasNext()) { + const dateFolder = dateFolders.next(); + const folderName = dateFolder.getName(); + const folderDate = parseFolderNameAsDate(folderName); + + // Skip if the folder name is not a valid date format + if (!folderDate) { + Logger.log(" Skipping folder (Not a valid MM.DD.YYYY date): " + folderName); + continue; + } + + // 4. Date Filter: Check if folder is 05.01.2026 or newer + if (folderDate >= CUTOFF_DATE) { + Logger.log(" Processing valid date folder: " + folderName); + processPdfFilesInFolder(dateFolder, API_URL); + } else { + Logger.log(" Skipping older folder: " + folderName); + } + } + } +} + +// Converts "05.21.2026" string format into a real JavaScript Date object +function parseFolderNameAsDate(folderName) { + const parts = folderName.split("."); + if (parts.length !== 3) return null; + + const month = parseInt(parts[0], 10) - 1; // 0-indexed months + const day = parseInt(parts[1], 10); + const year = parseInt(parts[2], 10); + + if (isNaN(month) || isNaN(day) || isNaN(year)) return null; + + return new Date(year, month, day); +} + +// Processes the actual PDF files inside the matching date folders +function processPdfFilesInFolder(folder, apiUrl) { + const files = folder.getFiles(); + + while (files.hasNext()) { + const file = files.next(); + + if (file.getMimeType() === MimeType.PDF) { + if (file.getDescription() === "API_REPORT_PROCESSED") { + Logger.log("Skipping " + file.getName() + " - already processed."); + continue; // Skip to the next file + } + sendAndOverwritePdf(file, apiUrl); + } + } +} + +function getPdfPageCount(file) { + try { + const blob = file.getBlob(); + const data = blob.getDataAsString(); + const matches = data.match(/\/Type\s*\/Pages[\s\S]*?\/Count\s*(\d+)/g); + + if (matches && matches.length > 0) { + const countMatch = matches[matches.length - 1].match(/\/Count\s*(\d+)/); + if (countMatch) { + return parseInt(countMatch, 10); + } + } + return -1; + } catch (e) { + Logger.log("Error reading page count for " + file.getName() + ": " + e.message); + return -1; + } +} + +function sendAndOverwritePdf(file, apiUrl) { + const fileId = file.getId(); + const fileName = file.getName(); + + try { + const pdfBlob = file.getBlob(); + const formData = { "file": pdfBlob }; + const options = { + "method": "POST", + "payload": formData, + "muteHttpExceptions": true + }; + + Logger.log(" Sending " + fileName + " to API..."); + const response = UrlFetchApp.fetch(apiUrl, options); + + if (response.getResponseCode() !== 200) { + Logger.log(" API Error for " + fileName + ": " + response.getContentText()); + return; + } + + const responseBlob = response.getBlob(); + if (responseBlob.getContentType() !== MimeType.PDF) { + Logger.log(" Skipping overwrite. API did not return a valid PDF."); + return; + } + + const updateUrl = "https://www.googleapis.com/upload/drive/v3/files/" + fileId + "?uploadType=media"; + const updateOptions = { + "method": "PATCH", + "headers": { + "Authorization": "Bearer " + ScriptApp.getOAuthToken(), + "Content-Type": MimeType.PDF + }, + "payload": responseBlob.getBytes() + }; + + UrlFetchApp.fetch(updateUrl, updateOptions); + file.setDescription("API_REPORT_PROCESSED"); + Logger.log(" Successfully processed and overwritten: " + fileName); + + } catch (e) { + Logger.log(" Failed to process file " + fileName + ": " + e.message); + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..512018e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + pft2pftr: + build: . + container_name: pft2pftr + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik_proxy" + - "traefik.http.routers.grafana_http.rule=Host(`pft2pftr.williambr.uno`)" + - "traefik.http.routers.grafana_http.entryPoints=web" + - "traefik.http.routers.grafana_http.middlewares=https_redirect@file" + - "traefik.http.routers.grafana_https.tls.certresolver=myresolver" + - "traefik.http.routers.grafana_https.rule=Host(`pftr2pftr.williambr.uno`)" + - "traefik.http.routers.grafana_https.entryPoints=web-secure" + - "traefik.http.routers.grafana_https.middlewares=lan_whitelist@file" + - "traefik.http.routers.grafana_https.tls=true" + - "traefik.http.services.grafana.loadbalancer.server.port=5000" + networks: + - traefik_proxy +networks: + traefik_proxy: + external: true + name: traefik_proxy diff --git a/pftr_template.pdf b/pftr_template.pdf new file mode 100644 index 0000000..2cd8d57 Binary files /dev/null and b/pftr_template.pdf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..07bbf62 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.0 +gunicorn==23.0.0 +pypdf==6.12.2 +pdfplumber==0.11.9