新功能:个性化推荐算法

This commit is contained in:
吕新雨
2026-02-02 16:47:37 +08:00
parent 936094211b
commit 6dc4e2b943
119 changed files with 7427 additions and 357 deletions

View 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 成本。

View File

@@ -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`

View File

@@ -0,0 +1,198 @@
# Content Repository候选查询与数据访问层Tasks
> 对应计划:`spec_kit/Personalized Reco/modules/content-repository/plan.md`
>
> 本清单已对齐确认点:
>
> - `text` 按客户端 **locale** 输出(请求携带)
> - **不允许语言回退**(缺少目标语言文本的内容直接过滤,不返回)
> - 需要做 **MySQLdevDB 集成测试**
> - 代码与其他推荐子模块放同一目录:`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` 模块与迁移脚本(仅新增模块与测试)

View File

@@ -0,0 +1,205 @@
# IntegrationFastAPI API + Celery WorkerPlan
> 对应规范:`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=30push/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 限流(按 IP1 分钟 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` 为 asyncCelery 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 口径一致)

View File

@@ -0,0 +1,188 @@
# IntegrationFastAPI API + Celery WorkerTasks
> 对应计划:`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` headerISO8601并保留 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` headerISO8601
- 其次使用 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限流按 IP10 次/分钟)
- [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超限返回 429detail=rate_limited
- [x] **将限流应用到 3 个推荐路由**(状态:已完成)
- **方式**
- 方案 A每个路由加 `Depends(rate_limit)`
- 方案 Brouter 级依赖(推荐)
- **证据**:任一接口连续请求超过 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/localeAPI 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 任务 + 限流 + 测试)

View 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 Scoringscoring
- Rerank/Freqcaprerank-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_reasonserved_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` 的统一结构与上报策略,便于报表按维度聚合。

View 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
- setraw/after_hard_filter/after_dedup/after_freqcap/fallback_level_final/served_k
- 可选 setrisk_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` 中模块状态与交付记录准确。

View 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`: intfeed 默认 30push/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`:直接返回空 itemsmeta.served_k=0fallback_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(...)`,输出结构一致

View 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` 返回空 itemsmeta.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 项 “已实施/已完成” 并补充变更记录(日期 + 简述)

View File

@@ -0,0 +1,264 @@
# Rerank & Freqcap重排 / 去重 / 频控Plan
> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md`
>
> 规则来源(必须对齐):
>
> - `设计说明文档/個性化推薦算法規則.md`Feed MMR λ=0.7Push/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按冷却维度句子/作者/模板)做“硬过滤或强约束”
- FeedMMR 生成序列(保证多样性)
- 输出 `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 默认 30push/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** 作为代表标签(若为空则跳过)
> 说明:内容画像是 suitability0/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 FreqcapPush/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 FeedMMR 序列重排(建议实现)
步骤:
- 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. 可观测与 metaV1
本模块建议输出(供 `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`,实现更平滑的序列控制(而非一刀切过滤)。

View 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` 口径一致
- FeedMMR λ=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 实现 FreqcapPush/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 实现 Feedtag 构造与相似度 `Sim`
- **tag 规则V1 写死)**
- `stage:<stage>`
- `need:<argmax_key>`(从 `need_suitability` 取最大值 key为空则跳过
- `context:<argmax_key>`(从 `context_suitability` 取最大值 key为空则跳过
- **Jaccard**`|A∩B|/|AB|`,空集合时返回 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 实现 FeedMMR 选序列
- **规则**
- 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
- FeedMMRPush/WidgetTopK
- 必须输出 meta即使 ranked_items 为空)
- **验收**:任何输入(含空候选)不抛异常,并输出稳定结构。
---
## 5. 单元测试pytest纯函数为主
- [x] 5.1 新建测试文件 `server/tests/test_rerank_freqcap.py`
- **用例覆盖**
- dedup历史集合过滤正确含 str/int 混用)
- freqcap有/无 recent_author/template 的分支与 meta 缺失标记
- feed mmrTop1=最高分;后续避免同作者/模板紧邻(构造数据断言)
- push/widgetTopK 输出正确
- **验收**`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` 中模块状态与交付记录准确。

View 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_contextV1.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_emotiongeneral=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_uncertaintyPush 默认启用)
\[
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. V1S_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` 的跨日多样性联动。

View 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` 为 generalNone→ 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 模块与测试。