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:
parent
ef7ac32a2b
commit
183a3cb2ff
4 changed files with 213 additions and 7 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
20
backend/prompts/report-executive-summary.md
Normal file
20
backend/prompts/report-executive-summary.md
Normal 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 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.
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue