Version: 0.7.5.dev.260324

🐛 fix(agent/schedulerefine): 修复复合微调分支链路问题,并将 MinContextSwitch 重构为固定坑位重排语义

- 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题
-  保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果
- 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch
- 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效
- ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变”
- 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突
- 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题
- 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败

📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程

♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent

- 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构
- 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责
- 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象
- 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力
- 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖
- 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路
- ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法
- 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件
- 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定
- 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档

♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt
This commit is contained in:
LoveLosita
2026-03-24 21:35:22 +08:00
parent e6941f98f2
commit f4ef6fb256
55 changed files with 5492 additions and 235 deletions

View File

@@ -0,0 +1,132 @@
package agentnode
import (
"context"
"errors"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
)
const (
// QuickNoteGraphNodeIntent 是随口记图里的“意图识别”节点名。
// 这里把节点名下沉到 node 层,是为了让:
// 1. 节点自己的分支方法可以直接返回目标节点名;
// 2. graph 层只负责连线,不需要反向暴露常量给 node 层;
// 3. 后续若节点改名,只需要在这里统一收口。
QuickNoteGraphNodeIntent = "quick_note_intent"
// QuickNoteGraphNodeRank 是随口记图里的“优先级评估”节点名。
QuickNoteGraphNodeRank = "quick_note_priority"
// QuickNoteGraphNodePersist 是随口记图里的“持久化写库”节点名。
QuickNoteGraphNodePersist = "quick_note_persist"
// QuickNoteGraphNodeExit 是随口记图里的“提前退出”节点名。
QuickNoteGraphNodeExit = "quick_note_exit"
)
// QuickNoteGraphRunInput 描述一次“随口记图运行”所需的请求级依赖。
//
// 职责边界:
// 1. Model当前请求实际使用的聊天模型
// 2. State本次图运行共享的状态对象
// 3. Deps工具层依赖例如解析 user_id、执行写库
// 4. SkipIntentVerification若上游路由已高置信命中可跳过二次意图判断
// 5. EmitStage向外层推送阶段消息的可选回调。
//
// 不负责什么:
// 1. 不负责真正的 graph 连线;
// 2. 不负责工具注册与提取;
// 3. 不负责节点内部业务流转。
type QuickNoteGraphRunInput struct {
Model *ark.ChatModel
State *agentmodel.QuickNoteState
Deps QuickNoteToolDeps
SkipIntentVerification bool
EmitStage func(stage, detail string)
}
// QuickNoteNodes 是“随口记”节点容器。
//
// 设计目的:
// 1. 把“请求级依赖”收口到 node 层,而不是继续堆在 graph 层;
// 2. 让 graph 层直接挂 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法;
// 3. 这样 graph 文件就只负责画图,不再负责依赖转接。
//
// 职责边界:
// 1. 负责提供可直接挂载到 graph 的节点方法;
// 2. 负责在节点执行时读取本次请求的 input / tool / stage emitter
// 3. 不负责 graph 编译与运行,也不负责 service 层收尾持久化。
type QuickNoteNodes struct {
input QuickNoteGraphRunInput
createTaskTool tool.InvokableTool
emitStage func(stage, detail string)
}
// NewQuickNoteNodes 创建随口记节点容器。
//
// 说明:
// 1. 这里做的是“节点依赖注入”,不是 graph 连线;
// 2. emitStage 允许为空,内部会补成 no-op避免节点里反复判空
// 3. createTaskTool 为 persist 节点的硬依赖,缺失时直接报错,避免跑到写库节点再失败。
func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) {
if createTaskTool == nil {
return nil, errors.New("quick note nodes: createTaskTool is nil")
}
emitStage := input.EmitStage
if emitStage == nil {
emitStage = func(stage, detail string) {}
}
return &QuickNoteNodes{
input: input,
createTaskTool: createTaskTool,
emitStage: emitStage,
}, nil
}
// Exit 是图里的显式退出节点。
//
// 职责边界:
// 1. 只负责把当前 state 原样透传到 END
// 2. 不负责追加业务逻辑;
// 3. 保留这个节点,是为了后续若要补统一埋点、日志、收尾逻辑时有稳定挂载点。
func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
_ = ctx
return st, nil
}
// NextAfterIntent 负责根据意图识别结果决定 intent 后的分支走向。
func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) {
_ = ctx
if st == nil || !st.IsQuickNoteIntent {
return QuickNoteGraphNodeExit, nil
}
if st.DeadlineValidationError != "" {
return QuickNoteGraphNodeExit, nil
}
return QuickNoteGraphNodeRank, nil
}
// NextAfterPersist 负责根据持久化结果决定 persist 后的分支走向。
func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) {
_ = ctx
if st == nil {
return compose.END, nil
}
if st.Persisted {
return compose.END, nil
}
if st.CanRetryTool() {
return QuickNoteGraphNodePersist, nil
}
if st.AssistantReply == "" {
// 1. 重试次数耗尽且上游没有明确失败文案时,在这里补一条兜底回复;
// 2. 这样可以保证图结束后 service 层一定能拿到稳定可展示的失败信息;
// 3. 不在 graph 层处理,是因为这属于节点业务状态修正。
st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。"
}
return compose.END, nil
}

View File

@@ -0,0 +1,395 @@
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// Intent 负责“意图识别 + 聚合规划 + 时间校验”。
//
// 职责边界:
// 1. 负责判断本次请求是否属于随口记;
// 2. 负责把模型规划结果回填到 state
// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL
// 4. 不负责真正写库。
func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 若上游路由已经高置信命中 quick_note则直接进入单次聚合规划。
// 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内
// 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。
if n.input.SkipIntentVerification {
n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
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 agentmodel.IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.4 最后一定要做一轮本地时间硬校验。
// 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间;
// 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。
n.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点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先做一次意图识别,再做本地时间硬校验。
n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput)
if callErr != nil {
// 2.1 这里不直接返回 error而是把它视为“本次未能确认是 quick note”交给上层回退普通聊天。
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
return st, nil
}
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// 2.2 先尝试吃模型返回的 deadline_at用于减少后续重复推理。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// 2.3 再强制对用户原句做一次时间线索校验。
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点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
// 2.4 若模型没提到 deadline但用户原句能解析出来则以用户原句为准补齐。
if st.ExtractedDeadline == nil && userDeadline != nil {
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// Priority 负责“优先级评估”。
//
// 职责边界:
// 1. 负责在 intent 节点之后补齐 priority_group
// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型;
// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走;
// 4. 不负责写库。
func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。
if agentmodel.IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 2. 单请求聚合路径若没有给出合法 priority则直接走本地兜底优先保证低时延。
if n.input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
if parsed == nil || !agentmodel.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
}
// Persist 负责“调工具写库 + 有限次重试状态回填”。
//
// 职责边界:
// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参;
// 2. 负责调用 createTaskTool 执行真正写库;
// 3. 负责把成功/失败结果回填到 state供后续分支与回复使用
// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。
func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
n.emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !agentmodel.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)
}
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 = "抱歉,记录任务时参数处理失败,请稍后重试。"
n.emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
n.emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
n.emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 1. 只有拿到有效 task_id才视为真正写入成功
// 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, 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) {
parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput)
if err != nil {
return nil, err
}
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),
}
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
}
}
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
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 *agentmodel.QuickNoteState) int {
if st == nil {
return agentmodel.QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
}
return agentmodel.QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
text = strings.Trim(text, ",。?! ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}

View File

@@ -0,0 +1,723 @@
package agentnode
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的标准名称。
// 该名称会直接暴露给大模型,因此建议保持稳定,避免后续提示词和历史上下文失配。
ToolNameQuickNoteCreateTask = "quick_note_create_task"
// ToolDescQuickNoteCreateTask 是工具的简要职责说明。
ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级"
)
var (
// quickNoteDeadlineLayouts 是“绝对时间”白名单格式。
// 只要命中任意一个 layout就会被归一化为分钟级时间并进入写库流程。
quickNoteDeadlineLayouts = []string{
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04:05",
"2006/01/02 15:04",
"2006.01.02 15:04:05",
"2006.01.02 15:04",
"2006-01-02",
"2006/01/02",
"2006.01.02",
}
quickNoteDateOnlyLayouts = map[string]struct{}{
"2006-01-02": {},
"2006/01/02": {},
"2006.01.02": {},
}
// 正则区:
// 1) 用于解析明确时间表达;
// 2) 用于“是否存在时间线索”的判定(即使格式错误,也会触发校验失败而非静默忽略)。
quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[:]\s*(\d{1,2})`)
quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
quickNoteRelativeTokens = []string{
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
}
)
// QuickNoteToolDeps 描述“随口记工具包”需要的外部依赖。
// 这里采用函数注入的方式,避免 agent 包和 service/dao 强耦合,后续更容易演进为 mock 测试或多实现切换。
type QuickNoteToolDeps struct {
// ResolveUserID 从上下文中解析当前登录用户 ID。
ResolveUserID func(ctx context.Context) (int, error)
// CreateTask 执行真实写库动作。
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
}
func (d QuickNoteToolDeps) Validate() error {
// 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
// 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
return nil
}
// QuickNoteToolBundle 是随口记工具集合的打包结果。
// - Tools: 给 ToolsNode 使用
// - ToolInfos: 给 ChatModel 绑定工具 schema 使用
// 两者分开返回,可以适配你后面用 chain、graph、react 的不同挂载姿势。
type QuickNoteToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// QuickNoteCreateTaskRequest 是工具层到业务层的内部请求结构。
// 与模型输入解耦,避免模型字段变化直接影响业务签名。
type QuickNoteCreateTaskRequest struct {
UserID int
Title string
PriorityGroup int
DeadlineAt *time.Time
// UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。
type QuickNoteCreateTaskResult struct {
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。
// 注意user_id 不对模型暴露,统一从鉴权上下文提取,避免越权写入。
type QuickNoteCreateTaskToolInput struct {
Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"`
// PriorityGroup 使用 1~4和后端 tasks.priority 保持一致。
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 是返回给大模型的工具结果。
// 该结构可直接给模型用于“向用户解释已记录到哪个优先级”。
type QuickNoteCreateTaskToolOutput struct {
TaskID int `json:"task_id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
DeadlineAt string `json:"deadline_at,omitempty"`
Message string `json:"message"`
}
// BuildQuickNoteToolBundle 构建“AI随口记”工具包。
// 这是 agent 目录给上层编排层chain/graph/react提供的统一入口。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
// 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。
if err := deps.Validate(); err != nil {
return nil, err
}
// 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。
// 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
// 2.1 防御式检查:工具调用参数不能为 nil。
if input == nil {
return nil, errors.New("工具参数不能为空")
}
// 2.2 标题与优先级是写库硬条件,必须先校验。
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
}
if !agentmodel.IsValidTaskPriority(input.PriorityGroup) {
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
}
// 这里对 deadline_at 做“强校验”:
// - 空值允许(代表没有截止时间);
// - 非空但无法解析直接报错,避免把有问题的时间静默写成 NULL。
deadline, err := parseOptionalDeadline(input.DeadlineAt)
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)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
}
if userID <= 0 {
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
// 2.4 走业务层写库。
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UrgencyThresholdAt: urgencyThresholdAt,
})
if err != nil {
return nil, err
}
if result == nil || result.TaskID <= 0 {
return nil, errors.New("写入任务后返回结果异常")
}
// 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
}
finalPriority := input.PriorityGroup
if agentmodel.IsValidTaskPriority(result.PriorityGroup) {
finalPriority = result.PriorityGroup
}
// 2.6 截止时间输出统一为 RFC3339便于跨系统传输与调试。
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
} else if deadline != nil {
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
}
// 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
PriorityGroup: finalPriority,
PriorityLabel: agentmodel.PriorityLabelCN(finalPriority),
DeadlineAt: deadlineStr,
Message: fmt.Sprintf("已记录:%s%s", finalTitle, agentmodel.PriorityLabelCN(finalPriority)),
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
// 3. Tools 给执行节点使用ToolInfos 给模型注册 schema 使用,二者都要返回。
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &QuickNoteToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
// 按工具列表顺序提取 ToolInfo确保“tools[idx] <-> infos[idx]”一一对应。
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, t := range tools {
info, err := t.Info(ctx)
if err != nil {
return nil, fmt.Errorf("读取工具信息失败: %w", err)
}
infos = append(infos, info)
}
return infos, nil
}
// GetInvokableToolByName 通过工具名提取可执行工具实例。
func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
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")
}
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)
}
// parseOptionalDeadline 解析工具输入中的可选截止时间。
// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at就必须能被解析。
func parseOptionalDeadline(raw string) (*time.Time, error) {
// 1. 先做标点与空白归一化,避免中文输入噪声影响解析。
value := normalizeDeadlineInput(raw)
if value == "" {
// 2. 空字符串合法,表示任务无截止时间。
return nil, nil
}
// 3. 统一按“严格模式”解析:给了时间就必须成功解析。
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
// 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
// 场景:模型已给出 deadline_at需要基于同一 requestNow 再次硬校验。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, _, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
return nil, err
}
if deadline == nil {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。
// 返回值说明:
// - deadline != nil成功解析出时间
// - hasHint=false 且 err=nil文本里没有明显时间线索应视为“用户没给时间”
// - hasHint=true 且 err!=nil用户给了时间但格式非法应提示用户修正不应落库。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
// 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
// 有时间线索 + 解析失败:上层应明确提示用户改时间格式。
return nil, true, err
}
// 无明显时间线索:按“未提供时间”处理。
return nil, false, nil
}
if deadline == nil {
if hasHint {
return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return nil, false, nil
}
return deadline, true, nil
}
// parseOptionalDeadlineFromText 是内部通用解析器。
// 解析顺序:
// 1) 绝对时间(明确年月日时分);
// 2) 相对时间(明天/下周一/今晚);
// 3) 若识别到时间线索但仍失败,返回 hasHint=true + error交给上层决定是否拦截。
func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
if strings.TrimSpace(value) == "" {
return nil, false, nil
}
// 1. 统一时区与时间基准,保证相对时间可重复计算。
loc := quickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
// 2. 先尝试绝对时间(优先级更高,歧义更小)。
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
// 3. 再尝试相对时间(明天/下周一/今晚)。
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
}
return rel, true, nil
}
// 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, false, nil
}
// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。
func normalizeDeadlineInput(raw string) string {
// 先 trim避免纯空格输入影响后续逻辑。
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
// 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。
replacer := strings.NewReplacer(
"", ":",
"", ",",
"。", ".",
" ", " ",
)
return strings.TrimSpace(replacer.Replace(trimmed))
}
// hasDeadlineHint 判断文本里是否存在“时间相关线索”。
// 该函数的意义是区分两种情况:
// 1) 用户根本没给时间(允许 deadline 为空);
// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL
func hasDeadlineHint(value string) bool {
// 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
quickNoteMDRegex.MatchString(value) ||
quickNoteDateSepRegex.MatchString(value) ||
quickNoteWeekdayRegex.MatchString(value) {
return true
}
// 2. 再用词元判断“明天/今晚”等语义线索。
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
}
}
return false
}
// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。
// 若只提供日期(无时分),默认归一到当天 23:59表示“当日截止”。
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
// 逐个 layout 尝试,命中即返回。
for _, layout := range quickNoteDeadlineLayouts {
var (
t time.Time
err error
)
if layout == time.RFC3339 {
t, err = time.Parse(layout, value)
if err == nil {
t = t.In(loc)
}
} else {
t, err = time.ParseInLocation(layout, value, loc)
}
if err != nil {
continue
}
// Date-only 输入(例如 2026-03-20默认补到 23:59。
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc)
} else {
// 非 date-only 则统一清零秒级,保持分钟粒度一致。
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
}
return &t, true
}
return nil, false
}
// tryParseRelativeDeadline 尝试解析“相对时间 + 可选时刻”。
// 例子:
// - 明天交报告(默认 23:59
// - 下周一上午9点开会解析为下周一 09:00
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
// 1. 先确定“哪一天”。
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
// 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
}
if !hasExplicitClock {
hour, minute = defaultClockByHint(value)
}
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
return &deadline, true, nil
}
// inferBaseDate 负责先确定“哪一天”。
// 解析优先级:
// 1) 明确年月日;
// 2) 月日(自动推断年份);
// 3) 周几表达(本周/下周);
// 4) 明天/后天/今晚等相对词。
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
// 1) yyyy年MM月dd日
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
day, _ := strconv.Atoi(matched[3])
if isValidDate(year, month, day) {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
}
}
// 2) MM月dd日自动推断年份若今年已过则滚到明年
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
year := now.Year()
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
if candidate.Before(startOfDay(now)) {
year++
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
}
return candidate, true
}
// 3) 本周/下周 + 周几
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
if ok {
return resolveWeekdayDate(now, prefix, target), true
}
}
// 4) 今天/明天/后天/大后天/昨天等相对词
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
return today.AddDate(0, 0, 3), true
case strings.Contains(value, "后天"):
return today.AddDate(0, 0, 2), true
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
return today.AddDate(0, 0, 1), true
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
return today, true
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
return today.AddDate(0, 0, -1), true
default:
return time.Time{}, false
}
}
// extractClock 从文本提取时刻(时/分)。
// 支持:
// - 24h 表达18:30
// - 中文表达3点、3点半、3点20分
func extractClock(value string) (int, int, bool, error) {
// hour/minute 最终会用于 time.Date需要先做范围约束。
hour := 0
minute := 0
hasClock := false
// 1) 24 小时制18:30
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
if errH != nil || errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
// 2) 中文时刻3点 / 3点半 / 3点20分
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = 0
hasClock = true
if len(matched) >= 3 {
if matched[2] == "半" {
minute = 30
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
if errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
minute = m
}
}
}
if !hasClock {
// 没有显式时刻并不是错误,交给默认时刻策略处理。
return 0, 0, false, nil
}
// 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。
if isPMHint(value) && hour < 12 {
hour += 12
}
if isNoonHint(value) && hour >= 1 && hour <= 10 {
hour += 12
}
if strings.Contains(value, "凌晨") && hour == 12 {
hour = 0
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
}
return hour, minute, true, nil
}
// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。
func defaultClockByHint(value string) (int, int) {
// 没有明确时刻时按中文语义设置一个“可解释的默认值”。
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
return 9, 0
case strings.Contains(value, "中午"):
return 12, 0
case strings.Contains(value, "下午"):
return 15, 0
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
return 20, 0
default:
// 只给了日期没有具体时刻时,默认当天结束前。
return 23, 59
}
}
func isPMHint(value string) bool {
// 下午/晚上/傍晚通常应映射到 12:00 之后。
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
// “中午 1 点”这类表达通常是 13:00 而非 01:00。
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
// 保留原时区,只把时分秒归零。
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
// 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
}
func toWeekday(chinese string) (time.Weekday, bool) {
// 把中文周几映射到 Go 的 Weekday 枚举。
switch chinese {
case "一":
return time.Monday, true
case "二":
return time.Tuesday, true
case "三":
return time.Wednesday, true
case "四":
return time.Thursday, true
case "五":
return time.Friday, true
case "六":
return time.Saturday, true
case "日", "天":
return time.Sunday, true
default:
return time.Sunday, false
}
}
// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
// 1. 先定位本周周一。
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
// 2. 再根据“本周/下周/无前缀”选择最终日期。
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
return candidateThisWeek
default:
if candidateThisWeek.Before(today) {
return candidateThisWeek.AddDate(0, 0, 7)
}
return candidateThisWeek
}
}
// quickNoteLocation 返回随口记链路使用的业务时区。
func quickNoteLocation() *time.Location {
loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName)
if err != nil {
return time.Local
}
return loc
}
// quickNoteNowToMinute 返回当前时间并截断到分钟级。
func quickNoteNowToMinute() time.Time {
return agentshared.NowToMinute()
}
// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。
func formatQuickNoteTimeToMinute(t time.Time) string {
return agentshared.FormatMinute(t.In(quickNoteLocation()))
}

View File

@@ -0,0 +1,25 @@
package agentnode
import (
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
)
// SchedulePlanNodeDeps 描述“首次排程”节点层公共依赖。
type SchedulePlanNodeDeps struct {
LLM *agentllm.Client
StageEmitter agentstream.StageEmitter
}
// SchedulePlanNodes 是“首次排程”节点逻辑容器。
type SchedulePlanNodes struct {
deps SchedulePlanNodeDeps
}
// NewSchedulePlanNodes 创建首次排程节点容器。
func NewSchedulePlanNodes(deps SchedulePlanNodeDeps) *SchedulePlanNodes {
if deps.StageEmitter == nil {
deps.StageEmitter = agentstream.NoopStageEmitter()
}
return &SchedulePlanNodes{deps: deps}
}

View File

@@ -0,0 +1,25 @@
package agentnode
import (
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
)
// ScheduleRefineNodeDeps 描述“连续微调排程”节点层公共依赖。
type ScheduleRefineNodeDeps struct {
LLM *agentllm.Client
StageEmitter agentstream.StageEmitter
}
// ScheduleRefineNodes 是“连续微调排程”节点逻辑容器。
type ScheduleRefineNodes struct {
deps ScheduleRefineNodeDeps
}
// NewScheduleRefineNodes 创建连续微调节点容器。
func NewScheduleRefineNodes(deps ScheduleRefineNodeDeps) *ScheduleRefineNodes {
if deps.StageEmitter == nil {
deps.StageEmitter = agentstream.NoopStageEmitter()
}
return &ScheduleRefineNodes{deps: deps}
}

View File

@@ -0,0 +1,25 @@
package agentnode
import (
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
)
// TaskQueryNodeDeps 描述“随口问任务”节点层的公共依赖。
type TaskQueryNodeDeps struct {
LLM *agentllm.Client
StageEmitter agentstream.StageEmitter
}
// TaskQueryNodes 是“随口问任务”节点逻辑容器。
type TaskQueryNodes struct {
deps TaskQueryNodeDeps
}
// NewTaskQueryNodes 创建任务查询节点容器。
func NewTaskQueryNodes(deps TaskQueryNodeDeps) *TaskQueryNodes {
if deps.StageEmitter == nil {
deps.StageEmitter = agentstream.NoopStageEmitter()
}
return &TaskQueryNodes{deps: deps}
}