forked from 0xWheatyz/SPARC
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa71eb17e | |||
| fb52d08387 | |||
| 223d5f7e5d | |||
| 595516e330 | |||
| 514e274fdb | |||
| 3d2c0ea27d | |||
| f611e3a30c | |||
| 2bbf2d70bb | |||
| f8ca1b80b1 | |||
| 338ac86086 | |||
| ce31a32322 | |||
| 449055b026 | |||
| 70925fbf04 | |||
| 9b2b2c75db | |||
| 730f455e2b | |||
| 04f4d36307 | |||
| 7a364e6736 |
@@ -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
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ruff check SPARC/ tests/
|
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
|
- name: Run pytest
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
env:
|
||||||
|
|||||||
+12
-7
@@ -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) -> str:
|
def analyze_company(self, company_name: str, patents: "Patents | None" = None, model: str | 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,6 +46,7 @@ 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
|
||||||
@@ -100,12 +101,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
|
patents_data=processed_patents, company_name=company_name, model=model
|
||||||
)
|
)
|
||||||
|
|
||||||
return analysis
|
return analysis
|
||||||
|
|
||||||
def analyze_single_patent(self, patent_id: str, company_name: str) -> str:
|
def analyze_single_patent(self, patent_id: str, company_name: str, model: str | None = None) -> 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
|
||||||
@@ -116,6 +117,7 @@ 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
|
||||||
@@ -151,7 +153,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
|
patent_content=minimized_content, company_name=company_name, model=model
|
||||||
)
|
)
|
||||||
|
|
||||||
return analysis
|
return analysis
|
||||||
@@ -201,18 +203,19 @@ 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) -> CompanyAnalysisResult:
|
def _analyze_company_safe(self, company_name: str, model: str | None = None) -> 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)
|
analysis = self.analyze_company(company_name, model=model)
|
||||||
|
|
||||||
# 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()
|
||||||
@@ -252,6 +255,7 @@ 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.
|
||||||
|
|
||||||
@@ -262,6 +266,7 @@ 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
|
||||||
@@ -273,7 +278,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): company
|
executor.submit(self._analyze_company_safe, company, model): company
|
||||||
for company in companies
|
for company in companies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+206
-3
@@ -41,6 +41,7 @@ class CompanyAnalysisResponse(BaseModel):
|
|||||||
patent_count: int
|
patent_count: int
|
||||||
success: bool
|
success: bool
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
model: str | None = None
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ class BatchAnalysisResponse(BaseModel):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyAnalysisRequest(BaseModel):
|
||||||
|
"""Request model for single company analysis with optional model selection."""
|
||||||
|
|
||||||
|
model: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="LLM model to use (e.g. 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o'). Defaults to server config.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BatchAnalysisRequest(BaseModel):
|
class BatchAnalysisRequest(BaseModel):
|
||||||
"""Request model for batch company analysis."""
|
"""Request model for batch company analysis."""
|
||||||
|
|
||||||
@@ -63,6 +73,10 @@ class BatchAnalysisRequest(BaseModel):
|
|||||||
max_workers: int = Field(
|
max_workers: int = Field(
|
||||||
default=3, ge=1, le=5, description="Max concurrent analyses"
|
default=3, ge=1, le=5, description="Max concurrent analyses"
|
||||||
)
|
)
|
||||||
|
model: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="LLM model to use for all analyses in this batch. Defaults to server config.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(BaseModel):
|
class JobStatus(BaseModel):
|
||||||
@@ -140,6 +154,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse:
|
|||||||
patent_count=result.patent_count,
|
patent_count=result.patent_count,
|
||||||
success=result.success,
|
success=result.success,
|
||||||
error=result.error,
|
error=result.error,
|
||||||
|
model=result.model,
|
||||||
timestamp=result.timestamp,
|
timestamp=result.timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -453,6 +468,32 @@ async def get_analytics(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Model Selection Endpoints ==============
|
||||||
|
|
||||||
|
# Supported models via OpenRouter
|
||||||
|
SUPPORTED_MODELS = [
|
||||||
|
{"id": "anthropic/claude-3.5-sonnet", "name": "Claude 3.5 Sonnet", "provider": "Anthropic"},
|
||||||
|
{"id": "openai/gpt-4o", "name": "GPT-4o", "provider": "OpenAI"},
|
||||||
|
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini", "provider": "OpenAI"},
|
||||||
|
{"id": "google/gemini-pro-1.5", "name": "Gemini Pro 1.5", "provider": "Google"},
|
||||||
|
{"id": "meta-llama/llama-3.1-70b-instruct", "name": "Llama 3.1 70B", "provider": "Meta"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models", tags=["System"])
|
||||||
|
async def list_models():
|
||||||
|
"""List supported LLM models for analysis.
|
||||||
|
|
||||||
|
Returns the available models that can be passed as the `model` field
|
||||||
|
in analysis requests. The default model is determined by the `MODEL`
|
||||||
|
environment variable on the server.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"models": SUPPORTED_MODELS,
|
||||||
|
"default": config.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analytics/trends", tags=["Analytics"])
|
@app.get("/analytics/trends", tags=["Analytics"])
|
||||||
async def get_analytics_trends(
|
async def get_analytics_trends(
|
||||||
days: int = Query(default=90, ge=7, le=365),
|
days: int = Query(default=90, ge=7, le=365),
|
||||||
@@ -580,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 ==============
|
# ============== System Endpoints ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -600,6 +799,7 @@ 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.
|
||||||
@@ -609,6 +809,7 @@ 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
|
||||||
@@ -616,7 +817,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)
|
result = _analyzer._analyze_company_safe(company_name, model=model)
|
||||||
return _convert_result(result)
|
return _convert_result(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -678,6 +879,7 @@ 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)
|
||||||
|
|
||||||
@@ -709,7 +911,7 @@ def _job_row_to_status(row: dict) -> JobStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
|
def _run_batch_job(job_id: str, companies: list[str], max_workers: int, model: str | None = None):
|
||||||
"""Background task for batch analysis."""
|
"""Background task for batch analysis."""
|
||||||
import json as _json
|
import json as _json
|
||||||
global _analyzer
|
global _analyzer
|
||||||
@@ -734,6 +936,7 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
|
|||||||
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(
|
||||||
@@ -789,7 +992,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
|
_run_batch_job, job_id, request.companies, request.max_workers, request.model
|
||||||
)
|
)
|
||||||
|
|
||||||
return _job_row_to_status(job_row)
|
return _job_row_to_status(job_row)
|
||||||
|
|||||||
+17
-11
@@ -40,12 +40,13 @@ class LLMAnalyzer:
|
|||||||
else:
|
else:
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
def analyze_patent_content(self, patent_content: str, company_name: str) -> str:
|
def analyze_patent_content(self, patent_content: str, company_name: str, model: str | None = None) -> str:
|
||||||
"""Analyze patent content to estimate company innovation and performance.
|
"""Analyze patent content to estimate company innovation and performance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
patent_content: Minimized patent text (abstract, claims, summary)
|
patent_content: Minimized patent text (abstract, claims, summary)
|
||||||
company_name: Name of the company for context
|
company_name: Name of the company for context
|
||||||
|
model: Optional model override (e.g. "openai/gpt-4o"). Defaults to config.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Analysis text describing innovation quality and potential impact
|
Analysis text describing innovation quality and potential impact
|
||||||
@@ -63,6 +64,8 @@ Patent Content:
|
|||||||
|
|
||||||
Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage."""
|
Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage."""
|
||||||
|
|
||||||
|
effective_model = model or self.model
|
||||||
|
|
||||||
if self.test_mode:
|
if self.test_mode:
|
||||||
logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
|
logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
|
||||||
return "[TEST MODE - No API call made]"
|
return "[TEST MODE - No API call made]"
|
||||||
@@ -81,7 +84,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
|
|||||||
response=cached["response"],
|
response=cached["response"],
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="single_patent",
|
analysis_type="single_patent",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata={
|
metadata={
|
||||||
"patent_content_length": len(patent_content),
|
"patent_content_length": len(patent_content),
|
||||||
"cache_hit": True,
|
"cache_hit": True,
|
||||||
@@ -94,7 +97,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
|
|||||||
# Call API if no cache hit and client is available
|
# Call API if no cache hit and client is available
|
||||||
if self.client:
|
if self.client:
|
||||||
response = self.client.chat.completions.create(
|
response = self.client.chat.completions.create(
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
@@ -106,7 +109,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
|
|||||||
response=response_text,
|
response=response_text,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="single_patent",
|
analysis_type="single_patent",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata={"patent_content_length": len(patent_content)},
|
metadata={"patent_content_length": len(patent_content)},
|
||||||
token_usage={
|
token_usage={
|
||||||
"prompt_tokens": response.usage.prompt_tokens,
|
"prompt_tokens": response.usage.prompt_tokens,
|
||||||
@@ -124,13 +127,13 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
|
|||||||
response=placeholder,
|
response=placeholder,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="single_patent",
|
analysis_type="single_patent",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata={"patent_content_length": len(patent_content), "pending": True}
|
metadata={"patent_content_length": len(patent_content), "pending": True}
|
||||||
)
|
)
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|
||||||
def analyze_patent_portfolio(
|
def analyze_patent_portfolio(
|
||||||
self, patents_data: list[Dict[str, str]], company_name: str
|
self, patents_data: list[Dict[str, str]], company_name: str, model: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Analyze multiple patents to estimate overall company performance.
|
"""Analyze multiple patents to estimate overall company performance.
|
||||||
|
|
||||||
@@ -165,13 +168,16 @@ Patent Portfolio:
|
|||||||
|
|
||||||
Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook."""
|
Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook."""
|
||||||
|
|
||||||
|
effective_model = model or self.model
|
||||||
|
|
||||||
if self.test_mode:
|
if self.test_mode:
|
||||||
logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt)
|
logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt)
|
||||||
return "[TEST MODE]"
|
return "[TEST MODE]"
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"patent_count": len(patents_data),
|
"patent_count": len(patents_data),
|
||||||
"patent_ids": [p['patent_id'] for p in patents_data]
|
"patent_ids": [p['patent_id'] for p in patents_data],
|
||||||
|
"model": effective_model,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
@@ -188,7 +194,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
|||||||
response=cached["response"],
|
response=cached["response"],
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="portfolio",
|
analysis_type="portfolio",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata={
|
metadata={
|
||||||
**metadata,
|
**metadata,
|
||||||
"cache_hit": True,
|
"cache_hit": True,
|
||||||
@@ -202,7 +208,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
|||||||
if self.client:
|
if self.client:
|
||||||
try:
|
try:
|
||||||
response = self.client.chat.completions.create(
|
response = self.client.chat.completions.create(
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
max_tokens=2048,
|
max_tokens=2048,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
@@ -215,7 +221,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
|||||||
response=response_text,
|
response=response_text,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="portfolio",
|
analysis_type="portfolio",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
token_usage={
|
token_usage={
|
||||||
"prompt_tokens": response.usage.prompt_tokens,
|
"prompt_tokens": response.usage.prompt_tokens,
|
||||||
@@ -235,7 +241,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
|||||||
response=placeholder,
|
response=placeholder,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
analysis_type="portfolio",
|
analysis_type="portfolio",
|
||||||
model=self.model,
|
model=effective_model,
|
||||||
metadata={**metadata, "pending": True}
|
metadata={**metadata, "pending": True}
|
||||||
)
|
)
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class CompanyAnalysisResult:
|
|||||||
patent_count: int
|
patent_count: int
|
||||||
success: bool
|
success: bool
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
model: str | None = None
|
||||||
timestamp: datetime = field(default_factory=datetime.now)
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"generate": "openapi-typescript http://localhost:8000/api/openapi.json -o src/api/schema.d.ts",
|
||||||
|
"generate:local": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"globals": "^15.8.0",
|
"globals": "^15.8.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
|
"openapi-typescript": "^7.0.0",
|
||||||
"typescript": "~5.5.3",
|
"typescript": "~5.5.3",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^5.3.3"
|
"vite": "^5.3.3"
|
||||||
|
|||||||
@@ -89,29 +89,53 @@ 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): Promise<CompanyAnalysis> => {
|
analyzeCompany: async (companyName: string, model?: string): Promise<CompanyAnalysis> => {
|
||||||
const response = await api.get<CompanyAnalysis>(`/analyze/${encodeURIComponent(companyName)}`);
|
const params = new URLSearchParams();
|
||||||
|
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): Promise<BatchAnalysisResult> => {
|
analyzeBatch: async (companies: string[], maxWorkers = 3, model?: string): 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): Promise<JobStatus> => {
|
analyzeBatchAsync: async (companies: string[], maxWorkers = 3, model?: string): 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;
|
||||||
@@ -141,6 +165,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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { analysisApi, exportApi } from '../api/client';
|
import { analysisApi, exportApi } from '../api/client';
|
||||||
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react';
|
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } 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),
|
mutationFn: (name: string) => analysisApi.analyzeCompany(name, selectedModel || undefined),
|
||||||
onSuccess: (data) => setResult(data),
|
onSuccess: (data) => setResult(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +39,8 @@ export function Analysis() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex gap-4">
|
<form onSubmit={handleSubmit} className="space-y-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
|
||||||
@@ -58,6 +65,31 @@ export function Analysis() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
@@ -110,6 +142,7 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => exportApi.exportCsv(result.company_name)}
|
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"
|
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"
|
||||||
@@ -117,6 +150,14 @@ export function Analysis() {
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</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,20 +1,34 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } 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({
|
||||||
|
queryKey: ['models'],
|
||||||
|
queryFn: () => analysisApi.listModels(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobsQuery = useQuery({
|
||||||
|
queryKey: ['jobs'],
|
||||||
|
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),
|
analysisApi.analyzeBatch(companies, workers, selectedModel || undefined),
|
||||||
onSuccess: (data) => setResult(data),
|
onSuccess: (data) => {
|
||||||
|
setResult(data);
|
||||||
|
jobsQuery.refetch();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -85,6 +99,29 @@ 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()}
|
||||||
@@ -123,13 +160,30 @@ 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>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => mutation.reset()}
|
onClick={() => mutation.reset()}
|
||||||
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
|
className="text-sm text-text-secondary hover:text-text-primary underline"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
@@ -230,6 +284,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ PyJWT
|
|||||||
slowapi
|
slowapi
|
||||||
apscheduler
|
apscheduler
|
||||||
boto3
|
boto3
|
||||||
|
reportlab
|
||||||
|
|||||||
Reference in New Issue
Block a user