Compare commits

..

37 Commits

Author SHA1 Message Date
agent-company f611e3a30c Docs: add MODEL, SERP_CACHE_TTL_HOURS, and LOG_LEVEL to .env.example
These environment variables were already supported in config.py but
were not documented in .env.example, making them hard to discover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:08:52 +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 03f8f7fa79 merge: resolve trend-charts conflicts with export and tracked endpoints
Keeps both analytics/trends endpoint and export endpoint from main.
2026-03-26 12:12:09 +00:00
AI-Manager f0edc5a3ae Merge pull request 'feat: add side-by-side patent portfolio comparison view' (#61) from feature/compare-view into main 2026-03-26 12:11:01 +00:00
agent-company f64d1b745f merge: resolve compare-view conflicts with dark mode changes
Combines GitCompareArrows icon import with Sun/Moon and ThemeContext imports.
2026-03-26 12:10:37 +00:00
AI-Manager 513b682dad Merge pull request 'feat: add S3/MinIO object storage support for patent PDFs' (#58) from feature/s3-storage into main 2026-03-26 12:09:49 +00:00
agent-company a6c92fde9f merge: resolve conflicts for S3 storage branch with main
Integrates S3/MinIO storage backend with structured logging changes
from main. Both boto3 and apscheduler retained in requirements.txt.
2026-03-26 12:09:24 +00:00
AI-Manager a4db9439f5 Merge pull request 'feat: add webhook notification support for job completion' (#66) from feature/webhooks into main 2026-03-26 12:08:08 +00:00
AI-Manager bbea16387d Merge pull request 'feat: implement scheduled/recurring analysis with change alerting' (#65) from feature/scheduled-analysis into main 2026-03-26 12:07:46 +00:00
AI-Manager 4e2bcae18a Merge pull request 'feat: add CSV export for company analysis results' (#60) from feature/export-csv into main 2026-03-26 12:06:57 +00:00
AI-Manager b66b8332b6 Merge pull request 'feat: add dark/light mode toggle with localStorage persistence' (#57) from feature/dark-mode into main 2026-03-26 12:06:33 +00:00
AI-Manager c42bf5bf71 Merge pull request 'feat: add cursor-based pagination to /jobs endpoint' (#59) from feature/cursor-pagination into main 2026-03-26 12:06:04 +00:00
AI-Manager 02991b6648 Merge pull request 'feat: add loading skeletons and error retry to Batch and Analytics' (#56) from feature/loading-error-states into main 2026-03-26 12:05:41 +00:00
AI-Manager ab74904845 Merge pull request 'fix: auto-download patent PDF in analyze_single_patent' (#55) from feature/fix-single-patent-download into main 2026-03-26 12:05:10 +00:00
AI-Manager 92197440bf Merge pull request 'feat: add structured logging to serp_api.py' (#54) from feature/structured-logging into main 2026-03-26 12:04:59 +00:00
AI-Manager 301a773622 Merge pull request 'ci: add tsc --noEmit TypeScript type checking to CI pipeline' (#53) from feature/ci-tsc-lint into main 2026-03-26 12:04:39 +00:00
agent-company 2e6b8c7445 feat: add webhook notification support for job completion and alerts
Send HTTP POST notifications to configured webhook URLs when batch
jobs complete or when scheduled analysis detects significant changes.

- Add SPARC/webhooks.py with retry logic (3 attempts, exponential backoff)
- Support generic HTTP POST and Slack-compatible text payloads
- Integrate into batch job completion handler in api.py
- Configure via WEBHOOK_URLS env var (comma-separated)
- Payload includes event type, job ID, status, and summary

Closes leeworks-agents/SPARC#23

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:32:07 +00:00
agent-company f33447eef8 feat: implement scheduled/recurring analysis with change alerting
Add APScheduler-based background task that periodically re-analyzes
tracked companies and alerts on significant patent count changes.

- Add tracked_companies and alerts tables to database schema
- Add SPARC/scheduler.py with configurable interval and threshold
- Add admin endpoints: GET/POST/DELETE /admin/tracked, GET /admin/alerts
- Scheduler starts at app startup; interval via SCHEDULE_INTERVAL_HOURS
- Change threshold configurable via CHANGE_THRESHOLD_PERCENT env var
- apscheduler is optional; graceful fallback if not installed

Closes leeworks-agents/SPARC#22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:30:43 +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
agent-company 52972bbff0 feat: add patent trend charts to the Analytics page
Add GET /analytics/trends endpoint returning per-company analysis
counts by month and analysis type distribution over time. Render
these as a line chart (analyses per company) and stacked bar chart
(analysis types) on the Analytics page using recharts.

Closes leeworks-agents/SPARC#24

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:23:47 +00:00
agent-company c738f785c3 feat: add side-by-side patent portfolio comparison view
Add /compare route with two-panel layout for comparing company patent
portfolios. Each panel shows patent count, analysis timestamp, and
full LLM narrative. The page is responsive (stacks vertically on
mobile) and supports URL params (?a=nvidia&b=intel) for shareability.

Closes leeworks-agents/SPARC#21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:22:14 +00:00
agent-company 1bd9dccdb8 feat: add CSV export for company analysis results
Add GET /export/{company_name} backend endpoint that returns analysis
records as a downloadable CSV file. Add Export CSV button to the
Analysis page that triggers the download via the API.

Closes leeworks-agents/SPARC#20

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:20:51 +00:00
agent-company 3b6411869d feat: add cursor-based pagination to /jobs endpoint
Add a cursor query parameter to GET /jobs and return a next_cursor
field in the response envelope. Existing clients using only limit
continue to work without modification. The cursor is an opaque token
encoding created_at and job_id for stable keyset pagination.

Closes leeworks-agents/SPARC#25

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:19:01 +00:00
agent-company 9a43f85259 feat: add S3/MinIO object storage support for patent PDFs
Introduce a StorageBackend abstraction (local filesystem and S3) for
patent PDF storage. When STORAGE_BACKEND=s3, PDFs are read/written via
boto3 to an S3-compatible bucket instead of the local filesystem.

- Add SPARC/storage.py with LocalStorageBackend and S3StorageBackend
- Update serp_api.py save_patents and parse_patent_pdf to use storage
- Add storage config vars to config.py and .env.example
- Add optional MinIO service to docker-compose.yml (--profile s3)
- Add boto3 to requirements.txt

Closes leeworks-agents/SPARC#38

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:17:24 +00:00
agent-company a4aa968434 feat: add dark/light mode toggle with localStorage persistence
- Enable Tailwind "class" dark mode strategy
- Use CSS custom properties for theme colors (bg, text, border)
- Add ThemeProvider context with toggle and localStorage persistence
- Add Sun/Moon toggle button in the header navigation
- Inline script in index.html prevents FOUC on page load
- All pages (Layout, Login, Register, ProtectedRoute) support both modes
- Default theme follows system preference (prefers-color-scheme)

Closes leeworks-agents/SPARC#33

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:15:11 +00:00
agent-company 153eb3b968 feat: improve loading and error states on Batch and Analytics pages
Analytics page now shows skeleton loaders (cards and chart placeholders)
while data loads, and displays a retry button when the API call fails.
Batch page error state now shows the actual error message and suggests
user action.

Closes leeworks-agents/SPARC#16

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:11:47 +00:00
agent-company ecc2c37bcd fix: auto-download patent PDF in analyze_single_patent before reading
When the PDF is not on disk, analyze_single_patent now looks up the
cached PDF link from the database and downloads it automatically.
If no link is cached, a clear FileNotFoundError is raised. Also adds
a GET /analyze/patent/{patent_id} API endpoint that exposes this
functionality and returns 404 when the PDF cannot be obtained.

Closes leeworks-agents/SPARC#36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:08:34 +00:00
agent-company 0b4d712fc5 feat: add structured logging to serp_api.py
Add module-level logger to serp_api.py with INFO-level messages for
patent queries and PDF downloads, and DEBUG-level messages for cache
hits and parsing details. All three target files (analyzer.py,
serp_api.py, llm.py) now use structured logging with no print() calls.

Closes leeworks-agents/SPARC#46

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:07:07 +00:00
agent-company 4696838fb8 ci: add tsc --noEmit TypeScript type checking to CI pipeline
Upgrade lucide-react to v1.7.0 for proper TypeScript declarations and
add a TypeScript type check step to the test workflow. Both ruff (Python)
and tsc --noEmit (TypeScript) now block merging on failure.

Closes leeworks-agents/SPARC#52

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:05:55 +00:00
AI-Manager 55c131cb32 Merge pull request 'ci: add pytest and ruff linting to CI workflow' (#32) from feature/ci-testing-linting into main 2026-03-26 07:04:31 +00:00
31 changed files with 2787 additions and 85 deletions
+33
View File
@@ -35,8 +35,41 @@ JWT_SECRET=your-secure-jwt-secret-change-in-production
# Defaults to http://localhost:3000,http://localhost:5173 when unset
# CORS_ORIGINS=https://sparc.example.com,https://app.example.com
# ---- Storage ----
# Backend for patent PDF storage: "local" (default) or "s3"
STORAGE_BACKEND=local
# S3/MinIO settings (only used when STORAGE_BACKEND=s3)
# S3_BUCKET=sparc-patents
# S3_ENDPOINT_URL=http://localhost:9000
# AWS_ACCESS_KEY_ID=minioadmin
# AWS_SECRET_ACCESS_KEY=minioadmin
# 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 ----
# 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)
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 ----
# Comma-separated list of webhook URLs for job completion and alert notifications
# Supports generic HTTP POST and Slack/Discord incoming webhooks
# WEBHOOK_URLS=https://hooks.slack.com/services/XXX,https://example.com/webhook
+11
View File
@@ -34,6 +34,17 @@ jobs:
run: |
ruff check SPARC/ tests/
- name: Install Node.js and frontend dependencies
shell: sh
run: |
apk add --no-cache nodejs npm
cd frontend && npm ci
- name: Run TypeScript type check
shell: sh
run: |
cd frontend && npx tsc --noEmit
- name: Run pytest
shell: sh
env:
+21 -11
View File
@@ -108,12 +108,10 @@ class CompanyAnalyzer:
def analyze_single_patent(self, patent_id: str, company_name: str) -> str:
"""Analyze a single patent by ID.
Prerequisite:
The patent PDF must already exist at ``patents/{patent_id}.pdf``
before calling this method. PDFs are downloaded automatically when
using the batch analysis pipeline (``analyze_company`` or the
``/analyze/batch`` API endpoint). For standalone usage, download
the PDF manually or call ``SERP.save_patents()`` first.
If the patent PDF is not already on disk, this method attempts to
download it automatically by looking up the PDF link in the database
cache. If the link is not cached either, a ``FileNotFoundError`` is
raised with instructions on how to obtain the PDF.
Args:
patent_id: Publication ID of the patent (e.g. "US-11234567-B2")
@@ -123,7 +121,7 @@ class CompanyAnalyzer:
Analysis of the specific patent's innovation quality
Raises:
FileNotFoundError: If the patent PDF is not found at the expected path.
FileNotFoundError: If the patent PDF cannot be found or downloaded.
"""
import os
logger.info("Analyzing patent %s for %s...", patent_id, company_name)
@@ -131,10 +129,22 @@ class CompanyAnalyzer:
patent_path = f"patents/{patent_id}.pdf"
if not os.path.exists(patent_path):
raise FileNotFoundError(
f"Patent PDF not found at '{patent_path}'. "
f"Download the PDF first using SERP.save_patents() or the batch analysis pipeline."
)
# Attempt to download the PDF automatically from cached metadata
cached = self.db.get_cached_patent(patent_id)
pdf_link = cached.get("pdf_link") if cached else None
if pdf_link:
logger.info("PDF not on disk; downloading %s from cached link", patent_id)
patent = SERP.save_patents(
Patent(patent_id=patent_id, pdf_link=pdf_link)
)
patent_path = patent.pdf_path
else:
raise FileNotFoundError(
f"Patent PDF not found at '{patent_path}' and no download link is "
f"cached for '{patent_id}'. Run a company analysis first to populate "
f"the cache, or call SERP.save_patents() with the patent's PDF link."
)
try:
sections = SERP.parse_patent_pdf(patent_path)
+472 -6
View File
@@ -9,7 +9,7 @@ from typing import Annotated, List
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, EmailStr, Field
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
@@ -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):
@@ -77,6 +91,13 @@ class JobStatus(BaseModel):
error: str | None = None
class PaginatedJobsResponse(BaseModel):
"""Paginated response for job listings."""
items: list["JobStatus"]
next_cursor: str | None = None
class HealthResponse(BaseModel):
"""Health check response."""
@@ -133,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,
)
@@ -169,6 +191,9 @@ async def lifespan(app: FastAPI):
import logging
logging.getLogger(__name__).warning("Marked %d stale jobs as failed on startup", stale)
_db.close()
# Start scheduled analysis if tracked companies are configured
from SPARC.scheduler import start_scheduler
start_scheduler()
yield
# Cleanup
_analyzer = None
@@ -369,6 +394,60 @@ async def delete_user(
return {"message": "User deleted"}
# ============== Tracked Companies Endpoints ==============
class TrackCompanyRequest(BaseModel):
"""Request to add a company to tracking."""
company_name: str = Field(..., min_length=1, max_length=255)
@app.get("/admin/tracked", tags=["Admin"])
async def list_tracked_companies(
_: UserResponse = Depends(get_current_admin),
):
"""List all tracked companies (admin only)."""
db = get_db_client()
return db.list_tracked_companies()
@app.post("/admin/tracked", tags=["Admin"])
async def add_tracked_company(
request: TrackCompanyRequest,
_: UserResponse = Depends(get_current_admin),
):
"""Add a company to the tracked list (admin only)."""
db = get_db_client()
result = db.add_tracked_company(request.company_name)
if not result:
raise HTTPException(status_code=409, detail="Company already tracked")
return result
@app.delete("/admin/tracked/{company_name}", tags=["Admin"])
async def remove_tracked_company(
company_name: str,
_: UserResponse = Depends(get_current_admin),
):
"""Remove a company from the tracked list (admin only)."""
db = get_db_client()
removed = db.remove_tracked_company(company_name)
if not removed:
raise HTTPException(status_code=404, detail="Company not found in tracking list")
return {"message": f"Stopped tracking {company_name}"}
@app.get("/admin/alerts", tags=["Admin"])
async def list_alerts(
limit: int = Query(default=50, ge=1, le=200),
_: UserResponse = Depends(get_current_admin),
):
"""List recent alerts from scheduled analysis (admin only)."""
db = get_db_client()
return db.list_alerts(limit=limit)
# ============== Analytics Endpoint ==============
@@ -389,6 +468,317 @@ 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 ==============
@app.get("/export/{company_name}", tags=["Export"])
async def export_company_csv(
company_name: str,
_: UserResponse = Depends(get_current_user),
):
"""Export analysis results for a company as a CSV file.
Returns all stored analysis records for the given company, including
analysis type, model used, response text, and timestamp.
Args:
company_name: Company name to export results for
Returns:
CSV file download
"""
import csv
import io
db = get_db_client()
# Query all non-cached analysis results for this company
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}'")
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["company_name", "analysis_type", "model", "analysis", "timestamp"])
for row in rows:
writer.writerow(row)
output.seek(0)
safe_name = company_name.replace(" ", "_").lower()
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="sparc_{safe_name}_export.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 ==============
@@ -429,6 +819,38 @@ async def analyze_company(
return _convert_result(result)
@app.get(
"/analyze/patent/{patent_id}",
tags=["Analysis"],
)
async def analyze_single_patent(
patent_id: str,
company_name: str = Query(description="Company name for analysis context"),
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single patent by its publication ID.
If the patent PDF is not already cached locally, the system will attempt
to download it automatically from a previously cached link. If no link
is available, a 404 error is returned.
Args:
patent_id: Patent publication ID (e.g. "US-11234567-B2")
company_name: Company name for analysis context
Returns:
Analysis text for the patent
"""
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
try:
analysis = _analyzer.analyze_single_patent(patent_id, company_name)
return {"patent_id": patent_id, "company_name": company_name, "analysis": analysis}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.post(
"/analyze/batch",
response_model=BatchAnalysisResponse,
@@ -519,8 +941,25 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
progress=100,
result_json=_json.dumps(batch_response.model_dump(), default=str),
)
# Fire webhook notification
from SPARC.webhooks import notify_job_completed
notify_job_completed(
job_id=job_id,
status="completed",
total_companies=result.total_companies,
successful=result.successful,
failed=result.failed,
)
except Exception as e:
db.update_job(job_id, status="failed", error=str(e))
from SPARC.webhooks import notify_job_completed
notify_job_completed(
job_id=job_id,
status="failed",
total_companies=len(companies),
successful=0,
failed=len(companies),
)
@app.post("/analyze/batch/async", response_model=JobStatus, tags=["Analysis"])
@@ -577,24 +1016,51 @@ async def get_job_status(
return _job_row_to_status(job_row)
@app.get("/jobs", response_model=list[JobStatus], tags=["Jobs"])
@app.get("/jobs", response_model=PaginatedJobsResponse, tags=["Jobs"])
async def list_jobs(
status: Annotated[
str | None,
Query(description="Filter by status: pending, running, completed, failed"),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
cursor: Annotated[
str | None,
Query(description="Opaque cursor from a previous response's next_cursor field"),
] = None,
_: UserResponse = Depends(get_current_user),
):
"""List all analysis jobs.
"""List analysis jobs with cursor-based pagination.
Pass ``limit`` to control page size. The response includes a ``next_cursor``
field; pass it back as the ``cursor`` query parameter to fetch the next page.
When ``next_cursor`` is ``null``, there are no more results.
Existing clients that use only ``limit`` (without ``cursor``) continue to
work without modification.
Args:
status: Optional filter by job status
limit: Maximum number of jobs to return (default 10, max 100)
cursor: Opaque pagination cursor from a previous response
Returns:
List of job statuses
Paginated list of job statuses
"""
db = _get_job_db()
job_rows = db.list_jobs(status=status, limit=limit)
return [_job_row_to_status(row) for row in job_rows]
# Fetch one extra to determine if there is a next page
job_rows = db.list_jobs(status=status, limit=limit + 1, cursor=cursor)
has_next = len(job_rows) > limit
if has_next:
job_rows = job_rows[:limit]
items = [_job_row_to_status(row) for row in job_rows]
next_cursor = None
if has_next and job_rows:
last = job_rows[-1]
created = last["created_at"]
ts = created.isoformat() if hasattr(created, "isoformat") else str(created)
next_cursor = f"{ts}|{last['job_id']}"
return PaginatedJobsResponse(items=items, next_cursor=next_cursor)
+7
View File
@@ -53,6 +53,13 @@ root_path = os.getenv("ROOT_PATH", "")
# Used for safety checks (e.g., refusing default JWT secret in production)
app_env = os.getenv("APP_ENV", "development")
# Storage backend: "local" (default) or "s3" for S3/MinIO object storage
storage_backend = os.getenv("STORAGE_BACKEND", "local")
s3_bucket = os.getenv("S3_BUCKET", "sparc-patents")
s3_endpoint_url = os.getenv("S3_ENDPOINT_URL", "")
s3_access_key = os.getenv("AWS_ACCESS_KEY_ID", "")
s3_secret_key = os.getenv("AWS_SECRET_ACCESS_KEY", "")
# CORS allowed origins (comma-separated)
# Defaults to localhost dev origins when unset
_cors_origins_raw = os.getenv("CORS_ORIGINS", "")
+139 -7
View File
@@ -192,6 +192,35 @@ class DatabaseClient:
ON jobs(status)
""")
# Create tracked companies table for scheduled analysis
cursor.execute("""
CREATE TABLE IF NOT EXISTS tracked_companies (
id SERIAL PRIMARY KEY,
company_name VARCHAR(255) UNIQUE NOT NULL,
last_patent_count INTEGER DEFAULT 0,
last_analysis_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create alerts table for significant changes
cursor.execute("""
CREATE TABLE IF NOT EXISTS alerts (
id SERIAL PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
alert_type VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
old_value NUMERIC,
new_value NUMERIC,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_alerts_company
ON alerts(company_name)
""")
self.conn.commit()
@staticmethod
@@ -568,20 +597,45 @@ class DatabaseClient:
self,
status: Optional[str] = None,
limit: int = 10,
cursor: Optional[str] = None,
) -> List[Dict]:
"""List jobs, optionally filtered by status."""
query = "SELECT * FROM jobs"
"""List jobs with optional status filter and cursor-based pagination.
Args:
status: Optional status filter (pending, running, completed, failed).
limit: Maximum number of jobs to return.
cursor: Opaque cursor (``created_at|job_id``) from a previous
response. When provided, only jobs older than the cursor are
returned.
Returns:
List of job dicts ordered by created_at descending.
"""
conditions: list[str] = []
params: list = []
if status:
query += " WHERE status = %s"
conditions.append("status = %s")
params.append(status)
query += " ORDER BY created_at DESC LIMIT %s"
if cursor:
try:
ts_str, cursor_job_id = cursor.rsplit("|", 1)
conditions.append("(created_at, job_id) < (%s, %s)")
params.extend([ts_str, cursor_job_id])
except ValueError:
pass # Ignore malformed cursors; return from start
query = "SELECT * FROM jobs"
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC, job_id DESC LIMIT %s"
params.append(limit)
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def mark_stale_jobs_failed(self) -> int:
"""Mark any jobs in 'running' or 'pending' state as 'failed'.
@@ -803,3 +857,81 @@ class DatabaseClient:
with conn.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM users")
return cursor.fetchone()[0]
# Tracked Companies Methods
def add_tracked_company(self, company_name: str) -> Optional[Dict]:
"""Add a company to the tracking list."""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
try:
cursor.execute(
"INSERT INTO tracked_companies (company_name) VALUES (%s) RETURNING *",
(company_name,),
)
row = cursor.fetchone()
conn.commit()
return dict(row) if row else None
except Exception:
conn.rollback()
return None
def remove_tracked_company(self, company_name: str) -> bool:
"""Remove a company from the tracking list."""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"DELETE FROM tracked_companies WHERE LOWER(company_name) = LOWER(%s)",
(company_name,),
)
conn.commit()
return cursor.rowcount > 0
def list_tracked_companies(self) -> List[Dict]:
"""List all tracked companies."""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT * FROM tracked_companies ORDER BY company_name")
return [dict(row) for row in cursor.fetchall()]
def update_tracked_company(
self, company_name: str, patent_count: int
) -> None:
"""Update the last analysis stats for a tracked company."""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""UPDATE tracked_companies
SET last_patent_count = %s, last_analysis_at = CURRENT_TIMESTAMP
WHERE LOWER(company_name) = LOWER(%s)""",
(patent_count, company_name),
)
conn.commit()
def store_alert(
self,
company_name: str,
alert_type: str,
message: str,
old_value: float | None = None,
new_value: float | None = None,
) -> None:
"""Record an alert for a significant change."""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""INSERT INTO alerts (company_name, alert_type, message, old_value, new_value)
VALUES (%s, %s, %s, %s, %s)""",
(company_name, alert_type, message, old_value, new_value),
)
conn.commit()
def list_alerts(self, limit: int = 50) -> List[Dict]:
"""List recent alerts."""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"SELECT * FROM alerts ORDER BY created_at DESC LIMIT %s",
(limit,),
)
return [dict(row) for row in cursor.fetchall()]
+18 -12
View File
@@ -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
+109
View File
@@ -0,0 +1,109 @@
"""Scheduled patent analysis for tracked companies.
Uses APScheduler to periodically re-analyze tracked companies and
detect significant changes in patent counts.
"""
import logging
import os
from SPARC import config
from SPARC.analyzer import CompanyAnalyzer
from SPARC.database import DatabaseClient
logger = logging.getLogger(__name__)
# Configurable via environment variable (in hours, default 24)
SCHEDULE_INTERVAL_HOURS = int(os.getenv("SCHEDULE_INTERVAL_HOURS", "24"))
# Patent count change threshold (percentage) to trigger an alert
CHANGE_THRESHOLD_PERCENT = int(os.getenv("CHANGE_THRESHOLD_PERCENT", "20"))
def run_scheduled_analysis() -> None:
"""Re-analyze all tracked companies and check for significant changes."""
db = DatabaseClient(config.database_url)
db.connect()
db.initialize_schema()
tracked = db.list_tracked_companies()
if not tracked:
logger.info("No tracked companies configured; skipping scheduled analysis")
return
logger.info("Running scheduled analysis for %d tracked companies", len(tracked))
analyzer = CompanyAnalyzer(db_client=db)
for company_row in tracked:
name = company_row["company_name"]
old_count = company_row.get("last_patent_count", 0) or 0
try:
result = analyzer._analyze_company_safe(name)
if result.success:
new_count = result.patent_count
# Update tracking record
db.update_tracked_company(name, new_count)
# Check for significant change
if old_count > 0:
delta_pct = abs(new_count - old_count) / old_count * 100
if delta_pct >= CHANGE_THRESHOLD_PERCENT:
direction = "increased" if new_count > old_count else "decreased"
message = (
f"Patent count for {name} {direction} by {delta_pct:.0f}% "
f"({old_count} -> {new_count})"
)
logger.warning("ALERT: %s", message)
db.store_alert(
company_name=name,
alert_type="patent_count_change",
message=message,
old_value=old_count,
new_value=new_count,
)
elif new_count > 0:
# First analysis -- record baseline
logger.info("Baseline for %s: %d patents", name, new_count)
else:
logger.warning("Scheduled analysis failed for %s: %s", name, result.error)
except Exception as e:
logger.error("Error analyzing tracked company %s: %s", name, e)
db.close()
logger.info("Scheduled analysis complete")
def start_scheduler() -> None:
"""Start the APScheduler background scheduler.
Safe to call at application startup. If apscheduler is not installed,
the function logs a warning and returns without starting anything.
"""
try:
from apscheduler.schedulers.background import BackgroundScheduler
except ImportError:
logger.warning(
"apscheduler not installed; scheduled analysis disabled. "
"Install with: pip install apscheduler"
)
return
scheduler = BackgroundScheduler()
scheduler.add_job(
run_scheduled_analysis,
"interval",
hours=SCHEDULE_INTERVAL_HOURS,
id="scheduled_patent_analysis",
replace_existing=True,
)
scheduler.start()
logger.info(
"Scheduled patent analysis started (every %d hours, threshold %d%%)",
SCHEDULE_INTERVAL_HOURS,
CHANGE_THRESHOLD_PERCENT,
)
+47 -13
View File
@@ -1,4 +1,5 @@
import os
import io
import logging
import re
from datetime import datetime, timedelta
from typing import Dict
@@ -8,8 +9,21 @@ import requests
import serpapi
from SPARC import config
from SPARC.storage import StorageBackend, get_storage_backend
from SPARC.types import Patent, Patents
logger = logging.getLogger(__name__)
# Module-level storage instance (lazy-initialized)
_storage: StorageBackend | None = None
def _get_storage() -> StorageBackend:
global _storage
if _storage is None:
_storage = get_storage_backend()
return _storage
class SERP:
def query(company: str, days_back: int = None) -> Patents:
@@ -44,6 +58,7 @@ class SERP:
"tbs": date_filter,
"api_key": config.api_key,
}
logger.info("Querying Google Patents for '%s' (last %d days)", company, days_back)
search = serpapi.search(params)
# Convert results to Patent objects, skipping any without PDF links
patent_ids = []
@@ -52,13 +67,16 @@ class SERP:
pdf_link = patent.get("pdf")
if pdf_link:
patent_ids.append(Patent(patent_id=patent["publication_number"], pdf_link=pdf_link, summary=None))
# Patents without PDF links are skipped (see docstring for details)
else:
logger.debug("Skipping patent %s (no PDF link)", patent.get("publication_number", "unknown"))
logger.info("Found %d patents with PDF links for '%s'", len(patent_ids), company)
return Patents(patents=patent_ids)
def save_patents(patent: Patent) -> Patent:
"""
Save the patent PDF to the patents folder, skipping download if already cached.
"""Save the patent PDF to storage, skipping download if already cached.
Uses the configured storage backend (local filesystem or S3).
Args:
patent: Patent object
@@ -66,35 +84,51 @@ class SERP:
Returns:
Patent object with updated PDF path
"""
pdf_path = f"patents/{patent.patent_id}.pdf"
os.makedirs("patents", exist_ok=True)
storage = _get_storage()
key = f"{patent.patent_id}.pdf"
if not (os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0):
if not storage.exists(key):
logger.info("Downloading PDF for %s", patent.patent_id)
response = requests.get(patent.pdf_link)
with open(pdf_path, "wb") as f:
f.write(response.content)
storage.write(key, response.content)
logger.debug("Saved %d bytes for %s", len(response.content), patent.patent_id)
else:
logger.debug("Using cached PDF for %s", patent.patent_id)
patent.pdf_path = pdf_path
patent.pdf_path = storage.path_for(key)
return patent
def parse_patent_pdf(pdf_path: str) -> Dict:
"""Extract structured sections from patent PDF.
Extracts all major sections from a patent PDF including abstract,
claims, summary, and detailed description.
claims, summary, and detailed description. Supports both local file
paths and S3 URIs (s3://bucket/key).
Args:
pdf_path: Path to the patent PDF file
pdf_path: Local path or S3 URI to the patent PDF file
Returns:
Dictionary containing all extracted sections
"""
logger.debug("Parsing patent PDF: %s", pdf_path)
with pdfplumber.open(pdf_path) as pdf:
if pdf_path.startswith("s3://"):
# Read from S3 via storage backend
storage = _get_storage()
# Extract key from "s3://bucket/key"
key = pdf_path.split("/", 3)[-1]
data = storage.read(key)
pdf_file: io.BytesIO | str = io.BytesIO(data)
else:
pdf_file = pdf_path
with pdfplumber.open(pdf_file) as pdf:
# Extract all text
full_text = ""
for page in pdf.pages:
full_text += page.extract_text() + "\n"
logger.debug("Extracted text from %d pages (%d chars)", len(pdf.pages), len(full_text))
# Define section patterns (common in patents)
sections = {
+171
View File
@@ -0,0 +1,171 @@
"""Patent PDF storage abstraction.
Provides a unified interface for reading and writing patent PDF files,
with pluggable backends for local filesystem and S3-compatible object
storage (e.g., MinIO, AWS S3).
"""
import logging
import os
from abc import ABC, abstractmethod
from SPARC import config
logger = logging.getLogger(__name__)
class StorageBackend(ABC):
"""Abstract base class for patent PDF storage."""
@abstractmethod
def read(self, key: str) -> bytes:
"""Read a file by key.
Args:
key: Storage key (e.g., "US-12345678-B2.pdf")
Returns:
File contents as bytes.
Raises:
FileNotFoundError: If the file does not exist.
"""
@abstractmethod
def write(self, key: str, data: bytes) -> None:
"""Write data to storage.
Args:
key: Storage key (e.g., "US-12345678-B2.pdf")
data: File contents as bytes.
"""
@abstractmethod
def exists(self, key: str) -> bool:
"""Check if a file exists in storage.
Args:
key: Storage key.
Returns:
True if the file exists and has non-zero size.
"""
@abstractmethod
def path_for(self, key: str) -> str:
"""Return a path or URI suitable for downstream consumers.
For local storage this is a filesystem path; for S3 it is the
object key (callers that need a local file should use read()
and write to a temporary location).
"""
class LocalStorageBackend(StorageBackend):
"""Store patent PDFs on the local filesystem under a directory."""
def __init__(self, base_dir: str = "patents"):
self.base_dir = base_dir
os.makedirs(self.base_dir, exist_ok=True)
def _full_path(self, key: str) -> str:
return os.path.join(self.base_dir, key)
def read(self, key: str) -> bytes:
path = self._full_path(key)
if not os.path.exists(path):
raise FileNotFoundError(f"File not found: {path}")
with open(path, "rb") as f:
return f.read()
def write(self, key: str, data: bytes) -> None:
path = self._full_path(key)
os.makedirs(os.path.dirname(path) or self.base_dir, exist_ok=True)
with open(path, "wb") as f:
f.write(data)
logger.debug("Wrote %d bytes to %s", len(data), path)
def exists(self, key: str) -> bool:
path = self._full_path(key)
return os.path.exists(path) and os.path.getsize(path) > 0
def path_for(self, key: str) -> str:
return self._full_path(key)
class S3StorageBackend(StorageBackend):
"""Store patent PDFs in an S3-compatible bucket."""
def __init__(
self,
bucket: str,
endpoint_url: str = "",
access_key: str = "",
secret_key: str = "",
):
import boto3
kwargs: dict = {}
if endpoint_url:
kwargs["endpoint_url"] = endpoint_url
if access_key and secret_key:
kwargs["aws_access_key_id"] = access_key
kwargs["aws_secret_access_key"] = secret_key
self.s3 = boto3.client("s3", **kwargs)
self.bucket = bucket
# Ensure bucket exists (useful for MinIO local dev)
try:
self.s3.head_bucket(Bucket=self.bucket)
except Exception:
try:
self.s3.create_bucket(Bucket=self.bucket)
logger.info("Created S3 bucket: %s", self.bucket)
except Exception as e:
logger.warning("Could not create bucket %s: %s", self.bucket, e)
def read(self, key: str) -> bytes:
try:
response = self.s3.get_object(Bucket=self.bucket, Key=key)
return response["Body"].read()
except self.s3.exceptions.NoSuchKey:
raise FileNotFoundError(f"S3 object not found: s3://{self.bucket}/{key}")
except Exception as e:
if "NoSuchKey" in str(e) or "404" in str(e):
raise FileNotFoundError(f"S3 object not found: s3://{self.bucket}/{key}")
raise
def write(self, key: str, data: bytes) -> None:
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=data,
ContentType="application/pdf",
)
logger.debug("Wrote %d bytes to s3://%s/%s", len(data), self.bucket, key)
def exists(self, key: str) -> bool:
try:
response = self.s3.head_object(Bucket=self.bucket, Key=key)
return response["ContentLength"] > 0
except Exception:
return False
def path_for(self, key: str) -> str:
return f"s3://{self.bucket}/{key}"
def get_storage_backend() -> StorageBackend:
"""Factory: return the configured storage backend instance."""
backend = config.storage_backend.lower()
if backend == "s3":
logger.info("Using S3 storage backend (bucket=%s)", config.s3_bucket)
return S3StorageBackend(
bucket=config.s3_bucket,
endpoint_url=config.s3_endpoint_url,
access_key=config.s3_access_key,
secret_key=config.s3_secret_key,
)
logger.info("Using local storage backend")
return LocalStorageBackend()
+1
View File
@@ -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)
+139
View File
@@ -0,0 +1,139 @@
"""Webhook notifications for job completion and alert events.
Sends JSON payloads to configured webhook URLs with retry logic.
Supports generic HTTP POST and Slack-compatible text payloads.
"""
import logging
import os
import time
from datetime import datetime
from typing import Any
import requests
logger = logging.getLogger(__name__)
# Comma-separated list of webhook URLs (env var based config)
_WEBHOOK_URLS_RAW = os.getenv("WEBHOOK_URLS", "")
WEBHOOK_URLS: list[str] = [
url.strip() for url in _WEBHOOK_URLS_RAW.split(",") if url.strip()
]
MAX_RETRIES = 3
BACKOFF_BASE = 2 # seconds
def _is_slack_url(url: str) -> bool:
"""Check if a URL looks like a Slack incoming webhook."""
return "hooks.slack.com" in url or "discord.com/api/webhooks" in url
def _build_payload(event_type: str, data: dict[str, Any], slack: bool = False) -> dict:
"""Build the webhook payload.
Args:
event_type: Type of event (e.g., "job_completed", "alert")
data: Event-specific data
slack: If True, wrap in Slack-compatible ``text`` format
Returns:
JSON-serializable payload dict
"""
payload = {
"event": event_type,
"timestamp": datetime.utcnow().isoformat() + "Z",
**data,
}
if slack:
# Build a human-readable summary for Slack/Discord
lines = [f"*[SPARC] {event_type}*"]
for key, value in data.items():
lines.append(f" {key}: {value}")
return {"text": "\n".join(lines)}
return payload
def _send_with_retry(url: str, payload: dict) -> bool:
"""Send a POST request with exponential backoff retry.
Args:
url: Webhook URL
payload: JSON payload to send
Returns:
True if delivered successfully, False after all retries exhausted
"""
for attempt in range(1, MAX_RETRIES + 1):
try:
response = requests.post(url, json=payload, timeout=10)
if response.status_code < 300:
logger.debug("Webhook delivered to %s (attempt %d)", url, attempt)
return True
logger.warning(
"Webhook %s returned %d (attempt %d/%d)",
url, response.status_code, attempt, MAX_RETRIES,
)
except requests.RequestException as e:
logger.warning(
"Webhook delivery failed for %s (attempt %d/%d): %s",
url, attempt, MAX_RETRIES, e,
)
if attempt < MAX_RETRIES:
wait = BACKOFF_BASE ** attempt
time.sleep(wait)
logger.error("Webhook permanently failed for %s after %d attempts", url, MAX_RETRIES)
return False
def notify(event_type: str, data: dict[str, Any]) -> None:
"""Fire all configured webhooks for an event.
Safe to call even when no webhooks are configured (returns immediately).
Args:
event_type: Event identifier (e.g., "job_completed", "patent_alert")
data: Event data to include in the payload
"""
if not WEBHOOK_URLS:
return
for url in WEBHOOK_URLS:
slack = _is_slack_url(url)
payload = _build_payload(event_type, data, slack=slack)
_send_with_retry(url, payload)
def notify_job_completed(
job_id: str,
status: str,
total_companies: int,
successful: int,
failed: int,
) -> None:
"""Send notification when a batch job completes."""
notify("job_completed", {
"job_id": job_id,
"status": status,
"total_companies": total_companies,
"successful": successful,
"failed": failed,
"summary": f"Batch job {job_id}: {successful}/{total_companies} succeeded",
})
def notify_alert(
company_name: str,
alert_type: str,
message: str,
) -> None:
"""Send notification for a tracked company alert."""
notify("patent_alert", {
"company_name": company_name,
"alert_type": alert_type,
"message": message,
})
+24
View File
@@ -52,6 +52,29 @@ services:
- ./patents:/app/patents
restart: unless-stopped
# Optional: MinIO for S3-compatible local object storage
# Enable by setting STORAGE_BACKEND=s3 in .env
minio:
image: minio/minio:latest
container_name: sparc-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
profiles:
- s3
dashboard:
build: ./frontend
container_name: sparc-dashboard
@@ -63,3 +86,4 @@ services:
volumes:
postgres_data:
minio_data:
+9
View File
@@ -7,6 +7,15 @@
<title>SPARC Dashboard</title>
</head>
<body>
<script>
// Prevent FOUC: apply saved theme before first render
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+4 -4
View File
@@ -10,7 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.2",
"lucide-react": "^0.400.0",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
@@ -3452,9 +3452,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.400.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
"integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+5 -1
View File
@@ -7,12 +7,15 @@
"dev": "vite",
"build": "tsc -b && vite build",
"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",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.2",
"lucide-react": "^0.400.0",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
@@ -30,6 +33,7 @@
"globals": "^15.8.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"openapi-typescript": "^7.0.0",
"typescript": "~5.5.3",
"typescript-eslint": "^8.0.0",
"vite": "^5.3.3"
+5
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { Layout } from './components/Layout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Login } from './pages/Login';
@@ -10,6 +11,7 @@ import { Batch } from './pages/Batch';
import { AnalyticsPage } from './pages/Analytics';
import { About } from './pages/About';
import { AdminUsers } from './pages/AdminUsers';
import { Compare } from './pages/Compare';
const queryClient = new QueryClient({
defaultOptions: {
@@ -22,6 +24,7 @@ const queryClient = new QueryClient({
function App() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
@@ -41,6 +44,7 @@ function App() {
<Route path="/analysis" element={<Analysis />} />
<Route path="/batch" element={<Batch />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/compare" element={<Compare />} />
<Route path="/about" element={<About />} />
{/* Admin routes */}
@@ -61,6 +65,7 @@ function App() {
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
);
}
+43
View File
@@ -126,12 +126,55 @@ export const analysisApi = {
},
};
// Export API
export const exportApi = {
exportCsv: async (companyName: string): Promise<void> => {
const response = await api.get(`/export/${encodeURIComponent(companyName)}`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `sparc_${companyName.toLowerCase().replace(/\s+/g, '_')}_export.csv`);
document.body.appendChild(link);
link.click();
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
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 = {
getAnalytics: async (days = 30): Promise<Analytics> => {
const response = await api.get<Analytics>(`/analytics?days=${days}`);
return response.data;
},
getTrends: async (days = 90): Promise<TrendData> => {
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
return response.data;
},
};
// Admin API
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,9 +1,11 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react';
export function Layout() {
const { user, isAdmin, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const handleLogout = () => {
@@ -15,6 +17,7 @@ export function Layout() {
{ to: '/analysis', icon: Search, label: 'Analysis' },
{ to: '/batch', icon: Layers, label: 'Batch' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
{ to: '/about', icon: Info, label: 'About' },
];
@@ -23,7 +26,7 @@ export function Layout() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950">
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 dark:to-indigo-950">
{/* Header */}
<header className="bg-bg-card/80 backdrop-blur-lg border-b border-primary/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -63,6 +66,13 @@ export function Layout() {
{/* User menu */}
<div className="flex items-center gap-4">
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-card-hover transition-all"
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="text-right hidden sm:block">
<div className="text-sm font-medium text-text-primary">{user?.email}</div>
<div className="text-xs text-text-secondary capitalize">{user?.role}</div>
+1 -1
View File
@@ -12,7 +12,7 @@ export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRout
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center">
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 dark:to-indigo-950 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
+48
View File
@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function getInitialTheme(): Theme {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+22 -2
View File
@@ -2,6 +2,26 @@
@tailwind components;
@tailwind utilities;
/* Light mode (default) */
:root {
--color-bg-dark: #f1f5f9;
--color-bg-card: #ffffff;
--color-bg-card-hover: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-border: #cbd5e1;
}
/* Dark mode */
.dark {
--color-bg-dark: #0f172a;
--color-bg-card: #1e293b;
--color-bg-card-hover: #334155;
--color-text-primary: #f8fafc;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
@@ -15,7 +35,7 @@ body {
}
::-webkit-scrollbar-track {
background: #1e293b;
background: var(--color-bg-card);
}
::-webkit-scrollbar-thumb {
@@ -30,5 +50,5 @@ body {
/* Selection */
::selection {
background: rgba(99, 102, 241, 0.3);
color: #f8fafc;
color: var(--color-text-primary);
}
+23 -5
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { analysisApi } from '../api/client';
import { Search, CheckCircle, AlertCircle, Clock, FileText } from 'lucide-react';
import { analysisApi, exportApi } from '../api/client';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react';
import type { CompanyAnalysis } from '../types';
export function Analysis() {
@@ -106,9 +106,27 @@ export function Analysis() {
{/* Analysis Content */}
{result.success && result.analysis && (
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
AI Analysis Results
</h3>
<div className="flex items-center justify-between border-b-2 border-primary/30 pb-2 mb-4">
<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"
>
<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">
{result.analysis}
+149 -10
View File
@@ -2,22 +2,50 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '../api/client';
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'];
export function AnalyticsPage() {
const [days, setDays] = useState(30);
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['analytics', days],
queryFn: () => analyticsApi.getAnalytics(days),
});
const trendsQuery = useQuery({
queryKey: ['analytics-trends', days],
queryFn: () => analyticsApi.getTrends(days),
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Analytics Dashboard
</h2>
<p className="text-text-secondary">Loading analytics data...</p>
</div>
{/* Skeleton cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-5 text-center animate-pulse">
<div className="h-9 w-16 bg-primary/20 rounded mx-auto mb-2" />
<div className="h-4 w-24 bg-primary/10 rounded mx-auto" />
</div>
))}
</div>
{/* Skeleton charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[1, 2].map((i) => (
<div key={i} className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 animate-pulse">
<div className="h-5 w-40 bg-primary/20 rounded mb-4" />
<div className="h-[300px] bg-primary/5 rounded" />
</div>
))}
</div>
</div>
);
}
@@ -33,15 +61,18 @@ export function AnalyticsPage() {
<div className="bg-gradient-to-br from-primary/10 to-secondary/5 border border-primary/20 rounded-xl p-6">
<div className="flex items-center gap-3 text-warning mb-2">
<Database size={24} />
<span className="font-semibold">Database Not Connected</span>
<span className="font-semibold">Unable to Load Analytics</span>
</div>
<p className="text-text-secondary">
Set <code className="bg-bg-card px-2 py-1 rounded">USE_DATABASE=true</code> in your .env file to enable analytics tracking.
Could not connect to the analytics database. Ensure PostgreSQL is running and
<code className="bg-bg-card px-2 py-1 rounded mx-1">DATABASE_URL</code> is configured correctly.
</p>
</div>
<div className="flex items-center gap-2 bg-secondary/10 border border-secondary/20 text-secondary rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Analytics features require storing analysis results in PostgreSQL for historical tracking.</span>
<button
onClick={() => refetch()}
className="mt-3 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-4 py-2 rounded-lg transition-colors"
>
Retry
</button>
</div>
</div>
);
@@ -163,6 +194,114 @@ export function AnalyticsPage() {
</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>
);
}
+15 -3
View File
@@ -114,9 +114,21 @@ export function Batch() {
{/* Error */}
{mutation.isError && (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Batch analysis failed. Please try again.</span>
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-error">
<AlertCircle size={18} />
<span className="font-semibold">Batch analysis failed</span>
</div>
<p className="text-text-secondary text-sm mt-1 ml-7">
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
{' '}Check your connection and try again.
</p>
<button
onClick={() => mutation.reset()}
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
>
Dismiss
</button>
</div>
)}
+161
View File
@@ -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>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export function Login() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center px-4">
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 dark:to-indigo-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Brand */}
<div className="text-center mb-8">
+1 -1
View File
@@ -40,7 +40,7 @@ export function Register() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center px-4">
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 dark:to-indigo-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Brand */}
<div className="text-center mb-8">
+7 -6
View File
@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
@@ -16,15 +17,15 @@ export default {
warning: '#f59e0b',
error: '#ef4444',
bg: {
dark: '#0f172a',
card: '#1e293b',
'card-hover': '#334155',
dark: 'var(--color-bg-dark)',
card: 'var(--color-bg-card)',
'card-hover': 'var(--color-bg-card-hover)',
},
text: {
primary: '#f8fafc',
secondary: '#94a3b8',
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
},
border: '#334155',
border: 'var(--color-border)',
},
},
},
+3
View File
@@ -15,3 +15,6 @@ pandas
bcrypt
PyJWT
slowapi
apscheduler
boto3
reportlab