# 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 项 “已实施/已完成” 并补充变更记录(日期 + 简述)