Update services/pdf_report.py
Browse files- services/pdf_report.py +91 -210
services/pdf_report.py
CHANGED
@@ -16,57 +16,83 @@ from services.logger import app_logger
|
|
16 |
class MockChatMessage:
|
17 |
"""
|
18 |
A simple data class to hold message attributes for PDF generation.
|
19 |
-
Ensures consistent attribute access and helps with type handling.
|
20 |
"""
|
21 |
-
def __init__(self, role:
|
|
|
22 |
self.role: str = str(role if role is not None else "unknown")
|
23 |
-
self.content: str = str(content if content is not None else "")
|
24 |
self.timestamp: Optional[datetime] = timestamp # Keep as datetime, format when used
|
25 |
self.tool_name: Optional[str] = str(tool_name) if tool_name is not None else None
|
26 |
|
27 |
# Store any other potential attributes (e.g., source_references, confidence)
|
|
|
28 |
for key, value in kwargs.items():
|
29 |
setattr(self, key, value)
|
30 |
|
31 |
def get_formatted_timestamp(self, default_val: str = "Time N/A") -> str:
|
|
|
32 |
return self.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if self.timestamp else default_val
|
33 |
|
34 |
def get_formatted_content_for_pdf(self) -> str:
|
35 |
-
|
36 |
-
text = self.content
|
37 |
-
|
|
|
|
|
38 |
|
39 |
|
40 |
def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
|
|
|
|
|
|
|
41 |
buffer = BytesIO()
|
42 |
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
43 |
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
44 |
-
topMargin=0.75*inch, bottomMargin=0.75*inch
|
|
|
|
|
|
|
45 |
styles = getSampleStyleSheet()
|
46 |
-
story = []
|
47 |
|
48 |
# --- Custom Styles Definition ---
|
|
|
49 |
styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
|
50 |
styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10, spaceBefore=6, spaceAfter=6, textColor=colors.dimgrey))
|
51 |
-
styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1, textColor=colors.
|
52 |
-
styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1, textColor=colors.
|
53 |
styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
|
54 |
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
|
|
|
|
|
|
|
|
60 |
|
61 |
# --- Extract data safely from report_data dictionary ---
|
62 |
-
|
|
|
63 |
session_id_str = str(report_data.get("session_id", "N/A"))
|
64 |
session_title_str = str(report_data.get("session_title", "Untitled Consultation"))
|
65 |
-
session_start_time_obj = report_data.get("session_start_time") #
|
66 |
-
patient_context_summary_str = str(report_data.get("patient_context_summary", "No specific patient context was provided."))
|
67 |
|
68 |
-
#
|
|
|
69 |
messages: List[MockChatMessage] = report_data.get("messages", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
|
71 |
|
72 |
# 1. Logo and Document Header
|
@@ -75,32 +101,32 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
75 |
if logo_path_str and Path(logo_path_str).exists():
|
76 |
try:
|
77 |
img = Image(logo_path_str, width=0.75*inch, height=0.75*inch, preserveAspectRatio=True)
|
78 |
-
img.hAlign = 'LEFT'
|
79 |
story.append(img)
|
80 |
story.append(Spacer(1, 0.05*inch))
|
81 |
except Exception as e: app_logger.warning(f"PDF Report: Could not add logo image: {e}")
|
82 |
|
83 |
-
story.append(Paragraph(str(settings.APP_TITLE), styles['h1']))
|
84 |
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
85 |
story.append(Spacer(1, 0.2*inch))
|
86 |
|
87 |
# 2. Report Metadata Table
|
88 |
app_logger.debug("PDF Generation: Adding metadata table.")
|
89 |
-
|
90 |
session_start_time_str_display = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
|
91 |
|
92 |
meta_data_content = [
|
93 |
-
[Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(
|
94 |
[Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(clinician_username, styles['Normal'])],
|
95 |
[Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(session_id_str, styles['Normal'])],
|
96 |
[Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title_str, styles['Normal'])],
|
97 |
[Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str_display, styles['Normal'])],
|
98 |
]
|
99 |
-
meta_table = Table(meta_data_content, colWidths=[2.0*inch,
|
100 |
meta_table.setStyle(TableStyle([
|
101 |
('GRID', (0,0), (-1,-1), 0.5, colors.darkgrey), ('VALIGN', (0,0), (-1,-1), 'TOP'),
|
102 |
-
('BACKGROUND', (0,0), (0,-1), colors.lightgrey), ('LEFTPADDING', (0,0), (-1,-1),
|
103 |
-
('RIGHTPADDING', (0,0), (-1,-1),
|
104 |
]))
|
105 |
story.append(meta_table)
|
106 |
story.append(Spacer(1, 0.3*inch))
|
@@ -115,51 +141,57 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
115 |
|
116 |
# 4. Patient Context Provided (if any)
|
117 |
app_logger.debug("PDF Generation: Adding patient context summary.")
|
118 |
-
if patient_context_summary_str and patient_context_summary_str.lower() not in ["no specific patient context was provided for this session.", "not provided."]:
|
119 |
story.append(Paragraph("Patient Context Provided by Clinician (Simulated Data):", styles['SectionHeader']))
|
120 |
-
#
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
126 |
|
127 |
# 5. Consultation Transcript
|
128 |
app_logger.debug(f"PDF Generation: Adding transcript with {len(messages)} messages.")
|
129 |
story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
|
130 |
story.append(Spacer(1, 0.1*inch))
|
131 |
|
132 |
-
for msg_obj in messages: # msg_obj is
|
133 |
-
# Skip system messages that are just context logging, unless you want them
|
134 |
if msg_obj.role == 'system' and "Initial Patient Context Set:" in msg_obj.content:
|
135 |
app_logger.debug(f"Skipping system context message in PDF transcript: {msg_obj.content[:50]}...")
|
136 |
continue
|
137 |
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
|
142 |
if msg_obj.role == 'assistant':
|
143 |
-
|
144 |
-
|
145 |
elif msg_obj.role == 'user':
|
146 |
-
|
147 |
-
|
148 |
elif msg_obj.role == 'tool':
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
elif msg_obj.role == 'system':
|
153 |
-
|
154 |
-
|
155 |
-
else:
|
156 |
-
|
157 |
|
158 |
-
|
159 |
|
160 |
-
|
161 |
-
story.append(Paragraph(
|
162 |
-
|
|
|
163 |
|
164 |
story.append(Spacer(1, 0.5*inch))
|
165 |
story.append(Paragraph("--- End of Report ---", ParagraphStyle(name='EndOfReport', parent=styles['Italic'], alignment=1, spaceBefore=12)))
|
@@ -173,170 +205,19 @@ def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
|
173 |
except Exception as e_build:
|
174 |
app_logger.error(f"Failed to build PDF document for session ID {session_id_str}: {e_build}", exc_info=True)
|
175 |
# Return an error PDF or an empty buffer
|
176 |
-
buffer = BytesIO()
|
177 |
-
error_styles_local = getSampleStyleSheet()
|
178 |
error_doc_local = SimpleDocTemplate(buffer, pagesize=letter)
|
179 |
error_story_local = [
|
180 |
Paragraph("Error: Could not generate PDF report.", error_styles_local['h1']),
|
181 |
-
Paragraph(f"An error occurred during PDF construction: {str(e_build)[:500]}", error_styles_local['Normal']),
|
182 |
-
Paragraph("Please check application logs for more details.", error_styles_local['Normal'])
|
183 |
]
|
184 |
try:
|
185 |
error_doc_local.build(error_story_local)
|
186 |
-
except Exception as e_err_pdf:
|
187 |
app_logger.error(f"Failed to build even the error PDF: {e_err_pdf}", exc_info=True)
|
188 |
-
#
|
189 |
-
buffer.seek(0)
|
190 |
-
|
191 |
-
return buffer
|
192 |
-
from assets.logo import get_logo_path # Assuming this function exists and works
|
193 |
-
from services.logger import app_logger
|
194 |
-
|
195 |
-
class MockChatMessage:
|
196 |
-
"""
|
197 |
-
A simple class to hold message data for PDF generation if generate_pdf_report
|
198 |
-
prefers object attribute access over dictionary key access for messages.
|
199 |
-
"""
|
200 |
-
def __init__(self, role: str, content: str, timestamp: datetime, tool_name: Optional[str] = None, **kwargs):
|
201 |
-
self.role = role
|
202 |
-
self.content = content
|
203 |
-
self.timestamp = timestamp
|
204 |
-
self.tool_name = tool_name
|
205 |
-
# Capture any other attributes that might be passed (e.g., source_references)
|
206 |
-
for key, value in kwargs.items():
|
207 |
-
setattr(self, key, value)
|
208 |
-
|
209 |
-
def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
210 |
-
buffer = BytesIO()
|
211 |
-
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
212 |
-
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
213 |
-
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
214 |
-
styles = getSampleStyleSheet()
|
215 |
-
story = []
|
216 |
-
|
217 |
-
# Custom Styles
|
218 |
-
styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
|
219 |
-
styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10, spaceBefore=6, spaceAfter=6))
|
220 |
-
styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1))
|
221 |
-
styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1))
|
222 |
-
styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
|
223 |
-
|
224 |
-
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)
|
225 |
-
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)
|
226 |
-
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)
|
227 |
-
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)
|
228 |
-
|
229 |
-
# --- Extract data ---
|
230 |
-
patient_name = report_data.get("patient_name", "N/A Clinician") # Clinician's username
|
231 |
-
session_id = report_data.get("session_id", "N/A")
|
232 |
-
session_title = report_data.get("session_title", "Untitled Consultation")
|
233 |
-
session_start_time_obj = report_data.get("session_start_time")
|
234 |
-
patient_context_summary = report_data.get("patient_context_summary", "No specific patient context was provided for this session.")
|
235 |
-
|
236 |
-
raw_messages_data = report_data.get("messages", []) # Expects a list of dicts
|
237 |
-
# Convert message dictionaries to MockChatMessage objects for consistent attribute access
|
238 |
-
messages = [MockChatMessage(**msg_data) for msg_data in raw_messages_data]
|
239 |
-
|
240 |
-
# 1. Logo and Document Header
|
241 |
-
logo_path_str = get_logo_path()
|
242 |
-
if logo_path_str and Path(logo_path_str).exists():
|
243 |
-
try:
|
244 |
-
img = Image(logo_path_str, width=0.8*inch, height=0.8*inch, preserveAspectRatio=True)
|
245 |
-
img.hAlign = 'LEFT'
|
246 |
-
story.append(img)
|
247 |
-
story.append(Spacer(1, 0.05*inch)) # Reduced spacer
|
248 |
-
except Exception as e: app_logger.warning(f"PDF Report: Could not add logo: {e}")
|
249 |
-
|
250 |
-
story.append(Paragraph(settings.APP_TITLE, styles['h1']))
|
251 |
-
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
252 |
-
story.append(Spacer(1, 0.2*inch))
|
253 |
-
|
254 |
-
# 2. Report Metadata Table
|
255 |
-
report_date_str = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
|
256 |
-
session_start_time_str = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
|
257 |
-
|
258 |
-
meta_data_content = [
|
259 |
-
[Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(report_date_str, styles['Normal'])],
|
260 |
-
[Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(patient_name, styles['Normal'])],
|
261 |
-
[Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(str(session_id), styles['Normal'])],
|
262 |
-
[Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title, styles['Normal'])],
|
263 |
-
[Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str, styles['Normal'])],
|
264 |
-
]
|
265 |
-
meta_table = Table(meta_data_content, colWidths=[2.0*inch, None]) # Adjusted colWidths
|
266 |
-
meta_table.setStyle(TableStyle([
|
267 |
-
('GRID', (0,0), (-1,-1), 0.5, colors.grey), ('VALIGN', (0,0), (-1,-1), 'TOP'),
|
268 |
-
('BACKGROUND', (0,0), (0,-1), colors.lightgrey), ('LEFTPADDING', (0,0), (-1,-1), 3),
|
269 |
-
('RIGHTPADDING', (0,0), (-1,-1), 3), ('TOPPADDING', (0,0), (-1,-1), 3), ('BOTTOMPADDING', (0,0), (-1,-1), 3)
|
270 |
-
]))
|
271 |
-
story.append(meta_table)
|
272 |
-
story.append(Spacer(1, 0.3*inch))
|
273 |
-
|
274 |
-
# 3. Disclaimer
|
275 |
-
story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
|
276 |
-
story.append(Paragraph(settings.MAIN_DISCLAIMER_LONG, styles['Disclaimer']))
|
277 |
-
if settings.SIMULATION_DISCLAIMER: # Only add if defined
|
278 |
-
story.append(Paragraph(settings.SIMULATION_DISCLAIMER, styles['Disclaimer']))
|
279 |
-
story.append(Spacer(1, 0.3*inch))
|
280 |
-
|
281 |
-
# 4. Patient Context Provided (if any)
|
282 |
-
if patient_context_summary and patient_context_summary not in ["No specific patient context was provided for this session.", "Not provided."]:
|
283 |
-
story.append(Paragraph("Patient Context Provided by Clinician (Simulated Data):", styles['SectionHeader']))
|
284 |
-
context_items = patient_context_summary.replace("Patient Context: ", "").split(';')
|
285 |
-
for item in context_items:
|
286 |
-
if item.strip(): story.append(Paragraph(f"• {item.strip()}", styles['ListItem']))
|
287 |
-
story.append(Spacer(1, 0.2*inch))
|
288 |
-
|
289 |
-
# 5. Consultation Transcript
|
290 |
-
story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
|
291 |
-
story.append(Spacer(1, 0.1*inch))
|
292 |
-
|
293 |
-
for msg in messages: # Iterate over MockChatMessage objects
|
294 |
-
timestamp_str = msg.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if msg.timestamp else "N/A"
|
295 |
-
prefix_text = ""
|
296 |
-
current_message_style = styles['Normal']
|
297 |
-
|
298 |
-
if msg.role == 'assistant':
|
299 |
-
prefix_text = f"AI Assistant ({timestamp_str}):"
|
300 |
-
current_message_style = ai_msg_style
|
301 |
-
elif msg.role == 'user':
|
302 |
-
prefix_text = f"Clinician ({timestamp_str}):"
|
303 |
-
current_message_style = user_msg_style
|
304 |
-
elif msg.role == 'tool':
|
305 |
-
tool_name_display = getattr(msg, 'tool_name', 'Tool') or "Tool" # Handle None or empty tool_name
|
306 |
-
prefix_text = f"{tool_name_display.capitalize()} Output ({timestamp_str}):"
|
307 |
-
current_message_style = tool_msg_style
|
308 |
-
elif msg.role == 'system' and msg.content.startswith("Initial Patient Context Set:"): # Don't show this verbose system message
|
309 |
-
continue
|
310 |
-
elif msg.role == 'system':
|
311 |
-
prefix_text = f"System Note ({timestamp_str}):"
|
312 |
-
current_message_style = system_msg_style
|
313 |
-
else:
|
314 |
-
prefix_text = f"{msg.role.capitalize()} ({timestamp_str}):"
|
315 |
-
|
316 |
-
content_for_pdf = msg.content.replace('\n', '<br/>\n')
|
317 |
-
content_for_pdf = content_for_pdf.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")
|
318 |
-
|
319 |
-
story.append(Paragraph(f"<b>{prefix_text}</b>", styles['Normal'])) # Prefix bolded on its own line
|
320 |
-
story.append(Paragraph(content_for_pdf, current_message_style)) # Content in styled box
|
321 |
-
story.append(Spacer(1, 0.05*inch))
|
322 |
-
|
323 |
-
story.append(Spacer(1, 0.5*inch))
|
324 |
-
story.append(Paragraph("--- End of Report ---", ParagraphStyle(name='EndOfReport', parent=styles['Italic'], alignment=1)))
|
325 |
-
|
326 |
-
try:
|
327 |
-
doc.build(story)
|
328 |
-
buffer.seek(0)
|
329 |
-
app_logger.info(f"PDF report generated successfully for session ID: {session_id}")
|
330 |
-
except Exception as e:
|
331 |
-
app_logger.error(f"Failed to build PDF document for session ID {session_id}: {e}", exc_info=True)
|
332 |
-
buffer = BytesIO() # Reset to empty buffer on error
|
333 |
-
# Create a simple error PDF
|
334 |
-
error_styles_local = getSampleStyleSheet() # Get fresh styles
|
335 |
-
error_doc_local = SimpleDocTemplate(buffer, pagesize=letter)
|
336 |
-
error_story_local = [Paragraph("Error: Could not generate PDF report.", error_styles_local['h1']),
|
337 |
-
Paragraph(f"Details: {str(e)[:500]}", error_styles_local['Normal'])]
|
338 |
-
try: error_doc_local.build(error_story_local)
|
339 |
-
except: pass # If even error PDF fails, return empty buffer
|
340 |
buffer.seek(0)
|
341 |
|
342 |
return buffer
|
|
|
16 |
class MockChatMessage:
|
17 |
"""
|
18 |
A simple data class to hold message attributes for PDF generation.
|
19 |
+
Ensures consistent attribute access and helps with type handling before ReportLab.
|
20 |
"""
|
21 |
+
def __init__(self, role: Optional[str], content: Optional[str],
|
22 |
+
timestamp: Optional[datetime], tool_name: Optional[str] = None, **kwargs):
|
23 |
self.role: str = str(role if role is not None else "unknown")
|
24 |
+
self.content: str = str(content if content is not None else "") # Ensure content is always a string
|
25 |
self.timestamp: Optional[datetime] = timestamp # Keep as datetime, format when used
|
26 |
self.tool_name: Optional[str] = str(tool_name) if tool_name is not None else None
|
27 |
|
28 |
# Store any other potential attributes (e.g., source_references, confidence)
|
29 |
+
# This allows flexibility if msg_data dicts from pages/3_Reports.py have extra keys
|
30 |
for key, value in kwargs.items():
|
31 |
setattr(self, key, value)
|
32 |
|
33 |
def get_formatted_timestamp(self, default_val: str = "Time N/A") -> str:
|
34 |
+
"""Returns a formatted string for the timestamp, or a default value."""
|
35 |
return self.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if self.timestamp else default_val
|
36 |
|
37 |
def get_formatted_content_for_pdf(self) -> str:
|
38 |
+
"""Ensures content is string, handles newlines for PDF, and escapes HTML-sensitive characters."""
|
39 |
+
text = self.content # Already ensured to be string in __init__
|
40 |
+
text_with_br = text.replace('\n', '<br/>\n')
|
41 |
+
# Basic escaping for ReportLab's Paragraph (which understands some HTML tags)
|
42 |
+
return text_with_br.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")
|
43 |
|
44 |
|
45 |
def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
|
46 |
+
"""
|
47 |
+
Generates a PDF report from the provided report_data dictionary.
|
48 |
+
`report_data["messages"]` is expected to be a List[MockChatMessage].
|
49 |
+
"""
|
50 |
buffer = BytesIO()
|
51 |
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
52 |
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
53 |
+
topMargin=0.75*inch, bottomMargin=0.75*inch,
|
54 |
+
title=f"Consultation Report - Session {report_data.get('session_id', 'N/A')}",
|
55 |
+
author=settings.APP_TITLE)
|
56 |
+
|
57 |
styles = getSampleStyleSheet()
|
58 |
+
story: List[Any] = [] # To hold ReportLab Flowables
|
59 |
|
60 |
# --- Custom Styles Definition ---
|
61 |
+
# Add new styles or modify existing ones for better visual appearance
|
62 |
styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
|
63 |
styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10, spaceBefore=6, spaceAfter=6, textColor=colors.dimgrey))
|
64 |
+
styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1, textColor=colors.HexColor("#000080"))) # Navy Blue
|
65 |
+
styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1, textColor=colors.HexColor("#4682B4"))) # Steel Blue
|
66 |
styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
|
67 |
|
68 |
+
# Message Styles with borders and padding for distinct visual blocks
|
69 |
+
base_message_style = ParagraphStyle(name='BaseMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3,
|
70 |
+
leftIndent=0.1*inch, rightIndent=0.1*inch, leading=14,
|
71 |
+
borderWidth=0.5, borderColor=colors.lightgrey, borderPadding=5)
|
72 |
+
user_msg_style = ParagraphStyle(name='UserMessage', parent=base_message_style, backColor=colors.HexColor("#F0F8FF")) # AliceBlue
|
73 |
+
ai_msg_style = ParagraphStyle(name='AIMessage', parent=base_message_style, backColor=colors.HexColor("#F5FFFA")) # MintCream
|
74 |
+
tool_msg_style = ParagraphStyle(name='ToolMessage', parent=base_message_style, backColor=colors.HexColor("#FFF8DC"), fontName='Courier', textColor=colors.darkslategrey) # Cornsilk
|
75 |
+
system_msg_style = ParagraphStyle(name='SystemMessage', parent=base_message_style, backColor=colors.HexColor("#FAFAD2"), fontSize=9, textColor=colors.dimgrey, fontName='Helvetica-Oblique') # LightGoldenrodYellow
|
76 |
+
|
77 |
|
78 |
# --- Extract data safely from report_data dictionary ---
|
79 |
+
# Ensure all text passed to Paragraph is string type
|
80 |
+
clinician_username = str(report_data.get("patient_name", "N/A Clinician"))
|
81 |
session_id_str = str(report_data.get("session_id", "N/A"))
|
82 |
session_title_str = str(report_data.get("session_title", "Untitled Consultation"))
|
83 |
+
session_start_time_obj = report_data.get("session_start_time") # datetime object or None
|
84 |
+
patient_context_summary_str = str(report_data.get("patient_context_summary", "No specific patient context was provided for this session."))
|
85 |
|
86 |
+
# `messages` key in report_data should already contain List[MockChatMessage] instances
|
87 |
+
# as prepared by pages/3_Reports.py
|
88 |
messages: List[MockChatMessage] = report_data.get("messages", [])
|
89 |
+
|
90 |
+
# Sanity check for messages type (optional, but good for debugging)
|
91 |
+
if not isinstance(messages, list) or (messages and not all(isinstance(m, MockChatMessage) for m in messages)):
|
92 |
+
app_logger.error("PDF Generation: 'messages' in report_data is not a list of MockChatMessage instances as expected!")
|
93 |
+
# Fallback to prevent downstream errors, or raise an error
|
94 |
+
messages = [] # Process an empty list to avoid further TypeErrors
|
95 |
+
story.append(Paragraph("Error: Message data for transcript was malformed.", styles['Heading3']))
|
96 |
|
97 |
|
98 |
# 1. Logo and Document Header
|
|
|
101 |
if logo_path_str and Path(logo_path_str).exists():
|
102 |
try:
|
103 |
img = Image(logo_path_str, width=0.75*inch, height=0.75*inch, preserveAspectRatio=True)
|
104 |
+
img.hAlign = 'LEFT' # Align logo to the left
|
105 |
story.append(img)
|
106 |
story.append(Spacer(1, 0.05*inch))
|
107 |
except Exception as e: app_logger.warning(f"PDF Report: Could not add logo image: {e}")
|
108 |
|
109 |
+
story.append(Paragraph(str(settings.APP_TITLE), styles['h1'])) # Ensure APP_TITLE is string
|
110 |
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
111 |
story.append(Spacer(1, 0.2*inch))
|
112 |
|
113 |
# 2. Report Metadata Table
|
114 |
app_logger.debug("PDF Generation: Adding metadata table.")
|
115 |
+
report_date_str_display = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
|
116 |
session_start_time_str_display = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
|
117 |
|
118 |
meta_data_content = [
|
119 |
+
[Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(report_date_str_display, styles['Normal'])],
|
120 |
[Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(clinician_username, styles['Normal'])],
|
121 |
[Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(session_id_str, styles['Normal'])],
|
122 |
[Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title_str, styles['Normal'])],
|
123 |
[Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str_display, styles['Normal'])],
|
124 |
]
|
125 |
+
meta_table = Table(meta_data_content, colWidths=[2.0*inch, 4.5*inch]) # Adjusted colWidths
|
126 |
meta_table.setStyle(TableStyle([
|
127 |
('GRID', (0,0), (-1,-1), 0.5, colors.darkgrey), ('VALIGN', (0,0), (-1,-1), 'TOP'),
|
128 |
+
('BACKGROUND', (0,0), (0,-1), colors.lightgrey), ('LEFTPADDING', (0,0), (-1,-1), 6),
|
129 |
+
('RIGHTPADDING', (0,0), (-1,-1), 6), ('TOPPADDING', (0,0), (-1,-1), 4), ('BOTTOMPADDING', (0,0), (-1,-1), 4)
|
130 |
]))
|
131 |
story.append(meta_table)
|
132 |
story.append(Spacer(1, 0.3*inch))
|
|
|
141 |
|
142 |
# 4. Patient Context Provided (if any)
|
143 |
app_logger.debug("PDF Generation: Adding patient context summary.")
|
144 |
+
if patient_context_summary_str and patient_context_summary_str.lower() not in ["no specific patient context was provided for this session.", "not provided.", ""]:
|
145 |
story.append(Paragraph("Patient Context Provided by Clinician (Simulated Data):", styles['SectionHeader']))
|
146 |
+
# Clean up the context string before splitting
|
147 |
+
cleaned_context_str = patient_context_summary_str.replace("Patient Context: ", "").replace("Initial Patient Context Set: ","").strip()
|
148 |
+
if cleaned_context_str:
|
149 |
+
context_items_list = cleaned_context_str.split(';')
|
150 |
+
for item_str in context_items_list:
|
151 |
+
item_clean = item_str.strip()
|
152 |
+
if item_clean: story.append(Paragraph(f"• {item_clean}", styles['ListItem']))
|
153 |
+
story.append(Spacer(1, 0.2*inch))
|
154 |
+
else: # If after cleaning, string is empty
|
155 |
+
story.append(Paragraph("No specific patient context details were recorded for this session.", styles['Normal']))
|
156 |
+
story.append(Spacer(1, 0.2*inch))
|
157 |
+
|
158 |
|
159 |
# 5. Consultation Transcript
|
160 |
app_logger.debug(f"PDF Generation: Adding transcript with {len(messages)} messages.")
|
161 |
story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
|
162 |
story.append(Spacer(1, 0.1*inch))
|
163 |
|
164 |
+
for msg_obj in messages: # msg_obj is an instance of MockChatMessage
|
|
|
165 |
if msg_obj.role == 'system' and "Initial Patient Context Set:" in msg_obj.content:
|
166 |
app_logger.debug(f"Skipping system context message in PDF transcript: {msg_obj.content[:50]}...")
|
167 |
continue
|
168 |
|
169 |
+
formatted_timestamp_str = msg_obj.get_formatted_timestamp()
|
170 |
+
prefix_display_str = ""
|
171 |
+
active_message_style_for_loop = styles['Normal'] # Default
|
172 |
|
173 |
if msg_obj.role == 'assistant':
|
174 |
+
prefix_display_str = f"AI Assistant ({formatted_timestamp_str}):"
|
175 |
+
active_message_style_for_loop = ai_msg_style
|
176 |
elif msg_obj.role == 'user':
|
177 |
+
prefix_display_str = f"Clinician ({formatted_timestamp_str}):"
|
178 |
+
active_message_style_for_loop = user_msg_style
|
179 |
elif msg_obj.role == 'tool':
|
180 |
+
tool_name_str_display = msg_obj.tool_name or "Tool"
|
181 |
+
prefix_display_str = f"{tool_name_str_display.capitalize()} Output ({formatted_timestamp_str}):"
|
182 |
+
active_message_style_for_loop = tool_msg_style
|
183 |
elif msg_obj.role == 'system':
|
184 |
+
prefix_display_str = f"System Note ({formatted_timestamp_str}):"
|
185 |
+
active_message_style_for_loop = system_msg_style
|
186 |
+
else:
|
187 |
+
prefix_display_str = f"{msg_obj.role.capitalize()} ({formatted_timestamp_str}):"
|
188 |
|
189 |
+
formatted_content_str = msg_obj.get_formatted_content_for_pdf()
|
190 |
|
191 |
+
# Add prefix and content as separate paragraphs if you want prefix bold and content in styled box
|
192 |
+
story.append(Paragraph(f"<b>{prefix_display_str}</b>", styles['Normal']))
|
193 |
+
story.append(Paragraph(formatted_content_str, active_message_style_for_loop))
|
194 |
+
# story.append(Spacer(1, 0.05*inch)) # Optional: reduce space between messages
|
195 |
|
196 |
story.append(Spacer(1, 0.5*inch))
|
197 |
story.append(Paragraph("--- End of Report ---", ParagraphStyle(name='EndOfReport', parent=styles['Italic'], alignment=1, spaceBefore=12)))
|
|
|
205 |
except Exception as e_build:
|
206 |
app_logger.error(f"Failed to build PDF document for session ID {session_id_str}: {e_build}", exc_info=True)
|
207 |
# Return an error PDF or an empty buffer
|
208 |
+
buffer = BytesIO() # Reset buffer to ensure it's clean
|
209 |
+
error_styles_local = getSampleStyleSheet() # Get fresh styles for error PDF
|
210 |
error_doc_local = SimpleDocTemplate(buffer, pagesize=letter)
|
211 |
error_story_local = [
|
212 |
Paragraph("Error: Could not generate PDF report.", error_styles_local['h1']),
|
213 |
+
Paragraph(f"An error occurred during PDF construction: {str(e_build)[:500]}", error_styles_local['Normal']), # Limit error message length
|
214 |
+
Paragraph("Please check application logs for more details or contact support.", error_styles_local['Normal'])
|
215 |
]
|
216 |
try:
|
217 |
error_doc_local.build(error_story_local)
|
218 |
+
except Exception as e_err_pdf: # If even the error PDF fails
|
219 |
app_logger.error(f"Failed to build even the error PDF: {e_err_pdf}", exc_info=True)
|
220 |
+
# Buffer will be empty from the reset above
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
buffer.seek(0)
|
222 |
|
223 |
return buffer
|