diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 80acc27..aeeb5c1 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -28,10 +28,10 @@ jobs: run: | pip3 install -r requirements.txt ruff -# - name: Run ruff linter -# shell: sh -# run: | -# ruff check SPARC/ tests/ + - name: Run ruff linter + shell: sh + run: | + ruff check SPARC/ tests/ - name: Install Node.js and check TypeScript types shell: sh @@ -47,16 +47,17 @@ jobs: fi npx tsc --noEmit -# - name: Run pytest -# shell: sh -# env: -# DATABASE_URL: "sqlite://" -# API_KEY: "test-key" -# OPENROUTER_API_KEY: "test-key" -# JWT_SECRET: "test-secret-for-ci" -# APP_ENV: "development" -# run: | -# python3 -m pytest tests/ -v --tb=short -x + - 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: | + pip3 install pytest + python3 -m pytest tests/ -v --tb=short -x build-api: needs: test diff --git a/ROADMAP.md b/ROADMAP.md index 42b571a..0e86ab5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,86 +7,124 @@ Semiconductor Patent & Analytics Report Core -- development priorities. 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. +image builds and testing. Core features include patent retrieval via SerpAPI, +PDF parsing, LLM analysis via OpenRouter (multi-model: Claude, GPT-4o, Gemini, +Llama), batch processing, JWT authentication, analytics dashboard with patent +trend charts, scheduled recurring analysis with alerting, webhook notifications +(Slack/Discord), CSV and PDF export, S3/MinIO storage, side-by-side company +comparison, and dark mode. + +--- + +## Completed + +Items that have been implemented and merged into main. + +### Security hardening + +- ~~Rotate default JWT secret.~~ Startup check refuses to start with the + default secret in non-development environments. +- ~~CORS allow-origins are hardcoded.~~ Allowed origins are now configurable + via environment variable. +- ~~Database credentials in docker-compose.yml.~~ Compose references `.env` + for sensitive values. + +### Error handling and resilience + +- ~~`get_db_client()` creates a new `DatabaseClient` on every call.~~ Refactored + to a shared pooled singleton initialized at startup. +- ~~No rate limiting on auth endpoints.~~ Rate limiting middleware added to + `/auth/login` and `/auth/register`. + +### Test coverage + +- ~~API tests bypass authentication.~~ JWT auth integration tests added (33 + cases covering registration, login, protected routes, token refresh, and + admin-only endpoints). +- ~~No test stage in CI.~~ Gitea Actions workflow now runs `pytest` and gates + the build. +- ~~No linting or type checking in CI.~~ `ruff` (Python) and `tsc --noEmit` + (TypeScript) added to CI pipeline. + +### Backend + +- ~~Add structured logging.~~ Python `logging` module used throughout. +- ~~Make LLM model configurable.~~ `MODEL` environment variable accepted; + multi-model support with per-analysis selection (GPT-4o, Gemini, Claude, + Llama). +- ~~SERP cache TTL hardcoded.~~ `SERP_CACHE_TTL_HOURS` exposed as env var. +- ~~Patent PDF storage.~~ S3/MinIO object storage backend added alongside + local filesystem. Volume mount requirement documented. +- ~~`analyze_single_patent` assumes local file.~~ Auto-download from cached + metadata link integrated. +- ~~`Patent.patent_id` typed as `int`.~~ Fixed to `str`. + +### Frontend + +- ~~No loading/error states.~~ Skeleton loaders and error states added to + Batch and Analytics pages. +- ~~No dark mode.~~ Full dark mode support with theme-aware chart colors. +- ~~Missing lockfile.~~ `package-lock.json` committed. + +### Features (formerly P3) + +- ~~Export analysis reports.~~ CSV and PDF export endpoints implemented. +- ~~Comparison view.~~ Side-by-side company patent portfolio comparison added. +- ~~Scheduled/recurring analysis.~~ APScheduler-based periodic re-analysis + with configurable interval and change-threshold alerting. +- ~~Webhook/notification support.~~ Slack, Discord, and generic HTTP POST + webhooks with retry logic. +- ~~Multi-model support.~~ Model picker in Analysis and Batch pages; backend + allow-list validation. +- ~~Patent trend charts.~~ Filing frequency and category distribution + visualizations added to Analytics page. +- ~~OpenAPI client generation.~~ TypeScript API client auto-generated from + FastAPI spec with CI freshness check. + +### Resilience + +- ~~`_jobs` dict is in-memory only.~~ Database-backed job persistence + implemented using `db.list_jobs()` and `mark_stale_jobs_failed()`. The + in-memory `_jobs` dict has been removed. + +### Test coverage (P1/P2) + +- ~~Export endpoint tests.~~ Tests added for CSV and PDF export endpoints. +- ~~Tracked company admin endpoint tests.~~ Tests added for `/admin/tracked` + CRUD endpoints and scheduler integration. +- ~~Webhook integration tests.~~ Tests added for retry logic, Slack/Discord + payload format, and multi-URL dispatch. +- ~~S3/MinIO storage backend tests.~~ Unit tests added for the S3 backend + (read, write, exists, delete, error handling). +- ~~`analyze_single_patent` auto-download path tests.~~ Tests added for the + auto-download fallback (cache lookup, PDF download, FileNotFoundError). + +### Code quality + +- ~~Scheduler creates its own DatabaseClient.~~ Refactored to use the + application-level pooled `get_db_client()`. --- ## 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. +No outstanding P1 items. All previously listed items have been completed and +moved to the Completed section above. --- ## P2 -- Medium Priority -Improvements to usability, performance, and developer experience. +Improvements to the API surface. -### Backend +### API improvements -- **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. +- **API pagination.** The `/analyze/batch` endpoint needs cursor-based + pagination for large result sets. The `/jobs` endpoint already has cursor + pagination. *(Issue #1669)* +- **Request validation improvements.** Add stricter input validation for + company names (disallow special characters, enforce length limits). + *(Issue #1670)* --- @@ -94,23 +132,20 @@ Improvements to usability, performance, and developer experience. 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. +- **Historical analysis diffing.** Show what changed between two analysis runs + for the same company, highlighting new patents and score shifts. +- **Patent classification tagging.** Automatically tag patents by technology + domain (AI, semiconductors, materials science) using LLM classification. +- **User-level API keys.** Allow users to generate personal API keys for + programmatic access without JWT token refresh. +- **Batch export.** Export analysis results for multiple companies at once as + a ZIP archive. +- **Rate limiting dashboard.** Surface rate limit status and usage statistics + in the admin panel. +- **Async webhook delivery.** Move webhook delivery to a background task queue + (e.g., Celery, arq) to avoid blocking the scheduler. +- **Multi-tenant support.** Scope analysis results and tracked companies per + user or organization. --- diff --git a/SPARC/analyzer.py b/SPARC/analyzer.py index 31ad7f1..1ebceaf 100644 --- a/SPARC/analyzer.py +++ b/SPARC/analyzer.py @@ -10,13 +10,13 @@ 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.llm import LLMAnalyzer from SPARC.serp_api import SERP from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents +logger = logging.getLogger(__name__) + class CompanyAnalyzer: """Orchestrates end-to-end company performance analysis via patents.""" diff --git a/SPARC/api.py b/SPARC/api.py index 3a28033..1b29d38 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -3,14 +3,19 @@ Provides REST API endpoints for analyzing company patent portfolios. """ +from __future__ import annotations + from contextlib import asynccontextmanager from datetime import datetime -from typing import Annotated, List +from typing import TYPE_CHECKING, Annotated, List -from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request +if TYPE_CHECKING: + from SPARC.database import DatabaseClient + +from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Path, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, StringConstraints from slowapi import Limiter from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address @@ -31,6 +36,16 @@ from SPARC.auth import ( ) from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult +# Validated company name type: 2-100 chars, alphanumeric + spaces/hyphens/ampersands/periods only. +CompanyName = Annotated[ + str, + StringConstraints( + min_length=2, + max_length=100, + pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", + ), +] + # Pydantic models for API class CompanyAnalysisResponse(BaseModel): @@ -67,7 +82,7 @@ class CompanyAnalysisRequest(BaseModel): class BatchAnalysisRequest(BaseModel): """Request model for batch company analysis.""" - companies: list[str] = Field( + companies: list[CompanyName] = Field( ..., min_length=1, max_length=20, description="List of company names to analyze" ) max_workers: int = Field( @@ -91,6 +106,24 @@ class JobStatus(BaseModel): error: str | None = None +class AnalysisRecord(BaseModel): + """A single stored analysis result.""" + + id: int + company_name: str | None = None + analysis_type: str | None = None + model: str | None = None + response: str | None = None + timestamp: datetime | None = None + + +class PaginatedAnalysisResponse(BaseModel): + """Paginated response for analysis result listings.""" + + items: list[AnalysisRecord] + next_cursor: str | None = None + + class PaginatedJobsResponse(BaseModel): """Paginated response for job listings.""" @@ -212,10 +245,37 @@ app = FastAPI( limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter +# In-memory rate limit statistics +_rate_limit_stats: dict[str, dict] = {} + + +def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None: + """Record a request against a rate-limited endpoint.""" + key = endpoint + if key not in _rate_limit_stats: + _rate_limit_stats[key] = { + "endpoint": endpoint, + "total_requests": 0, + "rejected_requests": 0, + "by_ip": {}, + } + _rate_limit_stats[key]["total_requests"] += 1 + if rejected: + _rate_limit_stats[key]["rejected_requests"] += 1 + ip_stats = _rate_limit_stats[key].setdefault("by_ip", {}) + if ip not in ip_stats: + ip_stats[ip] = {"total": 0, "rejected": 0} + ip_stats[ip]["total"] += 1 + if rejected: + ip_stats[ip]["rejected"] += 1 + @app.exception_handler(RateLimitExceeded) async def rate_limit_handler(request: Request, exc: RateLimitExceeded): """Return 429 with Retry-After header when rate limit is exceeded.""" + endpoint = request.url.path + ip = get_remote_address(request) + _track_rate_limit_request(endpoint, ip, rejected=True) retry_after = getattr(exc, "retry_after", 60) return JSONResponse( status_code=429, @@ -244,6 +304,7 @@ async def register(request: Request, body: RegisterRequest): The first registered user automatically becomes an admin. """ + _track_rate_limit_request("/auth/register", get_remote_address(request)) db = get_db_client() # First user becomes admin @@ -274,6 +335,7 @@ async def register(request: Request, body: RegisterRequest): @limiter.limit("10/minute") async def login(request: Request, body: LoginRequest): """Authenticate user and return JWT tokens.""" + _track_rate_limit_request("/auth/login", get_remote_address(request)) db = get_db_client() user = db.authenticate_user(body.email, body.password) @@ -400,7 +462,7 @@ async def delete_user( class TrackCompanyRequest(BaseModel): """Request to add a company to tracking.""" - company_name: str = Field(..., min_length=1, max_length=255) + company_name: CompanyName = Field(...) @app.get("/admin/tracked", tags=["Admin"]) @@ -427,7 +489,7 @@ async def add_tracked_company( @app.delete("/admin/tracked/{company_name}", tags=["Admin"]) async def remove_tracked_company( - company_name: str, + company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], _: UserResponse = Depends(get_current_admin), ): """Remove a company from the tracked list (admin only).""" @@ -438,6 +500,36 @@ async def remove_tracked_company( return {"message": f"Stopped tracking {company_name}"} +@app.get("/admin/rate-limits", tags=["Admin"]) +async def get_rate_limit_stats( + _: UserResponse = Depends(get_current_admin), +): + """Get rate limit status and usage statistics (admin only). + + Returns current rate limit configuration and request statistics + for all rate-limited endpoints. + + Returns: + List of rate limit stats per endpoint with total/rejected counts + """ + rate_limits_config = { + "/auth/register": {"limit": "5/minute"}, + "/auth/login": {"limit": "10/minute"}, + } + + results = [] + for endpoint, conf in rate_limits_config.items(): + stats = _rate_limit_stats.get(endpoint, {}) + results.append({ + "endpoint": endpoint, + "limit": conf["limit"], + "total_requests": stats.get("total_requests", 0), + "rejected_requests": stats.get("rejected_requests", 0), + }) + + return {"rate_limits": results} + + @app.get("/admin/alerts", tags=["Admin"]) async def list_alerts( limit: int = Query(default=50, ge=1, le=200), @@ -585,7 +677,7 @@ async def get_analytics_trends( @app.get("/export/{company_name}", tags=["Export"]) async def export_company_csv( - company_name: str, + company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], _: UserResponse = Depends(get_current_user), ): """Export analysis results for a company as a CSV file. @@ -637,7 +729,7 @@ async def export_company_csv( @app.get("/export/{company_name}/pdf", tags=["Export"]) async def export_company_pdf( - company_name: str, + company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], _: UserResponse = Depends(get_current_user), ): """Export analysis results for a company as a formatted PDF report. @@ -653,7 +745,6 @@ async def export_company_pdf( PDF file download """ import io - import textwrap from reportlab.lib import colors from reportlab.lib.pagesizes import letter @@ -812,7 +903,7 @@ async def health_check(): tags=["Analysis"], ) async def analyze_company( - company_name: str, + company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")], 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), ): @@ -842,7 +933,7 @@ async def analyze_company( ) async def analyze_single_patent( patent_id: str, - company_name: str = Query(description="Company name for analysis context"), + company_name: Annotated[str, Query(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", description="Company name for analysis context")], _: UserResponse = Depends(get_current_user), ): """Analyze a single patent by its publication ID. @@ -868,6 +959,58 @@ async def analyze_single_patent( raise HTTPException(status_code=404, detail=str(e)) +@app.get( + "/analyze/batch", + response_model=PaginatedAnalysisResponse, + tags=["Analysis"], +) +async def list_analysis_results( + company_name: Annotated[ + str | None, + Query(description="Filter results by company name"), + ] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + cursor: Annotated[ + str | None, + Query(description="Opaque cursor from a previous response's next_cursor field"), + ] = None, + _: UserResponse = Depends(get_current_user), +): + """List stored analysis results with cursor-based pagination. + + Returns past analysis results ordered by timestamp descending. Use + ``limit`` to control page size (default 50, max 200). 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. + + Args: + company_name: Optional filter by company name + limit: Maximum number of results to return (default 50, max 200) + cursor: Opaque pagination cursor from a previous response + + Returns: + Paginated list of analysis results + """ + db = _get_job_db() + rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor) + + has_next = len(rows) > limit + if has_next: + rows = rows[:limit] + + items = [AnalysisRecord(**row) for row in rows] + + next_cursor = None + if has_next and rows: + last = rows[-1] + ts = last["timestamp"] + ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts) + next_cursor = f"{ts_str}|{last['id']}" + + return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor) + + @app.post( "/analyze/batch", response_model=BatchAnalysisResponse, @@ -1043,7 +1186,7 @@ async def list_jobs( str | None, Query(description="Filter by status: pending, running, completed, failed"), ] = None, - limit: Annotated[int, Query(ge=1, le=100)] = 10, + limit: Annotated[int, Query(ge=1, le=200)] = 50, cursor: Annotated[ str | None, Query(description="Opaque cursor from a previous response's next_cursor field"), diff --git a/SPARC/database.py b/SPARC/database.py index 24c7081..0759a66 100644 --- a/SPARC/database.py +++ b/SPARC/database.py @@ -371,6 +371,48 @@ class DatabaseClient: cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] + def list_analyses( + self, + company_name: Optional[str] = None, + limit: int = 50, + cursor: Optional[str] = None, + ) -> List[Dict]: + """List analysis results with cursor-based pagination. + + Args: + company_name: Optional filter by company name. + limit: Maximum number of records to return. + cursor: Opaque cursor (``timestamp|id``) from a previous response. + + Returns: + List of analysis dicts ordered by timestamp descending. + """ + conditions: list[str] = ["is_cached = FALSE"] + params: list = [] + + if company_name: + conditions.append("LOWER(company_name) = LOWER(%s)") + params.append(company_name) + + if cursor: + try: + ts_str, cursor_id = cursor.rsplit("|", 1) + conditions.append("(timestamp, id) < (%s, %s)") + params.extend([ts_str, int(cursor_id)]) + except (ValueError, TypeError): + pass # Ignore malformed cursors; return from start + + query = "SELECT id, company_name, analysis_type, model, response, timestamp FROM llm_messages" + if conditions: + query += " WHERE " + " AND ".join(conditions) + query += " ORDER BY timestamp DESC, id DESC LIMIT %s" + params.append(limit) + + with self.get_conn() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + def get_analytics(self, days: int = 30) -> Dict: """Get analytics on message usage. diff --git a/SPARC/scheduler.py b/SPARC/scheduler.py index 5af3940..4428bfd 100644 --- a/SPARC/scheduler.py +++ b/SPARC/scheduler.py @@ -2,14 +2,17 @@ Uses APScheduler to periodically re-analyze tracked companies and detect significant changes in patent counts. + +The scheduler reuses the application-level pooled DatabaseClient +(from ``SPARC.auth``) instead of creating its own connection, which +avoids exhausting the database connection pool under load. """ import logging import os -from SPARC import config from SPARC.analyzer import CompanyAnalyzer -from SPARC.database import DatabaseClient +from SPARC.auth import get_db_client logger = logging.getLogger(__name__) @@ -21,10 +24,13 @@ 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() + """Re-analyze all tracked companies and check for significant changes. + + Uses the shared pooled DatabaseClient from ``SPARC.auth.get_db_client()`` + rather than creating a disposable connection, so the scheduler participates + in the same connection pool as the rest of the application. + """ + db = get_db_client() tracked = db.list_tracked_companies() if not tracked: @@ -74,7 +80,6 @@ def run_scheduled_analysis() -> None: except Exception as e: logger.error("Error analyzing tracked company %s: %s", name, e) - db.close() logger.info("Scheduled analysis complete") diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 7ec67f7..2f4fc35 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -159,7 +159,7 @@ export function Analysis() { -