✨ 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
840 lines
24 KiB
Go
840 lines
24 KiB
Go
package taskquery
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||
einoModel "github.com/cloudwego/eino/components/model"
|
||
"github.com/cloudwego/eino/schema"
|
||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||
)
|
||
|
||
type taskQueryPlanOutput struct {
|
||
UserGoal string `json:"user_goal"`
|
||
Quadrants []int `json:"quadrants"`
|
||
SortBy string `json:"sort_by"`
|
||
Order string `json:"order"`
|
||
Limit int `json:"limit"`
|
||
IncludeCompleted *bool `json:"include_completed"`
|
||
Keyword string `json:"keyword"`
|
||
DeadlineBefore string `json:"deadline_before"`
|
||
DeadlineAfter string `json:"deadline_after"`
|
||
}
|
||
|
||
type taskQueryReflectOutput struct {
|
||
Satisfied bool `json:"satisfied"`
|
||
NeedRetry bool `json:"need_retry"`
|
||
Reason string `json:"reason"`
|
||
Reply string `json:"reply"`
|
||
RetryPatch taskQueryRetryPatch `json:"retry_patch"`
|
||
}
|
||
|
||
type taskQueryRetryPatch struct {
|
||
Quadrants *[]int `json:"quadrants,omitempty"`
|
||
SortBy *string `json:"sort_by,omitempty"`
|
||
Order *string `json:"order,omitempty"`
|
||
Limit *int `json:"limit,omitempty"`
|
||
IncludeCompleted *bool `json:"include_completed,omitempty"`
|
||
Keyword *string `json:"keyword,omitempty"`
|
||
DeadlineBefore *string `json:"deadline_before,omitempty"`
|
||
DeadlineAfter *string `json:"deadline_after,omitempty"`
|
||
}
|
||
|
||
var (
|
||
// explicitLimitPatterns 用于从用户原话提取“显式数量要求”。
|
||
//
|
||
// 例子:
|
||
// 1. 前3个任务
|
||
// 2. 给我5条
|
||
// 3. top 10
|
||
explicitLimitPatterns = []*regexp.Regexp{
|
||
regexp.MustCompile(`(?i)\btop\s*(\d{1,2})\b`),
|
||
regexp.MustCompile(`前\s*(\d{1,2})\s*(个|条|项)?`),
|
||
regexp.MustCompile(`(\d{1,2})\s*(个|条|项)\s*任务?`),
|
||
regexp.MustCompile(`给我\s*(\d{1,2})\s*(个|条|项)?`),
|
||
}
|
||
// chineseDigitMap 支持常见中文数字(用于“前五个”“来三个”这类口语)。
|
||
chineseDigitMap = map[rune]int{
|
||
'一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5,
|
||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||
}
|
||
)
|
||
|
||
func (r *taskQueryGraphRunner) planNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||
// 1. 防御校验:state 为空时直接返回,避免后续节点空指针。
|
||
if st == nil {
|
||
return nil, fmt.Errorf("task query graph: nil state in plan node")
|
||
}
|
||
|
||
// 2. 规划节点只调用一次模型,把查询意图打包成结构化计划。
|
||
r.emit("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。")
|
||
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
|
||
用户输入:%s
|
||
|
||
请输出任务查询计划 JSON。`, st.RequestNowText, st.UserMessage)
|
||
|
||
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryPlanPrompt, prompt, 260)
|
||
if err != nil {
|
||
// 3. 模型失败时不直接终止:回退到默认计划,保证可用性。
|
||
st.UserGoal = "查询任务"
|
||
st.Plan = defaultTaskQueryPlan()
|
||
return st, nil
|
||
}
|
||
|
||
planned, parseErr := parseTaskQueryJSON[taskQueryPlanOutput](raw)
|
||
if parseErr != nil {
|
||
// 4. JSON 异常同样回退默认计划,避免用户请求直接失败。
|
||
st.UserGoal = "查询任务"
|
||
st.Plan = defaultTaskQueryPlan()
|
||
return st, nil
|
||
}
|
||
|
||
// 5. 规划结果统一规范化,保证后续节点拿到稳定参数。
|
||
st.UserGoal = strings.TrimSpace(planned.UserGoal)
|
||
if st.UserGoal == "" {
|
||
st.UserGoal = "查询任务"
|
||
}
|
||
st.Plan = normalizePlan(taskQueryPlanOutput{
|
||
UserGoal: planned.UserGoal,
|
||
Quadrants: planned.Quadrants,
|
||
SortBy: planned.SortBy,
|
||
Order: planned.Order,
|
||
Limit: planned.Limit,
|
||
IncludeCompleted: planned.IncludeCompleted,
|
||
Keyword: planned.Keyword,
|
||
DeadlineBefore: planned.DeadlineBefore,
|
||
DeadlineAfter: planned.DeadlineAfter,
|
||
})
|
||
|
||
// 6. 若用户原话里有明确数量要求(例如“给我3个”),强制覆盖 plan.limit。
|
||
// 这样即使规划模型漏掉 limit,也不会影响最终返回条数预期。
|
||
if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found {
|
||
st.ExplicitLimit = explicitLimit
|
||
st.Plan.Limit = explicitLimit
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
func (r *taskQueryGraphRunner) quadrantNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||
_ = ctx
|
||
if st == nil {
|
||
return nil, fmt.Errorf("task query graph: nil state in quadrant node")
|
||
}
|
||
|
||
// 1. 象限节点不调用模型,只做“象限参数兜底与去重”。
|
||
// 2. 为空表示全象限,非空表示指定象限。
|
||
r.emit("task_query.quadrant.routing", "正在归一化象限筛选范围。")
|
||
st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants)
|
||
return st, nil
|
||
}
|
||
|
||
func (r *taskQueryGraphRunner) timeAnchorNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||
_ = ctx
|
||
if st == nil {
|
||
return nil, fmt.Errorf("task query graph: nil state in time anchor node")
|
||
}
|
||
|
||
// 1. 时间节点不再调用模型,只负责把规划中的时间文本解析为绝对时间对象。
|
||
// 2. 解析失败时清空该边界,避免非法时间导致整条查询失败。
|
||
r.emit("task_query.time.anchoring", "正在锁定时间过滤边界。")
|
||
applyTimeAnchorOnPlan(&st.Plan)
|
||
return st, nil
|
||
}
|
||
|
||
func (r *taskQueryGraphRunner) queryNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||
if st == nil {
|
||
return nil, fmt.Errorf("task query graph: nil state in query node")
|
||
}
|
||
|
||
// 1. 按当前计划执行工具查询。
|
||
r.emit("task_query.tool.querying", "正在查询任务数据。")
|
||
items, err := r.executePlanByTool(ctx, st.Plan)
|
||
if err != nil {
|
||
// 查询失败不抛出硬错误,交给反思节点决定如何回复用户。
|
||
st.LastQueryItems = make([]TaskQueryToolRecord, 0)
|
||
st.LastQueryTotal = 0
|
||
st.ReflectReason = "查询工具执行失败"
|
||
return st, nil
|
||
}
|
||
st.LastQueryItems = items
|
||
st.LastQueryTotal = len(items)
|
||
|
||
// 2. 额外优化:若结果为空且还没自动放宽过,则先放宽一次再查询(无额外模型调用)。
|
||
if st.LastQueryTotal == 0 && !st.AutoBroadenApplied {
|
||
plan, broadened := autoBroadenPlan(st.Plan)
|
||
if broadened {
|
||
st.AutoBroadenApplied = true
|
||
st.Plan = plan
|
||
r.emit("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。")
|
||
retryItems, retryErr := r.executePlanByTool(ctx, st.Plan)
|
||
if retryErr == nil {
|
||
st.LastQueryItems = retryItems
|
||
st.LastQueryTotal = len(retryItems)
|
||
}
|
||
}
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
func (r *taskQueryGraphRunner) reflectNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||
if st == nil {
|
||
return nil, fmt.Errorf("task query graph: nil state in reflect node")
|
||
}
|
||
|
||
// 1. 反思节点负责三件事:
|
||
// 1.1 判断当前结果是否满足用户诉求;
|
||
// 1.2 需要重试时给出最小 patch;
|
||
// 1.3 同时给出可直接返回用户的中文回复。
|
||
r.emit("task_query.reflecting", "正在判断结果是否贴合你的需求。")
|
||
reflectPrompt := buildReflectUserPrompt(st)
|
||
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryReflectPrompt, reflectPrompt, 380)
|
||
if err != nil {
|
||
// 2. 反思调用失败时直接收束,避免无限等待。
|
||
st.NeedRetry = false
|
||
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
|
||
return st, nil
|
||
}
|
||
|
||
reflectResult, parseErr := parseTaskQueryJSON[taskQueryReflectOutput](raw)
|
||
if parseErr != nil {
|
||
st.NeedRetry = false
|
||
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
|
||
return st, nil
|
||
}
|
||
|
||
st.ReflectReason = strings.TrimSpace(reflectResult.Reason)
|
||
|
||
// 3. 满足需求时直接结束。
|
||
if reflectResult.Satisfied {
|
||
st.NeedRetry = false
|
||
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
|
||
return st, nil
|
||
}
|
||
|
||
// 4. 不满足且允许重试时,应用 patch 并回到查询节点。
|
||
if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry {
|
||
st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit)
|
||
st.RetryCount++
|
||
st.NeedRetry = true
|
||
if strings.TrimSpace(reflectResult.Reply) != "" {
|
||
// 4.1 这里先缓存中间回复,最终是否使用取决于后续是否成功命中。
|
||
st.FinalReply = strings.TrimSpace(reflectResult.Reply)
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
// 5. 不再重试:输出最终回复并结束。
|
||
st.NeedRetry = false
|
||
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
|
||
return st, nil
|
||
}
|
||
|
||
func (r *taskQueryGraphRunner) executePlanByTool(ctx context.Context, plan QueryPlan) ([]TaskQueryToolRecord, error) {
|
||
// 1. 这里强制通过工具执行查询,而不是直接读 DAO。
|
||
// 目的:保持“工具边界”一致,后续迁移多工具编排时可复用同一协议。
|
||
if r.queryTool == nil {
|
||
return nil, fmt.Errorf("task query tool is nil")
|
||
}
|
||
|
||
merged := make([]TaskQueryToolRecord, 0, plan.Limit)
|
||
seen := make(map[int]struct{}, plan.Limit*2)
|
||
|
||
runOne := func(quadrant *int) error {
|
||
input := TaskQueryToolInput{
|
||
Quadrant: quadrant,
|
||
SortBy: plan.SortBy,
|
||
Order: plan.Order,
|
||
Limit: plan.Limit,
|
||
Keyword: plan.Keyword,
|
||
DeadlineBefore: plan.DeadlineBeforeText,
|
||
DeadlineAfter: plan.DeadlineAfterText,
|
||
}
|
||
includeCompleted := plan.IncludeCompleted
|
||
input.IncludeCompleted = &includeCompleted
|
||
|
||
rawInput, err := json.Marshal(input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
rawOutput, err := r.queryTool.InvokableRun(ctx, string(rawInput))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
parsed, err := parseTaskQueryJSON[TaskQueryToolOutput](rawOutput)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, item := range parsed.Items {
|
||
if _, exists := seen[item.ID]; exists {
|
||
continue
|
||
}
|
||
seen[item.ID] = struct{}{}
|
||
merged = append(merged, item)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 2. Quadrants 为空表示全象限,执行一次无象限过滤查询。
|
||
if len(plan.Quadrants) == 0 {
|
||
if err := runOne(nil); err != nil {
|
||
return nil, err
|
||
}
|
||
} else {
|
||
// 3. 指定象限时逐个调用工具并合并去重。
|
||
for _, quadrant := range plan.Quadrants {
|
||
q := quadrant
|
||
if err := runOne(&q); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 合并后再按计划统一排序,保证跨象限结果顺序稳定。
|
||
sortTaskQueryToolRecords(merged, plan)
|
||
if len(merged) > plan.Limit {
|
||
merged = merged[:plan.Limit]
|
||
}
|
||
return merged, nil
|
||
}
|
||
|
||
func normalizePlan(raw taskQueryPlanOutput) QueryPlan {
|
||
plan := defaultTaskQueryPlan()
|
||
plan.Quadrants = normalizeQuadrants(raw.Quadrants)
|
||
|
||
sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy))
|
||
switch sortBy {
|
||
case "deadline", "priority", "id":
|
||
plan.SortBy = sortBy
|
||
}
|
||
|
||
order := strings.ToLower(strings.TrimSpace(raw.Order))
|
||
switch order {
|
||
case "asc", "desc":
|
||
plan.Order = order
|
||
}
|
||
|
||
if raw.Limit > 0 {
|
||
plan.Limit = raw.Limit
|
||
}
|
||
if plan.Limit > MaxTaskQueryLimit {
|
||
plan.Limit = MaxTaskQueryLimit
|
||
}
|
||
if plan.Limit <= 0 {
|
||
plan.Limit = DefaultTaskQueryLimit
|
||
}
|
||
|
||
if raw.IncludeCompleted != nil {
|
||
plan.IncludeCompleted = *raw.IncludeCompleted
|
||
}
|
||
plan.Keyword = strings.TrimSpace(raw.Keyword)
|
||
plan.DeadlineBeforeText = strings.TrimSpace(raw.DeadlineBefore)
|
||
plan.DeadlineAfterText = strings.TrimSpace(raw.DeadlineAfter)
|
||
applyTimeAnchorOnPlan(&plan)
|
||
return plan
|
||
}
|
||
|
||
func defaultTaskQueryPlan() QueryPlan {
|
||
return QueryPlan{
|
||
Quadrants: nil,
|
||
SortBy: "deadline",
|
||
Order: "asc",
|
||
Limit: DefaultTaskQueryLimit,
|
||
IncludeCompleted: false,
|
||
Keyword: "",
|
||
}
|
||
}
|
||
|
||
func normalizeQuadrants(quadrants []int) []int {
|
||
if len(quadrants) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[int]struct{}, len(quadrants))
|
||
result := make([]int, 0, len(quadrants))
|
||
for _, q := range quadrants {
|
||
if q < 1 || q > 4 {
|
||
continue
|
||
}
|
||
if _, exists := seen[q]; exists {
|
||
continue
|
||
}
|
||
seen[q] = struct{}{}
|
||
result = append(result, q)
|
||
}
|
||
sort.Ints(result)
|
||
if len(result) == 0 {
|
||
return nil
|
||
}
|
||
if len(result) == 4 {
|
||
// 指定了全部象限时与“空=全象限”等价,统一归一化为 nil。
|
||
return nil
|
||
}
|
||
return result
|
||
}
|
||
|
||
func applyTimeAnchorOnPlan(plan *QueryPlan) {
|
||
if plan == nil {
|
||
return
|
||
}
|
||
before, errBefore := parseOptionalBoundaryTime(plan.DeadlineBeforeText, true)
|
||
after, errAfter := parseOptionalBoundaryTime(plan.DeadlineAfterText, false)
|
||
|
||
if errBefore != nil {
|
||
plan.DeadlineBefore = nil
|
||
plan.DeadlineBeforeText = ""
|
||
} else {
|
||
plan.DeadlineBefore = before
|
||
}
|
||
if errAfter != nil {
|
||
plan.DeadlineAfter = nil
|
||
plan.DeadlineAfterText = ""
|
||
} else {
|
||
plan.DeadlineAfter = after
|
||
}
|
||
|
||
// 边界冲突时清空,防止构造出“必为空结果”的死条件。
|
||
if plan.DeadlineBefore != nil && plan.DeadlineAfter != nil && plan.DeadlineAfter.After(*plan.DeadlineBefore) {
|
||
plan.DeadlineBefore = nil
|
||
plan.DeadlineAfter = nil
|
||
plan.DeadlineBeforeText = ""
|
||
plan.DeadlineAfterText = ""
|
||
}
|
||
}
|
||
|
||
func autoBroadenPlan(plan QueryPlan) (QueryPlan, bool) {
|
||
// 1. 仅允许自动放宽一次,且放宽必须“可解释”:
|
||
// 1.1 清空关键词;
|
||
// 1.2 放开完成状态;
|
||
// 1.3 清空时间边界;
|
||
// 1.4 不主动改象限和 limit,避免语义漂移(例如“简单任务”被放宽成全象限)。
|
||
changed := false
|
||
broadened := plan
|
||
|
||
if strings.TrimSpace(broadened.Keyword) != "" {
|
||
broadened.Keyword = ""
|
||
changed = true
|
||
}
|
||
if !broadened.IncludeCompleted {
|
||
broadened.IncludeCompleted = true
|
||
changed = true
|
||
}
|
||
if broadened.DeadlineBefore != nil || broadened.DeadlineAfter != nil ||
|
||
broadened.DeadlineBeforeText != "" || broadened.DeadlineAfterText != "" {
|
||
broadened.DeadlineBefore = nil
|
||
broadened.DeadlineAfter = nil
|
||
broadened.DeadlineBeforeText = ""
|
||
broadened.DeadlineAfterText = ""
|
||
changed = true
|
||
}
|
||
return broadened, changed
|
||
}
|
||
|
||
func applyRetryPatch(plan QueryPlan, patch taskQueryRetryPatch, explicitLimit int) QueryPlan {
|
||
next := plan
|
||
changed := false
|
||
|
||
if patch.Quadrants != nil {
|
||
next.Quadrants = normalizeQuadrants(*patch.Quadrants)
|
||
changed = true
|
||
}
|
||
if patch.SortBy != nil {
|
||
sortBy := strings.ToLower(strings.TrimSpace(*patch.SortBy))
|
||
if sortBy == "deadline" || sortBy == "priority" || sortBy == "id" {
|
||
next.SortBy = sortBy
|
||
changed = true
|
||
}
|
||
}
|
||
if patch.Order != nil {
|
||
order := strings.ToLower(strings.TrimSpace(*patch.Order))
|
||
if order == "asc" || order == "desc" {
|
||
next.Order = order
|
||
changed = true
|
||
}
|
||
}
|
||
if patch.Limit != nil {
|
||
// 用户显式指定数量时,锁定 limit,不允许反思补丁改写。
|
||
if explicitLimit <= 0 {
|
||
limit := *patch.Limit
|
||
if limit <= 0 {
|
||
limit = DefaultTaskQueryLimit
|
||
}
|
||
if limit > MaxTaskQueryLimit {
|
||
limit = MaxTaskQueryLimit
|
||
}
|
||
next.Limit = limit
|
||
changed = true
|
||
}
|
||
}
|
||
if patch.IncludeCompleted != nil {
|
||
next.IncludeCompleted = *patch.IncludeCompleted
|
||
changed = true
|
||
}
|
||
if patch.Keyword != nil {
|
||
next.Keyword = strings.TrimSpace(*patch.Keyword)
|
||
changed = true
|
||
}
|
||
if patch.DeadlineBefore != nil {
|
||
next.DeadlineBeforeText = strings.TrimSpace(*patch.DeadlineBefore)
|
||
changed = true
|
||
}
|
||
if patch.DeadlineAfter != nil {
|
||
next.DeadlineAfterText = strings.TrimSpace(*patch.DeadlineAfter)
|
||
changed = true
|
||
}
|
||
|
||
if changed {
|
||
applyTimeAnchorOnPlan(&next)
|
||
}
|
||
// 双保险:显式数量存在时再次锁定,避免其他路径误改。
|
||
if explicitLimit > 0 {
|
||
next.Limit = explicitLimit
|
||
}
|
||
return next
|
||
}
|
||
|
||
func buildReflectUserPrompt(st *TaskQueryState) string {
|
||
planSummary := summarizePlan(st.Plan)
|
||
resultSummary := summarizeQueryItems(st.LastQueryItems, 6)
|
||
return fmt.Sprintf(`当前时间:%s
|
||
用户原话:%s
|
||
用户目标:%s
|
||
当前查询计划:%s
|
||
当前重试:%d/%d
|
||
查询结果摘要:
|
||
%s`,
|
||
st.RequestNowText,
|
||
st.UserMessage,
|
||
st.UserGoal,
|
||
planSummary,
|
||
st.RetryCount,
|
||
st.MaxReflectRetry,
|
||
resultSummary,
|
||
)
|
||
}
|
||
|
||
func summarizePlan(plan QueryPlan) string {
|
||
quadrants := "全部象限"
|
||
if len(plan.Quadrants) > 0 {
|
||
parts := make([]string, 0, len(plan.Quadrants))
|
||
for _, q := range plan.Quadrants {
|
||
parts = append(parts, strconv.Itoa(q))
|
||
}
|
||
quadrants = strings.Join(parts, ",")
|
||
}
|
||
return fmt.Sprintf("quadrants=%s sort=%s/%s limit=%d include_completed=%t keyword=%s before=%s after=%s",
|
||
quadrants, plan.SortBy, plan.Order, plan.Limit, plan.IncludeCompleted,
|
||
emptyToDash(plan.Keyword), emptyToDash(plan.DeadlineBeforeText), emptyToDash(plan.DeadlineAfterText))
|
||
}
|
||
|
||
func summarizeQueryItems(items []TaskQueryToolRecord, max int) string {
|
||
if len(items) == 0 {
|
||
return "无结果"
|
||
}
|
||
if max <= 0 {
|
||
max = 5
|
||
}
|
||
if len(items) > max {
|
||
items = items[:max]
|
||
}
|
||
lines := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
line := fmt.Sprintf("- #%d %s | 象限=%d | 完成=%t | 截止=%s",
|
||
item.ID, item.Title, item.PriorityGroup, item.IsCompleted, emptyToDash(item.DeadlineAt))
|
||
lines = append(lines, line)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func buildTaskQueryFallbackReply(items []TaskQueryToolRecord) string {
|
||
if len(items) == 0 {
|
||
return "我这边暂时没找到匹配的任务。你可以再补一句,比如“按截止时间最早的前3个”或“只看简单不重要”。"
|
||
}
|
||
// 1. 用最多 3 条摘要拼一个稳态回复,避免模型异常时空白返回。
|
||
preview := items
|
||
if len(preview) > 3 {
|
||
preview = preview[:3]
|
||
}
|
||
lines := make([]string, 0, len(preview))
|
||
for _, item := range preview {
|
||
lines = append(lines, fmt.Sprintf("%s(%s)", item.Title, item.PriorityLabel))
|
||
}
|
||
return fmt.Sprintf("我先给你筛到这些:%s。要不要我再按“更紧急”或“更简单”继续细化?", strings.Join(lines, "、"))
|
||
}
|
||
|
||
// buildTaskQueryFinalReply 构建“确定性条数”的最终回复。
|
||
//
|
||
// 设计目的:
|
||
// 1. 让返回条数严格受 plan.limit 约束,避免 LLM 自由发挥导致“只说1条”;
|
||
// 2. 仍可保留 LLM 的语气前缀,但清单主体由后端稳定渲染;
|
||
// 3. 无结果时统一走兜底文案。
|
||
func buildTaskQueryFinalReply(items []TaskQueryToolRecord, plan QueryPlan, llmReply string) string {
|
||
if len(items) == 0 {
|
||
base := buildTaskQueryFallbackReply(items)
|
||
if strings.TrimSpace(llmReply) == "" {
|
||
return base
|
||
}
|
||
return strings.TrimSpace(llmReply) + "\n" + base
|
||
}
|
||
|
||
desired := plan.Limit
|
||
if desired <= 0 {
|
||
desired = DefaultTaskQueryLimit
|
||
}
|
||
if desired > MaxTaskQueryLimit {
|
||
desired = MaxTaskQueryLimit
|
||
}
|
||
showCount := desired
|
||
if len(items) < showCount {
|
||
showCount = len(items)
|
||
}
|
||
|
||
preview := items[:showCount]
|
||
lines := make([]string, 0, len(preview))
|
||
for idx, item := range preview {
|
||
deadline := strings.TrimSpace(item.DeadlineAt)
|
||
if deadline == "" {
|
||
deadline = "无明确截止时间"
|
||
}
|
||
status := "未完成"
|
||
if item.IsCompleted {
|
||
status = "已完成"
|
||
}
|
||
lines = append(lines, fmt.Sprintf("%d. %s(%s,%s,截止:%s)",
|
||
idx+1, item.Title, item.PriorityLabel, status, deadline))
|
||
}
|
||
|
||
header := fmt.Sprintf("给你整理了 %d 条任务:", showCount)
|
||
if lead := extractSafeReplyLead(llmReply); lead != "" {
|
||
header = lead + "\n" + header
|
||
}
|
||
|
||
reply := header + "\n" + strings.Join(lines, "\n")
|
||
if len(items) > showCount {
|
||
reply += fmt.Sprintf("\n另外还有 %d 条匹配任务,要不要我继续往下列?", len(items)-showCount)
|
||
}
|
||
return reply
|
||
}
|
||
|
||
// extractSafeReplyLead 从 LLM 回复中提取“安全前缀句”。
|
||
//
|
||
// 目的:
|
||
// 1. 防止 LLM 已经输出一整段列表时再次和后端列表拼接,造成双重输出;
|
||
// 2. 仅保留单行短句语气前缀,正文列表始终以后端确定性渲染为准。
|
||
func extractSafeReplyLead(llmReply string) string {
|
||
text := strings.TrimSpace(llmReply)
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
// 有明显列表迹象时直接丢弃,避免重复列举。
|
||
lower := strings.ToLower(text)
|
||
if strings.Contains(text, "\n") || strings.Contains(text, "#") ||
|
||
strings.Contains(lower, "1.") || strings.Contains(text, "1、") || strings.Contains(text, "以下是") {
|
||
return ""
|
||
}
|
||
// 太长也不保留,避免把冗长模型输出混进最终回复。
|
||
if len([]rune(text)) > 30 {
|
||
return ""
|
||
}
|
||
return text
|
||
}
|
||
|
||
func sortTaskQueryToolRecords(items []TaskQueryToolRecord, plan QueryPlan) {
|
||
if len(items) <= 1 {
|
||
return
|
||
}
|
||
sortBy := strings.ToLower(strings.TrimSpace(plan.SortBy))
|
||
order := strings.ToLower(strings.TrimSpace(plan.Order))
|
||
if order != "desc" {
|
||
order = "asc"
|
||
}
|
||
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
left := items[i]
|
||
right := items[j]
|
||
switch sortBy {
|
||
case "priority":
|
||
if left.PriorityGroup != right.PriorityGroup {
|
||
if order == "desc" {
|
||
return left.PriorityGroup > right.PriorityGroup
|
||
}
|
||
return left.PriorityGroup < right.PriorityGroup
|
||
}
|
||
return left.ID > right.ID
|
||
case "id":
|
||
if order == "desc" {
|
||
return left.ID > right.ID
|
||
}
|
||
return left.ID < right.ID
|
||
default:
|
||
lTime, lOK := parseRecordDeadline(left.DeadlineAt)
|
||
rTime, rOK := parseRecordDeadline(right.DeadlineAt)
|
||
if lOK && rOK {
|
||
if !lTime.Equal(rTime) {
|
||
if order == "desc" {
|
||
return lTime.After(rTime)
|
||
}
|
||
return lTime.Before(rTime)
|
||
}
|
||
return left.ID > right.ID
|
||
}
|
||
if lOK && !rOK {
|
||
return true
|
||
}
|
||
if !lOK && rOK {
|
||
return false
|
||
}
|
||
return left.ID > right.ID
|
||
}
|
||
})
|
||
}
|
||
|
||
func parseRecordDeadline(raw string) (time.Time, bool) {
|
||
text := strings.TrimSpace(raw)
|
||
if text == "" {
|
||
return time.Time{}, false
|
||
}
|
||
t, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local)
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
return t, true
|
||
}
|
||
|
||
func emptyToDash(text string) string {
|
||
if strings.TrimSpace(text) == "" {
|
||
return "-"
|
||
}
|
||
return strings.TrimSpace(text)
|
||
}
|
||
|
||
// extractExplicitLimitFromUser 从用户原话提取显式数量诉求。
|
||
//
|
||
// 解析策略:
|
||
// 1. 先匹配阿拉伯数字(前3个/top 5/给我2条);
|
||
// 2. 再匹配常见中文数字(前五个/来三个);
|
||
// 3. 统一限制在 1~20 之间。
|
||
func extractExplicitLimitFromUser(userMessage string) (int, bool) {
|
||
text := strings.TrimSpace(userMessage)
|
||
if text == "" {
|
||
return 0, false
|
||
}
|
||
|
||
for _, pattern := range explicitLimitPatterns {
|
||
matches := pattern.FindStringSubmatch(text)
|
||
if len(matches) < 2 {
|
||
continue
|
||
}
|
||
number, err := strconv.Atoi(strings.TrimSpace(matches[1]))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
return normalizeExplicitLimit(number)
|
||
}
|
||
|
||
// 中文数字兜底:覆盖高频口语模式。
|
||
chinesePatterns := []string{"前", "来", "给我"}
|
||
for _, prefix := range chinesePatterns {
|
||
for digitRune, number := range chineseDigitMap {
|
||
token := prefix + string(digitRune)
|
||
if strings.Contains(text, token) {
|
||
return normalizeExplicitLimit(number)
|
||
}
|
||
// “前五个”“来三个”这类再补一个“个/条/项”尾缀判断。
|
||
for _, suffix := range []string{"个", "条", "项"} {
|
||
if strings.Contains(text, token+suffix) {
|
||
return normalizeExplicitLimit(number)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func normalizeExplicitLimit(number int) (int, bool) {
|
||
if number <= 0 {
|
||
return 0, false
|
||
}
|
||
if number > MaxTaskQueryLimit {
|
||
number = MaxTaskQueryLimit
|
||
}
|
||
return number, true
|
||
}
|
||
|
||
func callTaskQueryModelForJSON(
|
||
ctx context.Context,
|
||
model *ark.ChatModel,
|
||
systemPrompt string,
|
||
userPrompt string,
|
||
maxTokens int,
|
||
) (string, error) {
|
||
if model == nil {
|
||
return "", fmt.Errorf("task query model is nil")
|
||
}
|
||
messages := []*schema.Message{
|
||
schema.SystemMessage(systemPrompt),
|
||
schema.UserMessage(userPrompt),
|
||
}
|
||
|
||
opts := []einoModel.Option{
|
||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||
einoModel.WithTemperature(0),
|
||
}
|
||
if maxTokens > 0 {
|
||
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
|
||
}
|
||
|
||
resp, err := model.Generate(ctx, messages, opts...)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if resp == nil {
|
||
return "", fmt.Errorf("task query model returned nil")
|
||
}
|
||
text := strings.TrimSpace(resp.Content)
|
||
if text == "" {
|
||
return "", fmt.Errorf("task query model returned empty content")
|
||
}
|
||
return text, nil
|
||
}
|
||
|
||
func parseTaskQueryJSON[T any](raw string) (*T, error) {
|
||
clean := strings.TrimSpace(raw)
|
||
if clean == "" {
|
||
return nil, fmt.Errorf("empty response")
|
||
}
|
||
|
||
// 1. 兼容 ```json 包裹格式。
|
||
if strings.HasPrefix(clean, "```") {
|
||
clean = strings.TrimPrefix(clean, "```json")
|
||
clean = strings.TrimPrefix(clean, "```")
|
||
clean = strings.TrimSuffix(clean, "```")
|
||
clean = strings.TrimSpace(clean)
|
||
}
|
||
|
||
// 2. 先尝试整体解析。
|
||
var out T
|
||
if err := json.Unmarshal([]byte(clean), &out); err == nil {
|
||
return &out, nil
|
||
}
|
||
|
||
// 3. 若模型前后带了额外文本,则提取最外层对象再解析。
|
||
start := strings.Index(clean, "{")
|
||
end := strings.LastIndex(clean, "}")
|
||
if start == -1 || end == -1 || end <= start {
|
||
return nil, fmt.Errorf("no json object found")
|
||
}
|
||
obj := clean[start : end+1]
|
||
if err := json.Unmarshal([]byte(obj), &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|