Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 595516e330 feat: add loading skeletons, error states, and empty state to Batch page
Add a Job History section that loads past jobs via useQuery with:
- Animated skeleton placeholders while the job list is loading
- Error banner with retry button when the API call fails
- Empty state with helpful message when no jobs exist
- Job list cards with status badges and progress bars

Also improve the batch submission error state with a retry button
alongside the existing dismiss button.

Closes leeworks-agents/SPARC#343

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:08:49 +00:00
5 changed files with 190 additions and 142 deletions
+7 -12
View File
@@ -33,7 +33,7 @@ class CompanyAnalyzer:
self.db.connect() self.db.connect()
self.db.initialize_schema() self.db.initialize_schema()
def analyze_company(self, company_name: str, patents: "Patents | None" = None, model: str | None = None) -> str: def analyze_company(self, company_name: str, patents: "Patents | None" = None) -> str:
"""Analyze a company's performance based on their patent portfolio. """Analyze a company's performance based on their patent portfolio.
This is the main entry point that orchestrates the full pipeline: This is the main entry point that orchestrates the full pipeline:
@@ -46,7 +46,6 @@ class CompanyAnalyzer:
Args: Args:
company_name: Name of the company to analyze company_name: Name of the company to analyze
patents: Optional pre-fetched Patents result to avoid duplicate API calls patents: Optional pre-fetched Patents result to avoid duplicate API calls
model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns: Returns:
Comprehensive analysis of company's innovation and performance outlook Comprehensive analysis of company's innovation and performance outlook
@@ -101,12 +100,12 @@ class CompanyAnalyzer:
# Analyze the full portfolio with LLM # Analyze the full portfolio with LLM
analysis = self.llm_analyzer.analyze_patent_portfolio( analysis = self.llm_analyzer.analyze_patent_portfolio(
patents_data=processed_patents, company_name=company_name, model=model patents_data=processed_patents, company_name=company_name
) )
return analysis return analysis
def analyze_single_patent(self, patent_id: str, company_name: str, model: str | None = None) -> str: def analyze_single_patent(self, patent_id: str, company_name: str) -> str:
"""Analyze a single patent by ID. """Analyze a single patent by ID.
If the patent PDF is not already on disk, this method attempts to If the patent PDF is not already on disk, this method attempts to
@@ -117,7 +116,6 @@ class CompanyAnalyzer:
Args: Args:
patent_id: Publication ID of the patent (e.g. "US-11234567-B2") patent_id: Publication ID of the patent (e.g. "US-11234567-B2")
company_name: Name of the company (for context) company_name: Name of the company (for context)
model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns: Returns:
Analysis of the specific patent's innovation quality Analysis of the specific patent's innovation quality
@@ -153,7 +151,7 @@ class CompanyAnalyzer:
minimized_content = SERP.minimize_patent_for_llm(sections) minimized_content = SERP.minimize_patent_for_llm(sections)
analysis = self.llm_analyzer.analyze_patent_content( analysis = self.llm_analyzer.analyze_patent_content(
patent_content=minimized_content, company_name=company_name, model=model patent_content=minimized_content, company_name=company_name
) )
return analysis return analysis
@@ -203,19 +201,18 @@ class CompanyAnalyzer:
logger.warning("Failed to process %s: %s", patent.patent_id, e) logger.warning("Failed to process %s: %s", patent.patent_id, e)
return None return None
def _analyze_company_safe(self, company_name: str, model: str | None = None) -> CompanyAnalysisResult: def _analyze_company_safe(self, company_name: str) -> CompanyAnalysisResult:
"""Internal wrapper that catches exceptions and returns structured result. """Internal wrapper that catches exceptions and returns structured result.
Args: Args:
company_name: Name of the company to analyze company_name: Name of the company to analyze
model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns: Returns:
CompanyAnalysisResult with success/failure status CompanyAnalysisResult with success/failure status
""" """
try: try:
# Delegate to analyze_company which handles SERP/patent caching # Delegate to analyze_company which handles SERP/patent caching
analysis = self.analyze_company(company_name, model=model) analysis = self.analyze_company(company_name)
# Determine patent count from cached SERP query # Determine patent count from cached SERP query
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest() query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
@@ -255,7 +252,6 @@ class CompanyAnalyzer:
companies: list[str], companies: list[str],
max_workers: int = 3, max_workers: int = 3,
progress_callback: Callable[[str, int, int], None] | None = None, progress_callback: Callable[[str, int, int], None] | None = None,
model: str | None = None,
) -> BatchAnalysisResult: ) -> BatchAnalysisResult:
"""Analyze multiple companies' patent portfolios in batch. """Analyze multiple companies' patent portfolios in batch.
@@ -266,7 +262,6 @@ class CompanyAnalyzer:
companies: List of company names to analyze companies: List of company names to analyze
max_workers: Maximum concurrent analyses (default 3 to avoid rate limits) max_workers: Maximum concurrent analyses (default 3 to avoid rate limits)
progress_callback: Optional callback(company_name, completed, total) progress_callback: Optional callback(company_name, completed, total)
model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns: Returns:
BatchAnalysisResult containing all individual results and summary stats BatchAnalysisResult containing all individual results and summary stats
@@ -278,7 +273,7 @@ class CompanyAnalyzer:
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_company = { future_to_company = {
executor.submit(self._analyze_company_safe, company, model): company executor.submit(self._analyze_company_safe, company): company
for company in companies for company in companies
} }
+3 -7
View File
@@ -799,7 +799,6 @@ async def health_check():
) )
async def analyze_company( async def analyze_company(
company_name: str, company_name: str,
model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."),
_: UserResponse = Depends(get_current_user), _: UserResponse = Depends(get_current_user),
): ):
"""Analyze a single company's patent portfolio. """Analyze a single company's patent portfolio.
@@ -809,7 +808,6 @@ async def analyze_company(
Args: Args:
company_name: Name of the company to analyze (e.g., "nvidia", "intel") company_name: Name of the company to analyze (e.g., "nvidia", "intel")
model: Optional LLM model override
Returns: Returns:
Analysis results including patent count, AI insights, and success status Analysis results including patent count, AI insights, and success status
@@ -817,7 +815,7 @@ async def analyze_company(
if not _analyzer: if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized") raise HTTPException(status_code=503, detail="Analyzer not initialized")
result = _analyzer._analyze_company_safe(company_name, model=model) result = _analyzer._analyze_company_safe(company_name)
return _convert_result(result) return _convert_result(result)
@@ -879,7 +877,6 @@ async def analyze_companies_batch(
result = _analyzer.analyze_companies( result = _analyzer.analyze_companies(
companies=request.companies, companies=request.companies,
max_workers=request.max_workers, max_workers=request.max_workers,
model=request.model,
) )
return _convert_batch_result(result) return _convert_batch_result(result)
@@ -911,7 +908,7 @@ def _job_row_to_status(row: dict) -> JobStatus:
) )
def _run_batch_job(job_id: str, companies: list[str], max_workers: int, model: str | None = None): def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
"""Background task for batch analysis.""" """Background task for batch analysis."""
import json as _json import json as _json
global _analyzer global _analyzer
@@ -936,7 +933,6 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int, model: s
companies=companies, companies=companies,
max_workers=max_workers, max_workers=max_workers,
progress_callback=progress_callback, progress_callback=progress_callback,
model=model,
) )
batch_response = _convert_batch_result(result) batch_response = _convert_batch_result(result)
db.update_job( db.update_job(
@@ -992,7 +988,7 @@ async def analyze_companies_async(
job_row = db.create_job(job_id=job_id, total_companies=len(request.companies)) job_row = db.create_job(job_id=job_id, total_companies=len(request.companies))
background_tasks.add_task( background_tasks.add_task(
_run_batch_job, job_id, request.companies, request.max_workers, request.model _run_batch_job, job_id, request.companies, request.max_workers
) )
return _job_row_to_status(job_row) return _job_row_to_status(job_row)
+4 -28
View File
@@ -89,53 +89,29 @@ export const authApi = {
}, },
}; };
// Model types
export interface ModelInfo {
id: string;
name: string;
provider: string;
}
export interface ModelsResponse {
models: ModelInfo[];
default: string;
}
// Analysis API // Analysis API
export const analysisApi = { export const analysisApi = {
analyzeCompany: async (companyName: string, model?: string): Promise<CompanyAnalysis> => { analyzeCompany: async (companyName: string): Promise<CompanyAnalysis> => {
const params = new URLSearchParams(); const response = await api.get<CompanyAnalysis>(`/analyze/${encodeURIComponent(companyName)}`);
if (model) params.append('model', model);
const qs = params.toString();
const response = await api.get<CompanyAnalysis>(
`/analyze/${encodeURIComponent(companyName)}${qs ? `?${qs}` : ''}`
);
return response.data; return response.data;
}, },
analyzeBatch: async (companies: string[], maxWorkers = 3, model?: string): Promise<BatchAnalysisResult> => { analyzeBatch: async (companies: string[], maxWorkers = 3): Promise<BatchAnalysisResult> => {
const response = await api.post<BatchAnalysisResult>('/analyze/batch', { const response = await api.post<BatchAnalysisResult>('/analyze/batch', {
companies, companies,
max_workers: maxWorkers, max_workers: maxWorkers,
...(model ? { model } : {}),
}); });
return response.data; return response.data;
}, },
analyzeBatchAsync: async (companies: string[], maxWorkers = 3, model?: string): Promise<JobStatus> => { analyzeBatchAsync: async (companies: string[], maxWorkers = 3): Promise<JobStatus> => {
const response = await api.post<JobStatus>('/analyze/batch/async', { const response = await api.post<JobStatus>('/analyze/batch/async', {
companies, companies,
max_workers: maxWorkers, max_workers: maxWorkers,
...(model ? { model } : {}),
}); });
return response.data; return response.data;
}, },
listModels: async (): Promise<ModelsResponse> => {
const response = await api.get<ModelsResponse>('/models');
return response.data;
},
getJobStatus: async (jobId: string): Promise<JobStatus> => { getJobStatus: async (jobId: string): Promise<JobStatus> => {
const response = await api.get<JobStatus>(`/jobs/${jobId}`); const response = await api.get<JobStatus>(`/jobs/${jobId}`);
return response.data; return response.data;
+27 -59
View File
@@ -1,21 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { analysisApi, exportApi } from '../api/client'; import { analysisApi, exportApi } from '../api/client';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react'; import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react';
import type { CompanyAnalysis } from '../types'; import type { CompanyAnalysis } from '../types';
export function Analysis() { export function Analysis() {
const [companyName, setCompanyName] = useState(''); const [companyName, setCompanyName] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [result, setResult] = useState<CompanyAnalysis | null>(null); const [result, setResult] = useState<CompanyAnalysis | null>(null);
const modelsQuery = useQuery({
queryKey: ['models'],
queryFn: () => analysisApi.listModels(),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (name: string) => analysisApi.analyzeCompany(name, selectedModel || undefined), mutationFn: (name: string) => analysisApi.analyzeCompany(name),
onSuccess: (data) => setResult(data), onSuccess: (data) => setResult(data),
}); });
@@ -39,57 +33,31 @@ export function Analysis() {
</div> </div>
{/* Search Form */} {/* Search Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex gap-4">
<div className="flex gap-4"> <div className="flex-1 relative">
<div className="flex-1 relative"> <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} /> <input
<input type="text"
type="text" value={companyName}
value={companyName} onChange={(e) => setCompanyName(e.target.value)}
onChange={(e) => setCompanyName(e.target.value)} placeholder="Enter company name (e.g., nvidia, intel, amd)"
placeholder="Enter company name (e.g., nvidia, intel, amd)" className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" />
/>
</div>
<button
type="submit"
disabled={mutation.isPending || !companyName.trim()}
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{mutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
) : (
<>
<Search size={18} />
Analyze
</>
)}
</button>
</div>
{/* Model Selector */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-text-secondary whitespace-nowrap">
LLM Model
</label>
<div className="relative flex-1 max-w-xs">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full appearance-none bg-bg-card/80 border border-primary/30 rounded-lg pl-3 pr-8 py-2 text-sm text-text-primary focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all cursor-pointer"
>
<option value="">
{modelsQuery.data ? `Default (${modelsQuery.data.default})` : 'Default'}
</option>
{modelsQuery.data?.models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider})
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" size={16} />
</div>
</div> </div>
<button
type="submit"
disabled={mutation.isPending || !companyName.trim()}
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{mutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
) : (
<>
<Search size={18} />
Analyze
</>
)}
</button>
</form> </form>
{/* Error */} {/* Error */}
+149 -36
View File
@@ -1,26 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { analysisApi } from '../api/client'; 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 { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import type { BatchAnalysisResult } from '../types'; import type { BatchAnalysisResult } from '../types';
export function Batch() { export function Batch() {
const [companiesInput, setCompaniesInput] = useState(''); const [companiesInput, setCompaniesInput] = useState('');
const [maxWorkers, setMaxWorkers] = useState(3); const [maxWorkers, setMaxWorkers] = useState(3);
const [selectedModel, setSelectedModel] = useState('');
const [result, setResult] = useState<BatchAnalysisResult | null>(null); const [result, setResult] = useState<BatchAnalysisResult | null>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set()); const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const modelsQuery = useQuery({ const jobsQuery = useQuery({
queryKey: ['models'], queryKey: ['jobs'],
queryFn: () => analysisApi.listModels(), queryFn: () => analysisApi.listJobs(undefined, 20),
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) => mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
analysisApi.analyzeBatch(companies, workers, selectedModel || undefined), analysisApi.analyzeBatch(companies, workers),
onSuccess: (data) => setResult(data), onSuccess: (data) => {
setResult(data);
jobsQuery.refetch();
},
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -91,29 +93,6 @@ export function Batch() {
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div> <div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
</div> </div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
LLM Model
</label>
<div className="relative">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full appearance-none bg-bg-card/80 border border-primary/30 rounded-lg pl-3 pr-8 py-2 text-sm text-text-primary focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all cursor-pointer"
>
<option value="">
{modelsQuery.data ? `Default (${modelsQuery.data.default})` : 'Default'}
</option>
{modelsQuery.data?.models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider})
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" size={16} />
</div>
</div>
<button <button
type="submit" type="submit"
disabled={mutation.isPending || !companiesInput.trim()} disabled={mutation.isPending || !companiesInput.trim()}
@@ -152,12 +131,29 @@ export function Batch() {
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'} {mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
{' '}Check your connection and try again. {' '}Check your connection and try again.
</p> </p>
<button <div className="ml-7 mt-2 flex items-center gap-3">
onClick={() => mutation.reset()} <button
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline" onClick={() => {
> const companies = companiesInput
Dismiss .split(/[,\n]/)
</button> .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> </div>
)} )}
@@ -259,6 +255,123 @@ export function Batch() {
</div> </div>
</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> </div>
); );
} }