fix(llm): price by call time and stop paying for thinking tokens
Two defects that only surface against the real DeepSeek API, both of which break the cost model rather than the code: deepseek-v4-flash enables thinking mode by default. The same single-turn question took 13.5s with it on and 1.8s with it off, and the thinking tokens are billed at the output rate. That misses NFR-PERF-02b (15s per turn) and inflates every conversational call. SceneConfig now carries an explicit `thinking` flag, all four V1 scenes set it to false, and the provider always sends the parameter rather than relying on the upstream default. Reasoning tokens are recorded separately in Usage.tokens_reasoning so the cost of ever turning it back on is visible. DeepSeek moves to peak/off-peak pricing on 2026-08-17, with peak (Beijing 09:00-12:00 and 14:00-18:00) at twice the off-peak rate. Reports are generated mostly during peak hours, so a single blended rate would leave ob_ai_call -- the only input to V2 pricing -- off by nearly a factor of two. PeakAwareRateTable resolves the rate at call time; cost is computed and stored then, never recomputed later at whatever the current rate happens to be. Also here: - Replace the live API key in the redaction test with a synthetic one. The test that proves we redact secrets was itself a secret leak waiting for its first commit. - Correct LLM_COST_RATES_JSON, which held placeholder rates. - Make ids.SnowflakeGenerator take an injectable clock so rollover and clock-rollback are testable without patching a slots class. - Rename exceptions to the -Error suffix (N818) and clear the remaining ruff, mypy and import-linter findings, including two dead lines: a duplicate pytestmark assignment and a no-op assert on a None return. Gates: ruff, mypy (96 files incl. tests), 4/4 import contracts, detect-secrets, 166 unit + 3 integration tests, migration up/down round-trip.
This commit is contained in:
+47
-51
@@ -1,85 +1,81 @@
|
||||
# =============================================================================
|
||||
# 美搭 backend — environment template
|
||||
# =============================================================================
|
||||
# Copy to `.env` and fill in real values. `.env` is git-ignored and MUST NEVER
|
||||
# be committed (架构文档 §11.2: 密钥全部走 SSM/KMS,禁止进代码与镜像).
|
||||
# In dev/staging/prod these variables are injected by SSM/KMS, not by a file.
|
||||
# Every variable below is documented; NO real values appear in this file.
|
||||
# =============================================================================
|
||||
# 复制为 .env 后填入真实值。.env 已在 .gitignore 中,不要提交。
|
||||
# dev/staging/prod 由部署平台注入环境变量,不使用 .env(架构文档 §11.2)。
|
||||
|
||||
# --- Application ------------------------------------------------------------
|
||||
APP_ENV=local # local | dev | staging | prod (架构文档 §14.2)
|
||||
APP_ENV=local
|
||||
APP_NAME=meida-backend
|
||||
API_PREFIX=/api/v1 # 架构文档 §9.1 基础路径;/v1/* 作为别名同样可用
|
||||
API_PREFIX=/api/v1
|
||||
DEBUG=true
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
LOG_JSON=true # false => human-readable console renderer
|
||||
LOG_LEVEL=INFO
|
||||
LOG_JSON=true
|
||||
# 需要额外脱敏的字段名,逗号分隔;默认清单见 packages/shared/logging.py
|
||||
LOG_EXTRA_REDACT_KEYS=
|
||||
|
||||
# --- Database (PostgreSQL 16 + pgvector) ------------------------------------
|
||||
# Async driver is required (SQLAlchemy 2.0 asyncio + asyncpg).
|
||||
# ── 数据库 / Redis ─────────────────────────────────────────────────────────
|
||||
# 端口刻意用 55432 / 56379,避免与本机已有的 PostgreSQL、Redis 抢端口
|
||||
DATABASE_URL=postgresql+asyncpg://meida:meida@localhost:55432/meida
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_ECHO=false
|
||||
|
||||
# --- Redis 7 (cache / quota counters / celery broker) -----------------------
|
||||
REDIS_URL=redis://localhost:56379/0
|
||||
CELERY_BROKER_URL=redis://localhost:56379/1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:56379/2
|
||||
|
||||
# --- Auth (架构文档 §9.1: JWT access 15min + 可撤销 refresh) ----------------
|
||||
JWT_SECRET= # REQUIRED. >=32 random chars. Generate: openssl rand -hex 32
|
||||
# ── 鉴权 ──────────────────────────────────────────────────────────────────
|
||||
# 生成:openssl rand -hex 32 。staging/prod 若沿用占位值会启动失败
|
||||
JWT_SECRET=change-me-local-only-do-not-use-outside-local
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TTL_SECONDS=900 # 15 minutes
|
||||
JWT_ACCESS_TTL_SECONDS=900
|
||||
JWT_ISSUER=meida
|
||||
DEV_AUTH_ENABLED=true # MUST be false outside local/dev: enables POST /auth/dev-token
|
||||
# 免登录换 token 的调试入口,仅本地/联调可开
|
||||
DEV_AUTH_ENABLED=true
|
||||
|
||||
# --- LLM gateway: routing (架构文档 §8.1) -----------------------------------
|
||||
LLM_PRIMARY_PROVIDER=deepseek # DeepSeek 为主 (TD-04)
|
||||
LLM_BACKUP_PROVIDER=zhipu # 智谱 GLM 为备(V1 未实现,仅结构占位)
|
||||
LLM_STRUCTURED_MAX_RETRIES=2 # 结构化输出校验失败的重试次数
|
||||
LLM_CIRCUIT_FAILURE_THRESHOLD=3 # 连续失败达到该值即熔断
|
||||
LLM_CIRCUIT_COOLDOWN_SECONDS=30 # 熔断冷却时长,之后半开探测
|
||||
# JSON 值一律用单引号包住:scripts/smoke.sh 会 `source .env`,
|
||||
# 裸的双引号会被 bash 剥掉,JSON 就此损坏。
|
||||
LLM_SCENE_OVERRIDES_JSON='{}' # 场景路由覆盖,JSON: {"agent_chat":{"max_tokens":900}}
|
||||
# ── LLM 路由 ──────────────────────────────────────────────────────────────
|
||||
LLM_PRIMARY_PROVIDER=deepseek
|
||||
LLM_BACKUP_PROVIDER=zhipu
|
||||
LLM_STRUCTURED_MAX_RETRIES=2
|
||||
LLM_CIRCUIT_FAILURE_THRESHOLD=3
|
||||
LLM_CIRCUIT_COOLDOWN_SECONDS=30
|
||||
# 按场景覆盖模型与采样参数,如 {"agent_chat":{"temperature":0.5}}
|
||||
LLM_SCENE_OVERRIDES_JSON={}
|
||||
|
||||
# --- LLM gateway: DeepSeek (OpenAI-compatible) ------------------------------
|
||||
DEEPSEEK_API_KEY= # REQUIRED. sk-... Never commit.
|
||||
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash # 默认模型,可用: deepseek-v4-flash / deepseek-v4-pro
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
DEEPSEEK_TIMEOUT_SECONDS=30
|
||||
|
||||
# --- LLM gateway: 智谱 GLM (backup, not implemented in V1) ------------------
|
||||
ZHIPU_API_KEY=
|
||||
ZHIPU_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
ZHIPU_MODEL=glm-4-flash
|
||||
ZHIPU_TIMEOUT_SECONDS=30
|
||||
|
||||
# --- LLM gateway: prompt caching discipline (架构文档 §7.2 缓存前缀纪律) ----
|
||||
# strict => 固定前缀含时间戳/随机ID/昵称时直接拒绝调用
|
||||
# warn => 只记录告警日志;off => 关闭(不建议)
|
||||
# ── Prompt 缓存纪律 ───────────────────────────────────────────────────────
|
||||
# strict:固定前缀里出现时间戳/随机值等易变 token 直接启动失败。
|
||||
# 命中缓存的输入价格只有未命中的 1/30,前缀被污染会让输入成本翻数十倍。
|
||||
LLM_CACHE_PREFIX_MODE=strict
|
||||
PROMPTS_DIR=prompts
|
||||
|
||||
# --- LLM gateway: 成本归因与费率 (架构文档 §13, FR-BILL-08) -----------------
|
||||
# 单位:分/百万 token(cents per 1M tokens)。上线前必须以供应商实际价目表校准。
|
||||
LLM_COST_RATES_JSON='{"deepseek-v4-flash":{"input_cents_per_mtok":"14","cached_input_cents_per_mtok":"2","output_cents_per_mtok":"28"},"deepseek-v4-pro":{"input_cents_per_mtok":"200","cached_input_cents_per_mtok":"20","output_cents_per_mtok":"800"}}'
|
||||
LLM_DEFAULT_COST_RATE_JSON='{"input_cents_per_mtok":"100","cached_input_cents_per_mtok":"20","output_cents_per_mtok":"200"}'
|
||||
# ── 成本单价(单位:分 / 百万 token)──────────────────────────────────────
|
||||
# 取自 DeepSeek 官方价目表。2026-08-17 00:00 起改为峰谷定价:
|
||||
# 高峰(北京时间 09:00-12:00、14:00-18:00)单价是空闲时段的 2 倍。
|
||||
# 因此这里要配两套,由 packages/llm/cost.py 按调用时刻选择。
|
||||
# flash 空闲:缓存命中 ¥0.05 / 输入 ¥1.5 / 输出 ¥4.5 每百万 token
|
||||
LLM_COST_RATES_JSON={"deepseek-v4-flash":{"input_cents_per_mtok":"150","cached_input_cents_per_mtok":"5","output_cents_per_mtok":"450"},"deepseek-v4-pro":{"input_cents_per_mtok":"450","cached_input_cents_per_mtok":"15","output_cents_per_mtok":"1350"},"glm-4-flash":{"input_cents_per_mtok":"10","cached_input_cents_per_mtok":"10","output_cents_per_mtok":"10"}}
|
||||
# flash 高峰:缓存命中 ¥0.10 / 输入 ¥3 / 输出 ¥9 每百万 token
|
||||
LLM_COST_RATES_PEAK_JSON={"deepseek-v4-flash":{"input_cents_per_mtok":"300","cached_input_cents_per_mtok":"10","output_cents_per_mtok":"900"},"deepseek-v4-pro":{"input_cents_per_mtok":"900","cached_input_cents_per_mtok":"30","output_cents_per_mtok":"2700"},"glm-4-flash":{"input_cents_per_mtok":"10","cached_input_cents_per_mtok":"10","output_cents_per_mtok":"10"}}
|
||||
# 未登记的模型按这个价算。刻意取高档:宁可高估触发熔断,也不要低估成本形成盲区
|
||||
LLM_DEFAULT_COST_RATE_JSON={"input_cents_per_mtok":"300","cached_input_cents_per_mtok":"10","output_cents_per_mtok":"900"}
|
||||
LLM_PEAK_PRICING_ENABLED=true
|
||||
|
||||
# --- LLM gateway: 全局日成本熔断 (架构文档 §13 三道成本闸门) ----------------
|
||||
# 达到阈值后,非关键场景的 AI 调用被直接拒绝/降级,而不仅仅是告警。
|
||||
# ── 全局日成本熔断(NFR-COST-02)─────────────────────────────────────────
|
||||
# 按用户配额封不住总成本(总成本 = 用户数 × 人均,用户数无上限),
|
||||
# 真正封顶的只有这个全局开关。1000_00 分 = ¥1000/天
|
||||
LLM_DAILY_BUDGET_ENABLED=true
|
||||
LLM_DAILY_BUDGET_CENTS=100000 # 1000 元/天
|
||||
LLM_BUDGET_TIMEZONE_OFFSET_HOURS=8 # 结算日按 UTC+8 (FR-BILL-01)
|
||||
LLM_DAILY_BUDGET_CENTS=100000
|
||||
LLM_BUDGET_TIMEZONE_OFFSET_HOURS=8
|
||||
|
||||
# --- Observability (架构文档 §15) -------------------------------------------
|
||||
# ── 可观测性 ──────────────────────────────────────────────────────────────
|
||||
OTEL_ENABLED=false
|
||||
OTEL_SERVICE_NAME=meida-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT= # e.g. http://localhost:4318
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
PROMETHEUS_ENABLED=true
|
||||
|
||||
# --- Logging redaction (NFR-SEC-03) -----------------------------------------
|
||||
# Extra log field names to redact, comma separated. Built-in list already covers
|
||||
# phone / token / union_id / open_id / avatar_key / face image keys / diagnosis.
|
||||
LOG_EXTRA_REDACT_KEYS=
|
||||
|
||||
+12
-20
@@ -1,41 +1,33 @@
|
||||
# --- SECRETS: never commit. The real DeepSeek key lives in .env only. ---
|
||||
# ── 密钥与本地配置:只提交 .env.example ────────────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.p12
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# Python
|
||||
# ── Python ────────────────────────────────────────────────────────────────
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Tooling caches
|
||||
# ── 测试与工具缓存 ────────────────────────────────────────────────────────
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
.importlinter_cache/
|
||||
|
||||
# Local runtime artefacts
|
||||
*.log
|
||||
.pgdata/
|
||||
.redisdata/
|
||||
.scratch/
|
||||
htmlcov/
|
||||
.smoke/
|
||||
|
||||
# Editors / OS
|
||||
# ── 编辑器与系统 ──────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
logs/
|
||||
tmp/
|
||||
|
||||
+231
-42
@@ -1,48 +1,237 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{"name": "ArtifactoryDetector"},
|
||||
{"name": "AWSKeyDetector"},
|
||||
{"name": "AzureStorageKeyDetector"},
|
||||
{"name": "Base64HighEntropyString", "limit": 4.5},
|
||||
{"name": "BasicAuthDetector"},
|
||||
{"name": "CloudantDetector"},
|
||||
{"name": "DiscordBotTokenDetector"},
|
||||
{"name": "GitHubTokenDetector"},
|
||||
{"name": "GitLabTokenDetector"},
|
||||
{"name": "HexHighEntropyString", "limit": 3.0},
|
||||
{"name": "IbmCloudIamDetector"},
|
||||
{"name": "IbmCosHmacDetector"},
|
||||
{"name": "IPPublicDetector"},
|
||||
{"name": "JwtTokenDetector"},
|
||||
{"name": "KeywordDetector", "keyword_exclude": ""},
|
||||
{"name": "MailchimpDetector"},
|
||||
{"name": "NpmDetector"},
|
||||
{"name": "OpenAIDetector"},
|
||||
{"name": "PrivateKeyDetector"},
|
||||
{"name": "PypiTokenDetector"},
|
||||
{"name": "SendGridDetector"},
|
||||
{"name": "SlackDetector"},
|
||||
{"name": "SoftlayerDetector"},
|
||||
{"name": "SquareOAuthDetector"},
|
||||
{"name": "StripeDetector"},
|
||||
{"name": "TelegramBotTokenDetector"},
|
||||
{"name": "TwilioKeyDetector"}
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{"path": "detect_secrets.filters.allowlist.is_line_allowlisted"},
|
||||
{"path": "detect_secrets.filters.common.is_baseline_file"},
|
||||
{"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2},
|
||||
{"path": "detect_secrets.filters.heuristic.is_indirect_reference"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_likely_id_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_lock_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_potential_uuid"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_sequential_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_swagger_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_templated_secret"}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-08-13T00:00:00Z"
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".secrets.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".env.example": [
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": ".env.example",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 15
|
||||
}
|
||||
],
|
||||
".github/workflows/ci.yml": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/ci.yml",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/ci.yml",
|
||||
"hashed_secret": "0454753c5b5d78d5b1602b790450c2c4722e109c",
|
||||
"is_verified": false,
|
||||
"line_number": 47
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": ".github/workflows/ci.yml",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 48
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/ci.yml",
|
||||
"hashed_secret": "291cae9c7abc43bb36c3fcec27775787977ecbe7",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
}
|
||||
],
|
||||
"deploy/docker-compose.dev.yml": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deploy/docker-compose.dev.yml",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 14
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "deploy/docker-compose.dev.yml",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 51
|
||||
}
|
||||
],
|
||||
"packages/shared/config.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "packages/shared/config.py",
|
||||
"hashed_secret": "9b0fa95ea79b52c1ed98ded789d79eaca61144db",
|
||||
"is_verified": false,
|
||||
"line_number": 20
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "packages/shared/config.py",
|
||||
"hashed_secret": "5b0c72b82d7a4f2d7021527f86ab0bed290b9604",
|
||||
"is_verified": false,
|
||||
"line_number": 43
|
||||
}
|
||||
],
|
||||
"tests/conftest.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/conftest.py",
|
||||
"hashed_secret": "54391aaa964c71a817e17e42a523ffbe7c75f0b6",
|
||||
"is_verified": false,
|
||||
"line_number": 20
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/conftest.py",
|
||||
"hashed_secret": "cf2f6537b575e33aa209a982172c4253835eee83",
|
||||
"is_verified": false,
|
||||
"line_number": 21
|
||||
}
|
||||
],
|
||||
"tests/unit/test_deepseek_provider.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/unit/test_deepseek_provider.py",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 42
|
||||
}
|
||||
],
|
||||
"tests/unit/test_log_redaction.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/unit/test_log_redaction.py",
|
||||
"hashed_secret": "7ce6ed3727ece51d2219f2bdc7f6db3594dc4d7f",
|
||||
"is_verified": false,
|
||||
"line_number": 49
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-08-13T16:53:15Z"
|
||||
}
|
||||
|
||||
@@ -1,76 +1,79 @@
|
||||
.DEFAULT_GOAL := help
|
||||
SHELL := /bin/bash
|
||||
.PHONY: help install up down logs migrate downgrade revision run worker \
|
||||
test test-unit test-integration lint fmt typecheck imports secrets check smoke psql redis-cli
|
||||
|
||||
UV := $(shell command -v uv 2>/dev/null)
|
||||
COMPOSE := docker compose -f deploy/docker-compose.dev.yml
|
||||
|
||||
ifdef UV
|
||||
RUN := uv run
|
||||
else
|
||||
RUN := .venv/bin/python -m
|
||||
endif
|
||||
help: ## 列出所有目标
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: help install lint fmt typecheck importlint test test-unit migrate makemigration up down dev worker smoke secrets clean
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: ## Create the venv and install runtime + dev dependencies
|
||||
ifdef UV
|
||||
# ── 环境 ────────────────────────────────────────────────────────────────────
|
||||
install: ## 安装依赖(uv 管理,含开发依赖)
|
||||
uv sync --extra dev
|
||||
uv run pre-commit install || true
|
||||
else
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -e ".[dev]"
|
||||
.venv/bin/pre-commit install || true
|
||||
endif
|
||||
uv run pre-commit install
|
||||
|
||||
lint: ## ruff check + ruff format --check
|
||||
up: ## 起 PostgreSQL(pgvector) + Redis
|
||||
$(COMPOSE) up -d postgres redis
|
||||
|
||||
down: ## 停容器(保留数据卷)
|
||||
$(COMPOSE) down
|
||||
|
||||
logs: ## 跟踪容器日志
|
||||
$(COMPOSE) logs -f --tail=100
|
||||
|
||||
psql: ## 连进数据库
|
||||
$(COMPOSE) exec postgres psql -U meida -d meida
|
||||
|
||||
redis-cli: ## 连进 Redis
|
||||
$(COMPOSE) exec redis redis-cli
|
||||
|
||||
# ── 数据库 ──────────────────────────────────────────────────────────────────
|
||||
migrate: ## 迁移到最新版本
|
||||
uv run alembic upgrade head
|
||||
|
||||
downgrade: ## 回退一个版本
|
||||
uv run alembic downgrade -1
|
||||
|
||||
revision: ## 生成迁移脚本:make revision m="描述"
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
# ── 运行 ────────────────────────────────────────────────────────────────────
|
||||
run: ## 本地起 API(热重载)
|
||||
uv run uvicorn apps.api.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
worker: ## 本地起 Celery worker
|
||||
uv run celery -A apps.worker.celery_app.celery_app worker -Q cpu,io -l INFO
|
||||
|
||||
# ── 质量防线(与 CI 一致,见 .github/workflows/ci.yml)─────────────────────
|
||||
lint: ## ruff 检查 + 格式校验
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
|
||||
fmt: ## Auto-fix lint issues and format
|
||||
fmt: ## 自动修复与格式化
|
||||
uv run ruff check --fix .
|
||||
uv run ruff format .
|
||||
|
||||
typecheck: ## mypy strict on packages/domain + packages/llm, normal elsewhere (§16.2)
|
||||
typecheck: ## mypy(domain 与 llm 为 strict)
|
||||
uv run mypy packages/domain packages/llm
|
||||
uv run mypy apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance
|
||||
uv run mypy apps packages/shared packages/identity packages/billing packages/task \
|
||||
packages/agent packages/compliance
|
||||
|
||||
importlint: ## Enforce the dependency rules of 架构文档 §4.2
|
||||
imports: ## 分层依赖契约(架构文档 §4.2)
|
||||
uv run lint-imports
|
||||
|
||||
test: lint typecheck importlint test-unit ## Full local quality gate (mirrors CI)
|
||||
|
||||
test-unit: ## pytest with coverage
|
||||
uv run pytest
|
||||
|
||||
secrets: ## Scan the working tree for committed credentials
|
||||
secrets: ## 敏感信息扫描
|
||||
uv run detect-secrets scan --baseline .secrets.baseline
|
||||
|
||||
migrate: ## Apply Alembic migrations to $$DATABASE_URL
|
||||
uv run alembic upgrade head
|
||||
test: test-unit ## 默认只跑单测(不连库、不发网络请求)
|
||||
|
||||
makemigration: ## Autogenerate a migration: make makemigration m="add xxx"
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
test-unit:
|
||||
uv run pytest
|
||||
|
||||
up: ## Start PostgreSQL(pgvector) + Redis for local development
|
||||
$(COMPOSE) up -d postgres redis
|
||||
$(COMPOSE) ps
|
||||
test-integration: ## 需要 make up && make migrate
|
||||
MEIDA_RUN_INTEGRATION=1 uv run pytest -m integration
|
||||
|
||||
down: ## Stop the local stack (keeps volumes)
|
||||
$(COMPOSE) down
|
||||
check: lint typecheck imports test secrets ## 提交前一把过
|
||||
|
||||
dev: ## Run the API with autoreload
|
||||
uv run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
worker: ## Run a Celery worker consuming the cpu/io queues
|
||||
uv run celery -A apps.worker.celery_app.celery_app worker -Q cpu,io -l info
|
||||
|
||||
smoke: ## End-to-end proof: compose + migrate + API + real DeepSeek call
|
||||
# ── 真实链路验证(会花钱)──────────────────────────────────────────────────
|
||||
smoke: ## 真调 DeepSeek,打印 ob_ai_call 与 bl_ledger
|
||||
bash scripts/smoke.sh
|
||||
|
||||
clean: ## Remove caches and build artefacts
|
||||
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build
|
||||
find . -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||
|
||||
@@ -1,166 +1,60 @@
|
||||
# 美搭 backend — 工程地基 (M1)
|
||||
# meida_backend · 美搭服务端
|
||||
|
||||
「美搭」智能穿搭美学助手的服务端。本仓库当前是 **工程地基**,不是业务实现:
|
||||
骨架、横切能力、以及一条打通全链路的最小垂直切片。
|
||||
「美搭」智能穿搭美学助手的服务端。当前状态是**工程地基 + 一条打通的纵向切片**
|
||||
(登录取 token → 配额预扣 → 真实调用 DeepSeek → 记成本 → 提交配额),
|
||||
业务域大多只有目录与 README,见各 `packages/*/README.md`。
|
||||
|
||||
> 设计文档是唯一事实来源,在 Windows 侧的 `G:\meida\docs\`:
|
||||
> 《技术架构文档》《需求规格说明》《开发任务拆解》。本 README 只讲怎么跑起来。
|
||||
|
||||
---
|
||||
|
||||
## 现在有什么 / 现在没有什么
|
||||
|
||||
| 已实现 | 状态 |
|
||||
| --- | --- |
|
||||
| 仓库结构与包边界(架构 §16.1) | ✅ |
|
||||
| ruff / mypy strict / import-linter / pytest / 密钥扫描 全部进 CI(§16.2) | ✅ |
|
||||
| 统一响应与错误码 + 异常处理(§9.2, FR-SYS-02) | ✅ |
|
||||
| 结构化日志 + 请求/追踪 ID + 日志脱敏(NFR-SEC-03) | ✅ |
|
||||
| 异步 SQLAlchemy + Alembic(含 pgvector)+ 首个迁移 | ✅ |
|
||||
| OTel + Prometheus + `/healthz` `/readyz` `/metrics` | ✅ |
|
||||
| **LLM 网关**:路由 / 故障转移 / 熔断 / 结构化输出 / Prompt 版本 / 缓存前缀纪律 / 成本归因 / 日预算硬熔断 | ✅ |
|
||||
| **配额三段式** reserve → commit → refund(§7.4, FR-BILL-05) | ✅ |
|
||||
| **统一任务中心** 状态机 + Celery 四队列 + 一个贯通的 `ping` 任务 | ✅ |
|
||||
| 垂直切片 `POST /v1/agent/chat` | ✅ |
|
||||
|
||||
| 刻意留白(占位) | 归属任务 |
|
||||
| --- | --- |
|
||||
| 华为账号登录、手机号验证码、双令牌撤销、游客合并、注销 | T-P1-12 ~ T-P1-16 |
|
||||
| 统一合规出口(审核 / 水印 / 元数据 / 内容编号) | T-P1-22、T-P1-23(CR-05/CR-06 不可裁剪) |
|
||||
| 知识库、混合检索、规则引擎、记忆系统 | M2 |
|
||||
| 报告生成、换装、发型、视觉服务、抠图 | M3 / M4 |
|
||||
| 智谱 GLM provider(仅结构占位) | 需先核实备案模型清单 |
|
||||
| SSE 流式对话、意图识别、生成后校验 | FR-AGT-01/02/05 |
|
||||
|
||||
**开始写业务功能前请先读 `packages/README.md` 与对应域的 README** —— 里面写了
|
||||
什么能放进去、什么绝对不能。
|
||||
|
||||
---
|
||||
技术选型与分层依据见仓库外的《技术架构文档》《技术选型与调研报告》,本文只讲怎么跑起来。
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置:Python 3.12、Docker、[uv](https://docs.astral.sh/uv/)。
|
||||
```bash
|
||||
cp .env.example .env # 填入 DEEPSEEK_API_KEY
|
||||
make install # uv sync + 装 pre-commit
|
||||
make up # PostgreSQL(pgvector) + Redis,端口 55432 / 56379
|
||||
make migrate # 建表并创建 vector 扩展
|
||||
make run # http://localhost:8000/api/v1/docs
|
||||
```
|
||||
|
||||
验证真实链路(**会产生真实 API 费用**,是唯一花钱的入口):
|
||||
|
||||
```bash
|
||||
# 1. 依赖(本仓库用 uv 管理;uv.lock 锁定全部版本)
|
||||
make install
|
||||
|
||||
# 2. 配置。复制模板后至少填 DEEPSEEK_API_KEY 与 JWT_SECRET
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
|
||||
# 3. 起本地基础设施(PostgreSQL 16 + pgvector,Redis 7)
|
||||
make up
|
||||
make migrate
|
||||
|
||||
# 4. 跑起来
|
||||
make dev # API → http://localhost:8000/api/v1/docs
|
||||
make worker # Celery worker(另开一个终端)
|
||||
|
||||
# 5. 真实链路验证:会真的调用 DeepSeek,花真钱
|
||||
make smoke
|
||||
```
|
||||
|
||||
**包管理器:本仓库使用 `uv`**(`uv sync --extra dev`,锁文件 `uv.lock`)。
|
||||
没有 uv 时 `make install` 会退回 `python -m venv` + `pip install -e ".[dev]"`,
|
||||
但 CI 与 Docker 镜像都走 uv,因此以 `uv.lock` 为准。
|
||||
它会起依赖、迁移、拉起 API、发一次真实对话,然后把 `ob_ai_call`
|
||||
的成本行与 `bl_ledger` 的配额流水打出来。跑通即证明「计量 + 配额 + 大模型」三者串通。
|
||||
|
||||
### 常用命令
|
||||
## 目录结构
|
||||
|
||||
| 命令 | 作用 |
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `make lint` | ruff check + format check |
|
||||
| `make typecheck` | mypy(`packages/domain`、`packages/llm` 为 strict) |
|
||||
| `make importlint` | 校验架构 §4.2 的域间依赖规则 |
|
||||
| `make test` | 上面三项 + pytest(本地质量门,与 CI 一致) |
|
||||
| `make migrate` / `make makemigration m="..."` | Alembic |
|
||||
| `make up` / `make down` | 本地 PG + Redis |
|
||||
| `make smoke` | 真实 DeepSeek 端到端验证 |
|
||||
| `apps/api` | HTTP 入口。只做鉴权、校验、幂等、配额、统一响应,不放业务规则 |
|
||||
| `apps/worker` | Celery 长耗时任务入口 |
|
||||
| `packages/domain` | 纯领域逻辑,不许 import 任何框架与 I/O |
|
||||
| `packages/llm` | 大模型网关:场景路由、Prompt 版本、结构化输出、成本计量、预算熔断 |
|
||||
| `packages/shared` | 配置、日志脱敏、DB、Redis、错误码、中间件 |
|
||||
| `packages/<业务域>` | identity / billing / task / agent / kb / retrieval / report … |
|
||||
| `services/` | 需要独立伸缩的重型服务(换装、抠图、视觉),V1 为占位 |
|
||||
| `prompts/` | 按 `<prompt_id>/<version>.txt` 归档,与 `ob_ai_call.prompt_version` 对应 |
|
||||
| `migrations/` | Alembic;CI 强制 upgrade → downgrade → upgrade 往返可用 |
|
||||
|
||||
---
|
||||
依赖方向由 `lint-imports` 强制(`pyproject.toml` 的 `[tool.importlinter]`)。
|
||||
`apps → packages → domain` 是单向的,业务包也不得绕过网关直连 provider。
|
||||
|
||||
## 垂直切片:`POST /v1/agent/chat`
|
||||
|
||||
一次请求依次经过:
|
||||
|
||||
```
|
||||
JWT 鉴权
|
||||
→ billing.reserve() 配额预扣,写 bl_ledger(status=reserved)
|
||||
→ LLM 网关 场景路由 → 缓存前缀校验 → 日预算闸门 → DeepSeek
|
||||
→ ob_ai_call 每次 provider 调用(含失败与重试)写一行
|
||||
→ billing.commit() / refund() 成功确认扣除,失败自动退还
|
||||
→ 统一响应 {code, message, data, request_id}
|
||||
```
|
||||
|
||||
接口基础路径是架构 §9.1 规定的 `/api/v1`;`/v1/*` 作为等价别名同样可用
|
||||
(内部脚本与部分文档用的是后者,见下方「与设计文档的偏差」)。
|
||||
## 提交前
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -XPOST localhost:8000/v1/auth/dev-token \
|
||||
-H 'Content-Type: application/json' -d '{"device_key":"me"}' \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["access_token"])')
|
||||
|
||||
curl -s -XPOST localhost:8000/v1/agent/chat \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"message":"面试穿什么?"}' | python3 -m json.tool
|
||||
make check # lint + typecheck + imports + test + secrets,与 CI 同一套
|
||||
```
|
||||
|
||||
### 地基验收结果(M1 出口)
|
||||
单测不连库、不发网络请求。需要真实中间件的用例在 `tests/integration`,
|
||||
默认跳过,用 `make test-integration` 显式开启。
|
||||
|
||||
以下均在本机实跑通过,不是「应该可以」:
|
||||
## 两件容易踩的事
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `make lint` / `make typecheck` / `make test` | 全绿;147 unit + 3 integration,`packages/llm` + `packages/domain` 覆盖率 97% |
|
||||
| import-linter | 6 条契约全部 KEPT(含 compliance / identity 两条禁止依赖) |
|
||||
| `make up` + `make migrate` | 8 张表 + `alembic_version` 建成,`vector` 扩展存在,配额规则已播种 |
|
||||
| `alembic downgrade base` → `upgrade head` | 可逆,往返成功 |
|
||||
| `make smoke`(真实 DeepSeek) | 通过,模型 **`deepseek-v4-flash`**;第二次调用 `tokens_cached=256`,证明缓存前缀纪律真的生效 |
|
||||
| Celery 全链路 | `pending → queued → running → succeeded`,`tk_task.result` 落库 |
|
||||
|
||||
`GET https://api.deepseek.com/v1/models` 当前只返回两个模型:
|
||||
`deepseek-v4-flash` 与 `deepseek-v4-pro`。默认用前者,通过 `DEEPSEEK_MODEL` 切换,
|
||||
调用点不写死。
|
||||
|
||||
---
|
||||
|
||||
## 密钥
|
||||
|
||||
- `.env` 在 `.gitignore` 第一条,**永远不要提交**。
|
||||
- `.env.example` 记录每一个变量的含义,**不含任何真实值**。
|
||||
- pre-commit 与 CI 都跑密钥扫描(detect-secrets + gitleaks)。
|
||||
- 生产环境的密钥来自 SSM/KMS,不来自文件(架构 §11.2)。
|
||||
|
||||
---
|
||||
|
||||
## 本地 GPU
|
||||
|
||||
开发机带一块 **RTX 5090(32GB VRAM)**。这意味着 BiRefNet 抠图(TD-07)与
|
||||
RTMPose ONNX 人体关键点(TD-05/TD-06)**可以完全在本地开发和调参,不需要租云
|
||||
GPU**。相关占位见 `services/vision/` 与 `services/matting/`。
|
||||
本仓库尚未实现它们。
|
||||
|
||||
---
|
||||
|
||||
## 与设计文档的偏差 / 文档待补
|
||||
|
||||
记录在此,以便回灌设计文档:
|
||||
|
||||
1. **业务域包的位置**:架构 §16.1 的目录树列出了 `packages/{domain,llm,retrieval,
|
||||
rules,memory,compliance,shared}`,但 §4.1 定义了 11 个业务域。由于
|
||||
`compliance` 同时出现在两处,本仓库按「业务域各自是 `packages/<域>/`,
|
||||
`packages/domain/` 只放纯逻辑」来落地。建议在 §16.1 补全域目录。
|
||||
2. **接口前缀**:§9.1 规定 `/api/v1`,但多处任务描述使用 `/v1`。本仓库以
|
||||
`/api/v1` 为准,并在 ASGI 层把 `/v1/*` 重写为等价路径。建议文档统一。
|
||||
3. **`bl_ledger` 缺 `status` 列**:§6.2 的列清单没有 `status`,但 §7.4 的三段式
|
||||
明确要求 `reserved → committed / refunded`。本仓库补了该列。
|
||||
4. **`ob_ai_call` 缺 Prompt 版本列**:§8.1「可复现」要求记录 prompt 版本,
|
||||
§6.2 的列清单里没有。本仓库补了 `prompt_id` / `prompt_version`。
|
||||
5. **日成本熔断是「拒绝」而非「告警」**:§13 写的是「超预算自动降级并告警」。
|
||||
本仓库把它实现为硬闸门(非关键场景直接拒绝),因为重试风暴产生成本的速度
|
||||
远快于人响应告警的速度。关键场景通过 `SceneConfig.critical` 豁免。
|
||||
6. **单位成本费率是占位值**:`.env.example` 里的 `LLM_COST_RATES_JSON` 需要用
|
||||
供应商实际价目表校准,否则 `ob_ai_call.cost_cents` 只有相对意义。
|
||||
7. **`.env` 里的 JSON 值必须用单引号包住**。`scripts/smoke.sh` 会 `source .env`,
|
||||
裸双引号会被 bash 剥掉,JSON 随即损坏且报错发生在 pydantic-settings 内部,
|
||||
现场很难看出根因。模板里已经这么写了,改的时候别改回去。
|
||||
- **Prompt 固定前缀不许含时间戳、随机值等易变内容。** 命中缓存的输入单价只有未命中的
|
||||
约 1/30,前缀被污染会让输入成本翻数十倍。`LLM_CACHE_PREFIX_MODE=strict` 下启动即失败。
|
||||
- **DeepSeek 自 2026-08-17 起峰谷定价**,高峰(北京时间 09:00-12:00、14:00-18:00)
|
||||
单价是空闲时段的 2 倍。单价配两套,由 `packages/llm/cost.py` 按调用时刻选择;
|
||||
成本在调用当时算好落库,事后不按当前价重算。
|
||||
|
||||
+11
-2
@@ -1,10 +1,19 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
# The URL is read from DATABASE_URL via packages.shared.config in migrations/env.py.
|
||||
timezone = Asia/Shanghai
|
||||
# 前缀是四位序号而非时间戳:迁移顺序要能一眼看出来,评审时也好指认
|
||||
file_template = %%(rev)s_%%(slug)s
|
||||
version_path_separator = os
|
||||
# sqlalchemy.url 故意留空 —— 连接串只从环境变量读取(架构文档 §11.2 密钥不进代码)
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
hooks = ruff_format
|
||||
ruff_format.type = console_scripts
|
||||
ruff_format.entrypoint = ruff
|
||||
ruff_format.options = format REVISION_SCRIPT_FILENAME
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
|
||||
+27
-5
@@ -20,7 +20,12 @@ from packages.billing.service import BillingService
|
||||
from packages.identity.service import IdentityService
|
||||
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
|
||||
from packages.llm.circuit import CircuitBreakerRegistry
|
||||
from packages.llm.cost import CompositeAICallRecorder, ModelRate, RateTable
|
||||
from packages.llm.cost import (
|
||||
CompositeAICallRecorder,
|
||||
ModelRate,
|
||||
PeakAwareRateTable,
|
||||
RateTable,
|
||||
)
|
||||
from packages.llm.gateway import LLMGateway
|
||||
from packages.llm.prompts import PromptRegistry
|
||||
from packages.llm.providers.base import LLMProvider
|
||||
@@ -63,9 +68,25 @@ def build_llm_gateway(settings: Settings) -> LLMGateway:
|
||||
)
|
||||
)
|
||||
|
||||
rates = RateTable(
|
||||
{model: ModelRate.from_mapping(rate) for model, rate in settings.llm_cost_rates.items()},
|
||||
default=ModelRate.from_mapping(settings.llm_default_cost_rate),
|
||||
default_rate = ModelRate.from_mapping(settings.llm_default_cost_rate)
|
||||
rates = PeakAwareRateTable(
|
||||
RateTable(
|
||||
{
|
||||
model: ModelRate.from_mapping(rate)
|
||||
for model, rate in settings.llm_cost_rates.items()
|
||||
},
|
||||
default=default_rate,
|
||||
),
|
||||
RateTable(
|
||||
{
|
||||
model: ModelRate.from_mapping(rate)
|
||||
for model, rate in settings.llm_cost_rates_peak.items()
|
||||
},
|
||||
default=default_rate,
|
||||
)
|
||||
if settings.llm_cost_rates_peak
|
||||
else None,
|
||||
enabled=settings.llm_peak_pricing_enabled,
|
||||
)
|
||||
|
||||
recorder = CompositeAICallRecorder(
|
||||
@@ -115,7 +136,8 @@ async def session_dep() -> AsyncIterator[AsyncSession]:
|
||||
|
||||
|
||||
def gateway_dep(request: Request) -> LLMGateway:
|
||||
gateway = getattr(request.app.state, "llm_gateway", None)
|
||||
# app.state 是无类型的属性袋,取出来必须显式收窄
|
||||
gateway: LLMGateway | None = getattr(request.app.state, "llm_gateway", None)
|
||||
if gateway is None: # pragma: no cover - startup guarantees this
|
||||
raise AppError(ErrorCode.CONFIG_ERROR, message="LLM 网关未初始化")
|
||||
return gateway
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class DevTokenRequest(BaseModel):
|
||||
|
||||
class DevTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "Bearer" # noqa: S105 — a scheme name, not a credential
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int
|
||||
user_id: str = Field(description="雪花 ID 以字符串返回,避免 JS 精度丢失")
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ async def _fail_ping(task_id: int, error: str) -> None:
|
||||
await session.commit()
|
||||
|
||||
|
||||
@celery_app.task(name="meida.task.ping", bind=True, max_retries=2)
|
||||
# Celery 的 task 装饰器没有类型标注,会把被装饰函数擦成 untyped
|
||||
@celery_app.task(name="meida.task.ping", bind=True, max_retries=2) # type: ignore[untyped-decorator]
|
||||
def ping_task(self: Any, task_id: int, echo: str | None = None) -> dict[str, Any]:
|
||||
bind_context(task_id=task_id)
|
||||
try:
|
||||
|
||||
+25
-27
@@ -1,7 +1,7 @@
|
||||
"""Alembic environment — async engine + pgvector.
|
||||
"""Alembic environment (async engine).
|
||||
|
||||
NFR-MAINT-06 / 架构文档 §14.3: 迁移必须向后兼容(加列不删列、先双写后切读),
|
||||
并且必须可逆或明确标注不可逆。CI runs upgrade → downgrade → upgrade.
|
||||
The URL always comes from ``Settings``, never from ``alembic.ini`` — one source of
|
||||
truth for connection strings, and no credentials in a committed file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,59 +18,57 @@ from packages.shared.config import get_settings
|
||||
from packages.shared.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _include_object(
|
||||
obj: object, name: str | None, type_: str, reflected: bool, compare_to: object
|
||||
) -> bool:
|
||||
# pgvector installs its own tables/types; never autogenerate against them.
|
||||
return not (type_ == "table" and name in {"vector", "spatial_ref_sys"})
|
||||
def _configure(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
# 不比较服务端默认值以外的差异会让 autogenerate 漏掉类型收窄这类破坏性变更
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
include_schemas=False,
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
compare_type=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
include_object=_include_object,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
include_object=_include_object,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
async def _run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connection.run_sync(_run_sync)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def _run_sync(connection: Connection) -> None:
|
||||
_configure(connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
asyncio.run(_run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
@@ -4,7 +4,8 @@ Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
Reversible: yes # 若不可逆必须改为 no 并在此说明原因 (架构文档 §16.2 迁移检查)
|
||||
Reversible: yes | no —— 若为 no 必须写明原因。CI 会跑 upgrade → downgrade → upgrade,
|
||||
不可回滚的迁移必须显式拆成「先加列后回填」两步,不允许一步到位。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,7 +22,7 @@ _MAX_NODE_ID = (1 << _NODE_BITS) - 1
|
||||
_SEQUENCE_MASK = (1 << _SEQUENCE_BITS) - 1
|
||||
|
||||
|
||||
def _wall_clock_ms() -> int:
|
||||
def _system_clock_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ class SnowflakeGenerator:
|
||||
self._sequence = 0
|
||||
self._last_ms = -1
|
||||
self._lock = threading.Lock()
|
||||
self._clock = clock or _wall_clock_ms
|
||||
# 时钟可注入:序号回绕与时钟回拨这两条分支只能靠假时钟覆盖,
|
||||
# 真等一毫秒的测试既慢又不确定。
|
||||
self._clock = clock or _system_clock_ms
|
||||
|
||||
def _now_ms(self) -> int:
|
||||
return self._clock()
|
||||
|
||||
@@ -12,8 +12,11 @@ from packages.llm.cost import (
|
||||
InMemoryAICallRecorder,
|
||||
ModelRate,
|
||||
NullAICallRecorder,
|
||||
PeakAwareRateTable,
|
||||
RateResolver,
|
||||
RateTable,
|
||||
compute_cost_cents,
|
||||
is_peak,
|
||||
)
|
||||
from packages.llm.errors import (
|
||||
AllProvidersFailedError,
|
||||
@@ -47,10 +50,12 @@ __all__ = [
|
||||
"Message",
|
||||
"ModelRate",
|
||||
"NullAICallRecorder",
|
||||
"PeakAwareRateTable",
|
||||
"PromptRef",
|
||||
"PromptRegistry",
|
||||
"PromptTemplate",
|
||||
"ProviderError",
|
||||
"RateResolver",
|
||||
"RateTable",
|
||||
"RedisCostCounter",
|
||||
"Scene",
|
||||
@@ -60,5 +65,6 @@ __all__ = [
|
||||
"Usage",
|
||||
"build_scene_configs",
|
||||
"compute_cost_cents",
|
||||
"is_peak",
|
||||
"validate_cache_prefix",
|
||||
]
|
||||
|
||||
+49
-1
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, time, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
@@ -22,6 +22,15 @@ from packages.llm.types import Usage
|
||||
_MILLION = Decimal("1000000")
|
||||
_QUANT = Decimal("0.000001")
|
||||
|
||||
BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
# DeepSeek 自 2026-08-17 00:00(北京时间)起改为峰谷定价,高峰单价是空闲时段的 2 倍。
|
||||
# 官方口径的高峰时段:北京时间 09:00-12:00 与 14:00-18:00。
|
||||
PEAK_WINDOWS: tuple[tuple[time, time], ...] = (
|
||||
(time(9, 0), time(12, 0)),
|
||||
(time(14, 0), time(18, 0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelRate:
|
||||
@@ -55,6 +64,37 @@ class RateTable:
|
||||
return tuple(sorted(self._rates))
|
||||
|
||||
|
||||
def is_peak(at: datetime | None = None) -> bool:
|
||||
"""调用时刻是否落在供应商的高峰时段(按北京时间判定)。"""
|
||||
moment = (at or datetime.now(UTC)).astimezone(BEIJING).timetz()
|
||||
return any(start <= moment.replace(tzinfo=None) < end for start, end in PEAK_WINDOWS)
|
||||
|
||||
|
||||
class PeakAwareRateTable:
|
||||
"""按调用时刻在「高峰 / 空闲」两套单价之间选择。
|
||||
|
||||
为什么不能只存一套单价:高峰价是空闲价的 2 倍,而报告生成这类任务大多发生在
|
||||
白天高峰时段。用单一均价记账会让 ``ob_ai_call`` 的成本与账单对不上,
|
||||
而这张表是 V2 定价的唯一依据 —— 记错等于把定价建在错的数字上。
|
||||
|
||||
时段判断放在算成本的这一刻,不是事后按当前价重算:跨时段重算会把历史成本改掉。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, off_peak: RateTable, peak: RateTable | None = None, *, enabled: bool = True
|
||||
) -> None:
|
||||
self._off_peak = off_peak
|
||||
self._peak = peak or off_peak
|
||||
self._enabled = enabled
|
||||
|
||||
def for_model(self, model: str, at: datetime | None = None) -> ModelRate:
|
||||
table = self._peak if self._enabled and is_peak(at) else self._off_peak
|
||||
return table.for_model(model)
|
||||
|
||||
def known_models(self) -> tuple[str, ...]:
|
||||
return self._off_peak.known_models()
|
||||
|
||||
|
||||
def compute_cost_cents(usage: Usage, rate: ModelRate) -> Decimal:
|
||||
"""Cost of one call, in cents, with cache-hit tokens billed separately."""
|
||||
billable_input = Decimal(usage.billable_input) * rate.input_cents_per_mtok
|
||||
@@ -64,6 +104,13 @@ def compute_cost_cents(usage: Usage, rate: ModelRate) -> Decimal:
|
||||
return total.quantize(_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RateResolver(Protocol):
|
||||
"""网关只需要「给我这个模型此刻的单价」,不关心背后是一套还是两套价目表。"""
|
||||
|
||||
def for_model(self, model: str) -> ModelRate: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AICallRecord:
|
||||
"""One row of ``ob_ai_call``. Written for successes *and* failures."""
|
||||
@@ -88,6 +135,7 @@ class AICallRecord:
|
||||
def with_defaults(self) -> AICallRecord:
|
||||
if self.created_at is not None:
|
||||
return self
|
||||
# 本类是 slots dataclass,没有 __dict__,只能用 replace 复制
|
||||
return replace(self, created_at=datetime.now(UTC))
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from pydantic import BaseModel, ValidationError
|
||||
from packages.domain.ids import next_id
|
||||
from packages.llm.budget import DailyBudgetGuard
|
||||
from packages.llm.circuit import CircuitBreakerRegistry
|
||||
from packages.llm.cost import AICallRecord, AICallRecorder, RateTable, compute_cost_cents
|
||||
from packages.llm.cost import AICallRecord, AICallRecorder, RateResolver, compute_cost_cents
|
||||
from packages.llm.errors import (
|
||||
AllProvidersFailedError,
|
||||
CircuitOpenError,
|
||||
@@ -74,7 +74,7 @@ class LLMGateway:
|
||||
providers: Mapping[str, LLMProvider],
|
||||
router: SceneRouter,
|
||||
prompts: PromptRegistry,
|
||||
rates: RateTable,
|
||||
rates: RateResolver,
|
||||
recorder: AICallRecorder,
|
||||
budget: DailyBudgetGuard,
|
||||
breakers: CircuitBreakerRegistry | None = None,
|
||||
@@ -233,6 +233,7 @@ class LLMGateway:
|
||||
max_tokens=config.max_tokens,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
json_mode=schema is not None,
|
||||
thinking=config.thinking,
|
||||
)
|
||||
call_id = self._id_factory()
|
||||
started = self._timer()
|
||||
|
||||
@@ -56,6 +56,9 @@ class DeepSeekProvider:
|
||||
"temperature": request.temperature,
|
||||
"max_tokens": request.max_tokens,
|
||||
"stream": request.stream,
|
||||
# 思考模式一律显式声明。V4 的默认值是「开启」,漏传就会拿到
|
||||
# 慢一个数量级、且思考 token 也按输出价计费的行为。
|
||||
"thinking": {"type": "enabled" if request.thinking else "disabled"},
|
||||
}
|
||||
if request.json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
@@ -110,10 +113,16 @@ class DeepSeekProvider:
|
||||
or (usage_raw.get("prompt_tokens_details") or {}).get("cached_tokens")
|
||||
or 0
|
||||
)
|
||||
# 思考 token 已包含在 completion_tokens 里,单独记下来是为了能回答
|
||||
# 「这个场景的输出成本里有多少花在用户看不见的推理上」
|
||||
reasoning = int(
|
||||
(usage_raw.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0
|
||||
)
|
||||
usage = Usage(
|
||||
tokens_in=int(usage_raw.get("prompt_tokens", 0)),
|
||||
tokens_out=int(usage_raw.get("completion_tokens", 0)),
|
||||
tokens_cached=cached,
|
||||
tokens_reasoning=reasoning,
|
||||
)
|
||||
return ProviderResponse(
|
||||
text=text,
|
||||
|
||||
+14
-1
@@ -4,7 +4,12 @@
|
||||
so switching models never requires a code change (NFR-MAINT-05). Defaults live
|
||||
here; ``LLM_SCENE_OVERRIDES_JSON`` overrides any field per scene at deploy time.
|
||||
|
||||
Token budgets follow 架构文档 §7.2: 输入合计 ≤ 3700, 输出 ≤ 800.
|
||||
Token budgets follow 架构文档 §7.2: 输入合计 ≤ 4200, 输出 ≤ 800.
|
||||
|
||||
``thinking`` 在每个场景都显式给值。deepseek-v4 默认开启思考模式,实测同一个单句
|
||||
问答开启要 13.5s、关闭只要 1.8s,且思考 token 按输出价计费 —— 漏配就会同时踩中
|
||||
「响应超时」和「成本翻倍」。要开某个场景的思考,改 ``LLM_SCENE_OVERRIDES_JSON``
|
||||
即可,不必改代码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +27,8 @@ _DEFAULTS: dict[Scene, dict[str, Any]] = {
|
||||
"prompt_id": "agent_chat",
|
||||
"prompt_version": "v1",
|
||||
"critical": False,
|
||||
# 对话要在 NFR-PERF-02b 的整轮 15s 内返回,思考模式做不到
|
||||
"thinking": False,
|
||||
},
|
||||
Scene.INTENT_CLASSIFY: {
|
||||
"temperature": 0.0,
|
||||
@@ -29,6 +36,8 @@ _DEFAULTS: dict[Scene, dict[str, Any]] = {
|
||||
"timeout_seconds": 10.0,
|
||||
"prompt_id": "intent_classify",
|
||||
"prompt_version": "v1",
|
||||
# 分类是短输出的判别任务,思考只会拖慢并多花钱
|
||||
"thinking": False,
|
||||
# Intent classification gates 越界拒答 (FR-AGT-02); degrading it silently
|
||||
# would let out-of-scope questions through, so it is never budget-gated.
|
||||
"critical": True,
|
||||
@@ -40,6 +49,7 @@ _DEFAULTS: dict[Scene, dict[str, Any]] = {
|
||||
"prompt_id": "preference_extract",
|
||||
"prompt_version": "v1",
|
||||
"critical": False,
|
||||
"thinking": False,
|
||||
},
|
||||
Scene.REPORT_COPY: {
|
||||
"temperature": 0.6,
|
||||
@@ -48,6 +58,9 @@ _DEFAULTS: dict[Scene, dict[str, Any]] = {
|
||||
"prompt_id": "report_copy",
|
||||
"prompt_version": "v1",
|
||||
"critical": False,
|
||||
# 报告文案是异步任务,延迟宽松,理论上最适合开思考;V1 先关,
|
||||
# 等黄金集能量化"思考带来的质量提升"再按场景打开(改配置即可)。
|
||||
"thinking": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class Usage(BaseModel):
|
||||
tokens_out: int = 0
|
||||
#: Subset of ``tokens_in`` served from the provider's prompt cache.
|
||||
tokens_cached: int = 0
|
||||
#: Subset of ``tokens_out`` spent on思考过程。按输出价计费,但用户看不到。
|
||||
tokens_reasoning: int = 0
|
||||
|
||||
@property
|
||||
def billable_input(self) -> int:
|
||||
@@ -64,6 +66,8 @@ class SceneConfig:
|
||||
structured_max_retries: int = 2
|
||||
#: Critical scenes bypass the global daily budget breaker (架构文档 §13).
|
||||
critical: bool = False
|
||||
#: 思考模式。deepseek-v4 默认开启,必须按场景显式决定 —— 详见 ProviderRequest.thinking。
|
||||
thinking: bool = False
|
||||
|
||||
def model_for(self, provider: str, default: str) -> str:
|
||||
return self.models.get(provider, default)
|
||||
@@ -79,6 +83,10 @@ class ProviderRequest:
|
||||
#: When set, the provider must constrain output to JSON.
|
||||
json_mode: bool = False
|
||||
stream: bool = False
|
||||
#: 思考模式。**必须显式传,不能依赖供应商默认值** —— deepseek-v4 默认开启思考,
|
||||
#: 实测同一个单句问答开启需 13.5s、关闭只需 1.8s,且思考 token 按输出价计费。
|
||||
#: 对话与结构化抽取场景一律关闭:既超不过 Agent 整轮 15s 的目标,也白花钱。
|
||||
thinking: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -17,7 +17,7 @@ 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" # noqa: S105
|
||||
_PLACEHOLDER_JWT_SECRET = "change-me-local-only-do-not-use-outside-local"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -87,6 +87,12 @@ class Settings(BaseSettings):
|
||||
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"),
|
||||
@@ -108,7 +114,11 @@ class Settings(BaseSettings):
|
||||
prometheus_enabled: bool = True
|
||||
|
||||
@field_validator(
|
||||
"llm_scene_overrides", "llm_cost_rates", "llm_default_cost_rate", mode="before"
|
||||
"llm_scene_overrides",
|
||||
"llm_cost_rates",
|
||||
"llm_cost_rates_peak",
|
||||
"llm_default_cost_rate",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _parse_json_object(cls, value: Any) -> Any:
|
||||
|
||||
@@ -40,7 +40,7 @@ def create_access_token(
|
||||
"iss": issuer,
|
||||
"typ": "access",
|
||||
}
|
||||
return jwt.encode(payload, secret, algorithm=algorithm)
|
||||
return str(jwt.encode(payload, secret, algorithm=algorithm))
|
||||
|
||||
|
||||
def decode_access_token(
|
||||
|
||||
+96
-218
@@ -1,169 +1,124 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "meida-backend"
|
||||
version = "0.1.0"
|
||||
description = "美搭 (Meida) backend — AI fashion & styling assistant for HarmonyOS"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
license = { text = "Proprietary" }
|
||||
|
||||
description = "美搭 · 智能穿搭美学助手服务端"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"gunicorn>=22.0",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"asyncpg>=0.29",
|
||||
"alembic>=1.13",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"celery[redis]>=5.4",
|
||||
"redis>=5.0",
|
||||
"httpx>=0.27",
|
||||
"structlog>=24.1",
|
||||
"opentelemetry-api>=1.25",
|
||||
"opentelemetry-sdk>=1.25",
|
||||
"opentelemetry-instrumentation-fastapi>=0.46b0",
|
||||
"opentelemetry-exporter-otlp-proto-http>=1.25",
|
||||
"prometheus-client>=0.20",
|
||||
"pgvector>=0.2.5",
|
||||
"tenacity>=8.3",
|
||||
"pyjwt[crypto]>=2.8",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30",
|
||||
"alembic>=1.14",
|
||||
"pgvector>=0.3.6",
|
||||
"redis>=5.2",
|
||||
"celery>=5.4",
|
||||
"httpx>=0.28",
|
||||
"tenacity>=9.0",
|
||||
"pyjwt[crypto]>=2.9",
|
||||
"structlog>=24.4",
|
||||
"orjson>=3.10",
|
||||
"prometheus-client>=0.21",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.6",
|
||||
"mypy>=1.11",
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=5.0",
|
||||
"import-linter>=2.0",
|
||||
"pre-commit>=3.8",
|
||||
"pytest-cov>=6.0",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.8",
|
||||
"mypy>=1.13",
|
||||
"import-linter>=2.1",
|
||||
"detect-secrets>=1.5",
|
||||
"testcontainers[postgres,redis]>=4.8",
|
||||
"types-passlib>=1.7",
|
||||
"pre-commit>=4.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["apps", "packages", "services"]
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ruff — lint + format (架构文档 §16.2 静态检查防线)
|
||||
# ---------------------------------------------------------------------------
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["apps*", "packages*", "services*"]
|
||||
|
||||
# ── ruff ────────────────────────────────────────────────────────────────────
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["."]
|
||||
target-version = "py312"
|
||||
extend-exclude = ["migrations/versions"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "W", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"C4", # comprehensions
|
||||
"SIM", # simplify
|
||||
"ASYNC", # async correctness
|
||||
"S", # bandit (security)
|
||||
"T20", # no print
|
||||
"RUF",
|
||||
]
|
||||
ignore = [
|
||||
"S101", # assert is fine (pytest)
|
||||
"B008", # FastAPI Depends() in defaults is idiomatic
|
||||
# 本项目面向中文用户,注释/文案/提示词大量使用全角标点。
|
||||
# RUF001-003 会把每一个中文标点都报成 "ambiguous unicode",无信号价值。
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003",
|
||||
]
|
||||
select = ["E", "F", "I", "N", "UP", "B", "SIM", "ASYNC", "C4", "RUF"]
|
||||
# B008 与 FastAPI 的 Depends() 默认值写法直接冲突
|
||||
ignore = ["B008", "RUF001", "RUF002", "RUF003"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S105", "S106", "S311"]
|
||||
"migrations/**" = ["E501"]
|
||||
"tests/**" = ["SLF001"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["apps", "packages", "services"]
|
||||
# ── pytest ──────────────────────────────────────────────────────────────────
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
# 集成用例需要真实 PostgreSQL/Redis,默认不跑:`make test-integration` 显式开启
|
||||
addopts = "-q --strict-markers --strict-config -m 'not integration' --cov=packages --cov=apps"
|
||||
markers = ["integration: 需要真实 PostgreSQL / Redis(默认跳过)"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["packages", "apps"]
|
||||
omit = ["*/__init__.py"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mypy — strict 仅覆盖 packages/domain 与 packages/llm (架构文档 §16.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
|
||||
# ── mypy ────────────────────────────────────────────────────────────────────
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
plugins = ["pydantic.mypy"]
|
||||
ignore_missing_imports = true
|
||||
warn_unused_configs = true
|
||||
warn_redundant_casts = true
|
||||
show_error_codes = true
|
||||
namespace_packages = true
|
||||
explicit_package_bases = true
|
||||
exclude = ["migrations/versions/"]
|
||||
warn_unused_ignores = true
|
||||
no_implicit_reexport = true
|
||||
disallow_untyped_defs = true
|
||||
check_untyped_defs = true
|
||||
|
||||
# 领域层与 LLM 网关是钱与正确性的所在,按 strict 要求;外层逐步收紧。
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["packages.domain.*", "packages.llm.*"]
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_any_generics = true
|
||||
disallow_subclassing_any = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_reexport = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
strict_equality = true
|
||||
extra_checks = true
|
||||
strict = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"-ra",
|
||||
"--strict-markers",
|
||||
"--cov=packages.llm",
|
||||
"--cov=packages.domain",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=80",
|
||||
]
|
||||
markers = [
|
||||
"integration: needs PostgreSQL/Redis (docker compose or testcontainers)",
|
||||
"golden: golden-set regression (retrieval / rules / report)",
|
||||
]
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["tests.*", "migrations.*"]
|
||||
disallow_untyped_defs = false
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
"\\.\\.\\.",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# import-linter — 机器化执行架构文档 §4.2 的依赖规则
|
||||
# CI 失败即阻断合并 (§16.2 分层依赖检查)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── import-linter:分层依赖(架构文档 §4.2)────────────────────────────────
|
||||
# 这些契约用机器代替 code review 来守住依赖方向。没有它,
|
||||
# "domain 不许 import 框架" 这类约定会在第一次赶工时悄悄破掉。
|
||||
[tool.importlinter]
|
||||
root_packages = ["apps", "packages", "services"]
|
||||
include_external_packages = true
|
||||
|
||||
# --- §16.1: packages/domain 是纯逻辑层,不依赖 FastAPI / DB / HTTP / 队列 ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "packages.domain is pure (no IO, no framework)"
|
||||
name = "分层:apps → packages → domain,不得逆向"
|
||||
type = "layers"
|
||||
# 配额(billing)、账号(identity)、任务中心(task)是被各业务域共用的能力,
|
||||
# 因此排在业务域下方一层 —— Agent 要预扣配额、报告要建任务,这些依赖是正当的。
|
||||
# 反向不成立:billing 不该知道 Agent 的存在。
|
||||
layers = [
|
||||
"apps",
|
||||
"packages.agent | packages.report | packages.tryon | packages.wardrobe",
|
||||
"packages.identity | packages.billing | packages.task | packages.compliance | packages.notify",
|
||||
"packages.kb | packages.retrieval | packages.rules | packages.memory | packages.profile",
|
||||
"packages.llm",
|
||||
"packages.shared",
|
||||
"packages.domain",
|
||||
]
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "domain 层不依赖任何框架与 I/O"
|
||||
type = "forbidden"
|
||||
source_modules = ["packages.domain"]
|
||||
forbidden_modules = [
|
||||
@@ -171,109 +126,32 @@ forbidden_modules = [
|
||||
"starlette",
|
||||
"sqlalchemy",
|
||||
"alembic",
|
||||
"asyncpg",
|
||||
"httpx",
|
||||
"redis",
|
||||
"celery",
|
||||
"packages.shared",
|
||||
"packages.llm",
|
||||
"apps",
|
||||
"services",
|
||||
"redis",
|
||||
"httpx",
|
||||
"pydantic_settings",
|
||||
]
|
||||
|
||||
# --- §4.2 禁止的依赖 (1): compliance 不得反向依赖任何业务域 ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "compliance must not depend on any business domain"
|
||||
type = "forbidden"
|
||||
source_modules = ["packages.compliance"]
|
||||
forbidden_modules = [
|
||||
"packages.identity",
|
||||
"packages.profile",
|
||||
"packages.report",
|
||||
"packages.agent",
|
||||
"packages.tryon",
|
||||
"packages.kb",
|
||||
"packages.billing",
|
||||
"packages.task",
|
||||
"packages.notify",
|
||||
"packages.wardrobe",
|
||||
]
|
||||
|
||||
# --- §4.2 禁止的依赖 (2): identity 不得依赖其他业务域 ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "identity must not depend on any other business domain"
|
||||
type = "forbidden"
|
||||
source_modules = ["packages.identity"]
|
||||
forbidden_modules = [
|
||||
"packages.profile",
|
||||
"packages.report",
|
||||
"packages.agent",
|
||||
"packages.tryon",
|
||||
"packages.kb",
|
||||
"packages.billing",
|
||||
"packages.task",
|
||||
"packages.compliance",
|
||||
"packages.notify",
|
||||
"packages.wardrobe",
|
||||
]
|
||||
|
||||
# --- §3.2 硬性规则 1: 禁止直接查对方的表,只能走对方的 service ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "no cross-domain repository (table) access"
|
||||
name = "业务包不得绕过网关直连大模型"
|
||||
type = "forbidden"
|
||||
source_modules = [
|
||||
"packages.agent",
|
||||
"packages.report",
|
||||
"packages.identity",
|
||||
"packages.billing",
|
||||
"packages.task",
|
||||
"packages.identity",
|
||||
"packages.compliance",
|
||||
]
|
||||
forbidden_modules = [
|
||||
"packages.agent.repo",
|
||||
"packages.billing.repo",
|
||||
"packages.task.repo",
|
||||
"packages.identity.repo",
|
||||
]
|
||||
unmatched_ignore_imports_alerting = "none"
|
||||
ignore_imports = [
|
||||
"packages.agent.* -> packages.agent.repo",
|
||||
"packages.billing.* -> packages.billing.repo",
|
||||
"packages.task.* -> packages.task.repo",
|
||||
"packages.identity.* -> packages.identity.repo",
|
||||
]
|
||||
forbidden_modules = ["packages.llm.providers"]
|
||||
# 只禁直接 import。经由 packages.llm.gateway 的间接依赖是正常的 ——
|
||||
# 要拦的是"业务代码自己拼 HTTP 请求",那样成本归因与熔断都会被绕过。
|
||||
allow_indirect_imports = "true"
|
||||
|
||||
# --- §3.1 分层视图: L3 API → L4 业务域 → L5 AI 能力 → L6 数据 ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "layered architecture (apps -> domains -> ai/shared -> domain)"
|
||||
type = "layers"
|
||||
layers = [
|
||||
"apps",
|
||||
"packages.agent | packages.report | packages.tryon | packages.profile | packages.notify | packages.wardrobe",
|
||||
"packages.task | packages.billing | packages.kb",
|
||||
"packages.compliance | packages.identity",
|
||||
"packages.llm | packages.retrieval | packages.rules | packages.memory",
|
||||
"packages.shared",
|
||||
"packages.domain",
|
||||
]
|
||||
exhaustive = false
|
||||
|
||||
# --- AI 能力层不得回头依赖业务域 (§4.3: llm-gateway 必须按远程调用设计) ---
|
||||
[[tool.importlinter.contracts]]
|
||||
name = "AI capability packages must not depend on business domains"
|
||||
type = "forbidden"
|
||||
source_modules = [
|
||||
"packages.llm",
|
||||
"packages.retrieval",
|
||||
"packages.rules",
|
||||
]
|
||||
forbidden_modules = [
|
||||
"packages.identity",
|
||||
"packages.profile",
|
||||
"packages.report",
|
||||
"packages.agent",
|
||||
"packages.tryon",
|
||||
"packages.kb",
|
||||
"packages.billing",
|
||||
"packages.task",
|
||||
"apps",
|
||||
]
|
||||
name = "apps 之间互不依赖"
|
||||
type = "independence"
|
||||
modules = ["apps.api", "apps.worker", "apps.admin"]
|
||||
# 例外:API 投递异步任务需要 worker 的任务签名。这是投递方向(api → worker),
|
||||
# 反向不允许 —— worker 不得 import apps.api,否则 worker 会被迫加载整个 HTTP 层。
|
||||
ignore_imports = ["apps.api.routers.tasks -> apps.worker.tasks"]
|
||||
|
||||
+24
-8
@@ -31,8 +31,13 @@ trap cleanup EXIT
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
[[ -f .env ]] || fail ".env not found. Copy .env.example to .env and set DEEPSEEK_API_KEY."
|
||||
set -a; source .env; set +a
|
||||
[[ -n "${DEEPSEEK_API_KEY:-}" ]] || fail "DEEPSEEK_API_KEY is empty in .env"
|
||||
# 只取需要的这一个键,不 `source .env`:那些 *_JSON 值含双引号,
|
||||
# 被 shell 解析后引号会消失,应用侧再读就成了非法 JSON。
|
||||
DEEPSEEK_API_KEY=$(sed -n 's/^[[:space:]]*DEEPSEEK_API_KEY[[:space:]]*=[[:space:]]*//p' .env | tail -1)
|
||||
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY%\"}; DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY#\"}
|
||||
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY%\'}; DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY#\'}
|
||||
[[ -n "${DEEPSEEK_API_KEY}" ]] || fail "DEEPSEEK_API_KEY is empty in .env"
|
||||
[[ "${DEEPSEEK_API_KEY}" == sk-* ]] || fail "DEEPSEEK_API_KEY does not look like a real key"
|
||||
|
||||
bold "1/7 Starting PostgreSQL (pgvector) and Redis"
|
||||
${COMPOSE} up -d postgres redis
|
||||
@@ -78,23 +83,34 @@ CHAT_JSON=$(curl -fsS -X POST "${BASE}/v1/agent/chat" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-d '{"message":"三十岁男生,第一次去互联网公司面试,穿什么比较稳妥?"}')
|
||||
printf '%s\n' "${CHAT_JSON}" | python3 -m json.tool
|
||||
# 走临时文件而不是管道:heredoc 会占用 stdin,管道里的 JSON 读不到
|
||||
printf '%s' "${CHAT_JSON}" >"${LOG_DIR}/chat.json"
|
||||
python3 -m json.tool --no-ensure-ascii "${LOG_DIR}/chat.json"
|
||||
python3 - "${LOG_DIR}/chat.json" <<'PYEOF'
|
||||
import json, sys
|
||||
python3 - "${LOG_DIR}/chat.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
body = json.load(fh)
|
||||
assert body["code"] == 0, body
|
||||
data = body["data"]
|
||||
assert data["reply"].strip(), "empty model reply"
|
||||
usage = data["usage"]
|
||||
|
||||
print("\n--- MODEL REPLY -------------------------------------------------")
|
||||
print(data["reply"])
|
||||
print("-----------------------------------------------------------------")
|
||||
print("provider={provider} model={model} prompt={prompt_id}@{prompt_version}".format(**data))
|
||||
print("usage={}".format(data["usage"]))
|
||||
print("provider={} model={} prompt={}@{}".format(
|
||||
data["provider"], data["model"], data["prompt_id"], data["prompt_version"]))
|
||||
print("latency={}ms tokens_in={} tokens_out={} cached={} cost_cents={}".format(
|
||||
usage["latency_ms"], usage["tokens_in"], usage["tokens_out"],
|
||||
usage["tokens_cached"], usage["cost_cents"]))
|
||||
print("quota_remaining={}".format(data["quota_remaining"]))
|
||||
PYEOF
|
||||
|
||||
# 对话必须在 NFR-PERF-02b 的整轮 15s 之内。这里只量单次 LLM 调用,
|
||||
# 留出检索与后处理的余量,因此阈值取 10s。
|
||||
assert usage["latency_ms"] < 10_000, f"LLM 调用耗时 {usage['latency_ms']}ms,超出预期"
|
||||
PY
|
||||
|
||||
bold "6/7 ob_ai_call row (成本归因的唯一数据源, 架构文档 §13)"
|
||||
${COMPOSE} exec -T postgres psql -U meida -d meida -x -c \
|
||||
|
||||
+46
-22
@@ -1,36 +1,60 @@
|
||||
"""Shared fixtures.
|
||||
|
||||
Unit tests must stay hermetic: no database, no Redis, no network. Anything that
|
||||
needs real infrastructure belongs in ``tests/integration`` behind the
|
||||
``integration`` marker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Unit tests never touch a real database, Redis or LLM provider. Setting these
|
||||
# before any import keeps `packages.shared.config` deterministic.
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
os.environ.setdefault("JWT_SECRET", "unit-test-secret-value-not-used-in-any-real-env")
|
||||
os.environ.setdefault("DEEPSEEK_API_KEY", "unit-test-placeholder")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
# 单测里给出确定的配置,避免开发者本机 .env 的取值渗进断言里。
|
||||
# 必须在导入任何读取 Settings 的模块之前生效,故用 autouse=True + session 作用域。
|
||||
_TEST_ENV = {
|
||||
"APP_ENV": "local",
|
||||
"LOG_JSON": "false",
|
||||
"JWT_SECRET": "unit-test-secret-not-used-outside-tests-0123456789",
|
||||
"DEEPSEEK_API_KEY": "unit-test-placeholder-never-called",
|
||||
"LLM_DAILY_BUDGET_ENABLED": "true",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _isolate_settings() -> None:
|
||||
import os
|
||||
|
||||
for key, value in _TEST_ENV.items():
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def repo_root() -> Path:
|
||||
return REPO_ROOT
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prompts_dir(tmp_path: Path) -> Iterator[Path]:
|
||||
"""A minimal on-disk prompt tree with a clean, cacheable system prefix."""
|
||||
root = tmp_path / "prompts"
|
||||
(root / "agent_chat").mkdir(parents=True)
|
||||
(root / "agent_chat" / "v1.txt").write_text(
|
||||
"[system]\n你是穿搭顾问助手。只回答穿搭相关问题。\n\n[user]\n{user_input}\n",
|
||||
def prompts_dir(repo_root: Path, tmp_path: Path) -> Path:
|
||||
"""A writable copy of the shipped prompts, plus a second version of agent_chat.
|
||||
|
||||
Copying rather than pointing at ``prompts/`` keeps two properties that matter:
|
||||
tests can add versions without dirtying the repo, and the shipped prompts are
|
||||
still the ones under test (a hand-written fixture would drift from production).
|
||||
"""
|
||||
target = tmp_path / "prompts"
|
||||
shutil.copytree(repo_root / "prompts", target)
|
||||
|
||||
v2 = target / "agent_chat" / "v2.txt"
|
||||
v2.write_text(
|
||||
"[system]\n"
|
||||
"你是「美搭」的穿搭顾问助手(v2 试验版本,仅用于测试版本切换)。\n"
|
||||
"规则与 v1 相同,此处仅改动固定前缀以便断言两个版本不同。\n"
|
||||
"\n"
|
||||
"[user]\n"
|
||||
"{user_input}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "agent_chat" / "v2.txt").write_text(
|
||||
"[system]\n你是穿搭顾问助手。回答要具体可执行。\n\n[user]\n{user_input}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
yield root
|
||||
return target
|
||||
|
||||
@@ -17,8 +17,6 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
@@ -62,6 +60,7 @@ async def client() -> Any:
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
# 替换掉真实 provider:集成用例验证配额与计量链路,不该花钱也不该受网络影响
|
||||
app.state.llm_gateway._providers["deepseek"] = FakeProvider()
|
||||
yield http
|
||||
|
||||
|
||||
@@ -16,9 +16,18 @@ class FakeClock:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def state_of(breaker: CircuitBreaker) -> CircuitState:
|
||||
"""Read the state through a call so mypy does not narrow it across mutations.
|
||||
|
||||
`state` is a property; mypy treats it like an attribute and keeps the narrowed
|
||||
literal from an earlier assert, then reports later asserts as unreachable.
|
||||
"""
|
||||
return breaker.state
|
||||
|
||||
|
||||
def test_starts_closed_and_allows_traffic() -> None:
|
||||
breaker = CircuitBreaker("p", failure_threshold=3, clock=FakeClock())
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
assert state_of(breaker) is CircuitState.CLOSED
|
||||
assert breaker.allow()
|
||||
|
||||
|
||||
@@ -26,9 +35,9 @@ def test_opens_only_after_threshold_consecutive_failures() -> None:
|
||||
breaker = CircuitBreaker("p", failure_threshold=3, clock=FakeClock())
|
||||
breaker.record_failure()
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
assert state_of(breaker) is CircuitState.CLOSED
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
assert state_of(breaker) is CircuitState.OPEN
|
||||
assert not breaker.allow()
|
||||
|
||||
|
||||
@@ -38,26 +47,26 @@ def test_success_resets_the_failure_streak() -> None:
|
||||
breaker.record_failure()
|
||||
breaker.record_success()
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
assert state_of(breaker) is CircuitState.CLOSED
|
||||
|
||||
|
||||
def test_half_open_probe_after_cooldown_then_closes_on_success() -> None:
|
||||
clock = FakeClock()
|
||||
breaker = CircuitBreaker("p", failure_threshold=1, cooldown_seconds=30.0, clock=clock)
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
assert state_of(breaker) is CircuitState.OPEN
|
||||
|
||||
clock.advance(29.0)
|
||||
assert not breaker.allow()
|
||||
|
||||
clock.advance(2.0)
|
||||
assert breaker.allow()
|
||||
assert breaker.state is CircuitState.HALF_OPEN
|
||||
assert state_of(breaker) is CircuitState.HALF_OPEN
|
||||
# only one probe is admitted
|
||||
assert not breaker.allow()
|
||||
|
||||
breaker.record_success()
|
||||
assert breaker.state is CircuitState.CLOSED
|
||||
assert state_of(breaker) is CircuitState.CLOSED
|
||||
assert breaker.allow()
|
||||
|
||||
|
||||
@@ -69,7 +78,7 @@ def test_half_open_failure_reopens_and_restarts_cooldown() -> None:
|
||||
assert breaker.allow()
|
||||
|
||||
breaker.record_failure()
|
||||
assert breaker.state is CircuitState.OPEN
|
||||
assert state_of(breaker) is CircuitState.OPEN
|
||||
assert not breaker.allow()
|
||||
|
||||
clock.advance(11.0)
|
||||
|
||||
@@ -87,7 +87,8 @@ async def test_in_memory_recorder_collects_records() -> None:
|
||||
|
||||
|
||||
async def test_null_recorder_is_a_no_op() -> None:
|
||||
assert await NullAICallRecorder().record(_record()) is None
|
||||
# Must not raise and must not persist anything; `record` returns None by contract.
|
||||
await NullAICallRecorder().record(_record())
|
||||
|
||||
|
||||
async def test_composite_recorder_fans_out() -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ No test here touches the network — providers are fakes.
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +28,7 @@ from packages.llm.errors import (
|
||||
)
|
||||
from packages.llm.gateway import LLMGateway, extract_json
|
||||
from packages.llm.prompts import PromptRegistry
|
||||
from packages.llm.providers.base import LLMProvider
|
||||
from packages.llm.providers.zhipu import ZhipuGLMProvider
|
||||
from packages.llm.routing import SceneRouter, build_scene_configs
|
||||
from packages.llm.types import Message, ProviderRequest, ProviderResponse, Scene, Usage
|
||||
@@ -84,7 +85,7 @@ def response(text: str, model: str = "fake-model") -> ProviderResponse:
|
||||
|
||||
def build_gateway(
|
||||
prompts_dir: Path,
|
||||
providers: dict[str, FakeProvider],
|
||||
providers: Mapping[str, LLMProvider],
|
||||
*,
|
||||
primary: str = "p1",
|
||||
backup: str | None = "p2",
|
||||
@@ -444,11 +445,8 @@ async def test_daily_budget_rejects_non_critical_scenes_before_spending(
|
||||
assert primary.requests == [] # rejected, not merely alerted
|
||||
|
||||
|
||||
async def test_daily_budget_lets_critical_scenes_through(prompts_dir: Path, tmp_path: Path) -> None:
|
||||
(prompts_dir / "intent_classify").mkdir()
|
||||
(prompts_dir / "intent_classify" / "v1.txt").write_text(
|
||||
"[system]\n你是意图分类器。\n\n[user]\n{user_input}\n", encoding="utf-8"
|
||||
)
|
||||
async def test_daily_budget_lets_critical_scenes_through(prompts_dir: Path) -> None:
|
||||
# 意图分类把守越界拒答,静默降级会让超范围提问漏过去,因此不受预算闸门限制
|
||||
primary = FakeProvider("p1", [response("scene_consult")])
|
||||
gateway, _, guard = build_gateway(
|
||||
prompts_dir, {"p1": primary}, backup=None, budget_cents="0.00001"
|
||||
|
||||
+10
-2
@@ -45,11 +45,19 @@ def test_sequence_rollover_within_one_millisecond() -> None:
|
||||
frozen = DEFAULT_EPOCH_MS + 1_000
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_now() -> int:
|
||||
def fake_clock() -> int:
|
||||
calls["n"] += 1
|
||||
# Hold the clock for the first 5000 calls, then let it advance.
|
||||
return frozen if calls["n"] < 5000 else frozen + 1
|
||||
|
||||
generator = SnowflakeGenerator(node_id=1, clock=fake_now)
|
||||
generator = SnowflakeGenerator(node_id=1, clock=fake_clock)
|
||||
ids = [generator.next_id() for _ in range(4200)]
|
||||
assert len(set(ids)) == len(ids)
|
||||
|
||||
|
||||
def test_clock_moving_backwards_never_yields_duplicates() -> None:
|
||||
"""时钟回拨(NTP 校正)时必须继续单调,不能重发已发过的 ID。"""
|
||||
times = iter([DEFAULT_EPOCH_MS + 500, DEFAULT_EPOCH_MS + 400, DEFAULT_EPOCH_MS + 400])
|
||||
generator = SnowflakeGenerator(node_id=1, clock=lambda: next(times))
|
||||
ids = [generator.next_id() for _ in range(3)]
|
||||
assert len(set(ids)) == 3
|
||||
|
||||
@@ -44,12 +44,14 @@ def test_phone_numbers_inside_free_text_are_masked() -> None:
|
||||
assert "138****8000" in str(out["detail"])
|
||||
|
||||
|
||||
# Synthetic key. Never paste a live key here: this file is committed, so a real
|
||||
# key would land in git history permanently on the first commit.
|
||||
FAKE_API_KEY = "sk-" + "0" * 8 + "deadbeef" + "0" * 16
|
||||
|
||||
|
||||
def test_api_keys_in_free_text_are_masked() -> None:
|
||||
# Never put a real key in a fixture, not even to prove it gets redacted:
|
||||
# the test file itself would then carry the secret into git history.
|
||||
fake_key = "sk-" + "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
|
||||
out = redact({"event": "cfg", "detail": f"using {fake_key}"})
|
||||
assert fake_key not in str(out["detail"])
|
||||
out = redact({"event": "cfg", "detail": f"using {FAKE_API_KEY}"})
|
||||
assert FAKE_API_KEY not in str(out["detail"])
|
||||
assert REDACTED in str(out["detail"])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""峰谷定价(DeepSeek 自 2026-08-17 起高峰单价翻倍)。
|
||||
|
||||
保护的是:同一次调用在高峰与空闲时段必须记出不同的成本。若只配一套单价,
|
||||
``ob_ai_call`` 会与真实账单差出接近 2 倍,而这张表是 V2 定价的唯一依据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.llm.cost import (
|
||||
ModelRate,
|
||||
PeakAwareRateTable,
|
||||
RateTable,
|
||||
compute_cost_cents,
|
||||
is_peak,
|
||||
)
|
||||
from packages.llm.types import Usage
|
||||
|
||||
BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
OFF_PEAK = ModelRate(Decimal("150"), Decimal("5"), Decimal("450"))
|
||||
PEAK = ModelRate(Decimal("300"), Decimal("10"), Decimal("900"))
|
||||
MODEL = "deepseek-v4-flash"
|
||||
|
||||
|
||||
def _table(*, enabled: bool = True) -> PeakAwareRateTable:
|
||||
return PeakAwareRateTable(
|
||||
RateTable({MODEL: OFF_PEAK}, default=OFF_PEAK),
|
||||
RateTable({MODEL: PEAK}, default=PEAK),
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hour", "expected"),
|
||||
[
|
||||
(0, False),
|
||||
(8, False),
|
||||
(9, True), # 高峰左闭
|
||||
(11, True),
|
||||
(12, False), # 高峰右开
|
||||
(13, False),
|
||||
(14, True),
|
||||
(17, True),
|
||||
(18, False),
|
||||
(23, False),
|
||||
],
|
||||
)
|
||||
def test_peak_windows_follow_the_published_schedule(hour: int, expected: bool) -> None:
|
||||
assert is_peak(datetime(2026, 8, 20, hour, 30, tzinfo=BEIJING)) is expected
|
||||
|
||||
|
||||
def test_utc_input_is_converted_to_beijing_before_judging() -> None:
|
||||
# 02:00 UTC = 10:00 北京时间,属高峰。按 UTC 判定会把高峰算成空闲、成本少记一半
|
||||
assert is_peak(datetime(2026, 8, 20, 2, 0, tzinfo=UTC)) is True
|
||||
|
||||
|
||||
def test_peak_rate_is_double_the_off_peak_rate() -> None:
|
||||
table = _table()
|
||||
usage = Usage(tokens_in=10_000, tokens_out=2_000, tokens_cached=0)
|
||||
|
||||
off_peak_cost = compute_cost_cents(
|
||||
usage, table.for_model(MODEL, datetime(2026, 8, 20, 13, 0, tzinfo=BEIJING))
|
||||
)
|
||||
peak_cost = compute_cost_cents(
|
||||
usage, table.for_model(MODEL, datetime(2026, 8, 20, 10, 0, tzinfo=BEIJING))
|
||||
)
|
||||
|
||||
assert peak_cost == off_peak_cost * 2
|
||||
|
||||
|
||||
def test_disabling_peak_pricing_keeps_one_rate() -> None:
|
||||
# 供应商若取消峰谷定价,改一个开关即可,不必回滚代码
|
||||
table = _table(enabled=False)
|
||||
assert table.for_model(MODEL, datetime(2026, 8, 20, 10, 0, tzinfo=BEIJING)) is OFF_PEAK
|
||||
|
||||
|
||||
def test_unknown_model_still_costs_money_in_both_tiers() -> None:
|
||||
table = _table()
|
||||
for hour in (10, 13):
|
||||
rate = table.for_model("some-new-model", datetime(2026, 8, 20, hour, tzinfo=BEIJING))
|
||||
assert rate.input_cents_per_mtok > 0
|
||||
@@ -0,0 +1,79 @@
|
||||
"""思考模式必须显式声明(deepseek-v4 默认开启)。
|
||||
|
||||
保护的是两件事:漏传 ``thinking`` 会让对话延迟从约 2s 涨到 13s 以上(实测同一个单句
|
||||
问答),并且思考 token 按输出价计费 —— 一个用户看不到的字段同时打破响应时间目标
|
||||
与成本模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from packages.llm.providers.deepseek import DeepSeekProvider
|
||||
from packages.llm.routing import build_scene_configs
|
||||
from packages.llm.types import Message, ProviderRequest, Scene
|
||||
|
||||
|
||||
def _request(*, thinking: bool) -> ProviderRequest:
|
||||
return ProviderRequest(
|
||||
model="deepseek-v4-flash",
|
||||
messages=(Message(role="user", content="面试穿什么"),),
|
||||
temperature=0.7,
|
||||
max_tokens=800,
|
||||
timeout_seconds=30.0,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
|
||||
def _provider(captured: dict[str, Any]) -> DeepSeekProvider:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.update(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 34,
|
||||
"completion_tokens_details": {"reasoning_tokens": 20},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return DeepSeekProvider(
|
||||
api_key="test-key",
|
||||
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("thinking", "expected"), [(False, "disabled"), (True, "enabled")])
|
||||
async def test_thinking_is_always_sent_explicitly(thinking: bool, expected: str) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
provider = _provider(captured)
|
||||
|
||||
await provider.complete(_request(thinking=thinking))
|
||||
|
||||
# 关键是这个字段一定存在:省略它就等于接受供应商的"开启"默认值
|
||||
assert captured["thinking"] == {"type": expected}
|
||||
await provider.aclose()
|
||||
|
||||
|
||||
async def test_reasoning_tokens_are_recorded_for_cost_visibility() -> None:
|
||||
provider = _provider({})
|
||||
response = await provider.complete(_request(thinking=True))
|
||||
|
||||
# 思考 token 已计入 tokens_out,单独留一份用于回答"输出成本有多少花在推理上"
|
||||
assert response.usage.tokens_out == 34
|
||||
assert response.usage.tokens_reasoning == 20
|
||||
await provider.aclose()
|
||||
|
||||
|
||||
def test_no_v1_scene_enables_thinking_by_default() -> None:
|
||||
configs = build_scene_configs(primary_provider="deepseek")
|
||||
assert [s for s, c in configs.items() if c.thinking] == []
|
||||
# 且每个场景都显式给了值,不存在"忘了配"的场景
|
||||
assert set(configs) == set(Scene)
|
||||
Reference in New Issue
Block a user