forked from 0xWheatyz/SPARC
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f611e3a30c | |||
| f8ca1b80b1 | |||
| 338ac86086 | |||
| ce31a32322 | |||
| 449055b026 | |||
| 70925fbf04 | |||
| 9b2b2c75db | |||
| 730f455e2b | |||
| 03f8f7fa79 | |||
| f0edc5a3ae | |||
| f64d1b745f | |||
| 513b682dad | |||
| 04f4d36307 | |||
| 7a364e6736 | |||
| 52972bbff0 | |||
| c738f785c3 |
@@ -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
|
||||||
|
|||||||
+271
@@ -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,104 @@ 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"])
|
||||||
|
async def get_analytics_trends(
|
||||||
|
days: int = Query(default=90, ge=7, le=365),
|
||||||
|
_: UserResponse = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get trend data for patent analysis over time.
|
||||||
|
|
||||||
|
Returns two datasets:
|
||||||
|
- ``by_month``: analysis count per company per month
|
||||||
|
- ``by_type_over_time``: analysis type distribution per month
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back (default 90)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Trend data suitable for time-series and distribution charts
|
||||||
|
"""
|
||||||
|
db = get_db_client()
|
||||||
|
|
||||||
|
with db.get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Analyses per company per month
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||||
|
company_name,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM llm_messages
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||||
|
AND is_cached = FALSE
|
||||||
|
AND company_name IS NOT NULL
|
||||||
|
GROUP BY month, company_name
|
||||||
|
ORDER BY month
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
by_month_rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Analysis type distribution per month
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||||
|
analysis_type,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM llm_messages
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||||
|
AND is_cached = FALSE
|
||||||
|
GROUP BY month, analysis_type
|
||||||
|
ORDER BY month
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
by_type_rows = cur.fetchall()
|
||||||
|
|
||||||
|
by_month = [
|
||||||
|
{"month": row[0], "company_name": row[1], "count": row[2]}
|
||||||
|
for row in by_month_rows
|
||||||
|
]
|
||||||
|
by_type_over_time = [
|
||||||
|
{"month": row[0], "analysis_type": row[1], "count": row[2]}
|
||||||
|
for row in by_type_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"by_month": by_month,
|
||||||
|
"by_type_over_time": by_type_over_time,
|
||||||
|
"period_days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============== Export Endpoints ==============
|
# ============== Export Endpoints ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -508,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 ==============
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Batch } from './pages/Batch';
|
|||||||
import { AnalyticsPage } from './pages/Analytics';
|
import { AnalyticsPage } from './pages/Analytics';
|
||||||
import { About } from './pages/About';
|
import { About } from './pages/About';
|
||||||
import { AdminUsers } from './pages/AdminUsers';
|
import { AdminUsers } from './pages/AdminUsers';
|
||||||
|
import { Compare } from './pages/Compare';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -43,6 +44,7 @@ function App() {
|
|||||||
<Route path="/analysis" element={<Analysis />} />
|
<Route path="/analysis" element={<Analysis />} />
|
||||||
<Route path="/batch" element={<Batch />} />
|
<Route path="/batch" element={<Batch />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/compare" element={<Compare />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
|
|||||||
@@ -141,14 +141,40 @@ 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
|
||||||
|
export interface TrendData {
|
||||||
|
by_month: Array<{ month: string; company_name: string; count: number }>;
|
||||||
|
by_type_over_time: Array<{ month: string; analysis_type: string; count: number }>;
|
||||||
|
period_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
getAnalytics: async (days = 30): Promise<Analytics> => {
|
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||||
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTrends: async (days = 90): Promise<TrendData> => {
|
||||||
|
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { Search, Layers, BarChart3, Info, Users, LogOut, Sun, Moon } from 'lucide-react';
|
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
@@ -17,6 +17,7 @@ export function Layout() {
|
|||||||
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
||||||
{ to: '/batch', icon: Layers, label: 'Batch' },
|
{ to: '/batch', icon: Layers, label: 'Batch' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
|
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
|
||||||
{ to: '/about', icon: Info, label: 'About' },
|
{ to: '/about', icon: Info, label: 'About' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,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 +118,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">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { analyticsApi } from '../api/client';
|
import { analyticsApi } from '../api/client';
|
||||||
import { AlertCircle, Database } from 'lucide-react';
|
import { AlertCircle, Database } from 'lucide-react';
|
||||||
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ export function AnalyticsPage() {
|
|||||||
queryFn: () => analyticsApi.getAnalytics(days),
|
queryFn: () => analyticsApi.getAnalytics(days),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trendsQuery = useQuery({
|
||||||
|
queryKey: ['analytics-trends', days],
|
||||||
|
queryFn: () => analyticsApi.getTrends(days),
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -189,6 +194,114 @@ export function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Charts */}
|
||||||
|
{trendsQuery.data && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||||
|
Trends Over Time
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Patent count over time per company (line chart) */}
|
||||||
|
{trendsQuery.data.by_month.length > 0 && (() => {
|
||||||
|
// Pivot data: each month as a row, companies as columns
|
||||||
|
const companies = [...new Set(trendsQuery.data!.by_month.map(d => d.company_name))];
|
||||||
|
const months = [...new Set(trendsQuery.data!.by_month.map(d => d.month))].sort();
|
||||||
|
const pivoted = months.map(month => {
|
||||||
|
const row: Record<string, string | number> = { month };
|
||||||
|
for (const c of companies) {
|
||||||
|
const entry = trendsQuery.data!.by_month.find(d => d.month === month && d.company_name === c);
|
||||||
|
row[c] = entry?.count || 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={pivoted}>
|
||||||
|
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||||
|
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{companies.map((company, idx) => (
|
||||||
|
<Line
|
||||||
|
key={company}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={company}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
name={company.toUpperCase()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Analysis type distribution over time (stacked bar) */}
|
||||||
|
{trendsQuery.data.by_type_over_time.length > 0 && (() => {
|
||||||
|
const types = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.analysis_type))];
|
||||||
|
const months = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.month))].sort();
|
||||||
|
const pivoted = months.map(month => {
|
||||||
|
const row: Record<string, string | number> = { month };
|
||||||
|
for (const t of types) {
|
||||||
|
const entry = trendsQuery.data!.by_type_over_time.find(d => d.month === month && d.analysis_type === t);
|
||||||
|
row[t] = entry?.count || 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={pivoted}>
|
||||||
|
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||||
|
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{types.map((type, idx) => (
|
||||||
|
<Bar
|
||||||
|
key={type}
|
||||||
|
dataKey={type}
|
||||||
|
stackId="types"
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
name={type}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trendsQuery.data.by_month.length === 0 && (
|
||||||
|
<div className="text-text-secondary text-center py-8">
|
||||||
|
No trend data available yet. Run analyses over multiple days to see trends.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { analysisApi } from '../api/client';
|
||||||
|
import { GitCompareArrows, AlertCircle, FileText, Clock } from 'lucide-react';
|
||||||
|
import type { CompanyAnalysis } from '../types';
|
||||||
|
|
||||||
|
function CompanyPanel({ data, isLoading, isError }: { data?: CompanyAnalysis; isLoading: boolean; isError: boolean }) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 animate-pulse">
|
||||||
|
<div className="h-6 w-32 bg-primary/20 rounded mb-4" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 bg-primary/10 rounded w-full" />
|
||||||
|
<div className="h-4 bg-primary/10 rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-primary/10 rounded w-5/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-error/10 border border-error/20 rounded-2xl p-6">
|
||||||
|
<div className="flex items-center gap-2 text-error">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Failed to load analysis. Check the company name and try again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||||
|
{data.company_name.toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||||
|
<FileText className="mx-auto mb-1 text-primary" size={18} />
|
||||||
|
<div className="text-xl font-bold text-text-primary">{data.patent_count}</div>
|
||||||
|
<div className="text-xs text-text-secondary uppercase">Patents</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||||
|
<Clock className="mx-auto mb-1 text-primary" size={18} />
|
||||||
|
<div className="text-sm font-medium text-text-primary">
|
||||||
|
{new Date(data.timestamp).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary uppercase">Analyzed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.success && data.analysis ? (
|
||||||
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed text-sm">
|
||||||
|
{data.analysis}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-error text-sm">{data.error || 'Analysis not available'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Compare() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [companyA, setCompanyA] = useState(searchParams.get('a') || '');
|
||||||
|
const [companyB, setCompanyB] = useState(searchParams.get('b') || '');
|
||||||
|
|
||||||
|
const queryA = searchParams.get('a') || '';
|
||||||
|
const queryB = searchParams.get('b') || '';
|
||||||
|
|
||||||
|
const resultA = useQuery({
|
||||||
|
queryKey: ['analyze', queryA],
|
||||||
|
queryFn: () => analysisApi.analyzeCompany(queryA),
|
||||||
|
enabled: !!queryA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultB = useQuery({
|
||||||
|
queryKey: ['analyze', queryB],
|
||||||
|
queryFn: () => analysisApi.analyzeCompany(queryB),
|
||||||
|
enabled: !!queryB,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCompare = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const a = companyA.trim();
|
||||||
|
const b = companyB.trim();
|
||||||
|
if (a && b) {
|
||||||
|
setSearchParams({ a, b });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Portfolio Comparison
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Compare patent portfolios of two companies side by side.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Form */}
|
||||||
|
<form onSubmit={handleCompare} className="flex flex-col sm:flex-row gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1">Company A</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={companyA}
|
||||||
|
onChange={(e) => setCompanyA(e.target.value)}
|
||||||
|
placeholder="e.g. nvidia"
|
||||||
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1">Company B</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={companyB}
|
||||||
|
onChange={(e) => setCompanyB(e.target.value)}
|
||||||
|
placeholder="e.g. intel"
|
||||||
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 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={!companyA.trim() || !companyB.trim() || resultA.isLoading || resultB.isLoading}
|
||||||
|
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-2.5 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"
|
||||||
|
>
|
||||||
|
<GitCompareArrows size={18} />
|
||||||
|
Compare
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Comparison Panels */}
|
||||||
|
{(queryA || queryB) && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{queryA && (
|
||||||
|
<CompanyPanel
|
||||||
|
data={resultA.data}
|
||||||
|
isLoading={resultA.isLoading}
|
||||||
|
isError={resultA.isError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{queryB && (
|
||||||
|
<CompanyPanel
|
||||||
|
data={resultB.data}
|
||||||
|
isLoading={resultB.isLoading}
|
||||||
|
isError={resultB.isError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,3 +17,4 @@ PyJWT
|
|||||||
slowapi
|
slowapi
|
||||||
apscheduler
|
apscheduler
|
||||||
boto3
|
boto3
|
||||||
|
reportlab
|
||||||
|
|||||||
Reference in New Issue
Block a user