Files
resolutionflow/backend/app/core/config.py
Michael Chihlas aca915b047
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m6s
CI / backend (pull_request) Successful in 9m45s
CI / e2e (pull_request) Successful in 10m20s
fix(escalations): bump assessment timeout, surface picked-up sessions in sidebar
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>
2026-04-28 00:04:08 -04:00

230 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()