forked from 0xWheatyz/SPARC
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c317632edb |
@@ -40,9 +40,3 @@ JWT_SECRET=your-secure-jwt-secret-change-in-production
|
||||
# When USE_CACHE=true: check database for cached responses before making API calls
|
||||
# When USE_CACHE=false: always make fresh API calls (still stores results in database)
|
||||
USE_CACHE=true
|
||||
|
||||
# ---- Webhooks ----
|
||||
|
||||
# Comma-separated list of webhook URLs for job completion and alert notifications
|
||||
# Supports generic HTTP POST and Slack/Discord incoming webhooks
|
||||
# WEBHOOK_URLS=https://hooks.slack.com/services/XXX,https://example.com/webhook
|
||||
|
||||
+16
-21
@@ -5,13 +5,10 @@ to provide company performance estimation based on patent portfolios.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
from SPARC import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from SPARC.database import DatabaseClient
|
||||
from SPARC.llm import LLMAnalyzer
|
||||
from SPARC.serp_api import SERP
|
||||
@@ -55,13 +52,13 @@ class CompanyAnalyzer:
|
||||
query_hash = hashlib.sha256(company_name.lower().encode()).hexdigest()
|
||||
cached_ids = self.db.get_cached_serp_query(query_hash)
|
||||
if cached_ids is not None:
|
||||
logger.info("Using cached SERP results for %s (%d patents)", company_name, len(cached_ids))
|
||||
print(f"Using cached SERP results for {company_name} ({len(cached_ids)} patents)")
|
||||
patents = Patents(patents=[
|
||||
Patent(patent_id=pid, pdf_link="")
|
||||
for pid in cached_ids
|
||||
])
|
||||
else:
|
||||
logger.info("Retrieving patents for %s...", company_name)
|
||||
print(f"Retrieving patents for {company_name}...")
|
||||
patents = SERP.query(company_name)
|
||||
# Cache the SERP results
|
||||
if patents.patents:
|
||||
@@ -69,13 +66,12 @@ class CompanyAnalyzer:
|
||||
company_name=company_name,
|
||||
query_hash=query_hash,
|
||||
patent_ids=[p.patent_id for p in patents.patents],
|
||||
ttl_hours=config.serp_cache_ttl_hours,
|
||||
)
|
||||
|
||||
if not patents.patents:
|
||||
return f"No patents found for {company_name}"
|
||||
|
||||
logger.info("Found %d patents. Processing...", len(patents.patents))
|
||||
print(f"Found {len(patents.patents)} patents. Processing...")
|
||||
|
||||
# Download, parse, and minimize patents in parallel
|
||||
processed_patents = []
|
||||
@@ -91,12 +87,12 @@ class CompanyAnalyzer:
|
||||
if result:
|
||||
processed_patents.append(result)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to process %s: %s", patent.patent_id, e)
|
||||
print(f"Warning: Failed to process {patent.patent_id}: {e}")
|
||||
|
||||
if not processed_patents:
|
||||
return f"Failed to process any patents for {company_name}"
|
||||
|
||||
logger.info("Analyzing portfolio with LLM...")
|
||||
print("Analyzing portfolio with LLM...")
|
||||
|
||||
# Analyze the full portfolio with LLM
|
||||
analysis = self.llm_analyzer.analyze_patent_portfolio(
|
||||
@@ -126,7 +122,6 @@ class CompanyAnalyzer:
|
||||
FileNotFoundError: If the patent PDF is not found at the expected path.
|
||||
"""
|
||||
import os
|
||||
logger.info("Analyzing patent %s for %s...", patent_id, company_name)
|
||||
|
||||
patent_path = f"patents/{patent_id}.pdf"
|
||||
|
||||
@@ -188,7 +183,7 @@ class CompanyAnalyzer:
|
||||
|
||||
return {"patent_id": patent.patent_id, "content": minimized_content}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to process %s: %s", patent.patent_id, e)
|
||||
print(f"Warning: Failed to process {patent.patent_id}: {e}")
|
||||
return None
|
||||
|
||||
def _analyze_company_safe(self, company_name: str) -> CompanyAnalysisResult:
|
||||
@@ -259,7 +254,7 @@ class CompanyAnalyzer:
|
||||
results: list[CompanyAnalysisResult] = []
|
||||
total = len(companies)
|
||||
|
||||
logger.info("Starting batch analysis of %d companies...", total)
|
||||
print(f"Starting batch analysis of {total} companies...")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_company = {
|
||||
@@ -276,8 +271,8 @@ class CompanyAnalyzer:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
|
||||
status = "OK" if result.success else "FAIL"
|
||||
logger.info("[%d/%d] %s %s", completed, total, status, company)
|
||||
status = "✓" if result.success else "✗"
|
||||
print(f"[{completed}/{total}] {status} {company}")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(company, completed, total)
|
||||
@@ -292,12 +287,12 @@ class CompanyAnalyzer:
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
logger.error("[%d/%d] FAIL %s: %s", completed, total, company, e)
|
||||
print(f"[{completed}/{total}] ✗ {company}: {e}")
|
||||
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = total - successful
|
||||
|
||||
logger.info("Batch complete: %d succeeded, %d failed", successful, failed)
|
||||
print(f"\nBatch complete: {successful} succeeded, {failed} failed")
|
||||
|
||||
return BatchAnalysisResult(
|
||||
results=results,
|
||||
@@ -323,20 +318,20 @@ class CompanyAnalyzer:
|
||||
results: list[CompanyAnalysisResult] = []
|
||||
total = len(companies)
|
||||
|
||||
logger.info("Starting sequential analysis of %d companies...", total)
|
||||
print(f"Starting sequential analysis of {total} companies...")
|
||||
|
||||
for idx, company in enumerate(companies, 1):
|
||||
logger.info("[%d/%d] Analyzing %s...", idx, total, company)
|
||||
print(f"\n[{idx}/{total}] Analyzing {company}...")
|
||||
result = self._analyze_company_safe(company)
|
||||
results.append(result)
|
||||
|
||||
status = "OK" if result.success else "FAIL"
|
||||
logger.info("[%d/%d] %s %s", idx, total, status, company)
|
||||
status = "✓" if result.success else "✗"
|
||||
print(f"[{idx}/{total}] {status} {company}")
|
||||
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = total - successful
|
||||
|
||||
logger.info("Batch complete: %d succeeded, %d failed", successful, failed)
|
||||
print(f"\nBatch complete: {successful} succeeded, {failed} failed")
|
||||
|
||||
return BatchAnalysisResult(
|
||||
results=results,
|
||||
|
||||
+1
-22
@@ -21,13 +21,11 @@ from SPARC.auth import (
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
check_jwt_secret,
|
||||
close_db_client,
|
||||
create_tokens,
|
||||
decode_token,
|
||||
get_current_admin,
|
||||
get_current_user,
|
||||
get_db_client,
|
||||
init_db_client,
|
||||
)
|
||||
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
|
||||
|
||||
@@ -157,7 +155,6 @@ async def lifespan(app: FastAPI):
|
||||
"""Initialize resources on startup, clean up on shutdown."""
|
||||
global _analyzer
|
||||
check_jwt_secret()
|
||||
init_db_client()
|
||||
_analyzer = CompanyAnalyzer()
|
||||
# Mark any jobs that were running/pending before the restart as failed
|
||||
from SPARC.database import DatabaseClient
|
||||
@@ -170,9 +167,8 @@ async def lifespan(app: FastAPI):
|
||||
logging.getLogger(__name__).warning("Marked %d stale jobs as failed on startup", stale)
|
||||
_db.close()
|
||||
yield
|
||||
# Cleanup
|
||||
# Cleanup if needed
|
||||
_analyzer = None
|
||||
close_db_client()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -519,25 +515,8 @@ def _run_batch_job(job_id: str, companies: list[str], max_workers: int):
|
||||
progress=100,
|
||||
result_json=_json.dumps(batch_response.model_dump(), default=str),
|
||||
)
|
||||
# Fire webhook notification
|
||||
from SPARC.webhooks import notify_job_completed
|
||||
notify_job_completed(
|
||||
job_id=job_id,
|
||||
status="completed",
|
||||
total_companies=result.total_companies,
|
||||
successful=result.successful,
|
||||
failed=result.failed,
|
||||
)
|
||||
except Exception as e:
|
||||
db.update_job(job_id, status="failed", error=str(e))
|
||||
from SPARC.webhooks import notify_job_completed
|
||||
notify_job_completed(
|
||||
job_id=job_id,
|
||||
status="failed",
|
||||
total_companies=len(companies),
|
||||
successful=0,
|
||||
failed=len(companies),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/analyze/batch/async", response_model=JobStatus, tags=["Analysis"])
|
||||
|
||||
+4
-29
@@ -146,36 +146,11 @@ def decode_token(token: str) -> Optional[TokenPayload]:
|
||||
return None
|
||||
|
||||
|
||||
# Shared database client singleton, initialized at startup via init_db_client()
|
||||
_db_client: DatabaseClient | None = None
|
||||
|
||||
|
||||
def init_db_client() -> None:
|
||||
"""Initialize the shared database client. Call once at app startup."""
|
||||
global _db_client
|
||||
_db_client = DatabaseClient(config.database_url)
|
||||
_db_client.connect()
|
||||
|
||||
|
||||
def close_db_client() -> None:
|
||||
"""Close the shared database client. Call at app shutdown."""
|
||||
global _db_client
|
||||
if _db_client:
|
||||
_db_client.close()
|
||||
_db_client = None
|
||||
|
||||
|
||||
def get_db_client() -> DatabaseClient:
|
||||
"""Get the shared pooled database client for auth operations.
|
||||
|
||||
Returns the module-level singleton DatabaseClient. If not yet initialized
|
||||
(e.g., during tests), creates a new instance as a fallback.
|
||||
"""
|
||||
global _db_client
|
||||
if _db_client is None:
|
||||
_db_client = DatabaseClient(config.database_url)
|
||||
_db_client.connect()
|
||||
return _db_client
|
||||
"""Get database client for auth operations."""
|
||||
client = DatabaseClient(config.database_url)
|
||||
client.connect()
|
||||
return client
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
|
||||
@@ -2,20 +2,12 @@
|
||||
|
||||
Loads environment variables from .env file for API keys and other secrets.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Logging configuration
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level, logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
# SerpAPI key for patent search
|
||||
api_key = os.getenv("API_KEY")
|
||||
|
||||
@@ -39,12 +31,6 @@ use_database = os.getenv("USE_DATABASE", "false").lower() in ("true", "1", "yes"
|
||||
patent_search_days = int(os.getenv("PATENT_SEARCH_DAYS", "90"))
|
||||
patent_thread_workers = int(os.getenv("PATENT_THREAD_WORKERS", "5"))
|
||||
|
||||
# LLM model to use via OpenRouter (e.g. "anthropic/claude-3.5-sonnet", "openai/gpt-4o")
|
||||
model = os.getenv("MODEL", "anthropic/claude-3.5-sonnet")
|
||||
|
||||
# SERP cache TTL in hours (how long cached search results are considered fresh)
|
||||
serp_cache_ttl_hours = int(os.getenv("SERP_CACHE_TTL_HOURS", "24"))
|
||||
|
||||
# Root path for running behind a reverse proxy (e.g., "/api" when served at /api/)
|
||||
# This ensures OpenAPI docs work correctly when accessed via the proxy
|
||||
root_path = os.getenv("ROOT_PATH", "")
|
||||
|
||||
+41
-28
@@ -222,6 +222,8 @@ class DatabaseClient:
|
||||
Returns:
|
||||
Cached message dict if found, None otherwise
|
||||
"""
|
||||
self.connect()
|
||||
|
||||
prompt_hash = self.hash_prompt(prompt)
|
||||
|
||||
query = """
|
||||
@@ -244,8 +246,7 @@ class DatabaseClient:
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT 1"
|
||||
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(query, params)
|
||||
result = cursor.fetchone()
|
||||
return dict(result) if result else None
|
||||
@@ -276,10 +277,11 @@ class DatabaseClient:
|
||||
Returns:
|
||||
The ID of the inserted record
|
||||
"""
|
||||
self.connect()
|
||||
|
||||
prompt_hash = self.hash_prompt(prompt)
|
||||
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO llm_messages
|
||||
@@ -301,7 +303,7 @@ class DatabaseClient:
|
||||
)
|
||||
|
||||
message_id = cursor.fetchone()[0]
|
||||
conn.commit()
|
||||
self.conn.commit()
|
||||
|
||||
return message_id
|
||||
|
||||
@@ -323,6 +325,8 @@ class DatabaseClient:
|
||||
Returns:
|
||||
List of message dictionaries
|
||||
"""
|
||||
self.connect()
|
||||
|
||||
query = "SELECT * FROM llm_messages WHERE 1=1"
|
||||
params = []
|
||||
|
||||
@@ -337,8 +341,7 @@ class DatabaseClient:
|
||||
query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
@@ -351,8 +354,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
Dictionary with analytics data
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
# Total messages
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -647,11 +651,12 @@ class DatabaseClient:
|
||||
Returns:
|
||||
Created user dict or None if email exists
|
||||
"""
|
||||
self.connect()
|
||||
|
||||
password_hash = self.hash_password(password)
|
||||
|
||||
try:
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, role)
|
||||
@@ -661,9 +666,10 @@ class DatabaseClient:
|
||||
(email, password_hash, role),
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
conn.commit()
|
||||
self.conn.commit()
|
||||
return dict(user) if user else None
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
self.conn.rollback()
|
||||
return None
|
||||
|
||||
def authenticate_user(self, email: str, password: str) -> Optional[Dict]:
|
||||
@@ -676,8 +682,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
User dict if authenticated, None otherwise
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"SELECT * FROM users WHERE email = %s",
|
||||
(email,),
|
||||
@@ -702,8 +709,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
User dict or None
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, email, role, created_at FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
@@ -720,8 +728,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
User dict or None
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"SELECT id, email, role, created_at FROM users WHERE email = %s",
|
||||
(email,),
|
||||
@@ -739,8 +748,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
List of user dicts
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, email, role, created_at
|
||||
@@ -762,8 +772,9 @@ class DatabaseClient:
|
||||
Returns:
|
||||
Updated user dict or None
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
@@ -774,7 +785,7 @@ class DatabaseClient:
|
||||
(role, user_id),
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
conn.commit()
|
||||
self.conn.commit()
|
||||
return dict(user) if user else None
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
@@ -786,11 +797,12 @@ class DatabaseClient:
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
deleted = cursor.rowcount > 0
|
||||
conn.commit()
|
||||
self.conn.commit()
|
||||
return deleted
|
||||
|
||||
def get_user_count(self) -> int:
|
||||
@@ -799,7 +811,8 @@ class DatabaseClient:
|
||||
Returns:
|
||||
Number of users
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
self.connect()
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM users")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
+7
-6
@@ -1,6 +1,5 @@
|
||||
"""LLM integration for patent analysis using OpenRouter."""
|
||||
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from openai import OpenAI
|
||||
@@ -8,8 +7,6 @@ from openai import OpenAI
|
||||
from SPARC import config
|
||||
from SPARC.database import DatabaseClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMAnalyzer:
|
||||
"""Handles LLM-based analysis of patent content."""
|
||||
@@ -25,7 +22,7 @@ class LLMAnalyzer:
|
||||
"""
|
||||
self.test_mode = test_mode
|
||||
self.use_cache = use_cache if use_cache is not None else config.use_cache
|
||||
self.model = config.model
|
||||
self.model = "anthropic/claude-3.5-sonnet"
|
||||
|
||||
# Always initialize database client for storage and caching
|
||||
self.db_client = DatabaseClient(config.database_url)
|
||||
@@ -64,7 +61,11 @@ Patent Content:
|
||||
Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage."""
|
||||
|
||||
if self.test_mode:
|
||||
logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
|
||||
print("=" * 80)
|
||||
print("TEST MODE - Prompt that would be sent to LLM:")
|
||||
print("=" * 80)
|
||||
print(prompt)
|
||||
print("=" * 80)
|
||||
return "[TEST MODE - No API call made]"
|
||||
|
||||
# Check cache first
|
||||
@@ -166,7 +167,7 @@ Patent Portfolio:
|
||||
Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook."""
|
||||
|
||||
if self.test_mode:
|
||||
logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt)
|
||||
print(prompt)
|
||||
return "[TEST MODE]"
|
||||
|
||||
metadata = {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class Patent:
|
||||
patent_id: str
|
||||
patent_id: int
|
||||
pdf_link: str
|
||||
pdf_path: str | None = None
|
||||
summary: dict | None = None
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Webhook notifications for job completion and alert events.
|
||||
|
||||
Sends JSON payloads to configured webhook URLs with retry logic.
|
||||
Supports generic HTTP POST and Slack-compatible text payloads.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Comma-separated list of webhook URLs (env var based config)
|
||||
_WEBHOOK_URLS_RAW = os.getenv("WEBHOOK_URLS", "")
|
||||
WEBHOOK_URLS: list[str] = [
|
||||
url.strip() for url in _WEBHOOK_URLS_RAW.split(",") if url.strip()
|
||||
]
|
||||
|
||||
MAX_RETRIES = 3
|
||||
BACKOFF_BASE = 2 # seconds
|
||||
|
||||
|
||||
def _is_slack_url(url: str) -> bool:
|
||||
"""Check if a URL looks like a Slack incoming webhook."""
|
||||
return "hooks.slack.com" in url or "discord.com/api/webhooks" in url
|
||||
|
||||
|
||||
def _build_payload(event_type: str, data: dict[str, Any], slack: bool = False) -> dict:
|
||||
"""Build the webhook payload.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "job_completed", "alert")
|
||||
data: Event-specific data
|
||||
slack: If True, wrap in Slack-compatible ``text`` format
|
||||
|
||||
Returns:
|
||||
JSON-serializable payload dict
|
||||
"""
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
**data,
|
||||
}
|
||||
|
||||
if slack:
|
||||
# Build a human-readable summary for Slack/Discord
|
||||
lines = [f"*[SPARC] {event_type}*"]
|
||||
for key, value in data.items():
|
||||
lines.append(f" {key}: {value}")
|
||||
return {"text": "\n".join(lines)}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _send_with_retry(url: str, payload: dict) -> bool:
|
||||
"""Send a POST request with exponential backoff retry.
|
||||
|
||||
Args:
|
||||
url: Webhook URL
|
||||
payload: JSON payload to send
|
||||
|
||||
Returns:
|
||||
True if delivered successfully, False after all retries exhausted
|
||||
"""
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
if response.status_code < 300:
|
||||
logger.debug("Webhook delivered to %s (attempt %d)", url, attempt)
|
||||
return True
|
||||
logger.warning(
|
||||
"Webhook %s returned %d (attempt %d/%d)",
|
||||
url, response.status_code, attempt, MAX_RETRIES,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"Webhook delivery failed for %s (attempt %d/%d): %s",
|
||||
url, attempt, MAX_RETRIES, e,
|
||||
)
|
||||
|
||||
if attempt < MAX_RETRIES:
|
||||
wait = BACKOFF_BASE ** attempt
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("Webhook permanently failed for %s after %d attempts", url, MAX_RETRIES)
|
||||
return False
|
||||
|
||||
|
||||
def notify(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire all configured webhooks for an event.
|
||||
|
||||
Safe to call even when no webhooks are configured (returns immediately).
|
||||
|
||||
Args:
|
||||
event_type: Event identifier (e.g., "job_completed", "patent_alert")
|
||||
data: Event data to include in the payload
|
||||
"""
|
||||
if not WEBHOOK_URLS:
|
||||
return
|
||||
|
||||
for url in WEBHOOK_URLS:
|
||||
slack = _is_slack_url(url)
|
||||
payload = _build_payload(event_type, data, slack=slack)
|
||||
_send_with_retry(url, payload)
|
||||
|
||||
|
||||
def notify_job_completed(
|
||||
job_id: str,
|
||||
status: str,
|
||||
total_companies: int,
|
||||
successful: int,
|
||||
failed: int,
|
||||
) -> None:
|
||||
"""Send notification when a batch job completes."""
|
||||
notify("job_completed", {
|
||||
"job_id": job_id,
|
||||
"status": status,
|
||||
"total_companies": total_companies,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"summary": f"Batch job {job_id}: {successful}/{total_companies} succeeded",
|
||||
})
|
||||
|
||||
|
||||
def notify_alert(
|
||||
company_name: str,
|
||||
alert_type: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Send notification for a tracked company alert."""
|
||||
notify("patent_alert", {
|
||||
"company_name": company_name,
|
||||
"alert_type": alert_type,
|
||||
"message": message,
|
||||
})
|
||||
Reference in New Issue
Block a user