Update services/pdf_report.py
Browse files- services/pdf_report.py +67 -72
services/pdf_report.py
CHANGED
@@ -1,30 +1,32 @@
|
|
1 |
# /home/user/app/services/pdf_report.py
|
2 |
from reportlab.lib.pagesizes import letter
|
3 |
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
|
4 |
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
5 |
from reportlab.lib.units import inch
|
6 |
from reportlab.lib import colors
|
7 |
from io import BytesIO
|
8 |
-
from typing import List, Dict, Any #
|
9 |
from pathlib import Path
|
10 |
from datetime import datetime
|
11 |
|
12 |
-
# from models import ChatMessage # Not strictly needed if we use dicts/mock objects
|
13 |
from config.settings import settings
|
14 |
-
from assets.logo import get_logo_path
|
15 |
from services.logger import app_logger
|
16 |
|
17 |
-
class MockChatMessage:
|
|
|
|
|
|
|
|
|
18 |
def __init__(self, role: str, content: str, timestamp: datetime, tool_name: Optional[str] = None, **kwargs):
|
19 |
self.role = role
|
20 |
self.content = content
|
21 |
self.timestamp = timestamp
|
22 |
self.tool_name = tool_name
|
23 |
-
#
|
24 |
for key, value in kwargs.items():
|
25 |
setattr(self, key, value)
|
26 |
|
27 |
-
|
28 |
def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
29 |
buffer = BytesIO()
|
30 |
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
@@ -35,28 +37,26 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
35 |
|
36 |
# Custom Styles
|
37 |
styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
|
38 |
-
styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10))
|
39 |
styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1))
|
40 |
styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1))
|
41 |
-
styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch))
|
42 |
|
43 |
-
user_msg_style = ParagraphStyle(name='UserMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.whitesmoke)
|
44 |
-
ai_msg_style = ParagraphStyle(name='AIMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.lightcyan)
|
45 |
-
tool_msg_style = ParagraphStyle(name='ToolMessage', parent=styles['Code'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.lightgrey, textColor=colors.darkblue)
|
46 |
-
system_msg_style = ParagraphStyle(name='SystemMessage', parent=styles['Italic'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.beige, fontSize=9)
|
47 |
-
|
48 |
|
49 |
-
# --- Extract data
|
50 |
-
patient_name = report_data.get("patient_name", "N/A")
|
51 |
session_id = report_data.get("session_id", "N/A")
|
52 |
session_title = report_data.get("session_title", "Untitled Consultation")
|
53 |
session_start_time_obj = report_data.get("session_start_time")
|
54 |
patient_context_summary = report_data.get("patient_context_summary", "No specific patient context was provided for this session.")
|
55 |
|
56 |
-
|
57 |
-
|
58 |
-
messages = [MockChatMessage(**msg_data) for msg_data in
|
59 |
-
|
60 |
|
61 |
# 1. Logo and Document Header
|
62 |
logo_path_str = get_logo_path()
|
@@ -65,11 +65,10 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
65 |
img = Image(logo_path_str, width=0.8*inch, height=0.8*inch, preserveAspectRatio=True)
|
66 |
img.hAlign = 'LEFT'
|
67 |
story.append(img)
|
68 |
-
story.append(Spacer(1, 0.
|
69 |
-
except Exception as e:
|
70 |
-
app_logger.warning(f"PDF Report: Could not add logo: {e}")
|
71 |
|
72 |
-
story.append(Paragraph(
|
73 |
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
74 |
story.append(Spacer(1, 0.2*inch))
|
75 |
|
@@ -77,18 +76,18 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
77 |
report_date_str = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
|
78 |
session_start_time_str = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
|
79 |
|
80 |
-
|
81 |
[Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(report_date_str, styles['Normal'])],
|
82 |
-
[Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(patient_name, styles['Normal'])],
|
83 |
[Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(str(session_id), styles['Normal'])],
|
84 |
[Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title, styles['Normal'])],
|
85 |
[Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str, styles['Normal'])],
|
86 |
]
|
87 |
-
meta_table = Table(
|
88 |
meta_table.setStyle(TableStyle([
|
89 |
-
('GRID', (0,0), (-1,-1), 0.5, colors.grey),
|
90 |
-
('
|
91 |
-
('
|
92 |
]))
|
93 |
story.append(meta_table)
|
94 |
story.append(Spacer(1, 0.3*inch))
|
@@ -96,73 +95,69 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
96 |
# 3. Disclaimer
|
97 |
story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
|
98 |
story.append(Paragraph(settings.MAIN_DISCLAIMER_LONG, styles['Disclaimer']))
|
99 |
-
|
|
|
100 |
story.append(Spacer(1, 0.3*inch))
|
101 |
|
102 |
# 4. Patient Context Provided (if any)
|
103 |
-
if patient_context_summary and patient_context_summary
|
104 |
-
story.append(Paragraph("Patient Context Provided (Simulated Data):", styles['SectionHeader']))
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
for part in context_parts:
|
109 |
-
if part.strip():
|
110 |
-
story.append(Paragraph(f"• {part.strip()}", styles['ListItem']))
|
111 |
story.append(Spacer(1, 0.2*inch))
|
112 |
|
113 |
# 5. Consultation Transcript
|
114 |
story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
|
115 |
story.append(Spacer(1, 0.1*inch))
|
116 |
|
117 |
-
for msg in messages:
|
118 |
timestamp_str = msg.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if msg.timestamp else "N/A"
|
119 |
-
|
120 |
-
|
121 |
|
122 |
if msg.role == 'assistant':
|
123 |
-
|
124 |
-
|
125 |
elif msg.role == 'user':
|
126 |
-
|
127 |
-
|
128 |
elif msg.role == 'tool':
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
elif msg.role == 'system': #
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
|
|
138 |
|
139 |
-
# Sanitize content for ReportLab Paragraph (handles HTML-like tags for <br/>)
|
140 |
content_for_pdf = msg.content.replace('\n', '<br/>\n')
|
141 |
content_for_pdf = content_for_pdf.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")
|
|
|
|
|
|
|
|
|
142 |
|
143 |
-
story.append(Paragraph(f"<b>{prefix}</b><br/>{content_for_pdf}", current_style))
|
144 |
-
# story.append(Spacer(1, 0.05*inch)) # Removed for tighter message packing
|
145 |
-
|
146 |
-
# Footer on each page (if needed, more complex - requires onLaterPages in SimpleDocTemplate)
|
147 |
-
# For simplicity, a final "End of Report"
|
148 |
story.append(Spacer(1, 0.5*inch))
|
149 |
-
story.append(Paragraph("--- End of Report ---", styles['Italic']))
|
150 |
|
151 |
try:
|
152 |
doc.build(story)
|
153 |
buffer.seek(0)
|
154 |
-
app_logger.info(f"PDF report generated for session ID: {session_id}")
|
155 |
except Exception as e:
|
156 |
app_logger.error(f"Failed to build PDF document for session ID {session_id}: {e}", exc_info=True)
|
157 |
-
buffer = BytesIO() #
|
158 |
-
#
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
try:
|
164 |
-
|
165 |
-
except: pass # If even error PDF fails, return empty
|
166 |
buffer.seek(0)
|
167 |
|
168 |
return buffer
|
|
|
1 |
# /home/user/app/services/pdf_report.py
|
2 |
from reportlab.lib.pagesizes import letter
|
3 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
|
4 |
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
5 |
from reportlab.lib.units import inch
|
6 |
from reportlab.lib import colors
|
7 |
from io import BytesIO
|
8 |
+
from typing import List, Dict, Any, Optional # IMPORTED Optional
|
9 |
from pathlib import Path
|
10 |
from datetime import datetime
|
11 |
|
|
|
12 |
from config.settings import settings
|
13 |
+
from assets.logo import get_logo_path # Assuming this function exists and works
|
14 |
from services.logger import app_logger
|
15 |
|
16 |
+
class MockChatMessage:
|
17 |
+
"""
|
18 |
+
A simple class to hold message data for PDF generation if generate_pdf_report
|
19 |
+
prefers object attribute access over dictionary key access for messages.
|
20 |
+
"""
|
21 |
def __init__(self, role: str, content: str, timestamp: datetime, tool_name: Optional[str] = None, **kwargs):
|
22 |
self.role = role
|
23 |
self.content = content
|
24 |
self.timestamp = timestamp
|
25 |
self.tool_name = tool_name
|
26 |
+
# Capture any other attributes that might be passed (e.g., source_references)
|
27 |
for key, value in kwargs.items():
|
28 |
setattr(self, key, value)
|
29 |
|
|
|
30 |
def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
31 |
buffer = BytesIO()
|
32 |
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
|
|
37 |
|
38 |
# Custom Styles
|
39 |
styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
|
40 |
+
styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10, spaceBefore=6, spaceAfter=6))
|
41 |
styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1))
|
42 |
styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1))
|
43 |
+
styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
|
44 |
|
45 |
+
user_msg_style = ParagraphStyle(name='UserMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.whitesmoke, borderColor=colors.lightgrey, borderWidth=0.5, padding=3)
|
46 |
+
ai_msg_style = ParagraphStyle(name='AIMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.lightcyan, borderColor=colors.lightgrey, borderWidth=0.5, padding=3)
|
47 |
+
tool_msg_style = ParagraphStyle(name='ToolMessage', parent=styles['Code'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.lightgrey, textColor=colors.darkblue, borderColor=colors.grey, borderWidth=0.5, padding=3)
|
48 |
+
system_msg_style = ParagraphStyle(name='SystemMessage', parent=styles['Italic'], spaceBefore=3, spaceAfter=3, leftIndent=0.1*inch, backColor=colors.beige, fontSize=9, borderColor=colors.darkgrey, borderWidth=0.5, padding=3)
|
|
|
49 |
|
50 |
+
# --- Extract data ---
|
51 |
+
patient_name = report_data.get("patient_name", "N/A Clinician") # Clinician's username
|
52 |
session_id = report_data.get("session_id", "N/A")
|
53 |
session_title = report_data.get("session_title", "Untitled Consultation")
|
54 |
session_start_time_obj = report_data.get("session_start_time")
|
55 |
patient_context_summary = report_data.get("patient_context_summary", "No specific patient context was provided for this session.")
|
56 |
|
57 |
+
raw_messages_data = report_data.get("messages", []) # Expects a list of dicts
|
58 |
+
# Convert message dictionaries to MockChatMessage objects for consistent attribute access
|
59 |
+
messages = [MockChatMessage(**msg_data) for msg_data in raw_messages_data]
|
|
|
60 |
|
61 |
# 1. Logo and Document Header
|
62 |
logo_path_str = get_logo_path()
|
|
|
65 |
img = Image(logo_path_str, width=0.8*inch, height=0.8*inch, preserveAspectRatio=True)
|
66 |
img.hAlign = 'LEFT'
|
67 |
story.append(img)
|
68 |
+
story.append(Spacer(1, 0.05*inch)) # Reduced spacer
|
69 |
+
except Exception as e: app_logger.warning(f"PDF Report: Could not add logo: {e}")
|
|
|
70 |
|
71 |
+
story.append(Paragraph(settings.APP_TITLE, styles['h1']))
|
72 |
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
73 |
story.append(Spacer(1, 0.2*inch))
|
74 |
|
|
|
76 |
report_date_str = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
|
77 |
session_start_time_str = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
|
78 |
|
79 |
+
meta_data_content = [
|
80 |
[Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(report_date_str, styles['Normal'])],
|
81 |
+
[Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(patient_name, styles['Normal'])],
|
82 |
[Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(str(session_id), styles['Normal'])],
|
83 |
[Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title, styles['Normal'])],
|
84 |
[Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str, styles['Normal'])],
|
85 |
]
|
86 |
+
meta_table = Table(meta_data_content, colWidths=[2.0*inch, None]) # Adjusted colWidths
|
87 |
meta_table.setStyle(TableStyle([
|
88 |
+
('GRID', (0,0), (-1,-1), 0.5, colors.grey), ('VALIGN', (0,0), (-1,-1), 'TOP'),
|
89 |
+
('BACKGROUND', (0,0), (0,-1), colors.lightgrey), ('LEFTPADDING', (0,0), (-1,-1), 3),
|
90 |
+
('RIGHTPADDING', (0,0), (-1,-1), 3), ('TOPPADDING', (0,0), (-1,-1), 3), ('BOTTOMPADDING', (0,0), (-1,-1), 3)
|
91 |
]))
|
92 |
story.append(meta_table)
|
93 |
story.append(Spacer(1, 0.3*inch))
|
|
|
95 |
# 3. Disclaimer
|
96 |
story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
|
97 |
story.append(Paragraph(settings.MAIN_DISCLAIMER_LONG, styles['Disclaimer']))
|
98 |
+
if settings.SIMULATION_DISCLAIMER: # Only add if defined
|
99 |
+
story.append(Paragraph(settings.SIMULATION_DISCLAIMER, styles['Disclaimer']))
|
100 |
story.append(Spacer(1, 0.3*inch))
|
101 |
|
102 |
# 4. Patient Context Provided (if any)
|
103 |
+
if patient_context_summary and patient_context_summary not in ["No specific patient context was provided for this session.", "Not provided."]:
|
104 |
+
story.append(Paragraph("Patient Context Provided by Clinician (Simulated Data):", styles['SectionHeader']))
|
105 |
+
context_items = patient_context_summary.replace("Patient Context: ", "").split(';')
|
106 |
+
for item in context_items:
|
107 |
+
if item.strip(): story.append(Paragraph(f"• {item.strip()}", styles['ListItem']))
|
|
|
|
|
|
|
108 |
story.append(Spacer(1, 0.2*inch))
|
109 |
|
110 |
# 5. Consultation Transcript
|
111 |
story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
|
112 |
story.append(Spacer(1, 0.1*inch))
|
113 |
|
114 |
+
for msg in messages: # Iterate over MockChatMessage objects
|
115 |
timestamp_str = msg.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if msg.timestamp else "N/A"
|
116 |
+
prefix_text = ""
|
117 |
+
current_message_style = styles['Normal']
|
118 |
|
119 |
if msg.role == 'assistant':
|
120 |
+
prefix_text = f"AI Assistant ({timestamp_str}):"
|
121 |
+
current_message_style = ai_msg_style
|
122 |
elif msg.role == 'user':
|
123 |
+
prefix_text = f"Clinician ({timestamp_str}):"
|
124 |
+
current_message_style = user_msg_style
|
125 |
elif msg.role == 'tool':
|
126 |
+
tool_name_display = getattr(msg, 'tool_name', 'Tool') or "Tool" # Handle None or empty tool_name
|
127 |
+
prefix_text = f"{tool_name_display.capitalize()} Output ({timestamp_str}):"
|
128 |
+
current_message_style = tool_msg_style
|
129 |
+
elif msg.role == 'system' and msg.content.startswith("Initial Patient Context Set:"): # Don't show this verbose system message
|
130 |
+
continue
|
131 |
+
elif msg.role == 'system':
|
132 |
+
prefix_text = f"System Note ({timestamp_str}):"
|
133 |
+
current_message_style = system_msg_style
|
134 |
+
else:
|
135 |
+
prefix_text = f"{msg.role.capitalize()} ({timestamp_str}):"
|
136 |
|
|
|
137 |
content_for_pdf = msg.content.replace('\n', '<br/>\n')
|
138 |
content_for_pdf = content_for_pdf.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")
|
139 |
+
|
140 |
+
story.append(Paragraph(f"<b>{prefix_text}</b>", styles['Normal'])) # Prefix bolded on its own line
|
141 |
+
story.append(Paragraph(content_for_pdf, current_message_style)) # Content in styled box
|
142 |
+
story.append(Spacer(1, 0.05*inch))
|
143 |
|
|
|
|
|
|
|
|
|
|
|
144 |
story.append(Spacer(1, 0.5*inch))
|
145 |
+
story.append(Paragraph("--- End of Report ---", ParagraphStyle(name='EndOfReport', parent=styles['Italic'], alignment=1)))
|
146 |
|
147 |
try:
|
148 |
doc.build(story)
|
149 |
buffer.seek(0)
|
150 |
+
app_logger.info(f"PDF report generated successfully for session ID: {session_id}")
|
151 |
except Exception as e:
|
152 |
app_logger.error(f"Failed to build PDF document for session ID {session_id}: {e}", exc_info=True)
|
153 |
+
buffer = BytesIO() # Reset to empty buffer on error
|
154 |
+
# Create a simple error PDF
|
155 |
+
error_styles_local = getSampleStyleSheet() # Get fresh styles
|
156 |
+
error_doc_local = SimpleDocTemplate(buffer, pagesize=letter)
|
157 |
+
error_story_local = [Paragraph("Error: Could not generate PDF report.", error_styles_local['h1']),
|
158 |
+
Paragraph(f"Details: {str(e)[:500]}", error_styles_local['Normal'])]
|
159 |
+
try: error_doc_local.build(error_story_local)
|
160 |
+
except: pass # If even error PDF fails, return empty buffer
|
|
|
161 |
buffer.seek(0)
|
162 |
|
163 |
return buffer
|