Files
Neo-ZQYY/docs/_overview/04b-feedback/P1-13-deep-research.md
Neo 509cf43284 chore(docs): Wave 0 调研产出 + P0/P1/P2 反馈调研
建立项目级标杆文档 docs/_overview/ 作为产品全景索引,
解决"PRD 零碎、文档膨胀、跨子系统调研无入口"的问题。

主要内容:
- 00-index 总索引 + 维护协议 + 与 CLAUDE.md 关系
- 01-product-overview 产品全景脑图(6 角色 / 6 子系统 / 数据流 /
  7 业务概念 / 8+1 AI 矩阵 / 22 术语)
- 02a-miniprogram-page-matrix 小程序 21 页业务指纹
- 02b-adminweb-page-matrix admin-web 19 路由业务指纹
- 03-test-spec 测试规范 (L1-L5 分层 + 走查模板 + 75-95 case 估算)
- 04-doc-conflicts 39 条冲突索引(P0×8 / P1×13 / P2×13 + 5 子项)
- 04a/b/c-conflicts-*-detail 业务故事卡(7 字段:关联/逻辑/影响/选项/判定)
- 05-orphan-pages-cleanup admin-web 6 孤儿页面处置(1 归档 + 4 保留)
- WAVES-MASTER-PLAN.md 全 Wave 主计划(0-5,共 22-32 工作日)
- WAVE-1-KICKOFF.md Wave 1 实施 kickoff
- GLOBAL-DECISION-DASHBOARD.md 全局决策仪表板

反馈调研产物:
- 04a-feedback/ P0 两轮反馈(8+8 项决策 + D-1/2/3 + F-1/2 子代理产出)
- 04b-feedback/ P1 两轮反馈(13+1+5 项 + E-1/2/3/4 + G-1/2 子代理产出)
- 04c-feedback/ P2 反馈(13 项 + 5 子项 + H-1/2/3 子代理产出)
- NEO-DECISIONS-LOG 累积决策记录

关键追加发现 8 处 D Bug(原蓝本 0):
- P0-3 看板沙箱接入(Wave 1 W1-T1)
- P0-5 致命 1 (4 处 fdw_etl 残留, 已修 commit 17f045a)
- P0-5 致命 2 (JWT aud 缺失, 已修 commit 17f045a)
- P0-6 clearAllTasks 守卫 (Wave 3)
- P0-8 DBViewer 黑名单漏 (已修 commit 17f045a)
- P1-3 task-detail 跳转传 task_id 而非 customer_id
- P2-7 board-finance 隐式 null
- 2 个独立 Bug (page_context.created_at + ClueCategory 字典)

参考: docs/_overview/00-index.md
2026-05-04 07:38:28 +08:00

29 KiB

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 的唯一索引:

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 过滤:

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 是什么?


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

# 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.mddocs/_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 迁移作为兜底:

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)

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 行:

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 分钟)

-- 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 决策)

  1. 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,有业务回归风险
  2. 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)

  1. 要么补 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(可直接运行)

-- 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 实情,再做决策。