后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值
前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截
文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
194 KiB
第二阶段主动调度 MVP 实现方案
0. Handoff 说明
本文档已收口为第二阶段主动调度 MVP 的最终实施版。截至 2026-04-30,后端第一至第四阶段主体代码已实现并通过本地 go test ./...;真实飞书 webhook 配置接口和 important_urgent_task 主动触发端到端链路已通过本地后端验收。接手者请优先阅读本节、第 10 章装配边界和第 14 章验证 checklist,再从第五阶段剩余验收继续推进。
当前核心共识:
- 主动调度主链路走固定 graph / service pipeline,不进入 ReAct 工具循环。
- 第一版触发类型先做
important_urgent_task与unfinished_feedback,对应的业务目标分别是 task_pool 进日程和未完成反馈补做。 - task 创建 / 更新时按
urgency_threshold_atupsert 主动调度 job;task 完成后把 job 标记为canceled。 - schedule 动态任务默认
assumed_completed,只有用户明确反馈未完成才触发补救。 - 调度触发信号需要持久化,用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply。
- task_pool 任务进入日程时不创建孤儿 task_item,而是在
schedule_events上新增task_source_type:task_source_type=task_item时,rel_id指向task_items.id。task_source_type=task_pool时,rel_id指向tasks.id。
- 主动调度预览新增
active_schedule_previews,不塞进agent_schedule_states。 - 预览保存
base_version + before_summary + preview_changes,不保存全量 before 快照。 - 第一版不做 apply 成功后的撤销按钮;apply 失败必须事务不落库并回写失败原因。
- 用户确认入口走现有助手会话页和主动调度确认 API,不走 Agent resume;前端复用助手卡片式体验,支持拖动 after 方案后确认。
- 预览有效期 1 小时。
- 未完成补救第一版只生成新补做块,不直接移动原已排任务。
schedule.apply.requested第一版不走 outbox 异步消费,确认 API 内同步完成重校验和正式应用;成功 / 失败直接回写预览状态。- 应用幂等使用独立
apply_id + idempotency_key,preview_id + candidate_id只用于定位候选,不作为一次确认尝试的幂等键。 - 飞书通知必须包含唯一会话链接
/assistant/{conversation_id};若会话尚未创建,后端先预创建conversation_id并绑定主动调度 session 后再发通知。通知文案第一版先复用候选 / preview summary,固定模板作为兜底;LLM summary 作为后续增强分支,不作为当前已验收前提。 - 飞书通知幂等按
user_id + trigger_type + time_window聚合,不按preview_id;第一版落notification_records表支撑可观测与失败重试。 api / worker / all启动边界第一阶段已完成;主动调度 MVP 可直接挂到 worker / 事件链路,不需要等待启动边界拆分。- 主动调度第一版采用“准独立模块”策略:不放进
backend/service/active_scheduler,而是放在backend/active_scheduler;MVP 暂不拆独立 Go module / 独立进程。 - 事件契约第一版提前放入
backend/shared/events,只承载 event type、event version、payload DTO 和基础校验,不放业务逻辑。 - 主动调度采用 port / adapter 依赖边界:主链路不散落依赖其它领域 DAO;自有表用自有 repo;读取外部事实走 reader port;正式写入走 apply/service port。
- 主动调度验收以“后端链路可观测 + 动作-预期 checklist”为准,覆盖 dry-run、trigger、worker、preview、notification、confirm apply、幂等、过期和失败回写。
- 本轮给
tasks新增estimated_sections,模型层、普通任务创建请求和 quick task 创建入口以及主动调度消费侧都已接上,默认 1,MVP 允许 1~4 节;本轮验收已完成收口。 - 本轮给
schedule_events新增来源与审计字段:task_source_type / makeup_for_event_id / active_preview_id。 compress_with_next_dynamic_task第一轮实现先关闭,不生成该候选;保留 schema 和文档口径,待新增补做块主链路稳定后再打开。- 飞书第一版使用 mock / webhook 跑通主动触达闭环,不阻塞在用户 open_id 绑定体系上。
- notification 去重窗口第一版固定为 30 分钟。
- 真实飞书第一版走“用户级 Webhook 触发器”而不是群自定义机器人协议:后端按
user_id查用户配置的 webhook URL,POST 极简业务 JSON;私聊、群聊、分支和后续动作由用户在飞书流程里自行编排。 - 主动调度进入聊天页时新增
active_schedule_sessions作为路由桥:conversation 只承载用户可见历史,session 负责waiting_user_reply / rerunning的管辖权和ready_preview后的释放。 - 主动调度的三层口径要分开:触发来源分 worker 自动触发、API 验收入口、用户在聊天页内的主动入口和
ask_user回复;业务目标分important_urgent_task -> task_pool 进日程与unfinished_feedback -> 新补做块;投递方式上,只有后台离线自动触达才走飞书,用户已经在会话内时不再先发飞书通知。
0.1 多阶段推进计划
第一阶段:数据结构与事件契约。(已完成)
- 新增迁移:
tasks.estimated_sections、schedule_events.task_source_type / makeup_for_event_id / active_preview_id、active_schedule_jobs、active_schedule_triggers、active_schedule_previews、notification_records。 - 新增
backend/shared/events下的主动调度、通知、apply 结果事件契约。 - 先补 repo / model / validate,不接 LLM、不接 provider。
第二阶段:主动调度 dry-run 主链路。(已完成)
- 落
backend/active_scheduler目录骨架、ports、adapters。 - 实现
BuildContext -> Observe -> GenerateCandidates。 - 先只支持
important_urgent_task的add_task_pool_to_schedule和unfinished_feedback的create_makeup / ask_user / notify_only。 compress_with_next_dynamic_task首轮关闭,不生成候选。
第三阶段:预览与确认。(已完成)
- 实现
active_schedule_previews写入与详情查询。 - 实现 confirm API:
apply_id + idempotency_key、过期校验、edited_changes重校验、同步 apply。 - task_pool 正式落库写
schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)。 - 补做块新增 event,不移动原已排任务。
第四阶段:worker 与 notification。(主体代码已完成,真实 webhook 配置接口已验收)
- 接入
active_schedule.triggeredworker handler 和 due job scanner。 - 接入
notification.feishu.requestedhandler。 - 先使用 mock provider,再接用户级飞书 Webhook 触发器 provider。
notification_records支持幂等、状态流转和 provider retry。- 新增用户通知配置入口:保存 / 查询 / 删除 / 测试当前用户的飞书 webhook。
第五阶段:端到端验收与收口。(部分验收中)
- 跑通
api / worker / all三种启动模式。 - 按第 14 章 checklist 验证 dry-run、trigger、preview、notification、confirm apply、失败注入。
- 根据日志和测试结果补齐 trace 字段与错误码。
- 主链路稳定后再评估是否打开压缩融合候选。
第六阶段:主动调度 graph 补齐、会话桥与聊天页合流。(待实施)
estimated_sections写入入口已经补完:普通任务创建请求、转换层和 quick task / 随口记创建任务时,都会把 LLM 估计的 1~4 节写入tasks.estimated_sections;主动调度只消费该字段,不在 graph 内重新猜任务耗时。- 补主动调度 Eino graph:把现有
BuildContext -> Observe -> GenerateCandidates -> CreatePreview固定 pipeline 整理成 graph 节点,并新增 LLM 解释 / 有限选择、ask_user、fallback 分支;当前代码里的 first-fit /Candidates[0]只能作为过渡实现。 - 升级候选生成与裁决:生成 topN 合法候选,输出
capacity_fit / risk_level两个公开维度;后端负责粗排和默认裁决,LLM 只在接近候选间做有限选择,并负责解释与补全兜底。 - 新增
active_schedule_sessions,记录session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json等核心字段;state_json里收纳pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason这类轻量状态。 active_schedule_sessions也要接入缓存链路:chat 路由先查 session 热缓存,再回源 DB,状态变化后同步回填;构造用户消息时把 session 上下文、preview 和卡片 payload 一并缓存,避免反复从 DB 重组。- notification 发出前由后端预创建或绑定
conversation_id,飞书action_url指向现有/assistant/{conversation_id}路由,不再新增独立/schedule-adjust/{preview_id}主入口。 - 后端在 newAgent 入口按 session 状态决定是否拦截普通聊天:
waiting_user_reply / rerunning由主动调度 graph 同步推进;ready_preview / applied / ignored / expired / failed释放给正常聊天链路。 - 前端只做最小适配:复用
AssistantPanel.vue、ScheduleResultCard和ScheduleFineTuneModal,timeline 新增主动调度卡片类型,按钮动作按类型分支到主动调度 confirm API。 - 用户在聊天页补充偏好或缺失事实时,后端先更新 memory / 本轮事实,再重跑 active scheduler graph,生成新 preview 后通过 SSE / timeline 推送同一张卡片形态。
0.2 子代理并行推进计划
可在实现阶段使用 3 到 5 个子代理并行推进,但必须按文件所有权拆分,避免互相覆盖。
- 子代理 A:数据与契约。
- 负责 migrations、model、repo、
backend/shared/events。 - 不改 API handler、不改 active_scheduler pipeline。
- 负责 migrations、model、repo、
- 子代理 B:主动调度核心。
- 负责
backend/active_scheduler/context / observe / candidate / selection / timegrid / ports。 - 不改正式 apply、不改 notification provider。
- 负责
- 子代理 C:预览与 apply。
- 负责
backend/active_scheduler/preview / apply / apply/convert和 confirm 相关服务。 - 不改 worker handler、不改 notification。
- 负责
- 子代理 D:worker 与 notification。
- 负责
backend/service/events中主动调度与通知 handler、backend/notification、retry scanner。 - 不改 active_scheduler 核心候选逻辑。
- 负责
- 子代理 E:API 与验证。
- 负责
backend/api/active_schedule.go、路由接入、端到端测试脚本 / checklist 验证。 - 不改底层 repo 和 provider。
- 负责
并行规则:
- 每个子代理只改自己负责的目录;跨目录依赖通过接口或临时占位实现对齐。
- 先由子代理 A 完成事件契约和表结构,其他子代理基于契约开发。
- 合并顺序建议:A -> B -> C -> D -> E。
- 每轮集成后运行相关 Go 测试;按项目规则测试后清理
.gocache。 - 若发现公共能力第三次复制,暂停并抽公共 helper,不让并行开发制造长期重复实现。
0.3 当前实现状态与接力记录
本节用于新对话接手后快速对齐当前代码状态,避免重新翻历史讨论。
已完成阶段:
- 第一阶段:数据结构与事件契约。
- 已新增
tasks.estimated_sections,默认 1;普通任务创建和 quick task 创建入口已透传,主动调度消费侧也已接上。 - 已新增
schedule_events.task_source_type / makeup_for_event_id / active_preview_id。 - 已新增主动调度相关 model / DAO / 事件契约:
backend/model/active_schedule.go、backend/dao/active_schedule.go、backend/shared/events。 - AutoMigrate 已接入,并对历史
schedule_events.type=task做task_source_type=task_item回填。
- 第二阶段:主动调度 dry-run 主链路。
- 已落
backend/active_scheduler准独立模块骨架。 - 已实现
BuildContext -> Observe -> GenerateCandidates。 - 已开放
POST /api/v1/active-schedule/dry-run。 - task 创建 / 更新会 upsert
active_schedule_jobs;task 完成 / 删除会取消 pending job。
- 已落
- 第三阶段:预览与确认。
- 已开放:
POST /api/v1/active-schedule/preview GET /api/v1/active-schedule/preview/:preview_id POST /api/v1/active-schedule/preview/:preview_id/confirm - 已实现 preview 写入、详情查询、
apply_id + idempotency_key、候选转换、同步 apply adapter。 add_task_pool_to_schedule已能正式写入schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)和对应schedules。create_makeup转换与 adapter 已预留并实现基本写入路径,但尚需在第四 / 第五阶段结合正式 unfinished feedback worker 场景补端到端验收。
- 已开放:
- 第四阶段:worker 与 notification 主体代码。
- 已接入
active_schedule.triggeredworker handler、due job scanner、notification.feishu.requestedhandler 和 notification retry loop。 - 已新增
backend/notificationprovider / service 分层,mock provider 保留,真实投递切到用户级飞书 Webhook 触发器 provider。 - 已新增
user_notification_channelsmodel / DAO,并接入 AutoMigrate 与RepoManager。 - 已开放当前用户飞书 webhook 配置接口:
GET /api/v1/notification/channels/feishu PUT /api/v1/notification/channels/feishu DELETE /api/v1/notification/channels/feishu POST /api/v1/notification/channels/feishu/test cmd/start.go已把正式 notification service 注入为WebhookFeishuProvider;测试配置接口与正式投递复用同一个 provider 实例。- 用户未配置或禁用 webhook 时,通知记录进入
skipped,不阻塞主动调度 preview 链路。
- 已接入
本轮实测结果:
- 测试账号:
test0430 / 123456,当前本地环境 user_id 为 3。 - API 验证链路:
- 创建测试任务:
task_id=19。 - 生成 preview:
preview_id=asp_3bb18dcf-bd3a-433d-99ca-7ffadc1d6368。 - 后端候选:
candidate_id=add_task_pool_to_schedule:19:9:4:3,candidate_type=add_task_pool_to_schedule。 - confirm 成功:
apply_id=asap_19a3c6ae1cd7d308dc6b4fe2。
- 创建测试任务:
- DB 验证结果:
active_schedule_previews.status=applied,apply_status=applied,applied_event_ids_json=[423]。schedule_events.id=423,user_id=3,type=task,task_source_type=task_pool,rel_id=19,active_preview_id=asp_3bb18dcf-bd3a-433d-99ca-7ffadc1d6368。schedules.id=877,event_id=423,week=9,day_of_week=4,section=3,status=normal。
- 幂等验证:
- 使用同一
preview_id + idempotency_key重复 confirm,返回同一apply_id和同一event_id=423。 - DB 中该
active_preview_id只对应 1 条schedule_events和 1 条schedules。
- 使用同一
- 测试命令:
- 已在
backend目录执行go test ./...并通过。 - 已按项目规则清理根目录
.gocache。
- 已在
- 第四阶段本轮自动化结果:
- 临时新增
backend/notification/webhook_provider_test.go验证 payload 拼装、飞书 webhook URL 校验与脱敏规则;测试通过后已按项目规则删除临时*_test.go。 - 已再次执行
go test ./...并通过;GOCACHE明确指向项目根目录.gocache,命令结束后已清理。 - 后端按最新代码启动后,已注册本地测试账号
codex_webhook_0430_183147(user_id=6)。 - 已调用
PUT /api/v1/notification/channels/feishu保存用户飞书 webhook;接口返回configured=true、enabled=true、脱敏回显为https://www.feishu.cn/flow/api/trigger-webhook/e889...6624。 - 已调用
POST /api/v1/notification/channels/feishu/test;接口返回status=success、outcome=success,last_test_status=success,last_test_at=2026-04-30T18:31:47.885+08:00。
- 临时新增
- 第五阶段
important_urgent_task端到端验收结果:- 测试账号:
codex_e2e_0430_185311 / 123456,当前本地环境 user_id 为 7。 - 已保存同一个飞书 webhook 配置,创建测试任务
task_id=82,同步 dry-run 返回decision=select_candidate且候选数为 1。 - 已调用
POST /api/v1/active-schedule/trigger写入正式 trigger:trigger_id=ast_39a7f87a-d037-4361-82e5-03f58e4733a3,trace_id=trace_api_trigger_7_1777546391942562200。 - worker 已生成 preview:
preview_id=asp_e6701977-aeed-4bef-9964-29d26014f73d,active_schedule_triggers.status=preview_generated,active_schedule_previews.status=ready。 - outbox 两段均消费成功:
active_schedule.triggered对应 outbox id 2986 为consumed;notification.feishu.requested对应 outbox id 2987 为consumed。 - notification 投递成功:
notification_records.id=2,status=sent,attempt_count=1,provider_message_id=feishu_webhook_2_1777546395537770600。 provider_request_json.event=smartflow.schedule_adjustment_ready,message.title=SmartFlow 日程调整建议,message.action_url=http://localhost:5173/assistant/conv_xxx。- 飞书 webhook 响应:HTTP 200,响应体
{"code":0,"data":{},"msg":"success"}。
- 测试账号:
- 第五阶段补充自动验收结果:
- skipped 场景:测试账号
codex_skip_idem_0430_185759(user_id=8)未配置 webhook,正式 triggerast_da60cd1c-1909-4855-ad5d-53125b19fb76生成 previewasp_9e5c9c46-3460-4065-a2b8-1d531cf0c8aa;notification_records.id=3进入skipped,last_error_code=recipient_missing,两段 outbox 均为consumed。 - trigger 幂等:同一账号、同一 task、同一
idempotency_key重复调用POST /api/v1/active-schedule/trigger,第二次返回同一个 trigger_id,dedupe_hit=true。 - confirm apply 成功与幂等:对 preview
asp_e6701977-aeed-4bef-9964-29d26014f73d确认 candidateadd_task_pool_to_schedule:82:9:4:3,生成apply_id=asap_039719fda4f2ae75f1d3d1fe、schedule_events.id=2488、schedules.id=5177;同一幂等键重复确认返回同一个 apply_id 和 event_id,DB 中该 preview 只落 1 条正式事件。 unfinished_feedback端到端:基于schedule_events.id=2488触发unfinished_feedback,triggerast_25aced9e-554a-4021-9075-7166cf268480生成补做块 previewasp_555e4cb9-b3c4-4e5e-8830-bd271c99e346;notification_records.id=4为sent,飞书 webhook HTTP 200,响应体{"code":0,"data":{},"msg":"success"}。- failed 场景:测试账号
codex_fail_0430_190101(user_id=10)配置https://www.feishu.cn:81/...不可达端口,triggerast_cd8b2de9-d836-4470-ad6a-c02c32142274生成 previewasp_e5db98b2-b6bc-4683-8664-ae3d7eb76c25;notification_records.id=6进入failed,last_error_code=provider_timeout,并写入next_retry_at。 - retry loop 恢复:将
notification_records.id=6对应用户 webhook 改回真实地址并把next_retry_at调到当前时间,后台 retry loop 自动重试后该记录变为sent,最终attempt_count=3,HTTP 200。 - dead 场景:测试账号
codex_dead_runtime_0430_190150(user_id=11)通过 DB 注入非法http://webhook URL,triggerast_fc162833-7223-4aba-89c9-194ecdfbcf40生成 previewasp_731f7cb2-c5dd-4629-83cd-627bec901e30;notification_records.id=7进入dead,last_error_code=invalid_url,next_retry_at=NULL。 - api-only 启动边界:仅启动 API 后,健康检查通过;测试账号
codex_api_mode_0430_190708(user_id=12)创建任务task_id=87并调用正式 trigger,得到trigger_id=ast_b48c955f-dcb3-4e87-a296-fd98583e4807、status=pending。等待 6 秒后 DB 确认active_schedule_triggers.status=pending、preview 数为 0、notification 数为 0、outbox id 3008 为active_schedule.triggered / pending,证明 API 模式只写入 outbox,不启动 worker 消费。 - worker-only 启动边界:仅启动 worker 后,HTTP 健康检查超时,符合“不注册 API 路由”预期;worker 消费 api-only 留下的 outbox id 3008,
active_schedule_triggers.status=preview_generated,生成 previewasp_badb4be4-cf2c-4f9b-9719-cbe92f50abed,notification_records.id=8因该用户未配置 webhook 进入skipped,notification.feishu.requestedoutbox id 3009 为consumed。
- skipped 场景:测试账号
下一阶段入口:
- 下一步继续第五阶段剩余验收,不需要重做 dry-run / preview / confirm 主链路,也不需要重做第四阶段 provider / handler 主体代码。
- 第五阶段剩余重点:
- confirm apply 冲突失败、过期拒绝。
- 更完整的边界清理:测试数据隔离策略、失败注入脚本化、前端真实地址替换为正式域名配置。
- 工作区注意:
- 另一个前端对话可能在改前端;后端阶段不要碰
frontend相关改动。 - 当前允许单个 Go 文件 700 行以内;超过 700 再评估拆分。
- 每次执行
go test后必须清理根目录.gocache。 - 后续阶段必须优先自动化验收:能由代码、API、DB 查询、日志查询验证的内容,由实现者自己跑完并记录结果。
- 如果受限于外部账号、真实飞书环境、浏览器人工交互、权限或本地环境,导致某项验收无法完成,不能默认为通过,也不能在报告中省略;必须明确写出未验收项、阻塞原因、建议由用户执行的操作和预期结果。
- 另一个前端对话可能在改前端;后端阶段不要碰
1. 文档目的
本文档承接《第二阶段主动调度 MVP 功能预期》和《微服务四步迁移与第二阶段并行开发计划》,用于把产品预期逐步落成可执行的工程方案。
本文档已经完成业务逻辑、工程边界、执行计划和验证流程收口。实现时按第 0.1 节阶段推进,遇到未覆盖细节时优先遵循第 2 章总体原则和第 10 章迁移边界。
2. 总体实现原则
- 主动调度只生成诊断、候选和预览,不直接修改正式日程。
- LLM 只在后端生成的候选里做选择,不自由构造正式写库参数。
- 后台 worker 是主动调度主链路,API 只提供测试触发、预览查询、用户确认和正式应用入口。
- 当前仍在
backendGo module 内实现,但代码边界按未来active-scheduler独立服务设计。 - 飞书第一版只走
notification.feishu.requested通知事件,不承载确认和复杂聊天。 - 所有触发源统一进入
active_schedule.triggered,禁止每种触发单独写一套调度逻辑。 - 正式应用优先复用现有 schedule / task_class service,不在主动调度模块绕过既有写入链路。
3. 目标链路
后台定时 / 事件 / API 测试触发
-> active_schedule.triggered
-> 构造 ActiveScheduleContext
-> 刷新四象限紧急性派生
-> 读取滚动 24 小时任务与日程事实
-> 主动观测并生成 issues / decision / candidates
-> 写入待确认对比预览
-> 发布 schedule.preview.generated
-> 发布 notification.feishu.requested
-> 用户回系统查看并按候选确认
-> 确认 API 生成 apply_id 并同步重校验
-> 复用正式应用链路写入 MySQL
-> schedule.apply.succeeded / schedule.apply.failed
4. 模块一:触发入口与事件契约
4.1 业务实现逻辑简述
主动调度不应该依赖用户打开聊天后才发生。第一版需要支持三类入口:
- 后台 worker 定时扫描或按事件触发。
- API dry-run / trigger 测试触发,便于开发和验收。
- 用户反馈类触发,例如明确说某个已排任务没完成,或表达疲劳。
三类入口最终都归一成同一个 ActiveScheduleTrigger,再进入同一条观测链路。
4.2 已拍板结论
- 第一版触发类型是否只做两个:
important_urgent_task和unfinished_feedback?- 已确认:第一版先做这两类主触发,对应 task_pool 进日程和未完成反馈补做。
fatigue_feedback可作为用户反馈类的后续扩展,不抢第一轮主链路。
- 已确认:第一版先做这两类主触发,对应 task_pool 进日程和未完成反馈补做。
- API 测试触发是否允许直接同步返回诊断结果,还是必须也写入 outbox 后异步消费?
- 已确认:两种都保留。
dry-run同步返回诊断结果,不写预览、不发通知;trigger走正式异步链路,写预览并发布通知事件。
- 已确认:两种都保留。
mock_now是否只允许测试接口传入,后台真实 worker 禁止传入?- 已确认:
mock_now只允许 API dry-run / 测试 trigger 使用;后台 worker 正式定时触发必须使用真实当前时间。
- 已确认:
- 同一用户短时间多次触发的去重窗口设多长?
- 已确认:
important_urgent_task按user_id + trigger_type + target_task_id做 30 分钟去重;unfinished_feedback按用户反馈的feedback_id / idempotency_key防重复提交,不做固定时间窗强去重。
- 已确认:
4.3 执行计划:触发入口与事件契约
本模块只负责把各类入口统一归一成 ActiveScheduleTrigger,并决定同步 dry-run、正式 trigger、worker due job 和用户反馈如何进入同一条主动调度 pipeline。上下文构造、候选生成、预览写入和通知投递的内部 schema 分别在后续模块细化。
4.3.1 代码落点
- 事件契约:
只放 event type、event version、payload DTO、基础校验和消息键构造。
backend/shared/events/active_schedule.go - 主动调度触发入口:
负责 trigger DTO、幂等判断、trigger 记录写入和正式 pipeline 入口编排。
backend/active_scheduler/trigger - API handler:
只负责鉴权用户、绑定请求、调用 active_scheduler service,不直接构造候选。
backend/api/active_schedule.go - 路由注册:
按现有鉴权路由风格挂载 dry-run、trigger、preview 查询和 confirm;本节只补 dry-run / trigger。
backend/routers - worker handler:
只负责消费事件、解析 payload、调用 active_scheduler trigger service。
backend/service/events/active_schedule_triggered.go - due job 扫描器:
负责扫描到期
backend/active_scheduler/jobactive_schedule_jobs,重新读取 task 真值后生成 trigger。
4.3.2 DTO 字段定义
ActiveScheduleTrigger 是内部统一输入,建议字段如下:
trigger_id # active_schedule_triggers.id;dry-run 可为空
user_id
trigger_type # important_urgent_task / unfinished_feedback
source # worker_due_job / api_trigger / api_dry_run / user_feedback
target_type # task_pool / schedule_event / task_item
target_id
feedback_id # unfinished_feedback 场景使用,可为空
idempotency_key # API / 用户反馈幂等键
dedupe_key # important_urgent_task 30 分钟去重键,或 feedback 幂等键
mock_now
is_mock_time
requested_at
payload # 触发源补充信息,JSON DTO,不塞任意 map
trace_id
trigger_type 第一版只允许:
important_urgent_task
unfinished_feedback
source 第一版只允许:
worker_due_job
api_trigger
api_dry_run
user_feedback
target_type 第一版建议允许:
task_pool # rel_id / target_id 指向 tasks.id
schedule_event # 用户反馈“某个已排日程没完成”
task_item # 后续补救或明确定位 task_item 时使用
4.3.3 事件契约
事件名:
active_schedule.triggered
版本:
event_version = 1
payload 示例:
{
"trigger_id": "ast_123",
"user_id": 10001,
"trigger_type": "important_urgent_task",
"source": "worker_due_job",
"target_type": "task_pool",
"target_id": 345,
"idempotency_key": "",
"dedupe_key": "10001:important_urgent_task:task_pool:345:2026-04-30T10:00",
"mock_now": null,
"is_mock_time": false,
"requested_at": "2026-04-30T10:00:00+08:00",
"payload": {
"job_id": "asj_789",
"urgency_threshold_at": "2026-04-30T10:00:00+08:00"
},
"trace_id": "trace_xxx"
}
消息键建议:
message_key = user_id
aggregate_id = trigger_id
规则:
active_schedule.triggered只表示“主动调度链路需要处理一个触发信号”,不表示已经生成 preview。- payload 必须带
trigger_id,方便后续串联trigger -> preview -> notification -> apply。 - dry-run 不发布该事件。
- API trigger、worker due job、用户反馈正式触发都可以发布该事件。
- 消费者必须按
event_type + event_version解析,不直接依赖 active_scheduler 内部 struct。
4.3.4 API 路由设计
建议新增鉴权接口:
POST /active-schedule/dry-run
POST /active-schedule/trigger
dry-run 请求:
{
"trigger_type": "important_urgent_task",
"target_type": "task_pool",
"target_id": 345,
"mock_now": "2026-04-30T10:00:00+08:00",
"payload": {}
}
dry-run 响应:
{
"trigger": {},
"context_summary": {},
"issues": [],
"decision": {},
"candidates": []
}
trigger 请求:
{
"trigger_type": "important_urgent_task",
"target_type": "task_pool",
"target_id": 345,
"mock_now": "2026-04-30T10:00:00+08:00",
"idempotency_key": "client-generated-key",
"payload": {}
}
trigger 响应:
{
"trigger_id": "ast_123",
"status": "pending",
"deduped": false
}
接口语义:
dry-run同步执行到 decision / candidates,绝不写active_schedule_triggers / active_schedule_previews / notification_records。dry-run允许mock_now,但必须在返回 trace 中标记is_mock_time=true。trigger走正式链路,先写 trigger,再发布active_schedule.triggered,由 worker 消费生成 preview 和 notification。trigger允许mock_now,但必须持久化is_mock_time=true,避免排障误判。- 后台 worker due job 不允许
mock_now,必须使用真实当前时间。
4.3.5 幂等与去重
important_urgent_task:
dedupe_key = user_id + trigger_type + target_type + target_id + 30分钟窗口
预期行为:
- 30 分钟内命中相同 dedupe key 时,不重复写新 preview,不重复发飞书。
- 若已有 trigger 仍在
pending / processing / preview_generated,直接返回已有 trigger 状态。 - 若上一轮
failed,是否允许重新触发由表结构状态机阶段细化;MVP 倾向允许人工测试 trigger 重新触发,但必须生成新的 trace。
unfinished_feedback:
dedupe_key = user_id + trigger_type + feedback_id/idempotency_key
预期行为:
- 不做固定 30 分钟窗口强去重。
- 同一
feedback_id / idempotency_key重复提交时返回已有 trigger。 - 用户连续表达“还是没做完”时,只要反馈 ID 或幂等键不同,就允许进入新的补救链路。
4.3.6 worker handler 流程
worker 消费 active_schedule.triggered:
1. 解析 shared/events payload。
2. 校验 trigger_id / user_id / trigger_type / target_type / target_id。
3. 查询 active_schedule_triggers 当前状态。
4. 若状态已完成或已跳过,直接幂等返回。
5. 将 trigger 标记为 processing。
6. 调用 active_scheduler pipeline:
BuildContext -> Observe -> GenerateCandidates -> LLMSelectAndExplain -> WritePreview -> Notify
7. 成功写 preview 后,将 trigger 标记为 preview_generated。
8. 若无 issue 或后端裁决 close,将 trigger 标记为 skipped/closed,并记录 reason。
9. 失败则标记 failed,写 error,保留 outbox 重试语义。
due job 扫描器流程:
1. 扫描 due 且未完成的 active_schedule_jobs。
2. 重新读取 task 真值。
3. task 已完成 -> job 标记 canceled/skipped。
4. task 不再满足重要且紧急 -> job 标记 skipped。
5. task 已进入 schedule -> job 标记 skipped。
6. 仍需主动调度 -> 写 trigger 并发布 active_schedule.triggered。
4.3.7 错误处理与可观测
- payload 解析失败:outbox 标记 dead,记录解析错误。
- 参数非法:trigger 标记 failed 或 rejected,记录原因,不进入 pipeline。
- 幂等命中:不视为错误,返回已有 trigger / preview 状态。
- pipeline 失败:trigger 标记 failed,保留 error message 和 trace。
- preview 写入失败:不发布 notification。
- notification 发布失败:preview 保留,trigger 可标记 preview_generated,但 notification 状态由 notification 模块记录。
- 所有正式 trigger 必须能通过
trace_id / trigger_id / target_id查到链路日志。
4.3.8 测试方案
单元测试:
trigger_type / source / target_type枚举校验。mock_now只在api_dry_run / api_trigger允许。important_urgent_taskdedupe key 生成。unfinished_feedbackidempotency key 生成。active_schedule.triggeredpayload validate。- dry-run 不写 trigger / preview / notification。
集成测试:
- API
dry-run返回 diagnosis / candidates,不落库。 - API
trigger写active_schedule_triggers并发布active_schedule.triggered。 - worker 消费事件后推进 trigger 状态到
processing -> preview_generated。 - 30 分钟内重复
important_urgent_task触发命中去重。 - 相同
unfinished_feedback.idempotency_key重复提交命中幂等。 - due job 到期但 task 已完成时标记 skipped/canceled,不写 preview。
- payload 非法时 outbox dead 或 trigger failed,错误可查询。
人工验收:
- 使用 dry-run 验证某个 task_pool 任务能生成候选。
- 使用 trigger 验证 worker 能写 preview。
- 重复点击 trigger,确认不重复生成多条 preview 和飞书通知。
- 修改 task 为 completed 后触发 due job,确认不会进入主动调度链路。
5. 模块二:ActiveScheduleContext 构造
5.1 业务实现逻辑简述
ActiveScheduleContext 是主动调度的统一输入快照。它负责把用户、时间窗、任务、日程、四象限任务池、偏好、近期反馈和触发来源装配到一起。
上下文构造阶段需要先触发或复用四象限紧急性派生,避免后台读到懒加载前的旧任务池。
5.2 已拍板结论
- 滚动 24 小时如何映射到当前“周 + 星期 + 节次”模型?是否第一版只按节次粒度处理?
- 已确认:候选窗口按任务 DDL / 当前滚动 24 小时映射到现有相对时间坐标(week/day_of_week/section),正式写入仍同时维护 schedule 现有的绝对时间与相对时间字段。
- 已确认:第一版统一按 1 节粒度处理;任务预计长度先限定在 1~4 节,后续可在创建 task 时由 AI 根据复杂度写入预计节数。
- 四象限任务池里的
tasks是否需要映射到task_items,还是主动调度预览直接支持 task_pool 任务?- 已确认:不创建无所属任务类的“孤儿 task_item”。四象限任务进入日程时保留 task_pool 身份,通过
schedule_events.task_source_type=task_pool指向tasks.id。
- 已确认:不创建无所属任务类的“孤儿 task_item”。四象限任务进入日程时保留 task_pool 身份,通过
- 用户偏好第一版从哪里注入:memory 摘要、task_class 配置,还是先只消费已有排程约束?
- 已确认:若候选目标来自 task 池,优先使用 memory 中的用户偏好;若候选目标来自 task_item,则使用所属 task_class 的硬性偏好和约束。
- 近期用户反馈是否第一版只作为 trigger payload,不落数据库状态?
- 已确认:用户反馈类触发信号需要持久化,但不面向前端展示;主要用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply 链路。
5.3 执行计划:ActiveScheduleContext 构造
本模块只负责把触发信号转换成主动观测所需的事实快照,不负责生成候选、不调用 LLM、不写 preview。上下文构造必须尽量确定性、可测试、可排障。
5.3.1 代码落点
- Context DTO:
backend/active_scheduler/context - 读取端口定义:
backend/active_scheduler/ports - 本地 adapter:
backend/active_scheduler/adapters - 时间窗与节次转换辅助:
backend/active_scheduler/timegrid - 与既有公共能力复用:
- 优先复用
conv.RealDateToRelativeDate、conv.RelativeTimeToRealTime等现有时间转换能力。 - 若需要新的滚动窗口到节次格转换,放入
timegrid,避免散落在 observe / candidate 里。
- 优先复用
5.3.2 ActiveScheduleContext 结构
建议结构方向:
ActiveScheduleContext
Trigger
User
Now
Window
Target
TaskPoolFacts
ScheduleFacts
TaskClassFacts
PreferenceFacts
FeedbackFacts
DerivedFacts
Trace
字段语义:
Trigger
trigger_id
trigger_type
source
target_type
target_id
is_mock_time
payload
User
user_id
timezone
Now
real_now # 后台真实当前时间
effective_now # dry-run / trigger 可使用 mock_now
Window
start_at
end_at
relative_slots # week/day_of_week/section 原子格列表
window_reason # rolling_24h / task_deadline / task_class_end_date
Target
source_type # task_pool / schedule_event / task_item
task_id
schedule_event_id
task_item_id
title
estimated_sections
deadline_at
urgency_threshold_at
priority
status
TaskPoolFacts
target_task
urgent_unscheduled_tasks
ScheduleFacts
events
occupied_slots
free_slots
next_dynamic_task
TaskClassFacts
task_class
affected_items
constraints
PreferenceFacts
memory_context_text
memory_items
task_class_constraints
preference_source
FeedbackFacts
feedback_id
feedback_text
feedback_target
DerivedFacts
target_already_scheduled
target_completed
available_capacity
missing_info
Trace
trace_id
build_steps
warnings
约束:
ActiveScheduleContext是只读快照,不包含 DAO / service 实例。- context 中的时间统一使用带时区的绝对时间,同时保留相对节次格。
- context 中只放主动观测需要的事实,不塞完整数据库 model。
missing_info是正常输出,用于后续裁决ask_user / notify_only,不是构造失败。
5.3.3 读取端口
主动调度 pipeline 只依赖 port,不直接 import 其它领域 DAO。
建议端口:
type TaskReader interface {
GetTaskForActiveSchedule(...)
ListUrgentUnscheduledTasks(...)
IsTaskScheduled(...)
}
type ScheduleReader interface {
GetScheduleFactsByWindow(...)
GetFreeSlots(...)
GetNextDynamicTask(...)
HasSlotConflict(...)
}
type TaskClassReader interface {
GetTaskItemWithClass(...)
ListAffectedTaskItems(...)
GetTaskClassConstraints(...)
}
type MemoryContextReader interface {
LoadScheduleMemoryContext(...)
}
type FeedbackReader interface {
GetFeedbackSignal(...)
}
type UrgencyRefresher interface {
RefreshTaskUrgency(...)
}
MemoryContextReader 的语义建议:
type ScheduleMemoryContextRequest struct {
UserID int
Query string
TargetTitle string
TriggerType string
WindowStart time.Time
WindowEnd time.Time
Now time.Time
}
type ScheduleMemoryContextFacts struct {
RenderedText string
Items []ScheduleMemoryItem
Source string
Warnings []string
}
说明:
- port 命名为
MemoryContextReader,不命名为PreferenceReader,避免暗示 memory 模块已经提供结构化日程偏好。 RenderedText对齐 newAgent 的memory_context:给 LLM 参考,但不作为硬规则。Items只保留排障需要的轻量字段,例如id / memory_type / title / content / confidence / importance,不把 memory 模块内部 model 泄漏到主动调度主链路。
MVP adapter 规则:
- 优先复用现有 service。
- 若现有 service 无合适读模型,adapter 内部可调用 DAO 组装事实。
- DAO 调用不能出现在
BuildContext / Observe / GenerateCandidates主链路中。 - adapter 返回 active_scheduler 自己的轻量事实 DTO,不直接返回 GORM model。
- memory 侧不新增
GetMemorySchedulePreferences这类结构化偏好 DAO;第一版复用现有memoryReader.Retrieve(ctx, memorymodel.RetrieveRequest)召回能力。 - active_scheduler 不 import
backend/newAgent/node/execute,也不依赖ConversationContext/ pinned block;只复用“召回 + 渲染为 memory context 文本”的底层能力。 - 若实现时发现 memory 渲染逻辑只能从
agentsvc访问,应先抽到backend/memory或backend/shared下的公共渲染 helper,再让agentsvc和 active_scheduler adapter 共同复用,避免复制第三份 prompt 拼装逻辑。
5.3.4 构造顺序
建议固定顺序:
1. NormalizeTrigger
2. ResolveEffectiveNow
3. RefreshUrgencyIfNeeded
4. ResolveTarget
5. BuildWindow
6. LoadScheduleFacts
7. LoadPreferenceFacts
8. LoadFeedbackFacts
9. DeriveFacts
10. ValidateContextForObserve
步骤说明:
NormalizeTrigger- 校验 trigger 枚举、target 枚举、用户归属。
- 失败时直接返回构造错误,不进入观测。
ResolveEffectiveNow- API dry-run / trigger 可使用
mock_now。 - worker due job 必须使用真实
time.Now()。 - 写入
is_mock_time到 trace。
- API dry-run / trigger 可使用
RefreshUrgencyIfNeeded- 对
important_urgent_task先刷新或复用四象限紧急性派生。 - 目的是避免读到懒平移之前的旧优先级。
- 对
ResolveTargettask_pool:读取tasks。schedule_event:读取对应日程块,并根据task_source_type判断来源。task_item:读取 task_item 及 task_class。
BuildWindow- 默认窗口为
[effective_now, effective_now + 24h]。 - 未完成补救场景若目标属于 task_class,可扩展到
task_class.end_date,供局部补救使用。 - 所有窗口必须映射到
week / day_of_week / section。
- 默认窗口为
LoadScheduleFacts- 读取窗口内课程、已排任务、可嵌入课程、空闲槽。
- 生成
occupied_slots / free_slots。
LoadPreferenceFacts- target 是
task_pool:通过MemoryContextReader召回与排程相关的 memory context,作为软偏好输入。 - target 是
task_item:读 task_class 约束。
- target 是
LoadFeedbackFactsunfinished_feedback必须加载反馈目标和文本摘要。- 若无法定位反馈目标,写入
missing_info,由后续裁决ask_user。
DeriveFacts- 判断目标是否已完成、是否已进入日程、窗口容量是否足够。
- 这些是后续 observe 的确定性输入。
ValidateContextForObserve
- 只校验能否进入 observe。
- 信息不全但仍可 ask_user 的场景,不应直接失败。
5.3.5 四象限刷新复用方案
规则:
important_urgent_task构造 context 前必须调用UrgencyRefresher。- 刷新以数据库真实时间或
effective_now为准:- API dry-run / trigger:可使用
mock_now。 - worker due job:使用真实当前时间。
- API dry-run / trigger:可使用
- 刷新结果不要求本次一定更新 task;如果 task 已不满足平移条件,后续
DerivedFacts会标记 skipped/close。 - 刷新失败:
- dry-run:返回错误,便于开发发现问题。
- 正式 trigger:trigger 标记 failed,记录 error,不继续生成 preview。
- 不在 context 构造中重新实现四象限推导算法;复用现有 task urgency 能力或其 adapter。
5.3.6 时间窗转换与边界兜底
时间窗默认:
start_at = effective_now
end_at = effective_now + 24h
兜底规则:
- 如果
deadline_at早于effective_now:- 仍构造 24 小时窗口。
DerivedFacts标记deadline_already_passed=true。
- 如果
deadline_at位于 24 小时内:window_reason标记包含task_deadline。- 候选生成时优先考虑 deadline 前的槽位。
- 如果窗口跨天 / 跨周:
- 拆成多个相对时间格,不能只取当天。
- 如果某段绝对时间无法映射到节次:
- 丢弃该格,并在
Trace.warnings记录。 - 若全部无法映射,则 context 标记
missing_info=invalid_time_window,后续裁决为ask_user / notify_only。
- 丢弃该格,并在
- 第一版统一 1 节粒度:
estimated_sections为空时默认 1。- 非法值小于 1 时按 1 兜底。
- 非法值大于 4 时按 4 截断,并记录 warning。
5.3.7 偏好来源
与当前 execute.go 链路的关系:
backend/newAgent/node/execute.go本身只是转发壳,真正的 memory 注入发生在 graph/service 边界。agentsvc.injectMemoryContext会先读 Redis 预取缓存,再启动后台检索;检索结果来自memoryReader.Retrieve(ctx, memorymodel.RetrieveRequest)。agent_nodes.ensureFreshMemory只负责等待MemoryFuture,并把已渲染文本写入ConversationContext的memory_contextpinned block。executeprompt 只通过renderUnifiedMemoryContext(ctx)消费该 pinned block,不直接读取 memory DAO。- 因此主动调度应复用 memory 的“Retrieve + 渲染”能力,不复用 execute node / ConversationContext;主动调度没有对话轮次,也不需要引入 pinned block。
task_pool:
- 不读取 task_class 约束。
- 通过
MemoryContextReader.LoadScheduleMemoryContext读取排程相关 memory。 - adapter 内部使用现有 memory 模块的
Retrieve:UserID=user_idQuery由目标任务标题、触发类型、当前窗口意图拼成,例如“为 X 安排未来 24 小时的执行时间,参考用户的时间偏好和约束”MemoryTypes优先限制为constraint / preference / factLimit沿用 newAgent 注入预算或 active_scheduler 独立配置Now=effective_now
- adapter 返回 active_scheduler 自己的
ScheduleMemoryContextFacts,至少包含:items:memory item 的轻量快照,用于排障和 trace。rendered_text:复用公共 memory 渲染 helper 后得到的文本,用于 LLM 选择和解释。source=memory_retrievewarnings
- memory 缺失时继续构造 context,
PreferenceFacts.preference_source=none。 - memory 查询失败不阻断主动调度,只记录 warning;这与 execute 链路“记忆检索失败不阻断主链路”的策略保持一致。
- memory 中的偏好不能作为硬约束,只作为候选排序和解释输入;真正的硬冲突仍以后端 schedule facts / task_class constraints 为准。
task_item:
- 必须读取所属 task_class。
- 使用 task_class 的周几、时段、结束日期等约束。
- 未完成补救场景中,这些约束后续可被局部重排模块软化,但 context 中仍保留原始约束。
unfinished_feedback:
- 优先从 trigger payload 中解析
feedback_id / feedback_text / target_id。 - 如果 payload 只有自然语言文本但无法定位目标,context 不失败,写入
missing_info=feedback_target_unknown。 - 若能定位
schedule_event,需要读取该 event 的来源:task_source_type=task_pool:关联 tasks。task_source_type=task_item或空:兼容旧数据,关联 task_items。
5.3.8 输出给后续模块的契约
context 构造成功后,后续 observe 可依赖以下事实已经可用:
Trigger已标准化。effective_now已确定。Window.relative_slots已生成。- 目标归属已校验。
- schedule facts 已加载,至少包含空切片而不是 nil。
- preference facts 已按 target 类型分流。
- feedback facts 已持久化并能串联 trigger。
DerivedFacts至少包含:target_completedtarget_already_scheduledavailable_capacitymissing_info
5.3.9 错误处理与可观测
- 用户无权访问 target:构造失败,trigger 标记 failed/rejected。
- target 不存在:构造失败,trigger 标记 failed/rejected。
- schedule 查询失败:构造失败,trigger 标记 failed,可重试。
- memory 查询失败:不阻断,写 warning,偏好来源置为 none。
- task_class 查询失败:
- target 是 task_item:阻断。
- target 是 task_pool:不应查询 task_class,若发生说明 adapter 边界错误。
- 时间窗部分映射失败:不阻断,写 warning。
- 时间窗完全不可用:构造成功但
missing_info=invalid_time_window,交给 observe 裁决。
5.3.10 测试方案
单元测试:
mock_now与真实时间的effective_now选择。- 24 小时窗口跨天 / 跨周映射到相对节次。
estimated_sections默认值、截断和 warning。- task_pool 偏好来源为 memory。
- task_item 偏好来源为 task_class。
- memory 读取失败不阻断 context。
- feedback 无法定位目标时写入 missing_info。
- target 已完成 / 已安排时写入 DerivedFacts。
集成测试:
- API dry-run 触发 context 构造,返回 context summary。
- 正式 trigger 通过 worker 构造 context,并推进 trigger 状态。
- due job 触发前刷新四象限紧急性。
- schedule 窗口存在冲突和空闲槽时,context 同时包含
occupied_slots / free_slots。 - task_pool 不读取 task_class,task_item 必须读取 task_class。
人工验收:
- 构造一个 24 小时内有空闲节次的 task_pool,dry-run 能看到可用窗口。
- 构造一个 memory 偏好,例如“晚上更适合写作”,dry-run context summary 能显示偏好来源。
- 构造一个已排 task_item 的 unfinished feedback,context 能定位到 schedule_event 和 task_item。
- 构造无法定位的“刚才那个没做完”,context 不崩溃,后续裁决应进入 ask_user。
6. 模块三:主动观测与候选生成
6.1 业务实现逻辑简述
主动观测能力参考 analyze_health:后端先做结构化观测,再生成候选;LLM 主要负责解释、有限裁决和信息不足时的追问,不再承担主裁决责任。
第一版候选限制为 1 到 3 个,动作范围包括:
- 加入日程预览。
- 未完成补救预览。
- 后继挤压重排预览。
- 延后结束询问。
- 询问用户。
- 仅提醒。
- 收口。
压缩融合候选第一轮只保留 schema 和文档口径,不进入候选生成动作范围。
6.2 已拍板结论
- 主动观测最终是 Agent 工具,还是 worker 内部 service?第一版是否同时提供内部 service 和工具壳?
- 已确认:主动观测不作为 ReAct 工具进入工具循环,而是串进固定 graph / service pipeline。LLM 直接消费观测与候选结果,负责选择和表达。
- “重要且紧急任务未进入日程视图”的可用窗口查找,第一版是否允许打破 task_class 偏好?
- 已纠正:task_pool 任务不属于 task_class,不存在 task_class 偏好可打破。第一版按用户 memory 偏好和滚动 24 小时内的可用时间生成候选;若 memory 偏好与可用容量冲突,候选中说明偏好未满足的代价,而不是称为“打破 task_class 偏好”。
- 未完成补救里,局部重排第一版复用现有粗排算法到什么程度?
- 已确认:第一版做“偏好软化版局部粗排”。输入时间窗为当前时刻到任务类结束日期,只传受影响的部分 item;周几偏好和时段偏好从硬约束降级为优先级,优先排偏好范围内,排不下再打破偏好追加进去,最后恢复这些任务的原有顺序语义。
- 工程倾向:不直接污染现有粗排主函数,新增一条局部重排实现;底层时间格、可用槽位、冲突判断等公共能力优先抽公共层复用,避免复制第三份逻辑。
- 压缩融合候选第一轮是否打开?
- 已确认:第一轮先关闭,不生成
compress_with_next_dynamic_task候选;保留 schema 和实现预留,待新增补做块主链路稳定后再评估打开。
- 已确认:第一轮先关闭,不生成
- close / ask_user / notify_only 的判定阈值由后端固定,还是允许 LLM 结合上下文选择?
- 已确认:参考
analyze_health的裁决模式,由后端确定close / ask_user / notify_only / select_candidate。LLM 不决定能不能调度,只在select_candidate时选择候选;其它场景只负责解释后端理由。
- 已确认:参考
6.3 执行计划:主动观测与候选生成
本模块负责把 ActiveScheduleContext 转成结构化诊断结果,并生成 1 到 3 个后端已校验的候选。它不写 preview、不发通知、不正式改日程;LLM 只在 decision.action=select_candidate 时从候选中选择和解释,不负责决定是否允许调度。
6.3.1 代码落点
- 主动观测:
负责 metrics / issues / decision 的确定性计算。
backend/active_scheduler/observe - 候选生成:
负责枚举、模拟、校验、排序和截断候选。
backend/active_scheduler/candidate - LLM 选择与解释:
只负责把后端候选喂给 LLM,让 LLM 返回
backend/active_scheduler/selectionselected_candidate_id / summary / reason / risk_text。 - 与 schedule 公共能力复用:
或后续下沉到更公共目录,用于放时间格、冲突判断、before/after 摘要转换等可复用能力。
backend/active_scheduler/scheduleutil - 本模块输出 DTO:
放
backend/active_scheduler/modelObservationResult / ActiveScheduleDecision / ActiveScheduleCandidate等主动调度内部结构。
6.3.2 Pipeline 输入输出
输入:
ActiveScheduleContext
输出:
ActiveScheduleObservationResult
metrics
issues
decision
candidates
trace
处理顺序:
1. BuildMetrics
2. DetectIssues
3. DecideAction
4. GenerateCandidates
5. ValidateCandidates
6. RankAndTrimCandidates
7. SelectAndExplainByLLM
说明:
BuildMetrics / DetectIssues / DecideAction / GenerateCandidates / ValidateCandidates / RankAndTrimCandidates全部由后端确定性完成。SelectAndExplainByLLM只在decision.action=select_candidate且候选数大于 0 时执行。- LLM 返回的
candidate_id必须存在于后端候选列表;不存在或格式非法时,先进行一次受限重试。 - 受限重试仍失败时不影响 preview 生成,使用后端 top1 和固定解释 fallback。
6.3.3 Metrics schema
建议第一版 metrics 只保留能驱动裁决和排障的指标:
ActiveScheduleMetrics
target
completed
already_scheduled
deadline_already_passed
minutes_to_deadline
estimated_sections
window
total_slots
free_slots
occupied_slots
usable_slots_before_deadline
capacity_gap
preference
source # memory / task_class / none
matched_slot_count
unmatched_reason
feedback
has_feedback
feedback_target_known
unfinished_elapsed_minutes
risk
conflict_count
affected_event_count
affected_task_count
requires_reorder
指标语义:
capacity_gap = estimated_sections - usable_slots_before_deadline。matched_slot_count只表示满足软偏好的可用槽数量,不表示硬可排容量。requires_reorder=true表示候选可能涉及局部补救或压缩融合,不表示已经修改正式日程。- metrics 只描述事实,不夹带最终动作文案。
6.3.4 Issues schema
issue 是后端观察到的问题或阻断点:
ActiveScheduleIssue
issue_id
code
severity # critical / warning / info
target_type
target_id
reason
evidence
can_generate_candidate
第一版 issue code:
target_completed
target_already_scheduled
deadline_passed
no_valid_time_window
capacity_insufficient
no_free_slot
preference_not_satisfied
feedback_target_unknown
need_makeup_block
need_local_reorder
can_add_task_pool_to_schedule
can_compress_with_next_dynamic_task # 预留,第一轮不生成
生成规则:
target_completed:目标已完成,后续decision.action=close。target_already_scheduled:任务已进入正式日程,后续decision.action=close或notify_only。feedback_target_unknown:无法定位用户说的“没完成”是哪一个日程块,后续decision.action=ask_user。no_valid_time_window:窗口无法映射成任何节次,后续decision.action=ask_user。capacity_insufficient:可用容量不足但存在补救可能,第一轮优先生成询问或仅提醒;压缩融合只保留预留 code,不生成候选。can_add_task_pool_to_schedule:task_pool 任务可直接加入日程,是important_urgent_task的主路径。need_makeup_block / need_local_reorder:未完成反馈需要生成补做块或局部补救候选。
6.3.5 Decision schema
后端裁决结构:
ActiveScheduleDecision
action # close / ask_user / notify_only / select_candidate
reason_code
primary_issue_code
should_notify
should_write_preview
llm_selection_required
fallback_candidate_id
裁决优先级:
1. close
2. ask_user
3. notify_only
4. select_candidate
规则:
close- 目标已完成。
- 目标已进入日程且无需补救。
- 没有观察到需要用户处理的问题。
ask_user- 反馈目标无法定位。
- 时间窗完全不可用。
- 任务缺少必要信息,且后端无法安全生成候选。
notify_only- 有风险或状态变化需要告知用户,但不适合自动生成可确认变更。
- 例如 deadline 已过且没有合理补救窗口。
select_candidate- 至少存在 1 个后端合法候选。
should_write_preview=true。llm_selection_required=true,让 LLM 在候选内选择并生成解释。
兜底:
select_candidate但 LLM 输出非法:先受限重试一次,仍失败再使用fallback_candidate_id。select_candidate但候选列表最终为空:降级为ask_user或notify_only,不能写空 preview。ask_user / notify_only / close不调用 LLM 选择;是否需要 LLM 解释文案可在通知模块单独生成 summary,但不改变 decision。
6.3.6 Candidate schema
候选结构:
ActiveScheduleCandidate
candidate_id
candidate_type
title
summary
target
changes
before_summary
after_summary
risk
score
validation
source
candidate_type 第一版:
add_task_pool_to_schedule
makeup_block
local_reorder_makeup
ask_delay_end
compress_with_next_dynamic_task # 预留,第一轮关闭
notify_only
close
changes 使用预览模块可消费的统一变更项:
ActiveScheduleChangeItem
change_type # add / move / compress / create_makeup / ask_user / none
target_type # task_pool / task_item / schedule_event
target_id
from_slot
to_slot
duration_sections
affected_event_ids
edited_allowed
metadata
约束:
- 候选必须能转成预览模块的
preview_changes。 - 候选不能直接携带 DAO model。
close / notify_only / ask_delay_end可以没有正式日程变更,但仍要有明确candidate_type和解释。edited_allowed=true只表示详情页可以让用户拖动 after 方案;confirm 时仍必须重校验。
6.3.7 候选生成规则
important_urgent_task 主路径:
- 若
target_completed=true:生成close。 - 若
target_already_scheduled=true:生成close或notify_only。 - 若存在满足容量的 free slot:
- 生成
add_task_pool_to_schedule。 - 优先使用 deadline 前槽位。
- 优先使用 memory 偏好匹配槽位,但 memory 只能影响排序,不能覆盖硬冲突。
- 生成
- 若没有完整 free slot:
- 第一轮不生成
compress_with_next_dynamic_task。 - 记录
capacity_insufficient,生成notify_only / ask_user,提示用户重新选择时间或缩短任务。
- 第一轮不生成
- 若无候选但信息完整:
- 生成
notify_only,说明无法安全安排。
- 生成
unfinished_feedback 主路径:
- 若无法定位 feedback target:生成
ask_user。 - 若能定位 schedule_event:
- 第一版优先生成
makeup_block,只新增补做块,不移动原任务。 - 若补做块挤压后续动态任务,第一轮不生成压缩融合候选,降级为
ask_user / notify_only。
- 第一版优先生成
- 若目标属于 task_item 且需要局部补救:
- 调用“偏好软化版局部粗排”生成
local_reorder_makeup。 - 输入范围为当前时刻到 task_class.end_date。
- 只传受影响的 item,不重排整张大表。
- 调用“偏好软化版局部粗排”生成
- 若 deadline / end_date 已过且无可用窗口:
- 生成
ask_delay_end或notify_only,不强行安排到无效时间。
- 生成
6.3.8 合法性校验规则
候选写入 preview 前必须通过后端校验:
- 用户归属:
- candidate 中所有 target / affected event 必须属于同一 user。
- 时间窗:
- 所有
to_slot必须在 context window 或局部补救窗口内。 to_slot必须能映射到正式schedules原子节次。
- 所有
- 冲突:
add / create_makeup不得覆盖课程、固定日程、已确认任务。compress第一版不依赖“是否允许压缩”的显式配置项;只允许作用在后端识别出的next_dynamic_task上,且必须排除课程、固定日程、已锁定任务、已完成任务和无法缩短的任务块。
- 时长:
duration_sections必须等于目标预计节数,或明确记录压缩比例。- task_pool 第一版限制 1 到 4 节。
- 来源:
- task_pool 候选必须保持
task_source_type=task_pool。 - task_item 候选必须保留 task_class 归属与顺序语义。
- task_pool 候选必须保持
- 局部重排:
- 只能移动局部补救输入集合内的 item。
- 不得打乱同一 task_class 内必须保持的前后顺序。
- 幂等:
- 同一 context 内候选用
candidate_type + target_id + normalized_changes_hash去重。
- 同一 context 内候选用
- 可解释性:
- 候选必须有
before_summary / after_summary / risk,否则不能进入 LLM 选择。
- 候选必须有
校验失败处理:
- 单个候选失败:丢弃该候选并写入 trace warning。
- 全部候选失败:decision 降级为
ask_user / notify_only。 - 校验失败不能交给 LLM 自行判断。
6.3.9 候选排序规则
排序因子建议:
score =
deadline_score
+ capacity_score
+ preference_score
+ minimal_change_score
+ risk_penalty
+ disruption_penalty
排序原则:
- deadline 前候选优先于 deadline 后候选。
- 不移动已有任务优先于移动 / 压缩已有任务。
- 满足 memory 偏好的候选优先于不满足偏好的候选。
- 影响事件数量少的候选优先。
- 同分时选择更早可执行的槽位。
- 第一版最多保留 top3 给 LLM。
- 必须保留一个后端 fallback top1,LLM 受限重试后仍失败时使用。
候选数量:
min = 1
max = 3
但 close / ask_user / notify_only 场景允许没有可应用候选。
6.3.10 LLM 选择与解释边界
LLM 输入:
context_summary
metrics
issues
decision
candidates(top3)
memory_context_text
LLM 输出:
selected_candidate_id
summary
reason
risk_text
notification_summary
约束:
- LLM 不能新增候选。
- LLM 不能修改
changes。 - LLM 不能把
ask_user / notify_only / close改成select_candidate。 - LLM 返回的
selected_candidate_id不存在或格式非法时,不立刻采纳 top1,而是进行一次受限重试;重试 prompt 只允许从现有 candidate_id 列表中选择,不能新增候选或修改 changes。 - 受限重试仍失败时,使用后端 top1 作为推荐候选写入 preview,并记录
llm_fallback_used=true;该候选仍需用户确认后才会正式应用。 - LLM 文案需要做长度和空值校验;失败时使用固定 fallback:
我为你生成了一份日程调整建议,请回到系统确认是否应用。 notification_summary可传给通知模块,但通知模块仍保留自己的模板 fallback。
6.3.11 与 analyze_health 的复用和隔离边界
可复用思想:
- 后端先算 metrics / issues。
- 后端先做 decision。
- 候选由后端枚举并校验。
- 候选需要模拟 after,并保留 before/after 摘要。
- LLM 只在合法候选里选择,不做开放式搜索。
不直接复用的部分:
- 不把主动调度做成
analyze_health工具。 - 不进入 Execute ReAct 循环。
- 不直接复用
analyze_health的move / swap候选类型,因为主动调度第一版候选包含 task_pool 加入日程、补做块、通知和询问;压缩融合只作为后续预留候选。 - 不复用
ScheduleState作为唯一输入;主动调度输入是ActiveScheduleContext,其中包含 trigger、memory、feedback、task_pool facts 和 schedule facts。
建议抽公共层:
- 时间格和节次合法性。
- 冲突判断。
- 局部 before/after 摘要。
- 候选模拟后的收益 / 风险评分框架。
这些公共能力若已经在 backend/newAgent/tools/schedule 中存在,迁移时按并行迁移策略抽出小 helper;不要本轮直接大搬整个 schedule tool 包。
6.3.12 错误处理与可观测
- observe 失败:trigger 标记 failed,可重试。
- candidate 生成失败但 context 可解释:decision 降级为
notify_only / ask_user。 - LLM 选择输出非法:先受限重试一次,仍失败再使用后端 fallback candidate。
- LLM 文案失败:使用固定 fallback 文案。
- 每个候选保留
validation.warnings和score_breakdown,便于 dry-run 查看为什么它被保留或丢弃。 - trace 至少记录:
- metrics 摘要。
- issue codes。
- decision action / reason_code。
- generated_candidate_count。
- valid_candidate_count。
- selected_candidate_id。
- llm_fallback_used。
6.3.13 测试方案
单元测试:
- target 已完成时 decision 为
close。 - target 已进入日程时不生成重复
add_task_pool_to_schedule。 - feedback 目标未知时 decision 为
ask_user。 - 24 小时窗口内存在 free slot 时生成
add_task_pool_to_schedule。 - memory 偏好匹配槽位排序高于非匹配槽位。
- 无 free slot 但存在 next dynamic task 时不生成
compress_with_next_dynamic_task,decision 降级为ask_user / notify_only。 - task_pool 候选非法时被校验丢弃。
- 局部补救候选不得移动输入集合外的 task_item。
- 候选去重基于 normalized changes hash。
- LLM 返回非法 candidate_id 时先受限重试一次,重试仍失败再 fallback 到后端 top1。
集成测试:
- dry-run 返回 metrics / issues / decision / candidates。
- 正式 trigger 生成合法候选后进入 LLM selection,并输出 selected candidate。
- LLM 超时、失败或受限重试后仍输出非法时,仍能生成 preview fallback。
- 与 5.3 context 串联:task_pool 只用 memory 软偏好,task_item 使用 task_class 约束。
- 与 7.x preview 串联:候选可转换为 preview_changes。
人工验收:
- 创建一个 24 小时内有空闲节次的紧急 task,dry-run 能看到 1 到 3 个候选。
- 添加“晚上更适合写作”的 memory 后,晚间槽位排序更靠前或 explanation 说明偏好命中。
- 制造无空闲但有下一个动态任务的场景,看不到压缩融合候选,并返回
ask_user / notify_only。 - 制造“刚才那个没做完”但无法定位目标的反馈,返回 ask_user,不生成危险候选。
- 关闭 LLM 或模拟 LLM 两次输出非法,后端仍能用 top1 fallback 生成 preview。
7. 模块四:预览、前后对比与确认
7.1 业务实现逻辑简述
主动调度候选必须先写入待确认预览,让用户看到“为什么触发、改前是什么、改后是什么、风险是什么、不调整的后果是什么”。
确认粒度按候选项确认,不做整版黑盒确认。确认后才进入正式应用链路。
7.2 已拍板结论
- 预览复用
agent_schedule_states,还是新增active_schedule_previews?- 已确认:新增
active_schedule_previews承载主动调度预览持久化;不直接塞进agent_schedule_states。展示层可以抽通用 before/after change schema,供现有会话排程预览和主动调度预览复用。
- 已确认:新增
- 预览是否必须保存 before 快照,还是第一版只保存 change item + 当前状态版本?
- 已确认:第一版不保存全量 before 快照,保存受影响范围的
before_summary + preview_changes + base_version,用于展示改前/改后和确认前安全校验。
- 已确认:第一版不保存全量 before 快照,保存受影响范围的
- 回滚第一版是“失败后不落库即可”,还是必须支持已应用后的撤销?
- 已确认:第一版不开放 apply 成功后的撤销能力;apply 必须事务化,失败不落库,并回写
apply_status / apply_error。成功后轻量记录applied_event_ids,为审计和后续撤销能力预留。
- 已确认:第一版不开放 apply 成功后的撤销能力;apply 必须事务化,失败不落库,并回写
- 用户确认入口走现有 Agent resume 协议,还是新增主动调度确认 API?
- 已确认:不走 Agent resume。MVP 新增主动调度详情页和确认 API;飞书链接进入详情页。详情页采用助手卡片式体验,展示解释文案和日程对比卡片,支持拖动 after 方案后确认。
- 预览过期时间设多久?
- 已确认:MVP 预览过期时间为 1 小时;过期后不可确认应用,只能重新触发生成新的预览。
7.3 执行计划:预览、前后对比与确认协议
本模块负责把 6.3 选出的候选持久化成用户可查看、可确认、可审计的主动调度预览。它不负责重新生成候选,也不在用户未确认前修改正式日程。确认 API 第一版同步调用正式应用链路,但“如何把 candidate 转成正式写库请求”的细节放到 8.3。
7.3.1 代码落点
- 预览领域模型与 DTO:
backend/active_scheduler/preview - 预览 repo:
backend/active_scheduler/repo - API handler:
backend/api/active_schedule.go - 路由注册:
backend/routers - 与前端共享的展示 DTO:
若现有会话排程预览也要复用 before/after 展示结构,后续可再抽到更公共的 schedule preview DTO 包;MVP 先不大搬旧链路。
backend/active_scheduler/model
7.3.2 active_schedule_previews 表结构方向
第一版新增 active_schedule_previews,不复用 agent_schedule_states 和 Redis 会话预览缓存。
建议字段:
preview_id # 建议字符串或雪花 ID
user_id
trigger_id
trigger_type
target_type
target_id
status # pending / ready / applied / ignored / expired / failed
selected_candidate_id
candidate_count
selected_candidate_json
candidates_json
decision_json
metrics_json
issues_json
context_summary_json
before_summary_json
preview_changes_json
after_summary_json
risk_json
explanation_text
notification_summary
base_version
expires_at
generated_at
apply_id
apply_status # none / applying / applied / failed / rejected / expired
apply_candidate_id
apply_idempotency_key
apply_request_hash
applied_changes_json
applied_event_ids_json
apply_error
applied_at
trace_id
created_at
updated_at
deleted_at
索引建议:
idx_active_previews_user_created_at(user_id, created_at)
idx_active_previews_trigger_id(trigger_id)
idx_active_previews_expires_at(expires_at)
uk_active_previews_apply_idempotency(preview_id, apply_idempotency_key)
约束:
preview_id是飞书跳转和详情页查询的唯一定位键。trigger_id用于串联trigger -> preview -> notification -> apply。candidates_json保存后端合法候选全集,通常最多 3 个。selected_candidate_json保存 LLM 选择后或 fallback top1 的推荐候选。before_summary_json / preview_changes_json / after_summary_json是详情页展示和 confirm 重校验的核心输入。base_version用于确认时判断预览生成后的正式日程是否发生变化;MVP 可先使用受影响范围的更新时间摘要或 schedule 版本摘要,后续再收敛为正式 version 字段。apply_*字段先放在 preview 表内,MVP 不新增 apply request 表;后续异步化时可平滑迁到active_schedule_apply_requests。
7.3.3 Preview 状态机
预览主状态:
pending -> ready
ready -> applied
ready -> ignored
ready -> expired
ready -> failed
pending -> failed
状态语义:
pending:已准备写入或正在组装预览,不应对用户展示为可确认。ready:可查看、可确认,且未过期。applied:用户已确认并成功应用。ignored:用户明确忽略本次建议。expired:超过expires_at,不可确认。failed:预览写入、候选转换或 apply 回写失败。
apply 子状态:
none -> applying -> applied
none -> applying -> failed
none -> rejected
none -> expired
状态约束:
status=applied时,apply_status必须为applied。status=expired时,confirm API 必须拒绝确认,并把apply_status置为expired或保持不可应用状态。status=ignored / applied / expired后,默认不允许再次 confirm。- 第一版同一个 preview 只允许成功 apply 一次。
7.3.4 Preview 写入流程
worker pipeline 在 LLMSelectAndExplain 后写 preview:
1. 接收 observation result、候选列表、selected candidate 和解释文案。
2. 构造 before_summary:
- 只记录受影响时间窗 / 受影响事件 / 目标任务摘要。
- 不保存全量日程快照。
3. 构造 preview_changes:
- 由 selected candidate 的 changes 转换而来。
- 保留 candidate_id / change_id / target / slot / duration / affected ids。
4. 构造 after_summary:
- 基于 before_summary + preview_changes 生成用户可读改后视图。
- 不写正式 schedule 表。
5. 生成 base_version:
- 记录受影响范围内当前正式日程版本或更新时间摘要。
6. 写入 active_schedule_previews:
- status=ready
- apply_status=none
- expires_at=generated_at + 1h
7. 回写 trigger 状态为 preview_generated。
8. 发布 notification.feishu.requested。
失败处理:
- preview 写入失败:trigger 标记 failed,不发布 notification。
- selected candidate 缺少可展示字段:preview 不写入,trigger 标记 failed 或降级 notify_only。
- notification 失败不回滚 preview,通知状态由
notification_records承载。
7.3.5 展示 DTO
详情页响应建议:
ActiveSchedulePreviewDetail
preview_id
status
apply_status
expires_at
expired
trigger
explanation
selected_candidate
candidates
before
after
changes
risk
can_confirm
can_ignore
trace_id
before / after 建议使用轻量展示结构:
SchedulePreviewVersion
title
window_start
window_end
entries
summary_lines
entries:
SchedulePreviewEntry
entry_id
source_type # course / schedule_event / task_pool / task_item / virtual
source_id
title
start_at
end_at
week
day_of_week
section_from
section_to
status # unchanged / added / moved / compressed / affected / removed
editable
changes:
ActiveScheduleChangeItem
change_id
change_type # add / move / compress / create_makeup / ask_user / none
target_type
target_id
from_slot
to_slot
duration_sections
affected_event_ids
edited_allowed
metadata
展示原则:
- 前端展示的是后端持久化的 preview,不重新实时计算候选。
expired=true时仍可查看解释和 before/after,但不能确认。editable=true / edited_allowed=true只控制前端是否允许拖动 after 方案,不代表后端会信任前端结果。- 前端不要从 URL 中传
candidate_id;详情页通过preview_id读取完整数据。
7.3.6 API 设计
新增鉴权接口:
GET /active-schedule/previews/{preview_id}
POST /active-schedule/previews/{preview_id}/confirm
POST /active-schedule/previews/{preview_id}/ignore
飞书和 Web 路由:
/assistant/{conversation_id}
页面打开流程:
1. Web 路由解析 conversation_id。
2. 前端先加载 conversation 历史,再按当前会话上下文拉取主动调度 preview。
3. 后端通过 `active_schedule_sessions` 校验当前会话是否还在主动调度管辖期。
4. 返回详情 DTO。
5. 前端根据 can_confirm / expired / apply_status 展示确认、忽略或历史状态。
confirm 请求:
{
"candidate_id": "cand_1",
"action": "confirm",
"edited_changes": [
{
"change_id": "chg_1",
"change_type": "add",
"target_type": "task_pool",
"target_id": 123,
"to_slot": {
"week": 8,
"day_of_week": 4,
"section_from": 8,
"section_to": 8
},
"duration_sections": 1
}
],
"idempotency_key": "frontend-generated-uuid"
}
confirm 响应:
{
"preview_id": "asp_123",
"apply_id": "asa_456",
"apply_status": "applied",
"applied_event_ids": [1001],
"message": "已应用"
}
ignore 请求:
{
"reason": "user_dismissed"
}
ignore 语义:
- 只把 preview 标记为
ignored。 - 不修改正式日程。
- 不影响同一任务后续在新的时间窗再次触发;触发去重仍按 trigger 模块规则处理。
7.3.7 Confirm API 校验流程
确认接口固定流程:
1. 鉴权并读取 preview。
2. 校验 preview.user_id == 当前用户。
3. 校验 status=ready 且 apply_status=none;若命中幂等记录,则按幂等规则返回上一轮结果。
4. 校验 expires_at 未过期。
5. 校验 candidate_id 属于 preview.candidates。
6. 读取 idempotency_key,计算 apply_request_hash。
7. 命中同一 preview_id + idempotency_key:
- hash 相同:返回上一次 apply 结果。
- hash 不同:拒绝,提示幂等键复用到不同请求。
8. 生成 apply_id,写 apply_status=applying。
9. 后端重校验 edited_changes:
- target 归属。
- slot 合法。
- 不覆盖课程 / 固定日程 / 已确认任务。
- base_version 未失效或受影响范围未变化。
10. 调用 8.3 的正式应用服务。
11. 成功:写 applied_changes_json / applied_event_ids_json / applied_at,状态变为 applied。
12. 失败:写 apply_error,apply_status=failed;是否把主状态置为 failed 由失败类型决定,正式写入失败时不允许绕过幂等重复应用。
关键约束:
- 后端必须允许
edited_changes为空;为空时使用候选原始 changes。 - 后端必须允许
edited_changes与候选原始 changes 不同;不同表示用户拖动了 after 方案,但仍要在同一 candidate 的允许编辑范围内。 - 后端不能相信前端传来的 title、summary、risk,只信 target 和 slot 等结构化字段。
- confirm 成功前不得发布
schedule.apply.succeeded。 - confirm 失败不得产生半写正式日程。
7.3.8 幂等与重复提交
幂等键:
unique(preview_id, idempotency_key)
请求摘要:
apply_request_hash = hash(candidate_id + action + normalized_edited_changes)
规则:
- 前端每次点击确认生成一个新的
idempotency_key。 - 同一次点击的网络重试必须复用同一个
idempotency_key。 - 同一 key + 同一 hash:返回同一
apply_id和结果。 - 同一 key + 不同 hash:拒绝。
- 不同 key + 同 preview:如果 preview 已 applied,拒绝重复应用或返回已应用状态。
- 第一版不支持一个 preview 成功应用多个候选。
7.3.9 过期与重建
过期规则:
expires_at = generated_at + 1h
处理方式:
- GET 详情时若已过期,返回
expired=true / can_confirm=false。 - confirm 时若已过期,拒绝并更新状态为
expired。 - 过期 preview 仍可查看历史解释。
- 前端提示用户重新生成建议。
- 重新生成必须走新的 trigger / dry-run 链路,生成新的 preview_id,不在旧 preview 上覆盖。
7.3.10 与现有 Agent 预览的关系
复用边界:
- 不复用
agent_schedule_states。 - 不复用 Redis
schedule_previewkey。 - 可参考
SchedulePlanPreviewCache / GetSchedulePlanPreviewResponse的展示思想,但主动调度使用独立 DTO。 - 可抽通用
SchedulePreviewVersion / SchedulePreviewEntry / ActiveScheduleChangeItem展示结构,供后续会话排程预览复用。
隔离原因:
- 主动调度 preview 只管预览内容本身,不直接承担
conversation_id这类路由职责;会话由active_schedule_sessions单独承接。 - 主动调度 preview 绑定
trigger_id / preview_id / expires_at / apply_status,语义集中且便于审计。 - 会话排程预览是 Agent state 的派生视图,不适合承载后台通知和 apply 审计。
7.3.11 错误处理与可观测
- preview 不存在:返回 not found,不泄漏是否属于其它用户。
- preview 不属于当前用户:返回 not found 或 forbidden,产品上建议统一 not found。
- preview 已过期:GET 可返回详情,confirm 返回业务错误。
- preview 已 applied:confirm 幂等命中则返回原结果;非幂等重复确认则拒绝。
- base_version 失效:confirm 失败,不写正式日程,提示重新生成。
- edited_changes 非法:confirm 失败,写 apply_error。
- 正式应用服务失败:事务回滚,写 apply_status=failed。
- 所有 confirm 路径必须能通过
preview_id / apply_id / idempotency_key / trace_id查日志。
7.3.12 测试方案
单元测试:
- candidate 转
preview_changes。 - before_summary + preview_changes 生成 after_summary。
- preview 过期判断。
preview_id + idempotency_key幂等命中。- 同一 idempotency_key 不同 hash 被拒绝。
edited_changes越界、冲突、跨用户 target 被拒绝。- preview 状态机流转合法性。
集成测试:
- worker 写入
active_schedule_previews后,GET 详情能读取完整 before/after。 - 飞书链接
/assistant/{conversation_id}能进入会话页并读取同一 preview。 - confirm 原始候选成功,状态变为
applied。 - confirm 拖动后的
edited_changes成功,应用内容以 edited changes 为准。 - preview 过期后 confirm 被拒绝。
- base_version 改变后 confirm 被拒绝。
- 用户重复点击确认不会重复写正式日程。
人工验收:
- 打开主动调度详情页,能看到触发原因、推荐调整、改前 / 改后、风险说明。
- 拖动 after 方案后确认,后端按拖动后位置应用。
- 对过期 preview 点击确认,页面提示重新生成。
- 对已应用 preview 再次点击确认,不产生重复日程块。
8. 模块五:正式应用链路
8.1 业务实现逻辑简述
主动调度模块不直接写正式日程。用户确认某个候选后,后端把候选转换为现有 service 能理解的正式应用请求。
应用成功后发布 schedule.apply.succeeded;失败则发布 schedule.apply.failed,并把失败原因写回预览状态。
8.2 已拍板结论
- 从任务池任务加入日程时,正式写入目标是
schedule_events(type=task, rel_id=tasks.id),还是先转为task_items?- 已确认:不转为
task_items。正式写入schedule_events.type=task, task_source_type=task_pool, rel_id=tasks.id,并写入对应schedules原子节次。
- 已确认:不转为
- 未完成补救涉及已排任务移动时,是否第一版只支持生成新补做块,不支持直接移动原任务?
- 已确认:第一版只支持生成新的补做块,不直接移动原已排任务。这样可以降低对既有 schedule / task_item 状态的扰动,后续再扩展移动原任务。
schedule.apply.requested第一版是否需要 outbox 异步消费,还是确认接口内同步调用 service?- 已确认:MVP 确认接口内同步调用正式应用 service,不新增 outbox apply 消费链路,也不强制新增 apply request 表。
- 已确认:确认接口负责完成预览读取、过期校验、候选归属校验、
edited_changes重校验、事务写库和 apply 结果回写。 - 已确认:后续若 apply 变重,再迁移为
active_schedule_apply_requests + schedule.apply.requested异步消费;MVP 先通过 preview 表内 apply 字段保留迁移空间。
- 应用幂等键用
preview_id + candidate_id,还是单独生成apply_id?- 已确认:使用独立
apply_id表示一次确认应用尝试。 - 已确认:使用
idempotency_key绑定一次确认请求,推荐唯一约束为preview_id + idempotency_key。 - 已确认:
preview_id + candidate_id只用于定位用户基于哪一个候选确认,不代表最终应用内容;拖动后的最终内容以edited_changes为准,并必须重新校验。
- 已确认:使用独立
8.3 执行计划:正式应用链路
本模块负责把用户确认后的 preview_changes / edited_changes 转成正式日程写入。它必须在事务内完成重校验、写库和结果回写;失败时不能产生半写状态。MVP 确认接口内同步调用本模块,不发布 schedule.apply.requested 给异步 worker。
8.3.1 代码落点
- 正式应用入口:
backend/active_scheduler/apply - 候选 / change 转换器:
backend/active_scheduler/apply/convert - 正式写入 adapter:
backend/active_scheduler/adapters - 复用既有领域 service:
backend/service/task-class.go backend/service/schedule.go backend/dao/schedule.go - apply 结果回写:
backend/active_scheduler/preview
建议定义主动调度自己的 apply port:
type ScheduleApplyPort interface {
ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error)
}
说明:
- confirm API 只依赖主动调度 apply service,不直接调用 DAO。
- apply service 内部按 change 类型分流到现有 service 或本地 adapter。
- 所有正式写库都必须走事务。
8.3.2 Apply 请求与结果
请求结构方向:
ApplyActiveScheduleRequest
preview_id
apply_id
idempotency_key
user_id
candidate_id
base_version
changes
requested_at
trace_id
结果结构方向:
ApplyActiveScheduleResult
apply_id
apply_status
applied_event_ids
applied_schedule_ids
applied_changes
skipped_changes
warning_messages
约束:
changes来自edited_changes;若前端未编辑,则使用 preview 中的原始preview_changes。candidate_id只用于定位候选来源,不作为幂等键。base_version必须参与重校验,避免预览生成后正式日程已变化。applied_changes必须记录最终真实落库内容,而不是原始候选内容。
8.3.3 支持的 change_type
MVP 正式应用支持:
add_task_pool_to_schedule
create_makeup
有条件支持:
add_task_item_to_schedule
local_reorder_makeup
预留但第一轮不启用:
compress_with_next_dynamic_task
不直接应用:
ask_user
notify_only
close
规则:
ask_user / notify_only / close只更新 preview 状态或通知结果,不写正式日程。add_task_item_to_schedule仅用于未安排过的 task_item,可复用TaskClassService.BatchApplyPlans。local_reorder_makeup若只包含未安排 task_item 的新增落位,可转成BatchApplyPlans;若包含移动既有已排事件,MVP 不正式应用,应在候选生成阶段过滤或降级为ask_user。create_makeup表示新增一个补做块,不移动原已排任务。compress_with_next_dynamic_task第一轮不生成、不应用;后续打开前必须先完成 8.3.9 的事务落库能力和端到端测试。
8.3.4 候选到正式请求的转换器
转换流程:
1. 读取 preview 中的 selected_candidate / candidates。
2. 校验 confirm candidate_id 存在。
3. 选择 changes:
- edited_changes 非空:使用 edited_changes。
- edited_changes 为空:使用 candidate 原始 changes。
4. NormalizeChanges:
- 排序。
- 填充缺省 duration。
- 合并连续节次。
- 生成 normalized hash。
5. ValidateChangeScope:
- 不允许新增 preview 中不存在的 target。
- 不允许越过 candidate 的 edited_allowed 范围。
6. ConvertToApplyRequest:
- 按 change_type 转换为具体写入命令。
转换器输出:
ApplyCommand
command_type # insert_task_pool_event / insert_makeup_event / batch_apply_task_items / split_compress_event
target_type
target_id
slots
source_event_id
metadata
转换器不做 DB 写入,只生成可校验、可事务执行的命令。
8.3.5 重校验规则
正式写入前必须重新读取数据库真值:
- 预览归属:
- preview 属于当前 user。
- preview 未过期。
- preview 未 applied / ignored / expired。
- target 归属:
- task_pool 属于当前 user。
- task_item 属于当前 user 的 task_class。
- schedule_event 属于当前 user。
- target 状态:
- task_pool 未完成。
- task_pool 未进入日程。
- task_item 若走
BatchApplyPlans,必须未安排。 - 补做块允许原任务已安排,但不能更新原任务的 embedded_time。
- 时间合法:
- week / day_of_week / section 在合法范围内。
- 相对时间能转换为绝对时间。
- 节次数量符合 duration。
- 冲突合法:
- 不覆盖课程。
- 不覆盖固定日程。
- 不覆盖已确认任务。
- 若嵌入课程,必须满足课程可嵌入规则。
- base_version:
- 受影响范围内 schedule 版本或更新时间摘要未变化。
- 若变化,拒绝 apply,提示重新生成 preview。
8.3.6 task_pool 正式落库策略
add_task_pool_to_schedule 写入:
schedule_events
user_id = user_id
name = task.title
type = task
task_source_type = task_pool
rel_id = tasks.id
start_time = conv.RelativeTimeToRealTime(...)
end_time = conv.RelativeTimeToRealTime(...)
can_be_embedded = false
schedules
event_id = schedule_events.id
user_id = user_id
week = week
day_of_week = day_of_week
section = each section
status = normal
embedded_task_id = null
事务步骤:
1. 读取 task,校验 user_id / completed / status。
2. 校验 task 未已有 task_pool schedule_event。
3. 构造 schedules 原子节次。
4. 调用冲突检查。
5. 插入 schedule_events。
6. 回填 event_id 后插入 schedules。
7. 返回 applied_event_ids。
说明:
- 不创建 task_item。
- 不更新 task_class。
- 是否把 task 标记为“已安排”需要在 task 表结构阶段决定;MVP 可先通过
schedule_events.task_source_type=task_pool + rel_id判断是否已进入日程。 - 若后续要在 task 上加
scheduled_event_id / scheduled_at,也应在同一事务内更新。
8.3.7 task_item 正式落库策略
add_task_item_to_schedule / 可转换的 local_reorder_makeup:
- 若所有 change 都是未安排 task_item 的新增落位:
- 转成
model.UserInsertTaskClassItemToScheduleRequestBatch。 - 调用
TaskClassService.BatchApplyPlans(ctx, taskClassID, userID, batch)。
- 转成
- 使用
BatchApplyPlans的条件:- 所有 task_item 属于同一个 task_class。
- task_class mode 为
auto。 - task_item 当前未安排。
- 不包含移动既有 schedule_event。
- 不包含 task_pool。
- 不满足以上条件:
- 不调用
BatchApplyPlans。 - 若是移动已排任务,MVP 拒绝 apply 或在候选生成阶段不生成该候选。
- 不调用
原因:
BatchApplyPlans已经包含 task_class 归属校验、时间范围校验、课程嵌入校验、冲突校验和 task_item embedded_time 更新。- 但它会校验 task_item 未安排,因此不能拿它处理“已排任务的补做块”。
8.3.8 补做块正式落库策略
create_makeup 用于未完成反馈后的新增补做块。
写入原则:
- 不移动原 schedule_event。
- 不更新原 task_item 的
embedded_time。 - 新增一个独立 schedule_event 和对应 schedules。
- 必须记录它是补做块,避免后续误认为原任务唯一安排。
建议表结构配合:
schedule_events.task_source_type # task_pool / task_item
schedule_events.makeup_for_event_id # nullable,指向原未完成 schedule_event
schedule_events.active_preview_id # nullable,用于审计来源
如果 MVP 不想立刻加 makeup_for_event_id / active_preview_id,至少必须在 active_schedule_previews.applied_changes_json 中记录:
makeup_for_event_id
original_target_type
original_target_id
new_event_id
但工程倾向仍是给 schedule_events 加轻量来源字段,因为只存在 preview 审计里会让后续日程列表难以解释“这是补做块”。
写入流程:
1. 读取原 schedule_event,校验归属。
2. 判断原 event 来源:
- task_source_type=task_pool:rel_id 指向 tasks.id。
- task_source_type=task_item 或空:兼容旧数据,rel_id 指向 task_items.id。
3. 构造新 schedule_event:
- type=task
- task_source_type 沿用原来源
- rel_id 沿用原 target id
- makeup_for_event_id=原 event id
4. 插入新 event 和 schedules。
5. 返回新 event id。
8.3.9 压缩融合正式落库策略(预留)
compress_with_next_dynamic_task 第一轮实现关闭,本节只保留后续打开时的落库边界。任何 confirm 可见的候选都必须能正式应用;在该能力完成前,候选生成阶段不得返回压缩融合。
后续打开后的事务步骤:
1. 读取补做目标和 next_dynamic_task 当前真值。
2. 校验 next_dynamic_task 仍是同一个 event,且未完成、未锁定、不是课程。
3. 校验压缩后的两个时间段仍在 preview / edited_changes 允许范围内。
4. 删除或缩短 next_dynamic_task 原 schedules。
5. 写入补做块 schedules。
6. 写入压缩后的 next_dynamic_task schedules。
7. 更新两个 event 的 start_time / end_time。
8. 记录 applied_changes_json,标明 compression_ratio=50/50 或用户编辑后的比例。
MVP 保守规则:
- 只允许压缩动态任务,不允许压缩课程和固定日程。
- 只允许处理一个后继动态任务,不做多任务链式压缩。
- 压缩后每个任务至少保留 1 节,否则候选不合法。
- 若实现成本过高,第一版可在候选生成阶段关闭该候选;不能生成一个 confirm 后无法应用的 preview。
8.3.10 事务与回写
确认 API 中的状态流:
1. preview apply_status=none
2. confirm 获取幂等锁或行锁
3. 写 apply_id / apply_status=applying
4. 执行正式应用事务
5. 成功:
- preview.status=applied
- preview.apply_status=applied
- 写 applied_changes_json / applied_event_ids_json / applied_at
- 可发布 schedule.apply.succeeded
6. 失败:
- preview.apply_status=failed
- 写 apply_error
- 不写 applied_event_ids
- 可发布 schedule.apply.failed
事务边界:
- 正式 schedule 写入和 preview apply 回写应尽量在同一个数据库事务中完成。
- 若 preview 表与 schedule 表未来拆库,MVP 的同步事务需迁移为 apply request + outbox。
- 事件发布不能早于事务成功;若使用 outbox,应在同一事务内写 outbox。
8.3.11 应用失败分类
失败类型:
expired
idempotency_conflict
base_version_changed
target_not_found
target_completed
target_already_scheduled
slot_conflict
invalid_edited_changes
unsupported_change_type
db_error
处理规则:
- 可预期业务失败:返回明确业务错误,
apply_status=failed/rejected。 - DB 或事务失败:
apply_status=failed,保留 error code 和 trace。 - 幂等冲突:不进入正式写库。
- base_version 变化:不进入正式写库,提示重新生成预览。
- unsupported change type:说明候选生成和 apply 能力不匹配,视为后端 bug,trigger / preview 需可排障。
8.3.12 与事件契约的关系
MVP 不发布 schedule.apply.requested。
可发布:
schedule.apply.succeeded
schedule.apply.failed
事件 payload 建议包含:
preview_id
apply_id
user_id
trigger_id
candidate_id
applied_event_ids
apply_status
error_code
trace_id
说明:
- succeeded / failed 是结果事件,不是请求事件。
- 后续异步化时再新增
schedule.apply.requested,并把当前 confirm 内同步逻辑迁到 apply worker。 - 事件 payload 放
backend/shared/events,不直接复用 preview DB model。
8.3.13 测试方案
单元测试:
preview_changes转ApplyCommand。edited_changes为空时使用候选原始 changes。edited_changes越界时拒绝。- task_pool change 转 schedule_event / schedules。
- task_item change 转
BatchApplyPlans请求。 - create_makeup 不更新原 task_item embedded_time。
- compress 后两个任务均至少保留 1 节。
- apply_request_hash 稳定生成。
集成测试:
- task_pool 确认成功后写入
schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)。 - task_pool 重复确认不会重复写事件。
- task_item 未安排块通过
BatchApplyPlans成功落库。 - 已安排 task_item 补做块不调用
BatchApplyPlans,而是新增补做 event。 - slot 冲突时事务回滚,preview 写
apply_status=failed。 - base_version 变化时拒绝 apply。
- apply 成功后可通过
applied_event_ids查到正式日程。
人工验收:
- 从详情页确认 task_pool 候选,周视图出现新的任务块。
- 从详情页确认补做块,原任务不被移动,新补做块出现。
- 制造冲突后确认,页面显示失败,数据库没有半写 event。
- 网络重试同一 confirm 请求,只产生一组正式日程。
9. 模块六:通知触达与飞书边界
9.1 业务实现逻辑简述
飞书第一版只提醒用户回系统确认,不在飞书内应用日程、不标记完成、不做复杂 Agent Chat。
主动调度只发布 notification.feishu.requested,通知 handler/provider 负责具体投递。这样后续可以把 notification 拆成独立 Go module。
9.2 已拍板结论
- 第一版飞书通知文案是否只需要固定模板?
- 已确认:第一版先不把 LLM summary 当作已实现分支。通知文案优先复用候选 / preview summary,固定模板作为 fallback。
- 已确认:后续如果接入 LLM summary provider,也必须是可失败的增强分支,不能影响通知链路本身。
- 通知是否必须包含跳转链接?如果包含,Web 端预览详情 URL 规则是什么?
- 已确认:必须包含跳转链接。
- 已确认:URL 规则采用现有助手会话路由
/assistant/{conversation_id},每个主动调度会话在发通知前先绑定或预创建conversation_id。
- 通知幂等键是否按
preview_id,还是按user_id + trigger_type + time_window?- 已确认:按
user_id + trigger_type + time_window聚合去重,不按preview_id。 - 已确认:MVP 语义是同一用户同一触发类型在同一时间窗口内只推一次飞书,避免短时间重复打扰;具体 time_window 长度在表结构与状态机阶段细化。
- 已确认:按
- 飞书 provider 第一版放在 backend worker 内,是否需要同步预留
notification_records表?- 已确认:需要落
notification_records表。 - 已确认:飞书 provider 属于不可靠外部服务调用,必须保留可观测、可重试、可排障的投递记录,而不是只写日志。
- 已确认:需要落
9.3 执行计划:通知触达与飞书边界
本模块负责把主动调度预览转成“可观测、可重试、可去重”的飞书提醒。主动调度只发布 notification.feishu.requested,不直接调用飞书 provider;notification handler 负责落 notification_records、生成/兜底文案、调用 provider、记录结果和安排重试。
9.3.1 代码落点
- 事件契约:
只放事件类型、版本、payload DTO、基础校验和消息键构造。
backend/shared/events/notification.go - notification 模块:
放 service、provider interface、record repo、重试策略。
backend/notification - outbox handler:
负责注册并消费
backend/service/events/notification_feishu_requested.gonotification.feishu.requested。 - 飞书 provider:
backend/notification/providers/feishu - mock provider:
用于本地联调和自动化测试,避免真实打扰用户。
backend/notification/providers/mock - 配置加载:
注入 notification service 和 provider。
backend/config.example.yaml backend/cmd/start.go
9.3.2 事件契约
事件名:
notification.feishu.requested
版本:
event_version = 1
payload:
FeishuNotificationRequested
notification_id # 可为空;若发布前已创建 record,则携带
user_id
trigger_id
preview_id
trigger_type
target_type
target_id
dedupe_key
target_url # /assistant/{conversation_id}
summary_text # LLM 已生成摘要,可为空
fallback_text
trace_id
requested_at
消息键:
message_key = user_id
aggregate_id = preview_id
校验规则:
user_id / preview_id / target_url / dedupe_key必填。target_url必须是站内相对路径,例如/assistant/{conversation_id},不允许 provider payload 携带任意外部跳转链接。summary_text可为空;为空时 handler 使用 fallback 文案。- payload 不直接复用
active_schedule_previewsDB model。
9.3.3 notification_records 表结构方向
建议新增 notification_records:
id
channel # feishu
user_id
trigger_id
preview_id
trigger_type
target_type
target_id
dedupe_key
target_url
summary_text
fallback_text
fallback_used
status # pending / sending / sent / failed / dead / skipped
attempt_count
max_attempts
next_retry_at
last_error_code
last_error
provider_message_id
provider_request_json
provider_response_json
sent_at
trace_id
created_at
updated_at
deleted_at
索引建议:
uk_notification_dedupe(channel, dedupe_key)
idx_notification_status_retry(status, next_retry_at)
idx_notification_preview(preview_id)
idx_notification_user_created(user_id, created_at)
状态语义:
pending:记录已创建,等待投递。sending:当前 worker 正在调用 provider。sent:provider 明确返回成功。failed:本次投递失败,但仍可重试。dead:达到最大重试次数或不可恢复错误,不再自动重试。skipped:命中去重或配置关闭,本次不投递。
9.3.4 Provider 接口
notification 模块只依赖 provider interface:
type Provider interface {
Send(ctx context.Context, req SendRequest) (SendResult, error)
}
type SendRequest struct {
UserID int
OpenID string
TargetURL string
Title string
Text string
TraceID string
}
type SendResult struct {
ProviderMessageID string
RawResponse []byte
Retryable bool
}
职责边界:
- provider 只负责和飞书通信。
- provider 不做 dedupe。
- provider 不读取 preview。
- provider 不决定是否通知。
- provider 返回错误分类,notification service 决定 retry / dead。
MVP provider:
mock:打印日志或写入 record,不发真实飞书。feishu:通过配置的 webhook / app token / open_id 发送卡片或文本。- 若用户缺少飞书 open_id:记录
failed或dead,错误码为recipient_missing。
9.3.5 飞书配置项
建议配置:
notification:
enabled: true
provider: mock # mock / feishu
baseURL: "https://your-web-domain.example.com"
dedupeWindow: 30m
maxRetry: 5
retryBaseDelay: 30s
retryMaxDelay: 30m
feishu:
enabled: false
webhookURL: ""
appID: ""
appSecret: ""
说明:
baseURL用于把/assistant/{conversation_id}拼成飞书可点击链接。- 本地和测试环境默认
provider=mock。 notification.enabled=false时不调用 provider,但仍可按需要写skippedrecord 便于验证链路。dedupeWindow默认可先与important_urgent_task的 30 分钟触发去重窗口保持一致。
9.3.6 文案生成与 fallback
文案来源优先级:
1. payload.summary_text
2. preview.notification_summary
3. 后端固定 fallback_text
固定 fallback:
我为你生成了一份日程调整建议,请回到系统确认是否应用。
校验规则:
- summary 为空:使用 fallback。
- summary 过长:截断或使用 fallback,避免飞书卡片超限。
- summary 包含不允许的链接:去除链接或使用 fallback。
- summary 生成或校验失败不能阻断通知投递。
fallback_used=true必须记录到notification_records,方便排查通知文案质量。
9.3.7 通知处理流程
handler 消费 notification.feishu.requested:
1. 解析 shared/events payload。
2. 校验 user_id / preview_id / target_url / dedupe_key。
3. 按 channel + dedupe_key 查询 notification_records。
4. 若已有 pending / sending / sent:
- 标记当前 outbox consumed。
- 不重复创建记录,不重复发飞书。
5. 若已有 failed:
- 复用旧 record 进入重试流程,不新建重复通知。
6. 若不存在 record:
- 创建 pending 记录。
7. 读取用户飞书身份或 webhook 目标。
8. 生成最终文案。
9. 将 record 标记 sending,递增 attempt_count。
10. 调用 provider.Send。
11. 成功:status=sent,写 provider_message_id / response / sent_at。
12. 可重试失败:status=failed,写 last_error / next_retry_at。
13. 不可恢复失败:status=dead,写 last_error。
outbox 语义:
- handler 业务处理成功后才把 outbox 标记 consumed。
- 对 provider 临时失败,可选择:
- 让 outbox 重试整个 handler。
- 或 handler 自己写
notification_records.next_retry_at后 consumed,由 notification retry scanner 处理。
- MVP 建议采用“record 自己管理 provider 重试,outbox 只保证 notification request 被接收一次”的模式,避免 provider 慢失败阻塞通用 outbox 消费。
9.3.8 provider 重试扫描器
新增 notification retry worker:
1. 扫描 status=failed 且 next_retry_at <= now 的 notification_records。
2. 加行锁或状态 CAS,改为 sending。
3. 再次调用 provider。
4. 成功则 sent。
5. 失败则根据 attempt_count / max_attempts 决定 failed 或 dead。
退避策略:
next_retry_at = now + min(retryBaseDelay * 2^(attempt_count-1), retryMaxDelay)
不可重试错误:
recipient_missing
invalid_url
provider_auth_failed
payload_invalid
可重试错误:
provider_timeout
provider_rate_limited
provider_5xx
network_error
9.3.9 幂等与去重
通知 dedupe key:
user_id + trigger_type + time_window
MVP 窗口:
time_window = floor(requested_at / 30m)
规则:
- 同一
channel + dedupe_key同一时间只允许一条有效 notification record。 - 如果同一 dedupe key 已 sent,不再发送。
- 如果同一 dedupe key 已 pending / sending,不再创建。
- 如果同一 dedupe key failed,进入重试,不创建第二条。
- preview_id 不参与 dedupe 主键,但 record 仍保存 preview_id,用于知道最终跳转到哪份预览。
注意:
- 如果同一窗口多个 preview 命中同一 dedupe_key,MVP 先以减少打扰为优先,只保留第一条通知。
- 后续如需“聚合多条 preview”,可在 record 中增加
related_preview_ids_json,但不作为第一版范围。
9.3.10 与主动调度的边界
active_scheduler 负责:
- 决定是否需要通知。
- 生成 preview。
- 生成
notification.feishu.requestedpayload。 - 发布 outbox 事件。
notification 负责:
- dedupe。
- 落
notification_records。 - 文案 fallback。
- provider 调用。
- provider retry。
- provider 结果观测。
notification 不负责:
- 生成调度候选。
- 修改 preview。
- 应用日程。
- 判断任务是否紧急。
9.3.11 启动与注册
接入点:
cmd/start.go初始化 notification service。RegisterCoreOutboxHandlers增加RegisterFeishuNotificationRequestedHandler。worker和all模式启动 notification retry scanner。api模式只允许发布 outbox,不启动 provider 消费和 retry scanner。
依赖注入:
notification service
-> notification repo
-> provider(mock/feishu)
-> user contact reader
-> config
若第一版暂时没有用户飞书身份表:
- provider 先支持 webhook 模式,用测试群 webhook 完成链路验证。
user contact reader预留接口,后续再接 user profile / feishu binding。
9.3.12 迁出边界
后续迁出独立 notification 服务时保留:
backend/shared/events/notification.go
notification_records schema
Provider 接口语义
dedupe_key 规则
迁移方式:
- active_scheduler 继续只发布
notification.feishu.requested。 - notification 服务独立消费同一事件。
- 原 backend worker 停止注册 notification handler。
notification_records可按数据所有权迁出,或先保留在同库读写。
不能迁出的内容:
- active_scheduler 内部候选结构。
- preview DB model 的完整字段。
- 飞书 provider SDK 细节。
9.3.13 错误处理与可观测
必须记录:
notification_id
dedupe_key
preview_id
trigger_id
channel
status
attempt_count
last_error_code
last_error
provider_message_id
trace_id
日志要求:
- 每次 provider 调用记录
notification_id / preview_id / attempt_count / trace_id。 - provider response 不直接打印敏感 token。
- dead 状态必须有明确 error_code。
- dedupe 命中不视为错误,但要记录 debug / info 日志。
指标建议:
notification_requested_total
notification_sent_total
notification_failed_total
notification_dead_total
notification_dedupe_hit_total
notification_fallback_used_total
notification_provider_latency_ms
9.3.14 测试方案
单元测试:
notification.feishu.requestedpayload validate。- dedupe key 生成。
- summary 为空时使用 fallback。
- summary 过长时截断或 fallback。
- provider 可重试错误计算
next_retry_at。 - provider 不可重试错误进入 dead。
- 同一
channel + dedupe_key不重复创建 record。
集成测试:
- preview 生成后发布
notification.feishu.requested。 - handler 消费事件后写
notification_records。 - mock provider 成功后 record 变为 sent。
- mock provider 临时失败后 record 变为 failed,并写 next_retry_at。
- retry scanner 再次投递成功后 record 变为 sent。
- 重复消费同一 outbox 不重复发通知。
notification.enabled=false时生成 skipped 或不调用 provider,链路可观测。
人工验收:
- 使用 mock provider 验证 dry-run 不发通知、正式 trigger 发通知记录。
- 使用测试飞书 webhook 收到包含
/assistant/{conversation_id}的消息。 - 模拟 provider 失败后能看到 failed / retry / sent 状态变化。
- 30 分钟窗口内重复触发,不重复收到飞书。
10. 模块七:与微服务迁移的协作边界
10.1 业务实现逻辑简述
第二阶段开发必须避免阻塞微服务迁移。当前策略是:先在 backend 内按服务边界写清楚,等协议稳定后再迁出独立 module。
api / worker / all 启动边界第一阶段已经完成。当前剩余工作不是继续拆启动入口,而是在既有 worker / API 边界上接入主动调度、notification 和 schedule apply。
API、worker、active scheduler、notification、schedule apply 的职责边界仍必须从第一版就分清。
10.2 已拍板结论
- 是否先完成
api / worker / all启动边界拆分,再合入主动调度主链路?- 已确认:当前已完成第一阶段启动边界拆分,存在
api / worker / all三种启动入口。 - 已确认:
api模式只启动 Gin 和同步 service / DAO 依赖,不启动后台 worker;worker模式只启动 outbox、Kafka consumer、事件 handler、memory worker,不注册 Gin 路由;all模式保留迁移期单体兼容行为。 - 已确认:主动调度 MVP 可以直接挂到 worker / 事件链路,不需要再等待启动边界拆分。
- 说明:这里完成的是运行生命周期边界,不是完整微服务拆分;独立 Go module、独立部署配置和数据所有权拆分后续再做。
- 已确认:当前已完成第一阶段启动边界拆分,存在
- 主动调度代码第一版放在
backend/service/active_scheduler,还是backend/active_scheduler?- 已确认:第一版不放
backend/service/active_scheduler,避免继续并入旧 service 单体。 - 已确认:第一版放
backend/active_scheduler,按未来独立 active-scheduler 服务组织目录、DTO、状态机、pipeline 和 handler。 - 已确认:MVP 暂不拆成独立 Go module / 独立进程,仍复用当前
backend的启动、DAO、outbox、LLM 初始化和事务能力。 - 已确认:等事件契约、表结构、预览 / apply 协议稳定后,再按并行迁移策略迁出独立 active-scheduler module。
- 已确认:第一版不放
- 事件契约是否提前放入
backend/shared/events风格目录,即使当前还未多 module?- 已确认:提前放入
backend/shared/events。 - 已确认:该目录只承载跨模块事件协议,包括 event type、event version、payload DTO、基础校验和少量 normalize。
- 已确认:该目录不放 DAO、service、handler、provider、LLM prompt、复杂业务判断,避免 shared 目录变成共享业务层。
- 已确认:主动调度、notification、worker handler、API 依赖
backend/shared/events,而不是互相依赖业务包。 - 已确认:后续微服务切流时,
backend/shared/events可迁出为独立 contracts module。
- 已确认:提前放入
- 第一版是否允许主动调度 service 直接依赖 DAO,还是通过现有 service 读取?
- 已确认:不允许主动调度主链路散落依赖其它领域 DAO。
- 已确认:采用 port / adapter 方式组织依赖。
backend/active_scheduler内定义读取事实和正式应用所需的接口,MVP adapter 可复用现有 service;若现有 service 缺少合适读模型,允许 adapter 内部调用 DAO 组装,但不能把 DAO 泄漏到主动调度 pipeline。 - 已确认:主动调度自有表使用
backend/active_scheduler自己的 repo / DAO。 - 已确认:正式写入 schedule / task_class 必须走现有领域 service 或明确的 apply port,不能在主动调度里绕过既有写入链路。
- 已确认:notification provider 不归 active_scheduler 管;主动调度只发布
notification.feishu.requested。
10.3 执行计划:迁移协作边界与装配方案
本模块负责把主动调度、notification、API、worker、正式应用链路的代码边界和启动边界固定下来。第一版仍在 backend 单体内实现,但目录、事件契约、port / adapter 和启动装配必须按未来独立服务来组织,避免 MVP 写成新的大单体。
10.3.1 目录总览
建议目录:
backend/
active_scheduler/
trigger/
context/
observe/
candidate/
selection/
preview/
apply/
convert/
job/
ports/
adapters/
repo/
model/
timegrid/
scheduleutil/
notification/
service/
repo/
model/
providers/
mock/
feishu/
retry/
shared/
events/
active_schedule.go
notification.go
schedule_apply.go
service/
events/
active_schedule_triggered.go
notification_feishu_requested.go
schedule_apply_result.go
api/
active_schedule.go
目录职责:
backend/active_scheduler:主动调度业务闭环,拥有 job / trigger / preview 自有表。backend/notification:通知投递业务,拥有notification_records。backend/shared/events:跨模块事件契约,只放 DTO / event type / version / validate。backend/service/events:当前单体 worker 的 outbox handler 注册和消费实现。backend/api/active_schedule.go:HTTP 入站,负责鉴权、绑定请求、调用 active_scheduler service。
禁止事项:
- 不把主动调度放进
backend/service/active_scheduler。 - 不把 notification provider 放进 active_scheduler。
- 不在
shared/events放 DAO、service、provider、LLM prompt。 - 不让 active_scheduler 主链路直接 import 其它领域 DAO。
10.3.2 active_scheduler 内部分层
推荐主链路:
trigger
-> context
-> observe
-> candidate
-> selection
-> preview
-> notification event
各层职责:
trigger:统一 dry-run / API trigger / worker due job / unfinished feedback 入口,处理去重和 trigger 状态。context:构造ActiveScheduleContext,读取事实快照。observe:生成 metrics / issues / decision。candidate:生成并校验候选。selection:调用 LLM 做候选选择和解释,失败时受限重试,再 fallback。preview:写active_schedule_previews,提供详情查询、confirm 状态回写。apply:确认后同步调用正式应用链路。job:扫描active_schedule_jobs到期任务并发布 trigger。ports:定义TaskReader / ScheduleReader / MemoryContextReader / TaskClassReader / ScheduleApplyPort / NotificationPublisher。adapters:把 ports 接到当前单体里的 service / DAO / memory / outbox。
10.3.3 notification 内部分层
推荐主链路:
notification.feishu.requested
-> service
-> record repo
-> provider
-> retry scanner
职责:
service:处理 dedupe、文案 fallback、provider 调用、状态流转。repo:管理notification_records。providers/mock:本地测试,不发真实飞书。providers/feishu:飞书 webhook / app 调用。retry:扫描 failed 记录,按退避策略重试。
notification 不读取 active_scheduler 内部 model,只消费 shared/events.NotificationFeishuRequested 和必要的 preview 查询接口。
10.3.4 依赖注入关系
cmd/start.go 的 buildRuntime 继续作为单体装配入口。
建议新增 runtime 字段:
activeSchedulerService
activeSchedulerJobRunner
notificationService
notificationRetryRunner
activeScheduleHandler
notificationProvider
装配顺序:
1. 初始化 config / db / redis / aiHub / rag / memory。
2. 初始化 DAO / RepoManager / outboxRepo / eventBus。
3. 初始化现有 user / task / schedule / taskClass / agent service。
4. 初始化 active_scheduler repo。
5. 初始化 active_scheduler adapters:
- TaskReader -> task service / task DAO adapter
- ScheduleReader -> schedule service / schedule DAO adapter
- MemoryContextReader -> memory.Retrieve + 公共渲染 helper
- TaskClassReader -> taskClass service / DAO adapter
- ScheduleApplyPort -> schedule / taskClass apply adapter
- NotificationPublisher -> outbox event publisher
6. 初始化 active_scheduler service / job runner。
7. 初始化 notification repo / provider / service / retry runner。
8. 初始化 API handlers。
依赖方向:
api -> active_scheduler service
worker handler -> active_scheduler service
active_scheduler -> ports
ports adapter -> existing service / DAO / memory / outbox
notification handler -> notification service
notification service -> provider
不允许:
notification -> active_scheduler internal candidate model
active_scheduler observe/candidate -> dao.ScheduleDAO
api handler -> dao.ActiveSchedulePreviewDAO
shared/events -> active_scheduler repo
10.3.5 API 接入装配点
新增 handler:
api.NewActiveScheduleHandler(activeSchedulerService)
ApiHandlers 增加:
ActiveScheduleHandler *ActiveScheduleHandler
路由:
POST /active-schedule/dry-run
POST /active-schedule/trigger
GET /active-schedule/previews/{preview_id}
POST /active-schedule/previews/{preview_id}/confirm
POST /active-schedule/previews/{preview_id}/ignore
API 模式职责:
- 可以调用 dry-run。
- 可以写 trigger 和 outbox。
- 可以查询 preview。
- 可以同步 confirm apply。
- 不启动 due job scanner。
- 不消费 outbox。
- 不启动 notification retry scanner。
10.3.6 Worker 接入装配点
RegisterCoreOutboxHandlers 增加:
RegisterActiveScheduleTriggeredHandler(...)
RegisterFeishuNotificationRequestedHandler(...)
worker 模式启动:
1. eventBus.Start(ctx)
2. memoryModule.StartWorker(ctx)
3. activeSchedulerJobRunner.Start(ctx)
4. notificationRetryRunner.Start(ctx)
worker handler 职责:
active_schedule.triggered:- 解析 shared event。
- 幂等检查 trigger。
- 调用 active_scheduler pipeline。
- 写 preview。
- 发布 notification event。
notification.feishu.requested:- 写 / 查 notification record。
- 调 provider。
- 记录 sent / failed / dead。
注意:
- worker 不注册 Gin 路由。
- worker 不处理用户 confirm HTTP 请求。
- confirm 是 API 强交互动作,MVP 同步执行。
10.3.7 all 模式接入
all 模式仍是迁移期兼容入口:
StartAll:
buildRuntime
startWorkers
startHTTP
要求:
- 行为等于 API + worker 同进程。
- 本地开发可优先使用 all 跑全链路。
- 生产逐步切到 api / worker 分进程。
- 不能在 all 模式写专属业务逻辑。
10.3.8 配置项
建议新增:
activeScheduler:
enabled: true
jobScanInterval: 30s
jobScanBatch: 100
triggerDedupeWindow: 30m
previewTTL: 1h
llmSelectionRetry: 1
dryRunAllowMockNow: true
notification:
enabled: true
provider: mock
baseURL: "https://your-web-domain.example.com"
dedupeWindow: 30m
maxRetry: 5
retryBaseDelay: 30s
retryMaxDelay: 30m
feishu:
enabled: false
webhookURL: ""
appID: ""
appSecret: ""
配置规则:
activeScheduler.enabled=false时不启动 job scanner,不消费主动调度事件;API 可返回功能关闭。notification.enabled=false时不调用 provider,可写 skipped record。provider=mock是本地默认。previewTTL与 7.3 保持一致,默认 1 小时。llmSelectionRetry默认 1,对齐 6.3 的受限重试。
10.3.9 数据所有权
active_scheduler 拥有:
active_schedule_jobs
active_schedule_triggers
active_schedule_previews
notification 拥有:
notification_records
schedule 域拥有:
schedule_events
schedules
task_items.embedded_time
task 域拥有:
tasks
规则:
- active_scheduler 可以写自己的表。
- active_scheduler 读取 task / schedule / task_class 事实必须走 port。
- active_scheduler 正式写 schedule 必须走 apply port。
- notification 只写 notification_records,不写 preview / schedule。
- preview 表可以记录
applied_event_ids,但不拥有这些 event。
10.3.10 未来迁出 active-scheduler 的文件边界
未来可整体迁出的目录:
backend/active_scheduler
backend/shared/events/active_schedule.go
backend/shared/events/schedule_apply.go
迁出时需要替换的 adapter:
TaskReader local adapter -> task service RPC / HTTP adapter
ScheduleReader local adapter -> schedule service RPC / read model adapter
TaskClassReader local adapter -> task-class service RPC adapter
MemoryContextReader adapter -> memory service RPC adapter
ScheduleApplyPort local adapter -> schedule apply RPC / event adapter
NotificationPublisher adapter -> Kafka producer / outbox adapter
不应迁出的内容:
backend/api/active_schedule.go
backend/service/events/active_schedule_triggered.go
backend/notification
backend/service/task-class.go
backend/service/schedule.go
说明:
- API handler 属于当前 backend API 入口,未来可改成调用 active-scheduler 服务。
- outbox handler 是当前单体 worker 的接线层,未来独立服务会自己消费事件。
- notification 是独立服务边界,不随 active-scheduler 迁出。
- schedule / task / task-class 领域 service 不随 active-scheduler 迁出。
10.3.11 未来迁出 notification 的文件边界
未来可整体迁出的目录:
backend/notification
backend/shared/events/notification.go
当前单体内保留 / 替换:
backend/service/events/notification_feishu_requested.go
迁出步骤:
- 独立 notification 服务消费
notification.feishu.requested。 - backend worker 停止注册
RegisterFeishuNotificationRequestedHandler。 - active_scheduler 继续发布同一个事件。
notification_records按数据所有权迁出,或迁移期继续同库。
10.3.12 并行迁移策略
遵循并行迁移:
1. 新目录先落地。
2. 旧 service / DAO 保持不动。
3. adapter 调现有能力。
4. 跑通 API dry-run / trigger / worker / preview / confirm / notification。
5. 协议稳定后再切 module / 服务边界。
6. 最后清理旧兼容代码。
本轮不做:
- 不拆独立 Go module。
- 不新增独立部署配置。
- 不把 schedule / task 远程化。
- 不重命名大范围旧目录。
- 不删除现有 Agent 排程预览能力。
10.3.13 验收 checklist
| 动作 | 预期 |
|---|---|
api 模式启动 |
注册主动调度 API,不启动 worker / job scanner / notification retry |
worker 模式启动 |
不占用 HTTP 端口,注册主动调度和 notification outbox handler |
all 模式启动 |
API + worker 同进程跑通全链路 |
| API dry-run | 不写 trigger / preview / notification |
| API trigger | 写 trigger 并发布 active_schedule.triggered |
| worker 消费 trigger | 生成 preview 并发布 notification.feishu.requested |
| notification handler 消费事件 | 写 notification_records 并调用 mock / feishu provider |
| confirm API | 同步 apply,并回写 preview apply 状态 |
| 关闭 notification.enabled | 不调用 provider,但链路可观测 |
| 关闭 activeScheduler.enabled | 不启动主动调度后台能力,API 返回功能关闭或明确错误 |
10.3.14 风险控制
- 若 adapter 需要直接调 DAO,必须只出现在
backend/active_scheduler/adapters,并返回主动调度自己的 facts DTO。 - 若发现同一公共能力第三次复制,优先抽公共 helper。
- 若要修改
schedule_events / schedules / task_items结构,必须配合迁移 SQL 和兼容旧数据。 - 若 notification provider 未配置,默认 mock,不阻断主动调度 preview。
- 若 outbox 未启用,正式 trigger 应返回明确错误或降级为同步 dry-run,不假装已通知。
- 新增 Eino / LLM 能力前必须按项目规则先查官方文档;本节只定义边界,不直接编码。
11. 实施顺序
本章作为开工时的短施工单,详细阶段计划见 0.1。
- 先做迁移 SQL、model、repo、shared events,保证后续模块有稳定契约。
- 再做 active_scheduler dry-run:context / observe / candidate,不写 preview、不发通知。
- 再做 preview 查询与写入,跑通正式 trigger 后生成待确认预览。
- 再做 confirm apply,同步重校验并事务写正式日程。
- 再做 notification mock / webhook 和 retry。
- 最后接 due job scanner、worker handler、端到端验收。
- 第一轮不打开压缩融合;主链路稳定后再单独评估该候选。
12. 本轮决策记录
本章保留本轮已经拍板的实施结论,作为编码时遇到细节分歧的裁决依据。
12.1 触发 job 机制
task创建或更新时,若存在urgency_threshold_at,则 upsert 一条对应的主动调度 job。- job 的触发时间统一取
urgency_threshold_at;主动调度不再自行维护deadline_at - X之类的额外阈值。 task完成后,不物理删除 job,而是将仍未执行的 job 标记为canceled,方便后续排查为什么没有触发。task更新deadline_at或urgency_threshold_at时,直接覆盖当前有效 job,并刷新updated_at。- schedule 动态任务默认不写定时 job;计划时间过去后按
assumed_completed推进,只有用户明确反馈未完成时才进入主动调度链路。
12.2 最终实施拍板
- 主动调度相关表和状态机按 4.3 / 7.3 / 9.3 / 10.3 执行。
tasks本轮新增estimated_sections,默认 1,MVP 允许 1~4;模型层、普通创建入口和主动调度消费侧都已接上。schedule_events本轮新增task_source_type / makeup_for_event_id / active_preview_id。compress_with_next_dynamic_task第一轮关闭,不生成候选。- 飞书第一轮使用 mock / webhook,不依赖用户 open_id 绑定。
- notification 去重窗口第一轮为 30 分钟。
12.3 API 触发、mock_now 与去重
- API 侧同时提供
dry-run与trigger两类测试入口:dry-run:同步执行主动观测并直接返回诊断和候选;不写预览、不发布飞书通知,主要用于开发调试和验收。trigger:进入正式主动调度链路;写入预览,并发布notification.feishu.requested。
mock_now只允许 API dry-run / 测试 trigger 使用,用于模拟未来或历史时刻;后台 worker 正式定时触发必须使用真实time.Now()。- 使用
mock_now的触发应在 trace / payload 中标记is_mock_time=true,避免排障时把测试触发误认为真实后台触发。 important_urgent_task触发按user_id + trigger_type + target_task_id做 30 分钟去重,避免重复生成预览和重复飞书打扰。unfinished_feedback触发按用户反馈的feedback_id / idempotency_key做请求幂等;不做固定时间窗强去重,避免用户连续反馈未完成时被错误吞掉。
12.4 上下文构造与偏好来源
- 滚动 24 小时窗口需要映射到现有
week / day_of_week / section坐标,正式应用时仍按现有 schedule 口径同时维护绝对时间与相对时间。 - 第一版候选以 1 节为最小粒度,任务预计长度限定为 1~4 节。
- 后续在 task 创建阶段增加预计节数字段时,可由 AI 根据任务复杂度写入该值;主动调度只消费该字段,不在调度阶段重新发明复杂度判断。
- 偏好来源按目标类型分流:
- task 池任务:使用 memory 注入的用户偏好。
- task_item:使用所属 task_class 的硬性偏好和约束。
用户反馈在本文档中指显式调度触发信号,不是普通聊天上下文。第一版重点支持unfinished_feedback,即用户明确反馈某个已排动态任务未完成。- 调度触发信号持久化为后端链路状态,不直接展示给前端。建议使用类似
active_schedule_triggers的结构承载trigger_type / target_type / target_id / idempotency_key / payload_json / status。
12.5 task 池任务进入 schedule 的 schema 分叉
已确认采用方案 A:
- 在
schedule_events上新增任务来源列:task_source_type。 schedule_events.type继续表示日程展示与占用类型,保持现有course / task语义。- 当
type = task时,task_source_type表示任务来源:task_item:rel_id指向task_items.id。task_pool:rel_id指向tasks.id。
- 原有动态任务块继续使用
type = task, task_source_type = task_item。 - 四象限任务进入日程后使用
type = task, task_source_type = task_pool,不创建孤儿task_item。 - 不扩展
schedule_events.type为quadrant_task,避免把任务来源语义混入日程块展示类型,也避免影响现有按event.Type == "task"判断的前端、冲突、撤销和预览逻辑。
实施要求:迁移 SQL 需要回填历史 type=task 数据为 task_source_type=task_item,新写入的 task_pool 任务必须显式写 task_source_type=task_pool。
12.6 主动观测链路形态
- 主动调度主链路走固定 graph / service pipeline,不进入 ReAct 工具循环。
- graph 建议形态:
ActiveScheduleTrigger -> BuildContext -> Observe -> GenerateCandidates -> LLMSelectAndExplain -> WritePreview -> Notify BuildContext / Observe / GenerateCandidates使用确定性后端逻辑,负责读取事实、生成诊断、校验候选合法性。LLMSelectAndExplain不调用工具,只直接消费后端给出的结构化结果,负责在候选中选择、生成用户可读解释,或选择 ask_user / close / notify_only。- 第一版不提供 ReAct 工具壳;后续如果用户在聊天中主动要求“帮我看看接下来 24 小时安排”,可以再加一个人工触发入口复用同一套 service。
- API dry-run、API trigger、worker 后台触发都调用同一套主动调度 graph / service,避免出现多套观测逻辑。
12.7 未完成补救的局部重排策略
- 未完成补救里的局部重排不是整周 / 整任务类重排,而是只处理受影响的部分
task_item。 - 局部重排输入:
- 起点:当前时刻对应的相对时间坐标。
- 终点:目标任务所属
task_class.end_date。 - 任务集:未完成任务及其被挤压的后继 item,而不是整个 task_class 的全部 item。
- 粗排约束调整:
- 原有周几偏好、时段偏好在正式粗排里偏硬约束。
- 局部补救中改成软偏好:优先落在偏好范围内。
- 如果偏好范围内排不下,允许打破偏好,把剩余任务继续追加到可用时间里。
- 排序语义:
- 补救过程中可以为了找槽位临时调整候选顺序。
- 输出结果需要恢复这些受影响任务的原有顺序语义,避免把后继关系打乱。
- 工程实现:
- 不直接修改现有全量粗排主函数,避免影响现有智能排程行为。
- 新增一条“局部重排 / 偏好软化粗排”实现。
- 时间格构建、空位扫描、冲突判断、节次候选等公共能力优先抽公共层复用;若短期无法完全抽出,需要在实现注释中说明原因,避免长期复制第三份粗排逻辑。
12.8 压缩融合兜底候选
- 压缩融合只作为局部重排和延后结束都不可用时的后续兜底候选。
- 第一轮实现先关闭,不生成
compress_with_next_dynamic_task。 - 后续打开时固定选择“下一个动态任务”作为融合对象,不做跨多个后继任务的复杂搜索。
- 后续打开时默认比例为 50% / 50%:
- 未完成任务压缩到融合块的一半时间。
- 下一个动态任务压缩到融合块的一半时间。
- 压缩融合必须写清风险说明:两个任务都会被压缩,需要用户接受 rush 模式。
- 压缩融合只生成预览,不允许后台自动执行。
12.9 主动调度裁决模式
- 主动调度参考
analyze_health的裁决模式,但不复用其节奏指标。 - 后端固定执行:
观测事实 -> 生成 issues -> 收集 missing_info -> 尝试生成合法 candidates -> 构造 decision decision.action第一版包含:close:没有值得处理的问题,或问题已被现有日程覆盖。ask_user:缺少关键事实,或需要用户放宽边界才能继续。notify_only:有风险但无合法调整候选,也没有一个明确问题能继续推进。select_candidate:存在 1~3 个后端校验过的合法候选。
- 基础裁决规则:
- 没有 issue ->
close。 - 有 issue,但缺关键事实 ->
ask_user。 - 有 issue,且有合法 candidates ->
select_candidate。 - 有 issue,但没有合法 candidates:
- 若能通过一个明确问题继续推进 ->
ask_user。 - 否则 ->
notify_only。
- 若能通过一个明确问题继续推进 ->
- 没有 issue ->
- LLM 职责边界:
- 不判断候选是否合法。
- 不自由构造新候选。
select_candidate时只在候选里选择最合适的一项,并生成用户可读解释。ask_user / notify_only / close时只负责把后端裁决理由说清楚。
12.10 主动调度预览持久化边界
- 主动调度预览新增独立持久化结构,建议命名为
active_schedule_previews。 - 不复用
agent_schedule_states作为主动调度预览主存储,原因:agent_schedule_states强绑定conversation_id,更适合会话内智能排程快照。- 主动调度来自后台 worker,conversation 入口由
active_schedule_sessions在通知前绑定,不塞进 preview 主表。 - 主动调度预览需要绑定
trigger_id / candidate_id / expires_at / apply_status / notification_status,语义与会话快照不同。
- 展示协议可以复用:
- 抽通用
SchedulePreviewChangeItem/ before-after schema。 - 现有会话排程预览后续也应补齐改前 / 改后能力。
- 主动调度预览复用同一套 change schema,但独立存储和流转状态。
- 抽通用
- 这一路径更符合后续微服务拆分:
active-scheduler负责生成active_schedule_previews。- API 负责查询预览与接收确认。
- schedule 域负责正式应用。
12.11 预览快照、确认校验与 apply 结果
- 第一版不保存全量 before 快照,避免主动调度预览表过重,也避免未来误用全量快照覆盖用户后续改动。
- 第一版必须保存:
base_version:生成预览时的日程基准版本,可使用 schedule hash、相关 event 更新时间摘要或等价版本标识。before_summary:只保存受影响范围的改前信息,例如受影响 event、空闲槽位、原 task_item 落位。preview_changes:候选准备做的改动,例如新增 task_pool 日程、创建补做块、移动可安全处理的 task_item;压缩融合字段只保留后续预留。
before_summary + preview_changes的用途:- 给用户展示改前 / 改后。
- 用户确认时校验当前日程是否仍符合预览生成时的基准。
- 后续补撤销能力时,可以作为局部反向操作的基础。
- 第一版 apply 策略:
- 用户确认前不改正式日程,因此不需要回滚。
- 用户确认后,正式应用必须放在事务里执行。
- 如果事务失败,正式日程不落库,只把预览标记为
apply_failed并写入apply_error。
- 第一版不开放 apply 成功后的撤销按钮,不做整版快照覆盖式回滚。
- apply 成功后轻量记录:
apply_status = appliedapplied_atapplied_event_ids- 必要时记录
applied_change_ids
- 后续若要支持撤销,应基于后端实际应用成功的 change 做局部反向操作,不能用 apply 前全量快照覆盖整张日程表,避免误删用户后续手动修改。
12.12 用户确认入口与聊天增强预留
- MVP 不再把主动调度做成独立详情页主入口,而是直接进入现有助手会话页,复用
AssistantPanel.vue的历史、卡片和确认体验。 - 飞书通知在发送前由后端预创建或绑定
conversation_id,最终跳转链接使用现有路由:/assistant/{conversation_id} - 会话页表现尽量不变,后端在 timeline 中注入主动调度消息和卡片:
- 顶部仍然是助手解释文案。
- 中间仍然复用日程前后对比卡片。
- 展示触发原因、建议理由、风险和不调整后果。
- 支持用户拖动调整 after 方案。
- 支持确认应用、忽略 / 拒绝。
- 拖动后的确认请求仍然必须携带
edited_changes,后端重新校验,不信任前端坐标。 - 确认 API 仍然走主动调度自己的确认语义:
请求包含
POST /active-schedule/previews/:preview_id/confirmcandidate_id / action / edited_changes / idempotency_key。 - 前端只需要一个很小的分支:当 timeline item 是主动调度业务卡片时,按钮动作走主动调度 confirm / discuss;其它消息仍走正常聊天链路。
- 主动调度和普通聊天共用同一个
conversation_id历史,但路由管辖权仍由active_schedule_sessions控制,waiting_user_reply / rerunning未释放前不进入普通 newAgent 自由聊天。 - 聊天增强必须复用
active_schedule_previews / preview_changes / confirm API,不能另起一套确认和应用协议,也不能为了主动调度再建一套独立页面。
12.13 预览过期策略
- MVP 主动调度预览有效期为 1 小时。
active_schedule_previews需要保存expires_at = generated_at + 1h。- 超过
expires_at后:- 预览仍可查看历史说明。
- 不允许确认应用。
- 前端提示用户重新生成建议。
- 确认 API 必须校验过期状态,避免用户对旧日程基准执行过期候选。
12.14 正式应用同步策略与幂等
schedule.apply.requested第一版不进入 outbox 异步消费,确认 API 内同步调用正式应用 service。- 同步 apply 的职责包括:
- 校验 preview 存在、属于当前用户且未过期。
- 校验 preview 尚未
applied / rejected / expired。 - 校验
candidate_id属于当前 preview。 - 校验
edited_changes没有越权改目标、没有越过候选允许范围、没有产生日程冲突。 - 在事务内写入正式日程。
- 成功或失败后回写 preview 的 apply 状态。
- 第一版不新增独立
active_schedule_apply_requests表;apply 尝试状态先落在active_schedule_previews的 apply 字段中。 - 仍然生成独立
apply_id,用于标识一次用户确认应用尝试。 - 确认请求必须携带
idempotency_key,后端建议按preview_id + idempotency_key做幂等约束。 preview_id + candidate_id只定位“用户基于哪一个候选确认”,不代表最终应用内容;若用户拖动 after 方案,最终落库内容以edited_changes为准。- 同一个 preview MVP 只允许成功 apply 一次;apply 成功后再次确认直接返回已应用结果或业务错误,避免重复写入正式日程。
- 后续若 apply 变重或需要跨服务恢复,再迁移为
active_schedule_apply_requests + schedule.apply.requested异步消费;迁移时复用当前apply_id / idempotency_key / apply_status语义。
12.15 飞书通知最小实现
- 飞书通知第一版不是纯固定模板:
- 主链路已调用 LLM 时,顺手生成一段面向用户的调整摘要。
- 摘要应短、明确、可行动,避免制造焦虑。
- 固定模板只作为 fallback,用于 LLM 超时、失败、返回空内容或内容校验不过时。
- 飞书通知必须包含跳转链接:
每个
/assistant/{conversation_id}conversation_id对应一段已预创建的助手会话,用户从飞书点击后直接进入同一会话页查看并确认。 - 通知幂等键按
user_id + trigger_type + time_window聚合,而不是按preview_id。 - MVP 的去重含义是:同一用户、同一触发类型、同一时间窗口内只发一条飞书,避免主动调度在短时间内重复打扰用户。
- 飞书 provider 第一版可以放在 backend worker 内,但必须同步落
notification_records表。 notification_records用于:- 记录待发送、发送中、成功、失败、死亡状态。
- 保存 provider 请求摘要、响应摘要、失败原因和重试次数。
- 支撑后台重试和人工排障。
- 串联
trigger_id / preview_id / notification_id,回答“为什么发了这条飞书”。
notification.feishu.requested事件只表达“需要通知用户回来确认”,不承载飞书内确认、日程应用或聊天回复能力。
12.16 启动边界拆分状态
api / worker / all启动边界第一阶段已经完成,不再作为主动调度 MVP 的前置阻塞项。- 当前启动边界语义:
api:只启动 Gin HTTP 与同步 service / DAO 依赖,不启动后台 worker。worker:只启动 outbox relay、Kafka consumer、事件 handler、memory worker,不注册 Gin 路由。all:保持迁移期兼容模式,同时启动 HTTP 与 worker,适合本地开发和旧启动方式兜底。
- 主动调度 MVP 可以直接接入现有 worker / 事件链路:
- 后台触发、outbox 消费、飞书通知投递放在 worker。
- dry-run、trigger 测试、预览查询、确认 apply 放在 API。
- all 模式继续用于本地一键联调。
- 后续还未完成的是服务边界和模块边界拆分,不是启动生命周期拆分:
- active-scheduler 尚未独立 Go module。
- notification 尚未独立 Go module。
- DAO / service 依赖边界已按 port / adapter 策略拍板,后续执行计划需细化具体端口。
12.17 主动调度代码目录与迁移策略
- 主动调度第一版采用“准独立模块”策略。
- 第一版不放在
backend/service/active_scheduler:- 避免主动调度继续长进旧 service 单体。
- 避免后续迁移时再从既有 service 目录里拆业务边界。
- 避免把主动调度 graph / pipeline / prompt / 状态机和传统同步 service 混在一起。
- 第一版放在:
backend/active_scheduler backend/active_scheduler按未来独立 active-scheduler 服务组织代码:pipeline / graph:固定主动调度链路。context:ActiveScheduleContext 构造。observe:确定性观测和 issue 生成。candidate:候选生成与合法性校验。preview:预览构造与写入。apply:候选到正式应用请求的转换与确认入口协作。notification:发布通知请求,不直接沉淀 provider 细节。
- MVP 暂不拆独立 Go module / 独立进程:
- 主动调度仍需读取 task、schedule、memory 等现有数据。
- 确认 apply 已拍板为 API 内同步事务写库,过早独立进程会放大事务边界复杂度。
- LLM、outbox、worker 注册和配置初始化当前仍在
backend内,先复用现有装配能更快验证主链路。
- 后续迁出条件:
- 事件契约稳定。
active_schedule_*表结构和状态机稳定。- preview / confirm / apply 协议稳定。
- notification 与 schedule apply 的边界清楚。
- 迁出时优先采用并行迁移:
- 保留
backend/active_scheduler旧模块。 - 新建独立 active-scheduler Go module。
- 先迁移事件契约和只读链路。
- 再迁移 worker handler。
- 验证后切流,最后删除旧实现。
- 保留
12.18 事件契约目录策略
- 事件契约第一版提前放入:
backend/shared/events - 该目录用于承载异步消息世界里的“IDL”:
event_type常量。event_version。- payload DTO。
- 基础 validate / normalize。
- 幂等键、消息键、聚合 ID 等协议字段的构造约定。
- 该目录禁止承载业务实现:
- 不放 DAO。
- 不放 service。
- 不放 worker handler。
- 不放 notification provider。
- 不放 LLM prompt。
- 不放复杂业务判断。
- 主动调度、notification、worker handler、API 都依赖
backend/shared/events的事件契约,而不是互相 import 业务模块。 - 这样可以避免:
- notification 为了消费通知事件反向依赖
backend/active_scheduler。 - worker handler 为了注册事件依赖具体业务内部 model。
- 后续 active-scheduler 独立服务时需要大规模重写事件 DTO。
- notification 为了消费通知事件反向依赖
- 后续迁出微服务时,
backend/shared/events可以按并行迁移策略迁出为独立 contracts module,例如:或独立的 event contracts Go module。pkg/events - 事件 payload 不直接复用数据库 model,也不直接复用内部 service request:
- 数据库 model 容易夹带 GORM tag、关联关系和内部字段。
- service request 往往表达同步调用语义,不等于异步业务事实。
- 事件 payload 应表达“发生了什么 / 请求了什么异步动作”,并带明确版本。
12.19 主动调度依赖边界
- 主动调度主链路不直接散落依赖其它领域 DAO。
- 第一版采用 port / adapter 方式:
backend/active_scheduler内定义TaskReader / ScheduleReader / MemoryContextReader / ApplyService等端口。- 主动调度 pipeline 只依赖这些端口,不直接 import
dao.TaskDAO / dao.ScheduleDAO / dao.TaskClassDAO等其它领域 DAO。 - MVP adapter 可以复用现有 service。
- 如果现有 service 缺少适合后台调度的读模型,允许 adapter 内部调用 DAO 组装事实快照,但 DAO 调用必须封装在 adapter 内。
- 主动调度自有表由主动调度自己管理:
这些表的数据所有权属于 active-scheduler,后续迁出独立服务时随模块迁移。
active_schedule_jobs active_schedule_triggers active_schedule_previews - 读取其它领域事实时使用 reader port:
- task 池任务读取走
TaskReader。 - schedule 时间窗、冲突和空闲槽读取走
ScheduleReader。 - memory / 用户偏好读取走
MemoryContextReader,由 adapter 复用 memory 模块Retrieve和公共渲染 helper。 - task_class 约束读取走对应 reader port 或由 schedule/task_class adapter 组合。
- task 池任务读取走
- 正式写入必须走领域 service 或 apply port:
- task_pool 写入 schedule。
- task_item 补做块落库。
- schedule 冲突校验。
- schedules 原子节次写入。
- task_class item 状态更新。
- 主动调度不能绕过既有 schedule / task_class 写入链路直接改正式业务真值。
- notification provider 不归
backend/active_scheduler管:- 主动调度只发布
notification.feishu.requested。 notification_records、飞书 provider 调用、重试和失败观测属于 notification 模块 / worker handler。
- 主动调度只发布
- 这样后续迁移时可以把 adapter 从本地 DAO / service 实现替换为 RPC、HTTP 或事件投影实现,主动调度 pipeline 不需要整体重写。
13. 共识详述与实现备忘
本节用于保存讨论过程中的关键推理,避免后续上下文压缩或换对话后只剩简短结论。
13.1 为什么后台触发不是全量定时扫描
主动调度的“定时”不是 worker 每隔几分钟全表扫 tasks,而是 task 本身在创建或更新时写入一条未来到期 job。
推荐语义:
- task 创建时,如果有
urgency_threshold_at,写入或更新对应active_schedule_jobs。 - task 更新
deadline_at / urgency_threshold_at时,直接 upsert 覆盖当前有效 job,并刷新updated_at。 - task 完成时,不物理删除 job,而是把未执行 job 标记为
canceled。 - job 到期后,worker 读取 due job,再重新读取 task 真值:
- task 已完成 -> 标记 skipped / canceled,不进入主动调度。
- task 已不满足重要且紧急条件 -> 标记 skipped。
- task 仍未完成且到达触发条件 -> 生成
active_schedule.triggered。
这样做的原因:
- 避免后台全表扫描放大数据库压力。
- 触发时间与四象限懒平移机制一致,统一使用
urgency_threshold_at,不再维护deadline_at - X这类主动调度私有阈值。 canceled比物理删除更利于审计:后续可以解释“为什么这个任务没有触发主动调度”。- upsert 覆盖比“取消旧 job 再新建 job”简单,MVP 足够用。
13.2 schedule 动态任务为什么不写定时 job
schedule 里的动态任务计划时间过去后,第一版默认按 assumed_completed 推进体验,不主动追问、不自动补救。
只有用户明确反馈未完成时,才进入主动调度链路。例如:
刚才那个没做完
这项要延后
今天撑不住了
原因:
- 自动追问会打扰用户,且用户没有反馈时系统无法确认是真没做还是没打卡。
- 产品口径已经确定为“默认完成,用户反馈纠偏”。
- 未完成补救属于用户显式触发,不应由时间流逝自动触发。
13.3 用户反馈触发信号为什么要持久化
用户反馈类触发信号不展示给前端,它是后端链路状态。建议使用 active_schedule_triggers 保存。
它的目的不是做产品卡片,而是:
- 幂等:同一条“没做完”反馈不要重复触发两次。
- 审计:用户问“为什么系统给我发飞书”,可以查到触发原因。
- 排障:worker 失败、跳过、重试都有状态可查。
- 串链路:
trigger -> preview -> notification -> apply能通过trigger_id串起来。
建议字段方向:
id
user_id
trigger_type # important_urgent_task / unfinished_feedback
target_type # task_pool / schedule_event / task_item
target_id
idempotency_key
payload_json
status # pending / processing / preview_generated / skipped / failed
created_at
updated_at
其中 unfinished_feedback 不做固定时间窗强去重,而是依赖 feedback_id / idempotency_key 幂等;这样用户连续反馈“还是没做完”不会被 30 分钟窗口误吞。
13.4 为什么 task_pool 不转成孤儿 task_item
我们讨论过“把四象限任务转成孤儿 task_item”来复用 BatchApplyPlans。最终不采用这个方案。
原因:
- 现有
task_items基本语义是归属于task_classes的任务块。 - 虽然模型里
CategoryID是指针,但 DAO / service 很多地方默认 task_item 有所属 task_class:BatchApplyPlans必须传TaskClassID。ValidateTaskItemIDsBelongToTaskClass用category_id做归属校验。GetTaskClassIDByTaskItemID直接解引用CategoryID。- 预览分类、撤销、约束读取也默认 item 有父级。
- 孤儿 task_item 会带来一串问题:
- 属于哪个任务类?
- 用哪个 task_class 的周几 / 时段偏好?
- 撤销后回到哪里?
- task 完成后怎么同步 task_item?
- 前端任务类列表是否显示这个隐藏 item?
最终方案是保留 task_pool 身份,让 schedule 引用 tasks.id。
13.5 为什么新增 task_source_type,而不是扩展 schedule_events.type
已确认在 schedule_events 上新增 task_source_type。
字段语义:
schedule_events.type # 日程展示 / 占用类型:course / task
schedule_events.task_source_type # 当 type=task 时的业务来源:task_item / task_pool
schedule_events.rel_id # 指向对应来源表的 id
示例:
动态任务块:
type = task
task_source_type = task_item
rel_id = task_items.id
四象限任务:
type = task
task_source_type = task_pool
rel_id = tasks.id
不扩展 type = quadrant_task 的原因:
type现有语义更像“日历上展示/占用的类型”,四象限任务进入日程后仍然是任务块。- 现有代码和前端可能大量判断
event.Type == "task";新增quadrant_task容易漏分支。 - “四象限”是任务来源 / 优先级语义,不是日程块类型。
- 后续如果还有
manual_task / habit_task / external_task,都塞进type会把字段语义撑乱。
历史数据回填策略后续执行计划里再细化:历史 type=task 可默认回填为 task_item,避免破坏旧动态任务块。
13.6 task_pool 任务进入日程的正式写入语义
用户确认 task_pool 候选后,不创建 task_item,直接写正式日程:
schedule_events:
type = task
task_source_type = task_pool
rel_id = tasks.id
name = tasks.title
start_time / end_time = 绝对时间
schedules:
event_id = schedule_events.id
user_id
week
day_of_week
section
这意味着后续读取 schedule 时:
- 如果
type=task, task_source_type=task_item,按旧链路关联task_items。 - 如果
type=task, task_source_type=task_pool,关联tasks。 - 如果
task_source_type为空且type=task,兼容历史数据,默认按task_item处理。
13.7 滚动 24 小时与节次粒度
MVP 按现有课程表坐标工作,滚动 24 小时需要映射到:
week / day_of_week / section
正式应用时仍维护现有 schedule 的绝对时间与相对时间:
schedule_events.start_time / end_time保存绝对时间。schedules.week / day_of_week / section保存相对节次原子格。
第一版任务长度:
- 最小粒度统一为 1 节。
- task_pool 任务预计长度初步限定在 1~4 节。
- 由于当前 task 缺少预计耗时,第一版可以使用默认值或在候选里让用户确认。
- 后续创建 task 时增加预计节数字段,由 AI 根据任务复杂度写入;主动调度只消费该字段,不在调度阶段重新判断复杂度。
13.8 task_pool 与 task_item 的偏好来源不同
偏好不能混用。
task_pool 任务:
- 不属于 task_class。
- 不存在 task_class 的周几 / 时段硬约束。
- 按用户 memory 中注入的软偏好安排。
- 如果 memory 偏好与 24 小时容量冲突,候选里说明“未满足偏好”的代价,而不是称为“打破 task_class 偏好”。
task_item:
- 属于 task_class。
- 优先使用所属 task_class 的硬性偏好和约束。
- 未完成补救场景下,部分 task_class 偏好会在局部重排里从硬约束软化为优先级。
13.9 主动观测为什么不进 ReAct
主动调度主链路走固定 graph / service pipeline,不进入 ReAct 工具循环。
原因:
- 这是后台 worker 触发的链路,不是用户实时开放式问答。
- 它需要稳定、可幂等、可审计、可重试。
- ReAct 适合开放探索;主动调度 MVP 的目标是减少开放性,让后端出选择题。
- LLM 不应该自由查全窗、自由构造写库参数或直接 apply。
固定 graph 形态:
ActiveScheduleTrigger
-> BuildContext
-> Observe
-> GenerateCandidates
-> LLMSelectAndExplain
-> WritePreview
-> Notify
其中:
BuildContext / Observe / GenerateCandidates是确定性后端逻辑。LLMSelectAndExplain不调用工具,只消费结构化观测结果和候选。- API dry-run、API trigger、worker 后台触发都复用同一套 graph / service。
- 后续若聊天里需要“帮我看看接下来 24 小时安排”,可以加人工触发入口,但也只是调用同一套 service,不另写 ReAct 工具循环。
13.10 LLM 在选择题模式里的作用
后端给候选,并不代表 LLM 没有价值,但它的决策权要收窄。后端负责合法性、粗排和默认裁决,LLM 负责解释、有限裁决和补全兜底。
后端擅长:
- 判断时段是否冲突。
- 判断候选是否越过 24 小时窗口。
- 判断容量是否足够。
- 判断正式写入参数是否合法。
- 生成 1~3 个可执行候选。
LLM 擅长:
- 把结构化风险翻译成用户能理解的解释。
- 在候选非常接近、后端粗排已经给出多个合法方案时,做有限的软裁决。
- ask_user 时问得更自然,不让用户觉得被系统打断。
- notify_only 时用提醒语气,而不是制造焦虑。
- 在后端已经判断“信息不足”时,生成更合适的追问措辞。
边界:
- LLM 不判断候选是否合法。
- LLM 不自由构造新候选。
- LLM 不负责主排序,后端粗排结果优先。
- LLM 只在
decision.action=select_candidate时从候选里做有限选择。 close / ask_user / notify_only时,LLM 主要负责表达与追问,不负责改写业务裁决。
一句话:后端保证不出错,LLM 负责更像人。
13.11 后端裁决如何参考 analyze_health
主动调度参考 analyze_health 的裁决模式,而不是复用其节奏指标。
主动调度自己的裁决流程:
观测事实
-> 生成 issues
-> 收集 missing_info
-> 尝试生成合法 candidates
-> 构造 decision
裁决规则:
- 没有 issue ->
close。 - 有 issue,但缺关键事实 ->
ask_user。 - 有 issue,且有合法 candidates -> 先由后端粗排,再由 LLM 在接近候选间做有限选择。
- 有 issue,但没有合法 candidates:
- 如果能通过一个明确问题继续推进 ->
ask_user。 - 如果问用户也不能立刻推进,只是需要提醒 ->
notify_only。
- 如果能通过一个明确问题继续推进 ->
例子:
close:重要且紧急 task 已经在 schedule 里,或任务已完成。ask_user:用户说“刚才那个没做完”,但系统无法定位是哪条 schedule_event;或容量不足,需要问能否延后结束时间。select_candidate:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。若候选之间差异很小,LLM 只负责在解释和偏好上做有限补充,不代替后端决策。notify_only:有风险但没有安全可挪的任务,也没有一个明确问题能继续推进。
13.12 未完成补救的局部重排不是全量粗排
未完成补救里的局部重排是“偏好软化版局部粗排”。
输入:
- 起点:当前时刻对应的相对时间坐标。
- 终点:目标任务所属
task_class.end_date。 - 任务集:未完成任务及被挤压的后继 item。
- 不传整个 task_class 的全部 item。
偏好处理:
- 现有全量粗排里的周几 / 时段偏好偏硬约束。
- 局部补救中改为软偏好。
- 优先排偏好范围内。
- 偏好范围内排不下时,允许打破偏好,把剩余任务继续追加到可用时间里。
顺序处理:
- 搜索候选时可以为了找槽位临时调整。
- 输出需要恢复受影响任务的原有顺序语义,避免打乱后继关系。
工程策略:
- 不直接改现有全量粗排主函数,避免影响当前智能排程行为。
- 新增局部重排实现。
- 时间格、可用槽位、冲突判断、节次候选等能力优先抽公共层。
- 如果短期必须 copy 逻辑,需要在注释里写清楚为什么暂时不能抽公共层,避免长期复制第三份。
13.13 压缩融合为什么是兜底
压缩融合不是理想调度,只是当局部重排和延后结束都不可用时的兜底预览。
MVP 规则:
- 只找下一个动态任务作为融合对象。
- 不跨多个后继任务搜索。
- 默认 50% / 50%。
- 必须向用户说明两个任务都会被压缩。
- 只生成预览,不允许后台自动执行。
产品语义:
- 它通常比直接跳过失败任务更好。
- 但它会牺牲两个任务质量,所以必须用户确认。
- 后续可以用优先级、DDL、预计耗时动态调整比例,但第一版固定。
13.14 为什么主动调度预览不塞进 agent_schedule_states
agent_schedule_states 更像会话内智能排程快照,强绑定 conversation_id,用于粗排、拖拽、微调。
主动调度预览不同:
- 可能没有 conversation。
- 来自后台 worker。
- 绑定
trigger_id。 - 有
candidate_id。 - 有
expires_at。 - 有通知状态和 apply 状态。
- 要做幂等、防重复触达、审计。
因此新增 active_schedule_previews,但抽通用 before/after 展示协议。
这意味着:
- 持久化表不复用。
- 展示 schema 可以复用。
- 现有会话排程预览后续也应该补改前 / 改后能力。
- 未来迁出
active-scheduler时,预览表边界更清晰。
13.15 before_summary、preview_changes 和 applied_event_ids 的意义
MVP 不保存全量 before 快照,也不做成功后的撤销按钮。
必须保存:
base_version
before_summary
preview_changes
原因:
- 用户打开预览时能看到当时那版改前 / 改后,而不是重新查一个已经变化的当前日程。
- 用户确认时能校验:生成预览时空的时段,现在是否仍然空。
- 后续要做撤销时,有局部反向操作基础。
不保存全量 before 的原因:
- 表会很重。
- 后续如果误用全量快照覆盖日程,会抹掉用户 apply 后手动做的其它修改。
- 真正安全的撤销应该按后端实际应用成功的 change 做局部反向操作,而不是整版覆写。
apply 成功后轻量记录:
apply_status
applied_at
applied_event_ids
apply_error
这些当前用于审计和排障,不是为了第一版开放撤销按钮。
13.16 确认入口为什么先做详情页,而不是直接聊天页
聊天页效果最好,但第一版直接把主动调度完全塞进自由聊天也会引入很多复杂度:
- trigger / preview 发出时,session 可能还没有预创建 conversation,需要先由后端补齐。
- 用户拖动卡片后,需要明确是改 conversation 历史,还是改 active preview 的当前态。
- 用户一句“换晚点”到底是继续补信息,还是重新跑 graph,需要 session 状态来裁决。
- 聊天 SSE、卡片状态、确认状态要保持一致,不能让前端自己猜路由归属。
- notification 和 agent channel 容易混边界,必须由后端先定谁在管这段对话。
折中方案:
- 后端先创建或绑定
conversation_id,再把飞书链接发到现有/assistant/{conversation_id}路由。 active_schedule_sessions专门记录这段会话对主动调度流程意味着什么,不替代 conversation 表。- UI 仍然采用助手卡片式:
- 顶部助手解释。
- 中间日程对比卡片。
- 支持拖动 after。
- 支持确认 / 忽略。
- 前端只做一个很小的 timeline 类型分支:主动调度卡片走主动调度按钮,普通消息仍走原来的聊天动作。
- 后端继续复用
active_schedule_previews和确认 API,不依赖完整 Agent Chat 去重新设计卡片协议。
这样第一版仍然稳定,后续如果要进一步开放自由聊天,也只是在 session 释放后接回原来的聊天链路,不会重写整套入口。
13.17 预览 1 小时过期的具体语义
MVP 预览有效期为 1 小时:
expires_at = generated_at + 1h
过期后:
- 可以查看历史说明。
- 不能确认应用。
- 前端提示重新生成建议。
- 确认 API 必须拒绝过期 preview。
原因:主动调度候选依赖当时日程基准,时间越久越可能被用户或其它流程改动。1 小时是 MVP 的安全折中。
13.18 为什么 MVP 确认接口内同步 apply
第一版正式应用不走 outbox 异步消费,而是在确认 API 内同步调用正式应用 service。
原因:
- 用户确认是强交互动作,不是后台自然发生的动作。
- 用户点击确认后,需要尽快知道应用是否成功;同步返回成功 / 失败更符合详情页体验。
- 当前 MVP 的 apply 范围较小,主要是新增 task_pool 日程块、生成未完成补做块和后续少量候选变体,预计重校验与事务写库在可接受延迟内。
- 异步 apply 需要新增 apply request 表、恢复扫描、重复消费幂等、前端轮询或 SSE 状态同步,会把第一版链路明显拉长。
- 当前项目已有 outbox 能力,但主动调度 apply 还没有跨服务边界;提前异步化会让状态机复杂度先于业务复杂度增长。
同步 apply 不等于绕过事件语义。MVP 仍然保留以下状态和事件口径:
用户确认
-> confirm API 生成 apply_id
-> 写入 applying 状态
-> 事务内重校验并调用正式写入 service
-> 成功:apply_status=applied,记录 applied_event_ids
-> 失败:apply_status=failed,记录 apply_error
-> 可按需发布 schedule.apply.succeeded / schedule.apply.failed
第一版暂不发布 schedule.apply.requested 给 outbox 消费;该事件名可作为后续异步化时的协议入口。
后续迁移到异步 apply 的触发条件:
- apply 需要调用多个外部服务,确认接口延迟不可控。
- apply 可能超过普通 HTTP 请求可接受时长。
- 需要后台自动重试和失败恢复。
active-scheduler与 schedule 写入服务拆成独立进程,确认 API 不再适合直接持有完整写入事务。
届时新增:
active_schedule_apply_requests
schedule.apply.requested
apply worker
但 apply_id / idempotency_key / apply_status / applied_event_ids 的语义保持不变,避免推翻 MVP 数据模型。
13.19 为什么 preview_id + candidate_id 不能当应用幂等键
preview_id + candidate_id 只能说明“用户基于哪份预览里的哪个候选确认”,不能说明“这一次最终要应用什么内容”。
典型场景:
preview_id = p1
candidate_id = c1
c1 原建议:把“写实验报告”安排到今天第 7 节。
用户拖动后:改到今天第 8 节再确认。
此时 p1 + c1 没变,但最终 apply 内容已经变化。如果把 preview_id + candidate_id 当幂等键,会混淆候选身份和执行请求。
推荐分层:
preview_id # 哪一份主动调度预览
candidate_id # 基于哪一个候选
edited_changes # 用户最终确认的真实变更
apply_id # 哪一次确认应用尝试
idempotency_key # 防止同一次确认动作重复提交
确认请求建议:
{
"candidate_id": "c1",
"action": "confirm",
"edited_changes": [
{
"change_id": "chg_1",
"type": "add_task_pool_to_schedule",
"task_id": 123,
"week": 8,
"day_of_week": 4,
"section_from": 8,
"section_to": 8
}
],
"idempotency_key": "frontend-generated-uuid"
}
后端处理规则:
idempotency_key由前端为一次确认动作生成;用户双击、请求超时重试、移动端 WebView 重放时必须复用同一个 key。- 后端建议按
preview_id + idempotency_key做唯一约束或等价幂等查询。 - 如果同一个
preview_id + idempotency_key已成功应用,直接返回上一次apply_id / applied_event_ids。 - 如果同一个
preview_id + idempotency_key正在处理中,返回applying或在同步路径内等待本次结果。 - 如果同一个
preview_id + idempotency_key对应的请求体摘要与本次不同,应拒绝请求,避免同一个幂等键被复用到另一套变更。 - 如果
idempotency_key不同,即使candidate_id相同,也必须按新的确认尝试处理,并重新校验 preview 是否仍允许 apply。
active_schedule_previews 第一版可预留以下 apply 字段:
apply_id
apply_status # none / applying / applied / failed / rejected / expired
apply_candidate_id
apply_idempotency_key
apply_request_hash
applied_changes_json
applied_event_ids_json
apply_error
applied_at
MVP 状态流转建议:
none -> applying -> applied
none -> applying -> failed
none -> rejected
none -> expired
第一版建议同一个 preview 只允许成功 apply 一次。failed 后是否允许换一个候选再次确认,先不作为 MVP 主路径;若要支持,应生成新的 apply_id,并明确旧失败记录如何保留,避免审计链路被覆盖。
13.20 飞书通知为什么需要摘要、链接和记录表
飞书第一版只做“提醒用户回系统确认”,不在飞书内应用日程,也不做复杂聊天。但它仍然是用户会感知到的主动打扰,因此要兼顾表达质量、跳转确定性和投递可观测。
通知文案:
- 当前第一版先复用候选 / preview summary,不把 LLM summary 当作通知链路的硬依赖。
- summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定。
- 固定模板必须保留为 fallback,避免 summary 为空、过长、包含不允许内容或后续增强分支失败时,整条通知链路直接断掉。
- 后续如果补接 LLM summary provider,它只能作为增强,不应改变通知是否能够发出这一层级的可靠性。
推荐 fallback 方向:
我为你生成了一份日程调整建议,请回到系统确认是否应用。
链接规则:
/assistant/{conversation_id}
原因:
- 每条主动调度通知在发出前都绑定一个
conversation_id,用户点进来后直接进入现有助手会话页。 - 用户从飞书点进来后,仍然只在系统内确认,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。
- URL 不暴露
candidate_id / apply_id,因为用户进入会话页后仍可查看候选、拖动 after 方案并生成新的确认尝试。 preview_id / trigger_id由active_schedule_sessions在后端解析,前端 URL 不长期承担业务状态拼装。
通知幂等键按:
user_id + trigger_type + time_window
不按 preview_id 的原因:
preview_id每次生成都不同,如果按它去重,短时间内重复触发会重复发飞书。- 主动调度通知的产品目标是“提醒用户回来处理一类调整”,不是把每次后台生成都推一遍。
user_id + trigger_type + time_window更符合“同类打扰聚合”的口径。
MVP 需要注意:
important_urgent_task的触发本身已按user_id + trigger_type + target_task_id做 30 分钟去重;通知层再按user_id + trigger_type + time_window聚合,可以进一步避免多任务同时到线时连续轰炸。unfinished_feedback触发按反馈幂等键防重复提交;通知层仍可按窗口聚合,避免用户连续表达未完成时收到多条相似飞书。- 具体
time_window长度需要在表结构阶段拍板。MVP 可以先与触发去重窗口保持一致,例如 30 分钟;如果未完成反馈希望更即时,也可以单独设更短窗口。 - 如果后续产品要求“同一窗口内多个不同任务都必须分别通知”,再把
target_id纳入幂等键;MVP 当前先以减少打扰为优先。
notification_records 第一版建议字段方向:
id
channel # feishu
user_id
trigger_id
preview_id
dedupe_key # user_id + trigger_type + time_window
target_url # /assistant/{conversation_id}
summary_text
fallback_used
status # pending / sending / sent / failed / dead
attempt_count
next_retry_at
last_error
provider_request_json
provider_response_json
sent_at
created_at
updated_at
状态语义:
pending:已生成记录,等待 provider 投递。sending:当前 worker 正在调用飞书 provider。sent:provider 明确返回成功。failed:本次投递失败,但仍可重试。dead:超过最大重试次数或遇到不可恢复错误,不再自动重试。
重试原则:
- 飞书 provider 属于不可靠外部服务,不能只依赖日志排障。
- provider 调用前先落
notification_records,避免进程崩溃后丢失“本该通知”的事实。 - 重试时必须复用同一条
notification_records,递增attempt_count,更新last_error / next_retry_at。 - 若同一
dedupe_key已存在pending / sending / sent记录,应避免重复创建新通知;如果上一条是failed,可按重试策略推进,而不是新建多条相同飞书。 - 记录表只负责通知投递状态,不负责 apply 状态;apply 状态仍属于
active_schedule_previews。
13.20.1 飞书 Webhook 触发器与多用户配置
本轮真实飞书接入不使用群自定义机器人 msg_type=text/post 协议,而是使用飞书 Webhook 触发器。后端只负责把“SmartFlow 生成了一条日程调整建议”这个业务事实 POST 给用户配置的 webhook;飞书侧如何私聊、群发、分支、追加查询或调用其它流程,全部由用户在飞书工作流中编排。
用户级配置表建议:
user_notification_channels
- id
- user_id
- channel # feishu_webhook
- enabled
- webhook_url # 用户复制的飞书 Webhook 触发器 URL
- auth_type # none / bearer
- bearer_token # 可选;飞书触发器启用 Bearer Token 时使用
- last_test_status # success / failed
- last_test_error
- last_test_at
- created_at
- updated_at
管理接口建议:
GET /api/v1/notification/channels/feishu
PUT /api/v1/notification/channels/feishu
DELETE /api/v1/notification/channels/feishu
POST /api/v1/notification/channels/feishu/test
接口语义:
PUT保存当前用户的 webhook 配置;webhook_url必须是 HTTPS URL,域名第一版限制为www.feishu.cn或feishu.cn。GET返回当前用户配置状态,webhook_url / bearer_token只允许脱敏回显。DELETE关闭并软删除当前用户飞书通知配置。test使用同一套 provider 发送测试 JSON,并把last_test_status / last_test_error / last_test_at写回配置表。- 未配置或未启用时,真实通知不报错阻断主链路,
notification_records.status记为skipped,表示当前用户没有启用飞书触达。
发送给飞书 Webhook 触发器的业务 JSON 固定从简:
{
"event": "smartflow.schedule_adjustment_ready",
"version": "1",
"notification_id": 123,
"user_id": 5,
"preview_id": "asp_xxx",
"conversation_id": "conv_xxx",
"trigger_id": "ast_xxx",
"trigger_type": "important_urgent_task",
"target_type": "task_pool",
"target_id": 81,
"message": {
"title": "SmartFlow 日程调整建议",
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
"action_text": "查看并确认调整",
"action_url": "http://localhost:5173/assistant/conv_xxx"
},
"trace_id": "trace_xxx",
"sent_at": "2026-04-30T17:34:52+08:00"
}
拼装规则:
message.title固定为SmartFlow 日程调整建议。message.summary优先使用 preview 的notification_summary,为空时使用 notification fallback 文案。message.action_text固定为查看并确认调整。message.action_url使用frontend_base_url + target_url;若target_url已经是完整 HTTPS URL,则直接使用。- 其它字段只做飞书流程编排、排障和审计,不要求用户流程全部使用。
飞书侧推荐消息模板:
{{message.title}}
{{message.summary}} {{message.action_text}}
{{message.action_url}}
真实 provider 状态映射:
- HTTP 2xx 且响应体
code=0或响应体为空:视为成功,notification_records.status=sent。 - 网络错误、超时、HTTP 429、HTTP 5xx:视为临时失败,进入
failed并按现有 retry loop 重试。 - URL 非法、未配置、未启用:视为
skipped,不重试。 - HTTP 401 / 403 或飞书明确返回鉴权失败:视为不可恢复失败,进入
dead。
安全约束:
- webhook URL 本身等同密钥,接口和日志必须脱敏,禁止完整回显。
- bearer token 同样禁止完整回显;后续若引入统一密钥加密能力,再把明文存储替换为加密存储。
- 测试接口可以暴露成功 / 失败分类,但不能把完整 webhook 或 token 打到响应和日志里。
13.21 为什么事件契约要提前独立
事件契约可以理解为异步消息世界里的 IDL。Thrift / gRPC 描述同步 RPC 的请求、响应和字段语义;事件契约描述某个业务事实或异步动作的事件名、版本、payload、幂等键和消费语义。
主动调度里会出现多类跨边界事件:
active_schedule.triggered
schedule.preview.generated
notification.feishu.requested
schedule.apply.succeeded
schedule.apply.failed
这些事件会被不同模块使用:
backend/active_scheduler生成 trigger、preview 和 notification request。- worker handler 注册和消费 outbox / Kafka 事件。
- notification 投递模块消费
notification.feishu.requested。 - API 查询 preview、确认 apply 后可能发布 apply 成功 / 失败事件。
如果事件 DTO 放在 backend/active_scheduler 内部,后续容易形成反向依赖:
notification -> active_scheduler
worker handler -> active_scheduler
API -> active_scheduler internal model
这样主动调度就会变成事实上的共享业务包,未来拆独立服务时边界会很难清理。
提前放到 backend/shared/events 的目的:
- 让发布方和消费方只共享协议,不共享实现。
- 让 notification 不需要理解主动调度内部 preview 结构,只消费稳定的通知事件 payload。
- 让 worker handler 不需要 import 主动调度内部包才能注册事件。
- 给后续切到独立 active-scheduler / notification 服务预留 contracts module。
- 让事件版本演进有明确入口,避免直接复用内部 Go struct 导致兼容性失控。
边界约束:
backend/shared/events只放契约,不放业务。- payload DTO 必须是为事件专门设计的结构,不直接复用 GORM model。
- 字段新增优先保持向后兼容;破坏性调整必须提升
event_version。 - 消费者必须按
event_type + event_version解析,不能依赖生产者内部实现。 - 幂等键、消息键、聚合 ID 的构造口径应写在事件契约旁边,避免发布方和消费方各猜一套。
13.22 为什么主动调度依赖边界采用 port / adapter
主动调度如果直接依赖一堆其它领域 DAO,后续拆微服务时边界会变得很模糊。
风险:
- 主动调度会绕过 task、schedule、task_class 现有 service 中的权限校验、冲突判断、时间转换和状态流转。
- 主动调度会知道太多表结构,后续 task / schedule 拆服务时需要大改主动调度主链路。
- 调度决策会和领域数据所有权混在一起,难以判断哪些调用只是读事实,哪些调用在修改业务真值。
- 未来迁移时容易变成“换了目录的单体代码”,不是清晰的 active-scheduler 服务。
但也不能简单要求全部走现有 service:
- 现有 service 很多是面向 HTTP API 入参和前端响应设计的,不一定适合后台主动调度。
- 主动调度需要滚动 24 小时事实快照、局部可用槽、触发上下文等读模型,现有 service 未必已经提供。
- 主动调度自有表不属于 task / schedule / task_class,没必要绕到旧 service。
- 某些现有 service 太粗,可能带缓存、响应结构或前端 DTO,不适合作为内部调度 pipeline 的稳定边界。
因此采用分层策略:
读事实:优先通过领域 service / query port
写正式业务:必须通过领域 service / apply port
写主动调度自有表:使用 active_scheduler 自己的 repo
推荐端口方向:
type TaskReader interface {
GetTaskForActiveSchedule(...)
ListUrgentUnscheduledTasks(...)
}
type ScheduleReader interface {
GetScheduleFacts(...)
HasSlotConflict(...)
}
type MemoryContextReader interface {
LoadScheduleMemoryContext(...)
}
type ScheduleApplyService interface {
ApplyActiveScheduleChanges(...)
}
MVP 里这些端口的 adapter 可以在 backend 内调用现有 service。若现有 service 缺少合适读模型,adapter 内部可以调用 DAO 组装,但主动调度 pipeline 不应该直接依赖 DAO。memory 读取不新造结构化偏好 DAO,先复用 memory 模块 Retrieve,并把渲染逻辑抽成公共 helper 供 newAgent 与主动调度共同使用。
这样未来迁出时替换的是 adapter:
本地 service / DAO adapter
-> RPC / HTTP adapter
-> 事件投影 / read model adapter
主动调度的 BuildContext / Observe / GenerateCandidates / LLMSelectAndExplain / WritePreview / Notify 主链路不需要重写。
一句话:主动调度可以拥有自己的 repo,但不能把别人的 DAO 当自己的内部能力随便用。
14. 验证流程与动作-预期 checklist
14.1 验证目标
主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开现有 /assistant/{conversation_id} 会话页、展示主动调度卡片、提交确认即可;核心验证应覆盖:
- 触发是否正确:task 到达
urgency_threshold_at、用户反馈未完成、API 测试触发都能进入统一链路。 - 去重是否正确:同一触发不会重复生成预览、重复通知或重复 apply。
- 预览是否正确:只写
active_schedule_previews,不提前修改正式日程。 - 通知是否正确:写
notification_records,失败可观测、可重试。 - 确认是否正确:确认后同步重校验并事务写入正式日程,失败不落库。
- 状态是否正确:
job -> trigger -> preview -> notification -> apply能通过 ID 串起来排障。 - 边界是否正确:主动调度不进入 ReAct 工具循环,不绕过 schedule / task_class 正式写入链路。
14.2 验证环境
建议至少准备三种运行方式:
all模式:本地一键联调,验证 API + worker 同进程闭环。api + worker分进程模式:验证启动边界拆分后,API 发布 outbox、worker 消费事件。- provider mock 模式:飞书 provider 使用 mock 或测试 webhook,避免真实通知影响用户。
验证时需要可观察以下数据:
active_schedule_jobs
active_schedule_triggers
active_schedule_previews
notification_records
user_notification_channels
outbox / event bus 消费状态
schedule_events
schedules
tasks
14.3 最小测试数据
建议准备一个固定测试用户和以下数据:
- 至少 1 个
important_urgent_task:is_completed=falseurgency_threshold_at可通过mock_now命中- 当前 24 小时内尚未进入 schedule
- 至少 1 个已完成或不再紧急的 task:
- 用于验证 job 到期后能
skipped / canceled
- 用于验证 job 到期后能
- 至少 1 个已有 schedule 动态任务:
- 用于模拟用户反馈
unfinished_feedback
- 用于模拟用户反馈
- 至少 1 段 24 小时内空闲节次:
- 用于生成
add_task_pool_to_schedule候选
- 用于生成
- 至少 1 段冲突节次:
- 用于验证候选校验和 confirm 重校验失败
- 至少 1 条用户偏好 memory:
- 用于验证 task_pool 候选使用 memory 软偏好
14.4 API dry-run checklist
| 动作 | 预期 |
|---|---|
调用主动调度 dry-run,传入 important_urgent_task 与 mock_now |
同步返回 context / issues / decision / candidates,不写 active_schedule_previews |
| dry-run 命中无问题任务 | 返回 decision.action=close,不生成 candidates |
| dry-run 任务缺少必要事实 | 返回 ask_user 或 notify_only,说明 missing_info |
dry-run 传入非法 mock_now 或非法 target |
返回参数错误,不写 trigger / preview / notification |
| dry-run 连续调用同一输入 | 每次只返回诊断结果,不触发去重状态、不发飞书 |
14.5 trigger 与 worker checklist
| 动作 | 预期 |
|---|---|
task 创建时写入 urgency_threshold_at |
upsert active_schedule_jobs,状态为待触发 |
task 更新 deadline_at / urgency_threshold_at |
覆盖当前有效 job 的触发时间并更新 updated_at |
| task 完成 | 未执行 job 标记为 canceled,不物理删除 |
| worker 扫描到 due job 且 task 仍未完成 / 未进入日程 | 生成 active_schedule.triggered 或等价 trigger 记录 |
| worker 扫描到 due job 但 task 已完成 | job / trigger 标记为 skipped / canceled,不写 preview |
API trigger 使用 mock_now |
写入 trigger,payload 标记 is_mock_time=true |
| 后台真实 worker 触发 | 不允许传入 mock_now,使用真实当前时间 |
同一用户同一 task 30 分钟内重复触发 important_urgent_task |
命中去重,不重复生成 preview 和飞书通知 |
14.6 preview checklist
| 动作 | 预期 |
|---|---|
| 正式 trigger 成功生成候选 | 写入 active_schedule_previews,包含 trigger_id / candidate_id / base_version / before_summary / preview_changes / expires_at |
| preview 生成完成 | active_schedule_triggers.status=preview_generated 或等价状态 |
| preview 生成后查询正式日程 | schedule_events / schedules 未发生变化 |
| preview 查询接口读取详情 | 返回触发原因、解释摘要、before/after、风险、不调整后果、候选信息 |
| preview 超过 1 小时 | 仍可查看历史说明,但确认 API 拒绝 apply |
| 日程在 preview 生成后被用户手动改动 | confirm 时基于 base_version / before_summary 重校验失败,正式日程不落库 |
14.7 notification checklist
| 动作 | 预期 |
|---|---|
| preview 生成成功 | 发布 notification.feishu.requested 或等价 outbox 事件 |
| notification handler 收到事件 | 先写 notification_records,再调用 provider |
| summary 生成成功 | 飞书文案使用候选 / preview summary,包含 /assistant/{conversation_id} |
| summary 为空 / 过长 / 校验失败 | 使用固定 fallback 文案,通知链路不中断 |
| 飞书 provider 返回成功 | notification_records.status=sent,记录 sent_at / provider_response_json |
| 飞书 provider 返回临时失败 | notification_records.status=failed,递增 attempt_count,写 last_error / next_retry_at |
| 重试到达上限或不可恢复错误 | notification_records.status=dead,不再自动重试 |
同一 user_id + trigger_type + time_window 内重复通知 |
命中 dedupe_key,不重复创建多条待发送通知 |
| 用户未配置或禁用飞书 webhook | notification_records.status=skipped,不重试,不影响 preview 查询 |
| 调用飞书 webhook 测试接口 | 写入 / 更新 user_notification_channels.last_test_status / last_test_at,飞书流程收到极简 JSON |
14.8 confirm apply checklist
| 动作 | 预期 |
|---|---|
用户打开 /assistant/{conversation_id} |
能读取主动调度会话历史和 preview 详情;如果已过期,页面显示不可确认 |
| 用户确认原候选 | confirm API 生成 apply_id,写入 applying,同步重校验后事务写正式日程 |
| 用户拖动 after 方案后确认 | 请求携带 edited_changes,后端重新校验坐标和目标,不信任前端 |
| task_pool 候选确认成功 | 写入 schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id) 和对应 schedules 原子节次 |
| task_item 补做块确认成功 | 通过 schedule / task_class apply port 或现有领域 service 写入,不绕过既有正式写入链路 |
| confirm 时发生冲突 | 事务不落库,preview 标记 apply_failed,写入 apply_error |
| confirm 成功 | preview 标记 applied,记录 applied_at / applied_event_ids / applied_changes_json |
| confirm 成功后再次确认同一 preview | 不重复写日程,返回已应用结果或明确业务错误 |
| confirm 使用过期 preview | 拒绝 apply,不写正式日程 |
14.9 幂等与重复提交 checklist
| 动作 | 预期 |
|---|---|
同一 preview_id + idempotency_key 重复提交相同 confirm 请求 |
返回同一个 apply_id 和同一组 apply 结果,不重复写日程 |
同一 preview_id + idempotency_key 提交不同请求体 |
拒绝请求,提示幂等键被复用到不同内容 |
同一 preview_id 使用不同 idempotency_key 再次确认 |
若 preview 已 applied,拒绝重复应用或返回已应用状态 |
| 网络超时后前端重试 | 后端根据 idempotency_key 返回上一轮结果 |
| worker 重复消费同一通知事件 | notification_records.dedupe_key 防止重复飞书 |
14.10 失败注入 checklist
| 动作 | 预期 |
|---|---|
| 构造 LLM 选择超时 | 使用后端 fallback 决策或标记失败,trigger 状态可排障 |
| 构造 summary 为空或校验失败 | 使用固定通知模板,preview 仍可通知 |
| 构造 DB 写 preview 失败 | trigger 标记 failed,不发布 notification |
| 构造 notification provider 失败 | preview 保留,notification record 进入 failed / retry,不影响 preview 查询 |
| 构造 apply 写 schedule 中途失败 | 事务回滚,schedule_events / schedules 不产生半写状态 |
| 构造 outbox 消费重复 | 消费幂等,业务状态不重复推进 |
14.11 自动化测试建议
自动化测试原则:
- 能自动验收的,由实现者自己完成,不把可自动验证的工作转交给用户。
- 能用测试代码验证的,优先写单元测试或集成测试;若按项目规则临时生成
*_test.go,测试后必须删除临时测试文件。 - 能用 API + DB 查询验证的,必须实际调用接口并核对落库结果,不能只说“理论上可行”。
- 能用 mock provider 验证的外部服务链路,必须先用 mock 跑通状态机,再说明真实 provider 还剩哪些人工配置。
- 实在无法自动完成的验收项必须显式列入“需要用户验收”清单,写清动作、预期和阻塞原因。
- 最终报告必须区分:
- 已自动验收通过。
- 已自动验收失败并修复。
- 因环境 / 权限 / 外部服务限制未能验收,需要用户执行。
建议自动化流程:
- 静态与编译验证:
gofmt 相关改动文件 go test ./... 清理项目根目录 .gocache 检查新增 / 改动 Go 文件是否超过 700 行 检查是否遗留临时 *_test.go - 单元测试:
- 时间窗转换:绝对时间到
week / day_of_week / section。 - 候选合法性校验:冲突、越界、预计节数、target 篡改。
- decision 裁决:
close / ask_user / notify_only / select_candidate。 - 幂等键与
apply_request_hash校验。 - notification
dedupe_key生成。
- 时间窗转换:绝对时间到
- API + DB 集成测试:
- dry-run 不落库。
- trigger 写 trigger / preview / notification record。
- confirm 成功写 schedule。
- confirm 冲突失败不落库。
- 过期 preview 拒绝 apply。
- Worker 测试:
- due job 扫描。
- outbox 发布和消费。
- notification retry。
- 外部 provider 测试:
- mock provider 成功:
notification_records.status=sent。 - mock provider 临时失败:
status=failed,写attempt_count / last_error / next_retry_at。 - retry 后成功:同一条 record 变为
sent,不新建重复通知。 - 真实飞书 webhook / open_id 受限时,必须记录为“需要用户验收”,不能用 mock 结果冒充真实 provider 验收。
- mock provider 成功:
- 手工验收:
- 使用
/assistant/{conversation_id}打开会话页。- 拖动 after 方案并确认。
- 查看飞书测试消息跳转。
每阶段交付报告模板:
已自动验收:
- go test ./...:通过 / 失败后已修复
- API 链路:列出请求、关键 ID、响应状态
- DB 核对:列出表名、关键字段和结果
- 幂等 / 失败注入:列出动作和结果
未能自动验收:
- 验收项:
- 阻塞原因:
- 需要用户执行的动作:
- 预期结果:
风险与下一步:
- 尚未覆盖的边界:
- 建议下一阶段优先补的自动化:
14.12 验收通过标准
MVP 验收通过至少需要满足:
important_urgent_task和unfinished_feedback两条主触发均可生成 preview。- dry-run、trigger、worker 三类入口进入同一套主动调度 pipeline。
- preview 生成前后正式日程不被提前修改。
- 飞书通知记录可查,成功 / 失败 / 重试状态可观察。
- confirm 成功后正式日程正确写入,失败时事务不落库。
- 重复触发、重复通知、重复 confirm 都有幂等保护。
- 过期 preview、日程基准变化、前端篡改
edited_changes都能被拒绝。 - 关键状态能通过
trigger_id / preview_id / notification_id / apply_id串起来排障。