Compare commits

...

5 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
AI-Manager 313800215c Merge pull request 'Add rate limit stats to admin panel' (#1682) from feature/1675-rate-limit-admin into main
Merge PR #1682
2026-05-19 00:12:56 +00:00
AI-Manager 222f29deb1 Merge pull request 'Add cursor-based pagination to /analyze/batch and /jobs' (#1681) from feature/1669-cursor-pagination into main
Merge PR #1681
2026-05-19 00:12:48 +00:00
agent-company a0cb9a5773 Add rate limit status and usage statistics to admin panel
Add GET /admin/rate-limits endpoint (admin-only) that returns current
rate limit configuration and request statistics for all rate-limited
endpoints (/auth/register and /auth/login). Tracks total requests and
rejection counts via in-memory counters.

Includes tests for admin access, non-admin rejection, empty state,
request tracking, and configuration display.

Closes leeworks-agents/SPARC#1675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 21:53:01 +00:00
agent-company 857b3444df Add cursor-based pagination to GET /analyze/batch and update /jobs defaults
Add a new GET /analyze/batch endpoint that returns stored analysis results
with cursor-based pagination (default limit 50, max 200). Also update the
existing /jobs endpoint defaults from limit=10/max=100 to limit=50/max=200
for consistency.

The database layer gains a list_analyses() method with cursor support using
(timestamp, id) ordering, matching the existing list_jobs() pattern.

Includes tests for pagination behavior, boundary limits, cursor forwarding,
company name filtering, and empty result sets.

Closes leeworks-agents/SPARC#1669

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 21:49:22 +00:00
10 changed files with 1034 additions and 12 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:
+181 -1
View File
@@ -57,6 +57,7 @@ class CompanyAnalysisResponse(BaseModel):
success: bool
error: str | None = None
model: str | None = None
tags: list[str] = []
timestamp: datetime
@@ -106,6 +107,24 @@ class JobStatus(BaseModel):
error: str | None = None
class AnalysisRecord(BaseModel):
"""A single stored analysis result."""
id: int
company_name: str | None = None
analysis_type: str | None = None
model: str | None = None
response: str | None = None
timestamp: datetime | None = None
class PaginatedAnalysisResponse(BaseModel):
"""Paginated response for analysis result listings."""
items: list[AnalysisRecord]
next_cursor: str | None = None
class PaginatedJobsResponse(BaseModel):
"""Paginated response for job listings."""
@@ -170,6 +189,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse:
success=result.success,
error=result.error,
model=result.model,
tags=result.tags,
timestamp=result.timestamp,
)
@@ -227,10 +247,37 @@ app = FastAPI(
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
# In-memory rate limit statistics
_rate_limit_stats: dict[str, dict] = {}
def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None:
"""Record a request against a rate-limited endpoint."""
key = endpoint
if key not in _rate_limit_stats:
_rate_limit_stats[key] = {
"endpoint": endpoint,
"total_requests": 0,
"rejected_requests": 0,
"by_ip": {},
}
_rate_limit_stats[key]["total_requests"] += 1
if rejected:
_rate_limit_stats[key]["rejected_requests"] += 1
ip_stats = _rate_limit_stats[key].setdefault("by_ip", {})
if ip not in ip_stats:
ip_stats[ip] = {"total": 0, "rejected": 0}
ip_stats[ip]["total"] += 1
if rejected:
ip_stats[ip]["rejected"] += 1
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""Return 429 with Retry-After header when rate limit is exceeded."""
endpoint = request.url.path
ip = get_remote_address(request)
_track_rate_limit_request(endpoint, ip, rejected=True)
retry_after = getattr(exc, "retry_after", 60)
return JSONResponse(
status_code=429,
@@ -259,6 +306,7 @@ async def register(request: Request, body: RegisterRequest):
The first registered user automatically becomes an admin.
"""
_track_rate_limit_request("/auth/register", get_remote_address(request))
db = get_db_client()
# First user becomes admin
@@ -289,6 +337,7 @@ async def register(request: Request, body: RegisterRequest):
@limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest):
"""Authenticate user and return JWT tokens."""
_track_rate_limit_request("/auth/login", get_remote_address(request))
db = get_db_client()
user = db.authenticate_user(body.email, body.password)
@@ -453,6 +502,36 @@ async def remove_tracked_company(
return {"message": f"Stopped tracking {company_name}"}
@app.get("/admin/rate-limits", tags=["Admin"])
async def get_rate_limit_stats(
_: UserResponse = Depends(get_current_admin),
):
"""Get rate limit status and usage statistics (admin only).
Returns current rate limit configuration and request statistics
for all rate-limited endpoints.
Returns:
List of rate limit stats per endpoint with total/rejected counts
"""
rate_limits_config = {
"/auth/register": {"limit": "5/minute"},
"/auth/login": {"limit": "10/minute"},
}
results = []
for endpoint, conf in rate_limits_config.items():
stats = _rate_limit_stats.get(endpoint, {})
results.append({
"endpoint": endpoint,
"limit": conf["limit"],
"total_requests": stats.get("total_requests", 0),
"rejected_requests": stats.get("rejected_requests", 0),
})
return {"rate_limits": results}
@app.get("/admin/alerts", tags=["Admin"])
async def list_alerts(
limit: int = Query(default=50, ge=1, le=200),
@@ -483,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
@@ -882,6 +993,75 @@ async def analyze_single_patent(
raise HTTPException(status_code=404, detail=str(e))
@app.get(
"/analyze/batch",
response_model=PaginatedAnalysisResponse,
tags=["Analysis"],
)
async def list_analysis_results(
company_name: Annotated[
str | None,
Query(description="Filter results by company name"),
] = None,
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,
Query(description="Opaque cursor from a previous response's next_cursor field"),
] = None,
_: UserResponse = Depends(get_current_user),
):
"""List stored analysis results with cursor-based pagination.
Returns past analysis results ordered by timestamp descending. Use
``limit`` to control page size (default 50, max 200). The response
includes a ``next_cursor`` field; pass it back as the ``cursor`` query
parameter to fetch the next page. When ``next_cursor`` is ``null``,
there are no more results.
Args:
company_name: Optional filter by company name
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, tags=tag_list)
has_next = len(rows) > limit
if has_next:
rows = rows[:limit]
items = [AnalysisRecord(**row) for row in rows]
next_cursor = None
if has_next and rows:
last = rows[-1]
ts = last["timestamp"]
ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
next_cursor = f"{ts_str}|{last['id']}"
return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor)
@app.post(
"/analyze/batch",
response_model=BatchAnalysisResponse,
@@ -1057,7 +1237,7 @@ async def list_jobs(
str | None,
Query(description="Filter by status: pending, running, completed, failed"),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[
str | None,
Query(description="Opaque cursor from a previous response's next_cursor field"),
+119 -4
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 (
@@ -371,6 +391,60 @@ class DatabaseClient:
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
def list_analyses(
self,
company_name: Optional[str] = None,
limit: int = 50,
cursor: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> List[Dict]:
"""List analysis results with cursor-based pagination.
Args:
company_name: Optional filter by company name.
limit: Maximum number of records to return.
cursor: Opaque cursor (``timestamp|id``) from a previous response.
tags: Optional list of technology domain tags to filter by.
Returns:
List of analysis dicts ordered by timestamp descending.
"""
conditions: list[str] = ["m.is_cached = FALSE"]
params: list = []
join_clause = ""
if company_name:
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("(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 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 m.timestamp DESC, m.id DESC LIMIT %s"
params.append(limit)
with self.get_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def get_analytics(self, days: int = 30) -> Dict:
"""Get analytics on message usage.
@@ -451,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.
+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)
+10
View File
@@ -199,8 +199,18 @@ 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;
},
};
export interface TagDistribution {
by_tag: Array<{ tag: string; count: number }>;
canonical_tags: string[];
}
// Admin API
export const adminApi = {
listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
+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 */}
+169
View File
@@ -0,0 +1,169 @@
"""Tests for cursor-based pagination on /analyze/batch GET and /jobs endpoints."""
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
from SPARC.api import app
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
def _make_analysis_row(id_: int, minutes_ago: int = 0, company: str = "nvidia"):
"""Create a fake analysis row dict."""
ts = datetime.now() - timedelta(minutes=minutes_ago)
return {
"id": id_,
"company_name": company,
"analysis_type": "patent_portfolio",
"model": "openai/gpt-4o",
"response": f"Analysis for {company}",
"timestamp": ts,
}
def _make_job_row(job_id: str, minutes_ago: int = 0, status: str = "completed"):
"""Create a fake job row dict."""
ts = datetime.now() - timedelta(minutes=minutes_ago)
return {
"job_id": job_id,
"status": status,
"progress": 100 if status == "completed" else 0,
"total_companies": 1,
"completed_companies": 1 if status == "completed" else 0,
"result": None,
"error": None,
"created_at": ts,
}
class TestAnalyzeBatchGetPagination:
"""Test cursor-based pagination on GET /analyze/batch."""
@patch("SPARC.api._get_job_db")
def test_returns_items_and_no_cursor_when_less_than_limit(self, mock_get_db, client):
"""When fewer results than limit, next_cursor should be null."""
db = Mock()
db.list_analyses.return_value = [
_make_analysis_row(1, minutes_ago=10),
_make_analysis_row(2, minutes_ago=20),
]
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=10")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["next_cursor"] is None
@patch("SPARC.api._get_job_db")
def test_returns_cursor_when_more_results_exist(self, mock_get_db, client):
"""When more results exist than limit, next_cursor should be set."""
db = Mock()
# Return limit+1 rows to simulate more data
rows = [_make_analysis_row(i, minutes_ago=i) for i in range(4)]
db.list_analyses.return_value = rows
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=3")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 3
assert data["next_cursor"] is not None
@patch("SPARC.api._get_job_db")
def test_cursor_passed_to_db(self, mock_get_db, client):
"""The cursor query param should be forwarded to the database layer."""
db = Mock()
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?cursor=2025-01-01T00:00:00|42")
db.list_analyses.assert_called_once()
call_kwargs = db.list_analyses.call_args
assert call_kwargs.kwargs.get("cursor") == "2025-01-01T00:00:00|42" or \
(call_kwargs[1].get("cursor") == "2025-01-01T00:00:00|42" if len(call_kwargs) > 1 else False)
@patch("SPARC.api._get_job_db")
def test_default_limit_is_50(self, mock_get_db, client):
"""Default limit should be 50."""
db = Mock()
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch")
call_kwargs = db.list_analyses.call_args
# The endpoint requests limit+1 from DB, so 51
assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51
def test_limit_over_200_rejected(self, client):
"""Limit > 200 should be rejected with 422."""
response = client.get("/analyze/batch?limit=201")
assert response.status_code == 422
def test_limit_zero_rejected(self, client):
"""Limit < 1 should be rejected with 422."""
response = client.get("/analyze/batch?limit=0")
assert response.status_code == 422
@patch("SPARC.api._get_job_db")
def test_company_name_filter(self, mock_get_db, client):
"""The company_name filter should be forwarded to the database."""
db = Mock()
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?company_name=intel")
call_kwargs = db.list_analyses.call_args
assert call_kwargs.kwargs.get("company_name") == "intel" or \
"intel" in (call_kwargs.args if call_kwargs.args else [])
@patch("SPARC.api._get_job_db")
def test_empty_result_set(self, mock_get_db, client):
"""Empty result set returns empty items and null cursor."""
db = Mock()
db.list_analyses.return_value = []
mock_get_db.return_value = db
response = client.get("/analyze/batch")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["next_cursor"] is None
class TestJobsPaginationDefaults:
"""Test that /jobs endpoint uses updated defaults."""
@patch("SPARC.api._get_job_db")
def test_default_limit_is_50(self, mock_get_db, client):
"""Default limit should now be 50."""
db = Mock()
db.list_jobs.return_value = []
mock_get_db.return_value = db
client.get("/jobs")
call_kwargs = db.list_jobs.call_args
# Endpoint requests limit+1 from DB, so 51
assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51
def test_limit_over_200_rejected(self, client):
"""Limit > 200 should be rejected with 422."""
response = client.get("/jobs?limit=201")
assert response.status_code == 422
@patch("SPARC.api._get_job_db")
def test_limit_200_accepted(self, mock_get_db, client):
"""Limit of exactly 200 should be accepted."""
db = Mock()
db.list_jobs.return_value = []
mock_get_db.return_value = db
response = client.get("/jobs?limit=200")
assert response.status_code == 200
+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"]
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the /admin/rate-limits endpoint."""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from SPARC import api
from SPARC.api import app
from SPARC.auth import UserResponse
@pytest.fixture
def client():
"""Create test client."""
return TestClient(app)
@pytest.fixture(autouse=True)
def reset_stats():
"""Reset rate limit stats between tests."""
api._rate_limit_stats.clear()
yield
api._rate_limit_stats.clear()
def _mock_admin():
"""Return a mock admin user."""
return UserResponse(id=1, email="admin@test.com", role="admin", created_at="2025-01-01T00:00:00")
def _mock_user():
"""Return a mock non-admin user."""
return UserResponse(id=2, email="user@test.com", role="user", created_at="2025-01-01T00:00:00")
class TestRateLimitAdminEndpoint:
"""Test GET /admin/rate-limits."""
def test_admin_can_access(self, client):
"""Admin users should be able to access the rate-limits endpoint."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
assert response.status_code == 200
data = response.json()
assert "rate_limits" in data
assert isinstance(data["rate_limits"], list)
finally:
app.dependency_overrides.clear()
def test_non_admin_rejected(self, client):
"""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)
def test_returns_configured_endpoints(self, client):
"""Should list all rate-limited endpoints."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
assert response.status_code == 200
data = response.json()
endpoints = [rl["endpoint"] for rl in data["rate_limits"]]
assert "/auth/register" in endpoints
assert "/auth/login" in endpoints
finally:
app.dependency_overrides.clear()
def test_empty_state_shows_zero_counts(self, client):
"""When no requests have been made, counts should be zero."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
for rl in data["rate_limits"]:
assert rl["total_requests"] == 0
assert rl["rejected_requests"] == 0
finally:
app.dependency_overrides.clear()
def test_tracks_requests(self, client):
"""After making requests, the stats should reflect them."""
api._track_rate_limit_request("/auth/login", "127.0.0.1")
api._track_rate_limit_request("/auth/login", "127.0.0.1")
api._track_rate_limit_request("/auth/login", "192.168.1.1", rejected=True)
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")
assert login_stats["total_requests"] == 3
assert login_stats["rejected_requests"] == 1
finally:
app.dependency_overrides.clear()
def test_includes_limit_config(self, client):
"""Each endpoint entry should include the rate limit config string."""
app.dependency_overrides[api.get_current_admin] = _mock_admin
try:
response = client.get("/admin/rate-limits")
data = response.json()
for rl in data["rate_limits"]:
assert "limit" in rl
assert isinstance(rl["limit"], str)
finally:
app.dependency_overrides.clear()