Files
smartmate/backend/agent/quicknote/nodes.go
Losita 84371e2ff8 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`
- 📝 新增功能决策记录

之前画的饼正在一块块填上~这一块饼填上之后,第一批开发的后端部分基本已经搞定了。后面的功能全都是天马行空的拓展功能。
2026-03-16 20:33:33 +08:00

671 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package quicknote
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
type quickNoteIntentModelOutput struct {
IsQuickNote bool `json:"is_quick_note"`
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
Reason string `json:"reason"`
}
type quickNotePriorityModelOutput struct {
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"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
PriorityGroup int `json:"priority_group"`
PriorityReason string `json:"priority_reason"`
Banter string `json:"banter"`
}
// runQuickNoteIntentNode 负责“意图识别 + 聚合规划 + 时间校验”。
// 说明:
// 1) trustRoute 命中时,直接走单请求聚合规划,跳过二次意图识别;
// 2) 无论是否走快路径,最终都要走本地时间硬校验,防止脏时间落库。
func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
// 0. 基础防御state 为空直接返回错误,避免后续节点空指针。
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 如果上游路由已高置信命中 quick_note则走“单请求聚合快路径”。
if input.SkipIntentVerification {
emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
// 1.1 一次调用里尽量拿齐 title/deadline/priority/banter减少串行模型开销。
emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
// 1.2 聚合规划失败不终止链路,改为后续本地兜底。
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
// 1.3 仅在字段有效时回填,避免无效值污染状态。
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
if plan.Deadline != nil {
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)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.4 如果模型没给标题,基于原句做本地标题提取兜底。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.5 无论是否聚合成功,都要进行本地时间硬校验,防止脏时间写库。
emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
// 用户原句能解析出时间时,以原句解析结果为准(更贴近真实输入)。
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先让模型做意图识别 + 初步抽取。
emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"is_quick_note": boolean,
"title": string,
"deadline_at": string,
"reason": string
}
字段约束:
1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。
2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。
3) 如果用户没有提及时间deadline_at 输出空字符串。`,
st.RequestNowText,
st.UserInput,
)
// 2.1 模型调用失败时,保守回退普通聊天,避免误写任务。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt)
if callErr != nil {
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
// 2.2 解析失败同样回退普通聊天,保证稳定性优先。
parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw)
if parseErr != nil {
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
// 非随口记:后续通过分支直接退出 graph。
return st, nil
}
// 2.3 处理标题字段:为空时回退到用户原句。
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// Step A优先尝试解析模型抽取出来的 deadline。
// 这样可利用模型“结构化理解”能力先拿一次候选时间。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// Step B基于用户原句执行“本地时间解析 + 合法性校验”。
// 本地校验是最终硬门槛,确保“用户给错时间不会被静默写成 NULL”。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if st.ExtractedDeadline == nil && userDeadline != nil {
// 当模型未提取出时间,但原句能解析时,补写时间结果。
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// runQuickNotePriorityNode 负责“优先级评估”。
// 说明:
// 1) 聚合规划已给出合法优先级时直接复用;
// 2) 快路径下缺失优先级时直接本地兜底,避免额外模型调用;
// 3) 其余场景走独立评估模型,失败再兜底。
func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
// 1. 非随口记或时间校验失败时,不做优先级评估。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 已有合法优先级则直接复用,避免重复调用模型。
if IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 3. 快路径下若缺失优先级,直接本地兜底,追求低延迟。
if input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
// 4. 常规路径才调用独立优先级模型。
emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
请对以下任务评估优先级:
- 任务标题:%s
- 用户原始输入:%s
- 时间线索原文:%s
- 归一化截止时间:%s
请仅输出 JSON不要 markdown不要解释
{
"priority_group": 1|2|3|4,
"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,
deadlineClue,
deadlineText,
)
// 4.1 调用失败:使用本地兜底,不中断主链路。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
// 4.2 解析失败或非法值:同样兜底。
parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw)
if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
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
}
// runQuickNotePersistNodeInternal 负责“写库工具调用 + 重试态回填”。
func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, createTaskTool tool.InvokableTool, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
_ = input // 保留参数形状,后续若需要基于输入开关扩展可直接使用。
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
// 1. 非随口记或时间非法时不允许落库。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 准备工具入参:优先使用已评估优先级,缺失则兜底。
emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
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,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
// 4. 调用写库工具。
rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
// 5. 工具返回解析失败同样按“可重试错误”处理。
toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
// 成功判定硬门槛:必须拿到有效 task_id防止“假成功”。
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 6. 写库成功后回填状态,并准备最终回复内容。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
// selectQuickNoteNextAfterIntent 根据意图与时间校验结果决定 intent 后分支。
func selectQuickNoteNextAfterIntent(st *QuickNoteState) string {
// 1) 非随口记 -> exit
// 2) 时间校验失败 -> exit
// 3) 其余 -> priority 节点。
if st == nil || !st.IsQuickNoteIntent {
return quickNoteGraphNodeExit
}
if strings.TrimSpace(st.DeadlineValidationError) != "" {
return quickNoteGraphNodeExit
}
return quickNoteGraphNodeRank
}
// selectQuickNoteNextAfterPersist 根据持久化状态决定 persist 后分支。
func selectQuickNoteNextAfterPersist(st *QuickNoteState) string {
// 分支规则:
// 1) state=nil防御式结束
// 2) 已持久化:结束;
// 3) 可重试:回到 persist 重试;
// 4) 不可重试:写失败文案并结束。
if st == nil {
return compose.END
}
if st.Persisted {
return compose.END
}
if st.CanRetryTool() {
return quickNoteGraphNodePersist
}
if strings.TrimSpace(st.AssistantReply) == "" {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
}
return compose.END
}
func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
// 1. 校验工具包有效性。
if bundle == nil {
return nil, errors.New("tool bundle is nil")
}
if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
return nil, errors.New("tool bundle is empty")
}
// 2. 通过 ToolInfo 名称定位并拿到同索引的 Tool 实例。
for idx, info := range bundle.ToolInfos {
if info == nil || info.Name != name {
continue
}
invokable, ok := bundle.Tools[idx].(tool.InvokableTool)
if !ok {
return nil, fmt.Errorf("tool %s is not invokable", name)
}
return invokable, nil
}
return nil, fmt.Errorf("tool %s not found", name)
}
func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
// 默认 JSON 输出场景 token 足够小,使用 256 作为保守上限。
return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256)
}
func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
// 1. 构造 system + user 两段消息。
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
// 2. 统一关闭 thinking降低额外延迟并用温度 0 提升结构化稳定性。
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
}
if maxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
// 3. 调模型并对空响应做防御校验。
resp, err := chatModel.Generate(ctx, messages, opts...)
if err != nil {
return "", err
}
if resp == nil {
return "", errors.New("模型返回为空")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", errors.New("模型返回内容为空")
}
return content, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。
func planQuickNoteInSingleCall(
ctx context.Context,
chatModel *ark.ChatModel,
nowText string,
now time.Time,
userInput string,
) (*quickNotePlannedResult, error) {
// 1. 构造聚合 prompt一次返回所有结构化字段减少多次 LLM 往返。
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"title": string,
"deadline_at": string,
"urgency_threshold_at": string,
"priority_group": 1|2|3|4,
"priority_reason": string,
"banter": string
}
约束:
1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
2) urgency_threshold_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
3) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
4) 若任务不需要自动平移,可让 urgency_threshold_at 为空;
5) banter 只允许一句中文不超过30字不得改动任务事实。`,
nowText,
strings.TrimSpace(userInput),
)
// 2. 控制 maxTokens避免模型冗长输出导致延迟上升。
raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220)
if err != nil {
return nil, err
}
// 3. 解析模型输出 JSON。
parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw)
if parseErr != nil {
return nil, parseErr
}
result := &quickNotePlannedResult{
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 只保留首行,防止模型输出多行破坏最终回复风格。
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
// 5. 对 deadline 做本地二次校验,确保可落库。
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
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
}
func parseJSONPayload[T any](raw string) (*T, error) {
// 1. 空字符串直接失败。
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("empty response")
}
// 2. 兼容 ```json ... ``` 包裹输出。
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
}
// 3. 先尝试整体反序列化(最快路径)。
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 4. 若模型附带额外文本,则提取最外层 JSON 对象再解析。
obj := extractJSONObject(clean)
if obj == "" {
return nil, fmt.Errorf("no json object found in: %s", clean)
}
if err := json.Unmarshal([]byte(obj), &out); err != nil {
return nil, err
}
return &out, nil
}
func extractJSONObject(text string) string {
// 简化提取策略:取首个“{”到最后“}”的片段。
// 对当前 prompt 场景足够稳定,且实现成本低。
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start == -1 || end == -1 || end <= start {
return ""
}
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重要且紧急
// 2) 有截止时间但较远:重要不紧急;
// 3) 无截止时间:简单不重要。
if st == nil {
return QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return QuickNotePriorityImportantUrgent
}
return QuickNotePriorityImportantNotUrgent
}
return QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
// 1. 先清理空白。
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
// 2. 去掉常见指令前缀,保留核心任务语义。
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
// 3. 截断“记得/到时候”等尾部提醒语,避免标题过长。
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
// 4. 收尾清理标点;若清理后为空则回退原句。
text = strings.Trim(text, ",。.!; ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}