# P14：AI 模块改造 — DashScope 迁移 + 调度器完善

> 状态：Draft | 创建日期：2026-03-21 | 依赖：P5（AI 集成层）、RNS1.4（CHAT 对齐）
> 后续：P15（监控后台 + 测试重建 + 回填）

---

## 一、执行摘要

当前 AI 模块使用 `openai` SDK 的通用模型 API（`chat.completions.create`），但项目的 8 个 App 均为百炼控制台创建的智能体应用（各有独立 `app_id`）。通用模型 API 不接受 `app_id`，等于绕过了百炼控制台配置的 System Prompt、MCP 工具等全部能力。

本 PRD 将 SDK 从 `openai` 切换到 `dashscope`（Application API），一步到位完成迁移，同时修复调度器、事件触发、熔断限流等核心问题。

### 问题来源

- `docs/reports/2026-03-21__ai_module_issues.md`（18 个问题：4 P0 / 6 P1 / 5 P2 / 3 P3）
- AI 全链路测试报告 + 86 项 Gap 审查

### 改动范围

| 模块 | 改动内容 |
|------|---------|
| `apps/backend/app/ai/` | SDK 替换、客户端重写、调度器修复 |
| `apps/backend/app/services/ai/` | 缓存服务、对话服务适配 |
| `apps/backend/app/routers/` | 内部触发 API、SSE 端点适配 |
| `apps/etl/connectors/feiqiu/` | DWS 完成后 HTTP 触发 |
| `db/zqyy_app/` | DDL 迁移（新增表、字段） |
| `.env` / `.env.template` | 环境变量统一 |

---

## 二、技术方案

### 2.1 SDK 替换：openai → dashscope Application API

**当前**：`openai.AsyncOpenAI` + `chat.completions.create`
**目标**：`dashscope.Application.call` + `asyncio.to_thread()` 包装

#### 调用方式对比

```python
# ===== 当前（错误）=====
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=key, base_url=bailian_url)
response = await client.chat.completions.create(
    model=model, messages=messages, response_format={"type": "json_object"}
)

# ===== 目标（正确）=====
import dashscope
from dashscope import Application

# App1 流式调用
response = Application.call(
    app_id=app_id,
    prompt=user_message,
    session_id=session_id,          # 云端对话管理
    biz_params={"user_prompt_params": {"User_ID": uid, "Role": role, "Nickname": name}},
    stream=True,
    incremental_output=True,
)

# App2~8 单轮调用
response = Application.call(
    app_id=app_id,
    prompt=data_json,               # 后端拼好的完整数据 JSON
)
```

#### async 包装

`dashscope.Application.call()` 是同步方法，当前后端全 async。采用 `asyncio.to_thread()` 包装：

```python
import asyncio

async def call_application(app_id: str, prompt: str, **kwargs) -> dict:
    return await asyncio.to_thread(
        Application.call,
        app_id=app_id,
        prompt=prompt,
        **kwargs
    )
```

流式调用（App1）需特殊处理：`Application.call(stream=True)` 返回同步迭代器，需在线程中消费后通过 `asyncio.Queue` 桥接到 async generator。

### 2.2 环境变量统一

**废弃**（本轮直接删除，不保留兼容）：
- `BAILIAN_API_KEY`
- `BAILIAN_BASE_URL`
- `BAILIAN_MODEL`

**新增**：
- `DASHSCOPE_API_KEY` — DashScope API Key
- `DASHSCOPE_WORKSPACE_ID` — 百炼工作空间 ID（可选）

**保留不变**（仅改前缀注释）：
| 旧变量 | 新变量 | 说明 |
|--------|--------|------|
| `BAILIAN_APP_ID_1_CHAT` | `DASHSCOPE_APP_ID_1_CHAT` | App1 通用对话 |
| `BAILIAN_APP_ID_2_FINANCE` | `DASHSCOPE_APP_ID_2_FINANCE` | App2 财务洞察 |
| `BAILIAN_APP_ID_3_CLUE` | `DASHSCOPE_APP_ID_3_CLUE` | App3 维客线索 |
| `BAILIAN_APP_ID_4_ANALYSIS` | `DASHSCOPE_APP_ID_4_ANALYSIS` | App4 关系分析 |
| `BAILIAN_APP_ID_5_TACTICS` | `DASHSCOPE_APP_ID_5_TACTICS` | App5 话术参考 |
| `BAILIAN_APP_ID_6_NOTE` | `DASHSCOPE_APP_ID_6_NOTE` | App6 备注分析 |
| `BAILIAN_APP_ID_7_CUSTOMER` | `DASHSCOPE_APP_ID_7_CUSTOMER` | App7 客户分析 |
| `BAILIAN_APP_ID_8_CONSOLIDATE` | `DASHSCOPE_APP_ID_8_CONSOLIDATE` | App8 维客线索整理 |

### 2.3 App1 对话管理：session_id 云端 + 本地双轨

**策略**：
1. 每次 App1 调用携带 `session_id`（百炼云端管理上下文）
2. 同时将消息写入本地 `ai_messages` 表（持久化）
3. `session_id` 过期（1 小时无请求）时，从本地 `ai_messages` 重建 `messages` 数组传给百炼
4. 对话复用规则不变：task 入口无时限复用、customer/coach 入口 3 天时限、general 入口始终新建

**session_id 生成规则**：
- 格式：`conv_{conversation_id}_{created_timestamp}`
- 存储在 `ai_conversations.session_id` 字段（新增）

**过期重建流程**：
```
用户发消息 → 查 ai_conversations 获取 session_id
  → 尝试用 session_id 调用百炼
    → 成功 → 正常返回
    → 失败（session 过期）→ 从 ai_messages 加载历史
      → 用 messages 数组（不带 session_id）调用百炼
      → 百炼返回新 session_id → 更新本地
```

### 2.4 App2~8 单轮调用

所有非对话类应用统一为单轮 `prompt` 调用：
- 后端用 `build_prompt()` 拼好完整数据 JSON
- 通过 `prompt` 参数传入 `Application.call`
- 百炼侧 System Prompt 已在控制台配置，代码不再维护

**JSON 兜底策略**：百炼返回非合法 JSON 时，纯重试，最大 3 次。不做本地解析修复。

### 2.5 App1 参数传递

App1 使用 `biz_params.user_prompt_params` 传模板变量：
- `User_ID`：用户 ID
- `Role`：角色（member / assistant / admin）
- `Nickname`：昵称

同时用 `prompt` 传页面上下文（source_page、page_context、screen_content）。

App2~8 不使用 `biz_params`，数据 JSON 直接作为 `prompt` 传入。

---

## 三、熔断 / 限流 / 降级

### 3.1 熔断器

```
连续 5 次失败 → 熔断 60 秒（所有请求直接返回降级响应）
→ 60 秒后进入半开状态 → 放行 1 个请求
  → 成功 → 关闭熔断
  → 失败 → 重新熔断 60 秒
```

熔断粒度：按 `app_id` 独立计数。App1 熔断不影响 App2~8。

### 3.2 Token 预算控制

| 维度 | 默认上限 | 说明 |
|------|---------|------|
| 日预算 | 100,000 tokens | 所有 App 合计 |
| 月预算 | 2,000,000 tokens | 所有 App 合计 |

超预算时：
- App1（用户对话）：返回友好提示"AI 服务今日额度已用完，请明天再试"
- App2~8（后台任务）：跳过执行，记录 `budget_exceeded` 状态

预算数据来源：`ai_run_logs.tokens_used` 按日/月聚合。

### 3.3 限流

- App1：每用户每分钟 10 次
- App2~8：每门店每小时 100 次（合计）

---

## 四、调度器修复

### 4.1 dispatcher.py asyncio 修复

当前问题：`dispatcher.py` 中存在 `asyncio.run()` 嵌套调用，在已有事件循环的 FastAPI 环境中会报错。

修复方案：
- 移除所有 `asyncio.run()` 调用
- 所有调度入口改为 `async def`
- 使用 `asyncio.create_task()` 发起异步任务
- 超时控制用 `asyncio.wait_for()`

### 4.2 事件触发打通

#### 消费事件（ETL 侧发射）

```
ETL DWS 任务完成
  → HTTP POST /api/internal/ai/trigger
    → payload: { event_type: "consumption", connector_type: "feiqiu", site_id, member_id, settlement_id }
  → 后端写 ai_trigger_jobs 记录
  → 异步执行调用链：App3 → App8 → App7（+ App4 → App5 如有助教）
```

内部 API 设计为连接器无关接口，`connector_type` 字段标识来源，为多平台扩展预留。

#### 备注事件（后端 API 路由发射）

```
小程序助教提交备注
  → 后端 API 路由 fire_event
    → event_type: "note_created"
  → 调用链：App6 → App8
```

#### 任务分配事件（task_manager 自动触发）

```
task_manager 自动分配任务
  → fire_event: "task_assigned"
  → 调用链：App4 → App5
```

### 4.3 App2 预生成

- 触发时机：DWS 完成后，ETL 通过内部 API 触发
- 门店范围：当前写死 `2790685415443269`（多门店支持记入 BACKLOG）
- 时间维度：8 个（今日/昨日/本周/上周/本月/上月/本季/上季）
- 每日调用量：1 门店 × 8 维度 = 8 次

### 4.4 幂等与去重

| 场景 | 策略 |
|------|------|
| 自动触发 | 按 `(event_type, member_id, site_id, date)` 去重，重复事件跳过 |
| 手动重跑 | 允许强制执行，`ai_trigger_jobs.is_forced = true`，后台明显标记 "forced" |
| App8 | 强幂等：DELETE + INSERT `member_retention_clue`，同一 member 同一天只执行一次 |

---

## 五、缓存策略

### 5.1 过期时间（按 App 分开）

| App | cache_type | expires_at 策略 |
|-----|-----------|----------------|
| App2 | app2_finance | 当日 23:59:59（每日刷新） |
| App3 | app3_clue | 7 天 |
| App4 | app4_analysis | 7 天 |
| App5 | app5_tactics | 7 天 |
| App6 | app6_note_analysis | 30 天 |
| App7 | app7_customer_analysis | 7 天 |
| App8 | app8_clue_consolidated | 7 天 |

### 5.2 ai_cache 新增字段

```sql
ALTER TABLE biz.ai_cache ADD COLUMN status VARCHAR(20) DEFAULT 'valid'
  CHECK (status IN ('valid', 'expired', 'invalidated', 'generating'));
```

- `valid`：有效缓存
- `expired`：已过期（定时任务标记）
- `invalidated`：手动失效（admin-web 操作）
- `generating`：正在生成中（防并发）

### 5.3 数据保留上限

| App | 保留策略 |
|-----|---------|
| App1 | 不自动删除（用户对话记录） |
| App2~8 | 每个 App 保留最新 20,000 条 `ai_cache` 记录 |

---

## 六、数据库变更

所有新表放在 `biz` schema。

### 6.1 新增表：ai_run_logs（AI 运行记录）

```sql
CREATE TABLE biz.ai_run_logs (
    id              BIGSERIAL PRIMARY KEY,
    site_id         BIGINT NOT NULL,
    app_type        VARCHAR(30) NOT NULL,       -- app1_chat / app2_finance / ...
    trigger_type    VARCHAR(20) NOT NULL,        -- user / scheduled / event / forced
    member_id       BIGINT,                      -- 关联会员（可空）
    request_prompt  TEXT,                        -- 输入 prompt（截断前 2000 字符）
    response_text   TEXT,                        -- 输出全文
    tokens_used     INTEGER DEFAULT 0,
    latency_ms      INTEGER,                     -- 响应耗时（毫秒）
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
                    -- pending / running / success / failed / timeout / budget_exceeded
    error_message   TEXT,
    session_id      VARCHAR(100),                -- App1 百炼 session_id
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    finished_at     TIMESTAMPTZ
);

CREATE INDEX idx_ai_run_logs_site_app ON biz.ai_run_logs(site_id, app_type);
CREATE INDEX idx_ai_run_logs_created ON biz.ai_run_logs(created_at);
CREATE INDEX idx_ai_run_logs_status ON biz.ai_run_logs(status);
```

### 6.2 新增表：ai_trigger_jobs（调度运行记录）

```sql
CREATE TABLE biz.ai_trigger_jobs (
    id              BIGSERIAL PRIMARY KEY,
    site_id         BIGINT NOT NULL,
    event_type      VARCHAR(30) NOT NULL,        -- consumption / note_created / task_assigned / scheduled / manual
    connector_type  VARCHAR(30) DEFAULT 'feiqiu', -- 连接器类型（多平台预留）
    member_id       BIGINT,
    payload         JSONB,                       -- 事件原始数据
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
                    -- pending / running / completed / failed / skipped_duplicate / budget_exceeded
    is_forced       BOOLEAN DEFAULT false,       -- 手动强制执行标记
    app_chain       VARCHAR(100),                -- 执行链：如 "app3→app8→app7"
    started_at      TIMESTAMPTZ,
    finished_at     TIMESTAMPTZ,
    error_message   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_ai_trigger_jobs_site ON biz.ai_trigger_jobs(site_id, event_type);
CREATE INDEX idx_ai_trigger_jobs_dedup ON biz.ai_trigger_jobs(event_type, member_id, site_id, (created_at::date))
    WHERE status NOT IN ('skipped_duplicate');
CREATE INDEX idx_ai_trigger_jobs_status ON biz.ai_trigger_jobs(status);
```

### 6.3 ai_conversations 新增字段

```sql
ALTER TABLE biz.ai_conversations ADD COLUMN session_id VARCHAR(100);
```

### 6.4 ai_cache 新增字段

```sql
ALTER TABLE biz.ai_cache ADD COLUMN status VARCHAR(20) DEFAULT 'valid'
    CHECK (status IN ('valid', 'expired', 'invalidated', 'generating'));
```

---

## 七、内部 API 设计

### 7.1 ETL → 后端触发接口

```
POST /api/internal/ai/trigger
Authorization: Internal-Token {INTERNAL_API_TOKEN}
Content-Type: application/json

{
    "event_type": "consumption",        // consumption / dws_completed
    "connector_type": "feiqiu",
    "site_id": 2790685415443269,
    "member_id": 12345,                 // 可选
    "payload": {                        // 事件附加数据
        "settlement_id": "xxx",
        "dws_task": "DWS_MEMBER_CONSUMPTION"
    }
}

Response 200:
{
    "trigger_job_id": 1001,
    "status": "pending"
}
```

### 7.2 认证方式

内部 API 使用独立的 `INTERNAL_API_TOKEN` 环境变量，不走 JWT。ETL 进程通过 HTTP Header 传递。

---

## 八、文件变更清单

### 需要重写的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/ai/bailian_client.py` | 完全重写为 `dashscope_client.py` |
| `apps/backend/app/ai/dispatcher.py` | asyncio 修复 + 事件触发链 |
| `apps/backend/app/ai/config.py` | 环境变量 BAILIAN_* → DASHSCOPE_* |

### 需要新增的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/ai/circuit_breaker.py` | 熔断器实现 |
| `apps/backend/app/ai/rate_limiter.py` | 限流器实现 |
| `apps/backend/app/ai/budget_tracker.py` | Token 预算追踪 |
| `apps/backend/app/routers/internal_ai.py` | 内部触发 API 路由 |
| `db/zqyy_app/migrations/YYYYMMDD_ai_run_logs.sql` | DDL 迁移脚本 |

### 需要修改的文件
| 文件 | 说明 |
|------|------|
| `apps/backend/app/services/ai/cache_service.py` | 新增 status 字段处理 |
| `apps/backend/app/services/ai/chat_service.py` | session_id 双轨逻辑 |
| `apps/etl/connectors/feiqiu/tasks/` | DWS 完成后 HTTP 触发 |
| `.env` / `.env.template` | 环境变量更新 |
| `pyproject.toml` | 依赖：移除 openai，新增 dashscope |

---

## 九、问题修复映射

本 PRD 覆盖的问题（来自 `docs/reports/2026-03-21__ai_module_issues.md`）：

| 问题编号 | 优先级 | 描述 | 本 PRD 对应章节 |
|---------|--------|------|----------------|
| P0-1 | P0 | App3~8 JSON 格式化失败 | §2.4（Application API 原生支持） |
| P0-2 | P0 | AI 事件未触发 | §4.2（事件触发打通） |
| P0-3 | P0 | App2 无定时机制 | §4.3（App2 预生成） |
| P0-4 | P0 | ETL→AI 联动断裂 | §4.2 + §7.1（内部 API） |
| P1-1 | P1 | 缓存过期未检查 | §5（缓存策略） |
| P1-2 | P1 | 无限流/熔断 | §3（熔断/限流/降级） |
| P1-3 | P1 | 无 Token 预算控制 | §3.2（Token 预算） |
| P1-4 | P1 | dispatcher asyncio 问题 | §4.1（asyncio 修复） |
| P1-5 | P1 | 前端无刷新机制 | 不在本 PRD 范围（前端改动） |
| P1-6 | P1 | FDW 数据获取不一致 | 不在本 PRD 范围（数据层） |
| P2-3 | P2 | 缓存清理过宽松 | §5.3（保留上限） |
| P2-5 | P2 | 对话记录无清理 | §5.3（App1 不删，其他 2 万条） |
| P3-1 | P3 | 迁移 DashScope SDK | §2.1（核心改动） |
| P3-2 | P3 | 调度器独立化 | §4（调度器修复） |

---

## 十、收尾标准流程

> 参考历史 Spec 收尾模板（P5、P13、RNS1.4）

### 10.1 DDL 迁移

1. 编写迁移脚本 `db/zqyy_app/migrations/YYYYMMDD_p14_ai_module.sql`
2. 在测试库 `test_zqyy_app` 执行并验证
3. 编写回滚脚本（逆序 DROP）
4. 合并到 DDL 基线 `db/zqyy_app/ddl/`

### 10.2 BD 手册更新

- 更新 `docs/database/BD_Manual_ai_tables.md`：新增 `ai_run_logs`、`ai_trigger_jobs` 表结构
- 更新 `ai_cache` 的 `status` 字段说明
- 更新 `ai_conversations` 的 `session_id` 字段说明

### 10.3 文档同步

| 文档 | 更新内容 |
|------|---------|
| `docs/prd/ai-app-prompts.md` | 环境变量映射更新（BAILIAN_* → DASHSCOPE_*） |
| `apps/backend/README.md` | AI 模块架构说明更新 |
| `docs/DOCUMENTATION-MAP.md` | 新增文档条目 |
| `.env.template` | 环境变量模板更新 |
| `docs/deployment/EXPORT-PATHS.md` | 如有新输出路径则更新 |

### 10.4 属性测试

- 更新 `tests/` 下 AI 相关属性测试，适配新的 `dashscope` 调用方式
- 新增属性测试覆盖：熔断器状态转换、Token 预算计算、去重逻辑

### 10.5 最终检查点

- [ ] 所有 8 个 App 通过 Application API 调用成功
- [ ] App1 流式输出正常，session_id 双轨工作
- [ ] App2~8 返回合法 JSON
- [ ] 事件触发链完整（消费→App3→App8→App7）
- [ ] ETL → 后端内部 API 联通
- [ ] 熔断器在连续失败后正确触发
- [ ] Token 预算超限后正确降级
- [ ] 环境变量全部切换到 DASHSCOPE_*
- [ ] DDL 迁移脚本在测试库执行通过
- [ ] BD 手册已更新
- [ ] 属性测试全部通过

---

## 十一、风险与缓解

| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| DashScope SDK 同步调用阻塞事件循环 | App1 响应延迟 | `asyncio.to_thread()` + 线程池大小限制 |
| session_id 过期重建增加 token 消耗 | 成本上升 | 限制重建时的历史消息条数（最近 20 条） |
| 百炼控制台 System Prompt 与代码不一致 | 输出质量下降 | 代码不再维护 System Prompt，以控制台为准 |
| 环境变量一步切换导致部署遗漏 | 服务不可用 | 部署检查清单 + 启动时校验必需变量 |
| App8 写业务表（member_retention_clue）幂等失败 | 数据不一致 | 事务包裹 DELETE + INSERT，失败自动回滚 |

---

## 十二、不在本 PRD 范围

以下内容由 P15 或后续 PRD 处理：

- admin-web AI 监控后台（P15）
- 全链路测试重建（P15）
- 历史回填（P15）
- 旧测试脚本归档（P15）
- 多门店支持（BACKLOG）
- 消息队列（单独 PRD）
- Prompt 版本管理（BACKLOG）
- 前端刷新机制（P1-5，前端改动）
- FDW 数据一致性（P1-6，数据层改动）
