diff --git a/.env.example b/.env.example
index acf4901..788f953 100644
--- a/.env.example
+++ b/.env.example
@@ -1,21 +1,75 @@
# SPARC Configuration
+# ---- Application Environment ----
+# Set to "production" or "staging" in deployed environments.
+# The API will refuse to start with the default JWT secret unless APP_ENV=development.
+APP_ENV=development
+
+# ---- API Keys ----
+
# SerpAPI key for patent search
API_KEY=your_serpapi_key_here
# OpenRouter API key for LLM analysis
OPENROUTER_API_KEY=your_openrouter_key_here
-# Database configuration
-# All messages are stored in the database for persistence and caching
-DATABASE_URL=postgresql://postgres:postgres@localhost:5432/sparc
+# ---- Database ----
-# Cache configuration
-# 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)
-# Default: true
-USE_CACHE=true
+# PostgreSQL credentials (used by docker-compose)
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=change-me-to-a-secure-password
+POSTGRES_DB=sparc
-# JWT Secret for authentication
+# Full database URL (must match the credentials above)
+DATABASE_URL=postgresql://postgres:change-me-to-a-secure-password@localhost:5432/sparc
+
+# ---- Authentication ----
+
+# JWT Secret for signing tokens
# IMPORTANT: Change this to a secure random string in production
JWT_SECRET=your-secure-jwt-secret-change-in-production
+
+# ---- CORS ----
+
+# Comma-separated list of allowed origins for CORS
+# 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
diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
index f13e13a..beb2354 100644
--- a/.gitea/workflows/build.yaml
+++ b/.gitea/workflows/build.yaml
@@ -9,7 +9,57 @@ on:
workflow_dispatch:
jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install system dependencies
+ shell: sh
+ run: |
+ apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
+
+ - name: Checkout code
+ shell: sh
+ run: |
+ git clone http://gitea.gitea.svc.cluster.local/${{ gitea.repository }}.git .
+ git checkout ${{ gitea.sha }}
+
+ - name: Install Python dependencies
+ shell: sh
+ run: |
+ pip3 install --break-system-packages -r requirements.txt ruff
+
+ - name: Run ruff linter
+ shell: sh
+ run: |
+ 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
+ npm run generate:local
+ if ! git diff --quiet src/api/schema.d.ts; then
+ echo "ERROR: src/api/schema.d.ts is out of date. Run 'npm run generate:local' and commit the result."
+ git diff src/api/schema.d.ts
+ exit 1
+ fi
+ npx tsc --noEmit
+
+ - name: Run pytest
+ shell: sh
+ env:
+ DATABASE_URL: "sqlite://"
+ API_KEY: "test-key"
+ OPENROUTER_API_KEY: "test-key"
+ JWT_SECRET: "test-secret-for-ci"
+ APP_ENV: "development"
+ run: |
+ python3 -m pytest tests/ -v --tb=short -x
+
build-api:
+ needs: test
runs-on: ubuntu-latest
steps:
- name: Install dependencies
@@ -81,6 +131,7 @@ jobs:
echo "API image available at ${{ steps.tags.outputs.IMAGE_TAG }}"
build-frontend:
+ needs: test
runs-on: ubuntu-latest
steps:
- name: Install dependencies
diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml
new file mode 100644
index 0000000..71173d3
--- /dev/null
+++ b/.gitea/workflows/test.yaml
@@ -0,0 +1,67 @@
+name: Test and Lint
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install system dependencies
+ shell: sh
+ run: |
+ apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
+
+ - name: Checkout code
+ shell: sh
+ run: |
+ git clone http://gitea.gitea.svc.cluster.local/${{ gitea.repository }}.git .
+ git checkout ${{ gitea.sha }}
+
+ - name: Install Python dependencies
+ shell: sh
+ run: |
+ pip3 install --break-system-packages -r requirements.txt ruff
+
+ - name: Run ruff linter
+ shell: sh
+ 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: Verify generated API types are up to date
+ shell: sh
+ run: |
+ cd frontend && npm run generate:local
+ if ! git diff --quiet src/api/schema.d.ts; then
+ echo "ERROR: src/api/schema.d.ts is out of date. Run 'npm run generate:local' and commit the result."
+ git diff src/api/schema.d.ts
+ exit 1
+ fi
+
+ - name: Run TypeScript type check
+ shell: sh
+ run: |
+ cd frontend && npx tsc --noEmit
+
+ - name: Run pytest
+ shell: sh
+ env:
+ DATABASE_URL: "sqlite://"
+ API_KEY: "test-key"
+ OPENROUTER_API_KEY: "test-key"
+ JWT_SECRET: "test-secret-for-ci"
+ APP_ENV: "development"
+ run: |
+ python3 -m pytest tests/ -v --tb=short -x
diff --git a/README.md b/README.md
index c9b0b10..46b26c7 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,21 @@ docker-compose up -d
# - API Docs: http://localhost:8000/docs
```
+#### Patent PDF Storage
+
+The API stores downloaded patent PDFs in a `patents/` directory. In Docker,
+this is mounted as a bind mount (`./patents:/app/patents`) so that PDFs persist
+across container restarts.
+
+If you deploy to a different environment, ensure the `patents/` directory is a
+persistent volume. Without it, PDFs will be re-downloaded on every analysis.
+
+```yaml
+# docker-compose.yml excerpt
+volumes:
+ - ./patents:/app/patents
+```
+
### NixOS
```bash
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..42b571a
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,122 @@
+# SPARC Roadmap
+
+Semiconductor Patent & Analytics Report Core -- development priorities.
+
+## Current State
+
+SPARC is a patent analysis platform with a working end-to-end pipeline:
+Python/FastAPI backend, React/TypeScript frontend, PostgreSQL for persistence
+and caching, Docker Compose for local development, and Gitea Actions CI/CD for
+image builds. Core features (patent retrieval via SerpAPI, PDF parsing, LLM
+analysis via OpenRouter/Claude, batch processing, JWT authentication, analytics
+dashboard) are all implemented and functional.
+
+---
+
+## P1 -- High Priority
+
+These items address correctness, security, and reliability gaps that should be
+resolved before broader production use.
+
+### Security hardening
+
+- **Rotate default JWT secret.** `auth.py` ships a fallback
+ `sparc-secret-key-change-in-production` that will be used if `JWT_SECRET` is
+ unset. Add a startup check that refuses to start with the default secret in
+ non-development environments.
+- **CORS allow-origins are hardcoded.** `api.py` only permits
+ `localhost:3000` and `localhost:5173`. Make the allowed origins configurable
+ via environment variable so the dashboard works when deployed behind a real
+ domain.
+- **Database credentials in docker-compose.yml.** The compose file embeds
+ `postgres:postgres` in plain text. Reference a `.env` file or Docker secrets
+ instead.
+
+### Error handling and resilience
+
+- **`get_db_client()` in `auth.py` creates a new `DatabaseClient` on every
+ call.** This bypasses the connection pool and can exhaust database
+ connections under load. Refactor to share a single pooled client.
+- **`_jobs` dict is in-memory only.** Job state is lost on API restart. Persist
+ job status in PostgreSQL or Redis so async batch results survive restarts.
+- **No rate limiting on auth endpoints.** `/auth/login` and `/auth/register`
+ are unprotected against brute-force or abuse. Add rate limiting middleware.
+
+### Test coverage for auth and admin
+
+- The existing API tests (`tests/test_api.py`) bypass authentication entirely.
+ Add tests that exercise the JWT flow: registration, login, protected-route
+ access, token refresh, and admin-only endpoints.
+
+---
+
+## P2 -- Medium Priority
+
+Improvements to usability, performance, and developer experience.
+
+### Backend
+
+- **Add structured logging.** Replace `print()` calls throughout `analyzer.py`,
+ `serp_api.py`, and `llm.py` with Python `logging` so log levels and
+ formatting are consistent.
+- **Make LLM model configurable.** `llm.py` hardcodes
+ `anthropic/claude-3.5-sonnet`. Accept a `MODEL` environment variable to allow
+ switching models without code changes.
+- **SERP cache TTL is hardcoded to 24 hours.** Expose `SERP_CACHE_TTL_HOURS`
+ as an environment variable in `config.py`.
+- **Patent PDF storage.** PDFs are saved to a local `patents/` directory. For
+ containerized deployments, consider object storage (S3/MinIO) or at minimum
+ document the volume mount requirement more prominently.
+- **`analyze_single_patent` assumes local file path.** The method constructs
+ `patents/{patent_id}.pdf` and reads from disk, but does not download the PDF
+ first. Either integrate the download step or document the prerequisite.
+- **`Patent.patent_id` typed as `int` in `types.py` but used as `str`
+ everywhere.** Fix the type annotation to `str`.
+
+### Frontend
+
+- **No loading/error states on several pages.** The Batch and Analytics pages
+ would benefit from skeleton loaders and user-friendly error messages.
+- **No dark mode.** Tailwind is configured but no dark variant is applied.
+- **Missing `package-lock.json` or `pnpm-lock.yaml`.** The frontend has no
+ lockfile committed, leading to non-reproducible builds.
+
+### CI/CD
+
+- **No test stage in the Gitea Actions workflow.** `build.yaml` builds and
+ pushes images but never runs `pytest`. Add a test job that gates the build.
+- **No linting or type checking.** Add `ruff` (Python) and `tsc --noEmit`
+ (TypeScript) to CI.
+
+---
+
+## P3 -- Nice to Have
+
+Lower-urgency enhancements and future features.
+
+- **Export analysis reports.** Allow users to download analysis results as PDF
+ or CSV from the dashboard.
+- **Comparison view.** Side-by-side comparison of two companies' patent
+ portfolios.
+- **Scheduled/recurring analysis.** Periodically re-analyze tracked companies
+ and alert on significant changes.
+- **Webhook/notification support.** Send alerts (Slack, Discord, email) when
+ batch jobs complete or when a company's innovation score changes
+ significantly.
+- **Multi-model support.** Let users choose between LLM providers per analysis
+ (e.g., GPT-4o, Gemini, Claude) and compare outputs.
+- **Patent trend charts.** Visualize patent filing frequency and technology
+ category distribution over time in the Analytics page.
+- **API pagination.** The `/analyze/batch` and `/jobs` endpoints could benefit
+ from cursor-based pagination for large result sets.
+- **OpenAPI client generation.** Auto-generate the TypeScript API client from
+ the FastAPI OpenAPI spec to keep frontend types in sync.
+
+---
+
+## Infrastructure and Deployment
+
+Kubernetes manifests, Helm charts, and cluster-level concerns (MetalLB,
+storage, FluxCD sync) are tracked in the
+[Talos](https://10.0.1.10/leeworks-agents/Talos) repository. File
+infrastructure-related issues there, not here.
diff --git a/SPARC/__init__.py b/SPARC/__init__.py
index 9d594cd..687d563 100644
--- a/SPARC/__init__.py
+++ b/SPARC/__init__.py
@@ -1,3 +1,4 @@
-from .types import Patents, Patent
+from .types import Patent as Patent
+from .types import Patents as Patents
-all = ["Patents", "Patent"]
+__all__ = ["Patents", "Patent"]
diff --git a/SPARC/analyzer.py b/SPARC/analyzer.py
index 7f61283..31ad7f1 100644
--- a/SPARC/analyzer.py
+++ b/SPARC/analyzer.py
@@ -5,14 +5,17 @@ to provide company performance estimation based on patent portfolios.
"""
import hashlib
+import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable
from SPARC import config
+
+logger = logging.getLogger(__name__)
from SPARC.database import DatabaseClient
-from SPARC.serp_api import SERP
from SPARC.llm import LLMAnalyzer
-from SPARC.types import Patent, Patents, CompanyAnalysisResult, BatchAnalysisResult
+from SPARC.serp_api import SERP
+from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
class CompanyAnalyzer:
@@ -30,7 +33,7 @@ class CompanyAnalyzer:
self.db.connect()
self.db.initialize_schema()
- def analyze_company(self, company_name: str, patents: "Patents | None" = None) -> str:
+ def analyze_company(self, company_name: str, patents: "Patents | None" = None, model: str | None = None) -> str:
"""Analyze a company's performance based on their patent portfolio.
This is the main entry point that orchestrates the full pipeline:
@@ -43,6 +46,7 @@ class CompanyAnalyzer:
Args:
company_name: Name of the company to analyze
patents: Optional pre-fetched Patents result to avoid duplicate API calls
+ model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns:
Comprehensive analysis of company's innovation and performance outlook
@@ -52,13 +56,13 @@ class CompanyAnalyzer:
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
cached_ids = self.db.get_cached_serp_query(query_hash)
if cached_ids is not None:
- print(f"Using cached SERP results for {company_name} ({len(cached_ids)} patents)")
+ logger.info("Using cached SERP results for %s (%d patents)", company_name, len(cached_ids))
patents = Patents(patents=[
Patent(patent_id=pid, pdf_link="")
for pid in cached_ids
])
else:
- print(f"Retrieving patents for {company_name}...")
+ logger.info("Retrieving patents for %s...", company_name)
patents = SERP.query(company_name)
# Cache the SERP results
if patents.patents:
@@ -66,12 +70,13 @@ class CompanyAnalyzer:
company_name=company_name,
query_hash=query_hash,
patent_ids=[p.patent_id for p in patents.patents],
+ ttl_hours=config.serp_cache_ttl_hours,
)
if not patents.patents:
return f"No patents found for {company_name}"
- print(f"Found {len(patents.patents)} patents. Processing...")
+ logger.info("Found %d patents. Processing...", len(patents.patents))
# Download, parse, and minimize patents in parallel
processed_patents = []
@@ -87,48 +92,74 @@ class CompanyAnalyzer:
if result:
processed_patents.append(result)
except Exception as e:
- print(f"Warning: Failed to process {patent.patent_id}: {e}")
+ logger.warning("Failed to process %s: %s", patent.patent_id, e)
if not processed_patents:
return f"Failed to process any patents for {company_name}"
- print(f"Analyzing portfolio with LLM...")
+ logger.info("Analyzing portfolio with LLM...")
# Analyze the full portfolio with LLM
analysis = self.llm_analyzer.analyze_patent_portfolio(
- patents_data=processed_patents, company_name=company_name
+ patents_data=processed_patents, company_name=company_name, model=model
)
return analysis
- def analyze_single_patent(self, patent_id: str, company_name: str) -> str:
+ def analyze_single_patent(self, patent_id: str, company_name: str, model: str | None = None) -> str:
"""Analyze a single patent by ID.
- Useful for focused analysis of specific innovations.
+ 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
+ patent_id: Publication ID of the patent (e.g. "US-11234567-B2")
company_name: Name of the company (for context)
+ model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns:
Analysis of the specific patent's innovation quality
+
+ Raises:
+ FileNotFoundError: If the patent PDF cannot be found or downloaded.
"""
- # Note: This simplified version assumes the patent PDF is already downloaded
- # A more complete implementation would support direct patent ID lookup
- print(f"Analyzing patent {patent_id} for {company_name}...")
+ import os
+ logger.info("Analyzing patent %s for %s...", patent_id, company_name)
patent_path = f"patents/{patent_id}.pdf"
+ if not os.path.exists(patent_path):
+ # 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)
minimized_content = SERP.minimize_patent_for_llm(sections)
analysis = self.llm_analyzer.analyze_patent_content(
- patent_content=minimized_content, company_name=company_name
+ patent_content=minimized_content, company_name=company_name, model=model
)
return analysis
+ except FileNotFoundError:
+ raise
except Exception as e:
return f"Failed to analyze patent {patent_id}: {e}"
@@ -169,21 +200,22 @@ class CompanyAnalyzer:
return {"patent_id": patent.patent_id, "content": minimized_content}
except Exception as e:
- print(f"Warning: Failed to process {patent.patent_id}: {e}")
+ logger.warning("Failed to process %s: %s", patent.patent_id, e)
return None
- def _analyze_company_safe(self, company_name: str) -> CompanyAnalysisResult:
+ def _analyze_company_safe(self, company_name: str, model: str | None = None) -> CompanyAnalysisResult:
"""Internal wrapper that catches exceptions and returns structured result.
Args:
company_name: Name of the company to analyze
+ model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns:
CompanyAnalysisResult with success/failure status
"""
try:
# Delegate to analyze_company which handles SERP/patent caching
- analysis = self.analyze_company(company_name)
+ analysis = self.analyze_company(company_name, model=model)
# Determine patent count from cached SERP query
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
@@ -223,6 +255,7 @@ class CompanyAnalyzer:
companies: list[str],
max_workers: int = 3,
progress_callback: Callable[[str, int, int], None] | None = None,
+ model: str | None = None,
) -> BatchAnalysisResult:
"""Analyze multiple companies' patent portfolios in batch.
@@ -233,6 +266,7 @@ class CompanyAnalyzer:
companies: List of company names to analyze
max_workers: Maximum concurrent analyses (default 3 to avoid rate limits)
progress_callback: Optional callback(company_name, completed, total)
+ model: Optional LLM model override (e.g. 'openai/gpt-4o')
Returns:
BatchAnalysisResult containing all individual results and summary stats
@@ -240,11 +274,11 @@ class CompanyAnalyzer:
results: list[CompanyAnalysisResult] = []
total = len(companies)
- print(f"Starting batch analysis of {total} companies...")
+ logger.info("Starting batch analysis of %d companies...", total)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_company = {
- executor.submit(self._analyze_company_safe, company): company
+ executor.submit(self._analyze_company_safe, company, model): company
for company in companies
}
@@ -257,8 +291,8 @@ class CompanyAnalyzer:
result = future.result()
results.append(result)
- status = "✓" if result.success else "✗"
- print(f"[{completed}/{total}] {status} {company}")
+ status = "OK" if result.success else "FAIL"
+ logger.info("[%d/%d] %s %s", completed, total, status, company)
if progress_callback:
progress_callback(company, completed, total)
@@ -273,12 +307,12 @@ class CompanyAnalyzer:
error=str(e),
)
)
- print(f"[{completed}/{total}] ✗ {company}: {e}")
+ logger.error("[%d/%d] FAIL %s: %s", completed, total, company, e)
successful = sum(1 for r in results if r.success)
failed = total - successful
- print(f"\nBatch complete: {successful} succeeded, {failed} failed")
+ logger.info("Batch complete: %d succeeded, %d failed", successful, failed)
return BatchAnalysisResult(
results=results,
@@ -304,20 +338,20 @@ class CompanyAnalyzer:
results: list[CompanyAnalysisResult] = []
total = len(companies)
- print(f"Starting sequential analysis of {total} companies...")
+ logger.info("Starting sequential analysis of %d companies...", total)
for idx, company in enumerate(companies, 1):
- print(f"\n[{idx}/{total}] Analyzing {company}...")
+ logger.info("[%d/%d] Analyzing %s...", idx, total, company)
result = self._analyze_company_safe(company)
results.append(result)
- status = "✓" if result.success else "✗"
- print(f"[{idx}/{total}] {status} {company}")
+ status = "OK" if result.success else "FAIL"
+ logger.info("[%d/%d] %s %s", idx, total, status, company)
successful = sum(1 for r in results if r.success)
failed = total - successful
- print(f"\nBatch complete: {successful} succeeded, {failed} failed")
+ logger.info("Batch complete: %d succeeded, %d failed", successful, failed)
return BatchAnalysisResult(
results=results,
diff --git a/SPARC/api.py b/SPARC/api.py
index 482caab..3a28033 100644
--- a/SPARC/api.py
+++ b/SPARC/api.py
@@ -7,20 +7,27 @@ from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated, List
-from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query
+from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, EmailStr, Field
+from slowapi import Limiter
+from slowapi.errors import RateLimitExceeded
+from slowapi.util import get_remote_address
from SPARC import config
from SPARC.analyzer import CompanyAnalyzer
from SPARC.auth import (
TokenResponse,
UserResponse,
+ check_jwt_secret,
+ close_db_client,
create_tokens,
decode_token,
get_current_admin,
get_current_user,
get_db_client,
+ init_db_client,
)
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
@@ -34,6 +41,7 @@ class CompanyAnalysisResponse(BaseModel):
patent_count: int
success: bool
error: str | None = None
+ model: str | None = None
timestamp: datetime
@@ -47,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."""
@@ -56,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):
@@ -70,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."""
@@ -114,8 +142,7 @@ class AnalyticsResponse(BaseModel):
period_days: int
-# In-memory job storage (for demo; production would use Redis/DB)
-_jobs: dict[str, JobStatus] = {}
+# Job counter for generating unique IDs (the actual state is in PostgreSQL)
_job_counter = 0
@@ -127,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,
)
@@ -148,12 +176,28 @@ _analyzer: CompanyAnalyzer | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Initialize resources on startup."""
+ """Initialize resources on startup, clean up on shutdown."""
global _analyzer
+ check_jwt_secret()
+ init_db_client()
_analyzer = CompanyAnalyzer()
+ # Mark any jobs that were running/pending before the restart as failed
+ from SPARC.database import DatabaseClient
+ _db = DatabaseClient(config.database_url)
+ _db.connect()
+ _db.initialize_schema()
+ stale = _db.mark_stale_jobs_failed()
+ if stale:
+ 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 if needed
+ # Cleanup
_analyzer = None
+ close_db_client()
app = FastAPI(
@@ -164,10 +208,26 @@ app = FastAPI(
root_path=config.root_path,
)
+# Rate limiter (in-memory storage, suitable for single-instance deployments)
+limiter = Limiter(key_func=get_remote_address)
+app.state.limiter = limiter
+
+
+@app.exception_handler(RateLimitExceeded)
+async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
+ """Return 429 with Retry-After header when rate limit is exceeded."""
+ retry_after = getattr(exc, "retry_after", 60)
+ return JSONResponse(
+ status_code=429,
+ content={"detail": "Rate limit exceeded. Please try again later."},
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
# Add CORS middleware for React frontend
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://localhost:3000", "http://localhost:5173"],
+ allow_origins=config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -178,7 +238,8 @@ app.add_middleware(
@app.post("/auth/register", response_model=UserResponse, tags=["Auth"])
-async def register(request: RegisterRequest):
+@limiter.limit("5/minute")
+async def register(request: Request, body: RegisterRequest):
"""Register a new user.
The first registered user automatically becomes an admin.
@@ -190,8 +251,8 @@ async def register(request: RegisterRequest):
role = "admin" if user_count == 0 else "user"
user = db.create_user(
- email=request.email,
- password=request.password,
+ email=body.email,
+ password=body.password,
role=role,
)
@@ -210,11 +271,12 @@ async def register(request: RegisterRequest):
@app.post("/auth/login", response_model=TokenResponse, tags=["Auth"])
-async def login(request: LoginRequest):
+@limiter.limit("10/minute")
+async def login(request: Request, body: LoginRequest):
"""Authenticate user and return JWT tokens."""
db = get_db_client()
- user = db.authenticate_user(request.email, request.password)
+ user = db.authenticate_user(body.email, body.password)
if not user:
raise HTTPException(
@@ -332,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 ==============
@@ -352,6 +468,331 @@ 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"},
+]
+
+_SUPPORTED_MODEL_IDS = {m["id"] for m in SUPPORTED_MODELS}
+
+
+def _validate_model(model: str | None) -> None:
+ """Raise HTTP 400 if *model* is not in the supported allow-list."""
+ if model is not None and model not in _SUPPORTED_MODEL_IDS:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Unsupported model '{model}'. "
+ f"Supported models: {', '.join(sorted(_SUPPORTED_MODEL_IDS))}"
+ ),
+ )
+
+
+@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"Performed: {ts_str}", body_style)
+ )
+
+ # Wrap long response text into paragraphs, escaping XML special chars
+ safe_response = (
+ response.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ )
+ # Split into manageable paragraphs to avoid overflow
+ for line in safe_response.split("\n"):
+ if line.strip():
+ elements.append(Paragraph(line, body_style))
+ else:
+ elements.append(Spacer(1, 4))
+
+ elements.append(Spacer(1, 10))
+
+ doc.build(elements)
+ buffer.seek(0)
+
+ safe_name = company_name.replace(" ", "_").lower()
+ filename = f"{safe_name}-analysis-{analysis_date}.pdf"
+ return StreamingResponse(
+ iter([buffer.getvalue()]),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
# ============== System Endpoints ==============
@@ -372,6 +813,7 @@ async def health_check():
)
async def analyze_company(
company_name: str,
+ model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."),
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single company's patent portfolio.
@@ -381,17 +823,51 @@ async def analyze_company(
Args:
company_name: Name of the company to analyze (e.g., "nvidia", "intel")
+ model: Optional LLM model override
Returns:
Analysis results including patent count, AI insights, and success status
"""
+ _validate_model(model)
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
- result = _analyzer._analyze_company_safe(company_name)
+ result = _analyzer._analyze_company_safe(company_name, model=model)
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,
@@ -412,43 +888,98 @@ async def analyze_companies_batch(
Returns:
Batch results with individual company analyses and summary statistics
"""
+ _validate_model(request.model)
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
result = _analyzer.analyze_companies(
companies=request.companies,
max_workers=request.max_workers,
+ model=request.model,
)
return _convert_batch_result(result)
-def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
+def _get_job_db() -> "DatabaseClient":
+ """Get a DatabaseClient for job persistence."""
+ from SPARC.database import DatabaseClient
+ db = DatabaseClient(config.database_url)
+ return db
+
+
+def _job_row_to_status(row: dict) -> JobStatus:
+ """Convert a database job row to a JobStatus model."""
+ import json as _json
+ result = None
+ if row.get("result_json"):
+ result_data = row["result_json"]
+ if isinstance(result_data, str):
+ result_data = _json.loads(result_data)
+ result = BatchAnalysisResponse(**result_data)
+ return JobStatus(
+ job_id=row["job_id"],
+ status=row["status"],
+ progress=row["progress"],
+ total_companies=row["total_companies"],
+ completed_companies=row["completed_companies"],
+ result=result,
+ error=row.get("error"),
+ )
+
+
+def _run_batch_job(job_id: str, companies: list[str], max_workers: int, model: str | None = None):
"""Background task for batch analysis."""
- global _jobs, _analyzer
+ import json as _json
+ global _analyzer
+
+ db = _get_job_db()
if not _analyzer:
- _jobs[job_id].status = "failed"
- _jobs[job_id].error = "Analyzer not initialized"
+ db.update_job(job_id, status="failed", error="Analyzer not initialized")
return
- _jobs[job_id].status = "running"
+ db.update_job(job_id, status="running")
def progress_callback(company: str, completed: int, total: int):
- _jobs[job_id].completed_companies = completed
- _jobs[job_id].progress = int((completed / total) * 100)
+ db.update_job(
+ job_id,
+ completed_companies=completed,
+ progress=int((completed / total) * 100),
+ )
try:
result = _analyzer.analyze_companies(
companies=companies,
max_workers=max_workers,
progress_callback=progress_callback,
+ model=model,
+ )
+ batch_response = _convert_batch_result(result)
+ db.update_job(
+ job_id,
+ status="completed",
+ 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,
)
- _jobs[job_id].status = "completed"
- _jobs[job_id].progress = 100
- _jobs[job_id].result = _convert_batch_result(result)
except Exception as e:
- _jobs[job_id].status = "failed"
- _jobs[job_id].error = str(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"])
@@ -468,24 +999,20 @@ async def analyze_companies_async(
Returns:
Job status with job_id for polling
"""
+ _validate_model(request.model)
global _job_counter
_job_counter += 1
job_id = f"job_{_job_counter}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
- _jobs[job_id] = JobStatus(
- job_id=job_id,
- status="pending",
- progress=0,
- total_companies=len(request.companies),
- completed_companies=0,
- )
+ db = _get_job_db()
+ job_row = db.create_job(job_id=job_id, total_companies=len(request.companies))
background_tasks.add_task(
- _run_batch_job, job_id, request.companies, request.max_workers
+ _run_batch_job, job_id, request.companies, request.max_workers, request.model
)
- return _jobs[job_id]
+ return _job_row_to_status(job_row)
@app.get("/jobs/{job_id}", response_model=JobStatus, tags=["Jobs"])
@@ -501,36 +1028,60 @@ async def get_job_status(
Returns:
Current job status including progress and results when complete
"""
- if job_id not in _jobs:
+ db = _get_job_db()
+ job_row = db.get_job(job_id)
+
+ if not job_row:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
- return _jobs[job_id]
+ 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
"""
- jobs = list(_jobs.values())
+ db = _get_job_db()
+ # Fetch one extra to determine if there is a next page
+ job_rows = db.list_jobs(status=status, limit=limit + 1, cursor=cursor)
- if status:
- jobs = [j for j in jobs if j.status == status]
+ has_next = len(job_rows) > limit
+ if has_next:
+ job_rows = job_rows[:limit]
- # Return most recent first
- jobs.sort(key=lambda j: j.job_id, reverse=True)
+ items = [_job_row_to_status(row) for row in job_rows]
- return jobs[:limit]
+ 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)
diff --git a/SPARC/auth.py b/SPARC/auth.py
index 4a5a28f..890d286 100644
--- a/SPARC/auth.py
+++ b/SPARC/auth.py
@@ -13,11 +13,25 @@ from SPARC import config
from SPARC.database import DatabaseClient
# JWT Configuration
-JWT_SECRET = os.getenv("JWT_SECRET", "sparc-secret-key-change-in-production")
+_DEFAULT_JWT_SECRET = "sparc-secret-key-change-in-production"
+JWT_SECRET = os.getenv("JWT_SECRET", _DEFAULT_JWT_SECRET)
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
+
+def check_jwt_secret() -> None:
+ """Refuse to start with the default JWT secret in non-development environments.
+
+ Raises:
+ RuntimeError: If JWT_SECRET is the default value and APP_ENV is not 'development'.
+ """
+ if JWT_SECRET == _DEFAULT_JWT_SECRET and config.app_env != "development":
+ raise RuntimeError(
+ f"FATAL: JWT_SECRET is set to the default value and APP_ENV={config.app_env!r}. "
+ "Set a secure JWT_SECRET environment variable before running in non-development environments."
+ )
+
security = HTTPBearer()
@@ -132,11 +146,36 @@ def decode_token(token: str) -> Optional[TokenPayload]:
return None
+# Shared database client singleton, initialized at startup via init_db_client()
+_db_client: DatabaseClient | None = None
+
+
+def init_db_client() -> None:
+ """Initialize the shared database client. Call once at app startup."""
+ global _db_client
+ _db_client = DatabaseClient(config.database_url)
+ _db_client.connect()
+
+
+def close_db_client() -> None:
+ """Close the shared database client. Call at app shutdown."""
+ global _db_client
+ if _db_client:
+ _db_client.close()
+ _db_client = None
+
+
def get_db_client() -> DatabaseClient:
- """Get database client for auth operations."""
- client = DatabaseClient(config.database_url)
- client.connect()
- return client
+ """Get the shared pooled database client for auth operations.
+
+ Returns the module-level singleton DatabaseClient. If not yet initialized
+ (e.g., during tests), creates a new instance as a fallback.
+ """
+ global _db_client
+ if _db_client is None:
+ _db_client = DatabaseClient(config.database_url)
+ _db_client.connect()
+ return _db_client
async def get_current_user(
diff --git a/SPARC/config.py b/SPARC/config.py
index 31bee7a..4d89742 100644
--- a/SPARC/config.py
+++ b/SPARC/config.py
@@ -2,11 +2,20 @@
Loads environment variables from .env file for API keys and other secrets.
"""
-from dotenv import load_dotenv
+import logging
import os
+from dotenv import load_dotenv
+
load_dotenv()
+# Logging configuration
+log_level = os.getenv("LOG_LEVEL", "INFO").upper()
+logging.basicConfig(
+ level=getattr(logging, log_level, logging.INFO),
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
+)
+
# SerpAPI key for patent search
api_key = os.getenv("API_KEY")
@@ -30,6 +39,32 @@ use_database = os.getenv("USE_DATABASE", "false").lower() in ("true", "1", "yes"
patent_search_days = int(os.getenv("PATENT_SEARCH_DAYS", "90"))
patent_thread_workers = int(os.getenv("PATENT_THREAD_WORKERS", "5"))
+# LLM model to use via OpenRouter (e.g. "anthropic/claude-3.5-sonnet", "openai/gpt-4o")
+model = os.getenv("MODEL", "anthropic/claude-3.5-sonnet")
+
+# SERP cache TTL in hours (how long cached search results are considered fresh)
+serp_cache_ttl_hours = int(os.getenv("SERP_CACHE_TTL_HOURS", "24"))
+
# Root path for running behind a reverse proxy (e.g., "/api" when served at /api/)
# This ensures OpenAPI docs work correctly when accessed via the proxy
root_path = os.getenv("ROOT_PATH", "")
+
+# Application environment: "development", "staging", or "production"
+# 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", "")
+cors_origins: list[str] = (
+ [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
+ if _cors_origins_raw
+ else ["http://localhost:3000", "http://localhost:5173"]
+)
diff --git a/SPARC/database.py b/SPARC/database.py
index 0468312..24c7081 100644
--- a/SPARC/database.py
+++ b/SPARC/database.py
@@ -1,14 +1,15 @@
"""Database client for storing and retrieving LLM messages and user authentication."""
import contextlib
-import psycopg2
-from psycopg2.pool import ThreadedConnectionPool
-from psycopg2.extras import RealDictCursor
-from typing import Dict, List, Optional
-from datetime import datetime, timedelta
-import json
import hashlib
+import json
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional
+
import bcrypt
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from psycopg2.pool import ThreadedConnectionPool
class DatabaseClient:
@@ -171,6 +172,55 @@ class DatabaseClient:
ON serp_queries(query_hash)
""")
+ # Create jobs table for persisting async batch job state
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS jobs (
+ job_id VARCHAR(128) PRIMARY KEY,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ progress INTEGER NOT NULL DEFAULT 0,
+ total_companies INTEGER NOT NULL DEFAULT 0,
+ completed_companies INTEGER NOT NULL DEFAULT 0,
+ result_json JSONB,
+ error TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ cursor.execute("""
+ CREATE INDEX IF NOT EXISTS idx_jobs_status
+ 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
@@ -201,8 +251,6 @@ class DatabaseClient:
Returns:
Cached message dict if found, None otherwise
"""
- self.connect()
-
prompt_hash = self.hash_prompt(prompt)
query = """
@@ -225,10 +273,11 @@ class DatabaseClient:
query += " ORDER BY timestamp DESC LIMIT 1"
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(query, params)
- result = cursor.fetchone()
- return dict(result) if result else None
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(query, params)
+ result = cursor.fetchone()
+ return dict(result) if result else None
def store_message(
self,
@@ -256,33 +305,32 @@ class DatabaseClient:
Returns:
The ID of the inserted record
"""
- self.connect()
-
prompt_hash = self.hash_prompt(prompt)
- with self.conn.cursor() as cursor:
- cursor.execute(
- """
- INSERT INTO llm_messages
- (prompt, prompt_hash, response, company_name, analysis_type, model, metadata, token_usage, is_cached)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING id
- """,
- (
- prompt,
- prompt_hash,
- response,
- company_name,
- analysis_type,
- model,
- json.dumps(metadata) if metadata else None,
- json.dumps(token_usage) if token_usage else None,
- is_cached,
- ),
- )
+ with self.get_conn() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute(
+ """
+ INSERT INTO llm_messages
+ (prompt, prompt_hash, response, company_name, analysis_type, model, metadata, token_usage, is_cached)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id
+ """,
+ (
+ prompt,
+ prompt_hash,
+ response,
+ company_name,
+ analysis_type,
+ model,
+ json.dumps(metadata) if metadata else None,
+ json.dumps(token_usage) if token_usage else None,
+ is_cached,
+ ),
+ )
- message_id = cursor.fetchone()[0]
- self.conn.commit()
+ message_id = cursor.fetchone()[0]
+ conn.commit()
return message_id
@@ -304,8 +352,6 @@ class DatabaseClient:
Returns:
List of message dictionaries
"""
- self.connect()
-
query = "SELECT * FROM llm_messages WHERE 1=1"
params = []
@@ -320,9 +366,10 @@ class DatabaseClient:
query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(query, params)
- return [dict(row) for row in cursor.fetchall()]
+ 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()]
def get_analytics(self, days: int = 30) -> Dict:
"""Get analytics on message usage.
@@ -333,53 +380,52 @@ class DatabaseClient:
Returns:
Dictionary with analytics data
"""
- self.connect()
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ # Total messages
+ cursor.execute(
+ """
+ SELECT COUNT(*) as total_messages
+ FROM llm_messages
+ WHERE timestamp >= NOW() - INTERVAL '%s days'
+ """,
+ (days,),
+ )
+ total = cursor.fetchone()["total_messages"]
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- # Total messages
- cursor.execute(
- """
- SELECT COUNT(*) as total_messages
- FROM llm_messages
- WHERE timestamp >= NOW() - INTERVAL '%s days'
- """,
- (days,),
- )
- total = cursor.fetchone()["total_messages"]
+ # Messages by company
+ cursor.execute(
+ """
+ SELECT company_name, COUNT(*) as count
+ FROM llm_messages
+ WHERE timestamp >= NOW() - INTERVAL '%s days'
+ GROUP BY company_name
+ ORDER BY count DESC
+ LIMIT 10
+ """,
+ (days,),
+ )
+ by_company = cursor.fetchall()
- # Messages by company
- cursor.execute(
- """
- SELECT company_name, COUNT(*) as count
- FROM llm_messages
- WHERE timestamp >= NOW() - INTERVAL '%s days'
- GROUP BY company_name
- ORDER BY count DESC
- LIMIT 10
- """,
- (days,),
- )
- by_company = cursor.fetchall()
+ # Messages by type
+ cursor.execute(
+ """
+ SELECT analysis_type, COUNT(*) as count
+ FROM llm_messages
+ WHERE timestamp >= NOW() - INTERVAL '%s days'
+ GROUP BY analysis_type
+ ORDER BY count DESC
+ """,
+ (days,),
+ )
+ by_type = cursor.fetchall()
- # Messages by type
- cursor.execute(
- """
- SELECT analysis_type, COUNT(*) as count
- FROM llm_messages
- WHERE timestamp >= NOW() - INTERVAL '%s days'
- GROUP BY analysis_type
- ORDER BY count DESC
- """,
- (days,),
- )
- by_type = cursor.fetchall()
-
- return {
- "total_messages": total,
- "by_company": [dict(row) for row in by_company],
- "by_type": [dict(row) for row in by_type],
- "period_days": days,
- }
+ return {
+ "total_messages": total,
+ "by_company": [dict(row) for row in by_company],
+ "by_type": [dict(row) for row in by_type],
+ "period_days": days,
+ }
# Patent Cache Methods
@@ -462,6 +508,156 @@ class DatabaseClient:
)
conn.commit()
+ # Job Persistence Methods
+
+ def create_job(
+ self,
+ job_id: str,
+ total_companies: int,
+ ) -> Dict:
+ """Create a new job record.
+
+ Args:
+ job_id: Unique job identifier
+ total_companies: Number of companies in the batch
+
+ Returns:
+ Job dict
+ """
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ INSERT INTO jobs (job_id, status, progress, total_companies, completed_companies)
+ VALUES (%s, 'pending', 0, %s, 0)
+ RETURNING *
+ """,
+ (job_id, total_companies),
+ )
+ job = cursor.fetchone()
+ conn.commit()
+ return dict(job)
+
+ def update_job(
+ self,
+ job_id: str,
+ status: Optional[str] = None,
+ progress: Optional[int] = None,
+ completed_companies: Optional[int] = None,
+ result_json: Optional[str] = None,
+ error: Optional[str] = None,
+ ) -> Optional[Dict]:
+ """Update a job's state.
+
+ Only non-None fields are updated.
+ """
+ updates = []
+ params = []
+ if status is not None:
+ updates.append("status = %s")
+ params.append(status)
+ if progress is not None:
+ updates.append("progress = %s")
+ params.append(progress)
+ if completed_companies is not None:
+ updates.append("completed_companies = %s")
+ params.append(completed_companies)
+ if result_json is not None:
+ updates.append("result_json = %s")
+ params.append(result_json)
+ if error is not None:
+ updates.append("error = %s")
+ params.append(error)
+
+ if not updates:
+ return self.get_job(job_id)
+
+ updates.append("updated_at = CURRENT_TIMESTAMP")
+ params.append(job_id)
+
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ f"UPDATE jobs SET {', '.join(updates)} WHERE job_id = %s RETURNING *",
+ params,
+ )
+ job = cursor.fetchone()
+ conn.commit()
+ return dict(job) if job else None
+
+ def get_job(self, job_id: str) -> Optional[Dict]:
+ """Get a job by ID."""
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute("SELECT * FROM jobs WHERE job_id = %s", (job_id,))
+ job = cursor.fetchone()
+ return dict(job) if job else None
+
+ def list_jobs(
+ self,
+ status: Optional[str] = None,
+ limit: int = 10,
+ cursor: Optional[str] = None,
+ ) -> List[Dict]:
+ """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:
+ conditions.append("status = %s")
+ params.append(status)
+
+ 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 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'.
+
+ Called at startup to clean up jobs that were interrupted by a restart.
+
+ Returns:
+ Number of jobs marked as failed.
+ """
+ with self.get_conn() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute(
+ """
+ UPDATE jobs SET status = 'failed', error = 'Interrupted by server restart',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE status IN ('running', 'pending')
+ """
+ )
+ count = cursor.rowcount
+ conn.commit()
+ return count
+
# User Authentication Methods
@staticmethod
@@ -505,25 +701,23 @@ class DatabaseClient:
Returns:
Created user dict or None if email exists
"""
- self.connect()
-
password_hash = self.hash_password(password)
try:
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- """
- INSERT INTO users (email, password_hash, role)
- VALUES (%s, %s, %s)
- RETURNING id, email, role, created_at
- """,
- (email, password_hash, role),
- )
- user = cursor.fetchone()
- self.conn.commit()
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ INSERT INTO users (email, password_hash, role)
+ VALUES (%s, %s, %s)
+ RETURNING id, email, role, created_at
+ """,
+ (email, password_hash, role),
+ )
+ user = cursor.fetchone()
+ conn.commit()
return dict(user) if user else None
except psycopg2.errors.UniqueViolation:
- self.conn.rollback()
return None
def authenticate_user(self, email: str, password: str) -> Optional[Dict]:
@@ -536,23 +730,22 @@ class DatabaseClient:
Returns:
User dict if authenticated, None otherwise
"""
- self.connect()
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ "SELECT * FROM users WHERE email = %s",
+ (email,),
+ )
+ user = cursor.fetchone()
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- "SELECT * FROM users WHERE email = %s",
- (email,),
- )
- user = cursor.fetchone()
-
- if user and self.verify_password(password, user["password_hash"]):
- return {
- "id": user["id"],
- "email": user["email"],
- "role": user["role"],
- "created_at": user["created_at"],
- }
- return None
+ if user and self.verify_password(password, user["password_hash"]):
+ return {
+ "id": user["id"],
+ "email": user["email"],
+ "role": user["role"],
+ "created_at": user["created_at"],
+ }
+ return None
def get_user_by_id(self, user_id: int) -> Optional[Dict]:
"""Get a user by ID.
@@ -563,15 +756,14 @@ class DatabaseClient:
Returns:
User dict or None
"""
- self.connect()
-
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- "SELECT id, email, role, created_at FROM users WHERE id = %s",
- (user_id,),
- )
- user = cursor.fetchone()
- return dict(user) if user else None
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ "SELECT id, email, role, created_at FROM users WHERE id = %s",
+ (user_id,),
+ )
+ user = cursor.fetchone()
+ return dict(user) if user else None
def get_user_by_email(self, email: str) -> Optional[Dict]:
"""Get a user by email.
@@ -582,15 +774,14 @@ class DatabaseClient:
Returns:
User dict or None
"""
- self.connect()
-
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- "SELECT id, email, role, created_at FROM users WHERE email = %s",
- (email,),
- )
- user = cursor.fetchone()
- return dict(user) if user else None
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ "SELECT id, email, role, created_at FROM users WHERE email = %s",
+ (email,),
+ )
+ user = cursor.fetchone()
+ return dict(user) if user else None
def get_all_users(self, limit: int = 100, offset: int = 0) -> List[Dict]:
"""Get all users (admin only).
@@ -602,19 +793,18 @@ class DatabaseClient:
Returns:
List of user dicts
"""
- self.connect()
-
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- """
- SELECT id, email, role, created_at
- FROM users
- ORDER BY created_at DESC
- LIMIT %s OFFSET %s
- """,
- (limit, offset),
- )
- return [dict(row) for row in cursor.fetchall()]
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ SELECT id, email, role, created_at
+ FROM users
+ ORDER BY created_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (limit, offset),
+ )
+ return [dict(row) for row in cursor.fetchall()]
def update_user_role(self, user_id: int, role: str) -> Optional[Dict]:
"""Update a user's role (admin only).
@@ -626,20 +816,19 @@ class DatabaseClient:
Returns:
Updated user dict or None
"""
- self.connect()
-
- with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
- cursor.execute(
- """
- UPDATE users
- SET role = %s, updated_at = CURRENT_TIMESTAMP
- WHERE id = %s
- RETURNING id, email, role, created_at
- """,
- (role, user_id),
- )
- user = cursor.fetchone()
- self.conn.commit()
+ with self.get_conn() as conn:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ UPDATE users
+ SET role = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ RETURNING id, email, role, created_at
+ """,
+ (role, user_id),
+ )
+ user = cursor.fetchone()
+ conn.commit()
return dict(user) if user else None
def delete_user(self, user_id: int) -> bool:
@@ -651,12 +840,11 @@ class DatabaseClient:
Returns:
True if deleted
"""
- self.connect()
-
- with self.conn.cursor() as cursor:
- cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
- deleted = cursor.rowcount > 0
- self.conn.commit()
+ with self.get_conn() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
+ deleted = cursor.rowcount > 0
+ conn.commit()
return deleted
def get_user_count(self) -> int:
@@ -665,8 +853,85 @@ class DatabaseClient:
Returns:
Number of users
"""
- self.connect()
+ with self.get_conn() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute("SELECT COUNT(*) FROM users")
+ return cursor.fetchone()[0]
- with self.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()]
diff --git a/SPARC/llm.py b/SPARC/llm.py
index 2e60c9b..9214cee 100644
--- a/SPARC/llm.py
+++ b/SPARC/llm.py
@@ -1,9 +1,14 @@
"""LLM integration for patent analysis using OpenRouter."""
+import logging
+from typing import Dict
+
from openai import OpenAI
+
from SPARC import config
from SPARC.database import DatabaseClient
-from typing import Dict
+
+logger = logging.getLogger(__name__)
class LLMAnalyzer:
@@ -20,7 +25,7 @@ class LLMAnalyzer:
"""
self.test_mode = test_mode
self.use_cache = use_cache if use_cache is not None else config.use_cache
- self.model = "anthropic/claude-3.5-sonnet"
+ self.model = config.model
# Always initialize database client for storage and caching
self.db_client = DatabaseClient(config.database_url)
@@ -35,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
@@ -58,12 +64,10 @@ 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:
- print("=" * 80)
- print("TEST MODE - Prompt that would be sent to LLM:")
- print("=" * 80)
- print(prompt)
- print("=" * 80)
+ logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
return "[TEST MODE - No API call made]"
# Check cache first
@@ -80,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,
@@ -93,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}],
)
@@ -105,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,
@@ -123,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.
@@ -164,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:
- print(prompt)
+ 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
@@ -187,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,
@@ -201,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}],
)
@@ -214,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,
@@ -234,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
diff --git a/SPARC/scheduler.py b/SPARC/scheduler.py
new file mode 100644
index 0000000..5af3940
--- /dev/null
+++ b/SPARC/scheduler.py
@@ -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,
+ )
diff --git a/SPARC/serp_api.py b/SPARC/serp_api.py
index b4254d0..2c89a2a 100644
--- a/SPARC/serp_api.py
+++ b/SPARC/serp_api.py
@@ -1,12 +1,29 @@
-import os
-import serpapi
-from SPARC import config
+import io
+import logging
import re
-import pdfplumber # pip install pdfplumber
-import requests
from datetime import datetime, timedelta
from typing import Dict
-from SPARC.types import Patents, Patent
+
+import pdfplumber # pip install pdfplumber
+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:
@@ -41,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 = []
@@ -49,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
@@ -63,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 = {
diff --git a/SPARC/storage.py b/SPARC/storage.py
new file mode 100644
index 0000000..5159dd6
--- /dev/null
+++ b/SPARC/storage.py
@@ -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()
diff --git a/SPARC/types.py b/SPARC/types.py
index 09c17d1..fd11073 100644
--- a/SPARC/types.py
+++ b/SPARC/types.py
@@ -4,7 +4,7 @@ from datetime import datetime
@dataclass
class Patent:
- patent_id: int
+ patent_id: str
pdf_link: str
pdf_path: str | None = None
summary: dict | None = None
@@ -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)
diff --git a/SPARC/webhooks.py b/SPARC/webhooks.py
new file mode 100644
index 0000000..08760fe
--- /dev/null
+++ b/SPARC/webhooks.py
@@ -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,
+ })
diff --git a/docker-compose.yml b/docker-compose.yml
index 7bbdbe2..14842b2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,15 +3,15 @@ services:
image: postgres:16-alpine
container_name: sparc-postgres
environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: sparc
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
@@ -22,7 +22,7 @@ services:
container_name: sparc-init-db
command: python scripts/init_database.py
environment:
- DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sparc
+ DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
depends_on:
postgres:
condition: service_healthy
@@ -35,9 +35,11 @@ services:
environment:
API_KEY: ${API_KEY}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
- DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sparc
+ DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
USE_CACHE: "true"
JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production}
+ CORS_ORIGINS: ${CORS_ORIGINS:-}
+ APP_ENV: ${APP_ENV:-development}
ROOT_PATH: /api
ports:
- "8000:8000"
@@ -47,9 +49,32 @@ services:
init-db:
condition: service_completed_successfully
volumes:
- - ./patents:/app/patents
+ - patent_data:/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
@@ -61,3 +86,5 @@ services:
volumes:
postgres_data:
+ patent_data:
+ minio_data:
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index bb7bfd9..c89d883 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -276,7 +276,7 @@ The `docker-compose.yml` includes all services needed for production:
|---------|-----------|------|-------------|
| `postgres` | sparc-postgres | 5432 | PostgreSQL database |
| `init-db` | sparc-init-db | - | One-time database initialization (seeds admin user) |
-| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth |
+| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth (patent PDFs stored in `patent_data` volume) |
| `dashboard` | sparc-dashboard | 8080 | React TypeScript web UI |
### Common Docker Compose Commands
@@ -307,6 +307,81 @@ docker-compose restart api
---
+## Patent PDF Storage
+
+The SPARC API downloads patent PDFs during analysis and stores them at `/app/patents` inside the container. These files are used for subsequent single-patent analysis requests and as a local cache to avoid re-downloading. If this directory is not persisted, all downloaded PDFs are lost when the container is recreated.
+
+### Docker Compose (default)
+
+The default `docker-compose.yml` declares a named volume called `patent_data` that is mounted at `/app/patents`:
+
+```yaml
+# In the api service:
+volumes:
+ - patent_data:/app/patents
+
+# At the top-level volumes section:
+volumes:
+ patent_data:
+```
+
+This means PDFs survive `docker compose down` and `docker compose up` cycles. To remove patent data intentionally, run:
+
+```bash
+docker compose down -v # WARNING: also removes postgres_data
+# or selectively:
+docker volume rm sparc_patent_data
+```
+
+If you prefer a bind mount (e.g., for easy host-side access during development), replace the volume with:
+
+```yaml
+volumes:
+ - ./patents:/app/patents
+```
+
+### Kubernetes
+
+For Kubernetes deployments, create a PersistentVolumeClaim and mount it into the API pod:
+
+```yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: sparc-patent-data
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: sparc-api
+spec:
+ template:
+ spec:
+ containers:
+ - name: api
+ volumeMounts:
+ - name: patent-data
+ mountPath: /app/patents
+ volumes:
+ - name: patent-data
+ persistentVolumeClaim:
+ claimName: sparc-patent-data
+```
+
+Adjust the storage size based on expected patent volume. Each patent PDF is typically 1-5 MB.
+
+### S3 Object Storage (alternative)
+
+For production deployments that need shared or highly durable storage, set `STORAGE_BACKEND=s3` in your `.env` file. This stores patent PDFs in an S3-compatible bucket (AWS S3 or MinIO) instead of the local filesystem, eliminating the need for a persistent volume. See the S3/MinIO section in `.env.example` for configuration details.
+
+---
+
## Troubleshooting
### Database Connection Issues
diff --git a/frontend/index.html b/frontend/index.html
index 631e457..0ff0633 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -7,6 +7,15 @@
SPARC Dashboard
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..ca0ca36
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,4728 @@
+{
+ "name": "sparc-dashboard",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "sparc-dashboard",
+ "version": "1.0.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.51.0",
+ "axios": "^1.7.2",
+ "lucide-react": "^1.7.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.24.0",
+ "recharts": "^2.12.7"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.6.0",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^9.6.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.7",
+ "globals": "^15.8.0",
+ "postcss": "^8.4.39",
+ "tailwindcss": "^3.4.4",
+ "typescript": "~5.5.3",
+ "typescript-eslint": "^8.0.0",
+ "vite": "^5.3.3"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+ "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+ "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+ "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+ "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+ "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+ "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+ "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+ "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+ "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+ "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+ "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+ "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+ "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+ "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+ "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+ "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+ "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+ "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+ "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+ "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+ "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+ "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.95.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
+ "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/type-utils": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.57.2",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
+ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
+ "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.57.2",
+ "@typescript-eslint/types": "^8.57.2",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
+ "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
+ "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
+ "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
+ "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
+ "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.57.2",
+ "@typescript-eslint/tsconfig-utils": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
+ "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
+ "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.57.2",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.10",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
+ "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001781",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
+ "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.325",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
+ "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "15.15.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
+ "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "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"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.0",
+ "@rollup/rollup-android-arm64": "4.60.0",
+ "@rollup/rollup-darwin-arm64": "4.60.0",
+ "@rollup/rollup-darwin-x64": "4.60.0",
+ "@rollup/rollup-freebsd-arm64": "4.60.0",
+ "@rollup/rollup-freebsd-x64": "4.60.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+ "@rollup/rollup-linux-arm64-musl": "4.60.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+ "@rollup/rollup-linux-loong64-musl": "4.60.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-musl": "4.60.0",
+ "@rollup/rollup-openbsd-x64": "4.60.0",
+ "@rollup/rollup-openharmony-arm64": "4.60.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+ "@rollup/rollup-win32-x64-gnu": "4.60.0",
+ "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
+ "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.57.2",
+ "@typescript-eslint/parser": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
index b99eee1..679bb6e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c3426cd..d7ec5ba 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
+
@@ -41,6 +44,7 @@ function App() {
} />
} />
} />
+ } />
} />
{/* Admin routes */}
@@ -61,6 +65,7 @@ function App() {
+
);
}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 037d59c..09a4ae6 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -89,29 +89,53 @@ export const authApi = {
},
};
+// Model types
+export interface ModelInfo {
+ id: string;
+ name: string;
+ provider: string;
+}
+
+export interface ModelsResponse {
+ models: ModelInfo[];
+ default: string;
+}
+
// Analysis API
export const analysisApi = {
- analyzeCompany: async (companyName: string): Promise => {
- const response = await api.get(`/analyze/${encodeURIComponent(companyName)}`);
+ analyzeCompany: async (companyName: string, model?: string): Promise => {
+ const params = new URLSearchParams();
+ if (model) params.append('model', model);
+ const qs = params.toString();
+ const response = await api.get(
+ `/analyze/${encodeURIComponent(companyName)}${qs ? `?${qs}` : ''}`
+ );
return response.data;
},
- analyzeBatch: async (companies: string[], maxWorkers = 3): Promise => {
+ analyzeBatch: async (companies: string[], maxWorkers = 3, model?: string): Promise => {
const response = await api.post('/analyze/batch', {
companies,
max_workers: maxWorkers,
+ ...(model ? { model } : {}),
});
return response.data;
},
- analyzeBatchAsync: async (companies: string[], maxWorkers = 3): Promise => {
+ analyzeBatchAsync: async (companies: string[], maxWorkers = 3, model?: string): Promise => {
const response = await api.post('/analyze/batch/async', {
companies,
max_workers: maxWorkers,
+ ...(model ? { model } : {}),
});
return response.data;
},
+ listModels: async (): Promise => {
+ const response = await api.get('/models');
+ return response.data;
+ },
+
getJobStatus: async (jobId: string): Promise => {
const response = await api.get(`/jobs/${jobId}`);
return response.data;
@@ -126,12 +150,55 @@ export const analysisApi = {
},
};
+// Export API
+export const exportApi = {
+ exportCsv: async (companyName: string): Promise => {
+ 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 => {
+ 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 => {
const response = await api.get(`/analytics?days=${days}`);
return response.data;
},
+
+ getTrends: async (days = 90): Promise => {
+ const response = await api.get(`/analytics/trends?days=${days}`);
+ return response.data;
+ },
};
// Admin API
diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json
new file mode 100644
index 0000000..3a9d364
--- /dev/null
+++ b/frontend/src/api/openapi.json
@@ -0,0 +1,1086 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "SPARC API",
+ "description": "Semiconductor Patent & Analytics Report Core - Patent portfolio analysis using AI",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/auth/register": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Register",
+ "description": "Register a new user.\n\nThe first registered user automatically becomes an admin.",
+ "operationId": "register_auth_register_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/login": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Login",
+ "description": "Authenticate user and return JWT tokens.",
+ "operationId": "login_auth_login_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/refresh": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Refresh Token",
+ "description": "Refresh access token using refresh token.",
+ "operationId": "refresh_token_auth_refresh_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RefreshRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/me": {
+ "get": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Get Me",
+ "description": "Get current authenticated user.",
+ "operationId": "get_me_auth_me_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserResponse"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ]
+ }
+ },
+ "/admin/users": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "List Users",
+ "description": "List all users (admin only).",
+ "operationId": "list_users_admin_users_get",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 1000,
+ "minimum": 1,
+ "default": 100,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0,
+ "title": "Offset"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UserResponse"
+ },
+ "title": "Response List Users Admin Users Get"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/users/{user_id}/role": {
+ "patch": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Update User Role",
+ "description": "Update a user's role (admin only).",
+ "operationId": "update_user_role_admin_users__user_id__role_patch",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "User Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateRoleRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/users/{user_id}": {
+ "delete": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Delete User",
+ "description": "Delete a user (admin only).",
+ "operationId": "delete_user_admin_users__user_id__delete",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "User Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/analytics": {
+ "get": {
+ "tags": [
+ "Analytics"
+ ],
+ "summary": "Get Analytics",
+ "description": "Get analytics data (authenticated users only).",
+ "operationId": "get_analytics_analytics_get",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "days",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 365,
+ "minimum": 1,
+ "default": 30,
+ "title": "Days"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AnalyticsResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": [
+ "System"
+ ],
+ "summary": "Health Check",
+ "description": "Check API health status.",
+ "operationId": "health_check_health_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/analyze/{company_name}": {
+ "get": {
+ "tags": [
+ "Analysis"
+ ],
+ "summary": "Analyze Company",
+ "description": "Analyze a single company's patent portfolio.\n\nThis endpoint retrieves recent patents for the specified company,\nparses them, and uses AI to generate a comprehensive analysis.\n\nArgs:\n company_name: Name of the company to analyze (e.g., \"nvidia\", \"intel\")\n\nReturns:\n Analysis results including patent count, AI insights, and success status",
+ "operationId": "analyze_company_analyze__company_name__get",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "company_name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Company Name"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CompanyAnalysisResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/analyze/batch": {
+ "post": {
+ "tags": [
+ "Analysis"
+ ],
+ "summary": "Analyze Companies Batch",
+ "description": "Analyze multiple companies' patent portfolios.\n\nProcesses companies concurrently for improved performance.\nLimited to 20 companies per request.\n\nArgs:\n request: List of company names and optional worker count\n\nReturns:\n Batch results with individual company analyses and summary statistics",
+ "operationId": "analyze_companies_batch_analyze_batch_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchAnalysisRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchAnalysisResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ]
+ }
+ },
+ "/analyze/batch/async": {
+ "post": {
+ "tags": [
+ "Analysis"
+ ],
+ "summary": "Analyze Companies Async",
+ "description": "Start an asynchronous batch analysis job.\n\nReturns immediately with a job ID that can be used to poll for status.\nUseful for large batch analyses that may take a long time.\n\nArgs:\n request: List of company names and optional worker count\n\nReturns:\n Job status with job_id for polling",
+ "operationId": "analyze_companies_async_analyze_batch_async_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BatchAnalysisRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JobStatus"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ]
+ }
+ },
+ "/jobs/{job_id}": {
+ "get": {
+ "tags": [
+ "Jobs"
+ ],
+ "summary": "Get Job Status",
+ "description": "Get the status of a background analysis job.\n\nArgs:\n job_id: The job ID returned from the async batch endpoint\n\nReturns:\n Current job status including progress and results when complete",
+ "operationId": "get_job_status_jobs__job_id__get",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "job_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Job Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JobStatus"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/jobs": {
+ "get": {
+ "tags": [
+ "Jobs"
+ ],
+ "summary": "List Jobs",
+ "description": "List all analysis jobs.\n\nArgs:\n status: Optional filter by job status\n limit: Maximum number of jobs to return (default 10, max 100)\n\nReturns:\n List of job statuses",
+ "operationId": "list_jobs_jobs_get",
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by status: pending, running, completed, failed",
+ "title": "Status"
+ },
+ "description": "Filter by status: pending, running, completed, failed"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/JobStatus"
+ },
+ "title": "Response List Jobs Jobs Get"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "AnalyticsResponse": {
+ "properties": {
+ "total_messages": {
+ "type": "integer",
+ "title": "Total Messages"
+ },
+ "by_company": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "By Company"
+ },
+ "by_type": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "By Type"
+ },
+ "period_days": {
+ "type": "integer",
+ "title": "Period Days"
+ }
+ },
+ "type": "object",
+ "required": [
+ "total_messages",
+ "by_company",
+ "by_type",
+ "period_days"
+ ],
+ "title": "AnalyticsResponse",
+ "description": "Analytics response model."
+ },
+ "BatchAnalysisRequest": {
+ "properties": {
+ "companies": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "maxItems": 20,
+ "minItems": 1,
+ "title": "Companies",
+ "description": "List of company names to analyze"
+ },
+ "max_workers": {
+ "type": "integer",
+ "maximum": 5.0,
+ "minimum": 1.0,
+ "title": "Max Workers",
+ "description": "Max concurrent analyses",
+ "default": 3
+ }
+ },
+ "type": "object",
+ "required": [
+ "companies"
+ ],
+ "title": "BatchAnalysisRequest",
+ "description": "Request model for batch company analysis."
+ },
+ "BatchAnalysisResponse": {
+ "properties": {
+ "results": {
+ "items": {
+ "$ref": "#/components/schemas/CompanyAnalysisResponse"
+ },
+ "type": "array",
+ "title": "Results"
+ },
+ "total_companies": {
+ "type": "integer",
+ "title": "Total Companies"
+ },
+ "successful": {
+ "type": "integer",
+ "title": "Successful"
+ },
+ "failed": {
+ "type": "integer",
+ "title": "Failed"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp"
+ }
+ },
+ "type": "object",
+ "required": [
+ "results",
+ "total_companies",
+ "successful",
+ "failed",
+ "timestamp"
+ ],
+ "title": "BatchAnalysisResponse",
+ "description": "Response model for batch company analysis."
+ },
+ "CompanyAnalysisResponse": {
+ "properties": {
+ "company_name": {
+ "type": "string",
+ "title": "Company Name"
+ },
+ "analysis": {
+ "type": "string",
+ "title": "Analysis"
+ },
+ "patent_count": {
+ "type": "integer",
+ "title": "Patent Count"
+ },
+ "success": {
+ "type": "boolean",
+ "title": "Success"
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp"
+ }
+ },
+ "type": "object",
+ "required": [
+ "company_name",
+ "analysis",
+ "patent_count",
+ "success",
+ "timestamp"
+ ],
+ "title": "CompanyAnalysisResponse",
+ "description": "Response model for single company analysis."
+ },
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {
+ "$ref": "#/components/schemas/ValidationError"
+ },
+ "type": "array",
+ "title": "Detail"
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError"
+ },
+ "HealthResponse": {
+ "properties": {
+ "status": {
+ "type": "string",
+ "title": "Status"
+ },
+ "version": {
+ "type": "string",
+ "title": "Version"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp"
+ }
+ },
+ "type": "object",
+ "required": [
+ "status",
+ "version",
+ "timestamp"
+ ],
+ "title": "HealthResponse",
+ "description": "Health check response."
+ },
+ "JobStatus": {
+ "properties": {
+ "job_id": {
+ "type": "string",
+ "title": "Job Id"
+ },
+ "status": {
+ "type": "string",
+ "title": "Status"
+ },
+ "progress": {
+ "type": "integer",
+ "title": "Progress"
+ },
+ "total_companies": {
+ "type": "integer",
+ "title": "Total Companies"
+ },
+ "completed_companies": {
+ "type": "integer",
+ "title": "Completed Companies"
+ },
+ "result": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/BatchAnalysisResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ }
+ },
+ "type": "object",
+ "required": [
+ "job_id",
+ "status",
+ "progress",
+ "total_companies",
+ "completed_companies"
+ ],
+ "title": "JobStatus",
+ "description": "Status of a background analysis job."
+ },
+ "LoginRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email"
+ },
+ "password": {
+ "type": "string",
+ "title": "Password"
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "password"
+ ],
+ "title": "LoginRequest",
+ "description": "User login request."
+ },
+ "RefreshRequest": {
+ "properties": {
+ "refresh_token": {
+ "type": "string",
+ "title": "Refresh Token"
+ }
+ },
+ "type": "object",
+ "required": [
+ "refresh_token"
+ ],
+ "title": "RefreshRequest",
+ "description": "Token refresh request."
+ },
+ "RegisterRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email"
+ },
+ "password": {
+ "type": "string",
+ "minLength": 8,
+ "title": "Password",
+ "description": "Password (min 8 characters)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "password"
+ ],
+ "title": "RegisterRequest",
+ "description": "User registration request."
+ },
+ "TokenResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token"
+ },
+ "refresh_token": {
+ "type": "string",
+ "title": "Refresh Token"
+ },
+ "token_type": {
+ "type": "string",
+ "title": "Token Type",
+ "default": "bearer"
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token",
+ "refresh_token"
+ ],
+ "title": "TokenResponse",
+ "description": "Token response model."
+ },
+ "UpdateRoleRequest": {
+ "properties": {
+ "role": {
+ "type": "string",
+ "pattern": "^(admin|user)$",
+ "title": "Role"
+ }
+ },
+ "type": "object",
+ "required": [
+ "role"
+ ],
+ "title": "UpdateRoleRequest",
+ "description": "Update user role request."
+ },
+ "UserResponse": {
+ "properties": {
+ "id": {
+ "type": "integer",
+ "title": "Id"
+ },
+ "email": {
+ "type": "string",
+ "title": "Email"
+ },
+ "role": {
+ "type": "string",
+ "title": "Role"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Created At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "email",
+ "role",
+ "created_at"
+ ],
+ "title": "UserResponse",
+ "description": "User response model."
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ]
+ },
+ "type": "array",
+ "title": "Location"
+ },
+ "msg": {
+ "type": "string",
+ "title": "Message"
+ },
+ "type": {
+ "type": "string",
+ "title": "Error Type"
+ },
+ "input": {
+ "title": "Input"
+ },
+ "ctx": {
+ "type": "object",
+ "title": "Context"
+ }
+ },
+ "type": "object",
+ "required": [
+ "loc",
+ "msg",
+ "type"
+ ],
+ "title": "ValidationError"
+ }
+ },
+ "securitySchemes": {
+ "HTTPBearer": {
+ "type": "http",
+ "scheme": "bearer"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
new file mode 100644
index 0000000..0c4772e
--- /dev/null
+++ b/frontend/src/api/schema.d.ts
@@ -0,0 +1,975 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/auth/register": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Register
+ * @description Register a new user.
+ *
+ * The first registered user automatically becomes an admin.
+ */
+ post: operations["register_auth_register_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/auth/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Login
+ * @description Authenticate user and return JWT tokens.
+ */
+ post: operations["login_auth_login_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/auth/refresh": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Refresh Token
+ * @description Refresh access token using refresh token.
+ */
+ post: operations["refresh_token_auth_refresh_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/auth/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Me
+ * @description Get current authenticated user.
+ */
+ get: operations["get_me_auth_me_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Users
+ * @description List all users (admin only).
+ */
+ get: operations["list_users_admin_users_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/users/{user_id}/role": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Update User Role
+ * @description Update a user's role (admin only).
+ */
+ patch: operations["update_user_role_admin_users__user_id__role_patch"];
+ trace?: never;
+ };
+ "/admin/users/{user_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Delete User
+ * @description Delete a user (admin only).
+ */
+ delete: operations["delete_user_admin_users__user_id__delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/analytics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Analytics
+ * @description Get analytics data (authenticated users only).
+ */
+ get: operations["get_analytics_analytics_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/health": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Health Check
+ * @description Check API health status.
+ */
+ get: operations["health_check_health_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/analyze/{company_name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Analyze Company
+ * @description Analyze a single company's patent portfolio.
+ *
+ * This endpoint retrieves recent patents for the specified company,
+ * parses them, and uses AI to generate a comprehensive analysis.
+ *
+ * Args:
+ * company_name: Name of the company to analyze (e.g., "nvidia", "intel")
+ *
+ * Returns:
+ * Analysis results including patent count, AI insights, and success status
+ */
+ get: operations["analyze_company_analyze__company_name__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/analyze/batch": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Analyze Companies Batch
+ * @description Analyze multiple companies' patent portfolios.
+ *
+ * Processes companies concurrently for improved performance.
+ * Limited to 20 companies per request.
+ *
+ * Args:
+ * request: List of company names and optional worker count
+ *
+ * Returns:
+ * Batch results with individual company analyses and summary statistics
+ */
+ post: operations["analyze_companies_batch_analyze_batch_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/analyze/batch/async": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Analyze Companies Async
+ * @description Start an asynchronous batch analysis job.
+ *
+ * Returns immediately with a job ID that can be used to poll for status.
+ * Useful for large batch analyses that may take a long time.
+ *
+ * Args:
+ * request: List of company names and optional worker count
+ *
+ * Returns:
+ * Job status with job_id for polling
+ */
+ post: operations["analyze_companies_async_analyze_batch_async_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/jobs/{job_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Job Status
+ * @description Get the status of a background analysis job.
+ *
+ * Args:
+ * job_id: The job ID returned from the async batch endpoint
+ *
+ * Returns:
+ * Current job status including progress and results when complete
+ */
+ get: operations["get_job_status_jobs__job_id__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/jobs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Jobs
+ * @description List all analysis jobs.
+ *
+ * Args:
+ * status: Optional filter by job status
+ * limit: Maximum number of jobs to return (default 10, max 100)
+ *
+ * Returns:
+ * List of job statuses
+ */
+ get: operations["list_jobs_jobs_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ /**
+ * AnalyticsResponse
+ * @description Analytics response model.
+ */
+ AnalyticsResponse: {
+ /** Total Messages */
+ total_messages: number;
+ /** By Company */
+ by_company: {
+ [key: string]: unknown;
+ }[];
+ /** By Type */
+ by_type: {
+ [key: string]: unknown;
+ }[];
+ /** Period Days */
+ period_days: number;
+ };
+ /**
+ * BatchAnalysisRequest
+ * @description Request model for batch company analysis.
+ */
+ BatchAnalysisRequest: {
+ /**
+ * Companies
+ * @description List of company names to analyze
+ */
+ companies: string[];
+ /**
+ * Max Workers
+ * @description Max concurrent analyses
+ * @default 3
+ */
+ max_workers: number;
+ };
+ /**
+ * BatchAnalysisResponse
+ * @description Response model for batch company analysis.
+ */
+ BatchAnalysisResponse: {
+ /** Results */
+ results: components["schemas"]["CompanyAnalysisResponse"][];
+ /** Total Companies */
+ total_companies: number;
+ /** Successful */
+ successful: number;
+ /** Failed */
+ failed: number;
+ /**
+ * Timestamp
+ * Format: date-time
+ */
+ timestamp: string;
+ };
+ /**
+ * CompanyAnalysisResponse
+ * @description Response model for single company analysis.
+ */
+ CompanyAnalysisResponse: {
+ /** Company Name */
+ company_name: string;
+ /** Analysis */
+ analysis: string;
+ /** Patent Count */
+ patent_count: number;
+ /** Success */
+ success: boolean;
+ /** Error */
+ error?: string | null;
+ /**
+ * Timestamp
+ * Format: date-time
+ */
+ timestamp: string;
+ };
+ /** HTTPValidationError */
+ HTTPValidationError: {
+ /** Detail */
+ detail?: components["schemas"]["ValidationError"][];
+ };
+ /**
+ * HealthResponse
+ * @description Health check response.
+ */
+ HealthResponse: {
+ /** Status */
+ status: string;
+ /** Version */
+ version: string;
+ /**
+ * Timestamp
+ * Format: date-time
+ */
+ timestamp: string;
+ };
+ /**
+ * JobStatus
+ * @description Status of a background analysis job.
+ */
+ JobStatus: {
+ /** Job Id */
+ job_id: string;
+ /** Status */
+ status: string;
+ /** Progress */
+ progress: number;
+ /** Total Companies */
+ total_companies: number;
+ /** Completed Companies */
+ completed_companies: number;
+ result?: components["schemas"]["BatchAnalysisResponse"] | null;
+ /** Error */
+ error?: string | null;
+ };
+ /**
+ * LoginRequest
+ * @description User login request.
+ */
+ LoginRequest: {
+ /**
+ * Email
+ * Format: email
+ */
+ email: string;
+ /** Password */
+ password: string;
+ };
+ /**
+ * RefreshRequest
+ * @description Token refresh request.
+ */
+ RefreshRequest: {
+ /** Refresh Token */
+ refresh_token: string;
+ };
+ /**
+ * RegisterRequest
+ * @description User registration request.
+ */
+ RegisterRequest: {
+ /**
+ * Email
+ * Format: email
+ */
+ email: string;
+ /**
+ * Password
+ * @description Password (min 8 characters)
+ */
+ password: string;
+ };
+ /**
+ * TokenResponse
+ * @description Token response model.
+ */
+ TokenResponse: {
+ /** Access Token */
+ access_token: string;
+ /** Refresh Token */
+ refresh_token: string;
+ /**
+ * Token Type
+ * @default bearer
+ */
+ token_type: string;
+ };
+ /**
+ * UpdateRoleRequest
+ * @description Update user role request.
+ */
+ UpdateRoleRequest: {
+ /** Role */
+ role: string;
+ };
+ /**
+ * UserResponse
+ * @description User response model.
+ */
+ UserResponse: {
+ /** Id */
+ id: number;
+ /** Email */
+ email: string;
+ /** Role */
+ role: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
+ /** ValidationError */
+ ValidationError: {
+ /** Location */
+ loc: (string | number)[];
+ /** Message */
+ msg: string;
+ /** Error Type */
+ type: string;
+ /** Input */
+ input?: unknown;
+ /** Context */
+ ctx?: Record;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ register_auth_register_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RegisterRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ login_auth_login_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["LoginRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TokenResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ refresh_token_auth_refresh_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RefreshRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TokenResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_me_auth_me_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserResponse"];
+ };
+ };
+ };
+ };
+ list_users_admin_users_get: {
+ parameters: {
+ query?: {
+ limit?: number;
+ offset?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserResponse"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_user_role_admin_users__user_id__role_patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ user_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateRoleRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_user_admin_users__user_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ user_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_analytics_analytics_get: {
+ parameters: {
+ query?: {
+ days?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AnalyticsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ health_check_health_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HealthResponse"];
+ };
+ };
+ };
+ };
+ analyze_company_analyze__company_name__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ company_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CompanyAnalysisResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ analyze_companies_batch_analyze_batch_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BatchAnalysisRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BatchAnalysisResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ analyze_companies_async_analyze_batch_async_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BatchAnalysisRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["JobStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_job_status_jobs__job_id__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ job_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["JobStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_jobs_jobs_get: {
+ parameters: {
+ query?: {
+ /** @description Filter by status: pending, running, completed, failed */
+ status?: string | null;
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["JobStatus"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 501dc1f..d0df715 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -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 (
-
+
{/* Header */}
@@ -63,6 +66,13 @@ export function Layout() {
{/* User menu */}
+
{user?.email}
{user?.role}
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
index 667057d..7c4eac9 100644
--- a/frontend/src/components/ProtectedRoute.tsx
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -12,7 +12,7 @@ export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRout
if (isLoading) {
return (
-
+
);
diff --git a/frontend/src/context/ThemeContext.tsx b/frontend/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..ea7f091
--- /dev/null
+++ b/frontend/src/context/ThemeContext.tsx
@@ -0,0 +1,48 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+
+type Theme = 'light' | 'dark';
+
+interface ThemeContextType {
+ theme: Theme;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext
(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(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 (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/context/useChartTheme.ts b/frontend/src/context/useChartTheme.ts
new file mode 100644
index 0000000..c93142c
--- /dev/null
+++ b/frontend/src/context/useChartTheme.ts
@@ -0,0 +1,41 @@
+import { useTheme } from './ThemeContext';
+
+/**
+ * Returns theme-aware color values for recharts components.
+ *
+ * Recharts accepts only raw color strings (not CSS variables),
+ * so this hook bridges the Tailwind/CSS-variable theme system
+ * to the imperative recharts API.
+ */
+export function useChartTheme() {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return {
+ /** Axis tick and grid line stroke color */
+ axisStroke: isDark ? '#94a3b8' : '#64748b',
+ /** Tooltip container background */
+ tooltipBg: isDark ? '#1e293b' : '#ffffff',
+ /** Tooltip container border */
+ tooltipBorder: isDark
+ ? '1px solid rgba(99, 102, 241, 0.3)'
+ : '1px solid rgba(99, 102, 241, 0.2)',
+ /** Tooltip label text color */
+ tooltipLabelColor: isDark ? '#f8fafc' : '#0f172a',
+ /** Tooltip item text color */
+ tooltipItemColor: isDark ? '#e2e8f0' : '#334155',
+ /** Convenience: full contentStyle object for recharts Tooltip */
+ tooltipContentStyle: {
+ backgroundColor: isDark ? '#1e293b' : '#ffffff',
+ border: isDark
+ ? '1px solid rgba(99, 102, 241, 0.3)'
+ : '1px solid rgba(99, 102, 241, 0.2)',
+ borderRadius: '8px',
+ color: isDark ? '#f8fafc' : '#0f172a',
+ },
+ /** Convenience: labelStyle for recharts Tooltip */
+ tooltipLabelStyle: {
+ color: isDark ? '#f8fafc' : '#0f172a',
+ },
+ };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b94918a..3ef8621 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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);
}
diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx
index 2dfd2f5..7ec67f7 100644
--- a/frontend/src/pages/Analysis.tsx
+++ b/frontend/src/pages/Analysis.tsx
@@ -1,15 +1,21 @@
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 { useMutation, useQuery } from '@tanstack/react-query';
+import { analysisApi, exportApi } from '../api/client';
+import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react';
import type { CompanyAnalysis } from '../types';
export function Analysis() {
const [companyName, setCompanyName] = useState('');
+ const [selectedModel, setSelectedModel] = useState('');
const [result, setResult] = useState(null);
+ const modelsQuery = useQuery({
+ queryKey: ['models'],
+ queryFn: () => analysisApi.listModels(),
+ });
+
const mutation = useMutation({
- mutationFn: (name: string) => analysisApi.analyzeCompany(name),
+ mutationFn: (name: string) => analysisApi.analyzeCompany(name, selectedModel || undefined),
onSuccess: (data) => setResult(data),
});
@@ -33,31 +39,57 @@ export function Analysis() {
{/* Search Form */}
-
)}
+
+ {/* Job History */}
+
+
+ Job History
+
+
+ {/* Loading skeleton */}
+ {jobsQuery.isLoading && (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* Job history error */}
+ {jobsQuery.isError && (
+
+
+
+
Failed to load job history
+
+
+ {jobsQuery.error instanceof Error ? jobsQuery.error.message : 'Could not retrieve past jobs.'}
+
+
+
+ )}
+
+ {/* Empty state */}
+ {jobsQuery.isSuccess && jobsQuery.data.length === 0 && !result && (
+
+
+
No batch jobs yet
+
+ Submit a batch analysis above to get started. Your job history will appear here.
+
+
+ )}
+
+ {/* Job list */}
+ {jobsQuery.isSuccess && jobsQuery.data.length > 0 && (
+
+ {jobsQuery.data.map((job) => (
+
+
+
+ {job.status === 'completed' &&
}
+ {job.status === 'failed' &&
}
+ {(job.status === 'pending' || job.status === 'running') && (
+
+ )}
+
{job.job_id.slice(0, 8)}
+
+ {job.total_companies} {job.total_companies === 1 ? 'company' : 'companies'}
+
+
+
+ {job.status}
+
+
+ {(job.status === 'running' || job.status === 'pending') && job.total_companies > 0 && (
+
+
+ Progress
+ {job.completed_companies}/{job.total_companies}
+
+
+
+ )}
+ {job.status === 'failed' && job.error && (
+
{job.error}
+ )}
+
+ ))}
+
+ )}
+
);
}
diff --git a/frontend/src/pages/Compare.tsx b/frontend/src/pages/Compare.tsx
new file mode 100644
index 0000000..eef3e53
--- /dev/null
+++ b/frontend/src/pages/Compare.tsx
@@ -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 (
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
+
Failed to load analysis. Check the company name and try again.
+
+
+ );
+ }
+
+ if (!data) return null;
+
+ return (
+
+
+ {data.company_name.toUpperCase()}
+
+
+
+
+
+
{data.patent_count}
+
Patents
+
+
+
+
+ {new Date(data.timestamp).toLocaleDateString()}
+
+
Analyzed
+
+
+
+ {data.success && data.analysis ? (
+
+ {data.analysis}
+
+ ) : (
+
{data.error || 'Analysis not available'}
+ )}
+
+ );
+}
+
+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 (
+
+ {/* Header */}
+
+
+ Portfolio Comparison
+
+
+ Compare patent portfolios of two companies side by side.
+
+
+
+ {/* Input Form */}
+
+
+ {/* Comparison Panels */}
+ {(queryA || queryB) && (
+
+ {queryA && (
+
+ )}
+ {queryB && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 7246839..da3f157 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -31,7 +31,7 @@ export function Login() {
};
return (
-
+
{/* Brand */}
diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx
index b3d0a6a..dd08b8c 100644
--- a/frontend/src/pages/Register.tsx
+++ b/frontend/src/pages/Register.tsx
@@ -40,7 +40,7 @@ export function Register() {
};
return (
-
+
{/* Brand */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 01df9b1..000f263 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,46 +1,32 @@
-export interface User {
- id: number;
- email: string;
- role: 'admin' | 'user';
- created_at: string;
-}
+/**
+ * Application types derived from the auto-generated OpenAPI schema.
+ *
+ * Run `npm run generate:local` (or `npm run generate` with the API running)
+ * to regenerate `src/api/schema.d.ts` from the backend OpenAPI spec.
+ *
+ * These aliases keep the rest of the codebase stable while the source of
+ * truth lives in the generated file.
+ */
-export interface TokenResponse {
- access_token: string;
- refresh_token: string;
- token_type: string;
-}
+import type { components } from '../api/schema';
-export interface CompanyAnalysis {
- company_name: string;
- analysis: string;
- patent_count: number;
- success: boolean;
- error: string | null;
- timestamp: string;
-}
-
-export interface BatchAnalysisResult {
- results: CompanyAnalysis[];
- total_companies: number;
- successful: number;
- failed: number;
- timestamp: string;
-}
-
-export interface JobStatus {
- job_id: string;
- status: 'pending' | 'running' | 'completed' | 'failed';
- progress: number;
- total_companies: number;
- completed_companies: number;
- result: BatchAnalysisResult | null;
- error: string | null;
-}
-
-export interface Analytics {
- total_messages: number;
+// Re-export schema types under the names the rest of the app expects.
+export type User = components['schemas']['UserResponse'];
+export type TokenResponse = components['schemas']['TokenResponse'];
+export type CompanyAnalysis = components['schemas']['CompanyAnalysisResponse'];
+export type BatchAnalysisResult = components['schemas']['BatchAnalysisResponse'];
+export type JobStatus = components['schemas']['JobStatus'];
+export type Analytics = Omit & {
by_company: Array<{ company_name: string; count: number }>;
by_type: Array<{ analysis_type: string; count: number }>;
- period_days: number;
-}
+};
+
+// Additional generated types that may be useful elsewhere.
+export type RegisterRequest = components['schemas']['RegisterRequest'];
+export type LoginRequest = components['schemas']['LoginRequest'];
+export type RefreshRequest = components['schemas']['RefreshRequest'];
+export type UpdateRoleRequest = components['schemas']['UpdateRoleRequest'];
+export type HealthResponse = components['schemas']['HealthResponse'];
+export type BatchAnalysisRequest = components['schemas']['BatchAnalysisRequest'];
+export type ValidationError = components['schemas']['ValidationError'];
+export type HTTPValidationError = components['schemas']['HTTPValidationError'];
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index c03684f..7587f56 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -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)',
},
},
},
diff --git a/requirements.txt b/requirements.txt
index 7e87235..f000b82 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,3 +14,7 @@ numpy
pandas
bcrypt
PyJWT
+slowapi
+apscheduler
+boto3
+reportlab
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..d3db2f3
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,8 @@
+[lint]
+select = ["E", "F", "I"]
+ignore = [
+ "E501", # line too long (handled by formatter)
+]
+
+[lint.per-file-ignores]
+"tests/*" = ["E402", "F841"] # allow import not at top of file, unused vars (mocks) in tests
diff --git a/scripts/init_database.py b/scripts/init_database.py
index 607ca1f..a61d68f 100644
--- a/scripts/init_database.py
+++ b/scripts/init_database.py
@@ -40,6 +40,9 @@ def main():
print("\nTables created:")
print(" - llm_messages: Stores all LLM prompts and responses")
print(" - users: Stores user accounts")
+ print(" - jobs: Stores async batch job state")
+ print(" - patents: Patent PDF cache")
+ print(" - serp_queries: SERP query result cache")
print("\nIndexes created:")
print(" - idx_messages_timestamp: For time-based queries")
print(" - idx_messages_company: For company-specific queries")
diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py
index 4fd6aa3..4977feb 100644
--- a/tests/test_analyzer.py
+++ b/tests/test_analyzer.py
@@ -1,9 +1,11 @@
"""Tests for the high-level company analyzer orchestration."""
+from unittest.mock import MagicMock, Mock
+
import pytest
-from unittest.mock import Mock, patch, call, MagicMock
+
from SPARC.analyzer import CompanyAnalyzer
-from SPARC.types import Patent, Patents, CompanyAnalysisResult, BatchAnalysisResult
+from SPARC.types import BatchAnalysisResult, Patent, Patents
@pytest.fixture(autouse=True)
@@ -24,7 +26,7 @@ class TestCompanyAnalyzer:
"""Test analyzer initialization with API key."""
mock_llm = mocker.patch("SPARC.analyzer.LLMAnalyzer")
- analyzer = CompanyAnalyzer(openrouter_api_key="test-key")
+ _analyzer = CompanyAnalyzer(openrouter_api_key="test-key") # noqa: F841
mock_llm.assert_called_once_with(api_key="test-key")
diff --git a/tests/test_api.py b/tests/test_api.py
index 4852f2e..fd16921 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,12 +1,13 @@
"""Tests for FastAPI web service endpoints."""
-import pytest
from datetime import datetime
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
+
+import pytest
from fastapi.testclient import TestClient
-from SPARC.api import app, _analyzer, _jobs
-from SPARC.types import CompanyAnalysisResult, BatchAnalysisResult
+from SPARC.api import app
+from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
@pytest.fixture
@@ -181,3 +182,47 @@ class TestJobEndpoints:
"""Test listing jobs with status filter."""
response = client.get("/jobs?status=completed")
assert response.status_code == 200
+
+
+class TestModelValidation:
+ """Test that unsupported model identifiers are rejected."""
+
+ def test_analyze_rejects_unsupported_model(self, client, mock_analyzer):
+ """GET /analyze/{company} with unsupported model returns 400."""
+ response = client.get("/analyze/nvidia?model=fake/nonexistent-model")
+ assert response.status_code == 400
+ assert "Unsupported model" in response.json()["detail"]
+
+ def test_analyze_accepts_supported_model(self, client, mock_analyzer):
+ """GET /analyze/{company} with a supported model succeeds."""
+ mock_result = CompanyAnalysisResult(
+ company_name="nvidia",
+ analysis="test",
+ patent_count=1,
+ success=True,
+ timestamp=datetime.now(),
+ model="anthropic/claude-3.5-sonnet",
+ )
+ mock_analyzer._analyze_company_safe.return_value = mock_result
+
+ response = client.get("/analyze/nvidia?model=anthropic/claude-3.5-sonnet")
+ assert response.status_code == 200
+
+ def test_batch_rejects_unsupported_model(self, client, mock_analyzer):
+ """POST /analyze/batch with unsupported model returns 400."""
+ response = client.post(
+ "/analyze/batch",
+ json={"companies": ["nvidia"], "model": "fake/nonexistent-model"},
+ )
+ assert response.status_code == 400
+ assert "Unsupported model" in response.json()["detail"]
+
+ def test_list_models_returns_supported(self, client):
+ """GET /models returns the allow-list."""
+ response = client.get("/models")
+ assert response.status_code == 200
+ data = response.json()
+ assert "models" in data
+ assert "default" in data
+ assert len(data["models"]) > 0
+ assert all("id" in m and "name" in m and "provider" in m for m in data["models"])
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..de79259
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,302 @@
+"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access."""
+
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from SPARC.api import app
+from SPARC.auth import create_access_token, create_refresh_token
+
+
+@pytest.fixture
+def client():
+ """Create test client."""
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def mock_db(monkeypatch):
+ """Mock the database client used by auth endpoints.
+
+ Returns a MagicMock with all DB methods pre-configured.
+ """
+ db = MagicMock()
+
+ # Default: no users exist
+ db.get_user_count.return_value = 0
+ db.get_user_by_id.return_value = None
+ db.get_user_by_email.return_value = None
+ db.authenticate_user.return_value = None
+ db.create_user.return_value = None
+ db.get_all_users.return_value = []
+ db.update_user_role.return_value = None
+ db.delete_user.return_value = False
+
+ with patch("SPARC.api.get_db_client", return_value=db), \
+ patch("SPARC.auth.get_db_client", return_value=db):
+ yield db
+
+
+def _make_admin_user():
+ return {
+ "id": 1,
+ "email": "admin@test.com",
+ "role": "admin",
+ "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ }
+
+
+def _make_regular_user():
+ return {
+ "id": 2,
+ "email": "user@test.com",
+ "role": "user",
+ "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ }
+
+
+def _auth_header(user_dict):
+ """Create an Authorization header with a valid access token for the given user."""
+ token = create_access_token(user_dict["id"], user_dict["email"], user_dict["role"])
+ return {"Authorization": f"Bearer {token}"}
+
+
+class TestRegister:
+ """POST /auth/register"""
+
+ def test_register_first_user_becomes_admin(self, client, mock_db):
+ """First registered user should get admin role."""
+ mock_db.get_user_count.return_value = 0
+ mock_db.create_user.return_value = {
+ "id": 1,
+ "email": "admin@test.com",
+ "role": "admin",
+ "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ }
+
+ response = client.post(
+ "/auth/register",
+ json={"email": "admin@test.com", "password": "securepass123"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["email"] == "admin@test.com"
+ assert data["role"] == "admin"
+ mock_db.create_user.assert_called_once_with(
+ email="admin@test.com", password="securepass123", role="admin"
+ )
+
+ def test_register_subsequent_user_gets_user_role(self, client, mock_db):
+ """Non-first user should get regular user role."""
+ mock_db.get_user_count.return_value = 1
+ mock_db.create_user.return_value = _make_regular_user()
+
+ response = client.post(
+ "/auth/register",
+ json={"email": "user@test.com", "password": "securepass123"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["role"] == "user"
+
+ def test_register_duplicate_email_returns_400(self, client, mock_db):
+ """Registering with an existing email should return 400."""
+ mock_db.get_user_count.return_value = 1
+ mock_db.create_user.return_value = None # indicates duplicate
+
+ response = client.post(
+ "/auth/register",
+ json={"email": "existing@test.com", "password": "securepass123"},
+ )
+
+ assert response.status_code == 400
+ assert "already registered" in response.json()["detail"].lower()
+
+
+class TestLogin:
+ """POST /auth/login"""
+
+ def test_login_valid_credentials_returns_tokens(self, client, mock_db):
+ """Valid credentials should return access and refresh tokens."""
+ user = _make_regular_user()
+ mock_db.authenticate_user.return_value = user
+
+ response = client.post(
+ "/auth/login",
+ json={"email": "user@test.com", "password": "correctpassword"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert data["token_type"] == "bearer"
+
+ def test_login_invalid_credentials_returns_401(self, client, mock_db):
+ """Invalid credentials should return 401."""
+ mock_db.authenticate_user.return_value = None
+
+ response = client.post(
+ "/auth/login",
+ json={"email": "user@test.com", "password": "wrongpassword"},
+ )
+
+ assert response.status_code == 401
+ assert "invalid" in response.json()["detail"].lower()
+
+
+class TestGetMe:
+ """GET /auth/me"""
+
+ def test_valid_access_token_returns_user(self, client, mock_db):
+ """A valid access token should return the user's data."""
+ user = _make_regular_user()
+ mock_db.get_user_by_id.return_value = user
+
+ response = client.get("/auth/me", headers=_auth_header(user))
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["email"] == "user@test.com"
+ assert data["id"] == 2
+
+ def test_missing_token_returns_401(self, client):
+ """No token should return 401 (403 from HTTPBearer)."""
+ response = client.get("/auth/me")
+ assert response.status_code in (401, 403)
+
+ def test_expired_token_returns_401(self, client, mock_db):
+ """An expired token should return 401."""
+ # Create a token that has already expired
+ from datetime import timedelta
+
+ import jwt as pyjwt
+ from SPARC.auth import JWT_ALGORITHM, JWT_SECRET
+
+ payload = {
+ "sub": "1",
+ "email": "user@test.com",
+ "role": "user",
+ "exp": datetime.now(timezone.utc) - timedelta(hours=1),
+ "type": "access",
+ }
+ expired_token = pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+ response = client.get(
+ "/auth/me", headers={"Authorization": f"Bearer {expired_token}"}
+ )
+ assert response.status_code == 401
+
+ def test_refresh_token_as_access_returns_401(self, client, mock_db):
+ """Using a refresh token as an access token should return 401."""
+ user = _make_regular_user()
+ refresh_token = create_refresh_token(user["id"], user["email"], user["role"])
+
+ response = client.get(
+ "/auth/me", headers={"Authorization": f"Bearer {refresh_token}"}
+ )
+ assert response.status_code == 401
+
+
+class TestRefreshToken:
+ """POST /auth/refresh"""
+
+ def test_valid_refresh_token_returns_new_tokens(self, client, mock_db):
+ """A valid refresh token should issue new access and refresh tokens."""
+ user = _make_regular_user()
+ mock_db.get_user_by_id.return_value = user
+ refresh = create_refresh_token(user["id"], user["email"], user["role"])
+
+ response = client.post(
+ "/auth/refresh", json={"refresh_token": refresh}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+
+ def test_invalid_refresh_token_returns_401(self, client, mock_db):
+ """An invalid refresh token should return 401."""
+ response = client.post(
+ "/auth/refresh", json={"refresh_token": "invalid-token-string"}
+ )
+ assert response.status_code == 401
+
+ def test_access_token_as_refresh_returns_401(self, client, mock_db):
+ """Using an access token as a refresh token should return 401."""
+ user = _make_regular_user()
+ access = create_access_token(user["id"], user["email"], user["role"])
+
+ response = client.post(
+ "/auth/refresh", json={"refresh_token": access}
+ )
+ assert response.status_code == 401
+
+
+class TestAdminUsers:
+ """GET /admin/users and PATCH /admin/users/{id}/role"""
+
+ def test_admin_can_list_users(self, client, mock_db):
+ """Admin token should allow listing users."""
+ admin = _make_admin_user()
+ mock_db.get_user_by_id.return_value = admin
+ mock_db.get_all_users.return_value = [admin, _make_regular_user()]
+
+ response = client.get("/admin/users", headers=_auth_header(admin))
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 2
+
+ def test_regular_user_cannot_list_users(self, client, mock_db):
+ """Regular user token should be rejected with 403."""
+ user = _make_regular_user()
+ mock_db.get_user_by_id.return_value = user
+
+ response = client.get("/admin/users", headers=_auth_header(user))
+
+ assert response.status_code == 403
+
+ def test_no_token_cannot_list_users(self, client):
+ """No token should be rejected."""
+ response = client.get("/admin/users")
+ assert response.status_code in (401, 403)
+
+ def test_admin_can_change_user_role(self, client, mock_db):
+ """Admin should be able to change another user's role."""
+ admin = _make_admin_user()
+ mock_db.get_user_by_id.return_value = admin
+ mock_db.update_user_role.return_value = {
+ "id": 2,
+ "email": "user@test.com",
+ "role": "admin",
+ "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ }
+
+ response = client.patch(
+ "/admin/users/2/role",
+ json={"role": "admin"},
+ headers=_auth_header(admin),
+ )
+
+ assert response.status_code == 200
+ assert response.json()["role"] == "admin"
+
+ def test_admin_cannot_change_own_role(self, client, mock_db):
+ """Admin should not be able to change their own role."""
+ admin = _make_admin_user()
+ mock_db.get_user_by_id.return_value = admin
+
+ response = client.patch(
+ "/admin/users/1/role",
+ json={"role": "user"},
+ headers=_auth_header(admin),
+ )
+
+ assert response.status_code == 400
+ assert "own role" in response.json()["detail"].lower()
diff --git a/tests/test_llm.py b/tests/test_llm.py
index 154fdac..56bac29 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -1,7 +1,9 @@
"""Tests for LLM analysis functionality."""
+from unittest.mock import Mock
+
import pytest
-from unittest.mock import Mock, MagicMock, patch
+
from SPARC.llm import LLMAnalyzer
diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py
new file mode 100644
index 0000000..f9f06af
--- /dev/null
+++ b/tests/test_rate_limit.py
@@ -0,0 +1,97 @@
+"""Tests for rate limiting on auth endpoints."""
+
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+from fastapi.testclient import TestClient
+
+from SPARC.api import app
+
+
+@pytest.fixture
+def client():
+ """Create test client with rate limiter enabled."""
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def reset_limiter():
+ """Reset rate limiter storage between tests."""
+ from SPARC.api import limiter
+ limiter.reset()
+ yield
+
+
+class TestRateLimiting:
+ """Test rate limiting on login and register endpoints."""
+
+ @patch("SPARC.api.get_db_client")
+ def test_login_allows_requests_under_limit(self, mock_db_client, client):
+ """Login endpoint allows requests under the rate limit."""
+ mock_db = MagicMock()
+ mock_db.authenticate_user.return_value = None
+ mock_db_client.return_value = mock_db
+
+ # Should allow at least a few requests
+ for _ in range(5):
+ response = client.post(
+ "/auth/login",
+ json={"email": "test@example.com", "password": "password123"},
+ )
+ # 401 is expected (invalid credentials), not 429
+ assert response.status_code == 401
+
+ @patch("SPARC.api.get_db_client")
+ def test_login_rate_limited_after_threshold(self, mock_db_client, client):
+ """Login endpoint returns 429 after exceeding rate limit."""
+ mock_db = MagicMock()
+ mock_db.authenticate_user.return_value = None
+ mock_db_client.return_value = mock_db
+
+ # Send more than the limit (10/minute)
+ statuses = []
+ for _ in range(15):
+ response = client.post(
+ "/auth/login",
+ json={"email": "test@example.com", "password": "password123"},
+ )
+ statuses.append(response.status_code)
+
+ # At least one should be 429
+ assert 429 in statuses, f"Expected 429 in statuses but got: {set(statuses)}"
+
+ @patch("SPARC.api.get_db_client")
+ def test_register_rate_limited_after_threshold(self, mock_db_client, client):
+ """Register endpoint returns 429 after exceeding rate limit."""
+ mock_db = MagicMock()
+ mock_db.get_user_count.return_value = 1
+ mock_db.create_user.return_value = None # triggers 400 (email exists)
+ mock_db_client.return_value = mock_db
+
+ # Send more than the limit (5/minute)
+ statuses = []
+ for _ in range(10):
+ response = client.post(
+ "/auth/register",
+ json={"email": "test@example.com", "password": "password123"},
+ )
+ statuses.append(response.status_code)
+
+ # At least one should be 429
+ assert 429 in statuses, f"Expected 429 in statuses but got: {set(statuses)}"
+
+ @patch("SPARC.api.get_db_client")
+ def test_rate_limit_returns_retry_after_header(self, mock_db_client, client):
+ """Rate limited responses include a Retry-After header."""
+ mock_db = MagicMock()
+ mock_db.authenticate_user.return_value = None
+ mock_db_client.return_value = mock_db
+
+ # Exhaust the limit
+ for _ in range(15):
+ response = client.post(
+ "/auth/login",
+ json={"email": "test@example.com", "password": "password123"},
+ )
+ if response.status_code == 429:
+ assert "Retry-After" in response.headers
+ break
diff --git a/tests/test_security.py b/tests/test_security.py
new file mode 100644
index 0000000..b6e4be1
--- /dev/null
+++ b/tests/test_security.py
@@ -0,0 +1,116 @@
+"""Tests for security hardening: JWT secret startup check, CORS config, credential handling."""
+
+import os
+from unittest.mock import patch
+
+import pytest
+
+
+class TestJWTSecretStartupCheck:
+ """Test the startup guard that refuses default JWT secret in non-dev environments."""
+
+ def test_default_secret_in_production_raises(self):
+ """Starting with default secret and APP_ENV=production must raise RuntimeError."""
+ with patch.dict(os.environ, {"APP_ENV": "production"}):
+ # Reload config to pick up the new APP_ENV
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+
+ from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret
+ # Patch JWT_SECRET to the default
+ with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET):
+ with pytest.raises(RuntimeError, match="FATAL.*JWT_SECRET"):
+ check_jwt_secret()
+
+ # Restore config
+ with patch.dict(os.environ, {"APP_ENV": "development"}):
+ importlib.reload(SPARC.config)
+
+ def test_default_secret_in_development_succeeds(self):
+ """Starting with default secret and APP_ENV=development must not raise."""
+ with patch.dict(os.environ, {"APP_ENV": "development"}):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+
+ from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret
+ with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET):
+ # Should not raise
+ check_jwt_secret()
+
+ # Restore
+ importlib.reload(SPARC.config)
+
+ def test_custom_secret_in_production_succeeds(self):
+ """Starting with a custom secret in production must not raise."""
+ with patch.dict(os.environ, {"APP_ENV": "production"}):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+
+ from SPARC.auth import check_jwt_secret
+ with patch("SPARC.auth.JWT_SECRET", "my-secure-random-secret-abc123"):
+ # Should not raise
+ check_jwt_secret()
+
+ with patch.dict(os.environ, {"APP_ENV": "development"}):
+ importlib.reload(SPARC.config)
+
+ def test_default_secret_unset_env_succeeds(self):
+ """When APP_ENV is unset (defaults to development), default secret is allowed."""
+ with patch.dict(os.environ, {}, clear=False):
+ # Remove APP_ENV if present
+ env = os.environ.copy()
+ env.pop("APP_ENV", None)
+ with patch.dict(os.environ, env, clear=True):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+
+ from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret
+ with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET):
+ # Should not raise (defaults to development)
+ check_jwt_secret()
+
+ with patch.dict(os.environ, {"APP_ENV": "development"}):
+ importlib.reload(SPARC.config)
+
+
+class TestCORSConfig:
+ """Test that CORS origins are configurable via environment variable."""
+
+ def test_default_cors_origins(self):
+ """When CORS_ORIGINS is unset, defaults to localhost origins."""
+ with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+ assert SPARC.config.cors_origins == [
+ "http://localhost:3000",
+ "http://localhost:5173",
+ ]
+
+ def test_custom_cors_origins(self):
+ """Setting CORS_ORIGINS configures allowed origins."""
+ with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+ assert SPARC.config.cors_origins == [
+ "https://sparc.example.com",
+ "https://app.example.com",
+ ]
+ # Restore
+ with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
+ importlib.reload(SPARC.config)
+
+ def test_single_cors_origin(self):
+ """A single origin without comma works correctly."""
+ with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
+ import importlib
+ import SPARC.config
+ importlib.reload(SPARC.config)
+ assert SPARC.config.cors_origins == ["https://sparc.example.com"]
+ with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
+ importlib.reload(SPARC.config)
diff --git a/tests/test_serp_api.py b/tests/test_serp_api.py
index e6d123d..d6e429b 100644
--- a/tests/test_serp_api.py
+++ b/tests/test_serp_api.py
@@ -1,9 +1,8 @@
"""Tests for SERP API patent retrieval and parsing functionality."""
-import os
-import pytest
-from unittest.mock import patch, Mock
from datetime import datetime, timedelta
+from unittest.mock import Mock
+
from SPARC.serp_api import SERP
from SPARC.types import Patent