forked from 0xWheatyz/SPARC
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bbf2d70bb | |||
| f8ca1b80b1 | |||
| 338ac86086 | |||
| ce31a32322 | |||
| 449055b026 | |||
| 70925fbf04 | |||
| 9b2b2c75db | |||
| 730f455e2b | |||
| 04f4d36307 | |||
| 7a364e6736 |
@@ -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:
|
||||||
|
|||||||
+199
@@ -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 ==============
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ export const exportApi = {
|
|||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
exportPdf: async (companyName: string): Promise<void> => {
|
||||||
|
const response = await api.get(`/export/${encodeURIComponent(companyName)}/pdf`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const safeName = companyName.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `${safeName}-analysis-${date}.pdf`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Analytics API
|
// Analytics API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -110,13 +110,22 @@ export function Analysis() {
|
|||||||
<h3 className="text-lg font-semibold text-text-primary">
|
<h3 className="text-lg font-semibold text-text-primary">
|
||||||
AI Analysis Results
|
AI Analysis Results
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => exportApi.exportCsv(result.company_name)}
|
<button
|
||||||
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
onClick={() => exportApi.exportCsv(result.company_name)}
|
||||||
>
|
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
<Download size={14} />
|
>
|
||||||
Export CSV
|
<Download size={14} />
|
||||||
</button>
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportApi.exportPdf(result.company_name)}
|
||||||
|
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
Export PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prose prose-invert max-w-none">
|
<div className="prose prose-invert max-w-none">
|
||||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ PyJWT
|
|||||||
slowapi
|
slowapi
|
||||||
apscheduler
|
apscheduler
|
||||||
boto3
|
boto3
|
||||||
|
reportlab
|
||||||
|
|||||||
Reference in New Issue
Block a user