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 " 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()