# P1-13 P4 前置修复 深度依赖调研

> 日期: 2026-05-04
> 触发: Neo 担忧"前后依赖和上下文比看起来更复杂"
> 范围: T3 / T4 / T5 / T6 四点修复的真实代码现状 + 隐藏关联 + 风险评估

---

## TL;DR

- **`tasks.md` 全部标 [x] 已完成,但实际状态参差不齐**
- T3/T4 在 `note_reclassifier.py` + `note_service.py` 已实现,但**有沙箱兼容性硬伤**
- T5 仅"半实施":sync handler(recall_detector / note_reclassifier)已用 `update_job_last_run_at`,但 cron 路径 next_run_at 仍在独立事务、AI async handler 完全没拿到 job_id
- T6 代码默认值已改 7 点,但**迁移脚本 `2026-03-15__p52_update_cron_0700.sql` 不存在**
- **T3/T4/T5 全部 0 测试覆盖**(`tests/test_p52_*.py` 全部缺失,但 tasks 标 [x])
- 业务的真实风险已经被 2026-04-08 Fix-13 改造转移:`recall_detector` 自己已在 `_process_pair` 里"关旧开新"follow_up_visit,把 note_reclassifier 推到了**辅助角色**

---

## 一、当前实现状态精确盘点(回答 Q1)

### 1.1 状态对照表

| T | design.md 描述 | 实际代码现状 | 文件:行 | 评估 |
|---|----------------|--------------|---------|------|
| T1 | 已实现 ✅ | `WHERE status IN ('active','abandoned')` + `CASE WHEN status='abandoned' THEN 1 ELSE 0 END ASC` + 返回 abandon_reason | `apps/backend/app/services/task_manager.py:177-184, 261` | **真已实现** |
| T2 | 已实现 ✅ | `task_type IN ('high_priority_recall','priority_recall','follow_up_visit')` 出现在 active_tasks_map 查询(234 行),`_process_pair` 仅完成 recall 类型(381-409) | `apps/backend/app/services/recall_detector.py:234, 381-409` | **已实现但语义已变**(2026-04-08 Fix-13 重写) |
| T3 | 需修改 | `note_reclassifier.run()` 已含三分支(completed→跳过 / active→inactive+顶替 / 否则正常创建) | `apps/backend/app/services/note_reclassifier.py:179-247` | **已实现,但有沙箱缺陷** |
| T4 (note_service) | 需修改 | `create_note()` 已含"有备注即完成"逻辑,不依赖 ai_score | `apps/backend/app/services/note_service.py:229-253` | **已实现** |
| T4 (note_reclassifier) | 需修改 | 找到备注 → `task_status='completed'`;未找到 → `'active'`;不依赖 ai_score | `apps/backend/app/services/note_reclassifier.py:138-177` | **已实现** |
| T5 sync handler | 需修改 | `update_job_last_run_at()` 函数已有,recall_detector 与 note_reclassifier 在 commit 前调用 | `apps/backend/app/services/trigger_scheduler.py:68-80`;`recall_detector.py:111-118`;`note_reclassifier.py:289-294` | **半实施** |
| T5 cron 路径 | 需修改 | `check_scheduled_jobs()` 中 next_run_at 仍在 handler commit **之后** 的独立事务(176-185 行) | `trigger_scheduler.py:174-185` | **未完全实施** |
| T5 AI async | 隐含 | `_invoke_handler` 把 async coroutine 丢给 `asyncio.create_task` / 后台线程,handler 完全拿不到 conn/job_id | `trigger_scheduler.py:36-50`;`dispatcher.py:1147-1197` | **未实施且无法实施**(架构限制) |
| T6 代码默认值 | 需改 4→7 | `_calculate_next_run` 默认值已是 `"0 7 * * *"` | `trigger_scheduler.py:232` | **已实现** |
| T6 迁移脚本 | tasks 7.2 标 [x] | `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql` **不存在** | (缺失) | **未实施** |
| 测试覆盖 | tasks 标 [x] 8 项 | `tests/test_p52_*.py` 0 个 | (缺失) | **完全缺失** |

### 1.2 关键发现

1. **tasks.md 标 [x] 不可信**:8 个 [x] 任务里至少 3 项(test_p52_* 测试 / 迁移脚本 / cron next_run_at 同事务) 没有产出物。
2. **真实工作量已大幅缩减**:T3/T4 主体逻辑确实已写。
3. **关键不一致**:design.md 说 T6 代码默认值"仍为 0 4",但当前代码是 0 7。说明 design.md 是修复**前**的快照,不是修复后的状态。

---

## 二、依赖网 / 调用链全图

### 2.1 follow_up_visit 任务的**三条**创建路径

> Spec 只描述了路径 A 与 C,**路径 B 是 2026-04-08 Fix-13 改造引入的**,SPEC 与文档至今未同步。

```
路径 A — task_generator (cron 07:00)
  trigger_scheduler.check_scheduled_jobs()
    → task_generator.run()
      → _process_assistant() → _process_pair()
        → 基于 NCI/RS 指数判定 task_type
        → INSERT biz.coach_tasks (含 runtime_mode/sandbox_instance_id)
        → Case A(同类型已存在跳过) / Case B(替换/混合)
  task_generator.py:599-720

路径 B — recall_detector._process_pair (event: etl_data_updated, Fix-13 后)
  trigger_scheduler.fire_event("etl_data_updated")
    → recall_detector.run() → _process_site() → _process_pair()
      Step 3: 关闭已有 active follow_up_visit (UPDATE → inactive + superseded_by_new_visit 历史)
      Step 4: INSERT 新 follow_up_visit (status='active', expires_at=pay_time+72h, 含 runtime_mode)
      Step 5: 触发 fire_event("recall_completed")
  recall_detector.py:411-470

路径 C — note_reclassifier.run (event: recall_completed)
  trigger_scheduler.fire_event("recall_completed")
    → note_reclassifier.run()
      Step 1: 查 service_time 之后的第一条 normal 备注 → note_id
      Step 2 (T4): note_id 是否存在 → task_status = 'completed' / 'active'
      Step 3 (T3): 冲突检查
        - 已有 completed → 跳过创建(跳过路径 C 的 INSERT)
        - 已有 active → 旧任务 inactive + superseded
        - 否则正常创建
      Step 4: INSERT follow_up_visit (T3+T4 结果)
  note_reclassifier.py:179-296
```

### 2.2 路径 B + C 的 Race / 重复创建链

```
T0: ETL 跑批 → fire_event("etl_data_updated")
T1: recall_detector._process_pair 顺序处理:
    T1.1: 关闭旧 active follow_up_visit (UPDATE → inactive)
    T1.2: INSERT 新 follow_up_visit (status='active')
    T1.3: commit
    T1.4: fire_event("recall_completed")  ← 这里触发路径 C
T2: note_reclassifier.run 收到 recall_completed:
    T2.1: 查 normal 备注(可能找到也可能找不到)
    T2.2: 冲突检查:此时 path B 在 T1.2 创建的 follow_up_visit 已 commit 且 status='active'
          ↓
          note_reclassifier 看到的是 path B 刚创建的 active 任务
          ↓
          T3 顶替逻辑: 旧任务 → inactive + superseded
          T4 任务状态: 重新创建 follow_up_visit (completed 或 active)
```

**结论**: 在 Fix-13 之后,**每次 recall_detector 跑一次,follow_up_visit 任务会被立刻顶替一次**。链条:
- recall_detector 创建 active follow_up (path B)
- → note_reclassifier 立刻把它顶替成 inactive,创建 completed/active 的新 follow_up (path C)
- → coach_task_history 写两条 superseded(一条 superseded_by_new_visit, 一条 superseded)

每个 MAIN 关系对每天都会经历这个"创建→顶替"序列。这不算 race condition,但**任务表与历史表都被污染**,且 Spec 没描述这种行为。

---

## 三、T3/T4 隐藏关联(回答 Q2)

### 3.1 同表操作冲突分析

T3 处理"创建 follow_up_visit 时的冲突",T4 处理"创建备注后完成 follow_up_visit"。两者都写 `biz.coach_tasks`。

**真实顺序时间线(用户视角)**:
```
T0  ETL 跑入新结算 → fire_event("etl_data_updated")
T1  recall_detector 完成召回任务 + 关旧开新 follow_up(B 路径)
T2  fire_event("recall_completed") → note_reclassifier 介入
T3  note_reclassifier 走 T3+T4 逻辑(关 path B 创建的任务,自己再开)
T4  助教打开小程序 → 看到 active follow_up_visit
T5  助教提交备注 → note_service.create_note → T4 逻辑 → status='completed'
```

如果 path C 在 T3 阶段创建的就是 `status='completed'`(回溯发现已有备注),T5 不会再触发完成(`task_info["status"]` 不是 `active`)。这是兼容的。

### 3.2 真正的隐藏关联点

#### 关联点 1:唯一索引未覆盖 runtime_mode

`db/zqyy_app/schemas/biz.sql:430` 的唯一索引:
```sql
CREATE UNIQUE INDEX idx_coach_tasks_runtime_unique_active
ON biz.coach_tasks (site_id, assistant_id, member_id, task_type, runtime_mode, sandbox_instance_id)
WHERE status = 'active';
```

但 `note_reclassifier.run()` 的冲突检查 SQL(182-191 行)**完全没有 runtime_mode/sandbox_instance_id 过滤**:
```sql
SELECT id, status FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
  AND task_type = 'follow_up_visit'
  AND status IN ('active', 'completed')
```

后果:
- live 模式下,note_reclassifier 看到的是**全部 runtime_mode 的混合行**
- live 跑时碰到 sandbox 残留 completed 行 → 误判"已完成"跳过创建
- sandbox 跑时碰到 live 的 completed 行 → 误判已完成
- 唯一索引按 runtime_mode 区分,**真插入也不会冲突**,但前置查询逻辑早于 INSERT,可能误跳过

而 `recall_detector._process_pair`(411-419 行)关闭旧任务的 SQL **是带 runtime_mode/sandbox_instance_id 的**(参考 P20 设计)。**两条路径对同一张表的过滤标准不一致**。

#### 关联点 2:INSERT 不带 runtime_mode

`note_reclassifier.py:252-269` INSERT 完全没有 runtime_mode/sandbox_instance_id 列。表的 DEFAULT 是什么?

<br>

#### 关联点 3:T3 冲突检查与 INSERT 之间无锁

```python
# Step 3: SELECT (no FOR UPDATE)
existing = cur.fetchone()
conn.commit()        # ← 这里 commit 后释放快照

# Step 4: INSERT (新事务)
cur.execute("BEGIN")
cur.execute("INSERT ...")
```

中间窗口期,另一个 fire_event("recall_completed") 并发触发(Spec 没说不会),会双向 INSERT,触发唯一索引冲突 → 异常 → 整个 handler 回滚 → reclassified_count/tasks_created 都返回 0。

实际能否并发?`trigger_scheduler.fire_event` 内部对 sync handler 是同步顺序调用(124 行 `_invoke_handler`),单进程内不会并发。但多进程部署(uvicorn workers > 1)+ ETL 同时触发 → 仍可能并发。

### 3.3 实施顺序建议

T3 与 T4 在同一个 `note_reclassifier.run()` 内部实现,**已经协同了**。冲突检查(T3) 在前,INSERT 拿 task_status(T4) 在后。这是合理的。

不存在"T3 不实施 T4 单独实施"或反过来的可能性。**两者必须同步实施**(已经做到)。

---

## 四、T5 爆炸半径(回答 Q3)

### 4.1 影响的触发器清单

`apps/backend/app/main.py:81-84` + `dispatcher.py:1265-1267` 注册的 handlers:

| job_type | handler | 类型 | 是否消费 conn/job_id |
|----------|---------|------|----------------------|
| task_generator | `lambda **_kw: task_generator.run()` | sync, cron 07:00 | **不消费**(忽略 kw) |
| task_expiry_check | `lambda **_kw: task_expiry.run()` | sync, interval | **不消费** |
| recall_completion_check | `recall_detector.run` | sync, event | **消费 job_id** |
| note_reclassify_backfill | `note_reclassifier.run` | sync, event | **消费 job_id** |
| ai_data_cleanup | `_run_cleanup` (`cleanup_service.py`) | 待查 | 待查 |
| ai_consumption_settled, ai_note_created, ai_task_assigned, ai_dws_completed, ai_app2_finance_prewarm | dispatcher 注册的 async handlers | **async**, event/cron | **不消费**(`**_kw: Any` 接走) |

### 4.2 关键发现:T5 的"半实施"

design.md 第 207-211 行写:"采用方案 A — 将 last_run_at 更新纳入 handler 同一事务"。

实际只在 sync 路径生效:
- `recall_detector.run()`:111-118 行调用 `update_job_last_run_at`
- `note_reclassifier.run()`:289-294 行调用 `update_job_last_run_at`

但 **AI async handler 路径完全失效**:
- `_invoke_handler`(36-50 行)对 async coroutine 走 `asyncio.create_task` 或线程
- AI handler 签名 `async def handle_xxx(payload: dict | None = None, **_kw: Any)`,`**_kw` 接走 conn/job_id 但 handler 不知道
- handler 真正执行时,`fire_event` 主调用线程已经返回,无法把 conn 传到 handler 内部

也就是说:
- 路径 1(sync handler)last_run_at 在 handler 事务内更新 ✓
- 路径 2(async AI handler)`fire_event` 第 124 行调用后立即返回 executed,**完全没有更新 last_run_at**(代码看不到任何路径触发)
- 路径 3(cron sync handler)`check_scheduled_jobs` 第 175-185 行在 handler commit **之后** 的独立事务更新

### 4.3 锁等待 / 幂等性 / 沙箱兼容性

#### 锁等待
sync handler 现状下,`update_job_last_run_at` 只是 1 行 UPDATE,锁定 `biz.trigger_jobs.id = job_id` 一行。事务延长 ≈ 0。**无锁风险**。

#### 幂等性
- `recall_detector` 路径 B 的 INSERT recall_events 是 `ON CONFLICT DO NOTHING`,幂等 ✓
- `note_reclassifier` 没有 ON CONFLICT,但 T3 冲突检查可视作软幂等(已 completed 跳过,已 active 顶替)
- 但 T3 已存在的"已 completed 跳过"分支,如果相同 payload 重跑,**reclassified_count 是 0(没改备注)、tasks_created 是 0**(跳过)。如果 last_run_at 没更新过(handler 失败),重跑安全。
- 但 **task_generator 路径(cron 07:00)** 没消费 job_id,handler commit 失败不会回滚 last_run_at,因为 last_run_at 是 cron 路径在 handler 之后的独立事务。换言之,`check_scheduled_jobs` 仍然有 GAP-9 描述的风险。

#### 沙箱兼容性
P20 文档明确:`biz.trigger_jobs` 切沙箱不暂停,cron 仍按 7 点跑。task_generator 跑时通过 `task_runtime_filter` 拿当前 site 的 runtime_mode → 写带 sandbox_instance_id 的 coach_tasks 行。这条链路是 OK 的。**T5 改造对沙箱模式没新引入风险**。

#### 与 P0-6 clearAllTasks
P0-6 是清空小程序待办,操作的是 `biz.coach_tasks`(批量 status='inactive')。它不动 `trigger_jobs`,与 T5 完全不重叠。

#### handler 已发出的副作用
- recall_detector 在 commit 前调用 `update_job_last_run_at`,然后 commit。如果 commit 失败,last_run_at 不更新,handler 已 commit 的前面那些 conn.commit() 已生效(`_process_pair` 内每一对都 commit 一次)。下次 fire_event 重跑时:
  - recall_events 有 ON CONFLICT,跳过
  - coach_tasks 已 completed 的不会重复处理
  - 已 INSERT 的 follow_up_visit 会被冲突检测到 → 顶替自己?
- 这意味着 T5 的"事务一致性"在 recall_detector 的实际架构下**无法做到"全 handler 原子"**,因为 `_process_pair` 内部多次 commit。design.md 的需求 5.1/5.2 跟实际架构冲突。

---

## 五、T6 连锁影响(回答 Q4)

### 5.1 ETL 时间窗口对齐

`apps/etl/connectors/feiqiu/CLAUDE.md` 与 `docs/_overview/extra-dev-trace-wave-schedule.md` 中提到:
- 飞球 connector 拉取增量约每天凌晨 2-3 点完成
- DWD/DWS 计算约凌晨 4-5 点完成
- ETL → 后端通过 fire_event("etl_data_updated") 推动

**4:00 → 7:00 的影响**:
- 4:00 跑时 ETL 可能尚未完工,`task_generator` 通过 FDW/直连读 ETL 库的 DWS,可能读到**昨天的旧 DWS 数据**
- 7:00 跑时 ETL 已稳定完成,WBI/NCI/RS 都是当天准确值

这其实是修复 4:00 错误调度的根因。改 7:00 反而**降低数据竞争风险**。

### 5.2 与门店营业时间对齐

朗朗桌球营业时间(根据用户记忆 + ETL 经验)大致 12:00 起。早班助教一般 11:00-12:00 上班,7:00 比 4:00 更接近"上班前刚生成"。

**潜在风险**:如果将来引入"早 8 点查任务"的工作流,7:00 可能踩在边界上。但当前没有这种需求。

### 5.3 沙箱兼容性

P20 第 322 行:沙箱不暂停 trigger_jobs。task_generator 7:00 触发时,如果 site 是 sandbox,会按 sandbox_date 生成 sandbox 的任务。这是预期行为。

**T6 不引入新风险**。但需要注意:
- 沙箱演示当天若处于沙箱模式,7:00 任务生成会带 sandbox 标识
- 切回 live 后,sandbox 任务对 live 不可见,符合 P20 隔离设计

### 5.4 迁移脚本缺失的影响

代码默认值已是 7:00,但生产环境 `biz.trigger_jobs.task_generator.trigger_config.cron_expression` 当前值未知:
- 如果 DB 里写的是 `"0 4 * * *"`,**生产环境仍按 4 点跑**(代码默认值仅在配置缺失时生效)
- 如果 DB 里已是 `"0 7 * * *"`(种子数据初始化时写入),则 OK

这就是为什么 design.md 要求**幂等 UPDATE 迁移**作为兜底:
```sql
UPDATE biz.trigger_jobs
SET trigger_config = jsonb_set(trigger_config, '{cron_expression}', '"0 7 * * *"')
WHERE job_name = 'task_generator';
```

**该迁移当前不存在**,生产环境状态需要现场 SELECT 验证。

---

## 六、与 P5 AI 应用的关联(回答 Q5)

### 6.1 ai_analyze_note 已不是占位

design.md 与 requirements.md 多次描述"ai_analyze_note() 占位返回 None"。但实际代码:

- `note_service.py:56-91`:`ai_analyze_note` **已经是真实 AI 调用**(2026-03-27 改造,通过百炼 App6 评分)
- 通过 `_async_ai_score`(94-113 行)在 `asyncio.create_task` 后台执行
- 返回 score 后写入 `biz.notes.ai_score`
- 与任务完成判定**完全解耦**(T4 已实现)

但 `note_reclassifier.py:62-70`:**这里的 `ai_analyze_note` 仍是占位**(返回 None)
```python
def ai_analyze_note(note_id: int) -> int | None:
    return None
```

**两个文件有同名函数,行为不一致**!这是隐藏的不一致。

### 6.2 P5 真实接入时的影响

note_reclassifier 的 ai_analyze_note 何时换成真实调用?目前的代码结构:
- `note_reclassifier.run()` 154 行调用 `ai_analyze_note(note_id)`
- 返回值仅用于 154-167 行的 `UPDATE biz.notes SET ai_score = %s`

如果 P5 接入,需要:
- 把 note_reclassifier 的 `ai_analyze_note` 改为 import 自 `note_service.ai_analyze_note`(注意它是 async)
- 或者整体 fire_event("ai_note_reclassified") 走 dispatcher 路径

T4 的设计已经"解耦了 AI 评分与任务完成",所以 **P5 接入不会影响 T4 行为**。这是一个稳定的改造。

### 6.3 ai_note_created 的双链路

`note_service.create_note` 第 264-277 行:
```python
if target_type == "member":
    fire_event("ai_note_created", {
        "site_id": site_id, "member_id": target_id,
        "note_content": content, ...
    })
```

这触发 **App6 → App8 链路**(`dispatcher.py` 注册的 `ai_note_created` handler)。
但同时 `_async_ai_score` 也异步调用 App6。**App6 被调两次**?
- 路径 X:`asyncio.create_task(_async_ai_score(...))` → `ai_analyze_note` → App6
- 路径 Y:`fire_event("ai_note_created")` → dispatcher.handle_trigger → 内部也调 App6(待证)

如果 dispatcher 也调 App6,会产生**重复评分 + 浪费 token**。这是一个 P5 的潜在 bug,但不在 T4 范围内,只是顺带发现。

---

## 七、风险等级矩阵

| T | 当前状态 | 修复必要性 | 风险等级 | 推荐 |
|---|----------|------------|----------|------|
| T1 | 已实现且生产可用 | 不需要修 | 低 | **不修** |
| T2 | 已实现(2026-04-08 Fix-13 重写后语义已变) | 不需要修 | 低,但 spec 与现状已脱节 | **不修代码,补 docs** |
| T3 主体 | 已实现三分支 | 不需要修主逻辑 | 中 | **保留主逻辑** |
| T3 sandbox 隔离 | **缺陷:冲突检查 SQL 不带 runtime_mode** | 需要补丁 | **中-高**(sandbox 演示踩雷) | **补 runtime_mode 过滤** |
| T3 INSERT 沙箱列 | **缺陷:INSERT 不带 runtime_mode/sandbox_instance_id** | 需要修 | **中-高** | **加列** |
| T3 与 path B 冲突 | recall_detector 自己已开 follow_up,导致每次 path C 立即顶替 path B | 设计层问题 | **中**(数据污染 + 历史表噪音) | **设计层选择**:删除 path C 中的 INSERT,只做"补 task_status='completed'"的 UPDATE? |
| T4 note_service | 已实现 | 不需要修 | 低 | **不修** |
| T4 note_reclassifier | 已实现 | 不需要修 | 低 | **不修** |
| T4 ai_analyze_note 不一致 | note_service vs note_reclassifier 同名函数实现不同 | 不影响 T4 | 低 | **P5 接入时统一** |
| T5 sync handler | 已实施 | 不需要修 | 低 | **不修** |
| T5 cron 路径 next_run_at 同事务 | 未实施(在 handler commit 后独立事务) | design.md 要求,但 task_generator 等不消费 conn/job_id 的 handler 改造工作量大 | **中** | **不改架构,接受幂等性兜底**(同 GAP-9 当前结论) |
| T5 AI async handler | 完全未实施(架构限制) | 设计需求超出实施能力 | **中-高**(AI 触发器实际 last_run_at 永远不更新?) | **优先验证 AI handler 是否真的不更新 last_run_at,如真,补一条同步更新逻辑** |
| T6 代码默认值 | 已改 0 7 | 不需要修 | 低 | **不修** |
| T6 迁移脚本 | **缺失** | 需要补 | **中**(生产 DB 当前 cron 未知,可能仍 4 点) | **必须建迁移 + 验证生产 DB** |
| 测试覆盖 | 0 个 P52 测试存在 | 需要补 | **高**(tasks 标 [x] 但产物无) | **要么补测试,要么改 tasks.md 标 [ ]** |

---

## 八、推荐实施顺序

### Phase 0:**先验证生产 DB 现状**(零代码,5 分钟)

```sql
-- 1. 验证 task_generator 当前 cron
SELECT job_name, trigger_config->>'cron_expression', last_run_at, next_run_at
FROM biz.trigger_jobs WHERE job_name = 'task_generator';

-- 2. 看 follow_up_visit 任务的 runtime_mode 分布
SELECT runtime_mode, sandbox_instance_id, status, COUNT(*)
FROM biz.coach_tasks
WHERE task_type = 'follow_up_visit'
GROUP BY 1,2,3 ORDER BY 1,2,3;

-- 3. 看 note_reclassifier 是否有触发记录
SELECT job_name, last_run_at, last_error
FROM biz.trigger_jobs WHERE job_name = 'note_reclassify_backfill';

-- 4. 看每天有多少 path B → path C 的"创建-顶替"链
SELECT DATE(created_at), COUNT(*)
FROM biz.coach_task_history
WHERE action = 'superseded' AND new_task_type = 'follow_up_visit'
GROUP BY 1 ORDER BY 1 DESC LIMIT 7;
```

**先看真实情况,再决定修什么**。

### Phase 1:**最小修复**(必须做)

1. **建 T6 迁移脚本**(若 Phase 0 验证 DB cron 不是 0 7)
   - 文件:`db/zqyy_app/migrations/2026-05-04__p52_update_cron_0700.sql`
   - 内容:幂等 UPDATE + 验证 SQL 注释 + 回滚 SQL 注释
   - 不破坏任何已有逻辑

2. **note_reclassifier 加 runtime_mode 过滤**
   - `note_reclassifier.py:179-194` 冲突检查 SQL 加 `AND runtime_mode = %s AND sandbox_instance_id = %s`
   - `note_reclassifier.py:252-269` INSERT 加 `runtime_mode, sandbox_instance_id` 列
   - 复用 `runtime_context.task_runtime_filter` / `runtime_insert_columns`
   - 风险:无,完全对齐其他模块的沙箱模式

### Phase 2:**评估后决定**(需要 Neo 决策)

3. **Path B vs Path C 的 follow_up_visit 创建权移交**(决策题)
   - 选项 A:**保持现状**,接受每次都"创建-顶替"的数据污染。优点:不动代码;缺点:任务表/历史表都吃噪音
   - 选项 B:让 note_reclassifier **不再 INSERT,只 UPDATE**(把 path B 创建的 active follow_up,如果有备注就 UPDATE 成 completed)。优点:消除污染;缺点:改动较大,需要重新考虑"无 path B 任务存在"的回退分支
   - 选项 C:让 recall_detector **不再创建 follow_up,完全回到 Spec 设计**(只 path A + path C 创建)。优点:回到原 spec;缺点:撤销 Fix-13,有业务回归风险

4. **T5 AI async handler last_run_at 兜底**
   - 选项 A:在 `_invoke_handler` 走 async 分支前,**调用方**(`fire_event` / `check_scheduled_jobs`)在 dispatch 之后**立即同步**更新 last_run_at(at-most-once 语义)。原因:async handler 失败本身已不是"不更新 last_run_at"能拯救的(已经在后台线程跑了,也不会重跑)
   - 选项 B:dispatcher 改造 — 在 handler 内部接 conn/job_id,自己在 commit 前调 update_job_last_run_at。工作量大

### Phase 3:**测试补漏**(spec-close)

5. **要么补 `tests/test_p52_*.py`,要么改 tasks.md 标 [ ]**
   - 这是 spec 收尾的标准动作,不能让 [x] 与产物不一致

---

## 九、给 Neo 的决策清单

> 答 Y/N 即可,部分选 ABC

### Q1. T1/T2 已实现,但 T2 在 Fix-13 之后语义大变(Spec 没记录)。
- (a) 不修代码,docs 补一条"Fix-13 改造说明"对齐 Spec
- (b) 整个 T1/T2 视为"已 close",不动 spec
- **你选?**

### Q2. T3 在 note_reclassifier 已实现冲突检查,但 SQL 不带 runtime_mode/sandbox_instance_id。
- 是否同意补丁修复?(Y/N)

### Q3. T3 INSERT 也不带 runtime_mode/sandbox_instance_id 列。
- 是否同意补丁修复?(Y/N)

### Q4. follow_up_visit 任务**在 Fix-13 之后**有路径 B(recall_detector)+ 路径 C(note_reclassifier)双重创建,导致每次都"创建-顶替"。
- (a) 接受现状,数据污染由历史表吸收(推荐:风险最小)
- (b) 删除 path C 的 INSERT,只 UPDATE
- (c) 撤销 Fix-13,回到 Spec 原设计
- **你选?**

### Q5. T4 已实现且 ai_analyze_note 在 note_service 已是真实 AI 调用(2026-03-27 改造)。
- 是否同意 T4 视为"已 close",不动?(Y/N)

### Q6. T5 sync handler(recall_detector / note_reclassifier)已实施 last_run_at 同事务。是否接受这部分已 close?(Y/N)

### Q7. T5 cron 路径(check_scheduled_jobs)next_run_at 仍在独立事务。
- (a) 不改,接受 GAP-9 结论(handler 幂等性兜底)
- (b) 改造 task_generator / task_expiry 让它们消费 conn/job_id(工作量较大)
- **你选?**

### Q8. T5 AI async handler 完全没机会更新 last_run_at(架构限制)。
- (a) 不改,在 fire_event 主线程 dispatch 后立即同步更新(at-most-once)
- (b) 改造每个 AI async handler 让它自己更新(工作量大)
- (c) 暂不修,先验证生产 DB 中 ai_* trigger_jobs 的 last_run_at 是否真的不更新
- **你选?**

### Q9. T6 代码默认值已改,但迁移脚本不存在。
- (a) 立即建 `2026-05-04__p52_update_cron_0700.sql` 幂等 UPDATE
- (b) 先 SELECT 生产 DB,如果已是 0 7 就不补
- **你选?**

### Q10. tasks.md 全部标 [x] 但 8 项测试文件缺失。
- (a) 必须补全 P52 属性测试(工作量大)
- (b) 接受现状,改 tasks.md 标 [ ] 注明跳过原因
- (c) 选关键属性补几个(Property 5/6/7/8/11)
- **你选?**

### Q11. Phase 0(纯 SELECT 验证生产 DB 现状)是否先做?(Y/N)

### Q12. note_reclassifier 与 note_service 有同名 ai_analyze_note 函数,实现不一致。
- (a) 现在统一(import 自 note_service)
- (b) 留到 P5 接入时一并改
- **你选?**

### Q13. design.md / requirements.md 大量描述"占位返回 None"已过期(2026-03-27 已改)。
- (a) 现在更新文档
- (b) 留到 P5 收尾时一并更新
- **你选?**

### Q14. 文档冲突协议:本次发现 design.md 多处与现状不符。
- 是否登记到 `docs/_overview/04-doc-conflicts.md`?(Y/N)

### Q15. 整体推荐顺序:Phase 0(SELECT) → Phase 1(必修) → Q4 决策 → Phase 3(测试)。
- 是否同意?(Y/N)

---

## 附录 A:关键文件:行 索引

- `apps/backend/app/services/note_reclassifier.py:62-70` — ai_analyze_note 占位
- `apps/backend/app/services/note_reclassifier.py:138-177` — T4 任务状态判定
- `apps/backend/app/services/note_reclassifier.py:179-247` — T3 冲突三分支
- `apps/backend/app/services/note_reclassifier.py:252-269` — T3 INSERT(无 runtime_mode)
- `apps/backend/app/services/note_reclassifier.py:289-294` — T5 last_run_at 同事务
- `apps/backend/app/services/note_service.py:56-91` — ai_analyze_note 真实 AI 调用
- `apps/backend/app/services/note_service.py:229-253` — T4 有备注即完成
- `apps/backend/app/services/note_service.py:263-282` — fire_event ai_note_created
- `apps/backend/app/services/recall_detector.py:111-118` — T5 sync 同事务
- `apps/backend/app/services/recall_detector.py:411-470` — Fix-13 path B(关旧开新)
- `apps/backend/app/services/recall_detector.py:473-489` — fire_event recall_completed
- `apps/backend/app/services/trigger_scheduler.py:36-50` — _invoke_handler async 分支
- `apps/backend/app/services/trigger_scheduler.py:68-80` — update_job_last_run_at
- `apps/backend/app/services/trigger_scheduler.py:174-185` — cron 路径 next_run_at(独立事务)
- `apps/backend/app/services/trigger_scheduler.py:232` — T6 代码默认值 0 7
- `apps/backend/app/services/task_generator.py:599-720` — path A
- `apps/backend/app/services/task_manager.py:177-184, 261` — T1
- `apps/backend/app/main.py:81-84` — register_job 注册点
- `apps/backend/app/ai/dispatcher.py:1147-1268` — AI async handlers
- `db/zqyy_app/schemas/biz.sql:430` — coach_tasks 唯一索引(含 runtime_mode)

## 附录 B:Phase 0 验证 SQL(可直接运行)

```sql
-- A. task_generator 当前 cron + last_run_at
SELECT job_name, trigger_config->>'cron_expression' AS cron,
       last_run_at, next_run_at, status
FROM biz.trigger_jobs WHERE job_name = 'task_generator';

-- B. ai_* trigger_jobs 的 last_run_at(验证 T5 AI 半实施风险)
SELECT job_name, trigger_condition, last_run_at, next_run_at
FROM biz.trigger_jobs WHERE job_name LIKE 'ai_%' OR job_type LIKE 'ai_%';

-- C. follow_up_visit 任务 runtime_mode 分布
SELECT runtime_mode, sandbox_instance_id, status, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE task_type = 'follow_up_visit'
GROUP BY 1,2,3 ORDER BY 1,2,3;

-- D. 最近 7 天 path B → path C 的"顶替链"
SELECT DATE(created_at AT TIME ZONE 'Asia/Shanghai') AS day,
       action, COUNT(*) AS cnt
FROM biz.coach_task_history
WHERE action IN ('superseded', 'superseded_by_new_visit', 'created_by_reclassify')
  AND created_at > NOW() - INTERVAL '7 days'
GROUP BY 1, 2 ORDER BY 1 DESC, 2;

-- E. note_reclassify_backfill 是否长期未跑(沙箱演示后可能停)
SELECT job_name, last_run_at, last_error
FROM biz.trigger_jobs WHERE job_name = 'note_reclassify_backfill';
```

## 附录 C:核心结论一句话

> **T1/T2/T4/T6(代码)已实现可用;T3 有沙箱隔离硬伤(SQL 不带 runtime_mode);T5 仅 sync 路径生效,cron + AI async 路径未实施;T6 缺迁移脚本;P52 测试 0 覆盖。** 继续大改前,先跑 Phase 0 那 5 条 SELECT 看清生产 DB 实情,再做决策。
