File size: 13,617 Bytes
dd784bb
5ed5b69
6019d24
07918a8
5ed5b69
07918a8
5ed5b69
085c524
dd784bb
07918a8
dd784bb
07918a8
6019d24
07918a8
 
085c524
 
 
68006c0
085c524
68006c0
 
085c524
68006c0
085c524
 
 
 
68006c0
085c524
 
 
 
68006c0
085c524
 
 
68006c0
 
 
 
 
085c524
 
 
68006c0
 
 
 
085c524
 
 
68006c0
 
 
 
085c524
68006c0
085c524
 
68006c0
085c524
 
68006c0
 
085c524
 
68006c0
 
 
 
 
 
 
 
 
085c524
 
68006c0
 
085c524
 
68006c0
 
085c524
68006c0
 
085c524
68006c0
 
 
 
 
 
 
085c524
 
 
 
 
 
 
 
68006c0
085c524
 
 
 
68006c0
085c524
 
 
 
 
68006c0
085c524
 
 
68006c0
085c524
 
 
 
 
68006c0
085c524
 
68006c0
 
085c524
 
 
 
 
 
 
 
 
 
 
 
 
 
68006c0
085c524
68006c0
 
 
 
 
 
 
 
 
 
 
 
085c524
 
 
 
 
 
68006c0
085c524
 
 
 
68006c0
 
 
085c524
 
68006c0
 
085c524
68006c0
 
085c524
68006c0
 
 
085c524
68006c0
 
 
 
085c524
68006c0
085c524
68006c0
 
 
 
085c524
 
 
 
 
 
 
 
 
 
 
 
 
68006c0
 
085c524
 
 
68006c0
 
085c524
 
 
68006c0
085c524
68006c0
dd784bb
07918a8
5ed5b69
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# /home/user/app/services/pdf_report.py
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from io import BytesIO
from typing import List, Dict, Any, Optional
from pathlib import Path
from datetime import datetime

from config.settings import settings
from assets.logo import get_logo_path # Assuming this function exists and works
from services.logger import app_logger

class MockChatMessage:
    """
    A simple data class to hold message attributes for PDF generation.
    Ensures consistent attribute access and helps with type handling before ReportLab.
    """
    def __init__(self, role: Optional[str], content: Optional[str], 
                 timestamp: Optional[datetime], tool_name: Optional[str] = None, **kwargs):
        self.role: str = str(role if role is not None else "unknown")
        self.content: str = str(content if content is not None else "") # Ensure content is always a string
        self.timestamp: Optional[datetime] = timestamp # Keep as datetime, format when used
        self.tool_name: Optional[str] = str(tool_name) if tool_name is not None else None
        
        # Store any other potential attributes (e.g., source_references, confidence)
        # This allows flexibility if msg_data dicts from pages/3_Reports.py have extra keys
        for key, value in kwargs.items():
            setattr(self, key, value)

    def get_formatted_timestamp(self, default_val: str = "Time N/A") -> str:
        """Returns a formatted string for the timestamp, or a default value."""
        return self.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if self.timestamp else default_val

    def get_formatted_content_for_pdf(self) -> str:
        """Ensures content is string, handles newlines for PDF, and escapes HTML-sensitive characters."""
        text = self.content # Already ensured to be string in __init__
        text_with_br = text.replace('\n', '<br/>\n')
        # Basic escaping for ReportLab's Paragraph (which understands some HTML tags)
        return text_with_br.replace("<", "<").replace(">", ">").replace("<br/>", "<br/>")


def generate_pdf_report(report_data: Dict[str, Any]) -> BytesIO:
    """
    Generates a PDF report from the provided report_data dictionary.
    `report_data["messages"]` is expected to be a List[MockChatMessage].
    """
    buffer = BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=letter,
                            leftMargin=0.75*inch, rightMargin=0.75*inch,
                            topMargin=0.75*inch, bottomMargin=0.75*inch,
                            title=f"Consultation Report - Session {report_data.get('session_id', 'N/A')}",
                            author=settings.APP_TITLE)
    
    styles = getSampleStyleSheet()
    story: List[Any] = [] # To hold ReportLab Flowables

    # --- Custom Styles Definition ---
    # Add new styles or modify existing ones for better visual appearance
    styles.add(ParagraphStyle(name='Justify', alignment=4, parent=styles['Normal']))
    styles.add(ParagraphStyle(name='Disclaimer', parent=styles['Italic'], fontSize=8, leading=10, spaceBefore=6, spaceAfter=6, textColor=colors.dimgrey))
    styles.add(ParagraphStyle(name='SectionHeader', parent=styles['h2'], spaceBefore=12, spaceAfter=6, keepWithNext=1, textColor=colors.HexColor("#000080"))) # Navy Blue
    styles.add(ParagraphStyle(name='SubHeader', parent=styles['h3'], spaceBefore=6, spaceAfter=3, keepWithNext=1, textColor=colors.HexColor("#4682B4"))) # Steel Blue
    styles.add(ParagraphStyle(name='ListItem', parent=styles['Normal'], leftIndent=0.25*inch, bulletIndent=0.1*inch, spaceBefore=3))
    
    # Message Styles with borders and padding for distinct visual blocks
    base_message_style = ParagraphStyle(name='BaseMessage', parent=styles['Normal'], spaceBefore=3, spaceAfter=3, 
                                        leftIndent=0.1*inch, rightIndent=0.1*inch, leading=14,
                                        borderWidth=0.5, borderColor=colors.lightgrey, borderPadding=5)
    user_msg_style = ParagraphStyle(name='UserMessage', parent=base_message_style, backColor=colors.HexColor("#F0F8FF")) # AliceBlue
    ai_msg_style = ParagraphStyle(name='AIMessage', parent=base_message_style, backColor=colors.HexColor("#F5FFFA")) # MintCream
    tool_msg_style = ParagraphStyle(name='ToolMessage', parent=base_message_style, backColor=colors.HexColor("#FFF8DC"), fontName='Courier', textColor=colors.darkslategrey) # Cornsilk
    system_msg_style = ParagraphStyle(name='SystemMessage', parent=base_message_style, backColor=colors.HexColor("#FAFAD2"), fontSize=9, textColor=colors.dimgrey, fontName='Helvetica-Oblique') # LightGoldenrodYellow


    # --- Extract data safely from report_data dictionary ---
    # Ensure all text passed to Paragraph is string type
    clinician_username = str(report_data.get("patient_name", "N/A Clinician"))
    session_id_str = str(report_data.get("session_id", "N/A"))
    session_title_str = str(report_data.get("session_title", "Untitled Consultation"))
    session_start_time_obj = report_data.get("session_start_time") # datetime object or None
    patient_context_summary_str = str(report_data.get("patient_context_summary", "No specific patient context was provided for this session."))
    
    # `messages` key in report_data should already contain List[MockChatMessage] instances
    # as prepared by pages/3_Reports.py
    messages: List[MockChatMessage] = report_data.get("messages", [])
    
    # Sanity check for messages type (optional, but good for debugging)
    if not isinstance(messages, list) or (messages and not all(isinstance(m, MockChatMessage) for m in messages)):
        app_logger.error("PDF Generation: 'messages' in report_data is not a list of MockChatMessage instances as expected!")
        # Fallback to prevent downstream errors, or raise an error
        messages = [] # Process an empty list to avoid further TypeErrors
        story.append(Paragraph("Error: Message data for transcript was malformed.", styles['Heading3']))


    # 1. Logo and Document Header
    app_logger.debug("PDF Generation: Adding logo and header.")
    logo_path_str = get_logo_path()
    if logo_path_str and Path(logo_path_str).exists():
        try:
            img = Image(logo_path_str, width=0.75*inch, height=0.75*inch, preserveAspectRatio=True)
            img.hAlign = 'LEFT' # Align logo to the left
            story.append(img)
            story.append(Spacer(1, 0.05*inch))
        except Exception as e: app_logger.warning(f"PDF Report: Could not add logo image: {e}")
    
    story.append(Paragraph(str(settings.APP_TITLE), styles['h1'])) # Ensure APP_TITLE is string
    story.append(Paragraph("AI-Assisted Consultation Summary", styles['h2']))
    story.append(Spacer(1, 0.2*inch))

    # 2. Report Metadata Table
    app_logger.debug("PDF Generation: Adding metadata table.")
    report_date_str_display = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
    session_start_time_str_display = session_start_time_obj.strftime('%Y-%m-%d %H:%M UTC') if session_start_time_obj else "N/A"
    
    meta_data_content = [
        [Paragraph("<b>Report Generated:</b>", styles['Normal']), Paragraph(report_date_str_display, styles['Normal'])],
        [Paragraph("<b>Clinician:</b>", styles['Normal']), Paragraph(clinician_username, styles['Normal'])],
        [Paragraph("<b>Consultation Session ID:</b>", styles['Normal']), Paragraph(session_id_str, styles['Normal'])],
        [Paragraph("<b>Session Title:</b>", styles['Normal']), Paragraph(session_title_str, styles['Normal'])],
        [Paragraph("<b>Session Start Time:</b>", styles['Normal']), Paragraph(session_start_time_str_display, styles['Normal'])],
    ]
    meta_table = Table(meta_data_content, colWidths=[2.0*inch, 4.5*inch]) # Adjusted colWidths
    meta_table.setStyle(TableStyle([
        ('GRID', (0,0), (-1,-1), 0.5, colors.darkgrey), ('VALIGN', (0,0), (-1,-1), 'TOP'),
        ('BACKGROUND', (0,0), (0,-1), colors.lightgrey), ('LEFTPADDING', (0,0), (-1,-1), 6),
        ('RIGHTPADDING', (0,0), (-1,-1), 6), ('TOPPADDING', (0,0), (-1,-1), 4), ('BOTTOMPADDING', (0,0), (-1,-1), 4)
    ]))
    story.append(meta_table)
    story.append(Spacer(1, 0.3*inch))

    # 3. Disclaimer
    app_logger.debug("PDF Generation: Adding disclaimers.")
    story.append(Paragraph("<b>Important Disclaimer:</b>", styles['SubHeader']))
    story.append(Paragraph(str(settings.MAIN_DISCLAIMER_LONG), styles['Disclaimer']))
    if settings.SIMULATION_DISCLAIMER:
        story.append(Paragraph(str(settings.SIMULATION_DISCLAIMER), styles['Disclaimer']))
    story.append(Spacer(1, 0.3*inch))

    # 4. Patient Context Provided (if any)
    app_logger.debug("PDF Generation: Adding patient context summary.")
    if patient_context_summary_str and patient_context_summary_str.lower() not in ["no specific patient context was provided for this session.", "not provided.", ""]:
        story.append(Paragraph("Patient Context Provided by Clinician (Simulated Data):", styles['SectionHeader']))
        # Clean up the context string before splitting
        cleaned_context_str = patient_context_summary_str.replace("Patient Context: ", "").replace("Initial Patient Context Set: ","").strip()
        if cleaned_context_str:
            context_items_list = cleaned_context_str.split(';')
            for item_str in context_items_list:
                item_clean = item_str.strip()
                if item_clean: story.append(Paragraph(f"• {item_clean}", styles['ListItem']))
            story.append(Spacer(1, 0.2*inch))
        else: # If after cleaning, string is empty
            story.append(Paragraph("No specific patient context details were recorded for this session.", styles['Normal']))
            story.append(Spacer(1, 0.2*inch))


    # 5. Consultation Transcript
    app_logger.debug(f"PDF Generation: Adding transcript with {len(messages)} messages.")
    story.append(Paragraph("Consultation Transcript:", styles['SectionHeader']))
    story.append(Spacer(1, 0.1*inch))

    for msg_obj in messages: # msg_obj is an instance of MockChatMessage
        if msg_obj.role == 'system' and "Initial Patient Context Set:" in msg_obj.content:
            app_logger.debug(f"Skipping system context message in PDF transcript: {msg_obj.content[:50]}...")
            continue

        formatted_timestamp_str = msg_obj.get_formatted_timestamp()
        prefix_display_str = ""
        active_message_style_for_loop = styles['Normal'] # Default

        if msg_obj.role == 'assistant':
            prefix_display_str = f"AI Assistant ({formatted_timestamp_str}):"
            active_message_style_for_loop = ai_msg_style
        elif msg_obj.role == 'user':
            prefix_display_str = f"Clinician ({formatted_timestamp_str}):"
            active_message_style_for_loop = user_msg_style
        elif msg_obj.role == 'tool':
            tool_name_str_display = msg_obj.tool_name or "Tool"
            prefix_display_str = f"{tool_name_str_display.capitalize()} Output ({formatted_timestamp_str}):"
            active_message_style_for_loop = tool_msg_style
        elif msg_obj.role == 'system':
            prefix_display_str = f"System Note ({formatted_timestamp_str}):"
            active_message_style_for_loop = system_msg_style
        else:
            prefix_display_str = f"{msg_obj.role.capitalize()} ({formatted_timestamp_str}):"
        
        formatted_content_str = msg_obj.get_formatted_content_for_pdf()
        
        # Add prefix and content as separate paragraphs if you want prefix bold and content in styled box
        story.append(Paragraph(f"<b>{prefix_display_str}</b>", styles['Normal']))
        story.append(Paragraph(formatted_content_str, active_message_style_for_loop))
        # story.append(Spacer(1, 0.05*inch)) # Optional: reduce space between messages

    story.append(Spacer(1, 0.5*inch))
    story.append(Paragraph("--- End of Report ---", ParagraphStyle(name='EndOfReport', parent=styles['Italic'], alignment=1, spaceBefore=12)))

    # --- Build PDF ---
    app_logger.debug("PDF Generation: Building document.")
    try:
        doc.build(story)
        buffer.seek(0)
        app_logger.info(f"PDF report generated successfully for session ID: {session_id_str}")
    except Exception as e_build:
        app_logger.error(f"Failed to build PDF document for session ID {session_id_str}: {e_build}", exc_info=True)
        # Return an error PDF or an empty buffer
        buffer = BytesIO() # Reset buffer to ensure it's clean
        error_styles_local = getSampleStyleSheet() # Get fresh styles for error PDF
        error_doc_local = SimpleDocTemplate(buffer, pagesize=letter)
        error_story_local = [
            Paragraph("Error: Could not generate PDF report.", error_styles_local['h1']),
            Paragraph(f"An error occurred during PDF construction: {str(e_build)[:500]}", error_styles_local['Normal']), # Limit error message length
            Paragraph("Please check application logs for more details or contact support.", error_styles_local['Normal'])
        ]
        try:
            error_doc_local.build(error_story_local)
        except Exception as e_err_pdf: # If even the error PDF fails
            app_logger.error(f"Failed to build even the error PDF: {e_err_pdf}", exc_info=True)
            # Buffer will be empty from the reset above
        buffer.seek(0)
        
    return buffer