收尾两批并行开发的文档回写与任务包
- Sprint 执行计划 v1.1:§1.1 补第一批交付实据;T9 SPIKE 结论回写 Sprint 1 表 (T-P0-16 通过 2.8ms;T-P0-12 有条件;T-P0-10 原型 6/6 + 真实照片重验前置); Sprint 2 表标注 T-P2-01~06 交付;新增 §9 两批交付记录 - 开发任务拆解 v1.5:交付回写 + 新风险登记(RTMPose 域差异、BiRefNet 异步化) - 新增 开发任务包/:三批多 agent 并行的协作规程与 14 份任务契约 - 新增 前置开通清单-云资源与资质.md - 清理已用毕的评审报告(文档评审 2026-08-13、项目整体评审 2026-08-14), README 中相应引用改为留痕表述
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# 多 Agent 并行开发·协作规程
|
||||
|
||||
> 版本:v1.2 | 2026-08-14 | 前两批(T1~T5、T6~T11)已全部合并;本版适用第三批 **T12~T14**
|
||||
> **每个 agent 开工前必须先读本文件,再读自己的任务文档。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 本轮架构决策(为什么这样切)
|
||||
|
||||
六个任务按**文件所有权**切分:每个文件只属于一个任务。协调者(主会话)已提前在 main 分支做了一个「协调提交」,把**所有共享文件的改动一次性做完**(kb 七表迁移、ORM 模型、配置项、错误码、路由注册、依赖、gitignore)。因此:
|
||||
|
||||
- **你不需要碰任何共享文件**。如果你的任务文档说某配置/错误码/表已就绪,它就真的已就绪——直接用。
|
||||
- 禁改清单见你的任务文档。**改了禁改文件 = 合并时被整体丢弃**。
|
||||
|
||||
## 2. 环境操作手册(Windows 宿主 + WSL 仓库)
|
||||
|
||||
你的工作目录是 WSL 里的一个 **git worktree**(不是主仓库):
|
||||
|
||||
| 任务 | worktree 路径(WSL) | 分支 |
|
||||
| --- | --- | --- |
|
||||
| T12 | `/home/xsl/meida_backend-t12` | `task/t12-admin-console` |
|
||||
| T13 | `/home/xsl/meida_backend-t13` | `task/t13-golden-ci` |
|
||||
| T14 | `/home/xsl/meida_backend-t14` | `task/t14-retrieval-cache` |
|
||||
|
||||
- **读写文件**:用 Read / Write / Edit 工具,路径写成 UNC 形式:
|
||||
`\\wsl.localhost\Ubuntu-22.04\home\xsl\meida_backend-t6\packages\...`
|
||||
(Edit 前必须先 Read 目标文件。)
|
||||
- **跑命令**(测试/lint/git):
|
||||
```
|
||||
MSYS_NO_PATHCONV=1 wsl.exe -d Ubuntu-22.04 -e bash -c "cd /home/xsl/meida_backend-t6 && .venv/bin/pytest -q"
|
||||
```
|
||||
注意:venv 在**你自己的 worktree 里**(`.venv/bin/...`),不要用主仓库的(本批已含全部所需依赖,用 `--extra dev` 同步)。
|
||||
- PG(55432)/Redis(56379) 容器已在跑,`.env` 已复制到你的 worktree。
|
||||
|
||||
## 3. 提交前必须全绿(在你的 worktree 里)
|
||||
|
||||
```bash
|
||||
.venv/bin/ruff check . && .venv/bin/ruff format --check .
|
||||
.venv/bin/mypy <你的任务文档里指定的包列表>
|
||||
.venv/bin/lint-imports
|
||||
.venv/bin/pytest -q # 单测(不连库)
|
||||
# 若你的任务允许集成测试:
|
||||
MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m integration -q --no-cov
|
||||
```
|
||||
|
||||
全绿后提交(**只提交一次,不要多次碎提交**):
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "<你的任务文档里给定的提交信息>"
|
||||
```
|
||||
|
||||
**禁止 `git push`。禁止改动 `.git/`、禁止切分支、禁止 rebase/merge。**
|
||||
|
||||
## 4. 硬性纪律(违反任何一条 = 返工)
|
||||
|
||||
1. **禁改文件**以任务文档为准。通用禁改:`pyproject.toml`、`uv.lock`、`migrations/**`、`packages/shared/models.py`、`packages/shared/config.py`、`packages/shared/errors.py`、`.env.example`、`apps/api/main.py`、`apps/api/deps.py`、`.github/**`、`Makefile`、`scripts/smoke.sh`、`deploy/**`、`packages/domain/**`、`packages/llm/**`、`packages/billing/**`、`packages/agent/**`、`packages/task/**`、`packages/analytics/**`、`packages/storage/**`(除非任务文档明确允许某文件)。
|
||||
2. **不加任何新依赖**(不 `uv add`,不手写 import 第三方新包)。需要的库协调者已装(含 `spike` extra)。
|
||||
3. **不连外网**:单测禁止真实 HTTP。mock 方式参考 `tests/unit/test_deepseek_provider.py`(respx)与 `tests/integration/test_vertical_slice.py`(FakeProvider)。**唯一例外:T9 的模型下载脚本**(见其任务文档)。
|
||||
4. **代码风格对齐现状**:中文 docstring/注释(解释"为什么")、`from __future__ import annotations`、frozen+slots dataclass、结构化日志 `get_logger(__name__)`、错误一律走 `AppError(ErrorCode.X)`、响应一律 `ok(...)` 信封。
|
||||
5. **mypy strict** 适用于 `packages/domain`、`packages/llm`(如果你改这两处,标注必须完整)。其余包按现有宽松档,但新代码仍应写满类型标注。
|
||||
6. **ruff line-length 100**;格式化用 `ruff format`。
|
||||
7. 测试文件放 `tests/unit/test_<主题>.py`;集成测试放 `tests/integration/test_<主题>.py` **新文件**。**本批任何人不得编辑已有测试文件**(上批例外已用尽)。
|
||||
8. **不修改现有测试的断言语义**。
|
||||
9. 遇到与任务文档冲突的现实情况(比如文档说的符号不存在):**不要自行扩大范围**,在提交信息里写明偏差与原因。
|
||||
|
||||
## 5. 完成定义(DoD)
|
||||
|
||||
1. 任务文档「验收标准」全部满足;
|
||||
2. §3 的检查全绿;
|
||||
3. 新增代码有对应测试(纯逻辑必须有单测;涉及 DB 的路径按任务文档要求补集成测试);
|
||||
4. 提交信息按任务文档给定格式,正文用中文说明实现了什么、测了什么、有何偏差。
|
||||
|
||||
## 6. 冲突隔离原理(知其所以然)
|
||||
|
||||
- 本批 3 个分支最终由协调者**顺序合并**,每合一个跑一轮全量门禁(含 `make golden`——CI 防线 5c);
|
||||
- 你分支里的每一个文件改动,理论上不应与其它任务重叠——这是"并行不踩坑"的根本保证;
|
||||
- 如果你觉得"必须"改一个禁改文件才能完成任务:停下来,在提交信息里说明需求,由协调者在合并阶段统一处理。
|
||||
- 前两批(11 任务)用同样的方案跑完:零合并冲突,单测 169→473。照纪律执行即可。
|
||||
@@ -0,0 +1,106 @@
|
||||
# T1 · 全局日成本四档熔断降级
|
||||
|
||||
> 对应任务:`T-P1-25b` | 需求:`NFR-COST-02`、架构 §13.3 | 分支:`task/t1-tiered-budget` | worktree:`/home/xsl/meida_backend-t1`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把现有"单一日预算阈值"(`DailyBudgetGuard`:到 100% 拒绝非 critical 场景)升级为架构 §13.3 的**四档分级降级**,并补上 T-P1-25b 的验收要求:**被熔断拒绝的请求配额净扣减 = 0(须有集成断言)**。
|
||||
|
||||
四档(阈值可配置,百分比相对 `LLM_DAILY_BUDGET_CENTS`):
|
||||
|
||||
| 档位 | 阈值 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| tier 1 `warn` | ≥60% | 只告警(日志 + 指标),不影响任何请求 |
|
||||
| tier 2 `degrade_secondary` | ≥80% | 拒绝 blocked 列表中的场景(默认 `hd_export`、`report_regen`) |
|
||||
| tier 3 `degrade_generation` | ≥90% | 追加拒绝(默认 `hairstyle`、`tryon`) |
|
||||
| tier 4 `block_all` | ≥100% | 拒绝所有非 critical 场景,**但 allow 名单内的场景放行**(默认 `agent_chat`——架构原文"仅保留在途任务与纯文本对话") |
|
||||
|
||||
规则:
|
||||
- **critical 场景永远豁免**(现有 `SceneConfig.critical`,如 `INTENT_CLASSIFY`),但照常计入成本;
|
||||
- **手动干预**:Redis 键可强制指定当前档位(运营解除/加严),优先级高于成本水位;
|
||||
- 在途任务放行 = 熔断只拦网关**新请求**(现有行为已满足,不要在 worker 侧加检查)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,直接用,禁止改动)
|
||||
|
||||
### 配置(`packages/shared/config.py` 已加字段 + `.env.example` 已示例)
|
||||
|
||||
```python
|
||||
llm_budget_tiers: list[dict[str, Any]] # env: LLM_BUDGET_TIERS_JSON,**JSON 是一个列表**(无外层 key)
|
||||
```
|
||||
默认 JSON(元素结构:`{"threshold": 0.6, "name": "warn", "blocked": []}`;`blocked` 为字符串列表,`"*"` 表示全部;`allow` 仅 `block_all` 档有意义):
|
||||
```json
|
||||
{"tiers":[
|
||||
{"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"]}
|
||||
]}
|
||||
```
|
||||
|
||||
### 错误码(`packages/shared/errors.py` 已加)
|
||||
|
||||
- `LLM_SCENE_DEGRADED = 60006`,meta:http 503,message「该功能今日暂时关闭」,hint「AI 服务今日已达服务上限,明天恢复」。
|
||||
|
||||
### 现有代码事实(先读这些文件再动手)
|
||||
|
||||
- `packages/llm/budget.py`:`DailyBudgetGuard(counter, *, daily_budget_cents, enabled, key_prefix, tz_offset_hours, clock)`;方法 `current_key()`(`llm:cost:daily:{UTC+8日期}`)、`spent_today()`、`ensure_within_budget(scene, critical)`、`add_cost(cost_cents)`;`CostCounter` Protocol(`InMemoryCostCounter`/`RedisCostCounter`)。
|
||||
- `packages/llm/gateway.py:110`:`await self._budget.ensure_within_budget(scene=scene.value, critical=config.critical)` —— 你要替换的就是这一处调用点。
|
||||
- `packages/llm/errors.py`:已有 `DailyBudgetExceededError`。
|
||||
- `apps/api/routers/agent.py:38`:已 catch `DailyBudgetExceededError → LLM_DAILY_BUDGET_EXCEEDED`。
|
||||
- `packages/agent/service.py`:reserve → commit → gateway 异常时**先 refund 再 raise**(配额净扣减=0 的机制已存在,你只需用集成测试证明它)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
全部改动限定在:`packages/llm/budget.py`、`packages/llm/errors.py`、`packages/llm/gateway.py`(仅调用点与构造参数)、`apps/api/routers/agent.py`(异常映射)、`apps/api/deps.py` **不许改**——预算档配置从 `Settings.llm_budget_tiers` 读取的地方在哪?`build_llm_gateway()` 已在 deps.py(禁改)。**因此**:`DailyBudgetGuard.__init__` 增加关键字参数 `tiers: list[dict[str, Any]] | None = None`(默认 None = 内置默认四档),deps.py 已在协调提交里改为传入 `tiers=list(settings.llm_budget_tiers)`。你只管实现。
|
||||
|
||||
1. **budget.py**:
|
||||
- 新增 pydantic `BaseModel`:`BudgetTier(threshold: float, name: str, blocked: list[str], allow: list[str] = [])`(llm 层允许 pydantic);
|
||||
- `DailyBudgetGuard` 扩展:`current_tier() -> int`(0=未触发,1~4 如上)、`tier_name()`、`blocked_scenes() -> frozenset[str] | "*"`;
|
||||
- 新方法 `ensure_scene_allowed(*, scene: str, critical: bool = False) -> None`:
|
||||
- `critical` → 直接返回;
|
||||
- 手动 override(Redis `llm:budget:override`,值为 "1"~"4",**读不到/值非法则忽略**)优先于水位;
|
||||
- 按档位判定:tier2/3 且 scene ∈ blocked → 抛 `SceneDegradedError(scene, tier_name)`(新异常,加到 `errors.py`);tier4 且 `blocked=="*"` 且 scene ∉ allow → 抛 `DailyBudgetExceededError`(**保持 100% 档的既有语义与 60003 错误码不变**——现有测试依赖它);
|
||||
- 保留 `ensure_within_budget()` 兼容(内部转调 `ensure_scene_allowed`),或直接替换调用点后删除——二选一,保持 `gateway.py` 只调一个方法;
|
||||
- 新增 `set_override(tier: int)` / `clear_override()`(写 Redis,TTL 到当日 UTC+8 24 点),供运营/演练用;
|
||||
- **指标**:`prometheus_client` 的 `Gauge("llm_budget_tier", ...)` 与 `Counter("llm_budget_blocked_total", ..., ["scene"])`(参考 `packages/llm/recording.py` 里 Prometheus 的用法);档位上升时 `logger.warning("budget_tier_raised", tier=..., spent=..., budget=...)`(仅上升沿打一次,避免每请求刷日志——实例内记 last_tier 即可)。
|
||||
2. **gateway.py**:把 `ensure_within_budget` 调用换成 `ensure_scene_allowed(scene=scene.value, critical=config.critical)`;构造函数若需传 tiers,加默认参数(deps 已传)。
|
||||
3. **agent.py**:新增 `except SceneDegradedError as exc: raise AppError(ErrorCode.LLM_SCENE_DEGRADED) from exc`。
|
||||
4. **mypy strict**:budget.py 在 `packages/llm` 内,全量标注。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
单测(`tests/unit/test_tiered_budget.py` 新文件;现有 `test_daily_budget.py` 保持通过,允许因 API 扩展做**增量**修改):
|
||||
- 四档边界:59.9%/60%/79.9%/80%/89.9%/90%/99.9%/100% 时 `current_tier()` 正确;
|
||||
- tier2 拒绝 `hd_export` 放行 `agent_chat`;tier4 拒绝非 allow 非 critical;critical 全档豁免;
|
||||
- override 强制 tier4 / 清除后回落;
|
||||
- `allow=["agent_chat"]` 在 tier4 生效;
|
||||
- 计数用 `InMemoryCostCounter` + 注入 clock(参考现有测试)。
|
||||
|
||||
集成测试(**编辑 `tests/integration/test_vertical_slice.py`**,你是唯一允许编辑它的任务):
|
||||
- 用 `monkeypatch.setenv` 把 `LLM_DAILY_BUDGET_CENTS` 设为极小值(如 "1")、`LLM_BUDGET_TIERS_JSON` 把 `block_all.blocked` 设为 `["agent_chat"]`(即在本测试里连纯文本对话也拦),`get_settings.cache_clear()` 后 `create_app`;
|
||||
- 先用 Redis 客户端把当日成本键 `INCRBYFLOAT` 到预算以上(键格式照 `DailyBudgetGuard.current_key()` 计算);
|
||||
- `POST /v1/agent/chat`(FakeProvider,参考现有 fixture)→ 断言:响应 code 为 60003(或 60006,取决于你把 agent_chat 归到哪个异常——按上面设计应为 60003)、**且 `bl_ledger` 中该用户当日 `consume` 流水净和为 0(reserved 后 refund)**——这正是 T-P1-25b 的验收"被熔断拒绝的请求配额净扣减 = 0,须有集成断言覆盖 reserve 之后才触发熔断这条路径";
|
||||
- 测试结束 `get_settings.cache_clear()` 恢复,避免污染其它用例。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/agent/**`、`packages/billing/**`、`packages/identity/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{auth,quota,tasks,events,uploads}.py`、`apps/api/schemas.py`。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 四档降级可由配置驱动,`warn` 档无用户可感知变化;
|
||||
2. 手动 override 可加严/解除(redis-cli 可操作,写清键名与取值到 budget.py docstring);
|
||||
3. 集成断言证明"reserve 之后熔断 → refund → 净扣减 0";
|
||||
4. §3 检查全绿(mypy 范围:`packages/llm` strict + `apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(llm): four-tier daily budget degradation with manual override
|
||||
|
||||
四档熔断(warn/secondary/generation/block_all)+ critical 豁免 + Redis 手动 override
|
||||
+ Prometheus 指标 + 集成断言:被拒请求配额净扣减为 0(T-P1-25b)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# T10 · 登录·华为账号(代码先行,联调后置)
|
||||
|
||||
> 对应任务:`T-P1-12` | 需求:`FR-ACC-01`(登录成功率 ≥99%、UnionID 映射唯一、首次自动建用户)| 分支:`task/t10-auth-huawei` | worktree:`/home/xsl/meida_backend-t10`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /auth/huawei`:客户端传华为 Account Kit 签发的 **id_token**,服务端按 OIDC 标准做 **JWKS 验签 + claims 校验**,映射到内部用户并签发双令牌(access + refresh,复用 T4 已交付的 `issue_session`)。
|
||||
|
||||
**本任务性质是"代码先行"**:华为 AGC 后台尚未配置(见《前置开通清单》§3),因此真实联调后置;全部逻辑用**自造 RSA 密钥对 + 本地 JWKS** 做单测/集成,凭证到位后只改 `.env` 即可上线。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(config.py 已加)
|
||||
|
||||
```python
|
||||
huawei_jwks_url: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/certs" # HUAWEI_JWKS_URL
|
||||
huawei_audience: str = "" # HUAWEI_AUDIENCE,= AGC App ID,联调时填
|
||||
huawei_issuer: str = "https://accounts.huawei.com" # HUAWEI_ISSUER(以 Account Kit 文档为准,可配)
|
||||
huawei_jwks_cache_seconds: int = 86400
|
||||
```
|
||||
|
||||
### 可复用的现有设施(先读再写)
|
||||
|
||||
- `packages/identity/service.py`:`get_or_create_by_external_identity(provider, union_id, open_id, register_source)`(T4 已有);`issue_session(user_id) -> SessionIssued`(T4 交付,含 refresh 轮换结构)。
|
||||
- `packages/shared/security.py`:`create_access_token(...)`(HS256 access)。
|
||||
- `packages/identity/schemas.py`:`TokenPairResponse`(T4 交付)——**直接复用,不要在本任务重复定义**。
|
||||
- `pyjwt[crypto]` 已装(含 cryptography,RS256 验签可用)。
|
||||
|
||||
### 端点归属
|
||||
|
||||
`apps/api/routers/auth.py` 归你(T4 已合并完,本批无他人碰它)。`apps/api/routers/auth_sms.py` 归 T11,**禁碰**。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/huawei.py`(新建,核心逻辑全在此)、`apps/api/routers/auth.py`(追加端点)、测试文件。**不改 identity/service.py 与 identity/schemas.py**(避免与 T11 冲突——你的模型定义在 huawei.py 内)。
|
||||
|
||||
1. **`packages/identity/huawei.py`**:
|
||||
- `HuaweiIdTokenVerifier`:构造参数 `(jwks_url, audience, issuer, cache_seconds, *, http_fetch=None, now=None)`——`http_fetch` 是 `Callable[[str], Awaitable[str]]`(单测注入本地 JWKS,**不连外网**);JWKS 内存缓存(TTL 按配置,kid 变化时强制刷新一次并重试验签);
|
||||
- `verify(id_token) -> HuaweiClaims`(frozen dataclass:`union_id/open_id/exp/iat`):RS256 验签(`jwt.decode` + `PyJWK`/手工 kid 匹配)、`aud`(等于配置 audience,空配置时**拒绝**而不是跳过——联调前后都必须严格)、`iss`、`exp/iat`、`union_id` 缺失拒绝(华为子账号场景无 union_id 时降级用 open_id 的策略**不做**,注释说明留给联调期决定);
|
||||
- 错误映射:验签失败/claims 不符 → `AppError(TOKEN_INVALID)`(40102,已有);JWKS 拉取失败 → `AppError(UPSTREAM_ERROR)`(60000,已有)——**不需要新错误码**。
|
||||
2. **路由**(auth.py 追加):
|
||||
```python
|
||||
@router.post("/huawei", summary="华为账号授权登录(FR-ACC-01)")
|
||||
async def huawei_login(payload: HuaweiLoginRequest, ...) -> ...:
|
||||
claims = await verifier.verify(payload.id_token)
|
||||
user = await identity.get_or_create_by_external_identity(
|
||||
provider="huawei", union_id=claims.union_id, open_id=claims.open_id,
|
||||
register_source="huawei")
|
||||
issued = await identity.issue_session(user.user_id)
|
||||
access = create_access_token(...)
|
||||
return ok(TokenPairResponse(...)) # 复用 T4 模型
|
||||
```
|
||||
- verifier 用依赖注入(路由模块内建一个 `get_huawei_verifier` 的 FastAPI Depends,从 settings 构造并缓存到 `app.state` 或模块级 lru——**不改 deps.py**,在 auth.py 内实现);
|
||||
- `HuaweiLoginRequest(id_token: str)` 定义在 huawei.py。
|
||||
3. **模块 docstring** 写明:真实联调前置 = AGC 应用创建 + 证书指纹双套 + `HUAWEI_AUDIENCE` 填 App ID(引用《前置开通清单》§3)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_huawei_verifier.py`:测试内用 `cryptography` 生成 RSA 密钥对、自签 id_token + 本地 JWKS dict:
|
||||
1. 合法 token → claims 解析正确;
|
||||
2. 错误签名(换密钥)→ 40102;过期 → 40101(复用 TOKEN_EXPIRED);aud 不符 → 40102;audience 未配置 → 40102;
|
||||
3. JWKS 拉取失败 → 60000;kid 轮换 → 缓存刷新后验签成功。
|
||||
- 集成 `tests/integration/test_auth_huawei.py`(新文件):monkeypatch 注入 FakeFetcher(本地 JWKS)→ `POST /v1/auth/huawei` → 200 双令牌;再次登录同 union_id → 同一 user_id(映射唯一);新 access 可调 `GET /v1/quota`。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/identity/{service,repo,schemas,tokens}.py`、`apps/api/routers/auth_sms.py`、`packages/{kb,rules,retrieval}/**`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-01)
|
||||
|
||||
1. 验签逻辑完整(签名/aud/iss/exp/iat 全查),audience 未配置时拒绝启动校验路径;
|
||||
2. UnionID→内部 user_id 映射唯一,首次登录自动建用户(集成断言同 ID 复登);
|
||||
3. 全链路不连外网(FakeFetcher 注入);
|
||||
4. 全部检查绿(mypy 范围同 T6 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): Huawei Account Kit login via OIDC JWKS verification
|
||||
|
||||
POST /auth/huawei:JWKS 验签(kid 缓存/轮换)+ claims 严格校验 +
|
||||
UnionID 映射 + T4 双令牌签发(FR-ACC-01;真实联调待 AGC 配置)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# T11 · 登录·手机号验证码(代码先行,联调后置)
|
||||
|
||||
> 对应任务:`T-P1-12b/12c` | 需求:`FR-ACC-04`(6 位、5 分钟、一次性、失败 5 次作废)、`FR-ACC-14`(三重频控防刷)、`TD-32` | 分支:`task/t11-auth-sms` | worktree:`/home/xsl/meida_backend-t11`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
手机号验证码登录全链路(发送 → 校验 → 建户 → 双令牌)+ **短信轰炸防护**。腾讯云短信签名/模板还在审核(《前置开通清单》§1),因此真实发送后置:`SmsSender` 走 Protocol 隔离,默认 `fake` 实现(验证码打日志,dev 可用),腾讯实现写好待凭证。
|
||||
|
||||
**FR-ACC-04 纪律:与华为通道不得互相依赖**——本任务不 import 华为相关任何东西,登录成功路径只依赖 identity 域已有设施。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(config.py 已加,.env.example 已示例)
|
||||
|
||||
```python
|
||||
sms_provider: str = "fake" # SMS_PROVIDER: fake | tencent
|
||||
tencent_sms_secret_id/key/sdk_app_id/sign_name/template_id: str = ""
|
||||
sms_code_ttl_seconds: int = 300 # 5 分钟(FR-ACC-04)
|
||||
sms_code_length: int = 6
|
||||
sms_verify_max_attempts: int = 5 # 校验失败 5 次作废
|
||||
sms_resend_cooldown_seconds: int = 60 # 同号 60s 内 1 条
|
||||
sms_hourly_limit: int = 5 # 同号 1h ≤5
|
||||
sms_daily_limit: int = 10 # 同号 24h ≤10
|
||||
sms_ip_daily_limit: int = 20 # 同 IP 24h ≤20
|
||||
sms_device_daily_limit: int = 10 # 同设备 24h ≤10
|
||||
```
|
||||
|
||||
### 错误码(errors.py 已加,含 meta)
|
||||
|
||||
- `SMS_CODE_INVALID = 40110`(401「验证码错误」hint「请检查后重试」)
|
||||
- `SMS_CODE_EXPIRED = 40111`(401「验证码已过期」hint「请重新获取」)
|
||||
- `SMS_SEND_TOO_FREQUENT = 42910`(429「发送太频繁了」hint「请稍后再试」)
|
||||
- `SMS_DAILY_LIMIT = 42911`(429「今日发送次数已达上限」hint「明天再试」)
|
||||
- `SMS_PROVIDER_FAILED = 61000`(502「短信服务暂时不可用」)
|
||||
|
||||
### 依赖与设施
|
||||
|
||||
- `tencentcloud-sdk-python-sms` 已装(协调提交)。
|
||||
- 可复用(先读再写):`packages/identity/service.py` 的 `get_or_create_by_external_identity`(provider 用 `"phone"`)与 `issue_session`(T4);`packages/identity/schemas.py` 的 `TokenPairResponse`——**复用,不重复定义、不修改该文件**。
|
||||
- 路由骨架:`apps/api/routers/auth_sms.py` 空路由(prefix `/auth`)已注册 main.py——归你填充。`apps/api/routers/auth.py` 归 T10,**禁碰**。
|
||||
|
||||
### 号码存储决策(写进代码注释)
|
||||
|
||||
V1 简化:`id_user_identity` 现有列无 `phone_enc`,用 `union_id = "phone:" + sha256(规范化号码)[:16]`(E.164 规范化:去 +86/前导零/空白后哈希)。**Redis 与日志中一律不存明文号码**——键用 `sha256(phone)` 前 16 位,日志打码 `138****1234`(FR-ACC-14 掩码要求)。`phone_enc` 列的补齐属后续迁移任务,不在本批。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/sms_login.py`(新建,全部核心逻辑)、`apps/api/routers/auth_sms.py`、测试文件。**不改 identity/{service,repo,schemas,tokens,huawei}.py。**
|
||||
|
||||
1. **`packages/identity/sms_login.py`**:
|
||||
- `SmsSender` Protocol:`async def send_code(phone_masked_key: str, code: str) -> None`(收掩码键不收明文——防实现层记日志泄漏);
|
||||
- `FakeSmsSender`:`logger.info("sms_code_issued", masked=..., code=...)`(dev 专用,注释写明生产必须切 tencent);
|
||||
- `TencentSmsSender`:包官方 SDK(`SmsClient` + `SendSmsRequest`,参数从 settings 读);构造失败/响应非 ok → `AppError(SMS_PROVIDER_FAILED)`;**本批不要求真实联调,但代码路径要完整可读**;
|
||||
- `SmsAuthService(redis, sender, *, settings)`:
|
||||
- `request_code(phone, ip, device_id)`:号码格式校验(大陆手机号正则 `^1[3-9]\d{9}$`,写死并注释 V1 只做大陆)→ **先频控后发送**:同号 cooldown/hourly/daily、IP daily、设备 daily 全过才生成 6 位随机码(`secrets.randbelow`,非 random)→ 存 Redis `sms:code:{phone_hash}`(值 = sha256(code),**不存明文**,TTL 300s)+ 计数器自增 → sender 发送;
|
||||
- 频控计数键按自然窗口(cooldown 用 TTL;hourly/daily 用 UTC+8 小时/日期桶,对齐 billing 的时区纪律);
|
||||
- `verify(phone, code, ip, device_id)`:取码哈希比对(**恒定时间比较** `hmac.compare_digest`)→ 错误则 attempts+1(`sms:attempts:{phone_hash}`,达 5 删码并返回 40110)→ 成功删码(一次性)→ 由调用方建户;
|
||||
- 全部日志打码:`masked_phone(phone) -> "138****1234"`。
|
||||
2. **路由**(auth_sms.py):
|
||||
- `POST /auth/sms/send` body `{phone, device_id}`(ip 从 `Request.client.host` 取)→ 限流错误码透传;
|
||||
- `POST /auth/sms/verify` body `{phone, code, device_id}` → 成功后 `get_or_create_by_external_identity(provider="phone", union_id=..., register_source="phone")` + `issue_session` → `ok(TokenPairResponse(...))`;
|
||||
- 请求/响应模型定义在 sms_login.py 或新建 `packages/identity/sms_schemas.py`(二选一,不碰共享 schemas)。
|
||||
3. **刷量测试**(FR-ACC-14 验收原文"脚本化刷量测试证明拦截有效"):集成测试用循环模拟。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_sms_login.py`(fakeredis 或自写内存 dict 实现 Protocol 化的 redis 接口——参考 `InMemoryCostCounter` 的做法,不引新依赖):
|
||||
1. 号码正则(非法格式 400 类错误);
|
||||
2. 频控矩阵:cooldown 内二次 → 42910;1h 第 6 条 → 42910;24h 第 11 条 → 42911;IP 第 21 条 / 设备第 11 条 → 42911;
|
||||
3. 验证码:错误 4 次可再试、第 5 次后码作废(40110 且码已删)、过期(TTL 模拟)→ 40111、成功后码即失效(二次 verify 40111);
|
||||
4. 掩码函数;恒定时间比较(不 assert 时长,只 assert 行为)。
|
||||
- 集成 `tests/integration/test_auth_sms.py`(新文件,SMS_PROVIDER=fake,从响应前的日志拿不到码——**测试钩子**:fake sender 把码写进 Redis 测试键或直接用 fixture 替换 sender 为捕获型 fake,不要为测试开后门到生产代码):
|
||||
1. send → verify(fixture 捕获的码)→ 200 双令牌,新 access 可用;
|
||||
2. 同号再登录同 user_id(映射唯一);
|
||||
3. **刷量脚本化**:连发 11 次,第 11 次 42911;换 IP 维度再验一轮;
|
||||
4. 错误码 5 次作废路径。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/identity/{service,repo,schemas,tokens,huawei}.py`、`apps/api/routers/auth.py`、`packages/{kb,rules,retrieval}/**`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-04 / FR-ACC-14)
|
||||
|
||||
1. 验证码 6 位、5 分钟、一次性、失败 5 次作废——四点各有断言;
|
||||
2. 三重频控全部生效且有脚本化刷量测试证明;
|
||||
3. 号码与验证码在日志/Redis 均无明文(掩码 + 哈希);
|
||||
4. 与华为通道零耦合(不 import huawei 模块);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): phone SMS login with triple rate limiting and anti-abuse
|
||||
|
||||
SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/
|
||||
5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32;
|
||||
真实发送待签名审核)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# T12 · 极简运营后台
|
||||
|
||||
> 对应任务:`T-P2-09` 🔴 | 需求:`FR-ADM-01~05`、Sprint 2 出口「**顾问可独立完成内容维护、审核、发布与回滚,全程无工程介入**」| 分支:`task/t12-admin-console` | worktree:`/home/xsl/meida_backend-t12`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
`apps/admin/` 独立 FastAPI 应用(与 apps/api 平级,部署上分离):知识实体与规则的维护/审核/发布/回滚入口、素材登记与审核、知识缺口看板。**这是 M2 出口的成败标志**——顾问从此不找工程。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
### 配置与鉴权
|
||||
|
||||
- `settings.admin_token`(env `ADMIN_TOKEN`):**空字符串 = 全部 admin 端点 404**(等价未部署)。鉴权方式:请求头 `X-Admin-Token` 精确匹配(`hmac.compare_digest`);不匹配 → 40100(UNAUTHENTICATED)。
|
||||
- **apps 之间互不依赖**(import-linter):admin 不许 import `apps.api` 的任何东西——session 依赖、响应信封在 admin 包内自建(`ok`/`AppError` 来自 `packages.shared`,允许)。
|
||||
|
||||
### 可复用的服务层(先读再用,不改它们)
|
||||
|
||||
- `packages/kb/service.py`:`KbService`(实体 CRUD/状态机/图谱/色板)+ **`KbAssetService`(新)**:`register_asset(payload) -> KbAsset`、`review_asset(asset_id, payload)`(`tryon_ready` 只能随 `approved` 打开,服务端已强制)、`list_assets(...)`;schemas:`AssetCreate/AssetReview/AssetOut` 已加。
|
||||
- `packages/rules/service.py`:规则 CRUD + 状态机 + `PublishedRules` 缓存 `invalidate()`。
|
||||
- `packages/retrieval/service.py`:模块级 `list_gaps(session, ...)`(hit_count 降序)+ **`invalidate_cache()`**(发布编排后调用——T14 会在背后实现真缓存,你只管调)。
|
||||
- 实体状态机:draft→reviewing→published→archived(`KbService` 内已有 transition 语义,读源码确认方法名;若服务层没有显式 `transition` 方法,通过 `update_entity` 的 status 字段走 `assert_transition` 校验——以代码为准)。
|
||||
|
||||
### 现状
|
||||
|
||||
`apps/admin/__init__.py`(占位注释 FR-ADM-01~05)——你填充;无其它骨架。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`apps/admin/**`、测试文件。**不改任何 packages/**(服务层已齐;发现缺口写进偏差说明)。**
|
||||
|
||||
1. **`apps/admin/app.py`**:`create_admin_app(settings) -> FastAPI`;`lifespan` 里建 DB engine 复用 `packages.shared.db`;挂 `RequestContextMiddleware`、统一异常处理(`packages.shared.handlers`);路由前缀 `/admin/api/v1`(与客户端 API 区分);docs 仅 local。
|
||||
2. **`apps/admin/deps.py`**:`admin_auth` 依赖(读 `X-Admin-Token`,空配置 → 全部路由在装配时不注册或请求时 404——选**装配时不注册**,404 语义更干净);session 依赖(仿 `apps/api/deps.session_dep`,不 import 它)。
|
||||
3. **路由**(每域一个 router 文件,全部经 `admin_auth`):
|
||||
- **实体**:`GET/POST /kb/items`、`GET/PATCH /kb/items/{id}`、`POST /kb/items/{id}/transition`(body `{target: "reviewing"|"published"|"archived"}`,走服务层状态机;**发布成功后调用 `rules service invalidate()` + `retrieval invalidate_cache()`**——发布编排是后台的职责,这就是 T-P2-10 的"自然失效"入口)、`GET /kb/items/{id}/graph`;
|
||||
- **规则**:`GET/POST /rules`、`PATCH /rules/{id}`、`POST /rules/{id}/transition`(发布同样触发双 invalidate);
|
||||
- **素材**:`POST /assets`(登记)、`GET /assets`(entity/tryon_ready 过滤)、`POST /assets/{id}/review`(审核);
|
||||
- **缺口看板**:`GET /kb/gaps`(limit/offset,直接返回 `list_gaps` 结果)。
|
||||
4. **模块 docstring**:V1 极简鉴权的边界(共享令牌、无审计日志字段、无角色)与升级路径(运营账号体系属 V2;操作留证当前靠 structlog request_id)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_admin_app.py`:`ADMIN_TOKEN=""` 时所有路由 404(用 TestClient 打几个代表路径);令牌错误 → 40100;正确令牌 → 200(服务层用假 session 或只测装配层,不连库——若 router 直接依赖 DB session,把鉴权/404 逻辑测在装配层即可)。
|
||||
- 集成 `tests/integration/test_admin_console.py`(新文件,允许跑):
|
||||
1. 顾问全流程**不碰客户端 API**:建实体 → reviewing → published → 建规则 → 发布 → 断言规则可被 `PublishedRules` 缓存载入、检索新内容可见(简化断言:发布后 `invalidate` 被调用的副作用可观察——直接再查库即可);
|
||||
2. 非法跃迁 published→draft → 40900;
|
||||
3. 素材:登记(tryon_ready 恒 False)→ 审核 approved+tryon_ready=true → 列表过滤 `tryon_ready=true` 命中;
|
||||
4. 审核不通过时 tryon_ready=true → 400(服务层约束透传);
|
||||
5. 缺口:预插两行 kb_gap → `GET /kb/gaps` 按 hit_count 降序;
|
||||
6. 无令牌/错令牌 → 401。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:**全部 `packages/**`**、`apps/api/**`、所有已有测试文件。
|
||||
|
||||
## 6. 验收标准(T-P2-09)
|
||||
|
||||
1. 实体/规则/素材/缺口四类操作全部有 HTTP 入口,鉴权生效(空 token 404、错 token 401);
|
||||
2. 发布编排(publish → rules invalidate + retrieval invalidate_cache)落地;
|
||||
3. 集成测试覆盖顾问全流程(无工程介入语义);
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared ... packages/retrieval` 全列——照 T6 文档同口径,含你的 `apps/admin`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(admin): minimal console for KB content operations
|
||||
|
||||
apps/admin 独立应用:X-Admin-Token 鉴权(空=404)、实体/规则维护与状态机、
|
||||
发布编排(双 invalidate)、素材登记与审核、缺口看板(T-P2-09,FR-ADM-01~05)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# T13 · 黄金集回归框架接入 CI
|
||||
|
||||
> 对应任务:`T-P2-11` 🔴 | 需求:`FR-KB-11`、`NFR-MAINT-04`(指标退化阻断合并)、架构 §17「黄金集是最重要的一道防线」| 分支:`task/t13-golden-ci` | worktree:`/home/xsl/meida_backend-t13`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把黄金集从"散在单测里的固定样本"升级为**数据驱动的回归框架**:用例存 JSON、阈值存配置、CI 强制跑、**指标退化直接 fail**。V1 先落两个子集(规则黄金集、检索黄金集),Agent 问答与报告结构两个子集留占位(等 Sprint 3/4)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
- **pytest marker `golden`** 已注册(`--strict-markers` 下可直接用);默认 addopts 排除 `golden`(与 integration 同待遇),`make golden` / CI 防线 5c 强制跑(env `MEIDA_RUN_INTEGRATION=1`)。
|
||||
- **规则黄金集现有资产**:`tests/unit/test_rules_golden.py` 有 8 条规则 + 11 组样本(硬禁忌压制、多规则叠加等)——**不许改它**;你的 JSON 用例集是独立交付(可参考其场景设计新写,不是搬运代码)。
|
||||
- **检索侧可复用**:`packages/retrieval/embedding.py` 的 `DeterministicEmbedder`(同文本同向量→黄金集可断言确定性结果)+ `packages/rules` 的 DSL/引擎。
|
||||
|
||||
### 黄金集规模口径(知识库文档 §7,唯一事实源)
|
||||
|
||||
四子集共 **330 例**(检索 100 / Agent 问答 100 / 诊断 100 / 报告结构 30)。**本任务只搭框架 + 规则/检索各 10~15 例种子**,规模达标是内容轨 `T-P2-16` 的事——框架必须让"加用例=加 JSON 行"。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`tests/golden/**`(新目录,归你全部)、**仅此一处**;不改 packages/apps/已有测试。
|
||||
|
||||
1. **目录结构**:
|
||||
```
|
||||
tests/golden/
|
||||
README.md # 用例格式说明 + 如何加用例 + 阈值怎么调
|
||||
thresholds.json # {"rules": {"pass_rate_min": 1.0}, "retrieval": {"pass_rate_min": 0.8, "first_hit_in_top3_min": 0.8}}
|
||||
cases/
|
||||
rules_cases.json # 规则黄金集种子
|
||||
retrieval_cases.json # 检索黄金集种子
|
||||
runner.py # 用例加载 + 执行 + 指标计算 + 明细报告
|
||||
test_golden.py # pytest 入口(marker=golden,skipif 无 MEIDA_RUN_INTEGRATION)
|
||||
```
|
||||
2. **规则用例格式**(rules_cases.json):`{"name", "rules": [完整 Rule DSL 数组], "context": {...RuleContext}, "expect": {"must_recommend": [{"slot": "top", "attrs_include": {...}}], "must_avoid": [...], "hard_blocks_nonempty": bool, "forbidden_recommend": [...]}}`。判定:`evaluate()` 结果逐一核对 expect;一条不符 = 该用例 fail。
|
||||
3. **检索用例格式**(retrieval_cases.json):`{"name", "seed_entities": [{entity_type, name, attrs, status, chunks: [text]}], "query", "user_ctx": {...}, "expect": {"must_hit_names": [...], "must_miss_names": [...], "first_hit_in": "top1|top3"}`。执行:每个用例**先清自己 seed 的实体**(按 name 前缀 `golden:` 命名,跑前 DELETE 这些行,保证幂等重跑)→ DeterministicEmbedder 写 chunk → `hybrid_search` → 判定。`must_miss` 语义 = 硬过滤红线(禁忌/draft 实体绝不出现)。
|
||||
4. **指标与阻断**(runner.py):
|
||||
- 每子集算 `pass_rate`(通过用例/总数);检索加算 `first_hit_in_top3_rate`;
|
||||
- **规则子集阈值 = 1.0**(硬结论子集,一条不许退);检索 = 0.8 起步(阈值在 thresholds.json,顾问扩充用例后可调);
|
||||
- fail 时输出**逐用例明细**(name + 期望 vs 实际的差异摘要)——CI 日志里要能直接看出哪条退化了;
|
||||
- `runner.py` 是普通模块(不依赖 pytest),`test_golden.py` 只是薄壳调用——将来想在本地脚本或 admin 后台跑同一套,不用改框架。
|
||||
5. **README.md**:写明四子集规划(330 例口径)、当前覆盖(两子集 N 例)、加用例流程、"为什么规则阈值必须 1.0"(核心结论由规则产出,规则退化=产品根基退化)。
|
||||
|
||||
## 4. 测试要求(即框架本身)
|
||||
|
||||
- `make golden`(或 `MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m golden --no-cov`)全绿:两个种子子集通过率 100%(种子用例是手工校准过的,必须全对,否则框架自身有 bug);
|
||||
- **阻断语义自证**:在本地临时改坏一条用例的 expect 验证会 fail 且输出明细(验证完改回来——提交里只有正确版本;这个自证过程写进提交信息,不写代码);
|
||||
- 无 PG/Redis 时 `pytest -m golden` 全部 skip(不拖累本地 `make test`)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/**`、`apps/**`、`tests/unit/**`、`tests/integration/**`、`pyproject.toml`(golden marker 已注册)、`Makefile`/CI(防线 5c 已挂)。**你的全部产出在 `tests/golden/**`。**
|
||||
|
||||
## 6. 验收标准(T-P2-11)
|
||||
|
||||
1. CI 防线 5c 能跑且通过(本地等价命令全绿);
|
||||
2. 指标退化即 fail:人为退化会被拦截(自证过程留提交信息);
|
||||
3. 加用例 = 加 JSON 行,零代码改动(README 有流程);
|
||||
4. 规则子集含硬禁忌场景、检索子集含 must_miss(红线)场景;
|
||||
5. 全部检查绿(tests 不在 mypy 范围;ruff 对 tests/golden 全绿)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
test(golden): data-driven golden set framework wired into CI
|
||||
|
||||
tests/golden:JSON 用例 + 阈值配置 + runner(规则 pass_rate=1.0、检索 0.8
|
||||
起步)+ pytest 壳;规则/检索两子集种子,Agent/报告子集占位(T-P2-11)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
# T14 · 检索结果缓存与发布失效
|
||||
|
||||
> 对应任务:`T-P2-10` | 需求:发布后检索缓存自然失效(Sprint 2 验收行)| 分支:`task/t14-retrieval-cache` | worktree:`/home/xsl/meida_backend-t14`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
给 `RetrievalService.search` 加**进程内结果缓存**(TTL + 容量上限 + 代数失效),把协调提交里的 `invalidate_cache()` 桩变成真实现。缓存解决的是"同一 query 高频重复检索"(Agent 多轮、黄金集回归);失效保证知识发布后**不吐旧结果**。
|
||||
|
||||
## 2. 已就绪的契约(协调提交 `9c18021` 已提供)
|
||||
|
||||
- **配置**(config.py 已加,.env.example 已示例):`retrieval_cache_enabled=True`、`retrieval_cache_ttl_seconds=60`、`retrieval_cache_max_entries=512`。
|
||||
- **钩子已存在**:`packages/retrieval/service.py` 模块级 `invalidate_cache()`(当前只是 `repo.INVALIDATION_VERSION += 1`)与 `packages/retrieval/repo.py` 的 `INVALIDATION_VERSION = 1`——运营后台(T12)已经在调它。**你实现真缓存,不改钩子签名**。
|
||||
- `RetrievalService.__init__(session, *, embedding, weights, top_k)` 与 `from_settings(session, settings)`——缓存参数经 `from_settings` 读 Settings 注入(**不改 `__init__` 既有签名**,加带默认值的 keyword-only 参数)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/retrieval/cache.py`(新建)、`packages/retrieval/service.py`(缓存接线)、测试文件。**不改 repo.py 的既有函数与 INVALIDATION_VERSION 语义**。
|
||||
|
||||
1. **`packages/retrieval/cache.py`**(纯逻辑,重点单测):
|
||||
- `CacheKey`:`(version, query_text, body_shape, occasion, entity_types, rule_boost_hash, top_k)`——**全部影响结果的输入都必须进键**,缺一个就会串结果(rule_boost 是 dict[int,float],排序后序列化再哈希);
|
||||
- `TtlCache`:容量上限用**简单 LRU**(`OrderedDict` 移动末尾 + 超限 popitem(last=False));TTL 用惰性过期(读时比对时间戳,过期视作 miss);**线程安全不需要**(asyncio 单循环,无 await 竞态窗口——注释写明这个前提,别加锁);
|
||||
- 指标:`hits`/`misses`/`evictions` 计数器(Prometheus 可后续接,先暴露属性)+ `stats()` 方法。
|
||||
2. **service.py 接线**:
|
||||
- `RetrievalService` 增加 `cache: TtlCache | None`(None = 禁用,行为与现在完全一致);
|
||||
- `search()`:`cache` 非 None 且非 `degraded` 路径——命中直接返回缓存 `SearchOutcome`;未命中照常检索后**只在有结果时**写缓存(空结果不缓存:`record_gap` 每次都要跑,否则缺口计数被缓存吞掉);
|
||||
- `from_settings` 按 Settings 装配缓存;**模块级 `invalidate_cache()`**:把 service 模块持有的共享缓存实例(或 repo.INVALIDATION_VERSION,二选一,选实现更干净的那个)作废——调用方(admin 发布编排)不需要知道细节。
|
||||
3. **正确性红线**(写进 cache.py docstring):缓存的是 `SearchOutcome`(frozen dataclass,不可变)——**绝不缓存可变对象引用**;命中路径不得复用 session(缓存命中时 DB 零交互)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_retrieval_cache.py`(fake repo/embedding,不连库):
|
||||
1. 同参数二连查:第二次零 DB 调用(fake 计数断言)、结果相等;
|
||||
2. 任一键分量变化(query/occasion/body_shape/rule_boost/top_k)→ miss;
|
||||
3. TTL 过期 → miss(注入 clock);
|
||||
4. LRU 容量超限 → 最旧被逐、计数正确;
|
||||
5. 空结果不缓存(degraded 路径每次都触发 gap 记录 fake 调用);
|
||||
6. `invalidate_cache()` 后同参数 → miss(代数失效);
|
||||
7. cache=None → 行为与现在一致。
|
||||
- 集成 `tests/integration/test_retrieval_cache.py`(新文件):
|
||||
1. seed 实体 + chunk → search 命中 → **发布新实体(直接调 kb service 或 SQL 置 published)+ `invalidate_cache()`** → 再 search 能看到新实体(**这是 T-P2-10 的验收语义**);
|
||||
2. 不失效等 TTL 过期(TTL 配 1s,sleep 1.1s)→ 新实体同样可见(双保险)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/retrieval/repo.py` 的既有函数、`packages/{kb,rules}/**`、`apps/**`、所有已有测试文件。T12 会调 `invalidate_cache()`——**签名不变**是你与它的契约。
|
||||
|
||||
## 6. 验收标准(T-P2-10)
|
||||
|
||||
1. 同参重复检索零 DB 交互(单测断言);
|
||||
2. 发布后新内容立即可见(invalidate 路径)且 TTL 到期自然可见(双路径集成测试);
|
||||
3. 影响结果的输入全量进缓存键(rule_boost 哈希);
|
||||
4. 空结果不缓存(gap 埋点不被吞);
|
||||
5. 全部检查绿(mypy 范围照 T6 文档同口径 + `packages/retrieval`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(retrieval): TTL+LRU result cache with generation-based invalidation
|
||||
|
||||
TtlCache(全输入进键/惰性过期/容量逐出/命中零 DB)+ search 接线
|
||||
(空结果不缓存,保住 gap 埋点)+ invalidate_cache 真实现(T-P2-10)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# T2 · 任务奖励额度领取(claim)
|
||||
|
||||
> 对应任务:`T-P1-26` 收尾件 | 需求:`FR-BILL-03`、架构 §9.3 | 分支:`task/t2-task-bonus` | worktree:`/home/xsl/meida_backend-t2`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现产品方案 §3.2 的「做任务换额度」闭环的服务端:`POST /quota/tasks/{task_key}/claim`。
|
||||
发放即到账、**发放幂等**(同一任务同一周期不重复发放,FR-BILL-03 验收原文)、受 `bl_quota_rule.task_bonus_max` 封顶。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 配置(`config.py` 已加字段,`.env.example` 已示例)
|
||||
|
||||
```python
|
||||
task_bonus_rules: dict[str, dict[str, Any]] # env: TASK_BONUS_RULES_JSON
|
||||
```
|
||||
默认值(协调提交写死在 config.py,含义:task_key → {quota_key, amount, claim_period}):
|
||||
```json
|
||||
{
|
||||
"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"}
|
||||
}
|
||||
```
|
||||
⚠️ `amount` 全部是**占位值 1**——具体数值待产品在产品方案 §3.2 补充后改 env,不改代码(FR-BILL-04 配置化)。代码注释里必须写明这一点。
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `TASK_BONUS_UNKNOWN = 40410`:404「任务不存在或暂未开放」;
|
||||
- `TASK_BONUS_ALREADY_CLAIMED = 40910`:409「这个周期的任务奖励已领取」,hint「下个周期再来」;
|
||||
- `TASK_BONUS_CAP_REACHED = 40310`:403「任务奖励已达上限」,hint「明天再来,或等额度自然恢复」。
|
||||
(注意:这些文案已按 FR-BILL-07 写好,**不得出现价格/购买/会员/升级字样**,你不需要也不要改 meta。)
|
||||
|
||||
### 现有代码事实(先读再写)
|
||||
|
||||
- `packages/domain/billing/quota.py`:`QuotaState(used, base_limit, bonus, hard_cap)`、`period_key(now, period_type, tz_offset_hours)`、`period_reset_at(...)`、`reserve_idempotency_key`/`refund_idempotency_key`;`PeriodType = Literal["day","month","lifetime"]`。
|
||||
- `packages/billing/service.py`:`BillingService(session, tz_offset_hours)`;`QuotaSnapshot`;`reserve/commit/refund/get_quota/list_ledger`;内部 `_load_rule(quota_key)`、`lock_or_create_quota(...)`(返回已行锁的 `Quota` 行,`limit_value` 是 base_limit 快照,`bonus_granted` 是本周期已获奖励)。
|
||||
- `packages/billing/repo.py`:`add_ledger`/`find_ledger_by_idempotency_key` 等(照现有模式补你需要的)。
|
||||
- `packages/shared/models.py`:`Ledger`(`bl_ledger`:`tx_id` PK、`delta`、`tx_type`、`status`、`idempotency_key` UNIQUE、`balance_after`、`quota_key`、`period_key`、`reason`、`ref_type`、`ref_id`)。
|
||||
- `apps/api/routers/quota.py`:现有 `GET /quota`、`GET /quota/ledger`,响应模式照抄。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/billing/service.py`、`packages/billing/repo.py`、`packages/billing/schemas.py`(新建)、`packages/domain/billing/quota.py`、`apps/api/routers/quota.py`、测试文件。
|
||||
|
||||
1. **纯域函数**(`packages/domain/billing/quota.py`,mypy strict):
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BonusDecision: ...
|
||||
def grant_bonus(state: QuotaState, amount: int, task_bonus_max: int) -> QuotaDecision-like
|
||||
```
|
||||
语义:`bonus + amount > task_bonus_max` → 不允许(封顶);否则返回 `replace(state, bonus=state.bonus + amount)`。`task_bonus_max` 来自 `bl_quota_rule`。
|
||||
2. **服务方法** `BillingService.claim_task_bonus(user_id, task_key, rules)`:
|
||||
- task_key 不在 rules → `AppError(TASK_BONUS_UNKNOWN)`;
|
||||
- 解析 `{quota_key, amount, claim_period}`;`rule = await self._load_rule(quota_key)`;
|
||||
- `pk = period_key(now, claim_period, tz)`;幂等键 `f"grant:{user_id}:{task_key}:{pk}"`;
|
||||
- 先查 `bl_ledger.idempotency_key` 已存在 → `TASK_BONUS_ALREADY_CLAIMED`(幂等快捷路径);
|
||||
- `lock_or_create_quota`(同周期)→ `grant_bonus(...)` 封顶判定 → 不允许则 `TASK_BONUS_CAP_REACHED`(**不写任何流水**);
|
||||
- 同一事务内:更新 `bl_quota.bonus_granted`,插入 `Ledger(tx_type="system_grant", delta=0, status="committed", balance_after=state.remaining, reason=task_key, ref_type="task_bonus", ref_id=task_key)`,提交;
|
||||
- 并发兜底:`IntegrityError`(唯一键冲突)→ 回滚 → `TASK_BONUS_ALREADY_CLAIMED`。**`delta=0` 的理由写进注释**:system_grant 不改变 `used`,只提高有效额度,`balance_after` 仍按 remaining 语义记录(流水可重算的是 used,不是余额本身——与现有 reserve/commit 的记账口径一致)。
|
||||
3. **路由**(`apps/api/routers/quota.py` 追加):`POST /tasks/{task_key}/claim`(注意 router prefix 是 `/quota`,最终路径即架构 §9.3 的 `POST /quota/tasks/{task_key}/claim`)。请求无需 body;响应 `ok(TaskBonusClaimResponse(...))`,字段:`task_key/quota_key/amount/bonus_granted/remaining`。Pydantic 模型放 **`packages/billing/schemas.py`**(新建;apps/api/schemas.py 禁改)。
|
||||
- rules 从 settings 读:路由函数签名加 `settings: SettingsDep`,传 `dict(settings.task_bonus_rules)`。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_bonus_domain.py`:`grant_bonus` 的封顶边界(bonus+amount == max 允许、== max+1 拒绝)、amount≤0 抛 ValueError、不改变 used。
|
||||
- 单测 `tests/unit/test_task_bonus_rules.py`:settings 解析 `TASK_BONUS_RULES_JSON`(字符串 JSON / dict / 空值容错)——参考 config.py 的 `_parse_json_object` 模式,如果协调提交已把 `task_bonus_rules` 加进该 validator 的字段列表就不用测解析,直接测默认值形状(每个 entry 必有 quota_key/amount/claim_period,claim_period ∈ PeriodType)。
|
||||
- 集成测试 `tests/integration/test_task_bonus.py`(新文件,允许跑):
|
||||
1. dev-token 登录 → claim `sign_in` → 200,`GET /quota?quota_key=agent_chat` 的 `bonus_granted` = 1;
|
||||
2. 再 claim 同一 task → 40910;
|
||||
3. 把 Redis 无关、纯 DB 断言:`bl_ledger` 出现 `system_grant` 行且 `balance_after` = base+bonus;
|
||||
4. 封顶路径可用 `TASK_BONUS_RULES_JSON` 覆盖 amount 为 11(> task_bonus_max=10)→ claim → 40310,且无新流水。
|
||||
(settings 覆盖用 `monkeypatch.setenv + get_settings.cache_clear()`,结束恢复,参考 T1 文档的写法。)
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/agent/**`、`packages/identity/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{agent,auth,tasks,events,uploads}.py`、`apps/api/deps.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准(FR-BILL-03 原文)
|
||||
|
||||
1. 「签到、完善资料、反馈、邀请、分享完成即到账」——五类 task_key 全部走通 claim;
|
||||
2. **发放幂等**:同一任务同一周期重复 claim 返回 40910,账本无重复行;
|
||||
3. 封顶:`bonus_granted` 永不超过 `bl_quota_rule.task_bonus_max`;
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage` + `packages/domain` strict)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(billing): task bonus claim endpoint with idempotency and cap
|
||||
|
||||
POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
|
||||
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# T3 · 客户端埋点批量上报
|
||||
|
||||
> 对应任务:`T-P1-10`(埋点事件字典/上报通道的服务端部分)| 需求:`FR-SYS-06`、架构 §9.3 | 分支:`task/t3-events` | worktree:`/home/xsl/meida_backend-t3`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /events/batch`:客户端批量上报埋点事件,落 `ob_event` 表。这是 V1 全链路埋点(FR-SYS-06)与后续三张看板(TD-24)的数据入口。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 表(迁移 `0004` 已建,ORM 已加,**你不需要碰 models.py / migrations**)
|
||||
|
||||
`ob_event`(架构 §6.2):
|
||||
- `event_id` bigint PK(雪花)、`user_id` bigint NOT NULL、`event_name` varchar(64)、`props` JSONB、`client_ts` timestamptz NULL、`server_ts` timestamptz NOT NULL default now()、
|
||||
- 索引:`(event_name, server_ts)`、`(user_id, server_ts)`。
|
||||
- 架构原文"按月分区"——**V1 先普通表**(与 ob_ai_call 同样处理),代码注释写明"分区在数据量上来后再做,先不预支复杂度"。
|
||||
|
||||
### 配置(`config.py` 已加字段)
|
||||
|
||||
```python
|
||||
events_max_batch: int = 50 # env: EVENTS_MAX_BATCH
|
||||
events_props_max_bytes: int = 8192 # env: EVENTS_PROPS_MAX_BYTES
|
||||
```
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `EVENTS_BATCH_REJECTED = 40020`:400「埋点数据格式有误」,hint=null(对客户端静默降级即可,不打扰用户)。
|
||||
|
||||
### 骨架(协调提交已建,直接填充)
|
||||
|
||||
- `packages/analytics/__init__.py`、`packages/analytics/README.md`、`packages/analytics/service.py`(空骨架)
|
||||
- `apps/api/routers/events.py`(空骨架 router,**已注册进 main.py**,prefix `/events`)
|
||||
- `apps/api/deps.py` 已有 `AnalyticsServiceDep`(构造 `AnalyticsService(session)`)——**deps.py 禁改,签名如不满足需要,用路由函数内自建服务对象的方式,不改 deps**。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages.analytics` 被协调提交放进了 import-linter 的"共享能力层"(与 identity/billing/task 同层)——它只能 import `packages.shared`/`packages.domain`,**不得** import 其它业务包或 llm。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/analytics/**`、`apps/api/routers/events.py`、测试文件。
|
||||
|
||||
1. **`packages/analytics/schemas.py`**(新建):
|
||||
- `EventIn`:`event_name: str`(`pattern=r"^[a-z][a-z0-9_.]{1,63}$"`)、`props: dict[str, Any] = {}`、`client_ts: datetime | None = None`;
|
||||
- `EventBatchRequest`:`events: list[EventIn]`(`min_length=1`)、
|
||||
- `EventBatchResponse`:`accepted: int`。
|
||||
- **事件字典(event dictionary)**:V1 不做服务端枚举校验(客户端事件名集合由 TD-23 埋点字典维护,尚未定稿)——用正则约束形状即可,代码注释写明"字典校验等 TD-23 定稿后再收紧"。
|
||||
2. **`packages/analytics/service.py`**:
|
||||
- `AnalyticsService(session)`:`async def record(user_id: int, batch: list[EventIn]) -> int`;
|
||||
- 校验(fail-close,任一不过 → 整批拒绝 `AppError(EVENTS_BATCH_REJECTED)`,**不部分落库**):批量 ≤ `events_max_batch`;每条 props 序列化后 ≤ `events_props_max_bytes`(`len(json.dumps(props, ensure_ascii=False).encode())`);`client_ts` 若给出:不晚于 server_now+5min、不早于 7 天前;
|
||||
- 组装 `ObEvent(event_id=next_id(), user_id=..., server_ts=datetime.now(UTC))` 逐条 add,`flush` 一次,返回条数;(`from packages.domain.ids import next_id`,参考 identity/service.py 用法。)
|
||||
- **不含 PII 清洗**:props 里若有 `phone`/`token`/`authorization` 键 → 直接删除该键并 `logger.warning("events_pii_dropped", ...)`(NFR-SEC-03 日志脱敏的同族要求)。
|
||||
3. **`apps/api/routers/events.py`**:
|
||||
- `POST /batch`,`user_id: CurrentUserId`,`analytics: AnalyticsServiceDep`(若 Dep 不可用则函数内 `AnalyticsService(session)` with `SessionDep`);
|
||||
- `ok(EventBatchResponse(...))`;服务层抛的 `AppError` 自然走统一异常处理,不需要额外 catch。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_events_validation.py`(纯校验逻辑,不连库——把校验拆成模块级纯函数 `validate_batch(events, *, max_batch, max_bytes, now) -> list[ObEvent-like]` 便于无 DB 测试):
|
||||
- 合法批量通过;超批量拒;props 超字节拒;event_name 正则(大写/数字开头/超长拒);client_ts 未来>5min 拒、7 天前拒;PII 键被剔除。
|
||||
- 集成测试 `tests/integration/test_events.py`(新文件,允许跑):dev-token → POST /v1/events/batch 两条事件 → 200 accepted=2;查库两行 `ob_event`;再 POST 非法批(event_name 大写)→ 40020 且**库里没有半条**。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/billing/**`、`packages/identity/**`、`packages/storage/**`、`packages/agent/**`、`apps/api/routers/{agent,auth,quota,tasks,uploads}.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. `POST /api/v1/events/batch`(及 `/v1` 别名)可用,认证必需(无 token → 40100);
|
||||
2. 整批原子:要么全落、要么全拒;
|
||||
3. 校验规则全部有测试覆盖;
|
||||
4. 全部检查绿(mypy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(analytics): POST /events/batch with fail-close validation
|
||||
|
||||
ob_event 落库通道:形状校验 + props 字节上限 + client_ts 合理性 +
|
||||
PII 键剔除,整批原子(FR-SYS-06 服务端部分)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
# T4 · 刷新令牌与服务端可撤销会话
|
||||
|
||||
> 对应任务:`T-P1-13`(双令牌会话)| 需求:`FR-ACC-05`、架构 §6.2/§9.1 | 分支:`task/t4-refresh-session` | worktree:`/home/xsl/meida_backend-t4`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在现有 15 分钟 access JWT 之外,补齐 **refresh token**:opaque 令牌 + `id_session` 服务端可撤销 + 轮换(rotation)。端点:`POST /auth/refresh`、`POST /auth/logout`;dev-token 扩展为同时签发 refresh,使整条链路可测。
|
||||
|
||||
**明确不做**(写进代码注释,防止后人误判):华为账号登录(T-P1-12,等资质)、手机验证码(T-P1-12b,等短信通道)、游客/合并/注销、access token 的黑名单式即时吊销(JWT 无状态,V1 接受"access 最长 15 分钟残留";「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话,见下)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 表(迁移 `0004` 已建,ORM 已加,禁改 models.py/migrations)
|
||||
|
||||
`id_session`(架构 §6.2 原文列:`session_id, user_id, refresh_token_hash, expire_at, revoked`;协调提交落地为):
|
||||
- **ORM 类名为 `UserSession`**(刻意不用 `Session`,避免与 SQLAlchemy 的 AsyncSession 混淆):
|
||||
- `session_id` bigint PK(雪花)、`user_id` bigint NOT NULL、`refresh_token_hash` varchar(64) UNIQUE(sha256 hex)、`expire_at` timestamptz NOT NULL、`revoked` boolean NOT NULL default false、`revoked_at` timestamptz NULL、`created_at` timestamptz NOT NULL default now();
|
||||
- 索引:`(user_id)`。
|
||||
|
||||
### 配置(`config.py` 已加字段)
|
||||
|
||||
```python
|
||||
refresh_token_ttl_seconds: int = 2592000 # env: REFRESH_TOKEN_TTL_SECONDS,30 天
|
||||
```
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `REFRESH_TOKEN_INVALID = 40103`:401「登录状态无效」,hint「请重新登录」;
|
||||
- `REFRESH_TOKEN_EXPIRED = 40104`:401「登录状态已过期」,hint「请重新登录」;
|
||||
- `SESSION_REVOKED = 40105`:401「该会话已登出」,hint「请重新登录」。
|
||||
|
||||
### 现有代码事实(先读再写)
|
||||
|
||||
- `packages/shared/security.py`:`create_access_token`/`decode_access_token`(JWT HS256,claims `sub/iat/exp/iss/typ`)——**不改此文件**,refresh 不是 JWT。
|
||||
- `packages/identity/service.py`:`IdentityService(session)`、`get_or_create_by_external_identity`、`get_user`(注销/禁用会抛 UNAUTHENTICATED)、`record_consent`;`DEV_PROVIDER = "dev"`。
|
||||
- `packages/identity/repo.py`:`get_user/find_identity/add_user/add_identity/add_consent/get_consent` 模式(照抄风格补 session 函数)。
|
||||
- `apps/api/routers/auth.py`:dev-token 端点(`DEV_AUTH_ENABLED && is_local` 双开关)。
|
||||
- `apps/api/deps.py`:`IdentityServiceDep`、`SettingsDep` 已有(deps.py 禁改,够用)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/identity/tokens.py`(新建)、`packages/identity/repo.py`、`packages/identity/service.py`、`packages/identity/schemas.py`(新建)、`apps/api/routers/auth.py`、测试文件。
|
||||
|
||||
1. **`packages/identity/tokens.py`**(纯逻辑,无 IO,重点单测):
|
||||
```python
|
||||
def generate_refresh_token() -> str # secrets.token_urlsafe(48)
|
||||
def hash_refresh_token(token: str) -> str # hashlib.sha256(token.encode()).hexdigest()
|
||||
```
|
||||
决策注释:**为什么 opaque 而不是 JWT**——refresh 的核心价值就是"服务端可单条撤销"(FR-ACC-05),无状态 JWT 做不到撤销;哈希落库而非明文,泄漏库不等于泄漏令牌。
|
||||
2. **repo**:`find_session_by_hash(session, token_hash)`、`add_session(session, Session(...))`、`revoke_session(session, session_id)`、`revoke_all_sessions(session, user_id)`(最后一个 V1 先备着,logout-all 属 FR 后续)。
|
||||
3. **service**(`IdentityService` 上追加方法):
|
||||
- `async def issue_session(user_id) -> SessionIssued`(dataclass:`refresh_token: str`、`expires_at: datetime`、`session_id: int`):先 `await self.get_user(user_id)`(防已注销用户续签),生成/哈希/落库/flush;
|
||||
- `async def rotate(refresh_token: str) -> SessionIssued`:查哈希 → 无 → `REFRESH_TOKEN_INVALID`;`revoked` → `SESSION_REVOKED`;`expire_at < now` → `REFRESH_TOKEN_EXPIRED`(并把该行 `revoked=true` 顺手落库);通过 → **旧行 revoked=true + 新行创建,同一事务**(轮换 = 旧 refresh 一次性);返回新 token;
|
||||
- `async def revoke(refresh_token: str) -> bool`:查哈希 → 无/已撤销返回 False;否则 revoked=true。**重复使用已撤销的 refresh(重放检测)**:V1 只报 `SESSION_REVOKED` 并 `logger.warning("refresh_reuse_detected", user_id=...)`,不做家族连坐(注释写明这是 V2 硬化项)。
|
||||
4. **`packages/identity/schemas.py`**(新建;apps/api/schemas.py 禁改):
|
||||
- `RefreshRequest(refresh_token: str)`、`LogoutRequest(refresh_token: str)`;
|
||||
- `TokenPairResponse(access_token, refresh_token, expires_in, refresh_expires_in, user_id: str)`。
|
||||
5. **`apps/api/routers/auth.py`**:
|
||||
- `POST /refresh`:`identity.rotate(...)` → `create_access_token(...)` → `ok(TokenPairResponse(...))`;
|
||||
- `POST /logout`:`user_id: CurrentUserId` + `identity.revoke(...)` → `ok({"revoked": True/False})`(False 不报错——幂等登出);
|
||||
- dev-token 端点扩展:同时 `issue_session`,响应改用 `TokenPairResponse`(**保留原字段名 `access_token/expires_in/user_id` 不变**,新增 `refresh_token/refresh_expires_in`——不破坏 smoke.sh 与现有测试对旧字段的读取;若现有测试断言整个 dict 形状,允许增量更新断言但不得删除旧字段)。
|
||||
- 注意:登出语义 = 撤销会话(refresh 失效)。access JWT 在 TTL 内仍有效的残留窗口,写进 auth.py 模块 docstring。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_refresh_tokens.py`:token 长度/字符域、哈希稳定性、哈希不可逆(≠原文)。
|
||||
- 集成测试 `tests/integration/test_auth_refresh.py`(新文件,允许跑,**自建 app fixture 参考 test_vertical_slice.py 的 FakeProvider 段,但 auth 用例不需要 LLM**):
|
||||
1. dev-token → 拿 `refresh_token`;`POST /v1/auth/refresh` → 200,新 access 可调 `GET /v1/quota`;
|
||||
2. **旧 refresh 二次使用** → 40105;
|
||||
3. `POST /v1/auth/logout` → 再 refresh → 40105;
|
||||
4. 伪造 refresh → 40103;
|
||||
5. 过期路径:构造 `REFRESH_TOKEN_TTL_SECONDS=0`(monkeypatch.setenv + `get_settings.cache_clear()`)→ refresh → 40104;结束恢复 cache。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/shared/security.py`、`packages/llm/**`、`packages/billing/**`、`packages/analytics/**`、`packages/storage/**`、`apps/api/routers/{agent,quota,tasks,events,uploads}.py`、`apps/api/deps.py`、`tests/integration/test_vertical_slice.py`。
|
||||
|
||||
## 6. 验收标准(FR-ACC-05)
|
||||
|
||||
1. access/refresh 双令牌齐备:access 15 分钟 JWT(现状)+ refresh 30 天 opaque 可撤销;
|
||||
2. **refresh 服务端可单条撤销**:logout 后该 refresh 立即失效;
|
||||
3. 轮换生效:每次 refresh 换新 token,旧 token 一次性;
|
||||
4. dev-token 链路向后兼容(旧响应字段不变);
|
||||
5. 全部检查绿(mopy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(identity): refresh token rotation with server-side revocable sessions
|
||||
|
||||
id_session 落地:opaque refresh + sha256 哈希存储 + 一次性轮换 +
|
||||
logout 撤销 + 重放检测告警(FR-ACC-05);dev-token 兼容性扩展。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
# T5 · 图片直传预签名 URL
|
||||
|
||||
> 对应任务:`T-P1-27` | 需求:`FR-SYS-03`、架构 §9.3 | 分支:`task/t5-upload-presign` | worktree:`/home/xsl/meida_backend-t5`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
实现 `POST /uploads/presign`:为客户端签发 COS 临时桶的 PUT 预签名 URL,**图片不经应用服务器**(FR-SYS-03 验收原文)。本任务只做「签发」,不做上传回调/校验图片内容(那是报告链路 T-P3 的事)。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
### 依赖(协调提交已 `uv add`,禁改 pyproject/uv.lock)
|
||||
|
||||
`cos-python-sdk-v5`(腾讯云 COS 官方 SDK,模块名 `qcloud_cos`)。mypy 对它的缺失 stub 已在协调提交里配好 override。真实调用只需要 `CosS3Config` 与 `CosS3Client.get_presigned_url` / `get_presigned_download_url` 所在的 `CosS3Client`。
|
||||
|
||||
### 配置(`config.py` 已加字段,`.env.example` 已示例)
|
||||
|
||||
```python
|
||||
cos_region: str = "ap-shanghai" # env: COS_REGION
|
||||
cos_secret_id: str = "" # env: COS_SECRET_ID
|
||||
cos_secret_key: str = "" # env: COS_SECRET_KEY
|
||||
cos_bucket_temp: str = "meida-temp" # env: COS_BUCKET_TEMP(临时桶,2h 生命周期 CR-02)
|
||||
cos_bucket_user: str = "meida-user" # env: COS_BUCKET_USER
|
||||
cos_bucket_kb: str = "meida-kb" # env: COS_BUCKET_KB
|
||||
cos_bucket_report: str = "meida-report" # env: COS_BUCKET_REPORT
|
||||
cos_presign_expire_seconds: int = 600 # env: COS_PRESIGN_EXPIRE_SECONDS
|
||||
upload_max_bytes: int = 20_971_520 # env: UPLOAD_MAX_BYTES(20MB)
|
||||
upload_allowed_types: str = "image/jpeg,image/png,image/webp" # env: UPLOAD_ALLOWED_TYPES
|
||||
upload_scenes: dict[str, dict[str, Any]] = Field(..., alias="UPLOAD_SCENES_JSON")
|
||||
# 默认:{"report_photo": {"bucket": "temp"}, "avatar": {"bucket": "temp"}}
|
||||
```
|
||||
(字段名/别名以 config.py 协调提交的实际代码为准——**开工先读 config.py 核对**,文档若有出入以代码为准。)
|
||||
|
||||
### 错误码(`errors.py` 已加)
|
||||
|
||||
- `UPLOAD_REJECTED = 40010`:400「不支持的图片类型或超出大小限制」,hint「请上传 20MB 以内的 JPG/PNG/WebP 图片」。
|
||||
|
||||
### 骨架(协调提交已建)
|
||||
|
||||
- `packages/storage/__init__.py`、`README.md`、`service.py`(空骨架);
|
||||
- `apps/api/routers/uploads.py`(空骨架 router,prefix `/uploads`,已注册 main.py);
|
||||
- `apps/api/deps.py` 已有 `StorageServiceDep`(**deps.py 禁改**;若构造参数不满足,在路由函数内用 `SettingsDep` 自建 service)。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages.storage` 在 import-linter"共享能力层"。只准 import `packages.shared`/`packages.domain` 与 `qcloud_cos`/stdlib。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/storage/**`、`apps/api/routers/uploads.py`、测试文件。
|
||||
|
||||
1. **`packages/storage/schemas.py`**:
|
||||
- `PresignRequest(scene: str, content_type: str, size_bytes: int)`(size ≥1);
|
||||
- `PresignResponse(upload_url: str, object_key: str, expires_in: int, max_bytes: int, content_type: str)`。
|
||||
2. **`packages/storage/service.py`**:
|
||||
- 定义 `Protocol CosSigner`:`def presign_put(bucket: str, key: str, expires: int, content_type: str) -> str`(**把 SDK 隔离在 Protocol 后面**,单测用 FakeSigner,绝不连网);
|
||||
- `QcloudCosSigner`:包 `CosS3Client`(构造参数 region/secret_id/secret_key),`presign_put` 调 `client.get_presigned_url(Method.PUT, bucket, key, Expire=expires, Headers={"Content-Type": content_type})`(`from qcloud_cos import CosConfig, CosS3Client`;`Method` 从 `qcloud_cos.method` import——以实际 SDK 签名为准,若 Headers 参数名不同按 SDK 文档调整,**以能签出带 content-type 约束的 PUT URL 为准**);
|
||||
- `StorageService(settings, signer: CosSigner | None = None)`:signer 为 None 时用 `QcloudCosSigner` 惰性构造(**只有真实请求才建 client**;测试必传 FakeSigner);
|
||||
- `def build_presign(user_id: int, request: PresignRequest) -> PresignResponse`:
|
||||
- scene 不在 `settings.upload_scenes` → `AppError(UPLOAD_REJECTED)`(message 写"不支持的上传场景");
|
||||
- content_type ∉ `settings.upload_allowed_types` 解析集合 → `UPLOAD_REJECTED`;
|
||||
- size_bytes > `upload_max_bytes` → `UPLOAD_REJECTED`;
|
||||
- 扩展名映射 `{image/jpeg: jpg, image/png: png, image/webp: webp}`(其它类型已被上一条拦下);
|
||||
- object_key = `f"{scene}/{user_id}/{datetime:%Y%m%d}/{uuid4().hex}.{ext}"`(**这个 key 布局写死**:按场景分目录、按用户隔离、按日分组、uuid 防猜——注释说明这是 COS 侧做前缀审计与生命周期的基础);
|
||||
- bucket = scenes 配置里的 `bucket`("temp"/"user"/"kb"/"report" → 对应 `settings.cos_bucket_*` 四字段映射);
|
||||
- 调 signer 得 URL,返回 `PresignResponse(expires_in=settings.cos_presign_expire_seconds, max_bytes=settings.upload_max_bytes, ...)`。
|
||||
3. **`apps/api/routers/uploads.py`**:
|
||||
- `POST /presign`,`user_id: CurrentUserId` + settings;`ok(PresignResponse(...))`;
|
||||
- 模块 docstring 写明:真实联调需在 `.env` 配 COS 四件套;本地单测不依赖任何真实 COS。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
单测 `tests/unit/test_upload_presign.py`(全部用 FakeSigner,断言 URL/key/参数,**零网络**):
|
||||
- 合法请求 → key 布局正确(scene 前缀、user_id 段、日期段、扩展名)、expires_in/max_bytes 回显;
|
||||
- 非法 content_type(如 `application/pdf`)→ 40010;
|
||||
- 超大小 → 40010;
|
||||
- 未知 scene → 40010;
|
||||
- bucket 映射:`{"bucket":"temp"}` → 使用 `cos_bucket_temp` 配置值(FakeSigner 捕获 bucket 参数断言);
|
||||
- `upload_allowed_types` 字符串解析(空串/带空格容错)。
|
||||
|
||||
不需要集成测试(无 DB 依赖)。
|
||||
|
||||
## 5. 禁改清单(通用禁改之外)
|
||||
|
||||
`packages/llm/**`、`packages/billing/**`、`packages/identity/**`、`packages/analytics/**`、`apps/api/routers/{agent,auth,quota,tasks,events}.py`、`apps/api/deps.py`、`tests/integration/**`。
|
||||
|
||||
## 6. 验收标准(FR-SYS-03)
|
||||
|
||||
1. `POST /api/v1/uploads/presign`(含 `/v1` 别名)可用,认证必需;
|
||||
2. 类型/大小/场景三重服务端校验,错误码统一 40010;
|
||||
3. key 布局可审计(scene/user/日期/uuid);
|
||||
4. 单测全绿且无任何真实网络调用(FakeSigner 注入);
|
||||
5. 全部检查绿(mypy 范围同 T2 列表)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(storage): COS presigned PUT URL endpoint for direct upload
|
||||
|
||||
POST /uploads/presign:场景/类型/大小三重校验 + 可审计 object_key 布局 +
|
||||
SDK 隔离在 CosSigner Protocol 后(FR-SYS-03,T-P1-27)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# T6 · 知识库·实体与图谱
|
||||
|
||||
> 对应任务:`T-P2-01`(12 类实体可存取)、`T-P2-05`(1~3 跳可解释路径)| 需求:`FR-KB-*`、架构 §6.2 kb 域、`TD-09` | 分支:`task/t6-kb-entity` | worktree:`/home/xsl/meida_backend-t6`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
知识库的实体底座:12 类实体统一表的 CRUD 与状态机、`kb_relation` 邻接表 + 递归 CTE 的 1~3 跳图谱查询(可解释路径)、`kb_color` 自建色板存取。这是知识库可运营(M2 出口)的第一块。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表(迁移 `0005` 已建,ORM 已加:`KbEntity`/`KbRelation`/`KbColor`,先读 `packages/shared/models.py` 核对列名)
|
||||
|
||||
- `kb_entity`:`entity_id` PK 雪花、`entity_type` varchar(32)、`name` varchar(128)、`attrs` JSONB、`status` draft/reviewing/published/archived、`version` int、`source`、`license`、`updated_by`、`created_at/updated_at`;UNIQUE(entity_type,name)、Index(entity_type,status)
|
||||
- `kb_relation`:`relation_id` PK、`from_id`/`to_id` bigint、`rel_type` varchar(64)、`weight` Numeric(4,3)、`confidence` Numeric(4,3)、`evidence` varchar(255);UNIQUE(from_id,rel_type,to_id)、Index(to_id)
|
||||
- `kb_color`:`color_id` PK、`season_code` varchar(16)、`role` base/accent/avoid、`lab_l/lab_a/lab_b` Numeric(6,2)、`hex` varchar(7)、`name_cn` varchar(64);Index(season_code)
|
||||
|
||||
### 12 类 entity_type(知识库文档 §3.1,写进 schemas 的 Literal 校验)
|
||||
|
||||
`item / color / style / body_shape / face_hair / occasion / material / accessory / makeup / rule / outfit / trend`
|
||||
|
||||
### 状态机(写进 service,非法跃迁抛 `AppError(STATE_CONFLICT)`)
|
||||
|
||||
`draft → reviewing → published → archived`;`archived` 终态;`draft/reviewing → archived` 允许(撤稿);其余跃迁非法。每次**内容修改** version+1 且**不允许直接改 published 实体**——published 实体要改必须先回 draft(新版本走评审,这是知识库文档"发布回滚"的语义)。
|
||||
|
||||
### 路由骨架
|
||||
|
||||
`apps/api/routers/kb.py` 空路由(prefix `/kb`)已注册 main.py——你填充。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/kb/**`(service.py / repo.py / schemas.py / README.md)、`apps/api/routers/kb.py`、测试文件。**`packages/kb` 现只有 README + 空 `__init__`;`packages/rules`、`packages/retrieval` 归 T7/T8,禁碰。**
|
||||
|
||||
1. **schemas.py**(Pydantic):`EntityCreate`(entity_type/name/attrs)、`EntityUpdate`(仅 draft/reviewing 可用)、`EntityOut`(含 status/version/时间戳)、`RelationCreate`、`GraphPath`(路径节点序列 + 每跳 rel_type)、`ColorOut`。**跨域响应模型放本包**,apps/api/schemas.py 禁改。
|
||||
2. **repo.py**:CRUD + `search_entities(entity_type, status, name_ilike, limit, offset)` + `traverse(from_id, max_hops)` —— 递归 CTE 用 `sqlalchemy.text()` 写原生 SQL(返回 hop 序列),1~3 跳,**防环**(CTE 里用 path 数组 + `NOT path @> ARRAY[to_id]` 判重)。参考架构 TD-09 的理由:浅层有界遍历,不需要图数据库。
|
||||
3. **service.py**:状态机校验、版本递增、relation 写入(自环/重复抛 `AppError(BAD_REQUEST)`)、色板 CRUD(role/hex 格式校验,`#RRGGBB`)。实体创建时 `entity_id=next_id()`(`packages.domain.ids`)。
|
||||
4. **路由**:`GET /kb/items`(分页 + entity_type/status 过滤)、`GET /kb/items/{id}`、`POST /kb/items`、`PATCH /kb/items/{id}`、`GET /kb/items/{id}/graph?max_hops=3`(1~3 跳,超界 422/400)。写接口**暂不挂鉴权以外的前置**(后台运营角色属 T-P2-09,先 CurrentUserId)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_kb_entity.py`:状态机全跃迁矩阵(合法/非法)、12 类 Literal 校验、非法 entity_type 拒绝、色板 hex 校验。(纯逻辑部分;repo/service 层用集成覆盖。)
|
||||
- 集成 `tests/integration/test_kb_entity.py`(新文件):建实体→reviewing→published;改 published 被拒;draft 改→version+1;建 relation→graph 2 跳返回可解释路径(含每跳 rel_type);自环拒绝;`GET /v1/kb/items` 过滤分页。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/rules/**`、`packages/retrieval/**`、`packages/identity/**`、`apps/api/routers/{auth,auth_sms}.py`、全部已有测试文件。
|
||||
|
||||
## 6. 验收标准(T-P2-01/05)
|
||||
|
||||
1. 12 类实体可存取,`attrs` JSONB 差异化承载;
|
||||
2. 状态机非法跃迁 100% 被拦(有测试矩阵证明);
|
||||
3. 图谱 1~3 跳返回可解释路径且防环;
|
||||
4. 全部检查绿(mypy 范围:`apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage packages/kb`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(kb): entity store with lifecycle, graph traversal and color palette
|
||||
|
||||
12 类实体统一表 + 状态机 + version 递增 + kb_relation 递归 CTE 1~3 跳
|
||||
可解释路径(防环)+ kb_color 色板(T-P2-01/05,TD-09/18)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# T7 · 知识库·规则引擎
|
||||
|
||||
> 对应任务:`T-P2-02`(规则求值 p95 < 50ms;硬约束可拦截)| 需求:`FR-KB-02`、架构 §8.2 rules-engine、`TD-10` | 分支:`task/t7-rules-engine` | worktree:`/home/xsl/meida_backend-t7`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
自研数据驱动决策表(TD-10):`kb_rule` 的 JSON DSL 解析 + 纯域求值引擎。**核心结论由规则引擎产出,LLM 只负责表达**(防幻觉红线)——这是整个产品"专业可信"的根基,代码质量按"钱与正确性"标准写。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表 `kb_rule`(ORM `KbRule` 已加)
|
||||
|
||||
`rule_id` PK 雪花、`rule_key` varchar(64) UNIQUE(如 `R-BODY-A-002`)、`category` varchar(32)、`version` int、`status` draft/published/archived、`priority` int、`conditions` JSONB、`effects` JSONB、`is_hard` bool、`confidence` Numeric(4,3)、`evidence` varchar(255)、`author`/`reviewer` varchar(64)、`published_at`;Index(status, priority)。
|
||||
(注:架构 §6.2 写的列名 `conclusions`,协调提交统一为 **`effects`**——与"决策表"术语一致,任务文档以 models.py 实际代码为准。)
|
||||
|
||||
### DSL 规格(知识库文档 §3.2⑩ 原文结构)
|
||||
|
||||
```json
|
||||
{
|
||||
"rule_key": "R-BODY-A-002",
|
||||
"category": "body_shape",
|
||||
"priority": 90,
|
||||
"conditions": {
|
||||
"body_shape": "A型",
|
||||
"features": { "include": ["溜肩"] },
|
||||
"goals": { "include": ["修饰溜肩"] }
|
||||
},
|
||||
"effects": {
|
||||
"recommend": [ { "slot": "top", "attrs": { "shoulder": ["垫肩","自然肩"], "sleeve": ["泡泡袖"] } } ],
|
||||
"avoid": [ { "slot": "top", "attrs": { "shoulder": ["落肩"], "silhouette": ["修身"] } } ],
|
||||
"explanation": "溜肩需要在肩部建立结构感……"
|
||||
},
|
||||
"is_hard": false,
|
||||
"confidence": 0.95
|
||||
}
|
||||
```
|
||||
|
||||
### 优先级分组(知识库文档 §3.2⑩,语义:**数字越大越优先**)
|
||||
|
||||
合规 100 / 用户禁忌 95 / 体型 90 / 色彩 85 / 场景 80 / 温度 75 / 风格 70 / 偏好 60 / 趋势 50。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages/rules` 已在 import-linter 第 4 层(kb|retrieval|rules|memory|profile),现只有 README + 空 `__init__`。只准 import `packages.shared`/`packages.domain`。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/rules/**`、测试文件。
|
||||
|
||||
1. **`packages/rules/dsl.py`**——纯逻辑(无 IO,重点单测):
|
||||
- `RuleCondition` / `RuleEffect` / `Rule` pydantic 模型(解析校验 DSL;`conditions` 支持的字段:等值匹配(str)、`include`/`exclude` 列表、`min`/`max` 数值区间;未识别的键**拒绝**而非忽略——静默忽略会让规则悄悄失效);
|
||||
- `match(condition: RuleCondition, ctx: RuleContext) -> bool`:`RuleContext` 是 frozen dataclass(`body_shape/season/face_shape/temperature/features/goals/occasion/style/user_taboos…`,缺字段 = 不匹配该子句);
|
||||
- 所有子句 AND 语义。
|
||||
2. **`packages/rules/engine.py`**——纯域求值:
|
||||
- `evaluate(rules, ctx) -> RuleVerdict`:返回 `recommend/avoid/explanation 汇总 + rule_hits`(每条含 rule_key/priority/category/is_hard/confidence/explanation);
|
||||
- 排序:priority 降序,同 priority 按 confidence 降序;
|
||||
- **硬约束语义**:`is_hard=true` 且 avoid 命中 → `RuleVerdict.hard_blocks` 非空,调用方必须用它做硬过滤(禁忌违反 = 0 是发布红线);**用户禁忌(priority ≥95 的 avoid)永远压过专业推荐**——同 slot 冲突时用户禁忌胜出并从 recommend 中剔除冲突项;
|
||||
- p95 < 50ms:求值是纯内存操作,规则量 400 条时远低于此;写一个 400 条规则 × 1000 次求值的基准单测(`time.perf_counter`,断言均值 < 5ms/千次的量级即可,CI 机器差异留裕量)。
|
||||
3. **`packages/rules/service.py` + `repo.py`**——DB 侧:按 status='published' 载入规则(内存缓存 + `invalidate()`;量级 400,直接整表载入),CRUD(draft→published 状态机,同 T6 语义),DSL 写入前用 pydantic 校验。
|
||||
4. **黄金集雏形**(架构 §1081 明确要求):`tests/unit/test_rules_golden.py` 固定 8~10 组「输入画像 → 期望 recommend/avoid/hard_blocks」样本,其中**必须包含**:一条硬禁忌场景(用户禁忌压过专业推荐的用例)、一条多规则叠加场景。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测:DSL 解析(合法/非法结构/未识别键拒绝)、match 矩阵(等值/include/exclude/min-max/缺字段)、排序、硬约束剔除、用户禁忌优先、基准测试、黄金集。
|
||||
- 集成 `tests/integration/test_rules_engine.py`(新文件):插 3 条规则(含 1 条 is_hard)→ service 载入 → 求值命中与拦截正确;draft 规则不被载入。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/kb/**`(表共享但包不共享——**repo 只碰 kb_rule 表**,通过 ORM 模型访问,不改 T6 的文件)、`packages/retrieval/**`、`packages/identity/**`。
|
||||
|
||||
## 6. 验收标准(T-P2-02)
|
||||
|
||||
1. 求值 p95 满足 400 条规则量级远低于 50ms(基准测试证明);
|
||||
2. 硬约束可拦截(hard_blocks 语义 + 黄金集用例);
|
||||
3. 用户禁忌永远压过专业推荐(黄金集用例);
|
||||
4. DSL 非法结构 100% 拒绝(不会静默失效);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表 + `packages/rules`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(rules): data-driven decision table engine with hard constraints
|
||||
|
||||
kb_rule DSL 解析 + 纯域求值(优先级分组/硬约束/用户禁忌压过专业推荐)
|
||||
+ 400 条基准 + 黄金集雏形(T-P2-02,TD-10)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# T8 · 知识库·向量与混合检索
|
||||
|
||||
> 对应任务:`T-P2-03`(向量化可重跑/模型可切换)、`T-P2-04`(检索、硬过滤零漏)、`T-P2-06`(无结果 query 记录)| 需求:`FR-KB-03/04`、架构 §8.3、`TD-08` | 分支:`task/t8-retrieval` | worktree:`/home/xsl/meida_backend-t8`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
pgvector 上的混合检索管线骨架:**硬过滤 → 向量召回 → 关键词召回 → RRF 融合重排**,加 `kb_chunk` 写入通道(向量 + tsvector 双索引)与 `kb_gap` 知识缺口埋点。检索是 Agent(M4-b)和报告(M3)的共同前置。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供,禁改 models/migrations)
|
||||
|
||||
### 表(迁移 `0005` 已建,ORM `KbChunk`/`KbAsset`/`KbGap` 已加,先读 models.py 核对)
|
||||
|
||||
- `kb_chunk`:`chunk_id` PK、`entity_id`、`chunk_type` varchar(32)、`text` text、`embedding` **vector(1024)**、`embedding_model` varchar(64)、`embedding_version` varchar(16)、`tsv` tsvector(GENERATED ALWAYS AS to_tsvector('simple', text) STORED——**写入时不要手工填 tsv**);HNSW 索引(cosine)+ GIN(tsv)
|
||||
- `kb_asset`:`asset_id` PK、`entity_id`、`asset_type`、`cos_key` varchar(255)、`spec_check` JSONB、`tryon_ready` bool、`review_status`、`license`;Index(entity_id)、partial Index(tryon_ready)
|
||||
- `kb_gap`:`gap_id` PK、`query_text`、`filter_ctx` JSONB、`hit_count` int、`first_seen_at`;Index(query_text)
|
||||
|
||||
### 配置(config.py 已加,.env.example 已示例)
|
||||
|
||||
```python
|
||||
kb_vector_dimensions: int = 1024 # KB_VECTOR_DIMENSIONS
|
||||
kb_embedding_model: str = "deterministic-v1" # KB_EMBEDDING_MODEL(当前默认=确定性假实现)
|
||||
kb_embedding_device: str = "cpu" # KB_EMBEDDING_DEVICE
|
||||
retrieval_top_k: int = 50 # RETRIEVAL_TOP_K
|
||||
retrieval_rerank_weights: dict = {"vector":0.45,"keyword":0.2,"rule":0.25,"freshness":0.1} # RETRIEVAL_RERANK_WEIGHTS_JSON
|
||||
```
|
||||
⚠️ 上批经验:带 alias 的 Settings 字段在**测试里按字段名构造会被静默忽略**——测试用 `monkeypatch.setenv` + `get_settings.cache_clear()`。
|
||||
|
||||
### 重排公式(架构 §8.3 唯一口径)
|
||||
|
||||
`最终分 = 0.45×向量 + 0.2×关键词 + 0.25×规则权重 + 0.1×新鲜度`(权重可配,就是上面的 rerank_weights)。规则权重项本批的输入是 `rule_boost: dict[chunk_id, float]`(由调用方传入,规则引擎联动属后续任务,管线只认接口)。
|
||||
|
||||
### 分层位置
|
||||
|
||||
`packages/retrieval` 在 import-linter 第 4 层(与 rules 同层)。只准 import `packages.shared`/`packages.domain` 与已装依赖(pgvector 的 sqlalchemy 类型、numpy)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`packages/retrieval/**`、测试文件。**不建 HTTP 路由**(检索是内部能力,被 agent/report 的 service 调用)。
|
||||
|
||||
1. **`packages/retrieval/embedding.py`**:
|
||||
- `EmbeddingProvider` Protocol:`async def embed(texts: list[str]) -> list[list[float]]`、属性 `model_name/version/dimensions`;
|
||||
- `DeterministicEmbedder`:把文本 sha256 种子映射到**归一化**的 1024 维向量(同文本同向量、不同文本近似正交——用 seed 生成伪随机向量后 L2 归一化)。注释写明:这是测试/dev 的假实现,**真实 BGE-M3 ONNX 加载是后续任务**(配置里的 `kb_embedding_device` 是为它预留的);
|
||||
- `build_embedding_provider(settings)` 工厂:按 `kb_embedding_model` 选择实现(当前只认 `deterministic-v1`,其它值抛 `AppError(CONFIG_ERROR)`)。
|
||||
2. **`packages/retrieval/repo.py`**:
|
||||
- `upsert_chunks(session, entity_id, chunks, embedding)`:写 kb_chunk(embedding 用 `pgvector.sqlalchemy.Vector`;`embedding_model/version` 必填——**没有版本号的向量不可重跑**,这是 T-P2-03 的验收);
|
||||
- `hybrid_search(session, query_vec, query_text, *, entity_types, occasion, body_shape, rule_boost, top_k)`:按架构 §8.3 的 CTE 结构实现(可用 `sqlalchemy.text()` 原生 SQL + 绑定参数,或 SQLAlchemy core;`embedding <=> :query_vec` 余弦距离、`ts_rank`、RRF:`1/(60+rank_vec) + 1/(60+rank_kw)`,再叠加权重与新鲜度 `(now-updated_at)` 归一化衰减);
|
||||
- **硬过滤是第一公民**:`e.status='published'` + entity_types + occasion(`attrs->'occasions' ? :occasion`)+ 禁忌排除(`NOT (attrs->'avoid_body_shapes' ? :body_shape)`)——**硬过滤零漏**是 T-P2-04 红线,集成测试必须证明"禁忌实体即使向量最近也绝不出现";
|
||||
- `record_gap(session, query_text, filter_ctx)`:无结果时 upsert kb_gap(同 query_text + filter_ctx 则 `hit_count += 1`)。
|
||||
3. **`packages/retrieval/service.py`**:`RetrievalService.search(query_text, *, user_ctx) -> SearchOutcome`(编排 embed → hybrid_search →(结果为空时)record_gap);`SearchOutcome` 含 `results: list[ScoredChunk]`(chunk_id/entity_id/text/vec_score/kw_score/final_score)与 `degraded: bool`(无结果走了 gap 记录)。
|
||||
|
||||
## 4. 测试要求
|
||||
|
||||
- 单测 `tests/unit/test_embedding.py`:确定性(同文本同向量)、维度、L2 归一化、工厂的未知模型名拒绝。
|
||||
- 单测 `tests/unit/test_rerank.py`:RRF 融合 + 权重 + 新鲜度的数学正确性(构造已知分数断言最终序)。
|
||||
- 集成 `tests/integration/test_hybrid_retrieval.py`(新文件,用 DeterministicEmbedder,**不需要真实模型**):
|
||||
1. 建 3 个实体(1 个 published 带禁忌属性、1 个 draft、1 个 published 正常)+ 各挂 chunk → 检索命中正常实体、**draft 不出现、禁忌实体零出现**(T-P2-04 硬过滤零漏);
|
||||
2. 关键词命中路径(text 里放英文关键词,tsv 'simple' 分词);
|
||||
3. 无结果 query → kb_gap 出现一行、再查同 query → hit_count=2(T-P2-06);
|
||||
4. `tryon_ready=true` 的 kb_asset 可按 entity 查询(顺手验证 partial 索引可用)。
|
||||
|
||||
## 5. 禁改清单
|
||||
|
||||
通用禁改之外:`packages/kb/**`、`packages/rules/**`、`packages/identity/**`、所有路由文件。
|
||||
|
||||
## 6. 验收标准(T-P2-03/04/06)
|
||||
|
||||
1. 向量写入带 `embedding_model/version`,换模型名即视为不同版本(可重跑判据);
|
||||
2. 硬过滤零漏(集成断言禁忌/draft 实体绝不出现在结果);
|
||||
3. RRF + 权重重排数学正确(单测);
|
||||
4. 无结果 query 全量落 kb_gap 且可聚合排序(hit_count);
|
||||
5. 全部检查绿(mypy 范围同 T6 列表 + `packages/retrieval`)。
|
||||
|
||||
## 7. 提交信息
|
||||
|
||||
```
|
||||
feat(retrieval): hybrid search over pgvector with hard filters and gap tracking
|
||||
|
||||
DeterministicEmbedder(可换 BGE-M3 的接口位)+ kb_chunk 双索引写入
|
||||
+ 硬过滤→向量→关键词→RRF 重排管线 + kb_gap 缺口埋点(T-P2-03/04/06,TD-08)。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# T9 · 算法验证 SPIKE·RTMPose 体型原型 + BiRefNet 抠图
|
||||
|
||||
> 对应任务:`T-P0-16`(RTMPose ONNX 单张耗时)、`T-P0-12`(BiRefNet 抠图质检 ≥90%)、`T-P0-10` 前置(体型测算原型)| 分支:`task/t9-vision-spike` | worktree:`/home/xsl/meida_backend-t9`
|
||||
> 先读《README.md(协作规程)》,遵守全部纪律。**本任务是 SPIKE:产出是脚本 + 基准报告 + 结论,不是生产代码。**
|
||||
|
||||
## 1. 目标
|
||||
|
||||
用本地可跑的最小代价回答三个 Sprint 1 硬门槛的**可行性问题**,结论写进报告(不达标要换方案,不要硬做):
|
||||
|
||||
1. **RTMPose ONNX 在 CPU 上的单张推理耗时**(T-P0-16 验收:可接受即通过;开发机另有 RTX 5090,若 WSL 能用 GPU 也测一档,不行就只报 CPU——先探测 `nvidia-smi` 与 `onnxruntime` 的 CUDA 可用性,探测失败不折腾);
|
||||
2. **BiRefNet ONNX 抠图的质量代理指标**(T-P0-12 的 ≥90% 质检通过率需要真实素材与人工标注,本 SPIKE 只建管线 + 代理指标 + 少量样图的主观核验,正式验收留给素材流水线);
|
||||
3. **体型测算「掩膜宽度剖面法 + 关键点定高」原型**(TD-06):在合成人形图上跑通"关键点 → 定高 → 剖面宽度 → 肩/腰/臀比例 → H/A/V/O/X 初判",证明管线成立;**一致率 ≥80% 的正式验证需要人工标注数据(顾问轨),本任务不承诺**。
|
||||
|
||||
## 2. 已就绪的契约(协调提交已提供)
|
||||
|
||||
- **依赖**:`spike` extra 已加入 pyproject(`onnxruntime`、`opencv-python-headless`、`numpy`),你的 worktree venv 已 `uv sync --extra spike` 装好。**GPU 版 onnxruntime 不装**(CUDA 匹配成本高,CPU 基准已满足 T-P0-16 验收口径)。
|
||||
- **目录**:`scripts/spikes/`(新建,归你)+ 模型缓存目录 `models/`(已在 `.gitignore`,**模型文件绝不入库**)。
|
||||
- **样图**:用脚本合成(OpenCV 画人形剪影/几何人体,见 §3),不使用任何真实人脸/人体数据(合规红线:测试不碰真实个人数据)。
|
||||
|
||||
## 3. 实现要点
|
||||
|
||||
允许改动:`scripts/spikes/**`、测试(可选,本任务以报告为验收)。其余全禁改。
|
||||
|
||||
### 3.1 模型获取(本批唯一允许联网的环节)
|
||||
|
||||
- 优先 `HF_ENDPOINT=https://hf-mirror.com`(国内镜像)用 `huggingface_hub.snapshot_download` / `hf_hub_download` 下载 ONNX:
|
||||
- RTMPose:`PINTO0309/PINTO_model_zoo` 里的 rtmpose(搜 `rtmpose` + onnx,选 **body 26/17 keypoint 的小模型**)或 `dhkim0115/rtmpose-onnx` 等社区导出;取不到就换 OpenMMLab 官方导出说明,**再不行降级:用 `opencv` 自带的 HOGDetector 作关键点占位并在报告声明**;
|
||||
- BiRefNet:`zhengpeng7/birefnet` 的 ONNX 导出(或 `onnx-community/BiRefNet`);文件大(~1GB)下载慢的话选 **lite/general-lite** 变体,报告注明用的哪个;
|
||||
- 下载脚本 `scripts/spikes/download_models.py`:带本地存在性检查 + 进度打印 + 失败重试一次;模型路径默认 `models/<name>.onnx`,支持 CLI 参数覆盖;
|
||||
- **超时纪律**:单个模型下载超过 ~10 分钟即放弃并走降级路线,把时间花在报告上。
|
||||
|
||||
### 3.2 `scripts/spikes/rtmpose_benchmark.py`
|
||||
|
||||
- 加载 ONNX → 预处理(resize/归一化,按模型 meta)→ 推理 → 解码关键点(17 点 COCO 或 26 点,按模型);
|
||||
- 输入:合成图 `scripts/spikes/assets/synthetic_person.png`(用 OpenCV 画:头圆 + 躯干梯形 + 四肢线段,肩/腰/臀位置可控——这也是 3.4 的输入);
|
||||
- 耗时基准:预热 3 次 + 正式 20 次,报 **均值 / P95 / 单次最大**(`time.perf_counter`);
|
||||
- 输出关键点坐标 JSON + 控制台表格。
|
||||
|
||||
### 3.3 `scripts/spikes/birefnet_matting.py`
|
||||
|
||||
- 加载 ONNX → 推理 → 后处理(sigmoid + resize 回原尺寸 + 二值化阈值 0.5)→ 保存掩膜图与抠图预览到 `scripts/spikes/out/`(out/ 也 gitignore,报告里只留指标);
|
||||
- 代理质检指标(T-P2-07 质检的雏形口径,写清楚这是代理不是正式验收):
|
||||
- 前景像素占比(应在 15%~60% 合理区间);
|
||||
- 最大连通域占前景比(>0.9 视为"主体单一完整");
|
||||
- 边缘羽化度(掩膜梯度带宽度均值);
|
||||
- 抠图耗时(同 3.2 口径);
|
||||
- 输入:合成人形图(与 3.2 同一张)+ 一张带渐变背景的变体。
|
||||
|
||||
### 3.4 `scripts/spikes/body_measure_prototype.py`(TD-06 原型)
|
||||
|
||||
- 用 3.2 的关键点(或合成图直接已知的真值框)定肩线/腰线/臀线高度 → 在 3.3 的掩膜上取对应高度的**宽度剖面** → 肩宽/腰宽/臀宽比值;
|
||||
- 规则初判:肩/臀比 >1.05 → V 型;<0.95 → A 型;肩≈腰≈臀 → H;腰远小于肩臀 → X;中段最大 → O(阈值写成常量并注明"待顾问校准");
|
||||
- 对合成图断言:**已知真值形状的分类正确**(合成图生成时就知道自己画的是 A 型还是 H 型——这就是最小可行的一致率验证)。
|
||||
|
||||
### 3.5 `scripts/spikes/REPORT.md`(**核心交付物**)
|
||||
|
||||
结构:结论先行(三问三答,每问给"通过 / 有条件通过 / 不达标需换方案")→ 各基准数字表 → 用的模型与版本 → 降级与偏差 → 对 Sprint 1 正式验证的建议(需要什么数据、多少标注量)。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. `download_models.py` + 三个 spike 脚本可重复执行(模型已缓存时离线可跑);
|
||||
2. RTMPose CPU 耗时基准有数字(或降级路径有声明);
|
||||
3. 体型原型在合成图上分类正确;
|
||||
4. `REPORT.md` 三问三答齐全,结论可决策;
|
||||
5. `ruff check` / `ruff format --check` 对 `scripts/spikes/**` 全绿;无 pytest 强制要求(脚本里的断言跑一遍即可,在报告里贴输出)。
|
||||
|
||||
## 5. 提交信息
|
||||
|
||||
```
|
||||
spike(vision): rtmpose latency, birefnet matting proxies, body-shape prototype
|
||||
|
||||
T-P0-16/12/10 可行性验证:CPU 基准 + 代理质检指标 + 合成图体型剖面法
|
||||
原型 + REPORT.md 三问三答(Sprint 1 门槛的决策依据)。
|
||||
|
||||
偏差说明:<模型是否齐全、走了什么降级、写在这里>
|
||||
```
|
||||
Reference in New Issue
Block a user