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 }