feat: full report export — LLM executive summary + transcript + themes

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-24 15:08:40 +01:00
parent ef7ac32a2b
commit 183a3cb2ff
4 changed files with 213 additions and 7 deletions

View file

@ -1090,6 +1090,139 @@ async def download_discussion_guide(focus_group_id):
"message": str(e)
}), 500
@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."""
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."""

View file

@ -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 23 paragraphs (200300 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.

View file

@ -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
</Button>
<Button
onClick={exportThemes}
<Button
onClick={exportThemes}
disabled={!themes || themes.length === 0}
variant="outline"
className="w-full"
@ -112,6 +126,16 @@ const ThemesPanel = ({
<Download className="mr-2 h-4 w-4" />
Export Themes
</Button>
<Button
onClick={exportFullReport}
disabled={exportingReport}
variant="outline"
className="w-full"
>
<FileDown className="mr-2 h-4 w-4" />
{exportingReport ? "Generating report…" : "Export Full Report"}
</Button>
</div>
<div className="flex-grow overflow-hidden">

View file

@ -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),