Spaces:
Sleeping
Sleeping
File size: 17,429 Bytes
3f0377e ed6edd9 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e 79df050 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e ed6edd9 3f0377e 79df050 3f0377e 79df050 3f0377e 79df050 3f0377e 79df050 3f0377e 79df050 3f0377e 79df050 3f0377e 55c453c 26b9faa 3f0377e fd8dbb5 3f0377e fd8dbb5 3f0377e 79df050 fd8dbb5 3f0377e 26b9faa fd8dbb5 26b9faa fd8dbb5 26b9faa fd8dbb5 3f0377e fd8dbb5 ed6edd9 fd8dbb5 79df050 fd8dbb5 79df050 fd8dbb5 3f0377e c25e1a2 3f0377e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | """
Bloom Ware 統一配置管理中心
所有環境變數與敏感資訊的單一真理來源(Single Source of Truth)
"""
import os
import json
import base64
from typing import Optional, Dict, Any
from dotenv import load_dotenv
# 載入 .env 檔案(僅開發環境需要,Render 會自動注入環境變數)
load_dotenv()
class Settings:
"""統一配置管理中心"""
# ===== 環境檢測 =====
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
IS_PRODUCTION: bool = ENVIRONMENT == "production"
# ===== Firebase 配置 =====
FIREBASE_PROJECT_ID: str = os.getenv("FIREBASE_PROJECT_ID", "")
# Firebase 憑證:支援三種方式
_firebase_creds_json: Optional[str] = os.getenv("FIREBASE_CREDENTIALS_JSON")
_firebase_creds_base64: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON_BASE64")
_firebase_service_account_path: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH")
# Google Cloud Speech / TTS 專用服務帳戶(可與 Firebase 不同 GCP 專案)
_google_speech_creds_json: Optional[str] = os.getenv("GOOGLE_SPEECH_CREDENTIALS_JSON")
_google_speech_creds_base64: Optional[str] = os.getenv("GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64")
_google_speech_sa_path: Optional[str] = os.getenv("GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH")
@classmethod
def get_firebase_credentials(cls) -> Dict[str, Any]:
"""
取得 Firebase 憑證
優先順序:
1. 環境變數 FIREBASE_CREDENTIALS_JSON(生產環境,JSON 字串)
2. 環境變數 FIREBASE_SERVICE_ACCOUNT_JSON_BASE64(base64 編碼的 JSON)
3. 檔案路徑 FIREBASE_SERVICE_ACCOUNT_PATH(開發環境)
Returns:
dict: Firebase Service Account 憑證字典
Raises:
ValueError: 當所有方式都未設定時
"""
# 方式 1: 直接 JSON 字串
if cls._firebase_creds_json:
try:
return json.loads(cls._firebase_creds_json)
except json.JSONDecodeError as e:
raise ValueError(f"FIREBASE_CREDENTIALS_JSON 格式錯誤: {e}")
# 方式 2: Base64 編碼的 JSON
elif cls._firebase_creds_base64:
try:
decoded_bytes = base64.b64decode(cls._firebase_creds_base64)
decoded_str = decoded_bytes.decode('utf-8')
return json.loads(decoded_str)
except Exception as e:
raise ValueError(f"FIREBASE_SERVICE_ACCOUNT_JSON_BASE64 解碼失敗: {e}")
# 方式 3: 從檔案讀取
elif cls._firebase_service_account_path:
try:
with open(cls._firebase_service_account_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
raise ValueError(f"Firebase 憑證檔案不存在: {cls._firebase_service_account_path}")
except json.JSONDecodeError as e:
raise ValueError(f"Firebase 憑證檔案格式錯誤: {e}")
# 三種方式都沒設定
else:
raise ValueError(
"Firebase 憑證未設定!\n"
"請設定以下其中一項:\n"
"1. FIREBASE_CREDENTIALS_JSON(JSON 字串)\n"
"2. FIREBASE_SERVICE_ACCOUNT_JSON_BASE64(base64 編碼)\n"
"3. FIREBASE_SERVICE_ACCOUNT_PATH(檔案路徑)"
)
@classmethod
def try_get_google_speech_credentials(cls) -> Optional[Dict[str, Any]]:
"""
載入 STT/TTS 專用 Google 服務帳戶 JSON(與 Firebase 分離)。
若三種來源皆未設定,回傳 None;若已設定但格式錯誤則拋出 ValueError。
"""
if cls._google_speech_creds_json:
try:
return json.loads(cls._google_speech_creds_json)
except json.JSONDecodeError as e:
raise ValueError(f"GOOGLE_SPEECH_CREDENTIALS_JSON 格式錯誤: {e}") from e
if cls._google_speech_creds_base64:
try:
decoded_bytes = base64.b64decode(cls._google_speech_creds_base64)
return json.loads(decoded_bytes.decode("utf-8"))
except Exception as e:
raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64 解碼失敗: {e}") from e
if cls._google_speech_sa_path:
try:
with open(cls._google_speech_sa_path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH 檔案不存在: {cls._google_speech_sa_path}") from None
except json.JSONDecodeError as e:
raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH JSON 格式錯誤: {e}") from e
return None
@classmethod
def resolve_speech_service_account_info(cls) -> tuple[Optional[Dict[str, Any]], str]:
"""
解析語音 API 使用的服務帳戶:優先 GOOGLE_SPEECH_*,否則退回 Firebase 憑證(相容舊部署)。
Returns:
(credentials_dict | None, "speech" | "firebase" | "none")
"""
speech = cls.try_get_google_speech_credentials()
if speech is not None:
return speech, "speech"
try:
return cls.get_firebase_credentials(), "firebase"
except ValueError:
return None, "none"
@classmethod
def get_google_speech_project_id(cls, credential_project_id: Optional[str] = None) -> str:
"""
Speech-to-Text recognizer 所屬 GCP 專案 ID。
優先順序:GOOGLE_SPEECH_PROJECT_ID → GOOGLE_CLOUD_PROJECT_ID(若為純數字「專案編號」
且憑證 JSON 內有字串型 project_id,則改用憑證內 ID,避免誤用編號)→
憑證 JSON 內 project_id → FIREBASE_PROJECT_ID
"""
if cls.GOOGLE_SPEECH_PROJECT_ID.strip():
return cls.GOOGLE_SPEECH_PROJECT_ID.strip()
cloud = cls.GOOGLE_CLOUD_PROJECT_ID.strip()
cred = (credential_project_id or "").strip()
if cloud.isdigit() and cred and not cred.isdigit():
return cred
if cloud:
return cloud
if cred:
return cred
return cls.FIREBASE_PROJECT_ID.strip()
# ===== OpenAI 配置 =====
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
OPENAI_BASE_URL: str = os.getenv("OPENAI_BASE_URL", "")
OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-5.4")
OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "30"))
OPENAI_RESPONSES_TIMEOUT: int = int(os.getenv("OPENAI_RESPONSES_TIMEOUT", "90"))
OPENAI_USE_RESPONSES: bool = os.getenv("OPENAI_USE_RESPONSES", "true").lower() == "true"
OPENAI_MODEL_CONTEXT_WINDOW: int = int(os.getenv("OPENAI_MODEL_CONTEXT_WINDOW", "1000000"))
OPENAI_MODEL_AUTO_COMPACT_TOKEN_LIMIT: int = int(os.getenv("OPENAI_MODEL_AUTO_COMPACT_TOKEN_LIMIT", "900000"))
OPENAI_ENABLE_WEB_SEARCH: bool = os.getenv("OPENAI_ENABLE_WEB_SEARCH", "true").lower() == "true"
OPENAI_ENABLE_REMOTE_MCP: bool = os.getenv("OPENAI_ENABLE_REMOTE_MCP", "false").lower() == "true"
OPENAI_REMOTE_MCP_SERVERS_JSON: str = os.getenv("OPENAI_REMOTE_MCP_SERVERS_JSON", "[]")
OPENAI_ENABLE_SKILLS: bool = os.getenv("OPENAI_ENABLE_SKILLS", "false").lower() == "true"
# ===== Google OAuth(使用者「登入 Bloom Ware」用,非語音 API)=====
GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "")
GOOGLE_REDIRECT_URI: str = os.getenv(
"GOOGLE_REDIRECT_URI",
"http://localhost:8080/auth/google/callback" # 開發環境預設值
)
# ----- Google Cloud「語音」專案(STT/TTS,例:supervisor-project;常與 Firebase 不同)-----
# GOOGLE_CLOUD_PROJECT_ID:語音相關 REST/專案語境之預設專案 ID(請填「專案 ID」字串,勿只填控制台「專案編號」)
GOOGLE_CLOUD_PROJECT_ID: str = os.getenv("GOOGLE_CLOUD_PROJECT_ID", os.getenv("FIREBASE_PROJECT_ID", ""))
# GOOGLE_SPEECH_PROJECT_ID:明確指定 STT recognizer 所屬專案;與 Firebase 分離時必須搭配 GOOGLE_SPEECH_* 服務帳戶
GOOGLE_SPEECH_PROJECT_ID: str = os.getenv("GOOGLE_SPEECH_PROJECT_ID", "")
# STT gRPC 臨時除錯用;正式環境請用服務帳戶
GOOGLE_STT_ACCESS_TOKEN: str = os.getenv("GOOGLE_STT_ACCESS_TOKEN", "")
# TTS 與部分 REST 用 API Key(屬於語音 GCP;與 STT 串流 gRPC OAuth 分開)
GOOGLE_SPEECH_API_KEY: str = os.getenv("GOOGLE_SPEECH_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
GOOGLE_TTS_API_KEY: str = os.getenv("GOOGLE_TTS_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
GOOGLE_STT_LOCATION: str = os.getenv("GOOGLE_STT_LOCATION", "global")
GOOGLE_STT_RECOGNIZER_ID: str = os.getenv("GOOGLE_STT_RECOGNIZER_ID", "_")
GOOGLE_STT_AUTO_LANGUAGE_CODES: str = os.getenv("GOOGLE_STT_AUTO_LANGUAGE_CODES", "cmn-Hant-TW,en-US,ja-JP")
GOOGLE_TTS_LANGUAGE_CODE: str = os.getenv("GOOGLE_TTS_LANGUAGE_CODE", "cmn-TW")
GOOGLE_TTS_DEFAULT_VOICE: str = os.getenv("GOOGLE_TTS_DEFAULT_VOICE", "cmn-TW-Wavenet-A")
# ===== 第三方 API Keys =====
WEATHER_API_KEY: str = os.getenv("WEATHER_API_KEY", "")
TAVILY_API_KEY: str = os.getenv("TAVILY_API_KEY", "")
EXCHANGE_API_KEY: str = os.getenv("EXCHANGE_API_KEY", "")
# ===== JWT 認證配置 =====
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
# ===== 伺服器配置 =====
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8080")) # Render 會自動設為 10000
# ===== GPT 意圖檢測配置 =====
USE_GPT_INTENT: bool = os.getenv("USE_GPT_INTENT", "true").lower() == "true"
GPT_INTENT_MODEL: str = os.getenv("GPT_INTENT_MODEL", "gpt-5.4")
# ===== 背景任務開關 =====
ENABLE_BACKGROUND_JOBS: bool = os.getenv("ENABLE_BACKGROUND_JOBS", "true").lower() == "true"
# ===== 環境感知參數 =====
ENV_CONTEXT_DISTANCE_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_DISTANCE_THRESHOLD", "100"))
ENV_CONTEXT_HEADING_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_HEADING_THRESHOLD", "25"))
ENV_CONTEXT_TTL_SECONDS: float = float(os.getenv("ENV_CONTEXT_TTL_SECONDS", "300"))
# ===== CORS 安全設定 =====
# 生產環境應設定具體的允許來源,多個來源用逗號分隔
# 例如:CORS_ORIGINS=https://example.com,https://app.example.com
_cors_origins_raw: str = os.getenv("CORS_ORIGINS", "*")
@classmethod
def get_cors_origins(cls) -> list:
"""取得 CORS 允許的來源列表"""
if cls._cors_origins_raw == "*":
return ["*"]
return [origin.strip() for origin in cls._cors_origins_raw.split(",") if origin.strip()]
# ===== 安全性設定 =====
# 登入失敗封鎖閾值
FAILED_LOGIN_THRESHOLD: int = int(os.getenv("FAILED_LOGIN_THRESHOLD", "5"))
# 封鎖時間(秒)
LOGIN_BLOCK_DURATION: int = int(os.getenv("LOGIN_BLOCK_DURATION", "900")) # 15 分鐘
# JWT Secret 最小長度
JWT_SECRET_MIN_LENGTH: int = 32
# ===== 效能調優常數 =====
# WebSocket 會話超時(秒)
WEBSOCKET_SESSION_TIMEOUT: int = int(os.getenv("WEBSOCKET_SESSION_TIMEOUT", "1800")) # 30 分鐘
# 定期清理間隔(秒)
CLEANUP_INTERVAL: int = int(os.getenv("CLEANUP_INTERVAL", "1800")) # 30 分鐘
# 記憶重要性閾值
MEMORY_IMPORTANCE_THRESHOLD: float = float(os.getenv("MEMORY_IMPORTANCE_THRESHOLD", "0.6"))
# 意圖快取 TTL(秒)
INTENT_CACHE_TTL: int = int(os.getenv("INTENT_CACHE_TTL", "300")) # 5 分鐘
# 對話歷史載入限制
CHAT_HISTORY_LIMIT: int = int(os.getenv("CHAT_HISTORY_LIMIT", "12"))
# 關懷模式對話歷史限制
CARE_MODE_HISTORY_LIMIT: int = int(os.getenv("CARE_MODE_HISTORY_LIMIT", "3"))
@classmethod
def validate(cls) -> bool:
"""
驗證必要配置是否已設定
Returns:
bool: 所有必要配置是否完整
"""
required_fields = [
("FIREBASE_PROJECT_ID", cls.FIREBASE_PROJECT_ID),
("OPENAI_API_KEY", cls.OPENAI_API_KEY),
("GOOGLE_CLIENT_ID", cls.GOOGLE_CLIENT_ID),
("GOOGLE_CLIENT_SECRET", cls.GOOGLE_CLIENT_SECRET),
("JWT_SECRET_KEY", cls.JWT_SECRET_KEY),
]
missing_fields = [name for name, value in required_fields if not value]
if missing_fields:
import logging
logger = logging.getLogger("core.config")
logger.error(f"⚠️ 缺少必要環境變數: {', '.join(missing_fields)}")
logger.error("請檢查以下選項:")
logger.error("1. 環境變數是否正確設定")
logger.error("2. .env 檔案是否存在且格式正確")
logger.error("3. 生產環境中是否在部署平台設定了環境變數")
return False
# 驗證 Firebase 憑證
try:
cls.get_firebase_credentials()
except ValueError as e:
import logging
logger = logging.getLogger("core.config")
logger.error(f"⚠️ Firebase 憑證驗證失敗: {e}")
logger.error("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH")
return False
# 驗證 OpenAI API Key 格式(OpenAI-compatible relay keys may not use sk-*)
if not cls.OPENAI_BASE_URL and not cls.OPENAI_API_KEY.startswith("sk-"):
import logging
logger = logging.getLogger("core.config")
logger.warning("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
# 驗證 JWT Secret 長度(強制檢查)
if len(cls.JWT_SECRET_KEY) < cls.JWT_SECRET_MIN_LENGTH:
import logging
logger = logging.getLogger("core.config")
logger.error(f"❌ JWT Secret Key 長度必須至少 {cls.JWT_SECRET_MIN_LENGTH} 個字符")
if cls.IS_PRODUCTION:
return False
logger.warning("⚠️ 開發環境允許繼續,但生產環境將拒絕啟動")
# 生產環境 CORS 檢查
if cls.IS_PRODUCTION and cls._cors_origins_raw == "*":
import logging
logger = logging.getLogger("core.config")
logger.warning("⚠️ 生產環境建議設定具體的 CORS_ORIGINS,而非 '*'")
return True
@classmethod
def print_summary(cls) -> None:
"""列印當前配置摘要(隱藏敏感資訊)"""
import logging
logger = logging.getLogger("core.config")
logger.info("\n" + "=" * 60)
logger.info("📋 Bloom Ware 配置摘要")
logger.info("=" * 60)
logger.info(f"環境模式: {cls.ENVIRONMENT}")
logger.info(f"是否為生產環境: {cls.IS_PRODUCTION}")
logger.info(f"Firebase 專案 ID: {cls.FIREBASE_PROJECT_ID}")
# 判斷 Firebase 憑證來源
if cls._firebase_creds_json:
firebase_source = "環境變數 (JSON)"
elif cls._firebase_creds_base64:
firebase_source = "環境變數 (Base64)"
elif cls._firebase_service_account_path:
firebase_source = "檔案"
else:
firebase_source = "未設定 ❌"
logger.info(f"Firebase 憑證來源: {firebase_source}")
logger.info(f"OpenAI 模型: {cls.OPENAI_MODEL}")
logger.info(f"OpenAI Base URL: {cls.OPENAI_BASE_URL or 'default'}")
logger.info(f"OpenAI Responses API: {'enabled' if cls.OPENAI_USE_RESPONSES else 'disabled'}")
logger.info(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s")
logger.info(f"OpenAI Responses Timeout: {cls.OPENAI_RESPONSES_TIMEOUT}s")
logger.info(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}")
logger.info(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘")
logger.info(f"伺服器監聽: {cls.HOST}:{cls.PORT}")
logger.info(f"使用 GPT 意圖檢測: {cls.USE_GPT_INTENT}")
logger.info(f"Weather API Key: {'已設定 ✅' if cls.WEATHER_API_KEY else '未設定 ❌'}")
logger.info(f"NewsData API Key: {'已設定 ✅' if cls.NEWSDATA_API_KEY else '未設定 ❌'}")
logger.info(f"Exchange API Key: {'已設定 ✅' if cls.EXCHANGE_API_KEY else '未設定 ❌'}")
logger.info(f"環境節流距離: {cls.ENV_CONTEXT_DISTANCE_THRESHOLD} m")
logger.info(f"環境節流方位差: {cls.ENV_CONTEXT_HEADING_THRESHOLD}°")
logger.info(f"環境快取 TTL: {cls.ENV_CONTEXT_TTL_SECONDS} 秒")
logger.info("=" * 60 + "\n")
# 建立全域設定實例(單例模式)
settings = Settings()
# 啟動時驗證配置(僅在非測試環境)
if __name__ != "__main__":
import logging
logger = logging.getLogger("core.config")
if not settings.validate():
logger.warning("⚠️ 配置驗證失敗,部分功能可能無法正常運作")
# 開發環境下列印配置摘要
if not settings.IS_PRODUCTION and os.getenv("BLOOMWARE_SHOW_CONFIG", "false").lower() == "true":
settings.print_summary()
|