feat(report): PDF export with Cyrillic, i18n, key decisions + analytics
- PDF via fpdf2 (DejaVu font from fonts-dejavu-core, Cyrillic support) - Lang param (?lang=ru/en) passed from i18n.language in ThemesPanel - Prompts updated: executive summary + new key-decisions prompt, both write entirely in the requested language - Analytics section: participation bar chart per participant - Key themes with quotes rendered as side-bordered blocks - Filename: ASCII slug for Content-Disposition + UTF-8 filename* fallback - api.ts: handles PDF blob, decodes UTF-8 filename*, 120s timeout - AnalyticsPanel, ThemeHighlighter: light-mode colors → dark tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a419eeee1
commit
6d2f59fe74
9 changed files with 333 additions and 174 deletions
|
|
@ -5,6 +5,7 @@ WORKDIR /app
|
|||
# Install system deps for bcrypt, pymongo, etc.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies first (layer caching)
|
||||
|
|
|
|||
|
|
@ -1093,9 +1093,48 @@ async def download_discussion_guide(focus_group_id):
|
|||
@focus_groups_bp.route('/<focus_group_id>/report/download', methods=['GET'])
|
||||
@jwt_required()
|
||||
async def download_full_report(focus_group_id):
|
||||
"""Generate and download a full research report as markdown."""
|
||||
"""Generate and download a full research report as PDF."""
|
||||
from app.services.llm_service import LLMService, LLMServiceError
|
||||
from app.utils.prompt_loader import load_prompt # noqa: PLC0415
|
||||
from app.utils.prompt_loader import load_prompt
|
||||
import io
|
||||
from fpdf import FPDF
|
||||
|
||||
lang = request.args.get("lang", "ru")
|
||||
|
||||
LABELS = {
|
||||
"ru": {
|
||||
"title": "Отчёт по фокус-группе",
|
||||
"date": "Дата",
|
||||
"participants": "Участники",
|
||||
"messages": "Сообщений обменяно",
|
||||
"brief": "Бриф исследования",
|
||||
"exec_summary": "Резюме",
|
||||
"key_decisions": "Ключевые выводы и решения",
|
||||
"key_themes": "Ключевые темы",
|
||||
"quotes": "Опорные цитаты",
|
||||
"participation": "Аналитика участия",
|
||||
"transcript": "Полная стенограмма",
|
||||
"no_themes": "Темы ещё не сгенерированы.",
|
||||
"language_name": "русском",
|
||||
},
|
||||
"en": {
|
||||
"title": "Focus Group Research Report",
|
||||
"date": "Date",
|
||||
"participants": "Participants",
|
||||
"messages": "Messages exchanged",
|
||||
"brief": "Research Brief",
|
||||
"exec_summary": "Executive Summary",
|
||||
"key_decisions": "Key Decisions & Conclusions",
|
||||
"key_themes": "Key Themes",
|
||||
"quotes": "Supporting Quotes",
|
||||
"participation": "Participation Analytics",
|
||||
"transcript": "Full Transcript",
|
||||
"no_themes": "No themes have been generated yet.",
|
||||
"language_name": "English",
|
||||
},
|
||||
}
|
||||
L = LABELS.get(lang, LABELS["en"])
|
||||
language_name = {"ru": "Russian", "en": "English"}.get(lang, "English")
|
||||
|
||||
try:
|
||||
user_id = get_jwt_identity()
|
||||
|
|
@ -1117,103 +1156,226 @@ async def download_full_report(focus_group_id):
|
|||
p = await Persona.find_by_id(pid)
|
||||
if p:
|
||||
personas.append(p)
|
||||
participant_names = ", ".join(p.get("name", "Unknown") for p in personas) or "No participants"
|
||||
participant_names = ", ".join(p.get("name", "Unknown") for p in personas) or "—"
|
||||
|
||||
# Messages
|
||||
messages = await FocusGroup.get_messages(focus_group_id)
|
||||
# Participation stats
|
||||
messages_raw = await FocusGroup.get_messages(focus_group_id)
|
||||
message_counts = {}
|
||||
chat_lines = []
|
||||
for m in messages:
|
||||
sender = m.get("senderName") or m.get("senderId", "Unknown")
|
||||
for m in messages_raw:
|
||||
sender_id = m.get("senderId", "")
|
||||
sender_name = m.get("senderName") or sender_id
|
||||
text = m.get("text", "").strip()
|
||||
if text:
|
||||
chat_lines.append(f"**{sender}:** {text}")
|
||||
transcript_excerpt = "\n\n".join(chat_lines[:30]) or "No messages recorded."
|
||||
full_transcript = "\n\n".join(chat_lines) or "No messages recorded."
|
||||
if not text:
|
||||
continue
|
||||
# Find persona name
|
||||
for p in personas:
|
||||
if str(p.get("_id", "")) == sender_id or str(p.get("id", "")) == sender_id:
|
||||
sender_name = p.get("name", sender_name)
|
||||
break
|
||||
chat_lines.append(f"{sender_name}: {text}")
|
||||
if sender_id not in ("moderator", "facilitator"):
|
||||
message_counts[sender_name] = message_counts.get(sender_name, 0) + 1
|
||||
|
||||
transcript_excerpt = "\n\n".join(chat_lines[:30]) or "—"
|
||||
|
||||
# Themes
|
||||
themes = await FocusGroup.get_generated_themes(focus_group_id)
|
||||
generated_themes = [t for t in themes if t.get("source") == "generated"]
|
||||
themes_summary_lines = []
|
||||
for t in generated_themes:
|
||||
themes_summary_lines.append(f"**{t.get('title', 'Theme')}:** {t.get('description', '')}")
|
||||
themes_summary = "\n".join(themes_summary_lines) or "No themes generated yet."
|
||||
themes_summary_lines.append(f"{t.get('title', '')}: {t.get('description', '')}")
|
||||
themes_summary = "\n".join(themes_summary_lines) or "—"
|
||||
|
||||
# Executive summary via LLM
|
||||
prompt = load_prompt("report-executive-summary", variables={
|
||||
"topic": fg_topic,
|
||||
"participant_count": len(personas),
|
||||
"participant_names": participant_names,
|
||||
"message_count": len(chat_lines),
|
||||
"themes_summary": themes_summary,
|
||||
"transcript_excerpt": transcript_excerpt,
|
||||
})
|
||||
# LLM: executive summary
|
||||
try:
|
||||
executive_summary = await LLMService.generate_content(prompt, model_name="mini")
|
||||
exec_prompt = load_prompt("report-executive-summary", variables={
|
||||
"topic": fg_topic,
|
||||
"participant_count": len(personas),
|
||||
"participant_names": participant_names,
|
||||
"message_count": len(chat_lines),
|
||||
"themes_summary": themes_summary,
|
||||
"transcript_excerpt": transcript_excerpt,
|
||||
"language": language_name,
|
||||
})
|
||||
executive_summary = await LLMService.generate_content(exec_prompt, model_name="mini")
|
||||
except LLMServiceError:
|
||||
executive_summary = "Executive summary unavailable — LLM service error."
|
||||
executive_summary = "—"
|
||||
|
||||
# Build markdown report
|
||||
sanitized = "".join(c for c in fg_name if c.isalnum() or c in " -_").strip().replace(" ", "-").lower()
|
||||
report_lines = [
|
||||
f"# Research Report: {fg_name}",
|
||||
f"",
|
||||
f"**Date:** {date_str} ",
|
||||
f"**Participants:** {participant_names} ",
|
||||
f"**Messages exchanged:** {len(chat_lines)} ",
|
||||
f"",
|
||||
f"---",
|
||||
f"",
|
||||
f"## Executive Summary",
|
||||
f"",
|
||||
executive_summary,
|
||||
f"",
|
||||
f"---",
|
||||
f"",
|
||||
f"## Key Themes",
|
||||
f"",
|
||||
]
|
||||
# LLM: key decisions
|
||||
try:
|
||||
dec_prompt = load_prompt("report-key-decisions", variables={
|
||||
"topic": fg_topic,
|
||||
"participant_count": len(personas),
|
||||
"participant_names": participant_names,
|
||||
"research_brief": fg_topic,
|
||||
"themes_summary": themes_summary,
|
||||
"transcript_excerpt": transcript_excerpt,
|
||||
"language": language_name,
|
||||
})
|
||||
key_decisions = await LLMService.generate_content(dec_prompt, model_name="mini")
|
||||
except LLMServiceError:
|
||||
key_decisions = "—"
|
||||
|
||||
# ── Build PDF ──────────────────────────────────────────────────────────
|
||||
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
|
||||
FONT_REGULAR = f"{FONT_DIR}/DejaVuSans.ttf"
|
||||
FONT_BOLD = f"{FONT_DIR}/DejaVuSans-Bold.ttf"
|
||||
|
||||
pdf = FPDF()
|
||||
pdf.set_auto_page_break(auto=True, margin=20)
|
||||
pdf.add_font("R", "", FONT_REGULAR)
|
||||
pdf.add_font("R", "B", FONT_BOLD)
|
||||
|
||||
BRAND_ORANGE = (222, 143, 63)
|
||||
DARK = (30, 30, 40)
|
||||
MUTED = (110, 110, 130)
|
||||
WHITE = (255, 255, 255)
|
||||
LIGHT_BG = (245, 245, 250)
|
||||
|
||||
def set_r(size=10, bold=False):
|
||||
pdf.set_font("R", "B" if bold else "", size)
|
||||
|
||||
def h1(text):
|
||||
pdf.ln(4)
|
||||
set_r(16, bold=True)
|
||||
pdf.set_text_color(*BRAND_ORANGE)
|
||||
pdf.multi_cell(0, 8, text)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.ln(2)
|
||||
|
||||
def h2(text):
|
||||
pdf.ln(6)
|
||||
set_r(12, bold=True)
|
||||
pdf.set_text_color(*BRAND_ORANGE)
|
||||
pdf.multi_cell(0, 7, text)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.set_draw_color(*BRAND_ORANGE)
|
||||
pdf.set_line_width(0.3)
|
||||
pdf.line(pdf.get_x(), pdf.get_y(), pdf.get_x() + 170, pdf.get_y())
|
||||
pdf.ln(3)
|
||||
|
||||
def body(text, color=None):
|
||||
set_r(10)
|
||||
pdf.set_text_color(*(color or DARK))
|
||||
pdf.multi_cell(0, 5.5, text)
|
||||
pdf.set_text_color(*DARK)
|
||||
|
||||
def meta_row(label, value):
|
||||
set_r(9, bold=True)
|
||||
pdf.set_text_color(*MUTED)
|
||||
pdf.cell(40, 6, label + ":", ln=0)
|
||||
set_r(9)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.multi_cell(0, 6, value)
|
||||
|
||||
def quote_block(text):
|
||||
pdf.set_fill_color(*LIGHT_BG)
|
||||
pdf.set_draw_color(*BRAND_ORANGE)
|
||||
pdf.set_line_width(0.5)
|
||||
x = pdf.get_x()
|
||||
y = pdf.get_y()
|
||||
set_r(9)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.set_x(x + 4)
|
||||
pdf.multi_cell(0, 5, text, fill=False)
|
||||
line_h = pdf.get_y() - y
|
||||
pdf.line(x, y, x, y + line_h)
|
||||
pdf.ln(1)
|
||||
|
||||
# Cover page
|
||||
pdf.add_page()
|
||||
pdf.set_fill_color(*DARK)
|
||||
pdf.rect(0, 0, 210, 60, "F")
|
||||
pdf.set_y(15)
|
||||
set_r(20, bold=True)
|
||||
pdf.set_text_color(*BRAND_ORANGE)
|
||||
pdf.cell(0, 12, L["title"], ln=True, align="C")
|
||||
set_r(13)
|
||||
pdf.set_text_color(*WHITE)
|
||||
pdf.cell(0, 8, fg_name, ln=True, align="C")
|
||||
pdf.set_text_color(*DARK)
|
||||
|
||||
pdf.set_y(70)
|
||||
meta_row(L["date"], date_str)
|
||||
meta_row(L["participants"], participant_names)
|
||||
meta_row(L["messages"], str(len(chat_lines)))
|
||||
if fg_topic and fg_topic != "Not specified":
|
||||
pdf.ln(2)
|
||||
h2(L["brief"])
|
||||
body(fg_topic)
|
||||
|
||||
# Executive summary
|
||||
h2(L["exec_summary"])
|
||||
body(executive_summary)
|
||||
|
||||
# Key decisions
|
||||
h2(L["key_decisions"])
|
||||
body(key_decisions)
|
||||
|
||||
# Key themes
|
||||
h2(L["key_themes"])
|
||||
if generated_themes:
|
||||
for i, t in enumerate(generated_themes, 1):
|
||||
report_lines.append(f"### {i}. {t.get('title', 'Theme')}")
|
||||
report_lines.append(f"")
|
||||
report_lines.append(t.get("description", ""))
|
||||
report_lines.append(f"")
|
||||
pdf.ln(3)
|
||||
set_r(11, bold=True)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.multi_cell(0, 6, f"{i}. {t.get('title', '')}")
|
||||
body(t.get("description", ""), color=MUTED)
|
||||
quotes = t.get("quotes", [])
|
||||
if quotes:
|
||||
report_lines.append("**Supporting Quotes:**")
|
||||
report_lines.append("")
|
||||
for q in quotes:
|
||||
set_r(9, bold=True)
|
||||
pdf.set_text_color(*MUTED)
|
||||
pdf.cell(0, 5, L["quotes"] + ":", ln=True)
|
||||
pdf.set_text_color(*DARK)
|
||||
for q in quotes[:5]:
|
||||
if isinstance(q, str):
|
||||
report_lines.append(f"> {q}")
|
||||
qtext = q
|
||||
else:
|
||||
speaker = q.get("speaker", "")
|
||||
text = q.get("text", "")
|
||||
report_lines.append(f"> **{speaker}:** {text}" if speaker else f"> {text}")
|
||||
report_lines.append("")
|
||||
report_lines.append("---")
|
||||
report_lines.append("")
|
||||
sp = q.get("speaker", "")
|
||||
qtext = f"{sp}: {q.get('text', '')}" if sp else q.get("text", "")
|
||||
quote_block(qtext)
|
||||
else:
|
||||
report_lines.append("*No themes have been generated for this session yet.*")
|
||||
report_lines.append("")
|
||||
report_lines.append("---")
|
||||
report_lines.append("")
|
||||
body(L["no_themes"], color=MUTED)
|
||||
|
||||
report_lines += [
|
||||
f"## Full Transcript",
|
||||
f"",
|
||||
full_transcript,
|
||||
f"",
|
||||
]
|
||||
# Participation analytics
|
||||
if message_counts:
|
||||
h2(L["participation"])
|
||||
total = sum(message_counts.values()) or 1
|
||||
for name, count in sorted(message_counts.items(), key=lambda x: -x[1]):
|
||||
pct = int(count / total * 100)
|
||||
bar_w = int(pct * 1.2)
|
||||
set_r(9)
|
||||
pdf.set_text_color(*DARK)
|
||||
pdf.cell(50, 5, name, ln=0)
|
||||
pdf.set_fill_color(*BRAND_ORANGE)
|
||||
y_bar = pdf.get_y() + 1
|
||||
pdf.rect(pdf.get_x(), y_bar, bar_w, 3, "F")
|
||||
pdf.set_x(pdf.get_x() + bar_w + 2)
|
||||
pdf.cell(0, 5, f"{count} ({pct}%)", ln=True)
|
||||
pdf.ln(2)
|
||||
|
||||
content = "\n".join(report_lines)
|
||||
filename = f"report-{sanitized}-{date_str}.md"
|
||||
# Filename: ASCII-only slug
|
||||
import unicodedata
|
||||
import re
|
||||
nfkd = unicodedata.normalize("NFKD", fg_name)
|
||||
ascii_name = nfkd.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_name.lower()).strip("-") or "report"
|
||||
filename = f"report-{slug}-{date_str}.pdf"
|
||||
|
||||
buf = io.BytesIO()
|
||||
pdf.output(buf)
|
||||
buf.seek(0)
|
||||
|
||||
from urllib.parse import quote as url_quote
|
||||
safe_filename = url_quote(f"report-{fg_name}-{date_str}.pdf".encode("utf-8"))
|
||||
|
||||
return Response(
|
||||
content,
|
||||
mimetype="text/markdown",
|
||||
buf.read(),
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{safe_filename}",
|
||||
"Content-Type": "application/pdf",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
You are a qualitative research analyst. Write a concise executive summary for a focus group research report.
|
||||
|
||||
Respond entirely in {language}. Do not mix languages.
|
||||
|
||||
## Research Session
|
||||
**Topic:** {topic}
|
||||
**Participants:** {participant_count} AI personas — {participant_names}
|
||||
|
|
|
|||
25
backend/prompts/report-key-decisions.md
Normal file
25
backend/prompts/report-key-decisions.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
You are a senior qualitative research analyst preparing a stakeholder report.
|
||||
|
||||
## Research Session
|
||||
**Topic:** {topic}
|
||||
**Participants:** {participant_count} personas — {participant_names}
|
||||
|
||||
## Research Brief
|
||||
{research_brief}
|
||||
|
||||
## Key Themes
|
||||
{themes_summary}
|
||||
|
||||
## Transcript Excerpt
|
||||
{transcript_excerpt}
|
||||
|
||||
## Instructions
|
||||
Respond entirely in {language}. Do not mix languages.
|
||||
|
||||
Write a structured section called "Key Decisions & Conclusions" containing:
|
||||
|
||||
1. **3–5 key findings** directly relevant to the research brief — what the group revealed about the product/service/topic
|
||||
2. **Strategic recommendations** — 2–3 actionable next steps for the client based on the discussion
|
||||
3. **Open questions** — 1–2 areas that need further research
|
||||
|
||||
Be specific and evidence-based. Each finding should reference actual participant quotes or themes. Write in clear, professional prose suitable for a business stakeholder. Do not include a heading — start directly with the findings.
|
||||
|
|
@ -32,3 +32,6 @@ python-dotenv==1.1.1
|
|||
|
||||
# Token estimation (used by backfill_usage.py script)
|
||||
tiktoken>=0.9.0
|
||||
|
||||
# PDF report generation
|
||||
fpdf2>=2.8.0
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ interface ThemeHighlighterProps {
|
|||
onQuoteClick?: (quote: string | QuoteData, messageId?: string) => void;
|
||||
}
|
||||
|
||||
const ThemeHighlighter = ({
|
||||
themes,
|
||||
messages,
|
||||
const ThemeHighlighter = ({
|
||||
themes,
|
||||
messages,
|
||||
personas = [],
|
||||
onThemeDelete,
|
||||
onQuoteClick
|
||||
}: ThemeHighlighterProps) => {
|
||||
|
||||
|
||||
const handleThemeDelete = (e: React.MouseEvent, themeId: string) => {
|
||||
e.stopPropagation();
|
||||
if (onThemeDelete) {
|
||||
|
|
@ -32,62 +32,40 @@ const ThemeHighlighter = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Helper function to get persona name by ID
|
||||
const getPersona = (id: string) => {
|
||||
return personas.find(p => p.id === id || p._id === id);
|
||||
};
|
||||
|
||||
// Helper function to parse attributed quotes
|
||||
const parseAttributedQuote = (quote: string) => {
|
||||
// First, check if quote has message ID format and strip it
|
||||
let cleanQuote = quote;
|
||||
const msgIdMatch = quote.match(/^\[MSG_ID:[^\]]+\]\s*(.*)$/);
|
||||
if (msgIdMatch) {
|
||||
cleanQuote = msgIdMatch[1];
|
||||
}
|
||||
|
||||
// Check if quote has attribution format [Name]: text
|
||||
if (msgIdMatch) cleanQuote = msgIdMatch[1];
|
||||
|
||||
const attributionMatch = cleanQuote.match(/^\[([^\]]+)\]:\s*(.*)$/);
|
||||
if (attributionMatch) {
|
||||
return {
|
||||
persona: attributionMatch[1],
|
||||
text: attributionMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
// Check if quote has simple attribution format Name: text (without brackets)
|
||||
if (attributionMatch) return { persona: attributionMatch[1], text: attributionMatch[2] };
|
||||
|
||||
const simpleAttributionMatch = cleanQuote.match(/^([^:]+):\s*(.*)$/);
|
||||
if (simpleAttributionMatch && simpleAttributionMatch[1].trim() !== cleanQuote.trim()) {
|
||||
return {
|
||||
persona: simpleAttributionMatch[1].trim(),
|
||||
text: simpleAttributionMatch[2]
|
||||
};
|
||||
return { persona: simpleAttributionMatch[1].trim(), text: simpleAttributionMatch[2] };
|
||||
}
|
||||
|
||||
// Fallback for quotes without attribution
|
||||
return {
|
||||
persona: null,
|
||||
text: cleanQuote
|
||||
};
|
||||
return { persona: null, text: cleanQuote };
|
||||
};
|
||||
|
||||
|
||||
// Split themes into highlighted and generated
|
||||
const highlightedThemes = themes.filter(theme =>
|
||||
'source' in theme ? theme.source === 'highlight' : true // For backward compatibility
|
||||
|
||||
const highlightedThemes = themes.filter(theme =>
|
||||
'source' in theme ? theme.source === 'highlight' : true
|
||||
) as HighlightedTheme[];
|
||||
|
||||
const generatedThemes = themes.filter(theme =>
|
||||
|
||||
const generatedThemes = themes.filter(theme =>
|
||||
'source' in theme && theme.source === 'generated'
|
||||
) as GeneratedTheme[];
|
||||
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-6 h-[70vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center mb-4">
|
||||
<Lightbulb className="h-5 w-5 text-primary mr-2" />
|
||||
<h2 className="font-sf text-xl font-semibold">Key Themes</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="overflow-auto">
|
||||
{generatedThemes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
|
|
@ -97,39 +75,38 @@ const ThemeHighlighter = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
{generatedThemes.map((theme) => (
|
||||
<Card
|
||||
key={theme.id}
|
||||
<Card
|
||||
key={theme.id}
|
||||
className="hover:shadow-md transition-shadow relative group"
|
||||
>
|
||||
{onThemeDelete && (
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 p-1 rounded-full bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleThemeDelete(e, theme.id)}
|
||||
>
|
||||
<X className="h-3 w-3 text-slate-700" />
|
||||
<X className="h-3 w-3 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{theme.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 mb-2">{theme.description}</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">{theme.description}</p>
|
||||
{theme.quotes && theme.quotes.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<h4 className="text-xs font-medium text-slate-700 mb-2">Supporting Quotes:</h4>
|
||||
<h4 className="text-xs font-medium text-foreground/70 mb-2">Supporting Quotes:</h4>
|
||||
<div className="space-y-2">
|
||||
{theme.quotes.map((quote, index) => {
|
||||
// Handle both string and QuoteData formats
|
||||
const isQuoteData = typeof quote === 'object' && quote !== null;
|
||||
const quoteText = isQuoteData ? quote.text : quote;
|
||||
const speaker = isQuoteData ? quote.speaker : parseAttributedQuote(quote).persona;
|
||||
const messageId = isQuoteData ? quote.message_id : undefined;
|
||||
const originalQuote = isQuoteData ? quote.original : quote;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={index}
|
||||
className="bg-slate-50 p-2 rounded text-xs text-slate-600 border-l-2 border-slate-200 cursor-pointer hover:bg-slate-100 transition-colors"
|
||||
className="bg-white/5 p-2 rounded text-xs text-foreground/80 border-l-2 border-primary/40 cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onQuoteClick) {
|
||||
|
|
@ -139,15 +116,13 @@ const ThemeHighlighter = ({
|
|||
title={messageId ? `Message ID: ${messageId}` : 'Click to find original message'}
|
||||
>
|
||||
{speaker && (
|
||||
<span className="font-semibold text-slate-700 mr-1">
|
||||
<span className="font-semibold text-foreground mr-1">
|
||||
{speaker}:
|
||||
</span>
|
||||
)}
|
||||
"{quoteText}"
|
||||
{messageId && (
|
||||
<span className="ml-2 text-xs text-green-600 opacity-70">
|
||||
✓
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-green-400 opacity-70">✓</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -161,7 +136,7 @@ const ThemeHighlighter = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{highlightedThemes.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
|
|
@ -170,34 +145,21 @@ const ThemeHighlighter = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
{highlightedThemes.map((theme) => {
|
||||
// Find the associated message to get full text and speaker info
|
||||
const associatedMessage = theme.messages.length > 0
|
||||
const associatedMessage = theme.messages.length > 0
|
||||
? messages.find(msg => msg.id === theme.messages[0])
|
||||
: null;
|
||||
|
||||
// Get full text (up to 200 characters) instead of truncated version
|
||||
const fullText = associatedMessage?.text || theme.text;
|
||||
const displayText = fullText.length > 200
|
||||
? fullText.substring(0, 200) + '...'
|
||||
: fullText;
|
||||
|
||||
// Get speaker information
|
||||
const displayText = fullText.length > 200 ? fullText.substring(0, 200) + '...' : fullText;
|
||||
const senderId = associatedMessage?.senderId;
|
||||
let speakerName = '';
|
||||
if (senderId === 'moderator') {
|
||||
speakerName = 'AI Moderator';
|
||||
} else if (senderId === 'facilitator') {
|
||||
speakerName = 'Human Facilitator';
|
||||
} else if (senderId) {
|
||||
// Get the actual participant name from personas
|
||||
const persona = getPersona(senderId);
|
||||
speakerName = persona?.name || 'Unknown Participant';
|
||||
}
|
||||
if (senderId === 'moderator') speakerName = 'AI Moderator';
|
||||
else if (senderId === 'facilitator') speakerName = 'Human Facilitator';
|
||||
else if (senderId) speakerName = getPersona(senderId)?.name || 'Unknown Participant';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={theme.id}
|
||||
className="hover:shadow-md hover:bg-slate-50 transition-all cursor-pointer relative group"
|
||||
<Card
|
||||
key={theme.id}
|
||||
className="hover:shadow-md hover:bg-white/5 transition-all cursor-pointer relative group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onQuoteClick && associatedMessage) {
|
||||
|
|
@ -208,26 +170,24 @@ const ThemeHighlighter = ({
|
|||
>
|
||||
{onThemeDelete && (
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
className="absolute top-2 right-2 p-1 rounded-full bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
onClick={(e) => handleThemeDelete(e, theme.id)}
|
||||
>
|
||||
<X className="h-3 w-3 text-slate-700" />
|
||||
<X className="h-3 w-3 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-800 line-clamp-2">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{speakerName && (
|
||||
<span className="text-primary font-semibold">
|
||||
{speakerName}
|
||||
</span>
|
||||
<span className="text-primary font-semibold">{speakerName}</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-slate-600 leading-relaxed">
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||
"{displayText}"
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-xs text-slate-400">
|
||||
<div className="mt-2 flex items-center text-xs text-muted-foreground">
|
||||
<MessageCircle className="h-3 w-3 mr-1" />
|
||||
Click to view in discussion
|
||||
</div>
|
||||
|
|
@ -238,18 +198,18 @@ const ThemeHighlighter = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{themes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center bg-slate-50 rounded-lg">
|
||||
<Lightbulb className="h-8 w-8 text-slate-400 mb-3" />
|
||||
<p className="text-slate-600">No themes have been identified yet.</p>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center bg-white/5 rounded-lg">
|
||||
<Lightbulb className="h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">No themes have been identified yet.</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||
Highlight important messages in the discussion or generate themes automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -279,9 +279,9 @@ const AnalyticsPanel = ({ messages, themes, personas = [] }: AnalyticsPanelProps
|
|||
const color = sentimentData.find(s => s.name === dominant)?.color || '#93c5fd';
|
||||
|
||||
return (
|
||||
<div key={personaId} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<div key={personaId} className="flex items-center justify-between p-2 bg-white/5 rounded">
|
||||
<div className="flex items-center">
|
||||
<User className="h-4 w-4 text-slate-400 mr-2" />
|
||||
<User className="h-4 w-4 text-muted-foreground mr-2" />
|
||||
<span className="text-sm">{data.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
|
@ -298,7 +298,7 @@ const AnalyticsPanel = ({ messages, themes, personas = [] }: AnalyticsPanelProps
|
|||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium mb-2">Focus Group Balance Assessment</h4>
|
||||
<div className={`p-3 rounded text-sm ${balanceAssessment.isBalanced ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||
<div className={`p-3 rounded text-sm ${balanceAssessment.isBalanced ? 'bg-green-500/10 text-green-400' : 'bg-amber-500/10 text-amber-400'}`}>
|
||||
<span className="font-medium">{balanceAssessment.isBalanced ? 'Balanced Focus Group' : 'Potential Balance Issues'}</span>
|
||||
<p className="mt-1 text-xs">{balanceAssessment.reason}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Persona } from '@/types/persona';
|
|||
import { toast } from 'sonner';
|
||||
import { focusGroupsApi } from '@/lib/api';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemesPanelProps {
|
||||
themes: Theme[];
|
||||
|
|
@ -30,11 +31,13 @@ const ThemesPanel = ({
|
|||
onGenerateKeyThemes
|
||||
}: ThemesPanelProps) => {
|
||||
const [exportingReport, setExportingReport] = useState(false);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const exportFullReport = async () => {
|
||||
setExportingReport(true);
|
||||
try {
|
||||
const result = await focusGroupsApi.downloadFullReport(focusGroupId);
|
||||
const lang = i18n.language?.split('-')[0] || 'ru';
|
||||
const result = await focusGroupsApi.downloadFullReport(focusGroupId, lang);
|
||||
toast.success("Report downloaded", { description: result.filename });
|
||||
} catch {
|
||||
toast.error("Failed to download report", { description: "Please try again." });
|
||||
|
|
|
|||
|
|
@ -516,19 +516,22 @@ export const focusGroupsApi = {
|
|||
}
|
||||
},
|
||||
|
||||
downloadFullReport: async (focusGroupId: string) => {
|
||||
downloadFullReport: async (focusGroupId: string, lang = 'ru') => {
|
||||
try {
|
||||
const response = await api.get(`/focus-groups/${focusGroupId}/report/download`, {
|
||||
const response = await api.get(`/focus-groups/${focusGroupId}/report/download?lang=${lang}`, {
|
||||
responseType: 'blob',
|
||||
timeout: 60000,
|
||||
timeout: 120000,
|
||||
});
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = 'report.md';
|
||||
let filename = 'report.pdf';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="([^"]+)"/);
|
||||
if (match) filename = match[1];
|
||||
// prefer filename* (UTF-8) over filename
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/);
|
||||
if (utf8Match) filename = decodeURIComponent(utf8Match[1]);
|
||||
else if (asciiMatch) filename = asciiMatch[1];
|
||||
}
|
||||
const blob = new Blob([response.data], { type: 'text/markdown' });
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue