From 736ba0cff3c810246bd458494537bfdde5c04584 Mon Sep 17 00:00:00 2001
From: LoveLosita <2810873701@qq.com>
Date: Mon, 27 Apr 2026 12:20:17 +0800
Subject: [PATCH] =?UTF-8?q?Version:=200.9.46.dev.260427=20=E5=90=8E?=
=?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20taskclass=20=E6=89=A7=E8=A1=8C?=
=?UTF-8?q?=E9=97=AD=E7=8E=AF=E7=BB=A7=E7=BB=AD=E6=94=B6=E7=B4=A7=E2=80=94?=
=?UTF-8?q?=E2=80=94Plan=20/=20Execute=20=E5=85=A8=E9=9D=A2=E5=88=87?=
=?UTF-8?q?=E5=88=B0=E2=80=9C=E6=9C=80=E5=B0=8F=E5=B7=A5=E5=85=B7=E9=97=AD?=
=?UTF-8?q?=E7=8E=AF=E2=80=9D=E8=A7=86=E8=A7=92=EF=BC=8C=E6=98=8E=E7=A1=AE?=
=?UTF-8?q?=E5=AD=A6=E4=B9=A0=E7=9B=AE=E6=A0=87/=E6=80=BB=E8=8A=82?=
=?UTF-8?q?=E6=95=B0/=E7=A6=81=E6=8E=92=E6=97=B6=E6=AE=B5/=E6=8E=92?=
=?UTF-8?q?=E9=99=A4=E6=98=9F=E6=9C=9F=E9=BB=98=E8=AE=A4=E5=81=9C=E7=95=99?=
=?UTF-8?q?=20taskclass=20=E5=9F=9F=EF=BC=9B=E6=9C=AA=E7=BB=99=E6=97=A5?=
=?UTF-8?q?=E6=9C=9F=E8=8C=83=E5=9B=B4=E6=97=B6=E7=A6=81=E6=AD=A2=E6=93=85?=
=?UTF-8?q?=E8=87=AA=E8=A1=A5=20start=5Fdate/end=5Fdate=EF=BC=8Cupsert=5Ft?=
=?UTF-8?q?ask=5Fclass=20=E9=87=8D=E8=AF=95=E5=89=8D=E5=85=88=E5=81=9A?=
=?UTF-8?q?=E5=86=99=E5=89=8D=E6=A3=80=E6=9F=A5=E5=B9=B6=E5=8C=BA=E5=88=86?=
=?UTF-8?q?=E2=80=9C=E5=86=85=E9=83=A8=E8=A1=A8=E7=A4=BA=E4=BF=AE=E6=AD=A3?=
=?UTF-8?q?=E2=80=9D=E4=B8=8E=E2=80=9C=E5=BF=85=E9=A1=BB=E8=BF=BD=E9=97=AE?=
=?UTF-8?q?=E7=94=A8=E6=88=B7=E2=80=9D=E7=9A=84=E5=85=B3=E9=94=AE=E6=97=B6?=
=?UTF-8?q?=E9=97=B4=E4=BA=8B=E5=AE=9E=202.=20QuickTask=20/=20TaskQuery=20?=
=?UTF-8?q?=E8=BD=BB=E9=87=8F=E9=93=BE=E8=B7=AF=E7=BB=A7=E7=BB=AD=E6=94=B6?=
=?UTF-8?q?=E6=95=9B=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20model/taskquery?=
=?UTF-8?q?=5Fcontract.go=20=E7=BB=9F=E4=B8=80=E6=9F=A5=E8=AF=A2=E5=8D=8F?=
=?UTF-8?q?=E8=AE=AE=EF=BC=8CQuickTaskDeps=20/=20start.go=20=E6=94=B9?=
=?UTF-8?q?=E7=94=A8=20model=20=E5=B1=82=E5=8F=82=E6=95=B0=EF=BC=9B?=
=?UTF-8?q?=E5=88=A0=E9=99=A4=20query=5Ftasks=20/=20quick=5Fnote=5Fcreate?=
=?UTF-8?q?=20=E6=97=A7=E5=B7=A5=E5=85=B7=E5=AE=9E=E7=8E=B0=EF=BC=8C?=
=?UTF-8?q?=E9=81=BF=E5=85=8D=E4=BB=BB=E5=8A=A1=E6=9F=A5=E8=AF=A2=E4=B8=8E?=
=?UTF-8?q?=E9=9A=8F=E5=8F=A3=E8=AE=B0=E5=86=8D=E5=9B=9E=E6=B5=81=20execut?=
=?UTF-8?q?e=20=E5=B7=A5=E5=85=B7=E9=93=BE=203.=20schedule=20=E5=BE=AE?=
=?UTF-8?q?=E8=B0=83=E5=B7=A5=E5=85=B7=E7=BB=A7=E7=BB=AD=E7=98=A6=E8=BA=AB?=
=?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=8B=E7=BA=BF=20spread=5Feven=20/=20min?=
=?UTF-8?q?=5Fcontext=5Fswitch=20=E5=8F=8A=E5=85=B6=E5=A4=8D=E5=90=88?=
=?UTF-8?q?=E8=A7=84=E5=88=92=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B8=85=E7=90=86?=
=?UTF-8?q?=20analyze=5Fload=20/=20analyze=5Fsubjects=20/=20analyze=5Fcont?=
=?UTF-8?q?ext=20/=20analyze=5Ftolerance=20=E7=AD=89=E5=8E=86=E5=8F=B2?=
=?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=9Bexecute=20=E9=A1=BA=E5=BA=8F?=
=?UTF-8?q?=E7=AD=96=E7=95=A5=E6=94=B6=E6=95=9B=E4=B8=BA=E5=B1=80=E9=83=A8?=
=?UTF-8?q?=20move=20/=20swap=EF=BC=8C=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8E?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=9B=AE=E5=BD=95=E4=BB=85=E6=9A=B4=E9=9C=B2?=
=?UTF-8?q?=E5=BD=93=E5=89=8D=E7=9C=9F=E5=AE=9E=E5=8F=AF=E7=94=A8=E5=B7=A5?=
=?UTF-8?q?=E5=85=B7=204.=20=E6=89=A7=E8=A1=8C=E4=B8=8E=E6=97=B6=E9=97=B4?=
=?UTF-8?q?=E7=BA=BF=E4=BD=93=E9=AA=8C=E8=A1=A5=E9=BD=90=E2=80=94=E2=80=94?=
=?UTF-8?q?execute=20=E4=B8=BA=E6=B5=81=E5=BC=8F=20speak=20=E8=A1=A5?=
=?UTF-8?q?=E5=8F=91=E5=BD=92=E4=B8=80=E5=8C=96=E5=B0=BE=E9=83=A8=EF=BC=8C?=
=?UTF-8?q?=E9=81=BF=E5=85=8D=20deliver=20=E6=96=87=E6=A1=88=E9=BB=8F?=
=?UTF-8?q?=E8=BF=9E=EF=BC=9B=E5=89=8D=E7=AB=AF=E6=97=B6=E9=97=B4=E7=BA=BF?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=20interrupt=20/=20status=20=E5=8D=8F?=
=?UTF-8?q?=E8=AE=AE=E8=AF=86=E5=88=AB=E3=80=81=E5=B7=A5=E5=85=B7=E4=BA=8B?=
=?UTF-8?q?=E4=BB=B6=E5=BD=92=E5=B9=B6=E4=B8=8E=E7=8A=B6=E6=80=81=E8=BF=87?=
=?UTF-8?q?=E6=BB=A4=EF=BC=8C=E5=87=8F=E5=B0=91=20ToolTrace=20=E9=87=8D?=
=?UTF-8?q?=E5=A4=8D=E5=92=8C=E4=BC=9A=E8=AF=9D=E9=87=8D=E5=BB=BA=E8=AF=AF?=
=?UTF-8?q?=E5=88=A4=20=E5=89=8D=E7=AB=AF=EF=BC=9A=205.=20AssistantPanel?=
=?UTF-8?q?=20=E9=80=82=E9=85=8D=E6=96=B0=E7=89=88=20timeline=20extra=20?=
=?UTF-8?q?=E4=BA=8B=E4=BB=B6=E2=80=94=E2=80=94schedule=5Fagent.ts=20?=
=?UTF-8?q?=E8=A1=A5=E9=BD=90=20interrupt=20/=20status=20kind=EF=BC=8C?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E4=B8=8E=E7=BB=93=E6=9E=9C?=
=?UTF-8?q?=E6=8C=89=E6=91=98=E8=A6=81/=E5=8F=82=E6=95=B0/=E5=B7=A5?=
=?UTF-8?q?=E5=85=B7=E5=90=8D=E5=90=88=E5=B9=B6=EF=BC=8C=E6=81=A2=E5=A4=8D?=
=?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=97=B6=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=8D=8F?=
=?UTF-8?q?=E8=AE=AE=E4=BA=8B=E4=BB=B6=E8=AF=AF=E5=88=A4=E6=88=90=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/cmd/start.go | 6 +-
backend/logic/refine_compound_ops.go | 473 ------------
backend/logic/refine_compound_ops_test.go | 131 ----
backend/logic/smart_planning.go | 2 +-
backend/newAgent/model/common_state.go | 2 +-
backend/newAgent/model/graph_run_state.go | 4 +-
backend/newAgent/model/taskquery_contract.go | 35 +
backend/newAgent/node/execute.go | 100 +--
backend/newAgent/node/quick_task.go | 5 +-
backend/newAgent/prompt/execute.go | 17 +-
backend/newAgent/prompt/execute_context.go | 39 +-
.../prompt/execute_next_step_hint_v2.go | 4 +-
backend/newAgent/prompt/execute_rule_packs.go | 40 +-
backend/newAgent/prompt/plan.go | 78 +-
backend/newAgent/prompt/plan_context.go | 78 +-
backend/newAgent/tools/quicknote.go | 139 ----
backend/newAgent/tools/registry.go | 69 +-
.../newAgent/tools/schedule/analyze_tools.go | 178 -----
.../newAgent/tools/schedule/compound_tools.go | 707 ------------------
.../tools/schedule/order_constraints.go | 2 +-
backend/newAgent/tools/taskquery.go | 320 --------
backend/newAgent/tools/tool_domain_map.go | 15 +-
backend/service/agentsvc/agent_newagent.go | 2 +-
frontend/src/api/schedule_agent.ts | 10 +-
.../components/dashboard/AssistantPanel.vue | 142 +++-
25 files changed, 425 insertions(+), 2173 deletions(-)
delete mode 100644 backend/logic/refine_compound_ops.go
delete mode 100644 backend/logic/refine_compound_ops_test.go
create mode 100644 backend/newAgent/model/taskquery_contract.go
delete mode 100644 backend/newAgent/tools/quicknote.go
delete mode 100644 backend/newAgent/tools/schedule/compound_tools.go
delete mode 100644 backend/newAgent/tools/taskquery.go
diff --git a/backend/cmd/start.go b/backend/cmd/start.go
index e37c976..e5776b2 100644
--- a/backend/cmd/start.go
+++ b/backend/cmd/start.go
@@ -288,7 +288,7 @@ func Start() {
}
return created.ID, nil
},
- QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) {
+ QueryTasks: func(ctx context.Context, userID int, params newagentmodel.TaskQueryParams) ([]newagentmodel.TaskQueryResult, error) {
req := newagentmodel.TaskQueryRequest{
UserID: userID,
Quadrant: params.Quadrant,
@@ -304,13 +304,13 @@ func Start() {
if err != nil {
return nil, err
}
- results := make([]newagenttools.TaskQueryResult, 0, len(records))
+ results := make([]newagentmodel.TaskQueryResult, 0, len(records))
for _, r := range records {
deadlineStr := ""
if r.DeadlineAt != nil {
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
}
- results = append(results, newagenttools.TaskQueryResult{
+ results = append(results, newagentmodel.TaskQueryResult{
ID: r.ID,
Title: r.Title,
PriorityGroup: r.PriorityGroup,
diff --git a/backend/logic/refine_compound_ops.go b/backend/logic/refine_compound_ops.go
deleted file mode 100644
index 40c75fd..0000000
--- a/backend/logic/refine_compound_ops.go
+++ /dev/null
@@ -1,473 +0,0 @@
-package logic
-
-import (
- "fmt"
- "sort"
- "strings"
-)
-
-// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。
-//
-// 职责边界:
-// 1. 只承载“任务当前坐标 + 规划所需标签”;
-// 2. 不承载冲突判断、窗口判断等执行期逻辑;
-// 3. 由调用方保证 task_item_id 唯一且为正数。
-type RefineTaskCandidate struct {
- TaskItemID int
- Week int
- DayOfWeek int
- SectionFrom int
- SectionTo int
- Name string
- ContextTag string
- OriginRank int
-}
-
-// RefineSlotCandidate 表示复合工具可选落点(坑位)。
-//
-// 职责边界:
-// 1. 只描述可候选的时段坐标;
-// 2. 不描述“为什么可用”,可用性由调用方预先筛好;
-// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。
-type RefineSlotCandidate struct {
- Week int
- DayOfWeek int
- SectionFrom int
- SectionTo int
-}
-
-// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。
-type RefineMovePlanItem struct {
- TaskItemID int
- ToWeek int
- ToDay int
- ToSectionFrom int
- ToSectionTo int
-}
-
-// RefineCompositePlanOptions 是复合规划器的可选辅助输入。
-//
-// 说明:
-// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分;
-// 2. key 约定为 "week-day",例如 "16-3";
-// 3. 未提供时,规划器按 0 基线处理。
-type RefineCompositePlanOptions struct {
- ExistingDayLoad map[string]int
-}
-
-// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。
-//
-// 步骤化说明:
-// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出;
-// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度;
-// 3. 同分时按时间更早优先,进一步保证确定性;
-// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。
-func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
- normalizedTasks, err := normalizeRefineTasks(tasks)
- if err != nil {
- return nil, err
- }
- normalizedSlots, err := normalizeRefineSlots(slots)
- if err != nil {
- return nil, err
- }
- if len(normalizedSlots) < len(normalizedTasks) {
- return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
- }
-
- // 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。
- // 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。
- dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
- for key, value := range options.ExistingDayLoad {
- if value <= 0 {
- continue
- }
- dayLoad[strings.TrimSpace(key)] = value
- }
-
- used := make([]bool, len(normalizedSlots))
- moves := make([]RefineMovePlanItem, 0, len(normalizedTasks))
- selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks))
-
- for _, task := range normalizedTasks {
- taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
- bestIdx := -1
- bestScore := int(^uint(0) >> 1) // max int
-
- for idx, slot := range normalizedSlots {
- if used[idx] {
- continue
- }
- if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
- continue
- }
- if slotOverlapsAny(slot, selectedSlots) {
- continue
- }
- dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
- projectedLoad := dayLoad[dayKey] + 1
- // 1. projectedLoad 是主目标(越小越均衡);
- // 2. idx 是次级目标(越早的坑位越优先,保证稳定)。
- score := projectedLoad*10000 + idx
- if score < bestScore {
- bestScore = score
- bestIdx = idx
- }
- }
- if bestIdx < 0 {
- return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
- }
-
- chosen := normalizedSlots[bestIdx]
- used[bestIdx] = true
- selectedSlots = append(selectedSlots, chosen)
- dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
- moves = append(moves, RefineMovePlanItem{
- TaskItemID: task.TaskItemID,
- ToWeek: chosen.Week,
- ToDay: chosen.DayOfWeek,
- ToSectionFrom: chosen.SectionFrom,
- ToSectionTo: chosen.SectionTo,
- })
- }
- return moves, nil
-}
-
-// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。
-//
-// 步骤化说明:
-// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续;
-// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序;
-// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位;
-// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。
-func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
- normalizedTasks, err := normalizeRefineTasks(tasks)
- if err != nil {
- return nil, err
- }
- normalizedSlots, err := normalizeRefineSlots(slots)
- if err != nil {
- return nil, err
- }
- if len(normalizedSlots) < len(normalizedTasks) {
- return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
- }
-
- type taskGroup struct {
- ContextKey string
- Tasks []RefineTaskCandidate
- MinRank int
- }
- groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
- groupMap := make(map[string]*taskGroup)
- groupOrder := make([]string, 0, len(normalizedTasks))
-
- for _, task := range normalizedTasks {
- key := groupingKeys[task.TaskItemID]
- group, exists := groupMap[key]
- if !exists {
- group = &taskGroup{
- ContextKey: key,
- MinRank: normalizedOriginRank(task),
- }
- groupMap[key] = group
- groupOrder = append(groupOrder, key)
- }
- group.Tasks = append(group.Tasks, task)
- if rank := normalizedOriginRank(task); rank < group.MinRank {
- group.MinRank = rank
- }
- }
-
- groups := make([]taskGroup, 0, len(groupMap))
- for _, key := range groupOrder {
- group := groupMap[key]
- sort.SliceStable(group.Tasks, func(i, j int) bool {
- return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
- })
- groups = append(groups, *group)
- }
- sort.SliceStable(groups, func(i, j int) bool {
- if len(groups[i].Tasks) != len(groups[j].Tasks) {
- return len(groups[i].Tasks) > len(groups[j].Tasks)
- }
- if groups[i].MinRank != groups[j].MinRank {
- return groups[i].MinRank < groups[j].MinRank
- }
- return groups[i].ContextKey < groups[j].ContextKey
- })
-
- orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks))
- for _, group := range groups {
- orderedTasks = append(orderedTasks, group.Tasks...)
- }
-
- used := make([]bool, len(normalizedSlots))
- moves := make([]RefineMovePlanItem, 0, len(orderedTasks))
- selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks))
- for _, task := range orderedTasks {
- taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
- chosenIdx := -1
- for idx, slot := range normalizedSlots {
- if used[idx] {
- continue
- }
- if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
- continue
- }
- if slotOverlapsAny(slot, selectedSlots) {
- continue
- }
- chosenIdx = idx
- break
- }
- if chosenIdx < 0 {
- return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
- }
- chosen := normalizedSlots[chosenIdx]
- used[chosenIdx] = true
- selectedSlots = append(selectedSlots, chosen)
- moves = append(moves, RefineMovePlanItem{
- TaskItemID: task.TaskItemID,
- ToWeek: chosen.Week,
- ToDay: chosen.DayOfWeek,
- ToSectionFrom: chosen.SectionFrom,
- ToSectionTo: chosen.SectionTo,
- })
- }
- return moves, nil
-}
-
-func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) {
- if len(tasks) == 0 {
- return nil, fmt.Errorf("任务列表为空")
- }
- normalized := make([]RefineTaskCandidate, 0, len(tasks))
- seen := make(map[int]struct{}, len(tasks))
- for _, task := range tasks {
- if task.TaskItemID <= 0 {
- return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID)
- }
- if _, exists := seen[task.TaskItemID]; exists {
- return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID)
- }
- if !isValidDay(task.DayOfWeek) {
- return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek)
- }
- if !isValidSection(task.SectionFrom, task.SectionTo) {
- return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo)
- }
- seen[task.TaskItemID] = struct{}{}
- normalized = append(normalized, task)
- }
- sort.SliceStable(normalized, func(i, j int) bool {
- return compareTaskOrder(normalized[i], normalized[j]) < 0
- })
- return normalized, nil
-}
-
-func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) {
- if len(slots) == 0 {
- return nil, fmt.Errorf("可用坑位为空")
- }
- normalized := make([]RefineSlotCandidate, 0, len(slots))
- seen := make(map[string]struct{}, len(slots))
- for _, slot := range slots {
- if slot.Week <= 0 {
- return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
- }
- if !isValidDay(slot.DayOfWeek) {
- return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
- }
- if !isValidSection(slot.SectionFrom, slot.SectionTo) {
- return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
- }
- key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
- if _, exists := seen[key]; exists {
- continue
- }
- seen[key] = struct{}{}
- normalized = append(normalized, slot)
- }
- sort.SliceStable(normalized, func(i, j int) bool {
- if normalized[i].Week != normalized[j].Week {
- return normalized[i].Week < normalized[j].Week
- }
- if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
- return normalized[i].DayOfWeek < normalized[j].DayOfWeek
- }
- if normalized[i].SectionFrom != normalized[j].SectionFrom {
- return normalized[i].SectionFrom < normalized[j].SectionFrom
- }
- return normalized[i].SectionTo < normalized[j].SectionTo
- })
- return normalized, nil
-}
-
-func compareTaskOrder(a, b RefineTaskCandidate) int {
- rankA := normalizedOriginRank(a)
- rankB := normalizedOriginRank(b)
- if rankA != rankB {
- return rankA - rankB
- }
- if a.Week != b.Week {
- return a.Week - b.Week
- }
- if a.DayOfWeek != b.DayOfWeek {
- return a.DayOfWeek - b.DayOfWeek
- }
- if a.SectionFrom != b.SectionFrom {
- return a.SectionFrom - b.SectionFrom
- }
- if a.SectionTo != b.SectionTo {
- return a.SectionTo - b.SectionTo
- }
- return a.TaskItemID - b.TaskItemID
-}
-
-func normalizedOriginRank(task RefineTaskCandidate) int {
- if task.OriginRank > 0 {
- return task.OriginRank
- }
- // 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。
- // 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。
- return 1_000_000 + task.TaskItemID
-}
-
-func normalizeContextKey(tag string) string {
- text := strings.TrimSpace(tag)
- if text == "" {
- return "General"
- }
- return text
-}
-
-// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。
-//
-// 步骤化说明:
-// 1. 先优先使用现有 ContextTag,避免影响已稳定的显式标签链路;
-// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic),说明标签对“同科目连续”帮助不足;
-// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用;
-// 4. 若任务名也无法识别,则继续回落到原 ContextTag,保证行为可预测。
-func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string {
- keys := make(map[int]string, len(tasks))
- distinctExplicit := make(map[string]struct{}, len(tasks))
- distinctNonCoarse := make(map[string]struct{}, len(tasks))
-
- for _, task := range tasks {
- key := normalizeContextKey(task.ContextTag)
- keys[task.TaskItemID] = key
- distinctExplicit[key] = struct{}{}
- if !isCoarseContextKey(key) {
- distinctNonCoarse[key] = struct{}{}
- }
- }
-
- // 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义;
- // 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。
- if len(distinctNonCoarse) >= 2 {
- return keys
- }
- // 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用;
- // 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。
- if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
- return keys
- }
-
- inferredKeys := make(map[int]string, len(tasks))
- distinctInferred := make(map[string]struct{}, len(tasks))
- for _, task := range tasks {
- inferred := inferSubjectContextKeyFromTaskName(task.Name)
- if inferred == "" {
- inferred = keys[task.TaskItemID]
- }
- inferredKeys[task.TaskItemID] = inferred
- distinctInferred[inferred] = struct{}{}
- }
- if len(distinctInferred) >= 2 {
- return inferredKeys
- }
- return keys
-}
-
-func isCoarseContextKey(key string) bool {
- switch strings.ToLower(strings.TrimSpace(key)) {
- case "", "general", "high-logic", "high_logic", "memory", "review":
- return true
- default:
- return false
- }
-}
-
-func inferSubjectContextKeyFromTaskName(name string) string {
- text := strings.ToLower(strings.TrimSpace(name))
- if text == "" {
- return ""
- }
-
- subjectKeywordGroups := []struct {
- keywords []string
- groupKey string
- }{
- {
- keywords: []string{
- "概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
- "分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
- },
- groupKey: "subject:probability",
- },
- {
- keywords: []string{
- "数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
- "数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
- },
- groupKey: "subject:digital_logic",
- },
- {
- keywords: []string{
- "命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
- "图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
- },
- groupKey: "subject:discrete_math",
- },
- }
- for _, group := range subjectKeywordGroups {
- for _, keyword := range group.keywords {
- if strings.Contains(text, keyword) {
- return group.groupKey
- }
- }
- }
- return ""
-}
-
-func composeDayKey(week, day int) string {
- return fmt.Sprintf("%d-%d", week, day)
-}
-
-func sectionSpan(from, to int) int {
- return to - from + 1
-}
-
-func isValidDay(day int) bool {
- return day >= 1 && day <= 7
-}
-
-func isValidSection(from, to int) bool {
- if from < 1 || to > 12 {
- return false
- }
- return from <= to
-}
-
-func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool {
- for _, current := range selected {
- if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
- continue
- }
- if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
- return true
- }
- }
- return false
-}
diff --git a/backend/logic/refine_compound_ops_test.go b/backend/logic/refine_compound_ops_test.go
deleted file mode 100644
index 1c45ffa..0000000
--- a/backend/logic/refine_compound_ops_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package logic
-
-import (
- "sort"
- "testing"
-)
-
-func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) {
- tasks := []RefineTaskCandidate{
- {TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1},
- {TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2},
- }
- slots := []RefineSlotCandidate{
- {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
- {Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2},
- {Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
- }
- moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{
- ExistingDayLoad: map[string]int{
- composeDayKey(12, 1): 5,
- composeDayKey(12, 2): 1,
- composeDayKey(12, 3): 0,
- },
- })
- if err != nil {
- t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err)
- }
- if len(moves) != 2 {
- t.Fatalf("期望移动 2 条,实际=%d", len(moves))
- }
-
- // 1. 低负载日(周三)应优先被填充;
- // 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。
- weekDayByID := make(map[int][2]int, len(moves))
- for _, move := range moves {
- weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay}
- }
- if got := weekDayByID[101]; got != [2]int{12, 3} {
- t.Fatalf("任务101应优先落到 W12D3,实际=%v", got)
- }
- if got := weekDayByID[102]; got != [2]int{12, 2} {
- t.Fatalf("任务102应落到 W12D2,实际=%v", got)
- }
-}
-
-func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) {
- tasks := []RefineTaskCandidate{
- {TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1},
- {TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2},
- {TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3},
- }
- slots := []RefineSlotCandidate{
- {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
- {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
- {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
- }
- moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
- if err != nil {
- t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
- }
- if len(moves) != 3 {
- t.Fatalf("期望移动 3 条,实际=%d", len(moves))
- }
-
- // 1. “数学”有 2 条,分组后应先连续落在最早两个坑位;
- // 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4(顺序由 origin_rank 决定)。
- sort.SliceStable(moves, func(i, j int) bool {
- if moves[i].ToWeek != moves[j].ToWeek {
- return moves[i].ToWeek < moves[j].ToWeek
- }
- if moves[i].ToDay != moves[j].ToDay {
- return moves[i].ToDay < moves[j].ToDay
- }
- return moves[i].ToSectionFrom < moves[j].ToSectionFrom
- })
- if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 {
- t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves)
- }
- if moves[2].TaskItemID != 202 {
- t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2])
- }
-}
-
-func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) {
- tasks := []RefineTaskCandidate{
- {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1},
- {TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2},
- {TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3},
- }
- slots := []RefineSlotCandidate{
- {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
- {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
- {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
- }
- moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
- if err != nil {
- t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
- }
- if len(moves) != 3 {
- t.Fatalf("期望移动 3 条,实际=%d", len(moves))
- }
-
- sort.SliceStable(moves, func(i, j int) bool {
- if moves[i].ToWeek != moves[j].ToWeek {
- return moves[i].ToWeek < moves[j].ToWeek
- }
- if moves[i].ToDay != moves[j].ToDay {
- return moves[i].ToDay < moves[j].ToDay
- }
- return moves[i].ToSectionFrom < moves[j].ToSectionFrom
- })
- if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 {
- t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves)
- }
- if moves[2].TaskItemID != 302 {
- t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2])
- }
-}
-
-func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) {
- tasks := []RefineTaskCandidate{
- {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3
- }
- slots := []RefineSlotCandidate{
- {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2
- }
- _, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{})
- if err == nil {
- t.Fatalf("期望 span 不匹配时报错,实际 err=nil")
- }
-}
diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go
index 0b055fa..12b8f04 100644
--- a/backend/logic/smart_planning.go
+++ b/backend/logic/smart_planning.go
@@ -173,7 +173,7 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla
if err != nil {
return nil, err
}
- //3.把这些时间通过DTO函数回填到涉��周的 UserWeekSchedule 结构中,供前端展示
+ // 3. 把这些时间通过 DTO 函数回填到涉及周的 UserWeekSchedule 结构中,供前端展示。
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
}
diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go
index 0432f0a..324b2fd 100644
--- a/backend/newAgent/model/common_state.go
+++ b/backend/newAgent/model/common_state.go
@@ -189,7 +189,7 @@ type CommonState struct {
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
- // UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
+ // UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径。
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
UsedQuickNote bool `json:"used_quick_note,omitempty"`
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go
index cf82c04..6e2b757 100644
--- a/backend/newAgent/model/graph_run_state.go
+++ b/backend/newAgent/model/graph_run_state.go
@@ -105,12 +105,12 @@ type AgentGraphDeps struct {
//
// 职责边界:
// 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry,不走 ReAct 循环;
-// 2. CreateTask 和 QueryTasks 的签名与 tools 包的 QuickNoteDeps / TaskQueryDeps 一致。
+// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
type QuickTaskDeps struct {
// CreateTask 创建一条四象限任务,返回 task_id。
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
// QueryTasks 按条件查询用户任务列表。
- QueryTasks func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error)
+ QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
}
// --- 记忆 pinned block 常量(供 agentsvc 和 node 层共享) ---
diff --git a/backend/newAgent/model/taskquery_contract.go b/backend/newAgent/model/taskquery_contract.go
new file mode 100644
index 0000000..18774b5
--- /dev/null
+++ b/backend/newAgent/model/taskquery_contract.go
@@ -0,0 +1,35 @@
+package model
+
+import "time"
+
+// TaskQueryParams 描述快捷任务查询路径传给业务层的内部查询参数。
+//
+// 职责边界:
+// 1. 这里只承载“查询条件”本身,不负责 args 解析、默认值填充和错误提示;
+// 2. 所有字段均为轻量筛选语义,便于 quick_task 节点和 service 层直接复用;
+// 3. 不承担 LLM 工具协议,因为 query_tasks 工具链已下线。
+type TaskQueryParams struct {
+ Quadrant *int
+ SortBy string // deadline | priority | id
+ Order string // asc | desc
+ Limit int
+ IncludeCompleted bool
+ Keyword string
+ DeadlineBefore *time.Time
+ DeadlineAfter *time.Time
+}
+
+// TaskQueryResult 描述快捷任务查询返回给上层的轻量任务视图。
+//
+// 职责边界:
+// 1. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 newAgent 节点;
+// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
+// 3. 不负责序列化策略和文案渲染。
+type TaskQueryResult struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ PriorityGroup int `json:"priority_group"`
+ PriorityLabel string `json:"priority_label"`
+ IsCompleted bool `json:"is_completed"`
+ DeadlineAt string `json:"deadline_at,omitempty"`
+}
diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go
index 38aa9b8..6cb7b68 100644
--- a/backend/newAgent/node/execute.go
+++ b/backend/newAgent/node/execute.go
@@ -27,7 +27,6 @@ const (
executeStatusBlockID = "execute.status"
executeSpeakBlockID = "execute.speak"
executePinnedKey = "execution_context"
- toolMinContextSwitch = "min_context_switch"
toolAnalyzeHealth = "analyze_health"
executeHistoryKindKey = "newagent_history_kind"
executeHistoryKindStepAdvanced = "execute_step_advanced"
@@ -419,7 +418,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
// 非写工具的 confirm 动作自动降级为 continue。
- // 调用目的:quick_note_create 等非写工具不应走确认卡片流程;
+ // 调用目的:快捷随口记这类非日程写工具不应走确认卡片流程;
// 即使 LLM 误输出 action=confirm,也在此处强制修正,
// 确保 speak 正常推流和持久化,不会因 confirm 卡片跳过 persistVisibleAssistantMessage。
if decision.Action == newagentmodel.ExecuteActionConfirm &&
@@ -454,6 +453,25 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
firstChunk = false
}
+ // 1. execute 正文若已经在流式阶段推给前端,normalizeSpeak 新补出来的尾部(最常见是末尾 \n)
+ // 不会自动回流到前端,只会留在 history / persist 中。
+ // 2. 这会导致下一跳 deliver 首条正文直接接在 execute 最后一段后面,前端表现成两段文本黏连。
+ // 3. 这里只补发“归一化后新增的尾巴”,不重发整段正文,也不改写中间内容,避免误伤已有流式体验。
+ if speakStreamed {
+ streamedText := fullText.String()
+ if tail := buildExecuteNormalizedSpeakTail(streamedText, decision.Speak); tail != "" {
+ if emitErr := emitter.EmitAssistantText(
+ executeSpeakBlockID,
+ executeStageName,
+ tail,
+ firstChunk,
+ ); emitErr != nil {
+ return fmt.Errorf("执行文案尾部补发失败: %w", emitErr)
+ }
+ firstChunk = false
+ }
+ }
+
// 自省校验(仅 Plan 模式):next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。
//
// 1. ReAct(无预定义步骤)下不强制 goal_check,避免 done 被错误拦截后进入循环;
@@ -514,7 +532,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 继续当前步骤的 ReAct 循环。
// 若有工具调用意图,则执行工具并记录证据。
if decision.ToolCall != nil {
- // 1. 写工具必须走 confirm;continue 只允许读工具。
+ // 1. 所有写工具都必须走 confirm;continue 只允许读工具。
// 2. 若模型误输出 continue+写工具,这里先做纠偏,不直接执行写操作。
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
flowState.ConsecutiveCorrections++
@@ -533,7 +551,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
executeStatusBlockID,
executeStageName,
"executing",
- fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;日程修改工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
+ fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
false,
)
llmOutput := decision.Speak
@@ -544,7 +562,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
conversationContext,
llmOutput,
fmt.Sprintf("你输出了 action=continue,但工具 %q 属于写操作。", decision.ToolCall.Name),
- "写操作必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
+ "所有写操作都必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
)
return nil
}
@@ -1699,28 +1717,6 @@ func executeToolCall(
}
// 2. 执行工具。
- // 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch,并写回工具观察结果。
- if shouldBlockMinContextSwitch(flowState, toolName) {
- blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
- log.Printf(
- "[WARN] execute tool blocked chat=%s round=%d tool=%s allow_reorder=%v",
- flowState.ConversationID,
- flowState.RoundUsed,
- toolName,
- flowState.AllowReorder,
- )
- _ = emitter.EmitToolCallResult(
- executeStatusBlockID,
- executeStageName,
- toolName,
- "blocked",
- blockedResult,
- buildToolArgumentsPreviewCN(toolCall.Arguments),
- false,
- )
- appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
- return nil
- }
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
@@ -1845,19 +1841,6 @@ func buildTemporarilyDisabledToolResult(toolName string) string {
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等基础微调工具。", strings.TrimSpace(toolName))
}
-// shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。
-//
-// 说明:
-// 1. 仅当工具名为 min_context_switch 且未授权打乱顺序时返回 true;
-// 2. 其余场景统一放行;
-// 3. nil flowState 视为未命中拦截条件,避免因状态缺失导致误阻断。
-func shouldBlockMinContextSwitch(flowState *newagentmodel.CommonState, toolName string) bool {
- if flowState == nil {
- return false
- }
- return !flowState.AllowReorder && strings.EqualFold(strings.TrimSpace(toolName), toolMinContextSwitch)
-}
-
// executePendingTool 执行用户已确认的写工具。
//
// 职责边界:
@@ -1920,22 +1903,6 @@ func executePendingTool(
return nil
}
- // 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。
- if shouldBlockMinContextSwitch(flowState, pending.ToolName) {
- blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
- _ = emitter.EmitToolCallResult(
- executeStatusBlockID,
- executeStageName,
- pending.ToolName,
- "blocked",
- blockedResult,
- buildToolArgumentsPreviewCN(args),
- false,
- )
- appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
- runtimeState.PendingConfirmTool = nil
- return nil
- }
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
@@ -2060,6 +2027,24 @@ func normalizeSpeak(speak string) string {
return speak + "\n"
}
+// buildExecuteNormalizedSpeakTail 计算“归一化后新增、但前端尚未收到”的 execute 文案尾巴。
+//
+// 职责边界:
+// 1. 只处理“streamed 原文是 normalized 的前缀”这一保守场景,典型就是只缺末尾换行;
+// 2. 不尝试回放中间格式差异,避免把整段已流式输出的正文再推一遍;
+// 3. 若无法安全判断差额,则返回空串,交给现有行为继续执行。
+func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
+ streamed = strings.ReplaceAll(streamed, "\r\n", "\n")
+ normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
+ if streamed == "" || normalized == "" {
+ return ""
+ }
+ if !strings.HasPrefix(normalized, streamed) {
+ return ""
+ }
+ return normalized[len(streamed):]
+}
+
// truncateText 截断文本到指定长度。
//
// 用于状态推送时避免超长文本影响前端展示。
@@ -2397,15 +2382,12 @@ func resolveToolDisplayNameCN(toolName string) string {
"get_task_info": "查看任务详情",
"analyze_health": "综合体检",
"analyze_rhythm": "分析学习节奏",
- "analyze_tolerance": "分析容错空间",
"web_search": "网页搜索",
"web_fetch": "网页抓取",
"move": "移动任务",
"place": "放置任务",
"swap": "交换任务",
"batch_move": "批量移动任务",
- "spread_even": "均匀分散任务",
- "min_context_switch": "减少上下文切换",
"unplace": "移除任务安排",
"upsert_task_class": "写入任务类",
"context_tools_add": "激活工具域",
diff --git a/backend/newAgent/node/quick_task.go b/backend/newAgent/node/quick_task.go
index fb93518..5a6a1da 100644
--- a/backend/newAgent/node/quick_task.go
+++ b/backend/newAgent/node/quick_task.go
@@ -14,7 +14,6 @@ import (
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
- newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/cloudwego/eino/schema"
)
@@ -272,7 +271,7 @@ func handleQuickTaskQuery(
decision *quickTaskDecision,
flowState *newagentmodel.CommonState,
) string {
- params := newagenttools.TaskQueryParams{
+ params := newagentmodel.TaskQueryParams{
SortBy: "deadline",
Order: "asc",
Limit: 5,
@@ -316,7 +315,7 @@ func handleQuickTaskQuery(
return sb.String()
}
-// quickNoteFallbackPriority 根据截止时间推断默认优先级,与 tools/quicknote.go 保持一致。
+// quickNoteFallbackPriority 根据截止时间推断默认优先级。
func quickNoteFallbackPriority(deadline *time.Time) int {
if deadline != nil {
if time.Until(*deadline) <= 48*time.Hour {
diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go
index 4c38aeb..3510c53 100644
--- a/backend/newAgent/prompt/execute.go
+++ b/backend/newAgent/prompt/execute.go
@@ -90,7 +90,7 @@ func buildExecutePromptWithFormatGuard(base string) string {
输出协议硬约束:
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。
-3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空。
+3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具。
4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。
5. 只有 action=abort 时才允许输出 abort 字段。
6. 标签内只放 JSON,不要放自然语言。
@@ -111,14 +111,27 @@ func buildExecuteStrictJSONUserPrompt() string {
执行提醒:
- JSON 中不要包含 speak 字段;给用户看的话放在 标签之后
- 不要在 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
-- 日程写工具(place/move/swap/batch_move/unplace)一律走 action=confirm
+- 任何写工具都一律走 action=confirm,包括 upsert_task_class 与日程写工具(place/move/swap/batch_move/unplace);哪怕只是“按 validation.issues 重试一次”,也不能输出 continue + 写工具
- 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
- 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
- web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充
+- 任何写工具在真正输出 action=confirm 前,都必须先做一次“写前检查”:确认参数已齐全、格式合法、业务前提已满足;若尚未通过检查,就先补齐/归一/生成/ask_user,不要把 validation 失败当成正常探索路径
- upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done
+- 对 upsert_task_class,写前至少检查:mode=auto 时日期边界是否已满足;subject_type / difficulty_level / cognitive_intensity 是否齐;difficulty_level 是否已映射到合法枚举;items 是否非空且顺序内容已生成;config 中已知约束字段是否已落到合法格式
+- 若像 items 这种内容本就由当前轮模型负责生成,就应先把内容生成齐、顺序排好,再写入;不要先写一个 items 为空的 taskclass 去让 validation 提醒你补内容
+- 处理 validation.issues 时先分类:若是用户关键信息确实缺失,才 action=ask_user;若是 schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射等内部表示问题,应静默改参后直接重试,不要把底层表示教学抛给用户
+- 像 config.excluded_slots 的半天块索引映射,默认属于内部表示修正:你应自己把“第1-2节 / 第11-12节”换算成合法块索引,不要为此 ask_user,不要长篇解释底层表示
+- 当前时间锚点只用于解析用户已经明确说出的相对时间(如“今天开始”“两周内”“下周一前”),不能反过来把“现在是今天”当成用户已经同意从今天开始,更不能据此默认生成 start_date / end_date
+- 像 auto 模式缺 start_date/end_date 这类问题,先检查当前对话、历史、记忆、已知工具结果里是否已经出现可用日期;若已出现就静默补齐并重试,只有在上下文里确实没有时再 ask_user
+- 若当前是首次创建/修正 taskclass,且上下文里并没有用户明确给出的开始日期、结束日期、日期范围、完成期限、或可直接换算出的相对时间承诺,就不要擅自写 start_date / end_date;此时若工具闭环确实要求这些字段,必须 ask_user
+- 对 taskclass 来说,以下属于必须 ask_user 的关键信息:start_date、end_date、明确的日期范围、明确的开始时间承诺、明确的完成期限;这些会决定任务类的真实时间边界,不能由模型自行拍板
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
+- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这些默认都只是 taskclass 语义;不要因为信息完整就自动切进 schedule
+- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许 context_tools_add domain="schedule"、触发 rough_build,或继续 schedule 链路
+- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应先生成或更新 taskclass,而不是主动排进日程
- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
+- 当前轮目标若是创建/修正 taskclass,就优先把 taskclass 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上
`)
}
diff --git a/backend/newAgent/prompt/execute_context.go b/backend/newAgent/prompt/execute_context.go
index d87d60d..2595caf 100644
--- a/backend/newAgent/prompt/execute_context.go
+++ b/backend/newAgent/prompt/execute_context.go
@@ -231,9 +231,9 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
if state != nil {
if state.AllowReorder {
- lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
+ lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。")
} else {
- lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
+ lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。")
}
}
@@ -269,7 +269,7 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
//
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
-// 3. P1 阶段隐藏 min_context_switch,避免模型误用已禁能力。
+// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面。
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string {
if ctx == nil {
return ""
@@ -286,10 +286,6 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, sta
if name == "" {
continue
}
- if shouldHideMinContextSwitchForP1(state, name) {
- continue
- }
-
index++
desc := strings.TrimSpace(schemaItem.Desc)
if desc == "" {
@@ -329,7 +325,6 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
"web_fetch",
"analyze_health",
"analyze_rhythm",
- "analyze_tolerance",
"upsert_task_class":
return true
default:
@@ -340,7 +335,7 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
func renderExecuteToolCallHint(toolName string) string {
switch strings.ToLower(strings.TrimSpace(toolName)) {
case "upsert_task_class":
- return `{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,11],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
+ return `仅当用户或上下文已明确给出日期范围时,才允许写入 start_date/end_date;写前先检查 difficulty_level 已归一为 low/medium/high,items 已非空且内容顺序已生成完成:{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,6],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
default:
return ""
}
@@ -375,10 +370,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return returnType, "交换完成:[35]... ↔ [36]..."
case "batch_move":
return returnType, "批量移动完成,2 个任务全部成功。"
- case "spread_even":
- return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
- case "min_context_switch":
- return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
case "unplace":
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
case "web_search":
@@ -389,8 +380,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
case "analyze_rhythm":
return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
- case "analyze_tolerance":
- return "string(JSON字符串)", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
case "upsert_task_class":
return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
default:
@@ -564,9 +553,8 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string {
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
- "analyze_health": {},
- "analyze_rhythm": {},
- "analyze_tolerance": {},
+ "analyze_health": {},
+ "analyze_rhythm": {},
})
if !ok {
return ""
@@ -582,8 +570,6 @@ func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext)
"batch_move": {},
"unplace": {},
"queue_apply_head_move": {},
- "spread_even": {},
- "min_context_switch": {},
})
if !ok {
return ""
@@ -790,14 +776,13 @@ func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string {
}
}
if !state.TaskClassUpsertLastSuccess {
+ lines = append(lines, "- 写前最少检查项:mode=auto 的 start_date/end_date、subject_type/difficulty_level/cognitive_intensity、difficulty_level 合法枚举、items 非空且内容已生成、config 约束字段合法。")
+ lines = append(lines, "- 先判断当前 issues 属于哪一类:若是 schema 字段名、字段位置、半天块索引、枚举值、日期格式、工具语义映射等内部表示问题,直接静默改参重试。")
+ lines = append(lines, "- 若 issue 指向 start_date/end_date 等字段,先检查当前对话、历史、记忆、最近工具结果里是否已出现可用值;只有确实没有时再 ask_user。")
+ lines = append(lines, "- 若缺的是 start_date/end_date/日期范围/开始日期承诺/完成期限,而这些值并未在上下文中出现,就必须 ask_user;不能把当前日期或默认周期当成用户已同意的时间边界。")
+ lines = append(lines, "- 若 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,就先在本轮静默归一/补齐/生成,再 confirm 重试;不要把 validation 当试错器。")
+ lines = append(lines, "- 若再次调用 upsert_task_class,动作必须是 confirm,不能输出 continue + tool_call。")
lines = append(lines, "- 在 issues 处理完之前,不要用 done 收口。")
}
return strings.Join(lines, "\n")
}
-
-func shouldHideMinContextSwitchForP1(state *newagentmodel.CommonState, toolName string) bool {
- if strings.TrimSpace(toolName) != "min_context_switch" {
- return false
- }
- return true
-}
diff --git a/backend/newAgent/prompt/execute_next_step_hint_v2.go b/backend/newAgent/prompt/execute_next_step_hint_v2.go
index a1d7927..a1fc224 100644
--- a/backend/newAgent/prompt/execute_next_step_hint_v2.go
+++ b/backend/newAgent/prompt/execute_next_step_hint_v2.go
@@ -78,7 +78,7 @@ func renderExecuteNextStepHintV2(
if roughBuildDone {
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。`
}
- return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。`
+ return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass;只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。`
}
if activeDomain == "schedule" &&
@@ -97,7 +97,7 @@ func renderExecuteNextStepHintV2(
if activeDomain == "taskclass" &&
state.TaskClassUpsertLastTried &&
!state.TaskClassUpsertLastSuccess {
- return `先根据 validation.issues 补齐缺失字段,再重试 upsert_task_class,不要直接收口。`
+ return `先判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class,不要继续解释底层约束,更不要直接收口。`
}
return ""
diff --git a/backend/newAgent/prompt/execute_rule_packs.go b/backend/newAgent/prompt/execute_rule_packs.go
index 5ea0846..edeb3a7 100644
--- a/backend/newAgent/prompt/execute_rule_packs.go
+++ b/backend/newAgent/prompt/execute_rule_packs.go
@@ -178,9 +178,11 @@ func buildExecuteCoreMinPack() executeRulePack {
Name: executeRulePackCoreMin,
Content: strings.TrimSpace(fmt.Sprintf(`
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
-- 用户意图优先:只推进用户当前明确要求;未明确部分优先 ask_user。
+- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。
+- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass,不要主动切到 schedule。
+- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。
- 先事实后动作:优先读工具补齐事实,再决定下一步。
-- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirm;continue + 写工具无效。
+- 只要决定调用任何写工具,就必须输出 action=confirm;continue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。
- 输出格式固定:先 {JSON},再输出用户可见正文。`,
buildExecuteNowAnchorLine())),
}
@@ -197,9 +199,9 @@ func buildExecuteSafetyHardPack() executeRulePack {
Name: executeRulePackSafetyHard,
Content: strings.TrimSpace(`
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
+- P1 阶段禁止调用 min_context_switch。
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
-- P1 阶段禁止调用 min_context_switch。
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
}
}
@@ -210,6 +212,7 @@ func buildExecuteContextProtocolPack() executeRulePack {
Content: strings.TrimSpace(`
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
- 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。
+- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。
- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`),
@@ -232,7 +235,7 @@ func buildExecuteModeReActPack() executeRulePack {
Name: executeRulePackModeReAct,
Content: strings.TrimSpace(`
- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。
-- 如果关键事实无法通过工具补齐,优先 ask_user,不做猜测落库。
+- 如果关键事实既无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user;不要把本可静默修正的内部表示问题转嫁给用户。
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
}
}
@@ -242,6 +245,8 @@ func buildExecuteSchedulePack() executeRulePack {
Name: executeRulePackDomainSchedule,
Content: strings.TrimSpace(`
- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。
+- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。
+- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule;这些默认仍属于 taskclass 约束。
- existing 只作事实参考;真正可调对象优先看 suggested。
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
}
@@ -281,8 +286,28 @@ func buildExecuteTaskClassPack() executeRulePack {
Name: executeRulePackDomainTaskClass,
Content: strings.TrimSpace(`
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
+- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 taskclass 语义中。
+- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应进入或停留 taskclass,而不是主动切 schedule,也通常不需要 ask_user。
+- 在真正调用 upsert_task_class 前,必须先做一轮写前检查;只有当参数已齐全、格式合法、业务前提已满足时,才允许输出 confirm。
+- 不要把 validation 失败当成正常试错器;validation 只用于兜底发现漏项,不应成为“先乱写一次看看后端报什么”的主流程。
+- upsert_task_class 写前最少检查项:
+ 1. mode=auto 时,task_class 顶层 start_date/end_date 是否已经满足。
+ 2. subject_type / difficulty_level / cognitive_intensity 是否齐全。
+ 3. difficulty_level 是否已归一到合法枚举 low/medium/high。
+ 4. items 是否非空,且顺序与内容是否已在当前轮生成完成。
+ 5. config 中已知约束字段是否已是合法格式,例如 excluded_slots 半天块索引、excluded_days_of_week 取值范围、total_slots/strategy 等。
+- 若像 items 这种内容本就由当前轮模型负责生成,就应先生成齐再写,不要把空 items 提交给 validation 去提醒你补课表内容。
- upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。
+- 先区分 issue 类型:schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射,属于内部表示修正,应静默改参后直接重试;真正缺少用户关键信息时,才 ask_user。
+- taskclass 里的“关键信息缺失”要收窄定义:真正必须 ask_user 的,是会决定任务类真实时间边界/时间承诺的字段,而不是内部表示问题。
+- 必须 ask_user 的时间参数/条件包括:start_date、end_date、明确日期范围、明确开始日期承诺、明确完成期限;如果这些信息在当前对话、历史、记忆里都不存在,就不能由你自行拍板。
+- 当前时间锚点只能用来解析用户已经说出的相对时间;若用户没说“今天开始 / 本周内 / 两周内 / 下周前”这类时间承诺,不能因为“今天是 2026-04-27”就默认 start_date=今天,也不能默认补一个 end_date。
+- 禁排时段、排除星期、总节数、难度、内容拆分授权,不等于用户已经给出了日期范围;这些信息再完整,也不能单独推出 start_date/end_date。
+- config.excluded_slots 使用 1~6 的半天块索引;像“第1-2节”应映射到 1,“第11-12节”应映射到 6。这类换算由你内部处理,不要把底层表示解释成主要回复内容。
+- 若 validation 指出 auto 模式缺 start_date/end_date,先检查当前对话、历史、记忆里是否已有日期范围;已有就静默补齐并重试,只有确实没有时再 ask_user。
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
+- 只要再次调用 upsert_task_class,无论是首次写入还是失败后的重试,都必须走 action=confirm。
+- 当前轮目标若是创建/修正 taskclass,应优先追求静默闭环,不要把主要篇幅花在教育用户理解工具内部约束上。
- excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`),
}
}
@@ -301,6 +326,13 @@ func buildExecuteTaskClassRetryMicroPack() executeRulePack {
Name: executeRulePackMicroTaskRetry,
Content: strings.TrimSpace(`
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
+- 先回到“写前检查”再决定是否重试:确认 mode=auto 的日期边界、difficulty_level 合法枚举、subject_type/difficulty_level/cognitive_intensity 齐全、items 非空且已生成、config 约束字段合法。
+- 先判断 issue 是“用户关键信息缺失”还是“内部表示/工具语义修正”:前者才 ask_user,后者直接静默改参重试。
+- 如果 issue 最终落到 start_date / end_date / 日期范围 / 开始日期承诺 / 完成期限,而这些值在当前对话、历史、记忆、最近工具结果里都没有出现,就必须 ask_user;不要再拿当前时间锚点去替用户补。
+- 若用户只给了禁排时段、排除星期、总节数、难度、内容拆分授权,这仍不构成日期范围;不要把这类偏好误判成已经拿到了可写入的 start_date/end_date。
+- 如果 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,这都属于“写前本应整理好”的问题:应先在本轮静默归一/补齐/生成,再 confirm 重试,不要继续拿 validation 探路。
+- 若 issue 所需字段已在当前对话、历史、记忆或最近工具结果里出现,优先静默补齐,不要多轮解释后再写。
+- 重试 upsert_task_class 时仍然必须输出 action=confirm;不要输出 continue + tool_call。
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
}
}
diff --git a/backend/newAgent/prompt/plan.go b/backend/newAgent/prompt/plan.go
index ea32012..b091656 100644
--- a/backend/newAgent/prompt/plan.go
+++ b/backend/newAgent/prompt/plan.go
@@ -13,15 +13,24 @@ const planSystemPromptCore = `
最高优先级规则:
1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
-2. 事实边界:禁止伪造工具调用和执行结果。
+2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结论。
+3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。
规划规则:
1. 每轮只做一次决策(continue / ask_user / plan_done)。
2. 信息足够时优先 plan_done;信息不足时才 ask_user,且只问最小必要问题。
3. action=plan_done 时必须返回完整 plan_steps(不是增量)。
-4. plan_steps 使用自然语言描述目标与完成判定,不写执行结果。
-5. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids。
-6. 可在 plan_done 时附加 context_hook(执行阶段注入建议);规划阶段禁止调用 context_tools_add/remove。`
+4. plan_steps 必须优先按“工具闭环”拆步,而不是按抽象语义拆步。
+5. 若一个目标可由单个工具闭环完成,优先生成单步计划;禁止把本可直接执行的工具动作,拆成“先分析、再设计、再确认、再执行”这类抽象多步。
+6. 每个 step 的 done_when 都应尽量贴近可观察证据,优先锚定工具回执、校验结果、查询 observation,而不是“方案完整”“分析完成”“用户应该满意”这类抽象描述。
+7. 只有单工具无法闭环,或当前步骤天然依赖上一步 observation / 用户补充信息时,才允许拆成多步。
+8. 先判断为完成目标“首个可执行闭环”最小需要的 domain / packs,再围绕这些工具写 steps,最后再产出 context_hook。context_hook 不是顺手填空,而是计划的自然推导结果。
+9. context_hook 只有一份,供 execute 首轮激活工具域使用;它应对齐“第一个可执行 step”的最小工具需求,而不是试图一次覆盖整份计划的所有后续能力。
+10. 用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass 语义,不等于已经要求排进日程。
+11. 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许把目标规划为 schedule;否则优先停留在 taskclass。
+12. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids;但仅当用户明确提出排程请求时才允许这样做。
+13. 可在 plan_done 时附加 context_hook(执行阶段注入建议);若用户尚未明确要求排程,则 context_hook.domain 不得写 schedule。规划阶段禁止调用 context_tools_add/remove。
+14. 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——这应判定为 taskclass 设计;planner 应优先理解为 taskclass 域可闭环的请求,通常单步或极少步即可,不应抽象拆成多轮。`
// BuildPlanSystemPrompt 返回规划阶段系统提示词。
func BuildPlanSystemPrompt() string {
@@ -33,10 +42,6 @@ func BuildPlanSystemPrompt() string {
}
// BuildPlanMessages 组装规划阶段的 messages。
-//
-// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。
-// 2. msg1 展示真实对话,msg2 展示规划工作区,msg3 仅给最小执行指令与用户本轮输入。
-// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。
func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message {
return buildUnifiedStageMessages(
ctx,
@@ -58,6 +63,9 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n")
+ sb.WriteString("请先判断最小工具闭环,再决定是否需要拆步;能单步就单步。\n")
+ sb.WriteString("若需要 context_hook,请先根据第一个可执行 step 所需的最小 domain / packs 推导,再写入 hook。\n")
+ sb.WriteString("禁止把本可直接落工具的动作,抽象写成“完成设计 / 确认方案 / 整理思路”之类空步骤。\n")
sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
trimmedInput := strings.TrimSpace(userInput)
@@ -72,28 +80,38 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
func BuildPlanDecisionContractText() string {
- return strings.TrimSpace(fmt.Sprintf(`
-输出协议(唯一口径):
-1. 先输出:{JSON}
-2. 再输出:给用户看的自然语言正文
-
-JSON 字段:
-- action:只能是 %s / %s / %s
-- reason:给后端和日志看的简短说明
-- complexity:只能是 simple / moderate / complex
-- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划
-- plan_steps[].content:步骤正文,必填
-- plan_steps[].done_when:可选,建议写完成判定
-- needs_rough_build:仅满足粗排识别条件时为 true,否则省略
-- task_class_ids:needs_rough_build=true 时必填,从上下文读取
-- context_hook:可选,仅用于给 execute 阶段提供注入建议
-- context_hook.domain:schedule / taskclass
-- context_hook.packs:string 数组,可选;core 固定注入,不要填写 core
-- context_hook.reason:可选,说明为何建议该注入
-
-注意:
-- JSON 中不要包含 speak 字段
-- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)`,
+ return strings.TrimSpace(fmt.Sprintf(strings.Join([]string{
+ "输出协议(唯一口径):",
+ "1. 先输出:{JSON}",
+ "2. 再输出:给用户看的自然语言正文",
+ "",
+ "JSON 字段:",
+ "- action:只能是 %s / %s / %s",
+ "- reason:给后端和日志看的简短说明",
+ "- complexity:只能是 simple / moderate / complex",
+ "- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划",
+ "- plan_steps[].content:步骤正文,必填",
+ "- plan_steps[].done_when:可选;若提供,必须尽量写成 observation / 工具回执可直接证明的完成判定",
+ "- needs_rough_build:仅满足粗排识别条件时为 true,否则省略",
+ "- task_class_ids:needs_rough_build=true 时必填,从上下文读取",
+ "- context_hook:可选,仅用于给 execute 阶段提供注入建议",
+ "- context_hook.domain:schedule / taskclass",
+ "- context_hook.packs:string 数组,可选;core 固定注入,不要填入 core",
+ "- context_hook.reason:可选,说明为何建议该注入",
+ "",
+ "注意:",
+ "- JSON 中不要包含 speak 字段",
+ "- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)",
+ "- 写 plan_steps 前,先判断当前目标能否由单个工具或单个紧凑工具闭环完成;若能,优先输出单步计划",
+ "- 禁止把本可直接执行的工具动作,拆成抽象语义步骤,例如“先分析需求”“完成设计”“确认方案完整”",
+ "- 多步计划只应用于:上一步 observation 决定下一步;或确实需要先问用户补关键事实;或目标天然跨域",
+ "- context_hook 必须从 plan_steps 自然推导:优先对齐第一个可执行 step 的最小 domain / packs,不要脱离步骤单独拍脑袋生成",
+ "- 若用户只给出学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认属于 taskclass 设计;不要因此写 needs_rough_build=true,也不要把 context_hook.domain 设为 schedule",
+ "- 只有用户明确要求\"排进日程 / 给出具体时间安排 / 现在就排一版\"时,才允许输出 needs_rough_build=true 或 context_hook.domain=schedule",
+ "- 若首步本质上是任务类写入或修正,context_hook 通常应对齐 taskclass;若首步需要 schedule 查询/分析/修改,再按最小 packs 推导 schedule hook",
+ "- step 的 done_when 应优先锚定:查询结果已返回、validation 已通过、写工具已成功回执、粗排标记已产生、分析结论已可直接支撑下一步",
+ "- 例:\"我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节学习,周末也不想学,每节课内容你自己来\"——应规划为 taskclass,而不是 schedule,也通常不需要 ask_user",
+ }, "\n"),
newagentmodel.PlanActionContinue,
newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionDone,
diff --git a/backend/newAgent/prompt/plan_context.go b/backend/newAgent/prompt/plan_context.go
index 922190e..e30f0b8 100644
--- a/backend/newAgent/prompt/plan_context.go
+++ b/backend/newAgent/prompt/plan_context.go
@@ -16,13 +16,14 @@ func buildPlanConversationMessage(ctx *newagentmodel.ConversationContext) string
// buildPlanWorkspace 渲染 plan 节点自己的工作区。
//
// 设计说明:
-// 1. 这里只保留“规划真正需要知道的东西”:已有计划、当前步骤、task_class_ids、任务类约束;
-// 2. 不再复用通用胖状态摘要,避免把 execute / deliver 无关状态一起塞给 plan;
-// 3. 若当前没有正式计划,则明确告诉模型“从零开始规划”,避免继续误沿用旧上下文。
+// 1. 这里既保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”;
+// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆;
+// 3. 工具摘要不展开全量 schema,只提供规划真正需要的:负责什么、不负责什么、常见闭环、完成证据、域切换条件。
func buildPlanWorkspace(state *newagentmodel.CommonState) string {
lines := []string{"规划工作区:"}
if state == nil {
lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。")
+ lines = append(lines, buildPlanToolPlanningSummary())
return strings.Join(lines, "\n")
}
@@ -43,6 +44,7 @@ func buildPlanWorkspace(state *newagentmodel.CommonState) string {
lines = append(lines, taskClassMeta)
}
+ lines = append(lines, buildPlanToolPlanningSummary())
return strings.Join(lines, "\n")
}
@@ -142,6 +144,76 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string {
return strings.Join(lines, "\n")
}
+// buildPlanToolPlanningSummary 生成“规划视角的工具摘要”。
+//
+// 步骤化说明:
+// 1. 先讲 domain:让 planner 先判断目标应该停留在哪个业务域;
+// 2. 再讲 schedule packs:让 planner 知道若进入 schedule,该选最小哪组能力;
+// 3. 最后讲 hook 推导规则:因为 context_hook 只有一份,必须和“首个可执行闭环”对齐。
+func buildPlanToolPlanningSummary() string {
+ sections := []string{
+ "规划视角的工具摘要:",
+ buildPlanToolDomainTaskClassSummary(),
+ buildPlanToolDomainScheduleSummary(),
+ buildPlanToolPackSummary(),
+ buildPlanContextHookSummary(),
+ }
+ return strings.Join(sections, "\n")
+}
+
+func buildPlanToolDomainTaskClassSummary() string {
+ lines := []string{
+ "1. taskclass 域:",
+ "- 负责什么:创建 / 更新任务类,沉淀学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权、任务项结构。",
+ "- 不负责什么:不给出具体日期/节次落位,不负责把任务真正排进日程。",
+ "- 常见一步闭环:任务类设计或修正通常可由 taskclass 域单步闭环,核心写入动作为 upsert_task_class。",
+ "- 何时停留在本域:用户仍在描述目标、偏好、约束、拆分方式,而不是要求现在排进日程。",
+ "- 何时切到下一个域:只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”,或当前目标本身已变成排程执行。",
+ "- done_when 证据偏好:优先锚定 upsert_task_class 成功回执、validation.ok=true、validation.issues 已清空。",
+ }
+ return strings.Join(lines, "\n")
+}
+
+func buildPlanToolDomainScheduleSummary() string {
+ lines := []string{
+ "2. schedule 域:",
+ "- 负责什么:查询日程现状、粗排、具体落位、局部移动/交换、批量同规则调整、排程健康分析。",
+ "- 不负责什么:不凭空补考试时间、DDL、个人空闲、外部时间事实;这类信息拿不到时应 ask_user。",
+ "- 常见一步闭环:单次查询通常一个读工具即可闭环;单次移动/交换/放置通常一个写工具即可闭环;局部分析通常一个 analyze 工具即可闭环。",
+ "- 何时停留在本域:用户明确要求查询、安排、调整、优化当前日程。",
+ "- 何时先回 taskclass:如果用户还在定义“学什么、学多少、怎么拆、哪些时段不要学”,而不是要求立刻排程,应先停留在 taskclass。",
+ "- done_when 证据偏好:优先锚定查询 observation、写工具成功回执、rough_build_done 标记、analyze observation 已能直接支撑下一步。",
+ }
+ return strings.Join(lines, "\n")
+}
+
+func buildPlanToolPackSummary() string {
+ lines := []string{
+ "3. schedule packs 选择参考:",
+ "- detail_read:查看总览、查询区间、看任务详情;适合“先读事实再决定”的首步。",
+ "- mutation:place / move / swap / batch_move / unplace;适合真正落日程或调日程。",
+ "- analyze:analyze_health / analyze_rhythm;适合先判断是否还有优化空间、该往哪里动。",
+ "- queue:适合“按同一规则逐个处理一批任务”的计划,不必把整批任务细节都堆进 steps。",
+ "- web:仅补通用学习资料或通识信息;不用于补个人时间事实。",
+ "- deep_analyze:适合确实需要更深一层 schedule 分析时再加,默认不要为了“看起来完整”就提前注入。",
+ "- 选 pack 原则:只选首个可执行 step 真的需要的最小 packs,不要为了保险一次全带上。",
+ }
+ return strings.Join(lines, "\n")
+}
+
+func buildPlanContextHookSummary() string {
+ lines := []string{
+ "4. context_hook 推导规则:",
+ "- 先确定 steps,再看第一个可执行 step 需要哪个 domain / 哪组最小 packs,最后才写 hook。",
+ "- 若第一个可执行 step 本质上是任务类写入或修正,hook 通常应为 taskclass,且一般不需要 packs。",
+ "- 若第一个可执行 step 是 schedule 查询,hook 应为 schedule,并优先只带 detail_read。",
+ "- 若第一个可执行 step 是 schedule 分析,hook 应为 schedule,并优先带 analyze;若分析后立刻要落写,再补 mutation。",
+ "- 若第一个可执行 step 是批量同规则处理,hook 应在 schedule 基础上按需加 queue。",
+ "- hook 只有一份,不要求提前覆盖整份计划的所有后续能力;execute 可以在后续按计划再切域或补 packs。",
+ }
+ return strings.Join(lines, "\n")
+}
+
func planSemanticValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
diff --git a/backend/newAgent/tools/quicknote.go b/backend/newAgent/tools/quicknote.go
deleted file mode 100644
index f93b72c..0000000
--- a/backend/newAgent/tools/quicknote.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package newagenttools
-
-import (
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
- "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
-)
-
-// QuickNoteDeps 描述随口记工具所需的外部依赖。
-//
-// 职责边界:
-// 1. CreateTask 负责真正写库,工具层不直接依赖 DAO;
-// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
-type QuickNoteDeps struct {
- // CreateTask 将解析后的任务字段写入数据库。
- // 调用目的:解耦工具层与 DAO 层,方便测试和替换。
- CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (taskID int, err error)
-}
-
-// QuickNoteCreateResult 是 quick_note_create 工具的结构化返回。
-type QuickNoteCreateResult struct {
- TaskID int `json:"task_id"`
- Title string `json:"title"`
- PriorityLabel string `json:"priority_label"`
- DeadlineAt string `json:"deadline_at,omitempty"`
- Message string `json:"message"`
-}
-
-// quickNoteFallbackPriority 根据截止时间推断默认优先级。
-//
-// 推断规则:
-// 1. 有截止时间且距今 ≤48h → 1(重要且紧急);
-// 2. 有截止时间且距今 >48h → 2(重要不紧急);
-// 3. 无截止时间 → 3(简单不重要)。
-func quickNoteFallbackPriority(deadline *time.Time) int {
- if deadline != nil {
- if time.Until(*deadline) <= 48*time.Hour {
- return newagentshared.QuickNotePriorityImportantUrgent
- }
- return newagentshared.QuickNotePriorityImportantNotUrgent
- }
- return newagentshared.QuickNotePrioritySimpleNotImportant
-}
-
-// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。
-//
-// 职责边界:
-// 1. 负责参数校验、时间解析、优先级推断、调 deps 写库、组装返回;
-// 2. 不负责 LLM 交互和会话管理。
-// 3. state 参数忽略——随口记不需要 ScheduleState,已注册到 scheduleFreeTools。
-func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
- return func(state *schedule.ScheduleState, args map[string]any) string {
- _ = state
-
- // 1. 提取 _user_id(由 execute 节点在调用前注入)。
- userID := 0
- if uid, ok := args["_user_id"].(int); ok {
- userID = uid
- }
- if userID <= 0 {
- return "工具调用失败:无法识别用户身份。"
- }
-
- // 2. 提取必填参数 title。
- title := ""
- if t, ok := args["title"].(string); ok {
- title = strings.TrimSpace(t)
- }
- if title == "" {
- return "工具调用失败:缺少必填参数 title(任务标题)。"
- }
-
- // 3. 提取可选参数 deadline_at,复用旧链路时间解析能力。
- var deadline *time.Time
- if raw, ok := args["deadline_at"].(string); ok {
- raw = strings.TrimSpace(raw)
- if raw != "" {
- // 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。
- parsed, err := newagentshared.ParseOptionalDeadline(raw)
- if err != nil {
- return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s)。支持格式:2026-04-20 18:00、明天下午3点、下周一上午9点。", err)
- }
- deadline = parsed
- }
- }
-
- // 4. 提取可选参数 priority_group;未提供时按截止时间自动推断。
- priorityGroup := 0
- if pg, ok := args["priority_group"].(float64); ok {
- priorityGroup = int(pg)
- }
- if !newagentshared.IsValidTaskPriority(priorityGroup) {
- priorityGroup = quickNoteFallbackPriority(deadline)
- }
-
- // 5. 调用依赖写库。
- taskID, err := deps.CreateTask(userID, title, priorityGroup, deadline)
- if err != nil {
- return fmt.Sprintf("工具调用失败:写入任务时出错(%s)。", err)
- }
- if taskID <= 0 {
- return "工具调用失败:写入任务后未返回有效 task_id。"
- }
-
- // 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。
- priorityLabel := newagentshared.PriorityLabelCN(priorityGroup)
- deadlineStr := ""
- if deadline != nil {
- deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
- }
-
- result := QuickNoteCreateResult{
- TaskID: taskID,
- Title: title,
- PriorityLabel: priorityLabel,
- DeadlineAt: deadlineStr,
- }
-
- // 6.1 成功事实 + banter 提示:通过工具返回值引导 ReAct LLM 在 speak 中自然加入轻松跟进。
- if deadlineStr != "" {
- result.Message = fmt.Sprintf("已记录:%s(%s,截止 %s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
- title, priorityLabel, deadlineStr)
- } else {
- result.Message = fmt.Sprintf("已记录:%s(%s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
- title, priorityLabel)
- }
-
- jsonBytes, marshalErr := json.Marshal(result)
- if marshalErr != nil {
- // 6.2 JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
- return result.Message
- }
- return string(jsonBytes)
- }
-}
diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go
index 930c170..e92047e 100644
--- a/backend/newAgent/tools/registry.go
+++ b/backend/newAgent/tools/registry.go
@@ -52,14 +52,7 @@ type ToolRegistry struct {
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
-var temporaryDisabledTools = map[string]bool{
- "min_context_switch": true,
- "spread_even": true,
- "analyze_load": true,
- "analyze_subjects": true,
- "analyze_context": true,
- "analyze_tolerance": true,
-}
+var temporaryDisabledTools = map[string]bool{}
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
func IsTemporarilyDisabledTool(name string) bool {
@@ -232,8 +225,6 @@ var writeTools = map[string]bool{
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
- "spread_even": true,
- "min_context_switch": true,
"unplace": true,
"upsert_task_class": true,
}
@@ -244,8 +235,6 @@ var scheduleMutationTools = map[string]bool{
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
- "spread_even": true,
- "min_context_switch": true,
"unplace": true,
}
@@ -368,30 +357,6 @@ func registerScheduleReadTools(r *ToolRegistry) {
}
func registerScheduleAnalyzeTools(r *ToolRegistry) {
- r.Register(
- "analyze_load",
- "分析整体负载分布(当前阶段已临时禁用,仅保留定义)。",
- `{"name":"analyze_load","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"granularity":{"type":"string","enum":["day","week","time_of_day"]},"detail":{"type":"string","enum":["summary","full"]}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- return schedule.AnalyzeLoad(state, args)
- },
- )
- r.Register(
- "analyze_subjects",
- "分析学科分布与连贯性(当前阶段已临时禁用,仅保留定义)。",
- `{"name":"analyze_subjects","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- return schedule.AnalyzeSubjects(state, args)
- },
- )
- r.Register(
- "analyze_context",
- "分析上下文切换与相邻关系(当前阶段已临时禁用,仅保留定义)。",
- `{"name":"analyze_context","parameters":{"day_from":{"type":"int"},"day_to":{"type":"int"},"detail":{"type":"string","enum":["summary","day_detail"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- return schedule.AnalyzeContext(state, args)
- },
- )
r.Register(
"analyze_rhythm",
"分析学习节奏与切换情况。",
@@ -400,14 +365,6 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
return schedule.AnalyzeRhythm(state, args)
},
)
- r.Register(
- "analyze_tolerance",
- "分析局部容错与调整空间。",
- `{"name":"analyze_tolerance","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"min_usable_size":{"type":"int"},"min_daily_buffer":{"type":"int"},"detail":{"type":"string","enum":["summary","full"]}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- return schedule.AnalyzeTolerance(state, args)
- },
- )
r.Register(
"analyze_health",
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。",
@@ -503,30 +460,6 @@ func registerScheduleMutationTools(r *ToolRegistry) {
return schedule.QueueSkipHead(state, args)
},
)
- r.Register(
- "min_context_switch",
- "在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。",
- `{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
- if err != nil {
- return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
- }
- return schedule.MinContextSwitch(state, taskIDs)
- },
- )
- r.Register(
- "spread_even",
- "在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。",
- `{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
- func(state *schedule.ScheduleState, args map[string]any) string {
- taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
- if err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
- return schedule.SpreadEven(state, taskIDs, args)
- },
- )
r.Register(
"unplace",
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
diff --git a/backend/newAgent/tools/schedule/analyze_tools.go b/backend/newAgent/tools/schedule/analyze_tools.go
index 35d4af8..c8963c7 100644
--- a/backend/newAgent/tools/schedule/analyze_tools.go
+++ b/backend/newAgent/tools/schedule/analyze_tools.go
@@ -165,26 +165,6 @@ type analyzeHealthMetrics struct {
CanClose bool `json:"can_close"`
}
-// AnalyzeLoad 已退出主动优化主链路。
-func AnalyzeLoad(state *ScheduleState, args map[string]any) string {
- return encodeAnalyzeFailure("analyze_load", "deprecated", "analyze_load 已退出主动优化链路")
-}
-
-// AnalyzeSubjects 已被 analyze_rhythm 吸收。
-func AnalyzeSubjects(state *ScheduleState, args map[string]any) string {
- return encodeAnalyzeFailure("analyze_subjects", "deprecated", "analyze_subjects 已被 analyze_rhythm 吸收")
-}
-
-// AnalyzeContext 已被 analyze_rhythm 吸收。
-func AnalyzeContext(state *ScheduleState, args map[string]any) string {
- return encodeAnalyzeFailure("analyze_context", "deprecated", "analyze_context 已被 analyze_rhythm 吸收")
-}
-
-// AnalyzeTolerance 已退出主动优化主链路。
-func AnalyzeTolerance(state *ScheduleState, args map[string]any) string {
- return encodeAnalyzeFailure("analyze_tolerance", "deprecated", "analyze_tolerance 已退出主动优化链路")
-}
-
// AnalyzeRhythm 输出认知节奏层面的结构化观察。
func AnalyzeRhythm(state *ScheduleState, args map[string]any) string {
if state == nil {
@@ -958,164 +938,6 @@ func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyze
}}
}
-func buildAnalyzeHealthDecision(
- state *ScheduleState,
- snapshot analyzeHealthSnapshot,
-) analyzeHealthDecision {
- base := buildAnalyzeHealthDecisionBase(state, snapshot)
- decision := analyzeHealthDecision{
- ShouldContinueOptimize: base.ShouldContinueOptimize,
- PrimaryProblem: base.PrimaryProblem,
- ProblemScope: base.ProblemScope,
- IsForcedImperfection: base.IsForcedImperfection,
- RecommendedOperation: base.RecommendedOperation,
- ImprovementSignal: buildHealthImprovementSignal(
- snapshot.Rhythm,
- snapshot.Tightness,
- base.ProblemScope,
- base.RecommendedOperation,
- snapshot.Profile,
- snapshot.Feasibility,
- ),
- }
-
- // 1. 只有“高认知相邻”这类当前 P1 真正能靠确定性候选修复的问题,才进入候选枚举。
- // 2. 若所有合法候选都只是平移/无增益/恶化,则直接回到 close,避免把 LLM 逼成苦力工。
- // 3. close 永远保留为兜底选项,让 LLM 可以自然收口,而不是为了完成任务感继续乱挪。
- problem, ok := pickPrimaryHealthProblem(state, snapshot)
- if !ok || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
- decision.Candidates = []analyzeHealthCandidate{
- buildHealthCloseCandidate("保持当前安排并收口:当前没有可继续处理的候选认知问题。", snapshot, base),
- }
- decision.ShouldContinueOptimize = false
- decision.RecommendedOperation = "close"
- decision.ImprovementSignal = buildHealthImprovementSignal(
- snapshot.Rhythm,
- snapshot.Tightness,
- decision.ProblemScope,
- decision.RecommendedOperation,
- snapshot.Profile,
- snapshot.Feasibility,
- )
- return decision
- }
-
- beneficial := buildHealthCandidatesForProblem(state, snapshot, problem)
- if len(beneficial) == 0 {
- decision.Candidates = []analyzeHealthCandidate{
- buildHealthCloseCandidate("保持当前安排并收口:当前所有合法 move / swap 都只会平移、无增益或恶化问题。", snapshot, base),
- }
- decision.ShouldContinueOptimize = false
- decision.RecommendedOperation = "close"
- if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
- decision.IsForcedImperfection = true
- }
- decision.ImprovementSignal = buildHealthImprovementSignal(
- snapshot.Rhythm,
- snapshot.Tightness,
- decision.ProblemScope,
- decision.RecommendedOperation,
- snapshot.Profile,
- snapshot.Feasibility,
- )
- return decision
- }
-
- decision.Candidates = append(decision.Candidates, beneficial...)
- decision.Candidates = append(decision.Candidates,
- buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
- )
- decision.ShouldContinueOptimize = true
- decision.RecommendedOperation = strings.TrimSpace(beneficial[0].Tool)
- decision.ImprovementSignal = buildHealthImprovementSignal(
- snapshot.Rhythm,
- snapshot.Tightness,
- decision.ProblemScope,
- decision.RecommendedOperation,
- snapshot.Profile,
- snapshot.Feasibility,
- )
- return decision
-}
-
-func pickPrimaryRhythmProblem(
- rhythm analyzeRhythmMetrics,
- tightness analyzeTightnessMetrics,
-) (summary string, scope *analyzeProblemScope, operation string, ok bool) {
- type rhythmCandidate struct {
- score int
- summary string
- scope *analyzeProblemScope
- preferSwap bool
- }
-
- candidates := make([]rhythmCandidate, 0, len(rhythm.Days)*2)
- for _, day := range rhythm.Days {
- if day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) {
- score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20)
- candidates = append(candidates, rhythmCandidate{
- score: score,
- summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex),
- scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
- preferSwap: true,
- })
- }
- if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 {
- score := 220 + day.SwitchCount*10 + int(day.Fragmentation*100)
- candidates = append(candidates, rhythmCandidate{
- score: score,
- summary: fmt.Sprintf("第 %d 天切换次数偏多,学习节奏明显发碎", day.DayIndex),
- scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
- preferSwap: false,
- })
- }
- if day.MaxBlock >= 5 {
- score := 140 + day.MaxBlock*10
- candidates = append(candidates, rhythmCandidate{
- score: score,
- summary: fmt.Sprintf("第 %d 天连续同科目学习块过长,节奏略显单一", day.DayIndex),
- scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
- preferSwap: false,
- })
- }
- }
- if len(candidates) == 0 {
- return "", nil, "close", false
- }
- sort.SliceStable(candidates, func(i, j int) bool {
- if candidates[i].score != candidates[j].score {
- return candidates[i].score > candidates[j].score
- }
- leftDay := 1 << 30
- rightDay := 1 << 30
- if candidates[i].scope != nil && len(candidates[i].scope.DayRange) > 0 {
- leftDay = candidates[i].scope.DayRange[0]
- }
- if candidates[j].scope != nil && len(candidates[j].scope.DayRange) > 0 {
- rightDay = candidates[j].scope.DayRange[0]
- }
- return leftDay < rightDay
- })
- best := candidates[0]
- operation = chooseHealthOperation(tightness, best.preferSwap)
- return best.summary, best.scope, operation, true
-}
-
-func chooseHealthOperation(tightness analyzeTightnessMetrics, preferSwap bool) string {
- switch {
- case tightness.TightnessLevel == "locked":
- return "close"
- case preferSwap && tightness.CrossClassSwapOptions > 0:
- return "swap"
- case tightness.LocallyMovableTaskCount > 0:
- return "move"
- case tightness.CrossClassSwapOptions > 0:
- return "swap"
- default:
- return "close"
- }
-}
-
func shouldTreatHeavyAdjacencyAsAcceptable(rhythm analyzeRhythmMetrics, day analyzeContextDay) bool {
// 1. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”,
// 这类情况不该因为“高认知相邻”四个字就被反复优化。
diff --git a/backend/newAgent/tools/schedule/compound_tools.go b/backend/newAgent/tools/schedule/compound_tools.go
deleted file mode 100644
index bbb2f8c..0000000
--- a/backend/newAgent/tools/schedule/compound_tools.go
+++ /dev/null
@@ -1,707 +0,0 @@
-package schedule
-
-import (
- "encoding/json"
- "fmt"
- "sort"
- "strings"
-
- compositelogic "github.com/LoveLosita/smartflow/backend/logic"
-)
-
-var spreadEvenAllowedArgs = []string{
- "task_ids",
- "task_id",
- "limit",
- "allow_embed",
- "day",
- "day_start",
- "day_end",
- "day_scope",
- "day_of_week",
- "week",
- "week_filter",
- "week_from",
- "week_to",
- "slot_type",
- "slot_types",
- "exclude_sections",
- "after_section",
- "before_section",
-}
-
-// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
-type minContextSnapshot struct {
- StateID int
- Name string
- ContextTag string
- Slot TaskSlot
-}
-
-// refineTaskCandidate 是复合规划器使用的任务输入。
-type refineTaskCandidate struct {
- TaskID int
- Week int
- DayOfWeek int
- SectionFrom int
- SectionTo int
- Name string
- ContextTag string
- OriginRank int
-}
-
-// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。
-//
-// 说明:
-// 1. 当前阶段使用等值映射(logicID=stateID),保证行为不变;
-// 2. 保留独立适配层,后续若切到真实 task_item_id,只需改这里;
-// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
-type compositeIDMapper struct {
- stateToLogic map[int]int
- logicToState map[int]int
-}
-
-// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。
-func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
- mapper := &compositeIDMapper{
- stateToLogic: make(map[int]int, len(stateIDs)),
- logicToState: make(map[int]int, len(stateIDs)),
- }
- for _, stateID := range stateIDs {
- if stateID <= 0 {
- return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
- }
- if _, exists := mapper.stateToLogic[stateID]; exists {
- return nil, fmt.Errorf("state_id=%d 重复", stateID)
- }
- // 当前迁移阶段采用等值映射,先把“映射机制”跑通。
- logicID := stateID
- mapper.stateToLogic[stateID] = logicID
- mapper.logicToState[logicID] = stateID
- }
- return mapper, nil
-}
-
-// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
-//
-// 职责边界:
-// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
-// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
-// 3. 采用原子提交:任一校验失败则整体不生效。
-func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
- if state == nil {
- return "减少上下文切换失败:日程状态为空。"
- }
-
- // 1. 收集任务并做前置校验,确保规划输入可用。
- plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
- if err != nil {
- return err.Error()
- }
- logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
- if err != nil {
- return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
- }
-
- // 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
- currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
- plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
- if err != nil {
- return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
- }
-
- // 3. 映射回工具态坐标并在提交前做完整校验。
- afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
- if err != nil {
- return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
- }
- for taskID, after := range afterByID {
- before := beforeByID[taskID]
- if err := validateDay(state, after.Slot.Day); err != nil {
- return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
- }
- if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
- return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
- }
- if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
- return fmt.Sprintf(
- "减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
- before.StateID,
- before.Name,
- formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
- conflict.StateID,
- conflict.Name,
- )
- }
- }
- minContextProposals := make(map[int][]TaskSlot, len(afterByID))
- for taskID, after := range afterByID {
- minContextProposals[taskID] = []TaskSlot{after.Slot}
- }
- if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil {
- return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
- }
-
- // 4. 全量通过后再原子提交,避免半成品状态。
- clone := state.Clone()
- for taskID, after := range afterByID {
- task := clone.TaskByStateID(taskID)
- if task == nil {
- return fmt.Sprintf("减少上下文切换失败:任务ID %d 在提交阶段不存在。", taskID)
- }
- task.Slots = []TaskSlot{after.Slot}
- }
- state.Tasks = clone.Tasks
-
- beforeOrdered := sortMinContextSnapshots(beforeByID)
- afterOrdered := sortMinContextSnapshots(afterByID)
- beforeSwitches := countMinContextSwitches(beforeOrdered)
- afterSwitches := countMinContextSwitches(afterOrdered)
-
- changedLines := make([]string, 0, len(beforeOrdered))
- affectedDays := make(map[int]bool, len(beforeOrdered)*2)
- for _, before := range beforeOrdered {
- after := afterByID[before.StateID]
- if sameTaskSlot(before.Slot, after.Slot) {
- continue
- }
- changedLines = append(changedLines, fmt.Sprintf(
- " [%d]%s:%s -> %s",
- before.StateID,
- before.Name,
- formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
- formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
- ))
- affectedDays[before.Slot.Day] = true
- affectedDays[after.Slot.Day] = true
- }
-
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf(
- "最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n",
- len(beforeByID), beforeSwitches, afterSwitches,
- ))
- if len(changedLines) == 0 {
- sb.WriteString("当前任务顺序已是较优结果,无需调整。")
- return sb.String()
- }
- sb.WriteString("本次调整:\n")
- for _, line := range changedLines {
- sb.WriteString(line + "\n")
- }
- for _, day := range sortedKeys(affectedDays) {
- sb.WriteString(formatDayOccupancy(state, day) + "\n")
- }
- return strings.TrimSpace(sb.String())
-}
-
-// SpreadEven 在给定任务集合内执行“均匀化铺开”。
-//
-// 职责边界:
-// 1. 仅处理 suggested 且已落位任务;
-// 2. 先按筛选条件收集候选坑位,再调用确定性规划器;
-// 3. 通过统一校验后原子提交,失败不落地。
-func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string {
- if state == nil {
- return "均匀化调整失败:日程状态为空。"
- }
- // 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。
- if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
-
- // 1. 先做任务侧校验,避免后续规划在脏输入上执行。
- plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
- if err != nil {
- return err.Error()
- }
- logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
- if err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
-
- // 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
- spanNeed := make(map[int]int, len(logicTasks))
- for _, task := range logicTasks {
- spanNeed[task.SectionTo-task.SectionFrom+1]++
- }
- candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
- if err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
-
- // 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
- dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
- plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
- ExistingDayLoad: dayLoadBaseline,
- })
- if err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
-
- // 4. 回填 + 校验 + 原子提交。
- afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
- if err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
- for taskID, after := range afterByID {
- before := beforeByID[taskID]
- if err := validateDay(state, after.Slot.Day); err != nil {
- return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
- }
- if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
- return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
- }
- if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
- return fmt.Sprintf(
- "均匀化调整失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
- before.StateID,
- before.Name,
- formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
- conflict.StateID,
- conflict.Name,
- )
- }
- }
- spreadEvenProposals := make(map[int][]TaskSlot, len(afterByID))
- for taskID, after := range afterByID {
- spreadEvenProposals[taskID] = []TaskSlot{after.Slot}
- }
- if err := validateLocalOrderBatchPlacement(state, spreadEvenProposals); err != nil {
- return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
- }
-
- clone := state.Clone()
- for taskID, after := range afterByID {
- task := clone.TaskByStateID(taskID)
- if task == nil {
- return fmt.Sprintf("均匀化调整失败:任务ID %d 在提交阶段不存在。", taskID)
- }
- task.Slots = []TaskSlot{after.Slot}
- }
- state.Tasks = clone.Tasks
-
- beforeOrdered := sortMinContextSnapshots(beforeByID)
- changedLines := make([]string, 0, len(beforeOrdered))
- affectedDays := make(map[int]bool, len(beforeOrdered)*2)
- for _, before := range beforeOrdered {
- after := afterByID[before.StateID]
- if sameTaskSlot(before.Slot, after.Slot) {
- continue
- }
- changedLines = append(changedLines, fmt.Sprintf(
- " [%d]%s:%s -> %s",
- before.StateID,
- before.Name,
- formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
- formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
- ))
- affectedDays[before.Slot.Day] = true
- affectedDays[after.Slot.Day] = true
- }
-
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf(
- "均匀化调整完成:共处理 %d 个任务,候选坑位 %d 个。\n",
- len(beforeByID), len(candidateSlots),
- ))
- if len(changedLines) == 0 {
- sb.WriteString("规划结果与当前落位一致,无需调整。")
- return sb.String()
- }
- sb.WriteString("本次调整:\n")
- for _, line := range changedLines {
- sb.WriteString(line + "\n")
- }
- for _, day := range sortedKeys(affectedDays) {
- sb.WriteString(formatDayOccupancy(state, day) + "\n")
- }
- return strings.TrimSpace(sb.String())
-}
-
-func ParseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
- return ParseCompositeTaskIDs(args)
-}
-
-func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
- return ParseCompositeTaskIDs(args)
-}
-
-func ParseCompositeTaskIDs(args map[string]any) ([]int, error) {
- if ids, ok := ArgsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
- return ids, nil
- }
- if id, ok := ArgsInt(args, "task_id"); ok {
- return []int{id}, nil
- }
- return nil, fmt.Errorf("缺少必填参数 task_ids(兼容单值 task_id)")
-}
-
-// collectCompositePlannerTasks 统一收集复合工具输入任务,并做“可移动 suggested”校验。
-func collectCompositePlannerTasks(
- state *ScheduleState,
- taskIDs []int,
- toolLabel string,
-) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
- normalizedIDs := uniquePositiveInts(taskIDs)
- if len(normalizedIDs) < 2 {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
- }
-
- idMapper, err := buildCompositeIDMapper(normalizedIDs)
- if err != nil {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:ID 映射构建失败:%s", toolLabel, err.Error())
- }
-
- plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs))
- beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs))
- excludeIDs := make([]int, 0, len(normalizedIDs))
-
- for rank, taskID := range normalizedIDs {
- task := state.TaskByStateID(taskID)
- if task == nil {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
- }
- if !IsSuggestedTask(*task) {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
- }
- if err := checkLocked(*task); err != nil {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
- }
- if len(task.Slots) != 1 {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
- }
-
- slot := task.Slots[0]
- if err := validateDay(state, slot.Day); err != nil {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
- }
- if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
- }
- week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
- if !ok {
- return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
- }
-
- contextTag := normalizeMinContextTag(*task)
- beforeByID[task.StateID] = minContextSnapshot{
- StateID: task.StateID,
- Name: task.Name,
- ContextTag: contextTag,
- Slot: slot,
- }
- excludeIDs = append(excludeIDs, task.StateID)
- plannerTasks = append(plannerTasks, refineTaskCandidate{
- TaskID: task.StateID,
- Week: week,
- DayOfWeek: dayOfWeek,
- SectionFrom: slot.SlotStart,
- SectionTo: slot.SlotEnd,
- Name: strings.TrimSpace(task.Name),
- ContextTag: contextTag,
- OriginRank: rank + 1,
- })
- }
-
- return plannerTasks, beforeByID, excludeIDs, idMapper, nil
-}
-
-// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
-func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
- if len(tasks) == 0 {
- return nil, fmt.Errorf("任务列表为空")
- }
- if idMapper == nil {
- return nil, fmt.Errorf("ID 映射为空")
- }
- result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
- for _, task := range tasks {
- logicID, ok := idMapper.stateToLogic[task.TaskID]
- if !ok {
- return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
- }
- result = append(result, compositelogic.RefineTaskCandidate{
- TaskItemID: logicID,
- Week: task.Week,
- DayOfWeek: task.DayOfWeek,
- SectionFrom: task.SectionFrom,
- SectionTo: task.SectionTo,
- Name: task.Name,
- ContextTag: task.ContextTag,
- OriginRank: task.OriginRank,
- })
- }
- return result, nil
-}
-
-func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
- slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
- for _, task := range tasks {
- slots = append(slots, compositelogic.RefineSlotCandidate{
- Week: task.Week,
- DayOfWeek: task.DayOfWeek,
- SectionFrom: task.SectionFrom,
- SectionTo: task.SectionTo,
- })
- }
- return slots
-}
-
-func buildAfterSnapshotsFromPlannedMoves(
- state *ScheduleState,
- beforeByID map[int]minContextSnapshot,
- plannedMoves []compositelogic.RefineMovePlanItem,
- idMapper *compositeIDMapper,
-) (map[int]minContextSnapshot, error) {
- if len(plannedMoves) == 0 {
- return nil, fmt.Errorf("规划结果为空")
- }
- if idMapper == nil {
- return nil, fmt.Errorf("ID 映射为空")
- }
-
- moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
- for _, move := range plannedMoves {
- stateID, ok := idMapper.logicToState[move.TaskItemID]
- if !ok {
- return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
- }
- if _, exists := moveByID[stateID]; exists {
- return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
- }
- moveByID[stateID] = move
- }
-
- afterByID := make(map[int]minContextSnapshot, len(beforeByID))
- for taskID, before := range beforeByID {
- move, ok := moveByID[taskID]
- if !ok {
- return nil, fmt.Errorf("规划结果不完整:缺少任务 id=%d", taskID)
- }
- day, ok := state.WeekDayToDay(move.ToWeek, move.ToDay)
- if !ok {
- return nil, fmt.Errorf("任务 id=%d 目标 week/day 无法映射到 day_index:W%dD%d", taskID, move.ToWeek, move.ToDay)
- }
- afterByID[taskID] = minContextSnapshot{
- StateID: before.StateID,
- Name: before.Name,
- ContextTag: before.ContextTag,
- Slot: TaskSlot{
- Day: day,
- SlotStart: move.ToSectionFrom,
- SlotEnd: move.ToSectionTo,
- },
- }
- }
- return afterByID, nil
-}
-
-func collectSpreadEvenCandidateSlotsBySpan(
- state *ScheduleState,
- args map[string]any,
- spanNeed map[int]int,
-) ([]compositelogic.RefineSlotCandidate, error) {
- if len(spanNeed) == 0 {
- return nil, fmt.Errorf("未识别到任务跨度需求")
- }
-
- spans := make([]int, 0, len(spanNeed))
- for span := range spanNeed {
- spans = append(spans, span)
- }
- sort.Ints(spans)
-
- allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
- seen := make(map[string]struct{}, 64)
- for _, span := range spans {
- required := spanNeed[span]
- queryArgs := buildSpreadEvenSlotQueryArgs(args, span, required)
- raw := QueryAvailableSlots(state, queryArgs)
-
- var failed struct {
- Error string `json:"error"`
- }
- _ = json.Unmarshal([]byte(raw), &failed)
- if strings.TrimSpace(failed.Error) != "" {
- return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, strings.TrimSpace(failed.Error))
- }
-
- var payload queryAvailableSlotsResult
- if err := json.Unmarshal([]byte(raw), &payload); err != nil {
- return nil, fmt.Errorf("解析跨度=%d 的候选坑位结果失败:%v", span, err)
- }
- if len(payload.Slots) < required {
- return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots))
- }
-
- for _, slot := range payload.Slots {
- key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SlotStart, slot.SlotEnd)
- if _, exists := seen[key]; exists {
- continue
- }
- seen[key] = struct{}{}
- allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
- Week: slot.Week,
- DayOfWeek: slot.DayOfWeek,
- SectionFrom: slot.SlotStart,
- SectionTo: slot.SlotEnd,
- })
- }
- }
- return allSlots, nil
-}
-
-func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) map[string]any {
- query := make(map[string]any, 16)
- query["span"] = span
-
- limit := required * 6
- if limit < required {
- limit = required
- }
- if customLimit, ok := readIntAny(args, "limit"); ok && customLimit > limit {
- limit = customLimit
- }
- query["limit"] = limit
- query["allow_embed"] = readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
-
- for _, key := range []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
- if value, ok := args[key]; ok {
- query[key] = value
- }
- }
- if week, ok := readIntAny(args, "to_week", "target_week", "new_week"); ok {
- query["week"] = week
- }
- if day, ok := readIntAny(args, "to_day", "target_day", "target_day_of_week", "new_day"); ok {
- query["day_of_week"] = []int{day}
- }
-
- if values := uniquePositiveInts(readIntSliceAny(args, "week_filter", "weeks")); len(values) > 0 {
- query["week_filter"] = values
- }
- if values := uniqueInts(readIntSliceAny(args, "day_of_week", "days", "day_filter")); len(values) > 0 {
- query["day_of_week"] = values
- }
- if values := uniqueInts(readIntSliceAny(args, "exclude_sections", "exclude_section")); len(values) > 0 {
- query["exclude_sections"] = values
- }
-
- return query
-}
-
-func buildSpreadEvenDayLoadBaseline(
- state *ScheduleState,
- excludeTaskIDs []int,
- slots []compositelogic.RefineSlotCandidate,
-) map[string]int {
- if len(slots) == 0 {
- return nil
- }
-
- targetDays := make(map[string]struct{}, len(slots))
- for _, slot := range slots {
- targetDays[composeDayKey(slot.Week, slot.DayOfWeek)] = struct{}{}
- }
- if len(targetDays) == 0 {
- return nil
- }
-
- excludeSet := make(map[int]struct{}, len(excludeTaskIDs))
- for _, id := range excludeTaskIDs {
- excludeSet[id] = struct{}{}
- }
-
- load := make(map[string]int, len(targetDays))
- for _, task := range state.Tasks {
- if !IsSuggestedTask(task) {
- continue
- }
- if _, excluded := excludeSet[task.StateID]; excluded {
- continue
- }
- for _, slot := range task.Slots {
- week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
- if !ok {
- continue
- }
- key := composeDayKey(week, dayOfWeek)
- if _, inTarget := targetDays[key]; !inTarget {
- continue
- }
- load[key]++
- }
- }
- return load
-}
-
-func composeDayKey(week, day int) string {
- return fmt.Sprintf("%d-%d", week, day)
-}
-
-func uniquePositiveInts(values []int) []int {
- seen := make(map[int]struct{}, len(values))
- result := make([]int, 0, len(values))
- for _, value := range values {
- if value <= 0 {
- continue
- }
- if _, exists := seen[value]; exists {
- continue
- }
- seen[value] = struct{}{}
- result = append(result, value)
- }
- return result
-}
-
-func normalizeMinContextTag(task ScheduleTask) string {
- if tag := strings.TrimSpace(task.Category); tag != "" {
- return tag
- }
- if tag := strings.TrimSpace(task.Name); tag != "" {
- return tag
- }
- return "General"
-}
-
-func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot {
- items := make([]minContextSnapshot, 0, len(snapshotByID))
- for _, item := range snapshotByID {
- items = append(items, item)
- }
- sort.SliceStable(items, func(i, j int) bool {
- if items[i].Slot.Day != items[j].Slot.Day {
- return items[i].Slot.Day < items[j].Slot.Day
- }
- if items[i].Slot.SlotStart != items[j].Slot.SlotStart {
- return items[i].Slot.SlotStart < items[j].Slot.SlotStart
- }
- if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd {
- return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd
- }
- return items[i].StateID < items[j].StateID
- })
- return items
-}
-
-func countMinContextSwitches(ordered []minContextSnapshot) int {
- if len(ordered) < 2 {
- return 0
- }
- switches := 0
- prevTag := strings.TrimSpace(ordered[0].ContextTag)
- for i := 1; i < len(ordered); i++ {
- currentTag := strings.TrimSpace(ordered[i].ContextTag)
- if currentTag != prevTag {
- switches++
- }
- prevTag = currentTag
- }
- return switches
-}
-
-func sameTaskSlot(a, b TaskSlot) bool {
- return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd
-}
diff --git a/backend/newAgent/tools/schedule/order_constraints.go b/backend/newAgent/tools/schedule/order_constraints.go
index 2db2a9c..202e07e 100644
--- a/backend/newAgent/tools/schedule/order_constraints.go
+++ b/backend/newAgent/tools/schedule/order_constraints.go
@@ -20,7 +20,7 @@ func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targ
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
//
// 职责边界:
-// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
+// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免批量局部调整时出现伪冲突;
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
diff --git a/backend/newAgent/tools/taskquery.go b/backend/newAgent/tools/taskquery.go
deleted file mode 100644
index 4413b67..0000000
--- a/backend/newAgent/tools/taskquery.go
+++ /dev/null
@@ -1,320 +0,0 @@
-package newagenttools
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
-)
-
-// ==================== 常量 ====================
-
-const (
- // defaultTaskQueryLimit 是任务查询默认返回条数。
- defaultTaskQueryLimit = 5
- // maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。
- maxTaskQueryLimit = 20
-)
-
-// ==================== 优先级中文映射 ====================
-
-// taskQueryPriorityLabelCN 将象限编号转为中文标签。
-//
-// 职责边界:
-// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。
-// 2. 不依赖旧链路 agentmodel.PriorityLabelCN,保持新工具自包含。
-func taskQueryPriorityLabelCN(priority int) string {
- switch priority {
- case 1:
- return "重要且紧急"
- case 2:
- return "重要不紧急"
- case 3:
- return "简单不重要"
- case 4:
- return "复杂不重要"
- default:
- return "未知"
- }
-}
-
-// ==================== 类型定义 ====================
-
-// TaskQueryDeps 描述任务查询工具所需的外部依赖。
-//
-// 职责边界:
-// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO;
-// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
-type TaskQueryDeps struct {
- // QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。
- // 调用目的:解耦工具层与 DAO 层,方便测试和替换。
- QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
-}
-
-// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。
-//
-// 输入输出语义:
-// 1. 所有筛选条件均为可选,Quadrant 为 nil 表示不限象限。
-// 2. 时间边界为 nil 表示不限时间范围。
-type TaskQueryParams struct {
- Quadrant *int
- SortBy string // deadline | priority | id
- Order string // asc | desc
- Limit int
- IncludeCompleted bool
- Keyword string
- DeadlineBefore *time.Time
- DeadlineAfter *time.Time
-}
-
-// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。
-//
-// 职责边界:
-// 1. 只承载展示所需字段,避免暴露底层数据库结构。
-// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。
-type TaskQueryResult struct {
- ID int `json:"id"`
- Title string `json:"title"`
- PriorityGroup int `json:"priority_group"`
- PriorityLabel string `json:"priority_label"`
- IsCompleted bool `json:"is_completed"`
- DeadlineAt string `json:"deadline_at,omitempty"`
-}
-
-// ==================== 时间解析 ====================
-
-// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。
-var taskQueryTimeLayouts = []string{
- time.RFC3339,
- "2006-01-02 15:04:05",
- "2006-01-02 15:04",
- "2006-01-02",
-}
-
-// parseTaskQueryBoundaryTime 解析截止时间上下界。
-//
-// 职责边界:
-// 1. isUpper=true 时,纯日期补到当天 23:59:59。
-// 2. isUpper=false 时,纯日期补到当天 00:00:00。
-// 3. 不支持的格式直接返回错误,由调用方决定是否回退。
-func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return nil, nil
- }
-
- loc := time.Local
- for _, layout := range taskQueryTimeLayouts {
- var (
- parsed time.Time
- err error
- )
- if layout == time.RFC3339 {
- parsed, err = time.Parse(layout, text)
- if err == nil {
- parsed = parsed.In(loc)
- }
- } else {
- parsed, err = time.ParseInLocation(layout, text, loc)
- }
- if err != nil {
- continue
- }
-
- // 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。
- // 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束",
- // 所以补 23:59:59;作为下界则补 00:00:00。
- if layout == "2006-01-02" {
- if isUpper {
- parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc)
- } else {
- parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc)
- }
- }
- return &parsed, nil
- }
- return nil, fmt.Errorf("时间格式不支持: %s", text)
-}
-
-// formatTaskQueryTime 将内部时间格式化为给模型展示的分钟级文本。
-func formatTaskQueryTime(value *time.Time) string {
- if value == nil {
- return ""
- }
- return value.In(time.Local).Format("2006-01-02 15:04")
-}
-
-// ==================== 工具 Handler ====================
-
-// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。
-//
-// 职责边界:
-// 1. 负责参数校验、时间解析、调 deps 查库、组装返回;
-// 2. 不负责 LLM 交互和会话管理。
-// 3. state 参数忽略——任务查询不需要 ScheduleState,已注册到 scheduleFreeTools。
-func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler {
- return func(state *schedule.ScheduleState, args map[string]any) string {
- _ = state
-
- // 1. 提取 _user_id(由 execute 节点在调用前注入)。
- userID := 0
- if uid, ok := args["_user_id"].(int); ok {
- userID = uid
- }
- if userID <= 0 {
- return "工具调用失败:无法识别用户身份。"
- }
-
- // 2. 提取并校验查询参数。
- params, err := extractTaskQueryParams(args)
- if err != nil {
- return fmt.Sprintf("工具调用失败:%s", err)
- }
-
- // 3. 调用依赖查库。
- results, err := deps.QueryTasks(context.Background(), userID, params)
- if err != nil {
- return fmt.Sprintf("工具调用失败:查询任务时出错(%s)。", err)
- }
-
- // 4. 为每条结果填充优先级中文标签。
- for i := range results {
- results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup)
- }
-
- // 5. 返回结构化 JSON。
- if len(results) == 0 {
- return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}`
- }
-
- output := struct {
- Total int `json:"total"`
- Items []TaskQueryResult `json:"items"`
- Message string `json:"message"`
- }{
- Total: len(results),
- Items: results,
- Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)),
- }
-
- jsonBytes, marshalErr := json.Marshal(output)
- if marshalErr != nil {
- // JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
- return fmt.Sprintf("找到 %d 条匹配任务。", len(results))
- }
- return string(jsonBytes)
- }
-}
-
-// extractTaskQueryParams 从 args 提取并校验任务查询参数。
-//
-// 步骤说明:
-// 1. 先准备默认值,保证空参数也能执行一次合理查询。
-// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。
-// 3. 若上下界冲突,则直接返回错误。
-func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) {
- params := TaskQueryParams{
- SortBy: "deadline",
- Order: "asc",
- Limit: defaultTaskQueryLimit,
- IncludeCompleted: false,
- }
-
- // 2.1 象限:1~4,超出范围拒绝。
- if v, ok := args["quadrant"]; ok {
- switch val := v.(type) {
- case float64:
- q := int(val)
- if q < 1 || q > 4 {
- return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q)
- }
- params.Quadrant = &q
- case int:
- if val < 1 || val > 4 {
- return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val)
- }
- params.Quadrant = &val
- }
- }
-
- // 2.2 排序字段:仅支持 deadline/priority/id。
- if v, ok := args["sort_by"].(string); ok {
- sortBy := strings.ToLower(strings.TrimSpace(v))
- if sortBy != "" {
- switch sortBy {
- case "deadline", "priority", "id":
- params.SortBy = sortBy
- default:
- return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy)
- }
- }
- }
-
- // 2.3 排序方向:仅支持 asc/desc。
- if v, ok := args["order"].(string); ok {
- order := strings.ToLower(strings.TrimSpace(v))
- if order != "" {
- switch order {
- case "asc", "desc":
- params.Order = order
- default:
- return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order)
- }
- }
- }
-
- // 2.4 条数:默认 5,上限 20。
- if v, ok := args["limit"]; ok {
- switch val := v.(type) {
- case float64:
- params.Limit = int(val)
- case int:
- params.Limit = val
- }
- }
- if params.Limit <= 0 {
- params.Limit = defaultTaskQueryLimit
- }
- if params.Limit > maxTaskQueryLimit {
- params.Limit = maxTaskQueryLimit
- }
-
- // 2.5 是否包含已完成任务。
- if v, ok := args["include_completed"]; ok {
- switch val := v.(type) {
- case bool:
- params.IncludeCompleted = val
- }
- }
-
- // 2.6 关键词。
- if v, ok := args["keyword"].(string); ok {
- params.Keyword = strings.TrimSpace(v)
- }
-
- // 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。
- beforeRaw, _ := args["deadline_before"].(string)
- before, err := parseTaskQueryBoundaryTime(beforeRaw, true)
- if err != nil {
- return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err)
- }
- params.DeadlineBefore = before
-
- afterRaw, _ := args["deadline_after"].(string)
- after, err := parseTaskQueryBoundaryTime(afterRaw, false)
- if err != nil {
- return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err)
- }
- params.DeadlineAfter = after
-
- // 2.8 时间区间合法性校验:下界不能晚于上界。
- if params.DeadlineBefore != nil && params.DeadlineAfter != nil &&
- params.DeadlineAfter.After(*params.DeadlineBefore) {
- return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before")
- }
-
- return params, nil
-}
diff --git a/backend/newAgent/tools/tool_domain_map.go b/backend/newAgent/tools/tool_domain_map.go
index 03d8823..120505c 100644
--- a/backend/newAgent/tools/tool_domain_map.go
+++ b/backend/newAgent/tools/tool_domain_map.go
@@ -54,16 +54,13 @@ var toolProfileByName = map[string]toolProfile{
"queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
- "place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "spread_even": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "min_context_switch": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
+ "place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
+ "move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
+ "swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
+ "batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
+ "unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
- "analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
- "analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
+ "analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go
index 4f1af72..ef7b00b 100644
--- a/backend/service/agentsvc/agent_newagent.go
+++ b/backend/service/agentsvc/agent_newagent.go
@@ -253,7 +253,7 @@ func (s *AgentService) runNewAgentGraph(
// 11.6. graph 完成后条件触发记忆抽取。
// 说明:
- // 1. 只有本轮未使用 quick_note_create 时才触发记忆抽取;
+ // 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
if finalState != nil {
cs := finalState.EnsureRuntimeState().EnsureCommonState()
diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts
index 49bd392..5ed40c6 100644
--- a/frontend/src/api/schedule_agent.ts
+++ b/frontend/src/api/schedule_agent.ts
@@ -19,7 +19,15 @@ export interface TimelineConfirmPayload {
export interface TimelineEvent {
id: number
seq: number
- kind: 'user_text' | 'assistant_text' | 'tool_call' | 'tool_result' | 'confirm_request' | 'schedule_completed'
+ kind:
+ | 'user_text'
+ | 'assistant_text'
+ | 'tool_call'
+ | 'tool_result'
+ | 'confirm_request'
+ | 'schedule_completed'
+ | 'interrupt'
+ | 'status'
role?: 'user' | 'assistant'
content?: string
payload?: {
diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue
index 1f311dc..ff7dbbf 100644
--- a/frontend/src/components/dashboard/AssistantPanel.vue
+++ b/frontend/src/components/dashboard/AssistantPanel.vue
@@ -504,6 +504,22 @@ function appendToolTraceEvent(
}
ensureToolTraceBucket(messageId)
+ const normalizedDetail = detail.trim()
+ const normalizedToolName = toolName.trim()
+ const matchedPendingEvent = findMergeableToolTraceEvent(
+ messageId,
+ state,
+ normalizedSummary,
+ normalizedDetail,
+ normalizedToolName,
+ )
+ if (matchedPendingEvent) {
+ matchedPendingEvent.state = state
+ matchedPendingEvent.summary = normalizedSummary
+ matchedPendingEvent.detail = normalizedDetail || matchedPendingEvent.detail
+ matchedPendingEvent.toolName = normalizedToolName || matchedPendingEvent.toolName
+ return
+ }
const eventSeq = nextAssistantTimelineSeq()
const eventId = `${messageId}:tool:${eventSeq}`
@@ -517,12 +533,84 @@ function appendToolTraceEvent(
seq: eventSeq,
state,
summary: normalizedSummary,
- detail: detail.trim() || undefined,
- toolName: toolName.trim() || undefined,
+ detail: normalizedDetail || undefined,
+ toolName: normalizedToolName || undefined,
})
assistantTimelineLastKindMap[messageId] = 'tool'
}
+function isPendingToolTraceState(state: ToolTraceState) {
+ return state === 'called'
+}
+
+function findMergeableToolTraceEvent(
+ messageId: string,
+ nextState: ToolTraceState,
+ summary: string,
+ detail: string,
+ toolName: string,
+): ToolTraceEvent | null {
+ if (nextState === 'called') {
+ return null
+ }
+
+ const pendingEvents = (toolTraceEventsMap[messageId] || [])
+ .slice()
+ .reverse()
+ .filter((event) => isPendingToolTraceState(event.state))
+
+ if (pendingEvents.length <= 0) {
+ return null
+ }
+
+ const normalizedToolName = toolName.trim().toLowerCase()
+ const normalizedDetail = detail.trim()
+ const normalizedSummary = summary.trim()
+
+ if (normalizedToolName && normalizedDetail) {
+ const exactMatch = pendingEvents.find((event) => {
+ return (
+ `${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName &&
+ `${event.detail || ''}`.trim() === normalizedDetail
+ )
+ })
+ if (exactMatch) {
+ return exactMatch
+ }
+ }
+
+ if (normalizedToolName) {
+ const toolNameMatch = pendingEvents.find((event) => {
+ return `${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName
+ })
+ if (toolNameMatch) {
+ return toolNameMatch
+ }
+ }
+
+ if (normalizedDetail) {
+ const detailMatch = pendingEvents.find((event) => {
+ return `${event.detail || ''}`.trim() === normalizedDetail
+ })
+ if (detailMatch) {
+ return detailMatch
+ }
+ }
+
+ if (normalizedSummary) {
+ const summaryMatch = pendingEvents.find((event) => event.summary === normalizedSummary)
+ if (summaryMatch) {
+ return summaryMatch
+ }
+ }
+
+ if (pendingEvents.length === 1) {
+ return pendingEvents[0]
+ }
+
+ return null
+}
+
function appendStatusTraceEvent(
messageId: string,
code: string,
@@ -725,9 +813,46 @@ function shouldSkipStatusEvent(code: string, stage = '') {
if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) {
return true
}
+
+ const hiddenStatusCodes = new Set([
+ 'accepted',
+ 'ask_user',
+ 'planning',
+ 'resumed',
+ 'confirmed',
+ 'rejected',
+ 'executing',
+ 'summarizing',
+ 'done',
+ 'rough_building',
+ 'order_guard_initialized',
+ 'order_guard_passed',
+ 'order_guard_restored',
+ 'order_guard_restore_skipped',
+ 'context_compact_start',
+ 'context_compact_done',
+ 'plan_auto_confirmed',
+ ])
+
+ if (hiddenStatusCodes.has(code)) {
+ return true
+ }
return false
}
+function isAssistantTimelineKind(kind: string) {
+ const assistantKinds = new Set([
+ 'assistant_text',
+ 'tool_call',
+ 'tool_result',
+ 'confirm_request',
+ 'schedule_completed',
+ 'interrupt',
+ 'status',
+ ])
+ return assistantKinds.has(kind)
+}
+
function isToolTraceExpanded(eventId: string) {
return toolTraceExpandedMap[eventId] === true
}
@@ -1582,12 +1707,12 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
const kind = String(event.kind || '').toLowerCase()
const rawRole = String(event.role || '').toLowerCase()
- // 如果 role 已明确为 user,或者 kind 包含 user 关键字
+ // 1. timeline 重建时先识别显式 user 事件,避免把真正的用户输入吞进 assistant 回合。
+ // 2. interrupt / status 这类 assistant 侧协议事件不能再掉进 user 兜底,否则会把 ask_user 正文切断。
+ // 3. 这里仍保留 kind.includes('user') 的保守判断,只是把 assistant 白名单补齐到本轮真实协议。
let isUser = rawRole === 'user' || kind.includes('user')
- // 终极兜底:只要不是明确的五大助手专属事件,就将其视为用户的消息回合边界
if (!isUser) {
- const knownAssistantKinds = ['assistant_text', 'tool_call', 'tool_result', 'confirm_request', 'schedule_completed']
- if (!knownAssistantKinds.includes(kind)) {
+ if (!isAssistantTimelineKind(kind)) {
isUser = true
}
}
@@ -1620,6 +1745,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
switch (event.kind) {
case 'assistant_text':
+ case 'interrupt':
if (event.content) {
const newContent = event.content
const oldContent = currentAssistantMessage.content || ''
@@ -1657,14 +1783,14 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
case 'tool_call':
if (event.payload?.tool) {
const t = event.payload.tool
- appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
+ appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
}
break
case 'tool_result':
if (event.payload?.tool) {
const t = event.payload.tool
- appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
+ appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
}
break