Update services/pdf_report.py
Browse files- services/pdf_report.py +180 -1
services/pdf_report.py
CHANGED
@@ -5,7 +5,7 @@ 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
|
9 |
from pathlib import Path
|
10 |
from datetime import datetime
|
11 |
|
@@ -13,6 +13,185 @@ 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
|
|
|
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
|
9 |
from pathlib import Path
|
10 |
from datetime import datetime
|
11 |
|
|
|
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 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
|
73 |
+
app_logger.debug("PDF Generation: Adding logo and header.")
|
74 |
+
logo_path_str = get_logo_path()
|
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))
|
107 |
+
|
108 |
+
# 3. Disclaimer
|
109 |
+
app_logger.debug("PDF Generation: Adding disclaimers.")
|
110 |
+
story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
|
111 |
+
story.append(Paragraph(str(settings.MAIN_DISCLAIMER_LONG), styles['Disclaimer']))
|
112 |
+
if settings.SIMULATION_DISCLAIMER:
|
113 |
+
story.append(Paragraph(str(settings.SIMULATION_DISCLAIMER), styles['Disclaimer']))
|
114 |
+
story.append(Spacer(1, 0.3*inch))
|
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)))
|
166 |
+
|
167 |
+
# --- Build PDF ---
|
168 |
+
app_logger.debug("PDF Generation: Building document.")
|
169 |
+
try:
|
170 |
+
doc.build(story)
|
171 |
+
buffer.seek(0)
|
172 |
+
app_logger.info(f"PDF report generated successfully for session ID: {session_id_str}")
|
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
|