Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company cd81218154 Add LLM-based patent classification tagging by technology domain
- Add classify_patent_tags() to LLMAnalyzer with canonical tag list
  (ai, semiconductors, materials, biotech, networking, other)
- Add patent_tags TEXT[] column to patents table with GIN index
- Run classification automatically in the analysis pipeline after
  patent processing; persist tags via update_patent_tags()
- Include tags in CompanyAnalysisResult and API response models
- Add ?tags= filter to GET /analyze/batch endpoint
- Add GET /analytics/tags endpoint for tag distribution data
- Add tag filter controls and distribution chart to Analytics page
- Add 12 unit tests covering classification, DB storage, and caching

Closes leeworks-agents/SPARC#1672

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 15:27:46 +00:00
17 changed files with 603 additions and 1780 deletions
+28 -4
View File
@@ -40,8 +40,9 @@ class CompanyAnalyzer:
1. Retrieve patents from SERP API
2. Download and parse each patent PDF
3. Minimize patent content (remove bloat)
4. Analyze portfolio with LLM
5. Return performance estimation
4. Classify patent technology domain tags
5. Analyze portfolio with LLM
6. Return performance estimation
Args:
company_name: Name of the company to analyze
@@ -97,6 +98,17 @@ class CompanyAnalyzer:
if not processed_patents:
return f"Failed to process any patents for {company_name}"
# Classify each patent's technology domain tags
logger.info("Classifying patent technology domains...")
for patent_data in processed_patents:
if "tags" not in patent_data or not patent_data["tags"]:
tags = self.llm_analyzer.classify_patent_tags(
patent_content=patent_data["content"], model=model
)
patent_data["tags"] = tags
# Persist tags to the database
self.db.update_patent_tags(patent_data["patent_id"], tags)
logger.info("Analyzing portfolio with LLM...")
# Analyze the full portfolio with LLM
@@ -181,7 +193,10 @@ class CompanyAnalyzer:
if db:
cached = db.get_cached_patent(patent.patent_id)
if cached and cached.get("minimized_content"):
return {"patent_id": patent.patent_id, "content": cached["minimized_content"]}
result = {"patent_id": patent.patent_id, "content": cached["minimized_content"]}
if cached.get("patent_tags"):
result["tags"] = cached["patent_tags"]
return result
# Full processing: download, parse, minimize
patent = SERP.save_patents(patent)
@@ -217,11 +232,19 @@ class CompanyAnalyzer:
# Delegate to analyze_company which handles SERP/patent caching
analysis = self.analyze_company(company_name, model=model)
# Determine patent count from cached SERP query
# Determine patent count and aggregate tags from cached SERP query
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
cached_ids = self.db.get_cached_serp_query(query_hash)
patent_count = len(cached_ids) if cached_ids else 0
# Collect unique tags across all patents for this company
all_tags: set[str] = set()
if cached_ids:
for pid in cached_ids:
cached_patent = self.db.get_cached_patent(pid)
if cached_patent and cached_patent.get("patent_tags"):
all_tags.update(cached_patent["patent_tags"])
# Check if analysis indicates failure
if analysis.startswith("No patents found") or analysis.startswith(
"Failed to process"
@@ -239,6 +262,7 @@ class CompanyAnalyzer:
analysis=analysis,
patent_count=patent_count,
success=True,
tags=sorted(all_tags),
)
except Exception as e:
+56 -301
View File
@@ -5,9 +5,8 @@ Provides REST API endpoints for analyzing company patent portfolios.
from __future__ import annotations
from collections import deque
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from datetime import datetime
from typing import TYPE_CHECKING, Annotated, List
if TYPE_CHECKING:
@@ -30,11 +29,9 @@ from SPARC.auth import (
close_db_client,
create_tokens,
decode_token,
generate_api_key,
get_current_admin,
get_current_user,
get_db_client,
hash_api_key,
init_db_client,
)
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
@@ -60,6 +57,7 @@ class CompanyAnalysisResponse(BaseModel):
success: bool
error: str | None = None
model: str | None = None
tags: list[str] = []
timestamp: datetime
@@ -142,31 +140,6 @@ class HealthResponse(BaseModel):
timestamp: datetime
# Historical diff models
class AnalysisDiffResponse(BaseModel):
"""Response model for diffing two analysis runs of the same company."""
company_name: str
from_id: int
to_id: int
from_timestamp: datetime
to_timestamp: datetime
patent_count_delta: int
added_patents: list[str]
removed_patents: list[str]
changed_fields: dict[str, dict]
summary: str
class CompanyAnalysisHistoryItem(BaseModel):
"""A summary item from a company's analysis history."""
id: int
analysis_type: str | None = None
model: str | None = None
timestamp: datetime
# Auth request/response models
class RegisterRequest(BaseModel):
"""User registration request."""
@@ -216,6 +189,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse:
success=result.success,
error=result.error,
model=result.model,
tags=result.tags,
timestamp=result.timestamp,
)
@@ -276,9 +250,6 @@ app.state.limiter = limiter
# In-memory rate limit statistics
_rate_limit_stats: dict[str, dict] = {}
# Time-series log of rejected requests (capped to last 24 h worth of entries).
_rejected_log: deque[dict] = deque(maxlen=100_000)
def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None:
"""Record a request against a rate-limited endpoint."""
@@ -293,11 +264,6 @@ def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) ->
_rate_limit_stats[key]["total_requests"] += 1
if rejected:
_rate_limit_stats[key]["rejected_requests"] += 1
_rejected_log.append({
"endpoint": endpoint,
"ip": ip,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
ip_stats = _rate_limit_stats[key].setdefault("by_ip", {})
if ip not in ip_stats:
ip_stats[ip] = {"total": 0, "rejected": 0}
@@ -414,92 +380,6 @@ async def get_me(current_user: UserResponse = Depends(get_current_user)):
return current_user
# ============== API Key Endpoints ==============
class CreateApiKeyRequest(BaseModel):
"""Request to create a new API key."""
label: str | None = Field(default=None, max_length=100, description="Optional label for the key")
class ApiKeyResponse(BaseModel):
"""Response after creating an API key (includes plaintext key)."""
id: int
key: str # plaintext key, shown only at creation time
label: str | None = None
created_at: datetime
class ApiKeyInfo(BaseModel):
"""API key metadata (no secret)."""
id: int
label: str | None = None
created_at: datetime
@app.post("/auth/apikeys", response_model=ApiKeyResponse, tags=["Auth"])
async def create_api_key_endpoint(
body: CreateApiKeyRequest | None = None,
current_user: UserResponse = Depends(get_current_user),
):
"""Generate a new API key for the authenticated user.
The plaintext key is returned **only once** in the response.
Store it securely; it cannot be retrieved again.
"""
plaintext_key = generate_api_key()
key_hash = hash_api_key(plaintext_key)
db = get_db_client()
label = body.label if body else None
row = db.create_api_key(
user_id=current_user.id,
key_hash=key_hash,
label=label,
)
return ApiKeyResponse(
id=row["id"],
key=plaintext_key,
label=row["label"],
created_at=row["created_at"],
)
@app.get("/auth/apikeys", response_model=list[ApiKeyInfo], tags=["Auth"])
async def list_api_keys_endpoint(
current_user: UserResponse = Depends(get_current_user),
):
"""List active API key IDs and labels for the authenticated user.
Does **not** return the secret keys.
"""
db = get_db_client()
keys = db.list_api_keys(current_user.id)
return [ApiKeyInfo(**k) for k in keys]
@app.delete("/auth/apikeys/{key_id}", tags=["Auth"])
async def revoke_api_key_endpoint(
key_id: int,
current_user: UserResponse = Depends(get_current_user),
):
"""Revoke (delete) an API key by its ID.
The key must belong to the authenticated user.
"""
db = get_db_client()
deleted = db.delete_api_key(key_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="API key not found")
return {"message": "API key revoked"}
# ============== Admin Endpoints ==============
@@ -629,12 +509,10 @@ async def get_rate_limit_stats(
"""Get rate limit status and usage statistics (admin only).
Returns current rate limit configuration and request statistics
for all rate-limited endpoints, including per-IP breakdown and
a time-series of throttled (rejected) requests in the last 24 hours.
for all rate-limited endpoints.
Returns:
Rate limit stats per endpoint, per-IP breakdown, and throttled
request history bucketed by hour.
List of rate limit stats per endpoint with total/rejected counts
"""
rate_limits_config = {
"/auth/register": {"limit": "5/minute"},
@@ -644,45 +522,14 @@ async def get_rate_limit_stats(
results = []
for endpoint, conf in rate_limits_config.items():
stats = _rate_limit_stats.get(endpoint, {})
by_ip_raw = stats.get("by_ip", {})
by_ip = [
{"ip": ip, "total": counts["total"], "rejected": counts["rejected"]}
for ip, counts in by_ip_raw.items()
]
results.append({
"endpoint": endpoint,
"limit": conf["limit"],
"total_requests": stats.get("total_requests", 0),
"rejected_requests": stats.get("rejected_requests", 0),
"by_ip": by_ip,
})
# Build hourly buckets of throttled requests for the last 24 hours
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=24)
hourly_buckets: dict[str, int] = {}
throttled_24h = 0
for entry in _rejected_log:
ts_str = entry["timestamp"]
try:
ts = datetime.fromisoformat(ts_str)
except (ValueError, TypeError):
continue
if ts >= cutoff:
throttled_24h += 1
bucket = ts.strftime("%Y-%m-%dT%H:00:00Z")
hourly_buckets[bucket] = hourly_buckets.get(bucket, 0) + 1
throttled_over_time = [
{"timestamp": k, "count": v}
for k, v in sorted(hourly_buckets.items())
]
return {
"rate_limits": results,
"throttled_24h": throttled_24h,
"throttled_over_time": throttled_over_time,
}
return {"rate_limits": results}
@app.get("/admin/alerts", tags=["Admin"])
@@ -715,6 +562,38 @@ async def get_analytics(
)
@app.get("/analytics/tags", tags=["Analytics"])
async def get_tag_distribution(
_: UserResponse = Depends(get_current_user),
):
"""Get the distribution of technology domain tags across all patents.
Returns:
List of tag counts and the canonical tag list
"""
from SPARC.llm import LLMAnalyzer
db = get_db_client()
with db.get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT tag, COUNT(*) as count
FROM patents, UNNEST(patent_tags) AS tag
WHERE patent_tags IS NOT NULL
GROUP BY tag
ORDER BY count DESC
"""
)
rows = cur.fetchall()
by_tag = [{"tag": row[0], "count": row[1]} for row in rows]
return {
"by_tag": by_tag,
"canonical_tags": LLMAnalyzer.CANONICAL_TAGS,
}
# ============== Model Selection Endpoints ==============
# Supported models via OpenRouter
@@ -1082,147 +961,6 @@ async def analyze_company(
return _convert_result(result)
def _extract_patent_ids(response_text: str) -> set[str]:
"""Extract patent IDs from an analysis response text.
Looks for patterns like US-12345678-B2, US12345678B2, etc.
"""
import re
pattern = r"US[-\s]?\d{7,8}[-\s]?[A-Z]\d?"
return set(re.findall(pattern, response_text or ""))
def _compute_analysis_diff(from_rec: dict, to_rec: dict) -> AnalysisDiffResponse:
"""Compute a structured diff between two analysis records."""
from_patents = _extract_patent_ids(from_rec.get("response", "") or "")
to_patents = _extract_patent_ids(to_rec.get("response", "") or "")
added = sorted(to_patents - from_patents)
removed = sorted(from_patents - to_patents)
patent_count_delta = len(to_patents) - len(from_patents)
changed_fields: dict[str, dict] = {}
if from_rec.get("model") != to_rec.get("model"):
changed_fields["model"] = {
"from": from_rec.get("model"),
"to": to_rec.get("model"),
}
if from_rec.get("analysis_type") != to_rec.get("analysis_type"):
changed_fields["analysis_type"] = {
"from": from_rec.get("analysis_type"),
"to": to_rec.get("analysis_type"),
}
# Build a human-readable summary
parts: list[str] = []
if added:
parts.append(f"{len(added)} new patent(s) appeared")
if removed:
parts.append(f"{len(removed)} patent(s) no longer referenced")
if patent_count_delta > 0:
parts.append(f"patent mention count increased by {patent_count_delta}")
elif patent_count_delta < 0:
parts.append(f"patent mention count decreased by {abs(patent_count_delta)}")
if changed_fields:
parts.append(f"field(s) changed: {', '.join(changed_fields.keys())}")
summary = "; ".join(parts) if parts else "No significant differences detected."
return AnalysisDiffResponse(
company_name=to_rec["company_name"],
from_id=from_rec["id"],
to_id=to_rec["id"],
from_timestamp=from_rec["timestamp"],
to_timestamp=to_rec["timestamp"],
patent_count_delta=patent_count_delta,
added_patents=added,
removed_patents=removed,
changed_fields=changed_fields,
summary=summary,
)
@app.get(
"/analyze/{company_name}/history",
response_model=list[CompanyAnalysisHistoryItem],
tags=["Analysis"],
)
async def list_company_analysis_history(
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
limit: int = Query(default=20, ge=1, le=100),
_: UserResponse = Depends(get_current_user),
):
"""List previous analysis runs for a company.
Returns a list of analysis records ordered by timestamp descending,
useful for selecting which runs to compare via the diff endpoint.
Args:
company_name: Company name to look up
limit: Maximum number of results
Returns:
List of analysis history items
"""
db = _get_job_db()
rows = db.list_company_analyses(company_name, limit=limit)
return [
CompanyAnalysisHistoryItem(
id=r["id"],
analysis_type=r.get("analysis_type"),
model=r.get("model"),
timestamp=r["timestamp"],
)
for r in rows
]
@app.get(
"/analyze/{company_name}/diff",
response_model=AnalysisDiffResponse,
tags=["Analysis"],
)
async def diff_company_analyses(
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
from_id: int = Query(..., alias="from", description="Analysis ID of the older run"),
to_id: int = Query(..., alias="to", description="Analysis ID of the newer run"),
_: UserResponse = Depends(get_current_user),
):
"""Compare two analysis runs for the same company.
Returns a structured diff showing added/removed patents, score delta,
and a summary narrative.
Args:
company_name: Company name (must match both analysis records)
from_id: ID of the older analysis run
to_id: ID of the newer analysis run
Returns:
AnalysisDiffResponse with added/removed/changed fields
Raises:
404: If either analysis ID does not exist or belongs to a different company
"""
db = _get_job_db()
from_rec = db.get_analysis_by_id(from_id)
if not from_rec or (from_rec["company_name"] or "").lower() != company_name.lower():
raise HTTPException(
status_code=404,
detail=f"Analysis ID {from_id} not found for company '{company_name}'",
)
to_rec = db.get_analysis_by_id(to_id)
if not to_rec or (to_rec["company_name"] or "").lower() != company_name.lower():
raise HTTPException(
status_code=404,
detail=f"Analysis ID {to_id} not found for company '{company_name}'",
)
return _compute_analysis_diff(from_rec, to_rec)
@app.get(
"/analyze/patent/{patent_id}",
tags=["Analysis"],
@@ -1265,6 +1003,10 @@ async def list_analysis_results(
str | None,
Query(description="Filter results by company name"),
] = None,
tags: Annotated[
str | None,
Query(description="Comma-separated technology domain tags to filter by (e.g. 'ai,semiconductors')"),
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[
str | None,
@@ -1282,14 +1024,27 @@ async def list_analysis_results(
Args:
company_name: Optional filter by company name
tags: Optional comma-separated tag filter (e.g. 'ai,semiconductors')
limit: Maximum number of results to return (default 50, max 200)
cursor: Opaque pagination cursor from a previous response
Returns:
Paginated list of analysis results
"""
# Parse and validate tags
tag_list = None
if tags:
from SPARC.llm import LLMAnalyzer
tag_list = [t.strip().lower() for t in tags.split(",") if t.strip()]
invalid = [t for t in tag_list if t not in LLMAnalyzer.CANONICAL_TAGS]
if invalid:
raise HTTPException(
status_code=400,
detail=f"Invalid tags: {', '.join(invalid)}. Valid tags: {', '.join(LLMAnalyzer.CANONICAL_TAGS)}",
)
db = _get_job_db()
rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor)
rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor, tags=tag_list)
has_next = len(rows) > limit
if has_next:
+7 -96
View File
@@ -1,13 +1,11 @@
"""JWT and API key authentication utilities for SPARC API."""
"""JWT authentication utilities for SPARC API."""
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
@@ -34,7 +32,7 @@ def check_jwt_secret() -> None:
"Set a secure JWT_SECRET environment variable before running in non-development environments."
)
security = HTTPBearer(auto_error=False)
security = HTTPBearer()
class TokenPayload(BaseModel):
@@ -180,107 +178,20 @@ def get_db_client() -> DatabaseClient:
return _db_client
def generate_api_key() -> str:
"""Generate a random 32-byte hex API key.
Returns:
64-character hex string
"""
return secrets.token_hex(32)
def hash_api_key(key: str) -> str:
"""Hash an API key using bcrypt.
Args:
key: Plaintext API key
Returns:
bcrypt hash string
"""
return bcrypt.hashpw(key.encode(), bcrypt.gensalt()).decode()
def verify_api_key(key: str, key_hash: str) -> bool:
"""Verify a plaintext API key against its bcrypt hash.
Args:
key: Plaintext API key
key_hash: Stored bcrypt hash
Returns:
True if key matches
"""
return bcrypt.checkpw(key.encode(), key_hash.encode())
def _authenticate_via_api_key(api_key: str) -> Optional[UserResponse]:
"""Look up a user by raw API key.
Iterates over all stored key hashes (small table) and returns the
corresponding user when a match is found.
Args:
api_key: Plaintext API key from X-API-Key header
Returns:
UserResponse if valid key, None otherwise
"""
db = get_db_client()
key_rows = db.get_all_api_key_hashes()
for row in key_rows:
if verify_api_key(api_key, row["key_hash"]):
user = db.get_user_by_id(row["user_id"])
if user:
return UserResponse(
id=user["id"],
email=user["email"],
role=user["role"],
created_at=user["created_at"],
)
return None
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> UserResponse:
"""Get the current authenticated user from JWT token or API key.
Supports two authentication methods:
1. Bearer JWT token via Authorization header
2. API key via X-API-Key header
"""Get the current authenticated user from JWT token.
Args:
request: The incoming request (used for X-API-Key header)
credentials: Optional Bearer token from request
credentials: Bearer token from request
Returns:
UserResponse with user details
Raises:
HTTPException: If no valid credentials are provided
HTTPException: If token is invalid or expired
"""
# Try X-API-Key header first
api_key = request.headers.get("X-API-Key")
if api_key:
user = _authenticate_via_api_key(api_key)
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
# Fall back to JWT Bearer token
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = decode_token(token)
+82 -165
View File
@@ -146,15 +146,35 @@ class DatabaseClient:
pdf_link TEXT,
raw_sections JSONB,
minimized_content TEXT,
patent_tags TEXT[],
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Add patent_tags column if it doesn't exist (for existing tables)
cursor.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'patents' AND column_name = 'patent_tags'
) THEN
ALTER TABLE patents ADD COLUMN patent_tags TEXT[];
END IF;
END $$;
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_patents_company
ON patents(company_name)
""")
# GIN index for efficient tag array queries
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_patents_tags
ON patents USING GIN(patent_tags)
""")
# Create SERP query cache table
cursor.execute("""
CREATE TABLE IF NOT EXISTS serp_queries (
@@ -221,27 +241,6 @@ class DatabaseClient:
ON alerts(company_name)
""")
# Create API keys table for programmatic access
cursor.execute("""
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash VARCHAR(255) NOT NULL,
label VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id
ON api_keys(user_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash
ON api_keys(key_hash)
""")
self.conn.commit()
@staticmethod
@@ -397,6 +396,7 @@ class DatabaseClient:
company_name: Optional[str] = None,
limit: int = 50,
cursor: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> List[Dict]:
"""List analysis results with cursor-based pagination.
@@ -404,29 +404,40 @@ class DatabaseClient:
company_name: Optional filter by company name.
limit: Maximum number of records to return.
cursor: Opaque cursor (``timestamp|id``) from a previous response.
tags: Optional list of technology domain tags to filter by.
Returns:
List of analysis dicts ordered by timestamp descending.
"""
conditions: list[str] = ["is_cached = FALSE"]
conditions: list[str] = ["m.is_cached = FALSE"]
params: list = []
join_clause = ""
if company_name:
conditions.append("LOWER(company_name) = LOWER(%s)")
conditions.append("LOWER(m.company_name) = LOWER(%s)")
params.append(company_name)
if tags:
# Join with patents table to filter by tags
join_clause = (
" INNER JOIN patents p ON LOWER(p.company_name) = LOWER(m.company_name)"
)
conditions.append("p.patent_tags && %s")
params.append(tags)
if cursor:
try:
ts_str, cursor_id = cursor.rsplit("|", 1)
conditions.append("(timestamp, id) < (%s, %s)")
conditions.append("(m.timestamp, m.id) < (%s, %s)")
params.extend([ts_str, int(cursor_id)])
except (ValueError, TypeError):
pass # Ignore malformed cursors; return from start
query = "SELECT id, company_name, analysis_type, model, response, timestamp FROM llm_messages"
query = "SELECT DISTINCT m.id, m.company_name, m.analysis_type, m.model, m.response, m.timestamp FROM llm_messages m"
query += join_clause
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY timestamp DESC, id DESC LIMIT %s"
query += " ORDER BY m.timestamp DESC, m.id DESC LIMIT %s"
params.append(limit)
with self.get_conn() as conn:
@@ -514,22 +525,63 @@ class DatabaseClient:
pdf_link: str,
raw_sections: Dict,
minimized_content: str,
patent_tags: Optional[List[str]] = None,
) -> None:
"""Store a processed patent in the cache."""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO patents (patent_id, company_name, pdf_link, raw_sections, minimized_content)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO patents (patent_id, company_name, pdf_link, raw_sections, minimized_content, patent_tags)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (patent_id) DO UPDATE SET
raw_sections = EXCLUDED.raw_sections,
minimized_content = EXCLUDED.minimized_content
minimized_content = EXCLUDED.minimized_content,
patent_tags = EXCLUDED.patent_tags
""",
(patent_id, company_name, pdf_link, json.dumps(raw_sections), minimized_content),
(patent_id, company_name, pdf_link, json.dumps(raw_sections), minimized_content, patent_tags),
)
conn.commit()
def update_patent_tags(self, patent_id: str, tags: List[str]) -> None:
"""Update the technology domain tags for a patent."""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"UPDATE patents SET patent_tags = %s WHERE patent_id = %s",
(tags, patent_id),
)
conn.commit()
def get_patents_by_tags(
self,
tags: List[str],
limit: int = 100,
offset: int = 0,
) -> List[Dict]:
"""Retrieve patents that match any of the given tags.
Args:
tags: List of tag strings to filter by (OR logic)
limit: Max results
offset: Pagination offset
Returns:
List of patent dicts
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT * FROM patents
WHERE patent_tags && %s
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(tags, limit, offset),
)
return [dict(row) for row in cursor.fetchall()]
def get_cached_serp_query(self, query_hash: str) -> Optional[List[str]]:
"""Look up cached SERP query results.
@@ -998,138 +1050,3 @@ class DatabaseClient:
(limit,),
)
return [dict(row) for row in cursor.fetchall()]
# Historical Analysis Diff Methods
def get_analysis_by_id(self, analysis_id: int) -> Optional[Dict]:
"""Get a single analysis record by its ID.
Args:
analysis_id: The primary key of the llm_messages row.
Returns:
Dict with analysis fields, or None if not found.
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT id, company_name, analysis_type, model, response, timestamp
FROM llm_messages
WHERE id = %s AND is_cached = FALSE
""",
(analysis_id,),
)
row = cursor.fetchone()
return dict(row) if row else None
def list_company_analyses(
self, company_name: str, limit: int = 20
) -> List[Dict]:
"""List past analysis runs for a given company.
Returns records ordered by timestamp descending so callers can
identify which previous runs are available for diffing.
Args:
company_name: Company name (case-insensitive match).
limit: Maximum number of records.
Returns:
List of analysis dicts.
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT id, company_name, analysis_type, model, response, timestamp
FROM llm_messages
WHERE LOWER(company_name) = LOWER(%s) AND is_cached = FALSE
ORDER BY timestamp DESC
LIMIT %s
""",
(company_name, limit),
)
return [dict(row) for row in cursor.fetchall()]
# API Key Methods
def create_api_key(
self,
user_id: int,
key_hash: str,
label: Optional[str] = None,
) -> Dict:
"""Store a new API key hash for a user.
Args:
user_id: The owning user's ID
key_hash: bcrypt hash of the plaintext key
label: Optional human-readable label
Returns:
Dict with id, user_id, label, created_at
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO api_keys (user_id, key_hash, label)
VALUES (%s, %s, %s)
RETURNING id, user_id, label, created_at
""",
(user_id, key_hash, label),
)
row = cursor.fetchone()
conn.commit()
return dict(row)
def list_api_keys(self, user_id: int) -> List[Dict]:
"""List active API key metadata for a user (no secrets).
Args:
user_id: The user's ID
Returns:
List of dicts with id, label, created_at
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"SELECT id, label, created_at FROM api_keys WHERE user_id = %s ORDER BY created_at DESC",
(user_id,),
)
return [dict(row) for row in cursor.fetchall()]
def delete_api_key(self, key_id: int, user_id: int) -> bool:
"""Revoke an API key by ID (must belong to user).
Args:
key_id: The API key row ID
user_id: The owning user's ID
Returns:
True if a key was deleted
"""
with self.get_conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"DELETE FROM api_keys WHERE id = %s AND user_id = %s",
(key_id, user_id),
)
deleted = cursor.rowcount > 0
conn.commit()
return deleted
def get_all_api_key_hashes(self) -> List[Dict]:
"""Return all API key hashes with their associated user IDs.
Used by the auth layer to validate an incoming API key.
Returns:
List of dicts with key_hash, user_id
"""
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT key_hash, user_id FROM api_keys")
return [dict(row) for row in cursor.fetchall()]
+60 -2
View File
@@ -1,7 +1,8 @@
"""LLM integration for patent analysis using OpenRouter."""
import json
import logging
from typing import Dict
from typing import Dict, List
from openai import OpenAI
@@ -245,4 +246,61 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
metadata={**metadata, "pending": True}
)
return placeholder
# Canonical technology domain tags
CANONICAL_TAGS = ["ai", "semiconductors", "materials", "biotech", "networking", "other"]
def classify_patent_tags(self, patent_content: str, model: str | None = None) -> List[str]:
"""Classify a patent into one or more technology domain tags.
Sends the patent abstract/claims to the LLM with a classification prompt
and returns a list of canonical tags.
Args:
patent_content: Minimized patent text (abstract, claims, summary)
model: Optional model override
Returns:
List of canonical tag strings from CANONICAL_TAGS
"""
prompt = f"""You are a patent classification system. Analyze the following patent content and assign one or more technology domain tags from this exact list:
Tags: ai, semiconductors, materials, biotech, networking, other
Rules:
- Return ONLY a JSON array of tag strings, e.g. ["ai", "semiconductors"]
- Use ONLY tags from the list above
- Assign "other" only if no other tag fits
- A patent can have multiple tags if it spans domains
Patent Content:
{patent_content}
Return ONLY the JSON array, nothing else."""
effective_model = model or self.model
if self.test_mode:
logger.debug("TEST MODE - Classification prompt:\n%s", prompt)
return ["other"]
if self.client:
try:
response = self.client.chat.completions.create(
model=effective_model,
max_tokens=128,
messages=[{"role": "user", "content": prompt}],
)
raw = response.choices[0].message.content.strip()
tags = json.loads(raw)
# Validate and filter to canonical tags only
valid_tags = [t for t in tags if t in self.CANONICAL_TAGS]
return valid_tags if valid_tags else ["other"]
except (json.JSONDecodeError, AttributeError, TypeError) as e:
logger.warning("Failed to parse classification response: %s", e)
return ["other"]
except Exception as e:
logger.warning("Classification LLM call failed: %s", e)
return ["other"]
return ["other"]
+1
View File
@@ -25,6 +25,7 @@ class CompanyAnalysisResult:
success: bool
error: str | None = None
model: str | None = None
tags: list[str] = field(default_factory=list)
timestamp: datetime = field(default_factory=datetime.now)
-11
View File
@@ -11,9 +11,7 @@ import { Batch } from './pages/Batch';
import { AnalyticsPage } from './pages/Analytics';
import { About } from './pages/About';
import { AdminUsers } from './pages/AdminUsers';
import { AdminRateLimits } from './pages/AdminRateLimits';
import { Compare } from './pages/Compare';
import { HistoryDiff } from './pages/HistoryDiff';
const queryClient = new QueryClient({
defaultOptions: {
@@ -47,7 +45,6 @@ function App() {
<Route path="/batch" element={<Batch />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/compare" element={<Compare />} />
<Route path="/history-diff" element={<HistoryDiff />} />
<Route path="/about" element={<About />} />
{/* Admin routes */}
@@ -59,14 +56,6 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/rate-limits"
element={
<ProtectedRoute requireAdmin>
<AdminRateLimits />
</ProtectedRoute>
}
/>
</Route>
{/* Default redirect */}
+8 -64
View File
@@ -148,43 +148,8 @@ export const analysisApi = {
const response = await api.get<JobStatus[]>(`/jobs?${params}`);
return response.data;
},
getCompanyHistory: async (companyName: string, limit = 20): Promise<AnalysisHistoryItem[]> => {
const response = await api.get<AnalysisHistoryItem[]>(
`/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}`
);
return response.data;
},
diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise<AnalysisDiff> => {
const response = await api.get<AnalysisDiff>(
`/analyze/${encodeURIComponent(companyName)}/diff?from=${fromId}&to=${toId}`
);
return response.data;
},
};
// Analysis diff types
export interface AnalysisHistoryItem {
id: number;
analysis_type: string | null;
model: string | null;
timestamp: string;
}
export interface AnalysisDiff {
company_name: string;
from_id: number;
to_id: number;
from_timestamp: string;
to_timestamp: string;
patent_count_delta: number;
added_patents: string[];
removed_patents: string[];
changed_fields: Record<string, { from: string | null; to: string | null }>;
summary: string;
}
// Export API
export const exportApi = {
exportCsv: async (companyName: string): Promise<void> => {
@@ -234,32 +199,16 @@ export const analyticsApi = {
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
return response.data;
},
getTagDistribution: async (): Promise<TagDistribution> => {
const response = await api.get<TagDistribution>('/analytics/tags');
return response.data;
},
};
// Rate limit types
export interface RateLimitIpEntry {
ip: string;
total: number;
rejected: number;
}
export interface RateLimitEndpointStats {
endpoint: string;
limit: string;
total_requests: number;
rejected_requests: number;
by_ip: RateLimitIpEntry[];
}
export interface ThrottledBucket {
timestamp: string;
count: number;
}
export interface RateLimitStatsResponse {
rate_limits: RateLimitEndpointStats[];
throttled_24h: number;
throttled_over_time: ThrottledBucket[];
export interface TagDistribution {
by_tag: Array<{ tag: string; count: number }>;
canonical_tags: string[];
}
// Admin API
@@ -277,11 +226,6 @@ export const adminApi = {
deleteUser: async (userId: number): Promise<void> => {
await api.delete(`/admin/users/${userId}`);
},
getRateLimits: async (): Promise<RateLimitStatsResponse> => {
const response = await api.get<RateLimitStatsResponse>('/admin/rate-limits');
return response.data;
},
};
export default api;
+1 -3
View File
@@ -1,7 +1,7 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon, History, ShieldAlert } from 'lucide-react';
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react';
export function Layout() {
const { user, isAdmin, logout } = useAuth();
@@ -18,13 +18,11 @@ export function Layout() {
{ to: '/batch', icon: Layers, label: 'Batch' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
{ to: '/history-diff', icon: History, label: 'Diff' },
{ to: '/about', icon: Info, label: 'About' },
];
if (isAdmin) {
navItems.push({ to: '/admin/users', icon: Users, label: 'Users' });
navItems.push({ to: '/admin/rate-limits', icon: ShieldAlert, label: 'Rate Limits' });
}
return (
-240
View File
@@ -1,240 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../api/client';
import type { RateLimitStatsResponse } from '../api/client';
import { ShieldAlert, Activity, AlertCircle, RefreshCw, Clock } from 'lucide-react';
const REFRESH_OPTIONS = [
{ label: '15s', value: 15_000 },
{ label: '30s', value: 30_000 },
{ label: '1m', value: 60_000 },
{ label: 'Off', value: 0 },
];
export function AdminRateLimits() {
const [refreshInterval, setRefreshInterval] = useState(30_000);
const { data, isLoading, isError, dataUpdatedAt } = useQuery<RateLimitStatsResponse>({
queryKey: ['admin-rate-limits'],
queryFn: () => adminApi.getRateLimits(),
refetchInterval: refreshInterval || false,
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
if (isError) {
return (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to load rate limit statistics.</span>
</div>
);
}
const maxThrottledCount = data?.throttled_over_time?.length
? Math.max(...data.throttled_over_time.map((b) => b.count))
: 0;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Rate Limiting Dashboard
</h2>
<p className="text-text-secondary">Monitor API rate limits and throttled requests.</p>
</div>
<div className="flex items-center gap-3">
{/* Last updated */}
{dataUpdatedAt > 0 && (
<span className="text-xs text-text-secondary flex items-center gap-1">
<Clock size={12} />
Updated {new Date(dataUpdatedAt).toLocaleTimeString()}
</span>
)}
{/* Refresh interval selector */}
<div className="flex items-center gap-1 bg-bg-card/60 border border-primary/15 rounded-xl p-1">
<RefreshCw size={14} className="text-text-secondary ml-2" />
{REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRefreshInterval(opt.value)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
refreshInterval === opt.value
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-card-hover'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<Activity size={18} className="text-primary" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total Requests
</span>
</div>
<div className="text-3xl font-bold text-text-primary">
{data?.rate_limits.reduce((sum, rl) => sum + rl.total_requests, 0) ?? 0}
</div>
</div>
<div className="bg-bg-card/60 border border-error/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert size={18} className="text-error" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Throttled (24h)
</span>
</div>
<div className="text-3xl font-bold text-error">
{data?.throttled_24h ?? 0}
</div>
</div>
<div className="bg-bg-card/60 border border-secondary/15 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert size={18} className="text-secondary" />
<span className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rate-Limited Endpoints
</span>
</div>
<div className="text-3xl font-bold text-text-primary">
{data?.rate_limits.length ?? 0}
</div>
</div>
</div>
{/* Throttled over time chart (simple bar chart) */}
{data?.throttled_over_time && data.throttled_over_time.length > 0 && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-5">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Throttled Requests Over Time (Last 24h)
</h3>
<div className="flex items-end gap-1 h-32">
{data.throttled_over_time.map((bucket) => {
const height = maxThrottledCount > 0 ? (bucket.count / maxThrottledCount) * 100 : 0;
const hour = new Date(bucket.timestamp).getHours();
return (
<div key={bucket.timestamp} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-text-secondary">{bucket.count}</span>
<div
className="w-full bg-error/70 rounded-t-sm min-h-[2px] transition-all"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${bucket.timestamp}: ${bucket.count} throttled`}
/>
<span className="text-[10px] text-text-secondary">{hour}:00</span>
</div>
);
})}
</div>
</div>
)}
{/* Per-endpoint table */}
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-primary/10">
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Endpoint
</th>
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Limit
</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total Requests
</th>
<th className="text-right px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rejected
</th>
</tr>
</thead>
<tbody className="divide-y divide-primary/10">
{data?.rate_limits.map((rl) => (
<tr key={rl.endpoint} className="hover:bg-bg-card-hover/50 transition-colors">
<td className="px-6 py-4 font-mono text-sm text-text-primary">{rl.endpoint}</td>
<td className="px-6 py-4">
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{rl.limit}
</span>
</td>
<td className="px-6 py-4 text-right text-text-primary font-semibold">
{rl.total_requests}
</td>
<td className="px-6 py-4 text-right">
<span className={rl.rejected_requests > 0 ? 'text-error font-semibold' : 'text-text-secondary'}>
{rl.rejected_requests}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Per-IP breakdown */}
{data?.rate_limits.some((rl) => rl.by_ip.length > 0) && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-primary/10">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Per-IP Breakdown
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-primary/10">
<th className="text-left px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Endpoint
</th>
<th className="text-left px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
IP Address
</th>
<th className="text-right px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Total
</th>
<th className="text-right px-6 py-3 text-sm font-semibold text-text-secondary uppercase tracking-wider">
Rejected
</th>
</tr>
</thead>
<tbody className="divide-y divide-primary/10">
{data.rate_limits.flatMap((rl) =>
rl.by_ip.map((ipEntry) => (
<tr
key={`${rl.endpoint}-${ipEntry.ip}`}
className="hover:bg-bg-card-hover/50 transition-colors"
>
<td className="px-6 py-3 font-mono text-sm text-text-primary">{rl.endpoint}</td>
<td className="px-6 py-3 font-mono text-sm text-text-secondary">{ipEntry.ip}</td>
<td className="px-6 py-3 text-right text-text-primary">{ipEntry.total}</td>
<td className="px-6 py-3 text-right">
<span className={ipEntry.rejected > 0 ? 'text-error font-semibold' : 'text-text-secondary'}>
{ipEntry.rejected}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+1 -10
View File
@@ -1,12 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { analysisApi, exportApi } from '../api/client';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown, History } from 'lucide-react';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react';
import type { CompanyAnalysis } from '../types';
export function Analysis() {
const navigate = useNavigate();
const [companyName, setCompanyName] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [result, setResult] = useState<CompanyAnalysis | null>(null);
@@ -159,13 +157,6 @@ export function Analysis() {
<FileText size={14} />
Export PDF
</button>
<button
onClick={() => navigate(`/history-diff?company=${encodeURIComponent(result.company_name)}`)}
className="flex items-center gap-2 text-sm bg-secondary/20 hover:bg-secondary/30 text-secondary font-medium px-3 py-1.5 rounded-lg transition-colors"
>
<History size={14} />
Compare with previous
</button>
</div>
</div>
<div className="prose dark:prose-invert max-w-none">
+95 -1
View File
@@ -1,14 +1,24 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '../api/client';
import { AlertCircle, Database } from 'lucide-react';
import { AlertCircle, Database, Tag } from 'lucide-react';
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 TAG_COLORS: Record<string, string> = {
ai: '#6366f1',
semiconductors: '#0ea5e9',
materials: '#10b981',
biotech: '#f59e0b',
networking: '#ec4899',
other: '#8b5cf6',
};
export function AnalyticsPage() {
const [days, setDays] = useState(30);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const chartTheme = useChartTheme();
const { data, isLoading, isError, refetch } = useQuery({
@@ -21,6 +31,17 @@ export function AnalyticsPage() {
queryFn: () => analyticsApi.getTrends(days),
});
const tagQuery = useQuery({
queryKey: ['analytics-tags'],
queryFn: () => analyticsApi.getTagDistribution(),
});
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
if (isLoading) {
return (
<div className="space-y-6">
@@ -107,6 +128,13 @@ export function AnalyticsPage() {
count: t.count,
}));
const tagData = tagQuery.data?.by_tag?.map((t) => ({
name: t.tag,
count: t.count,
})) || [];
const canonicalTags = tagQuery.data?.canonical_tags || [];
return (
<div className="space-y-6">
{/* Header */}
@@ -131,6 +159,50 @@ export function AnalyticsPage() {
</select>
</div>
{/* Tag Filter Controls */}
{canonicalTags.length > 0 && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-4">
<div className="flex items-center gap-2 mb-3">
<Tag size={16} className="text-primary" />
<span className="text-sm font-semibold text-text-primary">Filter by Technology Domain</span>
{selectedTags.length > 0 && (
<button
onClick={() => setSelectedTags([])}
className="ml-auto text-xs text-text-secondary hover:text-primary transition-colors"
>
Clear all
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{canonicalTags.map((tag) => {
const isActive = selectedTags.includes(tag);
const color = TAG_COLORS[tag] || '#8b5cf6';
const tagCount = tagQuery.data?.by_tag?.find((t) => t.tag === tag)?.count || 0;
return (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
isActive
? 'text-white shadow-md'
: 'bg-bg-card/80 text-text-secondary border border-primary/20 hover:border-primary/40'
}`}
style={isActive ? { backgroundColor: color } : {}}
>
{tag}
{tagCount > 0 && (
<span className={`ml-1.5 text-xs ${isActive ? 'opacity-80' : 'opacity-60'}`}>
({tagCount})
</span>
)}
</button>
);
})}
</div>
</div>
)}
{/* Summary Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard label="Total Analyses" value={data.total_messages} />
@@ -187,6 +259,28 @@ export function AnalyticsPage() {
</ResponsiveContainer>
</div>
)}
{/* Bar Chart - Technology Domain Tags */}
{tagData.length > 0 && (
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Patents by Technology Domain</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tagData}>
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
<Tooltip
contentStyle={chartTheme.tooltipContentStyle}
labelStyle={chartTheme.tooltipLabelStyle}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{tagData.map((entry, index) => (
<Cell key={`tag-cell-${index}`} fill={TAG_COLORS[entry.name] || COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Trend Charts */}
-249
View File
@@ -1,249 +0,0 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { analysisApi } from '../api/client';
import type { AnalysisHistoryItem, AnalysisDiff } from '../api/client';
import { History, ArrowRight, Plus, Minus, AlertCircle, Search } from 'lucide-react';
export function HistoryDiff() {
const [searchParams, setSearchParams] = useSearchParams();
const [companyInput, setCompanyInput] = useState(searchParams.get('company') || '');
const company = searchParams.get('company') || '';
const fromId = searchParams.get('from');
const toId = searchParams.get('to');
// Fetch history when a company is selected
const historyQuery = useQuery({
queryKey: ['history', company],
queryFn: () => analysisApi.getCompanyHistory(company),
enabled: !!company,
});
// Fetch diff when both IDs are selected
const diffQuery = useQuery<AnalysisDiff>({
queryKey: ['diff', company, fromId, toId],
queryFn: () => analysisApi.diffAnalyses(company, Number(fromId), Number(toId)),
enabled: !!company && !!fromId && !!toId,
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const name = companyInput.trim();
if (name) {
setSearchParams({ company: name });
}
};
const handleSelectRuns = (from: number, to: number) => {
setSearchParams({ company, from: String(from), to: String(to) });
};
const history: AnalysisHistoryItem[] = historyQuery.data || [];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Historical Analysis Diff
</h2>
<p className="text-text-secondary">
Compare analysis runs for the same company to see what changed between them.
</p>
</div>
{/* Company Search */}
<form onSubmit={handleSearch} className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<input
type="text"
value={companyInput}
onChange={(e) => setCompanyInput(e.target.value)}
placeholder="Enter company name (e.g., nvidia)"
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
<button
type="submit"
disabled={!companyInput.trim()}
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<History size={18} />
Load History
</button>
</form>
{/* History list */}
{company && historyQuery.isLoading && (
<div className="text-text-secondary animate-pulse">Loading analysis history...</div>
)}
{company && historyQuery.isError && (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to load history. Check the company name and try again.</span>
</div>
)}
{company && history.length === 0 && !historyQuery.isLoading && (
<div className="text-text-secondary">No analysis history found for "{company}".</div>
)}
{history.length >= 2 && (
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
Select Two Runs to Compare
</h3>
<div className="space-y-2">
{history.map((item, idx) => {
const next = history[idx + 1];
if (!next) return null;
const isSelected =
fromId === String(next.id) && toId === String(item.id);
return (
<button
key={item.id}
onClick={() => handleSelectRuns(next.id, item.id)}
className={`w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border transition-all ${
isSelected
? 'border-primary bg-primary/10'
: 'border-primary/15 hover:border-primary/40 hover:bg-primary/5'
}`}
>
<span className="text-sm text-text-secondary font-mono">
#{next.id}
</span>
<span className="text-xs text-text-secondary">
{new Date(next.timestamp).toLocaleString()}
</span>
<ArrowRight size={14} className="text-primary" />
<span className="text-sm text-text-secondary font-mono">
#{item.id}
</span>
<span className="text-xs text-text-secondary">
{new Date(item.timestamp).toLocaleString()}
</span>
{item.model && (
<span className="ml-auto text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">
{item.model}
</span>
)}
</button>
);
})}
</div>
</div>
)}
{/* Diff Results */}
{diffQuery.isLoading && (
<div className="text-text-secondary animate-pulse">Computing diff...</div>
)}
{diffQuery.isError && (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to compute diff. One or both analysis IDs may not exist.</span>
</div>
)}
{diffQuery.data && <DiffView diff={diffQuery.data} />}
</div>
);
}
function DiffView({ diff }: { diff: AnalysisDiff }) {
return (
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6 space-y-6">
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
Diff: #{diff.from_id} &rarr; #{diff.to_id}
</h3>
{/* Summary */}
<div className="bg-primary/5 border border-primary/20 rounded-xl p-4">
<div className="text-sm font-medium text-text-primary">{diff.summary}</div>
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
<span>{new Date(diff.from_timestamp).toLocaleString()}</span>
<ArrowRight size={12} />
<span>{new Date(diff.to_timestamp).toLocaleString()}</span>
</div>
</div>
{/* Patent count delta */}
<div className="flex items-center gap-3">
<span className="text-sm text-text-secondary">Patent mention delta:</span>
<span
className={`text-lg font-bold ${
diff.patent_count_delta > 0
? 'text-success'
: diff.patent_count_delta < 0
? 'text-error'
: 'text-text-secondary'
}`}
>
{diff.patent_count_delta > 0 ? '+' : ''}
{diff.patent_count_delta}
</span>
</div>
{/* Added patents */}
{diff.added_patents.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-success flex items-center gap-1 mb-2">
<Plus size={14} />
New Patents ({diff.added_patents.length})
</h4>
<div className="flex flex-wrap gap-2">
{diff.added_patents.map((p) => (
<span
key={p}
className="text-xs bg-success/10 border border-success/20 text-success px-2 py-1 rounded font-mono"
>
{p}
</span>
))}
</div>
</div>
)}
{/* Removed patents */}
{diff.removed_patents.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-error flex items-center gap-1 mb-2">
<Minus size={14} />
Removed Patents ({diff.removed_patents.length})
</h4>
<div className="flex flex-wrap gap-2">
{diff.removed_patents.map((p) => (
<span
key={p}
className="text-xs bg-error/10 border border-error/20 text-error px-2 py-1 rounded font-mono"
>
{p}
</span>
))}
</div>
</div>
)}
{/* Changed fields */}
{Object.keys(diff.changed_fields).length > 0 && (
<div>
<h4 className="text-sm font-semibold text-text-primary mb-2">Changed Fields</h4>
<div className="space-y-1">
{Object.entries(diff.changed_fields).map(([field, vals]) => (
<div key={field} className="flex items-center gap-2 text-sm">
<span className="text-text-secondary font-mono">{field}:</span>
<span className="text-error line-through">{vals.from || 'null'}</span>
<ArrowRight size={12} className="text-text-secondary" />
<span className="text-success">{vals.to || 'null'}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
-244
View File
@@ -1,244 +0,0 @@
"""Tests for historical analysis diff endpoint."""
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from SPARC.api import AnalysisDiffResponse, _compute_analysis_diff, _extract_patent_ids, app
from SPARC.auth import UserResponse, get_current_user
# ---------- helpers ----------
def _mock_user():
"""Return a fake authenticated user for dependency override."""
return UserResponse(
id=1,
email="test@example.com",
role="user",
created_at=datetime(2025, 1, 1),
)
@pytest.fixture
def auth_client():
"""TestClient with auth dependency overridden."""
app.dependency_overrides[get_current_user] = _mock_user
client = TestClient(app, raise_server_exceptions=False)
yield client
app.dependency_overrides.clear()
# ---------- unit tests for helpers ----------
class TestExtractPatentIds:
"""Test _extract_patent_ids utility."""
def test_extracts_standard_ids(self):
text = "Patent US-12345678-B2 covers the device. Also see US-9876543-A1."
ids = _extract_patent_ids(text)
assert "US-12345678-B2" in ids
assert "US-9876543-A1" in ids
def test_empty_text(self):
assert _extract_patent_ids("") == set()
assert _extract_patent_ids(None) == set() # type: ignore[arg-type]
class TestComputeAnalysisDiff:
"""Test _compute_analysis_diff logic."""
def test_identical_analyses(self):
rec = {
"id": 1,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "Patent US-12345678-B2 is notable.",
"timestamp": datetime(2025, 5, 1),
}
diff = _compute_analysis_diff(rec, dict(rec, id=2, timestamp=datetime(2025, 5, 2)))
assert diff.patent_count_delta == 0
assert diff.added_patents == []
assert diff.removed_patents == []
def test_added_and_removed_patents(self):
from_rec = {
"id": 1,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "Patent US-12345678-B2 and US-11111111-A1.",
"timestamp": datetime(2025, 5, 1),
}
to_rec = {
"id": 2,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "Patent US-12345678-B2 and US-99999999-B1.",
"timestamp": datetime(2025, 5, 2),
}
diff = _compute_analysis_diff(from_rec, to_rec)
assert "US-99999999-B1" in diff.added_patents
assert "US-11111111-A1" in diff.removed_patents
assert diff.patent_count_delta == 0 # one added, one removed
def test_model_change_detected(self):
from_rec = {
"id": 1,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "",
"timestamp": datetime(2025, 5, 1),
}
to_rec = {
"id": 2,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "anthropic/claude-3.5-sonnet",
"response": "",
"timestamp": datetime(2025, 5, 2),
}
diff = _compute_analysis_diff(from_rec, to_rec)
assert "model" in diff.changed_fields
assert diff.changed_fields["model"]["from"] == "openai/gpt-4o"
assert diff.changed_fields["model"]["to"] == "anthropic/claude-3.5-sonnet"
# ---------- API endpoint tests ----------
class TestDiffEndpoint:
"""Test GET /analyze/{company_name}/diff."""
@patch("SPARC.api._get_job_db")
def test_happy_path(self, mock_get_db, auth_client):
"""Diff returns structured response when both IDs exist."""
db = MagicMock()
mock_get_db.return_value = db
from_rec = {
"id": 10,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "Patent US-12345678-B2 found.",
"timestamp": datetime(2025, 5, 1),
}
to_rec = {
"id": 20,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "Patent US-12345678-B2 and US-99999999-A1 found.",
"timestamp": datetime(2025, 5, 10),
}
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else to_rec
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
assert response.status_code == 200
data = response.json()
assert data["company_name"] == "nvidia"
assert data["from_id"] == 10
assert data["to_id"] == 20
assert "US-99999999-A1" in data["added_patents"]
assert data["patent_count_delta"] == 1
@patch("SPARC.api._get_job_db")
def test_from_id_not_found(self, mock_get_db, auth_client):
"""Returns 404 when 'from' analysis ID doesn't exist."""
db = MagicMock()
mock_get_db.return_value = db
db.get_analysis_by_id.return_value = None
response = auth_client.get("/analyze/nvidia/diff?from=999&to=1000")
assert response.status_code == 404
assert "999" in response.json()["detail"]
@patch("SPARC.api._get_job_db")
def test_to_id_not_found(self, mock_get_db, auth_client):
"""Returns 404 when 'to' analysis ID doesn't exist."""
db = MagicMock()
mock_get_db.return_value = db
from_rec = {
"id": 10,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "",
"timestamp": datetime(2025, 5, 1),
}
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else None
response = auth_client.get("/analyze/nvidia/diff?from=10&to=999")
assert response.status_code == 404
assert "999" in response.json()["detail"]
@patch("SPARC.api._get_job_db")
def test_company_mismatch(self, mock_get_db, auth_client):
"""Returns 404 when analysis belongs to a different company."""
db = MagicMock()
mock_get_db.return_value = db
rec = {
"id": 10,
"company_name": "intel",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "",
"timestamp": datetime(2025, 5, 1),
}
db.get_analysis_by_id.return_value = rec
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
assert response.status_code == 404
class TestHistoryEndpoint:
"""Test GET /analyze/{company_name}/history."""
@patch("SPARC.api._get_job_db")
def test_returns_history_list(self, mock_get_db, auth_client):
"""History endpoint returns list of past analysis runs."""
db = MagicMock()
mock_get_db.return_value = db
db.list_company_analyses.return_value = [
{
"id": 20,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "...",
"timestamp": datetime(2025, 5, 10),
},
{
"id": 10,
"company_name": "nvidia",
"analysis_type": "portfolio",
"model": "openai/gpt-4o",
"response": "...",
"timestamp": datetime(2025, 5, 1),
},
]
response = auth_client.get("/analyze/nvidia/history")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["id"] == 20
assert data[1]["id"] == 10
@patch("SPARC.api._get_job_db")
def test_empty_history(self, mock_get_db, auth_client):
"""History endpoint returns empty list when no analyses exist."""
db = MagicMock()
mock_get_db.return_value = db
db.list_company_analyses.return_value = []
response = auth_client.get("/analyze/nvidia/history")
assert response.status_code == 200
assert response.json() == []
-319
View File
@@ -1,319 +0,0 @@
"""Tests for user-level API key generation, listing, revocation, and authentication.
Covers all acceptance criteria from issue #1673:
1. Users can create API keys (POST /auth/apikeys)
2. Users can list their active key IDs (GET /auth/apikeys)
3. Users can revoke keys (DELETE /auth/apikeys/{key_id})
4. API requests authenticated with a valid API key work on protected endpoints
5. Revoked keys are immediately rejected
6. Plaintext key is shown only at creation time
All tests use mocked DB fixtures and require no live database.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from SPARC.api import app
from SPARC.auth import (
create_access_token,
generate_api_key,
hash_api_key,
verify_api_key,
)
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
def _make_user():
return {
"id": 1,
"email": "user@test.com",
"role": "user",
"created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
}
def _auth_header(user_dict):
"""Create an Authorization header with a valid access token."""
token = create_access_token(user_dict["id"], user_dict["email"], user_dict["role"])
return {"Authorization": f"Bearer {token}"}
@pytest.fixture(autouse=True)
def mock_db(monkeypatch):
"""Mock the database client used by auth and api endpoints."""
db = MagicMock()
db.get_user_count.return_value = 0
db.get_user_by_id.return_value = None
db.get_user_by_email.return_value = None
db.authenticate_user.return_value = None
db.create_user.return_value = None
db.get_all_users.return_value = []
db.update_user_role.return_value = None
db.delete_user.return_value = False
db.create_api_key.return_value = None
db.list_api_keys.return_value = []
db.delete_api_key.return_value = False
db.get_all_api_key_hashes.return_value = []
with patch("SPARC.api.get_db_client", return_value=db), \
patch("SPARC.auth.get_db_client", return_value=db):
yield db
class TestCreateApiKey:
"""POST /auth/apikeys"""
def test_create_key_returns_plaintext_and_id(self, client, mock_db):
"""Creating a key returns the plaintext key and metadata."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.create_api_key.return_value = {
"id": 42,
"user_id": user["id"],
"label": "my-ci-key",
"created_at": datetime(2025, 6, 1, tzinfo=timezone.utc),
}
response = client.post(
"/auth/apikeys",
json={"label": "my-ci-key"},
headers=_auth_header(user),
)
assert response.status_code == 200
data = response.json()
assert data["id"] == 42
assert len(data["key"]) == 64 # 32 bytes hex = 64 chars
assert data["label"] == "my-ci-key"
assert "created_at" in data
# Verify the hash passed to DB is valid for the returned key
call_args = mock_db.create_api_key.call_args
stored_hash = call_args.kwargs.get("key_hash") or call_args[1].get("key_hash") or call_args[0][1]
assert verify_api_key(data["key"], stored_hash)
def test_create_key_without_label(self, client, mock_db):
"""Creating a key without a label should work."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.create_api_key.return_value = {
"id": 1,
"user_id": user["id"],
"label": None,
"created_at": datetime(2025, 6, 1, tzinfo=timezone.utc),
}
response = client.post(
"/auth/apikeys",
headers=_auth_header(user),
)
assert response.status_code == 200
assert response.json()["label"] is None
def test_create_key_requires_auth(self, client):
"""Creating a key without auth should fail."""
response = client.post("/auth/apikeys")
assert response.status_code == 401
class TestListApiKeys:
"""GET /auth/apikeys"""
def test_list_keys_returns_metadata_only(self, client, mock_db):
"""Listing keys should return IDs and labels, not secrets."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.list_api_keys.return_value = [
{"id": 1, "label": "key-1", "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc)},
{"id": 2, "label": None, "created_at": datetime(2025, 2, 1, tzinfo=timezone.utc)},
]
response = client.get("/auth/apikeys", headers=_auth_header(user))
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["id"] == 1
assert data[0]["label"] == "key-1"
# Ensure no secret key is exposed
for item in data:
assert "key" not in item
assert "key_hash" not in item
def test_list_keys_empty(self, client, mock_db):
"""User with no keys gets an empty list."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.list_api_keys.return_value = []
response = client.get("/auth/apikeys", headers=_auth_header(user))
assert response.status_code == 200
assert response.json() == []
class TestRevokeApiKey:
"""DELETE /auth/apikeys/{key_id}"""
def test_revoke_existing_key(self, client, mock_db):
"""Revoking an owned key should succeed."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.delete_api_key.return_value = True
response = client.delete("/auth/apikeys/42", headers=_auth_header(user))
assert response.status_code == 200
assert "revoked" in response.json()["message"].lower()
mock_db.delete_api_key.assert_called_once_with(42, user["id"])
def test_revoke_nonexistent_key_returns_404(self, client, mock_db):
"""Revoking a key that doesn't exist (or isn't owned) returns 404."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
mock_db.delete_api_key.return_value = False
response = client.delete("/auth/apikeys/999", headers=_auth_header(user))
assert response.status_code == 404
class TestApiKeyAuthentication:
"""Using X-API-Key header on protected endpoints."""
def test_valid_api_key_accesses_protected_endpoint(self, client, mock_db):
"""A valid API key should authenticate and access /auth/me."""
user = _make_user()
plaintext = generate_api_key()
hashed = hash_api_key(plaintext)
mock_db.get_all_api_key_hashes.return_value = [
{"key_hash": hashed, "user_id": user["id"]},
]
mock_db.get_user_by_id.return_value = user
response = client.get("/auth/me", headers={"X-API-Key": plaintext})
assert response.status_code == 200
data = response.json()
assert data["email"] == user["email"]
assert data["id"] == user["id"]
def test_invalid_api_key_returns_401(self, client, mock_db):
"""An invalid API key should return 401."""
mock_db.get_all_api_key_hashes.return_value = []
response = client.get("/auth/me", headers={"X-API-Key": "bad-key"})
assert response.status_code == 401
assert "invalid api key" in response.json()["detail"].lower()
def test_revoked_key_returns_401(self, client, mock_db):
"""After revocation, using the key should return 401."""
# Simulate revoked key: no matching hashes in DB
mock_db.get_all_api_key_hashes.return_value = []
response = client.get("/auth/me", headers={"X-API-Key": "a" * 64})
assert response.status_code == 401
def test_api_key_for_deleted_user_returns_401(self, client, mock_db):
"""An API key whose user no longer exists should return 401."""
plaintext = generate_api_key()
hashed = hash_api_key(plaintext)
mock_db.get_all_api_key_hashes.return_value = [
{"key_hash": hashed, "user_id": 999},
]
mock_db.get_user_by_id.return_value = None # user deleted
response = client.get("/auth/me", headers={"X-API-Key": plaintext})
assert response.status_code == 401
def test_no_auth_at_all_returns_401(self, client, mock_db):
"""No auth header at all should return 401."""
response = client.get("/auth/me")
assert response.status_code == 401
class TestApiKeyFullFlow:
"""End-to-end flow: create key, use it, revoke it, try again."""
def test_create_use_revoke_flow(self, client, mock_db):
"""Simulate full lifecycle of an API key."""
user = _make_user()
mock_db.get_user_by_id.return_value = user
# Step 1: Create key
mock_db.create_api_key.return_value = {
"id": 10,
"user_id": user["id"],
"label": "test",
"created_at": datetime(2025, 6, 1, tzinfo=timezone.utc),
}
create_resp = client.post(
"/auth/apikeys",
json={"label": "test"},
headers=_auth_header(user),
)
assert create_resp.status_code == 200
plaintext = create_resp.json()["key"]
# Capture the hash that was stored
call_args = mock_db.create_api_key.call_args
stored_hash = call_args.kwargs.get("key_hash") or call_args[0][1]
# Step 2: Use key on protected endpoint
mock_db.get_all_api_key_hashes.return_value = [
{"key_hash": stored_hash, "user_id": user["id"]},
]
use_resp = client.get("/auth/me", headers={"X-API-Key": plaintext})
assert use_resp.status_code == 200
assert use_resp.json()["email"] == user["email"]
# Step 3: Revoke key
mock_db.delete_api_key.return_value = True
revoke_resp = client.delete("/auth/apikeys/10", headers=_auth_header(user))
assert revoke_resp.status_code == 200
# Step 4: Try using revoked key
mock_db.get_all_api_key_hashes.return_value = [] # key removed from DB
rejected_resp = client.get("/auth/me", headers={"X-API-Key": plaintext})
assert rejected_resp.status_code == 401
class TestApiKeyHelpers:
"""Unit tests for key generation and hashing helpers."""
def test_generate_api_key_length(self):
"""Generated key should be 64 hex characters (32 bytes)."""
key = generate_api_key()
assert len(key) == 64
# Should be valid hex
int(key, 16)
def test_generate_api_key_uniqueness(self):
"""Two generated keys should be different."""
k1 = generate_api_key()
k2 = generate_api_key()
assert k1 != k2
def test_hash_and_verify(self):
"""hash_api_key and verify_api_key should round-trip correctly."""
key = generate_api_key()
hashed = hash_api_key(key)
assert verify_api_key(key, hashed)
assert not verify_api_key("wrong-key", hashed)
+262
View File
@@ -0,0 +1,262 @@
"""Tests for LLM-based patent classification tagging by technology domain."""
import json
from unittest.mock import MagicMock, Mock, patch
import pytest
from SPARC.llm import LLMAnalyzer
class TestClassifyPatentTags:
"""Test the classify_patent_tags method on LLMAnalyzer."""
@pytest.fixture(autouse=True)
def mock_database(self, mocker):
"""Mock the database client for all tests."""
mock_db_client = Mock()
mock_db_client.get_cached_response.return_value = None
mock_db_client.store_message.return_value = 1
mocker.patch("SPARC.llm.DatabaseClient", return_value=mock_db_client)
return mock_db_client
def test_classify_returns_valid_tags(self, mocker, mock_database):
"""Test that classify_patent_tags returns valid canonical tags from LLM."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='["ai", "semiconductors"]'))]
mock_client.chat.completions.create.return_value = mock_response
analyzer = LLMAnalyzer(api_key="test-key")
tags = analyzer.classify_patent_tags("A patent about neural network accelerator chips")
assert tags == ["ai", "semiconductors"]
mock_client.chat.completions.create.assert_called_once()
# Verify the prompt contains classification instructions
call_args = mock_client.chat.completions.create.call_args
prompt_text = call_args[1]["messages"][0]["content"]
assert "ai" in prompt_text
assert "semiconductors" in prompt_text
assert "JSON array" in prompt_text
def test_classify_filters_invalid_tags(self, mocker, mock_database):
"""Test that invalid tags from LLM response are filtered out."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='["ai", "quantum_computing", "biotech"]'))]
mock_client.chat.completions.create.return_value = mock_response
analyzer = LLMAnalyzer(api_key="test-key")
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["ai", "biotech"]
assert "quantum_computing" not in tags
def test_classify_returns_other_when_all_invalid(self, mocker, mock_database):
"""Test fallback to 'other' when all LLM tags are invalid."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='["quantum", "robotics"]'))]
mock_client.chat.completions.create.return_value = mock_response
analyzer = LLMAnalyzer(api_key="test-key")
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["other"]
def test_classify_handles_malformed_json(self, mocker, mock_database):
"""Test graceful handling of non-JSON LLM response."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="ai, semiconductors"))]
mock_client.chat.completions.create.return_value = mock_response
analyzer = LLMAnalyzer(api_key="test-key")
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["other"]
def test_classify_handles_api_error(self, mocker, mock_database):
"""Test graceful fallback when LLM API call fails."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_client.chat.completions.create.side_effect = Exception("API timeout")
analyzer = LLMAnalyzer(api_key="test-key")
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["other"]
def test_classify_test_mode(self, mocker, mock_database):
"""Test that test mode returns 'other' without API call."""
mocker.patch("SPARC.llm.config")
analyzer = LLMAnalyzer(test_mode=True)
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["other"]
def test_classify_no_api_client(self, mocker, mock_database):
"""Test that without API client, classification returns 'other'."""
mocker.patch("SPARC.llm.config")
analyzer = LLMAnalyzer(use_cache=False)
tags = analyzer.classify_patent_tags("Some patent content")
assert tags == ["other"]
def test_canonical_tags_list(self):
"""Test that the canonical tag list matches requirements."""
expected = ["ai", "semiconductors", "materials", "biotech", "networking", "other"]
assert LLMAnalyzer.CANONICAL_TAGS == expected
def test_classify_uses_model_override(self, mocker, mock_database):
"""Test that model override is passed to the API call."""
mock_openai = mocker.patch("SPARC.llm.OpenAI")
mock_client = Mock()
mock_openai.return_value = mock_client
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='["ai"]'))]
mock_client.chat.completions.create.return_value = mock_response
analyzer = LLMAnalyzer(api_key="test-key")
analyzer.classify_patent_tags("content", model="openai/gpt-4o")
call_args = mock_client.chat.completions.create.call_args
assert call_args[1]["model"] == "openai/gpt-4o"
class TestPatentTagsStorage:
"""Test that tags are persisted to the database."""
@pytest.fixture(autouse=True)
def mock_db(self, mocker):
"""Mock DatabaseClient for all tests."""
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
def test_tags_stored_after_classification(self, mocker, mock_db):
"""Test that classify_patent_tags results are stored via update_patent_tags."""
from SPARC.analyzer import CompanyAnalyzer
from SPARC.types import Patent, Patents
mock_query = mocker.patch("SPARC.analyzer.SERP.query")
mock_save = mocker.patch("SPARC.analyzer.SERP.save_patents")
mock_parse = mocker.patch("SPARC.analyzer.SERP.parse_patent_pdf")
mock_minimize = mocker.patch("SPARC.analyzer.SERP.minimize_patent_for_llm")
mock_llm_cls = mocker.patch("SPARC.analyzer.LLMAnalyzer")
patent = Patent(patent_id="US123", pdf_link="http://example.com/test.pdf")
mock_query.return_value = Patents(patents=[patent])
def save_side_effect(p):
p.pdf_path = "patents/US123.pdf"
return p
mock_save.side_effect = save_side_effect
mock_parse.return_value = {"abstract": "Test abstract"}
mock_minimize.return_value = "Minimized content"
mock_llm_instance = Mock()
mock_llm_instance.analyze_patent_portfolio.return_value = "Analysis result"
mock_llm_instance.classify_patent_tags.return_value = ["ai", "semiconductors"]
mock_llm_cls.return_value = mock_llm_instance
analyzer = CompanyAnalyzer()
analyzer.analyze_company("TestCorp")
# Verify classification was called
mock_llm_instance.classify_patent_tags.assert_called_once_with(
patent_content="Minimized content", model=None
)
# Verify tags were persisted
mock_db.update_patent_tags.assert_called_once_with("US123", ["ai", "semiconductors"])
def test_cached_tags_skip_classification(self, mocker, mock_db):
"""Test that patents with cached tags skip re-classification."""
from SPARC.analyzer import CompanyAnalyzer
from SPARC.types import Patent, Patents
mocker.patch("SPARC.analyzer.SERP.query")
mocker.patch("SPARC.analyzer.SERP.save_patents")
mock_llm_cls = mocker.patch("SPARC.analyzer.LLMAnalyzer")
# Simulate DB cache hit with tags
mock_db.get_cached_patent.return_value = {
"patent_id": "US123",
"minimized_content": "Cached content",
"patent_tags": ["biotech"],
}
mock_db.get_cached_serp_query.return_value = ["US123"]
mock_llm_instance = Mock()
mock_llm_instance.analyze_patent_portfolio.return_value = "Analysis"
mock_llm_cls.return_value = mock_llm_instance
analyzer = CompanyAnalyzer()
analyzer.analyze_company("TestCorp")
# Should NOT classify since tags already present from cache
mock_llm_instance.classify_patent_tags.assert_not_called()
mock_db.update_patent_tags.assert_not_called()
def test_tags_in_analysis_result(self, mocker, mock_db):
"""Test that tags appear in the CompanyAnalysisResult."""
from SPARC.analyzer import CompanyAnalyzer
from SPARC.types import Patent, Patents
mock_query = mocker.patch("SPARC.analyzer.SERP.query")
mock_save = mocker.patch("SPARC.analyzer.SERP.save_patents")
mock_parse = mocker.patch("SPARC.analyzer.SERP.parse_patent_pdf")
mock_minimize = mocker.patch("SPARC.analyzer.SERP.minimize_patent_for_llm")
mock_llm_cls = mocker.patch("SPARC.analyzer.LLMAnalyzer")
patent = Patent(patent_id="US123", pdf_link="http://example.com/test.pdf")
mock_query.return_value = Patents(patents=[patent])
def save_side_effect(p):
p.pdf_path = "patents/US123.pdf"
return p
mock_save.side_effect = save_side_effect
mock_parse.return_value = {"abstract": "Test abstract"}
mock_minimize.return_value = "Minimized content"
mock_llm_instance = Mock()
mock_llm_instance.analyze_patent_portfolio.return_value = "Strong innovation"
mock_llm_instance.classify_patent_tags.return_value = ["ai", "networking"]
mock_llm_cls.return_value = mock_llm_instance
# After analysis, get_cached_patent returns the patent with tags
mock_db.get_cached_serp_query.side_effect = [None, ["US123"]]
mock_db.get_cached_patent.side_effect = [
None, # First call during _process_single_patent
{"patent_id": "US123", "patent_tags": ["ai", "networking"]}, # Second call during _analyze_company_safe
]
analyzer = CompanyAnalyzer()
result = analyzer._analyze_company_safe("TestCorp")
assert result.success is True
assert result.tags == ["ai", "networking"]
+2 -71
View File
@@ -20,10 +20,8 @@ def client():
def reset_stats():
"""Reset rate limit stats between tests."""
api._rate_limit_stats.clear()
api._rejected_log.clear()
yield
api._rate_limit_stats.clear()
api._rejected_log.clear()
def _mock_admin():
@@ -52,7 +50,8 @@ class TestRateLimitAdminEndpoint:
app.dependency_overrides.clear()
def test_non_admin_rejected(self, client):
"""Non-admin users should get 401/403."""
"""Non-admin users should get 403."""
# Without overriding the dependency, it should fail auth
response = client.get("/admin/rate-limits")
assert response.status_code in (401, 403)
@@ -78,9 +77,6 @@ class TestRateLimitAdminEndpoint:
for rl in data["rate_limits"]:
assert rl["total_requests"] == 0
assert rl["rejected_requests"] == 0
assert rl["by_ip"] == []
assert data["throttled_24h"] == 0
assert data["throttled_over_time"] == []
finally:
app.dependency_overrides.clear()
@@ -111,68 +107,3 @@ class TestRateLimitAdminEndpoint:
assert isinstance(rl["limit"], str)
finally:
app.dependency_overrides.clear()
def test_per_ip_breakdown(self, client):
"""Stats should include per-IP breakdown with total and rejected counts."""
api._track_rate_limit_request("/auth/login", "10.0.0.1")
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
api._track_rate_limit_request("/auth/login", "10.0.0.2")
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
login_stats = next(rl for rl in data["rate_limits"] if rl["endpoint"] == "/auth/login")
by_ip = login_stats["by_ip"]
assert len(by_ip) == 2
ip1 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.1")
assert ip1["total"] == 2
assert ip1["rejected"] == 1
ip2 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.2")
assert ip2["total"] == 1
assert ip2["rejected"] == 0
finally:
app.dependency_overrides.clear()
def test_throttled_24h_count(self, client):
"""Should report total throttled requests in the last 24 hours."""
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
api._track_rate_limit_request("/auth/register", "10.0.0.2", rejected=True)
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
assert data["throttled_24h"] == 2
finally:
app.dependency_overrides.clear()
def test_throttled_over_time_structure(self, client):
"""Throttled-over-time should be a list of {timestamp, count} buckets."""
api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True)
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
assert len(data["throttled_over_time"]) >= 1
entry = data["throttled_over_time"][0]
assert "timestamp" in entry
assert "count" in entry
assert entry["count"] >= 1
finally:
app.dependency_overrides.clear()
def test_response_shape_matches_contract(self, client):
"""The full response should match the expected shape for the frontend."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
# Top-level keys
assert set(data.keys()) == {"rate_limits", "throttled_24h", "throttled_over_time"}
# Each rate_limit entry
for rl in data["rate_limits"]:
assert set(rl.keys()) == {"endpoint", "limit", "total_requests", "rejected_requests", "by_ip"}
finally:
app.dependency_overrides.clear()