Update services/pdf_report.py
Browse files- services/pdf_report.py +130 -80
services/pdf_report.py
CHANGED
@@ -1,118 +1,168 @@
|
|
1 |
# /home/user/app/services/pdf_report.py
|
2 |
from reportlab.lib.pagesizes import letter
|
3 |
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
|
4 |
-
from reportlab.lib.styles import getSampleStyleSheet
|
5 |
from reportlab.lib.units import inch
|
|
|
6 |
from io import BytesIO
|
7 |
-
from typing import List,
|
8 |
from pathlib import Path
|
|
|
9 |
|
10 |
-
#
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
23 |
buffer = BytesIO()
|
24 |
-
# Reduce margins for more content space
|
25 |
doc = SimpleDocTemplate(buffer, pagesize=letter,
|
26 |
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
27 |
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
28 |
styles = getSampleStyleSheet()
|
29 |
story = []
|
30 |
|
31 |
-
#
|
32 |
-
styles
|
33 |
-
styles['
|
34 |
-
styles['
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
|
|
|
|
|
|
|
|
39 |
|
40 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
logo_path_str = get_logo_path()
|
42 |
-
if logo_path_str:
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
else:
|
54 |
-
app_logger.warning(f"Logo path '{logo_path_str}' from get_logo_path() does not exist.")
|
55 |
-
|
56 |
-
# 2. Title
|
57 |
-
title_text = f"{settings.APP_TITLE} - Consultation Report"
|
58 |
-
title = Paragraph(title_text, styles['h1'])
|
59 |
-
story.append(title)
|
60 |
story.append(Spacer(1, 0.2*inch))
|
61 |
|
62 |
-
#
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
-
#
|
69 |
-
story.append(Paragraph("<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
story.append(Spacer(1, 0.1*inch))
|
71 |
|
72 |
-
for msg in
|
|
|
73 |
prefix = ""
|
74 |
current_style = styles['Normal']
|
75 |
|
76 |
if msg.role == 'assistant':
|
77 |
-
prefix = "
|
78 |
-
current_style =
|
79 |
elif msg.role == 'user':
|
80 |
-
prefix = "
|
81 |
-
current_style =
|
82 |
elif msg.role == 'tool':
|
83 |
-
tool_name = getattr(msg, 'tool_name', '
|
84 |
-
prefix = f"
|
85 |
-
current_style =
|
86 |
-
|
87 |
-
prefix = f"
|
88 |
-
|
89 |
-
#
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
|
|
|
|
94 |
|
95 |
-
|
96 |
-
|
97 |
-
story.append(Spacer(1, 0.05*inch)) # Smaller spacer between messages
|
98 |
-
except Exception as e:
|
99 |
-
app_logger.error(f"Error adding message to PDF: {prefix}{content}. Error: {e}")
|
100 |
-
story.append(Paragraph(f"<i>Error rendering message: {e}</i>", styles['Italic']))
|
101 |
|
|
|
|
|
|
|
|
|
102 |
|
103 |
try:
|
104 |
doc.build(story)
|
105 |
buffer.seek(0)
|
106 |
-
app_logger.info(f"PDF report generated
|
107 |
except Exception as e:
|
108 |
-
app_logger.error(f"Failed to build PDF document: {e}", exc_info=True)
|
109 |
-
# Return
|
110 |
-
|
|
|
111 |
error_doc = SimpleDocTemplate(buffer, pagesize=letter)
|
112 |
-
error_story = [Paragraph("Error
|
|
|
113 |
try:
|
114 |
error_doc.build(error_story)
|
115 |
-
except: pass # If even error
|
116 |
buffer.seek(0)
|
117 |
-
|
118 |
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, PageBreak
|
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 # For the input data structure
|
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: # Helper class if generate_pdf_report expects objects with attributes
|
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 |
+
# Allow other attributes like source_references, confidence_score if added
|
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,
|
31 |
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
32 |
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
33 |
styles = getSampleStyleSheet()
|
34 |
story = []
|
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 from report_data dictionary ---
|
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 |
+
# Convert message dictionaries to MockChatMessage objects if needed, or use dicts directly
|
57 |
+
raw_messages = report_data.get("messages", [])
|
58 |
+
messages = [MockChatMessage(**msg_data) for msg_data in raw_messages] # If generate_pdf_report expects objects
|
59 |
+
|
60 |
+
|
61 |
+
# 1. Logo and Document Header
|
62 |
logo_path_str = get_logo_path()
|
63 |
+
if logo_path_str and Path(logo_path_str).exists():
|
64 |
+
try:
|
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.1*inch))
|
69 |
+
except Exception as e:
|
70 |
+
app_logger.warning(f"PDF Report: Could not add logo: {e}")
|
71 |
+
|
72 |
+
story.append(Paragraph(f"{settings.APP_TITLE}", styles['h1']))
|
73 |
+
story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
story.append(Spacer(1, 0.2*inch))
|
75 |
|
76 |
+
# 2. Report Metadata Table
|
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 |
+
meta_data = [
|
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'])], # "patient_name" is the clinician's username
|
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(meta_data, colWidths=[1.8*inch, None])
|
88 |
+
meta_table.setStyle(TableStyle([
|
89 |
+
('GRID', (0,0), (-1,-1), 0.5, colors.grey),
|
90 |
+
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
91 |
+
('BACKGROUND', (0,0), (0,-1), colors.whitesmoke)
|
92 |
+
]))
|
93 |
+
story.append(meta_table)
|
94 |
+
story.append(Spacer(1, 0.3*inch))
|
95 |
|
96 |
+
# 3. Disclaimer
|
97 |
+
story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
|
98 |
+
story.append(Paragraph(settings.MAIN_DISCLAIMER_LONG, styles['Disclaimer']))
|
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 != "No specific patient context provided.":
|
104 |
+
story.append(Paragraph("Patient Context Provided (Simulated Data):", styles['SectionHeader']))
|
105 |
+
# Assuming patient_context_summary is a string. If it's structured, format it nicely.
|
106 |
+
# Example: if it's "Key1: Value1; Key2: Value2", split and list.
|
107 |
+
context_parts = patient_context_summary.replace("Patient Context: ", "").split(';')
|
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 |
prefix = ""
|
120 |
current_style = styles['Normal']
|
121 |
|
122 |
if msg.role == 'assistant':
|
123 |
+
prefix = f"AI Assistant ({timestamp_str}):"
|
124 |
+
current_style = ai_msg_style
|
125 |
elif msg.role == 'user':
|
126 |
+
prefix = f"Clinician ({timestamp_str}):"
|
127 |
+
current_style = user_msg_style
|
128 |
elif msg.role == 'tool':
|
129 |
+
tool_name = getattr(msg, 'tool_name', 'Tool')
|
130 |
+
prefix = f"{tool_name.capitalize()} ({timestamp_str}):"
|
131 |
+
current_style = tool_msg_style
|
132 |
+
elif msg.role == 'system': # Typically system context, might not always be included
|
133 |
+
prefix = f"System Context ({timestamp_str}):"
|
134 |
+
current_style = system_msg_style
|
135 |
+
else: # Fallback for any other roles
|
136 |
+
prefix = f"{msg.role.capitalize()} ({timestamp_str}):"
|
137 |
+
current_style = styles['Normal']
|
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() # Return empty buffer on error
|
158 |
+
# Optionally, create a simple error PDF
|
159 |
+
error_styles = getSampleStyleSheet()
|
160 |
error_doc = SimpleDocTemplate(buffer, pagesize=letter)
|
161 |
+
error_story = [Paragraph("Error: Could not generate PDF report.", error_styles['h1']),
|
162 |
+
Paragraph(f"Details: {str(e)[:500]}", error_styles['Normal'])]
|
163 |
try:
|
164 |
error_doc.build(error_story)
|
165 |
+
except: pass # If even error PDF fails, return empty
|
166 |
buffer.seek(0)
|
167 |
+
|
168 |
return buffer
|