建立项目级标杆文档 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 残留, 已修 commit17f045a) - P0-5 致命 2 (JWT aud 缺失, 已修 commit17f045a) - P0-6 clearAllTasks 守卫 (Wave 3) - P0-8 DBViewer 黑名单漏 (已修 commit17f045a) - 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
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 关键发现
- tasks.md 标 [x] 不可信:8 个 [x] 任务里至少 3 项(test_p52_* 测试 / 迁移脚本 / cron next_run_at 同事务) 没有产出物。
- 真实工作量已大幅缩减:T3/T4 主体逻辑确实已写。
- 关键不一致: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_atnote_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 迁移作为兜底:
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:最小修复(必须做)
-
建 T6 迁移脚本(若 Phase 0 验证 DB cron 不是 0 7)
- 文件:
db/zqyy_app/migrations/2026-05-04__p52_update_cron_0700.sql - 内容:幂等 UPDATE + 验证 SQL 注释 + 回滚 SQL 注释
- 不破坏任何已有逻辑
- 文件:
-
note_reclassifier 加 runtime_mode 过滤
note_reclassifier.py:179-194冲突检查 SQL 加AND runtime_mode = %s AND sandbox_instance_id = %snote_reclassifier.py:252-269INSERT 加runtime_mode, sandbox_instance_id列- 复用
runtime_context.task_runtime_filter/runtime_insert_columns - 风险:无,完全对齐其他模块的沙箱模式
Phase 2:评估后决定(需要 Neo 决策)
-
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,有业务回归风险
-
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。工作量大
- 选项 A:在
Phase 3:测试补漏(spec-close)
- 要么补
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_createdapps/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_completedapps/backend/app/services/trigger_scheduler.py:36-50— _invoke_handler async 分支apps/backend/app/services/trigger_scheduler.py:68-80— update_job_last_run_atapps/backend/app/services/trigger_scheduler.py:174-185— cron 路径 next_run_at(独立事务)apps/backend/app/services/trigger_scheduler.py:232— T6 代码默认值 0 7apps/backend/app/services/task_generator.py:599-720— path Aapps/backend/app/services/task_manager.py:177-184, 261— T1apps/backend/app/main.py:81-84— register_job 注册点apps/backend/app/ai/dispatcher.py:1147-1268— AI async handlersdb/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 实情,再做决策。