# 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 模块与测试。

