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

