Compare commits

..

10 Commits

Author SHA1 Message Date
agent-company 2bbf2d70bb CI: add tsc --noEmit TypeScript type checking to test job
Adds a step to install Node.js and run tsc --noEmit in the frontend
directory, catching TypeScript type errors before images are built.
Ruff was already present; this completes issue #260.

Closes leeworks-agents/SPARC#260

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:08:06 +00:00
AI-Manager f8ca1b80b1 Merge pull request 'feat: add PDF export for analysis reports' (#171) from feature/export-pdf into main 2026-03-27 05:04:55 +00:00
agent-company 338ac86086 feat: add PDF export for analysis reports
Add a new /export/{company_name}/pdf endpoint that generates a formatted
PDF report using reportlab, including a summary table and all analysis
results. Add the corresponding frontend Export PDF button alongside the
existing Export CSV button on the Analysis page.

Closes leeworks-agents/SPARC#85

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:53 +00:00
AI-Manager ce31a32322 Merge pull request 'feat: add multi-model support for per-analysis LLM selection' (#64) from feature/multi-model into main 2026-03-26 12:14:25 +00:00
agent-company 449055b026 merge: resolve multi-model conflicts with trends and export endpoints
Keeps model selection, analytics trends, and CSV export endpoints.
2026-03-26 12:14:15 +00:00
AI-Manager 70925fbf04 Merge pull request 'feat: add OpenAPI TypeScript client generation setup' (#63) from feature/openapi-client-gen into main 2026-03-26 12:13:19 +00:00
agent-company 9b2b2c75db merge: resolve openapi-client-gen conflicts with CI typecheck script
Keeps both generate scripts and typecheck script in package.json.
2026-03-26 12:13:08 +00:00
AI-Manager 730f455e2b Merge pull request 'feat: add patent trend charts to the Analytics page' (#62) from feature/trend-charts into main 2026-03-26 12:12:24 +00:00
agent-company 04f4d36307 feat: add multi-model support for per-analysis LLM selection
Allow users to choose the LLM model on a per-analysis basis. The
model field is optional in both single and batch analysis requests,
defaulting to the server-configured MODEL env var. The model used
is recorded in the analysis result and database.

- Add model parameter to LLMAnalyzer.analyze_patent_content and
  analyze_patent_portfolio
- Add model field to CompanyAnalysisResult and API response
- Add model field to BatchAnalysisRequest
- Add GET /models endpoint listing supported models and the default
- Store model in llm_messages metadata for attribution

Closes leeworks-agents/SPARC#37

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:28:25 +00:00
agent-company 7a364e6736 feat: add OpenAPI TypeScript client generation setup
Add openapi-typescript devDependency and npm scripts for generating
typed TypeScript schema from the FastAPI OpenAPI spec. Include a
static openapi.json snapshot for offline generation.

- npm run generate: fetch schema from running backend and generate types
- npm run generate:local: generate types from the bundled openapi.json

Closes leeworks-agents/SPARC#26

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:26:06 +00:00
9 changed files with 1347 additions and 19 deletions
+8
View File
@@ -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
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# 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
View File
@@ -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
+1
View File
@@ -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)
+3
View File
@@ -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"
+15
View File
@@ -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
+16 -7
View File
@@ -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">
+1
View File
@@ -17,3 +17,4 @@ PyJWT
slowapi slowapi
apscheduler apscheduler
boto3 boto3
reportlab