Version: 0.6.3.dev.260316

 feat(task): 新增四象限任务懒触发自动平移链路(读时派生 + Outbox 异步收敛)

- 🧩 为 `Task` 模型新增 `urgency_threshold_at` 字段,并补充复合索引 `user_id,is_completed,urgency_threshold_at,priority` 及相关事件 payload
- ♻️ 重构 `TaskService.GetUserTasks`:调整为“缓存/DB 读取原始任务 -> 读时派生优先级(`2 -> 1`、`4 -> 3`)-> 通过 `SETNX` 去重后发布平移事件”的处理链路
- 🚚 新增任务平移事件链路:
  - `service/events/task_urgency_promote.go`
  - 事件类型:`task.urgency.promote.requested`
  - 支持 `Publish` + `RegisterHandler` + `ConsumeAndMarkConsumed` 的事务化消费流程
- 🛡️ 为 `TaskDAO` 新增幂等批量更新能力 `PromoteTaskUrgencyByIDs`,采用条件更新策略,仅对“达到阈值且未完成”的任务生效
- 🔌 更新启动接线逻辑:注册任务平移 handler,并将 `eventBus` 注入 `NewTaskService`
- 🧹 修复并升级任务缓存层,统一为 `[]model.Task` 原始模型缓存;同时清理误导性注释,并补充详细中文步骤化注释
- 🔗 打通 `QuickNote` 链路中的 `urgency_threshold_at` 透传与校验,覆盖 `state` / `tool` / `nodes` / `prompt` / `agent_quick_note` 全链路
- 💾 写库时补充落库 `task.UrgencyThresholdAt`
- 📝 新增功能决策记录

之前画的饼正在一块块填上~这一块饼填上之后,第一批开发的后端部分基本已经搞定了。后面的功能全都是天马行空的拓展功能。
This commit is contained in:
Losita
2026-03-16 20:33:33 +08:00
parent daeff0afab
commit 84371e2ff8
12 changed files with 792 additions and 115 deletions

View File

@@ -24,17 +24,19 @@ type quickNoteIntentModelOutput struct {
}
type quickNotePriorityModelOutput struct {
PriorityGroup int `json:"priority_group"`
Reason string `json:"reason"`
PriorityGroup int `json:"priority_group"`
Reason string `json:"reason"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
}
// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。
type quickNotePlanModelOutput struct {
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
PriorityGroup int `json:"priority_group"`
PriorityReason string `json:"priority_reason"`
Banter string `json:"banter"`
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
PriorityGroup int `json:"priority_group"`
PriorityReason string `json:"priority_reason"`
Banter string `json:"banter"`
}
// runQuickNoteIntentNode 负责“意图识别 + 聚合规划 + 时间校验”。
@@ -69,6 +71,9 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
st.ExtractedDeadline = plan.Deadline
}
st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
if plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
@@ -229,8 +234,15 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
请仅输出 JSON不要 markdown不要解释
{
"priority_group": 1|2|3|4,
"reason": "简短理由"
}`,
"reason": "简短理由",
"urgency_threshold_at": "yyyy-MM-dd HH:mm 或空字符串"
}
额外约束:
1) urgency_threshold_at 表示“何时从不紧急象限自动平移到紧急象限”;
2) 若该任务不需要自动平移,可输出空字符串;
3) 若任务已在紧急象限priority_group=1 或 3优先输出空字符串
4) 若输出非空时间,必须是绝对时间,且不晚于归一化截止时间(若有)。`,
st.RequestNowText,
st.ExtractedTitle,
st.UserInput,
@@ -256,6 +268,12 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
@@ -283,12 +301,17 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
}
// 3. 工具参数序列化失败视作一次失败尝试,交由重试分支处理。
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
@@ -444,12 +467,14 @@ func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
PriorityGroup int
PriorityReason string
Banter string
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。
@@ -468,6 +493,7 @@ func planQuickNoteInSingleCall(
{
"title": string,
"deadline_at": string,
"urgency_threshold_at": string,
"priority_group": 1|2|3|4,
"priority_reason": string,
"banter": string
@@ -475,8 +501,10 @@ func planQuickNoteInSingleCall(
约束:
1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
2) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间
3) banter 只允许一句中文不超过30字不得改动任务事实。`,
2) urgency_threshold_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串
3) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
4) 若任务不需要自动平移,可让 urgency_threshold_at 为空;
5) banter 只允许一句中文不超过30字不得改动任务事实。`,
nowText,
strings.TrimSpace(userInput),
)
@@ -493,11 +521,12 @@ func planQuickNoteInSingleCall(
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
}
// 4. banter 只保留首行,防止模型输出多行破坏最终回复风格。
@@ -513,6 +542,12 @@ func planQuickNoteInSingleCall(
result.Deadline = deadline
}
}
// 6. 对 urgency_threshold_at 做本地二次校验,并与 deadline 做上界约束。
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
@@ -559,6 +594,26 @@ func extractJSONObject(text string) string {
return text[start : end+1]
}
// normalizeUrgencyThreshold 归一化“紧急分界线时间”。
//
// 规则:
// 1. 分界线为空时直接返回空;
// 2. 存在 deadline 且分界线晚于 deadline 时,收敛到 deadline
// 3. 其余情况保持原值。
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *QuickNoteState) int {
// 兜底规则:
// 1) 有截止时间且 <=48h重要且紧急

View File

@@ -24,24 +24,26 @@ const (
禁止输出任何其他内容。`
// QuickNotePlanPrompt 用于“单请求聚合规划”:
// - 在一次调用内完成标题抽取、时间归一化、优先级评估、跟进句生成;
// - 在一次调用内完成标题抽取、时间归一化、紧急分界线评估、优先级评估、跟进句生成;
// - 主要用于路由已明确命中 quick_note 的场景,以降低串行 LLM 调用次数。
// 额外说明:
// 1) 强制 JSON 输出,减少后端解析分支复杂度;
// 2) deadline_at 统一分钟级,方便直接映射到数据库时间字段;
// 2) deadline_at / urgency_threshold_at 统一分钟级,方便直接映射到数据库时间字段;
// 3) banter 与事实分离,避免润色文案污染结构化字段。
QuickNotePlanPrompt = `你是 SmartFlow 的任务聚合规划器。
你将基于用户输入,一次性输出任务规划结果,供后端直接写库。
必须完成以下件事:
必须完成以下件事:
1) 提取任务标题 title简洁明确
2) 归一化截止时间 deadline_at若存在时间线索必须输出绝对时间
3) 评估优先级 priority_group1~4)。
4) 生成一句轻松跟进句 banter不超过30字)。
3) 评估紧急分界时间 urgency_threshold_at何时从不紧急象限自动平移到紧急象限可为空)。
4) 评估优先级 priority_group1~4)。
5) 生成一句轻松跟进句 banter不超过30字
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- deadline_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- urgency_threshold_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- priority_group 仅允许 1|2|3|4。
- banter 不得新增或修改任务事实(任务名、时间、优先级)。`
@@ -58,13 +60,14 @@ const (
- 若不是,请明确返回“非随口记意图”。
- 不要声称已经写入数据库。`
// QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级。
// QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级,并评估紧急分界线
// 输出会直接映射到 tasks.priority1~4因此要求结果必须可解释。
// 这里强调“理由必须可解释”,是为了后续日志复盘时能看懂模型为何这么判。
QuickNotePriorityPrompt = `你是 SmartFlow 的任务优先级评估器。
根据任务内容、时间约束和执行成本,输出优先级 priority_group
1=重要且紧急2=重要不紧急3=简单不重要4=不简单不重要。
请给出简短理由,理由必须可解释。`
请给出简短理由,理由必须可解释。
若你认为该任务需要后续自动平移,请额外输出 urgency_threshold_at绝对时间yyyy-MM-dd HH:mm否则输出空字符串。`
// QuickNoteReplyBanterPrompt 用于随口记成功后的“轻松跟进句”生成。
// 约束重点:

View File

@@ -75,7 +75,14 @@ type QuickNoteState struct {
ExtractedTitle string
ExtractedDeadline *time.Time
ExtractedDeadlineText string
ExtractedPriority int
// ExtractedUrgencyThreshold 表示“进入紧急象限的分界时间”。
//
// 语义说明:
// 1. 该时间由模型规划后给出,并在后端做解析校验;
// 2. 到达该时间后,任务可在“读时派生 + 异步落库”链路中被自动平移;
// 3. 为空表示该任务不参与自动平移。
ExtractedUrgencyThreshold *time.Time
ExtractedPriority int
// ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。
// 该字段非空时,最终回复阶段可直接复用,避免再触发一次独立润色模型调用。
ExtractedBanter string

View File

@@ -96,14 +96,17 @@ type QuickNoteCreateTaskRequest struct {
Title string
PriorityGroup int
DeadlineAt *time.Time
// UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。
type QuickNoteCreateTaskResult struct {
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。
@@ -114,6 +117,9 @@ type QuickNoteCreateTaskToolInput struct {
PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4不简单不重要)"`
// DeadlineAt 支持绝对时间与常见相对时间(如明天/后天/下周一/今晚),内部会归一化为绝对时间。
DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"`
// UrgencyThresholdAt 表示“何时从不紧急象限自动平移到紧急象限”。
// 允许为空;非空时会走同样的时间解析与合法性校验。
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间支持与deadline_at相同格式"`
}
// QuickNoteCreateTaskToolOutput 是返回给大模型的工具结果。
@@ -162,6 +168,10 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
if err != nil {
return nil, err
}
urgencyThresholdAt, err := parseOptionalDeadline(input.UrgencyThresholdAt)
if err != nil {
return nil, err
}
// 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。
userID, err := deps.ResolveUserID(ctx)
@@ -174,10 +184,11 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
// 2.4 走业务层写库。
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UrgencyThresholdAt: urgencyThresholdAt,
})
if err != nil {
return nil, err