mgbam commited on
Commit
68006c0
·
verified ·
1 Parent(s): 476a797

Update services/pdf_report.py

Browse files
Files changed (1) hide show
  1. 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: str, content: str, timestamp: Optional[datetime], tool_name: Optional[str] = None, **kwargs):
 
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
- # Ensure content is a string, handle newlines for PDF, and escape HTML-sensitive characters
36
- text = self.content.replace('\n', '<br/>\n')
37
- return text.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")
 
 
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.darkblue))
52
- styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1, textColor=colors.cadetblue))
53
  styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
54
 
55
- base_message_style = ParagraphStyle(name='BaseMessage', parent=styles['Normal'], spaceBefore=2, spaceAfter=2, leftIndent=0.1*inch, rightIndent=0.1*inch, leading=12, borderWidth=0.5, padding=(4, 4, 4, 4)) # top, right, bottom, left
56
- user_msg_style = ParagraphStyle(name='UserMessage', parent=base_message_style, backColor=colors.whitesmoke, borderColor=colors.lightgrey)
57
- ai_msg_style = ParagraphStyle(name='AIMessage', parent=base_message_style, backColor=colors.lightcyan, borderColor=colors.lightgrey)
58
- tool_msg_style = ParagraphStyle(name='ToolMessage', parent=base_message_style, backColor=colors.lightgoldenrodyellow, textColor=colors.darkslategrey, borderColor=colors.darkgrey, fontName='Courier')
59
- system_msg_style = ParagraphStyle(name='SystemMessage', parent=base_message_style, backColor=colors.beige, fontSize=9, textColor=colors.dimgrey, borderColor=colors.darkgrey, fontName='Helvetica-Oblique')
 
 
 
 
60
 
61
  # --- Extract data safely from report_data dictionary ---
62
- clinician_username = str(report_data.get("patient_name", "N/A Clinician")) # Actually the clinician's username
 
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") # This is a datetime object or None
66
- patient_context_summary_str = str(report_data.get("patient_context_summary", "No specific patient context was provided."))
67
 
68
- # Messages are expected to be List[MockChatMessage] from the caller
 
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
- report_date_str = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
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(report_date_str, styles['Normal'])],
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, None])
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), 5),
103
- ('RIGHTPADDING', (0,0), (-1,-1), 5), ('TOPPADDING', (0,0), (-1,-1), 3), ('BOTTOMPADDING', (0,0), (-1,-1), 3)
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
- # Split summary string into list items for better readability
121
- context_items_raw = patient_context_summary_str.replace("Patient Context: ", "").replace("Initial Patient Context Set: ","").split(';')
122
- for item_raw in context_items_raw:
123
- item_clean = item_raw.strip()
124
- if item_clean: story.append(Paragraph(f"• {item_clean}", styles['ListItem']))
125
- story.append(Spacer(1, 0.2*inch))
 
 
 
 
 
 
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 now an instance of MockChatMessage
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
- formatted_timestamp = msg_obj.get_formatted_timestamp()
139
- prefix_display = ""
140
- active_message_style = styles['Normal']
141
 
142
  if msg_obj.role == 'assistant':
143
- prefix_display = f"AI Assistant ({formatted_timestamp}):"
144
- active_message_style = ai_msg_style
145
  elif msg_obj.role == 'user':
146
- prefix_display = f"Clinician ({formatted_timestamp}):"
147
- active_message_style = user_msg_style
148
  elif msg_obj.role == 'tool':
149
- tool_name_str = msg_obj.tool_name or "Tool"
150
- prefix_display = f"{tool_name_str.capitalize()} Output ({formatted_timestamp}):"
151
- active_message_style = tool_msg_style
152
  elif msg_obj.role == 'system':
153
- prefix_display = f"System Note ({formatted_timestamp}):"
154
- active_message_style = system_msg_style
155
- else: # Fallback for any other roles
156
- prefix_display = f"{msg_obj.role.capitalize()} ({formatted_timestamp}):"
157
 
158
- formatted_content = msg_obj.get_formatted_content_for_pdf()
159
 
160
- story.append(Paragraph(f"<b>{prefix_display}</b>", styles['Normal']))
161
- story.append(Paragraph(formatted_content, active_message_style))
162
- # story.append(Spacer(1, 0.05*inch)) # Can make it too sparse
 
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
- # If error PDF fails, buffer will be empty from the reset
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