Files
meida_backend/pyproject.toml
T
xsl f46ecc5eb0 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.
2026-08-14 00:54:40 +08:00

158 lines
5.3 KiB
TOML
Executable File

[project]
name = "meida-backend"
version = "0.1.0"
description = "美搭 · 智能穿搭美学助手服务端"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"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 = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-cov>=6.0",
"respx>=0.22",
"ruff>=0.8",
"mypy>=1.13",
"import-linter>=2.1",
"detect-secrets>=1.5",
"pre-commit>=4.0",
]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["apps*", "packages*", "services*"]
# ── ruff ────────────────────────────────────────────────────────────────────
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["migrations/versions"]
[tool.ruff.lint]
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/**" = ["SLF001"]
# ── 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.coverage.run]
branch = true
source = ["packages", "apps"]
omit = ["*/__init__.py"]
[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
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.*"]
strict = true
[[tool.mypy.overrides]]
module = ["tests.*", "migrations.*"]
disallow_untyped_defs = false
# ── import-linter:分层依赖(架构文档 §4.2)────────────────────────────────
# 这些契约用机器代替 code review 来守住依赖方向。没有它,
# "domain 不许 import 框架" 这类约定会在第一次赶工时悄悄破掉。
[tool.importlinter]
root_packages = ["apps", "packages", "services"]
include_external_packages = true
[[tool.importlinter.contracts]]
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 = [
"fastapi",
"starlette",
"sqlalchemy",
"alembic",
"celery",
"redis",
"httpx",
"pydantic_settings",
]
[[tool.importlinter.contracts]]
name = "业务包不得绕过网关直连大模型"
type = "forbidden"
source_modules = [
"packages.agent",
"packages.report",
"packages.identity",
"packages.billing",
"packages.task",
"packages.compliance",
]
forbidden_modules = ["packages.llm.providers"]
# 只禁直接 import。经由 packages.llm.gateway 的间接依赖是正常的 ——
# 要拦的是"业务代码自己拼 HTTP 请求",那样成本归因与熔断都会被绕过。
allow_indirect_imports = "true"
[[tool.importlinter.contracts]]
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"]