后端: 1. 主动调度补齐 `unfinished_feedback` 定位闭环——用户补充信息先在滚动窗口内定位到可校验的日程块,定位失败则继续 ask_user,不再硬猜 target_id 或直接跑 graph。 2. 聊天占管重跑链路加并发保护——`waiting_user_reply -> rerunning` 改为 DB CAS 抢占,重复补充只返回可见等待提示,避免并发生成多份 preview。 3. rerun 结果回写继续收口——新 preview_id 同步回 trigger 审计指针,session 只在拿到新 preview 时更新当前预览,ready_preview 后清空追问状态并释放回普通聊天。 4. 主动调度事件校验放宽 unfinished_feedback 的空 target 场景,允许先触发、后定位,再进入 graph + preview 主链路。
25 KiB
主动调度缺口分阶段实施计划
本文档用于把《第二阶段主动调度 MVP 实现方案.md》《主动调度候选生成器讨论稿.md》和当前代码仓库的实际状态收口到一份可执行的推进计划里。
目标只有一个:把主动调度剩下的缺口按阶段补完,并且每个阶段都能明确验收、明确自动化边界、明确是否已经完成。后续我会在这里持续把 [ ] 改成 [x]。
补充约定:本文档是阶段进度看板,不再重复写设计细节;已经落地的阶段必须及时改成 [x],并保留简短验证记录。实现方案和边界口径以《第二阶段主动调度 MVP 实现方案.md》为准。
0. 当前仓库基线
先把现在已经有的和还缺的分开,避免后面阶段定义漂移。
已经落地的基座
backend/active_scheduler已经形成准独立模块,包含context / observe / candidate / preview / apply / service / job等目录。dry-run -> trigger -> preview -> confirm -> apply主链路已经存在。active_schedule.triggered、notification.feishu.requested、notification_records、用户级飞书 webhook 配置接口已经打通。active_schedule_previews、schedule_events.task_source_type / makeup_for_event_id / active_preview_id、tasks.estimated_sections这些模型层字段已经存在。api / worker / all三种启动边界已经有实测基础。important_urgent_task、unfinished_feedback的主触发链路已经跑过一轮端到端。
近期缺口收口状态
UserAddTaskRequest、转换层、quick task / 随口记创建入口已完整透传estimated_sections。CreatePreview已切到 graph + 受限 selector,不再是固定 top1 /Candidates[0]。active_schedule_sessions已正式进入代码,并接好缓存链路。- 聊天入口已按 session 状态拦截,
waiting_user_reply / rerunning会接管补信息链路。 unfinished_feedback的“定位 -> ask_user -> 重跑 graph”后端闭环已经接上,前端最小适配也已落地。- 聊天页里的主动调度 preview 卡片 / 微调弹窗已完成最小适配。
- 剩余极限验收项已做过实测验证,但还在继续脚本化沉淀。
近期实测记录(2026-05-02)
这轮重新核验后,主动调度最小闭环已经再次跑通,可作为后续阶段 5 负向边界验收的基线。
- 测试账号为
test0424 / 123456,任务为“做马原大作业”,mock_now固定到2026-04-30T01:14:21+08:00的周四窗口。 - 这次链路跑出了
trigger_id=ast_1bb62e3e-f2cf-48a9-8f29-1461b99bff6b、preview_id=asp_e79db789-ba16-4108-a843-cd33c03aa3f6、conversation_id=ce525dc0-101a-50ca-8993-7fc466328de2、notification_records.id=22。 - DB 对账显示
active_schedule_triggers.status=preview_generated、active_schedule_previews.status=ready、active_schedule_sessions.status=ready_preview,而schedule_events对该 preview 的正式写入计数为 0,说明这次只到预览态,没有执行正式 apply。 - 浏览器里周四列同时存在已有课程和待确认任务块;周六窗口只剩一个孤立任务块属于正常现象,不是后端漏课程。
- 这批结果说明:当前收口已经不在“主链路能不能跑通”,而是在“阶段 5 的过期、重复、篡改、冲突、幂等、通知和 outbox 负向边界能不能全部挡住”。
代码锚点
后续实施时优先看这些位置:
backend/model/task.go:Task.EstimatedSections已存在,普通创建请求已接入estimated_sections。backend/conv/task.go:任务创建请求转模型时已透传预计节数。backend/cmd/start.go:quick task 创建依赖已透传预计节数;主动调度 graph runner / LLM selector / session rerun 也已在启动期装配。backend/active_scheduler/preview/service.go:preview 已支持SelectedCandidateID / ExplanationText / NotificationSummary / FallbackUsed。backend/active_scheduler/graph:阶段 1 graph runner 已落地;backend/active_scheduler/selection承载 LLM selector / prompt / DTO。backend/active_scheduler/service/trigger_pipeline.go:trigger workflow 已调用 graph result,再写 preview 和 notification。backend/service/agentsvc/agent_newagent.go、backend/service/agentsvc/agent_active_schedule_session.go、backend/api/agent.go:聊天入口和 graph 执行边界已接 session 管辖。frontend/src/components/dashboard/AssistantPanel.vue:主动调度卡片与确认按钮做最小分支。backend/notification:飞书 webhook provider、notification 状态机和重试逻辑的已有基座。
与 execute 链路的接缝
主动调度不复制 task_item,也不新建一套排程写入链路。
最小接入方式:
- 主动调度 session 只负责识别当前会话是否处于主动调度占管态,并生成待确认 preview。
- preview / 微调 / 确认继续复用现有会话维度的
ScheduleState快照;前端拖拽仍走现有暂存语义,后端只更新同一份状态。 - 用户确认后,后端按 preview / edited changes 做重校验并正式写入
schedule_events / schedules。 - 主动调度释放占管后,后续普通聊天直接回到 newAgent
execute;execute继续在ScheduleState / OriginalScheduleState上调用现有move / swap / place / unplace等工具。
因此,本轮新增的是 session、路由拦截、preview DTO 适配和 graph 裁决;不是新增排程引擎,也不是把待调整任务复制成新任务再排一次。
平滑接回聊天的边界:
- graph 不直接进入 newAgent
execute,也不把主动调度包装成 ReAct 工具。 - graph 结果由后端转换成 conversation timeline:追问写
assistant_text,预览写business_card.card_type=active_schedule_preview。 waiting_user_reply / rerunning期间,chat 入口拦截用户消息并同步推进 graph;ready_preview或终态后,session 释放,普通聊天自然回到 newAgent。
1. 分阶段实施总表
| 阶段 | 状态 | 目标 | 验收点 | 自动化测试 |
|---|---|---|---|---|
| 阶段 0 | [x] | 补 estimated_sections 写入入口 |
创建任务时能稳定写入 1~4 节,主动调度只消费落库值 | 可以,API + DB + go test |
| 阶段 1 | [x] | 补主动调度 Eino graph 和 LLM 解释 / 补全兜底 | 产生候选、有限裁决、输出解释、保留 fallback | 可以,后端单测 + API 验证 |
| 阶段 2 | [x] | 补 active_schedule_sessions、聊天拦截和缓存链路 |
waiting_user_reply / rerunning 拦截生效,ready_preview 释放 |
可以,API + DB + 路由验证 |
| 阶段 3 | [x] | 完成 unfinished_feedback、ask_user 闭环和前端最小适配 |
用户在聊天页补信息后能重跑 graph 并刷新 preview | 后端可自动,前端需浏览器验证 |
| 阶段 4 | [x] | 收口飞书通知与会话链接 | action_url 指向 /assistant/{conversation_id},通知 payload 从简 |
可以,webhook POST + DB 验证 |
| 阶段 5 | [ ] | 跑完第五阶段剩余验收和失败注入脚本 | 冲突、过期、重复确认、重试、dead/skipped 全覆盖 | 可以,基本全自动 |
2. 阶段细则
阶段 0:补 estimated_sections 写入入口
当前状态
model.Task.EstimatedSections 已经有了,主动调度消费侧也已经接上,这一阶段的写入侧已经补完,并完成 API、聊天 quick task 和数据库三方验收,可以收口。
已完成内容
- 给
UserAddTaskRequest增加estimated_sections。 - 在
backend/conv/task.go、backend/cmd/start.go和 quick task 创建入口里把这个值写入model.Task。 - 做统一归一化,缺失或非法时兜底为
1,超过上限收敛到4。 - 让 newAgent / 随口记创建任务时能把 LLM 估计结果带入 DB。
- 让任务查询 DTO 和 quick task 卡片也能回显这个字段,方便验收。
验收点
- 任务创建后,查询结果里能看到最终写入的
estimated_sections。 - 缺失或越界值会被收敛到
1~4。 - 主动调度不再在 graph 内重新猜任务耗时。
- 旧任务和历史数据仍能按默认
1兼容。
验证记录
- 已执行
go test ./...。 - 已清理本次测试生成的
.gocache/.gopath。 - 普通
POST /api/v1/task/create+GET /api/v1/task/get+ MySQL 对账已通过,返回和落库均为estimated_sections=3。 POST /api/v1/agent/chat已验证可创建任务,tasks表中id=145的记录落库为title=交实验报告、priority=1、estimated_sections=1、deadline_at=2026-05-02 20:00:00。- 当前仓库没有保留临时
*_test.go。
自动化测试
- 可以自动跑。
- 建议路径:任务创建 API + DB 断言 +
go test ./...。 - 若当轮实现了输入输出纯函数,临时单测可写完即删,测试后清理
*_test.go。
阶段 1:补主动调度 Eino graph 和 LLM 解释 / 补全兜底
当前状态
现在 graph / selector 已经接上,核心链路是 BuildContext -> Observe -> GenerateCandidates -> SelectAndExplain -> CreatePreview,不再停留在固定 top1 过渡实现。
收口状态
- graph 已从固定 pipeline 升级为可复用 runner。
- LLM 只在受限候选里做有限选择,后端 fallback 仍保留。
- 这一阶段已经从“待做”转为“已完成”,后续只保留后续调优项。
已完成内容
- 把现有 pipeline 整成可扩展 graph,物理位置固定在
backend/active_scheduler/graph,不放进newAgent。 - 把 LLM 放到“只读 / 受限工具视图”里;后端仍是候选合法性、粗排和默认裁决的主责任方,LLM 只做有限选择、解释生成和信息补全兜底。
- 给 LLM 的输入分两层:
- 基础信息:
trigger_type / target / time_window / missing_info / warnings / before-after 摘要 - 候选层:
candidate_id / candidate_type / summary / preview_change / dimensions - 不暴露原始全量事实快照
- 基础信息:
- 增加选择题协议:
{
"action": "select_candidate",
"selected_candidate_id": "xxx",
"reason": "选择原因",
"user_message_summary": "给用户看的简短解释",
"ask_user_question": ""
}
- 支持
select_candidate / ask_user / notify_only / close。 - 保留后端 fallback:LLM 超时、输出非法、候选不存在时回落到后端粗排结果;事实不足时回落到后端
ask_user问题。 - 模型接入只走一次同步 JSON 调用:启动期复用
inits.InitEino()里的aiHub.Pro,再包装成backend/infra/llm.Client,由backend/active_scheduler/selection层调用GenerateJSON;默认不走流式、不走 ReAct、不开放工具。 - 候选维度只保留真正有用的最小集:
capacity_fitrisk_level
confidence不作为 LLM 可见候选维度;事实可信度只用于内部 trace /ask_user判断,低可信事实不生成正式候选。- 明确不把这些东西重新做成第一版主维度:
deadline_fituser_preference_fitdisruptionreversibilityexplainability
验收点
- dry-run 和正式 trigger 都能走到 graph。
- graph 能产出多个合法候选,而不是只剩一个 first-fit。
- LLM 不直接写正式日程参数,也不替代后端粗排;它的主要价值是解释、在接近候选间有限裁决、以及事实不足时生成更自然的追问。
- LLM 能看到基础上下文和候选摘要,但不会拿到原始全量快照;候选维度只公开
capacity_fit / risk_level。 ask_user只有在信息不足时才出现。compress_with_next_dynamic_task仍然默认关闭,不在这阶段打开。- LLM selector 能被 fake selector 替换,方便单测覆盖“选第二个候选、输出非法、fallback 命中”这几类情况。
自动化测试
- 可以自动跑。
- 建议路径:候选过滤单测、选择题解析单测、dry-run API 验证、trigger 端到端验证。
- 如果 Eino graph 需要调包,实现时要先对照官方文档,再落代码。
接入方向
这层 graph 放在 backend/active_scheduler/graph,由 active scheduler 自己持有业务编排;newAgent 不拥有 graph,只在聊天入口被 session 拦截时同步调用 active scheduler service。后续如果拆微服务,把 graph runner 换成 RPC 同步调用即可,不改聊天、worker、API 三个入口的业务口径。
阶段 1 的落地顺序:
- 先落 graph / selection 包,把
BuildContext -> Observe -> GenerateCandidates -> SelectAndExplain这条链串起来。 - 再把
TriggerWorkflowService从固定 pipeline 切到 graph runner,确保 dry-run / trigger 先吃到新裁决结果。 - 然后改
preview.CreatePreviewRequest和preview.Service,让SelectedCandidateID / ExplanationText / NotificationSummary / FallbackUsed真正进 preview。 - 最后在
cmd/start.go做装配,把aiHub.Pro -> llm.Client -> selector -> graph runner串进启动期依赖图。 - 完成后先用 API dry-run 和 trigger 验证,再去看 preview detail 是否正确回显 selected candidate。
阶段 2:补 active_schedule_sessions、聊天拦截和缓存链路
当前状态
active_schedule_sessions、session bridge、聊天入口拦截和缓存回填都已经接上,waiting_user_reply / rerunning 会先由主动调度接管,ready_preview 之后再释放回普通聊天链路。
收口状态
- session 表已落地,
session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json这些核心字段都在代码里。 - 后端 chat 路由已按 session 状态拦截,不会再把占管态消息误进自由聊天。
- Redis + MySQL 的双写回填链路已经打通,session 状态可回源、可回填。
- 这一阶段可以收口,后续只继续补
unfinished_feedback闭环和前端最小适配。
已完成内容
- 新增
active_schedule_sessions。 - 先把字段压到最小够用集:
| 字段 | 含义 |
|---|---|
session_id |
主动调度会话主键 |
user_id |
归属用户 |
conversation_id |
绑定到现有聊天会话 |
trigger_id |
这次主动调度触发来源 |
current_preview_id |
当前正在展示 / 等待处理的 preview |
status |
会话状态 |
state_json |
轻量业务状态,例如 pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason |
created_at |
创建时间 |
updated_at |
更新时间 |
- 状态只保留这几种:
waiting_user_replyrerunningready_previewappliedignoredexpiredfailed
- 聊天入口在后端直接查 session 状态:
waiting_user_reply / rerunning:拦截,先走主动调度补信息链路。ready_preview / applied / ignored / expired / failed:释放给普通聊天。
- session 和 conversation/timeline 分开记账:
- timeline 记用户可见对话。
- session 记主动调度路由管辖权。
- session 要接缓存链路:
- 先查热缓存。
- miss 再回源 DB。
- 状态变化后同步回填。
- 构造用户消息时把 session / preview / pending question 一起缓存好。
ask_userpending 不复用 newAgentPendingInteraction作为状态源,只借鉴交互协议。- outbox 不作为用户回复重跑 graph 的主驱动;主路径必须同步完成,再给 SSE / timeline 更新。
- graph 结果接回现有聊天协议:
ask_user:写assistant_text,session 继续waiting_user_reply。select_candidate / ready_preview:写assistant_text+business_card.card_type=active_schedule_preview,session 进入ready_preview。close / notify_only / failed:写assistant_text,session 进入终态并释放普通聊天。
验收点
- 同一个 conversation 进入聊天页时,能按 session 状态正确拦截或放行。
waiting_user_reply / rerunning状态下,用户消息不会直接滑进普通自由聊天链路。- session 状态变化后,缓存和 DB 一致。
- 关键审计链能串起来:
trigger_id -> preview_id -> conversation_id -> apply_id。 - 用户刷新会话历史时,主动调度追问和 preview 卡片能从 timeline 重建,不依赖 SSE 临时状态。
自动化测试
- 可以自动跑。
- 建议路径:API + DB 断言、路由拦截测试、缓存命中/回源测试。
- 需要你先把对应后端服务按模式起好时,我可以直接接着跑。
阶段 3:补 unfinished_feedback、ask_user 闭环和前端最小适配
当前状态
unfinished_feedback 的后端定位闭环、聊天页最小适配和并发兜底都已经收口,阶段 3 已完成。
已落地内容
- 定位逻辑按这个顺序走:
- LLM 上下文推断
- 当前时间
- 已排日程窗口
- 定位成功后直接出补做 preview,不移动原任务。
- 定位失败时,不硬猜,直接
ask_user。 ask_user的问题由missing_info驱动,缺什么问什么。- 用户在聊天页补全的是当前主动调度缺失事实;偏好类表达不属于
ask_user的缺失项,解锁后直接回到现有 newAgent memory / execute 链路,不在主动调度里新建记忆写入链路。 - 前端只做最小分支:
- 复用
AssistantPanel.vue - 复用现有
ScheduleResultCard - 复用现有
ScheduleFineTuneModal - 只是把 timeline 新类型和主动调度 confirm API 接起来
- 复用
- 后端负责把主动调度 preview DTO 转成前端容易复用的结构,前端不背脏活。
补充链路图
flowchart TD
A[用户在聊天页发送消息\nPOST /api/v1/agent/chat] --> B{查询 active_schedule_sessions}
B -->|未占管| N[进入普通 newAgent]
B -->|waiting_user_reply / rerunning| C[拦截到主动调度分支]
C --> D[写入用户消息到 timeline]
D --> E[LLM JSON 定位节点\n只负责补齐缺失事实]
E --> F{能否定位 schedule_event?}
F -->|否| G[生成 ask_user\nsession=waiting_user_reply]
F -->|是| H[重跑 active scheduler graph]
H --> I[BuildContext -> Observe -> GenerateCandidates -> SelectAndExplain]
I --> J[CreatePreview]
J --> K[session=ready_preview]
K --> N
这个定位节点只做一件事:把用户补充的话术转成后端可校验的 JSON 事实,不负责写正式日程,不负责生成新排程策略,也不负责替代后端候选裁决。
验收点
- 用户补完当前主动调度缺失事实后,能刷新 preview 并解除锁定;解锁后再说“我周末不想学习”这类偏好话术时,直接走现有 newAgent memory / execute 链路。
ask_user流程没走完时,不能直接进入普通聊天链路。- 补做块只新建,不移动原任务。
- 主动调度卡片能在聊天页显示,微调后走主动调度 confirm API。
自动化测试
- 后端部分可以自动跑。
- 前端卡片展示和按钮分支,建议用浏览器实际打开一次做可视确认。
- 如果只是检查 DOM / 路由 / 请求是否发对,能自动;如果要看卡片样式是否真的对齐,还是需要浏览器看一眼。
验证记录
go build ./cmd/all已通过。go test ./...已通过。- 并发补充场景已验证:同一会话两条补充同时到来时,只会有一条抢到 rerun,另一条返回占管提示,不会重复生成 preview。
阶段 4:收口飞书通知与会话链接
当前状态
已完成。用户级 webhook 配置、通知投递、测试接口和会话链接已经统一到 /assistant/{conversation_id},不再把旧的 /schedule-adjust/{preview_id} 当作新入口。
已落地内容
- 通知前由后端预创建或绑定
conversation_id,保证飞书点击后直接进入同一会话。 action_url已统一为/assistant/{conversation_id};通知 payload 保持从简,只保留会话跳转和排障所需字段。- 用户级飞书 webhook 的保存 / 查询 / 删除 / 测试接口已接通,真实投递与测试共用同一套 provider 校验和 JSON 拼装。
notification_records已覆盖sent / failed / dead / skipped和 retry 相关状态。- 用户未配置或禁用 webhook 时,通知记录会落
skipped,不阻塞主链路。
验证记录
PUT /api/v1/notification/channels/feishu已跑通。POST /api/v1/notification/channels/feishu/test已跑通,成功时会回写last_test_status / last_test_at。POST /api/v1/active-schedule/trigger后生成的通知请求 payload 中,message.action_url已指向/assistant/{conversation_id}。- 真实飞书消息样式和外部页面交互仍属于最终验收,不影响后端收口结论。
建议保留的边界
- 这里不再恢复旧的
/schedule-adjust/{preview_id}主入口。 - 业务 JSON 继续从简,不把复杂卡片协议塞进 webhook。
- 后续如果要扩展新的通知渠道,先复用
notification_records状态机和FeishuProvider的抽象边界。
阶段 5:跑完第五阶段剩余验收和失败注入脚本
当前状态
主链路已经有了,而且周四窗口的最小闭环已经再次跑通;现在阶段 5 的重点只剩极限边界、失败注入和脚本化收口。这里不再重跑阶段 3 的整套 ask_user 主链路,只保留 1 条真实 chat 烟测确认入口未回归。
要做什么
- 跑通
api / worker / all三种启动模式。 - 覆盖以下边界:
- 冲突失败
- preview 过期
- 重复 confirm
- trigger 幂等
- notification retry
dead / skipped / failed- outbox 重复消费
- 把失败注入做成脚本化验收,不靠手工猜。
- 再扫一遍哪些地方还是空壳,哪些地方只是文档先行。
验收点
- 所有核心状态机都能串起来排障。
- 同一条 preview / notification / apply 不会被重复落库。
- 过期、冲突、篡改、失败注入都能拒绝。
- 预览过期、重复 confirm、错误 candidate / 跨用户 preview / preview-session 不匹配都能挡住。
- 同一
idempotency_key / dedupe_key不能重复生成有效 trigger / preview / notification。 - notification 的
skipped / failed / dead / sent状态都能在 DB 里对上。 - outbox 重复消费不会重复投递或重复写通知记录。
api / worker / all三种启动边界相关 handler / job 注册不能缺失。- 最终能把这一轮主动调度缺口标成完成。
自动化测试
- 可以自动跑,而且这一阶段基本就是为了自动化收口。
- 绝大多数验收都能用 API + DB + 日志完成。
3. 已拍板、不再反复讨论的口径
这些口径已经在讨论里定过了,后面实施时按这个来,不再重开。
- 主动调度不做独立的
/schedule-adjust/{preview_id}主入口,主链接统一到/assistant/{conversation_id}。 active_schedule_sessions要单独建,不塞进现有 conversation 表,也不只加在active_schedule_previews上。waiting_user_reply / rerunning的聊天拦截在后端做,前端只做最小分支。ask_userpending 不复用 newAgent 的PendingInteraction作为状态源。compress_with_next_dynamic_task第一版继续关闭,只保留 schema / 口径,不生成候选。- 候选维度保持极简,
deadline_fit和user_preference_fit不再作为第一版公开维度。 - 本轮不再预留独立的健康分支,候选公开维度直接按
capacity_fit / risk_level执行;事实可信度只保留为内部 trace /ask_user门控。 unfinished_feedback先定位,再ask_user,定位成功后直接生成补做 preview,不移动原任务。- 用户在聊天页说偏好时,不归主动调度接管;解锁后直接走现有 newAgent memory / execute 链路。
- 只有后台离线自动触达才走飞书;用户已经在会话里时,不需要再先走飞书通知。
ask_user闭环只新增一个 LLM JSON 定位节点,沿用正常chat入口和 session 拦截,不单独再造工具系统。
4. 自动化测试边界
可以自动跑的
go test ./...- dry-run / trigger / preview / confirm 的 API 验证
- DB 结果核对
- 幂等、重复提交、冲突、过期、失败注入
- notification 状态流转
- webhook test 接口
- api-only / worker-only / all 的后端闭环
部分自动、需要浏览器或你配合开服务的
AssistantPanel.vue的实际页面交互- 主动调度卡片是否真的长对了
- 飞书真实消息是否真的落到外部页面
- 需要切换启动模式时的服务重启
我会怎么标记进度
每个阶段完成后,我会把对应标题从 [ ] 改成 [x],并补三件事:
- 实际跑过的命令。
- 关键请求 / 响应 ID。
- DB 核对结果和剩余风险。