From e8cdc089fadce8a2a512e505662243495187730a Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 00:06:56 +0000 Subject: [PATCH 01/32] chore: add ROADMAP.md for SPARC application development - Document current project state and architecture - Identify P1 priorities: security hardening, error handling, test coverage - Identify P2 priorities: structured logging, configurable LLM, frontend polish, CI tests - Identify P3 priorities: export, comparison, scheduled analysis, notifications - Reference Talos repo for infrastructure/deployment concerns Closes leeworks-agents/SPARC#2 Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 ROADMAP.md 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. -- 2.52.0 From 47cddcbeaf705be15352dc215b77433b75df2f2e Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:06:31 +0000 Subject: [PATCH 02/32] feat(security): add JWT startup guard, configurable CORS, and externalize DB credentials - Add check_jwt_secret() that refuses default JWT secret when APP_ENV != development - Make CORS origins configurable via CORS_ORIGINS env var (comma-separated) - Replace hardcoded postgres credentials in docker-compose.yml with env var references - Add APP_ENV and cors_origins to config.py - Update .env.example with all required variables and documentation - Add tests for JWT startup guard and CORS configuration Closes leeworks-agents/SPARC#4 Closes leeworks-agents/SPARC#5 Closes leeworks-agents/SPARC#6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 39 ++++++++++---- SPARC/api.py | 4 +- SPARC/auth.py | 16 +++++- SPARC/config.py | 13 +++++ docker-compose.yml | 14 ++--- tests/test_security.py | 116 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 tests/test_security.py diff --git a/.env.example b/.env.example index acf4901..4e78c43 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,42 @@ # 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 + +# ---- 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 diff --git a/SPARC/api.py b/SPARC/api.py index 482caab..23d2f2b 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -16,6 +16,7 @@ from SPARC.analyzer import CompanyAnalyzer from SPARC.auth import ( TokenResponse, UserResponse, + check_jwt_secret, create_tokens, decode_token, get_current_admin, @@ -150,6 +151,7 @@ _analyzer: CompanyAnalyzer | None = None async def lifespan(app: FastAPI): """Initialize resources on startup.""" global _analyzer + check_jwt_secret() _analyzer = CompanyAnalyzer() yield # Cleanup if needed @@ -167,7 +169,7 @@ app = FastAPI( # 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=["*"], diff --git a/SPARC/auth.py b/SPARC/auth.py index 4a5a28f..d134ad8 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() diff --git a/SPARC/config.py b/SPARC/config.py index 31bee7a..2bedc63 100644 --- a/SPARC/config.py +++ b/SPARC/config.py @@ -33,3 +33,16 @@ patent_thread_workers = int(os.getenv("PATENT_THREAD_WORKERS", "5")) # 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") + +# 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/docker-compose.yml b/docker-compose.yml index 7bbdbe2..fa42f8c 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" 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) -- 2.52.0 From e2d750146c2ec07a7d18d1c1bdf037d78258c171 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:08:22 +0000 Subject: [PATCH 03/32] feat(auth): add rate limiting to login and register endpoints - Add slowapi rate limiter: 10 req/min for /auth/login, 5 req/min for /auth/register - Return HTTP 429 with Retry-After header when limit is exceeded - Add slowapi to requirements.txt - Add 4 passing tests for rate limit behavior Closes leeworks-agents/SPARC#9 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 34 +++++++++++--- requirements.txt | 1 + tests/test_rate_limit.py | 97 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 tests/test_rate_limit.py diff --git a/SPARC/api.py b/SPARC/api.py index 482caab..fef8a8f 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -7,9 +7,13 @@ 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 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 @@ -164,6 +168,22 @@ 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, @@ -178,7 +198,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 +211,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 +231,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( diff --git a/requirements.txt b/requirements.txt index 7e87235..e854576 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ numpy pandas bcrypt PyJWT +slowapi 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 -- 2.52.0 From 3dac88ec90f71724f78a63b43d4ba7811a0c795d Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:17:09 +0000 Subject: [PATCH 04/32] docs: document patent PDF storage, add FileNotFoundError, commit lockfile - Add docstring to analyze_single_patent explaining the PDF prerequisite - Raise FileNotFoundError with helpful message when PDF is missing - Add patent PDF storage section to README with Docker volume mount example - Commit frontend/package-lock.json for reproducible builds Closes leeworks-agents/SPARC#15 Closes leeworks-agents/SPARC#17 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 15 + SPARC/analyzer.py | 24 +- frontend/package-lock.json | 4728 ++++++++++++++++++++++++++++++++++++ 3 files changed, 4762 insertions(+), 5 deletions(-) create mode 100644 frontend/package-lock.json 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/SPARC/analyzer.py b/SPARC/analyzer.py index 7f61283..9cd2fe7 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -104,21 +104,33 @@ class CompanyAnalyzer: def analyze_single_patent(self, patent_id: str, company_name: str) -> str: """Analyze a single patent by ID. - Useful for focused analysis of specific innovations. + Prerequisite: + The patent PDF must already exist at ``patents/{patent_id}.pdf`` + before calling this method. PDFs are downloaded automatically when + using the batch analysis pipeline (``analyze_company`` or the + ``/analyze/batch`` API endpoint). For standalone usage, download + the PDF manually or call ``SERP.save_patents()`` first. 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) Returns: Analysis of the specific patent's innovation quality + + Raises: + FileNotFoundError: If the patent PDF is not found at the expected path. """ - # 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 patent_path = f"patents/{patent_id}.pdf" + if not os.path.exists(patent_path): + raise FileNotFoundError( + f"Patent PDF not found at '{patent_path}'. " + f"Download the PDF first using SERP.save_patents() or the batch analysis pipeline." + ) + try: sections = SERP.parse_patent_pdf(patent_path) minimized_content = SERP.minimize_patent_for_llm(sections) @@ -129,6 +141,8 @@ class CompanyAnalyzer: return analysis + except FileNotFoundError: + raise except Exception as e: return f"Failed to analyze patent {patent_id}: {e}" diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4f5dab7 --- /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": "^0.400.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": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "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" + } + } + } +} -- 2.52.0 From 96d5d27b17719da23a4a6312e65bc38320043a2b Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:22:57 +0000 Subject: [PATCH 05/32] feat(jobs): persist async batch job state in PostgreSQL - Add jobs table to database schema (job_id, status, progress, result_json, etc.) - Add DatabaseClient methods: create_job, update_job, get_job, list_jobs - Add mark_stale_jobs_failed() called at startup to handle interrupted jobs - Refactor _run_batch_job and job endpoints to read/write from PostgreSQL - Remove in-memory _jobs dict; job state now survives API restarts - Update init_database.py to list all tables in output Closes leeworks-agents/SPARC#8 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 102 ++++++++++++++++++--------- SPARC/database.py | 145 +++++++++++++++++++++++++++++++++++++++ scripts/init_database.py | 3 + tests/test_api.py | 2 +- 4 files changed, 218 insertions(+), 34 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index 482caab..139d3b1 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -114,8 +114,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 @@ -148,9 +147,19 @@ _analyzer: CompanyAnalyzer | None = None @asynccontextmanager async def lifespan(app: FastAPI): - """Initialize resources on startup.""" + """Initialize resources on startup, clean up on shutdown.""" global _analyzer _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() yield # Cleanup if needed _analyzer = None @@ -422,20 +431,52 @@ async def analyze_companies_batch( return _convert_batch_result(result) +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): """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( @@ -443,12 +484,15 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int): max_workers=max_workers, progress_callback=progress_callback, ) - _jobs[job_id].status = "completed" - _jobs[job_id].progress = 100 - _jobs[job_id].result = _convert_batch_result(result) + 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), + ) 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)) @app.post("/analyze/batch/async", response_model=JobStatus, tags=["Analysis"]) @@ -473,19 +517,14 @@ async def analyze_companies_async( _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 ) - return _jobs[job_id] + return _job_row_to_status(job_row) @app.get("/jobs/{job_id}", response_model=JobStatus, tags=["Jobs"]) @@ -501,10 +540,13 @@ 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"]) @@ -525,12 +567,6 @@ async def list_jobs( Returns: List of job statuses """ - jobs = list(_jobs.values()) - - if status: - jobs = [j for j in jobs if j.status == status] - - # Return most recent first - jobs.sort(key=lambda j: j.job_id, reverse=True) - - return jobs[:limit] + db = _get_job_db() + job_rows = db.list_jobs(status=status, limit=limit) + return [_job_row_to_status(row) for row in job_rows] diff --git a/SPARC/database.py b/SPARC/database.py index 0468312..cc55304 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -171,6 +171,26 @@ 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) + """) + self.conn.commit() @staticmethod @@ -462,6 +482,131 @@ 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, + ) -> List[Dict]: + """List jobs, optionally filtered by status.""" + query = "SELECT * FROM jobs" + params: list = [] + if status: + query += " WHERE status = %s" + params.append(status) + query += " ORDER BY created_at DESC LIMIT %s" + params.append(limit) + + with self.get_conn() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + + 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 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_api.py b/tests/test_api.py index 4852f2e..a5923c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ from datetime import datetime from unittest.mock import Mock, patch from fastapi.testclient import TestClient -from SPARC.api import app, _analyzer, _jobs +from SPARC.api import app from SPARC.types import CompanyAnalysisResult, BatchAnalysisResult -- 2.52.0 From ae9f257dcb2934034491e1924a08289675bca134 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:24:12 +0000 Subject: [PATCH 06/32] test(auth): add comprehensive JWT authentication test suite Add 17 tests in tests/test_auth.py covering all auth flows: - Registration: first user admin, subsequent user, duplicate email - Login: valid credentials, invalid credentials - Protected routes: valid token, missing token, expired token, wrong token type - Token refresh: valid refresh, invalid refresh, access-as-refresh rejected - Admin endpoints: list users, change role, own-role prevention, permission checks All tests use mocked database (no live DB required). Closes leeworks-agents/SPARC#10 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_auth.py | 302 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 tests/test_auth.py 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() -- 2.52.0 From b0001465850208deefd0c65fec08c49fb2edab27 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:12:00 +0000 Subject: [PATCH 07/32] feat: configurable LLM model, SERP cache TTL, structured logging, fix patent_id type - Make LLM model configurable via MODEL env var, default anthropic/claude-3.5-sonnet (#12) - Expose SERP cache TTL as SERP_CACHE_TTL_HOURS env var, default 24 hours (#13) - Fix Patent.patent_id type annotation from int to str in types.py (#14) - Replace all print() calls with structured logging in analyzer.py and llm.py (#11) - Add LOG_LEVEL config with basicConfig setup in config.py - Add model and serp_cache_ttl_hours to config.py Closes leeworks-agents/SPARC#11 Closes leeworks-agents/SPARC#12 Closes leeworks-agents/SPARC#13 Closes leeworks-agents/SPARC#14 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/analyzer.py | 37 +++++++++++++++++++++---------------- SPARC/config.py | 17 ++++++++++++++++- SPARC/llm.py | 17 +++++++++-------- SPARC/types.py | 2 +- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/SPARC/analyzer.py b/SPARC/analyzer.py index 9cd2fe7..2d677cc 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -5,10 +5,13 @@ 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 @@ -52,13 +55,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 +69,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,12 +91,12 @@ 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( @@ -122,6 +126,7 @@ class CompanyAnalyzer: FileNotFoundError: If the patent PDF is not found at the expected path. """ import os + logger.info("Analyzing patent %s for %s...", patent_id, company_name) patent_path = f"patents/{patent_id}.pdf" @@ -183,7 +188,7 @@ 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: @@ -254,7 +259,7 @@ 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 = { @@ -271,8 +276,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) @@ -287,12 +292,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, @@ -318,20 +323,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/config.py b/SPARC/config.py index 2bedc63..e6f6173 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,12 @@ 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", "") diff --git a/SPARC/llm.py b/SPARC/llm.py index 2e60c9b..707a0d6 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) @@ -59,11 +64,7 @@ Patent Content: Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage.""" 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 @@ -165,7 +166,7 @@ Patent Portfolio: Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook.""" if self.test_mode: - print(prompt) + logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt) return "[TEST MODE]" metadata = { diff --git a/SPARC/types.py b/SPARC/types.py index 09c17d1..5bb692b 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 -- 2.52.0 From d366443b38f86dd50de171666d85e5bc32b67e73 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:15:03 +0000 Subject: [PATCH 08/32] refactor(db): use shared pooled DatabaseClient singleton instead of per-call instances - Replace get_db_client() creating new DatabaseClient on every call with a module-level singleton initialized once at startup via init_db_client() - Add init_db_client() and close_db_client() lifecycle functions called from FastAPI lifespan handler - Migrate all DatabaseClient methods from legacy self.connect()/self.conn to pooled self.get_conn() context manager for thread-safe connection reuse - Pool is properly torn down on application shutdown Closes leeworks-agents/SPARC#7 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 6 +- SPARC/auth.py | 33 ++++- SPARC/database.py | 317 ++++++++++++++++++++++------------------------ 3 files changed, 186 insertions(+), 170 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index 01b103c..a78c132 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -21,11 +21,13 @@ 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 @@ -155,6 +157,7 @@ async def lifespan(app: FastAPI): """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 @@ -167,8 +170,9 @@ async def lifespan(app: FastAPI): logging.getLogger(__name__).warning("Marked %d stale jobs as failed on startup", stale) _db.close() yield - # Cleanup if needed + # Cleanup _analyzer = None + close_db_client() app = FastAPI( diff --git a/SPARC/auth.py b/SPARC/auth.py index d134ad8..890d286 100644 --- a/SPARC/auth.py +++ b/SPARC/auth.py @@ -146,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/database.py b/SPARC/database.py index cc55304..a22d8e9 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -221,8 +221,6 @@ class DatabaseClient: Returns: Cached message dict if found, None otherwise """ - self.connect() - prompt_hash = self.hash_prompt(prompt) query = """ @@ -245,10 +243,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, @@ -276,33 +275,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 @@ -324,8 +322,6 @@ class DatabaseClient: Returns: List of message dictionaries """ - self.connect() - query = "SELECT * FROM llm_messages WHERE 1=1" params = [] @@ -340,9 +336,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. @@ -353,53 +350,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 @@ -650,25 +646,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]: @@ -681,23 +675,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. @@ -708,15 +701,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. @@ -727,15 +719,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). @@ -747,19 +738,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). @@ -771,20 +761,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: @@ -796,12 +785,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: @@ -810,8 +798,7 @@ class DatabaseClient: Returns: Number of users """ - self.connect() - - with self.conn.cursor() as cursor: - cursor.execute("SELECT COUNT(*) FROM users") - return cursor.fetchone()[0] + with self.get_conn() as conn: + with conn.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM users") + return cursor.fetchone()[0] -- 2.52.0 From fbb72fe2a54910079ea1ae279659c82dc193b5d3 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:19:45 +0000 Subject: [PATCH 09/32] ci: add pytest and ruff linting to CI, fix all lint errors - Add test job to build.yaml that runs pytest and ruff before building images - Add standalone test.yaml workflow for PRs - Add ruff.toml with E/F/I rules configured - Fix all ruff lint errors: sort imports, remove unused imports, fix re-exports - Build jobs now depend on test job passing (needs: test) Closes leeworks-agents/SPARC#18 Closes leeworks-agents/SPARC#19 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yaml | 37 +++++++++++++++++++++++++++++ .gitea/workflows/test.yaml | 46 +++++++++++++++++++++++++++++++++++++ SPARC/__init__.py | 5 ++-- SPARC/analyzer.py | 4 ++-- SPARC/database.py | 13 ++++++----- SPARC/serp_api.py | 13 +++++++---- ruff.toml | 8 +++++++ tests/test_analyzer.py | 8 ++++--- tests/test_api.py | 7 +++--- tests/test_llm.py | 4 +++- tests/test_serp_api.py | 5 ++-- 11 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/test.yaml create mode 100644 ruff.toml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index f13e13a..106a517 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,7 +9,43 @@ 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: 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 +117,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..9f452fb --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,46 @@ +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: 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/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 2d677cc..996558a 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -13,9 +13,9 @@ 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: diff --git a/SPARC/database.py b/SPARC/database.py index a22d8e9..4492311 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: diff --git a/SPARC/serp_api.py b/SPARC/serp_api.py index b4254d0..cb6a8af 100644 --- a/SPARC/serp_api.py +++ b/SPARC/serp_api.py @@ -1,12 +1,15 @@ import os -import serpapi -from SPARC import config 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.types import Patent, Patents + class SERP: def query(company: str, days_back: int = None) -> Patents: 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/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 a5923c6..169be27 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 -from SPARC.types import CompanyAnalysisResult, BatchAnalysisResult +from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult @pytest.fixture 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_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 -- 2.52.0 From 4696838fb811dd1082afff847ef1a0dcf15b27c6 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:05:55 +0000 Subject: [PATCH 10/32] ci: add tsc --noEmit TypeScript type checking to CI pipeline Upgrade lucide-react to v1.7.0 for proper TypeScript declarations and add a TypeScript type check step to the test workflow. Both ruff (Python) and tsc --noEmit (TypeScript) now block merging on failure. Closes leeworks-agents/SPARC#52 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/test.yaml | 11 +++++++++++ frontend/package-lock.json | 8 ++++---- frontend/package.json | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 9f452fb..49db9b9 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -34,6 +34,17 @@ jobs: run: | ruff check SPARC/ tests/ + - name: Install Node.js and frontend dependencies + shell: sh + run: | + apk add --no-cache nodejs npm + cd frontend && npm ci + + - name: Run TypeScript type check + shell: sh + run: | + cd frontend && npx tsc --noEmit + - name: Run pytest shell: sh env: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f5dab7..ca0ca36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.51.0", "axios": "^1.7.2", - "lucide-react": "^0.400.0", + "lucide-react": "^1.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0", @@ -3452,9 +3452,9 @@ } }, "node_modules/lucide-react": { - "version": "0.400.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", - "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/frontend/package.json b/frontend/package.json index b99eee1..2d3e00e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,12 +7,13 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "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", -- 2.52.0 From 0b4d712fc5a7e06fd0e5cb9858327e51e7b19987 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:07:07 +0000 Subject: [PATCH 11/32] feat: add structured logging to serp_api.py Add module-level logger to serp_api.py with INFO-level messages for patent queries and PDF downloads, and DEBUG-level messages for cache hits and parsing details. All three target files (analyzer.py, serp_api.py, llm.py) now use structured logging with no print() calls. Closes leeworks-agents/SPARC#46 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/serp_api.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/SPARC/serp_api.py b/SPARC/serp_api.py index cb6a8af..620cfd4 100644 --- a/SPARC/serp_api.py +++ b/SPARC/serp_api.py @@ -1,3 +1,4 @@ +import logging import os import re from datetime import datetime, timedelta @@ -10,6 +11,8 @@ import serpapi from SPARC import config from SPARC.types import Patent, Patents +logger = logging.getLogger(__name__) + class SERP: def query(company: str, days_back: int = None) -> Patents: @@ -44,6 +47,7 @@ class SERP: "tbs": date_filter, "api_key": config.api_key, } + logger.info("Querying Google Patents for '%s' (last %d days)", company, days_back) search = serpapi.search(params) # Convert results to Patent objects, skipping any without PDF links patent_ids = [] @@ -52,8 +56,10 @@ 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: @@ -70,9 +76,13 @@ class SERP: os.makedirs("patents", exist_ok=True) if not (os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0): + 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) + logger.debug("Saved %d bytes to %s", len(response.content), pdf_path) + else: + logger.debug("Using cached PDF for %s at %s", patent.patent_id, pdf_path) patent.pdf_path = pdf_path return patent @@ -90,11 +100,13 @@ class SERP: Dictionary containing all extracted sections """ + logger.debug("Parsing patent PDF: %s", pdf_path) with pdfplumber.open(pdf_path) 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 = { -- 2.52.0 From ecc2c37bcd586437b67938642709f3569e3e0da2 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:08:34 +0000 Subject: [PATCH 12/32] fix: auto-download patent PDF in analyze_single_patent before reading When the PDF is not on disk, analyze_single_patent now looks up the cached PDF link from the database and downloads it automatically. If no link is cached, a clear FileNotFoundError is raised. Also adds a GET /analyze/patent/{patent_id} API endpoint that exposes this functionality and returns 404 when the PDF cannot be obtained. Closes leeworks-agents/SPARC#36 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/analyzer.py | 32 +++++++++++++++++++++----------- SPARC/api.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/SPARC/analyzer.py b/SPARC/analyzer.py index 996558a..c55803b 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -108,12 +108,10 @@ class CompanyAnalyzer: def analyze_single_patent(self, patent_id: str, company_name: str) -> str: """Analyze a single patent by ID. - Prerequisite: - The patent PDF must already exist at ``patents/{patent_id}.pdf`` - before calling this method. PDFs are downloaded automatically when - using the batch analysis pipeline (``analyze_company`` or the - ``/analyze/batch`` API endpoint). For standalone usage, download - the PDF manually or call ``SERP.save_patents()`` first. + If the patent PDF is not already on disk, this method attempts to + download it automatically by looking up the PDF link in the database + cache. If the link is not cached either, a ``FileNotFoundError`` is + raised with instructions on how to obtain the PDF. Args: patent_id: Publication ID of the patent (e.g. "US-11234567-B2") @@ -123,7 +121,7 @@ class CompanyAnalyzer: Analysis of the specific patent's innovation quality Raises: - FileNotFoundError: If the patent PDF is not found at the expected path. + FileNotFoundError: If the patent PDF cannot be found or downloaded. """ import os logger.info("Analyzing patent %s for %s...", patent_id, company_name) @@ -131,10 +129,22 @@ class CompanyAnalyzer: patent_path = f"patents/{patent_id}.pdf" if not os.path.exists(patent_path): - raise FileNotFoundError( - f"Patent PDF not found at '{patent_path}'. " - f"Download the PDF first using SERP.save_patents() or the batch analysis pipeline." - ) + # Attempt to download the PDF automatically from cached metadata + cached = self.db.get_cached_patent(patent_id) + pdf_link = cached.get("pdf_link") if cached else None + + if pdf_link: + logger.info("PDF not on disk; downloading %s from cached link", patent_id) + patent = SERP.save_patents( + Patent(patent_id=patent_id, pdf_link=pdf_link) + ) + patent_path = patent.pdf_path + else: + raise FileNotFoundError( + f"Patent PDF not found at '{patent_path}' and no download link is " + f"cached for '{patent_id}'. Run a company analysis first to populate " + f"the cache, or call SERP.save_patents() with the patent's PDF link." + ) try: sections = SERP.parse_patent_pdf(patent_path) diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..e4b7d42 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -429,6 +429,38 @@ async def analyze_company( return _convert_result(result) +@app.get( + "/analyze/patent/{patent_id}", + tags=["Analysis"], +) +async def analyze_single_patent( + patent_id: str, + company_name: str = Query(description="Company name for analysis context"), + _: UserResponse = Depends(get_current_user), +): + """Analyze a single patent by its publication ID. + + If the patent PDF is not already cached locally, the system will attempt + to download it automatically from a previously cached link. If no link + is available, a 404 error is returned. + + Args: + patent_id: Patent publication ID (e.g. "US-11234567-B2") + company_name: Company name for analysis context + + Returns: + Analysis text for the patent + """ + if not _analyzer: + raise HTTPException(status_code=503, detail="Analyzer not initialized") + + try: + analysis = _analyzer.analyze_single_patent(patent_id, company_name) + return {"patent_id": patent_id, "company_name": company_name, "analysis": analysis} + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @app.post( "/analyze/batch", response_model=BatchAnalysisResponse, -- 2.52.0 From 153eb3b96892bf42c01a4d1e142ac5b54b3cf8f2 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:11:47 +0000 Subject: [PATCH 13/32] feat: improve loading and error states on Batch and Analytics pages Analytics page now shows skeleton loaders (cards and chart placeholders) while data loads, and displays a retry button when the API call fails. Batch page error state now shows the actual error message and suggests user action. Closes leeworks-agents/SPARC#16 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/Analytics.tsx | 44 +++++++++++++++++++++++++------- frontend/src/pages/Batch.tsx | 18 ++++++++++--- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 19f4aff..0072a19 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -9,15 +9,38 @@ const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6' export function AnalyticsPage() { const [days, setDays] = useState(30); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['analytics', days], queryFn: () => analyticsApi.getAnalytics(days), }); if (isLoading) { return ( -
-
+
+
+

+ Analytics Dashboard +

+

Loading analytics data...

+
+ {/* Skeleton cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ {/* Skeleton charts */} +
+ {[1, 2].map((i) => ( +
+
+
+
+ ))} +
); } @@ -33,15 +56,18 @@ export function AnalyticsPage() {
- Database Not Connected + Unable to Load Analytics

- Set USE_DATABASE=true in your .env file to enable analytics tracking. + Could not connect to the analytics database. Ensure PostgreSQL is running and + DATABASE_URL is configured correctly.

-
-
- - Analytics features require storing analysis results in PostgreSQL for historical tracking. +
); diff --git a/frontend/src/pages/Batch.tsx b/frontend/src/pages/Batch.tsx index 9b9b351..6620597 100644 --- a/frontend/src/pages/Batch.tsx +++ b/frontend/src/pages/Batch.tsx @@ -114,9 +114,21 @@ export function Batch() { {/* Error */} {mutation.isError && ( -
- - Batch analysis failed. Please try again. +
+
+ + Batch analysis failed +
+

+ {mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'} + {' '}Check your connection and try again. +

+
)} -- 2.52.0 From a4aa968434be047bcf0bd193f9c8a4e59d17c603 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:15:11 +0000 Subject: [PATCH 14/32] feat: add dark/light mode toggle with localStorage persistence - Enable Tailwind "class" dark mode strategy - Use CSS custom properties for theme colors (bg, text, border) - Add ThemeProvider context with toggle and localStorage persistence - Add Sun/Moon toggle button in the header navigation - Inline script in index.html prevents FOUC on page load - All pages (Layout, Login, Register, ProtectedRoute) support both modes - Default theme follows system preference (prefers-color-scheme) Closes leeworks-agents/SPARC#33 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/index.html | 9 ++++ frontend/src/App.tsx | 3 ++ frontend/src/components/Layout.tsx | 13 +++++- frontend/src/components/ProtectedRoute.tsx | 2 +- frontend/src/context/ThemeContext.tsx | 48 ++++++++++++++++++++++ frontend/src/index.css | 24 ++++++++++- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Register.tsx | 2 +- frontend/tailwind.config.js | 13 +++--- 9 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 frontend/src/context/ThemeContext.tsx 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/src/App.tsx b/frontend/src/App.tsx index c3426cd..c20ca32 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'; @@ -22,6 +23,7 @@ const queryClient = new QueryClient({ function App() { return ( + @@ -61,6 +63,7 @@ function App() { + ); } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 501dc1f..bf18963 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, Sun, Moon } from 'lucide-react'; export function Layout() { const { user, isAdmin, logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const navigate = useNavigate(); const handleLogout = () => { @@ -23,7 +25,7 @@ export function Layout() { } return ( -
+
{/* Header */}
@@ -63,6 +65,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/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/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/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)', }, }, }, -- 2.52.0 From 9a43f852599c8b090d67c5f9ca8973f86a41efc7 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:17:24 +0000 Subject: [PATCH 15/32] feat: add S3/MinIO object storage support for patent PDFs Introduce a StorageBackend abstraction (local filesystem and S3) for patent PDF storage. When STORAGE_BACKEND=s3, PDFs are read/written via boto3 to an S3-compatible bucket instead of the local filesystem. - Add SPARC/storage.py with LocalStorageBackend and S3StorageBackend - Update serp_api.py save_patents and parse_patent_pdf to use storage - Add storage config vars to config.py and .env.example - Add optional MinIO service to docker-compose.yml (--profile s3) - Add boto3 to requirements.txt Closes leeworks-agents/SPARC#38 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 12 ++++ SPARC/config.py | 7 ++ SPARC/serp_api.py | 55 +++++++++++---- SPARC/storage.py | 171 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 24 +++++++ requirements.txt | 1 + 6 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 SPARC/storage.py diff --git a/.env.example b/.env.example index 4e78c43..71df3b5 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,18 @@ JWT_SECRET=your-secure-jwt-secret-change-in-production # Defaults to http://localhost:3000,http://localhost:5173 when unset # CORS_ORIGINS=https://sparc.example.com,https://app.example.com +# ---- Storage ---- + +# Backend for patent PDF storage: "local" (default) or "s3" +STORAGE_BACKEND=local + +# S3/MinIO settings (only used when STORAGE_BACKEND=s3) +# S3_BUCKET=sparc-patents +# S3_ENDPOINT_URL=http://localhost:9000 +# AWS_ACCESS_KEY_ID=minioadmin +# AWS_SECRET_ACCESS_KEY=minioadmin +# To start MinIO locally: docker compose --profile s3 up -d minio + # ---- Cache ---- # When USE_CACHE=true: check database for cached responses before making API calls diff --git a/SPARC/config.py b/SPARC/config.py index e6f6173..4d89742 100644 --- a/SPARC/config.py +++ b/SPARC/config.py @@ -53,6 +53,13 @@ root_path = os.getenv("ROOT_PATH", "") # Used for safety checks (e.g., refusing default JWT secret in production) app_env = os.getenv("APP_ENV", "development") +# Storage backend: "local" (default) or "s3" for S3/MinIO object storage +storage_backend = os.getenv("STORAGE_BACKEND", "local") +s3_bucket = os.getenv("S3_BUCKET", "sparc-patents") +s3_endpoint_url = os.getenv("S3_ENDPOINT_URL", "") +s3_access_key = os.getenv("AWS_ACCESS_KEY_ID", "") +s3_secret_key = os.getenv("AWS_SECRET_ACCESS_KEY", "") + # CORS allowed origins (comma-separated) # Defaults to localhost dev origins when unset _cors_origins_raw = os.getenv("CORS_ORIGINS", "") diff --git a/SPARC/serp_api.py b/SPARC/serp_api.py index cb6a8af..af48039 100644 --- a/SPARC/serp_api.py +++ b/SPARC/serp_api.py @@ -1,4 +1,5 @@ -import os +import io +import logging import re from datetime import datetime, timedelta from typing import Dict @@ -8,8 +9,21 @@ import requests import serpapi from SPARC import config +from SPARC.storage import StorageBackend, get_storage_backend from SPARC.types import Patent, Patents +logger = logging.getLogger(__name__) + +# Module-level storage instance (lazy-initialized) +_storage: StorageBackend | None = None + + +def _get_storage() -> StorageBackend: + global _storage + if _storage is None: + _storage = get_storage_backend() + return _storage + class SERP: def query(company: str, days_back: int = None) -> Patents: @@ -57,8 +71,9 @@ class SERP: return Patents(patents=patent_ids) def save_patents(patent: Patent) -> Patent: - """ - Save the patent PDF to the patents folder, skipping download if already cached. + """Save the patent PDF to storage, skipping download if already cached. + + Uses the configured storage backend (local filesystem or S3). Args: patent: Patent object @@ -66,35 +81,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/docker-compose.yml b/docker-compose.yml index fa42f8c..95cc313 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,29 @@ services: - ./patents:/app/patents restart: unless-stopped + # Optional: MinIO for S3-compatible local object storage + # Enable by setting STORAGE_BACKEND=s3 in .env + minio: + image: minio/minio:latest + container_name: sparc-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-minioadmin} + MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-minioadmin} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + profiles: + - s3 + dashboard: build: ./frontend container_name: sparc-dashboard @@ -63,3 +86,4 @@ services: volumes: postgres_data: + minio_data: diff --git a/requirements.txt b/requirements.txt index e854576..ad2637d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ pandas bcrypt PyJWT slowapi +boto3 -- 2.52.0 From 3b6411869dfc59d08db1f78cd03ea1314f831307 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:19:01 +0000 Subject: [PATCH 16/32] feat: add cursor-based pagination to /jobs endpoint Add a cursor query parameter to GET /jobs and return a next_cursor field in the response envelope. Existing clients using only limit continue to work without modification. The cursor is an opaque token encoding created_at and job_id for stable keyset pagination. Closes leeworks-agents/SPARC#25 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 44 +++++++++++++++++++++++++++++++++++++++----- SPARC/database.py | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..2d6aadb 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -77,6 +77,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.""" @@ -577,24 +584,51 @@ async def get_job_status( return _job_row_to_status(job_row) -@app.get("/jobs", response_model=list[JobStatus], tags=["Jobs"]) +@app.get("/jobs", response_model=PaginatedJobsResponse, tags=["Jobs"]) async def list_jobs( status: Annotated[ str | None, Query(description="Filter by status: pending, running, completed, failed"), ] = None, limit: Annotated[int, Query(ge=1, le=100)] = 10, + cursor: Annotated[ + str | None, + Query(description="Opaque cursor from a previous response's next_cursor field"), + ] = None, _: UserResponse = Depends(get_current_user), ): - """List all analysis jobs. + """List analysis jobs with cursor-based pagination. + + Pass ``limit`` to control page size. The response includes a ``next_cursor`` + field; pass it back as the ``cursor`` query parameter to fetch the next page. + When ``next_cursor`` is ``null``, there are no more results. + + Existing clients that use only ``limit`` (without ``cursor``) continue to + work without modification. Args: status: Optional filter by job status limit: Maximum number of jobs to return (default 10, max 100) + cursor: Opaque pagination cursor from a previous response Returns: - List of job statuses + Paginated list of job statuses """ db = _get_job_db() - job_rows = db.list_jobs(status=status, limit=limit) - return [_job_row_to_status(row) for row in job_rows] + # Fetch one extra to determine if there is a next page + job_rows = db.list_jobs(status=status, limit=limit + 1, cursor=cursor) + + has_next = len(job_rows) > limit + if has_next: + job_rows = job_rows[:limit] + + items = [_job_row_to_status(row) for row in job_rows] + + next_cursor = None + if has_next and job_rows: + last = job_rows[-1] + created = last["created_at"] + ts = created.isoformat() if hasattr(created, "isoformat") else str(created) + next_cursor = f"{ts}|{last['job_id']}" + + return PaginatedJobsResponse(items=items, next_cursor=next_cursor) diff --git a/SPARC/database.py b/SPARC/database.py index 4492311..23bdacc 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -568,20 +568,45 @@ class DatabaseClient: self, status: Optional[str] = None, limit: int = 10, + cursor: Optional[str] = None, ) -> List[Dict]: - """List jobs, optionally filtered by status.""" - query = "SELECT * FROM jobs" + """List jobs with optional status filter and cursor-based pagination. + + Args: + status: Optional status filter (pending, running, completed, failed). + limit: Maximum number of jobs to return. + cursor: Opaque cursor (``created_at|job_id``) from a previous + response. When provided, only jobs older than the cursor are + returned. + + Returns: + List of job dicts ordered by created_at descending. + """ + conditions: list[str] = [] params: list = [] + if status: - query += " WHERE status = %s" + conditions.append("status = %s") params.append(status) - query += " ORDER BY created_at DESC LIMIT %s" + + if cursor: + try: + ts_str, cursor_job_id = cursor.rsplit("|", 1) + conditions.append("(created_at, job_id) < (%s, %s)") + params.extend([ts_str, cursor_job_id]) + except ValueError: + pass # Ignore malformed cursors; return from start + + query = "SELECT * FROM jobs" + if conditions: + query += " WHERE " + " AND ".join(conditions) + query += " ORDER BY created_at DESC, job_id DESC LIMIT %s" params.append(limit) with self.get_conn() as conn: - with conn.cursor(cursor_factory=RealDictCursor) as cursor: - cursor.execute(query, params) - return [dict(row) for row in cursor.fetchall()] + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] def mark_stale_jobs_failed(self) -> int: """Mark any jobs in 'running' or 'pending' state as 'failed'. -- 2.52.0 From 1bd9dccdb8c0df171a20049424103bf3c284eae5 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:20:51 +0000 Subject: [PATCH 17/32] feat: add CSV export for company analysis results Add GET /export/{company_name} backend endpoint that returns analysis records as a downloadable CSV file. Add Export CSV button to the Analysis page that triggers the download via the API. Closes leeworks-agents/SPARC#20 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 57 ++++++++++++++++++++++++++++++++- frontend/src/api/client.ts | 17 ++++++++++ frontend/src/pages/Analysis.tsx | 19 ++++++++--- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..762da0b 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -9,7 +9,7 @@ from typing import Annotated, List from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, EmailStr, Field from slowapi import Limiter from slowapi.errors import RateLimitExceeded @@ -389,6 +389,61 @@ async def get_analytics( ) +# ============== 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"'}, + ) + + # ============== System Endpoints ============== diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 037d59c..9a1c94f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -126,6 +126,23 @@ 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); + }, +}; + // Analytics API export const analyticsApi = { getAnalytics: async (days = 30): Promise => { diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 2dfd2f5..1c8c59b 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { analysisApi } from '../api/client'; -import { Search, CheckCircle, AlertCircle, Clock, FileText } from 'lucide-react'; +import { analysisApi, exportApi } from '../api/client'; +import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react'; import type { CompanyAnalysis } from '../types'; export function Analysis() { @@ -106,9 +106,18 @@ export function Analysis() { {/* Analysis Content */} {result.success && result.analysis && (
-

- AI Analysis Results -

+
+

+ AI Analysis Results +

+ +
{result.analysis} -- 2.52.0 From c738f785c3c5b245d294d8417ce0a833d65ca610 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:22:14 +0000 Subject: [PATCH 18/32] feat: add side-by-side patent portfolio comparison view Add /compare route with two-panel layout for comparing company patent portfolios. Each panel shows patent count, analysis timestamp, and full LLM narrative. The page is responsive (stacks vertically on mobile) and supports URL params (?a=nvidia&b=intel) for shareability. Closes leeworks-agents/SPARC#21 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/Compare.tsx | 161 +++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/Compare.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3426cd..e630389 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,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: { @@ -41,6 +42,7 @@ function App() { } /> } /> } /> + } /> } /> {/* Admin routes */} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 501dc1f..0f5afbf 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react'; +import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows } from 'lucide-react'; export function Layout() { const { user, isAdmin, logout } = useAuth(); @@ -15,6 +15,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' }, ]; 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 */} +
+
+ + setCompanyA(e.target.value)} + placeholder="e.g. nvidia" + className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" + /> +
+
+ + setCompanyB(e.target.value)} + placeholder="e.g. intel" + className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" + /> +
+ +
+ + {/* Comparison Panels */} + {(queryA || queryB) && ( +
+ {queryA && ( + + )} + {queryB && ( + + )} +
+ )} +
+ ); +} -- 2.52.0 From 52972bbff04e88ce0deb80aa51e9b5671db55cb6 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:23:47 +0000 Subject: [PATCH 19/32] feat: add patent trend charts to the Analytics page Add GET /analytics/trends endpoint returning per-company analysis counts by month and analysis type distribution over time. Render these as a line chart (analyses per company) and stacked bar chart (analysis types) on the Analytics page using recharts. Closes leeworks-agents/SPARC#24 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 72 +++++++++++++++++++ frontend/src/api/client.ts | 11 +++ frontend/src/pages/Analytics.tsx | 115 ++++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 1 deletion(-) diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..bc58fd0 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -389,6 +389,78 @@ async def get_analytics( ) +@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, + } + + # ============== System Endpoints ============== diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 037d59c..7db5226 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -127,11 +127,22 @@ export const analysisApi = { }; // 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/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 19f4aff..c3bd31a 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { analyticsApi } from '../api/client'; import { AlertCircle, Database } from 'lucide-react'; -import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'; const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6']; @@ -14,6 +14,11 @@ export function AnalyticsPage() { queryFn: () => analyticsApi.getAnalytics(days), }); + const trendsQuery = useQuery({ + queryKey: ['analytics-trends', days], + queryFn: () => analyticsApi.getTrends(days), + }); + if (isLoading) { return (
@@ -163,6 +168,114 @@ export function AnalyticsPage() {
)}
+ + {/* Trend Charts */} + {trendsQuery.data && ( +
+

+ Trends Over Time +

+ +
+ {/* Patent count over time per company (line chart) */} + {trendsQuery.data.by_month.length > 0 && (() => { + // Pivot data: each month as a row, companies as columns + const companies = [...new Set(trendsQuery.data!.by_month.map(d => d.company_name))]; + const months = [...new Set(trendsQuery.data!.by_month.map(d => d.month))].sort(); + const pivoted = months.map(month => { + const row: Record = { month }; + for (const c of companies) { + const entry = trendsQuery.data!.by_month.find(d => d.month === month && d.company_name === c); + row[c] = entry?.count || 0; + } + return row; + }); + + return ( +
+

Analyses per Company Over Time

+ + + + + + + {companies.map((company, idx) => ( + + ))} + + +
+ ); + })()} + + {/* Analysis type distribution over time (stacked bar) */} + {trendsQuery.data.by_type_over_time.length > 0 && (() => { + const types = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.analysis_type))]; + const months = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.month))].sort(); + const pivoted = months.map(month => { + const row: Record = { month }; + for (const t of types) { + const entry = trendsQuery.data!.by_type_over_time.find(d => d.month === month && d.analysis_type === t); + row[t] = entry?.count || 0; + } + return row; + }); + + return ( +
+

Analysis Types Over Time

+ + + + + + + {types.map((type, idx) => ( + + ))} + + +
+ ); + })()} +
+ + {trendsQuery.data.by_month.length === 0 && ( +
+ No trend data available yet. Run analyses over multiple days to see trends. +
+ )} +
+ )}
); } -- 2.52.0 From 7a364e6736243fffc4bd9d4e8ab8b717df212a2d Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:26:06 +0000 Subject: [PATCH 20/32] feat: add OpenAPI TypeScript client generation setup Add openapi-typescript devDependency and npm scripts for generating typed TypeScript schema from the FastAPI OpenAPI spec. Include a static openapi.json snapshot for offline generation. - npm run generate: fetch schema from running backend and generate types - npm run generate:local: generate types from the bundled openapi.json Closes leeworks-agents/SPARC#26 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package.json | 3 + frontend/src/api/openapi.json | 1086 +++++++++++++++++++++++++++++++++ 2 files changed, 1089 insertions(+) create mode 100644 frontend/src/api/openapi.json diff --git a/frontend/package.json b/frontend/package.json index b99eee1..70a7c52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,8 @@ "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", "preview": "vite preview" }, "dependencies": { @@ -30,6 +32,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/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 -- 2.52.0 From 04f4d36307d4a02783d477823f10fc11b4d8dada Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:28:25 +0000 Subject: [PATCH 21/32] feat: add multi-model support for per-analysis LLM selection Allow users to choose the LLM model on a per-analysis basis. The model field is optional in both single and batch analysis requests, defaulting to the server-configured MODEL env var. The model used is recorded in the analysis result and database. - Add model parameter to LLMAnalyzer.analyze_patent_content and analyze_patent_portfolio - Add model field to CompanyAnalysisResult and API response - Add model field to BatchAnalysisRequest - Add GET /models endpoint listing supported models and the default - Store model in llm_messages metadata for attribution Closes leeworks-agents/SPARC#37 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 41 +++++++++++++++++++++++++++++++++++++++++ SPARC/llm.py | 30 ++++++++++++++++++------------ SPARC/types.py | 1 + 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..3163a8a 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -41,6 +41,7 @@ class CompanyAnalysisResponse(BaseModel): patent_count: int success: bool error: str | None = None + model: str | None = None timestamp: datetime @@ -54,6 +55,15 @@ class BatchAnalysisResponse(BaseModel): timestamp: datetime +class CompanyAnalysisRequest(BaseModel): + """Request model for single company analysis with optional model selection.""" + + model: str | None = Field( + default=None, + description="LLM model to use (e.g. 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o'). Defaults to server config.", + ) + + class BatchAnalysisRequest(BaseModel): """Request model for batch company analysis.""" @@ -63,6 +73,10 @@ class BatchAnalysisRequest(BaseModel): max_workers: int = Field( default=3, ge=1, le=5, description="Max concurrent analyses" ) + model: str | None = Field( + default=None, + description="LLM model to use for all analyses in this batch. Defaults to server config.", + ) class JobStatus(BaseModel): @@ -133,6 +147,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse: patent_count=result.patent_count, success=result.success, error=result.error, + model=result.model, timestamp=result.timestamp, ) @@ -389,6 +404,32 @@ async def get_analytics( ) +# ============== Model Selection Endpoints ============== + +# Supported models via OpenRouter +SUPPORTED_MODELS = [ + {"id": "anthropic/claude-3.5-sonnet", "name": "Claude 3.5 Sonnet", "provider": "Anthropic"}, + {"id": "openai/gpt-4o", "name": "GPT-4o", "provider": "OpenAI"}, + {"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini", "provider": "OpenAI"}, + {"id": "google/gemini-pro-1.5", "name": "Gemini Pro 1.5", "provider": "Google"}, + {"id": "meta-llama/llama-3.1-70b-instruct", "name": "Llama 3.1 70B", "provider": "Meta"}, +] + + +@app.get("/models", tags=["System"]) +async def list_models(): + """List supported LLM models for analysis. + + Returns the available models that can be passed as the `model` field + in analysis requests. The default model is determined by the `MODEL` + environment variable on the server. + """ + return { + "models": SUPPORTED_MODELS, + "default": config.model, + } + + # ============== System Endpoints ============== diff --git a/SPARC/llm.py b/SPARC/llm.py index 707a0d6..9214cee 100644 --- a/SPARC/llm.py +++ b/SPARC/llm.py @@ -40,12 +40,13 @@ class LLMAnalyzer: else: self.client = None - def analyze_patent_content(self, patent_content: str, company_name: str) -> str: + def analyze_patent_content(self, patent_content: str, company_name: str, model: str | None = None) -> str: """Analyze patent content to estimate company innovation and performance. Args: patent_content: Minimized patent text (abstract, claims, summary) company_name: Name of the company for context + model: Optional model override (e.g. "openai/gpt-4o"). Defaults to config. Returns: Analysis text describing innovation quality and potential impact @@ -63,6 +64,8 @@ Patent Content: Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage.""" + effective_model = model or self.model + if self.test_mode: logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt) return "[TEST MODE - No API call made]" @@ -81,7 +84,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals response=cached["response"], company_name=company_name, analysis_type="single_patent", - model=self.model, + model=effective_model, metadata={ "patent_content_length": len(patent_content), "cache_hit": True, @@ -94,7 +97,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals # Call API if no cache hit and client is available if self.client: response = self.client.chat.completions.create( - model=self.model, + model=effective_model, max_tokens=1024, messages=[{"role": "user", "content": prompt}], ) @@ -106,7 +109,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals response=response_text, company_name=company_name, analysis_type="single_patent", - model=self.model, + model=effective_model, metadata={"patent_content_length": len(patent_content)}, token_usage={ "prompt_tokens": response.usage.prompt_tokens, @@ -124,13 +127,13 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals response=placeholder, company_name=company_name, analysis_type="single_patent", - model=self.model, + model=effective_model, metadata={"patent_content_length": len(patent_content), "pending": True} ) return placeholder - + def analyze_patent_portfolio( - self, patents_data: list[Dict[str, str]], company_name: str + self, patents_data: list[Dict[str, str]], company_name: str, model: str | None = None ) -> str: """Analyze multiple patents to estimate overall company performance. @@ -165,13 +168,16 @@ Patent Portfolio: Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook.""" + effective_model = model or self.model + if self.test_mode: logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt) return "[TEST MODE]" metadata = { "patent_count": len(patents_data), - "patent_ids": [p['patent_id'] for p in patents_data] + "patent_ids": [p['patent_id'] for p in patents_data], + "model": effective_model, } # Check cache first @@ -188,7 +194,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co response=cached["response"], company_name=company_name, analysis_type="portfolio", - model=self.model, + model=effective_model, metadata={ **metadata, "cache_hit": True, @@ -202,7 +208,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co if self.client: try: response = self.client.chat.completions.create( - model=self.model, + model=effective_model, max_tokens=2048, messages=[{"role": "user", "content": prompt}], ) @@ -215,7 +221,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co response=response_text, company_name=company_name, analysis_type="portfolio", - model=self.model, + model=effective_model, metadata=metadata, token_usage={ "prompt_tokens": response.usage.prompt_tokens, @@ -235,7 +241,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co response=placeholder, company_name=company_name, analysis_type="portfolio", - model=self.model, + model=effective_model, metadata={**metadata, "pending": True} ) return placeholder diff --git a/SPARC/types.py b/SPARC/types.py index 5bb692b..fd11073 100644 --- a/SPARC/types.py +++ b/SPARC/types.py @@ -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) -- 2.52.0 From f33447eef8dbe495425ade45fc5af8625282fd76 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:30:43 +0000 Subject: [PATCH 22/32] feat: implement scheduled/recurring analysis with change alerting Add APScheduler-based background task that periodically re-analyzes tracked companies and alerts on significant patent count changes. - Add tracked_companies and alerts tables to database schema - Add SPARC/scheduler.py with configurable interval and threshold - Add admin endpoints: GET/POST/DELETE /admin/tracked, GET /admin/alerts - Scheduler starts at app startup; interval via SCHEDULE_INTERVAL_HOURS - Change threshold configurable via CHANGE_THRESHOLD_PERCENT env var - apscheduler is optional; graceful fallback if not installed Closes leeworks-agents/SPARC#22 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 57 ++++++++++++++++++++++++ SPARC/database.py | 107 ++++++++++++++++++++++++++++++++++++++++++++ SPARC/scheduler.py | 109 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 274 insertions(+) create mode 100644 SPARC/scheduler.py diff --git a/SPARC/api.py b/SPARC/api.py index a78c132..63e7838 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -169,6 +169,9 @@ async def lifespan(app: FastAPI): import logging logging.getLogger(__name__).warning("Marked %d stale jobs as failed on startup", stale) _db.close() + # Start scheduled analysis if tracked companies are configured + from SPARC.scheduler import start_scheduler + start_scheduler() yield # Cleanup _analyzer = None @@ -369,6 +372,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 ============== diff --git a/SPARC/database.py b/SPARC/database.py index 4492311..978fba8 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -192,6 +192,35 @@ class DatabaseClient: ON jobs(status) """) + # Create tracked companies table for scheduled analysis + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tracked_companies ( + id SERIAL PRIMARY KEY, + company_name VARCHAR(255) UNIQUE NOT NULL, + last_patent_count INTEGER DEFAULT 0, + last_analysis_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create alerts table for significant changes + cursor.execute(""" + CREATE TABLE IF NOT EXISTS alerts ( + id SERIAL PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + alert_type VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + old_value NUMERIC, + new_value NUMERIC, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_alerts_company + ON alerts(company_name) + """) + self.conn.commit() @staticmethod @@ -803,3 +832,81 @@ class DatabaseClient: with conn.cursor() as cursor: cursor.execute("SELECT COUNT(*) FROM users") return cursor.fetchone()[0] + + # Tracked Companies Methods + + def add_tracked_company(self, company_name: str) -> Optional[Dict]: + """Add a company to the tracking list.""" + with self.get_conn() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + try: + cursor.execute( + "INSERT INTO tracked_companies (company_name) VALUES (%s) RETURNING *", + (company_name,), + ) + row = cursor.fetchone() + conn.commit() + return dict(row) if row else None + except Exception: + conn.rollback() + return None + + def remove_tracked_company(self, company_name: str) -> bool: + """Remove a company from the tracking list.""" + with self.get_conn() as conn: + with conn.cursor() as cursor: + cursor.execute( + "DELETE FROM tracked_companies WHERE LOWER(company_name) = LOWER(%s)", + (company_name,), + ) + conn.commit() + return cursor.rowcount > 0 + + def list_tracked_companies(self) -> List[Dict]: + """List all tracked companies.""" + with self.get_conn() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute("SELECT * FROM tracked_companies ORDER BY company_name") + return [dict(row) for row in cursor.fetchall()] + + def update_tracked_company( + self, company_name: str, patent_count: int + ) -> None: + """Update the last analysis stats for a tracked company.""" + with self.get_conn() as conn: + with conn.cursor() as cursor: + cursor.execute( + """UPDATE tracked_companies + SET last_patent_count = %s, last_analysis_at = CURRENT_TIMESTAMP + WHERE LOWER(company_name) = LOWER(%s)""", + (patent_count, company_name), + ) + conn.commit() + + def store_alert( + self, + company_name: str, + alert_type: str, + message: str, + old_value: float | None = None, + new_value: float | None = None, + ) -> None: + """Record an alert for a significant change.""" + with self.get_conn() as conn: + with conn.cursor() as cursor: + cursor.execute( + """INSERT INTO alerts (company_name, alert_type, message, old_value, new_value) + VALUES (%s, %s, %s, %s, %s)""", + (company_name, alert_type, message, old_value, new_value), + ) + conn.commit() + + def list_alerts(self, limit: int = 50) -> List[Dict]: + """List recent alerts.""" + with self.get_conn() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + "SELECT * FROM alerts ORDER BY created_at DESC LIMIT %s", + (limit,), + ) + return [dict(row) for row in cursor.fetchall()] 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/requirements.txt b/requirements.txt index e854576..25affa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ pandas bcrypt PyJWT slowapi +apscheduler -- 2.52.0 From 2e6b8c7445dd8b6e8e0f03fd0339dda643138d70 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:32:07 +0000 Subject: [PATCH 23/32] feat: add webhook notification support for job completion and alerts Send HTTP POST notifications to configured webhook URLs when batch jobs complete or when scheduled analysis detects significant changes. - Add SPARC/webhooks.py with retry logic (3 attempts, exponential backoff) - Support generic HTTP POST and Slack-compatible text payloads - Integrate into batch job completion handler in api.py - Configure via WEBHOOK_URLS env var (comma-separated) - Payload includes event type, job ID, status, and summary Closes leeworks-agents/SPARC#23 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 6 ++ SPARC/api.py | 17 ++++++ SPARC/webhooks.py | 139 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 SPARC/webhooks.py diff --git a/.env.example b/.env.example index 4e78c43..11bd485 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,9 @@ JWT_SECRET=your-secure-jwt-secret-change-in-production # 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 + +# ---- 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/SPARC/api.py b/SPARC/api.py index a78c132..046cae3 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -519,8 +519,25 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int): progress=100, result_json=_json.dumps(batch_response.model_dump(), default=str), ) + # Fire webhook notification + from SPARC.webhooks import notify_job_completed + notify_job_completed( + job_id=job_id, + status="completed", + total_companies=result.total_companies, + successful=result.successful, + failed=result.failed, + ) except Exception as e: db.update_job(job_id, status="failed", error=str(e)) + from SPARC.webhooks import notify_job_completed + notify_job_completed( + job_id=job_id, + status="failed", + total_companies=len(companies), + successful=0, + failed=len(companies), + ) @app.post("/analyze/batch/async", response_model=JobStatus, tags=["Analysis"]) 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, + }) -- 2.52.0 From 338ac860868b6deb5a3add63d79b967fbf98903f Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 02:03:53 +0000 Subject: [PATCH 24/32] feat: add PDF export for analysis reports Add a new /export/{company_name}/pdf endpoint that generates a formatted PDF report using reportlab, including a summary table and all analysis results. Add the corresponding frontend Export PDF button alongside the existing Export CSV button on the Analysis page. Closes leeworks-agents/SPARC#85 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/api.py | 158 ++++++++++++++++++++++++++++++++ frontend/src/api/client.ts | 15 +++ frontend/src/pages/Analysis.tsx | 23 +++-- requirements.txt | 1 + 4 files changed, 190 insertions(+), 7 deletions(-) diff --git a/SPARC/api.py b/SPARC/api.py index 295b405..dbcc01e 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -621,6 +621,164 @@ async def export_company_csv( ) +@app.get("/export/{company_name}/pdf", tags=["Export"]) +async def export_company_pdf( + company_name: str, + _: UserResponse = Depends(get_current_user), +): + """Export analysis results for a company as a formatted PDF report. + + Returns all stored analysis records for the given company, including + analysis type, model used, response text, and timestamp, formatted + as a downloadable PDF document. + + Args: + company_name: Company name to export results for + + Returns: + PDF file download + """ + import io + import textwrap + + from reportlab.lib import colors + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import inch + from reportlab.platypus import ( + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + + db = get_db_client() + with db.get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT company_name, analysis_type, model, response, timestamp + FROM llm_messages + WHERE LOWER(company_name) = LOWER(%s) AND is_cached = FALSE + ORDER BY timestamp DESC + """, + (company_name,), + ) + rows = cur.fetchall() + + if not rows: + raise HTTPException(status_code=404, detail=f"No analysis results found for '{company_name}'") + + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + rightMargin=0.75 * inch, + leftMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, + ) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=20, + spaceAfter=6, + ) + subtitle_style = ParagraphStyle( + "Subtitle", + parent=styles["Normal"], + fontSize=11, + textColor=colors.grey, + spaceAfter=20, + ) + heading_style = ParagraphStyle( + "SectionHeading", + parent=styles["Heading2"], + fontSize=13, + spaceBefore=16, + spaceAfter=8, + textColor=colors.HexColor("#1a1a2e"), + ) + body_style = ParagraphStyle( + "BodyText", + parent=styles["Normal"], + fontSize=9, + leading=13, + spaceAfter=10, + ) + + elements = [] + + # Title and date + display_name = rows[0][0] # Use the casing from the database + analysis_date = datetime.now().strftime("%Y-%m-%d") + elements.append(Paragraph(f"SPARC Analysis Report: {display_name}", title_style)) + elements.append(Paragraph(f"Generated on {analysis_date}", subtitle_style)) + + # Summary table + summary_data = [ + ["Total Analyses", str(len(rows))], + ["Analysis Types", ", ".join(sorted(set(r[1] for r in rows)))], + ["Models Used", ", ".join(sorted(set(r[2] for r in rows)))], + ] + summary_table = Table(summary_data, colWidths=[2 * inch, 4.5 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f0f0f5")), + ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("PADDING", (0, 0), (-1, -1), 6), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 16)) + + # Individual analysis sections + for i, row in enumerate(rows, 1): + _, analysis_type, model, response, timestamp = row + ts_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") if hasattr(timestamp, "strftime") else str(timestamp) + + elements.append( + Paragraph(f"Analysis {i}: {analysis_type} (via {model})", heading_style) + ) + elements.append( + Paragraph(f"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 ============== diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0775dec..7dd76ff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -141,6 +141,21 @@ export const exportApi = { 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 diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 1c8c59b..1ded981 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -110,13 +110,22 @@ export function Analysis() {

AI Analysis Results

- +
+ + +
diff --git a/requirements.txt b/requirements.txt index f97ca7d..f000b82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ PyJWT slowapi apscheduler boto3 +reportlab -- 2.52.0 From 2bbf2d70bbd5bdbea9e1eaebfd0e99fbcd8be7c0 Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 10:08:06 +0000 Subject: [PATCH 25/32] CI: add tsc --noEmit TypeScript type checking to test job Adds a step to install Node.js and run tsc --noEmit in the frontend directory, catching TypeScript type errors before images are built. Ruff was already present; this completes issue #260. Closes leeworks-agents/SPARC#260 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 106a517..7e42585 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -33,6 +33,14 @@ jobs: 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 + npx tsc --noEmit + - name: Run pytest shell: sh env: -- 2.52.0 From f611e3a30c35cba732e9c76d044414ba59b9e48f Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 10:08:52 +0000 Subject: [PATCH 26/32] Docs: add MODEL, SERP_CACHE_TTL_HOURS, and LOG_LEVEL to .env.example These environment variables were already supported in config.py but were not documented in .env.example, making them hard to discover. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.env.example b/.env.example index bdb08f3..788f953 100644 --- a/.env.example +++ b/.env.example @@ -47,12 +47,27 @@ STORAGE_BACKEND=local # 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 -- 2.52.0 From 595516e330a4b15d351adbffe48d7b80ae885447 Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 16:08:49 +0000 Subject: [PATCH 27/32] feat: add loading skeletons, error states, and empty state to Batch page Add a Job History section that loads past jobs via useQuery with: - Animated skeleton placeholders while the job list is loading - Error banner with retry button when the API call fails - Empty state with helpful message when no jobs exist - Job list cards with status badges and progress bars Also improve the batch submission error state with a retry button alongside the existing dismiss button. Closes leeworks-agents/SPARC#343 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/Batch.tsx | 160 +++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/Batch.tsx b/frontend/src/pages/Batch.tsx index 6620597..d0c271b 100644 --- a/frontend/src/pages/Batch.tsx +++ b/frontend/src/pages/Batch.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { analysisApi } from '../api/client'; -import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp, RefreshCw, Inbox } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; import type { BatchAnalysisResult } from '../types'; @@ -11,10 +11,18 @@ export function Batch() { const [result, setResult] = useState(null); const [expandedItems, setExpandedItems] = useState>(new Set()); + const jobsQuery = useQuery({ + queryKey: ['jobs'], + queryFn: () => analysisApi.listJobs(undefined, 20), + }); + const mutation = useMutation({ mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) => analysisApi.analyzeBatch(companies, workers), - onSuccess: (data) => setResult(data), + onSuccess: (data) => { + setResult(data); + jobsQuery.refetch(); + }, }); const handleSubmit = (e: React.FormEvent) => { @@ -123,12 +131,29 @@ export function Batch() { {mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'} {' '}Check your connection and try again.

- +
+ + +
)} @@ -230,6 +255,123 @@ export function Batch() {
)} + + {/* 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}

+ )} +
+ ))} +
+ )} +
); } -- 2.52.0 From 223d5f7e5d477581a29f22dca3e1ab7c707cf6a3 Mon Sep 17 00:00:00 2001 From: agent-company Date: Fri, 27 Mar 2026 16:13:00 +0000 Subject: [PATCH 28/32] feat: add model picker to Analysis and Batch pages with full backend wiring Thread the optional model parameter through the entire analysis pipeline: - analyzer.py: analyze_company, _analyze_company_safe, analyze_companies, and analyze_single_patent now accept and forward model override - api.py: single company endpoint accepts model query param; batch and async batch endpoints pass request.model through to the analyzer - client.ts: analyzeCompany, analyzeBatch, analyzeBatchAsync accept model; add listModels() to fetch available models from GET /models - Analysis.tsx: add model selector dropdown that loads from /models API - Batch.tsx: add model selector alongside the workers slider Users can now pick a specific LLM (GPT-4o, Claude 3.5, Gemini, etc.) per analysis request, or leave it on the server default. Closes leeworks-agents/SPARC#351 Co-Authored-By: Claude Opus 4.6 (1M context) --- SPARC/analyzer.py | 19 +++++--- SPARC/api.py | 10 ++-- frontend/src/api/client.ts | 32 ++++++++++-- frontend/src/pages/Analysis.tsx | 86 ++++++++++++++++++++++----------- frontend/src/pages/Batch.tsx | 33 ++++++++++++- 5 files changed, 137 insertions(+), 43 deletions(-) diff --git a/SPARC/analyzer.py b/SPARC/analyzer.py index c55803b..31ad7f1 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -33,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: @@ -46,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 @@ -100,12 +101,12 @@ class CompanyAnalyzer: # 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. If the patent PDF is not already on disk, this method attempts to @@ -116,6 +117,7 @@ class CompanyAnalyzer: Args: 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 @@ -151,7 +153,7 @@ class CompanyAnalyzer: 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 @@ -201,18 +203,19 @@ class CompanyAnalyzer: 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() @@ -252,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. @@ -262,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 @@ -273,7 +278,7 @@ class CompanyAnalyzer: 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 } diff --git a/SPARC/api.py b/SPARC/api.py index dbcc01e..6fbbcdb 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -799,6 +799,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. @@ -808,6 +809,7 @@ 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 @@ -815,7 +817,7 @@ async def analyze_company( 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) @@ -877,6 +879,7 @@ async def analyze_companies_batch( result = _analyzer.analyze_companies( companies=request.companies, max_workers=request.max_workers, + model=request.model, ) return _convert_batch_result(result) @@ -908,7 +911,7 @@ def _job_row_to_status(row: dict) -> JobStatus: ) -def _run_batch_job(job_id: str, companies: list[str], max_workers: int): +def _run_batch_job(job_id: str, companies: list[str], max_workers: int, model: str | None = None): """Background task for batch analysis.""" import json as _json global _analyzer @@ -933,6 +936,7 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int): companies=companies, max_workers=max_workers, progress_callback=progress_callback, + model=model, ) batch_response = _convert_batch_result(result) db.update_job( @@ -988,7 +992,7 @@ async def analyze_companies_async( 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 _job_row_to_status(job_row) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7dd76ff..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; diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 1ded981..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 { useMutation, useQuery } from '@tanstack/react-query'; import { analysisApi, exportApi } from '../api/client'; -import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react'; +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 */} -
-
- - setCompanyName(e.target.value)} - placeholder="Enter company name (e.g., nvidia, intel, amd)" - className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" - /> + +
+
+ + setCompanyName(e.target.value)} + placeholder="Enter company name (e.g., nvidia, intel, amd)" + className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" + /> +
+ +
+ + {/* Model Selector */} +
+ +
+ + +
- {/* Error */} diff --git a/frontend/src/pages/Batch.tsx b/frontend/src/pages/Batch.tsx index 6620597..4c53bb0 100644 --- a/frontend/src/pages/Batch.tsx +++ b/frontend/src/pages/Batch.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { analysisApi } from '../api/client'; import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; @@ -8,12 +8,18 @@ import type { BatchAnalysisResult } from '../types'; export function Batch() { const [companiesInput, setCompaniesInput] = useState(''); const [maxWorkers, setMaxWorkers] = useState(3); + const [selectedModel, setSelectedModel] = useState(''); const [result, setResult] = useState(null); const [expandedItems, setExpandedItems] = useState>(new Set()); + const modelsQuery = useQuery({ + queryKey: ['models'], + queryFn: () => analysisApi.listModels(), + }); + const mutation = useMutation({ mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) => - analysisApi.analyzeBatch(companies, workers), + analysisApi.analyzeBatch(companies, workers, selectedModel || undefined), onSuccess: (data) => setResult(data), }); @@ -85,6 +91,29 @@ export function Batch() {
{maxWorkers}
+
+ +
+ + +
+
+