# P1-5 ai_cache cache_type 规范化方案

> 反馈背景：Neo 接受"统一 cache_type 枚举、前后端共享"的规范方向，但提出深层问题：
> **"修改是否需要让 AI 返回一些标准的标记，以进行信息对齐？"**
> 本文档基于现状代码 + 测试库实际数据，给出 3 套方案对比，并直接回答这个核心问题。

调研日期：2026-05-04
调研范围：`apps/miniprogram/pages/board-finance` / `apps/backend/app/ai/cache_service.py` / `apps/backend/app/ai/dispatcher.py` / `apps/backend/app/ai/schemas.py` / `db/zqyy_app/schemas/biz.sql` / 测试库 `biz.ai_cache` 实际数据

---

## 一、现状盘点

### 1.1 cache_type 当前已有 3 处定义

| 层 | 位置 | 形式 |
|---|---|---|
| 数据库约束 | `biz.ai_cache.chk_ai_cache_type` | CHECK IN (8 个值) |
| 后端枚举 | `apps/backend/app/ai/schemas.py::CacheTypeEnum` | Python str Enum |
| 后端字典 | `apps/backend/app/ai/cache_service.py::CACHE_EXPIRY_DAYS` | dict[str,int] |

数据库 CHECK 约束（测试库实测）：
```
'app2_finance', 'app2a_finance_area', 'app3_clue', 'app4_analysis',
'app5_tactics', 'app6_note_analysis', 'app7_customer_analysis',
'app8_clue_consolidated'
```

后端 `CacheTypeEnum` 8 个枚举值与上述完全一致（已对齐）。

### 1.2 前端 3 类硬编码

#### 小程序 `apps/miniprogram/`

| 文件 | 行号 | 字符串 | 用途 |
|---|---|---|---|
| `pages/board-finance/board-finance.ts` | 532 | `'app2_finance'` / `'app2a_finance_area'` | 按 area 切 cache_type |
| `pages/customer-detail/customer-detail.ts` | 145 | `'app7_customer_analysis'` | 客户详情读取客户综合分析 |
| `services/api.ts` | 442 | `cacheType: string` 形参 | 通用 fetchAICache 入口（无类型约束） |

**现状特征**：cacheType 形参是 `string`，调用点写裸字符串字面量，**无 TS 联合类型限制**，拼写错误只能在运行时由后端 422 拦截。

#### admin-web `apps/admin-web/`

| 文件 | 用途 |
|---|---|
| `pages/AIOperations.tsx` (39-40 行) | `CACHE_TYPE_OPTIONS` 用于"缓存失效"下拉框，写裸字符串 |
| `pages/AIRunLogs.tsx` (228 行) | run log 筛选下拉框，写裸字符串 |
| `pages/AIPrewarm.tsx` (43-44 行) | `areaToAppType()` 返回 `"app2_finance" \| "app2a_finance_area"` 联合类型，已是局部最佳实践 |
| `__tests__/adminAiAppTypes.test.ts` | 已有回归测试守护 cache_type vs app_type 不混淆 |

### 1.3 测试库 cache_type 实际分布（2026-05-04）

```
app2_finance              98
app8_clue_consolidated    72
app3_clue                 71
app7_customer_analysis    42
app4_analysis             38
app5_tactics              31
app2a_finance_area        27
（app6_note_analysis 0 条 — 备注事件未在测试库触发）
```

7/8 类型在测试库有真实写入；result_json 实际 keys 抽样：

| cache_type | top-level keys |
|---|---|
| app2_finance | `_references` / `insights` / `key` / `value` （部分早期记录漏掉 schema） |
| app2a_finance_area | `insights` |
| app3_clue | `_references` / `clues` |
| app4_analysis | `_references` / `actions` / `relationship_summary` / `summary` / `task_description` |
| app5_tactics | `_references` / `tactics` |
| app7_customer_analysis | `_references` / `seq` / `strategies` / `summary` |
| app8_clue_consolidated | `_references` / `clues` / `seq` |

**关键发现**：所有 result_json **没有** `cache_type` / `metric_id` / `card_type` 这类自描述标记字段。AI 只返回业务负载，不返回类型 ID。

### 1.4 后端写入 cache_type 的决策逻辑

`apps/backend/app/ai/dispatcher.py` 的写入路径：

| 调用点 | cache_type 决策 |
|---|---|
| `_handle_consumption` | 硬编码 `CacheTypeEnum.APP3_CLUE.value` 等 |
| `_handle_dws_completed` | 按 area 路由：`area=='all'` → `APP2_FINANCE`；`area!='all'` → `APP2A_FINANCE_AREA` |
| `run_single_app` | `app_type` 入参 → 内部 if/elif 映射到 `cache_type` 常量 |

**结论**：cache_type 完全由**后端 dispatcher 在调用百炼前**决定（基于 app_type + 路由参数），写入时一并保存。**AI 端从不感知 cache_type，也无需返回任何标记**。

---

## 二、AI prompt 输出现状

### 2.1 后端是否要求 AI 返回 cache_type / metric_id

通读 `apps/backend/app/ai/prompts/app2_finance_prompt.py` 全文 873 行，**未发现任何要求 AI 返回 cache_type / metric_id / card_type 的指令**。

后端只调用 `json.dumps(payload)` 把业务上下文喂给百炼，system prompt 配置在百炼控制台（`docs/ai/app2a_finance_area_system_prompt_20260422_v1.md` 等版本化文档）。

### 2.2 AI 实际返回的 schema（按 app）

| App | schema | 来源 |
|---|---|---|
| App2 | `{ insights: [{seq, title, content}] }` | 百炼 system prompt 约定 |
| App2a | `{ insights: [{seq, title, content}] }` | 同上 |
| App3 | `{ clues: [{category, summary, detail, emoji}] }` | `App3Result` Pydantic |
| App4 | `{ task_description, action_suggestions, one_line_summary }` | `App4Result` |
| App5 | `{ tactics: [...] }` | `App5Result` |
| App6 | `{ score, clues: [...] }` | `App6Result` |
| App7 | `{ summary, strategies: [...] }` | `App7Result` |
| App8 | `{ clues: [{..., providers}] }` | `App8Result` |

**没有任何 app 的输出包含可作为 cache_type 来源的字段**。每个 app 与 cache_type 是 1:1 关系（除 App2 一种 app 内按 area 派生 2 个 cache_type 的例外）。

### 2.3 attach_references 的命名空间标记

`apps/backend/app/ai/dispatcher.py::_run_step` 在结果返回前调用 `attach_references(app_name, result, context)`，把 `_references` 元数据写入 result_json。**这是后端补的标记，不是 AI 自带**。注入键就是 `app_name`（如 `app3_clue`），等同 cache_type，但语义上仍是后端权威决定。

---

## 三、规范化方案对比

### 方案 A · 跨包共享枚举（"标准做法"）

#### 思路

新建 `packages/shared/src/neozqyy_shared/ai_cache_types.py`（Python 端）和 `packages/shared/ts/aiCacheTypes.ts`（TS 端，**新建 packages/shared TS 子目录**），通过 Python `import` / TS `import` 共享：

```python
# packages/shared/src/neozqyy_shared/ai_cache_types.py
from enum import Enum

class CacheType(str, Enum):
    APP2_FINANCE = "app2_finance"
    APP2A_FINANCE_AREA = "app2a_finance_area"
    APP3_CLUE = "app3_clue"
    APP4_ANALYSIS = "app4_analysis"
    APP5_TACTICS = "app5_tactics"
    APP6_NOTE_ANALYSIS = "app6_note_analysis"
    APP7_CUSTOMER_ANALYSIS = "app7_customer_analysis"
    APP8_CLUE_CONSOLIDATED = "app8_clue_consolidated"

CACHE_TYPE_VALUES: tuple[str, ...] = tuple(c.value for c in CacheType)
```

```ts
// packages/shared/ts/aiCacheTypes.ts
export const CACHE_TYPE = {
  APP2_FINANCE: "app2_finance",
  APP2A_FINANCE_AREA: "app2a_finance_area",
  APP3_CLUE: "app3_clue",
  APP4_ANALYSIS: "app4_analysis",
  APP5_TACTICS: "app5_tactics",
  APP6_NOTE_ANALYSIS: "app6_note_analysis",
  APP7_CUSTOMER_ANALYSIS: "app7_customer_analysis",
  APP8_CLUE_CONSOLIDATED: "app8_clue_consolidated",
} as const;

export type CacheType = typeof CACHE_TYPE[keyof typeof CACHE_TYPE];
```

#### 替换路径

- `apps/backend/app/ai/schemas.py::CacheTypeEnum` → re-export 自 `neozqyy_shared.ai_cache_types.CacheType`
- `apps/miniprogram/services/api.ts::fetchAICache(cacheType)` → 形参类型从 `string` 收紧为 `CacheType`
- `apps/miniprogram/pages/board-finance/board-finance.ts:532` → `CACHE_TYPE.APP2_FINANCE`
- `apps/miniprogram/pages/customer-detail/customer-detail.ts:145` → `CACHE_TYPE.APP7_CUSTOMER_ANALYSIS`
- `apps/admin-web/src/pages/AIOperations.tsx::CACHE_TYPE_OPTIONS` → 用 `CACHE_TYPE` 常量构造
- `apps/admin-web/src/pages/AIRunLogs.tsx` 同上
- `apps/admin-web/src/pages/AIPrewarm.tsx::areaToAppType` 返回类型用 `CacheType`

#### 评估

| 维度 | 值 |
|---|---|
| 工作量 | 中等（新建包 + 后端 re-export + 4 个前端文件改 import + 2 处小程序改 import；packages/shared 当前**无 TS 子目录**，需要新增一套 tsconfig + pnpm workspace 配置） |
| AI prompt 是否需改 | **否** — AI 完全不感知 cache_type |
| 回归测试范围 | 后端：`tests/ai/` 现有用例自动覆盖；前端：`__tests__/adminAiAppTypes.test.ts` 仍生效；小程序：board-finance + customer-detail 端到端冒烟 |
| 长期维护成本 | **低** — 新增 cache_type 时只改一个枚举文件，类型系统自动失败任何遗漏点 |
| 风险点 | 小程序使用微信原生 + Donut 编译，引入 monorepo TS 包需验证小程序构建工具是否能解析 `packages/shared/ts/` 路径；可能需要在打包阶段 inline copy 到 miniprogram/utils/ |

### 方案 B · 让 AI prompt 返回标准 metric_id

#### 思路

要求百炼控制台的 system prompt 在 result_json 顶层加 `metric_id` 字段（值如 `"app2_finance"`）。后端按 metric_id 校验 + 决定 cache_type，AI 与 cache_type 真正"信息对齐"。

#### 评估

| 维度 | 值 |
|---|---|
| 工作量 | **高** — 需要改动 8 个百炼 APP 的 system prompt（V5.x → V6），重新 A/B 测试 24+ 组合，version 化文档 |
| AI prompt 是否需改 | **是** — 8 个 APP 全部受影响 |
| 回归测试范围 | 全量：`scripts/ab_test_app2a_area.py` 类的 A/B 脚本要为每个 APP 跑一遍；hardcheck 增加 `metric_id` 必填 |
| 长期维护成本 | **高** — AI 输出每多一个字段，prompt 越胖，越容易出现"AI 漏返字段"的回归。Neo 反馈本身已经满意现状产出质量，引入 metric_id 是为规范付出 AI 质量代价 |
| 隐性问题 | **App2 单 APP 输出 2 种 cache_type**（按 area 派生）— 让 AI 决定 metric_id 等于把后端路由逻辑外包给百炼，违反"权威源在数据库 / 后端"原则 |

**判断**：方案 B 是反模式。cache_type 是数据存储维度，不是 AI 业务输出。让 AI 标注存储类型相当于让 SQL 表自己写 INSERT 语句。

### 方案 C · 后端规则映射（不动 AI）

#### 思路

不引入新枚举，只在 `apps/backend/app/ai/dispatcher.py` 加一个 `_resolve_cache_type(app_type, area)` 函数集中决策；前端继续硬编码字符串。

```python
def _resolve_cache_type(app_type: str, area: str | None = None) -> str:
    if app_type == "app2_finance":
        return CacheTypeEnum.APP2_FINANCE.value if area == "all" else CacheTypeEnum.APP2A_FINANCE_AREA.value
    return _APP_TO_CACHE_TYPE[app_type]
```

#### 评估

| 维度 | 值 |
|---|---|
| 工作量 | 极低（< 30 行） |
| AI prompt 是否需改 | 否 |
| 回归测试范围 | 后端单测 1 个 |
| 长期维护成本 | **低**，但**前端硬编码问题没解决** |
| 风险点 | Neo 反馈中"前端硬编码"问题未消除。下次新增 cache_type 仍要在 5 个文件里手改字符串 |

**判断**：方案 C 是局部清理，不解决 Neo 反馈的根因（"前后端共享枚举"诉求）。

---

## 四、AI 是否需要返回标准标记？（直接回答 Neo 的核心问题）

### 结论：不需要。

理由 4 条：

#### 1. cache_type 是数据存储分类，不是 AI 业务输出

cache_type 的本质是 `biz.ai_cache.cache_type` 列的值，决定 INSERT 时存哪个 partition、CHECK 约束验证、过期天数（`CACHE_EXPIRY_DAYS` 字典）。这些都是**后端工程关注点**，与 AI 推理结果无关。

#### 2. 后端在调用百炼**之前**就已决定 cache_type

`dispatcher._handle_dws_completed` 的循环：

```python
for dimension in APP2_TIME_DIMENSIONS:           # 8 个时间维度
    # 全域：app_id_2_finance + cache_type=app2_finance
    result = await self._run_step("app2_finance", self.config.app_id_2_finance, prompt, context)
    self._write_cache(CacheTypeEnum.APP2_FINANCE.value, ...)

    for area in APP2A_AREA_OPTIONS:              # 8 个区域
        # 区域：app_id_2a + cache_type=app2a_finance_area
        result = await self._run_step("app2a_finance_area", self.config.app_id_2a_finance_area, prompt, context)
        self._write_cache(CacheTypeEnum.APP2A_FINANCE_AREA.value, ...)
```

**百炼 APP ID 与 cache_type 是后端常量映射**。即便 AI 返回 metric_id，后端也不会信任这个外部输入（否则 AI 幻觉一个不存在的 cache_type 直接打穿数据库 CHECK 约束）。

#### 3. App2 现有架构已演示了"非 AI 决定"的最佳实践

2026-04-23 上线的 app2/app2a 拆分（`docs/audit/changes/2026-04-23__app2a_finance_area_integrated.md`）专门强调：**两个百炼 APP 输出结构完全相同**（都是 `{insights: [...]}`），但是写入两个不同 cache_type。**区分点是 area 参数**，不是 AI 标注。这恰恰证明 cache_type 必须在调用前由后端决定。

#### 4. 已有 `_references` 元数据替代了"信息对齐"需求

`apps/backend/app/ai/references.py::attach_references` 在 result_json 注入 `_references` 子字段，包含：
- `app_name`（实际就是 cache_type 同义）
- `triggered_by` / `member_id` / `site_id` / 上下文哈希等溯源信息

这是**后端层面的标准化标记**，前端读 result 时已能拿到 `result_json._references.app_name` 自检。AI 不参与这一步，但效果等同"标准标记"。

### 真正应该做的"信息对齐"是什么？

不是让 AI 返回标记，而是**前后端共享 cache_type 枚举常量**（方案 A）。让 TS 编译器在 `fetchAICache('app7_customer_analyse')`（拼错）这种代码上直接报错，比让 AI 返回字段做运行时校验靠谱百倍。

---

## 五、推荐实施步骤

**推荐采纳方案 A**，分 4 阶段：

### 阶段 1 · 共享枚举源码（D+0）

1. 新建 `packages/shared/src/neozqyy_shared/ai_cache_types.py`，定义 `CacheType` Enum + `CACHE_TYPE_VALUES` 元组
2. 新建 `packages/shared/ts/`（先不发包，源码 import 即可），文件 `aiCacheTypes.ts` 导出 `CACHE_TYPE` 常量对象 + `CacheType` 类型联合
3. 顶层 `pnpm-workspace.yaml` 增加 `packages/shared/ts` 成员（如未配置）

### 阶段 2 · 后端落地（D+0）

1. `apps/backend/app/ai/schemas.py::CacheTypeEnum` 改为从 `neozqyy_shared` import 后 re-export，向后兼容
2. `apps/backend/app/ai/dispatcher.py` 增加 `_resolve_cache_type(app_type, area)` 集中决策函数（封装现有 if/elif）
3. `apps/backend/app/routers/xcx_ai_cache.py::valid_types` 改用 `CACHE_TYPE_VALUES`
4. 后端测试：复跑 `apps/backend/tests/ai/`

### 阶段 3 · 前端常量替换（D+1）

1. `apps/admin-web/src/pages/AIOperations.tsx` `AIRunLogs.tsx` `AIPrewarm.tsx`：`CACHE_TYPE_OPTIONS` / 内联字符串 → `CACHE_TYPE.XXX`
2. `apps/admin-web/src/api/adminAI.ts::CacheInvalidateReq.app_type` 类型从 `string` → `CacheType`
3. `apps/miniprogram/services/api.ts::fetchAICache(cacheType)` 形参类型 → `CacheType`
4. `apps/miniprogram/pages/board-finance/board-finance.ts:532` 改用常量
5. `apps/miniprogram/pages/customer-detail/customer-detail.ts:145` 改用常量
6. 验证小程序构建（Donut + 微信原生）能解析 `packages/shared/ts/` 路径，不行则在小程序 utils 下做一份 inline copy 并加 ESLint rule 禁止裸字符串

### 阶段 4 · 文档同步（D+1）

1. 根 `CLAUDE.md` 飞球数据规范段后追加一节"AI 缓存类型规范"，链接 `packages/shared/src/neozqyy_shared/ai_cache_types.py`
2. `apps/backend/CLAUDE.md` 在 AI 集成段标注"cache_type 共享源在 packages/shared"
3. 写审计 `docs/audit/changes/<日期>__cache-type-shared-enum.md`，记录 8 类型枚举来源

---

## 六、给 Neo 的决策清单

请逐项 yes/no 给我确认后我再实施：

| 问 | 选项 | 推荐 |
|---|---|---|
| Q1 | 采纳方案 A（共享枚举），拒绝方案 B（AI 返回 metric_id），拒绝方案 C（仅后端清理）？ | **是** |
| Q2 | `packages/shared/` 新增 TS 子目录是否可接受？还是希望保留纯 Python 包，TS 端在 admin-web/miniprogram 各自维护一份从 Python 生成的 const？ | 推荐新增 TS 子目录（一处真相） |
| Q3 | 小程序构建（Donut + 微信原生）是否允许 import `packages/shared/ts/` 源码？还是必须 inline copy？ | 需先验证；若不行则 inline copy + 加单元测试守护两份保持同步 |
| Q4 | 是否需要把 `CACHE_EXPIRY_DAYS`（cache_type → 过期天数字典）也搬到 shared？还是保留后端独有？ | 推荐保留后端，前端不应感知过期策略 |
| Q5 | 是否需要把 admin-web 的 `RUN_APP_TYPES`（8 个 app_type）也共享化？此清单与 cache_type 不同（如 app8_consolidation vs app8_clue_consolidated） | 推荐**也共享**，避免 `__tests__/adminAiAppTypes.test.ts` 中靠测试守护两套字典 |
| Q6 | 改造完成后是否同步加一条 ESLint 规则禁止 `'app2_finance'` 类裸字符串？ | 推荐加 `no-restricted-syntax` 规则 |

---

## 附录：关键文件清单

### 后端
- `apps/backend/app/ai/schemas.py:38` — `CacheTypeEnum`（权威源）
- `apps/backend/app/ai/cache_service.py:31` — `CACHE_EXPIRY_DAYS` 字典
- `apps/backend/app/ai/dispatcher.py:946-979` — App2/App2a cache_type 路由
- `apps/backend/app/routers/xcx_ai_cache.py:37` — 校验 cache_type 入口

### 前端
- `apps/admin-web/src/api/adminAI.ts:12` — `RUN_APP_TYPES`（app_type ≠ cache_type，注意区分）
- `apps/admin-web/src/pages/AIOperations.tsx:39` — `CACHE_TYPE_OPTIONS` 硬编码
- `apps/admin-web/src/pages/AIRunLogs.tsx:228` — run log app_type 下拉
- `apps/admin-web/src/pages/AIPrewarm.tsx:43` — `areaToAppType` 局部联合类型（已是局部最佳实践）
- `apps/admin-web/src/__tests__/adminAiAppTypes.test.ts` — 现有回归测试

### 小程序
- `apps/miniprogram/services/api.ts:442` — `fetchAICache(cacheType: string)` 入口（待收紧）
- `apps/miniprogram/pages/board-finance/board-finance.ts:532` — 按 area 切 cache_type 硬编码
- `apps/miniprogram/pages/customer-detail/customer-detail.ts:145` — `'app7_customer_analysis'` 硬编码

### 数据库
- `db/zqyy_app/schemas/biz.sql:34` — `biz.ai_cache` 表 + `chk_ai_cache_type` CHECK 约束
- `db/zqyy_app/migrations/20260423__ai_cache_allow_app2a.sql` — 最近一次 cache_type 扩容（参考）

### 共享包
- `packages/shared/src/neozqyy_shared/enums.py` — 现有共享枚举（PaymentStatus / OrderStatus 等）— 方案 A 在此目录新建 `ai_cache_types.py`

### 审计参考
- `docs/audit/changes/2026-04-23__app2a_finance_area_integrated.md` — App2 拆分案例，证明 cache_type 不应由 AI 决定
