forked from 0xWheatyz/SPARC
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 595516e330 | |||
| 514e274fdb | |||
| 3d2c0ea27d | |||
| f611e3a30c | |||
| 2bbf2d70bb | |||
| f8ca1b80b1 | |||
| 338ac86086 | |||
| ce31a32322 |
@@ -47,12 +47,27 @@ STORAGE_BACKEND=local
|
||||
# AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
# 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 ----
|
||||
|
||||
# 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)
|
||||
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 ----
|
||||
|
||||
# Comma-separated list of webhook URLs for job completion and alert notifications
|
||||
|
||||
@@ -33,6 +33,14 @@ jobs:
|
||||
run: |
|
||||
ruff check SPARC/ tests/
|
||||
|
||||
- name: Install Node.js and check TypeScript types
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache nodejs npm
|
||||
cd frontend
|
||||
npm ci
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Run pytest
|
||||
shell: sh
|
||||
env:
|
||||
|
||||
+158
@@ -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("&", "&")
|
||||
.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 ==============
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,21 @@ export const exportApi = {
|
||||
link.remove();
|
||||
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
|
||||
|
||||
@@ -110,13 +110,22 @@ export function Analysis() {
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
AI Analysis Results
|
||||
</h3>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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
|
||||
</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 className="prose prose-invert max-w-none">
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi } from '../api/client';
|
||||
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp, RefreshCw, Inbox } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import type { BatchAnalysisResult } from '../types';
|
||||
|
||||
@@ -11,10 +11,18 @@ export function Batch() {
|
||||
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => analysisApi.listJobs(undefined, 20),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
|
||||
analysisApi.analyzeBatch(companies, workers),
|
||||
onSuccess: (data) => setResult(data),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
jobsQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -123,12 +131,29 @@ export function Batch() {
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
|
||||
{' '}Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => mutation.reset()}
|
||||
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<div className="ml-7 mt-2 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const companies = companiesInput
|
||||
.split(/[,\n]/)
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0);
|
||||
if (companies.length > 0) {
|
||||
mutation.mutate({ companies, workers: maxWorkers });
|
||||
}
|
||||
}}
|
||||
className="text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutation.reset()}
|
||||
className="text-sm text-text-secondary hover:text-text-primary underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -230,6 +255,123 @@ export function Batch() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||
Job History
|
||||
</h3>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{jobsQuery.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20" />
|
||||
<div className="h-4 w-32 rounded bg-primary/20" />
|
||||
<div className="h-4 w-20 rounded bg-primary/10" />
|
||||
</div>
|
||||
<div className="h-6 w-20 rounded-full bg-primary/15" />
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4">
|
||||
<div className="h-3 w-24 rounded bg-primary/10" />
|
||||
<div className="h-3 w-16 rounded bg-primary/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job history error */}
|
||||
{jobsQuery.isError && (
|
||||
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle size={18} />
|
||||
<span className="font-semibold">Failed to load job history</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm mt-1 ml-7">
|
||||
{jobsQuery.error instanceof Error ? jobsQuery.error.message : 'Could not retrieve past jobs.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => jobsQuery.refetch()}
|
||||
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{jobsQuery.isSuccess && jobsQuery.data.length === 0 && !result && (
|
||||
<div className="bg-bg-card/60 border border-primary/15 border-dashed rounded-xl p-8 text-center">
|
||||
<Inbox className="mx-auto text-text-secondary/40 mb-3" size={40} />
|
||||
<p className="text-text-secondary font-medium">No batch jobs yet</p>
|
||||
<p className="text-text-secondary/70 text-sm mt-1">
|
||||
Submit a batch analysis above to get started. Your job history will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
{jobsQuery.isSuccess && jobsQuery.data.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{jobsQuery.data.map((job) => (
|
||||
<div
|
||||
key={job.job_id}
|
||||
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{job.status === 'completed' && <CheckCircle className="text-success" size={18} />}
|
||||
{job.status === 'failed' && <AlertCircle className="text-error" size={18} />}
|
||||
{(job.status === 'pending' || job.status === 'running') && (
|
||||
<div className="animate-spin rounded-full h-[18px] w-[18px] border-t-2 border-b-2 border-secondary" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-text-primary">{job.job_id.slice(0, 8)}</span>
|
||||
<span className="text-text-secondary text-sm">
|
||||
{job.total_companies} {job.total_companies === 1 ? 'company' : 'companies'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
|
||||
job.status === 'completed'
|
||||
? 'bg-success/15 text-success'
|
||||
: job.status === 'failed'
|
||||
? 'bg-error/15 text-error'
|
||||
: 'bg-secondary/15 text-secondary'
|
||||
}`}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
{(job.status === 'running' || job.status === 'pending') && job.total_companies > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{job.completed_companies}/{job.total_companies}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-bg-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary to-secondary rounded-full transition-all duration-300"
|
||||
style={{ width: `${(job.completed_companies / job.total_companies) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{job.status === 'failed' && job.error && (
|
||||
<p className="mt-2 text-sm text-error/80">{job.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ PyJWT
|
||||
slowapi
|
||||
apscheduler
|
||||
boto3
|
||||
reportlab
|
||||
|
||||
Reference in New Issue
Block a user