chore: 文档与 IDE 配置整理

- .kiro/specs/ → docs/specs/(41 个历史需求 spec 迁移,移除 .config.kiro)
- CLAUDE.md 三层拆分:根文件精简 + apps/backend/CLAUDE.md + .claude/commands/
- 新增 /spec-close、/pre-change 两个工作流命令
- DDL 基线刷新(从测试库重新导出 11 个文件,dws 35→38 表,biz 18→21 表)
- BD_Manual → BD_manual 命名统一(48 个文件)
- 修复 3 处文档与数据库不一致(auth.users.status 默认值、scheduled_tasks 字段、RLS 视图数)
- 新增 BD_manual_public_rbac_tables.md(public schema 8 张 RBAC/工作流表)
- 合并 biz.trigger_jobs 文档(10→12 字段,归档独立文档)
- docs/database/README.md 索引更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:02:37 +08:00
parent 8228b3fa37
commit 70324d8542
185 changed files with 13595 additions and 1219 deletions

View File

@@ -0,0 +1,753 @@
# 设计文档 — P14AI 模块改造 — DashScope 迁移 + 调度器完善
## 概述
本设计将 AI 模块从 `openai` SDK通用模型 API迁移到 `dashscope` SDKApplication API使 8 个百炼智能体应用能通过各自的 `app_id` 调用,充分利用百炼控制台配置的 System Prompt 和 MCP 工具。同时修复调度器 asyncio 嵌套问题、打通事件触发链、新增熔断/限流/Token 预算控制,并完成相关数据库变更。
### 设计决策与理由
| 决策 | 理由 |
|------|------|
| `asyncio.to_thread()` 包装同步 SDK | `dashscope.Application.call()` 是同步方法FastAPI 全 async`to_thread` 是标准库方案,无需引入第三方异步包装 |
| 流式调用用 `asyncio.Queue` 桥接 | `Application.call(stream=True)` 返回同步迭代器,需在线程中消费后桥接到 async generator保持 SSE 端点的 async 特性 |
| 熔断器内存实现(非 Redis | 单实例部署,无需分布式状态;按 `app_id` 独立计数,结构简单 |
| 限流器内存计数器 | 同上,单实例部署;滑动窗口计数器足够 |
| Token 预算从 `ai_run_logs` 聚合 | 避免引入额外计数表,利用已有日志数据;查询频率低(每次调用前一次) |
| session_id 云端 + 本地双轨 | 百炼云端管理上下文减少 token 消耗,本地持久化保证 session 过期后可恢复 |
| 内部 API 用 `INTERNAL_API_TOKEN` 而非 JWT | ETL 进程是内部服务,不需要用户身份;简单 token 认证足够且配置简单 |
| App2~8 纯重试不做本地 JSON 修复 | 百炼控制台已配置 System Prompt 要求 JSON 输出,本地修复容易引入错误数据 |
## 架构
### 整体架构
```mermaid
graph TB
subgraph "外部触发"
ETL["ETL DWS 任务"]
XCX["小程序助教"]
TM["task_manager"]
end
subgraph "入口层"
SSE["SSE 端点<br/>POST /api/xcx/chat/stream"]
IAPI["内部触发 API<br/>POST /api/internal/ai/trigger"]
EVT["事件发射<br/>fire_event()"]
end
subgraph "防护层"
CB["CircuitBreaker<br/>按 app_id 独立"]
RL["RateLimiter<br/>用户/门店维度"]
BT["BudgetTracker<br/>日/月 token 预算"]
end
subgraph "核心层"
DSC["DashScopeClient<br/>Application.call() 包装"]
DISP["Dispatcher<br/>事件调度 + 调用链编排"]
end
subgraph "服务层"
CONV["ConversationService<br/>session_id 双轨"]
CACHE["AICacheService<br/>status 字段 + 过期策略"]
end
subgraph "存储层"
DB_CONV["ai_conversations<br/>+session_id"]
DB_MSG["ai_messages"]
DB_CACHE["ai_cache<br/>+status"]
DB_LOG["ai_run_logs<br/>新表"]
DB_JOB["ai_trigger_jobs<br/>新表"]
DB_CLUE["member_retention_clue"]
end
ETL -->|HTTP POST| IAPI
XCX -->|API 路由| EVT
TM -->|fire_event| EVT
SSE --> CB --> RL --> BT --> DSC
IAPI --> DISP
EVT --> DISP
DISP --> CB
DSC -->|App1 流式| SSE
DSC -->|App2~8 单轮| DISP
DISP --> CONV
DISP --> CACHE
DSC --> DB_LOG
CONV --> DB_CONV
CONV --> DB_MSG
CACHE --> DB_CACHE
DISP --> DB_JOB
DISP --> DB_CLUE
```
### 调用链流程
```mermaid
sequenceDiagram
participant ETL as ETL DWS
participant API as Internal AI API
participant DISP as Dispatcher
participant CB as CircuitBreaker
participant RL as RateLimiter
participant BT as BudgetTracker
participant DSC as DashScopeClient
participant DB as PostgreSQL
ETL->>API: POST /api/internal/ai/trigger
API->>DB: INSERT ai_trigger_jobs (pending)
API-->>ETL: {trigger_job_id, status: "pending"}
API->>DISP: asyncio.create_task(handle_event)
DISP->>DISP: 去重检查 (event_type, member_id, site_id, date)
loop 调用链每一步 (App3→App8→App7)
DISP->>CB: 检查 app_id 熔断状态
CB-->>DISP: closed/open/half_open
DISP->>RL: 检查门店限流
RL-->>DISP: allowed/rejected
DISP->>BT: 检查 token 预算
BT-->>DISP: within_budget/exceeded
DISP->>DB: INSERT ai_run_logs (pending→running)
DISP->>DSC: Application.call(app_id, prompt)
DSC-->>DISP: response
DISP->>DB: UPDATE ai_run_logs (success/failed)
DISP->>DB: UPSERT ai_cache
end
DISP->>DB: UPDATE ai_trigger_jobs (completed)
```
## 组件与接口
### 1. DashScopeClient替代 BailianClient
文件:`apps/backend/app/ai/dashscope_client.py`(完全重写 `bailian_client.py`
```python
class DashScopeClient:
"""DashScope Application API 统一封装层。"""
MAX_RETRIES = 3
BASE_INTERVAL = 1 # 秒
def __init__(self, api_key: str, workspace_id: str | None = None):
"""初始化。dashscope 通过全局 dashscope.api_key 设置密钥。"""
async def call_app_stream(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> AsyncGenerator[str, None]:
"""App1 流式调用。
在线程中消费同步迭代器,通过 asyncio.Queue 桥接到 async generator。
"""
async def call_app(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> tuple[dict, int, str | None]:
"""App2~8 单轮调用。
返回 (parsed_json, tokens_used, new_session_id)。
"""
async def _call_with_retry(self, func: Callable, **kwargs) -> Any:
"""指数退避重试1s→2s→4sHTTP 4xx 不重试5xx/超时/连接错误重试。"""
```
流式桥接实现要点:
```python
async def call_app_stream(self, app_id, prompt, session_id=None, biz_params=None):
queue: asyncio.Queue[str | None] = asyncio.Queue()
def _consume_in_thread():
"""在线程中消费同步迭代器,逐 chunk 放入 queue。"""
response = Application.call(
app_id=app_id, prompt=prompt, session_id=session_id,
biz_params=biz_params, stream=True, incremental_output=True,
)
for chunk in response:
if chunk.status_code == 200:
text = chunk.output.get("text", "")
if text:
# 线程安全地放入 queue
asyncio.run_coroutine_threadsafe(queue.put(text), loop)
else:
raise DashScopeApiError(chunk.message, chunk.status_code)
asyncio.run_coroutine_threadsafe(queue.put(None), loop) # 结束信号
loop = asyncio.get_running_loop()
loop.run_in_executor(None, _consume_in_thread)
while True:
item = await queue.get()
if item is None:
break
yield item
```
### 2. CircuitBreaker熔断器
文件:`apps/backend/app/ai/circuit_breaker.py`(新增)
```python
class CircuitState(enum.Enum):
CLOSED = "closed" # 正常
OPEN = "open" # 熔断中
HALF_OPEN = "half_open" # 探测中
class CircuitBreaker:
"""按 app_id 独立的熔断器。"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
self._breakers: dict[str, _BreakerState] = {}
def check(self, app_id: str) -> CircuitState:
"""检查当前状态OPEN 时直接拒绝。"""
def record_success(self, app_id: str) -> None:
"""记录成功HALF_OPEN→CLOSED。"""
def record_failure(self, app_id: str) -> None:
"""记录失败连续达阈值→OPENHALF_OPEN 失败→重新 OPEN。"""
class _BreakerState:
state: CircuitState
failure_count: int
last_failure_time: float
last_state_change: float
```
### 3. RateLimiter限流器
文件:`apps/backend/app/ai/rate_limiter.py`(新增)
```python
class RateLimiter:
"""滑动窗口内存限流器。"""
def __init__(self):
self._user_windows: dict[str, deque[float]] = {} # App1: user_id → 时间戳队列
self._store_windows: dict[str, deque[float]] = {} # App2~8: site_id → 时间戳队列
def check_user_rate(self, user_id: str, limit: int = 10, window_seconds: int = 60) -> bool:
"""App1 每用户每分钟限流。返回 True 表示允许。"""
def check_store_rate(self, site_id: int, limit: int = 100, window_seconds: int = 3600) -> bool:
"""App2~8 每门店每小时限流。返回 True 表示允许。"""
```
### 4. BudgetTrackerToken 预算追踪)
文件:`apps/backend/app/ai/budget_tracker.py`(新增)
```python
class BudgetTracker:
"""Token 预算追踪器,从 ai_run_logs 聚合。"""
def __init__(
self,
daily_limit: int = 100_000,
monthly_limit: int = 2_000_000,
):
self.daily_limit = daily_limit
self.monthly_limit = monthly_limit
def check_budget(self) -> BudgetStatus:
"""检查当前预算状态。返回 BudgetStatus(allowed, daily_used, monthly_used)。"""
def _get_daily_usage(self) -> int:
"""从 ai_run_logs 聚合今日 token 消耗。"""
def _get_monthly_usage(self) -> int:
"""从 ai_run_logs 聚合本月 token 消耗。"""
@dataclass
class BudgetStatus:
allowed: bool
daily_used: int
monthly_used: int
reason: str | None = None # "daily_exceeded" / "monthly_exceeded" / None
```
### 5. Dispatcher调度器重写
文件:`apps/backend/app/ai/dispatcher.py`(重写)
关键变更:
- 移除所有 `asyncio.run()``asyncio.new_event_loop()`
- 所有入口改为 `async def`,用 `asyncio.create_task()` 发起后台任务
- 超时用 `asyncio.wait_for()`
- 集成 CircuitBreaker、RateLimiter、BudgetTracker
- 新增 `ai_trigger_jobs` 记录
- 新增去重逻辑
```python
class AIDispatcher:
"""AI 事件调度与调用链编排器。"""
def __init__(
self,
client: DashScopeClient,
cache_svc: AICacheService,
conv_svc: ConversationService,
circuit_breaker: CircuitBreaker,
rate_limiter: RateLimiter,
budget_tracker: BudgetTracker,
): ...
async def handle_trigger(self, event: TriggerEvent) -> int:
"""统一事件入口。写 ai_trigger_jobs 后异步执行调用链。返回 trigger_job_id。"""
async def _execute_chain(self, job_id: int, event: TriggerEvent) -> None:
"""执行调用链,根据 event_type 分发。"""
async def _check_dedup(self, event: TriggerEvent) -> bool:
"""去重检查:(event_type, member_id, site_id, date)。"""
async def _run_step(self, app_name: str, app_id: str, prompt: str, context: dict) -> dict | None:
"""执行单步:熔断检查→限流检查→预算检查→调用→记录日志。"""
# 事件处理器
async def _handle_consumption(self, event: TriggerEvent) -> None: ...
async def _handle_note(self, event: TriggerEvent) -> None: ...
async def _handle_task_assigned(self, event: TriggerEvent) -> None: ...
async def _handle_dws_completed(self, event: TriggerEvent) -> None: ...
```
### 6. AI Config环境变量
文件:`apps/backend/app/ai/config.py`(重写)
```python
@dataclass(frozen=True)
class AIConfig:
"""AI 模块配置,从环境变量加载。"""
api_key: str # DASHSCOPE_API_KEY
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_3_clue: str # ...
app_id_4_analysis: str
app_id_5_tactics: str
app_id_6_note: str
app_id_7_customer: str
app_id_8_consolidate: str
internal_api_token: str # INTERNAL_API_TOKEN
@classmethod
def from_env(cls) -> "AIConfig":
"""从环境变量加载,缺失必需变量时立即报错。"""
```
### 7. Internal AI API内部触发接口
文件:`apps/backend/app/routers/internal_ai.py`(新增)
```python
router = APIRouter(prefix="/api/internal/ai", tags=["internal-ai"])
@router.post("/trigger")
async def trigger_ai_event(body: TriggerRequest, token: str = Depends(verify_internal_token)):
"""接收 ETL/内部事件,写 ai_trigger_jobs 后异步执行。"""
class TriggerRequest(BaseModel):
event_type: str # consumption / dws_completed / note_created / task_assigned
connector_type: str = "feiqiu"
site_id: int
member_id: int | None = None
payload: dict | None = None
def verify_internal_token(authorization: str = Header(...)) -> str:
"""校验 Internal-Token {token}"""
```
### 8. SSE 端点适配
文件:`apps/backend/app/routers/xcx_chat.py`(修改)
变更要点:
- `_get_bailian_client()``_get_dashscope_client()`
- `bailian.chat_stream(messages)``client.call_app_stream(app_id, prompt, session_id, biz_params)`
- `_build_ai_messages()` 改为构建 `prompt` + `biz_params`
- 流结束后记录 `ai_run_logs`
- 保持 SSE 事件格式不变:`event: message` / `event: done` / `event: error`
### 9. ETL 触发集成
文件:`apps/etl/connectors/feiqiu/tasks/` 相关 DWS 任务(修改)
变更要点:
- DWS 任务完成后,通过 `httpx` 发送 `POST /api/internal/ai/trigger`
- 携带 `Authorization: Internal-Token {INTERNAL_API_TOKEN}` Header
- 事件类型:`dws_completed`(触发 App2 预生成)或 `consumption`(触发消费事件链)
### 10. AI 运行日志服务
文件:`apps/backend/app/ai/run_log_service.py`(新增)
```python
class AIRunLogService:
"""AI 运行日志 CRUD。"""
def create_log(self, site_id: int, app_type: str, trigger_type: str, **kwargs) -> int:
"""创建日志记录status: pending返回 log_id。"""
def update_running(self, log_id: int) -> None:
"""更新为 running。"""
def update_success(self, log_id: int, response_text: str, tokens_used: int, latency_ms: int) -> None:
"""更新为 success。"""
def update_failed(self, log_id: int, error_message: str, latency_ms: int) -> None:
"""更新为 failed。"""
def update_timeout(self, log_id: int, latency_ms: int) -> None:
"""更新为 timeout。"""
def get_daily_token_usage(self) -> int:
"""聚合今日 token 消耗。"""
def get_monthly_token_usage(self) -> int:
"""聚合本月 token 消耗。"""
```
## 数据模型
### 新增表
#### biz.ai_run_logsAI 运行记录)
```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, -- 截断前 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),
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);
```
#### biz.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,
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);
```
### 已有表变更
#### biz.ai_conversations — 新增字段
```sql
ALTER TABLE biz.ai_conversations ADD COLUMN session_id VARCHAR(100);
```
- 存储百炼 session_id格式 `conv_{conversation_id}_{created_timestamp}`
- 仅 App1 使用
#### biz.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`:正在生成中(防并发读取不完整数据)
### 缓存过期策略
| 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 天 |
### 环境变量映射
| 旧变量 | 新变量 | 说明 |
|--------|--------|------|
| `BAILIAN_API_KEY` | `DASHSCOPE_API_KEY` | API Key |
| `BAILIAN_BASE_URL` | _(删除)_ | Application API 不需要 base_url |
| `BAILIAN_MODEL` | _(删除)_ | 通过 app_id 指定应用 |
| _(新增)_ | `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 |
| _(新增)_ | `INTERNAL_API_TOKEN` | 内部 API 认证 token |
## 正确性属性
*属性Property是在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 重试策略正确性
*对于任意* DashScopeClient 调用和任意错误类型序列HTTP 4xx 错误应立即抛出不重试HTTP 5xx/超时/连接错误应最多重试 3 次(间隔 1s→2s→4s非合法 JSON 响应应触发重试而非本地修复。
**Validates: Requirements 1.5, 1.6**
### Property 2: 环境变量校验完整性
*对于任意* 必需环境变量(`DASHSCOPE_API_KEY` 和 8 个 `DASHSCOPE_APP_ID_*`)的子集缺失,`AIConfig.from_env()` 应抛出异常,不返回包含空字符串的配置对象。
**Validates: Requirements 2.5**
### Property 3: session_id 格式不变量
*对于任意* conversation_id正整数和 created_timestamp生成的 session_id 应匹配正则 `^conv_\d+_\d+$`,且从 session_id 可以反解出原始 conversation_id。
**Validates: Requirements 3.1**
### Property 4: 对话复用规则
*对于任意* 入口类型和时间差组合对话复用决策应满足task 入口始终复用已有对话、customer/coach 入口在 3 天内复用、general 入口始终新建。
**Validates: Requirements 3.6**
### Property 5: 熔断器 app_id 隔离
*对于任意* 两个不同的 app_id对其中一个记录任意次数的失败不应改变另一个 app_id 的熔断状态。
**Validates: Requirements 5.1**
### Property 6: 熔断器状态机转换
*对于任意* app_id 和任意成功/失败事件序列,熔断器状态转换应满足:连续 5 次失败→OPENOPEN 经过 60 秒→HALF_OPENHALF_OPEN + 成功→CLOSEDHALF_OPEN + 失败→OPEN。且 CLOSED 状态下任何成功事件应重置失败计数。
**Validates: Requirements 5.2, 5.3, 5.4, 5.5**
### Property 7: 限流器窗口控制
*对于任意* 用户 ID 或门店 ID在滑动窗口内App1: 60 秒/10 次App2~8: 3600 秒/100 次)请求次数未超过阈值时应允许,超过阈值时应拒绝;窗口外的历史请求不影响当前判断。
**Validates: Requirements 6.1, 6.2**
### Property 8: Token 预算检查正确性
*对于任意* 一组 `ai_run_logs` 记录(含 `tokens_used``created_at`),日聚合应等于当日所有 `status='success'` 记录的 `tokens_used` 之和,月聚合应等于当月之和;当日聚合 ≥ 100,000 或月聚合 ≥ 2,000,000 时,`check_budget()` 应返回 `allowed=false`
**Validates: Requirements 7.1, 7.3**
### Property 9: 事件类型到调用链映射
*对于任意* 事件类型Dispatcher 应执行正确的调用链:`consumption`(无助教)→ App3→App8→App7`consumption`(有助教)→ App3→App8→App7 + App4→App5`note_created` → App6→App8`task_assigned` → App4→App5`dws_completed` → App28 个时间维度)。
**Validates: Requirements 9.1, 9.2, 9.3, 11.1**
### Property 10: 调用链容错不中断
*对于任意* 调用链和任意失败步骤位置,该步骤失败后链中后续步骤仍应继续执行(使用已有缓存),不中断整条链。
**Validates: Requirements 9.4**
### Property 11: 内部 API 认证
*对于任意* HTTP 请求到 `/api/internal/ai/trigger`,当 `Authorization` Header 缺失或 token 不匹配 `INTERNAL_API_TOKEN` 时应返回 HTTP 401token 匹配时应正常处理请求。
**Validates: Requirements 10.2, 10.3**
### Property 12: 事件去重与强制执行
*对于任意* 两个具有相同 `(event_type, member_id, site_id, date)` 的自动触发事件,第二个应被跳过(`skipped_duplicate`);但当 `is_forced=true` 时,即使存在重复也应正常执行。
**Validates: Requirements 12.1, 12.2**
### Property 13: App8 幂等写入
*对于任意* member_id 和日期,多次执行 App8 写入 `member_retention_clue` 后,该 member 该天的记录数应恒为 1DELETE + INSERT 事务保证)。
**Validates: Requirements 12.3**
### Property 14: 缓存过期策略正确性
*对于任意* App 类型和写入时间,缓存记录的 `expires_at` 应匹配该 App 的过期策略App2 为当日 23:59:59、App3/4/5/7/8 为写入时间 + 7 天、App6 为写入时间 + 30 天。
**Validates: Requirements 13.1, 11.3**
### Property 15: 缓存查询过滤
*对于任意* 缓存数据集(包含各种 `status``expires_at` 值),查询结果应仅包含 `status='valid'``expires_at > now()``expires_at IS NULL` 的记录;`generating``expired``invalidated` 状态的记录不应出现在查询结果中。
**Validates: Requirements 13.4**
### Property 16: 缓存保留上限
*对于任意* App 类型App2~8和任意数量的缓存写入清理后每个 App 的 `ai_cache` 记录数不超过 20,000 条App1 对话记录不受此限制。
**Validates: Requirements 13.5**
### Property 17: SSE 事件流格式
*对于任意* 流式对话输出序列包括正常完成和中途错误SSE 事件流应由零或多个 `event: message` 事件组成,最终以恰好一个 `event: done`(正常)或 `event: error`(异常)事件结束。
**Validates: Requirements 15.2**
### Property 18: AI 运行日志状态机
*对于任意* AI 调用(成功/失败/超时/预算超限),`ai_run_logs` 记录的状态转换应满足:正常调用经历 `pending→running→success/failed/timeout`;预算超限直接创建 `budget_exceeded` 状态。且 `success` 状态的记录必须包含 `response_text``tokens_used``latency_ms``finished_at``failed` 状态必须包含 `error_message`
**Validates: Requirements 16.1, 16.2, 16.3, 16.4, 16.5**
### Property 19: Prompt 截断不变量
*对于任意* 长度的 prompt 字符串,存储到 `ai_run_logs.request_prompt` 后的长度不超过 2000 字符;长度 ≤ 2000 的 prompt 应完整保留。
**Validates: Requirements 16.6**
### Property 20: Application API 响应解析
*对于任意* 合法的 `Application.call()` 响应对象(`response.output.text` 为 JSON 字符串),解析应正确提取并返回等价的 Python dict`response.output.text` 为非法 JSON 时应触发重试。
**Validates: Requirements 4.4**
## 错误处理
### 异常层级
```
DashScopeError (基类)
├── DashScopeApiError — Application API 调用失败(重试耗尽)
│ ├── DashScopeAuthError — API Key 无效HTTP 401
│ └── DashScopeTimeoutError — 调用超时
├── DashScopeJsonParseError — 响应 JSON 解析失败(重试耗尽)
├── CircuitOpenError — 熔断器处于 OPEN 状态
├── RateLimitExceededError — 限流阈值超限
└── BudgetExceededError — Token 预算超限
```
### 错误处理策略
| 错误类型 | App1用户对话 | App2~8后台任务 |
|---------|-----------------|-------------------|
| `DashScopeApiError` (5xx) | SSE `event: error` + 友好提示 | 记录 `failed``ai_run_logs`,链继续 |
| `DashScopeAuthError` (401) | SSE `event: error` + "AI 服务配置异常" | 记录 `failed`,链中断(全局配置问题) |
| `DashScopeTimeoutError` | SSE `event: error` + "AI 响应超时" | 记录 `timeout`,链继续 |
| `DashScopeJsonParseError` | 不适用App1 不解析 JSON | 重试 3 次后记录 `failed`,链继续 |
| `CircuitOpenError` | SSE `event: error` + "AI 服务暂时不可用" | 记录 `circuit_open`,跳过该步骤 |
| `RateLimitExceededError` | HTTP 429 + "请求过于频繁" | 记录 `rate_limited`,跳过执行 |
| `BudgetExceededError` | SSE `event: error` + "AI 额度已用完" | 记录 `budget_exceeded`,跳过执行 |
| session_id 过期 | 从本地加载历史重建,对用户透明 | 不适用App2~8 无 session |
| App8 幂等写入失败 | 不适用 | 事务回滚,记录错误到 `ai_trigger_jobs` |
### 降级策略
- 熔断状态下App1 返回友好提示App2~8 跳过执行,前端展示已有缓存
- 预算超限App1 返回"额度已用完"App2~8 跳过,记录 `budget_exceeded`
- 限流超限App1 返回"请求过于频繁"App2~8 跳过,记录 `rate_limited`
- 调用链某步失败:记录错误,后续步骤使用已有缓存继续
## 测试策略
### 属性测试Property-Based Testing
使用 `hypothesis` 库(项目已有依赖),每个属性测试最少运行 100 次迭代。
测试文件位于 `tests/` 目录Monorepo 级属性测试),按组件分文件:
| 测试文件 | 覆盖属性 |
|---------|---------|
| `tests/test_circuit_breaker_props.py` | Property 5隔离、Property 6状态机 |
| `tests/test_rate_limiter_props.py` | Property 7窗口控制 |
| `tests/test_budget_tracker_props.py` | Property 8预算检查 |
| `tests/test_dispatcher_props.py` | Property 9调用链映射、Property 10容错、Property 12去重 |
| `tests/test_cache_service_props.py` | Property 14过期策略、Property 15查询过滤、Property 16保留上限 |
| `tests/test_dashscope_client_props.py` | Property 1重试、Property 20响应解析 |
| `tests/test_ai_config_props.py` | Property 2环境变量校验 |
| `tests/test_session_props.py` | Property 3session_id 格式、Property 4对话复用 |
| `tests/test_run_log_props.py` | Property 18日志状态机、Property 19Prompt 截断) |
| `tests/test_sse_props.py` | Property 17SSE 格式) |
每个属性测试必须包含注释标签:
```python
# Feature: P14-ai-dashscope-migration, Property 6: 熔断器状态机转换
@given(st.lists(st.sampled_from(["success", "failure"]), min_size=1, max_size=50))
def test_circuit_breaker_state_machine(events):
...
```
### 单元测试
单元测试覆盖属性测试不适合的场景:
| 测试文件 | 覆盖内容 |
|---------|---------|
| `tests/test_internal_ai_api.py` | Property 11认证、API 端点集成 |
| `tests/test_app8_idempotent.py` | Property 13幂等写入、事务回滚 |
| `tests/test_sse_endpoint.py` | SSE 端点集成、错误事件 |
| `tests/test_session_recovery.py` | session_id 过期恢复流程Requirements 3.5 |
| `tests/test_app2_pregenerate.py` | App2 预生成 8 个时间维度Requirements 11.2 |
### 测试配置
```python
# conftest.py 中的 hypothesis 配置
from hypothesis import settings
settings.register_profile("ci", max_examples=200, deadline=10000)
settings.register_profile("dev", max_examples=100, deadline=5000)
```
### 测试依赖
- 属性测试:`hypothesis`(已有)
- Mock`unittest.mock`(标准库)
- DashScope 调用mock `Application.call()`,不依赖真实 API
- 数据库:使用 `test_zqyy_app` 测试库(遵循 `testing-env.md` 规范)

View File

@@ -0,0 +1,246 @@
# 需求文档 — P14AI 模块改造 — DashScope 迁移 + 调度器完善
## 简介
当前 AI 模块使用 `openai` SDK 的通用模型 API`chat.completions.create`),但项目的 8 个 App 均为百炼控制台创建的智能体应用(各有独立 `app_id`)。通用模型 API 不接受 `app_id`,等于绕过了百炼控制台配置的 System Prompt、MCP 工具等全部能力。
本 spec 将 SDK 从 `openai` 切换到 `dashscope`Application API一步到位完成迁移同时修复调度器 asyncio 嵌套问题、打通事件触发链、新增熔断/限流/Token 预算控制,并完成相关数据库变更。
### 依赖
- P5AI 集成层)— 现有 AI 模块基础架构、8 个 App 实现、缓存/对话服务
- RNS1.4CHAT 对齐)— CHAT 路径迁移、SSE 端点、对话复用规则
### 来源文档
- `docs/prd/specs/P14-ai-dashscope-migration.md` — PRD 主文档
- `docs/reports/2026-03-21__ai_module_issues.md` — 18 个问题清单4 P0 / 6 P1 / 5 P2 / 3 P3
### 不在本 spec 范围
- admin-web AI 监控后台P15
- 全链路测试重建与历史回填P15
- 多门店支持BACKLOG当前写死 `2790685415443269`
- 消息队列替代 HTTP 触发(单独 PRD
- Prompt 版本管理BACKLOG
- 前端刷新机制(前端改动,不在本 spec
## 术语表
- **Backend**FastAPI 后端应用,位于 `apps/backend/`
- **ETL**:飞球 Connector ETL 管道,位于 `apps/etl/connectors/feiqiu/`
- **DashScope_Client**:新的 DashScope Application API 客户端,替代现有 `BailianClient`
- **Application_API**`dashscope.Application.call()` 方法,百炼智能体应用的原生调用接口
- **App1**:通用对话应用(流式,支持多轮 session_id
- **App2**财务洞察应用单轮DWS 完成后预生成)
- **App3**:维客线索应用(单轮,消费事件触发)
- **App4**:关系分析应用(单轮,消费/任务事件触发)
- **App5**:话术参考应用(单轮,依赖 App4 结果)
- **App6**:备注分析应用(单轮,备注事件触发)
- **App7**:客户分析应用(单轮,消费事件触发)
- **App8**:维客线索整理应用(单轮,整合 App3/App6 线索)
- **session_id**:百炼云端对话管理标识,格式 `conv_{conversation_id}_{created_timestamp}`
- **Circuit_Breaker**:熔断器,按 app_id 独立计数,连续失败后暂停请求
- **Rate_Limiter**:限流器,按用户/门店维度控制请求频率
- **Budget_Tracker**Token 预算追踪器,按日/月聚合 token 消耗
- **Internal_AI_API**:内部触发接口 `POST /api/internal/ai/trigger`ETL 通过此接口触发 AI 调用链
- **Dispatcher**AI 事件调度与调用链编排器,位于 `apps/backend/app/ai/dispatcher.py`
- **ai_run_logs**AI 运行记录表,记录每次 Application API 调用的输入/输出/耗时/token
- **ai_trigger_jobs**:调度运行记录表,记录事件触发的调用链执行状态
## 需求
### 需求 1SDK 替换 — openai 切换到 dashscope Application API
**用户故事:** 作为后端开发者,我希望将 AI 客户端从 `openai` SDK 切换到 `dashscope` Application API以便 8 个百炼智能体应用能通过各自的 `app_id` 调用,使用百炼控制台配置的 System Prompt 和 MCP 工具。
#### 验收标准
1. THE DashScope_Client SHALL 使用 `dashscope.Application.call()` 替代 `openai.AsyncOpenAI.chat.completions.create()`,所有 8 个 App 通过各自的 `app_id` 参数调用 Application API
2. THE DashScope_Client SHALL 使用 `asyncio.to_thread()` 包装同步的 `Application.call()` 方法,避免阻塞 FastAPI 事件循环
3. WHEN App1 进行流式调用时THE DashScope_Client SHALL 在独立线程中消费 `Application.call(stream=True)` 返回的同步迭代器,通过 `asyncio.Queue` 桥接到 async generator逐 chunk 输出文本
4. WHEN App2~8 进行单轮调用时THE DashScope_Client SHALL 通过 `prompt` 参数传入后端拼好的完整数据 JSON不使用 `messages` 数组
5. THE DashScope_Client SHALL 保留指数退避重试机制:最多 3 次重试,间隔 1s → 2s → 4sHTTP 4xx 不重试5xx/超时/连接错误重试
6. WHEN Application API 返回非合法 JSON 时THE DashScope_Client SHALL 纯重试(最大 3 次),不做本地解析修复
7. THE Backend SHALL 在 `pyproject.toml` 中移除 `openai` 依赖,新增 `dashscope` 依赖
### 需求 2环境变量统一 — BAILIAN_* 迁移到 DASHSCOPE_*
**用户故事:** 作为运维人员,我希望环境变量从 `BAILIAN_*` 统一迁移到 `DASHSCOPE_*` 前缀,以便与 DashScope SDK 的命名规范保持一致。
#### 验收标准
1. THE Backend SHALL 废弃并删除以下环境变量:`BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL`
2. THE Backend SHALL 新增以下环境变量:`DASHSCOPE_API_KEY`DashScope API Key`DASHSCOPE_WORKSPACE_ID`(百炼工作空间 ID可选
3. THE Backend SHALL 将 8 个 App ID 环境变量从 `BAILIAN_APP_ID_*` 前缀重命名为 `DASHSCOPE_APP_ID_*` 前缀(`DASHSCOPE_APP_ID_1_CHAT``DASHSCOPE_APP_ID_8_CONSOLIDATE`
4. THE Backend SHALL 更新 `.env``.env.template` 文件,反映所有环境变量变更
5. THE Backend SHALL 在启动时校验必需环境变量(`DASHSCOPE_API_KEY` 和 8 个 `DASHSCOPE_APP_ID_*`),缺失时立即报错,禁止静默回退空字符串
### 需求 3App1 对话管理 — session_id 云端 + 本地双轨
**用户故事:** 作为助教用户,我希望与 AI 助手的多轮对话能通过百炼 session_id 保持上下文连贯,同时本地持久化消息记录,以便在 session 过期后仍能恢复对话。
#### 验收标准
1. WHEN App1 创建新对话时THE Backend SHALL 生成 session_id格式 `conv_{conversation_id}_{created_timestamp}`),存储到 `ai_conversations.session_id` 字段
2. WHEN App1 发送消息时THE DashScope_Client SHALL 携带 `session_id` 参数调用 Application API由百炼云端管理对话上下文
3. THE DashScope_Client SHALL 通过 `biz_params.user_prompt_params` 传递用户信息:`User_ID`(用户 ID`Role`(角色)、`Nickname`(昵称)
4. THE Backend SHALL 同时将每条消息写入本地 `ai_messages` 表,实现云端 + 本地双轨持久化
5. IF session_id 过期(百炼返回 session 无效错误THEN THE Backend SHALL 从本地 `ai_messages` 加载最近 20 条历史消息,用 `messages` 数组(不带 session_id重新调用百炼并将百炼返回的新 session_id 更新到本地
6. THE Backend SHALL 保持现有对话复用规则不变task 入口无时限复用、customer/coach 入口 3 天时限、general 入口始终新建
### 需求 4App2~8 单轮 Prompt 调用
**用户故事:** 作为后端开发者,我希望 App2~8 统一使用单轮 `prompt` 调用模式,以便简化调用逻辑并充分利用百炼控制台配置的 System Prompt。
#### 验收标准
1. THE Backend SHALL 为 App2~8 的每次调用使用 `build_prompt()` 函数拼好完整数据 JSON通过 `Application.call(app_id=..., prompt=data_json)` 传入
2. THE Backend SHALL 不再为 App2~8 在代码中维护 System Prompt以百炼控制台配置为准
3. THE Backend SHALL 不再为 App2~8 使用 `messages` 数组或 `response_format` 参数
4. WHEN Application API 返回结果时THE Backend SHALL 解析 `response.output.text` 字段获取 JSON 内容,解析失败时按需求 1 第 6 条重试
5. THE Backend SHALL 为每次 App2~8 调用记录 `ai_run_logs`(详见需求 10
### 需求 5熔断器
**用户故事:** 作为系统管理员,我希望 AI 调用具备熔断保护,以便在百炼服务异常时快速降级,避免无效请求堆积。
#### 验收标准
1. THE Circuit_Breaker SHALL 按 `app_id` 独立计数App1 熔断不影响 App2~8反之亦然
2. WHEN 某个 app_id 连续 5 次调用失败时THE Circuit_Breaker SHALL 进入熔断状态,持续 60 秒内所有该 app_id 的请求直接返回降级响应
3. WHEN 熔断 60 秒后THE Circuit_Breaker SHALL 进入半开状态,放行 1 个请求进行探测
4. WHEN 半开状态的探测请求成功时THE Circuit_Breaker SHALL 关闭熔断,恢复正常调用
5. WHEN 半开状态的探测请求失败时THE Circuit_Breaker SHALL 重新进入熔断状态,再等待 60 秒
6. WHILE Circuit_Breaker 处于熔断状态THE Backend SHALL 对 App1 请求返回友好提示"AI 服务暂时不可用,请稍后重试",对 App2~8 后台任务记录 `circuit_open` 状态并跳过执行
### 需求 6限流
**用户故事:** 作为系统管理员,我希望 AI 调用具备限流保护,以便防止单个用户或门店过度消耗 AI 资源。
#### 验收标准
1. THE Rate_Limiter SHALL 对 App1 实施每用户每分钟 10 次的请求频率限制
2. THE Rate_Limiter SHALL 对 App2~8 实施每门店每小时 100 次(合计)的请求频率限制
3. WHEN 请求超过限流阈值时THE Rate_Limiter SHALL 对 App1 返回友好提示"请求过于频繁,请稍后再试",对 App2~8 后台任务记录 `rate_limited` 状态并跳过执行
4. THE Rate_Limiter SHALL 使用内存计数器实现(单实例部署),不依赖外部 Redis
### 需求 7Token 预算控制
**用户故事:** 作为系统管理员,我希望 AI 调用具备 Token 预算控制,以便防止 API 费用失控。
#### 验收标准
1. THE Budget_Tracker SHALL 从 `ai_run_logs.tokens_used` 按日/月聚合计算已消耗 token 数
2. THE Budget_Tracker SHALL 支持日预算上限(默认 100,000 tokens和月预算上限默认 2,000,000 tokens
3. WHEN 日预算或月预算超限时THE Backend SHALL 对 App1 用户对话返回友好提示"AI 服务今日额度已用完,请明天再试"
4. WHEN 日预算或月预算超限时THE Backend SHALL 对 App2~8 后台任务跳过执行,记录 `budget_exceeded` 状态到 `ai_run_logs`
5. THE Budget_Tracker SHALL 在每次 AI 调用前检查预算,调用后更新 token 消耗记录
### 需求 8调度器 asyncio 修复
**用户故事:** 作为后端开发者,我希望修复 dispatcher.py 中的 asyncio 嵌套问题,以便事件处理器在 FastAPI 事件循环中正常工作,不再因 `asyncio.run()` 嵌套而报错。
#### 验收标准
1. THE Dispatcher SHALL 移除所有 `asyncio.run()``asyncio.new_event_loop()` 调用
2. THE Dispatcher SHALL 将所有事件处理器入口改为 `async def`,使用 `asyncio.create_task()` 发起后台异步任务
3. THE Dispatcher SHALL 使用 `asyncio.wait_for()` 实现超时控制,替代同步超时机制
4. THE Dispatcher SHALL 确保事件处理器在 FastAPI lifespan 中注册后,能在已有事件循环中正常执行调用链
### 需求 9事件触发链打通
**用户故事:** 作为系统管理员,我希望消费事件、备注事件、任务分配事件能正确触发对应的 AI 调用链,以便 AI 分析结果能自动生成,无需人工干预。
#### 验收标准
1. WHEN ETL DWS 任务完成后发送消费事件时THE Dispatcher SHALL 执行调用链App3 → App8 → App7无助教时或 App3 → App8 → App7 + App4 → App5有助教时
2. WHEN 小程序助教提交备注时THE Dispatcher SHALL 执行调用链App6 → App8
3. WHEN task_manager 自动分配任务时THE Dispatcher SHALL 执行调用链App4 → App5
4. THE Dispatcher SHALL 在调用链中某步失败时记录错误日志,后续应用使用已有缓存继续执行,不中断整条链
5. THE Dispatcher SHALL 将每次事件触发记录到 `ai_trigger_jobs` 表,包含事件类型、执行链、状态、耗时
### 需求 10ETL → 后端内部触发 API
**用户故事:** 作为 ETL 开发者,我希望 DWS 任务完成后能通过 HTTP 接口触发后端 AI 调用链,以便实现 ETL 与 AI 模块的自动联动。
#### 验收标准
1. THE Backend SHALL 实现 `POST /api/internal/ai/trigger` 端点,接受 JSON 请求体包含:`event_type`(事件类型)、`connector_type`(连接器类型,默认 `feiqiu`)、`site_id`(门店 ID`member_id`(会员 ID可选`payload`(事件附加数据)
2. THE Internal_AI_API SHALL 使用独立的 `INTERNAL_API_TOKEN` 环境变量进行认证,通过 HTTP Header `Authorization: Internal-Token {token}` 传递,不走 JWT
3. IF 认证 token 缺失或不匹配THEN THE Internal_AI_API SHALL 返回 HTTP 401
4. THE Internal_AI_API SHALL 将事件写入 `ai_trigger_jobs` 表后立即返回 `{ trigger_job_id, status: "pending" }`,调用链在后台异步执行
5. THE Internal_AI_API SHALL 设计为连接器无关接口,`connector_type` 字段标识来源,为多平台扩展预留
6. THE ETL SHALL 在 DWS 任务完成后通过 HTTP POST 调用 Internal_AI_API传递消费事件或 DWS 完成事件
### 需求 11App2 预生成
**用户故事:** 作为门店管理者我希望财务洞察App2在每日 DWS 数据刷新后自动预生成,以便打开页面时能立即看到最新分析结果,无需等待。
#### 验收标准
1. WHEN ETL 通过 Internal_AI_API 发送 `event_type: "dws_completed"` 事件时THE Dispatcher SHALL 触发 App2 预生成任务
2. THE Dispatcher SHALL 为当前门店(`site_id: 2790685415443269`)生成 8 个时间维度的财务洞察:今日、昨日、本周、上周、本月、上月、本季、上季
3. THE Dispatcher SHALL 将 App2 预生成结果写入 `ai_cache`cache_type: `app2_finance`),过期时间为当日 23:59:59
4. THE Dispatcher SHALL 确保 App2 预生成每日调用量为 1 门店 × 8 维度 = 8 次
### 需求 12幂等与去重
**用户故事:** 作为系统管理员,我希望 AI 事件触发具备幂等去重能力,以便重复事件不会导致重复执行和资源浪费。
#### 验收标准
1. THE Dispatcher SHALL 对自动触发的事件按 `(event_type, member_id, site_id, date)` 进行去重,重复事件跳过执行并记录 `skipped_duplicate` 状态
2. WHEN 手动重跑时(`is_forced = true`THE Dispatcher SHALL 允许强制执行,跳过去重检查,在 `ai_trigger_jobs` 中明确标记 `is_forced`
3. THE Backend SHALL 对 App8 写入 `member_retention_clue` 业务表时实施强幂等:在事务中先 DELETE 再 INSERT同一 member 同一天只执行一次
4. IF App8 幂等写入事务失败THEN THE Backend SHALL 自动回滚,记录错误到 `ai_trigger_jobs.error_message`
### 需求 13缓存策略
**用户故事:** 作为后端开发者,我希望 AI 缓存按 App 类型设置不同的过期时间和状态管理,以便缓存数据的新鲜度与业务需求匹配。
#### 验收标准
1. THE Backend SHALL 为每个 App 设置独立的缓存过期策略App2 当日 23:59:59、App3/App4/App5/App7/App8 为 7 天、App6 为 30 天
2. THE Backend SHALL 在 `ai_cache` 表新增 `status` 字段,支持四种状态:`valid`(有效)、`expired`(已过期)、`invalidated`(手动失效)、`generating`(生成中)
3. WHEN 写入新缓存前THE Backend SHALL 将 `status` 设为 `generating`,写入完成后更新为 `valid`,防止并发读取到不完整数据
4. THE Backend SHALL 在查询缓存时仅返回 `status = 'valid'` 且未过期(`expires_at > now()``expires_at IS NULL`)的记录
5. THE Backend SHALL 对 App2~8 每个 App 保留最新 20,000 条 `ai_cache` 记录超限时清理最旧记录App1 对话记录不自动删除
### 需求 14数据库变更
**用户故事:** 作为后端开发者,我希望新增必要的数据库表和字段,以便支持 AI 运行日志、事件调度记录、session_id 管理和缓存状态管理。
#### 验收标准
1. THE Backend SHALL 在 `biz` schema 新增 `ai_run_logs` 表,包含字段:`id`BIGSERIAL PK`site_id`BIGINT NOT NULL`app_type`VARCHAR(30))、`trigger_type`VARCHAR(20))、`member_id`BIGINT 可空)、`request_prompt`TEXT截断前 2000 字符)、`response_text`TEXT`tokens_used`INTEGER`latency_ms`INTEGER`status`VARCHAR(20))、`error_message`TEXT`session_id`VARCHAR(100))、`created_at`TIMESTAMPTZ`finished_at`TIMESTAMPTZ
2. THE Backend SHALL 在 `biz` schema 新增 `ai_trigger_jobs` 表,包含字段:`id`BIGSERIAL PK`site_id`BIGINT NOT NULL`event_type`VARCHAR(30))、`connector_type`VARCHAR(30) 默认 `feiqiu`)、`member_id`BIGINT 可空)、`payload`JSONB`status`VARCHAR(20))、`is_forced`BOOLEAN 默认 false`app_chain`VARCHAR(100))、`started_at`TIMESTAMPTZ`finished_at`TIMESTAMPTZ`error_message`TEXT`created_at`TIMESTAMPTZ
3. THE Backend SHALL 在 `ai_conversations` 表新增 `session_id` 字段VARCHAR(100)),用于存储百炼 session_id
4. THE Backend SHALL 在 `ai_cache` 表新增 `status` 字段VARCHAR(20) 默认 `valid`CHECK 约束限制为 `valid`/`expired`/`invalidated`/`generating`
5. THE Backend SHALL 为 `ai_run_logs` 创建索引:`(site_id, app_type)``(created_at)``(status)`
6. THE Backend SHALL 为 `ai_trigger_jobs` 创建索引:`(site_id, event_type)`、去重索引 `(event_type, member_id, site_id, created_at::date)` WHERE status NOT IN ('skipped_duplicate')、`(status)`
7. THE Backend SHALL 编写迁移脚本 `db/zqyy_app/migrations/YYYYMMDD_p14_ai_module.sql`,包含所有 DDL 变更,并编写对应的回滚脚本
### 需求 15SSE 端点适配
**用户故事:** 作为助教用户,我希望 AI 对话的 SSE 流式端点能适配 DashScope Application API 的流式输出,以便继续获得逐字显示的对话体验。
#### 验收标准
1. THE Backend SHALL 适配 SSE 端点(`POST /api/xcx/chat/stream`),将 DashScope_Client 的 async generator 输出转换为 SSE 事件流
2. THE Backend SHALL 保持现有 SSE 事件格式不变:`event: message`(逐 token`event: done`(流结束)、`event: error`(错误)
3. WHEN DashScope_Client 流式调用过程中发生错误时THE Backend SHALL 发送 `event: error` 事件并关闭连接,不导致客户端挂起
4. THE Backend SHALL 在流式调用完成后记录 `ai_run_logs`,包含 token 消耗和响应耗时
### 需求 16AI 运行日志记录
**用户故事:** 作为系统管理员,我希望每次 AI 调用都有完整的运行日志,以便追踪调用状态、排查问题和统计 token 消耗。
#### 验收标准
1. THE Backend SHALL 在每次 Application API 调用前创建 `ai_run_logs` 记录status: `pending`),调用开始时更新为 `running`
2. WHEN 调用成功时THE Backend SHALL 更新 `ai_run_logs` 状态为 `success`,记录 `response_text``tokens_used``latency_ms``finished_at`
3. WHEN 调用失败时THE Backend SHALL 更新 `ai_run_logs` 状态为 `failed`,记录 `error_message``latency_ms``finished_at`
4. WHEN 调用超时时THE Backend SHALL 更新 `ai_run_logs` 状态为 `timeout`
5. WHEN 预算超限跳过执行时THE Backend SHALL 创建 `ai_run_logs` 记录,状态为 `budget_exceeded`
6. THE Backend SHALL 将 `request_prompt` 截断为前 2000 字符后存储,避免大 prompt 占用过多存储空间

View File

@@ -0,0 +1,252 @@
# 实施计划P14 — AI 模块改造 — DashScope 迁移 + 调度器完善
## 概述
按依赖关系从底层到顶层逐步实施:环境变量/配置 → DashScope 客户端 → 防护层(熔断/限流/预算) → 运行日志 → 调度器 → API 路由 → 服务适配 → ETL 触发 → 数据库迁移 → 收尾。每个任务构建在前序任务之上,确保无孤立代码。
## 任务
- [x] 1. 环境变量与配置基础
- [x] 1.1 重写 `apps/backend/app/ai/config.py`,实现 `AIConfig` dataclass
- 定义所有 `DASHSCOPE_*` 环境变量字段和 `INTERNAL_API_TOKEN`
- 实现 `from_env()` 类方法,缺失必需变量时立即抛出异常
- 删除所有 `BAILIAN_*` 引用
- _需求: 2.1, 2.2, 2.3, 2.5_
- [x] 1.2 编写属性测试:环境变量校验完整性
- **Property 2: 环境变量校验完整性**
- 对任意必需变量子集缺失,`from_env()` 应抛出异常,不返回含空字符串的配置
- **验证: 需求 2.5**
- [x] 1.3 更新 `.env``.env.template``pyproject.toml`
- `.env` / `.env.template``BAILIAN_*``DASHSCOPE_*`,新增 `DASHSCOPE_WORKSPACE_ID``INTERNAL_API_TOKEN`
- `pyproject.toml`:移除 `openai` 依赖,新增 `dashscope` 依赖
- _需求: 2.4, 1.7_
- [x] 2. DashScopeClient 核心客户端
- [x] 2.1 创建 `apps/backend/app/ai/dashscope_client.py`,实现 `DashScopeClient`
- `call_app()` — App2~8 单轮调用,`asyncio.to_thread()` 包装,返回 `(parsed_json, tokens_used, new_session_id)`
- `call_app_stream()` — App1 流式调用,线程消费同步迭代器 + `asyncio.Queue` 桥接 async generator
- `_call_with_retry()` — 指数退避重试1s→2s→4sHTTP 4xx 不重试5xx/超时/连接错误重试
- 非合法 JSON 响应纯重试(最大 3 次),不做本地修复
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [x] 2.2 定义异常层级
- `DashScopeError` 基类及子类:`DashScopeApiError``DashScopeAuthError``DashScopeTimeoutError``DashScopeJsonParseError``CircuitOpenError``RateLimitExceededError``BudgetExceededError`
- _需求: 1.5, 5.6, 6.3, 7.3_
- [x] 2.3 编写属性测试:重试策略正确性
- **Property 1: 重试策略正确性**
- 4xx 立即抛出不重试5xx/超时/连接错误最多重试 3 次,非法 JSON 触发重试
- **验证: 需求 1.5, 1.6**
- [x] 2.4 编写属性测试Application API 响应解析
- **Property 20: Application API 响应解析**
- 合法 JSON 正确解析为 dict非法 JSON 触发重试
- **验证: 需求 4.4**
- [x] 3. 检查点 — 确保所有测试通过
- 确保所有测试通过ask the user if questions arise.
- [x] 4. 防护层熔断器、限流器、Token 预算
- [x] 4.1 创建 `apps/backend/app/ai/circuit_breaker.py`,实现 `CircuitBreaker`
-`app_id` 独立计数,`_BreakerState` 内部状态
- `check()` / `record_success()` / `record_failure()` 方法
- 状态机CLOSED → OPEN连续 5 次失败)→ HALF_OPEN60 秒后)→ CLOSED/OPEN
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
- [x] 4.2 编写属性测试:熔断器 app_id 隔离 + 状态机转换
- **Property 5: 熔断器 app_id 隔离**
- **Property 6: 熔断器状态机转换**
- 不同 app_id 互不影响;状态转换符合 CLOSED→OPEN→HALF_OPEN→CLOSED/OPEN 规则
- **验证: 需求 5.1, 5.2, 5.3, 5.4, 5.5**
- [x] 4.3 创建 `apps/backend/app/ai/rate_limiter.py`,实现 `RateLimiter`
- 滑动窗口内存计数器
- `check_user_rate()` — App1 每用户每分钟 10 次
- `check_store_rate()` — App2~8 每门店每小时 100 次
- _需求: 6.1, 6.2, 6.3, 6.4_
- [x] 4.4 编写属性测试:限流器窗口控制
- **Property 7: 限流器窗口控制**
- 窗口内未超阈值允许,超阈值拒绝;窗口外历史不影响当前判断
- **验证: 需求 6.1, 6.2**
- [x] 4.5 创建 `apps/backend/app/ai/budget_tracker.py`,实现 `BudgetTracker`
- `check_budget()``ai_run_logs` 聚合日/月 token 消耗
- 日预算 100,000 / 月预算 2,000,000
- 返回 `BudgetStatus(allowed, daily_used, monthly_used, reason)`
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5_
- [x] 4.6 编写属性测试Token 预算检查正确性
- **Property 8: Token 预算检查正确性**
- 日聚合 = 当日 success 记录 tokens_used 之和;超限时 allowed=false
- **验证: 需求 7.1, 7.3**
- [x] 5. AI 运行日志服务
- [x] 5.1 创建 `apps/backend/app/ai/run_log_service.py`,实现 `AIRunLogService`
- `create_log()` — 创建 pending 记录
- `update_running()` / `update_success()` / `update_failed()` / `update_timeout()` — 状态转换
- `get_daily_token_usage()` / `get_monthly_token_usage()` — 聚合查询
- `request_prompt` 截断为前 2000 字符
- _需求: 16.1, 16.2, 16.3, 16.4, 16.5, 16.6_
- [x] 5.2 编写属性测试AI 运行日志状态机 + Prompt 截断
- **Property 18: AI 运行日志状态机**
- **Property 19: Prompt 截断不变量**
- 状态转换正确pending→running→success/failed/timeoutprompt ≤ 2000 字符
- **验证: 需求 16.1, 16.2, 16.3, 16.4, 16.5, 16.6**
- [x] 6. 检查点 — 确保所有测试通过
- 确保所有测试通过ask the user if questions arise.
- [x] 7. Dispatcher 调度器重写
- [x] 7.1 重写 `apps/backend/app/ai/dispatcher.py`,实现 `AIDispatcher`
- 移除所有 `asyncio.run()``asyncio.new_event_loop()` 调用
- 所有入口改为 `async def`,用 `asyncio.create_task()` 发起后台任务
- 超时用 `asyncio.wait_for()`
- 集成 CircuitBreaker、RateLimiter、BudgetTracker
- `_run_step()` — 单步执行:熔断检查→限流检查→预算检查→调用→记录日志
- _需求: 8.1, 8.2, 8.3, 8.4_
- [x] 7.2 实现事件触发链编排
- `_handle_consumption()` — 消费事件App3→App8→App7无助教/ +App4→App5有助教
- `_handle_note()` — 备注事件App6→App8
- `_handle_task_assigned()` — 任务分配App4→App5
- `_handle_dws_completed()` — DWS 完成App2 预生成8 个时间维度)
- 调用链某步失败时记录错误,后续步骤使用已有缓存继续
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 11.1, 11.2, 11.3, 11.4_
- [x] 7.3 实现幂等去重逻辑
- `_check_dedup()` — 按 `(event_type, member_id, site_id, date)` 去重
- `is_forced=true` 时跳过去重检查
- 写入 `ai_trigger_jobs` 记录
- _需求: 12.1, 12.2_
- [x] 7.4 编写属性测试:事件类型到调用链映射
- **Property 9: 事件类型到调用链映射**
- 各事件类型映射到正确的 App 调用链
- **验证: 需求 9.1, 9.2, 9.3, 11.1**
- [x] 7.5 编写属性测试:调用链容错不中断
- **Property 10: 调用链容错不中断**
- 任意步骤失败后后续步骤仍继续执行
- **验证: 需求 9.4**
- [x] 7.6 编写属性测试:事件去重与强制执行
- **Property 12: 事件去重与强制执行**
- 重复自动事件跳过;`is_forced=true` 正常执行
- **验证: 需求 12.1, 12.2**
- [x] 8. 检查点 — 确保所有测试通过 ✅ 76 passed
- 确保所有测试通过ask the user if questions arise.
- [x] 9. API 路由与端点
- [x] 9.1 创建 `apps/backend/app/routers/internal_ai.py`,实现内部触发 API
- `POST /api/internal/ai/trigger` 端点
- `TriggerRequest` Pydantic 模型event_type, connector_type, site_id, member_id, payload
- `verify_internal_token()` 依赖注入,校验 `Authorization: Internal-Token {token}`
- 写入 `ai_trigger_jobs` 后立即返回 `{trigger_job_id, status: "pending"}`,后台异步执行
- 在 FastAPI app 中注册路由
- _需求: 10.1, 10.2, 10.3, 10.4, 10.5_
- [x] 9.2 编写属性测试:内部 API 认证
- **Property 11: 内部 API 认证**
- token 缺失/不匹配返回 401匹配时正常处理
- **验证: 需求 10.2, 10.3**
- [x] 9.3 适配 `apps/backend/app/routers/xcx_chat.py` SSE 端点
- 替换 `_get_bailian_client()``_get_dashscope_client()`
- 调用 `client.call_app_stream(app_id, prompt, session_id, biz_params)`
- 构建 `prompt` + `biz_params`User_ID, Role, Nickname
- 保持 SSE 事件格式:`event: message` / `event: done` / `event: error`
- 流结束后记录 `ai_run_logs`
- _需求: 15.1, 15.2, 15.3, 15.4, 3.3_
- [x] 9.4 编写属性测试SSE 事件流格式
- **Property 17: SSE 事件流格式**
- 零或多个 `event: message`,最终以恰好一个 `done``error` 结束
- **验证: 需求 15.2**
- [x] 10. 服务层适配
- [x] 10.1 适配 `apps/backend/app/services/ai/chat_service.py` — session_id 双轨逻辑
- 新对话生成 session_id格式 `conv_{conversation_id}_{created_timestamp}`
- 每条消息同时写入本地 `ai_messages`
- session_id 过期时从本地加载最近 20 条历史重建
- 保持对话复用规则task 无时限、customer/coach 3 天、general 新建
- _需求: 3.1, 3.2, 3.4, 3.5, 3.6_
- [x] 10.2 编写属性测试session_id 格式 + 对话复用规则
- **Property 3: session_id 格式不变量**
- **Property 4: 对话复用规则**
- session_id 匹配 `^conv_\d+_\d+$`;复用规则按入口类型正确判断
- **验证: 需求 3.1, 3.6**
- [x] 10.3 适配 `apps/backend/app/services/ai/cache_service.py` — 缓存状态与过期策略
- 新增 `status` 字段处理valid/expired/invalidated/generating
- 写入前设 `generating`,完成后设 `valid`
- 查询仅返回 `status='valid'` 且未过期的记录
- 按 App 类型设置过期时间App2 当日 23:59:59、App3~5/7/8 七天、App6 三十天)
- App2~8 每 App 保留最新 20,000 条,超限清理最旧记录
- _需求: 13.1, 13.2, 13.3, 13.4, 13.5_
- [x] 10.4 编写属性测试:缓存过期策略 + 查询过滤 + 保留上限
- **Property 14: 缓存过期策略正确性**
- **Property 15: 缓存查询过滤**
- **Property 16: 缓存保留上限**
- 过期时间匹配策略;查询仅返回 valid 且未过期;每 App ≤ 20,000 条
- **验证: 需求 13.1, 13.4, 13.5**
- [x] 10.5 实现 App8 幂等写入 `member_retention_clue`
- 事务中 DELETE + INSERT同一 member 同一天只执行一次
- 事务失败自动回滚,记录错误到 `ai_trigger_jobs.error_message`
- _需求: 12.3, 12.4_
- [x] 10.6 编写属性测试App8 幂等写入
- **Property 13: App8 幂等写入**
- 多次执行后同一 member 同一天记录数恒为 1
- **验证: 需求 12.3**
- [x] 11. 检查点 — 确保所有测试通过 ✅ 106 passed
- 确保所有测试通过ask the user if questions arise.
- [x] 12. ETL 触发集成
- [x] 12.1 修改 `apps/etl/connectors/feiqiu/tasks/` 相关 DWS 任务
- DWS 任务完成后通过 `requests` 发送 `POST /api/internal/ai/trigger`
- 携带 `Authorization: Internal-Token {INTERNAL_API_TOKEN}` Header
- 事件类型:`dws_completed`App2 预生成)/ `consumption`(消费事件链)
- 新增 `utils/ai_trigger.py` 工具函数 + `BaseDwsTask.AI_TRIGGER_EVENT` 类属性
- `FinanceDailyTask``dws_completed``MemberConsumptionTask``consumption`
- _需求: 10.6_
- [x] 13. 数据库迁移
- [x] 13.1 编写迁移脚本 `db/zqyy_app/migrations/2026-03-22__p14_ai_module.sql`
- CREATE TABLE `biz.ai_run_logs`(含 3 个索引)
- CREATE TABLE `biz.ai_trigger_jobs`(含 3 个索引,含去重部分索引)
- ALTER TABLE `biz.ai_conversations` ADD COLUMN `session_id`
- ALTER TABLE `biz.ai_cache` ADD COLUMN `status` + CHECK 约束
- 编写对应回滚脚本(逆序 DROP/ALTER+ 验证 SQL7 条)
- _需求: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_
- [x] 14. 收尾:文档与集成验证
- [x] 14.1 更新 BD 手册
- 更新 `docs/database/BD_Manual_ai_tables.md`:新增 `ai_run_logs``ai_trigger_jobs` 表结构
- 更新 `ai_cache.status``ai_conversations.session_id` 字段说明
- _需求: 14.1, 14.2, 14.3, 14.4_
- [x] 14.2 文档同步
- 更新 `docs/prd/ai-app-prompts.md`:环境变量映射 BAILIAN_* → DASHSCOPE_*
- 更新 `apps/backend/README.md`AI 模块架构说明
- 更新 `docs/DOCUMENTATION-MAP.md`:新增文档条目
- _需求: 2.4_
- [x] 15. 最终检查点 — 确保所有测试通过 ✅ 505 passedP14 全部 15 个测试文件通过80 个预存失败均非 P14 相关)
- 确保所有测试通过ask the user if questions arise.
## 说明
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务关联了具体的需求编号,确保可追溯
- 属性测试任务标注了对应的 Property 编号和验证的需求条款
- 检查点确保增量验证,避免问题累积
- 实现语言Python与设计文档一致