Compare commits

..

4 Commits

Author SHA1 Message Date
agent-company f611e3a30c Docs: add MODEL, SERP_CACHE_TTL_HOURS, and LOG_LEVEL to .env.example
These environment variables were already supported in config.py but
were not documented in .env.example, making them hard to discover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:08:52 +00:00
AI-Manager f8ca1b80b1 Merge pull request 'feat: add PDF export for analysis reports' (#171) from feature/export-pdf into main 2026-03-27 05:04:55 +00:00
agent-company 338ac86086 feat: add PDF export for analysis reports
Add a new /export/{company_name}/pdf endpoint that generates a formatted
PDF report using reportlab, including a summary table and all analysis
results. Add the corresponding frontend Export PDF button alongside the
existing Export CSV button on the Analysis page.

Closes leeworks-agents/SPARC#85

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:53 +00:00
AI-Manager ce31a32322 Merge pull request 'feat: add multi-model support for per-analysis LLM selection' (#64) from feature/multi-model into main 2026-03-26 12:14:25 +00:00
5 changed files with 205 additions and 7 deletions
+15
View File
@@ -47,12 +47,27 @@ STORAGE_BACKEND=local
# AWS_SECRET_ACCESS_KEY=minioadmin # AWS_SECRET_ACCESS_KEY=minioadmin
# To start MinIO locally: docker compose --profile s3 up -d minio # To start MinIO locally: docker compose --profile s3 up -d minio
# ---- LLM ----
# LLM model to use via OpenRouter
# Supported: anthropic/claude-3.5-sonnet, openai/gpt-4o, openai/gpt-4o-mini,
# google/gemini-pro-1.5, meta-llama/llama-3.1-70b-instruct
# MODEL=anthropic/claude-3.5-sonnet
# ---- Cache ---- # ---- Cache ----
# When USE_CACHE=true: check database for cached responses before making API calls # When USE_CACHE=true: check database for cached responses before making API calls
# When USE_CACHE=false: always make fresh API calls (still stores results in database) # When USE_CACHE=false: always make fresh API calls (still stores results in database)
USE_CACHE=true USE_CACHE=true
# SERP API cache TTL in hours (how long cached search results are considered fresh)
# SERP_CACHE_TTL_HOURS=24
# ---- Logging ----
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
# LOG_LEVEL=INFO
# ---- Webhooks ---- # ---- Webhooks ----
# Comma-separated list of webhook URLs for job completion and alert notifications # Comma-separated list of webhook URLs for job completion and alert notifications
+158
View File
@@ -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"<i>Performed: {ts_str}</i>", body_style)
)
# Wrap long response text into paragraphs, escaping XML special chars
safe_response = (
response.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# 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 ============== # ============== System Endpoints ==============
+15
View File
@@ -141,6 +141,21 @@ export const exportApi = {
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
exportPdf: async (companyName: string): Promise<void> => {
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 // Analytics API
+16 -7
View File
@@ -110,13 +110,22 @@ export function Analysis() {
<h3 className="text-lg font-semibold text-text-primary"> <h3 className="text-lg font-semibold text-text-primary">
AI Analysis Results AI Analysis Results
</h3> </h3>
<button <div className="flex items-center gap-2">
onClick={() => exportApi.exportCsv(result.company_name)} <button
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" onClick={() => 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"
<Download size={14} /> >
Export CSV <Download size={14} />
</button> Export CSV
</button>
<button
onClick={() => 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"
>
<FileText size={14} />
Export PDF
</button>
</div>
</div> </div>
<div className="prose prose-invert max-w-none"> <div className="prose prose-invert max-w-none">
<div className="text-text-primary whitespace-pre-wrap leading-relaxed"> <div className="text-text-primary whitespace-pre-wrap leading-relaxed">
+1
View File
@@ -17,3 +17,4 @@ PyJWT
slowapi slowapi
apscheduler apscheduler
boto3 boto3
reportlab