Version: 0.9.45.dev.260427

后端:
1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜
2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写
3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态
4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分
5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定

前端:
6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作

仓库:
7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件

PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
This commit is contained in:
Losita
2026-04-27 01:09:37 +08:00
parent 04b5836b39
commit 66c06eed0a
60 changed files with 9163 additions and 1819 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"time" "time"
"github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/api"
@@ -197,6 +198,78 @@ func Start() {
agentService.SetToolRegistry(newagenttools.NewDefaultRegistryWithDeps(newagenttools.DefaultRegistryDeps{ agentService.SetToolRegistry(newagenttools.NewDefaultRegistryWithDeps(newagenttools.DefaultRegistryDeps{
RAGRuntime: ragRuntime, RAGRuntime: ragRuntime,
WebSearchProvider: webSearchProvider, 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.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetCompactionStore(agentRepo) agentService.SetCompactionStore(agentRepo)
@@ -271,3 +344,11 @@ func Start() {
r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter) r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter)
routers.StartEngine(r) routers.StartEngine(r)
} }
func stringPtrOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}

View File

@@ -32,11 +32,14 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
} }
//1.填充section1,2 //1.填充section1,2
taskClass := model.TaskClass{ taskClass := model.TaskClass{
Name: &req.Name, Name: &req.Name,
Mode: &req.Mode, Mode: &req.Mode,
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
UserID: &userID, SubjectType: stringPtrOrNil(req.SubjectType),
DifficultyLevel: stringPtrOrNil(req.DifficultyLevel),
CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity),
UserID: &userID,
} }
//2.填充section3 //2.填充section3
taskClass.TotalSlots = &req.Config.TotalSlots taskClass.TotalSlots = &req.Config.TotalSlots
@@ -59,6 +62,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
taskClass.ExcludedSlots = &emptyJSON taskClass.ExcludedSlots = &emptyJSON
}*/ }*/
taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
taskClass.ExcludedDaysOfWeek = req.Config.ExcludedDaysOfWeek
//3.开始构建 items //3.开始构建 items
var items []model.TaskClassItem var items []model.TaskClassItem
for _, itemReq := range req.Items { for _, itemReq := range req.Items {
@@ -84,13 +88,16 @@ func TaskClassModelToResponse(taskClasses []model.TaskClass) *model.UserGetTaskC
var resp model.UserGetTaskClassesResponse var resp model.UserGetTaskClassesResponse
for _, tc := range taskClasses { for _, tc := range taskClasses {
tcResp := model.TaskClassSummary{ tcResp := model.TaskClassSummary{
ID: tc.ID, ID: tc.ID,
Name: *tc.Name, Name: *tc.Name,
Mode: *tc.Mode, Mode: *tc.Mode,
StartDate: timeOrZero(tc.StartDate), StartDate: timeOrZero(tc.StartDate),
EndDate: timeOrZero(tc.EndDate), EndDate: timeOrZero(tc.EndDate),
TotalSlots: *tc.TotalSlots, TotalSlots: *tc.TotalSlots,
Strategy: *tc.Strategy, Strategy: *tc.Strategy,
SubjectType: safeStr(tc.SubjectType),
DifficultyLevel: safeStr(tc.DifficultyLevel),
CognitiveIntensity: safeStr(tc.CognitiveIntensity),
} }
resp.TaskClasses = append(resp.TaskClasses, tcResp) resp.TaskClasses = append(resp.TaskClasses, tcResp)
} }
@@ -103,10 +110,13 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
} }
// 1. 映射基础信息 (处理指针解引用) // 1. 映射基础信息 (处理指针解引用)
req := &model.UserAddTaskClassRequest{ req := &model.UserAddTaskClassRequest{
Name: safeStr(taskClass.Name), Name: safeStr(taskClass.Name),
Mode: safeStr(taskClass.Mode), Mode: safeStr(taskClass.Mode),
StartDate: formatTime(taskClass.StartDate), StartDate: formatTime(taskClass.StartDate),
EndDate: formatTime(taskClass.EndDate), EndDate: formatTime(taskClass.EndDate),
SubjectType: safeStr(taskClass.SubjectType),
DifficultyLevel: safeStr(taskClass.DifficultyLevel),
CognitiveIntensity: safeStr(taskClass.CognitiveIntensity),
} }
// 2. 映射配置信息 (Config Section) // 2. 映射配置信息 (Config Section)
req.Config = model.UserAddTaskClassConfig{ req.Config = model.UserAddTaskClassConfig{
@@ -123,6 +133,7 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
} }
}*/ }*/
req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
req.Config.ExcludedDaysOfWeek = taskClass.ExcludedDaysOfWeek
// 4. 映射子项信息 (Items Section) // 4. 映射子项信息 (Items Section)
// 此时 items 已经通过 Preload 加载到了 taskClass.Items 中 // 此时 items 已经通过 Preload 加载到了 taskClass.Items 中
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items)) req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))
@@ -184,6 +195,13 @@ func safeInt(i *int) int {
return *i return *i
} }
func stringPtrOrNil(value string) *string {
if value == "" {
return nil
}
return &value
}
func safeBool(b *bool) bool { func safeBool(b *bool) bool {
if b == nil { if b == nil {
return true return true

View File

@@ -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 且只处理范围内的数据) // 映射日程 (尊重 Blocked 且只处理范围内的数据)
for _, s := range schedules { for _, s := range schedules {
@@ -450,6 +461,146 @@ type planningSlotCandidate struct {
sectionTo int 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 获取窗口内所有可用的原子节次坐标(逻辑一维化)。 // getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。
// //
// 设计说明: // 设计说明:
@@ -604,8 +755,8 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
} }
// 2. 计算间隔策略: // 2. 计算间隔策略:
// 2.1 rapidgap=0尽快塞满 // 2.1 rapid沿用“尽快塞满”的线性前进
// 2.2 steady按剩余可用位均匀留白 // 2.2 steady不再只靠 gap 跳格子,而是结合目标位置、单日负载、同科分散和缓冲保留做候选打分
gap := 0 gap := 0
if strategy == "steady" { if strategy == "steady" {
gap = (totalAvailable - totalRequired) / (len(items) + 1) gap = (totalAvailable - totalRequired) / (len(items) + 1)
@@ -617,16 +768,22 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
// 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。 // 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。
cursor := gap cursor := gap
lastPlacedIndex := -1 lastPlacedIndex := -1
placedDayOrdinals := make([]int, 0, len(items))
for i := range items { for i := range items {
if cursor >= totalAvailable { var (
break 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 { if !found {
break break
} }
@@ -648,7 +805,10 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
// 7. 推进游标并记录成功位置。 // 7. 推进游标并记录成功位置。
slotLen := candidate.sectionTo - candidate.sectionFrom + 1 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 lastPlacedIndex = i
} }

View File

@@ -13,16 +13,20 @@ type TaskClass struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"` ID int `gorm:"column:id;primaryKey;autoIncrement"`
UserID *int `gorm:"column:user_id;index:idx_task_classes_user_id"` UserID *int `gorm:"column:user_id;index:idx_task_classes_user_id"`
//section 2 //section 2
Name *string `gorm:"column:name;size:255"` Name *string `gorm:"column:name;size:255"`
Mode *string `gorm:"column:mode;type:enum('auto','manual')"` Mode *string `gorm:"column:mode;type:enum('auto','manual')"`
StartDate *time.Time `gorm:"column:start_date"` StartDate *time.Time `gorm:"column:start_date"`
EndDate *time.Time `gorm:"column:end_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 //section 3
TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"`
AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"`
Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"`
ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"`
Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem 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 字段 // IntSlice 用于把 []int 以 JSON 形式存入/读出数据库 json 字段
@@ -74,20 +78,24 @@ type TaskClassItem struct {
// UserAddTaskClassRequest 用于处理用户添加任务类别的请求 // UserAddTaskClassRequest 用于处理用户添加任务类别的请求
type UserAddTaskClassRequest struct { type UserAddTaskClassRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD
EndDate string `json:"end_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"` Mode string `json:"mode" binding:"required,oneof=auto manual"`
Config UserAddTaskClassConfig `json:"config" binding:"required"` SubjectType string `json:"subject_type,omitempty"`
Items []UserAddTaskClassItemRequest `json:"items" binding:"required"` 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 用于处理用户添加任务类别时的配置部分 // UserAddTaskClassConfig 用于处理用户添加任务类别时的配置部分
type UserAddTaskClassConfig struct { type UserAddTaskClassConfig struct {
TotalSlots int `json:"total_slots" binding:"required,min=1"` TotalSlots int `json:"total_slots" binding:"required,min=1"`
AllowFillerCourse bool `json:"allow_filler_course"` AllowFillerCourse bool `json:"allow_filler_course"`
Strategy string `json:"strategy" binding:"required,oneof=steady rapid"` Strategy string `json:"strategy" binding:"required,oneof=steady rapid"`
ExcludedSlots []int `json:"excluded_slots"` ExcludedSlots []int `json:"excluded_slots"`
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"`
} }
// UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分 // UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分
@@ -113,13 +121,16 @@ type UserGetTaskClassesResponse struct {
// TaskClassSummary 提供任务类别的简要信息 // TaskClassSummary 提供任务类别的简要信息
type TaskClassSummary struct { type TaskClassSummary struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Mode string `json:"mode"` Mode string `json:"mode"`
Strategy string `json:"strategy"` Strategy string `json:"strategy"`
StartDate time.Time `json:"start_date"` StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"` EndDate time.Time `json:"end_date"`
TotalSlots int `json:"total_slots"` 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 { type UserInsertTaskClassItemToScheduleRequest struct {

View File

@@ -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=<nil> 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。
输出格式(严格两段式):
第一段(控制码,用户不可见,后端会截取):
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply|execute|deep_answer|plan" rough_build="false" refine="false" reorder="false" thinking="false"/>
第二段(紧接控制码之后,用户可见):
根据路由输出对应内容。
属性说明(仅 route=execute 时有效,其余路由省略这些属性):
- rough_build是否需要粗排
- refine粗排后是否需要微调
- reorder是否允许打乱顺序
- thinking后续执行阶段是否需要深度思考
合法示例:
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply"/>
当然可以,我先直接回答你这个问题。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute"/>
好的,我来帮你看看今天的安排。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="false" reorder="false" thinking="false"/>
好的,我来帮你排课。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="true" reorder="false" thinking="true"/>
好的,我来帮你排课并按你的偏好做微调。
<SMARTFLOW_ROUTE nonce="给定nonce" route="deep_answer"/>
<SMARTFLOW_ROUTE nonce="给定nonce" route="plan"/>
明白,我来帮你制定一个完整的学习计划。
禁止输出任何 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=<SMARTFLOW_ROUTE nonce="932ef523-3e20-4595-8a09-2ff4319f0394" route="execute" rough_build="false" refine="false" reorder="false" thinking="false"/>
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. 你可以在需要日程写操作时提出 confirmmove/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_movebatch_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_switchaction=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)
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"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 则复用,保证一次只处理一个任务。
参数:{}
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
13. queue_status查看当前待处理队列状态pending/current/completed/skipped
参数:{}
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
19. web_searchWeb 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。
参数domain_allow(可选,array)query(必填,string)recency_days(可选,int)top_k(可选,int)
返回类型stringJSON字符串
返回示例:{"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. 你可以在需要日程写操作时提出 confirmmove/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_movebatch_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_switchaction=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)
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"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 则复用,保证一次只处理一个任务。
参数:{}
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
13. queue_status查看当前待处理队列状态pending/current/completed/skipped
参数:{}
返回类型stringJSON字符串
返回示例:{"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)
返回类型stringJSON字符串
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
19. web_searchWeb 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。
参数domain_allow(可选,array)query(必填,string)recency_days(可选,int)top_k(可选,int)
返回类型stringJSON字符串
返回示例:{"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_callquick_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"

View File

@@ -46,7 +46,8 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*
return nil, err return nil, err
} }
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) // 2. 全量读场景保留“当前周兜底”,兼容“只看本周课表/微调”类请求。
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
} }
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。 // LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
@@ -69,7 +70,9 @@ func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
return nil, err return nil, err
} }
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) // 1. 粗排/主动编排场景必须严格按任务类时间窗加载;
// 2. 若任务类缺少起止日期,则返回错误,交给上层 ask_user 补齐,而不是静默退回当前周。
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
} }
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。 // loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
@@ -82,10 +85,14 @@ func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
ctx context.Context, ctx context.Context,
userID int, userID int,
taskClasses []model.TaskClass, taskClasses []model.TaskClass,
allowCurrentWeekFallback bool,
) (*schedule.ScheduleState, error) { ) (*schedule.ScheduleState, error) {
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。 // 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
windowDays, weeks := buildWindowFromTaskClasses(taskClasses) windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
if len(windowDays) == 0 { if len(windowDays) == 0 {
if !allowCurrentWeekFallback {
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
}
var err error var err error
windowDays, weeks, err = buildCurrentWeekWindow() windowDays, weeks, err = buildCurrentWeekWindow()
if err != nil { if err != nil {
@@ -262,12 +269,24 @@ func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, t
if tc.ExcludedSlots != nil { if tc.ExcludedSlots != nil {
meta.ExcludedSlots = []int(tc.ExcludedSlots) meta.ExcludedSlots = []int(tc.ExcludedSlots)
} }
if tc.ExcludedDaysOfWeek != nil {
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
}
if tc.StartDate != nil { if tc.StartDate != nil {
meta.StartDate = tc.StartDate.Format("2006-01-02") meta.StartDate = tc.StartDate.Format("2006-01-02")
} }
if tc.EndDate != nil { if tc.EndDate != nil {
meta.EndDate = tc.EndDate.Format("2006-01-02") 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) metas = append(metas, meta)
} }
return metas, nil return metas, nil

View File

@@ -49,6 +49,7 @@ func LoadScheduleState(
// 2.1 先放 extraItemCategories低优先级兜底 // 2.1 先放 extraItemCategories低优先级兜底
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。 // 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
itemCategoryLookup := make(map[int]string) itemCategoryLookup := make(map[int]string)
itemOrderLookup := buildTaskItemOrderLookup(taskClasses)
for id, name := range extraItemCategories { for id, name := range extraItemCategories {
itemCategoryLookup[id] = name itemCategoryLookup[id] = name
} }
@@ -222,6 +223,7 @@ func LoadScheduleState(
Slots: hostSlots, Slots: hostSlots,
CategoryID: tc.ID, CategoryID: tc.ID,
TaskClassID: tc.ID, TaskClassID: tc.ID,
TaskOrder: itemOrderLookup[item.ID],
}) })
itemStateIDs[item.ID] = stateID itemStateIDs[item.ID] = stateID
nextStateID++ nextStateID++
@@ -240,6 +242,7 @@ func LoadScheduleState(
Slots: slots, Slots: slots,
CategoryID: tc.ID, CategoryID: tc.ID,
TaskClassID: tc.ID, TaskClassID: tc.ID,
TaskOrder: itemOrderLookup[item.ID],
}) })
itemStateIDs[item.ID] = stateID itemStateIDs[item.ID] = stateID
nextStateID++ nextStateID++
@@ -261,6 +264,7 @@ func LoadScheduleState(
Duration: defaultDuration, Duration: defaultDuration,
CategoryID: tc.ID, CategoryID: tc.ID,
TaskClassID: tc.ID, TaskClassID: tc.ID,
TaskOrder: itemOrderLookup[item.ID],
}) })
itemStateIDs[item.ID] = stateID itemStateIDs[item.ID] = stateID
nextStateID++ nextStateID++
@@ -285,12 +289,24 @@ func LoadScheduleState(
if tc.ExcludedSlots != nil { if tc.ExcludedSlots != nil {
meta.ExcludedSlots = []int(tc.ExcludedSlots) meta.ExcludedSlots = []int(tc.ExcludedSlots)
} }
if tc.ExcludedDaysOfWeek != nil {
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
}
if tc.StartDate != nil { if tc.StartDate != nil {
meta.StartDate = tc.StartDate.Format("2006-01-02") meta.StartDate = tc.StartDate.Format("2006-01-02")
} }
if tc.EndDate != nil { if tc.EndDate != nil {
meta.EndDate = tc.EndDate.Format("2006-01-02") 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) state.TaskClasses = append(state.TaskClasses, meta)
} }
} }
@@ -343,6 +359,7 @@ func LoadScheduleState(
Slots: hostSlots, Slots: hostSlots,
CategoryID: categoryID, CategoryID: categoryID,
TaskClassID: taskClassID, TaskClassID: taskClassID,
TaskOrder: itemOrderLookup[itemID],
}) })
itemStateIDs[itemID] = guestStateID itemStateIDs[itemID] = guestStateID
nextStateID++ nextStateID++
@@ -385,6 +402,26 @@ func isTaskItemPending(item model.TaskClassItem) bool {
return *item.Status == model.TaskItemStatusUnscheduled 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 任务默认时长。 // estimateTaskItemDuration 估算 pending 任务默认时长。
// //
// 规则:若任务类声明了 total_slots则按 total_slots / item_count 取整(最少 1 // 规则:若任务类声明了 total_slots则按 total_slots / item_count 取整(最少 1

View File

@@ -17,7 +17,6 @@ const (
NodeConfirm = "confirm" NodeConfirm = "confirm"
NodeRoughBuild = "rough_build" NodeRoughBuild = "rough_build"
NodeExecute = "execute" NodeExecute = "execute"
NodeOrderGuard = "order_guard"
NodeInterrupt = "interrupt" NodeInterrupt = "interrupt"
NodeDeliver = "deliver" NodeDeliver = "deliver"
NodeQuickTask = "quick_task" 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 { if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil {
return nil, err 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 { if err := g.AddLambdaNode(NodeQuickTask, compose.InvokableLambda(nodes.QuickTask)); err != nil {
return nil, err return nil, err
} }
@@ -115,38 +111,31 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
)); err != nil { )); err != nil {
return nil, err return nil, err
} }
// RoughBuild -> Execute / OrderGuard / Deliver // RoughBuild -> Execute / Deliver
// 1. 正常粗排完成后进入 execute 微调; // 1. 正常粗排完成后进入 execute 微调;
// 2. 若粗排阶段 completed 且默认保持顺序,先走 order_guard 再交付; // 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort则直接进入 deliver 收口。
// 3. 若粗排阶段已写入正式终止结果(如粗排异常 abort则直接进入 deliver 收口。
if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch( if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch(
branchAfterRoughBuild, branchAfterRoughBuild,
map[string]bool{ map[string]bool{
NodeExecute: true, NodeExecute: true,
NodeOrderGuard: true, NodeDeliver: true,
NodeDeliver: true, NodeInterrupt: true,
NodeInterrupt: true,
}, },
)); err != nil { )); err != nil {
return nil, err return nil, err
} }
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / OrderGuard(顺序守卫) / Deliver(完成) / Interrupt(需要追问用户) // Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户)
if err := g.AddBranch(NodeExecute, compose.NewGraphBranch( if err := g.AddBranch(NodeExecute, compose.NewGraphBranch(
branchAfterExecute, branchAfterExecute,
map[string]bool{ map[string]bool{
NodeExecute: true, NodeExecute: true,
NodeConfirm: true, NodeConfirm: true,
NodeOrderGuard: true, NodeDeliver: true,
NodeDeliver: true, NodeInterrupt: true,
NodeInterrupt: true,
}, },
)); err != nil { )); err != nil {
return nil, err return nil, err
} }
// OrderGuard -> Deliver顺序守卫只做校验最终都由 Deliver 统一收口。
if err := g.AddEdge(NodeOrderGuard, NodeDeliver); err != nil {
return nil, err
}
// Interrupt -> END当前连接必须在这里收口等待用户输入或确认回调恢复。 // Interrupt -> END当前连接必须在这里收口等待用户输入或确认回调恢复。
if err := g.AddEdge(NodeInterrupt, compose.END); err != nil { if err := g.AddEdge(NodeInterrupt, compose.END); err != nil {
return nil, err return nil, err
@@ -279,9 +268,6 @@ func branchAfterRoughBuild(_ context.Context, st *newagentmodel.AgentGraphState)
return NodeExecute, nil return NodeExecute, nil
} }
if flowState.Phase == newagentmodel.PhaseDone { if flowState.Phase == newagentmodel.PhaseDone {
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder {
return NodeOrderGuard, nil
}
return NodeDeliver, nil return NodeDeliver, nil
} }
return NodeExecute, nil return NodeExecute, nil
@@ -309,9 +295,6 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s
// 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver会绕过 Execute 内的 Exhaust 写入, // 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver会绕过 Execute 内的 Exhaust 写入,
// 导致 deliver 收口和后续预览落盘语义不一致。 // 导致 deliver 收口和后续预览落盘语义不一致。
if flowState.Phase == newagentmodel.PhaseDone { if flowState.Phase == newagentmodel.PhaseDone {
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder && flowState.HasScheduleWriteOps {
return NodeOrderGuard, nil
}
return NodeDeliver, nil return NodeDeliver, nil
} }
return NodeExecute, nil return NodeExecute, nil

View File

@@ -71,6 +71,24 @@ type CommonState struct {
TraceID string `json:"trace_id"` TraceID string `json:"trace_id"`
UserID int `json:"user_id"` UserID int `json:"user_id"`
ConversationID string `json:"conversation_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"` Phase Phase `json:"phase"`
@@ -106,12 +124,70 @@ type CommonState struct {
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"` NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。 // AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
// 默认 false只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。 // 默认 false只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。
AllowReorder bool `json:"allow_reorder,omitempty"` AllowReorder bool `json:"allow_reorder,omitempty"`
// SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。 OptimizationMode string `json:"optimization_mode,omitempty"`
// OrderGuard 节点会基于该基线判断微调是否破坏顺序约束 // ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”
SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"` // 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 循环是否执行过日程写工具。 // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的:graph 分支函数据此判断是否需要走 order_guard非日程操作跳过守卫 // 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。 // UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
// 调用目的graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。 // 调用目的graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
@@ -164,8 +240,12 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
s.PlanSteps = steps s.PlanSteps = steps
s.CurrentStep = 0 s.CurrentStep = 0
s.Phase = PhaseWaitingConfirm s.Phase = PhaseWaitingConfirm
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRefineAfterRoughBuild = false s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome() s.ClearTerminalOutcome()
} }
@@ -173,7 +253,8 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
func (s *CommonState) ConfirmPlan() { func (s *CommonState) ConfirmPlan() {
s.Phase = PhaseExecuting s.Phase = PhaseExecuting
s.NeedsRefineAfterRoughBuild = false s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome() s.ClearTerminalOutcome()
} }
@@ -185,9 +266,13 @@ func (s *CommonState) StartDirectExecute() {
s.PlanSteps = nil s.PlanSteps = nil
s.CurrentStep = 0 s.CurrentStep = 0
s.Phase = PhaseExecuting s.Phase = PhaseExecuting
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRoughBuild = false s.NeedsRoughBuild = false
s.NeedsRefineAfterRoughBuild = false s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome() s.ClearTerminalOutcome()
} }
@@ -196,8 +281,12 @@ func (s *CommonState) RejectPlan() {
s.PlanSteps = nil s.PlanSteps = nil
s.CurrentStep = 0 s.CurrentStep = 0
s.Phase = PhasePlanning s.Phase = PhasePlanning
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRefineAfterRoughBuild = false s.NeedsRefineAfterRoughBuild = false
s.SuggestedOrderBaseline = nil s.ActiveOptimizeOnly = false
s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome() s.ClearTerminalOutcome()
} }
@@ -223,18 +312,50 @@ func (s *CommonState) ResetForNextRun() {
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。 // 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
s.PlanSteps = nil s.PlanSteps = nil
s.CurrentStep = 0 s.CurrentStep = 0
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.NeedsRoughBuild = false s.NeedsRoughBuild = false
s.NeedsRefineAfterRoughBuild = false s.NeedsRefineAfterRoughBuild = false
s.ActiveOptimizeOnly = false
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。 // 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
s.AllowReorder = false 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.HasScheduleWriteOps = false
s.HasScheduleChanges = false s.HasScheduleChanges = false
s.UsedQuickNote = false s.UsedQuickNote = false
s.SuggestedOrderBaseline = nil s.resetTaskClassUpsertSnapshot()
s.ClearTerminalOutcome() 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 推进到下一个计划步骤,并返回是否仍有剩余步骤。 // AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
func (s *CommonState) AdvanceStep() bool { func (s *CommonState) AdvanceStep() bool {
s.CurrentStep++ s.CurrentStep++
@@ -248,6 +369,12 @@ func (s *CommonState) AdvanceStep() bool {
// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。 // 2. 只有在尚未写入任何终止结果时,才默认补成 completed。
func (s *CommonState) Done() { func (s *CommonState) Done() {
s.Phase = PhaseDone s.Phase = PhaseDone
// 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。
// 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。
s.ActiveToolDomain = ""
s.ActiveToolPacks = nil
s.PendingContextHook = nil
s.ActiveOptimizeOnly = false
if s.TerminalOutcome != nil { if s.TerminalOutcome != nil {
s.TerminalOutcome.Normalize() s.TerminalOutcome.Normalize()
return return

View File

@@ -3,6 +3,7 @@ package model
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"strings" "strings"
) )
@@ -61,7 +62,7 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
Speak string `json:"speak,omitempty"` Speak string `json:"speak,omitempty"`
Action ExecuteAction `json:"action"` Action ExecuteAction `json:"action"`
Reason string `json:"reason,omitempty"` 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"` ToolCall json.RawMessage `json:"tool_call,omitempty"`
Abort json.RawMessage `json:"abort,omitempty"` Abort json.RawMessage `json:"abort,omitempty"`
} }
@@ -74,7 +75,11 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
d.Speak = raw.Speak d.Speak = raw.Speak
d.Action = raw.Action d.Action = raw.Action
d.Reason = raw.Reason 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) toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
if err != nil { if err != nil {
@@ -91,6 +96,124 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
return nil 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 决策中的字符串字段。 // Normalize 统一清洗 execute 决策中的字符串字段。
func (d *ExecuteDecision) Normalize() { func (d *ExecuteDecision) Normalize() {
if d == nil { if d == nil {

View File

@@ -12,6 +12,15 @@ const (
const PendingInteractionSnapshotVersion = 1 const PendingInteractionSnapshotVersion = 1
const (
// PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。
// interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。
PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed"
// PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。
// interrupt 节点据此避免二次追加历史,防止上下文重复。
PendingMetaAskUserHistoryAppended = "ask_user_history_appended"
)
// PendingInteractionType 表示当前挂起交互的类型。 // PendingInteractionType 表示当前挂起交互的类型。
type PendingInteractionType string type PendingInteractionType string
@@ -179,6 +188,26 @@ func (s *AgentRuntimeState) ClearPendingInteraction() {
s.PendingInteraction = nil 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( func (s *AgentRuntimeState) openPendingInteraction(
interactionType PendingInteractionType, interactionType PendingInteractionType,
interactionID string, interactionID string,

View File

@@ -55,6 +55,19 @@ type PlanDecision struct {
PlanSteps []PlanStep `json:"plan_steps,omitempty"` PlanSteps []PlanStep `json:"plan_steps,omitempty"`
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"` NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
TaskClassIDs []int `json:"task_class_ids,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/taskclasspacks 仅允许 schedule 的可选包;
// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。
type ContextHook struct {
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Reason string `json:"reason,omitempty"`
} }
// Normalize 统一清洗规划决策中的字符串字段。 // Normalize 统一清洗规划决策中的字符串字段。
@@ -69,6 +82,9 @@ func (d *PlanDecision) Normalize() {
for i := range d.PlanSteps { for i := range d.PlanSteps {
d.PlanSteps[i].Normalize() d.PlanSteps[i].Normalize()
} }
if d.ContextHook != nil {
d.ContextHook.Normalize()
}
} }
// Validate 校验规划决策的最小合法性。 // Validate 校验规划决策的最小合法性。
@@ -102,6 +118,9 @@ func (d *PlanDecision) Validate() error {
if len(d.PlanSteps) > 0 { if len(d.PlanSteps) > 0 {
return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action) return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action)
} }
if d.ContextHook != nil {
return fmt.Errorf("%s 动作不应携带 context_hook", d.Action)
}
return nil return nil
case PlanActionDone: case PlanActionDone:
if len(d.PlanSteps) == 0 { if len(d.PlanSteps) == 0 {
@@ -112,6 +131,11 @@ func (d *PlanDecision) Validate() error {
return fmt.Errorf("plan_steps[%d] 非法: %w", i, err) return fmt.Errorf("plan_steps[%d] 非法: %w", i, err)
} }
} }
if d.ContextHook != nil {
if err := d.ContextHook.Validate(); err != nil {
return err
}
}
return nil return nil
default: default:
return fmt.Errorf("未知 plan action: %s", d.Action) return fmt.Errorf("未知 plan action: %s", d.Action)
@@ -149,3 +173,73 @@ func (s *PlanStep) Validate() error {
} }
return nil 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
}

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" "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 看到真实工具边界。 // 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。
if st.Deps.ToolRegistry != nil { 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)) toolSchemas := make([]newagentmodel.ToolSchemaContext, len(schemas))
for i, s := range schemas { for i, s := range schemas {
toolSchemas[i] = newagentmodel.ToolSchemaContext{ toolSchemas[i] = newagentmodel.ToolSchemaContext{
@@ -184,20 +193,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
return st, nil 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。 // QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。
func (n *AgentNodes) QuickTask(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { func (n *AgentNodes) QuickTask(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
if st == nil { if st == nil {
@@ -337,3 +332,31 @@ func deleteAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) {
_ = store.Delete(ctx, flowState.ConversationID) _ = 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)
}

View File

@@ -214,6 +214,10 @@ func streamAndDispatch(
decision.NeedsRoughBuild = false decision.NeedsRoughBuild = false
decision.NeedsRefineAfterRoughBuild = false decision.NeedsRefineAfterRoughBuild = false
} }
// 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。
if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) {
decision.NeedsRefineAfterRoughBuild = true
}
log.Printf( 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", "[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.ExecuteThinking = effectiveThinking
flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState)
return nil return nil
} }
@@ -510,6 +515,45 @@ func detectReorderPreference(userInput string) reorderPreference {
return reorderUnknown 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 { func containsAnyPhrase(text string, phrases []string) bool {
for _, phrase := range phrases { for _, phrase := range phrases {
if strings.Contains(text, phrase) { if strings.Contains(text, phrase) {
@@ -539,6 +583,27 @@ func shouldDisableRoughBuildForRefine(
return !isExplicitRoughBuildRequest(userInput) 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 { func hasRoughBuildDoneMarker(conversationContext *newagentmodel.ConversationContext) bool {
if conversationContext == nil { if conversationContext == nil {
return false return false
@@ -575,6 +640,31 @@ func isExplicitRoughBuildRequest(userInput string) bool {
return containsAnyPhrase(text, keywords) 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 处理复杂问答:关闭路由流 → 第二次流式调用。 // handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。
// //
// 步骤说明: // 步骤说明:

View File

@@ -42,15 +42,10 @@ func AppendLLMCorrection(
} }
// 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。 // 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。
// 如果 llmOutput 为空,则生成一个占位描述 // 2. 空输出不回灌,避免把占位文本写进历史造成噪音
// 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。
assistantContent := strings.TrimSpace(llmOutput) assistantContent := strings.TrimSpace(llmOutput)
if assistantContent == "" { appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
assistantContent = "[LLM 输出为空或无法解析]"
}
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: assistantContent,
})
// 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。 // 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。
// 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。 // 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。
@@ -88,13 +83,7 @@ func AppendLLMCorrectionWithHint(
} }
assistantContent := strings.TrimSpace(llmOutput) assistantContent := strings.TrimSpace(llmOutput)
if assistantContent == "" { appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
assistantContent = "[LLM 输出为空或无法解析]"
}
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: assistantContent,
})
correctionContent := fmt.Sprintf( correctionContent := fmt.Sprintf(
"%s %s 请重新分析当前状态,输出正确的内容。", "%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,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -82,18 +82,27 @@ func handleInterruptAskUser(
text = "请补充更多信息。" text = "请补充更多信息。"
} }
// 伪流式输出,和 chatReply 一样的体感。 speakStreamed := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserSpeakStreamed)
if err := emitter.EmitPseudoAssistantText( historyAppended := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserHistoryAppended)
ctx, interruptSpeakBlockID, interruptStageName,
text, // 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送;
newagentstream.DefaultPseudoStreamOptions(), // 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。
); err != nil { if !speakStreamed {
return fmt.Errorf("追问消息推送失败: %w", err) // 伪流式输出,和 chatReply 一样的体感。
if err := emitter.EmitPseudoAssistantText(
ctx, interruptSpeakBlockID, interruptStageName,
text,
newagentstream.DefaultPseudoStreamOptions(),
); err != nil {
return fmt.Errorf("追问消息推送失败: %w", err)
}
} }
// 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。 // 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。
msg := schema.AssistantMessage(text, nil) msg := schema.AssistantMessage(text, nil)
conversationContext.AppendHistory(msg) if !historyAppended {
conversationContext.AppendHistory(msg)
}
persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg) persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg)
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。 // 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
@@ -105,6 +114,21 @@ func handleInterruptAskUser(
return nil 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 处理确认型中断。 // handleInterruptConfirm 处理确认型中断。
// //
// 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。 // 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。

View File

@@ -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
}

View File

@@ -89,7 +89,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
messages, messages,
infrallm.GenerateOptions{ infrallm.GenerateOptions{
Temperature: 0.2, Temperature: 0.2,
Thinking: resolveThinkingMode(input.ThinkingEnabled), // 显式设置上限,避免依赖框架默认值(默认 4096导致长决策被截断。
// 注意:当前模型接口 max_tokens 上限为 131072超过会 400。
MaxTokens: 131072,
Thinking: resolveThinkingMode(input.ThinkingEnabled),
Metadata: map[string]any{ Metadata: map[string]any{
"stage": planStageName, "stage": planStageName,
"phase": "planning", "phase": "planning",
@@ -102,6 +105,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
parser := newagentrouter.NewStreamDecisionParser() parser := newagentrouter.NewStreamDecisionParser()
firstChunk := true firstChunk := true
speakStreamed := false
// 3.1 阶段一:解析决策标签。 // 3.1 阶段一:解析决策标签。
for { for {
@@ -151,6 +155,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil { if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
return fmt.Errorf("规划文案推送失败: %w", emitErr) return fmt.Errorf("规划文案推送失败: %w", emitErr)
} }
speakStreamed = true
fullText.WriteString(visible) fullText.WriteString(visible)
firstChunk = false 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 { if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
return fmt.Errorf("规划文案推送失败: %w", emitErr) return fmt.Errorf("规划文案推送失败: %w", emitErr)
} }
speakStreamed = true
fullText.WriteString(chunk2.Content) fullText.WriteString(chunk2.Content)
firstChunk = false firstChunk = false
} }
@@ -187,7 +193,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
} }
// 5. 按规划动作推进流程状态。 // 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, emitter *newagentstream.ChunkEmitter,
flowState *newagentmodel.CommonState, flowState *newagentmodel.CommonState,
decision *newagentmodel.PlanDecision, decision *newagentmodel.PlanDecision,
askUserSpeakStreamed bool,
) error { ) error {
switch decision.Action { switch decision.Action {
case newagentmodel.PlanActionContinue: case newagentmodel.PlanActionContinue:
@@ -211,9 +218,14 @@ func handlePlanAction(
case newagentmodel.PlanActionAskUser: case newagentmodel.PlanActionAskUser:
question := resolvePlanAskUserText(decision) question := resolvePlanAskUserText(decision)
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode)) 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 return nil
case newagentmodel.PlanActionDone: case newagentmodel.PlanActionDone:
flowState.FinishPlan(decision.PlanSteps) flowState.FinishPlan(decision.PlanSteps)
flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook)
writePlanPinnedBlocks(conversationContext, decision.PlanSteps) writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
if decision.NeedsRoughBuild { if decision.NeedsRoughBuild {
flowState.NeedsRoughBuild = true flowState.NeedsRoughBuild = true
@@ -295,6 +307,21 @@ func resolvePlanAskUserText(decision *newagentmodel.PlanDecision) string {
return "我还缺一点关键信息,想先向你确认一下。" 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) { func writePlanPinnedBlocks(ctx *newagentmodel.ConversationContext, steps []newagentmodel.PlanStep) {
if ctx == nil { if ctx == nil {
return return

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
) )
@@ -69,30 +70,57 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
return nil 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) scheduleState, err := st.EnsureScheduleState(ctx)
if err != nil { 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) return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
} }
if scheduleState == nil { if scheduleState == nil {
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排") return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
} }
// 5. 调用粗排算法。 // 6. 调用粗排算法。
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs) placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
if err != nil { if err != nil {
return fmt.Errorf("rough build node: 粗排算法失败: %w", err) return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
} }
// 6. 把粗排结果写入 ScheduleState。 // 7. 把粗排结果写入 ScheduleState。
applyStats := applyRoughBuildPlacements(scheduleState, placements) applyStats := applyRoughBuildPlacements(scheduleState, placements)
// 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。 // 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送排程完毕卡片。
if applyStats.AppliedCount > 0 { if applyStats.AppliedCount > 0 {
flowState.HasScheduleChanges = true flowState.HasScheduleChanges = true
} }
// 7. 先校验粗排后是否仍有真实 pending。 // 8. 先校验粗排后是否仍有真实 pending。
stillPending := countPendingTasks(scheduleState, taskClassIDs) stillPending := countPendingTasks(scheduleState, taskClassIDs)
log.Printf( 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", "[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.NeedsRoughBuild = false
flowState.NeedsRefineAfterRoughBuild = false flowState.NeedsRefineAfterRoughBuild = false
if !shouldRefineAfterRoughBuild { if !shouldRefineAfterRoughBuild {
flowState.ActiveOptimizeOnly = false
flowState.Done() flowState.Done()
return nil 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 flowState.Phase = newagentmodel.PhaseExecuting
return nil return nil
} }

View File

@@ -123,12 +123,22 @@ func renderStateSummary(state *newagentmodel.CommonState) string {
if tc.StartDate != "" || tc.EndDate != "" { if tc.StartDate != "" || tc.EndDate != "" {
line += fmt.Sprintf(",日期范围=%s ~ %s", 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 { if tc.AllowFillerCourse {
line += ",允许嵌入水课" line += ",允许嵌入水课"
} }
if len(tc.ExcludedSlots) > 0 { if len(tc.ExcludedSlots) > 0 {
line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots) line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots)
} }
if len(tc.ExcludedDaysOfWeek) > 0 {
line += fmt.Sprintf(",排除星期=%v", tc.ExcludedDaysOfWeek)
}
sb.WriteString(line + "\n") sb.WriteString(line + "\n")
} }
} }
@@ -136,6 +146,14 @@ func renderStateSummary(state *newagentmodel.CommonState) string {
return sb.String() return sb.String()
} }
func defaultSemanticValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "未标注"
}
return trimmed
}
// renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。 // renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。
func renderPinnedBlocks(ctx *newagentmodel.ConversationContext) string { func renderPinnedBlocks(ctx *newagentmodel.ConversationContext) string {
if ctx == nil { if ctx == nil {

View File

@@ -26,6 +26,11 @@ quick_task 判别要点:
- 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute - 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute
- 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问 - 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问
任务类设计路由要点:
- 普通"创建/修改任务类"默认走 execute由 execute 负责补字段与写入)。
- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan后续可使用 web_search
- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息必须向用户本人确认不能作为 web 搜索补齐目标。
通用回答约束: 通用回答约束:
- 非日程、非任务类问题,只要不需要工具,也应当正常回答。 - 非日程、非任务类问题,只要不需要工具,也应当正常回答。
- 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。 - 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。
@@ -39,8 +44,8 @@ quick_task 判别要点:
- "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine不得再次触发 rough build。 - "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine不得再次触发 rough build。
粗排后微调判断: 粗排后微调判断:
- 仅当 rough_build=true 时才判断 refine。 - 仅当 rough_build=true 时才判断 refine。
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 refine=true - 默认策略首次粗排完成后应进入微调refine=true按中位标准做主动优化
- 若用户只要求"先排进去/给初稿",未提出微调目标,设 refine=false。 - 仅当用户明确表达"只要初稿/先排进去别优化/先不微调/排完就收口"时,才设 refine=false。
顺序授权判断: 顺序授权判断:
- reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。 - reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。
- 用户明确要求"保持顺序/不要打乱"时必须为 false。 - 用户明确要求"保持顺序/不要打乱"时必须为 false。

View File

@@ -8,261 +8,14 @@ import (
"github.com/cloudwego/eino/schema" "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. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=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. 你可以在需要日程写操作时提出 confirmmove/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_movebatch_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. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. 缺关键上下文且无法通过工具补齐action=ask_user。
5. 任务完成action=done并在 goal_check 总结完成证据。
6. 流程应正式终止action=abort。`
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。 // BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
func BuildExecuteSystemPrompt() string { func BuildExecuteSystemPrompt() string {
return buildExecutePromptWithFormatGuard(executeSystemPromptWithPlan) return buildExecutePromptWithFormatGuard(executeSystemPromptBaseWithPlan)
} }
// BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。 // BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。
func BuildExecuteReActSystemPrompt() string { func BuildExecuteReActSystemPrompt() string {
return buildExecutePromptWithFormatGuard(executeSystemPromptReAct) return buildExecutePromptWithFormatGuard(executeSystemPromptBaseReAct)
}
// BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。
func BuildExecuteDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(两阶段格式):
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带格式 {"name":"工具名","arguments":{...}}
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
示例:
<SMARTFLOW_DECISION>{"action":"%s","reason":"需要先调用 get_overview 获取事实","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先查看当前整体安排。
<SMARTFLOW_DECISION>{"action":"%s","reason":"已完成当前步骤所需查询与校验","goal_check":"已满足当前步骤 done_when 条件"}</SMARTFLOW_DECISION>
当前步骤已完成。
<SMARTFLOW_DECISION>{"action":"%s","reason":"整体任务已完成"}</SMARTFLOW_DECISION>
`,
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标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 时必填,总结任务完成证据
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带格式 {"name":"工具名","arguments":{...}}
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
示例:
<SMARTFLOW_DECISION>{"action":"%s","reason":"先读取概览再决定微调方向","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先看一下现在的安排分布。
<SMARTFLOW_DECISION>{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"swap","arguments":{"task_a":1,"task_b":2}}}</SMARTFLOW_DECISION>
我准备把两项任务对调位置,你确认后执行。
<SMARTFLOW_DECISION>{"action":"%s","reason":"微调执行完毕并已校验结果","goal_check":"目标任务类已完成微调,且关键约束满足"}</SMARTFLOW_DECISION>
已完成你的请求。
`,
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标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
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 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。若 action=%s标签后通常留空。
示例:
<SMARTFLOW_DECISION>{"action":"%s","reason":"先读取事实再决策","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先查看当前安排。
<SMARTFLOW_DECISION>{"action":"%s","reason":"步骤完成条件满足","goal_check":"已满足当前步骤 done_when"}</SMARTFLOW_DECISION>
当前步骤完成。
<SMARTFLOW_DECISION>{"action":"%s","reason":"流程不应继续执行","abort":{"code":"execute_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}}</SMARTFLOW_DECISION>
`,
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标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
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 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。若 action=%s标签后通常留空。
示例:
<SMARTFLOW_DECISION>{"action":"%s","reason":"先获取事实再决策","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先读取当前安排。
<SMARTFLOW_DECISION>{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"move","arguments":{"task_id":5,"new_day":3,"new_slot_start":1}}}</SMARTFLOW_DECISION>
我准备执行写操作,等待你确认。
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前流程不应继续执行","abort":{"code":"domain_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}}</SMARTFLOW_DECISION>
`,
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,
))
} }
// BuildExecuteMessages 组装执行阶段消息。 // BuildExecuteMessages 组装执行阶段消息。
@@ -304,6 +57,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
计划步骤强约束: 计划步骤强约束:
- 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。 - 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。
- 若全部计划已完成:输出 action=done并在 goal_check 总结完成证据。 - 若全部计划已完成:输出 action=done并在 goal_check 总结完成证据。
- goal_check 字段类型必须为 string不要输出对象或数组。
- 若未完成但缺少关键信息:输出 action=ask_user。`) - 若未完成但缺少关键信息:输出 action=ask_user。`)
} }
@@ -324,6 +78,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
- 当前步骤完成判定(done_when)%s - 当前步骤完成判定(done_when)%s
- 未满足 done_when 时:只能输出 continue / confirm / ask_user禁止输出 next_plan。 - 未满足 done_when 时:只能输出 continue / confirm / ask_user禁止输出 next_plan。
- 满足 done_when 时:优先输出 action=next_plan并在 goal_check 逐条对照 done_when 给出证据。 - 满足 done_when 时:优先输出 action=next_plan并在 goal_check 逐条对照 done_when 给出证据。
- goal_check 字段类型固定为 string示例"已满足 done_when...;证据:..."),禁止输出 {"done_when":"...","evidence":"..."}。
- 禁止跳步:不要提前执行后续步骤。`, - 禁止跳步:不要提前执行后续步骤。`,
base, current, total, stepContent, doneWhen)) base, current, total, stepContent, doneWhen))
} }
@@ -332,13 +87,15 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
func buildExecutePromptWithFormatGuard(base string) string { func buildExecutePromptWithFormatGuard(base string) string {
base = strings.TrimSpace(base) base = strings.TrimSpace(base)
guard := strings.TrimSpace(` guard := strings.TrimSpace(`
补充 JSON 约束: 输出协议硬约束:
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。 1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
2. 若输出 tool_call,参数字段名只能是 arguments禁止写成 parameters 2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters也不能一次输出多个 tool_call
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组 3. action=ask_user / confirm 时标签后必须有自然语言正文action=continue 可为空
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort 4. action=done 时不要携带 tool_callaction=next_plan / done 时goal_check 必须是字符串
5. action=continue / ask_user / confirm 时,标签后的正文必须是非空自然语言 5. 只有 action=abort 时才允许输出 abort 字段
6. <SMARTFLOW_DECISION> 标签内只放 JSON不要放自然语言。`) 6. <SMARTFLOW_DECISION> 标签内只放 JSON不要放自然语言。
7. 不要在 <SMARTFLOW_DECISION> 标签前输出任何前言、寒暄、解释或铺垫;给用户看的正文只能放在 </SMARTFLOW_DECISION> 之后。
8. 任何动作都不得擅自超出用户当前明确意图;用户没让你做的下一步,不要自作主张推进。`)
if base == "" { if base == "" {
return guard return guard
} }
@@ -351,37 +108,17 @@ func buildExecuteStrictJSONUserPrompt() string {
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。 请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。 输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
补充格式要求 执行提醒
- JSON 中不要包含 speak 字段给用户看的话放在 </SMARTFLOW_DECISION> 标签之后 - JSON 中不要包含 speak 字段给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
- 与当前 action 无关的字段直接省略,不要输出空字符串、空对象、空数组或 null 占位 - 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
- tool_call 只能写 {"name":"工具名","arguments":{...}},且每轮最多一个 - 日程写工具place/move/swap/batch_move/unplace一律走 action=confirm
- 不要写 {"tool_call":{"name":"工具名","parameters":{...}}} - 若当前处于粗排后主动优化专用模式,先调 analyze_health再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
- 非 abort 动作不要输出 abort 字段
- action 为 continue / ask_user / confirm 时,标签后必须输出非空正文
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user - 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
- 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具进入 confirm - 不要连续两轮调用同一读工具 + 等价 arguments”;上一轮已成功返回,下一轮必须换工具进入 confirm,或明确说明阻塞
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化 - 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
- 若上下文已明确"当前未收到微调偏好,本轮先收口",请直接输出 action=done - web_search 仅用于通用学习资料补充不可用于考试时间、DDL、个人时段等时间字段填充
- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch - upsert_task_class 若返回 validation.ok=false必须先按 validation.issues 补齐,再重试;禁止直接 done
- spread_even 用于"范围内均匀化",必须先用 query_target_tasks 明确目标任务集合 - subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
- 多任务调整默认先调用 query_target_tasks(enqueue=true),再用 queue_pop_head 逐项处理 - 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
- queue_apply_head_move 只能用于 current 任务;若当前任务无法落位,调用 queue_skip_head 后继续
- batch_move 一次最多 2 条;超过 2 条必须改走队列逐项处理
`)
}
// BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。
func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
`)
}
// BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。
func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
`) `)
} }

View File

@@ -12,9 +12,8 @@ import (
) )
const ( const (
// executeHistoryKindKey 用于在 history 中打运行态标记,供 prompt 分层识别 // executeHistoryKindKey 用于在 history 里区分普通用户消息与后端注入的纠错提示
// 说明loop_closed / step_advanced 等边界标记仍由节点层写入,但 prompt 层已不再消费它们—— // 这里负责“识别并过滤”,不负责写入该标记。
// 因为 msg1/msg2 已经按"真实对话流 + 当前活跃 ReAct 记录"重构,不再做 msg2→msg1 的归档搬运。
executeHistoryKindKey = "newagent_history_kind" executeHistoryKindKey = "newagent_history_kind"
executeHistoryKindCorrectionUser = "llm_correction_prompt" executeHistoryKindCorrectionUser = "llm_correction_prompt"
) )
@@ -31,20 +30,29 @@ type executeLoopRecord struct {
Observation string Observation string
} }
// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。 type conversationTurn struct {
Role string
Content string
}
type executeLatestToolRecord struct {
ToolName string
Observation string
}
// buildExecuteStageMessages 组装 execute 阶段的四段式消息。
// //
// 消息结构(固定): // 1. msg0系统提示 + 动态规则包 + 工具简表。
// 1. message[0] 固定 prompt规则 + 微调硬引导 + 输出约束 + 工具简表) // 2. msg1真实对话流只保留 user 和 assistant speak。
// 2. message[1] 历史上下文(真实对话流 + 早期 ReAct 摘要) // 3. msg2当前 ReAct tool loop 记录。
// 3. message[2] 当轮 ReAct Loop 窗口thought/reason + tool_call + observation 绑定展示) // 4. msg3执行状态、阶段约束、记忆和本轮指令。
// 4. message[3] 当前执行状态轮次、模式、plan 步骤、任务类、相关记忆等)
func buildExecuteStageMessages( func buildExecuteStageMessages(
stageSystemPrompt string, stageSystemPrompt string,
state *newagentmodel.CommonState, state *newagentmodel.CommonState,
ctx *newagentmodel.ConversationContext, ctx *newagentmodel.ConversationContext,
runtimeUserPrompt string, runtimeUserPrompt string,
) []*schema.Message { ) []*schema.Message {
msg0 := buildExecuteMessage0(stageSystemPrompt, ctx) msg0 := buildExecuteMessage0(stageSystemPrompt, state, ctx)
msg1 := buildExecuteMessage1V3(ctx) msg1 := buildExecuteMessage1V3(ctx)
msg2 := buildExecuteMessage2V3(ctx) msg2 := buildExecuteMessage2V3(ctx)
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt) msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
@@ -57,27 +65,30 @@ func buildExecuteStageMessages(
} }
} }
// buildExecuteMessage0 生成固定规则消息,并附带工具简表 // buildExecuteMessage0 生成 execute 阶段的固定规则消息。
func buildExecuteMessage0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string { //
// 1. 先拼基础 system prompt保证身份和输出协议稳定。
// 2. 再按当前 domain / packs 注入动态规则包,让模型先读到边界。
// 3. 最后再附工具简表,避免模型只看到工具不看到纪律。
func buildExecuteMessage0(stageSystemPrompt string, state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) string {
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
if base == "" { if base == "" {
base = "你是 SmartMate 执行器,请继续 execute 阶段。" base = "你是 SmartMate 执行器,请继续当前执行阶段。"
} }
toolCatalog := renderExecuteToolCatalogCompact(ctx) rulePackSection, _ := renderExecuteRulePackSection(state, ctx)
if toolCatalog == "" { if rulePackSection != "" {
return base base += "\n\n" + rulePackSection
} }
return base + "\n\n" + toolCatalog
toolCatalog := renderExecuteToolCatalogCompact(ctx, state)
if toolCatalog != "" {
base += "\n\n" + toolCatalog
}
return base
} }
// buildExecuteMessage1V3 只渲染"真实对话流 + 阶段锚点" // buildExecuteMessage1V3 只渲染真实对话流,不混入 tool observation
//
// 改造说明:
// 1. msg1 只保留 user + assistant speak 组成的真实对话历史,全量注入;
// 2. tool_call / observation 一律由 msg2 承载,这里不再重复;
// 3. 不再从历史中"归档"上一轮 ReAct 结果到 msg1——归档搬运逻辑已随 splitExecuteLoopRecordsByBoundary 一并移除;
// 4. token 预算由统一压缩层兜底prompt 层不做提前裁剪。
func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string { func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
lines := []string{"历史上下文:"} lines := []string{"历史上下文:"}
if ctx == nil { if ctx == nil {
@@ -105,16 +116,13 @@ func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
} else { } else {
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。") lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
// buildExecuteMessage2V3 承载当前会话中全部 ReAct Loop 记录 // buildExecuteMessage2V3 承载当 ReAct loop。
// //
// 改造说明: // 1. 每条记录固定展示 thought / tool_call / observation方便模型做局部闭环。
// 1. 不再按 execute_loop_closed / execute_step_advanced 边界切分"归档/活跃"两段; // 2. 如果当前还没有任何 tool loop明确给“新一轮”占位避免模型误判缺上下文。
// 2. 直接从 history 提取全部 assistant tool_call + 对应 observation 作为当前 Loop 视图;
// 3. 新一轮刚开始(尚未产生 tool_call时返回明确占位方便模型识别"干净起点"。
func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string { func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
lines := []string{"当轮 ReAct Loop 记录:"} lines := []string{"当轮 ReAct Loop 记录:"}
if ctx == nil { if ctx == nil {
@@ -136,11 +144,19 @@ func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
// buildExecuteMessage3 汇总当前执行状态和本轮指令。
//
// 1. 这里只放“当前轮真正会影响决策”的状态,避免 msg3 继续膨胀。
// 2. 读工具最近结果只给最新一条摘要,避免旧 observation 重复占上下文。
// 3. 最后一行固定落到“本轮指令”,保证模型收尾时注意力还在执行目标上。
func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string { func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string {
lines := []string{"当前执行状态:"} lines := []string{"当前执行状态:"}
roughBuildDone := hasExecuteRoughBuildDone(ctx)
roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds
modeText := "自由执行(无预定义步骤)" modeText := "自由执行(无预定义步骤)"
activeDomain := ""
activePacks := []string{}
if state != nil { if state != nil {
roundUsed = state.RoundUsed roundUsed = state.RoundUsed
if state.MaxRounds > 0 { if state.MaxRounds > 0 {
@@ -149,15 +165,23 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
if state.HasPlan() { if state.HasPlan() {
modeText = "计划执行(有预定义步骤)" modeText = "计划执行(有预定义步骤)"
} }
activeDomain = strings.TrimSpace(state.ActiveToolDomain)
activePacks = readExecuteActiveToolPacks(state)
} }
lines = append(lines, lines = append(lines,
fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds), fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds),
"- 当前模式:"+modeText, "- 当前模式:"+modeText,
) )
// 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。 if activeDomain == "" {
// 2. 该锚点用于约束模型只推进当前步骤,避免退化成泛化 ReAct。 lines = append(lines, "- 动态工具区:当前仅激活 context 管理工具。")
// 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。 } else if len(activePacks) == 0 {
lines = append(lines, fmt.Sprintf("- 动态工具区domain=%s未显式激活 packs。", activeDomain))
} else {
lines = append(lines, fmt.Sprintf("- 动态工具区domain=%spacks=[%s]。", activeDomain, strings.Join(activePacks, ",")))
}
if state != nil && state.HasPlan() { if state != nil && state.HasPlan() {
current, total := state.PlanProgress() current, total := state.PlanProgress()
lines = append(lines, "计划步骤锚点(强约束):") lines = append(lines, "计划步骤锚点(强约束):")
@@ -170,26 +194,41 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
if doneWhen == "" { if doneWhen == "" {
doneWhen = "(未提供 done_when需基于步骤目标给出可验证完成证据" doneWhen = "(未提供 done_when需基于步骤目标给出可验证完成证据"
} }
lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total)) lines = append(lines,
lines = append(lines, "- 当前步骤内容:"+stepContent) fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total),
lines = append(lines, "- 当前步骤完成判定(done_when)"+doneWhen) "- 当前步骤内容:"+stepContent,
lines = append(lines, "- 动作纪律1未满足 done_when 时,只能 continue / confirm / ask_user禁止 next_plan") "- 当前步骤完成判定(done_when)"+doneWhen,
lines = append(lines, "- 动作纪律2满足 done_when 时,优先 next_plan并在 goal_check 对照 done_when 给证据") "- 动作纪律1满足 done_when 时,只能 continue / confirm / ask_user禁止 next_plan。",
lines = append(lines, "- 动作纪律3禁止跳到后续步骤执行") "- 动作纪律2满足 done_when 时,优先 next_plan并在 goal_check 对照 done_when 给证据。",
"- 动作纪律3禁止跳到后续步骤执行。",
)
} else { } else {
lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划") lines = append(lines,
lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据") "- 当前计划步骤不可读;请先判断是否已完成全部计划。",
"- 若已完成全部计划,输出 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 != "" { if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
lines = append(lines, "- 目标任务类:"+taskClassText) lines = append(lines, "- 目标任务类:"+taskClassText)
} }
lines = append(lines, "- 啥时候结束Loop你可以根据工具调用记录自行判断。")
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。") lines = append(lines,
if hasExecuteRoughBuildDone(ctx) { "- 啥时候结束Loop你可以根据工具调用记录自行判断。",
"- 非目标:不重新粗排、不修改无关任务类。",
)
if roughBuildDone {
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggestedexisting 仅作已安排事实参考,不作为可移动目标。") lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggestedexisting 仅作已安排事实参考,不作为可移动目标。")
} }
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。") lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回参数非法,需先改参再继续。")
if state != nil { if state != nil {
if state.AllowReorder { if state.AllowReorder {
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。") 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。") lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
} }
} }
if upsertRuntime := renderTaskClassUpsertRuntime(state); upsertRuntime != "" {
lines = append(lines, "任务类写入运行态:")
lines = append(lines, upsertRuntime)
}
if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" { if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" {
lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):") lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):")
lines = append(lines, memoryText) 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) instruction := strings.TrimSpace(runtimeUserPrompt)
if instruction == "" { if instruction == "" {
instruction = "请继续当前任务执行阶段,严格输出 JSON。" instruction = "请继续当前任务执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。"
} else { } else {
instruction = firstExecuteLine(instruction) instruction = firstExecuteLine(instruction)
} }
@@ -214,8 +265,12 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
// renderExecuteToolCatalogCompact 将工具 schema 渲染成简表,避免大段 JSON 示例占用上下文 // renderExecuteToolCatalogCompact 将当前 tool schemas 渲染为紧凑简表
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) string { //
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
// 3. P1 阶段隐藏 min_context_switch避免模型误用已禁能力。
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string {
if ctx == nil { if ctx == nil {
return "" return ""
} }
@@ -225,36 +280,79 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) str
} }
lines := []string{"可用工具(简表):"} lines := []string{"可用工具(简表):"}
for i, schemaItem := range schemas { index := 0
for _, schemaItem := range schemas {
name := strings.TrimSpace(schemaItem.Name) name := strings.TrimSpace(schemaItem.Name)
desc := strings.TrimSpace(schemaItem.Desc)
if name == "" { if name == "" {
continue continue
} }
if shouldHideMinContextSwitchForP1(state, name) {
continue
}
index++
desc := strings.TrimSpace(schemaItem.Desc)
if desc == "" { if desc == "" {
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) doc := parseExecuteToolSchema(schemaItem.SchemaText)
paramSummary := renderExecuteToolParamSummary(doc.Parameters) paramSummary := renderExecuteToolParamSummary(doc.Parameters)
lines = append(lines, " 参数:"+paramSummary) lines = append(lines, " 参数:"+paramSummary)
returnType, returnSample := renderExecuteToolReturnHint(name) returnType, returnSample := renderExecuteToolReturnHint(name)
lines = append(lines, " 返回类型:"+returnType) 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") 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) { func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
returnType = "string自然语言文本" returnType = "string自然语言文本"
switch strings.ToLower(strings.TrimSpace(toolName)) { switch strings.ToLower(strings.TrimSpace(toolName)) {
case "get_overview": case "get_overview":
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)..." return returnType, "规划窗口共27天...课程占位条目34个...任务清单(已过滤课程)..."
case "get_task_info": case "get_task_info":
return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段第3天第5-6节" return returnType, "[35] 第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段第3天第5-6节"
case "query_available_slots": case "query_available_slots":
return "stringJSON字符串", `{"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"}]}` return "stringJSON字符串", `{"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": case "query_target_tasks":
@@ -276,7 +374,7 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
case "swap": case "swap":
return returnType, "交换完成:[35]... ↔ [36]..." return returnType, "交换完成:[35]... ↔ [36]..."
case "batch_move": case "batch_move":
return returnType, "批量移动完成2个任务全部成功。单次最多2条" return returnType, "批量移动完成2 个任务全部成功。"
case "spread_even": case "spread_even":
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。" return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
case "min_context_switch": case "min_context_switch":
@@ -287,6 +385,14 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return "stringJSON字符串", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}` return "stringJSON字符串", `{"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": case "web_fetch":
return "stringJSON字符串", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}` return "stringJSON字符串", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}`
case "analyze_health":
return "stringJSON字符串", `{"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 "stringJSON字符串", `{"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 "stringJSON字符串", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
case "upsert_task_class":
return "stringJSON字符串", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
default: default:
return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。" return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。"
} }
@@ -353,12 +459,11 @@ func renderExecuteToolParamSummary(parameters map[string]any) string {
return strings.Join(parts, "") return strings.Join(parts, "")
} }
// collectExecuteLoopRecords 从历史中提取 ReAct 记录 // collectExecuteLoopRecords 从 history 里提取 thought + tool_call + observation 三元组
// //
// 提取策略: // 1. 以 assistant tool_call 为主记录。
// 1. 以 assistant tool_call 消息为主键; // 2. 用 ToolCallID 去关联 tool observation保证同轮绑定。
// 2. 关联同 ToolCallID 的 tool result 作为 observation // 3. thought 只向前取最近一条 assistant 纯文本消息,不跨越到更早的工具调用之前做复杂回溯。
// 3. 向前回溯最近一条 assistant 文本消息作为 thought/reason。
func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord { func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
if len(history) == 0 { if len(history) == 0 {
return nil return nil
@@ -381,12 +486,14 @@ func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 { if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
continue continue
} }
thought := findExecuteThoughtBefore(history, i) thought := findExecuteThoughtBefore(history, i)
for _, call := range msg.ToolCalls { for _, call := range msg.ToolCalls {
toolName := strings.TrimSpace(call.Function.Name) toolName := strings.TrimSpace(call.Function.Name)
if toolName == "" { if toolName == "" {
toolName = "unknown_tool" toolName = "unknown_tool"
} }
toolArgs := compactExecuteText(call.Function.Arguments, 160) toolArgs := compactExecuteText(call.Function.Arguments, 160)
if toolArgs == "" { if toolArgs == "" {
toolArgs = "{}" toolArgs = "{}"
@@ -424,10 +531,9 @@ func findExecuteThoughtBefore(history []*schema.Message, index int) string {
continue continue
} }
content := compactExecuteText(msg.Content, 140) content := compactExecuteText(msg.Content, 140)
if content == "" { if content != "" {
continue return content
} }
return content
} }
return "(未记录)" return "(未记录)"
} }
@@ -456,18 +562,116 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
return false return false
} }
// conversationTurn 表示对话历史中的一轮交互user 或 assistant speak func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string {
type conversationTurn struct { record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
Role string "analyze_health": {},
Content string "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. 过滤 correction prompt避免把后端纠错提示伪装成用户真实意图。
// 1. 只保留 user 消息(排除 correction prompt和 assistant speak 消息(非空 Content 且无 ToolCalls // 2. 过滤 assistant tool_call 消息,避免 msg1 和 msg2 重复。
// 2. 全量保留不再限制轮数和单条长度token 预算由 execute 层统一管理); // 3. 保持原始顺序,不在这里裁剪长度。
// 3. 返回的条目按原始时间顺序排列。
func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn { func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn {
if len(history) == 0 { if len(history) == 0 {
return nil return nil
@@ -556,11 +760,44 @@ func renderExecuteTaskClassIDs(state *newagentmodel.CommonState) string {
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ",")) return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ","))
} }
// renderExecuteMemoryContext 提取 execute 阶段要注入 msg3 的记忆文本 // renderExecuteMemoryContext 复用统一记忆入口,避免 execute 私自拼接其他 pinned block
//
// 1. 只读取统一的 memory_context避免把其他 pinned block 误塞进 prompt。
// 2. 为空时直接返回空串,保持 msg3 干净。
// 3. 复用统一记忆渲染逻辑,保证各阶段记忆入口一致。
func renderExecuteMemoryContext(ctx *newagentmodel.ConversationContext) string { func renderExecuteMemoryContext(ctx *newagentmodel.ConversationContext) string {
return renderUnifiedMemoryContext(ctx) 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
}

View File

@@ -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))
}

View File

@@ -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, ",")
}

View File

@@ -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 ""
}

View File

@@ -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=confirmcontinue + 写工具无效。
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
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。
- 只在业务方向切换时再 removedone 后的动态区清理由系统自动完成,不必手动 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)
}

View File

@@ -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 次之;不要做全窗口搜索。`),
}
}

View File

@@ -8,58 +8,46 @@ import (
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
) )
const planSystemPrompt = ` const planSystemPromptCore = `
你是 SmartMate 的规划器。 你是 SmartMate 的规划器Planner只负责规划不负责执行
你的职责不是直接执行任务,而是先把用户意图拆成一组清晰、稳定、可逐步执行的自然语言计划,并严格按后端约定的 JSON 协议输出。
请遵守以下规则: 最高优先级规则:
1. 只负责规划,不要假装已经调用了工具,也不要伪造执行结果 1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作
2. 每一轮只推进一步规划;如果信息不足,应明确转成 ask_user而不是继续硬猜 2. 事实边界:禁止伪造工具调用和执行结果
3. 若当前计划仍不完整,就继续围绕当前任务补全计划,不要跳去执行细节。
4. 若你认为计划已经完整可执行,请返回 action=plan_done并附带完整 plan_steps。
5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。
6. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。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. 每轮只做一次决策continue / ask_user / plan_done
- 已有完整 plan如果之前已经规划过 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。`
请基于这些输入继续规划,而不是重复忽略既有 plan。
`
// BuildPlanSystemPrompt 返回规划阶段系统提示词。 // BuildPlanSystemPrompt 返回规划阶段系统提示词。
func BuildPlanSystemPrompt() string { func BuildPlanSystemPrompt() string {
return strings.TrimSpace(planSystemPrompt) parts := []string{
strings.TrimSpace(planSystemPromptCore),
BuildPlanDecisionContractText(),
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
} }
// BuildPlanMessages 组装规划阶段的 messages。 // BuildPlanMessages 组装规划阶段的 messages。
// //
// 职责边界: // 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。
// 1. 负责把 state + context 收敛成统一 4 段式规划阶段模型输入 // 2. msg1 展示真实对话msg2 展示规划工作区msg3 仅给最小执行指令与用户本轮输入
// 2. 不负责解析模型输出,也不负责判断规划质量; // 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。
// 3. msg3 中的状态文本由本函数显式传入,确保统一骨架下仍能看到完整计划与阶段信息。
func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message { func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message {
return buildUnifiedStageMessages( return buildUnifiedStageMessages(
ctx, ctx,
StageMessagesConfig{ StageMessagesConfig{
SystemPrompt: BuildPlanSystemPrompt(), SystemPrompt: BuildPlanSystemPrompt(),
Msg1Content: buildPlanConversationMessage(ctx), Msg1Content: buildPlanConversationMessage(ctx),
Msg2Content: buildPlanWorkspace(state), Msg2Content: buildPlanWorkspace(state),
Msg3Suffix: BuildPlanUserPrompt(state, userInput), Msg3Suffix: BuildPlanUserPrompt(state, userInput),
Msg3Role: schema.User, 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 { func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("请继续当前任务规划阶段,严格按 SMARTFLOW_DECISION 标签格式输出。\n") sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
sb.WriteString("目标:围绕最近对话规划工作区信息,产出一份稳定、可执行的自然语言计划;若关键信息不足,请明确 ask_user。\n\n") sb.WriteString("请基于最近对话规划工作区推进,不要重复已有计划内容。\n")
sb.WriteString(BuildPlanDecisionContractText()) sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
trimmedInput := strings.TrimSpace(userInput) trimmedInput := strings.TrimSpace(userInput)
if trimmedInput != "" { if trimmedInput != "" {
@@ -85,40 +73,30 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。 // BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
func BuildPlanDecisionContractText() string { func BuildPlanDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(` return strings.TrimSpace(fmt.Sprintf(`
输出协议(两阶段格式 输出协议(唯一口径
1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
2. 再输出:给用户看的自然语言正文
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。 JSON 字段:
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s - action只能是 %s / %s / %s
- reason给后端和日志看的简短说明 - reason给后端和日志看的简短说明
- complexity任务复杂度,只能是 simple / moderate / complex - complexity只能是 simple / moderate / complex
- plan_steps仅当 action=%s 时允许返回;返回时必须是完整计划,不是增量 - plan_steps仅当 action=%s 时允许返回,且必须是完整计划
- plan_steps[].content步骤正文必填 - plan_steps[].content步骤正文必填
- plan_steps[].done_when可选建议写"什么情况下算这一步做完" - plan_steps[].done_when可选建议写完成判定
- needs_rough_build满足粗排识别规则时为 true否则省略;为 true 时后端自动运行粗排算法 - needs_rough_build仅满足粗排识别条件时为 true否则省略
- task_class_idsneeds_rough_build=true 时必填,从上下文"任务类 ID"字段读取 - task_class_idsneeds_rough_build=true 时必填,从上下文读取
- context_hook可选仅用于给 execute 阶段提供注入建议
- context_hook.domainschedule / taskclass
- context_hook.packsstring 数组可选core 固定注入,不要填写 core
- context_hook.reason可选说明为何建议该注入
注意:JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。 注意:
- JSON 中不要包含 speak 字段
合法示例: - 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove`,
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前信息已足够继续规划","complexity":"moderate"}</SMARTFLOW_DECISION>
我先把计划再收束一下。
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前时间范围仍不明确","complexity":"simple"}</SMARTFLOW_DECISION>
你更希望我优先安排今天,还是按整周来规划?
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前计划已具备执行条件","complexity":"simple","plan_steps":[{"content":"先确认本周可用时间范围","done_when":"拿到明确的可用时间段列表"},{"content":"基于可用时间生成执行安排","done_when":"得到一份用户可确认的安排方案"}]}</SMARTFLOW_DECISION>
计划已经整理好了,我先给你确认一下。
`,
newagentmodel.PlanActionContinue, newagentmodel.PlanActionContinue,
newagentmodel.PlanActionAskUser, newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionDone, newagentmodel.PlanActionDone,
newagentmodel.PlanActionDone, newagentmodel.PlanActionDone,
newagentmodel.PlanActionContinue,
newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionDone,
)) ))
} }

View File

@@ -127,7 +127,25 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string {
if tc.StartDate != "" || tc.EndDate != "" { if tc.StartDate != "" || tc.EndDate != "" {
line += fmt.Sprintf(";日期范围:%s ~ %s", 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) lines = append(lines, line)
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func planSemanticValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "未标注"
}
return trimmed
}

View File

@@ -1,6 +1,7 @@
package newagentprompt package newagentprompt
import ( import (
"fmt"
"strings" "strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
@@ -45,6 +46,13 @@ type StageMessagesConfig struct {
// Msg3Role 指定第 4 条消息的角色。 // Msg3Role 指定第 4 条消息的角色。
// Execute 继续使用 system其余节点一般使用 user。 // Execute 继续使用 system其余节点一般使用 user。
Msg3Role schema.RoleType Msg3Role schema.RoleType
// SkipBaseSystemPrompt 为 true 时msg0 只使用节点自己的 SystemPrompt
// 不再拼接 ConversationContext.SystemPrompt。
SkipBaseSystemPrompt bool
// UseLiteToolCatalogMsg 为 true 时msg0 工具目录采用轻量模式(仅名称与职责)。
UseLiteToolCatalogMsg bool
} }
// buildUnifiedStageMessages 组装统一 4 段式消息骨架。 // buildUnifiedStageMessages 组装统一 4 段式消息骨架。
@@ -58,7 +66,7 @@ func buildUnifiedStageMessages(
ctx *newagentmodel.ConversationContext, ctx *newagentmodel.ConversationContext,
config StageMessagesConfig, config StageMessagesConfig,
) []*schema.Message { ) []*schema.Message {
msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx) msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx, config.SkipBaseSystemPrompt, config.UseLiteToolCatalogMsg)
msg1 := buildUnifiedMsg1(config.Msg1Content) msg1 := buildUnifiedMsg1(config.Msg1Content)
msg2 := buildUnifiedMsg2(config.Msg2Content) msg2 := buildUnifiedMsg2(config.Msg2Content)
msg3 := buildUnifiedMsg3(ctx, config) msg3 := buildUnifiedMsg3(ctx, config)
@@ -85,19 +93,72 @@ func buildUnifiedMsg3Message(content string, role schema.RoleType) *schema.Messa
// 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定; // 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定;
// 2. 若当前节点注入了工具 schema则附加紧凑工具目录 // 2. 若当前节点注入了工具 schema则附加紧凑工具目录
// 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。 // 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。
func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string { func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext, skipBaseSystemPrompt bool, useLiteToolCatalog bool) string {
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt)) base := ""
if skipBaseSystemPrompt {
base = strings.TrimSpace(stageSystemPrompt)
} else {
base = strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
}
if base == "" { if base == "" {
base = "你是 SmartMate 助手,请继续当前阶段。" base = "你是 SmartMate 助手,请继续当前阶段。"
} }
toolCatalog := renderExecuteToolCatalogCompact(ctx) toolCatalog := renderExecuteToolCatalogCompact(ctx, nil)
if useLiteToolCatalog {
toolCatalog = renderUnifiedToolCatalogLite(ctx)
}
if toolCatalog == "" { if toolCatalog == "" {
return base return base
} }
return base + "\n\n" + toolCatalog 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 返回节点自行提供的历史视图。 // buildUnifiedMsg1 返回节点自行提供的历史视图。
// //
// 说明: // 说明:

View File

@@ -17,6 +17,10 @@ var (
// 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。 // 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。
decisionTagRegex = regexp.MustCompile( decisionTagRegex = regexp.MustCompile(
`(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`) `(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`)
// decisionTagHeadRegex 仅用于识别“起始标签是否已经出现”。
// 目的:避免模型已经输出了 <SMARTFLOW_DECISION 开头但尚未输出闭合标签时,
// 被长度阈值误判为 fallback即“假截断”
decisionTagHeadRegex = regexp.MustCompile(`(?i)<\s*SMARTFLOW_DECISION\b`)
) )
// StreamDecisionResult 描述解析器的最终输出状态。 // StreamDecisionResult 描述解析器的最终输出状态。
@@ -25,6 +29,14 @@ type StreamDecisionResult struct {
// 调用方应使用 infrallm.ParseJSONObject[T] 将其解析为具体决策类型。 // 调用方应使用 infrallm.ParseJSONObject[T] 将其解析为具体决策类型。
DecisionJSON string DecisionJSON string
// BeforeText 是 <SMARTFLOW_DECISION> 标签之前的自然语言前言。
// 仅用于“标签后正文为空”时的兜底展示,不参与 JSON 解析。
BeforeText string
// AfterText 是 </SMARTFLOW_DECISION> 标签之后的自然语言正文。
// 这是主协议约定的用户可见文本来源。
AfterText string
// Fallback=true 表示流中未找到决策标签(超过 500 字符阈值), // Fallback=true 表示流中未找到决策标签(超过 500 字符阈值),
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。 // RawBuffer 包含全部累积文本,调用方应走 correction 路径。
Fallback bool Fallback bool
@@ -51,6 +63,8 @@ type StreamDecisionParser struct {
buf strings.Builder buf strings.Builder
decisionFound bool decisionFound bool
decisionJSON string decisionJSON string
beforeText string
afterText string
rawBuf string // 用于 fallback/correction rawBuf string // 用于 fallback/correction
} }
@@ -81,8 +95,13 @@ func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool,
text := p.buf.String() text := p.buf.String()
match := decisionTagRegex.FindStringSubmatchIndex(text) match := decisionTagRegex.FindStringSubmatchIndex(text)
if match == nil { if match == nil {
// 标签尚未完整,检查 fallback 阈值。 // 1. 标签尚未完整,检查 fallback 阈值。
// 2. 仅当“完全没有出现起始标签”时才允许 fallback。
// 3. 若已经出现起始标签但还没闭合,则继续等待后续 chunk避免早退。
if len(text) > 500 { if len(text) > 500 {
if decisionTagHeadRegex.MatchString(text) {
return "", false, nil
}
p.decisionFound = true p.decisionFound = true
p.rawBuf = text p.rawBuf = text
return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签") 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.decisionJSON = jsonStr
p.rawBuf = text p.rawBuf = text
// 提取标签之后的文本作为 visible // 1. 同时提取标签前/标签后的自然语言片段
// 2. 标签后正文仍然作为主协议 visible 返回,保持现有流式链路不变。
// 3. 标签前前言只记入 Result供 execute 在“后文为空”时兜底补发。
fullMatch := groups[0] fullMatch := groups[0]
tagEndIdx := strings.Index(text, fullMatch) tagEndIdx := strings.Index(text, fullMatch)
if tagEndIdx >= 0 { if tagEndIdx >= 0 {
beforeTag := strings.TrimSpace(text[:tagEndIdx])
afterTag := text[tagEndIdx+len(fullMatch):] afterTag := text[tagEndIdx+len(fullMatch):]
afterTag = strings.TrimPrefix(afterTag, "\r\n") afterTag = strings.TrimPrefix(afterTag, "\r\n")
afterTag = strings.TrimPrefix(afterTag, "\n") afterTag = strings.TrimPrefix(afterTag, "\n")
p.beforeText = beforeTag
p.afterText = afterTag
return afterTag, true, nil return afterTag, true, nil
} }
@@ -138,6 +162,8 @@ func (p *StreamDecisionParser) DecisionJSON() string {
func (p *StreamDecisionParser) Result() *StreamDecisionResult { func (p *StreamDecisionParser) Result() *StreamDecisionResult {
r := &StreamDecisionResult{ r := &StreamDecisionResult{
DecisionJSON: p.decisionJSON, DecisionJSON: p.decisionJSON,
BeforeText: p.beforeText,
AfterText: p.afterText,
RawBuffer: p.rawBuf, RawBuffer: p.rawBuf,
} }
if p.rawBuf != "" && p.decisionJSON == "" { if p.rawBuf != "" && p.decisionJSON == "" {

View File

@@ -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
}

View File

@@ -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 支持可选 packstaskclass 目前不支持可选 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)
}

View File

@@ -10,36 +10,62 @@ import (
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web" "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 type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
// ToolSchemaEntry 注入给模型的工具说明快照。 // ToolSchemaEntry 描述注入给模型的工具快照。
type ToolSchemaEntry struct { type ToolSchemaEntry struct {
Name string Name string
Desc string Desc string
SchemaText string SchemaText string
} }
// DefaultRegistryDeps 描述默认工具注册表可选依赖。 // DefaultRegistryDeps 描述默认注册表需要的外部依赖。
// // 职责边界:
// 说明: // 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
// 1. 这层依赖注入先为后续 websearch / memory 工具预留统一入口 // 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new
// 2. 当前即便部分依赖暂未使用,也不应让业务侧再自行 new 底层 Infra // 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。
// 3. 后续新增读工具时,应优先在这里扩展依赖而不是走包级全局变量。
type DefaultRegistryDeps struct { type DefaultRegistryDeps struct {
RAGRuntime infrarag.Runtime RAGRuntime infrarag.Runtime
// WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。 // WebSearchProvider 为 nil 时web_search / web_fetch 仍会注册,
// 但 handler 会返回“暂未启用”的只读 observation不阻断主流程。
WebSearchProvider web.SearchProvider WebSearchProvider web.SearchProvider
// TaskClassWriteDeps 供 upsert_task_class 调用持久化层。
TaskClassWriteDeps TaskClassWriteDeps
} }
// ToolRegistry 管理工具注册、查找与执行。 // ToolRegistry 管理工具注册、过滤与执行。
type ToolRegistry struct { type ToolRegistry struct {
handlers map[string]ToolHandler handlers map[string]ToolHandler
schemas []ToolSchemaEntry schemas []ToolSchemaEntry
deps DefaultRegistryDeps 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 创建空注册表。 // NewToolRegistry 创建空注册表。
func NewToolRegistry() *ToolRegistry { func NewToolRegistry() *ToolRegistry {
return NewToolRegistryWithDeps(DefaultRegistryDeps{}) return NewToolRegistryWithDeps(DefaultRegistryDeps{})
@@ -65,7 +91,14 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
} }
// Execute 执行指定工具。 // Execute 执行指定工具。
// 职责边界:
// 1. 这里只负责找到 handler 并调用;
// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler
// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string { 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] handler, ok := r.handlers[toolName]
if !ok { if !ok {
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具%s", toolName, strings.Join(r.ToolNames(), "、")) 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) return handler(state, args)
} }
// HasTool 检查工具是否已注册。 // HasTool 判断工具是否已注册且当前可见
func (r *ToolRegistry) HasTool(name string) bool { func (r *ToolRegistry) HasTool(name string) bool {
if r.IsToolTemporarilyDisabled(name) {
return false
}
_, ok := r.handlers[name] _, ok := r.handlers[name]
return ok return ok
} }
// ToolNames 返回已注册工具名(按 schema 顺序) // IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态
func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool {
return IsTemporarilyDisabledTool(name)
}
// ToolNames 返回当前可暴露给模型的工具名。
func (r *ToolRegistry) ToolNames() []string { func (r *ToolRegistry) ToolNames() []string {
names := make([]string, 0, len(r.handlers)) names := make([]string, 0, len(r.schemas))
for _, item := range r.schemas { for _, item := range r.schemas {
if r.IsToolTemporarilyDisabled(item.Name) {
continue
}
names = append(names, item.Name) names = append(names, item.Name)
} }
return names return names
} }
// Schemas 返回 schema 快照。 // Schemas 返回当前可暴露给模型的 schema 快照。
func (r *ToolRegistry) Schemas() []ToolSchemaEntry { func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
result := make([]ToolSchemaEntry, len(r.schemas)) result := make([]ToolSchemaEntry, 0, len(r.schemas))
copy(result, r.schemas) for _, item := range r.schemas {
if r.IsToolTemporarilyDisabled(item.Name) {
continue
}
result = append(result, item)
}
return result 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 { 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。 // RequiresScheduleState 判断工具是否依赖 ScheduleState。
// 调用目的execute 节点据此决定是否允许在 ScheduleState 为 nil 时调用该工具。
func (r *ToolRegistry) RequiresScheduleState(name string) bool { func (r *ToolRegistry) RequiresScheduleState(name string) bool {
return !scheduleFreeTools[name] return !scheduleFreeTools[strings.TrimSpace(name)]
} }
// ==================== 写工具集合 ====================
var writeTools = map[string]bool{ var writeTools = map[string]bool{
"place": true, "place": true,
"move": true, "move": true,
@@ -117,38 +235,83 @@ var writeTools = map[string]bool{
"spread_even": true, "spread_even": true,
"min_context_switch": true, "min_context_switch": true,
"unplace": true, "unplace": true,
"upsert_task_class": true,
} }
// ==================== 不依赖 ScheduleState 的工具集合 ==================== var scheduleMutationTools = map[string]bool{
// 调用目的这些工具不需要日程状态即可执行execute 节点在 ScheduleState 为 nil 时允许调用。 "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{ var scheduleFreeTools = map[string]bool{
"web_search": true, "web_search": true,
"web_fetch": true, "web_fetch": true,
"upsert_task_class": true,
ToolNameContextToolsAdd: true,
ToolNameContextToolsRemove: true,
} }
// ==================== 默认注册表 ==================== // NewDefaultRegistry 创建默认注册表。
// NewDefaultRegistry 创建默认日程工具注册表。
func NewDefaultRegistry() *ToolRegistry { func NewDefaultRegistry() *ToolRegistry {
return NewDefaultRegistryWithDeps(DefaultRegistryDeps{}) return NewDefaultRegistryWithDeps(DefaultRegistryDeps{})
} }
// NewDefaultRegistryWithDeps 创建带依赖的默认日程工具注册表。 // NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
// 步骤化说明:
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
// 2. 再注册 schedule 域的读、诊断、写工具;
// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。
func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
r := NewToolRegistryWithDeps(deps) r := NewToolRegistryWithDeps(deps)
// --- 读工具 --- registerContextTools(r)
r.Register("get_overview", 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":{}}`, `{"name":"get_overview","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
_ = args
return schedule.GetOverview(state) return schedule.GetOverview(state)
}, },
) )
r.Register(
r.Register("query_range", "query_range",
"查看某天或某时段的细粒度占用详情。day 必填slot_start/slot_end 选填(不填查整天)。", "查看某天或某时段的占用详情。day 必填slot_start/slot_end 选填。",
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
day, ok := schedule.ArgsInt(args, "day") 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")) return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
}, },
) )
r.Register(
r.Register("query_available_slots", "query_available_slots",
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前落点筛选。", "查询候选空位池,适合 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"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryAvailableSlots(state, args) return schedule.QueryAvailableSlots(state, args)
}, },
) )
r.Register(
r.Register("query_target_tasks", "query_target_tasks",
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。", "查询候选任务集合,可按 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"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryTargetTasks(state, args) return schedule.QueryTargetTasks(state, args)
}, },
) )
r.Register(
r.Register("queue_pop_head", "queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。", "弹出并返回当前队首任务;若已有 current 则复用。",
`{"name":"queue_pop_head","parameters":{}}`, `{"name":"queue_pop_head","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueuePopHead(state, args) return schedule.QueuePopHead(state, args)
}, },
) )
r.Register(
r.Register("queue_status", "queue_status",
"查看当前待处理队列状态pending/current/completed/skipped。", "查看当前队列状态pending/current/completed/skipped。",
`{"name":"queue_status","parameters":{}}`, `{"name":"queue_status","parameters":{}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueStatus(state, args) return schedule.QueueStatus(state, args)
}, },
) )
r.Register(
r.Register("get_task_info", "get_task_info",
"查单个任务详细信息,包括类别、状态、占用时段、嵌入关系。", "查单个任务详,包括类别、状态与落位。",
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`, `{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id") taskID, ok := schedule.ArgsInt(args, "task_id")
@@ -202,10 +365,63 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.GetTaskInfo(state, taskID) return schedule.GetTaskInfo(state, taskID)
}, },
) )
}
// --- 写工具 --- func registerScheduleAnalyzeTools(r *ToolRegistry) {
r.Register("place", r.Register(
"将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。", "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}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id") taskID, ok := schedule.ArgsInt(args, "task_id")
@@ -223,9 +439,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.Place(state, taskID, day, slotStart) return schedule.Place(state, taskID, day, slotStart)
}, },
) )
r.Register(
r.Register("move", "move",
"将一个已预排任务(仅 suggested移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。", "将一个已预排任务(仅 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}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id") taskID, ok := schedule.ArgsInt(args, "task_id")
@@ -243,9 +459,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.Move(state, taskID, newDay, newSlotStart) return schedule.Move(state, taskID, newDay, newSlotStart)
}, },
) )
r.Register(
r.Register("swap", "swap",
"交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。", "交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`, `{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
taskA, ok := schedule.ArgsInt(args, "task_a") taskA, ok := schedule.ArgsInt(args, "task_a")
@@ -259,9 +475,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.Swap(state, taskA, taskB) return schedule.Swap(state, taskA, taskB)
}, },
) )
r.Register(
r.Register("batch_move", "batch_move",
"原子性批量移动多个任务(仅 suggested最多2条全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。", "原子性批量移动多个任务。moves 必填。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
moves, err := schedule.ArgsMoveList(args) moves, err := schedule.ArgsMoveList(args)
@@ -271,25 +487,25 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.BatchMove(state, moves) return schedule.BatchMove(state, moves)
}, },
) )
r.Register(
r.Register("queue_apply_head_move", "queue_apply_head_move",
"将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。new_day/new_slot_start 必填。", "将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueApplyHeadMove(state, args) return schedule.QueueApplyHeadMove(state, args)
}, },
) )
r.Register(
r.Register("queue_skip_head", "queue_skip_head",
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。", "跳过当前队首任务,将其标记为 skipped。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`, `{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueSkipHead(state, args) return schedule.QueueSkipHead(state, args)
}, },
) )
r.Register(
r.Register("min_context_switch", "min_context_switch",
"在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。", "在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。",
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args) taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
@@ -299,9 +515,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.MinContextSwitch(state, taskIDs) return schedule.MinContextSwitch(state, taskIDs)
}, },
) )
r.Register(
r.Register("spread_even", "spread_even",
"在给定任务集合内做均匀化铺开先按筛选条件收集候选坑位再规划并原子落地。task_ids 必填(兼容 task_id)。", "在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。",
`{"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"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args) taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
@@ -311,9 +527,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.SpreadEven(state, taskIDs, args) return schedule.SpreadEven(state, taskIDs, args)
}, },
) )
r.Register(
r.Register("unplace", "unplace",
"将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。", "将一个已落位任务移除恢复为待安排状态。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`, `{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id") taskID, ok := schedule.ArgsInt(args, "task_id")
@@ -323,33 +539,37 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
return schedule.Unplace(state, taskID) return schedule.Unplace(state, taskID)
}, },
) )
}
// --- Web 搜索读工具 --- func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation不会阻断主流程 r.Register(
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。 "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) webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider)
webFetchHandler := web.NewFetchToolHandler(web.NewFetcher()) webFetchHandler := web.NewFetchToolHandler(web.NewFetcher())
r.Register("web_search", r.Register(
"Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。", "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"}}}`, `{"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 { func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webSearchHandler.Handle(args) return webSearchHandler.Handle(args)
}, },
) )
r.Register(
r.Register("web_fetch", "web_fetch",
"抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。", "抓取指定 URL 的正文内容并做最小清洗。url 必填。",
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`, `{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string { func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webFetchHandler.Handle(args) 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
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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. 全量通过后再原子提交,避免半成品状态。 // 4. 全量通过后再原子提交,避免半成品状态。
clone := state.Clone() 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() clone := state.Clone()
for taskID, after := range afterByID { for taskID, after := range afterByID {

View File

@@ -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
}

View File

@@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。 // 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
// //
// 步骤化说明: // 步骤化说明:
// 1. 默认 enqueue=true让 LLM 优先走“逐项处理”而不是一次性批量组合 // 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选; // 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
// 3. 入队仅保存 task_id不复制任务全文避免队列状态膨胀。 // 3. 入队仅保存 task_id不复制任务全文避免队列状态膨胀。
queueInfo := (*queryTargetQueueInfo)(nil) queueInfo := (*queryTargetQueueInfo)(nil)
@@ -566,7 +566,7 @@ func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTa
Limit: limit, Limit: limit,
TaskIDSet: intSliceToSet(taskIDs), TaskIDSet: intSliceToSet(taskIDs),
Category: strings.TrimSpace(readStringAny(args, "category", "")), Category: strings.TrimSpace(readStringAny(args, "category", "")),
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"), Enqueue: readBoolAnyWithDefault(args, false, "enqueue"),
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"), ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
}, nil }, nil
} }

View File

@@ -92,6 +92,13 @@ func GetOverview(state *ScheduleState) string {
} }
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ",")) 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") sb.WriteString(line + "\n")
} }
} }

View File

@@ -20,17 +20,25 @@ type TaskSlot struct {
SlotEnd int `json:"slot_end"` SlotEnd int `json:"slot_end"`
} }
// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考 // TaskClassMeta 是任务类级别的调度与认知画像元数据
// 只记录影响排课决策的字段,不暴露数据库内部细节。 //
// 职责边界:
// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段;
// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据;
// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。
type TaskClassMeta struct { type TaskClassMeta struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击 Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
TotalSlots int `json:"total_slots"` // 该任务类总时段预算 TotalSlots int `json:"total_slots"` // 该任务类总时段预算
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段 AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制) ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
StartDate string `json:"start_date,omitempty"` // 排程起始日期YYYY-MM-DD ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几1-7空=无限制
EndDate string `json:"end_date,omitempty"` // 排程截止日期YYYY-MM-DD 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. // ScheduleTask is a unified task representation in the tool state.
@@ -51,6 +59,9 @@ type ScheduleTask struct {
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
// source=task_item only: TaskClass.ID用于反查任务类约束。 // source=task_item only: TaskClass.ID用于反查任务类约束。
TaskClassID int `json:"task_class_id,omitempty"` 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). // source=task_item only: TaskClass.ID for category lookup (internal alias).
CategoryID int `json:"category_id,omitempty"` CategoryID int `json:"category_id,omitempty"`
// source=event only: whether this slot allows embedding other tasks. // source=event only: whether this slot allows embedding other tasks.
@@ -68,7 +79,7 @@ type ScheduleTask struct {
type ScheduleState struct { type ScheduleState struct {
Window ScheduleWindow `json:"window"` Window ScheduleWindow `json:"window"`
Tasks []ScheduleTask `json:"tasks"` Tasks []ScheduleTask `json:"tasks"`
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考 TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。 // RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
// //
// 职责边界: // 职责边界:

View File

@@ -56,6 +56,9 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
if err := validateSlotRange(slotStart, slotEnd); err != nil { if err := validateSlotRange(slotStart, slotEnd); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error()) 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. 冲突检测。 // 4. 冲突检测。
conflict := findConflict(state, day, slotStart, slotEnd) 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 { if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error()) 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. 冲突检测(排除自身)。 // 5. 冲突检测(排除自身)。
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID) conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
@@ -213,6 +219,12 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
copy(oldSlotsA, taskA.Slots) copy(oldSlotsA, taskA.Slots)
oldSlotsB := make([]TaskSlot, len(taskB.Slots)) oldSlotsB := make([]TaskSlot, len(taskB.Slots))
copy(oldSlotsB, 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。 // 6. 交换 Slots。
taskA.Slots, taskB.Slots = taskB.Slots, taskA.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) 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在克隆上执行。 // 2. 克隆 state在克隆上执行。
clone := state.Clone() clone := state.Clone()

View File

@@ -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-22->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)
}

View File

@@ -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. 自动剔除固定包 corecore 不接受显式管理);
// 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
}

View File

@@ -55,6 +55,22 @@ func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model
if req.Mode == "" || req.Name == "" || len(req.Items) == 0 { if req.Mode == "" || req.Name == "" || len(req.Items) == 0 {
return respond.MissingParam 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.写数据库(事务内) //2.写数据库(事务内)
if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error {
taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID) taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID)

View File

@@ -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-CPrompt 策略升级(行为约束)
定义:
- 让 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-APrompt 草案生成
定义:
- 提供“完整任务类草案”生成能力(聊天触发),不新增后端草案工具。
改动动作:
- 修改 [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-1P1 工具层)
- 新增 5 分析工具实现 + 单元级自测(本地运行后清理临时测试文件)。
- 不动 prompt不动 chat 路由。
### PR-2P1 策略层)
- registry 注册 + execute prompt/示例补齐 + 可选 `OptimizationMode`
- 联调首次编排与局部请求两条路径。
- 联调“不可行协商分支”(避免持续 critical 循环)。
- 联调“后端给边界LLM 自主选目标”的读写闭环,并验证 P1 隐藏 `min_context_switch`
### PR-3P1.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。

View File

@@ -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. 主动优化完成后的结果,解释口径更像“为什么这样学更顺”,而不是“哪些数字变好看了”。

View File

@@ -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 Cprompt 仍把模型往“全局不乱序”上引
现象:
1. `execute_context` 里仍写着默认保持 suggested 相对顺序。
修法:
1. 改成“默认保持同任务类内部顺序”。
## 7.4 bug D复合工具可能绕过新规则
现象:
1. `spread_even` 当前只校验冲突,不校验同类前后边界。
修法:
1. 接入统一局部顺序约束层。
## 7.5 bug Eactive 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 相关职责文件,避免继续堆史山。

View File

@@ -153,6 +153,10 @@ interface DisplayAssistantBlock {
event?: ToolTraceEvent event?: ToolTraceEvent
statusEvent?: StatusTraceEvent statusEvent?: StatusTraceEvent
schedulePreview?: SchedulePreviewData schedulePreview?: SchedulePreviewData
/** 所属的源消息 ID用于状态查询 */
sourceId?: string
/** 所属的源消息引用,用于渲染辅助信息 */
source?: AssistantMessage
} }
interface AssistantContentBlock { interface AssistantContentBlock {
@@ -223,6 +227,7 @@ const statusTraceEventsMap = reactive<Record<string, StatusTraceEvent[]>>({})
const toolTraceExpandedMap = reactive<Record<string, boolean>>({}) const toolTraceExpandedMap = reactive<Record<string, boolean>>({})
const assistantReasoningSeqMap = reactive<Record<string, number>>({}) const assistantReasoningSeqMap = reactive<Record<string, number>>({})
const assistantContentBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({}) const assistantContentBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
const assistantReasoningBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
const assistantTimelineLastKindMap = reactive<Record<string, 'content' | 'tool' | 'status' | 'reasoning' | 'other'>>({}) const assistantTimelineLastKindMap = reactive<Record<string, 'content' | 'tool' | 'status' | 'reasoning' | 'other'>>({})
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({}) const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({}) const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
@@ -502,6 +507,11 @@ function appendToolTraceEvent(
const eventSeq = nextAssistantTimelineSeq() const eventSeq = nextAssistantTimelineSeq()
const eventId = `${messageId}:tool:${eventSeq}` const eventId = `${messageId}:tool:${eventSeq}`
// 如果上一个阶段是推理,则结束并折叠它
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
finishCurrentReasoningBlock(messageId)
}
toolTraceEventsMap[messageId].push({ toolTraceEventsMap[messageId].push({
id: eventId, id: eventId,
seq: eventSeq, seq: eventSeq,
@@ -536,6 +546,11 @@ function appendStatusTraceEvent(
} }
const eventSeq = nextAssistantTimelineSeq() const eventSeq = nextAssistantTimelineSeq()
// 如果上一个阶段是推理,则结束并折叠它
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
finishCurrentReasoningBlock(messageId)
}
statusEvents.push({ statusEvents.push({
id: `${messageId}:status:${eventSeq}`, id: `${messageId}:status:${eventSeq}`,
seq: eventSeq, seq: eventSeq,
@@ -554,6 +569,11 @@ function appendAssistantContentChunk(messageId: string, chunk: string) {
const blocks = assistantContentBlocksMap[messageId] const blocks = assistantContentBlocksMap[messageId]
const lastKind = assistantTimelineLastKindMap[messageId] const lastKind = assistantTimelineLastKindMap[messageId]
// 如果是从推理切换到正文,则结束并折叠推理块
if (lastKind === 'reasoning') {
finishCurrentReasoningBlock(messageId)
}
if (lastKind === 'content' && blocks.length > 0) { if (lastKind === 'content' && blocks.length > 0) {
blocks[blocks.length - 1]!.text += chunk blocks[blocks.length - 1]!.text += chunk
return return
@@ -568,6 +588,41 @@ function appendAssistantContentChunk(messageId: string, chunk: string) {
assistantTimelineLastKindMap[messageId] = 'content' 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 { function mapToolEventState(rawStatus?: string): ToolTraceState {
const normalized = `${rawStatus || ''}`.trim().toLowerCase() const normalized = `${rawStatus || ''}`.trim().toLowerCase()
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') { if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
@@ -993,22 +1048,21 @@ function markReasoningStart(message: AssistantMessage) {
reasoningStartedAtMap[message.id] = Date.now() reasoningStartedAtMap[message.id] = Date.now()
} }
function markReasoningFinished(message: AssistantMessage) { function markReasoningFinished(blockId: string, messageId: string) {
const startedAt = reasoningStartedAtMap[message.id] const startedAt = reasoningStartedAtMap[blockId]
if (startedAt && !reasoningDurationMap[message.id]) { if (startedAt && !reasoningDurationMap[blockId]) {
reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000)) reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
} }
thinkingMessageMap[messageId] = false
thinkingMessageMap[message.id] = false
} }
function getReasoningDurationSeconds(message: AssistantMessage) { function getReasoningDurationSeconds(blockId: string) {
const fixedDuration = reasoningDurationMap[message.id] const fixedDuration = reasoningDurationMap[blockId]
if (fixedDuration) { if (fixedDuration) {
return fixedDuration return fixedDuration
} }
const startedAt = reasoningStartedAtMap[message.id] const startedAt = reasoningStartedAtMap[blockId]
if (!startedAt) { if (!startedAt) {
return 0 return 0
} }
@@ -1016,13 +1070,28 @@ function getReasoningDurationSeconds(message: AssistantMessage) {
return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000)) return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000))
} }
function getReasoningStatusLabel(message: AssistantMessage) { function getReasoningStatusLabel(block: DisplayAssistantBlock) {
const durationSeconds = getReasoningDurationSeconds(message) const durationSeconds = getReasoningDurationSeconds(block.id)
if (durationSeconds > 0) { if (durationSeconds > 0) {
return `已思考(用时 ${durationSeconds} 秒)` 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) { function isReasoningCollapsed(messageId: string) {
@@ -1086,6 +1155,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
type: 'tool', type: 'tool',
seq: event.seq, seq: event.seq,
event, event,
sourceId: source.id,
source,
}) })
} }
@@ -1096,6 +1167,32 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
type: 'status', type: 'status',
seq: statusEvent.seq, seq: statusEvent.seq,
statusEvent, 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', type: 'content',
seq: contentBlock.seq, seq: contentBlock.seq,
text: contentBlock.text, text: contentBlock.text,
sourceId: source.id,
source,
}) })
} }
continue continue
@@ -1121,6 +1220,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
type: 'content', type: 'content',
seq: fallbackSeq, seq: fallbackSeq,
text: source.content, 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) { if (!hasContentBlock && dm.content) {
fallbackSeq += 1 fallbackSeq += 1
blocks.push({ blocks.push({
@@ -1180,38 +1271,16 @@ function getToolTraceStateLabel(state: ToolTraceState): string {
return '已完成' 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 { function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
return isDisplayStreaming(dm) && return isDisplayStreaming(dm) &&
dm.sources.every(m => thinkingMessageMap[m.id] !== true) && dm.sources.every(m => thinkingMessageMap[m.id] !== true) &&
!dm.content.trim() !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 { function getDisplayReasoningStatusLabel(dm: DisplayMessage): string {
const totalSeconds = dm.sources.reduce( // 此函数已废弃,推理状态现已下沉到各 source 块处理。
(sum, m) => sum + (reasoningDurationMap[m.id] ?? 0), 0, // 仅保留空实现以防意外调用。
) return '已思考'
if (totalSeconds > 0) return `已思考(用时 ${totalSeconds} 秒)`
const hasActiveThinking = dm.sources.some(
m => m.id === activeStreamingMessageId.value && thinkingMessageMap[m.id] === true,
)
return hasActiveThinking ? '思考中' : '已思考'
} }
function isMessageViewportAtBottom(viewport: HTMLElement) { function isMessageViewportAtBottom(viewport: HTMLElement) {
@@ -1576,7 +1645,8 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
if (reasoningChunk) { if (reasoningChunk) {
currentAssistantMessage.reasoning = oldReasoning + reasoningChunk currentAssistantMessage.reasoning = oldReasoning + reasoningChunk
// 记录推理块的 seq 环境 // 时序化存储推理内容
appendAssistantReasoningChunk(mid, reasoningChunk)
if (!assistantReasoningSeqMap[mid]) { if (!assistantReasoningSeqMap[mid]) {
assistantReasoningSeqMap[mid] = event.seq assistantReasoningSeqMap[mid] = event.seq
} }
@@ -1867,8 +1937,8 @@ async function submitConfirmRejectMessage() {
requestExtra: { requestExtra: {
resume: { resume: {
interaction_id: interactionId, interaction_id: interactionId,
type: 'ask_user', type: 'confirm',
action: 'reply' action: 'reject'
} }
} }
}) })
@@ -2107,10 +2177,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
if (payload === '[DONE]') { if (payload === '[DONE]') {
if (isThinkingMessage(assistantMessage)) { if (isThinkingMessage(assistantMessage)) {
markReasoningFinished(assistantMessage) finishCurrentReasoningBlock(assistantMessage.id)
} }
activeStreamingMessageId.value = '' activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
// 整个 SSE 流结束信号 // 整个 SSE 流结束信号
void loadConversationContextStats(selectedConversationId.value, true) void loadConversationContextStats(selectedConversationId.value, true)
return return
@@ -2150,27 +2219,23 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
if (!assistantReasoningSeqMap[assistantMessage.id]) { if (!assistantReasoningSeqMap[assistantMessage.id]) {
assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq() assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq()
} }
assistantTimelineLastKindMap[assistantMessage.id] = 'reasoning' appendAssistantReasoningChunk(assistantMessage.id, delta.reasoning_content)
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
} }
if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) { if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) {
appendAssistantContentChunk(assistantMessage.id, delta.content) appendAssistantContentChunk(assistantMessage.id, delta.content)
if (isThinkingMessage(assistantMessage)) { if (isThinkingMessage(assistantMessage)) {
// 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。 finishCurrentReasoningBlock(assistantMessage.id)
// 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
// 3. 若后端偶发交错发送 reasoning/content也以前端阶段机兜底优先保证阅读一致性。
markReasoningFinished(assistantMessage)
} }
assistantMessage.content += delta.content assistantMessage.content += delta.content
} }
if (finishReason) { if (finishReason) {
if (isThinkingMessage(assistantMessage)) { if (isThinkingMessage(assistantMessage)) {
markReasoningFinished(assistantMessage) finishCurrentReasoningBlock(assistantMessage.id)
} }
activeStreamingMessageId.value = '' activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
// 单条消息结束标志 // 单条消息结束标志
void loadConversationContextStats(selectedConversationId.value, true) void loadConversationContextStats(selectedConversationId.value, true)
} }
@@ -2681,18 +2746,18 @@ onBeforeUnmount(() => {
/> />
</svg> </svg>
</span> </span>
<span class="chat-message__reasoning-status">{{ getDisplayReasoningStatusLabel(dm) }}</span> <span class="chat-message__reasoning-status">{{ getReasoningStatusLabel(block) }}</span>
</div> </div>
<button <button
type="button" type="button"
class="chat-message__reasoning-toggle" class="chat-message__reasoning-toggle"
:aria-label="isDisplayReasoningCollapsed(dm) ? '展开深度思考' : '折叠深度思考'" :aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
@click="toggleDisplayReasoningCollapse(dm)" @click="toggleReasoningCollapse(block.id)"
> >
<span class="chat-message__reasoning-chevron"> <span class="chat-message__reasoning-chevron">
<svg <svg
class="chat-message__reasoning-chevron-icon" class="chat-message__reasoning-chevron-icon"
:class="{ 'chat-message__reasoning-chevron-icon--expanded': !isDisplayReasoningCollapsed(dm) }" :class="{ 'chat-message__reasoning-chevron-icon--expanded': !isReasoningCollapsed(block.id) }"
width="14" width="14"
height="14" height="14"
viewBox="0 0 14 14" viewBox="0 0 14 14"
@@ -2709,11 +2774,11 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
<div v-if="!isDisplayReasoningCollapsed(dm)" class="chat-message__reasoning-body"> <div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
<div <div
v-if="block.text" v-if="block.text"
class="chat-message__markdown chat-message__markdown--reasoning" class="chat-message__markdown chat-message__markdown--reasoning"
v-html="renderMessageMarkdown(block.text)" v-html="renderMessageMarkdown(block.text || '')"
/> />
<div v-else class="chat-message__streaming chat-message__streaming--reasoning"> <div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-indicator"> <div class="thinking-indicator">