Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
146 lines
5.6 KiB
Python
146 lines
5.6 KiB
Python
"""
|
||
配置管理
|
||
统一从项目根目录的 .env 文件加载配置
|
||
"""
|
||
|
||
import os
|
||
from dotenv import load_dotenv
|
||
|
||
# 加载项目根目录的 .env 文件
|
||
# 路径: CrowdSight/.env (相对于 backend/app/config.py)
|
||
project_root_env = os.path.join(os.path.dirname(__file__), '../../.env')
|
||
|
||
if os.path.exists(project_root_env):
|
||
load_dotenv(project_root_env, override=True)
|
||
else:
|
||
# 如果根目录没有 .env,尝试加载环境变量(用于生产环境)
|
||
load_dotenv(override=True)
|
||
|
||
|
||
def _resolve_llm_config() -> tuple[str, str, str | None]:
|
||
"""
|
||
解析LLM配置。
|
||
|
||
优先级:
|
||
1. 如果设置了 LLM_PROVIDER,使用提供商预设填充 base_url 和 model(但可被显式值覆盖)
|
||
2. 否则使用 LLM_BASE_URL / LLM_MODEL_NAME(兼容原有行为)
|
||
|
||
Returns:
|
||
(base_url, model_name, provider_name_or_none)
|
||
"""
|
||
from .providers import get_provider
|
||
|
||
provider_name = os.environ.get('LLM_PROVIDER', '').strip()
|
||
explicit_base_url = os.environ.get('LLM_BASE_URL', '').strip()
|
||
explicit_model = os.environ.get('LLM_MODEL_NAME', '').strip()
|
||
|
||
if provider_name:
|
||
preset = get_provider(provider_name)
|
||
if preset is None:
|
||
provider_names = ["openai", "deepseek", "xiaomi_mimo", "alibaba_dashscope", "minimax"]
|
||
available = ", ".join(provider_names)
|
||
raise ValueError(
|
||
f"未知的 LLM_PROVIDER: '{provider_name}'. "
|
||
f"可用值: {available}"
|
||
)
|
||
# 显式值优先于预设默认值
|
||
base_url = explicit_base_url or preset.base_url
|
||
model = explicit_model or preset.default_model
|
||
return base_url, model, provider_name
|
||
else:
|
||
# 兼容原有行为:无 LLM_PROVIDER 时直接使用显式值
|
||
base_url = explicit_base_url or 'https://api.openai.com/v1'
|
||
model = explicit_model or 'gpt-4o-mini'
|
||
return base_url, model, None
|
||
|
||
|
||
# 在模块加载时解析配置(避免重复计算)
|
||
_llm_base_url, _llm_model_name, _llm_provider = _resolve_llm_config()
|
||
|
||
|
||
class Config:
|
||
"""Flask配置类"""
|
||
|
||
# Flask configuration: production-safe defaults; secrets must be supplied.
|
||
SECRET_KEY = os.environ.get('SECRET_KEY')
|
||
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||
# Session cookies must be Secure in production; local HTTP can opt out explicitly.
|
||
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
|
||
CORS_ALLOWED_ORIGINS = [
|
||
origin.strip()
|
||
for origin in os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
|
||
if origin.strip()
|
||
]
|
||
|
||
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
||
JSON_AS_ASCII = False
|
||
|
||
# LLM配置(统一使用OpenAI格式)
|
||
LLM_API_KEY = os.environ.get('LLM_API_KEY')
|
||
LLM_PROVIDER = _llm_provider # e.g. "deepseek" or None
|
||
LLM_BASE_URL = _llm_base_url
|
||
LLM_MODEL_NAME = _llm_model_name
|
||
|
||
# Memory backend migration switch: keep Zep as the explicit default until data parity/cutover is complete.
|
||
MEMORY_BACKEND = os.environ.get('MEMORY_BACKEND', 'zep').strip().lower()
|
||
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
|
||
|
||
# 文件上传配置
|
||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
||
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')
|
||
ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'}
|
||
|
||
# 文本处理配置
|
||
DEFAULT_CHUNK_SIZE = 500 # 默认切块大小
|
||
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
|
||
|
||
# OASIS模拟配置
|
||
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
|
||
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
|
||
|
||
# OASIS平台可用动作配置
|
||
OASIS_TWITTER_ACTIONS = [
|
||
'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'
|
||
]
|
||
OASIS_REDDIT_ACTIONS = [
|
||
'LIKE_POST', 'DISLIKE_POST', 'CREATE_POST', 'CREATE_COMMENT',
|
||
'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',
|
||
'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'
|
||
]
|
||
|
||
# Report Agent配置
|
||
REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))
|
||
REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))
|
||
REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))
|
||
|
||
@classmethod
|
||
def validate(cls) -> list[str]:
|
||
"""验证必要配置"""
|
||
errors: list[str] = []
|
||
if not cls.SECRET_KEY:
|
||
errors.append("SECRET_KEY not configured")
|
||
if not cls.LLM_API_KEY:
|
||
errors.append("LLM_API_KEY not configured")
|
||
if cls.MEMORY_BACKEND not in {"zep", "local"}:
|
||
errors.append("MEMORY_BACKEND must be zep or local")
|
||
elif cls.MEMORY_BACKEND == "zep" and not cls.ZEP_API_KEY:
|
||
errors.append("ZEP_API_KEY not configured")
|
||
return errors
|
||
|
||
@classmethod
|
||
def get_active_provider_info(cls) -> dict:
|
||
"""返回当前活跃的LLM提供商信息(用于日志/API展示)"""
|
||
from .providers import get_provider
|
||
|
||
info = {
|
||
"provider": cls.LLM_PROVIDER or "custom",
|
||
"base_url": cls.LLM_BASE_URL,
|
||
"model": cls.LLM_MODEL_NAME,
|
||
}
|
||
if cls.LLM_PROVIDER:
|
||
preset = get_provider(cls.LLM_PROVIDER)
|
||
if preset:
|
||
info["display_name"] = preset.display_name
|
||
info["notes"] = preset.notes
|
||
return info
|