Version: 0.9.61.dev.260501

后端:
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 收口并同步接力状态
This commit is contained in:
Losita
2026-05-01 20:48:32 +08:00
parent 0a014f7472
commit a3eaa9b2c2
42 changed files with 4377 additions and 357 deletions

View File

@@ -0,0 +1,703 @@
# 主动调度缺口补全讨论稿
本文档由“主动调度候选生成器讨论稿”扩充而来,用于记录主动调度后续补全设计。
它不是实施计划,也不替代《第二阶段主动调度 MVP 实现方案》。这里主要保存三类内容:
1. 候选生成器、LLM 选择题和 `ask_user` 的设计共识。
2. 飞书通知进入聊天页后的主动调度合流方案。
3. 第四、第五阶段已经实现链路里仍然存在的空壳、占位和待验收点。
## 1. 当前实际链路
### 1.1 主动调度后端链路
当前主动调度主链路已经形成:
```text
active_schedule.triggered
-> BuildContext
-> Observe
-> GenerateCandidates
-> CreatePreview
-> notification.feishu.requested
-> WebhookFeishuProvider
-> 用户回系统确认
-> ConfirmPreview
-> ApplyActiveScheduleChanges
```
这个链路的主体已经不是空壳trigger、preview、notification、confirm apply、幂等、retry、api-only / worker-only / all 启动边界都已经被本地验收过。
这里必须把三件事拆开看,避免把“触发”和“通知”混成一条链:
1. 触发来源:后台 worker 自动触发、API 验收 / 后续产品内用户主动入口、`ask_user` 回复后的同步重跑。
2. 业务目标:`important_urgent_task` 负责把 task_pool 任务放进日程;`unfinished_feedback` 负责给已排动态任务新增补做块。
3. 投递方式:后台离线触达才需要飞书 webhook用户已经在聊天页主动发起或回复 `ask_user` 时,直接通过 timeline / SSE 返回新 preview不再先走飞书通知。
同一套 active scheduler graph 负责这两类业务目标,区别只在入口和投递方式,不是拆成两套逻辑。
### 1.2 当前没有真正做到的部分
当前仍然缺少这些关键能力:
1. `GenerateCandidates` 仍然是确定性 first-fit 候选生成,不是 topN 候选搜索器。
2. `Observation.Decision.LLMSelectionRequired=true` 已经写进结构,但没有真正的 LLM selector。
3. `CreatePreview` 明确写着“MVP 没有 LLM 选择器”,固定使用 `Candidates[0]`
4. 本轮主动调度不再额外扩展独立评分子系统,候选裁决只保留轻量、可直接解释的维度。
5. memory 偏好没有进入 active scheduler 的候选裁决。
6. `ask_user` 在 active scheduler 里目前只是候选 / decision 类型,没有用户回复、重跑 graph、重新出结果的闭环。
7. 飞书通知只是离线自动触达,不能承接用户回复;用户主动补充、`ask_user` 回复和后续自由协作都必须回系统内聊天页完成。
8. 飞书 action_url 的当前目标已经收口到现有助手会话路由;前端仍在补适配,但不能继续依赖旧的 `/schedule-adjust/{preview_id}` 详情页口径。
## 2. 总体补全方向
### 2.1 候选生成器定位
候选生成器应该升级为:
```text
安全候选工厂 + 维度评估器 + fallback 排序器
```
它负责:
1. 枚举合法候选。
2. 在只读事实快照上模拟候选实施后的局部结果。
3. 输出可解释的维度评估。
4. 给出后端 fallback 顺序。
5. 在无法安全生成候选时稳定降级为 `ask_user / notify_only / close`
它不负责:
1. 结合 memory 做最终主观裁决。
2. 生成最终用户话术。
3. 绕过 preview 直接写正式日程。
4. 在事实不足时猜测用户偏好。
### 2.2 LLM 定位
LLM 不应该自由构造日程写库参数。
LLM 应该做:
1. 在后端给出的候选中选择一个。
2. 读取已注入的 memory 上下文、用户近期反馈、候选维度,做软裁决;这些信息不是 `ask_user` 要现场补采的内容。
3. 决定是否需要追问用户。
4. 输出面向用户的解释文案。
LLM 不应该做:
1. 自己发明 slot、event_id、task_id 或写库 change。
2. 修改 hard constraint 判断。
3.`ask_user` 未完成时强行进入普通聊天链路。
### 2.3 newAgent / Eino 接入方向
当前倾向是把主动调度接到 newAgent 入口附近,而不是把主动调度塞进普通 ReAct 工具循环里。
建议边界:
1. active scheduler graph 仍然是独立业务 graph负责事实读取、候选生成、preview、apply 边界。
2. newAgent 负责承接用户自由表达、现有 memory / execute 链路、页面交互和后续微调。
3. 短期在同一后端进程内同步调用 active scheduler service。
4. 后续拆微服务后,把 active scheduler graph 变成 RPC 同步调用。
5. newAgent 可以把“主动调度会话”作为上下文注入,但不能绕过 active preview / confirm API 直接写正式日程。
## 3. 六个候选生成讨论维度
### 3.1 候选生成器职责边界
已形成的结论:
1. 后端负责硬约束归属校验、时间窗、容量、deadline、slot 合法性、候选 change 可执行性。
2. 后端负责 fallback 排序LLM 失败、超时或输出非法时,使用后端 top1。
3. LLM 负责软裁决:已注入的 memory 偏好、近期反馈、风险取舍、用户可接受度。
4. 单个候选生成失败时丢弃该候选并写 trace不让一个坏候选拖垮整轮。
5. 整体候选生成失败时按原因分流:
- 业务事实不足:`ask_user`
- 没有安全候选:`notify_only``ask_user`
- 系统异常trigger failed等待 worker retry 或人工排障。
待补全点:
1. 当前 `GenerateCandidates` 仍然是 first-fit不是“枚举多个合法候选”。
2. 当前 `rankCandidates` 只是粗排序,没有稳定的维度评分。
3. 当前 `ask_user` 没有和用户回复闭环打通。
### 3.2 候选类型集合
当前已开放:
1. `add_task_pool_to_schedule`:把重要且紧急的 task_pool 任务加入滚动 24 小时空闲节次。
2. `create_makeup`:为未完成反馈新增补做块,不移动原任务。
3. `ask_user`:事实不足时追问。
4. `notify_only`:没有安全候选时只提醒。
5. `close`:触发条件已经失效时关闭。
当前明确关闭:
1. `compress_with_next_dynamic_task`:只保留 schema 和常量,第一版不生成。
2. 局部重排 / 多任务交换:还没有进入 active scheduler 候选集合。
建议下一轮补全:
1. `important_urgent_task` 不只取第一个连续空位,而是枚举 topN
- 最早可用空位。
- deadline 前更稳的空位。
- 对用户偏好更友好的空位。
- 对日程节奏影响更低的空位。
2. `unfinished_feedback` 先解决“到底是哪条日程没完成”:
- 结合随口记上下文、当前时间和已排日程定位具体 schedule event。
- 定位成功后再生成补做候选;如果缺关键事实,直接进入 `ask_user`,不把比例推断作为主路径。
3. `compress_with_next_dynamic_task` 等候选必须先完成风险模型,再打开生成开关。
候选可编辑性建议:
```text
add_task_pool_to_schedule 可拖动时间,但不能改 target_id
create_makeup 可拖动时间,可调整补做节数上限
compress_with_next_dynamic_task 暂不开放
ask_user 不写日程,不可确认 apply
notify_only 不写日程,不可确认 apply
close 不写日程,不可确认 apply
```
### 3.3 评估维度与分数体系
当前倾向:先不做单一总分。
原因:
1. 主动调度早期更需要可解释,而不是一个看似精确的总分。
2. LLM 的决策价值要主动收窄后端负责合法性、粗排和默认裁决LLM 主要负责解释、接近候选间的有限裁决,以及信息不足时更自然地追问。
3. 后端 fallback 可以用简单稳定顺序,不需要过早引入复杂权重。
候选输出原则:
1. 未通过硬约束的方案不进入候选列表。
2. 硬约束失败只写入 trace / debug / invalid_reason供排障使用。
3. 给 LLM 和用户看的候选维度,只保留需要取舍、比较或解释的信息。
候选建议输出维度:
```text
capacity_fit 是否满足 estimated_sections
risk_level low / medium / high
```
暂不保留的维度:
1. `explainability`太像主观作文评分后端不好稳定计算LLM 也容易自证合理。
2. `reversibility`:当前主动调度第一版没有撤销按钮,贸然展示“可逆性”容易误导用户;后续如果实现 undo / rollback再重新设计。
3. `disruption`:当前主动调度只生成“新增任务块 / 新增补做块”,不移动已有日程,扰动度几乎恒为 `none`,对候选选择没有区分度;等打开移动、压缩、局部重排候选时再恢复。
4. `deadline_fit`deadline / urgency window 属于硬约束,不满足的方案不进入候选;满足后无需再作为展示维度。
5. `user_preference_fit`memory / 用户近期反馈更适合交给 LLM 在选择题环节阅读和裁决,不伪装成后端可精确计算的评分。
6. `confidence`:不作为 LLM 可见候选维度。事实可信度只用于内部 trace 和 `ask_user` 门控;如果事实不足以支撑正式候选,就直接 `ask_user`,不要生成“低 confidence 候选”。
各维度的计算口径必须尽量简单:
1. `capacity_fit`:只看候选 slot 数是否覆盖 `estimated_sections`;如果容量不足则不进入候选,保留该字段主要用于区分“刚好够 / 有余量”。
2. `risk_level`:由 candidate_type、候选是否移动 / 压缩 / 重排已有日程、slot 稳定性和非致命 warning 汇总成低/中/高,不单独引入玄学评分。当前第一版只新增不移动,所以多数正式候选会是 `low`;后续接入粗排整体重排时,这个字段才会明显拉开差异。
内部 trace 可保留 `fact_confidence``evidence_level`,但它不进入 LLM 候选维度:
1. `high`target 明确、`estimated_sections` 已落库、slot 来自确定课表空档、没有关键缺失信息。
2. `medium`:硬事实齐全,但部分定位来自推断或有非致命 warning。
3. `low`:核心目标不明确或缺关键事实;这种状态应转成 `ask_user`,不生成正式变更候选。
### 3.4 LLM 选择题协议
LLM 可见信息建议分两层:
1. 基础上下文:`trigger_type``target``time_window``missing_info``warnings``before/after` 摘要。
2. 候选层:`candidate_id``candidate_type``summary``preview_change``dimensions`,其中 `dimensions` 只包含 `capacity_fit / risk_level`
3. 已有 memory 可以作为上下文注入,但不作为 `ask_user` 的缺失信息采集目标。
4. 不暴露原始全量事实快照,避免把后端内脏直接端给模型。
LLM 输入建议:
```json
{
"trigger": {
"trigger_id": "ast_xxx",
"trigger_type": "important_urgent_task",
"target_type": "task_pool",
"target_id": 82
},
"context_summary": {
"window": "rolling_24h",
"missing_info": [],
"warnings": []
},
"memory": {
"preferences": ["周末不想学习"],
"recent_feedback": ["晚上不适合高强度任务"]
},
"candidates": [
{
"candidate_id": "xxx",
"candidate_type": "add_task_pool_to_schedule",
"summary": "放到周四第3节",
"dimensions": {
"capacity_fit": "exact",
"risk_level": "low"
}
}
]
}
```
LLM 输出必须限制为:
```json
{
"action": "select_candidate",
"selected_candidate_id": "xxx",
"reason": "选择这个候选的原因",
"user_message_summary": "给用户看的简短解释",
"ask_user_question": ""
}
```
允许的 `action`
```text
select_candidate
ask_user
notify_only
close
```
兜底规则:
1. LLM 超时:使用后端 fallback candidate。
2. LLM 选了不存在的 candidate重试一次仍失败则 fallback。
3. LLM 输出 `ask_user` 但问题为空:后端用候选生成器给出的 missing_info 生成兜底问题。
4. LLM 输出任何写库参数:丢弃写库参数,只保留合法 action / candidate_id。
### 3.5 未完成反馈补做链路
当前状态:
1. 若 trigger 直接携带 `schedule_event` target后端可以定位未完成对象。
2. 若无法定位目标observe 会降级为 `ask_user`
3. 第一版补做只生成新补做块,不移动原任务。
需要补全:
1. 随口记链路要能把“我这个没做完”定位到具体 schedule event。
2. 定位优先靠 LLM 上下文推断 + 当前时间 + 已排日程窗口;定位不稳时直接 `ask_user` 问“是哪一条没做完”。
3. 补做节数最好前置到 task 写入环节,由 `tasks.estimated_sections` 承接;当前 `model.Task` 里已经有这个字段,主动调度也会消费它,但普通任务创建接口和 quick task 入口还没完全透传,所以现在仍要保留 `1` 的兜底。
4. `ask_user` 追问不要设计成固定问法,而是由 `missing_info` 驱动,缺什么就问什么。
5. 用户在聊天页说“我周末不想学习”这类话时,如果已经解除主动调度锁定,就直接进入现有 newAgent memory / execute 链路,不回到主动调度 graph。
### 3.6 压缩融合与局部重排
当前结论:
1. `compress_with_next_dynamic_task` 第一版继续关闭。
2. 打开前必须先解决候选安全性和风险解释。
3. 压缩融合不能由 LLM 自由生成,必须由后端模拟和校验。
4. 压缩融合只允许“同一任务链路内部消化”,不把 A 任务压进无关 B 任务。
压缩融合的业务定义:
```text
谁污染谁治理:哪个任务发生未完成/超时问题,就只在这个任务自己的后继块里消化。
```
换句话说:
1. 如果 A 任务没完成,只允许把 A 的剩余内容压缩进 A 的后继任务块。
2. 不允许因为 B 看起来空余,就把 A 的剩余内容塞进 B。
3. 如果 B 真的长期空余,那是 B 自己的估时或安排问题,不应该用来替 A 兜底。
4. 这样可以避免主动调度把问题跨任务传染,保持候选解释简单、责任边界清楚。
打开条件建议:
1. 只处理同一任务自己的动态后继块,不压缩固定课程、外部事件或其它任务。
2. 被压缩任务必须保留最小节数,不能为了补救把后继块压到失真。
3. 不额外引入抽象风险评分;压缩融合的主要风险不是抽象节奏分,而是这个任务自身仍然完不成。
4. 用户确认页必须清楚展示“哪个任务自己的后继块被压缩、从几节压到几节、腾出的时间补哪里”。
5. 压缩失败时优先 `notify_only`,事实不足才 `ask_user`
压缩融合的风险口径:
1. 该任务自己的后继块被压缩后,任务整体仍可能完不成。
2. 如果后继块被压得过短,可能需要继续追加补做,而不是继续污染其它任务。
3. 用户可能不接受压缩某类自有后继块,例如复习、运动或休息。
4. 除上述情况外,不额外引入抽象 health 风险。
## 4. 主动调度进入聊天页的合流设计
### 4.1 为什么不做孤立表单页
飞书 webhook 是单向通知,不能指望飞书直接承接 `ask_user` 回复。
既然用户必须回系统内回复,就应该复用聊天页:
1. 复用 newAgent 的自由表达能力。
2. 复用已有日程预览、微调、确认、自动拖拽保存等体验。
3. 用户在主动调度解锁后可以直接说“我周末不想学习”,这类偏好由现有 newAgent memory / execute 链路处理,不在主动调度里单独新建记忆链路。
4. 避免做一个只能补字段的孤立页面。
### 4.2 两种进入聊天页状态
信息完整:
```text
飞书点击
-> 打开聊天页
-> 加载 active preview / trigger 上下文
-> 展示主动调度建议卡片
-> 用户可确认、拖动微调、提出异议
-> 正常进入 newAgent 自由链路
```
信息不完整:
```text
飞书点击
-> 打开聊天页
-> 进入主动调度 ask_user 锁定态
-> 用户回复缺失信息
-> 后端更新事实 / memory
-> 重跑 active scheduler graph
-> 生成新的 preview
-> 解除锁定,回到正常聊天链路
```
关键约束:
1. `ask_user` 未完成前,不允许直接进入普通聊天链路。
2. 用户回复不是普通闲聊,而是当前主动调度 session 的补信息输入。
3. 补信息后必须重跑 active scheduler graph而不是拿旧候选硬套。
4. 新结果出来后,才允许 Agent 基于新 preview 继续自由协作。
5. 聊天消息仍然正常写入 conversation / timeline锁定的是后端路由控制权不是聊天记录写入。
### 4.3 主动调度会话抽象
已拍板:单独新增 `active_schedule_sessions`,不把主动调度状态塞进现有 conversation 表,也不只在 `active_schedule_previews` 上加 `conversation_id`
原因:
1. 主动调度先于聊天发生:后台 trigger / preview 发出时,用户可能还没有打开聊天页;但在飞书通知真正发出前,后端会预创建或绑定 `conversation_id`,让最终入口落到现有会话路由。
2. 主动调度和聊天生命周期不同preview 会过期、重跑、确认、忽略conversation 只是承载用户可见对话。
3. `ask_user` 的恢复语义不同active scheduler 收到回复后只补当前缺失业务事实并重跑 graph而不是恢复 newAgent 的 plan / execute 节点,也不新建 memory 写入链路。
4. 审计链路需要串起 `trigger_id -> preview_id -> notification_id -> conversation_id -> apply_id`,单独 session 更清楚。
5. 一个聊天会话后续可以继续讨论同一个主动调度 preview但这不代表主动调度一直拥有聊天路由管辖权。
最小需要保存:
```text
session_id
user_id
conversation_id # 创建时可空,通知前必须绑定
trigger_id
current_preview_id
status
state_json # 轻量业务状态pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason 等
created_at
updated_at
```
状态建议:
```text
waiting_user_reply 等待用户补信息
rerunning 已收到回复,正在重跑 graph
ready_preview 已有可展示 preview已解除硬拦截
applied 用户已确认应用
ignored 用户忽略
expired 会话过期
failed 重跑或绑定失败
```
飞书入口已拍板:
```text
/assistant/{conversation_id}
```
不使用 `/assistant?active_preview_id=xxx&trigger_id=xxx` 作为主入口。`preview_id / trigger_id` 由后端通过 session 查询,避免前端 URL 长期承担业务状态拼装。
`ask_user` pending 已拍板:
1. 不复用 newAgent `PendingInteraction` 作为状态源。
2. active scheduler 的 pending 放在 `active_schedule_sessions` 里管理。
3. newAgent `PendingInteraction` 可以借鉴交互协议和 UI 体验,但不能决定主动调度 graph 如何恢复。
### 4.4 聊天管辖边界
主动调度 session 和聊天 timeline 是两本账:
```text
conversation / timeline
记录用户和 AI 看得见的对话内容。
active_schedule_sessions
记录这段对话对主动调度流程意味着什么状态变化。
```
因此,在 `waiting_user_reply / rerunning` 阶段会出现“双写”,但不是重复保存同一份消息:
1. timeline 写入用户可见消息。
2. session 写入主动调度状态流转。
3. 用户正在聊天页等待新方案时session 推进、graph 重跑、preview regenerated 必须走当前请求内同步 / SSE 主路径,不能只丢 outbox 后结束请求。
路由管辖边界:
```text
waiting_user_reply / rerunning
active_schedule_sessions 有路由管辖权。
用户输入不进入普通 newAgent先用于补信息、重跑主动调度偏好类表达留到解除锁定后再走 newAgent 的 memory / execute 链路。
ready_preview
解除硬管辖。
用户输入进入普通 newAgent但请求里可以携带 active_session 上下文。
applied / ignored / expired / failed
session 只做审计和历史引用,不再拦截后续聊天。
```
也就是说:
1. `active pending` 只表示是否拦截聊天输入。
2. `active context` 表示是否把这次主动调度 preview / session 注入给 newAgent 参考。
3. pending 结束后context 可以继续存在,但它没有路由管辖权。
session 表的 outbox 口径:
1. 第一版不新增 `active_schedule.session.reply_received` 作为主驱动事件。
2. 用户补信息后的主流程必须同步完成:
```text
写入 timeline
-> 更新 session.status = rerunning
-> 解析补充信息 / 更新本轮上下文
-> 重跑 active scheduler graph
-> 生成新 preview
-> SSE 推送新方案卡片
-> 更新 session.status = ready_preview
```
3. outbox 只用于已有异步副作用或兜底可靠性,例如:
- timeline 持久化。
- memory 长期抽取 / 写入。
- agent 状态快照。
- notification 投递。
- 同步处理失败后的 failed 审计或后续人工重放。
4. session 也要接入缓存链路chat 路由按 `user_id + conversation_id` 先查 session 热缓存miss 再回源 DB并在 `status / current_preview_id / conversation_id` 变化后同步回填缓存。
5. 构造用户消息时,要把 `active_schedule_sessions + preview + pending_question` 组装成可直接复用的消息快照并写入缓存,避免每次都从 DB 重组同一份主动调度上下文。
6. session 创建、绑定 `conversation_id`、释放路由管辖权都优先同步写表,不单独事件化。
7. 后续如果要补完整审计,可以再考虑 session event log但不作为 MVP 主链路依赖。
### 4.5 前端需要补的适配层
当前 `AssistantPanel.vue` 已有:
1. SSE `extra` 事件处理。
2. `confirm_request` 覆盖层。
3. `schedule_completed` 卡片。
4. `ScheduleResultCard`
5. `ScheduleFineTuneModal`
6. `extra.resume` 发送协议。
但它现在缺少:
1. 从 URL / route query 初始化 active schedule session。
2. 拉取 `GET /api/v1/active-schedule/preview/:preview_id`
3.`ActiveSchedulePreviewDetail` 转成前端可展示的主动调度卡片。
4. 主动调度 preview 和 newAgent `SchedulePreviewData` 的 DTO 适配边界。
5. `ask_user` 锁定态输入框提示和发送协议。
6. 主动调度 confirm 应调用 `/active-schedule/preview/:preview_id/confirm`,不能走 newAgent 普通 schedule preview 保存接口。
### 4.6 后端需要补的合流点
后端需要补:
1. 飞书 action_url 指向现有聊天页,而不是不存在的 `/schedule-adjust/{preview_id}`
2. `notification.FeishuNotificationRequestedPayload.Validate` 不应长期硬编码 `/schedule-adjust/`,应允许 `/assistant/{conversation_id}`
3. active scheduler session 需要能在通知前绑定 `conversation_id`
4. newAgent 入口需要识别 active scheduler 上下文:
- 信息完整:注入 preview / candidate / constraints。
- 等待补信息:拦截普通聊天,先完成 active `ask_user`
5. 用户在聊天中给出的偏好,如果已经处于自由聊天链路,就交给现有 newAgent memory / execute 处理;只有主动调度所需的缺失事实才会让 session 继续占管。
### 4.7 与现有 execute 链路的接缝
主动调度不复制 `task_item`,也不另起一套排程写入链路。
最小接法:
1. 主动调度只负责在会话层生成 preview 和确认态,不直接改正式日程。
2. 前端拖拽和确认继续复用现有会话快照语义,后端只更新同一份 `ScheduleState`
3. 确认通过后,再把这份状态的 diff 落成正式 `schedule_events / schedules`
4. 会话释放后,普通聊天回到 newAgent `execute`,继续在同一份 `ScheduleState / OriginalScheduleState` 上做 `move / swap / place / unplace`
所以新增量是 session、路由拦截、preview DTO 适配和 graph 裁决,不是新建排程引擎,也不是把待调整任务复制成新任务再排一遍。
## 5. 飞书 Webhook 与消息拼装
当前真实飞书走“用户级 Webhook 触发器”,后端向用户配置的 webhook POST 极简业务 JSON。
当前 payload 形态可以保留:
```json
{
"event": "smartflow.schedule_adjustment_ready",
"version": "1",
"notification_id": 123,
"user_id": 5,
"preview_id": "asp_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"
}
```
消息拼装原则:
1. 飞书流程只需要读 `message.title / summary / action_text / action_url`
2. 业务分支读顶层 `event / version / trigger_type / preview_id / trigger_id`
3. 不把复杂卡片协议塞进 webhook payload。
4. 私聊、群聊、机器人卡片由飞书流程自行编排。
5. 本地开发用 `notification.frontendBaseURL=http://localhost:5173`
6. 未上线前,示例配置也应写 localhost上线后再替换正式域名。
## 6. 第四、第五阶段链路扫点
### 6.1 已经比较实的部分
1. `active_schedule.triggered` handler 已能消费 outbox 并推进 trigger 状态。
2. due job scanner 已能把到期 job 转成正式 trigger。
3. preview 写入、详情查询、confirm apply 已有完整后端链路。
4. notification service 已有记录表、状态机、去重、retry、dead/skipped 分类。
5. WebhookFeishuProvider 已经接用户级 webhook 配置,并能真实 POST 飞书 webhook。
6. API-only / worker-only / all 启动边界已经验过。
7. `important_urgent_task``unfinished_feedback` 的基础端到端已经跑通。
### 6.2 仍是空壳或半空壳的部分
1. LLM selector 空壳:
- `LLMSelectionRequired=true` 只是标志。
- 没有选择题 prompt、模型调用、解析、重试和 fallback 分支。
2. LLM summary 空壳:
- notification summary 当前来自 selected candidate summary。
- 没有真正“LLM 生成通知摘要,模板兜底”的实现。
3. 候选搜索空壳:
- 当前基本是 first-fit top1。
- 没有 topN、维度评分、health before/after。
4. memory 接入空壳:
- active scheduler 不读取 memory。
- 用户偏好只会在 newAgent 普通聊天链路里发挥作用。
5. `ask_user` 闭环空壳:
- active scheduler 能产出 `ask_user` 类型但没有用户回复入口、session、重跑 graph。
6. 聊天页合流空壳:
- 前端没有 active preview route/query 初始化。
- 没有主动调度 preview DTO 到聊天卡片 / 微调弹窗的适配。
7. 飞书 action_url / 校验口径待收口:
- 主入口已经统一到 `/assistant/{conversation_id}`,不再把 `/schedule-adjust/{preview_id}` 当成新目标。
- 后端仍要保证在发通知前能从 session 生成最终会话链接。
- shared event 校验和前端适配仍需继续收尾,不能把旧路径当成主链路。
8. 未完成反馈自然语言定位空壳:
- 当前更依赖 trigger 直接给 `schedule_event` target。
- 随口记“哪个没做完”的定位链路还没接。
9. `unfinished_feedback` 补做节数口径仍粗:
- 比例推断不再作为当前主路径。
- 当前更应先补“定位目标 + 必要时 ask_user”补做节数先按原任务长度、用户明确剩余内容和可用容量保守估算。
10. 压缩融合空壳:
- schema / 常量存在。
- 生成逻辑明确关闭。
11. 前端主动调度确认体验空壳:
- 后端 confirm API 存在。
- 但聊天页还没把主动调度卡片的确认按钮接到该 API。
12. 剩余验收缺口:
- confirm apply 冲突失败。
- preview 过期拒绝。
- 更系统的失败注入脚本化。
- 测试数据隔离策略。
### 6.3 不是空壳,但需要改口径的地方
1. 当前实施口径已经统一为“进入聊天页承接主动调度会话”;文档或历史记录里若出现 `/schedule-adjust/{preview_id}`,只能作为旧口径残留,不能作为新实现目标。
2. 通知事件 `target_url` 的校验规则要允许站内聊天页路径,主入口使用 `/assistant/{conversation_id}`,不要再新增独立 schedule-adjust 页面。
3. 前端 `ScheduleFineTuneModal` 当前绑定 newAgent preview 数据结构,不能直接复用 active preview DTO必须加适配层。
### 6.4 本轮前置条件estimated_sections 写入链路补齐
这件事属于任务创建 / 随口记写入链路,不属于 active scheduler graph 内部逻辑;但它是本轮缺口补齐方案的前置条件,应该单独拎出来做。
目标口径:
1. LLM 在创建 task_pool 任务时,除了 title、priority、deadline、urgency_threshold_at也要给出预计占用节数。
2. 预计占用节数写入 `tasks.estimated_sections`,范围仍按 MVP 约定限制为 1~4。
3. 主动调度只消费 `tasks.estimated_sections`,不在调度阶段重新推断任务复杂度。
4. 如果 LLM 没给、解析失败或超出范围,写入链路兜底为 1 节,并记录 trace / warning避免阻断任务创建。
当前代码现状:
1. `model.Task` 已经有 `EstimatedSections` 字段。
2. active scheduler 已经读取并消费该字段。
3. 普通任务创建请求 `UserAddTaskRequest`、转换层和 quick task 创建入口还没完全透传该字段。
本轮建议改造点:
1.`UserAddTaskRequest` 增加 `estimated_sections` 入参,并在转换层写入 `model.Task.EstimatedSections`
2. 给 quick task 创建依赖增加 estimated sections 参数,让 newAgent / 随口记创建任务时能把 LLM 判断结果带进 DB。
3. 对入口值做 1~4 的统一归一化,避免每条写入链路各自截断。
4. 更新相关响应或查询 DTO 时,至少让调试 / 验收能看到任务最终写入的 estimated sections。
## 7. 下一轮讨论顺序
上面两项只是聊天页合流和验收尾项,不代表完整实施顺序。完整开工顺序必须先把主动调度 graph 补回来,否则只是给当前固定 pipeline 包一层 UI。
完整实施顺序建议:
1. 先补 `estimated_sections` 写入入口,保证 task 创建 / 随口记创建任务时已经带预计节数。
2. 补主动调度 Eino graph把现有 `BuildContext -> Observe -> GenerateCandidates -> CreatePreview` 包成可继续扩展的 graph并新增 LLM 选择题 / ask_user 分支。
3. 升级候选生成与裁决:从 first-fit top1 走向 topN 候选、维度信息、memory 输入和后端 fallback。
4.`active_schedule_sessions` 和聊天入口拦截,让 `ask_user` 回复能同步重跑 graph。
5. 补 active preview 到聊天页卡片 / 微调弹窗的前端 DTO 适配。
6. 跑第五阶段剩余验收项:冲突、过期、失败注入脚本化。
对应到《第二阶段主动调度 MVP 实现方案》里1 属于本轮前置条件2-4 属于第六阶段主动调度 graph 与会话桥5 属于第六阶段聊天页适配6 属于第十四章剩余验收。
`unfinished_feedback` 的主口径也已经基本拍板,不再作为下一轮主讨论项:
1. 定位靠 LLM 上下文推断 + 当前时间 + 已排日程窗口。
2. 定位不稳就 `ask_user`,由 `missing_info` 决定缺什么问什么。
3. 定位成功后生成补做 preview不直接移动原任务。
`estimated_sections` 写入入口补齐是本轮前置条件,见 6.4;它单独属于任务创建 / 随口记写入链路,不混进主动调度 graph 设计里。
`waiting_user_reply / rerunning` 阻塞态的聊天入口拦截协议已经拍板:
1. 后端在 chat 路由按 `active_schedule_sessions` 状态拦截。
2. `waiting_user_reply / rerunning` 时,用户消息先进入主动调度补信息链路,不进入普通 newAgent 自由聊天。
3. `ready_preview / applied / ignored / expired / failed` 才释放回普通聊天链路。
4. 这一块后续只补接口返回码、错误提示和前端文案,不再作为下一轮主讨论项。
`estimated_sections` 写入入口补齐优先补 `UserAddTaskRequest` 和 quick task 创建入口对该字段的透传,不要让主动调度侧重新猜一遍。
已基本定稿、不再占用下一轮主讨论的内容:
1. `active_schedule_sessions` 表字段和同步状态机,当前先按极简版 `session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json / created_at / updated_at` 落地。
2. 候选公开维度字段,当前先按 `capacity_fit / risk_level` 执行;事实可信度只作为内部 trace / `ask_user` 门控,不进入 LLM 候选维度。
3. LLM 选择题 JSON 协议,当前先按 `action / selected_candidate_id / reason / user_message_summary / ask_user_question` 执行。
如果你想继续把某一项再收紧,优先补拍板:
1. `state_json` 里面到底要不要再拆成独立列。
2. `ask_user_question` 是否必须非空。
3. `reason` 是给内部调试看,还是也要直接展示给用户。

View File

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

View File

@@ -10,12 +10,12 @@
课程 / 任务事实变化
-> 后台观测滚动 24 小时内的任务与日程风险
-> 生成结构化诊断和候选方案
-> 让 LLM 在候选方案中做选择与表达
-> 让 LLM 做解释、追问和有限选择
-> 写入预览 / 触达用户
-> 用户回系统确认后再进入正式应用
```
这里的关键是:**系统主动观测后端给候选LLM 做选择,用户掌握最终确认权**。
这里的关键是:**系统主动观测,后端给候选并粗排LLM 做解释 / 追问 / 有限选择,用户掌握最终确认权**。
---
@@ -78,7 +78,7 @@
1. 后端先做结构化观测。
2. 后端输出问题、指标、裁决和候选操作。
3. LLM 不做开放式全窗搜索,而是在候选项里选择
3. LLM 不做开放式全窗搜索,也不承担主排序;它只在后端候选里做有限选择与表达
4. 候选必须是后端验证过合法性和收益的。
5. 如果没有值得继续处理的问题,后端明确返回 close / ask_user而不是继续诱导 LLM 硬调。
@@ -86,13 +86,13 @@
也就是说,新能力更像一个主动调度观测能力,而不是一个自由排程工具。具体工程工具名后续再确认,本阶段只固定职责边界。
### 3.2 让 LLM 做选择
### 3.2 让 LLM 做解释、追问和有限选择
LLM 的职责:
1. 在后端候选方案中选择更符合上下文的一项
2. 把结构化诊断转换成用户能理解的解释
3.候选不足、信息不足、风险过高时选择 ask_user / close
1. 把结构化诊断转换成用户能理解的解释
2. 在后端候选非常接近时,做有限选择
3.信息不足时,把后端 `missing_info` 转成自然追问
4. 根据主动注入的上下文理解用户偏好,但不调用单独的 `user_preference.get` 工具。
后端的职责:
@@ -540,7 +540,7 @@ assumed_completed
4. 动态任务计划时间过去后默认按已完成推进,不主动追问。
5. 当前动态任务失败且影响后继时,能按“局部重排 -> 延后结束 -> 压缩融合”顺序生成候选。
6. 能输出结构化 metrics / issues / decision / candidates。
7. 候选项必须是选择题,不让 LLM 自由生成正式写库参数。
7. 候选项必须来自后端,不让 LLM 自由生成正式写库参数LLM 只做解释、追问和有限选择
8. 不直接写正式 schedule只写预览或触达用户。
9. 能发布 `notification.feishu.requested` 提醒用户回系统确认。
10. 用户确认后才允许进入正式应用链路。

View File

@@ -7,7 +7,7 @@
当前核心共识:
1. 主动调度主链路走固定 graph / service pipeline不进入 ReAct 工具循环。
2. 第一版触发先做 `important_urgent_task``unfinished_feedback`
2. 第一版触发类型先做 `important_urgent_task``unfinished_feedback`,对应的业务目标分别是 task_pool 进日程和未完成反馈补做
3. task 创建 / 更新时按 `urgency_threshold_at` upsert 主动调度 jobtask 完成后把 job 标记为 `canceled`
4. schedule 动态任务默认 `assumed_completed`,只有用户明确反馈未完成才触发补救。
5. 调度触发信号需要持久化,用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply。
@@ -17,24 +17,26 @@
7. 主动调度预览新增 `active_schedule_previews`,不塞进 `agent_schedule_states`
8. 预览保存 `base_version + before_summary + preview_changes`,不保存全量 before 快照。
9. 第一版不做 apply 成功后的撤销按钮apply 失败必须事务不落库并回写失败原因。
10. 用户确认入口走主动调度详情页和确认 API不走 Agent resume详情页采用助手卡片式体验,支持拖动 after 方案后确认。
10. 用户确认入口走现有助手会话页和主动调度确认 API不走 Agent resume前端复用助手卡片式体验,支持拖动 after 方案后确认。
11. 预览有效期 1 小时。
12. 未完成补救第一版只生成新补做块,不直接移动原已排任务。
13. `schedule.apply.requested` 第一版不走 outbox 异步消费,确认 API 内同步完成重校验和正式应用;成功 / 失败直接回写预览状态。
14. 应用幂等使用独立 `apply_id + idempotency_key``preview_id + candidate_id` 只用于定位候选,不作为一次确认尝试的幂等键。
15. 飞书通知必须包含唯一预览链接 `/schedule-adjust/{preview_id}`;通知文案优先由 LLM 生成摘要,固定模板作为失败兜底。
15. 飞书通知必须包含唯一会话链接 `/assistant/{conversation_id}`;若会话尚未创建,后端先预创建 `conversation_id` 并绑定主动调度 session 后再发通知。通知文案第一版先复用候选 / preview summary,固定模板作为兜底LLM summary 作为后续增强分支,不作为当前已验收前提
16. 飞书通知幂等按 `user_id + trigger_type + time_window` 聚合,不按 `preview_id`;第一版落 `notification_records` 表支撑可观测与失败重试。
17. `api / worker / all` 启动边界第一阶段已完成;主动调度 MVP 可直接挂到 worker / 事件链路,不需要等待启动边界拆分。
18. 主动调度第一版采用“准独立模块”策略:不放进 `backend/service/active_scheduler`,而是放在 `backend/active_scheduler`MVP 暂不拆独立 Go module / 独立进程。
19. 事件契约第一版提前放入 `backend/shared/events`,只承载 event type、event version、payload DTO 和基础校验,不放业务逻辑。
20. 主动调度采用 port / adapter 依赖边界:主链路不散落依赖其它领域 DAO自有表用自有 repo读取外部事实走 reader port正式写入走 apply/service port。
21. 主动调度验收以“后端链路可观测 + 动作-预期 checklist”为准覆盖 dry-run、trigger、worker、preview、notification、confirm apply、幂等、过期和失败回写。
22. 本轮给 `tasks` 新增 `estimated_sections`默认 1MVP 允许 1~4 节;主动调度只消费该字段,不在调度阶段重新推断任务复杂度
22. 本轮给 `tasks` 新增 `estimated_sections`模型层、普通任务创建请求和 quick task 创建入口以及主动调度消费侧都已接上,默认 1MVP 允许 1~4 节;本轮验收已完成收口
23. 本轮给 `schedule_events` 新增来源与审计字段:`task_source_type / makeup_for_event_id / active_preview_id`
24. `compress_with_next_dynamic_task` 第一轮实现先关闭,不生成该候选;保留 schema 和文档口径,待新增补做块主链路稳定后再打开。
25. 飞书第一版使用 mock / webhook 跑通主动触达闭环,不阻塞在用户 open_id 绑定体系上。
26. notification 去重窗口第一版固定为 30 分钟。
27. 真实飞书第一版走“用户级 Webhook 触发器”而不是群自定义机器人协议:后端按 `user_id` 查用户配置的 webhook URLPOST 极简业务 JSON私聊、群聊、分支和后续动作由用户在飞书流程里自行编排。
28. 主动调度进入聊天页时新增 `active_schedule_sessions` 作为路由桥conversation 只承载用户可见历史session 负责 `waiting_user_reply / rerunning` 的管辖权和 `ready_preview` 后的释放。
29. 主动调度的三层口径要分开:触发来源分 worker 自动触发、API 验收入口、用户在聊天页内的主动入口和 `ask_user` 回复;业务目标分 `important_urgent_task -> task_pool 进日程``unfinished_feedback -> 新补做块`;投递方式上,只有后台离线自动触达才走飞书,用户已经在会话内时不再先发飞书通知。
### 0.1 多阶段推进计划
@@ -73,6 +75,18 @@
3. 根据日志和测试结果补齐 trace 字段与错误码。
4. 主链路稳定后再评估是否打开压缩融合候选。
第六阶段:主动调度 graph 补齐、会话桥与聊天页合流。(待实施)
0. `estimated_sections` 写入入口已经补完:普通任务创建请求、转换层和 quick task / 随口记创建任务时,都会把 LLM 估计的 1~4 节写入 `tasks.estimated_sections`;主动调度只消费该字段,不在 graph 内重新猜任务耗时。
1. 补主动调度 Eino graph把现有 `BuildContext -> Observe -> GenerateCandidates -> CreatePreview` 固定 pipeline 整理成 graph 节点,并新增 LLM 解释 / 有限选择、`ask_user`、fallback 分支;当前代码里的 first-fit / `Candidates[0]` 只能作为过渡实现。
2. 升级候选生成与裁决:生成 topN 合法候选,输出 `capacity_fit / risk_level` 两个公开维度后端负责粗排和默认裁决LLM 只在接近候选间做有限选择,并负责解释与补全兜底。
3. 新增 `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` 这类轻量状态。
4. `active_schedule_sessions` 也要接入缓存链路chat 路由先查 session 热缓存,再回源 DB状态变化后同步回填构造用户消息时把 session 上下文、preview 和卡片 payload 一并缓存,避免反复从 DB 重组。
5. notification 发出前由后端预创建或绑定 `conversation_id`,飞书 `action_url` 指向现有 `/assistant/{conversation_id}` 路由,不再新增独立 `/schedule-adjust/{preview_id}` 主入口。
6. 后端在 newAgent 入口按 session 状态决定是否拦截普通聊天:`waiting_user_reply / rerunning` 由主动调度 graph 同步推进;`ready_preview / applied / ignored / expired / failed` 释放给正常聊天链路。
7. 前端只做最小适配:复用 `AssistantPanel.vue``ScheduleResultCard``ScheduleFineTuneModal`timeline 新增主动调度卡片类型,按钮动作按类型分支到主动调度 confirm API。
8. 用户在聊天页补充偏好或缺失事实时,后端先更新 memory / 本轮事实,再重跑 active scheduler graph生成新 preview 后通过 SSE / timeline 推送同一张卡片形态。
### 0.2 子代理并行推进计划
可在实现阶段使用 3 到 5 个子代理并行推进,但必须按文件所有权拆分,避免互相覆盖。
@@ -108,8 +122,8 @@
已完成阶段:
1. 第一阶段:数据结构与事件契约。
- 已新增 `tasks.estimated_sections`,默认 1。
- 已新增 `schedule_events.task_source_type / makeup_for_event_id / active_preview_id`
- 已新增 `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` 回填。
2. 第二阶段:主动调度 dry-run 主链路。
@@ -172,7 +186,7 @@
- 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=https://smartflow.example.com/schedule-adjust/asp_e6701977-aeed-4bef-9964-29d26014f73d`。
- `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"}`。
8. 第五阶段补充自动验收结果:
- skipped 场景:测试账号 `codex_skip_idem_0430_185759`user_id=8未配置 webhook正式 trigger `ast_da60cd1c-1909-4855-ad5d-53125b19fb76` 生成 preview `asp_9e5c9c46-3460-4065-a2b8-1d531cf0c8aa``notification_records.id=3` 进入 `skipped``last_error_code=recipient_missing`,两段 outbox 均为 `consumed`。
@@ -190,7 +204,7 @@
1. 下一步继续第五阶段剩余验收,不需要重做 dry-run / preview / confirm 主链路,也不需要重做第四阶段 provider / handler 主体代码。
2. 第五阶段剩余重点:
- confirm apply 冲突失败、过期拒绝。
- 更完整的边界清理:测试数据隔离策略、失败注入脚本化、前端真实地址替换 `smartflow.example.com`
- 更完整的边界清理:测试数据隔离策略、失败注入脚本化、前端真实地址替换为正式域名配置
4. 工作区注意:
- 另一个前端对话可能在改前端;后端阶段不要碰 `frontend` 相关改动。
- 当前允许单个 Go 文件 700 行以内;超过 700 再评估拆分。
@@ -246,8 +260,8 @@
### 4.2 已拍板结论
1. 第一版触发是否只做两个:`important_urgent_task` 和 `unfinished_feedback`
- 已确认:第一版先做这两类主触发。`fatigue_feedback` 可作为用户反馈类的后续扩展,不抢第一轮主链路。
1. 第一版触发类型是否只做两个:`important_urgent_task` 和 `unfinished_feedback`
- 已确认:第一版先做这两类主触发,对应 task_pool 进日程和未完成反馈补做。`fatigue_feedback` 可作为用户反馈类的后续扩展,不抢第一轮主链路。
2. API 测试触发是否允许直接同步返回诊断结果,还是必须也写入 outbox 后异步消费?
- 已确认:两种都保留。`dry-run` 同步返回诊断结果,不写预览、不发通知;`trigger` 走正式异步链路,写预览并发布通知事件。
3. `mock_now` 是否只允许测试接口传入,后台真实 worker 禁止传入?
@@ -968,7 +982,7 @@ context 构造成功后,后续 observe 可依赖以下事实已经可用:
### 6.1 业务实现逻辑简述
主动观测能力参考 `analyze_health`:后端先做结构化观测,再生成候选,让 LLM 做选择题
主动观测能力参考 `analyze_health`:后端先做结构化观测,再生成候选LLM 主要负责解释、有限裁决和信息不足时的追问,不再承担主裁决责任
第一版候选限制为 1 到 3 个,动作范围包括:
@@ -1732,15 +1746,15 @@ POST /active-schedule/previews/{preview_id}/ignore
飞书和 Web 路由:
```text
/schedule-adjust/{preview_id}
/assistant/{conversation_id}
```
页面打开流程:
```text
1. Web 路由解析 preview_id。
2. 前端调用 GET /active-schedule/previews/{preview_id}
3. 后端校验 preview 属于当前用户
1. Web 路由解析 conversation_id。
2. 前端先加载 conversation 历史,再按当前会话上下文拉取主动调度 preview。
3. 后端通过 `active_schedule_sessions` 校验当前会话是否还在主动调度管辖期
4. 返回详情 DTO。
5. 前端根据 can_confirm / expired / apply_status 展示确认、忽略或历史状态。
```
@@ -1879,8 +1893,8 @@ expires_at = generated_at + 1h
隔离原因:
1. 主动调度 preview 可能来自后台 worker没有 `conversation_id`。
2. 主动调度 preview 绑定 `trigger_id / preview_id / expires_at / apply_status`。
1. 主动调度 preview 只管预览内容本身,不直接承担 `conversation_id` 这类路由职责;会话由 `active_schedule_sessions` 单独承接
2. 主动调度 preview 绑定 `trigger_id / preview_id / expires_at / apply_status`,语义集中且便于审计
3. 会话排程预览是 Agent state 的派生视图,不适合承载后台通知和 apply 审计。
#### 7.3.11 错误处理与可观测
@@ -1909,7 +1923,7 @@ expires_at = generated_at + 1h
集成测试:
1. worker 写入 `active_schedule_previews` 后GET 详情能读取完整 before/after。
2. 飞书链接 `/schedule-adjust/{preview_id}` 能进入详情页并读取同一 preview。
2. 飞书链接 `/assistant/{conversation_id}` 能进入会话页并读取同一 preview。
3. confirm 原始候选成功,状态变为 `applied`。
4. confirm 拖动后的 `edited_changes` 成功,应用内容以 edited changes 为准。
5. preview 过期后 confirm 被拒绝。
@@ -2390,11 +2404,11 @@ trace_id
### 9.2 已拍板结论
1. 第一版飞书通知文案是否只需要固定模板?
- 已确认:不只用固定模板。既然主动调度链路已经调用 LLM通知文案优先由 LLM 生成简短 summary
- 已确认:固定模板作为 fallback只有 LLM 生成失败、超时或返回空内容时使用,避免通知链路因为文案生成失败而整体中断
- 已确认:第一版先不把 LLM summary 当作已实现分支。通知文案优先复用候选 / preview summary固定模板作为 fallback
- 已确认:后续如果接入 LLM summary provider也必须是可失败的增强分支不能影响通知链路本身
2. 通知是否必须包含跳转链接如果包含Web 端预览详情 URL 规则是什么?
- 已确认:必须包含跳转链接。
- 已确认URL 规则采用 `/schedule-adjust/{preview_id}`,每个主动调度 preview 对应一个唯一调整链接
- 已确认URL 规则采用现有助手会话路由 `/assistant/{conversation_id}`,每个主动调度会话在发通知前先绑定或预创建 `conversation_id`
3. 通知幂等键是否按 `preview_id`,还是按 `user_id + trigger_type + time_window`
- 已确认:按 `user_id + trigger_type + time_window` 聚合去重,不按 `preview_id`。
- 已确认MVP 语义是同一用户同一触发类型在同一时间窗口内只推一次飞书,避免短时间重复打扰;具体 time_window 长度在表结构与状态机阶段细化。
@@ -2465,7 +2479,7 @@ FeishuNotificationRequested
target_type
target_id
dedupe_key
target_url # /schedule-adjust/{preview_id}
target_url # /assistant/{conversation_id}
summary_text # LLM 已生成摘要,可为空
fallback_text
trace_id
@@ -2482,7 +2496,7 @@ aggregate_id = preview_id
校验规则:
1. `user_id / preview_id / target_url / dedupe_key` 必填。
2. `target_url` 必须是站内相对路径,例如 `/schedule-adjust/{preview_id}`,不允许 provider payload 携带任意外部跳转链接。
2. `target_url` 必须是站内相对路径,例如 `/assistant/{conversation_id}`,不允许 provider payload 携带任意外部跳转链接。
3. `summary_text` 可为空;为空时 handler 使用 fallback 文案。
4. payload 不直接复用 `active_schedule_previews` DB model。
@@ -2599,7 +2613,7 @@ notification:
说明:
1. `baseURL` 用于把 `/schedule-adjust/{preview_id}` 拼成飞书可点击链接。
1. `baseURL` 用于把 `/assistant/{conversation_id}` 拼成飞书可点击链接。
2. 本地和测试环境默认 `provider=mock`。
3. `notification.enabled=false` 时不调用 provider但仍可按需要写 `skipped` record 便于验证链路。
4. `dedupeWindow` 默认可先与 `important_urgent_task` 的 30 分钟触发去重窗口保持一致。
@@ -2625,8 +2639,8 @@ notification:
1. summary 为空:使用 fallback。
2. summary 过长:截断或使用 fallback避免飞书卡片超限。
3. summary 包含不允许的链接:去除链接或使用 fallback。
4. LLM summary 失败不能阻断通知投递。
5. `fallback_used=true` 必须记录到 `notification_records`,方便排查 LLM 文案质量。
4. summary 生成或校验失败不能阻断通知投递。
5. `fallback_used=true` 必须记录到 `notification_records`,方便排查通知文案质量。
#### 9.3.7 通知处理流程
@@ -2858,7 +2872,7 @@ notification_provider_latency_ms
人工验收:
1. 使用 mock provider 验证 dry-run 不发通知、正式 trigger 发通知记录。
2. 使用测试飞书 webhook 收到包含 `/schedule-adjust/{preview_id}` 的消息。
2. 使用测试飞书 webhook 收到包含 `/assistant/{conversation_id}` 的消息。
3. 模拟 provider 失败后能看到 failed / retry / sent 状态变化。
4. 30 分钟窗口内重复触发,不重复收到飞书。
@@ -3363,7 +3377,7 @@ backend/service/events/notification_feishu_requested.go
### 12.2 最终实施拍板
1. 主动调度相关表和状态机按 4.3 / 7.3 / 9.3 / 10.3 执行。
2. `tasks` 本轮新增 `estimated_sections`,默认 1MVP 允许 1~4。
2. `tasks` 本轮新增 `estimated_sections`,默认 1MVP 允许 1~4;模型层、普通创建入口和主动调度消费侧都已接上
3. `schedule_events` 本轮新增 `task_source_type / makeup_for_event_id / active_preview_id`。
4. `compress_with_next_dynamic_task` 第一轮关闭,不生成候选。
5. 飞书第一轮使用 mock / webhook不依赖用户 open_id 绑定。
@@ -3487,7 +3501,7 @@ backend/service/events/notification_feishu_requested.go
1. 主动调度预览新增独立持久化结构,建议命名为 `active_schedule_previews`。
2. 不复用 `agent_schedule_states` 作为主动调度预览主存储,原因:
- `agent_schedule_states` 强绑定 `conversation_id`,更适合会话内智能排程快照。
- 主动调度来自后台 worker可能没有会话上下文
- 主动调度来自后台 workerconversation 入口由 `active_schedule_sessions` 在通知前绑定,不塞进 preview 主表
- 主动调度预览需要绑定 `trigger_id / candidate_id / expires_at / apply_status / notification_status`,语义与会话快照不同。
3. 展示协议可以复用:
- 抽通用 `SchedulePreviewChangeItem` / before-after schema。
@@ -3523,30 +3537,26 @@ backend/service/events/notification_feishu_requested.go
### 12.12 用户确认入口与聊天增强预留
1. MVP 不走现有 Agent resume 协议,新增主动调度详情页与主动调度确认 API
2. 飞书通知包含 LLM 生成的简短摘要和详情页链接,默认进入
1. MVP 不再把主动调度做成独立详情页主入口,而是直接进入现有助手会话页,复用 `AssistantPanel.vue` 的历史、卡片和确认体验
2. 飞书通知在发送前由后端预创建或绑定 `conversation_id`,最终跳转链接使用现有路由
```text
/schedule-adjust/{preview_id}
/assistant/{conversation_id}
```
3. 详情页体验采用“助手卡片式”设计,但后端不依赖完整 Agent Chat
- 顶部展示助手解释文案。
- 中间展示日程前后对比卡片。
3. 会话页表现尽量不变,后端在 timeline 中注入主动调度消息和卡片
- 顶部仍然是助手解释文案。
- 中间仍然复用日程前后对比卡片。
- 展示触发原因、建议理由、风险和不调整后果。
- 支持用户拖动调整 after 方案。
- 支持确认应用、忽略 / 拒绝。
4. 拖动后的确认请求必须携带 `edited_changes`,后端重新校验,不信任前端坐标。
5. 确认 API 建议语义:
4. 拖动后的确认请求仍然必须携带 `edited_changes`,后端重新校验,不信任前端坐标。
5. 确认 API 仍然走主动调度自己的确认语义:
```text
POST /active-schedule/previews/:preview_id/confirm
```
请求包含 `candidate_id / action / edited_changes / idempotency_key`。
6. 后续增强可把同一个 `preview_id` 导入聊天页:
```text
/agent/chat?active_preview_id=xxx
```
聊天页加载同一份主动调度预览,由助手吐出解释消息和同一张日程卡片。
7. 聊天增强必须复用 `active_schedule_previews / preview_changes / confirm API`,不能另起一套确认和应用协议。
8. 若用户从详情页点击“和助手讨论”,再创建或绑定 `conversation_id`;主动调度预览本身的 `conversation_id` 保持可空。
6. 前端只需要一个很小的分支:当 timeline item 是主动调度业务卡片时,按钮动作走主动调度 confirm / discuss其它消息仍走正常聊天链路。
7. 主动调度和普通聊天共用同一个 `conversation_id` 历史,但路由管辖权仍由 `active_schedule_sessions` 控制,`waiting_user_reply / rerunning` 未释放前不进入普通 newAgent 自由聊天。
8. 聊天增强必须复用 `active_schedule_previews / preview_changes / confirm API`,不能另起一套确认和应用协议,也不能为了主动调度再建一套独立页面。
### 12.13 预览过期策略
@@ -3583,9 +3593,9 @@ backend/service/events/notification_feishu_requested.go
- 固定模板只作为 fallback用于 LLM 超时、失败、返回空内容或内容校验不过时。
2. 飞书通知必须包含跳转链接:
```text
/schedule-adjust/{preview_id}
/assistant/{conversation_id}
```
每个 `preview_id` 对应唯一详情 / 调整页面,用户从飞书点击后回系统查看并确认。
每个 `conversation_id` 对应一段已预创建的助手会话,用户从飞书点击后直接进入同一会话页查看并确认。
3. 通知幂等键按 `user_id + trigger_type + time_window` 聚合,而不是按 `preview_id`。
4. MVP 的去重含义是:同一用户、同一触发类型、同一时间窗口内只发一条飞书,避免主动调度在短时间内重复打扰用户。
5. 飞书 provider 第一版可以放在 backend worker 内,但必须同步落 `notification_records` 表。
@@ -3935,7 +3945,7 @@ ActiveScheduleTrigger
### 13.10 LLM 在选择题模式里的作用
后端给候选,并不代表 LLM 没有价值。后端负责合法性和硬约束LLM 负责软约束仲裁与表达
后端给候选,并不代表 LLM 没有价值但它的决策权要收窄。后端负责合法性、粗排和默认裁决LLM 负责解释、有限裁决和补全兜底
后端擅长:
@@ -3947,18 +3957,19 @@ ActiveScheduleTrigger
LLM 擅长:
1. 结合用户刚才语气判断是否疲劳
2. 在候选分数接近时,根据 memory 软偏好选更容易被接受的方案
3. 把结构化风险翻译成用户能理解的解释
4. ask_user 时问得更自然,不让用户觉得被系统打断
5. notify_only 时用提醒语气,而不是制造焦虑
1. 把结构化风险翻译成用户能理解的解释
2. 在候选非常接近、后端粗排已经给出多个合法方案时,做有限的软裁决
3. ask_user 时问得更自然,不让用户觉得被系统打断
4. notify_only 时用提醒语气,而不是制造焦虑
5. 在后端已经判断“信息不足”时,生成更合适的追问措辞
边界:
1. LLM 不判断候选是否合法。
2. LLM 不自由构造新候选。
3. LLM 只在 `decision.action=select_candidate` 时从候选里选
4. `close / ask_user / notify_only` 时LLM 只负责表达后端裁决理由
3. LLM 不负责主排序,后端粗排结果优先
4. LLM 只在 `decision.action=select_candidate` 时从候选里做有限选择
5. `close / ask_user / notify_only` 时LLM 主要负责表达与追问,不负责改写业务裁决。
一句话后端保证不出错LLM 负责更像人。
@@ -3980,7 +3991,7 @@ LLM 擅长:
1. 没有 issue -> `close`。
2. 有 issue但缺关键事实 -> `ask_user`。
3. 有 issue且有合法 candidates -> `select_candidate`
3. 有 issue且有合法 candidates -> 先由后端粗排,再由 LLM 在接近候选间做有限选择
4. 有 issue但没有合法 candidates
- 如果能通过一个明确问题继续推进 -> `ask_user`。
- 如果问用户也不能立刻推进,只是需要提醒 -> `notify_only`。
@@ -3989,7 +4000,7 @@ LLM 擅长:
1. `close`:重要且紧急 task 已经在 schedule 里,或任务已完成。
2. `ask_user`:用户说“刚才那个没做完”,但系统无法定位是哪条 schedule_event或容量不足需要问能否延后结束时间。
3. `select_candidate`:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。
3. `select_candidate`:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。若候选之间差异很小LLM 只负责在解释和偏好上做有限补充,不代替后端决策。
4. `notify_only`:有风险但没有安全可挪的任务,也没有一个明确问题能继续推进。
### 13.12 未完成补救的局部重排不是全量粗排
@@ -4100,27 +4111,27 @@ apply_error
### 13.16 确认入口为什么先做详情页,而不是直接聊天页
聊天页效果最好,但第一版直接做完整聊天会引入很多复杂度:
聊天页效果最好,但第一版直接把主动调度完全塞进自由聊天会引入很多复杂度:
1. 后台 preview 没有天然 `conversation_id`
2. 用户拖动卡片后,要同步到 Agent state 还是 active preview。
3. 用户一句“换晚点”是否重新跑 graph
4. 聊天 SSE、卡片状态、确认状态要保持一致。
5. notification 和 agent channel 容易混边界。
1. trigger / preview 发出时session 可能还没有预创建 conversation,需要先由后端补齐
2. 用户拖动卡片后,需要明确是改 conversation 历史,还是 active preview 的当前态
3. 用户一句“换晚点”到底是继续补信息,还是重新跑 graph需要 session 状态来裁决
4. 聊天 SSE、卡片状态、确认状态要保持一致,不能让前端自己猜路由归属
5. notification 和 agent channel 容易混边界,必须由后端先定谁在管这段对话
折中方案:
1. MVP 做主动调度详情页
2. UI 设计成助手卡片式:
1. 后端先创建或绑定 `conversation_id`,再把飞书链接发到现有 `/assistant/{conversation_id}` 路由
2. `active_schedule_sessions` 专门记录这段会话对主动调度流程意味着什么,不替代 conversation 表。
3. UI 仍然采用助手卡片式:
- 顶部助手解释。
- 中间日程对比卡片。
- 支持拖动 after。
- 支持确认 / 忽略。
3. 后端仍走 `active_schedule_previews` 和确认 API不依赖完整 Agent Chat
4. 后续可以通过 `/agent/chat?active_preview_id=xxx` 把同一份 preview 导入聊天页
5. 聊天增强必须复用同一套 preview / changes / confirm API。
4. 前端只做一个很小的 timeline 类型分支:主动调度卡片走主动调度按钮,普通消息仍走原来的聊天动作
5. 后端继续复用 `active_schedule_previews` 和确认 API不依赖完整 Agent Chat 去重新设计卡片协议
这样第一版稳定,后续聊天效果也能接上,不会重写链路
这样第一版仍然稳定,后续如果要进一步开放自由聊天,也只是在 session 释放后接回原来的聊天链路,不会重写整套入口
### 13.17 预览 1 小时过期的具体语义
@@ -4263,16 +4274,16 @@ none -> expired
第一版建议同一个 preview 只允许成功 apply 一次。`failed` 后是否允许换一个候选再次确认,先不作为 MVP 主路径;若要支持,应生成新的 `apply_id`,并明确旧失败记录如何保留,避免审计链路被覆盖。
### 13.20 飞书通知为什么需要 LLM 摘要、链接和记录表
### 13.20 飞书通知为什么需要摘要、链接和记录表
飞书第一版只做“提醒用户回系统确认”,不在飞书内应用日程,也不做复杂聊天。但它仍然是用户会感知到的主动打扰,因此要兼顾表达质量、跳转确定性和投递可观测。
通知文案:
1. 主动调度链路已经让 LLM 参与候选选择和解释,此时让 LLM 生成一段 summary 成本很低
2. LLM summary 比固定模板更能解释“为什么现在提醒你”,例如任务即将变紧急、刚才反馈未完成、后续日程被挤压等
3. summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定
4. 固定模板必须保留为 fallback避免 LLM 超时、失败、内容为空或内容校验不过时,整条通知链路直接断掉
1. 当前第一版先复用候选 / preview summary不把 LLM summary 当作通知链路的硬依赖
2. summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定
3. 固定模板必须保留为 fallback避免 summary 为空、过长、包含不允许内容或后续增强分支失败时,整条通知链路直接断掉
4. 后续如果补接 LLM summary provider它只能作为增强不应改变通知是否能够发出这一层级的可靠性
推荐 fallback 方向:
@@ -4283,15 +4294,15 @@ none -> expired
链接规则:
```text
/schedule-adjust/{preview_id}
/assistant/{conversation_id}
```
原因:
1. 每主动调度 preview 都有唯一 `preview_id`,天然适合作为详情页定位键
2. 用户从飞书点进来后,只进入系统详情页,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。
3. URL 不暴露 `candidate_id / apply_id`,因为用户进入详情页后仍可查看候选、拖动 after 方案并生成新的确认尝试。
4. 如果后续接入聊天增强,也应由详情页或聊天页读取同一个 `preview_id`,不能另起一套确认协议
1. 每主动调度通知在发出前都绑定一个 `conversation_id`,用户点进来后直接进入现有助手会话页
2. 用户从飞书点进来后,仍然只在系统内确认,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。
3. URL 不暴露 `candidate_id / apply_id`,因为用户进入会话页后仍可查看候选、拖动 after 方案并生成新的确认尝试。
4. `preview_id / trigger_id` 由 `active_schedule_sessions` 在后端解析,前端 URL 不长期承担业务状态拼装
通知幂等键按:
@@ -4321,7 +4332,7 @@ user_id
trigger_id
preview_id
dedupe_key # user_id + trigger_type + time_window
target_url # /schedule-adjust/{preview_id}
target_url # /assistant/{conversation_id}
summary_text
fallback_used
status # pending / sending / sent / failed / dead
@@ -4399,6 +4410,7 @@ POST /api/v1/notification/channels/feishu/test
"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",
@@ -4407,7 +4419,7 @@ POST /api/v1/notification/channels/feishu/test
"title": "SmartFlow 日程调整建议",
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
"action_text": "查看并确认调整",
"action_url": "https://smartflow.example.com/schedule-adjust/asp_xxx"
"action_url": "http://localhost:5173/assistant/conv_xxx"
},
"trace_id": "trace_xxx",
"sent_at": "2026-04-30T17:34:52+08:00"
@@ -4557,7 +4569,7 @@ MVP 里这些端口的 adapter 可以在 `backend` 内调用现有 service。若
### 14.1 验证目标
主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开 `/schedule-adjust/{preview_id}`、展示预览、提交确认即可;核心验证应覆盖:
主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开现有 `/assistant/{conversation_id}` 会话页、展示主动调度卡片、提交确认即可;核心验证应覆盖:
1. 触发是否正确task 到达 `urgency_threshold_at`、用户反馈未完成、API 测试触发都能进入统一链路。
2. 去重是否正确:同一触发不会重复生成预览、重复通知或重复 apply。
@@ -4648,8 +4660,8 @@ tasks
| --- | --- |
| preview 生成成功 | 发布 `notification.feishu.requested` 或等价 outbox 事件 |
| notification handler 收到事件 | 先写 `notification_records`,再调用 provider |
| LLM summary 生成成功 | 飞书文案使用 summary包含 `/schedule-adjust/{preview_id}` |
| LLM summary 失败 / 超时 / 空内容 | 使用固定 fallback 文案,通知链路不中断 |
| 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`,不再自动重试 |
@@ -4661,7 +4673,7 @@ tasks
| 动作 | 预期 |
| --- | --- |
| 用户打开 `/schedule-adjust/{preview_id}` | 能读取 preview 详情;如果已过期,页面显示不可确认 |
| 用户打开 `/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` 原子节次 |
@@ -4686,7 +4698,7 @@ tasks
| 动作 | 预期 |
| --- | --- |
| 构造 LLM 选择超时 | 使用后端 fallback 决策或标记失败trigger 状态可排障 |
| 构造 LLM summary 超时 | 使用固定通知模板preview 仍可通知 |
| 构造 summary 为空或校验失败 | 使用固定通知模板preview 仍可通知 |
| 构造 DB 写 preview 失败 | trigger 标记 failed不发布 notification |
| 构造 notification provider 失败 | preview 保留notification record 进入 failed / retry不影响 preview 查询 |
| 构造 apply 写 schedule 中途失败 | 事务回滚,`schedule_events / schedules` 不产生半写状态 |
@@ -4738,7 +4750,7 @@ tasks
- retry 后成功:同一条 record 变为 `sent`,不新建重复通知。
- 真实飞书 webhook / open_id 受限时,必须记录为“需要用户验收”,不能用 mock 结果冒充真实 provider 验收。
6. 手工验收:
- 使用 `/schedule-adjust/{preview_id}` 打开详情页。
- 使用 `/assistant/{conversation_id}` 打开会话页。
- 拖动 after 方案并确认。
- 查看飞书测试消息跳转。