Two field-reported issues from live wedge testing. ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS bumped 5s → 15s. The 5s bound fired too aggressively against the Sonnet diagnostic assessment prompt; ~4-8s is typical but tail latency hits 12-14s. The fallback "Assessment unavailable — model didn't respond in time" placeholder was showing on the magic-moment screen for two consecutive escalations, which kills the demo. 15s keeps the click-path bounded but lets the typical case return real content. Real fix is async generation (kick off, persist when done, surface "still computing" with refresh) — captured as a follow-up; bumping the bound is the right call for the wedge demo. list_sessions now matches escalated_to_id == current_user.id alongside the existing user_id and escalation_package.picked_up_by clauses. The unified HandoffManager.claim_session sets escalated_to_id but doesn't write the legacy picked_up_by JSONB key, so picked-up sessions never showed in the senior's chat list — the senior would land on the session detail (active chat) but the sidebar showed only their other unrelated sessions. User reported this as "4 different versions of the session in the chat history section" — they were actually 4 unrelated empty sessions the senior owned, plus the picked-up session was just invisible. Backend tests still 94/94. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
230 lines
9.1 KiB
Python
230 lines
9.1 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
|
||
|
||
@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
|
||
|
||
@property
|
||
def stripe_enabled(self) -> bool:
|
||
"""Check if Stripe is configured."""
|
||
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
|
||
|
||
# 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"
|
||
# 15s is generous for the click-path; Claude usually returns a 500-token
|
||
# diagnostic in 4-8s but tail latency on the assessment prompt has hit
|
||
# 12-14s in the field. Going below this leaves too many escalations with
|
||
# the "Assessment unavailable — model didn't respond in time" placeholder
|
||
# the senior sees on the magic-moment screen. Real fix is async generation
|
||
# (kick off, persist when done, surface "still computing" with refresh) —
|
||
# that's a follow-up; bumping the bound keeps the wedge demo coherent.
|
||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 15
|
||
|
||
# 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
|
||
|
||
# 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()
|