diff --git a/SPARC/api.py b/SPARC/api.py index 295b405..dbcc01e 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -621,6 +621,164 @@ async def export_company_csv( ) +@app.get("/export/{company_name}/pdf", tags=["Export"]) +async def export_company_pdf( + company_name: str, + _: UserResponse = Depends(get_current_user), +): + """Export analysis results for a company as a formatted PDF report. + + Returns all stored analysis records for the given company, including + analysis type, model used, response text, and timestamp, formatted + as a downloadable PDF document. + + Args: + company_name: Company name to export results for + + Returns: + PDF file download + """ + import io + import textwrap + + from reportlab.lib import colors + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import inch + from reportlab.platypus import ( + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + + db = get_db_client() + with db.get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT company_name, analysis_type, model, response, timestamp + FROM llm_messages + WHERE LOWER(company_name) = LOWER(%s) AND is_cached = FALSE + ORDER BY timestamp DESC + """, + (company_name,), + ) + rows = cur.fetchall() + + if not rows: + raise HTTPException(status_code=404, detail=f"No analysis results found for '{company_name}'") + + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + rightMargin=0.75 * inch, + leftMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, + ) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=20, + spaceAfter=6, + ) + subtitle_style = ParagraphStyle( + "Subtitle", + parent=styles["Normal"], + fontSize=11, + textColor=colors.grey, + spaceAfter=20, + ) + heading_style = ParagraphStyle( + "SectionHeading", + parent=styles["Heading2"], + fontSize=13, + spaceBefore=16, + spaceAfter=8, + textColor=colors.HexColor("#1a1a2e"), + ) + body_style = ParagraphStyle( + "BodyText", + parent=styles["Normal"], + fontSize=9, + leading=13, + spaceAfter=10, + ) + + elements = [] + + # Title and date + display_name = rows[0][0] # Use the casing from the database + analysis_date = datetime.now().strftime("%Y-%m-%d") + elements.append(Paragraph(f"SPARC Analysis Report: {display_name}", title_style)) + elements.append(Paragraph(f"Generated on {analysis_date}", subtitle_style)) + + # Summary table + summary_data = [ + ["Total Analyses", str(len(rows))], + ["Analysis Types", ", ".join(sorted(set(r[1] for r in rows)))], + ["Models Used", ", ".join(sorted(set(r[2] for r in rows)))], + ] + summary_table = Table(summary_data, colWidths=[2 * inch, 4.5 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f0f0f5")), + ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("PADDING", (0, 0), (-1, -1), 6), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 16)) + + # Individual analysis sections + for i, row in enumerate(rows, 1): + _, analysis_type, model, response, timestamp = row + ts_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") if hasattr(timestamp, "strftime") else str(timestamp) + + elements.append( + Paragraph(f"Analysis {i}: {analysis_type} (via {model})", heading_style) + ) + elements.append( + Paragraph(f"Performed: {ts_str}", body_style) + ) + + # Wrap long response text into paragraphs, escaping XML special chars + safe_response = ( + response.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + # Split into manageable paragraphs to avoid overflow + for line in safe_response.split("\n"): + if line.strip(): + elements.append(Paragraph(line, body_style)) + else: + elements.append(Spacer(1, 4)) + + elements.append(Spacer(1, 10)) + + doc.build(elements) + buffer.seek(0) + + safe_name = company_name.replace(" ", "_").lower() + filename = f"{safe_name}-analysis-{analysis_date}.pdf" + return StreamingResponse( + iter([buffer.getvalue()]), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + # ============== System Endpoints ============== diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0775dec..7dd76ff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -141,6 +141,21 @@ export const exportApi = { link.remove(); window.URL.revokeObjectURL(url); }, + exportPdf: async (companyName: string): Promise => { + const response = await api.get(`/export/${encodeURIComponent(companyName)}/pdf`, { + responseType: 'blob', + }); + const safeName = companyName.toLowerCase().replace(/\s+/g, '_'); + const date = new Date().toISOString().split('T')[0]; + const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${safeName}-analysis-${date}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, }; // Analytics API diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 1c8c59b..1ded981 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -110,13 +110,22 @@ export function Analysis() { AI Analysis Results - exportApi.exportCsv(result.company_name)} - className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors" - > - - Export CSV - + + exportApi.exportCsv(result.company_name)} + className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors" + > + + Export CSV + + exportApi.exportPdf(result.company_name)} + className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors" + > + + Export PDF + + diff --git a/requirements.txt b/requirements.txt index f97ca7d..f000b82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ PyJWT slowapi apscheduler boto3 +reportlab