Bundles four fixes from the live debugging session: 1. AssistantChatPage: replace urlSessionId === activeChatId gate with a loadedChatIdsRef. After8914391made activeChatId initialize from urlSessionId, the gate short-circuited fresh mounts and selectChat never fired. Symptom: senior picks up an escalation, lands on a blank chat surface with no conversation history and no sidebar entry. Fix also adds loadChats() in handleStartHere so the picked-up session appears in the sidebar (its escalated_to_id is null pre-claim, so listSessions doesn't return it until claim_session sets it). 2. config: bump ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS 15s → 45s. Sonnet was hitting tail latency at 15s in the field, leaving the magic-moment placeholder permanent. Background-task architecture (e8ba74e) means this no longer blocks the user; it's just the budget before publishing has_assessment=false. NOTE: live test still shows assessment not populating — see HANDOFF for the consolidation plan that supersedes this. 3. Enter-to-submit: chat-input convention (Enter submits, Shift+Enter inserts newline) on the escalate-flow forms. RichTextInput gains an optional onSubmit prop; EscalateModal wires it to handleSubmit; ConcludeSessionModal gets the same handler on its plain textarea. 4. PendingEscalations: each row is now expandable. Click row body to reveal the engineer's escalation reason, step count on record, confidence tier, and PSA ticket number. Pick Up still clicks through directly. Single-expand-at-a-time keeps the dashboard compact. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
232 lines
9.1 KiB
Python
232 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"
|
||
# 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
|
||
|
||
# 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()
|