Version: 0.6.5.dev.260316
✨ feat(agent): 通用分流接入随口问图编排,修复任务查询条数与重复输出问题 - ♻️ 将 Agent 路由升级为通用 `action` 分流机制,统一支持 `chat` / `quick_note_create` / `task_query` - 🧩 新增 `taskquery` 子模块并落地图编排链路:`plan -> quadrant -> time_anchor -> tool_query -> reflect` - 🔧 在图内接入 `query_tasks` 工具调用,支持自动放宽检索条件与反思重试,最多重试 2 次 - 🚪 保持 `/agent/chat` 作为多合一入口,不额外新增任务查询 HTTP 接口 - 🪄 修复“随口问”场景下的双重列表输出问题:LLM 仅保留简短前缀,任务列表统一由后端进行确定性渲染 - 🎯 修复显式数量约束失效问题:支持提取“来一个”“前 3 个”“top5”等数量表达,并将其锁定为 `limit` - 🛡️ 防止在重试或放宽检索阶段改写用户显式指定的数量约束 - ✅ 补充并更新测试,覆盖路由解析、数量提取、`limit` 生效及重复输出等关键场景 📝 docs: 更新随口问链路文档与决策记录 - 📚 更新 README 5.4,新增/修订随口问链路 Mermaid 图 - 🧭 新增随口问功能决策记录 FDR
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/chat"
|
||||
"github.com/LoveLosita/smartflow/backend/agent/route"
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||
@@ -260,58 +261,87 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
}
|
||||
|
||||
// 3) 统一异步分流:
|
||||
// - 先走“模型控制码路由”决定 quick_note / chat;
|
||||
// - 路由命中 quick_note 时推阶段状态并执行 graph;
|
||||
// - 路由命中 chat 时直接普通流式聊天。
|
||||
// 3.1 先走“通用控制码路由”决定 action(chat / quick_note_create / task_query);
|
||||
// 3.2 quick_note_create 进入随口记 graph;
|
||||
// 3.3 task_query 进入任务查询 tool-calling;
|
||||
// 3.4 chat 直接普通流式聊天。
|
||||
go func() {
|
||||
defer close(outChan)
|
||||
|
||||
// 3.1 先走轻量路由,判断是否进入“随口记”图。
|
||||
routing := s.decideQuickNoteRouting(ctx, selectedModel, userMessage)
|
||||
if !routing.EnterQuickNote {
|
||||
// 3.2 非随口记:直接走普通聊天主链路。
|
||||
// 3.1 先走轻量路由,拿到统一 action。
|
||||
routing := s.decideActionRouting(ctx, selectedModel, userMessage)
|
||||
|
||||
// 3.2 chat:直接走普通聊天主链路。
|
||||
if routing.Action == route.ActionChat {
|
||||
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.3 随口记:先发阶段状态,减少用户等待时的“无反馈感”。
|
||||
// 3.3 非 chat 分支统一先发“接收成功”阶段,减少用户等待时的“无反馈感”。
|
||||
progress := newQuickNoteProgressEmitter(outChan, resolvedModelName, true)
|
||||
progress.Emit("request.accepted", routing.Detail)
|
||||
|
||||
// 3.4 执行随口记 graph。
|
||||
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
|
||||
ctx,
|
||||
selectedModel,
|
||||
userMessage,
|
||||
userID,
|
||||
chatID,
|
||||
traceID,
|
||||
routing.TrustRoute,
|
||||
progress.Emit,
|
||||
)
|
||||
if quickErr != nil {
|
||||
// graph 出错不直接中断用户请求,而是回退普通聊天,保证可用性优先。
|
||||
log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr)
|
||||
}
|
||||
// 3.4 quick_note_create:执行随口记 graph。
|
||||
if routing.Action == route.ActionQuickNoteCreate {
|
||||
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
|
||||
ctx,
|
||||
selectedModel,
|
||||
userMessage,
|
||||
userID,
|
||||
chatID,
|
||||
traceID,
|
||||
routing.TrustRoute,
|
||||
progress.Emit,
|
||||
)
|
||||
if quickErr != nil {
|
||||
// graph 出错不直接中断用户请求,而是回退普通聊天,保证可用性优先。
|
||||
log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr)
|
||||
}
|
||||
|
||||
if quickHandled {
|
||||
// 3.5 随口记处理成功:组织最终回复并按 OpenAI 兼容格式输出。
|
||||
progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。")
|
||||
quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState)
|
||||
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil {
|
||||
pushErrNonBlocking(errChan, emitErr)
|
||||
if quickHandled {
|
||||
// 3.4.1 随口记处理成功:组织最终回复并按 OpenAI 兼容格式输出。
|
||||
progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。")
|
||||
quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState)
|
||||
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil {
|
||||
pushErrNonBlocking(errChan, emitErr)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.4.2 对随口记回复执行统一后置持久化(Redis + outbox/DB)。
|
||||
s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan)
|
||||
// 3.4.3 随口记链路同样异步生成会话标题(仅首次写入)。
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.6 对随口记回复执行统一后置持久化(Redis + outbox/DB)。
|
||||
s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan)
|
||||
// 3.7 随口记链路同样异步生成会话标题(仅首次写入)。
|
||||
// 3.4.4 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。
|
||||
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
|
||||
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.5 task_query:执行任务查询 tool-calling。
|
||||
if routing.Action == route.ActionTaskQuery {
|
||||
reply, queryErr := s.runTaskQueryFlow(ctx, selectedModel, userMessage, userID, progress.Emit)
|
||||
if queryErr != nil {
|
||||
// 3.5.1 任务查询失败时回退普通聊天,避免请求直接中断。
|
||||
log.Printf("任务查询 tool-calling 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, queryErr)
|
||||
progress.Emit("task_query.fallback", "任务查询暂不可用,先切回普通对话。")
|
||||
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.5.2 查询成功后按 OpenAI 兼容格式输出,并执行统一后置持久化。
|
||||
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
|
||||
pushErrNonBlocking(errChan, emitErr)
|
||||
return
|
||||
}
|
||||
s.persistChatAfterReply(ctx, userID, chatID, userMessage, reply, errChan)
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.8 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。
|
||||
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
|
||||
// 3.6 未知 action 兜底:走普通聊天,保证可用性。
|
||||
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
}()
|
||||
|
||||
|
||||
@@ -23,14 +23,34 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != route.ActionQuickNote {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNote, decision.Action)
|
||||
// 兼容逻辑:历史 quick_note 会被统一映射到 quick_note_create。
|
||||
if decision.Action != route.ActionQuickNoteCreate {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNoteCreate, decision.Action)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
t.Fatalf("reason 不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRouteControlTag_TaskQuery
|
||||
// 目的:验证通用分流中 action=task_query 的控制码可稳定解析。
|
||||
func TestParseRouteControlTag_TaskQuery(t *testing.T) {
|
||||
nonce := "taskquerynonce"
|
||||
raw := `<SMARTFLOW_ROUTE nonce="taskquerynonce" action="task_query"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>用户在查最紧急任务</SMARTFLOW_REASON>`
|
||||
|
||||
decision, err := route.ParseRouteControlTag(raw, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != route.ActionTaskQuery {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionTaskQuery, decision.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseQuickNoteRouteControlTag_NonceMismatch
|
||||
// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。
|
||||
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
|
||||
|
||||
27
backend/service/agentsvc/agent_route.go
Normal file
27
backend/service/agentsvc/agent_route.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/route"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
)
|
||||
|
||||
// actionRoutingDecision 是 route 层分流结果在 agentsvc 的本地别名。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 让 AgentService 对 route 包保持“最小接触面”;
|
||||
// 2. 后续若 route 包返回结构调整,只需改这个桥接文件。
|
||||
type actionRoutingDecision = route.RoutingDecision
|
||||
|
||||
// decideActionRouting 决定当前请求走向哪条业务链路。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责调用 route 包拿分流结论;
|
||||
// 2. 不负责执行任何业务节点;
|
||||
// 3. route 层失败时的兜底策略由 route 包内部统一处理(当前为回落 chat)。
|
||||
func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision {
|
||||
// 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。
|
||||
_ = s
|
||||
return route.DecideActionRouting(ctx, selectedModel, userMessage)
|
||||
}
|
||||
259
backend/service/agentsvc/agent_task_query.go
Normal file
259
backend/service/agentsvc/agent_task_query.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/taskquery"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
)
|
||||
|
||||
// runTaskQueryFlow 执行“任务查询”分支。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把本次请求接入 taskquery 执行器;
|
||||
// 2. 负责把 user_id 注入工具依赖,确保模型无法越权查他人任务;
|
||||
// 3. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
|
||||
func (s *AgentService) runTaskQueryFlow(
|
||||
ctx context.Context,
|
||||
selectedModel *ark.ChatModel,
|
||||
userMessage string,
|
||||
userID int,
|
||||
emitStage func(stage, detail string),
|
||||
) (string, error) {
|
||||
// 1. 依赖预检:任务查询必须依赖 taskRepo + model。
|
||||
if s == nil || s.taskRepo == nil {
|
||||
return "", errors.New("task query service dependency is not ready")
|
||||
}
|
||||
if selectedModel == nil {
|
||||
return "", errors.New("task query model is nil")
|
||||
}
|
||||
|
||||
// 2. 构建执行输入并启动 tool-calling。
|
||||
// 2.1 RequestNow 仅用于 prompt 辅助,不参与数据库过滤。
|
||||
requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04")
|
||||
return taskquery.RunTaskQueryGraph(ctx, taskquery.QueryGraphRunInput{
|
||||
Model: selectedModel,
|
||||
UserMessage: userMessage,
|
||||
RequestNowText: requestNow,
|
||||
MaxReflectRetry: 2,
|
||||
EmitStage: emitStage,
|
||||
Deps: taskquery.TaskQueryToolDeps{
|
||||
QueryTasks: func(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
// 2.2 调用目的:在工具层做完参数校验后,这里把 user_id 强制注入,再执行真实查询。
|
||||
// 这样可以保证模型永远只能查当前登录用户的数据。
|
||||
req.UserID = userID
|
||||
return s.queryTasksForAgent(ctx, req)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// queryTasksForAgent 在 Agent 任务查询场景下读取并筛选任务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责“读取原始任务 + 读时优先级派生 + 条件筛选 + 排序 + 截断”;
|
||||
// 2. 不负责写库,不触发 outbox(只读查询链路);
|
||||
// 3. 返回的是工具层结构,不直接暴露 DAO 模型给上层。
|
||||
func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
_ = ctx
|
||||
|
||||
// 1. 基础参数校验。
|
||||
if req.UserID <= 0 {
|
||||
return nil, errors.New("invalid user_id in task query")
|
||||
}
|
||||
if s.taskRepo == nil {
|
||||
return nil, errors.New("task repository is nil")
|
||||
}
|
||||
|
||||
// 2. 读取用户全部任务。
|
||||
// 2.1 当前 TaskDAO 读取接口无 context 参数,这里保持最小侵入复用既有能力;
|
||||
// 2.2 若用户任务为空,返回空切片而不是 error,方便模型自然回复“暂无任务”。
|
||||
tasks, err := s.taskRepo.GetTasksByUserID(req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return make([]taskquery.TaskRecord, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 读时派生 + 条件筛选:
|
||||
// 3.1 先按“紧急分界线”做内存派生,保证查询视图与主业务口径一致;
|
||||
// 3.2 再应用 include_completed/quadrant/keyword/deadline 条件。
|
||||
now := time.Now()
|
||||
filtered := make([]model.Task, 0, len(tasks))
|
||||
for _, originalTask := range tasks {
|
||||
currentTask := originalTask
|
||||
applyReadTimeUrgencyPromotion(¤tTask, now)
|
||||
if !taskMatchesQueryFilter(currentTask, req) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, currentTask)
|
||||
}
|
||||
|
||||
// 4. 排序与截断:
|
||||
// 4.1 排序字段/方向已经在工具层校验过,这里按约定执行;
|
||||
// 4.2 limit 截断只发生在排序之后,保证“前 N 条”语义正确。
|
||||
sortTasksForQuery(filtered, req)
|
||||
if req.Limit > 0 && len(filtered) > req.Limit {
|
||||
filtered = filtered[:req.Limit]
|
||||
}
|
||||
|
||||
// 5. 映射成工具输出结构。
|
||||
records := make([]taskquery.TaskRecord, 0, len(filtered))
|
||||
for _, task := range filtered {
|
||||
records = append(records, taskquery.TaskRecord{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
IsCompleted: task.IsCompleted,
|
||||
DeadlineAt: task.DeadlineAt,
|
||||
UrgencyThresholdAt: task.UrgencyThresholdAt,
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// applyReadTimeUrgencyPromotion 复用“读时紧急性派生”口径(内存态)。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 已完成任务不派生;
|
||||
// 2. 未到紧急分界线不派生;
|
||||
// 3. 到线后仅做 2->1、4->3 的象限平移;
|
||||
// 4. 只改内存对象,不改数据库。
|
||||
func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
if task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
return
|
||||
}
|
||||
if task.UrgencyThresholdAt.After(now) {
|
||||
return
|
||||
}
|
||||
|
||||
switch task.Priority {
|
||||
case 2:
|
||||
task.Priority = 1
|
||||
case 4:
|
||||
task.Priority = 3
|
||||
}
|
||||
}
|
||||
|
||||
// taskMatchesQueryFilter 判断任务是否满足查询条件。
|
||||
func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) bool {
|
||||
// 1. include_completed=false 时默认过滤掉已完成任务。
|
||||
if !req.IncludeCompleted && task.IsCompleted {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. quadrant 过滤:只保留指定象限。
|
||||
if req.Quadrant != nil && task.Priority != *req.Quadrant {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. keyword 过滤:对标题做大小写不敏感包含匹配。
|
||||
keyword := strings.TrimSpace(req.Keyword)
|
||||
if keyword != "" {
|
||||
if !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 4. deadline 区间过滤:
|
||||
// 4.1 只要设置了上下界,deadline_at 为空的任务默认不匹配;
|
||||
// 4.2 区间边界为闭区间(>= after 且 <= before)。
|
||||
if req.DeadlineAfter != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.DeadlineBefore != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.After(*req.DeadlineBefore) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sortTasksForQuery 按查询条件排序任务。
|
||||
//
|
||||
// 排序策略:
|
||||
// 1. sort_by=deadline:按截止时间排,deadline 为空的任务统一放末尾;
|
||||
// 2. sort_by=priority:按象限数值排(1 最紧急),同优先级再按 id 倒序;
|
||||
// 3. sort_by=id:按 id 排(可近似“新旧顺序”)。
|
||||
func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
if len(tasks) <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(req.Order))
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
}
|
||||
sortBy := strings.ToLower(strings.TrimSpace(req.SortBy))
|
||||
if sortBy == "" {
|
||||
sortBy = "deadline"
|
||||
}
|
||||
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
left := tasks[i]
|
||||
right := tasks[j]
|
||||
|
||||
switch sortBy {
|
||||
case "priority":
|
||||
if left.Priority != right.Priority {
|
||||
if order == "desc" {
|
||||
return left.Priority > right.Priority
|
||||
}
|
||||
return left.Priority < right.Priority
|
||||
}
|
||||
// 同优先级时按 id 倒序,保证排序稳定且更接近“最近创建在前”。
|
||||
return left.ID > right.ID
|
||||
case "id":
|
||||
if order == "desc" {
|
||||
return left.ID > right.ID
|
||||
}
|
||||
return left.ID < right.ID
|
||||
default: // deadline
|
||||
if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided {
|
||||
return less
|
||||
}
|
||||
// 截止时间相同或都为空时,回退 id 倒序保证稳定性。
|
||||
return left.ID > right.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// compareDeadline 比较两个可选截止时间。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. less:left 是否应排在 right 前;
|
||||
// 2. decided:本次比较是否已能得出顺序;false 表示需要上层继续用次级键比较。
|
||||
func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) {
|
||||
// 1. 都为空:本次不决策,交给次级键。
|
||||
if left == nil && right == nil {
|
||||
return false, false
|
||||
}
|
||||
// 2. 只有一边为空:为空的一侧统一放末尾。
|
||||
if left == nil && right != nil {
|
||||
return false, true
|
||||
}
|
||||
if left != nil && right == nil {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// 3. 两边都不为空:按 order 做时间比较。
|
||||
if left.Equal(*right) {
|
||||
return false, false
|
||||
}
|
||||
if order == "desc" {
|
||||
return left.After(*right), true
|
||||
}
|
||||
return left.Before(*right), true
|
||||
}
|
||||
Reference in New Issue
Block a user