Files
meida_backend/packages/shared/config.py
T
xsl 7bcbb8d40b
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
- agent: ag_conversation/ag_message 表(迁移0007),上下文组装注入最近报告画像摘要
  (report.profile.latest_profile_summary 经 apps 层闭包注入,同层不互调),历史带最近
  AGENT_HISTORY_TURNS=10 轮;POST/GET /agent/conversations 与消息历史接口
- agent 修复:ChatRequest.conversation_id 改 str(雪花ID超2^53,JS Number丢精度);
  _build_context 挪进 reserve commit 后同一 try,异常走 refund 兜底
- report: rp_feedback(维度反馈 upsert,认同率/修正率 SQL 可算,T-P3-21 最小版——
  L1 记忆未建,不回写结论)+ rp_reservation(预约留资幂等,T-P3-22,预约率口径
  行数/full_report.card_view 去重曝光,门槛≥8%),迁移0008
- 测试:全量 606 passed;ruff/mypy/lint-imports 全绿;E2E 真实 deepseek-v4-flash 验证
  画像注入与历史携带
2026-09-11 21:50:58 +08:00

295 lines
12 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.
"""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 天
# --- Agent 对话(FR-AGT-09)--------------------------------------------
# 对话上下文携带的最近轮数(一轮 = user + assistant 两条),默认 10。
agent_history_turns: int = 10
# --- 任务奖励(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"
# PDF 导出落盘目录(相对进程 cwd;worker 与 API 同 cwd 启动,两边解析一致)。
# rp_report.rendered_asset_key 存的是含该目录前缀的相对路径。
report_export_dir: str = "var/exports"
# --- 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()