Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Centralized application configuration.
|
||||
|
||||
Every environment-dependent value is read here, once, via pydantic-settings.
|
||||
Application code should depend on `get_settings()`, never on `os.environ`
|
||||
directly - that's what keeps provider selection (LLM/search/notifications/auth)
|
||||
swappable from a single place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# --- App ---
|
||||
app_env: Literal["development", "test", "production"] = "development"
|
||||
app_name: str = "CI Agent"
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
backend_url: str = "http://localhost:8000"
|
||||
|
||||
# --- Reverse proxy ---
|
||||
# Empty (default) = trust only the direct TCP connection for client-IP
|
||||
# resolution (app.core.security.get_client_ip) - correct today, no proxy
|
||||
# exists. Set to "CF-Connecting-IP" once deployed behind Cloudflare's
|
||||
# proxy so IP-based localhost detection and the ban/throttle system read
|
||||
# the real visitor IP instead of the proxy's own address. Only ever set
|
||||
# this when it's actually known a trusted proxy sits in front and strips
|
||||
# this header from untrusted clients - see KNOWN_LIMITATIONS.md.
|
||||
trusted_proxy_ip_header: str = ""
|
||||
|
||||
# --- Local-dev convenience ---
|
||||
# Comma-separated extra IPs that `is_localhost` treats as equivalent to
|
||||
# real loopback, on top of 127.0.0.1/::1. Needed because Docker
|
||||
# Desktop's bridge networking means even traffic that originates on the
|
||||
# host machine itself (dev tooling driving a browser against
|
||||
# localhost:3000/8000) arrives at the container from the bridge
|
||||
# gateway address, not literal loopback - so without this,
|
||||
# is_localhost is always false for that traffic, which both re-exposes
|
||||
# the admin-only API-keys/logs boxes' loopback gate and forces the
|
||||
# Turnstile widget to render (a real crash risk for automated browser
|
||||
# tooling - see KNOWN_LIMITATIONS.md). Empty by default (no bypass);
|
||||
# only ever populate this with IPs you know are your own dev host,
|
||||
# never in a real deployment.
|
||||
additional_trusted_local_ips: str = ""
|
||||
|
||||
# --- Auth ---
|
||||
auth_mode: Literal["local", "jwt"] = "local"
|
||||
jwt_secret: str = "dev-only-change-me-32-characters-minimum"
|
||||
jwt_access_token_minutes: int = 15
|
||||
jwt_refresh_token_days: int = 7
|
||||
# Encrypts each user's own stored API keys at rest (app/core/crypto.py)
|
||||
# - a Fernet key (44-char urlsafe-base64). This dev-only default is
|
||||
# fixed/insecure by design (same precedent as jwt_secret above); a real
|
||||
# deployment must set its own via `Fernet.generate_key()`. Rotating
|
||||
# this value makes every already-stored user key undecryptable, so
|
||||
# treat it like any other production secret - never regenerate it
|
||||
# casually once real keys exist.
|
||||
api_key_encryption_secret: str = "_wYtsm3nJ070987snBFp2eWVI5pyC0H9gGFUb6Cy4cQ="
|
||||
|
||||
# --- Database ---
|
||||
database_url: str = "sqlite+aiosqlite:///./ciagent_dev.db"
|
||||
|
||||
# --- Redis / Celery ---
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
celery_task_always_eager: bool = False
|
||||
|
||||
# --- LLM ---
|
||||
llm_provider: Literal["mock", "anthropic", "ollama", "gemini"] = "mock"
|
||||
anthropic_api_key: str = ""
|
||||
anthropic_model: str = "claude-sonnet-5"
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "llama3.1"
|
||||
# Gemini has an actual free rate-limited tier (unlike OpenAI's expiring
|
||||
# trial credits), so it's the production option this app ships wired up.
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-2.0-flash"
|
||||
llm_max_tokens_per_request: int = 4000
|
||||
llm_max_retries: int = 2
|
||||
|
||||
# --- Search ---
|
||||
search_provider: Literal["mock", "brave"] = "mock"
|
||||
brave_search_api_key: str = ""
|
||||
serpapi_api_key: str = ""
|
||||
bing_search_api_key: str = ""
|
||||
|
||||
# --- Patents ---
|
||||
# Free key via account registration at data.uspto.gov/apis/getting-started.
|
||||
# Unset by default - PatentSourceCollector falls back to its existing
|
||||
# honest disabled/fixture behavior when this is empty.
|
||||
uspto_api_key: str = ""
|
||||
|
||||
# --- Company enrichment (NinjaPear / nubela.co) ---
|
||||
# Paid, per-credit API - unset by default. Enrichment only ever fires
|
||||
# once, at company-creation time (never on a recurring schedule), and
|
||||
# the enqueue itself is skipped entirely when this is empty - see
|
||||
# company_service.create_company.
|
||||
ninjapear_api_key: str = ""
|
||||
ninjapear_max_leadership_lookups: int = 5
|
||||
|
||||
# --- Email ---
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_email: str = "[email protected]"
|
||||
smtp_use_tls: bool = False
|
||||
|
||||
# --- Resend (transactional security email: verification/reset/lockout) ---
|
||||
# Unset by default - security_email_service falls back to the SMTP
|
||||
# provider above (Mailpit locally) when this is empty, so the whole
|
||||
# verification/reset flow is testable with zero Resend account needed.
|
||||
# Deliberately separate from the alert-notification path (smtp_from_email
|
||||
# above) - a different sender identity for account-security mail.
|
||||
resend_api_key: str = ""
|
||||
resend_security_from_email: str = "[email protected]"
|
||||
|
||||
# --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) ---
|
||||
# Unset by default - skipped entirely for register/login/password-reset
|
||||
# when either the caller is on loopback (see is_localhost) or no secret
|
||||
# is configured, matching this app's usual optional-provider convention.
|
||||
turnstile_site_key: str = ""
|
||||
turnstile_secret: str = ""
|
||||
|
||||
# --- SMS ---
|
||||
notification_sms_enabled: bool = False
|
||||
sms_provider: Literal["twilio", "telnyx"] = "twilio"
|
||||
twilio_account_sid: str = ""
|
||||
twilio_auth_token: str = ""
|
||||
twilio_from_number: str = ""
|
||||
telnyx_api_key: str = ""
|
||||
telnyx_from_number: str = ""
|
||||
sms_monthly_cap: int = 50
|
||||
|
||||
# --- GitHub ---
|
||||
github_token: str = ""
|
||||
|
||||
# --- Scheduling ---
|
||||
default_timezone: str = "America/New_York"
|
||||
default_monitoring_frequency: str = "weekly"
|
||||
minimum_monitoring_interval_minutes: int = 60
|
||||
|
||||
# --- Scraper ---
|
||||
scraper_user_agent: str = "CIAgentBot/1.0 (+https://ci-agent.local/bot)"
|
||||
max_pages_per_domain: int = 25
|
||||
scraper_request_timeout_seconds: int = 30
|
||||
scraper_domain_delay_seconds: float = 2.0
|
||||
|
||||
# --- Cost controls ---
|
||||
max_companies_per_user: int = 25
|
||||
max_manual_runs_per_day: int = 10
|
||||
|
||||
# --- Retention / logging ---
|
||||
data_retention_days: int = 365
|
||||
log_level: str = "INFO"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _forbid_local_auth_in_production(self) -> Settings:
|
||||
if self.app_env == "production" and self.auth_mode == "local":
|
||||
raise ValueError(
|
||||
"AUTH_MODE=local is a development convenience and must not be used "
|
||||
"when APP_ENV=production. Set AUTH_MODE=jwt."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
Reference in New Issue
Block a user