# 设计文档 — NS2：AI 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: 实体 ID（contextId）
        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          # 边界/示例单元测试
```

