From 66c06eed0af0ddcb3dcbf9c8ebaaa1c778227612 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 27 Apr 2026 01:09:37 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.45.dev.260427=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20execute=20=E4=B8=BB=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=E2=80=9C=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=9F=9F=20+=20=E4=B8=BB=E5=8A=A8=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=80=99=E9=80=89=E9=97=AD=E7=8E=AF=E2=80=9D=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=A7=BB=E9=99=A4=20order=5Fguard=EF=BC=8C=E7=B2=97?= =?UTF-8?q?=E6=8E=92=E5=90=8E=E9=BB=98=E8=AE=A4=E8=BF=9B=E5=85=A5=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E5=BE=AE=E8=B0=83=EF=BC=8C=E5=85=88=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E5=86=8D=E4=BB=8E=E5=90=8E=E7=AB=AF=E5=80=99=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E9=80=89=E6=8B=A9=20move/swap=EF=BC=8C=E9=81=BF=E5=85=8D=20LLM?= =?UTF-8?q?=20=E8=87=AA=E7=94=B1=E5=85=A8=E5=B1=80=E4=B9=B1=E6=90=9C=202.?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E4=BD=93=E7=B3=BB=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E5=8A=A8=E6=80=81=E6=B3=A8=E5=85=A5=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20context=5Ftools=5Fadd=20?= =?UTF-8?q?/=20remove=E3=80=81=E5=B7=A5=E5=85=B7=E5=9F=9F=E4=B8=8E?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E5=8C=85=E6=98=A0=E5=B0=84=E3=80=81=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E4=BC=98=E5=8C=96=E7=99=BD=E5=90=8D=E5=8D=95=EF=BC=9B?= =?UTF-8?q?schedule=20/=20taskclass=20/=20web=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8C=89=E5=9F=9F=E6=8C=89=E5=8C=85=E6=9A=B4=E9=9C=B2=EF=BC=8C?= =?UTF-8?q?msg0=20=E8=A7=84=E5=88=99=E5=8C=85=E4=B8=8E=20execute=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=90=8C=E6=AD=A5=E9=87=8D=E5=86=99?= =?UTF-8?q?=203.=20analyze=5Fhealth=20=E5=8D=87=E7=BA=A7=E4=B8=BA=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E4=BC=98=E5=8C=96=E5=94=AF=E4=B8=80=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E2=80=94=E2=80=94=E8=A1=A5=E9=BD=90=20rhythm?= =?UTF-8?q?=20/=20tightness=20/=20profile=20/=20feasibility=20=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E3=80=81=E5=80=99=E9=80=89=E6=89=AB=E6=8F=8F=E4=B8=8E?= =?UTF-8?q?=E5=A4=8D=E8=AF=8A=E6=89=93=E5=88=86=E3=80=81=E5=81=9C=E6=BB=9E?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E3=80=81forced=20imperfection=20=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E5=B9=B6=E6=8A=8A=E8=BF=9E=E7=BB=AD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=8A=B6=E6=80=81=E5=86=99=E5=9B=9E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=80=81=204.=20=E4=BB=BB=E5=8A=A1=E7=B1=BB=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E6=96=B0=20Agent=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E9=93=BE=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20upsert=5Ftask?= =?UTF-8?q?=5Fclass=20=E5=86=99=E5=B7=A5=E5=85=B7=E4=B8=8E=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=B3=A8=E5=85=A5=E4=BA=8B=E5=8A=A1=E5=86=99=E5=85=A5?= =?UTF-8?q?=EF=BC=9B=E4=BB=BB=E5=8A=A1=E7=B1=BB=E6=A8=A1=E5=9E=8B=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=AD=A6=E7=A7=91=E7=94=BB=E5=83=8F=E4=B8=8E=E6=95=B4?= =?UTF-8?q?=E5=A4=A9=E5=B1=8F=E8=94=BD=E9=85=8D=E7=BD=AE=EF=BC=8C=E7=B2=97?= =?UTF-8?q?=E6=8E=92=E6=94=AF=E6=8C=81=20excluded=5Fdays=5Fof=5Fweek?= =?UTF-8?q?=EF=BC=8Csteady=20=E7=AD=96=E7=95=A5=E6=94=B9=E4=B8=BA=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E7=9B=AE=E6=A0=87=E4=BD=8D=E7=BD=AE/=E5=8D=95?= =?UTF-8?q?=E6=97=A5=E8=B4=9F=E8=BD=BD/=E5=88=86=E6=95=A3=E5=BA=A6/?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E7=9A=84=E5=80=99=E9=80=89=E6=89=93=E5=88=86?= =?UTF-8?q?=205.=20=E8=BF=90=E8=A1=8C=E6=80=81=E4=B8=8E=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BC=98=E5=8C=96=E6=A8=A1=E5=BC=8F=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20active=20tool?= =?UTF-8?q?=20domain/packs=E3=80=81pending=20context=20hook=E3=80=81active?= =?UTF-8?q?=20optimize=20only=E3=80=81taskclass=20=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=9B=9E=E7=9B=98=E5=BF=AB=E7=85=A7=EF=BC=9B=E5=8C=BA=E5=88=86?= =?UTF-8?q?=20first=5Ffull=20/=20global=5Freopt=20/=20local=5Fadjust?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E9=A6=96=E6=AC=A1=E7=B2=97?= =?UTF-8?q?=E6=8E=92=E5=90=8E=E9=BB=98=E8=AE=A4=20refine=20=E7=9A=84?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作 仓库: 7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件 PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。 --- backend/cmd/start.go | 81 + backend/conv/task-class.go | 50 +- backend/logic/smart_planning.go | 180 +- backend/model/task-class.go | 63 +- backend/newAgent/Log.txt | 575 ------- backend/newAgent/conv/schedule_provider.go | 23 +- backend/newAgent/conv/schedule_state.go | 37 + backend/newAgent/graph/common_graph.go | 37 +- backend/newAgent/model/common_state.go | 147 +- backend/newAgent/model/execute_contract.go | 127 +- backend/newAgent/model/pending_interaction.go | 29 + backend/newAgent/model/plan_contract.go | 94 ++ backend/newAgent/node/agent_nodes.go | 53 +- backend/newAgent/node/chat.go | 90 + backend/newAgent/node/correction.go | 55 +- backend/newAgent/node/execute.go | 792 ++++++++- backend/newAgent/node/interrupt.go | 40 +- backend/newAgent/node/order_guard.go | 462 ------ backend/newAgent/node/plan.go | 31 +- backend/newAgent/node/rough_build.go | 60 +- backend/newAgent/prompt/base.go | 18 + backend/newAgent/prompt/chat.go | 9 +- backend/newAgent/prompt/execute.go | 311 +--- backend/newAgent/prompt/execute_context.go | 395 ++++- .../newAgent/prompt/execute_context_health.go | 33 + .../prompt/execute_context_health_v2.go | 106 ++ .../prompt/execute_next_step_hint_v2.go | 104 ++ backend/newAgent/prompt/execute_rule_packs.go | 318 ++++ .../prompt/execute_rule_packs_health.go | 27 + backend/newAgent/prompt/plan.go | 114 +- backend/newAgent/prompt/plan_context.go | 18 + backend/newAgent/prompt/unified_context.go | 69 +- backend/newAgent/router/decision_parser.go | 30 +- backend/newAgent/tools/active_optimize.go | 37 + backend/newAgent/tools/context_tools.go | 305 ++++ backend/newAgent/tools/registry.go | 406 +++-- .../schedule/analyze_health_candidates.go | 1123 +++++++++++++ .../schedule/analyze_health_decision_v2.go | 124 ++ .../newAgent/tools/schedule/analyze_tools.go | 1478 +++++++++++++++++ .../newAgent/tools/schedule/compound_tools.go | 14 + .../tools/schedule/order_constraints.go | 184 ++ .../tools/schedule/read_filter_tools.go | 4 +- backend/newAgent/tools/schedule/read_tools.go | 7 + backend/newAgent/tools/schedule/state.go | 33 +- .../newAgent/tools/schedule/write_tools.go | 28 + backend/newAgent/tools/task_class_write.go | 494 ++++++ backend/newAgent/tools/tool_domain_map.go | 252 +++ backend/service/task-class.go | 16 + .../newAgent => docs/backend}/ARCHITECTURE.md | 0 .../HANDOFF_WebSearch两阶段实施计划.md | 0 .../backend}/HANDOFF_优化待办.md | 0 docs/backend/P1-P1.5执行改动计划.md | 634 +++++++ {backend/newAgent => docs/backend}/ROADMAP.md | 0 .../backend/newagent-roadmap.md | 0 {backend/newAgent => docs/backend}/prd文档.md | 0 docs/backend/主动优化整套改造计划.md | 547 ++++++ docs/backend/主动优化顺序约束拆分执行计划.md | 523 ++++++ docs/{ => backend}/智能排程四步走实施方案.md | 0 .../frontend-schedule-integration.md | 0 .../components/dashboard/AssistantPanel.vue | 195 ++- 60 files changed, 9163 insertions(+), 1819 deletions(-) delete mode 100644 backend/newAgent/Log.txt delete mode 100644 backend/newAgent/node/order_guard.go create mode 100644 backend/newAgent/prompt/execute_context_health.go create mode 100644 backend/newAgent/prompt/execute_context_health_v2.go create mode 100644 backend/newAgent/prompt/execute_next_step_hint_v2.go create mode 100644 backend/newAgent/prompt/execute_rule_packs.go create mode 100644 backend/newAgent/prompt/execute_rule_packs_health.go create mode 100644 backend/newAgent/tools/active_optimize.go create mode 100644 backend/newAgent/tools/context_tools.go create mode 100644 backend/newAgent/tools/schedule/analyze_health_candidates.go create mode 100644 backend/newAgent/tools/schedule/analyze_health_decision_v2.go create mode 100644 backend/newAgent/tools/schedule/analyze_tools.go create mode 100644 backend/newAgent/tools/schedule/order_constraints.go create mode 100644 backend/newAgent/tools/task_class_write.go create mode 100644 backend/newAgent/tools/tool_domain_map.go rename {backend/newAgent => docs/backend}/ARCHITECTURE.md (100%) rename {backend/newAgent => docs/backend}/HANDOFF_WebSearch两阶段实施计划.md (100%) rename {backend/newAgent => docs/backend}/HANDOFF_优化待办.md (100%) create mode 100644 docs/backend/P1-P1.5执行改动计划.md rename {backend/newAgent => docs/backend}/ROADMAP.md (100%) rename newagent-roadmap.md => docs/backend/newagent-roadmap.md (100%) rename {backend/newAgent => docs/backend}/prd文档.md (100%) create mode 100644 docs/backend/主动优化整套改造计划.md create mode 100644 docs/backend/主动优化顺序约束拆分执行计划.md rename docs/{ => backend}/智能排程四步走实施方案.md (100%) rename docs/{ => frontend}/frontend-schedule-integration.md (100%) diff --git a/backend/cmd/start.go b/backend/cmd/start.go index e3c54c6..e37c976 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strings" "time" "github.com/LoveLosita/smartflow/backend/api" @@ -197,6 +198,78 @@ func Start() { agentService.SetToolRegistry(newagenttools.NewDefaultRegistryWithDeps(newagenttools.DefaultRegistryDeps{ RAGRuntime: ragRuntime, WebSearchProvider: webSearchProvider, + TaskClassWriteDeps: newagenttools.TaskClassWriteDeps{ + UpsertTaskClass: func(userID int, input newagenttools.TaskClassUpsertInput) (newagenttools.TaskClassUpsertPersistResult, error) { + req := input.Request + taskClassID := 0 + created := input.ID == 0 + + err := taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { + // 1. 先构造任务类主体,保持与现有 AddOrUpdateTaskClass 口径一致。 + taskClass := &model.TaskClass{ + ID: input.ID, + Name: &req.Name, + Mode: &req.Mode, + SubjectType: stringPtrOrNil(req.SubjectType), + DifficultyLevel: stringPtrOrNil(req.DifficultyLevel), + CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity), + TotalSlots: &req.Config.TotalSlots, + Strategy: &req.Config.Strategy, + ExcludedSlots: req.Config.ExcludedSlots, + ExcludedDaysOfWeek: req.Config.ExcludedDaysOfWeek, + } + taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse + + // 2. 自动模式下写入日期范围;手动模式允许为空。 + if req.StartDate != "" { + startDate, parseErr := time.ParseInLocation("2006-01-02", req.StartDate, time.Local) + if parseErr != nil { + return parseErr + } + taskClass.StartDate = &startDate + } + if req.EndDate != "" { + endDate, parseErr := time.ParseInLocation("2006-01-02", req.EndDate, time.Local) + if parseErr != nil { + return parseErr + } + taskClass.EndDate = &endDate + } + + // 3. upsert 主体后拿到稳定 task_class_id,供 items 绑定 category_id。 + updatedID, upsertErr := txDAO.AddOrUpdateTaskClass(userID, taskClass) + if upsertErr != nil { + return upsertErr + } + taskClassID = updatedID + + // 4. 构造任务块并批量 upsert。 + items := make([]model.TaskClassItem, 0, len(req.Items)) + for _, itemReq := range req.Items { + categoryID := taskClassID + order := itemReq.Order + content := itemReq.Content + status := model.TaskItemStatusUnscheduled + items = append(items, model.TaskClassItem{ + ID: itemReq.ID, + CategoryID: &categoryID, + Order: &order, + Content: &content, + EmbeddedTime: itemReq.EmbeddedTime, + Status: &status, + }) + } + return txDAO.AddOrUpdateTaskClassItems(userID, items) + }) + if err != nil { + return newagenttools.TaskClassUpsertPersistResult{}, err + } + return newagenttools.TaskClassUpsertPersistResult{ + TaskClassID: taskClassID, + Created: created, + }, nil + }, + }, })) agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo)) agentService.SetCompactionStore(agentRepo) @@ -271,3 +344,11 @@ func Start() { r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter) routers.StartEngine(r) } + +func stringPtrOrNil(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/backend/conv/task-class.go b/backend/conv/task-class.go index 557ed73..954a28c 100644 --- a/backend/conv/task-class.go +++ b/backend/conv/task-class.go @@ -32,11 +32,14 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i } //1.填充section1,2 taskClass := model.TaskClass{ - Name: &req.Name, - Mode: &req.Mode, - StartDate: startDate, - EndDate: endDate, - UserID: &userID, + Name: &req.Name, + Mode: &req.Mode, + StartDate: startDate, + EndDate: endDate, + SubjectType: stringPtrOrNil(req.SubjectType), + DifficultyLevel: stringPtrOrNil(req.DifficultyLevel), + CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity), + UserID: &userID, } //2.填充section3 taskClass.TotalSlots = &req.Config.TotalSlots @@ -59,6 +62,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i taskClass.ExcludedSlots = &emptyJSON }*/ taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int + taskClass.ExcludedDaysOfWeek = req.Config.ExcludedDaysOfWeek //3.开始构建 items var items []model.TaskClassItem for _, itemReq := range req.Items { @@ -84,13 +88,16 @@ func TaskClassModelToResponse(taskClasses []model.TaskClass) *model.UserGetTaskC var resp model.UserGetTaskClassesResponse for _, tc := range taskClasses { tcResp := model.TaskClassSummary{ - ID: tc.ID, - Name: *tc.Name, - Mode: *tc.Mode, - StartDate: timeOrZero(tc.StartDate), - EndDate: timeOrZero(tc.EndDate), - TotalSlots: *tc.TotalSlots, - Strategy: *tc.Strategy, + ID: tc.ID, + Name: *tc.Name, + Mode: *tc.Mode, + StartDate: timeOrZero(tc.StartDate), + EndDate: timeOrZero(tc.EndDate), + TotalSlots: *tc.TotalSlots, + Strategy: *tc.Strategy, + SubjectType: safeStr(tc.SubjectType), + DifficultyLevel: safeStr(tc.DifficultyLevel), + CognitiveIntensity: safeStr(tc.CognitiveIntensity), } resp.TaskClasses = append(resp.TaskClasses, tcResp) } @@ -103,10 +110,13 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model. } // 1. 映射基础信息 (处理指针解引用) req := &model.UserAddTaskClassRequest{ - Name: safeStr(taskClass.Name), - Mode: safeStr(taskClass.Mode), - StartDate: formatTime(taskClass.StartDate), - EndDate: formatTime(taskClass.EndDate), + Name: safeStr(taskClass.Name), + Mode: safeStr(taskClass.Mode), + StartDate: formatTime(taskClass.StartDate), + EndDate: formatTime(taskClass.EndDate), + SubjectType: safeStr(taskClass.SubjectType), + DifficultyLevel: safeStr(taskClass.DifficultyLevel), + CognitiveIntensity: safeStr(taskClass.CognitiveIntensity), } // 2. 映射配置信息 (Config Section) req.Config = model.UserAddTaskClassConfig{ @@ -123,6 +133,7 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model. } }*/ req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int + req.Config.ExcludedDaysOfWeek = taskClass.ExcludedDaysOfWeek // 4. 映射子项信息 (Items Section) // 此时 items 已经通过 Preload 加载到了 taskClass.Items 中 req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items)) @@ -184,6 +195,13 @@ func safeInt(i *int) int { return *i } +func stringPtrOrNil(value string) *string { + if value == "" { + return nil + } + return &value +} + func safeBool(b *bool) bool { if b == nil { return true diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go index c051cf4..0b055fa 100644 --- a/backend/logic/smart_planning.go +++ b/backend/logic/smart_planning.go @@ -337,6 +337,17 @@ func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid } } } + // 标记整天屏蔽: + // 1. excluded_days_of_week 表示“这些星期几整天都不允许粗排”; + // 2. 与 excluded_slots 一样属于硬约束,因此直接写入 Blocked; + // 3. 一旦工作日容量不足,粗排应直接失败,而不是偷偷排到被排除的星期里。 + for _, blockedDay := range taskClass.ExcludedDaysOfWeek { + for w := startW; w <= endW; w++ { + for s := 1; s <= 12; s++ { + g.setNode(w, blockedDay, s, slotNode{Status: Blocked}) + } + } + } // 映射日程 (尊重 Blocked 且只处理范围内的数据) for _, s := range schedules { @@ -450,6 +461,146 @@ type planningSlotCandidate struct { sectionTo int } +// countDayAvailable 统计某一天当前还可用于粗排的节次数。 +// +// 职责边界: +// 1. 只把 Free/Filler 视为“仍可消费”的资源; +// 2. 不区分其来源是纯空位还是可嵌入课程,因为对粗排而言二者都代表后续还能放任务; +// 3. 仅用于候选打分,不直接参与最终合法性判断。 +func (g *grid) countDayAvailable(week, day int) int { + if g == nil { + return 0 + } + count := 0 + for section := 1; section <= 12; section++ { + node := g.getNode(week, day, section) + if node.Status == Free || node.Status == Filler { + count++ + } + } + return count +} + +// countDayOccupied 统计某一天当前已被 existing/virtual/task 占住的节次数。 +func (g *grid) countDayOccupied(week, day int) int { + if g == nil { + return 0 + } + count := 0 + for section := 1; section <= 12; section++ { + if g.getNode(week, day, section).Status == Occupied { + count++ + } + } + return count +} + +// collectPlanningCandidatesFromCursor 收集从给定游标开始仍然合法的候选落位。 +// +// 设计说明: +// 1. 这里复用现有 findNextCandidateFromCursor 的合法性规则,避免复制一套“什么叫合法双节”的判断; +// 2. 通过跳过已命中候选的跨度,减少同一课程块被重复返回; +// 3. 保留快照上的 coordIndex,供 steady 策略计算“距离目标位置有多远”。 +func (g *grid) collectPlanningCandidatesFromCursor(coords []slotCoord, startCursor int) []planningSlotCandidate { + if g == nil || startCursor >= len(coords) { + return nil + } + candidates := make([]planningSlotCandidate, 0, 16) + seen := make(map[string]struct{}) + for cursor := startCursor; cursor < len(coords); { + candidate, found := g.findNextCandidateFromCursor(coords, cursor) + if !found { + break + } + key := fmt.Sprintf("%d-%d-%d-%d", candidate.week, candidate.dayOfWeek, candidate.sectionFrom, candidate.sectionTo) + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + candidates = append(candidates, candidate) + } + nextCursor := candidate.coordIndex + (candidate.sectionTo - candidate.sectionFrom + 1) + if nextCursor <= cursor { + nextCursor = cursor + 1 + } + cursor = nextCursor + } + return candidates +} + +func computeSteadyTargetCursor(totalAvailable, totalItems, itemIndex int) int { + if totalAvailable <= 1 || totalItems <= 1 { + return 0 + } + target := ((itemIndex + 1) * totalAvailable) / (totalItems + 1) + if target < 0 { + return 0 + } + if target >= totalAvailable { + return totalAvailable - 1 + } + return target +} + +func planningDayOrdinal(week, day int) int { + return week*7 + day +} + +func absInt(value int) int { + if value < 0 { + return -value + } + return value +} + +// chooseSteadyCandidate 为 steady 策略挑选“更均衡、更分散、更留余地”的候选位。 +// +// 评分原则: +// 1. 先尽量接近本任务在窗口中的目标分布位置; +// 2. 再偏好当前已占用更少的天,避免单日继续堆高; +// 3. 再惩罚与同任务类既有落位过近或同日重复,降低同科过度集中; +// 4. 最后惩罚吃掉当天最后一小段缓冲,给后续调整保留容错空间。 +func (g *grid) chooseSteadyCandidate( + coords []slotCoord, + targetCursor int, + placedDayOrdinals []int, +) (planningSlotCandidate, bool) { + candidates := g.collectPlanningCandidatesFromCursor(coords, 0) + if len(candidates) == 0 { + return planningSlotCandidate{}, false + } + + best := candidates[0] + bestScore := int(^uint(0) >> 1) + for _, candidate := range candidates { + slotSpan := candidate.sectionTo - candidate.sectionFrom + 1 + distancePenalty := absInt(candidate.coordIndex-targetCursor) * 10 + dayOccupiedPenalty := g.countDayOccupied(candidate.week, candidate.dayOfWeek) * 25 + remainingAvailable := g.countDayAvailable(candidate.week, candidate.dayOfWeek) - slotSpan + bufferPenalty := 0 + if remainingAvailable < 2 { + bufferPenalty = 80 + } + + dayOrdinal := planningDayOrdinal(candidate.week, candidate.dayOfWeek) + rhythmPenalty := 0 + for _, placed := range placedDayOrdinals { + diff := absInt(dayOrdinal - placed) + switch { + case diff == 0: + rhythmPenalty += 180 + case diff == 1: + rhythmPenalty += 60 + } + } + + score := distancePenalty + dayOccupiedPenalty + bufferPenalty + rhythmPenalty + candidate.coordIndex + if score < bestScore { + bestScore = score + best = candidate + } + } + return best, true +} + // getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。 // // 设计说明: @@ -604,8 +755,8 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([ } // 2. 计算间隔策略: - // 2.1 rapid:gap=0,尽快塞满; - // 2.2 steady:按剩余可用位均匀留白。 + // 2.1 rapid:沿用“尽快塞满”的线性前进; + // 2.2 steady:不再只靠 gap 跳格子,而是结合目标位置、单日负载、同科分散和缓冲保留做候选打分。 gap := 0 if strategy == "steady" { gap = (totalAvailable - totalRequired) / (len(items) + 1) @@ -617,16 +768,22 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([ // 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。 cursor := gap lastPlacedIndex := -1 + placedDayOrdinals := make([]int, 0, len(items)) for i := range items { - if cursor >= totalAvailable { - break + var ( + candidate planningSlotCandidate + found bool + ) + if strategy == "steady" { + targetCursor := computeSteadyTargetCursor(totalAvailable, len(items), i) + candidate, found = g.chooseSteadyCandidate(coords, targetCursor, placedDayOrdinals) + } else { + if cursor >= totalAvailable { + break + } + candidate, found = g.findNextCandidateFromCursor(coords, cursor) } - - // 4. 先找候选,不立即写入: - // 4.1 找不到候选时提前结束; - // 4.2 最终统一通过 lastPlacedIndex 判断是否完整排完。 - candidate, found := g.findNextCandidateFromCursor(coords, cursor) if !found { break } @@ -648,7 +805,10 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([ // 7. 推进游标并记录成功位置。 slotLen := candidate.sectionTo - candidate.sectionFrom + 1 - cursor = candidate.coordIndex + slotLen + gap + if strategy != "steady" { + cursor = candidate.coordIndex + slotLen + gap + } + placedDayOrdinals = append(placedDayOrdinals, planningDayOrdinal(candidate.week, candidate.dayOfWeek)) lastPlacedIndex = i } diff --git a/backend/model/task-class.go b/backend/model/task-class.go index 48a4d6b..aad509b 100644 --- a/backend/model/task-class.go +++ b/backend/model/task-class.go @@ -13,16 +13,20 @@ type TaskClass struct { ID int `gorm:"column:id;primaryKey;autoIncrement"` UserID *int `gorm:"column:user_id;index:idx_task_classes_user_id"` //section 2 - Name *string `gorm:"column:name;size:255"` - Mode *string `gorm:"column:mode;type:enum('auto','manual')"` - StartDate *time.Time `gorm:"column:start_date"` - EndDate *time.Time `gorm:"column:end_date"` + Name *string `gorm:"column:name;size:255"` + Mode *string `gorm:"column:mode;type:enum('auto','manual')"` + StartDate *time.Time `gorm:"column:start_date"` + EndDate *time.Time `gorm:"column:end_date"` + SubjectType *string `gorm:"column:subject_type;size:32;comment:学科类型 quantitative|memory|reading|mixed"` + DifficultyLevel *string `gorm:"column:difficulty_level;size:16;comment:难度等级 low|medium|high"` + CognitiveIntensity *string `gorm:"column:cognitive_intensity;size:16;comment:认知强度 low|medium|high"` //section 3 - TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` - AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` - Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` - ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` - Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem + TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` + AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` + Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` + ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` + ExcludedDaysOfWeek IntSlice `gorm:"column:excluded_days_of_week;type:json;comment:不想要的星期几切片(1-7)"` + Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem } // IntSlice 用于把 []int 以 JSON 形式存入/读出数据库 json 字段 @@ -74,20 +78,24 @@ type TaskClassItem struct { // UserAddTaskClassRequest 用于处理用户添加任务类别的请求 type UserAddTaskClassRequest struct { - Name string `json:"name" binding:"required"` - StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD - EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD - Mode string `json:"mode" binding:"required,oneof=auto manual"` - Config UserAddTaskClassConfig `json:"config" binding:"required"` - Items []UserAddTaskClassItemRequest `json:"items" binding:"required"` + Name string `json:"name" binding:"required"` + StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD + EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD + Mode string `json:"mode" binding:"required,oneof=auto manual"` + SubjectType string `json:"subject_type,omitempty"` + DifficultyLevel string `json:"difficulty_level,omitempty"` + CognitiveIntensity string `json:"cognitive_intensity,omitempty"` + Config UserAddTaskClassConfig `json:"config" binding:"required"` + Items []UserAddTaskClassItemRequest `json:"items" binding:"required"` } // UserAddTaskClassConfig 用于处理用户添加任务类别时的配置部分 type UserAddTaskClassConfig struct { - TotalSlots int `json:"total_slots" binding:"required,min=1"` - AllowFillerCourse bool `json:"allow_filler_course"` - Strategy string `json:"strategy" binding:"required,oneof=steady rapid"` - ExcludedSlots []int `json:"excluded_slots"` + TotalSlots int `json:"total_slots" binding:"required,min=1"` + AllowFillerCourse bool `json:"allow_filler_course"` + Strategy string `json:"strategy" binding:"required,oneof=steady rapid"` + ExcludedSlots []int `json:"excluded_slots"` + ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` } // UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分 @@ -113,13 +121,16 @@ type UserGetTaskClassesResponse struct { // TaskClassSummary 提供任务类别的简要信息 type TaskClassSummary struct { - ID int `json:"id"` - Name string `json:"name"` - Mode string `json:"mode"` - Strategy string `json:"strategy"` - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` - TotalSlots int `json:"total_slots"` + ID int `json:"id"` + Name string `json:"name"` + Mode string `json:"mode"` + Strategy string `json:"strategy"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + TotalSlots int `json:"total_slots"` + SubjectType string `json:"subject_type,omitempty"` + DifficultyLevel string `json:"difficulty_level,omitempty"` + CognitiveIntensity string `json:"cognitive_intensity,omitempty"` } type UserInsertTaskClassItemToScheduleRequest struct { diff --git a/backend/newAgent/Log.txt b/backend/newAgent/Log.txt deleted file mode 100644 index 9d8b008..0000000 --- a/backend/newAgent/Log.txt +++ /dev/null @@ -1,575 +0,0 @@ -GOROOT=C:\Program Files\Go #gosetup -GOPATH=C:\Users\Dev\go #gosetup -"C:\Program Files\Go\bin\go.exe" build -o C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___6go_build_main_go.exe D:\SmartFlow-Agent\backend\main.go #gosetup -C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___6go_build_main_go.exe #gosetup -2026/04/18 10:03:36 Config loaded successfully -2026/04/18 10:03:45 Database connected successfully -2026/04/18 10:03:45 Database auto migration completed -2026/04/18 10:03:45 RAG runtime initialized: store=milvus embed=eino reranker=noop -2026/04/18 10:03:45 outbox engine starting: topic=smartflow.agent.outbox brokers=[localhost:9092] retry_scan=1s batch=100 -2026/04/18 10:03:45 Kafka topic is ready: smartflow.agent.outbox -2026/04/18 10:03:45 Outbox event bus started -2026/04/18 10:03:45 Memory worker started -2026/04/18 10:03:45 WebSearch provider: bocha -2026/04/18 10:03:45 Routes setup completed -2026/04/18 10:03:45 Server starting on port 8080... -[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. - -[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - - using env: export GIN_MODE=release - - using code: gin.SetMode(gin.ReleaseMode) - -[GIN-debug] GET /api/v1/health --> github.com/LoveLosita/smartflow/backend/routers.RegisterRouters.func1 (3 handlers) -[GIN-debug] POST /api/v1/user/register --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserRegister-fm (3 handlers) -[GIN-debug] POST /api/v1/user/login --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogin-fm (3 handlers) -[GIN-debug] POST /api/v1/user/refresh-token --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).RefreshTokenHandler-fm (3 handlers) -[GIN-debug] POST /api/v1/user/logout --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogout-fm (5 handlers) -[GIN-debug] POST /api/v1/task/create --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).AddTask-fm (6 handlers) -[GIN-debug] PUT /api/v1/task/complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).CompleteTask-fm (6 handlers) -[GIN-debug] PUT /api/v1/task/undo-complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).UndoCompleteTask-fm (6 handlers) -[GIN-debug] GET /api/v1/task/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).GetUserTasks-fm (5 handlers) -[GIN-debug] POST /api/v1/course/validate --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).CheckUserCourse-fm (5 handlers) -[GIN-debug] POST /api/v1/course/import --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).AddUserCourses-fm (6 handlers) -[GIN-debug] POST /api/v1/task-class/add --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClass-fm (6 handlers) -[GIN-debug] GET /api/v1/task-class/list --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetTaskClassInfos-fm (5 handlers) -[GIN-debug] GET /api/v1/task-class/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetCompleteTaskClass-fm (5 handlers) -[GIN-debug] PUT /api/v1/task-class/update --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserUpdateTaskClass-fm (6 handlers) -[GIN-debug] POST /api/v1/task-class/insert-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClassItemIntoSchedule-fm (6 handlers) -[GIN-debug] DELETE /api/v1/task-class/delete-item --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClassItem-fm (6 handlers) -[GIN-debug] DELETE /api/v1/task-class/delete-class --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClass-fm (6 handlers) -[GIN-debug] PUT /api/v1/task-class/apply-batch-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserInsertBatchTaskClassItemsIntoSchedule-fm (6 handlers) -[GIN-debug] GET /api/v1/schedule/today --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserTodaySchedule-fm (5 handlers) -[GIN-debug] GET /api/v1/schedule/week --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserWeeklySchedule-fm (5 handlers) -[GIN-debug] DELETE /api/v1/schedule/delete --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).DeleteScheduleEvent-fm (6 handlers) -[GIN-debug] GET /api/v1/schedule/recent-completed --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserRecentCompletedSchedules-fm (5 handlers) -[GIN-debug] GET /api/v1/schedule/current --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserOngoingSchedule-fm (5 handlers) -[GIN-debug] DELETE /api/v1/schedule/undo-task-item --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).UserRevocateTaskItemFromSchedule-fm (6 handlers) -[GIN-debug] GET /api/v1/schedule/smart-planning --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanning-fm (5 handlers) -[GIN-debug] POST /api/v1/schedule/smart-planning-multi --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanningMulti-fm (5 handlers) -[GIN-debug] POST /api/v1/agent/chat --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).ChatAgent-fm (6 handlers) -[GIN-debug] GET /api/v1/agent/conversation-meta --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationMeta-fm (5 handlers) -[GIN-debug] GET /api/v1/agent/conversation-list --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationList-fm (5 handlers) -[GIN-debug] GET /api/v1/agent/conversation-history --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationHistory-fm (5 handlers) -[GIN-debug] GET /api/v1/agent/schedule-preview --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetSchedulePlanPreview-fm (5 handlers) -[GIN-debug] GET /api/v1/agent/context-stats --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetContextStats-fm (5 handlers) -[GIN-debug] GET /api/v1/memory/items --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).ListItems-fm (5 handlers) -[GIN-debug] GET /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).GetItem-fm (5 handlers) -[GIN-debug] POST /api/v1/memory/items --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).CreateItem-fm (6 handlers) -[GIN-debug] PATCH /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).UpdateItem-fm (6 handlers) -[GIN-debug] DELETE /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).DeleteItem-fm (6 handlers) -[GIN-debug] POST /api/v1/memory/items/:id/restore --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).RestoreItem-fm (6 handlers) -[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. -Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. -[GIN-debug] Listening and serving HTTP on :8080 -[GIN] 2026/04/18 - 10:03:47 | 200 | 56.2777ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active" -[GIN] 2026/04/18 - 10:03:48 | 200 | 51.0388ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d" -[GIN] 2026/04/18 - 10:03:48 | 200 | 2.0207ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d" -[GIN] 2026/04/18 - 10:03:48 | 200 | 47.1267ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d" -[GIN] 2026/04/18 - 10:03:56 | 200 | 49.8019ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831" -[GIN] 2026/04/18 - 10:03:56 | 200 | 2.3995ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831" -[GIN] 2026/04/18 - 10:03:56 | 200 | 9.1263ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831" -[GIN] 2026/04/18 - 10:03:57 | 200 | 2.2448ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0" -[GIN] 2026/04/18 - 10:03:57 | 200 | 48.1556ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0" -[GIN] 2026/04/18 - 10:03:57 | 200 | 48.1556ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0" -[GIN] 2026/04/18 - 10:03:57 | 200 | 49.2902ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3" -[GIN] 2026/04/18 - 10:03:57 | 200 | 1.4866ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3" -[GIN] 2026/04/18 - 10:03:57 | 200 | 4.8978ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3" - -2026/04/18 10:04:06 D:/SmartFlow-Agent/backend/dao/agent.go:211 record not found -[47.428ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = '6c0edfe9-2dba-4927-905b-bfdb06e19e2a' ORDER BY `agent_chats`.`id` LIMIT 1 -2026/04/18 10:04:06 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a -2026/04/18 10:04:06 [DEBUG] loadOrCreateRuntimeState chatID=6c0edfe9-2dba-4927-905b-bfdb06e19e2a ok=false err= hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false -2026/04/18 10:04:06 [INFO] memory prefetch: 启动后台检索 goroutine user=1 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a -2026/04/18 10:04:06 [COMPACT:chat] token budget check: total=1619 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=1512 msg1=20 msg2=14 msg3=73) -2026/04/18 10:04:06 [DEBUG] chat LLM context begin phase=routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=0 message_count=4 ------ message[0] ----- -role: system -content: - 你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。 - 你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 - 你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 - 你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。 - 如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。 - 重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。 - - 你是 SmartMate 的聊天路由助手。SmartMate 是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助;它擅长日程安排、任务管理与学习规划,但不只会做排程。你的回复必须以路由控制码开头,控制码后紧跟用户可见的内容。 - - 路由规则: - - direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。 - - execute:需要用工具处理的请求(记录任务/提醒、查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。 - - deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。 - - plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。 - - 通用回答约束: - - 非日程、非任务类问题,只要不需要工具,也应当正常回答。 - - 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。 - - 不要把普通问答、生活建议、开放式讨论,硬拐成排程请求。 - - route=direct_reply 时,控制码后的可见内容应直接回应用户问题,而不是先讲能力边界。 - - route=deep_answer 时,只输出控制码即可,不要补“让我想想”“这是个好问题”之类的占位话术。 - - 粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程"等批量调度需求时,可设置 rough_build=true;后端会结合真实请求范围决定是否真正进入粗排。 - 二次粗排约束(强约束): - - 若上下文已出现 rough_build_done,且用户未明确要求"重新粗排/从头重排",必须设置 rough_build=false。 - - "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine,不得再次触发 rough build。 - 粗排后微调判断: - - 仅当 rough_build=true 时才判断 refine。 - - 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 refine=true。 - - 若用户只要求"先排进去/给初稿",未提出微调目标,设 refine=false。 - 顺序授权判断: - - reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。 - - 用户明确要求"保持顺序/不要打乱"时必须为 false。 - - 若用户未明确提及顺序,一律为 false。 - 深度思考判断: - - thinking 仅在 route=execute 时有效。 - - 当用户请求涉及复杂推理、多条件约束、需要深度分析后才能执行的操作时,设 thinking=true。 - - 简单查询、单步操作设 thinking=false。 - - 输出格式(严格两段式): - 第一段(控制码,用户不可见,后端会截取): - - 第二段(紧接控制码之后,用户可见): - 根据路由输出对应内容。 - - 属性说明(仅 route=execute 时有效,其余路由省略这些属性): - - rough_build:是否需要粗排 - - refine:粗排后是否需要微调 - - reorder:是否允许打乱顺序 - - thinking:后续执行阶段是否需要深度思考 - - 合法示例: - - - 当然可以,我先直接回答你这个问题。 - - - 好的,我来帮你看看今天的安排。 - - - 好的,我来帮你排课。 - - - 好的,我来帮你排课并按你的偏好做微调。 - - - - - 明白,我来帮你制定一个完整的学习计划。 - - 禁止输出任何 JSON、markdown 代码块或额外解释。nonce 必须精确使用给定值。 - ------ message[1] ----- -role: assistant -content: - 真实对话记录: - user: "提醒我明天中午吃乡村基" - ------ message[2] ----- -role: assistant -content: - 路由补充: - - 暂无额外流程标记。 - ------ message[3] ----- -role: user -content: - nonce=932ef523-3e20-4595-8a09-2ff4319f0394 - 当前时间=2026-04-18 10:04 - - 请基于最近真实对话和本轮输入选择最合适的路由,并严格按系统约定输出控制码。 - - 用户本轮输入: - 提醒我明天中午吃乡村基 - - -[DEBUG] chat LLM context end phase=routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=0 -2026/04/18 10:04:06 rag level=info component=store operation=ensure_collection action=search collection=smartflow_rag_chunks corpus=memory latency_ms=5 metric_type=COSINE status=already_exists store=milvus vector_dim=1024 -2026/04/18 10:04:06 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR filter_count=3 latency_ms=5 status=failed store=milvus top_k=10 vector_dim=1024 -2026/04/18 10:04:06 rag level=error component=runtime operation=retrieve action=search corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR latency_ms=215 query_len=33 status=failed threshold=0.55 top_k=10 -2026/04/18 10:04:07 memory level=info component=read operation=retrieve dedup_drop_count=0 degraded=true final_count=5 legacy_hit_count=0 pinned_hit_count=0 query_len=33 rag_fallback_used=true read_mode=hybrid semantic_hit_count=10 success=true user_id=1 -2026/04/18 10:04:07 [INFO] memory prefetch: 后台检索完成 user=1 count=5 -2026/04/18 10:04:07 outbox due messages=1, start dispatch -2026/04/18 10:04:08 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a -2026/04/18 10:04:09 outbox due messages=1, start dispatch -2026/04/18 10:04:09 [DEBUG] chat routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a route=execute needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw= -2026/04/18 10:04:10 [COMPACT:execute] token budget check: total=4060 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=3694 msg1=45 msg2=19 msg3=302) -2026/04/18 10:04:10 [DEBUG] execute LLM context begin phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 message_count=4 ------ message[0] ----- -role: system -content: - 你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。 - 你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 - 你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 - 你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。 - 如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。 - 重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。 - - 你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。 - - 阶段事实(强约束): - 1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing,不是待排入状态。 - 2. 当前阶段目标是"微调",不是"重新粗排"。 - 3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。 - 4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。 - - 你可以做什么: - 1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。 - 2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。 - 3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。 - 4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user。 - 5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。 - 6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。 - - 你不要做什么: - 1. 不要假设任务还没排进去,然后改成逐个手动 place。 - 2. 不要伪造工具结果。 - 3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。 - 4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。 - 5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。 - 6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。 - 7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。 - 8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。 - 9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。 - 10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。 - 11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。 - 12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。 - 13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。 - - 执行规则: - 1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。 - 2. 读操作:action=continue + tool_call。 - 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 - 4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。 - 5. 缺关键上下文且无法通过工具补齐:action=ask_user。 - 6. 任务完成:action=done,并在 goal_check 总结完成证据。 - 7. 流程应正式终止:action=abort。 - - 补充 JSON 约束: - 1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。 - 2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。 - 3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。 - 4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。 - 5. action=continue / ask_user / confirm 时,speak 必须是非空自然语言。 - - 可用工具(简表): - 1. batch_move:原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。 - 参数:moves(必填,array) - 返回类型:string(自然语言文本) - 返回示例:批量移动完成,2个任务全部成功。(单次最多2条) - 2. get_overview:获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。 - 参数:{} - 返回类型:string(自然语言文本) - 返回示例:规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)... - 3. get_task_info:查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。 - 参数:task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节 - 4. min_context_switch:在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。 - 参数:task_id(可选,int);task_ids(必填,array) - 返回类型:string(自然语言文本) - 返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。 - 5. move:将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。 - 参数:new_day(必填,int);new_slot_start(必填,int);task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。 - 6. place:将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。 - 参数:day(必填,int);slot_start(必填,int);task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 预排到第5天第3-4节。 - 7. query_available_slots:查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。 - 参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);duration(可选,int);exclude_sections(可选,array);limit(可选,int);section_from(可选,int);section_to(可选,int);slot_type(可选,string);slot_types(可选,array);span(可选,int);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]} - 8. query_range:查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。 - 参数:day(必填,int);slot_end(可选,int);slot_start(可选,int) - 返回类型:string(自然语言文本) - 返回示例:第5天第3-6节:第3节空、第4节空... - 9. query_target_tasks:查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。 - 参数:category(可选,string);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);enqueue(可选,bool);limit(可选,int);reset_queue(可选,bool);status(可选,string:all/existing/suggested/pending);task_id(可选,int);task_ids(可选,array);task_item_id(可选,int);task_item_ids(可选,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]} - 10. queue_apply_head_move:将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。 - 参数:new_day(必填,int);new_slot_start(必填,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"} - 11. queue_pop_head:弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。 - 参数:{} - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}} - 12. queue_skip_head:跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。 - 参数:reason(可选,string) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1} - 13. queue_status:查看当前待处理队列状态(pending/current/completed/skipped)。 - 参数:{} - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1} - 14. quick_note_create:记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间(如“明天下午3点”、“下周一”)。title 必填。记录成功后,回复时应包含一句与任务内容相关的轻松跟进话术(不超过30字),类似朋友间的友好调侃。 - 参数:deadline_at(可选,string);priority_group(可选,int);title(必填,string) - 返回类型:string(自然语言文本) - 返回示例:自然语言结果(成功/失败原因/关键数据摘要)。 - 15. spread_even:在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。 - 参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);exclude_sections(可选,array);limit(可选,int);slot_type(可选,string);slot_types(可选,array);task_id(可选,int);task_ids(必填,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(自然语言文本) - 返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。 - 16. swap:交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。 - 参数:task_a(必填,int);task_b(必填,int) - 返回类型:string(自然语言文本) - 返回示例:交换完成:[35]... ↔ [36]... - 17. unplace:将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。 - 参数:task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 移除,恢复为待安排状态。 - 18. web_fetch:抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。 - 参数:max_chars(可选,int);url(必填,string) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false} - 19. web_search:Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。 - 参数:domain_allow(可选,array);query(必填,string);recency_days(可选,int);top_k(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]} - ------ message[1] ----- -role: assistant -content: - 历史上下文: - 对话历史: - user: "提醒我明天中午吃乡村基" - - 阶段锚点:按当前工具事实推进,不做无依据操作。 - ------ message[2] ----- -role: assistant -content: - 当轮 ReAct Loop 记录: - - 已清空(新一轮 loop 准备中)。 - ------ message[3] ----- -role: system -content: - 当前执行状态: - - 当前轮次:1/60 - - 当前模式:自由执行(无预定义步骤) - - 啥时候结束Loop:你可以根据工具调用记录自行判断。 - - 非目标:不重新粗排、不修改无关任务类。 - - 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。 - - 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。 - 相关记忆(仅在确有帮助时参考,不要机械复述): - 以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。 - - [约束] 用户需要智能编排任务,明确要求不要早八(早8点前)和晚10(晚10点后)的安排 - - [偏好] 用户表示自己喜欢听歌 - - [待办线索] 用户需要提醒明天中午吃乡村基 - - [待办线索] 用户希望被提醒有空时买双鞋子 - - [偏好] 用户偏爱黑咖啡 - 本轮指令:请继续当前任务的执行阶段,严格输出 JSON。 - - -[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 -2026/04/18 10:04:15 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR filter_count=4 latency_ms=0 status=failed store=milvus top_k=5 vector_dim=1024 -2026/04/18 10:04:15 rag level=error component=runtime operation=retrieve action=search corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR latency_ms=115 query_len=54 status=failed threshold=0.6 top_k=5 -2026/04/18 10:04:15 [WARN][去重] Milvus 语义召回失败,降级到 MySQL: user_id=1 memory_type=todo_hint topk=5 err=unsupported milvus filter key: status -2026/04/18 10:04:15 [DEBUG][去重] 语义召回候选: job_id=65 user_id=1 memory_type=todo_hint candidate_count=2 -2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=30 score=0.0000 content="用户需要提醒明天中午吃乡村基" -2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=29 score=0.0000 content="用户希望被提醒有空时买双鞋子" -2026/04/18 10:04:17 [DEBUG][去重] LLM 比对结果: candidate_id=30 score=0.0000 relation=duplicate reason="新事实与旧记忆内容完全一致,都是提醒明天中午吃乡村基" candidate_content="用户需要提醒明天中午吃乡村基" -2026/04/18 10:04:17 [DEBUG] execute LLM 响应 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 action=continue speak_len=57 raw_len=301 raw_preview=```json -{ - "action": "continue", - "speak": "好的,帮你记下明天中午吃乡村基的提醒。", - "tool_call": { - "name": "quick_note_create", - "arguments": { - "title": "明天中午吃乡村基", - "deadline_at": "明天中午12点", - -2026/04/18 10:04:18 [DEBUG] execute tool chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 tool=quick_note_create args={"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"} before=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 after=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 result_preview={"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。"} -2026/04/18 10:04:18 [GORM-Cache] Invalidated task list cache for user 1 -2026/04/18 10:04:18 [COMPACT:execute] token budget check: total=4224 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=3694 msg1=66 msg2=162 msg3=302) -2026/04/18 10:04:18 [DEBUG] execute LLM context begin phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2 message_count=4 ------ message[0] ----- -role: system -content: - 你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。 - 你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 - 你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 - 你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。 - 如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。 - 重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。 - - 你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。 - - 阶段事实(强约束): - 1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing,不是待排入状态。 - 2. 当前阶段目标是"微调",不是"重新粗排"。 - 3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。 - 4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。 - - 你可以做什么: - 1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。 - 2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。 - 3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。 - 4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user。 - 5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。 - 6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。 - - 你不要做什么: - 1. 不要假设任务还没排进去,然后改成逐个手动 place。 - 2. 不要伪造工具结果。 - 3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。 - 4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。 - 5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。 - 6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。 - 7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。 - 8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。 - 9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。 - 10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。 - 11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。 - 12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。 - 13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。 - - 执行规则: - 1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。 - 2. 读操作:action=continue + tool_call。 - 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 - 4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。 - 5. 缺关键上下文且无法通过工具补齐:action=ask_user。 - 6. 任务完成:action=done,并在 goal_check 总结完成证据。 - 7. 流程应正式终止:action=abort。 - - 补充 JSON 约束: - 1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。 - 2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。 - 3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。 - 4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。 - 5. action=continue / ask_user / confirm 时,speak 必须是非空自然语言。 - - 可用工具(简表): - 1. batch_move:原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。 - 参数:moves(必填,array) - 返回类型:string(自然语言文本) - 返回示例:批量移动完成,2个任务全部成功。(单次最多2条) - 2. get_overview:获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。 - 参数:{} - 返回类型:string(自然语言文本) - 返回示例:规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)... - 3. get_task_info:查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。 - 参数:task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节 - 4. min_context_switch:在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。 - 参数:task_id(可选,int);task_ids(必填,array) - 返回类型:string(自然语言文本) - 返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。 - 5. move:将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。 - 参数:new_day(必填,int);new_slot_start(必填,int);task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。 - 6. place:将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。 - 参数:day(必填,int);slot_start(必填,int);task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 预排到第5天第3-4节。 - 7. query_available_slots:查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。 - 参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);duration(可选,int);exclude_sections(可选,array);limit(可选,int);section_from(可选,int);section_to(可选,int);slot_type(可选,string);slot_types(可选,array);span(可选,int);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]} - 8. query_range:查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。 - 参数:day(必填,int);slot_end(可选,int);slot_start(可选,int) - 返回类型:string(自然语言文本) - 返回示例:第5天第3-6节:第3节空、第4节空... - 9. query_target_tasks:查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。 - 参数:category(可选,string);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);enqueue(可选,bool);limit(可选,int);reset_queue(可选,bool);status(可选,string:all/existing/suggested/pending);task_id(可选,int);task_ids(可选,array);task_item_id(可选,int);task_item_ids(可选,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]} - 10. queue_apply_head_move:将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。 - 参数:new_day(必填,int);new_slot_start(必填,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"} - 11. queue_pop_head:弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。 - 参数:{} - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}} - 12. queue_skip_head:跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。 - 参数:reason(可选,string) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1} - 13. queue_status:查看当前待处理队列状态(pending/current/completed/skipped)。 - 参数:{} - 返回类型:string(JSON字符串) - 返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1} - 14. quick_note_create:记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间(如“明天下午3点”、“下周一”)。title 必填。记录成功后,回复时应包含一句与任务内容相关的轻松跟进话术(不超过30字),类似朋友间的友好调侃。 - 参数:deadline_at(可选,string);priority_group(可选,int);title(必填,string) - 返回类型:string(自然语言文本) - 返回示例:自然语言结果(成功/失败原因/关键数据摘要)。 - 15. spread_even:在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。 - 参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);exclude_sections(可选,array);limit(可选,int);slot_type(可选,string);slot_types(可选,array);task_id(可选,int);task_ids(必填,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int) - 返回类型:string(自然语言文本) - 返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。 - 16. swap:交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。 - 参数:task_a(必填,int);task_b(必填,int) - 返回类型:string(自然语言文本) - 返回示例:交换完成:[35]... ↔ [36]... - 17. unplace:将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。 - 参数:task_id(必填,int) - 返回类型:string(自然语言文本) - 返回示例:已将 [35]... 移除,恢复为待安排状态。 - 18. web_fetch:抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。 - 参数:max_chars(可选,int);url(必填,string) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false} - 19. web_search:Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。 - 参数:domain_allow(可选,array);query(必填,string);recency_days(可选,int);top_k(可选,int) - 返回类型:string(JSON字符串) - 返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]} - ------ message[1] ----- -role: assistant -content: - 历史上下文: - 对话历史: - user: "提醒我明天中午吃乡村基" - assistant: "好的,帮你记下明天中午吃乡村基的提醒。" - - 阶段锚点:按当前工具事实推进,不做无依据操作。 - ------ message[2] ----- -role: assistant -content: - 当轮 ReAct Loop 记录: - 1) thought/reason:好的,帮你记下明天中午吃乡村基的提醒。 - tool_call:quick_note_create({"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"}) - observation:{"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。"} - ------ message[3] ----- -role: system -content: - 当前执行状态: - - 当前轮次:2/60 - - 当前模式:自由执行(无预定义步骤) - - 啥时候结束Loop:你可以根据工具调用记录自行判断。 - - 非目标:不重新粗排、不修改无关任务类。 - - 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。 - - 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。 - 相关记忆(仅在确有帮助时参考,不要机械复述): - 以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。 - - [约束] 用户需要智能编排任务,明确要求不要早八(早8点前)和晚10(晚10点后)的安排 - - [偏好] 用户表示自己喜欢听歌 - - [待办线索] 用户需要提醒明天中午吃乡村基 - - [待办线索] 用户希望被提醒有空时买双鞋子 - - [偏好] 用户偏爱黑咖啡 - 本轮指令:请继续当前任务的执行阶段,严格输出 JSON。 - - -[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2 -2026/04/18 10:04:18 outbox due messages=1, start dispatch -2026/04/18 10:04:19 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a -2026/04/18 10:04:19 [DEBUG][去重] LLM 比对结果: candidate_id=29 score=0.0000 relation=unrelated reason="新事实是关于明天中午吃饭的提醒,旧记忆是关于买鞋子的提醒,两者属于不同待办事项" candidate_content="用户希望被提醒有空时买双鞋子" -2026/04/18 10:04:19 [DEBUG][去重] 汇总决策: job_id=65 action=NONE target_id=0 reason="存在完全重复的旧记忆,跳过写入" -2026/04/18 10:04:19 memory level=info component=write operation=decision candidate_count=2 conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a fact_type=todo_hint fallback_mode=rag_to_mysql final_action=NONE job_id=65 success=true user_id=1 -2026/04/18 10:04:19 [去重] 决策流程完成: job_id=65 user_id=1 新增=0 更新=0 删除=0 跳过=1 -2026/04/18 10:04:19 memory level=info component=write operation=job conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a job_id=65 status=success success=true user_id=1 -[GIN] 2026/04/18 - 10:04:25 | 200 | 19.2626896s | 127.0.0.1 | POST "/api/v1/agent/chat" -2026/04/18 10:04:25 [ERROR] newAgent graph 执行失败 trace=a35fc40f-261f-4d51-93cd-783f256b9902 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a: [NodeRunError] 执行阶段模型调用失败: failed to create chat completion: context canceled ------------------------- -node path: [execute] -2026/04/18 10:04:25 错误通道已满,丢弃错误: context canceled -[GIN] 2026/04/18 - 10:04:25 | 200 | 50.3305ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active" -[GIN] 2026/04/18 - 10:04:26 | 200 | 49.4404ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a" -[GIN] 2026/04/18 - 10:04:26 | 200 | 1.9621ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a" -[GIN] 2026/04/18 - 10:04:26 | 200 | 28.7049ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a" diff --git a/backend/newAgent/conv/schedule_provider.go b/backend/newAgent/conv/schedule_provider.go index 2fe4927..5be5c57 100644 --- a/backend/newAgent/conv/schedule_provider.go +++ b/backend/newAgent/conv/schedule_provider.go @@ -46,7 +46,8 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (* return nil, err } - return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) + // 2. 全量读场景保留“当前周兜底”,兼容“只看本周课表/微调”类请求。 + return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true) } // LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。 @@ -69,7 +70,9 @@ func (p *ScheduleProvider) LoadScheduleStateForTaskClasses( return nil, err } - return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) + // 1. 粗排/主动编排场景必须严格按任务类时间窗加载; + // 2. 若任务类缺少起止日期,则返回错误,交给上层 ask_user 补齐,而不是静默退回当前周。 + return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false) } // loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。 @@ -82,10 +85,14 @@ func (p *ScheduleProvider) loadScheduleStateWithTaskClasses( ctx context.Context, userID int, taskClasses []model.TaskClass, + allowCurrentWeekFallback bool, ) (*schedule.ScheduleState, error) { // 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。 windowDays, weeks := buildWindowFromTaskClasses(taskClasses) if len(windowDays) == 0 { + if !allowCurrentWeekFallback { + return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排") + } var err error windowDays, weeks, err = buildCurrentWeekWindow() if err != nil { @@ -262,12 +269,24 @@ func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, t if tc.ExcludedSlots != nil { meta.ExcludedSlots = []int(tc.ExcludedSlots) } + if tc.ExcludedDaysOfWeek != nil { + meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek) + } if tc.StartDate != nil { meta.StartDate = tc.StartDate.Format("2006-01-02") } if tc.EndDate != nil { meta.EndDate = tc.EndDate.Format("2006-01-02") } + if tc.SubjectType != nil { + meta.SubjectType = *tc.SubjectType + } + if tc.DifficultyLevel != nil { + meta.DifficultyLevel = *tc.DifficultyLevel + } + if tc.CognitiveIntensity != nil { + meta.CognitiveIntensity = *tc.CognitiveIntensity + } metas = append(metas, meta) } return metas, nil diff --git a/backend/newAgent/conv/schedule_state.go b/backend/newAgent/conv/schedule_state.go index 6d0b80a..b419b45 100644 --- a/backend/newAgent/conv/schedule_state.go +++ b/backend/newAgent/conv/schedule_state.go @@ -49,6 +49,7 @@ func LoadScheduleState( // 2.1 先放 extraItemCategories(低优先级,兜底); // 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。 itemCategoryLookup := make(map[int]string) + itemOrderLookup := buildTaskItemOrderLookup(taskClasses) for id, name := range extraItemCategories { itemCategoryLookup[id] = name } @@ -222,6 +223,7 @@ func LoadScheduleState( Slots: hostSlots, CategoryID: tc.ID, TaskClassID: tc.ID, + TaskOrder: itemOrderLookup[item.ID], }) itemStateIDs[item.ID] = stateID nextStateID++ @@ -240,6 +242,7 @@ func LoadScheduleState( Slots: slots, CategoryID: tc.ID, TaskClassID: tc.ID, + TaskOrder: itemOrderLookup[item.ID], }) itemStateIDs[item.ID] = stateID nextStateID++ @@ -261,6 +264,7 @@ func LoadScheduleState( Duration: defaultDuration, CategoryID: tc.ID, TaskClassID: tc.ID, + TaskOrder: itemOrderLookup[item.ID], }) itemStateIDs[item.ID] = stateID nextStateID++ @@ -285,12 +289,24 @@ func LoadScheduleState( if tc.ExcludedSlots != nil { meta.ExcludedSlots = []int(tc.ExcludedSlots) } + if tc.ExcludedDaysOfWeek != nil { + meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek) + } if tc.StartDate != nil { meta.StartDate = tc.StartDate.Format("2006-01-02") } if tc.EndDate != nil { meta.EndDate = tc.EndDate.Format("2006-01-02") } + if tc.SubjectType != nil { + meta.SubjectType = *tc.SubjectType + } + if tc.DifficultyLevel != nil { + meta.DifficultyLevel = *tc.DifficultyLevel + } + if tc.CognitiveIntensity != nil { + meta.CognitiveIntensity = *tc.CognitiveIntensity + } state.TaskClasses = append(state.TaskClasses, meta) } } @@ -343,6 +359,7 @@ func LoadScheduleState( Slots: hostSlots, CategoryID: categoryID, TaskClassID: taskClassID, + TaskOrder: itemOrderLookup[itemID], }) itemStateIDs[itemID] = guestStateID nextStateID++ @@ -385,6 +402,26 @@ func isTaskItemPending(item model.TaskClassItem) bool { return *item.Status == model.TaskItemStatusUnscheduled } +// buildTaskItemOrderLookup 为每个 task_item 构建稳定顺序号。 +// +// 职责边界: +// 1. 优先使用数据库里的 item.Order,保持用户或上游生成的显式顺序; +// 2. 若历史数据缺少 order,则退回 TaskClass.Items 当前顺序,保证写工具层仍有稳定边界; +// 3. 只负责构建运行态映射,不回写数据库。 +func buildTaskItemOrderLookup(taskClasses []model.TaskClass) map[int]int { + lookup := make(map[int]int) + for _, tc := range taskClasses { + for idx, item := range tc.Items { + order := idx + 1 + if item.Order != nil && *item.Order > 0 { + order = *item.Order + } + lookup[item.ID] = order + } + } + return lookup +} + // estimateTaskItemDuration 估算 pending 任务默认时长。 // // 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1); diff --git a/backend/newAgent/graph/common_graph.go b/backend/newAgent/graph/common_graph.go index 3b3705f..bf19c69 100644 --- a/backend/newAgent/graph/common_graph.go +++ b/backend/newAgent/graph/common_graph.go @@ -17,7 +17,6 @@ const ( NodeConfirm = "confirm" NodeRoughBuild = "rough_build" NodeExecute = "execute" - NodeOrderGuard = "order_guard" NodeInterrupt = "interrupt" NodeDeliver = "deliver" NodeQuickTask = "quick_task" @@ -53,9 +52,6 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil { return nil, err } - if err := g.AddLambdaNode(NodeOrderGuard, compose.InvokableLambda(nodes.OrderGuard)); err != nil { - return nil, err - } if err := g.AddLambdaNode(NodeQuickTask, compose.InvokableLambda(nodes.QuickTask)); err != nil { return nil, err } @@ -115,38 +111,31 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // RoughBuild -> Execute / OrderGuard / Deliver: + // RoughBuild -> Execute / Deliver: // 1. 正常粗排完成后进入 execute 微调; - // 2. 若粗排阶段 completed 且默认保持顺序,先走 order_guard 再交付; - // 3. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。 + // 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。 if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch( branchAfterRoughBuild, map[string]bool{ - NodeExecute: true, - NodeOrderGuard: true, - NodeDeliver: true, - NodeInterrupt: true, + NodeExecute: true, + NodeDeliver: true, + NodeInterrupt: true, }, )); err != nil { return nil, err } - // Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / OrderGuard(顺序守卫) / Deliver(完成) / Interrupt(需要追问用户) + // Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户) if err := g.AddBranch(NodeExecute, compose.NewGraphBranch( branchAfterExecute, map[string]bool{ - NodeExecute: true, - NodeConfirm: true, - NodeOrderGuard: true, - NodeDeliver: true, - NodeInterrupt: true, + NodeExecute: true, + NodeConfirm: true, + NodeDeliver: true, + NodeInterrupt: true, }, )); err != nil { return nil, err } - // OrderGuard -> Deliver:顺序守卫只做校验,最终都由 Deliver 统一收口。 - if err := g.AddEdge(NodeOrderGuard, NodeDeliver); err != nil { - return nil, err - } // Interrupt -> END:当前连接必须在这里收口,等待用户输入或确认回调恢复。 if err := g.AddEdge(NodeInterrupt, compose.END); err != nil { return nil, err @@ -279,9 +268,6 @@ func branchAfterRoughBuild(_ context.Context, st *newagentmodel.AgentGraphState) return NodeExecute, nil } if flowState.Phase == newagentmodel.PhaseDone { - if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder { - return NodeOrderGuard, nil - } return NodeDeliver, nil } return NodeExecute, nil @@ -309,9 +295,6 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s // 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入, // 导致 deliver 收口和后续预览落盘语义不一致。 if flowState.Phase == newagentmodel.PhaseDone { - if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder && flowState.HasScheduleWriteOps { - return NodeOrderGuard, nil - } return NodeDeliver, nil } return NodeExecute, nil diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index 3099ef2..0432f0a 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -71,6 +71,24 @@ type CommonState struct { TraceID string `json:"trace_id"` UserID int `json:"user_id"` ConversationID string `json:"conversation_id"` + // ActiveToolDomain 记录当前 msg0 动态区激活的业务工具域。 + // 说明: + // 1. 空字符串表示仅保留 context 管理工具,不注入业务工具定义; + // 2. 非空时仅允许注入对应域的工具(如 schedule/taskclass); + // 3. 该字段由 context_tools_add/remove 工具结果驱动更新。 + ActiveToolDomain string `json:"active_tool_domain,omitempty"` + // ActiveToolPacks 记录当前激活域下的可选二级包(不含 core 固定包)。 + // 说明: + // 1. 仅对 schedule 域生效(queue/mutation/analyze/web); + // 2. 为空时按域默认策略解释(schedule 兼容为“全可选包”); + // 3. 该字段与 ActiveToolDomain 一起由 context_tools_add/remove 结果更新。 + ActiveToolPacks []string `json:"active_tool_packs,omitempty"` + // PendingContextHook 保存 plan 阶段给 execute 阶段的一次性注入建议。 + // 说明: + // 1. 可由 plan_done 或 rough_build->execute 分支写入; + // 2. execute 首轮消费一次后清空; + // 3. 该字段只表达建议,不直接触发工具调用。 + PendingContextHook *ContextHook `json:"pending_context_hook,omitempty"` // 流程阶段 Phase Phase `json:"phase"` @@ -106,12 +124,70 @@ type CommonState struct { NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"` // AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。 // 默认 false,只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。 - AllowReorder bool `json:"allow_reorder,omitempty"` - // SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。 - // OrderGuard 节点会基于该基线判断微调是否破坏顺序约束。 - SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"` + AllowReorder bool `json:"allow_reorder,omitempty"` + OptimizationMode string `json:"optimization_mode,omitempty"` + // ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”。 + // 1. true 时,execute 只向 LLM 暴露 analyze_health + move + swap 这组最小闭环工具; + // 2. 该开关只用于首次粗排后的自动微调,不影响用户后续明确提出的日程调整请求; + // 3. 流程收口、重开新请求或切换业务域后,必须重置为 false。 + ActiveOptimizeOnly bool `json:"active_optimize_only,omitempty"` + HealthCheckDone bool `json:"health_check_done,omitempty"` + HealthIsFeasible bool `json:"health_is_feasible,omitempty"` + HealthCapacityGap int `json:"health_capacity_gap,omitempty"` + HealthReasonCode string `json:"health_reason_code,omitempty"` + // HealthShouldContinueOptimize 记录最近一次 analyze_health 是否认为“还值得继续优化”。 + // 调用目的: + // 1. 让 execute prompt 直接读取后端诊断结论,而不是只根据 issues 猜下一步; + // 2. 该字段只表达“是否值得继续动”,不替 LLM 决定具体写参数; + // 3. 默认 false,只有 analyze_health 明确判定后才会更新。 + HealthShouldContinueOptimize bool `json:"health_should_continue_optimize,omitempty"` + // HealthTightnessLevel 记录最近一次诊断得到的优化空间等级:loose / tight / locked。 + // 调用目的: + // 1. 用于提示 LLM 区分“还能优化”和“已经是被迫不完美”; + // 2. 该字段只服务主动优化链路,不参与粗排可行性判断; + // 3. 空字符串表示尚未拿到有效诊断。 + HealthTightnessLevel string `json:"health_tightness_level,omitempty"` + // HealthPrimaryProblem 保存最近一次诊断的主要局部问题摘要。 + // 调用目的: + // 1. 帮助 execute 聚焦当前最值得处理的那个点,避免全局乱搜; + // 2. 只保存短摘要,不保存完整工具原文,避免状态膨胀; + // 3. 为空表示当前没有明确主问题或诊断失败。 + HealthPrimaryProblem string `json:"health_primary_problem,omitempty"` + // HealthRecommendedOperation 保存最近一次诊断建议优先考虑的动作类型。 + // 允许值由 analyze_health 控制,当前主要为 swap / move / close / ask_user。 + HealthRecommendedOperation string `json:"health_recommended_operation,omitempty"` + // HealthIsForcedImperfection 标记当前剩余问题是否更像“约束代价”而非“仍值得修”的问题。 + // 调用目的: + // 1. 给 LLM 一个明确的收口信号; + // 2. 仅在 analyze_health 返回结构化 decision 时更新; + // 3. false 不代表一定要继续优化,只代表“不是明确的被迫不完美”。 + HealthIsForcedImperfection bool `json:"health_is_forced_imperfection,omitempty"` + // HealthImprovementSignal 保存最近一次诊断的紧凑对比信号,用于判断是否连续停滞。 + // 调用目的: + // 1. execute 可基于该字段识别“连续两轮几乎没改善”; + // 2. 信号由 analyze_health 生成,格式稳定但不面向用户展示; + // 3. 若诊断失败则保持空字符串。 + HealthImprovementSignal string `json:"health_improvement_signal,omitempty"` + // HealthStagnationCount 记录连续多少次 analyze_health 给出了相同的 improvement_signal。 + // 调用目的: + // 1. 让 prompt 可以在“继续磨也没明显改善”时提醒 LLM 主动收口; + // 2. 仅在两次连续有效诊断的信号完全相同时递增; + // 3. 只做软提醒,不做后端硬拦截。 + HealthStagnationCount int `json:"health_stagnation_count,omitempty"` + // TaskClassUpsertLastTried 标记本轮是否至少调用过一次 upsert_task_class。 + // 调用目的:execute_context 仅在该标记为 true 时注入“最近一次任务类写入结果”,避免噪音。 + TaskClassUpsertLastTried bool `json:"task_class_upsert_last_tried,omitempty"` + // TaskClassUpsertLastSuccess 记录最近一次 upsert_task_class 是否成功。 + // 调用目的:为 prompt 提供“是否需要继续追问补字段”的明确信号。 + TaskClassUpsertLastSuccess bool `json:"task_class_upsert_last_success,omitempty"` + // TaskClassUpsertLastIssues 记录最近一次写入返回的校验问题(validation.issues)。 + // 调用目的:让 LLM 直接按缺失字段追问,减少泛化提问。 + TaskClassUpsertLastIssues []string `json:"task_class_upsert_last_issues,omitempty"` + // TaskClassUpsertConsecutiveFailures 记录连续写入失败次数。 + // 调用目的:给 prompt 注入“避免空转”的软提示,不做硬拦截。 + TaskClassUpsertConsecutiveFailures int `json:"task_class_upsert_consecutive_failures,omitempty"` // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 - // 调用目的:graph 分支函数据此判断是否需要走 order_guard,非日程操作跳过守卫。 + // 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。 HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` // UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。 // 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。 @@ -164,8 +240,12 @@ func (s *CommonState) FinishPlan(steps []PlanStep) { s.PlanSteps = steps s.CurrentStep = 0 s.Phase = PhaseWaitingConfirm + s.ActiveToolDomain = "" + s.ActiveToolPacks = nil + s.PendingContextHook = nil s.NeedsRefineAfterRoughBuild = false - s.SuggestedOrderBaseline = nil + s.ActiveOptimizeOnly = false + s.resetTaskClassUpsertSnapshot() s.ClearTerminalOutcome() } @@ -173,7 +253,8 @@ func (s *CommonState) FinishPlan(steps []PlanStep) { func (s *CommonState) ConfirmPlan() { s.Phase = PhaseExecuting s.NeedsRefineAfterRoughBuild = false - s.SuggestedOrderBaseline = nil + s.ActiveOptimizeOnly = false + s.resetTaskClassUpsertSnapshot() s.ClearTerminalOutcome() } @@ -185,9 +266,13 @@ func (s *CommonState) StartDirectExecute() { s.PlanSteps = nil s.CurrentStep = 0 s.Phase = PhaseExecuting + s.ActiveToolDomain = "" + s.ActiveToolPacks = nil + s.PendingContextHook = nil s.NeedsRoughBuild = false s.NeedsRefineAfterRoughBuild = false - s.SuggestedOrderBaseline = nil + s.ActiveOptimizeOnly = false + s.resetTaskClassUpsertSnapshot() s.ClearTerminalOutcome() } @@ -196,8 +281,12 @@ func (s *CommonState) RejectPlan() { s.PlanSteps = nil s.CurrentStep = 0 s.Phase = PhasePlanning + s.ActiveToolDomain = "" + s.ActiveToolPacks = nil + s.PendingContextHook = nil s.NeedsRefineAfterRoughBuild = false - s.SuggestedOrderBaseline = nil + s.ActiveOptimizeOnly = false + s.resetTaskClassUpsertSnapshot() s.ClearTerminalOutcome() } @@ -223,18 +312,50 @@ func (s *CommonState) ResetForNextRun() { // 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。 s.PlanSteps = nil s.CurrentStep = 0 + s.ActiveToolDomain = "" + s.ActiveToolPacks = nil + s.PendingContextHook = nil s.NeedsRoughBuild = false s.NeedsRefineAfterRoughBuild = false + s.ActiveOptimizeOnly = false // 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。 s.AllowReorder = false + s.OptimizationMode = "" + s.HealthCheckDone = false + s.HealthIsFeasible = true + s.HealthCapacityGap = 0 + s.HealthReasonCode = "" + s.HealthShouldContinueOptimize = false + s.HealthTightnessLevel = "" + s.HealthPrimaryProblem = "" + s.HealthRecommendedOperation = "" + s.HealthIsForcedImperfection = false + s.HealthImprovementSignal = "" + s.HealthStagnationCount = 0 s.HasScheduleWriteOps = false s.HasScheduleChanges = false s.UsedQuickNote = false - s.SuggestedOrderBaseline = nil + s.resetTaskClassUpsertSnapshot() s.ClearTerminalOutcome() } +// resetTaskClassUpsertSnapshot 清理“任务类写入回盘”运行态。 +// +// 职责边界: +// 1. 仅清理 upsert_task_class 相关的临时回盘字段; +// 2. 不影响 Health/Plan/Phase 等其他执行状态; +// 3. 作为新一轮入口统一调用,避免旧失败信息污染本轮追问。 +func (s *CommonState) resetTaskClassUpsertSnapshot() { + if s == nil { + return + } + s.TaskClassUpsertLastTried = false + s.TaskClassUpsertLastSuccess = false + s.TaskClassUpsertLastIssues = nil + s.TaskClassUpsertConsecutiveFailures = 0 +} + // AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。 func (s *CommonState) AdvanceStep() bool { s.CurrentStep++ @@ -248,6 +369,12 @@ func (s *CommonState) AdvanceStep() bool { // 2. 只有在尚未写入任何终止结果时,才默认补成 completed。 func (s *CommonState) Done() { s.Phase = PhaseDone + // 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。 + // 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。 + s.ActiveToolDomain = "" + s.ActiveToolPacks = nil + s.PendingContextHook = nil + s.ActiveOptimizeOnly = false if s.TerminalOutcome != nil { s.TerminalOutcome.Normalize() return diff --git a/backend/newAgent/model/execute_contract.go b/backend/newAgent/model/execute_contract.go index 6af8066..7363382 100644 --- a/backend/newAgent/model/execute_contract.go +++ b/backend/newAgent/model/execute_contract.go @@ -3,6 +3,7 @@ package model import ( "encoding/json" "fmt" + "sort" "strings" ) @@ -61,7 +62,7 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error { Speak string `json:"speak,omitempty"` Action ExecuteAction `json:"action"` Reason string `json:"reason,omitempty"` - GoalCheck string `json:"goal_check,omitempty"` + GoalCheck json.RawMessage `json:"goal_check,omitempty"` ToolCall json.RawMessage `json:"tool_call,omitempty"` Abort json.RawMessage `json:"abort,omitempty"` } @@ -74,7 +75,11 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error { d.Speak = raw.Speak d.Action = raw.Action d.Reason = raw.Reason - d.GoalCheck = raw.GoalCheck + goalCheck, err := decodeGoalCheckText(raw.GoalCheck) + if err != nil { + return fmt.Errorf("goal_check 解析失败: %w", err) + } + d.GoalCheck = goalCheck toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall) if err != nil { @@ -91,6 +96,124 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error { return nil } +// decodeGoalCheckText 兼容 goal_check 的字符串/对象写法,统一降级为字符串。 +// +// 步骤化说明: +// 1. 字符串:直接使用,保持主协议不变; +// 2. 对象:按 done_when/evidence 提取并拼接为单行证据文本; +// 3. 数组或其他标量:尽量转成可读字符串,避免仅因格式漂移导致整轮失败。 +func decodeGoalCheckText(raw json.RawMessage) (string, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return "", nil + } + + // 1. 标准写法:goal_check 为字符串。 + if strings.HasPrefix(trimmed, "\"") { + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return "", err + } + return strings.TrimSpace(text), nil + } + + // 2. 兼容写法:goal_check 被模型写成对象。 + if strings.HasPrefix(trimmed, "{") { + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return "", err + } + return compactGoalCheckObject(obj), nil + } + + // 3. 兜底:数组/标量场景,尽量保留可读信息。 + var generic any + if err := json.Unmarshal(raw, &generic); err != nil { + return "", err + } + return strings.TrimSpace(formatGoalCheckValue(generic)), nil +} + +// compactGoalCheckObject 将对象型 goal_check 压缩为可读单行文本,优先提取 done_when/evidence。 +func compactGoalCheckObject(obj map[string]any) string { + if len(obj) == 0 { + return "" + } + + doneWhen := strings.TrimSpace(formatGoalCheckValue(obj["done_when"])) + evidence := strings.TrimSpace(formatGoalCheckValue(obj["evidence"])) + + parts := make([]string, 0, 2) + if doneWhen != "" { + parts = append(parts, "已满足 done_when:"+doneWhen) + } + if evidence != "" { + parts = append(parts, "证据:"+evidence) + } + if len(parts) > 0 { + return strings.Join(parts, ";") + } + + // done_when/evidence 缺失时,按 key 排序拼接,保证日志稳定可读。 + keys := make([]string, 0, len(obj)) + for key := range obj { + keys = append(keys, key) + } + sort.Strings(keys) + + fallback := make([]string, 0, len(keys)) + for _, key := range keys { + text := strings.TrimSpace(formatGoalCheckValue(obj[key])) + if text == "" { + continue + } + fallback = append(fallback, key+"="+text) + } + return strings.Join(fallback, ";") +} + +// formatGoalCheckValue 将任意值转成单行可读文本,用于 goal_check 压缩拼接。 +func formatGoalCheckValue(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case bool: + if typed { + return "true" + } + return "false" + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + text := strings.TrimSpace(formatGoalCheckValue(item)) + if text == "" { + continue + } + parts = append(parts, text) + } + return strings.Join(parts, ",") + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + text := strings.TrimSpace(formatGoalCheckValue(typed[key])) + if text == "" { + continue + } + parts = append(parts, key+"="+text) + } + return strings.Join(parts, ",") + default: + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } +} + // Normalize 统一清洗 execute 决策中的字符串字段。 func (d *ExecuteDecision) Normalize() { if d == nil { diff --git a/backend/newAgent/model/pending_interaction.go b/backend/newAgent/model/pending_interaction.go index 602d428..7a08086 100644 --- a/backend/newAgent/model/pending_interaction.go +++ b/backend/newAgent/model/pending_interaction.go @@ -12,6 +12,15 @@ const ( const PendingInteractionSnapshotVersion = 1 +const ( + // PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。 + // interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。 + PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed" + // PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。 + // interrupt 节点据此避免二次追加历史,防止上下文重复。 + PendingMetaAskUserHistoryAppended = "ask_user_history_appended" +) + // PendingInteractionType 表示当前挂起交互的类型。 type PendingInteractionType string @@ -179,6 +188,26 @@ func (s *AgentRuntimeState) ClearPendingInteraction() { s.PendingInteraction = nil } +// SetPendingInteractionMetadata 为当前 open 状态的 pending interaction 写入元信息。 +// +// 职责边界: +// 1. 仅对当前挂起交互打运行态标记,不参与业务语义判断; +// 2. 若当前没有 pending interaction,则静默跳过; +// 3. metadata 仅用于节点间协作(如避免 ask_user 重复推送)。 +func (s *AgentRuntimeState) SetPendingInteractionMetadata(key string, value any) { + if s == nil || s.PendingInteraction == nil || s.PendingInteraction.Status != PendingInteractionStatusOpen { + return + } + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return + } + if s.PendingInteraction.Metadata == nil { + s.PendingInteraction.Metadata = make(map[string]any) + } + s.PendingInteraction.Metadata[trimmedKey] = value +} + func (s *AgentRuntimeState) openPendingInteraction( interactionType PendingInteractionType, interactionID string, diff --git a/backend/newAgent/model/plan_contract.go b/backend/newAgent/model/plan_contract.go index b04e4d1..30866f9 100644 --- a/backend/newAgent/model/plan_contract.go +++ b/backend/newAgent/model/plan_contract.go @@ -55,6 +55,19 @@ type PlanDecision struct { PlanSteps []PlanStep `json:"plan_steps,omitempty"` NeedsRoughBuild bool `json:"needs_rough_build,omitempty"` TaskClassIDs []int `json:"task_class_ids,omitempty"` + ContextHook *ContextHook `json:"context_hook,omitempty"` +} + +// ContextHook 表示 plan 阶段给 execute 阶段的上下文注入建议。 +// +// 职责边界: +// 1. 仅承载“建议激活哪个 domain/packs”,不负责真正执行 context_tools_add/remove; +// 2. domain 仅允许 schedule/taskclass,packs 仅允许 schedule 的可选包; +// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。 +type ContextHook struct { + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + Reason string `json:"reason,omitempty"` } // Normalize 统一清洗规划决策中的字符串字段。 @@ -69,6 +82,9 @@ func (d *PlanDecision) Normalize() { for i := range d.PlanSteps { d.PlanSteps[i].Normalize() } + if d.ContextHook != nil { + d.ContextHook.Normalize() + } } // Validate 校验规划决策的最小合法性。 @@ -102,6 +118,9 @@ func (d *PlanDecision) Validate() error { if len(d.PlanSteps) > 0 { return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action) } + if d.ContextHook != nil { + return fmt.Errorf("%s 动作不应携带 context_hook", d.Action) + } return nil case PlanActionDone: if len(d.PlanSteps) == 0 { @@ -112,6 +131,11 @@ func (d *PlanDecision) Validate() error { return fmt.Errorf("plan_steps[%d] 非法: %w", i, err) } } + if d.ContextHook != nil { + if err := d.ContextHook.Validate(); err != nil { + return err + } + } return nil default: return fmt.Errorf("未知 plan action: %s", d.Action) @@ -149,3 +173,73 @@ func (s *PlanStep) Validate() error { } return nil } + +// Normalize 统一清洗 context hook 字段。 +func (h *ContextHook) Normalize() { + if h == nil { + return + } + h.Domain = normalizeContextHookDomain(h.Domain) + h.Reason = strings.TrimSpace(h.Reason) + h.Packs = normalizeContextHookPacks(h.Domain, h.Packs) +} + +// Validate 校验 context hook 最小合法性。 +func (h *ContextHook) Validate() error { + if h == nil { + return nil + } + h.Normalize() + if h.Domain == "" { + return fmt.Errorf("context_hook.domain 非法,仅支持 schedule/taskclass") + } + if h.Domain == "taskclass" && len(h.Packs) > 0 { + return fmt.Errorf("context_hook.taskclass 暂不支持 packs") + } + return nil +} + +func normalizeContextHookDomain(domain string) string { + switch strings.ToLower(strings.TrimSpace(domain)) { + case "schedule": + return "schedule" + case "taskclass": + return "taskclass" + default: + return "" + } +} + +func normalizeContextHookPacks(domain string, packs []string) []string { + if domain != "schedule" || len(packs) == 0 { + return nil + } + allowed := map[string]struct{}{ + "queue": {}, + "mutation": {}, + "analyze": {}, + "detail_read": {}, + "deep_analyze": {}, + "web": {}, + } + seen := make(map[string]struct{}, len(packs)) + result := make([]string, 0, len(packs)) + for _, raw := range packs { + pack := strings.ToLower(strings.TrimSpace(raw)) + if pack == "" || pack == "core" { + continue + } + if _, ok := allowed[pack]; !ok { + continue + } + if _, exists := seen[pack]; exists { + continue + } + seen[pack] = struct{}{} + result = append(result, pack) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index 97889f4..7929c5c 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -9,6 +9,7 @@ import ( "time" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" ) @@ -146,7 +147,15 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt // 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。 if st.Deps.ToolRegistry != nil { - schemas := st.Deps.ToolRegistry.Schemas() + activeDomain := "" + var activePacks []string + if flowState := st.EnsureFlowState(); flowState != nil { + activeDomain, activePacks = resolveEffectiveExecuteToolDomain(flowState) + } + schemas := st.Deps.ToolRegistry.SchemasForActiveDomain(activeDomain, activePacks) + if flowState := st.EnsureFlowState(); flowState != nil && flowState.ActiveOptimizeOnly { + schemas = newagenttools.FilterSchemasForActiveOptimize(schemas) + } toolSchemas := make([]newagentmodel.ToolSchemaContext, len(schemas)) for i, s := range schemas { toolSchemas[i] = newagentmodel.ToolSchemaContext{ @@ -184,20 +193,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt return st, nil } -// OrderGuard 负责把 graph 的 order_guard 节点请求转给 RunOrderGuardNode。 -func (n *AgentNodes) OrderGuard(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { - if st == nil { - return nil, errors.New("order_guard node: state is nil") - } - - if err := RunOrderGuardNode(ctx, st); err != nil { - return nil, err - } - - saveAgentState(ctx, st) - return st, nil -} - // QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。 func (n *AgentNodes) QuickTask(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { if st == nil { @@ -337,3 +332,31 @@ func deleteAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) { _ = store.Delete(ctx, flowState.ConversationID) } + +// resolveEffectiveExecuteToolDomain 计算“本轮 execute 真正应看到”的工具域快照。 +// +// 职责边界: +// 1. 优先读取 PendingContextHook,让首轮 execute 的 schema 注入与即将生效的规则包保持一致; +// 2. 只做只读推导,不消费 PendingContextHook,真正的状态更新仍由 RunExecuteNode 统一处理; +// 3. hook 非法或为空时,回退到已持久化的 ActiveToolDomain/ActiveToolPacks,保持历史链路兼容。 +func resolveEffectiveExecuteToolDomain(flowState *newagentmodel.CommonState) (string, []string) { + if flowState == nil { + return "", nil + } + + // 1. 若 plan / rough_build 已写入待生效 hook,则首轮 execute 必须优先按它推导工具域, + // 否则 prompt 里的规则包和注入的工具 schema 会错位,模型第一轮看不到该用的工具。 + if hook := flowState.PendingContextHook; hook != nil { + domain := newagenttools.NormalizeToolDomain(hook.Domain) + if domain != "" { + return domain, newagenttools.ResolveEffectiveToolPacks(domain, hook.Packs) + } + } + + // 2. hook 不可用时回退到当前已激活域,保持老链路与恢复链路的行为不变。 + domain := newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain) + if domain == "" { + return "", nil + } + return domain, newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks) +} diff --git a/backend/newAgent/node/chat.go b/backend/newAgent/node/chat.go index 60b6e7f..24876bc 100644 --- a/backend/newAgent/node/chat.go +++ b/backend/newAgent/node/chat.go @@ -214,6 +214,10 @@ func streamAndDispatch( decision.NeedsRoughBuild = false decision.NeedsRefineAfterRoughBuild = false } + // 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。 + if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) { + decision.NeedsRefineAfterRoughBuild = true + } log.Printf( "[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v thinking=%v has_rough_build_done=%v task_class_count=%d raw=%s", @@ -445,6 +449,7 @@ func handleRouteExecuteStream( } flowState.ExecuteThinking = effectiveThinking + flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState) return nil } @@ -510,6 +515,45 @@ func detectReorderPreference(userInput string) reorderPreference { return reorderUnknown } +// resolveOptimizationMode 统一确定当前 execute 的优化模式。 +func resolveOptimizationMode( + userInput string, + decision *newagentmodel.ChatRoutingDecision, + flowState *newagentmodel.CommonState, +) string { + if decision != nil && decision.NeedsRoughBuild && flowState != nil && len(flowState.TaskClassIDs) > 0 { + return "first_full" + } + if isExplicitGlobalReoptRequest(userInput) { + return "global_reopt" + } + return "local_adjust" +} + +// isExplicitGlobalReoptRequest 识别用户是否明确要求全局重优化。 +func isExplicitGlobalReoptRequest(userInput string) bool { + text := strings.ToLower(strings.TrimSpace(userInput)) + if text == "" { + return false + } + keywords := []string{ + "全局优化", + "整体优化", + "全局重排", + "整体重排", + "重新优化全部", + "重新优化整体", + "全面优化", + "整体体检", + "全局体检", + "重新体检", + "global optimize", + "global reopt", + "overall optimize", + } + return containsAnyPhrase(text, keywords) +} + func containsAnyPhrase(text string, phrases []string) bool { for _, phrase := range phrases { if strings.Contains(text, phrase) { @@ -539,6 +583,27 @@ func shouldDisableRoughBuildForRefine( return !isExplicitRoughBuildRequest(userInput) } +// shouldForceRefineAfterFirstRoughBuild 判断是否应在"首次粗排"场景下强制开启 refine。 +// +// 判定规则: +// 1. 仅在当前决策仍然请求粗排时生效; +// 2. 仅在首次粗排(上下文不存在 rough_build_done)时生效; +// 3. 若用户明确表达"只要初稿/先不优化",则不强制开启; +// 4. 其余首次粗排场景一律开启,确保符合 PRD 的默认主动优化策略。 +func shouldForceRefineAfterFirstRoughBuild( + conversationContext *newagentmodel.ConversationContext, + userInput string, + decision *newagentmodel.ChatRoutingDecision, +) bool { + if decision == nil || !decision.NeedsRoughBuild { + return false + } + if hasRoughBuildDoneMarker(conversationContext) { + return false + } + return !isExplicitNoRefineAfterRoughBuildRequest(userInput) +} + func hasRoughBuildDoneMarker(conversationContext *newagentmodel.ConversationContext) bool { if conversationContext == nil { return false @@ -575,6 +640,31 @@ func isExplicitRoughBuildRequest(userInput string) bool { return containsAnyPhrase(text, keywords) } +// isExplicitNoRefineAfterRoughBuildRequest 识别用户是否明确要求"粗排后先不要自动微调"。 +func isExplicitNoRefineAfterRoughBuildRequest(userInput string) bool { + text := strings.ToLower(strings.TrimSpace(userInput)) + if text == "" { + return false + } + keywords := []string{ + "只要初稿", + "先给初稿", + "先排进去就行", + "先排进去", + "先不优化", + "先别优化", + "先不微调", + "先别微调", + "排完就收口", + "粗排就行", + "草稿就行", + "draft only", + "no refine", + "no optimization", + } + return containsAnyPhrase(text, keywords) +} + // handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。 // // 步骤说明: diff --git a/backend/newAgent/node/correction.go b/backend/newAgent/node/correction.go index c9d0802..b720ee7 100644 --- a/backend/newAgent/node/correction.go +++ b/backend/newAgent/node/correction.go @@ -42,15 +42,10 @@ func AppendLLMCorrection( } // 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。 - // 如果 llmOutput 为空,则生成一个占位描述。 + // 2. 空输出不回灌,避免把占位文本写进历史造成噪音。 + // 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。 assistantContent := strings.TrimSpace(llmOutput) - if assistantContent == "" { - assistantContent = "[LLM 输出为空或无法解析]" - } - conversationContext.AppendHistory(&schema.Message{ - Role: schema.Assistant, - Content: assistantContent, - }) + appendCorrectionAssistantIfNeeded(conversationContext, assistantContent) // 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。 // 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。 @@ -88,13 +83,7 @@ func AppendLLMCorrectionWithHint( } assistantContent := strings.TrimSpace(llmOutput) - if assistantContent == "" { - assistantContent = "[LLM 输出为空或无法解析]" - } - conversationContext.AppendHistory(&schema.Message{ - Role: schema.Assistant, - Content: assistantContent, - }) + appendCorrectionAssistantIfNeeded(conversationContext, assistantContent) correctionContent := fmt.Sprintf( "%s %s 请重新分析当前状态,输出正确的内容。", @@ -109,3 +98,39 @@ func AppendLLMCorrectionWithHint( }, }) } + +// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。 +// +// 1. 空文本直接跳过,避免写入“占位噪音”; +// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌; +// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。 +func appendCorrectionAssistantIfNeeded( + conversationContext *newagentmodel.ConversationContext, + assistantContent string, +) { + if conversationContext == nil { + return + } + assistantContent = strings.TrimSpace(assistantContent) + if assistantContent == "" { + return + } + + history := conversationContext.HistorySnapshot() + for i := len(history) - 1; i >= 0; i-- { + msg := history[i] + if msg == nil || msg.Role != schema.Assistant { + continue + } + if strings.TrimSpace(msg.Content) == assistantContent { + return + } + // 只看最近一条 assistant,避免误去重很久以前的正常重复表达。 + break + } + + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: assistantContent, + }) +} diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index ed41541..38aa9b8 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -28,6 +28,7 @@ const ( executeSpeakBlockID = "execute.speak" executePinnedKey = "execution_context" toolMinContextSwitch = "min_context_switch" + toolAnalyzeHealth = "analyze_health" executeHistoryKindKey = "newagent_history_kind" executeHistoryKindStepAdvanced = "execute_step_advanced" @@ -105,6 +106,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return err } flowState := runtimeState.EnsureCommonState() + applyPendingContextHook(flowState) // 1.5. 确认执行分支:如果用户已确认写操作,直接执行工具。 if runtimeState.PendingConfirmTool != nil { @@ -132,9 +134,6 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if input.ScheduleState != nil && flowState.RoundUsed == 0 { schedule.ResetTaskProcessingQueue(input.ScheduleState) } - if !flowState.AllowReorder && len(flowState.SuggestedOrderBaseline) == 0 { - flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState) - } // 1. 每轮 execute 开始前先刷新一次执行锚点,避免 LLM 继续读取旧的当前步骤。 // 2. 这里仅维护上下文一致性,不改变流程状态。 @@ -201,8 +200,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { messages, infrallm.GenerateOptions{ Temperature: 1.0, - MaxTokens: 16000, - Thinking: resolveThinkingMode(input.ThinkingEnabled), + // 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。 + MaxTokens: 131072, + Thinking: resolveThinkingMode(input.ThinkingEnabled), Metadata: map[string]any{ "stage": executeStageName, "step_index": flowState.CurrentStep, @@ -216,9 +216,13 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { parser := newagentrouter.NewStreamDecisionParser() firstChunk := true + speakStreamed := false + askUserHistoryAppended := false var decision *newagentmodel.ExecuteDecision var fullText strings.Builder rawText := "" + parsedBeforeText := "" + parsedAfterText := "" // 阶段一:解析决策标签。 for { @@ -250,6 +254,8 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { result := parser.Result() rawText = result.RawBuffer + parsedBeforeText = result.BeforeText + parsedAfterText = result.AfterText if result.Fallback || result.ParseFailed { log.Printf("[DEBUG] execute LLM 输出解析失败 chat=%s round=%d raw=%s", @@ -281,9 +287,11 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return fmt.Errorf("连续 %d 次输出非 JSON,终止执行: 原始输出=%s", flowState.ConsecutiveCorrections, rawText) } - AppendLLMCorrectionWithHint(conversationContext, rawText, + // 1. parseErr 场景不回灌原始错误 JSON,避免把错误模板(如 goal_check 对象)再次灌回 msg1; + // 2. 明确补充 goal_check 类型要求,降低模型在 plan 模式下再次输出对象格式的概率。 + AppendLLMCorrectionWithHint(conversationContext, "", "决策标签内的 JSON 格式不合法。", - "请确保 标签内是合法 JSON,然后用标签后输出正文。") + "请确保 标签内是合法 JSON;当 action=next_plan/done 时,goal_check 必须是字符串(不要输出对象)。") return nil } @@ -292,6 +300,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if emitErr := emitter.EmitAssistantText(executeSpeakBlockID, executeStageName, visible, firstChunk); emitErr != nil { return fmt.Errorf("执行文案推送失败: %w", emitErr) } + speakStreamed = true fullText.WriteString(visible) firstChunk = false } @@ -314,6 +323,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if emitErr := emitter.EmitAssistantText(executeSpeakBlockID, executeStageName, chunk2.Content, firstChunk); emitErr != nil { return fmt.Errorf("执行文案推送失败: %w", emitErr) } + speakStreamed = true fullText.WriteString(chunk2.Content) firstChunk = false } @@ -342,13 +352,29 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return fmt.Errorf("执行阶段流结束但未提取到决策标签") } - decision.Speak = fullText.String() + decision.Speak = pickExecuteVisibleSpeak( + fullText.String(), + parsedAfterText, + parsedBeforeText, + decision, + ) // 调试日志:输出解析后的决策,方便排查。 log.Printf("[DEBUG] execute LLM 响应 chat=%s round=%d action=%s speak_len=%d raw_len=%d raw_preview=%.200s", flowState.ConversationID, flowState.RoundUsed, decision.Action, len(decision.Speak), len(rawText), rawText) + // done 收尾兼容:若模型在 done 时顺手带了 context_tools_remove,直接忽略该 tool_call。 + // + // 1. done 语义是“结束本轮”,不应再发起工具调用; + // 2. 动态区清理由系统在 Done() 自动完成,不依赖 LLM 显式 remove; + // 3. 仅对 context_tools_remove 放宽,其他 done+tool_call 仍按非法决策处理。 + if decision.Action == newagentmodel.ExecuteActionDone && + decision.ToolCall != nil && + strings.EqualFold(strings.TrimSpace(decision.ToolCall.Name), newagenttools.ToolNameContextToolsRemove) { + decision.ToolCall = nil + } + if err := decision.Validate(); err != nil { flowState.ConsecutiveCorrections++ log.Printf("[WARN] execute 决策不合法 chat=%s round=%d consecutive=%d/%d err=%s", @@ -358,10 +384,17 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return fmt.Errorf("连续 %d 次决策不合法,终止执行: %s (原始输出: %s)", flowState.ConsecutiveCorrections, err.Error(), rawText) } + _ = emitter.EmitStatus( + executeStatusBlockID, + executeStageName, + "executing", + fmt.Sprintf("执行校验:决策不合法(%s),已请求模型重试。", err.Error()), + false, + ) // 给 LLM 修正机会。 AppendLLMCorrectionWithHint( conversationContext, - rawText, + "", fmt.Sprintf("你的执行决策不合法:%s", err.Error()), "合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。", ) @@ -371,9 +404,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 决策合法,重置连续修正计数。 flowState.ConsecutiveCorrections = 0 - // speak 兜底:continue / ask_user / confirm 三类动作对前端可读文案是强依赖。 - // 若模型漏填 speak,这里回退到 reason 或默认短句,避免前端出现“静默一轮”。 - decision.Speak = buildExecuteSpeakWithFallback(decision) + // speak 兜底: + // 1. 优先使用标签后正文(主协议); + // 2. 若标签后无正文,则回退到标签前前言; + // 3. 前后都没有时,再使用 reason / 默认短句,避免前端出现“静默一轮”。 + decision.Speak = pickExecuteVisibleSpeak( + decision.Speak, + parsedAfterText, + parsedBeforeText, + decision, + ) // speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。 decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n @@ -389,28 +429,56 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { decision.Action = newagentmodel.ExecuteActionContinue } - // 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。 - if decision.Action == newagentmodel.ExecuteActionNextPlan || - decision.Action == newagentmodel.ExecuteActionDone { + // 1. context_tools_add/remove 属于“工具准备步”,不应在历史里保留完整追问文案; + // 2. 若该类动作携带了较长 speak,会在下一轮被 msg1/msg2 双重回灌,导致模型复读; + // 3. 这里统一清空 speak,仅保留工具调用事实,避免“同一句 ask_user 文案”被连续输出两次。 + if decision.Action == newagentmodel.ExecuteActionContinue && + decision.ToolCall != nil && + newagenttools.IsContextManagementTool(decision.ToolCall.Name) { + decision.Speak = "" + } + + // 若模型把自然语言放在标签前,或完全漏掉了标签后正文, + // 这里在“本轮尚未真正向前端推过正文”时补发最终 speak, + // 保证前端和历史都能看到同一份可见文案。 + if !speakStreamed && strings.TrimSpace(decision.Speak) != "" { + if emitErr := emitter.EmitAssistantText( + executeSpeakBlockID, + executeStageName, + decision.Speak, + firstChunk, + ); emitErr != nil { + return fmt.Errorf("执行文案兜底推送失败: %w", emitErr) + } + speakStreamed = true + firstChunk = false + } + + // 自省校验(仅 Plan 模式):next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。 + // + // 1. ReAct(无预定义步骤)下不强制 goal_check,避免 done 被错误拦截后进入循环; + // 2. Plan(有 done_when)下才要求 goal_check,对齐“按步骤验收”的语义; + // 3. 校验失败时推送一条可见状态,避免前端观察到“静默继续下一轮”。 + if flowState.HasPlan() && + (decision.Action == newagentmodel.ExecuteActionNextPlan || + decision.Action == newagentmodel.ExecuteActionDone) { if strings.TrimSpace(decision.GoalCheck) == "" { flowState.ConsecutiveCorrections++ if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections { return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections) } - // hint 区分有 plan / ReAct 两种模式: - // - 有 plan:要求对照 done_when 逐条验证; - // - ReAct:没有 done_when,只要求总结完成事实。 - var goalCheckHint string - if flowState.HasPlan() { - goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action) - } else { - goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中总结任务已完成的事实证据(调用了哪些工具、得到了什么结果)。", decision.Action) - } + _ = emitter.EmitStatus( + executeStatusBlockID, + executeStageName, + "executing", + fmt.Sprintf("执行校验:action=%s 缺少 goal_check,已请求模型重试。", decision.Action), + false, + ) AppendLLMCorrectionWithHint( conversationContext, - decision.Speak, + "", fmt.Sprintf("你输出了 action=%s,但 goal_check 为空。", decision.Action), - goalCheckHint, + fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action), ) return nil } @@ -434,6 +502,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { Role: schema.Assistant, Content: speakText, }) + if isAskUser { + askUserHistoryAppended = true + } } } @@ -443,6 +514,48 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 继续当前步骤的 ReAct 循环。 // 若有工具调用意图,则执行工具并记录证据。 if decision.ToolCall != nil { + // 1. 写工具必须走 confirm;continue 只允许读工具。 + // 2. 若模型误输出 continue+写工具,这里先做纠偏,不直接执行写操作。 + if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) { + flowState.ConsecutiveCorrections++ + log.Printf( + "[WARN] execute 决策协议违规 chat=%s round=%d action=continue tool=%s consecutive=%d/%d", + flowState.ConversationID, + flowState.RoundUsed, + strings.TrimSpace(decision.ToolCall.Name), + flowState.ConsecutiveCorrections, + maxConsecutiveCorrections, + ) + if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections { + return fmt.Errorf("连续 %d 次输出 continue+写工具,终止执行", flowState.ConsecutiveCorrections) + } + _ = emitter.EmitStatus( + executeStatusBlockID, + executeStageName, + "executing", + fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;日程修改工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)), + false, + ) + llmOutput := decision.Speak + if strings.TrimSpace(llmOutput) == "" { + llmOutput = decision.Reason + } + AppendLLMCorrectionWithHint( + conversationContext, + llmOutput, + fmt.Sprintf("你输出了 action=continue,但工具 %q 属于写操作。", decision.ToolCall.Name), + "写操作必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。", + ) + return nil + } + if shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) { + runtimeState.OpenAskUserInteraction( + uuid.NewString(), + buildInfeasibleNegotiationQuestion(flowState), + strings.TrimSpace(input.ResumeNode), + ) + return nil + } return executeToolCall( ctx, flowState, @@ -469,10 +582,23 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // LLM 判定缺少关键信息,打开追问交互。 question := resolveExecuteAskUserText(decision) runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode)) + // 1. execute 阶段可能已流式推送 ask_user 文本; + // 2. interrupt 节点读取该元信息后可跳过二次正文推送,避免前端重复显示; + // 3. history 是否已写入也一并标记,防止上下文重复追加。 + runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserSpeakStreamed, speakStreamed) + runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserHistoryAppended, askUserHistoryAppended) return nil case newagentmodel.ExecuteActionConfirm: // AlwaysExecute=true:跳过确认闸门,直接执行内存写工具,不走 confirm 节点。 + if decision.ToolCall != nil && shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) { + runtimeState.OpenAskUserInteraction( + uuid.NewString(), + buildInfeasibleNegotiationQuestion(flowState), + strings.TrimSpace(input.ResumeNode), + ) + return nil + } if input.AlwaysExecute && decision.ToolCall != nil { return executeToolCall( ctx, @@ -714,6 +840,30 @@ func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string { return "执行过程中遇到不确定的情况,需要向你确认。" } +// pickExecuteVisibleSpeak 统一按“后文 -> 前言 -> fallback”选择最终可见文案。 +// +// 规则: +// 1. streamed / afterText 对应 后的正文,优先级最高; +// 2. beforeText 对应标签前前言,仅在后文为空时兜底使用; +// 3. 三者都为空时,再回退到 reason / 默认短句。 +func pickExecuteVisibleSpeak( + streamed string, + afterText string, + beforeText string, + decision *newagentmodel.ExecuteDecision, +) string { + if text := strings.TrimSpace(streamed); text != "" { + return text + } + if text := strings.TrimSpace(afterText); text != "" { + return text + } + if text := strings.TrimSpace(beforeText); text != "" { + return text + } + return buildExecuteSpeakWithFallback(decision) +} + // buildExecuteSpeakWithFallback 统一为需要面向用户展示的动作补齐 speak 文案。 // // 规则: @@ -1358,6 +1508,33 @@ func parseAnyToIntSlice(value any) []int { } } +func parseAnyToStringSlice(value any) []string { + switch values := value.(type) { + case []string: + result := make([]string, 0, len(values)) + for _, item := range values { + text := strings.TrimSpace(item) + if text == "" { + continue + } + result = append(result, text) + } + return result + case []any: + result := make([]string, 0, len(values)) + for _, item := range values { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text == "" || text == "" { + continue + } + result = append(result, text) + } + return result + default: + return nil + } +} + // appendToolCallResultHistory 统一把“assistant tool_call + tool observation”写回历史。 // // 设计说明: @@ -1447,6 +1624,31 @@ func executeToolCall( if scheduleState == nil && registry.RequiresScheduleState(toolName) { return fmt.Errorf("日程状态未加载,无法执行工具 %q", toolName) } + if registry.IsToolTemporarilyDisabled(toolName) { + flowState.ConsecutiveCorrections++ + if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections { + return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s", + flowState.ConsecutiveCorrections, toolName) + } + blockedResult := buildTemporarilyDisabledToolResult(toolName) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + toolName, + "blocked", + blockedResult, + buildToolArgumentsPreviewCN(toolCall.Arguments), + false, + ) + appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) + AppendLLMCorrectionWithHint( + conversationContext, + "", + fmt.Sprintf("工具 %q 当前暂时禁用。", toolName), + "请改用 move/swap/batch_move/unplace 等基础微调工具继续推进。", + ) + return nil + } if !registry.HasTool(toolName) { // LLM 拼错或编造了工具名,走 correction 机制给重试机会,而非直接 fatal。 // 与 action 不合法、决策校验失败等路径一致:追加错误反馈 → Graph 循环 → LLM 修正。 @@ -1466,6 +1668,35 @@ func executeToolCall( ) return nil } + if !isToolVisibleForCurrentExecuteMode(flowState, registry, toolName) { + flowState.ConsecutiveCorrections++ + if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections { + return fmt.Errorf("连续 %d 次调用未激活域工具,终止执行: %s(active_domain=%q active_packs=%v)", + flowState.ConsecutiveCorrections, + toolName, + flowState.ActiveToolDomain, + newagenttools.ResolveEffectiveToolPacks(flowState.ActiveToolDomain, flowState.ActiveToolPacks)) + } + + addHint := `请先调用 context_tools_add 激活目标工具域后再继续。` + if flowState != nil && flowState.ActiveOptimizeOnly { + addHint = `当前处于“粗排后主动优化专用模式”,只允许使用 analyze_health、move、swap;不要再尝试 query_target_tasks / query_available_slots 等全窗搜索工具。` + } else if domain, pack, ok := newagenttools.ResolveToolDomainPack(toolName); ok { + if newagenttools.IsFixedToolPack(domain, pack) { + addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s"。`, domain) + } else { + addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s", packs=["%s"]。`, domain, pack) + } + } + + AppendLLMCorrectionWithHint( + conversationContext, + "", + fmt.Sprintf("你调用的工具 %q 当前不在已激活工具域内。", toolName), + addHint, + ) + return nil + } // 2. 执行工具。 // 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch,并写回工具观察结果。 @@ -1490,6 +1721,20 @@ func executeToolCall( appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) return nil } + if shouldForceFeasibilityNegotiation(flowState, registry, toolName) { + blockedResult := buildInfeasibleBlockedResult(flowState) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + toolName, + "blocked", + blockedResult, + buildToolArgumentsPreviewCN(toolCall.Arguments), + false, + ) + appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) + return nil + } beforeDigest := summarizeScheduleStateForDebug(scheduleState) // 调用目的:为不依赖 ScheduleState 的工具注入用户身份,工具层通过 args["_user_id"] 提取。 @@ -1500,6 +1745,9 @@ func executeToolCall( toolCall.Arguments["_user_id"] = flowState.UserID } result := registry.Execute(scheduleState, toolName, toolCall.Arguments) + updateHealthSnapshotV2(flowState, toolName, result) + updateTaskClassUpsertSnapshot(flowState, toolName, result) + updateActiveToolDomainSnapshot(flowState, toolName, result) afterDigest := summarizeScheduleStateForDebug(scheduleState) log.Printf( "[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", @@ -1524,8 +1772,9 @@ func executeToolCall( // 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。 appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result) - // 3.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 - if registry.IsWriteTool(toolName) { + // 3.1 仅“日程修改工具”才算日程变更。 + // 任务类写库(如 upsert_task_class)不应触发顺序守卫与排程完成卡片。 + if registry.IsScheduleMutationTool(toolName) { flowState.HasScheduleWriteOps = true flowState.HasScheduleChanges = true } @@ -1541,6 +1790,61 @@ func executeToolCall( return nil } +// applyPendingContextHook 在 execute 轮次开始时消费一次 plan 传递的 context_hook。 +// +// 步骤化说明: +// 1. 仅在存在 PendingContextHook 时生效,避免无意义状态写入; +// 2. 域与 packs 按工具映射规则归一化,保证和 context_tools_add 的结果语义一致; +// 3. 消费后立即清空 PendingContextHook,避免每轮重复覆盖造成噪声。 +func applyPendingContextHook(flowState *newagentmodel.CommonState) { + if flowState == nil || flowState.PendingContextHook == nil { + return + } + hook := flowState.PendingContextHook + domain := newagenttools.NormalizeToolDomain(hook.Domain) + if domain == "" { + flowState.PendingContextHook = nil + return + } + flowState.ActiveToolDomain = domain + flowState.ActiveToolPacks = newagenttools.ResolveEffectiveToolPacks(domain, hook.Packs) + flowState.PendingContextHook = nil +} + +// isToolVisibleForCurrentExecuteMode 统一判定“当前 execute 轮次里,这个工具到底能不能被调”。 +// +// 步骤化说明: +// 1. 先走原有的 domain + pack 可见性校验,保证普通链路行为不变; +// 2. 若当前开启了主动优化专用模式,再叠加一道更强的白名单裁剪; +// 3. 这样可以做到“工具定义仍保留,但主动优化场景只露最小闭环”,且不影响普通服务链路。 +func isToolVisibleForCurrentExecuteMode( + flowState *newagentmodel.CommonState, + registry *newagenttools.ToolRegistry, + toolName string, +) bool { + if registry == nil { + return false + } + activeDomain := "" + var activePacks []string + if flowState != nil { + activeDomain = flowState.ActiveToolDomain + activePacks = flowState.ActiveToolPacks + } + if !registry.IsToolVisibleInDomain(activeDomain, activePacks, toolName) { + return false + } + if flowState != nil && flowState.ActiveOptimizeOnly && !newagenttools.IsToolAllowedInActiveOptimize(toolName) { + return false + } + return true +} + +// buildTemporarilyDisabledToolResult 统一生成“工具临时禁用”的观察文本。 +func buildTemporarilyDisabledToolResult(toolName string) string { + return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等基础微调工具。", strings.TrimSpace(toolName)) +} + // shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。 // // 说明: @@ -1600,6 +1904,21 @@ func executePendingTool( return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName) } flowState := runtimeState.EnsureCommonState() + if registry.IsToolTemporarilyDisabled(pending.ToolName) { + blockedResult := buildTemporarilyDisabledToolResult(pending.ToolName) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + pending.ToolName, + "blocked", + blockedResult, + buildToolArgumentsPreviewCN(args), + false, + ) + appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) + runtimeState.PendingConfirmTool = nil + return nil + } // 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。 if shouldBlockMinContextSwitch(flowState, pending.ToolName) { @@ -1617,6 +1936,21 @@ func executePendingTool( runtimeState.PendingConfirmTool = nil return nil } + if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) { + blockedResult := buildInfeasibleBlockedResult(flowState) + _ = emitter.EmitToolCallResult( + executeStatusBlockID, + executeStageName, + pending.ToolName, + "blocked", + blockedResult, + buildToolArgumentsPreviewCN(args), + false, + ) + appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) + runtimeState.PendingConfirmTool = nil + return nil + } // 4. 执行工具。 beforeDigest := summarizeScheduleStateForDebug(scheduleState) @@ -1628,6 +1962,9 @@ func executePendingTool( args["_user_id"] = flowState.UserID } result := registry.Execute(scheduleState, pending.ToolName, args) + updateHealthSnapshotV2(flowState, pending.ToolName, result) + updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result) + updateActiveToolDomainSnapshot(flowState, pending.ToolName, result) afterDigest := summarizeScheduleStateForDebug(scheduleState) log.Printf( "[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", @@ -1652,8 +1989,8 @@ func executePendingTool( // 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。 appendToolCallResultHistory(conversationContext, pending.ToolName, args, result) - // 5.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 - if registry.IsWriteTool(pending.ToolName) { + // 5.1 仅“日程修改工具”才算日程变更。 + if registry.IsScheduleMutationTool(pending.ToolName) { flowState.HasScheduleWriteOps = true flowState.HasScheduleChanges = true } @@ -1684,7 +2021,7 @@ func tryWritePreviewAfterWriteTool( if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil { return } - if !registry.IsWriteTool(toolName) { + if !registry.IsScheduleMutationTool(toolName) { return } @@ -1864,6 +2201,13 @@ func tryExtractToolResultSummaryCN(raw string) (string, bool) { toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool")) toolName := resolveToolDisplayNameCN(toolRaw) + // 任务类写入工具优先走结构化提炼,确保前端摘要直接暴露“是否缺字段”。 + if strings.EqualFold(toolRaw, "upsert_task_class") { + if summary, ok := buildUpsertTaskClassSummaryCN(payload); ok { + return truncateToolSummaryCN(summary), true + } + } + if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" { return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, errText)), true } @@ -1911,6 +2255,37 @@ func tryExtractToolResultSummaryCN(raw string) (string, bool) { return "", false } +func buildUpsertTaskClassSummaryCN(payload map[string]any) (string, bool) { + validationRaw, hasValidation := payload["validation"] + if !hasValidation { + return "", false + } + validation, ok := validationRaw.(map[string]any) + if !ok { + return "", false + } + + validationOK, hasValidationOK := validation["ok"].(bool) + issues := parseAnyToStringSlice(validation["issues"]) + + if hasValidationOK && !validationOK { + if len(issues) > 0 { + return fmt.Sprintf("任务类写入未通过校验:%s。", strings.Join(issues, ";")), true + } + return "任务类写入未通过校验,请先补齐缺失字段。", true + } + + success, hasSuccess := payload["success"].(bool) + if hasSuccess && success { + if taskClassID, ok := readIntAnyFromMap(payload, "task_class_id"); ok && taskClassID > 0 { + return fmt.Sprintf("任务类写入成功,task_class_id=%d。", taskClassID), true + } + return "任务类写入成功。", true + } + + return "", false +} + func truncateToolSummaryCN(text string) string { runes := []rune(strings.TrimSpace(text)) if len(runes) <= 48 { @@ -1975,6 +2350,9 @@ func buildToolArgumentsPreviewCN(args map[string]any) string { {Key: "task_item_ids", Label: "任务条目列表"}, {Key: "query", Label: "搜索词"}, {Key: "keyword", Label: "关键词"}, + {Key: "domain", Label: "工具域"}, + {Key: "mode", Label: "注入模式"}, + {Key: "all", Label: "清空全部"}, {Key: "top_k", Label: "返回数量"}, {Key: "url", Label: "链接"}, {Key: "reason", Label: "原因"}, @@ -2017,6 +2395,9 @@ func resolveToolDisplayNameCN(toolName string) string { "query_target_tasks": "查询目标任务", "query_available_slots": "查询可用时间段", "get_task_info": "查看任务详情", + "analyze_health": "综合体检", + "analyze_rhythm": "分析学习节奏", + "analyze_tolerance": "分析容错空间", "web_search": "网页搜索", "web_fetch": "网页抓取", "move": "移动任务", @@ -2026,6 +2407,9 @@ func resolveToolDisplayNameCN(toolName string) string { "spread_even": "均匀分散任务", "min_context_switch": "减少上下文切换", "unplace": "移除任务安排", + "upsert_task_class": "写入任务类", + "context_tools_add": "激活工具域", + "context_tools_remove": "移除工具域", } if label, ok := displayNameMap[name]; ok { @@ -2135,3 +2519,349 @@ func formatToolArgValueCN(value any) string { return text } } + +// shouldForceFeasibilityNegotiation 判定是否需要先协商再继续写操作。 +func shouldForceFeasibilityNegotiation( + flowState *newagentmodel.CommonState, + registry *newagenttools.ToolRegistry, + toolName string, +) bool { + if flowState == nil || registry == nil { + return false + } + if !flowState.HealthCheckDone || flowState.HealthIsFeasible { + return false + } + // 仅拦截“依赖日程状态”的写工具,避免影响 upsert_task_class 等独立写库能力。 + if !registry.IsWriteTool(toolName) || !registry.RequiresScheduleState(toolName) { + return false + } + return true +} + +// buildInfeasibleNegotiationQuestion 生成不可行场景下的协商提示。 +func buildInfeasibleNegotiationQuestion(flowState *newagentmodel.CommonState) string { + capacityGap := 0 + reasonCode := "capacity_insufficient" + if flowState != nil { + capacityGap = flowState.HealthCapacityGap + if strings.TrimSpace(flowState.HealthReasonCode) != "" { + reasonCode = strings.TrimSpace(flowState.HealthReasonCode) + } + } + return fmt.Sprintf( + "当前方案在现有约束下不可行(capacity_gap=%d,reason=%s),继续挪动任务无法消除根因。请告诉我你希望哪种处理方向:扩展时间窗、放宽约束、缩减范围/预算,或接受风险并先收口。", + capacityGap, + reasonCode, + ) +} + +// buildInfeasibleBlockedResult 构造写工具被不可行约束拦截后的 observation。 +func buildInfeasibleBlockedResult(flowState *newagentmodel.CommonState) string { + capacityGap := 0 + reasonCode := "capacity_insufficient" + if flowState != nil { + capacityGap = flowState.HealthCapacityGap + if strings.TrimSpace(flowState.HealthReasonCode) != "" { + reasonCode = strings.TrimSpace(flowState.HealthReasonCode) + } + } + return fmt.Sprintf( + "已阻断本次写操作:analyze_health 判定当前约束不可行(capacity_gap=%d,reason=%s)。请先与用户协商:扩展时间窗 / 放宽约束 / 缩减范围或预算 / 接受风险收口。", + capacityGap, + reasonCode, + ) +} + +type contextToolsResultEnvelope struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + Mode string `json:"mode,omitempty"` + All bool `json:"all,omitempty"` +} + +type analyzeHealthResultEnvelope struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"` + Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"` +} + +type analyzeHealthFeasibilityBrief struct { + IsFeasible bool `json:"is_feasible"` + CapacityGap int `json:"capacity_gap"` + ReasonCode string `json:"reason_code"` +} + +type analyzeHealthDecisionBrief struct { + ShouldContinueOptimize bool `json:"should_continue_optimize"` + PrimaryProblem string `json:"primary_problem,omitempty"` + RecommendedOperation string `json:"recommended_operation,omitempty"` + IsForcedImperfection bool `json:"is_forced_imperfection"` + ImprovementSignal string `json:"improvement_signal,omitempty"` +} + +type upsertTaskClassResultEnvelope struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Validation *upsertTaskClassValidationPart `json:"validation,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +type upsertTaskClassValidationPart struct { + OK bool `json:"ok"` + Issues []string `json:"issues"` +} + +// updateActiveToolDomainSnapshot 根据 context 管理工具结果回写激活工具域与二级包。 +// +// 步骤化说明: +// 1. 仅处理 context_tools_add/remove,其他工具直接跳过; +// 2. 仅在 success=true 且结果可解析时更新,解析失败时保持旧值,避免误删关键域; +// 3. add 成功时覆盖域并写入 packs;remove 成功时按 all/domain/packs 精确回收。 +func updateActiveToolDomainSnapshot(flowState *newagentmodel.CommonState, toolName string, result string) { + if flowState == nil || !newagenttools.IsContextManagementTool(toolName) { + return + } + + var envelope contextToolsResultEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + return + } + if !envelope.Success { + return + } + + switch strings.TrimSpace(toolName) { + case newagenttools.ToolNameContextToolsAdd: + domain := newagenttools.NormalizeToolDomain(envelope.Domain) + if domain == "" { + return + } + nextPacks := newagenttools.ResolveEffectiveToolPacks(domain, envelope.Packs) + mode := strings.ToLower(strings.TrimSpace(envelope.Mode)) + if mode == "merge" && newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain) == domain { + merged := make([]string, 0, len(flowState.ActiveToolPacks)+len(nextPacks)) + seen := make(map[string]struct{}, len(flowState.ActiveToolPacks)+len(nextPacks)) + current := newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks) + for _, pack := range current { + if _, exists := seen[pack]; exists { + continue + } + seen[pack] = struct{}{} + merged = append(merged, pack) + } + for _, pack := range nextPacks { + if _, exists := seen[pack]; exists { + continue + } + seen[pack] = struct{}{} + merged = append(merged, pack) + } + nextPacks = merged + } + flowState.ActiveToolDomain = domain + flowState.ActiveToolPacks = nextPacks + case newagenttools.ToolNameContextToolsRemove: + if envelope.All { + flowState.ActiveToolDomain = "" + flowState.ActiveToolPacks = nil + return + } + domain := newagenttools.NormalizeToolDomain(envelope.Domain) + if domain == "" { + return + } + currentDomain := newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain) + if currentDomain != domain { + return + } + + removedPacks := newagenttools.NormalizeToolPacks(domain, envelope.Packs) + if len(removedPacks) == 0 { + flowState.ActiveToolDomain = "" + flowState.ActiveToolPacks = nil + return + } + + currentEffective := newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks) + if len(currentEffective) == 0 { + flowState.ActiveToolDomain = "" + flowState.ActiveToolPacks = nil + return + } + + removedSet := make(map[string]struct{}, len(removedPacks)) + for _, pack := range removedPacks { + removedSet[pack] = struct{}{} + } + remaining := make([]string, 0, len(currentEffective)) + for _, pack := range currentEffective { + if _, shouldRemove := removedSet[pack]; shouldRemove { + continue + } + remaining = append(remaining, pack) + } + if len(remaining) == 0 { + flowState.ActiveToolDomain = "" + flowState.ActiveToolPacks = nil + return + } + flowState.ActiveToolPacks = remaining + } +} + +// updateHealthFeasibilitySnapshot 从 analyze_health 的结构化返回中更新可行性快照。 +func updateHealthFeasibilitySnapshot(flowState *newagentmodel.CommonState, toolName string, result string) { + if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) { + return + } + + // 先重置成“未知”状态,避免沿用旧快照误导后续决策。 + flowState.HealthCheckDone = false + flowState.HealthIsFeasible = true + flowState.HealthCapacityGap = 0 + flowState.HealthReasonCode = "" + + var envelope analyzeHealthResultEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + return + } + if !envelope.Success || envelope.Feasibility == nil { + return + } + + flowState.HealthCheckDone = true + flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible + flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap + flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode) +} + +// updateTaskClassUpsertSnapshot 从 upsert_task_class 返回中更新“任务类写入回盘”运行态。 +// +// 步骤化说明: +// 1. 仅在工具名命中 upsert_task_class 时更新,避免污染其他链路; +// 2. 每次先标记 last_tried=true,再根据 success/validation 更新成功态与缺失项; +// 3. 连续失败计数仅用于软提示:成功归零,失败递增,不做硬拦截。 +func updateTaskClassUpsertSnapshot(flowState *newagentmodel.CommonState, toolName string, result string) { + if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), "upsert_task_class") { + return + } + + flowState.TaskClassUpsertLastTried = true + flowState.TaskClassUpsertLastSuccess = false + flowState.TaskClassUpsertLastIssues = nil + + var envelope upsertTaskClassResultEnvelope + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + flowState.TaskClassUpsertConsecutiveFailures++ + return + } + + success := envelope.Success + issues := make([]string, 0) + if envelope.Validation != nil { + issues = append(issues, parseAnyToStringSlice(any(envelope.Validation.Issues))...) + if !envelope.Validation.OK { + success = false + } + } + if !success && strings.TrimSpace(envelope.Error) != "" && len(issues) == 0 { + issues = append(issues, strings.TrimSpace(envelope.Error)) + } + issues = uniqueNonEmptyStrings(issues) + + flowState.TaskClassUpsertLastSuccess = success + flowState.TaskClassUpsertLastIssues = issues + if success { + flowState.TaskClassUpsertConsecutiveFailures = 0 + return + } + flowState.TaskClassUpsertConsecutiveFailures++ +} + +func uniqueNonEmptyStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + text := strings.TrimSpace(value) + if text == "" { + continue + } + if _, exists := seen[text]; exists { + continue + } + seen[text] = struct{}{} + result = append(result, text) + } + return result +} + +// updateHealthSnapshotV2 从 analyze_health 的结构化返回中同步“是否继续优化”的业务快照。 +// +// 职责边界: +// 1. 只负责把 analyze_health 的关键结论回写到 CommonState,供 execute prompt 直接消费; +// 2. 不负责替 LLM 生成下一步参数,也不做写工具硬拦截; +// 3. 若结果解析失败,则回到保守默认值,避免沿用旧结论误导本轮判断。 +func updateHealthSnapshotV2(flowState *newagentmodel.CommonState, toolName string, result string) { + if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) { + return + } + + prevSignal := strings.TrimSpace(flowState.HealthImprovementSignal) + flowState.HealthCheckDone = false + flowState.HealthIsFeasible = true + flowState.HealthCapacityGap = 0 + flowState.HealthReasonCode = "" + flowState.HealthShouldContinueOptimize = false + flowState.HealthTightnessLevel = "" + flowState.HealthPrimaryProblem = "" + flowState.HealthRecommendedOperation = "" + flowState.HealthIsForcedImperfection = false + flowState.HealthImprovementSignal = "" + + var envelope struct { + Success bool `json:"success"` + Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"` + Metrics struct { + Tightness *struct { + TightnessLevel string `json:"tightness_level"` + } `json:"tightness,omitempty"` + } `json:"metrics"` + Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"` + } + if err := json.Unmarshal([]byte(result), &envelope); err != nil { + flowState.HealthStagnationCount = 0 + return + } + if !envelope.Success || envelope.Feasibility == nil { + flowState.HealthStagnationCount = 0 + return + } + + flowState.HealthCheckDone = true + flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible + flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap + flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode) + if envelope.Metrics.Tightness != nil { + flowState.HealthTightnessLevel = strings.TrimSpace(envelope.Metrics.Tightness.TightnessLevel) + } + if envelope.Decision != nil { + flowState.HealthShouldContinueOptimize = envelope.Decision.ShouldContinueOptimize + flowState.HealthPrimaryProblem = strings.TrimSpace(envelope.Decision.PrimaryProblem) + flowState.HealthRecommendedOperation = strings.TrimSpace(envelope.Decision.RecommendedOperation) + flowState.HealthIsForcedImperfection = envelope.Decision.IsForcedImperfection + flowState.HealthImprovementSignal = strings.TrimSpace(envelope.Decision.ImprovementSignal) + } + if signal := strings.TrimSpace(flowState.HealthImprovementSignal); signal != "" && prevSignal != "" && signal == prevSignal { + flowState.HealthStagnationCount++ + return + } + flowState.HealthStagnationCount = 0 +} diff --git a/backend/newAgent/node/interrupt.go b/backend/newAgent/node/interrupt.go index fe2a3f8..e5ac171 100644 --- a/backend/newAgent/node/interrupt.go +++ b/backend/newAgent/node/interrupt.go @@ -82,18 +82,27 @@ func handleInterruptAskUser( text = "请补充更多信息。" } - // 伪流式输出,和 chatReply 一样的体感。 - if err := emitter.EmitPseudoAssistantText( - ctx, interruptSpeakBlockID, interruptStageName, - text, - newagentstream.DefaultPseudoStreamOptions(), - ); err != nil { - return fmt.Errorf("追问消息推送失败: %w", err) + speakStreamed := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserSpeakStreamed) + historyAppended := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserHistoryAppended) + + // 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送; + // 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。 + if !speakStreamed { + // 伪流式输出,和 chatReply 一样的体感。 + if err := emitter.EmitPseudoAssistantText( + ctx, interruptSpeakBlockID, interruptStageName, + text, + newagentstream.DefaultPseudoStreamOptions(), + ); err != nil { + return fmt.Errorf("追问消息推送失败: %w", err) + } } // 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。 msg := schema.AssistantMessage(text, nil) - conversationContext.AppendHistory(msg) + if !historyAppended { + conversationContext.AppendHistory(msg) + } persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg) // 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。 @@ -105,6 +114,21 @@ func handleInterruptAskUser( return nil } +func readPendingMetadataBool(pending *newagentmodel.PendingInteraction, key string) bool { + if pending == nil || pending.Metadata == nil { + return false + } + raw, exists := pending.Metadata[key] + if !exists { + return false + } + value, ok := raw.(bool) + if !ok { + return false + } + return value +} + // handleInterruptConfirm 处理确认型中断。 // // 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。 diff --git a/backend/newAgent/node/order_guard.go b/backend/newAgent/node/order_guard.go deleted file mode 100644 index 21932f9..0000000 --- a/backend/newAgent/node/order_guard.go +++ /dev/null @@ -1,462 +0,0 @@ -package newagentnode - -import ( - "context" - "fmt" - "log" - "sort" - "strings" - - newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" - "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" -) - -const ( - orderGuardStageName = "order_guard" - orderGuardStatusBlock = "order_guard.status" -) - -type suggestedOrderItem struct { - StateID int - Day int - SlotStart int - SlotEnd int - Slots []schedule.TaskSlot -} - -type orderRestoreResult struct { - Restored bool - Changed int - Detail string -} - -// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。 -// -// 职责边界: -// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库; -// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行; -// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止。 -func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error { - if st == nil { - return fmt.Errorf("order_guard node: state is nil") - } - - flowState := st.EnsureFlowState() - if flowState == nil { - return fmt.Errorf("order_guard node: flow state is nil") - } - // 1. 用户明确授权可打乱顺序时,顺序守卫节点直接放行。 - if flowState.AllowReorder { - return nil - } - - // 2. 读取当前 ScheduleState,提取 suggested 任务的“时间顺序快照”。 - scheduleState, err := st.EnsureScheduleState(ctx) - if err != nil { - return fmt.Errorf("order_guard node: load schedule state failed: %w", err) - } - if scheduleState == nil { - return nil - } - currentOrder := buildSuggestedOrderSnapshot(scheduleState) - - // 3. 基线为空时,仅初始化基线并放行,避免第一次进入守卫就误判。 - if len(flowState.SuggestedOrderBaseline) == 0 { - flowState.SuggestedOrderBaseline = append([]int(nil), currentOrder...) - _ = st.EnsureChunkEmitter().EmitStatus( - orderGuardStatusBlock, - orderGuardStageName, - "order_guard_initialized", - "已记录本轮建议任务顺序基线,继续交付当前结果。", - false, - ) - return nil - } - - // 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止。 - violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder) - if !violated { - _ = st.EnsureChunkEmitter().EmitStatus( - orderGuardStatusBlock, - orderGuardStageName, - "order_guard_passed", - "顺序守卫校验通过,保持原有相对顺序。", - false, - ) - return nil - } - - // 4.1 违序后进入自动复原: - // 1) 复用“当前坑位集合”,按 baseline 相对顺序回填任务; - // 2) 成功则继续 completed 路径,保证预览可写入; - // 3) 若复原条件不满足,保守放行并输出诊断,避免再次把整轮流程打成 aborted。 - restore := restoreSuggestedOrderByBaseline(scheduleState, flowState.SuggestedOrderBaseline) - if restore.Restored { - _ = st.EnsureChunkEmitter().EmitStatus( - orderGuardStatusBlock, - orderGuardStageName, - "order_guard_restored", - fmt.Sprintf("检测到建议任务顺序被打乱,已自动复原(调整 %d 个任务)。", restore.Changed), - false, - ) - return nil - } - - _ = st.EnsureChunkEmitter().EmitStatus( - orderGuardStatusBlock, - orderGuardStageName, - "order_guard_restore_skipped", - "检测到顺序异常,但本次未执行自动复原,已继续交付当前结果。详情见日志。", - false, - ) - log.Printf( - "[WARN] order_guard restore skipped chat=%s baseline=%v current=%v detail=%s restore_detail=%s", - flowState.ConversationID, - flowState.SuggestedOrderBaseline, - currentOrder, - detail, - restore.Detail, - ) - return nil -} - -// buildSuggestedOrderSnapshot 生成 suggested 任务的相对顺序快照(按时间坐标排序)。 -// -// 说明: -// 1. 这里只关心 suggested 任务,因为顺序守卫目标是约束“本轮建议层”的相对次序; -// 2. 多 slot 任务取“最早 slot”作为排序锚点,保证排序键稳定; -// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。 -func buildSuggestedOrderSnapshot(state *schedule.ScheduleState) []int { - items := buildSuggestedOrderItems(state) - order := make([]int, 0, len(items)) - for _, item := range items { - order = append(order, item.StateID) - } - return order -} - -// buildSuggestedOrderItems 生成 suggested 任务的排序明细。 -// -// 职责边界: -// 1. 统一封装顺序守卫和自动复原都需要的排序素材,避免两处逻辑口径漂移; -// 2. 排序键保持与历史实现一致:day -> slot_start -> slot_end -> state_id; -// 3. 每项附带完整 slots 快照,供“坑位复用式复原”直接使用。 -func buildSuggestedOrderItems(state *schedule.ScheduleState) []suggestedOrderItem { - if state == nil || len(state.Tasks) == 0 { - return nil - } - - items := make([]suggestedOrderItem, 0, len(state.Tasks)) - for i := range state.Tasks { - task := state.Tasks[i] - if !schedule.IsSuggestedTask(task) || len(task.Slots) == 0 { - continue - } - day, slotStart, slotEnd := earliestTaskSlot(task.Slots) - items = append(items, suggestedOrderItem{ - StateID: task.StateID, - Day: day, - SlotStart: slotStart, - SlotEnd: slotEnd, - Slots: cloneTaskSlots(task.Slots), - }) - } - - sort.SliceStable(items, func(i, j int) bool { - if items[i].Day != items[j].Day { - return items[i].Day < items[j].Day - } - if items[i].SlotStart != items[j].SlotStart { - return items[i].SlotStart < items[j].SlotStart - } - if items[i].SlotEnd != items[j].SlotEnd { - return items[i].SlotEnd < items[j].SlotEnd - } - return items[i].StateID < items[j].StateID - }) - - return items -} - -func earliestTaskSlot(slots []schedule.TaskSlot) (day int, slotStart int, slotEnd int) { - if len(slots) == 0 { - return 0, 0, 0 - } - best := slots[0] - for i := 1; i < len(slots); i++ { - current := slots[i] - if current.Day < best.Day { - best = current - continue - } - if current.Day == best.Day && current.SlotStart < best.SlotStart { - best = current - continue - } - if current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd { - best = current - } - } - return best.Day, best.SlotStart, best.SlotEnd -} - -// detectRelativeOrderViolation 检查 current 是否破坏 baseline 的相对顺序。 -// -// 规则: -// 1. 仅比较 baseline 与 current 的交集任务,避免新增/删除任务引发误报; -// 2. 一旦出现 rank 逆序即判定为 violation; -// 3. detail 只用于内部排查,不直接给用户。 -func detectRelativeOrderViolation(baseline []int, current []int) (bool, string) { - if len(baseline) == 0 || len(current) == 0 { - return false, "" - } - - rankByID := make(map[int]int, len(baseline)) - for idx, id := range baseline { - rankByID[id] = idx - } - - filtered := make([]int, 0, len(current)) - for _, id := range current { - if _, ok := rankByID[id]; ok { - filtered = append(filtered, id) - } - } - if len(filtered) < 2 { - return false, "" - } - - prevID := filtered[0] - prevRank := rankByID[prevID] - for i := 1; i < len(filtered); i++ { - id := filtered[i] - rank := rankByID[id] - if rank < prevRank { - return true, strings.TrimSpace(fmt.Sprintf( - "reverse pair detected: prev_id=%d prev_rank=%d current_id=%d current_rank=%d", - prevID, prevRank, id, rank, - )) - } - prevID = id - prevRank = rank - } - return false, "" -} - -// restoreSuggestedOrderByBaseline 在“默认不允许打乱顺序”场景下自动复原 suggested 相对顺序。 -// -// 步骤化说明: -// 1. 先提取 baseline 与 current 的交集任务,确保只修复本轮可比对对象; -// 2. 复用 current 的“坑位序列”(时段集合),按 baseline 顺序重新回填任务; -// 3. 回填前校验时长兼容,避免把长任务塞进短坑位; -// 4. 回填后再次校验顺序;若失败则回滚,保证状态不会半成功。 -func restoreSuggestedOrderByBaseline(state *schedule.ScheduleState, baseline []int) orderRestoreResult { - if state == nil { - return orderRestoreResult{Restored: false, Detail: "schedule_state=nil"} - } - if len(baseline) == 0 { - return orderRestoreResult{Restored: true} - } - - items := buildSuggestedOrderItems(state) - if len(items) < 2 { - return orderRestoreResult{Restored: true} - } - - itemByID := make(map[int]suggestedOrderItem, len(items)) - currentInScope := make([]int, 0, len(items)) - for _, item := range items { - itemByID[item.StateID] = item - } - for _, item := range items { - if _, ok := itemByID[item.StateID]; ok { - currentInScope = append(currentInScope, item.StateID) - } - } - - baselineInScope := make([]int, 0, len(baseline)) - for _, id := range baseline { - if _, ok := itemByID[id]; ok { - baselineInScope = append(baselineInScope, id) - } - } - if len(baselineInScope) < 2 { - return orderRestoreResult{Restored: true} - } - - // currentInScope 只保留 baseline 交集,保证两边长度一致且语义可比。 - baselineSet := make(map[int]struct{}, len(baselineInScope)) - for _, id := range baselineInScope { - baselineSet[id] = struct{}{} - } - filteredCurrent := make([]int, 0, len(currentInScope)) - for _, id := range currentInScope { - if _, ok := baselineSet[id]; ok { - filteredCurrent = append(filteredCurrent, id) - } - } - if sameIDOrder(filteredCurrent, baselineInScope) { - return orderRestoreResult{Restored: true} - } - if len(filteredCurrent) != len(baselineInScope) { - return orderRestoreResult{ - Restored: false, - Detail: fmt.Sprintf("size_mismatch baseline=%d current=%d", len(baselineInScope), len(filteredCurrent)), - } - } - - // 1. 先构建“当前坑位序列”。 - slotPool := make([][]schedule.TaskSlot, 0, len(filteredCurrent)) - for _, currentID := range filteredCurrent { - item, ok := itemByID[currentID] - if !ok { - return orderRestoreResult{ - Restored: false, - Detail: fmt.Sprintf("current_id_missing id=%d", currentID), - } - } - slotPool = append(slotPool, cloneTaskSlots(item.Slots)) - } - - // 2. 回填前做兼容性校验:默认要求“目标任务时长 == 坑位时长”。 - for i, targetID := range baselineInScope { - targetTask := state.TaskByStateID(targetID) - if targetTask == nil { - return orderRestoreResult{ - Restored: false, - Detail: fmt.Sprintf("target_task_missing id=%d", targetID), - } - } - if !isSlotsCompatibleWithTask(*targetTask, slotPool[i]) { - return orderRestoreResult{ - Restored: false, - Detail: fmt.Sprintf( - "slot_incompatible target=%d expected_duration=%d slot_duration=%d expected_segments=%d slot_segments=%d", - targetID, - expectedTaskDuration(*targetTask), - totalSlotDuration(slotPool[i]), - len(targetTask.Slots), - len(slotPool[i]), - ), - } - } - } - - // 3. 执行回填,并在失败时支持回滚。 - beforeSlots := make(map[int][]schedule.TaskSlot, len(baselineInScope)) - changed := 0 - for i, targetID := range baselineInScope { - task := state.TaskByStateID(targetID) - if task == nil { - continue - } - beforeSlots[targetID] = cloneTaskSlots(task.Slots) - targetSlots := cloneTaskSlots(slotPool[i]) - if !equalTaskSlots(task.Slots, targetSlots) { - task.Slots = targetSlots - changed++ - } - } - - afterOrder := buildSuggestedOrderSnapshot(state) - afterFiltered := make([]int, 0, len(afterOrder)) - for _, id := range afterOrder { - if _, ok := baselineSet[id]; ok { - afterFiltered = append(afterFiltered, id) - } - } - if !sameIDOrder(afterFiltered, baselineInScope) { - // 回滚,避免保留半成功状态。 - for _, targetID := range baselineInScope { - task := state.TaskByStateID(targetID) - if task == nil { - continue - } - task.Slots = cloneTaskSlots(beforeSlots[targetID]) - } - return orderRestoreResult{ - Restored: false, - Detail: fmt.Sprintf( - "restore_verify_failed expected=%v actual=%v", - baselineInScope, afterFiltered, - ), - } - } - - return orderRestoreResult{ - Restored: true, - Changed: changed, - } -} - -func sameIDOrder(left, right []int) bool { - if len(left) != len(right) { - return false - } - for i := range left { - if left[i] != right[i] { - return false - } - } - return true -} - -func cloneTaskSlots(slots []schedule.TaskSlot) []schedule.TaskSlot { - if len(slots) == 0 { - return nil - } - copied := make([]schedule.TaskSlot, len(slots)) - copy(copied, slots) - return copied -} - -func equalTaskSlots(left, right []schedule.TaskSlot) bool { - if len(left) != len(right) { - return false - } - for i := range left { - if left[i].Day != right[i].Day { - return false - } - if left[i].SlotStart != right[i].SlotStart { - return false - } - if left[i].SlotEnd != right[i].SlotEnd { - return false - } - } - return true -} - -func expectedTaskDuration(task schedule.ScheduleTask) int { - if task.Duration > 0 { - return task.Duration - } - if len(task.Slots) > 0 { - return totalSlotDuration(task.Slots) - } - return 0 -} - -func totalSlotDuration(slots []schedule.TaskSlot) int { - total := 0 - for _, slot := range slots { - total += slot.SlotEnd - slot.SlotStart + 1 - } - return total -} - -func isSlotsCompatibleWithTask(task schedule.ScheduleTask, slots []schedule.TaskSlot) bool { - if len(slots) == 0 { - return false - } - expectedDuration := expectedTaskDuration(task) - if expectedDuration > 0 && expectedDuration != totalSlotDuration(slots) { - return false - } - // 兼容策略:当前任务已有多段落位时,要求目标坑位段数一致,避免跨段语义被破坏。 - if len(task.Slots) > 0 && len(task.Slots) != len(slots) { - return false - } - return true -} diff --git a/backend/newAgent/node/plan.go b/backend/newAgent/node/plan.go index 60573a6..66b6238 100644 --- a/backend/newAgent/node/plan.go +++ b/backend/newAgent/node/plan.go @@ -89,7 +89,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { messages, infrallm.GenerateOptions{ Temperature: 0.2, - Thinking: resolveThinkingMode(input.ThinkingEnabled), + // 显式设置上限,避免依赖框架默认值(默认 4096)导致长决策被截断。 + // 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。 + MaxTokens: 131072, + Thinking: resolveThinkingMode(input.ThinkingEnabled), Metadata: map[string]any{ "stage": planStageName, "phase": "planning", @@ -102,6 +105,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { parser := newagentrouter.NewStreamDecisionParser() firstChunk := true + speakStreamed := false // 3.1 阶段一:解析决策标签。 for { @@ -151,6 +155,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil { return fmt.Errorf("规划文案推送失败: %w", emitErr) } + speakStreamed = true fullText.WriteString(visible) firstChunk = false } @@ -173,6 +178,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil { return fmt.Errorf("规划文案推送失败: %w", emitErr) } + speakStreamed = true fullText.WriteString(chunk2.Content) firstChunk = false } @@ -187,7 +193,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { } // 5. 按规划动作推进流程状态。 - return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision) + return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision, speakStreamed) } // 流结束但未找到决策标签。 @@ -203,6 +209,7 @@ func handlePlanAction( emitter *newagentstream.ChunkEmitter, flowState *newagentmodel.CommonState, decision *newagentmodel.PlanDecision, + askUserSpeakStreamed bool, ) error { switch decision.Action { case newagentmodel.PlanActionContinue: @@ -211,9 +218,14 @@ func handlePlanAction( case newagentmodel.PlanActionAskUser: question := resolvePlanAskUserText(decision) runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode)) + // 1. plan 阶段若已流式推送过 ask_user 文本,interrupt 侧应避免重复正文输出; + // 2. plan 阶段 ask_user 不会提前写入 history,这里显式标记为 false。 + runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserSpeakStreamed, askUserSpeakStreamed) + runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserHistoryAppended, false) return nil case newagentmodel.PlanActionDone: flowState.FinishPlan(decision.PlanSteps) + flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook) writePlanPinnedBlocks(conversationContext, decision.PlanSteps) if decision.NeedsRoughBuild { flowState.NeedsRoughBuild = true @@ -295,6 +307,21 @@ func resolvePlanAskUserText(decision *newagentmodel.PlanDecision) string { return "我还缺一点关键信息,想先向你确认一下。" } +func clonePlanContextHook(hook *newagentmodel.ContextHook) *newagentmodel.ContextHook { + if hook == nil { + return nil + } + cloned := *hook + if len(hook.Packs) > 0 { + cloned.Packs = append([]string(nil), hook.Packs...) + } + cloned.Normalize() + if cloned.Domain == "" { + return nil + } + return &cloned +} + func writePlanPinnedBlocks(ctx *newagentmodel.ConversationContext, steps []newagentmodel.PlanStep) { if ctx == nil { return diff --git a/backend/newAgent/node/rough_build.go b/backend/newAgent/node/rough_build.go index 24fb6a0..98f621f 100644 --- a/backend/newAgent/node/rough_build.go +++ b/backend/newAgent/node/rough_build.go @@ -8,6 +8,7 @@ import ( "strings" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" ) @@ -69,30 +70,57 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e return nil } - // 4. 加载 ScheduleState(含 DayMapping,用于坐标转换)。 + // 4. 粗排前强制刷新 ScheduleState,避免复用旧快照窗口。 + // 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids,而不是沿用历史“当前周”窗口。 + // 4.2 做法:主动丢弃内存中的旧 state,让 EnsureScheduleState 走 provider 重新加载。 + // 4.3 失败策略:若任务类缺少有效起止日期,provider 会返回错误,由上层统一透传并让用户补齐字段。 + st.ScheduleState = nil + st.OriginalScheduleState = nil + + // 5. 加载 ScheduleState(含 DayMapping,用于坐标转换)。 scheduleState, err := st.EnsureScheduleState(ctx) if err != nil { + // 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。 + // 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。 + if strings.Contains(err.Error(), "任务类缺少有效时间窗") { + failureMessage := "开始智能编排前,我需要任务类的起止日期(start_date / end_date)。请先补齐时间窗,再让我继续排课。" + _ = emitter.EmitStatus( + roughBuildStatusBlock, + roughBuildStageName, + "rough_build_need_time_window", + failureMessage, + true, + ) + flowState.NeedsRoughBuild = false + flowState.Abort( + roughBuildStageName, + "rough_build_window_missing", + failureMessage, + err.Error(), + ) + return nil + } return fmt.Errorf("rough build node: 加载日程状态失败: %w", err) } if scheduleState == nil { return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排") } - // 5. 调用粗排算法。 + // 6. 调用粗排算法。 placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs) if err != nil { return fmt.Errorf("rough build node: 粗排算法失败: %w", err) } - // 6. 把粗排结果写入 ScheduleState。 + // 7. 把粗排结果写入 ScheduleState。 applyStats := applyRoughBuildPlacements(scheduleState, placements) - // 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。 + // 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。 if applyStats.AppliedCount > 0 { flowState.HasScheduleChanges = true } - // 7. 先校验粗排后是否仍有真实 pending。 + // 8. 先校验粗排后是否仍有真实 pending。 stillPending := countPendingTasks(scheduleState, taskClassIDs) log.Printf( "[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d", @@ -197,9 +225,31 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e flowState.NeedsRoughBuild = false flowState.NeedsRefineAfterRoughBuild = false if !shouldRefineAfterRoughBuild { + flowState.ActiveOptimizeOnly = false flowState.Done() return nil } + if strings.TrimSpace(flowState.OptimizationMode) == "" { + flowState.OptimizationMode = "first_full" + } + // 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。 + // 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面, + // 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。 + // 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。 + flowState.ActiveOptimizeOnly = true + // 12. 粗排后进入 execute 微调时,补一条一次性 context hook。 + // + // 1. 目的:即使这条链路不回 plan,也能在 execute 首轮拿到建议工具面(analyze + mutation)。 + // 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add,仍由 execute 按统一入口消费。 + // 3. 回退:hook 无效时 execute 会自动忽略并清空,不影响主流程。 + flowState.PendingContextHook = &newagentmodel.ContextHook{ + Domain: newagenttools.ToolDomainSchedule, + Packs: []string{ + newagenttools.ToolPackAnalyze, + newagenttools.ToolPackMutation, + }, + Reason: "rough_build_post_refine", + } flowState.Phase = newagentmodel.PhaseExecuting return nil } diff --git a/backend/newAgent/prompt/base.go b/backend/newAgent/prompt/base.go index f46c0f0..c80c89f 100644 --- a/backend/newAgent/prompt/base.go +++ b/backend/newAgent/prompt/base.go @@ -123,12 +123,22 @@ func renderStateSummary(state *newagentmodel.CommonState) string { if tc.StartDate != "" || tc.EndDate != "" { line += fmt.Sprintf(",日期范围=%s ~ %s", tc.StartDate, tc.EndDate) } + if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" { + line += fmt.Sprintf(",语义画像=%s/%s/%s", + defaultSemanticValue(tc.SubjectType), + defaultSemanticValue(tc.DifficultyLevel), + defaultSemanticValue(tc.CognitiveIntensity), + ) + } if tc.AllowFillerCourse { line += ",允许嵌入水课" } if len(tc.ExcludedSlots) > 0 { line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots) } + if len(tc.ExcludedDaysOfWeek) > 0 { + line += fmt.Sprintf(",排除星期=%v", tc.ExcludedDaysOfWeek) + } sb.WriteString(line + "\n") } } @@ -136,6 +146,14 @@ func renderStateSummary(state *newagentmodel.CommonState) string { return sb.String() } +func defaultSemanticValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "未标注" + } + return trimmed +} + // renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。 func renderPinnedBlocks(ctx *newagentmodel.ConversationContext) string { if ctx == nil { diff --git a/backend/newAgent/prompt/chat.go b/backend/newAgent/prompt/chat.go index 116cd90..1bee0bb 100644 --- a/backend/newAgent/prompt/chat.go +++ b/backend/newAgent/prompt/chat.go @@ -26,6 +26,11 @@ quick_task 判别要点: - 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute - 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问 +任务类设计路由要点: +- 普通"创建/修改任务类"默认走 execute(由 execute 负责补字段与写入)。 +- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。 +- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。 + 通用回答约束: - 非日程、非任务类问题,只要不需要工具,也应当正常回答。 - 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。 @@ -39,8 +44,8 @@ quick_task 判别要点: - "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine,不得再次触发 rough build。 粗排后微调判断: - 仅当 rough_build=true 时才判断 refine。 -- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 refine=true。 -- 若用户只要求"先排进去/给初稿",未提出微调目标,设 refine=false。 +- 默认策略:首次粗排完成后应进入微调(refine=true),按中位标准做主动优化。 +- 仅当用户明确表达"只要初稿/先排进去别优化/先不微调/排完就收口"时,才设 refine=false。 顺序授权判断: - reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。 - 用户明确要求"保持顺序/不要打乱"时必须为 false。 diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 82f55f2..4c38aeb 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -8,261 +8,14 @@ import ( "github.com/cloudwego/eino/schema" ) -const executeSystemPromptWithPlan = ` -你是 SmartMate 的执行器。你需要在"当前 plan 步骤"约束下推进任务。 - -你可以做什么: -1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。 -2. 可调用读工具补充事实,再决定下一步。 -3. 日程写操作时输出 action=confirm 并附带 tool_call,等待用户确认。 -4. 若用户给出了"二次微调方向"(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。 -5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。 -6. 多任务微调时默认走队列链路:query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head。 - -你不要做什么: -1. 不要跳到其他 plan 步骤,不要越级执行。 -2. 不要伪造工具结果。 -3. 如果上下文明确"粗排已完成/rough_build_done",不要把任务当成未排入,不要重新逐个手动 place。 -4. 如果上下文明确"当前未收到明确微调偏好/本轮先收口",不要继续微调,直接输出 action=done。 -5. 不要连续重复同类查询而没有推进;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。 -6. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。 -7. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。 -8. 不要忽略用户最新补充的微调方向;若与旧目标冲突,以最新用户要求为准。 -9. 若当前顺序策略是"默认保持顺序",禁止调用 min_context_switch。 -10. 不要把超过 2 条任务打包到 batch_move;大批量调整请改走队列逐项处理。 -11. 不要在未获取队首(queue_pop_head)时直接调用 queue_apply_head_move。 -12. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。 -13. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。 -14. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。 - -执行规则: -1. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。 -2. 读操作:action=continue + tool_call。 -3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 -4. 缺关键上下文且无法通过工具补齐:action=ask_user。 -5. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。 -6. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。 -7. 流程应正式终止时输出 action=abort。` - -const executeSystemPromptReAct = ` -你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。 - -阶段事实(强约束): -1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing,不是待排入状态。 -2. 当前阶段目标是"微调",不是"重新粗排"。 -3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。 -4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。 - -你可以做什么: -1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。 -2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。 -3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。 -4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。 -5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。 -6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。 - -你不要做什么: -1. 不要假设任务还没排进去,然后改成逐个手动 place。 -2. 不要伪造工具结果。 -3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。 -4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。 -5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。 -6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。 -7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。 -8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。 -9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。 -10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。 -11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。 -12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。 -13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。 - -执行规则: -1. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。 -2. 读操作:action=continue + tool_call。 -3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 -4. 缺关键上下文且无法通过工具补齐:action=ask_user。 -5. 任务完成:action=done,并在 goal_check 总结完成证据。 -6. 流程应正式终止:action=abort。` - // BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。 func BuildExecuteSystemPrompt() string { - return buildExecutePromptWithFormatGuard(executeSystemPromptWithPlan) + return buildExecutePromptWithFormatGuard(executeSystemPromptBaseWithPlan) } // BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。 func BuildExecuteReActSystemPrompt() string { - return buildExecutePromptWithFormatGuard(executeSystemPromptReAct) -} - -// BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。 -func BuildExecuteDecisionContractText() string { - return strings.TrimSpace(fmt.Sprintf(` -输出协议(两阶段格式): - -先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 -决策标签格式:{JSON} - -JSON 字段说明: -- action:只能是 %s / %s / %s / %s / %s -- reason:给后端和日志看的简短说明 -- goal_check:输出 %s 或 %s 时必填,对照 done_when 逐条验证 -- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带,格式 {"name":"工具名","arguments":{...}} - -注意:JSON 中不要包含 speak 字段。给用户看的话放在 标签之后。 - -示例: - -{"action":"%s","reason":"需要先调用 get_overview 获取事实","tool_call":{"name":"get_overview","arguments":{}}} -我先查看当前整体安排。 - -{"action":"%s","reason":"已完成当前步骤所需查询与校验","goal_check":"已满足当前步骤 done_when 条件"} -当前步骤已完成。 - -{"action":"%s","reason":"整体任务已完成"} -`, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAskUser, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionDone, - )) -} - -// BuildExecuteReActContractText 返回自由执行模式输出协议。 -func BuildExecuteReActContractText() string { - return strings.TrimSpace(fmt.Sprintf(` -输出协议(两阶段格式): - -先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 -决策标签格式:{JSON} - -JSON 字段说明: -- action:只能是 %s / %s / %s / %s -- reason:给后端和日志看的简短说明 -- goal_check:输出 %s 时必填,总结任务完成证据 -- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带,格式 {"name":"工具名","arguments":{...}} - -注意:JSON 中不要包含 speak 字段。给用户看的话放在 标签之后。 - -示例: - -{"action":"%s","reason":"先读取概览再决定微调方向","tool_call":{"name":"get_overview","arguments":{}}} -我先看一下现在的安排分布。 - -{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"swap","arguments":{"task_a":1,"task_b":2}}} -我准备把两项任务对调位置,你确认后执行。 - -{"action":"%s","reason":"微调执行完毕并已校验结果","goal_check":"目标任务类已完成微调,且关键约束满足"} -已完成你的请求。 -`, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAskUser, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionDone, - )) -} - -// BuildExecuteDecisionContractTextV2 返回补齐 abort 协议后的执行输出契约(有 plan 模式)。 -func BuildExecuteDecisionContractTextV2() string { - return strings.TrimSpace(fmt.Sprintf(` -输出协议(两阶段格式): - -先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 -决策标签格式:{JSON} - -JSON 字段说明: -- action:只能是 %s / %s / %s / %s / %s / %s -- reason:给后端和日志看的简短说明 -- goal_check:输出 %s 或 %s 时必填,对照 done_when 逐条验证 -- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带,格式 {"name":"工具名","arguments":{...}} -- abort:仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."} -- tool_call 与 abort 互斥,禁止同时出现 - -注意:JSON 中不要包含 speak 字段。给用户看的话放在 标签之后。若 action=%s,标签后通常留空。 - -示例: - -{"action":"%s","reason":"先读取事实再决策","tool_call":{"name":"get_overview","arguments":{}}} -我先查看当前安排。 - -{"action":"%s","reason":"步骤完成条件满足","goal_check":"已满足当前步骤 done_when"} -当前步骤完成。 - -{"action":"%s","reason":"流程不应继续执行","abort":{"code":"execute_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}} -`, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAskUser, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionNextPlan, - newagentmodel.ExecuteActionAbort, - )) -} - -// BuildExecuteReActContractTextV2 返回补齐 abort 协议后的自由执行输出契约。 -func BuildExecuteReActContractTextV2() string { - return strings.TrimSpace(fmt.Sprintf(` -输出协议(两阶段格式): - -先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 -决策标签格式:{JSON} - -JSON 字段说明: -- action:只能是 %s / %s / %s / %s / %s -- reason:给后端和日志看的简短说明 -- goal_check:输出 %s 时必填,总结任务完成证据 -- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带,格式 {"name":"工具名","arguments":{...}} -- abort:仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."} -- tool_call 与 abort 互斥,禁止同时出现 - -注意:JSON 中不要包含 speak 字段。给用户看的话放在 标签之后。若 action=%s,标签后通常留空。 - -示例: - -{"action":"%s","reason":"先获取事实再决策","tool_call":{"name":"get_overview","arguments":{}}} -我先读取当前安排。 - -{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"move","arguments":{"task_id":5,"new_day":3,"new_slot_start":1}}} -我准备执行写操作,等待你确认。 - -{"action":"%s","reason":"当前流程不应继续执行","abort":{"code":"domain_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}} -`, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAskUser, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionDone, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionAbort, - newagentmodel.ExecuteActionContinue, - newagentmodel.ExecuteActionConfirm, - newagentmodel.ExecuteActionAbort, - )) + return buildExecutePromptWithFormatGuard(executeSystemPromptBaseReAct) } // BuildExecuteMessages 组装执行阶段消息。 @@ -304,6 +57,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState) 计划步骤强约束: - 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。 - 若全部计划已完成:输出 action=done,并在 goal_check 总结完成证据。 +- goal_check 字段类型必须为 string,不要输出对象或数组。 - 若未完成但缺少关键信息:输出 action=ask_user。`) } @@ -324,6 +78,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState) - 当前步骤完成判定(done_when):%s - 未满足 done_when 时:只能输出 continue / confirm / ask_user,禁止输出 next_plan。 - 满足 done_when 时:优先输出 action=next_plan,并在 goal_check 逐条对照 done_when 给出证据。 +- goal_check 字段类型固定为 string(示例:"已满足 done_when:...;证据:..."),禁止输出 {"done_when":"...","evidence":"..."}。 - 禁止跳步:不要提前执行后续步骤。`, base, current, total, stepContent, doneWhen)) } @@ -332,13 +87,15 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState) func buildExecutePromptWithFormatGuard(base string) string { base = strings.TrimSpace(base) guard := strings.TrimSpace(` -补充 JSON 约束: -1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。 -2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。 -3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。 -4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。 -5. action=continue / ask_user / confirm 时,标签后的正文必须是非空自然语言。 -6. 标签内只放 JSON,不要放自然语言。`) +输出协议硬约束: +1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。 +2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。 +3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空。 +4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。 +5. 只有 action=abort 时才允许输出 abort 字段。 +6. 标签内只放 JSON,不要放自然语言。 +7. 不要在 标签前输出任何前言、寒暄、解释或铺垫;给用户看的正文只能放在 之后。 +8. 任何动作都不得擅自超出用户当前明确意图;用户没让你做的下一步,不要自作主张推进。`) if base == "" { return guard } @@ -351,37 +108,17 @@ func buildExecuteStrictJSONUserPrompt() string { 请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。 输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 -补充格式要求: -- JSON 中不要包含 speak 字段,给用户看的话放在 标签之后 -- 与当前 action 无关的字段直接省略,不要输出空字符串、空对象、空数组或 null 占位 -- tool_call 只能写 {"name":"工具名","arguments":{...}},且每轮最多一个 -- 不要写 {"tool_call":{"name":"工具名","parameters":{...}}} -- 非 abort 动作不要输出 abort 字段 -- action 为 continue / ask_user / confirm 时,标签后必须输出非空正文 +执行提醒: +- JSON 中不要包含 speak 字段;给用户看的话放在 标签之后 +- 不要在 标签之前输出任何文字;哪怕只有一句“我先看下”也不行 +- 日程写工具(place/move/swap/batch_move/unplace)一律走 action=confirm +- 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤 - 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user -- 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm -- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化 -- 若上下文已明确"当前未收到微调偏好,本轮先收口",请直接输出 action=done -- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch -- spread_even 用于"范围内均匀化",必须先用 query_target_tasks 明确目标任务集合 -- 多任务调整默认先调用 query_target_tasks(enqueue=true),再用 queue_pop_head 逐项处理 -- queue_apply_head_move 只能用于 current 任务;若当前任务无法落位,调用 queue_skip_head 后继续 -- batch_move 一次最多 2 条;超过 2 条必须改走队列逐项处理 -`) -} - -// BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。 -func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string { - return strings.TrimSpace(` -请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。 -输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 -`) -} - -// BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。 -func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string { - return strings.TrimSpace(` -请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。 -输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 +- 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞 +- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done +- web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充 +- upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done +- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user +- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程” `) } diff --git a/backend/newAgent/prompt/execute_context.go b/backend/newAgent/prompt/execute_context.go index 8056b84..d87d60d 100644 --- a/backend/newAgent/prompt/execute_context.go +++ b/backend/newAgent/prompt/execute_context.go @@ -12,9 +12,8 @@ import ( ) const ( - // executeHistoryKindKey 用于在 history 中打运行态标记,供 prompt 分层识别。 - // 说明:loop_closed / step_advanced 等边界标记仍由节点层写入,但 prompt 层已不再消费它们—— - // 因为 msg1/msg2 已经按"真实对话流 + 当前活跃 ReAct 记录"重构,不再做 msg2→msg1 的归档搬运。 + // executeHistoryKindKey 用于在 history 里区分普通用户消息与后端注入的纠错提示。 + // 这里负责“识别并过滤”,不负责写入该标记。 executeHistoryKindKey = "newagent_history_kind" executeHistoryKindCorrectionUser = "llm_correction_prompt" ) @@ -31,20 +30,29 @@ type executeLoopRecord struct { Observation string } -// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。 +type conversationTurn struct { + Role string + Content string +} + +type executeLatestToolRecord struct { + ToolName string + Observation string +} + +// buildExecuteStageMessages 组装 execute 阶段的四段式消息。 // -// 消息结构(固定): -// 1. message[0] 固定 prompt(规则 + 微调硬引导 + 输出约束 + 工具简表) -// 2. message[1] 历史上下文(真实对话流 + 早期 ReAct 摘要) -// 3. message[2] 当轮 ReAct Loop 窗口(thought/reason + tool_call + observation 绑定展示) -// 4. message[3] 当前执行状态(轮次、模式、plan 步骤、任务类、相关记忆等) +// 1. msg0:系统提示 + 动态规则包 + 工具简表。 +// 2. msg1:真实对话流,只保留 user 和 assistant speak。 +// 3. msg2:当前 ReAct tool loop 记录。 +// 4. msg3:执行状态、阶段约束、记忆和本轮指令。 func buildExecuteStageMessages( stageSystemPrompt string, state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string, ) []*schema.Message { - msg0 := buildExecuteMessage0(stageSystemPrompt, ctx) + msg0 := buildExecuteMessage0(stageSystemPrompt, state, ctx) msg1 := buildExecuteMessage1V3(ctx) msg2 := buildExecuteMessage2V3(ctx) msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt) @@ -57,27 +65,30 @@ func buildExecuteStageMessages( } } -// buildExecuteMessage0 生成固定规则消息,并附带工具简表。 -func buildExecuteMessage0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string { +// buildExecuteMessage0 生成 execute 阶段的固定规则消息。 +// +// 1. 先拼基础 system prompt,保证身份和输出协议稳定。 +// 2. 再按当前 domain / packs 注入动态规则包,让模型先读到边界。 +// 3. 最后再附工具简表,避免模型只看到工具不看到纪律。 +func buildExecuteMessage0(stageSystemPrompt string, state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) string { base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) if base == "" { - base = "你是 SmartMate 执行器,请继续 execute 阶段。" + base = "你是 SmartMate 执行器,请继续当前执行阶段。" } - toolCatalog := renderExecuteToolCatalogCompact(ctx) - if toolCatalog == "" { - return base + rulePackSection, _ := renderExecuteRulePackSection(state, ctx) + if rulePackSection != "" { + base += "\n\n" + rulePackSection } - return base + "\n\n" + toolCatalog + + toolCatalog := renderExecuteToolCatalogCompact(ctx, state) + if toolCatalog != "" { + base += "\n\n" + toolCatalog + } + return base } -// buildExecuteMessage1V3 只渲染"真实对话流 + 阶段锚点"。 -// -// 改造说明: -// 1. msg1 只保留 user + assistant speak 组成的真实对话历史,全量注入; -// 2. tool_call / observation 一律由 msg2 承载,这里不再重复; -// 3. 不再从历史中"归档"上一轮 ReAct 结果到 msg1——归档搬运逻辑已随 splitExecuteLoopRecordsByBoundary 一并移除; -// 4. token 预算由统一压缩层兜底,prompt 层不做提前裁剪。 +// buildExecuteMessage1V3 只渲染真实对话流,不混入 tool observation。 func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string { lines := []string{"历史上下文:"} if ctx == nil { @@ -105,16 +116,13 @@ func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string { } else { lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。") } - return strings.Join(lines, "\n") } -// buildExecuteMessage2V3 承载当前会话中全部 ReAct Loop 记录。 +// buildExecuteMessage2V3 只承载当轮 ReAct loop。 // -// 改造说明: -// 1. 不再按 execute_loop_closed / execute_step_advanced 边界切分"归档/活跃"两段; -// 2. 直接从 history 提取全部 assistant tool_call + 对应 observation 作为当前 Loop 视图; -// 3. 新一轮刚开始(尚未产生 tool_call)时返回明确占位,方便模型识别"干净起点"。 +// 1. 每条记录固定展示 thought / tool_call / observation,方便模型做局部闭环。 +// 2. 如果当前还没有任何 tool loop,明确给“新一轮”占位,避免模型误判缺上下文。 func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string { lines := []string{"当轮 ReAct Loop 记录:"} if ctx == nil { @@ -136,11 +144,19 @@ func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string { return strings.Join(lines, "\n") } +// buildExecuteMessage3 汇总当前执行状态和本轮指令。 +// +// 1. 这里只放“当前轮真正会影响决策”的状态,避免 msg3 继续膨胀。 +// 2. 读工具最近结果只给最新一条摘要,避免旧 observation 重复占上下文。 +// 3. 最后一行固定落到“本轮指令”,保证模型收尾时注意力还在执行目标上。 func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string { lines := []string{"当前执行状态:"} + roughBuildDone := hasExecuteRoughBuildDone(ctx) roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds modeText := "自由执行(无预定义步骤)" + activeDomain := "" + activePacks := []string{} if state != nil { roundUsed = state.RoundUsed if state.MaxRounds > 0 { @@ -149,15 +165,23 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C if state.HasPlan() { modeText = "计划执行(有预定义步骤)" } + activeDomain = strings.TrimSpace(state.ActiveToolDomain) + activePacks = readExecuteActiveToolPacks(state) } + lines = append(lines, fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds), "- 当前模式:"+modeText, ) - // 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。 - // 2. 该锚点用于约束模型只推进当前步骤,避免退化成泛化 ReAct。 - // 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。 + if activeDomain == "" { + lines = append(lines, "- 动态工具区:当前仅激活 context 管理工具。") + } else if len(activePacks) == 0 { + lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,未显式激活 packs。", activeDomain)) + } else { + lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,packs=[%s]。", activeDomain, strings.Join(activePacks, ","))) + } + if state != nil && state.HasPlan() { current, total := state.PlanProgress() lines = append(lines, "计划步骤锚点(强约束):") @@ -170,26 +194,41 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C if doneWhen == "" { doneWhen = "(未提供 done_when,需基于步骤目标给出可验证完成证据)" } - lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total)) - lines = append(lines, "- 当前步骤内容:"+stepContent) - lines = append(lines, "- 当前步骤完成判定(done_when):"+doneWhen) - lines = append(lines, "- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan") - lines = append(lines, "- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据") - lines = append(lines, "- 动作纪律3:禁止跳到后续步骤执行") + lines = append(lines, + fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total), + "- 当前步骤内容:"+stepContent, + "- 当前步骤完成判定(done_when):"+doneWhen, + "- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。", + "- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据。", + "- 动作纪律3:禁止跳到后续步骤执行。", + ) } else { - lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划") - lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据") + lines = append(lines, + "- 当前计划步骤不可读;请先判断是否已完成全部计划。", + "- 若已完成全部计划,输出 done 并给出 goal_check 证据。", + ) } } + + if latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx); latestAnalyze != "" { + lines = append(lines, "- 最近一次诊断:"+latestAnalyze) + } + if latestMutation := renderExecuteLatestMutationSummary(ctx); latestMutation != "" { + lines = append(lines, "- 最近一次写操作:"+latestMutation) + } if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" { lines = append(lines, "- 目标任务类:"+taskClassText) } - lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。") - lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。") - if hasExecuteRoughBuildDone(ctx) { + + lines = append(lines, + "- 啥时候结束Loop:你可以根据工具调用记录自行判断。", + "- 非目标:不重新粗排、不修改无关任务类。", + ) + if roughBuildDone { lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。") } - lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。") + lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回“参数非法”,需先改参再继续。") + if state != nil { if state.AllowReorder { lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。") @@ -197,15 +236,27 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。") } } + + if upsertRuntime := renderTaskClassUpsertRuntime(state); upsertRuntime != "" { + lines = append(lines, "任务类写入运行态:") + lines = append(lines, upsertRuntime) + } + if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" { lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):") lines = append(lines, memoryText) } - // 兼容上层传入的执行指令;若为空则使用固定收口指令。 + latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx) + latestMutation := renderExecuteLatestMutationSummary(ctx) + if nextStep := renderExecuteNextStepHintV2(state, latestAnalyze, latestMutation, roughBuildDone); nextStep != "" { + lines = append(lines, "下一步提示:") + lines = append(lines, "- "+nextStep) + } + instruction := strings.TrimSpace(runtimeUserPrompt) if instruction == "" { - instruction = "请继续当前任务执行阶段,严格输出 JSON。" + instruction = "请继续当前任务执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。" } else { instruction = firstExecuteLine(instruction) } @@ -214,8 +265,12 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C return strings.Join(lines, "\n") } -// renderExecuteToolCatalogCompact 将工具 schema 渲染成简表,避免大段 JSON 示例占用上下文。 -func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) string { +// renderExecuteToolCatalogCompact 将当前 tool schemas 渲染为紧凑简表。 +// +// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。 +// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。 +// 3. P1 阶段隐藏 min_context_switch,避免模型误用已禁能力。 +func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string { if ctx == nil { return "" } @@ -225,36 +280,79 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) str } lines := []string{"可用工具(简表):"} - for i, schemaItem := range schemas { + index := 0 + for _, schemaItem := range schemas { name := strings.TrimSpace(schemaItem.Name) - desc := strings.TrimSpace(schemaItem.Desc) if name == "" { continue } + if shouldHideMinContextSwitchForP1(state, name) { + continue + } + + index++ + desc := strings.TrimSpace(schemaItem.Desc) if desc == "" { desc = "无描述" } - lines = append(lines, fmt.Sprintf("%d. %s:%s", i+1, name, desc)) + lines = append(lines, fmt.Sprintf("%d. %s:%s", index, name, desc)) doc := parseExecuteToolSchema(schemaItem.SchemaText) paramSummary := renderExecuteToolParamSummary(doc.Parameters) lines = append(lines, " 参数:"+paramSummary) + returnType, returnSample := renderExecuteToolReturnHint(name) lines = append(lines, " 返回类型:"+returnType) - lines = append(lines, " 返回示例:"+returnSample) + if shouldRenderExecuteToolReturnSample(name) { + lines = append(lines, " 返回示例:"+returnSample) + } + if callSample := renderExecuteToolCallHint(name); strings.TrimSpace(callSample) != "" { + lines = append(lines, " 调用示例:"+callSample) + } } + if index == 0 { + return "" + } return strings.Join(lines, "\n") } -// renderExecuteToolReturnHint 返回工具的返回类型 + 最小示例。 +func shouldRenderExecuteToolReturnSample(toolName string) bool { + switch strings.ToLower(strings.TrimSpace(toolName)) { + case "query_available_slots", + "query_target_tasks", + "queue_pop_head", + "queue_status", + "queue_apply_head_move", + "queue_skip_head", + "web_search", + "web_fetch", + "analyze_health", + "analyze_rhythm", + "analyze_tolerance", + "upsert_task_class": + return true + default: + return false + } +} + +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":"逆矩阵与矩阵的秩"}]}}}` + default: + return "" + } +} + func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) { returnType = "string(自然语言文本)" switch strings.ToLower(strings.TrimSpace(toolName)) { case "get_overview": - return returnType, "规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)..." + return returnType, "规划窗口共27天...课程占位条目34个...任务清单(已过滤课程)..." case "get_task_info": - return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节" + return returnType, "[35] 第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节" case "query_available_slots": return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}` case "query_target_tasks": @@ -276,7 +374,7 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str case "swap": return returnType, "交换完成:[35]... ↔ [36]..." case "batch_move": - return returnType, "批量移动完成,2个任务全部成功。(单次最多2条)" + return returnType, "批量移动完成,2 个任务全部成功。" case "spread_even": return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。" case "min_context_switch": @@ -287,6 +385,14 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str return "string(JSON字符串)", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}` case "web_fetch": return "string(JSON字符串)", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}` + case "analyze_health": + 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: return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。" } @@ -353,12 +459,11 @@ func renderExecuteToolParamSummary(parameters map[string]any) string { return strings.Join(parts, ";") } -// collectExecuteLoopRecords 从历史中提取 ReAct 记录。 +// collectExecuteLoopRecords 从 history 里提取 thought + tool_call + observation 三元组。 // -// 提取策略: -// 1. 以 assistant tool_call 消息为主键; -// 2. 关联同 ToolCallID 的 tool result 作为 observation; -// 3. 向前回溯最近一条 assistant 文本消息作为 thought/reason。 +// 1. 以 assistant tool_call 为主记录。 +// 2. 用 ToolCallID 去关联 tool observation,保证同轮绑定。 +// 3. thought 只向前取最近一条 assistant 纯文本消息,不跨越到更早的工具调用之前做复杂回溯。 func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord { if len(history) == 0 { return nil @@ -381,12 +486,14 @@ func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord { if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 { continue } + thought := findExecuteThoughtBefore(history, i) for _, call := range msg.ToolCalls { toolName := strings.TrimSpace(call.Function.Name) if toolName == "" { toolName = "unknown_tool" } + toolArgs := compactExecuteText(call.Function.Arguments, 160) if toolArgs == "" { toolArgs = "{}" @@ -424,10 +531,9 @@ func findExecuteThoughtBefore(history []*schema.Message, index int) string { continue } content := compactExecuteText(msg.Content, 140) - if content == "" { - continue + if content != "" { + return content } - return content } return "(未记录)" } @@ -456,18 +562,116 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool { return false } -// conversationTurn 表示对话历史中的一轮交互(user 或 assistant speak)。 -type conversationTurn struct { - Role string - Content string +func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string { + record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{ + "analyze_health": {}, + "analyze_rhythm": {}, + "analyze_tolerance": {}, + }) + if !ok { + return "" + } + return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation) } -// collectExecuteConversationTurns 从历史消息中提取 user + assistant speak 对话流。 +func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext) string { + record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{ + "place": {}, + "move": {}, + "swap": {}, + "batch_move": {}, + "unplace": {}, + "queue_apply_head_move": {}, + "spread_even": {}, + "min_context_switch": {}, + }) + if !ok { + return "" + } + return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation) +} + +func findExecuteLatestToolRecord(ctx *newagentmodel.ConversationContext, allowSet map[string]struct{}) (executeLatestToolRecord, bool) { + if ctx == nil || len(allowSet) == 0 { + return executeLatestToolRecord{}, false + } + history := ctx.HistorySnapshot() + if len(history) == 0 { + return executeLatestToolRecord{}, false + } + + toolNameByCallID := make(map[string]string, len(history)) + for _, msg := range history { + if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 { + continue + } + for _, call := range msg.ToolCalls { + callID := strings.TrimSpace(call.ID) + toolName := strings.TrimSpace(call.Function.Name) + if callID == "" || toolName == "" { + continue + } + toolNameByCallID[callID] = toolName + } + } + + for i := len(history) - 1; i >= 0; i-- { + msg := history[i] + if msg == nil || msg.Role != schema.Tool { + continue + } + callID := strings.TrimSpace(msg.ToolCallID) + if callID == "" { + continue + } + toolName := strings.TrimSpace(toolNameByCallID[callID]) + if toolName == "" { + continue + } + if _, ok := allowSet[toolName]; !ok { + continue + } + return executeLatestToolRecord{ + ToolName: toolName, + Observation: summarizeExecuteToolObservation(msg.Content), + }, true + } + + return executeLatestToolRecord{}, false +} + +func summarizeExecuteToolObservation(raw string) string { + content := strings.TrimSpace(raw) + if content == "" { + return "无返回内容。" + } + + var payload map[string]any + if err := json.Unmarshal([]byte(content), &payload); err == nil && len(payload) > 0 { + if toolName := strings.TrimSpace(asExecuteString(payload["tool"])); toolName == "analyze_health" { + return summarizeExecuteAnalyzeHealthObservationV2(payload) + } + for _, key := range []string{"result", "message", "reason", "error"} { + if text := strings.TrimSpace(asExecuteString(payload[key])); text != "" { + return compactExecuteText(text, 120) + } + } + if success, ok := payload["success"].(bool); ok { + if success { + return "执行成功。" + } + return "执行失败。" + } + } + + return compactExecuteText(content, 120) +} + +// collectExecuteConversationTurns 只提取 user 和 assistant speak。 // -// 提取规则: -// 1. 只保留 user 消息(排除 correction prompt)和 assistant speak 消息(非空 Content 且无 ToolCalls); -// 2. 全量保留,不再限制轮数和单条长度(token 预算由 execute 层统一管理); -// 3. 返回的条目按原始时间顺序排列。 +// 1. 过滤 correction prompt,避免把后端纠错提示伪装成用户真实意图。 +// 2. 过滤 assistant tool_call 消息,避免 msg1 和 msg2 重复。 +// 3. 保持原始顺序,不在这里裁剪长度。 func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn { if len(history) == 0 { return nil @@ -556,11 +760,44 @@ func renderExecuteTaskClassIDs(state *newagentmodel.CommonState) string { return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ",")) } -// renderExecuteMemoryContext 提取 execute 阶段要注入 msg3 的记忆文本。 -// -// 1. 只读取统一的 memory_context,避免把其他 pinned block 误塞进 prompt。 -// 2. 为空时直接返回空串,保持 msg3 干净。 -// 3. 复用统一记忆渲染逻辑,保证各阶段记忆入口一致。 +// renderExecuteMemoryContext 复用统一记忆入口,避免 execute 私自拼接其他 pinned block。 func renderExecuteMemoryContext(ctx *newagentmodel.ConversationContext) string { return renderUnifiedMemoryContext(ctx) } + +func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string { + if state == nil || !state.TaskClassUpsertLastTried { + return "" + } + + lines := make([]string, 0, 4) + if state.TaskClassUpsertLastSuccess { + lines = append(lines, "- 最近一次 upsert_task_class 成功。") + } else { + lines = append(lines, "- 最近一次 upsert_task_class 失败。") + } + if state.TaskClassUpsertConsecutiveFailures > 0 { + lines = append(lines, fmt.Sprintf("- 连续失败次数:%d", state.TaskClassUpsertConsecutiveFailures)) + } + if len(state.TaskClassUpsertLastIssues) > 0 { + lines = append(lines, "- 需要优先处理 validation.issues:") + for _, issue := range state.TaskClassUpsertLastIssues { + trimmed := strings.TrimSpace(issue) + if trimmed == "" { + continue + } + lines = append(lines, " - "+trimmed) + } + } + if !state.TaskClassUpsertLastSuccess { + 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_context_health.go b/backend/newAgent/prompt/execute_context_health.go new file mode 100644 index 0000000..65d0108 --- /dev/null +++ b/backend/newAgent/prompt/execute_context_health.go @@ -0,0 +1,33 @@ +package newagentprompt + +import ( + "fmt" + "strings" +) + +func fallbackExecuteText(value string, fallback string) string { + if text := strings.TrimSpace(value); text != "" { + return text + } + return fallback +} + +func compactHealthAny(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case bool: + if typed { + return "true" + } + return "false" + case int: + return fmt.Sprintf("%d", typed) + case float64: + return fmt.Sprintf("%.0f", typed) + } + return strings.TrimSpace(fmt.Sprintf("%v", value)) +} diff --git a/backend/newAgent/prompt/execute_context_health_v2.go b/backend/newAgent/prompt/execute_context_health_v2.go new file mode 100644 index 0000000..8d3e61a --- /dev/null +++ b/backend/newAgent/prompt/execute_context_health_v2.go @@ -0,0 +1,106 @@ +package newagentprompt + +import ( + "fmt" + "strings" +) + +// summarizeExecuteAnalyzeHealthObservationV2 把 analyze_health 结果压成更短的单行摘要。 +// +// 职责边界: +// 1. 只保留 execute 下一步真正需要消费的裁决字段,不重复展开整份 metrics。 +// 2. 若存在候选,会优先展示“候选数量 + 前两个候选工具”,帮助模型迅速进入选择题。 +// 3. 这里只做摘要,不负责改变决策含义;真实判定仍以 analyze_health 原始 JSON 为准。 +func summarizeExecuteAnalyzeHealthObservationV2(payload map[string]any) string { + decision, _ := payload["decision"].(map[string]any) + metrics, _ := payload["metrics"].(map[string]any) + rhythmMetrics, _ := metrics["rhythm"].(map[string]any) + tightnessMetrics, _ := metrics["tightness"].(map[string]any) + candidates, _ := decision["candidates"].([]any) + + parts := make([]string, 0, 7) + if text := compactHealthAny(decision["should_continue_optimize"]); text != "" { + parts = append(parts, "continue="+text) + } + if text := strings.TrimSpace(asExecuteString(decision["recommended_operation"])); text != "" { + parts = append(parts, "recommended="+text) + } + if text := strings.TrimSpace(asExecuteString(tightnessMetrics["tightness_level"])); text != "" { + parts = append(parts, "tightness="+text) + } + if text := buildBlockBalanceSummary(rhythmMetrics); text != "" { + parts = append(parts, text) + } + if text := compactHealthAny(decision["is_forced_imperfection"]); text != "" { + parts = append(parts, "forced="+text) + } + if len(candidates) > 0 { + parts = append(parts, fmt.Sprintf("candidates=%d", len(candidates))) + if preview := compactHealthCandidatePreview(candidates); preview != "" { + parts = append(parts, "options="+preview) + } + } + if text := strings.TrimSpace(asExecuteString(decision["primary_problem"])); text != "" { + parts = append(parts, "problem="+compactExecuteText(text, 36)) + } + if len(parts) == 0 { + return "返回了健康裁决结果。" + } + return strings.Join(parts, " | ") +} + +// buildBlockBalanceSummary 把 block_balance 连同正负来源一起压成单段摘要。 +// +// 职责边界: +// 1. 这里只做 execute 摘要层的可读性补充,避免 LLM 只看到 balance=0 却看不到来源。 +// 2. 不改变 analyze_health 原始 JSON 结构;原始结构仍由 metrics.rhythm 提供完整字段。 +// 3. 若三个字段都缺失,则直接留空,避免构造误导性的默认值。 +func buildBlockBalanceSummary(rhythmMetrics map[string]any) string { + if len(rhythmMetrics) == 0 { + return "" + } + + blockBalance := compactHealthAny(rhythmMetrics["block_balance"]) + fragmentedCount := compactHealthAny(rhythmMetrics["fragmented_count"]) + compressedCount := compactHealthAny(rhythmMetrics["compressed_run_count"]) + if blockBalance == "" && fragmentedCount == "" && compressedCount == "" { + return "" + } + + return fmt.Sprintf( + "block_balance=%s(fragmented=%s,compressed=%s)", + fallbackExecuteText(blockBalance, "?"), + fallbackExecuteText(fragmentedCount, "?"), + fallbackExecuteText(compressedCount, "?"), + ) +} + +func compactHealthCandidatePreview(candidates []any) string { + if len(candidates) == 0 { + return "" + } + preview := make([]string, 0, 2) + for _, raw := range candidates { + item, _ := raw.(map[string]any) + if len(item) == 0 { + continue + } + id := strings.TrimSpace(asExecuteString(item["candidate_id"])) + tool := strings.TrimSpace(asExecuteString(item["tool"])) + if id == "" && tool == "" { + continue + } + switch { + case id != "" && tool != "": + preview = append(preview, id+":"+tool) + case id != "": + preview = append(preview, id) + default: + preview = append(preview, tool) + } + if len(preview) >= 2 { + break + } + } + return strings.Join(preview, ",") +} diff --git a/backend/newAgent/prompt/execute_next_step_hint_v2.go b/backend/newAgent/prompt/execute_next_step_hint_v2.go new file mode 100644 index 0000000..a1d7927 --- /dev/null +++ b/backend/newAgent/prompt/execute_next_step_hint_v2.go @@ -0,0 +1,104 @@ +package newagentprompt + +import ( + "fmt" + "strings" + + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" +) + +// renderExecuteNextStepHintV2 生成 execute.msg3 的轻量方向提示。 +// +// 设计目标: +// 1. 主动优化模式下,只强调“先 analyze_health,再从 candidates 里选”,不再散发额外搜索暗示。 +// 2. 普通链路仍保留必要的业务引导,避免误伤用户明确提出的普通调整请求。 +// 3. 提示只给方向,不替模型代填最终写参数。 +func renderExecuteNextStepHintV2( + state *newagentmodel.CommonState, + latestAnalyze string, + latestMutation string, + roughBuildDone bool, +) string { + if state == nil { + return "" + } + + activeDomain := strings.TrimSpace(state.ActiveToolDomain) + activePacks := newagenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks) + + if state.ActiveOptimizeOnly { + switch { + case activeDomain == "" && roughBuildDone: + return "当前是粗排后主动优化专用模式;先激活 schedule,并只围绕 analyze_health -> move/swap 候选闭环推进。" + case !state.HealthCheckDone: + return "当前是粗排后主动优化专用模式;先调 analyze_health,等待后端给出 candidates,再做选择。" + case !state.HealthIsFeasible || strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "ask_user"): + return "analyze_health 已判定当前更像时间窗或信息约束问题;不要继续挪动,先把冲突或缺失点明确告诉用户。" + case !state.HealthShouldContinueOptimize: + return "analyze_health 已判定当前无需继续主动优化;若用户没有新增要求,直接收口。" + default: + return "当前是粗排后主动优化专用模式;直接从 analyze_health 的 decision.candidates 里选一个合法 move/swap 执行,不要再自己搜索读工具。" + } + } + + if activeDomain == "schedule" && state.HealthCheckDone { + switch { + case !state.HealthShouldContinueOptimize && state.HealthIsForcedImperfection: + return fmt.Sprintf( + "analyze_health 已判定当前更像约束代价:tightness=%s,主问题=%s。优先考虑收口。", + fallbackExecuteText(state.HealthTightnessLevel, "unknown"), + fallbackExecuteText(state.HealthPrimaryProblem, "无"), + ) + case !state.HealthShouldContinueOptimize: + return fmt.Sprintf( + "analyze_health 已判定当前没有更值得继续处理的局部问题:%s。若用户未追加新要求,优先收口。", + fallbackExecuteText(state.HealthPrimaryProblem, "当前可直接收口"), + ) + case state.HealthStagnationCount > 0: + return fmt.Sprintf( + "最近诊断已连续 %d 次无明显改善;若本轮仍不能让主问题变轻,优先收口。当前主问题:%s。", + state.HealthStagnationCount, + fallbackExecuteText(state.HealthPrimaryProblem, "无"), + ) + case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "swap"): + return fmt.Sprintf( + "当前主问题:%s。优先在已有落位之间做局部 swap,别把问题扩散到更远的天数。", + fallbackExecuteText(state.HealthPrimaryProblem, "无"), + ) + case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "move"): + return fmt.Sprintf( + "当前主问题:%s。若要 move,只在近范围合法落点里小修,不要做全窗口搜索。", + fallbackExecuteText(state.HealthPrimaryProblem, "无"), + ) + } + } + + if activeDomain == "" { + if roughBuildDone { + return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。` + } + return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。` + } + + if activeDomain == "schedule" && + strings.Contains(latestMutation, "batch_move") && + (strings.Contains(latestMutation, "缺少") || strings.Contains(latestMutation, "无效")) { + return `当前 batch_move 路径受参数约束;若要处理一批符合同一条件的任务,优先加 packs=["queue"] 逐个处理。` + } + + if activeDomain == "schedule" && + latestAnalyze != "" && + strings.Contains(latestAnalyze, "metrics") && + !containsExecutePack(activePacks, newagenttools.ToolPackQueue) { + return `若诊断已经完成,下一步应转入读事实或写操作,不要重复 analyze_health;涉及同类批量任务时优先考虑 packs=["queue"]。` + } + + if activeDomain == "taskclass" && + state.TaskClassUpsertLastTried && + !state.TaskClassUpsertLastSuccess { + return `先根据 validation.issues 补齐缺失字段,再重试 upsert_task_class,不要直接收口。` + } + + return "" +} diff --git a/backend/newAgent/prompt/execute_rule_packs.go b/backend/newAgent/prompt/execute_rule_packs.go new file mode 100644 index 0000000..5ea0846 --- /dev/null +++ b/backend/newAgent/prompt/execute_rule_packs.go @@ -0,0 +1,318 @@ +package newagentprompt + +import ( + "fmt" + "strings" + "time" + + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" +) + +const ( + executeRulePackCoreMin = "core_min" + executeRulePackSafetyHard = "safety_hard" + executeRulePackContextProtocol = "context_protocol" + executeRulePackModePlan = "mode_plan" + executeRulePackModeReAct = "mode_react" + executeRulePackDomainSchedule = "domain_schedule" + executeRulePackDomainTaskClass = "domain_taskclass" + executeRulePackScheduleMutation = "schedule_mutation" + executeRulePackScheduleAnalyze = "schedule_analyze" + executeRulePackScheduleWeb = "schedule_web" + executeRulePackMicroRoughDone = "micro_rough_build_done" + executeRulePackMicroDiagLoop = "micro_diag_tune_loop" + executeRulePackMicroQueue = "micro_queue_chain" + executeRulePackMicroTaskRetry = "micro_taskclass_retry" +) + +const executeSystemPromptBaseWithPlan = ` +你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。 +你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 +你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 +你当前处于“计划执行”模式。你必须围绕当前计划步骤推进,并通过 SMARTFLOW_DECISION 输出结构化动作。` + +const executeSystemPromptBaseReAct = ` +你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。 +你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。 +你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。 +你当前处于“自由执行(ReAct)”模式。你需要根据当前目标自主推进、按需调用工具,并通过 SMARTFLOW_DECISION 输出结构化动作。` + +type executeRulePack struct { + Name string + Content string +} + +// renderExecuteRulePackSection 渲染 execute.msg0 的动态规则包区域。 +// +// 1. 这里负责“选哪些包 + 以什么顺序展示”,不负责工具目录本身。 +// 2. 固定先放通用硬约束,再放 mode/domain/micro 包,保证模型先读边界后读特例。 +// 3. 如果没有任何可展示规则包,则直接返回空串,避免无意义占位。 +func renderExecuteRulePackSection(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) (string, []string) { + packs := selectExecuteRulePacks(state, ctx) + if len(packs) == 0 { + return "", nil + } + + lines := []string{"执行规则包(msg0 动态注入):"} + names := make([]string, 0, len(packs)) + for _, pack := range packs { + content := strings.TrimSpace(pack.Content) + if content == "" { + continue + } + lines = append(lines, fmt.Sprintf("[%s]", pack.Name)) + lines = append(lines, content) + names = append(names, pack.Name) + } + if len(names) == 0 { + return "", nil + } + return strings.Join(lines, "\n"), names +} + +func selectExecuteRulePacks(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []executeRulePack { + selected := make([]executeRulePack, 0, 8) + seen := map[string]bool{} + + appendPack := func(pack executeRulePack) { + name := strings.TrimSpace(pack.Name) + if name == "" || seen[name] { + return + } + seen[name] = true + selected = append(selected, pack) + } + + appendPack(buildExecuteCoreMinPack()) + appendPack(buildExecuteSafetyHardPack()) + appendPack(buildExecuteContextProtocolPack()) + + if state != nil && state.HasPlan() { + appendPack(buildExecuteModePlanPack()) + } else { + appendPack(buildExecuteModeReActPack()) + } + + switch normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) { + case "schedule": + activePacks := readExecuteActiveToolPacks(state) + appendPack(buildExecuteSchedulePack()) + if hasExecutePack(activePacks, newagenttools.ToolPackQueue) { + appendPack(buildExecuteQueueMicroPack()) + } + if hasExecutePack(activePacks, newagenttools.ToolPackMutation) { + appendPack(buildExecuteScheduleMutationPack()) + } + if hasExecutePack(activePacks, newagenttools.ToolPackAnalyze) { + appendPack(buildExecuteScheduleAnalyzePackV2()) + } + if hasExecutePack(activePacks, newagenttools.ToolPackWeb) { + appendPack(buildExecuteScheduleWebPack()) + } + case "taskclass": + appendPack(buildExecuteTaskClassPack()) + } + + if hasExecuteRoughBuildDone(ctx) { + appendPack(buildExecuteRoughDoneMicroPack()) + } + if shouldInjectExecuteDiagLoopPack(state, ctx) { + appendPack(buildExecuteDiagLoopMicroPackV2()) + } + if state != nil && state.TaskClassUpsertLastTried && !state.TaskClassUpsertLastSuccess { + appendPack(buildExecuteTaskClassRetryMicroPack()) + } + + return selected +} + +func readExecuteActiveToolDomain(state *newagentmodel.CommonState) string { + if state == nil { + return "" + } + return strings.TrimSpace(state.ActiveToolDomain) +} + +func readExecuteActiveToolPacks(state *newagentmodel.CommonState) []string { + if state == nil { + return nil + } + return newagenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks) +} + +func hasExecutePack(packs []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + return false + } + for _, pack := range packs { + if strings.ToLower(strings.TrimSpace(pack)) == target { + return true + } + } + return false +} + +// containsExecutePack 兼容旧调用点。 +// +// 1. 这里只做别名转发,不引入第二套判断口径。 +// 2. 保留它是为了避免下一轮再因为历史调用点而误删。 +func containsExecutePack(packs []string, target string) bool { + return hasExecutePack(packs, target) +} + +func normalizeExecuteToolDomain(domain string) string { + switch strings.ToLower(strings.TrimSpace(domain)) { + case "schedule": + return "schedule" + case "taskclass": + return "taskclass" + default: + return "" + } +} + +func buildExecuteCoreMinPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackCoreMin, + Content: strings.TrimSpace(fmt.Sprintf(` +- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。 +- 用户意图优先:只推进用户当前明确要求;未明确部分优先 ask_user。 +- 先事实后动作:优先读工具补齐事实,再决定下一步。 +- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirm;continue + 写工具无效。 +- 输出格式固定:先 {JSON},再输出用户可见正文。`, + buildExecuteNowAnchorLine())), + } +} + +func buildExecuteNowAnchorLine() string { + now := time.Now() + weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + return fmt.Sprintf("%s(%s,%s)", now.Format("2006-01-02 15:04:05 -07:00"), weekdays[int(now.Weekday())], now.Format("MST")) +} + +func buildExecuteSafetyHardPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackSafetyHard, + Content: strings.TrimSpace(` +- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。 +- 工具参数必须严格使用 schema 字段名,禁止自造别名。 +- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。 +- P1 阶段禁止调用 min_context_switch。 +- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`), + } +} + +func buildExecuteContextProtocolPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackContextProtocol, + Content: strings.TrimSpace(` +- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。 +- 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。 +- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。 +- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。 +- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`), + } +} + +func buildExecuteModePlanPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackModePlan, + Content: strings.TrimSpace(` +- 当前为计划执行模式:必须围绕当前计划步骤推进。 +- 未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。 +- next_plan / done 时,goal_check 必须是字符串,并对照 done_when 给出完成证据。 +- 禁止跳步执行后续计划。`), + } +} + +func buildExecuteModeReActPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackModeReAct, + Content: strings.TrimSpace(` +- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。 +- 如果关键事实无法通过工具补齐,优先 ask_user,不做猜测落库。 +- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`), + } +} + +func buildExecuteSchedulePack() executeRulePack { + return executeRulePack{ + Name: executeRulePackDomainSchedule, + Content: strings.TrimSpace(` +- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。 +- existing 只作事实参考;真正可调对象优先看 suggested。 +- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`), + } +} + +func buildExecuteScheduleMutationPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackScheduleMutation, + Content: strings.TrimSpace(` +- mutation 包负责真正落日程写操作:place / move / swap / batch_move / unplace。 +- 写操作必须走 action=confirm;不要在 continue 里偷跑写工具。 +- 若是主动优化链路,优先在后端给出的合法候选中选择,不要自己再全窗搜索新坑位。`), + } +} + +func buildExecuteQueueMicroPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackMicroQueue, + Content: strings.TrimSpace(` +- queue 包适合“按同一条件逐个处理一批任务”的场景,例如把所有早八任务依次挪走。 +- query_target_tasks 可结合 enqueue=true 先把候选任务入队,再用 queue_pop_head / queue_apply_head_move / queue_skip_head 顺序处理。 +- 当你需要连续处理多条相似任务时,优先走 queue,避免把整批任务细节长期堆在上下文里。`), + } +} + +func buildExecuteScheduleWebPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackScheduleWeb, + Content: strings.TrimSpace(` +- web 包只用于补充通用学习资料或通识信息,不用于捏造个人时间、考试时间、DDL 或排程事实。 +- web_search 先粗搜,web_fetch 再抓正文;不确定时宁可不用,也不要把网页结果当成排程事实直接写入。`), + } +} + +func buildExecuteTaskClassPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackDomainTaskClass, + Content: strings.TrimSpace(` +- taskclass 域只负责生成或修正任务类,不代表已经开始排程。 +- upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。 +- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。 +- excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`), + } +} + +func buildExecuteRoughDoneMicroPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackMicroRoughDone, + Content: strings.TrimSpace(` +- 已有 rough_build_done:本轮以微调为主,不要把任务重新当成“未排入”再全量 place。 +- 若当前问题已经可接受,应优先收口,不要为了追求完美继续反复局部打磨。`), + } +} + +func buildExecuteTaskClassRetryMicroPack() executeRulePack { + return executeRulePack{ + Name: executeRulePackMicroTaskRetry, + Content: strings.TrimSpace(` +- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。 +- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`), + } +} + +func shouldInjectExecuteDiagLoopPack(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) bool { + if state == nil || !hasExecuteRoughBuildDone(ctx) { + return false + } + if normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) != "schedule" { + return false + } + activePacks := readExecuteActiveToolPacks(state) + return hasExecutePack(activePacks, newagenttools.ToolPackAnalyze) && + hasExecutePack(activePacks, newagenttools.ToolPackMutation) +} diff --git a/backend/newAgent/prompt/execute_rule_packs_health.go b/backend/newAgent/prompt/execute_rule_packs_health.go new file mode 100644 index 0000000..acde43e --- /dev/null +++ b/backend/newAgent/prompt/execute_rule_packs_health.go @@ -0,0 +1,27 @@ +package newagentprompt + +import "strings" + +func buildExecuteScheduleAnalyzePackV2() executeRulePack { + return executeRulePack{ + Name: executeRulePackScheduleAnalyze, + Content: strings.TrimSpace(` +- analyze 包已激活:优先使用 analyze_health 判断“现在还值不值得继续主动优化”,不要把它当成全能体检表。 +- 若需要维度级细诊断(如 rhythm),再 add packs=["deep_analyze"],不要默认把所有分析都铺开。 +- 在主动优化专用模式里,analyze_health 会直接返回 decision.candidates:这些就是后端已经验证合法、并且复诊后确实变好的 move/swap 候选。 +- 一旦 decision.candidates 已经给出,下一步应直接从候选里选一个去执行;不要再自己搜索 query_target_tasks / query_available_slots。 +- 若 analyze_health 显示 should_continue_optimize=false,优先收口;不要因为“理论上还还能动”就继续局部修补。`), + } +} + +func buildExecuteDiagLoopMicroPackV2() executeRulePack { + return executeRulePack{ + Name: executeRulePackMicroDiagLoop, + Content: strings.TrimSpace(` +- 粗排后的主动优化允许多轮 execute,但每一轮都必须围绕“当前主问题”做局部、小范围、可解释的调整。 +- 在主动优化专用模式里,analyze_health 负责“出候选题”,你只负责在 decision.candidates 里做选择,不负责重新全窗搜点。 +- 若当前问题主要来自时间窗过紧,或所有合法候选都只是平移没有变轻,应接受局部不完美并收口。 +- 若连续两轮诊断没有明显改善,或当前 recommended_operation 已经是 close,应优先收口。 +- 主动优化优先在已有落位之间做选择:swap 优先,move 次之;不要做全窗口搜索。`), + } +} diff --git a/backend/newAgent/prompt/plan.go b/backend/newAgent/prompt/plan.go index c53ab3e..ea32012 100644 --- a/backend/newAgent/prompt/plan.go +++ b/backend/newAgent/prompt/plan.go @@ -8,58 +8,46 @@ import ( "github.com/cloudwego/eino/schema" ) -const planSystemPrompt = ` -你是 SmartMate 的规划器。 -你的职责不是直接执行任务,而是先把用户意图拆成一组清晰、稳定、可逐步执行的自然语言计划,并严格按后端约定的 JSON 协议输出。 +const planSystemPromptCore = ` +你是 SmartMate 的规划器(Planner),只负责规划,不负责执行。 -请遵守以下规则: -1. 只负责规划,不要假装已经调用了工具,也不要伪造执行结果。 -2. 每一轮只推进一步规划;如果信息不足,应明确转成 ask_user,而不是继续硬猜。 -3. 若当前计划仍不完整,就继续围绕当前任务补全计划,不要跳去执行细节。 -4. 若你认为计划已经完整可执行,请返回 action=plan_done,并附带完整 plan_steps。 -5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。 -6. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。 -7. 每次输出前先评估任务复杂度:simple(简单明确,无复杂依赖)、moderate(多步操作,需要一定推理)、complex(需要深度推理、多方案比较或复杂依赖关系)。 -8. 粗排识别规则:若满足以下两个条件,在 action=plan_done 时附加 needs_rough_build=true 和 task_class_ids: - 条件1:用户输入中存在"任务类 ID"字段(见上下文"任务类 ID"部分); - 条件2:用户意图明确是"批量安排/帮我排课/把任务类排进日程"等批量调度需求。 - 满足时:后端会在用户确认计划后自动运行粗排算法(硬性约束已由算法保证,无需 LLM 校验)。 - 你的 plan_steps 应聚焦于"用读写工具优化方案",建议两步: - 第1步:用 get_overview / query_target_tasks / query_available_slots 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等); - 第2步:用 move / batch_move 等写工具微调后,将最终方案展示给用户确认。 - 禁止安排任何"校验/验证约束"步骤——硬性约束由算法兜底,LLM 不需要操心。 +最高优先级规则: +1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。 +2. 事实边界:禁止伪造工具调用和执行结果。 -你会看到: -- 当前阶段与轮次信息 -- 已有完整 plan(如果之前已经规划过) -- 当前步骤(如果已存在) -- 置顶上下文块 -- 可用工具摘要 -- 历史对话 - -请基于这些输入继续规划,而不是重复忽略既有 plan。 -` +规划规则: +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。` // BuildPlanSystemPrompt 返回规划阶段系统提示词。 func BuildPlanSystemPrompt() string { - return strings.TrimSpace(planSystemPrompt) + parts := []string{ + strings.TrimSpace(planSystemPromptCore), + BuildPlanDecisionContractText(), + } + return strings.TrimSpace(strings.Join(parts, "\n\n")) } // BuildPlanMessages 组装规划阶段的 messages。 // -// 职责边界: -// 1. 负责把 state + context 收敛成统一 4 段式规划阶段模型输入; -// 2. 不负责解析模型输出,也不负责判断规划质量; -// 3. msg3 中的状态文本由本函数显式传入,确保统一骨架下仍能看到完整计划与阶段信息。 +// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。 +// 2. msg1 展示真实对话,msg2 展示规划工作区,msg3 仅给最小执行指令与用户本轮输入。 +// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。 func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message { return buildUnifiedStageMessages( ctx, StageMessagesConfig{ - SystemPrompt: BuildPlanSystemPrompt(), - Msg1Content: buildPlanConversationMessage(ctx), - Msg2Content: buildPlanWorkspace(state), - Msg3Suffix: BuildPlanUserPrompt(state, userInput), - Msg3Role: schema.User, + SystemPrompt: BuildPlanSystemPrompt(), + Msg1Content: buildPlanConversationMessage(ctx), + Msg2Content: buildPlanWorkspace(state), + Msg3Suffix: BuildPlanUserPrompt(state, userInput), + Msg3Role: schema.User, + SkipBaseSystemPrompt: true, + UseLiteToolCatalogMsg: true, }, ) } @@ -68,9 +56,9 @@ func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.Conv func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) string { var sb strings.Builder - sb.WriteString("请继续当前任务的规划阶段,严格按 SMARTFLOW_DECISION 标签格式输出。\n") - sb.WriteString("目标:围绕最近对话和规划工作区信息,产出一份稳定、可执行的自然语言计划;若关键信息不足,请明确 ask_user。\n\n") - sb.WriteString(BuildPlanDecisionContractText()) + sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n") + sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n") + sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n") trimmedInput := strings.TrimSpace(userInput) if trimmedInput != "" { @@ -85,40 +73,30 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str // BuildPlanDecisionContractText 返回规划阶段的输出协议说明。 func BuildPlanDecisionContractText() string { return strings.TrimSpace(fmt.Sprintf(` -输出协议(两阶段格式): +输出协议(唯一口径): +1. 先输出:{JSON} +2. 再输出:给用户看的自然语言正文 -先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 -决策标签格式:{JSON} - -JSON 字段说明: +JSON 字段: - action:只能是 %s / %s / %s - reason:给后端和日志看的简短说明 -- complexity:任务复杂度,只能是 simple / moderate / complex -- plan_steps:仅当 action=%s 时允许返回;返回时必须是完整计划,不是增量 +- complexity:只能是 simple / moderate / complex +- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划 - plan_steps[].content:步骤正文,必填 -- plan_steps[].done_when:可选,建议写"什么情况下算这一步做完" -- needs_rough_build:仅当满足粗排识别规则时为 true,否则省略;为 true 时后端自动运行粗排算法 -- task_class_ids:needs_rough_build=true 时必填,从上下文"任务类 ID"字段读取 +- 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 字段。给用户看的话放在 标签之后。 - -合法示例: - -{"action":"%s","reason":"当前信息已足够继续规划","complexity":"moderate"} -我先把计划再收束一下。 - -{"action":"%s","reason":"当前时间范围仍不明确","complexity":"simple"} -你更希望我优先安排今天,还是按整周来规划? - -{"action":"%s","reason":"当前计划已具备执行条件","complexity":"simple","plan_steps":[{"content":"先确认本周可用时间范围","done_when":"拿到明确的可用时间段列表"},{"content":"基于可用时间生成执行安排","done_when":"得到一份用户可确认的安排方案"}]} -计划已经整理好了,我先给你确认一下。 -`, +注意: +- JSON 中不要包含 speak 字段 +- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)`, newagentmodel.PlanActionContinue, newagentmodel.PlanActionAskUser, newagentmodel.PlanActionDone, newagentmodel.PlanActionDone, - newagentmodel.PlanActionContinue, - newagentmodel.PlanActionAskUser, - newagentmodel.PlanActionDone, )) } diff --git a/backend/newAgent/prompt/plan_context.go b/backend/newAgent/prompt/plan_context.go index 43e94ff..922190e 100644 --- a/backend/newAgent/prompt/plan_context.go +++ b/backend/newAgent/prompt/plan_context.go @@ -127,7 +127,25 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string { if tc.StartDate != "" || tc.EndDate != "" { line += fmt.Sprintf(";日期范围:%s ~ %s", tc.StartDate, tc.EndDate) } + if len(tc.ExcludedDaysOfWeek) > 0 { + line += fmt.Sprintf(";排除星期:%v", tc.ExcludedDaysOfWeek) + } + if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" { + line += fmt.Sprintf(";语义画像:%s/%s/%s", + planSemanticValue(tc.SubjectType), + planSemanticValue(tc.DifficultyLevel), + planSemanticValue(tc.CognitiveIntensity), + ) + } lines = append(lines, line) } return strings.Join(lines, "\n") } + +func planSemanticValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "未标注" + } + return trimmed +} diff --git a/backend/newAgent/prompt/unified_context.go b/backend/newAgent/prompt/unified_context.go index 579d5c9..74cc3fc 100644 --- a/backend/newAgent/prompt/unified_context.go +++ b/backend/newAgent/prompt/unified_context.go @@ -1,6 +1,7 @@ package newagentprompt import ( + "fmt" "strings" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" @@ -45,6 +46,13 @@ type StageMessagesConfig struct { // Msg3Role 指定第 4 条消息的角色。 // Execute 继续使用 system,其余节点一般使用 user。 Msg3Role schema.RoleType + + // SkipBaseSystemPrompt 为 true 时,msg0 只使用节点自己的 SystemPrompt, + // 不再拼接 ConversationContext.SystemPrompt。 + SkipBaseSystemPrompt bool + + // UseLiteToolCatalogMsg 为 true 时,msg0 工具目录采用轻量模式(仅名称与职责)。 + UseLiteToolCatalogMsg bool } // buildUnifiedStageMessages 组装统一 4 段式消息骨架。 @@ -58,7 +66,7 @@ func buildUnifiedStageMessages( ctx *newagentmodel.ConversationContext, config StageMessagesConfig, ) []*schema.Message { - msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx) + msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx, config.SkipBaseSystemPrompt, config.UseLiteToolCatalogMsg) msg1 := buildUnifiedMsg1(config.Msg1Content) msg2 := buildUnifiedMsg2(config.Msg2Content) msg3 := buildUnifiedMsg3(ctx, config) @@ -85,19 +93,72 @@ func buildUnifiedMsg3Message(content string, role schema.RoleType) *schema.Messa // 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定; // 2. 若当前节点注入了工具 schema,则附加紧凑工具目录; // 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。 -func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string { - base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) +func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext, skipBaseSystemPrompt bool, useLiteToolCatalog bool) string { + base := "" + if skipBaseSystemPrompt { + base = strings.TrimSpace(stageSystemPrompt) + } else { + base = strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) + } if base == "" { base = "你是 SmartMate 助手,请继续当前阶段。" } - toolCatalog := renderExecuteToolCatalogCompact(ctx) + toolCatalog := renderExecuteToolCatalogCompact(ctx, nil) + if useLiteToolCatalog { + toolCatalog = renderUnifiedToolCatalogLite(ctx) + } if toolCatalog == "" { return base } return base + "\n\n" + toolCatalog } +// renderUnifiedToolCatalogLite 渲染统一阶段可用工具的轻量目录。 +// +// 1. 只展示工具名和一句话职责,避免把 execute 的参数/返回示例污染到 plan/chat/deliver。 +// 2. 目录信息仅用于“能力边界感知”,不承担具体参数指导。 +// 3. 当工具数量过多时保留前若干项并给出省略提示,控制 msg0 体积。 +func renderUnifiedToolCatalogLite(ctx *newagentmodel.ConversationContext) string { + if ctx == nil { + return "" + } + + schemas := ctx.ToolSchemasSnapshot() + if len(schemas) == 0 { + return "" + } + + const maxItems = 18 + lines := []string{"当前可用工具(轻量目录):"} + added := 0 + + for _, item := range schemas { + name := strings.TrimSpace(item.Name) + if name == "" { + continue + } + desc := strings.TrimSpace(item.Desc) + if desc == "" { + lines = append(lines, fmt.Sprintf("- %s", name)) + } else { + lines = append(lines, fmt.Sprintf("- %s:%s", name, desc)) + } + added++ + if added >= maxItems { + break + } + } + + if added == 0 { + return "" + } + if len(schemas) > added { + lines = append(lines, fmt.Sprintf("- 其余 %d 个工具已省略(按需再看)。", len(schemas)-added)) + } + return strings.Join(lines, "\n") +} + // buildUnifiedMsg1 返回节点自行提供的历史视图。 // // 说明: diff --git a/backend/newAgent/router/decision_parser.go b/backend/newAgent/router/decision_parser.go index a699342..d990552 100644 --- a/backend/newAgent/router/decision_parser.go +++ b/backend/newAgent/router/decision_parser.go @@ -17,6 +17,10 @@ var ( // 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。 decisionTagRegex = regexp.MustCompile( `(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)`) + // decisionTagHeadRegex 仅用于识别“起始标签是否已经出现”。 + // 目的:避免模型已经输出了 标签之前的自然语言前言。 + // 仅用于“标签后正文为空”时的兜底展示,不参与 JSON 解析。 + BeforeText string + + // AfterText 是 标签之后的自然语言正文。 + // 这是主协议约定的用户可见文本来源。 + AfterText string + // Fallback=true 表示流中未找到决策标签(超过 500 字符阈值), // RawBuffer 包含全部累积文本,调用方应走 correction 路径。 Fallback bool @@ -51,6 +63,8 @@ type StreamDecisionParser struct { buf strings.Builder decisionFound bool decisionJSON string + beforeText string + afterText string rawBuf string // 用于 fallback/correction } @@ -81,8 +95,13 @@ func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool, text := p.buf.String() match := decisionTagRegex.FindStringSubmatchIndex(text) if match == nil { - // 标签尚未完整,检查 fallback 阈值。 + // 1. 标签尚未完整,检查 fallback 阈值。 + // 2. 仅当“完全没有出现起始标签”时才允许 fallback。 + // 3. 若已经出现起始标签但还没闭合,则继续等待后续 chunk,避免早退。 if len(text) > 500 { + if decisionTagHeadRegex.MatchString(text) { + return "", false, nil + } p.decisionFound = true p.rawBuf = text return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签") @@ -110,13 +129,18 @@ func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool, p.decisionJSON = jsonStr p.rawBuf = text - // 提取标签之后的文本作为 visible。 + // 1. 同时提取标签前/标签后的自然语言片段。 + // 2. 标签后正文仍然作为主协议 visible 返回,保持现有流式链路不变。 + // 3. 标签前前言只记入 Result,供 execute 在“后文为空”时兜底补发。 fullMatch := groups[0] tagEndIdx := strings.Index(text, fullMatch) if tagEndIdx >= 0 { + beforeTag := strings.TrimSpace(text[:tagEndIdx]) afterTag := text[tagEndIdx+len(fullMatch):] afterTag = strings.TrimPrefix(afterTag, "\r\n") afterTag = strings.TrimPrefix(afterTag, "\n") + p.beforeText = beforeTag + p.afterText = afterTag return afterTag, true, nil } @@ -138,6 +162,8 @@ func (p *StreamDecisionParser) DecisionJSON() string { func (p *StreamDecisionParser) Result() *StreamDecisionResult { r := &StreamDecisionResult{ DecisionJSON: p.decisionJSON, + BeforeText: p.beforeText, + AfterText: p.afterText, RawBuffer: p.rawBuf, } if p.rawBuf != "" && p.decisionJSON == "" { diff --git a/backend/newAgent/tools/active_optimize.go b/backend/newAgent/tools/active_optimize.go new file mode 100644 index 0000000..1b8a6e8 --- /dev/null +++ b/backend/newAgent/tools/active_optimize.go @@ -0,0 +1,37 @@ +package newagenttools + +import "strings" + +var activeOptimizeAllowedTools = map[string]struct{}{ + ToolNameContextToolsAdd: {}, + ToolNameContextToolsRemove: {}, + "analyze_health": {}, + "move": {}, + "swap": {}, +} + +// IsToolAllowedInActiveOptimize 判定工具是否允许出现在“粗排后主动优化专用模式”里。 +// +// 职责边界: +// 1. 这里只做场景级白名单裁剪,不参与工具是否已注册、是否被临时禁用、是否需要 confirm 的判断; +// 2. 该白名单只服务于“首次粗排后自动微调”链路,避免 LLM 在主动优化时重新暴露大量读工具; +// 3. context_tools_add/remove 仍保留,是为了兼容系统级动态区协议,但不代表会重新放开其它业务工具。 +func IsToolAllowedInActiveOptimize(name string) bool { + _, ok := activeOptimizeAllowedTools[strings.TrimSpace(name)] + return ok +} + +// FilterSchemasForActiveOptimize 过滤出主动优化专用模式允许暴露给 LLM 的工具 schema。 +func FilterSchemasForActiveOptimize(schemas []ToolSchemaEntry) []ToolSchemaEntry { + if len(schemas) == 0 { + return nil + } + filtered := make([]ToolSchemaEntry, 0, len(schemas)) + for _, item := range schemas { + if !IsToolAllowedInActiveOptimize(item.Name) { + continue + } + filtered = append(filtered, item) + } + return filtered +} diff --git a/backend/newAgent/tools/context_tools.go b/backend/newAgent/tools/context_tools.go new file mode 100644 index 0000000..d6d8598 --- /dev/null +++ b/backend/newAgent/tools/context_tools.go @@ -0,0 +1,305 @@ +package newagenttools + +import ( + "encoding/json" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +type contextToolsAddResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Action string `json:"action"` + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + Mode string `json:"mode,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +type contextToolsRemoveResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Action string `json:"action"` + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + All bool `json:"all,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// NewContextToolsAddHandler 创建 context_tools_add 工具。 +// +// 职责边界: +// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态; +// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState; +// 3. schedule 支持可选 packs;taskclass 目前不支持可选 packs。 +func NewContextToolsAddHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) string { + _ = state + + domain := NormalizeToolDomain(readContextToolString(args["domain"])) + if domain == "" { + return marshalContextToolsAddResult(contextToolsAddResult{ + Tool: ToolNameContextToolsAdd, + Success: false, + Action: "reject", + Error: "参数非法:domain 仅支持 schedule/taskclass", + ErrorCode: "invalid_domain", + }) + } + + mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"]))) + if mode == "" { + mode = "replace" + } + if mode != "replace" && mode != "merge" { + return marshalContextToolsAddResult(contextToolsAddResult{ + Tool: ToolNameContextToolsAdd, + Success: false, + Action: "reject", + Domain: domain, + Error: "参数非法:mode 仅支持 replace/merge", + ErrorCode: "invalid_mode", + }) + } + + packsRaw := readContextToolStringSlice(args["packs"]) + packs, errCode, errText := validateContextPacks(domain, packsRaw, false) + if errCode != "" { + return marshalContextToolsAddResult(contextToolsAddResult{ + Tool: ToolNameContextToolsAdd, + Success: false, + Action: "reject", + Domain: domain, + Error: errText, + ErrorCode: errCode, + }) + } + + // schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。 + if domain == ToolDomainSchedule && len(packsRaw) == 0 { + packs = ResolveEffectiveToolPacks(domain, nil) + } + + return marshalContextToolsAddResult(contextToolsAddResult{ + Tool: ToolNameContextToolsAdd, + Success: true, + Action: "activate", + Domain: domain, + Packs: packs, + Mode: mode, + Message: "已激活工具域,可继续调用对应业务工具。", + }) + } +} + +// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。 +// +// 职责边界: +// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储; +// 2. all=true 表示清空动态区业务工具;domain+packs 表示移除该域下指定二级包; +// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。 +func NewContextToolsRemoveHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) string { + _ = state + + all := readContextToolBool(args["all"]) + domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"]))) + packsRaw := readContextToolStringSlice(args["packs"]) + + // 兼容写法:domain=all 视为清空全部。 + if domainRaw == "all" { + all = true + } + if all { + return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + Tool: ToolNameContextToolsRemove, + Success: true, + Action: "clear_all", + All: true, + Message: "已移除全部业务工具域,仅保留上下文管理工具。", + }) + } + + domain := NormalizeToolDomain(domainRaw) + if domain == "" { + return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + Tool: ToolNameContextToolsRemove, + Success: false, + Action: "reject", + Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true", + ErrorCode: "invalid_domain", + }) + } + + packs, errCode, errText := validateContextPacks(domain, packsRaw, true) + if errCode != "" { + return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + Tool: ToolNameContextToolsRemove, + Success: false, + Action: "reject", + Domain: domain, + Error: errText, + ErrorCode: errCode, + }) + } + + if len(packs) > 0 { + return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + Tool: ToolNameContextToolsRemove, + Success: true, + Action: "deactivate_packs", + Domain: domain, + Packs: packs, + Message: "已移除指定工具包。", + }) + } + + return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + Tool: ToolNameContextToolsRemove, + Success: true, + Action: "deactivate", + Domain: domain, + Message: "已移除指定工具域。", + }) + } +} + +func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) { + normalizedDomain := NormalizeToolDomain(domain) + if normalizedDomain == "" { + return nil, "invalid_domain", "参数非法:domain 非法" + } + if len(packs) == 0 { + return nil, "", "" + } + + if normalizedDomain == ToolDomainTaskClass { + return nil, "unsupported_packs_for_domain", "参数非法:taskclass 暂不支持 packs" + } + + normalized := make([]string, 0, len(packs)) + seen := make(map[string]struct{}, len(packs)) + for _, raw := range packs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + pack := NormalizeToolPack(normalizedDomain, trimmed) + if pack == "" { + return nil, "invalid_pack", "参数非法:存在不支持的 pack" + } + if IsFixedToolPack(normalizedDomain, pack) { + if forRemove { + return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 remove" + } + return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 add" + } + if _, exists := seen[pack]; exists { + continue + } + seen[pack] = struct{}{} + normalized = append(normalized, pack) + } + if len(normalized) == 0 { + return nil, "invalid_pack", "参数非法:packs 为空或无效" + } + return normalized, "", "" +} + +func readContextToolString(raw any) string { + text, _ := raw.(string) + return strings.TrimSpace(text) +} + +func readContextToolStringSlice(raw any) []string { + switch typed := raw.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text := strings.TrimSpace(item) + if text == "" { + continue + } + out = append(out, text) + } + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + continue + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + out = append(out, text) + } + return out + case string: + text := strings.TrimSpace(typed) + if text == "" { + return nil + } + parts := strings.Split(text, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out + default: + return nil + } +} + +func readContextToolBool(raw any) bool { + switch v := raw.(type) { + case bool: + return v + case string: + value := strings.ToLower(strings.TrimSpace(v)) + return value == "1" || value == "true" || value == "yes" + case float64: + return v != 0 + case float32: + return v != 0 + case int: + return v != 0 + case int8: + return v != 0 + case int16: + return v != 0 + case int32: + return v != 0 + case int64: + return v != 0 + default: + return false + } +} + +func marshalContextToolsAddResult(result contextToolsAddResult) string { + raw, err := json.Marshal(result) + if err != nil { + return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}` + } + return string(raw) +} + +func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string { + raw, err := json.Marshal(result) + if err != nil { + return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}` + } + return string(raw) +} diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index 06ef2e3..930c170 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -10,36 +10,62 @@ import ( "github.com/LoveLosita/smartflow/backend/newAgent/tools/web" ) -// ToolHandler 是所有工具的统一执行签名。 +// ToolHandler 约定所有工具的统一执行签名。 +// 职责边界: +// 1. 负责消费当前 ScheduleState 与模型传入参数; +// 2. 返回统一 string 结果,供 execute 节点写回 observation; +// 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。 type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string -// ToolSchemaEntry 是注入给模型的工具说明快照。 +// ToolSchemaEntry 描述注入给模型的工具快照。 type ToolSchemaEntry struct { Name string Desc string SchemaText string } -// DefaultRegistryDeps 描述默认工具注册表可选依赖。 -// -// 说明: -// 1. 这层依赖注入先为后续 websearch / memory 工具预留统一入口; -// 2. 当前即便部分依赖暂未使用,也不应让业务侧再自行 new 底层 Infra; -// 3. 后续新增读工具时,应优先在这里扩展依赖而不是走包级全局变量。 +// DefaultRegistryDeps 描述默认注册表需要的外部依赖。 +// 职责边界: +// 1. 这里只承载工具层需要的依赖注入,不承载业务状态; +// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new; +// 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。 type DefaultRegistryDeps struct { RAGRuntime infrarag.Runtime - // WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。 + // WebSearchProvider 为 nil 时,web_search / web_fetch 仍会注册, + // 但 handler 会返回“暂未启用”的只读 observation,不阻断主流程。 WebSearchProvider web.SearchProvider + + // TaskClassWriteDeps 供 upsert_task_class 调用持久化层。 + TaskClassWriteDeps TaskClassWriteDeps } -// ToolRegistry 管理工具注册、查找与执行。 +// ToolRegistry 管理工具注册、过滤与执行。 type ToolRegistry struct { handlers map[string]ToolHandler schemas []ToolSchemaEntry deps DefaultRegistryDeps } +// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。 +// 设计说明: +// 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, +} + +// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。 +func IsTemporarilyDisabledTool(name string) bool { + return temporaryDisabledTools[strings.TrimSpace(name)] +} + // NewToolRegistry 创建空注册表。 func NewToolRegistry() *ToolRegistry { return NewToolRegistryWithDeps(DefaultRegistryDeps{}) @@ -65,7 +91,14 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl } // Execute 执行指定工具。 +// 职责边界: +// 1. 这里只负责找到 handler 并调用; +// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler; +// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。 func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string { + if r.IsToolTemporarilyDisabled(toolName) { + return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName)) + } handler, ok := r.handlers[toolName] if !ok { return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、")) @@ -73,41 +106,126 @@ func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, a return handler(state, args) } -// HasTool 检查工具是否已注册。 +// HasTool 判断工具是否已注册且当前可见。 func (r *ToolRegistry) HasTool(name string) bool { + if r.IsToolTemporarilyDisabled(name) { + return false + } _, ok := r.handlers[name] return ok } -// ToolNames 返回已注册工具名(按 schema 顺序)。 +// IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态。 +func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool { + return IsTemporarilyDisabledTool(name) +} + +// ToolNames 返回当前可暴露给模型的工具名。 func (r *ToolRegistry) ToolNames() []string { - names := make([]string, 0, len(r.handlers)) + names := make([]string, 0, len(r.schemas)) for _, item := range r.schemas { + if r.IsToolTemporarilyDisabled(item.Name) { + continue + } names = append(names, item.Name) } return names } -// Schemas 返回 schema 快照。 +// Schemas 返回当前可暴露给模型的 schema 快照。 func (r *ToolRegistry) Schemas() []ToolSchemaEntry { - result := make([]ToolSchemaEntry, len(r.schemas)) - copy(result, r.schemas) + result := make([]ToolSchemaEntry, 0, len(r.schemas)) + for _, item := range r.schemas { + if r.IsToolTemporarilyDisabled(item.Name) { + continue + } + result = append(result, item) + } return result } -// IsWriteTool 判断工具是否是写工具(需要 confirm)。 +// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。 +// 职责边界: +// 1. context_tools_add/remove 始终保留,用于动态区协议; +// 2. 仅当工具域已激活时,才暴露该域下可见工具; +// 3. schedule 域支持按 pack 过滤;taskclass 目前只有 core。 +func (r *ToolRegistry) SchemasForActiveDomain(activeDomain string, activePacks []string) []ToolSchemaEntry { + normalizedDomain := NormalizeToolDomain(activeDomain) + effectivePacks := ResolveEffectiveToolPacks(normalizedDomain, activePacks) + effectivePackSet := make(map[string]struct{}, len(effectivePacks)) + for _, pack := range effectivePacks { + effectivePackSet[pack] = struct{}{} + } + + selected := make([]ToolSchemaEntry, 0, len(r.schemas)) + for _, item := range r.schemas { + name := strings.TrimSpace(item.Name) + if r.IsToolTemporarilyDisabled(name) { + continue + } + if IsContextManagementTool(name) { + selected = append(selected, item) + continue + } + if normalizedDomain == "" { + continue + } + + domain, pack, ok := ResolveToolDomainPack(name) + if !ok { + // 兼容历史未建档工具:仅在 schedule 域下继续暴露,避免突然失联。 + if normalizedDomain == ToolDomainSchedule { + selected = append(selected, item) + } + continue + } + if domain != normalizedDomain { + continue + } + if IsFixedToolPack(domain, pack) { + selected = append(selected, item) + continue + } + if _, exists := effectivePackSet[pack]; exists { + selected = append(selected, item) + } + } + + result := make([]ToolSchemaEntry, len(selected)) + copy(result, selected) + return result +} + +// IsToolVisibleInDomain 判断某工具在当前动态区下是否应对模型可见。 +func (r *ToolRegistry) IsToolVisibleInDomain(activeDomain string, activePacks []string, toolName string) bool { + name := strings.TrimSpace(toolName) + if name == "" { + return false + } + for _, item := range r.SchemasForActiveDomain(activeDomain, activePacks) { + if strings.TrimSpace(item.Name) == name { + return true + } + } + return false +} + +// IsWriteTool 判断工具是否属于写工具。 func (r *ToolRegistry) IsWriteTool(name string) bool { - return writeTools[name] + return writeTools[strings.TrimSpace(name)] +} + +// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。 +// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。 +func (r *ToolRegistry) IsScheduleMutationTool(name string) bool { + return scheduleMutationTools[strings.TrimSpace(name)] } // RequiresScheduleState 判断工具是否依赖 ScheduleState。 -// 调用目的:execute 节点据此决定是否允许在 ScheduleState 为 nil 时调用该工具。 func (r *ToolRegistry) RequiresScheduleState(name string) bool { - return !scheduleFreeTools[name] + return !scheduleFreeTools[strings.TrimSpace(name)] } -// ==================== 写工具集合 ==================== - var writeTools = map[string]bool{ "place": true, "move": true, @@ -117,38 +235,83 @@ var writeTools = map[string]bool{ "spread_even": true, "min_context_switch": true, "unplace": true, + "upsert_task_class": true, } -// ==================== 不依赖 ScheduleState 的工具集合 ==================== -// 调用目的:这些工具不需要日程状态即可执行,execute 节点在 ScheduleState 为 nil 时允许调用。 +var scheduleMutationTools = map[string]bool{ + "place": true, + "move": true, + "swap": true, + "batch_move": true, + "queue_apply_head_move": true, + "spread_even": true, + "min_context_switch": true, + "unplace": true, +} +// scheduleFreeTools 描述“即使没有 ScheduleState 也能安全执行”的工具。 var scheduleFreeTools = map[string]bool{ - "web_search": true, - "web_fetch": true, + "web_search": true, + "web_fetch": true, + "upsert_task_class": true, + ToolNameContextToolsAdd: true, + ToolNameContextToolsRemove: true, } -// ==================== 默认注册表 ==================== - -// NewDefaultRegistry 创建默认日程工具注册表。 +// NewDefaultRegistry 创建默认注册表。 func NewDefaultRegistry() *ToolRegistry { return NewDefaultRegistryWithDeps(DefaultRegistryDeps{}) } -// NewDefaultRegistryWithDeps 创建带依赖的默认日程工具注册表。 +// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。 +// 步骤化说明: +// 1. 先注册上下文管理工具,保证动态区协议随时可用; +// 2. 再注册 schedule 域的读、诊断、写工具; +// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。 func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { r := NewToolRegistryWithDeps(deps) - // --- 读工具 --- - r.Register("get_overview", - "获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。", + registerContextTools(r) + registerScheduleReadTools(r) + registerScheduleAnalyzeTools(r) + registerScheduleMutationTools(r) + registerTaskClassTools(r, deps) + registerWebTools(r, deps) + + sort.Slice(r.schemas, func(i, j int) bool { + return r.schemas[i].Name < r.schemas[j].Name + }) + return r +} + +func registerContextTools(r *ToolRegistry) { + r.Register( + ToolNameContextToolsAdd, + "激活指定工具域,并可附带 schedule 二级包 packs。core 固定注入。", + `{"name":"context_tools_add","parameters":{"domain":{"type":"string","required":true,"enum":["schedule","taskclass"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"mode":{"type":"string","enum":["replace","merge"]}}}`, + NewContextToolsAddHandler(), + ) + r.Register( + ToolNameContextToolsRemove, + "移除指定工具域、指定二级包,或清空全部业务工具域(all=true)。core 固定包不支持 remove。", + `{"name":"context_tools_remove","parameters":{"domain":{"type":"string","enum":["schedule","taskclass","all"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"all":{"type":"bool"}}}`, + NewContextToolsRemoveHandler(), + ) +} + +func registerScheduleReadTools(r *ToolRegistry) { + r.Register( + "get_overview", + "获取当前窗口总览:保留课程占位统计,展开任务清单。", `{"name":"get_overview","parameters":{}}`, func(state *schedule.ScheduleState, args map[string]any) string { + _ = args return schedule.GetOverview(state) }, ) - - r.Register("query_range", - "查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。", + r.Register( + "query_range", + "查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。", `{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { day, ok := schedule.ArgsInt(args, "day") @@ -158,41 +321,41 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")) }, ) - - r.Register("query_available_slots", - "查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。", + r.Register( + "query_available_slots", + "查询候选空位池,适合 move 前筛落点。", `{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"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"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueryAvailableSlots(state, args) }, ) - - r.Register("query_target_tasks", - "查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。", + r.Register( + "query_target_tasks", + "查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。", `{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"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"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueryTargetTasks(state, args) }, ) - - r.Register("queue_pop_head", - "弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。", + r.Register( + "queue_pop_head", + "弹出并返回当前队首任务;若已有 current 则复用。", `{"name":"queue_pop_head","parameters":{}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueuePopHead(state, args) }, ) - - r.Register("queue_status", - "查看当前待处理队列状态(pending/current/completed/skipped)。", + r.Register( + "queue_status", + "查看当前队列状态(pending/current/completed/skipped)。", `{"name":"queue_status","parameters":{}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueueStatus(state, args) }, ) - - r.Register("get_task_info", - "查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。", + r.Register( + "get_task_info", + "查看单个任务详情,包括类别、状态与落位。", `{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { taskID, ok := schedule.ArgsInt(args, "task_id") @@ -202,10 +365,63 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.GetTaskInfo(state, taskID) }, ) +} - // --- 写工具 --- - r.Register("place", - "将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。", +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", + "分析学习节奏与切换情况。", + `{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`, + func(state *schedule.ScheduleState, args map[string]any) string { + 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,判断当前是否还值得继续优化,并给出候选。", + `{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`, + func(state *schedule.ScheduleState, args map[string]any) string { + return schedule.AnalyzeHealth(state, args) + }, + ) +} + +func registerScheduleMutationTools(r *ToolRegistry) { + r.Register( + "place", + "将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。", `{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { taskID, ok := schedule.ArgsInt(args, "task_id") @@ -223,9 +439,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.Place(state, taskID, day, slotStart) }, ) - - r.Register("move", - "将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。", + r.Register( + "move", + "将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。", `{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { taskID, ok := schedule.ArgsInt(args, "task_id") @@ -243,9 +459,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.Move(state, taskID, newDay, newSlotStart) }, ) - - r.Register("swap", - "交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。", + r.Register( + "swap", + "交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。", `{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { taskA, ok := schedule.ArgsInt(args, "task_a") @@ -259,9 +475,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.Swap(state, taskA, taskB) }, ) - - r.Register("batch_move", - "原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。", + r.Register( + "batch_move", + "原子性批量移动多个任务。moves 必填。", `{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`, func(state *schedule.ScheduleState, args map[string]any) string { moves, err := schedule.ArgsMoveList(args) @@ -271,25 +487,25 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.BatchMove(state, moves) }, ) - - r.Register("queue_apply_head_move", - "将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。", + r.Register( + "queue_apply_head_move", + "将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。", `{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueueApplyHeadMove(state, args) }, ) - - r.Register("queue_skip_head", - "跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。", + r.Register( + "queue_skip_head", + "跳过当前队首任务,将其标记为 skipped。", `{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueueSkipHead(state, args) }, ) - - r.Register("min_context_switch", - "在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。", + 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) @@ -299,9 +515,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.MinContextSwitch(state, taskIDs) }, ) - - r.Register("spread_even", - "在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。", + 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) @@ -311,9 +527,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.SpreadEven(state, taskIDs, args) }, ) - - r.Register("unplace", - "将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。", + r.Register( + "unplace", + "将一个已落位任务移除,恢复为待安排状态。task_id 必填。", `{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`, func(state *schedule.ScheduleState, args map[string]any) string { taskID, ok := schedule.ArgsInt(args, "task_id") @@ -323,33 +539,37 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { return schedule.Unplace(state, taskID) }, ) +} - // --- Web 搜索读工具 --- - // 1. provider 为 nil 时 handler 返回"暂未启用"的 observation,不会阻断主流程; - // 2. 两个工具均为读操作,走 action=continue + tool_call 模式。 +func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) { + r.Register( + "upsert_task_class", + "创建或更新任务类(统一写入口,必须 confirm)。auto 模式下 start_date/end_date 必须在 task_class 顶层字段。", + `{"name":"upsert_task_class","parameters":{"id":{"type":"int"},"task_class":{"type":"object","required":true},"items":{"type":"array","items":{"type":"object"}},"source":{"type":"string"}}}`, + NewTaskClassUpsertToolHandler(deps.TaskClassWriteDeps), + ) +} + +func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) { webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider) webFetchHandler := web.NewFetchToolHandler(web.NewFetcher()) - r.Register("web_search", - "Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。", + r.Register( + "web_search", + "Web 搜索:根据 query 返回结构化检索结果。query 必填。", `{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { + _ = state return webSearchHandler.Handle(args) }, ) - - r.Register("web_fetch", - "抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。", + r.Register( + "web_fetch", + "抓取指定 URL 的正文内容并做最小清洗。url 必填。", `{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`, func(state *schedule.ScheduleState, args map[string]any) string { + _ = state return webFetchHandler.Handle(args) }, ) - - // 按 schema name 排序,确保输出稳定。 - sort.Slice(r.schemas, func(i, j int) bool { - return r.schemas[i].Name < r.schemas[j].Name - }) - - return r } diff --git a/backend/newAgent/tools/schedule/analyze_health_candidates.go b/backend/newAgent/tools/schedule/analyze_health_candidates.go new file mode 100644 index 0000000..2b97e9d --- /dev/null +++ b/backend/newAgent/tools/schedule/analyze_health_candidates.go @@ -0,0 +1,1123 @@ +package schedule + +import ( + "fmt" + "sort" + "strings" +) + +const ( + healthProblemNone = "" + healthProblemHeavyAdjacent = "heavy_adjacent" + analyzeHealthMinBenefitScore = 1 + + healthCandidateEffectImprove = "improve" + healthCandidateEffectPartialImprove = "partial_improve" + healthCandidateEffectShift = "shift" + healthCandidateEffectNoGain = "no_gain" + healthCandidateEffectRegress = "regress" + healthCandidateEffectClose = "close" +) + +type analyzeHealthCandidate struct { + CandidateID string `json:"candidate_id"` + Tool string `json:"tool,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + Summary string `json:"summary"` + Effect string `json:"effect"` + After analyzeHealthCandidateAfter `json:"after"` +} + +type analyzeHealthCandidateAfter struct { + CanClose bool `json:"can_close"` + PrimaryProblem string `json:"primary_problem"` + RecommendedOperation string `json:"recommended_operation,omitempty"` + HeavyAdjacentDays int `json:"heavy_adjacent_days"` + MaxSwitchCount int `json:"max_switch_count"` + SameTypeRatio float64 `json:"same_type_transition_ratio"` +} + +type analyzeHealthSnapshot struct { + Subjects []analyzeSubjectItem + Days []analyzeContextDay + Rhythm analyzeRhythmMetrics + Tightness analyzeTightnessMetrics + Profile analyzeSemanticProfileMetrics + Feasibility analyzeFeasibility +} + +type analyzeHealthProblem struct { + Kind string + DayIndex int + Summary string + Scope *analyzeProblemScope + Pair *analyzeHeavyAdjacentPair + PreferSwap bool +} + +type analyzeHealthDecisionBase struct { + ShouldContinueOptimize bool + PrimaryProblem string + ProblemScope *analyzeProblemScope + IsForcedImperfection bool + RecommendedOperation string +} + +type analyzeHealthCandidateRanked struct { + Candidate analyzeHealthCandidate + Score int +} + +type analyzeHealthProblemScanResult struct { + Problem analyzeHealthProblem + Candidates []analyzeHealthCandidate + BestScore int + BestCandidateID string + PriorityScore int +} + +type analyzeHealthProblemScoreOnlyResult struct { + Problem analyzeHealthProblem + BestScore int + BestOperation string + PriorityScore int +} + +type analyzeHeavyAdjacentPair struct { + DayIndex int + Left ScheduleTask + Right ScheduleTask +} + +// buildAnalyzeHealthSnapshotFromState 统一计算 analyze_health 需要复用的核心快照。 +// +// 职责边界: +// 1. 这里只负责“从 state 派生节奏/紧度/画像/可行性”; +// 2. 不直接生成 issues / 候选动作,避免分析层和决策层耦死; +// 3. 候选模拟复诊时也复用这条路径,保证前后口径一致。 +func buildAnalyzeHealthSnapshotFromState(state *ScheduleState) analyzeHealthSnapshot { + subjects := computeAnalyzeSubjectMetricsV2(state, true, "") + days := computeAnalyzeContextDaysV2(state) + rhythmOverview := computeAnalyzeRhythmOverviewV2(subjects, days) + rhythm := analyzeRhythmMetrics{ + Overview: rhythmOverview, + Subjects: subjects, + Days: days, + } + return analyzeHealthSnapshot{ + Subjects: subjects, + Days: days, + Rhythm: rhythm, + Tightness: computeAnalyzeTightnessMetrics(state, rhythm), + Profile: computeSemanticProfileMetrics(subjects), + Feasibility: computeHealthFeasibilityV2(state), + } +} + +// buildAnalyzeHealthDecisionBase 生成“不带候选动作”的基础裁决结果。 +// +// 说明: +// 1. 这层只回答“当前是否还有值得继续修的主问题”,不负责枚举具体 move/swap 参数; +// 2. 主分析与候选模拟都会复用这层,避免出现“主诊断口径”和“候选复诊口径”不一致; +// 3. 当前 P1 只把 heavy_adjacent 作为可进入候选枚举的问题,其它问题仅保留指标展示,不驱动继续优化。 +func buildAnalyzeHealthDecisionBase( + state *ScheduleState, + snapshot analyzeHealthSnapshot, +) analyzeHealthDecisionBase { + decision := analyzeHealthDecisionBase{ + PrimaryProblem: "当前没有发现值得继续处理的局部认知问题", + RecommendedOperation: "close", + } + + if !snapshot.Feasibility.IsFeasible { + decision.PrimaryProblem = fmt.Sprintf("当前时间窗容量不足,还缺 %d 节可用容量", snapshot.Feasibility.CapacityGap) + decision.IsForcedImperfection = true + decision.RecommendedOperation = "ask_user" + return decision + } + if snapshot.Profile.MissingCompleteProfileCount > 0 { + decision.PrimaryProblem = fmt.Sprintf("仍有 %d 门任务类缺少完整语义画像,先补齐再谈主动优化", snapshot.Profile.MissingCompleteProfileCount) + decision.RecommendedOperation = "ask_user" + return decision + } + + problem, ok := pickPrimaryHealthProblem(state, snapshot) + if !ok { + return decision + } + decision.PrimaryProblem = problem.Summary + decision.ProblemScope = problem.Scope + + // P1 只支持“高认知相邻”进入候选求解;其他问题先只做观测,不驱动自动挪动。 + if problem.Kind != healthProblemHeavyAdjacent { + return decision + } + + if snapshot.Tightness.TightnessLevel == "locked" { + decision.IsForcedImperfection = true + return decision + } + + decision.RecommendedOperation = "swap" + decision.ShouldContinueOptimize = true + return decision +} + +// buildAnalyzeHealthFinalDecisionBrief 基于当前 active 扫描器语义,生成候选 after 视图所需的最小判定。 +// +// 职责边界: +// 1. 这里只产出 should_continue_optimize / primary_problem / recommended_operation / is_forced_imperfection。 +// 2. 这里通过 score-only 扫描层复用“全局扫描 + 最小收益阈值”的收口规则,但不构造 candidates / summary / after。 +// 3. score-only 扫描只能读取 state/snapshot 并计算最佳收益,绝不能回流到 simulate 后再调用 brief,避免递归闭环。 +func buildAnalyzeHealthFinalDecisionBrief( + state *ScheduleState, + snapshot analyzeHealthSnapshot, +) analyzeHealthDecisionBase { + base := buildAnalyzeHealthDecisionBase(state, snapshot) + decision := analyzeHealthDecisionBase{ + ShouldContinueOptimize: base.ShouldContinueOptimize, + PrimaryProblem: base.PrimaryProblem, + ProblemScope: base.ProblemScope, + IsForcedImperfection: base.IsForcedImperfection, + RecommendedOperation: base.RecommendedOperation, + } + + if !shouldEnterHealthCandidateLoop(base) { + decision.ShouldContinueOptimize = false + return decision + } + + bestScoreOnly, ok := findBestHealthProblemScoreOnly(state, snapshot) + if !ok || bestScoreOnly.Problem.Kind != healthProblemHeavyAdjacent || bestScoreOnly.Problem.Pair == nil { + decision.ShouldContinueOptimize = false + decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题" + decision.ProblemScope = nil + decision.RecommendedOperation = "close" + if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" { + decision.IsForcedImperfection = true + } + return decision + } + + decision.ShouldContinueOptimize = true + decision.PrimaryProblem = bestScoreOnly.Problem.Summary + decision.ProblemScope = bestScoreOnly.Problem.Scope + decision.RecommendedOperation = strings.TrimSpace(bestScoreOnly.BestOperation) + return decision +} + +// pickPrimaryHealthProblem 选择当前最值得处理的局部问题。 +func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) { + best := analyzeHealthProblem{} + bestScore := -1 + bestBalanceAlignment := -1 + for _, day := range snapshot.Rhythm.Days { + if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) { + continue + } + pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex) + if !ok { + continue + } + score := scoreHeavyAdjacentProblem(day, pair) + balanceAlignment := scoreHeavyAdjacentBalanceAlignment(snapshot.Rhythm.Overview.BlockBalance, day) + if score < bestScore { + continue + } + if score == bestScore && balanceAlignment <= bestBalanceAlignment { + continue + } + bestScore = score + bestBalanceAlignment = balanceAlignment + best = analyzeHealthProblem{ + Kind: healthProblemHeavyAdjacent, + DayIndex: day.DayIndex, + Summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex), + Scope: &analyzeProblemScope{ + DayRange: []int{day.DayIndex}, + TaskIDs: []int{pair.Left.StateID, pair.Right.StateID}, + }, + Pair: pair, + PreferSwap: true, + } + } + if best.Kind == "" { + return analyzeHealthProblem{}, false + } + return best, true +} + +// collectRepairableHeavyAdjacentProblems 收集当前所有仍值得扫描的 heavy_adjacent 问题天。 +// +// 职责边界: +// 1. 这里只负责把可扫描的问题天列出来,不负责最终选哪一天。 +// 2. 仍然只收集 heavy_adjacent,继续复用当前“可接受相邻天”放宽语义。 +// 3. 若当天找不到对应相邻任务对,则直接跳过,避免把不完整问题送入候选试算。 +func collectRepairableHeavyAdjacentProblems( + state *ScheduleState, + snapshot analyzeHealthSnapshot, +) []analyzeHealthProblem { + if state == nil { + return nil + } + + out := make([]analyzeHealthProblem, 0, len(snapshot.Rhythm.Days)) + for _, day := range snapshot.Rhythm.Days { + if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) { + continue + } + pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex) + if !ok { + continue + } + out = append(out, analyzeHealthProblem{ + Kind: healthProblemHeavyAdjacent, + DayIndex: day.DayIndex, + Summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex), + Scope: &analyzeProblemScope{ + DayRange: []int{day.DayIndex}, + TaskIDs: []int{pair.Left.StateID, pair.Right.StateID}, + }, + Pair: pair, + PreferSwap: true, + }) + } + return out +} + +// scoreHeavyAdjacentBalanceAlignment 在 heavy_adjacent 同类问题里提供极轻的节奏倾向参考。 +// +// 职责边界: +// 1. 这里只在 heavy_adjacent 候选内部做同分细排,不改变“只处理 heavy_adjacent”的主链路边界。 +// 2. 当整体更偏碎时,优先选择同样更碎的 heavy_adjacent 天;当整体更偏压缩时,优先选择块更长的 heavy_adjacent 天。 +// 3. 若整体 block_balance 接近 0,则返回 0,保持原有排序语义不变。 +func scoreHeavyAdjacentBalanceAlignment(blockBalance int, day analyzeContextDay) int { + switch { + case blockBalance > 0: + return int(day.Fragmentation * 100) + case blockBalance < 0: + return day.MaxBlock*10 - day.SwitchCount + default: + return 0 + } +} + +func scoreHeavyAdjacentProblem(day analyzeContextDay, pair *analyzeHeavyAdjacentPair) int { + score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20) + if pair != nil && pair.Left.TaskClassID > 0 && pair.Right.TaskClassID > 0 && pair.Left.TaskClassID != pair.Right.TaskClassID { + score += 15 + } + return score +} + +func scoreHeavyAdjacentProblemDay(rhythm analyzeRhythmMetrics, dayIndex int) int { + for _, day := range rhythm.Days { + if day.DayIndex != dayIndex { + continue + } + return scoreHeavyAdjacentProblem(day, nil) + } + return 0 +} + +// findBestHealthProblemScoreOnly 扫描当前所有 heavy_adjacent 问题天,并只返回“是否存在过阈值候选”所需的最小信息。 +// +// 步骤化说明: +// 1. 这里只做 score-only 扫描:复用合法 move/swap 枚举与收益计算,但不构造候选文案、after 视图或 summary。 +// 2. 返回值只关心最值得修的问题天、最佳收益和最佳操作类型,供 after brief / 其他只读裁决器复用。 +// 3. 一旦最佳收益未过阈值,就直接返回 false,让上层按正式扫描器语义收口。 +func findBestHealthProblemScoreOnly( + state *ScheduleState, + snapshot analyzeHealthSnapshot, +) (analyzeHealthProblemScoreOnlyResult, bool) { + problems := collectRepairableHeavyAdjacentProblems(state, snapshot) + if len(problems) == 0 { + return analyzeHealthProblemScoreOnlyResult{}, false + } + + results := make([]analyzeHealthProblemScoreOnlyResult, 0, len(problems)) + for _, problem := range problems { + result, ok := buildHealthProblemScoreOnly(state, snapshot, problem) + if !ok { + continue + } + results = append(results, result) + } + return selectBestHealthProblemScoreOnly(results) +} + +// buildHealthProblemScoreOnly 只计算单个问题天的最佳候选收益,不构造任何候选展示字段。 +// +// 职责边界: +// 1. 这里仍然只允许 move / swap,并复用现有合法性过滤与收益口径。 +// 2. 这里不产出 candidate.after / candidate.summary,也不调用 brief helper,避免递归。 +// 3. 这条路径存在的唯一目的,是回答“这个问题天还有没有过阈值收益的候选”。 +func buildHealthProblemScoreOnly( + state *ScheduleState, + snapshot analyzeHealthSnapshot, + problem analyzeHealthProblem, +) (analyzeHealthProblemScoreOnlyResult, bool) { + if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil { + return analyzeHealthProblemScoreOnlyResult{}, false + } + + bestScore := 0 + bestOperation := "" + found := false + + pool := collectSuggestedTaskItems(state) + movable := extractSuggestedProblemTasks(problem.Pair) + if len(movable) == 0 { + return analyzeHealthProblemScoreOnlyResult{}, false + } + + checked := 0 + for _, anchor := range movable { + for _, other := range pool { + if anchor.StateID == other.StateID { + continue + } + if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID { + continue + } + if taskDuration(anchor) != taskDuration(other) { + continue + } + checked++ + score, ok := simulateHealthSwapScoreOnly(state, snapshot, problem, anchor, other) + if ok { + bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "swap", bestScore, bestOperation, found) + } + if checked >= 48 { + goto movePhase + } + } + } + +movePhase: + for _, task := range movable { + placements := enumerateLegalMovePlacements(state, task, 24) + for _, target := range placements { + score, ok := simulateHealthMoveScoreOnly(state, snapshot, problem, task, target) + if ok { + bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "move", bestScore, bestOperation, found) + } + } + } + + if !found { + return analyzeHealthProblemScoreOnlyResult{}, false + } + return analyzeHealthProblemScoreOnlyResult{ + Problem: problem, + BestScore: bestScore, + BestOperation: bestOperation, + PriorityScore: scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex), + }, true +} + +func simulateHealthSwapScoreOnly( + state *ScheduleState, + baseline analyzeHealthSnapshot, + problem analyzeHealthProblem, + anchor ScheduleTask, + other ScheduleTask, +) (int, bool) { + clone := state.Clone() + if clone == nil { + return 0, false + } + result := Swap(clone, anchor.StateID, other.StateID) + if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") { + return 0, false + } + + after := buildAnalyzeHealthSnapshotFromState(clone) + _, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "swap", 4) + if !ok { + return 0, false + } + return applyHealthCandidateRankingBias("swap", score), true +} + +func simulateHealthMoveScoreOnly( + state *ScheduleState, + baseline analyzeHealthSnapshot, + problem analyzeHealthProblem, + task ScheduleTask, + target TaskSlot, +) (int, bool) { + clone := state.Clone() + if clone == nil { + return 0, false + } + result := Move(clone, task.StateID, target.Day, target.SlotStart) + if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") { + return 0, false + } + + after := buildAnalyzeHealthSnapshotFromState(clone) + moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart) + _, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "move", moveCost) + if !ok { + return 0, false + } + return applyHealthCandidateRankingBias("move", score), true +} + +func selectBestHealthProblemScoreOnly( + results []analyzeHealthProblemScoreOnlyResult, +) (analyzeHealthProblemScoreOnlyResult, bool) { + if len(results) == 0 { + return analyzeHealthProblemScoreOnlyResult{}, false + } + + best := analyzeHealthProblemScoreOnlyResult{} + bestSet := false + for _, item := range results { + if !meetsHealthCandidateBenefitThreshold(item.BestScore) { + continue + } + if !bestSet || shouldPreferHealthScoreCandidate( + item.BestScore, + item.PriorityScore, + item.Problem.DayIndex, + best.BestScore, + best.PriorityScore, + best.Problem.DayIndex, + ) { + best = item + bestSet = true + } + } + if !bestSet { + return analyzeHealthProblemScoreOnlyResult{}, false + } + return best, true +} + +func updateBestHealthScoreOnly( + score int, + operation string, + bestScore int, + bestOperation string, + found bool, +) (int, string, bool) { + if !found { + return score, operation, true + } + if score > bestScore { + return score, operation, true + } + if score == bestScore && strings.TrimSpace(operation) < strings.TrimSpace(bestOperation) { + return score, operation, true + } + return bestScore, bestOperation, true +} + +// applyHealthCandidateRankingBias 统一补齐候选最终排序分里的轻量工具偏置。 +// +// 职责边界: +// 1. 这里只负责把“基础收益分”映射成“最终排序分”,避免正式扫描器和 score-only 扫描口径漂移。 +// 2. 当前只有 swap 额外加分;move 保持原分不变。 +// 3. 该函数不参与候选合法性判断,只参与“谁更值得优先处理”的排序。 +func applyHealthCandidateRankingBias(operation string, baseScore int) int { + score := baseScore + if strings.EqualFold(strings.TrimSpace(operation), "swap") { + score += 12 + } + return score +} + +// selectBestHealthProblemScanResult 从所有单天扫描结果中挑出本轮最值得修的一天。 +// +// 职责边界: +// 1. 这里只比较“各天最佳候选”的收益,不拼接多天候选,也不改变候选内容。 +// 2. 先按 benefit_score 选天;若收益相同,再退回到原有问题优先级与天序做稳定 tie-break。 +// 3. 若第一名收益不足最小阈值,则返回 false,交给上层直接收口。 +func selectBestHealthProblemScanResult( + results []analyzeHealthProblemScanResult, +) (analyzeHealthProblemScanResult, bool) { + if len(results) == 0 { + return analyzeHealthProblemScanResult{}, false + } + + best := analyzeHealthProblemScanResult{} + bestSet := false + for _, item := range results { + if !meetsHealthCandidateBenefitThreshold(item.BestScore) { + continue + } + if !bestSet || shouldPreferHealthScoreCandidate( + item.BestScore, + item.PriorityScore, + item.Problem.DayIndex, + best.BestScore, + best.PriorityScore, + best.Problem.DayIndex, + ) { + best = item + bestSet = true + } + } + if !bestSet { + return analyzeHealthProblemScanResult{}, false + } + return best, true +} + +func meetsHealthCandidateBenefitThreshold(score int) bool { + return score >= analyzeHealthMinBenefitScore +} + +func shouldPreferHealthScoreCandidate( + leftScore int, + leftPriority int, + leftDay int, + rightScore int, + rightPriority int, + rightDay int, +) bool { + switch { + case leftScore > rightScore: + return true + case leftScore < rightScore: + return false + case leftPriority > rightPriority: + return true + case leftPriority < rightPriority: + return false + default: + return leftDay < rightDay + } +} + +// buildHealthCandidatesForProblem 为主问题生成“可直接抄去调用写工具”的合法候选。 +// +// 设计说明: +// 1. 候选空间完全由现有写工具的合法性约束定义,尤其复用“前驱/后继顺序边界”; +// 2. 后端会先在内存里模拟,再做一次 analyze_health 同口径复诊; +// 3. 只保留真正减轻问题的 top5,过滤掉平移、无增益和恶化候选。 +func buildHealthCandidatesForProblem( + state *ScheduleState, + snapshot analyzeHealthSnapshot, + problem analyzeHealthProblem, +) []analyzeHealthCandidate { + scan, ok := buildHealthProblemScanResult(state, snapshot, problem) + if !ok { + return nil + } + return scan.Candidates +} + +// buildHealthProblemScanResult 生成单个 heavy_adjacent 问题天的候选扫描结果。 +// +// 步骤化说明: +// 1. 先复用现有 move / swap 枚举与复诊过滤,保证只比较“原本就合法且确实变好”的候选。 +// 2. 再按现有 score 规则排序并截取该天 top5 候选,保持返回给 LLM 的候选语义不变。 +// 3. 额外保留该天最佳候选的 score,供上层做“先选天,再返回该天候选集”的全局比较。 +func buildHealthProblemScanResult( + state *ScheduleState, + snapshot analyzeHealthSnapshot, + problem analyzeHealthProblem, +) (analyzeHealthProblemScanResult, bool) { + if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil { + return analyzeHealthProblemScanResult{}, false + } + + ranked := make([]analyzeHealthCandidateRanked, 0, 16) + ranked = append(ranked, enumerateSwapCandidates(state, snapshot, problem)...) + ranked = append(ranked, enumerateMoveCandidates(state, snapshot, problem)...) + if len(ranked) == 0 { + return analyzeHealthProblemScanResult{}, false + } + + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].Score != ranked[j].Score { + return ranked[i].Score > ranked[j].Score + } + leftTool := strings.TrimSpace(ranked[i].Candidate.Tool) + rightTool := strings.TrimSpace(ranked[j].Candidate.Tool) + if leftTool != rightTool { + return leftTool < rightTool + } + return ranked[i].Candidate.CandidateID < ranked[j].Candidate.CandidateID + }) + + out := make([]analyzeHealthCandidate, 0, minInt(len(ranked), 5)) + seen := make(map[string]struct{}, len(ranked)) + for _, item := range ranked { + key := item.Candidate.Tool + "::" + marshalCandidateArgumentsKey(item.Candidate.Arguments) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item.Candidate) + if len(out) >= 5 { + break + } + } + if len(out) == 0 { + return analyzeHealthProblemScanResult{}, false + } + + return analyzeHealthProblemScanResult{ + Problem: problem, + Candidates: out, + BestScore: ranked[0].Score, + BestCandidateID: ranked[0].Candidate.CandidateID, + PriorityScore: scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex), + }, true +} + +func enumerateSwapCandidates( + state *ScheduleState, + snapshot analyzeHealthSnapshot, + problem analyzeHealthProblem, +) []analyzeHealthCandidateRanked { + pair := problem.Pair + if pair == nil { + return nil + } + pool := collectSuggestedTaskItems(state) + movable := extractSuggestedProblemTasks(pair) + if len(movable) == 0 || len(pool) == 0 { + return nil + } + + out := make([]analyzeHealthCandidateRanked, 0, 12) + checked := 0 + for _, anchor := range movable { + for _, other := range pool { + if anchor.StateID == other.StateID { + continue + } + if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID { + continue + } + if taskDuration(anchor) != taskDuration(other) { + continue + } + checked++ + candidate, score, ok := simulateHealthSwapCandidate(state, snapshot, problem, anchor, other) + if ok { + out = append(out, analyzeHealthCandidateRanked{ + Candidate: candidate, + Score: applyHealthCandidateRankingBias("swap", score), + }) + } + if checked >= 48 { + return out + } + } + } + return out +} + +func enumerateMoveCandidates( + state *ScheduleState, + snapshot analyzeHealthSnapshot, + problem analyzeHealthProblem, +) []analyzeHealthCandidateRanked { + pair := problem.Pair + if pair == nil { + return nil + } + movable := extractSuggestedProblemTasks(pair) + if len(movable) == 0 { + return nil + } + + out := make([]analyzeHealthCandidateRanked, 0, 20) + for _, task := range movable { + placements := enumerateLegalMovePlacements(state, task, 24) + for _, target := range placements { + candidate, score, ok := simulateHealthMoveCandidate(state, snapshot, problem, task, target) + if !ok { + continue + } + out = append(out, analyzeHealthCandidateRanked{Candidate: candidate, Score: score}) + } + } + return out +} + +func simulateHealthSwapCandidate( + state *ScheduleState, + baseline analyzeHealthSnapshot, + problem analyzeHealthProblem, + anchor ScheduleTask, + other ScheduleTask, +) (analyzeHealthCandidate, int, bool) { + clone := state.Clone() + if clone == nil { + return analyzeHealthCandidate{}, 0, false + } + result := Swap(clone, anchor.StateID, other.StateID) + if strings.Contains(result, "失败") { + return analyzeHealthCandidate{}, 0, false + } + + after := buildAnalyzeHealthSnapshotFromState(clone) + effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "swap", 4) + if !ok { + return analyzeHealthCandidate{}, 0, false + } + + candidate := analyzeHealthCandidate{ + CandidateID: fmt.Sprintf("swap_%d_%d", anchor.StateID, other.StateID), + Tool: "swap", + Arguments: map[string]any{ + "task_a": anchor.StateID, + "task_b": other.StateID, + }, + Summary: buildHealthCandidateSummary( + fmt.Sprintf("交换 [%d]%s 与 [%d]%s", anchor.StateID, anchor.Name, other.StateID, other.Name), + baseline, + after, + afterDecision, + ), + Effect: effect, + After: buildHealthCandidateAfter(after, afterDecision), + } + return candidate, score, true +} + +func simulateHealthMoveCandidate( + state *ScheduleState, + baseline analyzeHealthSnapshot, + problem analyzeHealthProblem, + task ScheduleTask, + target TaskSlot, +) (analyzeHealthCandidate, int, bool) { + clone := state.Clone() + if clone == nil { + return analyzeHealthCandidate{}, 0, false + } + result := Move(clone, task.StateID, target.Day, target.SlotStart) + if strings.Contains(result, "失败") { + return analyzeHealthCandidate{}, 0, false + } + + after := buildAnalyzeHealthSnapshotFromState(clone) + moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart) + effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "move", moveCost) + if !ok { + return analyzeHealthCandidate{}, 0, false + } + + candidate := analyzeHealthCandidate{ + CandidateID: fmt.Sprintf("move_%d_%d_%d", task.StateID, target.Day, target.SlotStart), + Tool: "move", + Arguments: map[string]any{ + "task_id": task.StateID, + "new_day": target.Day, + "new_slot_start": target.SlotStart, + }, + Summary: buildHealthCandidateSummary( + fmt.Sprintf("移动 [%d]%s 到第 %d 天第 %d 节", task.StateID, task.Name, target.Day, target.SlotStart), + baseline, + after, + afterDecision, + ), + Effect: effect, + After: buildHealthCandidateAfter(after, afterDecision), + } + return candidate, score, true +} + +func enumerateLegalMovePlacements(state *ScheduleState, task ScheduleTask, limit int) []TaskSlot { + if state == nil || len(task.Slots) == 0 || limit <= 0 { + return nil + } + duration := taskDuration(task) + if duration <= 0 { + return nil + } + + out := make([]TaskSlot, 0, limit) + for day := 1; day <= state.Window.TotalDays; day++ { + for _, gap := range findFreeRangesOnDay(state, day) { + maxStart := gap.slotEnd - duration + 1 + for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ { + target := TaskSlot{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1} + if sameTaskSlots(task.Slots, []TaskSlot{target}) { + continue + } + if err := validateLocalOrderForSinglePlacement(state, task.StateID, []TaskSlot{target}); err != nil { + continue + } + if conflict := findConflict(state, target.Day, target.SlotStart, target.SlotEnd, task.StateID); conflict != nil { + continue + } + out = append(out, target) + if len(out) >= limit { + return out + } + } + } + } + return out +} + +func evaluateHealthCandidateOutcome( + baseline analyzeHealthSnapshot, + after analyzeHealthSnapshot, + afterState *ScheduleState, + problem analyzeHealthProblem, + operation string, + moveCost int, +) (string, int, analyzeHealthDecisionBase, bool) { + afterDecision := buildAnalyzeHealthFinalDecisionBrief(afterState, after) + effect, score, ok := evaluateHealthCandidateScoreOnly( + baseline, + after, + problem, + operation, + moveCost, + ) + return effect, score, afterDecision, ok +} + +// evaluateHealthCandidateScoreOnly 只计算候选的收益与是否保留,不构造任何 after 视图。 +// +// 职责边界: +// 1. 这里复用当前 active 候选过滤口径:只保留真正改善的候选,并返回排序所需 score。 +// 2. 这里绝不依赖 afterDecision / brief helper,供 score-only 扫描层安全复用。 +// 3. 这样正式扫描器与 after brief 都能共享同一套收益定义,同时避免递归闭环。 +func evaluateHealthCandidateScoreOnly( + baseline analyzeHealthSnapshot, + after analyzeHealthSnapshot, + problem analyzeHealthProblem, + operation string, + moveCost int, +) (string, int, bool) { + baselineRepairable := countRepairableHeavyAdjacentDays(baseline.Rhythm) + afterRepairable := countRepairableHeavyAdjacentDays(after.Rhythm) + afterProblemStill := isRepairableHeavyAdjacentDay(after.Rhythm, problem.DayIndex) + + effect := healthCandidateEffectNoGain + switch { + case afterRepairable < baselineRepairable: + if afterRepairable > 0 { + effect = healthCandidateEffectPartialImprove + } else { + effect = healthCandidateEffectImprove + } + case afterRepairable > baselineRepairable: + effect = healthCandidateEffectRegress + case afterProblemStill: + effect = healthCandidateEffectNoGain + default: + effect = healthCandidateEffectShift + } + if effect != healthCandidateEffectImprove && effect != healthCandidateEffectPartialImprove { + return effect, 0, false + } + + score := (baselineRepairable-afterRepairable)*1000 + + (baseline.Rhythm.Overview.MaxSwitchCount-after.Rhythm.Overview.MaxSwitchCount)*20 + + int((after.Rhythm.Overview.SameTypeTransitionRatio-baseline.Rhythm.Overview.SameTypeTransitionRatio)*100) - + moveCost + if strings.EqualFold(strings.TrimSpace(operation), "swap") { + score += 10 + } + return effect, score, true +} + +func buildHealthCandidateSummary( + actionText string, + baseline analyzeHealthSnapshot, + after analyzeHealthSnapshot, + afterDecision analyzeHealthDecisionBase, +) string { + return fmt.Sprintf( + "%s;可修复的高认知相邻天数 %d -> %d,max_switch_count %d -> %d,same_type_ratio %.2f -> %.2f;复诊后主问题:%s", + actionText, + countRepairableHeavyAdjacentDays(baseline.Rhythm), + countRepairableHeavyAdjacentDays(after.Rhythm), + baseline.Rhythm.Overview.MaxSwitchCount, + after.Rhythm.Overview.MaxSwitchCount, + baseline.Rhythm.Overview.SameTypeTransitionRatio, + after.Rhythm.Overview.SameTypeTransitionRatio, + fallbackHealthProblemText(afterDecision.PrimaryProblem), + ) +} + +func buildHealthCandidateAfter(after analyzeHealthSnapshot, decision analyzeHealthDecisionBase) analyzeHealthCandidateAfter { + return analyzeHealthCandidateAfter{ + CanClose: !decision.ShouldContinueOptimize, + PrimaryProblem: decision.PrimaryProblem, + RecommendedOperation: decision.RecommendedOperation, + HeavyAdjacentDays: after.Rhythm.Overview.HeavyAdjacentDays, + MaxSwitchCount: after.Rhythm.Overview.MaxSwitchCount, + SameTypeRatio: after.Rhythm.Overview.SameTypeTransitionRatio, + } +} + +func buildHealthCloseCandidate(summary string, baseline analyzeHealthSnapshot, decisionBase analyzeHealthDecisionBase) analyzeHealthCandidate { + return analyzeHealthCandidate{ + CandidateID: "close", + Summary: summary, + Effect: healthCandidateEffectClose, + After: buildHealthCandidateAfter(baseline, decisionBase), + } +} + +func countRepairableHeavyAdjacentDays(rhythm analyzeRhythmMetrics) int { + count := 0 + for _, day := range rhythm.Days { + if !day.HeavyAdjacent { + continue + } + if shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) { + continue + } + count++ + } + return count +} + +func isRepairableHeavyAdjacentDay(rhythm analyzeRhythmMetrics, dayIndex int) bool { + for _, day := range rhythm.Days { + if day.DayIndex != dayIndex { + continue + } + return day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) + } + return false +} + +func findHeavyAdjacentPairOnDay(state *ScheduleState, dayIndex int) (*analyzeHeavyAdjacentPair, bool) { + if state == nil || dayIndex <= 0 { + return nil, false + } + tasks := collectPlacedTaskBlocksOnDay(state, dayIndex) + if len(tasks) < 2 { + return nil, false + } + for i := 1; i < len(tasks); i++ { + left := tasks[i-1] + right := tasks[i] + if strings.TrimSpace(left.Category) == "" || strings.TrimSpace(right.Category) == "" { + continue + } + if strings.TrimSpace(left.Category) == strings.TrimSpace(right.Category) { + continue + } + if !isHighIntensityTaskForHealth(state, left) || !isHighIntensityTaskForHealth(state, right) { + continue + } + pair := analyzeHeavyAdjacentPair{ + DayIndex: dayIndex, + Left: left, + Right: right, + } + return &pair, true + } + return nil, false +} + +func collectPlacedTaskBlocksOnDay(state *ScheduleState, dayIndex int) []ScheduleTask { + if state == nil || dayIndex <= 0 { + return nil + } + out := make([]ScheduleTask, 0) + for _, task := range state.Tasks { + if len(task.Slots) == 0 || isCourseScheduleTask(task) { + continue + } + for _, slot := range task.Slots { + if slot.Day != dayIndex { + continue + } + out = append(out, task) + break + } + } + sort.SliceStable(out, func(i, j int) bool { + left := out[i].Slots[0] + right := out[j].Slots[0] + if left.Day != right.Day { + return left.Day < right.Day + } + if left.SlotStart != right.SlotStart { + return left.SlotStart < right.SlotStart + } + if left.SlotEnd != right.SlotEnd { + return left.SlotEnd < right.SlotEnd + } + return out[i].StateID < out[j].StateID + }) + return out +} + +func isHighIntensityTaskForHealth(state *ScheduleState, task ScheduleTask) bool { + meta := findTaskClassMetaForTask(state, task) + if meta == nil { + return false + } + return isHighIntensityMeta(*meta) +} + +func findTaskClassMetaForTask(state *ScheduleState, task ScheduleTask) *TaskClassMeta { + if state == nil { + return nil + } + if task.TaskClassID > 0 { + for i := range state.TaskClasses { + if state.TaskClasses[i].ID == task.TaskClassID { + return &state.TaskClasses[i] + } + } + } + return findTaskClassMetaByName(state, task.Category) +} + +func extractSuggestedProblemTasks(pair *analyzeHeavyAdjacentPair) []ScheduleTask { + if pair == nil { + return nil + } + out := make([]ScheduleTask, 0, 2) + if IsSuggestedTask(pair.Left) { + out = append(out, pair.Left) + } + if IsSuggestedTask(pair.Right) { + out = append(out, pair.Right) + } + return out +} + +func fallbackHealthProblemText(value string) string { + text := strings.TrimSpace(value) + if text == "" { + return "可直接收口" + } + return text +} + +func marshalCandidateArgumentsKey(args map[string]any) string { + if len(args) == 0 { + return "" + } + parts := make([]string, 0, len(args)) + for key, value := range args { + parts = append(parts, fmt.Sprintf("%s=%v", key, value)) + } + sort.Strings(parts) + return strings.Join(parts, "|") +} + +func minInt(a, b int) int { + if a <= b { + return a + } + return b +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/backend/newAgent/tools/schedule/analyze_health_decision_v2.go b/backend/newAgent/tools/schedule/analyze_health_decision_v2.go new file mode 100644 index 0000000..2c3890d --- /dev/null +++ b/backend/newAgent/tools/schedule/analyze_health_decision_v2.go @@ -0,0 +1,124 @@ +package schedule + +import "strings" + +// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。 +// +// 职责边界: +// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。 +// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。 +// 3. 若没有真正改善的候选,则明确返回 close,避免把 LLM 推回开放式全窗搜索。 +func buildAnalyzeHealthDecisionV2( + 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, + ), + } + + if !shouldEnterHealthCandidateLoop(base) { + decision.Candidates = []analyzeHealthCandidate{ + buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base), + } + decision.ShouldContinueOptimize = false + return decision + } + + bestScan, ok := findBestHealthProblemScanResult(state, snapshot) + if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil { + decision.Candidates = []analyzeHealthCandidate{ + buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base), + } + decision.ShouldContinueOptimize = false + decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题" + decision.ProblemScope = nil + 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.PrimaryProblem = bestScan.Problem.Summary + decision.ProblemScope = bestScan.Problem.Scope + decision.Candidates = append(decision.Candidates, bestScan.Candidates...) + decision.Candidates = append(decision.Candidates, + buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base), + ) + decision.ShouldContinueOptimize = true + decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool) + decision.ImprovementSignal = buildHealthImprovementSignal( + snapshot.Rhythm, + snapshot.Tightness, + decision.ProblemScope, + decision.RecommendedOperation, + snapshot.Profile, + snapshot.Feasibility, + ) + return decision +} + +// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。 +// +// 步骤化说明: +// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。 +// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。 +// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。 +func findBestHealthProblemScanResult( + state *ScheduleState, + snapshot analyzeHealthSnapshot, +) (analyzeHealthProblemScanResult, bool) { + problems := collectRepairableHeavyAdjacentProblems(state, snapshot) + if len(problems) == 0 { + return analyzeHealthProblemScanResult{}, false + } + + results := make([]analyzeHealthProblemScanResult, 0, len(problems)) + for _, problem := range problems { + scan, ok := buildHealthProblemScanResult(state, snapshot, problem) + if !ok { + continue + } + results = append(results, scan) + } + return selectBestHealthProblemScanResult(results) +} + +// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。 +// +// 说明: +// 1. 只有 base 已判定“值得继续优化”时才放行。 +// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。 +// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。 +func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool { + if !base.ShouldContinueOptimize { + return false + } + switch strings.TrimSpace(base.RecommendedOperation) { + case "move", "swap": + return true + default: + return false + } +} diff --git a/backend/newAgent/tools/schedule/analyze_tools.go b/backend/newAgent/tools/schedule/analyze_tools.go new file mode 100644 index 0000000..35d4af8 --- /dev/null +++ b/backend/newAgent/tools/schedule/analyze_tools.go @@ -0,0 +1,1478 @@ +package schedule + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +const ( + analyzeSeverityCritical = "critical" + analyzeSeverityWarning = "warning" + analyzeSeverityInfo = "info" +) + +type analyzeMetricSchemaItem struct { + Description string `json:"description"` + Unit string `json:"unit,omitempty"` + Direction string `json:"direction,omitempty"` +} + +type analyzeIssueTrigger struct { + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold float64 `json:"threshold"` + Actual float64 `json:"actual"` +} + +type analyzeIssueItem struct { + IssueID string `json:"issue_id"` + Dimension string `json:"dimension"` + Severity string `json:"severity"` + Trigger *analyzeIssueTrigger `json:"trigger,omitempty"` +} + +type analyzeCandidateScope struct { + DayRange []int `json:"day_range"` + Categories []string `json:"categories"` + TaskPool string `json:"task_pool"` +} + +type analyzeNextAction struct { + ActionID string `json:"action_id"` + Priority int `json:"priority"` + IntentCode string `json:"intent_code"` + TargetFilter map[string]any `json:"target_filter"` + SlotFilter map[string]any `json:"slot_filter"` + CandidateScope analyzeCandidateScope `json:"candidate_scope"` + RequiredReads []string `json:"required_reads"` + SuccessCriteria map[string]any `json:"success_criteria"` + CandidateWriteTools []string `json:"candidate_write_tools"` +} + +type analyzeFeasibility struct { + IsFeasible bool `json:"is_feasible"` + CapacityGap int `json:"capacity_gap"` + ReasonCode string `json:"reason_code"` +} + +type analyzeEnvelope struct { + Tool string `json:"tool"` + Success bool `json:"success"` + MetricSchema map[string]analyzeMetricSchemaItem `json:"metric_schema"` + Metrics any `json:"metrics"` + Issues []analyzeIssueItem `json:"issues"` + NextActions []analyzeNextAction `json:"next_actions"` + Feasibility *analyzeFeasibility `json:"feasibility,omitempty"` + Decision *analyzeHealthDecision `json:"decision,omitempty"` + Error string `json:"error"` + ErrorCode string `json:"error_code"` +} + +type analyzeSubjectItem struct { + Category string `json:"category"` + TaskCount int `json:"task_count"` + PlacedCount int `json:"placed_count"` + PendingCount int `json:"pending_count"` + SubjectType string `json:"subject_type,omitempty"` + DifficultyLevel string `json:"difficulty_level,omitempty"` + CognitiveIntensity string `json:"cognitive_intensity,omitempty"` +} + +type analyzeContextDay struct { + DayIndex int `json:"day_index"` + SwitchCount int `json:"switch_count"` + Sequence []string `json:"sequence"` + MaxBlock int `json:"max_block"` + Fragmentation float64 `json:"fragmentation"` + HeavyAdjacent bool `json:"heavy_adjacent"` +} + +type analyzeContextOverall struct { + AvgSwitchesPerDay float64 `json:"avg_switches_per_day"` + MaxSwitchDay int `json:"max_switch_day"` + MaxSwitchCount int `json:"max_switch_count"` + AvgBlockSize float64 `json:"avg_block_size"` + LongestSameSubjectRun int `json:"longest_same_subject_run"` +} + +type analyzeRhythmOverview struct { + AvgSwitchesPerDay float64 `json:"avg_switches_per_day"` + MaxSwitchDay int `json:"max_switch_day"` + MaxSwitchCount int `json:"max_switch_count"` + AvgBlockSize float64 `json:"avg_block_size"` + LongestSameSubjectRun int `json:"longest_same_subject_run"` + HeavyAdjacentDays int `json:"heavy_adjacent_days"` + HighIntensityDays int `json:"high_intensity_days"` + LongHighIntensityDays int `json:"long_high_intensity_days"` + FragmentedCount int `json:"fragmented_count"` + CompressedRunCount int `json:"compressed_run_count"` + BlockBalance int `json:"block_balance"` + SameTypeTransitionRatio float64 `json:"same_type_transition_ratio"` +} + +type analyzeRhythmMetrics struct { + Overview analyzeRhythmOverview `json:"overview"` + Subjects []analyzeSubjectItem `json:"subjects"` + Days []analyzeContextDay `json:"days"` +} + +type analyzeSlackMetrics struct { + MovableTaskCount int `json:"movable_task_count"` + RigidTaskCount int `json:"rigid_task_count"` + AvgAlternativeSlots float64 `json:"avg_alternative_slots"` + CrossClassSwapOptions int `json:"cross_class_swap_options"` + AdjustabilityLevel string `json:"adjustability_level"` + PreferSwap bool `json:"prefer_swap"` +} + +type analyzeTightnessMetrics struct { + LocallyMovableTaskCount int `json:"locally_movable_task_count"` + AvgLocalAlternativeSlots float64 `json:"avg_local_alternative_slots"` + CrossClassSwapOptions int `json:"cross_class_swap_options"` + ForcedHeavyAdjacentDays int `json:"forced_heavy_adjacent_days"` + TightnessLevel string `json:"tightness_level"` +} + +type analyzeSemanticProfileMetrics struct { + TotalSubjects int `json:"total_subjects"` + MissingSubjectTypeCount int `json:"missing_subject_type_count"` + MissingDifficultyCount int `json:"missing_difficulty_count"` + MissingCognitiveCount int `json:"missing_cognitive_count"` + MissingCompleteProfileCount int `json:"missing_complete_profile_count"` +} + +type analyzeProblemScope struct { + DayRange []int `json:"day_range,omitempty"` + TaskIDs []int `json:"task_ids,omitempty"` +} + +type analyzeHealthDecision struct { + ShouldContinueOptimize bool `json:"should_continue_optimize"` + PrimaryProblem string `json:"primary_problem"` + ProblemScope *analyzeProblemScope `json:"problem_scope,omitempty"` + IsForcedImperfection bool `json:"is_forced_imperfection"` + RecommendedOperation string `json:"recommended_operation"` + ImprovementSignal string `json:"improvement_signal"` + Candidates []analyzeHealthCandidate `json:"candidates,omitempty"` +} + +type analyzeHealthMetrics struct { + Rhythm *analyzeRhythmOverview `json:"rhythm,omitempty"` + Tightness *analyzeTightnessMetrics `json:"tightness,omitempty"` + Profile *analyzeSemanticProfileMetrics `json:"profile,omitempty"` + 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 { + return encodeAnalyzeFailure("analyze_rhythm", "state_empty", "日程状态为空") + } + allowed := []string{"category", "include_pending", "detail", "hard_categories"} + if err := validateToolArgsStrict(args, allowed); err != nil { + return encodeAnalyzeFailure("analyze_rhythm", "invalid_args", err.Error()) + } + + includePending := readBoolAnyWithDefault(args, true, "include_pending") + categoryFilter := strings.TrimSpace(readStringAny(args, "category")) + + subjects := computeAnalyzeSubjectMetricsV2(state, includePending, categoryFilter) + days := computeAnalyzeContextDaysV2(state) + overview := computeAnalyzeRhythmOverviewV2(subjects, days) + metrics := analyzeRhythmMetrics{ + Overview: overview, + Subjects: subjects, + Days: days, + } + issues, actions := buildRhythmIssuesAndActionsV2(metrics) + + return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ + Tool: "analyze_rhythm", + Success: true, + MetricSchema: rhythmMetricSchemaV2(), + Metrics: metrics, + Issues: issues, + NextActions: actions, + Error: "", + ErrorCode: "", + }) +} + +// AnalyzeHealth 输出主动优化唯一总入口。 +func AnalyzeHealth(state *ScheduleState, args map[string]any) string { + if state == nil { + return encodeAnalyzeFailure("analyze_health", "state_empty", "日程状态为空") + } + allowed := []string{"dimensions", "threshold", "detail"} + if err := validateToolArgsStrict(args, allowed); err != nil { + return encodeAnalyzeFailure("analyze_health", "invalid_args", err.Error()) + } + if len(normalizeHealthDimensionsV3(parseAnalyzeStringSlice(args["dimensions"]))) == 0 { + return encodeAnalyzeFailure("analyze_health", "invalid_args", "dimensions 全部非法") + } + + snapshot := buildAnalyzeHealthSnapshotFromState(state) + rhythm := snapshot.Rhythm.Overview + rhythmMetrics := snapshot.Rhythm + tightness := snapshot.Tightness + profile := snapshot.Profile + feasibility := snapshot.Feasibility + + issues := make([]analyzeIssueItem, 0) + rhythmIssues, _ := buildRhythmIssuesAndActionsV2(rhythmMetrics) + issues = append(issues, rhythmIssues...) + + profileIssues := buildSemanticProfileIssues(profile) + issues = append(issues, profileIssues...) + + if !feasibility.IsFeasible { + issues = append(issues, analyzeIssueItem{ + IssueID: "issue_feasibility_capacity_gap", + Dimension: "feasibility", + Severity: analyzeSeverityCritical, + Trigger: &analyzeIssueTrigger{ + Metric: "capacity_gap", + Operator: ">", + Threshold: 0, + Actual: float64(feasibility.CapacityGap), + }, + }) + } + + sort.SliceStable(issues, func(i, j int) bool { + return analyzeSeverityRank(issues[i].Severity) < analyzeSeverityRank(issues[j].Severity) + }) + decision := buildAnalyzeHealthDecisionV2(state, snapshot) + + metrics := analyzeHealthMetrics{ + Rhythm: &rhythm, + Tightness: &tightness, + Profile: &profile, + CanClose: !decision.ShouldContinueOptimize, + } + return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ + Tool: "analyze_health", + Success: true, + MetricSchema: healthMetricSchemaV4(), + Metrics: metrics, + Issues: issues, + NextActions: []analyzeNextAction{}, + Feasibility: &feasibility, + Decision: &decision, + Error: "", + ErrorCode: "", + }) +} + +func computeAnalyzeSubjectMetricsV2(state *ScheduleState, includePending bool, categoryFilter string) []analyzeSubjectItem { + type counter struct { + taskCount int + placedCount int + pendingCount int + } + counterByCategory := make(map[string]*counter) + for _, task := range state.Tasks { + if task.Source != "task_item" || strings.TrimSpace(task.Category) == "" { + continue + } + if categoryFilter != "" && strings.TrimSpace(task.Category) != categoryFilter { + continue + } + if !includePending && IsPendingTask(task) { + continue + } + entry := counterByCategory[task.Category] + if entry == nil { + entry = &counter{} + counterByCategory[task.Category] = entry + } + entry.taskCount++ + if IsPendingTask(task) { + entry.pendingCount++ + } + if IsSuggestedTask(task) || IsExistingTask(task) { + entry.placedCount++ + } + } + + out := make([]analyzeSubjectItem, 0, len(counterByCategory)) + for category, item := range counterByCategory { + meta := findTaskClassMetaByName(state, category) + out = append(out, analyzeSubjectItem{ + Category: category, + TaskCount: item.taskCount, + PlacedCount: item.placedCount, + PendingCount: item.pendingCount, + SubjectType: metaValue(meta, func(m *TaskClassMeta) string { return m.SubjectType }), + DifficultyLevel: metaValue(meta, func(m *TaskClassMeta) string { return m.DifficultyLevel }), + CognitiveIntensity: metaValue(meta, func(m *TaskClassMeta) string { return m.CognitiveIntensity }), + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Category < out[j].Category + }) + return out +} + +func computeAnalyzeContextDaysV2(state *ScheduleState) []analyzeContextDay { + out := make([]analyzeContextDay, 0, state.Window.TotalDays) + highIntensityCategories := make(map[string]struct{}) + for _, meta := range state.TaskClasses { + if isHighIntensityMeta(meta) { + highIntensityCategories[strings.TrimSpace(meta.Name)] = struct{}{} + } + } + + for day := 1; day <= state.Window.TotalDays; day++ { + sequence := buildContextDaySequenceV2(state, day) + switchCount := 0 + maxBlock := 0 + currentBlock := 0 + prev := "" + heavyAdjacent := false + + for _, category := range sequence { + if prev != "" && prev != category { + switchCount++ + _, prevHigh := highIntensityCategories[prev] + _, currHigh := highIntensityCategories[category] + if prevHigh && currHigh { + heavyAdjacent = true + } + } + if category == prev { + currentBlock++ + } else { + currentBlock = 1 + prev = category + } + if currentBlock > maxBlock { + maxBlock = currentBlock + } + } + + fragmentation := 0.0 + if len(sequence) > 1 { + fragmentation = safeDivideFloat(float64(switchCount), float64(len(sequence)-1)) + } + out = append(out, analyzeContextDay{ + DayIndex: day, + SwitchCount: switchCount, + Sequence: sequence, + MaxBlock: maxBlock, + Fragmentation: fragmentation, + HeavyAdjacent: heavyAdjacent, + }) + } + return out +} + +func computeAnalyzeRhythmOverviewV2(subjects []analyzeSubjectItem, days []analyzeContextDay) analyzeRhythmOverview { + overview := analyzeRhythmOverview{} + totalSwitches := 0 + totalBlocks := 0 + totalBlockLength := 0 + totalTransitions := 0 + sameTypeTransitions := 0 + subjectTypeByCategory := make(map[string]string, len(subjects)) + highIntensityByCategory := make(map[string]bool, len(subjects)) + for _, subject := range subjects { + subjectTypeByCategory[subject.Category] = subject.SubjectType + highIntensityByCategory[subject.Category] = isHighIntensitySubject(subject) + } + + for _, day := range days { + totalSwitches += day.SwitchCount + if day.SwitchCount > overview.MaxSwitchCount { + overview.MaxSwitchCount = day.SwitchCount + overview.MaxSwitchDay = day.DayIndex + } + if day.HeavyAdjacent { + overview.HeavyAdjacentDays++ + } + if isFragmentedRhythmDay(day) { + overview.FragmentedCount++ + } + if day.MaxBlock > overview.LongestSameSubjectRun { + overview.LongestSameSubjectRun = day.MaxBlock + } + + currentHighRun := 0 + maxHighRun := 0 + hasHighIntensity := false + prev := "" + for _, category := range day.Sequence { + totalBlocks++ + totalBlockLength++ + if highIntensityByCategory[category] { + hasHighIntensity = true + currentHighRun++ + if currentHighRun > maxHighRun { + maxHighRun = currentHighRun + } + } else { + currentHighRun = 0 + } + if prev != "" { + totalTransitions++ + if sameSemanticType(subjectTypeByCategory[prev], subjectTypeByCategory[category]) { + sameTypeTransitions++ + } + } + prev = category + } + if hasHighIntensity { + overview.HighIntensityDays++ + } + if maxHighRun >= 4 { + overview.LongHighIntensityDays++ + } + if isCompressedRhythmDay(day, maxHighRun) { + overview.CompressedRunCount++ + } + } + + overview.AvgSwitchesPerDay = safeDivideFloat(float64(totalSwitches), float64(maxInt(len(days), 1))) + overview.AvgBlockSize = safeDivideFloat(float64(totalBlockLength), float64(maxInt(totalBlocks, 1))) + overview.BlockBalance = overview.FragmentedCount - overview.CompressedRunCount + overview.SameTypeTransitionRatio = safeDivideFloat(float64(sameTypeTransitions), float64(maxInt(totalTransitions, 1))) + return overview +} + +// isFragmentedRhythmDay 判断某一天是否更像“认知块切得过碎”。 +// +// 职责边界: +// 1. 这里只复用当前 analyze_health 已有的偏碎观察阈值,保证 block_balance 和 issue 口径一致。 +// 2. 不负责驱动新的候选类型;当前候选闭环仍然只允许 heavy_adjacent。 +// 3. 只要达到 warning 级碎片化观察条件,就把这一天记入 fragmented_count。 +func isFragmentedRhythmDay(day analyzeContextDay) bool { + return day.SwitchCount >= 3 || day.Fragmentation >= 0.55 +} + +// isCompressedRhythmDay 判断某一天是否更像“认知块过长或过于压缩”。 +// +// 职责边界: +// 1. 这里只做统一观测:把“长同科目块”和“高强度连续过长”都视为 compressed 信号。 +// 2. 不负责生成 long_block / compressed 的候选动作;这次只补统一指标。 +// 3. 同一天即使同时命中两个信号,也只记 1 次,避免 block_balance 被重复放大。 +func isCompressedRhythmDay(day analyzeContextDay, maxHighRun int) bool { + return day.MaxBlock >= 5 || maxHighRun >= 4 +} + +func buildRhythmIssuesAndActionsV2(metrics analyzeRhythmMetrics) ([]analyzeIssueItem, []analyzeNextAction) { + issues := make([]analyzeIssueItem, 0) + actions := make([]analyzeNextAction, 0) + for _, day := range metrics.Days { + if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 { + issues = append(issues, analyzeIssueItem{ + IssueID: fmt.Sprintf("issue_rhythm_switch_day_%d", day.DayIndex), + Dimension: "rhythm", + Severity: analyzeSeverityCritical, + Trigger: &analyzeIssueTrigger{ + Metric: "switch_count", + Operator: ">=", + Threshold: 5, + Actual: float64(day.SwitchCount), + }, + }) + actions = append(actions, analyzeNextAction{ + ActionID: fmt.Sprintf("na_rhythm_reduce_switch_day_%d", day.DayIndex), + Priority: 1, + IntentCode: "reduce_switch", + TargetFilter: map[string]any{ + "status": "suggested", + }, + SlotFilter: map[string]any{ + "day": day.DayIndex, + }, + CandidateScope: analyzeCandidateScope{ + DayRange: []int{day.DayIndex}, + Categories: []string{}, + TaskPool: "placed", + }, + RequiredReads: []string{"query_range", "query_target_tasks"}, + SuccessCriteria: map[string]any{"switch_count<": 5}, + CandidateWriteTools: []string{"swap", "move"}, + }) + } else if day.SwitchCount >= 3 || day.Fragmentation >= 0.55 { + issues = append(issues, analyzeIssueItem{ + IssueID: fmt.Sprintf("issue_rhythm_switch_warn_day_%d", day.DayIndex), + Dimension: "rhythm", + Severity: analyzeSeverityWarning, + }) + } + + if day.HeavyAdjacent { + issues = append(issues, analyzeIssueItem{ + IssueID: fmt.Sprintf("issue_rhythm_heavy_adjacent_day_%d", day.DayIndex), + Dimension: "rhythm", + Severity: analyzeSeverityWarning, + }) + actions = append(actions, analyzeNextAction{ + ActionID: fmt.Sprintf("na_rhythm_reorder_day_%d", day.DayIndex), + Priority: 2, + IntentCode: "smooth_rhythm", + TargetFilter: map[string]any{ + "status": "suggested", + }, + SlotFilter: map[string]any{ + "day": day.DayIndex, + }, + CandidateScope: analyzeCandidateScope{ + DayRange: []int{day.DayIndex}, + Categories: []string{}, + TaskPool: "placed", + }, + RequiredReads: []string{"query_range", "query_target_tasks"}, + SuccessCriteria: map[string]any{"heavy_adjacent": false}, + CandidateWriteTools: []string{"swap", "move"}, + }) + } + + if day.MaxBlock >= 5 { + issues = append(issues, analyzeIssueItem{ + IssueID: fmt.Sprintf("issue_rhythm_long_block_day_%d", day.DayIndex), + Dimension: "rhythm", + Severity: analyzeSeverityWarning, + }) + } + } + if len(issues) == 0 { + issues = append(issues, analyzeIssueItem{ + IssueID: "issue_rhythm_info", + Dimension: "rhythm", + Severity: analyzeSeverityInfo, + }) + } + return issues, actions +} + +func computeAnalyzeSlackMetrics(state *ScheduleState) analyzeSlackMetrics { + metrics := analyzeSlackMetrics{AdjustabilityLevel: "low"} + if state == nil { + return metrics + } + suggested := collectSuggestedTaskItems(state) + if len(suggested) == 0 { + return metrics + } + + totalAlternatives := 0 + for _, task := range suggested { + alternatives := countAlternativePlacements(state, task, 6) + if alternatives > 0 { + metrics.MovableTaskCount++ + totalAlternatives += alternatives + } else { + metrics.RigidTaskCount++ + } + } + metrics.AvgAlternativeSlots = safeDivideFloat(float64(totalAlternatives), float64(maxInt(metrics.MovableTaskCount, 1))) + metrics.CrossClassSwapOptions = countCrossClassSwapOptions(state, suggested, 24) + + switch { + case metrics.MovableTaskCount >= 3 && metrics.AvgAlternativeSlots >= 2.0: + metrics.AdjustabilityLevel = "high" + case metrics.MovableTaskCount >= 1 || metrics.CrossClassSwapOptions > 0: + metrics.AdjustabilityLevel = "medium" + default: + metrics.AdjustabilityLevel = "low" + } + metrics.PreferSwap = metrics.AdjustabilityLevel == "low" || metrics.CrossClassSwapOptions > 0 + return metrics +} + +// computeAnalyzeTightnessMetrics 评估“当前是否还值得继续优化”。 +// +// 设计说明: +// 1. 这里不再问“全窗口理论上还能不能挪”,而是问“在写工具顺序约束下,还剩多少合法候选”; +// 2. 合法性口径直接复用写工具的前驱/后继顺序边界,不再人为限定 day±1; +// 3. forced_heavy_adjacent_days 用来识别“即使有问题,也更像时间窗过紧下的代价”。 +func computeAnalyzeTightnessMetrics(state *ScheduleState, rhythm analyzeRhythmMetrics) analyzeTightnessMetrics { + metrics := analyzeTightnessMetrics{TightnessLevel: "locked"} + if state == nil { + return metrics + } + + // 1. 主动优化只关心“当前问题域附近还有没有低代价修法”, + // 不能再用全窗口可动任务数去放大“还可以继续折腾”的错觉。 + // 2. 若当前没有明显问题域,则退化为 suggested 全量,保证粗排初次诊断仍有结果。 + // 3. focusDays 会优先取 heavy_adjacent / 高切换 / 长连续块出现的天,并补前后 1 天作为局部缓冲区。 + suggested := filterSuggestedTasksByFocusDays(state, selectProblemFocusDays(rhythm)) + if len(suggested) == 0 { + suggested = collectSuggestedTaskItems(state) + } + if len(suggested) == 0 { + return metrics + } + + totalAlternatives := 0 + for _, task := range suggested { + alternatives := countLocalAlternativePlacements(state, task, 1, 4) + if alternatives > 0 { + metrics.LocallyMovableTaskCount++ + totalAlternatives += alternatives + } + } + metrics.AvgLocalAlternativeSlots = safeDivideFloat( + float64(totalAlternatives), + float64(maxInt(metrics.LocallyMovableTaskCount, 1)), + ) + metrics.CrossClassSwapOptions = countCrossClassSwapOptions(state, suggested, 12) + for _, day := range rhythm.Days { + if day.HeavyAdjacent && !hasRepairOpportunityOnDay(state, day.DayIndex) { + metrics.ForcedHeavyAdjacentDays++ + } + } + + switch { + case metrics.LocallyMovableTaskCount >= 4 && (metrics.AvgLocalAlternativeSlots >= 2.0 || metrics.CrossClassSwapOptions >= 2): + metrics.TightnessLevel = "loose" + case metrics.LocallyMovableTaskCount == 0 && metrics.CrossClassSwapOptions == 0: + metrics.TightnessLevel = "locked" + default: + metrics.TightnessLevel = "tight" + } + return metrics +} + +func selectProblemFocusDays(rhythm analyzeRhythmMetrics) []int { + seen := map[int]struct{}{} + out := make([]int, 0, 12) + appendDay := func(day int) { + if day <= 0 { + return + } + if _, ok := seen[day]; ok { + return + } + seen[day] = struct{}{} + out = append(out, day) + } + + // 1. 高认知相邻优先级最高,因为这是主动优化当前最关心的认知负荷问题。 + // 2. 其次是高切换高碎片,再其次是超长连续块。 + // 3. 每个问题日都补前后 1 天,让局部 move/swap 的可行空间评估更贴近真实操作面。 + for _, day := range rhythm.Days { + if day.HeavyAdjacent { + appendDay(day.DayIndex - 1) + appendDay(day.DayIndex) + appendDay(day.DayIndex + 1) + } + } + for _, day := range rhythm.Days { + if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 { + appendDay(day.DayIndex - 1) + appendDay(day.DayIndex) + appendDay(day.DayIndex + 1) + } + } + for _, day := range rhythm.Days { + if day.MaxBlock >= 5 { + appendDay(day.DayIndex - 1) + appendDay(day.DayIndex) + appendDay(day.DayIndex + 1) + } + } + sort.Ints(out) + return out +} + +func filterSuggestedTasksByFocusDays(state *ScheduleState, focusDays []int) []ScheduleTask { + if state == nil || len(focusDays) == 0 { + return nil + } + daySet := make(map[int]struct{}, len(focusDays)) + for _, day := range focusDays { + if day > 0 { + daySet[day] = struct{}{} + } + } + out := make([]ScheduleTask, 0) + for _, task := range collectSuggestedTaskItems(state) { + if len(task.Slots) == 0 { + continue + } + if _, ok := daySet[task.Slots[0].Day]; !ok { + continue + } + out = append(out, task) + } + return out +} + +func collectSuggestedTaskItems(state *ScheduleState) []ScheduleTask { + out := make([]ScheduleTask, 0) + for _, task := range state.Tasks { + if !IsSuggestedTask(task) || len(task.Slots) == 0 || task.Source != "task_item" { + continue + } + out = append(out, task) + } + return out +} + +func countLocalAlternativePlacements(state *ScheduleState, task ScheduleTask, dayRadius int, limit int) int { + if state == nil || len(task.Slots) == 0 { + return 0 + } + _ = dayRadius + duration := taskDuration(task) + if duration <= 0 { + return 0 + } + count := 0 + for day := 1; day <= state.Window.TotalDays; day++ { + for _, gap := range findFreeRangesOnDay(state, day) { + maxStart := gap.slotEnd - duration + 1 + for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ { + target := []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1}} + if sameTaskSlots(task.Slots, target) { + continue + } + if err := validateLocalOrderForSinglePlacement(state, task.StateID, target); err != nil { + continue + } + count++ + if count >= limit { + return count + } + } + } + } + return count +} + +func localCandidateDays(totalDays int, currentDay int, dayRadius int) []int { + if totalDays <= 0 || currentDay <= 0 { + return nil + } + out := make([]int, 0, dayRadius*2+1) + for day := currentDay - dayRadius; day <= currentDay+dayRadius; day++ { + if day < 1 || day > totalDays { + continue + } + out = append(out, day) + } + return out +} + +func hasRepairOpportunityOnDay(state *ScheduleState, dayIndex int) bool { + if state == nil || dayIndex <= 0 { + return false + } + dayTasks := make([]ScheduleTask, 0) + for _, task := range collectSuggestedTaskItems(state) { + if len(task.Slots) == 0 || task.Slots[0].Day != dayIndex { + continue + } + dayTasks = append(dayTasks, task) + if countLocalAlternativePlacements(state, task, 1, 1) > 0 { + return true + } + } + return countCrossClassSwapOptions(state, dayTasks, 12) > 0 +} + +func countAlternativePlacements(state *ScheduleState, task ScheduleTask, limit int) int { + if state == nil || len(task.Slots) == 0 { + return 0 + } + duration := taskDuration(task) + if duration <= 0 { + return 0 + } + count := 0 + for day := 1; day <= state.Window.TotalDays; day++ { + for _, gap := range findFreeRangesOnDay(state, day) { + maxStart := gap.slotEnd - duration + 1 + for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ { + target := []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1}} + if sameTaskSlots(task.Slots, target) { + continue + } + if err := validateLocalOrderForSinglePlacement(state, task.StateID, target); err != nil { + continue + } + count++ + if count >= limit { + return count + } + } + } + } + return count +} + +func countCrossClassSwapOptions(state *ScheduleState, tasks []ScheduleTask, pairLimit int) int { + if state == nil || len(tasks) < 2 { + return 0 + } + count := 0 + checked := 0 + for i := 0; i < len(tasks); i++ { + for j := i + 1; j < len(tasks); j++ { + if tasks[i].TaskClassID <= 0 || tasks[j].TaskClassID <= 0 || tasks[i].TaskClassID == tasks[j].TaskClassID { + continue + } + checked++ + if canSwapTasksForSlack(state, tasks[i], tasks[j]) { + count++ + } + if checked >= pairLimit { + return count + } + } + } + return count +} + +func canSwapTasksForSlack(state *ScheduleState, taskA, taskB ScheduleTask) bool { + if len(taskA.Slots) == 0 || len(taskB.Slots) == 0 { + return false + } + return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{ + taskA.StateID: cloneScheduleTaskSlots(taskB.Slots), + taskB.StateID: cloneScheduleTaskSlots(taskA.Slots), + }) == nil +} + +func sameTaskSlots(left, right []TaskSlot) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func buildSlackIssuesAndActions(metrics analyzeSlackMetrics) ([]analyzeIssueItem, []analyzeNextAction) { + issues := make([]analyzeIssueItem, 0, 1) + actions := make([]analyzeNextAction, 0, 1) + + switch metrics.AdjustabilityLevel { + case "low": + issues = append(issues, analyzeIssueItem{ + IssueID: "issue_slack_low", + Dimension: "slack", + Severity: analyzeSeverityInfo, + }) + if metrics.CrossClassSwapOptions > 0 { + actions = append(actions, analyzeNextAction{ + ActionID: "na_slack_prefer_swap", + Priority: 1, + IntentCode: "prefer_swap", + TargetFilter: map[string]any{ + "status": "suggested", + "different_task_class": true, + }, + SlotFilter: map[string]any{}, + CandidateScope: analyzeCandidateScope{ + DayRange: []int{}, + Categories: []string{}, + TaskPool: "placed", + }, + RequiredReads: []string{"query_range", "query_target_tasks"}, + SuccessCriteria: map[string]any{"cross_class_swap_options>": 0}, + CandidateWriteTools: []string{"swap"}, + }) + } + case "medium": + issues = append(issues, analyzeIssueItem{ + IssueID: "issue_slack_medium", + Dimension: "slack", + Severity: analyzeSeverityInfo, + }) + default: + issues = append(issues, analyzeIssueItem{ + IssueID: "issue_slack_info", + Dimension: "slack", + Severity: analyzeSeverityInfo, + }) + } + return issues, actions +} + +func computeSemanticProfileMetrics(subjects []analyzeSubjectItem) analyzeSemanticProfileMetrics { + metrics := analyzeSemanticProfileMetrics{TotalSubjects: len(subjects)} + for _, subject := range subjects { + missing := false + if strings.TrimSpace(subject.SubjectType) == "" { + metrics.MissingSubjectTypeCount++ + missing = true + } + if strings.TrimSpace(subject.DifficultyLevel) == "" { + metrics.MissingDifficultyCount++ + missing = true + } + if strings.TrimSpace(subject.CognitiveIntensity) == "" { + metrics.MissingCognitiveCount++ + missing = true + } + if missing { + metrics.MissingCompleteProfileCount++ + } + } + return metrics +} + +func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyzeIssueItem { + if metrics.MissingCompleteProfileCount <= 0 { + return nil + } + return []analyzeIssueItem{{ + IssueID: "issue_semantic_profile_missing", + Dimension: "semantic_profile", + Severity: analyzeSeverityWarning, + Trigger: &analyzeIssueTrigger{ + Metric: "missing_complete_profile_count", + Operator: ">", + Threshold: 0, + Actual: float64(metrics.MissingCompleteProfileCount), + }, + }} +} + +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. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”, + // 这类情况不该因为“高认知相邻”四个字就被反复优化。 + // 2. 这里只做保守放宽:必须同时满足整体平稳 + 当天不碎,才把该问题视为可接受。 + // 3. 这样可以减少“把问题从第 3 天搬到第 2 天”的空转行为。 + return rhythm.Overview.SameTypeTransitionRatio >= 0.80 && + rhythm.Overview.AvgSwitchesPerDay <= 1.0 && + rhythm.Overview.MaxSwitchCount <= 3 && + day.SwitchCount <= 2 && + day.Fragmentation <= 0.45 +} + +func buildHealthImprovementSignal( + rhythm analyzeRhythmMetrics, + tightness analyzeTightnessMetrics, + scope *analyzeProblemScope, + operation string, + profile analyzeSemanticProfileMetrics, + feasibility analyzeFeasibility, +) string { + // 1. 这里故意不写具体 day_index,避免“问题只是从第 3 天漂到第 2 天”时被误判成有进展。 + // 2. 信号只保留主动优化真正关心的局部形态:问题域大小、可修空间、全局节奏代价。 + // 3. execute 节点会用这个信号判断“连续两轮是否实质停滞”,因此格式要稳定。 + problemDays := 0 + if scope != nil { + problemDays = len(scope.DayRange) + } + return fmt.Sprintf( + "problem_days=%d|heavy_adjacent_days=%d|max_switch_count=%d|same_type_ratio=%.2f|non_forced_heavy_days=%d|local_moves=%d|swap_options=%d|tightness=%s|operation=%s|missing_profile=%d|capacity_gap=%d", + problemDays, + rhythm.Overview.HeavyAdjacentDays, + rhythm.Overview.MaxSwitchCount, + rhythm.Overview.SameTypeTransitionRatio, + maxInt(rhythm.Overview.HeavyAdjacentDays-tightness.ForcedHeavyAdjacentDays, 0), + tightness.LocallyMovableTaskCount, + tightness.CrossClassSwapOptions, + tightness.TightnessLevel, + strings.TrimSpace(operation), + profile.MissingCompleteProfileCount, + feasibility.CapacityGap, + ) +} + +func computeHealthFeasibilityV2(state *ScheduleState) analyzeFeasibility { + required := 0 + feasible := 0 + for _, task := range state.Tasks { + if IsPendingTask(task) { + required += maxInt(task.Duration, 0) + } + } + for day := 1; day <= state.Window.TotalDays; day++ { + for _, gap := range findFreeRangesOnDay(state, day) { + feasible += gap.slotEnd - gap.slotStart + 1 + } + } + capacityGap := required - feasible + if capacityGap <= 0 { + return analyzeFeasibility{IsFeasible: true, CapacityGap: 0, ReasonCode: "enough_capacity"} + } + return analyzeFeasibility{IsFeasible: false, CapacityGap: capacityGap, ReasonCode: "capacity_insufficient"} +} + +func buildContextDaySequenceV2(state *ScheduleState, day int) []string { + sequence := make([]string, 0) + for slot := 1; slot <= 12; slot++ { + category := subjectAtSlotV2(state, day, slot) + if category == "" { + continue + } + sequence = append(sequence, category) + } + return sequence +} + +func subjectAtSlotV2(state *ScheduleState, day, slot int) string { + best := "" + bestPriority := -1 + for _, task := range state.Tasks { + if len(task.Slots) == 0 || isCourseScheduleTask(task) { + continue + } + for _, ts := range task.Slots { + if ts.Day != day || slot < ts.SlotStart || slot > ts.SlotEnd { + continue + } + priority := 1 + if task.Source == "task_item" { + priority = 2 + } + if IsSuggestedTask(task) { + priority = 3 + } + if priority > bestPriority { + bestPriority = priority + best = strings.TrimSpace(task.Category) + } + } + } + return best +} + +func findTaskClassMetaByName(state *ScheduleState, name string) *TaskClassMeta { + if state == nil { + return nil + } + for i := range state.TaskClasses { + if strings.TrimSpace(state.TaskClasses[i].Name) == strings.TrimSpace(name) { + return &state.TaskClasses[i] + } + } + return nil +} + +func metaValue(meta *TaskClassMeta, getter func(*TaskClassMeta) string) string { + if meta == nil || getter == nil { + return "" + } + return strings.TrimSpace(getter(meta)) +} + +func isHighIntensityMeta(meta TaskClassMeta) bool { + return strings.EqualFold(strings.TrimSpace(meta.CognitiveIntensity), "high") || + strings.EqualFold(strings.TrimSpace(meta.DifficultyLevel), "high") +} + +func isHighIntensitySubject(subject analyzeSubjectItem) bool { + return strings.EqualFold(strings.TrimSpace(subject.CognitiveIntensity), "high") || + strings.EqualFold(strings.TrimSpace(subject.DifficultyLevel), "high") +} + +func sameSemanticType(left, right string) bool { + left = strings.TrimSpace(strings.ToLower(left)) + right = strings.TrimSpace(strings.ToLower(right)) + if left == "" || right == "" { + return false + } + return left == right +} + +func rhythmMetricSchemaV2() map[string]analyzeMetricSchemaItem { + return map[string]analyzeMetricSchemaItem{ + "overview.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, + "overview.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, + "overview.longest_same_subject_run": {Description: "单日最长连续同科块长度", Unit: "slots", Direction: "higher_is_more_monotone"}, + "overview.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, + "overview.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, + "overview.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, + "days.switch_count": {Description: "单日切换次数", Unit: "count", Direction: "higher_is_more_switching"}, + "days.fragmentation": {Description: "单日碎片化程度", Unit: "0-1", Direction: "higher_is_more_fragmented"}, + "days.max_block": {Description: "单日最长连续块", Unit: "slots", Direction: "higher_is_more_monotone"}, + "days.heavy_adjacent": {Description: "该天是否存在高强度相邻", Direction: "true_is_worse"}, + } +} + +func healthMetricSchemaV2() map[string]analyzeMetricSchemaItem { + return map[string]analyzeMetricSchemaItem{ + "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, + "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, + "rhythm.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, + "slack.movable_task_count": {Description: "仍有候选落点的任务数", Unit: "count", Direction: "higher_is_more_adjustable"}, + "slack.cross_class_swap_options": {Description: "跨任务类可交换机会数", Unit: "count", Direction: "higher_is_more_adjustable"}, + "slack.adjustability_level": {Description: "当前可调整空间等级", Direction: "high_is_looser"}, + "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, + "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, + } +} + +func healthMetricSchemaV3() map[string]analyzeMetricSchemaItem { + return map[string]analyzeMetricSchemaItem{ + "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, + "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, + "rhythm.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, + "slack.movable_task_count": {Description: "仍有候选落点的任务数", Unit: "count", Direction: "higher_is_more_adjustable"}, + "slack.cross_class_swap_options": {Description: "跨任务类可交换机会数", Unit: "count", Direction: "higher_is_more_adjustable"}, + "slack.adjustability_level": {Description: "当前可调整空间等级", Direction: "high_is_looser"}, + "profile.missing_subject_type_count": {Description: "缺少 subject_type 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_difficulty_count": {Description: "缺少 difficulty_level 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_cognitive_count": {Description: "缺少 cognitive_intensity 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_complete_profile_count": {Description: "语义画像不完整的科目数", Unit: "count", Direction: "higher_is_worse"}, + "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, + "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, + } +} + +func normalizeHealthDimensionsV2(raw []string) []string { + if len(raw) == 0 { + return []string{"rhythm"} + } + allowed := map[string]struct{}{ + "rhythm": {}, + } + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, item := range raw { + key := strings.ToLower(strings.TrimSpace(item)) + if _, ok := allowed[key]; !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} + +func parseAnalyzeStringSlice(raw any) []string { + switch typed := raw.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if strings.TrimSpace(item) != "" { + out = append(out, strings.TrimSpace(item)) + } + } + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { + out = append(out, strings.TrimSpace(text)) + } + } + return out + case string: + if strings.TrimSpace(typed) == "" { + return nil + } + return []string{strings.TrimSpace(typed)} + default: + return nil + } +} + +func analyzeSeverityRank(level string) int { + switch level { + case analyzeSeverityCritical: + return 0 + case analyzeSeverityWarning: + return 1 + default: + return 2 + } +} + +func maxInt(a, b int) int { + if a >= b { + return a + } + return b +} + +func safeDivideFloat(numerator, denominator float64) float64 { + if denominator == 0 { + return 0 + } + return numerator / denominator +} + +func deduplicateAndSortActions(actions []analyzeNextAction) []analyzeNextAction { + if len(actions) == 0 { + return actions + } + seen := make(map[string]struct{}, len(actions)) + out := make([]analyzeNextAction, 0, len(actions)) + for _, action := range actions { + key := action.IntentCode + "::" + action.ActionID + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, action) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Priority == out[j].Priority { + return out[i].ActionID < out[j].ActionID + } + return out[i].Priority < out[j].Priority + }) + return out +} + +func healthMetricSchemaV4() map[string]analyzeMetricSchemaItem { + return map[string]analyzeMetricSchemaItem{ + "rhythm.block_balance": {Description: "认知块平衡度;大于 0 更偏碎,小于 0 更偏连续或偏压缩", Unit: "score", Direction: "positive_is_more_fragmented_negative_is_more_compressed"}, + "rhythm.compressed_run_count": {Description: "偏连续或偏压缩的天数", Unit: "days", Direction: "higher_is_more_compressed"}, + "rhythm.fragmented_count": {Description: "偏碎的天数", Unit: "days", Direction: "higher_is_more_fragmented"}, + "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, + "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, + "rhythm.heavy_adjacent_days": {Description: "存在高认知相邻的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, + "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, + "tightness.locally_movable_task_count": {Description: "仍有近距离合法调整空间的任务数", Unit: "count", Direction: "higher_is_looser"}, + "tightness.avg_local_alternative_slots": {Description: "局部候选落点均值", Unit: "count", Direction: "higher_is_looser"}, + "tightness.cross_class_swap_options": {Description: "局部跨任务类可交换机会数", Unit: "count", Direction: "higher_is_looser"}, + "tightness.forced_heavy_adjacent_days": {Description: "更像被迫保留的高认知相邻天数", Unit: "days", Direction: "higher_is_more_forced"}, + "tightness.tightness_level": {Description: "当前优化空间等级", Direction: "loose_to_locked"}, + "profile.missing_subject_type_count": {Description: "缺少 subject_type 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_difficulty_count": {Description: "缺少 difficulty_level 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_cognitive_count": {Description: "缺少 cognitive_intensity 的科目数", Unit: "count", Direction: "higher_is_worse"}, + "profile.missing_complete_profile_count": {Description: "语义画像不完整的科目数", Unit: "count", Direction: "higher_is_worse"}, + "decision.should_continue_optimize": {Description: "当前是否还值得继续主动优化", Direction: "true_is_continue"}, + "decision.is_forced_imperfection": {Description: "剩余问题是否更像约束代价", Direction: "true_is_forced"}, + "decision.recommended_operation": {Description: "推荐优先考虑的动作类型", Direction: "swap_move_close"}, + "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, + "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, + } +} + +func normalizeHealthDimensionsV3(raw []string) []string { + if len(raw) == 0 { + return []string{"rhythm", "tightness", "semantic_profile"} + } + allowed := map[string]struct{}{ + "rhythm": {}, + "tightness": {}, + "semantic_profile": {}, + } + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, item := range raw { + key := strings.ToLower(strings.TrimSpace(item)) + if _, ok := allowed[key]; !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} + +func mustEncodeAnalyzeEnvelope(envelope analyzeEnvelope) string { + raw, err := json.Marshal(envelope) + if err != nil { + return fmt.Sprintf(`{"tool":"%s","success":false,"metric_schema":{},"metrics":{},"issues":[],"next_actions":[],"error":"encode analyze result failed","error_code":"encode_failed"}`, envelope.Tool) + } + return string(raw) +} + +func encodeAnalyzeFailure(tool, code, errText string) string { + return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ + Tool: tool, + Success: false, + MetricSchema: map[string]analyzeMetricSchemaItem{}, + Metrics: map[string]any{}, + Issues: []analyzeIssueItem{}, + NextActions: []analyzeNextAction{}, + Error: errText, + ErrorCode: code, + }) +} diff --git a/backend/newAgent/tools/schedule/compound_tools.go b/backend/newAgent/tools/schedule/compound_tools.go index df769bb..bbb2f8c 100644 --- a/backend/newAgent/tools/schedule/compound_tools.go +++ b/backend/newAgent/tools/schedule/compound_tools.go @@ -134,6 +134,13 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string { ) } } + 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() @@ -256,6 +263,13 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string ) } } + 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 { diff --git a/backend/newAgent/tools/schedule/order_constraints.go b/backend/newAgent/tools/schedule/order_constraints.go new file mode 100644 index 0000000..2db2a9c --- /dev/null +++ b/backend/newAgent/tools/schedule/order_constraints.go @@ -0,0 +1,184 @@ +package schedule + +import "fmt" + +// validateLocalOrderForSinglePlacement 校验单个任务落到目标时段后,是否仍满足同任务类内部顺序约束。 +// +// 职责边界: +// 1. 只负责“同任务类内部顺序”这一条规则,不负责冲突、锁定、范围合法性; +// 2. 采用“克隆态 + 假设落位”方式校验,避免直接污染真实 state; +// 3. 若任务不属于 task_item / 缺少 task_order / 当前无边界约束,直接放行。 +func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targetSlots []TaskSlot) error { + if len(targetSlots) == 0 { + return nil + } + return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{ + taskID: cloneScheduleTaskSlots(targetSlots), + }) +} + +// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。 +// +// 职责边界: +// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突; +// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序; +// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。 +func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error { + if state == nil || len(proposals) == 0 { + return nil + } + + clone := state.Clone() + for taskID, slots := range proposals { + task := clone.TaskByStateID(taskID) + if task == nil { + return fmt.Errorf("顺序约束校验失败:任务ID %d 不存在", taskID) + } + task.Slots = cloneScheduleTaskSlots(slots) + } + + for taskID := range proposals { + if err := validateTaskLocalOrderOnState(clone, taskID); err != nil { + return err + } + } + return nil +} + +// validateTaskLocalOrderOnState 判断某个任务在当前假设态下,是否仍处于同任务类前驱/后继之间。 +func validateTaskLocalOrderOnState(state *ScheduleState, taskID int) error { + task := state.TaskByStateID(taskID) + if task == nil { + return fmt.Errorf("顺序约束校验失败:任务ID %d 不存在", taskID) + } + if !shouldEnforceTaskLocalOrder(*task) || len(task.Slots) == 0 { + return nil + } + + prevTask, nextTask := findTaskClassNeighbors(state, *task) + targetStartDay, targetStartSlot, _ := earliestScheduleTaskSlot(task.Slots) + targetEndDay, _, targetEndSlot := latestScheduleTaskSlot(task.Slots) + + if prevTask != nil && len(prevTask.Slots) > 0 { + prevEndDay, _, prevEndSlot := latestScheduleTaskSlot(prevTask.Slots) + if !isStrictlyAfter(targetStartDay, targetStartSlot, prevEndDay, prevEndSlot) { + return fmt.Errorf( + "顺序约束不满足:[%d]%s 不能放到%s。它必须晚于同任务类前一个任务 %s 的结束位置(%s)。", + task.StateID, + task.Name, + formatTaskSlotsBriefWithState(state, task.Slots), + formatTaskLabel(*prevTask), + formatTaskSlotsBriefWithState(state, prevTask.Slots), + ) + } + } + + if nextTask != nil && len(nextTask.Slots) > 0 { + nextStartDay, nextStartSlot, _ := earliestScheduleTaskSlot(nextTask.Slots) + if !isStrictlyBefore(targetEndDay, targetEndSlot, nextStartDay, nextStartSlot) { + return fmt.Errorf( + "顺序约束不满足:[%d]%s 不能放到%s。它必须早于同任务类后一个任务 %s 的开始位置(%s)。", + task.StateID, + task.Name, + formatTaskSlotsBriefWithState(state, task.Slots), + formatTaskLabel(*nextTask), + formatTaskSlotsBriefWithState(state, nextTask.Slots), + ) + } + } + + return nil +} + +// shouldEnforceTaskLocalOrder 判断任务是否需要参与“同任务类内部顺序”约束。 +func shouldEnforceTaskLocalOrder(task ScheduleTask) bool { + return task.Source == "task_item" && task.TaskClassID > 0 && task.TaskOrder > 0 +} + +// findTaskClassNeighbors 查找同任务类中 order 紧邻当前任务的前驱与后继。 +func findTaskClassNeighbors(state *ScheduleState, task ScheduleTask) (prevTask *ScheduleTask, nextTask *ScheduleTask) { + if state == nil || !shouldEnforceTaskLocalOrder(task) { + return nil, nil + } + + for i := range state.Tasks { + candidate := &state.Tasks[i] + if candidate.StateID == task.StateID { + continue + } + if !shouldEnforceTaskLocalOrder(*candidate) { + continue + } + if candidate.TaskClassID != task.TaskClassID { + continue + } + + if candidate.TaskOrder < task.TaskOrder { + if prevTask == nil || candidate.TaskOrder > prevTask.TaskOrder { + prevTask = candidate + } + continue + } + if candidate.TaskOrder > task.TaskOrder { + if nextTask == nil || candidate.TaskOrder < nextTask.TaskOrder { + nextTask = candidate + } + } + } + return prevTask, nextTask +} + +func earliestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) { + if len(slots) == 0 { + return 0, 0, 0 + } + best := slots[0] + for i := 1; i < len(slots); i++ { + current := slots[i] + if current.Day < best.Day || + (current.Day == best.Day && current.SlotStart < best.SlotStart) || + (current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd) { + best = current + } + } + return best.Day, best.SlotStart, best.SlotEnd +} + +func latestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) { + if len(slots) == 0 { + return 0, 0, 0 + } + best := slots[0] + for i := 1; i < len(slots); i++ { + current := slots[i] + if current.Day > best.Day || + (current.Day == best.Day && current.SlotEnd > best.SlotEnd) || + (current.Day == best.Day && current.SlotEnd == best.SlotEnd && current.SlotStart > best.SlotStart) { + best = current + } + } + return best.Day, best.SlotStart, best.SlotEnd +} + +func isStrictlyAfter(dayA, slotA, dayB, slotB int) bool { + if dayA != dayB { + return dayA > dayB + } + return slotA > slotB +} + +func isStrictlyBefore(dayA, slotA, dayB, slotB int) bool { + if dayA != dayB { + return dayA < dayB + } + return slotA < slotB +} + +func cloneScheduleTaskSlots(src []TaskSlot) []TaskSlot { + if len(src) == 0 { + return nil + } + dst := make([]TaskSlot, len(src)) + copy(dst, src) + return dst +} diff --git a/backend/newAgent/tools/schedule/read_filter_tools.go b/backend/newAgent/tools/schedule/read_filter_tools.go index 22b210a..5ee6918 100644 --- a/backend/newAgent/tools/schedule/read_filter_tools.go +++ b/backend/newAgent/tools/schedule/read_filter_tools.go @@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string { // 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。 // // 步骤化说明: - // 1. 默认 enqueue=true,让 LLM 优先走“逐项处理”而不是一次性批量组合; + // 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路; // 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选; // 3. 入队仅保存 task_id,不复制任务全文,避免队列状态膨胀。 queueInfo := (*queryTargetQueueInfo)(nil) @@ -566,7 +566,7 @@ func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTa Limit: limit, TaskIDSet: intSliceToSet(taskIDs), Category: strings.TrimSpace(readStringAny(args, "category", "")), - Enqueue: readBoolAnyWithDefault(args, true, "enqueue"), + Enqueue: readBoolAnyWithDefault(args, false, "enqueue"), ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"), }, nil } diff --git a/backend/newAgent/tools/schedule/read_tools.go b/backend/newAgent/tools/schedule/read_tools.go index e15741f..a6b05fd 100644 --- a/backend/newAgent/tools/schedule/read_tools.go +++ b/backend/newAgent/tools/schedule/read_tools.go @@ -92,6 +92,13 @@ func GetOverview(state *ScheduleState) string { } line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ",")) } + if len(tc.ExcludedDaysOfWeek) > 0 { + parts := make([]string, len(tc.ExcludedDaysOfWeek)) + for i, d := range tc.ExcludedDaysOfWeek { + parts[i] = fmt.Sprintf("%d", d) + } + line += fmt.Sprintf(" 排除星期=[%s]", strings.Join(parts, ",")) + } sb.WriteString(line + "\n") } } diff --git a/backend/newAgent/tools/schedule/state.go b/backend/newAgent/tools/schedule/state.go index 77a596a..e13fffd 100644 --- a/backend/newAgent/tools/schedule/state.go +++ b/backend/newAgent/tools/schedule/state.go @@ -20,17 +20,25 @@ type TaskSlot struct { SlotEnd int `json:"slot_end"` } -// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考。 -// 只记录影响排课决策的字段,不暴露数据库内部细节。 +// TaskClassMeta 是任务类级别的调度与认知画像元数据。 +// +// 职责边界: +// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段; +// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据; +// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。 type TaskClassMeta struct { - ID int `json:"id"` - Name string `json:"name"` - Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击 - TotalSlots int `json:"total_slots"` // 该任务类总时段预算 - AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段 - ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制) - StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD) - EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD) + ID int `json:"id"` + Name string `json:"name"` + Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击 + TotalSlots int `json:"total_slots"` // 该任务类总时段预算 + AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段 + ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制) + ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几(1-7,空=无限制) + StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD) + EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD) + SubjectType string `json:"subject_type,omitempty"` // "quantitative" | "memory" | "reading" | "mixed" + DifficultyLevel string `json:"difficulty_level,omitempty"` + CognitiveIntensity string `json:"cognitive_intensity,omitempty"` } // ScheduleTask is a unified task representation in the tool state. @@ -51,6 +59,9 @@ type ScheduleTask struct { Duration int `json:"duration,omitempty"` // source=task_item only: TaskClass.ID,用于反查任务类约束。 TaskClassID int `json:"task_class_id,omitempty"` + // source=task_item only: 任务在所属任务类内的稳定顺序。 + // 该字段只用于写工具层的“同任务类内部顺序约束”,不直接暴露给 LLM 做决策。 + TaskOrder int `json:"task_order,omitempty"` // source=task_item only: TaskClass.ID for category lookup (internal alias). CategoryID int `json:"category_id,omitempty"` // source=event only: whether this slot allows embedding other tasks. @@ -68,7 +79,7 @@ type ScheduleTask struct { type ScheduleState struct { Window ScheduleWindow `json:"window"` Tasks []ScheduleTask `json:"tasks"` - TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考 + TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考 // RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。 // // 职责边界: diff --git a/backend/newAgent/tools/schedule/write_tools.go b/backend/newAgent/tools/schedule/write_tools.go index fd89f62..8bc56ca 100644 --- a/backend/newAgent/tools/schedule/write_tools.go +++ b/backend/newAgent/tools/schedule/write_tools.go @@ -56,6 +56,9 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string { if err := validateSlotRange(slotStart, slotEnd); err != nil { return fmt.Sprintf("放置失败:%s", err.Error()) } + if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}); err != nil { + return fmt.Sprintf("放置失败:%s", err.Error()) + } // 4. 冲突检测。 conflict := findConflict(state, day, slotStart, slotEnd) @@ -136,6 +139,9 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string { if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil { return fmt.Sprintf("移动失败:%s", err.Error()) } + if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}); err != nil { + return fmt.Sprintf("移动失败:%s", err.Error()) + } // 5. 冲突检测(排除自身)。 conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID) @@ -213,6 +219,12 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string { copy(oldSlotsA, taskA.Slots) oldSlotsB := make([]TaskSlot, len(taskB.Slots)) copy(oldSlotsB, taskB.Slots) + if err := validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{ + taskAID: cloneScheduleTaskSlots(oldSlotsB), + taskBID: cloneScheduleTaskSlots(oldSlotsA), + }); err != nil { + return fmt.Sprintf("交换失败:%s", err.Error()) + } // 6. 交换 Slots。 taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots @@ -303,6 +315,22 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string { return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1) } } + proposals := make(map[int][]TaskSlot, len(moves)) + for _, m := range moves { + task := state.TaskByStateID(m.TaskID) + if task == nil { + continue + } + duration := taskDuration(*task) + proposals[m.TaskID] = []TaskSlot{{ + Day: m.NewDay, + SlotStart: m.NewSlotStart, + SlotEnd: m.NewSlotStart + duration - 1, + }} + } + if err := validateLocalOrderBatchPlacement(state, proposals); err != nil { + return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s", err.Error()) + } // 2. 克隆 state,在克隆上执行。 clone := state.Clone() diff --git a/backend/newAgent/tools/task_class_write.go b/backend/newAgent/tools/task_class_write.go new file mode 100644 index 0000000..bab1504 --- /dev/null +++ b/backend/newAgent/tools/task_class_write.go @@ -0,0 +1,494 @@ +package newagenttools + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// TaskClassUpsertInput 描述任务类写库工具的标准化入参。 +// +// 职责边界: +// 1. ID=0 表示创建,ID>0 表示更新; +// 2. Request 直接复用现有 UserAddTaskClassRequest 语义,避免多套字段定义漂移; +// 3. Source 用于记录字段来源(chat/memory/web),不参与业务校验。 +type TaskClassUpsertInput struct { + ID int + Request model.UserAddTaskClassRequest + Source string +} + +// TaskClassUpsertPersistResult 描述任务类写入持久层后的结果。 +type TaskClassUpsertPersistResult struct { + TaskClassID int + Created bool +} + +// TaskClassWriteDeps 描述任务类写库工具依赖。 +// +// 职责边界: +// 1. 工具层只负责参数标准化与结果包装,不直接依赖 DAO; +// 2. UpsertTaskClass 由启动层注入,便于后续替换为 service/DAO 统一实现。 +type TaskClassWriteDeps struct { + UpsertTaskClass func(userID int, input TaskClassUpsertInput) (TaskClassUpsertPersistResult, error) +} + +type taskClassValidationResult struct { + OK bool `json:"ok"` + Issues []string `json:"issues"` +} + +type taskClassUpsertToolResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + TaskClassID int `json:"task_class_id,omitempty"` + Created bool `json:"created,omitempty"` + Validation taskClassValidationResult `json:"validation"` + Error string `json:"error"` + ErrorCode string `json:"error_code"` +} + +// NewTaskClassUpsertToolHandler 创建 upsert_task_class 工具 handler。 +// +// 职责边界: +// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON; +// 2. 不负责草案生成,草案由 prompt+LLM 完成; +// 3. 不依赖 ScheduleState,可在纯聊天场景调用(execute 会注入 _user_id)。 +func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) string { + _ = state + + if deps.UpsertTaskClass == nil { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}}, + Error: "任务类写库依赖未注入", + ErrorCode: "dependency_missing", + }) + } + + userID, ok := readUpsertUserID(args["_user_id"]) + if !ok || userID <= 0 { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}}, + Error: "工具调用失败:无法识别用户身份", + ErrorCode: "missing_user_id", + }) + } + + input, parseErr := parseTaskClassUpsertInput(args) + if parseErr != nil { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}}, + Error: parseErr.Error(), + ErrorCode: "invalid_args", + }) + } + + issues := validateTaskClassUpsertRequest(input.Request, input.ID) + if len(issues) > 0 { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: issues}, + Error: strings.Join(issues, ";"), + ErrorCode: "validation_failed", + }) + } + + result, err := deps.UpsertTaskClass(userID, input) + if err != nil { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}}, + Error: err.Error(), + ErrorCode: "persist_failed", + }) + } + if result.TaskClassID <= 0 { + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}}, + Error: "写入后未返回有效 task_class_id", + ErrorCode: "invalid_persist_result", + }) + } + + return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: true, + TaskClassID: result.TaskClassID, + Created: result.Created, + Validation: taskClassValidationResult{OK: true, Issues: []string{}}, + Error: "", + ErrorCode: "", + }) + } +} + +func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) { + id := 0 + if rawID, exists := args["id"]; exists { + parsedID, ok := readUpsertInt(rawID) + if !ok { + return TaskClassUpsertInput{}, fmt.Errorf("id 参数类型非法,必须为整数") + } + id = parsedID + } + + taskClassRaw, ok := args["task_class"] + if !ok { + return TaskClassUpsertInput{}, fmt.Errorf("缺少必填参数 task_class") + } + taskClassMap, ok := taskClassRaw.(map[string]any) + if !ok { + return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数类型非法,必须是对象") + } + + // 允许顶层 items 覆盖 task_class.items,便于 LLM 在生成参数时拆分表达。 + if rawItems, exists := args["items"]; exists { + taskClassMap["items"] = rawItems + } + normalizeTaskClassPayload(taskClassMap) + + rawJSON, err := json.Marshal(taskClassMap) + if err != nil { + return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数序列化失败") + } + + var request model.UserAddTaskClassRequest + if err := json.Unmarshal(rawJSON, &request); err != nil { + return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数解析失败:%v", err) + } + + source := "" + if rawSource, exists := args["source"]; exists { + if text, ok := rawSource.(string); ok { + source = strings.TrimSpace(text) + } + } + normalizeTaskClassSemanticRequest(&request) + + return TaskClassUpsertInput{ + ID: id, + Request: request, + Source: source, + }, nil +} + +// normalizeTaskClassPayload 对 LLM 常见“近义字段/错层字段”做轻量兼容归一化。 +// +// 职责边界: +// 1. 负责把已知等价字段映射到后端真实契约,减少“明明填了却校验失败”的误伤; +// 2. 不负责兜底补齐业务必填项(如 mode/config),这些仍由校验层决定是否报错; +// 3. 仅处理本工具已观测到的高频偏差,避免过度“自动纠错”掩盖真实输入问题。 +func normalizeTaskClassPayload(taskClassMap map[string]any) { + if len(taskClassMap) == 0 { + return + } + + // 1. 兼容日期字段错层: + // 1.1 若顶层 start_date/end_date 缺失; + // 1.2 且 config.start_date/config.end_date 有值; + // 1.3 则抬升到顶层,匹配 UserAddTaskClassRequest 契约。 + configMap, _ := readAnyMap(taskClassMap["config"]) + promoteStringField(taskClassMap, configMap, "start_date") + promoteStringField(taskClassMap, configMap, "end_date") + + // 2. 兼容 items 的语义字段: + // 2.1 content 缺失时,尝试从 description/title/name 回填; + // 2.2 order 缺失或非法时,按当前顺序补 1..N; + // 2.3 失败时不抛错,留给校验层输出明确问题。 + normalizeTaskClassItems(taskClassMap) +} + +func promoteStringField(top map[string]any, config map[string]any, key string) { + if top == nil { + return + } + if strings.TrimSpace(readAnyString(top[key])) != "" { + return + } + if strings.TrimSpace(readAnyString(config[key])) == "" { + return + } + top[key] = strings.TrimSpace(readAnyString(config[key])) +} + +func normalizeTaskClassItems(taskClassMap map[string]any) { + rawItems, exists := taskClassMap["items"] + if !exists { + return + } + itemList, ok := rawItems.([]any) + if !ok { + return + } + for idx := range itemList { + itemMap, ok := itemList[idx].(map[string]any) + if !ok { + continue + } + + if !hasPositiveInt(itemMap["order"]) { + itemMap["order"] = idx + 1 + } + if strings.TrimSpace(readAnyString(itemMap["content"])) == "" { + content := firstNonEmptyString( + readAnyString(itemMap["content"]), + readAnyString(itemMap["description"]), + readAnyString(itemMap["title"]), + readAnyString(itemMap["name"]), + ) + if strings.TrimSpace(content) != "" { + itemMap["content"] = strings.TrimSpace(content) + } + } + itemList[idx] = itemMap + } + taskClassMap["items"] = itemList +} + +func readAnyMap(raw any) (map[string]any, bool) { + if raw == nil { + return nil, false + } + value, ok := raw.(map[string]any) + return value, ok +} + +func readAnyString(raw any) string { + switch value := raw.(type) { + case string: + return value + default: + return "" + } +} + +// normalizeTaskClassSemanticRequest 归一化任务类语义画像字段。 +// +// 职责边界: +// 1. 负责把 LLM 或用户可能给出的中文/近义值收口成稳定枚举; +// 2. 不负责补默认值,字段缺失仍由上层决定是否接受; +// 3. 归一化失败时保留原值,交给校验层输出明确错误。 +func normalizeTaskClassSemanticRequest(req *model.UserAddTaskClassRequest) { + if req == nil { + return + } + if normalized := normalizeSubjectType(req.SubjectType); normalized != "" { + req.SubjectType = normalized + } + if normalized := normalizeLevelValue(req.DifficultyLevel); normalized != "" { + req.DifficultyLevel = normalized + } + if normalized := normalizeLevelValue(req.CognitiveIntensity); normalized != "" { + req.CognitiveIntensity = normalized + } +} + +func normalizeSubjectType(raw string) string { + value := strings.TrimSpace(strings.ToLower(raw)) + switch value { + case "quantitative", "计算型", "计算", "理工", "理工型": + return "quantitative" + case "memory", "记忆型", "记忆", "背诵型", "背诵": + return "memory" + case "reading", "阅读型", "阅读": + return "reading" + case "mixed", "混合型", "混合": + return "mixed" + default: + return "" + } +} + +func normalizeLevelValue(raw string) string { + value := strings.TrimSpace(strings.ToLower(raw)) + switch value { + case "low", "低": + return "low" + case "medium", "中", "中等": + return "medium" + case "high", "高": + return "high" + default: + return "" + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func hasPositiveInt(raw any) bool { + switch value := raw.(type) { + case int: + return value > 0 + case int8: + return value > 0 + case int16: + return value > 0 + case int32: + return value > 0 + case int64: + return value > 0 + case float32: + return int(value) > 0 + case float64: + return int(value) > 0 + default: + return false + } +} + +func validateTaskClassUpsertRequest(req model.UserAddTaskClassRequest, id int) []string { + issues := make([]string, 0) + if id < 0 { + issues = append(issues, "id 不能小于 0") + } + if strings.TrimSpace(req.Name) == "" { + issues = append(issues, "name 不能为空") + } + mode := strings.TrimSpace(strings.ToLower(req.Mode)) + if mode != "auto" && mode != "manual" { + issues = append(issues, "mode 仅支持 auto/manual") + } + if mode == "auto" { + if strings.TrimSpace(req.StartDate) == "" || strings.TrimSpace(req.EndDate) == "" { + issues = append(issues, "auto 模式必须提供 start_date/end_date") + } else { + startDate, err1 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.StartDate), time.Local) + endDate, err2 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.EndDate), time.Local) + if err1 != nil || err2 != nil { + issues = append(issues, "start_date/end_date 日期格式非法,需为 YYYY-MM-DD") + } else if startDate.After(endDate) { + issues = append(issues, "start_date 不能晚于 end_date") + } + } + } + if strings.TrimSpace(req.SubjectType) != "" && normalizeSubjectType(req.SubjectType) == "" { + issues = append(issues, "subject_type 仅支持 quantitative/memory/reading/mixed") + } + if strings.TrimSpace(req.DifficultyLevel) != "" && normalizeLevelValue(req.DifficultyLevel) == "" { + issues = append(issues, "difficulty_level 仅支持 low/medium/high") + } + if strings.TrimSpace(req.CognitiveIntensity) != "" && normalizeLevelValue(req.CognitiveIntensity) == "" { + issues = append(issues, "cognitive_intensity 仅支持 low/medium/high") + } + if strings.TrimSpace(req.SubjectType) == "" { + issues = append(issues, "subject_type 不能为空") + } + if strings.TrimSpace(req.DifficultyLevel) == "" { + issues = append(issues, "difficulty_level 不能为空") + } + if strings.TrimSpace(req.CognitiveIntensity) == "" { + issues = append(issues, "cognitive_intensity 不能为空") + } + if req.Config.TotalSlots <= 0 { + issues = append(issues, "config.total_slots 必须大于 0") + } + strategy := strings.TrimSpace(strings.ToLower(req.Config.Strategy)) + if strategy != "steady" && strategy != "rapid" { + issues = append(issues, "config.strategy 仅支持 steady/rapid") + } + for _, section := range req.Config.ExcludedSlots { + // 1. excluded_slots 在粗排算法中按“半天块索引”解释,而不是原子节次; + // 2. 每个块固定映射 2 节:1->1-2,2->3-4,...,6->11-12; + // 3. 若放行 7~12,会在 buildTimeGrid 扩展时生成 13~24 节,触发数组越界。 + if section < 1 || section > 6 { + issues = append(issues, "config.excluded_slots 仅允许 1~6(半天块索引,每块=2节)") + break + } + } + for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek { + // 1. excluded_days_of_week 属于“整天不可排”的硬约束; + // 2. 仅允许 1~7,对应周一到周日; + // 3. 非法值会导致粗排过滤口径失真,因此统一在写入口拦截。 + if dayOfWeek < 1 || dayOfWeek > 7 { + issues = append(issues, "config.excluded_days_of_week 仅允许 1~7(周一到周日)") + break + } + } + if len(req.Items) == 0 { + issues = append(issues, "items 不能为空") + } + for index, item := range req.Items { + if item.Order <= 0 { + issues = append(issues, fmt.Sprintf("items[%d].order 必须大于 0", index)) + } + if strings.TrimSpace(item.Content) == "" { + issues = append(issues, fmt.Sprintf("items[%d].content 不能为空", index)) + } + } + return uniqueTaskClassIssues(issues) +} + +func uniqueTaskClassIssues(issues []string) []string { + if len(issues) == 0 { + return issues + } + seen := make(map[string]struct{}, len(issues)) + out := make([]string, 0, len(issues)) + for _, issue := range issues { + trimmed := strings.TrimSpace(issue) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func readUpsertUserID(raw any) (int, bool) { + return readUpsertInt(raw) +} + +func readUpsertInt(raw any) (int, bool) { + switch value := raw.(type) { + case int: + return value, true + case int8: + return int(value), true + case int16: + return int(value), true + case int32: + return int(value), true + case int64: + return int(value), true + case float64: + return int(value), true + case float32: + return int(value), true + default: + return 0, false + } +} + +func marshalTaskClassUpsertResult(result taskClassUpsertToolResult) string { + raw, err := json.Marshal(result) + if err != nil { + return `{"tool":"upsert_task_class","success":false,"error":"result encode failed","error_code":"encode_failed"}` + } + return string(raw) +} diff --git a/backend/newAgent/tools/tool_domain_map.go b/backend/newAgent/tools/tool_domain_map.go new file mode 100644 index 0000000..03d8823 --- /dev/null +++ b/backend/newAgent/tools/tool_domain_map.go @@ -0,0 +1,252 @@ +package newagenttools + +import "strings" + +const ( + // ToolDomainSchedule 表示“排程调整”工具域。 + ToolDomainSchedule = "schedule" + // ToolDomainTaskClass 表示“任务类定义”工具域。 + ToolDomainTaskClass = "taskclass" +) + +const ( + // ToolNameContextToolsAdd 表示“向 msg0 动态区注入目标工具域定义”工具。 + ToolNameContextToolsAdd = "context_tools_add" + // ToolNameContextToolsRemove 表示“从 msg0 动态区移除目标工具域定义”工具。 + ToolNameContextToolsRemove = "context_tools_remove" +) + +const ( + // ToolPackCore 是固定包:始终注入,不允许显式 add/remove。 + ToolPackCore = "core" + + // schedule 二级包(可选)。 + ToolPackQueue = "queue" + ToolPackMutation = "mutation" + ToolPackAnalyze = "analyze" + ToolPackDetailRead = "detail_read" + ToolPackDeepAnalyze = "deep_analyze" + ToolPackWeb = "web" +) + +type toolProfile struct { + Domain string + Pack string +} + +// toolProfileByName 维护“工具名 -> 域/二级包”映射。 +// +// 设计说明: +// 1. context 管理工具不参与域/包映射; +// 2. schedule 的 core 包是固定注入;其余能力按二级包按需注入; +// 3. taskclass 目前只有 core 包(固定注入)。 +var toolProfileByName = map[string]toolProfile{ + "get_overview": {Domain: ToolDomainSchedule, Pack: ToolPackCore}, + "query_available_slots": {Domain: ToolDomainSchedule, Pack: ToolPackCore}, + "query_target_tasks": {Domain: ToolDomainSchedule, Pack: ToolPackCore}, + "analyze_health": {Domain: ToolDomainSchedule, Pack: ToolPackAnalyze}, + + "query_range": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead}, + "get_task_info": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead}, + + "queue_status": {Domain: ToolDomainSchedule, Pack: ToolPackQueue}, + "queue_pop_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue}, + "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}, + + "analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze}, + "analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze}, + + "web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb}, + "web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb}, + + "upsert_task_class": {Domain: ToolDomainTaskClass, Pack: ToolPackCore}, +} + +// NormalizeToolDomain 统一规范化工具域字符串。 +func NormalizeToolDomain(domain string) string { + switch strings.ToLower(strings.TrimSpace(domain)) { + case ToolDomainSchedule: + return ToolDomainSchedule + case ToolDomainTaskClass: + return ToolDomainTaskClass + default: + return "" + } +} + +// IsSupportedToolDomain 判断是否为当前支持的业务工具域。 +func IsSupportedToolDomain(domain string) bool { + return NormalizeToolDomain(domain) != "" +} + +// NormalizeToolPack 统一规范化指定域下的二级包名。 +func NormalizeToolPack(domain, pack string) string { + normalizedDomain := NormalizeToolDomain(domain) + normalizedPack := strings.ToLower(strings.TrimSpace(pack)) + if normalizedDomain == "" || normalizedPack == "" { + return "" + } + + switch normalizedDomain { + case ToolDomainSchedule: + switch normalizedPack { + case ToolPackCore, ToolPackQueue, ToolPackMutation, ToolPackAnalyze, ToolPackDetailRead, ToolPackDeepAnalyze, ToolPackWeb: + return normalizedPack + default: + return "" + } + case ToolDomainTaskClass: + if normalizedPack == ToolPackCore { + return ToolPackCore + } + return "" + default: + return "" + } +} + +// IsSupportedToolPack 判断某域下某二级包是否受支持。 +func IsSupportedToolPack(domain, pack string) bool { + return NormalizeToolPack(domain, pack) != "" +} + +// IsFixedToolPack 判断某域下某二级包是否属于固定注入包。 +func IsFixedToolPack(domain, pack string) bool { + normalizedPack := NormalizeToolPack(domain, pack) + return normalizedPack == ToolPackCore +} + +// ListOptionalToolPacks 返回某域可选二级包列表(不含 core)。 +func ListOptionalToolPacks(domain string) []string { + switch NormalizeToolDomain(domain) { + case ToolDomainSchedule: + return []string{ + ToolPackMutation, + ToolPackAnalyze, + ToolPackDetailRead, + ToolPackDeepAnalyze, + ToolPackQueue, + ToolPackWeb, + } + default: + return nil + } +} + +// ListDefaultToolPacks 返回某域“默认注入”的可选包集合。 +// +// 说明: +// 1. 仅用于 packs 为空时的兜底,目的是降低 msg0 噪声; +// 2. schedule 默认只开 mutation+analyze,其他包按需 add; +// 3. taskclass 当前无可选包。 +func ListDefaultToolPacks(domain string) []string { + switch NormalizeToolDomain(domain) { + case ToolDomainSchedule: + return []string{ToolPackMutation, ToolPackAnalyze} + default: + return nil + } +} + +// NormalizeToolPacks 规范化 pack 列表,并去重。 +// +// 1. 仅返回受支持的 pack; +// 2. 自动剔除固定包 core(core 不接受显式管理); +// 3. 顺序保持第一次出现顺序,便于日志和 prompt 可读性。 +func NormalizeToolPacks(domain string, packs []string) []string { + normalizedDomain := NormalizeToolDomain(domain) + if normalizedDomain == "" || len(packs) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(packs)) + result := make([]string, 0, len(packs)) + for _, rawPack := range packs { + pack := NormalizeToolPack(normalizedDomain, rawPack) + if pack == "" || IsFixedToolPack(normalizedDomain, pack) { + continue + } + if _, exists := seen[pack]; exists { + continue + } + seen[pack] = struct{}{} + result = append(result, pack) + } + if len(result) == 0 { + return nil + } + return result +} + +// ResolveEffectiveToolPacks 返回某域下“真正生效”的可选包集合。 +// +// 兼容策略: +// 1. schedule 域且 packs 为空时,默认启用最小可用包(mutation+analyze); +// 2. taskclass 目前无可选包,统一返回 nil; +// 3. 非法域统一返回 nil。 +func ResolveEffectiveToolPacks(domain string, packs []string) []string { + normalizedDomain := NormalizeToolDomain(domain) + if normalizedDomain == "" { + return nil + } + + if normalizedDomain == ToolDomainTaskClass { + return nil + } + + normalizedPacks := NormalizeToolPacks(normalizedDomain, packs) + if len(normalizedPacks) > 0 { + return normalizedPacks + } + + defaultPacks := ListDefaultToolPacks(normalizedDomain) + if len(defaultPacks) == 0 { + return nil + } + result := make([]string, len(defaultPacks)) + copy(result, defaultPacks) + return result +} + +// IsContextManagementTool 判断工具是否属于上下文管理工具。 +func IsContextManagementTool(name string) bool { + switch strings.TrimSpace(name) { + case ToolNameContextToolsAdd, ToolNameContextToolsRemove: + return true + default: + return false + } +} + +// ResolveToolDomain 返回工具所属业务域。 +func ResolveToolDomain(name string) (string, bool) { + domain, _, ok := ResolveToolDomainPack(name) + return domain, ok +} + +// ResolveToolDomainPack 返回工具所属域与二级包。 +// +// 返回语义: +// 1. 命中映射返回 (domain, pack, true); +// 2. 未命中返回 ("", "", false); +// 3. context 管理工具统一返回 ("", "", false)。 +func ResolveToolDomainPack(name string) (string, string, bool) { + toolName := strings.TrimSpace(name) + if IsContextManagementTool(toolName) { + return "", "", false + } + profile, ok := toolProfileByName[toolName] + if !ok { + return "", "", false + } + return profile.Domain, profile.Pack, true +} diff --git a/backend/service/task-class.go b/backend/service/task-class.go index 7e89e46..9d8ecec 100644 --- a/backend/service/task-class.go +++ b/backend/service/task-class.go @@ -55,6 +55,22 @@ func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model if req.Mode == "" || req.Name == "" || len(req.Items) == 0 { return respond.MissingParam } + // 1. excluded_slots 属于“半天块索引”,每个索引映射 2 节(1->1-2,...,6->11-12); + // 2. 若允许 7~12,会在粗排网格展开时产生越界节次,触发运行时 panic; + // 3. 这里统一在写入入口拦截,避免脏数据落库后污染后续排程链路。 + for _, slot := range req.Config.ExcludedSlots { + if slot < 1 || slot > 6 { + return respond.WrongParamType + } + } + // 1. excluded_days_of_week 表示“整天不可排”的硬约束,粗排时会直接整天屏蔽; + // 2. 只允许 1~7,对应周一到周日; + // 3. 若写入非法值,会导致粗排过滤口径和前端展示口径不一致,因此入口直接拦截。 + for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek { + if dayOfWeek < 1 || dayOfWeek > 7 { + return respond.WrongParamType + } + } //2.写数据库(事务内) if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID) diff --git a/backend/newAgent/ARCHITECTURE.md b/docs/backend/ARCHITECTURE.md similarity index 100% rename from backend/newAgent/ARCHITECTURE.md rename to docs/backend/ARCHITECTURE.md diff --git a/backend/newAgent/HANDOFF_WebSearch两阶段实施计划.md b/docs/backend/HANDOFF_WebSearch两阶段实施计划.md similarity index 100% rename from backend/newAgent/HANDOFF_WebSearch两阶段实施计划.md rename to docs/backend/HANDOFF_WebSearch两阶段实施计划.md diff --git a/backend/newAgent/HANDOFF_优化待办.md b/docs/backend/HANDOFF_优化待办.md similarity index 100% rename from backend/newAgent/HANDOFF_优化待办.md rename to docs/backend/HANDOFF_优化待办.md diff --git a/docs/backend/P1-P1.5执行改动计划.md b/docs/backend/P1-P1.5执行改动计划.md new file mode 100644 index 0000000..f48f967 --- /dev/null +++ b/docs/backend/P1-P1.5执行改动计划.md @@ -0,0 +1,634 @@ +# SmartFlow NewAgent P1-P1.5 执行改动计划(代码实施版) + +## 0. 文档定位 +- 文档类型:代码实施计划(非 PRD)。 +- 对齐范围:仅覆盖已冻结 PRD 的 `P1` 与 `P1.5`。 +- 执行目标:先跑通“首次编排主动优化闭环(P1)+ 对话内任务类共创可用版(P1.5)”。 + +--- + +## 1. 目标与边界 + +### 1.1 本轮目标(必须完成) +- P1:在 `execute` 主链路中引入分析型读工具,形成“观测 -> 调整 -> 复盘 -> 收口”的可执行闭环。 +- P1:保持旧写工具链路主执行地位(`move/swap/unplace/...`),分析工具只做观测,不直接执行改动。 +- P1.5:在对话内提供“完整任务类草案”能力,并通过 `upsert_task_class` 完成确认后的统一落库。 + +### 1.2 本轮非目标(明确不做) +- 不做多版本日程管理(已定 P2)。 +- 不做配置化持久禁改清单(仅对话内轻量语义)。 +- 不做聊天外按钮触发任务类共创。 +- 不做 `analyze_deadlines`(当前 `ScheduleState` 无单任务 deadline/priority 数据源,不满足稳定落地条件)。 + +--- + +## 2. 现状代码锚点(实施入口) + +### 2.1 执行主链路 +- [execute.go](/D:/SmartFlow-Agent/backend/newAgent/node/execute.go) +- [agent_nodes.go](/D:/SmartFlow-Agent/backend/newAgent/node/agent_nodes.go) +- [common_graph.go](/D:/SmartFlow-Agent/backend/newAgent/graph/common_graph.go) + +### 2.2 工具注册与调度态 +- [registry.go](/D:/SmartFlow-Agent/backend/newAgent/tools/registry.go) +- [state.go](/D:/SmartFlow-Agent/backend/newAgent/tools/schedule/state.go) +- [read_tools.go](/D:/SmartFlow-Agent/backend/newAgent/tools/schedule/read_tools.go) +- [read_filter_tools.go](/D:/SmartFlow-Agent/backend/newAgent/tools/schedule/read_filter_tools.go) +- [task-class.go](/D:/SmartFlow-Agent/backend/dao/task-class.go) + +### 2.3 Prompt 与工具可见性 +- [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go) +- [execute_context.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute_context.go) +- [chat.go](/D:/SmartFlow-Agent/backend/newAgent/node/chat.go) + +### 2.4 状态持久化与恢复 +- [common_state.go](/D:/SmartFlow-Agent/backend/newAgent/model/common_state.go) +- [graph_run_state.go](/D:/SmartFlow-Agent/backend/newAgent/model/graph_run_state.go) +- [state_store.go](/D:/SmartFlow-Agent/backend/newAgent/model/state_store.go) + +--- + +## 3. 总体改造方案(分层) + +### 3.1 工具层(新增能力) +- 新增 5 个分析读工具(P1): +- `analyze_health` +- `analyze_load` +- `analyze_subjects` +- `analyze_context` +- `analyze_tolerance` +- P1.5 不新增“任务类草案工具”;任务类草案由主 LLM 按 prompt 在对话内生成。 +- P1.5 新增 1 个任务类写库工具:`upsert_task_class`(创建/更新统一入口,走 confirm)。 + +### 3.2 策略层(执行行为约束) +- 通过 `chat -> execute` 路由策略 + `execute prompt` 约束,控制“何时走全局分析,何时走局部旧链路”。 +- 保持“单轮单工具调用”与现有 `confirm` 闸门不变。 + +### 3.3 状态层(最小增量) +- 建议新增轻量执行模式标记到 `CommonState`(避免全靠 prompt 猜): +- `OptimizationMode string`,建议取值: +- `first_full`(首次编排全流程) +- `local_adjust`(后续局部请求) +- `global_reopt`(用户明确触发全局重优化) + +说明:如你希望“最小侵入”,该字段也可先不加,改用 `PinnedBlock` 过渡;但建议保留,后续可测试性更好。 + +--- + +## 4. 统一数据契约(新增工具) + +### 4.1 分析工具统一返回包络(强约束) +所有分析工具返回 `string(JSON)`,顶层统一: + +```json +{ + "tool": "analyze_xxx", + "success": true, + "metric_schema": {}, + "metrics": {}, + "issues": [ + { + "issue_id": "issue_xxx", + "dimension": "load|subjects|context|tolerance|feasibility|health", + "severity": "critical|warning|info", + "trigger": { + "metric": "metric_key", + "operator": ">=|<=|>|<|==", + "threshold": 0, + "actual": 0 + } + } + ], + "next_actions": [ + { + "action_id": "na_xxx", + "priority": 1, + "intent_code": "rebalance_load|reduce_switch|increase_tolerance|...", + "target_filter": {}, + "slot_filter": {}, + "candidate_scope": { + "day_range": [], + "categories": [], + "task_pool": "placed|pending|mixed" + }, + "required_reads": [], + "success_criteria": {}, + "candidate_write_tools": ["move|swap|spread_even|..."] + } + ], + "error": "", + "error_code": "" +} +``` + +### 4.2 统一错误与成功语义(强约束) +- 所有新增工具统一返回 `string(JSON)`。 +- 顶层字段固定: +- `tool`: 工具名。 +- `success`: `true|false`。 +- `metric_schema`: 指标字典(每个指标的含义、单位、方向)。 +- `metrics`: 指标数据本体。 +- `issues`: 问题数据本体(机器可判定触发条件)。 +- `next_actions`: 下一步动作意图(不给最终写参数)。 +- `error`: 失败时的人类可读错误文案。 +- `error_code`: 失败时稳定机器码(如 `invalid_args` / `insufficient_data`)。 +- `feasibility`(可选):可行性快照(`is_feasible/capacity_gap/reason_code`)。 +- 成功时必须返回 `metrics/issues/next_actions`。 + +### 4.2.1 精简协议原则(新增) +- 不在协议中放大段中文解释,不依赖 `summary/reason` 驱动执行。 +- 协议只提供三类最小必要信息: +- 数据含义:`metric_schema` +- 当前状态:`metrics/issues` +- 下一步方向:`next_actions` +- LLM 通过 prompt 规则 + 上述结构化数据完成后续读写决策。 + +### 4.2.2 目标对象选择原则(新增) +- 后端只提供“方向+作用范围+成功判据”,不下发最终写参数,不指定唯一 `task_id`。 +- `next_actions` 的 `candidate_scope/target_filter/slot_filter` 只定义可行动边界与禁区。 +- LLM 负责在边界内自主选择目标对象与写工具参数(如选哪个任务、挪到哪个槽位)。 +- 执行层负责合法性校验与证据化回传(本次操作命中哪个 issue、是否满足 success_criteria),不替代 LLM 做确定性选点。 + +### 4.3 工具详规:`analyze_load` +适用场景: +- 首次编排后的全局负载体检。 +- 用户诉求命中“太满/太空/不均匀/某几天压力大”。 + +入参定义(建议): +- `scope`: `full|week|day_range`,默认 `full`。 +- `week_from/week_to`: `scope=week` 时可选;缺失则覆盖窗口内所有周。 +- `day_from/day_to`: `scope=day_range` 时必填。 +- `granularity`: `day|week|time_of_day`,默认 `day`。 +- `detail`: `summary|full`,默认 `summary`。 + +计算口径: +- `total_used = course_used + task_used`(总占用)。 +- `utilization = total_used / total_slots`。 +- `load_std_dev`: 按天 `total_used` 计算样本标准差。 +- `load_range = max_day_total_used - min_day_total_used`。 +- 时段拆分固定:上午 `1-4`,下午 `5-8`,晚上 `9-12`。 +- `delta_from_prev = today_total_used - yesterday_total_used`。 + +输出字段重点: +- `metrics.summary`: `total_used/course_used/task_used/pending_count`、`utilization_rate`、`peak/valley`、`load_std_dev`、`load_range`。 +- `metrics.days`: 每日 `total_used/course_used/task_used`、时段分解、负载等级。 +- `metrics.weeks`: 周级聚合(仅 `granularity=week` 或 `detail=full` 时返回)。 + +issues 判定(normal 档): +- `critical`: 任意天利用率 `>= 0.90`,或 `load_std_dev >= 3.0` 且 `peak_day_load - valley_day_load >= 7`。 +- `warning`: 任意天利用率 `0.80~0.90`,或 `load_std_dev 2.0~3.0`。 +- `info`: 利用率整体正常但有轻微波动。 + +阈值档位偏移: +- `strict`: 比 normal 更严格(提前约 10% 触发)。 +- `relaxed`: 比 normal 更宽松(延后约 10% 触发)。 + +next_actions 生成规则: +- 阈值判断基于 `total_used`,但建议动作仅作用任务层(`task_used/pending`),不建议“优化课程占位”。 +- 负载过高建议:`move`、`queue_apply_head_move`、`spread_even`。 +- 波动过大建议:跨天分流,优先“峰值日 -> 低负载日”。 +- 仅给建议,不输出可直接执行写操作。 + +失败返回: +- 参数非法、范围越界、窗口为空时返回 `success=false`。 + +### 4.4 工具详规:`analyze_subjects` +适用场景: +- 用户问“某科排得怎么样”“某任务类是不是太集中”。 +- 首次编排后检查任务类节奏与预算进度。 + +入参定义(建议): +- `category`: 可选;为空表示全科目。 +- `include_pending`: `true|false`,默认 `true`。 +- `detail`: `summary|full`,默认 `summary`。 + +计算口径: +- `present_days`: 该科目出现过落位的 `day_index` 集合。 +- `gaps`: 相邻出现日的间隔天数(`next_day - prev_day - 1`)。 +- `avg/max/min/std_gap`: 基于 `gaps` 统计。 +- `concentration`: 建议用按天时段占比的归一化 HHI(`0` 分散,`1` 集中)。 +- `budget_progress = used_slots / total_slots`(`total_slots` 来自 `TaskClassMeta`)。 + +输出字段重点: +- `metrics.subjects[]`: `task_count/placed/pending`、`present_days/gaps`、`concentration`、`budget_progress`。 +- 可选返回 `days_to_end`(当任务类存在 `end_date` 且可解析)。 + +issues 判定(normal 档): +- `critical`: `max_gap >= 6`,或 `concentration >= 0.85`,或 `budget_progress < 0.4` 且截止临近。 +- `warning`: `max_gap 4~5`,或 `concentration 0.70~0.85`。 +- `info`: 节奏基本稳定但有可优化空间。 + +next_actions 生成规则: +- 过于集中:建议 `spread_even` 或多次 `move` 分散。 +- 空窗过长:建议插入中间复习点。 +- 预算滞后:建议提高该科目近期优先级。 + +失败返回: +- `category` 不存在时可返回 `success=true` + 空结果,不建议硬失败。 + +### 4.5 工具详规:`analyze_context` +适用场景: +- 用户反馈“切换太多、心累、一天很碎”。 +- 首次编排后认知负荷体检。 + +入参定义(建议): +- `day_from/day_to`: 可选;缺省覆盖窗口全量。 +- `detail`: `summary|day_detail`,默认 `summary`。 +- `hard_categories`: 可选数组;用于“硬课相邻”判定。 + +计算口径: +- `sequence`: 按时段顺序提取当日科目序列(仅已落位任务)。 +- `switch_count`: 相邻非空且科目变化次数。 +- `blocks`: 连续同科目块。 +- `fragmentation = switch_count / max(occupied_slots-1, 1)`。 +- `heavy_adjacent`: 相邻 pair 同时命中 `hard_categories`。 + +输出字段重点: +- `metrics.overall`: 总切换次数、日均切换、最长同科目连续块、平均块长度。 +- `metrics.days[]`: `switch_count`、`fragmentation`、`adjacent_pairs`、`blocks`。 + +issues 判定(normal 档): +- `critical`: `switch_count >= 5` 且 `fragmentation >= 0.75`。 +- `warning`: `switch_count 3~4` 或 `fragmentation 0.55~0.75`。 +- `info`: 结构基本可接受但可继续聚合。 + +next_actions 生成规则: +- 优先建议同科目聚合(`move/swap`)。 +- P1 明确不把 `min_context_switch` 作为候选写工具,避免“窗口内强行并排”造成学习间隔恶化。 + +失败返回: +- 无落位任务时返回 `success=true` + 空指标,不硬失败。 + +### 4.6 工具详规:`analyze_tolerance` +适用场景: +- 用户反馈“排太满”“想留余量”“希望更抗突发”。 +- 与 PRD 中“容错”概念保持一致(替代旧“空窗”话术)。 + +入参定义(建议): +- `scope`: `full|week|day_range`,默认 `full`。 +- `week_from/week_to/day_from/day_to`: 按 scope 生效。 +- `min_usable_size`: 默认 `2`(>=2 连续空位视为可用块)。 +- `min_daily_buffer`: 默认 `2`(每日最少可用余量阈值)。 +- `detail`: `summary|full`,默认 `summary`。 + +计算口径: +- `total_free_slots`: 所有空闲时段总和。 +- `usable_slots`: 处于“可用空窗块(长度>=min_usable_size)”内的空闲时段。 +- `fragmented_slots`: 碎片空窗时段数。 +- `fragmentation_rate = fragmented_slots / total_free_slots`。 +- `buffer_sufficient`: 每天 `usable_slots >= min_daily_buffer`。 + +输出字段重点: +- `metrics.overall`: `total_free/usable/fragmented`、`fragmentation_rate`、`days_without_buffer`。 +- `metrics.days[]`: 每日空窗块细节、相邻任务类型、是否满足缓冲。 + +issues 判定(normal 档): +- `critical`: `days_without_buffer >= 2` 或 `fragmentation_rate >= 0.65`。 +- `warning`: `days_without_buffer = 1` 或 `fragmentation_rate 0.45~0.65`。 +- `info`: 容错足够但可进一步优化分布。 + +next_actions 生成规则: +- 容错过低:建议把边缘任务外移、打散拥堵日。 +- 碎片过高:建议合并连续学习块,减少“1节孤岛”。 + +失败返回: +- `min_usable_size<=0` 或参数范围非法时返回 `success=false`。 + +### 4.7 工具详规:`analyze_health` +适用场景: +- 首次编排全流程的默认首入口。 +- 用户明确要求“整体体检/全局重优化”。 + +入参定义(建议): +- `dimensions`: 可选,默认 `["load","subjects","context","tolerance"]`。 +- `threshold`: `strict|normal|relaxed`,默认 `normal`。 +- `detail`: `summary|full`,默认 `summary`。 + +聚合策略: +- 内部复用各分析器的统计函数,不在工具内二次调用 registry 工具(避免链式循环与重复成本)。 +- `issues` 合并后按 `severity -> impact_score -> recency` 排序。 +- 对同源问题去重(同维度、同天、同任务的重复报警合并)。 +- 聚合前先做可行性判定;若不可行,必须追加 `dimension=feasibility` 的 `critical` 问题。 + +输出字段重点: +- `metrics`: 各维度精简核心指标。 +- `issues`: 标准化问题清单(用于 execute 单轮主问题域选择)。 +- `next_actions`: 最多 3 条高价值建议动作(仅建议)。 +- `feasibility`: `{ "is_feasible": bool, "capacity_gap": int, "reason_code": string }`。 + +issues 生成口径: +- 直接沿用各维度阈值档位。 +- 若 `critical=0 && warning<=1`,在 `metrics` 明确写出“可接受收口”信号。 +- 若 `is_feasible=false`,无论其它维度如何,都必须输出 `feasibility` 的 `critical` 问题。 + +失败返回: +- `dimensions` 全非法时返回 `success=false`。 + +#### 4.7.1 可行性判定(强约束) +目的: +- 解决“窗口太小/约束过严,导致持续 critical 且无法优化”的循环问题。 + +判定输入: +- `required_task_slots`:当前仍需排入或重排的任务时段需求总量。 +- `feasible_slots`:在当前窗口与约束下,可承载任务的可用时段总量。 +- `capacity_gap = required_task_slots - feasible_slots`。 + +判定规则: +- `capacity_gap <= 0`:`is_feasible=true`,继续常规优化。 +- `capacity_gap > 0`:`is_feasible=false`,进入“不可行协商分支”。 + +不可行协商分支(由 `analyze_health.next_actions` 输出建议): +- `ask_expand_window`:建议扩展时间窗。 +- `ask_relax_constraints`:建议放松禁排时段/容错目标/顺序限制。 +- `ask_reduce_scope_or_budget`:建议缩范围或降低预算。 +- `accept_risk_and_close`:若用户坚持当前约束,按“有风险收口”结束本轮。 + +### 4.8 P1.5 草案生成原则(无新工具) +适用场景: +- 用户在聊天内要求“帮我设计任务类/补全参数/给个可排的草案”。 +- 输出应是“完整草案”,不是散点建议。 + +生成机制: +- 草案由主 LLM 在 prompt 引导下直接生成,不新增后端草案工具。 +- 来源优先级固定:`user_explicit > memory > web_common_knowledge`。 +- 冲突必须显式标记为 `conflicts`,不得静默覆盖用户明确偏好。 + +字段分级(按 PRD 冻结): +- 关键字段(必须 ask_user 确认):`time_window`、`strategy`、`total_slots`、`tolerance_preference`、`excluded_slots`、`task_items_integrity`、`task_item_priority_or_dependency`(用户给出时)。 +- 普通字段(可静默落):`time_of_day_preference_weight`、`same_category_aggregation_preference`、`milestone_split_suggestion`、`knowledge_tags_and_path_notes`(命中统一标准时结构化)。 + +后置校验原则: +- 各类字段合法性与完整性校验放在写流程之后执行。 +- 若写后校验失败,返回可修复反馈并进入下一轮对话修订。 + +### 4.9 工具详规:`upsert_task_class`(P1.5 写库) +适用场景: +- 草案与关键字段确认完成后,将任务类落库(新建或更新)。 +- 用户明确要求“创建任务类/更新任务类参数”。 + +工具定位: +- 这是 P1.5 唯一新增写工具,不负责草案生成。 +- 通过 `id` 语义统一创建与更新:`id=0` 创建,`id>0` 更新。 +- 必须走 confirm 闸门,避免模型在未确认关键字段时直接写库。 + +入参定义(建议): +- `id`: `int`,可选,默认为 `0`(创建);`>0` 表示更新已有任务类。 +- `task_class`: 任务类主体字段(名称、时间窗、策略、总预算、容错偏好、禁排时段等)。 +- `items`: 任务项数组(任务项名称、时长/预算、优先级或依赖等)。 +- `source`: 可选,记录来源(`chat|memory|web`),用于审计与回显。 + +执行语义: +- 工具内部以事务写库:先 upsert 任务类主体,再 upsert 任务项。 +- 复用 DAO 现有能力:`AddOrUpdateTaskClass` + `AddOrUpdateTaskClassItems`。 +- 写后执行字段合法性与完整性校验;失败时返回可修复错误,不做静默成功。 + +输出字段重点: +- `success`: 是否写库成功。 +- `task_class_id`: 最终任务类 ID(创建时为新 ID,更新时为原 ID)。 +- `created`: `true|false`(是否新建)。 +- `validation`: 写后校验结果(`ok/issues[]`)。 +- `error/error_code`: 写库或校验失败时的稳定错误信息。 + +失败返回: +- 关键字段缺失、字段非法、用户越权、事务失败时返回 `success=false`。 +- 校验失败时返回 `success=false` + 可修复 `issues`,由 LLM 继续 ask_user/修订。 + +--- + +## 5. P1 实施清单(逐项) + +## 5.1 P1-A:分析工具落地(工具层) +定义: +- 在 `tools/schedule` 新增分析工具实现,全部只读,不改 `ScheduleState`。 + +改动动作: +- 新增文件建议: +- `analyze_common.go`(通用统计、分级、JSON封装) +- `analyze_load.go` +- `analyze_subjects.go` +- `analyze_context.go` +- `analyze_tolerance.go` +- `analyze_health.go` + +- 每个工具遵循“参数校验失败返回 `success=false` JSON 错误”口径,与 `query_available_slots` 风格一致。 + +验收标准: +- 每个工具在 `ScheduleState` 空/小/大样本下可稳定返回合法 JSON。 +- 不产生任何状态写入副作用。 + +--- + +## 5.2 P1-B:注册表接线(工具可发现) +定义: +- 将新工具纳入 `ToolRegistry`,并确保被 Execute 看见。 + +改动动作: +- 修改 [registry.go](/D:/SmartFlow-Agent/backend/newAgent/tools/registry.go) +- `NewDefaultRegistryWithDeps` 注册 5 个分析工具。 +- 保持其为读工具(不加入 `writeTools`)。 +- 增加 P1 运行态工具可见性约束:`min_context_switch` 对 execute 模型侧默认隐藏(仅保留既有写工具链路中的 `move/swap/...`)。 + +验收标准: +- `ToolRegistry.ToolNames()` 可见新增工具。 +- `IsWriteTool` 对新增工具全部返回 `false`。 +- P1 模式下 execute 可见写工具集合不包含 `min_context_switch`。 + +--- + +## 5.3 P1-C:Prompt 策略升级(行为约束) +定义: +- 让 LLM 在正确场景优先使用分析工具,且不过度主动。 + +改动动作: +- 修改 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go) +- 增加规则: +- `first_full/global_reopt` 模式优先 `analyze_health`。 +- `local_adjust` 模式默认旧链路(`query_target_tasks/query_available_slots/...`)。 +- 未命中全局触发条件,不要滥用全局分析。 +- 增加“先定范围再写入”规则:先用分析/读取工具锁定 `candidate_scope`,再选择写工具执行。 +- 增加“自主选目标”规则:后端不指定具体任务,LLM 在边界内自行选择目标与参数,并在后续复盘中验证是否命中 success_criteria。 +- 在 P1 提示词中禁用 `min_context_switch`(不作为候选动作)。 + +- 修改 [execute_context.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute_context.go) +- 为新增工具补“返回类型+最小示例”。 + +验收标准: +- 同样输入下,首次编排与局部调整的工具选择有明显分流。 +- 不出现“局部请求强行全局体检”的高频行为。 +- 日志可还原“本轮 scope 是什么、为何选择该任务、成功判据是否达成”。 + +--- + +## 5.4 P1-D:执行模式标记(状态层,建议) +定义: +- 给执行链路显式模式,避免仅靠 prompt 推断。 + +改动动作(建议): +- 修改 [common_state.go](/D:/SmartFlow-Agent/backend/newAgent/model/common_state.go) +- 新增 `OptimizationMode string`。 +- 在 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/node/chat.go) 路由处设置模式: +- 首次编排粗排后微调 -> `first_full` +- 局部调整请求 -> `local_adjust` +- 明确全局重优化请求 -> `global_reopt` + +验收标准: +- `execute` 运行日志中可观测到模式值。 +- 恢复场景不丢模式(随 RuntimeState 快照持久化)。 + +--- + +## 5.5 P1-E:收口与质量防抖(执行层) +定义: +- 不改变现有阈值,只补齐可观测数据与兜底日志。 + +改动动作: +- 使用现有收口规则:`critical=0 && warning<=1`、连续无效 3 轮收口、60 轮上限。 +- 在 `analyze_health` 返回里统一输出 `issues`,供 LLM 与日志一致引用。 +- 当 `feasibility.is_feasible=false` 时,禁止继续常规微调回路(`move/swap` 反复试探)。 + +验收标准: +- 收口判断与 PRD 一致。 +- 日志可还原“每轮依据哪个 issue 在优化”。 +- 不可行场景下不会跑满无意义轮次。 + +--- + +## 5.6 P1-F:不可行协商分支(新增) +定义: +- 把“排不下”与“排不好”拆开处理;不可行时转入用户协商,而非继续磨轮次。 + +改动动作: +- 在 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/node/execute.go) 增加分支规则: +- 若最近一次 `analyze_health` 明确 `is_feasible=false`: +- 本轮优先 `ask_user`,给出四类选项(扩窗口/放松约束/降范围预算/接受风险收口)。 +- 若用户未调整约束且明确继续当前方案,允许“有风险收口”。 +- 若用户调整了约束,重开一轮入场判定并继续。 + +- 在 [prompt/execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go) 增加硬约束提示: +- 不可行时禁止继续无目标微调。 +- 不可行时必须先沟通约束变更。 + +验收标准: +- 人工构造“明显排不下”样例时,模型会在少量轮次内进入协商,不会持续 `critical` 循环。 +- 协商后可恢复正常优化或风险收口,路径清晰可追溯。 + +--- + +## 6. P1.5 实施清单(逐项) + +## 6.1 P1.5-A:Prompt 草案生成 +定义: +- 提供“完整任务类草案”生成能力(聊天触发),不新增后端草案工具。 + +改动动作: +- 修改 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/chat.go) 与 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go): +- 明确“任务类草案由 LLM 直接生成”的提示词约束。 +- 明确“关键字段必须 ask_user,普通字段可静默落”的输出约束。 +- 明确“来源优先级与冲突显式化”规则。 + +验收标准: +- 任意输入可生成完整草案结构(无新增工具调用)。 +- 关键字段缺失时会触发 ask_user,不会直接进入 `upsert_task_class`。 + +--- + +## 6.2 P1.5-B:写库工具落地(`upsert_task_class`) +定义: +- 新增统一任务类写库入口,承接“草案确认后落库”。 + +改动动作: +- 新增文件建议: +- `tools/task_class_write.go` +- `tools/task_class_write_types.go` +- 修改 [registry.go](/D:/SmartFlow-Agent/backend/newAgent/tools/registry.go): +- 注册 `upsert_task_class`。 +- 加入 `writeTools`(必须 confirm)。 +- 加入 `scheduleFreeTools`(不依赖 `ScheduleState`,可在纯聊天草案场景调用)。 +- 工具内部复用 DAO 事务能力:`AddOrUpdateTaskClass` + `AddOrUpdateTaskClassItems`。 +- 写后补齐字段合法性与完整性校验,统一返回可修复 `issues`。 + +验收标准: +- `id=0` 可创建成功,`id>0` 可更新成功,且返回稳定 `task_class_id`。 +- confirm 拒绝时不发生写入。 +- 写后校验失败时可稳定回到对话修订,不出现“写入后静默失败”。 + +--- + +## 6.3 P1.5-C:触发策略(聊天入口) +定义: +- 仅聊天触发,不加按钮分支。 + +改动动作: +- 调整 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/chat.go) 路由提示: +- 识别“设计任务类/补全任务类参数/生成任务类草案”等意图。 +- 路由建议继续走 `execute`(复用现有链路),不新增节点、不新增草案工具。 +- 调整 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go): +- 明确草案阶段只读/对话,落库阶段统一调用 `upsert_task_class`。 +- 明确“关键字段未确认禁止写库”的硬约束。 + +验收标准: +- 用户自然语言可稳定触发草案生成流程。 +- 关键字段确认后可稳定触发 `upsert_task_class` 落库。 +- 不出现“创建第二聊天区”的交互分叉。 + +--- + +## 7. 分阶段提交建议(按 PR 切) + +### PR-1(P1 工具层) +- 新增 5 分析工具实现 + 单元级自测(本地运行后清理临时测试文件)。 +- 不动 prompt,不动 chat 路由。 + +### PR-2(P1 策略层) +- registry 注册 + execute prompt/示例补齐 + 可选 `OptimizationMode`。 +- 联调首次编排与局部请求两条路径。 +- 联调“不可行协商分支”(避免持续 critical 循环)。 +- 联调“后端给边界,LLM 自主选目标”的读写闭环,并验证 P1 隐藏 `min_context_switch`。 + +### PR-3(P1.5 草案能力) +- prompt 草案生成约束 + 关键字段确认流 + `upsert_task_class` 写库工具接线 + 写后校验回传约束。 + +### PR-4(联调与收口) +- 统一日志字段、错误返回格式、文档回填(含 PRD 对应项映射)。 + +--- + +## 8. 验收与回滚 + +### 8.1 验收检查 +- 功能验收: +- 首次编排触发全流程分析策略。 +- 局部调整默认旧链路,不误触发全局分析。 +- 任务类草案可聊天触发,按“草案 -> 关键字段确认 -> 写入 -> 写后校验”链路闭环。 +- 任务类最终落库统一通过 `upsert_task_class`,且受 confirm 闸门保护。 + +- 质量验收: +- 无新增死循环风险(轮次与无效轮次机制保持)。 +- 写工具确认闸门不退化(A/B/C 硬规则仍生效)。 +- 不可行场景可被识别并进入协商分支,不再无效磨轮。 +- 写入前具备 scope 证据,且目标对象由 LLM 自主选择(非后端硬编码选点)。 + +### 8.2 回滚策略 +- 工具级开关:先通过注册表控制可见性(临时下线单工具不影响主链)。 +- Prompt级回滚:保留旧提示模板版本,出现偏航可快速切回。 +- 状态字段回滚:新增字段仅追加,删除前先做兼容读取。 + +--- + +## 9. 本轮对齐清单(逐项勾选) +- [ ] 1. 是否采用 `OptimizationMode` 显式模式字段(建议:采用)? +- [ ] 2. P1 是否严格限定 5 个分析工具(不含 deadlines)? +- [ ] 3. 分析工具返回包络是否冻结为 `metrics/issues/next_actions`? +- [ ] 4. P1.5 是否确认复用 execute 链路,不新增独立 graph 节点? +- [ ] 5. PR 拆分是否采用 `PR-1~PR-4` 顺序? +- [ ] 6. 是否冻结“可行性判定 + 不可行协商分支”为 P1 必做项? +- [ ] 7. 是否冻结“后端只给边界,LLM 自主选目标与参数”为执行原则? +- [ ] 8. 是否冻结 P1 默认禁用 `min_context_switch`(不暴露给 execute 候选写工具)? +- [ ] 9. 是否冻结“P1.5 不新增 `build_task_class_draft`,草案改为纯 prompt 生成 + 写后校验”? +- [ ] 10. 是否冻结“P1.5 新增 `upsert_task_class` 作为唯一任务类写库入口(必须 confirm)”? + +--- + +## 10. 备注(关键现实约束) +- 当前 `ScheduleState` 不含单任务 `deadline_at/priority_group/urgency_threshold_at`,故不建议在 P1 实现 `analyze_deadlines`。 +- 若后续要做 `analyze_deadlines`,需先在 `conv/schedule_state.go` 映射 task 维度截止信息到工具态,再进入 P2。 diff --git a/backend/newAgent/ROADMAP.md b/docs/backend/ROADMAP.md similarity index 100% rename from backend/newAgent/ROADMAP.md rename to docs/backend/ROADMAP.md diff --git a/newagent-roadmap.md b/docs/backend/newagent-roadmap.md similarity index 100% rename from newagent-roadmap.md rename to docs/backend/newagent-roadmap.md diff --git a/backend/newAgent/prd文档.md b/docs/backend/prd文档.md similarity index 100% rename from backend/newAgent/prd文档.md rename to docs/backend/prd文档.md diff --git a/docs/backend/主动优化整套改造计划.md b/docs/backend/主动优化整套改造计划.md new file mode 100644 index 0000000..61168de --- /dev/null +++ b/docs/backend/主动优化整套改造计划.md @@ -0,0 +1,547 @@ +# 主动优化整套改造计划 + +## 1. 文档目的 + +本文档只回答一件事:这轮主动优化链路改完之后,整体会如何工作。 + +重点不放在实现细节,而放在以下 4 个问题: + +1. 哪些能力保留,哪些能力直接删除。 +2. `task_class`、粗排、主动优化三者之间的职责如何重新划分。 +3. 首次排程时,agent 的完整执行链路会变成什么样。 +4. 时间窗口过紧时,agent 应该如何自动放宽要求,避免陷入无意义重试。 + +--- + +## 2. 改造后的核心原则 + +### 2.1 LLM 只负责“语义认知优化” + +这轮改造后的总原则是: + +1. 确定性算法负责“排得下、排得合法、排得别太离谱”。 +2. LLM 负责“学起来舒不舒服、搭配顺不顺、是否符合用户偏好”。 + +换句话说,LLM 不再负责: + +1. 全局负载均衡。 +2. 均匀铺开任务。 +3. 追逐空窗、碎片率、最大 gap 之类的统计指标。 +4. 为了把报表修漂亮而反复搬运任务。 + +LLM 保留的价值只有两类: + +1. 学科语义理解。 +2. 基于语义和偏好的认知微调。 + +### 2.2 主动优化从“统计修表”改成“认知微调” + +改造完成后,主动优化不再问: + +1. 哪天更满。 +2. 哪天更空。 +3. 哪门课间隔是不是又多了 1 天。 +4. 空窗碎片率是不是还可以再低一点。 + +改造完成后,主动优化只问: + +1. 这一天切换是不是太碎。 +2. 两门课放在一起,认知上是不是太累。 +3. 连续学习块是不是太长。 +4. 当前安排是不是违背了用户偏好。 +5. 在当前时间窗口下,这个问题是不是值得继续修。 + +--- + +## 3. 工具去留 + +## 3.1 保留 + +### 3.1.1 `analyze_health` + +保留,且继续作为主动优化的唯一总入口。 + +新职责: + +1. 汇总当前排程在认知节奏上的主要问题。 +2. 汇总当前排程和用户偏好的冲突。 +3. 判断当前是否还有足够可调整空间继续优化。 +4. 判断当前是否已经可以合理收口。 + +### 3.1.2 `analyze_rhythm` + +保留,作为 `analyze_health` 的下钻工具。 + +新职责: + +1. 解释某一天为什么学起来别扭。 +2. 解释某几个任务为什么不适合连在一起。 +3. 解释当前切换多、连续块长、高强度相邻等问题落在哪些具体任务上。 + +### 3.1.3 现有点查工具 + +全部保留,尤其是: + +1. `query_range` +2. `query_target_tasks` +3. `query_available_slots` +4. `get_task_info` + +原因很简单: + +1. `health/rhythm` 只负责指出问题和方向。 +2. LLM 真正落到“挪哪个任务、往哪里挪”时,仍然必须依赖这些点查工具。 + +### 3.1.4 现有写工具 + +全部保留。 + +主动优化改的是“如何观察和决策”,不是“如何写入”。 + +但写工具在主动优化里的使用优先级要重排: + +1. `slack` 高时,允许 `move` 和 `swap` 一起参与小范围微调。 +2. `slack` 低时,默认优先考虑 `swap`,不优先考虑 `move`。 +3. `slack` 低时若使用 `swap`,只允许交换属于不同 `task_class` 的任务。 +4. 这样做的目的不是保守,而是用“跨类互换”天然保证类内顺序不被破坏。 + +--- + +## 3.2 删除 + +### 3.2.1 删除 `analyze_load` + +原因: + +1. 负载均衡是确定性算法的职责。 +2. 它会强烈诱导 LLM 变成搬格子苦力。 +3. 它无法体现 LLM 真正有优势的学科语义判断。 + +### 3.2.2 删除 `analyze_tolerance` + +原因: + +1. 容错本质上是粗排风格与窗口宽松度问题。 +2. 它不适合作为主动优化主链路的独立分析工具。 +3. 它容易继续把模型引向“留空窗/修空窗”的伪目标。 + +### 3.2.3 删除所有 gap/load/tolerance 驱动指标 + +以下指标全部退出主动优化链路: + +1. `max_gap` +2. `avg_gap` +3. `gap_std_dev` +4. `fragmentation_rate` +5. `avg_gap_size` +6. `max_gap_size` +7. `days_without_buffer` +8. `utilization_rate` +9. `load_std_dev` +10. `load_range` +11. `budget_progress` +12. `days_to_end` + +说明: + +1. 它们可以在未来作为统计观察数据重建。 +2. 但本轮改造后,它们不再参与主动优化决策,不再生成 issue,不再生成 next action。 + +--- + +## 4. `task_class` 改造后会怎样 + +## 4.1 新增 3 个语义字段 + +每个 `task_class` 新增以下字段: + +1. `subject_type` +2. `difficulty_level` +3. `cognitive_intensity` + +这 3 个字段只服务于一个目标:让后续排程和主动优化不再对着学科名裸猜。 + +## 4.2 写入时机 + +这 3 个字段不在排程时临时生成,而是在创建或更新 `task_class` 时就提前写好。 + +也就是说: + +1. 用户创建任务类。 +2. LLM 在任务类阶段补全这 3 个字段。 +3. 任务类一旦落库,后续粗排和主动优化都直接读取。 + +兜底策略: + +1. 老数据如果没有这 3 个字段,排程时允许临时现判一次。 +2. 现判完成后,应补写回 `task_class`,避免下次重复猜。 + +## 4.3 这 3 个字段后续如何被使用 + +粗排阶段: + +1. 可以作为轻量参考,但不是主驱动。 + +主动优化阶段: + +1. `analyze_health` 直接消费这 3 个字段。 +2. `analyze_rhythm` 直接消费这 3 个字段。 +3. LLM 在诊断“背靠背是否太累、连续块是否太长、某种切换是否合理”时,统一以这 3 个字段为事实基础。 + +--- + +## 5. 粗排算法改造后会怎样 + +## 5.1 粗排负责的事 + +改造后,粗排要提前吃掉原本不该交给 LLM 的工作。 + +粗排的职责固定为: + +1. 保证可行。 +2. 保证顺序合法。 +3. 保证基础分布别太离谱。 +4. 保证不要明显堆到少数几天。 +5. 保证不要把整段窗口排成毫无操作空间的死局。 + +## 5.2 粗排不负责的事 + +粗排不追求: + +1. 认知体验最优。 +2. 学科搭配最优。 +3. 用户偏好最优。 + +这些交给 LLM 后续做 1 到 2 轮小范围微调。 + +## 5.3 粗排后的预期结果 + +粗排完成后,产物应该是: + +1. 一个合法可执行的初稿。 +2. 一个从统计上看不难看,但未必最舒服的日程。 +3. 一个仍然留有少量可调整空间的底盘。 + +也就是说,粗排之后不需要“完美”,只需要“足够好,值得微调”。 + +--- + +## 6. `analyze_health` 改造后会怎样 + +## 6.1 定位 + +`analyze_health` 变成“认知健康总览”。 + +它不再是统计体检工具,而是 execute 阶段判断“要不要继续动、该往哪种认知方向动”的入口。 + +## 6.2 新职责 + +改造后它只看三件事: + +1. 当前认知节奏是否别扭。 +2. 当前安排是否违背用户偏好。 +3. 当前窗口是否还允许继续优化。 + +## 6.3 新输出口径 + +它输出的问题应该是这种风格: + +1. 某天高强度切换过多。 +2. 两门高强度课背靠背。 +3. 某天连续高强度学习块过长。 +4. 当前安排违背“早上别排硬课”之类的用户偏好。 +5. 当前可调整空间过低,剩余问题属于必要妥协。 + +它不再输出这种风格: + +1. 哪天负载更满。 +2. 最大空窗还有几天。 +3. 空窗碎片率还可以再压多少。 +4. 某一科是不是再均匀一点更漂亮。 + +## 6.4 新 `can_close` 含义 + +改造后,`can_close` 的语义要收紧为: + +1. 当前没有明显值得继续修的认知问题。 +2. 当前没有明显违背用户偏好的安排。 +3. 或者虽然还存在小问题,但当前 `slack` 已低,继续优化收益不高。 + +也就是说,`can_close` 不再由统计指标主导,而由“是否还有高价值认知问题”主导。 + +--- + +## 7. `analyze_rhythm` 改造后会怎样 + +## 7.1 定位 + +`analyze_rhythm` 变成 `analyze_health` 的明细镜。 + +只有当 `health` 发现某类认知问题值得继续查时,才进一步调用 `rhythm`。 + +## 7.2 新职责 + +它要回答的不是“排得均不均”,而是: + +1. 哪一天切换太碎。 +2. 哪一段连续块太长。 +3. 哪几个任务挨在一起会特别累。 +4. 哪些切换虽然换科了,但其实仍属于同一种脑力模式。 + +## 7.3 新输出风格 + +它的输出重点应围绕: + +1. 日内切换次数。 +2. 连续学习块结构。 +3. 高强度相邻关系。 +4. 同类/异类学科切换关系。 +5. 某一天内部的认知压力分布。 + +它不再承担: + +1. 跨天 gap 追踪。 +2. 学科分散度统计优化。 +3. 预算推进告警。 + +--- + +## 8. 新增 `slack` 后会怎样 + +## 8.1 为什么必须加 `slack` + +有些用户给的时间窗口非常紧。 + +这时“高强度背靠背”不一定是错误,而可能是当前窗口下的必要代价。 + +如果没有 `slack` 概念,agent 会误以为: + +1. 这是可修的问题。 +2. 我应该继续搬。 +3. 继续搬总能更好。 + +然后就进入无意义重试。 + +## 8.2 `slack` 的职责 + +`slack` 不负责决定“舒服不舒服”,只负责决定“还有没有优化余地”。 + +也就是说,它是健康分析里的第二层判断: + +1. 有问题,不代表值得继续修。 +2. 值得继续修,还要看当前有没有空间修。 + +## 8.3 `slack` 接入后的行为 + +改造后: + +1. 若 `slack` 高,按正常标准检查认知问题。 +2. 若 `slack` 中,允许小问题存在,但仍可做 1 次小范围微调。 +3. 若 `slack` 低,自动放宽要求,允许必要的背靠背、较长连续块、略多切换。 +4. 若 `slack` 低但仍存在明显可改善的认知问题,优先尝试一次低成本 `swap`,而不是优先尝试 `move`。 +5. 这个 `swap` 必须限定为“只交换不同 `task_class` 的任务”,从而避免打乱任一类内部顺序。 +6. 若一次 `swap` 后没有明显改善,则倾向收口,不进入连续搬运。 + +## 8.4 `slack` 带来的收口变化 + +改造后,agent 不会再因为下面这类场景反复挣扎: + +1. 时间太紧,不得不连着上两门硬课。 +2. 可动任务几乎都被前驱后继夹死。 +3. 当前再动只会拆东墙补西墙。 + +这时 `analyze_health` 应直接给出结论: + +1. 当前仍有认知妥协点。 +2. 但由于可调整空间有限,已属于合理结果。 +3. 可以收口,或只在用户明确要求时继续深挖。 + +--- + +## 9. 改造后的首次排程完整链路 + +这是你最关心的部分:改完以后,首次排程到底怎么跑。 + +## 9.1 第 0 步:任务类先带语义字段 + +在真正排程前,相关 `task_class` 已经具备: + +1. `subject_type` +2. `difficulty_level` +3. `cognitive_intensity` + +如果缺失,先补齐再进入完整主动优化链路。 + +## 9.2 第 1 步:确定性粗排先出底盘 + +系统先用确定性算法完成一版粗排。 + +这一步的结果要求是: + +1. 可排下。 +2. 顺序合法。 +3. 分布不难看。 +4. 还留有一点可调整空间。 + +## 9.3 第 2 步:进入 `analyze_health` + +粗排完成后,不再先看 load,不再先看 gap,而是直接进入 `analyze_health`。 + +这一步会判断: + +1. 当前有哪些高价值认知问题。 +2. 当前是否存在用户偏好冲突。 +3. 当前 `slack` 高不高。 +4. 当前是否值得继续动。 + +## 9.4 第 3 步:必要时下钻 `analyze_rhythm` + +只有当 `health` 发现值得修的问题时,LLM 才进一步调用 `analyze_rhythm`。 + +这一步的作用是: + +1. 把问题定位到某一天、某几个任务、某种相邻关系。 +2. 给 LLM 读写工具调用提供更明确的认知方向。 + +## 9.5 第 4 步:LLM 用旧点查工具锁定目标 + +接下来 LLM 不会根据 `health/rhythm` 直接拍脑袋写入。 + +它仍然要走: + +1. `query_range` +2. `query_target_tasks` +3. `query_available_slots` +4. `get_task_info` + +也就是说,新的分析工具负责“告诉它为什么动、朝哪动”,旧点查工具负责“告诉它具体怎么动”。 + +当 `slack` 低时,这一步的目标还会进一步收窄为: + +1. 先找有没有值得做的一次性交换机会。 +2. 优先找跨 `task_class` 的互换对象。 +3. 只有在没有合适 `swap`,且单步 `move` 的收益明显高于风险时,才考虑 `move`。 + +## 9.6 第 5 步:LLM 做 1 到 2 次小范围微调 + +改造后,主动优化默认只做小范围微调,不做全盘翻修。 + +默认目标是: + +1. 消除最明显的认知别扭点。 +2. 避免新问题比旧问题更重。 +3. 不为了报表漂亮而继续搬运。 + +这里再补一条强规则: + +1. `slack` 高时,可以正常比较 `move` 与 `swap`。 +2. `slack` 低时,优先考虑一次跨 `task_class` 的 `swap` 来调整不同科目间的相对顺序。 +3. `slack` 低时,不鼓励进入多步 `move` 链路。 +4. `swap` 的价值在于:它更像“整理现有坑位里的学科顺序”,而不是“重新开一轮搬家”。 + +## 9.7 第 6 步:再做一次 `analyze_health` + +写操作后再次进入 `analyze_health`。 + +这一步不是看统计报表有没有更均匀,而是看: + +1. 主要认知问题是否缓解。 +2. 用户偏好冲突是否减少。 +3. 当前 `slack` 是否已不支持继续动。 +4. 是否可以收口。 + +## 9.8 第 7 步:合理收口 + +最终存在三种收口方式: + +1. 问题已明显改善,可以正常收口。 +2. 还存在小问题,但 `slack` 过低,按“合理妥协”收口。 +3. 用户明确还不满意,再继续下一轮。 + +其中第 2 种收口还要补一层判断: + +1. 不是一看到 `slack` 低就立刻停手。 +2. 而是先看是否存在一次低成本、跨 `task_class` 的 `swap` 机会。 +3. 若存在且收益明确,可先做这一次整理式调整。 +4. 若不存在,或做完后仍无明显改善,再按“合理妥协”收口。 + +--- + +## 10. 改造后的局部调整链路 + +改造后,不是所有用户请求都要走完整主动优化链路。 + +## 10.1 默认仍走旧链路 + +用户如果只是说: + +1. 把这个任务挪一下。 +2. 这节课换一天。 +3. 给我把这个排到周末。 + +这类请求默认继续走旧点查 + 旧写工具链路。 + +原因: + +1. 这是局部执行问题。 +2. 不值得每次都拉起 `health/rhythm` 做一轮体检。 + +## 10.2 只有两类情况再启动主动优化分析 + +1. 首次排程。 +2. 用户明确表达认知感受或结构性问题。 + +例如: + +1. “切换太多了,心累。” +2. “这些硬课连着看着就难受。” +3. “帮我整体调顺一点。” + +--- + +## 11. 改造后的最终表现 + +改完以后,这条链路的预期表现应该是: + +1. 用户创建任务类时,学科语义先被沉淀下来。 +2. 粗排算法先给出一个合法且分布不难看的初稿。 +3. 主动优化不再围着负载、空窗、gap 打转。 +4. `analyze_health` 只关心认知体验、偏好冲突、可调整空间。 +5. `analyze_rhythm` 只负责解释具体哪段学起来别扭。 +6. LLM 只做 1 到 2 次高价值认知微调,不再做长链路苦力搬运。 +7. 时间窗口很紧时,agent 会承认“这是必要妥协”,而不是继续死磕。 + +一句话总结: + +改造后,这套链路不再追求“把报表修漂亮”,而是追求“把这份日程修得更像人能学下去”。 + +--- + +## 12. 实施顺序 + +按以下顺序落地: + +1. 先改 `task_class`,补 3 个语义字段及写入链路。 +2. 再删 `analyze_load`、`analyze_tolerance` 及相关主链路接入。 +3. 再重写 `analyze_health` 的职责、指标和收口口径。 +4. 再重写 `analyze_rhythm` 的职责和输出结构。 +5. 再补 `slack` 及其自动放宽规则。 +6. 最后改 execute prompt 和链路收口逻辑。 + +这样做的原因是: + +1. 先有语义数据,分析工具才不至于空转。 +2. 先把旧统计驱动砍掉,execute 才不会继续被错误方向牵着跑。 +3. 最后再调 prompt,才不会变成给旧结构打补丁。 + +--- + +## 13. 本轮验收口径 + +如果改造成功,至少应满足以下表现: + +1. 首次排程时,agent 不再为了负载均匀或空窗漂亮反复搬任务。 +2. 日志中的主动优化理由,主要变成认知体验和偏好,而不是统计指标。 +3. 当时间很紧时,agent 会主动接受必要妥协,不再死循环。 +4. 当用户只是提局部挪动需求时,不会动辄拉起全局体检。 +5. 主动优化完成后的结果,解释口径更像“为什么这样学更顺”,而不是“哪些数字变好看了”。 diff --git a/docs/backend/主动优化顺序约束拆分执行计划.md b/docs/backend/主动优化顺序约束拆分执行计划.md new file mode 100644 index 0000000..2eb49be --- /dev/null +++ b/docs/backend/主动优化顺序约束拆分执行计划.md @@ -0,0 +1,523 @@ +# 主动优化顺序约束拆分执行计划 + +## 1. 本轮目标 + +本轮要解决的不是单点 bug,而是一个架构错位: + +1. 主动优化希望 LLM 在窗口内自主微调,围绕负载、节奏、容错做多轮观察与挪动。 +2. 现有顺序保护却是“全局 suggested 基线 + 收口时自动复原”,本质是事后抢救。 +3. 两者叠加后,LLM 前面刚优化完,后面又可能被 `order_guard` 否掉,甚至否不回去,只能带着异常结果交付。 + +因此,本轮的核心目标是: + +1. 把“顺序约束”从 graph 收口节点,下沉为写工具层的前置约束。 +2. 把“全局顺序冻结”改成“允许跨科目交错,但锁住同任务类内部顺序”。 +3. 顺手修掉当前主动优化链路里由旧守卫带来的提示污染、卡片误导、兼容性 bug。 +4. 借这次改造,把 `node/execute.go` 继续拆职责,避免后续主动优化逻辑继续堆在单文件里。 + +--- + +## 2. 当前问题诊断 + +### 2.1 产品语义错位 + +当前系统默认语义仍是: + +1. `AllowReorder=false` 时,尽量保持所有 suggested 的全局相对顺序。 +2. 若被打乱,则在 `order_guard` 节点尝试按 baseline 复原。 + +这和我们已经对齐的新产品语义冲突: + +1. 用户默认不是“完全不许动顺序”。 +2. 用户要的是“每门课内部别乱序,但不同课之间可以交错来换负载”。 +3. 主动优化阶段的目标是优化坑位分布,不是死守粗排全局序列。 + +### 2.2 约束位置放错了 + +当前顺序保护发生在: + +1. `execute` 完成后。 +2. `graph/order_guard` 收口前。 + +这会导致三个问题: + +1. 非法移动已经发生,后面只能补救。 +2. 补救失败也不会阻断交付,只会吐一句“顺序异常但未复原”。 +3. LLM 在执行时完全不知道哪些移动其实不该做,容易白跑。 + +### 2.3 约束粒度过粗 + +当前基线是“所有 suggested 任务的时间顺序快照”,这会把下面两类本来合理的操作也一起误伤: + +1. 不同任务类之间为了均衡负载而做的交错。 +2. 在不破坏科目内部先后关系的前提下做的跨天平衡。 + +### 2.4 当前 bug 已经暴露 + +从日志看,至少已有这些具体问题: + +1. `order_guard` 尝试复原时出现 `slot_incompatible`。 + - 本质说明旧复原逻辑对“任务时长单位”和“坑位跨度单位”的理解并不稳。 + - 这条链本来就不该继续扩展,而该整体退场。 +2. 前端会收到“已记录本轮建议任务顺序基线”“顺序异常但未执行自动复原”这类对用户价值很低的系统话术。 +3. `execute` prompt 仍在强调“默认保持 suggested 相对顺序”,这会继续把模型往旧目标上拽。 +4. `spread_even` / `move` / `swap` / `batch_move` 当前都不知道“同任务类兄弟节点边界”,所以无法在写入前拦住越界调整。 + +### 2.5 代码结构已经不适合继续堆功能 + +当前 `node/execute.go` 已经承载了: + +1. execute 主循环。 +2. 工具执行。 +3. 工具结果摘要。 +4. feasibility 守门。 +5. task class 写入状态回盘。 +6. preview 实时写。 +7. 顺序相关拦截。 +8. scope 解析。 + +这类文件继续加主动优化逻辑,后续回归会越来越难定位。 + +--- + +## 3. 目标行为 + +改造后的目标行为如下: + +1. LLM 仍然可以主动观察、主动微调、再观察,不退化成一次性确定性求解。 +2. 默认允许跨任务类交错调整。 +3. 默认不允许打乱同一任务类内部的学习顺序。 +4. 每次写工具调用前,后端都能判断这次移动是否越过“同任务类上一个/下一个任务”的合法边界。 +5. 如果越界,工具直接返回失败原因,让 LLM 换别的任务或别的坑位,而不是先写进去、最后再抢救。 +6. 交付阶段不再出现旧 `order_guard` 的提示文案,也不再依赖它去修复顺序。 + +一句话概括: + +> 允许跨科目穿插优化,但每门课内部始终保持原有学习推进顺序。 + +--- + +## 4. 必须补齐的数据 + +这是本轮最关键的数据面。没有这些字段,后端没法在写工具层判断“这个任务能挪到哪”。 + +### 4.1 任务类内部顺序 rank + +当前 `ScheduleTask` 里有: + +1. `TaskClassID` +2. `SourceID`(`task_item.id`) + +但没有: + +1. 该 `task_item` 在所属任务类里的 `order` + +这意味着后端知道“它属于哪门课”,但不知道“它是这门课里的第几个任务”。 + +本轮需要补: + +1. 在 `schedule.ScheduleTask` 增加类似 `TaskOrder` 的运行态字段。 +2. 在 `conv/schedule_state.go` 从 `model.TaskClassItem.Order` 映射进来。 + +### 4.2 顺序边界计算所需的同类兄弟信息 + +有了 `TaskClassID + TaskOrder` 后,不一定非要把前后兄弟 ID 也落进 state;两种方案都可行: + +1. 轻量方案:运行时动态扫描同任务类任务,按 `TaskOrder` 算前驱/后继。 +2. 预计算方案:在 state 初始化时直接建立 sibling index。 + +本轮建议先走轻量方案,原因: + +1. 改动面更小。 +2. 不引入新的状态同步负担。 +3. 足够支撑写工具前置校验。 + +### 4.3 合法时间边界的统一定义 + +需要明确一个统一规则: + +1. 一个任务的目标位置,必须晚于同任务类前驱任务的结束时间。 +2. 必须早于同任务类后继任务的开始时间。 +3. 若前驱/后继不存在,则该侧边界开放。 +4. 若前驱/后继当前是 pending、未落位,则该侧边界暂不收紧。 + +这样 LLM 仍有自由度,但自由度被严格限制在“本任务合法活动区间”里。 + +--- + +## 5. 方案总览 + +### 5.1 总体策略 + +本轮不再沿用“先放任移动,最后 graph 收口时修”的模式,而改成: + +1. 写工具调用前先验边界。 +2. 合法才允许写。 +3. 非法直接返回失败。 +4. 收口阶段只做轻量断言,不再自动复原。 + +### 5.2 顺序保护新哲学 + +旧哲学: + +1. 保护粗排全局时间序列。 + +新哲学: + +1. 保护每个任务类内部的推进顺序。 +2. 不保护不同任务类之间的相对先后。 + +### 5.3 对主动优化的意义 + +这套改法的直接意义是: + +1. LLM 终于可以真的做“负载优化”而不是被全局顺序锁死。 +2. LLM 即使选错目标,也会在写工具层收到具体失败原因。 +3. 失败原因足够明确时,模型下一步就知道该换任务、换天、还是换工具。 + +--- + +## 6. 具体拆分与改动计划 + +## 6.1 第一步:给 ScheduleState 补顺序语义 + +涉及文件: + +1. `backend/newAgent/tools/schedule/state.go` +2. `backend/newAgent/conv/schedule_state.go` + +计划动作: + +1. 在 `ScheduleTask` 增加任务类内部顺序字段。 + - 建议名:`TaskOrder int` +2. 仅 `source=task_item` 时填充该字段。 +3. 从 `model.TaskClassItem.Order` 注入运行态。 +4. 对缺失 order 的历史数据做兜底。 + - 优先使用数据库 order。 + - 若为空,则按 `TaskClass.Items` 当前顺序补稳定序号。 + +验收结果: + +1. 每个 `task_item` 在工具层都能知道自己是所属任务类里的第几项。 +2. 查询工具输出里不一定要暴露这个字段给 LLM,但后端必须可用。 + +## 6.2 第二步:新增“局部顺序约束”公共层 + +涉及文件: + +1. 新增 `backend/newAgent/tools/schedule/order_constraints.go` +2. 复用 `backend/newAgent/tools/schedule/write_helpers.go` + +计划动作: + +1. 抽一个独立公共层,不把顺序判断散落在每个写工具里重复写。 +2. 公共层职责只做一件事:判断某个任务能否落到某个目标时段。 +3. 需要提供的核心能力: + - 找到同任务类前驱任务 + - 找到同任务类后继任务 + - 计算合法最早起点 / 最晚终点 + - 判断目标位置是否越界 + - 输出中文失败原因 + +建议返回信息: + +1. `ok=true/false` +2. 失败原因中文摘要 +3. 命中的前驱/后继任务是谁 +4. 合法范围描述 + +这样后面各写工具都能直接复用,不再复制逻辑。 + +## 6.3 第三步:把约束前置到基础写工具 + +涉及文件: + +1. `backend/newAgent/tools/schedule/write_tools.go` + +计划动作: + +1. `move` 接入局部顺序约束。 +2. `swap` 在交换前对双方交换后的目标位置分别校验。 +3. `batch_move` 在克隆态上统一校验整批目标是否都满足局部顺序约束。 +4. `place` 也要接入。 + - 因为被 `unplace` 后再次放回,仍然可能破坏同类顺序。 +5. `unplace` 暂时不做顺序阻断。 + - 它只是把任务拿出来,不直接打乱同类内部先后。 + - 真正的顺序问题应在后续 `place/move` 时拦截。 + +验收目标: + +1. 任一基础写工具都不能把任务挪出自己的合法兄弟区间。 +2. 非法时工具直接失败,且提示能被 LLM 看懂。 + +## 6.4 第四步:让复合写工具也遵守边界 + +涉及文件: + +1. `backend/newAgent/tools/schedule/compound_tools.go` + +计划动作: + +1. `spread_even` 生成候选位置后,回填前逐任务校验局部顺序边界。 +2. 若规划器给出的结果越界,整次复合写失败并给出明确原因。 +3. `min_context_switch` 继续维持 P1 不暴露。 +4. 即使未来重开,也必须走同一套局部顺序约束,不允许绕过。 + +原因: + +1. 复合工具最容易“整体看起来更均匀,但把单科内部顺序打乱”。 +2. 如果只拦基础写工具,不拦复合工具,系统规则会不一致。 + +## 6.5 第五步:退役旧 order_guard + +涉及文件: + +1. `backend/newAgent/node/order_guard.go` +2. `backend/newAgent/graph/common_graph.go` +3. `backend/newAgent/model/common_state.go` +4. `backend/newAgent/node/execute.go` + +计划动作: + +1. 移除“全局 baseline + 收口复原”的主逻辑。 +2. 删除或停用 `SuggestedOrderBaseline` 运行态。 +3. 删除 `order_guard` 节点在主动优化链路中的强依赖。 +4. 交付前若仍需要安全兜底,只保留一个轻量 final assert: + - 仅检查每个任务类内部顺序是否仍合法 + - 不自动复原 + - 若非法,视为执行层 bug,直接中止交付并打日志 + +推荐做法: + +1. P1 先彻底切掉 graph 层 `order_guard` 分支。 +2. 若担心过渡期风险,再补一个极轻的校验函数在 deliver 前调用。 + +## 6.6 第六步:同步调整 prompt 与模型目标 + +涉及文件: + +1. `backend/newAgent/prompt/execute_context.go` +2. `backend/newAgent/prompt/execute.go` +3. 视需要补充 `prompt/execute_rule_packs.go` + +计划动作: + +1. 删除“默认保持 suggested 相对顺序”的旧表述。 +2. 改成新的明确描述: + - 默认保持同任务类内部顺序 + - 允许跨任务类交错调整 + - 不得擅自突破同任务类内部先后 +3. 把“非法时工具会直接失败”作为模型可感知规则写进 prompt。 + +这样 LLM 会更接近真实规则,不会一直沿着旧目标空转。 + +## 6.7 第七步:拆 execute.go 职责 + +涉及文件: + +1. `backend/newAgent/node/execute.go` +2. 新增若干并行文件 + +建议拆分方向: + +1. `node/execute.go` + - 只保留主循环、决策分发、节点入口 +2. `node/execute_scope_guard.go` + - 当前步骤作用域解析与日期范围守门 +3. `node/execute_tool_runtime.go` + - `executeToolCall` / `executePendingTool` / preview 写入 +4. `node/execute_tool_summary.go` + - 工具摘要、参数摘要、结果摘要 +5. `node/execute_taskclass_runtime.go` + - task class upsert 状态回盘相关 +6. `node/execute_health_runtime.go` + - feasibility / health 快照更新 + +这一步的目的不是“为了好看”,而是避免后面继续把主动优化规则、task class 流程规则、工具结果摘要全塞回一个文件。 + +--- + +## 7. 本轮顺手修复的 bug 清单 + +## 7.1 bug A:顺序异常提示污染用户体验 + +现象: + +1. 前端会看到“已记录本轮建议任务顺序基线” +2. 以及“检测到顺序异常,但本次未执行自动复原” + +修法: + +1. 随 `order_guard` 退役一起移除这两类状态文案。 +2. 这类内部守卫信息不再面向用户显式展示。 + +## 7.2 bug B:`slot_incompatible` 兼容性问题 + +现象: + +1. 日志里出现 `expected_duration=1 slot_duration=2` + +判断: + +1. 这是旧 `order_guard` 复原链上的单位不一致问题。 +2. 该问题不值得单独继续修补。 + +修法: + +1. 旧复原链退役后,这条 bug 自然消失。 +2. 本轮只保留一个动作:确认写工具本身的时长计算口径仍正确。 + +## 7.3 bug C:prompt 仍把模型往“全局不乱序”上引 + +现象: + +1. `execute_context` 里仍写着默认保持 suggested 相对顺序。 + +修法: + +1. 改成“默认保持同任务类内部顺序”。 + +## 7.4 bug D:复合工具可能绕过新规则 + +现象: + +1. `spread_even` 当前只校验冲突,不校验同类前后边界。 + +修法: + +1. 接入统一局部顺序约束层。 + +## 7.5 bug E:active optimize 链路和 execute 文件职责缠得太紧 + +现象: + +1. 任何主动优化 bug 都容易改进 `execute.go`,继续涨文件体积。 + +修法: + +1. 本轮同步拆文件,至少把工具执行与摘要逻辑拆出去。 + +--- + +## 8. 实施顺序 + +建议按下面顺序推进,避免中途状态既不兼容旧逻辑,也没完全切到新逻辑。 + +### 阶段 1:补数据 + +1. 给 `ScheduleTask` 增加 `TaskOrder` +2. 在 state loader 中完成映射 +3. 保证查询 / 粗排 / 预览链路不受影响 + +### 阶段 2:落局部顺序约束公共层 + +1. 实现前驱/后继查找 +2. 实现目标落位合法性判断 +3. 输出中文失败原因 + +### 阶段 3:接入基础写工具 + +1. `move` +2. `swap` +3. `batch_move` +4. `place` + +### 阶段 4:接入复合写工具 + +1. `spread_even` +2. 保持 `min_context_switch` 继续禁用 + +### 阶段 5:切掉旧 order_guard + +1. 删除 graph 分支 +2. 删除 baseline 运行态 +3. 去掉用户可见状态文案 + +### 阶段 6:更新 prompt + +1. 改目标描述 +2. 改顺序策略说明 +3. 明确非法写工具会被后端拒绝 + +### 阶段 7:拆 execute.go + +1. 先无行为变化拆文件 +2. 再补必要注释与最小验证 + +--- + +## 9. 验证口径 + +## 9.1 正向场景 + +要验证这些场景能通过: + +1. 同任务类内部顺序不变,但不同任务类交错后负载更均衡。 +2. LLM 将某任务从第 3 天挪到第 20 天,只要仍在其前后兄弟之间,就允许。 +3. `spread_even` 可以把多门课拉开,但不会把某一门课内部顺序反过来。 + +## 9.2 反向场景 + +要验证这些场景被拦住: + +1. 把某门课的第 4 个任务挪到第 1 个任务前面。 +2. 把某门课的中间任务挪到其后继任务之后。 +3. `swap` 后导致同任务类内部出现逆序。 +4. `batch_move` 中有一条越界时整批失败。 + +## 9.3 交付场景 + +要确认这些旧副作用消失: + +1. 不再出现 `order_guard_initialized` +2. 不再出现 `order_guard_restore_skipped` +3. 不再依赖 `SuggestedOrderBaseline` + +## 9.4 代码结构场景 + +要确认: + +1. `execute.go` 文件职责明显变轻 +2. 局部顺序约束逻辑只存在一份公共实现 + +--- + +## 10. 本轮建议的最小落地范围 + +如果要控制风险,本轮建议先做到这里: + +1. `TaskOrder` 注入 +2. 局部顺序约束公共层 +3. `move/swap/batch_move/place` 接入 +4. `spread_even` 接入 +5. prompt 改口径 +6. 切掉旧 `order_guard` +7. 拆出 `execute_tool_runtime.go` 与 `execute_tool_summary.go` + +这个范围已经足够让主动优化链路从“旧哲学打架”切到“新哲学能跑”。 + +--- + +## 11. 预期收益 + +做完之后,预期表现会变成: + +1. LLM 会更敢做真实优化,因为它不再被全局顺序锁死。 +2. 后端会在写工具层直接给出“能不能这么挪”的明确反馈。 +3. 同一门课的学习推进顺序能被稳定锁住。 +4. 不同门课之间仍有足够空间做均衡、分散、减压。 +5. 前端不会再收到旧 `order_guard` 带来的迷惑状态。 +6. 后续如果继续加主动优化策略,也有更干净的承载位置,不必继续往 `execute.go` 里堆。 + +--- + +## 12. 本文档对应的实施结论 + +本轮建议按以下原则执行: + +1. 删除“全局 suggested 顺序守卫”思路。 +2. 改为“同任务类内部顺序约束前置到写工具层”。 +3. 允许跨任务类交错优化。 +4. 顺手清理旧 guard 带来的用户可见噪音与兼容性问题。 +5. 同步拆分 execute 相关职责文件,避免继续堆史山。 + diff --git a/docs/智能排程四步走实施方案.md b/docs/backend/智能排程四步走实施方案.md similarity index 100% rename from docs/智能排程四步走实施方案.md rename to docs/backend/智能排程四步走实施方案.md diff --git a/docs/frontend-schedule-integration.md b/docs/frontend/frontend-schedule-integration.md similarity index 100% rename from docs/frontend-schedule-integration.md rename to docs/frontend/frontend-schedule-integration.md diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 6e32be2..1f311dc 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -153,6 +153,10 @@ interface DisplayAssistantBlock { event?: ToolTraceEvent statusEvent?: StatusTraceEvent schedulePreview?: SchedulePreviewData + /** 所属的源消息 ID,用于状态查询 */ + sourceId?: string + /** 所属的源消息引用,用于渲染辅助信息 */ + source?: AssistantMessage } interface AssistantContentBlock { @@ -223,6 +227,7 @@ const statusTraceEventsMap = reactive>({}) const toolTraceExpandedMap = reactive>({}) const assistantReasoningSeqMap = reactive>({}) const assistantContentBlocksMap = reactive>({}) +const assistantReasoningBlocksMap = reactive>({}) const assistantTimelineLastKindMap = reactive>({}) const conversationContextStatsMap = reactive>({}) const conversationContextStatsLoadingMap = reactive>({}) @@ -502,6 +507,11 @@ function appendToolTraceEvent( const eventSeq = nextAssistantTimelineSeq() const eventId = `${messageId}:tool:${eventSeq}` + // 如果上一个阶段是推理,则结束并折叠它 + if (assistantTimelineLastKindMap[messageId] === 'reasoning') { + finishCurrentReasoningBlock(messageId) + } + toolTraceEventsMap[messageId].push({ id: eventId, seq: eventSeq, @@ -536,6 +546,11 @@ function appendStatusTraceEvent( } const eventSeq = nextAssistantTimelineSeq() + // 如果上一个阶段是推理,则结束并折叠它 + if (assistantTimelineLastKindMap[messageId] === 'reasoning') { + finishCurrentReasoningBlock(messageId) + } + statusEvents.push({ id: `${messageId}:status:${eventSeq}`, seq: eventSeq, @@ -554,6 +569,11 @@ function appendAssistantContentChunk(messageId: string, chunk: string) { const blocks = assistantContentBlocksMap[messageId] const lastKind = assistantTimelineLastKindMap[messageId] + // 如果是从推理切换到正文,则结束并折叠推理块 + if (lastKind === 'reasoning') { + finishCurrentReasoningBlock(messageId) + } + if (lastKind === 'content' && blocks.length > 0) { blocks[blocks.length - 1]!.text += chunk return @@ -568,6 +588,41 @@ function appendAssistantContentChunk(messageId: string, chunk: string) { assistantTimelineLastKindMap[messageId] = 'content' } +/** + * 追加助理推理片段到特定消息的块映射中 + * 1. 采用与正文相同的块化存储逻辑,确保推理片段能按 sequence 与工具等交错排序 + * 2. 如果当前时间线最后一种类型就是 'reasoning',则追加到最后一个块,避免碎片化 + */ +function appendAssistantReasoningChunk(messageId: string, chunk: string) { + if (!chunk) { + return + } + if (!assistantReasoningBlocksMap[messageId]) { + assistantReasoningBlocksMap[messageId] = [] + } + const blocks = assistantReasoningBlocksMap[messageId] + const lastKind = assistantTimelineLastKindMap[messageId] + + if (lastKind === 'reasoning' && blocks.length > 0) { + blocks[blocks.length - 1]!.text += chunk + return + } + + const seq = nextAssistantTimelineSeq() + const blockId = `${messageId}:reasoning:${seq}` + blocks.push({ + id: blockId, + seq, + text: chunk, + }) + + // 记录块级别的起始时间和初始折叠状态 + reasoningStartedAtMap[blockId] = Date.now() + reasoningCollapsedMap[blockId] = false + + assistantTimelineLastKindMap[messageId] = 'reasoning' +} + function mapToolEventState(rawStatus?: string): ToolTraceState { const normalized = `${rawStatus || ''}`.trim().toLowerCase() if (normalized === 'start' || normalized === 'calling' || normalized === 'called') { @@ -993,22 +1048,21 @@ function markReasoningStart(message: AssistantMessage) { reasoningStartedAtMap[message.id] = Date.now() } -function markReasoningFinished(message: AssistantMessage) { - const startedAt = reasoningStartedAtMap[message.id] - if (startedAt && !reasoningDurationMap[message.id]) { - reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000)) +function markReasoningFinished(blockId: string, messageId: string) { + const startedAt = reasoningStartedAtMap[blockId] + if (startedAt && !reasoningDurationMap[blockId]) { + reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000)) } - - thinkingMessageMap[message.id] = false + thinkingMessageMap[messageId] = false } -function getReasoningDurationSeconds(message: AssistantMessage) { - const fixedDuration = reasoningDurationMap[message.id] +function getReasoningDurationSeconds(blockId: string) { + const fixedDuration = reasoningDurationMap[blockId] if (fixedDuration) { return fixedDuration } - const startedAt = reasoningStartedAtMap[message.id] + const startedAt = reasoningStartedAtMap[blockId] if (!startedAt) { return 0 } @@ -1016,13 +1070,28 @@ function getReasoningDurationSeconds(message: AssistantMessage) { return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000)) } -function getReasoningStatusLabel(message: AssistantMessage) { - const durationSeconds = getReasoningDurationSeconds(message) +function getReasoningStatusLabel(block: DisplayAssistantBlock) { + const durationSeconds = getReasoningDurationSeconds(block.id) if (durationSeconds > 0) { return `已思考(用时 ${durationSeconds} 秒)` } - return isStreamingMessage(message) && isThinkingMessage(message) ? '思考中' : '已思考' + const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId] + return isThinking ? '思考中' : '已思考' +} + +/** + * 结束当前消息正在进行的推理块 + * 1. 计算耗时 + * 2. 自动折叠 + */ +function finishCurrentReasoningBlock(messageId: string) { + const blocks = assistantReasoningBlocksMap[messageId] || [] + if (blocks.length === 0) return + const lastBlock = blocks[blocks.length - 1] + + markReasoningFinished(lastBlock.id, messageId) + reasoningCollapsedMap[lastBlock.id] = true } function isReasoningCollapsed(messageId: string) { @@ -1086,6 +1155,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] type: 'tool', seq: event.seq, event, + sourceId: source.id, + source, }) } @@ -1096,6 +1167,32 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] type: 'status', seq: statusEvent.seq, statusEvent, + sourceId: source.id, + source, + }) + } + + // 从推理块映射中提取所有独立的推理片段 + const reasoningBlocks = assistantReasoningBlocksMap[source.id] || [] + if (reasoningBlocks.length > 0) { + for (const rb of reasoningBlocks) { + blocks.push({ + id: rb.id, + type: 'reasoning', + seq: rb.seq, + text: rb.text, + sourceId: source.id, + source, + }) + } + } else if (source.id === activeStreamingMessageId.value && thinkingMessageMap[source.id]) { + // 流式过程中尚未有实质文本产出时的“思考中”占位块 + blocks.push({ + id: `${source.id}:reasoning:streaming`, + type: 'reasoning', + seq: assistantReasoningSeqMap[source.id] || 10, + sourceId: source.id, + source, }) } @@ -1108,6 +1205,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] type: 'content', seq: contentBlock.seq, text: contentBlock.text, + sourceId: source.id, + source, }) } continue @@ -1121,6 +1220,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] type: 'content', seq: fallbackSeq, text: source.content, + sourceId: source.id, + source, }) } } @@ -1135,16 +1236,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] }) } - if (shouldShowDisplayReasoningBox(dm)) { - const reasoningSeq = getDisplayReasoningSeq(dm) - blocks.push({ - id: `${dm.id}:reasoning`, - type: 'reasoning', - seq: reasoningSeq > 0 ? reasoningSeq : 10, - text: dm.reasoning, - }) - } - if (!hasContentBlock && dm.content) { fallbackSeq += 1 blocks.push({ @@ -1180,38 +1271,16 @@ function getToolTraceStateLabel(state: ToolTraceState): string { return '已完成' } -function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean { - if (dm.role !== 'assistant') return false - return dm.sources.some(m => - Boolean(m.reasoning?.trim()) || - (m.id === activeStreamingMessageId.value && thinkingMessageMap[m.id] === true), - ) -} - function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean { return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true) && !dm.content.trim() } -function isDisplayReasoningCollapsed(dm: DisplayMessage): boolean { - return dm.sources.every(m => reasoningCollapsedMap[m.id] === true) -} - -function toggleDisplayReasoningCollapse(dm: DisplayMessage): void { - const newCollapsed = !isDisplayReasoningCollapsed(dm) - dm.sources.forEach(m => { reasoningCollapsedMap[m.id] = newCollapsed }) -} - function getDisplayReasoningStatusLabel(dm: DisplayMessage): string { - const totalSeconds = dm.sources.reduce( - (sum, m) => sum + (reasoningDurationMap[m.id] ?? 0), 0, - ) - if (totalSeconds > 0) return `已思考(用时 ${totalSeconds} 秒)` - const hasActiveThinking = dm.sources.some( - m => m.id === activeStreamingMessageId.value && thinkingMessageMap[m.id] === true, - ) - return hasActiveThinking ? '思考中' : '已思考' + // 此函数已废弃,推理状态现已下沉到各 source 块处理。 + // 仅保留空实现以防意外调用。 + return '已思考' } function isMessageViewportAtBottom(viewport: HTMLElement) { @@ -1576,7 +1645,8 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ if (reasoningChunk) { currentAssistantMessage.reasoning = oldReasoning + reasoningChunk - // 记录推理块的 seq 环境 + // 时序化存储推理内容 + appendAssistantReasoningChunk(mid, reasoningChunk) if (!assistantReasoningSeqMap[mid]) { assistantReasoningSeqMap[mid] = event.seq } @@ -1867,8 +1937,8 @@ async function submitConfirmRejectMessage() { requestExtra: { resume: { interaction_id: interactionId, - type: 'ask_user', - action: 'reply' + type: 'confirm', + action: 'reject' } } }) @@ -2107,10 +2177,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { if (payload === '[DONE]') { if (isThinkingMessage(assistantMessage)) { - markReasoningFinished(assistantMessage) + finishCurrentReasoningBlock(assistantMessage.id) } activeStreamingMessageId.value = '' - reasoningCollapsedMap[assistantMessage.id] = true // 整个 SSE 流结束信号 void loadConversationContextStats(selectedConversationId.value, true) return @@ -2150,27 +2219,23 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { if (!assistantReasoningSeqMap[assistantMessage.id]) { assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq() } - assistantTimelineLastKindMap[assistantMessage.id] = 'reasoning' + appendAssistantReasoningChunk(assistantMessage.id, delta.reasoning_content) assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` } if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) { appendAssistantContentChunk(assistantMessage.id, delta.content) if (isThinkingMessage(assistantMessage)) { - // 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。 - // 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。 - // 3. 若后端偶发交错发送 reasoning/content,也以前端阶段机兜底,优先保证阅读一致性。 - markReasoningFinished(assistantMessage) + finishCurrentReasoningBlock(assistantMessage.id) } assistantMessage.content += delta.content } if (finishReason) { if (isThinkingMessage(assistantMessage)) { - markReasoningFinished(assistantMessage) + finishCurrentReasoningBlock(assistantMessage.id) } activeStreamingMessageId.value = '' - reasoningCollapsedMap[assistantMessage.id] = true // 单条消息结束标志 void loadConversationContextStats(selectedConversationId.value, true) } @@ -2681,18 +2746,18 @@ onBeforeUnmount(() => { /> - {{ getDisplayReasoningStatusLabel(dm) }} + {{ getReasoningStatusLabel(block) }} -
+