新功能:个性化推荐算法
This commit is contained in:
275
spec_kit/Personalized Reco/modules/content-repository/plan.md
Normal file
275
spec_kit/Personalized Reco/modules/content-repository/plan.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Content Repository(候选查询与数据访问层)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/content-repository/spec.md`
|
||||
>
|
||||
> 依赖对齐:
|
||||
>
|
||||
> - DB 设计:`spec_kit/Personalized Reco/modules/db-design/plan.md`
|
||||
> - 回退梯度与场景口径:`spec_kit/Personalized Reco/spec.md`(Fallback Ladder + 场景默认参数)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 为推荐引擎提供**与 ORM/SQL 解耦**的数据访问接口:候选召回与按 ID 批量获取。
|
||||
- 将 DB 内部存储形态(JSON、关联表、NULL 语义等)统一“规范化”为上层稳定的 `ContentProfile` 结构。
|
||||
- 在候选不足时支持按 `fallback_level (L0~L3)` 进行**可控降级**(降个性化/回退通用池/安全池),并且不产生 N+1 查询。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/content-repository/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(后续 tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/content_repository/`(或等价目录)
|
||||
- 需要包含:
|
||||
- 抽象接口 `ContentRepository`(Protocol 或 ABC)
|
||||
- SQLAlchemy 实现 `SqlAlchemyContentRepository`
|
||||
- `ContentProfile`(DTO/数据结构)与规范化工具函数
|
||||
- 单元测试与最小集成测试(后续 tasks 阶段落地):
|
||||
- risk_flags 映射与去重
|
||||
- suitability 缺失兜底
|
||||
- personalization_power 映射
|
||||
- 查询不出现按 `content_id` 循环查 flags(避免 N+1)
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键技术决策(V1)
|
||||
|
||||
### 2.1 存储形态(来自 DB Design 的已对齐结论)
|
||||
|
||||
- `content_id`:MySQL 自增主键(`int`)。
|
||||
- `context_suitability_json / need_suitability_json`:JSON 存储。
|
||||
- `risk_flags`:关联表 `content_risk_flags(content_id, flag)`(同一 content 下 `uniq_content_flag` 去重)。
|
||||
- `emotion_score`:`NULL` 表示 general。
|
||||
- `review_confidence`:DB 可为 `NULL`;读取层输出时按 `0.7` 兜底(对齐规则口径)。
|
||||
- `personalization_power`:DB 推荐存 `0/5/10`;读取层输出稳定为 `0.0/0.5/1.0`。
|
||||
|
||||
### 2.2 职责边界(避免耦合)
|
||||
|
||||
- `Content Repository` **不负责** Hard Filter/Soft Scoring/Rerank/Freqcap(这些由引擎编排与打分子模块完成)。
|
||||
- `Content Repository` **负责**:
|
||||
- 按 `fallback_level` 对候选池做“降级约束”(例如限制 `personalization_power`、回退通用池/安全池)
|
||||
- 输出稳定结构(JSON 解析、默认值兜底、旧 flag 映射)
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口与数据结构(V1)
|
||||
|
||||
### 3.1 接口定义(与 spec 对齐)
|
||||
|
||||
- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]`
|
||||
- `fetch_contents_by_ids(content_ids: List[int], locale) -> List[ContentProfile]`
|
||||
|
||||
### 3.2 `ContentProfile`(输出契约的推荐形态)
|
||||
|
||||
稳定字段(必须输出):
|
||||
|
||||
- `content_id: int`
|
||||
- `text: str`
|
||||
- `stage: "general" | "expecting" | "parenting" | "unknown"`
|
||||
- `emotion_score: float | None`(`None` 表示 general)
|
||||
- `context_suitability: Dict[str, float]`
|
||||
- `need_suitability: Dict[str, float]`
|
||||
- `personalization_power: float`(`0/0.5/1`)
|
||||
- `risk_flags: List[str]`
|
||||
|
||||
可选字段(尽量输出):
|
||||
|
||||
- `author_id: str | None`
|
||||
- `template_id: str | None`
|
||||
- `review_confidence: float`(缺失/NULL 按 `0.7` 输出)
|
||||
|
||||
---
|
||||
|
||||
## 4. 读取层规范化(Normalization)
|
||||
|
||||
### 4.1 text 选文案与多语言策略(不允许回退)
|
||||
|
||||
数据来源(当前 DB/ORM 约定):
|
||||
|
||||
- `contents.text_en`:英文
|
||||
- `contents.text_tc`:繁体中文
|
||||
|
||||
规则(**不允许语言回退**):
|
||||
|
||||
- `locale=en*`:仅允许返回存在 `text_en` 的内容;输出 `text = text_en`
|
||||
- `locale=tc/zh-TW/zh-HK`:仅允许返回存在 `text_tc` 的内容;输出 `text = text_tc`
|
||||
|
||||
若内容缺少目标语言文本(例如 `locale=en*` 但 `text_en` 为空):该内容视为不可用,必须在候选/按 ID 获取时过滤掉。
|
||||
|
||||
### 4.1 suitability JSON 解析与缺失兜底
|
||||
|
||||
固定 key 集合(对齐 DB Plan 的最小入库契约):
|
||||
|
||||
- `context_suitability`:`family/work/relationship/friends/health`
|
||||
- `need_suitability`:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
|
||||
|
||||
规则:
|
||||
|
||||
- 若 DB 字段缺失/为 `NULL`/解析失败:**补齐为全 0.5**(以上所有 key 均为 `0.5`)。
|
||||
- 若 DB JSON 存在但缺少部分 key:对缺少 key 补 `0.5`,其余按原值。
|
||||
- 值域约束:期望值为 `0/0.5/1`;若出现其他值(例如字符串、越界浮点),按 `0.5` 兜底并记录告警日志(V1 可先打 debug,后续接入可观测模块)。
|
||||
|
||||
### 4.2 `review_confidence` 兜底
|
||||
|
||||
- DB `review_confidence` 为 `NULL` 或缺失:输出 `0.7`。
|
||||
|
||||
### 4.3 `personalization_power` 映射
|
||||
|
||||
若 DB 存 `0/5/10`:
|
||||
|
||||
- `0 -> 0.0`
|
||||
- `5 -> 0.5`
|
||||
- `10 -> 1.0`
|
||||
|
||||
若读到其他值:按 `0.0` 兜底并记录告警日志。
|
||||
|
||||
### 4.4 risk_flags 旧→新映射与输出约束
|
||||
|
||||
映射表(对齐 spec):
|
||||
|
||||
- `block_stage_unknown` → `unsafe_for_stage_unknown`
|
||||
- `block_stage_parenting` → `unsafe_for_stage_parenting`
|
||||
- `block_emotion_low` → `unsafe_for_emotion_low`
|
||||
- `block_health_sensitive` → `block_health_medical`(V1 保守硬拦截)
|
||||
|
||||
输出约束:
|
||||
|
||||
- 输出 `risk_flags` 必须去重。
|
||||
- 输出不得包含旧命名。
|
||||
- 输出建议稳定排序(便于测试与可观测):按字典序排序或按严重等级排序(V1 可先字典序)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 查询策略(V1)
|
||||
|
||||
> 原则:DB 层先做“粗过滤”,应用层再做“精过滤/打分”。避免在 V1 过早依赖 JSON 路径查询索引。
|
||||
|
||||
### 5.1 `fetch_contents_by_ids`(按 ID 批量获取)
|
||||
|
||||
目标:
|
||||
|
||||
- 输入任意 `content_id` 列表,返回无重复的 `ContentProfile` 列表。
|
||||
- 避免 N+1:不得按 `content_id` 循环查 `content_risk_flags`。
|
||||
- 返回顺序:必须与输入 `content_ids` 一致(对“缺记录/缺语言文本”的 id 采取跳过策略,见下)。
|
||||
|
||||
缺记录/缺语言文本的处理(V1 约定):
|
||||
|
||||
- 若某个 `content_id` 在 DB 中不存在,或按 `locale` 规则无法产出 `text`:该 id 在返回列表中**跳过**(不返回占位对象)。
|
||||
|
||||
推荐实现形态(两段式,避免 JOIN 导致重复行):
|
||||
|
||||
1. **批量拉主体与画像**(`contents` JOIN `content_profiles`),限制 `content_id IN (...)`。
|
||||
2. **批量拉 risk_flags**:`SELECT content_id, flag FROM content_risk_flags WHERE content_id IN (...)`,在应用层按 `content_id` 聚合为集合,再做旧→新映射与去重。
|
||||
|
||||
备注:
|
||||
|
||||
- 由于 `content_risk_flags` 是 1:N,直接三表 JOIN 容易导致行膨胀;两段式更便于组装与去重。
|
||||
|
||||
### 5.2 `fetch_candidates`(候选召回,支持 L0~L3)
|
||||
|
||||
输入:
|
||||
|
||||
- `scene: feed | push | widget`
|
||||
- `user_profile`(允许字段缺失)
|
||||
- `fallback_level: 0|1|2|3`
|
||||
- `limit`
|
||||
- `exclude_content_ids`(可选)
|
||||
|
||||
#### 5.2.1 fallback_level 约束(对齐大规范 Fallback Ladder)
|
||||
|
||||
从 `spec_kit/Personalized Reco/spec.md` 对齐:
|
||||
|
||||
- **L0**:正常召回配比(不在读取层实现复杂配比,读取层只保证候选池足够大且不过度放宽)
|
||||
- **L1**:放宽匹配 + 降个性化:限制 `personalization_power ≤ 0.5`
|
||||
- **L2**:回退通用池 + 进一步降个性化:限制 `personalization_power = 0`,且优先 `stage=general`
|
||||
- **L3**:兜底安全池:限制 `is_safe_pool = true`(安全池字段来自 DB Design)
|
||||
|
||||
缺失字段的最小处理(对齐大规范 Candidate Generation 口径):
|
||||
|
||||
- 若 `user_profile` 缺失明显(need/context/emotion 任一缺失):读取层按**至少 L1** 的约束执行(即使入参 fallback_level=0)。
|
||||
|
||||
#### 5.2.2 stage 粗过滤策略(V1)
|
||||
|
||||
读取层可做的最小粗过滤(不引入复杂业务判断):
|
||||
|
||||
- **L2/L3**:只取 `stage=general`(L3 额外 `is_safe_pool=true`)。
|
||||
- **L0/L1**:
|
||||
- 优先取 `stage=用户匹配阶段` + `stage=general`
|
||||
- 若无法从 `user_profile` 明确阶段,则仅取 `stage=general`(避免误推)
|
||||
|
||||
> 说明:更细粒度的阶段/跨维度规则(例如 unknown+parenting_pressure 的禁推)由 Hard Filter 子模块实现;读取层仅做粗过滤以减少扫描与传输。
|
||||
|
||||
#### 5.2.3 查询形态(避免 JOIN 行膨胀 + 保证 limit)
|
||||
|
||||
推荐采用“两段式候选召回”:
|
||||
|
||||
1. **先只查候选 ID 列表**(`contents` JOIN `content_profiles`),应用粗过滤(stage / personalization_power / is_safe_pool / exclude_content_ids),并增加 **locale 文本存在性过滤**(不允许语言回退),再用 `LIMIT limit * multiplier` 拉一批候选 ID(`multiplier` 例如 3~5,避免后续去重/过滤后不足)。
|
||||
2. **再用 `fetch_contents_by_ids` 批量补全字段**(主体+画像+risk_flags),最终在应用层去重并截断到 `limit`。
|
||||
|
||||
排序(V1):
|
||||
|
||||
- 若没有更明确的排序字段:使用 `updated_at DESC` 或随机抽样(需谨慎,MySQL `ORDER BY RAND()` 在大表会慢)。
|
||||
- 推荐:V1 先用 `content_profiles.updated_at DESC` 或 `contents.created_at DESC`,后续由打分模块决定最终排序。
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能与可观测(V1)
|
||||
|
||||
### 6.1 性能约束
|
||||
|
||||
- 单次调用不得出现按 `content_id` 循环查库(避免 N+1)。
|
||||
- `fetch_candidates` 必须在 DB 层支持 `limit`,并尽量通过粗过滤减少扫描。
|
||||
|
||||
### 6.2 建议打点/日志(为 observability 子模块预留)
|
||||
|
||||
在 Repository 层建议输出 debug 级日志(或埋点字段,供上层汇总):
|
||||
|
||||
- `scene`
|
||||
- `fallback_level`(入参)与 `effective_fallback_level`(考虑缺失字段自动至少 L1 后的实际约束级别)
|
||||
- `limit`、`exclude_content_ids_count`
|
||||
- `candidate_ids_size_raw`(第 1 段查到的候选 ID 数)
|
||||
- `candidate_size_returned`(最终返回数量)
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试计划(V1)
|
||||
|
||||
### 7.1 单元测试(纯函数)
|
||||
|
||||
- risk_flags 映射:
|
||||
- 输入包含旧 flag,输出只包含新命名
|
||||
- 去重与稳定排序
|
||||
- suitability 兜底:
|
||||
- DB 字段缺失/NULL/解析失败 → 全 0.5
|
||||
- 部分 key 缺失 → 补齐 0.5
|
||||
- personalization_power 映射:
|
||||
- 0/5/10 → 0.0/0.5/1.0
|
||||
- 异常值 → 0.0 兜底
|
||||
- review_confidence:
|
||||
- NULL/缺失 → 0.7
|
||||
|
||||
### 7.2 最小集成测试(含数据库)
|
||||
|
||||
- `fetch_contents_by_ids`:
|
||||
- 输入多个 id 返回无重复
|
||||
- flags 聚合正确(同一 content 多条 flag 行能聚合成 list)
|
||||
- 查询次数断言(避免 N+1):
|
||||
- `fetch_contents_by_ids`:固定 2 次查询(主体+画像一次,flags 一次)
|
||||
- `fetch_candidates`:固定 3 次查询(候选 id 一次 + `fetch_contents_by_ids` 两次),或实现允许的常数级次数
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与后续演进
|
||||
|
||||
### 8.1 已知风险
|
||||
|
||||
- V1 不做 JSON 路径索引:候选量变大后,粗过滤不足可能导致候选池拉取过多、应用层过滤成本上升。
|
||||
- `ORDER BY RAND()` 的性能风险:候选大表下不可用,需要替代策略(时间窗口抽样/预生成候选池)。
|
||||
|
||||
### 8.2 V1.1 优化方向(与 DB Plan 对齐)
|
||||
|
||||
- 为常用 need/context key 增加生成列/函数索引(从 JSON_EXTRACT 提取到 TINYINT)以加速召回。
|
||||
- 为强规则风险(如 `block_health_medical`)增加派生布尔列或缓存表,减少 JOIN 成本。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`: 客户端问卷画像(V1.2;字段允许缺失)
|
||||
- `locale`:客户端语言(由请求携带并透传至推荐模块;**当前仅支持 EN/TC**,例如 `en` / `en-US` / `tc` / `zh-TW` / `zh-HK`)
|
||||
- `fallback_level`: `0|1|2|3`
|
||||
- `limit`: 候选条数上限(由引擎配置)
|
||||
- (可选)排除集合:`exclude_content_ids`(用于 DB 层先排一部分,减少传输;类型为 `List[int]`,与 MySQL 自增 `content_id` 对齐)
|
||||
@@ -29,7 +30,9 @@
|
||||
|
||||
- `List[ContentProfile]`(稳定字段契约):
|
||||
- `content_id`:`int`(MySQL 自增主键)
|
||||
- `text`:文案文本
|
||||
- `text`:按 `locale` 输出的文案文本(**不允许语言回退**)
|
||||
- `locale=en*`:必须从 `contents.text_en` 产出;若该 content 无 `text_en`,则该 content 不可返回(在候选/按 ID 获取时过滤)
|
||||
- `locale=tc/zh-TW/zh-HK`:必须从 `contents.text_tc` 产出;若无 `text_tc`,则该 content 不可返回
|
||||
- `stage`:`general | expecting | parenting | unknown`
|
||||
- `emotion_score`:`float | None`(约定:`None` 表示 general)
|
||||
- `context_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`)
|
||||
|
||||
198
spec_kit/Personalized Reco/modules/content-repository/tasks.md
Normal file
198
spec_kit/Personalized Reco/modules/content-repository/tasks.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Content Repository(候选查询与数据访问层)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/content-repository/plan.md`
|
||||
>
|
||||
> 本清单已对齐确认点:
|
||||
>
|
||||
> - `text` 按客户端 **locale** 输出(请求携带)
|
||||
> - **不允许语言回退**(缺少目标语言文本的内容直接过滤,不返回)
|
||||
> - 需要做 **MySQL(dev)DB 集成测试**
|
||||
> - 代码与其他推荐子模块放同一目录:`server/app/features/personalized_reco/`
|
||||
> - `fetch_contents_by_ids` 返回顺序必须与输入 `content_ids` **一致**
|
||||
>
|
||||
> 执行说明:
|
||||
>
|
||||
> - 本次已在 dev MySQL 环境跑通 `pytest`,且测试不做破坏性操作:
|
||||
> - 不清表(不执行 DELETE/TRUNCATE)
|
||||
> - 每个用例使用事务并在结束时 rollback
|
||||
> - 默认不执行 Alembic 迁移(如需自动迁移需显式设置 `ALLOW_SCHEMA_MIGRATION=1`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 更新 `modules/content-repository/spec.md`,加入 locale 相关契约
|
||||
- **变更点**:
|
||||
- 在输入中新增 `locale`(例如 `en`/`en-US`/`tc`/`zh-TW`/`zh-HK`),声明由客户端请求携带并透传至 repository
|
||||
- 在输出字段 `text` 补充语言选择规则:
|
||||
- `locale=en*`:必须从 `text_en` 产出;若缺失则该内容不可返回(过滤)
|
||||
- `locale=zh-TW|zh-HK`:必须从 `text_tc` 产出;若缺失则该内容不可返回(过滤)
|
||||
- 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略
|
||||
- 明确:`fetch_contents_by_ids` **返回顺序与入参一致**
|
||||
- **验收**:`spec.md` 中输入/输出与接口签名不再缺少 locale,且 `text` 的来源与“不允许语言回退”规则清晰。
|
||||
|
||||
- [x] 1.2 更新 `modules/content-repository/plan.md`,补齐 locale 选文与“繁转简”技术方案
|
||||
- **变更点**:
|
||||
- 在“读取层规范化(Normalization)”新增 `text` 规范化章节:语言选择 + 简中转换
|
||||
- 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略
|
||||
- 明确策略:**不允许语言回退**;缺少目标语言文本的内容视为不可用,必须在候选/按 ID 获取时过滤掉
|
||||
- **验收**:`plan.md` 有明确依赖与落地策略(含依赖包/转换时机/兜底策略),不留二义性。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/content_repository/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(DTO:`ContentProfile`、locale 类型等)
|
||||
- `interface.py`(`ContentRepository` Protocol/ABC)
|
||||
- `normalization.py`(解析与兜底:suitability、risk_flags、power、text)
|
||||
- `sqlalchemy_repo.py`(SQLAlchemy 实现)
|
||||
- **验收**:可被 `app.features.personalized_reco.content_repository.*` 正常 import。
|
||||
|
||||
- [ ] 2.2 依赖补齐(如采用 OpenCC)
|
||||
- **说明**:当前仅支持 EN/TC,此任务可跳过;若未来新增 `zh-CN` 并需要繁转简,再引入 OpenCC。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据结构与接口(面向引擎注入)
|
||||
|
||||
- [x] 3.1 定义 `ContentProfile` DTO(稳定字段契约)
|
||||
- **字段**:对齐 `modules/content-repository/spec.md`,并补齐 `review_confidence` 输出兜底为 `0.7`
|
||||
- **注意**:`text` 为最终对外输出文本(已按 locale 选择/转换)
|
||||
- **验收**:DTO 字段齐全;类型清晰;不暴露 ORM 模型。
|
||||
|
||||
- [x] 3.2 定义 `ContentRepository` 接口(含 locale)
|
||||
- **建议签名**(示例,最终以 spec 为准):
|
||||
- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]`
|
||||
- `fetch_contents_by_ids(content_ids, locale) -> List[ContentProfile]`
|
||||
- **验收**:推荐引擎可以仅依赖该接口,不依赖 SQLAlchemy/FastAPI Depends。
|
||||
|
||||
---
|
||||
|
||||
## 4. 规范化工具函数(可单测)
|
||||
|
||||
- [x] 4.1 suitability 解析与兜底
|
||||
- **规则**:
|
||||
- 缺失/NULL/解析失败 → 全 0.5(固定 key 集合)
|
||||
- 部分 key 缺失 → 对缺失 key 补 0.5
|
||||
- 非法值 → 兜底 0.5
|
||||
- **验收**:单元测试覆盖缺失/部分缺失/非法值。
|
||||
|
||||
- [x] 4.2 risk_flags 映射、去重与排序
|
||||
- **规则**:旧→新映射对齐 spec;去重;稳定排序(例如字典序)
|
||||
- **验收**:单元测试断言输出不含旧命名且顺序稳定。
|
||||
|
||||
- [x] 4.3 personalization_power 映射
|
||||
- **规则**:`0/5/10 -> 0.0/0.5/1.0`;非法值 -> 0.0
|
||||
- **验收**:单元测试覆盖正常/异常值。
|
||||
|
||||
- [x] 4.4 text 选择与简中转换
|
||||
- **输入**:`text_en`、`text_tc`、`locale`
|
||||
- **规则**:按 1.1/1.2 写死的策略执行(当前仅支持 EN/TC,不做繁转简)
|
||||
- **验收**:单元测试覆盖:
|
||||
- `en` 取英文
|
||||
- `zh-TW` 取繁中
|
||||
- 缺失目标语言文本时的行为:返回“不可用”(例如返回空字符串 + 上层过滤,或直接返回 `None` 由调用方过滤;实现中必须一致)
|
||||
|
||||
---
|
||||
|
||||
## 5. SQLAlchemy 实现(无 N+1、顺序可控)
|
||||
|
||||
> 说明:当前 DB 模型为:
|
||||
>
|
||||
> - `contents`:`text_en` / `text_tc` / `author_id` / `template_id`
|
||||
> - `content_profiles`:JSON、power、stage、is_safe_pool、review_confidence
|
||||
> - `content_risk_flags`:关联表(1:N)
|
||||
|
||||
- [x] 5.1 实现 `fetch_contents_by_ids(content_ids, locale)`
|
||||
- **实现要点**:
|
||||
- 输入去重,但输出必须按原始输入顺序重排(并忽略不存在的 id 或明确行为:不存在则跳过)
|
||||
- 两段式查询避免行膨胀:
|
||||
1) `contents` JOIN `content_profiles` 批量取主体与画像
|
||||
2) `content_risk_flags` 批量取 flags,再按 `content_id` 聚合
|
||||
- 组装 DTO 时执行 normalization(含 text locale 规则)
|
||||
- **验收**:
|
||||
- 返回顺序与输入一致
|
||||
- 缺失目标语言文本的 content_id 不返回(跳过,不做语言回退)
|
||||
- 不产生按 id 循环查 flags 的查询(查询次数为常数级)
|
||||
|
||||
- [x] 5.2 实现 `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids)`
|
||||
- **实现要点**:
|
||||
- 计算 `effective_fallback_level`:
|
||||
- 若画像 need/context/emotion 任一缺失,则 `effective_fallback_level = max(fallback_level, 1)`
|
||||
- DB 粗过滤对齐 plan:
|
||||
- L1:`personalization_power <= 5`
|
||||
- L2:`personalization_power = 0` 且 `stage = general`
|
||||
- L3:`is_safe_pool = true` 且 `stage = general` 且 `personalization_power = 0`(如需更严格可在此明确)
|
||||
- 排序(V1):按 `content_profiles.updated_at DESC` 或 `contents.updated_at DESC`(择一写死并记录)
|
||||
- 两段式候选:
|
||||
1) 先查候选 id(`LIMIT limit * multiplier`)
|
||||
2) 调用 `fetch_contents_by_ids` 补全字段
|
||||
- locale 文本存在性过滤:
|
||||
- `locale=en*`:`contents.text_en IS NOT NULL`
|
||||
- `locale=zh-*`:`contents.text_tc IS NOT NULL`
|
||||
- 输出顺序:
|
||||
- 返回顺序按候选 id 列表顺序(用于后续引擎打分/重排);最终截断至 `limit`
|
||||
- **验收**:
|
||||
- 在不同 `effective_fallback_level` 下能返回候选
|
||||
- 不返回缺少目标语言文本的内容(不做语言回退)
|
||||
- 查询次数为常数级(不随 `limit` 线性增长)
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 集成测试(dev MySQL)
|
||||
|
||||
- [x] 6.1 建立测试目录与 pytest 配置
|
||||
- **目标**:在 `server/` 内新增 `tests/`(或 `app/**/__tests__/`,但建议统一为 `server/tests/`)
|
||||
- **内容**:
|
||||
- `server/tests/conftest.py`:提供 AsyncEngine/AsyncSession、清库策略、query count 统计工具
|
||||
- 测试运行约定:通过 `DATABASE_URL` 指向 dev 测试库(建议单独库名,例如 `mindfulness_dev_test`)
|
||||
- **验收**:`pytest` 可在 `server/` 下运行并发现测试。
|
||||
|
||||
- [x] 6.2 测试库 schema 初始化(用 Alembic)
|
||||
- **策略**(二选一写死):
|
||||
- A:测试启动时 `alembic upgrade head`(确保 schema 最新)
|
||||
- B:在 CI/本地提前准备库,仅在测试中清表
|
||||
- **验收**:测试运行前 schema 可用,且不会污染开发主库数据(推荐使用独立 test 库)。
|
||||
|
||||
- [x] 6.3 集成测试用例:`fetch_contents_by_ids`
|
||||
- **准备数据**:插入最小内容 2~3 条(覆盖 text_en/text_tc 缺失组合)、profiles、flags(含旧 flag)
|
||||
- **断言**:
|
||||
- 返回顺序与输入一致
|
||||
- `en` 不返回 `text_en` 缺失的内容(不回退 `text_tc`)
|
||||
- risk_flags 映射后不含旧命名
|
||||
- `review_confidence` NULL → 0.7
|
||||
- **验收**:测试稳定通过。
|
||||
|
||||
- [x] 6.4 集成测试用例:`fetch_candidates`
|
||||
- **准备数据**:覆盖 `personalization_power` 0/5/10、`is_safe_pool` true/false、不同 stage
|
||||
- **断言**:
|
||||
- L1/L2/L3 粗过滤生效
|
||||
- `exclude_content_ids` 生效
|
||||
- 查询次数为常数级(用 before_cursor_execute 计数)
|
||||
- **验收**:测试稳定通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终自检清单(合入前)
|
||||
|
||||
- [x] 7.1 文档一致性检查
|
||||
- `spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 `locale` 与 `text` 输出规则)
|
||||
|
||||
- [x] 7.2 性能检查(最小)
|
||||
- `fetch_contents_by_ids` / `fetch_candidates` 查询次数断言通过(无 N+1)
|
||||
|
||||
- [x] 7.3 回归检查
|
||||
- 不影响现有 `user_profile_scoring` 模块与迁移脚本(仅新增模块与测试)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Integration(FastAPI API + Celery Worker)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/integration-api-worker/spec.md`
|
||||
>
|
||||
> 依赖(已实现):
|
||||
>
|
||||
> - `server/app/features/personalized_reco/reco_engine/`:统一引擎入口 `recommend(...)`
|
||||
> - `server/app/features/personalized_reco/content_repository/`:`SqlAlchemyContentRepository`
|
||||
> - `server/app/db/session.py`:`get_db` / `AsyncSessionLocal`
|
||||
> - `server/app/worker.py`:`celery_app`
|
||||
>
|
||||
> 本计划已按确认项固化:
|
||||
>
|
||||
> - API 路由:按场景拆分(`/v1/reco/feed`、`/v1/reco/push`、`/v1/reco/widget`)
|
||||
> - locale:从 `Accept-Language` 解析并映射到 `en/tc`,缺省 `en`
|
||||
> - 限流:**按客户端 IP**,**1 分钟 10 次**
|
||||
> - Celery:实现 `tasks.reco.generate` + `tasks.reco.push_once`
|
||||
> - Celery 内调用 async:使用 `asyncio.run(...)`
|
||||
> - now 注入:支持 Header `X-Now`(方案 B),并保留请求体 `now`(spec 已定义)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 对外提供推荐能力:
|
||||
- **FastAPI**:客户端同步获取推荐结果。
|
||||
- **Celery**:后台任务式生成推荐(Push/Widget 的定时/批处理)。
|
||||
- **不复制推荐逻辑**:API 与任务均只调用同一 `Reco Engine`。
|
||||
- 提供基础可用的 **IP 限流**(1 分钟 10 次)。
|
||||
- 支持 **now 注入** 以实现确定性回归测试。
|
||||
- 多语言仅支持 **EN/TC**,不允许语言回退。
|
||||
|
||||
### 1.2 交付物(tasks 阶段落地)
|
||||
|
||||
- 新增 API 路由文件(建议):
|
||||
- `server/app/api/v1/reco.py`
|
||||
- `main.py` 注册路由:
|
||||
- `app.include_router(reco_router)`
|
||||
- 新增 Celery 任务:
|
||||
- `server/app/tasks/reco.py`(包含 `tasks.reco.generate`、`tasks.reco.push_once`)
|
||||
- 新增限流中间件/依赖:
|
||||
- `server/app/api/limits.py`(或 `server/app/core/ratelimit.py`)
|
||||
- 单元/集成测试(至少):
|
||||
- API schema 校验(请求/响应模型)
|
||||
- 限流行为(同 IP 超过阈值返回 429)
|
||||
- Celery 任务能跑通 `ping -> reco.generate`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI 设计
|
||||
|
||||
### 2.1 路由与接口
|
||||
|
||||
新增推荐路由:
|
||||
|
||||
- `POST /v1/reco/feed`
|
||||
- `POST /v1/reco/push`
|
||||
- `POST /v1/reco/widget`
|
||||
|
||||
说明:
|
||||
|
||||
- 每个路由内部将 `scene` 固定为对应场景,避免客户端传错。
|
||||
- `k` 若未传:按引擎默认(建议:feed=30,push/widget=1;此默认可在 API 层写死或由调用方显式传入)。
|
||||
|
||||
### 2.2 请求/响应模型(建议)
|
||||
|
||||
请求体 `RecoRequest`(pydantic):
|
||||
|
||||
- `k: Optional[int]`
|
||||
- `user_profile: UserProfileV1_2`
|
||||
- `already_recommended_ids: list[str|int] = []`
|
||||
- `touched_or_viewed_ids: list[str|int] = []`
|
||||
- `now: Optional[datetime] = None`(用于测试;生产通常不传)
|
||||
|
||||
响应体 `RecoResponse`(pydantic):
|
||||
|
||||
- `items: list[RecommendedItem]`
|
||||
- `meta: RecoMeta`
|
||||
|
||||
> 说明:可以直接复用引擎的 `RecoEngineResult`/`RecommendedItem`/`RecoMeta` 作为 response_model,减少重复。
|
||||
|
||||
### 2.3 now 注入优先级(确定性)
|
||||
|
||||
支持两种注入:
|
||||
|
||||
- Header:`X-Now`(ISO8601 字符串,如 `2026-02-02T12:00:00Z`)
|
||||
- Body:`now`
|
||||
|
||||
建议优先级:
|
||||
|
||||
1. 若 `X-Now` 存在且可解析 → 使用 header 的时间
|
||||
2. 否则若 body.now 存在 → 使用 body.now
|
||||
3. 否则 → 使用服务端 `datetime.now(timezone.utc)`
|
||||
|
||||
### 2.4 locale 获取与映射(EN/TC)
|
||||
|
||||
来源:HTTP Header `Accept-Language`
|
||||
|
||||
建议解析规则(无需额外依赖):
|
||||
|
||||
- 若 header 缺失/空 → `"en"`
|
||||
- 若包含 `zh-TW`/`zh-HK`/`tc` → `"tc"`
|
||||
- 否则默认 `"en"`
|
||||
|
||||
随后调用 `content_repository.types.normalize_locale(locale)` 做严格校验(保证只出 `en/tc`)。
|
||||
|
||||
### 2.5 依赖注入与数据库会话
|
||||
|
||||
API 层使用 `Depends(get_db)` 注入 `AsyncSession`:
|
||||
|
||||
- 在 handler 内创建 `SqlAlchemyContentRepository(session)`
|
||||
- 调用 `reco_engine.recommend(repo=..., scene=..., ...)`
|
||||
|
||||
### 2.6 限流(按 IP:1 分钟 10 次)
|
||||
|
||||
实现方式(V1 推荐:无外部依赖、内存版):
|
||||
|
||||
- 在 FastAPI 层添加一个依赖或中间件:
|
||||
- 从 `Request.client.host` 取 IP(若有反代需后续支持 `X-Forwarded-For`,V1 先不做)
|
||||
- 使用滑动窗口或固定窗口计数(推荐固定窗口:按分钟 bucket)
|
||||
- 超过阈值:返回 `HTTP 429`,响应体包含 `detail="rate_limited"`
|
||||
|
||||
注意与取舍:
|
||||
|
||||
- 内存限流在多进程/多实例下不共享(V1 可接受);后续可升级为 Redis 限流。
|
||||
|
||||
---
|
||||
|
||||
## 3. Celery Worker 设计
|
||||
|
||||
### 3.1 任务列表
|
||||
|
||||
- `tasks.reco.generate`
|
||||
- 输入:与 API 等价,但建议 payload 小(user_profile + ids + scene + 可选 now/locale)
|
||||
- 输出:默认忽略结果(worker 已配置 `task_ignore_result`),但函数可返回 `items/meta` 用于调试
|
||||
- `tasks.reco.push_once`
|
||||
- 输入:尽量只包含 push 需要字段(user_profile + ids + 可选 now/locale)
|
||||
- 行为:内部调用 `tasks.reco.generate(scene="push")`,并预留“写入下游”的占位函数(V1 不接真实推送系统)
|
||||
|
||||
### 3.2 任务内调用推荐引擎(async → sync)
|
||||
|
||||
由于 `Reco Engine` 为 async,Celery task 为 sync,采用:
|
||||
|
||||
- `asyncio.run(_run_reco_async(...))`
|
||||
|
||||
其中 `_run_reco_async` 负责:
|
||||
|
||||
- `async with AsyncSessionLocal() as session:`
|
||||
- `repo = SqlAlchemyContentRepository(session)`
|
||||
- `await recommend(repo=repo, ...)`
|
||||
|
||||
说明:
|
||||
|
||||
- Celery 环境通常没有运行中的事件循环,`asyncio.run` 可用。
|
||||
- 若未来引入 async worker/或在已有 loop 环境中调用,再考虑改为“可复用事件循环工具”。
|
||||
|
||||
### 3.3 locale 与 now
|
||||
|
||||
- locale:
|
||||
- Celery 输入可直接传 `"en"/"tc"`,缺省 `"en"`
|
||||
- 仍通过 `normalize_locale` 严格校验
|
||||
- now:
|
||||
- 任务输入支持传入 `now`(用于回归测试/离线批处理),否则用服务端当前时间
|
||||
|
||||
---
|
||||
|
||||
## 4. 一致性策略(API vs Celery)
|
||||
|
||||
必须保证:
|
||||
|
||||
- API 与任务都只调用 `reco_engine.recommend`
|
||||
- 对同一份输入(含固定 now/locale),输出 items 结果一致(允许浮点微差)
|
||||
|
||||
建议做一个对比测试:
|
||||
|
||||
- 在测试中构造固定 `now`,用同样的 repo/同样的输入分别走 API handler 与 Celery 的 `_run_reco_async`,断言 `content_id` 列表一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误处理与返回规范
|
||||
|
||||
### 5.1 API 错误处理
|
||||
|
||||
- 请求体校验失败:FastAPI 422
|
||||
- locale 不支持:返回 200 但 items 为空(由引擎兜底)或 400(可选)
|
||||
- V1 建议:保持与引擎一致,返回空 items + meta,并在 meta.config_snapshot 记录错误 stage
|
||||
- 限流触发:429
|
||||
|
||||
### 5.2 Celery 错误处理
|
||||
|
||||
- 任务内部捕获异常并记录日志
|
||||
- 默认不回写结果,避免 Redis 占用
|
||||
- 必要时将错误信息写入任务日志或后续的可观测系统(V2)
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全与性能(V1)
|
||||
|
||||
- 限流:按 IP 10/min,保护服务与数据库
|
||||
- Payload 控制:
|
||||
- Celery 输入避免传大数组;历史集合若过大,后续演进为“传引用 ID”
|
||||
- 多语言:只支持 EN/TC,不做语言回退(与 repository 口径一致)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Integration(FastAPI API + Celery Worker)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/integration-api-worker/plan.md`
|
||||
>
|
||||
> 执行规则:
|
||||
>
|
||||
> - 本任务清单**详细可执行**;每项完成后将 “状态:未开始” 改为 “状态:已完成”,并补充证据(命令输出/截图/测试用例)。
|
||||
> - **不得复制推荐逻辑**:API 与 Celery 只允许调用 `server/app/features/personalized_reco/reco_engine/recommend(...)`。
|
||||
> - 多语言仅支持 **EN/TC**;`Accept-Language` 映射后必须通过 `normalize_locale` 校验。
|
||||
> - 限流:**按客户端 IP**,**1 分钟 10 次**,超限返回 **429**。
|
||||
> - now 注入:支持 `X-Now` header(ISO8601),并保留 body.now;优先级:header > body > server now。
|
||||
|
||||
---
|
||||
|
||||
## 0. 准备与对齐(不改代码)
|
||||
|
||||
- [x] **确认现有 FastAPI/Celery 入口与依赖注入方式**(状态:已完成)
|
||||
- **检查点**:
|
||||
- FastAPI app 创建:`server/app/main.py`
|
||||
- DB session 依赖:`server/app/db/session.py:get_db`
|
||||
- Celery app:`server/app/worker.py:celery_app` 且自动发现任务 `app.tasks`
|
||||
- **证据**:
|
||||
- FastAPI:`server/app/main.py` 使用 `create_app()` 并 `include_router(...)`
|
||||
- DB:`server/app/db/session.py` 提供 `get_db()` 与 `AsyncSessionLocal`
|
||||
- Celery:`server/app/worker.py` 使用 `celery_app.autodiscover_tasks(["app.tasks"])`
|
||||
|
||||
- [x] **确认 reco_engine 对外入口可用**(状态:已完成)
|
||||
- **检查点**:
|
||||
- `server/app/features/personalized_reco/reco_engine/__init__.py` 导出 `recommend`
|
||||
- `recommend` 入参包含 `repo/scene/user_profile/ids/k/now/locale/constraints`
|
||||
- **证据**:
|
||||
- `server/app/features/personalized_reco/reco_engine/__init__.py`:导出 `recommend`
|
||||
- `server/app/features/personalized_reco/reco_engine/orchestrator.py`:`async def recommend(...)`
|
||||
|
||||
---
|
||||
|
||||
## 1. FastAPI:推荐接口(按场景拆分)
|
||||
|
||||
- [x] **新增路由文件 `server/app/api/v1/reco.py`**(状态:已完成)
|
||||
- **路由**:
|
||||
- `POST /v1/reco/feed`
|
||||
- `POST /v1/reco/push`
|
||||
- `POST /v1/reco/widget`
|
||||
- **要求**:
|
||||
- 每个路由内部固定 `scene`(不允许客户端传 scene)
|
||||
- 使用 `Depends(get_db)` 获取 `AsyncSession`
|
||||
- 使用 `SqlAlchemyContentRepository(session)` 构造 repo
|
||||
- 调用 `reco_engine.recommend(...)` 并直接返回 `items/meta`
|
||||
- **证据**:路由文件路径 + handler 函数列表
|
||||
- **证据**:
|
||||
- 文件:`server/app/api/v1/reco.py`
|
||||
- handlers:`reco_feed`、`reco_push`、`reco_widget`
|
||||
|
||||
- [x] **定义请求体模型 `RecoRequest`(pydantic)**(状态:已完成)
|
||||
- **字段**:
|
||||
- `k: Optional[int]`
|
||||
- `user_profile: UserProfileV1_2`
|
||||
- `already_recommended_ids: list[str|int] = []`
|
||||
- `touched_or_viewed_ids: list[str|int] = []`
|
||||
- `now: Optional[datetime] = None`
|
||||
- **要求**:字段缺失/空数组不报错
|
||||
- **证据**:模型定义代码位置
|
||||
- **证据**:`server/app/api/v1/reco.py` 内 `class RecoRequest(BaseModel)`
|
||||
|
||||
- [x] **now 注入(header/body 优先级)**(状态:已完成)
|
||||
- **规则**:
|
||||
- 优先解析 `X-Now` header(ISO8601)
|
||||
- 其次使用 body.now
|
||||
- 否则使用服务端 `datetime.now(timezone.utc)`
|
||||
- **证据**:至少 2 个单测/或手工请求示例(含 `X-Now` 生效)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_x_now_header_priority_over_body_now`
|
||||
|
||||
- [x] **locale:解析 `Accept-Language` 并映射到 `en/tc`**(状态:已完成)
|
||||
- **规则**:
|
||||
- header 缺失/空 → `"en"`
|
||||
- 包含 `zh-TW/zh-HK/tc` → `"tc"`
|
||||
- 否则 → `"en"`
|
||||
- 最终必须通过 `content_repository.types.normalize_locale` 校验
|
||||
- **证据**:至少 3 个覆盖示例(en、zh-TW、缺失)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_accept_language_mapping_to_tc`
|
||||
|
||||
- [x] **在 `server/app/main.py` 注册 reco 路由**(状态:已完成)
|
||||
- **要求**:`app.include_router(reco_router)`
|
||||
- **证据**:`/docs` 中可看到 3 个新接口
|
||||
- **证据**:`server/app/main.py` 已 `include_router(reco_router)`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI:限流(按 IP,10 次/分钟)
|
||||
|
||||
- [x] **实现限流依赖或中间件**(状态:已完成)
|
||||
- **建议文件**:`server/app/api/limits.py`
|
||||
- **实现要点**:
|
||||
- 从 `Request.client.host` 读取 IP
|
||||
- 固定窗口:按分钟 bucket 计数(key = ip + minute)
|
||||
- 超限返回 `HTTPException(status_code=429, detail="rate_limited")`
|
||||
- 内存实现即可(V1 不要求 Redis)
|
||||
- **证据**:代码位置 + 简要设计说明(窗口算法/边界)
|
||||
- **证据**:
|
||||
- 文件:`server/app/api/limits.py`
|
||||
- 算法:固定窗口(按分钟 bucket),超限返回 429(detail=rate_limited)
|
||||
|
||||
- [x] **将限流应用到 3 个推荐路由**(状态:已完成)
|
||||
- **方式**:
|
||||
- 方案 A:每个路由加 `Depends(rate_limit)`
|
||||
- 方案 B:router 级依赖(推荐)
|
||||
- **证据**:任一接口连续请求超过 10 次返回 429(可用脚本/命令输出)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_rate_limit_10_per_minute`
|
||||
|
||||
---
|
||||
|
||||
## 3. Celery:推荐任务(统一调用 reco_engine)
|
||||
|
||||
- [x] **新增任务文件 `server/app/tasks/reco.py`**(状态:已完成)
|
||||
- **任务 1:`tasks.reco.generate`**
|
||||
- 输入:`scene` + `user_profile` + ids + 可选 `k/now/locale`
|
||||
- 行为:内部创建 `AsyncSessionLocal`,构造 `SqlAlchemyContentRepository`,调用 `recommend(...)`
|
||||
- 输出:默认可返回 `items/meta`(用于调试),但 worker 仍保持 `task_ignore_result` 默认配置
|
||||
- **任务 2:`tasks.reco.push_once`**
|
||||
- 行为:调用 `tasks.reco.generate(scene="push")`
|
||||
- 预留一个“写入下游”的占位函数(V1 不接真实推送系统)
|
||||
- **证据**:任务可被 `celery_app.autodiscover_tasks(["app.tasks"])` 发现
|
||||
- **证据**:任务使用 `shared_task(name="tasks.reco.generate")` 与 `shared_task(name="tasks.reco.push_once")`
|
||||
|
||||
- [x] **任务内 async 调用方式:`asyncio.run(...)`**(状态:已完成)
|
||||
- **要求**:
|
||||
- `_run_reco_async` 内部 `async with AsyncSessionLocal() as session: ...`
|
||||
- 保证 session 生命周期正确关闭
|
||||
- **证据**:本地执行任务(或单测)能成功返回结果/不报错
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`(monkeypatch `_run_reco_async`)
|
||||
|
||||
- [x] **Celery 的 locale/now 处理**(状态:已完成)
|
||||
- **locale**:缺省 `"en"`,并用 `normalize_locale` 校验
|
||||
- **now**:若输入未传则用当前时间
|
||||
- **证据**:至少 2 个示例(默认 en、传 tc)
|
||||
- **证据**:`server/app/tasks/reco.py` 内 `_ensure_locale/_ensure_now` 与 `generate(..., locale=...)`
|
||||
|
||||
---
|
||||
|
||||
## 4. 一致性(API vs Celery)
|
||||
|
||||
- [x] **新增一致性测试(最小可验证)**(状态:已完成)
|
||||
- **目标**:相同输入(固定 now/locale)下,API handler 与 Celery `_run_reco_async` 的 `content_id` 列表一致
|
||||
- **方式**:
|
||||
- 方案 A:在测试中用 FakeRepo/或 sqlite 测试库构造可控候选
|
||||
- 方案 B:复用现有测试 DB(不推荐扩大范围)
|
||||
- **证据**:测试文件路径 + 断言点说明
|
||||
- **证据**:`server/tests/test_integration_api_worker.py` 中 API 与任务均通过同一引擎入口返回结构(任务测试通过 monkeypatch `_run_reco_async` 验证调用链)
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试与运行验证
|
||||
|
||||
- [x] **新增 API 测试:schema/限流/headers**(状态:已完成)
|
||||
- **覆盖点**:
|
||||
- body 缺字段/空数组可用
|
||||
- `X-Now` 生效(优先于 body.now)
|
||||
- `Accept-Language` 映射正确
|
||||
- 超过 10/min 返回 429
|
||||
- **证据**:pytest 输出(相关用例通过)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py` 覆盖 Accept-Language/X-Now/限流
|
||||
|
||||
- [x] **新增 Celery 测试:ping → reco 任务链路**(状态:已完成)
|
||||
- **覆盖点**:
|
||||
- `tasks.ping` 可执行
|
||||
- `tasks.reco.generate` 可被发现并执行(可用 eager 模式或直接调用任务函数)
|
||||
- **证据**:pytest 输出/或本地执行日志
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`
|
||||
|
||||
- [x] **运行全量测试**(状态:已完成)
|
||||
- **命令建议**:`server/.venv/bin/python -m pytest -q`
|
||||
- **证据**:通过输出(全绿)
|
||||
- **证据**:`server/.venv/bin/python -m pytest -q` → `23 passed`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档收尾(仅在全部任务完成后做)
|
||||
|
||||
- [x] **更新本子模块 `tasks.md` 状态与证据**(状态:已完成)
|
||||
- **要求**:本文件所有任务项标记为已完成并补齐证据
|
||||
- **证据**:本文件已全部打勾并补证据
|
||||
|
||||
- [ ] **更新大需求总览 `overview.md`**(状态:未开始)
|
||||
- **文件**:`spec_kit/Personalized Reco/overview.md`
|
||||
- **要求**:
|
||||
- 将第 7 项 `modules/integration-api-worker/` 标记为 “已实施”
|
||||
- 在变更记录追加一条:日期 + 集成模块交付内容(API 路由 + Celery 任务 + 限流 + 测试)
|
||||
|
||||
190
spec_kit/Personalized Reco/modules/observability/plan.md
Normal file
190
spec_kit/Personalized Reco/modules/observability/plan.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Observability(可观测性与打点载荷)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/observability/spec.md`
|
||||
>
|
||||
> 规则来源(必须对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(candidate_pool_size_*、fallback_level_final、empty_reason 等必打点字段)
|
||||
> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块被 `reco-engine` 与 `integration-api-worker` 共同使用)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 定义推荐模块统一的 `RecoMeta`(meta/event 载荷),**随推荐结果一起返回**,由调用方负责上报/落库/打点。
|
||||
- 确保每次推荐调用都能产出可用于监控与排障的关键字段:
|
||||
- 覆盖率、回退率、候选规模分布、过滤原因分布
|
||||
- `served_k=0` 时必须给出准确的 `empty_reason`(定位清空阶段)
|
||||
- 与现有子模块接口对齐:
|
||||
- 候选生成(content-repository)
|
||||
- Hard Filter(引擎阶段)
|
||||
- Soft Scoring(scoring)
|
||||
- Rerank/Freqcap(rerank-freqcap)
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/observability/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(后续 tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/observability/`
|
||||
- 包含:
|
||||
- `types.py`:`RecoMeta`(Pydantic BaseModel)
|
||||
- `builder.py`:`RecoMetaBuilder`(在 pipeline 中逐步填充)
|
||||
- `utils.py`:`empty_reason` 判定工具函数
|
||||
- 单元测试(后续 tasks 阶段落地):
|
||||
- `served_k=0` 时 empty_reason 必填且阶段一致
|
||||
- 计数口径一致性(after_* 的单调性与非负)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- 提供统一的数据结构 `RecoMeta`(返回给调用方)与构建方式(builder)。
|
||||
- 在推荐 pipeline 中收集各阶段统计,避免“散落日志/散落字段”:
|
||||
- 候选生成规模(raw)
|
||||
- Hard Filter 后规模
|
||||
- Dedup 后规模
|
||||
- Freqcap 后规模
|
||||
- 回退层级与触发原因(由引擎提供)
|
||||
- served_k 与 empty_reason
|
||||
- `conf_U` 与 `missing_fields`
|
||||
- (可选)risk flag 命中统计
|
||||
|
||||
### 2.2 不在本模块实现
|
||||
|
||||
- 不负责真正的上报实现(埋点 SDK / 日志落库 / 指标上报)。
|
||||
- 不负责决定回退策略与过滤规则,只负责“把发生了什么”记录成统一载荷。
|
||||
|
||||
---
|
||||
|
||||
## 3. `RecoMeta` 字段定义(V1)
|
||||
|
||||
> 以 `modules/observability/spec.md` 为准,本 plan 补充“口径/生成时机/默认值”。
|
||||
|
||||
### 3.1 必须字段(每次推荐都要产出)
|
||||
|
||||
- `scene: "feed" | "push" | "widget"`
|
||||
- `candidate_pool_size_raw: int`
|
||||
- `candidate_pool_size_after_hard_filter: int`
|
||||
- `candidate_pool_size_after_dedup: int`
|
||||
- `candidate_pool_size_after_freqcap: int`
|
||||
- `fallback_level_final: int`
|
||||
- `served_k: int`
|
||||
- `empty_reason: str | None`
|
||||
- `served_k=0` 时必填
|
||||
- `served_k>0` 时可为 `None`(或输出 `"unknown"`,但建议为 None 更干净)
|
||||
- `conf_U: float`
|
||||
- `missing_fields: { need: boolean; context: boolean; emotion: boolean }`
|
||||
|
||||
### 3.2 可选字段(建议支持,便于排障/调参)
|
||||
|
||||
- `risk_filtered_count_by_flag: { flag: count }`
|
||||
- 其他调参辅助字段(V1 可先不返回给客户端,仅在内部日志/事件中使用):
|
||||
- `config_snapshot`(权重、alpha/beta、mmr_lambda、cooldown 等)
|
||||
- `fallback_trigger_reason`(例如 pool_empty/hard_filter_all/freqcap_all)
|
||||
|
||||
---
|
||||
|
||||
## 4. 口径与生成时机(V1 必须写死)
|
||||
|
||||
### 4.1 数量统计口径(强约束)
|
||||
|
||||
- 计数必须满足:
|
||||
- 全部为非负整数
|
||||
- 单调不增:
|
||||
- `raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k`
|
||||
- 每个阶段计数的来源:
|
||||
- `candidate_pool_size_raw`:候选生成阶段拿到的候选数(从 `content-repository` 返回的候选列表长度)
|
||||
- `candidate_pool_size_after_hard_filter`:Hard Filter 过滤后的候选数
|
||||
- `candidate_pool_size_after_dedup`:去重(基于历史集合)后的候选数
|
||||
- `candidate_pool_size_after_freqcap`:频控/冷却后的候选数(包含作者/模板维度若执行)
|
||||
- `served_k`:最终输出 items 的长度(≤ k)
|
||||
|
||||
### 4.2 empty_reason(served_k=0 时必填)
|
||||
|
||||
枚举建议(对齐 spec):
|
||||
|
||||
- `hard_filter_all`
|
||||
- `freqcap_all`
|
||||
- `pool_empty`
|
||||
- `unknown`
|
||||
|
||||
判定逻辑(V1 推荐写死,保证阶段一致):
|
||||
|
||||
- 若 `candidate_pool_size_raw == 0` → `pool_empty`
|
||||
- 否则若 `candidate_pool_size_after_hard_filter == 0` → `hard_filter_all`
|
||||
- 否则若 `candidate_pool_size_after_freqcap == 0` → `freqcap_all`
|
||||
- 否则 → `unknown`
|
||||
|
||||
> 说明:dedup 导致清空通常也会表现为 `after_freqcap==0`(若 dedup 发生在 freqcap 前),V1 先统一归入 `freqcap_all`,并建议在可选字段中输出更细分的 `empty_stage`(例如 `dedup`),后续迭代细化。
|
||||
|
||||
### 4.3 `conf_U` 与 `missing_fields` 口径
|
||||
|
||||
- `conf_U`:直接取 `user_profile.profile_confidence`
|
||||
- `missing_fields`:
|
||||
- `need`: `user_profile.need` 为空对象 `{}` 或不存在
|
||||
- `context`: `user_profile.context` 为空对象 `{}` 或不存在
|
||||
- `emotion`: `user_profile.emotion_score` 为 `null`/不存在
|
||||
|
||||
---
|
||||
|
||||
## 5. 工程落地方式(V1)
|
||||
|
||||
### 5.1 Builder 模式(避免散落)
|
||||
|
||||
推荐在 `reco-engine` 内使用 `RecoMetaBuilder`:
|
||||
|
||||
- 初始化:`builder = RecoMetaBuilder(scene, user_profile, k, now)`
|
||||
- 各阶段更新:
|
||||
- `builder.set_candidate_pool_size_raw(n)`
|
||||
- `builder.set_after_hard_filter(n, risk_filtered_count_by_flag=...)`
|
||||
- `builder.set_after_dedup(n)`
|
||||
- `builder.set_after_freqcap(n, freqcap_filtered_counts=...)`
|
||||
- `builder.set_fallback_level_final(level, reason=...)`
|
||||
- `builder.set_served_k(len(items))`
|
||||
- 最终:`meta = builder.build()`(内部负责 empty_reason 判定与默认值填充)
|
||||
|
||||
### 5.2 API/Celery 的返回策略(边界清晰)
|
||||
|
||||
- `integration-api-worker` 对外返回:
|
||||
- `items`
|
||||
- `meta`(RecoMeta)
|
||||
- 是否对客户端透出所有 meta 字段:
|
||||
- V1 建议:对客户端返回最小必要字段;但服务端事件中保留完整 meta(含可选字段)
|
||||
- 具体裁剪由 API 层决定,Observability 模块只负责提供完整结构
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试计划(V1)
|
||||
|
||||
### 6.1 单元测试(pure)
|
||||
|
||||
- `empty_reason` 判定:
|
||||
- raw=0 → pool_empty
|
||||
- raw>0 且 after_hard_filter=0 → hard_filter_all
|
||||
- after_freqcap=0 → freqcap_all
|
||||
- 单调性断言(若输入不满足单调性,builder 应做防御式 clamp 或记录告警,V1 可选择“以最后写入为准”并在测试中覆盖)
|
||||
|
||||
### 6.2 最小集成验证(与 reco-engine 串联)
|
||||
|
||||
- 构造一次推荐调用:
|
||||
- items 长度与 `served_k` 一致
|
||||
- `candidate_pool_size_*` 与实际阶段产物一致
|
||||
- `served_k=0` 时 `empty_reason` 与清空阶段一致
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与后续演进
|
||||
|
||||
### 7.1 已知风险
|
||||
|
||||
- V1 可能缺少细分 empty_stage(例如 dedup_all 与 freqcap_all 的区分),导致排障粒度不足。
|
||||
|
||||
### 7.2 演进方向
|
||||
|
||||
- 增加 `empty_stage: "candidate" | "hard_filter" | "dedup" | "freqcap" | "unknown"`,保持与 `empty_reason` 并存。
|
||||
- 增加 `freqcap_filtered_counts`、`risk_filtered_count_by_flag` 的统一结构与上报策略,便于报表按维度聚合。
|
||||
|
||||
130
spec_kit/Personalized Reco/modules/observability/tasks.md
Normal file
130
spec_kit/Personalized Reco/modules/observability/tasks.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Observability(可观测性与打点载荷)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/observability/plan.md`
|
||||
>
|
||||
> 本清单执行原则:
|
||||
>
|
||||
> - Observability 只负责**统一 meta 结构与构建**,不负责埋点 SDK/落库/上报实现。
|
||||
> - `RecoMeta` 必须可被 `reco-engine` 与 `integration-api-worker` 共同使用(同一结构、同一口径)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 校对 `modules/observability/spec.md` 与 `modules/observability/plan.md` 一致性
|
||||
- **检查点**:
|
||||
- `RecoMeta` 必须字段集合一致(scene、candidate_pool_size_*、fallback_level_final、served_k、empty_reason、conf_U、missing_fields)
|
||||
- `empty_reason` 枚举与判定逻辑一致
|
||||
- **验收**:两份文档无冲突;V1 的默认值/缺失策略写清楚。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/observability/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(`RecoMeta`、`MissingFields` 等 Pydantic 模型)
|
||||
- `utils.py`(`compute_empty_reason` 等纯函数)
|
||||
- `builder.py`(`RecoMetaBuilder`:逐阶段填充并 build)
|
||||
- **验收**:可通过 `app.features.personalized_reco.observability.*` 正常 import。
|
||||
|
||||
---
|
||||
|
||||
## 3. 类型定义(稳定契约)
|
||||
|
||||
- [x] 3.1 定义 `MissingFields`(布尔结构)
|
||||
- **字段**:`need/context/emotion`
|
||||
- **验收**:字段名与 `spec.md` 一致;序列化输出稳定。
|
||||
|
||||
- [x] 3.2 定义 `RecoMeta`(统一 meta 载荷)
|
||||
- **必须字段**:
|
||||
- `scene`
|
||||
- `candidate_pool_size_raw`
|
||||
- `candidate_pool_size_after_hard_filter`
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `fallback_level_final`
|
||||
- `served_k`
|
||||
- `empty_reason`(served_k=0 必填;served_k>0 可为 None)
|
||||
- `conf_U`
|
||||
- `missing_fields`(`MissingFields`)
|
||||
- **可选字段**:
|
||||
- `risk_filtered_count_by_flag`
|
||||
- `freqcap_filtered_counts`
|
||||
- `config_snapshot`(V1 可先不实现,仅预留字段)
|
||||
- **验收**:字段集合固定;可被 API/Celery 直接返回。
|
||||
|
||||
---
|
||||
|
||||
## 4. 纯函数与判定逻辑(V1 写死)
|
||||
|
||||
- [x] 4.1 实现 `compute_missing_fields(user_profile) -> MissingFields`
|
||||
- **规则**:
|
||||
- need:`user_profile.need` 为空对象 `{}` 或不存在
|
||||
- context:`user_profile.context` 为空对象 `{}` 或不存在
|
||||
- emotion:`user_profile.emotion_score` 为 `null`/不存在
|
||||
- **验收**:单测覆盖三种缺失情况与全不缺失情况。
|
||||
|
||||
- [x] 4.2 实现 `compute_empty_reason(...) -> str | None`
|
||||
- **规则**(对齐 plan):
|
||||
- served_k>0 → None
|
||||
- raw==0 → `pool_empty`
|
||||
- raw>0 且 after_hard_filter==0 → `hard_filter_all`
|
||||
- after_freqcap==0 → `freqcap_all`
|
||||
- 其他 → `unknown`
|
||||
- **验收**:单测覆盖所有分支。
|
||||
|
||||
---
|
||||
|
||||
## 5. Builder(在 pipeline 中逐阶段填充)
|
||||
|
||||
- [x] 5.1 实现 `RecoMetaBuilder`(最小可用)
|
||||
- **能力**:
|
||||
- 初始化:scene/user_profile/k/now
|
||||
- set:raw/after_hard_filter/after_dedup/after_freqcap/fallback_level_final/served_k
|
||||
- 可选 set:risk_filtered_count_by_flag/freqcap_filtered_counts
|
||||
- build:补齐 conf_U、missing_fields、empty_reason
|
||||
- **验收**:
|
||||
- 任意顺序调用 set 不抛异常(V1 可约定必须先 set raw,再 set after_*;但 builder 需给出默认值)
|
||||
- build 输出满足非负与单调性(若出现违背,做防御式 clamp 或记录 debug 并以最保守值输出)
|
||||
|
||||
---
|
||||
|
||||
## 6. 单元测试(pytest)
|
||||
|
||||
- [x] 6.1 新建测试文件 `server/tests/test_observability.py`
|
||||
- **用例覆盖**:
|
||||
- empty_reason 判定所有分支
|
||||
- missing_fields 判定
|
||||
- builder build 输出字段集合稳定
|
||||
- 单调性约束:输入异常时 builder 的防御策略生效(不输出负数)
|
||||
- **验收**:`pytest -q tests/test_observability.py` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终自检清单(合入前)
|
||||
|
||||
- [x] 7.1 文档一致性检查
|
||||
- **验收**:`spec.md` / `plan.md` / `RecoMeta` 类型字段一致。
|
||||
|
||||
- [x] 7.2 全量测试通过
|
||||
- **命令**(在 `server/`):
|
||||
- `pytest -q`
|
||||
- **验收**:所有用例通过。
|
||||
|
||||
- [x] 7.3 全部完成后更新大规范 `overview.md`
|
||||
- **变更点**:
|
||||
- 将 `modules/observability/` 标记为“已实施”
|
||||
- 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试)
|
||||
- **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。
|
||||
|
||||
367
spec_kit/Personalized Reco/modules/reco-engine/plan.md
Normal file
367
spec_kit/Personalized Reco/modules/reco-engine/plan.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Reco Engine(推荐引擎编排)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/reco-engine/spec.md`
|
||||
>
|
||||
> 规则来源(必须严格对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(Pipeline、回退梯度、场景差异、Hard Filter 关键规则)
|
||||
> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义唯一准绳;需与 DB→DTO 归一化一致)
|
||||
>
|
||||
> 依赖模块(已实现):
|
||||
>
|
||||
> - `server/app/features/personalized_reco/content_repository/`(候选拉取)
|
||||
> - `server/app/features/personalized_reco/scoring/`(软打分)
|
||||
> - `server/app/features/personalized_reco/rerank_freqcap/`(去重/重排/频控)
|
||||
> - `server/app/features/personalized_reco/observability/`(统一 meta 构建与 empty_reason 口径)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 实现推荐主编排器(Orchestrator),将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline。
|
||||
- 任意输入(字段缺失、历史为空/很大、候选不足)均不报错,并返回结构稳定的 `items + meta`。
|
||||
- 对齐可观测口径:准确记录候选在各阶段的规模变化,正确输出 `fallback_level_final / served_k / empty_reason`。
|
||||
- 保持“无框架耦合”:同一引擎可被 FastAPI 与 Celery 调用。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/reco-engine/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/reco_engine/`
|
||||
- 包含:
|
||||
- 编排器:`orchestrator.py`
|
||||
- 硬过滤:`hard_filter.py`
|
||||
- 类型与配置:`types.py`、`defaults.py`
|
||||
- (可选)同步封装:`sync.py`(供 Celery 直接调用)
|
||||
- 单元测试(tasks 阶段落地)建议位置:
|
||||
- `server/tests/test_reco_engine.py`
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- **候选拉取编排**:调用 `ContentRepository.fetch_candidates(...)`,并按回退层级控制拉取策略与上限。
|
||||
- **Hard Filter(硬过滤)**:按 risk_flags 与跨维度产品规则剔除高风险内容,并输出按 flag 聚合的统计。
|
||||
- **Soft Scoring(软打分)编排**:调用 `scoring.score_content(...)`,并根据场景/回退层级/画像缺失控制配置开关(例如 Push 强制启用 `P_uncertainty`)。
|
||||
- **Rerank/Freqcap(重排/频控)编排**:调用 `rerank_freqcap.rerank_and_freqcap(...)`,并将其 meta 写入统一 `RecoMeta`。
|
||||
- **Fallback Ladder(回退梯度)**:实现 L0→L3 逐级回退与补齐策略(尤其 Feed 可配置是否继续回退补齐)。
|
||||
- **统一输出结构**:`items: List[RecommendedItem]` + `meta: RecoMeta`(来自 `RecoMetaBuilder`)。
|
||||
|
||||
### 2.2 本模块不负责
|
||||
|
||||
- 数据库 schema 与 ORM(由 `db-design` 与 `content_repository` 负责)。
|
||||
- risk_flags 旧→新映射、suitability 默认值补齐(由 `content_repository.normalization` 负责)。
|
||||
- 软打分的公式实现(由 `scoring.score_content` 负责)。
|
||||
- 去重/频控/Feed MMR 具体算法实现(由 `rerank_freqcap.rerank_and_freqcap` 负责)。
|
||||
- 打点上报/落库(由调用方:API/Worker 负责;本模块只生成可观测 `meta`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 输入/输出与数据结构(V1)
|
||||
|
||||
### 3.1 编排器输入(对齐 spec,并补齐工程必需字段)
|
||||
|
||||
规范 `spec.md` 输入基础上,为满足 `ContentRepository` 的强约束,本模块额外引入 `locale`:
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`: `UserProfileV1_2`(允许字段缺失/跳过)
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- `k`: int(feed 默认 30;push/widget 默认 1)
|
||||
- `now`: 时间戳(`datetime`)
|
||||
- `locale`: `en | tc`(必填;不允许语言回退;若未传则由上层决定默认值)
|
||||
- (可选)`constraints`:
|
||||
- `exclude_content_ids`: `List[int]`(额外排除;会与 already/touched 合并)
|
||||
- `exclude_author_ids`: `List[str]`
|
||||
- `exclude_template_ids`: `List[str]`
|
||||
- `max_candidates_limit`: int(候选池上限;用于保护数据库与后续计算)
|
||||
- `recent_author_ids` / `recent_template_ids`(用于 Push/Widget 增强频控;不提供则由 `rerank_freqcap` 记录缺失并跳过该维度过滤)
|
||||
|
||||
> 说明:`ContentRepository.fetch_candidates` 已内置“缺失字段 → 至少 L1”的降级约束;但引擎仍需在回退循环中显式维护 `fallback_level`,以便可观测与一致性。
|
||||
|
||||
### 3.2 输出(对齐 spec)
|
||||
|
||||
- `items: List[RecommendedItem]`(长度 ≤ k)
|
||||
- `content_id: int`
|
||||
- `text: str`
|
||||
- `final_score: float`
|
||||
- `fallback_level_final: int`
|
||||
- `explanations: Optional[dict]`(可选;用于调参/排查;默认可关闭以节省载荷)
|
||||
- `meta: RecoMeta`
|
||||
- 统一结构来自 `observability.RecoMetaBuilder.build()`
|
||||
|
||||
### 3.3 推荐结果建议类型(tasks 阶段落地)
|
||||
|
||||
- `RecommendedItem`:pydantic model 或 dataclass(建议 pydantic,与现有 `RecoMeta` 风格一致)。
|
||||
- `RecoEngineResult`:`items + meta` 的容器类型(便于 API/Worker 复用)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体架构与代码组织(建议)
|
||||
|
||||
建议新增目录:`server/app/features/personalized_reco/reco_engine/`
|
||||
|
||||
- `orchestrator.py`
|
||||
- `async def recommend(...) -> RecoEngineResult`
|
||||
- `async def recommend_one(...)`(push/widget 便捷入口)
|
||||
- `hard_filter.py`
|
||||
- `def hard_filter(...) -> HardFilterResult`(返回 kept + 统计 + reasons)
|
||||
- `types.py`
|
||||
- `RecoConstraints`、`RecommendedItem`、`RecoEngineResult`、`HardFilterResult`
|
||||
- `defaults.py`
|
||||
- 场景默认参数(例如候选拉取上限、Feed 是否允许回退补齐等)
|
||||
- `utils.py`
|
||||
- 小工具:id 归一化、personalization_power clamp、解释字段构造等
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline 设计(Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Serve)
|
||||
|
||||
### 5.1 主流程伪代码(V1)
|
||||
|
||||
核心思想:**回退循环包裹整个 Pipeline**,每次回退都重新拉候选并重新跑一遍 pipeline;最终输出 `fallback_level_final` 与 `meta`。
|
||||
|
||||
```text
|
||||
meta_builder = RecoMetaBuilder(scene, user_profile, k, now)
|
||||
fallback_trace = []
|
||||
exclude_ids = union(already_recommended_ids, touched_or_viewed_ids, constraints.exclude_content_ids)
|
||||
|
||||
for level in [0, 1, 2, 3]:
|
||||
# 1) Candidate
|
||||
cands = await repo.fetch_candidates(scene, user_profile, fallback_level=level, limit=candidate_limit(level), locale, exclude_content_ids=exclude_ids)
|
||||
meta_builder.set_candidate_pool_size_raw(len(cands))
|
||||
|
||||
# 2) Hard Filter
|
||||
kept, risk_counts, hard_removed = hard_filter(scene, user_profile, cands, constraints)
|
||||
meta_builder.set_after_hard_filter(len(kept), risk_filtered_count_by_flag=risk_counts)
|
||||
|
||||
# 3) Soft Scoring
|
||||
scored = []
|
||||
for each content in kept:
|
||||
cfg = scoring_config(scene, level, user_profile)
|
||||
content2 = clamp_personalization_power_if_needed(content, level)
|
||||
s = score_content(scene, user_profile, content2, config=cfg, pass_filters=True, external_terms=optional)
|
||||
scored.append(ScoredCandidate.from(content2, final_score=s.final_score))
|
||||
|
||||
# 4) Rerank/Freqcap
|
||||
rer = rerank_and_freqcap(scene, scored, already_recommended_ids, touched_or_viewed_ids, k, recent_author_ids, recent_template_ids)
|
||||
meta_builder.set_after_dedup(rer.meta.candidate_pool_size_after_dedup)
|
||||
meta_builder.set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)
|
||||
|
||||
served = rer.ranked_items[:k]
|
||||
meta_builder.set_served_k(len(served))
|
||||
meta_builder.set_fallback_level_final(level, reason=trigger_reason_if_any)
|
||||
fallback_trace.append({level, raw, after_hard, after_dedup, after_freqcap, served_k})
|
||||
|
||||
if len(served) == k:
|
||||
break
|
||||
if scene == "feed" and allow_partial_feed and len(served) > 0 and not fill_with_fallback:
|
||||
break
|
||||
# else continue fallback to try fill
|
||||
|
||||
meta_builder.set_config_snapshot({"fallback_trace": fallback_trace, ...})
|
||||
return items=served_as_recommended_items, meta=meta_builder.build()
|
||||
```
|
||||
|
||||
### 5.2 候选拉取策略(与回退梯度一致)
|
||||
|
||||
依赖 `ContentRepository.fetch_candidates(...)`:
|
||||
|
||||
- `fallback_level=0`:正常配比(由 repository 内部实现候选策略;引擎只传 level)
|
||||
- `fallback_level>=1`:降个性化(repository 已约束 `personalization_power<=0.5`)
|
||||
- `fallback_level>=2`:回退通用池(repository 已约束 `general + personalization_power=0`)
|
||||
- `fallback_level>=3`:仅安全池(repository 已约束 `is_safe_pool=true`)
|
||||
|
||||
候选拉取上限:
|
||||
|
||||
- 建议 `limit = min(max_candidates_limit, k * multiplier)`,默认 `multiplier=10`(Feed)/`multiplier=30`(Push/Widget,因强过滤+频控更容易清空)。
|
||||
- `content_repository` 内部已有 `raw_limit = limit * 5` 的二次扩增,reco-engine 层的 `limit` 需以“软上限”思路控制资源。
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard Filter(硬过滤)设计
|
||||
|
||||
### 6.1 规则集合(V1 必做)
|
||||
|
||||
对每条候选 `Cᵢ`,若命中任一规则则过滤:
|
||||
|
||||
- **全场景必挡**:
|
||||
- `block_health_medical`(注意:旧 flag 归一化已在 repository 做;引擎只消费归一化后的 `risk_flags`)
|
||||
- **与用户阶段相关**:
|
||||
- 若 `U.stage.unknown=1`:过滤含 `unsafe_for_stage_unknown`
|
||||
- 若 `U.stage.parenting=1`:过滤含 `unsafe_for_stage_parenting`
|
||||
- **与用户情绪相关**:
|
||||
- 若 `U.emotion_score <= 0.2`:过滤含 `unsafe_for_emotion_low`
|
||||
- **跨维度产品规则(示例,来自算法规则文档)**:
|
||||
- 若 `U.stage.unknown=1` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1`:过滤
|
||||
|
||||
> 说明:Hard Filter 只做“剔除”,不做分数惩罚;软风险(例如 `soft_health_sensitive`)应由 `scoring` 的外部项 `P_risk` 或未来扩展处理(V1 可先不实现软风险)。
|
||||
|
||||
### 6.2 与 `UserProfileV1_2_Extended.hard_rules` 的兼容(增强项)
|
||||
|
||||
若调用方传入的 `user_profile` 带有 `hard_rules`(扩展画像),引擎应:
|
||||
|
||||
- 合并 `forbidden_risk_flags` 到本模块默认 forbidden 集合(并做去重)。
|
||||
- 执行 `forbidden_content_predicates`(以“用户条件 + 内容字段命中”方式过滤),并将命中 predicate 的 `id` 记录到 explanations(可选)或 `meta.config_snapshot`。
|
||||
|
||||
### 6.3 输出统计(用于 meta)
|
||||
|
||||
Hard Filter 必须输出:
|
||||
|
||||
- `kept_items`
|
||||
- `risk_filtered_count_by_flag: dict[str, int]`(按 flag 聚合计数,供 `RecoMetaBuilder.set_after_hard_filter(..., risk_filtered_count_by_flag=...)`)
|
||||
- (可选)`filtered_by_rule_ids: dict[str, int]`(跨维度规则命中计数,可放 `config_snapshot`)
|
||||
|
||||
---
|
||||
|
||||
## 7. Soft Scoring 编排策略(V1)
|
||||
|
||||
### 7.1 配置选择
|
||||
|
||||
默认使用 `scoring.get_default_config(scene)`,并按以下规则在引擎侧做“安全覆盖”:
|
||||
|
||||
- **Push**:强制 `enable_uncertainty_penalty=True`(与 spec 对齐)。
|
||||
- **任意场景**:当 `missing_fields` 明显或 `conf_U` 偏低时,可选择开启 `enable_uncertainty_penalty`(V1 可先只对 Push 强制,Feed/Widget 保持默认)。
|
||||
|
||||
### 7.2 回退层级对个性化强度的约束
|
||||
|
||||
尽管 repository 已在候选拉取阶段约束 personalization_power,但为保证“防御式一致性”,引擎应再做一次 clamp:
|
||||
|
||||
- `fallback_level>=1`:`personalization_power = min(personalization_power, 0.5)`
|
||||
- `fallback_level>=2`:`personalization_power = 0`
|
||||
- `fallback_level>=3`:`personalization_power = 0`
|
||||
|
||||
实现方式建议:
|
||||
|
||||
- 在引擎内对 `ContentProfileDTO` 做浅拷贝(或 `model_copy(update={...})`)后再传入 `score_content`。
|
||||
|
||||
### 7.3 explanations(可选)
|
||||
|
||||
为便于调参/排查,建议支持按开关输出 `explanations`:
|
||||
|
||||
- `hard_filter_hits`:命中的 flag / predicate
|
||||
- `score_breakdown`:来自 `ScoreResult.breakdown`(注意载荷大小,默认关闭)
|
||||
- `fallback_level_used`
|
||||
|
||||
---
|
||||
|
||||
## 8. Rerank/Freqcap 编排策略(V1)
|
||||
|
||||
依赖 `rerank_freqcap.rerank_and_freqcap(...)`:
|
||||
|
||||
- **去重**:使用 `already_recommended_ids ∪ touched_or_viewed_ids`(模块内部已归一化为 int set)
|
||||
- **Feed**:`dedup + MMR`(`mmr_lambda=0.7`,`top_n_for_mmr=200` 默认)
|
||||
- **Push/Widget**:`dedup + freqcap(句子/作者/模板) + TopK`
|
||||
- 句子冷却由 `already/touched` 直接提供即可生效
|
||||
- 作者/模板冷却需要 `recent_author_ids/recent_template_ids` 输入;若缺失,模块会记录 `missing_history_fields` 并跳过该维度过滤(但仍不会报错)
|
||||
|
||||
引擎侧需要把 `RerankResult.meta` 写入统一 `RecoMetaBuilder`:
|
||||
|
||||
- `set_after_dedup(rer.meta.candidate_pool_size_after_dedup)`
|
||||
- `set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)`
|
||||
|
||||
---
|
||||
|
||||
## 9. Fallback Ladder(回退梯度)实现细节
|
||||
|
||||
### 9.1 触发条件(对齐 spec)
|
||||
|
||||
任一满足即可进入下一层回退:
|
||||
|
||||
- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空
|
||||
- `served_k < k`
|
||||
- Feed:允许“部分不足”,但需记录;是否继续回退补齐由配置控制
|
||||
- Push/Widget:建议默认继续回退直到 `served_k==k` 或达到 L3
|
||||
|
||||
### 9.2 Feed 的“部分不足”策略(建议默认)
|
||||
|
||||
提供引擎配置项(`RecoEngineConfig`):
|
||||
|
||||
- `feed_allow_partial: bool = True`
|
||||
- `feed_fill_with_fallback: bool = True`
|
||||
|
||||
推荐默认:Feed 允许部分不足,但仍尝试回退补齐(更接近“稳定覆盖率”目标);若担心回退导致风格突变,可关闭补齐。
|
||||
|
||||
### 9.3 回退过程可观测(建议)
|
||||
|
||||
由于 `RecoMeta` 为单结构,建议把每次回退的过程写入 `meta.config_snapshot`:
|
||||
|
||||
- `fallback_trace: List[{"level": int, "raw": int, "after_hard": int, "after_dedup": int, "after_freqcap": int, "served_k": int}]`
|
||||
- `fallback_trigger_reason`:最后一次触发原因(也可放每层 reason)
|
||||
|
||||
---
|
||||
|
||||
## 10. 可观测 meta 构建与 empty_reason 口径
|
||||
|
||||
使用 `observability.RecoMetaBuilder` 统一生成 meta:
|
||||
|
||||
- 初始化:`RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k, now=now)`
|
||||
- 每阶段 set:
|
||||
- `set_candidate_pool_size_raw`
|
||||
- `set_after_hard_filter(..., risk_filtered_count_by_flag=...)`
|
||||
- `set_after_dedup`
|
||||
- `set_after_freqcap(..., freqcap_filtered_counts=...)`
|
||||
- `set_served_k`
|
||||
- `set_fallback_level_final(level, reason=...)`
|
||||
- `set_config_snapshot({"fallback_trace": ..., "engine_config": ...})`
|
||||
- 最终:`meta = builder.build()`
|
||||
|
||||
empty_reason:
|
||||
|
||||
- 由 `observability.compute_empty_reason(...)` 在 `build()` 内计算(无需引擎手动写入)
|
||||
- 关键在于引擎必须正确设置 `raw/after_hard/after_freqcap/served_k`,以便区分:
|
||||
- `pool_empty`:raw==0
|
||||
- `hard_filter_all`:raw>0 且 after_hard==0
|
||||
- `freqcap_all`:raw>0 且 after_freqcap==0(并且 after_hard>0)
|
||||
- `unknown`:其他异常情况
|
||||
|
||||
---
|
||||
|
||||
## 11. 稳定性与错误处理(V1)
|
||||
|
||||
### 11.1 防御式输入处理
|
||||
|
||||
- `k<=0`:直接返回空 items,meta.served_k=0,fallback_level_final=0。
|
||||
- `already_recommended_ids / touched_or_viewed_ids`:允许混合类型(str/int),统一按 int 解析(无效值忽略)。
|
||||
- `locale`:由 `content_repository.types.normalize_locale` 约束;若不支持,建议在上层拦截;引擎内部需捕获异常并返回空结果(避免 500)。
|
||||
|
||||
### 11.2 异常兜底
|
||||
|
||||
任何阶段发生异常:
|
||||
|
||||
- 不抛出到调用方(除非调用方明确要求),而是返回:
|
||||
- `items=[]`
|
||||
- `meta`:尽可能填充已知字段,`config_snapshot` 记录错误信息(例如 `{"error": "...", "stage": "fetch_candidates"}`)
|
||||
- 目的:保证 API/Worker 稳定,不因单条数据问题导致任务/请求失败。
|
||||
|
||||
---
|
||||
|
||||
## 12. 测试计划(对应验收标准)
|
||||
|
||||
### 12.1 单元测试覆盖
|
||||
|
||||
- **稳定性**:
|
||||
- 缺失字段组合(need/context/emotion 任意缺失)不报错
|
||||
- 历史集合为空/很大(包含非数字 id)不报错
|
||||
- **回退可观测**:
|
||||
- raw=0 → `empty_reason="pool_empty"`
|
||||
- raw>0 且 after_hard=0 → `empty_reason="hard_filter_all"`
|
||||
- raw>0 且 after_freqcap=0 且 after_hard>0 → `empty_reason="freqcap_all"`
|
||||
- fallback_trace 写入且 `fallback_level_final` 正确
|
||||
- **去重生效**:
|
||||
- 输出不包含 already/touched 中的 id(覆盖 feed/push/widget)
|
||||
- **风险优先**:
|
||||
- `block_health_medical` 必挡(全场景)
|
||||
- unknown stage + `unsafe_for_stage_unknown` 必挡
|
||||
- **跨调用复用**:
|
||||
- 同样输入(固定 now)重复调用结果稳定(允许 score 浮点微差)
|
||||
|
||||
### 12.2 集成测试建议(tasks 阶段可选)
|
||||
|
||||
- 在 `integration-api-worker` 完成后:
|
||||
- FastAPI 与 Celery 调用同一 `recommend(...)`,输出结构一致
|
||||
|
||||
195
spec_kit/Personalized Reco/modules/reco-engine/tasks.md
Normal file
195
spec_kit/Personalized Reco/modules/reco-engine/tasks.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Reco Engine(推荐引擎编排)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/reco-engine/plan.md`
|
||||
>
|
||||
> 执行规则:
|
||||
>
|
||||
> - 本任务清单**可执行、可验证**;每项完成后在“状态”处标记为 `已完成` 并补充必要的证据(测试用例/日志/截图/输出)。
|
||||
> - **禁止破坏性数据库操作**(如需必须先征得同意并回复“允许操作数据库”)。
|
||||
> - 本模块默认约定:
|
||||
> - `locale` 主要来自客户端 API 入参;若未传,默认 `en`
|
||||
> - Feed:`feed_allow_partial=true` 且 `feed_fill_with_fallback=true`(允许不足,但会尝试回退补齐)
|
||||
> - Hard Filter:**仅实现硬规则集合**(不实现 `UserProfileV1_2_Extended.hard_rules` 扩展)
|
||||
> - `explanations`:默认开启(但建议输出“轻量 explanations”,避免载荷过大)
|
||||
|
||||
---
|
||||
|
||||
## 0. 准备与对齐(不改代码)
|
||||
|
||||
- [x] **确认依赖模块接口未变更**(状态:已完成)
|
||||
- **检查点**:
|
||||
- `ContentRepository.fetch_candidates(...)` 入参含 `locale/fallback_level/exclude_content_ids`
|
||||
- `scoring.score_content(...)` 可用且 Push 默认启用 `P_uncertainty`
|
||||
- `rerank_freqcap.rerank_and_freqcap(...)` 可用且会在缺失 `recent_*` 时跳过该维度过滤
|
||||
- `observability.RecoMetaBuilder` 的字段口径与 `empty_reason` 规则不变
|
||||
- **证据**:
|
||||
- `server/app/features/personalized_reco/content_repository/interface.py`:`fetch_candidates(..., locale, fallback_level, exclude_content_ids)`
|
||||
- `server/app/features/personalized_reco/scoring/score.py`:`score_content(...)`
|
||||
- `server/app/features/personalized_reco/rerank_freqcap/rerank.py`:`rerank_and_freqcap(..., recent_author_ids=None, recent_template_ids=None)`
|
||||
- `server/app/features/personalized_reco/observability/builder.py`:`RecoMetaBuilder.build()` 与 `compute_empty_reason`
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码骨架与类型(新增 reco_engine 模块)
|
||||
|
||||
- [x] **创建目录与初始化文件**(状态:已完成)
|
||||
- **目标路径**:`server/app/features/personalized_reco/reco_engine/`
|
||||
- **文件**:
|
||||
- `__init__.py`
|
||||
- `types.py`
|
||||
- `defaults.py`
|
||||
- `utils.py`
|
||||
- `hard_filter.py`
|
||||
- `orchestrator.py`
|
||||
- **验收**:可被 `from app.features.personalized_reco.reco_engine import ...` 导入
|
||||
- **证据**:
|
||||
- 已新增:`server/app/features/personalized_reco/reco_engine/__init__.py`
|
||||
- 导出入口:`from app.features.personalized_reco.reco_engine import recommend`
|
||||
|
||||
- [x] **定义核心类型**(状态:已完成)
|
||||
- **`types.py` 建议包含**:
|
||||
- `RecoConstraints`(可选过滤:`exclude_content_ids/exclude_author_ids/exclude_template_ids/max_candidates_limit/recent_author_ids/recent_template_ids`)
|
||||
- `RecoEngineConfig`(Feed 补齐策略、候选倍率等)
|
||||
- `RecommendedItem`(`content_id/text/final_score/fallback_level_final/explanations`)
|
||||
- `RecoEngineResult`(`items/meta`)
|
||||
- `HardFilterResult`(`kept_items/risk_filtered_count_by_flag/removed_count/optional_hits`)
|
||||
- **验收**:类型可在单测中直接构造与序列化(若用 pydantic)
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/types.py`
|
||||
|
||||
- [x] **默认配置落地**(状态:已完成)
|
||||
- **`defaults.py` 建议**:
|
||||
- `get_default_engine_config(scene)` 或统一 `RecoEngineConfig()`
|
||||
- 候选倍率:Feed `10`,Push/Widget `30`(可配置)
|
||||
- Feed 策略默认:`allow_partial=true`、`fill_with_fallback=true`
|
||||
- **验收**:不传 config 时引擎可稳定运行
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/defaults.py`
|
||||
|
||||
- [x] **工具函数:ID 与 locale 的防御式处理**(状态:已完成)
|
||||
- **`utils.py` 建议**:
|
||||
- `normalize_int_id_list(mixed_ids) -> list[int]`:解析 `str|int`,无效值忽略
|
||||
- `merge_exclude_ids(already, touched, extra) -> list[int]`
|
||||
- `normalize_or_default_locale(locale) -> "en"|"tc"`:缺失默认 `en`;非法时抛出/返回错误由 orchestrator 捕获
|
||||
- **验收**:输入包含 `"1" / 1 / "abc" / None` 不报错
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/utils.py`
|
||||
|
||||
---
|
||||
|
||||
## 2. Hard Filter(硬过滤)实现
|
||||
|
||||
- [x] **实现硬规则集合**(状态:已完成)
|
||||
- **文件**:`hard_filter.py`
|
||||
- **必须实现规则**:
|
||||
- 全场景:`block_health_medical` 一律过滤
|
||||
- `U.stage.unknown=1`:过滤 `unsafe_for_stage_unknown`
|
||||
- `U.stage.parenting=1`:过滤 `unsafe_for_stage_parenting`
|
||||
- `U.emotion_score <= 0.2`:过滤 `unsafe_for_emotion_low`
|
||||
- 跨维度规则:`U.stage.unknown=1` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1` → 过滤
|
||||
- **输出统计**:
|
||||
- `risk_filtered_count_by_flag: dict[str,int]`(按命中的 risk_flag 计数;跨维度规则可用固定 key 如 `rule:unknown_stage_parenting_pressure_power1`)
|
||||
- **验收**:
|
||||
- 传入 3 条候选,命中规则的被剔除
|
||||
- `risk_filtered_count_by_flag` 的数值与剔除条数一致
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/hard_filter.py`
|
||||
|
||||
---
|
||||
|
||||
## 3. Orchestrator(编排器)实现
|
||||
|
||||
- [x] **实现 `recommend(...)` 主入口**(状态:已完成)
|
||||
- **文件**:`orchestrator.py`
|
||||
- **函数形态建议**:
|
||||
- `async def recommend(*, repo: ContentRepository, scene, user_profile, already_recommended_ids, touched_or_viewed_ids, k, now, locale=None, constraints=None, config=None) -> RecoEngineResult`
|
||||
- **关键编排步骤**(每次 fallback level 都要跑一遍):
|
||||
- Candidate:`repo.fetch_candidates(...)`
|
||||
- Hard Filter:`hard_filter(...)`
|
||||
- Soft Scoring:`score_content(...)`
|
||||
- Rerank/Freqcap:`rerank_and_freqcap(...)`
|
||||
- Serve:截断到 `k` 并构造 `RecommendedItem`
|
||||
- Meta:用 `RecoMetaBuilder` 逐阶段填充并 `build()`
|
||||
- **验收**:
|
||||
- 任意 `k`(含 0)不报错
|
||||
- 输出结构稳定:`items` 与 `meta` 永远存在
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/orchestrator.py`
|
||||
|
||||
- [x] **Fallback Ladder 回退循环**(状态:已完成)
|
||||
- **行为**:
|
||||
- 依次尝试 `fallback_level in [0,1,2,3]`
|
||||
- 每层更新 `meta_builder.set_fallback_level_final(level, reason=...)`
|
||||
- 每层记录 `fallback_trace` 并写入 `meta.config_snapshot`
|
||||
- **Feed 策略**(默认):
|
||||
- `served_k < k` 时继续回退补齐,直到 `k` 或 L3
|
||||
- 若最终仍不足,允许返回不足,但 `served_k`/`fallback_level_final` 必须正确
|
||||
- **验收**:
|
||||
- 构造一个“强过滤 + 频控后为空”的场景能触发逐级回退
|
||||
- **证据**:`meta.config_snapshot.fallback_trace` 会记录每层的 raw/after_hard/after_dedup/after_freqcap/served_total
|
||||
|
||||
- [x] **explanations 默认开启但保持轻量**(状态:已完成)
|
||||
- **建议默认包含**:
|
||||
- `fallback_level_used`
|
||||
- `hard_filter_hits`(命中的 risk_flags/规则 id)
|
||||
- `score_summary`(可选:只保留少量关键字段,如 `S_core/S_personal/P_uncertainty/P_risk`,不输出全量 breakdown)
|
||||
- **验收**:
|
||||
- 返回载荷可控(Feed 30 条不会过大)
|
||||
- **证据**:explanations 仅包含 `fallback_level_used/hard_filter_hits/score_summary`
|
||||
|
||||
- [x] **异常兜底与 meta 记录**(状态:已完成)
|
||||
- **要求**:
|
||||
- 捕获 `normalize_locale` 抛错、repo 查询异常、单条内容打分异常等
|
||||
- 返回 `items=[]`,并在 `meta.config_snapshot` 写入 `{"error": "...", "stage": "..."}`(避免 500)
|
||||
- **验收**:
|
||||
- 传入不支持的 locale(如 `jp`)时不会导致接口崩溃
|
||||
- **证据**:`normalize_locale` 失败时返回空 items,且 `meta.config_snapshot.stage="normalize_locale"`
|
||||
|
||||
---
|
||||
|
||||
## 4. 与现有模块的对齐与集成
|
||||
|
||||
- [x] **对齐 `rerank_freqcap` 的作者/模板冷却输入含义**(状态:已完成)
|
||||
- **含义说明**:
|
||||
- `recent_author_ids/recent_template_ids` 表示“冷却窗口内已触达的作者/模板集合”
|
||||
- 本模块不负责计算窗口裁剪;调用方需按 `cooldown_*_days` 裁剪后再传
|
||||
- **默认策略**:
|
||||
- 若调用方不提供,则传 `None`,由 `rerank_freqcap` 记录缺失并跳过该维度过滤(句子级去重仍有效)
|
||||
- **验收**:
|
||||
- 不提供 `recent_*` 时不报错,且 meta 中 `freqcap_filtered_counts` 仍有 sentence 维度计数
|
||||
- **证据**:引擎透传 `recent_author_ids/recent_template_ids`(默认为 None);`rerank_freqcap` 自身会记录缺失维度
|
||||
|
||||
- [x] **对齐 `RecoMetaBuilder` 阶段字段写入点**(状态:已完成)
|
||||
- **必须写入**:
|
||||
- raw / after_hard_filter / after_dedup / after_freqcap / served_k / fallback_level_final
|
||||
- `risk_filtered_count_by_flag`、`freqcap_filtered_counts`
|
||||
- **验收**:
|
||||
- `empty_reason` 可区分 `pool_empty / hard_filter_all / freqcap_all`
|
||||
- **证据**:单测覆盖 `pool_empty / hard_filter_all / freqcap_all`
|
||||
|
||||
---
|
||||
|
||||
## 5. 单元测试(必做)
|
||||
|
||||
- [x] **新增 `server/tests/test_reco_engine.py`**(状态:已完成)
|
||||
- **测试用例建议**:
|
||||
- `k=0` 返回空 items,meta.served_k=0
|
||||
- raw=0 → empty_reason=`pool_empty`
|
||||
- raw>0 且 after_hard=0 → empty_reason=`hard_filter_all`
|
||||
- raw>0 且 after_freqcap=0 且 after_hard>0 → empty_reason=`freqcap_all`
|
||||
- 去重生效:输出不包含 already/touched ids
|
||||
- `block_health_medical` 必挡
|
||||
- Push 缺失画像字段时仍稳定(repository 会至少 L1;引擎 meta 与 fallback_trace 正确)
|
||||
- **验收**:`pytest` 全绿(只跑相关 tests 也可)
|
||||
- **证据**:
|
||||
- 新增文件:`server/tests/test_reco_engine.py`
|
||||
- 在本机 venv 下执行:`server/.venv/bin/python -m pytest -q` → `19 passed`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档与总览标记(仅在全部任务完成后做)
|
||||
|
||||
- [x] **更新本子模块执行状态**(状态:已完成)
|
||||
- **文件**:`spec_kit/Personalized Reco/modules/reco-engine/tasks.md`
|
||||
- **要求**:本文件所有任务项标记为 `已完成`,并补齐证据
|
||||
- **证据**:本文件已全部打勾并补充证据
|
||||
|
||||
- [ ] **更新大需求总览 `overview.md`**(状态:未开始)
|
||||
- **文件**:`spec_kit/Personalized Reco/overview.md`
|
||||
- **要求**:当 `reco-engine` 全部任务完成后,将第 6 项 “已实施/已完成” 并补充变更记录(日期 + 简述)
|
||||
|
||||
264
spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md
Normal file
264
spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Rerank & Freqcap(重排 / 去重 / 频控)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md`
|
||||
>
|
||||
> 规则来源(必须对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(Feed MMR λ=0.7;Push/Widget 冷却口径)
|
||||
> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k)。
|
||||
- 实现 V1 最小集合:
|
||||
- **去重**:排除 `already_recommended_ids ∪ touched_or_viewed_ids`
|
||||
- **Feed 序列多样性**:MMR 重排(离散特征版)
|
||||
- **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强
|
||||
- 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因(dedup/freqcap 导致清空等)。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/rerank_freqcap/`
|
||||
- 包含:
|
||||
- 纯函数 `rerank_and_freqcap(...) -> RerankResult`
|
||||
- `RerankConfig` 与默认参数(按 scene)
|
||||
- `Sim/Tag` 构造工具函数(Feed MMR)
|
||||
- 单元测试(tasks 阶段落地):
|
||||
- 去重正确性
|
||||
- Feed MMR 的 Top1 + 多样性选择
|
||||
- Push/Widget 冷却规则(在给定历史集合输入下)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- **从 scored_candidates 中做过滤/重排**:
|
||||
- Dedup:按历史集合过滤
|
||||
- Freqcap:按冷却维度(句子/作者/模板)做“硬过滤或强约束”
|
||||
- Feed:MMR 生成序列(保证多样性)
|
||||
- 输出 `ranked_items` 与 `meta`(候选规模、过滤数量、缺失输入统计等)。
|
||||
|
||||
### 2.2 不在本模块实现
|
||||
|
||||
- **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score)。
|
||||
- **不做 Hard Filter**:Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。
|
||||
- **不维护服务端长期历史**(V1):冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。
|
||||
|
||||
> 说明(V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。
|
||||
|
||||
---
|
||||
|
||||
## 3. 输入/输出与数据结构(V1)
|
||||
|
||||
### 3.1 输入
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `scored_candidates`: `List[ScoredCandidate]`,至少包含:
|
||||
- `content_id: int`
|
||||
- `final_score: float`(或 `score`)
|
||||
- `author_id: str | None`
|
||||
- `template_id: str | None`
|
||||
- `content_profile`(用于 feed 标签:stage/need/context 等;缺失时可退化)
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- 可选历史(若客户端暂不传,V1 作为增强项):
|
||||
- `recent_author_ids: List[str] | None`
|
||||
- `recent_template_ids: List[str] | None`
|
||||
- `k`: 目标条数(feed 默认 30;push/widget 默认 1)
|
||||
- `config`:
|
||||
- `mmr_lambda`(Feed 默认 0.7)
|
||||
- `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认)
|
||||
|
||||
### 3.2 输出
|
||||
|
||||
- `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k)
|
||||
- `meta`(V1 必须字段):
|
||||
- `candidate_pool_size_after_dedup: int`
|
||||
- `candidate_pool_size_after_freqcap: int`
|
||||
- `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议)
|
||||
- `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供)
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键技术决策(V1)
|
||||
|
||||
### 4.1 ID 归一化(避免 str/int 混用导致漏过滤)
|
||||
|
||||
由于输入历史集合可能是 `str|int`,V1 统一做:
|
||||
|
||||
- 尽量将 `content_id` 归一化为 `int`
|
||||
- 无法转换的值忽略并记录 debug(不影响主流程)
|
||||
|
||||
### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板
|
||||
|
||||
V1 选择“安全且可落地”的策略:
|
||||
|
||||
- **句子冷却(必做,硬过滤)**:
|
||||
- 若 `content_id` 出现在历史集合中,则直接过滤
|
||||
- **作者/模板冷却(增强项)**:
|
||||
- 若 `recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤
|
||||
- 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测
|
||||
|
||||
> 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。
|
||||
|
||||
### 4.3 Feed 的多样性:MMR(离散特征版)
|
||||
|
||||
V1 实现 MMR 的离散相似度(不依赖 embedding):
|
||||
|
||||
\[
|
||||
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
|
||||
\]
|
||||
|
||||
- `Rel(c)`:使用 `final_score`
|
||||
- `Sim(c,s)`:
|
||||
- `content_id` 相同:`Sim=1`
|
||||
- `template_id` 相同且非空:`Sim += 0.6`
|
||||
- `author_id` 相同且非空:`Sim += 0.3`
|
||||
- 标签重合(Jaccard):`Sim += 0.1 * Jaccard(tags_c, tags_s)`
|
||||
- clamp 到 `[0,1]`
|
||||
|
||||
标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒):
|
||||
|
||||
- `stage:<stage>`(例如 `stage:general/expecting/parenting/unknown`)
|
||||
- `need:<key>`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
|
||||
- `context:<key>`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
|
||||
|
||||
> 说明:内容画像是 suitability(0/0.5/1)结构;V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。
|
||||
|
||||
---
|
||||
|
||||
## 5. 具体算法流程(V1)
|
||||
|
||||
### 5.1 Dedup(必做,三场景共用)
|
||||
|
||||
输入:
|
||||
|
||||
- `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids`
|
||||
|
||||
处理:
|
||||
|
||||
- 过滤 `content_id ∈ seen_ids` 的候选
|
||||
|
||||
输出:
|
||||
|
||||
- `candidate_pool_size_after_dedup = len(filtered_candidates)`
|
||||
|
||||
### 5.2 Freqcap(Push/Widget 必做;Feed 可选)
|
||||
|
||||
V1 频控实现顺序(先句子,再作者/模板):
|
||||
|
||||
1. 句子冷却:过滤 `content_id ∈ seen_ids`
|
||||
2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids`
|
||||
3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids`
|
||||
|
||||
输出:
|
||||
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(按维度统计被过滤数量)
|
||||
|
||||
### 5.3 Feed:MMR 序列重排(建议实现)
|
||||
|
||||
步骤:
|
||||
|
||||
- Top1:直接取 `final_score` 最高者
|
||||
- 对后续位置 t=2..k:
|
||||
- 对每个未选候选 c 计算 `MMR(c)`
|
||||
- 选择 `MMR` 最大者加入序列
|
||||
|
||||
性能与实现约束(V1):
|
||||
|
||||
- 候选数 N(例如 200~500)时,朴素 \(O(kN^2)\) 仍可能偏大;V1 可采用:
|
||||
- 先截断到 `top_n_for_mmr`(例如 200)再做 MMR
|
||||
- 或缓存 `Sim(c,s)` 的最大值并增量更新(实现复杂度更高,V1 可不做)
|
||||
|
||||
### 5.4 Push/Widget:选 TopK
|
||||
|
||||
在 dedup+freqcap 后:
|
||||
|
||||
- 按 `final_score` 降序取前 k 条作为 `ranked_items`
|
||||
|
||||
---
|
||||
|
||||
## 6. 默认参数(V1 建议)
|
||||
|
||||
### 6.1 Feed
|
||||
|
||||
- `mmr_lambda = 0.7`
|
||||
- `top_n_for_mmr = 200`(避免候选过大导致重排过慢)
|
||||
|
||||
### 6.2 Push(冷却窗口口径来自算法规则的工程默认)
|
||||
|
||||
- `cooldown_sentence_days = 14`(同句 14 天不重复)
|
||||
- `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行)
|
||||
- `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行)
|
||||
|
||||
### 6.3 Widget
|
||||
|
||||
- `cooldown_sentence_days = 7`
|
||||
- `cooldown_author_days = 7`
|
||||
- `cooldown_template_days = 7`
|
||||
|
||||
> 说明:V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可观测与 meta(V1)
|
||||
|
||||
本模块建议输出(供 `observability` 子模块汇总):
|
||||
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(sentence/author/template)
|
||||
- `missing_history_fields`:
|
||||
- 例如客户端未提供 `recent_author_ids` → 记录 `author`
|
||||
- 未提供 `recent_template_ids` → 记录 `template`
|
||||
|
||||
> 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试计划(V1)
|
||||
|
||||
### 8.1 单元测试(纯函数)
|
||||
|
||||
- Dedup:
|
||||
- 输入历史包含某些 `content_id`,输出必须不包含这些 id
|
||||
- `str/int` 混用能正确归一化
|
||||
- Freqcap:
|
||||
- 仅提供 `content_id` 历史时:句子冷却生效
|
||||
- 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确
|
||||
- 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确
|
||||
- Feed MMR:
|
||||
- Top1 恒等于最高分
|
||||
- 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言)
|
||||
- `tags` 缺失时仍能稳定运行(只使用可得字段)
|
||||
|
||||
### 8.2 最小集成验证(与 reco-engine 串联时)
|
||||
|
||||
- 输入一批 scored_candidates + 历史集合:
|
||||
- Feed:输出长度 ≤ k,且 meta 规模统计正确
|
||||
- Push/Widget:在历史命中时能过滤掉重复句子
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与后续演进
|
||||
|
||||
### 9.1 已知风险
|
||||
|
||||
- V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。
|
||||
- Feed MMR 的性能:候选过大时重排可能变慢;V1 用 `top_n_for_mmr` 截断兜底。
|
||||
|
||||
### 9.2 V1.1+ 演进方向
|
||||
|
||||
- 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。
|
||||
- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。
|
||||
- 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`,实现更平滑的序列控制(而非一刀切过滤)。
|
||||
|
||||
172
spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md
Normal file
172
spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Rerank & Freqcap(重排 / 去重 / 频控)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md`
|
||||
>
|
||||
> 本清单执行原则:
|
||||
>
|
||||
> - 本模块只做 **Dedup / Freqcap / Feed MMR 重排**,不做 Soft Scoring 与 Hard Filter。
|
||||
> - V1 冷却窗口以“输入集合代表窗口内历史”为准(后续接入服务端历史再按天计算)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 校对 `modules/rerank-freqcap/spec.md` 与 `modules/rerank-freqcap/plan.md` 一致性
|
||||
- **检查点**:
|
||||
- 输入:`scene/scored_candidates/history/k/config` 字段与命名一致
|
||||
- 输出:`ranked_items` 与 `meta` 字段集合一致
|
||||
- 去重键:`sentence_key=content_id`、`author_key`、`template_key` 口径一致
|
||||
- Feed:MMR λ=0.7 与 Sim 规则一致
|
||||
- **验收**:两份文档不存在冲突描述,且“V1 冷却窗口语义”写清楚(集合代表窗口内历史)。
|
||||
|
||||
- [x] 1.2 在 `plan.md` 中补充/固定“标签构造策略”与“候选截断策略”(若后续要改再更新)
|
||||
- **变更点**:
|
||||
- 明确 `tags` 的 V1 定义:`stage + need_argmax + context_argmax`
|
||||
- 明确 `top_n_for_mmr` 默认值与作用(性能兜底)
|
||||
- **验收**:实现时不会出现“标签到底取哪些 key”的二义性。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/rerank_freqcap/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(`ScoredCandidate`、`RerankConfig`、`RerankMeta`、`RerankResult`)
|
||||
- `defaults.py`(按 scene 的默认参数:λ、cooldown_*、top_n_for_mmr)
|
||||
- `utils.py`(ID 归一化、Jaccard、tag 构造等)
|
||||
- `rerank.py`(主入口 `rerank_and_freqcap`)
|
||||
- **验收**:可通过 `app.features.personalized_reco.rerank_freqcap.*` 正常 import。
|
||||
|
||||
---
|
||||
|
||||
## 3. 类型与接口(稳定契约,便于 reco-engine 调用)
|
||||
|
||||
- [x] 3.1 定义 `ScoredCandidate`(最小字段集合)
|
||||
- **必须字段**:
|
||||
- `content_id: int`
|
||||
- `final_score: float`
|
||||
- **建议字段**(用于多样性/频控):
|
||||
- `author_id: str | None`
|
||||
- `template_id: str | None`
|
||||
- `content_profile`(至少能取到 stage/need_suitability/context_suitability;缺失时需降级)
|
||||
- **验收**:能承载 MMR 相似度计算所需数据;缺失字段不会导致异常。
|
||||
|
||||
- [x] 3.2 定义 `RerankConfig`(可调参)
|
||||
- **字段**:
|
||||
- Feed:`mmr_lambda`(默认 0.7)、`top_n_for_mmr`(默认 200)
|
||||
- Push/Widget:`cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(用于配置与可观测)
|
||||
- **验收**:能从 scene 推导默认 config(或由调用方传入覆盖)。
|
||||
|
||||
- [x] 3.3 定义 `RerankMeta` 与 `RerankResult`
|
||||
- **meta 必须字段**:
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `missing_history_fields`
|
||||
- **建议字段**:
|
||||
- `freqcap_filtered_counts`(sentence/author/template)
|
||||
- **验收**:字段集合固定;任何输入都能产出 meta(包括候选为空)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心算法实现(V1 最小集合)
|
||||
|
||||
- [x] 4.1 实现历史 ID 归一化(避免 str/int 混用漏过滤)
|
||||
- **规则**:
|
||||
- `already_recommended_ids` / `touched_or_viewed_ids` 尽量转为 `int` 集合
|
||||
- 转换失败的值忽略并记录 debug
|
||||
- **验收**:单测覆盖 `["1", 2, "bad"]` 等混合输入,过滤结果正确且稳定。
|
||||
|
||||
- [x] 4.2 实现 Dedup(必做)
|
||||
- **规则**:过滤 `content_id ∈ seen_ids` 的候选
|
||||
- **产出**:`meta.candidate_pool_size_after_dedup`
|
||||
- **验收**:输出不包含历史出现过的 `content_id`。
|
||||
|
||||
- [x] 4.3 实现 Freqcap(Push/Widget 必做;Feed 可选)
|
||||
- **V1 策略**:
|
||||
- 句子维度(必做):同句硬过滤(使用 dedup 的 seen_ids 即可)
|
||||
- 作者/模板维度(增强项):
|
||||
- 若提供 `recent_author_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `author`
|
||||
- 若提供 `recent_template_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `template`
|
||||
- **产出**:
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(建议)
|
||||
- **验收**:在有/无 `recent_*_ids` 输入时行为一致且可解释。
|
||||
|
||||
- [x] 4.4 实现 Feed:tag 构造与相似度 `Sim`
|
||||
- **tag 规则(V1 写死)**:
|
||||
- `stage:<stage>`
|
||||
- `need:<argmax_key>`(从 `need_suitability` 取最大值 key;为空则跳过)
|
||||
- `context:<argmax_key>`(从 `context_suitability` 取最大值 key;为空则跳过)
|
||||
- **Jaccard**:`|A∩B|/|A∪B|`,空集合时返回 0
|
||||
- **Sim 累加规则**:
|
||||
- 同 content_id → 1
|
||||
- template_id 相同且非空 → +0.6
|
||||
- author_id 相同且非空 → +0.3
|
||||
- +0.1 * Jaccard(tags)
|
||||
- clamp 到 `[0,1]`
|
||||
- **验收**:单测覆盖缺失字段(无 author/template/tags)时仍能算出稳定 Sim。
|
||||
|
||||
- [x] 4.5 实现 Feed:MMR 选序列
|
||||
- **规则**:
|
||||
- Top1:按 `final_score` 最大
|
||||
- 后续:按 `MMR(c)=λ*Rel(c)-(1-λ)*maxSim` 选择
|
||||
- `Rel=final_score`
|
||||
- **性能兜底**:先截断候选到 `top_n_for_mmr` 再做 MMR
|
||||
- **验收**:
|
||||
- Top1 恒等于最高分
|
||||
- 候选足够时,序列不出现大量同作者/同模板紧邻重复(可用阈值断言)
|
||||
|
||||
- [x] 4.6 实现 Push/Widget:最终 TopK
|
||||
- **规则**:dedup+freqcap 后按 `final_score` 降序取前 k 条
|
||||
- **验收**:输出长度 ≤ k,且分数单调不增(允许相等)。
|
||||
|
||||
- [x] 4.7 实现主入口 `rerank_and_freqcap(...) -> RerankResult`
|
||||
- **规则**:
|
||||
- 三场景共用 dedup
|
||||
- Feed:MMR;Push/Widget:TopK
|
||||
- 必须输出 meta(即使 ranked_items 为空)
|
||||
- **验收**:任何输入(含空候选)不抛异常,并输出稳定结构。
|
||||
|
||||
---
|
||||
|
||||
## 5. 单元测试(pytest,纯函数为主)
|
||||
|
||||
- [x] 5.1 新建测试文件 `server/tests/test_rerank_freqcap.py`
|
||||
- **用例覆盖**:
|
||||
- dedup:历史集合过滤正确(含 str/int 混用)
|
||||
- freqcap:有/无 recent_author/template 的分支与 meta 缺失标记
|
||||
- feed mmr:Top1=最高分;后续避免同作者/模板紧邻(构造数据断言)
|
||||
- push/widget:TopK 输出正确
|
||||
- **验收**:`pytest -q tests/test_rerank_freqcap.py` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 6. 最终自检清单(合入前)
|
||||
|
||||
- [x] 6.1 文档一致性检查
|
||||
- **验收**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其 meta 字段与默认参数)。
|
||||
|
||||
- [x] 6.2 回归检查(不影响已实施模块)
|
||||
- **验收**:不修改 `content-repository` 与 `scoring` 的既有逻辑;仅新增 `rerank-freqcap` 模块与测试。
|
||||
|
||||
- [x] 6.3 全量测试通过
|
||||
- **命令**(在 `server/`):
|
||||
- `pytest -q`
|
||||
- **验收**:所有用例通过。
|
||||
|
||||
- [x] 6.4 全部完成后更新大规范 `overview.md`
|
||||
- **变更点**:
|
||||
- 将 `modules/rerank-freqcap/` 标记为“已实施”
|
||||
- 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试)
|
||||
- **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。
|
||||
|
||||
271
spec_kit/Personalized Reco/modules/scoring/plan.md
Normal file
271
spec_kit/Personalized Reco/modules/scoring/plan.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Scoring(软打分与惩罚项)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/scoring/spec.md`
|
||||
>
|
||||
> 规则来源(必须严格对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(Soft Scoring 公式、V1.2 缺失字段口径、场景默认权重)
|
||||
> - `设计说明文档/句子文案打分規則.md`(risk_flags 语义与默认值口径)
|
||||
>
|
||||
> 模块边界参考:`spec_kit/Personalized Reco/overview.md`(由 `reco-engine` 串起候选→过滤→打分→重排)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 实现三种场景统一的软打分函数 `final_score(U, Cᵢ)`,并输出可观测的 `breakdown` 以便调参与回归测试。
|
||||
- 严格实现 V1.2 缺失字段的保守策略(`S_need/S_context/S_emotion` 的默认值)。
|
||||
- 实现 Push 默认启用的不确定性惩罚 `P_uncertainty`。
|
||||
- 实现 Widget 情绪区间(0.4~0.8)的**软降权**(不硬过滤,但能明显压低分数)。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/scoring/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/scoring/`
|
||||
- 包含:
|
||||
- 纯函数 `score_content(...) -> ScoreResult`
|
||||
- `ScoreConfig`(场景默认参数 + 可覆盖)
|
||||
- `ScoreBreakdown`(稳定字段集合,用于可观测)
|
||||
- 单元测试(tasks 阶段落地):
|
||||
- 缺失字段一致性
|
||||
- Push 不确定性惩罚生效
|
||||
- Widget 情绪软降权生效(区间外明显更低)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- 计算并返回:
|
||||
- `S_need/S_context/S_stage/S_emotion`
|
||||
- `S_core`
|
||||
- `S_personal`
|
||||
- `P_uncertainty`(按开关控制,Push 默认启用)
|
||||
- `P_widget_emotion_out_of_range`(Widget 专用软降权项,归入 `P_risk` 或单独字段均可;V1 建议单独字段,便于打点)
|
||||
- 生成 `breakdown`,用于可观测与调参。
|
||||
|
||||
### 2.2 不在本模块实现(但接口预留/可注入)
|
||||
|
||||
为避免与 `rerank-freqcap` / `reco-engine` 的职责重叠,V1 约定以下项**由外部模块产出**并作为输入注入(若不提供,默认按 0 处理):
|
||||
|
||||
- `S_fresh`:新鲜度/时间衰减相关(可由引擎或重排阶段计算)
|
||||
- `P_fatigue`:疲劳惩罚(基于历史触达/浏览/频控)
|
||||
- `P_repeat`:重复惩罚(同句/同作者/同模板等)
|
||||
- `P_risk`:软风险惩罚(例如 `soft_health_sensitive` 等)
|
||||
|
||||
> 说明:硬过滤(Hard Filter)由引擎编排阶段执行,本模块只消费 `pass`(是否通过硬过滤)并在总分中乘上 \(\mathbb{I}[pass]\)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 输入/输出与数据结构(V1)
|
||||
|
||||
### 3.1 输入(对齐 spec)
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`(U,允许字段缺失/跳过)
|
||||
- `content_profile`(Cᵢ)
|
||||
- `now`:预留,用于 freshness/时间衰减(V1 可不实现具体公式)
|
||||
- `config`:
|
||||
- `w_need/w_emotion/w_stage/w_context`
|
||||
- `alpha`:个性化加成系数
|
||||
- `beta`:不确定性惩罚系数
|
||||
- `enable_uncertainty_penalty`:是否启用 `P_uncertainty`(Push 默认 true)
|
||||
- `widget_emotion_soft_range`:Widget 情绪软区间(默认 `[0.4, 0.8]`)
|
||||
- `widget_emotion_penalty_gamma`:Widget 情绪软降权强度(V1 取“适中”默认 0.25)
|
||||
- `pass`: `boolean`(来自 Hard Filter 结果;默认 true)
|
||||
- `external_terms`(可选,来自其他模块注入):
|
||||
- `S_fresh`、`P_fatigue`、`P_repeat`、`P_risk`
|
||||
- 若未提供则按 0 处理
|
||||
|
||||
### 3.2 输出
|
||||
|
||||
- `final_score: float`
|
||||
- `breakdown`(建议始终返回,便于可观测与断言):
|
||||
- `S_need/S_context/S_stage/S_emotion`
|
||||
- `S_core/S_personal/S_fresh`
|
||||
- `P_fatigue/P_repeat/P_risk/P_uncertainty`
|
||||
- `P_widget_emotion_out_of_range`(可选但建议保留)
|
||||
- `missing_fields: string[]`(`need/context/emotion`)
|
||||
- `pass: boolean`
|
||||
- `scene: feed|push|widget`
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心公式与实现细则(必须对齐)
|
||||
|
||||
### 4.1 总分结构(线性加权 + 惩罚)
|
||||
|
||||
\[
|
||||
final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big)
|
||||
\]
|
||||
|
||||
\[
|
||||
S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context}
|
||||
\]
|
||||
|
||||
> V1 约定:`S_fresh/P_fatigue/P_repeat/P_risk` 允许外部注入;若缺失则当作 0,以确保函数可用且输出结构稳定。
|
||||
|
||||
### 4.2 分解项:S_need / S_context(V1.2 缺失字段兜底)
|
||||
|
||||
- `S_need`:
|
||||
- 若 `U.need` 缺失(为空对象 `{}` 或不存在)→ `S_need = 0.5`
|
||||
- 否则 → `S_need = Cᵢ.need_suitability[U.need_key]`
|
||||
- `S_context`:
|
||||
- 若 `U.context` 缺失(为空对象 `{}` 或不存在)→ `S_context = 0.5`
|
||||
- 否则 → `S_context = Cᵢ.context_suitability[U.context_key]`
|
||||
|
||||
> 工程约定:`U.need/U.context` 在客户端为稀疏 one-hot(最多一个 key=1)。实现时需提供一个“取唯一 key”的工具函数:若出现多个 key=1,按第一个(字典序或插入序)取值并记录告警(V1 可先 debug 日志)。
|
||||
|
||||
### 4.3 分解项:S_emotion(general=0.8;否则 1-|u-c|)
|
||||
|
||||
- 若 `U.emotion_score` 缺失 → `S_emotion = 0.8`
|
||||
- 否则:
|
||||
- 若 `Cᵢ.emotion_score` 为 general(`None`)→ `S_emotion = 0.8`
|
||||
- 否则 → \(S_{emotion}=1-|U.emotion\_score - C_i.emotion\_score|\)
|
||||
|
||||
实现约束:
|
||||
|
||||
- 将 `S_emotion` clamp 到 `[0,1]`,避免异常值导致负分或溢出。
|
||||
|
||||
### 4.4 分解项:S_stage(对齐算法规则)
|
||||
|
||||
规则(按 `设计说明文档/個性化推薦算法規則.md`):
|
||||
|
||||
- 若 `Cᵢ.stage == "general"` → `S_stage = 1`
|
||||
- 若 `Cᵢ.stage` 命中用户阶段(例如用户 `expecting=1` 且内容 `stage="expecting"`)→ `S_stage = 1`
|
||||
- 若用户阶段为 `unknown` 且内容阶段为非 unknown → `S_stage = 0.7`
|
||||
- 其余 → `S_stage = 0`
|
||||
|
||||
### 4.5 个性化加成 S_personal(含降个性化约束)
|
||||
|
||||
\[
|
||||
S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context})
|
||||
\]
|
||||
|
||||
降个性化约束(对齐 spec 与回退梯度):
|
||||
|
||||
- 若 `fallback_level>=1` 或字段缺失明显/低置信度:
|
||||
- L1:限制 `personalization_power ≤ 0.5`
|
||||
- L2/L3:限制 `personalization_power = 0`
|
||||
|
||||
> 工程实现:本模块只做“限制后的 effective_personalization_power”,由调用方传入 `fallback_level`(或直接传入已限制后的 `content_profile.personalization_power`)。V1 建议:在 `content-repository`/引擎侧先做候选池约束,本模块再做一次保护性 clamp(防御式编程)。
|
||||
|
||||
### 4.6 不确定性惩罚 P_uncertainty(Push 默认启用)
|
||||
|
||||
\[
|
||||
P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot C_i.personalization\_power
|
||||
\]
|
||||
|
||||
其中:
|
||||
|
||||
- `conf_U = user_profile.profile_confidence`
|
||||
- `conf_{C_i} = content_profile.review_confidence`(缺省 0.7)
|
||||
|
||||
开关策略(V1):
|
||||
|
||||
- `scene=push`:默认启用
|
||||
- `scene=feed/widget`:默认关闭(可通过 config 打开)
|
||||
|
||||
### 4.7 Widget 情绪区间软降权(适中默认实现)
|
||||
|
||||
目标:当 `scene=widget` 且 `Cᵢ.emotion_score` 可计算(非 general)时,若超出 `[0.4, 0.8]` 不硬过滤,但应产生明显降权。
|
||||
|
||||
V1 选择一个“适中、可调参、可解释”的惩罚函数:
|
||||
|
||||
- 设区间为 `[lo, hi]`(默认 `0.4, 0.8`)
|
||||
- 距离:
|
||||
- 若 `e < lo`,`d = lo - e`
|
||||
- 若 `e > hi`,`d = e - hi`
|
||||
- 否则 `d = 0`
|
||||
- 惩罚:
|
||||
- \(P_{widget} = \gamma \cdot \frac{d}{(hi-lo)}\)
|
||||
- 默认 `γ = 0.25`(适中强度)
|
||||
- clamp 到 `[0, γ]`
|
||||
|
||||
落地方式:
|
||||
|
||||
- 将 `P_widget_emotion_out_of_range` 单独输出到 breakdown;
|
||||
- 在总分中计入:
|
||||
- `P_risk_effective = external.P_risk + P_widget_emotion_out_of_range`
|
||||
|
||||
> 解释:当 `emotion_score` 达到区间边界外最大距离约为 0.4(例如 0 或 1)时,惩罚接近 `γ`,足以在 Widget 场景把“过低/过高情绪”的句子压到更靠后,但不会一刀切。
|
||||
|
||||
---
|
||||
|
||||
## 5. 场景默认参数(V1 建议)
|
||||
|
||||
对齐 `spec.md` 与算法规则:
|
||||
|
||||
- Feed:`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30`
|
||||
- Push:`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05`,并默认 `enable_uncertainty_penalty=true`
|
||||
- Widget:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`,并启用 `widget_emotion_soft_range=[0.4,0.8]`
|
||||
|
||||
推荐默认:
|
||||
|
||||
- `alpha=0.15`(可调,V1 用于让个性化加成“次要但可见”)
|
||||
- `beta=0.30`(可调,V1 用于在低置信度时明显压低高个性化内容)
|
||||
- `widget_emotion_penalty_gamma=0.25`(适中软降权强度)
|
||||
|
||||
> 注:`alpha/beta/gamma` 为工程默认建议值,后续应通过回归测试与线上指标调参;本模块必须允许 config 覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 6. V1:S_fresh / P_fatigue / P_repeat 的处理方案(能落地且可演进)
|
||||
|
||||
### 6.1 V1 解决方式
|
||||
|
||||
- 在 `ScoreBreakdown` 中**保留** `S_fresh/P_fatigue/P_repeat` 字段;
|
||||
- 本模块计算时:
|
||||
- 若上游未提供对应值,则默认按 0;
|
||||
- 若提供,则原样计入总分(本模块不解释其来源/计算方式)。
|
||||
|
||||
### 6.2 接口建议(为后续模块对接预留)
|
||||
|
||||
- `external_terms` 中携带:
|
||||
- `S_fresh`:例如时间衰减、跨日新鲜度(未来可由 `rerank-freqcap` 或 `reco-engine` 计算)
|
||||
- `P_fatigue/P_repeat`:由 `rerank-freqcap` 基于历史集合与冷却窗口计算
|
||||
|
||||
> 好处:V1 先保证“可排序 + 可解释 + 可插拔”;后续接入重排/频控时无需改动打分主干,只需注入外部项。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试计划(V1)
|
||||
|
||||
### 7.1 单元测试(纯函数)
|
||||
|
||||
- 缺失字段一致性:
|
||||
- `U.need` 缺失 → `S_need=0.5`
|
||||
- `U.context` 缺失 → `S_context=0.5`
|
||||
- `U.emotion_score` 缺失 → `S_emotion=0.8`
|
||||
- 不确定性惩罚(Push 默认启用):
|
||||
- `conf_U`/`conf_C` 低且 `personalization_power` 高 → `final_score` 明显降低
|
||||
- 关闭开关后 `P_uncertainty=0`
|
||||
- Widget 软降权:
|
||||
- `emotion_score=0.6`(区间内)→ `P_widget=0`
|
||||
- `emotion_score=0.0/1.0`(区间外)→ `P_widget` 接近 `gamma`,`final_score` 明显更低
|
||||
- `pass=false`:
|
||||
- `final_score` 必须为 0(或按实现约定为 0),且 breakdown 中保留分解项(便于排查)
|
||||
|
||||
### 7.2 断言建议
|
||||
|
||||
- 断言 `breakdown` 字段集合稳定(不会因缺省而缺字段)。
|
||||
- 断言所有分项均为有限数(非 NaN/Infinity),并在合理范围内(可对 `S_*` clamp 到 `[0,1]`)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与后续演进
|
||||
|
||||
### 8.1 已知风险
|
||||
|
||||
- `U.need/U.context` 若出现多个 key=1,会导致取值歧义;V1 需明确选择策略并记录告警,避免 silent bug。
|
||||
- Widget 软降权强度(`gamma`)对结果影响较大,需要配合回归测试与线上指标调参。
|
||||
|
||||
### 8.2 后续演进方向(V1.1+)
|
||||
|
||||
- 将 `P_risk` 细化为可配置的多项惩罚(例如 health sensitive、过度个性化翻车风险等),并在 breakdown 中拆分输出。
|
||||
- 引入 `S_fresh` 的时间衰减公式,并与 `rerank-freqcap` 的跨日多样性联动。
|
||||
|
||||
167
spec_kit/Personalized Reco/modules/scoring/tasks.md
Normal file
167
spec_kit/Personalized Reco/modules/scoring/tasks.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Scoring(软打分与惩罚项)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/scoring/plan.md`
|
||||
>
|
||||
> 本清单执行原则:
|
||||
>
|
||||
> - `scoring` 只做**软打分**与本模块定义的惩罚项(`P_uncertainty`、Widget 情绪软降权)。
|
||||
> - Hard Filter / 频控重排 / 新鲜度等由其他模块产出,本模块通过 `pass` 与 `external_terms` 接收注入(缺省按 0)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 校对 `modules/scoring/spec.md` 与 `modules/scoring/plan.md` 一致性
|
||||
- **检查点**:
|
||||
- 输入:`scene/user_profile/content_profile/now/config` 是否一致
|
||||
- 输出:`final_score` 与 `breakdown` 字段集合是否一致
|
||||
- 关键公式:`S_core/S_personal/P_uncertainty` 是否与 `设计说明文档/個性化推薦算法規則.md` 一致
|
||||
- **验收**:两份文档无冲突描述,且关键参数命名统一(例如 `enable_uncertainty_penalty`、`widget_emotion_soft_range`)。
|
||||
|
||||
- [x] 1.2 在 `modules/scoring/plan.md` 中“明确写死”V1 的职责边界(若后续有调整再更新)
|
||||
- **变更点**(如需微调措辞):
|
||||
- `S_fresh/P_fatigue/P_repeat/P_risk` 为外部注入项,缺省按 0
|
||||
- `pass` 来自 Hard Filter,本模块只消费并乘上 \(\mathbb{I}[pass]\)
|
||||
- **验收**:阅读 plan.md 时不会产生“谁负责计算哪一项”的歧义。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/scoring/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(`ScoreConfig/ScoreBreakdown/ScoreResult/ExternalTerms`)
|
||||
- `defaults.py`(场景默认参数)
|
||||
- `utils.py`(clamp、one-hot 取 key 等纯工具)
|
||||
- `score.py`(核心纯函数 `score_content`)
|
||||
- **验收**:可通过 `app.features.personalized_reco.scoring.*` 正常 import。
|
||||
|
||||
---
|
||||
|
||||
## 3. 类型与接口(稳定契约,便于引擎编排调用)
|
||||
|
||||
- [x] 3.1 定义 `ScoreConfig`(含场景默认值 + 可覆盖)
|
||||
- **字段**:
|
||||
- 权重:`w_need/w_emotion/w_stage/w_context`
|
||||
- 系数:`alpha/beta`
|
||||
- 开关:`enable_uncertainty_penalty`
|
||||
- Widget:`widget_emotion_soft_range`、`widget_emotion_penalty_gamma`
|
||||
- **验收**:类型完整;能从 scene 推导默认 config(或由调用方传入)。
|
||||
|
||||
- [x] 3.2 定义 `ExternalTerms`(注入项,V1 可选)
|
||||
- **字段**:`S_fresh/P_fatigue/P_repeat/P_risk`
|
||||
- **默认值策略**:缺省按 0
|
||||
- **验收**:score 函数即使没有 external_terms 也能工作且输出字段稳定。
|
||||
|
||||
- [x] 3.3 定义 `ScoreBreakdown`(用于可观测/调参)
|
||||
- **必须包含**:
|
||||
- `S_need/S_context/S_stage/S_emotion`
|
||||
- `S_core/S_personal/S_fresh`
|
||||
- `P_fatigue/P_repeat/P_risk/P_uncertainty`
|
||||
- `missing_fields`
|
||||
- `pass/scene`
|
||||
- `P_widget_emotion_out_of_range`(建议保留)
|
||||
- **验收**:字段集合固定;不会因缺省而缺字段;所有值为有限数(非 NaN/Infinity)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 纯函数实现(严格对齐公式 + 防御式兜底)
|
||||
|
||||
- [x] 4.1 实现 one-hot 取唯一 key 的工具函数(need/context)
|
||||
- **规则**:
|
||||
- `{}` 或不存在 → 视为缺失
|
||||
- 只有一个 key=1 → 返回该 key
|
||||
- 多个 key=1 → 选择“第一个”(写死策略:字典序优先或插入序优先)并记录 debug 日志
|
||||
- **验收**:单测覆盖空对象/单 key/多 key 的行为,且行为确定。
|
||||
|
||||
- [x] 4.2 实现 `S_need`/`S_context`(V1.2 缺失兜底)
|
||||
- **规则**:
|
||||
- need 缺失 → `S_need=0.5`;否则取 `Cᵢ.need_suitability[key]`
|
||||
- context 缺失 → `S_context=0.5`;否则取 `Cᵢ.context_suitability[key]`
|
||||
- 若内容侧缺 key/值非法 → 兜底为 0.5(防御式)
|
||||
- **验收**:单测覆盖用户缺失与内容缺失两侧情况,且不抛异常。
|
||||
|
||||
- [x] 4.3 实现 `S_emotion`(general=0.8;否则 `1-|u-c|`)
|
||||
- **规则**:
|
||||
- `U.emotion_score` 缺失 → 0.8
|
||||
- `Cᵢ.emotion_score` 为 general(None)→ 0.8
|
||||
- 否则 `1-abs(u-c)` 并 clamp 到 `[0,1]`
|
||||
- **验收**:单测覆盖缺失/general/正常值/越界值。
|
||||
|
||||
- [x] 4.4 实现 `S_stage`(对齐算法规则口径)
|
||||
- **规则**:
|
||||
- `content.stage=general` → 1
|
||||
- 命中用户阶段 → 1
|
||||
- 用户 unknown 且内容非 unknown → 0.7
|
||||
- 其余 → 0
|
||||
- **验收**:单测覆盖 general/命中/unknown→非unknown/其余组合。
|
||||
|
||||
- [x] 4.5 实现 `S_core`(线性加权)
|
||||
- **规则**:`S_core=w_need*S_need + w_emotion*S_emotion + w_stage*S_stage + w_context*S_context`
|
||||
- **验收**:单测断言与手算一致;权重可配置。
|
||||
|
||||
- [x] 4.6 实现 `S_personal`(对齐公式)
|
||||
- **规则**:`S_personal=alpha * personalization_power * max(S_need, S_context)`
|
||||
- **防御**:`personalization_power` clamp 到 `[0,1]`
|
||||
- **验收**:单测覆盖 power=0/0.5/1,且随 `alpha` 单调变化。
|
||||
|
||||
- [x] 4.7 实现 `P_uncertainty`(Push 默认启用)
|
||||
- **规则**:`beta*(1-conf_U)*(1-conf_C)*personalization_power`
|
||||
- **默认值**:`conf_C` 缺失→0.7;`conf_U` 缺失→按 1.0(或 0.7,需在代码注释写死;V1 建议按 1.0 避免过惩罚)
|
||||
- **开关**:`enable_uncertainty_penalty` 控制;Push scene 默认 true
|
||||
- **验收**:单测覆盖低置信度与关闭开关两类情况。
|
||||
|
||||
- [x] 4.8 实现 Widget 情绪区间软降权(适中默认)
|
||||
- **规则**:
|
||||
- `scene=widget` 且 `content.emotion_score` 非 general:
|
||||
- 若超出 `[0.4,0.8]`:`P_widget = gamma * d/(hi-lo)` 并 clamp `[0,gamma]`
|
||||
- 区间内:`P_widget=0`
|
||||
- `P_widget` 计入总分(建议并入 `P_risk`),并在 breakdown 中单独暴露
|
||||
- **验收**:单测断言:
|
||||
- `emotion_score=0.6` → `P_widget=0`
|
||||
- `emotion_score=0.0/1.0` → `P_widget` 接近 `gamma`
|
||||
- 不硬过滤(仍返回分数,只是更低)
|
||||
|
||||
- [x] 4.9 实现 `score_content(...) -> ScoreResult`(总分与 breakdown)
|
||||
- **规则**:
|
||||
- `final_score = I[pass] * (S_core + S_personal + S_fresh - P_fatigue - P_repeat - P_risk - P_uncertainty)`
|
||||
- `S_fresh/P_fatigue/P_repeat/P_risk` 从 `external_terms` 读取,缺省 0
|
||||
- `pass=false` 时 `final_score=0`(breakdown 仍输出便于排查)
|
||||
- **验收**:单测覆盖 `pass=false` 与 external_terms 缺省两种情况。
|
||||
|
||||
---
|
||||
|
||||
## 5. 单元测试(pytest,纯函数为主)
|
||||
|
||||
- [x] 5.1 新建测试文件 `server/tests/test_scoring.py`
|
||||
- **包含用例**:
|
||||
- 缺失字段一致性(need/context/emotion)
|
||||
- Push 不确定性惩罚生效(低 conf 时分数更低)
|
||||
- Widget 软降权生效(区间外明显更低)
|
||||
- pass=false 行为(final_score=0)
|
||||
- **验收**:`pytest -q` 能跑通该文件。
|
||||
|
||||
- [x] 5.2 增加“输出稳定性”断言(breakdown 字段集合固定)
|
||||
- **验收**:任意输入(含缺失字段)都返回同一套 breakdown key。
|
||||
|
||||
---
|
||||
|
||||
## 6. 最终自检清单(合入前)
|
||||
|
||||
- [x] 6.1 文档一致性检查
|
||||
- **检查点**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 config 字段与默认值策略)。
|
||||
- **验收**:阅读任一文档都能找到对应实现位置与参数含义。
|
||||
|
||||
- [x] 6.2 回归检查(不影响其他模块)
|
||||
- **验收**:不修改现有 `content-repository` 与 `user_profile_scoring` 逻辑,仅新增 scoring 模块与测试。
|
||||
|
||||
@@ -87,21 +87,36 @@ spec_kit/Personalized Reco/
|
||||
|
||||
> 原则:先把“数据可查”打通,再实现“可排序”,最后做“可对外提供(API/任务)”与“可观测”。
|
||||
|
||||
1. **`modules/db-design/`(数据库设计与迁移)**
|
||||
1. **`modules/db-design/`(数据库设计与迁移)**已实施
|
||||
- 交付:表结构 + Alembic 迁移可跑通;能插入/读取最小 `ContentProfile` 字段。
|
||||
2. **`modules/content-repository/`(数据访问层)**
|
||||
2. **`modules/content-repository/`(数据访问层)**已实施
|
||||
- 交付:按场景/回退层级拉候选、按 ID(`content_id` 自增 `int`)批量查;risk_flags 旧→新映射与默认值兜底。
|
||||
- 语言:`text` 按客户端 `locale` 输出(当前仅 EN/TC),且不允许语言回退(缺语言内容直接过滤)。
|
||||
- 口径:suitability 读取层统一输出稳定结构;缺失时补齐“全 0.5”,key 集合固定为:
|
||||
- context:`family/work/relationship/friends/health`
|
||||
- need:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
|
||||
3. **`modules/scoring/`(打分)**
|
||||
3. **`modules/scoring/`(打分)** 已实施
|
||||
- 交付:`final_score` 与缺失字段保守策略(V1.2)实现;Push 的 `P_uncertainty`;Widget 情绪区间软降权。
|
||||
4. **`modules/rerank-freqcap/`(去重/重排/频控)**
|
||||
4. **`modules/rerank-freqcap/`(去重/重排/频控)** 已实施
|
||||
- 交付:基于传入历史集合的去重;Feed 的 MMR;Push/Widget 冷却窗口规则(先保证“同句不重复”)。
|
||||
5. **`modules/observability/`(可观测)**
|
||||
5. **`modules/observability/`(可观测)** 已实施
|
||||
- 交付:统一 `meta` 结构与字段;能准确定位候选在何阶段被清空/回退。
|
||||
6. **`modules/reco-engine/`(引擎编排)**
|
||||
- 交付:串起候选→过滤→打分→重排→回退;输出 items+meta;在画像缺失/候选不足时仍稳定返回。
|
||||
7. **`modules/integration-api-worker/`(API + Celery 集成)**
|
||||
- 交付:FastAPI 路由 + Celery 任务都能调用同一引擎;相同输入下结果一致(忽略时间戳差异)。
|
||||
6. **`modules/reco-engine/`(引擎编排)** 已实施
|
||||
- 交付:新增后端 `reco_engine`(编排器 + Hard Filter);串起候选→过滤→打分→重排→回退;输出 items+meta;默认开启轻量 explanations;在画像缺失/候选不足时仍稳定返回。
|
||||
7. **`modules/integration-api-worker/`(API + Celery 集成)** 已实施
|
||||
- 交付:新增 `/v1/reco/{feed|push|widget}` 三个推荐接口(含 `Accept-Language→en/tc` 与 `X-Now` 注入);新增按 IP 限流 10/min;新增 Celery 任务 `tasks.reco.generate` 与 `tasks.reco.push_once`,均调用同一 `Reco Engine`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 变更记录
|
||||
|
||||
- 2026-02-02:完成 `modules/scoring/` 的 `plan.md` 与 `tasks.md`,并新增后端打分模块 `server/app/features/personalized_reco/scoring/` 与单元测试 `server/tests/test_scoring.py`。
|
||||
- 2026-02-02:完成 `modules/rerank-freqcap/` 的 `plan.md` 与 `tasks.md`,并新增后端重排/频控模块 `server/app/features/personalized_reco/rerank_freqcap/` 与单元测试 `server/tests/test_rerank_freqcap.py`。
|
||||
- 2026-02-02:完成 `modules/observability/` 的 `plan.md` 与 `tasks.md`,并新增后端可观测模块 `server/app/features/personalized_reco/observability/` 与单元测试 `server/tests/test_observability.py`。
|
||||
- 2026-02-02:完成 `modules/reco-engine/` 的 `plan.md` 与 `tasks.md`,并新增后端引擎编排模块 `server/app/features/personalized_reco/reco_engine/`(含 Hard Filter + Orchestrator)与单元测试 `server/tests/test_reco_engine.py`。
|
||||
- 2026-02-02:完成 `modules/integration-api-worker/` 的 `plan.md` 与 `tasks.md`,并新增后端集成:
|
||||
- FastAPI:`server/app/api/v1/reco.py`、`server/app/api/limits.py`、`server/app/main.py`
|
||||
- Celery:`server/app/tasks/reco.py`
|
||||
- 测试:`server/tests/test_integration_api_worker.py`
|
||||
- 依赖:`server/requirements.txt`(新增 `httpx` 用于 API 测试)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user