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

