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,674 +0,0 @@
|
||||
# NewAgent 架构全景
|
||||
|
||||
> 本文档帮助读者建立对 newAgent 的完整心智模型,从宏观到微观逐层展开。
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话概括
|
||||
|
||||
newAgent 是一个 **状态机驱动的有向图**:用户消息进入 Chat 节点,经过意图分类、计划生成、用户确认、工具执行、最终交付,每一步由 Phase 和 PendingInteraction 驱动路由。
|
||||
|
||||
---
|
||||
|
||||
## 二、宏观架构
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
newAgent/
|
||||
├── graph/ 图骨架:节点注册、边连线、分支路由
|
||||
├── model/ 数据模型:状态、合约、接口定义
|
||||
├── node/ 节点实现:每个节点的业务逻辑
|
||||
├── prompt/ 提示词:每个阶段的 system prompt 和用户 prompt 构造
|
||||
├── llm/ LLM 客户端:文本生成、JSON 解析、流式适配
|
||||
├── stream/ SSE 输出:伪流式推送、OpenAI 兼容格式
|
||||
├── tools/ 工具层:10 个排程工具 + 注册表
|
||||
├── shared/ 公共工具:重试、时区
|
||||
├── router/ 路由(当前为空,路由逻辑在 graph/ 中)
|
||||
├── ROADMAP.md 改造计划
|
||||
└── ARCHITECTURE.md 本文档
|
||||
```
|
||||
|
||||
### 2.2 图结构
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
v
|
||||
Chat ──────┬── 意图=chat ────→ END(直接回复)
|
||||
│ │
|
||||
│ └── 意图=task ──→ Plan ──┬── continue ──→ Plan(继续规划)
|
||||
│ │ │
|
||||
│ │ ├── ask_user ──→ Interrupt ──→ END
|
||||
│ │ │
|
||||
│ │ └── plan_done ──→ Confirm
|
||||
│ │ │
|
||||
│ │ ├── 有计划 → 确认卡片
|
||||
│ │ │ │
|
||||
│ │ │ v
|
||||
│ │ │ Interrupt ──→ END
|
||||
│ │ │
|
||||
│ │ └── 有 PendingConfirmTool → 确认卡片
|
||||
│ │
|
||||
│ v
|
||||
│ Interrupt ──→ END
|
||||
│
|
||||
│ [用户确认后重新进入图]
|
||||
│
|
||||
v
|
||||
Chat(resume) ──┬── accept → 恢复 PendingConfirmTool → Execute
|
||||
│
|
||||
└── reject → 回到 planning 或 executing
|
||||
|
||||
Execute ──┬── continue(读工具) ──→ Execute(继续 ReAct)
|
||||
├── continue(无工具) ──→ Execute
|
||||
├── confirm(写工具) ──→ Confirm ──→ Interrupt ──→ END
|
||||
├── ask_user ──→ Interrupt ──→ END
|
||||
├── next_plan ──┬── 有剩余步骤 → Execute
|
||||
│ └── 无剩余步骤 → Deliver
|
||||
├── done ──→ Deliver
|
||||
└── 轮次耗尽 ──→ Deliver(强制)
|
||||
|
||||
Deliver ──→ END(最终总结,清理持久化快照)
|
||||
```
|
||||
|
||||
### 2.3 一次完整排课的请求序列
|
||||
|
||||
```
|
||||
请求1: 用户发 "帮我安排下周的复习"
|
||||
→ Chat(intent=task) → Plan(plan_done, 2步计划) → Confirm → Interrupt → END
|
||||
前端展示确认卡片
|
||||
|
||||
请求2: 用户点 "确认"
|
||||
→ Chat(resume, accept) → 确认通过 → Execute(读工具 get_overview)
|
||||
→ Execute(读工具 find_free)
|
||||
→ Execute(写工具 place → confirm) → Confirm → Interrupt → END
|
||||
前端展示写操作确认卡片
|
||||
|
||||
请求3: 用户点 "确认"
|
||||
→ Chat(resume, accept) → Execute(执行 pending tool place) → 持久化
|
||||
→ Execute(next_plan → 下一步)
|
||||
→ Execute(done) → Deliver → END
|
||||
前端展示最终总结
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、状态模型
|
||||
|
||||
理解 newAgent 的关键在于理解 **什么东西在什么时机变化**。
|
||||
|
||||
### 3.1 三个核心状态对象
|
||||
|
||||
```
|
||||
┌─────────────────────┐ 持久化到 Redis
|
||||
│ AgentRuntimeState │ ← StateStore.Save/Load
|
||||
│ │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ CommonState │ │ ← 每个节点都可能修改
|
||||
│ │ - Phase │ │
|
||||
│ │ - PlanSteps │ │
|
||||
│ │ - CurrentStep │ │
|
||||
│ │ - RoundUsed │ │
|
||||
│ └───────────────┘ │
|
||||
│ │
|
||||
│ PendingInteraction │ ← 确认/追问 的交互快照
|
||||
│ PendingConfirmTool │ ← Execute→Confirm 的临时邮箱
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────────┐ 不持久化,每次请求重建
|
||||
│ ConversationContext │
|
||||
│ - SystemPrompt │ ← 各节点 prompt 函数构造
|
||||
│ - History []*Message │ ← 对话历史(assistant+tool 配对)
|
||||
│ - PinnedBlocks │ ← 置顶上下文(计划、工具摘要)
|
||||
│ - ToolSchemas │ ← 工具 schema 注入
|
||||
└─────────────────────────┘
|
||||
|
||||
┌─────────────────────────┐ 懒加载,首次 Execute 时读取
|
||||
│ ScheduleState │
|
||||
│ - Window (天数+映射) │
|
||||
│ - Tasks []ScheduleTask │ ← 工具操作的数据源
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Phase 状态转换
|
||||
|
||||
```
|
||||
PhasePlanning Plan 节点 plan_done + 用户确认后
|
||||
│
|
||||
v
|
||||
PhaseExecuting Execute 节点执行中
|
||||
│
|
||||
├──→ PhaseWaitingConfirm Execute 输出 action=confirm
|
||||
│ │
|
||||
│ v 用户确认
|
||||
│ PhaseExecuting 恢复继续执行
|
||||
│
|
||||
├──→ PhaseDone Execute 输出 done 或所有步骤完成
|
||||
│
|
||||
└──→ PhaseInterrupted 被中断(追问/确认等待用户输入)
|
||||
```
|
||||
|
||||
### 3.3 PendingInteraction 生命周期
|
||||
|
||||
```
|
||||
场景 A: 计划确认
|
||||
Plan → plan_done → Confirm 节点 → OpenConfirmInteraction(type="confirm")
|
||||
→ Interrupt 展示 → END
|
||||
→ 用户确认 → Chat(resume) → ResumeFromPending() → Phase=executing
|
||||
|
||||
场景 B: 写操作确认
|
||||
Execute → action=confirm → 设置 PendingConfirmTool → Confirm 节点
|
||||
→ OpenConfirmInteraction(type="confirm", PendingTool=快照)
|
||||
→ Interrupt 展示 → END
|
||||
→ 用户确认 → Chat(resume) → PendingConfirmTool 从快照恢复 → Execute 执行工具
|
||||
|
||||
场景 C: 追问
|
||||
Execute → action=ask_user → OpenAskUserInteraction(question)
|
||||
→ Interrupt 展示 → END
|
||||
→ 用户回复 → Chat(resume) → Phase 回到 executing
|
||||
```
|
||||
|
||||
### 3.4 PendingConfirmTool 临时邮箱
|
||||
|
||||
这个字段 **不持久化**,只在单次图运行中存在:
|
||||
|
||||
```
|
||||
Execute(action=confirm)
|
||||
→ PendingConfirmTool = {ToolName, ArgsJSON, Summary}
|
||||
→ Phase = waiting_confirm
|
||||
→ Confirm 节点读取 → 转入 PendingInteraction.PendingTool
|
||||
→ PendingConfirmTool 被清空
|
||||
|
||||
用户确认后重新进入图:
|
||||
→ Chat(resume) → 从 PendingInteraction.PendingTool 恢复到 PendingConfirmTool
|
||||
→ Execute 发现 PendingConfirmTool 非空 → 直接执行工具 → 清空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、各节点详解
|
||||
|
||||
### 4.1 Chat 节点 (`node/chat.go`)
|
||||
|
||||
**职责**:入口分流 + 中断恢复
|
||||
|
||||
**两条路径**:
|
||||
1. **首次进入**:调 LLM 做意图分类("chat" / "task"),chat 直接回复,task 转到 Plan
|
||||
2. **中断恢复**:读取 PendingInteraction,根据类型(ask_user / confirm)走不同恢复路径
|
||||
|
||||
**关键逻辑**:
|
||||
```
|
||||
if HasPendingInteraction():
|
||||
handleChatResume() // 不调 LLM
|
||||
else:
|
||||
chatIntentDecision() // 调 LLM 做意图分类
|
||||
```
|
||||
|
||||
**confirm resume 的 accept/reject 处理**:
|
||||
- accept:从 PendingInteraction.PendingTool 恢复 PendingConfirmTool,Phase=executing
|
||||
- reject(有 PendingTool):不恢复 PendingConfirmTool,Phase=executing(LLM 换方案)
|
||||
- reject(无 PendingTool):调用 RejectPlan(),Phase=planning(回到规划)
|
||||
|
||||
### 4.2 Plan 节点 (`node/plan.go`)
|
||||
|
||||
**职责**:LLM 生成结构化计划
|
||||
|
||||
**两阶段 LLM 调用**:
|
||||
1. **Phase 1 快速评估**:temperature=0.2, max_tokens=1600, thinking=关闭
|
||||
- 输出 PlanDecision,判断 Complexity(simple/moderate/complex)
|
||||
2. **Phase 2 深度规划**(仅 complex 任务触发):thinking=开启, max_tokens=3200
|
||||
- 生成更详细的 PlanStep 列表(含 DoneWhen 完成判定条件)
|
||||
|
||||
**三种 action**:
|
||||
- `continue`:继续规划(多轮对话中补充信息)
|
||||
- `ask_user`:追问用户
|
||||
- `plan_done`:规划完成,输出 PlanSteps
|
||||
|
||||
**计划写入 PinnedBlocks**:用 `UpsertPinnedBlock` 把计划文本注入 ConversationContext,后续 Execute 阶段自动带入。
|
||||
|
||||
### 4.3 Confirm 节点 (`node/confirm.go`)
|
||||
|
||||
**职责**:创建确认卡片,不调 LLM
|
||||
|
||||
**两种确认**:
|
||||
1. **计划确认**(Phase=waiting_confirm, PendingConfirmTool 为空):格式化计划摘要,创建 PendingInteraction
|
||||
2. **工具确认**(PendingConfirmTool 非空):格式化工具操作摘要,把 PendingTool 快照转入 PendingInteraction
|
||||
|
||||
**关键**:Confirm 节点执行后,PendingConfirmTool 被清空(数据已转移到 PendingInteraction.PendingTool)。
|
||||
|
||||
### 4.4 Execute 节点 (`node/execute.go`)
|
||||
|
||||
**职责**:LLM 主导的 ReAct 循环,这是最复杂的节点。
|
||||
|
||||
**入口判断优先级**:
|
||||
```
|
||||
1. PendingConfirmTool 非空 → executePendingTool() → 结束
|
||||
2. 无有效 PlanStep → 报错
|
||||
3. 正常 ReAct → 调 LLM → 处理决策
|
||||
```
|
||||
|
||||
**LLM 调用参数**:temperature=0.3, max_tokens=1200, thinking=开启
|
||||
|
||||
**JSON 解析失败处理**(correction 机制):
|
||||
```
|
||||
LLM 输出非 JSON:
|
||||
→ ConsecutiveCorrections++
|
||||
→ 追加修正消息到历史
|
||||
→ return nil(图循环回来,LLM 看到修正消息后重试)
|
||||
→ 连续 3 次失败 → 返回硬错误,终止
|
||||
```
|
||||
|
||||
**五种 action 处理**:
|
||||
| action | 行为 | 工具执行? |
|
||||
|--------|------|-----------|
|
||||
| continue + tool_call | 读工具直接执行 | 是,executeToolCall() |
|
||||
| continue 无 tool | 仅说话,继续循环 | 否 |
|
||||
| confirm | 暂存 PendingConfirmTool | 否,等用户确认 |
|
||||
| ask_user | 打开追问 | 否 |
|
||||
| next_plan | 推进步骤 | 否 |
|
||||
| done | 结束所有步骤 | 否 |
|
||||
|
||||
**工具执行后历史消息格式**:
|
||||
```
|
||||
assistant message: {Role: "assistant", ToolCalls: [{ID, Function: {Name, Arguments}}]}
|
||||
tool message: {Role: "tool", ToolCallID: <匹配ID>, Content: "工具结果"}
|
||||
```
|
||||
这对消息必须配对,否则 OpenAI 兼容 API 会拒绝请求。
|
||||
|
||||
**轮次预算**:MaxRounds 默认 30,耗尽强制进入 Deliver。
|
||||
|
||||
### 4.5 Interrupt 节点 (`node/interrupt.go`)
|
||||
|
||||
**职责**:向用户展示消息后暂停图执行
|
||||
|
||||
**三种类型**:
|
||||
- ask_user:伪流式展示 DisplayText
|
||||
- confirm:展示确认状态
|
||||
- 默认:展示通用中断信息
|
||||
|
||||
### 4.6 Deliver 节点 (`node/deliver.go`)
|
||||
|
||||
**职责**:生成最终总结
|
||||
|
||||
- 调 LLM(temperature=0.5, max_tokens=800)生成总结
|
||||
- 失败时降级到机械格式化(逐条列出步骤 + 完成标记)
|
||||
- 完成后调用 deleteAgentState() 清理 Redis 快照
|
||||
|
||||
---
|
||||
|
||||
## 五、LLM 交互模式
|
||||
|
||||
### 5.1 统一 JSON 协议
|
||||
|
||||
所有 LLM 输出都是严格 JSON,不是纯文本。每个阶段有自己的合约:
|
||||
|
||||
**Plan 合约** (`model/plan_contract.go`):
|
||||
```json
|
||||
{
|
||||
"speak": "...",
|
||||
"action": "continue|ask_user|plan_done",
|
||||
"reason": "...",
|
||||
"complexity": "simple|moderate|complex",
|
||||
"need_thinking": false,
|
||||
"plan_steps": [{"content": "...", "done_when": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
**Execute 合约** (`model/execute_contract.go`):
|
||||
```json
|
||||
{
|
||||
"speak": "...",
|
||||
"action": "continue|ask_user|confirm|next_plan|done",
|
||||
"reason": "...",
|
||||
"goal_check": "(next_plan/done 时必填)",
|
||||
"tool_call": {"name": "工具名", "arguments": {...}}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 JSON 解析容错
|
||||
|
||||
`llm/json.go` 的 `ParseJSONObject` 能处理:
|
||||
- LLM 在 JSON 前后附带文字 → 提取中间的 JSON 对象
|
||||
- Markdown 代码块包裹(```json ... ```)→ 剥离
|
||||
- 嵌套对象(大括号配对计数)
|
||||
|
||||
### 5.3 Correction 循环
|
||||
|
||||
当 LLM 输出非法 JSON 时:
|
||||
```
|
||||
1. 原始输出作为 assistant 消息追加到历史
|
||||
2. 修正提示作为 user 消息追加到历史
|
||||
3. return nil → 图循环回来
|
||||
4. LLM 看到修正消息,下一轮输出合法 JSON
|
||||
5. ConsecutiveCorrections 重置为 0
|
||||
6. 连续 3 次失败 → 硬错误终止
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、工具系统
|
||||
|
||||
### 6.1 数据模型
|
||||
|
||||
`ScheduleState` 是工具操作的唯一数据源:
|
||||
|
||||
```
|
||||
ScheduleState
|
||||
├── Window 时间窗口
|
||||
│ ├── TotalDays 总天数(如 5 或 7)
|
||||
│ └── DayMapping[] day_index → (week, day_of_week) 映射
|
||||
└── Tasks[] 扁平任务列表
|
||||
├── source="event" 来自日程表的已有课程/任务
|
||||
│ ├── Slots[] 压缩的时段范围
|
||||
│ ├── CanEmbed 是否允许嵌入
|
||||
│ └── Locked 是否锁定(不可移动)
|
||||
└── source="task_item" 来自任务类的待安排任务
|
||||
├── Duration 需要的连续时段数
|
||||
├── CategoryID 所属 TaskClass.ID
|
||||
└── Status="pending" 待安排
|
||||
```
|
||||
|
||||
### 6.2 10 个工具
|
||||
|
||||
**读工具(直接执行,不需要确认)**:
|
||||
|
||||
| 工具 | 用途 | 典型调用时机 |
|
||||
|------|------|------------|
|
||||
| `get_overview` | 全局概览:天数、占用统计、可嵌入、待安排 | LLM 需要了解全局 |
|
||||
| `query_range` | 查询指定天/时段的详情 | LLM 需要具体位置信息 |
|
||||
| `find_free` | 查找连续空闲时段 | LLM 需要找空位放任务 |
|
||||
| `list_tasks` | 按条件列出任务 | LLM 需要筛选任务 |
|
||||
| `get_task_info` | 单个任务详情(含嵌入关系) | LLM 需要具体任务信息 |
|
||||
|
||||
**写工具(需用户确认)**:
|
||||
|
||||
| 工具 | 用途 | 关键逻辑 |
|
||||
|------|------|---------|
|
||||
| `place` | 放置 pending 任务到时段 | 自动检测嵌入(CanEmbed=true 的宿主) |
|
||||
| `move` | 移动已有任务到新位置 | 冲突检测(排除自身) |
|
||||
| `swap` | 交换两个等时长任务的时段 | 冲突时自动回滚 |
|
||||
| `batch_move` | 批量移动多个任务 | 原子性:任一冲突全部回滚 |
|
||||
| `unplace` | 取消放置,恢复 pending | 清理双向嵌入关系 |
|
||||
|
||||
### 6.3 工具执行流程
|
||||
|
||||
**读工具**(action=continue + tool_call):
|
||||
```
|
||||
Execute → executeToolCall() → registry.Execute() → 追加 assistant+tool 消息对 → return nil → 图循环
|
||||
```
|
||||
|
||||
**写工具**(action=confirm):
|
||||
```
|
||||
Execute → handleExecuteActionConfirm() → 暂存 PendingConfirmTool
|
||||
→ Confirm 节点 → Interrupt → 用户确认
|
||||
→ Chat(resume) → 恢复 PendingConfirmTool
|
||||
→ Execute → executePendingTool() → registry.Execute() + persistor + 追加消息对
|
||||
```
|
||||
|
||||
### 6.4 持久化路径
|
||||
|
||||
```
|
||||
工具执行成功
|
||||
→ DiffScheduleState(original, modified) → []ScheduleChange
|
||||
→ PersistScheduleChanges(事务)
|
||||
→ applyPlaceChange / applyMoveChange / applyUnplaceChange
|
||||
```
|
||||
|
||||
**当前限制**:`applyPlaceChange` 只处理 `source="event"`,`source="task_item"` 会报错。详见 ROADMAP.md P0 缺口。
|
||||
|
||||
---
|
||||
|
||||
## 七、SSE 输出系统
|
||||
|
||||
### 7.1 ChunkEmitter
|
||||
|
||||
所有节点通过 `ChunkEmitter` 向前端推送事件:
|
||||
|
||||
```
|
||||
EmitPseudoAssistantText() → 伪流式文本(分段推送,模拟打字效果)
|
||||
EmitStatus() → 状态推送("正在执行第2步")
|
||||
EmitConfirmRequest() → 确认卡片
|
||||
EmitFinish() / EmitDone() → 结束标记
|
||||
```
|
||||
|
||||
### 7.2 伪流式
|
||||
|
||||
LLM 的一次性文本输出通过 `SplitPseudoStreamText` 拆分成多个 chunk:
|
||||
- 按中英文标点断句
|
||||
- 每个 chunk 8~24 个字符
|
||||
- 间隔 40ms 推送
|
||||
|
||||
### 7.3 OpenAI 兼容格式
|
||||
|
||||
`stream/openai.go` 定义了 OpenAI 兼容的 SSE 格式,通过 `ext` 字段扩展:
|
||||
- `reasoning_text`:思考过程
|
||||
- `assistant_text`:正文
|
||||
- `status`:状态更新
|
||||
- `tool_call` / `tool_result`:工具调用
|
||||
- `confirm_request`:确认卡片
|
||||
- `interrupt`:中断消息
|
||||
|
||||
---
|
||||
|
||||
## 八、持久化模型
|
||||
|
||||
### 8.1 三个持久化层次
|
||||
|
||||
| 层级 | 机制 | 何时触发 | 存什么 |
|
||||
|------|------|---------|--------|
|
||||
| 快照 | AgentStateStore (Redis) | Plan/Confirm/Execute 节点后 | AgentRuntimeState + ConversationContext |
|
||||
| 变更 | SchedulePersistor (MySQL) | 写工具执行后 | ScheduleState 的 diff |
|
||||
| 历史 | Redis + MySQL | 图运行完成后 | 完整对话历史 |
|
||||
|
||||
### 8.2 快照恢复流程
|
||||
|
||||
```
|
||||
用户发送新消息(图需要从中断恢复)
|
||||
→ loadOrCreateRuntimeState()
|
||||
→ StateStore.Load(conversationID)
|
||||
→ 如果存在:恢复 RuntimeState + ConversationContext
|
||||
→ 如果不存在:创建全新状态
|
||||
```
|
||||
|
||||
快照在 Deliver 后被 `deleteAgentState()` 清理。
|
||||
|
||||
---
|
||||
|
||||
## 九、Prompt 体系
|
||||
|
||||
### 9.1 prompt 构造模式
|
||||
|
||||
所有阶段现在统一共享 `buildUnifiedStageMessages()` 函数:
|
||||
|
||||
```
|
||||
msg0(system) = 全局 system prompt + 阶段 system prompt + 工具简表
|
||||
msg1(assistant) = 对话历史 + 归档摘要
|
||||
msg2(assistant) = 阶段工作区
|
||||
msg3(system) = 阶段状态 + 记忆 + 本轮指令
|
||||
```
|
||||
|
||||
统一构造由 `StageMessagesConfig` 驱动,具体阶段只负责填充各自的 `Msg2Content`、`Msg3StageState` 和 `UserInstruction`。
|
||||
|
||||
### 9.2 各阶段 prompt 要点
|
||||
|
||||
| 阶段 | 核心指令 | 关键约束 |
|
||||
|------|---------|---------|
|
||||
| Chat | 分类意图:chat vs task | 保守默认为 task |
|
||||
| Plan | 两阶段:快速评估 + 深度规划 | 简单任务不开启 thinking |
|
||||
| Execute | ReAct:思考→执行→观察 | goal_check 为 next_plan/done 必填 |
|
||||
| Deliver | 总结计划执行结果 | 失败降级到机械格式化 |
|
||||
|
||||
### 9.3 置顶上下文块
|
||||
|
||||
```
|
||||
PinnedBlocks 是跨节点共享的上下文,通过 Key 去重:
|
||||
|
||||
execution_context ← Execute 节点注入(当前步骤、完成判定等)
|
||||
plan ← Plan 节点注入(完整计划文本)
|
||||
tool_summary ← Execute 节点注入(可用工具摘要)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、图路由逻辑 (`graph/common_graph.go`)
|
||||
|
||||
路由函数是图的核心控制逻辑,决定了每步之后走向哪个节点:
|
||||
|
||||
### branchAfterChat
|
||||
```
|
||||
if PendingInteraction → Interrupt
|
||||
else switch Phase:
|
||||
planning → Plan
|
||||
executing → Execute
|
||||
done → Deliver
|
||||
chatting → END
|
||||
```
|
||||
|
||||
### branchAfterPlan
|
||||
```
|
||||
if PendingInteraction → Interrupt
|
||||
else switch Phase:
|
||||
waiting_confirm → Confirm
|
||||
planning → Plan(continue,继续规划)
|
||||
executing → Execute(不应该发生,但防御性路由)
|
||||
```
|
||||
|
||||
### branchAfterConfirm
|
||||
```
|
||||
if PendingInteraction → Interrupt
|
||||
else → Execute(确认通过)
|
||||
```
|
||||
|
||||
### branchAfterExecute
|
||||
```
|
||||
if PendingInteraction → Interrupt
|
||||
else switch Phase:
|
||||
executing → Execute(继续循环)
|
||||
done → Deliver
|
||||
waiting_confirm → Confirm(不应该发生,防御性路由)
|
||||
```
|
||||
|
||||
### 关键保护机制
|
||||
|
||||
所有分支函数都以 `branchIfInterrupted()` 开头:
|
||||
```go
|
||||
func branchIfInterrupted(st *AgentGraphState) string {
|
||||
if st.RuntimeState.HasPendingInteraction() {
|
||||
return "interrupt"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
这确保任何节点设置了 PendingInteraction 后,图都会走向 Interrupt 节点展示给用户。
|
||||
|
||||
---
|
||||
|
||||
## 十一、Service 集成层 (`service/agentsvc/agent_newagent.go`)
|
||||
|
||||
### 入口函数:runNewAgentGraph
|
||||
|
||||
```
|
||||
1. 规范化 conversationID, modelName
|
||||
2. 确保会话存在(Redis 缓存 → DB)
|
||||
3. 构建重试元数据
|
||||
4. 加载或创建 RuntimeState(从 Redis 快照恢复)
|
||||
5. 构建 AgentGraphRequest(ConfirmAction 从 extra 取)
|
||||
6. 包装 Ark 客户端
|
||||
7. 创建 SSE 适配器 + ChunkEmitter
|
||||
8. 组装 AgentGraphDeps(注入所有依赖)
|
||||
9. 调用 RunAgentGraph()
|
||||
10. 持久化对话历史到 Redis + MySQL
|
||||
11. 发送 [DONE] 标记,触发异步标题生成
|
||||
```
|
||||
|
||||
### 依赖注入
|
||||
|
||||
```
|
||||
cmd/start.go:
|
||||
→ NewScheduleProvider(scheduleDAO, taskClassDAO) → SetScheduleProvider()
|
||||
→ NewSchedulePersistorAdapter(repoManager) → SetSchedulePersistor()
|
||||
→ NewDefaultRegistry() → SetToolRegistry()
|
||||
→ NewRedisStateStore(cacheDAO) → SetAgentStateStore()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、如何调试
|
||||
|
||||
### 12.1 日志关键字
|
||||
|
||||
| 搜索关键字 | 含义 |
|
||||
|-----------|------|
|
||||
| `[DEBUG] execute LLM` | Execute 节点的 LLM 原始输出和解析结果 |
|
||||
| `[DEBUG] plan LLM` | Plan 节点的 LLM 输出 |
|
||||
| `[WARN] execute 决策不合法` | LLM 输出合法 JSON 但 action 不合法 |
|
||||
| `[DEBUG] execute LLM 输出解析失败` | JSON 解析失败,触发 correction |
|
||||
| `PersistScheduleChanges` | 持久化调用 |
|
||||
| `loadOrCreateRuntimeState` | 状态恢复/创建 |
|
||||
|
||||
### 12.2 常见问题排查
|
||||
|
||||
**SSE 断开**:
|
||||
1. 检查 `[DEBUG] execute LLM` 日志,看 LLM 输出是否为合法 JSON
|
||||
2. 如果输出 `[NEXT_PLAN]` 等纯文本 → prompt 问题(已修复,参考 execute.go 的 correction 机制)
|
||||
3. 如果输出合法 JSON 但 action 不对 → 检查 prompt 的合约文本
|
||||
|
||||
**工具不执行**:
|
||||
1. 检查 PendingConfirmTool 是否被正确设置和恢复
|
||||
2. 检查 ScheduleState 是否为 nil(可能 ScheduleProvider 未注入)
|
||||
3. 检查 history 中 assistant+tool 消息是否配对(ToolCallID 是否匹配)
|
||||
|
||||
**图循环不退出**:
|
||||
1. 检查 ConsecutiveCorrections 计数(可能 LLM 反复输出非法 JSON)
|
||||
2. 检查 RoundUsed 是否耗尽(MaxRounds 默认 30)
|
||||
3. 检查 Phase 是否卡在某个状态
|
||||
|
||||
### 12.3 单元测试
|
||||
|
||||
```
|
||||
node/execute_confirm_flow_test.go → 7 个测试,覆盖完整 confirm 回路
|
||||
node/llm_tool_orchestration_test.go → 5 个测试,覆盖真实排课场景
|
||||
```
|
||||
|
||||
测试使用 mock LLM(预定义 JSON 响应序列)和 mock 工具注册表,不依赖外部服务。
|
||||
|
||||
---
|
||||
|
||||
## 十三、关键设计决策及理由
|
||||
|
||||
| 决策 | 理由 |
|
||||
|------|------|
|
||||
| Phase 驱动路由而非硬编码序列 | 同一个图支持多种流程(直接聊天、排课、追问恢复),Phase 是最小状态信号 |
|
||||
| PendingInteraction 作为中断快照 | 图是无状态的(每次请求重新运行),需要一种机制跨请求传递"等用户回复"的上下文 |
|
||||
| PendingConfirmTool 作为临时邮箱 | Execute 和 Confirm 之间不能直接传参(中间隔了 Interrupt+END+Chat),用运行态字段传递 |
|
||||
| JSON 协议而非文本标记 | LLM 输出结构化数据,后端用泛型解析,避免正则匹配的不确定性 |
|
||||
| Correction 机制 | LLM 不是 100% 可靠,需要给修正机会,但限制最大连续次数避免死循环 |
|
||||
| 伪流式而非真流式 | LLM API 的一次性返回更适合分段推送,真流式实现复杂且收益低 |
|
||||
| 工具操作扁平 ScheduleState | 避免嵌套数据结构,工具只需关心"在哪里放什么" |
|
||||
| Diff 持久化 | 只持久化变更部分,减少 DB 操作,支持原子性 |
|
||||
| PinnedBlocks 注入上下文 | 计划、工具摘要等信息不需要每轮都重复,用置顶块注入一次即可 |
|
||||
|
||||
---
|
||||
|
||||
## 十四、关键文件速查
|
||||
|
||||
| 想了解... | 看这个文件 |
|
||||
|----------|----------|
|
||||
| 图怎么连的 | `graph/common_graph.go` |
|
||||
| 每个节点怎么被调用的 | `node/agent_nodes.go` |
|
||||
| Chat 怎么分类意图的 | `prompt/chat.go` + `node/chat.go` |
|
||||
| Plan 怎么生成计划的 | `prompt/plan.go` + `node/plan.go` |
|
||||
| Execute 的 ReAct 循环 | `node/execute.go` |
|
||||
| confirm 回路怎么转的 | `node/confirm.go` + `node/chat.go`(handleConfirmResume) |
|
||||
| LLM 输出什么格式 | `model/plan_contract.go` + `model/execute_contract.go` |
|
||||
| JSON 解析怎么容错的 | `llm/json.go` |
|
||||
| correction 怎么追回的 | `node/correction.go` |
|
||||
| 工具怎么注册和执行的 | `tools/registry.go` |
|
||||
| 工具操作什么数据 | `tools/state.go` |
|
||||
| SSE 输出什么格式 | `stream/openai.go` |
|
||||
| 状态怎么持久化的 | `model/state_store.go` + `conv/schedule_persist.go` |
|
||||
| 日程数据怎么加载的 | `conv/schedule_provider.go` + `conv/schedule_state.go` |
|
||||
| Service 怎么组装的 | `service/agentsvc/agent_newagent.go` |
|
||||
| API 怎么调用的 | `api/agent.go` |
|
||||
| 距离全链路还差什么 | `ROADMAP.md` |
|
||||
@@ -1,142 +0,0 @@
|
||||
# WebSearch 两阶段实施计划(newAgent)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本文用于把 `newAgent` 的 WebSearch 能力按两阶段落地:
|
||||
|
||||
1. 第一阶段:先接入可用的检索与抓取能力(低风险、快交付)。
|
||||
2. 第二阶段:在第一阶段基础上升级为 WebRAG 语义召回链路(提升复杂问题命中率与可解释性)。
|
||||
|
||||
约束:
|
||||
|
||||
1. 不走 `infra/smartflow-mcp-server`,直接走 `newAgent/tools` 工具注册链路。
|
||||
2. 保持现有执行模式不变:读操作 `action=continue + tool_call`。
|
||||
3. 第一阶段只接单供应商;第二阶段再考虑 provider fallback。
|
||||
|
||||
---
|
||||
|
||||
## 2. 第一阶段(V1):WebSearch + 简单抓取
|
||||
|
||||
### 2.1 交付目标
|
||||
|
||||
让模型可以:
|
||||
|
||||
1. 通过 `web_search` 获得结构化检索结果(标题、摘要、URL、来源域名、时间)。
|
||||
2. 通过 `web_fetch` 拉取指定 URL 正文并做最小清洗。
|
||||
3. 在不改主流程的前提下,把结果作为标准 `tool observation` 写回历史。
|
||||
|
||||
### 2.2 计划新增工具
|
||||
|
||||
1. `web_search`
|
||||
- 输入:`query`、`top_k`、`domain_allow`、`recency_days` 等。
|
||||
- 输出:JSON 字符串(`tool`、`query`、`count`、`items[]`)。
|
||||
2. `web_fetch`
|
||||
- 输入:`url`、`max_chars`。
|
||||
- 输出:JSON 字符串(`tool`、`url`、`title`、`content`、`truncated`)。
|
||||
|
||||
### 2.3 代码落点
|
||||
|
||||
新增文件:
|
||||
|
||||
1. `backend/newAgent/tools/web_tools.go`:工具参数解析、输出组装、错误兜底。
|
||||
2. `backend/newAgent/tools/web_provider.go`:搜索供应商抽象接口与通用数据结构。
|
||||
3. `backend/newAgent/tools/web_provider_tavily.go`(或 `web_provider_brave.go`):首个 provider 实现。
|
||||
4. `backend/newAgent/tools/web_fetcher.go`:URL 抓取与 HTML 最小清洗。
|
||||
|
||||
修改文件:
|
||||
|
||||
1. `backend/newAgent/tools/registry.go`:注册 `web_search`、`web_fetch` 两个读工具。
|
||||
2. `backend/cmd/start.go`:初始化 provider 配置并注入 registry(或通过包级配置读取)。
|
||||
3. `backend/newAgent/prompt/execute_context.go`:补充新工具的 schema 说明与示例。
|
||||
|
||||
### 2.4 V1 验收标准
|
||||
|
||||
1. 模型能稳定调用 `web_search` 并拿到可解析 JSON 结果。
|
||||
2. `web_fetch` 在正文可达时返回正文,在失败时返回明确错误码与原因。
|
||||
3. 工具超时、429、5xx 均不会打断主流程,只返回可恢复 observation。
|
||||
4. 日志可定位:query、tool、耗时、结果数、失败原因。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第二阶段(V2):WebRAG 语义召回
|
||||
|
||||
### 3.1 交付目标
|
||||
|
||||
新增 `web_rag_search`,把“检索 + 抓取 + 分块 + 召回 + 重排 + 证据返回”收敛为一个读工具,提升复杂问答质量。
|
||||
|
||||
### 3.2 链路设计
|
||||
|
||||
1. 查询改写:把用户问题改写为 1~3 个检索子查询。
|
||||
2. WebSearch 召回:拿到候选 URL 集合。
|
||||
3. 抓取清洗:抽正文,去噪。
|
||||
4. 分块:按段落与 token 预算切块。
|
||||
5. 召回:向量召回 + 关键词召回(混合召回)。
|
||||
6. 重排:按 query 相关性重排 chunk。
|
||||
7. 输出:返回答案所需证据片段、来源 URL、片段得分。
|
||||
|
||||
### 3.3 代码落点
|
||||
|
||||
新增文件:
|
||||
|
||||
1. `backend/newAgent/tools/web_rag_tools.go`:`web_rag_search` 工具入口。
|
||||
2. `backend/newAgent/tools/web_rag_chunker.go`:清洗后分块。
|
||||
3. `backend/newAgent/tools/web_rag_retriever.go`:混合召回。
|
||||
4. `backend/newAgent/tools/web_rag_rerank.go`:重排层。
|
||||
5. `backend/newAgent/tools/web_rag_store.go`:会话级索引缓存(先内存/Redis TTL)。
|
||||
|
||||
修改文件:
|
||||
|
||||
1. `backend/newAgent/tools/registry.go`:注册 `web_rag_search`。
|
||||
2. `backend/newAgent/prompt/execute_context.go`:增加 `web_rag_search` 使用规范。
|
||||
|
||||
### 3.4 V2 验收标准
|
||||
|
||||
1. 同类复杂问题下,回答引用质量和相关性明显高于 V1。
|
||||
2. 返回至少包含:`answer_evidence[]`(片段+URL+score)。
|
||||
3. 召回或重排失败时可降级到 V1(`web_search + web_fetch`)路径。
|
||||
4. 提供基础评估指标:命中率、延迟、成本、失败率。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与记忆系统的关系
|
||||
|
||||
`WebRAG` 与记忆系统 RAG 高度重合,建议“共用内核、分语料适配”:
|
||||
|
||||
1. 共用:chunk / embed / retrieve / rerank 的通用接口与实现。
|
||||
2. 分开:`MemoryCorpus`(私有数据)与 `WebCorpus`(公网数据)的数据源适配层。
|
||||
3. 在工具层保持两个入口:`memory_search` 与 `web_rag_search`,返回结构尽量统一。
|
||||
|
||||
---
|
||||
|
||||
## 5. 上线顺序与回滚策略
|
||||
|
||||
### 5.1 上线顺序
|
||||
|
||||
1. 先灰度 V1:仅开放 `web_search`、`web_fetch`。
|
||||
2. 观察稳定性与成本后再灰度 V2:`web_rag_search`。
|
||||
3. V2 稳定后再考虑 provider fallback 与更长周期缓存。
|
||||
|
||||
### 5.2 回滚策略
|
||||
|
||||
1. `web_rag_search` 异常时,快速降级为 V1 工具集。
|
||||
2. V1 供应商异常时,返回“检索暂不可用”的结构化 observation,不阻断主流程。
|
||||
3. 保留 feature flag:按工具级别开关,支持秒级关闭。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险清单
|
||||
|
||||
1. 供应商配额/限流导致查询失败。
|
||||
2. 页面反爬与正文抽取质量不稳定。
|
||||
3. RAG 链路成本上升(抓取+embedding+重排)。
|
||||
4. 引用片段与最终答案不一致(需要强制证据对齐策略)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 里程碑建议
|
||||
|
||||
1. M1(1~2 天):V1 工具跑通,联调 Execute 节点可调用。
|
||||
2. M2(2~4 天):V1 稳定性优化(超时/限流/日志/错误码)。
|
||||
3. M3(4~7 天):V2 WebRAG MVP(混合召回+基础重排+证据输出)。
|
||||
4. M4(后续):统一 RAG Core,打通记忆系统复用。
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# newAgent 优化待办 Handoff
|
||||
|
||||
> 日期:2026-04-21
|
||||
> 来源:迁移 agent/ → newAgent/ 完成后的架构审视
|
||||
|
||||
---
|
||||
|
||||
## 1. TaskQuery 紧急度提升统一
|
||||
|
||||
### 问题
|
||||
|
||||
LLM 工具查询任务(`AgentService.QueryTasksForTool`)使用 `applyReadTimeUrgencyPromotion` 只做内存态优先级提升,不触发 outbox 写 MySQL。
|
||||
前端查询任务(`TaskService.GetUserTasks`)使用 `deriveTaskUrgencyForRead` + `tryEnqueueTaskUrgencyPromote`,会异步持久化。
|
||||
|
||||
两条路径行为不一致:LLM 看到的优先级可能比 DB 里的高。
|
||||
|
||||
### 方案
|
||||
|
||||
1. `service/task.go` — 从 `GetUserTasks` 中提取公共方法(如 `GetTasksWithUrgencyPromotion`),返回已提升的 `[]model.Task` 并触发 outbox
|
||||
2. `service/agentsvc/agent.go` — 新增 `taskSvc *service.TaskService` 字段
|
||||
3. `service/agentsvc/agent_task_query.go` — 重写 `QueryTasksForTool`,调用 TaskService 公共方法;删除 `applyReadTimeUrgencyPromotion` 死代码
|
||||
4. `cmd/start.go` — 注入 TaskService 到 AgentService
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `service/task.go` | 提取公共方法 |
|
||||
| `service/agentsvc/agent.go` | 加 taskSvc 字段 |
|
||||
| `service/agentsvc/agent_task_query.go` | 重写,删 `applyReadTimeUrgencyPromotion` |
|
||||
| `cmd/start.go` | 注入 TaskService |
|
||||
|
||||
---
|
||||
|
||||
## 2. service/agentsvc 层瘦身(低优先级)
|
||||
|
||||
### 现状
|
||||
|
||||
`service/agentsvc/` 目前 11 个文件,大部分是 HTTP→DB 转接层,职责合理。但有两个纯逻辑文件理论上可下沉:
|
||||
|
||||
| 文件 | 内容 | 可移至 |
|
||||
|------|------|--------|
|
||||
| `agent_memory_render.go` | 纯文本转换,零 DB 交互 | `memory/` 包 |
|
||||
| `agent_task_query.go` 的 `taskMatchesQueryFilter` / `sortTasksForQuery` | 纯过滤/排序 | `newAgent/tools/` |
|
||||
|
||||
### 判断
|
||||
|
||||
当前体量小(加起来约 200 行纯函数),搬出去收益不大,反而多一层 import 间接。如果未来这些函数膨胀再搬不迟。
|
||||
|
||||
---
|
||||
|
||||
## 3. go mod tidy
|
||||
|
||||
迁移完成后 `go.mod` 中有未使用的依赖(如 `github.com/bytedance/mockey`)。建议跑一次 `go mod tidy` 清理。
|
||||
@@ -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"
|
||||
@@ -1,300 +0,0 @@
|
||||
# NewAgent 全链路改造计划
|
||||
|
||||
> 本文档面向后续 coding agent,描述当前 newAgent 的架构现状、改造计划,以及距离"会主动问用户问题、生成多个任务类、自己 ReAct 排日程的智能体"还差什么。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标智能体行为
|
||||
|
||||
用户说:"帮我安排下周的复习计划"。
|
||||
|
||||
期望的完整链路:
|
||||
|
||||
```
|
||||
1. Chat 节点:LLM 主动追问
|
||||
- "这周有几门考试?"
|
||||
- "复习强度偏好?均匀分布还是集中在前几天?"
|
||||
- "有没有要排除的时段?"
|
||||
|
||||
2. Plan 节点:LLM 生成结构化计划
|
||||
- 识别意图为"批量安排任务类到日程"
|
||||
- 输出 needs_rough_build=true + task_class_ids
|
||||
- 或者:LLM 调用 create_task_class 工具创建新的任务类
|
||||
|
||||
3. Confirm 节点:展示计划,用户确认
|
||||
|
||||
4. RoughBuild 节点(新增):确定性粗排
|
||||
- 调用 SmartPlanningRawItemsMulti() 算法
|
||||
- 结果写入 ScheduleState(pending tasks 预填 suggested slots)
|
||||
|
||||
5. Execute 节点:LLM 用读写工具微调
|
||||
- 查看 get_overview,发现粗排结果
|
||||
- 用 move/swap 调整不合理的安排
|
||||
- 用 place 处理粗排未能安排的任务
|
||||
- 每次写操作经 confirm 流程
|
||||
|
||||
6. Deliver 节点:生成最终总结
|
||||
- 变更持久化到 DB
|
||||
- 向用户展示排课结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、当前架构
|
||||
|
||||
### 图结构
|
||||
|
||||
```
|
||||
Chat → Plan → Confirm → Execute(ReAct) → Deliver
|
||||
```
|
||||
|
||||
### 已实现的能力
|
||||
|
||||
| 模块 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| 图骨架 | `node/agent_nodes.go` | 已实现,6 个节点 |
|
||||
| Chat 节点 | `node/chat.go` | 已实现,支持 confirm resume |
|
||||
| Plan 节点 | `node/plan.go` + `prompt/plan.go` | 已实现,LLM 生成结构化 PlanStep |
|
||||
| Confirm 节点 | `node/confirm.go` | 已实现,创建 PendingInteraction |
|
||||
| Execute 节点 | `node/execute.go` + `prompt/execute.go` | 已实现,ReAct + correction + confirm 流 |
|
||||
| Deliver 节点 | `node/deliver.go` | 已实现,LLM 生成总结 |
|
||||
| 10 个读写工具 | `tools/read_tools.go` + `tools/write_tools.go` | 已实现,5 读 5 写 |
|
||||
| 工具注册表 | `tools/registry.go` | 已实现 |
|
||||
| ScheduleState 加载 | `conv/schedule_provider.go` + `conv/schedule_state.go` | 已实现,从 DB 加载日程+任务类 |
|
||||
| Confirm 回路测试 | `node/execute_confirm_flow_test.go` | 7 个测试全通过 |
|
||||
| 端到端排课测试 | `node/llm_tool_orchestration_test.go` | 5 个测试全通过 |
|
||||
| JSON 协议修正 | `prompt/execute.go` | 已修复,LLM 输出严格 JSON |
|
||||
|
||||
### 已有数据流
|
||||
|
||||
```
|
||||
ScheduleProvider.LoadScheduleState(userID)
|
||||
→ ScheduleDAO.GetUserWeeklySchedule() // 现有日程
|
||||
→ TaskClassDAO.GetCompleteTaskClassesByIDs() // 任务类(含 Items)
|
||||
→ LoadScheduleState() // 合并为 ScheduleState
|
||||
- existing tasks (source="event")
|
||||
- pending tasks (source="task_item", status="pending")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、缺口分析
|
||||
|
||||
### 按优先级排列
|
||||
|
||||
#### P0:粗排接入(核心能力)
|
||||
|
||||
**问题**:新 agent 没有 `SmartPlanningRawItemsMulti` 的调用路径。让 LLM 一个个 place 效率极低且全局最优性差。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
1. **Plan 节点输出扩展**
|
||||
- 文件:`model/plan_contract.go`(或 PlanDecision 所在文件)
|
||||
- PlanDecision 增加 `NeedsRoughBuild bool` 和 `TaskClassIDs []int`
|
||||
- `prompt/plan.go` 引导 LLM 判断意图:
|
||||
- 用户意图为"批量安排/智能排课/把任务类排进日程" → `needs_rough_build: true`
|
||||
- 从前端 `extra` 或对话中提取 `task_class_ids`
|
||||
- 其他意图 → `needs_rough_build: false`
|
||||
|
||||
2. **新增 RoughBuild 图节点**
|
||||
- 新文件:`node/rough_build.go`
|
||||
- 不调 LLM,纯确定性逻辑:
|
||||
```
|
||||
输入:task_class_ids, userID
|
||||
步骤:
|
||||
1. 调 ScheduleService.HybridScheduleWithPlanMulti(ctx, userID, taskClassIDs)
|
||||
(内部调用 SmartPlanningRawItemsMulti 粗排)
|
||||
2. 将粗排结果写入 ScheduleState:
|
||||
- pending tasks 的 Slots 字段填入 suggested 位置
|
||||
- pending tasks 的 Status 保持 "pending"(LLM 可调整,也可以改为 "suggested" 区分)
|
||||
3. 推送状态给前端
|
||||
输出:ScheduleState 已填充粗排结果
|
||||
```
|
||||
- 路由:`needs_rough_build=true` 时 Confirm 之后走 RoughBuild,否则跳过
|
||||
|
||||
3. **图路由修改**
|
||||
- 文件:`node/agent_nodes.go`
|
||||
- Confirm 之后、Execute 之前插入条件分支:
|
||||
```go
|
||||
func (n *AgentNodes) branchAfterConfirm(st *AgentGraphState) string {
|
||||
plan := st.RuntimeState.PlanDecision
|
||||
if plan != nil && plan.NeedsRoughBuild {
|
||||
return "rough_build"
|
||||
}
|
||||
return "execute"
|
||||
}
|
||||
```
|
||||
|
||||
4. **依赖注入**
|
||||
- 文件:`model/graph_run_state.go` AgentGraphDeps
|
||||
- 新增 `RoughBuildFunc` 闭包(和旧 agent 的 `HybridScheduleWithPlanMultiFunc` 同理)
|
||||
- 文件:`service/agentsvc/agent_newagent.go`
|
||||
- 注入 `ScheduleService.HybridScheduleWithPlanMulti` 到 AgentGraphDeps
|
||||
|
||||
**参考实现**:旧 agent 的 `agent/node/schedule_plan.go` 的 `runRoughBuildNode()` 函数。
|
||||
|
||||
---
|
||||
|
||||
#### P0:持久化 task_item 放置(必须)
|
||||
|
||||
**问题**:`conv/schedule_persist.go` 的 `applyPlaceChange` 只处理 `source="event"`,当 LLM place 一个 `source="task_item"` 的 pending 任务时会报错。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
- 文件:`conv/schedule_persist.go`
|
||||
- `applyPlaceChange` 增加 `source="task_item"` 分支:
|
||||
```
|
||||
if source == "task_item":
|
||||
1. 创建 ScheduleEvent(type="task", rel_id=SourceID, name=change.Name)
|
||||
2. 为每个 NewCoord 创建 Schedule 记录
|
||||
3. 如果有嵌入关系(EmbedHost 非空):
|
||||
- 找到宿主 schedule 记录
|
||||
- 设置 schedules.embedded_task_id = SourceID
|
||||
4. 更新 task_items.embedded_time(调用 TaskClassDAO.UpdateTaskClassItemEmbeddedTime)
|
||||
5. 更新 task_items.status = 2 (applied)
|
||||
```
|
||||
- `applyUnplaceChange` 也需要增加 `source="task_item"` 分支(反向清理)
|
||||
|
||||
**参考实现**:旧 agent 的 `service/task-class.go` 的 `BatchApplyPlans()` 函数(第 327-536 行)。
|
||||
|
||||
---
|
||||
|
||||
#### P1:TaskClass 约束元数据暴露给 LLM
|
||||
|
||||
**问题**:LLM 看到的 pending task 只有 name/category/duration,缺少 TaskClass 级别的调度约束。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
1. **扩展 ScheduleState 结构**
|
||||
- 文件:`newAgent/tools/state.go`
|
||||
- 新增:
|
||||
```go
|
||||
type TaskClassMeta struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady" | "rapid"
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课
|
||||
TotalSlots int `json:"total_slots"` // 总时间预算
|
||||
}
|
||||
```
|
||||
- `ScheduleState` 增加 `TaskClasses []TaskClassMeta`
|
||||
|
||||
2. **LoadScheduleState 填充元数据**
|
||||
- 文件:`conv/schedule_state.go`
|
||||
- 在 Step 4(处理 pending task items)同时填充 `state.TaskClasses`
|
||||
|
||||
3. **读工具输出约束信息**
|
||||
- 文件:`tools/read_tools.go`
|
||||
- `get_overview` 输出中增加任务类约束描述
|
||||
- 示例:
|
||||
```
|
||||
任务类约束:
|
||||
[学习] 策略=均匀分布, 总预算=12节, 允许嵌入水课=是, 排除时段=[3,4]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### P2:任务类 ID 从前端传入
|
||||
|
||||
**问题**:前端请求需要在 `extra` 中传递 `task_class_ids`,后端需要接收并传递到图内部。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
1. **API 层**:`api/agent.go` 的请求结构增加 `Extra map[string]any`
|
||||
2. **Service 层**:`service/agentsvc/agent_newagent.go` 从 `extra` 中提取 `task_class_ids`,存入 RuntimeState 或 AgentGraphRequest
|
||||
3. **Plan 节点**:从 RuntimeState/Request 中读取 `task_class_ids`,合并 LLM 从对话中提取的 IDs
|
||||
|
||||
**参考实现**:旧 agent 的 `agent/node/schedule_plan.go` 的 `normalizeTaskClassIDs()` 函数。
|
||||
|
||||
---
|
||||
|
||||
#### P3:LLM 主动追问能力增强
|
||||
|
||||
**问题**:当前 Chat 节点主要做"接收用户消息 + confirm resume",缺少"LLM 主动收集排课需求"的能力。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
- Chat 节点的 prompt 增强:
|
||||
- 引导 LLM 在信息不足时主动追问
|
||||
- 追问内容:考试科目、复习偏好、时段排除、强度偏好
|
||||
- 追问方式:通过 `ask_user` action 或直接在 speak 中提问
|
||||
- 可能需要新增 ConversationContext 的"收集到的需求"字段
|
||||
- 收集到的需求在 Plan 节点中被使用
|
||||
|
||||
---
|
||||
|
||||
#### P4:LLM 创建任务类工具(锦上添花)
|
||||
|
||||
**问题**:用户说"帮我安排复习",但系统里没有对应的 TaskClass,LLM 无法创建。
|
||||
|
||||
**改造内容**:
|
||||
|
||||
1. **新增工具**:`create_task_class`
|
||||
- 参数:`name`, `strategy`, `total_slots`, `allow_filler_course`, `items: [{content, duration}]`
|
||||
- 行为:调用 TaskClassDAO 在 DB 中创建 TaskClass + Items
|
||||
- 返回:创建结果 + 新的 task_class_id
|
||||
|
||||
2. **新增工具**:`update_task_class`(可选)
|
||||
- 修改已有任务类的参数
|
||||
|
||||
3. **ScheduleState 动态刷新**
|
||||
- 创建后需要重新加载 ScheduleState 以反映新的 pending tasks
|
||||
|
||||
---
|
||||
|
||||
## 四、改造顺序建议
|
||||
|
||||
```
|
||||
Phase 1(打通核心链路)
|
||||
├── P0: 粗排接入(RoughBuild 节点 + 图路由 + 依赖注入)
|
||||
├── P0: 持久化 task_item 放置
|
||||
└── P2: task_class_ids 从前端传入
|
||||
|
||||
Phase 2(提升排课质量)
|
||||
├── P1: TaskClass 约束元数据暴露
|
||||
└── P1: 读工具输出优化(携带约束信息)
|
||||
|
||||
Phase 3(智能化)
|
||||
├── P3: Chat 节点追问能力增强
|
||||
└── P4: create_task_class 工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、关键设计决策记录
|
||||
|
||||
1. **粗排由图节点驱动,不由 LLM 驱动**:粗排是确定性算法,浪费 LLM 调用不划算。
|
||||
2. **粗排通过 Plan 节点的 `needs_rough_build` 标签触发**:不是所有请求都需要粗排,LLM 判断意图后打标签。
|
||||
3. **粗排结果写入 ScheduleState 的 Slots 字段**:LLM 在 Execute 阶段看到的是"已粗排、可调整"的状态,用 move/swap 微调。
|
||||
4. **task_class_ids 来源**:前端 `extra` 传入为主,LLM 从对话提取为辅。
|
||||
5. **持久化用 Diff 模式**:对比 original 和 modified ScheduleState,只持久化变更部分。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键文件索引
|
||||
|
||||
| 用途 | 文件 |
|
||||
|------|------|
|
||||
| 图骨架与路由 | `newAgent/node/agent_nodes.go` |
|
||||
| Chat 节点 | `newAgent/node/chat.go` |
|
||||
| Plan 节点 | `newAgent/node/plan.go` |
|
||||
| Confirm 节点 | `newAgent/node/confirm.go` |
|
||||
| Execute 节点 | `newAgent/node/execute.go` |
|
||||
| Deliver 节点 | `newAgent/node/deliver.go` |
|
||||
| Execute prompt | `newAgent/prompt/execute.go` |
|
||||
| Plan prompt | `newAgent/prompt/plan.go` |
|
||||
| 工具状态模型 | `newAgent/tools/state.go` |
|
||||
| 读工具 | `newAgent/tools/read_tools.go` |
|
||||
| 写工具 | `newAgent/tools/write_tools.go` |
|
||||
| 工具注册表 | `newAgent/tools/registry.go` |
|
||||
| 图运行态 | `newAgent/model/graph_run_state.go` |
|
||||
| 公共状态 | `newAgent/model/common_state.go` |
|
||||
| 日程状态加载 | `conv/schedule_provider.go` |
|
||||
| DB→State 转换 | `conv/schedule_state.go` |
|
||||
| Diff 算法 | `conv/schedule_state.go` (DiffScheduleState) |
|
||||
| 持久化 | `conv/schedule_persist.go` |
|
||||
| Service 集成 | `service/agentsvc/agent_newagent.go` |
|
||||
| 粗排算法(旧,复用) | `logic/smart_planning.go` |
|
||||
| 旧 agent 粗排节点(参考) | `agent/node/schedule_plan.go` |
|
||||
| 旧 agent 批量应用(参考) | `service/task-class.go` BatchApplyPlans |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,732 +0,0 @@
|
||||
# SmartFlow 主动优化功能 PRD(讨论版)
|
||||
|
||||
## 0. 文档信息
|
||||
- 文档状态:讨论中(骨架版)
|
||||
- 适用范围:主动优化(对话内 execute + 对话内任务类共创)
|
||||
- 文档目的:先对齐产品方向,再指导后续实现
|
||||
- 约束说明:本 PRD 只谈产品,不谈技术实现
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务背景与问题定义(已讨论 v0.1)
|
||||
### 1.1 当前用户问题
|
||||
- 用户并不总会明确表达需求,存在两类典型入口:
|
||||
- 默认入口:用户未明确偏好,只希望“尽快排好任务类”。
|
||||
- 偏好入口:用户给出较多约束与倾向(强度、时段、节奏、容错等)。
|
||||
- 现状容易把优化做成“单点最佳实践”或“一次性建议”,缺少可持续迭代与偏好对齐。
|
||||
- 因此,工具体系必须同时支持:
|
||||
- 在信息不足时,按科学界公认最佳实践给出稳健中位方案。
|
||||
- 在用户偏好明确时,优先按用户需求调参,不盲从默认最佳实践。
|
||||
|
||||
### 1.2 核心问题陈述
|
||||
- 我们要解决的问题是:
|
||||
`如何让 AI 在“科学最佳实践”和“用户个性化需求”之间做可解释、可调节、可收敛的主动优化。`
|
||||
- 该问题直接决定工具设计方向:
|
||||
- 读工具覆盖面必须足够广,能够支撑不同偏好下的判断。
|
||||
- 每个核心指标必须是“区间型”而不是“单点型”:
|
||||
- 默认站在中位(平衡值)。
|
||||
- 能向左/向右偏移,对应不同用户诉求。
|
||||
|
||||
### 1.3 本章已确定结论
|
||||
- 首发主用户策略:
|
||||
- 若用户需求不提或较弱,系统默认采用中位最佳实践快速生成。
|
||||
- 若用户需求明确且较多,系统优先满足用户需求,科学原则作为安全边界。
|
||||
- “满意方案”判定口径(本章层面):
|
||||
- 本质不是固定模板,而是“在用户诉求方向上的可接受平衡点”。
|
||||
- 默认用户采用中位平衡;偏好用户采用定向偏移平衡。
|
||||
- 自动优化容忍边界(当前已定项):
|
||||
- 轮次上限暂定 60 轮。
|
||||
- 时长与是否开启深度思考的权衡暂不在本章冻结,后续章节决策。
|
||||
|
||||
### 1.4 对后续章节的约束
|
||||
- 第 6 章(科学原则)必须给出“中位默认 + 双向偏移”的可解释规则。
|
||||
- 第 8 章(工具蓝图)必须体现“覆盖广度 + 区间刻度”的产品能力。
|
||||
- 第 11 章(指标验收)必须衡量“默认模式质量”与“偏好对齐质量”两条线。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品目标与非目标(已讨论 v0.1)
|
||||
### 2.1 产品目标定义与优先级(已定)
|
||||
- 目标 A(最高优先级):自主迭代收敛
|
||||
- 定义:AI 以“观测-调整-复盘”循环持续优化,直到达到可接受方案再收口。
|
||||
- 用户价值:减少用户逐步指挥成本,体现“主动出击”。
|
||||
- 目标 B(第二优先级):可解释且有改进证据
|
||||
- 定义:每轮调整都要给出“为何调整、调整内容、前后差异”。
|
||||
- 用户价值:可控、可信,避免“黑箱瞎调”。
|
||||
- 目标 C(第三优先级):对话内任务类共创草案
|
||||
- 定义:用户在聊天中触发后,AI 通过反问与检索产出完整任务类草案。
|
||||
- 用户价值:降低冷启动门槛,减少配置负担,避免新增第二交互区。
|
||||
- 优先级结论:`A > B > C`。
|
||||
|
||||
### 2.2 阶段目标策略(已定)
|
||||
- 首发必须保证:A 与 B 构成闭环能力。
|
||||
- 首发可落可迭代:C 以“可用版”上线,后续逐步提高草案准确率与覆盖深度。
|
||||
- 取舍原则:若资源冲突,优先保障 A;若 A 满足基本可用,再保障 B;C 按剩余资源推进。
|
||||
|
||||
### 2.3 非目标(已定)
|
||||
- 不追求一次优化即全局最优,目标是“可收敛的高质量可接受方案”。
|
||||
- 不追求首发覆盖全部学习风格与全部人群偏好。
|
||||
- 不追求在高风险场景下完全替代用户决策。
|
||||
- 不以“工具数量”作为目标,避免能力堆叠但无法形成闭环价值。
|
||||
|
||||
### 2.4 本章已确定结论
|
||||
- 我们的核心差异化能力是 A(主动迭代优化),不是一次性建议或单轮算法执行。
|
||||
- B 是 A 的信任保障,必须同步建设,不能后补。
|
||||
- C 是重要入口能力,但在首发阶段不应挤占 A/B 的闭环建设资源。
|
||||
|
||||
### 2.5 对后续章节的约束
|
||||
- 第 5 章(主动优化流程)必须完整体现 A 的循环收敛机制。
|
||||
- 第 9 章(交互要求)必须体现 B 的解释与改进证据结构。
|
||||
- 第 12 章(分期路线图)必须以 `A > B > C` 排序规划交付。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户与场景(已讨论 v1.0)
|
||||
### 3.1 目标用户分层(已形成草案)
|
||||
| 用户分层 | 典型特征 | 当前痛点 | 价值诉求 | 首发优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 极速排程型 | 不想多聊,希望尽快出方案 | 参数配置成本高、上手慢 | 一键可用、少改动 | P1 |
|
||||
| 偏好驱动型 | 明确表达强度/时段/节奏偏好 | 通用最佳实践不一定贴合个人需求 | 结果沿偏好方向明显偏移、可控可解释 | P0(首发主优先) |
|
||||
| 反复调优型 | 接受多轮优化,关注持续变好 | 容易遇到来回调整、无效微调 | 稳定收敛、每轮有改进证据 | P1 |
|
||||
|
||||
### 3.2 首发核心场景清单(已形成草案)
|
||||
| 场景 | 触发方式 | 用户期望 | 成功标准 |
|
||||
|---|---|---|---|
|
||||
| 场景 S1:对话内任务类共创草案 | 用户在聊天中提出“帮我设计任务类” | 快速得到完整且可确认的任务类草案 | 用户可直接采纳或仅小幅修改后采纳 |
|
||||
| 场景 S2:对话内“帮我优化一下” | 用户在对话中发起优化请求 | AI 主动多轮调整并收口 | 至少完成 1-2 轮有效改进且最终可交付 |
|
||||
| 场景 S3:对话内“按我的偏好重排” | 用户明确给出偏好/约束 | AI 优先满足偏好,不盲从默认最佳实践 | 结果明显朝偏好方向偏移且不破坏硬约束 |
|
||||
|
||||
### 3.3 场景优先策略(已形成草案)
|
||||
- 首发优先主线:偏好驱动型(P0)。
|
||||
- 原因:该人群最能体现本功能差异化价值,即“可调节的主动优化”,而非一次性默认排程。
|
||||
- 策略要求:所有首发核心场景都必须支持“默认中位 + 偏好偏移”双模式。
|
||||
|
||||
### 3.4 暂不支持场景清单(草案)
|
||||
| 暂不支持场景 | 暂缓原因 | 后续进入条件 |
|
||||
|---|---|---|
|
||||
| 跨超长周期(如整学期/跨学期)全局最优规划 | 目标跨度过大,首发优先保证局部收敛质量 | 收敛稳定性和性能目标达标后再纳入 |
|
||||
| 多主体联合排程(多人协同/冲突协商) | 交互复杂度高,超出首发边界 | 单人场景成熟后评估 |
|
||||
| 高风险不可逆决策自动执行 | 需要更强确认链路与责任边界 | 风险治理机制完善后评估 |
|
||||
|
||||
### 3.5 本章已确定的判定阈值口径
|
||||
- S1(任务类共创草案“小幅修改”阈值):
|
||||
- 定义:关键字段修改率 <= 30% 视为“小幅修改”。
|
||||
- 用途:衡量草案可用性与采纳质量(用于产品验收,不作为用户前台提示)。
|
||||
- S2(主动优化“有效改进”最小标准):
|
||||
- 定义:至少一个核心问题域的严重度下降,视为“有效改进”。
|
||||
- 严重度层级:`critical > warning > info`。
|
||||
- 用途:判断单轮优化是否有实质收益,避免无效循环。
|
||||
- S3(偏好冲突裁决规则):
|
||||
- 定义:用户偏好优先,科学原则兜底。
|
||||
- 用途:在“通用最佳实践 vs 用户个性化需求”冲突时,给出统一裁决路径。
|
||||
|
||||
### 3.6 新增场景候选:对话内任务类共创(WebSearch 增强)
|
||||
#### 3.6.1 场景定义(已讨论结论)
|
||||
- 场景目标:由 AI 在对话中产出“完整任务类草案”,而非仅补全单个参数。
|
||||
- 触发方式:仅支持聊天触发,不新增聊天外按钮入口。
|
||||
- 原因:该能力需要多轮反问与澄清,若放在聊天外容易形成“第二对话区”,增加认知负担。
|
||||
|
||||
#### 3.6.2 信息来源优先级(已讨论结论)
|
||||
- WebSearch 负责:补充通用知识(如课程信息、学习路径共识、考试结构常识)。
|
||||
- 用户输入负责:表达个人偏好与约束(强度、时段、节奏、目标侧重)。
|
||||
- 冲突处理:用户偏好优先,通用知识仅作参考与兜底。
|
||||
|
||||
#### 3.6.3 字段确认策略(已讨论结论)
|
||||
- 关键字段:必须用户确认后落库。
|
||||
- 普通字段:允许静默落库,并在结果摘要中可追溯展示。
|
||||
|
||||
#### 3.6.4 成功标准(草案)
|
||||
- 草案采纳率(用户直接采纳完整草案的比例)。
|
||||
- 草案修改率(用户修改后采纳的比例)。
|
||||
- 后续优化收敛效率(基于该草案进入主动优化后的平均有效轮次变化)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心体验原则(已讨论 v1.0)
|
||||
### 4.1 体验总纲(草案)
|
||||
- 原则 1:先看全局,再做局部。
|
||||
- 先识别主要矛盾,再执行局部调整,避免“盲调”。
|
||||
- 原则 2:单轮单主问题域。
|
||||
- 每轮只聚焦一个主问题域,降低震荡与来回改动。
|
||||
- 原则 3:每轮必须复盘并判定有效性。
|
||||
- 任何调整都要有“是否变好”的结论,不允许无结论进入下一轮。
|
||||
- 原则 4:达标即收口。
|
||||
- 达到可接受阈值后立即停止,避免过度优化。
|
||||
- 原则 5:偏好优先、科学兜底。
|
||||
- 用户偏好是目标方向,科学原则提供安全边界。
|
||||
- 原则 6:硬约束优先于体验优化。
|
||||
- 先保证不违约束,再追求负载/节奏/切换等体验改进。
|
||||
|
||||
### 4.2 单轮优化行为规范(草案)
|
||||
- 规范 A:本轮开始前必须声明“主问题域 + 目标变化”。
|
||||
- 规范 B:单轮仅允许一个主问题域,允许附带次问题观察但不展开动作。
|
||||
- 规范 C:同一主问题域若尚未出现有效改进,不应频繁切换到其他问题域。
|
||||
- 规范 D:若用户明确指定优化方向,优先采用用户方向作为本轮主问题域。
|
||||
|
||||
### 4.3 单轮复盘输出规范(草案)
|
||||
- 每轮都应给出三段式结果:
|
||||
- 本轮目标:本轮要改善什么。
|
||||
- 本轮改动:改了哪些关键位置。
|
||||
- 本轮结果:哪些指标或问题严重度发生了变化。
|
||||
- 单轮判定结果仅允许两类:
|
||||
- `有效改进`:至少一个核心问题域严重度下降。
|
||||
- `无效改进`:无严重度下降,需换策略或收口。
|
||||
|
||||
### 4.4 收口与停机原则(已定)
|
||||
- 正常收口条件:
|
||||
- 达到可接受方案阈值;
|
||||
- 或主要问题已降至可接受等级。
|
||||
- 防循环停机条件:
|
||||
- 连续多轮无有效改进;
|
||||
- 或达到轮次上限(当前上限 60)。
|
||||
- 强制人工确认规则(已定):
|
||||
- 只要涉及“移动类改动”,默认都需用户确认后执行。
|
||||
- 仅当用户显式开启“始终同意”时,允许自动通过确认。
|
||||
- 即使自动通过,也需在结果中保留可追溯记录。
|
||||
|
||||
### 4.5 本章已确定结论
|
||||
- Q4-1 结论:支持用户强制覆盖单轮主问题域。
|
||||
- 说明:前端已支持用户自由拖动,该能力与产品原则一致。
|
||||
- Q4-2 结论:采用“移动必确认,始终同意可自动通过”的统一规则。
|
||||
- 说明:确认链路以用户控制权优先,兼顾效率模式。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主动优化产品流程(已讨论 v1.0)
|
||||
### 5.0 模式切换策略(补充,已定)
|
||||
- 首次主动排课(粗排 + 主动微调)默认启用全流程模式。
|
||||
- 后续局部调整请求默认启用局部执行模式(优先旧工具链)。
|
||||
- 仅在以下情况升级为全流程模式:
|
||||
- 用户明确授权“重新全局优化”;
|
||||
- 用户诉求明确命中指标域(如切换过多、太满、容错不足等)。
|
||||
|
||||
### 5.1 流程总览(已定)
|
||||
1. 入场判定:确定本次优化模式(默认中位 / 偏好驱动)、目标窗口、可改动范围。
|
||||
2. 首轮体检:强制先体检,再进入改动(避免盲调)。
|
||||
3. 迭代优化:按“单轮主问题域”执行改动与复盘。
|
||||
4. 收口判定:达标即收口;未达标则继续循环。
|
||||
5. 异常处理:冲突、失败、用户改目标时按规则回退或重开。
|
||||
6. 结果交付:输出改动摘要、改进证据、剩余风险与下一步建议。
|
||||
|
||||
### 5.2 轮次定义(已定)
|
||||
- “1 轮优化”定义为一次完整闭环:
|
||||
1. 选定主问题域;
|
||||
2. 生成本轮改动方案;
|
||||
3. 通过确认门禁;
|
||||
4. 执行改动;
|
||||
5. 复盘并判定有效/无效。
|
||||
- 说明:
|
||||
- 仅观察不改动,不计入优化轮。
|
||||
- “连续无效轮次”仅统计“已执行改动但未出现有效改进”的轮。
|
||||
|
||||
### 5.3 详细流程规则(已定)
|
||||
#### 5.3.1 入场判定
|
||||
- 输入:用户目标、偏好、限制、当前日程状态。
|
||||
- 输出:本次优化上下文(模式、范围、约束、初始问题池)。
|
||||
- 规则:若用户目标不明确,默认按中位最佳实践入场。
|
||||
- 规则补充:
|
||||
- 局部执行模式可跳过全流程体检,直接做最小必要校验后执行。
|
||||
- 全流程模式必须先体检再改动。
|
||||
|
||||
#### 5.3.2 首轮体检(强制)
|
||||
- 必须先完成体检再改动。
|
||||
- 体检结果至少包含:问题清单、严重度排序、建议主问题域。
|
||||
- 禁止跳过体检直接执行改动。
|
||||
|
||||
#### 5.3.3 单轮优化执行
|
||||
- 每轮必须先声明:本轮主问题域与目标变化。
|
||||
- 本轮仅允许一个主问题域,避免并发多目标拉扯。
|
||||
- 涉及移动类改动:
|
||||
- 默认需用户确认;
|
||||
- 用户开启“始终同意”后可自动通过;
|
||||
- 自动通过仍需可追溯记录。
|
||||
|
||||
#### 5.3.4 单轮复盘判定
|
||||
- 有效改进标准:至少一个核心问题域严重度下降。
|
||||
- 无效改进标准:执行改动后无严重度下降。
|
||||
- 无效轮次处置:允许换策略继续,但需计入连续无效轮次计数。
|
||||
|
||||
### 5.4 收口规则(已定)
|
||||
- 正常收口阈值:
|
||||
- `critical = 0`;
|
||||
- `warning <= 1`。
|
||||
- 防循环强制收口:
|
||||
- 连续无效轮次 >= 3;
|
||||
- 或达到总轮次上限(当前 60 轮)。
|
||||
- 收口后必须输出:已解决问题、未解决问题、建议后续动作。
|
||||
|
||||
### 5.5 用户中途改目标处理(已定)
|
||||
- 当用户在优化过程中明确变更目标/偏好时:
|
||||
- 立即重开“入场判定”;
|
||||
- 清空当前主问题域上下文;
|
||||
- 基于新目标重新体检并进入下一轮。
|
||||
- 目的:避免沿旧目标继续优化导致结果跑偏。
|
||||
|
||||
### 5.6 本章已确定结论
|
||||
- 首轮体检强制执行。
|
||||
- 可接受阈值采用 `critical=0 且 warning<=1`。
|
||||
- 连续无效 3 轮即强制收口。
|
||||
- 用户中途改目标时,必须重开入场判定。
|
||||
- 首次主动排课默认全流程;后续局部调整默认旧工具链。
|
||||
|
||||
---
|
||||
|
||||
## 6. 科学安排原则(已讨论 v1.0)
|
||||
### 6.1 原则优先级(已定)
|
||||
按“上位约束可否决下位偏好”的顺序执行:
|
||||
1. 硬约束合法性(不可冲突、不可越界、不可违规改动)
|
||||
2. 截止与时间压力(先保证不发生明显延期风险)
|
||||
3. 用户偏好方向(在上位约束允许范围内优先满足)
|
||||
4. 负载均衡(避免极端堆积与突增)
|
||||
5. 认知切换(控制高频切换与过长连续块)
|
||||
6. 容错能力(可用空窗规模,平衡稳定性与利用率)
|
||||
|
||||
### 6.2 冲突裁决规则(已定)
|
||||
| 冲突场景 | 裁决规则 | 用户可覆盖性 |
|
||||
|---|---|---|
|
||||
| 用户偏好 vs 硬约束合法性 | 硬约束优先,拒绝违规方案并给替代建议 | 不可覆盖 |
|
||||
| 用户偏好 vs 截止/时间压力红线 | 截止压力优先,默认前移高风险任务 | 可显式确认后覆盖部分策略 |
|
||||
| 用户偏好 vs 下位优化项(负载/切换/容错) | 用户偏好优先,科学原则兜底 | 可覆盖 |
|
||||
| 无明确用户偏好 | 采用中位最佳实践 | 不适用 |
|
||||
|
||||
### 6.3 原则刻度化口径(中位默认 + 双向偏移)
|
||||
| 原则维度 | 中位默认 | 左偏 | 右偏 |
|
||||
|---|---|---|---|
|
||||
| 负载强度 | 平衡推进 | 低强度(更松) | 冲刺强度(更满) |
|
||||
| 截止推进 | 均衡前移 | 早缓冲(更早完成) | 临近冲刺(更晚推进) |
|
||||
| 认知切换 | 适度切换 | 低切换(同类聚合) | 高切换(灵活穿插) |
|
||||
| 容错能力 | 平衡容错 | 高容错(多留大空窗) | 低容错(任务排得更满) |
|
||||
|
||||
### 6.4 软硬约束分层(已定)
|
||||
- 硬约束:
|
||||
- 合法性约束(冲突、越界、禁止改动范围)
|
||||
- 截止/时间压力红线
|
||||
- 软约束:
|
||||
- 负载均衡
|
||||
- 认知切换
|
||||
- 容错能力
|
||||
- 执行原则:
|
||||
- 先满足硬约束,再在软约束内做偏好优化。
|
||||
|
||||
### 6.5 本章已确定结论
|
||||
- 科学原则优先级已固定为“硬约束与截止优先,偏好次之,其余体验项随后优化”。
|
||||
- 冲突裁决已固定为“分层裁决”:不可覆盖项直接否决,可覆盖项通过显式确认处理。
|
||||
- “容错”作为用户可理解维度,已替代“空窗/缓冲”作为统一外显术语。
|
||||
|
||||
---
|
||||
|
||||
## 7. 用户需求与偏好模型(已讨论 v1.0)
|
||||
### 7.1 边界定义(已定)
|
||||
- 本章只定义“偏好消费与确认规则”,不定义“偏好采集机制”。
|
||||
- 偏好采集由 memory 系统负责:
|
||||
- 持续采集;
|
||||
- 去重注入;
|
||||
- 产品层直接消费。
|
||||
|
||||
### 7.2 偏好消费优先级(已定)
|
||||
1. 用户显式输入(最高优先级)
|
||||
2. memory 注入偏好(次优先)
|
||||
3. WebSearch 通用知识(仅补全,不可覆盖用户偏好)
|
||||
4. 无信息时采用中位默认值
|
||||
|
||||
### 7.3 必要点判定与 ask_user 规则(已定)
|
||||
- 必要点定义:缺失会导致方案不可执行或高风险误判的关键信息。
|
||||
- 必要点缺失时:必须 ask_user,不允许静默推断。
|
||||
- 当前必要点清单:
|
||||
- 时间窗(至少明确 end,start 可按策略补齐);
|
||||
- 强度方向(均匀/冲刺);
|
||||
- 容错偏好(高容错/平衡/低容错);
|
||||
- 禁排时段(若用户表达了禁忌但未结构化)。
|
||||
|
||||
### 7.4 字段分级(已定)
|
||||
#### 7.4.1 关键字段(必须确认)
|
||||
- 时间窗(start/end,截止时间统一归入 end,不单列重复字段)
|
||||
- 强度策略(均匀/冲刺)
|
||||
- 总预算(total_slots)
|
||||
- 容错偏好(高容错/平衡/低容错)
|
||||
- 禁排时段(excluded_slots)
|
||||
- 任务项清单完整性(是否齐全)
|
||||
- 任务项优先级/依赖关系(如用户提供)
|
||||
|
||||
#### 7.4.2 普通字段(可静默落)
|
||||
- 推荐时段偏好权重(上午/下午/晚间)
|
||||
- 同类任务聚合偏好(聚合/平衡/穿插)
|
||||
- 阶段里程碑拆分建议
|
||||
- 标准化知识标签与学习路径备注(命中统一标准时结构化落地;未命中仅文本备注)
|
||||
|
||||
### 7.5 口径修正(已定)
|
||||
- 不在偏好层管理“单次学习块长度”:
|
||||
- 该项属于任务类/任务项结构属性,不作为本章普通偏好字段。
|
||||
- 统一命名“时间窗”:
|
||||
- “截止时间”视为时间窗 end 的口语表达,不单列独立字段。
|
||||
|
||||
### 7.6 本章已确定结论
|
||||
- 偏好由 memory 采集,产品层只做消费与确认。
|
||||
- 必要点缺失必须 ask_user,避免静默误判。
|
||||
- 字段分级与统一命名口径已固定,可直接指导后续工具设计与交互文案。
|
||||
|
||||
---
|
||||
|
||||
## 8. 工具能力产品蓝图(已讨论 v1.0)
|
||||
### 8.1 工具分层(产品视角)
|
||||
- 事实读取层:告诉 AI“现在是什么”
|
||||
- 分析体检层:告诉 AI“问题在哪”
|
||||
- 评估复盘层:告诉 AI“这轮是否变好”
|
||||
- 执行动作层:让 AI 进行可控调整(以旧工具链为主)
|
||||
|
||||
### 8.2 混合工具策略(新增)
|
||||
- 策略 1:旧工具保留为主执行层,不做全线替换。
|
||||
- 策略 2:新分析工具作为导航层,主要用于首次主动排课与指标域重优化。
|
||||
- 策略 3:局部请求默认旧工具直达执行,避免过度主动出击。
|
||||
- 策略 4:仅在用户授权或命中指标域诉求时,升级为分析链路。
|
||||
|
||||
### 8.3 对话内能力(草案)
|
||||
| 能力 | 适用模式 | 用户价值 | AI 产出 | 风险控制 |
|
||||
|---|---|---|---|---|
|
||||
| analyze_health(总览体检) | 首次编排/明确触发全流程时默认首入口(可跳过) | 快速定位主要问题 | metrics/issues/next_actions | 防盲钻、防误判 |
|
||||
| analyze_load | 全流程模式/指标域触发 | 识别过载与波动 | 负载证据 + 动作建议 | 防局部最优 |
|
||||
| analyze_subjects | 全流程模式/指标域触发 | 识别科目节奏与预算压力 | 分布证据 + 动作建议 | 防断档 |
|
||||
| analyze_context | 全流程模式/指标域触发 | 识别切换过高与碎片化 | 切换证据 + 动作建议 | 防认知疲劳 |
|
||||
| analyze_tolerance | 全流程模式/指标域触发 | 识别容错不足风险 | 容错证据 + 动作建议 | 防计划脆弱 |
|
||||
| build_task_class_draft(WebSearch增强) | 共创模式 | 从 0 到 1 生成可用任务类草案 | 完整任务类草案 + 关键字段确认请求 | 防知识幻觉、防越权落库 |
|
||||
|
||||
### 8.4 分析工具输出结构规范(草案)
|
||||
- 分析工具统一返回三段:
|
||||
- `metrics`:测量值;
|
||||
- `issues`:问题及严重度(critical/warning/info);
|
||||
- `next_actions`:下一步建议(只建议,不自动执行)。
|
||||
- 细节级别:
|
||||
- 默认 `summary`;
|
||||
- 用户追问或需要取证时使用 `full`。
|
||||
|
||||
### 8.5 WebSearch 共创能力边界(新增)
|
||||
- 本能力定位:对话内共创,不替代主动优化主线。
|
||||
- 输出形态:完整任务类草案,不是单字段建议。
|
||||
- 决策边界:用户偏好优先于通用知识。
|
||||
- 安全边界:关键字段需确认,普通字段可静默落并可追溯。
|
||||
|
||||
### 8.6 本章已确定结论
|
||||
- `analyze_health` 仅在“首次编排”或“用户明确触发全流程”时作为默认首入口(可跳过)。
|
||||
- 分析工具默认明细级别统一为 `summary`,用户追问或需取证时切换 `full`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键体验与交互要求(已讨论 v1.0)
|
||||
### 9.1 本章定位(已对齐)
|
||||
- 本章只定义“用户看到什么、怎么被解释、何时需要确认”。
|
||||
- 不定义算法细节、不定义工具内部实现。
|
||||
- 目标是让主动优化“有方向、可理解、不过度”。
|
||||
|
||||
### 9.2 双模式对话体验(已对齐)
|
||||
- 首次编排/明确触发全流程时:进入“体检 + 迭代优化”模式,先给全局判断,再给单轮改进。
|
||||
- 后续局部请求时:默认走旧工具的局部执行链,不擅自升级为全流程。
|
||||
- 仅在两类条件下可升级全流程:用户明确授权;用户诉求明确命中指标域(如“切换太多”“太满了”)。
|
||||
|
||||
### 9.3 单轮解释三段式(已定)
|
||||
- 观察段:本轮先说“我看到了什么问题”,并给最小证据(指标或现象)。
|
||||
- 动作段:再说“我准备怎么改、为什么这么改”,同时点明遵循了哪条科学原则与用户偏好。
|
||||
- 结果段:最后说“改完发生了什么变化”,并给下一步建议(继续微调或收口)。
|
||||
- 三段式的意义:让用户始终知道“问题-动作-结果”的闭环,避免 AI 黑箱式挪动。
|
||||
|
||||
### 9.4 解释字段最小集合(已定)
|
||||
- 字段1(必显):本轮主问题域(负载/切换/截止/容错/科目分布等)。
|
||||
- 字段2(必显):本轮改动摘要(改了哪些任务、从哪到哪、影响了哪几天)。
|
||||
- 字段3(必显):改动理由(科学原则 + 用户偏好 + 冲突裁决依据)。
|
||||
- 字段4(建议显):前后对比(至少 1 个核心指标变化)。
|
||||
- 字段5(建议显):副作用提示(例如“容错下降”“切换略增”)。
|
||||
- 字段6(建议显):下一步建议(继续某方向微调,或建议收口)。
|
||||
- 默认规则:最少展示前 3 字段;全流程场景建议展示 1-6 字段。
|
||||
|
||||
### 9.5 用户控制与确认边界(已对齐)
|
||||
- 涉及“移动类改动”默认都要确认;若用户已开启“始终同意”,可自动通过但需可追溯。
|
||||
- 用户可自由手动拖动,系统应尊重手动结果,不反向强改。
|
||||
- 用户可随时改目标;改目标后按既定规则重开入场判定。
|
||||
- AI 可主动给建议,但不能越权执行超出用户授权范围的改动。
|
||||
|
||||
### 9.6 对话内任务类共创体验(已对齐)
|
||||
- 仅聊天触发,不做聊天外按钮触发。
|
||||
- 输出形态为“完整任务类草案”,而非零散参数建议。
|
||||
- 关键字段必须确认;普通字段可静默落并保留可追溯记录。
|
||||
- 用户偏好与 Web 通用知识冲突时,用户偏好优先。
|
||||
|
||||
### 9.7 本章已确定结论
|
||||
- 默认解释风格采用“专业结论 + 通俗补充”双层表达。
|
||||
- 最小必显字段固定为 3 项:主问题域、改动摘要、改动理由。
|
||||
- 局部模式下不强制固定边界提示,是否提示由上下文按需决定。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险、边界与治理(已讨论 v1.0)
|
||||
### 10.1 风险分层(产品视角)
|
||||
- R1 收敛风险:LLM 长时间小步试探但无实质改进,造成轮次浪费。
|
||||
- R2 体验风险:指标看起来改善,但用户主观体感变差(例如更累、更碎)。
|
||||
- R3 越权风险:AI 在未充分授权下做了超出预期范围的改动。
|
||||
- R4 可信风险:解释与真实改动不一致,导致用户不信任系统。
|
||||
- R5 数据风险:关键信息缺失/冲突,导致判断前提不成立却仍继续优化。
|
||||
|
||||
### 10.2 产品边界(已对齐)
|
||||
- 边界1:全流程优化默认仅用于首次编排或用户明确触发,后续局部请求默认局部执行。
|
||||
- 边界2:涉及移动类改动默认确认;用户开启“始终同意”后可自动通过,但需保留追溯。
|
||||
- 边界3:用户手动拖动结果优先,AI 不得反向强改。
|
||||
- 边界4:用户可随时改目标;改目标后立即重开入场判定。
|
||||
- 边界5:用户偏好与通用知识冲突时,用户偏好优先。
|
||||
|
||||
### 10.3 治理机制(过程治理)
|
||||
- 入场治理:先判定是“全流程模式”还是“局部模式”;必要信息缺失必须 ask_user,不允许静默猜测。
|
||||
- 轮中治理:坚持单轮单主问题域;每轮都输出“观察-动作-结果”,并判断是否有效改进。
|
||||
- 收口治理:命中 `critical=0 且 warning<=1` 立即收口;连续无效 3 轮或达到轮次上限强制收口。
|
||||
- 出口治理:收口时必须显式说明“当前残留问题 + 可选后续动作”,避免用户误以为已全局最优。
|
||||
|
||||
### 10.4 强制确认清单(已定)
|
||||
- A类(必须确认):任何会导致任务/课程位置变化的移动类改动(已拍板规则)。
|
||||
- B类(必须确认):会改变用户明确声明偏好的改动(如偏好时段、偏好节奏)。
|
||||
- C类(必须确认):一次影响多个日期的大范围联动调整(避免“无感大改”)。
|
||||
- 说明:A/B/C 三类均为硬规则;若用户开启“始终同意”,可自动通过但须完整追溯。
|
||||
|
||||
### 10.5 “禁止 AI 改动清单”能力(已定)
|
||||
- 能力定义:用户可声明一组“不可被 AI 主动改动”的对象或范围(例如某类固定课程/某些日期)。
|
||||
- 产品意义:降低越权风险,提升高控制型用户的信任感。
|
||||
- 首发口径:支持“对话内声明即生效”的轻量禁改语义;通过现有上下文注入链路生效,本期不新增 agent 侧治理改动。
|
||||
- 后续演进:配置化、持久化禁改清单能力纳入后续阶段评估。
|
||||
|
||||
### 10.6 可追溯与回退要求(已定)
|
||||
- 每轮必须可追溯:至少记录主问题域、改动摘要、改动理由、影响范围、确认来源。
|
||||
- 对“已执行改动”应支持最小粒度回退能力,避免用户对试错型优化产生风险焦虑。
|
||||
- 回退后应触发一次简版复盘,避免回退导致隐性冲突未被感知。
|
||||
- 首发最低要求:至少支持“回退最近一轮已执行改动”;多版本日程管理(多轮历史回退)纳入 P2。
|
||||
|
||||
### 10.7 本章已确定结论
|
||||
- 强制确认范围升级为 A/B/C 三类全部硬规则。
|
||||
- 首发纳入“禁止 AI 改动清单(对话内轻量版)”。
|
||||
- 回退能力首发最低要求为“回退最近一轮”,多版本管理纳入 P2。
|
||||
|
||||
---
|
||||
|
||||
## 11. 目标指标与验收标准(已讨论 v1.0)
|
||||
### 11.1 指标设计原则(已对齐)
|
||||
- 原则1:指标必须服务于“首次编排全流程”主场景,不用局部请求噪声稀释判断。
|
||||
- 原则2:指标必须同时覆盖“结果好不好、过程稳不稳、体验可不可信”三层。
|
||||
- 原则3:指标必须可落地采集,避免依赖大量主观人工打分。
|
||||
|
||||
### 11.2 首发核心指标(已定)
|
||||
| 指标层级 | 指标名 | 指标定义(产品口径) | 首发目标 |
|
||||
|---|---|---|---|
|
||||
| 结果指标 | 首次编排可接受收口率 | 首次编排全流程中,满足 `critical=0 且 warning<=1` 并进入收口的会话占比 | >= 70% |
|
||||
| 过程指标 | 有效优化轮次占比 | 全流程会话内,“有效轮次”占总轮次比例 | >= 50% |
|
||||
| 质量指标 | 无效回摆率 | 近两轮内被反向撤回的改动占全部改动比例(衡量“折返跑”) | <= 15% |
|
||||
|
||||
### 11.3 关键口径定义(已定)
|
||||
- 有效优化轮次:至少满足“一个核心问题域严重度下降”,且不引入新的 `critical` 问题。
|
||||
- 可接受收口:达到既定收口阈值(`critical=0 且 warning<=1`)并完成收口说明。
|
||||
- 无效回摆:同一任务/课程在短窗口内出现“改过去又改回来”的反向变更。
|
||||
|
||||
### 11.4 辅助观测指标(不作为首发硬门槛)
|
||||
- 平均收口轮次:成功收口会话平均用了多少轮(用于评估效率,不单独卡上线)。
|
||||
- 强制确认后撤销率:已确认改动后被用户撤销的比例(用于识别解释质量问题)。
|
||||
- 对话内追问率:用户对“为什么这么改”继续追问的比例(用于评估解释清晰度)。
|
||||
|
||||
### 11.5 验收规则(已定)
|
||||
- 验收窗口:按自然周滚动观测,至少连续 2 个观察窗口达标再判定“阶段通过”。
|
||||
- 达标判定:第 11.2 的 3 个核心指标同时达标。
|
||||
- 未达标处理:按指标归因回到对应章节优化(流程、工具、解释、确认边界),不允许只调阈值“做数字”。
|
||||
|
||||
### 11.6 本章已确定结论
|
||||
- 首发核心指标冻结为:可接受收口率 + 有效优化轮次占比 + 无效回摆率。
|
||||
- “有效优化轮次”口径冻结为:至少一个问题域下降,且不新增 `critical`。
|
||||
- 首发目标值冻结为:`>=70% / >=50% / <=15%`。
|
||||
|
||||
---
|
||||
|
||||
## 12. 分期路线图(已讨论 v1.0)
|
||||
### 12.1 分期原则(执行导向)
|
||||
- 原则1:先闭环再扩面。先把“首次编排可收敛”做扎实,再扩展高级能力。
|
||||
- 原则2:每期都有“明确不做”,避免执行期目标漂移。
|
||||
- 原则3:每期必须有可量化出场标准,未达标不进入下一期主目标。
|
||||
|
||||
### 12.2 分期总览(已定)
|
||||
| 阶段 | 核心目标 | 必做交付范围(产品) | 明确不做(冻结范围) | 出场标准(产品) |
|
||||
|---|---|---|---|---|
|
||||
| Phase 1 | 建立首次编排的主动优化闭环 | 首次编排默认全流程;后续局部默认旧工具;6个分析工具口径落地;A/B/C三类确认规则;最近一轮回退;第11章三核心指标可观测 | 不做多版本日程管理;不做配置化禁改清单;不扩展到聊天外触发 | 连续2个观察窗口达到第11章目标值(70%/50%/15%) |
|
||||
| Phase 1.5 | 建立对话内任务类共创可用版 | 聊天触发的完整任务类草案;关键字段确认+普通字段静默落;用户偏好优先于Web通识 | 不做按钮触发;不做全自动无确认落库;不做课程库平台化治理 | 任务类草案一次可用率达到预设阈值(阈值在阶段启动前冻结) |
|
||||
| Phase 2 | 强化个性化和治理能力 | 配置化禁改清单;多版本日程管理(含多轮回退);解释与确认策略按用户类型分层 | 不做跨终端复杂编排协同;不做完全自治无人值守优化 | 在保持Phase 1核心指标不退化前提下,撤销率与追问率下降 |
|
||||
| Phase 3 | 平台化与长期稳定性 | 能力模块化复用;跨场景复用统一口径;长期策略调优与治理看板 | 不新增未经验证的大跨度能力域 | 核心指标长期稳定且新增能力不破坏既有闭环 |
|
||||
|
||||
### 12.3 Phase 1 最小可用闭环(MVP)定义(已定)
|
||||
- 入口:仅“首次编排”自动进入全流程,或用户明确触发全流程。
|
||||
- 执行:按既定单轮机制运行(观察-动作-结果),并遵守A/B/C确认规则。
|
||||
- 收口:按既定阈值收口(`critical=0 且 warning<=1`;或触发强制收口)。
|
||||
- 保障:支持最近一轮回退、保留可追溯记录、支持对话内轻量禁改。
|
||||
- 验收:以第11章三核心指标作为唯一阶段通过标准。
|
||||
|
||||
### 12.4 跨期依赖关系(已定)
|
||||
- Phase 1 是所有后续阶段前置,未通过则不进入 Phase 2 的主交付。
|
||||
- Phase 1.5 可与 Phase 1 后段并行推进,但不得影响 Phase 1 指标达标。
|
||||
- Phase 2 的多版本管理与配置化禁改,依赖 Phase 1 的追溯数据结构稳定。
|
||||
|
||||
### 12.5 本章已确定结论
|
||||
- Phase 1 出场标准固定为:第11章三核心指标连续 2 个窗口达标。
|
||||
- Phase 1.5 与 Phase 1 时序固定为:允许后半程并行推进,前提是不影响 Phase 1 指标达标。
|
||||
- Phase 2 主目标冻结为:配置化禁改清单 + 多版本日程管理。
|
||||
|
||||
### 12.6 当前执行优先级(新增)
|
||||
- 当前版本优先目标为“先跑通 Phase 1 ~ Phase 1.5”。
|
||||
- Phase 2 / Phase 3 暂缓,待前两阶段稳定后再回到路线图继续推进。
|
||||
|
||||
---
|
||||
|
||||
## 13. 待决策清单(滚动更新)
|
||||
| 编号 | 议题 | 决策选项 | 当前状态 | 负责人 |
|
||||
|---|---|---|---|---|
|
||||
| D-001 | 对话内主动优化目标优先级 | A>B>C / A=C>B / C>A>B | 已确定(A>B>C) | 产品 |
|
||||
| D-002 | WebSearch 任务类设计触发形态 | 聊天触发 / 聊天外按钮触发 | 已确定(聊天触发) | 产品 |
|
||||
| D-003 | WebSearch 与用户偏好冲突策略 | 通用知识优先 / 用户偏好优先 | 已确定(用户偏好优先) | 产品 |
|
||||
| D-004 | 任务类草案落库确认策略 | 全字段确认 / 关键字段确认+普通字段静默落 | 已确定(后者) | 产品 |
|
||||
| D-005 | 任务类草案“小幅修改”阈值 | 20% / 30% / 40% | 已确定(30%) | 产品 |
|
||||
| D-006 | 主动优化“有效改进”最小标准 | 严重度下降 / 分数提升 / 二者同时满足 | 已确定(至少一个问题域严重度下降) | 产品 |
|
||||
| D-007 | 用户是否可强制覆盖单轮主问题域 | 支持 / 不支持 / 有条件支持 | 已确定(支持) | 产品 |
|
||||
| D-008 | 强制人工确认触发条件 | 精简2类 / 标准3类 / 扩展4类+ | 已确定(涉及移动默认确认;始终同意可自动通过) | 产品 |
|
||||
| D-009 | 连续无效轮次强制收口阈值 | 2 / 3 / 4 | 已确定(3) | 产品 |
|
||||
| D-010 | 可接受方案阈值 | critical=0且warning<=0/1/2 | 已确定(critical=0 且 warning<=1) | 产品 |
|
||||
| D-011 | 用户中途改目标处理策略 | 延续当前轮 / 下轮生效 / 立即重开入场判定 | 已确定(立即重开入场判定) | 产品 |
|
||||
| D-012 | 科学原则优先级 | 多种排序方案 | 已确定(硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错) | 产品 |
|
||||
| D-013 | 原则冲突裁决口径 | 用户优先 / 科学优先 / 分层裁决 | 已确定(分层裁决) | 产品 |
|
||||
| D-014 | 偏好模型边界 | 产品层负责采集+消费 / 仅消费不采集 | 已确定(仅消费不采集) | 产品 |
|
||||
| D-015 | 必要点缺失处理 | 静默推断 / ask_user / 混合策略 | 已确定(必要点缺失必须 ask_user) | 产品 |
|
||||
| D-016 | 后续局部请求默认模式 | 全流程优先 / 局部执行优先 | 已确定(局部执行优先) | 产品 |
|
||||
| D-017 | 旧工具与新工具关系 | 全替换 / 并行混合 | 已确定(并行混合,旧工具主执行) | 产品 |
|
||||
| D-018 | `analyze_health` 默认入口触发条件 | 全程默认 / 首次与明确触发默认 | 已确定(首次与明确触发默认) | 产品 |
|
||||
| D-019 | 分析工具默认明细级别 | summary / full | 已确定(summary) | 产品 |
|
||||
| D-020 | 第九章默认解释风格 | 纯专业 / 纯通俗 / 专业结论+通俗补充 | 已确定(专业结论+通俗补充) | 产品 |
|
||||
| D-021 | 第九章最小必显字段 | 2项 / 3项 / 4项+ | 已确定(3项) | 产品 |
|
||||
| D-022 | 局部模式是否固定边界提示 | 固定提示 / 按需提示 | 已确定(按需提示) | 产品 |
|
||||
| D-023 | 第十章强制确认范围 | 仅A类(移动类)硬规则 / A+B类硬规则 / A+B+C类硬规则 | 已确定(A+B+C类硬规则) | 产品 |
|
||||
| D-024 | 首发是否支持禁改清单 | 不支持 / 支持对话内轻量版 / 直接支持配置化 | 已确定(支持对话内轻量版) | 产品 |
|
||||
| D-025 | 回退能力最低要求 | 不要求 / 回退最近一轮 / 多轮可选回退 | 已确定(回退最近一轮;多版本管理纳入P2) | 产品 |
|
||||
| D-026 | 第十一章首发核心指标组合 | 多种组合方案 | 已确定(收口率+有效轮次占比+无效回摆率) | 产品 |
|
||||
| D-027 | “有效优化轮次”口径 | 仅严重度下降 / 严重度下降且不新增critical / 复合打分 | 已确定(严重度下降且不新增critical) | 产品 |
|
||||
| D-028 | 第十一章首发目标值 | 激进/中性/保守三档 | 已确定(70% / 50% / 15%) | 产品 |
|
||||
| D-029 | Phase 1 出场标准 | 三核心指标连续1/2/3窗口达标 | 已确定(连续2窗口) | 产品 |
|
||||
| D-030 | Phase 1.5 与 Phase 1 时序 | 串行 / 后半程并行 / 完全并行 | 已确定(后半程并行) | 产品 |
|
||||
| D-031 | Phase 2 主目标冻结范围 | 多方案 | 已确定(配置化禁改+多版本管理) | 产品 |
|
||||
| D-032 | 当前版本执行优先级 | 全路线并推 / 先P1~P1.5后续暂缓 | 已确定(先P1~P1.5后续暂缓) | 产品 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 章节讨论记录(按“讨论一章、定一章”推进)
|
||||
### 记录模板
|
||||
- 讨论章节:
|
||||
- 结论:
|
||||
- 未决问题:
|
||||
- 下一步动作:
|
||||
- 更新时间:
|
||||
|
||||
### 已讨论记录
|
||||
- 讨论章节:第 1 章 业务背景与问题定义
|
||||
- 结论:采用“双模式策略”(默认中位最佳实践 + 偏好优先偏移);读工具按“广覆盖+区间指标”设计;自动优化轮次上限暂定 60。
|
||||
- 未决问题:时长目标与是否默认开启深度思考的策略未冻结。
|
||||
- 下一步动作:进入第 2 章,冻结“满意方案”与目标优先级定义。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 2 章 产品目标与非目标
|
||||
- 结论:目标优先级确定为 A(自主迭代收敛)> B(可解释与改进证据)> C(对话内任务类共创草案);首发先保 A+B 闭环,C 走可用版。
|
||||
- 未决问题:C 可用版的覆盖范围与补全字段边界待在第 8 章细化。
|
||||
- 下一步动作:进入第 3 章,明确首发用户分层与高频场景清单。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 3 章补充议题 WebSearch 任务类共创
|
||||
- 结论:定位为“对话内触发、产出完整任务类草案”的增强能力;知识来源为 WebSearch 通用信息 + 用户偏好,冲突时用户优先;字段按关键/普通分级确认。
|
||||
- 未决问题:关键字段名单与普通字段名单待在后续章节细化。
|
||||
- 下一步动作:在第 8 章与第 12 章细化能力边界与分期。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 3 章阈值口径补充(S1/S2)
|
||||
- 结论:S1 采用“关键字段修改率<=30%”作为小幅修改阈值;S2 采用“至少一个核心问题域严重度下降”作为有效改进最小标准。
|
||||
- 未决问题:关键字段清单与核心问题域枚举待后续章节细化。
|
||||
- 下一步动作:推进第 4 章核心体验原则,固化“单轮单问题域 + 复盘判定”。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 3 章 用户与场景(v1.0)
|
||||
- 结论:用户分层、首发场景、场景优先级、暂不支持边界、S1/S2/S3 判定口径均已形成可冻结版本。
|
||||
- 未决问题:无(本章内容进入后续引用阶段)。
|
||||
- 下一步动作:推进第 4 章,明确“单轮策略、复盘规范、停机确认”的执行口径。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 4 章 核心体验原则(v0.1 草案)
|
||||
- 结论:已形成“总纲-单轮规范-复盘规范-停机原则”的完整草案结构。
|
||||
- 未决问题:D-007(用户强制覆盖策略)与 D-008(强制确认触发条件)待拍板。
|
||||
- 下一步动作:根据 D-007/D-008 决策冻结第 4 章。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 4 章 核心体验原则(v1.0)
|
||||
- 结论:支持用户强制覆盖单轮主问题域;涉及移动类改动默认确认,用户开启“始终同意”后可自动通过并保留追溯记录。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 5 章,细化主动优化流程与收口判定口径。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 5 章 主动优化产品流程(v1.0)
|
||||
- 结论:明确了“轮次定义、首轮强制体检、单轮执行闭环、连续无效3轮收口、critical=0且warning<=1收口、用户改目标即重开入场判定”。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 6 章,细化科学安排原则与冲突优先级口径。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 6 章 科学安排原则(v1.0)
|
||||
- 结论:优先级确定为“硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错”;冲突裁决采用分层规则;“容错”作为统一用户解释术语。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 7 章,细化偏好模型与关键字段清单。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 7 章 用户需求与偏好模型(v1.0)
|
||||
- 结论:偏好采集由 memory 负责,产品层仅消费;必要点缺失必须 ask_user;关键/普通字段分级与“时间窗”统一口径已确定。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 8 章,细化工具能力蓝图与工具边界。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 8 章补充议题(首次全流程 vs 后续局部执行)
|
||||
- 结论:首次主动排课默认全流程;后续局部请求默认旧工具链;仅在授权或命中指标域诉求时升级分析链路。
|
||||
- 未决问题:`analyze_health` 是否固定为默认首入口(可跳过)仍待拍板。
|
||||
- 下一步动作:继续冻结第 8 章细项后推进第 9 章。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 8 章 工具能力产品蓝图(v1.0)
|
||||
- 结论:`analyze_health` 仅在首次编排或明确触发全流程时默认首入口;分析工具默认 `summary`,按需切换 `full`。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 9 章,细化对话内体验文案与解释字段规范。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 9 章 关键体验与交互要求(v0.1 草案)
|
||||
- 结论:已形成“双模式体验 + 单轮三段式解释 + 最小解释字段 + 用户控制边界 + 共创体验”的完整草案。
|
||||
- 未决问题:D-020(默认解释风格)、D-021(最小必显字段数量)、D-022(局部模式固定边界提示)待拍板。
|
||||
- 下一步动作:完成 D-020~D-022 拍板后冻结第 9 章,进入第 10 章风险与治理。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 9 章 关键体验与交互要求(v1.0)
|
||||
- 结论:解释风格定为“专业结论+通俗补充”;最小必显字段固定 3 项;局部模式边界提示改为按需提示;第 9 章冻结。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 10 章,讨论风险、边界与治理策略。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 10 章 风险、边界与治理(v0.1 草案)
|
||||
- 结论:已形成“风险分层 + 过程治理 + 强制确认分级 + 禁改清单 + 回退追溯”的完整草案结构。
|
||||
- 未决问题:D-023(强制确认范围)、D-024(禁改清单首发形态)、D-025(回退能力最低要求)待拍板。
|
||||
- 下一步动作:完成 D-023~D-025 拍板后冻结第 10 章,进入第 11 章指标与验收。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 10 章 风险、边界与治理(v1.0)
|
||||
- 结论:强制确认范围定为 A/B/C 全硬规则;首发支持对话内轻量禁改清单;回退最低要求定为“最近一轮”,多版本管理纳入 P2;第 10 章冻结。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 11 章,讨论目标指标与验收标准。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 11 章 目标指标与验收标准(v0.1 草案)
|
||||
- 结论:已形成“首发三核心指标 + 关键口径定义 + 验收窗口规则”的完整草案结构。
|
||||
- 未决问题:D-026(核心指标组合)、D-027(有效轮次口径)、D-028(首发目标值)待拍板。
|
||||
- 下一步动作:完成 D-026~D-028 拍板后冻结第 11 章,进入第 12 章分期路线图。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 11 章 目标指标与验收标准(v1.0)
|
||||
- 结论:首发核心指标冻结为“收口率+有效轮次占比+无效回摆率”;有效轮次口径冻结为“问题域下降且不新增critical”;目标值冻结为“70% / 50% / 15%”;第 11 章冻结。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入第 12 章,讨论分期路线图与每期冻结范围。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 12 章 分期路线图(v0.1 草案)
|
||||
- 结论:已形成“分期总览 + 每期明确不做 + 出场标准 + 跨期依赖”的执行导向草案。
|
||||
- 未决问题:D-029(Phase 1出场标准窗口数)、D-030(Phase 1.5与Phase 1时序)、D-031(Phase 2主目标冻结范围)待拍板。
|
||||
- 下一步动作:完成 D-029~D-031 拍板后冻结第 12 章。
|
||||
- 更新时间:2026-04-24
|
||||
- 讨论章节:第 12 章 分期路线图(v1.0)
|
||||
- 结论:Phase 1 出场标准定为连续2窗口达标;Phase 1.5 采用后半程并行;Phase 2 主目标冻结为“配置化禁改+多版本管理”;当前执行优先级定为先跑通 P1~P1.5、后续阶段暂缓;第 12 章冻结。
|
||||
- 未决问题:无(本章已冻结)。
|
||||
- 下一步动作:进入收尾阶段,统一检查决策表与章节状态一致性。
|
||||
- 更新时间:2026-04-24
|
||||
|
||||
---
|
||||
|
||||
## 15. 术语表(持续补充)
|
||||
| 术语 | 业务定义 |
|
||||
|---|---|
|
||||
| 主动优化 | AI 连续观测-调整-复盘-收口的优化过程 |
|
||||
| 收口 | 达到阈值后停止迭代并输出最终方案 |
|
||||
| 主问题域 | 单轮优化聚焦的首要问题类型 |
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user