Soumik Bose commited on
Commit
a6e6a7c
Β·
1 Parent(s): ae4a807
Dockerfile CHANGED
@@ -1,8 +1,8 @@
1
  # Use the official Python 3.11 slim image
2
  FROM python:3.11-slim
3
 
4
- # Install system dependencies required for Math/Data libraries
5
- # libgomp1 is CRITICAL for scikit-learn & numpy parallelization to prevent segfaults
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  curl \
8
  gcc \
@@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
  libgomp1 \
11
  libpango-1.0-0 \
12
  libpangoft2-1.0-0 \
 
 
13
  libjpeg62-turbo-dev \
14
  libopenjp2-7-dev \
15
  libffi-dev \
@@ -18,14 +20,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
18
  && rm -rf /var/lib/apt/lists/*
19
 
20
  # Set the working directory inside the container
 
21
  RUN mkdir -p /app/output \
22
  /app/chat_pdfs \
 
23
  /app/generated_charts \
24
  /app/cache
25
 
26
  # Create required directories with permissions
 
27
  RUN chmod -R 777 /app/output \
28
  /app/chat_pdfs \
 
29
  /app/generated_charts \
30
  /app/cache
31
 
@@ -51,5 +57,4 @@ RUN chown -R 1000:1000 /app && chmod -R 777 /app
51
  EXPOSE 7860
52
 
53
  # CMD: Run the python script directly.
54
- # This ensures the 'if __name__ == "__main__":' block in your code actually runs.
55
  CMD bash -c "while true; do curl -s https://code-api-executor.hf.space/ping >/dev/null && sleep 300; done & python controller.py"
 
1
  # Use the official Python 3.11 slim image
2
  FROM python:3.11-slim
3
 
4
+ # Install system dependencies required for Math/Data libraries AND PDF Generation
5
+ # Added: libcairo2, libgdk-pixbuf-2.0-0 (CRITICAL for WeasyPrint/PDF generation)
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  curl \
8
  gcc \
 
10
  libgomp1 \
11
  libpango-1.0-0 \
12
  libpangoft2-1.0-0 \
13
+ libcairo2 \
14
+ libgdk-pixbuf-2.0-0 \
15
  libjpeg62-turbo-dev \
16
  libopenjp2-7-dev \
17
  libffi-dev \
 
20
  && rm -rf /var/lib/apt/lists/*
21
 
22
  # Set the working directory inside the container
23
+ # Added: /app/temp_reports
24
  RUN mkdir -p /app/output \
25
  /app/chat_pdfs \
26
+ /app/temp_reports \
27
  /app/generated_charts \
28
  /app/cache
29
 
30
  # Create required directories with permissions
31
+ # Added: /app/temp_reports
32
  RUN chmod -R 777 /app/output \
33
  /app/chat_pdfs \
34
+ /app/temp_reports \
35
  /app/generated_charts \
36
  /app/cache
37
 
 
57
  EXPOSE 7860
58
 
59
  # CMD: Run the python script directly.
 
60
  CMD bash -c "while true; do curl -s https://code-api-executor.hf.space/ping >/dev/null && sleep 300; done & python controller.py"
controller.py CHANGED
@@ -40,6 +40,8 @@ from csv_metadata_service import CsvDataRequest, CsvInfoRequest, CsvInfoResponse
40
  from download_messages_log_helper import ChatLogRequest, ChatLogResponse, generate_chat_pdf
41
  from extract_csv_metadata_service import extract_csv_metadata_logic
42
  from mongo_service import convert_oid, execute_mongo_operation, extract_database_name, sanitize_json_input
 
 
43
  from pydantic_csv_analysis_model import AnalysisRequest, AnalysisResponse
44
  from pydantic_csv_charts_model import ChartExecutionPayload, ChartExecutionResponse
45
  from pydantic_migration_model import MigrationRequest, MigrationResponse
@@ -774,6 +776,51 @@ async def get_chat_log_endpoint(payload: ChatLogRequest, token: str = Depends(va
774
  except Exception as cleanup_error:
775
  logger.warning(f"Failed to delete local PDF {generated_pdf_path}: {cleanup_error}")
776
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  # --- Batch Handlers ---
778
  async def batch_parallel_handler(func, requests: List[Any], token: str):
779
  tasks = [func(req, token) for req in requests]
 
40
  from download_messages_log_helper import ChatLogRequest, ChatLogResponse, generate_chat_pdf
41
  from extract_csv_metadata_service import extract_csv_metadata_logic
42
  from mongo_service import convert_oid, execute_mongo_operation, extract_database_name, sanitize_json_input
43
+ from pdf_report_generation_helper import generate_and_upload_report
44
+ from pdf_report_generation_model import ReportGenerationRequest, ReportGenerationResponse
45
  from pydantic_csv_analysis_model import AnalysisRequest, AnalysisResponse
46
  from pydantic_csv_charts_model import ChartExecutionPayload, ChartExecutionResponse
47
  from pydantic_migration_model import MigrationRequest, MigrationResponse
 
776
  except Exception as cleanup_error:
777
  logger.warning(f"Failed to delete local PDF {generated_pdf_path}: {cleanup_error}")
778
 
779
+ # --- PDF Report Generation ---
780
+ @app.post("/api/generate_pdf_report", response_model=ReportGenerationResponse)
781
+ async def generate_pdf_report_endpoint(
782
+ payload: ReportGenerationRequest,
783
+ token: str = Depends(validate_token) # Assuming validate_token is defined in your main file
784
+ ):
785
+ """
786
+ Generates a PDF report, uploads it to Supabase via the helper,
787
+ and returns the public URL.
788
+ """
789
+ request_id = str(uuid.uuid4())[:8]
790
+
791
+ try:
792
+ # Convert Pydantic models to a list of dictionaries for the helper
793
+ # .model_dump() is for Pydantic v2, use .dict() for v1
794
+ config_content = [section.dict() for section in payload.sections]
795
+
796
+ # Call the helper function directly.
797
+ # It handles generation, upload, and cleanup.
798
+ public_url = await generate_and_upload_report(
799
+ config_content=config_content,
800
+ file_name=payload.file_name,
801
+ chat_id=payload.chat_id,
802
+ title=payload.title,
803
+ subtitle=payload.subtitle,
804
+ author=payload.author,
805
+ department=payload.department,
806
+ output_dir="temp_reports"
807
+ )
808
+
809
+ return ReportGenerationResponse(
810
+ success=True,
811
+ pdf_report_file_url=public_url,
812
+ file_name=f"{payload.file_name}.pdf" if not payload.file_name.endswith('.pdf') else payload.file_name,
813
+ request_id=request_id
814
+ )
815
+
816
+ except Exception as e:
817
+ logger.error(f"Report Generation Failed: {e}")
818
+ return ReportGenerationResponse(
819
+ success=False,
820
+ error=str(e),
821
+ request_id=request_id
822
+ )
823
+
824
  # --- Batch Handlers ---
825
  async def batch_parallel_handler(func, requests: List[Any], token: str):
826
  tasks = [func(req, token) for req in requests]
pdf_report_generation_helper.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import logging
4
+ import asyncio
5
+ from typing import List, Dict, Any
6
+ from pdf_report_generation_service import Q4ReportGenerator
7
+ from supabase_service import upload_file_to_supabase # Assuming this exists from your previous context
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ async def generate_and_upload_report(
12
+ config_content: List[Dict[str, Any]],
13
+ file_name: str,
14
+ chat_id: str,
15
+ title: str = "Analytics Report",
16
+ subtitle: str = "Generated Report",
17
+ author: str = "AI Assistant",
18
+ department: str = "Data Analytics",
19
+ output_dir: str = "temp_reports"
20
+ ) -> str:
21
+ """
22
+ Generates a PDF report from content configuration, uploads it to Supabase,
23
+ and returns the public URL. Cleans up temporary files afterwards.
24
+ """
25
+
26
+ # 1. Setup paths
27
+ os.makedirs(output_dir, exist_ok=True)
28
+
29
+ # Ensure filename ends with .pdf
30
+ if not file_name.endswith('.pdf'):
31
+ file_name += '.pdf'
32
+
33
+ # Create a unique filename to prevent collisions locally
34
+ unique_id = str(uuid.uuid4())[:8]
35
+ local_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
36
+
37
+ # 2. Initialize Generator
38
+ generator = Q4ReportGenerator(
39
+ title=title,
40
+ subtitle=subtitle,
41
+ author=author,
42
+ department=department,
43
+ confidential=True
44
+ )
45
+
46
+ # 3. Generate PDF (Blocking I/O, so we run it in a way that doesn't block main loop if called properly)
47
+ # In FastAPI, we'll wrap this whole function or the generation part in run_in_threadpool
48
+ success = generator.generate(
49
+ sections=config_content,
50
+ filename=local_filename,
51
+ output_dir=output_dir,
52
+ cover_page=True
53
+ )
54
+
55
+ if not success:
56
+ raise Exception("Failed to generate PDF report locally.")
57
+
58
+ local_file_path = os.path.join(output_dir, local_filename)
59
+
60
+ try:
61
+ # 4. Upload to Supabase
62
+ # We use the original desired file_name for the upload (or unique if you prefer)
63
+ # Using unique name in cloud storage is usually safer
64
+ cloud_filename = f"{os.path.splitext(file_name)[0]}_{unique_id}.pdf"
65
+
66
+ logger.info(f"Uploading {local_file_path} to Supabase as {cloud_filename}")
67
+
68
+ public_url = await upload_file_to_supabase(
69
+ file_path=local_file_path,
70
+ file_name=cloud_filename,
71
+ chat_id=chat_id
72
+ )
73
+
74
+ logger.info(f"Upload successful. URL: {public_url}")
75
+
76
+ return public_url
77
+
78
+ except Exception as e:
79
+ logger.error(f"Failed to upload report: {e}")
80
+ raise e
81
+
82
+ finally:
83
+ # 5. Cleanup - Delete local PDF
84
+ if os.path.exists(local_file_path):
85
+ try:
86
+ os.remove(local_file_path)
87
+ logger.info(f"Deleted local file: {local_file_path}")
88
+ except Exception as e:
89
+ logger.warning(f"Failed to delete local file {local_file_path}: {e}")
90
+
91
+ # 6. Cleanup - Delete downloaded images
92
+ # The generator class tracks images it downloaded
93
+ if hasattr(generator, 'downloaded_images'):
94
+ for img_uri in generator.downloaded_images:
95
+ # Convert URI back to path if needed (file://...)
96
+ if img_uri.startswith('file://'):
97
+ img_path = img_uri[7:] # Simple strip, robust implementation depends on OS
98
+ # On windows file:///C:/... might need handling
99
+ if os.name == 'nt' and img_uri.startswith('file:///'):
100
+ img_path = img_uri[8:]
101
+
102
+ if os.path.exists(img_path):
103
+ try:
104
+ os.remove(img_path)
105
+ logger.info(f"Deleted temp image: {img_path}")
106
+ except Exception as e:
107
+ logger.warning(f"Failed to delete image {img_path}: {e}")
108
+
109
+ # Try to remove the images directory if empty
110
+ images_dir = os.path.join(output_dir, 'images')
111
+ if os.path.exists(images_dir):
112
+ try:
113
+ os.rmdir(images_dir)
114
+ except:
115
+ pass
pdf_report_generation_model.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import List, Optional, Union, Dict, Any
3
+
4
+ class ImageConfig(BaseModel):
5
+ url: str
6
+ caption: Optional[str] = "Figure"
7
+
8
+ class ReportSection(BaseModel):
9
+ content: str
10
+ images: Optional[List[Union[str, ImageConfig]]] = []
11
+ page_break: Optional[bool] = False
12
+
13
+ class ReportGenerationRequest(BaseModel):
14
+ chat_id: str
15
+ file_name: str = "Analysis_Report"
16
+ title: Optional[str] = "Analytics Performance Report"
17
+ subtitle: Optional[str] = "Generated Insights"
18
+ author: Optional[str] = "AI Assistant"
19
+ department: Optional[str] = "Data Analytics Team"
20
+ sections: List[ReportSection]
21
+
22
+ class ReportGenerationResponse(BaseModel):
23
+ success: bool
24
+ pdf_report_file_url: Optional[str] = None
25
+ file_name: Optional[str] = None
26
+ error: Optional[str] = None
27
+ request_id: str
pdf_report_generation_service.py ADDED
@@ -0,0 +1,659 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Professional Q4 Report Generator - Complete Working Version
3
+ Generates executive-quality PDF reports with proper image rendering
4
+ """
5
+ import markdown
6
+ from weasyprint import HTML, CSS
7
+ import requests
8
+ import os
9
+ from datetime import datetime
10
+ from typing import List, Dict, Optional
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ # ---------------------------------------------------------
15
+ # LOGGING CONFIGURATION
16
+ # ---------------------------------------------------------
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(levelname)s - %(message)s',
20
+ datefmt='%Y-%m-%d %H:%M:%S'
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------
25
+ # EXECUTIVE Q4 REPORT THEME
26
+ # ---------------------------------------------------------
27
+ CSS_STYLE = """
28
+ @page {
29
+ size: letter;
30
+ margin: 0.75in 0.75in 1in 0.75in;
31
+
32
+ @top-center {
33
+ content: element(header);
34
+ }
35
+
36
+ @bottom-center {
37
+ content: element(footer);
38
+ }
39
+ }
40
+
41
+ body {
42
+ font-family: 'Helvetica', Arial, sans-serif;
43
+ color: #1a1a1a;
44
+ line-height: 1.65;
45
+ font-size: 11pt;
46
+ }
47
+
48
+ .header {
49
+ position: running(header);
50
+ font-size: 9pt;
51
+ color: #666;
52
+ border-bottom: 1px solid #ddd;
53
+ padding-bottom: 6px;
54
+ margin-bottom: 20px;
55
+ }
56
+
57
+ .footer {
58
+ position: running(footer);
59
+ font-size: 9pt;
60
+ color: #666;
61
+ border-top: 1px solid #ddd;
62
+ padding-top: 6px;
63
+ text-align: center;
64
+ }
65
+
66
+ .footer::after {
67
+ content: "Page " counter(page) " of " counter(pages);
68
+ }
69
+
70
+ .confidential-mark {
71
+ color: #d32f2f;
72
+ font-weight: bold;
73
+ float: left;
74
+ }
75
+
76
+ .cover-page {
77
+ text-align: center;
78
+ padding-top: 3in;
79
+ page-break-after: always;
80
+ }
81
+
82
+ .cover-title {
83
+ font-size: 36pt;
84
+ font-weight: bold;
85
+ color: #003366;
86
+ margin-bottom: 0.5in;
87
+ letter-spacing: 1px;
88
+ line-height: 1.2;
89
+ }
90
+
91
+ .cover-subtitle {
92
+ font-size: 20pt;
93
+ color: #0055a5;
94
+ margin-bottom: 1.2in;
95
+ font-weight: 500;
96
+ }
97
+
98
+ .cover-meta {
99
+ font-size: 15pt;
100
+ color: #555;
101
+ margin-bottom: 0.25in;
102
+ line-height: 1.5;
103
+ }
104
+
105
+ .cover-date {
106
+ font-size: 12pt;
107
+ color: #888;
108
+ margin-top: 0.5in;
109
+ }
110
+
111
+ .cover-divider {
112
+ width: 3in;
113
+ height: 3px;
114
+ background: linear-gradient(to right, #003366, #0055a5);
115
+ margin: 0.5in auto;
116
+ }
117
+
118
+ h1 {
119
+ color: #003366;
120
+ font-size: 20pt;
121
+ border-bottom: 3px solid #0055a5;
122
+ padding-bottom: 10px;
123
+ margin-top: 28px;
124
+ margin-bottom: 18px;
125
+ page-break-after: avoid;
126
+ font-weight: bold;
127
+ }
128
+
129
+ h2 {
130
+ color: #0055a5;
131
+ font-size: 15pt;
132
+ margin-top: 22px;
133
+ margin-bottom: 14px;
134
+ page-break-after: avoid;
135
+ font-weight: 600;
136
+ }
137
+
138
+ h3 {
139
+ color: #333;
140
+ font-size: 13pt;
141
+ margin-top: 18px;
142
+ margin-bottom: 12px;
143
+ page-break-after: avoid;
144
+ font-weight: 600;
145
+ }
146
+
147
+ table {
148
+ width: 100%;
149
+ border-collapse: collapse;
150
+ margin: 18px 0;
151
+ font-size: 10pt;
152
+ page-break-inside: avoid;
153
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
154
+ }
155
+
156
+ thead {
157
+ background: linear-gradient(to bottom, #003366, #00264d);
158
+ }
159
+
160
+ th {
161
+ background-color: #003366;
162
+ color: white;
163
+ padding: 12px;
164
+ text-align: left;
165
+ font-weight: bold;
166
+ border: 1px solid #002244;
167
+ font-size: 10.5pt;
168
+ }
169
+
170
+ td {
171
+ padding: 10px 12px;
172
+ border: 1px solid #ddd;
173
+ line-height: 1.5;
174
+ }
175
+
176
+ tr:nth-child(even) {
177
+ background-color: #f8f9fa;
178
+ }
179
+
180
+ tr:nth-child(odd) {
181
+ background-color: #ffffff;
182
+ }
183
+
184
+ .highlight-box {
185
+ background-color: #e8f4f8;
186
+ border-left: 5px solid #0055a5;
187
+ padding: 16px 20px;
188
+ margin: 20px 0;
189
+ page-break-inside: avoid;
190
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
191
+ }
192
+
193
+ .key-metric-box {
194
+ background: linear-gradient(135deg, #003366 0%, #0055a5 100%);
195
+ color: white;
196
+ border-radius: 8px;
197
+ padding: 18px 22px;
198
+ margin: 20px 0;
199
+ page-break-inside: avoid;
200
+ box-shadow: 0 3px 6px rgba(0,0,0,0.15);
201
+ }
202
+
203
+ .key-metric-box h3 {
204
+ color: white;
205
+ margin-top: 0;
206
+ }
207
+
208
+ blockquote {
209
+ border-left: 5px solid #003366;
210
+ margin-left: 0;
211
+ padding-left: 20px;
212
+ color: #333;
213
+ font-style: italic;
214
+ background-color: #f8f9fa;
215
+ padding: 14px 14px 14px 20px;
216
+ margin: 18px 0;
217
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
218
+ }
219
+
220
+ .figure-container {
221
+ text-align: center;
222
+ margin: 24px 0;
223
+ page-break-inside: avoid;
224
+ }
225
+
226
+ .figure-container img {
227
+ max-width: 100%;
228
+ height: auto;
229
+ border: 1px solid #ddd;
230
+ padding: 8px;
231
+ background: white;
232
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
233
+ }
234
+
235
+ .figure-caption {
236
+ font-size: 9.5pt;
237
+ color: #666;
238
+ margin-top: 10px;
239
+ font-style: italic;
240
+ font-weight: 500;
241
+ }
242
+
243
+ .executive-summary {
244
+ background-color: #f0f7ff;
245
+ border: 2px solid #0055a5;
246
+ padding: 20px;
247
+ margin: 24px 0;
248
+ page-break-inside: avoid;
249
+ border-radius: 5px;
250
+ }
251
+
252
+ .page-break {
253
+ page-break-after: always;
254
+ }
255
+
256
+ ul, ol {
257
+ margin: 14px 0;
258
+ padding-left: 28px;
259
+ }
260
+
261
+ li {
262
+ margin: 8px 0;
263
+ line-height: 1.6;
264
+ }
265
+
266
+ strong {
267
+ font-weight: 600;
268
+ color: #000;
269
+ }
270
+ """
271
+
272
+ # ---------------------------------------------------------
273
+ # IMAGE HANDLING
274
+ # ---------------------------------------------------------
275
+ def fetch_image(url: str, output_dir: str) -> Optional[str]:
276
+ """
277
+ Download image from URL to persistent file for PDF rendering.
278
+
279
+ Args:
280
+ url: The URL of the image
281
+ output_dir: Directory to save downloaded images
282
+
283
+ Returns:
284
+ File URI string or None if failed
285
+ """
286
+ if not url.startswith(('http://', 'https://')):
287
+ if os.path.exists(url):
288
+ abs_path = os.path.abspath(url)
289
+ return Path(abs_path).as_uri()
290
+ return None
291
+
292
+ try:
293
+ img_dir = os.path.join(output_dir, 'images')
294
+ os.makedirs(img_dir, exist_ok=True)
295
+
296
+ headers = {
297
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
298
+ }
299
+ response = requests.get(url, headers=headers, timeout=15)
300
+ response.raise_for_status()
301
+
302
+ # Determine file extension
303
+ content_type = response.headers.get('content-type', '')
304
+ ext = '.jpg'
305
+ if 'png' in content_type:
306
+ ext = '.png'
307
+ elif 'gif' in content_type:
308
+ ext = '.gif'
309
+ elif 'svg' in content_type:
310
+ ext = '.svg'
311
+ elif 'webp' in content_type:
312
+ ext = '.webp'
313
+
314
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
315
+ filename = f'img_{timestamp}{ext}'
316
+ filepath = os.path.join(img_dir, filename)
317
+
318
+ with open(filepath, 'wb') as f:
319
+ f.write(response.content)
320
+
321
+ abs_path = os.path.abspath(filepath)
322
+ file_uri = Path(abs_path).as_uri()
323
+
324
+ logger.info(f"Downloaded image: {url[:50]}... -> {filename}")
325
+ return file_uri
326
+
327
+ except Exception as e:
328
+ logger.warning(f"Failed to download image: {url[:50]}... - {e}")
329
+ return None
330
+
331
+ # ---------------------------------------------------------
332
+ # Q4 REPORT GENERATOR CLASS
333
+ # ---------------------------------------------------------
334
+ class Q4ReportGenerator:
335
+ """Generates executive-quality Q4 reports using WeasyPrint."""
336
+
337
+ def __init__(self,
338
+ title: str = "Q4 Performance Report",
339
+ subtitle: str = None,
340
+ author: str = None,
341
+ department: str = None,
342
+ date: str = None,
343
+ header_text: str = None,
344
+ confidential: bool = True):
345
+ self.title = title
346
+ self.subtitle = subtitle or "Fourth Quarter Analysis"
347
+ self.author = author or "Data Analytics Team"
348
+ self.department = department or "Analytics Department"
349
+ self.date = date or datetime.now().strftime("%B %d, %Y")
350
+ self.confidential = confidential
351
+ self.downloaded_images = []
352
+
353
+ if header_text:
354
+ self.header_text = header_text
355
+ else:
356
+ parts = [self.department, self.subtitle]
357
+ self.header_text = " | ".join(parts)
358
+
359
+ def _build_header(self) -> str:
360
+ return f'<div class="header">{self.header_text}</div>'
361
+
362
+ def _build_footer(self) -> str:
363
+ footer_html = '<div class="footer">'
364
+ if self.confidential:
365
+ footer_html += '<span class="confidential-mark">CONFIDENTIAL</span>'
366
+ footer_html += '</div>'
367
+ return footer_html
368
+
369
+ def _build_cover_page(self) -> str:
370
+ return f"""
371
+ <div class="cover-page">
372
+ <div class="cover-title">{self.title}</div>
373
+ <div class="cover-divider"></div>
374
+ <div class="cover-subtitle">{self.subtitle}</div>
375
+ <div class="cover-meta">{self.department}</div>
376
+ <div class="cover-meta">Prepared by: {self.author}</div>
377
+ <div class="cover-date">{self.date}</div>
378
+ </div>
379
+ """
380
+
381
+ def _process_section(self, section: Dict, output_dir: str) -> str:
382
+ html = ""
383
+
384
+ content = section.get("content", "")
385
+ if content:
386
+ html_content = markdown.markdown(
387
+ content,
388
+ extensions=['extra', 'sane_lists', 'tables', 'fenced_code']
389
+ )
390
+ html += f"<div class='section'>{html_content}</div>"
391
+
392
+ images = section.get("images", [])
393
+ for img_data in images:
394
+ if isinstance(img_data, dict):
395
+ img_url = img_data.get("url", "")
396
+ caption = img_data.get("caption", "Figure: Visualization")
397
+ else:
398
+ img_url = img_data
399
+ caption = "Figure: Visualization"
400
+
401
+ if img_url:
402
+ file_uri = fetch_image(img_url, output_dir)
403
+ if file_uri:
404
+ self.downloaded_images.append(file_uri)
405
+ html += f"""
406
+ <div class="figure-container">
407
+ <img src="{file_uri}" alt="{caption}">
408
+ <div class="figure-caption">{caption}</div>
409
+ </div>
410
+ """
411
+
412
+ if section.get("page_break", False):
413
+ html += '<div class="page-break"></div>'
414
+
415
+ return html
416
+
417
+ def generate(self,
418
+ sections: List[Dict],
419
+ filename: str = "Q4_Report.pdf",
420
+ output_dir: str = "./output",
421
+ cover_page: bool = True) -> bool:
422
+ try:
423
+ os.makedirs(output_dir, exist_ok=True)
424
+ output_path = os.path.join(output_dir, filename)
425
+
426
+ logger.info(f"Starting Q4 report generation: {output_path}")
427
+
428
+ body_html = ""
429
+ if cover_page:
430
+ body_html += self._build_cover_page()
431
+
432
+ for idx, section in enumerate(sections):
433
+ logger.info(f"Processing section {idx + 1}/{len(sections)}")
434
+ body_html += self._process_section(section, output_dir)
435
+
436
+ full_html = f"""
437
+ <!DOCTYPE html>
438
+ <html>
439
+ <head>
440
+ <meta charset="UTF-8">
441
+ <title>{self.title}</title>
442
+ </head>
443
+ <body>
444
+ {self._build_header()}
445
+ {self._build_footer()}
446
+ {body_html}
447
+ </body>
448
+ </html>
449
+ """
450
+
451
+ logger.info("Rendering PDF with WeasyPrint...")
452
+ html_doc = HTML(string=full_html)
453
+ css_doc = CSS(string=CSS_STYLE)
454
+
455
+ html_doc.write_pdf(output_path, stylesheets=[css_doc])
456
+
457
+ abs_path = os.path.abspath(output_path)
458
+ file_size = os.path.getsize(output_path) / 1024
459
+
460
+ print(f"\n{'='*70}")
461
+ print(f"βœ… PDF GENERATED SUCCESSFULLY!")
462
+ print(f"{'='*70}")
463
+ print(f"πŸ“ Location: {abs_path}")
464
+ print(f"πŸ“Š Size: {file_size:.2f} KB")
465
+ print(f"πŸ–ΌοΈ Images: {len(self.downloaded_images)} embedded")
466
+ print(f"{'='*70}\n")
467
+
468
+ return True
469
+
470
+ except Exception as e:
471
+ logger.error(f"❌ Error generating report: {e}", exc_info=True)
472
+ return False
473
+
474
+ # ---------------------------------------------------------
475
+ # COMPLETE TEST CASE WITH FULL Q4 REPORT
476
+ # ---------------------------------------------------------
477
+ # if __name__ == "__main__":
478
+
479
+ # q4_sections = [
480
+ # {
481
+ # "content": """
482
+ # # Executive Summary
483
+
484
+ # This report presents the comprehensive Q4 2025 performance analysis for our customer analytics division. The quarter demonstrated strong growth across key metrics with notable improvements in customer engagement and retention.
485
+
486
+ # <div class="key-metric-box">
487
+
488
+ # ### Key Q4 Highlights
489
+
490
+ # - **Revenue Growth**: 23.4% increase over Q3 2025
491
+ # - **Customer Acquisition**: 8,547 new customers (+18% QoQ)
492
+ # - **Retention Rate**: 94.2% (industry-leading performance)
493
+ # - **Data Quality Score**: 98.7% completeness
494
+
495
+ # </div>
496
+
497
+ # > "Q4 2025 marks our strongest quarter to date, with record-breaking performance across all major KPIs and successful implementation of our new customer engagement strategy."
498
+
499
+ # ## Quarter at a Glance
500
+
501
+ # The analytics team processed over 2.3 million customer interactions, generating actionable insights that drove strategic decisions across the organization.
502
+ # """
503
+ # },
504
+ # {
505
+ # "content": """
506
+ # # Performance Metrics
507
+
508
+ # ## Overall Q4 Performance
509
+
510
+ # | Metric | Q4 2025 | Q3 2025 | Change | Target |
511
+ # |--------|---------|---------|--------|--------|
512
+ # | Total Revenue | $4.2M | $3.4M | +23.4% | $4.0M |
513
+ # | New Customers | 8,547 | 7,243 | +18.0% | 8,000 |
514
+ # | Active Users | 45,892 | 42,156 | +8.9% | 44,000 |
515
+ # | Retention Rate | 94.2% | 92.8% | +1.4pp | 93.0% |
516
+ # | Avg. Order Value | $127 | $118 | +7.6% | $120 |
517
+
518
+ # <div class="highlight-box">
519
+
520
+ # **Performance Assessment**: All key metrics exceeded quarterly targets, demonstrating the effectiveness of our data-driven strategy and customer-focused initiatives.
521
+
522
+ # </div>
523
+
524
+ # ## Channel Performance Analysis
525
+
526
+ # The following visualization shows the distribution of customer orders across different channels in Q4 2025.
527
+ # """,
528
+ # "images": [
529
+ # {
530
+ # "url": "https://quickchart.io/chart?c={type:'bar',data:{labels:['Mobile App','Desktop Web','Mobile Web'],datasets:[{label:'Q4 Orders (%)',data:[52,35,13],backgroundColor:['rgba(0,51,102,0.8)','rgba(0,85,165,0.8)','rgba(52,152,219,0.8)']}]},options:{title:{display:true,text:'Q4 2025 Channel Distribution'},scales:{y:{beginAtZero:true,max:60}},plugins:{legend:{display:true}}}}",
531
+ # "caption": "Figure 1: Customer Orders by Channel - Q4 2025"
532
+ # }
533
+ # ],
534
+ # "page_break": True
535
+ # },
536
+ # {
537
+ # "content": """
538
+ # # Customer Analytics Deep Dive
539
+
540
+ # ## Demographic Insights
541
+
542
+ # Our Q4 analysis reveals significant shifts in customer demographics and behavior patterns.
543
+
544
+ # ### Age Distribution
545
+
546
+ # | Age Group | Customers | % of Total | Avg. Spend | Growth (QoQ) |
547
+ # |-----------|-----------|------------|------------|--------------|
548
+ # | 18-24 | 8,234 | 17.9% | $89 | +22% |
549
+ # | 25-34 | 15,678 | 34.2% | $142 | +15% |
550
+ # | 35-44 | 12,456 | 27.1% | $156 | +11% |
551
+ # | 45-54 | 6,789 | 14.8% | $178 | +8% |
552
+ # | 55+ | 2,735 | 6.0% | $203 | +5% |
553
+
554
+ # ## Revenue Trend Analysis
555
+
556
+ # Below is the revenue trend across Q4 months showing consistent growth:
557
+ # """,
558
+ # "images": [
559
+ # {
560
+ # "url": "https://quickchart.io/chart?c={type:'line',data:{labels:['October','November','December'],datasets:[{label:'Revenue ($M)',data:[1.2,1.5,1.5],borderColor:'rgb(0,51,102)',backgroundColor:'rgba(0,51,102,0.1)',fill:true}]},options:{title:{display:true,text:'Q4 2025 Monthly Revenue Trend'},scales:{y:{beginAtZero:true,max:2}}}}",
561
+ # "caption": "Figure 2: Monthly Revenue Trend - Q4 2025"
562
+ # }
563
+ # ],
564
+ # "page_break": True
565
+ # },
566
+ # {
567
+ # "content": """
568
+ # # Recommendations & 2026 Strategy
569
+
570
+ # ## Key Recommendations
571
+
572
+ # ### Immediate Actions (Q1 2026)
573
+
574
+ # 1. **Expand Mobile Capabilities**
575
+ # - Invest in mobile app feature development
576
+ # - Optimize mobile checkout experience
577
+ # - Target: 60% mobile order share by Q2 2026
578
+
579
+ # 2. **Scale Personalization Engine**
580
+ # - Deploy to all customer touchpoints
581
+ # - Expected: 35% increase in engagement
582
+
583
+ # 3. **Enhance Data Infrastructure**
584
+ # - Implement advanced data governance framework
585
+ # - Prepare for 3x data volume growth
586
+
587
+ # <div class="executive-summary">
588
+
589
+ # **2026 Vision**: Position our analytics capabilities as a competitive differentiator through advanced AI, real-time insights, and predictive decision-making.
590
+
591
+ # </div>
592
+
593
+ # ## Investment Requirements
594
+
595
+ # | Initiative | Investment | Expected ROI | Timeline |
596
+ # |------------|------------|--------------|----------|
597
+ # | Mobile Expansion | $450K | 280% | Q1-Q2 2026 |
598
+ # | AI/ML Platform | $780K | 420% | Q1-Q4 2026 |
599
+ # | Infrastructure | $320K | 195% | Q1-Q3 2026 |
600
+ # | **Total** | **$1.55M** | **310%** | **2026** |
601
+ # """
602
+ # },
603
+ # {
604
+ # "content": f"""
605
+ # # Conclusion
606
+
607
+ # ## Quarter Summary
608
+
609
+ # Q4 2025 represents a milestone quarter for our analytics organization. We exceeded all primary objectives, delivered exceptional data quality, and laid the foundation for continued growth.
610
+
611
+ # ### Key Achievements
612
+
613
+ # - βœ“ **23.4% revenue growth** exceeding target by 5%
614
+ # - βœ“ **8,547 new customers** acquired, 7% above goal
615
+ # - βœ“ **94.2% retention rate**, best in company history
616
+ # - βœ“ **98.7% data quality score**, industry-leading
617
+
618
+ # <div class="highlight-box">
619
+
620
+ # **Strategic Position**: Our analytics capabilities represent a significant competitive advantage for 2026 and beyond.
621
+
622
+ # </div>
623
+
624
+ # ---
625
+
626
+ # **Prepared by**: Data Analytics Team
627
+ # **Report Date**: {datetime.now().strftime("%B %d, %Y")}
628
+ # **Classification**: Confidential - Internal Use Only
629
+ # """
630
+ # }
631
+ # ]
632
+
633
+ # # Generate the complete Q4 report
634
+ # generator = Q4ReportGenerator(
635
+ # title="Q4 2025 Analytics Performance Report",
636
+ # subtitle="Fourth Quarter Results & 2026 Strategy",
637
+ # author="Senior Data Analyst",
638
+ # department="Data Analytics Division",
639
+ # confidential=True
640
+ # )
641
+
642
+ # success = generator.generate(
643
+ # sections=q4_sections,
644
+ # filename="Q4_2025_Complete_Report.pdf",
645
+ # output_dir="./output",
646
+ # cover_page=True
647
+ # )
648
+
649
+ # if success:
650
+ # print("\nπŸ“‹ Report Features:")
651
+ # print(" βœ“ Executive cover page")
652
+ # print(" βœ“ Performance metrics with tables")
653
+ # print(" βœ“ Embedded charts and visualizations")
654
+ # print(" βœ“ Customer analytics insights")
655
+ # print(" βœ“ Strategic recommendations")
656
+ # print(" βœ“ Professional styling and formatting")
657
+ # print("\nπŸ’‘ Check the 'output' folder for your PDF!")
658
+ # else:
659
+ # print("\n❌ Report generation failed. Check logs for details.")