Danialebrat commited on
Commit
8654a5b
·
1 Parent(s): 32acf2d

Adding joy

Browse files
visualization/utils/auth.py CHANGED
@@ -25,7 +25,8 @@ AUTHORIZED_EMAILS = {
25
  "jmilligan@musora.com",
26
  "dave@musora.com",
27
  "aarong@musora.com",
28
- "tugrul@musora.com"
 
29
  }
30
 
31
 
 
25
  "jmilligan@musora.com",
26
  "dave@musora.com",
27
  "aarong@musora.com",
28
+ "tugrul@musora.com",
29
+ "joy@musora.com"
30
  }
31
 
32
 
visualization/utils/pdf_exporter.py.tmp.61068.1776964409035 ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Report Exporter for Musora Sentiment Analysis Dashboard.
3
+
4
+ Generates a comprehensive PDF report from the filtered dashboard data and
5
+ Plotly visualizations.
6
+
7
+ Dependencies:
8
+ fpdf2 — PDF assembly (pip install fpdf2)
9
+ kaleido — Plotly PNG rendering (pip install kaleido)
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ import logging
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ # Ensure the visualization package root is importable when this module is
20
+ # loaded directly (e.g., during testing outside Streamlit).
21
+ _parent = Path(__file__).resolve().parent.parent
22
+ if str(_parent) not in sys.path:
23
+ sys.path.insert(0, str(_parent))
24
+
25
+ import plotly.io as pio
26
+ from fpdf import FPDF
27
+
28
+ from utils.metrics import SentimentMetrics
29
+ from utils.data_processor import SentimentDataProcessor
30
+ from visualizations.sentiment_charts import SentimentCharts
31
+ from visualizations.distribution_charts import DistributionCharts
32
+ from visualizations.demographic_charts import DemographicCharts
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Section descriptions — plain-language context shown below each section header.
38
+ # ---------------------------------------------------------------------------
39
+ _DESCRIPTIONS = {
40
+ "executive_summary": (
41
+ "A top-level snapshot of community sentiment across all Musora brands and platforms. "
42
+ "All findings are based on comments processed through the AI sentiment analysis pipeline."
43
+ ),
44
+ "sentiment": (
45
+ "Every comment is assigned one of five sentiment levels: "
46
+ "Very Positive, Positive, Neutral, Negative, or Very Negative. "
47
+ "The pie chart shows how those levels split across all analyzed comments. "
48
+ "The Sentiment Score (0-100) converts the average rating to a percentage scale: "
49
+ "50 = perfectly neutral, above 60 = primarily positive."
50
+ ),
51
+ "brand": (
52
+ "Sentiment broken down by Musora brand (Drumeo, Pianote, Guitareo, Singeo, etc.). "
53
+ "Shows both the count and percentage of each sentiment level per brand, "
54
+ "helping identify which brands receive the most positive or negative feedback."
55
+ ),
56
+ "platform": (
57
+ "Sentiment broken down by platform (Facebook, Instagram, YouTube, Twitter, Musora App). "
58
+ "Helps compare audience sentiment across channels."
59
+ ),
60
+ "intent": (
61
+ "Beyond positive/negative, the AI identifies the intent behind each comment: "
62
+ "praise, questions, requests, feedback, suggestions, humor, off-topic, or spam. "
63
+ "Understanding intent helps prioritize community management."
64
+ ),
65
+ "cross_dimensional": (
66
+ "Cross-dimensional analysis reveals patterns across both brand and platform simultaneously. "
67
+ "The heatmaps show comment volume and negative sentiment concentration by combination."
68
+ ),
69
+ "volume": (
70
+ "Volume analysis shows the distribution of comments across platforms and brands, "
71
+ "indicating where the most community engagement is happening."
72
+ ),
73
+ "reply_requirements": (
74
+ "Comments flagged as requiring a reply, broken down by brand and platform. "
75
+ "The urgency breakdown helps prioritize community management resources."
76
+ ),
77
+ "demographics": (
78
+ "Demographics data is available for Musora App comments and is derived from user profiles. "
79
+ "Note: These charts reflect only users who have filled in their profile information - "
80
+ "they do not represent all community members."
81
+ ),
82
+ "emotion": (
83
+ "Beyond sentiment polarity, the AI identifies the underlying emotion in each comment: "
84
+ "joy, excitement, gratitude, admiration, curiosity, humor, frustration, "
85
+ "disappointment, sadness, anger, or neutral. "
86
+ "Comments can have multiple emotions (multi-label). "
87
+ "Emotions with no data are omitted from the charts."
88
+ ),
89
+ "language": (
90
+ "Language distribution shows what languages comments are written in. "
91
+ "Non-English comments are automatically translated for analysis."
92
+ ),
93
+ }
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Musora brand colours
97
+ # ---------------------------------------------------------------------------
98
+ _PRIMARY_HEX = "#1982C4"
99
+ _PRIMARY_RGB = (25, 130, 196)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # PDF document class
104
+ # ---------------------------------------------------------------------------
105
+
106
+ class MusoraPDF(FPDF):
107
+ """Custom FPDF subclass with Musora branding and layout helpers."""
108
+
109
+ PRIMARY = _PRIMARY_RGB
110
+ WHITE = (255, 255, 255)
111
+ GRAY = (180, 180, 180)
112
+ LIGHT_GRAY = (240, 240, 240)
113
+
114
+ def __init__(self):
115
+ super().__init__(orientation="P", unit="mm", format="A4")
116
+ self.set_auto_page_break(auto=True, margin=20)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Helpers
120
+ # ------------------------------------------------------------------
121
+
122
+ @staticmethod
123
+ def _sanitize(text: str) -> str:
124
+ """Strip characters outside Latin-1 (required by the Helvetica font)."""
125
+ if not isinstance(text, str):
126
+ text = str(text)
127
+ return text.encode("latin-1", errors="ignore").decode("latin-1")
128
+
129
+ # ------------------------------------------------------------------
130
+ # FPDF overrides
131
+ # ------------------------------------------------------------------
132
+
133
+ def header(self):
134
+ if self.page_no() > 1:
135
+ self.set_font("Helvetica", "B", 8)
136
+ self.set_text_color(*self.GRAY)
137
+ self.cell(0, 6, "Musora Sentiment Analysis Report", align="L")
138
+ self.cell(
139
+ 0, 6, f"Page {self.page_no()}", align="R",
140
+ new_x="LMARGIN", new_y="NEXT",
141
+ )
142
+ self.set_draw_color(*self.PRIMARY)
143
+ self.set_line_width(0.5)
144
+ self.line(10, self.get_y(), 200, self.get_y())
145
+ self.ln(4)
146
+
147
+ def footer(self):
148
+ self.set_y(-15)
149
+ self.set_font("Helvetica", "I", 7)
150
+ self.set_text_color(*self.GRAY)
151
+ self.cell(
152
+ 0, 10,
153
+ f"Generated on {datetime.now().strftime('%Y-%m-%d %H:%M')} | Confidential",
154
+ align="C",
155
+ )
156
+
157
+ # ------------------------------------------------------------------
158
+ # Layout primitives
159
+ # ------------------------------------------------------------------
160
+
161
+ def check_page_break(self, needed_mm: float) -> None:
162
+ """Add a page break if less than *needed_mm* mm remain on the page."""
163
+ if self.get_y() + needed_mm > self.h - 20:
164
+ self.add_page()
165
+
166
+ def section_header(self, title: str) -> None:
167
+ """Bold, brand-coloured section heading with an underline rule."""
168
+ title = self._sanitize(title)
169
+ self.check_page_break(20)
170
+ self.ln(4)
171
+ self.set_font("Helvetica", "B", 14)
172
+ self.set_text_color(*self.PRIMARY)
173
+ self.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT")
174
+ self.set_draw_color(*self.PRIMARY)
175
+ self.set_line_width(0.3)
176
+ self.line(10, self.get_y(), 200, self.get_y())
177
+ self.ln(3)
178
+ self.set_text_color(0, 0, 0)
179
+
180
+ def subsection_header(self, title: str) -> None:
181
+ """Lighter subsection heading."""
182
+ title = self._sanitize(title)
183
+ self.check_page_break(15)
184
+ self.ln(2)
185
+ self.set_font("Helvetica", "B", 11)
186
+ self.set_text_color(60, 60, 60)
187
+ self.cell(0, 8, title, new_x="LMARGIN", new_y="NEXT")
188
+ self.ln(1)
189
+ self.set_text_color(0, 0, 0)
190
+
191
+ def section_description(self, text: str) -> None:
192
+ """Italicised description block beneath a section header."""
193
+ text = self._sanitize(text)
194
+ self.set_font("Helvetica", "I", 9)
195
+ self.set_text_color(80, 80, 80)
196
+ self.multi_cell(0, 5, text)
197
+ self.ln(4)
198
+ self.set_text_color(0, 0, 0)
199
+
200
+ def body_text(self, text: str) -> None:
201
+ """Standard paragraph text."""
202
+ text = self._sanitize(text)
203
+ self.set_font("Helvetica", "", 9)
204
+ self.set_text_color(50, 50, 50)
205
+ self.multi_cell(0, 5, text)
206
+ self.ln(2)
207
+ self.set_text_color(0, 0, 0)
208
+
209
+ def callout_box(
210
+ self,
211
+ text: str,
212
+ bg_color: tuple = (240, 248, 255),
213
+ border_color: tuple = None,
214
+ ) -> None:
215
+ """Lightly-coloured info/callout box with a left accent bar."""
216
+ if border_color is None:
217
+ border_color = self.PRIMARY
218
+ text = self._sanitize(text)
219
+ self.check_page_break(20)
220
+ x, w = 10, 180
221
+ approx_lines = max(2, len(text) // 90 + text.count("\n") + 1)
222
+ h = approx_lines * 5 + 6
223
+ y = self.get_y()
224
+ self.set_fill_color(*bg_color)
225
+ self.rect(x, y, w, h, style="F")
226
+ self.set_fill_color(*border_color)
227
+ self.rect(x, y, 3, h, style="F")
228
+ self.set_font("Helvetica", "", 8.5)
229
+ self.set_text_color(40, 40, 40)
230
+ self.set_xy(x + 5, y + 3)
231
+ self.multi_cell(w - 7, 4.5, text)
232
+ self.set_y(y + h + 3)
233
+ self.set_text_color(0, 0, 0)
234
+
235
+ def metric_row(self, metrics: list) -> None:
236
+ """
237
+ Horizontal row of metric tiles.
238
+
239
+ Args:
240
+ metrics: list of (label, value) tuples.
241
+ """
242
+ self.check_page_break(18)
243
+ n = len(metrics)
244
+ if n == 0:
245
+ return
246
+ box_w = (190 - (n - 1) * 3) / n
247
+ x0 = 10
248
+ y = self.get_y()
249
+ for i, (label, value) in enumerate(metrics):
250
+ x = x0 + i * (box_w + 3)
251
+ self.set_fill_color(245, 245, 245)
252
+ self.rect(x, y, box_w, 14, style="F")
253
+ self.set_xy(x, y + 1)
254
+ self.set_font("Helvetica", "B", 10)
255
+ self.set_text_color(*self.PRIMARY)
256
+ self.cell(box_w, 6, self._sanitize(str(value)), align="C")
257
+ self.set_xy(x, y + 7)
258
+ self.set_font("Helvetica", "", 7)
259
+ self.set_text_color(100, 100, 100)
260
+ self.cell(box_w, 5, self._sanitize(str(label)), align="C")
261
+ self.set_text_color(0, 0, 0)
262
+ self.set_y(y + 16)
263
+
264
+ def add_table(
265
+ self,
266
+ headers: list,
267
+ rows: list,
268
+ col_widths: list = None,
269
+ ) -> None:
270
+ """
271
+ Styled data table with alternating row shading.
272
+
273
+ Args:
274
+ headers: Column header strings.
275
+ rows: List of row tuples/lists.
276
+ col_widths: Optional column widths in mm.
277
+ """
278
+ self.check_page_break(10 + len(rows) * 6)
279
+ n = len(headers)
280
+ if col_widths is None:
281
+ col_widths = [190 / n] * n
282
+ # Header
283
+ self.set_font("Helvetica", "B", 8)
284
+ self.set_fill_color(*self.PRIMARY)
285
+ self.set_text_color(*self.WHITE)
286
+ for i, hdr in enumerate(headers):
287
+ self.cell(col_widths[i], 7, self._sanitize(hdr), border=1, fill=True, align="C")
288
+ self.ln()
289
+ # Rows
290
+ self.set_font("Helvetica", "", 8)
291
+ self.set_text_color(0, 0, 0)
292
+ for row_idx, row in enumerate(rows):
293
+ self.set_fill_color(250, 250, 250) if row_idx % 2 == 0 else self.set_fill_color(*self.WHITE)
294
+ for i, cell_val in enumerate(row):
295
+ self.cell(col_widths[i], 6, self._sanitize(str(cell_val)), border=1, fill=True, align="C")
296
+ self.ln()
297
+ self.ln(2)
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Main exporter
302
+ # ---------------------------------------------------------------------------
303
+
304
+ class DashboardPDFExporter:
305
+ """
306
+ Generates a comprehensive PDF report from the Musora Sentiment dashboard.
307
+
308
+ Usage::
309
+
310
+ exporter = DashboardPDFExporter()
311
+ pdf_bytes = exporter.generate_report(filtered_df, filter_info)
312
+
313
+ The *filter_info* dict (optional) maps human-readable filter names to their
314
+ selected values and is shown on the cover page.
315
+ """
316
+
317
+ # Kaleido scale factor: 3× ≈ 300 DPI at A4 print size.
318
+ RENDER_SCALE = 3
319
+
320
+ def __init__(self):
321
+ self.sentiment_charts = SentimentCharts()
322
+ self.distribution_charts = DistributionCharts()
323
+ self.demographic_charts = DemographicCharts()
324
+ self.processor = SentimentDataProcessor()
325
+ self._temp_files: list[str] = []
326
+
327
+ # ------------------------------------------------------------------
328
+ # Public entry point
329
+ # ------------------------------------------------------------------
330
+
331
+ def generate_report(self, df, filter_info: dict = None) -> bytes:
332
+ """
333
+ Build and return the full PDF report.
334
+
335
+ Args:
336
+ df: Filtered dashboard DataFrame.
337
+ filter_info: Optional dict of active filter descriptions shown on
338
+ the cover page, e.g. {"Platforms": ["facebook"],
339
+ "Brands": ["drumeo"]}.
340
+
341
+ Returns:
342
+ bytes: Raw PDF file contents ready for st.download_button.
343
+ """
344
+ self.pdf = MusoraPDF()
345
+ try:
346
+ self._add_cover_page(df, filter_info)
347
+ self._add_executive_summary(df)
348
+ self._add_sentiment_section(df)
349
+ self._add_brand_section(df)
350
+ self._add_platform_section(df)
351
+ self._add_intent_section(df)
352
+ self._add_emotion_section(df)
353
+ self._add_cross_dimensional_section(df)
354
+ self._add_volume_section(df)
355
+ self._add_reply_requirements_section(df)
356
+ if self._has_demographics(df):
357
+ self._add_demographics_section(df)
358
+ if "detected_language" in df.columns:
359
+ self._add_language_section(df)
360
+ self._add_data_summary(df, filter_info)
361
+ self._add_helpscout_summary_section()
362
+
363
+ return bytes(self.pdf.output())
364
+ finally:
365
+ self._cleanup_temp_files()
366
+
367
+ # ------------------------------------------------------------------
368
+ # Chart rendering helpers
369
+ # ------------------------------------------------------------------
370
+
371
+ def _prepare_fig_for_pdf(self, fig, is_side_by_side: bool = False) -> None:
372
+ """Apply white background, readable fonts, and automargin to a Plotly figure."""
373
+ base_fs = 13 if is_side_by_side else 14
374
+ fig.update_layout(
375
+ paper_bgcolor="white",
376
+ plot_bgcolor="white",
377
+ font=dict(color="black", size=base_fs),
378
+ title_font_size=base_fs + 4,
379
+ margin=(
380
+ dict(l=60, r=40, t=60, b=60)
381
+ if is_side_by_side
382
+ else dict(l=80, r=40, t=60, b=80)
383
+ ),
384
+ )
385
+ fig.update_xaxes(automargin=True)
386
+ fig.update_yaxes(automargin=True)
387
+ if fig.layout.showlegend is not False:
388
+ fig.update_layout(legend_font_size=base_fs - 2)
389
+
390
+ def _fig_to_temp_path(
391
+ self, fig, width: int = 800, height: int = 400, is_side_by_side: bool = False
392
+ ) -> str:
393
+ """Render a Plotly figure to a temporary high-DPI PNG and return the path."""
394
+ self._prepare_fig_for_pdf(fig, is_side_by_side=is_side_by_side)
395
+ img_bytes = pio.to_image(
396
+ fig,
397
+ format="png",
398
+ width=width,
399
+ height=height,
400
+ scale=self.RENDER_SCALE,
401
+ engine="kaleido",
402
+ )
403
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
404
+ tmp.write(img_bytes)
405
+ tmp.close()
406
+ self._temp_files.append(tmp.name)
407
+ return tmp.name
408
+
409
+ def _add_chart(self, fig, width: int = 180, img_width: int = 800, img_height: int = 400) -> None:
410
+ """Render one figure full-width on the current PDF page."""
411
+ try:
412
+ path = self._fig_to_temp_path(fig, img_width, img_height)
413
+ h_mm = width * (img_height / img_width)
414
+ self.pdf.check_page_break(h_mm + 5)
415
+ self.pdf.image(path, x=10, w=width)
416
+ self.pdf.ln(3)
417
+ except Exception:
418
+ logger.exception("Chart render failed")
419
+ self.pdf.body_text("[Chart could not be rendered]")
420
+
421
+ def _add_two_charts(self, fig1, fig2, width: int = 92) -> None:
422
+ """Render two figures side-by-side."""
423
+ try:
424
+ p1 = self._fig_to_temp_path(fig1, 700, 450, is_side_by_side=True)
425
+ p2 = self._fig_to_temp_path(fig2, 700, 450, is_side_by_side=True)
426
+ h_mm = width * (450 / 700)
427
+ self.pdf.check_page_break(h_mm + 5)
428
+ y = self.pdf.get_y()
429
+ self.pdf.image(p1, x=10, y=y, w=width)
430
+ self.pdf.image(p2, x=10 + width + 4, y=y, w=width)
431
+ self.pdf.set_y(y + h_mm + 3)
432
+ except Exception:
433
+ logger.exception("Side-by-side chart render failed")
434
+ self.pdf.body_text("[Charts could not be rendered]")
435
+
436
+ def _cleanup_temp_files(self) -> None:
437
+ for path in self._temp_files:
438
+ try:
439
+ os.unlink(path)
440
+ except OSError:
441
+ pass
442
+ self._temp_files.clear()
443
+
444
+ # ------------------------------------------------------------------
445
+ # Data helpers
446
+ # ------------------------------------------------------------------
447
+
448
+ @staticmethod
449
+ def _has_demographics(df) -> bool:
450
+ return (
451
+ "platform" in df.columns
452
+ and "musora_app" in df["platform"].values
453
+ and "age_group" in df.columns
454
+ and "timezone" in df.columns
455
+ and "experience_level" in df.columns
456
+ )
457
+
458
+ @staticmethod
459
+ def _filter_summary(filter_info: dict) -> str:
460
+ if not filter_info:
461
+ return "No filters applied - showing all data."
462
+ parts = []
463
+ for key, value in filter_info.items():
464
+ if value:
465
+ display = (
466
+ value if isinstance(value, str)
467
+ else ", ".join(str(v) for v in value)
468
+ )
469
+ parts.append(f"{key}: {display}")
470
+ return "; ".join(parts) if parts else "No filters applied."
471
+
472
+ @staticmethod
473
+ def _date_range_str(df) -> str:
474
+ if "comment_timestamp" not in df.columns or df.empty:
475
+ return "N/A"
476
+ valid = df["comment_timestamp"].dropna()
477
+ if valid.empty:
478
+ return "N/A"
479
+ return (
480
+ f"{valid.min().strftime('%b %d, %Y')} to {valid.max().strftime('%b %d, %Y')}"
481
+ )
482
+
483
+ # ------------------------------------------------------------------
484
+ # Report sections
485
+ # ------------------------------------------------------------------
486
+
487
+ def _add_cover_page(self, df, filter_info: dict) -> None:
488
+ self.pdf.add_page()
489
+ self.pdf.ln(40)
490
+
491
+ r, g, b = MusoraPDF.PRIMARY
492
+ self.pdf.set_fill_color(r, g, b)
493
+ self.pdf.rect(0, 60, 210, 4, style="F")
494
+
495
+ self.pdf.ln(20)
496
+ self.pdf.set_font("Helvetica", "B", 28)
497
+ self.pdf.set_text_color(r, g, b)
498
+ self.pdf.cell(0, 15, "Musora", align="C", new_x="LMARGIN", new_y="NEXT")
499
+
500
+ self.pdf.set_font("Helvetica", "", 16)
501
+ self.pdf.set_text_color(80, 80, 80)
502
+ self.pdf.cell(
503
+ 0, 10, "Sentiment Analysis Report",
504
+ align="C", new_x="LMARGIN", new_y="NEXT",
505
+ )
506
+
507
+ self.pdf.ln(10)
508
+ self.pdf.set_draw_color(r, g, b)
509
+ self.pdf.set_line_width(0.5)
510
+ self.pdf.line(60, self.pdf.get_y(), 150, self.pdf.get_y())
511
+ self.pdf.ln(10)
512
+
513
+ self.pdf.set_font("Helvetica", "", 12)
514
+ self.pdf.set_text_color(100, 100, 100)
515
+ self.pdf.cell(
516
+ 0, 8,
517
+ f"Generated: {datetime.now().strftime('%B %d, %Y at %H:%M')}",
518
+ align="C", new_x="LMARGIN", new_y="NEXT",
519
+ )
520
+
521
+ self.pdf.ln(5)
522
+ self.pdf.set_font("Helvetica", "", 10)
523
+ self.pdf.cell(
524
+ 0, 7,
525
+ f"Total Comments Analyzed: {len(df):,}",
526
+ align="C", new_x="LMARGIN", new_y="NEXT",
527
+ )
528
+
529
+ date_str = self._date_range_str(df)
530
+ if date_str != "N/A":
531
+ self.pdf.ln(3)
532
+ self.pdf.set_font("Helvetica", "I", 9)
533
+ self.pdf.set_text_color(120, 120, 120)
534
+ self.pdf.cell(
535
+ 0, 6,
536
+ MusoraPDF._sanitize(f"Data period: {date_str}"),
537
+ align="C", new_x="LMARGIN", new_y="NEXT",
538
+ )
539
+
540
+ if filter_info:
541
+ self.pdf.ln(8)
542
+ self.pdf.set_font("Helvetica", "B", 9)
543
+ self.pdf.set_text_color(80, 80, 80)
544
+ self.pdf.cell(0, 6, "Active Filters:", align="C", new_x="LMARGIN", new_y="NEXT")
545
+ self.pdf.set_font("Helvetica", "", 9)
546
+ for key, value in filter_info.items():
547
+ if value:
548
+ display = (
549
+ value if isinstance(value, str)
550
+ else ", ".join(str(v) for v in value)
551
+ )
552
+ self.pdf.cell(
553
+ 0, 5,
554
+ MusoraPDF._sanitize(f"{key}: {display}"),
555
+ align="C", new_x="LMARGIN", new_y="NEXT",
556
+ )
557
+
558
+ self.pdf.ln(20)
559
+ self.pdf.set_font("Helvetica", "I", 8)
560
+ self.pdf.set_text_color(150, 150, 150)
561
+ self.pdf.cell(
562
+ 0, 6, "Confidential - For Internal Use Only",
563
+ align="C", new_x="LMARGIN", new_y="NEXT",
564
+ )
565
+ self.pdf.cell(
566
+ 0, 6, "Data Source: Snowflake | Musora Sentiment Pipeline",
567
+ align="C", new_x="LMARGIN", new_y="NEXT",
568
+ )
569
+
570
+ def _add_executive_summary(self, df) -> None:
571
+ self.pdf.add_page()
572
+ self.pdf.section_header("Executive Summary")
573
+ self.pdf.section_description(_DESCRIPTIONS["executive_summary"])
574
+
575
+ metrics = SentimentMetrics.calculate_overall_metrics(df)
576
+ normalized_score = ((metrics["avg_sentiment_score"] + 2) / 4) * 100
577
+
578
+ # Health label
579
+ neg_pct = metrics["negative_pct"]
580
+ health = "Healthy" if neg_pct < 20 else ("Moderate" if neg_pct < 35 else "Needs Attention")
581
+
582
+ # Opening narrative
583
+ brands = sorted(df["brand"].dropna().unique().tolist()) if "brand" in df.columns else []
584
+ platforms = sorted(df["platform"].dropna().unique().tolist()) if "platform" in df.columns else []
585
+ brands_str = ", ".join(str(b).title() for b in brands[:6]) if brands else "all brands"
586
+ platforms_str = ", ".join(str(p).title() for p in platforms[:6]) if platforms else "all platforms"
587
+
588
+ narrative = (
589
+ f"This report analyzes {metrics['total_comments']:,} comments across {brands_str} "
590
+ f"on {platforms_str}. "
591
+ f"Overall sentiment is {metrics['positive_pct']:.1f}% positive and "
592
+ f"{metrics['negative_pct']:.1f}% negative, "
593
+ f"with {metrics['reply_required_pct']:.1f}% of comments requiring a reply."
594
+ )
595
+ self.pdf.body_text(narrative)
596
+
597
+ # Health status
598
+ r, g, b = MusoraPDF.PRIMARY
599
+ self.pdf.set_font("Helvetica", "B", 11)
600
+ self.pdf.set_text_color(r, g, b)
601
+ self.pdf.cell(
602
+ 0, 8, f"Overall Sentiment Health: {health}",
603
+ new_x="LMARGIN", new_y="NEXT",
604
+ )
605
+ self.pdf.ln(2)
606
+ self.pdf.set_text_color(0, 0, 0)
607
+
608
+ # Metric tiles — two rows
609
+ self.pdf.metric_row([
610
+ ("Total Comments", f"{metrics['total_comments']:,}"),
611
+ ("Positive %", f"{metrics['positive_pct']:.1f}%"),
612
+ ("Negative %", f"{metrics['negative_pct']:.1f}%"),
613
+ ("Sentiment Score", f"{normalized_score:.0f}/100"),
614
+ ])
615
+ self.pdf.metric_row([
616
+ ("Reply Required", f"{metrics['total_reply_required']:,}"),
617
+ ("Reply Rate %", f"{metrics['reply_required_pct']:.1f}%"),
618
+ ("Brands Analyzed", str(len(brands))),
619
+ ("Platforms Analyzed", str(len(platforms))),
620
+ ])
621
+
622
+ # Score explanation
623
+ self.pdf.ln(2)
624
+ self.pdf.callout_box(
625
+ "How to read the Sentiment Score:\n"
626
+ "Each comment is rated Very Positive (+2), Positive (+1), Neutral (0), "
627
+ "Negative (-1), or Very Negative (-2). "
628
+ "The Score (0-100) converts the average: 50 = perfectly neutral, "
629
+ "above 60 = primarily positive, below 40 = primarily negative.",
630
+ )
631
+
632
+ # Key findings
633
+ self.pdf.subsection_header("Key Findings")
634
+ for finding in self._generate_key_findings(df, metrics):
635
+ self.pdf.body_text(f" * {finding}")
636
+
637
+ def _generate_key_findings(self, df, metrics: dict) -> list:
638
+ findings = []
639
+
640
+ # Sentiment summary
641
+ if metrics["positive_pct"] > 50:
642
+ findings.append(
643
+ f"Sentiment is predominantly positive at {metrics['positive_pct']:.1f}%."
644
+ )
645
+ elif metrics["negative_pct"] > 30:
646
+ findings.append(
647
+ f"Negative sentiment is elevated at {metrics['negative_pct']:.1f}% - "
648
+ f"consider targeted community management."
649
+ )
650
+ else:
651
+ findings.append(
652
+ f"Sentiment is balanced: {metrics['positive_pct']:.1f}% positive, "
653
+ f"{metrics['negative_pct']:.1f}% negative."
654
+ )
655
+
656
+ # Top brand by volume
657
+ if "brand" in df.columns and not df.empty:
658
+ top_brand = df["brand"].value_counts().index[0]
659
+ top_count = df["brand"].value_counts().iloc[0]
660
+ findings.append(
661
+ f"Most discussed brand: {str(top_brand).title()} "
662
+ f"({top_count:,} comments, {top_count / len(df) * 100:.1f}% of total)."
663
+ )
664
+
665
+ # Reply urgency
666
+ if metrics["reply_required_pct"] > 10:
667
+ findings.append(
668
+ f"{metrics['total_reply_required']:,} comments "
669
+ f"({metrics['reply_required_pct']:.1f}%) require a reply."
670
+ )
671
+
672
+ # Top platform by volume
673
+ if "platform" in df.columns and not df.empty:
674
+ top_platform = df["platform"].value_counts().index[0]
675
+ plat_count = df["platform"].value_counts().iloc[0]
676
+ findings.append(
677
+ f"Most active platform: {str(top_platform).title()} "
678
+ f"({plat_count:,} comments)."
679
+ )
680
+
681
+ return findings[:4]
682
+
683
+ def _add_sentiment_section(self, df) -> None:
684
+ self.pdf.add_page()
685
+ self.pdf.section_header("Sentiment Distribution")
686
+ self.pdf.section_description(_DESCRIPTIONS["sentiment"])
687
+
688
+ metrics = SentimentMetrics.calculate_overall_metrics(df)
689
+ normalized_score = ((metrics["avg_sentiment_score"] + 2) / 4) * 100
690
+
691
+ pie = self.sentiment_charts.create_sentiment_pie_chart(df, title="Sentiment Distribution")
692
+ gauge = self.sentiment_charts.create_sentiment_score_gauge(
693
+ metrics["avg_sentiment_score"], title="Overall Sentiment Score"
694
+ )
695
+ self._add_two_charts(pie, gauge)
696
+
697
+ self.pdf.body_text(
698
+ f"Across {metrics['total_comments']:,} analyzed comments: "
699
+ f"{metrics['positive_pct']:.1f}% positive, "
700
+ f"{100 - metrics['positive_pct'] - metrics['negative_pct']:.1f}% neutral, "
701
+ f"{metrics['negative_pct']:.1f}% negative. "
702
+ f"Sentiment Score: {normalized_score:.0f}/100 "
703
+ f"(raw average: {metrics['avg_sentiment_score']:.2f} on a -2 to +2 scale)."
704
+ )
705
+
706
+ def _add_brand_section(self, df) -> None:
707
+ if "brand" not in df.columns or df["brand"].nunique() == 0:
708
+ return
709
+
710
+ self.pdf.add_page()
711
+ self.pdf.section_header("Sentiment by Brand")
712
+ self.pdf.section_description(_DESCRIPTIONS["brand"])
713
+
714
+ bar = self.sentiment_charts.create_sentiment_bar_chart(
715
+ df, group_by="brand", title="Sentiment Distribution by Brand"
716
+ )
717
+ pct = self.sentiment_charts.create_sentiment_percentage_bar_chart(
718
+ df, group_by="brand", title="Sentiment by Brand (%)"
719
+ )
720
+ self._add_two_charts(bar, pct)
721
+
722
+ # Summary table
723
+ brand_metrics = SentimentMetrics.calculate_brand_metrics(df)
724
+ rows = []
725
+ for brand, m in sorted(brand_metrics.items()):
726
+ score = ((m["avg_sentiment_score"] + 2) / 4) * 100
727
+ rows.append((
728
+ str(brand).title(),
729
+ f"{m['total_comments']:,}",
730
+ f"{m['positive_pct']:.1f}%",
731
+ f"{m['negative_pct']:.1f}%",
732
+ f"{m['reply_required_pct']:.1f}%",
733
+ f"{score:.0f}/100",
734
+ ))
735
+ self.pdf.subsection_header("Brand Metrics Summary")
736
+ self.pdf.add_table(
737
+ headers=["Brand", "Comments", "Positive %", "Negative %", "Reply Rate", "Score"],
738
+ rows=rows,
739
+ col_widths=[38, 32, 30, 30, 30, 30],
740
+ )
741
+
742
+ def _add_platform_section(self, df) -> None:
743
+ if "platform" not in df.columns or df["platform"].nunique() == 0:
744
+ return
745
+
746
+ self.pdf.add_page()
747
+ self.pdf.section_header("Sentiment by Platform")
748
+ self.pdf.section_description(_DESCRIPTIONS["platform"])
749
+
750
+ bar = self.sentiment_charts.create_sentiment_bar_chart(
751
+ df, group_by="platform", title="Sentiment Distribution by Platform"
752
+ )
753
+ pct = self.sentiment_charts.create_sentiment_percentage_bar_chart(
754
+ df, group_by="platform", title="Sentiment by Platform (%)"
755
+ )
756
+ self._add_two_charts(bar, pct)
757
+
758
+ # Summary table
759
+ platform_metrics = SentimentMetrics.calculate_platform_metrics(df)
760
+ rows = []
761
+ for platform, m in sorted(platform_metrics.items()):
762
+ score = ((m["avg_sentiment_score"] + 2) / 4) * 100
763
+ rows.append((
764
+ str(platform).title(),
765
+ f"{m['total_comments']:,}",
766
+ f"{m['positive_pct']:.1f}%",
767
+ f"{m['negative_pct']:.1f}%",
768
+ f"{m['reply_required_pct']:.1f}%",
769
+ f"{score:.0f}/100",
770
+ ))
771
+ self.pdf.subsection_header("Platform Metrics Summary")
772
+ self.pdf.add_table(
773
+ headers=["Platform", "Comments", "Positive %", "Negative %", "Reply Rate", "Score"],
774
+ rows=rows,
775
+ col_widths=[38, 32, 30, 30, 30, 30],
776
+ )
777
+
778
+ def _add_intent_section(self, df) -> None:
779
+ if "intent" not in df.columns:
780
+ return
781
+
782
+ self.pdf.add_page()
783
+ self.pdf.section_header("Intent Analysis")
784
+ self.pdf.section_description(_DESCRIPTIONS["intent"])
785
+
786
+ intent_bar = self.distribution_charts.create_intent_bar_chart(
787
+ df, title="Intent Distribution", orientation="h"
788
+ )
789
+ intent_pie = self.distribution_charts.create_intent_pie_chart(
790
+ df, title="Intent Distribution"
791
+ )
792
+ self._add_two_charts(intent_bar, intent_pie)
793
+
794
+ def _add_emotion_section(self, df) -> None:
795
+ if "emotions" not in df.columns or df["emotions"].dropna().empty:
796
+ return
797
+
798
+ self.pdf.add_page()
799
+ self.pdf.section_header("Emotion Analysis")
800
+ self.pdf.section_description(_DESCRIPTIONS["emotion"])
801
+
802
+ emotion_bar = self.distribution_charts.create_emotion_bar_chart(
803
+ df, title="Emotion Distribution", orientation="h"
804
+ )
805
+ emotion_pie = self.distribution_charts.create_emotion_pie_chart(
806
+ df, title="Emotion Distribution"
807
+ )
808
+ self._add_two_charts(emotion_bar, emotion_pie)
809
+
810
+ # Top 5 emotions summary
811
+ emotion_dist = self.processor.get_emotion_distribution(df)
812
+ if not emotion_dist.empty:
813
+ self.pdf.subsection_header("Top Emotions")
814
+ rows = []
815
+ for _, row in emotion_dist.sort_values('count', ascending=False).head(8).iterrows():
816
+ rows.append((
817
+ str(row['emotions']).title(),
818
+ f"{int(row['count']):,}",
819
+ f"{row['percentage']:.1f}%",
820
+ ))
821
+ self.pdf.add_table(
822
+ headers=["Emotion", "Count", "Percentage"],
823
+ rows=rows,
824
+ col_widths=[80, 55, 55],
825
+ )
826
+
827
+ def _add_cross_dimensional_section(self, df) -> None:
828
+ if "brand" not in df.columns or "platform" not in df.columns:
829
+ return
830
+
831
+ self.pdf.add_page()
832
+ self.pdf.section_header("Cross-Dimensional Analysis")
833
+ self.pdf.section_description(_DESCRIPTIONS["cross_dimensional"])
834
+
835
+ matrix = self.distribution_charts.create_brand_platform_matrix(
836
+ df, title="Brand-Platform Comment Matrix"
837
+ )
838
+ heatmap = self.sentiment_charts.create_sentiment_heatmap(
839
+ df,
840
+ row_dimension="brand",
841
+ col_dimension="platform",
842
+ title="Negative Sentiment Heatmap",
843
+ )
844
+ self._add_two_charts(matrix, heatmap)
845
+
846
+ def _add_volume_section(self, df) -> None:
847
+ has_platform = "platform" in df.columns
848
+ has_brand = "brand" in df.columns
849
+ if not has_platform and not has_brand:
850
+ return
851
+
852
+ self.pdf.add_page()
853
+ self.pdf.section_header("Volume Analysis")
854
+ self.pdf.section_description(_DESCRIPTIONS["volume"])
855
+
856
+ if has_platform and has_brand:
857
+ platform_dist = self.distribution_charts.create_platform_distribution(
858
+ df, title="Comments by Platform"
859
+ )
860
+ brand_dist = self.distribution_charts.create_brand_distribution(
861
+ df, title="Comments by Brand"
862
+ )
863
+ self._add_two_charts(platform_dist, brand_dist)
864
+ elif has_platform:
865
+ self._add_chart(
866
+ self.distribution_charts.create_platform_distribution(df, title="Comments by Platform")
867
+ )
868
+ else:
869
+ self._add_chart(
870
+ self.distribution_charts.create_brand_distribution(df, title="Comments by Brand")
871
+ )
872
+
873
+ def _add_reply_requirements_section(self, df) -> None:
874
+ if "requires_reply" not in df.columns:
875
+ return
876
+
877
+ self.pdf.add_page()
878
+ self.pdf.section_header("Reply Requirements Analysis")
879
+ self.pdf.section_description(_DESCRIPTIONS["reply_requirements"])
880
+
881
+ urgency = SentimentMetrics.calculate_response_urgency(df)
882
+ self.pdf.metric_row([
883
+ ("Urgent", str(urgency["urgent_count"])),
884
+ ("High Priority", str(urgency["high_priority_count"])),
885
+ ("Medium Priority", str(urgency["medium_priority_count"])),
886
+ ("Low Priority", str(urgency["low_priority_count"])),
887
+ ])
888
+ self.pdf.ln(3)
889
+
890
+ has_brand = "brand" in df.columns
891
+ has_platform = "platform" in df.columns
892
+ if has_brand and has_platform:
893
+ reply_brand = self.distribution_charts.create_reply_required_chart(
894
+ df, group_by="brand", title="Comments Requiring Reply by Brand"
895
+ )
896
+ reply_platform = self.distribution_charts.create_reply_required_chart(
897
+ df, group_by="platform", title="Comments Requiring Reply by Platform"
898
+ )
899
+ self._add_two_charts(reply_brand, reply_platform)
900
+ elif has_brand:
901
+ self._add_chart(
902
+ self.distribution_charts.create_reply_required_chart(
903
+ df, group_by="brand", title="Comments Requiring Reply by Brand"
904
+ )
905
+ )
906
+
907
+ def _add_demographics_section(self, df) -> None:
908
+ df_musora = df[df["platform"] == "musora_app"].copy()
909
+ if df_musora.empty:
910
+ return
911
+
912
+ self.pdf.add_page()
913
+ self.pdf.section_header("Demographics Analysis (Musora App)")
914
+ self.pdf.section_description(_DESCRIPTIONS["demographics"])
915
+ self.pdf.body_text(f"Analyzing demographics for {len(df_musora):,} Musora App comments.")
916
+
917
+ # Age
918
+ age_dist = self.processor.get_demographics_distribution(df_musora, "age_group")
919
+ if not age_dist.empty:
920
+ self.pdf.subsection_header("Age Distribution")
921
+ self._add_chart(
922
+ self.demographic_charts.create_age_distribution_chart(
923
+ age_dist, title="Comments by Age Group"
924
+ ),
925
+ img_height=350,
926
+ )
927
+
928
+ # Region
929
+ region_dist = self.processor.get_timezone_regions_distribution(df_musora)
930
+ if not region_dist.empty:
931
+ self.pdf.subsection_header("Geographic Distribution")
932
+ self._add_chart(
933
+ self.demographic_charts.create_region_distribution_chart(
934
+ region_dist, title="Comments by Region"
935
+ ),
936
+ img_height=350,
937
+ )
938
+
939
+ # Experience
940
+ exp_dist = self.processor.get_experience_level_distribution(df_musora, use_groups=True)
941
+ if not exp_dist.empty:
942
+ self.pdf.subsection_header("Experience Level Distribution")
943
+ self._add_chart(
944
+ self.demographic_charts.create_experience_distribution_chart(
945
+ exp_dist, title="Comments by Experience Group", use_groups=True
946
+ ),
947
+ img_height=350,
948
+ )
949
+
950
+ def _add_language_section(self, df) -> None:
951
+ self.pdf.add_page()
952
+ self.pdf.section_header("Language Distribution")
953
+ self.pdf.section_description(_DESCRIPTIONS["language"])
954
+ self._add_chart(
955
+ self.distribution_charts.create_language_distribution(df, top_n=10, title="Top 10 Languages")
956
+ )
957
+
958
+ def _add_helpscout_summary_section(self) -> None:
959
+ """Short HelpScout overview appended to the combined dashboard PDF."""
960
+ try:
961
+ import streamlit as st
962
+ hs_df = st.session_state.get("helpscout_df")
963
+ if hs_df is None or hs_df.empty:
964
+ return
965
+
966
+ from utils.helpscout_utils import boolean_flag_counts
967
+ from visualizations.helpscout_charts import HelpScoutCharts
968
+
969
+ self.pdf.add_page()
970
+ self.pdf.section_header("HelpScout Support Overview")
971
+ self.pdf.section_description(
972
+ "Summary of customer support conversations processed through the "
973
+ "HelpScout sentiment pipeline."
974
+ )
975
+
976
+ total = len(hs_df)
977
+ flags = boolean_flag_counts(hs_df)
978
+ neg_pct = hs_df["sentiment_polarity"].isin(["negative", "very_negative"]).sum() / total * 100 if total else 0
979
+ esc = int(hs_df["is_escalation"].sum()) if "is_escalation" in hs_df.columns else 0
980
+
981
+ self.pdf.metric_row([
982
+ ("Conversations", f"{total:,}"),
983
+ ("Negative %", f"{neg_pct:.1f}%"),
984
+ ("Escalations", f"{esc:,}"),
985
+ ("Refund Requests", f"{flags['is_refund_request']:,}"),
986
+ ])
987
+
988
+ hs_charts = HelpScoutCharts()
989
+ pie = hs_charts.create_sentiment_pie_chart(hs_df, title="HelpScout Sentiment Distribution")
990
+ tbar = hs_charts.create_topic_bar_chart(hs_df, title="Top Topics", top_n=5)
991
+ self._add_two_charts(pie, tbar)
992
+
993
+ except Exception:
994
+ logger.exception("HelpScout summary section failed — skipping")
995
+
996
+ def _add_data_summary(self, df, filter_info: dict) -> None:
997
+ self.pdf.add_page()
998
+ self.pdf.section_header("Data Summary")
999
+
1000
+ self.pdf.body_text(
1001
+ f"Report generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1002
+ )
1003
+ self.pdf.body_text(f"Total records in report: {len(df):,}")
1004
+
1005
+ date_str = self._date_range_str(df)
1006
+ if date_str != "N/A":
1007
+ self.pdf.body_text(f"Data range: {date_str}")
1008
+
1009
+ self.pdf.body_text(f"Active filters: {self._filter_summary(filter_info)}")
1010
+
1011
+ if "brand" in df.columns:
1012
+ brands = sorted(str(b).title() for b in df["brand"].dropna().unique())
1013
+ self.pdf.body_text(f"Brands included: {', '.join(brands)}")
1014
+
1015
+ if "platform" in df.columns:
1016
+ platforms = sorted(str(p).title() for p in df["platform"].dropna().unique())
1017
+ self.pdf.body_text(f"Platforms included: {', '.join(platforms)}")
1018
+
1019
+ self.pdf.ln(5)
1020
+ self.pdf.callout_box(
1021
+ "Data source: Snowflake - SOCIAL_MEDIA_DB.ML_FEATURES.COMMENT_SENTIMENT_FEATURES "
1022
+ "and SOCIAL_MEDIA_DB.ML_FEATURES.MUSORA_COMMENT_SENTIMENT_FEATURES.\n"
1023
+ "This report is confidential and intended for internal Musora team use only.",
1024
+ bg_color=(245, 245, 245),
1025
+ )
visualization_brand_sentiment/app.py CHANGED
@@ -199,16 +199,30 @@ def main():
199
  default=[]
200
  )
201
 
202
- # Date range filter
203
  date_range = None
 
 
 
204
  if (not raw_df.empty
205
  and 'post_created_at' in raw_df.columns
206
  and not raw_df['post_created_at'].isna().all()):
207
- valid_dates = raw_df[raw_df['post_created_at'].notna()]['post_created_at']
208
- min_date = valid_dates.min().date()
209
- max_date = valid_dates.max().date()
210
-
211
- st.markdown("**Date Range (Forums)**")
 
 
 
 
 
 
 
 
 
 
 
212
  date_range = st.date_input(
213
  "Select dates",
214
  value=(min_date, max_date),
 
199
  default=[]
200
  )
201
 
202
+ # Date range filter — bounds from both posts AND comments
203
  date_range = None
204
+ all_min_dates = []
205
+ all_max_dates = []
206
+
207
  if (not raw_df.empty
208
  and 'post_created_at' in raw_df.columns
209
  and not raw_df['post_created_at'].isna().all()):
210
+ valid_posts = raw_df[raw_df['post_created_at'].notna()]['post_created_at']
211
+ all_min_dates.append(valid_posts.min().date())
212
+ all_max_dates.append(valid_posts.max().date())
213
+
214
+ if (not raw_comments_df.empty
215
+ and 'comment_timestamp' in raw_comments_df.columns
216
+ and not raw_comments_df['comment_timestamp'].isna().all()):
217
+ valid_comments = raw_comments_df[raw_comments_df['comment_timestamp'].notna()]['comment_timestamp']
218
+ all_min_dates.append(valid_comments.min().date())
219
+ all_max_dates.append(valid_comments.max().date())
220
+
221
+ if all_min_dates and all_max_dates:
222
+ min_date = min(all_min_dates)
223
+ max_date = max(all_max_dates)
224
+
225
+ st.markdown("**Date Range**")
226
  date_range = st.date_input(
227
  "Select dates",
228
  value=(min_date, max_date),