From 183a3cb2ff23442a072697a0097c05b371bbbfb1 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sun, 24 May 2026 15:08:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20full=20report=20export=20=E2=80=94=20LL?= =?UTF-8?q?M=20executive=20summary=20+=20transcript=20+=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/focus-groups/{id}/report/download generates a markdown report with AI-written executive summary, key themes with quotes, and full transcript. Frontend adds "Export Full Report" button to ThemesPanel. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/focus_groups.py | 133 ++++++++++++++++++ backend/prompts/report-executive-summary.md | 20 +++ .../focus-group-session/ThemesPanel.tsx | 38 ++++- src/lib/api.ts | 29 ++++ 4 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 backend/prompts/report-executive-summary.md diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 223ebf47..4871feb7 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -1090,6 +1090,139 @@ async def download_discussion_guide(focus_group_id): "message": str(e) }), 500 +@focus_groups_bp.route('//report/download', methods=['GET']) +@jwt_required() +async def download_full_report(focus_group_id): + """Generate and download a full research report as markdown.""" + from app.services.llm_service import LLMService, LLMServiceError + from app.utils.prompt_loader import load_prompt # noqa: PLC0415 + + try: + user_id = get_jwt_identity() + focus_group = await FocusGroup.find_by_id(focus_group_id) + if not focus_group: + return jsonify({"error": "Focus group not found"}), 404 + if focus_group.get("created_by") and focus_group.get("created_by") != user_id: + return jsonify({"message": "Permission denied"}), 403 + + fg_name = focus_group.get("name", "Unnamed Session") + fg_topic = focus_group.get("researchBrief") or focus_group.get("discussionTopics") or "Not specified" + created_at = focus_group.get("created_at") or focus_group.get("createdAt") + date_str = created_at.strftime("%Y-%m-%d") if hasattr(created_at, "strftime") else datetime.datetime.now().strftime("%Y-%m-%d") + + # Participants + participant_ids = focus_group.get("participants", []) + personas = [] + for pid in participant_ids: + 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" + + # Messages + messages = await FocusGroup.get_messages(focus_group_id) + chat_lines = [] + for m in messages: + sender = m.get("senderName") or m.get("senderId", "Unknown") + 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." + + # 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." + + # 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, + }) + try: + executive_summary = await LLMService.generate_content(prompt, model_name="mini") + except LLMServiceError: + executive_summary = "Executive summary unavailable — LLM service error." + + # 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"", + ] + 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"") + quotes = t.get("quotes", []) + if quotes: + report_lines.append("**Supporting Quotes:**") + report_lines.append("") + for q in quotes: + if isinstance(q, str): + report_lines.append(f"> {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("") + else: + report_lines.append("*No themes have been generated for this session yet.*") + report_lines.append("") + report_lines.append("---") + report_lines.append("") + + report_lines += [ + f"## Full Transcript", + f"", + full_transcript, + f"", + ] + + content = "\n".join(report_lines) + filename = f"report-{sanitized}-{date_str}.md" + + return Response( + content, + mimetype="text/markdown", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Type": "text/markdown; charset=utf-8", + }, + ) + + except Exception as e: + logger.error(f"Error in download_full_report: {e}") + logger.exception("Full exception traceback:") + return jsonify({"error": "Failed to generate report", "message": str(e)}), 500 + + # Additional asset upload utility functions def get_upload_folder(focus_group_id): """Get the upload folder path for a focus group.""" diff --git a/backend/prompts/report-executive-summary.md b/backend/prompts/report-executive-summary.md new file mode 100644 index 00000000..fcd299da --- /dev/null +++ b/backend/prompts/report-executive-summary.md @@ -0,0 +1,20 @@ +You are a qualitative research analyst. Write a concise executive summary for a focus group research report. + +## Research Session +**Topic:** {topic} +**Participants:** {participant_count} AI personas — {participant_names} +**Duration context:** {message_count} exchanges + +## Key Themes Identified +{themes_summary} + +## Transcript Excerpt (first 30 exchanges) +{transcript_excerpt} + +## Instructions +Write 2–3 paragraphs (200–300 words total): +1. **Paragraph 1** — What was studied and why it matters +2. **Paragraph 2** — The most important findings (synthesise from themes and quotes) +3. **Paragraph 3** — Key tensions, open questions, or recommended next steps + +Be specific and evidence-based. Reference actual quotes and themes. Do not use bullet points — write in flowing prose suitable for a stakeholder report. Do not include a heading — start directly with the first paragraph. diff --git a/src/components/focus-group-session/ThemesPanel.tsx b/src/components/focus-group-session/ThemesPanel.tsx index 7679f9a9..e19458e1 100755 --- a/src/components/focus-group-session/ThemesPanel.tsx +++ b/src/components/focus-group-session/ThemesPanel.tsx @@ -1,10 +1,12 @@ import ThemeHighlighter from '@/components/ThemeHighlighter'; import { Button } from '@/components/ui/button'; -import { WandSparkles, Download } from 'lucide-react'; +import { WandSparkles, Download, FileDown } from 'lucide-react'; import { Theme, Message } from './types'; import { Persona } from '@/types/persona'; import { toast } from 'sonner'; +import { focusGroupsApi } from '@/lib/api'; +import { useState } from 'react'; interface ThemesPanelProps { themes: Theme[]; @@ -17,9 +19,9 @@ interface ThemesPanelProps { onGenerateKeyThemes?: () => void; } -const ThemesPanel = ({ - themes, - messages, +const ThemesPanel = ({ + themes, + messages, personas, focusGroupId, onThemesGenerated, @@ -27,7 +29,19 @@ const ThemesPanel = ({ onQuoteClick, onGenerateKeyThemes }: ThemesPanelProps) => { - + const [exportingReport, setExportingReport] = useState(false); + + const exportFullReport = async () => { + setExportingReport(true); + try { + const result = await focusGroupsApi.downloadFullReport(focusGroupId); + toast.success("Report downloaded", { description: result.filename }); + } catch { + toast.error("Failed to download report", { description: "Please try again." }); + } finally { + setExportingReport(false); + } + }; const exportThemes = () => { if (!themes || themes.length === 0) { @@ -103,8 +117,8 @@ const ThemesPanel = ({ Analyze Discussion for Key Themes - + +
diff --git a/src/lib/api.ts b/src/lib/api.ts index e2cf4439..147c4768 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -516,6 +516,35 @@ export const focusGroupsApi = { } }, + downloadFullReport: async (focusGroupId: string) => { + try { + const response = await api.get(`/focus-groups/${focusGroupId}/report/download`, { + responseType: 'blob', + timeout: 60000, + }); + const contentDisposition = response.headers['content-disposition']; + let filename = 'report.md'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="([^"]+)"/); + if (match) filename = match[1]; + } + const blob = new Blob([response.data], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + return { success: true, filename }; + } catch (error) { + console.error('Error downloading report:', error); + throw new Error('Failed to download report'); + } + }, + // Notes endpoints createNote: (focusGroupId: string, noteData: any) => api.post(`/focus-groups/${focusGroupId}/notes`, noteData),