Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -64,21 +64,22 @@ class Patient(db.Model):
|
|
| 64 |
# Create database tables if they don't exist
|
| 65 |
@app.before_request
|
| 66 |
def create_tables():
|
| 67 |
-
# Ensure this runs only once per
|
| 68 |
-
#
|
| 69 |
-
if not app
|
| 70 |
with app.app_context():
|
| 71 |
-
# Check if at least one table exists (e.g., the Patient table)
|
| 72 |
-
# This check is basic and might not be sufficient for complex setups
|
| 73 |
inspector = db.inspect(db.engine)
|
| 74 |
-
if not inspector.has_table("patient"):
|
| 75 |
logger.info("Creating database tables.")
|
| 76 |
db.create_all()
|
| 77 |
-
app.
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
-
upload_base = os.getenv('UPLOAD_DIR', '/tmp
|
| 81 |
-
upload_folder = os.path.join(upload_base, 'pdfs')
|
| 82 |
|
| 83 |
app.config['UPLOAD_FOLDER'] = upload_folder
|
| 84 |
|
|
@@ -92,6 +93,9 @@ AUTH_TOKEN = os.getenv('TWILIO_AUTH_TOKEN')
|
|
| 92 |
TWILIO_FROM = os.getenv('TWILIO_FROM_NUMBER')
|
| 93 |
TWILIO_TO = os.getenv('TWILIO_TO_NUMBER') # Hardcoded number as requested, consider making this configurable per patient
|
| 94 |
|
|
|
|
|
|
|
|
|
|
| 95 |
twilio_client = None
|
| 96 |
if ACCOUNT_SID and AUTH_TOKEN and TWILIO_FROM and TWILIO_TO:
|
| 97 |
try:
|
|
@@ -150,23 +154,18 @@ def extract_text_from_pdf(pdf_file):
|
|
| 150 |
text = ""
|
| 151 |
if pdf_reader.is_encrypted:
|
| 152 |
try:
|
| 153 |
-
# Attempt decryption
|
| 154 |
-
# or might fail differently. Explicitly try common/empty passwords.
|
| 155 |
-
# Note: Robust decryption requires knowing the password, which is not handled here.
|
| 156 |
-
# This attempt is basic.
|
| 157 |
try:
|
| 158 |
pdf_reader.decrypt('') # Try with empty password
|
|
|
|
| 159 |
except PyPDF2.errors.PasswordError:
|
| 160 |
-
# If empty password fails, and it's truly password protected
|
| 161 |
logger.warning("PDF is encrypted and requires a password.")
|
| 162 |
return "[PDF Content Unavailable: File is encrypted and requires a password]"
|
| 163 |
except Exception as dec_e:
|
| 164 |
-
# Handle other potential decryption errors
|
| 165 |
logger.error(f"Error during PDF decryption attempt: {dec_e}")
|
| 166 |
return "[PDF Content Unavailable: Error decrypting file]"
|
| 167 |
|
| 168 |
-
|
| 169 |
-
except Exception: # Catch any other unexpected error during decryption check
|
| 170 |
logger.error("Unexpected error during PDF decryption check.")
|
| 171 |
return "[PDF Content Unavailable: Unexpected error with encrypted file]"
|
| 172 |
|
|
@@ -179,9 +178,15 @@ def extract_text_from_pdf(pdf_file):
|
|
| 179 |
text += page_text + "\n"
|
| 180 |
except Exception as page_e:
|
| 181 |
logger.error(f"Error extracting text from page {page_num + 1}: {page_e}")
|
| 182 |
-
text += f"[Error extracting page {page_num + 1}]\n" # Add placeholder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
return text.strip() or "[No readable text found in PDF]"
|
| 185 |
except Exception as e:
|
| 186 |
logger.error(f"Error extracting PDF text: {e}", exc_info=True)
|
| 187 |
return f"[Error extracting PDF text: {e}]"
|
|
@@ -189,44 +194,34 @@ def extract_text_from_pdf(pdf_file):
|
|
| 189 |
|
| 190 |
def extract_care_plan_format(pdf_text):
|
| 191 |
"""Extract a general format template from PDF text by identifying common section headers."""
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
| 193 |
logger.info("No valid PDF text available to extract format.")
|
| 194 |
return None
|
| 195 |
|
| 196 |
-
#
|
| 197 |
-
#
|
| 198 |
-
#
|
| 199 |
-
#
|
| 200 |
-
#
|
| 201 |
-
potential_headers = re.findall(r'^\
|
| 202 |
|
| 203 |
if not potential_headers:
|
| 204 |
# Fallback: Look for lines that start with a capital letter and seem like standalone headers
|
| 205 |
-
# (e.g.,
|
| 206 |
-
#
|
| 207 |
-
#
|
| 208 |
-
|
| 209 |
-
fallback_headers_strict = re.findall(r'^[A-Z][A-Za-z\s]*[:.][ \t]*$', pdf_text, re.MULTILINE)
|
| 210 |
-
|
| 211 |
-
if fallback_headers_strict:
|
| 212 |
-
logger.info(f"Extracted potential headers (fallback - strict): {list(set(fallback_headers_strict))}")
|
| 213 |
-
# Remove ending colon/period and strip whitespace
|
| 214 |
-
unique_headers = sorted(list(set([re.sub(r'[:.\s]*$', '', h).strip() for h in fallback_headers_strict if re.sub(r'[:.\s]*$', '', h).strip()])))
|
| 215 |
-
format_template = "\n".join([f"{header.strip()}:" for header in unique_headers if header.strip()]) # Sort for consistency
|
| 216 |
-
return format_template if format_template.strip() else None
|
| 217 |
-
|
| 218 |
-
# Less strict fallback: Starts with Capital, seems like a short phrase line
|
| 219 |
-
fallback_headers_loose = re.findall(r'^[A-Z][A-Za-z\s]{3,}[ \t]*$', pdf_text, re.MULTILINE)
|
| 220 |
fallback_headers_loose = [h.strip() for h in fallback_headers_loose if h.strip()]
|
| 221 |
if fallback_headers_loose:
|
| 222 |
-
#
|
| 223 |
-
|
| 224 |
-
# Simple filter: check length and word count.
|
| 225 |
-
fallback_headers_loose = [h for h in fallback_headers_loose if len(h) > 5 and len(h.split()) < 6] # Example filter
|
| 226 |
if fallback_headers_loose:
|
| 227 |
logger.info(f"Extracted potential headers (fallback - loose): {list(set(fallback_headers_loose))}")
|
| 228 |
unique_headers = sorted(list(set([h.strip() for h in fallback_headers_loose if h.strip()])))
|
| 229 |
-
format_template = "\n".join([f"{header.strip()}:" for header in unique_headers if header.strip()])
|
| 230 |
return format_template if format_template.strip() else None
|
| 231 |
|
| 232 |
|
|
@@ -234,7 +229,8 @@ def extract_care_plan_format(pdf_text):
|
|
| 234 |
return None
|
| 235 |
|
| 236 |
# Use a set to get unique headers and sort them for consistency
|
| 237 |
-
|
|
|
|
| 238 |
|
| 239 |
if not unique_headers:
|
| 240 |
logger.info("Extracted headers are empty after cleaning.")
|
|
@@ -247,10 +243,9 @@ def extract_care_plan_format(pdf_text):
|
|
| 247 |
return format_template if format_template.strip() else None
|
| 248 |
|
| 249 |
|
| 250 |
-
# --- OPTIMIZED determine_patient_status FUNCTION ---
|
| 251 |
def determine_patient_status(original_plan, updated_plan, feedback):
|
| 252 |
"""
|
| 253 |
-
Determine patient status based on feedback, original plan, and the
|
| 254 |
Prioritizes emergency > deteriorating > improving. Checks feedback/original first,
|
| 255 |
then checks updated plan for emergency confirmation. Uses refined keyword matching.
|
| 256 |
"""
|
|
@@ -258,11 +253,10 @@ def determine_patient_status(original_plan, updated_plan, feedback):
|
|
| 258 |
|
| 259 |
feedback_lower = feedback.lower() if feedback else ""
|
| 260 |
original_plan_lower = original_plan.lower() if original_plan else ""
|
| 261 |
-
updated_plan_lower = updated_plan.lower() if updated_plan else ""
|
| 262 |
|
| 263 |
|
| 264 |
# Define robust keyword lists
|
| 265 |
-
# Emergency Keywords: Indicate immediate threat to life or limb, requires urgent medical intervention.
|
| 266 |
emergency_keywords = [
|
| 267 |
"severe chest pain", "heart attack", "sudden shortness of breath",
|
| 268 |
"difficulty breathing severely", "loss of consciousness", "unresponsive",
|
|
@@ -276,11 +270,11 @@ def determine_patient_status(original_plan, updated_plan, feedback):
|
|
| 276 |
"signs of shock", "severe dehydration symptoms", "acute change in mental status",
|
| 277 |
"unstable vitals", "rapidly worsening symptoms", "can't breathe", "chest tight severe",
|
| 278 |
"severe difficulty swallowing suddenly", "new onset paralysis", "severe burns",
|
| 279 |
-
"major trauma", "suspected poisoning", "overdose", "suicidal thoughts active",
|
| 280 |
-
"unstable", "critically ill", "life-threatening", "no pulse", "low oxygen saturation severe"
|
|
|
|
| 281 |
]
|
| 282 |
|
| 283 |
-
# Deteriorating Keywords: Indicate condition is worsening, not improving as expected, or new concerning symptoms. Requires prompt medical review.
|
| 284 |
deteriorating_keywords = [
|
| 285 |
"worsening", "increased pain", "not improving", "deteriorating",
|
| 286 |
"getting worse", "more frequent symptoms", "elevated", "higher",
|
|
@@ -293,10 +287,10 @@ def determine_patient_status(original_plan, updated_plan, feedback):
|
|
| 293 |
"tired all the time", "much weaker", "feeling noticeably worse", "consistent high blood pressure",
|
| 294 |
"uncontrolled blood sugar levels", "increased swelling", "persistent cough getting worse",
|
| 295 |
"unexplained weight loss significant", "gradual decline", "unmanaged symptoms",
|
| 296 |
-
"not resolving", "changes in condition", "difficulty performing daily tasks"
|
|
|
|
| 297 |
]
|
| 298 |
|
| 299 |
-
# Improvement Keywords: Indicate condition is getting better, symptoms are resolving, or goals are being met.
|
| 300 |
improvement_keywords = [
|
| 301 |
"improving", "better today", "reduced pain", "lower", "less frequent",
|
| 302 |
"healing well", "recovery on track", "making progress", "stable condition",
|
|
@@ -309,49 +303,37 @@ def determine_patient_status(original_plan, updated_plan, feedback):
|
|
| 309 |
"more energy", "walking further", "blood pressure normal range",
|
| 310 |
"blood sugar stable", "swelling reduced", "easier breathing", "cough improving",
|
| 311 |
"weight gain healthy", "feeling like myself again", "in remission", "managing well at home",
|
| 312 |
-
"tolerating well", "no issues reported", "feeling good", "symptoms gone"
|
|
|
|
| 313 |
]
|
| 314 |
|
| 315 |
-
# Helper to check if any keyword is found in the text
|
| 316 |
def check_keywords(text, keywords):
|
| 317 |
-
|
| 318 |
pattern = r'\b(?:' + '|'.join(re.escape(kw) for kw in keywords) + r')\b'
|
| 319 |
return re.search(pattern, text) is not None
|
| 320 |
|
| 321 |
# --- Classification Logic ---
|
| 322 |
|
| 323 |
-
#
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
# 2. Check for DETERIORATING status (Second Priority)
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
if is_deteriorating_initial:
|
| 331 |
-
logger.info("Status determined: DETERIORATING (keyword found in feedback/original).")
|
| 332 |
return "deteriorating"
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
if is_improving_initial:
|
| 338 |
-
logger.info("Status determined: IMPROVING (keyword found in feedback/original).")
|
| 339 |
return "improving"
|
| 340 |
|
| 341 |
-
# 1. Check for EMERGENCY status (Highest Priority)
|
| 342 |
-
# Check combined initial text for emergency keywords
|
| 343 |
-
is_emergency_initial = check_keywords(combined_initial_text_lower, emergency_keywords)
|
| 344 |
-
|
| 345 |
-
# Also, check if the *final generated plan* explicitly contains strong emergency keywords.
|
| 346 |
-
# This helps catch cases where the AI correctly inferred emergency from subtle cues
|
| 347 |
-
# or context, even if the patient's feedback wasn't explicitly critical *using the exact keywords*.
|
| 348 |
-
is_emergency_final_plan = check_keywords(updated_plan_lower, emergency_keywords)
|
| 349 |
-
|
| 350 |
-
if is_emergency_initial:
|
| 351 |
-
logger.info("Status determined: EMERGENCY (keyword found in feedback/original or final plan).")
|
| 352 |
-
return "emergency"
|
| 353 |
-
|
| 354 |
-
|
| 355 |
# 4. Default to STABLE if no specific status keywords are found
|
| 356 |
logger.info("Status determined: STABLE (no specific status keywords found).")
|
| 357 |
return "stable"
|
|
@@ -491,16 +473,21 @@ def generate_care_plan_pdf(patient_info, care_plan_text, status):
|
|
| 491 |
|
| 492 |
# Check for section headers (e.g., "MEDICATIONS:", "ASSESSMENT:")
|
| 493 |
# Look for lines starting with one or more uppercase words followed by a colon or period
|
| 494 |
-
header_match = re.match(r'
|
| 495 |
if header_match:
|
| 496 |
# Remove trailing colon or period for cleaner heading
|
| 497 |
header_text = re.sub(r'[:.\s]*$', '', stripped_line).strip()
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
if bullet_text:
|
| 505 |
formatted_bullet_text = bullet_text.replace('\n', '<br/>')
|
| 506 |
story.append(Paragraph(f"• {formatted_bullet_text}", bullet_style))
|
|
@@ -508,9 +495,11 @@ def generate_care_plan_pdf(patient_info, care_plan_text, status):
|
|
| 508 |
# Handle cases with just a bullet point on a line
|
| 509 |
story.append(Paragraph("• ", bullet_style))
|
| 510 |
else:
|
| 511 |
-
# Handle regular paragraph text
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
| 514 |
|
| 515 |
|
| 516 |
story.append(Spacer(1, 20))
|
|
@@ -524,29 +513,31 @@ def generate_care_plan_pdf(patient_info, care_plan_text, status):
|
|
| 524 |
return buffer
|
| 525 |
except Exception as e:
|
| 526 |
logger.error(f"Error building PDF: {e}", exc_info=True)
|
|
|
|
| 527 |
error_buffer = io.BytesIO()
|
| 528 |
c = canvas.Canvas(error_buffer, pagesize=letter)
|
| 529 |
c.drawString(100, 750, "Error Generating Care Plan PDF")
|
| 530 |
c.drawString(100, 735, f"Details: {str(e)}")
|
|
|
|
| 531 |
c.save()
|
| 532 |
error_buffer.seek(0)
|
| 533 |
return error_buffer
|
| 534 |
|
| 535 |
|
| 536 |
def send_whatsapp_care_plan(patient_info, care_plan_text, status):
|
| 537 |
-
"""Send care plan via WhatsApp using Twilio with improved formatting"""
|
| 538 |
if not twilio_client:
|
| 539 |
logger.warning("Twilio client not configured. Cannot send WhatsApp message.")
|
| 540 |
-
return False, "Twilio client not configured."
|
| 541 |
|
| 542 |
if not TWILIO_TO or not TWILIO_FROM:
|
| 543 |
logger.warning("Twilio TO or FROM number not set.")
|
| 544 |
-
return False, "Twilio TO or FROM number not configured."
|
| 545 |
|
| 546 |
# Basic check for empty plan text before sending
|
| 547 |
if not care_plan_text or not care_plan_text.strip():
|
| 548 |
logger.warning("Care plan text is empty. Cannot send WhatsApp message.")
|
| 549 |
-
return False, "Care plan text is empty."
|
| 550 |
|
| 551 |
try:
|
| 552 |
status_emoji = {
|
|
@@ -569,12 +560,14 @@ def send_whatsapp_care_plan(patient_info, care_plan_text, status):
|
|
| 569 |
for line in formatted_plan.split('\n'):
|
| 570 |
stripped_line = line.strip()
|
| 571 |
# Check for lines that look like headers (starts with capital, ends with colon/period, possibly followed by whitespace)
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
| 574 |
header_text = re.sub(r'[:.\s]*$', '', stripped_line).strip()
|
| 575 |
if header_text: # Only bold if there's actual text before the colon/period
|
| 576 |
-
formatted_plan_lines.append(f"*{header_text}:*")
|
| 577 |
-
else: # Handle cases like just ":" on a line
|
| 578 |
formatted_plan_lines.append(line)
|
| 579 |
else:
|
| 580 |
formatted_plan_lines.append(line)
|
|
@@ -588,12 +581,20 @@ def send_whatsapp_care_plan(patient_info, care_plan_text, status):
|
|
| 588 |
message += f"*Status:* {status_emoji.get(status, 'Unknown')}\n\n"
|
| 589 |
# Add feedback if available
|
| 590 |
feedback_text = patient_info.get('feedback')
|
| 591 |
-
if feedback_text and feedback_text.strip():
|
| 592 |
message += f"*Latest Feedback:*\n{feedback_text.strip()}\n\n"
|
| 593 |
|
| 594 |
message += f"*Care Plan Details:*\n{formatted_plan}"
|
| 595 |
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
message_sent = twilio_client.messages.create(
|
| 598 |
from_=TWILIO_FROM,
|
| 599 |
body=message,
|
|
@@ -605,16 +606,13 @@ def send_whatsapp_care_plan(patient_info, care_plan_text, status):
|
|
| 605 |
logger.error(f"Error sending WhatsApp message: {e}", exc_info=True)
|
| 606 |
# Provide more specific Twilio error details if available
|
| 607 |
twilio_error_message = str(e)
|
| 608 |
-
# Attempt to parse Twilio API error response
|
| 609 |
if hasattr(e, 'status_code') and hasattr(e, 'text'):
|
| 610 |
try:
|
| 611 |
error_details = json.loads(e.text)
|
| 612 |
if 'message' in error_details:
|
| 613 |
twilio_error_message = f"Twilio API error: {error_details['message']} (Code: {error_details.get('code')})"
|
| 614 |
-
except json.JSONDecodeError:
|
| 615 |
-
pass
|
| 616 |
-
except Exception:
|
| 617 |
-
pass # Catch other potential exceptions during parsing/formatting
|
| 618 |
|
| 619 |
return False, f"Error sending WhatsApp: {twilio_error_message}"
|
| 620 |
|
|
@@ -657,6 +655,8 @@ def doctor_dashboard():
|
|
| 657 |
def submit_feedback():
|
| 658 |
ai_enabled = bool(model) # Check if model is initialized
|
| 659 |
ai_error_message = None # Initialize error message
|
|
|
|
|
|
|
| 660 |
|
| 661 |
try:
|
| 662 |
name = request.form.get('name', 'Unnamed Patient')
|
|
@@ -673,12 +673,12 @@ def submit_feedback():
|
|
| 673 |
try:
|
| 674 |
age = int(age)
|
| 675 |
if age <= 0: raise ValueError("Age must be positive")
|
| 676 |
-
if age > 150: raise ValueError("Age seems unreasonably high")
|
| 677 |
except ValueError:
|
| 678 |
logger.warning(f"Submission failed: Invalid Age provided: {age}")
|
| 679 |
return jsonify({'success': False, 'error': 'Invalid Age provided.'}), 400
|
| 680 |
else:
|
| 681 |
-
age = None
|
| 682 |
|
| 683 |
care_plan_text = "" # This will store the extracted text from PDF
|
| 684 |
care_plan_format = None # This will store the detected format
|
|
@@ -692,10 +692,13 @@ def submit_feedback():
|
|
| 692 |
return jsonify({'success': False, 'error': 'Invalid file type. Only PDF files are allowed.'}), 400
|
| 693 |
|
| 694 |
logger.info(f"Processing uploaded PDF: {pdf_file.filename}")
|
|
|
|
|
|
|
| 695 |
care_plan_text = extract_text_from_pdf(pdf_file)
|
| 696 |
|
| 697 |
# If extraction resulted in an error message, set format to None
|
| 698 |
-
|
|
|
|
| 699 |
care_plan_format = None
|
| 700 |
logger.warning(f"PDF text extraction failed or empty: {care_plan_text}")
|
| 701 |
else:
|
|
@@ -706,6 +709,7 @@ def submit_feedback():
|
|
| 706 |
|
| 707 |
# Determine the initial status based on feedback and original plan
|
| 708 |
# Pass "" for updated_plan initially, as it hasn't been generated yet for status check
|
|
|
|
| 709 |
initial_status = determine_patient_status(care_plan_text, "", feedback)
|
| 710 |
logger.info(f"Initial status determined based on feedback/original plan: {initial_status}")
|
| 711 |
|
|
@@ -714,7 +718,6 @@ def submit_feedback():
|
|
| 714 |
final_status_to_save = initial_status # Start with initial status
|
| 715 |
|
| 716 |
# Only generate AI plan if AI is enabled AND status isn't immediate emergency based on feedback
|
| 717 |
-
# If feedback triggers "emergency", the generated_plan_text is a fixed emergency plan.
|
| 718 |
if final_status_to_save == 'emergency':
|
| 719 |
logger.info("Emergency status detected. Generating fixed emergency plan.")
|
| 720 |
generated_plan_text = (
|
|
@@ -740,8 +743,7 @@ def submit_feedback():
|
|
| 740 |
"- Inform your primary physician/care team as soon as medically stable.\n"
|
| 741 |
"- A new care plan will be developed after the emergency situation is resolved and evaluated by medical professionals.\n"
|
| 742 |
)
|
| 743 |
-
# For emergency, the
|
| 744 |
-
# Status remains 'emergency', which was already set as final_status_to_save.
|
| 745 |
|
| 746 |
|
| 747 |
elif ai_enabled: # AI is enabled and initial status is not emergency
|
|
@@ -790,8 +792,8 @@ Age: {age if age is not None else 'N/A'}
|
|
| 790 |
Gender: {gender}
|
| 791 |
Patient Feedback/Symptoms Update:
|
| 792 |
{feedback}
|
| 793 |
-
Previous Care Plan Details (if available):
|
| 794 |
-
{care_plan_text if care_plan_text and
|
| 795 |
Instructions:
|
| 796 |
1. Generate the updated care plan strictly using the exact following format template.
|
| 797 |
2. Populate each section of the template based on the patient's information, their *latest feedback/symptoms*, and integrate relevant, SAFE, and appropriate elements from the previous plan if they are still applicable and helpful given the feedback.
|
|
@@ -803,7 +805,7 @@ Instructions:
|
|
| 803 |
8. If the previous care plan was unavailable or unreadable, create the plan based solely on the patient information and feedback, still following the template.
|
| 804 |
9. Ensure the plan is medically sound and reflects standard care principles. If feedback indicates a significant change or potential issue, the ASSESSMENT and subsequent sections should clearly address this and recommend appropriate actions (like contacting their doctor for a re-evaluation, even if it's not an immediate emergency).
|
| 805 |
10. If the feedback indicates significant improvement, the plan should reflect this (e.g., adjusting activity levels up, noting successful symptom management) while still including monitoring and red flags.
|
| 806 |
-
11. Review the feedback and previous plan carefully to determine the most likely current STATUS (e.g., Emergency, Deteriorating, Improving, Stable) and ensure the plan content aligns with that status, especially the RED FLAGS section.
|
| 807 |
Care Plan Format Template:
|
| 808 |
{care_plan_format}
|
| 809 |
"""
|
|
@@ -811,7 +813,6 @@ Care Plan Format Template:
|
|
| 811 |
|
| 812 |
try:
|
| 813 |
response = model.generate_content(prompt)
|
| 814 |
-
# Access text attribute
|
| 815 |
generated_plan_text = response.text.strip()
|
| 816 |
|
| 817 |
# Remove markdown code block formatting if present
|
|
@@ -838,8 +839,6 @@ Care Plan Format Template:
|
|
| 838 |
logger.info(f"AI Response received. Length: {len(generated_plan_text)}")
|
| 839 |
|
| 840 |
# Re-determine the final status using the generated plan as well.
|
| 841 |
-
# This is important because the AI might infer severity the keyword matching missed initially,
|
| 842 |
-
# or the generated plan text itself might contain explicit strong status indicators.
|
| 843 |
final_status_to_save = determine_patient_status(care_plan_text, generated_plan_text, feedback)
|
| 844 |
logger.info(f"Final status determined after AI generation: {final_status_to_save}")
|
| 845 |
|
|
@@ -848,10 +847,11 @@ Care Plan Format Template:
|
|
| 848 |
logger.error(f"Error generating content from AI: {ai_error}", exc_info=True)
|
| 849 |
# If AI fails, construct an error message plan
|
| 850 |
generated_plan_text = f"[Error generating updated plan from AI: {ai_error}]\n\n"
|
| 851 |
-
|
|
|
|
| 852 |
generated_plan_text += "Falling back to original plan if available:\n\n" + care_plan_text
|
| 853 |
# If falling back to original, status should reflect original plan/feedback
|
| 854 |
-
final_status_to_save = determine_patient_status(care_plan_text, care_plan_text, feedback) # Use original plan for status check if AI failed
|
| 855 |
ai_error_message = f"AI generation failed. Showing original plan if available. Error: {ai_error}"
|
| 856 |
else:
|
| 857 |
generated_plan_text += "No previous plan available."
|
|
@@ -864,7 +864,8 @@ Care Plan Format Template:
|
|
| 864 |
else: # AI is not enabled and status is not emergency
|
| 865 |
logger.warning("AI generation is disabled.")
|
| 866 |
generated_plan_text = f"[AI generation is currently disabled.]\n\n"
|
| 867 |
-
|
|
|
|
| 868 |
generated_plan_text += "Showing original plan if available:\n\n" + care_plan_text
|
| 869 |
# Status based on original plan/feedback
|
| 870 |
final_status_to_save = determine_patient_status(care_plan_text, care_plan_text, feedback)
|
|
@@ -895,17 +896,16 @@ Care Plan Format Template:
|
|
| 895 |
logger.info(f"Patient {patient_id} added to DB with status: {final_status_to_save}.")
|
| 896 |
|
| 897 |
# Generate PDF for downloading using the stored data
|
| 898 |
-
# Note: We pass the patient object directly to the PDF generator for simplicity
|
| 899 |
-
# and to include feedback in the PDF
|
| 900 |
pdf_buffer = generate_care_plan_pdf(new_patient.to_dict(), new_patient.updated_plan, new_patient.status)
|
| 901 |
pdf_buffer.seek(0) # Ensure buffer is at the start before base64 encoding
|
| 902 |
pdf_base64 = base64.b64encode(pdf_buffer.getvalue()).decode('utf-8')
|
| 903 |
logger.info("PDF generated and base64 encoded.")
|
| 904 |
|
| 905 |
# Send care plan via WhatsApp (using the final saved data)
|
| 906 |
-
#
|
| 907 |
whatsapp_sent, whatsapp_message = send_whatsapp_care_plan(new_patient.to_dict(), new_patient.updated_plan, new_patient.status)
|
| 908 |
-
logger.info(f"WhatsApp message attempt
|
|
|
|
| 909 |
|
| 910 |
# Return success response, include relevant data
|
| 911 |
return jsonify({
|
|
@@ -914,8 +914,8 @@ Care Plan Format Template:
|
|
| 914 |
'pdf_data': pdf_base64,
|
| 915 |
'patient_id': patient_id,
|
| 916 |
'status': new_patient.status, # Return the final determined status
|
| 917 |
-
'whatsapp_sent': whatsapp_sent,
|
| 918 |
-
'whatsapp_message': whatsapp_message,
|
| 919 |
'ai_error': not ai_enabled or (ai_error_message is not None) # Indicate if AI was not enabled or failed
|
| 920 |
})
|
| 921 |
|
|
@@ -946,9 +946,7 @@ def update_care_plan(patient_id):
|
|
| 946 |
logger.warning(f"Update failed for ID {patient_id}: Patient not found.")
|
| 947 |
return jsonify({'success': False, 'error': 'Patient not found.'}), 404
|
| 948 |
|
| 949 |
-
# Re-determine status based on the manually updated plan + existing feedback/original
|
| 950 |
-
# Yes, this makes sense. If the doctor edits the plan, it should potentially change the status indication
|
| 951 |
-
# if their edits include stronger language about severity or improvement.
|
| 952 |
patient.status = determine_patient_status(patient.original_plan, updated_plan_text, patient.feedback)
|
| 953 |
|
| 954 |
patient.updated_plan = updated_plan_text
|
|
@@ -985,6 +983,7 @@ def send_whatsapp_doctor(patient_id):
|
|
| 985 |
care_plan_text_to_send = patient.updated_plan
|
| 986 |
status_to_send = patient.status
|
| 987 |
|
|
|
|
| 988 |
whatsapp_sent, whatsapp_message = send_whatsapp_care_plan(
|
| 989 |
patient_info_for_whatsapp,
|
| 990 |
care_plan_text_to_send,
|
|
@@ -995,14 +994,15 @@ def send_whatsapp_doctor(patient_id):
|
|
| 995 |
logger.info(f"WhatsApp sent successfully for patient ID: {patient_id}")
|
| 996 |
return jsonify({'success': True, 'message': whatsapp_message})
|
| 997 |
else:
|
| 998 |
-
logger.
|
| 999 |
-
#
|
| 1000 |
-
#
|
| 1001 |
-
return jsonify({'success': False, 'error': whatsapp_message}),
|
| 1002 |
|
| 1003 |
except Exception as e:
|
| 1004 |
logger.error(f"Error triggering WhatsApp send for ID {patient_id}: {str(e)}", exc_info=True)
|
| 1005 |
-
|
|
|
|
| 1006 |
|
| 1007 |
|
| 1008 |
# --- Existing routes remain below ---
|
|
@@ -1025,7 +1025,20 @@ def download_pdf(patient_id):
|
|
| 1025 |
patient.status
|
| 1026 |
)
|
| 1027 |
|
| 1028 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
|
| 1030 |
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '', patient.name or 'patient').lower()
|
| 1031 |
download_name = f"care_plan_{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
@@ -1039,15 +1052,13 @@ def download_pdf(patient_id):
|
|
| 1039 |
)
|
| 1040 |
except Exception as e:
|
| 1041 |
logger.error(f"PDF Download Error for ID {patient_id}: {str(e)}", exc_info=True)
|
|
|
|
| 1042 |
return f"Error generating PDF for download: {str(e)}", 500
|
| 1043 |
|
| 1044 |
|
| 1045 |
@app.route('/get_emergency_notifications')
|
| 1046 |
def get_emergency_notifications():
|
| 1047 |
# Only include patients whose status is 'emergency'
|
| 1048 |
-
# Exclude temporary 'emergency' status from new submissions before AI runs,
|
| 1049 |
-
# perhaps only include statuses confirmed by AI or manual save?
|
| 1050 |
-
# For simplicity now, just filter by final status == 'emergency' in DB.
|
| 1051 |
emergency_patients_query = Patient.query.filter_by(status='emergency').order_by(Patient.timestamp.desc())
|
| 1052 |
|
| 1053 |
# Only return basic info needed for the alert, not full patient details
|
|
@@ -1120,8 +1131,11 @@ if __name__ == '__main__':
|
|
| 1120 |
if not inspector.has_table("patient"): # Check for at least one model's table
|
| 1121 |
logger.info("Database tables not found, creating.")
|
| 1122 |
db.create_all()
|
|
|
|
| 1123 |
else:
|
| 1124 |
logger.info("Database tables already exist.")
|
|
|
|
|
|
|
| 1125 |
|
| 1126 |
# Use a more robust development server like Waitress or Gunicorn in production
|
| 1127 |
# For development, debug=True is fine
|
|
|
|
| 64 |
# Create database tables if they don't exist
|
| 65 |
@app.before_request
|
| 66 |
def create_tables():
|
| 67 |
+
# Ensure this runs only once per application start
|
| 68 |
+
# Use a flag to prevent repeated checks/creations on every request
|
| 69 |
+
if not getattr(app, '_tables_created', False):
|
| 70 |
with app.app_context():
|
|
|
|
|
|
|
| 71 |
inspector = db.inspect(db.engine)
|
| 72 |
+
if not inspector.has_table("patient"): # Check for at least one table
|
| 73 |
logger.info("Creating database tables.")
|
| 74 |
db.create_all()
|
| 75 |
+
app._tables_created = True # Set flag
|
| 76 |
+
else:
|
| 77 |
+
logger.info("Database tables already exist.")
|
| 78 |
+
app._tables_created = True # Set flag even if they exist
|
| 79 |
|
| 80 |
|
| 81 |
+
upload_base = os.getenv('UPLOAD_DIR', '/tmp') # Changed base to /tmp, safer default
|
| 82 |
+
upload_folder = os.path.join(upload_base, 'uploads', 'pdfs') # Added 'uploads' subdirectory
|
| 83 |
|
| 84 |
app.config['UPLOAD_FOLDER'] = upload_folder
|
| 85 |
|
|
|
|
| 93 |
TWILIO_FROM = os.getenv('TWILIO_FROM_NUMBER')
|
| 94 |
TWILIO_TO = os.getenv('TWILIO_TO_NUMBER') # Hardcoded number as requested, consider making this configurable per patient
|
| 95 |
|
| 96 |
+
# Define WhatsApp message length limit (slightly below the official 4096 for safety)
|
| 97 |
+
WHATSAPP_MAX_CHARS = 4000
|
| 98 |
+
|
| 99 |
twilio_client = None
|
| 100 |
if ACCOUNT_SID and AUTH_TOKEN and TWILIO_FROM and TWILIO_TO:
|
| 101 |
try:
|
|
|
|
| 154 |
text = ""
|
| 155 |
if pdf_reader.is_encrypted:
|
| 156 |
try:
|
| 157 |
+
# Attempt decryption with common/empty passwords - very basic
|
|
|
|
|
|
|
|
|
|
| 158 |
try:
|
| 159 |
pdf_reader.decrypt('') # Try with empty password
|
| 160 |
+
logger.warning("PDF is encrypted but decrypted successfully with empty password.")
|
| 161 |
except PyPDF2.errors.PasswordError:
|
|
|
|
| 162 |
logger.warning("PDF is encrypted and requires a password.")
|
| 163 |
return "[PDF Content Unavailable: File is encrypted and requires a password]"
|
| 164 |
except Exception as dec_e:
|
|
|
|
| 165 |
logger.error(f"Error during PDF decryption attempt: {dec_e}")
|
| 166 |
return "[PDF Content Unavailable: Error decrypting file]"
|
| 167 |
|
| 168 |
+
except Exception: # Catch any other unexpected error
|
|
|
|
| 169 |
logger.error("Unexpected error during PDF decryption check.")
|
| 170 |
return "[PDF Content Unavailable: Unexpected error with encrypted file]"
|
| 171 |
|
|
|
|
| 178 |
text += page_text + "\n"
|
| 179 |
except Exception as page_e:
|
| 180 |
logger.error(f"Error extracting text from page {page_num + 1}: {page_e}")
|
| 181 |
+
text += f"[Error extracting page {page_num + 1}]\n" # Add placeholder
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
extracted = text.strip()
|
| 185 |
+
if not extracted:
|
| 186 |
+
logger.info("PDF text extraction resulted in empty string.")
|
| 187 |
+
return "[No readable text found in PDF]"
|
| 188 |
+
return extracted
|
| 189 |
|
|
|
|
| 190 |
except Exception as e:
|
| 191 |
logger.error(f"Error extracting PDF text: {e}", exc_info=True)
|
| 192 |
return f"[Error extracting PDF text: {e}]"
|
|
|
|
| 194 |
|
| 195 |
def extract_care_plan_format(pdf_text):
|
| 196 |
"""Extract a general format template from PDF text by identifying common section headers."""
|
| 197 |
+
# Define unreadable indicators
|
| 198 |
+
unreadable_indicators = ["[No readable text found", "[Error extracting PDF text", "[PDF Content Unavailable"]
|
| 199 |
+
|
| 200 |
+
if not pdf_text or any(indicator in pdf_text for indicator in unreadable_indicators):
|
| 201 |
logger.info("No valid PDF text available to extract format.")
|
| 202 |
return None
|
| 203 |
|
| 204 |
+
# Pattern to find potential headers: Start of line, contains uppercase letters/spaces, ends with colon or period,
|
| 205 |
+
# possibly followed by whitespace only.
|
| 206 |
+
# \b ensures we match whole words at boundaries
|
| 207 |
+
# [:.] accounts for headers ending in colon or period
|
| 208 |
+
# (?:[\s\r\n]|$) accounts for whitespace or end of string after colon/period
|
| 209 |
+
potential_headers = re.findall(r'^\s*([A-Z][A-Z\s]*)\b[ \t]*[:.]?[\s\r\n]*$', pdf_text, re.MULTILINE)
|
| 210 |
|
| 211 |
if not potential_headers:
|
| 212 |
# Fallback: Look for lines that start with a capital letter and seem like standalone headers
|
| 213 |
+
# Ensure it doesn't look like a sentence (e.g., short phrase, starts with Cap, ends maybe with space or newline).
|
| 214 |
+
# Simple heuristic: Starts with Capital, followed by 3+ letters/spaces, ends before another capital or end of line.
|
| 215 |
+
# This is less reliable than the colon/period method.
|
| 216 |
+
fallback_headers_loose = re.findall(r'^[A-Z][A-Za-z\s]{3,}(?=\s[A-Z]|\s*$)', pdf_text, re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
fallback_headers_loose = [h.strip() for h in fallback_headers_loose if h.strip()]
|
| 218 |
if fallback_headers_loose:
|
| 219 |
+
# Filter out potential sentences - check length, word count, maybe look for typical header words
|
| 220 |
+
fallback_headers_loose = [h for h in fallback_headers_loose if len(h) > 5 and len(h.split()) < 7 and not h.endswith('.')] # Example filter
|
|
|
|
|
|
|
| 221 |
if fallback_headers_loose:
|
| 222 |
logger.info(f"Extracted potential headers (fallback - loose): {list(set(fallback_headers_loose))}")
|
| 223 |
unique_headers = sorted(list(set([h.strip() for h in fallback_headers_loose if h.strip()])))
|
| 224 |
+
format_template = "\n".join([f"{header.strip()}:" for header in unique_headers if header.strip()])
|
| 225 |
return format_template if format_template.strip() else None
|
| 226 |
|
| 227 |
|
|
|
|
| 229 |
return None
|
| 230 |
|
| 231 |
# Use a set to get unique headers and sort them for consistency
|
| 232 |
+
# Clean up headers: remove trailing colons/periods and strip
|
| 233 |
+
unique_headers = sorted(list(set([re.sub(r'[:.\s]*$', '', h).strip() for h in potential_headers if h.strip()])))
|
| 234 |
|
| 235 |
if not unique_headers:
|
| 236 |
logger.info("Extracted headers are empty after cleaning.")
|
|
|
|
| 243 |
return format_template if format_template.strip() else None
|
| 244 |
|
| 245 |
|
|
|
|
| 246 |
def determine_patient_status(original_plan, updated_plan, feedback):
|
| 247 |
"""
|
| 248 |
+
Determine patient status based on feedback, original plan, and the final updated plan text.
|
| 249 |
Prioritizes emergency > deteriorating > improving. Checks feedback/original first,
|
| 250 |
then checks updated plan for emergency confirmation. Uses refined keyword matching.
|
| 251 |
"""
|
|
|
|
| 253 |
|
| 254 |
feedback_lower = feedback.lower() if feedback else ""
|
| 255 |
original_plan_lower = original_plan.lower() if original_plan else ""
|
| 256 |
+
updated_plan_lower = updated_plan.lower() if updated_plan else ""
|
| 257 |
|
| 258 |
|
| 259 |
# Define robust keyword lists
|
|
|
|
| 260 |
emergency_keywords = [
|
| 261 |
"severe chest pain", "heart attack", "sudden shortness of breath",
|
| 262 |
"difficulty breathing severely", "loss of consciousness", "unresponsive",
|
|
|
|
| 270 |
"signs of shock", "severe dehydration symptoms", "acute change in mental status",
|
| 271 |
"unstable vitals", "rapidly worsening symptoms", "can't breathe", "chest tight severe",
|
| 272 |
"severe difficulty swallowing suddenly", "new onset paralysis", "severe burns",
|
| 273 |
+
"major trauma", "suspected poisoning", "overdose", "suicidal thoughts active",
|
| 274 |
+
"unstable", "critically ill", "life-threatening", "no pulse", "low oxygen saturation severe",
|
| 275 |
+
"uncontrolled high fever", "septic shock", "signs of major infection" # Added a few more
|
| 276 |
]
|
| 277 |
|
|
|
|
| 278 |
deteriorating_keywords = [
|
| 279 |
"worsening", "increased pain", "not improving", "deteriorating",
|
| 280 |
"getting worse", "more frequent symptoms", "elevated", "higher",
|
|
|
|
| 287 |
"tired all the time", "much weaker", "feeling noticeably worse", "consistent high blood pressure",
|
| 288 |
"uncontrolled blood sugar levels", "increased swelling", "persistent cough getting worse",
|
| 289 |
"unexplained weight loss significant", "gradual decline", "unmanaged symptoms",
|
| 290 |
+
"not resolving", "changes in condition", "difficulty performing daily tasks",
|
| 291 |
+
"pain getting worse", "more tired", "less active", "increased falls", "poorly controlled" # Added a few more
|
| 292 |
]
|
| 293 |
|
|
|
|
| 294 |
improvement_keywords = [
|
| 295 |
"improving", "better today", "reduced pain", "lower", "less frequent",
|
| 296 |
"healing well", "recovery on track", "making progress", "stable condition",
|
|
|
|
| 303 |
"more energy", "walking further", "blood pressure normal range",
|
| 304 |
"blood sugar stable", "swelling reduced", "easier breathing", "cough improving",
|
| 305 |
"weight gain healthy", "feeling like myself again", "in remission", "managing well at home",
|
| 306 |
+
"tolerating well", "no issues reported", "feeling good", "symptoms gone",
|
| 307 |
+
"activity increased", "pain well managed", "sleeping better", "appetite improved" # Added a few more
|
| 308 |
]
|
| 309 |
|
| 310 |
+
# Helper to check if any keyword is found in the text (using word boundaries)
|
| 311 |
def check_keywords(text, keywords):
|
| 312 |
+
if not text: return False
|
| 313 |
pattern = r'\b(?:' + '|'.join(re.escape(kw) for kw in keywords) + r')\b'
|
| 314 |
return re.search(pattern, text) is not None
|
| 315 |
|
| 316 |
# --- Classification Logic ---
|
| 317 |
|
| 318 |
+
# 1. Check for EMERGENCY status (Highest Priority)
|
| 319 |
+
# Check combined initial text (feedback + original plan) AND the final generated plan text
|
| 320 |
+
combined_text_lower = feedback_lower + " " + (original_plan_lower if original_plan_lower else "") + " " + (updated_plan_lower if updated_plan_lower else "")
|
| 321 |
+
combined_text_lower = re.sub(r'\s+', ' ', combined_text_lower).strip() # Clean up spaces
|
| 322 |
+
|
| 323 |
+
if check_keywords(combined_text_lower, emergency_keywords):
|
| 324 |
+
logger.info("Status determined: EMERGENCY (keyword found).")
|
| 325 |
+
return "emergency"
|
| 326 |
|
| 327 |
# 2. Check for DETERIORATING status (Second Priority)
|
| 328 |
+
if check_keywords(combined_text_lower, deteriorating_keywords):
|
| 329 |
+
logger.info("Status determined: DETERIORATING (keyword found).")
|
|
|
|
|
|
|
| 330 |
return "deteriorating"
|
| 331 |
|
| 332 |
+
# 3. Check for IMPROVING status (Third Priority)
|
| 333 |
+
if check_keywords(combined_text_lower, improvement_keywords):
|
| 334 |
+
logger.info("Status determined: IMPROVING (keyword found).")
|
|
|
|
|
|
|
| 335 |
return "improving"
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
# 4. Default to STABLE if no specific status keywords are found
|
| 338 |
logger.info("Status determined: STABLE (no specific status keywords found).")
|
| 339 |
return "stable"
|
|
|
|
| 473 |
|
| 474 |
# Check for section headers (e.g., "MEDICATIONS:", "ASSESSMENT:")
|
| 475 |
# Look for lines starting with one or more uppercase words followed by a colon or period
|
| 476 |
+
header_match = re.match(r'^\s*([A-Z][A-Z\s]*)\b[ \t]*[:.]?[\s\r\n]*$', line) # Use original line to preserve leading spaces if intentional?
|
| 477 |
if header_match:
|
| 478 |
# Remove trailing colon or period for cleaner heading
|
| 479 |
header_text = re.sub(r'[:.\s]*$', '', stripped_line).strip()
|
| 480 |
+
if header_text: # Ensure there's actual text before adding a heading
|
| 481 |
+
story.append(Spacer(1, 8)) # Add space before a new section
|
| 482 |
+
story.append(Paragraph(header_text + ":", heading_style)) # Use heading style for sections
|
| 483 |
+
# else: # If line was just ":" or ".", treat as empty and skip or handle as plain text?
|
| 484 |
+
# pass # Skipping lines that are just punctuation seems reasonable
|
| 485 |
+
|
| 486 |
+
# Check for list items (starting with -, *, •) - only if NOT matched as a header
|
| 487 |
+
# Use original line to preserve leading spaces/tabs for bullet indent
|
| 488 |
+
elif line.lstrip().startswith(('-', '*', '•')): # Use lstrip to check for bullet at start ignoring leading space
|
| 489 |
+
# Remove the bullet character and any leading space/tab after it
|
| 490 |
+
bullet_text = re.sub(r'^[\s-]*[-*•][ \t]*', '', line).strip() # More robust removal including space/tab before bullet
|
| 491 |
if bullet_text:
|
| 492 |
formatted_bullet_text = bullet_text.replace('\n', '<br/>')
|
| 493 |
story.append(Paragraph(f"• {formatted_bullet_text}", bullet_style))
|
|
|
|
| 495 |
# Handle cases with just a bullet point on a line
|
| 496 |
story.append(Paragraph("• ", bullet_style))
|
| 497 |
else:
|
| 498 |
+
# Handle regular paragraph text that wasn't a header or bullet
|
| 499 |
+
# Only add if the stripped line has content
|
| 500 |
+
if stripped_line:
|
| 501 |
+
normal_line_content = line.strip().replace('\n', '<br/>') # Use stripped line for content
|
| 502 |
+
story.append(Paragraph(normal_line_content, normal_style))
|
| 503 |
|
| 504 |
|
| 505 |
story.append(Spacer(1, 20))
|
|
|
|
| 513 |
return buffer
|
| 514 |
except Exception as e:
|
| 515 |
logger.error(f"Error building PDF: {e}", exc_info=True)
|
| 516 |
+
# Fallback: create a simple error PDF
|
| 517 |
error_buffer = io.BytesIO()
|
| 518 |
c = canvas.Canvas(error_buffer, pagesize=letter)
|
| 519 |
c.drawString(100, 750, "Error Generating Care Plan PDF")
|
| 520 |
c.drawString(100, 735, f"Details: {str(e)}")
|
| 521 |
+
c.drawString(100, 720, "Please check server logs for more information.")
|
| 522 |
c.save()
|
| 523 |
error_buffer.seek(0)
|
| 524 |
return error_buffer
|
| 525 |
|
| 526 |
|
| 527 |
def send_whatsapp_care_plan(patient_info, care_plan_text, status):
|
| 528 |
+
"""Send care plan via WhatsApp using Twilio with improved formatting and length check"""
|
| 529 |
if not twilio_client:
|
| 530 |
logger.warning("Twilio client not configured. Cannot send WhatsApp message.")
|
| 531 |
+
return False, "Twilio client not configured. Check server settings."
|
| 532 |
|
| 533 |
if not TWILIO_TO or not TWILIO_FROM:
|
| 534 |
logger.warning("Twilio TO or FROM number not set.")
|
| 535 |
+
return False, "Twilio TO or FROM number not configured. Check server settings."
|
| 536 |
|
| 537 |
# Basic check for empty plan text before sending
|
| 538 |
if not care_plan_text or not care_plan_text.strip():
|
| 539 |
logger.warning("Care plan text is empty. Cannot send WhatsApp message.")
|
| 540 |
+
return False, "Care plan text is empty. No plan to send."
|
| 541 |
|
| 542 |
try:
|
| 543 |
status_emoji = {
|
|
|
|
| 560 |
for line in formatted_plan.split('\n'):
|
| 561 |
stripped_line = line.strip()
|
| 562 |
# Check for lines that look like headers (starts with capital, ends with colon/period, possibly followed by whitespace)
|
| 563 |
+
# Added ? after [:. ] to handle cases where header doesn't have punctuation
|
| 564 |
+
header_match = re.match(r'^[A-Z][A-Za-z\s]*\b[ \t]*[:.]?$', stripped_line)
|
| 565 |
+
if header_match:
|
| 566 |
+
# Remove trailing colon or period before bolding
|
| 567 |
header_text = re.sub(r'[:.\s]*$', '', stripped_line).strip()
|
| 568 |
if header_text: # Only bold if there's actual text before the colon/period
|
| 569 |
+
formatted_plan_lines.append(f"*{header_text}:*") # Add colon back after bolding
|
| 570 |
+
else: # Handle cases like just ":" or "." on a line
|
| 571 |
formatted_plan_lines.append(line)
|
| 572 |
else:
|
| 573 |
formatted_plan_lines.append(line)
|
|
|
|
| 581 |
message += f"*Status:* {status_emoji.get(status, 'Unknown')}\n\n"
|
| 582 |
# Add feedback if available
|
| 583 |
feedback_text = patient_info.get('feedback')
|
| 584 |
+
if feedback_text and feedback_text.strip():
|
| 585 |
message += f"*Latest Feedback:*\n{feedback_text.strip()}\n\n"
|
| 586 |
|
| 587 |
message += f"*Care Plan Details:*\n{formatted_plan}"
|
| 588 |
|
| 589 |
+
# --- WhatsApp Message Length Check ---
|
| 590 |
+
if len(message) > WHATSAPP_MAX_CHARS:
|
| 591 |
+
error_msg = f"WhatsApp message exceeds the maximum length ({WHATSAPP_MAX_CHARS} characters). The full care plan text is too long to send via WhatsApp. Please download the PDF instead."
|
| 592 |
+
logger.warning(f"WhatsApp message too long for patient {patient_info.get('name', 'N/A')} (ID: {patient_info.get('id', 'N/A')}). Length: {len(message)}. Max: {WHATSAPP_MAX_CHARS}")
|
| 593 |
+
return False, error_msg
|
| 594 |
+
# --- End Length Check ---
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
logger.info(f"Attempting to send WhatsApp message to {TWILIO_TO} (length: {len(message)})...")
|
| 598 |
message_sent = twilio_client.messages.create(
|
| 599 |
from_=TWILIO_FROM,
|
| 600 |
body=message,
|
|
|
|
| 606 |
logger.error(f"Error sending WhatsApp message: {e}", exc_info=True)
|
| 607 |
# Provide more specific Twilio error details if available
|
| 608 |
twilio_error_message = str(e)
|
|
|
|
| 609 |
if hasattr(e, 'status_code') and hasattr(e, 'text'):
|
| 610 |
try:
|
| 611 |
error_details = json.loads(e.text)
|
| 612 |
if 'message' in error_details:
|
| 613 |
twilio_error_message = f"Twilio API error: {error_details['message']} (Code: {error_details.get('code')})"
|
| 614 |
+
except (json.JSONDecodeError, Exception):
|
| 615 |
+
pass
|
|
|
|
|
|
|
| 616 |
|
| 617 |
return False, f"Error sending WhatsApp: {twilio_error_message}"
|
| 618 |
|
|
|
|
| 655 |
def submit_feedback():
|
| 656 |
ai_enabled = bool(model) # Check if model is initialized
|
| 657 |
ai_error_message = None # Initialize error message
|
| 658 |
+
whatsapp_sent = False
|
| 659 |
+
whatsapp_message = "WhatsApp sending skipped or not attempted yet." # Default message
|
| 660 |
|
| 661 |
try:
|
| 662 |
name = request.form.get('name', 'Unnamed Patient')
|
|
|
|
| 673 |
try:
|
| 674 |
age = int(age)
|
| 675 |
if age <= 0: raise ValueError("Age must be positive")
|
| 676 |
+
if age > 150: raise ValueError("Age seems unreasonably high")
|
| 677 |
except ValueError:
|
| 678 |
logger.warning(f"Submission failed: Invalid Age provided: {age}")
|
| 679 |
return jsonify({'success': False, 'error': 'Invalid Age provided.'}), 400
|
| 680 |
else:
|
| 681 |
+
age = None
|
| 682 |
|
| 683 |
care_plan_text = "" # This will store the extracted text from PDF
|
| 684 |
care_plan_format = None # This will store the detected format
|
|
|
|
| 692 |
return jsonify({'success': False, 'error': 'Invalid file type. Only PDF files are allowed.'}), 400
|
| 693 |
|
| 694 |
logger.info(f"Processing uploaded PDF: {pdf_file.filename}")
|
| 695 |
+
# Reset the file pointer just in case, although seek(0) is in extract_text_from_pdf
|
| 696 |
+
# pdf_file.seek(0) # Redundant due to seek(0) in extract_text_from_pdf
|
| 697 |
care_plan_text = extract_text_from_pdf(pdf_file)
|
| 698 |
|
| 699 |
# If extraction resulted in an error message, set format to None
|
| 700 |
+
unreadable_indicators = ["[No readable text found", "[Error extracting PDF text", "[PDF Content Unavailable"]
|
| 701 |
+
if any(indicator in care_plan_text for indicator in unreadable_indicators):
|
| 702 |
care_plan_format = None
|
| 703 |
logger.warning(f"PDF text extraction failed or empty: {care_plan_text}")
|
| 704 |
else:
|
|
|
|
| 709 |
|
| 710 |
# Determine the initial status based on feedback and original plan
|
| 711 |
# Pass "" for updated_plan initially, as it hasn't been generated yet for status check
|
| 712 |
+
# Using care_plan_text for original_plan part of status check
|
| 713 |
initial_status = determine_patient_status(care_plan_text, "", feedback)
|
| 714 |
logger.info(f"Initial status determined based on feedback/original plan: {initial_status}")
|
| 715 |
|
|
|
|
| 718 |
final_status_to_save = initial_status # Start with initial status
|
| 719 |
|
| 720 |
# Only generate AI plan if AI is enabled AND status isn't immediate emergency based on feedback
|
|
|
|
| 721 |
if final_status_to_save == 'emergency':
|
| 722 |
logger.info("Emergency status detected. Generating fixed emergency plan.")
|
| 723 |
generated_plan_text = (
|
|
|
|
| 743 |
"- Inform your primary physician/care team as soon as medically stable.\n"
|
| 744 |
"- A new care plan will be developed after the emergency situation is resolved and evaluated by medical professionals.\n"
|
| 745 |
)
|
| 746 |
+
# For emergency, the status is already 'emergency' and the generated plan is fixed.
|
|
|
|
| 747 |
|
| 748 |
|
| 749 |
elif ai_enabled: # AI is enabled and initial status is not emergency
|
|
|
|
| 792 |
Gender: {gender}
|
| 793 |
Patient Feedback/Symptoms Update:
|
| 794 |
{feedback}
|
| 795 |
+
Previous Care Plan Details (if available and readable):
|
| 796 |
+
{care_plan_text if care_plan_text and not any(indicator in care_plan_text for indicator in unreadable_indicators) else "No previous care plan provided or could not be read."}
|
| 797 |
Instructions:
|
| 798 |
1. Generate the updated care plan strictly using the exact following format template.
|
| 799 |
2. Populate each section of the template based on the patient's information, their *latest feedback/symptoms*, and integrate relevant, SAFE, and appropriate elements from the previous plan if they are still applicable and helpful given the feedback.
|
|
|
|
| 805 |
8. If the previous care plan was unavailable or unreadable, create the plan based solely on the patient information and feedback, still following the template.
|
| 806 |
9. Ensure the plan is medically sound and reflects standard care principles. If feedback indicates a significant change or potential issue, the ASSESSMENT and subsequent sections should clearly address this and recommend appropriate actions (like contacting their doctor for a re-evaluation, even if it's not an immediate emergency).
|
| 807 |
10. If the feedback indicates significant improvement, the plan should reflect this (e.g., adjusting activity levels up, noting successful symptom management) while still including monitoring and red flags.
|
| 808 |
+
11. Review the feedback and previous plan carefully to determine the most likely current STATUS (e.g., Emergency, Deteriorating, Improving, Stable) and ensure the plan content aligns with that status, especially the RED FLAGS section. The determined status will be saved alongside the plan.
|
| 809 |
Care Plan Format Template:
|
| 810 |
{care_plan_format}
|
| 811 |
"""
|
|
|
|
| 813 |
|
| 814 |
try:
|
| 815 |
response = model.generate_content(prompt)
|
|
|
|
| 816 |
generated_plan_text = response.text.strip()
|
| 817 |
|
| 818 |
# Remove markdown code block formatting if present
|
|
|
|
| 839 |
logger.info(f"AI Response received. Length: {len(generated_plan_text)}")
|
| 840 |
|
| 841 |
# Re-determine the final status using the generated plan as well.
|
|
|
|
|
|
|
| 842 |
final_status_to_save = determine_patient_status(care_plan_text, generated_plan_text, feedback)
|
| 843 |
logger.info(f"Final status determined after AI generation: {final_status_to_save}")
|
| 844 |
|
|
|
|
| 847 |
logger.error(f"Error generating content from AI: {ai_error}", exc_info=True)
|
| 848 |
# If AI fails, construct an error message plan
|
| 849 |
generated_plan_text = f"[Error generating updated plan from AI: {ai_error}]\n\n"
|
| 850 |
+
unreadable_indicators = ["[No readable text found", "[Error extracting PDF text", "[PDF Content Unavailable"]
|
| 851 |
+
if care_plan_text and not any(indicator in care_plan_text for indicator in unreadable_indicators):
|
| 852 |
generated_plan_text += "Falling back to original plan if available:\n\n" + care_plan_text
|
| 853 |
# If falling back to original, status should reflect original plan/feedback
|
| 854 |
+
final_status_to_save = determine_patient_status(care_plan_text, care_plan_text, feedback) # Use original plan text for status check if AI failed
|
| 855 |
ai_error_message = f"AI generation failed. Showing original plan if available. Error: {ai_error}"
|
| 856 |
else:
|
| 857 |
generated_plan_text += "No previous plan available."
|
|
|
|
| 864 |
else: # AI is not enabled and status is not emergency
|
| 865 |
logger.warning("AI generation is disabled.")
|
| 866 |
generated_plan_text = f"[AI generation is currently disabled.]\n\n"
|
| 867 |
+
unreadable_indicators = ["[No readable text found", "[Error extracting PDF text", "[PDF Content Unavailable"]
|
| 868 |
+
if care_plan_text and not any(indicator in care_plan_text for indicator in unreadable_indicators):
|
| 869 |
generated_plan_text += "Showing original plan if available:\n\n" + care_plan_text
|
| 870 |
# Status based on original plan/feedback
|
| 871 |
final_status_to_save = determine_patient_status(care_plan_text, care_plan_text, feedback)
|
|
|
|
| 896 |
logger.info(f"Patient {patient_id} added to DB with status: {final_status_to_save}.")
|
| 897 |
|
| 898 |
# Generate PDF for downloading using the stored data
|
|
|
|
|
|
|
| 899 |
pdf_buffer = generate_care_plan_pdf(new_patient.to_dict(), new_patient.updated_plan, new_patient.status)
|
| 900 |
pdf_buffer.seek(0) # Ensure buffer is at the start before base64 encoding
|
| 901 |
pdf_base64 = base64.b64encode(pdf_buffer.getvalue()).decode('utf-8')
|
| 902 |
logger.info("PDF generated and base64 encoded.")
|
| 903 |
|
| 904 |
# Send care plan via WhatsApp (using the final saved data)
|
| 905 |
+
# This call will now handle the length check internally
|
| 906 |
whatsapp_sent, whatsapp_message = send_whatsapp_care_plan(new_patient.to_dict(), new_patient.updated_plan, new_patient.status)
|
| 907 |
+
logger.info(f"WhatsApp message attempt finished: {whatsapp_sent}, message: {whatsapp_message}")
|
| 908 |
+
|
| 909 |
|
| 910 |
# Return success response, include relevant data
|
| 911 |
return jsonify({
|
|
|
|
| 914 |
'pdf_data': pdf_base64,
|
| 915 |
'patient_id': patient_id,
|
| 916 |
'status': new_patient.status, # Return the final determined status
|
| 917 |
+
'whatsapp_sent': whatsapp_sent, # Indicates if the message was *attempted* and passed length check
|
| 918 |
+
'whatsapp_message': whatsapp_message, # Contains success or error message
|
| 919 |
'ai_error': not ai_enabled or (ai_error_message is not None) # Indicate if AI was not enabled or failed
|
| 920 |
})
|
| 921 |
|
|
|
|
| 946 |
logger.warning(f"Update failed for ID {patient_id}: Patient not found.")
|
| 947 |
return jsonify({'success': False, 'error': 'Patient not found.'}), 404
|
| 948 |
|
| 949 |
+
# Re-determine status based on the manually updated plan + existing feedback/original
|
|
|
|
|
|
|
| 950 |
patient.status = determine_patient_status(patient.original_plan, updated_plan_text, patient.feedback)
|
| 951 |
|
| 952 |
patient.updated_plan = updated_plan_text
|
|
|
|
| 983 |
care_plan_text_to_send = patient.updated_plan
|
| 984 |
status_to_send = patient.status
|
| 985 |
|
| 986 |
+
# This call now includes the length check
|
| 987 |
whatsapp_sent, whatsapp_message = send_whatsapp_care_plan(
|
| 988 |
patient_info_for_whatsapp,
|
| 989 |
care_plan_text_to_send,
|
|
|
|
| 994 |
logger.info(f"WhatsApp sent successfully for patient ID: {patient_id}")
|
| 995 |
return jsonify({'success': True, 'message': whatsapp_message})
|
| 996 |
else:
|
| 997 |
+
logger.warning(f"WhatsApp failed (likely too long) for patient ID: {patient_id} - {whatsapp_message}")
|
| 998 |
+
# Return a 400 error specifically for the length issue or other client-side fixable problems
|
| 999 |
+
# Use a more descriptive error message for the user
|
| 1000 |
+
return jsonify({'success': False, 'error': whatsapp_message}), 400 # Changed status to 400
|
| 1001 |
|
| 1002 |
except Exception as e:
|
| 1003 |
logger.error(f"Error triggering WhatsApp send for ID {patient_id}: {str(e)}", exc_info=True)
|
| 1004 |
+
# Use 500 for unexpected server errors
|
| 1005 |
+
return jsonify({'success': False, 'error': f'An unexpected error occurred while sending WhatsApp: {str(e)}'}), 500
|
| 1006 |
|
| 1007 |
|
| 1008 |
# --- Existing routes remain below ---
|
|
|
|
| 1025 |
patient.status
|
| 1026 |
)
|
| 1027 |
|
| 1028 |
+
# Check if the buffer contains the error message text (simple check)
|
| 1029 |
+
buffer_content = pdf_buffer.getvalue().decode('latin-1', errors='ignore') # Decode to check content
|
| 1030 |
+
if "Error Generating Care Plan PDF" in buffer_content:
|
| 1031 |
+
logger.error(f"PDF generation failed for ID {patient_id}, returning error PDF.")
|
| 1032 |
+
# Send the error PDF, but indicate it's an error
|
| 1033 |
+
return send_file(
|
| 1034 |
+
io.BytesIO(pdf_buffer.getvalue()), # Need to create a new buffer from the value
|
| 1035 |
+
as_attachment=True,
|
| 1036 |
+
download_name=f"care_plan_{re.sub(r'[^a-zA-Z0-9_\-]', '', patient.name or 'patient').lower()}_error.pdf",
|
| 1037 |
+
mimetype='application/pdf'
|
| 1038 |
+
)
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
pdf_buffer.seek(0) # Reset buffer position after checking content
|
| 1042 |
|
| 1043 |
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '', patient.name or 'patient').lower()
|
| 1044 |
download_name = f"care_plan_{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
|
|
| 1052 |
)
|
| 1053 |
except Exception as e:
|
| 1054 |
logger.error(f"PDF Download Error for ID {patient_id}: {str(e)}", exc_info=True)
|
| 1055 |
+
# Fallback error response if even generating the basic error PDF fails
|
| 1056 |
return f"Error generating PDF for download: {str(e)}", 500
|
| 1057 |
|
| 1058 |
|
| 1059 |
@app.route('/get_emergency_notifications')
|
| 1060 |
def get_emergency_notifications():
|
| 1061 |
# Only include patients whose status is 'emergency'
|
|
|
|
|
|
|
|
|
|
| 1062 |
emergency_patients_query = Patient.query.filter_by(status='emergency').order_by(Patient.timestamp.desc())
|
| 1063 |
|
| 1064 |
# Only return basic info needed for the alert, not full patient details
|
|
|
|
| 1131 |
if not inspector.has_table("patient"): # Check for at least one model's table
|
| 1132 |
logger.info("Database tables not found, creating.")
|
| 1133 |
db.create_all()
|
| 1134 |
+
app._tables_created = True # Set flag after creation
|
| 1135 |
else:
|
| 1136 |
logger.info("Database tables already exist.")
|
| 1137 |
+
app._tables_created = True # Set flag if they exist
|
| 1138 |
+
|
| 1139 |
|
| 1140 |
# Use a more robust development server like Waitress or Gunicorn in production
|
| 1141 |
# For development, debug=True is fine
|