Files
Neo 70324d8542 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>
2026-04-06 00:02:37 +08:00

670 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档 — NS2AI Prompt 细化
## 概述
本设计将 P5-A 阶段交付的 6 个 AI 应用(应用 1/3/4/5/6/7`build_prompt()` 占位骨架升级为完整实现。核心变更:
1. 新建共享数据获取层 `apps/backend/app/ai/data_fetchers/`,封装 FDW 查询逻辑
2. 完善应用 3/4/5/6/7 的 `build_prompt()` 函数,从 TODO 占位升级为真实数据拼接
3. 实现应用 1 的页面上下文文本化,根据 `contextType` 自动获取并格式化页面数据
设计原则:
- 数据获取与 Prompt 拼接分离data_fetchers 可被多个应用复用
- 所有 FDW 查询遵循 RLS 隔离(`SET LOCAL app.current_site_id`
- 金额口径统一使用 `items_sum`,禁止 `consume_money`
- 部分数据获取失败不阻断 Prompt 生成(错误降级)
## 架构
### 整体分层
```
┌─────────────────────────────────────────────────┐
│ AI 应用层apps/app*.py
│ build_prompt() / run() │
├─────────────────────────────────────────────────┤
│ 数据获取层data_fetchers/ 🆕 新建 │
│ member_data / assistant_data / page_context │
├─────────────────────────────────────────────────┤
│ 基础设施层(已有) │
│ database.py / cache_service.py / bailian_client │
└─────────────────────────────────────────────────┘
```
### 数据流
```mermaid
graph TD
subgraph 事件触发
E1[消费结算事件]
E2[备注提交事件]
E3[任务分配事件]
E4[用户进入 chat]
end
subgraph data_fetchers
MF[member_data.py]
AF[assistant_data.py]
PC[page_context.py]
end
subgraph FDW 视图
DWD[v_dwd_settlement_head<br/>v_dwd_table_fee_log<br/>v_dwd_store_goods_sale<br/>v_dwd_assistant_service_log]
DWS[v_dws_member_consumption_summary<br/>v_dws_member_visit_detail<br/>v_dws_assistant_salary_calc]
DIM[v_dim_member<br/>v_dim_assistant<br/>v_dim_member_card_account]
end
subgraph 业务库
BIZ[biz.notes<br/>biz.coach_tasks<br/>biz.ai_cache]
end
E1 --> App3 --> MF
E1 --> App4 --> AF
E1 --> App4 --> MF
E2 --> App6 --> MF
E3 --> App4
E4 --> App1 --> PC
MF --> DWD
MF --> DWS
MF --> DIM
AF --> DWD
AF --> DWS
AF --> DIM
PC --> BIZ
PC --> MF
PC --> AF
```
### 调用链编排(已有 dispatcher.py
消费事件链:`App3 → App8 → App7 + App4 → App5`
备注事件链:`App6 → App8`
任务分配链:`App4 → App5`
对话流:`App1.chat_stream()``build_page_text()` → SSE 流式
## 组件与接口
### 1. 数据获取层(`apps/backend/app/ai/data_fetchers/`
#### 1.1 `member_data.py` — 客户消费数据获取
应用 3/6/7 共用。从 FDW 视图获取客户近 N 个月消费数据。
```python
async def fetch_member_consumption_data(
site_id: int, member_id: int, months: int = 3
) -> dict:
"""获取客户近 N 个月消费数据。
返回:
{
"consumption_records": list[dict], # 消费记录(最多 100 条settle_date DESC
"member_cards": list[dict], # 会员卡明细
"card_balance_total": Decimal, # 储值卡余额合计
"stored_value_balance_total": Decimal, # 储值余额合计
"expected_visit_date": str | None, # 预计到店日期
"days_since_last_visit": int | None, # 距上次到店天数
"member_nickname": str, # 会员昵称
}
"""
```
数据源与查询策略:
| 数据 | FDW 视图 | 连接方式 | 筛选条件 |
|------|---------|---------|---------|
| 台桌结账 | `v_dwd_settlement_head` + `v_dwd_table_fee_log` | `get_etl_readonly_connection` | `settle_type IN (1,3)`, `settle_date >= NOW() - N months` |
| 商城订单 | `v_dwd_store_goods_sale` | 同上 | `sale_date >= NOW() - N months` |
| 会员卡 | `v_dim_member_card_account` | 同上 | `member_id` 匹配 |
| 消费汇总 | `v_dws_member_consumption_summary` | 同上 | `member_id` 匹配 |
| 到店明细 | `v_dws_member_visit_detail` | 同上 | `member_id` 匹配 |
| 会员信息 | `v_dim_member` | 同上 | `member_id` 匹配, `scd2_is_current=1` |
每条消费记录字段:
```python
{
"settle_date": "2026-03-05",
"settle_type": 1,
"items_sum": 280.00, # 强制口径
"table_charge_money": 180.00,
"assistant_pd_money": 80.00, # 陪打费
"assistant_cx_money": 0, # 超休费
"goods_money": 20.00,
"room_name": "VIP-3",
"duration_minutes": 120,
"assistant_names": ["张助教"],
}
```
实现要点:
- 使用 `_fdw_context` 模式(参考 `fdw_queries.py``get_etl_readonly_connection(site_id)` + `SET LOCAL app.current_site_id`
- 多个 FDW 查询可在同一连接上串行执行(共享 RLS 设置)
- 消费记录限制 100 条,超出时在返回 dict 中附加 `"truncated": True, "total_count": N`
- 5 秒查询超时,超时抛出 `TimeoutError`
- 会员昵称通过 `member_id JOIN v_dim_member (scd2_is_current=1)` 获取
#### 1.2 `assistant_data.py` — 助教数据获取
应用 4/5 共用。
```python
async def fetch_assistant_info(
site_id: int, assistant_id: int
) -> dict:
"""获取助教基本信息。
返回:
{
"nickname": str,
"level": str,
"hire_date": str,
"tenure_months": int,
"monthly_customers": int,
"performance_tier": str,
}
"""
async def fetch_service_history(
site_id: int, assistant_id: int, member_id: int, months: int = 3
) -> list[dict]:
"""获取助教服务该客户的历史记录。
返回:
[
{
"service_date": str,
"duration_minutes": int,
"items_sum": Decimal,
"room_name": str,
"is_pd": bool, # 是否陪打
},
...
]
"""
```
数据源:
| 数据 | FDW 视图 | 说明 |
|------|---------|------|
| 助教基本信息 | `v_dim_assistant` | 花名、级别、入职日期 |
| 绩效数据 | `v_dws_assistant_salary_calc` | 本月客户数、绩效档位 |
| 服务记录 | `v_dwd_assistant_service_log` | 按 assistant_id + member_id 筛选 |
| 关系指数 | `v_dws_member_assistant_relation_index` | 助教-客户关系指数 |
| 亲密度 | `v_dws_member_assistant_intimacy` | 亲密度数据 |
实现要点:
- 使用 `is_trash` 字段排除废单(`WHERE is_trash = false`),禁止使用已废弃的 `dwd_assistant_trash_event`
- 服务记录按 `service_date DESC` 排序
- `tenure_months``hire_date` 到当前日期计算
#### 1.3 `page_context.py` — 页面上下文文本化(应用 1 专用)
```python
async def build_page_text(
source_page: str,
context_id: int | str | None,
site_id: int,
filters: dict | None = None,
) -> str:
"""将页面数据转换为 AI 可读的结构化中文文本。
Args:
source_page: 页面类型contextType
context_id: 实体 IDcontextId
site_id: 门店 ID
filters: 看板类页面的筛选参数
Returns:
结构化中文文本(≤ 2000 字符)
"""
```
支持的 10 种页面类型:
| source_page | context_id | filters | 数据获取 |
|-------------|-----------|---------|---------|
| `task-detail` | taskId | — | `biz.coach_tasks` + 会员信息 + 备注 + `ai_cache` |
| `customer-detail` | memberId | — | 会员信息 + 消费记录 + 维客线索 |
| `coach-detail` | assistantId | — | 助教信息 + 任务统计 + 备注 |
| `board-finance` | — | `timeDimension`, `areaFilter` | 财务 DWS 汇总 |
| `board-customer` | — | `dimension`, `typeFilter` | 客户排名 top 列表 |
| `board-coach` | — | `dimension`, `projectFilter`, `timeDimension` | 助教排名 |
| `performance` | — | `timeDimension` | `v_dws_assistant_salary_calc` |
| `my-profile` | — | — | 用户信息 + 助教绑定 |
| `task-list` | taskId | — | 任务摘要 + 客户-助教关系 |
| `customer-service-records` | memberId | — | 服务记录列表 |
实现要点:
- 每个页面类型对应一个内部函数(如 `_text_task_detail()``_text_customer_detail()`
- 输出为结构化中文描述(分段标题 + 缩进),非 JSON
- 输出限制 2000 字符,超出截断并标注
- 看板类页面未传筛选参数时使用默认值(`board-finance` 默认"本月"
- 数据获取失败返回 `"页面上下文获取失败,请直接描述您的问题"`
- 不传入 `member_phone` 等断档敏感字段
- 复用 `member_data.py``assistant_data.py` 的数据获取函数
### 2. 应用层 Prompt 拼接改造
#### 2.1 应用 3`app3_clue.py`)— 客户数据维客线索分析
改造 `build_prompt()``async def`,调用 `fetch_member_consumption_data()` 获取真实数据。
Prompt JSON 结构:
```json
{
"current_time": "2026-03-08 14:30:25",
"member_nickname": "客户昵称",
"main_data": {
"consumption_records": [...],
"member_cards": [...],
"card_balance_total": 1700.00,
"stored_value_balance_total": 1700.00,
"expected_visit_date": "2026-03-10",
"days_since_last_visit": 15
},
"reference": {
"app6_clues": [...],
"app8_history": [...]
}
}
```
变更点:
- `build_prompt()` 签名改为 `async def`,新增 `site_id` 参数用于 FDW 查询
- `system_content["data"]` 替换为 `fetch_member_consumption_data()` 返回的真实数据
- `run()` 中调用 `build_prompt()``await`
- 空数据时标注"该客户暂无消费记录"
#### 2.2 应用 4`app4_analysis.py`)— 关系分析/任务建议
改造 `build_prompt()``async def`,调用三个数据获取函数。
Prompt JSON 结构:
```json
{
"current_time": "2026-03-08 14:30:25",
"assistant_info": { "nickname": "...", "level": "...", ... },
"service_history": [...],
"task_assignment_basis": "优先召回",
"customer_data": {
"system_data": { /* 3 main_data */ },
"notes": [...]
},
"reference": { "app8_current": {...}, "app8_history": [...] }
}
```
变更点:
- 调用 `fetch_assistant_info()` + `fetch_service_history()` + `fetch_member_consumption_data()`
- 备注从 `biz.notes` 获取,单条截断 500 字符
- 使用 `asyncio.gather` 并发获取三类数据
#### 2.3 应用 5`app5_tactics.py`)— 话术参考
复用应用 4 的数据获取逻辑,额外接收 `context["app4_result"]` 作为 `task_suggestion`
变更点:
- `build_prompt()` 改为 `async def`
- 数据获取逻辑与应用 4 一致
- `task_suggestion``context["app4_result"]` 获取,缺失时设为空对象
#### 2.4 应用 6`app6_note.py`)— 备注分析
改造 `build_prompt()``async def`,调用 `fetch_member_consumption_data()` 获取客户消费数据。
Prompt JSON 结构:
```json
{
"current_time": "2026-03-08 14:30:25",
"current_note": { "content": "...", "recorded_by": "...", "created_at": "..." },
"reference": {
"member_nickname": "王先生",
"consumption_data": { /* 3 main_data */ },
"all_notes": [...],
"app3_clues": [...],
"app8_history": [...]
}
}
```
变更点:
- 调用 `fetch_member_consumption_data()` 获取消费数据
- `all_notes``biz.notes` 获取,单条截断 500 字符,最多 50 条
- 空备注时 `all_notes` 设为空数组
#### 2.5 应用 7`app7_customer.py`)— 客户分析
改造 `build_prompt()``async def`,调用 `fetch_member_consumption_data()` 获取客观数据。
Prompt JSON 结构:
```json
{
"current_time": "2026-03-08 14:30:25",
"member_id": 12345,
"member_nickname": "王先生",
"objective_data": { /* 3 main_data */ },
"subjective_data": {
"notes": [{ "recorded_by": "...", "content": "...", "created_at": "..." }]
},
"reference": { "app8_current": {...}, "app8_history": [...] }
}
```
变更点:
- 调用 `fetch_member_consumption_data()` 获取客观数据
- 备注从 `biz.notes` 获取,标注"【来源:{recorded_by},请甄别信息真实性】"
- 空备注时标注"该客户暂无主观备注信息"
#### 2.6 应用 1`app1_chat.py`)— 页面上下文集成
改造 `_build_page_context()``async def`,调用 `build_page_text()` 获取页面上下文。
> **P5 对齐说明**P5 定义首条 Prompt 包含 `page_context` + `screen_content` 两个独立字段。NS2 通过 `build_page_text()` 生成合并文本,等效覆盖两者的信息需求(详见 NS2 PRD 3.7 设计决策)。
变更点:
- `_build_page_context()` 改为 `async def`,内部调用 `page_context.build_page_text()`
- `source_page` 参数映射到 `contextType``page_context` 中的 `contextId` 传入 `build_page_text()`
- 看板类页面从 `page_context` 提取筛选参数传入 `filters`
- `contextType` 为空或未识别时跳过页面上下文注入
- `biz_params.user_prompt_params` 保持不变
- system prompt 总字符数控制在 4000 以内
### 3. 备注查询辅助函数
多个应用4/5/6/7需要从 `biz.notes` 获取客户备注,抽取为共享函数:
```python
# data_fetchers/member_data.py 或独立 notes 模块
async def fetch_member_notes(
site_id: int, member_id: int, limit: int = 50
) -> list[dict]:
"""获取客户的全部备注(按 created_at DESC最多 limit 条)。
返回:
[{"recorded_by": str, "content": str, "created_at": str}, ...]
"""
```
- 使用 `get_connection()` 查询业务库 `biz.notes`
- 单条备注内容截断 500 字符,超出附加"…(已截断)"
-`created_at DESC` 排序
## 数据模型
### 无新建数据库表
本设计不需要新建数据库表。所有数据获取基于已有的 FDW 视图和业务表。
### 数据获取涉及的表
| 数据获取函数 | 涉及表 | 连接方式 |
|-------------|--------|---------|
| `fetch_member_consumption_data` | `v_dwd_settlement_head`, `v_dwd_table_fee_log`, `v_dwd_store_goods_sale`, `v_dim_member_card_account`, `v_dws_member_consumption_summary`, `v_dws_member_visit_detail`, `v_dim_member` | ETL 只读连接 |
| `fetch_assistant_info` | `v_dim_assistant`, `v_dws_assistant_salary_calc` | ETL 只读连接 |
| `fetch_service_history` | `v_dwd_assistant_service_log`, `v_dws_member_assistant_relation_index`, `v_dws_member_assistant_intimacy` | ETL 只读连接 |
| `fetch_member_notes` | `biz.notes` | 业务库连接 |
| `build_page_text` | 以上全部 + `biz.coach_tasks`, `biz.ai_cache`, `public.member_retention_clue` | 混合 |
### Prompt JSON 结构汇总
各应用 Prompt 的 `system_content` 顶层字段:
| 应用 | 顶层字段 | 数据来源 |
|------|---------|---------|
| App3 | `current_time`, `member_nickname`, `main_data`, `reference` | `fetch_member_consumption_data` + `ai_cache` |
| App4 | `current_time`, `assistant_info`, `service_history`, `task_assignment_basis`, `customer_data`, `reference` | `fetch_assistant_info` + `fetch_service_history` + `fetch_member_consumption_data` + `biz.notes` + `ai_cache` |
| App5 | 同 App4 + `task_suggestion` | 同 App4 + `context["app4_result"]` |
| App6 | `current_time`, `current_note`, `reference`(含 `consumption_data`, `all_notes` | `fetch_member_consumption_data` + `biz.notes` + `ai_cache` |
| App7 | `current_time`, `member_id`, `member_nickname`, `objective_data`, `subjective_data`, `reference` | `fetch_member_consumption_data` + `biz.notes` + `ai_cache` |
| App1 | `task`, `biz_params`, `page_context` | `build_page_text()` |
### Token 预算约束
| 应用 | 限制 | 说明 |
|------|------|------|
| App 3/4/5/6/7 | Prompt JSON ≤ 8000 字符 | 约 4000 token |
| App 1 | system prompt ≤ 4000 字符 | 含 `page_context`(≤ 2000 字符) |
### 金额口径强制规则
```
items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money
```
- 禁止使用 `consume_money`
- 助教费用必须拆分:`assistant_pd_money`(陪打)+ `assistant_cx_money`(超休)
- 会员信息通过 `member_id JOIN v_dim_member (scd2_is_current=1)` 获取,禁止使用结算单冗余字段
## 正确性属性
*属性Property是系统在所有有效执行中都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 数据获取函数返回结构完整性
*对于任意* 有效的 `site_id``member_id``fetch_member_consumption_data()` 返回的字典必须包含所有必需键(`consumption_records``member_cards``card_balance_total``stored_value_balance_total``expected_visit_date``days_since_last_visit``member_nickname`),且 `consumption_records` 中每条记录包含 `settle_date``settle_type``items_sum``table_charge_money``assistant_pd_money``assistant_cx_money``goods_money``room_name``duration_minutes``assistant_names` 字段。同理,`fetch_assistant_info()``fetch_service_history()` 返回的数据也必须包含各自的必需键。
**Validates: Requirements 1.1, 1.3, 1.5, 2.1, 2.2, 2.4**
### Property 2: 消费记录仅包含正向交易
*对于任意* `fetch_member_consumption_data()` 返回的消费记录列表,其中每条记录的 `settle_type` 必须属于 `{1, 3}`(正向交易),不得包含退款或其他类型。
**Validates: Requirements 1.2**
### Property 3: 金额口径使用 items_sum
*对于任意* 数据获取函数返回的包含金额的记录,必须包含 `items_sum` 字段且不包含 `consume_money` 字段;助教费用必须拆分为 `assistant_pd_money``assistant_cx_money` 两个独立字段。
**Validates: Requirements 1.4, 3.5, 11.3, 11.4**
### Property 4: 消费记录数量限制与排序
*对于任意* `fetch_member_consumption_data()` 返回的 `consumption_records`,列表长度必须 ≤ 100且按 `settle_date` 降序排列(即 `records[i].settle_date >= records[i+1].settle_date`)。
**Validates: Requirements 1.9, 14.3**
### Property 5: 废单记录排除
*对于任意* `fetch_service_history()` 返回的服务记录列表,不得包含 `is_trash=true` 的记录。
**Validates: Requirements 2.3**
### Property 6: 备注截断不变量
*对于任意* 长度的备注内容字符串,经截断处理后长度必须 ≤ 500 字符;若原始内容超过 500 字符,截断后必须以"…(已截断)"结尾。
**Validates: Requirements 4.4, 6.4**
### Property 7: 各应用 build_prompt 返回结构完整性
*对于任意* 有效的 context 输入和数据获取结果:
- App3 的 `build_prompt` 返回的 JSON 必须包含 `current_time``member_nickname``main_data``reference` 顶层键
- App4 的 `build_prompt` 返回的 JSON 必须包含 `current_time``assistant_info``service_history``customer_data``reference` 顶层键
- App5 的 `build_prompt` 返回的 JSON 必须包含 App4 的所有键加 `task_suggestion`
- App6 的 `build_prompt` 返回的 JSON 必须包含 `current_time``current_note``reference` 顶层键
- App7 的 `build_prompt` 返回的 JSON 必须包含 `current_time``member_id``member_nickname``objective_data``subjective_data``reference` 顶层键
**Validates: Requirements 3.1, 3.2, 4.1, 4.2, 5.2, 6.1, 6.2, 7.1, 7.2**
### Property 8: App5 task_suggestion 传递
*对于任意* `context["app4_result"]` 值(包括非空字典和空/缺失App5 的 `build_prompt` 返回的 Prompt JSON 中 `task_suggestion` 字段必须等于 `context["app4_result"]`(非空时)或空对象(缺失时)。
**Validates: Requirements 5.3, 5.4**
### Property 9: App7 主观信息来源标注
*对于任意* 包含备注的 App7 Prompt每条备注在 Prompt 文本中必须附带"【来源:{recorded_by},请甄别信息真实性】"格式的标注。
**Validates: Requirements 7.4**
### Property 10: 页面上下文输出长度约束
*对于任意* 页面类型和任意数据量,`build_page_text()` 返回的文本长度必须 ≤ 2000 字符。
**Validates: Requirements 8.8**
### Property 11: 页面上下文覆盖所有页面类型
*对于任意* 10 种支持的页面类型(`task-detail``customer-detail``coach-detail``board-finance``board-customer``board-coach``performance``my-profile``task-list``customer-service-records``build_page_text()` 必须能处理而不抛出未识别类型异常,且返回非空字符串。
**Validates: Requirements 8.1, 8.2**
### Property 12: 页面上下文不泄露敏感字段
*对于任意* `build_page_text()` 的输出文本,不得包含 `member_phone` 等断档敏感字段的值。
**Validates: Requirements 8.12**
### Property 13: biz_params 注入后不变量
*对于任意* 页面上下文注入操作App1 的 system prompt 中 `biz_params.user_prompt_params` 必须包含 `User_ID`(字符串)、`Role``Nickname` 三个键,且值与输入的用户信息一致。
**Validates: Requirements 9.6, 13.2**
### Property 14: 错误降级产生合法 Prompt
*对于任意* 数据获取函数失败的组合0 到全部失败),`build_prompt` 仍必须返回可被 `json.loads` 解析的合法 JSON不包含 Python `None`JSON `null`)值,且失败部分使用空数组/空对象替代并附带提示文本。
**Validates: Requirements 12.1, 12.2, 12.4**
### Property 15: 应用 3-7 Prompt Token 预算
*对于任意* 有效输入数据,应用 3/4/5/6/7 的 `build_prompt` 返回的消息列表中system message 的 `content` 字符串长度必须 ≤ 8000 字符。
**Validates: Requirements 14.1**
### Property 16: 应用 1 System Prompt Token 预算
*对于任意* 页面上下文App1 的 `_build_system_prompt()` 返回的 JSON 序列化后字符串长度必须 ≤ 4000 字符。
**Validates: Requirements 14.2**
### Property 17: 备注数量限制
*对于任意* 数量的客户备注,传入 Prompt 的备注列表长度必须 ≤ 50 条,且按 `created_at` 降序排列。
**Validates: Requirements 14.4**
## 错误处理
### 数据获取层错误处理
| 错误场景 | 处理策略 | 调用方行为 |
|---------|---------|-----------|
| FDW 连接失败 | 抛出 `ConnectionError`,包含视图名称 | `build_prompt` 捕获,该部分数据设为默认空值 |
| FDW 查询超时(>5s | 抛出 `TimeoutError`,包含视图名称和耗时 | 同上 |
| 会员不存在(`v_dim_member` 无匹配) | 返回 `member_nickname=""` | `build_prompt` 使用空昵称 |
| 助教不存在(`v_dim_assistant` 无匹配) | 抛出 `ValueError("assistant not found")` | `build_prompt` 捕获,`assistant_info` 设为空对象 |
| 业务库连接失败(`biz.notes` | 抛出 `ConnectionError` | `build_prompt` 捕获,备注设为空数组 |
### Prompt 拼接层错误处理
```python
# 各应用 build_prompt 中的错误降级模式
async def build_prompt(context, cache_svc=None):
# 并发获取数据,部分失败不阻断
member_data, assistant_data, notes = await asyncio.gather(
fetch_member_consumption_data(site_id, member_id),
fetch_assistant_info(site_id, assistant_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True, # 关键:失败返回异常对象而非抛出
)
# 检查每个结果,失败时使用默认值
if isinstance(member_data, Exception):
logger.warning("客户数据获取失败: %s", member_data)
member_data = _default_member_data() # 空数组/零值
# Prompt 中标注"该部分数据获取失败"
```
### 页面上下文错误处理
- `build_page_text()` 内部捕获所有异常
- 任何数据获取失败返回 `"页面上下文获取失败,请直接描述您的问题"`
- 不阻断 App1 对话流程
### Prompt JSON 合法性保证
- 所有 `None` 值替换为空字符串、空数组或空对象
- `json.dumps` 前进行最终校验
- 使用 `default=str` 处理 `datetime``Decimal` 等非标准类型
## 测试策略
### 属性测试Property-Based Testing
使用 **Hypothesis** 库(项目已有 `.hypothesis/` 目录)。
每个属性测试最少运行 100 次迭代。每个测试用注释标注对应的设计属性:
```python
# Feature: ai-prompt-refinement, Property 1: 数据获取函数返回结构完整性
@given(st.integers(min_value=1), st.integers(min_value=1))
@settings(max_examples=100)
def test_member_data_structure_completeness(site_id, member_id):
...
```
属性测试覆盖的属性Property 1-17
| 属性 | 测试策略 | 生成器 |
|------|---------|--------|
| P1: 返回结构完整性 | Mock 数据库返回随机行,验证函数输出包含所有必需键 | `st.fixed_dictionaries` 生成随机数据库行 |
| P2: 正向交易过滤 | 生成混合 settle_type 的记录,验证输出仅含 1/3 | `st.sampled_from([1,2,3,4])` |
| P3: items_sum 口径 | 验证输出记录包含 items_sum 且不含 consume_money | 复用 P1 生成器 |
| P4: 记录数量与排序 | 生成 0-200 条记录,验证输出 ≤100 且降序 | `st.lists(record_strategy, max_size=200)` |
| P5: 废单排除 | 生成含 is_trash 标记的记录,验证输出不含废单 | `st.booleans()` 控制 is_trash |
| P6: 备注截断 | 生成 0-2000 字符的字符串,验证截断后 ≤500 | `st.text(min_size=0, max_size=2000)` |
| P7: Prompt 结构完整性 | Mock 数据获取返回随机数据,验证 Prompt JSON 键 | 组合生成器 |
| P8: task_suggestion 传递 | 生成随机 app4_result含空验证传递正确 | `st.one_of(st.none(), st.dictionaries(...))` |
| P9: 主观信息标注 | 生成随机备注,验证标注格式 | `st.text()` 生成 recorded_by |
| P10: 页面上下文长度 | 生成大量数据,验证输出 ≤2000 | 各页面类型的数据生成器 |
| P11: 页面类型覆盖 | 枚举 10 种类型,验证不抛异常 | `st.sampled_from(PAGE_TYPES)` |
| P12: 敏感字段排除 | 生成含手机号的数据,验证输出不含 | `st.from_regex(r'1[3-9]\d{9}')` |
| P13: biz_params 不变量 | 生成随机用户信息,验证注入后保持 | `st.text()` 生成 user_id/role/nickname |
| P14: 错误降级 | 随机让 0-N 个数据获取函数失败,验证 Prompt 合法 | `st.booleans()` 控制每个函数是否失败 |
| P15: App3-7 Token 预算 | 生成大量数据,验证 Prompt ≤8000 字符 | 大数据量生成器 |
| P16: App1 Token 预算 | 生成大量页面上下文,验证 ≤4000 字符 | 大数据量生成器 |
| P17: 备注数量限制 | 生成 0-100 条备注,验证输出 ≤50 且降序 | `st.lists(note_strategy, max_size=100)` |
### 单元测试
单元测试聚焦于具体示例、边界情况和集成点:
| 测试类别 | 测试内容 |
|---------|---------|
| 边界:空数据 | 客户无消费记录、无备注、无服务历史时的 Prompt 输出 |
| 边界:新客户 | ai_cache 无历史数据时 reference 为空对象 |
| 边界:超时 | FDW 查询超时时的异常类型和消息 |
| 边界:未识别 contextType | App1 收到未知 contextType 时跳过页面上下文 |
| 边界app4_result 缺失 | App5 的 task_suggestion 设为空对象 |
| 边界:看板无筛选参数 | 使用默认值(本月/消费金额排序) |
| 示例App3 完整流程 | Mock 数据 → build_prompt → 验证 JSON 结构和内容 |
| 示例App1 页面上下文 | task-detail 页面 → build_page_text → 验证输出包含任务信息 |
| 示例RLS 隔离 | 验证 get_etl_readonly_connection 被调用且传入正确 site_id |
| 示例:看板筛选参数传递 | board-finance + timeDimension → 验证传入 build_page_text |
| 集成:调用链 | dispatcher 触发消费事件 → App3 → App8 → App7 完整链路 |
### 测试文件组织
```
tests/
├── test_data_fetchers/
│ ├── test_member_data_props.py # P1-P4 属性测试
│ ├── test_assistant_data_props.py # P5 属性测试
│ ├── test_page_context_props.py # P10-P12 属性测试
│ └── test_data_fetchers_unit.py # 边界/示例单元测试
├── test_ai_apps/
│ ├── test_build_prompt_props.py # P6-P9, P14-P17 属性测试
│ ├── test_app1_props.py # P13, P16 属性测试
│ └── test_ai_apps_unit.py # 边界/示例单元测试
```