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:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/api"
|
||||
@@ -197,6 +198,78 @@ func Start() {
|
||||
agentService.SetToolRegistry(newagenttools.NewDefaultRegistryWithDeps(newagenttools.DefaultRegistryDeps{
|
||||
RAGRuntime: ragRuntime,
|
||||
WebSearchProvider: webSearchProvider,
|
||||
TaskClassWriteDeps: newagenttools.TaskClassWriteDeps{
|
||||
UpsertTaskClass: func(userID int, input newagenttools.TaskClassUpsertInput) (newagenttools.TaskClassUpsertPersistResult, error) {
|
||||
req := input.Request
|
||||
taskClassID := 0
|
||||
created := input.ID == 0
|
||||
|
||||
err := taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error {
|
||||
// 1. 先构造任务类主体,保持与现有 AddOrUpdateTaskClass 口径一致。
|
||||
taskClass := &model.TaskClass{
|
||||
ID: input.ID,
|
||||
Name: &req.Name,
|
||||
Mode: &req.Mode,
|
||||
SubjectType: stringPtrOrNil(req.SubjectType),
|
||||
DifficultyLevel: stringPtrOrNil(req.DifficultyLevel),
|
||||
CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity),
|
||||
TotalSlots: &req.Config.TotalSlots,
|
||||
Strategy: &req.Config.Strategy,
|
||||
ExcludedSlots: req.Config.ExcludedSlots,
|
||||
ExcludedDaysOfWeek: req.Config.ExcludedDaysOfWeek,
|
||||
}
|
||||
taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse
|
||||
|
||||
// 2. 自动模式下写入日期范围;手动模式允许为空。
|
||||
if req.StartDate != "" {
|
||||
startDate, parseErr := time.ParseInLocation("2006-01-02", req.StartDate, time.Local)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
taskClass.StartDate = &startDate
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
endDate, parseErr := time.ParseInLocation("2006-01-02", req.EndDate, time.Local)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
taskClass.EndDate = &endDate
|
||||
}
|
||||
|
||||
// 3. upsert 主体后拿到稳定 task_class_id,供 items 绑定 category_id。
|
||||
updatedID, upsertErr := txDAO.AddOrUpdateTaskClass(userID, taskClass)
|
||||
if upsertErr != nil {
|
||||
return upsertErr
|
||||
}
|
||||
taskClassID = updatedID
|
||||
|
||||
// 4. 构造任务块并批量 upsert。
|
||||
items := make([]model.TaskClassItem, 0, len(req.Items))
|
||||
for _, itemReq := range req.Items {
|
||||
categoryID := taskClassID
|
||||
order := itemReq.Order
|
||||
content := itemReq.Content
|
||||
status := model.TaskItemStatusUnscheduled
|
||||
items = append(items, model.TaskClassItem{
|
||||
ID: itemReq.ID,
|
||||
CategoryID: &categoryID,
|
||||
Order: &order,
|
||||
Content: &content,
|
||||
EmbeddedTime: itemReq.EmbeddedTime,
|
||||
Status: &status,
|
||||
})
|
||||
}
|
||||
return txDAO.AddOrUpdateTaskClassItems(userID, items)
|
||||
})
|
||||
if err != nil {
|
||||
return newagenttools.TaskClassUpsertPersistResult{}, err
|
||||
}
|
||||
return newagenttools.TaskClassUpsertPersistResult{
|
||||
TaskClassID: taskClassID,
|
||||
Created: created,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}))
|
||||
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
|
||||
agentService.SetCompactionStore(agentRepo)
|
||||
@@ -271,3 +344,11 @@ func Start() {
|
||||
r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter)
|
||||
routers.StartEngine(r)
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
@@ -32,11 +32,14 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
|
||||
}
|
||||
//1.填充section1,2
|
||||
taskClass := model.TaskClass{
|
||||
Name: &req.Name,
|
||||
Mode: &req.Mode,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
UserID: &userID,
|
||||
Name: &req.Name,
|
||||
Mode: &req.Mode,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
SubjectType: stringPtrOrNil(req.SubjectType),
|
||||
DifficultyLevel: stringPtrOrNil(req.DifficultyLevel),
|
||||
CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity),
|
||||
UserID: &userID,
|
||||
}
|
||||
//2.填充section3
|
||||
taskClass.TotalSlots = &req.Config.TotalSlots
|
||||
@@ -59,6 +62,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
|
||||
taskClass.ExcludedSlots = &emptyJSON
|
||||
}*/
|
||||
taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
|
||||
taskClass.ExcludedDaysOfWeek = req.Config.ExcludedDaysOfWeek
|
||||
//3.开始构建 items
|
||||
var items []model.TaskClassItem
|
||||
for _, itemReq := range req.Items {
|
||||
@@ -84,13 +88,16 @@ func TaskClassModelToResponse(taskClasses []model.TaskClass) *model.UserGetTaskC
|
||||
var resp model.UserGetTaskClassesResponse
|
||||
for _, tc := range taskClasses {
|
||||
tcResp := model.TaskClassSummary{
|
||||
ID: tc.ID,
|
||||
Name: *tc.Name,
|
||||
Mode: *tc.Mode,
|
||||
StartDate: timeOrZero(tc.StartDate),
|
||||
EndDate: timeOrZero(tc.EndDate),
|
||||
TotalSlots: *tc.TotalSlots,
|
||||
Strategy: *tc.Strategy,
|
||||
ID: tc.ID,
|
||||
Name: *tc.Name,
|
||||
Mode: *tc.Mode,
|
||||
StartDate: timeOrZero(tc.StartDate),
|
||||
EndDate: timeOrZero(tc.EndDate),
|
||||
TotalSlots: *tc.TotalSlots,
|
||||
Strategy: *tc.Strategy,
|
||||
SubjectType: safeStr(tc.SubjectType),
|
||||
DifficultyLevel: safeStr(tc.DifficultyLevel),
|
||||
CognitiveIntensity: safeStr(tc.CognitiveIntensity),
|
||||
}
|
||||
resp.TaskClasses = append(resp.TaskClasses, tcResp)
|
||||
}
|
||||
@@ -103,10 +110,13 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
|
||||
}
|
||||
// 1. 映射基础信息 (处理指针解引用)
|
||||
req := &model.UserAddTaskClassRequest{
|
||||
Name: safeStr(taskClass.Name),
|
||||
Mode: safeStr(taskClass.Mode),
|
||||
StartDate: formatTime(taskClass.StartDate),
|
||||
EndDate: formatTime(taskClass.EndDate),
|
||||
Name: safeStr(taskClass.Name),
|
||||
Mode: safeStr(taskClass.Mode),
|
||||
StartDate: formatTime(taskClass.StartDate),
|
||||
EndDate: formatTime(taskClass.EndDate),
|
||||
SubjectType: safeStr(taskClass.SubjectType),
|
||||
DifficultyLevel: safeStr(taskClass.DifficultyLevel),
|
||||
CognitiveIntensity: safeStr(taskClass.CognitiveIntensity),
|
||||
}
|
||||
// 2. 映射配置信息 (Config Section)
|
||||
req.Config = model.UserAddTaskClassConfig{
|
||||
@@ -123,6 +133,7 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
|
||||
}
|
||||
}*/
|
||||
req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
|
||||
req.Config.ExcludedDaysOfWeek = taskClass.ExcludedDaysOfWeek
|
||||
// 4. 映射子项信息 (Items Section)
|
||||
// 此时 items 已经通过 Preload 加载到了 taskClass.Items 中
|
||||
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))
|
||||
@@ -184,6 +195,13 @@ func safeInt(i *int) int {
|
||||
return *i
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func safeBool(b *bool) bool {
|
||||
if b == nil {
|
||||
return true
|
||||
|
||||
@@ -337,6 +337,17 @@ func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid
|
||||
}
|
||||
}
|
||||
}
|
||||
// 标记整天屏蔽:
|
||||
// 1. excluded_days_of_week 表示“这些星期几整天都不允许粗排”;
|
||||
// 2. 与 excluded_slots 一样属于硬约束,因此直接写入 Blocked;
|
||||
// 3. 一旦工作日容量不足,粗排应直接失败,而不是偷偷排到被排除的星期里。
|
||||
for _, blockedDay := range taskClass.ExcludedDaysOfWeek {
|
||||
for w := startW; w <= endW; w++ {
|
||||
for s := 1; s <= 12; s++ {
|
||||
g.setNode(w, blockedDay, s, slotNode{Status: Blocked})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 映射日程 (尊重 Blocked 且只处理范围内的数据)
|
||||
for _, s := range schedules {
|
||||
@@ -450,6 +461,146 @@ type planningSlotCandidate struct {
|
||||
sectionTo int
|
||||
}
|
||||
|
||||
// countDayAvailable 统计某一天当前还可用于粗排的节次数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只把 Free/Filler 视为“仍可消费”的资源;
|
||||
// 2. 不区分其来源是纯空位还是可嵌入课程,因为对粗排而言二者都代表后续还能放任务;
|
||||
// 3. 仅用于候选打分,不直接参与最终合法性判断。
|
||||
func (g *grid) countDayAvailable(week, day int) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for section := 1; section <= 12; section++ {
|
||||
node := g.getNode(week, day, section)
|
||||
if node.Status == Free || node.Status == Filler {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// countDayOccupied 统计某一天当前已被 existing/virtual/task 占住的节次数。
|
||||
func (g *grid) countDayOccupied(week, day int) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for section := 1; section <= 12; section++ {
|
||||
if g.getNode(week, day, section).Status == Occupied {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// collectPlanningCandidatesFromCursor 收集从给定游标开始仍然合法的候选落位。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这里复用现有 findNextCandidateFromCursor 的合法性规则,避免复制一套“什么叫合法双节”的判断;
|
||||
// 2. 通过跳过已命中候选的跨度,减少同一课程块被重复返回;
|
||||
// 3. 保留快照上的 coordIndex,供 steady 策略计算“距离目标位置有多远”。
|
||||
func (g *grid) collectPlanningCandidatesFromCursor(coords []slotCoord, startCursor int) []planningSlotCandidate {
|
||||
if g == nil || startCursor >= len(coords) {
|
||||
return nil
|
||||
}
|
||||
candidates := make([]planningSlotCandidate, 0, 16)
|
||||
seen := make(map[string]struct{})
|
||||
for cursor := startCursor; cursor < len(coords); {
|
||||
candidate, found := g.findNextCandidateFromCursor(coords, cursor)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", candidate.week, candidate.dayOfWeek, candidate.sectionFrom, candidate.sectionTo)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = struct{}{}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
nextCursor := candidate.coordIndex + (candidate.sectionTo - candidate.sectionFrom + 1)
|
||||
if nextCursor <= cursor {
|
||||
nextCursor = cursor + 1
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func computeSteadyTargetCursor(totalAvailable, totalItems, itemIndex int) int {
|
||||
if totalAvailable <= 1 || totalItems <= 1 {
|
||||
return 0
|
||||
}
|
||||
target := ((itemIndex + 1) * totalAvailable) / (totalItems + 1)
|
||||
if target < 0 {
|
||||
return 0
|
||||
}
|
||||
if target >= totalAvailable {
|
||||
return totalAvailable - 1
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func planningDayOrdinal(week, day int) int {
|
||||
return week*7 + day
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// chooseSteadyCandidate 为 steady 策略挑选“更均衡、更分散、更留余地”的候选位。
|
||||
//
|
||||
// 评分原则:
|
||||
// 1. 先尽量接近本任务在窗口中的目标分布位置;
|
||||
// 2. 再偏好当前已占用更少的天,避免单日继续堆高;
|
||||
// 3. 再惩罚与同任务类既有落位过近或同日重复,降低同科过度集中;
|
||||
// 4. 最后惩罚吃掉当天最后一小段缓冲,给后续调整保留容错空间。
|
||||
func (g *grid) chooseSteadyCandidate(
|
||||
coords []slotCoord,
|
||||
targetCursor int,
|
||||
placedDayOrdinals []int,
|
||||
) (planningSlotCandidate, bool) {
|
||||
candidates := g.collectPlanningCandidatesFromCursor(coords, 0)
|
||||
if len(candidates) == 0 {
|
||||
return planningSlotCandidate{}, false
|
||||
}
|
||||
|
||||
best := candidates[0]
|
||||
bestScore := int(^uint(0) >> 1)
|
||||
for _, candidate := range candidates {
|
||||
slotSpan := candidate.sectionTo - candidate.sectionFrom + 1
|
||||
distancePenalty := absInt(candidate.coordIndex-targetCursor) * 10
|
||||
dayOccupiedPenalty := g.countDayOccupied(candidate.week, candidate.dayOfWeek) * 25
|
||||
remainingAvailable := g.countDayAvailable(candidate.week, candidate.dayOfWeek) - slotSpan
|
||||
bufferPenalty := 0
|
||||
if remainingAvailable < 2 {
|
||||
bufferPenalty = 80
|
||||
}
|
||||
|
||||
dayOrdinal := planningDayOrdinal(candidate.week, candidate.dayOfWeek)
|
||||
rhythmPenalty := 0
|
||||
for _, placed := range placedDayOrdinals {
|
||||
diff := absInt(dayOrdinal - placed)
|
||||
switch {
|
||||
case diff == 0:
|
||||
rhythmPenalty += 180
|
||||
case diff == 1:
|
||||
rhythmPenalty += 60
|
||||
}
|
||||
}
|
||||
|
||||
score := distancePenalty + dayOccupiedPenalty + bufferPenalty + rhythmPenalty + candidate.coordIndex
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
// getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。
|
||||
//
|
||||
// 设计说明:
|
||||
@@ -604,8 +755,8 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
|
||||
}
|
||||
|
||||
// 2. 计算间隔策略:
|
||||
// 2.1 rapid:gap=0,尽快塞满;
|
||||
// 2.2 steady:按剩余可用位均匀留白。
|
||||
// 2.1 rapid:沿用“尽快塞满”的线性前进;
|
||||
// 2.2 steady:不再只靠 gap 跳格子,而是结合目标位置、单日负载、同科分散和缓冲保留做候选打分。
|
||||
gap := 0
|
||||
if strategy == "steady" {
|
||||
gap = (totalAvailable - totalRequired) / (len(items) + 1)
|
||||
@@ -617,16 +768,22 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
|
||||
// 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。
|
||||
cursor := gap
|
||||
lastPlacedIndex := -1
|
||||
placedDayOrdinals := make([]int, 0, len(items))
|
||||
|
||||
for i := range items {
|
||||
if cursor >= totalAvailable {
|
||||
break
|
||||
var (
|
||||
candidate planningSlotCandidate
|
||||
found bool
|
||||
)
|
||||
if strategy == "steady" {
|
||||
targetCursor := computeSteadyTargetCursor(totalAvailable, len(items), i)
|
||||
candidate, found = g.chooseSteadyCandidate(coords, targetCursor, placedDayOrdinals)
|
||||
} else {
|
||||
if cursor >= totalAvailable {
|
||||
break
|
||||
}
|
||||
candidate, found = g.findNextCandidateFromCursor(coords, cursor)
|
||||
}
|
||||
|
||||
// 4. 先找候选,不立即写入:
|
||||
// 4.1 找不到候选时提前结束;
|
||||
// 4.2 最终统一通过 lastPlacedIndex 判断是否完整排完。
|
||||
candidate, found := g.findNextCandidateFromCursor(coords, cursor)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
@@ -648,7 +805,10 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([
|
||||
|
||||
// 7. 推进游标并记录成功位置。
|
||||
slotLen := candidate.sectionTo - candidate.sectionFrom + 1
|
||||
cursor = candidate.coordIndex + slotLen + gap
|
||||
if strategy != "steady" {
|
||||
cursor = candidate.coordIndex + slotLen + gap
|
||||
}
|
||||
placedDayOrdinals = append(placedDayOrdinals, planningDayOrdinal(candidate.week, candidate.dayOfWeek))
|
||||
lastPlacedIndex = i
|
||||
}
|
||||
|
||||
|
||||
@@ -13,16 +13,20 @@ type TaskClass struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
UserID *int `gorm:"column:user_id;index:idx_task_classes_user_id"`
|
||||
//section 2
|
||||
Name *string `gorm:"column:name;size:255"`
|
||||
Mode *string `gorm:"column:mode;type:enum('auto','manual')"`
|
||||
StartDate *time.Time `gorm:"column:start_date"`
|
||||
EndDate *time.Time `gorm:"column:end_date"`
|
||||
Name *string `gorm:"column:name;size:255"`
|
||||
Mode *string `gorm:"column:mode;type:enum('auto','manual')"`
|
||||
StartDate *time.Time `gorm:"column:start_date"`
|
||||
EndDate *time.Time `gorm:"column:end_date"`
|
||||
SubjectType *string `gorm:"column:subject_type;size:32;comment:学科类型 quantitative|memory|reading|mixed"`
|
||||
DifficultyLevel *string `gorm:"column:difficulty_level;size:16;comment:难度等级 low|medium|high"`
|
||||
CognitiveIntensity *string `gorm:"column:cognitive_intensity;size:16;comment:认知强度 low|medium|high"`
|
||||
//section 3
|
||||
TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"`
|
||||
AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"`
|
||||
Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"`
|
||||
ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"`
|
||||
Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem
|
||||
TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"`
|
||||
AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"`
|
||||
Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"`
|
||||
ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"`
|
||||
ExcludedDaysOfWeek IntSlice `gorm:"column:excluded_days_of_week;type:json;comment:不想要的星期几切片(1-7)"`
|
||||
Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem
|
||||
}
|
||||
|
||||
// IntSlice 用于把 []int 以 JSON 形式存入/读出数据库 json 字段
|
||||
@@ -74,20 +78,24 @@ type TaskClassItem struct {
|
||||
|
||||
// UserAddTaskClassRequest 用于处理用户添加任务类别的请求
|
||||
type UserAddTaskClassRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD
|
||||
EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD
|
||||
Mode string `json:"mode" binding:"required,oneof=auto manual"`
|
||||
Config UserAddTaskClassConfig `json:"config" binding:"required"`
|
||||
Items []UserAddTaskClassItemRequest `json:"items" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD
|
||||
EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD
|
||||
Mode string `json:"mode" binding:"required,oneof=auto manual"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
DifficultyLevel string `json:"difficulty_level,omitempty"`
|
||||
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
|
||||
Config UserAddTaskClassConfig `json:"config" binding:"required"`
|
||||
Items []UserAddTaskClassItemRequest `json:"items" binding:"required"`
|
||||
}
|
||||
|
||||
// UserAddTaskClassConfig 用于处理用户添加任务类别时的配置部分
|
||||
type UserAddTaskClassConfig struct {
|
||||
TotalSlots int `json:"total_slots" binding:"required,min=1"`
|
||||
AllowFillerCourse bool `json:"allow_filler_course"`
|
||||
Strategy string `json:"strategy" binding:"required,oneof=steady rapid"`
|
||||
ExcludedSlots []int `json:"excluded_slots"`
|
||||
TotalSlots int `json:"total_slots" binding:"required,min=1"`
|
||||
AllowFillerCourse bool `json:"allow_filler_course"`
|
||||
Strategy string `json:"strategy" binding:"required,oneof=steady rapid"`
|
||||
ExcludedSlots []int `json:"excluded_slots"`
|
||||
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"`
|
||||
}
|
||||
|
||||
// UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分
|
||||
@@ -113,13 +121,16 @@ type UserGetTaskClassesResponse struct {
|
||||
|
||||
// TaskClassSummary 提供任务类别的简要信息
|
||||
type TaskClassSummary struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
TotalSlots int `json:"total_slots"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
TotalSlots int `json:"total_slots"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
DifficultyLevel string `json:"difficulty_level,omitempty"`
|
||||
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
|
||||
}
|
||||
|
||||
type UserInsertTaskClassItemToScheduleRequest struct {
|
||||
|
||||
@@ -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. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要假设任务还没排进去,然后改成逐个手动 place。
|
||||
2. 不要伪造工具结果。
|
||||
3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
|
||||
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
|
||||
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
|
||||
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
|
||||
9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
||||
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
||||
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||
12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。
|
||||
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。
|
||||
5. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
6. 任务完成:action=done,并在 goal_check 总结完成证据。
|
||||
7. 流程应正式终止:action=abort。
|
||||
|
||||
补充 JSON 约束:
|
||||
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
|
||||
2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。
|
||||
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
|
||||
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
|
||||
5. action=continue / ask_user / confirm 时,speak 必须是非空自然语言。
|
||||
|
||||
可用工具(简表):
|
||||
1. batch_move:原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。
|
||||
参数:moves(必填,array)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:批量移动完成,2个任务全部成功。(单次最多2条)
|
||||
2. get_overview:获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。
|
||||
参数:{}
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)...
|
||||
3. get_task_info:查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。
|
||||
参数:task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节
|
||||
4. min_context_switch:在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。
|
||||
参数:task_id(可选,int);task_ids(必填,array)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
|
||||
5. move:将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。
|
||||
参数:new_day(必填,int);new_slot_start(必填,int);task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。
|
||||
6. place:将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。
|
||||
参数:day(必填,int);slot_start(必填,int);task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 预排到第5天第3-4节。
|
||||
7. query_available_slots:查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。
|
||||
参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);duration(可选,int);exclude_sections(可选,array);limit(可选,int);section_from(可选,int);section_to(可选,int);slot_type(可选,string);slot_types(可选,array);span(可选,int);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}
|
||||
8. query_range:查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。
|
||||
参数:day(必填,int);slot_end(可选,int);slot_start(可选,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:第5天第3-6节:第3节空、第4节空...
|
||||
9. query_target_tasks:查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。
|
||||
参数:category(可选,string);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);enqueue(可选,bool);limit(可选,int);reset_queue(可选,bool);status(可选,string:all/existing/suggested/pending);task_id(可选,int);task_ids(可选,array);task_item_id(可选,int);task_item_ids(可选,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}
|
||||
10. queue_apply_head_move:将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。
|
||||
参数:new_day(必填,int);new_slot_start(必填,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
|
||||
11. queue_pop_head:弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
|
||||
参数:{}
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
|
||||
12. queue_skip_head:跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。
|
||||
参数:reason(可选,string)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
|
||||
13. queue_status:查看当前待处理队列状态(pending/current/completed/skipped)。
|
||||
参数:{}
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
|
||||
14. quick_note_create:记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间(如“明天下午3点”、“下周一”)。title 必填。记录成功后,回复时应包含一句与任务内容相关的轻松跟进话术(不超过30字),类似朋友间的友好调侃。
|
||||
参数:deadline_at(可选,string);priority_group(可选,int);title(必填,string)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:自然语言结果(成功/失败原因/关键数据摘要)。
|
||||
15. spread_even:在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。
|
||||
参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);exclude_sections(可选,array);limit(可选,int);slot_type(可选,string);slot_types(可选,array);task_id(可选,int);task_ids(必填,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
|
||||
16. swap:交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。
|
||||
参数:task_a(必填,int);task_b(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:交换完成:[35]... ↔ [36]...
|
||||
17. unplace:将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。
|
||||
参数:task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 移除,恢复为待安排状态。
|
||||
18. web_fetch:抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。
|
||||
参数:max_chars(可选,int);url(必填,string)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
|
||||
19. web_search:Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。
|
||||
参数:domain_allow(可选,array);query(必填,string);recency_days(可选,int);top_k(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}
|
||||
|
||||
----- message[1] -----
|
||||
role: assistant
|
||||
content:
|
||||
历史上下文:
|
||||
对话历史:
|
||||
user: "提醒我明天中午吃乡村基"
|
||||
- 阶段锚点:按当前工具事实推进,不做无依据操作。
|
||||
|
||||
----- message[2] -----
|
||||
role: assistant
|
||||
content:
|
||||
当轮 ReAct Loop 记录:
|
||||
- 已清空(新一轮 loop 准备中)。
|
||||
|
||||
----- message[3] -----
|
||||
role: system
|
||||
content:
|
||||
当前执行状态:
|
||||
- 当前轮次:1/60
|
||||
- 当前模式:自由执行(无预定义步骤)
|
||||
- 啥时候结束Loop:你可以根据工具调用记录自行判断。
|
||||
- 非目标:不重新粗排、不修改无关任务类。
|
||||
- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。
|
||||
- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。
|
||||
相关记忆(仅在确有帮助时参考,不要机械复述):
|
||||
以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。
|
||||
- [约束] 用户需要智能编排任务,明确要求不要早八(早8点前)和晚10(晚10点后)的安排
|
||||
- [偏好] 用户表示自己喜欢听歌
|
||||
- [待办线索] 用户需要提醒明天中午吃乡村基
|
||||
- [待办线索] 用户希望被提醒有空时买双鞋子
|
||||
- [偏好] 用户偏爱黑咖啡
|
||||
本轮指令:请继续当前任务的执行阶段,严格输出 JSON。
|
||||
|
||||
|
||||
[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1
|
||||
2026/04/18 10:04:15 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR filter_count=4 latency_ms=0 status=failed store=milvus top_k=5 vector_dim=1024
|
||||
2026/04/18 10:04:15 rag level=error component=runtime operation=retrieve action=search corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR latency_ms=115 query_len=54 status=failed threshold=0.6 top_k=5
|
||||
2026/04/18 10:04:15 [WARN][去重] Milvus 语义召回失败,降级到 MySQL: user_id=1 memory_type=todo_hint topk=5 err=unsupported milvus filter key: status
|
||||
2026/04/18 10:04:15 [DEBUG][去重] 语义召回候选: job_id=65 user_id=1 memory_type=todo_hint candidate_count=2
|
||||
2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=30 score=0.0000 content="用户需要提醒明天中午吃乡村基"
|
||||
2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=29 score=0.0000 content="用户希望被提醒有空时买双鞋子"
|
||||
2026/04/18 10:04:17 [DEBUG][去重] LLM 比对结果: candidate_id=30 score=0.0000 relation=duplicate reason="新事实与旧记忆内容完全一致,都是提醒明天中午吃乡村基" candidate_content="用户需要提醒明天中午吃乡村基"
|
||||
2026/04/18 10:04:17 [DEBUG] execute LLM 响应 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 action=continue speak_len=57 raw_len=301 raw_preview=```json
|
||||
{
|
||||
"action": "continue",
|
||||
"speak": "好的,帮你记下明天中午吃乡村基的提醒。",
|
||||
"tool_call": {
|
||||
"name": "quick_note_create",
|
||||
"arguments": {
|
||||
"title": "明天中午吃乡村基",
|
||||
"deadline_at": "明天中午12点",
|
||||
|
||||
2026/04/18 10:04:18 [DEBUG] execute tool chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 tool=quick_note_create args={"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"} before=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 after=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 result_preview={"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。"}
|
||||
2026/04/18 10:04:18 [GORM-Cache] Invalidated task list cache for user 1
|
||||
2026/04/18 10:04:18 [COMPACT:execute] token budget check: total=4224 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=3694 msg1=66 msg2=162 msg3=302)
|
||||
2026/04/18 10:04:18 [DEBUG] execute LLM context begin phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2 message_count=4
|
||||
----- message[0] -----
|
||||
role: system
|
||||
content:
|
||||
你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
|
||||
如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
|
||||
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。
|
||||
|
||||
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
|
||||
|
||||
阶段事实(强约束):
|
||||
1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing,不是待排入状态。
|
||||
2. 当前阶段目标是"微调",不是"重新粗排"。
|
||||
3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。
|
||||
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
|
||||
|
||||
你可以做什么:
|
||||
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。
|
||||
4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要假设任务还没排进去,然后改成逐个手动 place。
|
||||
2. 不要伪造工具结果。
|
||||
3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
|
||||
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
|
||||
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
|
||||
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
|
||||
9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
||||
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
||||
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||
12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。
|
||||
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。
|
||||
5. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
6. 任务完成:action=done,并在 goal_check 总结完成证据。
|
||||
7. 流程应正式终止:action=abort。
|
||||
|
||||
补充 JSON 约束:
|
||||
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
|
||||
2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。
|
||||
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
|
||||
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
|
||||
5. action=continue / ask_user / confirm 时,speak 必须是非空自然语言。
|
||||
|
||||
可用工具(简表):
|
||||
1. batch_move:原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。
|
||||
参数:moves(必填,array)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:批量移动完成,2个任务全部成功。(单次最多2条)
|
||||
2. get_overview:获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。
|
||||
参数:{}
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)...
|
||||
3. get_task_info:查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。
|
||||
参数:task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节
|
||||
4. min_context_switch:在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。
|
||||
参数:task_id(可选,int);task_ids(必填,array)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
|
||||
5. move:将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。
|
||||
参数:new_day(必填,int);new_slot_start(必填,int);task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。
|
||||
6. place:将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。
|
||||
参数:day(必填,int);slot_start(必填,int);task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 预排到第5天第3-4节。
|
||||
7. query_available_slots:查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。
|
||||
参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);duration(可选,int);exclude_sections(可选,array);limit(可选,int);section_from(可选,int);section_to(可选,int);slot_type(可选,string);slot_types(可选,array);span(可选,int);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}
|
||||
8. query_range:查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。
|
||||
参数:day(必填,int);slot_end(可选,int);slot_start(可选,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:第5天第3-6节:第3节空、第4节空...
|
||||
9. query_target_tasks:查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。
|
||||
参数:category(可选,string);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);enqueue(可选,bool);limit(可选,int);reset_queue(可选,bool);status(可选,string:all/existing/suggested/pending);task_id(可选,int);task_ids(可选,array);task_item_id(可选,int);task_item_ids(可选,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}
|
||||
10. queue_apply_head_move:将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。
|
||||
参数:new_day(必填,int);new_slot_start(必填,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
|
||||
11. queue_pop_head:弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
|
||||
参数:{}
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
|
||||
12. queue_skip_head:跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。
|
||||
参数:reason(可选,string)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
|
||||
13. queue_status:查看当前待处理队列状态(pending/current/completed/skipped)。
|
||||
参数:{}
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
|
||||
14. quick_note_create:记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间(如“明天下午3点”、“下周一”)。title 必填。记录成功后,回复时应包含一句与任务内容相关的轻松跟进话术(不超过30字),类似朋友间的友好调侃。
|
||||
参数:deadline_at(可选,string);priority_group(可选,int);title(必填,string)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:自然语言结果(成功/失败原因/关键数据摘要)。
|
||||
15. spread_even:在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。
|
||||
参数:after_section(可选,int);allow_embed(可选,bool);before_section(可选,int);day(可选,int);day_end(可选,int);day_of_week(可选,array);day_scope(可选,string:all/workday/weekend);day_start(可选,int);exclude_sections(可选,array);limit(可选,int);slot_type(可选,string);slot_types(可选,array);task_id(可选,int);task_ids(必填,array);week(可选,int);week_filter(可选,array);week_from(可选,int);week_to(可选,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
|
||||
16. swap:交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。
|
||||
参数:task_a(必填,int);task_b(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:交换完成:[35]... ↔ [36]...
|
||||
17. unplace:将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。
|
||||
参数:task_id(必填,int)
|
||||
返回类型:string(自然语言文本)
|
||||
返回示例:已将 [35]... 移除,恢复为待安排状态。
|
||||
18. web_fetch:抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。
|
||||
参数:max_chars(可选,int);url(必填,string)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
|
||||
19. web_search:Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。
|
||||
参数:domain_allow(可选,array);query(必填,string);recency_days(可选,int);top_k(可选,int)
|
||||
返回类型:string(JSON字符串)
|
||||
返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}
|
||||
|
||||
----- message[1] -----
|
||||
role: assistant
|
||||
content:
|
||||
历史上下文:
|
||||
对话历史:
|
||||
user: "提醒我明天中午吃乡村基"
|
||||
assistant: "好的,帮你记下明天中午吃乡村基的提醒。"
|
||||
- 阶段锚点:按当前工具事实推进,不做无依据操作。
|
||||
|
||||
----- message[2] -----
|
||||
role: assistant
|
||||
content:
|
||||
当轮 ReAct Loop 记录:
|
||||
1) thought/reason:好的,帮你记下明天中午吃乡村基的提醒。
|
||||
tool_call:quick_note_create({"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"})
|
||||
observation:{"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。"}
|
||||
|
||||
----- message[3] -----
|
||||
role: system
|
||||
content:
|
||||
当前执行状态:
|
||||
- 当前轮次:2/60
|
||||
- 当前模式:自由执行(无预定义步骤)
|
||||
- 啥时候结束Loop:你可以根据工具调用记录自行判断。
|
||||
- 非目标:不重新粗排、不修改无关任务类。
|
||||
- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。
|
||||
- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。
|
||||
相关记忆(仅在确有帮助时参考,不要机械复述):
|
||||
以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。
|
||||
- [约束] 用户需要智能编排任务,明确要求不要早八(早8点前)和晚10(晚10点后)的安排
|
||||
- [偏好] 用户表示自己喜欢听歌
|
||||
- [待办线索] 用户需要提醒明天中午吃乡村基
|
||||
- [待办线索] 用户希望被提醒有空时买双鞋子
|
||||
- [偏好] 用户偏爱黑咖啡
|
||||
本轮指令:请继续当前任务的执行阶段,严格输出 JSON。
|
||||
|
||||
|
||||
[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2
|
||||
2026/04/18 10:04:18 outbox due messages=1, start dispatch
|
||||
2026/04/18 10:04:19 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a
|
||||
2026/04/18 10:04:19 [DEBUG][去重] LLM 比对结果: candidate_id=29 score=0.0000 relation=unrelated reason="新事实是关于明天中午吃饭的提醒,旧记忆是关于买鞋子的提醒,两者属于不同待办事项" candidate_content="用户希望被提醒有空时买双鞋子"
|
||||
2026/04/18 10:04:19 [DEBUG][去重] 汇总决策: job_id=65 action=NONE target_id=0 reason="存在完全重复的旧记忆,跳过写入"
|
||||
2026/04/18 10:04:19 memory level=info component=write operation=decision candidate_count=2 conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a fact_type=todo_hint fallback_mode=rag_to_mysql final_action=NONE job_id=65 success=true user_id=1
|
||||
2026/04/18 10:04:19 [去重] 决策流程完成: job_id=65 user_id=1 新增=0 更新=0 删除=0 跳过=1
|
||||
2026/04/18 10:04:19 memory level=info component=write operation=job conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a job_id=65 status=success success=true user_id=1
|
||||
[GIN] 2026/04/18 - 10:04:25 | 200 | 19.2626896s | 127.0.0.1 | POST "/api/v1/agent/chat"
|
||||
2026/04/18 10:04:25 [ERROR] newAgent graph 执行失败 trace=a35fc40f-261f-4d51-93cd-783f256b9902 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a: [NodeRunError] 执行阶段模型调用失败: failed to create chat completion: context canceled
|
||||
------------------------
|
||||
node path: [execute]
|
||||
2026/04/18 10:04:25 错误通道已满,丢弃错误: context canceled
|
||||
[GIN] 2026/04/18 - 10:04:25 | 200 | 50.3305ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
|
||||
[GIN] 2026/04/18 - 10:04:26 | 200 | 49.4404ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"
|
||||
[GIN] 2026/04/18 - 10:04:26 | 200 | 1.9621ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"
|
||||
[GIN] 2026/04/18 - 10:04:26 | 200 | 28.7049ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"
|
||||
@@ -46,7 +46,8 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
|
||||
// 2. 全量读场景保留“当前周兜底”,兼容“只看本周课表/微调”类请求。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
|
||||
}
|
||||
|
||||
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
|
||||
@@ -69,7 +70,9 @@ func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
|
||||
// 1. 粗排/主动编排场景必须严格按任务类时间窗加载;
|
||||
// 2. 若任务类缺少起止日期,则返回错误,交给上层 ask_user 补齐,而不是静默退回当前周。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
|
||||
}
|
||||
|
||||
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
|
||||
@@ -82,10 +85,14 @@ func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClasses []model.TaskClass,
|
||||
allowCurrentWeekFallback bool,
|
||||
) (*schedule.ScheduleState, error) {
|
||||
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
|
||||
windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
|
||||
if len(windowDays) == 0 {
|
||||
if !allowCurrentWeekFallback {
|
||||
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
|
||||
}
|
||||
var err error
|
||||
windowDays, weeks, err = buildCurrentWeekWindow()
|
||||
if err != nil {
|
||||
@@ -262,12 +269,24 @@ func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, t
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return metas, nil
|
||||
|
||||
@@ -49,6 +49,7 @@ func LoadScheduleState(
|
||||
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||||
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||||
itemCategoryLookup := make(map[int]string)
|
||||
itemOrderLookup := buildTaskItemOrderLookup(taskClasses)
|
||||
for id, name := range extraItemCategories {
|
||||
itemCategoryLookup[id] = name
|
||||
}
|
||||
@@ -222,6 +223,7 @@ func LoadScheduleState(
|
||||
Slots: hostSlots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
@@ -240,6 +242,7 @@ func LoadScheduleState(
|
||||
Slots: slots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
@@ -261,6 +264,7 @@ func LoadScheduleState(
|
||||
Duration: defaultDuration,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
@@ -285,12 +289,24 @@ func LoadScheduleState(
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
state.TaskClasses = append(state.TaskClasses, meta)
|
||||
}
|
||||
}
|
||||
@@ -343,6 +359,7 @@ func LoadScheduleState(
|
||||
Slots: hostSlots,
|
||||
CategoryID: categoryID,
|
||||
TaskClassID: taskClassID,
|
||||
TaskOrder: itemOrderLookup[itemID],
|
||||
})
|
||||
itemStateIDs[itemID] = guestStateID
|
||||
nextStateID++
|
||||
@@ -385,6 +402,26 @@ func isTaskItemPending(item model.TaskClassItem) bool {
|
||||
return *item.Status == model.TaskItemStatusUnscheduled
|
||||
}
|
||||
|
||||
// buildTaskItemOrderLookup 为每个 task_item 构建稳定顺序号。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先使用数据库里的 item.Order,保持用户或上游生成的显式顺序;
|
||||
// 2. 若历史数据缺少 order,则退回 TaskClass.Items 当前顺序,保证写工具层仍有稳定边界;
|
||||
// 3. 只负责构建运行态映射,不回写数据库。
|
||||
func buildTaskItemOrderLookup(taskClasses []model.TaskClass) map[int]int {
|
||||
lookup := make(map[int]int)
|
||||
for _, tc := range taskClasses {
|
||||
for idx, item := range tc.Items {
|
||||
order := idx + 1
|
||||
if item.Order != nil && *item.Order > 0 {
|
||||
order = *item.Order
|
||||
}
|
||||
lookup[item.ID] = order
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
// estimateTaskItemDuration 估算 pending 任务默认时长。
|
||||
//
|
||||
// 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1);
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
NodeConfirm = "confirm"
|
||||
NodeRoughBuild = "rough_build"
|
||||
NodeExecute = "execute"
|
||||
NodeOrderGuard = "order_guard"
|
||||
NodeInterrupt = "interrupt"
|
||||
NodeDeliver = "deliver"
|
||||
NodeQuickTask = "quick_task"
|
||||
@@ -53,9 +52,6 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
|
||||
if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeOrderGuard, compose.InvokableLambda(nodes.OrderGuard)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeQuickTask, compose.InvokableLambda(nodes.QuickTask)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,38 +111,31 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// RoughBuild -> Execute / OrderGuard / Deliver:
|
||||
// RoughBuild -> Execute / Deliver:
|
||||
// 1. 正常粗排完成后进入 execute 微调;
|
||||
// 2. 若粗排阶段 completed 且默认保持顺序,先走 order_guard 再交付;
|
||||
// 3. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。
|
||||
// 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。
|
||||
if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch(
|
||||
branchAfterRoughBuild,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeOrderGuard: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / OrderGuard(顺序守卫) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
if err := g.AddBranch(NodeExecute, compose.NewGraphBranch(
|
||||
branchAfterExecute,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeConfirm: true,
|
||||
NodeOrderGuard: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
NodeExecute: true,
|
||||
NodeConfirm: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// OrderGuard -> Deliver:顺序守卫只做校验,最终都由 Deliver 统一收口。
|
||||
if err := g.AddEdge(NodeOrderGuard, NodeDeliver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Interrupt -> END:当前连接必须在这里收口,等待用户输入或确认回调恢复。
|
||||
if err := g.AddEdge(NodeInterrupt, compose.END); err != nil {
|
||||
return nil, err
|
||||
@@ -279,9 +268,6 @@ func branchAfterRoughBuild(_ context.Context, st *newagentmodel.AgentGraphState)
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if flowState.Phase == newagentmodel.PhaseDone {
|
||||
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder {
|
||||
return NodeOrderGuard, nil
|
||||
}
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
@@ -309,9 +295,6 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s
|
||||
// 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入,
|
||||
// 导致 deliver 收口和后续预览落盘语义不一致。
|
||||
if flowState.Phase == newagentmodel.PhaseDone {
|
||||
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder && flowState.HasScheduleWriteOps {
|
||||
return NodeOrderGuard, nil
|
||||
}
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
|
||||
@@ -71,6 +71,24 @@ type CommonState struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
// ActiveToolDomain 记录当前 msg0 动态区激活的业务工具域。
|
||||
// 说明:
|
||||
// 1. 空字符串表示仅保留 context 管理工具,不注入业务工具定义;
|
||||
// 2. 非空时仅允许注入对应域的工具(如 schedule/taskclass);
|
||||
// 3. 该字段由 context_tools_add/remove 工具结果驱动更新。
|
||||
ActiveToolDomain string `json:"active_tool_domain,omitempty"`
|
||||
// ActiveToolPacks 记录当前激活域下的可选二级包(不含 core 固定包)。
|
||||
// 说明:
|
||||
// 1. 仅对 schedule 域生效(queue/mutation/analyze/web);
|
||||
// 2. 为空时按域默认策略解释(schedule 兼容为“全可选包”);
|
||||
// 3. 该字段与 ActiveToolDomain 一起由 context_tools_add/remove 结果更新。
|
||||
ActiveToolPacks []string `json:"active_tool_packs,omitempty"`
|
||||
// PendingContextHook 保存 plan 阶段给 execute 阶段的一次性注入建议。
|
||||
// 说明:
|
||||
// 1. 可由 plan_done 或 rough_build->execute 分支写入;
|
||||
// 2. execute 首轮消费一次后清空;
|
||||
// 3. 该字段只表达建议,不直接触发工具调用。
|
||||
PendingContextHook *ContextHook `json:"pending_context_hook,omitempty"`
|
||||
|
||||
// 流程阶段
|
||||
Phase Phase `json:"phase"`
|
||||
@@ -106,12 +124,70 @@ type CommonState struct {
|
||||
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
|
||||
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
|
||||
// 默认 false,只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
// SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。
|
||||
// OrderGuard 节点会基于该基线判断微调是否破坏顺序约束。
|
||||
SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"`
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
OptimizationMode string `json:"optimization_mode,omitempty"`
|
||||
// ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”。
|
||||
// 1. true 时,execute 只向 LLM 暴露 analyze_health + move + swap 这组最小闭环工具;
|
||||
// 2. 该开关只用于首次粗排后的自动微调,不影响用户后续明确提出的日程调整请求;
|
||||
// 3. 流程收口、重开新请求或切换业务域后,必须重置为 false。
|
||||
ActiveOptimizeOnly bool `json:"active_optimize_only,omitempty"`
|
||||
HealthCheckDone bool `json:"health_check_done,omitempty"`
|
||||
HealthIsFeasible bool `json:"health_is_feasible,omitempty"`
|
||||
HealthCapacityGap int `json:"health_capacity_gap,omitempty"`
|
||||
HealthReasonCode string `json:"health_reason_code,omitempty"`
|
||||
// HealthShouldContinueOptimize 记录最近一次 analyze_health 是否认为“还值得继续优化”。
|
||||
// 调用目的:
|
||||
// 1. 让 execute prompt 直接读取后端诊断结论,而不是只根据 issues 猜下一步;
|
||||
// 2. 该字段只表达“是否值得继续动”,不替 LLM 决定具体写参数;
|
||||
// 3. 默认 false,只有 analyze_health 明确判定后才会更新。
|
||||
HealthShouldContinueOptimize bool `json:"health_should_continue_optimize,omitempty"`
|
||||
// HealthTightnessLevel 记录最近一次诊断得到的优化空间等级:loose / tight / locked。
|
||||
// 调用目的:
|
||||
// 1. 用于提示 LLM 区分“还能优化”和“已经是被迫不完美”;
|
||||
// 2. 该字段只服务主动优化链路,不参与粗排可行性判断;
|
||||
// 3. 空字符串表示尚未拿到有效诊断。
|
||||
HealthTightnessLevel string `json:"health_tightness_level,omitempty"`
|
||||
// HealthPrimaryProblem 保存最近一次诊断的主要局部问题摘要。
|
||||
// 调用目的:
|
||||
// 1. 帮助 execute 聚焦当前最值得处理的那个点,避免全局乱搜;
|
||||
// 2. 只保存短摘要,不保存完整工具原文,避免状态膨胀;
|
||||
// 3. 为空表示当前没有明确主问题或诊断失败。
|
||||
HealthPrimaryProblem string `json:"health_primary_problem,omitempty"`
|
||||
// HealthRecommendedOperation 保存最近一次诊断建议优先考虑的动作类型。
|
||||
// 允许值由 analyze_health 控制,当前主要为 swap / move / close / ask_user。
|
||||
HealthRecommendedOperation string `json:"health_recommended_operation,omitempty"`
|
||||
// HealthIsForcedImperfection 标记当前剩余问题是否更像“约束代价”而非“仍值得修”的问题。
|
||||
// 调用目的:
|
||||
// 1. 给 LLM 一个明确的收口信号;
|
||||
// 2. 仅在 analyze_health 返回结构化 decision 时更新;
|
||||
// 3. false 不代表一定要继续优化,只代表“不是明确的被迫不完美”。
|
||||
HealthIsForcedImperfection bool `json:"health_is_forced_imperfection,omitempty"`
|
||||
// HealthImprovementSignal 保存最近一次诊断的紧凑对比信号,用于判断是否连续停滞。
|
||||
// 调用目的:
|
||||
// 1. execute 可基于该字段识别“连续两轮几乎没改善”;
|
||||
// 2. 信号由 analyze_health 生成,格式稳定但不面向用户展示;
|
||||
// 3. 若诊断失败则保持空字符串。
|
||||
HealthImprovementSignal string `json:"health_improvement_signal,omitempty"`
|
||||
// HealthStagnationCount 记录连续多少次 analyze_health 给出了相同的 improvement_signal。
|
||||
// 调用目的:
|
||||
// 1. 让 prompt 可以在“继续磨也没明显改善”时提醒 LLM 主动收口;
|
||||
// 2. 仅在两次连续有效诊断的信号完全相同时递增;
|
||||
// 3. 只做软提醒,不做后端硬拦截。
|
||||
HealthStagnationCount int `json:"health_stagnation_count,omitempty"`
|
||||
// TaskClassUpsertLastTried 标记本轮是否至少调用过一次 upsert_task_class。
|
||||
// 调用目的:execute_context 仅在该标记为 true 时注入“最近一次任务类写入结果”,避免噪音。
|
||||
TaskClassUpsertLastTried bool `json:"task_class_upsert_last_tried,omitempty"`
|
||||
// TaskClassUpsertLastSuccess 记录最近一次 upsert_task_class 是否成功。
|
||||
// 调用目的:为 prompt 提供“是否需要继续追问补字段”的明确信号。
|
||||
TaskClassUpsertLastSuccess bool `json:"task_class_upsert_last_success,omitempty"`
|
||||
// TaskClassUpsertLastIssues 记录最近一次写入返回的校验问题(validation.issues)。
|
||||
// 调用目的:让 LLM 直接按缺失字段追问,减少泛化提问。
|
||||
TaskClassUpsertLastIssues []string `json:"task_class_upsert_last_issues,omitempty"`
|
||||
// TaskClassUpsertConsecutiveFailures 记录连续写入失败次数。
|
||||
// 调用目的:给 prompt 注入“避免空转”的软提示,不做硬拦截。
|
||||
TaskClassUpsertConsecutiveFailures int `json:"task_class_upsert_consecutive_failures,omitempty"`
|
||||
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
|
||||
// 调用目的:graph 分支函数据此判断是否需要走 order_guard,非日程操作跳过守卫。
|
||||
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
|
||||
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
|
||||
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
|
||||
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
|
||||
@@ -164,8 +240,12 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
|
||||
s.PlanSteps = steps
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseWaitingConfirm
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -173,7 +253,8 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
|
||||
func (s *CommonState) ConfirmPlan() {
|
||||
s.Phase = PhaseExecuting
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -185,9 +266,13 @@ func (s *CommonState) StartDirectExecute() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseExecuting
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -196,8 +281,12 @@ func (s *CommonState) RejectPlan() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhasePlanning
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -223,18 +312,50 @@ func (s *CommonState) ResetForNextRun() {
|
||||
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
|
||||
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
|
||||
s.AllowReorder = false
|
||||
s.OptimizationMode = ""
|
||||
s.HealthCheckDone = false
|
||||
s.HealthIsFeasible = true
|
||||
s.HealthCapacityGap = 0
|
||||
s.HealthReasonCode = ""
|
||||
s.HealthShouldContinueOptimize = false
|
||||
s.HealthTightnessLevel = ""
|
||||
s.HealthPrimaryProblem = ""
|
||||
s.HealthRecommendedOperation = ""
|
||||
s.HealthIsForcedImperfection = false
|
||||
s.HealthImprovementSignal = ""
|
||||
s.HealthStagnationCount = 0
|
||||
s.HasScheduleWriteOps = false
|
||||
s.HasScheduleChanges = false
|
||||
s.UsedQuickNote = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// resetTaskClassUpsertSnapshot 清理“任务类写入回盘”运行态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅清理 upsert_task_class 相关的临时回盘字段;
|
||||
// 2. 不影响 Health/Plan/Phase 等其他执行状态;
|
||||
// 3. 作为新一轮入口统一调用,避免旧失败信息污染本轮追问。
|
||||
func (s *CommonState) resetTaskClassUpsertSnapshot() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.TaskClassUpsertLastTried = false
|
||||
s.TaskClassUpsertLastSuccess = false
|
||||
s.TaskClassUpsertLastIssues = nil
|
||||
s.TaskClassUpsertConsecutiveFailures = 0
|
||||
}
|
||||
|
||||
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
|
||||
func (s *CommonState) AdvanceStep() bool {
|
||||
s.CurrentStep++
|
||||
@@ -248,6 +369,12 @@ func (s *CommonState) AdvanceStep() bool {
|
||||
// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。
|
||||
func (s *CommonState) Done() {
|
||||
s.Phase = PhaseDone
|
||||
// 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。
|
||||
// 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
if s.TerminalOutcome != nil {
|
||||
s.TerminalOutcome.Normalize()
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package model
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -61,7 +62,7 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action ExecuteAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GoalCheck string `json:"goal_check,omitempty"`
|
||||
GoalCheck json.RawMessage `json:"goal_check,omitempty"`
|
||||
ToolCall json.RawMessage `json:"tool_call,omitempty"`
|
||||
Abort json.RawMessage `json:"abort,omitempty"`
|
||||
}
|
||||
@@ -74,7 +75,11 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
|
||||
d.Speak = raw.Speak
|
||||
d.Action = raw.Action
|
||||
d.Reason = raw.Reason
|
||||
d.GoalCheck = raw.GoalCheck
|
||||
goalCheck, err := decodeGoalCheckText(raw.GoalCheck)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goal_check 解析失败: %w", err)
|
||||
}
|
||||
d.GoalCheck = goalCheck
|
||||
|
||||
toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
|
||||
if err != nil {
|
||||
@@ -91,6 +96,124 @@ func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeGoalCheckText 兼容 goal_check 的字符串/对象写法,统一降级为字符串。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 字符串:直接使用,保持主协议不变;
|
||||
// 2. 对象:按 done_when/evidence 提取并拼接为单行证据文本;
|
||||
// 3. 数组或其他标量:尽量转成可读字符串,避免仅因格式漂移导致整轮失败。
|
||||
func decodeGoalCheckText(raw json.RawMessage) (string, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 标准写法:goal_check 为字符串。
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// 2. 兼容写法:goal_check 被模型写成对象。
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compactGoalCheckObject(obj), nil
|
||||
}
|
||||
|
||||
// 3. 兜底:数组/标量场景,尽量保留可读信息。
|
||||
var generic any
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(formatGoalCheckValue(generic)), nil
|
||||
}
|
||||
|
||||
// compactGoalCheckObject 将对象型 goal_check 压缩为可读单行文本,优先提取 done_when/evidence。
|
||||
func compactGoalCheckObject(obj map[string]any) string {
|
||||
if len(obj) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
doneWhen := strings.TrimSpace(formatGoalCheckValue(obj["done_when"]))
|
||||
evidence := strings.TrimSpace(formatGoalCheckValue(obj["evidence"]))
|
||||
|
||||
parts := make([]string, 0, 2)
|
||||
if doneWhen != "" {
|
||||
parts = append(parts, "已满足 done_when:"+doneWhen)
|
||||
}
|
||||
if evidence != "" {
|
||||
parts = append(parts, "证据:"+evidence)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// done_when/evidence 缺失时,按 key 排序拼接,保证日志稳定可读。
|
||||
keys := make([]string, 0, len(obj))
|
||||
for key := range obj {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
fallback := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(obj[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
fallback = append(fallback, key+"="+text)
|
||||
}
|
||||
return strings.Join(fallback, ";")
|
||||
}
|
||||
|
||||
// formatGoalCheckValue 将任意值转成单行可读文本,用于 goal_check 压缩拼接。
|
||||
func formatGoalCheckValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case []any:
|
||||
parts := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(item))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(typed[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, key+"="+text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", typed))
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 execute 决策中的字符串字段。
|
||||
func (d *ExecuteDecision) Normalize() {
|
||||
if d == nil {
|
||||
|
||||
@@ -12,6 +12,15 @@ const (
|
||||
|
||||
const PendingInteractionSnapshotVersion = 1
|
||||
|
||||
const (
|
||||
// PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。
|
||||
// interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。
|
||||
PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed"
|
||||
// PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。
|
||||
// interrupt 节点据此避免二次追加历史,防止上下文重复。
|
||||
PendingMetaAskUserHistoryAppended = "ask_user_history_appended"
|
||||
)
|
||||
|
||||
// PendingInteractionType 表示当前挂起交互的类型。
|
||||
type PendingInteractionType string
|
||||
|
||||
@@ -179,6 +188,26 @@ func (s *AgentRuntimeState) ClearPendingInteraction() {
|
||||
s.PendingInteraction = nil
|
||||
}
|
||||
|
||||
// SetPendingInteractionMetadata 为当前 open 状态的 pending interaction 写入元信息。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅对当前挂起交互打运行态标记,不参与业务语义判断;
|
||||
// 2. 若当前没有 pending interaction,则静默跳过;
|
||||
// 3. metadata 仅用于节点间协作(如避免 ask_user 重复推送)。
|
||||
func (s *AgentRuntimeState) SetPendingInteractionMetadata(key string, value any) {
|
||||
if s == nil || s.PendingInteraction == nil || s.PendingInteraction.Status != PendingInteractionStatusOpen {
|
||||
return
|
||||
}
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
return
|
||||
}
|
||||
if s.PendingInteraction.Metadata == nil {
|
||||
s.PendingInteraction.Metadata = make(map[string]any)
|
||||
}
|
||||
s.PendingInteraction.Metadata[trimmedKey] = value
|
||||
}
|
||||
|
||||
func (s *AgentRuntimeState) openPendingInteraction(
|
||||
interactionType PendingInteractionType,
|
||||
interactionID string,
|
||||
|
||||
@@ -55,6 +55,19 @@ type PlanDecision struct {
|
||||
PlanSteps []PlanStep `json:"plan_steps,omitempty"`
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
ContextHook *ContextHook `json:"context_hook,omitempty"`
|
||||
}
|
||||
|
||||
// ContextHook 表示 plan 阶段给 execute 阶段的上下文注入建议。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅承载“建议激活哪个 domain/packs”,不负责真正执行 context_tools_add/remove;
|
||||
// 2. domain 仅允许 schedule/taskclass,packs 仅允许 schedule 的可选包;
|
||||
// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。
|
||||
type ContextHook struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗规划决策中的字符串字段。
|
||||
@@ -69,6 +82,9 @@ func (d *PlanDecision) Normalize() {
|
||||
for i := range d.PlanSteps {
|
||||
d.PlanSteps[i].Normalize()
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
d.ContextHook.Normalize()
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验规划决策的最小合法性。
|
||||
@@ -102,6 +118,9 @@ func (d *PlanDecision) Validate() error {
|
||||
if len(d.PlanSteps) > 0 {
|
||||
return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action)
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 context_hook", d.Action)
|
||||
}
|
||||
return nil
|
||||
case PlanActionDone:
|
||||
if len(d.PlanSteps) == 0 {
|
||||
@@ -112,6 +131,11 @@ func (d *PlanDecision) Validate() error {
|
||||
return fmt.Errorf("plan_steps[%d] 非法: %w", i, err)
|
||||
}
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
if err := d.ContextHook.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知 plan action: %s", d.Action)
|
||||
@@ -149,3 +173,73 @@ func (s *PlanStep) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 context hook 字段。
|
||||
func (h *ContextHook) Normalize() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.Domain = normalizeContextHookDomain(h.Domain)
|
||||
h.Reason = strings.TrimSpace(h.Reason)
|
||||
h.Packs = normalizeContextHookPacks(h.Domain, h.Packs)
|
||||
}
|
||||
|
||||
// Validate 校验 context hook 最小合法性。
|
||||
func (h *ContextHook) Validate() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.Normalize()
|
||||
if h.Domain == "" {
|
||||
return fmt.Errorf("context_hook.domain 非法,仅支持 schedule/taskclass")
|
||||
}
|
||||
if h.Domain == "taskclass" && len(h.Packs) > 0 {
|
||||
return fmt.Errorf("context_hook.taskclass 暂不支持 packs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeContextHookDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "schedule":
|
||||
return "schedule"
|
||||
case "taskclass":
|
||||
return "taskclass"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContextHookPacks(domain string, packs []string) []string {
|
||||
if domain != "schedule" || len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"queue": {},
|
||||
"mutation": {},
|
||||
"analyze": {},
|
||||
"detail_read": {},
|
||||
"deep_analyze": {},
|
||||
"web": {},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := make([]string, 0, len(packs))
|
||||
for _, raw := range packs {
|
||||
pack := strings.ToLower(strings.TrimSpace(raw))
|
||||
if pack == "" || pack == "core" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[pack]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
result = append(result, pack)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
@@ -146,7 +147,15 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
|
||||
// 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。
|
||||
if st.Deps.ToolRegistry != nil {
|
||||
schemas := st.Deps.ToolRegistry.Schemas()
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState := st.EnsureFlowState(); flowState != nil {
|
||||
activeDomain, activePacks = resolveEffectiveExecuteToolDomain(flowState)
|
||||
}
|
||||
schemas := st.Deps.ToolRegistry.SchemasForActiveDomain(activeDomain, activePacks)
|
||||
if flowState := st.EnsureFlowState(); flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
schemas = newagenttools.FilterSchemasForActiveOptimize(schemas)
|
||||
}
|
||||
toolSchemas := make([]newagentmodel.ToolSchemaContext, len(schemas))
|
||||
for i, s := range schemas {
|
||||
toolSchemas[i] = newagentmodel.ToolSchemaContext{
|
||||
@@ -184,20 +193,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// OrderGuard 负责把 graph 的 order_guard 节点请求转给 RunOrderGuardNode。
|
||||
func (n *AgentNodes) OrderGuard(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("order_guard node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunOrderGuardNode(ctx, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。
|
||||
func (n *AgentNodes) QuickTask(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
@@ -337,3 +332,31 @@ func deleteAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) {
|
||||
|
||||
_ = store.Delete(ctx, flowState.ConversationID)
|
||||
}
|
||||
|
||||
// resolveEffectiveExecuteToolDomain 计算“本轮 execute 真正应看到”的工具域快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 PendingContextHook,让首轮 execute 的 schema 注入与即将生效的规则包保持一致;
|
||||
// 2. 只做只读推导,不消费 PendingContextHook,真正的状态更新仍由 RunExecuteNode 统一处理;
|
||||
// 3. hook 非法或为空时,回退到已持久化的 ActiveToolDomain/ActiveToolPacks,保持历史链路兼容。
|
||||
func resolveEffectiveExecuteToolDomain(flowState *newagentmodel.CommonState) (string, []string) {
|
||||
if flowState == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 若 plan / rough_build 已写入待生效 hook,则首轮 execute 必须优先按它推导工具域,
|
||||
// 否则 prompt 里的规则包和注入的工具 schema 会错位,模型第一轮看不到该用的工具。
|
||||
if hook := flowState.PendingContextHook; hook != nil {
|
||||
domain := newagenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain != "" {
|
||||
return domain, newagenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. hook 不可用时回退到当前已激活域,保持老链路与恢复链路的行为不变。
|
||||
domain := newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if domain == "" {
|
||||
return "", nil
|
||||
}
|
||||
return domain, newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,10 @@ func streamAndDispatch(
|
||||
decision.NeedsRoughBuild = false
|
||||
decision.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
// 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。
|
||||
if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRefineAfterRoughBuild = true
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v thinking=%v has_rough_build_done=%v task_class_count=%d raw=%s",
|
||||
@@ -445,6 +449,7 @@ func handleRouteExecuteStream(
|
||||
}
|
||||
|
||||
flowState.ExecuteThinking = effectiveThinking
|
||||
flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -510,6 +515,45 @@ func detectReorderPreference(userInput string) reorderPreference {
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
// resolveOptimizationMode 统一确定当前 execute 的优化模式。
|
||||
func resolveOptimizationMode(
|
||||
userInput string,
|
||||
decision *newagentmodel.ChatRoutingDecision,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) string {
|
||||
if decision != nil && decision.NeedsRoughBuild && flowState != nil && len(flowState.TaskClassIDs) > 0 {
|
||||
return "first_full"
|
||||
}
|
||||
if isExplicitGlobalReoptRequest(userInput) {
|
||||
return "global_reopt"
|
||||
}
|
||||
return "local_adjust"
|
||||
}
|
||||
|
||||
// isExplicitGlobalReoptRequest 识别用户是否明确要求全局重优化。
|
||||
func isExplicitGlobalReoptRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"全局优化",
|
||||
"整体优化",
|
||||
"全局重排",
|
||||
"整体重排",
|
||||
"重新优化全部",
|
||||
"重新优化整体",
|
||||
"全面优化",
|
||||
"整体体检",
|
||||
"全局体检",
|
||||
"重新体检",
|
||||
"global optimize",
|
||||
"global reopt",
|
||||
"overall optimize",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
func containsAnyPhrase(text string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(text, phrase) {
|
||||
@@ -539,6 +583,27 @@ func shouldDisableRoughBuildForRefine(
|
||||
return !isExplicitRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
// shouldForceRefineAfterFirstRoughBuild 判断是否应在"首次粗排"场景下强制开启 refine。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 仅在当前决策仍然请求粗排时生效;
|
||||
// 2. 仅在首次粗排(上下文不存在 rough_build_done)时生效;
|
||||
// 3. 若用户明确表达"只要初稿/先不优化",则不强制开启;
|
||||
// 4. 其余首次粗排场景一律开启,确保符合 PRD 的默认主动优化策略。
|
||||
func shouldForceRefineAfterFirstRoughBuild(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *newagentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitNoRefineAfterRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
func hasRoughBuildDoneMarker(conversationContext *newagentmodel.ConversationContext) bool {
|
||||
if conversationContext == nil {
|
||||
return false
|
||||
@@ -575,6 +640,31 @@ func isExplicitRoughBuildRequest(userInput string) bool {
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// isExplicitNoRefineAfterRoughBuildRequest 识别用户是否明确要求"粗排后先不要自动微调"。
|
||||
func isExplicitNoRefineAfterRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"只要初稿",
|
||||
"先给初稿",
|
||||
"先排进去就行",
|
||||
"先排进去",
|
||||
"先不优化",
|
||||
"先别优化",
|
||||
"先不微调",
|
||||
"先别微调",
|
||||
"排完就收口",
|
||||
"粗排就行",
|
||||
"草稿就行",
|
||||
"draft only",
|
||||
"no refine",
|
||||
"no optimization",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。
|
||||
//
|
||||
// 步骤说明:
|
||||
|
||||
@@ -42,15 +42,10 @@ func AppendLLMCorrection(
|
||||
}
|
||||
|
||||
// 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。
|
||||
// 如果 llmOutput 为空,则生成一个占位描述。
|
||||
// 2. 空输出不回灌,避免把占位文本写进历史造成噪音。
|
||||
// 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
if assistantContent == "" {
|
||||
assistantContent = "[LLM 输出为空或无法解析]"
|
||||
}
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
// 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。
|
||||
// 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。
|
||||
@@ -88,13 +83,7 @@ func AppendLLMCorrectionWithHint(
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
if assistantContent == "" {
|
||||
assistantContent = "[LLM 输出为空或无法解析]"
|
||||
}
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
@@ -109,3 +98,39 @@ func AppendLLMCorrectionWithHint(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,18 +82,27 @@ func handleInterruptAskUser(
|
||||
text = "请补充更多信息。"
|
||||
}
|
||||
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
speakStreamed := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserSpeakStreamed)
|
||||
historyAppended := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserHistoryAppended)
|
||||
|
||||
// 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送;
|
||||
// 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。
|
||||
if !speakStreamed {
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
if !historyAppended {
|
||||
conversationContext.AppendHistory(msg)
|
||||
}
|
||||
persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg)
|
||||
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
@@ -105,6 +114,21 @@ func handleInterruptAskUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPendingMetadataBool(pending *newagentmodel.PendingInteraction, key string) bool {
|
||||
if pending == nil || pending.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, exists := pending.Metadata[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// handleInterruptConfirm 处理确认型中断。
|
||||
//
|
||||
// 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -89,7 +89,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
// 显式设置上限,避免依赖框架默认值(默认 4096)导致长决策被截断。
|
||||
// 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。
|
||||
MaxTokens: 131072,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": planStageName,
|
||||
"phase": "planning",
|
||||
@@ -102,6 +105,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
|
||||
parser := newagentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
speakStreamed := false
|
||||
|
||||
// 3.1 阶段一:解析决策标签。
|
||||
for {
|
||||
@@ -151,6 +155,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
@@ -173,6 +178,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
@@ -187,7 +193,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision)
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision, speakStreamed)
|
||||
}
|
||||
|
||||
// 流结束但未找到决策标签。
|
||||
@@ -203,6 +209,7 @@ func handlePlanAction(
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
flowState *newagentmodel.CommonState,
|
||||
decision *newagentmodel.PlanDecision,
|
||||
askUserSpeakStreamed bool,
|
||||
) error {
|
||||
switch decision.Action {
|
||||
case newagentmodel.PlanActionContinue:
|
||||
@@ -211,9 +218,14 @@ func handlePlanAction(
|
||||
case newagentmodel.PlanActionAskUser:
|
||||
question := resolvePlanAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
// 1. plan 阶段若已流式推送过 ask_user 文本,interrupt 侧应避免重复正文输出;
|
||||
// 2. plan 阶段 ask_user 不会提前写入 history,这里显式标记为 false。
|
||||
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserSpeakStreamed, askUserSpeakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserHistoryAppended, false)
|
||||
return nil
|
||||
case newagentmodel.PlanActionDone:
|
||||
flowState.FinishPlan(decision.PlanSteps)
|
||||
flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook)
|
||||
writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
|
||||
if decision.NeedsRoughBuild {
|
||||
flowState.NeedsRoughBuild = true
|
||||
@@ -295,6 +307,21 @@ func resolvePlanAskUserText(decision *newagentmodel.PlanDecision) string {
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
|
||||
func clonePlanContextHook(hook *newagentmodel.ContextHook) *newagentmodel.ContextHook {
|
||||
if hook == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *hook
|
||||
if len(hook.Packs) > 0 {
|
||||
cloned.Packs = append([]string(nil), hook.Packs...)
|
||||
}
|
||||
cloned.Normalize()
|
||||
if cloned.Domain == "" {
|
||||
return nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func writePlanPinnedBlocks(ctx *newagentmodel.ConversationContext, steps []newagentmodel.PlanStep) {
|
||||
if ctx == nil {
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
@@ -69,30 +70,57 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
// 4. 粗排前强制刷新 ScheduleState,避免复用旧快照窗口。
|
||||
// 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids,而不是沿用历史“当前周”窗口。
|
||||
// 4.2 做法:主动丢弃内存中的旧 state,让 EnsureScheduleState 走 provider 重新加载。
|
||||
// 4.3 失败策略:若任务类缺少有效起止日期,provider 会返回错误,由上层统一透传并让用户补齐字段。
|
||||
st.ScheduleState = nil
|
||||
st.OriginalScheduleState = nil
|
||||
|
||||
// 5. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
// 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。
|
||||
// 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。
|
||||
if strings.Contains(err.Error(), "任务类缺少有效时间窗") {
|
||||
failureMessage := "开始智能编排前,我需要任务类的起止日期(start_date / end_date)。请先补齐时间窗,再让我继续排课。"
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_need_time_window",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_window_missing",
|
||||
failureMessage,
|
||||
err.Error(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
|
||||
}
|
||||
|
||||
// 5. 调用粗排算法。
|
||||
// 6. 调用粗排算法。
|
||||
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 把粗排结果写入 ScheduleState。
|
||||
// 7. 把粗排结果写入 ScheduleState。
|
||||
applyStats := applyRoughBuildPlacements(scheduleState, placements)
|
||||
|
||||
// 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。
|
||||
// 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。
|
||||
if applyStats.AppliedCount > 0 {
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
// 7. 先校验粗排后是否仍有真实 pending。
|
||||
// 8. 先校验粗排后是否仍有真实 pending。
|
||||
stillPending := countPendingTasks(scheduleState, taskClassIDs)
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d",
|
||||
@@ -197,9 +225,31 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
flowState.ActiveOptimizeOnly = false
|
||||
flowState.Done()
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(flowState.OptimizationMode) == "" {
|
||||
flowState.OptimizationMode = "first_full"
|
||||
}
|
||||
// 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。
|
||||
// 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面,
|
||||
// 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。
|
||||
// 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。
|
||||
flowState.ActiveOptimizeOnly = true
|
||||
// 12. 粗排后进入 execute 微调时,补一条一次性 context hook。
|
||||
//
|
||||
// 1. 目的:即使这条链路不回 plan,也能在 execute 首轮拿到建议工具面(analyze + mutation)。
|
||||
// 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add,仍由 execute 按统一入口消费。
|
||||
// 3. 回退:hook 无效时 execute 会自动忽略并清空,不影响主流程。
|
||||
flowState.PendingContextHook = &newagentmodel.ContextHook{
|
||||
Domain: newagenttools.ToolDomainSchedule,
|
||||
Packs: []string{
|
||||
newagenttools.ToolPackAnalyze,
|
||||
newagenttools.ToolPackMutation,
|
||||
},
|
||||
Reason: "rough_build_post_refine",
|
||||
}
|
||||
flowState.Phase = newagentmodel.PhaseExecuting
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -123,12 +123,22 @@ func renderStateSummary(state *newagentmodel.CommonState) string {
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(",日期范围=%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(",语义画像=%s/%s/%s",
|
||||
defaultSemanticValue(tc.SubjectType),
|
||||
defaultSemanticValue(tc.DifficultyLevel),
|
||||
defaultSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
if tc.AllowFillerCourse {
|
||||
line += ",允许嵌入水课"
|
||||
}
|
||||
if len(tc.ExcludedSlots) > 0 {
|
||||
line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(",排除星期=%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
@@ -136,6 +146,14 @@ func renderStateSummary(state *newagentmodel.CommonState) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func defaultSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。
|
||||
func renderPinnedBlocks(ctx *newagentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
|
||||
@@ -26,6 +26,11 @@ quick_task 判别要点:
|
||||
- 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute
|
||||
- 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问
|
||||
|
||||
任务类设计路由要点:
|
||||
- 普通"创建/修改任务类"默认走 execute(由 execute 负责补字段与写入)。
|
||||
- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。
|
||||
- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。
|
||||
|
||||
通用回答约束:
|
||||
- 非日程、非任务类问题,只要不需要工具,也应当正常回答。
|
||||
- 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。
|
||||
@@ -39,8 +44,8 @@ quick_task 判别要点:
|
||||
- "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine,不得再次触发 rough build。
|
||||
粗排后微调判断:
|
||||
- 仅当 rough_build=true 时才判断 refine。
|
||||
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 refine=true。
|
||||
- 若用户只要求"先排进去/给初稿",未提出微调目标,设 refine=false。
|
||||
- 默认策略:首次粗排完成后应进入微调(refine=true),按中位标准做主动优化。
|
||||
- 仅当用户明确表达"只要初稿/先排进去别优化/先不微调/排完就收口"时,才设 refine=false。
|
||||
顺序授权判断:
|
||||
- reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。
|
||||
- 用户明确要求"保持顺序/不要打乱"时必须为 false。
|
||||
|
||||
@@ -8,261 +8,14 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const executeSystemPromptWithPlan = `
|
||||
你是 SmartMate 的执行器。你需要在"当前 plan 步骤"约束下推进任务。
|
||||
|
||||
你可以做什么:
|
||||
1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。
|
||||
2. 可调用读工具补充事实,再决定下一步。
|
||||
3. 日程写操作时输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
4. 若用户给出了"二次微调方向"(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。
|
||||
5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。
|
||||
6. 多任务微调时默认走队列链路:query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要跳到其他 plan 步骤,不要越级执行。
|
||||
2. 不要伪造工具结果。
|
||||
3. 如果上下文明确"粗排已完成/rough_build_done",不要把任务当成未排入,不要重新逐个手动 place。
|
||||
4. 如果上下文明确"当前未收到明确微调偏好/本轮先收口",不要继续微调,直接输出 action=done。
|
||||
5. 不要连续重复同类查询而没有推进;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
6. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
|
||||
7. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
8. 不要忽略用户最新补充的微调方向;若与旧目标冲突,以最新用户要求为准。
|
||||
9. 若当前顺序策略是"默认保持顺序",禁止调用 min_context_switch。
|
||||
10. 不要把超过 2 条任务打包到 batch_move;大批量调整请改走队列逐项处理。
|
||||
11. 不要在未获取队首(queue_pop_head)时直接调用 queue_apply_head_move。
|
||||
12. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||
13. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。
|
||||
14. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
|
||||
|
||||
执行规则:
|
||||
1. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
5. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。
|
||||
6. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。
|
||||
7. 流程应正式终止时输出 action=abort。`
|
||||
|
||||
const executeSystemPromptReAct = `
|
||||
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
|
||||
|
||||
阶段事实(强约束):
|
||||
1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing,不是待排入状态。
|
||||
2. 当前阶段目标是"微调",不是"重新粗排"。
|
||||
3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。
|
||||
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
|
||||
|
||||
你可以做什么:
|
||||
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。
|
||||
4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要假设任务还没排进去,然后改成逐个手动 place。
|
||||
2. 不要伪造工具结果。
|
||||
3. 不要重复做同类查询而没有新增结论;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
|
||||
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
|
||||
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
|
||||
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
|
||||
9. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
||||
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
||||
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||
12. web_search 仅在"制定学习计划需要查外部资料"时使用(如考试日期、课程信息、校历政策等);日程排布本身(place/move/swap)不需要搜索。
|
||||
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
|
||||
|
||||
执行规则:
|
||||
1. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
5. 任务完成:action=done,并在 goal_check 总结完成证据。
|
||||
6. 流程应正式终止:action=abort。`
|
||||
|
||||
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
|
||||
func BuildExecuteSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptWithPlan)
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseWithPlan)
|
||||
}
|
||||
|
||||
// BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。
|
||||
func BuildExecuteReActSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptReAct)
|
||||
}
|
||||
|
||||
// BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。
|
||||
func BuildExecuteDecisionContractText() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(两阶段格式):
|
||||
|
||||
先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。
|
||||
决策标签格式:<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,
|
||||
))
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseReAct)
|
||||
}
|
||||
|
||||
// BuildExecuteMessages 组装执行阶段消息。
|
||||
@@ -304,6 +57,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
|
||||
计划步骤强约束:
|
||||
- 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。
|
||||
- 若全部计划已完成:输出 action=done,并在 goal_check 总结完成证据。
|
||||
- goal_check 字段类型必须为 string,不要输出对象或数组。
|
||||
- 若未完成但缺少关键信息:输出 action=ask_user。`)
|
||||
}
|
||||
|
||||
@@ -324,6 +78,7 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
|
||||
- 当前步骤完成判定(done_when):%s
|
||||
- 未满足 done_when 时:只能输出 continue / confirm / ask_user,禁止输出 next_plan。
|
||||
- 满足 done_when 时:优先输出 action=next_plan,并在 goal_check 逐条对照 done_when 给出证据。
|
||||
- goal_check 字段类型固定为 string(示例:"已满足 done_when:...;证据:..."),禁止输出 {"done_when":"...","evidence":"..."}。
|
||||
- 禁止跳步:不要提前执行后续步骤。`,
|
||||
base, current, total, stepContent, doneWhen))
|
||||
}
|
||||
@@ -332,13 +87,15 @@ func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState)
|
||||
func buildExecutePromptWithFormatGuard(base string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
guard := strings.TrimSpace(`
|
||||
补充 JSON 约束:
|
||||
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
|
||||
2. 若输出 tool_call,参数字段名只能是 arguments,禁止写成 parameters。
|
||||
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
|
||||
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
|
||||
5. action=continue / ask_user / confirm 时,标签后的正文必须是非空自然语言。
|
||||
6. <SMARTFLOW_DECISION> 标签内只放 JSON,不要放自然语言。`)
|
||||
输出协议硬约束:
|
||||
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。
|
||||
3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空。
|
||||
4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。
|
||||
5. 只有 action=abort 时才允许输出 abort 字段。
|
||||
6. <SMARTFLOW_DECISION> 标签内只放 JSON,不要放自然语言。
|
||||
7. 不要在 <SMARTFLOW_DECISION> 标签前输出任何前言、寒暄、解释或铺垫;给用户看的正文只能放在 </SMARTFLOW_DECISION> 之后。
|
||||
8. 任何动作都不得擅自超出用户当前明确意图;用户没让你做的下一步,不要自作主张推进。`)
|
||||
if base == "" {
|
||||
return guard
|
||||
}
|
||||
@@ -351,37 +108,17 @@ func buildExecuteStrictJSONUserPrompt() string {
|
||||
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
|
||||
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
|
||||
|
||||
补充格式要求:
|
||||
- JSON 中不要包含 speak 字段,给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
- 与当前 action 无关的字段直接省略,不要输出空字符串、空对象、空数组或 null 占位
|
||||
- tool_call 只能写 {"name":"工具名","arguments":{...}},且每轮最多一个
|
||||
- 不要写 {"tool_call":{"name":"工具名","parameters":{...}}}
|
||||
- 非 abort 动作不要输出 abort 字段
|
||||
- action 为 continue / ask_user / confirm 时,标签后必须输出非空正文
|
||||
执行提醒:
|
||||
- JSON 中不要包含 speak 字段;给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
- 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
|
||||
- 日程写工具(place/move/swap/batch_move/unplace)一律走 action=confirm
|
||||
- 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
|
||||
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
|
||||
- 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm
|
||||
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化
|
||||
- 若上下文已明确"当前未收到微调偏好,本轮先收口",请直接输出 action=done
|
||||
- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch
|
||||
- spread_even 用于"范围内均匀化",必须先用 query_target_tasks 明确目标任务集合
|
||||
- 多任务调整默认先调用 query_target_tasks(enqueue=true),再用 queue_pop_head 逐项处理
|
||||
- queue_apply_head_move 只能用于 current 任务;若当前任务无法落位,调用 queue_skip_head 后继续
|
||||
- batch_move 一次最多 2 条;超过 2 条必须改走队列逐项处理
|
||||
`)
|
||||
}
|
||||
|
||||
// BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。
|
||||
func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
|
||||
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
|
||||
`)
|
||||
}
|
||||
|
||||
// BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。
|
||||
func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
|
||||
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
|
||||
- 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞
|
||||
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
|
||||
- web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
|
||||
- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// executeHistoryKindKey 用于在 history 中打运行态标记,供 prompt 分层识别。
|
||||
// 说明:loop_closed / step_advanced 等边界标记仍由节点层写入,但 prompt 层已不再消费它们——
|
||||
// 因为 msg1/msg2 已经按"真实对话流 + 当前活跃 ReAct 记录"重构,不再做 msg2→msg1 的归档搬运。
|
||||
// executeHistoryKindKey 用于在 history 里区分普通用户消息与后端注入的纠错提示。
|
||||
// 这里负责“识别并过滤”,不负责写入该标记。
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
@@ -31,20 +30,29 @@ type executeLoopRecord struct {
|
||||
Observation string
|
||||
}
|
||||
|
||||
// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。
|
||||
type conversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type executeLatestToolRecord struct {
|
||||
ToolName string
|
||||
Observation string
|
||||
}
|
||||
|
||||
// buildExecuteStageMessages 组装 execute 阶段的四段式消息。
|
||||
//
|
||||
// 消息结构(固定):
|
||||
// 1. message[0] 固定 prompt(规则 + 微调硬引导 + 输出约束 + 工具简表)
|
||||
// 2. message[1] 历史上下文(真实对话流 + 早期 ReAct 摘要)
|
||||
// 3. message[2] 当轮 ReAct Loop 窗口(thought/reason + tool_call + observation 绑定展示)
|
||||
// 4. message[3] 当前执行状态(轮次、模式、plan 步骤、任务类、相关记忆等)
|
||||
// 1. msg0:系统提示 + 动态规则包 + 工具简表。
|
||||
// 2. msg1:真实对话流,只保留 user 和 assistant speak。
|
||||
// 3. msg2:当前 ReAct tool loop 记录。
|
||||
// 4. msg3:执行状态、阶段约束、记忆和本轮指令。
|
||||
func buildExecuteStageMessages(
|
||||
stageSystemPrompt string,
|
||||
state *newagentmodel.CommonState,
|
||||
ctx *newagentmodel.ConversationContext,
|
||||
runtimeUserPrompt string,
|
||||
) []*schema.Message {
|
||||
msg0 := buildExecuteMessage0(stageSystemPrompt, ctx)
|
||||
msg0 := buildExecuteMessage0(stageSystemPrompt, state, ctx)
|
||||
msg1 := buildExecuteMessage1V3(ctx)
|
||||
msg2 := buildExecuteMessage2V3(ctx)
|
||||
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
|
||||
@@ -57,27 +65,30 @@ func buildExecuteStageMessages(
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecuteMessage0 生成固定规则消息,并附带工具简表。
|
||||
func buildExecuteMessage0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string {
|
||||
// buildExecuteMessage0 生成 execute 阶段的固定规则消息。
|
||||
//
|
||||
// 1. 先拼基础 system prompt,保证身份和输出协议稳定。
|
||||
// 2. 再按当前 domain / packs 注入动态规则包,让模型先读到边界。
|
||||
// 3. 最后再附工具简表,避免模型只看到工具不看到纪律。
|
||||
func buildExecuteMessage0(stageSystemPrompt string, state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) string {
|
||||
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 执行器,请继续 execute 阶段。"
|
||||
base = "你是 SmartMate 执行器,请继续当前执行阶段。"
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx)
|
||||
if toolCatalog == "" {
|
||||
return base
|
||||
rulePackSection, _ := renderExecuteRulePackSection(state, ctx)
|
||||
if rulePackSection != "" {
|
||||
base += "\n\n" + rulePackSection
|
||||
}
|
||||
return base + "\n\n" + toolCatalog
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, state)
|
||||
if toolCatalog != "" {
|
||||
base += "\n\n" + toolCatalog
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// buildExecuteMessage1V3 只渲染"真实对话流 + 阶段锚点"。
|
||||
//
|
||||
// 改造说明:
|
||||
// 1. msg1 只保留 user + assistant speak 组成的真实对话历史,全量注入;
|
||||
// 2. tool_call / observation 一律由 msg2 承载,这里不再重复;
|
||||
// 3. 不再从历史中"归档"上一轮 ReAct 结果到 msg1——归档搬运逻辑已随 splitExecuteLoopRecordsByBoundary 一并移除;
|
||||
// 4. token 预算由统一压缩层兜底,prompt 层不做提前裁剪。
|
||||
// buildExecuteMessage1V3 只渲染真实对话流,不混入 tool observation。
|
||||
func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"历史上下文:"}
|
||||
if ctx == nil {
|
||||
@@ -105,16 +116,13 @@ func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
|
||||
} else {
|
||||
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildExecuteMessage2V3 承载当前会话中全部 ReAct Loop 记录。
|
||||
// buildExecuteMessage2V3 只承载当轮 ReAct loop。
|
||||
//
|
||||
// 改造说明:
|
||||
// 1. 不再按 execute_loop_closed / execute_step_advanced 边界切分"归档/活跃"两段;
|
||||
// 2. 直接从 history 提取全部 assistant tool_call + 对应 observation 作为当前 Loop 视图;
|
||||
// 3. 新一轮刚开始(尚未产生 tool_call)时返回明确占位,方便模型识别"干净起点"。
|
||||
// 1. 每条记录固定展示 thought / tool_call / observation,方便模型做局部闭环。
|
||||
// 2. 如果当前还没有任何 tool loop,明确给“新一轮”占位,避免模型误判缺上下文。
|
||||
func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"当轮 ReAct Loop 记录:"}
|
||||
if ctx == nil {
|
||||
@@ -136,11 +144,19 @@ func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildExecuteMessage3 汇总当前执行状态和本轮指令。
|
||||
//
|
||||
// 1. 这里只放“当前轮真正会影响决策”的状态,避免 msg3 继续膨胀。
|
||||
// 2. 读工具最近结果只给最新一条摘要,避免旧 observation 重复占上下文。
|
||||
// 3. 最后一行固定落到“本轮指令”,保证模型收尾时注意力还在执行目标上。
|
||||
func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string {
|
||||
lines := []string{"当前执行状态:"}
|
||||
roughBuildDone := hasExecuteRoughBuildDone(ctx)
|
||||
|
||||
roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds
|
||||
modeText := "自由执行(无预定义步骤)"
|
||||
activeDomain := ""
|
||||
activePacks := []string{}
|
||||
if state != nil {
|
||||
roundUsed = state.RoundUsed
|
||||
if state.MaxRounds > 0 {
|
||||
@@ -149,15 +165,23 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
if state.HasPlan() {
|
||||
modeText = "计划执行(有预定义步骤)"
|
||||
}
|
||||
activeDomain = strings.TrimSpace(state.ActiveToolDomain)
|
||||
activePacks = readExecuteActiveToolPacks(state)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds),
|
||||
"- 当前模式:"+modeText,
|
||||
)
|
||||
|
||||
// 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。
|
||||
// 2. 该锚点用于约束模型只推进当前步骤,避免退化成泛化 ReAct。
|
||||
// 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。
|
||||
if activeDomain == "" {
|
||||
lines = append(lines, "- 动态工具区:当前仅激活 context 管理工具。")
|
||||
} else if len(activePacks) == 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,未显式激活 packs。", activeDomain))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,packs=[%s]。", activeDomain, strings.Join(activePacks, ",")))
|
||||
}
|
||||
|
||||
if state != nil && state.HasPlan() {
|
||||
current, total := state.PlanProgress()
|
||||
lines = append(lines, "计划步骤锚点(强约束):")
|
||||
@@ -170,26 +194,41 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
if doneWhen == "" {
|
||||
doneWhen = "(未提供 done_when,需基于步骤目标给出可验证完成证据)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total))
|
||||
lines = append(lines, "- 当前步骤内容:"+stepContent)
|
||||
lines = append(lines, "- 当前步骤完成判定(done_when):"+doneWhen)
|
||||
lines = append(lines, "- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan")
|
||||
lines = append(lines, "- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据")
|
||||
lines = append(lines, "- 动作纪律3:禁止跳到后续步骤执行")
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total),
|
||||
"- 当前步骤内容:"+stepContent,
|
||||
"- 当前步骤完成判定(done_when):"+doneWhen,
|
||||
"- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。",
|
||||
"- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据。",
|
||||
"- 动作纪律3:禁止跳到后续步骤执行。",
|
||||
)
|
||||
} else {
|
||||
lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划")
|
||||
lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据")
|
||||
lines = append(lines,
|
||||
"- 当前计划步骤不可读;请先判断是否已完成全部计划。",
|
||||
"- 若已完成全部计划,输出 done 并给出 goal_check 证据。",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx); latestAnalyze != "" {
|
||||
lines = append(lines, "- 最近一次诊断:"+latestAnalyze)
|
||||
}
|
||||
if latestMutation := renderExecuteLatestMutationSummary(ctx); latestMutation != "" {
|
||||
lines = append(lines, "- 最近一次写操作:"+latestMutation)
|
||||
}
|
||||
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
|
||||
lines = append(lines, "- 目标任务类:"+taskClassText)
|
||||
}
|
||||
lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。")
|
||||
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。")
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
|
||||
lines = append(lines,
|
||||
"- 啥时候结束Loop:你可以根据工具调用记录自行判断。",
|
||||
"- 非目标:不重新粗排、不修改无关任务类。",
|
||||
)
|
||||
if roughBuildDone {
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。")
|
||||
}
|
||||
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。")
|
||||
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回“参数非法”,需先改参再继续。")
|
||||
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
|
||||
@@ -197,15 +236,27 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
|
||||
}
|
||||
}
|
||||
|
||||
if upsertRuntime := renderTaskClassUpsertRuntime(state); upsertRuntime != "" {
|
||||
lines = append(lines, "任务类写入运行态:")
|
||||
lines = append(lines, upsertRuntime)
|
||||
}
|
||||
|
||||
if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" {
|
||||
lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):")
|
||||
lines = append(lines, memoryText)
|
||||
}
|
||||
|
||||
// 兼容上层传入的执行指令;若为空则使用固定收口指令。
|
||||
latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx)
|
||||
latestMutation := renderExecuteLatestMutationSummary(ctx)
|
||||
if nextStep := renderExecuteNextStepHintV2(state, latestAnalyze, latestMutation, roughBuildDone); nextStep != "" {
|
||||
lines = append(lines, "下一步提示:")
|
||||
lines = append(lines, "- "+nextStep)
|
||||
}
|
||||
|
||||
instruction := strings.TrimSpace(runtimeUserPrompt)
|
||||
if instruction == "" {
|
||||
instruction = "请继续当前任务执行阶段,严格输出 JSON。"
|
||||
instruction = "请继续当前任务执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。"
|
||||
} else {
|
||||
instruction = firstExecuteLine(instruction)
|
||||
}
|
||||
@@ -214,8 +265,12 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderExecuteToolCatalogCompact 将工具 schema 渲染成简表,避免大段 JSON 示例占用上下文。
|
||||
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) string {
|
||||
// renderExecuteToolCatalogCompact 将当前 tool schemas 渲染为紧凑简表。
|
||||
//
|
||||
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
|
||||
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
|
||||
// 3. P1 阶段隐藏 min_context_switch,避免模型误用已禁能力。
|
||||
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -225,36 +280,79 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) str
|
||||
}
|
||||
|
||||
lines := []string{"可用工具(简表):"}
|
||||
for i, schemaItem := range schemas {
|
||||
index := 0
|
||||
for _, schemaItem := range schemas {
|
||||
name := strings.TrimSpace(schemaItem.Name)
|
||||
desc := strings.TrimSpace(schemaItem.Desc)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if shouldHideMinContextSwitchForP1(state, name) {
|
||||
continue
|
||||
}
|
||||
|
||||
index++
|
||||
desc := strings.TrimSpace(schemaItem.Desc)
|
||||
if desc == "" {
|
||||
desc = "无描述"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s:%s", i+1, name, desc))
|
||||
lines = append(lines, fmt.Sprintf("%d. %s:%s", index, name, desc))
|
||||
|
||||
doc := parseExecuteToolSchema(schemaItem.SchemaText)
|
||||
paramSummary := renderExecuteToolParamSummary(doc.Parameters)
|
||||
lines = append(lines, " 参数:"+paramSummary)
|
||||
|
||||
returnType, returnSample := renderExecuteToolReturnHint(name)
|
||||
lines = append(lines, " 返回类型:"+returnType)
|
||||
lines = append(lines, " 返回示例:"+returnSample)
|
||||
if shouldRenderExecuteToolReturnSample(name) {
|
||||
lines = append(lines, " 返回示例:"+returnSample)
|
||||
}
|
||||
if callSample := renderExecuteToolCallHint(name); strings.TrimSpace(callSample) != "" {
|
||||
lines = append(lines, " 调用示例:"+callSample)
|
||||
}
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderExecuteToolReturnHint 返回工具的返回类型 + 最小示例。
|
||||
func shouldRenderExecuteToolReturnSample(toolName string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "query_available_slots",
|
||||
"query_target_tasks",
|
||||
"queue_pop_head",
|
||||
"queue_status",
|
||||
"queue_apply_head_move",
|
||||
"queue_skip_head",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"analyze_health",
|
||||
"analyze_rhythm",
|
||||
"analyze_tolerance",
|
||||
"upsert_task_class":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolCallHint(toolName string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "upsert_task_class":
|
||||
return `{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,11],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
|
||||
returnType = "string(自然语言文本)"
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "get_overview":
|
||||
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)..."
|
||||
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(已过滤课程)..."
|
||||
case "get_task_info":
|
||||
return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
return returnType, "[35] 第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
case "query_available_slots":
|
||||
return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
|
||||
case "query_target_tasks":
|
||||
@@ -276,7 +374,7 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
case "swap":
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2个任务全部成功。(单次最多2条)"
|
||||
return returnType, "批量移动完成,2 个任务全部成功。"
|
||||
case "spread_even":
|
||||
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
|
||||
case "min_context_switch":
|
||||
@@ -287,6 +385,14 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return "string(JSON字符串)", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}`
|
||||
case "web_fetch":
|
||||
return "string(JSON字符串)", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}`
|
||||
case "analyze_health":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
|
||||
case "analyze_rhythm":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
|
||||
case "analyze_tolerance":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
|
||||
case "upsert_task_class":
|
||||
return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
|
||||
default:
|
||||
return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。"
|
||||
}
|
||||
@@ -353,12 +459,11 @@ func renderExecuteToolParamSummary(parameters map[string]any) string {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// collectExecuteLoopRecords 从历史中提取 ReAct 记录。
|
||||
// collectExecuteLoopRecords 从 history 里提取 thought + tool_call + observation 三元组。
|
||||
//
|
||||
// 提取策略:
|
||||
// 1. 以 assistant tool_call 消息为主键;
|
||||
// 2. 关联同 ToolCallID 的 tool result 作为 observation;
|
||||
// 3. 向前回溯最近一条 assistant 文本消息作为 thought/reason。
|
||||
// 1. 以 assistant tool_call 为主记录。
|
||||
// 2. 用 ToolCallID 去关联 tool observation,保证同轮绑定。
|
||||
// 3. thought 只向前取最近一条 assistant 纯文本消息,不跨越到更早的工具调用之前做复杂回溯。
|
||||
func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
@@ -381,12 +486,14 @@ func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
thought := findExecuteThoughtBefore(history, i)
|
||||
for _, call := range msg.ToolCalls {
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if toolName == "" {
|
||||
toolName = "unknown_tool"
|
||||
}
|
||||
|
||||
toolArgs := compactExecuteText(call.Function.Arguments, 160)
|
||||
if toolArgs == "" {
|
||||
toolArgs = "{}"
|
||||
@@ -424,10 +531,9 @@ func findExecuteThoughtBefore(history []*schema.Message, index int) string {
|
||||
continue
|
||||
}
|
||||
content := compactExecuteText(msg.Content, 140)
|
||||
if content == "" {
|
||||
continue
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
return content
|
||||
}
|
||||
return "(未记录)"
|
||||
}
|
||||
@@ -456,18 +562,116 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// conversationTurn 表示对话历史中的一轮交互(user 或 assistant speak)。
|
||||
type conversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"analyze_health": {},
|
||||
"analyze_rhythm": {},
|
||||
"analyze_tolerance": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
// collectExecuteConversationTurns 从历史消息中提取 user + assistant speak 对话流。
|
||||
func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"place": {},
|
||||
"move": {},
|
||||
"swap": {},
|
||||
"batch_move": {},
|
||||
"unplace": {},
|
||||
"queue_apply_head_move": {},
|
||||
"spread_even": {},
|
||||
"min_context_switch": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
func findExecuteLatestToolRecord(ctx *newagentmodel.ConversationContext, allowSet map[string]struct{}) (executeLatestToolRecord, bool) {
|
||||
if ctx == nil || len(allowSet) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
toolNameByCallID := make(map[string]string, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
callID := strings.TrimSpace(call.ID)
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if callID == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
toolNameByCallID[callID] = toolName
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
callID := strings.TrimSpace(msg.ToolCallID)
|
||||
if callID == "" {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(toolNameByCallID[callID])
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowSet[toolName]; !ok {
|
||||
continue
|
||||
}
|
||||
return executeLatestToolRecord{
|
||||
ToolName: toolName,
|
||||
Observation: summarizeExecuteToolObservation(msg.Content),
|
||||
}, true
|
||||
}
|
||||
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
func summarizeExecuteToolObservation(raw string) string {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return "无返回内容。"
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(content), &payload); err == nil && len(payload) > 0 {
|
||||
if toolName := strings.TrimSpace(asExecuteString(payload["tool"])); toolName == "analyze_health" {
|
||||
return summarizeExecuteAnalyzeHealthObservationV2(payload)
|
||||
}
|
||||
for _, key := range []string{"result", "message", "reason", "error"} {
|
||||
if text := strings.TrimSpace(asExecuteString(payload[key])); text != "" {
|
||||
return compactExecuteText(text, 120)
|
||||
}
|
||||
}
|
||||
if success, ok := payload["success"].(bool); ok {
|
||||
if success {
|
||||
return "执行成功。"
|
||||
}
|
||||
return "执行失败。"
|
||||
}
|
||||
}
|
||||
|
||||
return compactExecuteText(content, 120)
|
||||
}
|
||||
|
||||
// collectExecuteConversationTurns 只提取 user 和 assistant speak。
|
||||
//
|
||||
// 提取规则:
|
||||
// 1. 只保留 user 消息(排除 correction prompt)和 assistant speak 消息(非空 Content 且无 ToolCalls);
|
||||
// 2. 全量保留,不再限制轮数和单条长度(token 预算由 execute 层统一管理);
|
||||
// 3. 返回的条目按原始时间顺序排列。
|
||||
// 1. 过滤 correction prompt,避免把后端纠错提示伪装成用户真实意图。
|
||||
// 2. 过滤 assistant tool_call 消息,避免 msg1 和 msg2 重复。
|
||||
// 3. 保持原始顺序,不在这里裁剪长度。
|
||||
func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
@@ -556,11 +760,44 @@ func renderExecuteTaskClassIDs(state *newagentmodel.CommonState) string {
|
||||
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
// renderExecuteMemoryContext 提取 execute 阶段要注入 msg3 的记忆文本。
|
||||
//
|
||||
// 1. 只读取统一的 memory_context,避免把其他 pinned block 误塞进 prompt。
|
||||
// 2. 为空时直接返回空串,保持 msg3 干净。
|
||||
// 3. 复用统一记忆渲染逻辑,保证各阶段记忆入口一致。
|
||||
// renderExecuteMemoryContext 复用统一记忆入口,避免 execute 私自拼接其他 pinned block。
|
||||
func renderExecuteMemoryContext(ctx *newagentmodel.ConversationContext) string {
|
||||
return renderUnifiedMemoryContext(ctx)
|
||||
}
|
||||
|
||||
func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string {
|
||||
if state == nil || !state.TaskClassUpsertLastTried {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 4)
|
||||
if state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 成功。")
|
||||
} else {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 失败。")
|
||||
}
|
||||
if state.TaskClassUpsertConsecutiveFailures > 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 连续失败次数:%d", state.TaskClassUpsertConsecutiveFailures))
|
||||
}
|
||||
if len(state.TaskClassUpsertLastIssues) > 0 {
|
||||
lines = append(lines, "- 需要优先处理 validation.issues:")
|
||||
for _, issue := range state.TaskClassUpsertLastIssues {
|
||||
trimmed := strings.TrimSpace(issue)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " - "+trimmed)
|
||||
}
|
||||
}
|
||||
if !state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 在 issues 处理完之前,不要用 done 收口。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func shouldHideMinContextSwitchForP1(state *newagentmodel.CommonState, toolName string) bool {
|
||||
if strings.TrimSpace(toolName) != "min_context_switch" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
33
backend/newAgent/prompt/execute_context_health.go
Normal file
33
backend/newAgent/prompt/execute_context_health.go
Normal 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))
|
||||
}
|
||||
106
backend/newAgent/prompt/execute_context_health_v2.go
Normal file
106
backend/newAgent/prompt/execute_context_health_v2.go
Normal 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, ",")
|
||||
}
|
||||
104
backend/newAgent/prompt/execute_next_step_hint_v2.go
Normal file
104
backend/newAgent/prompt/execute_next_step_hint_v2.go
Normal 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 ""
|
||||
}
|
||||
318
backend/newAgent/prompt/execute_rule_packs.go
Normal file
318
backend/newAgent/prompt/execute_rule_packs.go
Normal 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=confirm;continue + 写工具无效。
|
||||
- 输出格式固定:先 <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。
|
||||
- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。
|
||||
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModePlanPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModePlan,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为计划执行模式:必须围绕当前计划步骤推进。
|
||||
- 未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。
|
||||
- next_plan / done 时,goal_check 必须是字符串,并对照 done_when 给出完成证据。
|
||||
- 禁止跳步执行后续计划。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModeReActPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModeReAct,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。
|
||||
- 如果关键事实无法通过工具补齐,优先 ask_user,不做猜测落库。
|
||||
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteSchedulePack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainSchedule,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。
|
||||
- existing 只作事实参考;真正可调对象优先看 suggested。
|
||||
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleMutationPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleMutation,
|
||||
Content: strings.TrimSpace(`
|
||||
- mutation 包负责真正落日程写操作:place / move / swap / batch_move / unplace。
|
||||
- 写操作必须走 action=confirm;不要在 continue 里偷跑写工具。
|
||||
- 若是主动优化链路,优先在后端给出的合法候选中选择,不要自己再全窗搜索新坑位。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteQueueMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroQueue,
|
||||
Content: strings.TrimSpace(`
|
||||
- queue 包适合“按同一条件逐个处理一批任务”的场景,例如把所有早八任务依次挪走。
|
||||
- query_target_tasks 可结合 enqueue=true 先把候选任务入队,再用 queue_pop_head / queue_apply_head_move / queue_skip_head 顺序处理。
|
||||
- 当你需要连续处理多条相似任务时,优先走 queue,避免把整批任务细节长期堆在上下文里。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleWebPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleWeb,
|
||||
Content: strings.TrimSpace(`
|
||||
- web 包只用于补充通用学习资料或通识信息,不用于捏造个人时间、考试时间、DDL 或排程事实。
|
||||
- web_search 先粗搜,web_fetch 再抓正文;不确定时宁可不用,也不要把网页结果当成排程事实直接写入。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainTaskClass,
|
||||
Content: strings.TrimSpace(`
|
||||
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
|
||||
- excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteRoughDoneMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroRoughDone,
|
||||
Content: strings.TrimSpace(`
|
||||
- 已有 rough_build_done:本轮以微调为主,不要把任务重新当成“未排入”再全量 place。
|
||||
- 若当前问题已经可接受,应优先收口,不要为了追求完美继续反复局部打磨。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassRetryMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroTaskRetry,
|
||||
Content: strings.TrimSpace(`
|
||||
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
|
||||
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInjectExecuteDiagLoopPack(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) bool {
|
||||
if state == nil || !hasExecuteRoughBuildDone(ctx) {
|
||||
return false
|
||||
}
|
||||
if normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) != "schedule" {
|
||||
return false
|
||||
}
|
||||
activePacks := readExecuteActiveToolPacks(state)
|
||||
return hasExecutePack(activePacks, newagenttools.ToolPackAnalyze) &&
|
||||
hasExecutePack(activePacks, newagenttools.ToolPackMutation)
|
||||
}
|
||||
27
backend/newAgent/prompt/execute_rule_packs_health.go
Normal file
27
backend/newAgent/prompt/execute_rule_packs_health.go
Normal 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 次之;不要做全窗口搜索。`),
|
||||
}
|
||||
}
|
||||
@@ -8,58 +8,46 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const planSystemPrompt = `
|
||||
你是 SmartMate 的规划器。
|
||||
你的职责不是直接执行任务,而是先把用户意图拆成一组清晰、稳定、可逐步执行的自然语言计划,并严格按后端约定的 JSON 协议输出。
|
||||
const planSystemPromptCore = `
|
||||
你是 SmartMate 的规划器(Planner),只负责规划,不负责执行。
|
||||
|
||||
请遵守以下规则:
|
||||
1. 只负责规划,不要假装已经调用了工具,也不要伪造执行结果。
|
||||
2. 每一轮只推进一步规划;如果信息不足,应明确转成 ask_user,而不是继续硬猜。
|
||||
3. 若当前计划仍不完整,就继续围绕当前任务补全计划,不要跳去执行细节。
|
||||
4. 若你认为计划已经完整可执行,请返回 action=plan_done,并附带完整 plan_steps。
|
||||
5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。
|
||||
6. 输出格式:先输出一行 <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. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
|
||||
2. 事实边界:禁止伪造工具调用和执行结果。
|
||||
|
||||
你会看到:
|
||||
- 当前阶段与轮次信息
|
||||
- 已有完整 plan(如果之前已经规划过)
|
||||
- 当前步骤(如果已存在)
|
||||
- 置顶上下文块
|
||||
- 可用工具摘要
|
||||
- 历史对话
|
||||
|
||||
请基于这些输入继续规划,而不是重复忽略既有 plan。
|
||||
`
|
||||
规划规则:
|
||||
1. 每轮只做一次决策(continue / ask_user / plan_done)。
|
||||
2. 信息足够时优先 plan_done;信息不足时才 ask_user,且只问最小必要问题。
|
||||
3. action=plan_done 时必须返回完整 plan_steps(不是增量)。
|
||||
4. plan_steps 使用自然语言描述目标与完成判定,不写执行结果。
|
||||
5. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids。
|
||||
6. 可在 plan_done 时附加 context_hook(执行阶段注入建议);规划阶段禁止调用 context_tools_add/remove。`
|
||||
|
||||
// BuildPlanSystemPrompt 返回规划阶段系统提示词。
|
||||
func BuildPlanSystemPrompt() string {
|
||||
return strings.TrimSpace(planSystemPrompt)
|
||||
parts := []string{
|
||||
strings.TrimSpace(planSystemPromptCore),
|
||||
BuildPlanDecisionContractText(),
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
// BuildPlanMessages 组装规划阶段的 messages。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 state + context 收敛成统一 4 段式规划阶段模型输入;
|
||||
// 2. 不负责解析模型输出,也不负责判断规划质量;
|
||||
// 3. msg3 中的状态文本由本函数显式传入,确保统一骨架下仍能看到完整计划与阶段信息。
|
||||
// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。
|
||||
// 2. msg1 展示真实对话,msg2 展示规划工作区,msg3 仅给最小执行指令与用户本轮输入。
|
||||
// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。
|
||||
func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildPlanSystemPrompt(),
|
||||
Msg1Content: buildPlanConversationMessage(ctx),
|
||||
Msg2Content: buildPlanWorkspace(state),
|
||||
Msg3Suffix: BuildPlanUserPrompt(state, userInput),
|
||||
Msg3Role: schema.User,
|
||||
SystemPrompt: BuildPlanSystemPrompt(),
|
||||
Msg1Content: buildPlanConversationMessage(ctx),
|
||||
Msg2Content: buildPlanWorkspace(state),
|
||||
Msg3Suffix: BuildPlanUserPrompt(state, userInput),
|
||||
Msg3Role: schema.User,
|
||||
SkipBaseSystemPrompt: true,
|
||||
UseLiteToolCatalogMsg: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -68,9 +56,9 @@ func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.Conv
|
||||
func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请继续当前任务的规划阶段,严格按 SMARTFLOW_DECISION 标签格式输出。\n")
|
||||
sb.WriteString("目标:围绕最近对话和规划工作区信息,产出一份稳定、可执行的自然语言计划;若关键信息不足,请明确 ask_user。\n\n")
|
||||
sb.WriteString(BuildPlanDecisionContractText())
|
||||
sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
|
||||
sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n")
|
||||
sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
@@ -85,40 +73,30 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
|
||||
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
|
||||
func BuildPlanDecisionContractText() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(两阶段格式):
|
||||
输出协议(唯一口径):
|
||||
1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
|
||||
2. 再输出:给用户看的自然语言正文
|
||||
|
||||
先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。
|
||||
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
|
||||
|
||||
JSON 字段说明:
|
||||
JSON 字段:
|
||||
- action:只能是 %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- complexity:任务复杂度,只能是 simple / moderate / complex
|
||||
- plan_steps:仅当 action=%s 时允许返回;返回时必须是完整计划,不是增量
|
||||
- complexity:只能是 simple / moderate / complex
|
||||
- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划
|
||||
- plan_steps[].content:步骤正文,必填
|
||||
- plan_steps[].done_when:可选,建议写"什么情况下算这一步做完"
|
||||
- needs_rough_build:仅当满足粗排识别规则时为 true,否则省略;为 true 时后端自动运行粗排算法
|
||||
- task_class_ids:needs_rough_build=true 时必填,从上下文"任务类 ID"字段读取
|
||||
- plan_steps[].done_when:可选,建议写完成判定
|
||||
- needs_rough_build:仅满足粗排识别条件时为 true,否则省略
|
||||
- task_class_ids:needs_rough_build=true 时必填,从上下文读取
|
||||
- context_hook:可选,仅用于给 execute 阶段提供注入建议
|
||||
- context_hook.domain:schedule / taskclass
|
||||
- context_hook.packs:string 数组,可选;core 固定注入,不要填写 core
|
||||
- context_hook.reason:可选,说明为何建议该注入
|
||||
|
||||
注意:JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
|
||||
|
||||
合法示例:
|
||||
|
||||
<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>
|
||||
计划已经整理好了,我先给你确认一下。
|
||||
`,
|
||||
注意:
|
||||
- JSON 中不要包含 speak 字段
|
||||
- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)`,
|
||||
newagentmodel.PlanActionContinue,
|
||||
newagentmodel.PlanActionAskUser,
|
||||
newagentmodel.PlanActionDone,
|
||||
newagentmodel.PlanActionDone,
|
||||
newagentmodel.PlanActionContinue,
|
||||
newagentmodel.PlanActionAskUser,
|
||||
newagentmodel.PlanActionDone,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -127,7 +127,25 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string {
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(";日期范围:%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(";排除星期:%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(";语义画像:%s/%s/%s",
|
||||
planSemanticValue(tc.SubjectType),
|
||||
planSemanticValue(tc.DifficultyLevel),
|
||||
planSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func planSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package newagentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
@@ -45,6 +46,13 @@ type StageMessagesConfig struct {
|
||||
// Msg3Role 指定第 4 条消息的角色。
|
||||
// Execute 继续使用 system,其余节点一般使用 user。
|
||||
Msg3Role schema.RoleType
|
||||
|
||||
// SkipBaseSystemPrompt 为 true 时,msg0 只使用节点自己的 SystemPrompt,
|
||||
// 不再拼接 ConversationContext.SystemPrompt。
|
||||
SkipBaseSystemPrompt bool
|
||||
|
||||
// UseLiteToolCatalogMsg 为 true 时,msg0 工具目录采用轻量模式(仅名称与职责)。
|
||||
UseLiteToolCatalogMsg bool
|
||||
}
|
||||
|
||||
// buildUnifiedStageMessages 组装统一 4 段式消息骨架。
|
||||
@@ -58,7 +66,7 @@ func buildUnifiedStageMessages(
|
||||
ctx *newagentmodel.ConversationContext,
|
||||
config StageMessagesConfig,
|
||||
) []*schema.Message {
|
||||
msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx)
|
||||
msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx, config.SkipBaseSystemPrompt, config.UseLiteToolCatalogMsg)
|
||||
msg1 := buildUnifiedMsg1(config.Msg1Content)
|
||||
msg2 := buildUnifiedMsg2(config.Msg2Content)
|
||||
msg3 := buildUnifiedMsg3(ctx, config)
|
||||
@@ -85,19 +93,72 @@ func buildUnifiedMsg3Message(content string, role schema.RoleType) *schema.Messa
|
||||
// 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定;
|
||||
// 2. 若当前节点注入了工具 schema,则附加紧凑工具目录;
|
||||
// 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。
|
||||
func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string {
|
||||
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
func buildUnifiedMsg0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext, skipBaseSystemPrompt bool, useLiteToolCatalog bool) string {
|
||||
base := ""
|
||||
if skipBaseSystemPrompt {
|
||||
base = strings.TrimSpace(stageSystemPrompt)
|
||||
} else {
|
||||
base = strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
}
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 助手,请继续当前阶段。"
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx)
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, nil)
|
||||
if useLiteToolCatalog {
|
||||
toolCatalog = renderUnifiedToolCatalogLite(ctx)
|
||||
}
|
||||
if toolCatalog == "" {
|
||||
return base
|
||||
}
|
||||
return base + "\n\n" + toolCatalog
|
||||
}
|
||||
|
||||
// renderUnifiedToolCatalogLite 渲染统一阶段可用工具的轻量目录。
|
||||
//
|
||||
// 1. 只展示工具名和一句话职责,避免把 execute 的参数/返回示例污染到 plan/chat/deliver。
|
||||
// 2. 目录信息仅用于“能力边界感知”,不承担具体参数指导。
|
||||
// 3. 当工具数量过多时保留前若干项并给出省略提示,控制 msg0 体积。
|
||||
func renderUnifiedToolCatalogLite(ctx *newagentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxItems = 18
|
||||
lines := []string{"当前可用工具(轻量目录):"}
|
||||
added := 0
|
||||
|
||||
for _, item := range schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
desc := strings.TrimSpace(item.Desc)
|
||||
if desc == "" {
|
||||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- %s:%s", name, desc))
|
||||
}
|
||||
added++
|
||||
if added >= maxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(schemas) > added {
|
||||
lines = append(lines, fmt.Sprintf("- 其余 %d 个工具已省略(按需再看)。", len(schemas)-added))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildUnifiedMsg1 返回节点自行提供的历史视图。
|
||||
//
|
||||
// 说明:
|
||||
|
||||
@@ -17,6 +17,10 @@ var (
|
||||
// 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。
|
||||
decisionTagRegex = regexp.MustCompile(
|
||||
`(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`)
|
||||
// decisionTagHeadRegex 仅用于识别“起始标签是否已经出现”。
|
||||
// 目的:避免模型已经输出了 <SMARTFLOW_DECISION 开头但尚未输出闭合标签时,
|
||||
// 被长度阈值误判为 fallback(即“假截断”)。
|
||||
decisionTagHeadRegex = regexp.MustCompile(`(?i)<\s*SMARTFLOW_DECISION\b`)
|
||||
)
|
||||
|
||||
// StreamDecisionResult 描述解析器的最终输出状态。
|
||||
@@ -25,6 +29,14 @@ type StreamDecisionResult struct {
|
||||
// 调用方应使用 infrallm.ParseJSONObject[T] 将其解析为具体决策类型。
|
||||
DecisionJSON string
|
||||
|
||||
// BeforeText 是 <SMARTFLOW_DECISION> 标签之前的自然语言前言。
|
||||
// 仅用于“标签后正文为空”时的兜底展示,不参与 JSON 解析。
|
||||
BeforeText string
|
||||
|
||||
// AfterText 是 </SMARTFLOW_DECISION> 标签之后的自然语言正文。
|
||||
// 这是主协议约定的用户可见文本来源。
|
||||
AfterText string
|
||||
|
||||
// Fallback=true 表示流中未找到决策标签(超过 500 字符阈值),
|
||||
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
|
||||
Fallback bool
|
||||
@@ -51,6 +63,8 @@ type StreamDecisionParser struct {
|
||||
buf strings.Builder
|
||||
decisionFound bool
|
||||
decisionJSON string
|
||||
beforeText string
|
||||
afterText string
|
||||
rawBuf string // 用于 fallback/correction
|
||||
}
|
||||
|
||||
@@ -81,8 +95,13 @@ func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool,
|
||||
text := p.buf.String()
|
||||
match := decisionTagRegex.FindStringSubmatchIndex(text)
|
||||
if match == nil {
|
||||
// 标签尚未完整,检查 fallback 阈值。
|
||||
// 1. 标签尚未完整,检查 fallback 阈值。
|
||||
// 2. 仅当“完全没有出现起始标签”时才允许 fallback。
|
||||
// 3. 若已经出现起始标签但还没闭合,则继续等待后续 chunk,避免早退。
|
||||
if len(text) > 500 {
|
||||
if decisionTagHeadRegex.MatchString(text) {
|
||||
return "", false, nil
|
||||
}
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签")
|
||||
@@ -110,13 +129,18 @@ func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool,
|
||||
p.decisionJSON = jsonStr
|
||||
p.rawBuf = text
|
||||
|
||||
// 提取标签之后的文本作为 visible。
|
||||
// 1. 同时提取标签前/标签后的自然语言片段。
|
||||
// 2. 标签后正文仍然作为主协议 visible 返回,保持现有流式链路不变。
|
||||
// 3. 标签前前言只记入 Result,供 execute 在“后文为空”时兜底补发。
|
||||
fullMatch := groups[0]
|
||||
tagEndIdx := strings.Index(text, fullMatch)
|
||||
if tagEndIdx >= 0 {
|
||||
beforeTag := strings.TrimSpace(text[:tagEndIdx])
|
||||
afterTag := text[tagEndIdx+len(fullMatch):]
|
||||
afterTag = strings.TrimPrefix(afterTag, "\r\n")
|
||||
afterTag = strings.TrimPrefix(afterTag, "\n")
|
||||
p.beforeText = beforeTag
|
||||
p.afterText = afterTag
|
||||
return afterTag, true, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +162,8 @@ func (p *StreamDecisionParser) DecisionJSON() string {
|
||||
func (p *StreamDecisionParser) Result() *StreamDecisionResult {
|
||||
r := &StreamDecisionResult{
|
||||
DecisionJSON: p.decisionJSON,
|
||||
BeforeText: p.beforeText,
|
||||
AfterText: p.afterText,
|
||||
RawBuffer: p.rawBuf,
|
||||
}
|
||||
if p.rawBuf != "" && p.decisionJSON == "" {
|
||||
|
||||
37
backend/newAgent/tools/active_optimize.go
Normal file
37
backend/newAgent/tools/active_optimize.go
Normal 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
|
||||
}
|
||||
305
backend/newAgent/tools/context_tools.go
Normal file
305
backend/newAgent/tools/context_tools.go
Normal 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 支持可选 packs;taskclass 目前不支持可选 packs。
|
||||
func NewContextToolsAddHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
|
||||
if domain == "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:domain 仅支持 schedule/taskclass",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
|
||||
if mode == "" {
|
||||
mode = "replace"
|
||||
}
|
||||
if mode != "replace" && mode != "merge" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: "参数非法:mode 仅支持 replace/merge",
|
||||
ErrorCode: "invalid_mode",
|
||||
})
|
||||
}
|
||||
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
// schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。
|
||||
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
|
||||
packs = ResolveEffectiveToolPacks(domain, nil)
|
||||
}
|
||||
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: true,
|
||||
Action: "activate",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Mode: mode,
|
||||
Message: "已激活工具域,可继续调用对应业务工具。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储;
|
||||
// 2. all=true 表示清空动态区业务工具;domain+packs 表示移除该域下指定二级包;
|
||||
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。
|
||||
func NewContextToolsRemoveHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
all := readContextToolBool(args["all"])
|
||||
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
|
||||
// 兼容写法:domain=all 视为清空全部。
|
||||
if domainRaw == "all" {
|
||||
all = true
|
||||
}
|
||||
if all {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "clear_all",
|
||||
All: true,
|
||||
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
|
||||
})
|
||||
}
|
||||
|
||||
domain := NormalizeToolDomain(domainRaw)
|
||||
if domain == "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
if len(packs) > 0 {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate_packs",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Message: "已移除指定工具包。",
|
||||
})
|
||||
}
|
||||
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate",
|
||||
Domain: domain,
|
||||
Message: "已移除指定工具域。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" {
|
||||
return nil, "invalid_domain", "参数非法:domain 非法"
|
||||
}
|
||||
if len(packs) == 0 {
|
||||
return nil, "", ""
|
||||
}
|
||||
|
||||
if normalizedDomain == ToolDomainTaskClass {
|
||||
return nil, "unsupported_packs_for_domain", "参数非法:taskclass 暂不支持 packs"
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(packs))
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
for _, raw := range packs {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
pack := NormalizeToolPack(normalizedDomain, trimmed)
|
||||
if pack == "" {
|
||||
return nil, "invalid_pack", "参数非法:存在不支持的 pack"
|
||||
}
|
||||
if IsFixedToolPack(normalizedDomain, pack) {
|
||||
if forRemove {
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 remove"
|
||||
}
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 add"
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
normalized = append(normalized, pack)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, "invalid_pack", "参数非法:packs 为空或无效"
|
||||
}
|
||||
return normalized, "", ""
|
||||
}
|
||||
|
||||
func readContextToolString(raw any) string {
|
||||
text, _ := raw.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readContextToolStringSlice(raw any) []string {
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(text, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readContextToolBool(raw any) bool {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
value := strings.ToLower(strings.TrimSpace(v))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
case float64:
|
||||
return v != 0
|
||||
case float32:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
case int8:
|
||||
return v != 0
|
||||
case int16:
|
||||
return v != 0
|
||||
case int32:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalContextToolsAddResult(result contextToolsAddResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -10,36 +10,62 @@ import (
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
|
||||
)
|
||||
|
||||
// ToolHandler 是所有工具的统一执行签名。
|
||||
// ToolHandler 约定所有工具的统一执行签名。
|
||||
// 职责边界:
|
||||
// 1. 负责消费当前 ScheduleState 与模型传入参数;
|
||||
// 2. 返回统一 string 结果,供 execute 节点写回 observation;
|
||||
// 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。
|
||||
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
|
||||
|
||||
// ToolSchemaEntry 是注入给模型的工具说明快照。
|
||||
// ToolSchemaEntry 描述注入给模型的工具快照。
|
||||
type ToolSchemaEntry struct {
|
||||
Name string
|
||||
Desc string
|
||||
SchemaText string
|
||||
}
|
||||
|
||||
// DefaultRegistryDeps 描述默认工具注册表可选依赖。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这层依赖注入先为后续 websearch / memory 工具预留统一入口;
|
||||
// 2. 当前即便部分依赖暂未使用,也不应让业务侧再自行 new 底层 Infra;
|
||||
// 3. 后续新增读工具时,应优先在这里扩展依赖而不是走包级全局变量。
|
||||
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
|
||||
// 职责边界:
|
||||
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
|
||||
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new;
|
||||
// 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。
|
||||
type DefaultRegistryDeps struct {
|
||||
RAGRuntime infrarag.Runtime
|
||||
|
||||
// WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。
|
||||
// WebSearchProvider 为 nil 时,web_search / web_fetch 仍会注册,
|
||||
// 但 handler 会返回“暂未启用”的只读 observation,不阻断主流程。
|
||||
WebSearchProvider web.SearchProvider
|
||||
|
||||
// TaskClassWriteDeps 供 upsert_task_class 调用持久化层。
|
||||
TaskClassWriteDeps TaskClassWriteDeps
|
||||
}
|
||||
|
||||
// ToolRegistry 管理工具注册、查找与执行。
|
||||
// ToolRegistry 管理工具注册、过滤与执行。
|
||||
type ToolRegistry struct {
|
||||
handlers map[string]ToolHandler
|
||||
schemas []ToolSchemaEntry
|
||||
deps DefaultRegistryDeps
|
||||
}
|
||||
|
||||
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
|
||||
// 设计说明:
|
||||
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
|
||||
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
|
||||
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
|
||||
var temporaryDisabledTools = map[string]bool{
|
||||
"min_context_switch": true,
|
||||
"spread_even": true,
|
||||
"analyze_load": true,
|
||||
"analyze_subjects": true,
|
||||
"analyze_context": true,
|
||||
"analyze_tolerance": true,
|
||||
}
|
||||
|
||||
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
|
||||
func IsTemporarilyDisabledTool(name string) bool {
|
||||
return temporaryDisabledTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// NewToolRegistry 创建空注册表。
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return NewToolRegistryWithDeps(DefaultRegistryDeps{})
|
||||
@@ -65,7 +91,14 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
|
||||
}
|
||||
|
||||
// Execute 执行指定工具。
|
||||
// 职责边界:
|
||||
// 1. 这里只负责找到 handler 并调用;
|
||||
// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler;
|
||||
// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。
|
||||
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string {
|
||||
if r.IsToolTemporarilyDisabled(toolName) {
|
||||
return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
|
||||
}
|
||||
handler, ok := r.handlers[toolName]
|
||||
if !ok {
|
||||
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、"))
|
||||
@@ -73,41 +106,126 @@ func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, a
|
||||
return handler(state, args)
|
||||
}
|
||||
|
||||
// HasTool 检查工具是否已注册。
|
||||
// HasTool 判断工具是否已注册且当前可见。
|
||||
func (r *ToolRegistry) HasTool(name string) bool {
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
return false
|
||||
}
|
||||
_, ok := r.handlers[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ToolNames 返回已注册工具名(按 schema 顺序)。
|
||||
// IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态。
|
||||
func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool {
|
||||
return IsTemporarilyDisabledTool(name)
|
||||
}
|
||||
|
||||
// ToolNames 返回当前可暴露给模型的工具名。
|
||||
func (r *ToolRegistry) ToolNames() []string {
|
||||
names := make([]string, 0, len(r.handlers))
|
||||
names := make([]string, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Schemas 返回 schema 快照。
|
||||
// Schemas 返回当前可暴露给模型的 schema 快照。
|
||||
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
|
||||
result := make([]ToolSchemaEntry, len(r.schemas))
|
||||
copy(result, r.schemas)
|
||||
result := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsWriteTool 判断工具是否是写工具(需要 confirm)。
|
||||
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
|
||||
// 职责边界:
|
||||
// 1. context_tools_add/remove 始终保留,用于动态区协议;
|
||||
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
|
||||
// 3. schedule 域支持按 pack 过滤;taskclass 目前只有 core。
|
||||
func (r *ToolRegistry) SchemasForActiveDomain(activeDomain string, activePacks []string) []ToolSchemaEntry {
|
||||
normalizedDomain := NormalizeToolDomain(activeDomain)
|
||||
effectivePacks := ResolveEffectiveToolPacks(normalizedDomain, activePacks)
|
||||
effectivePackSet := make(map[string]struct{}, len(effectivePacks))
|
||||
for _, pack := range effectivePacks {
|
||||
effectivePackSet[pack] = struct{}{}
|
||||
}
|
||||
|
||||
selected := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
continue
|
||||
}
|
||||
if IsContextManagementTool(name) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if normalizedDomain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
domain, pack, ok := ResolveToolDomainPack(name)
|
||||
if !ok {
|
||||
// 兼容历史未建档工具:仅在 schedule 域下继续暴露,避免突然失联。
|
||||
if normalizedDomain == ToolDomainSchedule {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if domain != normalizedDomain {
|
||||
continue
|
||||
}
|
||||
if IsFixedToolPack(domain, pack) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if _, exists := effectivePackSet[pack]; exists {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ToolSchemaEntry, len(selected))
|
||||
copy(result, selected)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsToolVisibleInDomain 判断某工具在当前动态区下是否应对模型可见。
|
||||
func (r *ToolRegistry) IsToolVisibleInDomain(activeDomain string, activePacks []string, toolName string) bool {
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range r.SchemasForActiveDomain(activeDomain, activePacks) {
|
||||
if strings.TrimSpace(item.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsWriteTool 判断工具是否属于写工具。
|
||||
func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
return writeTools[name]
|
||||
return writeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
|
||||
// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
|
||||
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
|
||||
return scheduleMutationTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// RequiresScheduleState 判断工具是否依赖 ScheduleState。
|
||||
// 调用目的:execute 节点据此决定是否允许在 ScheduleState 为 nil 时调用该工具。
|
||||
func (r *ToolRegistry) RequiresScheduleState(name string) bool {
|
||||
return !scheduleFreeTools[name]
|
||||
return !scheduleFreeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// ==================== 写工具集合 ====================
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
@@ -117,38 +235,83 @@ var writeTools = map[string]bool{
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
"upsert_task_class": true,
|
||||
}
|
||||
|
||||
// ==================== 不依赖 ScheduleState 的工具集合 ====================
|
||||
// 调用目的:这些工具不需要日程状态即可执行,execute 节点在 ScheduleState 为 nil 时允许调用。
|
||||
var scheduleMutationTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// scheduleFreeTools 描述“即使没有 ScheduleState 也能安全执行”的工具。
|
||||
var scheduleFreeTools = map[string]bool{
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
"upsert_task_class": true,
|
||||
ToolNameContextToolsAdd: true,
|
||||
ToolNameContextToolsRemove: true,
|
||||
}
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
|
||||
// NewDefaultRegistry 创建默认日程工具注册表。
|
||||
// NewDefaultRegistry 创建默认注册表。
|
||||
func NewDefaultRegistry() *ToolRegistry {
|
||||
return NewDefaultRegistryWithDeps(DefaultRegistryDeps{})
|
||||
}
|
||||
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认日程工具注册表。
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
|
||||
// 步骤化说明:
|
||||
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
|
||||
// 2. 再注册 schedule 域的读、诊断、写工具;
|
||||
// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。
|
||||
func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
r := NewToolRegistryWithDeps(deps)
|
||||
|
||||
// --- 读工具 ---
|
||||
r.Register("get_overview",
|
||||
"获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
|
||||
registerContextTools(r)
|
||||
registerScheduleReadTools(r)
|
||||
registerScheduleAnalyzeTools(r)
|
||||
registerScheduleMutationTools(r)
|
||||
registerTaskClassTools(r, deps)
|
||||
registerWebTools(r, deps)
|
||||
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func registerContextTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
ToolNameContextToolsAdd,
|
||||
"激活指定工具域,并可附带 schedule 二级包 packs。core 固定注入。",
|
||||
`{"name":"context_tools_add","parameters":{"domain":{"type":"string","required":true,"enum":["schedule","taskclass"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"mode":{"type":"string","enum":["replace","merge"]}}}`,
|
||||
NewContextToolsAddHandler(),
|
||||
)
|
||||
r.Register(
|
||||
ToolNameContextToolsRemove,
|
||||
"移除指定工具域、指定二级包,或清空全部业务工具域(all=true)。core 固定包不支持 remove。",
|
||||
`{"name":"context_tools_remove","parameters":{"domain":{"type":"string","enum":["schedule","taskclass","all"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"all":{"type":"bool"}}}`,
|
||||
NewContextToolsRemoveHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleReadTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"get_overview",
|
||||
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = args
|
||||
return schedule.GetOverview(state)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_range",
|
||||
"查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。",
|
||||
r.Register(
|
||||
"query_range",
|
||||
"查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。",
|
||||
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
day, ok := schedule.ArgsInt(args, "day")
|
||||
@@ -158,41 +321,41 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_available_slots",
|
||||
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。",
|
||||
r.Register(
|
||||
"query_available_slots",
|
||||
"查询候选空位池,适合 move 前筛落点。",
|
||||
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryAvailableSlots(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。",
|
||||
r.Register(
|
||||
"query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
|
||||
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryTargetTasks(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。",
|
||||
r.Register(
|
||||
"queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用。",
|
||||
`{"name":"queue_pop_head","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueuePopHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_status",
|
||||
"查看当前待处理队列状态(pending/current/completed/skipped)。",
|
||||
r.Register(
|
||||
"queue_status",
|
||||
"查看当前队列状态(pending/current/completed/skipped)。",
|
||||
`{"name":"queue_status","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueStatus(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("get_task_info",
|
||||
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
|
||||
r.Register(
|
||||
"get_task_info",
|
||||
"查看单个任务详情,包括类别、状态与落位。",
|
||||
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -202,10 +365,63 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.GetTaskInfo(state, taskID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- 写工具 ---
|
||||
r.Register("place",
|
||||
"将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。",
|
||||
func registerScheduleAnalyzeTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"analyze_load",
|
||||
"分析整体负载分布(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_load","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"granularity":{"type":"string","enum":["day","week","time_of_day"]},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeLoad(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_subjects",
|
||||
"分析学科分布与连贯性(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_subjects","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeSubjects(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_context",
|
||||
"分析上下文切换与相邻关系(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_context","parameters":{"day_from":{"type":"int"},"day_to":{"type":"int"},"detail":{"type":"string","enum":["summary","day_detail"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeContext(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_rhythm",
|
||||
"分析学习节奏与切换情况。",
|
||||
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeRhythm(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_tolerance",
|
||||
"分析局部容错与调整空间。",
|
||||
`{"name":"analyze_tolerance","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"min_usable_size":{"type":"int"},"min_daily_buffer":{"type":"int"},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeTolerance(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_health",
|
||||
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。",
|
||||
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeHealth(state, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleMutationTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"place",
|
||||
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
|
||||
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -223,9 +439,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Place(state, taskID, day, slotStart)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。",
|
||||
r.Register(
|
||||
"move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。",
|
||||
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -243,9 +459,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Move(state, taskID, newDay, newSlotStart)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("swap",
|
||||
"交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。",
|
||||
r.Register(
|
||||
"swap",
|
||||
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
|
||||
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskA, ok := schedule.ArgsInt(args, "task_a")
|
||||
@@ -259,9 +475,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Swap(state, taskA, taskB)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("batch_move",
|
||||
"原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。",
|
||||
r.Register(
|
||||
"batch_move",
|
||||
"原子性批量移动多个任务。moves 必填。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
moves, err := schedule.ArgsMoveList(args)
|
||||
@@ -271,25 +487,25 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.BatchMove(state, moves)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。",
|
||||
r.Register(
|
||||
"queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
|
||||
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueApplyHeadMove(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_skip_head",
|
||||
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。",
|
||||
r.Register(
|
||||
"queue_skip_head",
|
||||
"跳过当前队首任务,将其标记为 skipped。",
|
||||
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueSkipHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("min_context_switch",
|
||||
"在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。",
|
||||
r.Register(
|
||||
"min_context_switch",
|
||||
"在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
|
||||
@@ -299,9 +515,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.MinContextSwitch(state, taskIDs)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("spread_even",
|
||||
"在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。",
|
||||
r.Register(
|
||||
"spread_even",
|
||||
"在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
|
||||
@@ -311,9 +527,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.SpreadEven(state, taskIDs, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
|
||||
r.Register(
|
||||
"unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -323,33 +539,37 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Unplace(state, taskID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Web 搜索读工具 ---
|
||||
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation,不会阻断主流程;
|
||||
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。
|
||||
func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
r.Register(
|
||||
"upsert_task_class",
|
||||
"创建或更新任务类(统一写入口,必须 confirm)。auto 模式下 start_date/end_date 必须在 task_class 顶层字段。",
|
||||
`{"name":"upsert_task_class","parameters":{"id":{"type":"int"},"task_class":{"type":"object","required":true},"items":{"type":"array","items":{"type":"object"}},"source":{"type":"string"}}}`,
|
||||
NewTaskClassUpsertToolHandler(deps.TaskClassWriteDeps),
|
||||
)
|
||||
}
|
||||
|
||||
func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider)
|
||||
webFetchHandler := web.NewFetchToolHandler(web.NewFetcher())
|
||||
|
||||
r.Register("web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。",
|
||||
r.Register(
|
||||
"web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
|
||||
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webSearchHandler.Handle(args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。",
|
||||
r.Register(
|
||||
"web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
|
||||
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webFetchHandler.Handle(args)
|
||||
},
|
||||
)
|
||||
|
||||
// 按 schema name 排序,确保输出稳定。
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
1123
backend/newAgent/tools/schedule/analyze_health_candidates.go
Normal file
1123
backend/newAgent/tools/schedule/analyze_health_candidates.go
Normal file
File diff suppressed because it is too large
Load Diff
124
backend/newAgent/tools/schedule/analyze_health_decision_v2.go
Normal file
124
backend/newAgent/tools/schedule/analyze_health_decision_v2.go
Normal 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
|
||||
}
|
||||
}
|
||||
1478
backend/newAgent/tools/schedule/analyze_tools.go
Normal file
1478
backend/newAgent/tools/schedule/analyze_tools.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
)
|
||||
}
|
||||
}
|
||||
minContextProposals := make(map[int][]TaskSlot, len(afterByID))
|
||||
for taskID, after := range afterByID {
|
||||
minContextProposals[taskID] = []TaskSlot{after.Slot}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 4. 全量通过后再原子提交,避免半成品状态。
|
||||
clone := state.Clone()
|
||||
@@ -256,6 +263,13 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
||||
)
|
||||
}
|
||||
}
|
||||
spreadEvenProposals := make(map[int][]TaskSlot, len(afterByID))
|
||||
for taskID, after := range afterByID {
|
||||
spreadEvenProposals[taskID] = []TaskSlot{after.Slot}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, spreadEvenProposals); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
clone := state.Clone()
|
||||
for taskID, after := range afterByID {
|
||||
|
||||
184
backend/newAgent/tools/schedule/order_constraints.go
Normal file
184
backend/newAgent/tools/schedule/order_constraints.go
Normal 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
|
||||
}
|
||||
@@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
||||
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 默认 enqueue=true,让 LLM 优先走“逐项处理”而不是一次性批量组合;
|
||||
// 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路;
|
||||
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
|
||||
// 3. 入队仅保存 task_id,不复制任务全文,避免队列状态膨胀。
|
||||
queueInfo := (*queryTargetQueueInfo)(nil)
|
||||
@@ -566,7 +566,7 @@ func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTa
|
||||
Limit: limit,
|
||||
TaskIDSet: intSliceToSet(taskIDs),
|
||||
Category: strings.TrimSpace(readStringAny(args, "category", "")),
|
||||
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
|
||||
Enqueue: readBoolAnyWithDefault(args, false, "enqueue"),
|
||||
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ func GetOverview(state *ScheduleState) string {
|
||||
}
|
||||
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
parts := make([]string, len(tc.ExcludedDaysOfWeek))
|
||||
for i, d := range tc.ExcludedDaysOfWeek {
|
||||
parts[i] = fmt.Sprintf("%d", d)
|
||||
}
|
||||
line += fmt.Sprintf(" 排除星期=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,17 +20,25 @@ type TaskSlot struct {
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考。
|
||||
// 只记录影响排课决策的字段,不暴露数据库内部细节。
|
||||
// TaskClassMeta 是任务类级别的调度与认知画像元数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段;
|
||||
// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据;
|
||||
// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。
|
||||
type TaskClassMeta struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
|
||||
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
|
||||
StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD)
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
|
||||
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
|
||||
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几(1-7,空=无限制)
|
||||
StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD)
|
||||
SubjectType string `json:"subject_type,omitempty"` // "quantitative" | "memory" | "reading" | "mixed"
|
||||
DifficultyLevel string `json:"difficulty_level,omitempty"`
|
||||
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
|
||||
}
|
||||
|
||||
// ScheduleTask is a unified task representation in the tool state.
|
||||
@@ -51,6 +59,9 @@ type ScheduleTask struct {
|
||||
Duration int `json:"duration,omitempty"`
|
||||
// source=task_item only: TaskClass.ID,用于反查任务类约束。
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
// source=task_item only: 任务在所属任务类内的稳定顺序。
|
||||
// 该字段只用于写工具层的“同任务类内部顺序约束”,不直接暴露给 LLM 做决策。
|
||||
TaskOrder int `json:"task_order,omitempty"`
|
||||
// source=task_item only: TaskClass.ID for category lookup (internal alias).
|
||||
CategoryID int `json:"category_id,omitempty"`
|
||||
// source=event only: whether this slot allows embedding other tasks.
|
||||
@@ -68,7 +79,7 @@ type ScheduleTask struct {
|
||||
type ScheduleState struct {
|
||||
Window ScheduleWindow `json:"window"`
|
||||
Tasks []ScheduleTask `json:"tasks"`
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考
|
||||
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -56,6 +56,9 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
if err := validateSlotRange(slotStart, slotEnd); err != nil {
|
||||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||||
}
|
||||
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}); err != nil {
|
||||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 4. 冲突检测。
|
||||
conflict := findConflict(state, day, slotStart, slotEnd)
|
||||
@@ -136,6 +139,9 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
|
||||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||||
}
|
||||
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}); err != nil {
|
||||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 5. 冲突检测(排除自身)。
|
||||
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
|
||||
@@ -213,6 +219,12 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
copy(oldSlotsA, taskA.Slots)
|
||||
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
|
||||
copy(oldSlotsB, taskB.Slots)
|
||||
if err := validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
|
||||
taskAID: cloneScheduleTaskSlots(oldSlotsB),
|
||||
taskBID: cloneScheduleTaskSlots(oldSlotsA),
|
||||
}); err != nil {
|
||||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 6. 交换 Slots。
|
||||
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
|
||||
@@ -303,6 +315,22 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||||
}
|
||||
}
|
||||
proposals := make(map[int][]TaskSlot, len(moves))
|
||||
for _, m := range moves {
|
||||
task := state.TaskByStateID(m.TaskID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
duration := taskDuration(*task)
|
||||
proposals[m.TaskID] = []TaskSlot{{
|
||||
Day: m.NewDay,
|
||||
SlotStart: m.NewSlotStart,
|
||||
SlotEnd: m.NewSlotStart + duration - 1,
|
||||
}}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, proposals); err != nil {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s", err.Error())
|
||||
}
|
||||
|
||||
// 2. 克隆 state,在克隆上执行。
|
||||
clone := state.Clone()
|
||||
|
||||
494
backend/newAgent/tools/task_class_write.go
Normal file
494
backend/newAgent/tools/task_class_write.go
Normal 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-2,2->3-4,...,6->11-12;
|
||||
// 3. 若放行 7~12,会在 buildTimeGrid 扩展时生成 13~24 节,触发数组越界。
|
||||
if section < 1 || section > 6 {
|
||||
issues = append(issues, "config.excluded_slots 仅允许 1~6(半天块索引,每块=2节)")
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
|
||||
// 1. excluded_days_of_week 属于“整天不可排”的硬约束;
|
||||
// 2. 仅允许 1~7,对应周一到周日;
|
||||
// 3. 非法值会导致粗排过滤口径失真,因此统一在写入口拦截。
|
||||
if dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
issues = append(issues, "config.excluded_days_of_week 仅允许 1~7(周一到周日)")
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
issues = append(issues, "items 不能为空")
|
||||
}
|
||||
for index, item := range req.Items {
|
||||
if item.Order <= 0 {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].order 必须大于 0", index))
|
||||
}
|
||||
if strings.TrimSpace(item.Content) == "" {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].content 不能为空", index))
|
||||
}
|
||||
}
|
||||
return uniqueTaskClassIssues(issues)
|
||||
}
|
||||
|
||||
func uniqueTaskClassIssues(issues []string) []string {
|
||||
if len(issues) == 0 {
|
||||
return issues
|
||||
}
|
||||
seen := make(map[string]struct{}, len(issues))
|
||||
out := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
trimmed := strings.TrimSpace(issue)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readUpsertUserID(raw any) (int, bool) {
|
||||
return readUpsertInt(raw)
|
||||
}
|
||||
|
||||
func readUpsertInt(raw any) (int, bool) {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return value, true
|
||||
case int8:
|
||||
return int(value), true
|
||||
case int16:
|
||||
return int(value), true
|
||||
case int32:
|
||||
return int(value), true
|
||||
case int64:
|
||||
return int(value), true
|
||||
case float64:
|
||||
return int(value), true
|
||||
case float32:
|
||||
return int(value), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalTaskClassUpsertResult(result taskClassUpsertToolResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"upsert_task_class","success":false,"error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
252
backend/newAgent/tools/tool_domain_map.go
Normal file
252
backend/newAgent/tools/tool_domain_map.go
Normal 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. 自动剔除固定包 core(core 不接受显式管理);
|
||||
// 3. 顺序保持第一次出现顺序,便于日志和 prompt 可读性。
|
||||
func NormalizeToolPacks(domain string, packs []string) []string {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" || len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := make([]string, 0, len(packs))
|
||||
for _, rawPack := range packs {
|
||||
pack := NormalizeToolPack(normalizedDomain, rawPack)
|
||||
if pack == "" || IsFixedToolPack(normalizedDomain, pack) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
result = append(result, pack)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolveEffectiveToolPacks 返回某域下“真正生效”的可选包集合。
|
||||
//
|
||||
// 兼容策略:
|
||||
// 1. schedule 域且 packs 为空时,默认启用最小可用包(mutation+analyze);
|
||||
// 2. taskclass 目前无可选包,统一返回 nil;
|
||||
// 3. 非法域统一返回 nil。
|
||||
func ResolveEffectiveToolPacks(domain string, packs []string) []string {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if normalizedDomain == ToolDomainTaskClass {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedPacks := NormalizeToolPacks(normalizedDomain, packs)
|
||||
if len(normalizedPacks) > 0 {
|
||||
return normalizedPacks
|
||||
}
|
||||
|
||||
defaultPacks := ListDefaultToolPacks(normalizedDomain)
|
||||
if len(defaultPacks) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(defaultPacks))
|
||||
copy(result, defaultPacks)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsContextManagementTool 判断工具是否属于上下文管理工具。
|
||||
func IsContextManagementTool(name string) bool {
|
||||
switch strings.TrimSpace(name) {
|
||||
case ToolNameContextToolsAdd, ToolNameContextToolsRemove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveToolDomain 返回工具所属业务域。
|
||||
func ResolveToolDomain(name string) (string, bool) {
|
||||
domain, _, ok := ResolveToolDomainPack(name)
|
||||
return domain, ok
|
||||
}
|
||||
|
||||
// ResolveToolDomainPack 返回工具所属域与二级包。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 命中映射返回 (domain, pack, true);
|
||||
// 2. 未命中返回 ("", "", false);
|
||||
// 3. context 管理工具统一返回 ("", "", false)。
|
||||
func ResolveToolDomainPack(name string) (string, string, bool) {
|
||||
toolName := strings.TrimSpace(name)
|
||||
if IsContextManagementTool(toolName) {
|
||||
return "", "", false
|
||||
}
|
||||
profile, ok := toolProfileByName[toolName]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return profile.Domain, profile.Pack, true
|
||||
}
|
||||
@@ -55,6 +55,22 @@ func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model
|
||||
if req.Mode == "" || req.Name == "" || len(req.Items) == 0 {
|
||||
return respond.MissingParam
|
||||
}
|
||||
// 1. excluded_slots 属于“半天块索引”,每个索引映射 2 节(1->1-2,...,6->11-12);
|
||||
// 2. 若允许 7~12,会在粗排网格展开时产生越界节次,触发运行时 panic;
|
||||
// 3. 这里统一在写入入口拦截,避免脏数据落库后污染后续排程链路。
|
||||
for _, slot := range req.Config.ExcludedSlots {
|
||||
if slot < 1 || slot > 6 {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
}
|
||||
// 1. excluded_days_of_week 表示“整天不可排”的硬约束,粗排时会直接整天屏蔽;
|
||||
// 2. 只允许 1~7,对应周一到周日;
|
||||
// 3. 若写入非法值,会导致粗排过滤口径和前端展示口径不一致,因此入口直接拦截。
|
||||
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
|
||||
if dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
return respond.WrongParamType
|
||||
}
|
||||
}
|
||||
//2.写数据库(事务内)
|
||||
if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error {
|
||||
taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID)
|
||||
|
||||
634
docs/backend/P1-P1.5执行改动计划.md
Normal file
634
docs/backend/P1-P1.5执行改动计划.md
Normal 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-C:Prompt 策略升级(行为约束)
|
||||
定义:
|
||||
- 让 LLM 在正确场景优先使用分析工具,且不过度主动。
|
||||
|
||||
改动动作:
|
||||
- 修改 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go)
|
||||
- 增加规则:
|
||||
- `first_full/global_reopt` 模式优先 `analyze_health`。
|
||||
- `local_adjust` 模式默认旧链路(`query_target_tasks/query_available_slots/...`)。
|
||||
- 未命中全局触发条件,不要滥用全局分析。
|
||||
- 增加“先定范围再写入”规则:先用分析/读取工具锁定 `candidate_scope`,再选择写工具执行。
|
||||
- 增加“自主选目标”规则:后端不指定具体任务,LLM 在边界内自行选择目标与参数,并在后续复盘中验证是否命中 success_criteria。
|
||||
- 在 P1 提示词中禁用 `min_context_switch`(不作为候选动作)。
|
||||
|
||||
- 修改 [execute_context.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute_context.go)
|
||||
- 为新增工具补“返回类型+最小示例”。
|
||||
|
||||
验收标准:
|
||||
- 同样输入下,首次编排与局部调整的工具选择有明显分流。
|
||||
- 不出现“局部请求强行全局体检”的高频行为。
|
||||
- 日志可还原“本轮 scope 是什么、为何选择该任务、成功判据是否达成”。
|
||||
|
||||
---
|
||||
|
||||
## 5.4 P1-D:执行模式标记(状态层,建议)
|
||||
定义:
|
||||
- 给执行链路显式模式,避免仅靠 prompt 推断。
|
||||
|
||||
改动动作(建议):
|
||||
- 修改 [common_state.go](/D:/SmartFlow-Agent/backend/newAgent/model/common_state.go)
|
||||
- 新增 `OptimizationMode string`。
|
||||
- 在 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/node/chat.go) 路由处设置模式:
|
||||
- 首次编排粗排后微调 -> `first_full`
|
||||
- 局部调整请求 -> `local_adjust`
|
||||
- 明确全局重优化请求 -> `global_reopt`
|
||||
|
||||
验收标准:
|
||||
- `execute` 运行日志中可观测到模式值。
|
||||
- 恢复场景不丢模式(随 RuntimeState 快照持久化)。
|
||||
|
||||
---
|
||||
|
||||
## 5.5 P1-E:收口与质量防抖(执行层)
|
||||
定义:
|
||||
- 不改变现有阈值,只补齐可观测数据与兜底日志。
|
||||
|
||||
改动动作:
|
||||
- 使用现有收口规则:`critical=0 && warning<=1`、连续无效 3 轮收口、60 轮上限。
|
||||
- 在 `analyze_health` 返回里统一输出 `issues`,供 LLM 与日志一致引用。
|
||||
- 当 `feasibility.is_feasible=false` 时,禁止继续常规微调回路(`move/swap` 反复试探)。
|
||||
|
||||
验收标准:
|
||||
- 收口判断与 PRD 一致。
|
||||
- 日志可还原“每轮依据哪个 issue 在优化”。
|
||||
- 不可行场景下不会跑满无意义轮次。
|
||||
|
||||
---
|
||||
|
||||
## 5.6 P1-F:不可行协商分支(新增)
|
||||
定义:
|
||||
- 把“排不下”与“排不好”拆开处理;不可行时转入用户协商,而非继续磨轮次。
|
||||
|
||||
改动动作:
|
||||
- 在 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/node/execute.go) 增加分支规则:
|
||||
- 若最近一次 `analyze_health` 明确 `is_feasible=false`:
|
||||
- 本轮优先 `ask_user`,给出四类选项(扩窗口/放松约束/降范围预算/接受风险收口)。
|
||||
- 若用户未调整约束且明确继续当前方案,允许“有风险收口”。
|
||||
- 若用户调整了约束,重开一轮入场判定并继续。
|
||||
|
||||
- 在 [prompt/execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go) 增加硬约束提示:
|
||||
- 不可行时禁止继续无目标微调。
|
||||
- 不可行时必须先沟通约束变更。
|
||||
|
||||
验收标准:
|
||||
- 人工构造“明显排不下”样例时,模型会在少量轮次内进入协商,不会持续 `critical` 循环。
|
||||
- 协商后可恢复正常优化或风险收口,路径清晰可追溯。
|
||||
|
||||
---
|
||||
|
||||
## 6. P1.5 实施清单(逐项)
|
||||
|
||||
## 6.1 P1.5-A:Prompt 草案生成
|
||||
定义:
|
||||
- 提供“完整任务类草案”生成能力(聊天触发),不新增后端草案工具。
|
||||
|
||||
改动动作:
|
||||
- 修改 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/chat.go) 与 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go):
|
||||
- 明确“任务类草案由 LLM 直接生成”的提示词约束。
|
||||
- 明确“关键字段必须 ask_user,普通字段可静默落”的输出约束。
|
||||
- 明确“来源优先级与冲突显式化”规则。
|
||||
|
||||
验收标准:
|
||||
- 任意输入可生成完整草案结构(无新增工具调用)。
|
||||
- 关键字段缺失时会触发 ask_user,不会直接进入 `upsert_task_class`。
|
||||
|
||||
---
|
||||
|
||||
## 6.2 P1.5-B:写库工具落地(`upsert_task_class`)
|
||||
定义:
|
||||
- 新增统一任务类写库入口,承接“草案确认后落库”。
|
||||
|
||||
改动动作:
|
||||
- 新增文件建议:
|
||||
- `tools/task_class_write.go`
|
||||
- `tools/task_class_write_types.go`
|
||||
- 修改 [registry.go](/D:/SmartFlow-Agent/backend/newAgent/tools/registry.go):
|
||||
- 注册 `upsert_task_class`。
|
||||
- 加入 `writeTools`(必须 confirm)。
|
||||
- 加入 `scheduleFreeTools`(不依赖 `ScheduleState`,可在纯聊天草案场景调用)。
|
||||
- 工具内部复用 DAO 事务能力:`AddOrUpdateTaskClass` + `AddOrUpdateTaskClassItems`。
|
||||
- 写后补齐字段合法性与完整性校验,统一返回可修复 `issues`。
|
||||
|
||||
验收标准:
|
||||
- `id=0` 可创建成功,`id>0` 可更新成功,且返回稳定 `task_class_id`。
|
||||
- confirm 拒绝时不发生写入。
|
||||
- 写后校验失败时可稳定回到对话修订,不出现“写入后静默失败”。
|
||||
|
||||
---
|
||||
|
||||
## 6.3 P1.5-C:触发策略(聊天入口)
|
||||
定义:
|
||||
- 仅聊天触发,不加按钮分支。
|
||||
|
||||
改动动作:
|
||||
- 调整 [chat.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/chat.go) 路由提示:
|
||||
- 识别“设计任务类/补全任务类参数/生成任务类草案”等意图。
|
||||
- 路由建议继续走 `execute`(复用现有链路),不新增节点、不新增草案工具。
|
||||
- 调整 [execute.go](/D:/SmartFlow-Agent/backend/newAgent/prompt/execute.go):
|
||||
- 明确草案阶段只读/对话,落库阶段统一调用 `upsert_task_class`。
|
||||
- 明确“关键字段未确认禁止写库”的硬约束。
|
||||
|
||||
验收标准:
|
||||
- 用户自然语言可稳定触发草案生成流程。
|
||||
- 关键字段确认后可稳定触发 `upsert_task_class` 落库。
|
||||
- 不出现“创建第二聊天区”的交互分叉。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分阶段提交建议(按 PR 切)
|
||||
|
||||
### PR-1(P1 工具层)
|
||||
- 新增 5 分析工具实现 + 单元级自测(本地运行后清理临时测试文件)。
|
||||
- 不动 prompt,不动 chat 路由。
|
||||
|
||||
### PR-2(P1 策略层)
|
||||
- registry 注册 + execute prompt/示例补齐 + 可选 `OptimizationMode`。
|
||||
- 联调首次编排与局部请求两条路径。
|
||||
- 联调“不可行协商分支”(避免持续 critical 循环)。
|
||||
- 联调“后端给边界,LLM 自主选目标”的读写闭环,并验证 P1 隐藏 `min_context_switch`。
|
||||
|
||||
### PR-3(P1.5 草案能力)
|
||||
- prompt 草案生成约束 + 关键字段确认流 + `upsert_task_class` 写库工具接线 + 写后校验回传约束。
|
||||
|
||||
### PR-4(联调与收口)
|
||||
- 统一日志字段、错误返回格式、文档回填(含 PRD 对应项映射)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收与回滚
|
||||
|
||||
### 8.1 验收检查
|
||||
- 功能验收:
|
||||
- 首次编排触发全流程分析策略。
|
||||
- 局部调整默认旧链路,不误触发全局分析。
|
||||
- 任务类草案可聊天触发,按“草案 -> 关键字段确认 -> 写入 -> 写后校验”链路闭环。
|
||||
- 任务类最终落库统一通过 `upsert_task_class`,且受 confirm 闸门保护。
|
||||
|
||||
- 质量验收:
|
||||
- 无新增死循环风险(轮次与无效轮次机制保持)。
|
||||
- 写工具确认闸门不退化(A/B/C 硬规则仍生效)。
|
||||
- 不可行场景可被识别并进入协商分支,不再无效磨轮。
|
||||
- 写入前具备 scope 证据,且目标对象由 LLM 自主选择(非后端硬编码选点)。
|
||||
|
||||
### 8.2 回滚策略
|
||||
- 工具级开关:先通过注册表控制可见性(临时下线单工具不影响主链)。
|
||||
- Prompt级回滚:保留旧提示模板版本,出现偏航可快速切回。
|
||||
- 状态字段回滚:新增字段仅追加,删除前先做兼容读取。
|
||||
|
||||
---
|
||||
|
||||
## 9. 本轮对齐清单(逐项勾选)
|
||||
- [ ] 1. 是否采用 `OptimizationMode` 显式模式字段(建议:采用)?
|
||||
- [ ] 2. P1 是否严格限定 5 个分析工具(不含 deadlines)?
|
||||
- [ ] 3. 分析工具返回包络是否冻结为 `metrics/issues/next_actions`?
|
||||
- [ ] 4. P1.5 是否确认复用 execute 链路,不新增独立 graph 节点?
|
||||
- [ ] 5. PR 拆分是否采用 `PR-1~PR-4` 顺序?
|
||||
- [ ] 6. 是否冻结“可行性判定 + 不可行协商分支”为 P1 必做项?
|
||||
- [ ] 7. 是否冻结“后端只给边界,LLM 自主选目标与参数”为执行原则?
|
||||
- [ ] 8. 是否冻结 P1 默认禁用 `min_context_switch`(不暴露给 execute 候选写工具)?
|
||||
- [ ] 9. 是否冻结“P1.5 不新增 `build_task_class_draft`,草案改为纯 prompt 生成 + 写后校验”?
|
||||
- [ ] 10. 是否冻结“P1.5 新增 `upsert_task_class` 作为唯一任务类写库入口(必须 confirm)”?
|
||||
|
||||
---
|
||||
|
||||
## 10. 备注(关键现实约束)
|
||||
- 当前 `ScheduleState` 不含单任务 `deadline_at/priority_group/urgency_threshold_at`,故不建议在 P1 实现 `analyze_deadlines`。
|
||||
- 若后续要做 `analyze_deadlines`,需先在 `conv/schedule_state.go` 映射 task 维度截止信息到工具态,再进入 P2。
|
||||
547
docs/backend/主动优化整套改造计划.md
Normal file
547
docs/backend/主动优化整套改造计划.md
Normal 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. 主动优化完成后的结果,解释口径更像“为什么这样学更顺”,而不是“哪些数字变好看了”。
|
||||
523
docs/backend/主动优化顺序约束拆分执行计划.md
Normal file
523
docs/backend/主动优化顺序约束拆分执行计划.md
Normal 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 C:prompt 仍把模型往“全局不乱序”上引
|
||||
|
||||
现象:
|
||||
|
||||
1. `execute_context` 里仍写着默认保持 suggested 相对顺序。
|
||||
|
||||
修法:
|
||||
|
||||
1. 改成“默认保持同任务类内部顺序”。
|
||||
|
||||
## 7.4 bug D:复合工具可能绕过新规则
|
||||
|
||||
现象:
|
||||
|
||||
1. `spread_even` 当前只校验冲突,不校验同类前后边界。
|
||||
|
||||
修法:
|
||||
|
||||
1. 接入统一局部顺序约束层。
|
||||
|
||||
## 7.5 bug E:active optimize 链路和 execute 文件职责缠得太紧
|
||||
|
||||
现象:
|
||||
|
||||
1. 任何主动优化 bug 都容易改进 `execute.go`,继续涨文件体积。
|
||||
|
||||
修法:
|
||||
|
||||
1. 本轮同步拆文件,至少把工具执行与摘要逻辑拆出去。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施顺序
|
||||
|
||||
建议按下面顺序推进,避免中途状态既不兼容旧逻辑,也没完全切到新逻辑。
|
||||
|
||||
### 阶段 1:补数据
|
||||
|
||||
1. 给 `ScheduleTask` 增加 `TaskOrder`
|
||||
2. 在 state loader 中完成映射
|
||||
3. 保证查询 / 粗排 / 预览链路不受影响
|
||||
|
||||
### 阶段 2:落局部顺序约束公共层
|
||||
|
||||
1. 实现前驱/后继查找
|
||||
2. 实现目标落位合法性判断
|
||||
3. 输出中文失败原因
|
||||
|
||||
### 阶段 3:接入基础写工具
|
||||
|
||||
1. `move`
|
||||
2. `swap`
|
||||
3. `batch_move`
|
||||
4. `place`
|
||||
|
||||
### 阶段 4:接入复合写工具
|
||||
|
||||
1. `spread_even`
|
||||
2. 保持 `min_context_switch` 继续禁用
|
||||
|
||||
### 阶段 5:切掉旧 order_guard
|
||||
|
||||
1. 删除 graph 分支
|
||||
2. 删除 baseline 运行态
|
||||
3. 去掉用户可见状态文案
|
||||
|
||||
### 阶段 6:更新 prompt
|
||||
|
||||
1. 改目标描述
|
||||
2. 改顺序策略说明
|
||||
3. 明确非法写工具会被后端拒绝
|
||||
|
||||
### 阶段 7:拆 execute.go
|
||||
|
||||
1. 先无行为变化拆文件
|
||||
2. 再补必要注释与最小验证
|
||||
|
||||
---
|
||||
|
||||
## 9. 验证口径
|
||||
|
||||
## 9.1 正向场景
|
||||
|
||||
要验证这些场景能通过:
|
||||
|
||||
1. 同任务类内部顺序不变,但不同任务类交错后负载更均衡。
|
||||
2. LLM 将某任务从第 3 天挪到第 20 天,只要仍在其前后兄弟之间,就允许。
|
||||
3. `spread_even` 可以把多门课拉开,但不会把某一门课内部顺序反过来。
|
||||
|
||||
## 9.2 反向场景
|
||||
|
||||
要验证这些场景被拦住:
|
||||
|
||||
1. 把某门课的第 4 个任务挪到第 1 个任务前面。
|
||||
2. 把某门课的中间任务挪到其后继任务之后。
|
||||
3. `swap` 后导致同任务类内部出现逆序。
|
||||
4. `batch_move` 中有一条越界时整批失败。
|
||||
|
||||
## 9.3 交付场景
|
||||
|
||||
要确认这些旧副作用消失:
|
||||
|
||||
1. 不再出现 `order_guard_initialized`
|
||||
2. 不再出现 `order_guard_restore_skipped`
|
||||
3. 不再依赖 `SuggestedOrderBaseline`
|
||||
|
||||
## 9.4 代码结构场景
|
||||
|
||||
要确认:
|
||||
|
||||
1. `execute.go` 文件职责明显变轻
|
||||
2. 局部顺序约束逻辑只存在一份公共实现
|
||||
|
||||
---
|
||||
|
||||
## 10. 本轮建议的最小落地范围
|
||||
|
||||
如果要控制风险,本轮建议先做到这里:
|
||||
|
||||
1. `TaskOrder` 注入
|
||||
2. 局部顺序约束公共层
|
||||
3. `move/swap/batch_move/place` 接入
|
||||
4. `spread_even` 接入
|
||||
5. prompt 改口径
|
||||
6. 切掉旧 `order_guard`
|
||||
7. 拆出 `execute_tool_runtime.go` 与 `execute_tool_summary.go`
|
||||
|
||||
这个范围已经足够让主动优化链路从“旧哲学打架”切到“新哲学能跑”。
|
||||
|
||||
---
|
||||
|
||||
## 11. 预期收益
|
||||
|
||||
做完之后,预期表现会变成:
|
||||
|
||||
1. LLM 会更敢做真实优化,因为它不再被全局顺序锁死。
|
||||
2. 后端会在写工具层直接给出“能不能这么挪”的明确反馈。
|
||||
3. 同一门课的学习推进顺序能被稳定锁住。
|
||||
4. 不同门课之间仍有足够空间做均衡、分散、减压。
|
||||
5. 前端不会再收到旧 `order_guard` 带来的迷惑状态。
|
||||
6. 后续如果继续加主动优化策略,也有更干净的承载位置,不必继续往 `execute.go` 里堆。
|
||||
|
||||
---
|
||||
|
||||
## 12. 本文档对应的实施结论
|
||||
|
||||
本轮建议按以下原则执行:
|
||||
|
||||
1. 删除“全局 suggested 顺序守卫”思路。
|
||||
2. 改为“同任务类内部顺序约束前置到写工具层”。
|
||||
3. 允许跨任务类交错优化。
|
||||
4. 顺手清理旧 guard 带来的用户可见噪音与兼容性问题。
|
||||
5. 同步拆分 execute 相关职责文件,避免继续堆史山。
|
||||
|
||||
@@ -153,6 +153,10 @@ interface DisplayAssistantBlock {
|
||||
event?: ToolTraceEvent
|
||||
statusEvent?: StatusTraceEvent
|
||||
schedulePreview?: SchedulePreviewData
|
||||
/** 所属的源消息 ID,用于状态查询 */
|
||||
sourceId?: string
|
||||
/** 所属的源消息引用,用于渲染辅助信息 */
|
||||
source?: AssistantMessage
|
||||
}
|
||||
|
||||
interface AssistantContentBlock {
|
||||
@@ -223,6 +227,7 @@ const statusTraceEventsMap = reactive<Record<string, StatusTraceEvent[]>>({})
|
||||
const toolTraceExpandedMap = reactive<Record<string, boolean>>({})
|
||||
const assistantReasoningSeqMap = reactive<Record<string, number>>({})
|
||||
const assistantContentBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
||||
const assistantReasoningBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
||||
const assistantTimelineLastKindMap = reactive<Record<string, 'content' | 'tool' | 'status' | 'reasoning' | 'other'>>({})
|
||||
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
|
||||
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||
@@ -502,6 +507,11 @@ function appendToolTraceEvent(
|
||||
const eventSeq = nextAssistantTimelineSeq()
|
||||
const eventId = `${messageId}:tool:${eventSeq}`
|
||||
|
||||
// 如果上一个阶段是推理,则结束并折叠它
|
||||
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
|
||||
finishCurrentReasoningBlock(messageId)
|
||||
}
|
||||
|
||||
toolTraceEventsMap[messageId].push({
|
||||
id: eventId,
|
||||
seq: eventSeq,
|
||||
@@ -536,6 +546,11 @@ function appendStatusTraceEvent(
|
||||
}
|
||||
|
||||
const eventSeq = nextAssistantTimelineSeq()
|
||||
// 如果上一个阶段是推理,则结束并折叠它
|
||||
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
|
||||
finishCurrentReasoningBlock(messageId)
|
||||
}
|
||||
|
||||
statusEvents.push({
|
||||
id: `${messageId}:status:${eventSeq}`,
|
||||
seq: eventSeq,
|
||||
@@ -554,6 +569,11 @@ function appendAssistantContentChunk(messageId: string, chunk: string) {
|
||||
const blocks = assistantContentBlocksMap[messageId]
|
||||
const lastKind = assistantTimelineLastKindMap[messageId]
|
||||
|
||||
// 如果是从推理切换到正文,则结束并折叠推理块
|
||||
if (lastKind === 'reasoning') {
|
||||
finishCurrentReasoningBlock(messageId)
|
||||
}
|
||||
|
||||
if (lastKind === 'content' && blocks.length > 0) {
|
||||
blocks[blocks.length - 1]!.text += chunk
|
||||
return
|
||||
@@ -568,6 +588,41 @@ function appendAssistantContentChunk(messageId: string, chunk: string) {
|
||||
assistantTimelineLastKindMap[messageId] = 'content'
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加助理推理片段到特定消息的块映射中
|
||||
* 1. 采用与正文相同的块化存储逻辑,确保推理片段能按 sequence 与工具等交错排序
|
||||
* 2. 如果当前时间线最后一种类型就是 'reasoning',则追加到最后一个块,避免碎片化
|
||||
*/
|
||||
function appendAssistantReasoningChunk(messageId: string, chunk: string) {
|
||||
if (!chunk) {
|
||||
return
|
||||
}
|
||||
if (!assistantReasoningBlocksMap[messageId]) {
|
||||
assistantReasoningBlocksMap[messageId] = []
|
||||
}
|
||||
const blocks = assistantReasoningBlocksMap[messageId]
|
||||
const lastKind = assistantTimelineLastKindMap[messageId]
|
||||
|
||||
if (lastKind === 'reasoning' && blocks.length > 0) {
|
||||
blocks[blocks.length - 1]!.text += chunk
|
||||
return
|
||||
}
|
||||
|
||||
const seq = nextAssistantTimelineSeq()
|
||||
const blockId = `${messageId}:reasoning:${seq}`
|
||||
blocks.push({
|
||||
id: blockId,
|
||||
seq,
|
||||
text: chunk,
|
||||
})
|
||||
|
||||
// 记录块级别的起始时间和初始折叠状态
|
||||
reasoningStartedAtMap[blockId] = Date.now()
|
||||
reasoningCollapsedMap[blockId] = false
|
||||
|
||||
assistantTimelineLastKindMap[messageId] = 'reasoning'
|
||||
}
|
||||
|
||||
function mapToolEventState(rawStatus?: string): ToolTraceState {
|
||||
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
|
||||
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
|
||||
@@ -993,22 +1048,21 @@ function markReasoningStart(message: AssistantMessage) {
|
||||
reasoningStartedAtMap[message.id] = Date.now()
|
||||
}
|
||||
|
||||
function markReasoningFinished(message: AssistantMessage) {
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
if (startedAt && !reasoningDurationMap[message.id]) {
|
||||
reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
|
||||
function markReasoningFinished(blockId: string, messageId: string) {
|
||||
const startedAt = reasoningStartedAtMap[blockId]
|
||||
if (startedAt && !reasoningDurationMap[blockId]) {
|
||||
reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
|
||||
}
|
||||
|
||||
thinkingMessageMap[message.id] = false
|
||||
thinkingMessageMap[messageId] = false
|
||||
}
|
||||
|
||||
function getReasoningDurationSeconds(message: AssistantMessage) {
|
||||
const fixedDuration = reasoningDurationMap[message.id]
|
||||
function getReasoningDurationSeconds(blockId: string) {
|
||||
const fixedDuration = reasoningDurationMap[blockId]
|
||||
if (fixedDuration) {
|
||||
return fixedDuration
|
||||
}
|
||||
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
const startedAt = reasoningStartedAtMap[blockId]
|
||||
if (!startedAt) {
|
||||
return 0
|
||||
}
|
||||
@@ -1016,13 +1070,28 @@ function getReasoningDurationSeconds(message: AssistantMessage) {
|
||||
return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000))
|
||||
}
|
||||
|
||||
function getReasoningStatusLabel(message: AssistantMessage) {
|
||||
const durationSeconds = getReasoningDurationSeconds(message)
|
||||
function getReasoningStatusLabel(block: DisplayAssistantBlock) {
|
||||
const durationSeconds = getReasoningDurationSeconds(block.id)
|
||||
if (durationSeconds > 0) {
|
||||
return `已思考(用时 ${durationSeconds} 秒)`
|
||||
}
|
||||
|
||||
return isStreamingMessage(message) && isThinkingMessage(message) ? '思考中' : '已思考'
|
||||
const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId]
|
||||
return isThinking ? '思考中' : '已思考'
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束当前消息正在进行的推理块
|
||||
* 1. 计算耗时
|
||||
* 2. 自动折叠
|
||||
*/
|
||||
function finishCurrentReasoningBlock(messageId: string) {
|
||||
const blocks = assistantReasoningBlocksMap[messageId] || []
|
||||
if (blocks.length === 0) return
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
|
||||
markReasoningFinished(lastBlock.id, messageId)
|
||||
reasoningCollapsedMap[lastBlock.id] = true
|
||||
}
|
||||
|
||||
function isReasoningCollapsed(messageId: string) {
|
||||
@@ -1086,6 +1155,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
type: 'tool',
|
||||
seq: event.seq,
|
||||
event,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1096,6 +1167,32 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
type: 'status',
|
||||
seq: statusEvent.seq,
|
||||
statusEvent,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
// 从推理块映射中提取所有独立的推理片段
|
||||
const reasoningBlocks = assistantReasoningBlocksMap[source.id] || []
|
||||
if (reasoningBlocks.length > 0) {
|
||||
for (const rb of reasoningBlocks) {
|
||||
blocks.push({
|
||||
id: rb.id,
|
||||
type: 'reasoning',
|
||||
seq: rb.seq,
|
||||
text: rb.text,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
} else if (source.id === activeStreamingMessageId.value && thinkingMessageMap[source.id]) {
|
||||
// 流式过程中尚未有实质文本产出时的“思考中”占位块
|
||||
blocks.push({
|
||||
id: `${source.id}:reasoning:streaming`,
|
||||
type: 'reasoning',
|
||||
seq: assistantReasoningSeqMap[source.id] || 10,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1108,6 +1205,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
type: 'content',
|
||||
seq: contentBlock.seq,
|
||||
text: contentBlock.text,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
continue
|
||||
@@ -1121,6 +1220,8 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
type: 'content',
|
||||
seq: fallbackSeq,
|
||||
text: source.content,
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1135,16 +1236,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowDisplayReasoningBox(dm)) {
|
||||
const reasoningSeq = getDisplayReasoningSeq(dm)
|
||||
blocks.push({
|
||||
id: `${dm.id}:reasoning`,
|
||||
type: 'reasoning',
|
||||
seq: reasoningSeq > 0 ? reasoningSeq : 10,
|
||||
text: dm.reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasContentBlock && dm.content) {
|
||||
fallbackSeq += 1
|
||||
blocks.push({
|
||||
@@ -1180,38 +1271,16 @@ function getToolTraceStateLabel(state: ToolTraceState): string {
|
||||
return '已完成'
|
||||
}
|
||||
|
||||
function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean {
|
||||
if (dm.role !== 'assistant') return false
|
||||
return dm.sources.some(m =>
|
||||
Boolean(m.reasoning?.trim()) ||
|
||||
(m.id === activeStreamingMessageId.value && thinkingMessageMap[m.id] === true),
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
|
||||
return isDisplayStreaming(dm) &&
|
||||
dm.sources.every(m => thinkingMessageMap[m.id] !== true) &&
|
||||
!dm.content.trim()
|
||||
}
|
||||
|
||||
function isDisplayReasoningCollapsed(dm: DisplayMessage): boolean {
|
||||
return dm.sources.every(m => reasoningCollapsedMap[m.id] === true)
|
||||
}
|
||||
|
||||
function toggleDisplayReasoningCollapse(dm: DisplayMessage): void {
|
||||
const newCollapsed = !isDisplayReasoningCollapsed(dm)
|
||||
dm.sources.forEach(m => { reasoningCollapsedMap[m.id] = newCollapsed })
|
||||
}
|
||||
|
||||
function getDisplayReasoningStatusLabel(dm: DisplayMessage): string {
|
||||
const totalSeconds = dm.sources.reduce(
|
||||
(sum, m) => sum + (reasoningDurationMap[m.id] ?? 0), 0,
|
||||
)
|
||||
if (totalSeconds > 0) return `已思考(用时 ${totalSeconds} 秒)`
|
||||
const hasActiveThinking = dm.sources.some(
|
||||
m => m.id === activeStreamingMessageId.value && thinkingMessageMap[m.id] === true,
|
||||
)
|
||||
return hasActiveThinking ? '思考中' : '已思考'
|
||||
// 此函数已废弃,推理状态现已下沉到各 source 块处理。
|
||||
// 仅保留空实现以防意外调用。
|
||||
return '已思考'
|
||||
}
|
||||
|
||||
function isMessageViewportAtBottom(viewport: HTMLElement) {
|
||||
@@ -1576,7 +1645,8 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
|
||||
if (reasoningChunk) {
|
||||
currentAssistantMessage.reasoning = oldReasoning + reasoningChunk
|
||||
// 记录推理块的 seq 环境
|
||||
// 时序化存储推理内容
|
||||
appendAssistantReasoningChunk(mid, reasoningChunk)
|
||||
if (!assistantReasoningSeqMap[mid]) {
|
||||
assistantReasoningSeqMap[mid] = event.seq
|
||||
}
|
||||
@@ -1867,8 +1937,8 @@ async function submitConfirmRejectMessage() {
|
||||
requestExtra: {
|
||||
resume: {
|
||||
interaction_id: interactionId,
|
||||
type: 'ask_user',
|
||||
action: 'reply'
|
||||
type: 'confirm',
|
||||
action: 'reject'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2107,10 +2177,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
|
||||
if (payload === '[DONE]') {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
finishCurrentReasoningBlock(assistantMessage.id)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
// 整个 SSE 流结束信号
|
||||
void loadConversationContextStats(selectedConversationId.value, true)
|
||||
return
|
||||
@@ -2150,27 +2219,23 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
if (!assistantReasoningSeqMap[assistantMessage.id]) {
|
||||
assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq()
|
||||
}
|
||||
assistantTimelineLastKindMap[assistantMessage.id] = 'reasoning'
|
||||
appendAssistantReasoningChunk(assistantMessage.id, delta.reasoning_content)
|
||||
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
|
||||
}
|
||||
|
||||
if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) {
|
||||
appendAssistantContentChunk(assistantMessage.id, delta.content)
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
// 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。
|
||||
// 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
|
||||
// 3. 若后端偶发交错发送 reasoning/content,也以前端阶段机兜底,优先保证阅读一致性。
|
||||
markReasoningFinished(assistantMessage)
|
||||
finishCurrentReasoningBlock(assistantMessage.id)
|
||||
}
|
||||
assistantMessage.content += delta.content
|
||||
}
|
||||
|
||||
if (finishReason) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
finishCurrentReasoningBlock(assistantMessage.id)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
// 单条消息结束标志
|
||||
void loadConversationContextStats(selectedConversationId.value, true)
|
||||
}
|
||||
@@ -2681,18 +2746,18 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="chat-message__reasoning-status">{{ getDisplayReasoningStatusLabel(dm) }}</span>
|
||||
<span class="chat-message__reasoning-status">{{ getReasoningStatusLabel(block) }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-message__reasoning-toggle"
|
||||
:aria-label="isDisplayReasoningCollapsed(dm) ? '展开深度思考' : '折叠深度思考'"
|
||||
@click="toggleDisplayReasoningCollapse(dm)"
|
||||
:aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
|
||||
@click="toggleReasoningCollapse(block.id)"
|
||||
>
|
||||
<span class="chat-message__reasoning-chevron">
|
||||
<svg
|
||||
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"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
@@ -2709,11 +2774,11 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isDisplayReasoningCollapsed(dm)" class="chat-message__reasoning-body">
|
||||
<div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
|
||||
<div
|
||||
v-if="block.text"
|
||||
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 class="thinking-indicator">
|
||||
|
||||
Reference in New Issue
Block a user