LivePulse / ml /report_generator.py
DivYonko
Fix PDF: strip emoji/unicode chars that Helvetica can't render
e7565ac
# ml/report_generator.py
"""
PDF report generator for LivePulse dashboard.
Generates a structured PDF containing both Comments and Stats views.
Uses fpdf2 β€” no system dependencies required.
"""
from __future__ import annotations
import io
import re
from datetime import datetime
from collections import Counter
from fpdf import FPDF
# ── Colour palette (matches dashboard theme) ──────────────────────────────────
_C_BG = (7, 7, 15)
_C_CARD = (15, 15, 30)
_C_ACCENT = (124, 58, 237)
_C_POS = (34, 197, 94)
_C_NEU = (234, 179, 8)
_C_NEG = (239, 68, 68)
_C_TEXT1 = (241, 245, 249)
_C_TEXT2 = (148, 163, 184)
_C_WHITE = (255, 255, 255)
_C_DIVIDER = (30, 30, 50)
TOPIC_COLORS = {
"Appreciation": (245, 158, 11),
"Question": ( 59, 130, 246),
"Request/Feedback":(139, 92, 246),
"Promo": (236, 72, 153),
"Spam": (239, 68, 68),
"General": (107, 114, 128),
"MCQ Answer": ( 16, 185, 129),
}
def _safe(text: str, max_len: int = 80) -> str:
"""Strip emoji and non-Latin-1 characters so Helvetica can render them."""
if not text:
return ""
# Remove emoji and symbols outside Latin-1
cleaned = re.sub(r'[^\x00-\xFF]', '', str(text))
# Collapse multiple spaces
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned[:max_len]
class LivePulsePDF(FPDF):
def __init__(self):
super().__init__(orientation="P", unit="mm", format="A4")
self.set_auto_page_break(auto=True, margin=15)
self.set_margins(15, 15, 15)
def header(self):
self.set_fill_color(*_C_BG)
self.rect(0, 0, 210, 20, "F")
self.set_font("Helvetica", "B", 11)
self.set_text_color(*_C_ACCENT)
self.set_y(6)
self.cell(0, 8, "LivePulse | YouTube Live Chat Analytics", align="L")
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT2)
self.cell(0, 8, f"Generated {datetime.now().strftime('%d %b %Y %H:%M')}", align="R")
self.ln(4)
def footer(self):
self.set_y(-12)
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT2)
self.cell(0, 8, f"Page {self.page_no()}", align="C")
def section_title(self, title: str, pill: str = "") -> None:
self.set_fill_color(*_C_CARD)
self.set_draw_color(*_C_ACCENT)
self.set_line_width(0.5)
self.rect(15, self.get_y(), 180, 9, "FD")
self.set_font("Helvetica", "B", 10)
self.set_text_color(*_C_TEXT1)
self.set_x(17)
self.cell(140, 9, _safe(title), ln=0)
if pill:
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_ACCENT)
self.cell(0, 9, _safe(pill), align="R")
self.ln(11)
def stat_box(self, label: str, value: str, color: tuple, x: float, y: float, w: float = 42, h: float = 18) -> None:
self.set_fill_color(*_C_CARD)
self.set_draw_color(*color)
self.set_line_width(0.4)
self.rect(x, y, w, h, "FD")
self.set_fill_color(*color)
self.rect(x, y, w, 1.5, "F")
self.set_font("Helvetica", "B", 14)
self.set_text_color(*color)
self.set_xy(x, y + 3)
self.cell(w, 7, _safe(value, 12), align="C")
self.set_font("Helvetica", "", 7)
self.set_text_color(*_C_TEXT2)
self.set_xy(x, y + 10)
self.cell(w, 5, _safe(label.upper(), 20), align="C")
def h_bar(self, label: str, value: int, max_val: int, color: tuple, bar_w: float = 100) -> None:
y = self.get_y()
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT1)
self.set_x(17)
self.cell(55, 6, _safe(label, 35), ln=0)
self.set_fill_color(*_C_DIVIDER)
self.rect(73, y + 1, bar_w, 4, "F")
fill_w = (value / max(max_val, 1)) * bar_w
self.set_fill_color(*color)
self.rect(73, y + 1, fill_w, 4, "F")
self.set_font("Helvetica", "B", 8)
self.set_text_color(*_C_TEXT2)
self.set_xy(175, y)
self.cell(20, 6, str(value), align="R")
self.ln(7)
def table_header(self, cols: list[tuple[str, float]]) -> None:
self.set_fill_color(*_C_CARD)
self.set_font("Helvetica", "B", 8)
self.set_text_color(*_C_ACCENT)
for label, w in cols:
self.cell(w, 7, _safe(label), border=0, fill=True, align="L")
self.ln(7)
self.set_draw_color(*_C_ACCENT)
self.set_line_width(0.3)
self.line(15, self.get_y(), 195, self.get_y())
self.ln(1)
def table_row(self, values: list[tuple[str, float]], alt: bool = False) -> None:
if alt:
self.set_fill_color(20, 20, 35)
else:
self.set_fill_color(*_C_BG)
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT1)
for val, w in values:
self.cell(w, 6, _safe(str(val), 40), border=0, fill=True, align="L")
self.ln(6)
def generate_report(
all_data: list[dict],
stream_title: str = "LivePulse Stream",
msg_limit: int = 100,
) -> bytes:
pdf = LivePulsePDF()
pdf.add_page()
# Cover
pdf.set_fill_color(*_C_BG)
pdf.rect(0, 20, 210, 40, "F")
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*_C_TEXT1)
pdf.set_y(28)
pdf.cell(0, 10, "Dashboard Report", align="C", ln=True)
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(*_C_ACCENT)
pdf.cell(0, 8, _safe(stream_title, 80), align="C", ln=True)
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_C_TEXT2)
pdf.cell(0, 6, f"Total messages analysed: {len(all_data)}", align="C", ln=True)
pdf.ln(8)
if not all_data:
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(*_C_NEG)
pdf.cell(0, 10, "No data available.", align="C")
return bytes(pdf.output())
# Pre-compute
sentiments = [m.get("sentiment", "Neutral") for m in all_data]
topics = [m.get("topic", "General") for m in all_data]
action_types = [m.get("action_type", "N/A") for m in all_data]
authors = [m.get("author", "Unknown") for m in all_data]
c_pos = sentiments.count("Positive")
c_neu = sentiments.count("Neutral")
c_neg = sentiments.count("Negative")
c_total = max(len(all_data), 1)
topic_counts = Counter(topics)
action_counts = Counter(a for a in action_types if a not in ("N/A", "", None))
author_counts = Counter(authors)
try:
from datetime import datetime as _dt
recent = all_data[-50:]
n = len(recent)
t0 = _dt.fromisoformat(recent[0]["time"])
t1 = _dt.fromisoformat(recent[-1]["time"])
elapsed = max((t1 - t0).total_seconds() / 60, 0.1)
rate = round(n / elapsed, 1)
pos_ratio = sum(1 for m in recent if m.get("sentiment") == "Positive") / max(n, 1)
q_density = sum(1 for m in recent if m.get("topic") == "Question") / max(n, 1)
rate_norm = min(rate / 60, 1.0)
eng_score = round((rate_norm * 0.4 + pos_ratio * 0.4 + q_density * 0.2) * 100)
except Exception:
eng_score = 0; rate = 0.0; pos_ratio = 0.0; q_density = 0.0
# Section 1: Engagement Summary
pdf.section_title("Engagement Summary", "Live")
y0 = pdf.get_y()
pdf.stat_box("Engagement", str(eng_score), _C_ACCENT, 15, y0)
pdf.stat_box("Positive", f"{c_pos} ({c_pos/c_total*100:.0f}%)", _C_POS, 59, y0)
pdf.stat_box("Neutral", f"{c_neu} ({c_neu/c_total*100:.0f}%)", _C_NEU, 103, y0)
pdf.stat_box("Negative", f"{c_neg} ({c_neg/c_total*100:.0f}%)", _C_NEG, 147, y0)
pdf.set_y(y0 + 22)
y1 = pdf.get_y()
pdf.stat_box("Total Msgs", str(c_total), _C_TEXT2, 15, y1)
pdf.stat_box("Msgs/min", f"{rate:.1f}", _C_ACCENT, 59, y1)
pdf.stat_box("Pos ratio", f"{pos_ratio*100:.0f}%",_C_POS, 103, y1)
pdf.stat_box("Q density", f"{q_density*100:.0f}%",_C_NEU, 147, y1)
pdf.set_y(y1 + 22)
pdf.ln(4)
# Section 2: Topic Distribution
pdf.section_title("Topic Distribution", "All Time")
max_topic = max(topic_counts.values(), default=1)
for topic in ["Appreciation", "Question", "Request/Feedback", "Promo", "Spam", "General", "MCQ Answer"]:
pdf.h_bar(topic, topic_counts.get(topic, 0), max_topic, TOPIC_COLORS.get(topic, _C_TEXT2))
pdf.ln(4)
# Section 3: Action Types
if action_counts:
pdf.section_title("Top Action Types", "Questions & Requests")
max_action = max(action_counts.values(), default=1)
for action, count in action_counts.most_common(15):
pdf.h_bar(action[:40], count, max_action, _C_ACCENT)
pdf.ln(4)
# Section 4: Top Contributors
pdf.section_title("Top Contributors", "All Time")
cols = [("Author", 60), ("Messages", 25), ("Positive%", 30), ("Neutral%", 30), ("Negative%", 30)]
pdf.table_header(cols)
for i, (author, count) in enumerate(author_counts.most_common(15)):
author_msgs = [m for m in all_data if m.get("author") == author]
total_a = max(len(author_msgs), 1)
pos_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Positive") / total_a * 100)
neu_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Neutral") / total_a * 100)
neg_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Negative") / total_a * 100)
pdf.table_row([
(_safe(author, 28), 60), (str(count), 25),
(f"{pos_p}%", 30), (f"{neu_p}%", 30), (f"{neg_p}%", 30),
], alt=(i % 2 == 1))
pdf.ln(4)
# Section 5: Recent Comments
pdf.add_page()
recent_msgs = all_data[-msg_limit:]
pdf.section_title("Recent Comments", f"Last {len(recent_msgs)} messages")
cols_c = [("Author", 40), ("Message", 90), ("Sentiment", 22), ("Topic", 28)]
pdf.table_header(cols_c)
sent_colors = {"Positive": _C_POS, "Negative": _C_NEG, "Neutral": _C_NEU}
for i, msg in enumerate(reversed(recent_msgs)):
author = _safe(msg.get("author", ""), 18)
text = _safe(msg.get("text", ""), 55)
sent = msg.get("sentiment", "Neutral")
topic = _safe(msg.get("topic", "General"), 14)
alt = (i % 2 == 1)
pdf.set_fill_color(20, 20, 35) if alt else pdf.set_fill_color(*_C_BG)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*_C_TEXT1)
pdf.cell(40, 5.5, author, border=0, fill=True)
pdf.cell(90, 5.5, text, border=0, fill=True)
pdf.set_text_color(*sent_colors.get(sent, _C_TEXT2))
pdf.cell(22, 5.5, sent, border=0, fill=True)
pdf.set_text_color(*TOPIC_COLORS.get(topic, _C_TEXT2))
pdf.cell(28, 5.5, topic, border=0, fill=True)
pdf.ln(5.5)
# Section 6: Questions Log
questions = [m for m in all_data if m.get("topic") == "Question"]
if questions:
pdf.add_page()
pdf.section_title("Questions Asked", f"{len(questions)} total")
cols_q = [("Author", 40), ("Question", 115), ("Action Type", 40)]
pdf.table_header(cols_q)
for i, msg in enumerate(reversed(questions[-100:])):
author = _safe(msg.get("author", ""), 18)
text = _safe(msg.get("text", ""), 65)
action = _safe(msg.get("action_type", "N/A"), 22)
alt = (i % 2 == 1)
pdf.set_fill_color(20, 20, 35) if alt else pdf.set_fill_color(*_C_BG)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*_C_TEXT1)
pdf.cell(40, 5.5, author, border=0, fill=True)
pdf.cell(115, 5.5, text, border=0, fill=True)
pdf.set_text_color(*_C_ACCENT)
pdf.cell(40, 5.5, action, border=0, fill=True)
pdf.ln(5.5)
return bytes(pdf.output())
# ── Colour palette (matches dashboard theme) ──────────────────────────────────
_C_BG = (7, 7, 15) # dark background
_C_CARD = (15, 15, 30)
_C_ACCENT = (124, 58, 237) # purple
_C_POS = (34, 197, 94) # green
_C_NEU = (234, 179, 8) # yellow
_C_NEG = (239, 68, 68) # red
_C_TEXT1 = (241, 245, 249) # light text
_C_TEXT2 = (148, 163, 184) # muted text
_C_WHITE = (255, 255, 255)
_C_DIVIDER = (30, 30, 50)
TOPIC_COLORS = {
"Appreciation": (245, 158, 11),
"Question": ( 59, 130, 246),
"Request/Feedback":(139, 92, 246),
"Promo": (236, 72, 153),
"Spam": (239, 68, 68),
"General": (107, 114, 128),
"MCQ Answer": ( 16, 185, 129),
}
class LivePulsePDF(FPDF):
"""Custom FPDF subclass with LivePulse branding."""
def __init__(self):
super().__init__(orientation="P", unit="mm", format="A4")
self.set_auto_page_break(auto=True, margin=15)
self.set_margins(15, 15, 15)
def header(self):
self.set_fill_color(*_C_BG)
self.rect(0, 0, 210, 20, "F")
self.set_font("Helvetica", "B", 11)
self.set_text_color(*_C_ACCENT)
self.set_y(6)
self.cell(0, 8, "LivePulse | YouTube Live Chat Analytics", align="L")
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT2)
self.cell(0, 8, f"Generated {datetime.now().strftime('%d %b %Y %H:%M')}", align="R")
self.ln(4)
def footer(self):
self.set_y(-12)
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT2)
self.cell(0, 8, f"Page {self.page_no()}", align="C")
# ── Helpers ───────────────────────────────────────────────────────────────
def section_title(self, title: str, pill: str = "") -> None:
self.set_fill_color(*_C_CARD)
self.set_draw_color(*_C_ACCENT)
self.set_line_width(0.5)
self.rect(15, self.get_y(), 180, 9, "FD")
self.set_font("Helvetica", "B", 10)
self.set_text_color(*_C_TEXT1)
self.set_x(17)
self.cell(140, 9, title, ln=0)
if pill:
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_ACCENT)
self.cell(0, 9, pill, align="R")
self.ln(11)
def stat_box(self, label: str, value: str, color: tuple, x: float, y: float, w: float = 42, h: float = 18) -> None:
self.set_fill_color(*_C_CARD)
self.set_draw_color(*color)
self.set_line_width(0.4)
self.rect(x, y, w, h, "FD")
# top accent bar
self.set_fill_color(*color)
self.rect(x, y, w, 1.5, "F")
self.set_font("Helvetica", "B", 14)
self.set_text_color(*color)
self.set_xy(x, y + 3)
self.cell(w, 7, value, align="C")
self.set_font("Helvetica", "", 7)
self.set_text_color(*_C_TEXT2)
self.set_xy(x, y + 10)
self.cell(w, 5, label.upper(), align="C")
def h_bar(self, label: str, value: int, max_val: int, color: tuple, bar_w: float = 100) -> None:
y = self.get_y()
# label
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT1)
self.set_x(17)
self.cell(55, 6, label[:35], ln=0)
# bar background
self.set_fill_color(*_C_DIVIDER)
self.rect(73, y + 1, bar_w, 4, "F")
# bar fill
fill_w = (value / max(max_val, 1)) * bar_w
self.set_fill_color(*color)
self.rect(73, y + 1, fill_w, 4, "F")
# value
self.set_font("Helvetica", "B", 8)
self.set_text_color(*_C_TEXT2)
self.set_xy(175, y)
self.cell(20, 6, str(value), align="R")
self.ln(7)
def table_header(self, cols: list[tuple[str, float]]) -> None:
self.set_fill_color(*_C_CARD)
self.set_font("Helvetica", "B", 8)
self.set_text_color(*_C_ACCENT)
for label, w in cols:
self.cell(w, 7, label, border=0, fill=True, align="L")
self.ln(7)
# divider line
self.set_draw_color(*_C_ACCENT)
self.set_line_width(0.3)
self.line(15, self.get_y(), 195, self.get_y())
self.ln(1)
def table_row(self, values: list[tuple[str, float]], alt: bool = False) -> None:
if alt:
self.set_fill_color(20, 20, 35)
else:
self.set_fill_color(*_C_BG)
self.set_font("Helvetica", "", 8)
self.set_text_color(*_C_TEXT1)
for val, w in values:
self.cell(w, 6, str(val)[:40], border=0, fill=True, align="L")
self.ln(6)
# ── Public API ─────────────────────────────────────────────────────────────────
def generate_report(
all_data: list[dict],
stream_title: str = "LivePulse Stream",
msg_limit: int = 100,
) -> bytes:
"""
Generate a PDF report from the full message history.
Parameters
----------
all_data : list of message dicts from the SQLite store
stream_title: video title shown in the report header
msg_limit : max recent messages to include in the comments table
Returns
-------
bytes β€” PDF file content ready for st.download_button
"""
pdf = LivePulsePDF()
pdf.add_page()
# ── Cover / title ─────────────────────────────────────────────────────────
pdf.set_fill_color(*_C_BG)
pdf.rect(0, 20, 210, 40, "F")
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*_C_TEXT1)
pdf.set_y(28)
pdf.cell(0, 10, "Dashboard Report", align="C", ln=True)
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(*_C_ACCENT)
pdf.cell(0, 8, stream_title[:80], align="C", ln=True)
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_C_TEXT2)
pdf.cell(0, 6, f"Total messages analysed: {len(all_data)}", align="C", ln=True)
pdf.ln(8)
if not all_data:
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(*_C_NEG)
pdf.cell(0, 10, "No data available.", align="C")
return bytes(pdf.output())
# ── Pre-compute stats ─────────────────────────────────────────────────────
sentiments = [m.get("sentiment", "Neutral") for m in all_data]
topics = [m.get("topic", "General") for m in all_data]
action_types= [m.get("action_type", "N/A") for m in all_data]
authors = [m.get("author", "Unknown") for m in all_data]
c_pos = sentiments.count("Positive")
c_neu = sentiments.count("Neutral")
c_neg = sentiments.count("Negative")
c_total = max(len(all_data), 1)
topic_counts = Counter(topics)
action_counts = Counter(a for a in action_types if a not in ("N/A", "", None))
author_counts = Counter(authors)
# Engagement score
try:
from datetime import datetime as _dt
recent = all_data[-50:]
n = len(recent)
t0 = _dt.fromisoformat(recent[0]["time"])
t1 = _dt.fromisoformat(recent[-1]["time"])
elapsed = max((t1 - t0).total_seconds() / 60, 0.1)
rate = round(n / elapsed, 1)
pos_ratio = sum(1 for m in recent if m.get("sentiment") == "Positive") / max(n, 1)
q_density = sum(1 for m in recent if m.get("topic") == "Question") / max(n, 1)
rate_norm = min(rate / 60, 1.0)
eng_score = round((rate_norm * 0.4 + pos_ratio * 0.4 + q_density * 0.2) * 100)
except Exception:
eng_score = 0
rate = 0.0
pos_ratio = 0.0
q_density = 0.0
# ── SECTION 1: Engagement Summary ─────────────────────────────────────────
pdf.section_title("Engagement Summary", "Live")
y_boxes = pdf.get_y()
pdf.stat_box("Engagement", str(eng_score), _C_ACCENT, 15, y_boxes)
pdf.stat_box("Positive", f"{c_pos} ({c_pos/c_total*100:.0f}%)", _C_POS, 59, y_boxes)
pdf.stat_box("Neutral", f"{c_neu} ({c_neu/c_total*100:.0f}%)", _C_NEU, 103, y_boxes)
pdf.stat_box("Negative", f"{c_neg} ({c_neg/c_total*100:.0f}%)", _C_NEG, 147, y_boxes)
pdf.set_y(y_boxes + 22)
y_boxes2 = pdf.get_y()
pdf.stat_box("Total Msgs", str(c_total), _C_TEXT2, 15, y_boxes2)
pdf.stat_box("Msgs/min", f"{rate:.1f}", _C_ACCENT, 59, y_boxes2)
pdf.stat_box("Pos ratio", f"{pos_ratio*100:.0f}%", _C_POS, 103, y_boxes2)
pdf.stat_box("Q density", f"{q_density*100:.0f}%", _C_NEU, 147, y_boxes2)
pdf.set_y(y_boxes2 + 22)
pdf.ln(4)
# ── SECTION 2: Topic Distribution ─────────────────────────────────────────
pdf.section_title("Topic Distribution", "All Time")
max_topic = max(topic_counts.values(), default=1)
for topic in ["Appreciation", "Question", "Request/Feedback", "Promo", "Spam", "General", "MCQ Answer"]:
count = topic_counts.get(topic, 0)
color = TOPIC_COLORS.get(topic, _C_TEXT2)
pdf.h_bar(topic, count, max_topic, color)
pdf.ln(4)
# ── SECTION 3: Action Type Breakdown ──────────────────────────────────────
if action_counts:
pdf.section_title("Top Action Types", "Questions & Requests")
max_action = max(action_counts.values(), default=1)
for action, count in action_counts.most_common(15):
pdf.h_bar(action[:40], count, max_action, _C_ACCENT)
pdf.ln(4)
# ── SECTION 4: Top Contributors ───────────────────────────────────────────
pdf.section_title("Top Contributors", "All Time")
cols = [("Author", 60), ("Messages", 25), ("Positive%", 30), ("Neutral%", 30), ("Negative%", 30)]
pdf.table_header(cols)
for i, (author, count) in enumerate(author_counts.most_common(15)):
author_msgs = [m for m in all_data if m.get("author") == author]
total_a = max(len(author_msgs), 1)
pos_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Positive") / total_a * 100)
neu_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Neutral") / total_a * 100)
neg_p = round(sum(1 for m in author_msgs if m.get("sentiment") == "Negative") / total_a * 100)
pdf.table_row([
(author[:28], 60),
(str(count), 25),
(f"{pos_p}%", 30),
(f"{neu_p}%", 30),
(f"{neg_p}%", 30),
], alt=(i % 2 == 1))
pdf.ln(4)
# ── SECTION 5: Recent Comments ────────────────────────────────────────────
pdf.add_page()
recent_msgs = all_data[-msg_limit:]
pdf.section_title("Recent Comments", f"Last {len(recent_msgs)} messages")
cols_c = [("Author", 40), ("Message", 90), ("Sentiment", 22), ("Topic", 28)]
pdf.table_header(cols_c)
sent_colors = {"Positive": _C_POS, "Negative": _C_NEG, "Neutral": _C_NEU}
for i, msg in enumerate(reversed(recent_msgs)):
author = (msg.get("author", "") or "")[:18]
text = (msg.get("text", "") or "")[:55]
sent = msg.get("sentiment", "Neutral")
topic = (msg.get("topic", "General") or "General")[:14]
alt = (i % 2 == 1)
if alt:
pdf.set_fill_color(20, 20, 35)
else:
pdf.set_fill_color(*_C_BG)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*_C_TEXT1)
pdf.cell(40, 5.5, author, border=0, fill=True)
pdf.cell(90, 5.5, text, border=0, fill=True)
# Sentiment with colour
pdf.set_text_color(*sent_colors.get(sent, _C_TEXT2))
pdf.cell(22, 5.5, sent, border=0, fill=True)
# Topic with colour
t_color = TOPIC_COLORS.get(topic, _C_TEXT2)
pdf.set_text_color(*t_color)
pdf.cell(28, 5.5, topic, border=0, fill=True)
pdf.ln(5.5)
# ── SECTION 6: Questions Log ──────────────────────────────────────────────
questions = [m for m in all_data if m.get("topic") == "Question"]
if questions:
pdf.add_page()
pdf.section_title("Questions Asked", f"{len(questions)} total")
cols_q = [("Author", 40), ("Question", 115), ("Action Type", 40)]
pdf.table_header(cols_q)
for i, msg in enumerate(reversed(questions[-100:])):
author = (msg.get("author", "") or "")[:18]
text = (msg.get("text", "") or "")[:65]
action = (msg.get("action_type", "N/A") or "N/A")[:22]
alt = (i % 2 == 1)
pdf.set_fill_color(20, 20, 35) if alt else pdf.set_fill_color(*_C_BG)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*_C_TEXT1)
pdf.cell(40, 5.5, author, border=0, fill=True)
pdf.cell(115, 5.5, text, border=0, fill=True)
pdf.set_text_color(*_C_ACCENT)
pdf.cell(40, 5.5, action, border=0, fill=True)
pdf.ln(5.5)
return bytes(pdf.output())