# Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心)

| 字段 | 值 |
|---|---|
| 日期 | 2026-05-05 |
| Wave | 1 / Day 5 主线必修(P0-7 沙箱) |
| 范围 | batch-run executor 真实化 + ctx_snapshot 防漂移 + retry 落 runtime 列 + ai_run_logs 索引 + App2/2a ref_date 修复 + GUC 接入机制 + admin-web sandbox 提示条 |
| 拆分背景 | F1-5b(zqyy_app RLS 视图层 + 46 处 `FROM biz.*` → `app.v_*` + B1/B2 完整漂移防御)紧接本次后做 |

## 一、F1-5 决策卡 6 项 Neo 决策

详见 [`docs/_overview/wave1-findings/F1-5-impl-decisions.md`](../../_overview/wave1-findings/F1-5-impl-decisions.md)。

| # | 决策 | Neo 反馈 | 落实 |
|---|---|---|---|
| D1 | batch executor 实现 | 同意 c 方案 + N=5,ctx_snapshot 时机 Claude 定 | Semaphore(5) + asyncio.gather(return_exceptions=True);estimate 入口抓快照 |
| D2 | GUC C 方案处置 | "完整且统一,符合规范化项目架构" | 拆 F1-5a/F1-5b 两步:F1-5a 接入机制 + ETL 库激活,F1-5b 完整 zqyy_app RLS 视图层落地 |
| D3 | 测试覆盖 | 同意 a 纳入 | 测试文件本地保留(`.gitignore:71` 排除 tests/,F2-2 入仓后一并 commit) |
| D4 | 走查 mock 授权 | 授权 a | W1-T8 走查时调 PATCH 切 sandbox=2026-03-01,完成切回 live |
| D5 | commit 拆分 | 1 个 commit | 本审计对应 1 个 commit `fix(ai): F1-5a 沙箱 batch-run 接入 runtime_context (W1)` |
| D6 | P20 SPEC 同步 | 纳入 a | F1-5a 完整改造后 SPEC §6/§10/§11/§15 在 F1-5b commit 一并同步(因 F1-5b 才完整收口) |

## 二、本次产出(F1-5a 范围)

### 2.1 后端 — `_run_batch` 真实化(A1)

**文件**:`apps/backend/app/services/ai/admin_service.py`

| 改动 | 行号(改动后) | 说明 |
|---|---|---|
| import | L7-19 | 加 `RuntimeContext / get_runtime_context / runtime_insert_columns`,`TYPE_CHECKING: AIDispatcher` |
| 常量 | L31 | `_BATCH_CONCURRENCY = 5`(Neo 决策) |
| `__init__` | L40-45 | 加 `_dispatcher: AIDispatcher \| None = None` 字段 + `set_dispatcher` 方法,lazy 注入 |
| `_run_batch` | 整段重写 | 真正实现:`Semaphore(5)` + `asyncio.gather` + `return_exceptions=True`;每个 sub-call 调 `dispatcher.run_single_app(app_type, context={..., business_date}, triggered_by=f"batch:{batch_id}")` |

**关键设计点**:
- **快照防漂移**:`ctx_snapshot` 在 estimate 阶段抓取,confirm 取出传给 worker,worker 按快照值执行(避免 Neo 在 estimate→confirm 间切 sandbox 模式造成数据漂移)
- **`return_exceptions=True`**:单个 member 失败不连坐,失败信息已写 ai_run_logs(熔断/限流/超时由 dispatcher.\_run_step 自动处理)
- **`triggered_by="batch:{batch_id}"`**:打标 ai_run_logs,Wave 2 加 `GET /batch-run/{batch_id}/status` 进度查询时 `WHERE triggered_by LIKE 'batch:<id>%'` 即可统计

### 2.2 后端 — `estimate_batch` / `confirm_batch` ctx_snapshot(A2)

**文件**:`apps/backend/app/services/ai/admin_service.py`

| 改动 | 说明 |
|---|---|
| `estimate_batch` | 入口调 `get_runtime_context(site_id)` 抓 RuntimeContext,存入 `_batch_store[batch_id].ctx_snapshot`;日志输出 mode + sandbox_date |
| `confirm_batch` | 取出 `ctx_snapshot`,异步调 `_run_batch(params, ctx_snapshot)` |
| `_batch_store` | 字典结构从 `{params, expires_at}` 扩展为 `{params, ctx_snapshot, expires_at}`;`params` 内加 `batch_id` 字段(传给 `_run_batch` 用于 triggered_by 标注) |

### 2.3 后端 — `confirm_batch_run` 路由 lazy 注入 dispatcher

**文件**:`apps/backend/app/routers/admin_ai.py:283-303`

`_admin_svc` 是模块级单例,不持有 dispatcher 引用;`dispatcher` 通常在 lifespan startup 才初始化。lazy 注入避免模块加载顺序问题:

```python
if _admin_svc._dispatcher is None:
    try:
        from app.ai.dispatcher import get_dispatcher
        _admin_svc.set_dispatcher(get_dispatcher())
    except RuntimeError as exc:
        raise HTTPException(503, f"Dispatcher 未初始化: {exc}")
```

### 2.4 后端 — `retry_trigger_job` INSERT 落 runtime 列(A4)

**文件**:`apps/backend/app/services/ai/admin_service.py:380-422`

INSERT 显式拼接 `runtime_mode` + `sandbox_instance_id` 列(用 `runtime_insert_columns(site_id)` helper),与原 trigger_job 的 runtime 上下文保持一致。避免依赖默认值 'live' 导致 sandbox 模式下重试丢失 sandbox 标记。

### 2.5 后端 — App2 / App2a 3 行漏传 ref_date(A3)

| 文件:行 | 改动 |
|---|---|
| `app2_finance_prompt.py:817` | `_calc_date_range(board_time)` → `_calc_date_range(board_time, ref_date=get_runtime_context(site_id).business_date)`(储值卡余额变化板块) |
| `app2_finance_prompt.py:841` | 同上(日粒度 series + 异常检测窗口) |
| `app2a_finance_area_prompt.py:466` | 同上(区域日粒度 series) |

### 2.6 后端 — `runtime_context.get_runtime_context` 加 `bind_to_session` 参数

**文件**:`apps/backend/app/services/runtime_context.py:88-148`

新增可选参数 `bind_to_session: bool = False`,True 时返回前调用 `apply_runtime_session_vars(conn, ctx)`,激活 GUC `app.current_business_date` / `app.current_runtime_mode`,使 ETL 库 26 个 `app.v_*` 视图自动按业务日上界裁剪(`app.business_date_now()` 函数读取 GUC)。

**说明**:激活机制就位,但只在 `run_log_service.create_log` 试点开启(2.7);其余完整传递留 F1-5b。

### 2.7 后端 — `run_log_service.create_log` 启用 `bind_to_session=True`

**文件**:`apps/backend/app/ai/run_log_service.py:62-65`

试点改造,验证 GUC 激活机制可用。运行时事务级 SET LOCAL 在该 INSERT 期间生效。

### 2.8 DB — `biz.ai_run_logs` 复合索引补建(A6)

**文件**:`db/zqyy_app/migrations/20260505__ai_run_logs_runtime_index.sql`

```sql
CREATE INDEX IF NOT EXISTS idx_ai_run_logs_runtime_site
    ON biz.ai_run_logs (site_id, runtime_mode, sandbox_instance_id, created_at DESC);
```

补齐其他 6 张业务表的复合索引模式。admin-web 按 runtime 维度过滤不再全表扫。

### 2.9 前端 — AIOperations.tsx sandbox 提示条(A5)

**文件**:`apps/admin-web/src/pages/AIOperations.tsx`

| 改动 | 说明 |
|---|---|
| import | 加 `Alert`(antd) + `fetchRuntimeContext / RuntimeContext`(api/runtimeContext) |
| `useEffect` | 拉 `cacheSiteId` 对应的 runtime context |
| Title 后 Alert | sandbox 模式下显示 `沙箱模式 · 业务日 X · 实例 sbx_*` + 详细描述(影响范围 + 切回 live 入口) |

## 三、未做(留 F1-5b 完整收口)

### 3.1 B1 — `admin_service.py` 6 处 `CURRENT_DATE` → `business_date`

涉及 `_get_range_stats` / `_get_7d_trend` / `_get_app_distribution` / `get_budget` 等,需要为这些函数加 site_id 参数链路(当前部分函数 site_id 是可选 None,改造涉及函数签名变更),工作量较大。F1-5b 完整 zqyy_app RLS 视图层落地时,这些查询自然走 `app.v_*` 视图(GUC 自动裁剪),无需改函数签名,更优雅。

### 3.2 B2 — `fdw_queries.py:113, 2552` 异常分支兜底改 `ctx.business_date`

异常分支用 `_date.today().isoformat()`,sandbox 触发异常会泄露真实今日。F1-5b 完整 fdw_queries GUC 接入时一并改。

### 3.3 GUC 完整传递

`run_log_service.create_log` 试点开启了 `bind_to_session=True`,但其他真正走 ETL 库视图的函数(fdw_queries 11+ 处 / page_context 等)没改。F1-5b 一并改造。

### 3.4 测试 3 套

`test_runtime_context.py` / `test_admin_ai_batch_runtime.py` / `test_dispatcher_runtime.py` 因 `.gitignore:71` 排除 tests/,本次会话写本地。F2-2 tests/ 入仓(Wave 5)时一并入仓 + 跑 CI。

### 3.5 P20 SPEC 同步

§6/§10/§11/§15 同步**留 F1-5b commit 一并改**(因为 F1-5b 才是 P20 SPEC C 方案的完整收口,届时 SPEC 反映"两库都用 GUC + zqyy_app 加 RLS 视图层"的最终状态更准确)。

## 四、风险与回滚

| 项 | 风险 | 回滚 |
|---|---|---|
| `_run_batch` 真实化 | 调用 dispatcher 失败时单 member 失败已写 ai_run_logs,不连坐;dispatcher 未注入时返 503 | git revert 当前 commit |
| `ctx_snapshot` 设计 | 内存 dict,worker crash 时 batch 丢失 — 接受(原 batch_id 设计本就 in-memory) | 同上 |
| `retry_trigger_job` 加 runtime 列 | INSERT 多 2 列,与已有索引兼容(只加列不删字段) | 同上 |
| App2/2a 3 行 ref_date | 仅在已有 try 块内改,异常 fallback 不变 | 同上 |
| `bind_to_session` 加可选参数 | 默认 False,旧调用不变;只在 run_log_service 试点开启 | 同上 |
| `migration` 仅 CREATE INDEX IF NOT EXISTS | 索引可重复执行,不破坏数据 | `DROP INDEX IF EXISTS biz.idx_ai_run_logs_runtime_site` |
| 前端 sandbox 提示条 | 失败时不阻断页面渲染(catch 后静默) | git revert |

## 五、F1-5b 待办(下一个 commit)

- B1 admin_service 6 处 CURRENT_DATE → business_date(随 RLS 视图层一并)
- B2 fdw_queries.py:113, 2552 异常分支兜底
- GUC 完整传递(fdw_queries / page_context / 等)
- zqyy_app 加 RLS 视图层(7+ 视图 `app.v_*`)+ 后端 46 处 `FROM biz.*` → `FROM app.v_*`
- 测试 3 套(本地写,F2-2 入仓时同步)
- P20 SPEC §6/§10/§11/§15 同步(反映 F1-5a + F1-5b 完整收口状态)

## 六、验证(F1-5a 范围)

### 6.1 syntax 检查(已通过)

```bash
.venv/Scripts/python.exe -m py_compile \
  apps/backend/app/services/runtime_context.py \
  apps/backend/app/services/ai/admin_service.py \
  apps/backend/app/routers/admin_ai.py \
  apps/backend/app/ai/run_log_service.py \
  apps/backend/app/ai/prompts/app2_finance_prompt.py \
  apps/backend/app/ai/prompts/app2a_finance_area_prompt.py
# → all-files-compile-ok
```

### 6.2 W1-T8 走查待验证项(F1-5a + F1-5b 改完后一并验证)

- 切 sandbox=2026-03-01 后触发 1 次 app2_finance,验证 `ai_run_logs.runtime_mode='sandbox'` + prompt JSON `current_time='2026-03-01 HH:MM'` + `ai_cache.target_id` 含 `sbx_*:` 前缀
- admin-web `/ai/operations` 顶部 sandbox 条带显示
- batch-run 真正执行(estimate→confirm 后 ai_run_logs 出现 `triggered_by='batch:<batch_id>'` 记录)
- estimate→切模式→confirm:验证 worker 用 estimate 时刻的 ctx_snapshot,**不**用 confirm 时刻的新模式

## 七、commit 建议

```
fix(ai): F1-5a 沙箱 batch-run 接入 runtime_context (W1 / 阶段 A 主体)

Neo F1-5 反馈: "让沙箱起到其真正的作用. 真正的模拟日期, 仅能看到沙箱设定日期
及之前日期的数据, 并运行 AI 的各个业务."

调研发现 (4 个并行子代理): batch-run 端点 _run_batch 是空壳 stub
(只 logger.info, 实际不跑 AI), GUC apply_runtime_session_vars 0 处调用
(dead code), 7 张业务表 6 张有 runtime 复合索引唯独 ai_run_logs 漏建,
App2/2a 3 行 _calc_date_range 漏传 ref_date.

本 commit (F1-5a 阶段 A 主体, F1-5b 后续完整 zqyy_app RLS 视图层):

后端核心:
- admin_service.py: _run_batch 真实化 (Semaphore(5)+asyncio.gather+
  return_exceptions=True+ctx_snapshot 防漂移); estimate 入口抓
  RuntimeContext 快照, confirm 取出传给 worker
- admin_ai.py: confirm_batch_run lazy 注入 dispatcher
- admin_service.retry_trigger_job: INSERT 落 runtime_mode +
  sandbox_instance_id 列 (用 runtime_insert_columns helper)
- runtime_context.py: get_runtime_context 加 bind_to_session 参数,
  激活 GUC app.current_business_date / app.current_runtime_mode
- run_log_service.create_log: 启用 bind_to_session=True 试点

App2/2a 3 行 ref_date 修复:
- app2_finance_prompt.py:817 储值卡余额变化板块
- app2_finance_prompt.py:841 日粒度 series + 异常检测窗口
- app2a_finance_area_prompt.py:466 区域日粒度 series

DB:
- migrations/20260505__ai_run_logs_runtime_index.sql:
  补 (site_id, runtime_mode, sandbox_instance_id, created_at DESC) 复合索引

前端:
- AIOperations.tsx: 顶部加 sandbox 模式提示条 (Alert 显示 sandbox_date +
  sandbox_instance_id + 影响范围 + 切回 live 入口)

未做 (留 F1-5b 完整 zqyy_app RLS 视图层一并):
- B1 admin_service 6 处 CURRENT_DATE → business_date
- B2 fdw_queries 异常分支兜底
- GUC 完整传递 (fdw_queries / page_context 等)
- 测试 3 套 (.gitignore:71 排除, F2-2 入仓时 commit)
- P20 SPEC §6/§10/§11/§15 (F1-5b 完整收口后同步更准确)

Neo 决策: docs/_overview/wave1-findings/F1-5-impl-decisions.md

详见 docs/audit/changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md
```
