Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
241 lines
9.5 KiB
Python
241 lines
9.5 KiB
Python
from pydantic_settings import BaseSettings
|
||
from pydantic import field_validator
|
||
from typing import Optional
|
||
|
||
|
||
_DEFAULT_SECRET_KEY = "your-secret-key-change-in-production-use-openssl-rand-hex-32"
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
# Application
|
||
APP_NAME: str = "ResolutionFlow"
|
||
DEBUG: bool = False
|
||
API_V1_PREFIX: str = "/api/v1"
|
||
|
||
# Database - Railway provides DATABASE_URL, we convert it for asyncpg
|
||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly"
|
||
|
||
@field_validator("DATABASE_URL", mode="before")
|
||
@classmethod
|
||
def convert_database_url(cls, v: str) -> str:
|
||
"""Convert standard postgres URL to asyncpg format."""
|
||
if v.startswith("postgresql://"):
|
||
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||
return v
|
||
|
||
# Sync URL for Alembic migrations. Defaults to DATABASE_URL (sync-converted).
|
||
# Set explicitly in .env to use a different role for migrations (e.g. superuser)
|
||
# when DATABASE_URL has been switched to the app role.
|
||
DATABASE_URL_SYNC: str = ""
|
||
|
||
@field_validator("DATABASE_URL_SYNC", mode="before")
|
||
@classmethod
|
||
def default_database_url_sync(cls, v: str, info) -> str:
|
||
"""Fall back to sync-converted DATABASE_URL if not explicitly set."""
|
||
if not v:
|
||
base = info.data.get("DATABASE_URL", "")
|
||
return base.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||
return v
|
||
|
||
# Admin database — resolutionflow_admin role, BYPASSRLS.
|
||
# Used by /admin/* endpoints. Defaults to DATABASE_URL for local dev.
|
||
ADMIN_DATABASE_URL: str = ""
|
||
|
||
@field_validator("ADMIN_DATABASE_URL", mode="before")
|
||
@classmethod
|
||
def default_admin_database_url(cls, v: str, info) -> str:
|
||
"""Fall back to DATABASE_URL if ADMIN_DATABASE_URL is not set."""
|
||
if not v:
|
||
return info.data.get("DATABASE_URL", "")
|
||
if v.startswith("postgresql://"):
|
||
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||
return v
|
||
|
||
# JWT Settings
|
||
SECRET_KEY: str = _DEFAULT_SECRET_KEY
|
||
|
||
@field_validator("SECRET_KEY", mode="after")
|
||
@classmethod
|
||
def reject_default_secret_in_production(cls, v: str, info) -> str:
|
||
"""Fail loudly if the default secret key is used outside of DEBUG mode."""
|
||
debug = info.data.get("DEBUG", False)
|
||
if v == _DEFAULT_SECRET_KEY and not debug:
|
||
raise ValueError(
|
||
"SECRET_KEY must be set to a secure value in production. "
|
||
"Generate one with: openssl rand -hex 32"
|
||
)
|
||
return v
|
||
ALGORITHM: str = "HS256"
|
||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 5
|
||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||
|
||
# Security
|
||
BCRYPT_ROUNDS: int = 12
|
||
|
||
# Security Headers
|
||
CSP_REPORT_ONLY: bool = True # Set False to enforce CSP
|
||
CSP_EXTRA_SCRIPT_SOURCES: list[str] = [] # Additional script-src domains
|
||
CSP_EXTRA_CONNECT_SOURCES: list[str] = [] # Additional connect-src domains
|
||
|
||
# Registration
|
||
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration
|
||
|
||
# Email (Resend)
|
||
RESEND_API_KEY: Optional[str] = None
|
||
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
||
FEEDBACK_EMAIL: Optional[str] = None
|
||
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
|
||
|
||
@property
|
||
def email_enabled(self) -> bool:
|
||
"""Check if email sending is configured."""
|
||
return self.RESEND_API_KEY is not None
|
||
|
||
# Stripe
|
||
STRIPE_SECRET_KEY: Optional[str] = None
|
||
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
|
||
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
||
SELF_SERVE_ENABLED: bool = False
|
||
|
||
@property
|
||
def stripe_enabled(self) -> bool:
|
||
"""Check if Stripe is configured."""
|
||
return bool(self.STRIPE_SECRET_KEY)
|
||
|
||
# AI Flow Builder
|
||
ANTHROPIC_API_KEY: Optional[str] = None
|
||
AI_MODEL: str = "claude-sonnet-4-6"
|
||
AI_CONVERSATION_TTL_HOURS: int = 24
|
||
AI_MAX_CALLS_PER_FLOW: int = 10
|
||
AI_REQUEST_TIMEOUT_SECONDS: int = 120
|
||
# AI Provider selection
|
||
AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic"
|
||
GOOGLE_AI_API_KEY: Optional[str] = None
|
||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
|
||
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
|
||
# senior's click — only how long we wait before publishing
|
||
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
|
||
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
|
||
# leaving the magic-moment placeholder permanent. 45s is the right
|
||
# ceiling: well above Sonnet p99 for a 500-token output, far enough
|
||
# below "the senior gives up watching" that we still surface SOMETHING
|
||
# on persistent slowness.
|
||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 45
|
||
|
||
# Model tier routing — maps action types to model tiers
|
||
AI_MODEL_TIERS: dict[str, str] = {
|
||
"fast": "claude-haiku-4-5-20251001",
|
||
"standard": "claude-sonnet-4-6",
|
||
}
|
||
|
||
ACTION_MODEL_MAP: dict[str, str] = {
|
||
"generate_full": "standard",
|
||
"generate_branch": "standard",
|
||
"modify_node": "fast",
|
||
"add_steps": "standard",
|
||
"quick_action": "fast",
|
||
"open_chat": "standard",
|
||
"variable_inference": "fast",
|
||
"kb_convert": "standard",
|
||
"script_build": "standard",
|
||
"network_diagram_generate": "standard",
|
||
# FlowPilot migration Phase 2 — short, latency-sensitive transformation
|
||
# of an engineer's answer/check output into a candidate fact.
|
||
# Doc Section 6.6 sets Haiku as the default; instrumentation tracks
|
||
# disputed_fact_rate so we can escalate to Sonnet if quality drops.
|
||
"fact_synthesis": "fast",
|
||
# FlowPilot migration Phase 3 — resolution-note preview that ships to
|
||
# the customer ticket. Sonnet because customer-facing artifact quality
|
||
# matters more than latency; the in-process state_version cache keeps
|
||
# cost manageable.
|
||
"resolution_note": "standard",
|
||
# FlowPilot migration Phase 4 — escalation handoff package. Parallel
|
||
# to resolution_note: Sonnet, same cache story, no MCP.
|
||
"escalation_package": "standard",
|
||
# FlowPilot migration Phase 5 — extract a parameter schema from a
|
||
# concrete rendered script so a draft_template can be proposed.
|
||
# Creates a persistent library artifact on accept, so Sonnet.
|
||
"template_extraction": "standard",
|
||
}
|
||
|
||
def get_model_for_action(self, action_type: str) -> str:
|
||
"""Resolve an action type to a concrete model name via tier routing."""
|
||
tier = self.ACTION_MODEL_MAP.get(action_type, "standard")
|
||
return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"])
|
||
|
||
# MCP (Model Context Protocol) integrations
|
||
ENABLE_MCP_MICROSOFT_LEARN: bool = True
|
||
|
||
# Embedding / RAG
|
||
VOYAGE_API_KEY: Optional[str] = None
|
||
EMBEDDING_MODEL: str = "voyage-3.5"
|
||
EMBEDDING_DIMENSIONS: int = 1024
|
||
|
||
@property
|
||
def ai_enabled(self) -> bool:
|
||
"""Check if any AI provider is configured."""
|
||
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
|
||
|
||
# Object Storage (Railway S3-compatible)
|
||
STORAGE_ENDPOINT: str | None = None
|
||
STORAGE_ACCESS_KEY: str | None = None
|
||
STORAGE_SECRET_KEY: str | None = None
|
||
STORAGE_BUCKET_NAME: str = "resolutionflow-uploads"
|
||
STORAGE_REGION: str = "us-east-1"
|
||
|
||
# ConnectWise PSA Integration
|
||
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
|
||
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration
|
||
CW_CLIENT_ID: Optional[str] = None
|
||
|
||
@property
|
||
def cw_enabled(self) -> bool:
|
||
"""Check if ConnectWise integration is configured."""
|
||
return self.CW_CLIENT_ID is not None
|
||
|
||
# OAuth providers (self-serve signup)
|
||
GOOGLE_CLIENT_ID: Optional[str] = None
|
||
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||
MS_CLIENT_ID: Optional[str] = None
|
||
MS_CLIENT_SECRET: Optional[str] = None
|
||
OAUTH_REDIRECT_BASE: str = "http://localhost:5173"
|
||
|
||
# Monitoring
|
||
SENTRY_DSN: Optional[str] = None
|
||
|
||
# Deployment – auto-seed test data on PR environments
|
||
SEED_ON_DEPLOY: bool = False
|
||
|
||
# CORS - set FRONTEND_URL in production (e.g., https://patherly.up.railway.app)
|
||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
|
||
FRONTEND_URL: Optional[str] = None
|
||
# Allow all Railway PR environments (set to True in Railway env vars)
|
||
ALLOW_RAILWAY_ORIGINS: bool = False
|
||
|
||
@property
|
||
def allowed_origins(self) -> list[str]:
|
||
"""Get all allowed CORS origins including FRONTEND_URL if set."""
|
||
origins = self.CORS_ORIGINS.copy()
|
||
if self.FRONTEND_URL and self.FRONTEND_URL not in origins:
|
||
origins.append(self.FRONTEND_URL)
|
||
return origins
|
||
|
||
def is_origin_allowed(self, origin: str) -> bool:
|
||
"""Check if an origin is allowed, including Railway wildcard pattern."""
|
||
if origin in self.allowed_origins:
|
||
return True
|
||
# Allow any *.up.railway.app origin for PR environments
|
||
if self.ALLOW_RAILWAY_ORIGINS and origin.endswith(".up.railway.app"):
|
||
return True
|
||
return False
|
||
|
||
class Config:
|
||
env_file = ".env"
|
||
case_sensitive = True
|
||
extra = "ignore" # Ignore extra env vars like DATABASE_URL_SYNC
|
||
|
||
|
||
settings = Settings()
|