forked from 0xWheatyz/SPARC
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 338ac86086 | |||
| ce31a32322 | |||
| 449055b026 | |||
| 70925fbf04 | |||
| 04f4d36307 |
+199
@@ -41,6 +41,7 @@ class CompanyAnalysisResponse(BaseModel):
|
||||
patent_count: int
|
||||
success: bool
|
||||
error: str | None = None
|
||||
model: str | None = None
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@@ -54,6 +55,15 @@ class BatchAnalysisResponse(BaseModel):
|
||||
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):
|
||||
"""Request model for batch company analysis."""
|
||||
|
||||
@@ -63,6 +73,10 @@ class BatchAnalysisRequest(BaseModel):
|
||||
max_workers: int = Field(
|
||||
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):
|
||||
@@ -140,6 +154,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse:
|
||||
patent_count=result.patent_count,
|
||||
success=result.success,
|
||||
error=result.error,
|
||||
model=result.model,
|
||||
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"])
|
||||
async def get_analytics_trends(
|
||||
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 ==============
|
||||
|
||||
|
||||
|
||||
+17
-11
@@ -40,12 +40,13 @@ class LLMAnalyzer:
|
||||
else:
|
||||
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.
|
||||
|
||||
Args:
|
||||
patent_content: Minimized patent text (abstract, claims, summary)
|
||||
company_name: Name of the company for context
|
||||
model: Optional model override (e.g. "openai/gpt-4o"). Defaults to config.
|
||||
|
||||
Returns:
|
||||
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."""
|
||||
|
||||
effective_model = model or self.model
|
||||
|
||||
if self.test_mode:
|
||||
logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
|
||||
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"],
|
||||
company_name=company_name,
|
||||
analysis_type="single_patent",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata={
|
||||
"patent_content_length": len(patent_content),
|
||||
"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
|
||||
if self.client:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
max_tokens=1024,
|
||||
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,
|
||||
company_name=company_name,
|
||||
analysis_type="single_patent",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata={"patent_content_length": len(patent_content)},
|
||||
token_usage={
|
||||
"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,
|
||||
company_name=company_name,
|
||||
analysis_type="single_patent",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata={"patent_content_length": len(patent_content), "pending": True}
|
||||
)
|
||||
return placeholder
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
|
||||
effective_model = model or self.model
|
||||
|
||||
if self.test_mode:
|
||||
logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt)
|
||||
return "[TEST MODE]"
|
||||
|
||||
metadata = {
|
||||
"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
|
||||
@@ -188,7 +194,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
||||
response=cached["response"],
|
||||
company_name=company_name,
|
||||
analysis_type="portfolio",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata={
|
||||
**metadata,
|
||||
"cache_hit": True,
|
||||
@@ -202,7 +208,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
|
||||
if self.client:
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
max_tokens=2048,
|
||||
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,
|
||||
company_name=company_name,
|
||||
analysis_type="portfolio",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata=metadata,
|
||||
token_usage={
|
||||
"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,
|
||||
company_name=company_name,
|
||||
analysis_type="portfolio",
|
||||
model=self.model,
|
||||
model=effective_model,
|
||||
metadata={**metadata, "pending": True}
|
||||
)
|
||||
return placeholder
|
||||
|
||||
@@ -24,6 +24,7 @@ class CompanyAnalysisResult:
|
||||
patent_count: int
|
||||
success: bool
|
||||
error: str | None = None
|
||||
model: str | None = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,21 @@ export const exportApi = {
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
exportPdf: async (companyName: string): Promise<void> => {
|
||||
const response = await api.get(`/export/${encodeURIComponent(companyName)}/pdf`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const safeName = companyName.toLowerCase().replace(/\s+/g, '_');
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `${safeName}-analysis-${date}.pdf`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
// Analytics API
|
||||
|
||||
@@ -110,6 +110,7 @@ export function Analysis() {
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
AI Analysis Results
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => exportApi.exportCsv(result.company_name)}
|
||||
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
@@ -117,6 +118,14 @@ export function Analysis() {
|
||||
<Download size={14} />
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportApi.exportPdf(result.company_name)}
|
||||
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
|
||||
@@ -17,3 +17,4 @@ PyJWT
|
||||
slowapi
|
||||
apscheduler
|
||||
boto3
|
||||
reportlab
|
||||
|
||||
Reference in New Issue
Block a user