第四批(T15 编排/T16 视觉诊断/T17 文案校验)协调提交: - 迁移 0006:rp_report(result JSONB 按阶段键承载中间态)/rp_input (derived_features 视觉中间态)/rp_share(V1 只建结构) - ORM 三模型;错误码 40420/40920(文案无价格字样) - 配置:report_pipeline_stages(dotted path 动态装配)+ vision/tier - packages/report/pipeline.py:Stage Protocol + ReportContext(三任务共同契约, 动态 import 的动机与代价写在 docstring) - 路由桩 reports.py 已注册
288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""Typed application configuration.
|
||
|
||
Every value comes from the environment (架构文档 §11.2: 密钥不进代码/镜像).
|
||
`.env` is only a developer convenience; dev/staging/prod inject via SSM/KMS.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from decimal import Decimal
|
||
from functools import lru_cache
|
||
from typing import Any, Literal
|
||
|
||
from pydantic import Field, ValidationInfo, field_validator
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
Environment = Literal["local", "dev", "staging", "prod"]
|
||
CachePrefixMode = Literal["strict", "warn", "off"]
|
||
|
||
_PLACEHOLDER_JWT_SECRET = "change-me-local-only-do-not-use-outside-local"
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
"""Root settings object. Read it through :func:`get_settings`."""
|
||
|
||
model_config = SettingsConfigDict(
|
||
env_file=".env",
|
||
env_file_encoding="utf-8",
|
||
extra="ignore",
|
||
case_sensitive=False,
|
||
)
|
||
|
||
# --- Application -------------------------------------------------------
|
||
app_env: Environment = "local"
|
||
app_name: str = "meida-backend"
|
||
api_prefix: str = "/api/v1"
|
||
debug: bool = False
|
||
log_level: str = "INFO"
|
||
log_json: bool = True
|
||
log_extra_redact_keys: str = ""
|
||
|
||
# --- Database ----------------------------------------------------------
|
||
database_url: str = "postgresql+asyncpg://meida:meida@localhost:55432/meida"
|
||
db_pool_size: int = 10
|
||
db_max_overflow: int = 20
|
||
db_echo: bool = False
|
||
|
||
# --- Redis / Celery ----------------------------------------------------
|
||
redis_url: str = "redis://localhost:56379/0"
|
||
celery_broker_url: str = "redis://localhost:56379/1"
|
||
celery_result_backend: str = "redis://localhost:56379/2"
|
||
|
||
# --- Auth --------------------------------------------------------------
|
||
jwt_secret: str = _PLACEHOLDER_JWT_SECRET
|
||
jwt_algorithm: str = "HS256"
|
||
jwt_access_ttl_seconds: int = 900
|
||
jwt_issuer: str = "meida"
|
||
dev_auth_enabled: bool = True
|
||
|
||
# --- LLM routing -------------------------------------------------------
|
||
llm_primary_provider: str = "deepseek"
|
||
llm_backup_provider: str = "zhipu"
|
||
llm_structured_max_retries: int = 2
|
||
llm_circuit_failure_threshold: int = 3
|
||
llm_circuit_cooldown_seconds: float = 30.0
|
||
llm_scene_overrides: dict[str, dict[str, Any]] = Field(
|
||
default_factory=dict, alias="LLM_SCENE_OVERRIDES_JSON"
|
||
)
|
||
|
||
# --- DeepSeek (primary, OpenAI-compatible) -----------------------------
|
||
deepseek_api_key: str = ""
|
||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||
deepseek_model: str = "deepseek-v4-flash"
|
||
deepseek_timeout_seconds: float = 30.0
|
||
|
||
# --- 智谱 GLM (backup slot, not implemented in V1) ---------------------
|
||
zhipu_api_key: str = ""
|
||
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
||
zhipu_model: str = "glm-4-flash"
|
||
zhipu_timeout_seconds: float = 30.0
|
||
|
||
# --- Prompt caching discipline (架构文档 §7.2) -------------------------
|
||
llm_cache_prefix_mode: CachePrefixMode = "strict"
|
||
prompts_dir: str = "prompts"
|
||
|
||
# --- Cost attribution (架构文档 §13) -----------------------------------
|
||
llm_cost_rates: dict[str, dict[str, Decimal]] = Field(
|
||
default_factory=dict, alias="LLM_COST_RATES_JSON"
|
||
)
|
||
# DeepSeek 自 2026-08-17 起峰谷定价,高峰单价是空闲时段的 2 倍
|
||
# (高峰:北京时间 09:00-12:00、14:00-18:00)。留空则两个时段同价。
|
||
llm_cost_rates_peak: dict[str, dict[str, Decimal]] = Field(
|
||
default_factory=dict, alias="LLM_COST_RATES_PEAK_JSON"
|
||
)
|
||
llm_peak_pricing_enabled: bool = True
|
||
llm_default_cost_rate: dict[str, Decimal] = Field(
|
||
default_factory=lambda: {
|
||
"input_cents_per_mtok": Decimal("100"),
|
||
"cached_input_cents_per_mtok": Decimal("20"),
|
||
"output_cents_per_mtok": Decimal("200"),
|
||
},
|
||
alias="LLM_DEFAULT_COST_RATE_JSON",
|
||
)
|
||
|
||
# --- Global daily cost circuit breaker ---------------------------------
|
||
llm_daily_budget_enabled: bool = True
|
||
llm_daily_budget_cents: Decimal = Decimal("100000")
|
||
llm_budget_timezone_offset_hours: int = 8
|
||
# 四档分级降级(架构 §13.3,T-P1-25b)。JSON 为列表:
|
||
# [{"threshold":0.8,"name":"degrade_secondary","blocked":["hd_export"],...}]
|
||
# blocked 支持 "*"; allow 仅 block_all 档有意义(纯文本对话放行)。
|
||
llm_budget_tiers: list[dict[str, Any]] = Field(
|
||
default_factory=lambda: [
|
||
{"threshold": 0.6, "name": "warn", "blocked": []},
|
||
{
|
||
"threshold": 0.8,
|
||
"name": "degrade_secondary",
|
||
"blocked": ["hd_export", "report_regen"],
|
||
},
|
||
{"threshold": 0.9, "name": "degrade_generation", "blocked": ["hairstyle", "tryon"]},
|
||
{"threshold": 1.0, "name": "block_all", "blocked": "*", "allow": ["agent_chat"]},
|
||
],
|
||
alias="LLM_BUDGET_TIERS_JSON",
|
||
)
|
||
|
||
# --- Auth: refresh sessions (FR-ACC-05) --------------------------------
|
||
refresh_token_ttl_seconds: int = 2_592_000 # 30 天
|
||
|
||
# --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
|
||
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。
|
||
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
|
||
task_bonus_rules: dict[str, dict[str, Any]] = Field(
|
||
default_factory=lambda: {
|
||
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"},
|
||
"profile_complete": {
|
||
"quota_key": "agent_chat",
|
||
"amount": 1,
|
||
"claim_period": "lifetime",
|
||
},
|
||
"report_feedback": {"quota_key": "report_regen", "amount": 1, "claim_period": "day"},
|
||
"share_report": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
|
||
"invite_friend": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
|
||
},
|
||
alias="TASK_BONUS_RULES_JSON",
|
||
)
|
||
|
||
# --- 客户端埋点(FR-SYS-06)---------------------------------------------
|
||
events_max_batch: int = 50
|
||
events_props_max_bytes: int = 8192
|
||
|
||
# --- 腾讯云 COS 直传(FR-SYS-03 / T-P1-27)------------------------------
|
||
cos_region: str = "ap-shanghai"
|
||
cos_secret_id: str = ""
|
||
cos_secret_key: str = ""
|
||
cos_bucket_temp: str = "meida-temp" # 临时桶,2 小时生命周期(CR-02)
|
||
cos_bucket_user: str = "meida-user"
|
||
cos_bucket_kb: str = "meida-kb"
|
||
cos_bucket_report: str = "meida-report"
|
||
cos_presign_expire_seconds: int = 600
|
||
upload_max_bytes: int = 20_971_520 # 20MB
|
||
upload_allowed_types: str = "image/jpeg,image/png,image/webp"
|
||
# scene → {"bucket": "temp"|"user"|"kb"|"report"}。新增上传场景只改这里。
|
||
upload_scenes: dict[str, dict[str, Any]] = Field(
|
||
default_factory=lambda: {
|
||
"report_photo": {"bucket": "temp"},
|
||
"avatar": {"bucket": "temp"},
|
||
},
|
||
alias="UPLOAD_SCENES_JSON",
|
||
)
|
||
|
||
# --- 知识库与检索(架构 §8.3,TD-08)--------------------------------------
|
||
kb_vector_dimensions: int = 1024
|
||
# 当前默认 = 确定性假实现;真实 BGE-M3 ONNX 是后续任务,届时只换这里。
|
||
kb_embedding_model: str = "deterministic-v1"
|
||
kb_embedding_device: str = "cpu"
|
||
retrieval_top_k: int = 50
|
||
# 重排权重(架构 §8.3 唯一口径):0.45 向量 + 0.2 关键词 + 0.25 规则 + 0.1 新鲜度。
|
||
retrieval_rerank_weights: dict[str, float] = Field(
|
||
default_factory=lambda: {"vector": 0.45, "keyword": 0.2, "rule": 0.25, "freshness": 0.1},
|
||
alias="RETRIEVAL_RERANK_WEIGHTS_JSON",
|
||
)
|
||
|
||
# --- 华为账号登录(FR-ACC-01;联调待 AGC 配置,见《前置开通清单》§3)------
|
||
huawei_jwks_url: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/certs"
|
||
huawei_audience: str = "" # = AGC App ID;空配置时验签必须拒绝而不是跳过
|
||
huawei_issuer: str = "https://accounts.huawei.com"
|
||
huawei_jwks_cache_seconds: int = 86400
|
||
|
||
# --- 手机号验证码登录(FR-ACC-04/14,TD-32)--------------------------------
|
||
# fake = 验证码打日志(dev);tencent = 真实发送(待签名审核通过后切换)
|
||
sms_provider: str = "fake"
|
||
tencent_sms_secret_id: str = ""
|
||
tencent_sms_secret_key: str = ""
|
||
tencent_sms_sdk_app_id: str = ""
|
||
tencent_sms_sign_name: str = ""
|
||
tencent_sms_template_id: str = ""
|
||
sms_code_ttl_seconds: int = 300
|
||
sms_code_length: int = 6
|
||
sms_verify_max_attempts: int = 5
|
||
sms_resend_cooldown_seconds: int = 60
|
||
sms_hourly_limit: int = 5
|
||
sms_daily_limit: int = 10
|
||
sms_ip_daily_limit: int = 20
|
||
sms_device_daily_limit: int = 10
|
||
|
||
# --- 极简运营后台(T-P2-09)----------------------------------------------
|
||
# 空字符串 = 后台全部端点 404(等价于未部署)。V1 极简鉴权:共享令牌,
|
||
# 后续接运营账号体系时替换。令牌只进 .env / SSM,绝不进代码。
|
||
admin_token: str = ""
|
||
|
||
# --- 检索结果缓存(T-P2-10,T14 实现背后的配置)--------------------------
|
||
retrieval_cache_enabled: bool = True
|
||
retrieval_cache_ttl_seconds: int = 60
|
||
retrieval_cache_max_entries: int = 512
|
||
|
||
# --- 美学报告流水线(T-P3,第四批)---------------------------------------
|
||
# 阶段按 dotted path 动态装配(importlib):T15 编排、T16 供 vision/diagnosis、
|
||
# T17 供 copy/validation——三个任务互不改对方文件,靠这份清单衔接。
|
||
# 缺失模块/类在装配时报 CONFIG_ERROR 并列出缺失项,绝不静默跳过。
|
||
report_pipeline_stages: list[str] = Field(
|
||
default_factory=lambda: [
|
||
"packages.report.vision_stage:VisionStage",
|
||
"packages.report.diagnosis_stage:DiagnosisStage",
|
||
"packages.report.copy_stage:CopyStage",
|
||
"packages.report.validation_stage:ValidationStage",
|
||
],
|
||
alias="REPORT_PIPELINE_STAGES_JSON",
|
||
)
|
||
# 视觉提供者:deterministic = V1 骨架的确定性假实现(真实 RTMPose/BiRefNet
|
||
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
|
||
report_vision_provider: str = "deterministic"
|
||
report_tier: str = "basic"
|
||
|
||
# --- Observability -----------------------------------------------------
|
||
otel_enabled: bool = False
|
||
otel_service_name: str = "meida-api"
|
||
otel_exporter_otlp_endpoint: str = ""
|
||
prometheus_enabled: bool = True
|
||
|
||
@field_validator(
|
||
"llm_scene_overrides",
|
||
"llm_cost_rates",
|
||
"llm_cost_rates_peak",
|
||
"llm_default_cost_rate",
|
||
"llm_budget_tiers",
|
||
"task_bonus_rules",
|
||
"upload_scenes",
|
||
"retrieval_rerank_weights",
|
||
"report_pipeline_stages",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _parse_json_object(cls, value: Any) -> Any:
|
||
if isinstance(value, str):
|
||
stripped = value.strip()
|
||
if not stripped:
|
||
return {}
|
||
return json.loads(stripped)
|
||
return value
|
||
|
||
@field_validator("jwt_secret")
|
||
@classmethod
|
||
def _reject_placeholder_secret_outside_local(cls, value: str, info: ValidationInfo) -> str:
|
||
env = info.data.get("app_env", "local")
|
||
if env in ("staging", "prod") and (value == _PLACEHOLDER_JWT_SECRET or len(value) < 32):
|
||
msg = "JWT_SECRET must be set to a >=32 char random value outside local/dev"
|
||
raise ValueError(msg)
|
||
return value
|
||
|
||
@property
|
||
def is_local(self) -> bool:
|
||
return self.app_env in ("local", "dev")
|
||
|
||
@property
|
||
def extra_redact_keys(self) -> tuple[str, ...]:
|
||
return tuple(k.strip() for k in self.log_extra_redact_keys.split(",") if k.strip())
|
||
|
||
@property
|
||
def sync_database_url(self) -> str:
|
||
"""psycopg-style URL, used by tooling that cannot speak asyncpg."""
|
||
return self.database_url.replace("+asyncpg", "")
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def get_settings() -> Settings:
|
||
"""Process-wide settings singleton."""
|
||
return Settings()
|