import os from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() import json import torch import re import numpy as np import pandas as pd from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session from transformers import AutoTokenizer, AutoModelForSequenceClassification from docx import Document from sklearn.model_selection import train_test_split from torch.utils.data import Dataset, DataLoader import torch.optim as optim import torch.nn as nn from tqdm import tqdm import openai from openai import OpenAI import time from huggingface_hub import login import logging from functools import wraps import requests from safetensors.torch import save_file # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler("app.log"), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Modify environment variable handling OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") if not OPENAI_API_KEY: raise ValueError("OPENAI_API_KEY environment variable is not set. Please set it in your .env file.") os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY client = OpenAI( api_key=OPENAI_API_KEY, ) # Model configuration MODEL_NAME = 'dmis-lab_biobert-v1.1_20250316_195322' MODIFIER_MODEL_NAME = 'dmis-lab_biobert-v1.1_modifier_20250406_160702' OUTPUT_DIR = os.environ.get('CCTA_OUTPUT_DIR', 'Saved Models') # Set device device = torch.device('cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu') logger.info(f"Using device: {device}") # Label mapping label_mapping = { 0: "0", 1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "N" } reverse_label_mapping = {v: k for k, v in label_mapping.items()} # Modifier mapping modifier_mapping = { 0: "P1", # Mild plaque burden (CAC 0-99) 1: "P2", # Moderate plaque burden (CAC 100-399) 2: "P3", # Severe plaque burden (CAC 400-999) 3: "P4" # Extensive plaque burden (CAC ≥1000) } # Load model def load_model(model_name, num_labels): try: # Direct path to model model_path = os.path.join(OUTPUT_DIR, model_name.replace("/", "_")) if not os.path.exists(model_path): raise FileNotFoundError(f"No model found at {model_path}") logger.info(f"Loading model from {model_path}") tokenizer = AutoTokenizer.from_pretrained(model_path) # Check if safetensors version exists safetensors_path = os.path.join(model_path, "model.safetensors") pytorch_path = os.path.join(model_path, "pytorch_model.bin") # If PyTorch model exists but safetensors doesn't, convert it if os.path.exists(pytorch_path) and not os.path.exists(safetensors_path): logger.info(f"Converting PyTorch model to safetensors format") # Load the PyTorch model temp_model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=num_labels ) # Extract state dict state_dict = temp_model.state_dict() # Save as safetensors save_file(state_dict, safetensors_path) logger.info(f"Model converted and saved to {safetensors_path}") # Load the model (will use safetensors if available) model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=num_labels ) model.to(device) model.eval() return model, tokenizer except Exception as e: logger.error(f"Error loading model: {str(e)}") raise # Load models and tokenizers try: # Load classifier model for CADRADS score classifier_model, classifier_tokenizer = load_model(MODEL_NAME, len(label_mapping)) logger.info("Classifier model and tokenizer loaded successfully") # Load modifier model modifier_model, modifier_tokenizer = load_model(MODIFIER_MODEL_NAME, len(modifier_mapping)) logger.info("Modifier model and tokenizer loaded successfully") except Exception as e: logger.error(f"Failed to load models: {str(e)}") raise # Create uploads directory if it doesn't exist UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') os.makedirs(UPLOAD_FOLDER, exist_ok=True) app = Flask(__name__) # Get authentication credentials from environment variables username = os.getenv('BASIC_AUTH_USERNAME') password = os.getenv('BASIC_AUTH_PASSWORD') # Check if credentials are set if not username or not password: logger.error("BASIC_AUTH_USERNAME or BASIC_AUTH_PASSWORD environment variables are not set") raise ValueError("Authentication credentials are required. Set BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD in environment variables") # Use environment variable for secret key if available, otherwise generate a random one app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', os.urandom(24)) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # Custom login required decorator def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('login', next=request.url)) return f(*args, **kwargs) return decorated_function history = [] def extract_text_from_docx(docx_path): try: doc = Document(docx_path) full_text = [] # Extract text from paragraphs for para in doc.paragraphs: if para.text.strip(): # Only add non-empty paragraphs full_text.append(para.text) # Extract text from tables for table in doc.tables: for row in table.rows: row_text = [] for cell in row.cells: # Extract text from paragraphs inside cells cell_text = ' '.join(p.text.strip() for p in cell.paragraphs if p.text.strip()) if cell_text: row_text.append(cell_text) if row_text: full_text.append(' | '.join(row_text)) # Use pipe to separate cell content extracted_text = '\n'.join(full_text) # Add a check to warn about potentially empty documents if len(extracted_text.strip()) < 10: # Less than 10 characters is suspiciously small logger.warning(f"Very little text extracted ({len(extracted_text)} chars). Document may be corrupt or empty.") return extracted_text except Exception as e: logger.error(f"Error extracting text from document: {str(e)}") raise def generate_json_output(report_text, prompt_template): prompt = prompt_template.replace("{report_text}", report_text) try: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt} ], temperature=0.3, max_tokens=6500, top_p=0.9, frequency_penalty=0.2, presence_penalty=0, ) response_text = response.choices[0].message.content cleaned_response_text = response_text.strip() if cleaned_response_text.startswith("```") and cleaned_response_text.endswith("```"): cleaned_response_text = cleaned_response_text[3:-3].strip() json_match = re.search(r'{.*}', cleaned_response_text, re.DOTALL) if json_match: cleaned_response_text = json_match.group(0) else: raise json.JSONDecodeError("No valid JSON object found", cleaned_response_text, 0) report_json = json.loads(cleaned_response_text) return report_json except json.JSONDecodeError as e: logger.error(f"JSON parsing error: {str(e)}") return {"error": "Unable to parse response"} except Exception as e: logger.error(f"Error generating JSON output: {str(e)}") return {"error": f"Error generating output: {str(e)}"} def create_text_representation(report_json): """Create a consistent text representation for prediction, using same format as in training""" try: combined_text = ( f"Technical Quality: {report_json['technical_quality']} | " f"Left Main: {report_json['left_main']} | " f"LAD: {report_json['lad']} | " f"LCX: {report_json['lcx']} | " f"RCA: {report_json['rca']} | " f"Stenosis Summary: {report_json['stenosis_summary']} | " f"Calcium Score: {report_json['calcium_score']} | " f"Cardiac Valves: {report_json['cardiac_valves']} | " f"Pericardium: {report_json['pericardium']} | " f"Dominance: {report_json['dominance']} | " f"Impression: {report_json['impression']}" ) return combined_text except KeyError as e: logger.error(f"Missing field in report JSON: {str(e)}") raise def predict_cadrads_score(report_json): try: # Create text representation consistent with training combined_text = create_text_representation(report_json) with torch.no_grad(): # Predict CADRADS score with classifier model classifier_encoding = classifier_tokenizer(combined_text, truncation=True, padding='max_length', max_length=512, return_tensors='pt') classifier_input_ids = classifier_encoding['input_ids'].to(device) classifier_attention_mask = classifier_encoding['attention_mask'].to(device) classifier_outputs = classifier_model(classifier_input_ids, attention_mask=classifier_attention_mask) # Get CADRADS score (highest probability class) classifier_probabilities = torch.nn.functional.softmax(classifier_outputs.logits, dim=1) _, predicted_cadrads = torch.max(classifier_probabilities, dim=1) cadrads_class = predicted_cadrads.item() # Convert to label if cadrads_class in label_mapping: cadrads_score = label_mapping[cadrads_class] else: logger.warning(f"Predicted CADRADS class {cadrads_class} not in label mapping") cadrads_score = "Unknown" # If CADRADS score is 0 or N, don't use modifier if cadrads_score == "0" or cadrads_score == "N": logger.info(f"CADRADS score is {cadrads_score}, not displaying modifier") return cadrads_score, "None" # Only predict modifier if CADRADS score is not 0 or N modifier_encoding = modifier_tokenizer(combined_text, truncation=True, padding='max_length', max_length=512, return_tensors='pt') modifier_input_ids = modifier_encoding['input_ids'].to(device) modifier_attention_mask = modifier_encoding['attention_mask'].to(device) modifier_outputs = modifier_model(modifier_input_ids, attention_mask=modifier_attention_mask) # Get modifier (highest probability class) modifier_probabilities = torch.nn.functional.softmax(modifier_outputs.logits, dim=1) _, predicted_modifier = torch.max(modifier_probabilities, dim=1) modifier_class = predicted_modifier.item() # Convert to label if modifier_class in modifier_mapping: modifier = modifier_mapping[modifier_class] else: logger.warning(f"Predicted modifier class {modifier_class} not in modifier mapping") modifier = "None" return cadrads_score, modifier except Exception as e: logger.error(f"Error in prediction: {str(e)}") return "Error", "None" # Load prompt template try: prompt_template_path = "prompt_template_modifiers_new.md" with open(prompt_template_path, "r") as file: prompt_template = file.read() logger.info("Prompt template loaded successfully") except Exception as e: logger.error(f"Error loading prompt template: {str(e)}") prompt_template = None @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': input_username = request.form['username'] input_password = request.form['password'] if input_username == username and input_password == password: session['user_id'] = input_username next_page = request.args.get('next') if next_page and next_page.startswith('/'): return redirect(next_page) return redirect(url_for('home')) else: flash('Invalid username or password', 'danger') return render_template('login.html') @app.route('/logout') def logout(): session.pop('user_id', None) flash('You have been logged out', 'info') return redirect(url_for('login')) @app.route('/') @login_required def home(): return render_template('index.html', history=history) @app.route('/predict', methods=['POST']) @login_required def predict(): if not prompt_template: flash('Prompt template not loaded', 'error') return redirect(url_for('home')) if 'file' not in request.files: flash('No file selected', 'error') return redirect(url_for('home')) file = request.files['file'] if file.filename == '': flash('No file selected', 'error') return redirect(url_for('home')) if not file.filename.endswith('.docx'): flash('Invalid file format. Please upload a .docx file.', 'error') return redirect(url_for('home')) try: session['processing'] = True filename = os.path.join( app.config['UPLOAD_FOLDER'], file.filename ) os.makedirs(os.path.dirname(filename), exist_ok=True) file.save(filename) logger.info(f"File saved to {filename}") try: # Extract text from document report_text = extract_text_from_docx(filename) logger.info(f"Text extracted from {file.filename}") os.remove(filename) # Generate structured JSON from report text report_json = generate_json_output(report_text, prompt_template) if "error" in report_json: logger.error(f"Error in JSON generation: {report_json['error']}") flash('Error processing report', 'error') return redirect(url_for('home')) # Predict CADRADS score cadrads_score, modifier = predict_cadrads_score(report_json) # Log prediction logger.info(f"Prediction for {file.filename}: CADRADS {cadrads_score}, Modifier {modifier}") # Add to history report_id = len(history) + 1 history.append({ 'report_id': report_id, 'prediction': cadrads_score, 'modifier': modifier, 'text': report_text, 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), 'filename': file.filename, 'json_output': report_json # Store the JSON output in the report history }) flash('Report processed successfully!', 'success') return redirect(url_for('home')) except Exception as e: if os.path.exists(filename): os.remove(filename) logger.error(f"Error processing file content: {str(e)}") raise e except Exception as e: logger.error(f"Error in prediction process: {str(e)}") flash(f'Error processing file: {str(e)}', 'error') return redirect(url_for('home')) finally: session.pop('processing', None) @app.route('/view_report/') @login_required def view_report(report_id): report = next((item for item in history if item["report_id"] == report_id), None) if report: if 'mesa_results' not in report: report['mesa_results'] = None # Make the JSON data available to the template for extracting calcium score if 'json_output' in report: report['json_data'] = report['json_output'] return render_template('view_report.html', report=report) @app.route('/calculate_mesa_score', methods=['POST']) @login_required def calculate_mesa_score(): try: # Get form data from request data = request.json if not data: return jsonify({"error": "No data provided"}), 400 # Validate required fields required_fields = ['gender', 'age', 'ethnicity', 'diabetes', 'smoke', 'fhhx', 'chol', 'hdl', 'sbp', 'lipid', 'htnmed'] for field in required_fields: if field not in data: return jsonify({"error": f"Missing required field: {field}"}), 400 # Prepare API request api_endpoint = "https://www.mesa-nhlbi.org/MESACHDRiskCoronaryAge/api/score" # Add CAC score if provided cac_param = "" if 'cac' in data and data['cac']: cac_param = f"/{data['cac']}" else: cac_param = "/-9" # Default value for CAC if not provided # Construct full URL with parameters url = f"{api_endpoint}/{data['gender']}/{data['age']}/{data['ethnicity']}/{data['diabetes']}/{data['smoke']}/{data['fhhx']}/{data['chol']}/{data['hdl']}/{data['sbp']}/{data['lipid']}/{data['htnmed']}{cac_param}" # Call MESA API logger.info(f"Calling MESA API: {url}") response = requests.get(url) if response.status_code != 200: logger.error(f"MESA API error: {response.status_code} - {response.text}") return jsonify({"error": f"MESA API returned error: {response.status_code}"}), 500 # Process and return the results result = response.json() logger.info(f"MESA API result: {result}") # Extract and format relevant data formatted_result = { 'cac_riskscore': result.get('cac_riskscore', '-'), 'cac_coronary_age': result.get('cac_coronary_age', '-'), 'cac_age_diff': result.get('cac_age_diff', '-'), 'nocac_riskscore': result.get('nocac_riskscore', '-'), 'nocac_coronary_age': result.get('nocac_coronary_age', '-'), 'nocac_age_diff': result.get('nocac_age_diff', '-') } # Store the form values along with the MESA results in the report data report_id = request.json.get('report_id') if report_id: for item in history: if item['report_id'] == int(report_id): # Save both the results and the form input values item['mesa_results'] = formatted_result # Create a copy of the form data without the report_id form_data = {k: v for k, v in data.items() if k != 'report_id'} item['mesa_form_data'] = form_data logger.info(f"MESA results and form data stored for report {report_id}") break return jsonify(formatted_result) except Exception as e: logger.error(f"Error calculating MESA score: {str(e)}") return jsonify({"error": str(e)}), 500 @app.errorhandler(413) def too_large(e): flash('File is too large. Maximum size is 16MB.', 'error') return redirect(url_for('home')) @app.errorhandler(404) def not_found(e): return render_template('404.html'), 404 if __name__ == "__main__": # Get default port from environment variable or use 8080 default_port = int(os.environ.get('PORT', 8080)) port = default_port max_port_attempts = 10 # Try to find an available port for attempt in range(max_port_attempts): try: logger.info(f"Attempting to start Flask server on port {port}") app.run(host='0.0.0.0', port=port) break # If we get here, the app started successfully except OSError as e: if "Address already in use" in str(e) and attempt < max_port_attempts - 1: port = default_port + attempt + 1 logger.warning(f"Port {port-1} is already in use. Trying port {port}...") else: logger.error(f"Failed to start server after {attempt+1} attempts: {str(e)}") raise