forked from 0xWheatyz/SPARC
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2f81b0396 | |||
| a07a0c7fbe | |||
| 43fd2c9575 | |||
| d4d43cf9b8 | |||
| 2f2b6382fa | |||
| 1319530f04 | |||
| b32eebff8a | |||
|
68ee19025a
|
|||
|
ef97710d1c
|
|||
|
88812b5967
|
|||
|
90e58949fc
|
|||
|
bd10925c97
|
|||
|
89fec43aa2
|
|||
|
02e1c41126
|
|||
|
c17a0d006a
|
|||
|
c6760a39a1
|
|||
|
2ae6280566
|
|||
|
9745ed75a8
|
|||
|
c649eaf343
|
|||
| 7e66d0e7e0 | |||
| 71465401c6 | |||
| 97048917f2 | |||
| 88abd9574b | |||
| e0ed39908e | |||
| 87e09b365b | |||
| 5d11f514c0 | |||
| cbc8f449a1 | |||
| 44620614b6 | |||
| c72a44aa56 | |||
| 6aa71eb17e | |||
| fb52d08387 | |||
| 223d5f7e5d | |||
| 595516e330 | |||
| 514e274fdb | |||
| 3d2c0ea27d | |||
| f611e3a30c |
@@ -47,12 +47,27 @@ STORAGE_BACKEND=local
|
|||||||
# AWS_SECRET_ACCESS_KEY=minioadmin
|
# AWS_SECRET_ACCESS_KEY=minioadmin
|
||||||
# To start MinIO locally: docker compose --profile s3 up -d minio
|
# 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 ----
|
# ---- Cache ----
|
||||||
|
|
||||||
# When USE_CACHE=true: check database for cached responses before making API calls
|
# 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)
|
# When USE_CACHE=false: always make fresh API calls (still stores results in database)
|
||||||
USE_CACHE=true
|
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 ----
|
# ---- Webhooks ----
|
||||||
|
|
||||||
# Comma-separated list of webhook URLs for job completion and alert notifications
|
# Comma-separated list of webhook URLs for job completion and alert notifications
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
|
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
pip3 install --break-system-packages -r requirements.txt ruff
|
pip3 install -r requirements.txt ruff
|
||||||
|
|
||||||
- name: Run ruff linter
|
- name: Run ruff linter
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -36,9 +36,15 @@ jobs:
|
|||||||
- name: Install Node.js and check TypeScript types
|
- name: Install Node.js and check TypeScript types
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache nodejs npm
|
apt-get install -y nodejs npm
|
||||||
cd frontend
|
cd frontend
|
||||||
npm ci
|
npm ci
|
||||||
|
npm run generate:local
|
||||||
|
if ! git diff --quiet src/api/schema.d.ts; then
|
||||||
|
echo "ERROR: src/api/schema.d.ts is out of date. Run 'npm run generate:local' and commit the result."
|
||||||
|
git diff src/api/schema.d.ts
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
npx tsc --noEmit
|
npx tsc --noEmit
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
@@ -50,6 +56,7 @@ jobs:
|
|||||||
JWT_SECRET: "test-secret-for-ci"
|
JWT_SECRET: "test-secret-for-ci"
|
||||||
APP_ENV: "development"
|
APP_ENV: "development"
|
||||||
run: |
|
run: |
|
||||||
|
pip3 install pytest
|
||||||
python3 -m pytest tests/ -v --tb=short -x
|
python3 -m pytest tests/ -v --tb=short -x
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@@ -59,7 +66,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git docker-cli
|
apt-get update && apt-get install -y git docker.io
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -131,7 +138,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git docker-cli
|
apt-get update && apt-get install -y git docker.io
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
|
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
pip3 install --break-system-packages -r requirements.txt ruff
|
pip3 install -r requirements.txt ruff
|
||||||
|
|
||||||
- name: Run ruff linter
|
- name: Run ruff linter
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -37,9 +37,19 @@ jobs:
|
|||||||
- name: Install Node.js and frontend dependencies
|
- name: Install Node.js and frontend dependencies
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache nodejs npm
|
apt-get install -y nodejs npm
|
||||||
cd frontend && npm ci
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Verify generated API types are up to date
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
cd frontend && npm run generate:local
|
||||||
|
if ! git diff --quiet src/api/schema.d.ts; then
|
||||||
|
echo "ERROR: src/api/schema.d.ts is out of date. Run 'npm run generate:local' and commit the result."
|
||||||
|
git diff src/api/schema.d.ts
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run TypeScript type check
|
- name: Run TypeScript type check
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+14
-9
@@ -10,13 +10,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from SPARC import config
|
from SPARC import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from SPARC.database import DatabaseClient
|
from SPARC.database import DatabaseClient
|
||||||
from SPARC.llm import LLMAnalyzer
|
from SPARC.llm import LLMAnalyzer
|
||||||
from SPARC.serp_api import SERP
|
from SPARC.serp_api import SERP
|
||||||
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
|
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CompanyAnalyzer:
|
class CompanyAnalyzer:
|
||||||
"""Orchestrates end-to-end company performance analysis via patents."""
|
"""Orchestrates end-to-end company performance analysis via patents."""
|
||||||
@@ -33,7 +33,7 @@ class CompanyAnalyzer:
|
|||||||
self.db.connect()
|
self.db.connect()
|
||||||
self.db.initialize_schema()
|
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.
|
"""Analyze a company's performance based on their patent portfolio.
|
||||||
|
|
||||||
This is the main entry point that orchestrates the full pipeline:
|
This is the main entry point that orchestrates the full pipeline:
|
||||||
@@ -46,6 +46,7 @@ class CompanyAnalyzer:
|
|||||||
Args:
|
Args:
|
||||||
company_name: Name of the company to analyze
|
company_name: Name of the company to analyze
|
||||||
patents: Optional pre-fetched Patents result to avoid duplicate API calls
|
patents: Optional pre-fetched Patents result to avoid duplicate API calls
|
||||||
|
model: Optional LLM model override (e.g. 'openai/gpt-4o')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Comprehensive analysis of company's innovation and performance outlook
|
Comprehensive analysis of company's innovation and performance outlook
|
||||||
@@ -100,12 +101,12 @@ class CompanyAnalyzer:
|
|||||||
|
|
||||||
# Analyze the full portfolio with LLM
|
# Analyze the full portfolio with LLM
|
||||||
analysis = self.llm_analyzer.analyze_patent_portfolio(
|
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
|
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.
|
"""Analyze a single patent by ID.
|
||||||
|
|
||||||
If the patent PDF is not already on disk, this method attempts to
|
If the patent PDF is not already on disk, this method attempts to
|
||||||
@@ -116,6 +117,7 @@ class CompanyAnalyzer:
|
|||||||
Args:
|
Args:
|
||||||
patent_id: Publication ID of the patent (e.g. "US-11234567-B2")
|
patent_id: Publication ID of the patent (e.g. "US-11234567-B2")
|
||||||
company_name: Name of the company (for context)
|
company_name: Name of the company (for context)
|
||||||
|
model: Optional LLM model override (e.g. 'openai/gpt-4o')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Analysis of the specific patent's innovation quality
|
Analysis of the specific patent's innovation quality
|
||||||
@@ -151,7 +153,7 @@ class CompanyAnalyzer:
|
|||||||
minimized_content = SERP.minimize_patent_for_llm(sections)
|
minimized_content = SERP.minimize_patent_for_llm(sections)
|
||||||
|
|
||||||
analysis = self.llm_analyzer.analyze_patent_content(
|
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
|
return analysis
|
||||||
@@ -201,18 +203,19 @@ class CompanyAnalyzer:
|
|||||||
logger.warning("Failed to process %s: %s", patent.patent_id, e)
|
logger.warning("Failed to process %s: %s", patent.patent_id, e)
|
||||||
return None
|
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.
|
"""Internal wrapper that catches exceptions and returns structured result.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
company_name: Name of the company to analyze
|
company_name: Name of the company to analyze
|
||||||
|
model: Optional LLM model override (e.g. 'openai/gpt-4o')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CompanyAnalysisResult with success/failure status
|
CompanyAnalysisResult with success/failure status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Delegate to analyze_company which handles SERP/patent caching
|
# 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
|
# Determine patent count from cached SERP query
|
||||||
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
|
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
|
||||||
@@ -252,6 +255,7 @@ class CompanyAnalyzer:
|
|||||||
companies: list[str],
|
companies: list[str],
|
||||||
max_workers: int = 3,
|
max_workers: int = 3,
|
||||||
progress_callback: Callable[[str, int, int], None] | None = None,
|
progress_callback: Callable[[str, int, int], None] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
) -> BatchAnalysisResult:
|
) -> BatchAnalysisResult:
|
||||||
"""Analyze multiple companies' patent portfolios in batch.
|
"""Analyze multiple companies' patent portfolios in batch.
|
||||||
|
|
||||||
@@ -262,6 +266,7 @@ class CompanyAnalyzer:
|
|||||||
companies: List of company names to analyze
|
companies: List of company names to analyze
|
||||||
max_workers: Maximum concurrent analyses (default 3 to avoid rate limits)
|
max_workers: Maximum concurrent analyses (default 3 to avoid rate limits)
|
||||||
progress_callback: Optional callback(company_name, completed, total)
|
progress_callback: Optional callback(company_name, completed, total)
|
||||||
|
model: Optional LLM model override (e.g. 'openai/gpt-4o')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BatchAnalysisResult containing all individual results and summary stats
|
BatchAnalysisResult containing all individual results and summary stats
|
||||||
@@ -273,7 +278,7 @@ class CompanyAnalyzer:
|
|||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
future_to_company = {
|
future_to_company = {
|
||||||
executor.submit(self._analyze_company_safe, company): company
|
executor.submit(self._analyze_company_safe, company, model): company
|
||||||
for company in companies
|
for company in companies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-5
@@ -3,9 +3,14 @@
|
|||||||
Provides REST API endpoints for analyzing company patent portfolios.
|
Provides REST API endpoints for analyzing company patent portfolios.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, List
|
from typing import TYPE_CHECKING, Annotated, List
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from SPARC.database import DatabaseClient
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
|
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -479,6 +484,20 @@ SUPPORTED_MODELS = [
|
|||||||
{"id": "meta-llama/llama-3.1-70b-instruct", "name": "Llama 3.1 70B", "provider": "Meta"},
|
{"id": "meta-llama/llama-3.1-70b-instruct", "name": "Llama 3.1 70B", "provider": "Meta"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_SUPPORTED_MODEL_IDS = {m["id"] for m in SUPPORTED_MODELS}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_model(model: str | None) -> None:
|
||||||
|
"""Raise HTTP 400 if *model* is not in the supported allow-list."""
|
||||||
|
if model is not None and model not in _SUPPORTED_MODEL_IDS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"Unsupported model '{model}'. "
|
||||||
|
f"Supported models: {', '.join(sorted(_SUPPORTED_MODEL_IDS))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models", tags=["System"])
|
@app.get("/models", tags=["System"])
|
||||||
async def list_models():
|
async def list_models():
|
||||||
@@ -639,7 +658,6 @@ async def export_company_pdf(
|
|||||||
PDF file download
|
PDF file download
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from reportlab.lib import colors
|
from reportlab.lib import colors
|
||||||
from reportlab.lib.pagesizes import letter
|
from reportlab.lib.pagesizes import letter
|
||||||
@@ -799,6 +817,7 @@ async def health_check():
|
|||||||
)
|
)
|
||||||
async def analyze_company(
|
async def analyze_company(
|
||||||
company_name: str,
|
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),
|
_: UserResponse = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Analyze a single company's patent portfolio.
|
"""Analyze a single company's patent portfolio.
|
||||||
@@ -808,14 +827,16 @@ async def analyze_company(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
company_name: Name of the company to analyze (e.g., "nvidia", "intel")
|
company_name: Name of the company to analyze (e.g., "nvidia", "intel")
|
||||||
|
model: Optional LLM model override
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Analysis results including patent count, AI insights, and success status
|
Analysis results including patent count, AI insights, and success status
|
||||||
"""
|
"""
|
||||||
|
_validate_model(model)
|
||||||
if not _analyzer:
|
if not _analyzer:
|
||||||
raise HTTPException(status_code=503, detail="Analyzer not initialized")
|
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)
|
return _convert_result(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -871,12 +892,14 @@ async def analyze_companies_batch(
|
|||||||
Returns:
|
Returns:
|
||||||
Batch results with individual company analyses and summary statistics
|
Batch results with individual company analyses and summary statistics
|
||||||
"""
|
"""
|
||||||
|
_validate_model(request.model)
|
||||||
if not _analyzer:
|
if not _analyzer:
|
||||||
raise HTTPException(status_code=503, detail="Analyzer not initialized")
|
raise HTTPException(status_code=503, detail="Analyzer not initialized")
|
||||||
|
|
||||||
result = _analyzer.analyze_companies(
|
result = _analyzer.analyze_companies(
|
||||||
companies=request.companies,
|
companies=request.companies,
|
||||||
max_workers=request.max_workers,
|
max_workers=request.max_workers,
|
||||||
|
model=request.model,
|
||||||
)
|
)
|
||||||
return _convert_batch_result(result)
|
return _convert_batch_result(result)
|
||||||
|
|
||||||
@@ -908,7 +931,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."""
|
"""Background task for batch analysis."""
|
||||||
import json as _json
|
import json as _json
|
||||||
global _analyzer
|
global _analyzer
|
||||||
@@ -933,6 +956,7 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
|
|||||||
companies=companies,
|
companies=companies,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
|
model=model,
|
||||||
)
|
)
|
||||||
batch_response = _convert_batch_result(result)
|
batch_response = _convert_batch_result(result)
|
||||||
db.update_job(
|
db.update_job(
|
||||||
@@ -979,6 +1003,7 @@ async def analyze_companies_async(
|
|||||||
Returns:
|
Returns:
|
||||||
Job status with job_id for polling
|
Job status with job_id for polling
|
||||||
"""
|
"""
|
||||||
|
_validate_model(request.model)
|
||||||
global _job_counter
|
global _job_counter
|
||||||
|
|
||||||
_job_counter += 1
|
_job_counter += 1
|
||||||
@@ -988,7 +1013,7 @@ async def analyze_companies_async(
|
|||||||
job_row = db.create_job(job_id=job_id, total_companies=len(request.companies))
|
job_row = db.create_job(job_id=job_id, total_companies=len(request.companies))
|
||||||
|
|
||||||
background_tasks.add_task(
|
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)
|
return _job_row_to_status(job_row)
|
||||||
|
|||||||
+6
-2
@@ -18,6 +18,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
init-db:
|
init-db:
|
||||||
|
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
|
||||||
build: .
|
build: .
|
||||||
container_name: sparc-init-db
|
container_name: sparc-init-db
|
||||||
command: python scripts/init_database.py
|
command: python scripts/init_database.py
|
||||||
@@ -29,6 +30,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
|
||||||
build: .
|
build: .
|
||||||
container_name: sparc-api
|
container_name: sparc-api
|
||||||
command: uvicorn SPARC.api:app --host 0.0.0.0 --port 8000
|
command: uvicorn SPARC.api:app --host 0.0.0.0 --port 8000
|
||||||
@@ -40,7 +42,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production}
|
JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
ROOT_PATH: /api
|
ROOT_PATH: ""
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -49,7 +51,7 @@ services:
|
|||||||
init-db:
|
init-db:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- ./patents:/app/patents
|
- patent_data:/app/patents
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Optional: MinIO for S3-compatible local object storage
|
# Optional: MinIO for S3-compatible local object storage
|
||||||
@@ -76,6 +78,7 @@ services:
|
|||||||
- s3
|
- s3
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
|
image: gitea.leeworks.dev/0xwheatyz/sparc:frontend-latest
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: sparc-dashboard
|
container_name: sparc-dashboard
|
||||||
ports:
|
ports:
|
||||||
@@ -86,4 +89,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
patent_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
+76
-1
@@ -276,7 +276,7 @@ The `docker-compose.yml` includes all services needed for production:
|
|||||||
|---------|-----------|------|-------------|
|
|---------|-----------|------|-------------|
|
||||||
| `postgres` | sparc-postgres | 5432 | PostgreSQL database |
|
| `postgres` | sparc-postgres | 5432 | PostgreSQL database |
|
||||||
| `init-db` | sparc-init-db | - | One-time database initialization (seeds admin user) |
|
| `init-db` | sparc-init-db | - | One-time database initialization (seeds admin user) |
|
||||||
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth |
|
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth (patent PDFs stored in `patent_data` volume) |
|
||||||
| `dashboard` | sparc-dashboard | 8080 | React TypeScript web UI |
|
| `dashboard` | sparc-dashboard | 8080 | React TypeScript web UI |
|
||||||
|
|
||||||
### Common Docker Compose Commands
|
### Common Docker Compose Commands
|
||||||
@@ -307,6 +307,81 @@ docker-compose restart api
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Patent PDF Storage
|
||||||
|
|
||||||
|
The SPARC API downloads patent PDFs during analysis and stores them at `/app/patents` inside the container. These files are used for subsequent single-patent analysis requests and as a local cache to avoid re-downloading. If this directory is not persisted, all downloaded PDFs are lost when the container is recreated.
|
||||||
|
|
||||||
|
### Docker Compose (default)
|
||||||
|
|
||||||
|
The default `docker-compose.yml` declares a named volume called `patent_data` that is mounted at `/app/patents`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In the api service:
|
||||||
|
volumes:
|
||||||
|
- patent_data:/app/patents
|
||||||
|
|
||||||
|
# At the top-level volumes section:
|
||||||
|
volumes:
|
||||||
|
patent_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
This means PDFs survive `docker compose down` and `docker compose up` cycles. To remove patent data intentionally, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v # WARNING: also removes postgres_data
|
||||||
|
# or selectively:
|
||||||
|
docker volume rm sparc_patent_data
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer a bind mount (e.g., for easy host-side access during development), replace the volume with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./patents:/app/patents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
|
||||||
|
For Kubernetes deployments, create a PersistentVolumeClaim and mount it into the API pod:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: sparc-patent-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: sparc-api
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
volumeMounts:
|
||||||
|
- name: patent-data
|
||||||
|
mountPath: /app/patents
|
||||||
|
volumes:
|
||||||
|
- name: patent-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: sparc-patent-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the storage size based on expected patent volume. Each patent PDF is typically 1-5 MB.
|
||||||
|
|
||||||
|
### S3 Object Storage (alternative)
|
||||||
|
|
||||||
|
For production deployments that need shared or highly durable storage, set `STORAGE_BACKEND=s3` in your `.env` file. This stores patent PDFs in an S3-compatible bucket (AWS S3 or MinIO) instead of the local filesystem, eliminating the need for a persistent volume. See the S3/MinIO section in `.env.example` for configuration details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Database Connection Issues
|
### Database Connection Issues
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ server {
|
|||||||
|
|
||||||
# Proxy API requests to backend
|
# Proxy API requests to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass ${API_URL}/;
|
proxy_pass ${API_URL};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|||||||
Generated
+257
@@ -26,6 +26,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
"globals": "^15.8.0",
|
"globals": "^15.8.0",
|
||||||
|
"openapi-typescript": "^7.0.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "~5.5.3",
|
"typescript": "~5.5.3",
|
||||||
@@ -1025,6 +1026,82 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@redocly/ajv": {
|
||||||
|
"version": "8.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
|
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2",
|
||||||
|
"uri-js-replace": "^1.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/config": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core": {
|
||||||
|
"version": "1.34.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
|
||||||
|
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/ajv": "8.11.2",
|
||||||
|
"@redocly/config": "0.22.0",
|
||||||
|
"colorette": "1.4.0",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
|
"js-levenshtein": "1.1.6",
|
||||||
|
"js-yaml": "4.1.1",
|
||||||
|
"minimatch": "5.1.9",
|
||||||
|
"pluralize": "8.0.0",
|
||||||
|
"yaml-ast-parser": "0.0.43"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0",
|
||||||
|
"npm": ">=9.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1906,6 +1983,16 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -1923,6 +2010,16 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-colors": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -2190,6 +2287,13 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/change-case": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -2257,6 +2361,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -3165,6 +3276,20 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3202,6 +3327,19 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/index-to-position": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
@@ -3290,6 +3428,16 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-levenshtein": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3608,6 +3756,40 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi-typescript": {
|
||||||
|
"version": "7.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||||
|
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/openapi-core": "^1.34.6",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"change-case": "^5.4.4",
|
||||||
|
"parse-json": "^8.3.0",
|
||||||
|
"supports-color": "^10.2.2",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openapi-typescript": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -3671,6 +3853,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-json": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
"index-to-position": "^1.1.0",
|
||||||
|
"type-fest": "^4.39.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -3738,6 +3938,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pluralize": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -4124,6 +4334,16 @@
|
|||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -4510,6 +4730,19 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.5.4",
|
"version": "5.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||||
@@ -4589,6 +4822,13 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uri-js-replace": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -4711,6 +4951,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml-ast-parser": {
|
||||||
|
"version": "0.0.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -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
|
// Analysis API
|
||||||
export const analysisApi = {
|
export const analysisApi = {
|
||||||
analyzeCompany: async (companyName: string): Promise<CompanyAnalysis> => {
|
analyzeCompany: async (companyName: string, model?: string): Promise<CompanyAnalysis> => {
|
||||||
const response = await api.get<CompanyAnalysis>(`/analyze/${encodeURIComponent(companyName)}`);
|
const params = new URLSearchParams();
|
||||||
|
if (model) params.append('model', model);
|
||||||
|
const qs = params.toString();
|
||||||
|
const response = await api.get<CompanyAnalysis>(
|
||||||
|
`/analyze/${encodeURIComponent(companyName)}${qs ? `?${qs}` : ''}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
analyzeBatch: async (companies: string[], maxWorkers = 3): Promise<BatchAnalysisResult> => {
|
analyzeBatch: async (companies: string[], maxWorkers = 3, model?: string): Promise<BatchAnalysisResult> => {
|
||||||
const response = await api.post<BatchAnalysisResult>('/analyze/batch', {
|
const response = await api.post<BatchAnalysisResult>('/analyze/batch', {
|
||||||
companies,
|
companies,
|
||||||
max_workers: maxWorkers,
|
max_workers: maxWorkers,
|
||||||
|
...(model ? { model } : {}),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
analyzeBatchAsync: async (companies: string[], maxWorkers = 3): Promise<JobStatus> => {
|
analyzeBatchAsync: async (companies: string[], maxWorkers = 3, model?: string): Promise<JobStatus> => {
|
||||||
const response = await api.post<JobStatus>('/analyze/batch/async', {
|
const response = await api.post<JobStatus>('/analyze/batch/async', {
|
||||||
companies,
|
companies,
|
||||||
max_workers: maxWorkers,
|
max_workers: maxWorkers,
|
||||||
|
...(model ? { model } : {}),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listModels: async (): Promise<ModelsResponse> => {
|
||||||
|
const response = await api.get<ModelsResponse>('/models');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getJobStatus: async (jobId: string): Promise<JobStatus> => {
|
getJobStatus: async (jobId: string): Promise<JobStatus> => {
|
||||||
const response = await api.get<JobStatus>(`/jobs/${jobId}`);
|
const response = await api.get<JobStatus>(`/jobs/${jobId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Vendored
+975
@@ -0,0 +1,975 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
"/auth/register": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Register
|
||||||
|
* @description Register a new user.
|
||||||
|
*
|
||||||
|
* The first registered user automatically becomes an admin.
|
||||||
|
*/
|
||||||
|
post: operations["register_auth_register_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Login
|
||||||
|
* @description Authenticate user and return JWT tokens.
|
||||||
|
*/
|
||||||
|
post: operations["login_auth_login_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/refresh": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Refresh Token
|
||||||
|
* @description Refresh access token using refresh token.
|
||||||
|
*/
|
||||||
|
post: operations["refresh_token_auth_refresh_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/me": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Me
|
||||||
|
* @description Get current authenticated user.
|
||||||
|
*/
|
||||||
|
get: operations["get_me_auth_me_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/users": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List Users
|
||||||
|
* @description List all users (admin only).
|
||||||
|
*/
|
||||||
|
get: operations["list_users_admin_users_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/users/{user_id}/role": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/**
|
||||||
|
* Update User Role
|
||||||
|
* @description Update a user's role (admin only).
|
||||||
|
*/
|
||||||
|
patch: operations["update_user_role_admin_users__user_id__role_patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/users/{user_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete User
|
||||||
|
* @description Delete a user (admin only).
|
||||||
|
*/
|
||||||
|
delete: operations["delete_user_admin_users__user_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/analytics": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Analytics
|
||||||
|
* @description Get analytics data (authenticated users only).
|
||||||
|
*/
|
||||||
|
get: operations["get_analytics_analytics_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/health": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Health Check
|
||||||
|
* @description Check API health status.
|
||||||
|
*/
|
||||||
|
get: operations["health_check_health_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/analyze/{company_name}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Analyze Company
|
||||||
|
* @description Analyze a single company's patent portfolio.
|
||||||
|
*
|
||||||
|
* This endpoint retrieves recent patents for the specified company,
|
||||||
|
* parses them, and uses AI to generate a comprehensive analysis.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* company_name: Name of the company to analyze (e.g., "nvidia", "intel")
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* Analysis results including patent count, AI insights, and success status
|
||||||
|
*/
|
||||||
|
get: operations["analyze_company_analyze__company_name__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/analyze/batch": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Analyze Companies Batch
|
||||||
|
* @description Analyze multiple companies' patent portfolios.
|
||||||
|
*
|
||||||
|
* Processes companies concurrently for improved performance.
|
||||||
|
* Limited to 20 companies per request.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* request: List of company names and optional worker count
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* Batch results with individual company analyses and summary statistics
|
||||||
|
*/
|
||||||
|
post: operations["analyze_companies_batch_analyze_batch_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/analyze/batch/async": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Analyze Companies Async
|
||||||
|
* @description Start an asynchronous batch analysis job.
|
||||||
|
*
|
||||||
|
* Returns immediately with a job ID that can be used to poll for status.
|
||||||
|
* Useful for large batch analyses that may take a long time.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* request: List of company names and optional worker count
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* Job status with job_id for polling
|
||||||
|
*/
|
||||||
|
post: operations["analyze_companies_async_analyze_batch_async_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/jobs/{job_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Job Status
|
||||||
|
* @description Get the status of a background analysis job.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* job_id: The job ID returned from the async batch endpoint
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* Current job status including progress and results when complete
|
||||||
|
*/
|
||||||
|
get: operations["get_job_status_jobs__job_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/jobs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List Jobs
|
||||||
|
* @description List all analysis jobs.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* status: Optional filter by job status
|
||||||
|
* limit: Maximum number of jobs to return (default 10, max 100)
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* List of job statuses
|
||||||
|
*/
|
||||||
|
get: operations["list_jobs_jobs_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
/**
|
||||||
|
* AnalyticsResponse
|
||||||
|
* @description Analytics response model.
|
||||||
|
*/
|
||||||
|
AnalyticsResponse: {
|
||||||
|
/** Total Messages */
|
||||||
|
total_messages: number;
|
||||||
|
/** By Company */
|
||||||
|
by_company: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
|
/** By Type */
|
||||||
|
by_type: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
|
/** Period Days */
|
||||||
|
period_days: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* BatchAnalysisRequest
|
||||||
|
* @description Request model for batch company analysis.
|
||||||
|
*/
|
||||||
|
BatchAnalysisRequest: {
|
||||||
|
/**
|
||||||
|
* Companies
|
||||||
|
* @description List of company names to analyze
|
||||||
|
*/
|
||||||
|
companies: string[];
|
||||||
|
/**
|
||||||
|
* Max Workers
|
||||||
|
* @description Max concurrent analyses
|
||||||
|
* @default 3
|
||||||
|
*/
|
||||||
|
max_workers: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* BatchAnalysisResponse
|
||||||
|
* @description Response model for batch company analysis.
|
||||||
|
*/
|
||||||
|
BatchAnalysisResponse: {
|
||||||
|
/** Results */
|
||||||
|
results: components["schemas"]["CompanyAnalysisResponse"][];
|
||||||
|
/** Total Companies */
|
||||||
|
total_companies: number;
|
||||||
|
/** Successful */
|
||||||
|
successful: number;
|
||||||
|
/** Failed */
|
||||||
|
failed: number;
|
||||||
|
/**
|
||||||
|
* Timestamp
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* CompanyAnalysisResponse
|
||||||
|
* @description Response model for single company analysis.
|
||||||
|
*/
|
||||||
|
CompanyAnalysisResponse: {
|
||||||
|
/** Company Name */
|
||||||
|
company_name: string;
|
||||||
|
/** Analysis */
|
||||||
|
analysis: string;
|
||||||
|
/** Patent Count */
|
||||||
|
patent_count: number;
|
||||||
|
/** Success */
|
||||||
|
success: boolean;
|
||||||
|
/** Error */
|
||||||
|
error?: string | null;
|
||||||
|
/**
|
||||||
|
* Timestamp
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
/** HTTPValidationError */
|
||||||
|
HTTPValidationError: {
|
||||||
|
/** Detail */
|
||||||
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* HealthResponse
|
||||||
|
* @description Health check response.
|
||||||
|
*/
|
||||||
|
HealthResponse: {
|
||||||
|
/** Status */
|
||||||
|
status: string;
|
||||||
|
/** Version */
|
||||||
|
version: string;
|
||||||
|
/**
|
||||||
|
* Timestamp
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* JobStatus
|
||||||
|
* @description Status of a background analysis job.
|
||||||
|
*/
|
||||||
|
JobStatus: {
|
||||||
|
/** Job Id */
|
||||||
|
job_id: string;
|
||||||
|
/** Status */
|
||||||
|
status: string;
|
||||||
|
/** Progress */
|
||||||
|
progress: number;
|
||||||
|
/** Total Companies */
|
||||||
|
total_companies: number;
|
||||||
|
/** Completed Companies */
|
||||||
|
completed_companies: number;
|
||||||
|
result?: components["schemas"]["BatchAnalysisResponse"] | null;
|
||||||
|
/** Error */
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* LoginRequest
|
||||||
|
* @description User login request.
|
||||||
|
*/
|
||||||
|
LoginRequest: {
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
* Format: email
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* RefreshRequest
|
||||||
|
* @description Token refresh request.
|
||||||
|
*/
|
||||||
|
RefreshRequest: {
|
||||||
|
/** Refresh Token */
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* RegisterRequest
|
||||||
|
* @description User registration request.
|
||||||
|
*/
|
||||||
|
RegisterRequest: {
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
* Format: email
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
/**
|
||||||
|
* Password
|
||||||
|
* @description Password (min 8 characters)
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* TokenResponse
|
||||||
|
* @description Token response model.
|
||||||
|
*/
|
||||||
|
TokenResponse: {
|
||||||
|
/** Access Token */
|
||||||
|
access_token: string;
|
||||||
|
/** Refresh Token */
|
||||||
|
refresh_token: string;
|
||||||
|
/**
|
||||||
|
* Token Type
|
||||||
|
* @default bearer
|
||||||
|
*/
|
||||||
|
token_type: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* UpdateRoleRequest
|
||||||
|
* @description Update user role request.
|
||||||
|
*/
|
||||||
|
UpdateRoleRequest: {
|
||||||
|
/** Role */
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* UserResponse
|
||||||
|
* @description User response model.
|
||||||
|
*/
|
||||||
|
UserResponse: {
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Email */
|
||||||
|
email: string;
|
||||||
|
/** Role */
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/** ValidationError */
|
||||||
|
ValidationError: {
|
||||||
|
/** Location */
|
||||||
|
loc: (string | number)[];
|
||||||
|
/** Message */
|
||||||
|
msg: string;
|
||||||
|
/** Error Type */
|
||||||
|
type: string;
|
||||||
|
/** Input */
|
||||||
|
input?: unknown;
|
||||||
|
/** Context */
|
||||||
|
ctx?: Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
register_auth_register_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RegisterRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
login_auth_login_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LoginRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TokenResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
refresh_token_auth_refresh_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RefreshRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TokenResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_me_auth_me_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_users_admin_users_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserResponse"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_user_role_admin_users__user_id__role_patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateRoleRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_user_admin_users__user_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_analytics_analytics_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnalyticsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
health_check_health_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HealthResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
analyze_company_analyze__company_name__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
company_name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CompanyAnalysisResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
analyze_companies_batch_analyze_batch_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BatchAnalysisRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BatchAnalysisResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
analyze_companies_async_analyze_batch_async_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BatchAnalysisRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JobStatus"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_job_status_jobs__job_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
job_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JobStatus"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_jobs_jobs_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
/** @description Filter by status: pending, running, completed, failed */
|
||||||
|
status?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JobStatus"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useTheme } from './ThemeContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns theme-aware color values for recharts components.
|
||||||
|
*
|
||||||
|
* Recharts accepts only raw color strings (not CSS variables),
|
||||||
|
* so this hook bridges the Tailwind/CSS-variable theme system
|
||||||
|
* to the imperative recharts API.
|
||||||
|
*/
|
||||||
|
export function useChartTheme() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Axis tick and grid line stroke color */
|
||||||
|
axisStroke: isDark ? '#94a3b8' : '#64748b',
|
||||||
|
/** Tooltip container background */
|
||||||
|
tooltipBg: isDark ? '#1e293b' : '#ffffff',
|
||||||
|
/** Tooltip container border */
|
||||||
|
tooltipBorder: isDark
|
||||||
|
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||||
|
: '1px solid rgba(99, 102, 241, 0.2)',
|
||||||
|
/** Tooltip label text color */
|
||||||
|
tooltipLabelColor: isDark ? '#f8fafc' : '#0f172a',
|
||||||
|
/** Tooltip item text color */
|
||||||
|
tooltipItemColor: isDark ? '#e2e8f0' : '#334155',
|
||||||
|
/** Convenience: full contentStyle object for recharts Tooltip */
|
||||||
|
tooltipContentStyle: {
|
||||||
|
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||||
|
border: isDark
|
||||||
|
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||||
|
: '1px solid rgba(99, 102, 241, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: isDark ? '#f8fafc' : '#0f172a',
|
||||||
|
},
|
||||||
|
/** Convenience: labelStyle for recharts Tooltip */
|
||||||
|
tooltipLabelStyle: {
|
||||||
|
color: isDark ? '#f8fafc' : '#0f172a',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { analysisApi, exportApi } from '../api/client';
|
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';
|
import type { CompanyAnalysis } from '../types';
|
||||||
|
|
||||||
export function Analysis() {
|
export function Analysis() {
|
||||||
const [companyName, setCompanyName] = useState('');
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
||||||
|
|
||||||
|
const modelsQuery = useQuery({
|
||||||
|
queryKey: ['models'],
|
||||||
|
queryFn: () => analysisApi.listModels(),
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (name: string) => analysisApi.analyzeCompany(name),
|
mutationFn: (name: string) => analysisApi.analyzeCompany(name, selectedModel || undefined),
|
||||||
onSuccess: (data) => setResult(data),
|
onSuccess: (data) => setResult(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +39,8 @@ export function Analysis() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex gap-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
<input
|
<input
|
||||||
@@ -58,6 +65,31 @@ export function Analysis() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selector */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-text-secondary whitespace-nowrap">
|
||||||
|
LLM Model
|
||||||
|
</label>
|
||||||
|
<div className="relative flex-1 max-w-xs">
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
className="w-full appearance-none bg-bg-card/80 border border-primary/30 rounded-lg pl-3 pr-8 py-2 text-sm text-text-primary focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{modelsQuery.data ? `Default (${modelsQuery.data.default})` : 'Default'}
|
||||||
|
</option>
|
||||||
|
{modelsQuery.data?.models.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.provider})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
@@ -127,7 +159,7 @@ export function Analysis() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prose prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||||
{result.analysis}
|
{result.analysis}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { analyticsApi } from '../api/client';
|
import { analyticsApi } from '../api/client';
|
||||||
import { AlertCircle, Database } from 'lucide-react';
|
import { AlertCircle, Database } from 'lucide-react';
|
||||||
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
import { useChartTheme } from '../context/useChartTheme';
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
|
|
||||||
export function AnalyticsPage() {
|
export function AnalyticsPage() {
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
|
const chartTheme = useChartTheme();
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['analytics', days],
|
queryKey: ['analytics', days],
|
||||||
@@ -160,11 +162,7 @@ export function AnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={chartTheme.tooltipContentStyle}
|
||||||
backgroundColor: '#1e293b',
|
|
||||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
@@ -178,15 +176,11 @@ export function AnalyticsPage() {
|
|||||||
<h3 className="text-lg font-semibold text-text-primary mb-4">Analysis Types</h3>
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Analysis Types</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={typeData}>
|
<BarChart data={typeData}>
|
||||||
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={chartTheme.tooltipContentStyle}
|
||||||
backgroundColor: '#1e293b',
|
labelStyle={chartTheme.tooltipLabelStyle}
|
||||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#f8fafc' }}
|
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -222,15 +216,11 @@ export function AnalyticsPage() {
|
|||||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={pivoted}>
|
<LineChart data={pivoted}>
|
||||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={chartTheme.tooltipContentStyle}
|
||||||
backgroundColor: '#1e293b',
|
labelStyle={chartTheme.tooltipLabelStyle}
|
||||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#f8fafc' }}
|
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
{companies.map((company, idx) => (
|
{companies.map((company, idx) => (
|
||||||
@@ -268,15 +258,11 @@ export function AnalyticsPage() {
|
|||||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={pivoted}>
|
<BarChart data={pivoted}>
|
||||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={chartTheme.tooltipContentStyle}
|
||||||
backgroundColor: '#1e293b',
|
labelStyle={chartTheme.tooltipLabelStyle}
|
||||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#f8fafc' }}
|
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
{types.map((type, idx) => (
|
{types.map((type, idx) => (
|
||||||
|
|||||||
+183
-13
@@ -1,20 +1,37 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { analysisApi } from '../api/client';
|
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 { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { useChartTheme } from '../context/useChartTheme';
|
||||||
import type { BatchAnalysisResult } from '../types';
|
import type { BatchAnalysisResult } from '../types';
|
||||||
|
|
||||||
export function Batch() {
|
export function Batch() {
|
||||||
const [companiesInput, setCompaniesInput] = useState('');
|
const [companiesInput, setCompaniesInput] = useState('');
|
||||||
const [maxWorkers, setMaxWorkers] = useState(3);
|
const [maxWorkers, setMaxWorkers] = useState(3);
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const chartTheme = useChartTheme();
|
||||||
|
|
||||||
|
const modelsQuery = useQuery({
|
||||||
|
queryKey: ['models'],
|
||||||
|
queryFn: () => analysisApi.listModels(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobsQuery = useQuery({
|
||||||
|
queryKey: ['jobs'],
|
||||||
|
queryFn: () => analysisApi.listJobs(undefined, 20),
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
|
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
|
||||||
analysisApi.analyzeBatch(companies, workers),
|
analysisApi.analyzeBatch(companies, workers, selectedModel || undefined),
|
||||||
onSuccess: (data) => setResult(data),
|
onSuccess: (data) => {
|
||||||
|
setResult(data);
|
||||||
|
jobsQuery.refetch();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -85,6 +102,29 @@ export function Batch() {
|
|||||||
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
|
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
LLM Model
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
className="w-full appearance-none bg-bg-card/80 border border-primary/30 rounded-lg pl-3 pr-8 py-2 text-sm text-text-primary focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{modelsQuery.data ? `Default (${modelsQuery.data.default})` : 'Default'}
|
||||||
|
</option>
|
||||||
|
{modelsQuery.data?.models.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.provider})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none" size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={mutation.isPending || !companiesInput.trim()}
|
disabled={mutation.isPending || !companiesInput.trim()}
|
||||||
@@ -123,13 +163,30 @@ export function Batch() {
|
|||||||
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
|
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
|
||||||
{' '}Check your connection and try again.
|
{' '}Check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="ml-7 mt-2 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const companies = companiesInput
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter((c) => c.length > 0);
|
||||||
|
if (companies.length > 0) {
|
||||||
|
mutation.mutate({ companies, workers: maxWorkers });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => mutation.reset()}
|
onClick={() => mutation.reset()}
|
||||||
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
|
className="text-sm text-text-secondary hover:text-text-primary underline"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
@@ -156,15 +213,11 @@ export function Batch() {
|
|||||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={chartTheme.tooltipContentStyle}
|
||||||
backgroundColor: '#1e293b',
|
labelStyle={chartTheme.tooltipLabelStyle}
|
||||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: '#f8fafc' }}
|
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
|
||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
@@ -230,6 +283,123 @@ export function Batch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Job History */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
Job History
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{jobsQuery.isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-5 w-5 rounded-full bg-primary/20" />
|
||||||
|
<div className="h-4 w-32 rounded bg-primary/20" />
|
||||||
|
<div className="h-4 w-20 rounded bg-primary/10" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-20 rounded-full bg-primary/15" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-4">
|
||||||
|
<div className="h-3 w-24 rounded bg-primary/10" />
|
||||||
|
<div className="h-3 w-16 rounded bg-primary/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job history error */}
|
||||||
|
{jobsQuery.isError && (
|
||||||
|
<div className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-error">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span className="font-semibold">Failed to load job history</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm mt-1 ml-7">
|
||||||
|
{jobsQuery.error instanceof Error ? jobsQuery.error.message : 'Could not retrieve past jobs.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => jobsQuery.refetch()}
|
||||||
|
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{jobsQuery.isSuccess && jobsQuery.data.length === 0 && !result && (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 border-dashed rounded-xl p-8 text-center">
|
||||||
|
<Inbox className="mx-auto text-text-secondary/40 mb-3" size={40} />
|
||||||
|
<p className="text-text-secondary font-medium">No batch jobs yet</p>
|
||||||
|
<p className="text-text-secondary/70 text-sm mt-1">
|
||||||
|
Submit a batch analysis above to get started. Your job history will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job list */}
|
||||||
|
{jobsQuery.isSuccess && jobsQuery.data.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{jobsQuery.data.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.job_id}
|
||||||
|
className="bg-bg-card/60 border border-primary/15 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{job.status === 'completed' && <CheckCircle className="text-success" size={18} />}
|
||||||
|
{job.status === 'failed' && <AlertCircle className="text-error" size={18} />}
|
||||||
|
{(job.status === 'pending' || job.status === 'running') && (
|
||||||
|
<div className="animate-spin rounded-full h-[18px] w-[18px] border-t-2 border-b-2 border-secondary" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-sm text-text-primary">{job.job_id.slice(0, 8)}</span>
|
||||||
|
<span className="text-text-secondary text-sm">
|
||||||
|
{job.total_companies} {job.total_companies === 1 ? 'company' : 'companies'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
|
||||||
|
job.status === 'completed'
|
||||||
|
? 'bg-success/15 text-success'
|
||||||
|
: job.status === 'failed'
|
||||||
|
? 'bg-error/15 text-error'
|
||||||
|
: 'bg-secondary/15 text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(job.status === 'running' || job.status === 'pending') && job.total_companies > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{job.completed_companies}/{job.total_companies}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-bg-dark rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-primary to-secondary rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${(job.completed_companies / job.total_companies) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && job.error && (
|
||||||
|
<p className="mt-2 text-sm text-error/80">{job.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-42
@@ -1,46 +1,32 @@
|
|||||||
export interface User {
|
/**
|
||||||
id: number;
|
* Application types derived from the auto-generated OpenAPI schema.
|
||||||
email: string;
|
*
|
||||||
role: 'admin' | 'user';
|
* Run `npm run generate:local` (or `npm run generate` with the API running)
|
||||||
created_at: string;
|
* to regenerate `src/api/schema.d.ts` from the backend OpenAPI spec.
|
||||||
}
|
*
|
||||||
|
* These aliases keep the rest of the codebase stable while the source of
|
||||||
|
* truth lives in the generated file.
|
||||||
|
*/
|
||||||
|
|
||||||
export interface TokenResponse {
|
import type { components } from '../api/schema';
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompanyAnalysis {
|
// Re-export schema types under the names the rest of the app expects.
|
||||||
company_name: string;
|
export type User = components['schemas']['UserResponse'];
|
||||||
analysis: string;
|
export type TokenResponse = components['schemas']['TokenResponse'];
|
||||||
patent_count: number;
|
export type CompanyAnalysis = components['schemas']['CompanyAnalysisResponse'];
|
||||||
success: boolean;
|
export type BatchAnalysisResult = components['schemas']['BatchAnalysisResponse'];
|
||||||
error: string | null;
|
export type JobStatus = components['schemas']['JobStatus'];
|
||||||
timestamp: string;
|
export type Analytics = Omit<components['schemas']['AnalyticsResponse'], 'by_company' | 'by_type'> & {
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchAnalysisResult {
|
|
||||||
results: CompanyAnalysis[];
|
|
||||||
total_companies: number;
|
|
||||||
successful: number;
|
|
||||||
failed: number;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobStatus {
|
|
||||||
job_id: string;
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
progress: number;
|
|
||||||
total_companies: number;
|
|
||||||
completed_companies: number;
|
|
||||||
result: BatchAnalysisResult | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Analytics {
|
|
||||||
total_messages: number;
|
|
||||||
by_company: Array<{ company_name: string; count: number }>;
|
by_company: Array<{ company_name: string; count: number }>;
|
||||||
by_type: Array<{ analysis_type: string; count: number }>;
|
by_type: Array<{ analysis_type: string; count: number }>;
|
||||||
period_days: number;
|
};
|
||||||
}
|
|
||||||
|
// Additional generated types that may be useful elsewhere.
|
||||||
|
export type RegisterRequest = components['schemas']['RegisterRequest'];
|
||||||
|
export type LoginRequest = components['schemas']['LoginRequest'];
|
||||||
|
export type RefreshRequest = components['schemas']['RefreshRequest'];
|
||||||
|
export type UpdateRoleRequest = components['schemas']['UpdateRoleRequest'];
|
||||||
|
export type HealthResponse = components['schemas']['HealthResponse'];
|
||||||
|
export type BatchAnalysisRequest = components['schemas']['BatchAnalysisRequest'];
|
||||||
|
export type ValidationError = components['schemas']['ValidationError'];
|
||||||
|
export type HTTPValidationError = components['schemas']['HTTPValidationError'];
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Tests for analyze_single_patent auto-download path.
|
||||||
|
|
||||||
|
Covers issue #1661:
|
||||||
|
- PDF exists on disk: direct analysis (happy path)
|
||||||
|
- PDF not on disk, cached link exists: auto-download and analyze
|
||||||
|
- PDF not on disk, no cached link: FileNotFoundError
|
||||||
|
- Analysis failure after PDF found: graceful error message
|
||||||
|
- Model override parameter passthrough
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from SPARC.analyzer import CompanyAnalyzer
|
||||||
|
from SPARC.types import Patent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_db(mocker):
|
||||||
|
"""Mock DatabaseClient so no real DB is needed."""
|
||||||
|
mock_db_cls = mocker.patch("SPARC.analyzer.DatabaseClient")
|
||||||
|
mock_db_instance = MagicMock()
|
||||||
|
mock_db_instance.get_cached_patent.return_value = None
|
||||||
|
mock_db_instance.get_cached_serp_query.return_value = None
|
||||||
|
mock_db_cls.return_value = mock_db_instance
|
||||||
|
return mock_db_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def analyzer(mocker, mock_db):
|
||||||
|
"""Create a CompanyAnalyzer with mocked LLM and DB."""
|
||||||
|
mocker.patch("SPARC.analyzer.LLMAnalyzer")
|
||||||
|
return CompanyAnalyzer(openrouter_api_key="test-key")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeSinglePatentAutoDownload:
|
||||||
|
"""Test the auto-download logic in analyze_single_patent."""
|
||||||
|
|
||||||
|
def test_pdf_on_disk_analyzed_directly(self, analyzer, mocker, tmp_path):
|
||||||
|
"""When PDF exists on disk, it is analyzed directly without download."""
|
||||||
|
patent_id = "US-11234567-B2"
|
||||||
|
|
||||||
|
# Create the patents dir and PDF file
|
||||||
|
patents_dir = tmp_path / "patents"
|
||||||
|
patents_dir.mkdir()
|
||||||
|
pdf_path = patents_dir / f"{patent_id}.pdf"
|
||||||
|
pdf_path.write_bytes(b"fake PDF content")
|
||||||
|
|
||||||
|
mock_parse = mocker.patch("SPARC.analyzer.SERP.parse_patent_pdf")
|
||||||
|
mock_minimize = mocker.patch("SPARC.analyzer.SERP.minimize_patent_for_llm")
|
||||||
|
mock_parse.return_value = {"abstract": "test", "claims": "test claims"}
|
||||||
|
mock_minimize.return_value = "minimized content"
|
||||||
|
analyzer.llm_analyzer.analyze_patent_content.return_value = "Good patent."
|
||||||
|
|
||||||
|
# Change cwd so patents/{patent_id}.pdf resolves to our tmp_path
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
result = analyzer.analyze_single_patent(patent_id, "TestCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
assert result == "Good patent."
|
||||||
|
# DB cache should not have been queried since file existed
|
||||||
|
analyzer.db.get_cached_patent.assert_not_called()
|
||||||
|
|
||||||
|
def test_auto_download_from_cached_link(self, analyzer, mocker, tmp_path):
|
||||||
|
"""When PDF is not on disk but link is cached, auto-download occurs."""
|
||||||
|
patent_id = "US-99887766-A1"
|
||||||
|
|
||||||
|
# No patents dir exists (PDF not on disk)
|
||||||
|
mock_save = mocker.patch("SPARC.analyzer.SERP.save_patents")
|
||||||
|
downloaded_patent = Patent(patent_id=patent_id, pdf_link="https://example.com/patent.pdf")
|
||||||
|
downloaded_patent.pdf_path = f"patents/{patent_id}.pdf"
|
||||||
|
mock_save.return_value = downloaded_patent
|
||||||
|
|
||||||
|
# Cached patent has a PDF link
|
||||||
|
analyzer.db.get_cached_patent.return_value = {
|
||||||
|
"patent_id": patent_id,
|
||||||
|
"pdf_link": "https://example.com/patent.pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock the rest of the analysis pipeline
|
||||||
|
mock_parse = mocker.patch("SPARC.analyzer.SERP.parse_patent_pdf")
|
||||||
|
mock_minimize = mocker.patch("SPARC.analyzer.SERP.minimize_patent_for_llm")
|
||||||
|
mock_parse.return_value = {"abstract": "test abstract"}
|
||||||
|
mock_minimize.return_value = "minimized content"
|
||||||
|
analyzer.llm_analyzer.analyze_patent_content.return_value = "Strong innovation."
|
||||||
|
|
||||||
|
# Change cwd so patents/{patent_id}.pdf does NOT exist
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
result = analyzer.analyze_single_patent(patent_id, "DownloadCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
assert result == "Strong innovation."
|
||||||
|
analyzer.db.get_cached_patent.assert_called_once_with(patent_id)
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
# Verify the Patent passed to save_patents has the correct ID and link
|
||||||
|
saved_patent = mock_save.call_args[0][0]
|
||||||
|
assert saved_patent.patent_id == patent_id
|
||||||
|
assert saved_patent.pdf_link == "https://example.com/patent.pdf"
|
||||||
|
|
||||||
|
def test_no_cached_link_raises_file_not_found(self, analyzer, mocker, tmp_path):
|
||||||
|
"""When PDF is not on disk and no cached link, FileNotFoundError raised."""
|
||||||
|
patent_id = "US-00000000-X1"
|
||||||
|
|
||||||
|
analyzer.db.get_cached_patent.return_value = None
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
with pytest.raises(FileNotFoundError, match="no download link is cached"):
|
||||||
|
analyzer.analyze_single_patent(patent_id, "MissingCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_cached_patent_without_pdf_link_raises(self, analyzer, mocker, tmp_path):
|
||||||
|
"""When cached patent exists but has no pdf_link, FileNotFoundError raised."""
|
||||||
|
patent_id = "US-11111111-B1"
|
||||||
|
|
||||||
|
analyzer.db.get_cached_patent.return_value = {
|
||||||
|
"patent_id": patent_id,
|
||||||
|
"pdf_link": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
with pytest.raises(FileNotFoundError, match="no download link is cached"):
|
||||||
|
analyzer.analyze_single_patent(patent_id, "NoPDFCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
def test_analysis_exception_returns_error_message(self, analyzer, mocker, tmp_path):
|
||||||
|
"""When analysis pipeline fails, returns error string instead of raising."""
|
||||||
|
patent_id = "US-22222222-A2"
|
||||||
|
|
||||||
|
# Create the PDF on disk so it skips download
|
||||||
|
patents_dir = tmp_path / "patents"
|
||||||
|
patents_dir.mkdir()
|
||||||
|
(patents_dir / f"{patent_id}.pdf").write_bytes(b"fake PDF")
|
||||||
|
|
||||||
|
# Parse fails
|
||||||
|
mocker.patch(
|
||||||
|
"SPARC.analyzer.SERP.parse_patent_pdf",
|
||||||
|
side_effect=ValueError("Corrupt PDF"),
|
||||||
|
)
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
result = analyzer.analyze_single_patent(patent_id, "ErrorCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
assert "Failed to analyze patent" in result
|
||||||
|
assert "Corrupt PDF" in result
|
||||||
|
|
||||||
|
def test_model_override_passed_to_llm(self, analyzer, mocker, tmp_path):
|
||||||
|
"""The model parameter is forwarded to the LLM analyzer."""
|
||||||
|
patent_id = "US-33333333-B2"
|
||||||
|
|
||||||
|
patents_dir = tmp_path / "patents"
|
||||||
|
patents_dir.mkdir()
|
||||||
|
(patents_dir / f"{patent_id}.pdf").write_bytes(b"fake PDF")
|
||||||
|
|
||||||
|
mocker.patch("SPARC.analyzer.SERP.parse_patent_pdf", return_value={"abstract": "test"})
|
||||||
|
mocker.patch("SPARC.analyzer.SERP.minimize_patent_for_llm", return_value="content")
|
||||||
|
analyzer.llm_analyzer.analyze_patent_content.return_value = "Analysis result."
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
result = analyzer.analyze_single_patent(
|
||||||
|
patent_id, "ModelCo", model="openai/gpt-4o"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
|
assert result == "Analysis result."
|
||||||
|
analyzer.llm_analyzer.analyze_patent_content.assert_called_once_with(
|
||||||
|
patent_content="content",
|
||||||
|
company_name="ModelCo",
|
||||||
|
model="openai/gpt-4o",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_file_not_found_during_parse_re_raised(self, analyzer, mocker, tmp_path):
|
||||||
|
"""FileNotFoundError during parsing is re-raised, not caught."""
|
||||||
|
patent_id = "US-44444444-C1"
|
||||||
|
|
||||||
|
patents_dir = tmp_path / "patents"
|
||||||
|
patents_dir.mkdir()
|
||||||
|
(patents_dir / f"{patent_id}.pdf").write_bytes(b"fake PDF")
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"SPARC.analyzer.SERP.parse_patent_pdf",
|
||||||
|
side_effect=FileNotFoundError("PDF file vanished"),
|
||||||
|
)
|
||||||
|
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
try:
|
||||||
|
with pytest.raises(FileNotFoundError, match="PDF file vanished"):
|
||||||
|
analyzer.analyze_single_patent(patent_id, "VanishCo")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
@@ -182,3 +182,47 @@ class TestJobEndpoints:
|
|||||||
"""Test listing jobs with status filter."""
|
"""Test listing jobs with status filter."""
|
||||||
response = client.get("/jobs?status=completed")
|
response = client.get("/jobs?status=completed")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelValidation:
|
||||||
|
"""Test that unsupported model identifiers are rejected."""
|
||||||
|
|
||||||
|
def test_analyze_rejects_unsupported_model(self, client, mock_analyzer):
|
||||||
|
"""GET /analyze/{company} with unsupported model returns 400."""
|
||||||
|
response = client.get("/analyze/nvidia?model=fake/nonexistent-model")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Unsupported model" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_analyze_accepts_supported_model(self, client, mock_analyzer):
|
||||||
|
"""GET /analyze/{company} with a supported model succeeds."""
|
||||||
|
mock_result = CompanyAnalysisResult(
|
||||||
|
company_name="nvidia",
|
||||||
|
analysis="test",
|
||||||
|
patent_count=1,
|
||||||
|
success=True,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
model="anthropic/claude-3.5-sonnet",
|
||||||
|
)
|
||||||
|
mock_analyzer._analyze_company_safe.return_value = mock_result
|
||||||
|
|
||||||
|
response = client.get("/analyze/nvidia?model=anthropic/claude-3.5-sonnet")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_batch_rejects_unsupported_model(self, client, mock_analyzer):
|
||||||
|
"""POST /analyze/batch with unsupported model returns 400."""
|
||||||
|
response = client.post(
|
||||||
|
"/analyze/batch",
|
||||||
|
json={"companies": ["nvidia"], "model": "fake/nonexistent-model"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Unsupported model" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_list_models_returns_supported(self, client):
|
||||||
|
"""GET /models returns the allow-list."""
|
||||||
|
response = client.get("/models")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "models" in data
|
||||||
|
assert "default" in data
|
||||||
|
assert len(data["models"]) > 0
|
||||||
|
assert all("id" in m and "name" in m and "provider" in m for m in data["models"])
|
||||||
|
|||||||
+209
-9
@@ -1,13 +1,29 @@
|
|||||||
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access."""
|
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access.
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
Covers all five scenarios required by issue #1624:
|
||||||
|
1. Registration (POST /auth/register)
|
||||||
|
2. Login (POST /auth/login)
|
||||||
|
3. Protected route access (GET /auth/me) -- valid, missing, expired, wrong-type tokens
|
||||||
|
4. Token refresh (POST /auth/refresh)
|
||||||
|
5. Admin-only endpoints (GET /admin/users, PATCH role, DELETE user)
|
||||||
|
|
||||||
|
All tests use mocked DB fixtures and require no live database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from SPARC.api import app
|
from SPARC.api import app
|
||||||
from SPARC.auth import create_access_token, create_refresh_token
|
from SPARC.auth import (
|
||||||
|
JWT_ALGORITHM,
|
||||||
|
JWT_SECRET,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -171,12 +187,6 @@ class TestGetMe:
|
|||||||
|
|
||||||
def test_expired_token_returns_401(self, client, mock_db):
|
def test_expired_token_returns_401(self, client, mock_db):
|
||||||
"""An expired token should return 401."""
|
"""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 = {
|
payload = {
|
||||||
"sub": "1",
|
"sub": "1",
|
||||||
"email": "user@test.com",
|
"email": "user@test.com",
|
||||||
@@ -300,3 +310,193 @@ class TestAdminUsers:
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "own role" in response.json()["detail"].lower()
|
assert "own role" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_role_change_nonexistent_user_returns_404(self, client, mock_db):
|
||||||
|
"""Changing role for a user that does not exist should return 404."""
|
||||||
|
admin = _make_admin_user()
|
||||||
|
mock_db.get_user_by_id.return_value = admin
|
||||||
|
mock_db.update_user_role.return_value = None
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
"/admin/users/999/role",
|
||||||
|
json={"role": "admin"},
|
||||||
|
headers=_auth_header(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not found" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_regular_user_cannot_change_role(self, client, mock_db):
|
||||||
|
"""Non-admin user should receive 403 when trying to change roles."""
|
||||||
|
user = _make_regular_user()
|
||||||
|
mock_db.get_user_by_id.return_value = user
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
"/admin/users/1/role",
|
||||||
|
json={"role": "admin"},
|
||||||
|
headers=_auth_header(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminDeleteUser:
|
||||||
|
"""DELETE /admin/users/{user_id}"""
|
||||||
|
|
||||||
|
def test_admin_can_delete_user(self, client, mock_db):
|
||||||
|
"""Admin should be able to delete another user."""
|
||||||
|
admin = _make_admin_user()
|
||||||
|
mock_db.get_user_by_id.return_value = admin
|
||||||
|
mock_db.delete_user.return_value = True
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
"/admin/users/2",
|
||||||
|
headers=_auth_header(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "deleted" in response.json()["message"].lower()
|
||||||
|
mock_db.delete_user.assert_called_once_with(2)
|
||||||
|
|
||||||
|
def test_admin_cannot_delete_self(self, client, mock_db):
|
||||||
|
"""Admin should not be able to delete themselves."""
|
||||||
|
admin = _make_admin_user()
|
||||||
|
mock_db.get_user_by_id.return_value = admin
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
"/admin/users/1",
|
||||||
|
headers=_auth_header(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "yourself" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_delete_nonexistent_user_returns_404(self, client, mock_db):
|
||||||
|
"""Deleting a user that does not exist should return 404."""
|
||||||
|
admin = _make_admin_user()
|
||||||
|
mock_db.get_user_by_id.return_value = admin
|
||||||
|
mock_db.delete_user.return_value = False
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
"/admin/users/999",
|
||||||
|
headers=_auth_header(admin),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not found" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_regular_user_cannot_delete_user(self, client, mock_db):
|
||||||
|
"""Non-admin user should receive 403 when trying to delete users."""
|
||||||
|
user = _make_regular_user()
|
||||||
|
mock_db.get_user_by_id.return_value = user
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
"/admin/users/1",
|
||||||
|
headers=_auth_header(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_no_token_cannot_delete_user(self, client):
|
||||||
|
"""Missing token should be rejected for delete endpoint."""
|
||||||
|
response = client.delete("/admin/users/1")
|
||||||
|
assert response.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Additional edge-case tests for auth robustness."""
|
||||||
|
|
||||||
|
def test_register_invalid_email_returns_422(self, client, mock_db):
|
||||||
|
"""Registration with an invalid email format should return 422."""
|
||||||
|
response = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "not-an-email", "password": "securepass123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_register_short_password_returns_422(self, client, mock_db):
|
||||||
|
"""Registration with a password shorter than 8 chars should return 422."""
|
||||||
|
response = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
json={"email": "user@test.com", "password": "short"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_register_missing_fields_returns_422(self, client, mock_db):
|
||||||
|
"""Registration with missing fields should return 422."""
|
||||||
|
response = client.post("/auth/register", json={})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_login_missing_fields_returns_422(self, client, mock_db):
|
||||||
|
"""Login with missing fields should return 422."""
|
||||||
|
response = client.post("/auth/login", json={"email": "user@test.com"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_malformed_token_returns_401(self, client, mock_db):
|
||||||
|
"""A completely malformed token string should return 401."""
|
||||||
|
response = client.get(
|
||||||
|
"/auth/me",
|
||||||
|
headers={"Authorization": "Bearer not.a.valid.jwt.token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_token_with_wrong_secret_returns_401(self, client, mock_db):
|
||||||
|
"""A token signed with a different secret should return 401."""
|
||||||
|
payload = {
|
||||||
|
"sub": "1",
|
||||||
|
"email": "user@test.com",
|
||||||
|
"role": "user",
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
wrong_secret_token = pyjwt.encode(payload, "wrong-secret", algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {wrong_secret_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_token_for_deleted_user_returns_401(self, client, mock_db):
|
||||||
|
"""A valid token for a user no longer in the DB should return 401."""
|
||||||
|
user = _make_regular_user()
|
||||||
|
mock_db.get_user_by_id.return_value = None # user was deleted
|
||||||
|
|
||||||
|
response = client.get("/auth/me", headers=_auth_header(user))
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_refresh_for_deleted_user_returns_401(self, client, mock_db):
|
||||||
|
"""Refreshing a token for a deleted user should return 401."""
|
||||||
|
user = _make_regular_user()
|
||||||
|
mock_db.get_user_by_id.return_value = None
|
||||||
|
refresh = create_refresh_token(user["id"], user["email"], user["role"])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/auth/refresh", json={"refresh_token": refresh}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_login_returns_decodable_tokens(self, client, mock_db):
|
||||||
|
"""Tokens returned by login should be decodable and contain expected claims."""
|
||||||
|
user = _make_regular_user()
|
||||||
|
mock_db.authenticate_user.return_value = user
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"email": "user@test.com", "password": "correctpassword"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
access_payload = pyjwt.decode(
|
||||||
|
data["access_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
|
||||||
|
)
|
||||||
|
assert access_payload["sub"] == str(user["id"])
|
||||||
|
assert access_payload["email"] == user["email"]
|
||||||
|
assert access_payload["type"] == "access"
|
||||||
|
|
||||||
|
refresh_payload = pyjwt.decode(
|
||||||
|
data["refresh_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
|
||||||
|
)
|
||||||
|
assert refresh_payload["type"] == "refresh"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Tests for rate limiting on auth endpoints."""
|
"""Tests for rate limiting on auth endpoints."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import Mock, patch, MagicMock
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from SPARC.api import app
|
from SPARC.api import app
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class TestJWTSecretStartupCheck:
|
|||||||
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
||||||
# Reload config to pick up the new APP_ENV
|
# Reload config to pick up the new APP_ENV
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class TestJWTSecretStartupCheck:
|
|||||||
"""Starting with default secret and APP_ENV=development must not raise."""
|
"""Starting with default secret and APP_ENV=development must not raise."""
|
||||||
with patch.dict(os.environ, {"APP_ENV": "development"}):
|
with patch.dict(os.environ, {"APP_ENV": "development"}):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class TestJWTSecretStartupCheck:
|
|||||||
"""Starting with a custom secret in production must not raise."""
|
"""Starting with a custom secret in production must not raise."""
|
||||||
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ class TestJWTSecretStartupCheck:
|
|||||||
env.pop("APP_ENV", None)
|
env.pop("APP_ENV", None)
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ class TestCORSConfig:
|
|||||||
"""When CORS_ORIGINS is unset, defaults to localhost origins."""
|
"""When CORS_ORIGINS is unset, defaults to localhost origins."""
|
||||||
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
|
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
assert SPARC.config.cors_origins == [
|
assert SPARC.config.cors_origins == [
|
||||||
@@ -95,6 +100,7 @@ class TestCORSConfig:
|
|||||||
"""Setting CORS_ORIGINS configures allowed origins."""
|
"""Setting CORS_ORIGINS configures allowed origins."""
|
||||||
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
|
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
assert SPARC.config.cors_origins == [
|
assert SPARC.config.cors_origins == [
|
||||||
@@ -109,6 +115,7 @@ class TestCORSConfig:
|
|||||||
"""A single origin without comma works correctly."""
|
"""A single origin without comma works correctly."""
|
||||||
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
|
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import SPARC.config
|
import SPARC.config
|
||||||
importlib.reload(SPARC.config)
|
importlib.reload(SPARC.config)
|
||||||
assert SPARC.config.cors_origins == ["https://sparc.example.com"]
|
assert SPARC.config.cors_origins == ["https://sparc.example.com"]
|
||||||
|
|||||||
Reference in New Issue
Block a user