Files
microfish/backend/app/config.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

146 lines
5.6 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.
"""
配置管理
统一从项目根目录的 .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